Files
Genarrative/server-rs/crates/module-runtime-story/src/story_engine.rs
kdletters 8f4ca9abfa Merge remote-tracking branch 'origin/master' into codex/ddd
# Conflicts:
#	docs/technical/README.md
#	docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md
#	docs/technical/SPACETIMEDB_TABLE_CATALOG.md
#	scripts/generate-spacetime-bindings.mjs
#	server-rs/crates/api-server/src/app.rs
#	server-rs/crates/api-server/src/assets.rs
#	server-rs/crates/api-server/src/big_fish.rs
#	server-rs/crates/api-server/src/custom_world_ai.rs
#	server-rs/crates/api-server/src/llm.rs
#	server-rs/crates/api-server/src/main.rs
#	server-rs/crates/api-server/src/puzzle.rs
#	server-rs/crates/api-server/src/runtime_profile.rs
#	server-rs/crates/api-server/src/runtime_story/compat/ai.rs
#	server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs
#	server-rs/crates/api-server/src/runtime_story/compat/presentation.rs
#	server-rs/crates/api-server/src/runtime_story/compat/tests.rs
#	server-rs/crates/api-server/src/state.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/module-big-fish/src/lib.rs
#	server-rs/crates/module-custom-world/src/lib.rs
#	server-rs/crates/module-puzzle/src/lib.rs
#	server-rs/crates/module-runtime/src/lib.rs
#	server-rs/crates/spacetime-client/src/big_fish.rs
#	server-rs/crates/spacetime-client/src/lib.rs
#	server-rs/crates/spacetime-client/src/mapper.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/mod.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	server-rs/crates/spacetime-module/src/custom_world/mod.rs
#	server-rs/crates/spacetime-module/src/lib.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	server-rs/crates/spacetime-module/src/puzzle.rs
#	server-rs/crates/spacetime-module/src/runtime/profile.rs
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/aiService.ts
#	src/services/puzzle-runtime/puzzleRuntimeClient.ts
2026-05-02 03:35:59 +08:00

