推进 server-rs DDD 分层与新接口接线
This commit is contained in:
323
server-rs/crates/module-runtime-story/src/core.rs
Normal file
323
server-rs/crates/module-runtime-story/src/core.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_kernel::format_rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// Runtime story compat 的纯 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())
|
||||
}
|
||||
Reference in New Issue
Block a user