Merge remote-tracking branch 'origin/master' into codex/publish-flow
Some checks failed
CI / verify (pull_request) Has been cancelled

# Conflicts:
#	docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md
#	docs/technical/README.md
#	jenkins/Jenkinsfile.database-export
#	jenkins/Jenkinsfile.database-import
This commit is contained in:
2026-05-03 03:46:39 +08:00
1041 changed files with 53757 additions and 49983 deletions

View File

@@ -23,7 +23,7 @@ module-match3d = { path = "../module-match3d" }
module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story-compat = { path = "../module-runtime-story-compat" }
module-runtime-story = { path = "../module-runtime-story" }
module-runtime-item = { path = "../module-runtime-item" }
module-story = { path = "../module-story" }
platform-auth = { path = "../platform-auth" }

View File

@@ -46,12 +46,10 @@
22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract
23. 接入 `custom-world-library``custom-world-gallery` 与 agent `publish_world` 首批 Axum facade
24. 接入 custom world agent `session create / session snapshot` Axum facade
25. 接入`runtime story` 兼容接口:
- `POST /api/runtime/story/state/resolve`
- `GET /api/runtime/story/state/{session_id}`
- `POST /api/runtime/story/actions/resolve`
- `POST /api/runtime/story/initial`
- `POST /api/runtime/story/continue`
25.`runtime story` 兼容接口已下线并保持未挂载;运行时故事写链路改为
- `POST /api/story/sessions/runtime`
- `GET /api/story/sessions/{story_session_id}/runtime-projection`
- `POST /api/story/sessions/{story_session_id}/actions/resolve`
26. 接入 `POST /api/assets/character-visual/generate`
27. 接入 `GET /api/assets/character-visual/jobs/{task_id}`
28. 接入 `POST /api/assets/character-visual/publish`
@@ -62,7 +60,8 @@
33. 接入 `POST /api/assets/character-animation/generate`
34. 接入 `GET /api/assets/character-animation/jobs/{task_id}`
35. 接入 `POST /api/assets/character-animation/publish`
36. 接入`/generated-character-drafts/*``/generated-characters/*``/generated-animations/*``/generated-custom-world-scenes/*``/generated-custom-world-covers/*``/generated-qwen-sprites/*` 到 OSS 私有读代理
36. 下线`/generated-*` 资产直读代理;生成资产读取统一走 `/api/assets/read-url` 或 asset object projection
37. 下线旧 `/api/custom-world/*` 非 runtime 前缀和 `/api/runtime/puzzle/runs/local-next-level`,并用路由回归测试确认这些旧入口保持 `404`
后续与本 crate 直接相关的任务包括:
@@ -87,12 +86,13 @@
19. [x] 接入 `/api/assets/sts-upload-credentials`
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
21. [x] 接入 `custom world agent session create / snapshot` facade
22. [x] 接入`runtime story` compat facade
22. [x] 下线`runtime story` compat facade,并接入 story session scoped runtime story 写读入口
23. [x] 接入 `character-visual generate / jobs / publish` 第一批 OSS 主链兼容 facade
24. [x] 接入 `character-animation templates / import-video` 第一批 OSS 草稿兼容 facade
25. [x] 接入 `character-workflow-cache get / save` 第一批 OSS JSON 草稿兼容 facade
26. [x] 接入 `character-animation generate / jobs / publish` 第一批 OSS 主链兼容 facade
27. [x] 接入`/generated-*` 路径 OSS 私有读同源代理
27. [x] 下线`/generated-*` 路径 OSS 私有读同源代理
28. [x] 下线旧 `/api/custom-world/*` 非 runtime 前缀和 Puzzle `local-next-level` 兼容入口
当前 tracing 约定:
@@ -161,10 +161,12 @@
12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB而是统一换成系统签发的 JWT。
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
14. 当前 `/api/runtime/custom-world/agent/sessions``/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。
15. 当前 `/api/runtime/story/*` 已在 Rust 侧补齐 compat handler但内部仍是 `runtime_snapshot` 驱动的兼容桥与确定性动作编排,不应误判为真正的 SpacetimeDB `resolve_story_action` 真相链已完成
15. 当前 `/api/runtime/story/*` 不再挂载runtime story 开局、读取和动作结算通过 `/api/story/sessions/runtime``/api/story/sessions/{story_session_id}/runtime-projection``/api/story/sessions/{story_session_id}/actions/resolve` 进入 BFF并由 `module-runtime-story`、story session 和 runtime snapshot 投影共同闭合
16. 当前 `/api/assets/character-visual/*` 第一批只保证旧接口 contract、OSS 草稿/正式对象、`asset_object``asset_entity_binding` 主链可用真实图片模型、workflow cache 与本地角色覆盖写回仍在后续阶段。
17. 当前 `/api/assets/character-animation/import-video` 第一批只接受 `data:video/*;base64,...` 并写入 OSS 草稿区,不读取旧本地 `public/` 路径,也不创建正式 `asset_object`
18. 当前 `/api/assets/character-workflow-cache/*` 第一批只把工作流 JSON 草稿写入 OSS不迁移历史本地缓存也不创建正式 `asset_object`
19. 当前 `/api/assets/character-animation/generate` 第一批只用 Rust 占位产物打通 `AiTaskService + OSS` 草稿链;`image-sequence` 写 SVG 帧,视频类策略优先复用参考视频或仓库内可播放占位视频,不代表真实上游视频模型已完成迁移。
20. 当前 `/api/assets/character-animation/publish` 会把前端提交帧、动作级 manifest 与总 manifest 写入 OSS并只把总 manifest 确认为 `asset_object` 后绑定到 `character / animation_set`
21. 当前旧 `/generated-*`兼容层只代理受支持 generated 前缀到 OSS 私有读签名,不回退仓库 `public/`Stage 1 不支持视频 Range 分片。
21. 当前旧 `/generated-*` 读兼容层已下线;`/generated-*` 只作为 `legacyPublicPath` / OSS object key 标识,读取必须通过 `/api/assets/read-url` 或业务投影中的签名读 URL。
22. 当前旧 `/api/custom-world/entity``/api/custom-world/scene-npc``/api/custom-world/scene-image``/api/custom-world/cover-image``/api/custom-world/cover-upload` 不再挂载RPG 创作资产入口统一使用 `/api/runtime/custom-world/*`
23. 当前旧 `/api/runtime/puzzle/runs/local-next-level` 不再挂载,正式 Puzzle 运行态只通过 `/api/runtime/puzzle/runs/{run_id}/next-level` 进入后端真相源。

View File

@@ -0,0 +1,18 @@
{
"provider": "ark",
"protocol": "responses",
"model": "deepseek-v3-2-251201",
"stream": false,
"attempt": 1,
"maxTokens": null,
"messages": [
{
"role": "system",
"content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。"
},
{
"role": "user",
"content": "请为下面这一批场景角色补全养成档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n世界核心信息\n世界雾港归航\n副标题失灯旧案\n世界概述守灯人与群岛议会围绕沉船旧案对峙。\n世界基调海雾悬疑\n玩家核心目标查清父亲沉船真相\n主要势力群岛议会、灯塔署\n核心冲突守灯塔的旧档案被人改写。\n开局归处旧灯塔归舍海雾边缘的守灯人旧居。\n关键场景旧灯塔雾中仍亮着错位灯火、沉船湾退潮后露出旧船骨\n本批角色\n- 议长甲 / 群岛议长\n身份遮掩者\n框架描述压住旧档的人\n预设好感-10\n关系切入口旧档案\n标签议会\n- 潮医乙 / 潮汐医师\n身份证人\n框架描述知道沉船伤痕\n预设好感20\n关系切入口救治记录\n标签证人\n出现场景沉船湾\n输出 JSON 模板:\n{\n \"storyNpcs\": [\n {\n \"name\": \"角色名称\",\n \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },\n \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],\n \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]\n }\n ]\n}\n要求\n- 必须只补全本批角色name 必须与本批角色完全一致,不得增删改名。\n- 每个角色必须包含name、backstoryReveal、skills、initialItems。\n- backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
}
]
}

View File

@@ -0,0 +1 @@
{"error":{"message":"story dossier timeout"}}

View File

@@ -0,0 +1,18 @@
{
"provider": "ark",
"protocol": "responses",
"model": "deepseek-v3-2-251201",
"stream": false,
"attempt": 1,
"maxTokens": null,
"messages": [
{
"role": "system",
"content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。"
},
{
"role": "user",
"content": "请为下面这一批场景角色补全养成档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n世界核心信息\n世界雾港归航\n副标题失灯旧案\n世界概述守灯人与群岛议会围绕沉船旧案对峙。\n世界基调海雾悬疑\n玩家核心目标查清父亲沉船真相\n主要势力群岛议会、灯塔署\n核心冲突守灯塔的旧档案被人改写。\n开局归处旧灯塔归舍海雾边缘的守灯人旧居。\n关键场景旧灯塔雾中仍亮着错位灯火、沉船湾退潮后露出旧船骨\n本批角色\n- 雾商丙 / 雾港商人\n身份中间人\n框架描述贩卖航线的人\n预设好感5\n关系切入口伪造海图\n标签商人\n- 灯童丁 / 灯塔学徒\n身份目击者\n框架描述听见夜钟的人\n预设好感30\n关系切入口夜钟\n标签学徒\n出现场景旧灯塔\n输出 JSON 模板:\n{\n \"storyNpcs\": [\n {\n \"name\": \"角色名称\",\n \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },\n \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],\n \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]\n }\n ]\n}\n要求\n- 必须只补全本批角色name 必须与本批角色完全一致,不得增删改名。\n- 每个角色必须包含name、backstoryReveal、skills、initialItems。\n- backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
}
]
}

View File

@@ -0,0 +1 @@
{"error":{"message":"story dossier timeout"}}

View File

@@ -500,6 +500,7 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
};
@@ -639,6 +640,129 @@ mod tests {
);
}
#[tokio::test]
async fn ai_task_mutation_routes_require_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for route in ai_task_mutation_route_cases() {
let (status, _) = post_ai_task_route(app.clone(), route.uri, None, route.body).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{}", route.uri);
}
}
#[tokio::test]
async fn ai_task_mutation_routes_return_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
for route in ai_task_mutation_route_cases() {
let (status, payload) =
post_ai_task_route(app.clone(), route.uri, Some(&token), route.body).await;
assert_eq!(status, StatusCode::BAD_GATEWAY, "{}", route.uri);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string()),
"{}",
route.uri
);
}
}
struct AiTaskRouteCase {
uri: &'static str,
body: Option<Value>,
}
fn ai_task_mutation_route_cases() -> Vec<AiTaskRouteCase> {
vec![
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/stages/request_model/start",
body: None,
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/chunks",
body: Some(json!({
"stageKind": "request_model",
"sequence": 1,
"deltaText": "你听见远处的铃声。"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/stages/request_model/complete",
body: Some(json!({
"textOutput": "你听见远处的铃声。",
"structuredPayloadJson": "{\"scene\":\"camp\"}",
"warningMessages": []
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/references",
body: Some(json!({
"referenceKind": "story_event",
"referenceId": "storyevt_001",
"label": "营地开场"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/complete",
body: None,
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/fail",
body: Some(json!({
"failureMessage": "模型返回内容为空"
})),
},
AiTaskRouteCase {
uri: "/api/ai/tasks/aitask_001/cancel",
body: None,
},
]
}
async fn post_ai_task_route(
app: Router,
uri: &str,
bearer_token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value) {
let mut request = Request::builder()
.method("POST")
.uri(uri)
.header("x-genarrative-response-envelope", "v1");
if let Some(token) = bearer_token {
request = request.header("authorization", format!("Bearer {token}"));
}
let body = if let Some(payload) = body {
request = request.header("content-type", "application/json");
Body::from(payload.to_string())
} else {
Body::empty()
};
let response = app
.oneshot(request.body(body).expect("request should build"))
.await
.expect("request should succeed");
let status = response.status();
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload = if body.is_empty() {
Value::Null
} else {
serde_json::from_slice(&body).expect("response body should be valid json")
};
(status, payload)
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -30,10 +30,11 @@ use crate::{
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::auth_sessions,
big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action,
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
record_big_fish_gallery_like, record_big_fish_play, remix_big_fish_gallery_work,
stream_big_fish_message, submit_big_fish_message,
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
submit_big_fish_message,
},
character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
@@ -65,12 +66,6 @@ use crate::{
},
error_middleware::normalize_error_response,
health::health_check,
legacy_generated_assets::{
proxy_generated_animations, proxy_generated_big_fish_assets,
proxy_generated_character_drafts, proxy_generated_characters,
proxy_generated_custom_world_covers, proxy_generated_custom_world_scenes,
proxy_generated_puzzle_assets, proxy_generated_qwen_sprites,
},
llm::proxy_llm_chat_completions,
login_options::auth_login_options,
logout::logout,
@@ -88,11 +83,11 @@ use crate::{
phone_auth::{phone_login, send_phone_code},
profile_identity::update_profile_identity,
puzzle::{
advance_local_puzzle_next_level, advance_puzzle_next_level,
claim_puzzle_work_point_incentive, create_puzzle_agent_session, delete_puzzle_work,
execute_puzzle_agent_action, get_puzzle_agent_session, get_puzzle_gallery_detail,
get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery,
put_puzzle_work, record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run,
advance_puzzle_next_level, claim_puzzle_work_point_incentive, create_puzzle_agent_session,
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run,
stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard,
swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop,
},
@@ -120,16 +115,14 @@ use crate::{
put_runtime_snapshot, resume_profile_save_archive,
},
runtime_settings::{get_runtime_settings, put_runtime_settings},
runtime_story::{
begin_runtime_story_session, generate_runtime_story_continue,
generate_runtime_story_initial, get_runtime_story_state, resolve_runtime_story_action,
resolve_runtime_story_state,
},
state::AppState,
story_battles::{
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
},
story_sessions::{begin_story_session, continue_story, get_story_session_state},
story_sessions::{
begin_story_runtime_session, begin_story_session, continue_story,
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
},
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
};
@@ -212,38 +205,6 @@ pub fn build_router(state: AppState) -> Router {
"/api/auth/public-users/by-id/{user_id}",
get(get_public_user_by_id),
)
.route(
"/generated-character-drafts/{*path}",
get(proxy_generated_character_drafts),
)
.route(
"/generated-characters/{*path}",
get(proxy_generated_characters),
)
.route(
"/generated-animations/{*path}",
get(proxy_generated_animations),
)
.route(
"/generated-big-fish-assets/{*path}",
get(proxy_generated_big_fish_assets),
)
.route(
"/generated-puzzle-assets/{*path}",
get(proxy_generated_puzzle_assets),
)
.route(
"/generated-custom-world-scenes/{*path}",
get(proxy_generated_custom_world_scenes),
)
.route(
"/generated-custom-world-covers/{*path}",
get(proxy_generated_custom_world_covers),
)
.route(
"/generated-qwen-sprites/{*path}",
get(proxy_generated_qwen_sprites),
)
.route(
"/api/auth/me",
get(auth_me).route_layer(middleware::from_fn_with_state(
@@ -713,6 +674,27 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/sessions/{session_id}/runs",
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}",
get(get_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}/input",
post(submit_big_fish_input).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions",
post(create_match3d_agent_session).route_layer(middleware::from_fn_with_state(
@@ -918,13 +900,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/local-next-level",
post(advance_local_puzzle_next_level).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}",
get(get_puzzle_run).route_layer(middleware::from_fn_with_state(
@@ -939,6 +914,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/drag",
post(drag_puzzle_piece_or_group).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/next-level",
post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state(
@@ -974,13 +956,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
@@ -988,13 +963,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
@@ -1003,19 +971,12 @@ pub fn build_router(state: AppState) -> Router {
)),
)
.route(
"/api/custom-world/scene-image",
"/api/runtime/custom-world/scene-image",
post(generate_custom_world_scene_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
@@ -1023,13 +984,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
@@ -1037,16 +991,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/browse-history",
get(get_runtime_browse_history)
.post(post_runtime_browse_history)
.delete(delete_runtime_browse_history)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/browse-history",
get(get_runtime_browse_history)
@@ -1057,13 +1001,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
@@ -1071,13 +1008,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
@@ -1085,13 +1015,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
@@ -1099,13 +1022,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
@@ -1113,13 +1029,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
@@ -1127,13 +1036,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
@@ -1141,13 +1043,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
@@ -1155,20 +1050,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
@@ -1176,13 +1057,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
@@ -1197,48 +1071,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/sessions",
post(begin_runtime_story_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/state/resolve",
post(resolve_runtime_story_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/state/{session_id}",
get(get_runtime_story_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/actions/resolve",
post(resolve_runtime_story_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/initial",
post(generate_runtime_story_initial).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/continue",
post(generate_runtime_story_continue).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
@@ -1253,6 +1085,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/runtime",
post(begin_story_runtime_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/{story_session_id}/state",
get(get_story_session_state).route_layer(middleware::from_fn_with_state(
@@ -1260,6 +1099,20 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/{story_session_id}/runtime-projection",
get(get_story_runtime_projection).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/{story_session_id}/actions/resolve",
post(resolve_story_runtime_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/continue",
post(continue_story).route_layer(middleware::from_fn_with_state(
@@ -1542,6 +1395,132 @@ mod tests {
);
}
#[tokio::test]
async fn runtime_story_legacy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for (method, uri) in [
("POST", "/api/runtime/story/sessions"),
("POST", "/api/runtime/story/state/resolve"),
("GET", "/api/runtime/story/state/runtime-main"),
("POST", "/api/runtime/story/actions/resolve"),
("POST", "/api/runtime/story/initial"),
("POST", "/api/runtime/story/continue"),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(uri)
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("legacy runtime story request should build"),
)
.await
.expect("legacy runtime story request should be handled");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response
.into_body()
.collect()
.await
.expect("legacy runtime story body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("legacy runtime story body should be json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["code"],
Value::String("NOT_FOUND".to_string())
);
assert_eq!(
payload["error"]["message"],
Value::String("资源不存在".to_string())
);
}
}
#[tokio::test]
async fn deleted_old_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
// 中文注释:旧 custom-world 非 runtime 前缀没有任何新路由可匹配,
// 因此必须稳定返回 404避免前端继续误用旧入口。
for uri in [
"/api/custom-world/entity",
"/api/custom-world/scene-npc",
"/api/custom-world/scene-image",
"/api/custom-world/cover-image",
"/api/custom-world/cover-upload",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("deleted old route request should build"),
)
.await
.expect("deleted old route request should be handled");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/puzzle/runs/local-next-level")
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("deleted old puzzle route request should build"),
)
.await
.expect("deleted old puzzle route request should be handled");
// 中文注释:该路径会被现有 GET /runs/{run_id} 的动态段识别,
// 但 POST 方法没有挂载,返回 405 代表旧 local-next-level handler 已移除。
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn generated_asset_read_proxy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
// 中文注释:生成资产仍可作为 legacyPublicPath 传给 /api/assets/read-url
// 但不能再通过 /generated-* 同源路由裸读 OSS 对象。
for uri in [
"/generated-character-drafts/hero/visual/candidate.png",
"/generated-characters/hero/visual/master.png",
"/generated-animations/hero/idle/frame01.png",
"/generated-big-fish-assets/session-1/level/image.png",
"/generated-puzzle-assets/session-1/candidate/image.png",
"/generated-custom-world-scenes/world-1/camp/scene.png",
"/generated-custom-world-covers/world-1/cover.webp",
"/generated-qwen-sprites/master/candidate-01.png",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("generated asset proxy route request should build"),
)
.await
.expect("generated asset proxy route request should be handled");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
#[tokio::test]
async fn internal_auth_claims_rejects_missing_bearer_token() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));

View File

@@ -19,11 +19,45 @@ pub(crate) async fn execute_billable_asset_operation<T, Fut>(
where
Fut: Future<Output = Result<T, AppError>>,
{
consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await?;
execute_billable_asset_operation_with_cost(
state,
owner_user_id,
asset_kind,
asset_id,
ASSET_OPERATION_POINTS_COST,
operation,
)
.await
}
/// 生图等特殊操作可声明独立光点成本,避免修改全局资产操作默认价格。
pub(crate) async fn execute_billable_asset_operation_with_cost<T, Fut>(
state: &AppState,
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
points_cost: u64,
operation: Fut,
) -> Result<T, AppError>
where
Fut: Future<Output = Result<T, AppError>>,
{
let points_consumed =
consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id, points_cost)
.await?;
match operation.await {
Ok(value) => Ok(value),
Err(error) => {
refund_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await;
if points_consumed {
refund_asset_operation_points(
state,
owner_user_id,
asset_kind,
asset_id,
points_cost,
)
.await;
}
Err(error)
}
}
@@ -35,22 +69,36 @@ async fn consume_asset_operation_points(
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
) -> Result<(), AppError> {
points_cost: u64,
) -> Result<bool, AppError> {
let ledger_id = format!(
"asset_operation_consume:{}:{}:{}",
owner_user_id, asset_kind, asset_id
);
state
match state
.spacetime_client()
.consume_profile_wallet_points(
owner_user_id.to_string(),
ASSET_OPERATION_POINTS_COST,
points_cost,
ledger_id,
current_utc_micros(),
)
.await
.map(|_| ())
.map_err(map_asset_operation_wallet_error)
{
Ok(_) => Ok(true),
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
// 中文注释:外部生图不应被 Maincloud 钱包短暂 503 阻断;此时跳过扣费,让业务链路继续,避免用户重复点击。
tracing::warn!(
owner_user_id,
asset_kind,
asset_id,
error = %error,
"资产操作光点预扣因 SpacetimeDB 连接不可用而降级跳过"
);
Ok(false)
}
Err(error) => Err(map_asset_operation_wallet_error(error)),
}
}
/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。
@@ -59,6 +107,7 @@ async fn refund_asset_operation_points(
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
points_cost: u64,
) {
let ledger_id = format!(
"asset_operation_refund:{}:{}:{}",
@@ -68,7 +117,7 @@ async fn refund_asset_operation_points(
.spacetime_client()
.refund_profile_wallet_points(
owner_user_id.to_string(),
ASSET_OPERATION_POINTS_COST,
points_cost,
ledger_id,
current_utc_micros(),
)
@@ -104,6 +153,45 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A
}))
}
pub(crate) fn should_skip_asset_operation_billing_for_connectivity(
error: &SpacetimeClientError,
) -> bool {
match error {
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout => true,
SpacetimeClientError::Build(message)
| SpacetimeClientError::Procedure(message)
| SpacetimeClientError::Runtime(message) => {
message.contains("503")
|| message.contains("Service Unavailable")
|| message.contains("Failed to connect")
|| message.contains("WebSocket")
|| message.contains("连接已断开")
|| message.contains("连接在返回结果前已断开")
}
}
}
fn current_utc_micros() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asset_operation_billing_skips_spacetime_connectivity_errors() {
assert_eq!(ASSET_OPERATION_POINTS_COST, 1);
assert!(should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::ConnectDropped
));
assert!(should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::Runtime(
"Failed to connect: HTTP error: 503 Service Unavailable".to_string(),
),
));
assert!(!should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::Procedure("光点余额不足".to_string()),
));
}
}

View File

@@ -24,7 +24,7 @@ use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
platform_errors::map_oss_error, request_context::RequestContext, state::AppState,
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
@@ -144,7 +144,7 @@ pub async fn get_asset_history(
AssetHistoryListResponse {
assets: entries
.into_iter()
// 中文注释:Maincloud 旧 wasm 的历史素材 procedure 仍按类型返回HTTP 门面必须兜底做账号隔离。
// 中文注释:旧 wasm 的历史素材 procedure 仍按类型返回HTTP 门面必须兜底做账号隔离。
.filter(|entry| {
is_asset_history_owned_by(
entry.owner_user_id.as_deref(),
@@ -402,17 +402,7 @@ fn map_confirm_asset_object_prepare_error(error: ConfirmAssetObjectPrepareError)
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(platform_oss::OssError::ObjectNotFound(_)) => {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(_) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
ConfirmAssetObjectPrepareError::Oss(error) => map_oss_error(error, "aliyun-oss"),
}
}

View File

@@ -23,9 +23,10 @@ use shared_contracts::big_fish::{
BigFishActionResponse, BigFishAgentMessageResponse, BigFishAnchorItemResponse,
BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse,
BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse,
BigFishRuntimeParamsResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
CreateBigFishSessionRequest, ExecuteBigFishActionRequest, RecordBigFishPlayRequest,
SendBigFishMessageRequest,
BigFishRunResponse, BigFishRuntimeEntityResponse, BigFishRuntimeParamsResponse,
BigFishRuntimeSnapshotResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
BigFishVector2Response, CreateBigFishSessionRequest, ExecuteBigFishActionRequest,
RecordBigFishPlayRequest, SendBigFishMessageRequest, SubmitBigFishInputRequest,
};
use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
@@ -33,10 +34,11 @@ use spacetime_client::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord,
BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput, BigFishMessageSubmitRecordInput,
BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput,
BigFishSessionRecord, BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord,
SpacetimeClientError,
BigFishInputSubmitRecordInput, BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput,
BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRunStartRecordInput,
BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record,
BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, SpacetimeClientError,
};
use tokio::time::sleep;
@@ -59,6 +61,7 @@ use crate::{
auth::AuthenticatedAccessToken,
character_visual_assets::try_apply_background_alpha_to_png,
http_error::AppError,
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
@@ -253,6 +256,35 @@ pub async fn record_big_fish_play(
))
}
pub async fn start_big_fish_run(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let run = state
.spacetime_client()
.start_big_fish_run(BigFishRunStartRecordInput {
run_id: build_prefixed_uuid_id("big-fish-run-"),
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
started_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn record_big_fish_gallery_like(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -284,6 +316,73 @@ pub async fn record_big_fish_gallery_like(
))
}
pub async fn get_big_fish_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let run = state
.spacetime_client()
.get_big_fish_run(run_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn submit_big_fish_input(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SubmitBigFishInputRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
big_fish_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, &run_id, "runId")?;
if !payload.x.is_finite() || !payload.y.is_finite() {
return Err(big_fish_bad_request(&request_context, "input is invalid"));
}
let run = state
.spacetime_client()
.submit_big_fish_input(BigFishInputSubmitRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
x: payload.x,
y: payload.y,
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishRunResponse {
run: map_big_fish_run_response(run),
},
))
}
pub async fn remix_big_fish_gallery_work(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -862,6 +961,51 @@ fn map_big_fish_asset_coverage_response(
}
}
fn map_big_fish_run_response(run: BigFishRuntimeRunRecord) -> BigFishRuntimeSnapshotResponse {
BigFishRuntimeSnapshotResponse {
run_id: run.run_id,
session_id: run.session_id,
status: run.status,
tick: run.tick,
player_level: run.player_level,
win_level: run.win_level,
leader_entity_id: run.leader_entity_id,
owned_entities: run
.owned_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
wild_entities: run
.wild_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
camera_center: map_big_fish_vector2_response(run.camera_center),
last_input: map_big_fish_vector2_response(run.last_input),
event_log: run.event_log,
updated_at: run.updated_at,
}
}
fn map_big_fish_runtime_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
BigFishRuntimeEntityResponse {
entity_id: entity.entity_id,
level: entity.level,
position: map_big_fish_vector2_response(entity.position),
radius: entity.radius,
offscreen_seconds: entity.offscreen_seconds,
}
}
fn map_big_fish_vector2_response(vector: BigFishVector2Record) -> BigFishVector2Response {
BigFishVector2Response {
x: vector.x,
y: vector.y,
}
}
async fn compile_big_fish_draft_only(
state: &AppState,
session_id: String,
@@ -1642,19 +1786,7 @@ fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
}
fn map_big_fish_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn build_big_fish_level_part(level: Option<u32>) -> String {
@@ -1731,6 +1863,14 @@ fn map_big_fish_client_error(error: SpacetimeClientError) -> AppError {
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("big_fish_runtime_run 不存在") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message) if message.contains("无权访问") => {
StatusCode::FORBIDDEN
}
SpacetimeClientError::Procedure(message)
if message.contains("不能为空")
|| message.contains("尚未编译")

View File

@@ -50,6 +50,7 @@ use crate::{
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
},
http_error::AppError,
platform_errors::map_oss_error,
prompt::role_asset_studio::{
build_role_asset_workflow, normalize_animation_prompt_text_by_key,
},
@@ -1639,7 +1640,9 @@ async fn load_workflow_cache(
expire_seconds: Some(60),
}) {
Ok(signed) => signed,
Err(platform_oss::OssError::ObjectNotFound(_)) => return Ok(None),
Err(error) if error.kind() == platform_oss::OssErrorKind::ObjectNotFound => {
return Ok(None);
}
Err(error) => return Err(map_character_animation_oss_error(error)),
};
let response = reqwest::Client::new()
@@ -3303,19 +3306,7 @@ fn map_character_animation_spacetime_error(error: SpacetimeClientError) -> AppEr
}
fn map_character_animation_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn character_animation_error_response(

View File

@@ -1,7 +1,4 @@
use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use std::collections::BTreeMap;
use axum::{
Json,
@@ -38,16 +35,20 @@ use crate::{
build_fallback_moderation_safe_character_visual_prompt,
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiImageSettings,
build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
};
use tokio::time::sleep;
const CHARACTER_VISUAL_MODEL: &str = "wan2.7-image-pro";
const CHARACTER_VISUAL_MODEL: &str = GPT_IMAGE_2_MODEL;
const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
const CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS: u64 = 2_500;
const CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS: u8 = 2;
#[derive(Clone, Debug)]
@@ -78,7 +79,7 @@ pub async fn generate_character_visual(
let fallback_prompt =
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let model = resolve_character_visual_model(payload.image_model.as_str());
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
let candidate_count = payload.candidate_count.clamp(1, 4);
@@ -93,8 +94,8 @@ pub async fn generate_character_visual(
.map_err(|error| character_visual_error_response(&request_context, error))?;
let result = async {
let settings = require_dashscope_settings(&state)?;
let http_client = build_dashscope_http_client(&settings)?;
let settings = require_openai_image_settings(&state)?;
let http_client = build_openai_image_http_client(&settings)?;
state
.ai_task_service()
@@ -120,6 +121,7 @@ pub async fn generate_character_visual(
"sourceMode": payload.source_mode,
"size": size,
"referenceImageCount": payload.reference_image_data_urls.len(),
"provider": "apimart",
})
.to_string(),
),
@@ -191,7 +193,7 @@ pub async fn generate_character_visual(
),
structured_payload_json: Some(
json!({
"provider": "dashscope",
"provider": "apimart",
"taskId": generated.task_id,
"model": model,
"imageCount": generated.images.len(),
@@ -306,7 +308,7 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
let fallback_prompt =
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let model = resolve_character_visual_model(payload.image_model.as_str());
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
create_visual_task(
state,
@@ -316,8 +318,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
&model,
&prompt,
)?;
let settings = require_dashscope_settings(state)?;
let http_client = build_dashscope_http_client(&settings)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
state
.ai_task_service()
.start_task(task_id.as_str(), current_utc_micros())
@@ -767,47 +769,17 @@ fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJob
}
}
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
// Stage 2 的真实图片生成统一走 DashScope这里先把配置缺失拦在业务入口前
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_BASE_URL 未配置",
})),
fn resolve_character_visual_model(value: &str) -> String {
// 中文注释:旧前端和历史草稿可能仍传 wan2.7-image-proRPG 主图当前统一归一到 gpt-image-2
let trimmed = value.trim();
if !trimmed.is_empty() && trimmed != CHARACTER_VISUAL_MODEL {
tracing::warn!(
requested_model = trimmed,
effective_model = CHARACTER_VISUAL_MODEL,
"角色主形象图片模型已归一到 gpt-image-2"
);
}
let api_key = state
.config
.dashscope_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_API_KEY 未配置",
}))
})?;
Ok(DashScopeSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
})
}
fn build_dashscope_http_client(settings: &DashScopeSettings) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "dashscope",
"message": format!("构造 DashScope HTTP 客户端失败:{error}"),
}))
})
CHARACTER_VISUAL_MODEL.to_string()
}
async fn resolve_reference_image_as_data_url(
@@ -867,9 +839,7 @@ async fn resolve_reference_image_as_data_url(
.get(signed.signed_url)
.send()
.await
.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象参考图失败:{error}"))
})?;
.map_err(|error| map_image_request_error(format!("读取角色主形象参考图失败:{error}")))?;
let status = response.status();
let content_type = response
.headers()
@@ -878,7 +848,7 @@ async fn resolve_reference_image_as_data_url(
.unwrap_or("image/png")
.to_string();
let body = response.bytes().await.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象参考图内容失败:{error}"))
map_image_request_error(format!("读取角色主形象参考图内容失败:{error}"))
})?;
if !status.is_success() {
return Err(
@@ -910,7 +880,7 @@ async fn resolve_reference_image_as_data_url(
async fn create_character_visual_generation(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
settings: &OpenAiImageSettings,
model: &str,
prompt: &str,
fallback_prompt: &str,
@@ -921,12 +891,13 @@ async fn create_character_visual_generation(
let mut active_prompt = prompt;
let mut moderation_fallback_applied = false;
let mut last_moderation_error = String::new();
let model = resolve_character_visual_model(model);
for attempt_index in 0..CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS {
match create_character_visual_generation_once(
http_client,
settings,
model,
model.as_str(),
active_prompt,
size,
candidate_count,
@@ -943,7 +914,7 @@ async fn create_character_visual_generation(
if attempt_index == 0
&& !fallback_prompt.trim().is_empty()
&& fallback_prompt.trim() != prompt.trim()
&& is_dashscope_moderation_error(&error) =>
&& is_image_moderation_error(&error) =>
{
last_moderation_error = error.body_text();
active_prompt = fallback_prompt;
@@ -953,7 +924,7 @@ async fn create_character_visual_generation(
}
}
Err(map_dashscope_request_error(format!(
Err(map_image_request_error(format!(
"角色主形象安全兜底重试未返回结果:{}",
last_moderation_error.if_empty_then("上游内容审核仍未通过。")
)))
@@ -961,183 +932,44 @@ async fn create_character_visual_generation(
async fn create_character_visual_generation_once(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
model: &str,
settings: &OpenAiImageSettings,
_model: &str,
prompt: &str,
size: &str,
candidate_count: u32,
reference_images: &[String],
) -> Result<GeneratedCharacterVisuals, AppError> {
let mut content = vec![json!({ "text": prompt })];
for image in reference_images {
content.push(json!({ "image": image }));
}
let response = http_client
.post(format!(
"{}/services/aigc/image-generation/generation",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header("X-DashScope-Async", "enable")
.json(&json!({
"model": model,
"input": {
"messages": [
{
"role": "user",
"content": content,
}
],
},
"parameters": {
"n": candidate_count,
"size": size,
"negative_prompt": build_character_visual_negative_prompt(),
"prompt_extend": true,
"watermark": false,
},
}))
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("创建角色主形象任务失败:{error}")))?;
let response_status = response.status();
let response_text = response.text().await.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象任务响应失败:{error}"))
})?;
if !response_status.is_success() {
return Err(map_dashscope_upstream_error(
response_text.as_str(),
"创建角色主形象任务失败。",
));
}
let response_json = parse_json_payload(response_text.as_str(), "创建角色主形象任务失败。")?;
let task_id = extract_task_id(&response_json.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "角色主形象任务未返回 task_id",
}))
})?;
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_dashscope_request_error(format!("查询角色主形象任务失败:{error}"))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象任务状态失败:{error}"))
})?;
if !poll_status.is_success() {
return Err(map_dashscope_upstream_error(
poll_text.as_str(),
"查询角色主形象任务失败。",
));
}
let poll_json = parse_json_payload(poll_text.as_str(), "查询角色主形象任务失败。")?;
let task_status = find_first_string_by_key(&poll_json.payload, "task_status")
.unwrap_or_default()
.trim()
.to_string();
if task_status == "SUCCEEDED" {
let image_urls = extract_image_urls(&poll_json.payload);
if image_urls.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "角色主形象生成成功,但没有返回可下载图片。",
})),
);
}
let mut images = Vec::with_capacity(image_urls.len());
for image_url in image_urls {
images.push(
download_generated_image(
http_client,
image_url.as_str(),
"下载角色主形象候选图失败。",
)
.await?,
);
}
return Ok(GeneratedCharacterVisuals {
task_id,
actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"),
submitted_prompt: prompt.to_string(),
moderation_fallback_applied: false,
images,
});
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN" | "CANCELED") {
return Err(map_dashscope_upstream_error(
poll_text.as_str(),
"角色主形象任务执行失败。",
));
}
sleep(Duration::from_millis(
CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS,
))
.await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "角色主形象任务执行超时,请稍后重试。",
})),
let generated = create_openai_image_generation(
http_client,
settings,
prompt,
Some(build_character_visual_negative_prompt().as_str()),
size,
candidate_count,
reference_images,
"角色主形象生成失败",
)
.await?;
Ok(GeneratedCharacterVisuals {
task_id: generated.task_id,
actual_prompt: generated.actual_prompt,
submitted_prompt: prompt.to_string(),
moderation_fallback_applied: false,
images: generated
.images
.into_iter()
.map(downloaded_openai_to_character_visual_image)
.collect(),
})
}
async fn download_generated_image(
http_client: &reqwest::Client,
image_url: &str,
fallback_message: &str,
) -> Result<DownloadedGeneratedImage, AppError> {
let response = http_client
.get(image_url)
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("{fallback_message}{error}")))?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let body = response
.bytes()
.await
.map_err(|error| map_dashscope_request_error(format!("{fallback_message}{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str());
let mut bytes = body.to_vec();
let mut extension = mime_to_extension(normalized_mime_type.as_str()).to_string();
let mut mime_type = normalized_mime_type;
fn downloaded_openai_to_character_visual_image(
image: DownloadedOpenAiImage,
) -> DownloadedGeneratedImage {
let mut bytes = image.bytes;
let mut extension = image.extension;
let mut mime_type = image.mime_type;
if mime_type == "image/png"
&& let Some(optimized) = try_apply_background_alpha_to_png(bytes.as_slice())
@@ -1147,11 +979,11 @@ async fn download_generated_image(
mime_type = "image/png".to_string();
}
Ok(DownloadedGeneratedImage {
DownloadedGeneratedImage {
bytes,
mime_type,
extension,
})
}
}
/// 统一的 PNG 透明背景后处理入口。
@@ -1335,94 +1167,35 @@ fn map_character_visual_spacetime_error(error: SpacetimeClientError) -> AppError
}
fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
fn parse_json_payload(
raw_text: &str,
fallback_message: &str,
) -> Result<ParsedJsonPayload, AppError> {
serde_json::from_str::<Value>(raw_text)
.map(|payload| ParsedJsonPayload { payload })
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": format!("{fallback_message}:解析响应失败:{error}"),
}))
})
}
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if raw_text.trim().is_empty() {
return fallback_message.to_string();
}
if let Ok(parsed) = serde_json::from_str::<Value>(raw_text) {
if let Some(message) = parsed
.pointer("/error/message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(message) = parsed
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(code) = parsed
.pointer("/error/code")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
if let Some(code) = parsed
.get("code")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
}
raw_text.trim().to_string()
}
fn map_dashscope_request_error(message: String) -> AppError {
fn map_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"provider": "apimart",
"message": message,
}))
}
fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
#[cfg(test)]
fn map_image_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
let message = match raw_text.trim() {
"" => fallback_message.to_string(),
value => value.to_string(),
};
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": parse_api_error_message(raw_text, fallback_message),
"provider": "apimart",
"message": message,
"raw": raw_text.trim(),
}))
}
fn is_dashscope_moderation_error(error: &AppError) -> bool {
#[cfg(test)]
fn is_image_test_moderation_error(error: &AppError) -> bool {
is_image_moderation_error(error)
}
fn is_image_moderation_error(error: &AppError) -> bool {
let text = error.body_text();
let normalized = text.to_ascii_lowercase();
normalized.contains("ipinfringementsuspect")
@@ -1435,77 +1208,6 @@ fn is_dashscope_moderation_error(error: &AppError) -> bool {
|| text.contains("知识产权")
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value {
Value::Array(entries) => {
for entry in entries {
collect_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key
&& let Some(text) = nested_value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
continue;
}
collect_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_strings_by_key(value, target_key, &mut results);
results.into_iter().next()
}
fn extract_task_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "task_id")
}
fn extract_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_strings_by_key(payload, "image", &mut urls);
collect_strings_by_key(payload, "url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/jpeg");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/jpeg".to_string(),
}
}
fn mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "jpg",
}
}
fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
let body = value.trim().strip_prefix("data:")?;
let (mime_type, data) = body.split_once(";base64,")?;
@@ -2023,12 +1725,6 @@ impl EmptyFallback for String {
}
}
struct DashScopeSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
}
struct GeneratedCharacterVisuals {
task_id: String,
actual_prompt: Option<String>,
@@ -2043,10 +1739,6 @@ struct DownloadedGeneratedImage {
extension: String,
}
struct ParsedJsonPayload {
payload: Value,
}
struct ParsedImageDataUrl {
mime_type: String,
bytes: Vec<u8>,
@@ -2077,13 +1769,22 @@ mod tests {
}
#[test]
fn dashscope_ip_infringement_error_uses_moderation_fallback() {
let error = map_dashscope_upstream_error(
fn legacy_character_visual_model_normalizes_to_gpt_image_2() {
assert_eq!(
resolve_character_visual_model("wan2.7-image-pro"),
"gpt-image-2"
);
assert_eq!(resolve_character_visual_model(""), "gpt-image-2");
}
#[test]
fn image_ip_infringement_error_uses_moderation_fallback() {
let error = map_image_upstream_error(
r#"{"request_id":"a18fb05d","output":{"task_id":"cb768c95","task_status":"FAILED","code":"IPInfringementSuspect","message":"Input data is suspected of being involved in IP infringement."}}"#,
"角色主形象任务执行失败。",
);
assert!(is_dashscope_moderation_error(&error));
assert!(is_image_test_moderation_error(&error));
}
#[test]

View File

@@ -90,6 +90,9 @@ pub struct AppConfig {
pub dashscope_reference_image_model: String,
pub dashscope_cover_image_model: String,
pub dashscope_image_request_timeout_ms: u64,
pub apimart_base_url: String,
pub apimart_api_key: Option<String>,
pub apimart_image_request_timeout_ms: u64,
pub draft_asset_generation_max_concurrent_requests: usize,
pub ark_character_video_base_url: String,
pub ark_character_video_api_key: Option<String>,
@@ -182,6 +185,9 @@ impl Default for AppConfig {
dashscope_reference_image_model: "qwen-image-2.0".to_string(),
dashscope_cover_image_model: "wan2.2-t2i-flash".to_string(),
dashscope_image_request_timeout_ms: 150_000,
apimart_base_url: "https://api.apimart.ai/v1".to_string(),
apimart_api_key: None,
apimart_image_request_timeout_ms: 180_000,
draft_asset_generation_max_concurrent_requests: 4,
ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(),
ark_character_video_api_key: None,
@@ -415,24 +421,19 @@ impl AppConfig {
config.oss_success_action_status = oss_success_action_status;
}
if let Some(spacetime_server_url) = read_first_non_empty_env(&[
"GENARRATIVE_SPACETIME_SERVER_URL",
"GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL",
]) {
if let Some(spacetime_server_url) =
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"])
{
config.spacetime_server_url = spacetime_server_url;
}
if let Some(spacetime_database) = read_first_non_empty_env(&[
"GENARRATIVE_SPACETIME_DATABASE",
"GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE",
]) {
if let Some(spacetime_database) =
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_DATABASE"])
{
config.spacetime_database = spacetime_database;
}
config.spacetime_token = read_first_non_empty_env(&[
"GENARRATIVE_SPACETIME_TOKEN",
"GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN",
]);
config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]);
if let Some(spacetime_pool_size) =
read_first_positive_u32_env(&["GENARRATIVE_SPACETIME_POOL_SIZE"])
{
@@ -530,6 +531,18 @@ impl AppConfig {
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
}
if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) {
config.apimart_base_url = apimart_base_url;
}
config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
if let Some(apimart_image_request_timeout_ms) =
read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"])
{
config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms;
}
if let Some(max_concurrent_requests) = read_first_usize_env(&[
"GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",
"DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",

View File

@@ -1,4 +1,4 @@
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use platform_llm::{LlmClient, LlmError, LlmMessage, LlmStreamDelta, LlmTextRequest};
use serde_json::Value as JsonValue;
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
@@ -33,10 +33,63 @@ where
{
let llm_client =
llm_client.ok_or_else(|| build_error(messages.model_unavailable.to_string()))?;
let user_prompt = user_prompt.into();
let turn_output = match request_stream_creation_agent_json_turn(
llm_client,
system_prompt.clone(),
user_prompt.clone(),
enable_web_search,
&mut on_reply_update,
)
.await
{
Ok(turn_output) => Ok(turn_output),
Err(CreationAgentJsonTurnFailure::Stream(error))
if enable_web_search && is_web_search_tool_unavailable(&error) =>
{
tracing::warn!(
error = %error,
"创作 Agent 联网搜索插件不可用,自动降级为无联网搜索重试"
);
request_stream_creation_agent_json_turn(
llm_client,
system_prompt,
user_prompt,
false,
&mut on_reply_update,
)
.await
}
Err(error) => Err(error),
};
turn_output.map_err(|error| match error {
CreationAgentJsonTurnFailure::Stream(_) => {
build_error(messages.generation_failed.to_string())
}
CreationAgentJsonTurnFailure::Parse => build_error(messages.parse_failed.to_string()),
})
}
enum CreationAgentJsonTurnFailure {
Stream(LlmError),
Parse,
}
async fn request_stream_creation_agent_json_turn<F>(
llm_client: &LlmClient,
system_prompt: String,
user_prompt: String,
enable_web_search: bool,
on_reply_update: &mut F,
) -> Result<CreationAgentJsonTurnOutput, CreationAgentJsonTurnFailure>
where
F: FnMut(&str),
{
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
build_creation_agent_llm_request(system_prompt, user_prompt.into(), enable_web_search),
build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
@@ -48,9 +101,9 @@ where
},
)
.await
.map_err(|_| build_error(messages.generation_failed.to_string()))?;
.map_err(CreationAgentJsonTurnFailure::Stream)?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| build_error(messages.parse_failed.to_string()))?;
.map_err(|_| CreationAgentJsonTurnFailure::Parse)?;
let reply_text = read_reply_text(&parsed);
if let Some(reply_text) = reply_text.as_deref()
&& reply_text != latest_reply_text
@@ -61,6 +114,13 @@ where
Ok(CreationAgentJsonTurnOutput { parsed })
}
fn is_web_search_tool_unavailable(error: &LlmError) -> bool {
let message = error.to_string();
message.contains("ToolNotOpen")
|| message.contains("has not activated web search")
|| message.contains("未开通")
}
fn build_creation_agent_llm_request(
system_prompt: String,
user_prompt: String,
@@ -168,11 +228,23 @@ fn read_reply_text(parsed: &JsonValue) -> Option<String> {
#[cfg(test)]
mod tests {
use std::{
fs,
io::{Read, Write},
net::TcpListener,
sync::{Arc, Mutex},
thread,
time::{Duration as StdDuration, SystemTime, UNIX_EPOCH},
};
use platform_llm::{LlmConfig, LlmProvider};
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
use super::{
build_creation_agent_llm_request, extract_reply_text_from_partial_json,
parse_json_response_text,
CreationAgentLlmTurnErrorMessages, build_creation_agent_llm_request,
extract_reply_text_from_partial_json, is_web_search_tool_unavailable,
parse_json_response_text, stream_creation_agent_json_turn,
};
#[test]
@@ -202,4 +274,214 @@ mod tests {
assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses);
assert_eq!(request.messages.len(), 2);
}
#[test]
fn detects_upstream_web_search_tool_unavailable_error() {
let error = platform_llm::LlmError::Upstream {
status_code: 502,
message: "Your account has not activated web search. code=ToolNotOpen".to_string(),
};
assert!(is_web_search_tool_unavailable(&error));
}
#[tokio::test]
async fn stream_turn_retries_without_web_search_when_tool_is_unavailable() {
let log_dir = std::env::temp_dir().join(format!(
"api-server-creation-agent-raw-log-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos()
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let success_json = serde_json::json!({
"replyText": "好,我们先把玩具王国定住。",
"progressPercent": 12,
"nextAnchorContent": {
"worldPromise": "玩具王国初步方向",
"playerFantasy": null,
"themeBoundary": null,
"playerEntryPoint": null,
"coreConflict": null,
"keyRelationships": null,
"hiddenLines": null,
"iconicElements": null
}
})
.to_string();
let server = spawn_capturing_mock_server(vec![
MockResponse {
body: concat!(
"data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n",
"data: [DONE]\n\n"
)
.to_string(),
},
MockResponse {
body: format!(
"data: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": success_json
})
) + "data: {\"type\":\"response.completed\"}\n\n",
},
]);
let config = LlmConfig::new(
LlmProvider::Ark,
server.base_url,
"test-key".to_string(),
"test-model".to_string(),
30_000,
0,
1,
)
.expect("LLM config should build");
let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build");
let mut visible_replies = Vec::new();
let output = stream_creation_agent_json_turn(
Some(&llm_client),
"系统提示".to_string(),
"用户提示",
true,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "模型不可用",
generation_failed: "生成失败",
parse_failed: "解析失败",
},
|text| visible_replies.push(text.to_string()),
|message| message,
)
.await
.expect("web search fallback should succeed");
assert_eq!(
output.parsed["replyText"].as_str(),
Some("好,我们先把玩具王国定住。")
);
assert_eq!(visible_replies, vec!["好,我们先把玩具王国定住。"]);
let requests = server.requests.lock().expect("requests lock").clone();
assert_eq!(requests.len(), 2);
assert!(requests[0].contains("\"tools\""));
assert!(requests[0].contains("\"web_search\""));
assert!(!requests[1].contains("\"tools\""));
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
if log_dir.exists() {
fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed");
}
}
struct MockResponse {
body: String,
}
struct CapturingMockServer {
base_url: String,
requests: Arc<Mutex<Vec<String>>>,
}
fn spawn_capturing_mock_server(responses: Vec<MockResponse>) -> CapturingMockServer {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let requests = Arc::new(Mutex::new(Vec::new()));
let requests_for_thread = Arc::clone(&requests);
thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
requests_for_thread
.lock()
.expect("requests lock")
.push(request_text);
write_sse_response(&mut stream, response);
}
});
CapturingMockServer {
base_url: format!("http://{address}"),
requests,
}
}
fn read_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(StdDuration::from_secs(1)))
.expect("read timeout should be set");
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
let mut expected_total = None;
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(bytes_read) => {
buffer.extend_from_slice(&chunk[..bytes_read]);
if expected_total.is_none()
&& let Some(header_end) = find_header_end(&buffer)
{
let content_length =
read_content_length(&buffer[..header_end]).unwrap_or(0);
expected_total = Some(header_end + content_length);
}
if let Some(total_bytes) = expected_total
&& buffer.len() >= total_bytes
{
break;
}
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => panic!("mock server failed to read request: {error}"),
}
}
String::from_utf8_lossy(buffer.as_slice()).to_string()
}
fn write_sse_response(stream: &mut std::net::TcpStream, response: MockResponse) {
let raw_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response.body.len(),
response.body
);
stream
.write_all(raw_response.as_bytes())
.expect("mock response should be written");
stream.flush().expect("mock response should flush");
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
}
fn read_content_length(headers: &[u8]) -> Option<usize> {
let text = String::from_utf8_lossy(headers);
text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
return value.trim().parse::<usize>().ok();
}
None
})
}
}

View File

@@ -185,17 +185,22 @@ pub async fn generate_custom_world_profile(
);
// 中文注释profile 生成需要外部 LLM必须留在 Axum/api-serverSpacetimeDB reducer 只接收确定结果。
let result = generate_custom_world_foundation_draft(llm_client, &session, |_| {})
.await
.map_err(|message| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-profile",
"message": message,
})),
)
})?;
let result = generate_custom_world_foundation_draft(
llm_client,
&session,
state.config.creation_agent_llm_web_search_enabled,
|_| {},
)
.await
.map_err(|message| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-profile",
"message": message,
})),
)
})?;
let mut profile =
serde_json::from_str::<Value>(&result.draft_profile_json).map_err(|error| {
custom_world_error_response(
@@ -1775,26 +1780,31 @@ fn spawn_custom_world_draft_foundation_job(
Err(error) => Err(format!("已保存底稿序列化失败:{error}")),
}
} else {
generate_custom_world_foundation_draft(&llm_client, &session, move |progress| {
let progress_state = progress_state.clone();
let session_id = progress_session_id.clone();
let owner_user_id = progress_owner_user_id.clone();
let operation_id = progress_operation_id.clone();
tokio::spawn(async move {
let _ = upsert_custom_world_draft_foundation_progress(
&progress_state,
&session_id,
&owner_user_id,
&operation_id,
"running",
progress.phase_label.as_str(),
progress.phase_detail.as_str(),
progress.progress,
None,
)
.await;
});
})
generate_custom_world_foundation_draft(
&llm_client,
&session,
state.config.creation_agent_llm_web_search_enabled,
move |progress| {
let progress_state = progress_state.clone();
let session_id = progress_session_id.clone();
let owner_user_id = progress_owner_user_id.clone();
let operation_id = progress_operation_id.clone();
tokio::spawn(async move {
let _ = upsert_custom_world_draft_foundation_progress(
&progress_state,
&session_id,
&owner_user_id,
&operation_id,
"running",
progress.phase_label.as_str(),
progress.phase_detail.as_str(),
progress.progress,
None,
)
.await;
});
},
)
.await
};

View File

@@ -36,6 +36,11 @@ use crate::{
},
http_error::AppError,
llm_model_routing::CREATION_TEMPLATE_LLM_MODEL,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, build_openai_image_http_client,
create_openai_image_generation, require_openai_image_settings,
},
platform_errors::map_oss_error,
prompt::scene_background::{
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark,
SceneImagePromptParams, SceneImagePromptProfile, build_custom_world_scene_image_prompt,
@@ -311,6 +316,8 @@ struct DownloadedRemoteImage {
bytes: Vec<u8>,
}
const RPG_SCENE_IMAGE_MODEL: &str = GPT_IMAGE_2_MODEL;
struct CoverPromptContext {
opening_act_title: String,
opening_act_summary: String,
@@ -442,6 +449,8 @@ pub async fn generate_custom_world_scene_image(
let owner_user_id = authenticated.claims().user_id().to_string();
let normalized = normalize_scene_image_request(payload)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
require_openai_image_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_id = format!("custom-scene-{}", current_utc_millis());
let asset = execute_billable_asset_operation(
&state,
@@ -449,8 +458,8 @@ pub async fn generate_custom_world_scene_image(
"scene_image",
asset_id.as_str(),
async {
let settings = require_dashscope_settings(&state)?;
let http_client = build_dashscope_http_client(&settings)?;
let settings = require_openai_image_settings(&state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image =
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
Some(
@@ -465,46 +474,32 @@ pub async fn generate_custom_world_scene_image(
} else {
None
};
let generated = if let Some(reference_image) = reference_image.as_deref() {
create_reference_image_generation(
&http_client,
&settings,
state.config.dashscope_reference_image_model.as_str(),
normalized.prompt.as_str(),
normalized.size.as_str(),
&[reference_image.to_string()],
Some(normalized.negative_prompt.as_str()),
"创建参考图场景编辑任务失败",
"参考图场景编辑未返回图片地址",
"scene-edit",
)
.await
} else {
create_text_to_image_generation(
&http_client,
&settings,
state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
"创建场景图片生成任务失败",
"查询场景图片任务失败",
"场景图片生成任务失败",
"场景图片生成超时或未返回图片地址",
)
.await
}?;
let scene_model = if reference_image.is_some() {
state.config.dashscope_reference_image_model.clone()
} else {
state.config.dashscope_scene_image_model.clone()
};
let downloaded = download_remote_image(
let reference_images = reference_image
.as_ref()
.map(|value| vec![value.clone()])
.unwrap_or_default();
let generated = create_openai_image_generation(
&http_client,
generated.image_url.as_str(),
"下载生成图片失败",
&settings,
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
1,
&reference_images,
"场景图片生成失败",
)
.await?;
let downloaded = generated
.images
.into_iter()
.next()
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "场景图片生成成功但未返回图片。",
}))
})?;
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
@@ -536,7 +531,7 @@ pub async fn generate_custom_world_scene_image(
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(scene_model),
model: Some(RPG_SCENE_IMAGE_MODEL.to_string()),
size: Some(normalized.size),
task_id: Some(generated.task_id),
prompt: Some(normalized.prompt),
@@ -585,27 +580,30 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
}),
};
let normalized = normalize_scene_image_request(payload)?;
let settings = require_dashscope_settings(state)?;
let http_client = build_dashscope_http_client(&settings)?;
let generated = create_text_to_image_generation(
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,
&settings,
state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
"创建场景图片生成任务失败",
"查询场景图片任务失败",
"场景图片生成任务失败",
"场景图片生成超时或未返回图片地址",
)
.await?;
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
"下载生成图片失败",
1,
&[],
"场景图片生成失败",
)
.await?;
let downloaded = generated
.images
.into_iter()
.next()
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "场景图片生成成功但未返回图片。",
}))
})?;
let asset_id = format!("custom-scene-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
@@ -630,7 +628,7 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
slot: "scene_image",
source_job_id: Some(generated.task_id.clone()),
};
let model = state.config.dashscope_scene_image_model.clone();
let model = RPG_SCENE_IMAGE_MODEL.to_string();
let prompt = normalized.prompt.clone();
let asset = persist_custom_world_asset(
state,
@@ -703,6 +701,8 @@ pub async fn generate_custom_world_cover_image(
trim_to_option(payload.profile.name.as_deref()).unwrap_or_else(|| "world".to_string());
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
let size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string());
require_dashscope_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_id = format!("custom-cover-{}", current_utc_millis());
let asset = execute_billable_asset_operation(
&state,
@@ -1017,19 +1017,7 @@ fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppErr
}
fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
map_oss_error(error, "aliyun-oss")
}
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {
@@ -1641,6 +1629,14 @@ async fn download_remote_image(
})
}
fn downloaded_openai_to_custom_world_image(image: DownloadedOpenAiImage) -> DownloadedRemoteImage {
DownloadedRemoteImage {
extension: image.extension,
mime_type: image.mime_type,
bytes: image.bytes,
}
}
fn optimize_uploaded_cover_image(
parsed_data_url: &ParsedImageDataUrl,
crop_rect: &CustomWorldCoverCropRect,
@@ -2458,10 +2454,22 @@ mod tests {
serde_json::from_slice(&body).expect("body should be valid json")
}
fn build_state_without_apimart_key() -> AppState {
let mut config = AppConfig::default();
config.apimart_api_key = None;
AppState::new(config).expect("state should build")
}
fn build_state_without_dashscope_key() -> AppState {
let mut config = AppConfig::default();
config.dashscope_api_key = None;
AppState::new(config).expect("state should build")
}
#[tokio::test]
async fn scene_image_returns_service_unavailable_when_dashscope_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/scene-image");
async fn scene_image_returns_service_unavailable_when_apimart_missing() {
let state = build_state_without_apimart_key();
let request_context = build_request_context("POST /api/runtime/custom-world/scene-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_scene_image(
@@ -2483,7 +2491,7 @@ mod tests {
})),
)
.await
.expect_err("missing dashscope should fail");
.expect_err("missing apimart should fail");
let payload = read_error_response(response).await;
assert_eq!(
@@ -2492,7 +2500,7 @@ mod tests {
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("dashscope".to_string())
Value::String("apimart".to_string())
);
}
@@ -2530,15 +2538,27 @@ mod tests {
};
let normalized = normalize_scene_image_request(payload).expect("payload should normalize");
let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams {
profile: SceneImagePromptProfile {
name: "雾海群岛",
subtitle: "失落航线",
tone: "潮湿、神秘、低魔奇幻",
player_goal: "找到王冠并阻止海妖复苏",
summary: "玩家在雾海中追查沉没王冠。",
setting_text: "群岛被永恒雾潮包围。",
},
landmark: SceneImagePromptLandmark {
name: "礁石神殿",
description: "古老礁石上的半沉神殿。",
},
user_prompt: "破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。",
has_reference_image: false,
fallback_landmark_name: Some("礁石神殿"),
fallback_world_name: "雾海群岛",
});
assert!(normalized.prompt.contains("世界名:雾海群岛"));
assert!(normalized.prompt.contains("世界副标题:失落航线"));
assert!(normalized.prompt.contains("场景名称:礁石神殿"));
assert!(
normalized
.prompt
.contains("本次想要生成的画面内容:破碎神殿")
);
assert_eq!(normalized.prompt, manual_prompt);
assert!(normalized.prompt.contains("破碎神殿矗立在蓝绿色雾潮中"));
assert_ne!(
normalized.prompt,
"破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。"
@@ -2601,10 +2621,15 @@ mod tests {
assert_eq!(normalized.prompt, manual_prompt);
}
#[test]
fn scene_image_response_model_is_gpt_image_2() {
assert_eq!(RPG_SCENE_IMAGE_MODEL, "gpt-image-2");
}
#[tokio::test]
async fn cover_image_returns_service_unavailable_when_dashscope_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/cover-image");
let state = build_state_without_dashscope_key();
let request_context = build_request_context("POST /api/runtime/custom-world/cover-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_cover_image(
@@ -2640,7 +2665,7 @@ mod tests {
#[tokio::test]
async fn cover_upload_rejects_invalid_data_url_before_touching_oss() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/cover-upload");
let request_context = build_request_context("POST /api/runtime/custom-world/cover-upload");
let authenticated = build_authenticated(&state);
let response = upload_custom_world_cover_image(

View File

@@ -10,6 +10,7 @@ use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map as JsonMap, Value as JsonValue, json};
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
use spacetime_client::CustomWorldAgentSessionRecord;
use tracing::warn;
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
@@ -35,6 +36,7 @@ pub struct CustomWorldFoundationDraftProgress {
pub async fn generate_custom_world_foundation_draft(
llm_client: &LlmClient,
session: &CustomWorldAgentSessionRecord,
enable_web_search: bool,
mut on_progress: impl FnMut(CustomWorldFoundationDraftProgress) + Send,
) -> Result<CustomWorldFoundationDraftResult, String> {
let setting_text = build_foundation_generation_seed_text(session);
@@ -51,6 +53,7 @@ pub async fn generate_custom_world_foundation_draft(
|response_text| build_custom_world_framework_json_repair_prompt(response_text),
"agent-foundation-framework-json-repair",
"世界框架阶段没有返回有效内容。",
enable_web_search,
)
.await?;
normalize_framework_shape(&mut framework, setting_text.as_str());
@@ -61,6 +64,7 @@ pub async fn generate_custom_world_foundation_draft(
"playable",
FOUNDATION_DRAFT_PLAYABLE_COUNT,
(16, 30),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -72,6 +76,7 @@ pub async fn generate_custom_world_foundation_draft(
"story",
FOUNDATION_DRAFT_STORY_COUNT,
(30, 44),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -82,6 +87,7 @@ pub async fn generate_custom_world_foundation_draft(
&framework,
FOUNDATION_DRAFT_LANDMARK_COUNT,
(44, 66),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -94,6 +100,7 @@ pub async fn generate_custom_world_foundation_draft(
&playable_outlines,
"narrative",
(66, 76),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -104,6 +111,7 @@ pub async fn generate_custom_world_foundation_draft(
&playable_narrative,
"dossier",
(76, 84),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -114,6 +122,7 @@ pub async fn generate_custom_world_foundation_draft(
&story_outlines,
"narrative",
(84, 92),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -124,6 +133,7 @@ pub async fn generate_custom_world_foundation_draft(
&story_narrative,
"dossier",
(92, 96),
enable_web_search,
&mut on_progress,
)
.await?;
@@ -155,7 +165,8 @@ const FOUNDATION_DRAFT_PLAYABLE_COUNT: usize = 1;
const FOUNDATION_DRAFT_STORY_COUNT: usize = 8;
const FOUNDATION_DRAFT_LANDMARK_COUNT: usize = 2;
const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE: usize = 2;
const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 2;
// 中文注释:单个场景已经包含三幕事件、三幕背景图 prompt 和 NPC 分配;按 1 个场景拆批,避免 landmark seed 大 JSON 在 Responses 请求中超时。
const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 1;
const FOUNDATION_ROLE_DETAIL_BATCH_SIZE: usize = 2;
const WORLD_ATTRIBUTE_SLOT_IDS: [&str; 6] =
["axis_a", "axis_b", "axis_c", "axis_d", "axis_e", "axis_f"];
@@ -171,22 +182,19 @@ async fn request_foundation_json_stage<F>(
repair_prompt_builder: F,
repair_debug_label: &str,
empty_response_message: &str,
enable_web_search: bool,
) -> Result<JsonValue, String>
where
F: Fn(&str) -> String,
{
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(FOUNDATION_JSON_ONLY_SYSTEM_PROMPT),
LlmMessage::user(user_prompt),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(true),
)
.await
.map_err(|error| format!("{debug_label} LLM 请求失败:{error}"))?;
let response = request_foundation_text_with_optional_search_fallback(
llm_client,
FOUNDATION_JSON_ONLY_SYSTEM_PROMPT,
user_prompt.as_str(),
debug_label,
enable_web_search,
)
.await?;
let text = response.content.trim();
if text.is_empty() {
return Err(empty_response_message.to_string());
@@ -211,12 +219,69 @@ where
}
}
async fn request_foundation_text_with_optional_search_fallback(
llm_client: &LlmClient,
system_prompt: &str,
user_prompt: &str,
debug_label: &str,
enable_web_search: bool,
) -> Result<platform_llm::LlmTextResponse, String> {
match request_foundation_text(llm_client, system_prompt, user_prompt, enable_web_search).await {
Ok(response) => Ok(response),
Err(error) if enable_web_search && should_retry_foundation_without_web_search(&error) => {
warn!(
error = %error,
debug_label,
"foundation draft 联网搜索增强不可用或超时,自动降级为无联网搜索重试"
);
request_foundation_text(llm_client, system_prompt, user_prompt, false)
.await
.map_err(|retry_error| format!("{debug_label} LLM 请求失败:{retry_error}"))
}
Err(error) => Err(format!("{debug_label} LLM 请求失败:{error}")),
}
}
async fn request_foundation_text(
llm_client: &LlmClient,
system_prompt: &str,
user_prompt: &str,
enable_web_search: bool,
) -> Result<platform_llm::LlmTextResponse, platform_llm::LlmError> {
llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(enable_web_search),
)
.await
}
fn should_retry_foundation_without_web_search(error: &platform_llm::LlmError) -> bool {
match error {
platform_llm::LlmError::Timeout { .. } | platform_llm::LlmError::Connectivity { .. } => {
true
}
platform_llm::LlmError::Upstream { message, .. } => {
message.contains("ToolNotOpen")
|| message.contains("has not activated web search")
|| message.contains("未开通")
}
_ => false,
}
}
async fn generate_foundation_role_outline_entries(
llm_client: &LlmClient,
framework: &JsonValue,
role_type: &str,
total_count: usize,
progress_range: (u32, u32),
enable_web_search: bool,
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new();
@@ -275,6 +340,7 @@ async fn generate_foundation_role_outline_entries(
)
.as_str(),
"角色框架名单阶段没有返回有效内容。",
enable_web_search,
)
.await?;
let key = role_key(role_type);
@@ -305,6 +371,7 @@ async fn generate_foundation_landmark_seed_entries(
framework: &JsonValue,
total_count: usize,
progress_range: (u32, u32),
enable_web_search: bool,
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new();
@@ -352,6 +419,7 @@ async fn generate_foundation_landmark_seed_entries(
)
.as_str(),
"地点框架名单阶段没有返回有效内容。",
enable_web_search,
)
.await?;
merged_entries.extend(array_field(&raw, "landmarks").into_iter().take(batch_count));
@@ -486,6 +554,7 @@ async fn expand_foundation_role_entries(
base_entries: &[JsonValue],
stage: &str,
progress_range: (u32, u32),
enable_web_search: bool,
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new();
@@ -518,7 +587,7 @@ async fn expand_foundation_role_entries(
.as_str(),
to_batch_progress(progress_range, processed_count, base_entries.len()),
);
let raw = request_foundation_json_stage(
let raw_result = request_foundation_json_stage(
llm_client,
build_custom_world_role_batch_prompt(framework, role_type, batch, stage),
format!(
@@ -540,9 +609,22 @@ async fn expand_foundation_role_entries(
)
.as_str(),
"角色档案补全阶段没有返回有效内容。",
enable_web_search,
)
.await?;
merged_entries.extend(array_field(&raw, role_key(role_type)));
.await;
match raw_result {
Ok(raw) => merged_entries.extend(array_field(&raw, role_key(role_type))),
Err(error) if stage == "dossier" => {
warn!(
error = %error,
role_type,
batch_index = batch_index + 1,
"foundation draft 角色养成档案 LLM 补全失败,使用本地结构化兜底"
);
merged_entries.extend(build_fallback_role_dossier_entries(batch));
}
Err(error) => return Err(error),
}
processed_count = processed_count
.saturating_add(batch.len())
.min(base_entries.len());
@@ -566,6 +648,103 @@ async fn expand_foundation_role_entries(
Ok(merge_entries_by_name(base_entries, &merged_entries))
}
fn build_fallback_role_dossier_entries(entries: &[JsonValue]) -> Vec<JsonValue> {
entries
.iter()
.enumerate()
.map(|(index, entry)| build_fallback_role_dossier_entry(entry, index))
.collect()
}
fn build_fallback_role_dossier_entry(entry: &JsonValue, index: usize) -> JsonValue {
let name = json_text(entry, "name").unwrap_or_else(|| format!("角色{}", index + 1));
let title = json_text(entry, "title").unwrap_or_default();
let role = json_text(entry, "role").unwrap_or_else(|| "关键角色".to_string());
let description = json_text(entry, "description").unwrap_or_else(|| role.clone());
let backstory = json_text(entry, "backstory").unwrap_or_else(|| description.clone());
let motivation = json_text(entry, "motivation").unwrap_or_else(|| description.clone());
let tag = json_string_array(entry, "tags")
.and_then(|items| items.first().cloned())
.unwrap_or_else(|| role.clone());
let item_prefix = if title.trim().is_empty() {
name.clone()
} else {
title.clone()
};
json!({
"name": name.clone(),
"backstoryReveal": {
"publicSummary": format!("{name}的公开档案围绕“{description}”展开。"),
"chapters": [
{
"affinityRequired": 15,
"title": "初识",
"summary": format!("{name}以{role}身份进入玩家视野,留下与“{tag}”有关的第一条线索。"),
},
{
"affinityRequired": 30,
"title": "试探",
"summary": format!("{name}开始透露“{backstory}”背后的压力,但仍保留关键隐情。"),
},
{
"affinityRequired": 60,
"title": "共同行动",
"summary": format!("{name}围绕“{motivation}”与玩家形成更明确的合作或冲突。"),
},
{
"affinityRequired": 90,
"title": "真相",
"summary": format!("{name}交出与“{description}”相关的核心选择,关系走向定型。"),
},
],
},
"skills": [
{
"name": format!("{tag}洞察"),
"summary": format!("围绕“{description}”判断局势与隐藏线索。"),
"style": "侦查",
},
{
"name": format!("{item_prefix}协助"),
"summary": format!("以{role}身份为玩家提供行动支援。"),
"style": "支援",
},
{
"name": "临场应变",
"summary": format!("在压力升级时根据“{motivation}”调整行动。"),
"style": "应变",
},
],
"initialItems": [
{
"name": format!("{item_prefix}记录"),
"category": "道具",
"quantity": 1,
"rarity": "common",
"description": format!("记录{name}与“{description}”相关的线索。"),
"tags": [tag.clone()],
},
{
"name": format!("{tag}信物"),
"category": "道具",
"quantity": 1,
"rarity": "common",
"description": format!("能证明{name}身份和立场的随身物。"),
"tags": [role.clone()],
},
{
"name": "备用补给",
"category": "消耗品",
"quantity": 1,
"rarity": "common",
"description": format!("{name}在关键行动前准备的基础补给。"),
"tags": ["补给"],
},
],
})
}
fn emit_foundation_draft_progress(
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
phase_label: &str,
@@ -2047,7 +2226,7 @@ mod tests {
net::TcpListener,
sync::{Arc, Mutex},
thread,
time::Duration as StdDuration,
time::{Duration as StdDuration, SystemTime, UNIX_EPOCH},
};
use platform_llm::{DEFAULT_REQUEST_TIMEOUT_MS, LlmConfig, LlmProvider};
@@ -2383,6 +2562,80 @@ mod tests {
);
}
#[test]
fn foundation_search_fallback_handles_tool_unavailable_and_timeout() {
let tool_error = platform_llm::LlmError::Upstream {
status_code: 404,
message: "Your account has not activated web search. code=ToolNotOpen".to_string(),
};
let timeout_error = platform_llm::LlmError::Timeout { attempts: 2 };
assert!(should_retry_foundation_without_web_search(&tool_error));
assert!(should_retry_foundation_without_web_search(&timeout_error));
assert!(!should_retry_foundation_without_web_search(
&platform_llm::LlmError::EmptyResponse
));
}
#[tokio::test]
async fn foundation_json_stage_retries_without_web_search_when_tool_unavailable() {
let log_dir = std::env::temp_dir().join(format!(
"api-server-foundation-raw-log-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos()
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let request_capture = Arc::new(Mutex::new(Vec::new()));
let server_url = spawn_mock_server_with_statuses(
request_capture.clone(),
vec![
MockHttpResponse {
status_code: 404,
body: r#"{"error":{"code":"ToolNotOpen","message":"Your account has not activated web search."}}"#.to_string(),
},
MockHttpResponse {
status_code: 200,
body: llm_response(r#"{"name":"无搜索底稿"}"#),
},
],
);
let llm_client = build_test_llm_client(server_url);
let parsed = request_foundation_json_stage(
&llm_client,
"请生成 JSON".to_string(),
"agent-foundation-test",
|_| "修复 JSON".to_string(),
"agent-foundation-test-json-repair",
"空响应",
true,
)
.await
.expect("web search fallback should succeed");
assert_eq!(parsed.get("name"), Some(&json!("无搜索底稿")));
let requests = request_capture
.lock()
.expect("request capture should lock")
.clone();
assert_eq!(requests.len(), 2);
assert!(requests[0].contains("\"tools\""));
assert!(requests[0].contains("\"web_search\""));
assert!(!requests[1].contains("\"tools\""));
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
if log_dir.exists() {
std::fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed");
}
}
#[tokio::test]
async fn role_outline_missing_asset_fields_are_filled_locally_before_details() {
let request_capture = Arc::new(Mutex::new(Vec::<String>::new()));
@@ -2427,6 +2680,60 @@ mod tests {
);
}
#[test]
fn role_dossier_fallback_keeps_names_and_required_fields() {
let entries = vec![json!({
"name": "埃琳娜·沃克",
"title": "深渊学者",
"role": "深海科研联盟成员",
"description": "执着研究深海生物发光现象的年轻科学家",
"backstory": "她长期追踪发光生物与古代遗迹之间的联系。",
"motivation": "用氧气补给换取玩家的目击信息",
"tags": ["科研人员", "偏执学者"]
})];
let fallback = build_fallback_role_dossier_entries(&entries);
let first = fallback.first().expect("fallback entry should exist");
assert_eq!(first.get("name"), Some(&json!("埃琳娜·沃克")));
assert_eq!(
first
.get("backstoryReveal")
.and_then(|value| value.get("chapters"))
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(4)
);
assert_eq!(
first
.get("backstoryReveal")
.and_then(|value| value.get("chapters"))
.and_then(JsonValue::as_array)
.map(|chapters| {
chapters
.iter()
.filter_map(|chapter| chapter.get("affinityRequired"))
.cloned()
.collect::<Vec<_>>()
}),
Some(vec![json!(15), json!(30), json!(60), json!(90)])
);
assert_eq!(
first
.get("skills")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
assert_eq!(
first
.get("initialItems")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
}
#[tokio::test]
async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() {
let request_capture = Arc::new(Mutex::new(Vec::new()));
@@ -2452,7 +2759,10 @@ mod tests {
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
),
llm_response(
r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#,
@@ -2471,7 +2781,7 @@ mod tests {
let llm_client = build_test_llm_client(server_url);
let session = build_test_session();
let result = generate_custom_world_foundation_draft(&llm_client, &session, |_| {})
let result = generate_custom_world_foundation_draft(&llm_client, &session, false, |_| {})
.await
.expect("draft generation should succeed");
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)
@@ -2481,15 +2791,24 @@ mod tests {
.expect("request capture should lock")
.clone();
let request_text = captured_requests.join("\n---request---\n");
let landmark_seed_requests = captured_requests
.iter()
.filter(|request| request.contains("场景框架名单"))
.collect::<Vec<_>>();
assert!(captured_requests.len() >= 17);
assert!(captured_requests.len() >= 18);
assert!(request_text.contains("在失真的海图上追查一场被篡改的沉船事故。"));
assert!(request_text.contains("世界核心骨架"));
assert!(request_text.contains("attributeSchema"));
assert!(request_text.contains("可扮演角色框架名单"));
assert!(request_text.contains("场景角色框架名单"));
assert!(request_text.contains("场景框架名单"));
assert!(request_text.contains("第一条场景必须是玩家进入世界时所在的开局场景"));
assert_eq!(landmark_seed_requests.len(), 2);
assert!(landmark_seed_requests[0].contains("本批场景必须是玩家进入世界时所在的开局场景"));
assert!(landmark_seed_requests[0].contains("必须生成恰好 1 个场景"));
assert!(landmark_seed_requests[1].contains("本批只生成普通关键场景"));
assert!(landmark_seed_requests[1].contains("这些场景已经生成,禁止重复:旧灯塔"));
assert!(!landmark_seed_requests[0].contains("一次性生成开局场景和普通关键场景"));
assert!(request_text.contains("camp 只表示玩家开局时的落脚处占位"));
assert!(!request_text.contains("camp.sceneTaskDescription"));
assert!(!request_text.contains("camp.actBackgroundPromptTexts"));
@@ -2702,6 +3021,150 @@ mod tests {
);
}
#[tokio::test]
async fn role_dossier_timeout_uses_local_fallback_and_keeps_generation_alive() {
let request_capture = Arc::new(Mutex::new(Vec::new()));
let server_url = spawn_mock_server_with_statuses(
request_capture.clone(),
vec![
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"slots":[{"name":"灯骨"},{"name":"潮步"},{"name":"灯识"},{"name":"雾魄"},{"name":"旧约"},{"name":"回澜"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","visualDescription":"浅灰防潮医袍挽到肘部,药箱铜扣发暗,袖口沾着海盐。","actionDescription":"俯身检查伤痕并快速记录潮汐症状,动作谨慎而利落。","sceneVisualDescription":"他常在沉船湾临时诊台前,背后是湿木棚和摇晃药灯。","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","visualDescription":"暗绿长外套挂满防水口袋,帽檐压低,腰间藏着卷曲海图。","actionDescription":"摊开假海图低声议价,手指总按着袖中短刃。","sceneVisualDescription":"他常站在雾港货棚阴影里,周围堆着封蜡货箱和潮湿灯牌。","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","visualDescription":"瘦小学徒披着过大的灯塔制服,怀里抱黄铜小灯和旧钥匙。","actionDescription":"抱灯快步穿过回廊,听见夜钟时会突然停住回头。","sceneVisualDescription":"他常出现在灯塔螺旋楼梯间,雾光从窄窗切进灰墙。","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"船魂戊","title":"沉船残魂","role":"异类","description":"困在潮声里","visualDescription":"半透明水渍轮廓披着破碎船员衣,胸口嵌着发暗船钉。","actionDescription":"随潮声漂移抬手指路,情绪激烈时水雾会拉长身影。","sceneVisualDescription":"它常浮在沉船湾退潮泥滩上,身后旧船骨像黑色肋骨。","initialAffinity":-20,"relationshipHooks":["沉船真相"],"tags":["异类"]},{"name":"巡海己","title":"巡海队长","role":"追捕者","description":"封锁海岸线","visualDescription":"深蓝巡海甲衣覆着雨水,肩章锋利,手握带灯长枪。","actionDescription":"举枪封路并用灯束扫过海岸,步伐整齐带压迫感。","sceneVisualDescription":"他常立在封锁栈桥尽头,巡海灯和铁链把退路切断。","initialAffinity":-15,"relationshipHooks":["封锁令"],"tags":["巡海"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"landmarks":[{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"playableNpcs":[{"name":"岑灯","backstoryReveal":{"publicSummary":"返乡守灯人的旧案羁绊。","chapters":[{"affinityRequired":15,"title":"返乡","summary":"回到旧灯塔。"},{"affinityRequired":30,"title":"旧档","summary":"发现档案错页。"},{"affinityRequired":60,"title":"沉船","summary":"接近沉船湾。"},{"affinityRequired":90,"title":"真相","summary":"直面议会遮掩。"}]},"skills":[{"name":"读灯","summary":"辨认灯火暗号","style":"侦查"}],"initialItems":[{"name":"旧海图","category":"道具","quantity":1,"rarity":"common","description":"父亲留下的海图。","tags":["线索"]}]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"潮医乙","backstory":"他保存着沉船伤痕和潮汐症状的旧记录。","personality":"谨慎利落","motivation":"保住证据","combatStyle":"以医疗知识支援判断"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"雾商丙","backstory":"他长期倒卖雾港航线和假海图。","personality":"圆滑警惕","motivation":"从旧案里脱身","combatStyle":"以情报和交易周旋"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"船魂戊","backstory":"它被沉船旧案困在潮声和船骨之间。","personality":"激烈执拗","motivation":"让真相重新浮上海面","combatStyle":"借潮声与残影指路"}]}"#,
),
},
MockHttpResponse {
status_code: 504,
body: r#"{"error":{"message":"story dossier timeout"}}"#.to_string(),
},
MockHttpResponse {
status_code: 504,
body: r#"{"error":{"message":"story dossier timeout"}}"#.to_string(),
},
],
);
let llm_client = build_test_llm_client(server_url);
let session = build_test_session();
let result = generate_custom_world_foundation_draft(&llm_client, &session, false, |_| {})
.await
.expect("dossier fallback should keep draft generation alive");
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)
.expect("draft profile should parse");
let first_story = draft_profile
.get("storyNpcs")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.expect("first story role should exist");
assert_eq!(first_story.get("name"), Some(&json!("议长甲")));
assert_eq!(
first_story
.get("backstoryReveal")
.and_then(|value| value.get("chapters"))
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(4)
);
assert_eq!(
first_story
.get("skills")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
assert_eq!(
first_story
.get("initialItems")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
let request_text = request_capture
.lock()
.expect("request capture should lock")
.join("\n---request---\n");
assert!(request_text.contains("请为下面这一批场景角色补全养成档案"));
}
#[test]
fn generated_scene_batch_first_entry_becomes_opening_camp() {
let fallback_camp = json!({
@@ -2739,13 +3202,9 @@ mod tests {
fn llm_response(content: &str) -> String {
json!({
"id": "resp_01",
"choices": [
{
"message": {
"content": content,
}
}
]
"model": CREATION_TEMPLATE_LLM_MODEL,
"output_text": content,
"status": "completed"
})
.to_string()
}
@@ -2814,6 +3273,27 @@ mod tests {
fn spawn_mock_server(
request_capture: Arc<Mutex<Vec<String>>>,
response_bodies: Vec<String>,
) -> String {
spawn_mock_server_with_statuses(
request_capture,
response_bodies
.into_iter()
.map(|body| MockHttpResponse {
status_code: 200,
body,
})
.collect(),
)
}
struct MockHttpResponse {
status_code: u16,
body: String,
}
fn spawn_mock_server_with_statuses(
request_capture: Arc<Mutex<Vec<String>>>,
responses: Vec<MockHttpResponse>,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener
@@ -2821,10 +3301,13 @@ mod tests {
.expect("listener should expose address");
thread::spawn(move || {
let mut response_queue = VecDeque::from(response_bodies);
let mut response_queue = VecDeque::from(responses);
for _ in 0..32 {
let response_body = response_queue.pop_front().unwrap_or_else(|| {
llm_response(r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索","backstoryReveal":{"publicSummary":"议会遮掩者。","chapters":[{"affinityRequired":15,"title":"议会","summary":"议会出面。"},{"affinityRequired":30,"title":"封锁","summary":"封锁港口。"},{"affinityRequired":60,"title":"旧案","summary":"旧案松动。"},{"affinityRequired":90,"title":"对质","summary":"灯塔对质。"}]},"skills":[{"name":"封港令","summary":"调动巡海封锁","style":"压制"}],"initialItems":[{"name":"议会印信","category":"道具","quantity":1,"rarity":"rare","description":"可调动巡海队。","tags":["权力"]}]}]}"#)
let response = response_queue.pop_front().unwrap_or_else(|| {
MockHttpResponse {
status_code: 200,
body: llm_response(r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索","backstoryReveal":{"publicSummary":"议会遮掩者。","chapters":[{"affinityRequired":15,"title":"议会","summary":"议会出面。"},{"affinityRequired":30,"title":"封锁","summary":"封锁港口。"},{"affinityRequired":60,"title":"旧案","summary":"旧案松动。"},{"affinityRequired":90,"title":"对质","summary":"灯塔对质。"}]},"skills":[{"name":"封港令","summary":"调动巡海封锁","style":"压制"}],"initialItems":[{"name":"议会印信","category":"道具","quantity":1,"rarity":"rare","description":"可调动巡海队。","tags":["权力"]}]}]}"#),
}
});
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
@@ -2832,7 +3315,7 @@ mod tests {
.lock()
.expect("request capture should lock")
.push(request_text);
write_response(&mut stream, response_body);
write_response(&mut stream, response);
}
});
@@ -2880,11 +3363,18 @@ mod tests {
String::from_utf8(buffer).expect("request should be utf-8")
}
fn write_response(stream: &mut std::net::TcpStream, body: String) {
fn write_response(stream: &mut std::net::TcpStream, response: MockHttpResponse) {
let status_text = if response.status_code == 200 {
"OK"
} else {
"ERROR"
};
let raw_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
"HTTP/1.1 {} {}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response.status_code,
status_text,
response.body.len(),
response.body
);
stream
.write_all(raw_response.as_bytes())

View File

@@ -34,6 +34,10 @@ impl AppError {
self.code
}
pub fn status_code(&self) -> StatusCode {
self.status_code
}
pub fn message(&self) -> &str {
&self.message
}

View File

@@ -1,258 +0,0 @@
use axum::{
extract::{Path, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
use serde_json::json;
use crate::{http_error::AppError, state::AppState};
const CACHE_CONTROL_VALUE: &str = "private, max-age=60";
const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key";
pub async fn proxy_generated_character_drafts(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CharacterDrafts, path).await
}
pub async fn proxy_generated_characters(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Characters, path).await
}
pub async fn proxy_generated_animations(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Animations, path).await
}
pub async fn proxy_generated_big_fish_assets(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::BigFishAssets, path).await
}
pub async fn proxy_generated_puzzle_assets(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::PuzzleAssets, path).await
}
pub async fn proxy_generated_custom_world_scenes(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldScenes, path).await
}
pub async fn proxy_generated_custom_world_covers(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldCovers, path).await
}
pub async fn proxy_generated_qwen_sprites(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::QwenSprites, path).await
}
async fn proxy_legacy_generated_asset(
state: AppState,
prefix: LegacyAssetPrefix,
path: String,
) -> Response {
match read_legacy_generated_asset(&state, prefix, path).await {
Ok(response) => response,
Err(error) => error.into_response(),
}
}
async fn read_legacy_generated_asset(
state: &AppState,
prefix: LegacyAssetPrefix,
path: String,
) -> Result<Response, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let object_key = build_generated_object_key(prefix, path.as_str())?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: object_key.clone(),
expire_seconds: Some(60),
})
.map_err(map_legacy_generated_oss_error)?;
let upstream_response = reqwest::Client::new()
.get(signed.signed_url)
.send()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
}))
})?;
if upstream_response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "aliyun-oss",
"objectKey": object_key,
})),
);
}
let status = upstream_response.status();
let content_type = upstream_response
.headers()
.get(header::CONTENT_TYPE)
.cloned();
if !status.is_success() {
return Err(map_legacy_generated_upstream_status(status, object_key));
}
let bytes = upstream_response.bytes().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取 OSS 旧 generated 资源内容失败:{error}"),
}))
})?;
// 旧 generated 路径会被 <img> / <video> 直接消费,成功分支必须返回原始二进制体。
// 这里显式组装 HeaderMap 并设置长度,避免代理层把已成功读取的 OSS 对象变成空响应。
let mut headers = HeaderMap::new();
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(CACHE_CONTROL_VALUE),
);
headers.insert(
HeaderName::from_static(ASSET_OBJECT_KEY_HEADER),
HeaderValue::from_str(object_key.as_str()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "legacy-generated-assets",
"message": format!("构造资源响应头失败:{error}"),
}))
})?,
);
headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(bytes.len().to_string().as_str()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "legacy-generated-assets",
"message": format!("构造资源长度响应头失败:{error}"),
}))
})?,
);
if let Some(content_type) = content_type {
headers.insert(header::CONTENT_TYPE, content_type);
}
Ok((status, headers, bytes).into_response())
}
fn build_generated_object_key(prefix: LegacyAssetPrefix, path: &str) -> Result<String, AppError> {
let path = path.trim().trim_matches('/');
if path.is_empty() || path.split('/').any(is_invalid_path_segment) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "legacy-generated-assets",
"message": "generated 资源路径不合法。",
})),
);
}
Ok(format!("{}/{}", prefix.as_str(), path))
}
fn is_invalid_path_segment(segment: &str) -> bool {
segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\')
}
fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
fn map_legacy_generated_upstream_status(
status: reqwest::StatusCode,
object_key: String,
) -> AppError {
let mapped_status = match status {
reqwest::StatusCode::NOT_FOUND => StatusCode::NOT_FOUND,
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::UNAUTHORIZED => {
StatusCode::BAD_GATEWAY
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(mapped_status).with_details(json!({
"provider": "aliyun-oss",
"objectKey": object_key,
"upstreamStatus": status.as_u16(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_generated_object_key_keeps_supported_prefix() {
let object_key = build_generated_object_key(
LegacyAssetPrefix::Animations,
"hero/animation-set-1/idle/frame01.png",
)
.expect("object key should build");
assert_eq!(
object_key,
"generated-animations/hero/animation-set-1/idle/frame01.png"
);
}
#[test]
fn build_generated_object_key_supports_big_fish_assets() {
let object_key = build_generated_object_key(
LegacyAssetPrefix::BigFishAssets,
"big-fish-session-1/level-main-image/level-1/image.png",
)
.expect("object key should build");
assert_eq!(
object_key,
"generated-big-fish-assets/big-fish-session-1/level-main-image/level-1/image.png"
);
}
#[test]
fn build_generated_object_key_rejects_parent_segment() {
assert!(
build_generated_object_key(LegacyAssetPrefix::Characters, "../secret.png").is_err()
);
}
}

View File

@@ -2,17 +2,21 @@ use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
response::Response,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextProtocol, LlmTextRequest};
use serde_json::Value;
use platform_llm::{LlmMessage, LlmMessageRole, LlmTextProtocol, LlmTextRequest};
use serde_json::{Value, json};
use shared_contracts::llm::{
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole,
};
use std::convert::Infallible;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
platform_errors::map_llm_error, request_context::RequestContext, state::AppState,
};
pub async fn proxy_llm_chat_completions(
@@ -20,15 +24,7 @@ pub async fn proxy_llm_chat_completions(
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<LlmChatCompletionRequest>,
) -> Result<Json<Value>, Response> {
if payload.stream {
return Err(llm_error_response(
&request_context,
AppError::from_status(StatusCode::NOT_IMPLEMENTED)
.with_message("Rust `api-server` 首版暂不支持流式 LLM 代理"),
));
}
) -> Result<Response, Response> {
let llm_client = state.llm_client().ok_or_else(|| {
llm_error_response(
&request_context,
@@ -49,6 +45,10 @@ pub async fn proxy_llm_chat_completions(
enable_web_search: false,
};
if payload.stream {
return Ok(stream_llm_chat_completions(llm_client.clone(), request).into_response());
}
let response = llm_client
.request_text(request)
.await
@@ -62,7 +62,78 @@ pub async fn proxy_llm_chat_completions(
content: response.content,
finish_reason: response.finish_reason,
},
))
)
.into_response())
}
fn stream_llm_chat_completions(
llm_client: platform_llm::LlmClient,
request: LlmTextRequest,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let stream = async_stream::stream! {
let (delta_tx, mut delta_rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
let llm_stream = llm_client.stream_text(request, move |delta| {
let _ = delta_tx.send(json!({
"delta": delta.delta_text,
"content": delta.accumulated_text,
"finishReason": delta.finish_reason,
}));
});
tokio::pin!(llm_stream);
let llm_result = loop {
// `platform-llm` 负责上游 SSE 解析;这里尽快把增量转成 API 层 SSE 事件。
tokio::select! {
result = &mut llm_stream => break result,
maybe_delta = delta_rx.recv() => {
if let Some(delta) = maybe_delta {
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error("delta", delta));
}
}
}
};
while let Some(delta) = delta_rx.recv().await {
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error("delta", delta));
}
match llm_result {
Ok(response) => {
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error(
"complete",
json!(LlmChatCompletionResponse {
id: response.response_id,
model: response.model,
content: response.content,
finish_reason: response.finish_reason,
}),
));
}
Err(error) => {
let app_error = map_llm_error(error);
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error(
"error",
json!({
"code": app_error.code(),
"message": app_error.message(),
}),
));
}
}
yield Ok::<Event, Infallible>(Event::default().data("[DONE]"));
};
Sse::new(stream)
}
fn llm_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match serde_json::to_string(&payload) {
Ok(payload_text) => Event::default().event(event_name).data(payload_text),
Err(_) => Event::default()
.event("error")
.data("{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"SSE payload 序列化失败\"}"),
}
}
fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
@@ -75,39 +146,6 @@ fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
LlmMessage::new(role, message.content)
}
fn map_llm_error(error: LlmError) -> AppError {
match error {
LlmError::InvalidRequest(message) => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
}
LlmError::InvalidConfig(message) => {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message)
}
LlmError::Upstream {
status_code: 429,
message,
} => AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(message),
LlmError::Upstream { message, .. } => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
}
LlmError::Timeout { attempts } => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("LLM 请求超时,累计尝试 {attempts}")),
LlmError::Connectivity { attempts, message } => {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("LLM 连接失败,累计尝试 {attempts} 次:{message}"))
}
LlmError::StreamUnavailable => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 流式响应体不可用")
}
LlmError::EmptyResponse => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 返回内容为空")
}
LlmError::Transport(message) | LlmError::Deserialize(message) => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
}
}
}
fn llm_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -139,6 +177,7 @@ mod tests {
status_line: &'static str,
content_type: &'static str,
body: String,
extra_headers: Vec<(&'static str, &'static str)>,
}
#[tokio::test]
@@ -147,6 +186,7 @@ mod tests {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_api_server_01","model":"ark-router-test","choices":[{"message":{"content":""},"finish_reason":"stop"}]}"#.to_string(),
extra_headers: Vec::new(),
}]);
let state = seed_authenticated_state(AppConfig {
llm_base_url: server_url,
@@ -210,8 +250,25 @@ mod tests {
}
#[tokio::test]
async fn llm_chat_completions_rejects_stream_mode() {
let state = seed_authenticated_state(AppConfig::default()).await;
async fn llm_chat_completions_streams_sse_payload() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\n",
"data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n"
)
.to_string(),
extra_headers: vec![("x-request-id", "req_llm_stream_01")],
}]);
let state = seed_authenticated_state(AppConfig {
llm_base_url: server_url,
llm_api_key: Some("test-key".to_string()),
..AppConfig::default()
})
.await;
let token = issue_access_token(&state);
let app = build_router(state);
@@ -222,7 +279,6 @@ mod tests {
.uri("/api/llm/chat/completions")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"stream": true,
@@ -237,7 +293,14 @@ mod tests {
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok()),
Some("text/event-stream")
);
let body = response
.into_body()
@@ -245,14 +308,15 @@ mod tests {
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
let body_text = String::from_utf8(body.to_vec()).expect("body should be utf8");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["code"],
Value::String("NOT_IMPLEMENTED".to_string())
);
assert!(body_text.contains("event: delta"));
assert!(body_text.contains(r#""delta":"你""#));
assert!(body_text.contains(r#""content":"你好""#));
assert!(body_text.contains("event: complete"));
assert!(body_text.contains(r#""id":"req_llm_stream_01""#));
assert!(body_text.contains(r#""finishReason":"stop""#));
assert!(body_text.contains("data: [DONE]"));
}
async fn seed_authenticated_state(config: AppConfig) -> AppState {
@@ -340,13 +404,17 @@ mod tests {
fn write_response(stream: &mut std::net::TcpStream, response: MockResponse) {
let body = response.body;
let raw_response = format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
let mut raw_response = format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n",
response.status_line,
response.content_type,
body.len(),
body
body.len()
);
for (name, value) in response.extra_headers {
raw_response.push_str(format!("{name}: {value}\r\n").as_str());
}
raw_response.push_str("\r\n");
raw_response.push_str(body.as_str());
stream
.write_all(raw_response.as_bytes())

View File

@@ -34,16 +34,17 @@ mod custom_world_rpg_draft_prompts;
mod error_middleware;
mod health;
mod http_error;
mod legacy_generated_assets;
mod llm;
mod llm_model_routing;
mod login_options;
mod logout;
mod logout_all;
mod match3d;
mod openai_image_generation;
mod password_entry;
mod password_management;
mod phone_auth;
mod platform_errors;
mod profile_identity;
mod prompt;
mod puzzle;
@@ -59,7 +60,6 @@ mod runtime_inventory;
mod runtime_profile;
mod runtime_save;
mod runtime_settings;
mod runtime_story;
mod session_client;
mod state;
mod story_battles;
@@ -76,9 +76,10 @@ use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// 运行本地开发与联调时,优先从仓库根目录的 .env / .env.local 加载变量,避免手工逐项导出 OSS 配置。
// 运行本地开发与联调时,优先从仓库根目录加载本地变量,避免手工逐项导出 OSS / APIMart 配置。
let _ = dotenvy::from_filename(".env");
let _ = dotenvy::from_filename(".env.local");
let _ = dotenvy::from_filename(".env.secrets.local");
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
let config = AppConfig::from_env();

View File

@@ -0,0 +1,632 @@
use std::time::{Duration, Instant};
use axum::http::StatusCode;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use reqwest::header;
use serde_json::{Map, Value, json};
use tokio::time::sleep;
use crate::{http_error::AppError, state::AppState};
pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2";
#[derive(Clone, Debug)]
pub(crate) struct OpenAiImageSettings {
pub base_url: String,
pub api_key: String,
pub request_timeout_ms: u64,
}
#[derive(Clone, Debug)]
pub(crate) struct OpenAiGeneratedImages {
pub task_id: String,
pub actual_prompt: Option<String>,
pub images: Vec<DownloadedOpenAiImage>,
}
#[derive(Clone, Debug)]
pub(crate) struct DownloadedOpenAiImage {
pub bytes: Vec<u8>,
pub mime_type: String,
pub extension: String,
}
// 中文注释RPG 图片资产与拼图一样走 APIMart 的 OpenAI 兼容图片入口,避免把密钥或供应商协议暴露到前端。
pub(crate) fn require_openai_image_settings(
state: &AppState,
) -> Result<OpenAiImageSettings, AppError> {
let base_url = state.config.apimart_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.apimart_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_API_KEY 未配置",
}))
})?;
Ok(OpenAiImageSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1),
})
}
pub(crate) fn build_openai_image_http_client(
settings: &OpenAiImageSettings,
) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "apimart",
"message": format!("构造 APIMart 图片生成 HTTP 客户端失败:{error}"),
}))
})
}
pub(crate) async fn create_openai_image_generation(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[String],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let request_body = build_openai_image_request_body(
prompt,
negative_prompt,
size,
candidate_count,
reference_images,
);
let response = http_client
.post(format!("{}/images/generations", settings.base_url))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::CONTENT_TYPE, "application/json")
.json(&request_body)
.send()
.await
.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:创建图片生成任务失败:{error}"
))
})?;
let response_status = response.status();
let response_text = response.text().await.map_err(|error| {
map_openai_image_request_error(format!("{failure_context}:读取图片生成响应失败:{error}"))
})?;
if !response_status.is_success() {
return Err(map_openai_image_upstream_error(
response_status.as_u16(),
response_text.as_str(),
failure_context,
));
}
let response_json = parse_json_payload(response_text.as_str(), failure_context)?;
let image_urls = extract_image_urls(&response_json.payload);
if !image_urls.is_empty() {
return download_images_from_urls(
http_client,
format!("apimart-{}", current_utc_micros()),
image_urls,
candidate_count,
)
.await;
}
let b64_images = extract_b64_images(&response_json.payload);
if !b64_images.is_empty() {
return Ok(images_from_base64(
format!("apimart-{}", current_utc_micros()),
b64_images,
candidate_count,
));
}
let task_id = extract_task_id(&response_json.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": format!("{failure_context}:上游未返回 task_id 或图片"),
}))
})?;
wait_openai_generated_images(
http_client,
settings,
task_id.as_str(),
candidate_count,
failure_context,
)
.await
}
pub(crate) fn build_openai_image_request_body(
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[String],
) -> Value {
let mut body = Map::from_iter([
(
"model".to_string(),
Value::String(GPT_IMAGE_2_MODEL.to_string()),
),
(
"prompt".to_string(),
Value::String(build_prompt_with_negative(prompt, negative_prompt)),
),
("n".to_string(), json!(candidate_count.clamp(1, 4))),
(
"size".to_string(),
Value::String(normalize_image_size(size)),
),
]);
if !reference_images.is_empty() {
body.insert("image_urls".to_string(), json!(reference_images));
}
Value::Object(body)
}
fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String {
let prompt = prompt.trim();
let Some(negative_prompt) = negative_prompt
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return prompt.to_string();
};
format!("{prompt}\n避免:{negative_prompt}")
}
fn normalize_image_size(size: &str) -> String {
match size.trim() {
"1024*1024" | "1024x1024" | "1:1" => "1:1",
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" => "16:9",
value if !value.is_empty() => value,
_ => "1:1",
}
.to_string()
}
async fn wait_openai_generated_images(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
task_id: &str,
candidate_count: u32,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
sleep(Duration::from_secs(10)).await;
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:查询图片生成任务失败:{error}"
))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:读取图片生成任务响应失败:{error}"
))
})?;
if !poll_status.is_success() {
return Err(map_openai_image_upstream_error(
poll_status.as_u16(),
poll_text.as_str(),
failure_context,
));
}
let poll_json = parse_json_payload(poll_text.as_str(), failure_context)?;
let task_status = find_first_string_by_key(&poll_json.payload, "status")
.or_else(|| find_first_string_by_key(&poll_json.payload, "task_status"))
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(task_status.as_str(), "completed" | "succeeded" | "success") {
let image_urls = extract_image_urls(&poll_json.payload);
if image_urls.is_empty() {
let b64_images = extract_b64_images(&poll_json.payload);
if b64_images.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
"provider": "apimart",
"message": format!("{failure_context}:任务成功但未返回图片"),
}),
));
}
let mut generated =
images_from_base64(task_id.to_string(), b64_images, candidate_count);
generated.actual_prompt =
find_first_string_by_key(&poll_json.payload, "actual_prompt");
return Ok(generated);
}
let mut generated = download_images_from_urls(
http_client,
task_id.to_string(),
image_urls,
candidate_count,
)
.await?;
generated.actual_prompt = find_first_string_by_key(&poll_json.payload, "actual_prompt");
return Ok(generated);
}
if matches!(
task_status.as_str(),
"failed" | "error" | "canceled" | "cancelled" | "unknown"
) {
return Err(map_openai_image_upstream_error(
poll_status.as_u16(),
poll_text.as_str(),
failure_context,
));
}
sleep(Duration::from_secs(3)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": format!("{failure_context}:图片生成超时或未返回图片地址"),
})),
)
}
async fn download_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
image_urls: Vec<String>,
candidate_count: u32,
) -> Result<OpenAiGeneratedImages, AppError> {
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
{
images.push(download_remote_image(http_client, image_url.as_str()).await?);
}
Ok(OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
})
}
fn images_from_base64(
task_id: String,
b64_images: Vec<String>,
candidate_count: u32,
) -> OpenAiGeneratedImages {
let images = b64_images
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
.filter_map(|raw| decode_generated_image_base64(raw.as_str()))
.collect();
OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
}
}
fn decode_generated_image_base64(raw: &str) -> Option<DownloadedOpenAiImage> {
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
let mime_type = infer_image_mime_type(bytes.as_slice());
Some(DownloadedOpenAiImage {
extension: mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes,
})
}
pub(crate) async fn download_remote_image(
http_client: &reqwest::Client,
image_url: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let response =
http_client.get(image_url).send().await.map_err(|error| {
map_openai_image_request_error(format!("下载生成图片失败:{error}"))
})?;
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let body = response.bytes().await.map_err(|error| {
map_openai_image_request_error(format!("读取生成图片内容失败:{error}"))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "下载生成图片失败",
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str());
Ok(DownloadedOpenAiImage {
extension: mime_to_extension(normalized_mime_type.as_str()).to_string(),
mime_type: normalized_mime_type,
bytes: body.to_vec(),
})
}
fn parse_json_payload(
raw_text: &str,
failure_context: &str,
) -> Result<ParsedJsonPayload, AppError> {
serde_json::from_str::<Value>(raw_text)
.map(|payload| ParsedJsonPayload { payload })
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": format!("{failure_context}:解析响应失败:{error}"),
"rawExcerpt": truncate_raw(raw_text),
}))
})
}
fn map_openai_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": message,
}))
}
fn map_openai_image_upstream_error(
upstream_status: u16,
raw_text: &str,
failure_context: &str,
) -> AppError {
let message = parse_api_error_message(raw_text, failure_context);
tracing::warn!(
provider = "apimart",
upstream_status,
raw_excerpt = %truncate_raw(raw_text),
message,
"APIMart 图片生成上游错误"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": message,
"upstreamStatus": upstream_status,
"rawExcerpt": truncate_raw(raw_text),
}))
}
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if raw_text.trim().is_empty() {
return fallback_message.to_string();
}
if let Ok(parsed) = serde_json::from_str::<Value>(raw_text) {
for pointer in [
"/error/message",
"/message",
"/output/message",
"/data/message",
] {
if let Some(message) = parsed
.pointer(pointer)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
}
for pointer in ["/error/code", "/code", "/output/code", "/data/code"] {
if let Some(code) = parsed
.pointer(pointer)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
}
}
raw_text.trim().to_string()
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value {
Value::Array(entries) => {
for entry in entries {
collect_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key {
match nested_value {
Value::String(text) => {
let text = text.trim();
if !text.is_empty() {
results.push(text.to_string());
continue;
}
}
Value::Array(entries) => {
for entry in entries {
if let Some(text) = entry
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
}
}
}
_ => {}
}
}
collect_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_strings_by_key(value, target_key, &mut results);
results.into_iter().next()
}
fn extract_task_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "task_id")
.or_else(|| find_first_string_by_key(payload, "taskId"))
.or_else(|| find_first_string_by_key(payload, "id"))
}
fn extract_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_strings_by_key(payload, "url", &mut urls);
collect_strings_by_key(payload, "image", &mut urls);
collect_strings_by_key(payload, "image_url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn extract_b64_images(payload: &Value) -> Vec<String> {
let mut values = Vec::new();
collect_strings_by_key(payload, "b64_json", &mut values);
values
}
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/jpeg");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/jpeg".to_string(),
}
}
fn mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "jpg",
}
}
fn infer_image_mime_type(bytes: &[u8]) -> String {
if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
return "image/png".to_string();
}
if bytes.starts_with(b"\xFF\xD8\xFF") {
return "image/jpeg".to_string();
}
if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
return "image/webp".to_string();
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return "image/gif".to_string();
}
"image/png".to_string()
}
fn truncate_raw(raw_text: &str) -> String {
raw_text.chars().take(800).collect()
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
struct ParsedJsonPayload {
payload: Value,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gpt_image_2_request_normalizes_legacy_sizes_and_reference_images() {
let body = build_openai_image_request_body(
"雾海神殿",
Some("文字,水印"),
"1280*720",
2,
&["data:image/png;base64,abcd".to_string()],
);
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], "16:9");
assert_eq!(body["n"], 2);
assert_eq!(body["image_urls"][0], "data:image/png;base64,abcd");
assert!(body["prompt"].as_str().unwrap_or_default().contains("避免"));
}
#[test]
fn b64_json_response_decodes_png_image() {
let images = images_from_base64(
"task-1".to_string(),
vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")],
1,
);
assert_eq!(images.images.len(), 1);
assert_eq!(images.images[0].mime_type, "image/png");
assert_eq!(images.images[0].extension, "png");
}
}

View File

@@ -1,7 +1,7 @@
use axum::{
Json,
extract::{Extension, State},
http::{HeaderMap, HeaderValue, StatusCode},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::{
@@ -22,6 +22,7 @@ use crate::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_phone_auth_platform_store_error},
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
@@ -303,10 +304,7 @@ pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
attach_retry_after(app_error, retry_after_seconds)
}
PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())
@@ -315,7 +313,7 @@ pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
PhoneAuthError::Store(_) | PhoneAuthError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
map_phone_auth_platform_store_error(error.to_string())
}
}
}

View File

@@ -0,0 +1,133 @@
use axum::http::{HeaderValue, StatusCode};
use platform_auth::{AuthPlatformErrorKind, WechatProviderError};
use platform_llm::{LlmError, LlmErrorKind};
use platform_oss::{OssError, OssErrorKind};
use serde_json::json;
use crate::http_error::AppError;
// API 层统一消费 platform 的稳定错误分类,避免各 route 重复 match 具体 provider 分支。
pub fn map_llm_error(error: LlmError) -> AppError {
let message = llm_error_message(&error);
let status = match error.kind() {
LlmErrorKind::InvalidRequest => StatusCode::BAD_REQUEST,
LlmErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
LlmErrorKind::Upstream
if matches!(
error,
LlmError::Upstream {
status_code: 429,
..
}
) =>
{
StatusCode::TOO_MANY_REQUESTS
}
LlmErrorKind::Timeout
| LlmErrorKind::Connectivity
| LlmErrorKind::Upstream
| LlmErrorKind::StreamUnavailable
| LlmErrorKind::EmptyResponse
| LlmErrorKind::Transport
| LlmErrorKind::Deserialize => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_message(message)
}
pub fn map_oss_error(error: OssError, provider: &'static str) -> AppError {
let status = oss_error_status(error.kind());
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": error.to_string(),
}))
}
pub fn map_phone_auth_platform_store_error(message: String) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
pub fn map_wechat_provider_error(error: WechatProviderError) -> AppError {
let status = match error.kind() {
AuthPlatformErrorKind::Disabled
| AuthPlatformErrorKind::MissingCode
| AuthPlatformErrorKind::InvalidCallback => StatusCode::BAD_REQUEST,
AuthPlatformErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
AuthPlatformErrorKind::RequestFailed
| AuthPlatformErrorKind::DeserializeFailed
| AuthPlatformErrorKind::MissingProfile
| AuthPlatformErrorKind::Upstream => StatusCode::BAD_GATEWAY,
AuthPlatformErrorKind::InvalidClaims
| AuthPlatformErrorKind::SignFailed
| AuthPlatformErrorKind::VerifyFailed
| AuthPlatformErrorKind::CookieConfig
| AuthPlatformErrorKind::HashFailed
| AuthPlatformErrorKind::InvalidVerifyCode => StatusCode::INTERNAL_SERVER_ERROR,
};
AppError::from_status(status).with_message(error.to_string())
}
pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError {
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => error.with_header("retry-after", value),
Err(_) => error,
}
}
fn oss_error_status(kind: OssErrorKind) -> StatusCode {
match kind {
OssErrorKind::InvalidConfig | OssErrorKind::InvalidRequest => StatusCode::BAD_REQUEST,
OssErrorKind::ObjectNotFound => StatusCode::NOT_FOUND,
OssErrorKind::Request | OssErrorKind::SerializePolicy | OssErrorKind::Sign => {
StatusCode::BAD_GATEWAY
}
}
}
fn llm_error_message(error: &LlmError) -> String {
match error {
LlmError::InvalidConfig(message)
| LlmError::InvalidRequest(message)
| LlmError::Transport(message)
| LlmError::Deserialize(message) => message.clone(),
LlmError::Timeout { .. }
| LlmError::Connectivity { .. }
| LlmError::Upstream { .. }
| LlmError::StreamUnavailable
| LlmError::EmptyResponse => error.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_oss_error_uses_stable_kind_for_not_found() {
let error = map_oss_error(
OssError::ObjectNotFound("missing object".to_string()),
"oss",
);
assert_eq!(error.status_code(), StatusCode::NOT_FOUND);
}
#[test]
fn map_llm_error_preserves_upstream_rate_limit() {
let error = map_llm_error(LlmError::Upstream {
status_code: 429,
message: "too many requests".to_string(),
});
assert_eq!(error.status_code(), StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn map_wechat_provider_error_keeps_provider_boundary() {
let error = map_wechat_provider_error(WechatProviderError::MissingCode);
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(error.message(), "缺少微信授权 code");
}
}

View File

@@ -153,7 +153,11 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
[
"请根据下面的世界核心信息,批量生成场景框架名单。".to_string(),
if is_opening_batch {
"这一步必须一次性生成开局场景和普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
if batch_count == 1 {
"这一步只生成开局场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
} else {
"这一步必须一次性生成开局场景和普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
}
} else {
"这一步必须一次性生成普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
},
@@ -162,7 +166,11 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
build_framework_summary_text(framework, 0),
if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("")) },
if is_opening_batch {
"第一条场景必须是玩家进入世界时所在的开局场景,后续条目才是普通关键场景。".to_string()
if batch_count == 1 {
"本批场景必须是玩家进入世界时所在的开局场景。".to_string()
} else {
"第一条场景必须是玩家进入世界时所在的开局场景,后续条目才是普通关键场景。".to_string()
}
} else {
"本批只生成普通关键场景,不要再生成开局场景。".to_string()
},

File diff suppressed because it is too large Load Diff

View File

@@ -258,7 +258,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.body(Body::empty())
.expect("request should build"),
)
@@ -278,7 +278,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
@@ -324,7 +324,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/browse-history")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
@@ -361,64 +361,6 @@ mod tests {
);
}
#[tokio::test]
async fn runtime_browse_history_compat_route_matches_main_route_error_shape() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/browse-history")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_body = main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let compat_body = compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let main_payload: Value =
serde_json::from_slice(&main_body).expect("response body should be valid json");
let compat_payload: Value =
serde_json::from_slice(&compat_body).expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -10,10 +10,10 @@ use axum::{
use platform_llm::{LlmMessage, LlmTextRequest};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime_story::RuntimeStorySnapshotPayload;
use shared_contracts::story::StoryRuntimeSnapshotPayload as RuntimeStorySnapshotPayload;
use std::convert::Infallible;
use module_runtime_story_compat::{
use module_runtime_story::{
RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type,
normalize_required_string, read_array_field, read_field, read_i32_field, read_object_field,
read_optional_string_field, read_runtime_session_id,

View File

@@ -10,7 +10,7 @@ use axum::{
use platform_llm::{LlmMessage, LlmTextRequest};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime_story::RuntimeStorySnapshotPayload;
use shared_contracts::story::StoryRuntimeSnapshotPayload as RuntimeStorySnapshotPayload;
use std::convert::Infallible;
use crate::{
@@ -18,7 +18,7 @@ use crate::{
llm_model_routing::RPG_STORY_LLM_MODEL, prompt::runtime_chat::*,
request_context::RequestContext, state::AppState,
};
use module_runtime_story_compat::{
use module_runtime_story::{
RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type,
normalize_required_string, read_array_field, read_field, read_runtime_session_id,
};

View File

@@ -685,7 +685,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/dashboard")
.uri("/api/profile/dashboard")
.body(Body::empty())
.expect("request should build"),
)
@@ -703,7 +703,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/wallet-ledger")
.uri("/api/profile/wallet-ledger")
.body(Body::empty())
.expect("request should build"),
)
@@ -721,7 +721,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/play-stats")
.uri("/api/profile/play-stats")
.body(Body::empty())
.expect("request should build"),
)
@@ -860,90 +860,36 @@ mod tests {
}
#[tokio::test]
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
async fn runtime_profile_legacy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for uri in [
"/api/runtime/profile/dashboard",
"/api/profile/dashboard",
)
.await;
}
#[tokio::test]
async fn profile_wallet_ledger_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/wallet-ledger",
"/api/profile/wallet-ledger",
)
.await;
}
#[tokio::test]
async fn profile_play_stats_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/recharge-center",
"/api/runtime/profile/recharge/orders",
"/api/runtime/profile/referrals/invite-center",
"/api/runtime/profile/referrals/redeem-code",
"/api/runtime/profile/redeem-codes/redeem",
"/api/runtime/profile/play-stats",
"/api/profile/play-stats",
)
.await;
}
"/api/runtime/profile/save-archives",
"/api/runtime/profile/save-archives/world-1",
"/api/runtime/profile/browse-history",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
async fn assert_compat_route_matches_main_route_error_shape(
main_route: &str,
compat_route: &str,
) {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(main_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(compat_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_body = main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let compat_body = compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let main_payload: Value =
serde_json::from_slice(&main_body).expect("response body should be valid json");
let compat_payload: Value =
serde_json::from_slice(&compat_body).expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
}
}
async fn seed_authenticated_state() -> AppState {

View File

@@ -4,7 +4,10 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_runtime::format_utc_micros;
use module_runtime::{
RuntimeProfileFieldError, build_runtime_save_checkpoint_input,
build_runtime_save_checkpoint_update,
};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime::{
@@ -52,26 +55,6 @@ pub async fn put_runtime_snapshot(
Json(payload): Json<PutRuntimeSaveCheckpointRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "sessionId",
"message": "sessionId 不能为空",
})),
)
})?;
let bottom_tab = normalize_required_string(payload.bottom_tab.as_str()).ok_or_else(|| {
runtime_save_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "bottomTab",
"message": "bottomTab 不能为空",
})),
)
})?;
let now = OffsetDateTime::now_utc();
let saved_at = payload
.saved_at
@@ -90,6 +73,15 @@ pub async fn put_runtime_snapshot(
.unwrap_or(now);
let updated_at_micros = offset_datetime_to_unix_micros(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let checkpoint_input = build_runtime_save_checkpoint_input(
payload.session_id,
payload.bottom_tab,
saved_at_micros,
updated_at_micros,
)
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_domain_error(error))
})?;
let existing = state
.get_runtime_snapshot_record(user_id.clone())
@@ -107,16 +99,18 @@ pub async fn put_runtime_snapshot(
)
})?;
validate_checkpoint_snapshot(&request_context, &session_id, &existing.game_state)?;
let game_state = sync_runtime_snapshot_play_time(existing.game_state, updated_at_micros);
let update =
build_runtime_save_checkpoint_update(checkpoint_input, existing).map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_domain_error(error))
})?;
let record = state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
bottom_tab,
game_state,
existing.current_story,
updated_at_micros,
update.saved_at_micros,
update.bottom_tab,
update.game_state,
update.current_story,
update.updated_at_micros,
)
.await
.map_err(|error| {
@@ -223,132 +217,6 @@ fn build_saved_game_snapshot_response(
}
}
fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
let Some(game_state) = game_state.as_object() else {
return false;
};
if game_state
.get("runtimePersistenceDisabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return true;
}
matches!(
game_state
.get("runtimeMode")
.and_then(Value::as_str)
.map(str::trim),
Some("preview") | Some("test")
)
}
fn validate_checkpoint_snapshot(
request_context: &RequestContext,
session_id: &str,
game_state: &Value,
) -> Result<(), Response> {
if is_non_persistent_runtime_snapshot(game_state) {
return Err(runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "预览或测试运行态不能创建正式 checkpoint",
})),
));
}
let persisted_session_id =
read_string_field(game_state, "runtimeSessionId").ok_or_else(|| {
runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "服务端运行时快照缺少 runtimeSessionId无法创建 checkpoint",
})),
)
})?;
if persisted_session_id != session_id {
return Err(runtime_save_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": "checkpoint sessionId 与服务端运行时快照不一致",
"expectedSessionId": persisted_session_id,
"actualSessionId": session_id,
})),
));
}
Ok(())
}
fn sync_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value {
let Some(game_state_object) = game_state.as_object_mut() else {
return game_state;
};
let now_text = format_utc_micros(now_micros);
let Some(runtime_stats) = game_state_object
.get_mut("runtimeStats")
.and_then(Value::as_object_mut)
else {
game_state_object.insert(
"runtimeStats".to_string(),
json!({
"playTimeMs": 0,
"lastPlayTickAt": now_text,
"hostileNpcsDefeated": 0,
"questsAccepted": 0,
"itemsUsed": 0,
"scenesTraveled": 0,
}),
);
return game_state;
};
let current_play_time = runtime_stats
.get("playTimeMs")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0);
let elapsed_ms = runtime_stats
.get("lastPlayTickAt")
.and_then(Value::as_str)
.and_then(|last_tick| parse_rfc3339(last_tick).ok())
.map(offset_datetime_to_unix_micros)
.map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0)
.unwrap_or(0.0);
let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0);
// 中文注释checkpoint 只刷新服务端已有 runtimeStats 的时间水位,
// 不从浏览器接收任何任务、背包、战斗或剧情状态。
runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64));
runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text));
game_state
}
fn read_string_field(value: &Value, field: &str) -> Option<String> {
value
.as_object()?
.get(field)?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn normalize_required_string(value: &str) -> Option<String> {
let normalized = value.trim();
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
fn build_profile_save_archive_summary_response(
record: &module_runtime::RuntimeProfileSaveArchiveRecord,
) -> ProfileSaveArchiveSummaryResponse {
@@ -395,6 +263,51 @@ fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError
}))
}
fn map_runtime_save_domain_error(error: RuntimeProfileFieldError) -> AppError {
let message = error.to_string();
match error {
RuntimeProfileFieldError::MissingCheckpointSessionId => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "sessionId",
"message": "sessionId 不能为空",
}))
}
RuntimeProfileFieldError::MissingRuntimeSessionId => {
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
}))
}
RuntimeProfileFieldError::MissingBottomTab => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "bottomTab",
"message": "bottomTab 不能为空",
}))
}
RuntimeProfileFieldError::NonPersistentRuntimeSnapshot => {
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
}))
}
RuntimeProfileFieldError::RuntimeSessionMismatch {
expected_session_id,
actual_session_id,
} => AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
"expectedSessionId": expected_session_id,
"actualSessionId": actual_session_id,
})),
_ => AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"message": message,
})),
}
}
fn runtime_save_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -584,7 +497,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/save-archives")
.uri("/api/profile/save-archives")
.body(Body::empty())
.expect("request should build"),
)
@@ -594,15 +507,6 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_save_archives_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(
"/api/runtime/profile/save-archives",
"/api/profile/save-archives",
)
.await;
}
#[tokio::test]
async fn resume_profile_save_archive_rejects_blank_world_key() {
let state = seed_authenticated_state().await;
@@ -613,7 +517,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/save-archives/%20%20")
.uri("/api/profile/save-archives/%20%20")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
@@ -625,68 +529,6 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
async fn assert_compat_route_matches_main_route_error_shape(
main_route: &str,
compat_route: &str,
) {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let main_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(main_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let compat_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(compat_route)
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(main_response.status(), compat_response.status());
let main_payload: Value = serde_json::from_slice(
&main_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response body should be valid json");
let compat_payload: Value = serde_json::from_slice(
&compat_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response body should be valid json");
assert_eq!(
main_payload["error"]["details"]["provider"],
compat_payload["error"]["details"]["provider"]
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -1,6 +0,0 @@
mod compat;
pub use compat::{
begin_runtime_story_session, generate_runtime_story_continue, generate_runtime_story_initial,
get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,372 +0,0 @@
use super::*;
use crate::llm_model_routing::RPG_STORY_LLM_MODEL;
use crate::prompt::runtime_chat::{
RuntimeNpcDialoguePromptParams, RuntimeReasonedStoryPromptParams, RuntimeStoryTextPromptParams,
build_runtime_npc_dialogue_user_prompt, build_runtime_reasoned_story_user_prompt,
build_runtime_story_director_user_prompt, runtime_npc_dialogue_system_prompt,
runtime_reasoned_story_system_prompt, runtime_story_director_system_prompt,
};
pub(super) async fn build_runtime_story_ai_response(
state: &AppState,
payload: RuntimeStoryAiRequest,
initial: bool,
) -> RuntimeStoryAiResponse {
let options = build_ai_response_options(&payload);
let fallback = build_ai_fallback_story_text(&payload, initial);
let story_text = generate_ai_story_text(state, &payload, initial)
.await
.filter(|text| !text.trim().is_empty())
.unwrap_or(fallback);
RuntimeStoryAiResponse {
story_text,
options,
encounter: None,
}
}
pub(super) async fn generate_ai_story_text(
state: &AppState,
payload: &RuntimeStoryAiRequest,
initial: bool,
) -> Option<String> {
let llm_client = state.llm_client()?;
let system_prompt = runtime_story_director_system_prompt(initial);
let user_prompt = build_runtime_story_director_user_prompt(RuntimeStoryTextPromptParams {
world_type: payload.world_type.as_str(),
character: payload.character.clone(),
monsters: Value::Array(payload.monsters.clone()),
history: Value::Array(payload.history.clone()),
choice: Value::String(payload.choice.clone()),
context: payload.context.clone(),
available_options: Value::Array(payload.request_options.available_options.clone()),
});
let mut request = LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]);
request.max_tokens = Some(700);
apply_rpg_web_search(state, &mut request);
llm_client
.request_text(request)
.await
.ok()
.map(|response| response.content.trim().to_string())
.filter(|text| !text.is_empty())
}
pub(super) async fn generate_action_story_payload(
state: &AppState,
game_state: &Value,
request: &RuntimeStoryActionRequest,
function_id: &str,
action_text: &str,
result_text: &str,
options: &[RuntimeStoryOptionView],
battle: Option<&RuntimeBattlePresentation>,
) -> Option<GeneratedStoryPayload> {
let llm_client = state.llm_client()?;
// 动作结算仍由确定性规则完成LLM 只负责把已结算结果改写为可展示文本,失败时不影响主链。
if function_id == "npc_chat" || function_id == "story_opening_camp_dialogue" {
return generate_npc_dialogue_payload(
llm_client,
state.config.rpg_llm_web_search_enabled,
game_state,
request,
action_text,
result_text,
options,
)
.await;
}
if should_generate_reasoned_combat_story(battle) {
return generate_reasoned_story_payload(
llm_client,
state.config.rpg_llm_web_search_enabled,
game_state,
request,
action_text,
result_text,
options,
battle,
)
.await;
}
None
}
fn apply_rpg_web_search(state: &AppState, request: &mut LlmTextRequest) {
request.enable_web_search = state.config.rpg_llm_web_search_enabled;
request.model = Some(RPG_STORY_LLM_MODEL.to_string());
}
pub(super) async fn generate_npc_dialogue_payload(
llm_client: &LlmClient,
enable_web_search: bool,
game_state: &Value,
request: &RuntimeStoryActionRequest,
action_text: &str,
result_text: &str,
deferred_options: &[RuntimeStoryOptionView],
) -> Option<GeneratedStoryPayload> {
let world_type = current_world_type(game_state)?;
let character = read_object_field(game_state, "playerCharacter")?.clone();
let encounter = read_object_field(game_state, "currentEncounter")?;
if read_required_string_field(encounter, "kind").as_deref() != Some("npc") {
return None;
}
let npc_name = read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
let user_prompt = build_runtime_npc_dialogue_user_prompt(
npc_name.as_str(),
RuntimeNpcDialoguePromptParams {
world_type: world_type.as_str(),
character: &character,
encounter,
monsters: read_array_field(game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect::<Vec<_>>(),
history: build_action_story_history(game_state, action_text, result_text),
context: build_action_story_prompt_context(game_state, None),
topic: action_text,
result_summary: result_text,
requested_option: request.action.payload.clone().unwrap_or(Value::Null),
available_options: build_action_prompt_options(deferred_options),
},
);
let mut llm_request = LlmTextRequest::new(vec![
LlmMessage::system(runtime_npc_dialogue_system_prompt()),
LlmMessage::user(user_prompt),
]);
llm_request.max_tokens = Some(700);
llm_request.enable_web_search = enable_web_search;
llm_request.model = Some(RPG_STORY_LLM_MODEL.to_string());
let dialogue_text = llm_client
.request_text(llm_request)
.await
.ok()
.map(|response| response.content.trim().to_string())
.filter(|text| !text.is_empty())?;
let presentation_options = vec![build_continue_adventure_runtime_story_option()];
let saved_current_story =
build_dialogue_current_story(npc_name.as_str(), dialogue_text.as_str(), deferred_options);
Some(GeneratedStoryPayload {
story_text: dialogue_text.clone(),
history_result_text: dialogue_text,
presentation_options,
saved_current_story,
})
}
pub(super) async fn generate_reasoned_story_payload(
llm_client: &LlmClient,
enable_web_search: bool,
game_state: &Value,
request: &RuntimeStoryActionRequest,
action_text: &str,
result_text: &str,
options: &[RuntimeStoryOptionView],
battle: Option<&RuntimeBattlePresentation>,
) -> Option<GeneratedStoryPayload> {
let world_type = current_world_type(game_state)?;
let character = read_object_field(game_state, "playerCharacter")?.clone();
let user_prompt = build_runtime_reasoned_story_user_prompt(RuntimeReasonedStoryPromptParams {
world_type: world_type.as_str(),
character: &character,
monsters: read_array_field(game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect::<Vec<_>>(),
history: build_action_story_history(game_state, action_text, result_text),
context: build_action_story_prompt_context(game_state, battle),
choice: action_text,
result_summary: result_text,
requested_option: request.action.payload.clone().unwrap_or(Value::Null),
available_options: build_action_prompt_options(options),
});
let mut llm_request = LlmTextRequest::new(vec![
LlmMessage::system(runtime_reasoned_story_system_prompt()),
LlmMessage::user(user_prompt),
]);
llm_request.max_tokens = Some(700);
llm_request.enable_web_search = enable_web_search;
llm_request.model = Some(RPG_STORY_LLM_MODEL.to_string());
let story_text = llm_client
.request_text(llm_request)
.await
.ok()
.map(|response| response.content.trim().to_string())
.filter(|text| !text.is_empty())?;
Some(GeneratedStoryPayload {
story_text: story_text.clone(),
history_result_text: story_text.clone(),
presentation_options: options.to_vec(),
saved_current_story: build_legacy_current_story(story_text.as_str(), options),
})
}
pub(super) fn should_generate_reasoned_combat_story(
_battle: Option<&RuntimeBattlePresentation>,
) -> bool {
// 战斗动作、逃跑、胜利、切磋结束与死亡都只走确定性结算,避免战斗链路再次触发剧情推理。
false
}
pub(super) fn build_action_story_history(
game_state: &Value,
action_text: &str,
result_text: &str,
) -> Vec<Value> {
let mut history = read_array_field(game_state, "storyHistory")
.into_iter()
.filter_map(|entry| {
let text = read_optional_string_field(entry, "text")?;
let history_role = read_optional_string_field(entry, "historyRole")
.unwrap_or_else(|| "result".to_string());
Some(json!({
"text": text,
"historyRole": history_role,
}))
})
.collect::<Vec<_>>();
history.push(json!({
"text": action_text,
"historyRole": "action",
}));
history.push(json!({
"text": result_text,
"historyRole": "result",
}));
let keep_from = history.len().saturating_sub(12);
history.into_iter().skip(keep_from).collect()
}
pub(super) fn build_action_story_prompt_context(
game_state: &Value,
battle: Option<&RuntimeBattlePresentation>,
) -> Value {
let scene_preset = read_object_field(game_state, "currentScenePreset");
let battle_value = battle
.and_then(|presentation| serde_json::to_value(presentation).ok())
.unwrap_or(Value::Null);
json!({
"sceneName": scene_preset
.and_then(|scene| read_optional_string_field(scene, "name"))
.or_else(|| read_optional_string_field(game_state, "currentScene"))
.unwrap_or_else(|| "当前区域".to_string()),
"sceneDescription": scene_preset
.and_then(|scene| read_optional_string_field(scene, "description"))
.or_else(|| read_optional_string_field(game_state, "sceneDescription"))
.unwrap_or_else(|| "周围气氛仍在继续变化。".to_string()),
"encounterName": read_object_field(game_state, "currentEncounter")
.and_then(|encounter| {
read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
}),
"encounterId": current_encounter_id(game_state),
"playerHp": read_i32_field(game_state, "playerHp").unwrap_or(0),
"playerMaxHp": read_i32_field(game_state, "playerMaxHp").unwrap_or(1),
"playerMana": read_i32_field(game_state, "playerMana").unwrap_or(0),
"playerMaxMana": read_i32_field(game_state, "playerMaxMana").unwrap_or(1),
"inBattle": read_bool_field(game_state, "inBattle").unwrap_or(false),
"currentNpcBattleOutcome": read_optional_string_field(game_state, "currentNpcBattleOutcome"),
"battle": battle_value,
})
}
pub(super) fn build_action_prompt_options(options: &[RuntimeStoryOptionView]) -> Vec<Value> {
options
.iter()
.filter(|option| !option.disabled.unwrap_or(false))
.map(|option| {
json!({
"functionId": option.function_id,
"actionText": option.action_text,
"text": option.action_text,
})
})
.collect()
}
pub(super) fn build_ai_response_options(payload: &RuntimeStoryAiRequest) -> Vec<Value> {
let source = if payload.request_options.available_options.is_empty() {
&payload.request_options.option_catalog
} else {
&payload.request_options.available_options
};
let options = source
.iter()
.filter_map(normalize_ai_story_option)
.collect::<Vec<_>>();
if !options.is_empty() {
return options;
}
vec![
build_ai_story_option_value("idle_observe_signs", "观察周围迹象"),
build_ai_story_option_value("idle_explore_forward", "继续向前探索"),
build_ai_story_option_value("idle_rest_focus", "原地调息"),
]
}
pub(super) fn normalize_ai_story_option(value: &Value) -> Option<Value> {
let function_id = read_required_string_field(value, "functionId")?;
let action_text = read_required_string_field(value, "actionText")
.or_else(|| read_required_string_field(value, "text"))
.unwrap_or_else(|| function_id.clone());
let mut option = value.as_object()?.clone();
option.insert("functionId".to_string(), Value::String(function_id));
option.insert("actionText".to_string(), Value::String(action_text.clone()));
option
.entry("text".to_string())
.or_insert_with(|| Value::String(action_text));
Some(Value::Object(option))
}
pub(super) fn build_ai_story_option_value(function_id: &str, action_text: &str) -> Value {
json!({
"functionId": function_id,
"actionText": action_text,
"text": action_text,
"visuals": {
"playerAnimation": "idle",
"playerMoveMeters": 0,
"playerOffsetY": 0,
"playerFacing": "right",
"scrollWorld": false,
"monsterChanges": []
}
})
}
pub(super) fn build_ai_fallback_story_text(
payload: &RuntimeStoryAiRequest,
initial: bool,
) -> String {
let character_name =
read_optional_string_field(&payload.character, "name").unwrap_or_else(|| "".to_string());
let scene_name = read_optional_string_field(&payload.context, "sceneName")
.or_else(|| read_optional_string_field(&payload.context, "scene"))
.unwrap_or_else(|| "当前区域".to_string());
if initial {
return format!(
"{character_name}{scene_name} 稳住脚步,周围的气息正在变化,第一轮选择已经摆到眼前。"
);
}
let choice = normalize_required_string(payload.choice.as_str())
.unwrap_or_else(|| "继续推进".to_string());
format!("{character_name} 选择了「{choice}」,{scene_name} 的局势随之向下一步展开。")
}

View File

@@ -1,106 +0,0 @@
use super::*;
/// 对齐 Node 旧 inventory compat先按装备位把物品从背包切到 playerEquipment
/// 再把基础面板属性回算到快照上。
pub(super) fn resolve_equipment_equip_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
if read_field(game_state, "playerCharacter").is_none() {
return Err("缺少玩家角色,无法调整装备。".to_string());
}
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
return Err("战斗中无法调整装备。".to_string());
}
let item_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "itemId"))
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "equipment_equip 缺少 itemId".to_string())?;
let item = find_player_inventory_entry(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "背包里没有这件装备。".to_string())?;
let slot_id = resolve_equipment_slot_for_item(&item)
.ok_or_else(|| format!("{} 不是可装备物品。", read_inventory_item_name(&item)))?;
let previous_equipment = read_player_equipment_item(game_state, slot_id);
let next_equipment_item = normalize_equipped_item(&item);
remove_player_inventory_item(game_state, item_id.as_str(), 1);
if let Some(previous_equipment) = previous_equipment.as_ref() {
add_player_inventory_items(game_state, vec![previous_equipment.clone()]);
}
write_player_equipment_item(game_state, slot_id, Some(next_equipment_item));
apply_equipment_loadout_to_state(game_state);
let item_name = read_inventory_item_name(&item);
let result_text = if let Some(previous_equipment) = previous_equipment.as_ref() {
format!(
"你将{}{}位上换下,改为装备{}",
read_inventory_item_name(previous_equipment),
equipment_slot_label(slot_id),
item_name
)
} else {
format!(
"你将{}装备在{}位上。",
item_name,
equipment_slot_label(slot_id)
)
};
Ok(StoryResolution {
action_text: resolve_action_text(&format!("装备{}", item_name), request),
result_text,
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: Some(build_current_build_toast(game_state)),
})
}
pub(super) fn resolve_equipment_unequip_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
ensure_inventory_action_available(
game_state,
"缺少玩家角色,无法卸下装备。",
"战斗中无法卸下装备。",
)?;
let slot_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "slotId"))
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?;
let slot_id = normalize_equipment_slot_id(slot_id.as_str())
.ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?;
let equipped_item = read_player_equipment_item(game_state, slot_id)
.ok_or_else(|| format!("{}位当前没有装备。", equipment_slot_label(slot_id)))?;
write_player_equipment_item(game_state, slot_id, None);
add_player_inventory_items(game_state, vec![equipped_item.clone()]);
apply_equipment_loadout_to_state(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!("卸下{}", read_inventory_item_name(&equipped_item)),
request,
),
result_text: format!(
"你卸下了{},暂时收回背包。",
read_inventory_item_name(&equipped_item)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: Some(build_current_build_toast(game_state)),
})
}

View File

@@ -1,699 +0,0 @@
use super::*;
use module_runtime_story_compat::{build_runtime_equipment_item, build_runtime_material_item};
pub(super) fn current_npc_trade_context(game_state: &Value) -> Result<(String, String), String> {
let encounter = read_object_field(game_state, "currentEncounter")
.ok_or_else(|| "当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string())?;
let kind = read_required_string_field(encounter, "kind")
.ok_or_else(|| "当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string())?;
if kind != "npc" {
return Err("当前不在可结算的 NPC 交互态,无法执行交易或赠礼。".to_string());
}
let npc_name = current_encounter_name(game_state);
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| npc_name.clone());
if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none()
{
return Err("当前 NPC 状态不存在,无法继续结算。".to_string());
}
Ok((npc_id, npc_name))
}
pub(super) fn current_npc_inventory_items<'a>(game_state: &'a Value) -> Vec<&'a Value> {
let Some(npc_id) = current_encounter_id(game_state) else {
return Vec::new();
};
let npc_name = current_encounter_name(game_state);
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
.map(|state| read_array_field(state, "inventory"))
.unwrap_or_default()
}
/// 兼容桥沿用 Node 旧域的入口预处理:在读取选项或结算动作前,
/// 先确保当前 NPC 的持久状态最少可用,避免空快照直接打断交易/赠礼/委托主链。
pub(super) fn ensure_runtime_story_bridge_state(game_state: &mut Value) {
ensure_current_encounter_npc_state_initialized(game_state);
}
/// 这里不尝试一次性重建完整真相态,只补 compat bridge 当前确实依赖的字段,
/// 并为“纯商贩型 NPC”补一份确定性 trade stock保证旧前端菜单不因空状态掉链子。
pub(super) fn ensure_current_encounter_npc_state_initialized(game_state: &mut Value) {
let Some(encounter) = read_object_field(game_state, "currentEncounter").cloned() else {
return;
};
if read_optional_string_field(&encounter, "kind").as_deref() != Some("npc") {
return;
}
let npc_name = read_optional_string_field(&encounter, "npcName")
.or_else(|| read_optional_string_field(&encounter, "name"))
.unwrap_or_else(|| "当前遭遇".to_string());
let npc_id = read_optional_string_field(&encounter, "id").unwrap_or_else(|| npc_name.clone());
let storage_key = resolve_npc_state_storage_key(game_state, npc_id.as_str(), npc_name.as_str());
let existing_state = read_field(game_state, "npcStates")
.and_then(|states| read_field(states, storage_key.as_str()))
.cloned();
let affinity = existing_state
.as_ref()
.and_then(|state| read_i32_field(state, "affinity"))
.unwrap_or_else(|| default_current_npc_affinity(&encounter));
let recruited = existing_state
.as_ref()
.and_then(|state| read_bool_field(state, "recruited"))
.unwrap_or(false);
let chatted_count = existing_state
.as_ref()
.and_then(|state| read_i32_field(state, "chattedCount"))
.unwrap_or(0)
.max(0);
let gifts_given = existing_state
.as_ref()
.and_then(|state| read_i32_field(state, "giftsGiven"))
.unwrap_or(0)
.max(0);
let help_used = existing_state
.as_ref()
.and_then(|state| read_bool_field(state, "helpUsed"))
.unwrap_or(false);
let first_meaningful_contact_resolved = existing_state
.as_ref()
.and_then(|state| read_bool_field(state, "firstMeaningfulContactResolved"))
.unwrap_or(false);
let revealed_facts = existing_state
.as_ref()
.map(|state| read_string_list_field(state, "revealedFacts"))
.unwrap_or_default();
let known_attribute_rumors = existing_state
.as_ref()
.map(|state| read_string_list_field(state, "knownAttributeRumors"))
.unwrap_or_default();
let seen_backstory_chapter_ids = existing_state
.as_ref()
.map(|state| read_string_list_field(state, "seenBackstoryChapterIds"))
.unwrap_or_default();
let existing_inventory = existing_state
.as_ref()
.map(|state| {
read_array_field(state, "inventory")
.into_iter()
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
let existing_trade_stock_signature = existing_state
.as_ref()
.and_then(|state| read_optional_string_field(state, "tradeStockSignature"));
let hostile = read_bool_field(&encounter, "hostile").unwrap_or(false)
|| read_optional_string_field(&encounter, "monsterPresetId").is_some()
|| affinity < 0;
let context_text = read_optional_string_field(&encounter, "context");
let (inventory, trade_stock_signature) = if is_trade_driven_role_npc(&encounter) {
let next_signature = build_current_npc_trade_stock_signature(game_state, npc_id.as_str());
if existing_trade_stock_signature.as_deref() == Some(next_signature.as_str()) {
(existing_inventory, Some(next_signature))
} else {
(
sync_bootstrapped_trade_inventory(
game_state,
npc_id.as_str(),
npc_name.as_str(),
existing_inventory,
next_signature.as_str(),
),
Some(next_signature),
)
}
} else {
(existing_inventory, existing_trade_stock_signature)
};
let relation_state = build_runtime_story_relation_state_value(affinity);
let stance_profile = build_runtime_story_stance_profile_value(
affinity,
recruited,
hostile,
context_text.as_deref(),
existing_state
.as_ref()
.and_then(|state| read_field(state, "stanceProfile"))
.and_then(Value::as_object),
);
let npc_state = json!({
"affinity": affinity,
"chattedCount": chatted_count,
"helpUsed": help_used,
"giftsGiven": gifts_given,
"inventory": inventory,
"recruited": recruited,
"relationState": relation_state,
"revealedFacts": revealed_facts,
"knownAttributeRumors": known_attribute_rumors,
"firstMeaningfulContactResolved": first_meaningful_contact_resolved,
"seenBackstoryChapterIds": seen_backstory_chapter_ids,
"tradeStockSignature": trade_stock_signature,
"stanceProfile": stance_profile,
});
let root = ensure_json_object(game_state);
let npc_states = root
.entry("npcStates".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !npc_states.is_object() {
*npc_states = Value::Object(Map::new());
}
npc_states
.as_object_mut()
.expect("npcStates should be object")
.insert(storage_key, npc_state);
}
pub(super) fn resolve_npc_state_storage_key(
game_state: &Value,
npc_id: &str,
npc_name: &str,
) -> String {
read_object_field(game_state, "npcStates")
.and_then(Value::as_object)
.and_then(|states| {
if states.contains_key(npc_id) {
Some(npc_id.to_string())
} else if states.contains_key(npc_name) {
Some(npc_name.to_string())
} else {
None
}
})
.unwrap_or_else(|| npc_id.to_string())
}
pub(super) fn default_current_npc_affinity(encounter: &Value) -> i32 {
read_i32_field(encounter, "initialAffinity").unwrap_or_else(|| {
if read_optional_string_field(encounter, "monsterPresetId").is_some() {
-40
} else if read_optional_string_field(encounter, "characterId").is_some() {
18
} else {
6
}
})
}
pub(super) fn read_string_list_field(value: &Value, key: &str) -> Vec<String> {
let mut items = read_array_field(value, key)
.into_iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
if items.len() > 3 {
items = items.split_off(items.len() - 3);
}
items
}
pub(super) fn build_runtime_story_relation_state_value(affinity: i32) -> Value {
let relation_state = build_module_npc_relation_state(affinity);
json!({
"affinity": relation_state.affinity,
"stance": npc_relation_stance_key(relation_state.stance),
})
}
pub(super) fn npc_relation_stance_key(value: NpcRelationStance) -> &'static str {
match value {
NpcRelationStance::Hostile => "hostile",
NpcRelationStance::Guarded => "guarded",
NpcRelationStance::Neutral => "neutral",
NpcRelationStance::Cooperative => "cooperative",
NpcRelationStance::Bonded => "bonded",
}
}
pub(super) fn build_runtime_story_stance_profile_value(
affinity: i32,
recruited: bool,
hostile: bool,
role_text: Option<&str>,
existing_profile: Option<&Map<String, Value>>,
) -> Value {
let base = build_module_npc_initial_stance_profile(affinity, recruited, hostile, role_text);
let read_metric = |key: &str, fallback: u8| -> i32 {
existing_profile
.and_then(|profile| profile.get(key))
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
.unwrap_or(i32::from(fallback))
.clamp(0, 100)
};
let recent_approvals = existing_profile
.and_then(|profile| profile.get("recentApprovals"))
.map(|value| read_string_list_field(value, ""))
.unwrap_or_else(|| base.recent_approvals.clone());
let recent_disapprovals = existing_profile
.and_then(|profile| profile.get("recentDisapprovals"))
.map(|value| read_string_list_field(value, ""))
.unwrap_or_else(|| base.recent_disapprovals.clone());
json!({
"trust": read_metric("trust", base.trust),
"warmth": read_metric("warmth", base.warmth),
"ideologicalFit": read_metric("ideologicalFit", base.ideological_fit),
"fearOrGuard": read_metric("fearOrGuard", base.fear_or_guard),
"loyalty": read_metric("loyalty", base.loyalty),
"currentConflictTag": existing_profile
.and_then(|profile| profile.get("currentConflictTag"))
.and_then(Value::as_str)
.map(str::to_string)
.or(base.current_conflict_tag),
"recentApprovals": recent_approvals,
"recentDisapprovals": recent_disapprovals,
})
}
pub(super) fn is_trade_driven_role_npc(encounter: &Value) -> bool {
read_optional_string_field(encounter, "characterId").is_none()
&& read_optional_string_field(encounter, "monsterPresetId").is_none()
}
pub(super) fn build_current_npc_trade_stock_signature(game_state: &Value, npc_id: &str) -> String {
let scene_key = read_object_field(game_state, "currentScenePreset")
.and_then(|preset| {
read_optional_string_field(preset, "id")
.or_else(|| read_optional_string_field(preset, "name"))
})
.or_else(|| read_optional_string_field(game_state, "currentScene"))
.unwrap_or_else(|| "scene".to_string());
let world_key = current_world_type(game_state).unwrap_or_else(|| "world".to_string());
format!(
"{}:{}:{}",
sanitize_trade_stock_fragment(npc_id),
sanitize_trade_stock_fragment(scene_key.as_str()),
sanitize_trade_stock_fragment(world_key.as_str())
)
}
pub(super) fn sanitize_trade_stock_fragment(value: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|ch| match ch {
':' | '/' | '\\' | ' ' => '-',
_ => ch,
})
.collect::<String>();
if normalized.is_empty() {
"unknown".to_string()
} else {
normalized
}
}
pub(super) fn sync_bootstrapped_trade_inventory(
game_state: &Value,
npc_id: &str,
npc_name: &str,
existing_inventory: Vec<Value>,
trade_stock_signature: &str,
) -> Vec<Value> {
let preserved_inventory = existing_inventory
.into_iter()
.filter(|item| {
read_field(item, "runtimeMetadata")
.and_then(|metadata| read_optional_string_field(metadata, "generationChannel"))
.as_deref()
!= Some("npc_trade")
})
.collect::<Vec<_>>();
let mut next_inventory = preserved_inventory;
next_inventory.extend(build_bootstrapped_trade_inventory(
game_state,
npc_id,
npc_name,
trade_stock_signature,
));
next_inventory
}
pub(super) fn build_bootstrapped_trade_inventory(
game_state: &Value,
npc_id: &str,
npc_name: &str,
trade_stock_signature: &str,
) -> Vec<Value> {
let world_type = current_world_type(game_state);
let consumable_name = if world_type.as_deref() == Some("XIANXIA") {
"回灵散"
} else {
"回气散"
};
let material_name = if world_type.as_deref() == Some("XIANXIA") {
"凝光纱"
} else {
"工巧残材"
};
let relic_name = if world_type.as_deref() == Some("XIANXIA") {
"行旅护符"
} else {
"结绳护符"
};
let armor_name = if world_type.as_deref() == Some("XIANXIA") {
"护行法衣"
} else {
"护行短甲"
};
let tonic_id = format!("npc-trade:{trade_stock_signature}:tonic");
let material_id = format!("npc-trade:{trade_stock_signature}:material");
let relic_id = format!("npc-trade:{trade_stock_signature}:relic");
let armor_id = format!("npc-trade:{trade_stock_signature}:armor");
vec![
build_bootstrapped_trade_consumable_item(
tonic_id.as_str(),
consumable_name,
npc_name,
world_type.as_deref(),
),
attach_generated_trade_metadata(
build_runtime_material_item(
game_state,
material_name,
2,
&["工巧", "补给"],
"uncommon",
),
material_id.as_str(),
"npc_trade",
format!("{npc_id}:material").as_str(),
format!("{npc_name}整理出来的可交易工坊材料。").as_str(),
),
attach_generated_trade_metadata(
build_runtime_equipment_item(
game_state,
relic_name,
"relic",
"rare",
"适合长途行路时稳住灵力与节奏的护符。",
"护持",
&["护持", "法力"],
&["护持", "法力"],
json!({
"maxManaBonus": 12,
"outgoingDamageBonus": 0.05
}),
),
relic_id.as_str(),
"npc_trade",
format!("{npc_id}:relic").as_str(),
format!("{npc_name}随身携带的护身小物。").as_str(),
),
attach_generated_trade_metadata(
build_runtime_equipment_item(
game_state,
armor_name,
"armor",
"rare",
"为行路与近身护体准备的轻装护具。",
"守御",
&["守御", "护体"],
&["守御", "护体"],
json!({
"maxHpBonus": 18,
"incomingDamageMultiplier": 0.93
}),
),
armor_id.as_str(),
"npc_trade",
format!("{npc_id}:armor").as_str(),
format!("{npc_name}压箱底留下的一件护身装备。").as_str(),
),
]
}
pub(super) fn build_bootstrapped_trade_consumable_item(
item_id: &str,
name: &str,
npc_name: &str,
world_type: Option<&str>,
) -> Value {
json!({
"id": item_id,
"category": "消耗品",
"name": name,
"description": format!("{npc_name}常备的一份行路补给。"),
"quantity": 2,
"rarity": "uncommon",
"tags": if world_type == Some("XIANXIA") {
vec!["mana", "support", "trade"]
} else {
vec!["mana", "support", "trade"]
},
"useProfile": {
"hpRestore": 0,
"manaRestore": 10,
"cooldownReduction": 0,
"buildBuffs": []
},
"runtimeMetadata": {
"origin": "procedural",
"generationChannel": "npc_trade",
"seedKey": format!("{item_id}:seed"),
"sourceReason": format!("{npc_name}把最常用的补给拿出来做成了交易库存。"),
"storyFingerprint": {
"relatedScarIds": [format!("scar:npc_trade:{item_id}")],
"relatedThreadIds": [],
"visibleClue": format!("{npc_name}随身药囊里最顺手的一味补给。"),
"witnessMark": "药包封口处还留着反复拆开的折痕。",
"unresolvedQuestion": "这份补给之前究竟替谁留着。"
}
}
})
}
pub(super) fn attach_generated_trade_metadata(
mut item: Value,
item_id: &str,
generation_channel: &str,
seed_key: &str,
source_reason: &str,
) -> Value {
let item_name = read_inventory_item_name(&item);
let entry = ensure_json_object(&mut item);
entry.insert("id".to_string(), Value::String(item_id.to_string()));
entry.insert(
"runtimeMetadata".to_string(),
json!({
"origin": "procedural",
"generationChannel": generation_channel,
"seedKey": seed_key,
"sourceReason": source_reason,
"storyFingerprint": {
"relatedScarIds": [format!("scar:{generation_channel}:{seed_key}")],
"relatedThreadIds": [],
"visibleClue": format!("{item_name}上保留着反复流转留下的使用痕迹。"),
"witnessMark": "表面仍残留旧主人长期携带的磨损。",
"unresolvedQuestion": format!("{item_name}最初为什么会落到这名 NPC 手里。"),
}
}),
);
item
}
pub(super) fn read_current_npc_inventory_item<'a>(
game_state: &'a Value,
item_id: &str,
) -> Option<&'a Value> {
current_npc_inventory_items(game_state)
.into_iter()
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(item_id))
}
pub(super) fn adjust_current_npc_affinity(
game_state: &mut Value,
delta: i32,
) -> Option<(String, i32, i32)> {
let npc_id = current_encounter_id(game_state)?;
let npc_name = current_encounter_name(game_state);
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
let previous_affinity = state
.get("affinity")
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
.unwrap_or(0);
let next_affinity = (previous_affinity + delta).clamp(-100, 100);
state.insert("affinity".to_string(), json!(next_affinity));
state
.entry("recruited".to_string())
.or_insert(Value::Bool(false));
Some((npc_id, previous_affinity, next_affinity))
}
pub(super) fn read_current_npc_state_i32_field(game_state: &Value, key: &str) -> Option<i32> {
let npc_id = current_encounter_id(game_state)?;
let npc_name = current_encounter_name(game_state);
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
.and_then(|state| read_i32_field(state, key))
}
pub(super) fn read_current_npc_state_bool_field(game_state: &Value, key: &str) -> Option<bool> {
let npc_id = current_encounter_id(game_state)?;
let npc_name = current_encounter_name(game_state);
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
.and_then(|state| read_bool_field(state, key))
}
pub(super) fn write_current_npc_state_i32_field(game_state: &mut Value, key: &str, value: i32) {
let Some(npc_id) = current_encounter_id(game_state) else {
return;
};
let npc_name = current_encounter_name(game_state);
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
state.insert(key.to_string(), json!(value));
}
pub(super) fn write_current_npc_state_bool_field(game_state: &mut Value, key: &str, value: bool) {
let Some(npc_id) = current_encounter_id(game_state) else {
return;
};
let npc_name = current_encounter_name(game_state);
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
state.insert(key.to_string(), Value::Bool(value));
}
pub(super) fn set_current_npc_recruited(
game_state: &mut Value,
recruited: bool,
) -> Option<(i32, i32)> {
let npc_id = current_encounter_id(game_state)?;
let npc_name = current_encounter_name(game_state);
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
let previous_affinity = state
.get("affinity")
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
.unwrap_or(0);
let next_affinity = previous_affinity.max(60);
state.insert("affinity".to_string(), json!(next_affinity));
state.insert("recruited".to_string(), Value::Bool(recruited));
Some((previous_affinity, next_affinity))
}
pub(super) fn read_current_npc_affinity(game_state: &Value) -> i32 {
let Some(npc_id) = current_encounter_id(game_state) else {
return 0;
};
let npc_name = current_encounter_name(game_state);
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
.and_then(|state| read_i32_field(state, "affinity"))
.unwrap_or(0)
}
pub(super) fn ensure_npc_state_object<'a>(
game_state: &'a mut Value,
npc_id: &str,
npc_name: &str,
) -> &'a mut Map<String, Value> {
let root = ensure_json_object(game_state);
let npc_states = root
.entry("npcStates".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !npc_states.is_object() {
*npc_states = Value::Object(Map::new());
}
let states = npc_states
.as_object_mut()
.expect("npcStates should be object");
let existing_key = if states.contains_key(npc_id) {
npc_id.to_string()
} else if states.contains_key(npc_name) {
npc_name.to_string()
} else {
npc_id.to_string()
};
let state = states
.entry(existing_key)
.or_insert_with(|| Value::Object(Map::new()));
if !state.is_object() {
*state = Value::Object(Map::new());
}
state.as_object_mut().expect("npc state should be object")
}
pub(super) fn mark_current_npc_first_meaningful_contact_resolved(game_state: &mut Value) {
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
}
pub(super) fn ensure_current_npc_inventory_array<'a>(
game_state: &'a mut Value,
) -> Option<&'a mut Vec<Value>> {
let npc_id = current_encounter_id(game_state)?;
let npc_name = current_encounter_name(game_state);
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
let inventory = state
.entry("inventory".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !inventory.is_array() {
*inventory = Value::Array(Vec::new());
}
inventory.as_array_mut()
}
pub(super) fn add_current_npc_inventory_items(game_state: &mut Value, additions: Vec<Value>) {
if additions.is_empty() {
return;
}
let Some(items) = ensure_current_npc_inventory_array(game_state) else {
return;
};
for addition in additions {
let Some(add_id) = read_optional_string_field(&addition, "id") else {
continue;
};
let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1);
if let Some(existing) = items
.iter_mut()
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str()))
{
let next_quantity =
read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity;
if let Some(existing_object) = existing.as_object_mut() {
existing_object.insert("quantity".to_string(), json!(next_quantity));
}
continue;
}
items.push(addition);
}
}
pub(super) fn remove_current_npc_inventory_item(
game_state: &mut Value,
item_id: &str,
quantity: i32,
) {
if quantity <= 0 {
return;
}
let Some(items) = ensure_current_npc_inventory_array(game_state) else {
return;
};
let Some(index) = items
.iter()
.position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
else {
return;
};
let current_quantity = read_i32_field(&items[index], "quantity")
.unwrap_or(0)
.max(0);
let next_quantity = current_quantity - quantity;
if next_quantity <= 0 {
items.remove(index);
return;
}
if let Some(entry) = items[index].as_object_mut() {
entry.insert("quantity".to_string(), json!(next_quantity));
}
}

View File

@@ -1,551 +0,0 @@
use super::*;
pub(super) fn resolve_npc_preview_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc_name = current_encounter_name(game_state);
write_bool_field(game_state, "npcInteractionActive", true);
Ok(StoryResolution {
action_text: resolve_action_text("转向眼前角色", request),
result_text: format!("{npc_name} 注意到了你的靠近,正在等你先把话说出来。"),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: None,
})
}
pub(super) fn resolve_npc_affinity_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
default_action_text: &str,
affinity_delta: i32,
fallback_result_text: &str,
) -> Result<StoryResolution, String> {
write_bool_field(game_state, "npcInteractionActive", true);
let affinity_patch = adjust_current_npc_affinity(game_state, affinity_delta).map(
|(npc_id, previous_affinity, next_affinity)| RuntimeStoryPatch::NpcAffinityChanged {
npc_id,
previous_affinity,
next_affinity,
},
);
let mut patches = Vec::new();
if let Some(patch) = affinity_patch {
patches.push(patch);
}
patches.push(build_status_patch(game_state));
Ok(StoryResolution {
action_text: resolve_action_text(default_action_text, request),
result_text: fallback_result_text.to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches,
battle: None,
toast: None,
})
}
pub(super) fn resolve_npc_chat_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let chatted_count = read_current_npc_state_i32_field(game_state, "chattedCount").unwrap_or(0);
let affinity_gain = (6 - chatted_count).max(2);
let result_text = format!(
"{} 愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 {} 点。",
current_encounter_name(game_state),
affinity_gain
);
let mut resolution = resolve_npc_affinity_action(
game_state,
request,
"继续交谈",
affinity_gain,
result_text.as_str(),
)?;
write_current_npc_state_i32_field(game_state, "chattedCount", chatted_count.saturating_add(1));
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
resolution.action_text = format!("继续和{}交谈", current_encounter_name(game_state));
Ok(resolution)
}
pub(super) fn resolve_npc_help_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) {
return Err("当前 NPC 的一次性援手已经用完了".to_string());
}
restore_player_resource(game_state, 10, 8);
write_current_npc_state_bool_field(game_state, "helpUsed", true);
resolve_npc_affinity_action(
game_state,
request,
&format!("{}请求援手", current_encounter_name(game_state)),
4,
&format!(
"{} 给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。",
current_encounter_name(game_state)
),
)
}
pub(super) fn resolve_npc_battle_entry_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
function_id: &str,
) -> Result<StoryResolution, String> {
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
let npc_name = current_encounter_name(game_state);
let battle_mode = if function_id == "npc_spar" {
"spar"
} else {
"fight"
};
let return_encounter = read_object_field(game_state, "currentEncounter").cloned();
let resolved_formation =
resolve_npc_battle_formation(game_state, return_encounter.as_ref(), battle_mode);
write_bool_field(game_state, "inBattle", true);
write_bool_field(game_state, "npcInteractionActive", false);
write_string_field(game_state, "currentBattleNpcId", npc_id.as_str());
write_string_field(game_state, "currentNpcBattleMode", battle_mode);
write_null_field(game_state, "currentNpcBattleOutcome");
write_null_field(game_state, "currentEncounter");
ensure_json_object(game_state).insert(
"sceneHostileNpcs".to_string(),
Value::Array(resolved_formation),
);
if let Some(return_encounter) = return_encounter {
ensure_json_object(game_state).insert("sparReturnEncounter".to_string(), return_encounter);
}
Ok(StoryResolution {
action_text: resolve_action_text(
if battle_mode == "spar" {
"点到为止切磋"
} else {
"与对方战斗"
},
request,
),
result_text: format!(
"{npc_name} 已经进入{}节奏,下一步必须按战斗动作结算。",
battle_mode_text(battle_mode)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: Some(RuntimeBattlePresentation {
target_id: Some(npc_id),
target_name: Some(npc_name),
damage_dealt: None,
damage_taken: None,
outcome: Some("ongoing".to_string()),
}),
toast: None,
})
}
fn resolve_npc_battle_formation(
game_state: &Value,
encounter: Option<&Value>,
battle_mode: &str,
) -> Vec<Value> {
let visible_formation = read_array_field(game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect::<Vec<_>>();
if !visible_formation.is_empty() {
return visible_formation
.into_iter()
.enumerate()
.map(|(index, monster)| {
normalize_npc_battle_monster(monster, encounter, battle_mode, index)
})
.collect();
}
encounter
.map(|encounter| {
vec![build_npc_battle_monster_from_encounter(
game_state,
encounter,
battle_mode,
3.2,
0,
)]
})
.unwrap_or_default()
}
fn normalize_npc_battle_monster(
mut monster: Value,
fallback_encounter: Option<&Value>,
battle_mode: &str,
index: usize,
) -> Value {
let Some(monster_object) = monster.as_object_mut() else {
return monster;
};
monster_object
.entry("animation".to_string())
.or_insert_with(|| Value::String("idle".to_string()));
monster_object
.entry("facing".to_string())
.or_insert_with(|| Value::String("left".to_string()));
monster_object
.entry("renderKind".to_string())
.or_insert_with(|| Value::String("npc".to_string()));
monster_object
.entry("attackRange".to_string())
.or_insert_with(|| json!(1.8));
monster_object
.entry("speed".to_string())
.or_insert_with(|| json!(7));
let max_hp = monster_object
.get("maxHp")
.and_then(Value::as_i64)
.unwrap_or_else(|| if battle_mode == "spar" { 10 } else { 80 });
monster_object
.entry("hp".to_string())
.or_insert_with(|| json!(max_hp));
if !monster_object
.get("encounter")
.is_some_and(|value| value.is_object())
&& let Some(fallback_encounter) = fallback_encounter
{
// 中文注释:进入 NPC 战斗时画布已经改由 sceneHostileNpcs 渲染敌方;
// 旧快照里的敌方条目可能只有数值没有形象上下文,必须把当前 NPC encounter 补进去。
let mut battle_encounter = fallback_encounter.clone();
if let Some(entry) = battle_encounter.as_object_mut() {
entry.insert("hostile".to_string(), Value::Bool(true));
if !entry.contains_key("xMeters") {
let x_meters = monster_object
.get("xMeters")
.and_then(Value::as_f64)
.unwrap_or(3.2 + index as f64 * 1.08);
entry.insert("xMeters".to_string(), json!(x_meters));
}
}
monster_object.insert("encounter".to_string(), battle_encounter);
}
monster
}
fn build_npc_battle_monster_from_encounter(
game_state: &Value,
encounter: &Value,
battle_mode: &str,
x_meters: f64,
y_offset: i32,
) -> Value {
let npc_id = read_optional_string_field(encounter, "id")
.unwrap_or_else(|| current_encounter_name(game_state));
let npc_name = current_encounter_name(game_state);
let npc_state =
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str());
let affinity = npc_state
.and_then(|state| read_i32_field(state, "affinity"))
.or_else(|| read_i32_field(encounter, "initialAffinity"))
.unwrap_or(0);
let base_hp = if battle_mode == "spar" {
10
} else {
(80 + affinity).max(24)
};
let monster_id = read_optional_string_field(encounter, "monsterPresetId")
.unwrap_or_else(|| format!("npc-opponent-{npc_id}"));
let mut battle_encounter = encounter.clone();
if let Some(entry) = battle_encounter.as_object_mut() {
entry.insert("hostile".to_string(), Value::Bool(true));
entry.insert("xMeters".to_string(), json!(x_meters));
}
json!({
"id": monster_id,
"name": npc_name,
"action": if battle_mode == "spar" {
"抱拳行礼,准备点到为止地切磋武艺"
} else {
"摆开架势,随时准备出手"
},
"description": read_optional_string_field(encounter, "npcDescription").unwrap_or_default(),
"animation": "idle",
"xMeters": x_meters,
"yOffset": y_offset,
"facing": "left",
"attackRange": 1.8,
"speed": 7,
"hp": base_hp,
"maxHp": base_hp,
"renderKind": "npc",
"levelProfile": read_field(encounter, "levelProfile").cloned(),
"experienceReward": read_i32_field(encounter, "experienceReward").unwrap_or(0),
"encounter": battle_encounter
})
}
pub(super) fn resolve_npc_recruit_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
let npc_name = current_encounter_name(game_state);
let current_affinity = read_current_npc_affinity(game_state);
if read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false) {
return Err("当前 NPC 已经处于已招募状态".to_string());
}
if current_affinity < 60 {
return Err("当前关系还没达到招募阈值,暂时不能邀请入队".to_string());
}
let release_npc_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "releaseNpcId"));
let released_companion_name = recruit_companion_to_party(
game_state,
npc_id.as_str(),
current_affinity,
release_npc_id.as_deref(),
)?;
let affinity_patch =
set_current_npc_recruited(game_state, true).map(|(previous_affinity, next_affinity)| {
RuntimeStoryPatch::NpcAffinityChanged {
npc_id: npc_id.clone(),
previous_affinity,
next_affinity,
}
});
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
write_bool_field(game_state, "npcInteractionActive", false);
clear_encounter_only(game_state);
write_null_field(game_state, "currentNpcBattleMode");
write_null_field(game_state, "currentNpcBattleOutcome");
write_bool_field(game_state, "inBattle", false);
let mut patches = Vec::new();
if let Some(patch) = affinity_patch {
patches.push(patch);
}
patches.push(build_status_patch(game_state));
patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None });
Ok(StoryResolution {
action_text: resolve_action_text(&format!("邀请{npc_name}加入队伍"), request),
result_text: match released_companion_name {
Some(released_name) => format!(
"{npc_name} 接受了你的邀请,你先让 {released_name} 暂时离队,把位置腾给了新的同行者。"
),
None => format!("{npc_name} 接受了你的邀请,正式进入了同行队伍。"),
},
story_text: None,
presentation_options: None,
saved_current_story: None,
patches,
battle: None,
toast: Some(format!("{npc_name} 已加入队伍")),
})
}
/// 先按 NPC 当前遭遇态结算简化版买卖逻辑,保持与 Node compat 一致的字段写回,
/// 后续再由真相态 inventory / runtime-item reducer 接管。
pub(super) fn resolve_npc_trade_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let (_npc_id, npc_name) = current_npc_trade_context(game_state)?;
let payload = request.action.payload.as_ref();
let mode = payload
.and_then(|value| read_optional_string_field(value, "mode"))
.ok_or_else(|| "npc_trade 缺少合法 mode需为 buy 或 sell".to_string())?;
if mode != "buy" && mode != "sell" {
return Err("npc_trade 缺少合法 mode需为 buy 或 sell".to_string());
}
let item_id = payload
.and_then(|value| {
read_optional_string_field(value, "itemId")
.or_else(|| read_optional_string_field(value, "selectedNpcItemId"))
.or_else(|| read_optional_string_field(value, "selectedPlayerItemId"))
})
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "npc_trade 缺少 itemId".to_string())?;
let quantity = payload
.and_then(|value| read_i32_field(value, "quantity"))
.unwrap_or(1);
if quantity <= 0 {
return Err("npc_trade.quantity 必须大于 0".to_string());
}
if mode == "buy" {
let npc_item = read_current_npc_inventory_item(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "目标商品不存在或库存不足。".to_string())?;
let available_quantity = read_i32_field(&npc_item, "quantity").unwrap_or(0).max(0);
if available_quantity < quantity {
return Err("目标商品不存在或库存不足。".to_string());
}
let total_price = npc_purchase_price(&npc_item, read_current_npc_affinity(game_state))
.saturating_mul(quantity);
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
if player_currency < total_price {
return Err("当前钱币不足,无法完成购买。".to_string());
}
write_i32_field(game_state, "playerCurrency", player_currency - total_price);
add_player_inventory_items(
game_state,
vec![clone_inventory_item_with_quantity(&npc_item, quantity)],
);
remove_current_npc_inventory_item(game_state, item_id.as_str(), quantity);
mark_current_npc_first_meaningful_contact_resolved(game_state);
let item_name = read_inventory_item_name(&npc_item);
return Ok(StoryResolution {
action_text: resolve_action_text(
&format!(
"{}手里买下{}{}",
npc_name,
item_name,
trade_quantity_suffix(quantity)
),
request,
),
result_text: format!(
"{}收下了{},把{}{}卖给了你。",
npc_name,
format_currency_text(
total_price,
read_optional_string_field(game_state, "worldType").as_deref()
),
item_name,
trade_quantity_suffix(quantity)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: None,
});
}
let player_item = find_player_inventory_entry(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "背包里没有足够数量的目标物品。".to_string())?;
let available_quantity = read_i32_field(&player_item, "quantity").unwrap_or(0).max(0);
if available_quantity < quantity {
return Err("背包里没有足够数量的目标物品。".to_string());
}
let total_price = npc_buyback_price(&player_item, read_current_npc_affinity(game_state))
.saturating_mul(quantity);
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
write_i32_field(
game_state,
"playerCurrency",
player_currency.saturating_add(total_price),
);
remove_player_inventory_item(game_state, item_id.as_str(), quantity);
add_current_npc_inventory_items(
game_state,
vec![clone_inventory_item_with_quantity(&player_item, quantity)],
);
mark_current_npc_first_meaningful_contact_resolved(game_state);
let item_name = read_inventory_item_name(&player_item);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!(
"{}{}卖给{}",
item_name,
trade_quantity_suffix(quantity),
npc_name
),
request,
),
result_text: format!(
"{}收下了{}{},付给你{}。",
npc_name,
item_name,
trade_quantity_suffix(quantity),
format_currency_text(
total_price,
read_optional_string_field(game_state, "worldType").as_deref()
)
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: None,
})
}
pub(super) fn resolve_npc_gift_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let (npc_id, npc_name) = current_npc_trade_context(game_state)?;
let item_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "itemId"))
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "npc_gift 缺少 itemId".to_string())?;
let gift_item = find_player_inventory_entry(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "背包里没有这件可赠送的物品。".to_string())?;
if read_i32_field(&gift_item, "quantity").unwrap_or(0) <= 0 {
return Err("背包里没有这件可赠送的物品。".to_string());
}
let previous_affinity = read_current_npc_affinity(game_state);
let affinity_gain = resolve_npc_gift_affinity_gain(&gift_item);
let next_affinity = (previous_affinity + affinity_gain).clamp(-100, 100);
remove_player_inventory_item(game_state, item_id.as_str(), 1);
add_current_npc_inventory_items(
game_state,
vec![clone_inventory_item_with_quantity(&gift_item, 1)],
);
write_current_npc_state_i32_field(game_state, "affinity", next_affinity);
let next_gifts_given =
read_current_npc_state_i32_field(game_state, "giftsGiven").unwrap_or(0) + 1;
write_current_npc_state_i32_field(game_state, "giftsGiven", next_gifts_given);
mark_current_npc_first_meaningful_contact_resolved(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!("{}赠给{}", read_inventory_item_name(&gift_item), npc_name),
request,
),
result_text: build_npc_gift_result_text(
npc_name.as_str(),
&gift_item,
affinity_gain,
next_affinity,
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![RuntimeStoryPatch::NpcAffinityChanged {
npc_id,
previous_affinity,
next_affinity,
}],
battle: None,
toast: None,
})
}

View File

@@ -1,736 +0,0 @@
use super::*;
pub(super) fn build_runtime_story_state_response(
requested_session_id: &str,
client_version: Option<u32>,
mut snapshot: RuntimeStorySnapshotPayload,
) -> RuntimeStoryActionResponse {
ensure_runtime_story_bridge_state(&mut snapshot.game_state);
write_runtime_npc_interaction_view(&mut snapshot.game_state);
let session_id = read_runtime_session_id(&snapshot.game_state)
.unwrap_or_else(|| requested_session_id.to_string());
let options =
build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state);
let story_text = read_story_text(snapshot.current_story.as_ref())
.unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state));
let server_version = read_u32_field(&snapshot.game_state, "runtimeActionVersion")
.or(client_version)
.unwrap_or(0);
build_runtime_story_action_response(RuntimeStoryActionResponseParts {
requested_session_id: session_id,
server_version,
snapshot,
action_text: String::new(),
result_text: String::new(),
story_text,
options,
patches: Vec::new(),
toast: None,
battle: None,
})
}
pub(super) fn build_runtime_story_action_response(
parts: RuntimeStoryActionResponseParts,
) -> RuntimeStoryActionResponse {
let session_id = read_runtime_session_id(&parts.snapshot.game_state)
.unwrap_or_else(|| parts.requested_session_id);
RuntimeStoryActionResponse {
session_id,
server_version: parts.server_version,
view_model: build_runtime_story_view_model(&parts.snapshot.game_state, &parts.options),
presentation: RuntimeStoryPresentation {
action_text: parts.action_text,
result_text: parts.result_text,
story_text: parts.story_text,
options: parts.options,
toast: parts.toast,
battle: parts.battle,
},
patches: parts.patches,
snapshot: parts.snapshot,
}
}
pub(super) fn build_dialogue_current_story(
npc_name: &str,
text: &str,
deferred_options: &[RuntimeStoryOptionView],
) -> Value {
let continue_option = build_continue_adventure_runtime_story_option();
// 对齐 Node 旧 currentStory先展示单轮对话只把真实下一步选项压到 deferredOptions。
json!({
"text": text,
"options": vec![build_story_option_from_runtime_option(&continue_option)],
"displayMode": "dialogue",
"dialogue": parse_dialogue_turns(text, npc_name),
"streaming": false,
"deferredOptions": deferred_options
.iter()
.map(build_story_option_from_runtime_option)
.collect::<Vec<_>>(),
})
}
pub(super) fn build_continue_adventure_runtime_story_option() -> RuntimeStoryOptionView {
build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story")
}
pub(super) fn parse_dialogue_turns(text: &str, npc_name: &str) -> Vec<Value> {
let mut turns = Vec::new();
for raw_line in text.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if let Some(turn) = parse_dialogue_line(line, npc_name) {
turns.push(turn);
}
}
if turns.is_empty() && !text.trim().is_empty() {
turns.push(json!({
"speaker": "npc",
"speakerName": npc_name,
"text": text.trim(),
}));
}
turns
}
pub(super) fn parse_dialogue_line(line: &str, npc_name: &str) -> Option<Value> {
let delimiter_index = line.find('').or_else(|| line.find(':'))?;
let speaker_name = line[..delimiter_index].trim();
let content_start = delimiter_index + line[delimiter_index..].chars().next()?.len_utf8();
let content = line[content_start..].trim();
if content.is_empty() {
return None;
}
if speaker_name == "" {
return Some(json!({
"speaker": "player",
"text": content,
}));
}
if speaker_name == npc_name {
return Some(json!({
"speaker": "npc",
"speakerName": npc_name,
"text": content,
}));
}
Some(json!({
"speaker": "companion",
"speakerName": speaker_name,
"text": content,
}))
}
pub(super) fn build_runtime_story_options(
current_story: Option<&Value>,
game_state: &Value,
) -> Vec<RuntimeStoryOptionView> {
if let Some(story) = current_story {
let prefers_deferred = read_required_string_field(story, "displayMode")
.is_some_and(|value| value == "dialogue")
&& !read_array_field(story, "deferredOptions").is_empty();
let source = if prefers_deferred {
read_array_field(story, "deferredOptions")
} else {
read_array_field(story, "options")
};
let compiled = source
.into_iter()
.filter_map(build_runtime_story_option_from_story_option)
.collect::<Vec<_>>();
if !compiled.is_empty() {
return compiled;
}
}
build_fallback_runtime_story_options(game_state)
}
pub(super) fn build_fallback_runtime_story_options(
game_state: &Value,
) -> Vec<RuntimeStoryOptionView> {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
return build_battle_runtime_story_options(game_state);
}
let encounter = read_object_field(game_state, "currentEncounter");
if let Some(encounter) = encounter {
if matches!(
read_required_string_field(encounter, "kind").as_deref(),
Some("npc")
) {
let interaction_active =
read_bool_field(game_state, "npcInteractionActive").unwrap_or(false);
let npc_id = read_required_string_field(encounter, "id")
.unwrap_or_else(|| "npc_current".to_string());
if let Some(active_quest) = find_active_quest_for_issuer(game_state, npc_id.as_str()) {
if read_optional_string_field(active_quest, "status")
.is_some_and(|status| status == "completed")
{
return vec![
build_npc_runtime_story_option_with_quest(
"npc_quest_turn_in",
&format!("{}交付委托", current_encounter_name(game_state)),
&npc_id,
"quest_turn_in",
read_optional_string_field(active_quest, "id"),
),
build_npc_runtime_story_option(
"npc_leave",
"离开当前角色",
&npc_id,
"leave",
),
];
}
}
if interaction_active {
return build_active_npc_runtime_story_options(game_state, npc_id.as_str());
}
return vec![
build_npc_runtime_story_option("npc_preview_talk", "转向眼前角色", &npc_id, "chat"),
build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"),
build_npc_runtime_story_option("npc_leave", "离开当前角色", &npc_id, "leave"),
];
}
}
vec![
build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"),
build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"),
build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"),
build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"),
build_static_runtime_story_option("idle_travel_next_scene", "前往相邻场景", "story"),
build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story"),
]
}
pub(super) fn build_npc_runtime_story_option(
function_id: &str,
action_text: &str,
npc_id: &str,
action: &str,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
interaction: Some(RuntimeStoryOptionInteraction::Npc {
npc_id: npc_id.to_string(),
action: action.to_string(),
quest_id: None,
}),
..build_static_runtime_story_option(function_id, action_text, "npc")
}
}
pub(super) fn build_npc_runtime_story_option_with_payload(
function_id: &str,
action_text: &str,
npc_id: &str,
action: &str,
payload: Value,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
payload: Some(payload),
..build_npc_runtime_story_option(function_id, action_text, npc_id, action)
}
}
pub(super) fn build_npc_runtime_story_option_with_quest(
function_id: &str,
action_text: &str,
npc_id: &str,
action: &str,
quest_id: Option<String>,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
interaction: Some(RuntimeStoryOptionInteraction::Npc {
npc_id: npc_id.to_string(),
action: action.to_string(),
quest_id,
}),
..build_static_runtime_story_option(function_id, action_text, "npc")
}
}
/// 对齐 Node 旧 compat 入口顺序,在 NPC 交互态下统一补齐交易、赠礼、委托与招募入口。
pub(super) fn build_active_npc_runtime_story_options(
game_state: &Value,
npc_id: &str,
) -> Vec<RuntimeStoryOptionView> {
if read_current_npc_affinity(game_state) < 0 {
return vec![build_npc_runtime_story_option(
"npc_chat",
"继续交谈",
npc_id,
"chat",
)];
}
let mut options = vec![
build_npc_runtime_story_option("npc_chat", "继续交谈", npc_id, "chat"),
build_npc_help_runtime_story_option(game_state, npc_id),
];
if current_npc_inventory_items(game_state)
.iter()
.any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0)
{
options.push(build_npc_runtime_story_option(
"npc_trade",
"交易",
npc_id,
"trade",
));
}
if has_giftable_player_inventory(game_state) {
options.push(build_npc_runtime_story_option(
"npc_gift",
"赠送礼物",
npc_id,
"gift",
));
}
let active_quest = find_active_quest_for_issuer(game_state, npc_id);
if let Some(active_quest) = active_quest {
let can_turn_in = read_optional_string_field(active_quest, "status")
.is_some_and(|status| status == "completed" || status == "ready_to_turn_in");
if can_turn_in {
options.push(build_npc_runtime_story_option_with_quest(
"npc_quest_turn_in",
&format!("{}交付委托", current_encounter_name(game_state)),
npc_id,
"quest_turn_in",
read_optional_string_field(active_quest, "id"),
));
}
} else {
options.push(build_npc_runtime_story_option(
"npc_quest_accept",
"接下委托",
npc_id,
"quest_accept",
));
}
if read_current_npc_affinity(game_state) >= 60
&& !read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false)
{
options.push(build_npc_runtime_story_option(
"npc_recruit",
"邀请同行",
npc_id,
"recruit",
));
}
options
}
pub(super) fn build_npc_help_runtime_story_option(
game_state: &Value,
npc_id: &str,
) -> RuntimeStoryOptionView {
if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) {
return build_disabled_runtime_story_option(
"npc_help",
"请求援手",
"npc",
None,
"当前 NPC 的一次性援手已经用完了。",
None,
);
}
build_npc_runtime_story_option("npc_help", "请求援手", npc_id, "help")
}
pub(super) fn current_encounter_npc_quest_context(
game_state: &Value,
) -> Result<CurrentEncounterNpcQuestContext, String> {
let encounter = read_object_field(game_state, "currentEncounter")
.ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?;
let kind = read_required_string_field(encounter, "kind")
.ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?;
if kind != "npc" {
return Err("当前不在可结算的 NPC 委托态。".to_string());
}
let npc_name = read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
.unwrap_or_else(|| "当前角色".to_string());
let npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none()
{
return Err("当前 NPC 状态不存在,无法处理委托。".to_string());
}
Ok(CurrentEncounterNpcQuestContext { npc_id, npc_name })
}
pub(super) fn read_pending_quest_offer_context(
current_story: Option<&Value>,
npc_key: &str,
) -> Option<PendingQuestOfferContext> {
let current_story = current_story?;
let npc_chat_state = read_object_field(current_story, "npcChatState")?;
let pending_offer = read_object_field(npc_chat_state, "pendingQuestOffer")?;
let quest = read_object_field(pending_offer, "quest")?.clone();
let quest_id = read_optional_string_field(&quest, "id")?;
let pending_npc_id = read_optional_string_field(npc_chat_state, "npcId");
let issuer_npc_id = read_optional_string_field(&quest, "issuerNpcId");
if pending_npc_id
.as_deref()
.is_some_and(|value| value != npc_key)
{
return None;
}
if issuer_npc_id
.as_deref()
.is_some_and(|value| value != npc_key)
{
return None;
}
Some(PendingQuestOfferContext {
dialogue: read_array_field(current_story, "dialogue")
.into_iter()
.cloned()
.collect(),
turn_count: read_i32_field(npc_chat_state, "turnCount").unwrap_or(0),
custom_input_placeholder: read_optional_string_field(
npc_chat_state,
"customInputPlaceholder",
)
.unwrap_or_else(|| "输入你想对 TA 说的话".to_string()),
quest,
quest_id,
intro_text: read_optional_string_field(pending_offer, "introText"),
})
}
pub(super) fn build_quest_offer_dialogue_text(npc_name: &str, quest: &Value) -> String {
let summary_text = read_optional_string_field(quest, "summary")
.or_else(|| read_optional_string_field(quest, "description"))
.unwrap_or_default();
if summary_text.is_empty() {
return format!(
"{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把眼前这件事正式交给你。"
);
}
format!(
"{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把这件事正式交给你:{summary_text}"
)
}
pub(super) fn append_dialogue_turns(existing: &[Value], additions: Vec<Value>) -> Vec<Value> {
let mut dialogue = existing.to_vec();
dialogue.extend(additions);
dialogue
}
pub(super) fn build_pending_quest_offer_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
vec![
build_npc_runtime_story_option_with_payload(
"npc_chat_quest_offer_view",
"查看任务",
npc_id,
"quest_offer_view",
json!({
"npcChatQuestOfferAction": "view"
}),
),
build_npc_runtime_story_option_with_payload(
"npc_chat_quest_offer_replace",
"更换任务",
npc_id,
"quest_offer_replace",
json!({
"npcChatQuestOfferAction": "replace"
}),
),
build_npc_runtime_story_option_with_payload(
"npc_chat_quest_offer_abandon",
"放弃任务",
npc_id,
"quest_offer_abandon",
json!({
"npcChatQuestOfferAction": "abandon"
}),
),
]
}
pub(super) fn build_post_quest_offer_chat_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
vec![
build_npc_runtime_story_option(
"npc_chat",
"那先继续聊聊你刚才没说完的部分",
npc_id,
"chat",
),
build_npc_runtime_story_option(
"npc_chat",
"除了委托,你对眼前局势还有什么判断",
npc_id,
"chat",
),
build_npc_runtime_story_option(
"npc_chat",
"先把这附近真正危险的地方说清楚",
npc_id,
"chat",
),
]
}
pub(super) fn build_post_quest_accept_chat_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
vec![
build_npc_runtime_story_option("npc_chat", "这件事里你最担心哪一步", npc_id, "chat"),
build_npc_runtime_story_option("npc_chat", "我回来时你最想先知道什么", npc_id, "chat"),
build_npc_runtime_story_option(
"npc_chat",
"除了这份委托,你还想提醒我什么",
npc_id,
"chat",
),
]
}
pub(super) fn build_pending_quest_offer_story(
dialogue: Vec<Value>,
npc_id: &str,
npc_name: &str,
turn_count: i32,
custom_input_placeholder: &str,
pending_quest: Option<Value>,
options: &[RuntimeStoryOptionView],
) -> Value {
json!({
"text": dialogue
.iter()
.filter_map(|entry| read_optional_string_field(entry, "text"))
.collect::<Vec<_>>()
.join("\n"),
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
"displayMode": "dialogue",
"dialogue": dialogue,
"streaming": false,
"npcChatState": {
"npcId": npc_id,
"npcName": npc_name,
"turnCount": turn_count,
"customInputPlaceholder": custom_input_placeholder,
"pendingQuestOffer": pending_quest.map(|quest| json!({ "quest": quest })),
}
})
}
pub(super) fn build_next_pending_quest_offer(
game_state: &Value,
npc_id: &str,
npc_name: &str,
previous_quest_id: Option<&str>,
) -> Value {
let next_id = if previous_quest_id.is_some_and(|id| id == "quest-bridge-offer") {
"quest-bridge-replaced"
} else {
"quest-generated-replaced"
};
let title = if next_id == "quest-bridge-replaced" {
"断桥夜巡"
} else {
"新的临时委托"
};
let scene_id = read_object_field(game_state, "currentScenePreset")
.and_then(|scene| read_optional_string_field(scene, "id"));
json!({
"id": next_id,
"issuerNpcId": npc_id,
"issuerNpcName": npc_name,
"sceneId": scene_id,
"title": title,
"description": format!("{title}的详细说明。"),
"summary": format!("{title}的简要目标。"),
"objective": {
"kind": "talk_to_npc",
"requiredCount": 1
},
"progress": 0,
"status": "active",
"reward": {
"affinityBonus": 6,
"currency": 30,
"items": []
},
"rewardText": "完成后可以领取报酬。",
"steps": [{
"id": format!("{next_id}-step-1"),
"title": "查清线索",
"kind": "talk_to_npc",
"requiredCount": 1,
"progress": 0,
"revealText": "先去断桥口附近把相关线索问清楚。",
"completeText": "关键线索已经问清。"
}],
"activeStepId": format!("{next_id}-step-1")
})
}
pub(super) fn find_active_quest_for_issuer<'a>(
game_state: &'a Value,
issuer_npc_id: &str,
) -> Option<&'a Value> {
read_array_field(game_state, "quests")
.into_iter()
.find(|quest| {
read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id)
&& read_optional_string_field(quest, "status")
.is_some_and(|status| status != "turned_in")
})
}
pub(super) fn push_quest_record(game_state: &mut Value, quest: &Value) {
let root = ensure_json_object(game_state);
let quests = root
.entry("quests".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !quests.is_array() {
*quests = Value::Array(Vec::new());
}
quests
.as_array_mut()
.expect("quests should be array")
.push(quest.clone());
}
pub(super) fn first_quest_reveal_text(quest: &Value) -> Option<String> {
read_array_field(quest, "steps")
.first()
.and_then(|step| read_optional_string_field(step, "revealText"))
}
pub(super) fn build_quest_accept_result_text(quest: &Value) -> String {
let issuer_name =
read_optional_string_field(quest, "issuerNpcName").unwrap_or_else(|| "对方".to_string());
let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string());
format!("你正式接下了 {issuer_name} 的委托「{title}」,接下来可以开始推进任务目标。")
}
pub(super) fn turn_in_quest_record(
game_state: &mut Value,
issuer_npc_id: &str,
quest_id: &str,
) -> Result<Value, String> {
let root = ensure_json_object(game_state);
let quests = root
.entry("quests".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !quests.is_array() {
*quests = Value::Array(Vec::new());
}
let quests = quests.as_array_mut().expect("quests should be array");
let Some(index) = quests.iter().position(|quest| {
read_optional_string_field(quest, "id").as_deref() == Some(quest_id)
&& read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id)
}) else {
return Err("当前没有可交付的委托。".to_string());
};
let mut turned_in = quests[index].clone();
if read_optional_string_field(&turned_in, "status").as_deref() != Some("completed") {
return Err("这份委托还没有达到可交付状态。".to_string());
}
if let Some(object) = turned_in.as_object_mut() {
object.insert("status".to_string(), Value::String("turned_in".to_string()));
object.insert("completionNotified".to_string(), Value::Bool(true));
if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) {
for step in steps.iter_mut() {
let required_count = read_i32_field(step, "requiredCount").unwrap_or(0);
if let Some(step_object) = step.as_object_mut() {
step_object.insert("progress".to_string(), json!(required_count.max(0)));
}
}
}
}
quests[index] = turned_in.clone();
Ok(turned_in)
}
pub(super) fn build_quest_turn_in_result_text(quest: &Value) -> String {
let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string());
let reward_text = read_optional_string_field(quest, "rewardText")
.unwrap_or_else(|| "报酬已经结清。".to_string());
format!("你已经完成并交付了「{title}」。{reward_text}")
}
pub(super) fn apply_quest_turn_in_rewards(game_state: &mut Value, quest: &Value) {
let Some(reward) = read_field(quest, "reward") else {
return;
};
let currency = read_i32_field(reward, "currency").unwrap_or(0).max(0);
if currency > 0 {
add_player_currency(game_state, currency);
}
let reward_items = read_array_field(reward, "items")
.into_iter()
.cloned()
.collect::<Vec<_>>();
if !reward_items.is_empty() {
add_player_inventory_items(game_state, reward_items);
}
let experience = read_i32_field(reward, "experience").unwrap_or(0).max(0);
if experience > 0 {
grant_player_progression_experience(game_state, experience, "quest");
}
}
pub(super) fn build_legacy_current_story(
story_text: &str,
options: &[RuntimeStoryOptionView],
) -> Value {
json!({
"text": story_text,
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
"streaming": false
})
}
pub(super) fn read_story_text(current_story: Option<&Value>) -> Option<String> {
current_story.and_then(|story| read_optional_string_field(story, "text"))
}
pub(super) fn build_fallback_story_text(game_state: &Value) -> String {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
let encounter_name = read_object_field(game_state, "currentEncounter")
.and_then(|encounter| read_optional_string_field(encounter, "npcName"))
.unwrap_or_else(|| "眼前的敌人".to_string());
return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。");
}
if let Some(encounter) = read_object_field(game_state, "currentEncounter")
&& let Some(npc_name) = read_optional_string_field(encounter, "npcName")
{
return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。");
}
"当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string()
}

View File

@@ -1,234 +0,0 @@
use super::*;
pub(super) fn resolve_pending_quest_offer_view_action(
game_state: &mut Value,
current_story: Option<&Value>,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let encounter = current_encounter_npc_quest_context(game_state)?;
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
.ok_or_else(|| "当前没有待处理的委托可查看。".to_string())?;
Ok(StoryResolution {
action_text: resolve_action_text(&format!("查看{}提出的委托", encounter.npc_name), request),
result_text: pending_offer.intro_text.clone().unwrap_or_else(|| {
build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &pending_offer.quest)
}),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![],
battle: None,
toast: None,
})
}
pub(super) fn resolve_pending_quest_offer_replace_action(
game_state: &mut Value,
current_story: Option<&Value>,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let encounter = current_encounter_npc_quest_context(game_state)?;
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
.ok_or_else(|| "当前没有待处理的委托可更换。".to_string())?;
let next_quest = build_next_pending_quest_offer(
game_state,
encounter.npc_id.as_str(),
encounter.npc_name.as_str(),
Some(pending_offer.quest_id.as_str()),
);
let quest_text = build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &next_quest);
let dialogue = append_dialogue_turns(
pending_offer.dialogue.as_slice(),
vec![
json!({
"speaker": "player",
"text": "能不能换一份更适合眼下局势的委托?"
}),
json!({
"speaker": "npc",
"speakerName": encounter.npc_name,
"text": quest_text,
}),
],
);
let options = build_pending_quest_offer_options(encounter.npc_id.as_str());
let saved_current_story = build_pending_quest_offer_story(
dialogue,
encounter.npc_id.as_str(),
encounter.npc_name.as_str(),
pending_offer.turn_count,
pending_offer.custom_input_placeholder.as_str(),
Some(next_quest.clone()),
options.as_slice(),
);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("{}更换委托", encounter.npc_name), request),
result_text: quest_text.clone(),
story_text: Some(quest_text),
presentation_options: Some(options),
saved_current_story: Some(saved_current_story),
patches: vec![],
battle: None,
toast: None,
})
}
pub(super) fn resolve_pending_quest_offer_abandon_action(
game_state: &mut Value,
current_story: Option<&Value>,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let encounter = current_encounter_npc_quest_context(game_state)?;
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
.ok_or_else(|| "当前没有待处理的委托可放弃。".to_string())?;
let npc_reply = format!(
"{}点了点头,没有继续强求,只把这份委托暂时收了回去。",
encounter.npc_name
);
let dialogue = append_dialogue_turns(
pending_offer.dialogue.as_slice(),
vec![
json!({
"speaker": "player",
"text": "这件事我先不接,咱们还是先聊别的。"
}),
json!({
"speaker": "npc",
"speakerName": encounter.npc_name,
"text": npc_reply,
}),
],
);
let options = build_post_quest_offer_chat_options(encounter.npc_id.as_str());
let saved_current_story = build_pending_quest_offer_story(
dialogue,
encounter.npc_id.as_str(),
encounter.npc_name.as_str(),
pending_offer.turn_count,
pending_offer.custom_input_placeholder.as_str(),
None,
options.as_slice(),
);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("暂不接受{}的委托", encounter.npc_name), request),
result_text: npc_reply.clone(),
story_text: Some(npc_reply),
presentation_options: Some(options),
saved_current_story: Some(saved_current_story),
patches: vec![],
battle: None,
toast: None,
})
}
pub(super) fn resolve_pending_quest_accept_action(
game_state: &mut Value,
current_story: Option<&Value>,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let encounter = current_encounter_npc_quest_context(game_state)?;
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
.ok_or_else(|| "当前没有待处理的委托可接下。".to_string())?;
if find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()).is_some() {
return Err("当前角色已经有未结清的委托。".to_string());
}
let quest = pending_offer.quest.clone();
push_quest_record(game_state, &quest);
increment_runtime_stat(game_state, "questsAccepted", 1);
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
let reply_text = first_quest_reveal_text(&quest)
.map(|text| format!("那就拜托你了。{text}"))
.unwrap_or_else(|| {
format!(
"那就拜托你了。{}",
read_optional_string_field(&quest, "summary")
.unwrap_or_else(|| "这份委托的关键要点我已经交给你。".to_string())
)
});
let dialogue = append_dialogue_turns(
pending_offer.dialogue.as_slice(),
vec![
json!({
"speaker": "player",
"text": "这件事我愿意接下,你把关键要点交给我。"
}),
json!({
"speaker": "npc",
"speakerName": encounter.npc_name,
"text": reply_text,
}),
],
);
let options = build_post_quest_accept_chat_options(encounter.npc_id.as_str());
let saved_current_story = build_pending_quest_offer_story(
dialogue,
encounter.npc_id.as_str(),
encounter.npc_name.as_str(),
pending_offer.turn_count,
pending_offer.custom_input_placeholder.as_str(),
None,
options.as_slice(),
);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("接下{}的委托", encounter.npc_name), request),
result_text: build_quest_accept_result_text(&quest),
story_text: Some(
saved_current_story["text"]
.as_str()
.unwrap_or_default()
.to_string(),
),
presentation_options: Some(options),
saved_current_story: Some(saved_current_story),
patches: vec![],
battle: None,
toast: None,
})
}
pub(super) fn resolve_pending_quest_turn_in_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let encounter = current_encounter_npc_quest_context(game_state)?;
let quest_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "questId"))
.or_else(|| request.action.target_id.clone())
.or_else(|| {
find_active_quest_for_issuer(game_state, encounter.npc_id.as_str())
.and_then(|quest| read_optional_string_field(quest, "id"))
})
.ok_or_else(|| "当前没有可交付的委托。".to_string())?;
let turned_in = turn_in_quest_record(game_state, encounter.npc_id.as_str(), quest_id.as_str())?;
let previous_affinity = read_current_npc_affinity(game_state);
let affinity_bonus = read_field(&turned_in, "reward")
.and_then(|reward| read_i32_field(reward, "affinityBonus"))
.unwrap_or(0);
let next_affinity = previous_affinity.saturating_add(affinity_bonus);
write_current_npc_state_i32_field(game_state, "affinity", next_affinity);
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
apply_quest_turn_in_rewards(game_state, &turned_in);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("{}交付委托", encounter.npc_name), request),
result_text: build_quest_turn_in_result_text(&turned_in),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![RuntimeStoryPatch::NpcAffinityChanged {
npc_id: encounter.npc_id,
previous_affinity,
next_affinity,
}],
battle: None,
toast: None,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, sign_access_token, verify_access_token,
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
@@ -24,7 +24,7 @@ use time::OffsetDateTime;
use tracing::{info, warn};
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
use crate::wechat_provider::build_wechat_provider;
const ADMIN_ROLE: &str = "admin";
@@ -270,13 +270,13 @@ impl AppState {
if !snapshot_json.trim().is_empty() {
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?;
info!("?? SpacetimeDB ???????????");
info!("已从 SpacetimeDB 表恢复认证快照");
return Self::new_with_auth_store(config, auth_store);
}
}
}
Err(error) => {
warn!(error = %error, "? SpacetimeDB ????????????????");
warn!(error = %error, " SpacetimeDB 表恢复认证快照失败");
}
}
@@ -286,13 +286,13 @@ impl AppState {
if !snapshot_json.trim().is_empty() {
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?;
info!("?? SpacetimeDB ???????????");
info!("已从 SpacetimeDB 快照记录恢复认证快照");
return Self::new_with_auth_store(config, auth_store);
}
}
}
Err(error) => {
warn!(error = %error, "? SpacetimeDB ?????????????????");
warn!(error = %error, " SpacetimeDB 快照记录恢复认证快照失败");
}
}

View File

@@ -8,8 +8,14 @@ use module_combat::{
BattleMode, BattleStateInput, ResolveCombatActionInput, generate_battle_state_id,
};
use module_npc::{NPC_FIGHT_FUNCTION_ID, NPC_SPAR_FUNCTION_ID, ResolveNpcInteractionInput};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::story::{
CreateStoryBattleRequest, CreateStoryNpcBattleRequest, CreateStoryNpcBattleResponse,
ResolveStoryBattleRequest, ResolveStoryBattleResponse, StoryBattleRewardItemPayload,
StoryBattleRewardItemRequest, StoryBattleStatePayload, StoryBattleStateResponse,
StoryCombatActionPayload, StoryNpcInteractionPayload, StoryNpcStanceProfilePayload,
StoryNpcStatePayload,
};
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
use spacetime_client::{ResolveNpcBattleInteractionInput, SpacetimeClientError};
@@ -18,84 +24,6 @@ use crate::{
request_context::RequestContext, state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateStoryBattleRequest {
pub story_session_id: String,
pub runtime_session_id: String,
#[serde(default)]
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: 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,
#[serde(default)]
pub experience_reward: u32,
#[serde(default)]
pub reward_items: Vec<StoryBattleRewardItemRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveStoryBattleRequest {
pub battle_state_id: String,
pub function_id: String,
pub action_text: String,
pub base_damage: i32,
pub mana_cost: i32,
pub heal: i32,
pub mana_restore: i32,
pub counter_multiplier_basis_points: u32,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateStoryNpcBattleRequest {
pub story_session_id: String,
pub runtime_session_id: String,
pub npc_id: String,
pub npc_name: String,
pub interaction_function_id: String,
#[serde(default)]
pub release_npc_id: Option<String>,
#[serde(default)]
pub battle_state_id: Option<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,
#[serde(default)]
pub experience_reward: u32,
#[serde(default)]
pub reward_items: Vec<StoryBattleRewardItemRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoryBattleRewardItemRequest {
pub item_id: String,
pub category: String,
pub item_name: String,
#[serde(default)]
pub description: Option<String>,
pub quantity: u32,
pub rarity: String,
#[serde(default)]
pub tags: Vec<String>,
pub stackable: bool,
#[serde(default)]
pub stack_key: String,
#[serde(default)]
pub equipment_slot_id: Option<String>,
}
pub async fn create_story_battle(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -123,6 +51,23 @@ pub async fn create_story_battle(
})),
)
})?;
let story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
let result = state
.spacetime_client()
@@ -152,23 +97,38 @@ pub async fn create_story_battle(
Ok(json_success_body(
Some(&request_context),
json!({
"battleState": build_battle_state_payload(&result),
}),
StoryBattleStateResponse {
battle_state: build_battle_state_payload(&result),
},
))
}
pub async fn resolve_story_battle(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ResolveStoryBattleRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let battle_state_id = payload.battle_state_id;
let current_battle = state
.spacetime_client()
.get_battle_state(battle_state_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_battle_owner(
&request_context,
&current_battle.actor_user_id,
&actor_user_id,
)?;
let result = state
.spacetime_client()
.resolve_combat_action(ResolveCombatActionInput {
battle_state_id: payload.battle_state_id,
battle_state_id,
function_id: payload.function_id,
action_text: payload.action_text,
base_damage: payload.base_damage,
@@ -185,14 +145,14 @@ pub async fn resolve_story_battle(
Ok(json_success_body(
Some(&request_context),
json!({
"battleState": build_battle_state_payload(&result.battle_state),
"combat": {
"damageDealt": result.damage_dealt,
"damageTaken": result.damage_taken,
"outcome": result.outcome,
}
}),
ResolveStoryBattleResponse {
battle_state: build_battle_state_payload(&result.battle_state),
combat: StoryCombatActionPayload {
damage_dealt: result.damage_dealt,
damage_taken: result.damage_taken,
outcome: result.outcome,
},
},
))
}
@@ -200,8 +160,9 @@ pub async fn get_story_battle_state(
State(state): State<AppState>,
Path(battle_state_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.get_battle_state(battle_state_id)
@@ -209,12 +170,13 @@ pub async fn get_story_battle_state(
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_battle_owner(&request_context, &result.actor_user_id, &actor_user_id)?;
Ok(json_success_body(
Some(&request_context),
json!({
"battleState": build_battle_state_payload(&result),
}),
StoryBattleStateResponse {
battle_state: build_battle_state_payload(&result),
},
))
}
@@ -247,6 +209,23 @@ pub async fn create_story_npc_battle(
})),
)
})?;
let story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
let result = state
.spacetime_client()
@@ -278,58 +257,69 @@ pub async fn create_story_npc_battle(
Ok(json_success_body(
Some(&request_context),
json!({
"npcInteraction": build_npc_interaction_payload(&result.npc_interaction),
"battleState": build_battle_state_payload(&result.battle_state),
}),
CreateStoryNpcBattleResponse {
npc_interaction: build_npc_interaction_payload(&result.npc_interaction),
battle_state: build_battle_state_payload(&result.battle_state),
},
))
}
fn build_battle_state_payload(record: &spacetime_client::BattleStateRecord) -> Value {
json!({
"battleStateId": record.battle_state_id,
"storySessionId": record.story_session_id,
"runtimeSessionId": record.runtime_session_id,
"actorUserId": record.actor_user_id,
"chapterId": record.chapter_id,
"targetNpcId": record.target_npc_id,
"targetName": record.target_name,
"battleMode": record.battle_mode,
"status": record.status,
"playerHp": record.player_hp,
"playerMaxHp": record.player_max_hp,
"playerMana": record.player_mana,
"playerMaxMana": record.player_max_mana,
"targetHp": record.target_hp,
"targetMaxHp": record.target_max_hp,
"experienceReward": record.experience_reward,
"rewardItems": record.reward_items.iter().map(|item| {
json!({
"itemId": item.item_id,
"category": item.category,
"itemName": item.item_name,
"description": item.description,
"quantity": item.quantity,
"rarity": format_runtime_item_reward_item_rarity(item.rarity),
"tags": item.tags,
"stackable": item.stackable,
"stackKey": item.stack_key,
"equipmentSlotId": item
.equipment_slot_id
.map(format_runtime_item_equipment_slot),
})
}).collect::<Vec<_>>(),
"turnIndex": record.turn_index,
"lastActionFunctionId": record.last_action_function_id,
"lastActionText": record.last_action_text,
"lastResultText": record.last_result_text,
"lastDamageDealt": record.last_damage_dealt,
"lastDamageTaken": record.last_damage_taken,
"lastOutcome": record.last_outcome,
"version": record.version,
"createdAt": record.created_at,
"updatedAt": record.updated_at,
})
fn build_battle_state_payload(
record: &spacetime_client::BattleStateRecord,
) -> StoryBattleStatePayload {
StoryBattleStatePayload {
battle_state_id: record.battle_state_id.clone(),
story_session_id: record.story_session_id.clone(),
runtime_session_id: record.runtime_session_id.clone(),
actor_user_id: record.actor_user_id.clone(),
chapter_id: record.chapter_id.clone(),
target_npc_id: record.target_npc_id.clone(),
target_name: record.target_name.clone(),
battle_mode: record.battle_mode.clone(),
status: record.status.clone(),
player_hp: record.player_hp,
player_max_hp: record.player_max_hp,
player_mana: record.player_mana,
player_max_mana: record.player_max_mana,
target_hp: record.target_hp,
target_max_hp: record.target_max_hp,
experience_reward: record.experience_reward,
reward_items: record
.reward_items
.iter()
.map(build_battle_reward_item_payload)
.collect(),
turn_index: record.turn_index,
last_action_function_id: record.last_action_function_id.clone(),
last_action_text: record.last_action_text.clone(),
last_result_text: record.last_result_text.clone(),
last_damage_dealt: record.last_damage_dealt,
last_damage_taken: record.last_damage_taken,
last_outcome: record.last_outcome.clone(),
version: record.version,
created_at: record.created_at.clone(),
updated_at: record.updated_at.clone(),
}
}
fn build_battle_reward_item_payload(
item: &module_runtime_item::RuntimeItemRewardItemSnapshot,
) -> StoryBattleRewardItemPayload {
StoryBattleRewardItemPayload {
item_id: item.item_id.clone(),
category: item.category.clone(),
item_name: item.item_name.clone(),
description: item.description.clone(),
quantity: item.quantity,
rarity: format_runtime_item_reward_item_rarity(item.rarity).to_string(),
tags: item.tags.clone(),
stackable: item.stackable,
stack_key: item.stack_key.clone(),
equipment_slot_id: item
.equipment_slot_id
.map(format_runtime_item_equipment_slot)
.map(ToOwned::to_owned),
}
}
fn format_runtime_item_reward_item_rarity(
@@ -354,51 +344,53 @@ fn format_runtime_item_equipment_slot(
}
}
fn build_npc_state_payload(record: &spacetime_client::NpcStateRecord) -> Value {
json!({
"npcStateId": record.npc_state_id,
"runtimeSessionId": record.runtime_session_id,
"npcId": record.npc_id,
"npcName": record.npc_name,
"affinity": record.affinity,
"relationStance": record.relation_stance,
"helpUsed": record.help_used,
"chattedCount": record.chatted_count,
"giftsGiven": record.gifts_given,
"recruited": record.recruited,
"tradeStockSignature": record.trade_stock_signature,
"revealedFacts": record.revealed_facts,
"knownAttributeRumors": record.known_attribute_rumors,
"firstMeaningfulContactResolved": record.first_meaningful_contact_resolved,
"seenBackstoryChapterIds": record.seen_backstory_chapter_ids,
"stanceProfile": {
"trust": record.trust,
"warmth": record.warmth,
"ideologicalFit": record.ideological_fit,
"fearOrGuard": record.fear_or_guard,
"loyalty": record.loyalty,
"currentConflictTag": record.current_conflict_tag,
"recentApprovals": record.recent_approvals,
"recentDisapprovals": record.recent_disapprovals,
fn build_npc_state_payload(record: &spacetime_client::NpcStateRecord) -> StoryNpcStatePayload {
StoryNpcStatePayload {
npc_state_id: record.npc_state_id.clone(),
runtime_session_id: record.runtime_session_id.clone(),
npc_id: record.npc_id.clone(),
npc_name: record.npc_name.clone(),
affinity: record.affinity,
relation_stance: record.relation_stance.clone(),
help_used: record.help_used,
chatted_count: record.chatted_count,
gifts_given: record.gifts_given,
recruited: record.recruited,
trade_stock_signature: record.trade_stock_signature.clone(),
revealed_facts: record.revealed_facts.clone(),
known_attribute_rumors: record.known_attribute_rumors.clone(),
first_meaningful_contact_resolved: record.first_meaningful_contact_resolved,
seen_backstory_chapter_ids: record.seen_backstory_chapter_ids.clone(),
stance_profile: StoryNpcStanceProfilePayload {
trust: record.trust,
warmth: record.warmth,
ideological_fit: record.ideological_fit,
fear_or_guard: record.fear_or_guard,
loyalty: record.loyalty,
current_conflict_tag: record.current_conflict_tag.clone(),
recent_approvals: record.recent_approvals.clone(),
recent_disapprovals: record.recent_disapprovals.clone(),
},
"createdAt": record.created_at,
"updatedAt": record.updated_at,
})
created_at: record.created_at.clone(),
updated_at: record.updated_at.clone(),
}
}
fn build_npc_interaction_payload(record: &spacetime_client::NpcInteractionRecord) -> Value {
json!({
"npcState": build_npc_state_payload(&record.npc_state),
"interactionStatus": record.interaction_status,
"actionText": record.action_text,
"resultText": record.result_text,
"storyText": record.story_text,
"battleMode": record.battle_mode,
"encounterClosed": record.encounter_closed,
"affinityChanged": record.affinity_changed,
"previousAffinity": record.previous_affinity,
"nextAffinity": record.next_affinity,
})
fn build_npc_interaction_payload(
record: &spacetime_client::NpcInteractionRecord,
) -> StoryNpcInteractionPayload {
StoryNpcInteractionPayload {
npc_state: build_npc_state_payload(&record.npc_state),
interaction_status: record.interaction_status.clone(),
action_text: record.action_text.clone(),
result_text: record.result_text.clone(),
story_text: record.story_text.clone(),
battle_mode: record.battle_mode.clone(),
encounter_closed: record.encounter_closed,
affinity_changed: record.affinity_changed,
previous_affinity: record.previous_affinity,
next_affinity: record.next_affinity,
}
}
fn parse_battle_mode_strict(raw: &str) -> Option<BattleMode> {
@@ -490,6 +482,73 @@ fn story_battles_error_response(request_context: &RequestContext, error: AppErro
error.into_response_with_context(Some(request_context))
}
fn require_story_session_owner_for_battle(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-session",
"story session 不属于当前用户,不能创建战斗",
)
}
fn require_story_battle_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-battle",
"battle state 不属于当前用户",
)
}
fn require_story_session_runtime_for_battle(
request_context: &RequestContext,
session_runtime_id: &str,
requested_runtime_id: &str,
) -> Result<(), Response> {
if session_runtime_id == requested_runtime_id {
return Ok(());
}
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-session",
"message": "runtimeSessionId 与 story session 不匹配,不能创建战斗",
})),
))
}
fn require_resource_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
provider: &'static str,
message: &'static str,
) -> Result<(), Response> {
if resource_actor_user_id == authenticated_actor_user_id {
return Ok(());
}
// API 层只做登录用户与资源属主的边界检查;战斗结算仍由 module-combat 与 SpacetimeDB 承担。
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": provider,
"message": message,
})),
))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -501,6 +560,8 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -513,7 +574,10 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
use super::{require_story_battle_owner, require_story_session_runtime_for_battle};
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
#[tokio::test]
async fn create_story_battle_requires_authentication() {
@@ -707,6 +771,37 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn resolve_story_battle_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/battles/resolve")
.header("content-type", "application/json")
.body(Body::from(
json!({
"battleStateId": "battle_001",
"functionId": "battle_attack_basic",
"actionText": "普通攻击",
"baseDamage": 10,
"manaCost": 0,
"heal": 0,
"manaRestore": 0,
"counterMultiplierBasisPoints": 10000
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_story_battle_state_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
@@ -794,6 +889,63 @@ mod tests {
);
}
#[test]
fn story_battle_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
let response = require_story_battle_owner(&context, "user_owner", "user_other")
.expect_err("mismatched actor should be forbidden");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn story_battle_owner_guard_accepts_matching_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
require_story_battle_owner(&context, "user_owner", "user_owner")
.expect("matching actor should pass");
}
#[test]
fn story_battle_runtime_guard_rejects_mismatched_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
let response =
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_other")
.expect_err("mismatched runtime session should be bad request");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn story_battle_runtime_guard_accepts_matching_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_owner")
.expect("matching runtime session should pass");
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -4,12 +4,17 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_runtime::RuntimeSnapshotRecord;
use serde_json::{Value, json};
use shared_contracts::story::{
BeginStorySessionRequest, ContinueStoryRequest, StoryEventPayload,
StorySessionMutationResponse, StorySessionPayload, StorySessionStateResponse,
BeginStoryRuntimeSessionRequest, BeginStorySessionRequest, ContinueStoryRequest,
ResolveStoryRuntimeActionRequest, StoryEventPayload, StoryRuntimeMutationResponse,
StoryRuntimeSnapshotPayload, StorySessionMutationResponse, StorySessionPayload,
StorySessionStateResponse,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
@@ -43,28 +48,75 @@ pub async fn begin_story_session(
Ok(json_success_body(
Some(&request_context),
StorySessionMutationResponse {
story_session: StorySessionPayload {
story_session_id: result.session.story_session_id,
runtime_session_id: result.session.runtime_session_id,
actor_user_id: result.session.actor_user_id,
world_profile_id: result.session.world_profile_id,
initial_prompt: result.session.initial_prompt,
opening_summary: result.session.opening_summary,
latest_narrative_text: result.session.latest_narrative_text,
latest_choice_function_id: result.session.latest_choice_function_id,
status: result.session.status,
version: result.session.version,
created_at: result.session.created_at,
updated_at: result.session.updated_at,
},
story_event: StoryEventPayload {
event_id: result.event.event_id,
story_session_id: result.event.story_session_id,
event_kind: result.event.event_kind,
narrative_text: result.event.narrative_text,
choice_function_id: result.event.choice_function_id,
created_at: result.event.created_at,
},
story_session: story_session_payload_from_record(result.session),
story_event: story_event_payload_from_record(result.event),
},
))
}
pub async fn begin_story_runtime_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<BeginStoryRuntimeSessionRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let runtime_session_id = module_runtime_story::generate_runtime_session_id(
actor_user_id.as_str(),
payload.custom_world_profile.as_ref(),
&payload.character,
now_micros,
);
let story_session_id = module_story::generate_story_session_id(now_micros);
let built = module_runtime_story::build_runtime_story_bootstrap(
&payload,
module_runtime_story::RuntimeStoryBootstrapSeed {
runtime_session_id,
story_session_id,
actor_user_id: actor_user_id.clone(),
now_micros,
},
)
.map_err(|message| {
story_sessions_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": message,
})),
)
})?;
let story_result = state
.spacetime_client()
.begin_story_session(
built.story_session_id.clone(),
built.runtime_session_id.clone(),
actor_user_id.clone(),
built.world_profile_id,
built.initial_prompt,
built.opening_summary,
now_micros,
)
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
let persisted =
persist_story_runtime_snapshot(&state, &request_context, actor_user_id, built.snapshot)
.await?;
Ok(json_success_body(
Some(&request_context),
StoryRuntimeMutationResponse {
projection: build_story_runtime_projection_from_persisted(
story_session_payload_from_record(story_result.session),
vec![story_event_payload_from_record(story_result.event)],
&persisted,
persisted.version,
),
},
))
}
@@ -72,14 +124,29 @@ pub async fn begin_story_session(
pub async fn continue_story(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ContinueStoryRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let story_session_id = payload.story_session_id;
let current_state = state
.spacetime_client()
.get_story_session_state(story_session_id.clone())
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&current_state.session.actor_user_id,
&actor_user_id,
)?;
let result = state
.spacetime_client()
.continue_story(
payload.story_session_id,
story_session_id,
module_story::generate_story_event_id(now_micros),
payload.narrative_text,
payload.choice_function_id,
@@ -93,28 +160,105 @@ pub async fn continue_story(
Ok(json_success_body(
Some(&request_context),
StorySessionMutationResponse {
story_session: StorySessionPayload {
story_session_id: result.session.story_session_id,
runtime_session_id: result.session.runtime_session_id,
actor_user_id: result.session.actor_user_id,
world_profile_id: result.session.world_profile_id,
initial_prompt: result.session.initial_prompt,
opening_summary: result.session.opening_summary,
latest_narrative_text: result.session.latest_narrative_text,
latest_choice_function_id: result.session.latest_choice_function_id,
status: result.session.status,
version: result.session.version,
created_at: result.session.created_at,
updated_at: result.session.updated_at,
},
story_event: StoryEventPayload {
event_id: result.event.event_id,
story_session_id: result.event.story_session_id,
event_kind: result.event.event_kind,
narrative_text: result.event.narrative_text,
choice_function_id: result.event.choice_function_id,
created_at: result.event.created_at,
},
story_session: story_session_payload_from_record(result.session),
story_event: story_event_payload_from_record(result.event),
},
))
}
pub async fn resolve_story_runtime_action(
State(state): State<AppState>,
Path(story_session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ResolveStoryRuntimeActionRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let story_session_id = validate_story_runtime_action_path(
&request_context,
story_session_id,
payload.story_session_id.as_str(),
)?;
let story_state = state
.spacetime_client()
.get_story_session_state(story_session_id.clone())
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
let snapshot_record = state
.get_runtime_snapshot_record(actor_user_id.clone())
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?
.ok_or_else(|| {
story_sessions_error_response(
&request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "story-runtime",
"message": "当前用户缺少 runtime snapshot",
})),
)
})?;
let snapshot = story_runtime_snapshot_payload_from_record(&snapshot_record);
validate_story_runtime_client_version(
&request_context,
payload.client_version,
&snapshot.game_state,
)?;
let resolved = module_runtime_story::resolve_story_runtime_action(
module_runtime_story::StoryRuntimeActionResolveInput {
story_session_id: story_state.session.story_session_id.clone(),
runtime_session_id: story_state.session.runtime_session_id.clone(),
snapshot,
request: payload,
},
)
.map_err(|message| {
story_sessions_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": message,
})),
)
})?;
let story_result = state
.spacetime_client()
.continue_story(
story_state.session.story_session_id,
module_story::generate_story_event_id(now_micros),
resolved.narrative_text,
resolved.choice_function_id,
now_micros,
)
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
let persisted =
persist_story_runtime_snapshot(&state, &request_context, actor_user_id, resolved.snapshot)
.await?;
Ok(json_success_body(
Some(&request_context),
StoryRuntimeMutationResponse {
projection: build_story_runtime_projection_from_persisted(
story_session_payload_from_record(story_result.session),
vec![story_event_payload_from_record(story_result.event)],
&persisted,
resolved.server_version.max(persisted.version),
),
},
))
}
@@ -123,8 +267,9 @@ pub async fn get_story_session_state(
State(state): State<AppState>,
Path(story_session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let result = state
.spacetime_client()
.get_story_session_state(story_session_id)
@@ -132,40 +277,287 @@ pub async fn get_story_session_state(
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&result.session.actor_user_id,
&actor_user_id,
)?;
Ok(json_success_body(
Some(&request_context),
StorySessionStateResponse {
story_session: StorySessionPayload {
story_session_id: result.session.story_session_id,
runtime_session_id: result.session.runtime_session_id,
actor_user_id: result.session.actor_user_id,
world_profile_id: result.session.world_profile_id,
initial_prompt: result.session.initial_prompt,
opening_summary: result.session.opening_summary,
latest_narrative_text: result.session.latest_narrative_text,
latest_choice_function_id: result.session.latest_choice_function_id,
status: result.session.status,
version: result.session.version,
created_at: result.session.created_at,
updated_at: result.session.updated_at,
},
story_session: story_session_payload_from_record(result.session),
story_events: result
.events
.into_iter()
.map(|event| StoryEventPayload {
event_id: event.event_id,
story_session_id: event.story_session_id,
event_kind: event.event_kind,
narrative_text: event.narrative_text,
choice_function_id: event.choice_function_id,
created_at: event.created_at,
})
.map(story_event_payload_from_record)
.collect(),
},
))
}
async fn persist_story_runtime_snapshot(
state: &AppState,
request_context: &RequestContext,
user_id: String,
snapshot: StoryRuntimeSnapshotPayload,
) -> Result<RuntimeSnapshotRecord, Response> {
validate_story_runtime_snapshot_payload(&snapshot).map_err(|message| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": message,
})),
)
})?;
let now = OffsetDateTime::now_utc();
let saved_at = snapshot
.saved_at
.as_deref()
.and_then(module_runtime_story::normalize_required_string)
.map(|value| parse_rfc3339(value.as_str()))
.transpose()
.map_err(|error| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"field": "snapshot.savedAt",
"message": format!("savedAt 非法: {error}"),
})),
)
})?
.unwrap_or(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let updated_at_micros = offset_datetime_to_unix_micros(now);
let game_state = canonicalize_story_runtime_game_state(snapshot.game_state);
state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
snapshot.bottom_tab,
game_state,
snapshot.current_story,
updated_at_micros,
)
.await
.map_err(|error| {
story_sessions_error_response(request_context, map_story_session_client_error(error))
})
}
fn canonicalize_story_runtime_game_state(mut game_state: Value) -> Value {
if let Some(root) = game_state.as_object_mut() {
// 中文注释NPC 交易 / 赠礼 view 是展示层投影,持久快照只保存可复算真相。
root.remove("runtimeNpcInteraction");
}
game_state
}
fn validate_story_runtime_snapshot_payload(
snapshot: &StoryRuntimeSnapshotPayload,
) -> Result<(), String> {
if module_runtime_story::normalize_required_string(snapshot.bottom_tab.as_str()).is_none() {
return Err("snapshot.bottomTab 不能为空".to_string());
}
if !snapshot.game_state.is_object() {
return Err("snapshot.gameState 必须是 JSON object".to_string());
}
if snapshot
.current_story
.as_ref()
.is_some_and(|current_story| !current_story.is_object())
{
return Err("snapshot.currentStory 必须是 JSON object 或 null".to_string());
}
Ok(())
}
fn story_runtime_snapshot_payload_from_record(
record: &RuntimeSnapshotRecord,
) -> StoryRuntimeSnapshotPayload {
let mut game_state = record.game_state.clone();
module_runtime_story::write_runtime_npc_interaction_view(&mut game_state);
StoryRuntimeSnapshotPayload {
saved_at: Some(record.saved_at.clone()),
bottom_tab: record.bottom_tab.clone(),
game_state,
current_story: record.current_story.clone(),
}
}
fn build_story_runtime_projection_from_persisted(
story_session: StorySessionPayload,
story_events: Vec<StoryEventPayload>,
record: &RuntimeSnapshotRecord,
server_version: u32,
) -> shared_contracts::story::StoryRuntimeProjectionResponse {
let snapshot = story_runtime_snapshot_payload_from_record(record);
let current_story = snapshot.current_story.as_ref();
let options =
module_runtime_story::build_runtime_story_options(current_story, &snapshot.game_state);
let current_narrative_text = read_story_runtime_current_text(current_story)
.or_else(|| Some(story_session.latest_narrative_text.clone()));
let action_result_text = read_story_runtime_current_field(current_story, "resultText");
let toast = read_story_runtime_current_field(current_story, "toast");
module_runtime_story::build_story_runtime_projection(
module_runtime_story::StoryRuntimeProjectionSource {
story_session,
story_events,
game_state: snapshot.game_state,
options,
server_version,
current_narrative_text,
action_result_text,
toast,
},
)
}
fn read_story_runtime_current_text(current_story: Option<&Value>) -> Option<String> {
read_story_runtime_current_field(current_story, "text")
.or_else(|| read_story_runtime_current_field(current_story, "storyText"))
}
fn read_story_runtime_current_field(current_story: Option<&Value>, field: &str) -> Option<String> {
current_story?
.as_object()?
.get(field)?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn validate_story_runtime_action_path(
request_context: &RequestContext,
path_story_session_id: String,
body_story_session_id: &str,
) -> Result<String, Response> {
let path_story_session_id =
module_runtime_story::normalize_required_string(path_story_session_id.as_str())
.ok_or_else(|| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": "storySessionId 不能为空",
})),
)
})?;
let body_story_session_id = module_runtime_story::normalize_required_string(
body_story_session_id,
)
.ok_or_else(|| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": "request.storySessionId 不能为空",
})),
)
})?;
if path_story_session_id != body_story_session_id {
return Err(story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "story-runtime",
"message": "path storySessionId 与 request.storySessionId 不一致",
"pathStorySessionId": path_story_session_id,
"requestStorySessionId": body_story_session_id,
})),
));
}
Ok(path_story_session_id)
}
fn validate_story_runtime_client_version(
request_context: &RequestContext,
client_version: Option<u32>,
game_state: &Value,
) -> Result<(), Response> {
let Some(client_version) = client_version else {
return Ok(());
};
let Some(server_version) =
module_runtime_story::read_u32_field(game_state, "runtimeActionVersion")
else {
return Ok(());
};
if client_version == server_version {
return Ok(());
}
Err(story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "story-runtime",
"message": "运行时版本已变化,请先同步最新快照后再提交动作",
"clientVersion": client_version,
"serverVersion": server_version,
})),
))
}
fn story_session_payload_from_record(
record: module_story::StorySessionRecord,
) -> StorySessionPayload {
StorySessionPayload {
story_session_id: record.story_session_id,
runtime_session_id: record.runtime_session_id,
actor_user_id: record.actor_user_id,
world_profile_id: record.world_profile_id,
initial_prompt: record.initial_prompt,
opening_summary: record.opening_summary,
latest_narrative_text: record.latest_narrative_text,
latest_choice_function_id: record.latest_choice_function_id,
status: record.status,
version: record.version,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn story_event_payload_from_record(record: module_story::StoryEventRecord) -> StoryEventPayload {
StoryEventPayload {
event_id: record.event_id,
story_session_id: record.story_session_id,
event_kind: record.event_kind,
narrative_text: record.narrative_text,
choice_function_id: record.choice_function_id,
created_at: record.created_at,
}
}
pub async fn get_story_runtime_projection(
State(state): State<AppState>,
Path(story_session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let actor_user_id = authenticated.claims().user_id().to_string();
let source = state
.spacetime_client()
.get_story_runtime_projection_source(story_session_id, actor_user_id)
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
module_runtime_story::build_story_runtime_projection(source),
))
}
fn map_story_session_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
@@ -183,6 +575,25 @@ fn story_sessions_error_response(request_context: &RequestContext, error: AppErr
error.into_response_with_context(Some(request_context))
}
fn require_story_session_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
if resource_actor_user_id == authenticated_actor_user_id {
return Ok(());
}
// 这里只做 HTTP 鉴权边界判断story session 的推进规则仍由 SpacetimeDB 领域层处理。
Err(story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "story-session",
"message": "story session 不属于当前用户",
})),
))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -194,6 +605,8 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -206,7 +619,10 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
use super::require_story_session_owner;
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
#[tokio::test]
async fn begin_story_session_requires_authentication() {
@@ -281,6 +697,182 @@ mod tests {
);
}
#[tokio::test]
async fn continue_story_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/continue")
.header("content-type", "application/json")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"narrativeText": "你看见篝火边有人招手。",
"choiceFunctionId": "talk_to_npc"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn begin_story_runtime_session_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/runtime")
.header("content-type", "application/json")
.body(Body::from(
json!({
"worldType": "CUSTOM",
"customWorldProfile": { "id": "profile_001" },
"character": { "id": "hero_001", "name": "沈砺" },
"runtimeMode": "play",
"disablePersistence": false
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn begin_story_runtime_session_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/runtime")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"worldType": "CUSTOM",
"customWorldProfile": { "id": "profile_001" },
"character": { "id": "hero_001", "name": "沈砺" },
"runtimeMode": "play",
"disablePersistence": false
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn resolve_story_runtime_action_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/storysess_001/actions/resolve")
.header("content-type", "application/json")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"clientVersion": 1,
"functionId": "idle_observe_signs",
"actionText": "观察周围迹象",
"payload": { "optionText": "观察周围迹象" }
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn resolve_story_runtime_action_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/storysess_001/actions/resolve")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"clientVersion": 1,
"functionId": "idle_observe_signs",
"actionText": "观察周围迹象",
"payload": { "optionText": "观察周围迹象" }
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn continue_story_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
@@ -381,6 +973,89 @@ mod tests {
);
}
#[tokio::test]
async fn get_story_runtime_projection_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/story/sessions/storysess_001/runtime-projection")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_story_runtime_projection_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/story/sessions/storysess_001/runtime-projection")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[test]
fn story_session_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(
"req_story_owner_guard".to_string(),
"GET /api/story/sessions/storysess_001/state".to_string(),
Duration::ZERO,
true,
);
let response = require_story_session_owner(&context, "user_owner", "user_other")
.expect_err("mismatched actor should be forbidden");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn story_session_owner_guard_accepts_matching_actor() {
let context = RequestContext::new(
"req_story_owner_guard".to_string(),
"GET /api/story/sessions/storysess_001/state".to_string(),
Duration::ZERO,
true,
);
require_story_session_owner(&context, "user_owner", "user_owner")
.expect("matching actor should pass");
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state

View File

@@ -1,13 +1,13 @@
use axum::{
Json,
extract::{Extension, Query, State},
http::{HeaderMap, HeaderValue, StatusCode},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use module_auth::{
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
WechatAuthScene,
};
use platform_auth::WechatAuthScene;
use shared_contracts::auth::{
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery,
WechatStartResponse,
@@ -23,6 +23,7 @@ use crate::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_wechat_provider_error},
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
@@ -50,17 +51,20 @@ pub async fn start_wechat_login(
query.redirect_path.as_deref(),
&state.config.wechat_redirect_path,
),
scene: scene.clone(),
scene: map_wechat_scene_to_domain(&scene),
request_user_agent: user_agent.clone(),
},
OffsetDateTime::now_utc(),
)
.map_err(map_wechat_auth_error)?;
let authorization_url = state.wechat_provider().build_authorization_url(
&resolve_wechat_callback_url(&state, &headers)?,
&state_record.state.state_token,
&scene,
)?;
let authorization_url = state
.wechat_provider()
.build_authorization_url(
&resolve_wechat_callback_url(&state, &headers)?,
&state_record.state.state_token,
&scene,
)
.map_err(map_wechat_provider_error)?;
Ok(json_success_body(
Some(&request_context),
@@ -121,10 +125,12 @@ pub async fn handle_wechat_callback(
{
Ok(profile) => state
.wechat_auth_service()
.resolve_login(module_auth::ResolveWechatLoginInput { profile })
.resolve_login(module_auth::ResolveWechatLoginInput {
profile: map_wechat_profile_to_domain(profile),
})
.await
.map_err(map_wechat_auth_error),
Err(error) => Err(error),
Err(error) => Err(map_wechat_provider_error(error)),
};
match result {
@@ -247,6 +253,24 @@ fn resolve_wechat_scene(user_agent: Option<&str>) -> Result<WechatAuthScene, App
Ok(WechatAuthScene::Desktop)
}
fn map_wechat_scene_to_domain(scene: &WechatAuthScene) -> module_auth::WechatAuthScene {
match scene {
WechatAuthScene::Desktop => module_auth::WechatAuthScene::Desktop,
WechatAuthScene::WechatInApp => module_auth::WechatAuthScene::WechatInApp,
}
}
fn map_wechat_profile_to_domain(
profile: platform_auth::WechatIdentityProfile,
) -> module_auth::WechatIdentityProfile {
module_auth::WechatIdentityProfile {
provider_uid: profile.provider_uid,
provider_union_id: profile.provider_union_id,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
}
}
fn normalize_redirect_path(raw_value: Option<&str>, fallback: &str) -> String {
let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else {
return fallback.to_string();
@@ -348,10 +372,7 @@ fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(serde_json::json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
attach_retry_after(app_error, retry_after_seconds)
}
module_auth::PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())

View File

@@ -1,280 +1,40 @@
use module_auth::{WechatAuthScene, WechatIdentityProfile};
use reqwest::Client;
use serde::Deserialize;
use tracing::warn;
use url::Url;
use platform_auth::{
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
DEFAULT_WECHAT_USER_INFO_ENDPOINT, WechatAuthConfig, WechatProvider,
};
use crate::{config::AppConfig, http_error::AppError};
use axum::http::StatusCode;
#[derive(Clone, Debug)]
pub enum WechatProvider {
Disabled,
Mock(MockWechatProvider),
Real(RealWechatProvider),
}
#[derive(Clone, Debug)]
pub struct MockWechatProvider {
mock_user_id: String,
mock_union_id: Option<String>,
mock_display_name: String,
mock_avatar_url: Option<String>,
}
#[derive(Clone, Debug)]
pub struct RealWechatProvider {
client: Client,
app_id: String,
app_secret: String,
authorize_endpoint: String,
access_token_endpoint: String,
user_info_endpoint: String,
}
#[derive(Debug, Deserialize)]
struct WechatAccessTokenResponse {
access_token: Option<String>,
openid: Option<String>,
unionid: Option<String>,
errmsg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WechatUserInfoResponse {
openid: Option<String>,
unionid: Option<String>,
nickname: Option<String>,
headimgurl: Option<String>,
errmsg: Option<String>,
}
use crate::config::AppConfig;
pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider {
if !config.wechat_auth_enabled {
return WechatProvider::Disabled;
}
if config
.wechat_auth_provider
.trim()
.eq_ignore_ascii_case("mock")
{
return WechatProvider::Mock(MockWechatProvider {
mock_user_id: config.wechat_mock_user_id.clone(),
mock_union_id: config.wechat_mock_union_id.clone(),
mock_display_name: config.wechat_mock_display_name.clone(),
mock_avatar_url: config.wechat_mock_avatar_url.clone(),
});
}
let Some(app_id) = config.wechat_app_id.clone() else {
return WechatProvider::Disabled;
};
let Some(app_secret) = config.wechat_app_secret.clone() else {
return WechatProvider::Disabled;
};
WechatProvider::Real(RealWechatProvider {
client: Client::new(),
app_id,
app_secret,
authorize_endpoint: config.wechat_authorize_endpoint.clone(),
access_token_endpoint: config.wechat_access_token_endpoint.clone(),
user_info_endpoint: config.wechat_user_info_endpoint.clone(),
})
WechatProvider::new(WechatAuthConfig::new(
config.wechat_auth_enabled,
config.wechat_auth_provider.clone(),
config.wechat_app_id.clone(),
config.wechat_app_secret.clone(),
normalize_wechat_endpoint(
&config.wechat_authorize_endpoint,
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
),
normalize_wechat_endpoint(
&config.wechat_access_token_endpoint,
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT,
),
normalize_wechat_endpoint(
&config.wechat_user_info_endpoint,
DEFAULT_WECHAT_USER_INFO_ENDPOINT,
),
config.wechat_mock_user_id.clone(),
config.wechat_mock_union_id.clone(),
config.wechat_mock_display_name.clone(),
config.wechat_mock_avatar_url.clone(),
))
}
impl WechatProvider {
pub fn build_authorization_url(
&self,
callback_url: &str,
state: &str,
scene: &WechatAuthScene,
) -> Result<String, AppError> {
match self {
Self::Disabled => {
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"))
}
Self::Mock(_) => {
let mut callback = Url::parse(callback_url).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信回调地址非法:{error}"))
})?;
callback
.query_pairs_mut()
.append_pair("mock_code", "wx-mock-code")
.append_pair("state", state);
Ok(callback.to_string())
}
Self::Real(provider) => provider.build_authorization_url(callback_url, state, scene),
}
}
pub async fn resolve_callback_profile(
&self,
code: Option<&str>,
mock_code: Option<&str>,
) -> Result<WechatIdentityProfile, AppError> {
match self {
Self::Disabled => {
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"))
}
Self::Mock(provider) => Ok(provider.resolve_callback_profile(mock_code)),
Self::Real(provider) => provider.resolve_callback_profile(code).await,
}
}
}
impl MockWechatProvider {
fn resolve_callback_profile(&self, mock_code: Option<&str>) -> WechatIdentityProfile {
let provider_uid = mock_code
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(self.mock_user_id.as_str())
.to_string();
WechatIdentityProfile {
provider_uid,
provider_union_id: self.mock_union_id.clone(),
display_name: Some(self.mock_display_name.clone()),
avatar_url: self.mock_avatar_url.clone(),
}
}
}
impl RealWechatProvider {
fn build_authorization_url(
&self,
callback_url: &str,
state: &str,
scene: &WechatAuthScene,
) -> Result<String, AppError> {
let mut url = Url::parse(match scene {
WechatAuthScene::Desktop => &self.authorize_endpoint,
WechatAuthScene::WechatInApp => "https://open.weixin.qq.com/connect/oauth2/authorize",
})
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信授权地址非法:{error}"))
})?;
url.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("redirect_uri", callback_url)
.append_pair("response_type", "code")
.append_pair(
"scope",
match scene {
WechatAuthScene::Desktop => "snsapi_login",
WechatAuthScene::WechatInApp => "snsapi_userinfo",
},
)
.append_pair("state", state);
Ok(format!("{url}#wechat_redirect"))
}
async fn resolve_callback_profile(
&self,
code: Option<&str>,
) -> Result<WechatIdentityProfile, AppError> {
let code = code
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少微信授权 code")
})?;
let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信 access_token 地址非法:{error}"))
})?;
access_token_url
.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("secret", &self.app_secret)
.append_pair("code", code)
.append_pair("grant_type", "authorization_code");
let access_token_payload = self
.client
.get(access_token_url.as_str())
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信 access_token 请求失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败access_token 请求失败")
})?
.json::<WechatAccessTokenResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信 access_token 响应解析失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败access_token 响应非法")
})?;
let access_token = access_token_payload
.access_token
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!(
"微信登录失败:{}",
access_token_payload
.errmsg
.unwrap_or_else(|| "缺少 access_token".to_string())
))
})?;
let openid = access_token_payload
.openid
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:缺少 openid")
})?;
let mut user_info_url = Url::parse(&self.user_info_endpoint).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信用户信息地址非法:{error}"))
})?;
user_info_url
.query_pairs_mut()
.append_pair("access_token", &access_token)
.append_pair("openid", &openid)
.append_pair("lang", "zh_CN");
let user_info_payload = self
.client
.get(user_info_url.as_str())
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信用户信息请求失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:用户信息请求失败")
})?
.json::<WechatUserInfoResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信用户信息响应解析失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:用户信息响应非法")
})?;
let provider_uid = user_info_payload
.openid
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!(
"微信登录失败:{}",
user_info_payload
.errmsg
.unwrap_or_else(|| "缺少 openid".to_string())
))
})?;
Ok(WechatIdentityProfile {
provider_uid,
provider_union_id: user_info_payload.unionid.or(access_token_payload.unionid),
display_name: user_info_payload.nickname,
avatar_url: user_info_payload.headimgurl,
})
fn normalize_wechat_endpoint(value: &str, fallback: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}

View File

@@ -16,17 +16,31 @@
当前提交已完成:
1. `module-ai``Cargo.toml`
2. 首版核心类型
2. DDD 分层文件与内部子模块
- `src/domain.rs`
- `src/domain/types.rs`
- `src/domain/stages.rs`
- `src/domain/ids.rs`
- `src/commands.rs`
- `src/commands/inputs.rs`
- `src/commands/validation.rs`
- `src/application.rs`
- `src/application/service.rs`
- `src/application/store.rs`
- `src/application/result.rs`
- `src/events.rs`
- `src/errors.rs`
3. 首版核心类型:
- `AiTaskKind`
- `AiTaskStatus`
- `AiTaskStageKind`
- `AiTaskSnapshot`
- `AiTextChunkSnapshot`
- `AiResultReferenceSnapshot`
3. 默认阶段蓝图与 ID 前缀
4. `InMemoryAiTaskStore`
5. `AiTaskService`
6. 面向 `SpacetimeDB` 的输入类型与 ID helper
4. 默认阶段蓝图与 ID 前缀
5. `InMemoryAiTaskStore`
6. `AiTaskService`
7. 面向 `SpacetimeDB` 的输入类型与 ID helper
- `AiTaskStartInput`
- `AiTaskStageStartInput`
- `AiTextChunkAppendInput`
@@ -34,13 +48,15 @@
- `AiTaskFinishInput`
- `AiTaskCancelInput`
- `AiTaskFailureInput`
7. 基础单元测试
8. `src/tests.rs` 中的基础单元测试
首版详细设计见:
1. [../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md)
2. [../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md)
3. [../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md)
4. [../../../docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_AI_TASK_DOMAIN_REFACTOR_2026-04-29.md)
5. [../../../docs/technical/SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_AI_INTERNAL_MODULE_SPLIT_2026-04-29.md)
## 3. 当前仍未进入的范围

View File

@@ -0,0 +1,19 @@
mod result;
mod service;
mod store;
pub use result::AiTaskProcedureResult;
pub use service::AiTaskService;
pub use store::InMemoryAiTaskStore;
use crate::{AiTaskFieldError, AiTaskServiceError, AiTaskStatus};
fn ensure_task_is_not_terminal(status: AiTaskStatus) -> Result<(), AiTaskServiceError> {
if status.is_terminal() {
Err(AiTaskServiceError::Field(
AiTaskFieldError::InvalidTaskState,
))
} else {
Ok(())
}
}

View File

@@ -0,0 +1,14 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::{AiTaskSnapshot, AiTextChunkSnapshot};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskProcedureResult {
pub ok: bool,
pub task: Option<AiTaskSnapshot>,
pub text_chunk: Option<AiTextChunkSnapshot>,
pub error_message: Option<String>,
}

View File

@@ -0,0 +1,250 @@
use shared_kernel::normalize_required_string;
use crate::commands::validate_task_create_input;
use crate::{
AiResultReferenceKind, AiResultReferenceSnapshot, AiStageCompletionInput, AiTaskCreateInput,
AiTaskFieldError, AiTaskServiceError, AiTaskSnapshot, AiTaskStageKind, AiTaskStageSnapshot,
AiTaskStageStatus, AiTaskStatus, AiTextChunkSnapshot, INITIAL_AI_TASK_VERSION,
generate_ai_result_ref_id, generate_ai_text_chunk_id, normalize_optional_text,
normalize_string_list,
};
use super::{InMemoryAiTaskStore, ensure_task_is_not_terminal};
#[derive(Clone, Debug)]
pub struct AiTaskService {
store: InMemoryAiTaskStore,
}
impl AiTaskService {
pub fn new(store: InMemoryAiTaskStore) -> Self {
Self { store }
}
pub fn create_task(
&self,
input: AiTaskCreateInput,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
validate_task_create_input(&input).map_err(AiTaskServiceError::Field)?;
let snapshot = AiTaskSnapshot {
task_id: input.task_id.clone(),
task_kind: input.task_kind,
owner_user_id: normalize_required_string(input.owner_user_id).unwrap_or_default(),
request_label: normalize_required_string(input.request_label).unwrap_or_default(),
source_module: normalize_required_string(input.source_module).unwrap_or_default(),
source_entity_id: normalize_optional_text(input.source_entity_id),
request_payload_json: normalize_optional_text(input.request_payload_json),
status: AiTaskStatus::Pending,
failure_message: None,
stages: input
.stages
.into_iter()
.map(|stage| AiTaskStageSnapshot {
stage_kind: stage.stage_kind,
label: normalize_required_string(stage.label).unwrap_or_default(),
detail: normalize_required_string(stage.detail).unwrap_or_default(),
order: stage.order,
status: AiTaskStageStatus::Pending,
text_output: None,
structured_payload_json: None,
warning_messages: Vec::new(),
started_at_micros: None,
completed_at_micros: None,
})
.collect(),
result_references: Vec::new(),
latest_text_output: None,
latest_structured_payload_json: None,
version: INITIAL_AI_TASK_VERSION,
created_at_micros: input.created_at_micros,
started_at_micros: None,
completed_at_micros: None,
updated_at_micros: input.created_at_micros,
};
self.store.insert_task(snapshot)
}
pub fn start_task(
&self,
task_id: &str,
started_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Running;
task.started_at_micros.get_or_insert(started_at_micros);
task.updated_at_micros = started_at_micros;
task.version += 1;
Ok(())
})
}
pub fn start_stage(
&self,
task_id: &str,
stage_kind: AiTaskStageKind,
started_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Running;
task.started_at_micros.get_or_insert(started_at_micros);
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.status = AiTaskStageStatus::Running;
stage.started_at_micros.get_or_insert(started_at_micros);
task.updated_at_micros = started_at_micros;
task.version += 1;
Ok(())
})
}
pub fn append_text_chunk(
&self,
task_id: &str,
stage_kind: AiTaskStageKind,
sequence: u32,
delta_text: String,
created_at_micros: i64,
) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), AiTaskServiceError> {
if delta_text.trim().is_empty() {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingChunkText,
));
}
if sequence == 0 {
return Err(AiTaskServiceError::Field(AiTaskFieldError::InvalidSequence));
}
let chunk = AiTextChunkSnapshot {
chunk_id: generate_ai_text_chunk_id(created_at_micros, sequence),
task_id: normalize_required_string(task_id).unwrap_or_default(),
stage_kind,
sequence,
delta_text: normalize_required_string(delta_text).unwrap_or_default(),
created_at_micros,
};
let task = self.store.append_text_chunk(chunk.clone())?;
Ok((task, chunk))
}
pub fn complete_stage(
&self,
input: AiStageCompletionInput,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(&input.task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == input.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.status = AiTaskStageStatus::Completed;
stage.completed_at_micros = Some(input.completed_at_micros);
stage.text_output = normalize_optional_text(input.text_output.clone());
stage.structured_payload_json =
normalize_optional_text(input.structured_payload_json.clone());
stage.warning_messages = normalize_string_list(input.warning_messages.clone());
task.latest_text_output = stage.text_output.clone();
task.latest_structured_payload_json = stage.structured_payload_json.clone();
task.updated_at_micros = input.completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn attach_result_reference(
&self,
task_id: &str,
reference_kind: AiResultReferenceKind,
reference_id: String,
label: Option<String>,
created_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let Some(reference_id) = normalize_required_string(reference_id) else {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingReferenceId,
));
};
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.result_references.push(AiResultReferenceSnapshot {
result_ref_id: generate_ai_result_ref_id(created_at_micros),
task_id: task.task_id.clone(),
reference_kind,
reference_id: reference_id.clone(),
label: normalize_optional_text(label.clone()),
created_at_micros,
});
task.updated_at_micros = created_at_micros;
task.version += 1;
Ok(())
})
}
pub fn complete_task(
&self,
task_id: &str,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Completed;
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn fail_task(
&self,
task_id: &str,
failure_message: String,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let Some(failure_message) = normalize_required_string(failure_message) else {
return Err(AiTaskServiceError::Field(
AiTaskFieldError::MissingFailureMessage,
));
};
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Failed;
task.failure_message = Some(failure_message.clone());
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn cancel_task(
&self,
task_id: &str,
completed_at_micros: i64,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.update_task(task_id, |task| {
ensure_task_is_not_terminal(task.status)?;
task.status = AiTaskStatus::Cancelled;
task.completed_at_micros = Some(completed_at_micros);
task.updated_at_micros = completed_at_micros;
task.version += 1;
Ok(())
})
}
pub fn get_task(&self, task_id: &str) -> Result<AiTaskSnapshot, AiTaskServiceError> {
self.store.get_task(task_id)
}
}

View File

@@ -0,0 +1,138 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use crate::{
AiTaskServiceError, AiTaskSnapshot, AiTaskStageStatus, AiTaskStatus, AiTextChunkSnapshot,
};
use super::ensure_task_is_not_terminal;
#[derive(Clone, Debug, Default)]
pub struct InMemoryAiTaskStore {
inner: Arc<Mutex<InMemoryAiTaskStoreState>>,
}
#[derive(Debug, Default)]
struct InMemoryAiTaskStoreState {
tasks: HashMap<String, AiTaskSnapshot>,
text_chunks: HashMap<String, Vec<AiTextChunkSnapshot>>,
}
impl InMemoryAiTaskStore {
pub(super) fn insert_task(
&self,
task: AiTaskSnapshot,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
if state.tasks.contains_key(&task.task_id) {
return Err(AiTaskServiceError::TaskAlreadyExists);
}
state.text_chunks.insert(task.task_id.clone(), Vec::new());
state.tasks.insert(task.task_id.clone(), task.clone());
Ok(task)
}
pub(super) fn update_task<F>(
&self,
task_id: &str,
mut apply: F,
) -> Result<AiTaskSnapshot, AiTaskServiceError>
where
F: FnMut(&mut AiTaskSnapshot) -> Result<(), AiTaskServiceError>,
{
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
let task = state
.tasks
.get_mut(task_id.trim())
.ok_or(AiTaskServiceError::TaskNotFound)?;
apply(task)?;
Ok(task.clone())
}
pub(super) fn append_text_chunk(
&self,
chunk: AiTextChunkSnapshot,
) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let mut state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
{
let task = state
.tasks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
ensure_task_is_not_terminal(task.status)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == chunk.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
if stage.status == AiTaskStageStatus::Pending {
stage.status = AiTaskStageStatus::Running;
stage.started_at_micros = Some(chunk.created_at_micros);
}
task.status = AiTaskStatus::Running;
task.started_at_micros
.get_or_insert(chunk.created_at_micros);
}
let chunks = state
.text_chunks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
chunks.push(chunk.clone());
chunks.sort_by_key(|value| value.sequence);
let aggregated_text = chunks
.iter()
.filter(|value| value.stage_kind == chunk.stage_kind)
.map(|value| value.delta_text.as_str())
.collect::<Vec<_>>()
.join("");
let normalized_output = if aggregated_text.trim().is_empty() {
None
} else {
Some(aggregated_text)
};
let task = state
.tasks
.get_mut(&chunk.task_id)
.ok_or(AiTaskServiceError::TaskNotFound)?;
let stage = task
.stages
.iter_mut()
.find(|stage| stage.stage_kind == chunk.stage_kind)
.ok_or(AiTaskServiceError::StageNotFound)?;
stage.text_output = normalized_output.clone();
task.latest_text_output = normalized_output;
task.updated_at_micros = chunk.created_at_micros;
task.version += 1;
Ok(task.clone())
}
pub(super) fn get_task(&self, task_id: &str) -> Result<AiTaskSnapshot, AiTaskServiceError> {
let state = self
.inner
.lock()
.map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?;
state
.tasks
.get(task_id.trim())
.cloned()
.ok_or(AiTaskServiceError::TaskNotFound)
}
}

View File

@@ -0,0 +1,9 @@
mod inputs;
mod validation;
pub use inputs::{
AiResultReferenceInput, AiStageCompletionInput, AiTaskCancelInput, AiTaskCreateInput,
AiTaskFailureInput, AiTaskFinishInput, AiTaskStageStartInput, AiTaskStartInput,
AiTextChunkAppendInput,
};
pub use validation::validate_task_create_input;

View File

@@ -0,0 +1,87 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::{AiResultReferenceKind, AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskCreateInput {
pub task_id: String,
pub task_kind: AiTaskKind,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub stages: Vec<AiTaskStageBlueprint>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStartInput {
pub task_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageStartInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTextChunkAppendInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub sequence: u32,
pub delta_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiStageCompletionInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiResultReferenceInput {
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskFinishInput {
pub task_id: String,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskCancelInput {
pub task_id: String,
pub completed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskFailureInput {
pub task_id: String,
pub failure_message: String,
pub completed_at_micros: i64,
}

View File

@@ -0,0 +1,40 @@
use std::collections::HashMap;
use shared_kernel::normalize_required_string;
use crate::{AiTaskFieldError, AiTaskStageKind};
use super::inputs::AiTaskCreateInput;
pub fn validate_task_create_input(input: &AiTaskCreateInput) -> Result<(), AiTaskFieldError> {
if normalize_required_string(&input.task_id).is_none() {
return Err(AiTaskFieldError::MissingTaskId);
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(AiTaskFieldError::MissingOwnerUserId);
}
if normalize_required_string(&input.request_label).is_none() {
return Err(AiTaskFieldError::MissingRequestLabel);
}
if normalize_required_string(&input.source_module).is_none() {
return Err(AiTaskFieldError::MissingSourceModule);
}
if input.stages.is_empty() {
return Err(AiTaskFieldError::MissingStageBlueprints);
}
let mut seen: HashMap<AiTaskStageKind, bool> = HashMap::new();
for stage in &input.stages {
if normalize_required_string(&stage.label).is_none()
|| normalize_required_string(&stage.detail).is_none()
{
return Err(AiTaskFieldError::MissingStageBlueprints);
}
if seen.insert(stage.stage_kind, true).is_some() {
return Err(AiTaskFieldError::DuplicateStageBlueprint);
}
}
Ok(())
}

View File

@@ -0,0 +1,15 @@
mod ids;
mod stages;
mod types;
pub use ids::{
AI_RESULT_REF_ID_PREFIX, AI_TASK_ID_PREFIX, AI_TASK_STAGE_ID_PREFIX, AI_TEXT_CHUNK_ID_PREFIX,
INITIAL_AI_TASK_VERSION, generate_ai_result_ref_id, generate_ai_task_id,
generate_ai_task_stage_id, generate_ai_text_chunk_id, normalize_optional_text,
normalize_string_list,
};
pub use types::{
AiResultReferenceKind, AiResultReferenceSnapshot, AiTaskKind, AiTaskSnapshot,
AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageSnapshot, AiTaskStageStatus, AiTaskStatus,
AiTextChunkSnapshot,
};

View File

@@ -0,0 +1,41 @@
use shared_kernel::{
build_prefixed_seed_id, normalize_optional_string as normalize_shared_optional_string,
normalize_string_list as normalize_shared_string_list,
};
use super::types::AiTaskStageKind;
pub const AI_TASK_ID_PREFIX: &str = "aitask_";
pub const AI_TASK_STAGE_ID_PREFIX: &str = "aistage_";
pub const AI_RESULT_REF_ID_PREFIX: &str = "aires_";
pub const AI_TEXT_CHUNK_ID_PREFIX: &str = "aichunk_";
pub const INITIAL_AI_TASK_VERSION: u32 = 1;
pub fn generate_ai_task_id(seed_micros: i64) -> String {
build_prefixed_seed_id(AI_TASK_ID_PREFIX, seed_micros)
}
pub fn generate_ai_task_stage_id(task_id: &str, stage_kind: AiTaskStageKind) -> String {
format!(
"{}{}_{}",
AI_TASK_STAGE_ID_PREFIX,
task_id.trim(),
stage_kind.as_str()
)
}
pub fn generate_ai_result_ref_id(seed_micros: i64) -> String {
build_prefixed_seed_id(AI_RESULT_REF_ID_PREFIX, seed_micros)
}
pub fn generate_ai_text_chunk_id(seed_micros: i64, sequence: u32) -> String {
format!("{}{seed_micros:x}_{sequence:x}", AI_TEXT_CHUNK_ID_PREFIX)
}
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
normalize_shared_string_list(values)
}

View File

@@ -0,0 +1,77 @@
use super::types::{AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind, AiTaskStatus};
impl AiTaskKind {
pub fn default_stage_blueprints(self) -> Vec<AiTaskStageBlueprint> {
let ordered_kinds = match self {
Self::StoryGeneration => vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::RepairResponse,
AiTaskStageKind::NormalizeResult,
],
Self::CharacterChat | Self::NpcChat | Self::QuestIntent | Self::RuntimeItemIntent => {
vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::NormalizeResult,
]
}
Self::CustomWorldGeneration => vec![
AiTaskStageKind::PreparePrompt,
AiTaskStageKind::RequestModel,
AiTaskStageKind::RepairResponse,
AiTaskStageKind::NormalizeResult,
AiTaskStageKind::PersistResult,
],
};
ordered_kinds
.into_iter()
.enumerate()
.map(|(index, stage_kind)| AiTaskStageBlueprint {
stage_kind,
label: stage_kind.default_label().to_string(),
detail: stage_kind.default_detail().to_string(),
order: index as u32,
})
.collect()
}
}
impl AiTaskStageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::PreparePrompt => "prepare_prompt",
Self::RequestModel => "request_model",
Self::RepairResponse => "repair_response",
Self::NormalizeResult => "normalize_result",
Self::PersistResult => "persist_result",
}
}
pub fn default_label(self) -> &'static str {
match self {
Self::PreparePrompt => "整理提示词",
Self::RequestModel => "请求模型",
Self::RepairResponse => "修复响应",
Self::NormalizeResult => "归一结果",
Self::PersistResult => "回写结果",
}
}
pub fn default_detail(self) -> &'static str {
match self {
Self::PreparePrompt => "整理输入上下文并构建本轮提示词。",
Self::RequestModel => "向上游模型发起正式推理请求。",
Self::RepairResponse => "对非严格输出做补救修复或二次编排。",
Self::NormalizeResult => "把模型输出归一成模块可消费结构。",
Self::PersistResult => "把结果引用或聚合状态回写到下游模块。",
}
}
}
impl AiTaskStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
}

View File

@@ -0,0 +1,124 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
// AI 编排类型只表达领域意图,具体 prompt 策略留给业务模块和平台层。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskKind {
StoryGeneration,
CharacterChat,
NpcChat,
CustomWorldGeneration,
QuestIntent,
RuntimeItemIntent,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskStatus {
Pending,
Running,
Completed,
Failed,
Cancelled,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AiTaskStageKind {
PreparePrompt,
RequestModel,
RepairResponse,
NormalizeResult,
PersistResult,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiTaskStageStatus {
Pending,
Running,
Completed,
Skipped,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiResultReferenceKind {
StorySession,
StoryEvent,
CustomWorldProfile,
QuestRecord,
RuntimeItemRecord,
AssetObject,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageBlueprint {
pub stage_kind: AiTaskStageKind,
pub label: String,
pub detail: String,
pub order: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskStageSnapshot {
pub stage_kind: AiTaskStageKind,
pub label: String,
pub detail: String,
pub order: u32,
pub status: AiTaskStageStatus,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTaskSnapshot {
pub task_id: String,
pub task_kind: AiTaskKind,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub status: AiTaskStatus,
pub failure_message: Option<String>,
pub stages: Vec<AiTaskStageSnapshot>,
pub result_references: Vec<AiResultReferenceSnapshot>,
pub latest_text_output: Option<String>,
pub latest_structured_payload_json: Option<String>,
pub version: u32,
pub created_at_micros: i64,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiTextChunkSnapshot {
pub chunk_id: String,
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub sequence: u32,
pub delta_text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiResultReferenceSnapshot {
pub result_ref_id: String,
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option<String>,
pub created_at_micros: i64,
}

View File

@@ -0,0 +1,61 @@
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AiTaskFieldError {
MissingTaskId,
MissingOwnerUserId,
MissingRequestLabel,
MissingSourceModule,
MissingStageBlueprints,
DuplicateStageBlueprint,
MissingReferenceId,
MissingChunkText,
InvalidSequence,
MissingFailureMessage,
MissingStage,
InvalidTaskState,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AiTaskServiceError {
Field(AiTaskFieldError),
TaskAlreadyExists,
TaskNotFound,
StageNotFound,
Store(String),
}
impl fmt::Display for AiTaskFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingTaskId => f.write_str("ai_task.task_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("ai_task.owner_user_id 不能为空"),
Self::MissingRequestLabel => f.write_str("ai_task.request_label 不能为空"),
Self::MissingSourceModule => f.write_str("ai_task.source_module 不能为空"),
Self::MissingStageBlueprints => f.write_str("ai_task.stages 至少需要一个有效阶段"),
Self::DuplicateStageBlueprint => f.write_str("ai_task.stages 不能包含重复阶段"),
Self::MissingReferenceId => f.write_str("ai_result_reference.reference_id 不能为空"),
Self::MissingChunkText => f.write_str("ai_text_chunk.delta_text 不能为空"),
Self::InvalidSequence => f.write_str("ai_text_chunk.sequence 必须大于 0"),
Self::MissingFailureMessage => f.write_str("ai_task.failure_message 不能为空"),
Self::MissingStage => f.write_str("ai_task.stage 不存在"),
Self::InvalidTaskState => f.write_str("当前 ai_task 状态不允许执行该操作"),
}
}
}
impl Error for AiTaskFieldError {}
impl fmt::Display for AiTaskServiceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Field(error) => write!(f, "{error}"),
Self::TaskAlreadyExists => f.write_str("ai_task 已存在,不能重复创建"),
Self::TaskNotFound => f.write_str("ai_task 不存在"),
Self::StageNotFound => f.write_str("ai_task.stage 不存在"),
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for AiTaskServiceError {}

View File

@@ -0,0 +1,32 @@
use crate::{
AiResultReferenceKind, AiTaskKind, AiTaskStageKind, AiTaskStatus, AiTextChunkSnapshot,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AiTaskDomainEvent {
TaskCreated {
task_id: String,
task_kind: AiTaskKind,
owner_user_id: String,
},
TaskStatusChanged {
task_id: String,
status: AiTaskStatus,
},
StageStarted {
task_id: String,
stage_kind: AiTaskStageKind,
},
StageCompleted {
task_id: String,
stage_kind: AiTaskStageKind,
},
TextChunkAppended {
chunk: AiTextChunkSnapshot,
},
ResultReferenceAttached {
task_id: String,
reference_kind: AiResultReferenceKind,
reference_id: String,
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
use super::*;
fn build_service() -> AiTaskService {
AiTaskService::new(InMemoryAiTaskStore::default())
}
fn build_create_input(task_kind: AiTaskKind) -> AiTaskCreateInput {
AiTaskCreateInput {
task_id: generate_ai_task_id(1_713_680_000_000_000),
task_kind,
owner_user_id: "user_001".to_string(),
request_label: "首轮故事生成".to_string(),
source_module: "story".to_string(),
source_entity_id: Some("storysess_001".to_string()),
request_payload_json: Some("{\"scene\":\"camp\"}".to_string()),
stages: task_kind.default_stage_blueprints(),
created_at_micros: 1_713_680_000_000_000,
}
}
#[test]
fn default_stage_blueprints_match_story_baseline() {
let stages = AiTaskKind::StoryGeneration.default_stage_blueprints();
assert_eq!(stages.len(), 4);
assert_eq!(stages[0].stage_kind, AiTaskStageKind::PreparePrompt);
assert_eq!(stages[1].stage_kind, AiTaskStageKind::RequestModel);
assert_eq!(stages[2].stage_kind, AiTaskStageKind::RepairResponse);
assert_eq!(stages[3].stage_kind, AiTaskStageKind::NormalizeResult);
}
#[test]
fn create_task_rejects_duplicate_stage_blueprints() {
let mut input = build_create_input(AiTaskKind::StoryGeneration);
input.stages.push(AiTaskStageBlueprint {
stage_kind: AiTaskStageKind::PreparePrompt,
label: "重复阶段".to_string(),
detail: "重复阶段".to_string(),
order: 99,
});
let error = validate_task_create_input(&input).expect_err("duplicate stages should fail");
assert_eq!(error, AiTaskFieldError::DuplicateStageBlueprint);
}
#[test]
fn generate_ai_task_stage_id_contains_task_and_stage_slug() {
let stage_id = generate_ai_task_stage_id("aitask_demo", AiTaskStageKind::NormalizeResult);
assert_eq!(stage_id, "aistage_aitask_demo_normalize_result");
}
#[test]
fn create_and_start_task_updates_status() {
let service = build_service();
let created = service
.create_task(build_create_input(AiTaskKind::QuestIntent))
.expect("task should create");
let started = service
.start_task(&created.task_id, created.created_at_micros + 1)
.expect("task should start");
assert_eq!(created.status, AiTaskStatus::Pending);
assert_eq!(started.status, AiTaskStatus::Running);
assert_eq!(
started.started_at_micros,
Some(created.created_at_micros + 1)
);
assert_eq!(started.version, INITIAL_AI_TASK_VERSION + 1);
}
#[test]
fn append_text_chunk_aggregates_stream_output_by_stage() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::CharacterChat))
.expect("task should create");
service
.start_stage(
&task.task_id,
AiTaskStageKind::RequestModel,
task.created_at_micros + 10,
)
.expect("stage should start");
let (after_first, _) = service
.append_text_chunk(
&task.task_id,
AiTaskStageKind::RequestModel,
1,
"".to_string(),
task.created_at_micros + 20,
)
.expect("first chunk should append");
let (after_second, second_chunk) = service
.append_text_chunk(
&task.task_id,
AiTaskStageKind::RequestModel,
2,
"好。".to_string(),
task.created_at_micros + 30,
)
.expect("second chunk should append");
assert_eq!(after_first.latest_text_output.as_deref(), Some(""));
assert_eq!(after_second.latest_text_output.as_deref(), Some("你好。"));
assert_eq!(second_chunk.sequence, 2);
}
#[test]
fn complete_stage_updates_latest_outputs() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::StoryGeneration))
.expect("task should create");
let completed = service
.complete_stage(AiStageCompletionInput {
task_id: task.task_id.clone(),
stage_kind: AiTaskStageKind::NormalizeResult,
text_output: Some("营地前的篝火重新亮了起来。".to_string()),
structured_payload_json: Some("{\"choices\":3}".to_string()),
warning_messages: vec!["使用了 fallback 选项池".to_string()],
completed_at_micros: task.created_at_micros + 50,
})
.expect("stage should complete");
let stage = completed
.stages
.iter()
.find(|stage| stage.stage_kind == AiTaskStageKind::NormalizeResult)
.expect("normalize stage should exist");
assert_eq!(stage.status, AiTaskStageStatus::Completed);
assert_eq!(
completed.latest_text_output.as_deref(),
Some("营地前的篝火重新亮了起来。")
);
assert_eq!(
completed.latest_structured_payload_json.as_deref(),
Some("{\"choices\":3}")
);
assert_eq!(stage.warning_messages, vec!["使用了 fallback 选项池"]);
}
#[test]
fn attach_result_reference_appends_binding() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::CustomWorldGeneration))
.expect("task should create");
let updated = service
.attach_result_reference(
&task.task_id,
AiResultReferenceKind::CustomWorldProfile,
"profile_001".to_string(),
Some("主世界档案".to_string()),
task.created_at_micros + 10,
)
.expect("reference should attach");
assert_eq!(updated.result_references.len(), 1);
assert_eq!(
updated.result_references[0].reference_kind,
AiResultReferenceKind::CustomWorldProfile
);
assert_eq!(updated.result_references[0].reference_id, "profile_001");
}
#[test]
fn fail_and_cancel_task_move_into_terminal_states() {
let service = build_service();
let first = service
.create_task(build_create_input(AiTaskKind::NpcChat))
.expect("task should create");
let failed = service
.fail_task(
&first.task_id,
"上游模型超时".to_string(),
first.created_at_micros + 10,
)
.expect("task should fail");
assert_eq!(failed.status, AiTaskStatus::Failed);
assert_eq!(failed.failure_message.as_deref(), Some("上游模型超时"));
let second = service
.create_task(AiTaskCreateInput {
task_id: generate_ai_task_id(1_713_680_000_000_999),
..build_create_input(AiTaskKind::RuntimeItemIntent)
})
.expect("second task should create");
let cancelled = service
.cancel_task(&second.task_id, second.created_at_micros + 20)
.expect("task should cancel");
assert_eq!(cancelled.status, AiTaskStatus::Cancelled);
assert_eq!(
cancelled.completed_at_micros,
Some(second.created_at_micros + 20)
);
}
#[test]
fn complete_task_marks_terminal_success() {
let service = build_service();
let task = service
.create_task(build_create_input(AiTaskKind::QuestIntent))
.expect("task should create");
let completed = service
.complete_task(&task.task_id, task.created_at_micros + 100)
.expect("task should complete");
assert_eq!(completed.status, AiTaskStatus::Completed);
assert_eq!(
completed.completed_at_micros,
Some(task.created_at_micros + 100)
);
}

View File

@@ -14,7 +14,7 @@
## 2. 当前阶段说明
当前提交尚未进入完整资产状态建模,但已完成与本模块直接相关的前置基础设施与首版 schema 骨架
当前资产对象主链已完成后端收口资产对象确认、实体槽位绑定、历史读取、OSS 对象确认、API facade、SpacetimeDB adapter 和资产事件表已经形成同一条后端真相链。与本模块直接相关的基础设施包括
1. `api-server` 已具备 `POST /api/assets/direct-upload-tickets`
2. `platform-oss` 已具备旧 `/generated-*` 前缀兼容的 `PostObject` 签名能力
@@ -25,25 +25,31 @@
- `assetobj_` ID 前缀与初始版本常量
- `asset_entity_binding` 输入、快照、返回记录与字段校验 helper
- `assetbind_` ID 前缀
5. `WP-AS Assets` 资产对象类型归位已完成,领域快照、命令 DTO、应用返回 DTO、领域事件和字段错误已分别落到 DDD 骨架文件中。
6. `asset_event` public event table 已承接对象确认与实体绑定变更事实,订阅端和审计流程可以感知资产主链变化。
当前 `asset_object` 表的字段、索引与可编码约束见:
1. [../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
2. [../../../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
3. [../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
4. [../../../docs/technical/SERVER_RS_DDD_WP_AS_ASSET_OBJECT_TYPE_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_AS_ASSET_OBJECT_TYPE_REHOME_2026-04-29.md)
5. [../../../docs/technical/SERVER_RS_DDD_WP_AS_ASSET_CHAIN_CLOSURE_2026-05-01.md](../../../docs/technical/SERVER_RS_DDD_WP_AS_ASSET_CHAIN_CLOSURE_2026-05-01.md)
当前还已补齐:
1. `AssetObjectService`
2. 私有 bucket `HEAD Object` 后的对象确认写入
3. 当前阶段的进程内 `asset_object` 去重存储
4. SpacetimeDB `asset_object` / `asset_entity_binding` / `asset_event` adapter 写入
5. Rust `spacetime-client` 资产对象确认、绑定和历史 facade
后续与本 package 直接相关的任务包括:
1. 设计 `asset_job``asset_object``asset_manifest`
1. 设计 `asset_job``asset_manifest`
2. 设计角色、动作、场景、精灵表相关资产表
3. 对齐资产生成、发布、对象确认与兼容接口链路
4. 接入 OSS 对象写入与绑定编排
3. 对齐资产生成、发布和专业资产任务编排
4. 新增资产生成表或专业资产任务时继续复用 OSS read-url 读取链路
## 3. 边界约束

View File

@@ -0,0 +1,45 @@
//! 资产应用编排返回类型。
//!
//! 这里只组合纯校验与应用结果;对象探测、签名和持久化由 adapter 层完成。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::domain::{
AssetEntityBindingSnapshot, AssetHistoryEntrySnapshot, AssetObjectRecord,
AssetObjectUpsertSnapshot,
};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectProcedureResult {
pub ok: bool,
pub record: Option<AssetObjectUpsertSnapshot>,
pub error_message: Option<String>,
}
#[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 {
pub ok: bool,
pub record: Option<AssetEntityBindingSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectResult {
pub record: AssetObjectRecord,
}
pub use crate::asset_object_core::{
build_asset_entity_binding_input, build_asset_object_upsert_input,
};

View File

@@ -1,223 +1,18 @@
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use shared_kernel::{
build_prefixed_seed_id, format_timestamp_micros, normalize_optional_string,
normalize_required_string,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const ASSET_OBJECT_ID_PREFIX: &str = "assetobj_";
pub const ASSET_BINDING_ID_PREFIX: &str = "assetbind_";
pub const INITIAL_ASSET_OBJECT_VERSION: u32 = 1;
// 资产对象访问策略先冻结为枚举,避免后续在 reducer、HTTP DTO 和脚本里散落字符串字面量。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetObjectAccessPolicy {
Private,
PublicRead,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AssetObjectFieldError {
MissingBucket,
MissingObjectKey,
MissingAssetKind,
MissingAssetObjectId,
MissingBindingId,
MissingEntityKind,
MissingEntityId,
MissingSlot,
InvalidVersion,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectInput {
pub bucket: Option<String>,
pub object_key: String,
pub content_type: Option<String>,
pub content_length: Option<u64>,
pub content_hash: Option<String>,
pub asset_kind: String,
pub access_policy: Option<AssetObjectAccessPolicy>,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectProcedureResult {
pub ok: bool,
pub record: Option<AssetObjectUpsertSnapshot>,
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 {
pub ok: bool,
pub record: Option<AssetEntityBindingSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertInput {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertSnapshot {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: 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 AssetEntityBindingInput {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingSnapshot {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetObjectRecord {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at: String,
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,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetEntityBindingRecord {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl AssetObjectAccessPolicy {
pub fn as_str(&self) -> &'static str {
match self {
Self::Private => "private",
Self::PublicRead => "public_read",
}
}
}
use crate::{
commands::{AssetEntityBindingInput, AssetObjectUpsertInput},
domain::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord,
AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot,
AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot,
INITIAL_ASSET_OBJECT_VERSION,
},
errors::AssetObjectFieldError,
};
// 资产核心对象字段需要继续保留模块自己的错误语义,但基础必填字符串归一化统一走 shared-kernel。
fn normalize_required_asset_field(
@@ -420,24 +215,6 @@ pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
normalize_optional_string(value)
}
impl fmt::Display for AssetObjectFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBucket => f.write_str("asset_object.bucket 不能为空"),
Self::MissingObjectKey => f.write_str("asset_object.object_key 不能为空"),
Self::MissingAssetKind => f.write_str("asset_object.asset_kind 不能为空"),
Self::MissingAssetObjectId => f.write_str("asset_object.asset_object_id 不能为空"),
Self::MissingBindingId => f.write_str("asset_entity_binding.binding_id 不能为空"),
Self::MissingEntityKind => f.write_str("asset_entity_binding.entity_kind 不能为空"),
Self::MissingEntityId => f.write_str("asset_entity_binding.entity_id 不能为空"),
Self::MissingSlot => f.write_str("asset_entity_binding.slot 不能为空"),
Self::InvalidVersion => f.write_str("asset_object.version 必须大于 0"),
}
}
}
impl Error for AssetObjectFieldError {}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1,105 @@
//! 资产写入命令。
//!
//! 用于表达确认资产对象、绑定实体槽位和查询资产历史的输入,不直接访问 OSS。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::domain::{
AssetEntityBindingSnapshot, AssetObjectAccessPolicy, AssetObjectUpsertSnapshot,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectInput {
pub bucket: Option<String>,
pub object_key: String,
pub content_type: Option<String>,
pub content_length: Option<u64>,
pub content_hash: Option<String>,
pub asset_kind: String,
pub access_policy: Option<AssetObjectAccessPolicy>,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: 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 AssetObjectUpsertInput {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingInput {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub updated_at_micros: i64,
}
impl From<AssetObjectUpsertInput> for AssetObjectUpsertSnapshot {
fn from(value: AssetObjectUpsertInput) -> Self {
Self {
asset_object_id: value.asset_object_id,
bucket: value.bucket,
object_key: value.object_key,
access_policy: value.access_policy,
content_type: value.content_type,
content_length: value.content_length,
content_hash: value.content_hash,
version: value.version,
source_job_id: value.source_job_id,
owner_user_id: value.owner_user_id,
profile_id: value.profile_id,
entity_id: value.entity_id,
asset_kind: value.asset_kind,
created_at_micros: value.updated_at_micros,
updated_at_micros: value.updated_at_micros,
}
}
}
impl From<AssetEntityBindingInput> for AssetEntityBindingSnapshot {
fn from(value: AssetEntityBindingInput) -> Self {
Self {
binding_id: value.binding_id,
asset_object_id: value.asset_object_id,
entity_kind: value.entity_kind,
entity_id: value.entity_id,
slot: value.slot,
asset_kind: value.asset_kind,
owner_user_id: value.owner_user_id,
profile_id: value.profile_id,
created_at_micros: value.updated_at_micros,
updated_at_micros: value.updated_at_micros,
}
}
}

View File

@@ -0,0 +1,128 @@
//! 资产领域模型。
//!
//! 本层只保留资产对象、实体绑定、访问策略、版本和业务归属等纯领域事实。
//! OSS 对象探测、HTTP DTO 映射和 SpacetimeDB row 写入都属于外层 adapter。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const ASSET_OBJECT_ID_PREFIX: &str = "assetobj_";
pub const ASSET_BINDING_ID_PREFIX: &str = "assetbind_";
pub const INITIAL_ASSET_OBJECT_VERSION: u32 = 1;
// 资产对象访问策略先冻结为枚举,避免 reducer、HTTP DTO 和脚本里散落字符串字面量。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetObjectAccessPolicy {
Private,
PublicRead,
}
impl AssetObjectAccessPolicy {
pub fn as_str(&self) -> &'static str {
match self {
Self::Private => "private",
Self::PublicRead => "public_read",
}
}
}
/// SpacetimeDB 写入前的资产对象快照。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertSnapshot {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: 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 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 AssetEntityBindingSnapshot {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
/// 面向 API 与前端展示的资产对象记录。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetObjectRecord {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at: String,
pub updated_at: String,
}
/// 面向 API 与前端展示的资产历史记录。
#[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,
}
/// 面向 API 与前端展示的实体绑定记录。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetEntityBindingRecord {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}

View File

@@ -0,0 +1,36 @@
//! 资产领域错误。
//!
//! 字段错误和业务错误在这里收口HTTP 状态码与 SpacetimeDB 字符串错误由 adapter 映射。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AssetObjectFieldError {
MissingBucket,
MissingObjectKey,
MissingAssetKind,
MissingAssetObjectId,
MissingBindingId,
MissingEntityKind,
MissingEntityId,
MissingSlot,
InvalidVersion,
}
impl fmt::Display for AssetObjectFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBucket => f.write_str("asset_object.bucket 不能为空"),
Self::MissingObjectKey => f.write_str("asset_object.object_key 不能为空"),
Self::MissingAssetKind => f.write_str("asset_object.asset_kind 不能为空"),
Self::MissingAssetObjectId => f.write_str("asset_object.asset_object_id 不能为空"),
Self::MissingBindingId => f.write_str("asset_entity_binding.binding_id 不能为空"),
Self::MissingEntityKind => f.write_str("asset_entity_binding.entity_kind 不能为空"),
Self::MissingEntityId => f.write_str("asset_entity_binding.entity_id 不能为空"),
Self::MissingSlot => f.write_str("asset_entity_binding.slot 不能为空"),
Self::InvalidVersion => f.write_str("asset_object.version 必须大于 0"),
}
}
}
impl Error for AssetObjectFieldError {}

View File

@@ -0,0 +1,43 @@
//! 资产领域事件。
//!
//! 用于表达资产已确认、绑定已变更和资产历史投影待刷新等事实。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
/// 资产领域事件。
///
/// 事件只描述已经发生的轻量事实,正式资产状态仍以 `asset_object`
/// 和 `asset_entity_binding` 为准。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetDomainEvent {
ObjectConfirmed(AssetObjectConfirmedEvent),
EntityBindingChanged(AssetEntityBindingChangedEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectConfirmedEvent {
pub asset_object_id: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingChangedEvent {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub occurred_at_micros: i64,
}

View File

@@ -1,20 +1,35 @@
mod application;
mod commands;
mod domain;
mod errors;
mod events;
mod asset_object_core;
#[cfg(feature = "server-service")]
mod asset_object_service;
pub use asset_object_core::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput,
AssetEntityBindingProcedureResult, AssetEntityBindingRecord, AssetEntityBindingSnapshot,
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,
pub use application::{
AssetEntityBindingProcedureResult, AssetHistoryListResult, AssetObjectProcedureResult,
ConfirmAssetObjectResult, build_asset_entity_binding_input, build_asset_object_upsert_input,
};
#[cfg(feature = "server-service")]
pub use asset_object_service::{
AssetObjectService, ConfirmAssetObjectError, InMemoryAssetObjectStore,
};
pub use commands::{
AssetEntityBindingInput, AssetHistoryListInput, AssetObjectUpsertInput, ConfirmAssetObjectInput,
};
pub use domain::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord,
AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot,
AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot,
INITIAL_ASSET_OBJECT_VERSION,
};
pub use errors::AssetObjectFieldError;
pub use events::{AssetDomainEvent, AssetEntityBindingChangedEvent, AssetObjectConfirmedEvent};
pub use asset_object_core::{
build_asset_entity_binding_record, build_asset_history_entry_record, build_asset_object_record,
generate_asset_binding_id, generate_asset_object_id, normalize_optional_value,
validate_asset_entity_binding_fields, validate_asset_object_fields,
};

View File

@@ -18,15 +18,18 @@
1. JWT claims 设计与 `platform-auth` 落地。
2. refresh cookie 读取适配。
3. `module-auth` 真实 crate 与首版密码登录用例落地。
4. 微信登录链路暂缓执行,不进入当前连续实现顺序
4. `WP-A Auth` DDD 分层收口,账号、会话、验证码、微信 state/绑定规则、命令输入、应用返回、领域错误和领域事件已归位到 `domain / commands / application / errors / events`
5. `api-server / platform-auth / spacetime-module` 认证边界已核查:真实短信、微信 OAuth、JWT、cookie 和密码哈希仍由平台层或 BFF 装配承接SpacetimeDB 侧只保留快照与表适配。
当前连续实现优先顺序固定为
当前已覆盖的鉴权用例
1. 密码登录
2. refresh token 轮换
3. `me` 查询
4. 会话吊销
5. 手机验证码登录
6. 微信登录 state 创建/消费
7. 微信身份解析与手机号绑定
## 3. 当前已冻结文档
@@ -44,6 +47,7 @@
12. [../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)
13. [../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)
14. [../../../docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](../../../docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md)
15. [../../../docs/technical/SERVER_RS_DDD_WP_A_AUTH_DOMAIN_VALUE_OBJECT_REFACTOR_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_A_AUTH_DOMAIN_VALUE_OBJECT_REFACTOR_2026-04-29.md)
## 4. 边界约束
@@ -56,3 +60,4 @@
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。
8. 当前 `module-auth` 已承接当前 refresh session 吊销与用户 `token_version` 递增能力,供 `/api/auth/logout` 复用。
9. 当前手机号验证码真实 provider 由 `platform-auth` 注入,`module-auth` 只保留冷却、TTL、失败次数和账号编排不保存验证码明文。
10. 当前 `lib.rs` 仍保留进程内仓储和文件持久化支撑,但不再继续拥有命令、结果、错误、事件和纯领域值对象定义。

View File

@@ -0,0 +1,118 @@
//! 认证应用返回类型。
//!
//! 这里只返回纯应用结果与领域事件;短信 provider、JWT 签发和持久化由外层 adapter 完成。
use serde::{Deserialize, Serialize};
use crate::domain::{
AuthStoreSnapshotRecord, AuthUser, RefreshSessionRecord, WechatAuthStateRecord,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthMeResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicUserSearchResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangePasswordResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateProfileResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordResult {
pub user: AuthUser,
pub provider: String,
pub provider_out_id: Option<String>,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeResult {
pub cooldown_seconds: u64,
pub expires_in_seconds: u64,
pub provider_request_id: Option<String>,
pub provider_out_id: Option<String>,
pub provider: String,
pub scene: String,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginResult {
pub user: AuthUser,
pub created: bool,
pub provider: String,
pub provider_out_id: Option<String>,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConsumeWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneResult {
pub user: AuthUser,
pub activated_new_user: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionResult {
pub session: RefreshSessionRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionResult {
pub session: RefreshSessionRecord,
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListActiveRefreshSessionsResult {
pub sessions: Vec<RefreshSessionRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotProcedureResult {
pub ok: bool,
pub record: Option<AuthStoreSnapshotRecord>,
pub error_message: Option<String>,
}

View File

@@ -0,0 +1,99 @@
//! 认证写入命令。
//!
//! 用于表达密码入口、手机号验证码、微信登录、刷新会话签发和吊销等用例输入。
use serde::{Deserialize, Serialize};
use crate::domain::{
AuthLoginMethod, PhoneAuthScene, RefreshSessionClientInfo, WechatAuthScene,
WechatIdentityProfile,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub phone_number: String,
pub password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangePasswordInput {
pub user_id: String,
pub current_password: Option<String>,
pub new_password: String,
}
#[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 UpdateProfileInput {
pub user_id: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeInput {
pub phone_number: String,
pub scene: PhoneAuthScene,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginInput {
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginInput {
pub profile: WechatIdentityProfile,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateInput {
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneInput {
pub user_id: String,
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionInput {
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionInput {
pub refresh_token_hash: String,
pub next_refresh_token_hash: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionInput {
pub user_id: String,
pub refresh_token_hash: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsInput {
pub user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotUpsertInput {
pub snapshot_json: String,
pub updated_at_micros: i64,
}

View File

@@ -0,0 +1,256 @@
//! 认证领域模型。
//!
//! 这里只保留账号、登录方式、绑定状态等纯领域事实。文件持久化、真实短信发送、
//! cookie 写入、JWT 签发和 HTTP 上下文都属于外层 adapter。
use serde::{Deserialize, Serialize};
use crate::errors::{PasswordEntryError, PhoneAuthError};
pub const PASSWORD_MIN_LENGTH: usize = 6;
pub const PASSWORD_MAX_LENGTH: usize = 128;
pub const SMS_CODE_LENGTH: usize = 6;
pub const SMS_CODE_TTL_MINUTES: i64 = 5;
pub const SMS_CODE_COOLDOWN_SECONDS: u64 = 60;
pub const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5;
/// 用户最近一次完成认证的入口类型。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthLoginMethod {
Password,
Phone,
Wechat,
}
impl AuthLoginMethod {
pub fn as_str(&self) -> &'static str {
match self {
Self::Password => "password",
Self::Phone => "phone",
Self::Wechat => "wechat",
}
}
}
/// 账号是否已经完成必要绑定。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthBindingStatus {
Active,
PendingBindPhone,
}
impl AuthBindingStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::PendingBindPhone => "pending_bind_phone",
}
}
}
/// 认证用户快照。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthUser {
pub id: String,
pub public_user_code: String,
pub username: String,
pub display_name: String,
#[serde(default)]
pub avatar_url: Option<String>,
pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
pub token_version: u64,
#[serde(default)]
pub created_at: String,
}
/// 规范化后的手机号快照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneNumberSnapshot {
pub e164: String,
pub masked_national_number: String,
}
/// 手机验证码使用场景。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthScene {
Login,
BindPhone,
ChangePhone,
ResetPassword,
}
impl PhoneAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Login => "login",
Self::BindPhone => "bind_phone",
Self::ChangePhone => "change_phone",
Self::ResetPassword => "reset_password",
}
}
}
/// 微信授权入口场景。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthScene {
Desktop,
WechatInApp,
}
impl WechatAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Desktop => "desktop",
Self::WechatInApp => "wechat_in_app",
}
}
}
/// 微信身份资料快照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatIdentityProfile {
pub provider_uid: String,
pub provider_union_id: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
/// 微信授权 state 快照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatAuthStateRecord {
pub wechat_state_id: String,
pub state_token: String,
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
pub expires_at: String,
pub consumed_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// refresh session 的客户端环境快照。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefreshSessionClientInfo {
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
pub client_instance_id: Option<String>,
pub device_fingerprint: Option<String>,
pub device_display_name: String,
pub mini_program_app_id: Option<String>,
pub mini_program_env: Option<String>,
pub user_agent: Option<String>,
pub ip: Option<String>,
}
/// refresh session 快照。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefreshSessionRecord {
pub session_id: String,
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
pub expires_at: String,
pub revoked_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub last_seen_at: String,
}
/// Auth store 持久化快照记录。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreSnapshotRecord {
pub snapshot_json: Option<String>,
pub updated_at_micros: Option<i64>,
}
pub fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
let length = password.chars().count();
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
return Err(PasswordEntryError::InvalidPasswordLength);
}
Ok(())
}
pub fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> {
let verify_code = verify_code.trim();
if verify_code.len() != SMS_CODE_LENGTH
|| !verify_code
.chars()
.all(|character| character.is_ascii_digit())
{
return Err(PhoneAuthError::InvalidVerifyCode);
}
Ok(())
}
pub fn normalize_mainland_china_phone_number(
raw_phone_number: &str,
) -> Result<PhoneNumberSnapshot, PhoneAuthError> {
let digits = raw_phone_number
.trim()
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
if digits.len() != 11 || !digits.starts_with('1') {
return Err(PhoneAuthError::InvalidPhoneNumber);
}
Ok(PhoneNumberSnapshot {
e164: format!("+86{digits}"),
masked_national_number: mask_phone_number(&digits),
})
}
pub fn mask_phone_number(phone_number: &str) -> String {
format!("{}****{}", &phone_number[..3], &phone_number[7..11])
}
pub fn build_national_phone_number(e164_phone_number: &str) -> Result<String, PhoneAuthError> {
let digits = e164_phone_number.trim().trim_start_matches('+');
if let Some(national) = digits.strip_prefix("86")
&& national.len() == 11
{
return Ok(national.to_string());
}
Err(PhoneAuthError::InvalidPhoneNumber)
}
pub fn build_system_username(prefix: &str, sequence: u64) -> String {
format!("{prefix}_{sequence:08}")
}
// 公开百梦号是稳定的公开检索键,不替代内部 user_id仅用于展示、分享与搜索。
pub fn build_public_user_code(sequence: u64) -> String {
format!("SY-{sequence:08}")
}
pub fn normalize_public_user_code(input: &str) -> Result<String, PasswordEntryError> {
let normalized = input
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_uppercase();
let digits = normalized.strip_prefix("SY").unwrap_or(&normalized);
if digits.is_empty()
|| digits.len() > 8
|| !digits.chars().all(|character| character.is_ascii_digit())
{
return Err(PasswordEntryError::InvalidPublicUserCode);
}
Ok(format!("SY-{digits:0>8}"))
}
pub fn build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String {
format!("{}:{}", phone_number.trim(), scene.as_str())
}

View File

@@ -0,0 +1,194 @@
//! 认证领域错误。
//!
//! 领域错误保持可测试、可映射,不能直接依赖 Axum、cookie 或平台 provider 错误模型。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PasswordEntryError {
InvalidPhoneNumber,
InvalidPasswordLength,
InvalidDisplayName,
InvalidAvatarDataUrl,
EmptyProfileUpdate,
InvalidPublicUserCode,
InvalidCredentials,
UserNotFound,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthError {
InvalidPhoneNumber,
InvalidVerifyCode,
VerifyCodeNotFound,
VerifyCodeExpired,
SendCoolingDown { retry_after_seconds: u64 },
VerifyAttemptsExceeded,
UserNotFound,
UserStateMismatch,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthError {
MissingProfile,
StateNotFound,
StateExpired,
StateConsumed,
UserNotFound,
MissingWechatIdentity,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RefreshSessionError {
MissingToken,
SessionNotFound,
SessionExpired,
UserNotFound,
Store(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogoutError {
UserNotFound,
Store(String),
}
impl fmt::Display for PasswordEntryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidDisplayName => f.write_str("昵称格式不正确"),
Self::InvalidAvatarDataUrl => f.write_str("头像图片格式不正确"),
Self::EmptyProfileUpdate => f.write_str("请至少修改昵称或头像"),
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),
}
}
}
impl Error for PasswordEntryError {}
impl fmt::Display for PhoneAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidVerifyCode => f.write_str("验证码错误"),
Self::VerifyCodeNotFound => f.write_str("验证码不存在或已失效"),
Self::VerifyCodeExpired => f.write_str("验证码已过期"),
Self::SendCoolingDown { .. } => f.write_str("验证码发送过于频繁,请稍后再试"),
Self::VerifyAttemptsExceeded => f.write_str("验证码错误次数过多,请重新获取验证码"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::UserStateMismatch => f.write_str("当前账号状态不允许执行该操作"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PhoneAuthError {}
impl fmt::Display for WechatAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingProfile => f.write_str("缺少微信身份信息"),
Self::StateNotFound => f.write_str("微信登录状态已失效,请重新发起登录"),
Self::StateExpired => f.write_str("微信登录状态已过期,请重新发起登录"),
Self::StateConsumed => f.write_str("微信登录状态已被消费,请重新发起登录"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::MissingWechatIdentity => f.write_str("当前账号缺少微信身份"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for WechatAuthError {}
impl fmt::Display for RefreshSessionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingToken => f.write_str("缺少刷新会话"),
Self::SessionNotFound | Self::SessionExpired | Self::UserNotFound => {
f.write_str("当前登录态已失效,请重新登录")
}
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for RefreshSessionError {}
impl fmt::Display for LogoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UserNotFound => f.write_str("当前登录态已失效,请重新登录"),
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for LogoutError {}
pub(crate) fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
match error {
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => {
RefreshSessionError::Store("用户仓储读取失败".to_string())
}
}
}
pub(crate) fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthError {
match error {
PasswordEntryError::Store(message) => PhoneAuthError::Store(message),
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()),
}
}
pub(crate) fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError {
match error {
PasswordEntryError::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
}
}
pub(crate) fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError {
match error {
RefreshSessionError::Store(message) => LogoutError::Store(message),
RefreshSessionError::MissingToken
| RefreshSessionError::SessionNotFound
| RefreshSessionError::SessionExpired
| RefreshSessionError::UserNotFound => LogoutError::Store("会话吊销失败".to_string()),
}
}

View File

@@ -0,0 +1,27 @@
//! 认证领域事件。
//!
//! 用于表达用户创建、会话签发/吊销、手机号验证通过和微信身份绑定等事实。
use crate::domain::AuthLoginMethod;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AuthDomainEvent {
UserCreated {
user_id: String,
login_method: AuthLoginMethod,
},
RefreshSessionIssued {
session_id: String,
user_id: String,
},
RefreshSessionRevoked {
session_id: String,
user_id: String,
},
PhoneVerified {
user_id: String,
},
WechatIdentityBound {
user_id: String,
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
# module-big-fish 独立模块 package 说明
日期:`2026-04-30`
## 1. package 职责
`module-big-fish` 是大鱼吃小鱼创作与运行态规则模块 package负责
1. 创作会话、锚点包、草稿、资产槽和作品摘要的纯领域类型。
2. 草稿编译、资产覆盖、发布门禁、字段校验和序列化规则。
3. Big Fish 运行态一局的服务端真相源规则。
4.`spacetime-module``spacetime-client``api-server` 提供稳定领域边界。
## 2. 当前阶段说明
当前 DDD 物理拆分已经收口:
1. `src/domain.rs` 承接创作阶段、锚点、资产槽、草稿、会话、作品摘要、发布门禁和运行态领域类型。
2. `src/commands.rs` 承接会话、消息、草稿、资产、发布、游玩记录和运行态输入 DTO。
3. `src/application.rs` 承接锚点推断、默认草稿编译、资产覆盖、资产槽构造、字段校验、序列化与运行态真相源规则。
4. `src/errors.rs` 承接应用错误、字段错误和中文错误文案。
5. `src/events.rs` 承接发布门禁和运行态领域事件。
6. `src/lib.rs` 只保留模块声明、公开导出和测试,继续保持 `module_big_fish::*` 公开 API。
当前设计依据:
1. [../../../docs/technical/SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md)
2. [../../../docs/technical/SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md](../../../docs/technical/SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md)
## 3. 边界约束
1. `module-big-fish` 不直接调用图片生成、OSS、HTTP、SSE 或 SpacetimeDB SDK。
2. 领域函数只处理纯规则和可序列化领域事实。
3. 表、procedure、route、前端 client 和绑定 shape 由外层 adapter 承接。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,196 @@
//! 大鱼吃小鱼写入命令。
//!
//! 用于表达创建会话、写入消息、更新资产槽和推进运行态等输入。
use crate::domain::*;
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
/// 评估作品是否可以发布的纯领域命令。
///
/// adapter 负责把 SpacetimeDB row 或 HTTP DTO 映射成这里的输入;
/// 命令本身只关心草稿与资产槽这些领域事实。
#[derive(Clone, Debug, PartialEq)]
pub struct EvaluateBigFishPublishReadinessCommand {
pub session_id: String,
pub owner_user_id: String,
pub draft: Option<BigFishGameDraft>,
pub evaluated_at_micros: i64,
}
/// 开始一局 Big Fish 运行态的纯领域命令。
#[derive(Clone, Debug, PartialEq)]
pub struct StartBigFishRunCommand {
pub run_id: String,
pub session_id: String,
pub owner_user_id: String,
pub draft: Option<BigFishGameDraft>,
pub work_level_count: Option<u32>,
pub started_at_micros: i64,
}
/// 提交方向输入并推进一帧的纯领域命令。
#[derive(Clone, Debug, PartialEq)]
pub struct SubmitBigFishInputCommand {
pub owner_user_id: String,
pub x: f32,
pub y: f32,
pub submitted_at_micros: i64,
pub current_snapshot: BigFishRuntimeSnapshot,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksListInput {
pub owner_user_id: String,
pub published_only: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkDeleteInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkRemixInput {
pub source_session_id: String,
pub target_session_id: String,
pub target_owner_user_id: String,
pub welcome_message_id: String,
pub remixed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishSessionCreateInput {
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,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishSessionGetInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishMessageSubmitInput {
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,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub stage: BigFishCreationStage,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishDraftCompileInput {
pub session_id: String,
pub owner_user_id: String,
pub draft_json: Option<String>,
pub compiled_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetGenerateInput {
pub session_id: String,
pub owner_user_id: String,
pub asset_kind: BigFishAssetKind,
pub level: Option<u32>,
pub motion_key: Option<String>,
pub asset_url: Option<String>,
pub generated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishPublishInput {
pub session_id: String,
pub owner_user_id: String,
pub published_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishPlayRecordInput {
pub session_id: String,
pub user_id: String,
pub elapsed_ms: u64,
pub played_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkLikeRecordInput {
pub session_id: String,
pub user_id: String,
pub liked_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunStartInput {
pub run_id: String,
pub session_id: String,
pub owner_user_id: String,
pub started_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunGetInput {
pub run_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishInputSubmitInput {
pub run_id: String,
pub owner_user_id: String,
pub x: f32,
pub y: f32,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishRunProcedureResult {
pub ok: bool,
pub run_json: Option<String>,
pub error_message: Option<String>,
}

View File

@@ -0,0 +1,370 @@
//! 大鱼吃小鱼领域模型。
//!
//! 保留创作会话、资产槽、发布门禁和运行态聚合的纯领域结构图片生成、OSS 与 HTTP handler 均留在 adapter 层。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const BIG_FISH_SESSION_ID_PREFIX: &str = "big-fish-session-";
pub const BIG_FISH_MESSAGE_ID_PREFIX: &str = "big-fish-message-";
pub const BIG_FISH_OPERATION_ID_PREFIX: &str = "big-fish-operation-";
pub const BIG_FISH_ASSET_SLOT_ID_PREFIX: &str = "big-fish-asset-";
pub const PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID: &str = "public-big-fish-gallery";
pub const BIG_FISH_DEFAULT_LEVEL_COUNT: u32 = 8;
pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6;
pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12;
pub const BIG_FISH_MERGE_COUNT_PER_UPGRADE: u32 = 3;
pub const BIG_FISH_OFFSCREEN_CULL_SECONDS: f32 = 3.0;
pub const BIG_FISH_TARGET_WILD_COUNT: usize = 12;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishCreationStage {
CollectingAnchors,
DraftReady,
AssetRefining,
ReadyToPublish,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAnchorStatus {
Confirmed,
Inferred,
Missing,
Locked,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAgentMessageRole {
User,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAgentMessageKind {
Chat,
Summary,
ActionResult,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAssetKind {
LevelMainImage,
LevelMotion,
StageBackground,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAssetStatus {
Missing,
Ready,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAnchorItem {
pub key: String,
pub label: String,
pub value: String,
pub status: BigFishAnchorStatus,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAnchorPack {
pub gameplay_promise: BigFishAnchorItem,
pub ecology_visual_theme: BigFishAnchorItem,
pub growth_ladder: BigFishAnchorItem,
pub risk_tempo: BigFishAnchorItem,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishLevelBlueprint {
pub level: u32,
pub name: String,
pub one_line_fantasy: String,
pub text_description: String,
pub silhouette_direction: String,
pub size_ratio: f32,
pub visual_description: String,
pub visual_prompt_seed: String,
pub idle_motion_description: String,
pub move_motion_description: String,
pub motion_prompt_seed: String,
pub merge_source_level: Option<u32>,
pub prey_window: Vec<u32>,
pub threat_window: Vec<u32>,
pub is_final_level: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishBackgroundBlueprint {
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,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRuntimeParams {
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<u32>,
pub threat_spawn_delta_levels: Vec<u32>,
pub win_level: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishGameDraft {
pub title: String,
pub subtitle: String,
pub core_fun: String,
pub ecology_theme: String,
pub levels: Vec<BigFishLevelBlueprint>,
pub background: BigFishBackgroundBlueprint,
pub runtime_params: BigFishRuntimeParams,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAgentMessageSnapshot {
pub message_id: String,
pub session_id: String,
pub role: BigFishAgentMessageRole,
pub kind: BigFishAgentMessageKind,
pub text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetSlotSnapshot {
pub slot_id: String,
pub session_id: String,
pub asset_kind: BigFishAssetKind,
pub level: Option<u32>,
pub motion_key: Option<String>,
pub status: BigFishAssetStatus,
pub asset_url: Option<String>,
pub prompt_snapshot: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetCoverage {
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<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishSessionSnapshot {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub current_turn: u32,
pub progress_percent: u32,
pub stage: BigFishCreationStage,
pub anchor_pack: BigFishAnchorPack,
pub draft: Option<BigFishGameDraft>,
pub asset_slots: Vec<BigFishAssetSlotSnapshot>,
pub asset_coverage: BigFishAssetCoverage,
pub messages: Vec<BigFishAgentMessageSnapshot>,
pub last_assistant_reply: Option<String>,
pub publish_ready: bool,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishSessionProcedureResult {
pub ok: bool,
pub session: Option<BigFishSessionSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkSummarySnapshot {
pub work_id: String,
pub source_session_id: String,
pub owner_user_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
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,
pub play_count: u32,
pub remix_count: u32,
pub like_count: u32,
#[serde(default)]
pub recent_play_count_7d: u32,
pub published_at_micros: Option<i64>,
}
/// 发布门禁的领域判定结果。
///
/// 这里不保存外部任务状态,只表达当前聚合快照是否满足发布条件。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BigFishPublishReadiness {
pub session_id: String,
pub owner_user_id: String,
pub publish_ready: bool,
pub blockers: Vec<String>,
pub evaluated_at_micros: i64,
}
/// 运行态一局的状态。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishRunStatus {
Running,
Won,
Failed,
}
/// 运行态二维坐标。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishVector2 {
pub x: f32,
pub y: f32,
}
/// 运行态实体快照。
///
/// 只表达服务端结算后的事实,前端不能据此反推规则并本地裁决。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRuntimeEntitySnapshot {
pub entity_id: String,
pub level: u32,
pub position: BigFishVector2,
pub radius: f32,
pub offscreen_seconds: f32,
}
/// 运行态一局快照。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRuntimeSnapshot {
pub run_id: String,
pub session_id: String,
pub status: BigFishRunStatus,
pub tick: u64,
pub player_level: u32,
pub win_level: u32,
pub leader_entity_id: Option<String>,
pub owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
pub wild_entities: Vec<BigFishRuntimeEntitySnapshot>,
pub camera_center: BigFishVector2,
pub last_input: BigFishVector2,
pub event_log: Vec<String>,
pub updated_at_micros: i64,
}
impl BigFishRunStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Running => "running",
Self::Won => "won",
Self::Failed => "failed",
}
}
}
impl BigFishCreationStage {
pub fn as_str(self) -> &'static str {
match self {
Self::CollectingAnchors => "collecting_anchors",
Self::DraftReady => "draft_ready",
Self::AssetRefining => "asset_refining",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
}
}
}
impl BigFishAnchorStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Confirmed => "confirmed",
Self::Inferred => "inferred",
Self::Missing => "missing",
Self::Locked => "locked",
}
}
}
impl BigFishAgentMessageRole {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
impl BigFishAgentMessageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Summary => "summary",
Self::ActionResult => "action_result",
Self::Warning => "warning",
}
}
}
impl BigFishAssetKind {
pub fn as_str(self) -> &'static str {
match self {
Self::LevelMainImage => "level_main_image",
Self::LevelMotion => "level_motion",
Self::StageBackground => "stage_background",
}
}
}
impl BigFishAssetStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Missing => "missing",
Self::Ready => "ready",
}
}
}

View File

@@ -0,0 +1,60 @@
//! 大鱼吃小鱼领域错误。
//!
//! 错误只表达玩法规则失败,由 HTTP 和 SpacetimeDB adapter 分别映射展示。
use std::{error::Error, fmt};
/// 大鱼吃小鱼应用服务错误。
///
/// 这里不携带 HTTP status 或 SpacetimeDB 字符串错误,避免领域层泄漏 adapter 语义。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BigFishApplicationError {
MissingSessionId,
MissingOwnerUserId,
MissingRunId,
InvalidRuntimeInput,
}
impl fmt::Display for BigFishApplicationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingSessionId => f.write_str("big_fish.session_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("big_fish.owner_user_id 不能为空"),
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
}
}
}
impl Error for BigFishApplicationError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BigFishFieldError {
MissingSessionId,
MissingOwnerUserId,
MissingMessageId,
MissingMessageText,
MissingDraft,
InvalidLevel,
InvalidAssetKind,
MissingRunId,
InvalidRuntimeInput,
}
impl fmt::Display for BigFishFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingSessionId => f.write_str("big_fish.session_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("big_fish.owner_user_id 不能为空"),
Self::MissingMessageId => f.write_str("big_fish.message_id 不能为空"),
Self::MissingMessageText => f.write_str("big_fish.message_text 不能为空"),
Self::MissingDraft => f.write_str("big_fish.draft 尚未编译"),
Self::InvalidLevel => f.write_str("big_fish.level 不在合法等级范围内"),
Self::InvalidAssetKind => f.write_str("big_fish.asset_kind 或动作位非法"),
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
}
}
}
impl Error for BigFishFieldError {}

View File

@@ -0,0 +1,31 @@
//! 大鱼吃小鱼领域事件。
//!
//! 用于表达草稿变化、资产槽变化和运行态 tick 等事实。
/// 大鱼吃小鱼领域事件。
///
/// 事件只描述已经发生的领域事实,后续由 SpacetimeDB adapter 或 BFF
/// 决定是否持久化、投影或通知前端。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BigFishDomainEvent {
PublishReadinessEvaluated {
session_id: String,
owner_user_id: String,
publish_ready: bool,
blockers: Vec<String>,
occurred_at_micros: i64,
},
RuntimeRunStarted {
run_id: String,
session_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
RuntimeRunSettled {
run_id: String,
session_id: String,
owner_user_id: String,
status: String,
occurred_at_micros: i64,
},
}

View File

@@ -1,975 +1,11 @@
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use shared_kernel::normalize_required_string;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const BIG_FISH_SESSION_ID_PREFIX: &str = "big-fish-session-";
pub const BIG_FISH_MESSAGE_ID_PREFIX: &str = "big-fish-message-";
pub const BIG_FISH_OPERATION_ID_PREFIX: &str = "big-fish-operation-";
pub const BIG_FISH_ASSET_SLOT_ID_PREFIX: &str = "big-fish-asset-";
pub const PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID: &str = "public-big-fish-gallery";
pub const BIG_FISH_DEFAULT_LEVEL_COUNT: u32 = 8;
pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6;
pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12;
pub const BIG_FISH_MERGE_COUNT_PER_UPGRADE: u32 = 3;
pub const BIG_FISH_OFFSCREEN_CULL_SECONDS: f32 = 3.0;
pub const BIG_FISH_TARGET_WILD_COUNT: usize = 12;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishCreationStage {
CollectingAnchors,
DraftReady,
AssetRefining,
ReadyToPublish,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAnchorStatus {
Confirmed,
Inferred,
Missing,
Locked,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAgentMessageRole {
User,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAgentMessageKind {
Chat,
Summary,
ActionResult,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAssetKind {
LevelMainImage,
LevelMotion,
StageBackground,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BigFishAssetStatus {
Missing,
Ready,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAnchorItem {
pub key: String,
pub label: String,
pub value: String,
pub status: BigFishAnchorStatus,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAnchorPack {
pub gameplay_promise: BigFishAnchorItem,
pub ecology_visual_theme: BigFishAnchorItem,
pub growth_ladder: BigFishAnchorItem,
pub risk_tempo: BigFishAnchorItem,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishLevelBlueprint {
pub level: u32,
pub name: String,
pub one_line_fantasy: String,
pub text_description: String,
pub silhouette_direction: String,
pub size_ratio: f32,
pub visual_description: String,
pub visual_prompt_seed: String,
pub idle_motion_description: String,
pub move_motion_description: String,
pub motion_prompt_seed: String,
pub merge_source_level: Option<u32>,
pub prey_window: Vec<u32>,
pub threat_window: Vec<u32>,
pub is_final_level: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishBackgroundBlueprint {
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,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRuntimeParams {
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<u32>,
pub threat_spawn_delta_levels: Vec<u32>,
pub win_level: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishGameDraft {
pub title: String,
pub subtitle: String,
pub core_fun: String,
pub ecology_theme: String,
pub levels: Vec<BigFishLevelBlueprint>,
pub background: BigFishBackgroundBlueprint,
pub runtime_params: BigFishRuntimeParams,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAgentMessageSnapshot {
pub message_id: String,
pub session_id: String,
pub role: BigFishAgentMessageRole,
pub kind: BigFishAgentMessageKind,
pub text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetSlotSnapshot {
pub slot_id: String,
pub session_id: String,
pub asset_kind: BigFishAssetKind,
pub level: Option<u32>,
pub motion_key: Option<String>,
pub status: BigFishAssetStatus,
pub asset_url: Option<String>,
pub prompt_snapshot: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetCoverage {
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<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishSessionSnapshot {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub current_turn: u32,
pub progress_percent: u32,
pub stage: BigFishCreationStage,
pub anchor_pack: BigFishAnchorPack,
pub draft: Option<BigFishGameDraft>,
pub asset_slots: Vec<BigFishAssetSlotSnapshot>,
pub asset_coverage: BigFishAssetCoverage,
pub messages: Vec<BigFishAgentMessageSnapshot>,
pub last_assistant_reply: Option<String>,
pub publish_ready: bool,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishSessionProcedureResult {
pub ok: bool,
pub session: Option<BigFishSessionSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkSummarySnapshot {
pub work_id: String,
pub source_session_id: String,
pub owner_user_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
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,
pub play_count: u32,
pub remix_count: u32,
pub like_count: u32,
#[serde(default)]
pub recent_play_count_7d: u32,
pub published_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksListInput {
pub owner_user_id: String,
pub published_only: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkDeleteInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkRemixInput {
pub source_session_id: String,
pub target_session_id: String,
pub target_owner_user_id: String,
pub welcome_message_id: String,
pub remixed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishSessionCreateInput {
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,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishSessionGetInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishMessageSubmitInput {
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,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub stage: BigFishCreationStage,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishDraftCompileInput {
pub session_id: String,
pub owner_user_id: String,
pub draft_json: Option<String>,
pub compiled_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishAssetGenerateInput {
pub session_id: String,
pub owner_user_id: String,
pub asset_kind: BigFishAssetKind,
pub level: Option<u32>,
pub motion_key: Option<String>,
pub asset_url: Option<String>,
pub generated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishPublishInput {
pub session_id: String,
pub owner_user_id: String,
pub published_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishPlayRecordInput {
pub session_id: String,
pub user_id: String,
pub elapsed_ms: u64,
pub played_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkLikeRecordInput {
pub session_id: String,
pub user_id: String,
pub liked_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BigFishFieldError {
MissingSessionId,
MissingOwnerUserId,
MissingMessageId,
MissingMessageText,
MissingDraft,
InvalidLevel,
InvalidAssetKind,
}
impl BigFishCreationStage {
pub fn as_str(self) -> &'static str {
match self {
Self::CollectingAnchors => "collecting_anchors",
Self::DraftReady => "draft_ready",
Self::AssetRefining => "asset_refining",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
}
}
}
impl BigFishAnchorStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Confirmed => "confirmed",
Self::Inferred => "inferred",
Self::Missing => "missing",
Self::Locked => "locked",
}
}
}
impl BigFishAgentMessageRole {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
impl BigFishAgentMessageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Summary => "summary",
Self::ActionResult => "action_result",
Self::Warning => "warning",
}
}
}
impl BigFishAssetKind {
pub fn as_str(self) -> &'static str {
match self {
Self::LevelMainImage => "level_main_image",
Self::LevelMotion => "level_motion",
Self::StageBackground => "stage_background",
}
}
}
impl BigFishAssetStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Missing => "missing",
Self::Ready => "ready",
}
}
}
pub fn empty_anchor_pack() -> BigFishAnchorPack {
BigFishAnchorPack {
gameplay_promise: BigFishAnchorItem {
key: "gameplayPromise".to_string(),
label: "玩法承诺".to_string(),
value: String::new(),
status: BigFishAnchorStatus::Missing,
},
ecology_visual_theme: BigFishAnchorItem {
key: "ecologyVisualTheme".to_string(),
label: "生态与视觉母题".to_string(),
value: String::new(),
status: BigFishAnchorStatus::Missing,
},
growth_ladder: BigFishAnchorItem {
key: "growthLadder".to_string(),
label: "成长阶梯".to_string(),
value: String::new(),
status: BigFishAnchorStatus::Missing,
},
risk_tempo: BigFishAnchorItem {
key: "riskTempo".to_string(),
label: "风险节奏".to_string(),
value: "平衡".to_string(),
status: BigFishAnchorStatus::Inferred,
},
}
}
pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> BigFishAnchorPack {
let source = normalize_required_string(latest_message.unwrap_or(seed_text))
.or_else(|| normalize_required_string(seed_text))
.unwrap_or_else(|| "深海弱小逆袭,逐级吞噬成长".to_string());
let mut pack = empty_anchor_pack();
pack.gameplay_promise.value = if source.contains("可爱") {
"可爱生态成长".to_string()
} else if source.contains("机械") {
"机械微生物吞并进化".to_string()
} else {
"弱小逆袭和群体吞并".to_string()
};
pack.gameplay_promise.status = BigFishAnchorStatus::Inferred;
pack.ecology_visual_theme.value = if source.contains("机械") {
"机械微生物水域".to_string()
} else if source.contains("") {
"梦境纸鱼生态".to_string()
} else {
"深海生物生态".to_string()
};
pack.ecology_visual_theme.status = BigFishAnchorStatus::Inferred;
pack.growth_ladder.value = "8 级连续进化,从幼小个体成长为终局巨兽".to_string();
pack.growth_ladder.status = BigFishAnchorStatus::Inferred;
pack.risk_tempo.value = if source.contains("") {
"偏爽快".to_string()
} else if source.contains("压迫") {
"偏压迫".to_string()
} else {
"平衡".to_string()
};
pack
}
pub fn compile_default_draft(anchor_pack: &BigFishAnchorPack) -> BigFishGameDraft {
let level_count = BIG_FISH_DEFAULT_LEVEL_COUNT;
let theme = fallback_anchor_value(&anchor_pack.ecology_visual_theme, "深海生物生态");
let core_fun = fallback_anchor_value(&anchor_pack.gameplay_promise, "弱小逆袭和群体吞并");
let risk_tempo = fallback_anchor_value(&anchor_pack.risk_tempo, "平衡");
let levels = (1..=level_count)
.map(|level| build_level_blueprint(level, level_count, &theme))
.collect();
BigFishGameDraft {
title: format!("{theme} 大鱼吃小鱼"),
subtitle: format!("{core_fun} · {risk_tempo}节奏"),
core_fun,
ecology_theme: theme.clone(),
levels,
background: BigFishBackgroundBlueprint {
theme: theme.clone(),
color_mood: "深蓝、青绿、带少量暖色生物光".to_string(),
foreground_hints: "只保留少量漂浮颗粒和边缘水草,不遮挡中央操作区".to_string(),
midground_composition: "中央留出大面积清晰活动区域,边缘只做出生缓冲层".to_string(),
background_depth: "简洁纵深水域与极少量远处剪影".to_string(),
safe_play_area_hint: "9:16 竖屏中央 80% 为主要活动区".to_string(),
spawn_edge_hint: "四周边缘以少量暗礁或水草提示野生实体出生区".to_string(),
background_prompt_seed: format!(
"{theme},竖屏 9:16全屏大场地游戏背景元素少中央开阔无文字无 UI 框"
),
},
runtime_params: BigFishRuntimeParams {
level_count,
merge_count_per_upgrade: BIG_FISH_MERGE_COUNT_PER_UPGRADE,
spawn_target_count: BIG_FISH_TARGET_WILD_COUNT as u32,
leader_move_speed: 160.0,
follower_catch_up_speed: 120.0,
offscreen_cull_seconds: BIG_FISH_OFFSCREEN_CULL_SECONDS,
prey_spawn_delta_levels: vec![1, 2],
threat_spawn_delta_levels: vec![1, 2],
win_level: level_count,
},
}
}
pub fn build_asset_coverage(
draft: Option<&BigFishGameDraft>,
asset_slots: &[BigFishAssetSlotSnapshot],
) -> BigFishAssetCoverage {
let required_level_count = draft
.map(|value| value.runtime_params.level_count)
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT);
let main_ready = asset_slots
.iter()
.filter(|slot| {
slot.asset_kind == BigFishAssetKind::LevelMainImage
&& slot.status == BigFishAssetStatus::Ready
})
.count() as u32;
let motion_ready = asset_slots
.iter()
.filter(|slot| {
slot.asset_kind == BigFishAssetKind::LevelMotion
&& slot.status == BigFishAssetStatus::Ready
})
.count() as u32;
let background_ready = asset_slots.iter().any(|slot| {
slot.asset_kind == BigFishAssetKind::StageBackground
&& slot.status == BigFishAssetStatus::Ready
});
let required_motion_count = required_level_count * 2;
let mut blockers = Vec::new();
if draft.is_none() {
blockers.push("玩法草稿尚未编译".to_string());
}
if main_ready < required_level_count {
blockers.push(format!(
"还缺少 {} 个等级主图",
required_level_count.saturating_sub(main_ready)
));
}
if motion_ready < required_motion_count {
blockers.push(format!(
"还缺少 {} 个基础动作",
required_motion_count.saturating_sub(motion_ready)
));
}
if !background_ready {
blockers.push("还缺少活动区域背景图".to_string());
}
BigFishAssetCoverage {
level_main_image_ready_count: main_ready,
level_motion_ready_count: motion_ready,
background_ready,
required_level_count,
publish_ready: blockers.is_empty(),
blockers,
}
}
pub fn build_generated_asset_slot(
session_id: &str,
draft: &BigFishGameDraft,
asset_kind: BigFishAssetKind,
level: Option<u32>,
motion_key: Option<String>,
asset_url: Option<String>,
updated_at_micros: i64,
) -> Result<BigFishAssetSlotSnapshot, BigFishFieldError> {
let session_id =
normalize_required_string(session_id).ok_or(BigFishFieldError::MissingSessionId)?;
let prompt_snapshot =
build_asset_prompt_snapshot(draft, asset_kind, level, motion_key.as_deref())?;
let slot_id = build_asset_slot_id(&session_id, asset_kind, level, motion_key.as_deref());
let resolved_asset_url = normalize_required_string(asset_url.as_deref().unwrap_or_default())
.unwrap_or_else(|| build_placeholder_asset_url(asset_kind, level, updated_at_micros));
Ok(BigFishAssetSlotSnapshot {
slot_id,
session_id,
asset_kind,
level,
motion_key,
status: BigFishAssetStatus::Ready,
asset_url: Some(resolved_asset_url),
prompt_snapshot,
updated_at_micros,
})
}
pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> {
if input.published_only {
return Ok(());
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_session_create_input(
input: &BigFishSessionCreateInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.welcome_message_id).is_none() {
return Err(BigFishFieldError::MissingMessageId);
}
Ok(())
}
pub fn validate_message_submit_input(
input: &BigFishMessageSubmitInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.user_message_id).is_none()
|| normalize_required_string(&input.assistant_message_id).is_none()
{
return Err(BigFishFieldError::MissingMessageId);
}
if normalize_required_string(&input.user_message_text).is_none() {
return Err(BigFishFieldError::MissingMessageText);
}
Ok(())
}
pub fn validate_message_finalize_input(
input: &BigFishMessageFinalizeInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_draft_compile_input(
input: &BigFishDraftCompileInput,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_asset_generate_input(
input: &BigFishAssetGenerateInput,
draft: &BigFishGameDraft,
) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)?;
match input.asset_kind {
BigFishAssetKind::LevelMainImage => validate_level(input.level, draft),
BigFishAssetKind::LevelMotion => {
validate_level(input.level, draft)?;
match input.motion_key.as_deref() {
Some("idle_float" | "move_swim") => Ok(()),
_ => Err(BigFishFieldError::InvalidAssetKind),
}
}
BigFishAssetKind::StageBackground => Ok(()),
}
}
pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFishFieldError> {
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.session_id).is_none() {
return Err(BigFishFieldError::MissingSessionId);
}
if normalize_required_string(&input.user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
serde_json::to_string(anchor_pack)
}
pub fn deserialize_anchor_pack(value: &str) -> Result<BigFishAnchorPack, serde_json::Error> {
serde_json::from_str(value)
}
pub fn serialize_draft(draft: &BigFishGameDraft) -> Result<String, serde_json::Error> {
serde_json::to_string(draft)
}
pub fn deserialize_draft(value: &str) -> Result<BigFishGameDraft, serde_json::Error> {
serde_json::from_str(value)
}
pub fn serialize_asset_coverage(
coverage: &BigFishAssetCoverage,
) -> Result<String, serde_json::Error> {
serde_json::to_string(coverage)
}
pub fn deserialize_asset_coverage(value: &str) -> Result<BigFishAssetCoverage, serde_json::Error> {
serde_json::from_str(value)
}
fn fallback_anchor_value(anchor: &BigFishAnchorItem, fallback: &str) -> String {
normalize_required_string(&anchor.value).unwrap_or_else(|| fallback.to_string())
}
fn build_level_blueprint(level: u32, level_count: u32, theme: &str) -> BigFishLevelBlueprint {
let prey_window = (1..level)
.rev()
.take(2)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let threat_window = ((level + 1)..=(level + 2).min(level_count)).collect::<Vec<_>>();
let size_ratio = 1.0 + (level.saturating_sub(1) as f32 * 0.22);
let name = format!("{theme} L{level}");
let one_line_fantasy = if level == level_count {
"终局巨兽形态,获得即可通关".to_string()
} else {
format!("{level} 阶实体,继续吞噬同级和低级个体成长")
};
let text_description = if level == 1 {
format!(
"{name} 是这套 {theme} 等级阶梯的起点个体,体型最小、动作轻盈,会在谨慎试探中寻找第一个可吞噬目标。"
)
} else if level == level_count {
format!(
"{name} 是这套 {theme} 生态中的终局霸主形态,体格巨大、压迫感最强,一旦成型就代表本局成长链已经完成。"
)
} else {
format!(
"{name}{theme} 生态里的第 {level} 阶进化体,已经具备更鲜明的轮廓、猎食性和压迫感,会继续通过吞并同级与低级实体向上跃迁。"
)
};
let visual_description = if level == 1 {
format!(
"{theme} 风格的小型初始鱼形生物,体态轻巧,轮廓圆润,局部带少量发光纹路或主题特征,明显呈现弱小但灵动的开局形象。"
)
} else if level == level_count {
format!(
"{theme} 风格的终局巨型鱼形霸主,体长与鳍面明显扩张,轮廓锋利或威严,层次细节最丰富,拥有一眼可辨识的终局统治感。"
)
} else {
format!(
"{theme} 风格的第 {level} 级进化鱼形生物,相比上一阶段更大、更强、更成熟,身体主轮廓更清晰,局部装饰、鳍面结构和主题特征都更明显。"
)
};
let idle_motion_description = if level == level_count {
"待机时缓慢悬停,身体主体保持稳定,尾鳍与侧鳍做低频摆动,呈现强者从容压场的漂浮感。"
.to_string()
} else {
format!(
"待机时保持轻微漂浮与呼吸感摆动,尾鳍和侧鳍以小幅度节奏晃动,体现 Lv.{level} 生物在水中蓄势观察的状态。"
)
};
let move_motion_description = if level == level_count {
"移动时身体前倾,尾鳍和背鳍形成强力推进姿态,带出稳定而有压迫感的高速巡游动势。".to_string()
} else {
format!(
"移动时身体向前游动,尾鳍形成清晰摆尾推进,整体节奏比待机更主动,体现 Lv.{level} 生物追逐猎物时的连续游动感。"
)
};
BigFishLevelBlueprint {
level,
name,
one_line_fantasy,
text_description,
silhouette_direction: format!(
"体型约为初始的 {:.1} 倍,轮廓更清晰",
1.0 + level as f32 * 0.22
),
size_ratio,
visual_description: visual_description.clone(),
visual_prompt_seed: format!(
"{visual_description} 透明背景,单体完整入镜,适合作为竖屏吞噬成长玩法的等级主图。"
),
idle_motion_description: idle_motion_description.clone(),
move_motion_description: move_motion_description.clone(),
motion_prompt_seed: format!(
"待机动作:{idle_motion_description} 移动动作:{move_motion_description}"
),
merge_source_level: if level == 1 { None } else { Some(level - 1) },
prey_window,
threat_window,
is_final_level: level == level_count,
}
}
fn build_asset_prompt_snapshot(
draft: &BigFishGameDraft,
asset_kind: BigFishAssetKind,
level: Option<u32>,
motion_key: Option<&str>,
) -> Result<String, BigFishFieldError> {
match asset_kind {
BigFishAssetKind::LevelMainImage => {
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
let blueprint = draft
.levels
.iter()
.find(|item| item.level == level)
.ok_or(BigFishFieldError::InvalidLevel)?;
Ok(blueprint.visual_prompt_seed.clone())
}
BigFishAssetKind::LevelMotion => {
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
let blueprint = draft
.levels
.iter()
.find(|item| item.level == level)
.ok_or(BigFishFieldError::InvalidLevel)?;
let motion_key = motion_key.ok_or(BigFishFieldError::InvalidAssetKind)?;
let motion_description = match motion_key {
"idle_float" => blueprint.idle_motion_description.as_str(),
"move_swim" => blueprint.move_motion_description.as_str(),
_ => return Err(BigFishFieldError::InvalidAssetKind),
};
Ok(format!(
"{} 动作位:{}{} 透明背景,单体完整入镜。",
blueprint.motion_prompt_seed, motion_key, motion_description
))
}
BigFishAssetKind::StageBackground => Ok(draft.background.background_prompt_seed.clone()),
}
}
fn build_asset_slot_id(
session_id: &str,
asset_kind: BigFishAssetKind,
level: Option<u32>,
motion_key: Option<&str>,
) -> String {
let level_part = level
.map(|value| value.to_string())
.unwrap_or_else(|| "stage".to_string());
let motion_part = motion_key.unwrap_or("main");
format!(
"{BIG_FISH_ASSET_SLOT_ID_PREFIX}{session_id}_{}_{}_{}",
asset_kind.as_str(),
level_part,
motion_part
)
}
fn build_placeholder_asset_url(
asset_kind: BigFishAssetKind,
level: Option<u32>,
seed_micros: i64,
) -> String {
let level_part = level
.map(|value| format!("level-{value}"))
.unwrap_or_else(|| "stage".to_string());
format!(
"/generated-big-fish/{}/{}/{}.png",
asset_kind.as_str(),
level_part,
seed_micros
)
}
fn validate_session_owner(session_id: &str, owner_user_id: &str) -> Result<(), BigFishFieldError> {
if normalize_required_string(session_id).is_none() {
return Err(BigFishFieldError::MissingSessionId);
}
if normalize_required_string(owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
fn validate_level(level: Option<u32>, draft: &BigFishGameDraft) -> Result<(), BigFishFieldError> {
match level {
Some(value) if (1..=draft.runtime_params.level_count).contains(&value) => Ok(()),
_ => Err(BigFishFieldError::InvalidLevel),
}
}
impl fmt::Display for BigFishFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingSessionId => f.write_str("big_fish.session_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("big_fish.owner_user_id 不能为空"),
Self::MissingMessageId => f.write_str("big_fish.message_id 不能为空"),
Self::MissingMessageText => f.write_str("big_fish.message_text 不能为空"),
Self::MissingDraft => f.write_str("big_fish.draft 尚未编译"),
Self::InvalidLevel => f.write_str("big_fish.level 不在合法等级范围内"),
Self::InvalidAssetKind => f.write_str("big_fish.asset_kind 或动作位非法"),
}
}
}
impl Error for BigFishFieldError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_draft_compiles_eight_levels_with_fixed_runtime_params() {
let draft = compile_default_draft(&infer_anchor_pack("机械深海,节奏偏爽", None));
assert_eq!(draft.levels.len(), BIG_FISH_DEFAULT_LEVEL_COUNT as usize);
assert_eq!(draft.runtime_params.merge_count_per_upgrade, 3);
assert_eq!(draft.runtime_params.offscreen_cull_seconds, 3.0);
assert_eq!(draft.runtime_params.prey_spawn_delta_levels, vec![1, 2]);
assert_eq!(draft.runtime_params.threat_spawn_delta_levels, vec![1, 2]);
assert!(
draft
.levels
.last()
.is_some_and(|level| level.is_final_level)
);
}
#[test]
fn asset_coverage_requires_main_images_two_motions_and_background() {
let draft = compile_default_draft(&infer_anchor_pack("深海", None));
let coverage = build_asset_coverage(Some(&draft), &[]);
assert!(!coverage.publish_ready);
assert_eq!(coverage.required_level_count, 8);
assert!(
coverage
.blockers
.iter()
.any(|item| item.contains("等级主图"))
);
assert!(
coverage
.blockers
.iter()
.any(|item| item.contains("基础动作"))
);
assert!(coverage.blockers.iter().any(|item| item.contains("背景图")));
}
#[test]
fn public_big_fish_gallery_owner_placeholder_is_non_empty() {
assert_eq!(
PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID,
"public-big-fish-gallery"
);
assert!(!PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID.trim().is_empty());
}
}
mod application;
mod commands;
mod domain;
mod errors;
mod events;
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;

View File

@@ -6,7 +6,7 @@ license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
spacetime-types = ["dep:spacetimedb", "module-runtime-item/spacetime-types"]
[dependencies]
module-runtime-item = { path = "../module-runtime-item", default-features = false }

View File

@@ -15,7 +15,7 @@
当前已经真实落地:
1. `BattleMode / BattleStatus / CombatOutcome`
1. `src/domain.rs` 承接战斗 ID 前缀、版本、伤害、切磋保底生命、旧攻击 function 列表和 `BattleMode / BattleStatus / CombatOutcome`
2. `BattleStateInput / BattleStateSnapshot / BattleStateQueryInput`
3. `ResolveCombatActionInput / ResolveCombatActionResult`
4. `BattleStateProcedureResult / ResolveCombatActionProcedureResult`
@@ -34,11 +34,12 @@
落地依据见:
1. [../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md)
2. [../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md)
3. [../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md)
4. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md)
5. [../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md)
1. [../../../docs/technical/SERVER_RS_DDD_WP_RPG_COMBAT_DOMAIN_ENUM_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_RPG_COMBAT_DOMAIN_ENUM_REHOME_2026-04-29.md)
2. [../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md)
3. [../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md)
4. [../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md)
5. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md)
6. [../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md)
## 4. 边界约束

View File

@@ -0,0 +1,291 @@
//! 战斗应用编排。
//!
//! 这里只返回结算结果与待处理事件,不直接写入其他上下文表。
use crate::commands::{
BattleStateInput, ResolveCombatActionInput, validate_resolve_combat_action_input,
};
use crate::domain::{
BASIC_FIGHT_COUNTER_RATIO, BATTLE_STATE_ID_PREFIX, BattleMode, BattleStateSnapshot,
BattleStatus, CombatOutcome, INITIAL_BATTLE_VERSION, MIN_FIGHT_COUNTER_DAMAGE, SPAR_MIN_HP,
};
use crate::errors::CombatFieldError;
use serde::{Deserialize, Serialize};
use shared_kernel::{build_prefixed_seed_id, normalize_required_string};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionResult {
pub snapshot: BattleStateSnapshot,
pub damage_dealt: i32,
pub damage_taken: i32,
pub outcome: CombatOutcome,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateProcedureResult {
pub ok: bool,
pub snapshot: Option<BattleStateSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionProcedureResult {
pub ok: bool,
pub result: Option<ResolveCombatActionResult>,
pub error_message: Option<String>,
}
pub fn build_battle_state_snapshot(input: BattleStateInput) -> BattleStateSnapshot {
BattleStateSnapshot {
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: input.battle_mode,
status: BattleStatus::Ongoing,
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,
turn_index: 0,
last_action_function_id: None,
last_action_text: None,
last_result_text: None,
last_damage_dealt: 0,
last_damage_taken: 0,
last_outcome: CombatOutcome::Ongoing,
version: INITIAL_BATTLE_VERSION,
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
}
}
pub fn resolve_combat_action(
current: BattleStateSnapshot,
input: ResolveCombatActionInput,
) -> Result<ResolveCombatActionResult, CombatFieldError> {
validate_resolve_combat_action_input(&input)?;
if current.version == 0 {
return Err(CombatFieldError::InvalidVersion);
}
if current.status != BattleStatus::Ongoing {
return Err(CombatFieldError::BattleAlreadyResolved);
}
if current.player_mana < input.mana_cost.max(0) {
return Err(CombatFieldError::InsufficientMana);
}
let action_text = if input.action_text.trim().is_empty() {
input.function_id.clone()
} else {
normalize_required_string(input.action_text).unwrap_or_else(|| input.function_id.clone())
};
if input.function_id == "battle_escape_breakout" {
let next = BattleStateSnapshot {
status: BattleStatus::Resolved,
turn_index: current.turn_index + 1,
last_action_function_id: Some(input.function_id),
last_action_text: Some(action_text),
last_result_text: Some(format!("你抓住空当摆脱了{}的压制。", current.target_name)),
last_damage_dealt: 0,
last_damage_taken: 0,
last_outcome: CombatOutcome::Escaped,
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
return Ok(ResolveCombatActionResult {
snapshot: next,
damage_dealt: 0,
damage_taken: 0,
outcome: CombatOutcome::Escaped,
});
}
let mana_cost = input.mana_cost.max(0);
let heal = input.heal.max(0);
let mana_restore = input.mana_restore.max(0);
let base_damage = input.base_damage.max(0);
let mut next_player_hp = current.player_hp;
let mut next_player_mana = (current.player_mana - mana_cost).max(0);
let mut next_target_hp = current.target_hp;
let mut damage_dealt = 0;
let mut damage_taken = 0;
next_player_hp = clamp_hp(
current.battle_mode,
next_player_hp + heal,
current.player_max_hp,
);
next_player_mana = clamp_mana(next_player_mana + mana_restore, current.player_max_mana);
if base_damage > 0 {
next_target_hp =
clamp_target_hp_after_damage(current.battle_mode, current.target_hp, base_damage);
damage_dealt = current.target_hp - next_target_hp;
}
let (status, outcome, result_text) = if is_target_resolved(current.battle_mode, next_target_hp)
{
let outcome = match current.battle_mode {
BattleMode::Fight => CombatOutcome::Victory,
BattleMode::Spar => CombatOutcome::SparComplete,
};
(
BattleStatus::Resolved,
outcome,
build_resolved_result_text(&action_text, &current.target_name, outcome),
)
} else {
damage_taken = compute_counter_damage(
current.battle_mode,
current.target_max_hp,
input.counter_multiplier_basis_points,
);
next_player_hp = clamp_hp(
current.battle_mode,
next_player_hp - damage_taken,
current.player_max_hp,
);
(
BattleStatus::Ongoing,
CombatOutcome::Ongoing,
build_ongoing_result_text(&input.function_id, &action_text, &current.target_name),
)
};
let next = BattleStateSnapshot {
player_hp: next_player_hp,
player_mana: next_player_mana,
target_hp: next_target_hp,
status,
turn_index: current.turn_index + 1,
last_action_function_id: Some(input.function_id),
last_action_text: Some(action_text),
last_result_text: Some(result_text),
last_damage_dealt: damage_dealt,
last_damage_taken: damage_taken,
last_outcome: outcome,
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
Ok(ResolveCombatActionResult {
snapshot: next,
damage_dealt,
damage_taken,
outcome,
})
}
pub fn generate_battle_state_id(seed_micros: i64) -> String {
build_prefixed_seed_id(BATTLE_STATE_ID_PREFIX, seed_micros)
}
fn clamp_hp(mode: BattleMode, value: i32, max_hp: i32) -> i32 {
let min_hp = match mode {
BattleMode::Fight => 0,
BattleMode::Spar => SPAR_MIN_HP,
};
value.clamp(min_hp, max_hp)
}
fn clamp_mana(value: i32, max_mana: i32) -> i32 {
value.clamp(0, max_mana)
}
fn clamp_target_hp_after_damage(mode: BattleMode, current_hp: i32, damage: i32) -> i32 {
match mode {
BattleMode::Fight => (current_hp - damage).max(0),
BattleMode::Spar => (current_hp - damage).max(SPAR_MIN_HP),
}
}
fn is_target_resolved(mode: BattleMode, target_hp: i32) -> bool {
match mode {
BattleMode::Fight => target_hp <= 0,
BattleMode::Spar => target_hp <= SPAR_MIN_HP,
}
}
fn compute_counter_damage(
mode: BattleMode,
target_max_hp: i32,
counter_multiplier_basis_points: u32,
) -> i32 {
match mode {
BattleMode::Spar => 1,
BattleMode::Fight => {
let multiplier = counter_multiplier_basis_points as f32 / 10_000.0;
let raw =
(target_max_hp as f32 * BASIC_FIGHT_COUNTER_RATIO * multiplier).round() as i32;
raw.max(MIN_FIGHT_COUNTER_DAMAGE)
}
}
}
fn build_resolved_result_text(
action_text: &str,
target_name: &str,
outcome: CombatOutcome,
) -> String {
match outcome {
CombatOutcome::Victory => {
format!(
"{}命中了{},这轮战斗已经正式结束。",
action_text, target_name
)
}
CombatOutcome::SparComplete => {
format!(
"{}压住了{}的节奏,这场切磋已经分出高下。",
action_text, target_name
)
}
CombatOutcome::Escaped => {
format!("{}后你成功脱离了当前战斗。", action_text)
}
CombatOutcome::Ongoing => format!("{}已完成结算。", action_text),
}
}
fn build_ongoing_result_text(function_id: &str, action_text: &str, target_name: &str) -> String {
match function_id {
"battle_recover_breath" => {
format!(
"你先把伤势和气息稳住了一轮,但{}仍在持续逼近。",
target_name
)
}
"battle_use_skill" => {
format!(
"{}命中了{},这一轮技能效果已经直接结算。",
action_text, target_name
)
}
_ => format!(
"{}命中了{},本次攻击已经完成结算。",
action_text, target_name
),
}
}

View File

@@ -0,0 +1,170 @@
//! 战斗写入命令。
//!
//! 用于表达创建战斗、使用技能、逃离和结算等输入,不直接携带表行类型。
use crate::domain::{BattleMode, LEGACY_ATTACK_FUNCTION_IDS};
use crate::errors::CombatFieldError;
use module_runtime_item::{
RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot,
};
use serde::{Deserialize, Serialize};
use shared_kernel::normalize_required_string;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateInput {
pub battle_state_id: String,
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: BattleMode,
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<RuntimeItemRewardItemSnapshot>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionInput {
pub battle_state_id: String,
pub function_id: String,
pub action_text: String,
pub base_damage: i32,
pub mana_cost: i32,
pub heal: i32,
pub mana_restore: i32,
pub counter_multiplier_basis_points: u32,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateQueryInput {
pub battle_state_id: String,
}
pub fn validate_battle_state_input(input: &BattleStateInput) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
if normalize_required_string(&input.story_session_id).is_none() {
return Err(CombatFieldError::MissingStorySessionId);
}
if normalize_required_string(&input.runtime_session_id).is_none() {
return Err(CombatFieldError::MissingRuntimeSessionId);
}
if normalize_required_string(&input.actor_user_id).is_none() {
return Err(CombatFieldError::MissingActorUserId);
}
if normalize_required_string(&input.target_npc_id).is_none() {
return Err(CombatFieldError::MissingTargetNpcId);
}
if normalize_required_string(&input.target_name).is_none() {
return Err(CombatFieldError::MissingTargetName);
}
if input.player_max_hp <= 0 || input.player_hp <= 0 || input.player_hp > input.player_max_hp {
return Err(CombatFieldError::InvalidPlayerVitals);
}
if input.player_max_mana < 0
|| input.player_mana < 0
|| input.player_mana > input.player_max_mana
{
return Err(CombatFieldError::InvalidPlayerVitals);
}
if input.target_max_hp <= 0 || input.target_hp <= 0 || input.target_hp > input.target_max_hp {
return Err(CombatFieldError::InvalidTargetVitals);
}
for reward_item in input.reward_items.iter().cloned() {
normalize_reward_item_snapshot(reward_item).map_err(map_reward_item_field_error)?;
}
Ok(())
}
pub fn validate_resolve_combat_action_input(
input: &ResolveCombatActionInput,
) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
if normalize_required_string(&input.function_id).is_none() {
return Err(CombatFieldError::MissingFunctionId);
}
if !is_supported_combat_function_id(&input.function_id) {
return Err(CombatFieldError::UnsupportedFunctionId);
}
Ok(())
}
pub fn build_battle_state_query_input(
battle_state_id: String,
) -> Result<BattleStateQueryInput, CombatFieldError> {
let input = BattleStateQueryInput {
battle_state_id: normalize_required_string(battle_state_id).unwrap_or_default(),
};
validate_battle_state_query_input(&input)?;
Ok(input)
}
pub fn validate_battle_state_query_input(
input: &BattleStateQueryInput,
) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
Ok(())
}
pub fn is_supported_combat_function_id(function_id: &str) -> bool {
matches!(
function_id,
"battle_attack_basic"
| "battle_recover_breath"
| "battle_use_skill"
| "battle_escape_breakout"
) || LEGACY_ATTACK_FUNCTION_IDS.contains(&function_id)
}
fn map_reward_item_field_error(error: TreasureFieldError) -> CombatFieldError {
let message = match error {
TreasureFieldError::MissingRewardItemId => {
"battle_state.reward_items[].item_id 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemCategory => {
"battle_state.reward_items[].category 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemName => {
"battle_state.reward_items[].item_name 不能为空".to_string()
}
TreasureFieldError::InvalidRewardItemQuantity => {
"battle_state.reward_items[].quantity 必须大于 0".to_string()
}
TreasureFieldError::MissingRewardItemStackKey => {
"battle_state.reward_items[].stack_key 不能为空".to_string()
}
TreasureFieldError::RewardEquipmentItemCannotStack => {
"battle_state.reward_items[] 可装备物品不能标记为 stackable".to_string()
}
TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity => {
"battle_state.reward_items[] 不可堆叠物品必须固定为单槽位单数量".to_string()
}
other => other.to_string(),
};
CombatFieldError::InvalidRewardItem(message)
}

View File

@@ -0,0 +1,118 @@
//! 战斗领域模型。
//!
//! 本文件只承载战斗聚合内部状态和值对象;背包奖励、成长记账和任务联动由
//! SpacetimeDB 事务 adapter 编排,不在战斗领域内直连其他上下文。
use module_runtime_item::RuntimeItemRewardItemSnapshot;
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
/// 战斗状态 ID 的稳定前缀,由领域层统一持有,避免应用层重复拼接规则。
pub const BATTLE_STATE_ID_PREFIX: &str = "battle_";
/// 新建战斗状态的初始版本号,用于乐观更新和快照投影。
pub const INITIAL_BATTLE_VERSION: u32 = 1;
/// 普通战斗中敌方反击伤害占玩家输出的比例。
pub const BASIC_FIGHT_COUNTER_RATIO: f32 = 0.14;
/// 普通战斗中敌方反击的最低伤害,保证战斗有稳定消耗。
pub const MIN_FIGHT_COUNTER_DAMAGE: i32 = 4;
/// 切磋模式保底生命值,避免非生死战把玩家扣到 0。
pub const SPAR_MIN_HP: i32 = 1;
/// 旧版战斗动作 function id 白名单,仍由结算规则用于识别攻击类动作。
pub(crate) const LEGACY_ATTACK_FUNCTION_IDS: [&str; 5] = [
"battle_all_in_crush",
"battle_guard_break",
"battle_probe_pressure",
"battle_feint_step",
"battle_finisher_window",
];
/// 战斗模式,决定结算时是否允许击败玩家。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BattleMode {
Fight,
Spar,
}
impl BattleMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Fight => "fight",
Self::Spar => "spar",
}
}
}
/// 战斗状态,用于标记战斗是否仍可继续接收行动。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BattleStatus {
Ongoing,
Resolved,
Aborted,
}
impl BattleStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Resolved => "resolved",
Self::Aborted => "aborted",
}
}
}
/// 单次战斗行动结算后的领域结果。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CombatOutcome {
Ongoing,
Victory,
SparComplete,
Escaped,
}
impl CombatOutcome {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Victory => "victory",
Self::SparComplete => "spar_complete",
Self::Escaped => "escaped",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateSnapshot {
pub battle_state_id: String,
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: BattleMode,
pub status: BattleStatus,
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<RuntimeItemRewardItemSnapshot>,
pub turn_index: u32,
pub last_action_function_id: Option<String>,
pub last_action_text: Option<String>,
pub last_result_text: Option<String>,
pub last_damage_dealt: i32,
pub last_damage_taken: i32,
pub last_outcome: CombatOutcome,
pub version: u32,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}

View File

@@ -0,0 +1,50 @@
//! 战斗领域错误。
//!
//! 错误保持纯领域语义,不能绑定 HTTP 状态码或 SpacetimeDB 字符串格式。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CombatFieldError {
MissingBattleStateId,
MissingStorySessionId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingTargetNpcId,
MissingTargetName,
MissingFunctionId,
InvalidVersion,
InvalidPlayerVitals,
InvalidTargetVitals,
InvalidRewardItem(String),
BattleAlreadyResolved,
UnsupportedFunctionId,
InsufficientMana,
}
impl fmt::Display for CombatFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBattleStateId => f.write_str("battle_state.battle_state_id 不能为空"),
Self::MissingStorySessionId => f.write_str("battle_state.story_session_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("battle_state.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("battle_state.actor_user_id 不能为空"),
Self::MissingTargetNpcId => f.write_str("battle_state.target_npc_id 不能为空"),
Self::MissingTargetName => f.write_str("battle_state.target_name 不能为空"),
Self::MissingFunctionId => f.write_str("resolve_combat_action.function_id 不能为空"),
Self::InvalidVersion => f.write_str("battle_state.version 必须大于 0"),
Self::InvalidPlayerVitals => f.write_str("battle_state 玩家生命或灵力字段不合法"),
Self::InvalidTargetVitals => f.write_str("battle_state 目标生命字段不合法"),
Self::InvalidRewardItem(message) => f.write_str(message),
Self::BattleAlreadyResolved => f.write_str("battle_state 已经结束,不能继续结算"),
Self::UnsupportedFunctionId => {
f.write_str("resolve_combat_action.function_id 当前不受支持")
}
Self::InsufficientMana => f.write_str("当前灵力不足,无法执行该战斗动作"),
}
}
}
impl Error for CombatFieldError {}

View File

@@ -0,0 +1,34 @@
//! 战斗领域事件。
//!
//! 用于表达战斗胜利、切磋完成、奖励待发放和战斗被终止等事实。
use crate::domain::CombatOutcome;
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CombatDomainEvent {
BattleActionResolved(CombatBattleActionResolvedEvent),
BattleRewardPending(CombatBattleRewardPendingEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CombatBattleActionResolvedEvent {
pub battle_state_id: String,
pub outcome: CombatOutcome,
pub damage_dealt: i32,
pub damage_taken: i32,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CombatBattleRewardPendingEvent {
pub battle_state_id: String,
pub actor_user_id: String,
pub experience_reward: u32,
pub occurred_at_micros: i64,
}

View File

@@ -1,593 +1,19 @@
use std::{error::Error, fmt};
mod application;
mod commands;
mod domain;
mod errors;
mod events;
use module_runtime_item::{
RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot,
};
use serde::{Deserialize, Serialize};
use shared_kernel::{build_prefixed_seed_id, normalize_required_string};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const BATTLE_STATE_ID_PREFIX: &str = "battle_";
pub const INITIAL_BATTLE_VERSION: u32 = 1;
pub const BASIC_FIGHT_COUNTER_RATIO: f32 = 0.14;
pub const MIN_FIGHT_COUNTER_DAMAGE: i32 = 4;
pub const SPAR_MIN_HP: i32 = 1;
const LEGACY_ATTACK_FUNCTION_IDS: [&str; 5] = [
"battle_all_in_crush",
"battle_guard_break",
"battle_probe_pressure",
"battle_feint_step",
"battle_finisher_window",
];
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BattleMode {
Fight,
Spar,
}
impl BattleMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Fight => "fight",
Self::Spar => "spar",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BattleStatus {
Ongoing,
Resolved,
Aborted,
}
impl BattleStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Resolved => "resolved",
Self::Aborted => "aborted",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CombatOutcome {
Ongoing,
Victory,
SparComplete,
Escaped,
}
impl CombatOutcome {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Victory => "victory",
Self::SparComplete => "spar_complete",
Self::Escaped => "escaped",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CombatFieldError {
MissingBattleStateId,
MissingStorySessionId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingTargetNpcId,
MissingTargetName,
MissingFunctionId,
InvalidVersion,
InvalidPlayerVitals,
InvalidTargetVitals,
InvalidRewardItem(String),
BattleAlreadyResolved,
UnsupportedFunctionId,
InsufficientMana,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateInput {
pub battle_state_id: String,
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: BattleMode,
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<RuntimeItemRewardItemSnapshot>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateSnapshot {
pub battle_state_id: String,
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub chapter_id: Option<String>,
pub target_npc_id: String,
pub target_name: String,
pub battle_mode: BattleMode,
pub status: BattleStatus,
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<RuntimeItemRewardItemSnapshot>,
pub turn_index: u32,
pub last_action_function_id: Option<String>,
pub last_action_text: Option<String>,
pub last_result_text: Option<String>,
pub last_damage_dealt: i32,
pub last_damage_taken: i32,
pub last_outcome: CombatOutcome,
pub version: u32,
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 ResolveCombatActionInput {
pub battle_state_id: String,
pub function_id: String,
pub action_text: String,
pub base_damage: i32,
pub mana_cost: i32,
pub heal: i32,
pub mana_restore: i32,
pub counter_multiplier_basis_points: u32,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateQueryInput {
pub battle_state_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionResult {
pub snapshot: BattleStateSnapshot,
pub damage_dealt: i32,
pub damage_taken: i32,
pub outcome: CombatOutcome,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleStateProcedureResult {
pub ok: bool,
pub snapshot: Option<BattleStateSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveCombatActionProcedureResult {
pub ok: bool,
pub result: Option<ResolveCombatActionResult>,
pub error_message: Option<String>,
}
pub fn validate_battle_state_input(input: &BattleStateInput) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
if normalize_required_string(&input.story_session_id).is_none() {
return Err(CombatFieldError::MissingStorySessionId);
}
if normalize_required_string(&input.runtime_session_id).is_none() {
return Err(CombatFieldError::MissingRuntimeSessionId);
}
if normalize_required_string(&input.actor_user_id).is_none() {
return Err(CombatFieldError::MissingActorUserId);
}
if normalize_required_string(&input.target_npc_id).is_none() {
return Err(CombatFieldError::MissingTargetNpcId);
}
if normalize_required_string(&input.target_name).is_none() {
return Err(CombatFieldError::MissingTargetName);
}
if input.player_max_hp <= 0 || input.player_hp <= 0 || input.player_hp > input.player_max_hp {
return Err(CombatFieldError::InvalidPlayerVitals);
}
if input.player_max_mana < 0
|| input.player_mana < 0
|| input.player_mana > input.player_max_mana
{
return Err(CombatFieldError::InvalidPlayerVitals);
}
if input.target_max_hp <= 0 || input.target_hp <= 0 || input.target_hp > input.target_max_hp {
return Err(CombatFieldError::InvalidTargetVitals);
}
for reward_item in input.reward_items.iter().cloned() {
normalize_reward_item_snapshot(reward_item).map_err(map_reward_item_field_error)?;
}
Ok(())
}
pub fn validate_resolve_combat_action_input(
input: &ResolveCombatActionInput,
) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
if normalize_required_string(&input.function_id).is_none() {
return Err(CombatFieldError::MissingFunctionId);
}
if !is_supported_combat_function_id(&input.function_id) {
return Err(CombatFieldError::UnsupportedFunctionId);
}
Ok(())
}
pub fn build_battle_state_query_input(
battle_state_id: String,
) -> Result<BattleStateQueryInput, CombatFieldError> {
let input = BattleStateQueryInput {
battle_state_id: normalize_required_string(battle_state_id).unwrap_or_default(),
};
validate_battle_state_query_input(&input)?;
Ok(input)
}
pub fn validate_battle_state_query_input(
input: &BattleStateQueryInput,
) -> Result<(), CombatFieldError> {
if normalize_required_string(&input.battle_state_id).is_none() {
return Err(CombatFieldError::MissingBattleStateId);
}
Ok(())
}
pub fn build_battle_state_snapshot(input: BattleStateInput) -> BattleStateSnapshot {
BattleStateSnapshot {
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: input.battle_mode,
status: BattleStatus::Ongoing,
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,
turn_index: 0,
last_action_function_id: None,
last_action_text: None,
last_result_text: None,
last_damage_dealt: 0,
last_damage_taken: 0,
last_outcome: CombatOutcome::Ongoing,
version: INITIAL_BATTLE_VERSION,
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
}
}
pub fn resolve_combat_action(
current: BattleStateSnapshot,
input: ResolveCombatActionInput,
) -> Result<ResolveCombatActionResult, CombatFieldError> {
validate_resolve_combat_action_input(&input)?;
if current.version == 0 {
return Err(CombatFieldError::InvalidVersion);
}
if current.status != BattleStatus::Ongoing {
return Err(CombatFieldError::BattleAlreadyResolved);
}
if current.player_mana < input.mana_cost.max(0) {
return Err(CombatFieldError::InsufficientMana);
}
let action_text = if input.action_text.trim().is_empty() {
input.function_id.clone()
} else {
normalize_required_string(input.action_text).unwrap_or_else(|| input.function_id.clone())
};
if input.function_id == "battle_escape_breakout" {
let next = BattleStateSnapshot {
status: BattleStatus::Resolved,
turn_index: current.turn_index + 1,
last_action_function_id: Some(input.function_id),
last_action_text: Some(action_text),
last_result_text: Some(format!("你抓住空当摆脱了{}的压制。", current.target_name)),
last_damage_dealt: 0,
last_damage_taken: 0,
last_outcome: CombatOutcome::Escaped,
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
return Ok(ResolveCombatActionResult {
snapshot: next,
damage_dealt: 0,
damage_taken: 0,
outcome: CombatOutcome::Escaped,
});
}
let mana_cost = input.mana_cost.max(0);
let heal = input.heal.max(0);
let mana_restore = input.mana_restore.max(0);
let base_damage = input.base_damage.max(0);
let mut next_player_hp = current.player_hp;
let mut next_player_mana = (current.player_mana - mana_cost).max(0);
let mut next_target_hp = current.target_hp;
let mut damage_dealt = 0;
let mut damage_taken = 0;
next_player_hp = clamp_hp(
current.battle_mode,
next_player_hp + heal,
current.player_max_hp,
);
next_player_mana = clamp_mana(next_player_mana + mana_restore, current.player_max_mana);
if base_damage > 0 {
next_target_hp =
clamp_target_hp_after_damage(current.battle_mode, current.target_hp, base_damage);
damage_dealt = current.target_hp - next_target_hp;
}
let (status, outcome, result_text) = if is_target_resolved(current.battle_mode, next_target_hp)
{
let outcome = match current.battle_mode {
BattleMode::Fight => CombatOutcome::Victory,
BattleMode::Spar => CombatOutcome::SparComplete,
};
(
BattleStatus::Resolved,
outcome,
build_resolved_result_text(&action_text, &current.target_name, outcome),
)
} else {
damage_taken = compute_counter_damage(
current.battle_mode,
current.target_max_hp,
input.counter_multiplier_basis_points,
);
next_player_hp = clamp_hp(
current.battle_mode,
next_player_hp - damage_taken,
current.player_max_hp,
);
(
BattleStatus::Ongoing,
CombatOutcome::Ongoing,
build_ongoing_result_text(&input.function_id, &action_text, &current.target_name),
)
};
let next = BattleStateSnapshot {
player_hp: next_player_hp,
player_mana: next_player_mana,
target_hp: next_target_hp,
status,
turn_index: current.turn_index + 1,
last_action_function_id: Some(input.function_id),
last_action_text: Some(action_text),
last_result_text: Some(result_text),
last_damage_dealt: damage_dealt,
last_damage_taken: damage_taken,
last_outcome: outcome,
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
Ok(ResolveCombatActionResult {
snapshot: next,
damage_dealt,
damage_taken,
outcome,
})
}
pub fn generate_battle_state_id(seed_micros: i64) -> String {
build_prefixed_seed_id(BATTLE_STATE_ID_PREFIX, seed_micros)
}
pub fn is_supported_combat_function_id(function_id: &str) -> bool {
matches!(
function_id,
"battle_attack_basic"
| "battle_recover_breath"
| "battle_use_skill"
| "battle_escape_breakout"
) || LEGACY_ATTACK_FUNCTION_IDS.contains(&function_id)
}
fn clamp_hp(mode: BattleMode, value: i32, max_hp: i32) -> i32 {
let min_hp = match mode {
BattleMode::Fight => 0,
BattleMode::Spar => SPAR_MIN_HP,
};
value.clamp(min_hp, max_hp)
}
fn clamp_mana(value: i32, max_mana: i32) -> i32 {
value.clamp(0, max_mana)
}
fn clamp_target_hp_after_damage(mode: BattleMode, current_hp: i32, damage: i32) -> i32 {
match mode {
BattleMode::Fight => (current_hp - damage).max(0),
BattleMode::Spar => (current_hp - damage).max(SPAR_MIN_HP),
}
}
fn is_target_resolved(mode: BattleMode, target_hp: i32) -> bool {
match mode {
BattleMode::Fight => target_hp <= 0,
BattleMode::Spar => target_hp <= SPAR_MIN_HP,
}
}
fn compute_counter_damage(
mode: BattleMode,
target_max_hp: i32,
counter_multiplier_basis_points: u32,
) -> i32 {
match mode {
BattleMode::Spar => 1,
BattleMode::Fight => {
let multiplier = counter_multiplier_basis_points as f32 / 10_000.0;
let raw =
(target_max_hp as f32 * BASIC_FIGHT_COUNTER_RATIO * multiplier).round() as i32;
raw.max(MIN_FIGHT_COUNTER_DAMAGE)
}
}
}
fn build_resolved_result_text(
action_text: &str,
target_name: &str,
outcome: CombatOutcome,
) -> String {
match outcome {
CombatOutcome::Victory => {
format!(
"{}命中了{},这轮战斗已经正式结束。",
action_text, target_name
)
}
CombatOutcome::SparComplete => {
format!(
"{}压住了{}的节奏,这场切磋已经分出高下。",
action_text, target_name
)
}
CombatOutcome::Escaped => {
format!("{}后你成功脱离了当前战斗。", action_text)
}
CombatOutcome::Ongoing => format!("{}已完成结算。", action_text),
}
}
fn build_ongoing_result_text(function_id: &str, action_text: &str, target_name: &str) -> String {
match function_id {
"battle_recover_breath" => {
format!(
"你先把伤势和气息稳住了一轮,但{}仍在持续逼近。",
target_name
)
}
"battle_use_skill" => {
format!(
"{}命中了{},这一轮技能效果已经直接结算。",
action_text, target_name
)
}
_ => format!(
"{}命中了{},本次攻击已经完成结算。",
action_text, target_name
),
}
}
fn map_reward_item_field_error(error: TreasureFieldError) -> CombatFieldError {
let message = match error {
TreasureFieldError::MissingRewardItemId => {
"battle_state.reward_items[].item_id 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemCategory => {
"battle_state.reward_items[].category 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemName => {
"battle_state.reward_items[].item_name 不能为空".to_string()
}
TreasureFieldError::InvalidRewardItemQuantity => {
"battle_state.reward_items[].quantity 必须大于 0".to_string()
}
TreasureFieldError::MissingRewardItemStackKey => {
"battle_state.reward_items[].stack_key 不能为空".to_string()
}
TreasureFieldError::RewardEquipmentItemCannotStack => {
"battle_state.reward_items[] 可装备物品不能标记为 stackable".to_string()
}
TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity => {
"battle_state.reward_items[] 不可堆叠物品必须固定为单槽位单数量".to_string()
}
other => other.to_string(),
};
CombatFieldError::InvalidRewardItem(message)
}
impl fmt::Display for CombatFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBattleStateId => f.write_str("battle_state.battle_state_id 不能为空"),
Self::MissingStorySessionId => f.write_str("battle_state.story_session_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("battle_state.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("battle_state.actor_user_id 不能为空"),
Self::MissingTargetNpcId => f.write_str("battle_state.target_npc_id 不能为空"),
Self::MissingTargetName => f.write_str("battle_state.target_name 不能为空"),
Self::MissingFunctionId => f.write_str("resolve_combat_action.function_id 不能为空"),
Self::InvalidVersion => f.write_str("battle_state.version 必须大于 0"),
Self::InvalidPlayerVitals => f.write_str("battle_state 玩家生命或灵力字段不合法"),
Self::InvalidTargetVitals => f.write_str("battle_state 目标生命字段不合法"),
Self::InvalidRewardItem(message) => f.write_str(message),
Self::BattleAlreadyResolved => f.write_str("battle_state 已经结束,不能继续结算"),
Self::UnsupportedFunctionId => {
f.write_str("resolve_combat_action.function_id 当前不受支持")
}
Self::InsufficientMana => f.write_str("当前灵力不足,无法执行该战斗动作"),
}
}
}
impl Error for CombatFieldError {}
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;
#[cfg(test)]
mod tests {
use super::*;
use module_runtime_item::RuntimeItemRewardItemSnapshot;
fn build_fight_snapshot() -> BattleStateSnapshot {
build_battle_state_snapshot(BattleStateInput {

View File

@@ -14,41 +14,41 @@
## 2. 当前阶段说明
当前阶段已经不再是单纯目录占位,而是先把 `M5` 首批 `custom world / agent` 类型契约字段校验固定下来,避免 `spacetime-module` 在缺少领域边界的情况下直接堆表。
当前阶段已经不再是单纯目录占位,`custom world / agent` 类型契约字段校验、发布编译规则和 Agent action 应用结果已经固定到 DDD 骨架中,避免 `spacetime-module` 在缺少领域边界的情况下直接堆表。
当前已落地:
1. 真实 `Cargo.toml` crate scaffold
2. `CustomWorldPublicationStatus``CustomWorldThemeMode``CustomWorldGenerationMode`
3. `CustomWorldSessionStatus``RpgAgentStage`
4. `RpgAgentMessageRole``RpgAgentMessageKind`
5. `RpgAgentOperationType``RpgAgentOperationStatus`
6. `RpgAgentDraftCardKind``RpgAgentDraftCardStatus`
7. `CustomWorldRoleAssetStatus`
8. 首批表字段校验函数与最小单测
9. `published profile compile` 输入输出 contract
10. `publish_world` 串联输入输出 contract
2. `src/domain.rs` 承接基础枚举、进度常量、profile/session/card/gallery/publish gate 快照与结果类型。
3. `src/commands.rs` 承接 profile、library/gallery、Agent session/message/operation/action、published profile compile 和 publish world 输入 DTO。
4. `src/application.rs` 承接字段校验、默认 JSON、profile canonicalize、published profile compile 和 publish gate 相关纯规则。
5. `src/errors.rs` 承接 `CustomWorldFieldError` 与中文错误文案。
6. `src/events.rs` 承接 Custom World 领域事件与 payload struct。
7. `src/lib.rs` 只保留模块声明、公开导出和测试,继续保持 `module_custom_world::*` 公开 API。
8. `spacetime-module``generate_characters``generate_landmarks``generate_role_assets``sync_role_assets``generate_scene_assets``sync_scene_assets``expand_long_tail` 已移除最小兼容占位,改为确定性状态编排。
当前 crate 仍然只承接:
1. 共享枚举与类型口径
2. 字段校验字符串归一化
3. published profile compile 的最小编译摘要 contract
4. 后续 `spacetime-module` 聚合表时需要复用的领域边界
1. 共享枚举、进度常量与类型口径,基础枚举统一从 `src/domain.rs` 导出。
2. 字段校验字符串归一化与发布编译纯规则。
3. published profile compile 与 publish world 的输入输出 contract
4. 后续 `spacetime-module` 聚合表时需要复用的领域边界
当前阶段明确不提前进入:
1. 旧问答流 reducer 编排
2. RPG 创作 Agent 编排
3. publish gate blocker 规则迁移
4. 资产绑定与图片生成副作用
2. 外部 LLM 创作编排、图片生成、OSS 上传和 SSE 推送。
3. 资产对象真相表、资产绑定表和完整资产历史。
4. 前端创作流程和 UI 表现状态。
当前设计依据:
1. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md)
2. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
3. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md)
4. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md)
1. [../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md)
2. [../../../docs/technical/SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md](../../../docs/technical/SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md)
3. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md)
4. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
5. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md)
6. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md)
后续与本 package 直接相关的任务包括:

View File

@@ -0,0 +1,957 @@
//! 自定义世界应用规则。
//!
//! 这里只组合纯领域校验、草稿编译、profile 归一化和默认 JSON 结构,不承接外部副作用。
use crate::{commands::*, domain::*, errors::CustomWorldFieldError};
use serde_json::{Map, Value};
pub fn validate_custom_world_profile_fields(
profile_id: &str,
owner_user_id: &str,
world_name: &str,
profile_payload_json: &str,
) -> Result<(), CustomWorldFieldError> {
if profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if world_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingWorldName);
}
if profile_payload_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfilePayloadJson);
}
Ok(())
}
pub fn validate_custom_world_published_profile_compile_input(
input: &CustomWorldPublishedProfileCompileInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.draft_profile_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingDraftProfileJson);
}
if input.setting_text.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSettingText);
}
if input.author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
Ok(())
}
pub fn validate_custom_world_publish_world_input(
input: &CustomWorldPublishWorldInput,
) -> Result<(), CustomWorldFieldError> {
if input.author_public_user_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
validate_custom_world_published_profile_compile_input(
&CustomWorldPublishedProfileCompileInput {
session_id: input.session_id.clone(),
profile_id: input.profile_id.clone(),
owner_user_id: input.owner_user_id.clone(),
draft_profile_json: input.draft_profile_json.clone(),
legacy_result_profile_json: input.legacy_result_profile_json.clone(),
setting_text: input.setting_text.clone(),
author_display_name: input.author_display_name.clone(),
updated_at_micros: input.published_at_micros,
},
)
}
pub fn validate_custom_world_profile_upsert_input(
input: &CustomWorldProfileUpsertInput,
) -> Result<(), CustomWorldFieldError> {
validate_custom_world_profile_fields(
&input.profile_id,
&input.owner_user_id,
&input.world_name,
&input.profile_payload_json,
)?;
if input.author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
Ok(())
}
pub fn validate_custom_world_profile_publish_input(
input: &CustomWorldProfilePublishInput,
) -> Result<(), CustomWorldFieldError> {
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
if input.author_public_user_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_profile_unpublish_input(
input: &CustomWorldProfileUnpublishInput,
) -> Result<(), CustomWorldFieldError> {
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
Ok(())
}
pub fn validate_custom_world_profile_delete_input(
input: &CustomWorldProfileDeleteInput,
) -> Result<(), CustomWorldFieldError> {
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_profile_list_input(
input: &CustomWorldProfileListInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_library_detail_input(
input: &CustomWorldLibraryDetailInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
Ok(())
}
pub fn validate_custom_world_gallery_detail_input(
input: &CustomWorldGalleryDetailInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
Ok(())
}
pub fn validate_custom_world_gallery_detail_by_code_input(
input: &CustomWorldGalleryDetailByCodeInput,
) -> Result<(), CustomWorldFieldError> {
if input.public_work_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingPublicWorkCode);
}
Ok(())
}
pub fn validate_custom_world_session_fields(
session_id: &str,
owner_user_id: &str,
setting_text: &str,
question_snapshot_json: &str,
) -> Result<(), CustomWorldFieldError> {
if session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if setting_text.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSettingText);
}
if question_snapshot_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingQuestionSnapshotJson);
}
Ok(())
}
pub fn validate_custom_world_agent_session_fields(
session_id: &str,
owner_user_id: &str,
anchor_content_json: &str,
creator_intent_readiness_json: &str,
pending_clarifications_json: &str,
asset_coverage_json: &str,
progress_percent: u32,
) -> Result<(), CustomWorldFieldError> {
if session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if anchor_content_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAnchorContentJson);
}
if creator_intent_readiness_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCreatorIntentReadinessJson);
}
if pending_clarifications_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingPendingClarificationsJson);
}
if asset_coverage_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAssetCoverageJson);
}
if progress_percent > MAX_PROGRESS_PERCENT {
return Err(CustomWorldFieldError::InvalidProgressPercent);
}
Ok(())
}
pub fn validate_custom_world_agent_session_create_input(
input: &CustomWorldAgentSessionCreateInput,
) -> Result<(), CustomWorldFieldError> {
validate_custom_world_agent_session_fields(
&input.session_id,
&input.owner_user_id,
&input.anchor_content_json,
&input.creator_intent_readiness_json,
&input.pending_clarifications_json,
&input.asset_coverage_json,
0,
)?;
validate_custom_world_agent_message_fields(
&input.welcome_message_id,
&input.session_id,
&input.welcome_message_text,
)?;
ensure_json_object(&input.anchor_content_json)?;
ensure_optional_json_object(input.creator_intent_json.as_deref())?;
ensure_json_object(&input.creator_intent_readiness_json)?;
ensure_optional_json_object(input.anchor_pack_json.as_deref())?;
ensure_optional_json_object(input.lock_state_json.as_deref())?;
ensure_optional_json_object(input.draft_profile_json.as_deref())?;
ensure_json_array(&input.pending_clarifications_json)?;
ensure_json_array(&input.suggested_actions_json)?;
ensure_json_array(&input.recommended_replies_json)?;
ensure_json_array(&input.quality_findings_json)?;
ensure_json_object(&input.asset_coverage_json)?;
ensure_json_array(&input.checkpoints_json)?;
Ok(())
}
pub fn validate_custom_world_agent_session_get_input(
input: &CustomWorldAgentSessionGetInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_agent_message_submit_input(
input: &CustomWorldAgentMessageSubmitInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
validate_custom_world_agent_message_fields(
&input.user_message_id,
&input.session_id,
&input.user_message_text,
)?;
validate_custom_world_agent_operation_fields(
&input.operation_id,
&input.session_id,
"消息已处理",
MAX_PROGRESS_PERCENT,
)?;
Ok(())
}
pub fn validate_custom_world_agent_message_finalize_input(
input: &CustomWorldAgentMessageFinalizeInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
match input.operation_status {
RpgAgentOperationStatus::Completed => {
validate_custom_world_agent_message_fields(
input.assistant_message_id.as_deref().unwrap_or_default(),
&input.session_id,
input.assistant_reply_text.as_deref().unwrap_or_default(),
)?;
}
RpgAgentOperationStatus::Failed => {}
_ => {
validate_custom_world_agent_message_fields(
input.assistant_message_id.as_deref().unwrap_or_default(),
&input.session_id,
input.assistant_reply_text.as_deref().unwrap_or_default(),
)?;
}
}
validate_custom_world_agent_operation_fields(
&input.operation_id,
&input.session_id,
&input.phase_label,
input.operation_progress,
)?;
validate_custom_world_agent_session_fields(
&input.session_id,
&input.owner_user_id,
&input.anchor_content_json,
&input.creator_intent_readiness_json,
&input.pending_clarifications_json,
&input.asset_coverage_json,
input.progress_percent,
)?;
ensure_json_object(&input.anchor_content_json)?;
ensure_optional_json_object(input.creator_intent_json.as_deref())?;
ensure_json_object(&input.creator_intent_readiness_json)?;
ensure_optional_json_object(input.anchor_pack_json.as_deref())?;
ensure_optional_json_object(input.draft_profile_json.as_deref())?;
ensure_json_array(&input.pending_clarifications_json)?;
ensure_json_array(&input.suggested_actions_json)?;
ensure_json_array(&input.recommended_replies_json)?;
ensure_json_array(&input.quality_findings_json)?;
ensure_json_object(&input.asset_coverage_json)?;
Ok(())
}
pub fn validate_custom_world_agent_operation_get_input(
input: &CustomWorldAgentOperationGetInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.operation_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOperationId);
}
Ok(())
}
pub fn validate_custom_world_agent_operation_progress_input(
input: &CustomWorldAgentOperationProgressInput,
) -> Result<(), CustomWorldFieldError> {
validate_custom_world_agent_operation_get_input(&CustomWorldAgentOperationGetInput {
session_id: input.session_id.clone(),
owner_user_id: input.owner_user_id.clone(),
operation_id: input.operation_id.clone(),
})?;
validate_custom_world_agent_operation_fields(
&input.operation_id,
&input.session_id,
&input.phase_label,
input.operation_progress,
)?;
Ok(())
}
pub fn validate_custom_world_works_list_input(
input: &CustomWorldWorksListInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_agent_card_detail_get_input(
input: &CustomWorldAgentCardDetailGetInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.card_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCardId);
}
Ok(())
}
pub fn validate_custom_world_agent_action_execute_input(
input: &CustomWorldAgentActionExecuteInput,
) -> Result<(), CustomWorldFieldError> {
validate_custom_world_agent_operation_get_input(&CustomWorldAgentOperationGetInput {
session_id: input.session_id.clone(),
owner_user_id: input.owner_user_id.clone(),
operation_id: input.operation_id.clone(),
})?;
if input.action.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAction);
}
ensure_optional_json_object(input.payload_json.as_deref())?;
Ok(())
}
pub fn validate_custom_world_agent_message_fields(
message_id: &str,
session_id: &str,
text: &str,
) -> Result<(), CustomWorldFieldError> {
if message_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingMessageId);
}
if session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if text.trim().is_empty() {
return Err(CustomWorldFieldError::MissingMessageText);
}
Ok(())
}
pub fn validate_custom_world_agent_operation_fields(
operation_id: &str,
session_id: &str,
phase_label: &str,
progress: u32,
) -> Result<(), CustomWorldFieldError> {
if operation_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOperationId);
}
if session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if phase_label.trim().is_empty() {
return Err(CustomWorldFieldError::MissingPhaseLabel);
}
if progress > MAX_PROGRESS_PERCENT {
return Err(CustomWorldFieldError::InvalidProgressPercent);
}
Ok(())
}
pub fn validate_custom_world_draft_card_fields(
card_id: &str,
session_id: &str,
title: &str,
summary: &str,
linked_ids_json: &str,
) -> Result<(), CustomWorldFieldError> {
if card_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCardId);
}
if session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if title.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCardTitle);
}
if summary.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCardSummary);
}
if linked_ids_json.trim().is_empty() {
return Err(CustomWorldFieldError::MissingLinkedIdsJson);
}
Ok(())
}
pub fn validate_custom_world_gallery_entry_fields(
profile_id: &str,
owner_user_id: &str,
author_display_name: &str,
world_name: &str,
) -> Result<(), CustomWorldFieldError> {
if profile_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingProfileId);
}
if owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
if world_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingWorldName);
}
Ok(())
}
pub fn build_custom_world_published_profile_compile_snapshot(
input: CustomWorldPublishedProfileCompileInput,
) -> Result<CustomWorldPublishedProfileCompileSnapshot, CustomWorldFieldError> {
validate_custom_world_published_profile_compile_input(&input)?;
let draft = parse_required_json_object(
&input.draft_profile_json,
CustomWorldFieldError::InvalidDraftProfileJson,
)?;
let legacy = parse_optional_json_object(
input.legacy_result_profile_json.clone(),
CustomWorldFieldError::InvalidLegacyResultProfileJson,
)?;
let world_name = resolve_text_field(&draft, &legacy, "name")
.ok_or(CustomWorldFieldError::MissingWorldName)?;
let subtitle = resolve_text_field(&draft, &legacy, "subtitle").unwrap_or_default();
let summary_text = resolve_text_field(&draft, &legacy, "summary").unwrap_or_default();
let cover_image_src = resolve_cover_image_src(&draft, &legacy);
let theme_mode = resolve_theme_mode(&legacy);
let playable_npc_count =
count_distinct_roles(draft.get("playableNpcs"), draft.get("storyNpcs"));
let landmark_count = to_array(draft.get("landmarks")).len() as u32;
let compiled_payload_json = build_compiled_profile_payload_json(
&input,
&draft,
&legacy,
&world_name,
&subtitle,
&summary_text,
)?;
Ok(CustomWorldPublishedProfileCompileSnapshot {
profile_id: input.profile_id,
owner_user_id: input.owner_user_id,
world_name,
subtitle,
summary_text,
theme_mode,
cover_image_src,
playable_npc_count,
landmark_count,
author_display_name: input.author_display_name,
compiled_profile_payload_json: compiled_payload_json,
updated_at_micros: input.updated_at_micros,
})
}
pub fn canonicalize_custom_world_profile_before_save(profile: &mut Value) -> bool {
let Some(object) = profile.as_object_mut() else {
return false;
};
let foundation_text = build_creator_intent_foundation_text(object.get("creatorIntent"))
.trim()
.to_string();
if foundation_text.is_empty() {
return false;
}
let current_setting_text = object
.get("settingText")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if current_setting_text == foundation_text {
return false;
}
// 中文注释:保存与 session 同步前统一以后端 creatorIntent 锚点重建 settingText
// 避免浏览器继续持有正式 profile canonicalize 规则。
object.insert("settingText".to_string(), Value::String(foundation_text));
true
}
pub fn empty_agent_anchor_content_json() -> String {
r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":null,"hiddenLines":null,"iconicElements":null}"#.to_string()
}
pub fn empty_agent_creator_intent_readiness_json() -> String {
r#"{"isReady":false,"completedKeys":[],"missingKeys":[]}"#.to_string()
}
pub fn empty_agent_asset_coverage_json() -> String {
r#"{"roleAssets":[],"sceneAssets":[],"allRoleAssetsReady":false,"allSceneAssetsReady":false}"#
.to_string()
}
pub fn empty_json_object() -> String {
"{}".to_string()
}
pub fn empty_json_array() -> String {
"[]".to_string()
}
pub fn normalize_optional_json_slice(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() { None } else { Some(value) }
})
}
fn ensure_json_object(value: &str) -> Result<(), CustomWorldFieldError> {
match serde_json::from_str::<Value>(value) {
Ok(Value::Object(_)) => Ok(()),
_ => Err(CustomWorldFieldError::InvalidJsonPayload),
}
}
fn ensure_optional_json_object(value: Option<&str>) -> Result<(), CustomWorldFieldError> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => ensure_json_object(value),
None => Ok(()),
}
}
fn ensure_json_array(value: &str) -> Result<(), CustomWorldFieldError> {
match serde_json::from_str::<Value>(value) {
Ok(Value::Array(_)) => Ok(()),
_ => Err(CustomWorldFieldError::InvalidJsonPayload),
}
}
fn parse_required_json_object(
value: &str,
error: CustomWorldFieldError,
) -> Result<Map<String, Value>, CustomWorldFieldError> {
match serde_json::from_str::<Value>(value) {
Ok(Value::Object(object)) => Ok(object),
_ => Err(error),
}
}
fn parse_optional_json_object(
value: Option<String>,
error: CustomWorldFieldError,
) -> Result<Map<String, Value>, CustomWorldFieldError> {
match normalize_optional_json_slice(value) {
Some(value) => parse_required_json_object(&value, error),
None => Ok(Map::new()),
}
}
fn to_text(value: Option<&Value>) -> Option<String> {
match value {
Some(Value::String(value)) => {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
_ => None,
}
}
fn to_array(value: Option<&Value>) -> Vec<Value> {
match value {
Some(Value::Array(items)) => items.clone(),
_ => Vec::new(),
}
}
fn to_object(value: Option<&Value>) -> Option<Map<String, Value>> {
match value {
Some(Value::Object(object)) => Some(object.clone()),
_ => None,
}
}
fn build_creator_intent_foundation_text(value: Option<&Value>) -> String {
let Some(intent) = value.and_then(Value::as_object) else {
return String::new();
};
if !has_meaningful_creator_intent(intent) {
return String::new();
}
let relationship_text = intent
.get("keyCharacters")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(Value::as_object)
.map(build_creator_intent_relationship_text)
.unwrap_or_default();
let player_opening_text = [
read_text(intent, "playerPremise"),
read_text(intent, "openingSituation"),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("");
let theme_tone_text = [
read_string_list(intent, "themeKeywords").join(""),
read_string_list(intent, "toneDirectives").join(""),
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join(" / ");
[
build_anchor_line(
"世界一句话",
read_text(intent, "worldHook").unwrap_or_default(),
),
build_anchor_line("玩家开局", player_opening_text),
build_anchor_line("主题气质", theme_tone_text),
build_anchor_line(
"核心冲突",
read_string_list(intent, "coreConflicts").join(""),
),
build_anchor_line("关键关系", relationship_text),
build_anchor_line(
"标志元素",
read_string_list(intent, "iconicElements").join(""),
),
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn has_meaningful_creator_intent(intent: &Map<String, Value>) -> bool {
[
"rawSettingText",
"worldHook",
"playerPremise",
"openingSituation",
]
.iter()
.any(|key| read_text(intent, key).is_some())
|| [
"themeKeywords",
"toneDirectives",
"coreConflicts",
"iconicElements",
"forbiddenDirectives",
]
.iter()
.any(|key| !read_string_list(intent, key).is_empty())
|| ["keyFactions", "keyCharacters", "keyLandmarks"]
.iter()
.any(|key| has_meaningful_creator_seed_array(intent.get(*key)))
}
fn build_creator_intent_relationship_text(character: &Map<String, Value>) -> String {
[
read_text(character, "name"),
read_text(character, "role"),
read_text(character, "relationToPlayer").map(|value| format!("与玩家 {value}")),
read_text(character, "hiddenHook").map(|value| format!("暗线 {value}")),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" · ")
}
fn build_anchor_line(label: &str, content: String) -> String {
if content.is_empty() {
String::new()
} else {
format!("{label}{content}")
}
}
fn read_text(object: &Map<String, Value>, key: &str) -> Option<String> {
object
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn read_string_list(object: &Map<String, Value>, key: &str) -> Vec<String> {
object
.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn has_meaningful_creator_seed_array(value: Option<&Value>) -> bool {
value.and_then(Value::as_array).is_some_and(|items| {
items.iter().any(|item| {
item.as_object().is_some_and(|object| {
[
"name",
"publicGoal",
"tension",
"notes",
"role",
"publicMask",
"hiddenHook",
"relationToPlayer",
"purpose",
"mood",
"secret",
]
.iter()
.any(|key| read_text(object, key).is_some())
})
})
})
}
fn resolve_text_field(
draft: &Map<String, Value>,
legacy: &Map<String, Value>,
key: &str,
) -> Option<String> {
to_text(draft.get(key)).or_else(|| to_text(legacy.get(key)))
}
fn resolve_theme_mode(legacy: &Map<String, Value>) -> CustomWorldThemeMode {
to_text(legacy.get("themeMode"))
.and_then(|value| CustomWorldThemeMode::from_client_str(&value))
.unwrap_or(CustomWorldThemeMode::Mythic)
}
fn resolve_cover_image_src(
draft: &Map<String, Value>,
legacy: &Map<String, Value>,
) -> Option<String> {
if let Some(camp) = to_object(draft.get("camp")) {
if let Some(image_src) = to_text(camp.get("imageSrc")) {
return Some(image_src);
}
}
for landmark in to_array(draft.get("landmarks")) {
if let Value::Object(landmark) = landmark {
if let Some(image_src) = to_text(landmark.get("imageSrc")) {
return Some(image_src);
}
}
}
if let Some(cover) = to_object(legacy.get("cover")) {
if let Some(image_src) = to_text(cover.get("imageSrc")) {
return Some(image_src);
}
}
to_text(legacy.get("coverImageSrc"))
}
fn count_distinct_roles(playable: Option<&Value>, story: Option<&Value>) -> u32 {
let mut seen = std::collections::BTreeSet::new();
for role in to_array(playable).into_iter().chain(to_array(story)) {
if let Value::Object(role) = role {
let key = to_text(role.get("id"))
.or_else(|| to_text(role.get("name")))
.unwrap_or_else(|| format!("role-{}", seen.len()));
seen.insert(key);
}
}
seen.len() as u32
}
fn build_compiled_profile_payload_json(
input: &CustomWorldPublishedProfileCompileInput,
draft: &Map<String, Value>,
legacy: &Map<String, Value>,
world_name: &str,
subtitle: &str,
summary_text: &str,
) -> Result<String, CustomWorldFieldError> {
let mut payload = legacy.clone();
payload.insert("id".to_string(), Value::String(input.profile_id.clone()));
payload.insert(
"settingText".to_string(),
Value::String(input.setting_text.trim().to_string()),
);
payload.insert("name".to_string(), Value::String(world_name.to_string()));
payload.insert("subtitle".to_string(), Value::String(subtitle.to_string()));
payload.insert(
"summary".to_string(),
Value::String(summary_text.to_string()),
);
payload.insert(
"updatedAtMicros".to_string(),
Value::Number(input.updated_at_micros.into()),
);
for key in ["tone", "playerGoal"] {
if let Some(value) = draft.get(key) {
payload.insert(key.to_string(), value.clone());
}
}
for key in [
"majorFactions",
"coreConflicts",
"playableNpcs",
"storyNpcs",
"landmarks",
"camp",
] {
if let Some(value) = draft.get(key) {
payload.insert(key.to_string(), value.clone());
}
}
if let Some(scene_chapters) = draft
.get("sceneChapterBlueprints")
.or_else(|| draft.get("sceneChapters"))
{
payload.insert("sceneChapterBlueprints".to_string(), scene_chapters.clone());
}
serde_json::to_string(&Value::Object(payload))
.map_err(|_| CustomWorldFieldError::InvalidDraftProfileJson)
}

View File

@@ -0,0 +1,311 @@
//! 自定义世界写入命令。
//!
//! 用于表达会话创建、消息写入、草稿更新、发布和下架等用例输入。
use crate::domain::{
CustomWorldAgentOperationSnapshot, CustomWorldGalleryEntrySnapshot, CustomWorldProfileSnapshot,
CustomWorldThemeMode, RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage,
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileUpsertInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub source_agent_session_id: Option<String>,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub theme_mode: CustomWorldThemeMode,
pub cover_image_src: Option<String>,
pub profile_payload_json: String,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub author_display_name: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfilePublishInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: String,
pub author_display_name: String,
pub published_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileUnpublishInput {
pub profile_id: String,
pub owner_user_id: String,
pub author_display_name: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileDeleteInput {
pub profile_id: String,
pub owner_user_id: String,
pub deleted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldLibraryDetailInput {
pub owner_user_id: String,
pub profile_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldGalleryDetailInput {
pub owner_user_id: String,
pub profile_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldGalleryDetailByCodeInput {
pub public_work_code: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileRemixInput {
pub source_owner_user_id: String,
pub source_profile_id: String,
pub target_owner_user_id: String,
pub target_profile_id: String,
pub author_display_name: String,
pub remixed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfilePlayRecordInput {
pub owner_user_id: String,
pub profile_id: String,
pub played_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileLikeRecordInput {
pub owner_user_id: String,
pub profile_id: String,
pub user_id: String,
pub liked_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionCreateInput {
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<String>,
pub creator_intent_readiness_json: String,
pub anchor_pack_json: Option<String>,
pub lock_state_json: Option<String>,
pub draft_profile_json: Option<String>,
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,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionGetInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentMessageSubmitInput {
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,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub phase_label: String,
pub phase_detail: String,
pub operation_status: RpgAgentOperationStatus,
pub operation_progress: u32,
pub stage: RpgAgentStage,
pub progress_percent: u32,
pub focus_card_id: Option<String>,
pub anchor_content_json: String,
pub creator_intent_json: Option<String>,
pub creator_intent_readiness_json: String,
pub anchor_pack_json: Option<String>,
pub draft_profile_json: Option<String>,
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<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentOperationGetInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentOperationProgressInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub operation_type: RpgAgentOperationType,
pub operation_status: RpgAgentOperationStatus,
pub phase_label: String,
pub phase_detail: String,
pub operation_progress: u32,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentOperationProcedureResult {
pub ok: bool,
pub operation: Option<CustomWorldAgentOperationSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorksListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentCardDetailGetInput {
pub session_id: String,
pub owner_user_id: String,
pub card_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentActionExecuteInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub action: String,
pub payload_json: Option<String>,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentActionExecuteResult {
pub ok: bool,
pub operation: Option<CustomWorldAgentOperationSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishedProfileCompileInput {
pub session_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub draft_profile_json: String,
pub legacy_result_profile_json: Option<String>,
pub setting_text: String,
pub author_display_name: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishedProfileCompileSnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub theme_mode: CustomWorldThemeMode,
pub cover_image_src: Option<String>,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub author_display_name: String,
pub compiled_profile_payload_json: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishedProfileCompileResult {
pub ok: bool,
pub record: Option<CustomWorldPublishedProfileCompileSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishWorldInput {
pub session_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: String,
pub draft_profile_json: String,
pub legacy_result_profile_json: Option<String>,
pub setting_text: String,
pub author_display_name: String,
pub published_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishWorldResult {
pub ok: bool,
pub compiled_record: Option<CustomWorldPublishedProfileCompileSnapshot>,
pub entry: Option<CustomWorldProfileSnapshot>,
pub gallery_entry: Option<CustomWorldGalleryEntrySnapshot>,
pub session_stage: Option<RpgAgentStage>,
pub error_message: Option<String>,
}

View File

@@ -0,0 +1,554 @@
//! 自定义世界领域模型。
//!
//! 只保留 profile、Agent 会话、草稿卡、发布门禁和画廊投影的纯领域结构LLM 推理、SSE 和 OSS 均留在外层 adapter。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const MAX_PROGRESS_PERCENT: u32 = 100;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldPublicationStatus {
Draft,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldThemeMode {
Martial,
Arcane,
Machina,
Tide,
Rift,
Mythic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldGenerationMode {
Fast,
Full,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldSessionStatus {
Clarifying,
ReadyToGenerate,
Generating,
Completed,
GenerationError,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentStage {
CollectingIntent,
Clarifying,
FoundationReview,
ObjectRefining,
VisualRefining,
LongTailReview,
ReadyToPublish,
Published,
Error,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentMessageRole {
User,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentMessageKind {
Chat,
Clarification,
Summary,
Checkpoint,
Warning,
ActionResult,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentOperationType {
ProcessMessage,
DraftFoundation,
UpdateDraftCard,
SyncResultProfile,
GenerateCharacters,
GenerateLandmarks,
DeleteCharacters,
DeleteLandmarks,
GenerateRoleAssets,
SyncRoleAssets,
GenerateSceneAssets,
SyncSceneAssets,
ExpandLongTail,
PublishWorld,
RevertCheckpoint,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentOperationStatus {
Queued,
Running,
Completed,
Failed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentDraftCardKind {
World,
Camp,
Faction,
Character,
Landmark,
Thread,
Chapter,
SceneChapter,
Carrier,
SidequestSeed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RpgAgentDraftCardStatus {
Suggested,
Confirmed,
Locked,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldRoleAssetStatus {
Missing,
VisualReady,
AnimationsReady,
Complete,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileSnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub source_agent_session_id: Option<String>,
pub publication_status: CustomWorldPublicationStatus,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub theme_mode: CustomWorldThemeMode,
pub cover_image_src: Option<String>,
pub profile_payload_json: String,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub play_count: u32,
pub remix_count: u32,
pub like_count: u32,
pub author_display_name: String,
pub published_at_micros: Option<i64>,
pub deleted_at_micros: Option<i64>,
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 CustomWorldGalleryEntrySnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: String,
pub author_public_user_code: String,
pub author_display_name: String,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub cover_image_src: Option<String>,
pub theme_mode: CustomWorldThemeMode,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub play_count: u32,
pub remix_count: u32,
pub like_count: u32,
pub recent_play_count_7d: u32,
pub published_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldLibraryMutationResult {
pub ok: bool,
pub entry: Option<CustomWorldProfileSnapshot>,
pub gallery_entry: Option<CustomWorldGalleryEntrySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileListResult {
pub ok: bool,
pub entries: Vec<CustomWorldProfileSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldGalleryListResult {
pub ok: bool,
pub entries: Vec<CustomWorldGalleryEntrySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishBlockerSnapshot {
pub blocker_id: String,
pub code: String,
pub message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishGateSnapshot {
pub profile_id: String,
pub blockers: Vec<CustomWorldPublishBlockerSnapshot>,
pub blocker_count: u32,
pub publish_ready: bool,
pub can_enter_world: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorkSummarySnapshot {
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<String>,
pub cover_render_mode: Option<String>,
pub cover_character_image_srcs_json: String,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
pub stage: Option<RpgAgentStage>,
pub stage_label: Option<String>,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub role_visual_ready_count: Option<u32>,
pub role_animation_ready_count: Option<u32>,
pub role_asset_summary_label: Option<String>,
pub session_id: Option<String>,
pub profile_id: Option<String>,
pub can_resume: bool,
pub can_enter_world: bool,
pub blocker_count: u32,
pub publish_ready: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorksListResult {
pub ok: bool,
pub items: Vec<CustomWorldWorkSummarySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentMessageSnapshot {
pub message_id: String,
pub session_id: String,
pub role: RpgAgentMessageRole,
pub kind: RpgAgentMessageKind,
pub text: String,
pub related_operation_id: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentOperationSnapshot {
pub operation_id: String,
pub session_id: String,
pub operation_type: RpgAgentOperationType,
pub status: RpgAgentOperationStatus,
pub phase_label: String,
pub phase_detail: String,
pub progress: u32,
pub error_message: 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 CustomWorldDraftCardSnapshot {
pub card_id: String,
pub session_id: String,
pub kind: RpgAgentDraftCardKind,
pub status: RpgAgentDraftCardStatus,
pub title: String,
pub subtitle: String,
pub summary: String,
pub linked_ids_json: String,
pub warning_count: u32,
pub asset_status: Option<CustomWorldRoleAssetStatus>,
pub asset_status_label: Option<String>,
pub detail_payload_json: 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 CustomWorldDraftCardDetailSectionSnapshot {
pub section_id: String,
pub label: String,
pub value: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardDetailSnapshot {
pub card_id: String,
pub kind: RpgAgentDraftCardKind,
pub title: String,
pub sections: Vec<CustomWorldDraftCardDetailSectionSnapshot>,
pub linked_ids_json: String,
pub locked: bool,
pub editable: bool,
pub editable_section_ids_json: String,
pub warning_messages_json: String,
pub asset_status: Option<CustomWorldRoleAssetStatus>,
pub asset_status_label: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardDetailResult {
pub ok: bool,
pub card: Option<CustomWorldDraftCardDetailSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionSnapshot {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub current_turn: u32,
pub progress_percent: u32,
pub stage: RpgAgentStage,
pub focus_card_id: Option<String>,
pub anchor_content_json: String,
pub creator_intent_json: Option<String>,
pub creator_intent_readiness_json: String,
pub anchor_pack_json: Option<String>,
pub lock_state_json: Option<String>,
pub draft_profile_json: Option<String>,
pub last_assistant_reply: Option<String>,
pub publish_gate_json: Option<String>,
pub result_preview_json: Option<String>,
pub pending_clarifications_json: String,
pub quality_findings_json: String,
pub suggested_actions_json: String,
pub recommended_replies_json: String,
pub asset_coverage_json: String,
pub checkpoints_json: String,
pub supported_actions_json: String,
pub messages: Vec<CustomWorldAgentMessageSnapshot>,
pub draft_cards: Vec<CustomWorldDraftCardSnapshot>,
pub operations: Vec<CustomWorldAgentOperationSnapshot>,
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 CustomWorldAgentSessionProcedureResult {
pub ok: bool,
pub session: Option<CustomWorldAgentSessionSnapshot>,
pub error_message: Option<String>,
}
impl CustomWorldPublicationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
}
}
}
impl CustomWorldThemeMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Martial => "martial",
Self::Arcane => "arcane",
Self::Machina => "machina",
Self::Tide => "tide",
Self::Rift => "rift",
Self::Mythic => "mythic",
}
}
pub fn from_client_str(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"martial" => Some(Self::Martial),
"arcane" => Some(Self::Arcane),
"machina" => Some(Self::Machina),
"tide" => Some(Self::Tide),
"rift" => Some(Self::Rift),
"mythic" => Some(Self::Mythic),
_ => None,
}
}
}
impl CustomWorldGenerationMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Fast => "fast",
Self::Full => "full",
}
}
}
impl CustomWorldSessionStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Clarifying => "clarifying",
Self::ReadyToGenerate => "ready_to_generate",
Self::Generating => "generating",
Self::Completed => "completed",
Self::GenerationError => "generation_error",
}
}
}
impl RpgAgentStage {
pub fn as_str(&self) -> &'static str {
match self {
Self::CollectingIntent => "collecting_intent",
Self::Clarifying => "clarifying",
Self::FoundationReview => "foundation_review",
Self::ObjectRefining => "object_refining",
Self::VisualRefining => "visual_refining",
Self::LongTailReview => "long_tail_review",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
Self::Error => "error",
}
}
}
impl RpgAgentMessageRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
impl RpgAgentMessageKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Clarification => "clarification",
Self::Summary => "summary",
Self::Checkpoint => "checkpoint",
Self::Warning => "warning",
Self::ActionResult => "action_result",
}
}
}
impl RpgAgentOperationType {
pub fn as_str(&self) -> &'static str {
match self {
Self::ProcessMessage => "process_message",
Self::DraftFoundation => "draft_foundation",
Self::UpdateDraftCard => "update_draft_card",
Self::SyncResultProfile => "sync_result_profile",
Self::GenerateCharacters => "generate_characters",
Self::GenerateLandmarks => "generate_landmarks",
Self::DeleteCharacters => "delete_characters",
Self::DeleteLandmarks => "delete_landmarks",
Self::GenerateRoleAssets => "generate_role_assets",
Self::SyncRoleAssets => "sync_role_assets",
Self::GenerateSceneAssets => "generate_scene_assets",
Self::SyncSceneAssets => "sync_scene_assets",
Self::ExpandLongTail => "expand_long_tail",
Self::PublishWorld => "publish_world",
Self::RevertCheckpoint => "revert_checkpoint",
}
}
}
impl RpgAgentOperationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Completed => "completed",
Self::Failed => "failed",
}
}
}
impl RpgAgentDraftCardKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::World => "world",
Self::Camp => "camp",
Self::Faction => "faction",
Self::Character => "character",
Self::Landmark => "landmark",
Self::Thread => "thread",
Self::Chapter => "chapter",
Self::SceneChapter => "scene_chapter",
Self::Carrier => "carrier",
Self::SidequestSeed => "sidequest_seed",
}
}
}
impl RpgAgentDraftCardStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Suggested => "suggested",
Self::Confirmed => "confirmed",
Self::Locked => "locked",
Self::Warning => "warning",
}
}
}
impl CustomWorldRoleAssetStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Missing => "missing",
Self::VisualReady => "visual_ready",
Self::AnimationsReady => "animations_ready",
Self::Complete => "complete",
}
}
}

View File

@@ -0,0 +1,100 @@
//! 自定义世界领域错误。
//!
//! 错误只表达世界创作规则失败,由 adapter 显式映射为 HTTP 或 reducer 错误。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CustomWorldFieldError {
MissingProfileId,
MissingSessionId,
MissingOwnerUserId,
MissingPublicWorkCode,
MissingAction,
MissingWorldName,
MissingDraftProfileJson,
MissingProfilePayloadJson,
MissingSettingText,
MissingQuestionSnapshotJson,
MissingAnchorContentJson,
MissingCreatorIntentReadinessJson,
MissingAssetCoverageJson,
MissingPendingClarificationsJson,
MissingMessageId,
MissingMessageText,
MissingOperationId,
MissingPhaseLabel,
InvalidProgressPercent,
MissingCardId,
MissingCardTitle,
MissingCardSummary,
MissingLinkedIdsJson,
MissingAuthorDisplayName,
InvalidDraftProfileJson,
InvalidLegacyResultProfileJson,
InvalidJsonPayload,
}
impl fmt::Display for CustomWorldFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"),
Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"),
Self::MissingPublicWorkCode => {
f.write_str("custom_world_gallery_detail.public_work_code 不能为空")
}
Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"),
Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"),
Self::MissingDraftProfileJson => {
f.write_str("custom_world.compile.draft_profile_json 不能为空")
}
Self::MissingProfilePayloadJson => {
f.write_str("custom_world.profile_payload_json 不能为空")
}
Self::MissingSettingText => f.write_str("custom_world.setting_text 不能为空"),
Self::MissingQuestionSnapshotJson => {
f.write_str("custom_world.question_snapshot_json 不能为空")
}
Self::MissingAnchorContentJson => {
f.write_str("custom_world.anchor_content_json 不能为空")
}
Self::MissingCreatorIntentReadinessJson => {
f.write_str("custom_world.creator_intent_readiness_json 不能为空")
}
Self::MissingAssetCoverageJson => {
f.write_str("custom_world.asset_coverage_json 不能为空")
}
Self::MissingPendingClarificationsJson => {
f.write_str("custom_world.pending_clarifications_json 不能为空")
}
Self::MissingMessageId => f.write_str("custom_world_agent_message.message_id 不能为空"),
Self::MissingMessageText => f.write_str("custom_world_agent_message.text 不能为空"),
Self::MissingOperationId => {
f.write_str("custom_world_agent_operation.operation_id 不能为空")
}
Self::MissingPhaseLabel => {
f.write_str("custom_world_agent_operation.phase_label 不能为空")
}
Self::InvalidProgressPercent => f.write_str("progress 必须位于 0~100"),
Self::MissingCardId => f.write_str("custom_world_draft_card.card_id 不能为空"),
Self::MissingCardTitle => f.write_str("custom_world_draft_card.title 不能为空"),
Self::MissingCardSummary => f.write_str("custom_world_draft_card.summary 不能为空"),
Self::MissingLinkedIdsJson => {
f.write_str("custom_world_draft_card.linked_ids_json 不能为空")
}
Self::MissingAuthorDisplayName => {
f.write_str("custom_world_gallery_entry.author_display_name 不能为空")
}
Self::InvalidDraftProfileJson => {
f.write_str("custom_world.compile.draft_profile_json 不是合法 JSON object")
}
Self::InvalidLegacyResultProfileJson => {
f.write_str("custom_world.compile.legacy_result_profile_json 不是合法 JSON object")
}
Self::InvalidJsonPayload => f.write_str("custom_world JSON payload 结构非法"),
}
}
}
impl Error for CustomWorldFieldError {}

View File

@@ -0,0 +1,68 @@
//! 自定义世界领域事件。
//!
//! 用于表达草稿变化、profile 发布、画廊投影刷新和 Agent 操作进度变化。
use crate::domain::{
RpgAgentDraftCardKind, RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage,
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustomWorldDomainEvent {
ProfileUpserted(CustomWorldProfileUpsertedEvent),
ProfilePublished(CustomWorldProfilePublishedEvent),
GalleryProjectionRefreshed(CustomWorldGalleryProjectionRefreshedEvent),
AgentSessionAdvanced(CustomWorldAgentSessionAdvancedEvent),
AgentOperationProgressed(CustomWorldAgentOperationProgressedEvent),
DraftCardUpdated(CustomWorldDraftCardUpdatedEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfileUpsertedEvent {
pub profile_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldProfilePublishedEvent {
pub profile_id: String,
pub public_work_code: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldGalleryProjectionRefreshedEvent {
pub profile_id: String,
pub public_work_code: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionAdvancedEvent {
pub session_id: String,
pub stage: RpgAgentStage,
pub progress_percent: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentOperationProgressedEvent {
pub session_id: String,
pub operation_id: String,
pub operation_type: RpgAgentOperationType,
pub status: RpgAgentOperationStatus,
pub progress: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardUpdatedEvent {
pub session_id: String,
pub card_id: String,
pub kind: RpgAgentDraftCardKind,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,564 @@
//! 背包应用编排。
//!
//! 这里只返回背包变更结果和领域事件,不直接访问持久化。
use crate::commands::{
ConsumeInventoryItemInput, EquipInventoryItemInput, GrantInventoryItemInput, InventoryMutation,
InventoryMutationInput, RuntimeInventoryStateQueryInput, UnequipInventoryItemInput,
};
use crate::domain::{
InventoryContainerKind, InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot,
InventoryItemSourceKind, InventorySlotSnapshot,
};
use crate::errors::InventoryMutationFieldError;
use serde::{Deserialize, Serialize};
use shared_kernel::{
format_timestamp_micros, normalize_optional_string as normalize_shared_optional_string,
normalize_required_string, normalize_string_list as normalize_shared_string_list,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeInventoryStateSnapshot {
pub runtime_session_id: String,
pub actor_user_id: String,
pub backpack_items: Vec<InventorySlotSnapshot>,
pub equipment_items: Vec<InventorySlotSnapshot>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeInventoryStateProcedureResult {
pub ok: bool,
pub snapshot: Option<RuntimeInventoryStateSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeInventorySlotRecord {
pub slot_id: String,
pub container_kind: String,
pub slot_key: String,
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: String,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<String>,
pub source_kind: String,
pub source_reference_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeInventoryStateRecord {
pub runtime_session_id: String,
pub actor_user_id: String,
pub backpack_items: Vec<RuntimeInventorySlotRecord>,
pub equipment_items: Vec<RuntimeInventorySlotRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InventoryMutationOutcome {
pub next_slots: Vec<InventorySlotSnapshot>,
pub changed: bool,
pub updated_slot_ids: Vec<String>,
pub removed_slot_ids: Vec<String>,
pub affected_equipment_slot: Option<InventoryEquipmentSlot>,
}
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
normalize_shared_string_list(values)
}
pub fn build_runtime_inventory_state_query_input(
runtime_session_id: String,
actor_user_id: String,
) -> Result<RuntimeInventoryStateQueryInput, InventoryMutationFieldError> {
let input = RuntimeInventoryStateQueryInput {
runtime_session_id: normalize_required_text(
runtime_session_id,
InventoryMutationFieldError::MissingRuntimeSessionId,
)?,
actor_user_id: normalize_required_text(
actor_user_id,
InventoryMutationFieldError::MissingActorUserId,
)?,
};
Ok(input)
}
pub fn build_runtime_inventory_state_snapshot(
input: RuntimeInventoryStateQueryInput,
slots: Vec<InventorySlotSnapshot>,
) -> RuntimeInventoryStateSnapshot {
let mut backpack_items = Vec::new();
let mut equipment_items = Vec::new();
for slot in slots {
match slot.container_kind {
InventoryContainerKind::Backpack => backpack_items.push(slot),
InventoryContainerKind::Equipment => equipment_items.push(slot),
}
}
backpack_items.sort_by(|left, right| {
left.slot_key
.cmp(&right.slot_key)
.then(left.slot_id.cmp(&right.slot_id))
});
equipment_items.sort_by(|left, right| {
equipment_slot_order(left.equipment_slot_id)
.cmp(&equipment_slot_order(right.equipment_slot_id))
.then(left.slot_id.cmp(&right.slot_id))
});
RuntimeInventoryStateSnapshot {
runtime_session_id: input.runtime_session_id,
actor_user_id: input.actor_user_id,
backpack_items,
equipment_items,
}
}
pub fn apply_inventory_mutation(
current_slots: Vec<InventorySlotSnapshot>,
input: InventoryMutationInput,
) -> Result<InventoryMutationOutcome, InventoryMutationFieldError> {
let _mutation_id = normalize_required_text(
input.mutation_id,
InventoryMutationFieldError::MissingMutationId,
)?;
let runtime_session_id = normalize_required_text(
input.runtime_session_id,
InventoryMutationFieldError::MissingRuntimeSessionId,
)?;
let actor_user_id = normalize_required_text(
input.actor_user_id,
InventoryMutationFieldError::MissingActorUserId,
)?;
let story_session_id = normalize_optional_text(input.story_session_id);
let mut slots = current_slots;
for slot in &slots {
if slot.runtime_session_id != runtime_session_id || slot.actor_user_id != actor_user_id {
return Err(InventoryMutationFieldError::SlotScopeMismatch);
}
}
let outcome = match input.mutation {
InventoryMutation::GrantItem(grant) => apply_grant_item(
&mut slots,
runtime_session_id,
story_session_id,
actor_user_id,
grant,
input.updated_at_micros,
)?,
InventoryMutation::ConsumeItem(consume) => {
apply_consume_item(&mut slots, consume, input.updated_at_micros)?
}
InventoryMutation::EquipItem(equip) => {
apply_equip_item(&mut slots, equip, input.updated_at_micros)?
}
InventoryMutation::UnequipItem(unequip) => {
apply_unequip_item(&mut slots, unequip, input.updated_at_micros)?
}
};
Ok(InventoryMutationOutcome {
next_slots: sort_inventory_slots(slots),
changed: outcome.changed,
updated_slot_ids: sort_string_list(outcome.updated_slot_ids),
removed_slot_ids: sort_string_list(outcome.removed_slot_ids),
affected_equipment_slot: outcome.affected_equipment_slot,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct InventoryMutationInternalOutcome {
changed: bool,
updated_slot_ids: Vec<String>,
removed_slot_ids: Vec<String>,
affected_equipment_slot: Option<InventoryEquipmentSlot>,
}
fn apply_grant_item(
slots: &mut Vec<InventorySlotSnapshot>,
runtime_session_id: String,
story_session_id: Option<String>,
actor_user_id: String,
grant: GrantInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(grant.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let item = normalize_inventory_item_snapshot(grant.item)?;
if item.stackable {
if let Some(existing) = slots.iter_mut().find(|slot| {
slot.container_kind == InventoryContainerKind::Backpack
&& slot.stackable
&& slot.item_id == item.item_id
&& slot.stack_key == item.stack_key
}) {
existing.category = item.category;
existing.name = item.name;
existing.description = item.description;
existing.quantity += item.quantity;
existing.rarity = item.rarity;
existing.tags = item.tags;
existing.stackable = item.stackable;
existing.stack_key = item.stack_key;
existing.equipment_slot_id = item.equipment_slot_id;
existing.source_kind = item.source_kind;
existing.source_reference_id = item.source_reference_id;
existing.updated_at_micros = updated_at_micros;
return Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![existing.slot_id.clone()],
removed_slot_ids: vec![],
affected_equipment_slot: None,
});
}
}
slots.push(InventorySlotSnapshot {
slot_id: slot_id.clone(),
runtime_session_id,
story_session_id,
actor_user_id,
container_kind: InventoryContainerKind::Backpack,
slot_key: build_backpack_slot_key(&slot_id),
item_id: item.item_id,
category: item.category,
name: item.name,
description: item.description,
quantity: item.quantity,
rarity: item.rarity,
tags: item.tags,
stackable: item.stackable,
stack_key: item.stack_key,
equipment_slot_id: item.equipment_slot_id,
source_kind: item.source_kind,
source_reference_id: item.source_reference_id,
created_at_micros: updated_at_micros,
updated_at_micros,
});
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slot_id],
removed_slot_ids: vec![],
affected_equipment_slot: None,
})
}
fn apply_consume_item(
slots: &mut Vec<InventorySlotSnapshot>,
consume: ConsumeInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(consume.slot_id, InventoryMutationFieldError::MissingSlotId)?;
if consume.quantity == 0 {
return Err(InventoryMutationFieldError::InvalidQuantity);
}
let slot_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
if slots[slot_index].container_kind != InventoryContainerKind::Backpack {
return Err(InventoryMutationFieldError::ItemNotInBackpack);
}
if slots[slot_index].quantity < consume.quantity {
return Err(InventoryMutationFieldError::InsufficientQuantity);
}
if slots[slot_index].quantity == consume.quantity {
slots.remove(slot_index);
return Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![],
removed_slot_ids: vec![slot_id],
affected_equipment_slot: None,
});
}
slots[slot_index].quantity -= consume.quantity;
slots[slot_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slots[slot_index].slot_id.clone()],
removed_slot_ids: vec![],
affected_equipment_slot: None,
})
}
fn apply_equip_item(
slots: &mut [InventorySlotSnapshot],
equip: EquipInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(equip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let source_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
let target_slot = slots[source_index]
.equipment_slot_id
.ok_or(InventoryMutationFieldError::ItemNotEquippable)?;
if slots[source_index].stackable {
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
}
if slots[source_index].quantity != 1 {
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
}
if slots[source_index].container_kind != InventoryContainerKind::Backpack {
if slots[source_index].container_kind == InventoryContainerKind::Equipment {
return Ok(InventoryMutationInternalOutcome {
changed: false,
updated_slot_ids: vec![],
removed_slot_ids: vec![],
affected_equipment_slot: Some(target_slot),
});
}
return Err(InventoryMutationFieldError::ItemNotInBackpack);
}
let occupied_index = slots.iter().position(|slot| {
slot.container_kind == InventoryContainerKind::Equipment
&& slot.slot_key == build_equipment_slot_key(target_slot)
});
let mut updated_slot_ids = vec![slot_id.clone()];
if let Some(occupied_index) = occupied_index {
// 首版装备互换直接在同一条 slot 真相记录上切容器,不生成临时副本。
slots[occupied_index].container_kind = InventoryContainerKind::Backpack;
slots[occupied_index].slot_key = build_backpack_slot_key(&slots[occupied_index].slot_id);
slots[occupied_index].updated_at_micros = updated_at_micros;
updated_slot_ids.push(slots[occupied_index].slot_id.clone());
}
slots[source_index].container_kind = InventoryContainerKind::Equipment;
slots[source_index].slot_key = build_equipment_slot_key(target_slot);
slots[source_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids,
removed_slot_ids: vec![],
affected_equipment_slot: Some(target_slot),
})
}
fn apply_unequip_item(
slots: &mut [InventorySlotSnapshot],
unequip: UnequipInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(unequip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let slot_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
if slots[slot_index].container_kind != InventoryContainerKind::Equipment {
return Err(InventoryMutationFieldError::ItemNotEquipped);
}
let affected_equipment_slot = slots[slot_index].equipment_slot_id;
slots[slot_index].container_kind = InventoryContainerKind::Backpack;
slots[slot_index].slot_key = build_backpack_slot_key(&slot_id);
slots[slot_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slot_id],
removed_slot_ids: vec![],
affected_equipment_slot,
})
}
fn normalize_inventory_item_snapshot(
item: InventoryItemSnapshot,
) -> Result<InventoryItemSnapshot, InventoryMutationFieldError> {
let item_id =
normalize_required_text(item.item_id, InventoryMutationFieldError::MissingItemId)?;
let category =
normalize_required_text(item.category, InventoryMutationFieldError::MissingCategory)?;
let name = normalize_required_text(item.name, InventoryMutationFieldError::MissingName)?;
if item.quantity == 0 {
return Err(InventoryMutationFieldError::InvalidQuantity);
}
if !item.stackable && item.quantity != 1 {
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
}
if item.equipment_slot_id.is_some() && item.stackable {
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
}
let stack_key = if item.stackable {
normalize_required_text(item.stack_key, InventoryMutationFieldError::MissingStackKey)?
} else {
normalize_optional_text(Some(item.stack_key)).unwrap_or_else(|| item_id.clone())
};
Ok(InventoryItemSnapshot {
item_id,
category,
name,
description: normalize_optional_text(item.description),
quantity: item.quantity,
rarity: item.rarity,
tags: normalize_string_list(item.tags),
stackable: item.stackable,
stack_key,
equipment_slot_id: item.equipment_slot_id,
source_kind: item.source_kind,
source_reference_id: normalize_optional_text(item.source_reference_id),
})
}
fn normalize_required_text(
value: String,
error: InventoryMutationFieldError,
) -> Result<String, InventoryMutationFieldError> {
normalize_required_string(value).ok_or(error)
}
fn sort_inventory_slots(mut slots: Vec<InventorySlotSnapshot>) -> Vec<InventorySlotSnapshot> {
slots.sort_by(|left, right| {
container_order(left.container_kind)
.cmp(&container_order(right.container_kind))
.then(left.slot_key.cmp(&right.slot_key))
.then(left.slot_id.cmp(&right.slot_id))
});
slots
}
fn sort_string_list(mut values: Vec<String>) -> Vec<String> {
values.sort();
values
}
fn container_order(kind: InventoryContainerKind) -> u8 {
match kind {
InventoryContainerKind::Equipment => 0,
InventoryContainerKind::Backpack => 1,
}
}
fn equipment_slot_order(slot: Option<InventoryEquipmentSlot>) -> u8 {
match slot {
Some(InventoryEquipmentSlot::Weapon) => 0,
Some(InventoryEquipmentSlot::Armor) => 1,
Some(InventoryEquipmentSlot::Relic) => 2,
None => 3,
}
}
fn build_backpack_slot_key(slot_id: &str) -> String {
slot_id.to_string()
}
fn build_equipment_slot_key(slot: InventoryEquipmentSlot) -> String {
slot.as_str().to_string()
}
pub fn build_runtime_inventory_state_record(
snapshot: RuntimeInventoryStateSnapshot,
) -> RuntimeInventoryStateRecord {
RuntimeInventoryStateRecord {
runtime_session_id: snapshot.runtime_session_id,
actor_user_id: snapshot.actor_user_id,
backpack_items: snapshot
.backpack_items
.into_iter()
.map(build_runtime_inventory_slot_record)
.collect(),
equipment_items: snapshot
.equipment_items
.into_iter()
.map(build_runtime_inventory_slot_record)
.collect(),
}
}
fn build_runtime_inventory_slot_record(slot: InventorySlotSnapshot) -> RuntimeInventorySlotRecord {
RuntimeInventorySlotRecord {
slot_id: slot.slot_id,
container_kind: format_inventory_container_kind(slot.container_kind).to_string(),
slot_key: slot.slot_key,
item_id: slot.item_id,
category: slot.category,
name: slot.name,
description: slot.description,
quantity: slot.quantity,
rarity: format_inventory_item_rarity(slot.rarity).to_string(),
tags: slot.tags,
stackable: slot.stackable,
stack_key: slot.stack_key,
equipment_slot_id: slot
.equipment_slot_id
.map(|value| value.as_str().to_string()),
source_kind: format_inventory_item_source_kind(slot.source_kind).to_string(),
source_reference_id: slot.source_reference_id,
created_at: format_timestamp_micros(slot.created_at_micros),
updated_at: format_timestamp_micros(slot.updated_at_micros),
}
}
fn format_inventory_container_kind(value: InventoryContainerKind) -> &'static str {
match value {
InventoryContainerKind::Backpack => "backpack",
InventoryContainerKind::Equipment => "equipment",
}
}
fn format_inventory_item_rarity(value: InventoryItemRarity) -> &'static str {
match value {
InventoryItemRarity::Common => "common",
InventoryItemRarity::Uncommon => "uncommon",
InventoryItemRarity::Rare => "rare",
InventoryItemRarity::Epic => "epic",
InventoryItemRarity::Legendary => "legendary",
}
}
fn format_inventory_item_source_kind(value: InventoryItemSourceKind) -> &'static str {
match value {
InventoryItemSourceKind::StoryReward => "story_reward",
InventoryItemSourceKind::QuestReward => "quest_reward",
InventoryItemSourceKind::TreasureReward => "treasure_reward",
InventoryItemSourceKind::NpcGift => "npc_gift",
InventoryItemSourceKind::NpcTrade => "npc_trade",
InventoryItemSourceKind::CombatDrop => "combat_drop",
InventoryItemSourceKind::ForgeCraft => "forge_craft",
InventoryItemSourceKind::ForgeReforge => "forge_reforge",
InventoryItemSourceKind::ManualPatch => "manual_patch",
}
}

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