1625 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde_json::{Map, Value, json};
use crate::{
ensure_json_object, format_now_rfc3339, read_array_field, read_bool_field, read_field,
read_i32_field, read_object_field, read_optional_string_field,
};
const CHAPTER_STAGE_OPENING: &str = "opening";
const CHAPTER_STAGE_EXPANSION: &str = "expansion";
const CHAPTER_STAGE_TURNING_POINT: &str = "turning_point";
const CHAPTER_STAGE_CLIMAX: &str = "climax";
const CHAPTER_STAGE_AFTERMATH: &str = "aftermath";
/// 将运行时动作结算后的叙事记忆投影到正式快照。
///
/// 中文注释:这里是从前端 story engine hook 迁出的最小确定性状态机。
/// 它只依赖动作前后 JSON 快照和本轮 functionId不访问 HTTP、LLM 或外部资源。
pub fn project_story_engine_after_action(
previous_state: &Value,
game_state: &mut Value,
action_text: &str,
result_text: &str,
function_id: &str,
battle_outcome: Option<&str>,
) {
let mut memory = read_object_field(game_state, "storyEngineMemory")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
ensure_memory_defaults(&mut memory);
let signals = collect_story_signals(
previous_state,
game_state,
&memory,
action_text,
function_id,
battle_outcome,
);
apply_thread_signal_updates(game_state, &mut memory, &signals);
// 中文注释NPC 战斗入口只是把当前 NPC 切入战斗结算,
// 不能顺手触发“首进场景章节任务”。否则玩家点战斗会误以为被系统自动接了任务。
if should_update_scene_chapter_state(function_id) {
ensure_scene_chapter_state(game_state, &mut memory);
}
let previous_chapter = read_object_field(game_state, "chapterState")
.or_else(|| read_object_field(&memory, "currentChapter"))
.cloned();
let chapter_state =
resolve_current_chapter_state(game_state, &memory, previous_chapter.as_ref());
ensure_json_object(game_state).insert("chapterState".to_string(), chapter_state.clone());
ensure_json_object(&mut memory).insert("currentChapter".to_string(), chapter_state.clone());
let journey_beat = resolve_current_journey_beat(game_state, &memory, &chapter_state);
let journey_beat_id = read_optional_string_field(&journey_beat, "id")
.unwrap_or_else(|| "journey:default".to_string());
let memory_root = ensure_json_object(&mut memory);
memory_root.insert("currentJourneyBeatId".to_string(), json!(journey_beat_id));
memory_root.insert("currentJourneyBeat".to_string(), journey_beat.clone());
let new_mutations = resolve_world_mutations(game_state, &memory, &signals, &chapter_state);
let world_mutations = append_world_mutations(&memory, new_mutations);
ensure_json_object(&mut memory).insert(
"worldMutations".to_string(),
Value::Array(world_mutations.clone()),
);
apply_world_mutations_to_game_state(game_state, &world_mutations);
let reactions = build_companion_reactions(game_state, &signals, action_text);
apply_companion_reactions_to_stance(game_state, &reactions);
append_recent_companion_reactions(&mut memory, reactions);
let chronicle = append_chronicle_entries(&memory, &chapter_state, &world_mutations);
ensure_json_object(&mut memory)
.insert("chronicle".to_string(), Value::Array(chronicle.clone()));
ensure_json_object(&mut memory).insert(
"continueGameDigest".to_string(),
Value::String(build_continue_digest(
&chapter_state,
result_text,
&chronicle,
)),
);
ensure_json_object(&mut memory).insert(
"saveMigrationManifest".to_string(),
json!({
"version": "story-engine-backend-v1",
"requiredTransforms": [],
"backwardCompatible": true
}),
);
ensure_json_object(game_state).insert("storyEngineMemory".to_string(), memory);
}
fn ensure_memory_defaults(memory: &mut Value) {
let root = ensure_json_object(memory);
ensure_array_field(root, "discoveredFactIds");
ensure_array_field(root, "inferredFactIds");
ensure_array_field(root, "activeThreadIds");
ensure_array_field(root, "resolvedScarIds");
ensure_array_field(root, "recentCarrierIds");
ensure_array_field(root, "openedSceneChapterIds");
ensure_array_field(root, "recentSignalIds");
ensure_array_field(root, "recentCompanionReactions");
ensure_array_field(root, "worldMutations");
ensure_array_field(root, "chronicle");
ensure_array_field(root, "factionTensionStates");
ensure_array_field(root, "consequenceLedger");
ensure_array_field(root, "companionResolutions");
ensure_array_field(root, "narrativeCodex");
root.entry("currentSceneActState".to_string())
.or_insert(Value::Null);
root.entry("currentChapter".to_string())
.or_insert(Value::Null);
root.entry("currentJourneyBeatId".to_string())
.or_insert(Value::Null);
root.entry("currentJourneyBeat".to_string())
.or_insert(Value::Null);
root.entry("currentCampEvent".to_string())
.or_insert(Value::Null);
root.entry("currentSetpieceDirective".to_string())
.or_insert(Value::Null);
root.entry("continueGameDigest".to_string())
.or_insert(Value::Null);
root.entry("campaignState".to_string())
.or_insert(Value::Null);
root.entry("actState".to_string()).or_insert(Value::Null);
root.entry("endingState".to_string()).or_insert(Value::Null);
root.entry("authorialConstraintPack".to_string())
.or_insert(Value::Null);
root.entry("branchBudgetStatus".to_string())
.or_insert(Value::Null);
root.entry("narrativeQaReport".to_string())
.or_insert(Value::Null);
root.entry("releaseGateReport".to_string())
.or_insert(Value::Null);
root.entry("playerStyleProfile".to_string())
.or_insert(Value::Null);
}
fn ensure_array_field(root: &mut Map<String, Value>, key: &str) {
if !root.get(key).is_some_and(Value::is_array) {
root.insert(key.to_string(), Value::Array(Vec::new()));
}
}
fn should_update_scene_chapter_state(function_id: &str) -> bool {
!matches!(function_id, "npc_fight" | "npc_spar")
}
fn collect_story_signals(
previous_state: &Value,
next_state: &Value,
memory: &Value,
action_text: &str,
function_id: &str,
battle_outcome: Option<&str>,
) -> Vec<Value> {
let mut signals = Vec::new();
let active_thread_ids = read_string_array_field(memory, "activeThreadIds");
let previous_scene_id = current_scene_id(previous_state);
let next_scene_id = current_scene_id(next_state);
if previous_scene_id != next_scene_id {
if let Some(scene_id) = previous_scene_id.as_deref() {
signals.push(build_signal(
"leave_scene",
scene_id,
json!({
"sceneId": scene_id,
"threadIds": active_thread_ids,
}),
));
}
if let Some(scene_id) = next_scene_id.as_deref() {
signals.push(build_signal(
"enter_scene",
scene_id,
json!({
"sceneId": scene_id,
"threadIds": active_thread_ids,
}),
));
}
}
if function_id == "idle_observe_signs" {
let key = next_scene_id.as_deref().unwrap_or("scene");
signals.push(build_signal(
"inspect_scene",
key,
json!({
"sceneId": next_scene_id,
"threadIds": active_thread_ids,
}),
));
}
if is_talk_signal(function_id, action_text, next_state) {
let actor_id =
current_encounter_id(next_state).or_else(|| current_encounter_id(previous_state));
let key = actor_id.as_deref().unwrap_or(action_text);
signals.push(build_signal(
"talk_to_actor",
key,
json!({
"actorId": actor_id,
"threadIds": active_thread_ids,
}),
));
}
if function_id == "npc_gift" {
let actor_id =
current_encounter_id(previous_state).or_else(|| current_encounter_id(next_state));
signals.push(build_signal(
"give_item",
action_text,
json!({
"actorId": actor_id,
"threadIds": active_thread_ids,
}),
));
}
if function_id == "npc_quest_accept" {
let actor_id =
current_encounter_id(previous_state).or_else(|| current_encounter_id(next_state));
signals.push(build_signal(
"accept_contract",
action_text,
json!({
"actorId": actor_id,
"threadIds": active_thread_ids,
}),
));
}
if is_battle_win_signal(function_id, previous_state, next_state, battle_outcome) {
let key = next_scene_id.as_deref().unwrap_or("battle");
signals.push(build_signal(
"win_battle",
key,
json!({
"sceneId": next_scene_id,
"threadIds": active_thread_ids,
}),
));
}
for item in find_new_inventory_items(previous_state, next_state) {
let item_id = read_optional_string_field(&item, "id").unwrap_or_else(|| "item".to_string());
let thread_ids = read_field(&item, "runtimeMetadata")
.and_then(|metadata| read_field(metadata, "storyFingerprint"))
.map(|fingerprint| read_string_array_field(fingerprint, "relatedThreadIds"))
.filter(|ids| !ids.is_empty())
.unwrap_or_else(|| active_thread_ids.clone());
signals.push(build_signal(
"obtain_carrier",
item_id.as_str(),
json!({
"carrierId": item_id,
"threadIds": thread_ids,
}),
));
}
dedupe_value_objects_by_id(signals, 12)
}
fn build_signal(signal_type: &str, key: &str, extra: Value) -> Value {
let mut signal = extra.as_object().cloned().unwrap_or_default();
signal.insert(
"id".to_string(),
Value::String(format!("{signal_type}:{key}")),
);
signal.insert(
"signalType".to_string(),
Value::String(signal_type.to_string()),
);
Value::Object(signal)
}
fn is_talk_signal(function_id: &str, action_text: &str, next_state: &Value) -> bool {
matches!(
function_id,
"npc_chat"
| "npc_preview_talk"
| "npc_help"
| "story_opening_camp_dialogue"
| "npc_chat_quest_offer_view"
| "npc_quest_accept"
| "npc_quest_turn_in"
) || read_object_field(next_state, "currentEncounter")
.and_then(|encounter| read_optional_string_field(encounter, "kind"))
.as_deref()
== Some("npc")
|| action_text.contains('聊')
|| action_text.contains('问')
|| action_text.contains("试探")
}
fn is_battle_win_signal(
function_id: &str,
previous_state: &Value,
next_state: &Value,
battle_outcome: Option<&str>,
) -> bool {
if !function_id.starts_with("battle_") && function_id != "inventory_use" {
return false;
}
if let Some(outcome) = battle_outcome {
// 中文注释:战斗终局已经由 battle resolver 明确给出时,
// story engine 必须信任该结果,避免败北复活清空战斗态后被误判为胜利信号。
return matches!(outcome, "victory" | "spar_complete");
}
let previous_battle = read_bool_field(previous_state, "inBattle").unwrap_or(false);
let next_battle = read_bool_field(next_state, "inBattle").unwrap_or(false);
let outcome = read_optional_string_field(next_state, "currentNpcBattleOutcome");
matches!(
outcome.as_deref(),
Some("fight_victory") | Some("spar_complete")
) && previous_battle
&& !next_battle
}
fn find_new_inventory_items(previous_state: &Value, next_state: &Value) -> Vec<Value> {
let previous_ids = read_array_field(previous_state, "playerInventory")
.into_iter()
.filter_map(|item| read_optional_string_field(item, "id"))
.collect::<std::collections::HashSet<_>>();
read_array_field(next_state, "playerInventory")
.into_iter()
.filter(|item| {
read_optional_string_field(item, "id").is_some_and(|id| !previous_ids.contains(&id))
})
.cloned()
.collect()
}
fn apply_thread_signal_updates(game_state: &mut Value, memory: &mut Value, signals: &[Value]) {
if signals.is_empty() {
return;
}
let active_thread_ids = dedupe_strings(
read_string_array_field(memory, "activeThreadIds")
.into_iter()
.chain(
signals
.iter()
.flat_map(|signal| read_string_array_field(signal, "threadIds")),
)
.collect(),
8,
);
let recent_signal_ids = dedupe_strings(
read_string_array_field(memory, "recentSignalIds")
.into_iter()
.chain(
signals
.iter()
.filter_map(|signal| read_optional_string_field(signal, "id")),
)
.collect(),
12,
);
let root = ensure_json_object(memory);
root.insert("activeThreadIds".to_string(), json!(active_thread_ids));
root.insert("recentSignalIds".to_string(), json!(recent_signal_ids));
update_quests_from_signals(game_state, signals);
}
fn update_quests_from_signals(game_state: &mut Value, signals: &[Value]) {
let signal_thread_ids = signals
.iter()
.flat_map(|signal| read_string_array_field(signal, "threadIds"))
.collect::<Vec<_>>();
if signal_thread_ids.is_empty() {
return;
}
let root = ensure_json_object(game_state);
let quests = root
.entry("quests".to_string())
.or_insert_with(|| json!([]));
let Some(items) = quests.as_array_mut() else {
*quests = Value::Array(Vec::new());
return;
};
for quest in items {
let Some(quest_object) = quest.as_object_mut() else {
continue;
};
let quest_thread_id = quest_object
.get("threadId")
.and_then(Value::as_str)
.map(str::to_string);
if !quest_thread_id
.as_ref()
.is_some_and(|thread_id| signal_thread_ids.iter().any(|id| id == thread_id))
{
continue;
}
let next_visible_stage = quest_object
.get("visibleStage")
.and_then(Value::as_i64)
.unwrap_or(0)
.saturating_add(i64::try_from(signals.len()).unwrap_or(0))
.min(12);
quest_object.insert("visibleStage".to_string(), json!(next_visible_stage));
let discovered = dedupe_strings(
read_string_array_from_object(quest_object, "discoveredFactIds")
.into_iter()
.chain(signal_thread_ids.clone())
.collect(),
12,
);
quest_object.insert("discoveredFactIds".to_string(), json!(discovered));
}
}
fn ensure_scene_chapter_state(game_state: &mut Value, memory: &mut Value) {
let current_scene = read_optional_string_field(game_state, "currentScene");
let world_type = read_optional_string_field(game_state, "worldType");
let Some(scene) = read_object_field(game_state, "currentScenePreset").cloned() else {
return;
};
let Some(scene_id) = read_optional_string_field(&scene, "id") else {
return;
};
if current_scene.as_deref() != Some("Story") || world_type.is_none() {
return;
}
let opened = dedupe_strings(
read_string_array_field(memory, "openedSceneChapterIds")
.into_iter()
.chain(std::iter::once(scene_id.clone()))
.collect(),
64,
);
ensure_json_object(memory).insert("openedSceneChapterIds".to_string(), json!(opened));
if let Some(scene_act_state) =
build_initial_scene_act_runtime_state(game_state, memory, scene_id.as_str())
{
ensure_json_object(memory).insert("currentSceneActState".to_string(), scene_act_state);
}
if has_live_scene_chapter_quest(game_state, scene_id.as_str()) {
return;
}
let quest = build_scene_chapter_quest(game_state, &scene, scene_id.as_str());
let root = ensure_json_object(game_state);
let quests = root
.entry("quests".to_string())
.or_insert_with(|| json!([]));
if !quests.is_array() {
*quests = Value::Array(Vec::new());
}
quests
.as_array_mut()
.expect("quests should be array")
.push(quest);
}
fn has_live_scene_chapter_quest(game_state: &Value, scene_id: &str) -> bool {
let chapter_id = build_scene_chapter_id(scene_id);
read_array_field(game_state, "quests")
.into_iter()
.any(|quest| {
read_optional_string_field(quest, "chapterId").as_deref() == Some(chapter_id.as_str())
&& !matches!(
read_optional_string_field(quest, "status").as_deref(),
Some("turned_in") | Some("failed") | Some("expired")
)
})
}
fn build_scene_chapter_quest(game_state: &Value, scene: &Value, scene_id: &str) -> Value {
let scene_name =
read_optional_string_field(scene, "name").unwrap_or_else(|| "当前区域".to_string());
let scene_description = read_optional_string_field(scene, "description")
.unwrap_or_else(|| format!("{scene_name} 的局势正在变化。"));
let (issuer_npc_id, issuer_npc_name) = resolve_scene_chapter_issuer(scene);
let chapter_id = build_scene_chapter_id(scene_id);
let quest_id = format!("quest:chapter:{scene_id}");
let title = compact_title(format!("{scene_name}异动").as_str(), "查明异动");
let world_type = read_optional_string_field(game_state, "worldType");
let currency = if world_type.as_deref() == Some("XIANXIA") {
54
} else {
72
};
json!({
"id": quest_id,
"issuerNpcId": issuer_npc_id,
"issuerNpcName": issuer_npc_name,
"sceneId": scene_id,
"chapterId": chapter_id,
"actId": read_field(game_state, "storyEngineMemory")
.and_then(|memory| read_field(memory, "actState"))
.and_then(|act| read_optional_string_field(act, "id")),
"threadId": Value::Null,
"contractId": Value::Null,
"title": title,
"description": format!("{scene_description} 这一章需要先把现场线索和压力接住。"),
"summary": format!("在 {scene_name} 接住这一章的线索并完成收束"),
"objective": {
"kind": "talk_to_npc",
"targetNpcId": issuer_npc_id,
"requiredCount": 1
},
"progress": 0,
"status": "active",
"completionNotified": false,
"reward": {
"affinityBonus": 12,
"currency": currency,
"experience": 40,
"items": []
},
"rewardText": format!("完成后可获得好感 +12、赏金 {currency}、经验 +40。"),
"narrativeBinding": {
"origin": "fallback_builder",
"narrativeType": "investigation",
"dramaticNeed": format!("{scene_name} 的异常已经足以独立成章。"),
"issuerGoal": format!("查清 {scene_name} 当前没有说透的异动。"),
"playerHook": format!("你已经进入 {scene_name},这一章现在就落在你面前。"),
"worldReason": format!("{scene_name} 的线索和残痕正在把局势往前推。"),
"followupHooks": [format!("{scene_name} 的这一章收束后,下一段去向会更明确。")]
},
"steps": [
{
"id": format!("{quest_id}:opening"),
"title": "确认现场异样",
"kind": "talk_to_npc",
"targetNpcId": issuer_npc_id,
"requiredCount": 1,
"progress": 0,
"revealText": format!("先在 {scene_name} 确认眼前异样,不要让这一章从开口处滑过去。"),
"completeText": format!("{scene_name} 的表层线索已经确认,可以继续推进收束。")
},
{
"id": format!("{quest_id}:resolve"),
"title": "收束当前章节",
"kind": "reach_scene",
"targetSceneId": scene_id,
"requiredCount": 1,
"progress": 0,
"revealText": format!("继续推进 {scene_name} 的线索,把这一章推向收束。"),
"completeText": format!("{scene_name} 的这一章已经完成收束。")
}
],
"activeStepId": format!("{quest_id}:opening"),
"visibleStage": 0,
"hiddenFlags": [],
"discoveredFactIds": [],
"relatedCarrierIds": [],
"consequenceIds": []
})
}
fn resolve_scene_chapter_issuer(scene: &Value) -> (String, String) {
let npc = read_array_field(scene, "npcs")
.into_iter()
.find(|npc| !read_bool_field(npc, "hostile").unwrap_or(false))
.or_else(|| read_array_field(scene, "npcs").into_iter().next());
let npc_id = npc
.and_then(|value| read_optional_string_field(value, "id"))
.unwrap_or_else(|| "scene-guide".to_string());
let npc_name = npc
.and_then(|value| {
read_optional_string_field(value, "name")
.or_else(|| read_optional_string_field(value, "npcName"))
})
.unwrap_or_else(|| {
read_optional_string_field(scene, "name").unwrap_or_else(|| "现场线索".to_string())
});
(npc_id, npc_name)
}
fn build_initial_scene_act_runtime_state(
game_state: &Value,
memory: &Value,
scene_id: &str,
) -> Option<Value> {
let profile = read_object_field(game_state, "customWorldProfile")?;
let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?;
let chapter_id = read_optional_string_field(chapter, "id")?;
let acts = read_array_field(chapter, "acts");
let first_act = acts.first().copied()?;
let first_act_id = read_optional_string_field(first_act, "id")?;
if let Some(runtime_state) = read_object_field(memory, "currentSceneActState") {
if read_optional_string_field(runtime_state, "chapterId").as_deref()
== Some(chapter_id.as_str())
{
let current_act_id = read_optional_string_field(runtime_state, "currentActId");
if current_act_id.as_ref().is_some_and(|act_id| {
acts.iter().any(|act| {
read_optional_string_field(act, "id").as_deref() == Some(act_id.as_str())
})
}) {
return Some(json!({
"sceneId": read_optional_string_field(runtime_state, "sceneId").unwrap_or_else(|| scene_id.to_string()),
"chapterId": chapter_id,
"currentActId": current_act_id,
"currentActIndex": read_i32_field(runtime_state, "currentActIndex").unwrap_or(0).max(0),
"completedActIds": read_string_array_field(runtime_state, "completedActIds"),
"visitedActIds": read_string_array_field(runtime_state, "visitedActIds"),
}));
}
}
}
Some(json!({
"sceneId": read_optional_string_field(chapter, "sceneId").unwrap_or_else(|| scene_id.to_string()),
"chapterId": chapter_id,
"currentActId": first_act_id,
"currentActIndex": 0,
"completedActIds": [],
"visitedActIds": [first_act_id]
}))
}
fn resolve_scene_chapter_blueprint<'a>(profile: &'a Value, scene_id: &str) -> Option<&'a Value> {
read_array_field(profile, "sceneChapterBlueprints")
.into_iter()
.find(|chapter| {
read_optional_string_field(chapter, "sceneId").as_deref() == Some(scene_id)
|| read_string_array_field(chapter, "linkedLandmarkIds")
.iter()
.any(|id| id == scene_id)
|| read_array_field(chapter, "acts").into_iter().any(|act| {
read_optional_string_field(act, "sceneId").as_deref() == Some(scene_id)
})
})
}
fn resolve_current_chapter_state(
game_state: &Value,
memory: &Value,
previous_chapter: Option<&Value>,
) -> Value {
let active_thread_ids = read_string_array_field(memory, "activeThreadIds");
let scene_id = current_scene_id(game_state);
let scene_name = read_object_field(game_state, "currentScenePreset")
.and_then(|scene| read_optional_string_field(scene, "name"))
.unwrap_or_else(|| "当前区域".to_string());
if let Some((quest, chapter_id)) = scene_id.as_deref().and_then(|id| {
find_scene_chapter_quest(game_state, id).map(|quest| (quest, build_scene_chapter_id(id)))
}) {
let stage = derive_chapter_stage_from_quest(quest);
let theme = read_optional_string_field(quest, "title")
.or_else(|| resolve_profile_theme(game_state, &active_thread_ids))
.unwrap_or_else(|| "旅程推进".to_string());
let primary_thread_ids = dedupe_strings(
read_optional_string_field(quest, "threadId")
.into_iter()
.chain(active_thread_ids.clone())
.collect(),
3,
);
return json!({
"id": chapter_id,
"title": format!("{scene_name}·{}", stage_label(stage)),
"theme": theme,
"primaryThreadIds": primary_thread_ids,
"stage": stage,
"chapterSummary": build_scene_chapter_summary(scene_name.as_str(), quest, stage),
"sceneId": scene_id,
"chapterQuestId": read_optional_string_field(quest, "id"),
});
}
let stage = resolve_freeform_chapter_stage(game_state, memory, previous_chapter);
let theme = resolve_profile_theme(game_state, &active_thread_ids)
.unwrap_or_else(|| "旅程推进".to_string());
let title = format!("{theme}·{}", stage_label(stage));
let chapter_id = if previous_chapter
.and_then(|chapter| read_optional_string_field(chapter, "stage"))
.as_deref()
== Some(stage)
&& previous_chapter
.and_then(|chapter| read_optional_string_field(chapter, "theme"))
.as_deref()
== Some(theme.as_str())
{
previous_chapter
.and_then(|chapter| read_optional_string_field(chapter, "id"))
.unwrap_or_else(|| build_freeform_chapter_id(&active_thread_ids, stage))
} else {
build_freeform_chapter_id(&active_thread_ids, stage)
};
json!({
"id": chapter_id,
"title": title,
"theme": theme,
"primaryThreadIds": dedupe_strings(active_thread_ids, 3),
"stage": stage,
"chapterSummary": format!("{title} 当前围绕 {theme} 推进。"),
"sceneId": Value::Null,
"chapterQuestId": Value::Null,
})
}
fn find_scene_chapter_quest<'a>(game_state: &'a Value, scene_id: &str) -> Option<&'a Value> {
let chapter_id = build_scene_chapter_id(scene_id);
read_array_field(game_state, "quests")
.into_iter()
.find(|quest| {
read_optional_string_field(quest, "chapterId").as_deref() == Some(chapter_id.as_str())
&& !matches!(
read_optional_string_field(quest, "status").as_deref(),
Some("failed") | Some("expired")
)
})
}
fn derive_chapter_stage_from_quest(quest: &Value) -> &'static str {
match read_optional_string_field(quest, "status").as_deref() {
Some("turned_in") => return CHAPTER_STAGE_AFTERMATH,
Some("ready_to_turn_in") | Some("completed") => return CHAPTER_STAGE_CLIMAX,
_ => {}
}
let steps = read_array_field(quest, "steps");
let active_step_id = read_optional_string_field(quest, "activeStepId");
let active_step_index = active_step_id
.as_deref()
.and_then(|id| {
steps
.iter()
.position(|step| read_optional_string_field(step, "id").as_deref() == Some(id))
})
.unwrap_or(0);
match active_step_index {
0 => CHAPTER_STAGE_OPENING,
1 => CHAPTER_STAGE_EXPANSION,
_ => CHAPTER_STAGE_TURNING_POINT,
}
}
fn build_scene_chapter_summary(scene_name: &str, quest: &Value, stage: &str) -> String {
let quest_description = read_optional_string_field(quest, "description")
.or_else(|| read_optional_string_field(quest, "summary"))
.unwrap_or_else(|| "这一章仍在推进中。".to_string());
match stage {
CHAPTER_STAGE_OPENING => format!("{scene_name} 的这一章刚刚开启。{quest_description}"),
CHAPTER_STAGE_EXPANSION => format!("{scene_name} 的压力正在展开。{quest_description}"),
CHAPTER_STAGE_TURNING_POINT => {
format!("{scene_name} 的线索正在改写当前判断。{quest_description}")
}
CHAPTER_STAGE_CLIMAX => {
format!("{scene_name} 的核心矛盾已经被推到最后一步,只差正式收束。")
}
CHAPTER_STAGE_AFTERMATH => {
format!("{scene_name} 这一章已经完成收束,余波和下一段去向正在显形。")
}
_ => format!("{scene_name} 的这一章仍在推进中。"),
}
}
fn resolve_freeform_chapter_stage(
game_state: &Value,
memory: &Value,
previous_chapter: Option<&Value>,
) -> &'static str {
let score = i32::try_from(read_string_array_field(memory, "recentSignalIds").len())
.unwrap_or(0)
+ i32::try_from(read_array_field(memory, "chronicle").len()).unwrap_or(0)
+ i32::try_from(read_string_array_field(memory, "activeThreadIds").len()).unwrap_or(0);
if score >= 12 {
CHAPTER_STAGE_AFTERMATH
} else if score >= 9 {
CHAPTER_STAGE_CLIMAX
} else if score >= 6 {
CHAPTER_STAGE_TURNING_POINT
} else if score >= 3 {
CHAPTER_STAGE_EXPANSION
} else if read_object_field(game_state, "chapterState")
.and_then(|chapter| read_optional_string_field(chapter, "stage"))
.or_else(|| {
previous_chapter.and_then(|chapter| read_optional_string_field(chapter, "stage"))
})
.as_deref()
== Some(CHAPTER_STAGE_AFTERMATH)
{
CHAPTER_STAGE_AFTERMATH
} else {
CHAPTER_STAGE_OPENING
}
}
fn resolve_profile_theme(game_state: &Value, active_thread_ids: &[String]) -> Option<String> {
let profile = read_object_field(game_state, "customWorldProfile")?;
if let Some(thread_title) = first_thread_title(profile, active_thread_ids) {
return Some(thread_title);
}
read_object_field(profile, "themePack")
.and_then(|theme_pack| read_optional_string_field(theme_pack, "displayName"))
.or_else(|| read_optional_string_field(profile, "summary"))
}
fn first_thread_title(profile: &Value, active_thread_ids: &[String]) -> Option<String> {
let story_graph = read_object_field(profile, "storyGraph")?;
let threads = read_array_field(story_graph, "visibleThreads")
.into_iter()
.chain(read_array_field(story_graph, "hiddenThreads"))
.collect::<Vec<_>>();
active_thread_ids.iter().find_map(|thread_id| {
threads.iter().find_map(|thread| {
(read_optional_string_field(thread, "id").as_deref() == Some(thread_id.as_str()))
.then(|| read_optional_string_field(thread, "title"))
.flatten()
})
})
}
fn resolve_current_journey_beat(
game_state: &Value,
memory: &Value,
chapter_state: &Value,
) -> Value {
let chapter_id = read_optional_string_field(chapter_state, "id")
.unwrap_or_else(|| "chapter:default".to_string());
let chapter_title = read_optional_string_field(chapter_state, "title")
.unwrap_or_else(|| "当前章节".to_string());
let stage = read_optional_string_field(chapter_state, "stage")
.unwrap_or_else(|| CHAPTER_STAGE_OPENING.to_string());
let beat_type = match stage.as_str() {
CHAPTER_STAGE_OPENING => "approach",
CHAPTER_STAGE_EXPANSION => "investigation",
CHAPTER_STAGE_TURNING_POINT => "conflict",
CHAPTER_STAGE_CLIMAX => "climax",
CHAPTER_STAGE_AFTERMATH => "recovery",
_ => "approach",
};
let stored_beat_id = read_optional_string_field(memory, "currentJourneyBeatId");
let id = stored_beat_id.unwrap_or_else(|| format!("{chapter_id}:{beat_type}"));
let current_scene_id = current_scene_id(game_state).into_iter().collect::<Vec<_>>();
let emotional_goal = if beat_type == "climax" {
"把冲突推到最前台。"
} else if beat_type == "recovery" {
"让角色和世界消化刚发生的后果。"
} else {
"让线索、关系和压力继续叠加。"
};
json!({
"id": id,
"beatType": beat_type,
"title": format!("{chapter_title}·当前段落"),
"triggerThreadIds": read_string_array_field(chapter_state, "primaryThreadIds"),
"recommendedSceneIds": current_scene_id,
"emotionalGoal": emotional_goal,
})
}
fn resolve_world_mutations(
game_state: &Value,
memory: &Value,
signals: &[Value],
chapter_state: &Value,
) -> Vec<Value> {
let mut mutations = Vec::new();
let current_scene_id = current_scene_id(game_state);
let active_thread_ids = read_string_array_field(memory, "activeThreadIds");
let chapter_stage = read_optional_string_field(chapter_state, "stage");
if let Some(scene_id) = current_scene_id.as_deref() {
let chapter_title = read_optional_string_field(chapter_state, "title")
.unwrap_or_else(|| "当前章节".to_string());
mutations.push(json!({
"id": format!("mutation:scene:{scene_id}:{}", chapter_stage.as_deref().unwrap_or(CHAPTER_STAGE_OPENING)),
"mutationType": "scene_text",
"targetId": scene_id,
"reason": format!("{chapter_title}正在改写这片地界的表面气氛。"),
"relatedThreadIds": read_string_array_field(chapter_state, "primaryThreadIds"),
}));
}
if current_scene_id.is_some()
&& signals.iter().any(|signal| {
read_optional_string_field(signal, "signalType").as_deref() == Some("win_battle")
})
{
let scene_id = current_scene_id.as_deref().unwrap_or("scene");
mutations.push(json!({
"id": format!("mutation:pressure:{scene_id}:battle"),
"mutationType": "enemy_pressure",
"targetId": scene_id,
"reason": "这一带的敌意正在因交锋结果重新聚拢。",
"relatedThreadIds": dedupe_strings(active_thread_ids.clone(), 4),
}));
}
if signals.iter().any(|signal| {
read_optional_string_field(signal, "signalType").as_deref() == Some("obtain_carrier")
}) {
let scene_id = current_scene_id.as_deref().unwrap_or("scene");
mutations.push(json!({
"id": format!("mutation:attitude:{scene_id}:carrier"),
"mutationType": "npc_attitude",
"targetId": scene_id,
"reason": "关键载体已经落到你手里,相关角色的口风会开始变化。",
"relatedThreadIds": dedupe_strings(active_thread_ids.clone(), 4),
}));
}
if chapter_stage.as_deref() == Some(CHAPTER_STAGE_CLIMAX) {
if let Some(scene_id) = current_scene_id.as_deref() {
mutations.push(json!({
"id": format!("mutation:route:{scene_id}:climax"),
"mutationType": "route_unlock",
"targetId": scene_id,
"reason": "章节高潮逼近,新的通路或对峙点开始显影。",
"relatedThreadIds": read_string_array_field(chapter_state, "primaryThreadIds"),
}));
}
}
dedupe_value_objects_by_id(mutations, 8)
}
fn append_world_mutations(memory: &Value, additions: Vec<Value>) -> Vec<Value> {
dedupe_value_objects_by_id(
read_array_field(memory, "worldMutations")
.into_iter()
.cloned()
.chain(additions)
.collect(),
24,
)
}
fn apply_world_mutations_to_game_state(game_state: &mut Value, mutations: &[Value]) {
let Some(current_scene_id) = current_scene_id(game_state) else {
return;
};
let relevant = mutations
.iter()
.filter(|mutation| {
read_optional_string_field(mutation, "targetId").as_deref()
== Some(current_scene_id.as_str())
})
.collect::<Vec<_>>();
if relevant.is_empty() {
return;
}
let latest_scene_reason = relevant
.iter()
.rev()
.find(|mutation| {
read_optional_string_field(mutation, "mutationType").as_deref() == Some("scene_text")
})
.and_then(|mutation| read_optional_string_field(mutation, "reason"));
let latest_attitude_reason = relevant
.iter()
.rev()
.find(|mutation| {
read_optional_string_field(mutation, "mutationType").as_deref() == Some("npc_attitude")
})
.and_then(|mutation| read_optional_string_field(mutation, "reason"));
let pressure_count = relevant
.iter()
.filter(|mutation| {
read_optional_string_field(mutation, "mutationType").as_deref()
== Some("enemy_pressure")
})
.count();
let pressure_level = match pressure_count {
count if count >= 3 => "extreme",
2 => "high",
1 => "medium",
_ => "low",
};
let root = ensure_json_object(game_state);
let Some(scene) = root
.get_mut("currentScenePreset")
.and_then(Value::as_object_mut)
else {
return;
};
let mutation_text = [latest_scene_reason.clone(), latest_attitude_reason.clone()]
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
if !mutation_text.is_empty() {
scene.insert(
"mutationStateText".to_string(),
Value::String(mutation_text),
);
}
scene.insert(
"currentPressureLevel".to_string(),
Value::String(pressure_level.to_string()),
);
if let Some(reason) = latest_scene_reason {
let description = scene
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if !description.contains(reason.as_str()) {
let next_description = [description.as_str(), reason.as_str()]
.into_iter()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
scene.insert("description".to_string(), Value::String(next_description));
}
}
if let Some(attitude_reason) = latest_attitude_reason {
if let Some(npcs) = scene.get_mut("npcs").and_then(Value::as_array_mut) {
for npc in npcs {
if read_bool_field(npc, "hostile").unwrap_or(false) {
continue;
}
let description =
read_optional_string_field(npc, "description").unwrap_or_default();
if description.contains(attitude_reason.as_str()) {
continue;
}
if let Some(npc_object) = npc.as_object_mut() {
let next_description = [description.as_str(), attitude_reason.as_str()]
.into_iter()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
npc_object.insert("description".to_string(), Value::String(next_description));
}
}
}
}
}
fn build_companion_reactions(
game_state: &Value,
signals: &[Value],
action_text: &str,
) -> Vec<Value> {
let signal_types = signals
.iter()
.filter_map(|signal| read_optional_string_field(signal, "signalType"))
.collect::<Vec<_>>();
let related_thread_ids = dedupe_strings(
signals
.iter()
.flat_map(|signal| read_string_array_field(signal, "threadIds"))
.collect(),
4,
);
let companions = read_array_field(game_state, "companions")
.into_iter()
.chain(read_array_field(game_state, "roster"))
.take(2)
.cloned()
.collect::<Vec<_>>();
let reaction_type = resolve_reaction_type(action_text, &signal_types);
companions
.into_iter()
.enumerate()
.filter_map(|(index, companion)| {
let character_id = read_optional_string_field(&companion, "characterId")?;
Some(json!({
"id": format!("reaction:{character_id}:{}:{}", signal_types.len(), index + 1),
"characterId": character_id,
"reactionType": reaction_type,
"reason": build_reaction_reason(action_text, reaction_type),
"relatedThreadIds": related_thread_ids,
"createdAt": format_now_rfc3339(),
}))
})
.collect()
}
fn resolve_reaction_type<'a>(action_text: &str, signal_types: &[String]) -> &'a str {
if action_text.contains("强行")
|| action_text.contains("掠夺")
|| action_text.contains("恶意")
|| action_text.contains("开战")
|| action_text.contains("威胁")
{
"disapprove"
} else if signal_types
.iter()
.any(|signal| signal == "accept_contract")
|| action_text.contains('帮')
|| action_text.contains('援')
|| action_text.contains("调查")
{
"approve"
} else if signal_types
.iter()
.any(|signal| signal == "obtain_carrier" || signal == "inspect_scene")
{
"curious"
} else if action_text.contains('礼') || action_text.contains('赠') || action_text.contains('送')
{
"concern"
} else {
"silence"
}
}
fn build_reaction_reason(action_text: &str, reaction_type: &str) -> String {
match reaction_type {
"approve" => format!("同行角色觉得你这一步接得住局势:{action_text}"),
"disapprove" => format!("同行角色对这一步明显有保留:{action_text}"),
"concern" => format!("同行角色觉得你这一步可能会牵出额外代价:{action_text}"),
"curious" => format!("同行角色被这一步新露出的线索勾住了注意力:{action_text}"),
_ => format!("同行角色暂时没有正面插话,但显然记住了这一步:{action_text}"),
}
}
fn apply_companion_reactions_to_stance(game_state: &mut Value, reactions: &[Value]) {
if reactions.is_empty() {
return;
}
let companions = read_array_field(game_state, "companions")
.into_iter()
.chain(read_array_field(game_state, "roster"))
.cloned()
.collect::<Vec<_>>();
let root = ensure_json_object(game_state);
let Some(npc_states) = root.get_mut("npcStates").and_then(Value::as_object_mut) else {
return;
};
for reaction in reactions {
let Some(character_id) = read_optional_string_field(reaction, "characterId") else {
continue;
};
let Some(companion) = companions.iter().find(|companion| {
read_optional_string_field(companion, "characterId").as_deref()
== Some(character_id.as_str())
}) else {
continue;
};
let Some(npc_id) = read_optional_string_field(companion, "npcId") else {
continue;
};
let Some(stance) = npc_states
.get_mut(npc_id.as_str())
.and_then(|state| state.as_object_mut())
.and_then(|state| state.get_mut("stanceProfile"))
.and_then(Value::as_object_mut)
else {
continue;
};
let reaction_type =
read_optional_string_field(reaction, "reactionType").unwrap_or_default();
adjust_stance_for_reaction(stance, reaction, reaction_type.as_str());
}
}
fn adjust_stance_for_reaction(
stance: &mut Map<String, Value>,
reaction: &Value,
reaction_type: &str,
) {
match reaction_type {
"approve" => {
bump_stance_number(stance, "trust", 2);
bump_stance_number(stance, "loyalty", 1);
append_stance_note(stance, "recentApprovals", reaction);
}
"disapprove" => {
bump_stance_number(stance, "fearOrGuard", 3);
append_stance_note(stance, "recentDisapprovals", reaction);
}
"concern" => {
bump_stance_number(stance, "fearOrGuard", 2);
append_stance_note(stance, "recentDisapprovals", reaction);
}
"curious" => {
bump_stance_number(stance, "ideologicalFit", 1);
append_stance_note(stance, "recentApprovals", reaction);
}
_ => {}
}
}
fn bump_stance_number(stance: &mut Map<String, Value>, key: &str, delta: i32) {
let next = stance
.get(key)
.and_then(Value::as_i64)
.unwrap_or(0)
.saturating_add(i64::from(delta))
.clamp(0, 100);
stance.insert(key.to_string(), json!(next));
}
fn append_stance_note(stance: &mut Map<String, Value>, key: &str, reaction: &Value) {
let mut notes = stance
.get(key)
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if let Some(reason) = read_optional_string_field(reaction, "reason") {
notes.push(Value::String(reason));
}
let keep_from = notes.len().saturating_sub(3);
stance.insert(
key.to_string(),
Value::Array(notes.into_iter().skip(keep_from).collect()),
);
}
fn append_recent_companion_reactions(memory: &mut Value, reactions: Vec<Value>) {
if reactions.is_empty() {
return;
}
let mut recent = read_array_field(memory, "recentCompanionReactions")
.into_iter()
.cloned()
.chain(reactions)
.collect::<Vec<_>>();
let keep_from = recent.len().saturating_sub(6);
recent = recent.into_iter().skip(keep_from).collect();
ensure_json_object(memory).insert("recentCompanionReactions".to_string(), Value::Array(recent));
}
fn append_chronicle_entries(
memory: &Value,
chapter_state: &Value,
world_mutations: &[Value],
) -> Vec<Value> {
let now = format_now_rfc3339();
let mut entries = read_array_field(memory, "chronicle")
.into_iter()
.cloned()
.collect::<Vec<_>>();
if let Some(chapter_id) = read_optional_string_field(chapter_state, "id") {
entries.push(json!({
"id": format!("chronicle:chapter:{chapter_id}"),
"category": "chapter",
"title": read_optional_string_field(chapter_state, "title").unwrap_or_else(|| "当前章节".to_string()),
"summary": read_optional_string_field(chapter_state, "chapterSummary").unwrap_or_default(),
"relatedIds": read_string_array_field(chapter_state, "primaryThreadIds"),
"createdAt": now,
}));
}
for mutation in world_mutations.iter().rev().take(4) {
let Some(mutation_id) = read_optional_string_field(mutation, "id") else {
continue;
};
entries.push(json!({
"id": format!("chronicle:world_event:{mutation_id}"),
"category": "world_event",
"title": read_optional_string_field(mutation, "reason").unwrap_or_else(|| "世界状态变化".to_string()),
"summary": format!(
"{} 影响了 {}",
read_optional_string_field(mutation, "mutationType").unwrap_or_else(|| "world_event".to_string()),
read_optional_string_field(mutation, "targetId").unwrap_or_else(|| "scene".to_string())
),
"relatedIds": read_string_array_field(mutation, "relatedThreadIds"),
"createdAt": format_now_rfc3339(),
}));
}
dedupe_value_objects_by_id(entries, 18)
}
fn build_continue_digest(chapter_state: &Value, result_text: &str, chronicle: &[Value]) -> String {
let chapter_summary = read_optional_string_field(chapter_state, "chapterSummary")
.unwrap_or_else(|| "当前章节仍在推进。".to_string());
let recent = chronicle
.iter()
.rev()
.take(3)
.filter_map(|entry| {
Some(format!(
"- {}{}",
read_optional_string_field(entry, "title")?,
read_optional_string_field(entry, "summary").unwrap_or_default()
))
})
.collect::<Vec<_>>()
.join("\n");
[chapter_summary, result_text.to_string(), recent]
.into_iter()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn current_scene_id(game_state: &Value) -> Option<String> {
read_object_field(game_state, "currentScenePreset")
.and_then(|scene| read_optional_string_field(scene, "id"))
}
fn current_encounter_id(game_state: &Value) -> Option<String> {
read_object_field(game_state, "currentEncounter").and_then(|encounter| {
read_optional_string_field(encounter, "id")
.or_else(|| read_optional_string_field(encounter, "npcName"))
})
}
fn build_scene_chapter_id(scene_id: &str) -> String {
format!("chapter:scene:{scene_id}")
}
fn build_freeform_chapter_id(active_thread_ids: &[String], stage: &str) -> String {
let key = if active_thread_ids.is_empty() {
"default".to_string()
} else {
active_thread_ids
.iter()
.take(2)
.cloned()
.collect::<Vec<_>>()
.join("+")
};
format!("chapter:{key}:{stage}")
}
fn stage_label(stage: &str) -> &'static str {
match stage {
CHAPTER_STAGE_OPENING => "序章",
CHAPTER_STAGE_EXPANSION => "展开",
CHAPTER_STAGE_TURNING_POINT => "转折",
CHAPTER_STAGE_CLIMAX => "高潮",
CHAPTER_STAGE_AFTERMATH => "余波",
_ => "推进",
}
}
fn compact_title(raw: &str, fallback: &str) -> String {
let cleaned = raw
.replace(['《', '》', '「', '」', '“', '”', '"', '\''], "")
.split([
'', '。', '', '', '', '', ',', '.', '!', '?', ';', ':',
])
.next()
.unwrap_or_default()
.trim()
.to_string();
if cleaned.is_empty() {
fallback.to_string()
} else if cleaned.chars().count() > 12 {
cleaned.chars().take(10).collect()
} else {
cleaned
}
}
fn read_string_array_field(value: &Value, key: &str) -> Vec<String> {
read_field(value, key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
}
fn read_string_array_from_object(value: &Map<String, Value>, key: &str) -> Vec<String> {
value
.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
}
fn dedupe_strings(values: Vec<String>, limit: usize) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for value in values {
let trimmed = value.trim();
if trimmed.is_empty() || !seen.insert(trimmed.to_string()) {
continue;
}
result.push(trimmed.to_string());
if result.len() >= limit {
break;
}
}
result
}
fn dedupe_value_objects_by_id(values: Vec<Value>, limit: usize) -> Vec<Value> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for value in values {
let id = read_optional_string_field(&value, "id").unwrap_or_else(|| value.to_string());
if !seen.insert(id) {
continue;
}
result.push(value);
}
let keep_from = result.len().saturating_sub(limit);
result.into_iter().skip(keep_from).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn story_engine_projector_creates_scene_chapter_and_world_mutation() {
let previous_state = json!({
"worldType": "WUXIA",
"currentScene": "Story",
"storyHistory": [],
"quests": [],
"currentScenePreset": {
"id": "scene-bridge",
"name": "断桥口",
"description": "风从桥下吹上来。",
"npcs": [{
"id": "npc-guide",
"name": "沈七",
"hostile": false,
"description": "腰间挂着药囊的行商"
}]
},
"storyEngineMemory": {
"activeThreadIds": ["thread-bridge"]
}
});
let mut next_state = previous_state.clone();
project_story_engine_after_action(
&previous_state,
&mut next_state,
"观察周围迹象",
"你读出桥边留下的新痕。",
"idle_observe_signs",
None,
);
assert_eq!(
next_state["chapterState"]["id"],
json!("chapter:scene:scene-bridge")
);
assert_eq!(
next_state["storyEngineMemory"]["currentChapter"]["stage"],
json!("opening")
);
assert_eq!(
next_state["quests"][0]["chapterId"],
json!("chapter:scene:scene-bridge")
);
assert!(
next_state["currentScenePreset"]["mutationStateText"]
.as_str()
.is_some_and(|text| text.contains("断桥口"))
);
assert!(
next_state["storyEngineMemory"]["worldMutations"]
.as_array()
.is_some_and(|items| !items.is_empty())
);
}
#[test]
fn story_engine_projector_records_battle_pressure_mutation() {
let previous_state = json!({
"worldType": "WUXIA",
"currentScene": "Story",
"inBattle": true,
"quests": [],
"playerInventory": [],
"currentScenePreset": {
"id": "scene-bridge",
"name": "断桥口",
"description": "风从桥下吹上来。"
},
"storyEngineMemory": {
"activeThreadIds": []
}
});
let mut next_state = previous_state.clone();
next_state["inBattle"] = Value::Bool(false);
project_story_engine_after_action(
&previous_state,
&mut next_state,
"普通攻击",
"敌人倒下。",
"battle_attack_basic",
Some("victory"),
);
assert_eq!(
next_state["currentScenePreset"]["currentPressureLevel"],
json!("medium")
);
assert!(
next_state["storyEngineMemory"]["worldMutations"]
.as_array()
.unwrap()
.iter()
.any(|mutation| mutation["mutationType"] == json!("enemy_pressure"))
);
}
#[test]
fn story_engine_projector_does_not_record_defeat_as_battle_win() {
let previous_state = json!({
"worldType": "CUSTOM",
"currentScene": "Story",
"inBattle": true,
"quests": [],
"playerInventory": [],
"currentScenePreset": {
"id": "custom-scene-camp",
"name": "回潮营地",
"description": "潮雾暂时压住脚步。"
},
"storyEngineMemory": {
"activeThreadIds": []
}
});
let mut next_state = previous_state.clone();
next_state["inBattle"] = Value::Bool(false);
project_story_engine_after_action(
&previous_state,
&mut next_state,
"普通攻击",
"你在交锋中倒下,随后重新醒来。",
"battle_attack_basic",
Some("defeat"),
);
assert!(
next_state["storyEngineMemory"]["recentSignalIds"]
.as_array()
.is_none_or(|items| {
items
.iter()
.all(|signal| signal != "win_battle:custom-scene-camp")
})
);
assert!(
next_state["storyEngineMemory"]["worldMutations"]
.as_array()
.unwrap()
.iter()
.all(|mutation| mutation["mutationType"] != json!("enemy_pressure"))
);
}
#[test]
fn story_engine_projector_does_not_create_chapter_quest_on_npc_battle_entry() {
let previous_state = json!({
"worldType": "WUXIA",
"currentScene": "Story",
"storyHistory": [],
"quests": [],
"currentScenePreset": {
"id": "scene-bridge",
"name": "断桥口",
"description": "风从桥下吹上来。",
"npcs": [{
"id": "npc-guide",
"name": "沈七",
"hostile": false
}]
},
"currentEncounter": {
"kind": "npc",
"id": "npc-guide",
"npcName": "沈七"
},
"storyEngineMemory": {
"activeThreadIds": ["thread-bridge"]
}
});
let mut next_state = previous_state.clone();
next_state["inBattle"] = Value::Bool(true);
next_state["currentEncounter"] = Value::Null;
project_story_engine_after_action(
&previous_state,
&mut next_state,
"与沈七战斗",
"沈七已经进入战斗节奏。",
"npc_fight",
Some("ongoing"),
);
assert!(
next_state["quests"]
.as_array()
.is_some_and(|items| items.is_empty())
);
assert_eq!(next_state["chapterState"]["chapterQuestId"], Value::Null);
}
}