use serde_json::{Map, Value, json}; use shared_kernel::format_rfc3339; use time::OffsetDateTime; /// Runtime story 的纯 JSON 快照工具层。 /// /// 这里不允许引入 HTTP、AppState 或持久化依赖,保证后续 battle/forge/npc/quest /// 规则迁入独立 crate 时可以继续复用同一批状态读写函数。 pub fn clear_encounter_state(game_state: &mut Value) { clear_encounter_only(game_state); write_bool_field(game_state, "inBattle", false); write_bool_field(game_state, "npcInteractionActive", false); write_null_field(game_state, "currentNpcBattleMode"); } pub fn clear_encounter_only(game_state: &mut Value) { write_null_field(game_state, "currentEncounter"); let root = ensure_json_object(game_state); root.insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new())); } pub fn append_story_history(game_state: &mut Value, action_text: &str, result_text: &str) { let root = ensure_json_object(game_state); let story_history = root .entry("storyHistory".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !story_history.is_array() { *story_history = Value::Array(Vec::new()); } let entries = story_history .as_array_mut() .expect("storyHistory should be array"); entries.push(json!({ "text": action_text, "historyRole": "action", })); entries.push(json!({ "text": result_text, "historyRole": "result", })); } pub fn increment_runtime_stat(game_state: &mut Value, key: &str, delta: i32) { let root = ensure_json_object(game_state); let stats = root .entry("runtimeStats".to_string()) .or_insert_with(|| Value::Object(Map::new())); if !stats.is_object() { *stats = Value::Object(Map::new()); } let stats = stats .as_object_mut() .expect("runtimeStats should be object"); let previous = stats .get(key) .and_then(Value::as_i64) .and_then(|value| i32::try_from(value).ok()) .unwrap_or(0); stats.insert(key.to_string(), json!((previous + delta).max(0))); } pub fn add_player_currency(game_state: &mut Value, delta: i32) { let previous = read_i32_field(game_state, "playerCurrency").unwrap_or(0); write_i32_field( game_state, "playerCurrency", previous.saturating_add(delta.max(0)), ); } pub fn add_player_inventory_items(game_state: &mut Value, additions: Vec) { if additions.is_empty() { return; } let root = ensure_json_object(game_state); let inventory = root .entry("playerInventory".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !inventory.is_array() { *inventory = Value::Array(Vec::new()); } let items = inventory .as_array_mut() .expect("playerInventory should be array"); items.extend(additions); } pub fn grant_player_progression_experience(game_state: &mut Value, amount: i32, source: &str) { if amount <= 0 { return; } let root = ensure_json_object(game_state); let progression = root .entry("playerProgression".to_string()) .or_insert_with(|| Value::Object(Map::new())); if !progression.is_object() { *progression = Value::Object(Map::new()); } let progression = progression .as_object_mut() .expect("playerProgression should be object"); let previous_total_xp = progression .get("totalXp") .and_then(Value::as_i64) .and_then(|value| i32::try_from(value).ok()) .unwrap_or(0) .max(0); let next_total_xp = previous_total_xp.saturating_add(amount); let level = resolve_progression_level(next_total_xp); let current_level_xp = next_total_xp.saturating_sub(cumulative_xp_required(level)); let xp_to_next_level = if level >= MAX_PLAYER_LEVEL { 0 } else { xp_to_next_level_for(level) }; progression.insert("level".to_string(), json!(level)); progression.insert("currentLevelXp".to_string(), json!(current_level_xp.max(0))); progression.insert("totalXp".to_string(), json!(next_total_xp)); progression.insert("xpToNextLevel".to_string(), json!(xp_to_next_level.max(0))); progression.insert("pendingLevelUps".to_string(), json!(0)); progression.insert( "lastGrantedSource".to_string(), Value::String(source.to_string()), ); } pub const MAX_PLAYER_LEVEL: i32 = 20; pub fn xp_to_next_level_for(level: i32) -> i32 { if level >= MAX_PLAYER_LEVEL { 0 } else { let scale = (level - 1).max(0); 60 + 20 * scale + 8 * scale * scale } } pub fn cumulative_xp_required(level: i32) -> i32 { let mut total = 0; let capped_level = level.clamp(1, MAX_PLAYER_LEVEL); for current_level in 1..capped_level { total += xp_to_next_level_for(current_level); } total } pub fn resolve_progression_level(total_xp: i32) -> i32 { let normalized_total_xp = total_xp.max(0); let mut resolved_level = 1; for level in 2..=MAX_PLAYER_LEVEL { if normalized_total_xp < cumulative_xp_required(level) { break; } resolved_level = level; } resolved_level } pub fn append_active_build_buffs(game_state: &mut Value, additions: Vec) { if additions.is_empty() { return; } let root = ensure_json_object(game_state); let buffs = root .entry("activeBuildBuffs".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !buffs.is_array() { *buffs = Value::Array(Vec::new()); } buffs .as_array_mut() .expect("activeBuildBuffs should be array") .extend(additions); } pub fn remove_player_inventory_item(game_state: &mut Value, item_id: &str, quantity: i32) { if quantity <= 0 { return; } let root = ensure_json_object(game_state); let inventory = root .entry("playerInventory".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !inventory.is_array() { *inventory = Value::Array(Vec::new()); } let items = inventory .as_array_mut() .expect("playerInventory should be array"); let Some(index) = items .iter() .position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id)) else { return; }; let current_quantity = read_i32_field(&items[index], "quantity") .unwrap_or(0) .max(0); let next_quantity = current_quantity - quantity; if next_quantity <= 0 { items.remove(index); return; } if let Some(item) = items[index].as_object_mut() { item.insert("quantity".to_string(), json!(next_quantity)); } } pub fn write_current_encounter_i32_field(game_state: &mut Value, key: &str, value: i32) { let root = ensure_json_object(game_state); let Some(encounter) = root.get_mut("currentEncounter") else { return; }; if let Some(encounter) = encounter.as_object_mut() { encounter.insert(key.to_string(), json!(value)); } } pub fn write_first_hostile_npc_i32_field(game_state: &mut Value, key: &str, value: i32) { let root = ensure_json_object(game_state); let Some(hostiles) = root.get_mut("sceneHostileNpcs") else { return; }; let Some(first) = hostiles.as_array_mut().and_then(|items| items.first_mut()) else { return; }; if let Some(first) = first.as_object_mut() { first.insert(key.to_string(), json!(value)); } } pub fn first_hostile_npc_string_field(game_state: &Value, key: &str) -> Option { read_array_field(game_state, "sceneHostileNpcs") .first() .and_then(|target| read_optional_string_field(target, key)) } pub fn read_runtime_session_id(game_state: &Value) -> Option { read_optional_string_field(game_state, "runtimeSessionId") } pub fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { value.as_object()?.get(key) } pub fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { let field = read_field(value, key)?; field.is_object().then_some(field) } pub fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> { read_field(value, key) .and_then(Value::as_array) .map(|items| items.iter().collect()) .unwrap_or_default() } pub fn read_required_string_field(value: &Value, key: &str) -> Option { normalize_required_string(read_field(value, key)?.as_str()?) } pub fn read_optional_string_field(value: &Value, key: &str) -> Option { normalize_optional_string(read_field(value, key).and_then(Value::as_str)) } pub fn read_bool_field(value: &Value, key: &str) -> Option { read_field(value, key).and_then(Value::as_bool) } pub fn read_i32_field(value: &Value, key: &str) -> Option { read_field(value, key) .and_then(Value::as_i64) .and_then(|number| i32::try_from(number).ok()) } pub fn read_u32_field(value: &Value, key: &str) -> Option { read_field(value, key) .and_then(Value::as_u64) .and_then(|number| u32::try_from(number).ok()) } pub fn write_i32_field(value: &mut Value, key: &str, field_value: i32) { ensure_json_object(value).insert(key.to_string(), json!(field_value)); } pub fn write_u32_field(value: &mut Value, key: &str, field_value: u32) { ensure_json_object(value).insert(key.to_string(), json!(field_value)); } pub fn write_bool_field(value: &mut Value, key: &str, field_value: bool) { ensure_json_object(value).insert(key.to_string(), Value::Bool(field_value)); } pub fn write_string_field(value: &mut Value, key: &str, field_value: &str) { ensure_json_object(value).insert(key.to_string(), Value::String(field_value.to_string())); } pub fn write_null_field(value: &mut Value, key: &str) { ensure_json_object(value).insert(key.to_string(), Value::Null); } pub fn ensure_json_object(value: &mut Value) -> &mut Map { if !value.is_object() { *value = Value::Object(Map::new()); } value.as_object_mut().expect("value should be object") } pub fn normalize_required_string(value: &str) -> Option { let trimmed = value.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) } pub fn normalize_optional_string(value: Option<&str>) -> Option { value.and_then(normalize_required_string) } pub fn format_now_rfc3339() -> String { format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) }