This commit is contained in:
2026-04-26 20:50:58 +08:00
parent a3a9bfa194
commit 67161bd6d1
142 changed files with 3349 additions and 10674 deletions
@@ -8,7 +8,7 @@
1. 战斗中一次点击只完成一个明确行为,不再做连续多轮击打、连续多 actor 轮转的 function 设计。
2. 战斗中除逃跑外,不再为每次动作额外触发剧情推理,而是直接结算数值并刷新下一轮战斗选项。
3. 只有在逃跑成功或战斗正式结束后,才触发一次剧情推理,生成脱战后的 storyText 与后续剧情选项。
3. 战斗动作、逃跑、战斗胜利、切磋结束与玩家死亡都不再触发剧情推理,只做确定性数值结算与固定流程选项。
---
@@ -143,24 +143,13 @@ ongoing battle 的本地/后端结果文本只负责说明这一次动作结算
3. 直接刷新新一轮战斗选项
4. `storyText` 直接使用本次结算结果文本,不请求 AI 续写
### 5.2 必须触发剧情推理的情况
### 5.2 战斗结束后的固定流程
以下情况必须触发剧情推理
战斗正式结束后仍然不触发剧情推理,直接走固定 UI 流程
1. `battle_escape_breakout` 执行后成功脱战
2. 任意战斗动作执行后,战斗正式结束
战斗正式结束包括:
- 敌方被击败
- 切磋结束
- 玩家被系统判定为本轮战斗已断开
此时系统行为为:
1. 先完成数值结算与状态落地
2. 再以“本次动作 + 本次战斗结果”为上下文触发一次剧情推理
3. 生成脱战后的 `storyText` 与非战斗态 options
1. 敌方被击败或切磋结束:展示结算文本,并只给一个“继续前进”选项。
2. 如果当前场景当前幕已经是最后一幕,则不再给“继续前进”,改为列出可前往的其他场景选项。
3. 玩家血量小于等于 0:先播放死亡动画,三秒后把玩家复活到第一个场景第一幕。
---
@@ -196,7 +185,7 @@ ongoing battle 的本地/后端结果文本只负责说明这一次动作结算
1. 后端 runtime 战斗 option 池切换到单行为模型
2. 后端 combat resolution 支持普通攻击 / 指定技能 / 恢复 / 战斗物品 / 逃跑
3. 后端只在逃跑或战斗结束后做剧情推理
3. 后端和前端都不在战斗流程里触发剧情推理,胜负只走固定结算选项
4. 前端支持透传战斗 option 的 `runtimePayload`
5. 前端支持 disabled battle option 展示
6. 文档、测试同步更新
@@ -228,6 +217,6 @@ ongoing battle 的本地/后端结果文本只负责说明这一次动作结算
- 每个技能一个独立技能项
- 逃跑
3. 点击普通攻击 / 恢复 / 使用物品 / 技能时,不请求新的剧情推理,直接返回结算结果并刷新下一轮战斗 options。
4. 点击逃跑成功后,请求一次剧情推理并切回脱战后的剧情 options。
5. 任意攻击或技能把敌人打死后,请求一次剧情推理并切回脱战后的剧情 options
4. 点击逃跑成功后,请求剧情推理,直接切回非战斗固定 options。
5. 任意攻击或技能把敌人打死后,请求剧情推理,直接显示“继续前进”或其他场景选项
6. 旧存档里残留旧 battle functionId 时,不会因为 function 不识别而报错。
@@ -0,0 +1,45 @@
# 世界底稿开局场景批生成解耦说明 2026-04-26
## 背景
当前第一版世界底稿生成链路里,`framework` 阶段同时要求模型输出 `camp.sceneTaskDescription``camp.actBackgroundPromptTexts``camp.actEventDescriptions`。这让“世界核心骨架”和“开局场景多幕内容”混在同一次世界生成任务里,后续普通场景批生成又要单独生成相同粒度的场景任务、三幕事件、三幕背景和幕 NPC 分配。
这与《AI 原生多幕场景创作与玩法流程 PRD》中“开局场景不是特殊系统,只是玩家开局所处的第一个场景”的约束不一致。开局场景应复用普通场景的批生成能力,而不是由世界骨架阶段提前生成一套缩水版内容。
## 落地边界
1. `framework` 阶段只负责世界顶层信息和轻量 `camp` 占位:
- `name`
- `description`
2. 场景批生成阶段负责生成完整场景骨架:
- 场景名与描述
- 默认场景生图描述
- `sceneTaskDescription`
- `actBackgroundPromptTexts`
- `actEventDescriptions`
- `actNPCNames`
- `connectedLandmarkNames`
- `entryHook`
3. 批生成场景结果的第一项固定视为开局场景:
- 写回 `profile.camp`
- `camp.id` 缺失时固定为 `camp-1`
- `camp.kind` 固定为 `camp`
4. 批生成场景结果的其余项写入 `profile.landmarks`
5. `sceneChapterBlueprints` 仍由统一场景蓝图编译函数生成:
- 第 0 项来自 `camp`
- 后续项来自 `landmarks`
- 开局场景和普通场景共用三幕、NPC、任务、背景提示词规则
## 兼容策略
为了减少前端和存量链路改动,场景批生成 API 层仍沿用现有 `landmarks` JSON 字段名。字段名不再表示“只包含普通地标”,而表示“本批生成的场景条目”,其中第一项是 opening/camp 场景。
如果模型没有返回任何场景条目,则继续使用 `framework.camp` 的轻量占位构造兜底开局场景;这只是异常兜底,不是主生成路径。
## 验收点
1. 世界骨架 prompt 不再要求 `camp.sceneTaskDescription``camp.actBackgroundPromptTexts``camp.actEventDescriptions`
2. 场景批生成 prompt 明确要求第一项是开局场景。
3. 生成后的 `profile.camp` 来自场景批生成第一项,而不是来自世界骨架阶段的开局多幕内容。
4. `profile.landmarks[0]` 是第一个普通场景,不再重复包含开局场景。
5. `sceneChapterBlueprints[0].sceneId === "camp-1"`,且仍包含 3 幕背景与事件描述。
@@ -0,0 +1,38 @@
# RPG 战斗确定性结束流程
更新时间:`2026-04-26`
## 背景
旧战斗单行为 PRD 允许在逃跑成功或战斗结束后再触发一次剧情推理。新的运行时规则改为:战斗过程与战斗结果都不再请求剧情推理,所有胜负出口都由确定性状态与固定选项驱动。
## 落地规则
1. 战斗中的 `battle_attack_basic / battle_use_skill / battle_recover_breath / inventory_use / battle_escape_breakout` 只结算数值、血量、冷却、物品与战斗状态,不再请求 `generateNextStep` 或 Rust `generate_action_story_payload` 的战斗推理。
2. 玩家血量小于等于 0 时,先展示死亡动画;三秒后复活到当前世界的第一个场景第一幕。
3. 复活状态必须清理战斗、遭遇、NPC 交互、战斗特效、当前 NPC 战斗结果,并把生命与灵力恢复到最大值。
4. 战斗胜利或切磋结束时,弹出固定选项:
- 若当前场景仍有下一幕:只显示“继续前进”,并把下一幕运行时状态与可用选项挂到 `deferredRuntimeState / deferredOptions`,点击后只揭开下一幕选项,不触发剧情推理。
- 若当前场景已是最后一幕:显示可前往的其他场景选项,每个选项透传 `runtimePayload.targetSceneId`
5. “继续前进”和“前往其他场景”都是本地状态推进,不展示额外说明面板,不把规则描述写入 UI。
## 工程落点
1. `src/hooks/rpg-runtime-story/postBattleFlow.ts`
- 生成死亡复活状态。
- 生成战斗胜利后的固定 `StoryMoment`
- 解析下一幕或其他场景选项。
2. `src/hooks/rpg-runtime-story/storyChoiceContinuation.ts`
- 本地 fallback 胜利分支不再调用 `generateStoryForState(...)`
- 玩家死亡分支播放死亡态并延迟复活。
3. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts`
- 服务端 runtime action 回包若是战斗死亡或胜利,同样覆盖为固定流程。
4. `server-rs/crates/api-server/src/runtime_story/compat/ai.rs`
- 战斗 outcome 不再触发 reasoned story payload。
## 验收
1. 战斗中普通攻击、技能、恢复、物品、逃跑不会触发剧情推理。
2. 玩家被打到 0 血时先播放死亡动画,约三秒后复活到第一个场景第一幕。
3. 战斗胜利后只出现“继续前进”;最后一幕后出现其他场景入口。
4. 服务端 runtime action 与本地 fallback 两条链表现一致。
@@ -18,6 +18,8 @@
3. `battle_use_skill` 的本地兜底结算必须尊重 `runtimePayload.skillId`,不能重新随机挑技能。
4. `battle_recover_breath` 的本地兜底不能伪装成攻击动作;它只做恢复、冷却推进和后续敌方压力。
5. 服务端战斗回包如果带 `presentation.battle`,前端先播放一次短动作和血量变化,再落 `hydratedSnapshot.gameState`,避免选项点击后血量直接跳变。
6. 战斗中任一角色血量发生变化时,表现层根据前后血量差派生一次浮字:伤害使用红色负数,治疗使用绿色正数;该浮字只表达已经结算的血量差,不参与数值计算。
7. 伤害浮字出现时,受击角色形象需要沿自身背向短距离后退再恢复;该后撤只作用在角色形象视觉容器,不改变 `playerX``sceneHostileNpcs.xMeters`、同伴站位或任何战斗结算字段。
## 验收点
@@ -25,3 +27,5 @@
- 点击具体技能按钮时,播放与结算使用同一个 `skillId`
- 点击恢复时不会出现玩家同时释放攻击技能的错位表现。
- 走服务端 runtime action 的战斗选项仍以 server-rs 返回快照为最终状态。
- 受到伤害时,角色形象上方出现类似 `-9` 的红色浮字,并有短暂后撤回弹。
- 获得治疗时,角色形象上方出现类似 `+1` 的绿色浮字,不触发受击后撤。
@@ -0,0 +1,27 @@
# RPG 创作结果页新增实体同步修复 2026-04-26
## 问题
Agent 草稿结果页点击“新增可扮演角色”后,后端生成请求已完成,但前端提示:
`生成请求已完成,但结果页未收到新增内容,请返回创作页重新打开草稿后重试。`
触发点在结果页实体生成完成后的数量校验:前端会执行 `generate_characters`,等待操作完成后重新拉取 Agent session,再把 session 编译成结果页 `CustomWorldProfile`。如果新 profile 中对应实体数量没有增加,就显示该错误。
## 根因
当前链路已经约定 `session.draftProfile` 是 Agent 草稿与 RPG 运行时的真相源,但 `rpgCreationPreviewAdapter.buildPreviewFromSession()` 仍优先读取 `session.resultPreview.preview`
当后端 action 已把新增角色写入 `draftProfile`,但 `resultPreview` 仍是旧快照时,结果页会继续消费旧预览,导致数量校验误判为“没有新增内容”。
## 修复
- `buildCustomWorldProfileFromAgentSession()` 改为优先归一化 `session.draftProfile`
- `resultPreview.preview` 只作为 `draftProfile` 缺失时的兼容 fallback。
- 更新单测,覆盖“session 同时存在 draftProfile 与 resultPreview 时,结果页 profile 必须来自 draftProfile”。
## 后续约束
- Agent 草稿结果页新增、删除、进入世界都应以 `draftProfile` 为数据源。
- `resultPreview` 继续承载发布质量、blocker、预览外壳信息,不再作为结果页实体列表的优先数据源。
- 若新增实体字段在结果页缺失,应扩展 `draftProfile` 的归一化兼容,而不是把实体读取切回 `resultPreview.preview`
@@ -0,0 +1,102 @@
# RPG 创作编辑器历史素材复用设计
日期:`2026-04-26`
## 1. 本次目标
编辑场景角色与编辑场景图片时,玩家可以复用历史生成素材:
1. 场景角色形象区删除 `基于预设素材修改` 入口,以及它打开的预设拼装编辑下游。
2. 场景角色形象区新增 `使用历史素材`,打开独立素材面板。
3. 场景图片的幕背景配置新增 `使用历史素材`,打开独立素材面板。
4. 历史素材面板需要展示所有账号过去生成过的对应类型素材,并明确标注素材归属账号。
5. 选择素材后直接应用到当前角色或当前幕背景,不触发新一轮生成。
## 2. 数据真相
历史素材不从前端草稿 profile 扫描,也不从预设素材目录扫描。
历史素材统一来自 SpacetimeDB 的 `asset_object` 表:
| 使用位置 | `asset_kind` | 应用字段 |
| --- | --- | --- |
| 场景角色形象 | `character_visual` | `imageSrc``generatedVisualAssetId` |
| 场景幕背景 | `scene_image` | `backgroundImageSrc``backgroundAssetId` |
`asset_object.owner_user_id` 是归属账号标注主源。为空时 UI 标注为 `未记录账号`,不能隐藏归属栏。
## 3. 后端接口
新增只读接口:
```text
GET /api/assets/history?kind=character_visual
GET /api/assets/history?kind=scene_image
```
返回字段只包含前端选择所需脱敏信息:
1. `assetObjectId`
2. `assetKind`
3. `imageSrc`
4. `ownerUserId`
5. `ownerLabel`
6. `profileId`
7. `entityId`
8. `createdAt`
9. `updatedAt`
`asset_object` 仍保持 private table。读取历史列表必须通过 SpacetimeDB procedure + Axum facade,不能让前端直接订阅整表。
## 4. SpacetimeDB 设计
`module-assets` 新增:
1. `AssetHistoryListInput`
2. `AssetHistoryEntrySnapshot`
3. `AssetHistoryListResult`
`spacetime-module` 新增 procedure
```rust
list_asset_history_and_return(input: AssetHistoryListInput) -> AssetHistoryListResult
```
实现规则:
1. 只允许 `character_visual``scene_image`
2.`asset_kind` 过滤。
3.`created_at` 倒序返回。
4. 默认最多返回 120 条,允许调用方传更小 limit。
5. `image_src``object_key` 转为兼容代理路径:`/{object_key}`
## 5. 前端交互
历史素材面板为独立弹层,不在当前面板下方展开。
角色历史素材卡片:
1. 展示素材图。
2. 展示归属账号。
3. 展示创建时间。
4. 点击 `使用` 后写入当前草稿角色:
- `imageSrc = imageSrc`
- `generatedVisualAssetId = assetObjectId`
- `generatedAnimationSetId = undefined`
- `animationMap = undefined`
场景历史素材卡片:
1. 展示 16:9 预览。
2. 展示归属账号。
3. 展示创建时间。
4. 点击 `使用` 后写入当前幕:
- `backgroundImageSrc = imageSrc`
- `backgroundAssetId = assetObjectId`
## 6. 非目标
1. 不新增图片生成模型能力。
2. 不把历史素材复制成新 OSS 对象。
3. 不修改旧预设素材目录。
4. 不恢复 server-node 或 PostgreSQL 链路。
@@ -0,0 +1,24 @@
# RPG 创作场景幕资产一致性修复 2026-04-26
## 背景
当前世界草稿和场景编辑器存在三类一致性问题:
1. 世界草稿生成后,开局场景的三幕可能没有默认主角色。
2. 开局场景列表层、幕卡片层、幕背景配置弹层可能显示不同图片。
3. 幕背景智能生成弹层的默认提示词可能退回规则拼接文本,且预览图和外层当前幕不一致。
## 落地约束
1. 后端草稿生成必须为 `sceneChapterBlueprints[*].acts[*]` 写入稳定的幕级字段:`encounterNpcIds``primaryNpcId``oppositeNpcId``eventDescription``backgroundPromptText`
2. 开局场景 `camp` 在生成角色名单之前建立,但最终编译草稿时必须基于已生成的场景角色,为三幕自动补默认主角色,不允许把“开局关键角色”这类占位词留到可编辑草稿里。
3. `backgroundPromptText` 必须优先来自模型生成的自然画面描述;缺失时才使用规则兜底,兜底也要基于真实主角色名。
4. 前端场景编辑器展示某一幕时,列表卡、幕卡、配置弹层、智能生成弹层都应读取同一个幕级 `backgroundImageSrc`;只有旧草稿缺幕图时才展示场景主图作为视觉兜底,保存时不得把兜底图反写到所有幕。
5. 智能生成幕背景时,默认提示词必须使用当前幕 `backgroundPromptText`,不再用标题、摘要、目标拼接替代。
## 验收点
1. 新生成的开局三幕每幕都有非空 `primaryNpcId`,并且第一位 `encounterNpcIds[0]``primaryNpcId` 一致。
2. 普通场景与开局场景都能在幕背景生图 prompt 中写入真实主角色名。
3. 开局场景第 2 幕在列表层、编辑卡片层、配置弹层、智能生成弹层中的预览图保持一致。
4. 点击“跟随场景主图”只影响当前幕,不会把同一张图同步覆盖到三幕。
@@ -0,0 +1,58 @@
# RPG 创作世界角色维度信息编辑落地说明
更新时间:`2026-04-26`
## 1. 背景
统一角色属性系统把一个世界中“角色能力如何被理解”收口到 `CustomWorldProfile.attributeSchema.slots`。这六个 slot 是世界级设定,不是单个角色自己的六个字段。
当前结果页世界页可以展示角色维度,但编辑世界信息时只能修改世界名称、概述、基调、目标等文本,尚不能手动修订六个维度本身的信息。
## 2. 本次目标
在“编辑世界信息”独立面板中允许用户编辑六个角色维度的信息:
1. 修改 `attributeSchema.slots` 中每个维度的 `name``definition``positiveSignals``negativeSignals``combatUseText``socialUseText``explorationUseText`
2. 不在可扮演角色或场景角色编辑器中新增单角色六维数值编辑。
3. 保存时同步更新 `profile.attributeSchema`
4.`profile.ownedSettingLayers.ruleProfile.attributeSchema` 存在,同步写入同一份 schema,避免世界档案和设定层出现双源漂移。
5. 前端只负责编辑结构化文本,不新增属性结算逻辑。
## 3. 交互设计
入口位置:
- 世界页点击“世界概述”里的编辑按钮
- 打开现有“编辑世界信息”面板
- 在基础世界文本字段下方增加“角色维度”区块
每个维度展示并允许编辑:
- 维度名称
- 定义
- 正向信号
- 负向信号
- 战斗体现
- 社交体现
- 探索体现
正向信号与负向信号使用逗号、中文逗号或换行拆分成数组。
## 4. 数据落点
保存路径:
- `profile.attributeSchema.slots[n]`
- `profile.ownedSettingLayers.ruleProfile.attributeSchema.slots[n]`,仅当 `ownedSettingLayers` 已存在时同步
不修改:
- `profile.playableNpcs[n].attributeProfile`
- `profile.storyNpcs[n].attributeProfile`
## 5. 验收
1. 世界信息面板能看到六个角色维度。
2. 修改任一维度名称、定义、信号或三类用途说明后,保存到 `profile.attributeSchema.slots`
3. 编辑角色自身时不出现单角色六维数值输入区。
4. UI 仍读取当前世界 schema,不回退写死旧四维文案。
@@ -0,0 +1,19 @@
# 世界档案 PC 布局扩展(2026-04-26
## 背景
世界档案结果页和各实体编辑子面板在 PC 端仍沿用偏窄的弹窗与两列列表布局,宽屏下画布利用率不足。移动端现有单列滚动、底部弹层和触控布局已经稳定,本次不改变移动端结构。
## 落地规则
1. 世界档案主页面只在 `xl` 及以上断点扩展画布宽度与内部间距,移动端和 `sm/md/lg` 断点保持原有布局。
2. 场景、可扮演角色、场景角色列表在 PC 端提高列数上限,让宽屏能显示更多卡片。
3. 世界 Tab 在 PC 端由窄两列调整为更充分的三列信息布局,减少首屏纵向堆叠。
4. 世界信息、基本设定、角色与场景编辑子面板在 PC 端使用更宽的 `max-width` 与更高可用高度;移动端仍保持 `h-[92vh]` 的底部弹层。
5. 不新增说明类 UI 文案,不改写现有中文内容,不改变保存、删除、生成、发布等行为。
## 验收点
1. PC 端世界档案结果页内容区域能接近占满工作区宽度,列表在 2K 宽屏下可呈现更多列。
2. PC 端打开世界、基本设定、角色、场景子面板时,弹窗宽度明显大于旧版 `sm:max-w-2xl` 默认宽度。
3. 移动端仍为单列滚动列表,编辑子面板仍从底部弹出且宽度为全屏。
@@ -29,6 +29,7 @@
- 单轮变化限制在 `[-3, 3]`
5. `chatDirective.forceExitAfterTurn / closingMode=foreshadow_close` 时不生成建议,返回空数组,并在 `complete.chatDirective.forceExit` 中显式告知前端退出。
6. LLM 未配置或失败时继续返回后端兜底 SSE,保证相遇和点击聊天链路不断。
7. 在 Node 已完成平台账号鉴权并通过内部密钥转发到 Rust API 的链路中,`/api/runtime/chat/` 必须纳入 Rust 内部转发鉴权白名单;否则 NPC 主动开场会在进入 `runtime_chat` handler 前被 401 拒绝,前端只能收到“NPC 聊天续写失败”。
## 暂不落地
@@ -46,11 +47,14 @@
- 优先 `LlmClient.stream_text(...)` 生成 `reply_delta`
- 再调用 `request_text(...)` 生成建议。
- 计算 `affinityDelta / affinityText / chatDirective` 后输出 `complete`
3. 修改 `server-rs/crates/api-server/src/main.rs`
3. 修改 `server-rs/crates/api-server/src/auth.rs`
- `allows_internal_forwarded_auth(...)` 允许 `/api/runtime/chat/`,与 big-fish、puzzle 的内部转发鉴权策略保持一致。
- 单测覆盖 `/api/runtime/chat/npc/turn/stream`,防止后续新增 runtime 路由时再次遗漏内部转发白名单。
4. 修改 `server-rs/crates/api-server/src/main.rs`
- 注册 `runtime_chat_prompt` 模块。
## 验收
1. `cargo fmt -p api-server`
2. `cargo check -p api-server`
3. `node scripts/check-encoding.mjs docs/technical/RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md server-rs/crates/api-server/src/runtime_chat.rs server-rs/crates/api-server/src/runtime_chat_prompt.rs server-rs/crates/api-server/src/main.rs`
3. `node scripts/check-encoding.mjs docs/technical/RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md server-rs/crates/api-server/src/auth.rs server-rs/crates/api-server/src/runtime_chat.rs server-rs/crates/api-server/src/runtime_chat_prompt.rs server-rs/crates/api-server/src/main.rs`
@@ -8,19 +8,19 @@
Maincloud 发布不复用本地 `spacetime.local.json`,避免误把本地开发库名发布到云端。需要显式提供:
| 变量 | 用途 |
| --- | --- |
| `GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE` | Maincloud 数据库名,发布脚本优先读取 |
| 变量 | 用途 |
| -------------------------------------------- | ------------------------------------------------------------ |
| `GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE` | Maincloud 数据库名,发布脚本优先读取 |
| `GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL` | Maincloud 服务地址,默认 `https://maincloud.spacetimedb.com` |
| `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN` | `api-server` 连接 Maincloud 时使用的 token |
| `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN` | `api-server` 连接 Maincloud 时使用的 token |
兼容 `api-server` 现有变量:
| 变量 | 用途 |
| --- | --- |
| `GENARRATIVE_SPACETIME_SERVER_URL` | `api-server` 实际连接地址 |
| `GENARRATIVE_SPACETIME_DATABASE` | `api-server` 实际连接数据库 |
| `GENARRATIVE_SPACETIME_TOKEN` | `api-server` 实际连接 token |
| 变量 | 用途 |
| ---------------------------------- | --------------------------- |
| `GENARRATIVE_SPACETIME_SERVER_URL` | `api-server` 实际连接地址 |
| `GENARRATIVE_SPACETIME_DATABASE` | `api-server` 实际连接数据库 |
| `GENARRATIVE_SPACETIME_TOKEN` | `api-server` 实际连接 token |
## npm 命令
@@ -50,10 +50,12 @@ npm run api-server:maincloud
1.`.env``.env.local` 读取默认环境。
2.`GENARRATIVE_SPACETIME_MAINCLOUD_*` 映射为 `api-server` 已支持的 `GENARRATIVE_SPACETIME_*`
3. 启动 `cargo run -p api-server --manifest-path server-rs/Cargo.toml`
3. 在 Windows 启动前检查 `server-rs/target/debug/api-server.exe` 对应的旧进程;如果旧进程仍在运行,先停止它,避免 Rust 编译阶段覆盖 exe 时出现 `failed to remove file ... 拒绝访问。 (os error 5)`
4. 启动 `cargo run -p api-server --manifest-path server-rs/Cargo.toml`
## 设计约束
- Maincloud 数据库名必须显式配置,不能默认读取本地 `spacetime.local.json`
- 发布脚本只处理 SpacetimeDB 模块发布,不启动本地 SpacetimeDB。
- `api-server` 继续通过 `SpacetimeClientConfig``server_url / database / token` 连接数据库,不在前端增加逻辑。
- Windows 进程清理只能匹配本仓库 `server-rs/target/debug/api-server.exe` 的完整路径,不能按进程名泛化清理,避免影响其他 Rust 服务。
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+52 -5
View File
@@ -1,8 +1,12 @@
import {spawn} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import {resolve} from 'node:path';
import { execFileSync, spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const repoRoot = process.cwd();
const apiServerExePath = resolve(
repoRoot,
'server-rs/target/debug/api-server.exe',
);
function loadEnvFile(path, target) {
if (!existsSync(path)) {
@@ -30,7 +34,7 @@ function loadEnvFile(path, target) {
}
}
const mergedEnv = {...process.env};
const mergedEnv = { ...process.env };
loadEnvFile(resolve(repoRoot, '.env'), mergedEnv);
loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv);
@@ -56,6 +60,50 @@ if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) {
process.exit(1);
}
function stopExistingWindowsApiServer() {
if (process.platform !== 'win32') {
return;
}
// Windows 下 cargo 重新编译时无法覆盖仍在运行的 exe,只清理本仓库 target 内的旧进程。
const command = [
'$target = [System.IO.Path]::GetFullPath($env:GENARRATIVE_API_SERVER_EXE_TARGET)',
'$processes = Get-Process -Name api-server -ErrorAction SilentlyContinue | Where-Object {',
' $_.Path -and ([System.IO.Path]::GetFullPath($_.Path) -ieq $target)',
'}',
'foreach ($process in $processes) {',
' Stop-Process -Id $process.Id -Force',
' Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue',
' Write-Output $process.Id',
'}',
].join('\n');
const output = execFileSync(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
{
encoding: 'utf8',
env: {
...process.env,
GENARRATIVE_API_SERVER_EXE_TARGET: apiServerExePath,
},
},
).trim();
if (output) {
console.log(`[api-server:maincloud] 已停止旧 api-server 进程: ${output}`);
}
}
try {
stopExistingWindowsApiServer();
} catch (error) {
console.error(
`[api-server:maincloud] 清理旧 api-server 进程失败: ${error.message}`,
);
process.exit(1);
}
console.log(
`[api-server:maincloud] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`,
);
@@ -67,7 +115,6 @@ const child = spawn(
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
shell: process.platform === 'win32',
},
);
+8 -1
View File
@@ -23,7 +23,7 @@ use crate::{
},
assets::{
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
create_sts_upload_credentials, get_asset_read_url,
create_sts_upload_credentials, get_asset_history, get_asset_read_url,
},
auth::{
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
@@ -394,6 +394,13 @@ pub fn build_router(state: AppState) -> Router {
get(get_character_workflow_cache),
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route(
"/api/assets/history",
get(get_asset_history).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/settings",
get(get_runtime_settings)
+60 -4
View File
@@ -14,10 +14,11 @@ use platform_oss::{
};
use serde_json::{Value, json};
use shared_contracts::assets::{
AssetBindingPayload, AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest,
BindAssetObjectResponse, ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest,
ConfirmAssetObjectResponse, CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse,
DirectUploadTicketPayload, GetAssetReadUrlResponse, GetReadUrlQuery,
AssetBindingPayload, AssetHistoryEntryPayload, AssetHistoryListResponse, AssetHistoryQuery,
AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest, BindAssetObjectResponse,
ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, ConfirmAssetObjectResponse,
CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadTicketPayload,
GetAssetReadUrlResponse, GetReadUrlQuery,
};
use spacetime_client::SpacetimeClientError;
@@ -111,6 +112,51 @@ pub async fn get_asset_read_url(
))
}
pub async fn get_asset_history(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Query(query): Query<AssetHistoryQuery>,
) -> Result<Json<Value>, AppError> {
let asset_kind = query.kind.trim().to_string();
if asset_kind != "character_visual" && asset_kind != "scene_image" {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "kind",
"message": "历史素材类型只支持 character_visual 或 scene_image",
})),
);
}
let entries = state
.spacetime_client()
.list_asset_history(module_assets::AssetHistoryListInput {
asset_kind,
limit: query.limit.unwrap_or(120).clamp(1, 120),
})
.await
.map_err(map_confirm_asset_object_error)?;
Ok(json_success_body(
Some(&request_context),
AssetHistoryListResponse {
assets: entries
.into_iter()
.map(|entry| AssetHistoryEntryPayload {
owner_label: format_asset_owner_label(entry.owner_user_id.as_deref()),
asset_object_id: entry.asset_object_id,
asset_kind: entry.asset_kind,
image_src: entry.image_src,
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
entity_id: entry.entity_id,
created_at: entry.created_at,
updated_at: entry.updated_at,
})
.collect(),
},
))
}
pub async fn create_sts_upload_credentials(
Extension(_request_context): Extension<RequestContext>,
) -> Result<Json<Value>, AppError> {
@@ -232,6 +278,16 @@ fn resolve_object_key_from_query(query: &GetReadUrlQuery) -> Option<String> {
.map(|value| value.trim_start_matches('/').to_string())
}
fn format_asset_owner_label(owner_user_id: Option<&str>) -> String {
let Some(owner_user_id) = owner_user_id
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return "未记录账号".to_string();
};
format!("账号 {owner_user_id}")
}
async fn build_confirm_asset_object_upsert_input(
oss_client: &platform_oss::OssClient,
payload: ConfirmAssetObjectRequest,
+6 -1
View File
@@ -189,7 +189,9 @@ fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
fn allows_internal_forwarded_auth(path: &str) -> bool {
// Node 代理已经完成平台账号 JWT 校验,Rust 运行时只信任这些明确的内部转发路径。
path.starts_with("/api/runtime/big-fish/") || path.starts_with("/api/runtime/puzzle/")
path.starts_with("/api/runtime/big-fish/")
|| path.starts_with("/api/runtime/chat/")
|| path.starts_with("/api/runtime/puzzle/")
}
fn try_build_internal_forwarded_claims(
@@ -282,6 +284,9 @@ mod tests {
assert!(allows_internal_forwarded_auth(
"/api/runtime/big-fish/sessions"
));
assert!(allows_internal_forwarded_auth(
"/api/runtime/chat/npc/turn/stream"
));
assert!(allows_internal_forwarded_auth("/api/runtime/puzzle/works"));
assert!(!allows_internal_forwarded_auth("/api/auth/me"));
}
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
[
"请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物地图细节。".to_string(),
"这一步只保留世界顶层信息与一个开局归处占位,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物地图细节或多幕场景内容".to_string(),
"玩家设定:".to_string(),
setting_text.trim().to_string(),
"".to_string(),
@@ -33,10 +33,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
" },".to_string(),
" \"camp\": {".to_string(),
" \"name\": \"开局归处名称\",".to_string(),
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
" \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(),
" \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(),
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\"".to_string(),
" }".to_string(),
"}".to_string(),
"".to_string(),
@@ -45,10 +42,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
"- 这一步只输出顶层 10 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。".to_string(),
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应第 1/2/3 幕背景图画面内容描述;每条必须基于同序号 actEventDescriptions 和相关角色写出画面主体、站位空间、冲突痕迹与氛围,能直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
"- camp 表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。".to_string(),
"- attributeSchema 必须是本世界专属的角色六维属性体系,slots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f,维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
@@ -68,7 +62,7 @@ pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &st
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。",
"attributeSchema 必须是对象,且包含 schemaName 与 slotsslots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f。",
"camp 必须是对象,且包含:name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions",
"camp 必须是对象,且包含:name、description。",
"原始文本:",
response_text.trim(),
].join("\n")
@@ -154,16 +148,26 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
framework: &JsonValue,
batch_count: usize,
forbidden_names: &[String],
is_opening_batch: bool,
) -> String {
let story_npc_names = names_from_entries(&array_field(framework, "storyNpcs"));
[
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
"这一步必须一次性生成场景骨架、地点默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string(),
"请根据下面的世界核心信息,批量生成场景框架名单。".to_string(),
if is_opening_batch {
"这一步必须一次性生成开局场景和普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
} else {
"这一步必须一次性生成普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
},
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("")) },
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("")) },
if is_opening_batch {
"第一条场景必须是玩家进入世界时所在的开局场景,后续条目才是普通关键场景。".to_string()
} else {
"本批只生成普通关键场景,不要再生成开局场景。".to_string()
},
if forbidden_names.is_empty() { "".to_string() } else { format!("这些场景已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
@@ -183,16 +187,21 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
"}".to_string(),
"".to_string(),
"要求:".to_string(),
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
"- 每个地点只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
if is_opening_batch {
format!("- 必须生成恰好 {batch_count} 个场景,第 1 个必须是开局场景。")
} else {
format!("- 必须生成恰好 {batch_count} 个普通关键场景,不能包含开局场景。")
},
if is_opening_batch { "- 开局场景也必须按普通场景同级规则生成完整字段,不能只给 camp 简介。".to_string() } else { "".to_string() },
"- 这是一个完全独立的自定义世界;场景名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1、开局场景 之类的占位名。".to_string(),
"- 每个场景只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- actNPCNames 只能引用上方可用场景角色名单中的名字,表示第 1/2/3 幕各自的主场景角色;如果名单为空,输出空数组。".to_string(),
"- 可用场景角色名单非空时,actNPCNames 必须恰好 3 个;可以重复使用同一角色,但每一项都必须服务对应幕事件。".to_string(),
"- actNPCNames[n] 会成为第 n+1 幕对面主角色;三幕事件和幕背景必须围绕对应角色的行动、阻碍、试探或求助展开。".to_string(),
"- connectedLandmarkNames 优先引用本批或已知关键场景名称,每个地点 1 到 3 个;只有 1 个地点时可以输出空数组。".to_string(),
"- connectedLandmarkNames 优先引用本批或已知场景名称,每个场景 1 到 3 个;只有 1 个场景时可以输出空数组。".to_string(),
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须基于同序号 actEventDescriptions、当前地点和可出场角色直接写出画面主体、站位空间、冲突痕迹与氛围,控制在 40 到 90 个汉字内。".to_string(),
@@ -207,14 +216,16 @@ pub(crate) fn build_custom_world_landmark_seed_batch_json_repair_prompt(
response_text: &str,
expected_count: usize,
forbidden_names: &[String],
is_opening_batch: bool,
) -> String {
[
"下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
"下面这段文本本应是自定义世界场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
"请只输出修复后的 JSON 对象。".to_string(),
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("必须保留恰好 {expected_count}地点对象。"),
format!("必须保留恰好 {expected_count}场景对象。"),
if is_opening_batch { "第一项必须是开局场景,且字段粒度与普通场景一致。".to_string() } else { "本批只保留普通关键场景,不要包含开局场景。".to_string() },
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个地点只包含:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
"每个场景只包含:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
"如果缺少字段:字符串补空字符串,actBackgroundPromptTexts、actEventDescriptions、actNPCNames 和 connectedLandmarkNames 补空数组。".to_string(),
"不要输出 items 或任何其他字段。".to_string(),
"原始文本:".to_string(),
@@ -212,11 +212,10 @@ pub(super) async fn generate_reasoned_story_payload(
}
pub(super) fn should_generate_reasoned_combat_story(
battle: Option<&RuntimeBattlePresentation>,
_battle: Option<&RuntimeBattlePresentation>,
) -> bool {
battle
.and_then(|presentation| presentation.outcome.as_deref())
.is_some_and(|outcome| matches!(outcome, "victory" | "spar_complete" | "escaped"))
// 战斗动作、逃跑、胜利、切磋结束与死亡都只走确定性结算,避免战斗链路再次触发剧情推理。
false
}
pub(super) fn build_action_story_history(
@@ -1913,7 +1913,7 @@ fn runtime_story_quest_turn_in_marks_quest_rewards_and_affinity() {
}
#[test]
fn runtime_story_reasoned_combat_story_guard_only_targets_terminal_outcomes() {
fn runtime_story_reasoned_combat_story_guard_blocks_all_battle_outcomes() {
assert!(!should_generate_reasoned_combat_story(None));
assert!(!should_generate_reasoned_combat_story(Some(
&RuntimeBattlePresentation {
@@ -1924,7 +1924,7 @@ fn runtime_story_reasoned_combat_story_guard_only_targets_terminal_outcomes() {
outcome: Some("ongoing".to_string()),
}
)));
assert!(should_generate_reasoned_combat_story(Some(
assert!(!should_generate_reasoned_combat_story(Some(
&RuntimeBattlePresentation {
target_id: Some("npc_merchant_01".to_string()),
target_name: Some("沈七".to_string()),
@@ -1933,7 +1933,7 @@ fn runtime_story_reasoned_combat_story_guard_only_targets_terminal_outcomes() {
outcome: Some("victory".to_string()),
}
)));
assert!(should_generate_reasoned_combat_story(Some(
assert!(!should_generate_reasoned_combat_story(Some(
&RuntimeBattlePresentation {
target_id: Some("npc_merchant_01".to_string()),
target_name: Some("沈七".to_string()),
@@ -56,6 +56,34 @@ pub struct AssetObjectProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListInput {
pub asset_kind: String,
pub limit: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryEntrySnapshot {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListResult {
pub ok: bool,
pub entries: Vec<AssetHistoryEntrySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingProcedureResult {
@@ -151,6 +179,18 @@ pub struct AssetObjectRecord {
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetHistoryEntryRecord {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectResult {
pub record: AssetObjectRecord,
@@ -297,6 +337,21 @@ pub fn build_asset_object_record(snapshot: AssetObjectUpsertSnapshot) -> AssetOb
}
}
pub fn build_asset_history_entry_record(
snapshot: AssetHistoryEntrySnapshot,
) -> AssetHistoryEntryRecord {
AssetHistoryEntryRecord {
asset_object_id: snapshot.asset_object_id,
asset_kind: snapshot.asset_kind,
image_src: snapshot.image_src,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
entity_id: snapshot.entity_id,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_asset_entity_binding_input(
binding_id: String,
+8 -6
View File
@@ -5,12 +5,14 @@ mod asset_object_service;
pub use asset_object_core::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput,
AssetEntityBindingProcedureResult, AssetEntityBindingRecord, AssetEntityBindingSnapshot,
AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectProcedureResult, AssetObjectRecord,
AssetObjectUpsertInput, AssetObjectUpsertSnapshot, ConfirmAssetObjectInput,
ConfirmAssetObjectResult, INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_input,
build_asset_entity_binding_record, build_asset_object_record, build_asset_object_upsert_input,
generate_asset_binding_id, generate_asset_object_id, normalize_optional_value,
validate_asset_entity_binding_fields, validate_asset_object_fields,
AssetHistoryEntryRecord, AssetHistoryEntrySnapshot, AssetHistoryListInput,
AssetHistoryListResult, AssetObjectAccessPolicy, AssetObjectFieldError,
AssetObjectProcedureResult, AssetObjectRecord, AssetObjectUpsertInput,
AssetObjectUpsertSnapshot, ConfirmAssetObjectInput, ConfirmAssetObjectResult,
INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_input,
build_asset_entity_binding_record, build_asset_history_entry_record, build_asset_object_record,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
normalize_optional_value, validate_asset_entity_binding_fields, validate_asset_object_fields,
};
#[cfg(feature = "server-service")]
pub use asset_object_service::{
@@ -84,6 +84,34 @@ pub struct BindAssetObjectRequest {
pub profile_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AssetHistoryQuery {
pub kind: String,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AssetHistoryEntryPayload {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub owner_label: String,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AssetHistoryListResponse {
pub assets: Vec<AssetHistoryEntryPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CharacterVisualSourceMode {
@@ -1,6 +1,26 @@
use super::*;
impl SpacetimeClient {
pub async fn list_asset_history(
&self,
input: module_assets::AssetHistoryListInput,
) -> Result<Vec<AssetHistoryEntryRecord>, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(move |connection, sender| {
connection.procedures().list_asset_history_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_asset_history_list_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn confirm_asset_object(
&self,
input: module_assets::AssetObjectUpsertInput,

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