324 lines
10 KiB
Rust
324 lines
10 KiB
Rust
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<Value>) {
|
|
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<Value>) {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
normalize_required_string(read_field(value, key)?.as_str()?)
|
|
}
|
|
|
|
pub fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
|
|
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
|
|
}
|
|
|
|
pub fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
|
|
read_field(value, key).and_then(Value::as_bool)
|
|
}
|
|
|
|
pub fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
|
|
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<u32> {
|
|
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<String, Value> {
|
|
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<String> {
|
|
let trimmed = value.trim();
|
|
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
|
}
|
|
|
|
pub fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
|
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())
|
|
}
|