refactor: extract runtime story npc support
This commit is contained in:
@@ -257,3 +257,18 @@ server-rs/crates/api-server/src/
|
|||||||
- `game_state.rs`:快照态读写与 NPC / inventory / equipment 状态桥
|
- `game_state.rs`:快照态读写与 NPC / inventory / equipment 状态桥
|
||||||
|
|
||||||
后续再继续迁 `trade / gift / companion` 时,目标就不再是单纯减少行数,而是把 compat bridge 逐步收束成“动作编排壳 + 多个纯规则模块”的明确结构。
|
后续再继续迁 `trade / gift / companion` 时,目标就不再是单纯减少行数,而是把 compat bridge 逐步收束成“动作编排壳 + 多个纯规则模块”的明确结构。
|
||||||
|
|
||||||
|
在此基础上,同日又继续把 NPC 交互侧的一批纯 helper 收到独立模块:
|
||||||
|
|
||||||
|
1. `compat` 新增 [npc_support.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/npc_support.rs)。
|
||||||
|
2. 已迁入的内容包括:
|
||||||
|
- 赠礼好感收益与赠礼结果文本
|
||||||
|
- 交易价格、折扣档位、货币文本、数量后缀
|
||||||
|
- 队伍招募与满员换队 helper
|
||||||
|
3. [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 现在对 `npc_trade / npc_gift / npc_recruit` 仍只保留动作编排,不再承担底层价格计算和队伍变换逻辑。
|
||||||
|
4. 到这一步,`compat.rs` 的主要职责已经更接近:
|
||||||
|
- route handler / snapshot bridge
|
||||||
|
- action orchestration
|
||||||
|
- 少量尚未迁出的共享 glue code
|
||||||
|
|
||||||
|
这为后续把“无 HTTP / 无 `AppState`”的剩余 glue code 再往下收,提供了更明确的拆分方向。
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
- [M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `server-rs` 侧 `M4` 首轮已落地的 `story_session / story_event` SpacetimeDB 基座、`begin_story_session / continue_story` reducer、同步返回快照的 story procedure、`spacetime-client` facade 与新的 `/api/story/sessions*` Axum 接口,以及当前尚未兼容旧 `runtime story` 路由的边界。
|
- [M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `server-rs` 侧 `M4` 首轮已落地的 `story_session / story_event` SpacetimeDB 基座、`begin_story_session / continue_story` reducer、同步返回快照的 story procedure、`spacetime-client` facade 与新的 `/api/story/sessions*` Axum 接口,以及当前尚未兼容旧 `runtime story` 路由的边界。
|
||||||
- [M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md](./M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md):冻结 `GET /api/story/sessions/:storySessionId/state` 这条最小 story state 查询切片,明确当前只返回 `storySession + storyEvents`,不等价于旧 `runtime story state` 兼容完成。
|
- [M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md](./M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md):冻结 `GET /api/story/sessions/:storySessionId/state` 这条最小 story state 查询切片,明确当前只返回 `storySession + storyEvents`,不等价于旧 `runtime story state` 兼容完成。
|
||||||
- [M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md](./M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md):冻结旧 `POST /api/runtime/story/state/resolve` 兼容桥的首版边界,明确先补 `RuntimeStoryActionResponse` DTO 与状态桥,再继续进入 Rust `actions/resolve` 与正式 snapshot projection。
|
- [M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md](./M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md):冻结旧 `POST /api/runtime/story/state/resolve` 兼容桥的首版边界,明确先补 `RuntimeStoryActionResponse` DTO 与状态桥,再继续进入 Rust `actions/resolve` 与正式 snapshot projection。
|
||||||
- [M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md](./M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md):冻结 `runtime_story.rs` 从超大单文件拆到 `compat/ai/presentation/tests/battle/core/game_state/forge` 子模块的收口策略、验证要求与下一阶段纯规则下沉边界。
|
- [M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md](./M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md):冻结 `runtime_story.rs` 从超大单文件拆到 `compat/ai/presentation/tests/battle/core/game_state/forge/npc_support` 子模块的收口策略、验证要求与下一阶段纯规则下沉边界。
|
||||||
- [M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](./M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md):冻结 `module-ai` 首版的任务/阶段/流式片段/结果引用领域模型、最小内存服务与后续 `platform-llm` / `api-server` / `spacetime-module` 的边界。
|
- [M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](./M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md):冻结 `module-ai` 首版的任务/阶段/流式片段/结果引用领域模型、最小内存服务与后续 `platform-llm` / `api-server` / `spacetime-module` 的边界。
|
||||||
- [M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `module-ai` 在 `spacetime-module` 中首轮已落地的 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 真相表、最小 reducer/procedure 与当前仍未扩到真实模型调用和 Axum facade 的边界。
|
- [M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `module-ai` 在 `spacetime-module` 中首轮已落地的 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 真相表、最小 reducer/procedure 与当前仍未扩到真实模型调用和 Axum facade 的边界。
|
||||||
- [M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](./M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md):冻结 `module-ai` 从 `shared-contracts`、`spacetime-client` 到 `api-server` 的最小 AI task mutation facade,明确 `start` 路由当前只返回 `202 Accepted`。
|
- [M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](./M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md):冻结 `module-ai` 从 `shared-contracts`、`spacetime-client` 到 `api-server` 的最小 AI task mutation facade,明确 `start` 路由当前只返回 `202 Accepted`。
|
||||||
|
|||||||
@@ -38,10 +38,12 @@ mod core;
|
|||||||
mod forge;
|
mod forge;
|
||||||
#[path = "compat/game_state.rs"]
|
#[path = "compat/game_state.rs"]
|
||||||
mod game_state;
|
mod game_state;
|
||||||
|
#[path = "compat/npc_support.rs"]
|
||||||
|
mod npc_support;
|
||||||
#[path = "compat/presentation.rs"]
|
#[path = "compat/presentation.rs"]
|
||||||
mod presentation;
|
mod presentation;
|
||||||
|
|
||||||
use self::{ai::*, battle::*, core::*, forge::*, game_state::*, presentation::*};
|
use self::{ai::*, battle::*, core::*, forge::*, game_state::*, npc_support::*, presentation::*};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "compat/tests.rs"]
|
#[path = "compat/tests.rs"]
|
||||||
@@ -1748,234 +1750,6 @@ fn current_world_type(game_state: &Value) -> Option<String> {
|
|||||||
read_optional_string_field(game_state, "worldType")
|
read_optional_string_field(game_state, "worldType")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_npc_gift_affinity_gain(item: &Value) -> i32 {
|
|
||||||
let rarity_score = match item_rarity_key(item).as_str() {
|
|
||||||
"legendary" => 5,
|
|
||||||
"epic" => 4,
|
|
||||||
"rare" => 3,
|
|
||||||
"uncommon" => 2,
|
|
||||||
_ => 1,
|
|
||||||
};
|
|
||||||
let tags = read_array_field(item, "tags")
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mana_bonus = if tags.iter().any(|tag| tag == "mana") {
|
|
||||||
3
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
let healing_bonus = if tags.iter().any(|tag| tag == "healing") {
|
|
||||||
3
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
(4 + rarity_score * 3 + mana_bonus + healing_bonus).min(24)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_npc_gift_result_text(
|
|
||||||
npc_name: &str,
|
|
||||||
item: &Value,
|
|
||||||
affinity_gain: i32,
|
|
||||||
next_affinity: i32,
|
|
||||||
) -> String {
|
|
||||||
let shift_text = if affinity_gain >= 12 {
|
|
||||||
"态度一下子软化了许多"
|
|
||||||
} else if affinity_gain >= 8 {
|
|
||||||
"态度明显和缓下来"
|
|
||||||
} else if affinity_gain >= 5 {
|
|
||||||
"态度比先前亲近了一些"
|
|
||||||
} else {
|
|
||||||
"态度略微放松了些"
|
|
||||||
};
|
|
||||||
let affinity_text = if next_affinity >= 90 {
|
|
||||||
"对你高度信赖,言谈间明显亲近,几乎已经把你当成自己人。"
|
|
||||||
} else if next_affinity >= 60 {
|
|
||||||
"对你已经建立起稳固信任,愿意进一步合作。"
|
|
||||||
} else if next_affinity >= 30 {
|
|
||||||
"对你的态度明显友善了许多,也更愿意正常交流。"
|
|
||||||
} else if next_affinity >= 15 {
|
|
||||||
"戒备开始松动,愿意试探性地配合你的节奏。"
|
|
||||||
} else if next_affinity >= 0 {
|
|
||||||
"仍保持明显距离,只会给出谨慎而有限的回应。"
|
|
||||||
} else {
|
|
||||||
"关系已经降到冰点,对你几乎不再保留善意。"
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
"{}收下了{},{}。{}",
|
|
||||||
npc_name,
|
|
||||||
read_inventory_item_name(item),
|
|
||||||
shift_text,
|
|
||||||
affinity_text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inventory_item_value(item: &Value) -> i32 {
|
|
||||||
if let Some(explicit_value) = read_i32_field(item, "value") {
|
|
||||||
return explicit_value.max(8);
|
|
||||||
}
|
|
||||||
let rarity_base = match item_rarity_key(item).as_str() {
|
|
||||||
"legendary" => 168,
|
|
||||||
"epic" => 92,
|
|
||||||
"rare" => 48,
|
|
||||||
"uncommon" => 24,
|
|
||||||
_ => 12,
|
|
||||||
};
|
|
||||||
let category = read_optional_string_field(item, "category").unwrap_or_default();
|
|
||||||
let tags = read_array_field(item, "tags")
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mut value = rarity_base;
|
|
||||||
if tags.iter().any(|tag| tag == "weapon") {
|
|
||||||
value += 14;
|
|
||||||
}
|
|
||||||
if tags.iter().any(|tag| tag == "armor") {
|
|
||||||
value += 12;
|
|
||||||
}
|
|
||||||
if tags.iter().any(|tag| tag == "relic") {
|
|
||||||
value += 16;
|
|
||||||
}
|
|
||||||
if tags.iter().any(|tag| tag == "mana") {
|
|
||||||
value += 8;
|
|
||||||
}
|
|
||||||
if tags.iter().any(|tag| tag == "healing") {
|
|
||||||
value += 8;
|
|
||||||
}
|
|
||||||
if tags.iter().any(|tag| tag == "material") {
|
|
||||||
value += 4;
|
|
||||||
}
|
|
||||||
if category.contains("专属") {
|
|
||||||
value += 10;
|
|
||||||
}
|
|
||||||
value.max(8)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn discount_tier_for_affinity(affinity: i32) -> i32 {
|
|
||||||
if affinity >= 90 {
|
|
||||||
3
|
|
||||||
} else if affinity >= 60 {
|
|
||||||
2
|
|
||||||
} else if affinity >= 30 {
|
|
||||||
1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn npc_purchase_price(item: &Value, affinity: i32) -> i32 {
|
|
||||||
let discount_multiplier = 1.0 - f64::from(discount_tier_for_affinity(affinity)) * 0.08;
|
|
||||||
(f64::from(inventory_item_value(item)) * discount_multiplier)
|
|
||||||
.round()
|
|
||||||
.max(6.0) as i32
|
|
||||||
}
|
|
||||||
|
|
||||||
fn npc_buyback_price(item: &Value, affinity: i32) -> i32 {
|
|
||||||
let buyback_multiplier = 0.4 + f64::from(discount_tier_for_affinity(affinity)) * 0.06;
|
|
||||||
(f64::from(inventory_item_value(item)) * buyback_multiplier)
|
|
||||||
.round()
|
|
||||||
.max(4.0) as i32
|
|
||||||
}
|
|
||||||
|
|
||||||
fn trade_quantity_suffix(quantity: i32) -> String {
|
|
||||||
if quantity > 1 {
|
|
||||||
format!(" x{quantity}")
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_currency_text(value: i32, world_type: Option<&str>) -> String {
|
|
||||||
let currency_name = match world_type {
|
|
||||||
Some("XIANXIA") => "灵石",
|
|
||||||
Some("WUXIA") => "铜钱",
|
|
||||||
_ => "钱币",
|
|
||||||
};
|
|
||||||
format!("{value} {currency_name}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_companion_if_absent(
|
|
||||||
game_state: &mut Value,
|
|
||||||
npc_id: &str,
|
|
||||||
character_id: Option<String>,
|
|
||||||
joined_at_affinity: i32,
|
|
||||||
) {
|
|
||||||
let root = ensure_json_object(game_state);
|
|
||||||
let companions = root
|
|
||||||
.entry("companions".to_string())
|
|
||||||
.or_insert_with(|| Value::Array(Vec::new()));
|
|
||||||
if !companions.is_array() {
|
|
||||||
*companions = Value::Array(Vec::new());
|
|
||||||
}
|
|
||||||
let items = companions
|
|
||||||
.as_array_mut()
|
|
||||||
.expect("companions should be array");
|
|
||||||
if items
|
|
||||||
.iter()
|
|
||||||
.any(|item| read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
items.push(json!({
|
|
||||||
"npcId": npc_id,
|
|
||||||
"characterId": character_id,
|
|
||||||
"joinedAtAffinity": joined_at_affinity,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_companion_by_npc_id(game_state: &mut Value, npc_id: &str) -> Option<Value> {
|
|
||||||
let root = ensure_json_object(game_state);
|
|
||||||
let companions = root
|
|
||||||
.entry("companions".to_string())
|
|
||||||
.or_insert_with(|| Value::Array(Vec::new()));
|
|
||||||
if !companions.is_array() {
|
|
||||||
*companions = Value::Array(Vec::new());
|
|
||||||
}
|
|
||||||
let items = companions
|
|
||||||
.as_array_mut()
|
|
||||||
.expect("companions should be array");
|
|
||||||
let index = items.iter().position(|item| {
|
|
||||||
read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id)
|
|
||||||
})?;
|
|
||||||
Some(items.remove(index))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recruit_companion_to_party(
|
|
||||||
game_state: &mut Value,
|
|
||||||
npc_id: &str,
|
|
||||||
_npc_name: &str,
|
|
||||||
release_npc_id: Option<&str>,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
let companion_count = read_array_field(game_state, "companions").len();
|
|
||||||
if companion_count < MAX_TASK5_COMPANIONS {
|
|
||||||
add_companion_if_absent(
|
|
||||||
game_state,
|
|
||||||
npc_id,
|
|
||||||
None,
|
|
||||||
read_current_npc_affinity(game_state),
|
|
||||||
);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(release_npc_id) = release_npc_id.and_then(normalize_required_string) else {
|
|
||||||
return Err("队伍已满时必须明确指定一名离队同伴".to_string());
|
|
||||||
};
|
|
||||||
|
|
||||||
let released_companion = remove_companion_by_npc_id(game_state, release_npc_id.as_str())
|
|
||||||
.ok_or_else(|| "指定的离队同伴不存在,无法完成换队招募".to_string())?;
|
|
||||||
let released_name = read_optional_string_field(&released_companion, "displayName")
|
|
||||||
.or_else(|| read_optional_string_field(&released_companion, "name"))
|
|
||||||
.or_else(|| read_optional_string_field(&released_companion, "npcName"))
|
|
||||||
.unwrap_or_else(|| release_npc_id.clone());
|
|
||||||
add_companion_if_absent(
|
|
||||||
game_state,
|
|
||||||
npc_id,
|
|
||||||
None,
|
|
||||||
read_current_npc_affinity(game_state),
|
|
||||||
);
|
|
||||||
Ok(Some(released_name))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError {
|
fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError {
|
||||||
let (status, provider) = match error {
|
let (status, provider) = match error {
|
||||||
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"),
|
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"),
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn resolve_npc_gift_affinity_gain(item: &Value) -> i32 {
|
||||||
|
let rarity_score = match item_rarity_key(item).as_str() {
|
||||||
|
"legendary" => 5,
|
||||||
|
"epic" => 4,
|
||||||
|
"rare" => 3,
|
||||||
|
"uncommon" => 2,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
let tags = read_array_field(item, "tags")
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mana_bonus = if tags.iter().any(|tag| tag == "mana") {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let healing_bonus = if tags.iter().any(|tag| tag == "healing") {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
(4 + rarity_score * 3 + mana_bonus + healing_bonus).min(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_npc_gift_result_text(
|
||||||
|
npc_name: &str,
|
||||||
|
item: &Value,
|
||||||
|
affinity_gain: i32,
|
||||||
|
next_affinity: i32,
|
||||||
|
) -> String {
|
||||||
|
let shift_text = if affinity_gain >= 12 {
|
||||||
|
"态度一下子软化了许多"
|
||||||
|
} else if affinity_gain >= 8 {
|
||||||
|
"态度明显和缓下来"
|
||||||
|
} else if affinity_gain >= 5 {
|
||||||
|
"态度比先前亲近了一些"
|
||||||
|
} else {
|
||||||
|
"态度略微放松了些"
|
||||||
|
};
|
||||||
|
let affinity_text = if next_affinity >= 90 {
|
||||||
|
"对你高度信赖,言谈间明显亲近,几乎已经把你当成自己人。"
|
||||||
|
} else if next_affinity >= 60 {
|
||||||
|
"对你已经建立起稳固信任,愿意进一步合作。"
|
||||||
|
} else if next_affinity >= 30 {
|
||||||
|
"对你的态度明显友善了许多,也更愿意正常交流。"
|
||||||
|
} else if next_affinity >= 15 {
|
||||||
|
"戒备开始松动,愿意试探性地配合你的节奏。"
|
||||||
|
} else if next_affinity >= 0 {
|
||||||
|
"仍保持明显距离,只会给出谨慎而有限的回应。"
|
||||||
|
} else {
|
||||||
|
"关系已经降到冰点,对你几乎不再保留善意。"
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{}收下了{},{}。{}",
|
||||||
|
npc_name,
|
||||||
|
read_inventory_item_name(item),
|
||||||
|
shift_text,
|
||||||
|
affinity_text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inventory_item_value(item: &Value) -> i32 {
|
||||||
|
if let Some(explicit_value) = read_i32_field(item, "value") {
|
||||||
|
return explicit_value.max(8);
|
||||||
|
}
|
||||||
|
let rarity_base = match item_rarity_key(item).as_str() {
|
||||||
|
"legendary" => 168,
|
||||||
|
"epic" => 92,
|
||||||
|
"rare" => 48,
|
||||||
|
"uncommon" => 24,
|
||||||
|
_ => 12,
|
||||||
|
};
|
||||||
|
let category = read_optional_string_field(item, "category").unwrap_or_default();
|
||||||
|
let tags = read_array_field(item, "tags")
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut value = rarity_base;
|
||||||
|
if tags.iter().any(|tag| tag == "weapon") {
|
||||||
|
value += 14;
|
||||||
|
}
|
||||||
|
if tags.iter().any(|tag| tag == "armor") {
|
||||||
|
value += 12;
|
||||||
|
}
|
||||||
|
if tags.iter().any(|tag| tag == "relic") {
|
||||||
|
value += 16;
|
||||||
|
}
|
||||||
|
if tags.iter().any(|tag| tag == "mana") {
|
||||||
|
value += 8;
|
||||||
|
}
|
||||||
|
if tags.iter().any(|tag| tag == "healing") {
|
||||||
|
value += 8;
|
||||||
|
}
|
||||||
|
if tags.iter().any(|tag| tag == "material") {
|
||||||
|
value += 4;
|
||||||
|
}
|
||||||
|
if category.contains("专属") {
|
||||||
|
value += 10;
|
||||||
|
}
|
||||||
|
value.max(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discount_tier_for_affinity(affinity: i32) -> i32 {
|
||||||
|
if affinity >= 90 {
|
||||||
|
3
|
||||||
|
} else if affinity >= 60 {
|
||||||
|
2
|
||||||
|
} else if affinity >= 30 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn npc_purchase_price(item: &Value, affinity: i32) -> i32 {
|
||||||
|
let discount_multiplier = 1.0 - f64::from(discount_tier_for_affinity(affinity)) * 0.08;
|
||||||
|
(f64::from(inventory_item_value(item)) * discount_multiplier)
|
||||||
|
.round()
|
||||||
|
.max(6.0) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn npc_buyback_price(item: &Value, affinity: i32) -> i32 {
|
||||||
|
let buyback_multiplier = 0.4 + f64::from(discount_tier_for_affinity(affinity)) * 0.06;
|
||||||
|
(f64::from(inventory_item_value(item)) * buyback_multiplier)
|
||||||
|
.round()
|
||||||
|
.max(4.0) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn trade_quantity_suffix(quantity: i32) -> String {
|
||||||
|
if quantity > 1 {
|
||||||
|
format!(" x{quantity}")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn format_currency_text(value: i32, world_type: Option<&str>) -> String {
|
||||||
|
let currency_name = match world_type {
|
||||||
|
Some("XIANXIA") => "灵石",
|
||||||
|
Some("WUXIA") => "铜钱",
|
||||||
|
_ => "钱币",
|
||||||
|
};
|
||||||
|
format!("{value} {currency_name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_companion_if_absent(
|
||||||
|
game_state: &mut Value,
|
||||||
|
npc_id: &str,
|
||||||
|
character_id: Option<String>,
|
||||||
|
joined_at_affinity: i32,
|
||||||
|
) {
|
||||||
|
let root = ensure_json_object(game_state);
|
||||||
|
let companions = root
|
||||||
|
.entry("companions".to_string())
|
||||||
|
.or_insert_with(|| Value::Array(Vec::new()));
|
||||||
|
if !companions.is_array() {
|
||||||
|
*companions = Value::Array(Vec::new());
|
||||||
|
}
|
||||||
|
let items = companions
|
||||||
|
.as_array_mut()
|
||||||
|
.expect("companions should be array");
|
||||||
|
if items
|
||||||
|
.iter()
|
||||||
|
.any(|item| read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.push(json!({
|
||||||
|
"npcId": npc_id,
|
||||||
|
"characterId": character_id,
|
||||||
|
"joinedAtAffinity": joined_at_affinity,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_companion_by_npc_id(game_state: &mut Value, npc_id: &str) -> Option<Value> {
|
||||||
|
let root = ensure_json_object(game_state);
|
||||||
|
let companions = root
|
||||||
|
.entry("companions".to_string())
|
||||||
|
.or_insert_with(|| Value::Array(Vec::new()));
|
||||||
|
if !companions.is_array() {
|
||||||
|
*companions = Value::Array(Vec::new());
|
||||||
|
}
|
||||||
|
let items = companions
|
||||||
|
.as_array_mut()
|
||||||
|
.expect("companions should be array");
|
||||||
|
let index = items.iter().position(|item| {
|
||||||
|
read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id)
|
||||||
|
})?;
|
||||||
|
Some(items.remove(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// compat bridge 先只维护一个轻量队伍名单,继续复用旧前端的满员换队语义。
|
||||||
|
pub(super) fn recruit_companion_to_party(
|
||||||
|
game_state: &mut Value,
|
||||||
|
npc_id: &str,
|
||||||
|
_npc_name: &str,
|
||||||
|
release_npc_id: Option<&str>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let companion_count = read_array_field(game_state, "companions").len();
|
||||||
|
if companion_count < MAX_TASK5_COMPANIONS {
|
||||||
|
add_companion_if_absent(
|
||||||
|
game_state,
|
||||||
|
npc_id,
|
||||||
|
None,
|
||||||
|
read_current_npc_affinity(game_state),
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(release_npc_id) = release_npc_id.and_then(normalize_required_string) else {
|
||||||
|
return Err("队伍已满时必须明确指定一名离队同伴".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
let released_companion = remove_companion_by_npc_id(game_state, release_npc_id.as_str())
|
||||||
|
.ok_or_else(|| "指定的离队同伴不存在,无法完成换队招募".to_string())?;
|
||||||
|
let released_name = read_optional_string_field(&released_companion, "displayName")
|
||||||
|
.or_else(|| read_optional_string_field(&released_companion, "name"))
|
||||||
|
.or_else(|| read_optional_string_field(&released_companion, "npcName"))
|
||||||
|
.unwrap_or_else(|| release_npc_id.clone());
|
||||||
|
add_companion_if_absent(
|
||||||
|
game_state,
|
||||||
|
npc_id,
|
||||||
|
None,
|
||||||
|
read_current_npc_affinity(game_state),
|
||||||
|
);
|
||||||
|
Ok(Some(released_name))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user