Files
Genarrative/server-rs/crates/module-runtime-story-compat/src/story_engine.rs
2026-04-28 19:36:39 +08:00

1570 lines
56 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);
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 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"))
);
}
}