1
This commit is contained in:
@@ -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
@@ -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',
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 与 slots;slots 必须恰好 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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user