diff --git a/.env.example b/.env.example index ed6cfa2f..9ed58726 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,8 @@ AUTH_REFRESH_SESSION_TTL_DAYS="30" AUTH_REFRESH_COOKIE_PATH="/api/auth" AUTH_REFRESH_COOKIE_SAME_SITE="Lax" AUTH_REFRESH_COOKIE_SECURE="false" +# Rust 鉴权快照路径;包含 password_hash 与 refresh token hash,只能放服务端私有目录。 +GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json" # 手机号验证码登录配置(阿里云 PNVS)。 # 正式环境请改成你自己的 AccessKey 和短信签名/模板。 diff --git a/.gitignore b/.gitignore index a9ea5a0e..6e5a2cbc 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ temp*build*/ !/server-node/data/.gitkeep /server-rs/target/ /server-rs/.spacetimedb/ +/server-rs/.data/ /public/generated-animations /public/generated-character-drafts /public/generated-characters diff --git a/docs/technical/AUTH_REFRESH_SESSION_PERSISTENCE_HOTFIX_2026-04-24.md b/docs/technical/AUTH_REFRESH_SESSION_PERSISTENCE_HOTFIX_2026-04-24.md new file mode 100644 index 00000000..039e72be --- /dev/null +++ b/docs/technical/AUTH_REFRESH_SESSION_PERSISTENCE_HOTFIX_2026-04-24.md @@ -0,0 +1,66 @@ +# Auth refresh session 持久化热修方案 + +日期:`2026-04-24` + +## 1. 背景 + +当前 Rust 鉴权链路已经具备 refresh cookie 自动续签能力:access token 过期后,前端会调用 `POST /api/auth/refresh`,后端轮换 refresh token 并返回新的 access token。 + +但 `module-auth` 当前仍使用进程内 `InMemoryAuthStore` 保存账号与 refresh session。只要 `server-rs` 在 access token 生命周期内发生重启,浏览器侧 HttpOnly cookie 仍然存在,服务端却找不到对应账号与 session,最终表现为约 `JWT_EXPIRES_IN` 后需要重新登录。 + +## 2. 本次目标 + +本次先落一个低风险持久化闭环,解决“后端重启导致 2 小时后必须重新登录”的线上体验问题: + +1. 为当前 `InMemoryAuthStore` 增加 UTF-8 JSON 快照文件。 +2. 在账号、手机号索引、微信身份、refresh session 发生变更后自动保存快照。 +3. `api-server` 启动时从配置路径恢复快照。 +4. 保持现有 `/api/auth/refresh`、`logout`、`sessions` 语义不变。 + +## 3. 非目标 + +本次不把完整认证域一次性迁入 SpacetimeDB 表,原因是 refresh session 独立持久化不足以解决问题:refresh 成功后还需要按 `user_id` 读取账号快照重新签发 access token,因此账号主数据也必须同源恢复。 + +SpacetimeDB 正式表接管仍按以下既有文档继续推进: + +1. `docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md` +2. `docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md` +3. `docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md` + +## 4. 配置 + +新增环境变量: + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `GENARRATIVE_AUTH_STORE_PATH` | `server-rs/.data/auth-store.json` | 当前 Rust 鉴权快照文件路径。相对路径按进程工作目录解析。 | + +## 5. 数据边界 + +快照文件保存当前 Rust 鉴权服务已经在内存中维护的最小真相: + +1. `next_user_id` +2. `users_by_username` +3. `phone_to_user_id` +4. `sessions_by_id` +5. `session_id_by_refresh_token_hash` +6. `wechat_identity_by_provider_uid` +7. `user_id_by_provider_union_id` + +短信验证码和微信 OAuth state 不持久化,原因是它们是短生命周期挑战数据,重启后失效是可接受行为。 + +## 6. 安全约束 + +1. refresh token 原文仍只存在浏览器 HttpOnly cookie,快照只保存 `sha256(refresh_token)`。 +2. 快照包含 `password_hash`、手机号映射和 refresh token hash,部署时必须放在服务端私有目录,不允许暴露到静态资源目录。 +3. 快照写入必须使用 UTF-8,并通过临时文件原子替换降低写坏风险。 + +## 7. 后续 SpacetimeDB 接管点 + +当 `user_account`、`auth_identity`、`refresh_session` 表及 reducer 全部落地后,替换策略如下: + +1. 保留 `module-auth` 用例语义。 +2. 把当前快照仓储替换为 SpacetimeDB 仓储适配器。 +3. 启动时可提供一次性导入脚本,把 JSON 快照导入 SpacetimeDB 表。 +4. 导入完成后禁用 `GENARRATIVE_AUTH_STORE_PATH` 快照写入。 + diff --git a/docs/technical/AUTH_SPACETIMEDB_SNAPSHOT_MIGRATION_STAGE1_2026-04-24.md b/docs/technical/AUTH_SPACETIMEDB_SNAPSHOT_MIGRATION_STAGE1_2026-04-24.md new file mode 100644 index 00000000..3969db61 --- /dev/null +++ b/docs/technical/AUTH_SPACETIMEDB_SNAPSHOT_MIGRATION_STAGE1_2026-04-24.md @@ -0,0 +1,83 @@ +# Auth SpacetimeDB 快照迁移 Stage 1 + +日期:`2026-04-24` + +## 1. 背景 + +`AUTH_REFRESH_SESSION_PERSISTENCE_HOTFIX_2026-04-24.md` 已把 Rust 鉴权内存态落到本地 JSON 快照,解决 `server-rs` 重启后 refresh session 丢失导致 `JWT_EXPIRES_IN` 到期后必须重新登录的问题。 + +本阶段继续把该快照迁入 SpacetimeDB,作为正式 `user_account/auth_identity/refresh_session` 表完全拆分前的过渡真相源。 + +## 2. 阶段目标 + +1. 在 `spacetime-module` 新增私有 `auth_store_snapshot` 表。 +2. 表内只保存一条当前 Axum 鉴权快照 JSON,主键固定为 `default`。 +3. 新增 `get_auth_store_snapshot` 与 `upsert_auth_store_snapshot` procedure,供 `api-server` 同步读写。 +4. `module-auth` 继续拥有鉴权业务语义,SpacetimeDB 只承接当前阶段的持久化真相。 + +## 3. 为什么先做快照表 + +只迁 `refresh_session` 表无法恢复登录态,因为 refresh 成功后仍必须按 `user_id` 找到账号快照重新签发 access token。因此正式拆表必须同时完成账号、身份、会话三组 reducer。 + +本阶段先把已经验证过的 JSON 快照从本地文件迁到 SpacetimeDB,收益是: + +1. 后端进程重启后可恢复登录态。 +2. 多实例部署时可共享同一份鉴权快照。 +3. 后续正式拆表时有统一导入来源。 + +## 4. 表设计 + +表名:`auth_store_snapshot` + +访问级别:private table + +字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `snapshot_id` | `String` | 主键,固定为 `default`。 | +| `snapshot_json` | `String` | `module-auth` 当前持久化快照 JSON。 | +| `updated_at` | `Timestamp` | 最近写入时间。 | + +## 5. Procedure 设计 + +### 5.1 `get_auth_store_snapshot` + +输入:无。 + +输出: + +```json +{ + "ok": true, + "snapshotJson": "...", + "updatedAtMicros": 123456789, + "errorMessage": null +} +``` + +当记录不存在时,`snapshotJson = null`。 + +### 5.2 `upsert_auth_store_snapshot` + +输入: + +```json +{ + "snapshotJson": "...", + "updatedAtMicros": 123456789 +} +``` + +输出同 `get_auth_store_snapshot`。 + +## 6. 后续拆表迁移点 + +Stage 2 再把 `auth_store_snapshot.snapshot_json` 导入并拆分为: + +1. `user_account` +2. `auth_identity` +3. `refresh_session` + +拆分完成后,`auth_store_snapshot` 只保留为迁移备份,不再作为运行时写入目标。 + diff --git a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md index d80ec00a..f5cfdcfb 100644 --- a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md +++ b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md @@ -1,4 +1,6 @@ -# 密码登录与自动建号落地设计 +# 密码登录历史落地设计 + +> 2026-04-24 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经设置密码的账号。密码修改与重置以 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准;本文中“密码自动建号”仅保留为历史基线说明,不再作为当前落地依据。 日期:`2026-04-21` diff --git a/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md new file mode 100644 index 00000000..c4da3d47 --- /dev/null +++ b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md @@ -0,0 +1,66 @@ +# 密码登录、修改与重置落地设计 + +日期:`2026-04-24` + +## 1. 目标边界 + +本次迭代开放账号密码登录、登录后修改密码、手机号验证码重置密码,但不开放密码注册。 + +1. 新用户只能通过手机号验证码完成注册与首次登录。 +2. 已有用户可以在登录后设置或修改密码。 +3. 忘记密码时,只能通过已绑定手机号验证码重置密码。 +4. 密码登录只校验已存在且已设置密码的账号,不自动创建新账号。 + +## 2. 接口设计 + +### 2.1 密码登录 + +沿用现有 `POST /api/auth/entry`: + +1. 请求字段:`username`、`password`。 +2. 用户不存在时返回 `401`,不创建账号。 +3. 用户存在但未设置密码时返回 `401`。 +4. 校验成功后签发 access token,并写入 refresh cookie。 + +### 2.2 修改密码 + +新增 `POST /api/auth/password/change`: + +1. 需要 Bearer 登录态。 +2. 请求字段:`currentPassword`、`newPassword`。 +3. 若账号未设置过密码,允许 `currentPassword` 为空并设置首个密码。 +4. 若账号已有密码,必须校验 `currentPassword` 后才能写入 `newPassword`。 +5. 修改成功后递增用户 `token_version`,使旧 access token 失效;前端沿用当前 refresh 会话刷新登录态。 + +### 2.3 重置密码 + +新增 `POST /api/auth/password/reset`: + +1. 不需要 Bearer 登录态。 +2. 请求字段:`phone`、`code`、`newPassword`。 +3. 使用 `reset_password` 短信场景校验验证码。 +4. 手机号不存在时返回 `404`,避免用密码重置隐式注册账号。 +5. 重置成功后签发新的 access token,并写入 refresh cookie,便于用户直接进入登录态。 + +### 2.4 发送重置验证码 + +复用 `POST /api/auth/phone/send-code`,`scene` 增加 `reset_password`。 + +## 3. 前端交互 + +登录弹窗拆成两个页签: + +1. `登录`:提供密码登录、手机号验证码登录、忘记密码入口。 +2. `注册`:只提供手机号验证码注册/登录,不提供账号密码注册。 +3. `忘记密码`:从登录页进入独立重置面板,不在当前表单下方展开。 +4. 账号设置面板提供密码修改入口;未设置密码的账号显示为设置密码。 + +## 4. 数据约束 + +进程内认证快照中的 `password_hash` 改为可空语义: + +1. 手机号新建账号默认没有用户可用密码。 +2. 微信待绑定账号默认没有用户可用密码。 +3. 只有用户显式修改或重置密码后,才允许密码登录。 + +后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力。 diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md index 92d0c4c6..0bdf8ddf 100644 --- a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md @@ -190,6 +190,8 @@ session snapshot 中的 `resultPreview` 固定输出: 7. 最终兜底 `还在收集你的世界锚点。` 8. `subtitle` 先取 `draft_profile_json.subtitle` 9. 否则用 `stageLabel` +10. 同一 `source_agent_session_id` 同时存在未发布 Agent 会话草稿与 `custom_world_profile` 草稿时,works 只输出一条草稿;优先保留可继续聊天的 `agent_session`,避免作品库把“聊天中的草稿”和“待发布草稿 profile”展示成两份作品。 +11. 只有找不到同源未发布 Agent 会话,或 profile 已经发布时,`custom_world_profile` 才作为独立作品输出。 ### 4.4 已发布 works 最小取值规则 diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index c39f053c..00d2aeff 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -32,6 +32,26 @@ export type AuthEntryResponse = { user: AuthUser; }; +export type AuthPasswordChangeRequest = { + currentPassword?: string; + newPassword: string; +}; + +export type AuthPasswordChangeResponse = { + user: AuthUser; +}; + +export type AuthPasswordResetRequest = { + phone: string; + code: string; + newPassword: string; +}; + +export type AuthPasswordResetResponse = { + token: string; + user: AuthUser; +}; + export type AuthPhoneSendCodeRequest = { phone: string; scene?: 'login' | 'bind_phone' | 'change_phone'; diff --git a/scripts/spacetime-logs-local.sh b/scripts/spacetime-logs-local.sh new file mode 100644 index 00000000..4a4b9046 --- /dev/null +++ b/scripts/spacetime-logs-local.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +用法: + npm run dev:rust:logs + npm run dev:rust:logs -- --follow + ./scripts/spacetime-logs-local.sh --lines 500 --output logs/spacetime/local.log + +说明: + 1. 从本地 SpacetimeDB 通过 spacetime logs 提取模块日志到本地文件。 + 2. 默认读取 spacetime.local.json 的 database 字段,默认 server 为 http://127.0.0.1:3101。 + 3. 默认输出到 logs/spacetime/-.log;--follow 会持续追加并同步写到终端。 +EOF +} + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "[stdb:logs] 缺少命令: ${command_name}" >&2 + exit 1 + fi +} + +read_local_spacetime_database() { + local config_path="${REPO_ROOT}/spacetime.local.json" + + if [[ ! -f "${config_path}" ]]; then + return + fi + + node -e ' +const fs = require("fs"); +const path = process.argv[1]; +try { + const value = JSON.parse(fs.readFileSync(path, "utf8")).database; + if (typeof value === "string" && value.trim()) { + process.stdout.write(value.trim()); + } +} catch (error) { + process.stderr.write(`[stdb:logs] ignore invalid spacetime.local.json: ${error.message}\n`); +} +' "${config_path}" +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +DATABASE="" +SPACETIME_SERVER="http://127.0.0.1:3101" +LINES="200" +OUTPUT="" +FOLLOW=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --database) + DATABASE="${2:?缺少 --database 的值}" + shift 2 + ;; + --server) + SPACETIME_SERVER="${2:?缺少 --server 的值}" + shift 2 + ;; + --lines|-n) + LINES="${2:?缺少 --lines 的值}" + shift 2 + ;; + --output|-o) + OUTPUT="${2:?缺少 --output 的值}" + shift 2 + ;; + --follow|-f) + FOLLOW=1 + shift + ;; + *) + echo "[stdb:logs] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_command node +require_command spacetime + +if [[ -z "${DATABASE//[[:space:]]/}" ]]; then + DATABASE="$(read_local_spacetime_database)" +fi + +if [[ -z "${DATABASE//[[:space:]]/}" ]]; then + DATABASE="genarrative-dev" +fi + +if [[ -z "${OUTPUT//[[:space:]]/}" ]]; then + LOG_DIR="${REPO_ROOT}/logs/spacetime" + mkdir -p "${LOG_DIR}" + TIMESTAMP="$(date +%Y%m%d-%H%M%S)" + OUTPUT="${LOG_DIR}/${DATABASE}-${TIMESTAMP}.log" +else + mkdir -p "$(dirname -- "${OUTPUT}")" +fi + +ARGS=(logs "${DATABASE}" --server "${SPACETIME_SERVER}" -n "${LINES}") + +if [[ "${FOLLOW}" -eq 1 ]]; then + ARGS+=(-f) +fi + +echo "[stdb:logs] database: ${DATABASE}" +echo "[stdb:logs] server: ${SPACETIME_SERVER}" +echo "[stdb:logs] output: ${OUTPUT}" + +spacetime "${ARGS[@]}" | tee -a "${OUTPUT}" diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 85d24a25..f3f2aa7b 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -1518,6 +1518,8 @@ name = "module-auth" version = "0.1.0" dependencies = [ "platform-auth", + "serde", + "serde_json", "shared-kernel", "time", "tokio", diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index 70887e8c..585e274c 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -29,10 +29,12 @@ 7. 基础 `TraceLayer` 挂载 8. 接入 `shared-logging` 完成 `tracing subscriber` 初始化 9. 接入 `POST /api/auth/entry` 首版密码登录链路 -10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口 -11. 接入 `GET /api/auth/me` 当前用户查询链路 -12. 接入 `POST /api/auth/refresh` refresh token 轮换链路 -13. 接入 `POST /api/auth/logout` 当前设备退出链路 +10. 接入 `POST /api/auth/password/change` 登录后修改密码链路 +11. 接入 `POST /api/auth/password/reset` 手机验证码重置密码链路 +12. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口 +13. 接入 `GET /api/auth/me` 当前用户查询链路 +14. 接入 `POST /api/auth/refresh` refresh token 轮换链路 +15. 接入 `POST /api/auth/logout` 当前设备退出链路 14. 接入 `POST /api/assets/objects/confirm` 上传完成确认链路 15. 接入 `GET /api/auth/login-options` 登录方式探测链路 16. 接入 `POST /api/auth/phone/send-code` 手机验证码发送链路 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 4a4e53b3..c40cfe80 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -73,6 +73,7 @@ use crate::{ logout::logout, logout_all::logout_all, password_entry::password_entry, + password_management::{change_password, reset_password}, phone_auth::{phone_login, send_phone_code}, puzzle::{ advance_puzzle_next_level, create_puzzle_agent_session, drag_puzzle_piece_or_group, @@ -887,6 +888,14 @@ pub fn build_router(state: AppState) -> Router { )), ) .route("/api/auth/entry", post(password_entry)) + .route( + "/api/auth/password/change", + post(change_password).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route("/api/auth/password/reset", post(reset_password)) // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 .layer(middleware::from_fn(normalize_error_response)) // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 diff --git a/server-rs/crates/api-server/src/auth_public_user.rs b/server-rs/crates/api-server/src/auth_public_user.rs index efa27da3..f950ef03 100644 --- a/server-rs/crates/api-server/src/auth_public_user.rs +++ b/server-rs/crates/api-server/src/auth_public_user.rs @@ -66,7 +66,8 @@ fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppEr | module_auth::PasswordEntryError::PasswordHash(_) | module_auth::PasswordEntryError::InvalidUsername | module_auth::PasswordEntryError::InvalidPasswordLength - | module_auth::PasswordEntryError::InvalidCredentials => { + | module_auth::PasswordEntryError::InvalidCredentials + | module_auth::PasswordEntryError::UserNotFound => { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) } } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 42eac88b..f80ff5f5 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -7,6 +7,7 @@ use platform_llm::{ const DEFAULT_LLM_MODEL: &str = "doubao-1-5-pro-32k-character-250715"; const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge"; +const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json"; const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json"; // 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。 @@ -27,6 +28,7 @@ pub struct AppConfig { pub refresh_cookie_secure: bool, pub refresh_cookie_same_site: String, pub refresh_session_ttl_days: u32, + pub auth_store_path: PathBuf, pub sms_auth_enabled: bool, pub sms_auth_provider: String, pub sms_endpoint: String, @@ -109,6 +111,7 @@ impl Default for AppConfig { refresh_cookie_secure: false, refresh_cookie_same_site: "Lax".to_string(), refresh_session_ttl_days: 30, + auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH), sms_auth_enabled: false, sms_auth_provider: "mock".to_string(), sms_endpoint: "dypnsapi.aliyuncs.com".to_string(), @@ -255,6 +258,10 @@ impl AppConfig { config.refresh_session_ttl_days = refresh_session_ttl_days; } + if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) { + config.auth_store_path = PathBuf::from(auth_store_path); + } + if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) { config.sms_auth_enabled = sms_auth_enabled; } diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 8fcf6b1d..ae50ce3e 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -988,10 +988,10 @@ pub async fn execute_custom_world_agent_action( let result = state .spacetime_client() .execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput { - session_id, - owner_user_id, + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), operation_id: build_prefixed_uuid_id("operation-"), - action, + action: action.clone(), payload_json: Some(payload_json), submitted_at_micros, }) @@ -1179,7 +1179,7 @@ fn log_custom_world_publish_gate_diagnostics( blocker_codes = %blocker_codes, has_draft_profile = session.draft_profile.as_object().map(|value| !value.is_empty()).unwrap_or(false), has_result_preview = session.result_preview.is_some(), - preview_source = session.result_preview.as_ref().and_then(|value| value.get("source")).and_then(Value::as_str).unwrap_or(""), + preview_source = session.result_preview.as_ref().and_then(|value| value.get("source")).and_then(serde_json::Value::as_str).unwrap_or(""), has_world_hook = has_custom_world_publish_text(profile, &["worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise.hook", "settingText"]), has_player_premise = has_custom_world_publish_text(profile, &["playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation"]), has_core_conflicts = has_custom_world_non_empty_text_array(profile, "coreConflicts"), diff --git a/server-rs/crates/api-server/src/logout.rs b/server-rs/crates/api-server/src/logout.rs index 126b6d74..98866782 100644 --- a/server-rs/crates/api-server/src/logout.rs +++ b/server-rs/crates/api-server/src/logout.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Extension, State}, - http::HeaderMap, + http::{HeaderMap, StatusCode}, response::IntoResponse, }; use module_auth::LogoutCurrentSessionInput; @@ -44,6 +44,13 @@ pub async fn logout( OffsetDateTime::now_utc(), ) .map_err(map_logout_error)?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/logout_all.rs b/server-rs/crates/api-server/src/logout_all.rs index 93cd4927..e3f1711e 100644 --- a/server-rs/crates/api-server/src/logout_all.rs +++ b/server-rs/crates/api-server/src/logout_all.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Extension, State}, - http::HeaderMap, + http::{HeaderMap, StatusCode}, response::IntoResponse, }; use module_auth::LogoutAllSessionsInput; @@ -32,6 +32,13 @@ pub async fn logout_all( OffsetDateTime::now_utc(), ) .map_err(map_logout_error)?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index e91478bd..331748a9 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -29,6 +29,7 @@ mod login_options; mod logout; mod logout_all; mod password_entry; +mod password_management; mod phone_auth; mod puzzle; mod puzzle_agent_turn; @@ -67,7 +68,8 @@ async fn main() -> Result<(), std::io::Error> { let bind_address = config.bind_socket_addr(); let listener = TcpListener::bind(bind_address).await?; - let state = AppState::new(config) + let state = AppState::try_restore_auth_store_from_spacetime(config) + .await .map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?; let router = build_router(state); diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 6ba2797f..6702147d 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -36,6 +36,13 @@ pub async fn password_entry( .map_err(map_password_entry_error)?; let session_client = resolve_session_client_context(&headers); let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; let mut headers = HeaderMap::new(); attach_set_cookie_header( @@ -75,6 +82,9 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError { PasswordEntryError::InvalidCredentials => { AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误") } + PasswordEntryError::UserNotFound => { + AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误") + } PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) } diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs new file mode 100644 index 00000000..da56f4c6 --- /dev/null +++ b/server-rs/crates/api-server/src/password_management.rs @@ -0,0 +1,117 @@ +use axum::{ + Json, + extract::{Extension, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, +}; +use module_auth::{ChangePasswordInput, PasswordEntryError, ResetPasswordInput}; +use shared_contracts::auth::{ + PasswordChangeRequest, PasswordChangeResponse, PasswordResetRequest, PasswordResetResponse, +}; +use time::OffsetDateTime; + +use crate::{ + api_response::json_success_body, + auth::AuthenticatedAccessToken, + auth_payload::map_auth_user_payload, + auth_session::{ + attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session, + }, + http_error::AppError, + phone_auth::map_phone_auth_error, + request_context::RequestContext, + session_client::resolve_session_client_context, + state::AppState, +}; + +pub async fn change_password( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let result = state + .password_entry_service() + .change_password(ChangePasswordInput { + user_id: authenticated.claims().user_id().to_string(), + current_password: payload.current_password, + new_password: payload.new_password, + }) + .await + .map_err(map_password_management_error)?; + + Ok(json_success_body( + Some(&request_context), + PasswordChangeResponse { + user: map_auth_user_payload(result.user), + }, + )) +} + +pub async fn reset_password( + State(state): State, + Extension(request_context): Extension, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + if !state.config.sms_auth_enabled { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用") + ); + } + + let result = state + .phone_auth_service() + .reset_password( + ResetPasswordInput { + phone_number: payload.phone, + verify_code: payload.code, + new_password: payload.new_password, + }, + OffsetDateTime::now_utc(), + ) + .await + .map_err(map_phone_auth_error)?; + let session_client = resolve_session_client_context(&headers); + let signed_session = create_auth_session( + &state, + &result.user, + &session_client, + module_auth::AuthLoginMethod::Password, + )?; + + let mut headers = HeaderMap::new(); + attach_set_cookie_header( + &mut headers, + build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?, + ); + + Ok(( + headers, + json_success_body( + Some(&request_context), + PasswordResetResponse { + token: signed_session.access_token, + user: map_auth_user_payload(result.user), + }, + ), + )) +} + +fn map_password_management_error(error: PasswordEntryError) -> AppError { + match error { + PasswordEntryError::InvalidUsername | PasswordEntryError::InvalidPublicUserCode => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } + PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("密码长度需要在 6 到 128 位之间"), + PasswordEntryError::InvalidCredentials => { + AppError::from_status(StatusCode::UNAUTHORIZED).with_message("当前密码错误") + } + PasswordEntryError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("当前登录态已失效,请重新登录"), + PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + } + } +} diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 80bce49e..39cb8f2b 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -153,6 +153,13 @@ pub async fn phone_login( &session_client, AuthLoginMethod::Phone, )?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; let mut headers = HeaderMap::new(); attach_set_cookie_header( @@ -177,6 +184,7 @@ fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result Ok(PhoneAuthScene::Login), "bind_phone" => Ok(PhoneAuthScene::BindPhone), "change_phone" => Ok(PhoneAuthScene::ChangePhone), + "reset_password" => Ok(PhoneAuthScene::ResetPassword), _ => Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("短信验证码场景不合法") .with_details(json!({ "field": "scene" }))), @@ -214,7 +222,7 @@ fn mask_phone_digits(value: &str) -> String { masked } -fn map_phone_auth_error(error: PhoneAuthError) -> AppError { +pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError { match error { PhoneAuthError::InvalidPhoneNumber | PhoneAuthError::InvalidVerifyCode diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index 3f558c92..3b82c038 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -54,6 +54,13 @@ pub async fn refresh_session( &rotated.session.session_id, Some(&rotated.session.issued_by_provider), )?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 8ef0dfeb..14e2e252 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -21,6 +21,7 @@ use platform_oss::{OssClient, OssConfig, OssError}; use serde_json::Value; use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError}; use time::OffsetDateTime; +use tracing::{info, warn}; use crate::config::AppConfig; use crate::wechat_provider::{WechatProvider, build_wechat_provider}; @@ -37,6 +38,7 @@ pub struct AppState { admin_runtime: Option, refresh_cookie_config: RefreshCookieConfig, oss_client: Option, + auth_store: InMemoryAuthStore, password_entry_service: PasswordEntryService, refresh_session_service: RefreshSessionService, auth_user_service: AuthUserService, @@ -86,6 +88,7 @@ pub struct AdminSession { pub enum AppStateInitError { Jwt(JwtError), RefreshCookie(RefreshCookieError), + AuthStore(String), SmsProvider(SmsProviderError), Oss(OssError), Llm(LlmError), @@ -93,6 +96,15 @@ pub enum AppStateInitError { impl AppState { pub fn new(config: AppConfig) -> Result { + let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone()) + .map_err(AppStateInitError::AuthStore)?; + Self::new_with_auth_store(config, auth_store) + } + + fn new_with_auth_store( + config: AppConfig, + auth_store: InMemoryAuthStore, + ) -> Result { let auth_jwt_config = JwtConfig::new( config.jwt_issuer.clone(), config.jwt_secret.clone(), @@ -111,7 +123,6 @@ impl AppState { config.refresh_session_ttl_days, )?; let oss_client = build_oss_client(&config)?; - let auth_store = InMemoryAuthStore::default(); let sms_provider = SmsAuthProvider::new(SmsAuthConfig::new( SmsAuthProviderKind::parse(&config.sms_auth_provider).ok_or_else(|| { SmsProviderError::InvalidConfig("短信 provider 配置非法".to_string()) @@ -141,7 +152,7 @@ impl AppState { let wechat_auth_service = WechatAuthService::new(auth_store.clone()); let wechat_provider = build_wechat_provider(&config); let refresh_session_service = - RefreshSessionService::new(auth_store, config.refresh_session_ttl_days); + RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days); // AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。 let ai_task_service = AiTaskService::new(InMemoryAiTaskStore::default()); let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig { @@ -158,6 +169,7 @@ impl AppState { admin_runtime, refresh_cookie_config, oss_client, + auth_store, password_entry_service, refresh_session_service, auth_user_service, @@ -193,6 +205,49 @@ impl AppState { &self.password_entry_service } + pub async fn sync_auth_store_snapshot_to_spacetime(&self) -> Result<(), SpacetimeClientError> { + let snapshot_json = self + .auth_store + .export_snapshot_json() + .map_err(SpacetimeClientError::Runtime)?; + let updated_at_micros = i64::try_from( + OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000, + ) + .map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?; + self.spacetime_client + .upsert_auth_store_snapshot(snapshot_json, updated_at_micros) + .await?; + Ok(()) + } + + pub async fn try_restore_auth_store_from_spacetime( + config: AppConfig, + ) -> Result { + let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig { + server_url: config.spacetime_server_url.clone(), + database: config.spacetime_database.clone(), + token: config.spacetime_token.clone(), + pool_size: config.spacetime_pool_size, + }); + match spacetime_client.get_auth_store_snapshot().await { + Ok(snapshot) => { + if let Some(snapshot_json) = snapshot.snapshot_json { + if !snapshot_json.trim().is_empty() { + let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) + .map_err(AppStateInitError::AuthStore)?; + info!("已从 SpacetimeDB 恢复认证快照"); + return Self::new_with_auth_store(config, auth_store); + } + } + } + Err(error) => { + warn!(error = %error, "从 SpacetimeDB 恢复认证快照失败,回退到本地快照"); + } + } + + Self::new(config) + } + pub fn refresh_session_service(&self) -> &RefreshSessionService { &self.refresh_session_service } @@ -392,6 +447,7 @@ impl fmt::Display for AppStateInitError { match self { Self::Jwt(error) => write!(f, "{error}"), Self::RefreshCookie(error) => write!(f, "{error}"), + Self::AuthStore(error) => write!(f, "{error}"), Self::SmsProvider(error) => write!(f, "{error}"), Self::Oss(error) => write!(f, "{error}"), Self::Llm(error) => write!(f, "{error}"), diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index bda40b0c..fc4c3511 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -135,6 +135,13 @@ pub async fn handle_wechat_callback( &session_client, AuthLoginMethod::Wechat, )?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; let mut response = Redirect::to(&build_auth_result_redirect_url( &redirect_path, &[ @@ -187,6 +194,13 @@ pub async fn bind_wechat_phone( &session_client, AuthLoginMethod::Wechat, )?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; let mut response_headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/module-auth/Cargo.toml b/server-rs/crates/module-auth/Cargo.toml index b9517b1e..48ca1bd7 100644 --- a/server-rs/crates/module-auth/Cargo.toml +++ b/server-rs/crates/module-auth/Cargo.toml @@ -7,6 +7,8 @@ license.workspace = true [dependencies] platform-auth = { path = "../platform-auth" } shared-kernel = { path = "../shared-kernel" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" time = { version = "0.3", features = ["formatting", "parsing"] } tracing = "0.1" uuid = { version = "1", features = ["v4"] } diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 3b8d7325..093ecd45 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -1,7 +1,8 @@ use std::{ collections::HashMap, error::Error, - fmt, + fmt, fs, + path::{Path, PathBuf}, sync::{Arc, Mutex}, }; @@ -9,6 +10,7 @@ use platform_auth::{ SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password, verify_password, }; +use serde::{Deserialize, Serialize}; use shared_kernel::{ build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string, normalize_optional_string, normalize_required_string, parse_rfc3339, @@ -25,20 +27,20 @@ const SMS_CODE_TTL_MINUTES: i64 = 5; const SMS_CODE_COOLDOWN_SECONDS: u64 = 60; const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5; -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum AuthLoginMethod { Password, Phone, Wechat, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum AuthBindingStatus { Active, PendingBindPhone, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthUser { pub id: String, pub public_user_code: String, @@ -73,11 +75,39 @@ pub struct PasswordEntryResult { pub created: bool, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChangePasswordInput { + pub user_id: String, + pub current_password: Option, + pub new_password: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChangePasswordResult { + pub user: AuthUser, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResetPasswordInput { + pub phone_number: String, + pub verify_code: String, + pub new_password: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResetPasswordResult { + pub user: AuthUser, + pub provider: String, + pub provider_out_id: Option, + pub phone_number_masked: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum PhoneAuthScene { Login, BindPhone, ChangePhone, + ResetPassword, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -193,7 +223,7 @@ pub struct CreateRefreshSessionInput { pub client_info: RefreshSessionClientInfo, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RefreshSessionClientInfo { pub client_type: String, pub client_runtime: String, @@ -207,7 +237,7 @@ pub struct RefreshSessionClientInfo { pub ip: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RefreshSessionRecord { pub session_id: String, pub user_id: String, @@ -264,12 +294,32 @@ pub struct LogoutAllSessionsResult { pub user: AuthUser, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthStoreSnapshotRecord { + pub snapshot_json: Option, + pub updated_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthStoreSnapshotUpsertInput { + pub snapshot_json: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthStoreSnapshotProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum PasswordEntryError { InvalidUsername, InvalidPasswordLength, InvalidPublicUserCode, InvalidCredentials, + UserNotFound, Store(String), PasswordHash(String), } @@ -318,6 +368,7 @@ pub enum LogoutError { #[derive(Clone, Debug)] pub struct InMemoryAuthStore { inner: Arc>, + persistence_path: Option>, } #[derive(Debug)] @@ -333,14 +384,27 @@ struct InMemoryAuthStoreState { user_id_by_provider_union_id: HashMap, } -#[derive(Clone, Debug)] +#[derive(Debug, Serialize, Deserialize)] +struct PersistentAuthStoreSnapshot { + next_user_id: u64, + users_by_username: HashMap, + phone_to_user_id: HashMap, + sessions_by_id: HashMap, + session_id_by_refresh_token_hash: HashMap, + wechat_identity_by_provider_uid: HashMap, + user_id_by_provider_union_id: HashMap, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] struct StoredPasswordUser { user: AuthUser, password_hash: String, + #[serde(default)] + password_login_enabled: bool, phone_number: Option, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] struct StoredRefreshSession { session: RefreshSessionRecord, } @@ -360,7 +424,7 @@ struct StoredWechatAuthState { state: WechatAuthStateRecord, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] struct StoredWechatIdentity { user_id: String, provider_uid: String, @@ -415,6 +479,9 @@ impl PasswordEntryService { validate_password(&input.password)?; if let Some(existing_user) = self.store.find_by_username(&username)? { + if !existing_user.password_login_enabled { + return Err(PasswordEntryError::InvalidCredentials); + } let is_valid = verify_password(&existing_user.password_hash, &input.password) .await .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; @@ -428,32 +495,7 @@ impl PasswordEntryService { }); } - let password_hash = hash_password(&input.password) - .await - .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; - match self.store.create_user(username.clone(), password_hash) { - Ok(user) => Ok(PasswordEntryResult { - user, - created: true, - }), - Err(CreateUserError::AlreadyExists) => { - let existing_user = self.store.find_by_username(&username)?.ok_or_else(|| { - PasswordEntryError::Store("唯一键冲突后未能重新读取账号".to_string()) - })?; - let is_valid = verify_password(&existing_user.password_hash, &input.password) - .await - .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; - if !is_valid { - return Err(PasswordEntryError::InvalidCredentials); - } - - Ok(PasswordEntryResult { - user: existing_user.user, - created: false, - }) - } - Err(CreateUserError::Store(message)) => Err(PasswordEntryError::Store(message)), - } + Err(PasswordEntryError::InvalidCredentials) } pub fn get_user_by_id( @@ -474,6 +516,42 @@ impl PasswordEntryService { .find_by_public_user_code(&normalized_public_user_code) .map(|maybe_user| maybe_user.map(|stored| PublicUserSearchResult { user: stored.user })) } + + pub async fn change_password( + &self, + input: ChangePasswordInput, + ) -> Result { + validate_password(&input.new_password)?; + let stored_user = self + .store + .find_by_user_id(&input.user_id)? + .ok_or(PasswordEntryError::UserNotFound)?; + + if stored_user.password_login_enabled { + let current_password = input + .current_password + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(PasswordEntryError::InvalidCredentials)?; + let is_valid = verify_password(&stored_user.password_hash, current_password) + .await + .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; + if !is_valid { + return Err(PasswordEntryError::InvalidCredentials); + } + } + + let password_hash = hash_password(&input.new_password) + .await + .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; + let user = self + .store + .set_user_password_hash(&input.user_id, password_hash)? + .ok_or(PasswordEntryError::UserNotFound)?; + + Ok(ChangePasswordResult { user }) + } } impl RefreshSessionService { @@ -737,6 +815,57 @@ impl PhoneAuthService { }) } + pub async fn reset_password( + &self, + input: ResetPasswordInput, + now: OffsetDateTime, + ) -> Result { + let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + verify_sms_code_format(&input.verify_code)?; + validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?; + let provider_out_id = self.store.assert_phone_code_active( + &normalized_phone.e164, + &PhoneAuthScene::ResetPassword, + now, + )?; + match self + .sms_provider + .verify_code(SmsVerifyCodeRequest { + national_phone_number: build_national_phone_number(&normalized_phone.e164)?, + verify_code: input.verify_code.trim().to_string(), + provider_out_id: provider_out_id.clone(), + }) + .await + { + Ok(()) => self.store.consume_phone_code_success( + &normalized_phone.e164, + &PhoneAuthScene::ResetPassword, + )?, + Err(SmsProviderError::InvalidVerifyCode) => self.store.consume_phone_code_failure( + &normalized_phone.e164, + &PhoneAuthScene::ResetPassword, + )?, + Err(other) => return Err(map_sms_provider_error_to_phone_error(other)), + } + + self.store + .find_by_phone_number(&normalized_phone.e164)? + .ok_or(PhoneAuthError::UserNotFound)?; + let password_hash = hash_password(&input.new_password) + .await + .map_err(|error| PhoneAuthError::PasswordHash(error.to_string()))?; + let user = self + .store + .set_user_password_by_phone_number(&normalized_phone.e164, password_hash)?; + + Ok(ResetPasswordResult { + user, + provider: self.sms_provider.kind().as_str().to_string(), + provider_out_id, + phone_number_masked: normalized_phone.masked_national_number, + }) + } + pub async fn bind_wechat_phone( &self, input: BindWechatPhoneInput, @@ -954,22 +1083,154 @@ impl AuthUserService { impl Default for InMemoryAuthStore { fn default() -> Self { Self { - inner: Arc::new(Mutex::new(InMemoryAuthStoreState { - next_user_id: 1, - users_by_username: HashMap::new(), - phone_to_user_id: HashMap::new(), - sessions_by_id: HashMap::new(), - session_id_by_refresh_token_hash: HashMap::new(), - phone_codes_by_key: HashMap::new(), - wechat_states_by_token: HashMap::new(), - wechat_identity_by_provider_uid: HashMap::new(), - user_id_by_provider_union_id: HashMap::new(), - })), + inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())), + persistence_path: None, } } } +impl Default for InMemoryAuthStoreState { + fn default() -> Self { + Self { + next_user_id: 1, + users_by_username: HashMap::new(), + phone_to_user_id: HashMap::new(), + sessions_by_id: HashMap::new(), + session_id_by_refresh_token_hash: HashMap::new(), + phone_codes_by_key: HashMap::new(), + wechat_states_by_token: HashMap::new(), + wechat_identity_by_provider_uid: HashMap::new(), + user_id_by_provider_union_id: HashMap::new(), + } + } +} + +impl InMemoryAuthStoreState { + fn from_persistent_snapshot(snapshot: PersistentAuthStoreSnapshot) -> Self { + Self { + next_user_id: snapshot.next_user_id, + users_by_username: snapshot.users_by_username, + phone_to_user_id: snapshot.phone_to_user_id, + sessions_by_id: snapshot.sessions_by_id, + session_id_by_refresh_token_hash: snapshot.session_id_by_refresh_token_hash, + phone_codes_by_key: HashMap::new(), + wechat_states_by_token: HashMap::new(), + wechat_identity_by_provider_uid: snapshot.wechat_identity_by_provider_uid, + user_id_by_provider_union_id: snapshot.user_id_by_provider_union_id, + } + } + + fn to_persistent_snapshot(&self) -> PersistentAuthStoreSnapshot { + PersistentAuthStoreSnapshot { + next_user_id: self.next_user_id, + users_by_username: self.users_by_username.clone(), + phone_to_user_id: self.phone_to_user_id.clone(), + sessions_by_id: self.sessions_by_id.clone(), + session_id_by_refresh_token_hash: self.session_id_by_refresh_token_hash.clone(), + wechat_identity_by_provider_uid: self.wechat_identity_by_provider_uid.clone(), + user_id_by_provider_union_id: self.user_id_by_provider_union_id.clone(), + } + } +} + +fn build_temp_persistence_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("auth-store.json"); + path.with_file_name(format!("{file_name}.tmp")) +} + impl InMemoryAuthStore { + pub fn from_snapshot_json(snapshot_json: &str) -> Result { + let snapshot = serde_json::from_str::(snapshot_json) + .map_err(|error| format!("解析认证快照失败:{error}"))?; + Ok(Self { + inner: Arc::new(Mutex::new( + InMemoryAuthStoreState::from_persistent_snapshot(snapshot), + )), + persistence_path: None, + }) + } + + pub fn from_persistence_path(path: impl Into) -> Result { + let path = path.into(); + let state = if path.is_file() { + let raw_text = + fs::read_to_string(&path).map_err(|error| format!("读取认证快照失败:{error}"))?; + let snapshot = serde_json::from_str::(&raw_text) + .map_err(|error| format!("解析认证快照失败:{error}"))?; + InMemoryAuthStoreState::from_persistent_snapshot(snapshot) + } else { + InMemoryAuthStoreState::default() + }; + + Ok(Self { + inner: Arc::new(Mutex::new(state)), + persistence_path: Some(Arc::new(path)), + }) + } + + pub fn export_snapshot_json(&self) -> Result { + let state = self + .inner + .lock() + .map_err(|_| "认证仓储锁已中毒".to_string())?; + let snapshot = state.to_persistent_snapshot(); + serde_json::to_string_pretty(&snapshot) + .map_err(|error| format!("序列化认证快照失败:{error}")) + } + + fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> { + let Some(path) = self.persistence_path.as_deref() else { + return Ok(()); + }; + + if let Some(parent_dir) = path.parent() { + fs::create_dir_all(parent_dir).map_err(|error| { + format!( + "创建认证快照目录失败:{},路径:{}", + error, + parent_dir.display() + ) + })?; + } + + let snapshot = state.to_persistent_snapshot(); + let raw_text = serde_json::to_string_pretty(&snapshot) + .map_err(|error| format!("序列化认证快照失败:{error}"))?; + let temp_path = build_temp_persistence_path(path); + fs::write(&temp_path, raw_text) + .map_err(|error| format!("写入认证快照临时文件失败:{error}"))?; + fs::rename(&temp_path, path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!("替换认证快照文件失败:{error}") + }) + } + + fn persist_password_state( + &self, + state: &InMemoryAuthStoreState, + ) -> Result<(), PasswordEntryError> { + self.persist_state(state).map_err(PasswordEntryError::Store) + } + + fn persist_phone_state(&self, state: &InMemoryAuthStoreState) -> Result<(), PhoneAuthError> { + self.persist_state(state).map_err(PhoneAuthError::Store) + } + + fn persist_wechat_state(&self, state: &InMemoryAuthStoreState) -> Result<(), WechatAuthError> { + self.persist_state(state).map_err(WechatAuthError::Store) + } + + fn persist_refresh_state( + &self, + state: &InMemoryAuthStoreState, + ) -> Result<(), RefreshSessionError> { + self.persist_state(state) + .map_err(RefreshSessionError::Store) + } + fn find_by_username( &self, username: &str, @@ -1031,48 +1292,6 @@ impl InMemoryAuthStore { .cloned()) } - fn create_user( - &self, - username: String, - password_hash: String, - ) -> Result { - let mut state = self - .inner - .lock() - .map_err(|_| CreateUserError::Store("用户仓储锁已中毒".to_string()))?; - - if state.users_by_username.contains_key(&username) { - return Err(CreateUserError::AlreadyExists); - } - - let sequence = state.next_user_id; - let user_id = format!("user_{sequence:08}"); - let public_user_code = build_public_user_code(sequence); - state.next_user_id += 1; - - let user = AuthUser { - id: user_id, - public_user_code, - username: username.clone(), - display_name: username.clone(), - phone_number_masked: None, - login_method: AuthLoginMethod::Password, - binding_status: AuthBindingStatus::Active, - wechat_bound: false, - token_version: 1, - }; - state.users_by_username.insert( - username, - StoredPasswordUser { - user: user.clone(), - password_hash, - phone_number: None, - }, - ); - - Ok(user) - } - fn create_phone_user( &self, phone_number: PhoneNumberSnapshot, @@ -1113,9 +1332,11 @@ impl InMemoryAuthStore { StoredPasswordUser { user: user.clone(), password_hash, + password_login_enabled: false, phone_number: Some(phone_number.e164), }, ); + self.persist_phone_state(&state)?; Ok(user) } @@ -1158,6 +1379,7 @@ impl InMemoryAuthStore { StoredPasswordUser { user: user.clone(), password_hash, + password_login_enabled: false, phone_number: None, }, ); @@ -1176,6 +1398,7 @@ impl InMemoryAuthStore { state .wechat_identity_by_provider_uid .insert(identity.provider_uid.clone(), identity); + self.persist_wechat_state(&state)?; Ok(user) } @@ -1259,21 +1482,25 @@ impl InMemoryAuthStore { .insert(provider_union_id, user_id.to_string()); } - let stored_user = state - .users_by_username - .values_mut() - .find(|stored_user| stored_user.user.id == user_id) - .ok_or(WechatAuthError::UserNotFound)?; - if let Some(display_name) = next_display_name - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - && stored_user.user.binding_status == AuthBindingStatus::PendingBindPhone - { - stored_user.user.display_name = display_name.to_string(); - } + let next_user = { + let stored_user = state + .users_by_username + .values_mut() + .find(|stored_user| stored_user.user.id == user_id) + .ok_or(WechatAuthError::UserNotFound)?; + if let Some(display_name) = next_display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + && stored_user.user.binding_status == AuthBindingStatus::PendingBindPhone + { + stored_user.user.display_name = display_name.to_string(); + } + stored_user.user.clone() + }; + self.persist_wechat_state(&state)?; - Ok(stored_user.user.clone()) + Ok(next_user) } fn insert_session(&self, session: RefreshSessionRecord) -> Result<(), RefreshSessionError> { @@ -1298,6 +1525,7 @@ impl InMemoryAuthStore { state .sessions_by_id .insert(session.session_id.clone(), StoredRefreshSession { session }); + self.persist_refresh_state(&state)?; Ok(()) } @@ -1530,8 +1758,10 @@ impl InMemoryAuthStore { .find(|stored| stored.user.id == target_user_id) .ok_or(PhoneAuthError::UserNotFound)?; target_user.user.wechat_bound = true; + let next_user = target_user.user.clone(); + self.persist_phone_state(&state)?; - return Ok(target_user.user.clone()); + return Ok(next_user); } state @@ -1547,8 +1777,10 @@ impl InMemoryAuthStore { stored_user.user.binding_status = AuthBindingStatus::Active; stored_user.user.wechat_bound = true; stored_user.phone_number = Some(phone_number.e164); + let next_user = stored_user.user.clone(); + self.persist_phone_state(&state)?; - Ok(stored_user.user.clone()) + Ok(next_user) } fn find_session_by_refresh_token_hash( @@ -1663,6 +1895,7 @@ impl InMemoryAuthStore { next_refresh_token_hash, updated_session.session.session_id.clone(), ); + self.persist_refresh_state(&state)?; Ok(updated_session) } @@ -1696,6 +1929,7 @@ impl InMemoryAuthStore { })?; stored.session.revoked_at = Some(now_iso.clone()); stored.session.updated_at = now_iso; + self.persist_refresh_state(&state)?; Ok(()) } @@ -1726,6 +1960,7 @@ impl InMemoryAuthStore { stored.session.revoked_at = Some(now_iso.clone()); stored.session.updated_at = now_iso.clone(); } + self.persist_refresh_state(&state)?; Ok(()) } @@ -1745,17 +1980,70 @@ impl InMemoryAuthStore { } stored_user.user.token_version += 1; - return Ok(Some(stored_user.user.clone())); + let next_user = stored_user.user.clone(); + self.persist_password_state(&state)?; + return Ok(Some(next_user)); } Ok(None) } -} -#[derive(Debug, PartialEq, Eq)] -enum CreateUserError { - AlreadyExists, - Store(String), + fn set_user_password_hash( + &self, + user_id: &str, + password_hash: String, + ) -> Result, PasswordEntryError> { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + + for stored_user in state.users_by_username.values_mut() { + if stored_user.user.id != user_id { + continue; + } + + stored_user.password_hash = password_hash; + stored_user.password_login_enabled = true; + stored_user.user.token_version += 1; + let next_user = stored_user.user.clone(); + self.persist_password_state(&state)?; + return Ok(Some(next_user)); + } + + Ok(None) + } + + fn set_user_password_by_phone_number( + &self, + phone_number: &str, + password_hash: String, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; + let user_id = state + .phone_to_user_id + .get(phone_number) + .cloned() + .ok_or(PhoneAuthError::UserNotFound)?; + + for stored_user in state.users_by_username.values_mut() { + if stored_user.user.id != user_id { + continue; + } + + stored_user.password_hash = password_hash; + stored_user.password_login_enabled = true; + stored_user.user.token_version += 1; + let next_user = stored_user.user.clone(); + self.persist_phone_state(&state)?; + return Ok(next_user); + } + + Err(PhoneAuthError::UserNotFound) + } } impl AuthLoginMethod { @@ -1784,6 +2072,7 @@ impl fmt::Display for PasswordEntryError { Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"), Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"), Self::InvalidCredentials => f.write_str("用户名或密码错误"), + Self::UserNotFound => f.write_str("用户不存在"), Self::Store(message) | Self::PasswordHash(message) => f.write_str(message), } } @@ -1857,6 +2146,7 @@ fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError { | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials + | PasswordEntryError::UserNotFound | PasswordEntryError::PasswordHash(_) => { RefreshSessionError::Store("用户仓储读取失败".to_string()) } @@ -1870,9 +2160,8 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro PasswordEntryError::InvalidUsername | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode - | PasswordEntryError::InvalidCredentials => { - PhoneAuthError::Store("用户仓储读取失败".to_string()) - } + | PasswordEntryError::InvalidCredentials + | PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()), } } @@ -1883,6 +2172,7 @@ fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials + | PasswordEntryError::UserNotFound | PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()), } } @@ -2055,6 +2345,7 @@ impl PhoneAuthScene { Self::Login => "login", Self::BindPhone => "bind_phone", Self::ChangePhone => "change_phone", + Self::ResetPassword => "reset_password", } } } @@ -2138,65 +2429,91 @@ mod tests { } } - #[tokio::test] - async fn first_password_entry_creates_user() { - let service = build_password_service(build_store()); - - let result = service - .execute(PasswordEntryInput { - username: "guest_001".to_string(), - password: "secret123".to_string(), - }) + async fn create_phone_login_user(store: InMemoryAuthStore, phone_number: &str) -> AuthUser { + let phone_service = build_phone_service(store); + let now = OffsetDateTime::now_utc(); + phone_service + .send_code( + SendPhoneCodeInput { + phone_number: phone_number.to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) .await - .expect("first login should succeed"); - - assert!(result.created); - assert_eq!(result.user.id, "user_00000001"); - assert_eq!(result.user.username, "guest_001"); - assert_eq!(result.user.display_name, "guest_001"); - assert_eq!(result.user.login_method, AuthLoginMethod::Password); - assert_eq!(result.user.binding_status, AuthBindingStatus::Active); + .expect("phone code should send"); + phone_service + .login( + PhoneLoginInput { + phone_number: phone_number.to_string(), + verify_code: "123456".to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("phone login should create user") + .user } #[tokio::test] - async fn repeated_password_entry_reuses_same_user() { - let store = build_store(); - let service = build_password_service(store); - let first = service - .execute(PasswordEntryInput { - username: "guest_001".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("first login should succeed"); - - let second = service - .execute(PasswordEntryInput { - username: "guest_001".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("second login should succeed"); - - assert!(first.created); - assert!(!second.created); - assert_eq!(second.user.id, first.user.id); - } - - #[tokio::test] - async fn repeated_password_entry_rejects_wrong_password() { + async fn password_entry_rejects_unknown_user_without_registration() { let service = build_password_service(build_store()); - service - .execute(PasswordEntryInput { - username: "guest_001".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("first login should succeed"); let error = service .execute(PasswordEntryInput { username: "guest_001".to_string(), + password: "secret123".to_string(), + }) + .await + .expect_err("password login must not create user"); + + assert_eq!(error, PasswordEntryError::InvalidCredentials); + } + + #[tokio::test] + async fn phone_user_can_set_password_then_login() { + let store = build_store(); + let user = create_phone_login_user(store.clone(), "13800138000").await; + let service = build_password_service(store); + + let changed = service + .change_password(ChangePasswordInput { + user_id: user.id.clone(), + current_password: None, + new_password: "secret123".to_string(), + }) + .await + .expect("phone user should set first password"); + let result = service + .execute(PasswordEntryInput { + username: changed.user.username.clone(), + password: "secret123".to_string(), + }) + .await + .expect("password login should succeed after setting password"); + + assert!(!result.created); + assert_eq!(result.user.id, user.id); + assert_eq!(result.user.login_method, AuthLoginMethod::Phone); + } + + #[tokio::test] + async fn password_entry_rejects_wrong_password_after_set() { + let store = build_store(); + let user = create_phone_login_user(store.clone(), "13800138001").await; + let service = build_password_service(store); + service + .change_password(ChangePasswordInput { + user_id: user.id.clone(), + current_password: None, + new_password: "secret123".to_string(), + }) + .await + .expect("password should set"); + + let error = service + .execute(PasswordEntryInput { + username: user.username, password: "secret999".to_string(), }) .await @@ -2205,6 +2522,94 @@ mod tests { assert_eq!(error, PasswordEntryError::InvalidCredentials); } + #[tokio::test] + async fn reset_password_requires_existing_phone_user() { + let store = build_store(); + let phone_service = build_phone_service(store.clone()); + let now = OffsetDateTime::now_utc(); + phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138002".to_string(), + scene: PhoneAuthScene::ResetPassword, + }, + now, + ) + .await + .expect("reset code should send"); + + let error = phone_service + .reset_password( + ResetPasswordInput { + phone_number: "13800138002".to_string(), + verify_code: "123456".to_string(), + new_password: "secret123".to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect_err("unknown phone must not register by reset"); + + assert_eq!(error, PhoneAuthError::UserNotFound); + } + + #[tokio::test] + async fn persistent_store_restores_user_and_refresh_session_after_restart() { + let store_path = std::env::temp_dir().join(format!( + "genarrative-auth-store-{}.json", + new_uuid_simple_string() + )); + let _ = std::fs::remove_file(&store_path); + + let store = InMemoryAuthStore::from_persistence_path(store_path.clone()) + .expect("persistent store should initialize"); + let user = create_phone_login_user(store.clone(), "13800138003").await; + let password_service = build_password_service(store.clone()); + let refresh_service = build_refresh_service(store.clone()); + password_service + .change_password(ChangePasswordInput { + user_id: user.id.clone(), + current_password: None, + new_password: "secret123".to_string(), + }) + .await + .expect("password should set before persistence check"); + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("persist-token-01"), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + OffsetDateTime::now_utc(), + ) + .expect("refresh session should be persisted"); + drop(store); + + let restored_store = InMemoryAuthStore::from_persistence_path(store_path.clone()) + .expect("persistent store should restore"); + let restored_user = build_password_service(restored_store.clone()) + .get_user_by_id(&user.id) + .expect("restored user query should succeed") + .expect("restored user should exist") + .user; + assert_eq!(restored_user.username, user.username); + + let rotated = build_refresh_service(restored_store) + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: hash_refresh_session_token("persist-token-01"), + next_refresh_token_hash: hash_refresh_session_token("persist-token-02"), + }, + OffsetDateTime::now_utc(), + ) + .expect("restored refresh session should rotate"); + assert_eq!(rotated.user.id, user.id); + + let _ = std::fs::remove_file(&store_path); + } + #[tokio::test] async fn invalid_username_returns_bad_request_error() { let service = build_password_service(build_store()); @@ -2363,16 +2768,8 @@ mod tests { #[tokio::test] async fn refresh_session_creation_and_rotation_keep_same_session_id() { let store = build_store(); - let password_service = build_password_service(store.clone()); + let user = create_phone_login_user(store.clone(), "13800138004").await; let refresh_service = build_refresh_service(store); - let user = password_service - .execute(PasswordEntryInput { - username: "guest_002".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("seed login should succeed") - .user; let now = OffsetDateTime::now_utc(); let first_token_hash = hash_refresh_session_token("refresh-token-01"); let created = refresh_service @@ -2426,17 +2823,9 @@ mod tests { #[tokio::test] async fn logout_current_session_revokes_session_and_increments_token_version() { let store = build_store(); - let password_service = build_password_service(store.clone()); + let user = create_phone_login_user(store.clone(), "13800138005").await; let refresh_service = build_refresh_service(store.clone()); let user_service = build_user_service(store); - let user = password_service - .execute(PasswordEntryInput { - username: "guest_logout".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("seed login should succeed") - .user; let refresh_token_hash = hash_refresh_session_token("logout-token"); refresh_service .create_session( @@ -2477,17 +2866,9 @@ mod tests { #[tokio::test] async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() { let store = build_store(); - let password_service = build_password_service(store.clone()); + let user = create_phone_login_user(store.clone(), "13800138006").await; let refresh_service = build_refresh_service(store.clone()); let user_service = build_user_service(store); - let user = password_service - .execute(PasswordEntryInput { - username: "guest_logout_all".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("seed login should succeed") - .user; let first_refresh_token_hash = hash_refresh_session_token("logout-all-token-01"); let second_refresh_token_hash = hash_refresh_session_token("logout-all-token-02"); let now = OffsetDateTime::now_utc(); @@ -2564,16 +2945,8 @@ mod tests { #[tokio::test] async fn list_active_sessions_by_user_filters_revoked_and_expired_sessions() { let store = build_store(); - let password_service = build_password_service(store.clone()); let refresh_service = build_refresh_service(store.clone()); - let user = password_service - .execute(PasswordEntryInput { - username: "guest_sessions".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("seed login should succeed") - .user; + let user = create_phone_login_user(store.clone(), "13800138007").await; let now = OffsetDateTime::now_utc(); let active_session = refresh_service diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index bc99bcdd..bddb182b 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -53,6 +53,34 @@ pub struct PasswordEntryResponse { pub user: AuthUserPayload, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PasswordChangeRequest { + pub current_password: Option, + pub new_password: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PasswordChangeResponse { + pub user: AuthUserPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PasswordResetRequest { + pub phone: String, + pub code: String, + pub new_password: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PasswordResetResponse { + pub token: String, + pub user: AuthUserPayload, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AuthMeResponse { @@ -167,7 +195,7 @@ pub fn build_available_login_methods( sms_auth_enabled: bool, wechat_auth_enabled: bool, ) -> Vec { - let mut methods = Vec::new(); + let mut methods = vec![AUTH_LOGIN_METHOD_PASSWORD.to_string()]; if sms_auth_enabled { methods.push(AUTH_LOGIN_METHOD_PHONE.to_string()); } @@ -189,6 +217,7 @@ mod tests { assert_eq!( methods, vec![ + AUTH_LOGIN_METHOD_PASSWORD.to_string(), AUTH_LOGIN_METHOD_PHONE.to_string(), AUTH_LOGIN_METHOD_WECHAT.to_string() ] diff --git a/server-rs/crates/spacetime-client/src/ai.rs b/server-rs/crates/spacetime-client/src/ai.rs new file mode 100644 index 00000000..65d0de12 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/ai.rs @@ -0,0 +1,205 @@ +use super::*; + +impl SpacetimeClient { + pub async fn create_ai_task( + &self, + input: DomainAiTaskCreateInput, + ) -> Result { + let procedure_input = map_ai_task_create_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().create_ai_task_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn start_ai_task( + &self, + input: DomainAiTaskStartInput, + ) -> Result<(), SpacetimeClientError> { + let reducer_input = map_ai_task_start_input(input); + + self.call_reducer_after_connect(move |connection, sender| { + let callback_sender = sender.clone(); + if let Err(error) = + connection + .reducers + .start_ai_task_then(reducer_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(|inner| inner.map_err(SpacetimeClientError::Runtime)); + send_reducer_once(&callback_sender, mapped); + }) + { + send_reducer_once( + &sender, + Err(SpacetimeClientError::Procedure(error.to_string())), + ); + } + }) + .await + } + + + pub async fn start_ai_task_stage( + &self, + input: DomainAiTaskStageStartInput, + ) -> Result<(), SpacetimeClientError> { + let reducer_input = map_ai_task_stage_start_input(input); + + self.call_reducer_after_connect(move |connection, sender| { + let callback_sender = sender.clone(); + if let Err(error) = + connection + .reducers + .start_ai_task_stage_then(reducer_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(|inner| inner.map_err(SpacetimeClientError::Runtime)); + send_reducer_once(&callback_sender, mapped); + }) + { + send_reducer_once( + &sender, + Err(SpacetimeClientError::Procedure(error.to_string())), + ); + } + }) + .await + } + + + pub async fn append_ai_text_chunk( + &self, + input: DomainAiTextChunkAppendInput, + ) -> Result { + let procedure_input = map_ai_text_chunk_append_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .append_ai_text_chunk_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn complete_ai_stage( + &self, + input: DomainAiStageCompletionInput, + ) -> Result { + let procedure_input = map_ai_stage_completion_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().complete_ai_stage_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn attach_ai_result_reference( + &self, + input: DomainAiResultReferenceInput, + ) -> Result { + let procedure_input = map_ai_result_reference_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .attach_ai_result_reference_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn complete_ai_task( + &self, + input: DomainAiTaskFinishInput, + ) -> Result { + let procedure_input = map_ai_task_finish_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().complete_ai_task_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn fail_ai_task( + &self, + input: DomainAiTaskFailureInput, + ) -> Result { + let procedure_input = map_ai_task_failure_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().fail_ai_task_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn cancel_ai_task( + &self, + input: DomainAiTaskCancelInput, + ) -> Result { + let procedure_input = map_ai_task_cancel_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().cancel_ai_task_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + +} diff --git a/server-rs/crates/spacetime-client/src/assets.rs b/server-rs/crates/spacetime-client/src/assets.rs new file mode 100644 index 00000000..5f4fbe2a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/assets.rs @@ -0,0 +1,44 @@ +use super::*; + +impl SpacetimeClient { + pub async fn confirm_asset_object( + &self, + input: module_assets::AssetObjectUpsertInput, + ) -> Result { + let procedure_input = map_upsert_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .confirm_asset_object_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn bind_asset_object_to_entity( + &self, + input: module_assets::AssetEntityBindingInput, + ) -> Result { + let procedure_input = map_entity_binding_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .bind_asset_object_to_entity_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_entity_binding_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + +} diff --git a/server-rs/crates/spacetime-client/src/auth.rs b/server-rs/crates/spacetime-client/src/auth.rs new file mode 100644 index 00000000..60852ffe --- /dev/null +++ b/server-rs/crates/spacetime-client/src/auth.rs @@ -0,0 +1,46 @@ +use super::*; + +impl SpacetimeClient { + pub async fn get_auth_store_snapshot( + &self, + ) -> Result { + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_auth_store_snapshot_then(move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_auth_store_snapshot_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn upsert_auth_store_snapshot( + &self, + snapshot_json: String, + updated_at_micros: i64, + ) -> Result { + let procedure_input = AuthStoreSnapshotUpsertInput { + snapshot_json, + updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().upsert_auth_store_snapshot_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_auth_store_snapshot_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + +} diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs new file mode 100644 index 00000000..54b65a3f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -0,0 +1,263 @@ +use super::*; + +impl SpacetimeClient { + pub async fn create_big_fish_session( + &self, + input: BigFishSessionCreateRecordInput, + ) -> Result { + let procedure_input = BigFishSessionCreateInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + seed_text: input.seed_text, + welcome_message_id: input.welcome_message_id, + welcome_message_text: input.welcome_message_text, + created_at_micros: input.created_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().create_big_fish_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn get_big_fish_session( + &self, + session_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = BigFishSessionGetInput { + session_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_big_fish_session_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn list_big_fish_works( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = BigFishWorksListInput { owner_user_id }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .list_big_fish_works_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_works_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn submit_big_fish_message( + &self, + input: BigFishMessageSubmitRecordInput, + ) -> Result { + let procedure_input = BigFishMessageSubmitInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + user_message_id: input.user_message_id, + user_message_text: input.user_message_text, + assistant_message_id: input.assistant_message_id, + submitted_at_micros: input.submitted_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().submit_big_fish_message_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn compile_big_fish_draft( + &self, + session_id: String, + owner_user_id: String, + compiled_at_micros: i64, + ) -> Result { + let procedure_input = BigFishDraftCompileInput { + session_id, + owner_user_id, + compiled_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().compile_big_fish_draft_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn generate_big_fish_asset( + &self, + input: BigFishAssetGenerateRecordInput, + ) -> Result { + let procedure_input = BigFishAssetGenerateInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + asset_kind: map_big_fish_asset_kind_input(input.asset_kind.as_str())?, + level: input.level, + motion_key: input.motion_key, + asset_url: input.asset_url, + generated_at_micros: input.generated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().generate_big_fish_asset_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn publish_big_fish_game( + &self, + session_id: String, + owner_user_id: String, + published_at_micros: i64, + ) -> Result { + let procedure_input = BigFishPublishInput { + session_id, + owner_user_id, + published_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().publish_big_fish_game_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn start_big_fish_run( + &self, + input: BigFishRunStartRecordInput, + ) -> Result { + let procedure_input = BigFishRunStartInput { + run_id: input.run_id, + session_id: input.session_id, + owner_user_id: input.owner_user_id, + started_at_micros: input.started_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .start_big_fish_run_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_run_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn submit_big_fish_input( + &self, + input: BigFishRunInputSubmitRecordInput, + ) -> Result { + let procedure_input = BigFishRunInputSubmitInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + input_x: input.input_x, + input_y: input.input_y, + submitted_at_micros: input.submitted_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().submit_big_fish_input_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn get_big_fish_run( + &self, + run_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = BigFishRunGetInput { + run_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_big_fish_run_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_run_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + +} diff --git a/server-rs/crates/spacetime-client/src/combat.rs b/server-rs/crates/spacetime-client/src/combat.rs new file mode 100644 index 00000000..9d087537 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/combat.rs @@ -0,0 +1,72 @@ +use super::*; + +impl SpacetimeClient { + pub async fn create_battle_state( + &self, + input: DomainBattleStateInput, + ) -> Result { + validate_battle_state_input(&input) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; + let procedure_input = map_battle_state_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().create_battle_state_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_battle_state_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn get_battle_state( + &self, + battle_state_id: String, + ) -> Result { + let procedure_input = map_battle_state_query_input( + build_battle_state_query_input(battle_state_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_battle_state_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_battle_state_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn resolve_combat_action( + &self, + input: DomainResolveCombatActionInput, + ) -> Result { + validate_resolve_combat_action_input(&input) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; + let procedure_input = map_resolve_combat_action_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .resolve_combat_action_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_resolve_combat_action_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + +} diff --git a/server-rs/crates/spacetime-client/src/custom_world.rs b/server-rs/crates/spacetime-client/src/custom_world.rs new file mode 100644 index 00000000..f4883244 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/custom_world.rs @@ -0,0 +1,481 @@ +use super::*; + +impl SpacetimeClient { + pub async fn list_custom_world_profiles( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = CustomWorldProfileListInput { owner_user_id }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().list_custom_world_profiles_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_profile_list_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn get_custom_world_library_detail( + &self, + owner_user_id: String, + profile_id: String, + ) -> Result { + let procedure_input = CustomWorldLibraryDetailInput { + owner_user_id, + profile_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_custom_world_library_detail_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_detail_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn upsert_custom_world_profile( + &self, + input: CustomWorldProfileUpsertRecordInput, + ) -> Result { + let procedure_input = map_custom_world_profile_upsert_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .upsert_custom_world_profile_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn publish_custom_world_profile( + &self, + profile_id: String, + owner_user_id: String, + public_work_code: Option, + author_public_user_code: String, + author_display_name: String, + published_at_micros: i64, + ) -> Result { + let procedure_input = CustomWorldProfilePublishInput { + profile_id, + owner_user_id, + public_work_code, + author_public_user_code, + author_display_name, + published_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .publish_custom_world_profile_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn unpublish_custom_world_profile( + &self, + profile_id: String, + owner_user_id: String, + author_display_name: String, + updated_at_micros: i64, + ) -> Result { + let procedure_input = CustomWorldProfileUnpublishInput { + profile_id, + owner_user_id, + author_display_name, + updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .unpublish_custom_world_profile_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn delete_custom_world_profile( + &self, + profile_id: String, + owner_user_id: String, + deleted_at_micros: i64, + ) -> Result, SpacetimeClientError> { + let procedure_input = CustomWorldProfileDeleteInput { + profile_id, + owner_user_id, + deleted_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .delete_custom_world_profile_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_profile_list_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn list_custom_world_gallery_entries( + &self, + ) -> Result, SpacetimeClientError> { + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .list_custom_world_gallery_entries_then(move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_gallery_list_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn get_custom_world_gallery_detail( + &self, + owner_user_id: String, + profile_id: String, + ) -> Result { + let procedure_input = CustomWorldGalleryDetailInput { + owner_user_id, + profile_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_custom_world_gallery_detail_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn get_custom_world_gallery_detail_by_code( + &self, + public_work_code: String, + ) -> Result { + let procedure_input = CustomWorldGalleryDetailByCodeInput { public_work_code }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_custom_world_gallery_detail_by_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn publish_custom_world_world( + &self, + input: CustomWorldPublishWorldRecordInput, + ) -> Result { + let procedure_input = map_custom_world_publish_world_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().publish_custom_world_world_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_publish_world_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn create_custom_world_agent_session( + &self, + input: CustomWorldAgentSessionCreateRecordInput, + ) -> Result { + let procedure_input = CustomWorldAgentSessionCreateInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + seed_text: input.seed_text, + welcome_message_id: input.welcome_message_id, + welcome_message_text: input.welcome_message_text, + anchor_content_json: input.anchor_content_json, + creator_intent_json: input.creator_intent_json, + creator_intent_readiness_json: input.creator_intent_readiness_json, + anchor_pack_json: input.anchor_pack_json, + lock_state_json: input.lock_state_json, + draft_profile_json: input.draft_profile_json, + pending_clarifications_json: input.pending_clarifications_json, + suggested_actions_json: input.suggested_actions_json, + recommended_replies_json: input.recommended_replies_json, + quality_findings_json: input.quality_findings_json, + asset_coverage_json: input.asset_coverage_json, + checkpoints_json: input.checkpoints_json, + created_at_micros: input.created_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .create_custom_world_agent_session_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn get_custom_world_agent_session( + &self, + session_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = CustomWorldAgentSessionGetInput { + session_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_custom_world_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn list_custom_world_works( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = CustomWorldWorksListInput { owner_user_id }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().list_custom_world_works_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_works_list_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn get_custom_world_agent_card_detail( + &self, + session_id: String, + owner_user_id: String, + card_id: String, + ) -> Result { + let procedure_input = CustomWorldAgentCardDetailGetInput { + session_id, + owner_user_id, + card_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_custom_world_agent_card_detail_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_draft_card_detail_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn execute_custom_world_agent_action( + &self, + input: CustomWorldAgentActionExecuteRecordInput, + ) -> Result { + let procedure_input = CustomWorldAgentActionExecuteInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + operation_id: input.operation_id, + action: input.action, + payload_json: input.payload_json, + submitted_at_micros: input.submitted_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .execute_custom_world_agent_action_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_action_execute_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn submit_custom_world_agent_message( + &self, + input: CustomWorldAgentMessageSubmitRecordInput, + ) -> Result { + let procedure_input = CustomWorldAgentMessageSubmitInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + user_message_id: input.user_message_id, + user_message_text: input.user_message_text, + operation_id: input.operation_id, + submitted_at_micros: input.submitted_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .submit_custom_world_agent_message_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn finalize_custom_world_agent_message( + &self, + input: CustomWorldAgentMessageFinalizeRecordInput, + ) -> Result { + let procedure_input = CustomWorldAgentMessageFinalizeInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + operation_id: input.operation_id, + assistant_message_id: input.assistant_message_id, + assistant_reply_text: input.assistant_reply_text, + phase_label: input.phase_label, + phase_detail: input.phase_detail, + operation_status: parse_rpg_agent_operation_status_record( + input.operation_status.as_str(), + )?, + operation_progress: input.operation_progress, + stage: parse_rpg_agent_stage_record(input.stage.as_str())?, + progress_percent: input.progress_percent, + focus_card_id: input.focus_card_id, + anchor_content_json: input.anchor_content_json, + creator_intent_json: input.creator_intent_json, + creator_intent_readiness_json: input.creator_intent_readiness_json, + anchor_pack_json: input.anchor_pack_json, + draft_profile_json: input.draft_profile_json, + pending_clarifications_json: input.pending_clarifications_json, + suggested_actions_json: input.suggested_actions_json, + recommended_replies_json: input.recommended_replies_json, + quality_findings_json: input.quality_findings_json, + asset_coverage_json: input.asset_coverage_json, + error_message: input.error_message, + updated_at_micros: input.updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .finalize_custom_world_agent_message_turn_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn get_custom_world_agent_operation( + &self, + session_id: String, + owner_user_id: String, + operation_id: String, + ) -> Result { + let procedure_input = CustomWorldAgentOperationGetInput { + session_id, + owner_user_id, + operation_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_custom_world_agent_operation_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + +} diff --git a/server-rs/crates/spacetime-client/src/inventory.rs b/server-rs/crates/spacetime-client/src/inventory.rs new file mode 100644 index 00000000..06dfd71b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/inventory.rs @@ -0,0 +1,29 @@ +use super::*; + +impl SpacetimeClient { + pub async fn get_runtime_inventory_state( + &self, + runtime_session_id: String, + actor_user_id: String, + ) -> Result { + let procedure_input = map_runtime_inventory_state_query_input( + build_runtime_inventory_state_query_input(runtime_session_id, actor_user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_runtime_inventory_state_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_inventory_state_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + +} diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 0d7e432b..63f2b08b 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -2,6 +2,21 @@ #[rustfmt::skip] pub mod module_bindings; +mod mapper; +pub use mapper::*; + +pub mod ai; +pub mod assets; +pub mod auth; +pub mod big_fish; +pub mod combat; +pub mod custom_world; +pub mod inventory; +pub mod npc; +pub mod puzzle; +pub mod runtime; +pub mod story; + use std::{ error::Error, fmt, @@ -69,9 +84,11 @@ use module_puzzle::{ PuzzleWorkProfile as DomainPuzzleWorkProfile, }; use module_runtime::{ - RuntimeBrowseHistoryRecord, RuntimeBrowseHistoryThemeMode, RuntimePlatformTheme, + RuntimeBrowseHistoryRecord, RuntimeBrowseHistoryThemeMode as DomainRuntimeBrowseHistoryThemeMode, + RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileSaveArchiveRecord, - RuntimeProfileWalletLedgerEntryRecord, RuntimeProfileWalletLedgerSourceType, + RuntimeProfileWalletLedgerEntryRecord, + RuntimeProfileWalletLedgerSourceType as DomainRuntimeProfileWalletLedgerSourceType, RuntimeSettingsRecord, RuntimeSnapshotRecord, build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, build_runtime_browse_history_record, build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input, @@ -104,6 +121,7 @@ use tokio::{ time::timeout, }; +<<<<<<< HEAD use crate::module_bindings::{ AiResultReferenceInput as BindingAiResultReferenceInput, AiResultReferenceKind as BindingAiResultReferenceKind, @@ -360,6 +378,9 @@ use crate::module_bindings::{ upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return as _, upsert_runtime_snapshot_and_return_procedure::upsert_runtime_snapshot_and_return as _, }; +======= +use crate::module_bindings::*; +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) #[derive(Clone, Debug)] pub struct SpacetimeClientConfig { @@ -369,6 +390,12 @@ pub struct SpacetimeClientConfig { pub pool_size: u32, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthStoreSnapshotRecord { + pub snapshot_json: Option, + pub updated_at_micros: Option, +} + #[derive(Clone)] pub struct SpacetimeClient { config: SpacetimeClientConfig, @@ -431,6 +458,7 @@ impl SpacetimeClient { Self { config, pool } } +<<<<<<< HEAD pub async fn create_ai_task( &self, input: DomainAiTaskCreateInput, @@ -2362,6 +2390,8 @@ impl SpacetimeClient { .await } +======= +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) async fn call_after_connect( &self, call: impl FnOnce(&DbConnection, ProcedureResultSender) + Send + 'static, @@ -2596,6 +2626,7 @@ fn send_connect_once( } } +<<<<<<< HEAD fn map_entity_binding_input( input: module_assets::AssetEntityBindingInput, ) -> BindingAssetEntityBindingInput { @@ -7089,6 +7120,8 @@ fn map_inventory_item_source_kind( } } +======= +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) impl fmt::Display for SpacetimeClientError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs new file mode 100644 index 00000000..14dd697f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -0,0 +1,4494 @@ +use super::*; + +pub(crate) fn map_entity_binding_input( + input: module_assets::AssetEntityBindingInput, +) -> AssetEntityBindingInput { + AssetEntityBindingInput { + binding_id: input.binding_id, + asset_object_id: input.asset_object_id, + entity_kind: input.entity_kind, + entity_id: input.entity_id, + slot: input.slot, + asset_kind: input.asset_kind, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + updated_at_micros: input.updated_at_micros, + } +} + +pub(crate) fn map_upsert_input(input: module_assets::AssetObjectUpsertInput) -> AssetObjectUpsertInput { + AssetObjectUpsertInput { + asset_object_id: input.asset_object_id, + bucket: input.bucket, + object_key: input.object_key, + access_policy: map_access_policy(input.access_policy), + content_type: input.content_type, + content_length: input.content_length, + content_hash: input.content_hash, + version: input.version, + source_job_id: input.source_job_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + entity_id: input.entity_id, + asset_kind: input.asset_kind, + updated_at_micros: input.updated_at_micros, + } +} + +pub(crate) fn map_runtime_setting_get_input( + input: module_runtime::RuntimeSettingGetInput, +) -> RuntimeSettingGetInput { + RuntimeSettingGetInput { + user_id: input.user_id, + } +} + +pub(crate) fn map_runtime_setting_upsert_input( + input: module_runtime::RuntimeSettingUpsertInput, +) -> RuntimeSettingUpsertInput { + RuntimeSettingUpsertInput { + user_id: input.user_id, + music_volume: input.music_volume, + platform_theme: map_runtime_platform_theme(input.platform_theme), + updated_at_micros: input.updated_at_micros, + } +} + +pub(crate) fn map_runtime_browse_history_list_input( + input: module_runtime::RuntimeBrowseHistoryListInput, +) -> RuntimeBrowseHistoryListInput { + RuntimeBrowseHistoryListInput { + user_id: input.user_id, + } +} + +pub(crate) fn map_runtime_browse_history_clear_input( + input: module_runtime::RuntimeBrowseHistoryClearInput, +) -> RuntimeBrowseHistoryClearInput { + RuntimeBrowseHistoryClearInput { + user_id: input.user_id, + } +} + +pub(crate) fn map_runtime_browse_history_sync_input( + input: module_runtime::RuntimeBrowseHistorySyncInput, +) -> RuntimeBrowseHistorySyncInput { + RuntimeBrowseHistorySyncInput { + user_id: input.user_id, + entries: input + .entries + .into_iter() + .map(map_runtime_browse_history_write_input) + .collect(), + updated_at_micros: input.updated_at_micros, + } +} + +pub(crate) fn map_runtime_browse_history_write_input( + input: module_runtime::RuntimeBrowseHistoryWriteInput, +) -> RuntimeBrowseHistoryWriteInput { + RuntimeBrowseHistoryWriteInput { + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + world_name: input.world_name, + subtitle: input.subtitle, + summary_text: input.summary_text, + cover_image_src: input.cover_image_src, + theme_mode: input.theme_mode, + author_display_name: input.author_display_name, + visited_at: input.visited_at, + } +} + +pub(crate) fn map_runtime_profile_dashboard_get_input( + input: module_runtime::RuntimeProfileDashboardGetInput, +) -> RuntimeProfileDashboardGetInput { + RuntimeProfileDashboardGetInput { + user_id: input.user_id, + } +} + +pub(crate) fn map_runtime_profile_wallet_ledger_list_input( + input: module_runtime::RuntimeProfileWalletLedgerListInput, +) -> RuntimeProfileWalletLedgerListInput { + RuntimeProfileWalletLedgerListInput { + user_id: input.user_id, + } +} + +pub(crate) fn map_runtime_profile_play_stats_get_input( + input: module_runtime::RuntimeProfilePlayStatsGetInput, +) -> RuntimeProfilePlayStatsGetInput { + RuntimeProfilePlayStatsGetInput { + user_id: input.user_id, + } +} + +pub(crate) fn map_runtime_snapshot_get_input( + input: module_runtime::RuntimeSnapshotGetInput, +) -> RuntimeSnapshotGetInput { + RuntimeSnapshotGetInput { + user_id: input.user_id, + } +} + +pub(crate) fn map_runtime_snapshot_upsert_input( + input: module_runtime::RuntimeSnapshotUpsertInput, +) -> RuntimeSnapshotUpsertInput { + RuntimeSnapshotUpsertInput { + user_id: input.user_id, + saved_at_micros: input.saved_at_micros, + bottom_tab: input.bottom_tab, + game_state_json: input.game_state_json, + current_story_json: input.current_story_json, + updated_at_micros: input.updated_at_micros, + } +} + +pub(crate) fn map_runtime_snapshot_delete_input( + input: module_runtime::RuntimeSnapshotDeleteInput, +) -> RuntimeSnapshotDeleteInput { + RuntimeSnapshotDeleteInput { + user_id: input.user_id, + } +} + +pub(crate) fn map_runtime_profile_save_archive_list_input( + input: module_runtime::RuntimeProfileSaveArchiveListInput, +) -> RuntimeProfileSaveArchiveListInput { + RuntimeProfileSaveArchiveListInput { + user_id: input.user_id, + } +} + +pub(crate) fn map_runtime_profile_save_archive_resume_input( + input: module_runtime::RuntimeProfileSaveArchiveResumeInput, +) -> RuntimeProfileSaveArchiveResumeInput { + RuntimeProfileSaveArchiveResumeInput { + user_id: input.user_id, + world_key: input.world_key, + } +} + +pub(crate) fn map_ai_task_create_input(input: DomainAiTaskCreateInput) -> AiTaskCreateInput { + AiTaskCreateInput { + task_id: input.task_id, + task_kind: map_ai_task_kind(input.task_kind), + owner_user_id: input.owner_user_id, + request_label: input.request_label, + source_module: input.source_module, + source_entity_id: input.source_entity_id, + request_payload_json: input.request_payload_json, + stages: input + .stages + .into_iter() + .map(map_ai_task_stage_blueprint) + .collect(), + created_at_micros: input.created_at_micros, + } +} + +pub(crate) fn map_ai_task_start_input(input: DomainAiTaskStartInput) -> AiTaskStartInput { + AiTaskStartInput { + task_id: input.task_id, + started_at_micros: input.started_at_micros, + } +} + +pub(crate) fn map_ai_task_stage_start_input( + input: DomainAiTaskStageStartInput, +) -> AiTaskStageStartInput { + AiTaskStageStartInput { + task_id: input.task_id, + stage_kind: map_ai_task_stage_kind(input.stage_kind), + started_at_micros: input.started_at_micros, + } +} + +pub(crate) fn map_ai_text_chunk_append_input( + input: DomainAiTextChunkAppendInput, +) -> AiTextChunkAppendInput { + AiTextChunkAppendInput { + task_id: input.task_id, + stage_kind: map_ai_task_stage_kind(input.stage_kind), + sequence: input.sequence, + delta_text: input.delta_text, + created_at_micros: input.created_at_micros, + } +} + +pub(crate) fn map_ai_stage_completion_input( + input: DomainAiStageCompletionInput, +) -> AiStageCompletionInput { + AiStageCompletionInput { + task_id: input.task_id, + stage_kind: map_ai_task_stage_kind(input.stage_kind), + text_output: input.text_output, + structured_payload_json: input.structured_payload_json, + warning_messages: input.warning_messages, + completed_at_micros: input.completed_at_micros, + } +} + +pub(crate) fn map_ai_result_reference_input( + input: DomainAiResultReferenceInput, +) -> AiResultReferenceInput { + AiResultReferenceInput { + task_id: input.task_id, + reference_kind: map_ai_result_reference_kind(input.reference_kind), + reference_id: input.reference_id, + label: input.label, + created_at_micros: input.created_at_micros, + } +} + +pub(crate) fn map_ai_task_finish_input(input: DomainAiTaskFinishInput) -> AiTaskFinishInput { + AiTaskFinishInput { + task_id: input.task_id, + completed_at_micros: input.completed_at_micros, + } +} + +pub(crate) fn map_ai_task_failure_input(input: DomainAiTaskFailureInput) -> AiTaskFailureInput { + AiTaskFailureInput { + task_id: input.task_id, + failure_message: input.failure_message, + completed_at_micros: input.completed_at_micros, + } +} + +pub(crate) fn map_ai_task_cancel_input(input: DomainAiTaskCancelInput) -> AiTaskCancelInput { + AiTaskCancelInput { + task_id: input.task_id, + completed_at_micros: input.completed_at_micros, + } +} + +pub(crate) fn map_ai_task_stage_blueprint( + blueprint: DomainAiTaskStageBlueprint, +) -> AiTaskStageBlueprint { + AiTaskStageBlueprint { + stage_kind: map_ai_task_stage_kind(blueprint.stage_kind), + label: blueprint.label, + detail: blueprint.detail, + order: blueprint.order, + } +} + +pub(crate) fn map_custom_world_profile_upsert_input( + input: CustomWorldProfileUpsertRecordInput, +) -> CustomWorldProfileUpsertInput { + CustomWorldProfileUpsertInput { + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + public_work_code: input.public_work_code, + author_public_user_code: input.author_public_user_code, + source_agent_session_id: input.source_agent_session_id, + world_name: input.world_name, + subtitle: input.subtitle, + summary_text: input.summary_text, + theme_mode: map_custom_world_theme_mode(input.theme_mode), + cover_image_src: input.cover_image_src, + profile_payload_json: input.profile_payload_json, + playable_npc_count: input.playable_npc_count, + landmark_count: input.landmark_count, + author_display_name: input.author_display_name, + updated_at_micros: input.updated_at_micros, + } +} + +pub(crate) fn map_custom_world_publish_world_input( + input: CustomWorldPublishWorldRecordInput, +) -> CustomWorldPublishWorldInput { + CustomWorldPublishWorldInput { + session_id: input.session_id, + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + public_work_code: input.public_work_code, + author_public_user_code: input.author_public_user_code, + draft_profile_json: input.draft_profile_json, + legacy_result_profile_json: input.legacy_result_profile_json, + setting_text: input.setting_text, + author_display_name: input.author_display_name, + published_at_micros: input.published_at_micros, + } +} + +pub(crate) fn map_story_session_input(input: DomainStorySessionInput) -> StorySessionInput { + StorySessionInput { + story_session_id: input.story_session_id, + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + world_profile_id: input.world_profile_id, + initial_prompt: input.initial_prompt, + opening_summary: input.opening_summary, + created_at_micros: input.created_at_micros, + } +} + +pub(crate) fn map_story_continue_input(input: DomainStoryContinueInput) -> StoryContinueInput { + StoryContinueInput { + story_session_id: input.story_session_id, + event_id: input.event_id, + narrative_text: input.narrative_text, + choice_function_id: input.choice_function_id, + updated_at_micros: input.updated_at_micros, + } +} + +pub(crate) fn map_story_session_state_input( + input: DomainStorySessionStateInput, +) -> StorySessionStateInput { + StorySessionStateInput { + story_session_id: input.story_session_id, + } +} + +pub(crate) fn map_runtime_inventory_state_query_input( + input: DomainRuntimeInventoryStateQueryInput, +) -> RuntimeInventoryStateQueryInput { + RuntimeInventoryStateQueryInput { + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + } +} + +pub(crate) fn map_battle_state_query_input( + input: DomainBattleStateQueryInput, +) -> BattleStateQueryInput { + BattleStateQueryInput { + battle_state_id: input.battle_state_id, + } +} + +pub(crate) fn map_battle_state_input(input: DomainBattleStateInput) -> BattleStateInput { + BattleStateInput { + battle_state_id: input.battle_state_id, + story_session_id: input.story_session_id, + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + chapter_id: input.chapter_id, + target_npc_id: input.target_npc_id, + target_name: input.target_name, + battle_mode: map_battle_mode(input.battle_mode), + player_hp: input.player_hp, + player_max_hp: input.player_max_hp, + player_mana: input.player_mana, + player_max_mana: input.player_max_mana, + target_hp: input.target_hp, + target_max_hp: input.target_max_hp, + experience_reward: input.experience_reward, + reward_items: input + .reward_items + .into_iter() + .map(map_runtime_item_reward_item_snapshot) + .collect(), + created_at_micros: input.created_at_micros, + } +} + +pub(crate) fn map_resolve_combat_action_input( + input: DomainResolveCombatActionInput, +) -> ResolveCombatActionInput { + ResolveCombatActionInput { + battle_state_id: input.battle_state_id, + function_id: input.function_id, + action_text: input.action_text, + base_damage: input.base_damage, + mana_cost: input.mana_cost, + heal: input.heal, + mana_restore: input.mana_restore, + counter_multiplier_basis_points: input.counter_multiplier_basis_points, + updated_at_micros: input.updated_at_micros, + } +} + +pub(crate) fn map_procedure_result( + result: AssetObjectProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回对象快照".to_string()) + })?; + + Ok(build_asset_object_record(map_snapshot(snapshot))) +} + +pub(crate) fn map_entity_binding_procedure_result( + result: AssetEntityBindingProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回绑定快照".to_string()) + })?; + + Ok(build_asset_entity_binding_record( + map_entity_binding_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_setting_procedure_result( + result: RuntimeSettingProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 runtime settings 快照".to_string(), + ) + })?; + + Ok(build_runtime_setting_record(map_runtime_setting_snapshot( + snapshot, + ))) +} + +pub(crate) fn map_auth_store_snapshot_procedure_result( + result: AuthStoreSnapshotProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let record = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回认证快照".to_string()) + })?; + + Ok(map_auth_store_snapshot_record(record)) +} + +pub(crate) fn map_auth_store_snapshot_record( + record: AuthStoreSnapshotRecord, +) -> AuthStoreSnapshotRecord { + AuthStoreSnapshotRecord { + snapshot_json: record.snapshot_json, + updated_at_micros: record.updated_at_micros, + } +} + +pub(crate) fn map_runtime_browse_history_procedure_result( + result: RuntimeBrowseHistoryProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + Ok(result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_browse_history_record(map_runtime_browse_history_snapshot(snapshot)) + }) + .collect()) +} + +pub(crate) fn map_runtime_profile_dashboard_procedure_result( + result: RuntimeProfileDashboardProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 profile dashboard 快照".to_string(), + ) + })?; + + Ok(build_runtime_profile_dashboard_record( + map_runtime_profile_dashboard_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_wallet_ledger_procedure_result( + result: RuntimeProfileWalletLedgerProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + Ok(result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_wallet_ledger_entry_record( + map_runtime_profile_wallet_ledger_entry_snapshot(snapshot), + ) + }) + .collect()) +} + +pub(crate) fn map_runtime_profile_play_stats_procedure_result( + result: RuntimeProfilePlayStatsProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 profile play stats 快照".to_string(), + ) + })?; + + Ok(build_runtime_profile_play_stats_record( + map_runtime_profile_play_stats_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_snapshot_procedure_result( + result: RuntimeSnapshotProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + result + .record + .map(|snapshot| { + build_runtime_snapshot_record(map_runtime_snapshot_snapshot(snapshot)) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string())) + }) + .transpose() +} + +pub(crate) fn map_runtime_snapshot_required_procedure_result( + result: RuntimeSnapshotProcedureResult, +) -> Result { + map_runtime_snapshot_procedure_result(result)?.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 runtime snapshot 快照".to_string(), + ) + }) +} + +pub(crate) fn map_runtime_snapshot_delete_procedure_result( + result: RuntimeSnapshotProcedureResult, +) -> Result { + map_runtime_snapshot_procedure_result(result).map(|record| record.is_some()) +} + +pub(crate) fn map_runtime_profile_save_archive_list_procedure_result( + result: RuntimeProfileSaveArchiveProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_save_archive_record(map_runtime_profile_save_archive_snapshot( + snapshot, + )) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string())) + }) + .collect() +} + +pub(crate) fn map_runtime_profile_save_archive_resume_procedure_result( + result: RuntimeProfileSaveArchiveProcedureResult, +) -> Result<(RuntimeProfileSaveArchiveRecord, RuntimeSnapshotRecord), SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let archive = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 save archive 快照".to_string(), + ) + })?; + let snapshot = result.current_snapshot.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回恢复后的 runtime snapshot".to_string(), + ) + })?; + + Ok(( + build_runtime_profile_save_archive_record(map_runtime_profile_save_archive_snapshot( + archive, + )) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + build_runtime_snapshot_record(map_runtime_snapshot_snapshot(snapshot)) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + )) +} + +pub(crate) fn map_ai_task_procedure_result( + result: AiTaskProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Runtime( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let task = result.task.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 ai_task 快照".to_string()) + })?; + + Ok(AiTaskMutationRecord { + task: map_ai_task_snapshot(task), + text_chunk: result.text_chunk.map(map_ai_text_chunk_snapshot), + }) +} + +pub(crate) fn map_custom_world_profile_list_result( + result: CustomWorldProfileListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + result + .entries + .into_iter() + .map(map_custom_world_library_entry_from_profile_snapshot) + .collect() +} + +pub(crate) fn map_custom_world_library_detail_result( + result: CustomWorldLibraryMutationResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let entry = result + .entry + .ok_or_else(|| SpacetimeClientError::Procedure("custom_world_profile 不存在".to_string())) + .and_then(map_custom_world_library_entry_from_profile_snapshot)?; + let gallery_entry = result + .gallery_entry + .map(map_custom_world_gallery_entry_snapshot) + .transpose()?; + + Ok(CustomWorldLibraryMutationRecord { + entry, + gallery_entry, + }) +} + +pub(crate) fn map_custom_world_gallery_list_result( + result: CustomWorldGalleryListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + Ok(result + .entries + .into_iter() + .map(map_custom_world_gallery_entry_snapshot) + .collect::, _>>()?) +} + +pub(crate) fn map_custom_world_library_mutation_result( + result: CustomWorldLibraryMutationResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let entry = result + .entry + .ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB 未返回 custom world entry".to_string()) + }) + .and_then(map_custom_world_library_entry_from_profile_snapshot)?; + let gallery_entry = result + .gallery_entry + .map(map_custom_world_gallery_entry_snapshot) + .transpose()?; + + Ok(CustomWorldLibraryMutationRecord { + entry, + gallery_entry, + }) +} + +pub(crate) fn map_custom_world_publish_world_result( + result: CustomWorldPublishWorldResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let compiled_record = result + .compiled_record + .ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 published profile compile 快照".to_string(), + ) + }) + .and_then(map_custom_world_published_profile_compile_snapshot)?; + let entry = result + .entry + .ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB 未返回 custom world entry".to_string()) + }) + .and_then(map_custom_world_library_entry_from_profile_snapshot)?; + let gallery_entry = result + .gallery_entry + .map(map_custom_world_gallery_entry_snapshot) + .transpose()?; + let session_stage = result + .session_stage + .ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB 未返回 session stage".to_string()) + }) + .map(map_rpg_agent_stage)?; + + Ok(CustomWorldPublishWorldRecord { + compiled_record, + entry, + gallery_entry, + session_stage, + }) +} + +pub(crate) fn map_custom_world_agent_session_procedure_result( + result: CustomWorldAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let session = result.session.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 custom world agent session 快照".to_string(), + ) + })?; + + map_custom_world_agent_session_snapshot(session) +} + +pub(crate) fn map_custom_world_agent_operation_procedure_result( + result: CustomWorldAgentOperationProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let operation = result.operation.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 custom world agent operation 快照".to_string(), + ) + })?; + + Ok(map_custom_world_agent_operation_snapshot(operation)) +} + +pub(crate) fn map_custom_world_works_list_result( + result: CustomWorldWorksListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + result + .items + .into_iter() + .map(map_custom_world_work_summary_snapshot) + .collect() +} + +pub(crate) fn map_custom_world_draft_card_detail_result( + result: CustomWorldDraftCardDetailResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let card = result.card.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 custom world card detail 快照".to_string(), + ) + })?; + + map_custom_world_draft_card_detail_snapshot(card) +} + +pub(crate) fn map_custom_world_agent_action_execute_result( + result: CustomWorldAgentActionExecuteResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let operation = result.operation.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 custom world action operation 快照".to_string(), + ) + })?; + + Ok(CustomWorldAgentActionExecuteRecord { + operation: map_custom_world_agent_operation_snapshot(operation), + }) +} + +pub(crate) fn map_puzzle_agent_session_procedure_result( + result: PuzzleAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let session_json = result.session_json.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 puzzle agent session 快照".to_string(), + ) + })?; + let session: DomainPuzzleAgentSessionSnapshot = + serde_json::from_str(&session_json).map_err(|error| { + SpacetimeClientError::Runtime(format!("puzzle agent session_json 非法: {error}")) + })?; + Ok(map_puzzle_agent_session_snapshot(session)) +} + +pub(crate) fn map_puzzle_work_procedure_result( + result: PuzzleWorkProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let item_json = result.item_json.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 puzzle work 快照".to_string()) + })?; + let item: DomainPuzzleWorkProfile = serde_json::from_str(&item_json).map_err(|error| { + SpacetimeClientError::Runtime(format!("puzzle work item_json 非法: {error}")) + })?; + Ok(map_puzzle_work_profile(item)) +} + +pub(crate) fn map_puzzle_works_procedure_result( + result: PuzzleWorksProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let items_json = result.items_json.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 puzzle works 快照".to_string(), + ) + })?; + let items: Vec = + serde_json::from_str(&items_json).map_err(|error| { + SpacetimeClientError::Runtime(format!("puzzle works items_json 非法: {error}")) + })?; + Ok(items.into_iter().map(map_puzzle_work_profile).collect()) +} + +pub(crate) fn map_puzzle_run_procedure_result( + result: PuzzleRunProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let run_json = result.run_json.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 puzzle run 快照".to_string()) + })?; + let run: DomainPuzzleRunSnapshot = serde_json::from_str(&run_json).map_err(|error| { + SpacetimeClientError::Runtime(format!("puzzle run run_json 非法: {error}")) + })?; + Ok(map_puzzle_run_snapshot(run)) +} + +pub(crate) fn map_big_fish_session_procedure_result( + result: BigFishSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let session = result.session.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 big fish session 快照".to_string(), + ) + })?; + + Ok(map_big_fish_session_snapshot(session)) +} + +pub(crate) fn map_big_fish_works_procedure_result( + result: BigFishWorksProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let items_json = result.items_json.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 big fish works 快照".to_string(), + ) + })?; + serde_json::from_str::>(&items_json).map_err(|error| { + SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}")) + }) +} + +pub(crate) fn map_big_fish_run_procedure_result( + result: BigFishRunProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let run = result.run.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 big fish runtime 快照".to_string(), + ) + })?; + + Ok(map_big_fish_runtime_snapshot(run)) +} + +pub(crate) fn map_story_session_procedure_result( + result: StorySessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let session = result.session.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 story session 快照".to_string(), + ) + })?; + let event = result.event.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 story event 快照".to_string()) + })?; + + Ok(StorySessionResultRecord { + session: map_story_session_snapshot(session), + event: map_story_event_snapshot(event), + }) +} + +pub(crate) fn map_story_session_state_procedure_result( + result: StorySessionStateProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let session = result.session.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 story session state 快照".to_string(), + ) + })?; + + Ok(StorySessionStateRecord { + session: map_story_session_snapshot(session), + events: result + .events + .into_iter() + .map(map_story_event_snapshot) + .collect(), + }) +} + +pub(crate) fn map_runtime_inventory_state_procedure_result( + result: RuntimeInventoryStateProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.snapshot.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 runtime inventory state 快照".to_string(), + ) + })?; + + Ok(build_runtime_inventory_state_record( + map_runtime_inventory_state_snapshot(snapshot), + )) +} + +pub(crate) fn map_battle_state_procedure_result( + result: BattleStateProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.snapshot.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 battle_state 快照".to_string(), + ) + })?; + + Ok(build_battle_state_record(map_battle_state_snapshot( + snapshot, + ))) +} + +pub(crate) fn map_resolve_combat_action_procedure_result( + result: ResolveCombatActionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let action_result = result.result.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回战斗结算结果".to_string()) + })?; + + Ok(build_resolve_combat_action_record( + map_resolve_combat_action_result(action_result), + )) +} + +pub(crate) fn map_npc_battle_interaction_procedure_result( + result: NpcBattleInteractionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let interaction_result = result.result.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 NPC 开战结果".to_string()) + })?; + + Ok(build_npc_battle_interaction_record( + map_npc_battle_interaction_result(interaction_result), + )) +} + +pub(crate) fn map_entity_binding_snapshot( + snapshot: AssetEntityBindingSnapshot, +) -> module_assets::AssetEntityBindingSnapshot { + module_assets::AssetEntityBindingSnapshot { + binding_id: snapshot.binding_id, + asset_object_id: snapshot.asset_object_id, + entity_kind: snapshot.entity_kind, + entity_id: snapshot.entity_id, + slot: snapshot.slot, + asset_kind: snapshot.asset_kind, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_snapshot( + snapshot: AssetObjectUpsertSnapshot, +) -> module_assets::AssetObjectUpsertSnapshot { + module_assets::AssetObjectUpsertSnapshot { + asset_object_id: snapshot.asset_object_id, + bucket: snapshot.bucket, + object_key: snapshot.object_key, + access_policy: map_access_policy_back(snapshot.access_policy), + content_type: snapshot.content_type, + content_length: snapshot.content_length, + content_hash: snapshot.content_hash, + version: snapshot.version, + source_job_id: snapshot.source_job_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + entity_id: snapshot.entity_id, + asset_kind: snapshot.asset_kind, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_setting_snapshot( + snapshot: RuntimeSettingSnapshot, +) -> module_runtime::RuntimeSettingSnapshot { + module_runtime::RuntimeSettingSnapshot { + user_id: snapshot.user_id, + music_volume: snapshot.music_volume, + platform_theme: map_runtime_platform_theme_back(snapshot.platform_theme), + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_browse_history_snapshot( + snapshot: RuntimeBrowseHistorySnapshot, +) -> module_runtime::RuntimeBrowseHistorySnapshot { + module_runtime::RuntimeBrowseHistorySnapshot { + browse_history_id: snapshot.browse_history_id, + user_id: snapshot.user_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + theme_mode: map_runtime_browse_history_theme_mode_back(snapshot.theme_mode), + author_display_name: snapshot.author_display_name, + visited_at_micros: snapshot.visited_at_micros, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_dashboard_snapshot( + snapshot: RuntimeProfileDashboardSnapshot, +) -> module_runtime::RuntimeProfileDashboardSnapshot { + module_runtime::RuntimeProfileDashboardSnapshot { + user_id: snapshot.user_id, + wallet_balance: snapshot.wallet_balance, + total_play_time_ms: snapshot.total_play_time_ms, + played_world_count: snapshot.played_world_count, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_wallet_ledger_entry_snapshot( + snapshot: RuntimeProfileWalletLedgerEntrySnapshot, +) -> module_runtime::RuntimeProfileWalletLedgerEntrySnapshot { + module_runtime::RuntimeProfileWalletLedgerEntrySnapshot { + wallet_ledger_id: snapshot.wallet_ledger_id, + user_id: snapshot.user_id, + amount_delta: snapshot.amount_delta, + balance_after: snapshot.balance_after, + source_type: map_runtime_profile_wallet_ledger_source_type(snapshot.source_type), + created_at_micros: snapshot.created_at_micros, + } +} + +pub(crate) fn map_runtime_profile_played_world_snapshot( + snapshot: RuntimeProfilePlayedWorldSnapshot, +) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { + module_runtime::RuntimeProfilePlayedWorldSnapshot { + played_world_id: snapshot.played_world_id, + user_id: snapshot.user_id, + world_key: snapshot.world_key, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + world_type: snapshot.world_type, + world_title: snapshot.world_title, + world_subtitle: snapshot.world_subtitle, + first_played_at_micros: snapshot.first_played_at_micros, + last_played_at_micros: snapshot.last_played_at_micros, + last_observed_play_time_ms: snapshot.last_observed_play_time_ms, + } +} + +pub(crate) fn map_runtime_profile_play_stats_snapshot( + snapshot: RuntimeProfilePlayStatsSnapshot, +) -> module_runtime::RuntimeProfilePlayStatsSnapshot { + module_runtime::RuntimeProfilePlayStatsSnapshot { + user_id: snapshot.user_id, + total_play_time_ms: snapshot.total_play_time_ms, + played_works: snapshot + .played_works + .into_iter() + .map(map_runtime_profile_played_world_snapshot) + .collect(), + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_snapshot_snapshot( + snapshot: RuntimeSnapshot, +) -> module_runtime::RuntimeSnapshot { + module_runtime::RuntimeSnapshot { + user_id: snapshot.user_id, + version: snapshot.version, + saved_at_micros: snapshot.saved_at_micros, + bottom_tab: snapshot.bottom_tab, + game_state_json: snapshot.game_state_json, + current_story_json: snapshot.current_story_json, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_save_archive_snapshot( + snapshot: RuntimeProfileSaveArchiveSnapshot, +) -> module_runtime::RuntimeProfileSaveArchiveSnapshot { + module_runtime::RuntimeProfileSaveArchiveSnapshot { + archive_id: snapshot.archive_id, + user_id: snapshot.user_id, + world_key: snapshot.world_key, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + world_type: snapshot.world_type, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + saved_at_micros: snapshot.saved_at_micros, + bottom_tab: snapshot.bottom_tab, + game_state_json: snapshot.game_state_json, + current_story_json: snapshot.current_story_json, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_custom_world_library_entry_from_profile_snapshot( + snapshot: CustomWorldProfileSnapshot, +) -> Result { + let profile = serde_json::from_str::(&snapshot.profile_payload_json) + .map_err(|error| { + SpacetimeClientError::Runtime(format!( + "custom world profile payload JSON 非法: {error}" + )) + })?; + + Ok(CustomWorldLibraryEntryRecord { + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + public_work_code: snapshot.public_work_code, + author_public_user_code: snapshot.author_public_user_code, + profile, + visibility: map_custom_world_publication_status(snapshot.publication_status).to_string(), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + author_display_name: snapshot.author_display_name, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( + snapshot.theme_mode, + )) + .to_string(), + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + }) +} + +pub(crate) fn map_custom_world_gallery_entry_snapshot( + snapshot: CustomWorldGalleryEntrySnapshot, +) -> Result { + Ok(CustomWorldGalleryEntryRecord { + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + public_work_code: snapshot.public_work_code, + author_public_user_code: snapshot.author_public_user_code, + visibility: "published".to_string(), + published_at: Some(format_timestamp_micros(snapshot.published_at_micros)), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + author_display_name: snapshot.author_display_name, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( + snapshot.theme_mode, + )) + .to_string(), + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + }) +} + +pub(crate) fn map_custom_world_published_profile_compile_snapshot( + snapshot: CustomWorldPublishedProfileCompileSnapshot, +) -> Result { + let compiled_profile = + serde_json::from_str::(&snapshot.compiled_profile_payload_json) + .map_err(|error| { + SpacetimeClientError::Runtime(format!( + "published profile compile JSON 非法: {error}" + )) + })?; + + Ok(CustomWorldPublishedProfileCompileRecord { + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( + snapshot.theme_mode, + )) + .to_string(), + cover_image_src: snapshot.cover_image_src, + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + author_display_name: snapshot.author_display_name, + compiled_profile: compiled_profile, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + }) +} + +pub(crate) fn map_custom_world_work_summary_snapshot( + snapshot: CustomWorldWorkSummarySnapshot, +) -> Result { + Ok(CustomWorldWorkSummaryRecord { + work_id: snapshot.work_id, + source_type: snapshot.source_type, + status: snapshot.status, + title: snapshot.title, + subtitle: snapshot.subtitle, + summary: snapshot.summary, + cover_image_src: snapshot.cover_image_src, + cover_render_mode: snapshot.cover_render_mode, + cover_character_image_srcs: parse_json_string_array( + &snapshot.cover_character_image_srcs_json, + "custom world work cover_character_image_srcs_json", + )?, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + stage: snapshot.stage.map(map_rpg_agent_stage), + stage_label: snapshot.stage_label, + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + role_visual_ready_count: snapshot.role_visual_ready_count, + role_animation_ready_count: snapshot.role_animation_ready_count, + role_asset_summary_label: snapshot.role_asset_summary_label, + session_id: snapshot.session_id, + profile_id: snapshot.profile_id, + can_resume: snapshot.can_resume, + can_enter_world: snapshot.can_enter_world, + blocker_count: snapshot.blocker_count, + publish_ready: snapshot.publish_ready, + }) +} + +pub(crate) fn map_custom_world_agent_session_snapshot( + snapshot: CustomWorldAgentSessionSnapshot, +) -> Result { + let anchor_content = parse_json_value( + &snapshot.anchor_content_json, + "custom world agent anchor_content_json", + )?; + let creator_intent = parse_optional_json_value( + snapshot.creator_intent_json.as_deref(), + serde_json::json!({}), + "custom world agent creator_intent_json", + )?; + let creator_intent_readiness = parse_json_value( + &snapshot.creator_intent_readiness_json, + "custom world agent creator_intent_readiness_json", + )?; + let anchor_pack = parse_optional_json_value( + snapshot.anchor_pack_json.as_deref(), + serde_json::json!({}), + "custom world agent anchor_pack_json", + )?; + let lock_state = parse_optional_json_value( + snapshot.lock_state_json.as_deref(), + serde_json::json!({}), + "custom world agent lock_state_json", + )?; + let draft_profile = parse_optional_json_value( + snapshot.draft_profile_json.as_deref(), + serde_json::json!({}), + "custom world agent draft_profile_json", + )?; + let pending_clarifications = parse_json_array( + &snapshot.pending_clarifications_json, + "custom world agent pending_clarifications_json", + )?; + let suggested_actions = parse_json_array( + &snapshot.suggested_actions_json, + "custom world agent suggested_actions_json", + )?; + let recommended_replies = parse_json_string_array( + &snapshot.recommended_replies_json, + "custom world agent recommended_replies_json", + )?; + let quality_findings = parse_json_array( + &snapshot.quality_findings_json, + "custom world agent quality_findings_json", + )?; + let asset_coverage = parse_json_value( + &snapshot.asset_coverage_json, + "custom world agent asset_coverage_json", + )?; + let checkpoints_json = parse_json_array( + &snapshot.checkpoints_json, + "custom world agent checkpoints_json", + )?; + let checkpoints = checkpoints_json + .into_iter() + .map(map_custom_world_checkpoint_record) + .collect::, _>>()?; + let supported_actions = parse_supported_actions_json(&snapshot.supported_actions_json)?; + let publish_gate = snapshot + .publish_gate_json + .as_deref() + .map(parse_custom_world_publish_gate_record) + .transpose()?; + + Ok(CustomWorldAgentSessionRecord { + session_id: snapshot.session_id, + seed_text: snapshot.seed_text, + current_turn: snapshot.current_turn, + anchor_content, + progress_percent: snapshot.progress_percent, + last_assistant_reply: snapshot.last_assistant_reply, + stage: map_rpg_agent_stage(snapshot.stage), + focus_card_id: snapshot.focus_card_id, + creator_intent, + creator_intent_readiness, + anchor_pack, + lock_state, + draft_profile, + messages: snapshot + .messages + .into_iter() + .map(map_custom_world_agent_message_snapshot) + .collect(), + draft_cards: snapshot + .draft_cards + .into_iter() + .map(map_custom_world_draft_card_snapshot) + .collect::, _>>()?, + pending_clarifications, + suggested_actions, + recommended_replies, + quality_findings, + asset_coverage, + checkpoints, + supported_actions, + publish_gate, + result_preview: snapshot + .result_preview_json + .as_deref() + .map(|value| parse_json_value(value, "custom world agent result_preview_json")) + .transpose()?, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + }) +} + +pub(crate) fn map_custom_world_agent_message_snapshot( + snapshot: CustomWorldAgentMessageSnapshot, +) -> CustomWorldAgentMessageRecord { + CustomWorldAgentMessageRecord { + message_id: snapshot.message_id, + role: format_rpg_agent_message_role(snapshot.role).to_string(), + kind: format_rpg_agent_message_kind(snapshot.kind).to_string(), + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + related_operation_id: snapshot.related_operation_id, + } +} + +pub(crate) fn map_custom_world_agent_operation_snapshot( + snapshot: CustomWorldAgentOperationSnapshot, +) -> CustomWorldAgentOperationRecord { + CustomWorldAgentOperationRecord { + operation_id: snapshot.operation_id, + operation_type: format_rpg_agent_operation_type(snapshot.operation_type).to_string(), + status: format_rpg_agent_operation_status(snapshot.status).to_string(), + phase_label: snapshot.phase_label, + phase_detail: snapshot.phase_detail, + progress: snapshot.progress, + error_message: snapshot.error_message, + } +} + +pub(crate) fn map_custom_world_draft_card_snapshot( + snapshot: CustomWorldDraftCardSnapshot, +) -> Result { + Ok(CustomWorldDraftCardRecord { + card_id: snapshot.card_id, + kind: format_rpg_agent_draft_card_kind(snapshot.kind).to_string(), + title: snapshot.title, + subtitle: snapshot.subtitle, + summary: snapshot.summary, + status: format_rpg_agent_draft_card_status(snapshot.status).to_string(), + linked_ids: parse_json_string_array( + &snapshot.linked_ids_json, + "custom world draft_card linked_ids_json", + )?, + warning_count: snapshot.warning_count, + asset_status: snapshot + .asset_status + .map(format_custom_world_role_asset_status_back), + asset_status_label: snapshot.asset_status_label, + detail_payload: snapshot + .detail_payload_json + .as_deref() + .map(|value| parse_json_value(value, "custom world draft_card detail_payload_json")) + .transpose()?, + }) +} + +pub(crate) fn map_custom_world_draft_card_detail_snapshot( + snapshot: CustomWorldDraftCardDetailSnapshot, +) -> Result { + Ok(CustomWorldDraftCardDetailRecord { + card_id: snapshot.card_id, + kind: format_rpg_agent_draft_card_kind(snapshot.kind).to_string(), + title: snapshot.title, + sections: snapshot + .sections + .into_iter() + .map(map_custom_world_draft_card_detail_section_snapshot) + .collect(), + linked_ids: parse_json_string_array( + &snapshot.linked_ids_json, + "custom world card detail linked_ids_json", + )?, + locked: snapshot.locked, + editable: snapshot.editable, + editable_section_ids: parse_json_string_array( + &snapshot.editable_section_ids_json, + "custom world card detail editable_section_ids_json", + )?, + warning_messages: parse_json_string_array( + &snapshot.warning_messages_json, + "custom world card detail warning_messages_json", + )?, + asset_status: snapshot + .asset_status + .map(format_custom_world_role_asset_status_back), + asset_status_label: snapshot.asset_status_label, + }) +} + +pub(crate) fn map_custom_world_draft_card_detail_section_snapshot( + snapshot: CustomWorldDraftCardDetailSectionSnapshot, +) -> CustomWorldDraftCardDetailSectionRecord { + CustomWorldDraftCardDetailSectionRecord { + section_id: snapshot.section_id, + label: snapshot.label, + value: snapshot.value, + } +} + +pub(crate) fn map_big_fish_session_snapshot(snapshot: BigFishSessionSnapshot) -> BigFishSessionRecord { + BigFishSessionRecord { + session_id: snapshot.session_id, + current_turn: snapshot.current_turn, + progress_percent: snapshot.progress_percent, + stage: format_big_fish_creation_stage(snapshot.stage).to_string(), + anchor_pack: map_big_fish_anchor_pack(snapshot.anchor_pack), + draft: snapshot.draft.map(map_big_fish_game_draft), + asset_slots: snapshot + .asset_slots + .into_iter() + .map(map_big_fish_asset_slot_snapshot) + .collect(), + asset_coverage: map_big_fish_asset_coverage(snapshot.asset_coverage), + messages: snapshot + .messages + .into_iter() + .map(map_big_fish_agent_message_snapshot) + .collect(), + last_assistant_reply: snapshot.last_assistant_reply, + publish_ready: snapshot.publish_ready, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn map_puzzle_agent_session_snapshot( + snapshot: DomainPuzzleAgentSessionSnapshot, +) -> PuzzleAgentSessionRecord { + PuzzleAgentSessionRecord { + session_id: snapshot.session_id, + current_turn: snapshot.current_turn, + progress_percent: snapshot.progress_percent, + stage: snapshot.stage.as_str().to_string(), + anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), + draft: snapshot.draft.map(map_puzzle_result_draft), + messages: snapshot + .messages + .into_iter() + .map(map_puzzle_agent_message_snapshot) + .collect(), + last_assistant_reply: snapshot.last_assistant_reply, + published_profile_id: snapshot.published_profile_id, + suggested_actions: snapshot + .suggested_actions + .into_iter() + .map(map_puzzle_suggested_action) + .collect(), + result_preview: snapshot.result_preview.map(map_puzzle_result_preview), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn map_puzzle_anchor_pack(snapshot: DomainPuzzleAnchorPack) -> PuzzleAnchorPackRecord { + PuzzleAnchorPackRecord { + theme_promise: map_puzzle_anchor_item(snapshot.theme_promise), + visual_subject: map_puzzle_anchor_item(snapshot.visual_subject), + visual_mood: map_puzzle_anchor_item(snapshot.visual_mood), + composition_hooks: map_puzzle_anchor_item(snapshot.composition_hooks), + tags_and_forbidden: map_puzzle_anchor_item(snapshot.tags_and_forbidden), + } +} + +pub(crate) fn map_puzzle_anchor_item(snapshot: DomainPuzzleAnchorItem) -> PuzzleAnchorItemRecord { + PuzzleAnchorItemRecord { + key: snapshot.key, + label: snapshot.label, + value: snapshot.value, + status: snapshot.status.as_str().to_string(), + } +} + +pub(crate) fn map_puzzle_result_draft(snapshot: DomainPuzzleResultDraft) -> PuzzleResultDraftRecord { + PuzzleResultDraftRecord { + level_name: snapshot.level_name, + summary: snapshot.summary, + theme_tags: snapshot.theme_tags, + forbidden_directives: snapshot.forbidden_directives, + creator_intent: snapshot.creator_intent.map(map_puzzle_creator_intent), + anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), + candidates: snapshot + .candidates + .into_iter() + .map(map_puzzle_generated_image_candidate) + .collect(), + selected_candidate_id: snapshot.selected_candidate_id, + cover_image_src: snapshot.cover_image_src, + cover_asset_id: snapshot.cover_asset_id, + generation_status: snapshot.generation_status, + } +} + +pub(crate) fn map_puzzle_creator_intent(snapshot: DomainPuzzleCreatorIntent) -> PuzzleCreatorIntentRecord { + PuzzleCreatorIntentRecord { + source_mode: snapshot.source_mode, + raw_messages_summary: snapshot.raw_messages_summary, + theme_promise: snapshot.theme_promise, + visual_subject: snapshot.visual_subject, + visual_mood: snapshot.visual_mood, + composition_hooks: snapshot.composition_hooks, + theme_tags: snapshot.theme_tags, + forbidden_directives: snapshot.forbidden_directives, + } +} + +pub(crate) fn map_puzzle_generated_image_candidate( + snapshot: DomainPuzzleGeneratedImageCandidate, +) -> PuzzleGeneratedImageCandidateRecord { + PuzzleGeneratedImageCandidateRecord { + candidate_id: snapshot.candidate_id, + image_src: snapshot.image_src, + asset_id: snapshot.asset_id, + prompt: snapshot.prompt, + actual_prompt: snapshot.actual_prompt, + source_type: snapshot.source_type, + selected: snapshot.selected, + } +} + +pub(crate) fn map_puzzle_agent_message_snapshot( + snapshot: DomainPuzzleAgentMessageSnapshot, +) -> PuzzleAgentMessageRecord { + PuzzleAgentMessageRecord { + message_id: snapshot.message_id, + role: snapshot.role.as_str().to_string(), + kind: snapshot.kind.as_str().to_string(), + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +pub(crate) fn map_puzzle_suggested_action( + snapshot: DomainPuzzleAgentSuggestedAction, +) -> PuzzleAgentSuggestedActionRecord { + PuzzleAgentSuggestedActionRecord { + action_id: snapshot.id, + action_type: snapshot.action_type, + label: snapshot.label, + } +} + +pub(crate) fn map_puzzle_result_preview( + snapshot: DomainPuzzleResultPreviewEnvelope, +) -> PuzzleResultPreviewRecord { + PuzzleResultPreviewRecord { + draft: map_puzzle_result_draft(snapshot.draft), + blockers: snapshot + .blockers + .into_iter() + .map(map_puzzle_result_preview_blocker) + .collect(), + quality_findings: snapshot + .quality_findings + .into_iter() + .map(map_puzzle_result_preview_finding) + .collect(), + publish_ready: snapshot.publish_ready, + } +} + +pub(crate) fn map_puzzle_result_preview_blocker( + snapshot: DomainPuzzleResultPreviewBlocker, +) -> PuzzleResultPreviewBlockerRecord { + PuzzleResultPreviewBlockerRecord { + blocker_id: snapshot.id, + code: snapshot.code, + message: snapshot.message, + } +} + +pub(crate) fn map_puzzle_result_preview_finding( + snapshot: DomainPuzzleResultPreviewFinding, +) -> PuzzleResultPreviewFindingRecord { + PuzzleResultPreviewFindingRecord { + finding_id: snapshot.id, + severity: snapshot.severity, + code: snapshot.code, + message: snapshot.message, + } +} + +pub(crate) fn map_puzzle_work_profile(snapshot: DomainPuzzleWorkProfile) -> PuzzleWorkProfileRecord { + PuzzleWorkProfileRecord { + work_id: snapshot.work_id, + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + source_session_id: snapshot.source_session_id, + author_display_name: snapshot.author_display_name, + level_name: snapshot.level_name, + summary: snapshot.summary, + theme_tags: snapshot.theme_tags, + cover_image_src: snapshot.cover_image_src, + cover_asset_id: snapshot.cover_asset_id, + publication_status: snapshot.publication_status.as_str().to_string(), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + play_count: snapshot.play_count, + publish_ready: snapshot.publish_ready, + anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), + } +} + +pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> PuzzleRunRecord { + PuzzleRunRecord { + run_id: snapshot.run_id, + entry_profile_id: snapshot.entry_profile_id, + cleared_level_count: snapshot.cleared_level_count, + current_level_index: snapshot.current_level_index, + current_grid_size: snapshot.current_grid_size, + played_profile_ids: snapshot.played_profile_ids, + previous_level_tags: snapshot.previous_level_tags, + current_level: snapshot + .current_level + .map(map_puzzle_runtime_level_snapshot), + recommended_next_profile_id: snapshot.recommended_next_profile_id, + } +} + +pub(crate) fn map_puzzle_runtime_level_snapshot( + snapshot: DomainPuzzleRuntimeLevelSnapshot, +) -> PuzzleRuntimeLevelRecord { + PuzzleRuntimeLevelRecord { + run_id: snapshot.run_id, + level_index: snapshot.level_index, + grid_size: snapshot.grid_size, + profile_id: snapshot.profile_id, + level_name: snapshot.level_name, + author_display_name: snapshot.author_display_name, + theme_tags: snapshot.theme_tags, + cover_image_src: snapshot.cover_image_src, + board: map_puzzle_board_snapshot(snapshot.board), + status: snapshot.status.as_str().to_string(), + } +} + +pub(crate) fn map_puzzle_board_snapshot(snapshot: DomainPuzzleBoardSnapshot) -> PuzzleBoardRecord { + PuzzleBoardRecord { + rows: snapshot.rows, + cols: snapshot.cols, + pieces: snapshot + .pieces + .into_iter() + .map(map_puzzle_piece_state) + .collect(), + merged_groups: snapshot + .merged_groups + .into_iter() + .map(map_puzzle_merged_group_state) + .collect(), + selected_piece_id: snapshot.selected_piece_id, + all_tiles_resolved: snapshot.all_tiles_resolved, + } +} + +pub(crate) fn map_puzzle_piece_state(snapshot: DomainPuzzlePieceState) -> PuzzlePieceStateRecord { + PuzzlePieceStateRecord { + piece_id: snapshot.piece_id, + correct_row: snapshot.correct_row, + correct_col: snapshot.correct_col, + current_row: snapshot.current_row, + current_col: snapshot.current_col, + merged_group_id: snapshot.merged_group_id, + } +} + +pub(crate) fn map_puzzle_merged_group_state( + snapshot: DomainPuzzleMergedGroupState, +) -> PuzzleMergedGroupRecord { + PuzzleMergedGroupRecord { + group_id: snapshot.group_id, + piece_ids: snapshot.piece_ids, + occupied_cells: snapshot + .occupied_cells + .into_iter() + .map(map_puzzle_cell_position) + .collect(), + } +} + +pub(crate) fn map_puzzle_cell_position(snapshot: DomainPuzzleCellPosition) -> PuzzleCellPositionRecord { + PuzzleCellPositionRecord { + row: snapshot.row, + col: snapshot.col, + } +} + +pub(crate) fn map_big_fish_anchor_pack(snapshot: BigFishAnchorPack) -> BigFishAnchorPackRecord { + BigFishAnchorPackRecord { + gameplay_promise: map_big_fish_anchor_item(snapshot.gameplay_promise), + ecology_visual_theme: map_big_fish_anchor_item(snapshot.ecology_visual_theme), + growth_ladder: map_big_fish_anchor_item(snapshot.growth_ladder), + risk_tempo: map_big_fish_anchor_item(snapshot.risk_tempo), + } +} + +pub(crate) fn map_big_fish_anchor_item(snapshot: BigFishAnchorItem) -> BigFishAnchorItemRecord { + BigFishAnchorItemRecord { + key: snapshot.key, + label: snapshot.label, + value: snapshot.value, + status: format_big_fish_anchor_status(snapshot.status).to_string(), + } +} + +pub(crate) fn map_big_fish_game_draft(snapshot: BigFishGameDraft) -> BigFishGameDraftRecord { + BigFishGameDraftRecord { + title: snapshot.title, + subtitle: snapshot.subtitle, + core_fun: snapshot.core_fun, + ecology_theme: snapshot.ecology_theme, + levels: snapshot + .levels + .into_iter() + .map(map_big_fish_level_blueprint) + .collect(), + background: map_big_fish_background_blueprint(snapshot.background), + runtime_params: map_big_fish_runtime_params(snapshot.runtime_params), + } +} + +pub(crate) fn map_big_fish_level_blueprint( + snapshot: BigFishLevelBlueprint, +) -> BigFishLevelBlueprintRecord { + BigFishLevelBlueprintRecord { + level: snapshot.level, + name: snapshot.name, + one_line_fantasy: snapshot.one_line_fantasy, + silhouette_direction: snapshot.silhouette_direction, + size_ratio: snapshot.size_ratio, + visual_prompt_seed: snapshot.visual_prompt_seed, + motion_prompt_seed: snapshot.motion_prompt_seed, + merge_source_level: snapshot.merge_source_level, + prey_window: snapshot.prey_window, + threat_window: snapshot.threat_window, + is_final_level: snapshot.is_final_level, + } +} + +pub(crate) fn map_big_fish_background_blueprint( + snapshot: BigFishBackgroundBlueprint, +) -> BigFishBackgroundBlueprintRecord { + BigFishBackgroundBlueprintRecord { + theme: snapshot.theme, + color_mood: snapshot.color_mood, + foreground_hints: snapshot.foreground_hints, + midground_composition: snapshot.midground_composition, + background_depth: snapshot.background_depth, + safe_play_area_hint: snapshot.safe_play_area_hint, + spawn_edge_hint: snapshot.spawn_edge_hint, + background_prompt_seed: snapshot.background_prompt_seed, + } +} + +pub(crate) fn map_big_fish_runtime_params( + snapshot: BigFishRuntimeParams, +) -> BigFishRuntimeParamsRecord { + BigFishRuntimeParamsRecord { + level_count: snapshot.level_count, + merge_count_per_upgrade: snapshot.merge_count_per_upgrade, + spawn_target_count: snapshot.spawn_target_count, + leader_move_speed: snapshot.leader_move_speed, + follower_catch_up_speed: snapshot.follower_catch_up_speed, + offscreen_cull_seconds: snapshot.offscreen_cull_seconds, + prey_spawn_delta_levels: snapshot.prey_spawn_delta_levels, + threat_spawn_delta_levels: snapshot.threat_spawn_delta_levels, + win_level: snapshot.win_level, + } +} + +pub(crate) fn map_big_fish_asset_slot_snapshot( + snapshot: BigFishAssetSlotSnapshot, +) -> BigFishAssetSlotRecord { + BigFishAssetSlotRecord { + slot_id: snapshot.slot_id, + asset_kind: format_big_fish_asset_kind(snapshot.asset_kind).to_string(), + level: snapshot.level, + motion_key: snapshot.motion_key, + status: format_big_fish_asset_status(snapshot.status).to_string(), + asset_url: snapshot.asset_url, + prompt_snapshot: snapshot.prompt_snapshot, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn map_big_fish_asset_coverage( + snapshot: BigFishAssetCoverage, +) -> BigFishAssetCoverageRecord { + BigFishAssetCoverageRecord { + level_main_image_ready_count: snapshot.level_main_image_ready_count, + level_motion_ready_count: snapshot.level_motion_ready_count, + background_ready: snapshot.background_ready, + required_level_count: snapshot.required_level_count, + publish_ready: snapshot.publish_ready, + blockers: snapshot.blockers, + } +} + +pub(crate) fn map_big_fish_agent_message_snapshot( + snapshot: BigFishAgentMessageSnapshot, +) -> BigFishAgentMessageRecord { + BigFishAgentMessageRecord { + message_id: snapshot.message_id, + role: format_big_fish_agent_message_role(snapshot.role).to_string(), + kind: format_big_fish_agent_message_kind(snapshot.kind).to_string(), + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +pub(crate) fn map_big_fish_runtime_snapshot(snapshot: BigFishRuntimeSnapshot) -> BigFishRuntimeRecord { + BigFishRuntimeRecord { + run_id: snapshot.run_id, + session_id: snapshot.session_id, + status: format_big_fish_run_status(snapshot.status).to_string(), + tick: snapshot.tick, + player_level: snapshot.player_level, + win_level: snapshot.win_level, + leader_entity_id: snapshot.leader_entity_id, + owned_entities: snapshot + .owned_entities + .into_iter() + .map(map_big_fish_runtime_entity) + .collect(), + wild_entities: snapshot + .wild_entities + .into_iter() + .map(map_big_fish_runtime_entity) + .collect(), + camera_center: map_big_fish_vector2(snapshot.camera_center), + last_input: map_big_fish_vector2(snapshot.last_input), + event_log: snapshot.event_log, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn map_big_fish_runtime_entity( + snapshot: BigFishRuntimeEntity, +) -> BigFishRuntimeEntityRecord { + BigFishRuntimeEntityRecord { + entity_id: snapshot.entity_id, + level: snapshot.level, + position: map_big_fish_vector2(snapshot.position), + radius: snapshot.radius, + offscreen_seconds: snapshot.offscreen_seconds, + } +} + +pub(crate) fn map_big_fish_vector2(snapshot: BigFishVector2) -> BigFishVector2Record { + BigFishVector2Record { + x: snapshot.x, + y: snapshot.y, + } +} + +pub(crate) fn map_story_session_snapshot(snapshot: StorySessionSnapshot) -> StorySessionRecord { + StorySessionRecord { + story_session_id: snapshot.story_session_id, + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + world_profile_id: snapshot.world_profile_id, + initial_prompt: snapshot.initial_prompt, + opening_summary: snapshot.opening_summary, + latest_narrative_text: snapshot.latest_narrative_text, + latest_choice_function_id: snapshot.latest_choice_function_id, + status: map_story_session_status(snapshot.status) + .as_str() + .to_string(), + version: snapshot.version, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn map_ai_task_snapshot(snapshot: AiTaskSnapshot) -> AiTaskRecord { + AiTaskRecord { + task_id: snapshot.task_id, + task_kind: format_ai_task_kind(snapshot.task_kind).to_string(), + owner_user_id: snapshot.owner_user_id, + request_label: snapshot.request_label, + source_module: snapshot.source_module, + source_entity_id: snapshot.source_entity_id, + request_payload_json: snapshot.request_payload_json, + status: format_ai_task_status(snapshot.status).to_string(), + failure_message: snapshot.failure_message, + stages: snapshot + .stages + .into_iter() + .map(map_ai_task_stage_snapshot) + .collect(), + result_references: snapshot + .result_references + .into_iter() + .map(map_ai_result_reference_snapshot) + .collect(), + latest_text_output: snapshot.latest_text_output, + latest_structured_payload_json: snapshot.latest_structured_payload_json, + version: snapshot.version, + created_at: format_timestamp_micros(snapshot.created_at_micros), + started_at: snapshot.started_at_micros.map(format_timestamp_micros), + completed_at: snapshot.completed_at_micros.map(format_timestamp_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn map_ai_task_stage_snapshot(snapshot: AiTaskStageSnapshot) -> AiTaskStageRecord { + AiTaskStageRecord { + stage_kind: format_ai_task_stage_kind(snapshot.stage_kind).to_string(), + label: snapshot.label, + detail: snapshot.detail, + order: snapshot.order, + status: format_ai_task_stage_status(snapshot.status).to_string(), + text_output: snapshot.text_output, + structured_payload_json: snapshot.structured_payload_json, + warning_messages: snapshot.warning_messages, + started_at: snapshot.started_at_micros.map(format_timestamp_micros), + completed_at: snapshot.completed_at_micros.map(format_timestamp_micros), + } +} + +pub(crate) fn map_ai_text_chunk_snapshot(snapshot: AiTextChunkSnapshot) -> AiTextChunkRecord { + AiTextChunkRecord { + chunk_id: snapshot.chunk_id, + task_id: snapshot.task_id, + stage_kind: format_ai_task_stage_kind(snapshot.stage_kind).to_string(), + sequence: snapshot.sequence, + delta_text: snapshot.delta_text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +pub(crate) fn map_ai_result_reference_snapshot( + snapshot: AiResultReferenceSnapshot, +) -> AiResultReferenceRecord { + AiResultReferenceRecord { + result_ref_id: snapshot.result_ref_id, + task_id: snapshot.task_id, + reference_kind: format_ai_result_reference_kind(snapshot.reference_kind).to_string(), + reference_id: snapshot.reference_id, + label: snapshot.label, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +pub(crate) fn map_story_event_snapshot(snapshot: StoryEventSnapshot) -> StoryEventRecord { + StoryEventRecord { + event_id: snapshot.event_id, + story_session_id: snapshot.story_session_id, + event_kind: map_story_event_kind(snapshot.event_kind) + .as_str() + .to_string(), + narrative_text: snapshot.narrative_text, + choice_function_id: snapshot.choice_function_id, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +pub(crate) fn map_battle_state_snapshot(snapshot: BattleStateSnapshot) -> DomainBattleStateSnapshot { + DomainBattleStateSnapshot { + battle_state_id: snapshot.battle_state_id, + story_session_id: snapshot.story_session_id, + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + chapter_id: snapshot.chapter_id, + target_npc_id: snapshot.target_npc_id, + target_name: snapshot.target_name, + battle_mode: map_battle_mode_back(snapshot.battle_mode), + status: map_battle_status(snapshot.status), + player_hp: snapshot.player_hp, + player_max_hp: snapshot.player_max_hp, + player_mana: snapshot.player_mana, + player_max_mana: snapshot.player_max_mana, + target_hp: snapshot.target_hp, + target_max_hp: snapshot.target_max_hp, + experience_reward: snapshot.experience_reward, + reward_items: snapshot + .reward_items + .into_iter() + .map(map_runtime_item_reward_item_snapshot_back) + .collect(), + turn_index: snapshot.turn_index, + last_action_function_id: snapshot.last_action_function_id, + last_action_text: snapshot.last_action_text, + last_result_text: snapshot.last_result_text, + last_damage_dealt: snapshot.last_damage_dealt, + last_damage_taken: snapshot.last_damage_taken, + last_outcome: map_combat_outcome(snapshot.last_outcome), + version: snapshot.version, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_inventory_state_snapshot( + snapshot: RuntimeInventoryStateSnapshot, +) -> DomainRuntimeInventoryStateSnapshot { + DomainRuntimeInventoryStateSnapshot { + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + backpack_items: snapshot + .backpack_items + .into_iter() + .map(map_inventory_slot_snapshot) + .collect(), + equipment_items: snapshot + .equipment_items + .into_iter() + .map(map_inventory_slot_snapshot) + .collect(), + } +} + +pub(crate) fn map_resolve_combat_action_result( + result: ResolveCombatActionResult, +) -> DomainResolveCombatActionResult { + DomainResolveCombatActionResult { + snapshot: map_battle_state_snapshot(result.snapshot), + damage_dealt: result.damage_dealt, + damage_taken: result.damage_taken, + outcome: map_combat_outcome(result.outcome), + } +} + +pub(crate) fn map_npc_battle_interaction_result( + result: NpcBattleInteractionResult, +) -> NpcBattleInteractionSnapshot { + NpcBattleInteractionSnapshot { + interaction: map_npc_interaction_result(result.interaction), + battle_state: map_battle_state_snapshot(result.battle_state), + } +} + +pub(crate) fn map_inventory_slot_snapshot( + snapshot: InventorySlotSnapshot, +) -> module_inventory::InventorySlotSnapshot { + module_inventory::InventorySlotSnapshot { + slot_id: snapshot.slot_id, + runtime_session_id: snapshot.runtime_session_id, + story_session_id: snapshot.story_session_id, + actor_user_id: snapshot.actor_user_id, + container_kind: map_inventory_container_kind(snapshot.container_kind), + slot_key: snapshot.slot_key, + item_id: snapshot.item_id, + category: snapshot.category, + name: snapshot.name, + description: snapshot.description, + quantity: snapshot.quantity, + rarity: map_inventory_item_rarity(snapshot.rarity), + tags: snapshot.tags, + stackable: snapshot.stackable, + stack_key: snapshot.stack_key, + equipment_slot_id: snapshot.equipment_slot_id.map(map_inventory_equipment_slot), + source_kind: map_inventory_item_source_kind(snapshot.source_kind), + source_reference_id: snapshot.source_reference_id, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_npc_interaction_result(result: NpcInteractionResult) -> DomainNpcInteractionResult { + DomainNpcInteractionResult { + npc_state: map_npc_state_snapshot(result.npc_state), + interaction_status: map_npc_interaction_status(result.interaction_status), + action_text: result.action_text, + result_text: result.result_text, + story_text: result.story_text, + battle_mode: result.battle_mode.map(map_npc_interaction_battle_mode), + encounter_closed: result.encounter_closed, + affinity_changed: result.affinity_changed, + previous_affinity: result.previous_affinity, + next_affinity: result.next_affinity, + } +} + +pub(crate) fn map_npc_state_snapshot(snapshot: NpcStateSnapshot) -> DomainNpcStateSnapshot { + DomainNpcStateSnapshot { + npc_state_id: snapshot.npc_state_id, + runtime_session_id: snapshot.runtime_session_id, + npc_id: snapshot.npc_id, + npc_name: snapshot.npc_name, + affinity: snapshot.affinity, + relation_state: map_npc_relation_state(snapshot.relation_state), + help_used: snapshot.help_used, + chatted_count: snapshot.chatted_count, + gifts_given: snapshot.gifts_given, + recruited: snapshot.recruited, + trade_stock_signature: snapshot.trade_stock_signature, + revealed_facts: snapshot.revealed_facts, + known_attribute_rumors: snapshot.known_attribute_rumors, + first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved, + seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids, + stance_profile: map_npc_stance_profile(snapshot.stance_profile), + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_npc_relation_state(value: NpcRelationState) -> DomainNpcRelationState { + DomainNpcRelationState { + affinity: value.affinity, + stance: map_npc_relation_stance(value.stance), + } +} + +pub(crate) fn map_npc_stance_profile(value: NpcStanceProfile) -> DomainNpcStanceProfile { + DomainNpcStanceProfile { + trust: value.trust, + warmth: value.warmth, + ideological_fit: value.ideological_fit, + fear_or_guard: value.fear_or_guard, + loyalty: value.loyalty, + current_conflict_tag: value.current_conflict_tag, + recent_approvals: value.recent_approvals, + recent_disapprovals: value.recent_disapprovals, + } +} + +pub(crate) fn map_npc_interaction_status(value: NpcInteractionStatus) -> DomainNpcInteractionStatus { + match value { + NpcInteractionStatus::Previewed => DomainNpcInteractionStatus::Previewed, + NpcInteractionStatus::Dialogue => DomainNpcInteractionStatus::Dialogue, + NpcInteractionStatus::Resolved => DomainNpcInteractionStatus::Resolved, + NpcInteractionStatus::Recruited => DomainNpcInteractionStatus::Recruited, + NpcInteractionStatus::BattlePending => DomainNpcInteractionStatus::BattlePending, + NpcInteractionStatus::Left => DomainNpcInteractionStatus::Left, + } +} + +pub(crate) fn map_npc_interaction_battle_mode( + value: NpcInteractionBattleMode, +) -> DomainNpcInteractionBattleMode { + match value { + NpcInteractionBattleMode::Fight => DomainNpcInteractionBattleMode::Fight, + NpcInteractionBattleMode::Spar => DomainNpcInteractionBattleMode::Spar, + } +} + +pub(crate) fn map_npc_relation_stance(value: NpcRelationStance) -> DomainNpcRelationStance { + match value { + NpcRelationStance::Hostile => DomainNpcRelationStance::Hostile, + NpcRelationStance::Guarded => DomainNpcRelationStance::Guarded, + NpcRelationStance::Neutral => DomainNpcRelationStance::Neutral, + NpcRelationStance::Cooperative => DomainNpcRelationStance::Cooperative, + NpcRelationStance::Bonded => DomainNpcRelationStance::Bonded, + } +} + +pub(crate) fn map_access_policy( + value: AssetObjectAccessPolicy, +) -> crate::module_bindings::AssetObjectAccessPolicy { + match value { + AssetObjectAccessPolicy::Private => { + crate::module_bindings::AssetObjectAccessPolicy::Private + } + AssetObjectAccessPolicy::PublicRead => { + crate::module_bindings::AssetObjectAccessPolicy::PublicRead + } + } +} + +pub(crate) fn map_access_policy_back( + value: crate::module_bindings::AssetObjectAccessPolicy, +) -> AssetObjectAccessPolicy { + match value { + crate::module_bindings::AssetObjectAccessPolicy::Private => { + AssetObjectAccessPolicy::Private + } + crate::module_bindings::AssetObjectAccessPolicy::PublicRead => { + AssetObjectAccessPolicy::PublicRead + } + } +} + +pub(crate) fn map_runtime_platform_theme(value: RuntimePlatformTheme) -> RuntimePlatformTheme { + match value { + RuntimePlatformTheme::Light => RuntimePlatformTheme::Light, + RuntimePlatformTheme::Dark => RuntimePlatformTheme::Dark, + } +} + +pub(crate) fn map_runtime_profile_wallet_ledger_source_type( + value: RuntimeProfileWalletLedgerSourceType, +) -> RuntimeProfileWalletLedgerSourceType { + match value { + RuntimeProfileWalletLedgerSourceType::SnapshotSync => { + RuntimeProfileWalletLedgerSourceType::SnapshotSync + } + } +} + +pub(crate) fn map_runtime_item_reward_item_rarity( + value: DomainRuntimeItemRewardItemRarity, +) -> RuntimeItemRewardItemRarity { + match value { + DomainRuntimeItemRewardItemRarity::Common => RuntimeItemRewardItemRarity::Common, + DomainRuntimeItemRewardItemRarity::Uncommon => RuntimeItemRewardItemRarity::Uncommon, + DomainRuntimeItemRewardItemRarity::Rare => RuntimeItemRewardItemRarity::Rare, + DomainRuntimeItemRewardItemRarity::Epic => RuntimeItemRewardItemRarity::Epic, + DomainRuntimeItemRewardItemRarity::Legendary => { + RuntimeItemRewardItemRarity::Legendary + } + } +} + +pub(crate) fn map_runtime_item_equipment_slot( + value: DomainRuntimeItemEquipmentSlot, +) -> RuntimeItemEquipmentSlot { + match value { + DomainRuntimeItemEquipmentSlot::Weapon => RuntimeItemEquipmentSlot::Weapon, + DomainRuntimeItemEquipmentSlot::Armor => RuntimeItemEquipmentSlot::Armor, + DomainRuntimeItemEquipmentSlot::Relic => RuntimeItemEquipmentSlot::Relic, + } +} + +pub(crate) fn map_custom_world_theme_mode(value: DomainCustomWorldThemeMode) -> CustomWorldThemeMode { + match value { + DomainCustomWorldThemeMode::Martial => CustomWorldThemeMode::Martial, + DomainCustomWorldThemeMode::Arcane => CustomWorldThemeMode::Arcane, + DomainCustomWorldThemeMode::Machina => CustomWorldThemeMode::Machina, + DomainCustomWorldThemeMode::Tide => CustomWorldThemeMode::Tide, + DomainCustomWorldThemeMode::Rift => CustomWorldThemeMode::Rift, + DomainCustomWorldThemeMode::Mythic => CustomWorldThemeMode::Mythic, + } +} + +pub(crate) fn map_battle_mode(value: DomainBattleMode) -> BattleMode { + match value { + DomainBattleMode::Fight => BattleMode::Fight, + DomainBattleMode::Spar => BattleMode::Spar, + } +} + +pub(crate) fn map_runtime_platform_theme_back(value: RuntimePlatformTheme) -> RuntimePlatformTheme { + match value { + RuntimePlatformTheme::Light => RuntimePlatformTheme::Light, + RuntimePlatformTheme::Dark => RuntimePlatformTheme::Dark, + } +} + +pub(crate) fn map_runtime_item_reward_item_rarity_back( + value: RuntimeItemRewardItemRarity, +) -> DomainRuntimeItemRewardItemRarity { + match value { + RuntimeItemRewardItemRarity::Common => DomainRuntimeItemRewardItemRarity::Common, + RuntimeItemRewardItemRarity::Uncommon => DomainRuntimeItemRewardItemRarity::Uncommon, + RuntimeItemRewardItemRarity::Rare => DomainRuntimeItemRewardItemRarity::Rare, + RuntimeItemRewardItemRarity::Epic => DomainRuntimeItemRewardItemRarity::Epic, + RuntimeItemRewardItemRarity::Legendary => { + DomainRuntimeItemRewardItemRarity::Legendary + } + } +} + +pub(crate) fn map_runtime_item_equipment_slot_back( + value: RuntimeItemEquipmentSlot, +) -> DomainRuntimeItemEquipmentSlot { + match value { + RuntimeItemEquipmentSlot::Weapon => DomainRuntimeItemEquipmentSlot::Weapon, + RuntimeItemEquipmentSlot::Armor => DomainRuntimeItemEquipmentSlot::Armor, + RuntimeItemEquipmentSlot::Relic => DomainRuntimeItemEquipmentSlot::Relic, + } +} + +pub(crate) fn map_custom_world_theme_mode_back( + value: CustomWorldThemeMode, +) -> DomainCustomWorldThemeMode { + match value { + CustomWorldThemeMode::Martial => DomainCustomWorldThemeMode::Martial, + CustomWorldThemeMode::Arcane => DomainCustomWorldThemeMode::Arcane, + CustomWorldThemeMode::Machina => DomainCustomWorldThemeMode::Machina, + CustomWorldThemeMode::Tide => DomainCustomWorldThemeMode::Tide, + CustomWorldThemeMode::Rift => DomainCustomWorldThemeMode::Rift, + CustomWorldThemeMode::Mythic => DomainCustomWorldThemeMode::Mythic, + } +} + +pub(crate) fn map_custom_world_publication_status(value: CustomWorldPublicationStatus) -> &'static str { + match value { + CustomWorldPublicationStatus::Draft => "draft", + CustomWorldPublicationStatus::Published => "published", + } +} + +pub(crate) fn map_rpg_agent_stage(value: crate::module_bindings::RpgAgentStage) -> String { + match value { + crate::module_bindings::RpgAgentStage::CollectingIntent => "collecting_intent", + crate::module_bindings::RpgAgentStage::Clarifying => "clarifying", + crate::module_bindings::RpgAgentStage::FoundationReview => "foundation_review", + crate::module_bindings::RpgAgentStage::ObjectRefining => "object_refining", + crate::module_bindings::RpgAgentStage::VisualRefining => "visual_refining", + crate::module_bindings::RpgAgentStage::LongTailReview => "long_tail_review", + crate::module_bindings::RpgAgentStage::ReadyToPublish => "ready_to_publish", + crate::module_bindings::RpgAgentStage::Published => "published", + crate::module_bindings::RpgAgentStage::Error => "error", + } + .to_string() +} + +pub(crate) fn parse_puzzle_agent_stage_record( + value: &str, +) -> Result { + match value.trim() { + "collecting_anchors" => Ok(crate::module_bindings::PuzzleAgentStage::CollectingAnchors), + "draft_ready" => Ok(crate::module_bindings::PuzzleAgentStage::DraftReady), + "image_refining" => Ok(crate::module_bindings::PuzzleAgentStage::ImageRefining), + "ready_to_publish" => Ok(crate::module_bindings::PuzzleAgentStage::ReadyToPublish), + "published" => Ok(crate::module_bindings::PuzzleAgentStage::Published), + other => Err(SpacetimeClientError::Runtime(format!( + "未知 puzzle agent stage: {other}" + ))), + } +} + +pub(crate) fn parse_rpg_agent_stage_record( + value: &str, +) -> Result { + match value.trim() { + "collecting_intent" => Ok(crate::module_bindings::RpgAgentStage::CollectingIntent), + "clarifying" => Ok(crate::module_bindings::RpgAgentStage::Clarifying), + "foundation_review" => Ok(crate::module_bindings::RpgAgentStage::FoundationReview), + "object_refining" => Ok(crate::module_bindings::RpgAgentStage::ObjectRefining), + "visual_refining" => Ok(crate::module_bindings::RpgAgentStage::VisualRefining), + "long_tail_review" => Ok(crate::module_bindings::RpgAgentStage::LongTailReview), + "ready_to_publish" => Ok(crate::module_bindings::RpgAgentStage::ReadyToPublish), + "published" => Ok(crate::module_bindings::RpgAgentStage::Published), + "error" => Ok(crate::module_bindings::RpgAgentStage::Error), + other => Err(SpacetimeClientError::Runtime(format!( + "未知 rpg agent stage: {other}" + ))), + } +} + +pub(crate) fn format_rpg_agent_message_role( + value: crate::module_bindings::RpgAgentMessageRole, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentMessageRole::User => "user", + crate::module_bindings::RpgAgentMessageRole::Assistant => "assistant", + crate::module_bindings::RpgAgentMessageRole::System => "system", + } +} + +pub(crate) fn format_rpg_agent_message_kind( + value: crate::module_bindings::RpgAgentMessageKind, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentMessageKind::Chat => "chat", + crate::module_bindings::RpgAgentMessageKind::Clarification => "clarification", + crate::module_bindings::RpgAgentMessageKind::Summary => "summary", + crate::module_bindings::RpgAgentMessageKind::Checkpoint => "checkpoint", + crate::module_bindings::RpgAgentMessageKind::Warning => "warning", + crate::module_bindings::RpgAgentMessageKind::ActionResult => "action_result", + } +} + +pub(crate) fn format_rpg_agent_operation_type( + value: crate::module_bindings::RpgAgentOperationType, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentOperationType::ProcessMessage => "process_message", + crate::module_bindings::RpgAgentOperationType::DraftFoundation => "draft_foundation", + crate::module_bindings::RpgAgentOperationType::UpdateDraftCard => "update_draft_card", + crate::module_bindings::RpgAgentOperationType::SyncResultProfile => "sync_result_profile", + crate::module_bindings::RpgAgentOperationType::GenerateCharacters => "generate_characters", + crate::module_bindings::RpgAgentOperationType::GenerateLandmarks => "generate_landmarks", + crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets => "generate_role_assets", + crate::module_bindings::RpgAgentOperationType::SyncRoleAssets => "sync_role_assets", + crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets => { + "generate_scene_assets" + } + crate::module_bindings::RpgAgentOperationType::SyncSceneAssets => "sync_scene_assets", + crate::module_bindings::RpgAgentOperationType::ExpandLongTail => "expand_long_tail", + crate::module_bindings::RpgAgentOperationType::PublishWorld => "publish_world", + crate::module_bindings::RpgAgentOperationType::RevertCheckpoint => "revert_checkpoint", + } +} + +pub(crate) fn format_rpg_agent_operation_status( + value: crate::module_bindings::RpgAgentOperationStatus, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentOperationStatus::Queued => "queued", + crate::module_bindings::RpgAgentOperationStatus::Running => "running", + crate::module_bindings::RpgAgentOperationStatus::Completed => "completed", + crate::module_bindings::RpgAgentOperationStatus::Failed => "failed", + } +} + +pub(crate) fn parse_rpg_agent_operation_status_record( + value: &str, +) -> Result { + match value.trim() { + "queued" => Ok(crate::module_bindings::RpgAgentOperationStatus::Queued), + "running" => Ok(crate::module_bindings::RpgAgentOperationStatus::Running), + "completed" => Ok(crate::module_bindings::RpgAgentOperationStatus::Completed), + "failed" => Ok(crate::module_bindings::RpgAgentOperationStatus::Failed), + other => Err(SpacetimeClientError::Runtime(format!( + "未知 rpg agent operation status: {other}" + ))), + } +} + +pub(crate) fn format_rpg_agent_draft_card_kind( + value: crate::module_bindings::RpgAgentDraftCardKind, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentDraftCardKind::World => "world", + crate::module_bindings::RpgAgentDraftCardKind::Camp => "camp", + crate::module_bindings::RpgAgentDraftCardKind::Faction => "faction", + crate::module_bindings::RpgAgentDraftCardKind::Character => "character", + crate::module_bindings::RpgAgentDraftCardKind::Landmark => "landmark", + crate::module_bindings::RpgAgentDraftCardKind::Thread => "thread", + crate::module_bindings::RpgAgentDraftCardKind::Chapter => "chapter", + crate::module_bindings::RpgAgentDraftCardKind::SceneChapter => "scene_chapter", + crate::module_bindings::RpgAgentDraftCardKind::Carrier => "carrier", + crate::module_bindings::RpgAgentDraftCardKind::SidequestSeed => "sidequest_seed", + } +} + +pub(crate) fn format_rpg_agent_draft_card_status( + value: crate::module_bindings::RpgAgentDraftCardStatus, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentDraftCardStatus::Suggested => "suggested", + crate::module_bindings::RpgAgentDraftCardStatus::Confirmed => "confirmed", + crate::module_bindings::RpgAgentDraftCardStatus::Locked => "locked", + crate::module_bindings::RpgAgentDraftCardStatus::Warning => "warning", + } +} + +pub(crate) fn format_custom_world_role_asset_status_back( + value: crate::module_bindings::CustomWorldRoleAssetStatus, +) -> String { + match value { + crate::module_bindings::CustomWorldRoleAssetStatus::Missing => "missing", + crate::module_bindings::CustomWorldRoleAssetStatus::VisualReady => "visual_ready", + crate::module_bindings::CustomWorldRoleAssetStatus::AnimationsReady => "animations_ready", + crate::module_bindings::CustomWorldRoleAssetStatus::Complete => "complete", + } + .to_string() +} + +pub(crate) fn map_big_fish_asset_kind_input( + value: &str, +) -> Result { + match value.trim() { + "level_main_image" => Ok(BigFishAssetKind::LevelMainImage), + "level_motion" => Ok(BigFishAssetKind::LevelMotion), + "stage_background" => Ok(BigFishAssetKind::StageBackground), + other => Err(SpacetimeClientError::Runtime(format!( + "big fish asset kind `{other}` 当前尚未支持" + ))), + } +} + +pub(crate) fn format_big_fish_creation_stage(value: BigFishCreationStage) -> &'static str { + match value { + BigFishCreationStage::CollectingAnchors => "collecting_anchors", + BigFishCreationStage::DraftReady => "draft_ready", + BigFishCreationStage::AssetRefining => "asset_refining", + BigFishCreationStage::ReadyToPublish => "ready_to_publish", + BigFishCreationStage::Published => "published", + } +} + +pub(crate) fn format_big_fish_anchor_status(value: BigFishAnchorStatus) -> &'static str { + match value { + BigFishAnchorStatus::Confirmed => "confirmed", + BigFishAnchorStatus::Inferred => "inferred", + BigFishAnchorStatus::Missing => "missing", + BigFishAnchorStatus::Locked => "locked", + } +} + +pub(crate) fn format_big_fish_agent_message_role(value: BigFishAgentMessageRole) -> &'static str { + match value { + BigFishAgentMessageRole::User => "user", + BigFishAgentMessageRole::Assistant => "assistant", + BigFishAgentMessageRole::System => "system", + } +} + +pub(crate) fn format_big_fish_agent_message_kind(value: BigFishAgentMessageKind) -> &'static str { + match value { + BigFishAgentMessageKind::Chat => "chat", + BigFishAgentMessageKind::Summary => "summary", + BigFishAgentMessageKind::ActionResult => "action_result", + BigFishAgentMessageKind::Warning => "warning", + } +} + +pub(crate) fn format_big_fish_asset_kind(value: BigFishAssetKind) -> &'static str { + match value { + BigFishAssetKind::LevelMainImage => "level_main_image", + BigFishAssetKind::LevelMotion => "level_motion", + BigFishAssetKind::StageBackground => "stage_background", + } +} + +pub(crate) fn format_big_fish_asset_status(value: BigFishAssetStatus) -> &'static str { + match value { + BigFishAssetStatus::Missing => "missing", + BigFishAssetStatus::Ready => "ready", + } +} + +pub(crate) fn format_big_fish_run_status(value: BigFishRunStatus) -> &'static str { + match value { + BigFishRunStatus::Running => "running", + BigFishRunStatus::Won => "won", + BigFishRunStatus::Failed => "failed", + } +} + +pub(crate) fn format_custom_world_theme_mode(value: DomainCustomWorldThemeMode) -> &'static str { + match value { + DomainCustomWorldThemeMode::Martial => "martial", + DomainCustomWorldThemeMode::Arcane => "arcane", + DomainCustomWorldThemeMode::Machina => "machina", + DomainCustomWorldThemeMode::Tide => "tide", + DomainCustomWorldThemeMode::Rift => "rift", + DomainCustomWorldThemeMode::Mythic => "mythic", + } +} + +pub(crate) fn map_battle_mode_back(value: BattleMode) -> DomainBattleMode { + match value { + BattleMode::Fight => DomainBattleMode::Fight, + BattleMode::Spar => DomainBattleMode::Spar, + } +} + +pub(crate) fn map_runtime_browse_history_theme_mode_back( + value: RuntimeBrowseHistoryThemeMode, +) -> RuntimeBrowseHistoryThemeMode { + match value { + RuntimeBrowseHistoryThemeMode::Martial => RuntimeBrowseHistoryThemeMode::Martial, + RuntimeBrowseHistoryThemeMode::Arcane => RuntimeBrowseHistoryThemeMode::Arcane, + RuntimeBrowseHistoryThemeMode::Machina => RuntimeBrowseHistoryThemeMode::Machina, + RuntimeBrowseHistoryThemeMode::Tide => RuntimeBrowseHistoryThemeMode::Tide, + RuntimeBrowseHistoryThemeMode::Rift => RuntimeBrowseHistoryThemeMode::Rift, + RuntimeBrowseHistoryThemeMode::Mythic => RuntimeBrowseHistoryThemeMode::Mythic, + } +} + +pub(crate) fn map_story_session_status(value: StorySessionStatus) -> DomainStorySessionStatus { + match value { + StorySessionStatus::Active => DomainStorySessionStatus::Active, + StorySessionStatus::Completed => DomainStorySessionStatus::Completed, + StorySessionStatus::Archived => DomainStorySessionStatus::Archived, + } +} + +pub(crate) fn map_battle_status(value: BattleStatus) -> DomainBattleStatus { + match value { + BattleStatus::Ongoing => DomainBattleStatus::Ongoing, + BattleStatus::Resolved => DomainBattleStatus::Resolved, + BattleStatus::Aborted => DomainBattleStatus::Aborted, + } +} + +pub(crate) fn map_story_event_kind(value: StoryEventKind) -> DomainStoryEventKind { + match value { + StoryEventKind::SessionStarted => DomainStoryEventKind::SessionStarted, + StoryEventKind::StoryContinued => DomainStoryEventKind::StoryContinued, + } +} + +pub(crate) fn map_ai_task_kind(value: DomainAiTaskKind) -> AiTaskKind { + match value { + DomainAiTaskKind::StoryGeneration => AiTaskKind::StoryGeneration, + DomainAiTaskKind::CharacterChat => AiTaskKind::CharacterChat, + DomainAiTaskKind::NpcChat => AiTaskKind::NpcChat, + DomainAiTaskKind::CustomWorldGeneration => AiTaskKind::CustomWorldGeneration, + DomainAiTaskKind::QuestIntent => AiTaskKind::QuestIntent, + DomainAiTaskKind::RuntimeItemIntent => AiTaskKind::RuntimeItemIntent, + } +} + +pub(crate) fn map_ai_task_stage_kind(value: DomainAiTaskStageKind) -> AiTaskStageKind { + match value { + DomainAiTaskStageKind::PreparePrompt => AiTaskStageKind::PreparePrompt, + DomainAiTaskStageKind::RequestModel => AiTaskStageKind::RequestModel, + DomainAiTaskStageKind::RepairResponse => AiTaskStageKind::RepairResponse, + DomainAiTaskStageKind::NormalizeResult => AiTaskStageKind::NormalizeResult, + DomainAiTaskStageKind::PersistResult => AiTaskStageKind::PersistResult, + } +} + +pub(crate) fn map_ai_result_reference_kind( + value: DomainAiResultReferenceKind, +) -> AiResultReferenceKind { + match value { + DomainAiResultReferenceKind::StorySession => AiResultReferenceKind::StorySession, + DomainAiResultReferenceKind::StoryEvent => AiResultReferenceKind::StoryEvent, + DomainAiResultReferenceKind::CustomWorldProfile => { + AiResultReferenceKind::CustomWorldProfile + } + DomainAiResultReferenceKind::QuestRecord => AiResultReferenceKind::QuestRecord, + DomainAiResultReferenceKind::RuntimeItemRecord => { + AiResultReferenceKind::RuntimeItemRecord + } + DomainAiResultReferenceKind::AssetObject => AiResultReferenceKind::AssetObject, + } +} + +pub(crate) fn format_ai_task_kind(value: AiTaskKind) -> &'static str { + match value { + AiTaskKind::StoryGeneration => "story_generation", + AiTaskKind::CharacterChat => "character_chat", + AiTaskKind::NpcChat => "npc_chat", + AiTaskKind::CustomWorldGeneration => "custom_world_generation", + AiTaskKind::QuestIntent => "quest_intent", + AiTaskKind::RuntimeItemIntent => "runtime_item_intent", + } +} + +pub(crate) fn format_ai_task_status(value: AiTaskStatus) -> &'static str { + match value { + AiTaskStatus::Pending => "pending", + AiTaskStatus::Running => "running", + AiTaskStatus::Completed => "completed", + AiTaskStatus::Failed => "failed", + AiTaskStatus::Cancelled => "cancelled", + } +} + +pub(crate) fn format_ai_task_stage_kind(value: AiTaskStageKind) -> &'static str { + match value { + AiTaskStageKind::PreparePrompt => "prepare_prompt", + AiTaskStageKind::RequestModel => "request_model", + AiTaskStageKind::RepairResponse => "repair_response", + AiTaskStageKind::NormalizeResult => "normalize_result", + AiTaskStageKind::PersistResult => "persist_result", + } +} + +pub(crate) fn format_ai_task_stage_status(value: AiTaskStageStatus) -> &'static str { + match value { + AiTaskStageStatus::Pending => "pending", + AiTaskStageStatus::Running => "running", + AiTaskStageStatus::Completed => "completed", + AiTaskStageStatus::Skipped => "skipped", + } +} + +pub(crate) fn format_ai_result_reference_kind(value: AiResultReferenceKind) -> &'static str { + match value { + AiResultReferenceKind::StorySession => "story_session", + AiResultReferenceKind::StoryEvent => "story_event", + AiResultReferenceKind::CustomWorldProfile => "custom_world_profile", + AiResultReferenceKind::QuestRecord => "quest_record", + AiResultReferenceKind::RuntimeItemRecord => "runtime_item_record", + AiResultReferenceKind::AssetObject => "asset_object", + } +} + +pub(crate) fn map_combat_outcome(value: CombatOutcome) -> DomainCombatOutcome { + match value { + CombatOutcome::Ongoing => DomainCombatOutcome::Ongoing, + CombatOutcome::Victory => DomainCombatOutcome::Victory, + CombatOutcome::SparComplete => DomainCombatOutcome::SparComplete, + CombatOutcome::Escaped => DomainCombatOutcome::Escaped, + } +} + +pub(crate) fn map_runtime_item_reward_item_snapshot( + snapshot: DomainRuntimeItemRewardItemSnapshot, +) -> RuntimeItemRewardItemSnapshot { + RuntimeItemRewardItemSnapshot { + item_id: snapshot.item_id, + category: snapshot.category, + item_name: snapshot.item_name, + description: snapshot.description, + quantity: snapshot.quantity, + rarity: map_runtime_item_reward_item_rarity(snapshot.rarity), + tags: snapshot.tags, + stackable: snapshot.stackable, + stack_key: snapshot.stack_key, + equipment_slot_id: snapshot + .equipment_slot_id + .map(map_runtime_item_equipment_slot), + } +} + +pub(crate) fn map_runtime_item_reward_item_snapshot_back( + snapshot: RuntimeItemRewardItemSnapshot, +) -> DomainRuntimeItemRewardItemSnapshot { + DomainRuntimeItemRewardItemSnapshot { + item_id: snapshot.item_id, + category: snapshot.category, + item_name: snapshot.item_name, + description: snapshot.description, + quantity: snapshot.quantity, + rarity: map_runtime_item_reward_item_rarity_back(snapshot.rarity), + tags: snapshot.tags, + stackable: snapshot.stackable, + stack_key: snapshot.stack_key, + equipment_slot_id: snapshot + .equipment_slot_id + .map(map_runtime_item_equipment_slot_back), + } +} + +pub(crate) fn parse_json_value(value: &str, label: &str) -> Result { + serde_json::from_str::(value) + .map_err(|error| SpacetimeClientError::Runtime(format!("{label} 非法: {error}"))) +} + +pub(crate) fn parse_optional_json_value( + value: Option<&str>, + fallback: serde_json::Value, + label: &str, +) -> Result { + match value.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => parse_json_value(value, label), + None => Ok(fallback), + } +} + +pub(crate) fn parse_json_array( + value: &str, + label: &str, +) -> Result, SpacetimeClientError> { + match parse_json_value(value, label)? { + serde_json::Value::Array(entries) => Ok(entries), + _ => Err(SpacetimeClientError::Runtime(format!( + "{label} 必须是 JSON array" + ))), + } +} + +pub(crate) fn parse_json_string_array(value: &str, label: &str) -> Result, SpacetimeClientError> { + parse_json_array(value, label)? + .into_iter() + .map(|entry| match entry { + serde_json::Value::String(value) => Ok(value), + _ => Err(SpacetimeClientError::Runtime(format!( + "{label} 必须是 string array" + ))), + }) + .collect() +} + +pub(crate) fn map_custom_world_checkpoint_record( + value: serde_json::Value, +) -> Result { + let object = value.as_object().ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint 必须是 JSON object".to_string()) + })?; + let checkpoint_id = object + .get("checkpointId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint.checkpointId 缺失".to_string()) + })?; + let created_at = object + .get("createdAt") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint.createdAt 缺失".to_string()) + })?; + let label = object + .get("label") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint.label 缺失".to_string()) + })?; + + Ok(CustomWorldCheckpointRecord { + checkpoint_id: checkpoint_id.to_string(), + created_at: created_at.to_string(), + label: label.to_string(), + }) +} + +pub(crate) fn parse_supported_actions_json( + value: &str, +) -> Result, SpacetimeClientError> { + parse_json_array(value, "custom world agent supported_actions_json")? + .into_iter() + .map(|entry| { + let object = entry.as_object().ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world supported action 必须是 JSON object".to_string(), + ) + })?; + let action = object + .get("action") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world supported action.action 缺失".to_string(), + ) + })?; + let enabled = object + .get("enabled") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world supported action.enabled 缺失".to_string(), + ) + })?; + + Ok(CustomWorldSupportedActionRecord { + action: action.to_string(), + enabled, + reason: object + .get("reason") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + }) + }) + .collect() +} + +pub(crate) fn parse_custom_world_publish_gate_record( + value: &str, +) -> Result { + let object = parse_json_value(value, "custom world publish_gate_json")? + .as_object() + .cloned() + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish_gate_json 必须是 JSON object".to_string(), + ) + })?; + + let profile_id = object + .get("profileId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world publish_gate.profileId 缺失".to_string()) + })?; + let blockers = object + .get("blockers") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world publish_gate.blockers 缺失".to_string()) + })? + .iter() + .cloned() + .map(|entry| { + let object = entry.as_object().ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker 必须是 JSON object".to_string(), + ) + })?; + let id = object + .get("id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker.id 缺失".to_string(), + ) + })?; + let code = object + .get("code") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker.code 缺失".to_string(), + ) + })?; + let message = object + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker.message 缺失".to_string(), + ) + })?; + + Ok(CustomWorldResultPreviewBlockerRecord { + id: id.to_string(), + code: code.to_string(), + message: message.to_string(), + }) + }) + .collect::, _>>()?; + let blocker_count = object + .get("blockerCount") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world publish_gate.blockerCount 缺失".to_string()) + })?; + let publish_ready = object + .get("publishReady") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world publish_gate.publishReady 缺失".to_string()) + })?; + let can_enter_world = object + .get("canEnterWorld") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish_gate.canEnterWorld 缺失".to_string(), + ) + })?; + + Ok(CustomWorldPublishGateRecord { + profile_id: profile_id.to_string(), + blockers, + blocker_count, + publish_ready, + can_enter_world, + }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BattleStateRecord { + pub battle_state_id: String, + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub chapter_id: Option, + pub target_npc_id: String, + pub target_name: String, + pub battle_mode: String, + pub status: String, + pub player_hp: i32, + pub player_max_hp: i32, + pub player_mana: i32, + pub player_max_mana: i32, + pub target_hp: i32, + pub target_max_hp: i32, + pub experience_reward: u32, + pub reward_items: Vec, + pub turn_index: u32, + pub last_action_function_id: Option, + pub last_action_text: Option, + pub last_result_text: Option, + pub last_damage_dealt: i32, + pub last_damage_taken: i32, + pub last_outcome: String, + pub version: u32, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolveCombatActionRecord { + pub battle_state: BattleStateRecord, + pub damage_dealt: i32, + pub damage_taken: i32, + pub outcome: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldLibraryEntryRecord { + pub owner_user_id: String, + pub profile_id: String, + pub public_work_code: Option, + pub author_public_user_code: Option, + pub profile: serde_json::Value, + pub visibility: String, + pub published_at: Option, + pub updated_at: String, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: String, + pub playable_npc_count: u32, + pub landmark_count: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldGalleryEntryRecord { + pub owner_user_id: String, + pub profile_id: String, + pub public_work_code: String, + pub author_public_user_code: String, + pub visibility: String, + pub published_at: Option, + pub updated_at: String, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: String, + pub playable_npc_count: u32, + pub landmark_count: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldLibraryMutationRecord { + pub entry: CustomWorldLibraryEntryRecord, + pub gallery_entry: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldPublishedProfileCompileRecord { + pub profile_id: String, + pub owner_user_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: String, + pub cover_image_src: Option, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub compiled_profile: serde_json::Value, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldPublishWorldRecord { + pub compiled_record: CustomWorldPublishedProfileCompileRecord, + pub entry: CustomWorldLibraryEntryRecord, + pub gallery_entry: Option, + pub session_stage: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentMessageRecord { + pub message_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, + pub related_operation_id: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentOperationRecord { + pub operation_id: String, + pub operation_type: String, + pub status: String, + pub phase_label: String, + pub phase_detail: String, + pub progress: u32, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldDraftCardRecord { + pub card_id: String, + pub kind: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub status: String, + pub linked_ids: Vec, + pub warning_count: u32, + pub asset_status: Option, + pub asset_status_label: Option, + pub detail_payload: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldSupportedActionRecord { + pub action: String, + pub enabled: bool, + pub reason: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldCheckpointRecord { + pub checkpoint_id: String, + pub created_at: String, + pub label: String, +} + +// 兼容并行 custom world facade 中仍在使用的旧命名,避免本轮 module-npc 收口被无关改动阻塞。 +pub type CustomWorldAgentCheckpointRecord = CustomWorldCheckpointRecord; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldResultPreviewBlockerRecord { + pub id: String, + pub code: String, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldPublishGateRecord { + pub profile_id: String, + pub blockers: Vec, + pub blocker_count: u32, + pub publish_ready: bool, + pub can_enter_world: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldWorkSummaryRecord { + pub work_id: String, + pub source_type: String, + pub status: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub cover_image_src: Option, + pub cover_render_mode: Option, + pub cover_character_image_srcs: Vec, + pub updated_at: String, + pub published_at: Option, + pub stage: Option, + pub stage_label: Option, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub role_visual_ready_count: Option, + pub role_animation_ready_count: Option, + pub role_asset_summary_label: Option, + pub session_id: Option, + pub profile_id: Option, + pub can_resume: bool, + pub can_enter_world: bool, + pub blocker_count: u32, + pub publish_ready: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldDraftCardDetailSectionRecord { + pub section_id: String, + pub label: String, + pub value: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldDraftCardDetailRecord { + pub card_id: String, + pub kind: String, + pub title: String, + pub sections: Vec, + pub linked_ids: Vec, + pub locked: bool, + pub editable: bool, + pub editable_section_ids: Vec, + pub warning_messages: Vec, + pub asset_status: Option, + pub asset_status_label: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentSessionRecord { + pub session_id: String, + pub seed_text: String, + pub current_turn: u32, + pub anchor_content: serde_json::Value, + pub progress_percent: u32, + pub last_assistant_reply: Option, + pub stage: String, + pub focus_card_id: Option, + pub creator_intent: serde_json::Value, + pub creator_intent_readiness: serde_json::Value, + pub anchor_pack: serde_json::Value, + pub lock_state: serde_json::Value, + pub draft_profile: serde_json::Value, + pub messages: Vec, + pub draft_cards: Vec, + pub pending_clarifications: Vec, + pub suggested_actions: Vec, + pub recommended_replies: Vec, + pub quality_findings: Vec, + pub asset_coverage: serde_json::Value, + pub checkpoints: Vec, + pub supported_actions: Vec, + pub publish_gate: Option, + pub result_preview: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfileUpsertRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub public_work_code: Option, + pub author_public_user_code: Option, + pub source_agent_session_id: Option, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: DomainCustomWorldThemeMode, + pub cover_image_src: Option, + pub profile_payload_json: String, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldPublishWorldRecordInput { + pub session_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub public_work_code: Option, + pub author_public_user_code: String, + pub draft_profile_json: String, + pub legacy_result_profile_json: Option, + pub setting_text: String, + pub author_display_name: String, + pub published_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub lock_state_json: Option, + pub draft_profile_json: Option, + pub pending_clarifications_json: String, + pub suggested_actions_json: String, + pub recommended_replies_json: String, + pub quality_findings_json: String, + pub asset_coverage_json: String, + pub checkpoints_json: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentMessageSubmitRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub operation_id: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentMessageFinalizeRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub phase_label: String, + pub phase_detail: String, + pub operation_status: String, + pub operation_progress: u32, + pub stage: String, + pub progress_percent: u32, + pub focus_card_id: Option, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub draft_profile_json: Option, + pub pending_clarifications_json: String, + pub suggested_actions_json: String, + pub recommended_replies_json: String, + pub quality_findings_json: String, + pub asset_coverage_json: String, + pub error_message: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentActionExecuteRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + pub action: String, + pub payload_json: Option, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentActionExecuteRecord { + pub operation: CustomWorldAgentOperationRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentMessageSubmitRecordInput { + 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 PuzzleAgentMessageFinalizeRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub stage: String, + pub progress_percent: u32, + pub anchor_pack_json: String, + pub error_message: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleGeneratedImagesSaveRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub candidates_json: String, + pub saved_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleSelectCoverImageRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub candidate_id: String, + pub selected_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzlePublishRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub work_id: String, + pub profile_id: String, + pub author_display_name: String, + pub level_name: Option, + pub summary: Option, + pub theme_tags: Option>, + pub published_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkUpsertRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub level_name: String, + pub summary: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunStartRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub started_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunSwapRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub first_piece_id: String, + pub second_piece_id: String, + pub swapped_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunDragRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub piece_id: String, + pub target_row: u32, + pub target_col: u32, + pub dragged_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunNextLevelRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub advanced_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAnchorItemRecord { + pub key: String, + pub label: String, + pub value: String, + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAnchorPackRecord { + pub theme_promise: PuzzleAnchorItemRecord, + pub visual_subject: PuzzleAnchorItemRecord, + pub visual_mood: PuzzleAnchorItemRecord, + pub composition_hooks: PuzzleAnchorItemRecord, + pub tags_and_forbidden: PuzzleAnchorItemRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleCreatorIntentRecord { + pub source_mode: String, + pub raw_messages_summary: String, + pub theme_promise: String, + pub visual_subject: String, + pub visual_mood: Vec, + pub composition_hooks: Vec, + pub theme_tags: Vec, + pub forbidden_directives: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleGeneratedImageCandidateRecord { + pub candidate_id: String, + pub image_src: String, + pub asset_id: String, + pub prompt: String, + pub actual_prompt: Option, + pub source_type: String, + pub selected: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleResultDraftRecord { + pub level_name: String, + pub summary: String, + pub theme_tags: Vec, + pub forbidden_directives: Vec, + pub creator_intent: Option, + pub anchor_pack: PuzzleAnchorPackRecord, + pub candidates: Vec, + pub selected_candidate_id: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub generation_status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentMessageRecord { + pub message_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentSuggestedActionRecord { + pub action_id: String, + pub action_type: String, + pub label: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleResultPreviewBlockerRecord { + pub blocker_id: String, + pub code: String, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleResultPreviewFindingRecord { + pub finding_id: String, + pub severity: String, + pub code: String, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleResultPreviewRecord { + pub draft: PuzzleResultDraftRecord, + pub blockers: Vec, + pub quality_findings: Vec, + pub publish_ready: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentSessionRecord { + pub session_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub anchor_pack: PuzzleAnchorPackRecord, + pub draft: Option, + pub messages: Vec, + pub last_assistant_reply: Option, + pub published_profile_id: Option, + pub suggested_actions: Vec, + pub result_preview: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkProfileRecord { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub level_name: String, + pub summary: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub publication_status: String, + pub updated_at: String, + pub published_at: Option, + pub play_count: u32, + pub publish_ready: bool, + pub anchor_pack: PuzzleAnchorPackRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleCellPositionRecord { + pub row: u32, + pub col: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzlePieceStateRecord { + pub piece_id: String, + pub correct_row: u32, + pub correct_col: u32, + pub current_row: u32, + pub current_col: u32, + pub merged_group_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleMergedGroupRecord { + pub group_id: String, + pub piece_ids: Vec, + pub occupied_cells: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleBoardRecord { + pub rows: u32, + pub cols: u32, + pub pieces: Vec, + pub merged_groups: Vec, + pub selected_piece_id: Option, + pub all_tiles_resolved: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRuntimeLevelRecord { + pub run_id: String, + pub level_index: u32, + pub grid_size: u32, + pub profile_id: String, + pub level_name: String, + pub author_display_name: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub board: PuzzleBoardRecord, + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunRecord { + pub run_id: String, + pub entry_profile_id: String, + pub cleared_level_count: u32, + pub current_level_index: u32, + pub current_grid_size: u32, + pub played_profile_ids: Vec, + pub previous_level_tags: Vec, + pub current_level: Option, + pub recommended_next_profile_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishMessageSubmitRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub assistant_message_id: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAssetGenerateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub asset_kind: String, + pub level: Option, + pub motion_key: Option, + pub asset_url: Option, + pub generated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishRunStartRecordInput { + pub run_id: String, + pub session_id: String, + pub owner_user_id: String, + pub started_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishRunInputSubmitRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub input_x: f32, + pub input_y: f32, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAnchorItemRecord { + pub key: String, + pub label: String, + pub value: String, + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAnchorPackRecord { + pub gameplay_promise: BigFishAnchorItemRecord, + pub ecology_visual_theme: BigFishAnchorItemRecord, + pub growth_ladder: BigFishAnchorItemRecord, + pub risk_tempo: BigFishAnchorItemRecord, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishLevelBlueprintRecord { + pub level: u32, + pub name: String, + pub one_line_fantasy: String, + pub silhouette_direction: String, + pub size_ratio: f32, + pub visual_prompt_seed: String, + pub motion_prompt_seed: String, + pub merge_source_level: Option, + pub prey_window: Vec, + pub threat_window: Vec, + pub is_final_level: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishBackgroundBlueprintRecord { + pub theme: String, + pub color_mood: String, + pub foreground_hints: String, + pub midground_composition: String, + pub background_depth: String, + pub safe_play_area_hint: String, + pub spawn_edge_hint: String, + pub background_prompt_seed: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishRuntimeParamsRecord { + pub level_count: u32, + pub merge_count_per_upgrade: u32, + pub spawn_target_count: u32, + pub leader_move_speed: f32, + pub follower_catch_up_speed: f32, + pub offscreen_cull_seconds: f32, + pub prey_spawn_delta_levels: Vec, + pub threat_spawn_delta_levels: Vec, + pub win_level: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishGameDraftRecord { + pub title: String, + pub subtitle: String, + pub core_fun: String, + pub ecology_theme: String, + pub levels: Vec, + pub background: BigFishBackgroundBlueprintRecord, + pub runtime_params: BigFishRuntimeParamsRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAgentMessageRecord { + pub message_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAssetSlotRecord { + pub slot_id: String, + pub asset_kind: String, + pub level: Option, + pub motion_key: Option, + pub status: String, + pub asset_url: Option, + pub prompt_snapshot: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAssetCoverageRecord { + pub level_main_image_ready_count: u32, + pub level_motion_ready_count: u32, + pub background_ready: bool, + pub required_level_count: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishSessionRecord { + pub session_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub anchor_pack: BigFishAnchorPackRecord, + pub draft: Option, + pub asset_slots: Vec, + pub asset_coverage: BigFishAssetCoverageRecord, + pub messages: Vec, + pub last_assistant_reply: Option, + pub publish_ready: bool, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct BigFishWorkSummaryRecord { + pub work_id: String, + pub source_session_id: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub cover_image_src: Option, + pub status: String, + pub updated_at_micros: i64, + pub publish_ready: bool, + pub level_count: u32, + pub level_main_image_ready_count: u32, + pub level_motion_ready_count: u32, + pub background_ready: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishVector2Record { + pub x: f32, + pub y: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishRuntimeEntityRecord { + pub entity_id: String, + pub level: u32, + pub position: BigFishVector2Record, + pub radius: f32, + pub offscreen_seconds: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishRuntimeRecord { + pub run_id: String, + pub session_id: String, + pub status: String, + pub tick: u64, + pub player_level: u32, + pub win_level: u32, + pub leader_entity_id: Option, + pub owned_entities: Vec, + pub wild_entities: Vec, + pub camera_center: BigFishVector2Record, + pub last_input: BigFishVector2Record, + pub event_log: Vec, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolveNpcBattleInteractionInput { + pub npc_interaction: DomainResolveNpcInteractionInput, + pub story_session_id: String, + pub actor_user_id: String, + pub battle_state_id: Option, + pub player_hp: i32, + pub player_max_hp: i32, + pub player_mana: i32, + pub player_max_mana: i32, + pub target_hp: i32, + pub target_max_hp: i32, + pub experience_reward: u32, + pub reward_items: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTaskStageRecord { + pub stage_kind: String, + pub label: String, + pub detail: String, + pub order: u32, + pub status: String, + pub text_output: Option, + pub structured_payload_json: Option, + pub warning_messages: Vec, + pub started_at: Option, + pub completed_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiResultReferenceRecord { + pub result_ref_id: String, + pub task_id: String, + pub reference_kind: String, + pub reference_id: String, + pub label: Option, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTextChunkRecord { + pub chunk_id: String, + pub task_id: String, + pub stage_kind: String, + pub sequence: u32, + pub delta_text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTaskRecord { + pub task_id: String, + pub task_kind: String, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + pub source_entity_id: Option, + pub request_payload_json: Option, + pub status: String, + pub failure_message: Option, + pub stages: Vec, + pub result_references: Vec, + pub latest_text_output: Option, + pub latest_structured_payload_json: Option, + pub version: u32, + pub created_at: String, + pub started_at: Option, + pub completed_at: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTaskMutationRecord { + pub task: AiTaskRecord, + pub text_chunk: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NpcStateRecord { + pub npc_state_id: String, + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub affinity: i32, + pub relation_stance: String, + pub help_used: bool, + pub chatted_count: u32, + pub gifts_given: u32, + pub recruited: bool, + pub trade_stock_signature: Option, + pub revealed_facts: Vec, + pub known_attribute_rumors: Vec, + pub first_meaningful_contact_resolved: bool, + pub seen_backstory_chapter_ids: Vec, + pub trust: u8, + pub warmth: u8, + pub ideological_fit: u8, + pub fear_or_guard: u8, + pub loyalty: u8, + pub current_conflict_tag: Option, + pub recent_approvals: Vec, + pub recent_disapprovals: Vec, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NpcInteractionRecord { + pub npc_state: NpcStateRecord, + pub interaction_status: String, + pub action_text: String, + pub result_text: String, + pub story_text: Option, + pub battle_mode: Option, + pub encounter_closed: bool, + pub affinity_changed: bool, + pub previous_affinity: i32, + pub next_affinity: i32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NpcBattleInteractionRecord { + pub npc_interaction: NpcInteractionRecord, + pub battle_state: BattleStateRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct NpcBattleInteractionSnapshot { + interaction: DomainNpcInteractionResult, + battle_state: DomainBattleStateSnapshot, +} + +pub(crate) fn build_battle_state_record(snapshot: DomainBattleStateSnapshot) -> BattleStateRecord { + BattleStateRecord { + battle_state_id: snapshot.battle_state_id, + story_session_id: snapshot.story_session_id, + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + chapter_id: snapshot.chapter_id, + target_npc_id: snapshot.target_npc_id, + target_name: snapshot.target_name, + battle_mode: snapshot.battle_mode.as_str().to_string(), + status: snapshot.status.as_str().to_string(), + player_hp: snapshot.player_hp, + player_max_hp: snapshot.player_max_hp, + player_mana: snapshot.player_mana, + player_max_mana: snapshot.player_max_mana, + target_hp: snapshot.target_hp, + target_max_hp: snapshot.target_max_hp, + experience_reward: snapshot.experience_reward, + reward_items: snapshot.reward_items, + turn_index: snapshot.turn_index, + last_action_function_id: snapshot.last_action_function_id, + last_action_text: snapshot.last_action_text, + last_result_text: snapshot.last_result_text, + last_damage_dealt: snapshot.last_damage_dealt, + last_damage_taken: snapshot.last_damage_taken, + last_outcome: snapshot.last_outcome.as_str().to_string(), + version: snapshot.version, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn build_resolve_combat_action_record( + result: DomainResolveCombatActionResult, +) -> ResolveCombatActionRecord { + ResolveCombatActionRecord { + battle_state: build_battle_state_record(result.snapshot), + damage_dealt: result.damage_dealt, + damage_taken: result.damage_taken, + outcome: result.outcome.as_str().to_string(), + } +} + +pub(crate) fn map_resolve_npc_battle_interaction_input( + input: ResolveNpcBattleInteractionInput, +) -> ResolveNpcBattleInteractionInput { + ResolveNpcBattleInteractionInput { + npc_interaction: ResolveNpcInteractionInput { + runtime_session_id: input.npc_interaction.runtime_session_id, + npc_id: input.npc_interaction.npc_id, + npc_name: input.npc_interaction.npc_name, + interaction_function_id: input.npc_interaction.interaction_function_id, + release_npc_id: input.npc_interaction.release_npc_id, + updated_at_micros: input.npc_interaction.updated_at_micros, + }, + story_session_id: input.story_session_id, + actor_user_id: input.actor_user_id, + battle_state_id: input.battle_state_id, + player_hp: input.player_hp, + player_max_hp: input.player_max_hp, + player_mana: input.player_mana, + player_max_mana: input.player_max_mana, + target_hp: input.target_hp, + target_max_hp: input.target_max_hp, + experience_reward: input.experience_reward, + reward_items: input + .reward_items + .into_iter() + .map(map_runtime_item_reward_item_snapshot) + .collect(), + } +} + +pub(crate) fn validate_npc_battle_interaction_input( + input: &ResolveNpcBattleInteractionInput, +) -> Result<(), SpacetimeClientError> { + let battle_state_input = DomainBattleStateInput { + battle_state_id: input + .battle_state_id + .clone() + .unwrap_or_else(|| "battle_preview".to_string()), + story_session_id: input.story_session_id.clone(), + runtime_session_id: input.npc_interaction.runtime_session_id.clone(), + actor_user_id: input.actor_user_id.clone(), + chapter_id: None, + target_npc_id: input.npc_interaction.npc_id.clone(), + target_name: input.npc_interaction.npc_name.clone(), + battle_mode: DomainBattleMode::Fight, + player_hp: input.player_hp, + player_max_hp: input.player_max_hp, + player_mana: input.player_mana, + player_max_mana: input.player_max_mana, + target_hp: input.target_hp, + target_max_hp: input.target_max_hp, + experience_reward: input.experience_reward, + reward_items: input.reward_items.clone(), + created_at_micros: input.npc_interaction.updated_at_micros, + }; + validate_battle_state_input(&battle_state_input) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; + for reward_item in input.reward_items.iter().cloned() { + normalize_reward_item_snapshot(reward_item) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; + } + + Ok(()) +} + +pub(crate) fn build_npc_state_record(snapshot: DomainNpcStateSnapshot) -> NpcStateRecord { + NpcStateRecord { + npc_state_id: snapshot.npc_state_id, + runtime_session_id: snapshot.runtime_session_id, + npc_id: snapshot.npc_id, + npc_name: snapshot.npc_name, + affinity: snapshot.affinity, + relation_stance: format_npc_relation_stance(snapshot.relation_state.stance).to_string(), + help_used: snapshot.help_used, + chatted_count: snapshot.chatted_count, + gifts_given: snapshot.gifts_given, + recruited: snapshot.recruited, + trade_stock_signature: snapshot.trade_stock_signature, + revealed_facts: snapshot.revealed_facts, + known_attribute_rumors: snapshot.known_attribute_rumors, + first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved, + seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids, + trust: snapshot.stance_profile.trust, + warmth: snapshot.stance_profile.warmth, + ideological_fit: snapshot.stance_profile.ideological_fit, + fear_or_guard: snapshot.stance_profile.fear_or_guard, + loyalty: snapshot.stance_profile.loyalty, + current_conflict_tag: snapshot.stance_profile.current_conflict_tag, + recent_approvals: snapshot.stance_profile.recent_approvals, + recent_disapprovals: snapshot.stance_profile.recent_disapprovals, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn build_npc_interaction_record(result: DomainNpcInteractionResult) -> NpcInteractionRecord { + NpcInteractionRecord { + npc_state: build_npc_state_record(result.npc_state), + interaction_status: format_npc_interaction_status(result.interaction_status).to_string(), + action_text: result.action_text, + result_text: result.result_text, + story_text: result.story_text, + battle_mode: result + .battle_mode + .map(|mode| format_npc_interaction_battle_mode(mode).to_string()), + encounter_closed: result.encounter_closed, + affinity_changed: result.affinity_changed, + previous_affinity: result.previous_affinity, + next_affinity: result.next_affinity, + } +} + +pub(crate) fn build_npc_battle_interaction_record( + result: NpcBattleInteractionSnapshot, +) -> NpcBattleInteractionRecord { + NpcBattleInteractionRecord { + npc_interaction: build_npc_interaction_record(result.interaction), + battle_state: build_battle_state_record(result.battle_state), + } +} + +pub(crate) fn format_npc_relation_stance(value: DomainNpcRelationStance) -> &'static str { + match value { + DomainNpcRelationStance::Hostile => "hostile", + DomainNpcRelationStance::Guarded => "guarded", + DomainNpcRelationStance::Neutral => "neutral", + DomainNpcRelationStance::Cooperative => "cooperative", + DomainNpcRelationStance::Bonded => "bonded", + } +} + +pub(crate) fn format_npc_interaction_status(value: DomainNpcInteractionStatus) -> &'static str { + match value { + DomainNpcInteractionStatus::Previewed => "previewed", + DomainNpcInteractionStatus::Dialogue => "dialogue", + DomainNpcInteractionStatus::Resolved => "resolved", + DomainNpcInteractionStatus::Recruited => "recruited", + DomainNpcInteractionStatus::BattlePending => "battle_pending", + DomainNpcInteractionStatus::Left => "left", + } +} + +pub(crate) fn format_npc_interaction_battle_mode(value: DomainNpcInteractionBattleMode) -> &'static str { + match value { + DomainNpcInteractionBattleMode::Fight => "fight", + DomainNpcInteractionBattleMode::Spar => "spar", + } +} + +pub(crate) fn map_inventory_container_kind( + value: InventoryContainerKind, +) -> module_inventory::InventoryContainerKind { + match value { + InventoryContainerKind::Backpack => { + module_inventory::InventoryContainerKind::Backpack + } + InventoryContainerKind::Equipment => { + module_inventory::InventoryContainerKind::Equipment + } + } +} + +pub(crate) fn map_inventory_item_rarity( + value: InventoryItemRarity, +) -> module_inventory::InventoryItemRarity { + match value { + InventoryItemRarity::Common => module_inventory::InventoryItemRarity::Common, + InventoryItemRarity::Uncommon => module_inventory::InventoryItemRarity::Uncommon, + InventoryItemRarity::Rare => module_inventory::InventoryItemRarity::Rare, + InventoryItemRarity::Epic => module_inventory::InventoryItemRarity::Epic, + InventoryItemRarity::Legendary => module_inventory::InventoryItemRarity::Legendary, + } +} + +pub(crate) fn map_inventory_equipment_slot( + value: InventoryEquipmentSlot, +) -> module_inventory::InventoryEquipmentSlot { + match value { + InventoryEquipmentSlot::Weapon => module_inventory::InventoryEquipmentSlot::Weapon, + InventoryEquipmentSlot::Armor => module_inventory::InventoryEquipmentSlot::Armor, + InventoryEquipmentSlot::Relic => module_inventory::InventoryEquipmentSlot::Relic, + } +} + +pub(crate) fn map_inventory_item_source_kind( + value: InventoryItemSourceKind, +) -> module_inventory::InventoryItemSourceKind { + match value { + InventoryItemSourceKind::StoryReward => { + module_inventory::InventoryItemSourceKind::StoryReward + } + InventoryItemSourceKind::QuestReward => { + module_inventory::InventoryItemSourceKind::QuestReward + } + InventoryItemSourceKind::TreasureReward => { + module_inventory::InventoryItemSourceKind::TreasureReward + } + InventoryItemSourceKind::NpcGift => { + module_inventory::InventoryItemSourceKind::NpcGift + } + InventoryItemSourceKind::NpcTrade => { + module_inventory::InventoryItemSourceKind::NpcTrade + } + InventoryItemSourceKind::CombatDrop => { + module_inventory::InventoryItemSourceKind::CombatDrop + } + InventoryItemSourceKind::ForgeCraft => { + module_inventory::InventoryItemSourceKind::ForgeCraft + } + InventoryItemSourceKind::ForgeReforge => { + module_inventory::InventoryItemSourceKind::ForgeReforge + } + InventoryItemSourceKind::ManualPatch => { + module_inventory::InventoryItemSourceKind::ManualPatch + } + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_procedure_result_type.rs new file mode 100644 index 00000000..8c8dbf3e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_procedure_result_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::auth_store_snapshot_record_type::AuthStoreSnapshotRecord; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AuthStoreSnapshotProcedureResult { + pub ok: bool, + pub record: Option::, + pub error_message: Option::, +} + + +impl __sdk::InModule for AuthStoreSnapshotProcedureResult { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_record_type.rs new file mode 100644 index 00000000..ee5e4262 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_record_type.rs @@ -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)] +pub struct AuthStoreSnapshotRecord { + pub snapshot_json: Option::, + pub updated_at_micros: Option::, +} + + +impl __sdk::InModule for AuthStoreSnapshotRecord { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_table.rs new file mode 100644 index 00000000..2d9cf56f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_table.rs @@ -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 spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; +use super::auth_store_snapshot_type::AuthStoreSnapshot; + +/// 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, + 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::("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 + '_ { 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, + 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::("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 { + self.imp.find(col_val) + } + } + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + + let _table = client_cache.get_or_make_table::("auth_store_snapshot"); + _table.add_unique_constraint::("snapshot_id", |row| &row.snapshot_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ).with_cause(e).into() + }) +} + + #[allow(non_camel_case_types)] + /// Extension trait for query builder access to the table `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; + } + + impl auth_store_snapshotQueryTableAccess for __sdk::QueryTableAccessor { + fn auth_store_snapshot(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("auth_store_snapshot") + } + } + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_type.rs new file mode 100644 index 00000000..af600737 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_type.rs @@ -0,0 +1,66 @@ +// 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 AuthStoreSnapshot { + pub snapshot_id: String, + pub snapshot_json: String, + pub updated_at: __sdk::Timestamp, +} + + +impl __sdk::InModule for AuthStoreSnapshot { + type Module = super::RemoteModule; +} + + +/// Column accessor struct for the table `AuthStoreSnapshot`. +/// +/// Provides typed access to columns for query building. +pub struct AuthStoreSnapshotCols { + pub snapshot_id: __sdk::__query_builder::Col, + pub snapshot_json: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for AuthStoreSnapshot { + type Cols = AuthStoreSnapshotCols; + fn cols(table_name: &'static str) -> Self::Cols { + AuthStoreSnapshotCols { + snapshot_id: __sdk::__query_builder::Col::new(table_name, "snapshot_id"), + snapshot_json: __sdk::__query_builder::Col::new(table_name, "snapshot_json"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + + } + } +} + +/// Indexed column accessor struct for the table `AuthStoreSnapshot`. +/// +/// Provides typed access to indexed columns for query building. +pub struct AuthStoreSnapshotIxCols { + pub snapshot_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for AuthStoreSnapshot { + type IxCols = AuthStoreSnapshotIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + AuthStoreSnapshotIxCols { + snapshot_id: __sdk::__query_builder::IxCol::new(table_name, "snapshot_id"), + + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for AuthStoreSnapshot {} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_upsert_input_type.rs new file mode 100644 index 00000000..17e2aa35 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_upsert_input_type.rs @@ -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)] +pub struct AuthStoreSnapshotUpsertInput { + pub snapshot_json: String, + pub updated_at_micros: i64, +} + + +impl __sdk::InModule for AuthStoreSnapshotUpsertInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs new file mode 100644 index 00000000..887894ea --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs @@ -0,0 +1,53 @@ +// 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::auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct GetAuthStoreSnapshotArgs { + } + + +impl __sdk::InModule for GetAuthStoreSnapshotArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_auth_store_snapshot`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_auth_store_snapshot { + fn get_auth_store_snapshot(&self, ) { + self.get_auth_store_snapshot_then( |_, _| {}); + } + + fn get_auth_store_snapshot_then( + &self, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl get_auth_store_snapshot for super::RemoteProcedures { + fn get_auth_store_snapshot_then( + &self, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, AuthStoreSnapshotProcedureResult>( + "get_auth_store_snapshot", + GetAuthStoreSnapshotArgs { }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 604b75ff..967cae67 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -44,6 +44,13 @@ pub mod asset_object_access_policy_type; pub mod asset_object_procedure_result_type; pub mod asset_object_upsert_input_type; pub mod asset_object_upsert_snapshot_type; +<<<<<<< HEAD +======= +pub mod auth_store_snapshot_type; +pub mod auth_store_snapshot_procedure_result_type; +pub mod auth_store_snapshot_record_type; +pub mod auth_store_snapshot_upsert_input_type; +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub mod battle_mode_type; pub mod battle_state_type; pub mod battle_state_input_type; @@ -339,6 +346,10 @@ pub mod ai_task_stage_table; pub mod ai_text_chunk_table; pub mod asset_entity_binding_table; pub mod asset_object_table; +<<<<<<< HEAD +======= +pub mod auth_store_snapshot_table; +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub mod battle_state_table; pub mod big_fish_agent_message_table; pub mod big_fish_asset_slot_table; @@ -396,10 +407,17 @@ pub mod delete_runtime_snapshot_and_return_procedure; pub mod drag_puzzle_piece_or_group_procedure; pub mod execute_custom_world_agent_action_procedure; pub mod fail_ai_task_and_return_procedure; +<<<<<<< HEAD pub mod finalize_big_fish_agent_message_turn_procedure; pub mod finalize_custom_world_agent_message_turn_procedure; pub mod finalize_puzzle_agent_message_turn_procedure; pub mod generate_big_fish_asset_procedure; +======= +pub mod finalize_custom_world_agent_message_turn_procedure; +pub mod finalize_puzzle_agent_message_turn_procedure; +pub mod generate_big_fish_asset_procedure; +pub mod get_auth_store_snapshot_procedure; +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub mod get_battle_state_procedure; pub mod get_big_fish_run_procedure; pub mod get_big_fish_session_procedure; @@ -452,6 +470,7 @@ pub mod submit_puzzle_agent_message_procedure; pub mod swap_puzzle_pieces_procedure; pub mod unpublish_custom_world_profile_and_return_procedure; pub mod update_puzzle_work_procedure; +pub mod upsert_auth_store_snapshot_procedure; pub mod upsert_chapter_progression_and_return_procedure; pub mod upsert_custom_world_profile_and_return_procedure; pub mod upsert_npc_state_and_return_procedure; @@ -492,6 +511,13 @@ pub use asset_object_access_policy_type::AssetObjectAccessPolicy; pub use asset_object_procedure_result_type::AssetObjectProcedureResult; pub use asset_object_upsert_input_type::AssetObjectUpsertInput; pub use asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; +<<<<<<< HEAD +======= +pub use auth_store_snapshot_type::AuthStoreSnapshot; +pub use auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult; +pub use auth_store_snapshot_record_type::AuthStoreSnapshotRecord; +pub use auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput; +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub use battle_mode_type::BattleMode; pub use battle_state_type::BattleState; pub use battle_state_input_type::BattleStateInput; @@ -763,6 +789,10 @@ pub use ai_task_stage_table::*; pub use ai_text_chunk_table::*; pub use asset_entity_binding_table::*; pub use asset_object_table::*; +<<<<<<< HEAD +======= +pub use auth_store_snapshot_table::*; +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub use battle_state_table::*; pub use big_fish_agent_message_table::*; pub use big_fish_asset_slot_table::*; @@ -844,10 +874,17 @@ pub use delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_an pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group; pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action; pub use fail_ai_task_and_return_procedure::fail_ai_task_and_return; +<<<<<<< HEAD pub use finalize_big_fish_agent_message_turn_procedure::finalize_big_fish_agent_message_turn; pub use finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn; pub use finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; +======= +pub use finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn; +pub use finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn; +pub use generate_big_fish_asset_procedure::generate_big_fish_asset; +pub use get_auth_store_snapshot_procedure::get_auth_store_snapshot; +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub use get_battle_state_procedure::get_battle_state; pub use get_big_fish_run_procedure::get_big_fish_run; pub use get_big_fish_session_procedure::get_big_fish_session; @@ -900,6 +937,7 @@ pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message; pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces; pub use unpublish_custom_world_profile_and_return_procedure::unpublish_custom_world_profile_and_return; pub use update_puzzle_work_procedure::update_puzzle_work; +pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot; pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return; pub use upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return; pub use upsert_npc_state_and_return_procedure::upsert_npc_state_and_return; @@ -1162,6 +1200,7 @@ pub struct DbUpdate { ai_text_chunk: __sdk::TableUpdate, asset_entity_binding: __sdk::TableUpdate, asset_object: __sdk::TableUpdate, + auth_store_snapshot: __sdk::TableUpdate, battle_state: __sdk::TableUpdate, big_fish_agent_message: __sdk::TableUpdate, big_fish_asset_slot: __sdk::TableUpdate, @@ -1210,6 +1249,10 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "ai_text_chunk" => db_update.ai_text_chunk.append(ai_text_chunk_table::parse_table_update(table_update)?), "asset_entity_binding" => db_update.asset_entity_binding.append(asset_entity_binding_table::parse_table_update(table_update)?), "asset_object" => db_update.asset_object.append(asset_object_table::parse_table_update(table_update)?), +<<<<<<< HEAD +======= + "auth_store_snapshot" => db_update.auth_store_snapshot.append(auth_store_snapshot_table::parse_table_update(table_update)?), +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) "battle_state" => db_update.battle_state.append(battle_state_table::parse_table_update(table_update)?), "big_fish_agent_message" => db_update.big_fish_agent_message.append(big_fish_agent_message_table::parse_table_update(table_update)?), "big_fish_asset_slot" => db_update.big_fish_asset_slot.append(big_fish_asset_slot_table::parse_table_update(table_update)?), @@ -1270,6 +1313,10 @@ impl __sdk::DbUpdate for DbUpdate { diff.ai_text_chunk = cache.apply_diff_to_table::("ai_text_chunk", &self.ai_text_chunk).with_updates_by_pk(|row| &row.text_chunk_row_id); diff.asset_entity_binding = cache.apply_diff_to_table::("asset_entity_binding", &self.asset_entity_binding).with_updates_by_pk(|row| &row.binding_id); diff.asset_object = cache.apply_diff_to_table::("asset_object", &self.asset_object).with_updates_by_pk(|row| &row.asset_object_id); +<<<<<<< HEAD +======= + diff.auth_store_snapshot = cache.apply_diff_to_table::("auth_store_snapshot", &self.auth_store_snapshot).with_updates_by_pk(|row| &row.snapshot_id); +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) diff.battle_state = cache.apply_diff_to_table::("battle_state", &self.battle_state).with_updates_by_pk(|row| &row.battle_state_id); diff.big_fish_agent_message = cache.apply_diff_to_table::("big_fish_agent_message", &self.big_fish_agent_message).with_updates_by_pk(|row| &row.message_id); diff.big_fish_asset_slot = cache.apply_diff_to_table::("big_fish_asset_slot", &self.big_fish_asset_slot).with_updates_by_pk(|row| &row.slot_id); @@ -1315,6 +1362,10 @@ for table_rows in raw.tables { "ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), +<<<<<<< HEAD +======= + "auth_store_snapshot" => db_update.auth_store_snapshot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) "battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -1360,6 +1411,10 @@ for table_rows in raw.tables { "ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), +<<<<<<< HEAD +======= + "auth_store_snapshot" => db_update.auth_store_snapshot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) "battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -1407,6 +1462,7 @@ pub struct AppliedDiff<'r> { ai_text_chunk: __sdk::TableAppliedDiff<'r, AiTextChunk>, asset_entity_binding: __sdk::TableAppliedDiff<'r, AssetEntityBinding>, asset_object: __sdk::TableAppliedDiff<'r, AssetObject>, + auth_store_snapshot: __sdk::TableAppliedDiff<'r, AuthStoreSnapshot>, battle_state: __sdk::TableAppliedDiff<'r, BattleState>, big_fish_agent_message: __sdk::TableAppliedDiff<'r, BigFishAgentMessage>, big_fish_asset_slot: __sdk::TableAppliedDiff<'r, BigFishAssetSlot>, @@ -1455,6 +1511,10 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { callbacks.invoke_table_row_callbacks::("ai_text_chunk", &self.ai_text_chunk, event); callbacks.invoke_table_row_callbacks::("asset_entity_binding", &self.asset_entity_binding, event); callbacks.invoke_table_row_callbacks::("asset_object", &self.asset_object, event); +<<<<<<< HEAD +======= + callbacks.invoke_table_row_callbacks::("auth_store_snapshot", &self.auth_store_snapshot, event); +>>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) callbacks.invoke_table_row_callbacks::("battle_state", &self.battle_state, event); callbacks.invoke_table_row_callbacks::("big_fish_agent_message", &self.big_fish_agent_message, event); callbacks.invoke_table_row_callbacks::("big_fish_asset_slot", &self.big_fish_asset_slot, event); @@ -2144,6 +2204,7 @@ fn register_tables(client_cache: &mut __sdk::ClientCache) { ai_text_chunk_table::register_table(client_cache); asset_entity_binding_table::register_table(client_cache); asset_object_table::register_table(client_cache); + auth_store_snapshot_table::register_table(client_cache); battle_state_table::register_table(client_cache); big_fish_agent_message_table::register_table(client_cache); big_fish_asset_slot_table::register_table(client_cache); @@ -2184,6 +2245,7 @@ const ALL_TABLE_NAMES: &'static [&'static str] = &[ "ai_text_chunk", "asset_entity_binding", "asset_object", + "auth_store_snapshot", "battle_state", "big_fish_agent_message", "big_fish_asset_slot", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs new file mode 100644 index 00000000..cc7f5700 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult; +use super::auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct UpsertAuthStoreSnapshotArgs { + pub input: AuthStoreSnapshotUpsertInput, +} + + +impl __sdk::InModule for UpsertAuthStoreSnapshotArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upsert_auth_store_snapshot`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upsert_auth_store_snapshot { + fn upsert_auth_store_snapshot(&self, input: AuthStoreSnapshotUpsertInput, +) { + self.upsert_auth_store_snapshot_then(input, |_, _| {}); + } + + fn upsert_auth_store_snapshot_then( + &self, + input: AuthStoreSnapshotUpsertInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl upsert_auth_store_snapshot for super::RemoteProcedures { + fn upsert_auth_store_snapshot_then( + &self, + input: AuthStoreSnapshotUpsertInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, AuthStoreSnapshotProcedureResult>( + "upsert_auth_store_snapshot", + UpsertAuthStoreSnapshotArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/npc.rs b/server-rs/crates/spacetime-client/src/npc.rs new file mode 100644 index 00000000..6d9a0367 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/npc.rs @@ -0,0 +1,28 @@ +use super::*; + +impl SpacetimeClient { + pub async fn resolve_npc_battle_interaction( + &self, + input: ResolveNpcBattleInteractionInput, + ) -> Result { + validate_npc_battle_interaction_input(&input)?; + let procedure_input = map_resolve_npc_battle_interaction_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .resolve_npc_battle_interaction_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_npc_battle_interaction_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs new file mode 100644 index 00000000..60e1f5b6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -0,0 +1,459 @@ +use super::*; + +impl SpacetimeClient { + pub async fn create_puzzle_agent_session( + &self, + input: PuzzleAgentSessionCreateRecordInput, + ) -> Result { + let procedure_input = PuzzleAgentSessionCreateInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + seed_text: input.seed_text, + welcome_message_id: input.welcome_message_id, + welcome_message_text: input.welcome_message_text, + created_at_micros: input.created_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().create_puzzle_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn get_puzzle_agent_session( + &self, + session_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = PuzzleAgentSessionGetInput { + session_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_puzzle_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn submit_puzzle_agent_message( + &self, + input: PuzzleAgentMessageSubmitRecordInput, + ) -> Result { + let procedure_input = PuzzleAgentMessageSubmitInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + user_message_id: input.user_message_id, + user_message_text: input.user_message_text, + submitted_at_micros: input.submitted_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().submit_puzzle_agent_message_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn finalize_puzzle_agent_message( + &self, + input: PuzzleAgentMessageFinalizeRecordInput, + ) -> Result { + let procedure_input = PuzzleAgentMessageFinalizeInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + assistant_message_id: input.assistant_message_id, + assistant_reply_text: input.assistant_reply_text, + stage: parse_puzzle_agent_stage_record(input.stage.as_str())?, + progress_percent: input.progress_percent, + anchor_pack_json: input.anchor_pack_json, + error_message: input.error_message, + updated_at_micros: input.updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .finalize_puzzle_agent_message_turn_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn compile_puzzle_agent_draft( + &self, + session_id: String, + owner_user_id: String, + compiled_at_micros: i64, + ) -> Result { + let procedure_input = PuzzleDraftCompileInput { + session_id, + owner_user_id, + compiled_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().compile_puzzle_agent_draft_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn save_puzzle_generated_images( + &self, + input: PuzzleGeneratedImagesSaveRecordInput, + ) -> Result { + let procedure_input = PuzzleGeneratedImagesSaveInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + candidates_json: input.candidates_json, + saved_at_micros: input.saved_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().save_puzzle_generated_images_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn select_puzzle_cover_image( + &self, + input: PuzzleSelectCoverImageRecordInput, + ) -> Result { + let procedure_input = PuzzleSelectCoverImageInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + candidate_id: input.candidate_id, + selected_at_micros: input.selected_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().select_puzzle_cover_image_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn publish_puzzle_work( + &self, + input: PuzzlePublishRecordInput, + ) -> Result { + let procedure_input = PuzzlePublishInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + work_id: input.work_id, + profile_id: input.profile_id, + author_display_name: input.author_display_name, + level_name: input.level_name, + summary: input.summary, + theme_tags: input.theme_tags, + published_at_micros: input.published_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .publish_puzzle_work_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_work_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn list_puzzle_works( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = PuzzleWorksListInput { owner_user_id }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .list_puzzle_works_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_works_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn get_puzzle_work_detail( + &self, + profile_id: String, + ) -> Result { + let procedure_input = PuzzleWorkGetInput { profile_id }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_puzzle_work_detail_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn update_puzzle_work( + &self, + input: PuzzleWorkUpsertRecordInput, + ) -> Result { + let procedure_input = PuzzleWorkUpsertInput { + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + level_name: input.level_name, + summary: input.summary, + theme_tags: input.theme_tags, + cover_image_src: input.cover_image_src, + cover_asset_id: input.cover_asset_id, + updated_at_micros: input.updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .update_puzzle_work_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_work_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn list_puzzle_gallery( + &self, + ) -> Result, SpacetimeClientError> { + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .list_puzzle_gallery_then(move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_works_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn get_puzzle_gallery_detail( + &self, + profile_id: String, + ) -> Result { + let procedure_input = PuzzleWorkGetInput { profile_id }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_puzzle_gallery_detail_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn start_puzzle_run( + &self, + input: PuzzleRunStartRecordInput, + ) -> Result { + let procedure_input = PuzzleRunStartInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + started_at_micros: input.started_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .start_puzzle_run_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_run_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn get_puzzle_run( + &self, + run_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = PuzzleRunGetInput { + run_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_puzzle_run_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_run_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn swap_puzzle_pieces( + &self, + input: PuzzleRunSwapRecordInput, + ) -> Result { + let procedure_input = PuzzleRunSwapInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + first_piece_id: input.first_piece_id, + second_piece_id: input.second_piece_id, + swapped_at_micros: input.swapped_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .swap_puzzle_pieces_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_run_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn drag_puzzle_piece_or_group( + &self, + input: PuzzleRunDragRecordInput, + ) -> Result { + let procedure_input = PuzzleRunDragInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + piece_id: input.piece_id, + target_row: input.target_row, + target_col: input.target_col, + dragged_at_micros: input.dragged_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().drag_puzzle_piece_or_group_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn advance_puzzle_next_level( + &self, + input: PuzzleRunNextLevelRecordInput, + ) -> Result { + let procedure_input = PuzzleRunNextLevelInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + advanced_at_micros: input.advanced_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().advance_puzzle_next_level_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs new file mode 100644 index 00000000..05435f6c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -0,0 +1,339 @@ +use super::*; + +impl SpacetimeClient { + pub async fn get_runtime_settings( + &self, + user_id: String, + ) -> Result { + let procedure_input = map_runtime_setting_get_input( + build_runtime_setting_get_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_runtime_setting_or_default_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_setting_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn list_platform_browse_history( + &self, + user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = map_runtime_browse_history_list_input( + build_runtime_browse_history_list_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().list_platform_browse_history_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_browse_history_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn get_profile_dashboard( + &self, + user_id: String, + ) -> Result { + let procedure_input = map_runtime_profile_dashboard_get_input( + build_runtime_profile_dashboard_get_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_profile_dashboard_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_dashboard_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn list_profile_wallet_ledger( + &self, + user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = map_runtime_profile_wallet_ledger_list_input( + build_runtime_profile_wallet_ledger_list_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().list_profile_wallet_ledger_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_wallet_ledger_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn get_profile_play_stats( + &self, + user_id: String, + ) -> Result { + let procedure_input = map_runtime_profile_play_stats_get_input( + build_runtime_profile_play_stats_get_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_profile_play_stats_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_play_stats_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn get_runtime_snapshot( + &self, + user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = map_runtime_snapshot_get_input( + build_runtime_snapshot_get_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_runtime_snapshot_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_snapshot_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn put_runtime_snapshot( + &self, + user_id: String, + saved_at_micros: i64, + bottom_tab: String, + game_state: serde_json::Value, + current_story: Option, + updated_at_micros: i64, + ) -> Result { + let procedure_input = map_runtime_snapshot_upsert_input( + build_runtime_snapshot_upsert_input( + user_id, + saved_at_micros, + bottom_tab, + game_state, + current_story, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .upsert_runtime_snapshot_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_snapshot_required_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn delete_runtime_snapshot( + &self, + user_id: String, + ) -> Result { + let procedure_input = map_runtime_snapshot_delete_input( + build_runtime_snapshot_delete_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .delete_runtime_snapshot_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_snapshot_delete_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn list_profile_save_archives( + &self, + user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = map_runtime_profile_save_archive_list_input( + build_runtime_profile_save_archive_list_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().list_profile_save_archives_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_save_archive_list_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn resume_profile_save_archive( + &self, + user_id: String, + world_key: String, + ) -> Result<(RuntimeProfileSaveArchiveRecord, RuntimeSnapshotRecord), SpacetimeClientError> + { + let procedure_input = map_runtime_profile_save_archive_resume_input( + build_runtime_profile_save_archive_resume_input(user_id, world_key) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .resume_profile_save_archive_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_save_archive_resume_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn put_runtime_settings( + &self, + user_id: String, + music_volume: f32, + platform_theme: RuntimePlatformTheme, + updated_at_micros: i64, + ) -> Result { + let procedure_input = map_runtime_setting_upsert_input( + build_runtime_setting_upsert_input( + user_id, + music_volume, + platform_theme, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .upsert_runtime_setting_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_setting_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + + pub async fn upsert_platform_browse_history_entries( + &self, + user_id: String, + entries: Vec, + updated_at_micros: i64, + ) -> Result, SpacetimeClientError> { + let procedure_input = map_runtime_browse_history_sync_input( + build_runtime_browse_history_sync_input(user_id, entries, updated_at_micros) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .upsert_platform_browse_history_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_browse_history_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn clear_platform_browse_history( + &self, + user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = map_runtime_browse_history_clear_input( + build_runtime_browse_history_clear_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .clear_platform_browse_history_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_browse_history_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + +} diff --git a/server-rs/crates/spacetime-client/src/story.rs b/server-rs/crates/spacetime-client/src/story.rs new file mode 100644 index 00000000..57eee7a2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/story.rs @@ -0,0 +1,100 @@ +use super::*; + +impl SpacetimeClient { + pub async fn begin_story_session( + &self, + story_session_id: String, + runtime_session_id: String, + actor_user_id: String, + world_profile_id: String, + initial_prompt: String, + opening_summary: Option, + created_at_micros: i64, + ) -> Result { + let procedure_input = map_story_session_input( + build_story_session_input( + story_session_id, + runtime_session_id, + actor_user_id, + world_profile_id, + initial_prompt, + opening_summary, + created_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().begin_story_session_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_story_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn continue_story( + &self, + story_session_id: String, + event_id: String, + narrative_text: String, + choice_function_id: Option, + updated_at_micros: i64, + ) -> Result { + let procedure_input = map_story_continue_input( + build_story_continue_input( + story_session_id, + event_id, + narrative_text, + choice_function_id, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().continue_story_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_story_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + + pub async fn get_story_session_state( + &self, + story_session_id: String, + ) -> Result { + let procedure_input = map_story_session_state_input( + build_story_session_state_input(story_session_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_story_session_state_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_story_session_state_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + +} diff --git a/server-rs/crates/spacetime-module/src/auth.rs b/server-rs/crates/spacetime-module/src/auth.rs new file mode 100644 index 00000000..13d7c171 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/auth.rs @@ -0,0 +1,122 @@ +use crate::*; + +const AUTH_STORE_SNAPSHOT_ID: &str = "default"; + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AuthStoreSnapshotRecord { + pub snapshot_json: Option, + pub updated_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AuthStoreSnapshotUpsertInput { + pub snapshot_json: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AuthStoreSnapshotProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[spacetimedb::table(accessor = auth_store_snapshot)] +pub struct AuthStoreSnapshot { + #[primary_key] + pub(crate) snapshot_id: String, + pub(crate) snapshot_json: String, + pub(crate) updated_at: Timestamp, +} + +// Axum 启动恢复认证状态时读取当前快照;记录不存在代表尚未产生登录态。 +#[spacetimedb::procedure] +pub fn get_auth_store_snapshot(ctx: &mut ProcedureContext) -> AuthStoreSnapshotProcedureResult { + match ctx.try_with_tx(|tx| get_auth_store_snapshot_tx(tx)) { + Ok(record) => AuthStoreSnapshotProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => AuthStoreSnapshotProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// Axum 每次鉴权仓储变更后覆盖写入整份快照,后续拆表阶段再替换为细粒度 reducer。 +#[spacetimedb::procedure] +pub fn upsert_auth_store_snapshot( + ctx: &mut ProcedureContext, + input: AuthStoreSnapshotUpsertInput, +) -> AuthStoreSnapshotProcedureResult { + match ctx.try_with_tx(|tx| upsert_auth_store_snapshot_tx(tx, input.clone())) { + Ok(record) => AuthStoreSnapshotProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => AuthStoreSnapshotProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +fn get_auth_store_snapshot_tx(ctx: &ReducerContext) -> Result { + Ok( + match ctx + .db + .auth_store_snapshot() + .snapshot_id() + .find(&AUTH_STORE_SNAPSHOT_ID.to_string()) + { + Some(row) => AuthStoreSnapshotRecord { + snapshot_json: Some(row.snapshot_json), + updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()), + }, + None => AuthStoreSnapshotRecord { + snapshot_json: None, + updated_at_micros: None, + }, + }, + ) +} + +fn upsert_auth_store_snapshot_tx( + ctx: &ReducerContext, + input: AuthStoreSnapshotUpsertInput, +) -> Result { + let snapshot_json = input.snapshot_json.trim().to_string(); + if snapshot_json.is_empty() { + return Err("认证快照 JSON 不能为空".to_string()); + } + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + + if ctx + .db + .auth_store_snapshot() + .snapshot_id() + .find(&AUTH_STORE_SNAPSHOT_ID.to_string()) + .is_some() + { + ctx.db + .auth_store_snapshot() + .snapshot_id() + .delete(&AUTH_STORE_SNAPSHOT_ID.to_string()); + } + + ctx.db.auth_store_snapshot().insert(AuthStoreSnapshot { + snapshot_id: AUTH_STORE_SNAPSHOT_ID.to_string(), + snapshot_json: snapshot_json.clone(), + updated_at, + }); + + Ok(AuthStoreSnapshotRecord { + snapshot_json: Some(snapshot_json), + updated_at_micros: Some(input.updated_at_micros), + }) +} diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 8bf740d0..06bd1979 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -21,9 +21,11 @@ use module_quest::{ pub(crate) use serde_json::{Map as JsonMap, Value as JsonValue, json}; pub(crate) use shared_kernel::format_timestamp_micros; pub use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; +use std::collections::HashSet; mod ai; mod asset_metadata; +mod auth; mod big_fish; mod domain_types; mod entry; @@ -32,6 +34,7 @@ mod runtime; pub use ai::*; pub use asset_metadata::*; +pub use auth::*; pub use big_fish::*; pub use domain_types::*; pub use entry::*; @@ -2733,10 +2736,12 @@ fn list_custom_world_work_snapshots( validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?; let mut items = Vec::new(); + let mut active_agent_session_ids = HashSet::new(); for session in ctx.db.custom_world_agent_session().iter().filter(|row| { row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published }) { + active_agent_session_ids.insert(session.session_id.clone()); let gate = build_custom_world_publish_gate_from_session(&session); let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()); let title = resolve_session_work_title(&session, draft_profile.as_ref()); @@ -2780,6 +2785,7 @@ fn list_custom_world_work_snapshots( .custom_world_profile() .iter() .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()) + .filter(|row| should_include_custom_world_profile_work(row, &active_agent_session_ids)) { items.push(CustomWorldWorkSummarySnapshot { work_id: format!("published:{}", profile.profile_id), @@ -2834,6 +2840,24 @@ fn list_custom_world_work_snapshots( Ok(items) } +fn should_include_custom_world_profile_work( + row: &CustomWorldProfile, + active_agent_session_ids: &HashSet, +) -> bool { + // 已发布 profile 是正式作品;即使来源会话还存在,也必须保留独立入口。 + if row.publication_status == CustomWorldPublicationStatus::Published { + return true; + } + + // 未发布 profile 若来源于仍可继续聊天的 Agent 会话,只是同一草稿的编译产物, + // works 里保留 agent_session 即可,避免草稿分组显示两份同名作品。 + row.source_agent_session_id + .as_ref() + .map_or(true, |session_id| { + !active_agent_session_ids.contains(session_id) + }) +} + fn get_custom_world_agent_card_detail_tx( ctx: &ReducerContext, input: CustomWorldAgentCardDetailGetInput, @@ -5996,6 +6020,113 @@ mod tests { )); } + #[test] + fn custom_world_works_hides_compiled_draft_profile_when_agent_session_is_active() { + fn build_test_custom_world_profile( + profile_id: &str, + source_agent_session_id: Option<&str>, + publication_status: CustomWorldPublicationStatus, + ) -> CustomWorldProfile { + CustomWorldProfile { + profile_id: profile_id.to_string(), + owner_user_id: "user-1".to_string(), + public_work_code: if publication_status == CustomWorldPublicationStatus::Published { + Some("CW-00000001".to_string()) + } else { + None + }, + author_public_user_code: None, + source_agent_session_id: source_agent_session_id.map(str::to_string), + publication_status, + world_name: "潮雾列岛".to_string(), + subtitle: String::new(), + summary_text: String::new(), + theme_mode: CustomWorldThemeMode::Mythic, + cover_image_src: None, + profile_payload_json: "{}".to_string(), + playable_npc_count: 0, + landmark_count: 0, + author_display_name: "玩家".to_string(), + published_at: if publication_status == CustomWorldPublicationStatus::Published { + Some(Timestamp::from_micros_since_unix_epoch(2)) + } else { + None + }, + deleted_at: None, + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(1), + } + } + + let draft_profile = build_test_custom_world_profile( + "profile-1", + Some("session-1"), + CustomWorldPublicationStatus::Draft, + ); + let orphan_draft_profile = build_test_custom_world_profile( + "profile-2", + Some("session-2"), + CustomWorldPublicationStatus::Draft, + ); + let published_profile = build_test_custom_world_profile( + "profile-3", + Some("session-1"), + CustomWorldPublicationStatus::Published, + ); + let mut active_agent_session_ids = HashSet::new(); + active_agent_session_ids.insert("session-1".to_string()); + + assert!(!should_include_custom_world_profile_work( + &draft_profile, + &active_agent_session_ids, + )); + assert!(should_include_custom_world_profile_work( + &orphan_draft_profile, + &active_agent_session_ids, + )); + assert!(should_include_custom_world_profile_work( + &published_profile, + &active_agent_session_ids, + )); + } + + #[test] + fn custom_world_works_keeps_compiled_draft_profile_without_active_agent_session() { + let draft_profile = CustomWorldProfile { + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + public_work_code: None, + author_public_user_code: None, + source_agent_session_id: Some("session-1".to_string()), + publication_status: CustomWorldPublicationStatus::Draft, + world_name: "潮雾列岛".to_string(), + subtitle: String::new(), + summary_text: String::new(), + theme_mode: CustomWorldThemeMode::Mythic, + cover_image_src: None, + profile_payload_json: "{}".to_string(), + playable_npc_count: 0, + landmark_count: 0, + author_display_name: "玩家".to_string(), + published_at: None, + deleted_at: None, + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(1), + }; + let mut active_agent_session_ids = HashSet::new(); + + assert!(should_include_custom_world_profile_work( + &draft_profile, + &active_agent_session_ids, + )); + + active_agent_session_ids.insert("session-2".to_string()); + assert!(should_include_custom_world_profile_work( + &draft_profile, + &active_agent_session_ids, + )); + } + #[test] fn summarize_publish_gate_accepts_current_agent_result_schema() { let draft_profile = serde_json::from_str::( diff --git a/src/components/auth/AccountModal.test.tsx b/src/components/auth/AccountModal.test.tsx index a508ca8d..e4d1b03c 100644 --- a/src/components/auth/AccountModal.test.tsx +++ b/src/components/auth/AccountModal.test.tsx @@ -66,6 +66,7 @@ function renderAccountModal(overrides?: { expiresInSeconds: 300, })} onChangePhone={vi.fn().mockResolvedValue(undefined)} + onChangePassword={vi.fn().mockResolvedValue(undefined)} />, ); } diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index bba8c7fe..12cd18ba 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -52,6 +52,10 @@ type AccountModalProps = { expiresInSeconds: number; }>; onChangePhone: (phone: string, code: string) => Promise; + onChangePassword: ( + currentPassword: string, + newPassword: string, + ) => Promise; }; const SETTINGS_SECTIONS: Array<{ @@ -285,24 +289,31 @@ export function AccountModal({ changePhoneCaptchaChallenge, onSendChangePhoneCode, onChangePhone, + onChangePassword, }: AccountModalProps) { const [activeSection, setActiveSection] = useState( normalizeSettingsSection(initialSection), ); const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false); + const [isPasswordPanelOpen, setIsPasswordPanelOpen] = useState(false); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); const [captchaAnswer, setCaptchaAnswer] = useState(''); const [changePhoneError, setChangePhoneError] = useState(''); + const [passwordError, setPasswordError] = useState(''); const [changePhoneHint, setChangePhoneHint] = useState(''); const [accountNotice, setAccountNotice] = useState(''); const [sendingCode, setSendingCode] = useState(false); const [changingPhone, setChangingPhone] = useState(false); + const [changingPassword, setChangingPassword] = useState(false); const [cooldownSeconds, setCooldownSeconds] = useState(0); const settingsHomeRef = useRef(null); const sectionTriggerRef = useRef(null); const changePhoneTriggerRef = useRef(null); + const passwordTriggerRef = useRef(null); const focusAfterNextPaint = useCallback((element: HTMLElement | null) => { if (!element) { @@ -325,6 +336,12 @@ export function AccountModal({ setCooldownSeconds(0); }, []); + const resetPasswordDraft = useCallback(() => { + setCurrentPassword(''); + setNewPassword(''); + setPasswordError(''); + }, []); + useEffect(() => { if (!isOpen) { return; @@ -332,11 +349,14 @@ export function AccountModal({ setActiveSection(normalizeSettingsSection(initialSection)); setIsChangePhonePanelOpen(false); + setIsPasswordPanelOpen(false); setAccountNotice(''); sectionTriggerRef.current = null; changePhoneTriggerRef.current = null; + passwordTriggerRef.current = null; resetChangePhoneDraft(); - }, [initialSection, isOpen, resetChangePhoneDraft]); + resetPasswordDraft(); + }, [initialSection, isOpen, resetChangePhoneDraft, resetPasswordDraft]); useEffect(() => { const settingsHome = settingsHomeRef.current; @@ -368,10 +388,12 @@ export function AccountModal({ const closeSectionPanel = useCallback(() => { const sectionTrigger = sectionTriggerRef.current; setIsChangePhonePanelOpen(false); + setIsPasswordPanelOpen(false); setActiveSection(null); resetChangePhoneDraft(); + resetPasswordDraft(); focusAfterNextPaint(sectionTrigger); - }, [focusAfterNextPaint, resetChangePhoneDraft]); + }, [focusAfterNextPaint, resetChangePhoneDraft, resetPasswordDraft]); const closeChangePhonePanel = useCallback(() => { const changePhoneTrigger = changePhoneTriggerRef.current; @@ -380,6 +402,13 @@ export function AccountModal({ focusAfterNextPaint(changePhoneTrigger); }, [focusAfterNextPaint, resetChangePhoneDraft]); + const closePasswordPanel = useCallback(() => { + const passwordTrigger = passwordTriggerRef.current; + setIsPasswordPanelOpen(false); + resetPasswordDraft(); + focusAfterNextPaint(passwordTrigger); + }, [focusAfterNextPaint, resetPasswordDraft]); + if (!isOpen) { return null; } @@ -556,6 +585,31 @@ export function AccountModal({ +
+
+
+
+ 登录密码 +
+
+ 在独立面板中设置或修改账号密码。 +
+
+ +
+
+
@@ -893,6 +947,74 @@ export function AccountModal({
) : null} + + {isPasswordPanelOpen ? ( + +
+ + + + {passwordError ? ( +
+ {passwordError} +
+ ) : null} + + +
+
+ ) : null} ) : null}
diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index d8b71d6c..5e9559c1 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -19,7 +19,9 @@ import { type AuthRiskBlockSummary, type AuthSessionSummary, type AuthUser, + authEntry, bindWechatPhone, + changePassword, changePhoneNumber, consumeAuthCallbackResult, ensureAutoAuthUser, @@ -33,6 +35,7 @@ import { loginWithPhoneCode, logoutAllAuthSessions, logoutAuthUser, + resetPassword, revokeAuthSession, sendPhoneLoginCode, startWechatLogin, @@ -648,6 +651,10 @@ export function AuthGate({ children }: AuthGateProps) { setChangePhoneCaptchaChallenge(null); setUser(nextUser); }} + onChangePassword={async (currentPassword, newPassword) => { + const nextUser = await changePassword(currentPassword, newPassword); + setUser(nextUser); + }} /> ) : null} { + onSendCode={async (phone, scene, captcha) => { setSendingCode(true); setError(''); try { - const result = await sendPhoneLoginCode(phone, 'login', captcha); + const result = await sendPhoneLoginCode(phone, scene, captcha); setLoginCaptchaChallenge(null); return result; } catch (sendError) { @@ -682,7 +689,7 @@ export function AuthGate({ children }: AuthGateProps) { setSendingCode(false); } }} - onSubmit={async (phone, code) => { + onPhoneSubmit={async (phone, code) => { setLoggingIn(true); setError(''); try { @@ -699,6 +706,38 @@ export function AuthGate({ children }: AuthGateProps) { setLoggingIn(false); } }} + onPasswordSubmit={async (username, password) => { + setLoggingIn(true); + setError(''); + try { + const nextUser = await authEntry(username, password); + activateReadyUser(nextUser); + } catch (loginError) { + setError( + loginError instanceof Error + ? loginError.message + : '登录失败,请稍后再试。', + ); + } finally { + setLoggingIn(false); + } + }} + onResetPassword={async (phone, code, newPassword) => { + setLoggingIn(true); + setError(''); + try { + const nextUser = await resetPassword(phone, code, newPassword); + activateReadyUser(nextUser); + } catch (resetError) { + setError( + resetError instanceof Error + ? resetError.message + : '重置密码失败,请稍后再试。', + ); + } finally { + setLoggingIn(false); + } + }} onStartWechatLogin={async () => { setWechatLoading(true); setError(''); diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index feda8aaf..dea916f1 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -8,6 +8,8 @@ import type { } from '../../services/authService'; import { CaptchaChallengeField } from './CaptchaChallengeField'; +type SmsScene = 'login' | 'reset_password'; + type LoginScreenProps = { isOpen: boolean; platformTheme: PlatformTheme; @@ -20,6 +22,7 @@ type LoginScreenProps = { onClose: () => void; onSendCode: ( phone: string, + scene: SmsScene, captcha?: { challengeId?: string; answer?: string; @@ -28,7 +31,13 @@ type LoginScreenProps = { cooldownSeconds: number; expiresInSeconds: number; }>; - onSubmit: (phone: string, code: string) => Promise; + onPhoneSubmit: (phone: string, code: string) => Promise; + onPasswordSubmit: (username: string, password: string) => Promise; + onResetPassword: ( + phone: string, + code: string, + newPassword: string, + ) => Promise; onStartWechatLogin: () => Promise; }; @@ -43,14 +52,25 @@ export function LoginScreen({ captchaChallenge, onClose, onSendCode, - onSubmit, + onPhoneSubmit, + onPasswordSubmit, + onResetPassword, onStartWechatLogin, }: LoginScreenProps) { + const [activeTab, setActiveTab] = useState<'login' | 'register'>('login'); + const [isResetPanelOpen, setIsResetPanelOpen] = useState(false); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); + const [resetPhone, setResetPhone] = useState(''); + const [resetCode, setResetCode] = useState(''); + const [resetPasswordValue, setResetPasswordValue] = useState(''); const [captchaAnswer, setCaptchaAnswer] = useState(''); const [cooldownSeconds, setCooldownSeconds] = useState(0); + const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0); const [hint, setHint] = useState(''); + const passwordLoginEnabled = availableLoginMethods.includes('password'); const phoneLoginEnabled = availableLoginMethods.includes('phone'); const wechatLoginEnabled = availableLoginMethods.includes('wechat'); @@ -63,15 +83,27 @@ export function LoginScreen({ setCooldownSeconds((current) => Math.max(0, current - 1)); }, 1000); - return () => { - window.clearTimeout(timeoutId); - }; + return () => window.clearTimeout(timeoutId); }, [cooldownSeconds]); + useEffect(() => { + if (resetCooldownSeconds <= 0) { + return; + } + + const timeoutId = window.setTimeout(() => { + setResetCooldownSeconds((current) => Math.max(0, current - 1)); + }, 1000); + + return () => window.clearTimeout(timeoutId); + }, [resetCooldownSeconds]); + if (!isOpen) { return null; } + const submitDisabled = loggingIn || sendingCode; + return (
{ - event.stopPropagation(); - }} + onClick={(event) => event.stopPropagation()} >
- 登录账号 + {isResetPanelOpen ? '重置密码' : '账号入口'}
-
{ - event.preventDefault(); - if (!phoneLoginEnabled) { - return; - } - void onSubmit(phone, code); - }} - > - {phoneLoginEnabled ? ( - <> - - - - - setIsResetPanelOpen(false)} + onSendCode={async () => { + const result = await onSendCode(resetPhone, 'reset_password'); + setResetCooldownSeconds(result.cooldownSeconds); + }} + onSubmit={() => onResetPassword(resetPhone, resetCode, resetPasswordValue)} + /> + ) : ( +
+
+ setActiveTab('login')} + /> + setActiveTab('register')} /> - - {hint ? ( -
- {hint} -
- ) : null} - - ) : null} - - {error ? ( -
- {error}
- ) : null} - {phoneLoginEnabled ? ( - - ) : null} + {activeTab === 'login' ? ( + { + event.preventDefault(); + if (!passwordLoginEnabled) { + return; + } + void onPasswordSubmit(username, password); + }} + > + {passwordLoginEnabled ? ( + <> + + + + ) : null} - {wechatLoginEnabled ? ( - - ) : null} + {error ? : null} - {!phoneLoginEnabled && !wechatLoginEnabled ? ( -
- 当前登录入口暂不可用。 -
- ) : null} - + {passwordLoginEnabled ? ( + + ) : null} + + + + {wechatLoginEnabled ? ( + + ) : null} + + ) : ( + { + setHint(''); + const result = await onSendCode(phone, 'login', { + challengeId: captchaChallenge?.challengeId, + answer: captchaAnswer, + }); + setCooldownSeconds(result.cooldownSeconds); + setHint( + `短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`, + ); + setCaptchaAnswer(''); + }} + onSubmit={() => onPhoneSubmit(phone, code)} + /> + )} + + {!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? ( +
+ 当前登录入口暂不可用。 +
+ ) : null} +
+ )}
); } + +function TabButton({ + active, + label, + onClick, +}: { + active: boolean; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function PhoneCodeForm({ + phone, + code, + captchaAnswer, + captchaChallenge, + cooldownSeconds, + sendingCode, + loggingIn, + error, + hint, + submitLabel, + enabled, + onPhoneChange, + onCodeChange, + onCaptchaAnswerChange, + onSendCode, + onSubmit, +}: { + phone: string; + code: string; + captchaAnswer: string; + captchaChallenge: AuthCaptchaChallenge | null; + cooldownSeconds: number; + sendingCode: boolean; + loggingIn: boolean; + error: string; + hint: string; + submitLabel: string; + enabled: boolean; + onPhoneChange: (value: string) => void; + onCodeChange: (value: string) => void; + onCaptchaAnswerChange: (value: string) => void; + onSendCode: () => Promise; + onSubmit: () => Promise; +}) { + if (!enabled) { + return null; + } + + return ( +
{ + event.preventDefault(); + void onSubmit(); + }} + > + + + + + + + {hint ? : null} + {error ? : null} + + + + ); +} + +function PasswordResetPanel({ + phone, + code, + password, + sendingCode, + loggingIn, + cooldownSeconds, + error, + onPhoneChange, + onCodeChange, + onPasswordChange, + onBack, + onSendCode, + onSubmit, +}: { + phone: string; + code: string; + password: string; + sendingCode: boolean; + loggingIn: boolean; + cooldownSeconds: number; + error: string; + onPhoneChange: (value: string) => void; + onCodeChange: (value: string) => void; + onPasswordChange: (value: string) => void; + onBack: () => void; + onSendCode: () => Promise; + onSubmit: () => Promise; +}) { + return ( +
{ + event.preventDefault(); + void onSubmit(); + }} + > + + + + + {error ? : null} + +
+ + +
+ + ); +} + +function WechatButton({ + loading, + disabled, + onClick, +}: { + loading: boolean; + disabled: boolean; + onClick: () => Promise; +}) { + return ( + + ); +} + +function ErrorBanner({ message }: { message: string }) { + return
{message}
; +} + +function SuccessBanner({ message }: { message: string }) { + return
{message}
; +} diff --git a/src/services/authService.ts b/src/services/authService.ts index ddca7d8f..86e493a9 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -7,6 +7,8 @@ import type { AuthLoginMethod, AuthLoginOptionsResponse, AuthLogoutAllResponse, + AuthPasswordChangeResponse, + AuthPasswordResetResponse, AuthMeResponse, AuthPhoneChangeResponse, AuthPhoneLoginResponse, @@ -137,7 +139,7 @@ export function clearAuthSession() { export async function sendPhoneLoginCode( phone: string, - scene: 'login' | 'bind_phone' | 'change_phone' = 'login', + scene: 'login' | 'bind_phone' | 'change_phone' | 'reset_password' = 'login', captcha?: { challengeId?: string; answer?: string; @@ -257,6 +259,50 @@ export async function authEntry(username: string, password: string) { return response.user; } +export async function changePassword( + currentPassword: string, + newPassword: string, +) { + const response = await requestJson( + '/api/auth/password/change', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + currentPassword: currentPassword.trim() || undefined, + newPassword: newPassword.trim(), + }), + }, + '修改密码失败', + ); + + return response.user; +} + +export async function resetPassword( + phone: string, + code: string, + newPassword: string, +) { + const response = await requestJson( + '/api/auth/password/reset', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: normalizePhoneInput(phone), + code: code.trim(), + newPassword: newPassword.trim(), + }), + }, + '重置密码失败', + PUBLIC_AUTH_REQUEST_OPTIONS, + ); + + setStoredAccessToken(response.token, { emit: false }); + return response.user; +} + export async function authEntryWithStoredCredentials( credentials: AutoAuthCredentials, ) {