Merge remote-tracking branch 'origin/master' into codex/publish-flow
Some checks failed
CI / verify (pull_request) Has been cancelled
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:
@@ -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" }
|
||||
|
||||
@@ -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` 进入后端真相源。
|
||||
|
||||
@@ -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 个 chapters,chapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":{"message":"story dossier timeout"}}
|
||||
@@ -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 个 chapters,chapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":{"message":"story dossier timeout"}}
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("尚未编译")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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-pro;RPG 主图当前统一归一到 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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,17 +185,22 @@ pub async fn generate_custom_world_profile(
|
||||
);
|
||||
|
||||
// 中文注释:profile 生成需要外部 LLM,必须留在 Axum/api-server;SpacetimeDB 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
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -34,6 +34,10 @@ impl AppError {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
self.status_code
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
632
server-rs/crates/api-server/src/openai_image_generation.rs
Normal file
632
server-rs/crates/api-server/src/openai_image_generation.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
133
server-rs/crates/api-server/src/platform_errors.rs
Normal file
133
server-rs/crates/api-server/src/platform_errors.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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} 的局势随之向下一步展开。")
|
||||
}
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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 快照记录恢复认证快照失败");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
¤t_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
|
||||
|
||||
@@ -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,
|
||||
¤t_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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. 当前仍未进入的范围
|
||||
|
||||
|
||||
19
server-rs/crates/module-ai/src/application.rs
Normal file
19
server-rs/crates/module-ai/src/application.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
14
server-rs/crates/module-ai/src/application/result.rs
Normal file
14
server-rs/crates/module-ai/src/application/result.rs
Normal 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>,
|
||||
}
|
||||
250
server-rs/crates/module-ai/src/application/service.rs
Normal file
250
server-rs/crates/module-ai/src/application/service.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
138
server-rs/crates/module-ai/src/application/store.rs
Normal file
138
server-rs/crates/module-ai/src/application/store.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
9
server-rs/crates/module-ai/src/commands.rs
Normal file
9
server-rs/crates/module-ai/src/commands.rs
Normal 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;
|
||||
87
server-rs/crates/module-ai/src/commands/inputs.rs
Normal file
87
server-rs/crates/module-ai/src/commands/inputs.rs
Normal 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,
|
||||
}
|
||||
40
server-rs/crates/module-ai/src/commands/validation.rs
Normal file
40
server-rs/crates/module-ai/src/commands/validation.rs
Normal 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(())
|
||||
}
|
||||
15
server-rs/crates/module-ai/src/domain.rs
Normal file
15
server-rs/crates/module-ai/src/domain.rs
Normal 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,
|
||||
};
|
||||
41
server-rs/crates/module-ai/src/domain/ids.rs
Normal file
41
server-rs/crates/module-ai/src/domain/ids.rs
Normal 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)
|
||||
}
|
||||
77
server-rs/crates/module-ai/src/domain/stages.rs
Normal file
77
server-rs/crates/module-ai/src/domain/stages.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
124
server-rs/crates/module-ai/src/domain/types.rs
Normal file
124
server-rs/crates/module-ai/src/domain/types.rs
Normal 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,
|
||||
}
|
||||
61
server-rs/crates/module-ai/src/errors.rs
Normal file
61
server-rs/crates/module-ai/src/errors.rs
Normal 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 {}
|
||||
32
server-rs/crates/module-ai/src/events.rs
Normal file
32
server-rs/crates/module-ai/src/events.rs
Normal 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
220
server-rs/crates/module-ai/src/tests.rs
Normal file
220
server-rs/crates/module-ai/src/tests.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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. 边界约束
|
||||
|
||||
|
||||
45
server-rs/crates/module-assets/src/application.rs
Normal file
45
server-rs/crates/module-assets/src/application.rs
Normal 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,
|
||||
};
|
||||
@@ -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::*;
|
||||
|
||||
105
server-rs/crates/module-assets/src/commands.rs
Normal file
105
server-rs/crates/module-assets/src/commands.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
128
server-rs/crates/module-assets/src/domain.rs
Normal file
128
server-rs/crates/module-assets/src/domain.rs
Normal 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,
|
||||
}
|
||||
36
server-rs/crates/module-assets/src/errors.rs
Normal file
36
server-rs/crates/module-assets/src/errors.rs
Normal 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 {}
|
||||
43
server-rs/crates/module-assets/src/events.rs
Normal file
43
server-rs/crates/module-assets/src/events.rs
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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` 仍保留进程内仓储和文件持久化支撑,但不再继续拥有命令、结果、错误、事件和纯领域值对象定义。
|
||||
|
||||
118
server-rs/crates/module-auth/src/application.rs
Normal file
118
server-rs/crates/module-auth/src/application.rs
Normal 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>,
|
||||
}
|
||||
99
server-rs/crates/module-auth/src/commands.rs
Normal file
99
server-rs/crates/module-auth/src/commands.rs
Normal 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,
|
||||
}
|
||||
256
server-rs/crates/module-auth/src/domain.rs
Normal file
256
server-rs/crates/module-auth/src/domain.rs
Normal 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())
|
||||
}
|
||||
194
server-rs/crates/module-auth/src/errors.rs
Normal file
194
server-rs/crates/module-auth/src/errors.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
27
server-rs/crates/module-auth/src/events.rs
Normal file
27
server-rs/crates/module-auth/src/events.rs
Normal 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
34
server-rs/crates/module-big-fish/README.md
Normal file
34
server-rs/crates/module-big-fish/README.md
Normal 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 承接。
|
||||
1266
server-rs/crates/module-big-fish/src/application.rs
Normal file
1266
server-rs/crates/module-big-fish/src/application.rs
Normal file
File diff suppressed because it is too large
Load Diff
196
server-rs/crates/module-big-fish/src/commands.rs
Normal file
196
server-rs/crates/module-big-fish/src/commands.rs
Normal 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>,
|
||||
}
|
||||
370
server-rs/crates/module-big-fish/src/domain.rs
Normal file
370
server-rs/crates/module-big-fish/src/domain.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
60
server-rs/crates/module-big-fish/src/errors.rs
Normal file
60
server-rs/crates/module-big-fish/src/errors.rs
Normal 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 {}
|
||||
31
server-rs/crates/module-big-fish/src/events.rs
Normal file
31
server-rs/crates/module-big-fish/src/events.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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. 边界约束
|
||||
|
||||
|
||||
291
server-rs/crates/module-combat/src/application.rs
Normal file
291
server-rs/crates/module-combat/src/application.rs
Normal 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, ¤t.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, ¤t.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
|
||||
),
|
||||
}
|
||||
}
|
||||
170
server-rs/crates/module-combat/src/commands.rs
Normal file
170
server-rs/crates/module-combat/src/commands.rs
Normal 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)
|
||||
}
|
||||
118
server-rs/crates/module-combat/src/domain.rs
Normal file
118
server-rs/crates/module-combat/src/domain.rs
Normal 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,
|
||||
}
|
||||
50
server-rs/crates/module-combat/src/errors.rs
Normal file
50
server-rs/crates/module-combat/src/errors.rs
Normal 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 {}
|
||||
34
server-rs/crates/module-combat/src/events.rs
Normal file
34
server-rs/crates/module-combat/src/events.rs
Normal 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,
|
||||
}
|
||||
@@ -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, ¤t.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, ¤t.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 {
|
||||
|
||||
@@ -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 直接相关的任务包括:
|
||||
|
||||
|
||||
957
server-rs/crates/module-custom-world/src/application.rs
Normal file
957
server-rs/crates/module-custom-world/src/application.rs
Normal 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)
|
||||
}
|
||||
311
server-rs/crates/module-custom-world/src/commands.rs
Normal file
311
server-rs/crates/module-custom-world/src/commands.rs
Normal 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>,
|
||||
}
|
||||
554
server-rs/crates/module-custom-world/src/domain.rs
Normal file
554
server-rs/crates/module-custom-world/src/domain.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
100
server-rs/crates/module-custom-world/src/errors.rs
Normal file
100
server-rs/crates/module-custom-world/src/errors.rs
Normal 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 {}
|
||||
68
server-rs/crates/module-custom-world/src/events.rs
Normal file
68
server-rs/crates/module-custom-world/src/events.rs
Normal 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
564
server-rs/crates/module-inventory/src/application.rs
Normal file
564
server-rs/crates/module-inventory/src/application.rs
Normal 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
Reference in New Issue
Block a user