推进 server-rs DDD 分层与新接口接线
This commit is contained in:
6
server-rs/Cargo.lock
generated
6
server-rs/Cargo.lock
generated
@@ -89,7 +89,7 @@ dependencies = [
|
||||
"module-puzzle",
|
||||
"module-runtime",
|
||||
"module-runtime-item",
|
||||
"module-runtime-story-compat",
|
||||
"module-runtime-story",
|
||||
"module-story",
|
||||
"platform-auth",
|
||||
"platform-llm",
|
||||
@@ -1624,7 +1624,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-runtime-story-compat"
|
||||
name = "module-runtime-story"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
@@ -2668,9 +2668,11 @@ dependencies = [
|
||||
"module-puzzle",
|
||||
"module-runtime",
|
||||
"module-runtime-item",
|
||||
"module-runtime-story",
|
||||
"module-story",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared-contracts",
|
||||
"shared-kernel",
|
||||
"spacetimedb-sdk",
|
||||
"tokio",
|
||||
|
||||
@@ -20,7 +20,7 @@ members = [
|
||||
"crates/module-progression",
|
||||
"crates/module-quest",
|
||||
"crates/module-runtime",
|
||||
"crates/module-runtime-story-compat",
|
||||
"crates/module-runtime-story",
|
||||
"crates/module-runtime-item",
|
||||
"crates/module-story",
|
||||
"crates/platform-oss",
|
||||
|
||||
@@ -23,7 +23,7 @@ module-inventory = { path = "../module-inventory" }
|
||||
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" }
|
||||
|
||||
@@ -111,16 +111,13 @@ 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_session, continue_story, get_story_runtime_projection, get_story_session_state,
|
||||
},
|
||||
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
|
||||
};
|
||||
|
||||
@@ -991,48 +988,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(
|
||||
@@ -1054,6 +1009,13 @@ 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/continue",
|
||||
post(continue_story).route_layer(middleware::from_fn_with_state(
|
||||
@@ -1312,6 +1274,53 @@ 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 internal_auth_claims_rejects_missing_bearer_token() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -55,7 +55,6 @@ mod runtime_inventory;
|
||||
mod runtime_profile;
|
||||
mod runtime_save;
|
||||
mod runtime_settings;
|
||||
mod runtime_story;
|
||||
mod session_client;
|
||||
mod state;
|
||||
mod story_battles;
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
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,
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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,368 +0,0 @@
|
||||
use super::*;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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} 的局势随之向下一步展开。")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,523 +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()
|
||||
.map(|monster| normalize_npc_battle_monster(monster, battle_mode))
|
||||
.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, battle_mode: &str) -> 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));
|
||||
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,735 +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> {
|
||||
let mut options = vec![
|
||||
build_npc_runtime_story_option("npc_chat", "继续交谈", npc_id, "chat"),
|
||||
build_npc_help_runtime_story_option(game_state, npc_id),
|
||||
build_npc_runtime_story_option("npc_spar", "点到为止切磋", npc_id, "spar"),
|
||||
build_npc_runtime_story_option("npc_fight", "与对方战斗", npc_id, "fight"),
|
||||
];
|
||||
|
||||
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.push(build_npc_runtime_story_option(
|
||||
"npc_leave",
|
||||
"离开当前角色",
|
||||
npc_id,
|
||||
"leave",
|
||||
));
|
||||
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
@@ -166,6 +166,27 @@ pub async fn get_story_session_state(
|
||||
))
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -381,6 +402,61 @@ 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())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
|
||||
@@ -16,17 +16,23 @@
|
||||
当前提交已完成:
|
||||
|
||||
1. `module-ai` 的 `Cargo.toml`
|
||||
2. 首版核心类型:
|
||||
2. DDD 分层文件:
|
||||
- `src/domain.rs`
|
||||
- `src/commands.rs`
|
||||
- `src/application.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 +40,14 @@
|
||||
- `AiTaskFinishInput`
|
||||
- `AiTaskCancelInput`
|
||||
- `AiTaskFailureInput`
|
||||
7. 基础单元测试
|
||||
8. 基础单元测试
|
||||
|
||||
首版详细设计见:
|
||||
|
||||
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)
|
||||
|
||||
## 3. 当前仍未进入的范围
|
||||
|
||||
|
||||
@@ -1,4 +1,400 @@
|
||||
//! AI 应用编排过渡落位。
|
||||
//!
|
||||
//! 这里仅返回纯应用结果或领域事件;真实 LLM 调用继续留在 `platform-llm`
|
||||
//! 与 `api-server` 编排层。
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use shared_kernel::normalize_required_string;
|
||||
|
||||
use crate::commands::validate_task_create_input;
|
||||
use crate::{
|
||||
AiResultReferenceKind, AiResultReferenceSnapshot, AiStageCompletionInput, AiTaskCreateInput,
|
||||
AiTaskFieldError, AiTaskServiceError, AiTaskSnapshot, 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 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 AiTaskProcedureResult {
|
||||
pub ok: bool,
|
||||
pub task: Option<AiTaskSnapshot>,
|
||||
pub text_chunk: Option<AiTextChunkSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[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>>,
|
||||
}
|
||||
|
||||
#[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: crate::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: crate::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)
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemoryAiTaskStore {
|
||||
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)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_task_is_not_terminal(status: AiTaskStatus) -> Result<(), AiTaskServiceError> {
|
||||
if status.is_terminal() {
|
||||
Err(AiTaskServiceError::Field(
|
||||
AiTaskFieldError::InvalidTaskState,
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,125 @@
|
||||
//! AI 写入命令过渡落位。
|
||||
//!
|
||||
//! 只描述创建任务、推进阶段、追加文本片段和挂接结果引用等用例输入,
|
||||
//! 不承载外部模型请求或持久化细节。
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::normalize_required_string;
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
use crate::{
|
||||
AiResultReferenceKind, AiTaskFieldError, 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,
|
||||
}
|
||||
|
||||
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::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(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,239 @@
|
||||
//! AI 领域模型过渡落位。
|
||||
//!
|
||||
//! 当前历史实现仍在 `lib.rs`。后续迁移 `AiTask`、阶段、流式片段和结果引用时,
|
||||
//! 只能放入纯领域类型与状态迁移,不能引入 LLM、HTTP 或 SpacetimeDB adapter。
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
build_prefixed_seed_id, normalize_optional_string as normalize_shared_optional_string,
|
||||
normalize_string_list as normalize_shared_string_list,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
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;
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,61 @@
|
||||
//! AI 领域错误过渡落位。
|
||||
//!
|
||||
//! 错误必须可被 HTTP adapter 和 SpacetimeDB adapter 显式映射,不能直接绑定状态码。
|
||||
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 {}
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
//! AI 领域事件过渡落位。
|
||||
//!
|
||||
//! 用于表达任务开始、阶段完成、任务失败和结果引用挂接等跨上下文事实。
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,832 +4,22 @@ mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
sync::{Arc, Mutex},
|
||||
pub use application::{AiTaskProcedureResult, AiTaskService, InMemoryAiTaskStore};
|
||||
pub use commands::{
|
||||
AiResultReferenceInput, AiStageCompletionInput, AiTaskCancelInput, AiTaskCreateInput,
|
||||
AiTaskFailureInput, AiTaskFinishInput, AiTaskStageStartInput, AiTaskStartInput,
|
||||
AiTextChunkAppendInput, validate_task_create_input,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
build_prefixed_seed_id, normalize_optional_string as normalize_shared_optional_string,
|
||||
normalize_required_string, normalize_string_list as normalize_shared_string_list,
|
||||
pub use domain::{
|
||||
AI_RESULT_REF_ID_PREFIX, AI_TASK_ID_PREFIX, AI_TASK_STAGE_ID_PREFIX, AI_TEXT_CHUNK_ID_PREFIX,
|
||||
AiResultReferenceKind, AiResultReferenceSnapshot, AiTaskKind, AiTaskSnapshot,
|
||||
AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageSnapshot, AiTaskStageStatus, AiTaskStatus,
|
||||
AiTextChunkSnapshot, 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,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
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;
|
||||
|
||||
// AI 编排类型与当前 Node 正式运行时主链保持一致,避免后续接线时重新发明命名。
|
||||
#[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 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 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 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 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,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[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),
|
||||
}
|
||||
|
||||
#[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>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AiTaskService {
|
||||
store: InMemoryAiTaskStore,
|
||||
}
|
||||
|
||||
impl AiTaskKind {
|
||||
// 默认阶段蓝图只冻结通用语义,具体 prompt 内容与供应商策略仍由上层模块决定。
|
||||
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 {
|
||||
fn is_terminal(self) -> bool {
|
||||
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
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| {
|
||||
if task.status.is_terminal() {
|
||||
return Err(AiTaskServiceError::Field(
|
||||
AiTaskFieldError::InvalidTaskState,
|
||||
));
|
||||
}
|
||||
|
||||
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| {
|
||||
if task.status.is_terminal() {
|
||||
return Err(AiTaskServiceError::Field(
|
||||
AiTaskFieldError::InvalidTaskState,
|
||||
));
|
||||
}
|
||||
|
||||
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| {
|
||||
if task.status.is_terminal() {
|
||||
return Err(AiTaskServiceError::Field(
|
||||
AiTaskFieldError::InvalidTaskState,
|
||||
));
|
||||
}
|
||||
|
||||
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| {
|
||||
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| {
|
||||
if task.status.is_terminal() {
|
||||
return Err(AiTaskServiceError::Field(
|
||||
AiTaskFieldError::InvalidTaskState,
|
||||
));
|
||||
}
|
||||
|
||||
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| {
|
||||
if task.status.is_terminal() {
|
||||
return Err(AiTaskServiceError::Field(
|
||||
AiTaskFieldError::InvalidTaskState,
|
||||
));
|
||||
}
|
||||
|
||||
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| {
|
||||
if task.status.is_terminal() {
|
||||
return Err(AiTaskServiceError::Field(
|
||||
AiTaskFieldError::InvalidTaskState,
|
||||
));
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemoryAiTaskStore {
|
||||
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)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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)?;
|
||||
if task.status.is_terminal() {
|
||||
return Err(AiTaskServiceError::Field(
|
||||
AiTaskFieldError::InvalidTaskState,
|
||||
));
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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::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(())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {}
|
||||
pub use errors::{AiTaskFieldError, AiTaskServiceError};
|
||||
pub use events::AiTaskDomainEvent;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -1,3 +1,136 @@
|
||||
//! 大鱼吃小鱼应用编排过渡落位。
|
||||
//!
|
||||
//! 这里只组合领域规则并返回结果或事件,不直接调用外部图片、视频或存储服务。
|
||||
|
||||
use shared_kernel::normalize_required_string;
|
||||
|
||||
use crate::{
|
||||
BigFishAssetSlotSnapshot, build_asset_coverage,
|
||||
commands::EvaluateBigFishPublishReadinessCommand, domain::BigFishPublishReadiness,
|
||||
errors::BigFishApplicationError, events::BigFishDomainEvent,
|
||||
};
|
||||
|
||||
/// 发布门禁应用结果,供 adapter 持久化快照或转换成 API DTO。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EvaluateBigFishPublishReadinessResult {
|
||||
pub readiness: BigFishPublishReadiness,
|
||||
pub events: Vec<BigFishDomainEvent>,
|
||||
}
|
||||
|
||||
/// 评估 Big Fish 作品是否具备发布条件。
|
||||
///
|
||||
/// 规则只依赖草稿和资产槽:草稿必须存在,等级主图、基础动作和背景图
|
||||
/// 必须满足 `build_asset_coverage` 的统一口径。
|
||||
pub fn evaluate_publish_readiness(
|
||||
command: EvaluateBigFishPublishReadinessCommand,
|
||||
asset_slots: &[BigFishAssetSlotSnapshot],
|
||||
) -> Result<EvaluateBigFishPublishReadinessResult, BigFishApplicationError> {
|
||||
let session_id = normalize_required_string(command.session_id)
|
||||
.ok_or(BigFishApplicationError::MissingSessionId)?;
|
||||
let owner_user_id = normalize_required_string(command.owner_user_id)
|
||||
.ok_or(BigFishApplicationError::MissingOwnerUserId)?;
|
||||
let coverage = build_asset_coverage(command.draft.as_ref(), asset_slots);
|
||||
let readiness = BigFishPublishReadiness {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
publish_ready: coverage.publish_ready,
|
||||
blockers: coverage.blockers.clone(),
|
||||
evaluated_at_micros: command.evaluated_at_micros,
|
||||
};
|
||||
let event = BigFishDomainEvent::PublishReadinessEvaluated {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
publish_ready: readiness.publish_ready,
|
||||
blockers: readiness.blockers.clone(),
|
||||
occurred_at_micros: readiness.evaluated_at_micros,
|
||||
};
|
||||
|
||||
Ok(EvaluateBigFishPublishReadinessResult {
|
||||
readiness,
|
||||
events: vec![event],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
BigFishAssetKind, build_generated_asset_slot, compile_default_draft, infer_anchor_pack,
|
||||
};
|
||||
|
||||
fn build_command() -> EvaluateBigFishPublishReadinessCommand {
|
||||
EvaluateBigFishPublishReadinessCommand {
|
||||
session_id: "big-fish-session-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
draft: Some(compile_default_draft(&infer_anchor_pack("机械深海", None))),
|
||||
evaluated_at_micros: 1_713_680_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_publish_readiness_reports_blockers_when_assets_missing() {
|
||||
let result = evaluate_publish_readiness(build_command(), &[]).expect("result");
|
||||
|
||||
assert!(!result.readiness.publish_ready);
|
||||
assert!(
|
||||
result
|
||||
.readiness
|
||||
.blockers
|
||||
.iter()
|
||||
.any(|item| item.contains("等级主图"))
|
||||
);
|
||||
assert_eq!(result.events.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_publish_readiness_accepts_complete_assets() {
|
||||
let command = build_command();
|
||||
let draft = command.draft.clone().expect("draft");
|
||||
let mut slots = Vec::new();
|
||||
for level in 1..=draft.runtime_params.level_count {
|
||||
slots.push(
|
||||
build_generated_asset_slot(
|
||||
&command.session_id,
|
||||
&draft,
|
||||
BigFishAssetKind::LevelMainImage,
|
||||
Some(level),
|
||||
None,
|
||||
Some(format!("/assets/level-{level}.png")),
|
||||
command.evaluated_at_micros + level as i64,
|
||||
)
|
||||
.expect("main image slot"),
|
||||
);
|
||||
for motion_key in ["idle_float", "move_swim"] {
|
||||
slots.push(
|
||||
build_generated_asset_slot(
|
||||
&command.session_id,
|
||||
&draft,
|
||||
BigFishAssetKind::LevelMotion,
|
||||
Some(level),
|
||||
Some(motion_key.to_string()),
|
||||
Some(format!("/assets/level-{level}-{motion_key}.webm")),
|
||||
command.evaluated_at_micros + 100 + level as i64,
|
||||
)
|
||||
.expect("motion slot"),
|
||||
);
|
||||
}
|
||||
}
|
||||
slots.push(
|
||||
build_generated_asset_slot(
|
||||
&command.session_id,
|
||||
&draft,
|
||||
BigFishAssetKind::StageBackground,
|
||||
None,
|
||||
None,
|
||||
Some("/assets/bg.png".to_string()),
|
||||
command.evaluated_at_micros + 1_000,
|
||||
)
|
||||
.expect("background slot"),
|
||||
);
|
||||
|
||||
let result = evaluate_publish_readiness(command, &slots).expect("result");
|
||||
|
||||
assert!(result.readiness.publish_ready);
|
||||
assert!(result.readiness.blockers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
//! 大鱼吃小鱼写入命令过渡落位。
|
||||
//!
|
||||
//! 用于表达创建会话、写入消息、更新资产槽和推进运行态等输入。
|
||||
|
||||
use crate::BigFishGameDraft;
|
||||
|
||||
/// 评估作品是否可以发布的纯领域命令。
|
||||
///
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -2,3 +2,15 @@
|
||||
//!
|
||||
//! 后续迁移创作会话、资产槽和运行态聚合时,只保留玩法状态与规则;
|
||||
//! 图片生成、OSS 与 HTTP handler 均留在 adapter 层。
|
||||
|
||||
/// 发布门禁的领域判定结果。
|
||||
///
|
||||
/// 这里不保存外部任务状态,只表达当前聚合快照是否满足发布条件。
|
||||
#[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,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
//! 大鱼吃小鱼领域错误过渡落位。
|
||||
//!
|
||||
//! 错误只表达玩法规则失败,由 HTTP 和 SpacetimeDB adapter 分别映射展示。
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
/// 大鱼吃小鱼应用服务错误。
|
||||
///
|
||||
/// 这里不携带 HTTP status 或 SpacetimeDB 字符串错误,避免领域层泄漏 adapter 语义。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BigFishApplicationError {
|
||||
MissingSessionId,
|
||||
MissingOwnerUserId,
|
||||
}
|
||||
|
||||
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 不能为空"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for BigFishApplicationError {}
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
//! 大鱼吃小鱼领域事件过渡落位。
|
||||
//!
|
||||
//! 用于表达草稿变化、资产槽变化和运行态 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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
pub use application::{EvaluateBigFishPublishReadinessResult, evaluate_publish_readiness};
|
||||
pub use commands::EvaluateBigFishPublishReadinessCommand;
|
||||
pub use domain::BigFishPublishReadiness;
|
||||
pub use errors::BigFishApplicationError;
|
||||
pub use events::BigFishDomainEvent;
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# module-runtime-story-compat
|
||||
|
||||
`module-runtime-story-compat` 承接旧 `/api/runtime/story/*` 兼容桥中不依赖 HTTP / `AppState` 的核心类型与纯 helper。
|
||||
|
||||
当前首批迁入范围保持克制:
|
||||
|
||||
1. action 结算结果结构。
|
||||
2. action response 组装参数结构。
|
||||
3. NPC 委托上下文结构。
|
||||
4. functionId / 队伍上限常量。
|
||||
5. 少量只依赖 `serde_json::Value` 与 `shared-contracts` 的纯 helper。
|
||||
|
||||
后续再按 battle / forge / NPC / quest / presentation 的顺序,把已经拆好的 `api-server` 内部模块逐步迁入本 crate。
|
||||
@@ -1,3 +0,0 @@
|
||||
//! runtime story 兼容应用编排过渡落位。
|
||||
//!
|
||||
//! 这里只组合旧规则并返回兼容结果;真实保存、SSE 和模型调用由外层完成。
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "module-runtime-story-compat"
|
||||
name = "module-runtime-story"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
13
server-rs/crates/module-runtime-story/README.md
Normal file
13
server-rs/crates/module-runtime-story/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# module-runtime-story
|
||||
|
||||
`module-runtime-story` 承接 RPG runtime story 的纯领域规则、应用用例、事件和错误模型,不依赖 HTTP / `AppState` / SpacetimeDB。
|
||||
|
||||
当前已经迁入的历史兼容纯逻辑会继续收口为 session scoped 新主链:
|
||||
|
||||
1. action 结算结果结构。
|
||||
2. action response 组装参数结构。
|
||||
3. NPC 委托上下文结构。
|
||||
4. functionId / 队伍上限常量。
|
||||
5. 少量只依赖 `serde_json::Value` 与 `shared-contracts` 的纯 helper。
|
||||
|
||||
后续 WP-RS 继续按 battle / forge / NPC / quest / presentation 的顺序,把旧 `/api/runtime/story/*` 兼容桥中剩余纯规则迁入本 crate,并删除运行代码中的 compat 命名。
|
||||
3
server-rs/crates/module-runtime-story/src/application.rs
Normal file
3
server-rs/crates/module-runtime-story/src/application.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! runtime story 应用编排落位。
|
||||
//!
|
||||
//! 这里组合纯领域规则并返回后端投影;真实保存、SSE 和模型调用由外层完成。
|
||||
@@ -20,6 +20,7 @@ pub mod game_state;
|
||||
pub mod npc_support;
|
||||
pub mod options;
|
||||
pub mod post_battle;
|
||||
pub mod projection;
|
||||
pub mod prompt_context;
|
||||
pub mod story_engine;
|
||||
pub mod view_model;
|
||||
@@ -69,6 +70,7 @@ pub use options::{
|
||||
pub use post_battle::{
|
||||
finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_post_battle_story_options,
|
||||
};
|
||||
pub use projection::{StoryRuntimeProjectionSource, build_story_runtime_projection};
|
||||
pub use prompt_context::{RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context};
|
||||
pub use story_engine::project_story_engine_after_action;
|
||||
pub use view_model::{
|
||||
188
server-rs/crates/module-runtime-story/src/projection.rs
Normal file
188
server-rs/crates/module-runtime-story/src/projection.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use serde_json::{Value, to_value};
|
||||
|
||||
use shared_contracts::{
|
||||
runtime_story::RuntimeStoryOptionView,
|
||||
story::{
|
||||
StoryEventPayload, StoryRuntimeActorProjection, StoryRuntimeInventoryProjection,
|
||||
StoryRuntimeOptionProjection, StoryRuntimeProjectionResponse, StoryRuntimeStatusProjection,
|
||||
StorySessionPayload,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
current_encounter_id, read_bool_field, read_i32_field, read_optional_string_field,
|
||||
view_model::build_runtime_story_inventory,
|
||||
};
|
||||
|
||||
pub struct StoryRuntimeProjectionSource {
|
||||
pub story_session: StorySessionPayload,
|
||||
pub story_events: Vec<StoryEventPayload>,
|
||||
pub game_state: Value,
|
||||
pub options: Vec<RuntimeStoryOptionView>,
|
||||
pub server_version: u32,
|
||||
pub current_narrative_text: Option<String>,
|
||||
pub action_result_text: Option<String>,
|
||||
pub toast: Option<String>,
|
||||
}
|
||||
|
||||
/// 将领域快照折成前端可直接消费的新 story runtime 投影。
|
||||
pub fn build_story_runtime_projection(
|
||||
source: StoryRuntimeProjectionSource,
|
||||
) -> StoryRuntimeProjectionResponse {
|
||||
let inventory = build_runtime_story_inventory(&source.game_state);
|
||||
|
||||
StoryRuntimeProjectionResponse {
|
||||
story_session: source.story_session,
|
||||
story_events: source.story_events,
|
||||
server_version: source.server_version,
|
||||
actor: StoryRuntimeActorProjection {
|
||||
hp: read_i32_field(&source.game_state, "playerHp").unwrap_or(0),
|
||||
max_hp: read_i32_field(&source.game_state, "playerMaxHp").unwrap_or(1),
|
||||
mana: read_i32_field(&source.game_state, "playerMana").unwrap_or(0),
|
||||
max_mana: read_i32_field(&source.game_state, "playerMaxMana").unwrap_or(1),
|
||||
currency: inventory.player_currency,
|
||||
currency_text: inventory.currency_text.clone(),
|
||||
},
|
||||
inventory: StoryRuntimeInventoryProjection {
|
||||
backpack_items: inventory
|
||||
.backpack_items
|
||||
.into_iter()
|
||||
.map(|item| to_value(item).expect("runtime inventory item should serialize"))
|
||||
.collect(),
|
||||
equipment_slots: inventory
|
||||
.equipment_slots
|
||||
.into_iter()
|
||||
.map(|slot| to_value(slot).expect("runtime equipment slot should serialize"))
|
||||
.collect(),
|
||||
forge_recipes: inventory
|
||||
.forge_recipes
|
||||
.into_iter()
|
||||
.map(|recipe| to_value(recipe).expect("runtime forge recipe should serialize"))
|
||||
.collect(),
|
||||
},
|
||||
options: source
|
||||
.options
|
||||
.into_iter()
|
||||
.map(build_story_runtime_option_projection)
|
||||
.collect(),
|
||||
status: StoryRuntimeStatusProjection {
|
||||
in_battle: read_bool_field(&source.game_state, "inBattle").unwrap_or(false),
|
||||
npc_interaction_active: read_bool_field(&source.game_state, "npcInteractionActive")
|
||||
.unwrap_or(false),
|
||||
current_encounter_id: current_encounter_id(&source.game_state),
|
||||
current_npc_battle_mode: read_optional_string_field(
|
||||
&source.game_state,
|
||||
"currentNpcBattleMode",
|
||||
),
|
||||
current_npc_battle_outcome: read_optional_string_field(
|
||||
&source.game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
),
|
||||
},
|
||||
current_narrative_text: source.current_narrative_text,
|
||||
action_result_text: source.action_result_text,
|
||||
toast: source.toast,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_story_runtime_option_projection(
|
||||
option: RuntimeStoryOptionView,
|
||||
) -> StoryRuntimeOptionProjection {
|
||||
let disabled = option.disabled.unwrap_or(false);
|
||||
|
||||
StoryRuntimeOptionProjection {
|
||||
function_id: option.function_id,
|
||||
action_text: option.action_text,
|
||||
detail_text: option.detail_text,
|
||||
scope: option.scope,
|
||||
payload: option.payload,
|
||||
enabled: !disabled,
|
||||
reason: option.reason,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn story_session() -> StorySessionPayload {
|
||||
StorySessionPayload {
|
||||
story_session_id: "storysess_1".to_string(),
|
||||
runtime_session_id: "runtime_1".to_string(),
|
||||
actor_user_id: "user_1".to_string(),
|
||||
world_profile_id: "profile_1".to_string(),
|
||||
initial_prompt: "进入营地".to_string(),
|
||||
opening_summary: Some("营地开场".to_string()),
|
||||
latest_narrative_text: "篝火仍然亮着。".to_string(),
|
||||
latest_choice_function_id: Some("npc_chat".to_string()),
|
||||
status: "active".to_string(),
|
||||
version: 3,
|
||||
created_at: "1.000000Z".to_string(),
|
||||
updated_at: "3.000000Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_builds_frontend_ready_story_runtime_shape() {
|
||||
let projection = build_story_runtime_projection(StoryRuntimeProjectionSource {
|
||||
story_session: story_session(),
|
||||
story_events: vec![StoryEventPayload {
|
||||
event_id: "storyevt_1".to_string(),
|
||||
story_session_id: "storysess_1".to_string(),
|
||||
event_kind: "story_continued".to_string(),
|
||||
narrative_text: "篝火仍然亮着。".to_string(),
|
||||
choice_function_id: Some("npc_chat".to_string()),
|
||||
created_at: "3.000000Z".to_string(),
|
||||
}],
|
||||
game_state: json!({
|
||||
"worldType": "WUXIA",
|
||||
"playerCharacter": { "id": "hero-1", "name": "沈砺" },
|
||||
"playerHp": 28,
|
||||
"playerMaxHp": 40,
|
||||
"playerMana": 12,
|
||||
"playerMaxMana": 20,
|
||||
"playerCurrency": 80,
|
||||
"playerInventory": [{
|
||||
"id": "potion-1",
|
||||
"category": "消耗品",
|
||||
"name": "疗伤药",
|
||||
"quantity": 2,
|
||||
"rarity": "common",
|
||||
"tags": ["healing"]
|
||||
}],
|
||||
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
|
||||
"currentEncounter": { "id": "npc_firekeeper", "npcName": "守火人" },
|
||||
"inBattle": false,
|
||||
"npcInteractionActive": true
|
||||
}),
|
||||
options: vec![RuntimeStoryOptionView {
|
||||
function_id: "npc_chat".to_string(),
|
||||
action_text: "继续交谈".to_string(),
|
||||
detail_text: Some("围绕当前话题继续推进关系判断。".to_string()),
|
||||
scope: "npc".to_string(),
|
||||
interaction: None,
|
||||
payload: Some(json!({ "npcId": "npc_firekeeper" })),
|
||||
disabled: None,
|
||||
reason: None,
|
||||
}],
|
||||
server_version: 3,
|
||||
current_narrative_text: Some("守火人示意你继续说。".to_string()),
|
||||
action_result_text: None,
|
||||
toast: Some("关系有所变化。".to_string()),
|
||||
});
|
||||
|
||||
assert_eq!(projection.story_session.story_session_id, "storysess_1");
|
||||
assert_eq!(projection.actor.hp, 28);
|
||||
assert_eq!(projection.actor.currency_text, "80 铜钱");
|
||||
assert_eq!(projection.inventory.backpack_items.len(), 1);
|
||||
assert_eq!(projection.options[0].function_id, "npc_chat");
|
||||
assert!(projection.options[0].enabled);
|
||||
assert_eq!(
|
||||
projection.status.current_encounter_id.as_deref(),
|
||||
Some("npc_firekeeper")
|
||||
);
|
||||
assert_eq!(projection.toast.as_deref(), Some("关系有所变化。"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -64,6 +65,79 @@ pub struct StorySessionStateResponse {
|
||||
pub story_events: Vec<StoryEventPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoryRuntimeProjectionRequest {
|
||||
pub story_session_id: String,
|
||||
#[serde(default)]
|
||||
pub client_version: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoryRuntimeActorProjection {
|
||||
pub hp: i32,
|
||||
pub max_hp: i32,
|
||||
pub mana: i32,
|
||||
pub max_mana: i32,
|
||||
pub currency: i32,
|
||||
pub currency_text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoryRuntimeInventoryProjection {
|
||||
pub backpack_items: Vec<Value>,
|
||||
pub equipment_slots: Vec<Value>,
|
||||
pub forge_recipes: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoryRuntimeOptionProjection {
|
||||
pub function_id: String,
|
||||
pub action_text: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detail_text: Option<String>,
|
||||
pub scope: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payload: Option<Value>,
|
||||
pub enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoryRuntimeStatusProjection {
|
||||
pub in_battle: bool,
|
||||
pub npc_interaction_active: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_encounter_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_npc_battle_mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_npc_battle_outcome: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoryRuntimeProjectionResponse {
|
||||
pub story_session: StorySessionPayload,
|
||||
pub story_events: Vec<StoryEventPayload>,
|
||||
pub server_version: u32,
|
||||
pub actor: StoryRuntimeActorProjection,
|
||||
pub inventory: StoryRuntimeInventoryProjection,
|
||||
pub options: Vec<StoryRuntimeOptionProjection>,
|
||||
pub status: StoryRuntimeStatusProjection,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_narrative_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub action_result_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub toast: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -161,4 +235,81 @@ mod tests {
|
||||
json!("story_continued")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_runtime_projection_response_uses_new_story_runtime_contract() {
|
||||
let payload = serde_json::to_value(StoryRuntimeProjectionResponse {
|
||||
story_session: StorySessionPayload {
|
||||
story_session_id: "storysess_1".to_string(),
|
||||
runtime_session_id: "runtime_1".to_string(),
|
||||
actor_user_id: "user_1".to_string(),
|
||||
world_profile_id: "profile_1".to_string(),
|
||||
initial_prompt: "进入营地".to_string(),
|
||||
opening_summary: Some("营地开场".to_string()),
|
||||
latest_narrative_text: "你看见篝火边有人招手。".to_string(),
|
||||
latest_choice_function_id: Some("talk_to_npc".to_string()),
|
||||
status: "active".to_string(),
|
||||
version: 2,
|
||||
created_at: "1.000000Z".to_string(),
|
||||
updated_at: "2.000000Z".to_string(),
|
||||
},
|
||||
story_events: vec![StoryEventPayload {
|
||||
event_id: "storyevt_2".to_string(),
|
||||
story_session_id: "storysess_1".to_string(),
|
||||
event_kind: "story_continued".to_string(),
|
||||
narrative_text: "你看见篝火边有人招手。".to_string(),
|
||||
choice_function_id: Some("talk_to_npc".to_string()),
|
||||
created_at: "2.000000Z".to_string(),
|
||||
}],
|
||||
server_version: 2,
|
||||
actor: StoryRuntimeActorProjection {
|
||||
hp: 32,
|
||||
max_hp: 40,
|
||||
mana: 18,
|
||||
max_mana: 20,
|
||||
currency: 80,
|
||||
currency_text: "80 铜钱".to_string(),
|
||||
},
|
||||
inventory: StoryRuntimeInventoryProjection {
|
||||
backpack_items: vec![json!({ "id": "potion-1", "name": "疗伤药" })],
|
||||
equipment_slots: vec![json!({ "slotId": "weapon", "label": "武器" })],
|
||||
forge_recipes: Vec::new(),
|
||||
},
|
||||
options: vec![StoryRuntimeOptionProjection {
|
||||
function_id: "npc_chat".to_string(),
|
||||
action_text: "继续交谈".to_string(),
|
||||
detail_text: Some("围绕当前话题继续推进关系判断。".to_string()),
|
||||
scope: "npc".to_string(),
|
||||
payload: Some(json!({ "npcId": "npc_camp_firekeeper" })),
|
||||
enabled: true,
|
||||
reason: None,
|
||||
}],
|
||||
status: StoryRuntimeStatusProjection {
|
||||
in_battle: false,
|
||||
npc_interaction_active: true,
|
||||
current_encounter_id: Some("npc_camp_firekeeper".to_string()),
|
||||
current_npc_battle_mode: None,
|
||||
current_npc_battle_outcome: None,
|
||||
},
|
||||
current_narrative_text: Some("守火人示意你继续说。".to_string()),
|
||||
action_result_text: None,
|
||||
toast: None,
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["storySession"]["storySessionId"],
|
||||
json!("storysess_1")
|
||||
);
|
||||
assert_eq!(payload["serverVersion"], json!(2));
|
||||
assert_eq!(payload["actor"]["maxHp"], json!(40));
|
||||
assert_eq!(
|
||||
payload["inventory"]["backpackItems"][0]["name"],
|
||||
json!("疗伤药")
|
||||
);
|
||||
assert_eq!(payload["options"][0]["functionId"], json!("npc_chat"));
|
||||
assert!(payload.get("snapshot").is_none());
|
||||
assert!(payload.get("viewModel").is_none());
|
||||
assert!(payload.get("presentation").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ module-inventory = { path = "../module-inventory" }
|
||||
module-npc = { path = "../module-npc" }
|
||||
module-puzzle = { path = "../module-puzzle" }
|
||||
module-runtime = { path = "../module-runtime" }
|
||||
module-runtime-story = { path = "../module-runtime-story" }
|
||||
module-runtime-item = { path = "../module-runtime-item" }
|
||||
module-story = { path = "../module-story" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shared-contracts = { path = "../shared-contracts" }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb-sdk = "2.1.0"
|
||||
tokio = { version = "1", features = ["rt", "sync", "time"] }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# spacetime-client 共享 package 占位说明
|
||||
# spacetime-client 共享 package 说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
@@ -10,6 +10,15 @@
|
||||
2. Axum 与各模块对 reducer、view、订阅的调用适配
|
||||
3. 身份透传、连接配置与基础错误处理适配
|
||||
|
||||
在 DDD 重构中,本 package 只承接 `WP-SC Spacetime Client`:
|
||||
|
||||
1. 把 SpacetimeDB 生成绑定转换成 `api-server` 可消费的 typed facade。
|
||||
2. 把 row snapshot / procedure result 转换成 BFF record。
|
||||
3. 统一 SDK 调用错误、业务 procedure 错误、缺失快照错误和超时错误。
|
||||
4. 不承载领域规则,不直接定义 table / reducer / procedure,不替代 `spacetime-module`。
|
||||
|
||||
本轮方案见 [`SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md`](../../../docs/technical/SERVER_RS_DDD_WP_SC_SPACETIME_CLIENT_REFACTOR_2026-04-29.md)。
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前目录已不再只是占位,当前阶段已经落下:
|
||||
@@ -76,3 +85,5 @@ cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml
|
||||
1. `spacetime-client` 只承接 SpacetimeDB 客户端访问适配,不承接具体业务模块的规则实现。
|
||||
2. 业务状态真相仍由 `apps/spacetime-module` 管理,业务编排由各模块 package 与 `apps/api-server` 承担。
|
||||
3. 不允许把 reducer、view、订阅调用细节重新散落到多个业务模块里各自实现。
|
||||
4. 新增 facade 必须等待对应 `spacetime-module` facade 稳定后再接,不提前假设 row shape。
|
||||
5. `src/module_bindings/**` 是生成产物,只能通过 SpacetimeDB CLI 生成流程刷新。
|
||||
|
||||
@@ -13,7 +13,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_ai_task_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -35,15 +35,12 @@ impl SpacetimeClient {
|
||||
.reducers
|
||||
.start_ai_task_then(reducer_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(|inner| inner.map_err(SpacetimeClientError::Runtime));
|
||||
send_reducer_once(&callback_sender, mapped);
|
||||
})
|
||||
{
|
||||
send_reducer_once(
|
||||
&sender,
|
||||
Err(SpacetimeClientError::Procedure(error.to_string())),
|
||||
);
|
||||
send_reducer_once(&sender, Err(SpacetimeClientError::from_sdk_error(error)));
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -62,15 +59,12 @@ impl SpacetimeClient {
|
||||
.reducers
|
||||
.start_ai_task_stage_then(reducer_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(|inner| inner.map_err(SpacetimeClientError::Runtime));
|
||||
send_reducer_once(&callback_sender, mapped);
|
||||
})
|
||||
{
|
||||
send_reducer_once(
|
||||
&sender,
|
||||
Err(SpacetimeClientError::Procedure(error.to_string())),
|
||||
);
|
||||
send_reducer_once(&sender, Err(SpacetimeClientError::from_sdk_error(error)));
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -87,7 +81,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.append_ai_text_chunk_and_return_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_ai_task_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
@@ -106,7 +100,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_ai_task_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -126,7 +120,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.attach_ai_result_reference_and_return_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_ai_task_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
@@ -145,7 +139,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_ai_task_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -165,7 +159,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_ai_task_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -185,7 +179,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_ai_task_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_asset_history_list_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -32,7 +32,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.confirm_asset_object_and_return_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
@@ -51,7 +51,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.bind_asset_object_to_entity_and_return_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_entity_binding_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.export_auth_store_snapshot_from_tables_then(move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_auth_store_snapshot_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
@@ -25,7 +25,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.get_auth_store_snapshot_then(move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_auth_store_snapshot_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
@@ -48,7 +48,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_auth_store_snapshot_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -65,7 +65,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.import_auth_store_snapshot_then(move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_auth_store_snapshot_import_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_big_fish_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -46,7 +46,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.get_big_fish_session_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_big_fish_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
@@ -90,7 +90,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.list_big_fish_works_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(|result| {
|
||||
map_big_fish_works_procedure_result(
|
||||
result,
|
||||
@@ -119,7 +119,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.delete_big_fish_work_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(|result| {
|
||||
map_big_fish_works_procedure_result(
|
||||
result,
|
||||
@@ -150,7 +150,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_big_fish_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -180,7 +180,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.finalize_big_fish_agent_message_turn_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_big_fish_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
@@ -204,7 +204,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_big_fish_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -232,7 +232,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_big_fish_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -258,7 +258,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_big_fish_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -283,7 +283,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.record_big_fish_play_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(|result| map_big_fish_works_procedure_result(result, None));
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_battle_state_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -37,7 +37,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.get_battle_state_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_battle_state_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
@@ -58,7 +58,7 @@ impl SpacetimeClient {
|
||||
.procedures()
|
||||
.resolve_combat_action_and_return_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_resolve_combat_action_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_runtime_inventory_state_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@ pub mod npc;
|
||||
pub mod puzzle;
|
||||
pub mod runtime;
|
||||
pub mod story;
|
||||
pub mod story_runtime;
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
@@ -424,6 +425,20 @@ impl SpacetimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
impl SpacetimeClientError {
|
||||
pub(crate) fn from_sdk_error(error: impl fmt::Display) -> Self {
|
||||
Self::Procedure(error.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn procedure_failed(message: Option<String>) -> Self {
|
||||
Self::Procedure(message.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn missing_snapshot(label: &'static str) -> Self {
|
||||
Self::Procedure(format!("SpacetimeDB procedure 未返回{label}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl PooledConnection {
|
||||
fn is_broken(&self) -> bool {
|
||||
self.broken.load(Ordering::SeqCst)
|
||||
|
||||
@@ -530,16 +530,12 @@ pub(crate) fn map_procedure_result(
|
||||
result: AssetObjectProcedureResult,
|
||||
) -> Result<AssetObjectRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let snapshot = result.record.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回对象快照".to_string())
|
||||
})?;
|
||||
let snapshot = result
|
||||
.record
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("对象快照"))?;
|
||||
|
||||
Ok(build_asset_object_record(map_snapshot(snapshot)))
|
||||
}
|
||||
@@ -548,16 +544,12 @@ pub(crate) fn map_entity_binding_procedure_result(
|
||||
result: AssetEntityBindingProcedureResult,
|
||||
) -> Result<AssetEntityBindingRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let snapshot = result.record.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回绑定快照".to_string())
|
||||
})?;
|
||||
let snapshot = result
|
||||
.record
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("绑定快照"))?;
|
||||
|
||||
Ok(build_asset_entity_binding_record(
|
||||
map_entity_binding_snapshot(snapshot),
|
||||
@@ -568,11 +560,7 @@ pub(crate) fn map_asset_history_list_result(
|
||||
result: AssetHistoryListResult,
|
||||
) -> Result<Vec<AssetHistoryEntryRecord>, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
Ok(result
|
||||
@@ -609,16 +597,12 @@ pub(crate) fn map_auth_store_snapshot_procedure_result(
|
||||
result: AuthStoreSnapshotProcedureResult,
|
||||
) -> Result<AuthStoreSnapshotRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let record = result.record.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回认证快照".to_string())
|
||||
})?;
|
||||
let record = result
|
||||
.record
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("认证快照"))?;
|
||||
|
||||
Ok(map_auth_store_snapshot_record(record))
|
||||
}
|
||||
@@ -1003,16 +987,12 @@ pub(crate) fn map_ai_task_procedure_result(
|
||||
result: AiTaskProcedureResult,
|
||||
) -> Result<AiTaskMutationRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Runtime(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let task = result.task.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 ai_task 快照".to_string())
|
||||
})?;
|
||||
let task = result
|
||||
.task
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("ai_task 快照"))?;
|
||||
|
||||
Ok(AiTaskMutationRecord {
|
||||
task: map_ai_task_snapshot(task),
|
||||
@@ -1344,18 +1324,12 @@ pub(crate) fn map_big_fish_session_procedure_result(
|
||||
result: BigFishSessionProcedureResult,
|
||||
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let session = result.session.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure(
|
||||
"SpacetimeDB procedure 未返回 big fish session 快照".to_string(),
|
||||
)
|
||||
})?;
|
||||
let session = result
|
||||
.session
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("big fish session 快照"))?;
|
||||
|
||||
Ok(map_big_fish_session_snapshot(session))
|
||||
}
|
||||
@@ -1365,18 +1339,12 @@ pub(crate) fn map_big_fish_works_procedure_result(
|
||||
fallback_owner_user_id: Option<&str>,
|
||||
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let items_json = result.items_json.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure(
|
||||
"SpacetimeDB procedure 未返回 big fish works 快照".to_string(),
|
||||
)
|
||||
})?;
|
||||
let items_json = result
|
||||
.items_json
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("big fish works 快照"))?;
|
||||
let items = serde_json::from_str::<Vec<CompatibleBigFishWorkSummaryRecord>>(&items_json)
|
||||
.map_err(|error| {
|
||||
SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}"))
|
||||
@@ -1392,21 +1360,15 @@ pub(crate) fn map_story_session_procedure_result(
|
||||
result: StorySessionProcedureResult,
|
||||
) -> Result<StorySessionResultRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let session = result.session.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure(
|
||||
"SpacetimeDB procedure 未返回 story session 快照".to_string(),
|
||||
)
|
||||
})?;
|
||||
let event = result.event.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 story event 快照".to_string())
|
||||
})?;
|
||||
let session = result
|
||||
.session
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("story session 快照"))?;
|
||||
let event = result
|
||||
.event
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("story event 快照"))?;
|
||||
|
||||
Ok(StorySessionResultRecord {
|
||||
session: map_story_session_snapshot(session),
|
||||
@@ -1418,18 +1380,12 @@ pub(crate) fn map_story_session_state_procedure_result(
|
||||
result: StorySessionStateProcedureResult,
|
||||
) -> Result<StorySessionStateRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let session = result.session.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure(
|
||||
"SpacetimeDB procedure 未返回 story session state 快照".to_string(),
|
||||
)
|
||||
})?;
|
||||
let session = result
|
||||
.session
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("story session state 快照"))?;
|
||||
|
||||
Ok(StorySessionStateRecord {
|
||||
session: map_story_session_snapshot(session),
|
||||
@@ -1445,18 +1401,12 @@ pub(crate) fn map_runtime_inventory_state_procedure_result(
|
||||
result: RuntimeInventoryStateProcedureResult,
|
||||
) -> Result<RuntimeInventoryStateRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let snapshot = result.snapshot.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure(
|
||||
"SpacetimeDB procedure 未返回 runtime inventory state 快照".to_string(),
|
||||
)
|
||||
})?;
|
||||
let snapshot = result
|
||||
.snapshot
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("runtime inventory state 快照"))?;
|
||||
|
||||
Ok(build_runtime_inventory_state_record(
|
||||
map_runtime_inventory_state_snapshot(snapshot),
|
||||
@@ -1467,18 +1417,12 @@ pub(crate) fn map_battle_state_procedure_result(
|
||||
result: BattleStateProcedureResult,
|
||||
) -> Result<BattleStateRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let snapshot = result.snapshot.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure(
|
||||
"SpacetimeDB procedure 未返回 battle_state 快照".to_string(),
|
||||
)
|
||||
})?;
|
||||
let snapshot = result
|
||||
.snapshot
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("battle_state 快照"))?;
|
||||
|
||||
Ok(build_battle_state_record(map_battle_state_snapshot(
|
||||
snapshot,
|
||||
@@ -1489,16 +1433,12 @@ pub(crate) fn map_resolve_combat_action_procedure_result(
|
||||
result: ResolveCombatActionProcedureResult,
|
||||
) -> Result<ResolveCombatActionRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let action_result = result.result.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回战斗结算结果".to_string())
|
||||
})?;
|
||||
let action_result = result
|
||||
.result
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("战斗结算结果"))?;
|
||||
|
||||
Ok(build_resolve_combat_action_record(
|
||||
map_resolve_combat_action_result(action_result),
|
||||
@@ -1509,16 +1449,12 @@ pub(crate) fn map_npc_battle_interaction_procedure_result(
|
||||
result: NpcBattleInteractionProcedureResult,
|
||||
) -> Result<NpcBattleInteractionRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let interaction_result = result.result.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 NPC 开战结果".to_string())
|
||||
})?;
|
||||
let interaction_result = result
|
||||
.result
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("NPC 开战结果"))?;
|
||||
|
||||
Ok(build_npc_battle_interaction_record(
|
||||
map_npc_battle_interaction_result(interaction_result),
|
||||
|
||||
@@ -16,7 +16,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_npc_battle_interaction_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_story_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -60,7 +60,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_story_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
@@ -82,7 +82,7 @@ impl SpacetimeClient {
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_story_session_state_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
|
||||
227
server-rs/crates/spacetime-client/src/story_runtime.rs
Normal file
227
server-rs/crates/spacetime-client/src/story_runtime.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use module_runtime_story::StoryRuntimeProjectionSource;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::{
|
||||
runtime_story::RuntimeStoryOptionView,
|
||||
story::{StoryEventPayload, StorySessionPayload},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn get_story_runtime_projection_source(
|
||||
&self,
|
||||
story_session_id: String,
|
||||
actor_user_id: String,
|
||||
) -> Result<StoryRuntimeProjectionSource, SpacetimeClientError> {
|
||||
let story_state = self.get_story_session_state(story_session_id).await?;
|
||||
if story_state.session.actor_user_id != actor_user_id {
|
||||
return Err(SpacetimeClientError::Runtime(
|
||||
"story session 不属于当前用户".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let runtime_snapshot =
|
||||
self.get_runtime_snapshot(actor_user_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
SpacetimeClientError::Runtime("当前用户缺少 runtime snapshot".to_string())
|
||||
})?;
|
||||
assert_runtime_snapshot_matches_story_session(&story_state.session, &runtime_snapshot)?;
|
||||
|
||||
let current_story = runtime_snapshot.current_story.as_ref();
|
||||
let latest_narrative_text = story_state.session.latest_narrative_text.clone();
|
||||
let server_version = runtime_snapshot.version.max(story_state.session.version);
|
||||
|
||||
Ok(StoryRuntimeProjectionSource {
|
||||
story_session: build_story_session_payload(story_state.session),
|
||||
story_events: story_state
|
||||
.events
|
||||
.into_iter()
|
||||
.map(build_story_event_payload)
|
||||
.collect(),
|
||||
game_state: runtime_snapshot.game_state,
|
||||
options: read_runtime_story_options(current_story)?,
|
||||
server_version,
|
||||
current_narrative_text: read_current_story_text(current_story)
|
||||
.or(Some(latest_narrative_text)),
|
||||
action_result_text: read_current_story_string(current_story, "resultText"),
|
||||
toast: read_current_story_string(current_story, "toast"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_runtime_snapshot_matches_story_session(
|
||||
session: &StorySessionRecord,
|
||||
snapshot: &RuntimeSnapshotRecord,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
let Some(runtime_session_id) = snapshot
|
||||
.game_state
|
||||
.as_object()
|
||||
.and_then(|state| state.get("runtimeSessionId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Err(SpacetimeClientError::Runtime(
|
||||
"runtime snapshot 缺少 runtimeSessionId".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
if runtime_session_id != session.runtime_session_id {
|
||||
return Err(SpacetimeClientError::Runtime(
|
||||
"runtime snapshot 与 story session 不匹配".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_story_session_payload(record: 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 build_story_event_payload(record: 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,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_runtime_story_options(
|
||||
current_story: Option<&Value>,
|
||||
) -> Result<Vec<RuntimeStoryOptionView>, SpacetimeClientError> {
|
||||
let Some(options) = current_story.and_then(|story| story.get("options")) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
serde_json::from_value::<Vec<RuntimeStoryOptionView>>(options.clone()).map_err(|error| {
|
||||
SpacetimeClientError::Runtime(format!(
|
||||
"currentStory.options 无法映射为后端选项投影: {error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_current_story_text(current_story: Option<&Value>) -> Option<String> {
|
||||
read_current_story_string(current_story, "text")
|
||||
.or_else(|| read_current_story_string(current_story, "storyText"))
|
||||
}
|
||||
|
||||
fn read_current_story_string(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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn runtime_snapshot_session_guard_accepts_matching_runtime_session() {
|
||||
let session = story_session_record();
|
||||
let snapshot = runtime_snapshot_record(json!({ "runtimeSessionId": "runtime_1" }), None);
|
||||
|
||||
assert!(assert_runtime_snapshot_matches_story_session(&session, &snapshot).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_snapshot_session_guard_rejects_mismatched_runtime_session() {
|
||||
let session = story_session_record();
|
||||
let snapshot =
|
||||
runtime_snapshot_record(json!({ "runtimeSessionId": "runtime_other" }), None);
|
||||
|
||||
let error = assert_runtime_snapshot_matches_story_session(&session, &snapshot)
|
||||
.expect_err("mismatched runtime session should fail");
|
||||
|
||||
assert!(error.to_string().contains("不匹配"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_story_options_parse_runtime_story_options() {
|
||||
let options = read_runtime_story_options(Some(&json!({
|
||||
"text": "守火人抬眼看着你。",
|
||||
"options": [{
|
||||
"functionId": "npc_chat",
|
||||
"actionText": "继续交谈",
|
||||
"scope": "npc"
|
||||
}]
|
||||
})))
|
||||
.expect("options should parse");
|
||||
|
||||
assert_eq!(options[0].function_id, "npc_chat");
|
||||
assert_eq!(options[0].action_text, "继续交谈");
|
||||
assert_eq!(options[0].scope, "npc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_story_text_prefers_text_then_story_text() {
|
||||
assert_eq!(
|
||||
read_current_story_text(Some(&json!({ "text": "正文", "storyText": "备用" })))
|
||||
.as_deref(),
|
||||
Some("正文")
|
||||
);
|
||||
assert_eq!(
|
||||
read_current_story_text(Some(&json!({ "storyText": "备用" }))).as_deref(),
|
||||
Some("备用")
|
||||
);
|
||||
}
|
||||
|
||||
fn story_session_record() -> StorySessionRecord {
|
||||
StorySessionRecord {
|
||||
story_session_id: "storysess_1".to_string(),
|
||||
runtime_session_id: "runtime_1".to_string(),
|
||||
actor_user_id: "user_1".to_string(),
|
||||
world_profile_id: "profile_1".to_string(),
|
||||
initial_prompt: "进入营地".to_string(),
|
||||
opening_summary: Some("营地开场".to_string()),
|
||||
latest_narrative_text: "篝火仍然亮着。".to_string(),
|
||||
latest_choice_function_id: Some("npc_chat".to_string()),
|
||||
status: "active".to_string(),
|
||||
version: 3,
|
||||
created_at: "1.000000Z".to_string(),
|
||||
updated_at: "3.000000Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_snapshot_record(
|
||||
game_state: Value,
|
||||
current_story: Option<Value>,
|
||||
) -> RuntimeSnapshotRecord {
|
||||
RuntimeSnapshotRecord {
|
||||
user_id: "user_1".to_string(),
|
||||
version: 2,
|
||||
saved_at: "3.000000Z".to_string(),
|
||||
saved_at_micros: 3,
|
||||
bottom_tab: "adventure".to_string(),
|
||||
game_state,
|
||||
current_story,
|
||||
game_state_json: "{}".to_string(),
|
||||
current_story_json: None,
|
||||
created_at_micros: 1,
|
||||
updated_at_micros: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
100
server-rs/crates/spacetime-module/src/ai/events.rs
Normal file
100
server-rs/crates/spacetime-module/src/ai/events.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use crate::*;
|
||||
|
||||
/// AI 任务事件类型。
|
||||
///
|
||||
/// 事件表用于给订阅端和 BFF 增量消费状态变化;正式任务真相仍以
|
||||
/// `ai_task`、`ai_task_stage`、`ai_text_chunk` 和 `ai_result_reference` 为准。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub enum AiTaskEventKind {
|
||||
TaskCreated,
|
||||
TaskStatusChanged,
|
||||
StageStarted,
|
||||
StageCompleted,
|
||||
TextChunkAppended,
|
||||
ResultReferenceAttached,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = ai_task_event,
|
||||
public,
|
||||
event,
|
||||
index(accessor = by_ai_task_event_task_id, btree(columns = [task_id])),
|
||||
index(accessor = by_ai_task_event_owner_user_id, btree(columns = [owner_user_id]))
|
||||
)]
|
||||
pub struct AiTaskEvent {
|
||||
#[primary_key]
|
||||
pub(crate) event_id: String,
|
||||
pub(crate) task_id: String,
|
||||
pub(crate) owner_user_id: String,
|
||||
pub(crate) event_kind: AiTaskEventKind,
|
||||
pub(crate) task_status: Option<AiTaskStatus>,
|
||||
pub(crate) stage_kind: Option<AiTaskStageKind>,
|
||||
pub(crate) text_chunk_row_id: Option<String>,
|
||||
pub(crate) result_reference_row_id: Option<String>,
|
||||
pub(crate) occurred_at: Timestamp,
|
||||
}
|
||||
|
||||
pub(crate) fn emit_ai_task_event(
|
||||
ctx: &ReducerContext,
|
||||
task: &AiTaskSnapshot,
|
||||
event_kind: AiTaskEventKind,
|
||||
stage_kind: Option<AiTaskStageKind>,
|
||||
text_chunk_row_id: Option<String>,
|
||||
result_reference_row_id: Option<String>,
|
||||
occurred_at_micros: i64,
|
||||
) {
|
||||
let suffix = match event_kind {
|
||||
AiTaskEventKind::TaskCreated => "created".to_string(),
|
||||
AiTaskEventKind::TaskStatusChanged => format!("status_{}", task.status.as_event_slug()),
|
||||
AiTaskEventKind::StageStarted => {
|
||||
format!("stage_started_{}", stage_kind_slug(stage_kind))
|
||||
}
|
||||
AiTaskEventKind::StageCompleted => {
|
||||
format!("stage_completed_{}", stage_kind_slug(stage_kind))
|
||||
}
|
||||
AiTaskEventKind::TextChunkAppended => {
|
||||
format!(
|
||||
"chunk_{}",
|
||||
text_chunk_row_id.as_deref().unwrap_or("unknown")
|
||||
)
|
||||
}
|
||||
AiTaskEventKind::ResultReferenceAttached => {
|
||||
format!(
|
||||
"result_{}",
|
||||
result_reference_row_id.as_deref().unwrap_or("unknown")
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
ctx.db.ai_task_event().insert(AiTaskEvent {
|
||||
event_id: format!("aievt_{}_{}_{}", task.task_id, occurred_at_micros, suffix),
|
||||
task_id: task.task_id.clone(),
|
||||
owner_user_id: task.owner_user_id.clone(),
|
||||
event_kind,
|
||||
task_status: Some(task.status),
|
||||
stage_kind,
|
||||
text_chunk_row_id,
|
||||
result_reference_row_id,
|
||||
occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_micros),
|
||||
});
|
||||
}
|
||||
|
||||
fn stage_kind_slug(stage_kind: Option<AiTaskStageKind>) -> &'static str {
|
||||
stage_kind.map(AiTaskStageKind::as_str).unwrap_or("unknown")
|
||||
}
|
||||
|
||||
trait AiTaskStatusEventSlug {
|
||||
fn as_event_slug(self) -> &'static str;
|
||||
}
|
||||
|
||||
impl AiTaskStatusEventSlug for AiTaskStatus {
|
||||
fn as_event_slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Running => "running",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
Self::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod events;
|
||||
mod snapshots;
|
||||
mod stages;
|
||||
mod tasks;
|
||||
|
||||
pub(crate) use events::*;
|
||||
pub(crate) use snapshots::*;
|
||||
pub use stages::*;
|
||||
pub use tasks::*;
|
||||
|
||||
@@ -119,13 +119,7 @@ pub(crate) fn build_ai_task_stage_snapshot_from_row(row: &AiTaskStage) -> AiTask
|
||||
|
||||
pub(crate) fn build_ai_text_chunk_row(snapshot: &AiTextChunkSnapshot) -> AiTextChunk {
|
||||
AiTextChunk {
|
||||
text_chunk_row_id: format!(
|
||||
"{}{}_{}_{}",
|
||||
AI_TEXT_CHUNK_ID_PREFIX,
|
||||
snapshot.task_id,
|
||||
snapshot.stage_kind.as_str(),
|
||||
snapshot.sequence
|
||||
),
|
||||
text_chunk_row_id: build_ai_text_chunk_row_id(snapshot),
|
||||
chunk_id: snapshot.chunk_id.clone(),
|
||||
task_id: snapshot.task_id.clone(),
|
||||
stage_kind: snapshot.stage_kind,
|
||||
@@ -135,6 +129,16 @@ pub(crate) fn build_ai_text_chunk_row(snapshot: &AiTextChunkSnapshot) -> AiTextC
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_ai_text_chunk_row_id(snapshot: &AiTextChunkSnapshot) -> String {
|
||||
format!(
|
||||
"{}{}_{}_{}",
|
||||
AI_TEXT_CHUNK_ID_PREFIX,
|
||||
snapshot.task_id,
|
||||
snapshot.stage_kind.as_str(),
|
||||
snapshot.sequence
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_ai_text_chunk_snapshot_from_row(row: &AiTextChunk) -> AiTextChunkSnapshot {
|
||||
AiTextChunkSnapshot {
|
||||
chunk_id: row.chunk_id.clone(),
|
||||
@@ -150,10 +154,7 @@ pub(crate) fn build_ai_result_reference_row(
|
||||
snapshot: &AiResultReferenceSnapshot,
|
||||
) -> AiResultReference {
|
||||
AiResultReference {
|
||||
result_reference_row_id: format!(
|
||||
"{}{}_{}",
|
||||
AI_RESULT_REF_ID_PREFIX, snapshot.task_id, snapshot.result_ref_id
|
||||
),
|
||||
result_reference_row_id: build_ai_result_reference_row_id(snapshot),
|
||||
result_ref_id: snapshot.result_ref_id.clone(),
|
||||
task_id: snapshot.task_id.clone(),
|
||||
reference_kind: snapshot.reference_kind,
|
||||
@@ -163,6 +164,13 @@ pub(crate) fn build_ai_result_reference_row(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_ai_result_reference_row_id(snapshot: &AiResultReferenceSnapshot) -> String {
|
||||
format!(
|
||||
"{}{}_{}",
|
||||
AI_RESULT_REF_ID_PREFIX, snapshot.task_id, snapshot.result_ref_id
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_ai_result_reference_snapshot_from_row(
|
||||
row: &AiResultReference,
|
||||
) -> AiResultReferenceSnapshot {
|
||||
|
||||
@@ -156,6 +156,15 @@ pub(crate) fn start_ai_task_stage_tx(
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
emit_ai_task_event(
|
||||
ctx,
|
||||
&snapshot,
|
||||
AiTaskEventKind::StageStarted,
|
||||
Some(input.stage_kind),
|
||||
None,
|
||||
None,
|
||||
input.started_at_micros,
|
||||
);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
@@ -207,6 +216,15 @@ pub(crate) fn append_ai_text_chunk_tx(
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
emit_ai_task_event(
|
||||
ctx,
|
||||
&snapshot,
|
||||
AiTaskEventKind::TextChunkAppended,
|
||||
Some(chunk.stage_kind),
|
||||
Some(build_ai_text_chunk_row_id(&chunk)),
|
||||
None,
|
||||
chunk.created_at_micros,
|
||||
);
|
||||
Ok((snapshot, chunk))
|
||||
}
|
||||
|
||||
@@ -235,6 +253,15 @@ pub(crate) fn complete_ai_stage_tx(
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
emit_ai_task_event(
|
||||
ctx,
|
||||
&snapshot,
|
||||
AiTaskEventKind::StageCompleted,
|
||||
Some(input.stage_kind),
|
||||
None,
|
||||
None,
|
||||
input.completed_at_micros,
|
||||
);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
@@ -267,6 +294,19 @@ pub(crate) fn attach_ai_result_reference_tx(
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
let reference = snapshot
|
||||
.result_references
|
||||
.last()
|
||||
.ok_or_else(|| "ai_result_reference 写入后缺少快照".to_string())?;
|
||||
emit_ai_task_event(
|
||||
ctx,
|
||||
&snapshot,
|
||||
AiTaskEventKind::ResultReferenceAttached,
|
||||
None,
|
||||
None,
|
||||
Some(build_ai_result_reference_row_id(reference)),
|
||||
input.created_at_micros,
|
||||
);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,15 @@ fn create_ai_task_tx(
|
||||
let task_snapshot = build_ai_task_snapshot_from_create_input(&input);
|
||||
ctx.db.ai_task().insert(build_ai_task_row(&task_snapshot));
|
||||
replace_ai_task_stages(ctx, &task_snapshot.task_id, &task_snapshot.stages);
|
||||
emit_ai_task_event(
|
||||
ctx,
|
||||
&task_snapshot,
|
||||
AiTaskEventKind::TaskCreated,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
task_snapshot.created_at_micros,
|
||||
);
|
||||
|
||||
get_ai_task_snapshot_tx(ctx, &task_snapshot.task_id)
|
||||
}
|
||||
@@ -154,6 +163,15 @@ fn start_ai_task_tx(
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
emit_ai_task_event(
|
||||
ctx,
|
||||
&snapshot,
|
||||
AiTaskEventKind::TaskStatusChanged,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
input.started_at_micros,
|
||||
);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
@@ -170,6 +188,15 @@ fn complete_ai_task_tx(
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
emit_ai_task_event(
|
||||
ctx,
|
||||
&snapshot,
|
||||
AiTaskEventKind::TaskStatusChanged,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
input.completed_at_micros,
|
||||
);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
@@ -192,6 +219,15 @@ fn fail_ai_task_tx(
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
emit_ai_task_event(
|
||||
ctx,
|
||||
&snapshot,
|
||||
AiTaskEventKind::TaskStatusChanged,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
input.completed_at_micros,
|
||||
);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
@@ -208,6 +244,15 @@ fn cancel_ai_task_tx(
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
emit_ai_task_event(
|
||||
ctx,
|
||||
&snapshot,
|
||||
AiTaskEventKind::TaskStatusChanged,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
input.completed_at_micros,
|
||||
);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::big_fish::tables::{big_fish_asset_slot, big_fish_creation_session};
|
||||
use crate::*;
|
||||
use module_big_fish::{EvaluateBigFishPublishReadinessCommand, evaluate_publish_readiness};
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn generate_big_fish_asset(
|
||||
@@ -70,6 +71,16 @@ pub(crate) fn generate_big_fish_asset_tx(
|
||||
upsert_big_fish_asset_slot(ctx, slot);
|
||||
|
||||
let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id);
|
||||
let readiness = evaluate_publish_readiness(
|
||||
EvaluateBigFishPublishReadinessCommand {
|
||||
session_id: session.session_id.clone(),
|
||||
owner_user_id: session.owner_user_id.clone(),
|
||||
draft: Some(draft.clone()),
|
||||
evaluated_at_micros: input.generated_at_micros,
|
||||
},
|
||||
&asset_slots,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let coverage = build_asset_coverage(Some(&draft), &asset_slots);
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.generated_at_micros);
|
||||
let uses_placeholder = input
|
||||
@@ -90,7 +101,7 @@ pub(crate) fn generate_big_fish_asset_tx(
|
||||
}
|
||||
}
|
||||
.to_string();
|
||||
let next_stage = if coverage.publish_ready {
|
||||
let next_stage = if readiness.readiness.publish_ready {
|
||||
BigFishCreationStage::ReadyToPublish
|
||||
} else {
|
||||
BigFishCreationStage::AssetRefining
|
||||
@@ -100,19 +111,26 @@ pub(crate) fn generate_big_fish_asset_tx(
|
||||
owner_user_id: session.owner_user_id.clone(),
|
||||
seed_text: session.seed_text.clone(),
|
||||
current_turn: session.current_turn,
|
||||
progress_percent: if coverage.publish_ready { 96 } else { 88 },
|
||||
progress_percent: if readiness.readiness.publish_ready {
|
||||
96
|
||||
} else {
|
||||
88
|
||||
},
|
||||
stage: next_stage,
|
||||
anchor_pack_json: session.anchor_pack_json.clone(),
|
||||
draft_json: session.draft_json.clone(),
|
||||
asset_coverage_json: serialize_asset_coverage(&coverage)
|
||||
.map_err(|error| error.to_string())?,
|
||||
last_assistant_reply: Some(reply.clone()),
|
||||
publish_ready: coverage.publish_ready,
|
||||
publish_ready: readiness.readiness.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at,
|
||||
};
|
||||
replace_big_fish_session(ctx, &session, next_session);
|
||||
for event in readiness.events {
|
||||
emit_big_fish_publish_readiness_event(ctx, event)?;
|
||||
}
|
||||
|
||||
get_big_fish_session_tx(
|
||||
ctx,
|
||||
@@ -140,14 +158,22 @@ pub(crate) fn publish_big_fish_game_tx(
|
||||
.as_deref()
|
||||
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
|
||||
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
|
||||
let coverage = build_asset_coverage(
|
||||
Some(&draft),
|
||||
&list_big_fish_asset_slots(ctx, &session.session_id),
|
||||
);
|
||||
if !coverage.publish_ready {
|
||||
let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id);
|
||||
let readiness = evaluate_publish_readiness(
|
||||
EvaluateBigFishPublishReadinessCommand {
|
||||
session_id: session.session_id.clone(),
|
||||
owner_user_id: session.owner_user_id.clone(),
|
||||
draft: Some(draft.clone()),
|
||||
evaluated_at_micros: input.published_at_micros,
|
||||
},
|
||||
&asset_slots,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let coverage = build_asset_coverage(Some(&draft), &asset_slots);
|
||||
if !readiness.readiness.publish_ready {
|
||||
return Err(format!(
|
||||
"big_fish 发布校验未通过:{}",
|
||||
coverage.blockers.join(";")
|
||||
readiness.readiness.blockers.join(";")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -170,6 +196,9 @@ pub(crate) fn publish_big_fish_game_tx(
|
||||
updated_at: published_at,
|
||||
};
|
||||
replace_big_fish_session(ctx, &session, next_session);
|
||||
for event in readiness.events {
|
||||
emit_big_fish_publish_readiness_event(ctx, event)?;
|
||||
}
|
||||
|
||||
get_big_fish_session_tx(
|
||||
ctx,
|
||||
|
||||
56
server-rs/crates/spacetime-module/src/big_fish/events.rs
Normal file
56
server-rs/crates/spacetime-module/src/big_fish/events.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use crate::*;
|
||||
|
||||
/// Big Fish 创作事件类型。
|
||||
///
|
||||
/// 事件表只承接跨层订阅和审计所需的轻量事实,正式作品状态仍以
|
||||
/// `big_fish_creation_session` 和 `big_fish_asset_slot` 为准。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub enum BigFishEventKind {
|
||||
PublishReadinessEvaluated,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = big_fish_event,
|
||||
public,
|
||||
event,
|
||||
index(accessor = by_big_fish_event_session_id, btree(columns = [session_id])),
|
||||
index(accessor = by_big_fish_event_owner_user_id, btree(columns = [owner_user_id]))
|
||||
)]
|
||||
pub struct BigFishEvent {
|
||||
#[primary_key]
|
||||
pub(crate) event_id: String,
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) owner_user_id: String,
|
||||
pub(crate) event_kind: BigFishEventKind,
|
||||
pub(crate) publish_ready: bool,
|
||||
pub(crate) blockers_json: String,
|
||||
pub(crate) occurred_at: Timestamp,
|
||||
}
|
||||
|
||||
pub(crate) fn emit_big_fish_publish_readiness_event(
|
||||
ctx: &ReducerContext,
|
||||
event: BigFishDomainEvent,
|
||||
) -> Result<(), String> {
|
||||
let BigFishDomainEvent::PublishReadinessEvaluated {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
publish_ready,
|
||||
blockers,
|
||||
occurred_at_micros,
|
||||
} = event;
|
||||
|
||||
let blockers_json = serde_json::to_string(&blockers)
|
||||
.map_err(|error| format!("big_fish.publish_readiness.blockers 序列化失败: {error}"))?;
|
||||
let state_slug = if publish_ready { "ready" } else { "blocked" };
|
||||
ctx.db.big_fish_event().insert(BigFishEvent {
|
||||
event_id: format!("bfevt_{session_id}_{occurred_at_micros}_{state_slug}"),
|
||||
session_id,
|
||||
owner_user_id,
|
||||
event_kind: BigFishEventKind::PublishReadinessEvaluated,
|
||||
publish_ready,
|
||||
blockers_json,
|
||||
occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_micros),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod assets;
|
||||
mod events;
|
||||
mod session;
|
||||
mod tables;
|
||||
|
||||
pub use assets::*;
|
||||
pub(crate) use events::*;
|
||||
pub use session::*;
|
||||
pub use tables::*;
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::runtime::{
|
||||
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
|
||||
};
|
||||
use crate::*;
|
||||
use module_big_fish::{EvaluateBigFishPublishReadinessCommand, evaluate_publish_readiness};
|
||||
|
||||
const INITIAL_BIG_FISH_CREATION_PROGRESS_PERCENT: u32 = 0;
|
||||
|
||||
@@ -552,6 +553,16 @@ pub(crate) fn compile_big_fish_draft_tx(
|
||||
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?
|
||||
.unwrap_or_else(|| compile_default_draft(&anchor_pack));
|
||||
let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id);
|
||||
let readiness = evaluate_publish_readiness(
|
||||
EvaluateBigFishPublishReadinessCommand {
|
||||
session_id: session.session_id.clone(),
|
||||
owner_user_id: session.owner_user_id.clone(),
|
||||
draft: Some(draft.clone()),
|
||||
evaluated_at_micros: input.compiled_at_micros,
|
||||
},
|
||||
&asset_slots,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let coverage = build_asset_coverage(Some(&draft), &asset_slots);
|
||||
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
|
||||
let reply = "第一版玩法草稿已编译完成,可以在结果页逐级生成主图、动作和场地背景。".to_string();
|
||||
@@ -568,12 +579,15 @@ pub(crate) fn compile_big_fish_draft_tx(
|
||||
asset_coverage_json: serialize_asset_coverage(&coverage)
|
||||
.map_err(|error| error.to_string())?,
|
||||
last_assistant_reply: Some(reply.clone()),
|
||||
publish_ready: coverage.publish_ready,
|
||||
publish_ready: readiness.readiness.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at: compiled_at,
|
||||
};
|
||||
replace_big_fish_session(ctx, &session, next_session);
|
||||
for event in readiness.events {
|
||||
emit_big_fish_publish_readiness_event(ctx, event)?;
|
||||
}
|
||||
|
||||
get_big_fish_session_tx(
|
||||
ctx,
|
||||
|
||||
@@ -104,6 +104,7 @@ macro_rules! migration_tables {
|
||||
ai_task_stage,
|
||||
ai_text_chunk,
|
||||
ai_result_reference,
|
||||
ai_task_event,
|
||||
runtime_snapshot,
|
||||
runtime_setting,
|
||||
user_browse_history,
|
||||
@@ -142,7 +143,8 @@ macro_rules! migration_tables {
|
||||
puzzle_runtime_run,
|
||||
big_fish_creation_session,
|
||||
big_fish_agent_message,
|
||||
big_fish_asset_slot
|
||||
big_fish_asset_slot,
|
||||
big_fish_event
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user