1102 lines
43 KiB
Rust
1102 lines
43 KiB
Rust
use super::*;
|
||
|
||
const PLAYER_BASE_MAX_HP: i32 = 180;
|
||
const DEFAULT_PLAYER_MAX_MANA: i32 = 999;
|
||
pub(super) const RESOLVED_ENTITY_X_METERS: f64 = 12.0;
|
||
|
||
pub async fn begin_runtime_story_session(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<RuntimeStoryBootstrapRequest>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||
let now = OffsetDateTime::now_utc();
|
||
let now_micros = offset_datetime_to_unix_micros(now);
|
||
let session_id = build_runtime_session_id(
|
||
actor_user_id.as_str(),
|
||
payload.custom_world_profile.as_ref(),
|
||
&payload.character,
|
||
now_micros,
|
||
);
|
||
let game_state =
|
||
build_initial_runtime_game_state(&payload, session_id.as_str()).map_err(|message| {
|
||
runtime_story_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "runtime-story",
|
||
"message": message,
|
||
})),
|
||
)
|
||
})?;
|
||
let snapshot = RuntimeStorySnapshotPayload {
|
||
saved_at: Some(format_now_rfc3339()),
|
||
bottom_tab: "adventure".to_string(),
|
||
game_state,
|
||
current_story: None,
|
||
};
|
||
let persisted =
|
||
persist_runtime_story_snapshot(&state, &request_context, actor_user_id, snapshot).await?;
|
||
let persisted_snapshot = runtime_snapshot_payload_from_record(&persisted);
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
RuntimeStoryBootstrapResponse {
|
||
session_id,
|
||
server_version: 1,
|
||
snapshot: persisted_snapshot,
|
||
},
|
||
))
|
||
}
|
||
|
||
fn build_runtime_session_id(
|
||
actor_user_id: &str,
|
||
custom_world_profile: Option<&Value>,
|
||
character: &Value,
|
||
now_micros: i64,
|
||
) -> String {
|
||
let profile_id = custom_world_profile
|
||
.and_then(|profile| read_optional_string_field(profile, "id"))
|
||
.or_else(|| {
|
||
custom_world_profile.and_then(|profile| read_optional_string_field(profile, "name"))
|
||
})
|
||
.unwrap_or_else(|| "builtin".to_string());
|
||
let character_id = read_optional_string_field(character, "id")
|
||
.or_else(|| read_optional_string_field(character, "name"))
|
||
.unwrap_or_else(|| "character".to_string());
|
||
|
||
format!(
|
||
"runtime-{}-{}-{}-{now_micros}",
|
||
sanitize_id_segment(actor_user_id),
|
||
sanitize_id_segment(profile_id.as_str()),
|
||
sanitize_id_segment(character_id.as_str())
|
||
)
|
||
}
|
||
|
||
fn sanitize_id_segment(value: &str) -> String {
|
||
let normalized = value
|
||
.trim()
|
||
.chars()
|
||
.filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_')
|
||
.take(36)
|
||
.collect::<String>();
|
||
if normalized.is_empty() {
|
||
"unknown".to_string()
|
||
} else {
|
||
normalized
|
||
}
|
||
}
|
||
|
||
fn build_initial_runtime_game_state(
|
||
payload: &RuntimeStoryBootstrapRequest,
|
||
session_id: &str,
|
||
) -> Result<Value, String> {
|
||
let world_type = normalize_required_string(payload.world_type.as_str())
|
||
.ok_or_else(|| "worldType 不能为空".to_string())?;
|
||
if world_type == "CUSTOM" && payload.custom_world_profile.is_none() {
|
||
return Err("自定义世界开局必须提供 customWorldProfile".to_string());
|
||
}
|
||
if !payload.character.is_object() {
|
||
return Err("character 必须是 JSON object".to_string());
|
||
}
|
||
|
||
let runtime_mode = normalize_runtime_mode(payload.runtime_mode.as_deref());
|
||
let custom_world_profile = payload.custom_world_profile.clone().unwrap_or(Value::Null);
|
||
let character = payload.character.clone();
|
||
let initial_scene_preset =
|
||
resolve_initial_scene_preset(world_type.as_str(), payload.custom_world_profile.as_ref());
|
||
let initial_encounter = resolve_initial_encounter(
|
||
world_type.as_str(),
|
||
payload.custom_world_profile.as_ref(),
|
||
&character,
|
||
initial_scene_preset.as_ref(),
|
||
);
|
||
let initial_npc_state = initial_encounter
|
||
.as_ref()
|
||
.map(build_initial_npc_state_value)
|
||
.unwrap_or(Value::Null);
|
||
let player_max_hp = resolve_character_max_hp(&character);
|
||
let player_max_mana = resolve_character_max_mana(&character);
|
||
let initial_inventory = build_initial_player_inventory(
|
||
world_type.as_str(),
|
||
payload.custom_world_profile.as_ref(),
|
||
&character,
|
||
);
|
||
let initial_equipment = build_initial_player_equipment(
|
||
world_type.as_str(),
|
||
payload.custom_world_profile.as_ref(),
|
||
&character,
|
||
&initial_inventory,
|
||
);
|
||
let equipment_bonuses = read_equipment_total_bonuses(&initial_equipment);
|
||
let player_max_hp_with_equipment = player_max_hp + equipment_bonuses.max_hp_bonus;
|
||
let story_engine_memory = build_opening_story_engine_memory(
|
||
payload.custom_world_profile.as_ref(),
|
||
&initial_scene_preset,
|
||
);
|
||
|
||
let mut npc_states = Map::new();
|
||
if let (Some(encounter), Value::Object(npc_state)) = (&initial_encounter, initial_npc_state) {
|
||
let npc_id = read_optional_string_field(encounter, "id")
|
||
.unwrap_or_else(|| current_encounter_name(encounter));
|
||
npc_states.insert(npc_id, Value::Object(npc_state));
|
||
}
|
||
|
||
let mut game_state = json!({
|
||
"worldType": world_type,
|
||
"customWorldProfile": custom_world_profile,
|
||
"playerCharacter": character,
|
||
"runtimeSessionId": session_id,
|
||
"runtimeActionVersion": 1,
|
||
"runtimeMode": runtime_mode,
|
||
"runtimePersistenceDisabled": payload.disable_persistence.unwrap_or(false),
|
||
"runtimeStats": {
|
||
"playTimeMs": 0,
|
||
"lastPlayTickAt": Value::Null,
|
||
"hostileNpcsDefeated": 0,
|
||
"questsAccepted": 0,
|
||
"itemsUsed": 0,
|
||
"scenesTraveled": 0
|
||
},
|
||
"playerProgression": {
|
||
"level": 1,
|
||
"currentLevelXp": 0,
|
||
"totalXp": 0,
|
||
"xpToNextLevel": 100,
|
||
"pendingLevelUps": 0,
|
||
"lastGrantedSource": Value::Null
|
||
},
|
||
"currentScene": "Story",
|
||
"storyHistory": [],
|
||
"storyEngineMemory": story_engine_memory,
|
||
"chapterState": Value::Null,
|
||
"campaignState": Value::Null,
|
||
"activeScenarioPackId": payload.custom_world_profile.as_ref().and_then(|profile| read_optional_string_field(profile, "scenarioPackId")),
|
||
"activeCampaignPackId": payload.custom_world_profile.as_ref().and_then(|profile| read_optional_string_field(profile, "campaignPackId")),
|
||
"characterChats": {},
|
||
"lastObserveSignsSceneId": Value::Null,
|
||
"lastObserveSignsReport": Value::Null,
|
||
"animationState": "idle",
|
||
"currentEncounter": initial_encounter,
|
||
"npcInteractionActive": false,
|
||
"currentScenePreset": initial_scene_preset,
|
||
"sceneHostileNpcs": [],
|
||
"playerX": 0,
|
||
"playerOffsetY": 0,
|
||
"playerFacing": "right",
|
||
"playerActionMode": "idle",
|
||
"scrollWorld": false,
|
||
"inBattle": false,
|
||
"playerHp": player_max_hp_with_equipment,
|
||
"playerMaxHp": player_max_hp_with_equipment,
|
||
"playerMana": player_max_mana,
|
||
"playerMaxMana": player_max_mana,
|
||
"playerSkillCooldowns": {},
|
||
"activeBuildBuffs": [],
|
||
"activeCombatEffects": [],
|
||
"playerCurrency": resolve_initial_player_currency(world_type.as_str(), payload.custom_world_profile.as_ref()),
|
||
"playerInventory": initial_inventory,
|
||
"playerEquipment": initial_equipment,
|
||
"npcStates": npc_states,
|
||
"quests": [],
|
||
"roster": [],
|
||
"companions": [],
|
||
"currentBattleNpcId": Value::Null,
|
||
"currentNpcBattleMode": Value::Null,
|
||
"currentNpcBattleOutcome": Value::Null,
|
||
"sparReturnEncounter": Value::Null,
|
||
"sparPlayerHpBefore": Value::Null,
|
||
"sparPlayerMaxHpBefore": Value::Null,
|
||
"sparStoryHistoryBefore": Value::Null
|
||
});
|
||
ensure_json_object(&mut game_state).insert(
|
||
"playerSkillCooldowns".to_string(),
|
||
build_character_skill_cooldowns(&payload.character),
|
||
);
|
||
Ok(game_state)
|
||
}
|
||
|
||
fn normalize_runtime_mode(value: Option<&str>) -> &'static str {
|
||
match value.map(str::trim) {
|
||
Some("preview") => "preview",
|
||
Some("test") => "test",
|
||
_ => "play",
|
||
}
|
||
}
|
||
|
||
fn resolve_initial_scene_preset(world_type: &str, profile: Option<&Value>) -> Option<Value> {
|
||
if world_type == "CUSTOM" {
|
||
let profile = profile?;
|
||
let scene_id =
|
||
resolve_opening_scene_id(profile).unwrap_or_else(|| "custom-scene-camp".to_string());
|
||
return build_custom_scene_preset(profile, scene_id.as_str());
|
||
}
|
||
|
||
Some(build_builtin_camp_scene_preset(world_type))
|
||
}
|
||
|
||
fn resolve_opening_scene_id(profile: &Value) -> Option<String> {
|
||
let opening_chapter = read_array_field(profile, "sceneChapterBlueprints")
|
||
.into_iter()
|
||
.next()?;
|
||
let opening_act = read_array_field(opening_chapter, "acts").into_iter().next();
|
||
[
|
||
opening_act.and_then(|act| read_optional_string_field(act, "sceneId")),
|
||
read_optional_string_field(opening_chapter, "sceneId"),
|
||
read_array_field(opening_chapter, "linkedLandmarkIds")
|
||
.into_iter()
|
||
.find_map(Value::as_str)
|
||
.map(str::to_string),
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
.map(|scene_id| resolve_custom_runtime_scene_id(profile, scene_id.as_str()))
|
||
.find(|scene_id| !scene_id.trim().is_empty())
|
||
}
|
||
|
||
pub(super) fn resolve_custom_runtime_scene_id(profile: &Value, scene_id: &str) -> String {
|
||
let normalized = scene_id.trim();
|
||
if normalized.is_empty()
|
||
|| normalized == "custom-scene-camp"
|
||
|| read_object_field(profile, "camp")
|
||
.and_then(|camp| read_optional_string_field(camp, "id"))
|
||
.as_deref()
|
||
== Some(normalized)
|
||
{
|
||
return "custom-scene-camp".to_string();
|
||
}
|
||
|
||
for (index, landmark) in read_array_field(profile, "landmarks")
|
||
.into_iter()
|
||
.enumerate()
|
||
{
|
||
if read_optional_string_field(landmark, "id").as_deref() == Some(normalized) {
|
||
return format!("custom-scene-landmark-{}", index + 1);
|
||
}
|
||
}
|
||
|
||
normalized.to_string()
|
||
}
|
||
|
||
fn build_builtin_camp_scene_preset(world_type: &str) -> Value {
|
||
let is_xianxia = world_type == "XIANXIA";
|
||
json!({
|
||
"id": if is_xianxia { "xianxia-star-vessel" } else { "wuxia-border-camp" },
|
||
"name": if is_xianxia { "星槎泊台" } else { "边城营地" },
|
||
"description": if is_xianxia { "星槎停泊在云海边缘,远处灵潮微明。" } else { "边城营地炊烟未散,旧路与山影在前方交错。" },
|
||
"imageSrc": "",
|
||
"worldType": world_type,
|
||
"forwardSceneId": Value::Null,
|
||
"connectedSceneIds": [],
|
||
"connections": [],
|
||
"npcs": [],
|
||
"treasureHints": [],
|
||
"narrativeResidues": []
|
||
})
|
||
}
|
||
|
||
pub(super) fn build_custom_scene_preset(profile: &Value, scene_id: &str) -> Option<Value> {
|
||
if scene_id == "custom-scene-camp" {
|
||
let camp = read_object_field(profile, "camp");
|
||
let name = camp
|
||
.and_then(|value| read_optional_string_field(value, "name"))
|
||
.unwrap_or_else(|| "开局归处".to_string());
|
||
let description = camp
|
||
.and_then(|value| read_optional_string_field(value, "description"))
|
||
.unwrap_or_else(|| read_optional_string_field(profile, "summary").unwrap_or_default());
|
||
let connected_scene_ids = read_array_field(profile, "landmarks")
|
||
.into_iter()
|
||
.take(3)
|
||
.enumerate()
|
||
.map(|(index, _)| Value::String(format!("custom-scene-landmark-{}", index + 1)))
|
||
.collect::<Vec<_>>();
|
||
let npcs = build_custom_scene_npcs(profile, scene_id);
|
||
return Some(json!({
|
||
"id": "custom-scene-camp",
|
||
"name": name,
|
||
"description": description,
|
||
"imageSrc": camp.and_then(|value| read_optional_string_field(value, "imageSrc")).unwrap_or_default(),
|
||
"worldType": "CUSTOM",
|
||
"forwardSceneId": connected_scene_ids.first().cloned().unwrap_or(Value::Null),
|
||
"connectedSceneIds": connected_scene_ids,
|
||
"connections": [],
|
||
"npcs": npcs,
|
||
"treasureHints": [],
|
||
"narrativeResidues": camp.and_then(|value| read_field(value, "narrativeResidues")).cloned().unwrap_or(Value::Array(Vec::new()))
|
||
}));
|
||
}
|
||
|
||
let landmark_index = scene_id
|
||
.strip_prefix("custom-scene-landmark-")
|
||
.and_then(|value| value.parse::<usize>().ok())
|
||
.and_then(|value| value.checked_sub(1))
|
||
.unwrap_or(0);
|
||
let landmark = *read_array_field(profile, "landmarks").get(landmark_index)?;
|
||
let npcs = build_custom_scene_npcs(profile, scene_id);
|
||
Some(json!({
|
||
"id": scene_id,
|
||
"name": read_optional_string_field(landmark, "name").unwrap_or_else(|| format!("地标{}", landmark_index + 1)),
|
||
"description": read_optional_string_field(landmark, "description").unwrap_or_default(),
|
||
"imageSrc": read_optional_string_field(landmark, "imageSrc").unwrap_or_default(),
|
||
"worldType": "CUSTOM",
|
||
"forwardSceneId": Value::Null,
|
||
"connectedSceneIds": ["custom-scene-camp"],
|
||
"connections": [],
|
||
"npcs": npcs,
|
||
"treasureHints": [],
|
||
"narrativeResidues": read_field(landmark, "narrativeResidues").cloned().unwrap_or(Value::Array(Vec::new()))
|
||
}))
|
||
}
|
||
|
||
pub(super) fn build_custom_scene_npcs(profile: &Value, scene_id: &str) -> Vec<Value> {
|
||
let mut npc_ids = Vec::new();
|
||
if scene_id == "custom-scene-camp" {
|
||
read_object_field(profile, "camp")
|
||
.map(|camp| read_array_field(camp, "sceneNpcIds"))
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.filter_map(Value::as_str)
|
||
.for_each(|id| push_unique_string(&mut npc_ids, id));
|
||
} else if let Some(landmark_index) = scene_id
|
||
.strip_prefix("custom-scene-landmark-")
|
||
.and_then(|value| value.parse::<usize>().ok())
|
||
.and_then(|value| value.checked_sub(1))
|
||
{
|
||
if let Some(landmark) = read_array_field(profile, "landmarks").get(landmark_index) {
|
||
read_array_field(landmark, "sceneNpcIds")
|
||
.into_iter()
|
||
.filter_map(Value::as_str)
|
||
.for_each(|id| push_unique_string(&mut npc_ids, id));
|
||
}
|
||
}
|
||
|
||
collect_scene_act_npc_ids(profile, scene_id)
|
||
.into_iter()
|
||
.for_each(|id| push_unique_string(&mut npc_ids, id.as_str()));
|
||
|
||
npc_ids
|
||
.into_iter()
|
||
.filter_map(|npc_id| find_custom_world_role_by_reference(profile, npc_id.as_str()))
|
||
.map(build_custom_scene_npc)
|
||
.collect()
|
||
}
|
||
|
||
fn collect_scene_act_npc_ids(profile: &Value, scene_id: &str) -> Vec<String> {
|
||
let aliases = custom_scene_aliases(profile, scene_id);
|
||
let mut npc_ids = Vec::new();
|
||
for chapter in read_array_field(profile, "sceneChapterBlueprints") {
|
||
let chapter_scene_ids = [
|
||
read_optional_string_field(chapter, "sceneId"),
|
||
Some(
|
||
read_array_field(chapter, "linkedLandmarkIds")
|
||
.into_iter()
|
||
.filter_map(Value::as_str)
|
||
.map(str::to_string)
|
||
.collect::<Vec<_>>()
|
||
.join("|"),
|
||
),
|
||
];
|
||
let mut matches_scene = chapter_scene_ids
|
||
.into_iter()
|
||
.flatten()
|
||
.flat_map(|entry| entry.split('|').map(str::to_string).collect::<Vec<_>>())
|
||
.any(|id| aliases.contains(&resolve_custom_runtime_scene_id(profile, id.as_str())));
|
||
for act in read_array_field(chapter, "acts") {
|
||
if aliases.contains(&resolve_custom_runtime_scene_id(
|
||
profile,
|
||
read_optional_string_field(act, "sceneId")
|
||
.unwrap_or_default()
|
||
.as_str(),
|
||
)) {
|
||
matches_scene = true;
|
||
}
|
||
if matches_scene {
|
||
[
|
||
read_optional_string_field(act, "primaryNpcId"),
|
||
read_optional_string_field(act, "oppositeNpcId"),
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
.for_each(|id| {
|
||
let resolved = resolve_custom_role_id_reference(profile, id.as_str());
|
||
push_unique_string(&mut npc_ids, resolved.as_str());
|
||
});
|
||
read_array_field(act, "encounterNpcIds")
|
||
.into_iter()
|
||
.filter_map(Value::as_str)
|
||
.for_each(|id| {
|
||
let resolved = resolve_custom_role_id_reference(profile, id);
|
||
push_unique_string(&mut npc_ids, resolved.as_str());
|
||
});
|
||
}
|
||
}
|
||
}
|
||
npc_ids
|
||
}
|
||
|
||
fn custom_scene_aliases(profile: &Value, scene_id: &str) -> Vec<String> {
|
||
let runtime_id = resolve_custom_runtime_scene_id(profile, scene_id);
|
||
let mut aliases = vec![runtime_id.clone()];
|
||
if runtime_id == "custom-scene-camp" {
|
||
if let Some(camp_id) = read_object_field(profile, "camp")
|
||
.and_then(|camp| read_optional_string_field(camp, "id"))
|
||
{
|
||
aliases.push(camp_id);
|
||
}
|
||
}
|
||
aliases
|
||
}
|
||
|
||
fn push_unique_string(values: &mut Vec<String>, value: &str) {
|
||
let normalized = value.trim();
|
||
if !normalized.is_empty() && !values.iter().any(|entry| entry == normalized) {
|
||
values.push(normalized.to_string());
|
||
}
|
||
}
|
||
|
||
fn build_custom_scene_npc(role: Value) -> Value {
|
||
let role_id = read_optional_string_field(&role, "id").unwrap_or_default();
|
||
let name = read_optional_string_field(&role, "name").unwrap_or_else(|| role_id.clone());
|
||
let initial_affinity = read_i32_field(&role, "initialAffinity").unwrap_or(18);
|
||
let hostile = initial_affinity < 0;
|
||
json!({
|
||
"id": role_id,
|
||
"characterId": read_optional_string_field(&role, "id"),
|
||
"name": name,
|
||
"title": read_optional_string_field(&role, "title"),
|
||
"role": read_optional_string_field(&role, "role").unwrap_or_default(),
|
||
"avatar": read_optional_string_field(&role, "imageSrc").unwrap_or_else(|| name.chars().next().map(|ch| ch.to_string()).unwrap_or_else(|| "?".to_string())),
|
||
"description": read_optional_string_field(&role, "description").unwrap_or_default(),
|
||
"gender": "unknown",
|
||
"initialAffinity": initial_affinity,
|
||
"hostile": hostile,
|
||
"recruitable": !hostile,
|
||
"functions": if hostile { json!(["fight"]) } else { json!(["trade", "fight", "spar", "help", "chat", "recruit", "gift"]) },
|
||
"backstory": read_optional_string_field(&role, "backstory"),
|
||
"personality": read_optional_string_field(&role, "personality"),
|
||
"motivation": read_optional_string_field(&role, "motivation"),
|
||
"combatStyle": read_optional_string_field(&role, "combatStyle"),
|
||
"relationshipHooks": read_field(&role, "relationshipHooks").cloned().unwrap_or(Value::Array(Vec::new())),
|
||
"tags": read_field(&role, "tags").cloned().unwrap_or(Value::Array(Vec::new())),
|
||
"backstoryReveal": read_field(&role, "backstoryReveal").cloned(),
|
||
"skills": read_field(&role, "skills").cloned().unwrap_or(Value::Array(Vec::new())),
|
||
"initialItems": read_field(&role, "initialItems").cloned().unwrap_or(Value::Array(Vec::new())),
|
||
"imageSrc": read_optional_string_field(&role, "imageSrc"),
|
||
"visual": read_field(&role, "visual").cloned(),
|
||
"narrativeProfile": read_field(&role, "narrativeProfile").cloned(),
|
||
"attributeProfile": read_field(&role, "attributeProfile").cloned()
|
||
})
|
||
}
|
||
|
||
fn resolve_initial_encounter(
|
||
world_type: &str,
|
||
profile: Option<&Value>,
|
||
character: &Value,
|
||
scene_preset: Option<&Value>,
|
||
) -> Option<Value> {
|
||
if world_type == "CUSTOM" {
|
||
let profile = profile?;
|
||
if let Some(role_id) = resolve_opening_act_encounter_role_id(profile, character) {
|
||
if let Some(scene_npc) = scene_preset.and_then(|scene| {
|
||
read_array_field(scene, "npcs").into_iter().find(|npc| {
|
||
do_role_references_match(
|
||
profile,
|
||
read_optional_string_field(npc, "id").as_deref(),
|
||
Some(role_id.as_str()),
|
||
)
|
||
})
|
||
}) {
|
||
return Some(build_encounter_from_scene_npc(scene_npc));
|
||
}
|
||
return find_custom_world_role_by_reference(profile, role_id.as_str())
|
||
.map(build_opening_encounter_from_custom_role);
|
||
}
|
||
return None;
|
||
}
|
||
|
||
scene_preset.and_then(|scene| {
|
||
read_array_field(scene, "npcs")
|
||
.into_iter()
|
||
.find(|npc| {
|
||
read_optional_string_field(npc, "characterId")
|
||
!= read_optional_string_field(character, "id")
|
||
})
|
||
.map(build_encounter_from_scene_npc)
|
||
})
|
||
}
|
||
|
||
fn resolve_opening_act_encounter_role_id(profile: &Value, character: &Value) -> Option<String> {
|
||
let opening_chapter = read_array_field(profile, "sceneChapterBlueprints")
|
||
.into_iter()
|
||
.next()?;
|
||
let opening_act = read_array_field(opening_chapter, "acts")
|
||
.into_iter()
|
||
.next()?;
|
||
let references = [
|
||
read_optional_string_field(opening_act, "oppositeNpcId"),
|
||
read_optional_string_field(opening_act, "primaryNpcId"),
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
.chain(
|
||
read_array_field(opening_act, "encounterNpcIds")
|
||
.into_iter()
|
||
.filter_map(Value::as_str)
|
||
.map(str::to_string),
|
||
);
|
||
for reference in references {
|
||
let role_id = resolve_custom_role_id_reference(profile, reference.as_str());
|
||
if do_role_references_match(
|
||
profile,
|
||
Some(role_id.as_str()),
|
||
read_optional_string_field(character, "id").as_deref(),
|
||
) || do_role_references_match(
|
||
profile,
|
||
Some(role_id.as_str()),
|
||
read_optional_string_field(character, "name").as_deref(),
|
||
) {
|
||
continue;
|
||
}
|
||
if !role_id.trim().is_empty() {
|
||
return Some(role_id);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
pub(super) fn build_encounter_from_scene_npc(npc: &Value) -> Value {
|
||
let name = read_optional_string_field(npc, "name").unwrap_or_else(|| "当前遭遇".to_string());
|
||
json!({
|
||
"id": read_optional_string_field(npc, "id"),
|
||
"kind": "npc",
|
||
"characterId": read_optional_string_field(npc, "characterId"),
|
||
"npcName": name,
|
||
"npcDescription": read_optional_string_field(npc, "description").unwrap_or_default(),
|
||
"npcAvatar": read_optional_string_field(npc, "avatar").unwrap_or_else(|| name.chars().next().map(|ch| ch.to_string()).unwrap_or_else(|| "?".to_string())),
|
||
"context": read_optional_string_field(npc, "role").unwrap_or_default(),
|
||
"gender": read_optional_string_field(npc, "gender").unwrap_or_else(|| "unknown".to_string()),
|
||
"xMeters": RESOLVED_ENTITY_X_METERS,
|
||
"initialAffinity": read_i32_field(npc, "initialAffinity"),
|
||
"hostile": read_bool_field(npc, "hostile").unwrap_or(false) || read_i32_field(npc, "initialAffinity").unwrap_or(0) < 0,
|
||
"title": read_optional_string_field(npc, "title"),
|
||
"backstory": read_optional_string_field(npc, "backstory"),
|
||
"personality": read_optional_string_field(npc, "personality"),
|
||
"motivation": read_optional_string_field(npc, "motivation"),
|
||
"combatStyle": read_optional_string_field(npc, "combatStyle"),
|
||
"relationshipHooks": read_field(npc, "relationshipHooks").cloned().unwrap_or(Value::Array(Vec::new())),
|
||
"tags": read_field(npc, "tags").cloned().unwrap_or(Value::Array(Vec::new())),
|
||
"backstoryReveal": read_field(npc, "backstoryReveal").cloned(),
|
||
"skills": read_field(npc, "skills").cloned().unwrap_or(Value::Array(Vec::new())),
|
||
"initialItems": read_field(npc, "initialItems").cloned().unwrap_or(Value::Array(Vec::new())),
|
||
"imageSrc": read_optional_string_field(npc, "imageSrc"),
|
||
"visual": read_field(npc, "visual").cloned(),
|
||
"narrativeProfile": read_field(npc, "narrativeProfile").cloned(),
|
||
"attributeProfile": read_field(npc, "attributeProfile").cloned()
|
||
})
|
||
}
|
||
|
||
fn build_opening_encounter_from_custom_role(role: Value) -> Value {
|
||
let scene_npc = build_custom_scene_npc(role);
|
||
build_encounter_from_scene_npc(&scene_npc)
|
||
}
|
||
|
||
fn build_initial_npc_state_value(encounter: &Value) -> Value {
|
||
let affinity = read_i32_field(encounter, "initialAffinity").unwrap_or_else(|| {
|
||
if read_bool_field(encounter, "hostile").unwrap_or(false) {
|
||
-40
|
||
} else {
|
||
18
|
||
}
|
||
});
|
||
json!({
|
||
"affinity": affinity,
|
||
"chattedCount": 0,
|
||
"helpUsed": false,
|
||
"giftsGiven": 0,
|
||
"inventory": [],
|
||
"recruited": false,
|
||
"relationState": build_runtime_story_relation_state_value(affinity),
|
||
"revealedFacts": [],
|
||
"knownAttributeRumors": [],
|
||
"firstMeaningfulContactResolved": false,
|
||
"seenBackstoryChapterIds": [],
|
||
"tradeStockSignature": Value::Null,
|
||
"stanceProfile": build_runtime_story_stance_profile_value(
|
||
affinity,
|
||
false,
|
||
read_bool_field(encounter, "hostile").unwrap_or(false),
|
||
read_optional_string_field(encounter, "context").as_deref(),
|
||
None,
|
||
)
|
||
})
|
||
}
|
||
|
||
fn build_opening_story_engine_memory(
|
||
profile: Option<&Value>,
|
||
scene_preset: &Option<Value>,
|
||
) -> Value {
|
||
let current_scene_act_state = profile
|
||
.and_then(|profile| {
|
||
scene_preset.as_ref().and_then(|scene| {
|
||
read_optional_string_field(scene, "id").map(|scene_id| (profile, scene_id))
|
||
})
|
||
})
|
||
.and_then(|(profile, scene_id)| {
|
||
build_initial_scene_act_runtime_state(profile, scene_id.as_str())
|
||
});
|
||
json!({
|
||
"visibleFacts": [],
|
||
"hiddenFacts": [],
|
||
"threadStates": {},
|
||
"companionMemory": {},
|
||
"worldMutations": [],
|
||
"currentSceneActState": current_scene_act_state
|
||
})
|
||
}
|
||
|
||
fn build_initial_scene_act_runtime_state(profile: &Value, scene_id: &str) -> Option<Value> {
|
||
let aliases = custom_scene_aliases(profile, scene_id);
|
||
for chapter in read_array_field(profile, "sceneChapterBlueprints") {
|
||
let chapter_scene_id = read_optional_string_field(chapter, "sceneId")
|
||
.map(|id| resolve_custom_runtime_scene_id(profile, id.as_str()));
|
||
let chapter_matches = chapter_scene_id
|
||
.as_ref()
|
||
.is_some_and(|id| aliases.contains(id))
|
||
|| read_array_field(chapter, "linkedLandmarkIds")
|
||
.into_iter()
|
||
.filter_map(Value::as_str)
|
||
.any(|id| aliases.contains(&resolve_custom_runtime_scene_id(profile, id)));
|
||
if !chapter_matches {
|
||
continue;
|
||
}
|
||
let Some(first_act) = read_array_field(chapter, "acts").into_iter().next() else {
|
||
continue;
|
||
};
|
||
return Some(json!({
|
||
"sceneId": read_optional_string_field(chapter, "sceneId").unwrap_or_else(|| scene_id.to_string()),
|
||
"chapterId": read_optional_string_field(chapter, "id").unwrap_or_default(),
|
||
"currentActId": read_optional_string_field(first_act, "id").unwrap_or_default(),
|
||
"currentActIndex": 0,
|
||
"completedActIds": [],
|
||
"visitedActIds": [read_optional_string_field(first_act, "id").unwrap_or_default()]
|
||
}));
|
||
}
|
||
None
|
||
}
|
||
|
||
fn build_character_skill_cooldowns(character: &Value) -> Value {
|
||
let mut cooldowns = Map::new();
|
||
read_array_field(character, "skills")
|
||
.into_iter()
|
||
.filter_map(|skill| read_optional_string_field(skill, "id"))
|
||
.for_each(|skill_id| {
|
||
cooldowns.insert(skill_id, json!(0));
|
||
});
|
||
Value::Object(cooldowns)
|
||
}
|
||
|
||
fn resolve_character_max_hp(character: &Value) -> i32 {
|
||
read_object_field(character, "resourceProfile")
|
||
.and_then(|profile| read_i32_field(profile, "maxHp"))
|
||
.unwrap_or_else(|| {
|
||
let strength = read_object_field(character, "attributes")
|
||
.and_then(|attributes| read_i32_field(attributes, "strength"))
|
||
.unwrap_or(6);
|
||
let spirit = read_object_field(character, "attributes")
|
||
.and_then(|attributes| read_i32_field(attributes, "spirit"))
|
||
.unwrap_or(4);
|
||
PLAYER_BASE_MAX_HP.max(90 + strength * 10 + spirit * 4)
|
||
})
|
||
}
|
||
|
||
fn resolve_character_max_mana(character: &Value) -> i32 {
|
||
read_object_field(character, "resourceProfile")
|
||
.and_then(|profile| read_i32_field(profile, "maxMana"))
|
||
.unwrap_or(DEFAULT_PLAYER_MAX_MANA)
|
||
}
|
||
|
||
fn resolve_initial_player_currency(world_type: &str, profile: Option<&Value>) -> i32 {
|
||
profile
|
||
.and_then(|profile| read_object_field(profile, "ownedSettingLayers"))
|
||
.and_then(|layers| read_object_field(layers, "ruleProfile"))
|
||
.and_then(|rule| read_object_field(rule, "economyProfile"))
|
||
.and_then(|economy| read_i32_field(economy, "initialCurrency"))
|
||
.unwrap_or_else(|| if world_type == "XIANXIA" { 140 } else { 160 })
|
||
}
|
||
|
||
fn build_initial_player_inventory(
|
||
world_type: &str,
|
||
profile: Option<&Value>,
|
||
character: &Value,
|
||
) -> Vec<Value> {
|
||
let mut items = Vec::new();
|
||
if world_type == "CUSTOM" {
|
||
if let Some(profile) = profile {
|
||
if let Some(role) = resolve_custom_character_role(profile, character) {
|
||
read_array_field(&role, "initialItems")
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(index, item)| build_explicit_role_inventory_item(&role, item, index))
|
||
.for_each(|item| merge_inventory_item(&mut items, item));
|
||
}
|
||
}
|
||
}
|
||
|
||
read_array_field(character, "inventory")
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(index, item)| normalize_character_inventory_item(character, item, index))
|
||
.for_each(|item| merge_inventory_item(&mut items, item));
|
||
|
||
if items.is_empty() {
|
||
items.push(json!({
|
||
"id": format!("starter:{}:supply", read_optional_string_field(character, "id").unwrap_or_else(|| "character".to_string())),
|
||
"category": "消耗品",
|
||
"name": if world_type == "XIANXIA" { "回灵散" } else { "行囊补给" },
|
||
"quantity": 2,
|
||
"rarity": "common",
|
||
"tags": ["healing", "supply"],
|
||
"description": "开局随身携带的基础补给。"
|
||
}));
|
||
}
|
||
|
||
items
|
||
}
|
||
|
||
fn build_initial_player_equipment(
|
||
world_type: &str,
|
||
profile: Option<&Value>,
|
||
character: &Value,
|
||
inventory: &[Value],
|
||
) -> Value {
|
||
let mut equipment = json!({
|
||
"weapon": Value::Null,
|
||
"armor": Value::Null,
|
||
"relic": Value::Null
|
||
});
|
||
|
||
for item in inventory {
|
||
let Some(slot) = read_optional_string_field(item, "equipmentSlotId") else {
|
||
continue;
|
||
};
|
||
if ["weapon", "armor", "relic"].contains(&slot.as_str())
|
||
&& read_field(&equipment, slot.as_str()).is_some_and(Value::is_null)
|
||
{
|
||
write_field(&mut equipment, slot.as_str(), item.clone());
|
||
}
|
||
}
|
||
|
||
for (slot, label) in [("weapon", "武器"), ("armor", "护甲"), ("relic", "饰品")] {
|
||
if read_field(&equipment, slot).is_some_and(Value::is_null) {
|
||
write_field(
|
||
&mut equipment,
|
||
slot,
|
||
build_fallback_equipment_item(world_type, profile, character, slot, label),
|
||
);
|
||
}
|
||
}
|
||
|
||
equipment
|
||
}
|
||
|
||
fn build_fallback_equipment_item(
|
||
world_type: &str,
|
||
_profile: Option<&Value>,
|
||
character: &Value,
|
||
slot: &str,
|
||
label: &str,
|
||
) -> Value {
|
||
let character_id =
|
||
read_optional_string_field(character, "id").unwrap_or_else(|| "character".to_string());
|
||
let character_name =
|
||
read_optional_string_field(character, "name").unwrap_or_else(|| "旅人".to_string());
|
||
let name = match (world_type, slot) {
|
||
("XIANXIA", "weapon") => format!("{character_name}的灵刃"),
|
||
("XIANXIA", "armor") => format!("{character_name}的护行法衣"),
|
||
("XIANXIA", "relic") => format!("{character_name}的行旅护符"),
|
||
(_, "weapon") => format!("{character_name}的短刃"),
|
||
(_, "armor") => format!("{character_name}的护行短甲"),
|
||
_ => format!("{character_name}的旧信物"),
|
||
};
|
||
json!({
|
||
"id": format!("starter:{character_id}:{slot}"),
|
||
"category": label,
|
||
"name": name,
|
||
"quantity": 1,
|
||
"rarity": "common",
|
||
"tags": [slot],
|
||
"equipmentSlotId": slot
|
||
})
|
||
}
|
||
|
||
fn normalize_character_inventory_item(character: &Value, item: &Value, index: usize) -> Value {
|
||
let category =
|
||
read_optional_string_field(item, "category").unwrap_or_else(|| "消耗品".to_string());
|
||
json!({
|
||
"id": read_optional_string_field(item, "id").unwrap_or_else(|| format!("starter:{}:inventory:{}", read_optional_string_field(character, "id").unwrap_or_else(|| "character".to_string()), index + 1)),
|
||
"category": category,
|
||
"name": read_optional_string_field(item, "name").or_else(|| read_optional_string_field(item, "item")).unwrap_or_else(|| "随身物品".to_string()),
|
||
"quantity": read_i32_field(item, "quantity").unwrap_or(1).max(1),
|
||
"rarity": read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string()),
|
||
"tags": read_field(item, "tags").cloned().unwrap_or(Value::Array(Vec::new())),
|
||
"description": read_optional_string_field(item, "description"),
|
||
"equipmentSlotId": infer_explicit_starter_slot(category.as_str())
|
||
})
|
||
}
|
||
|
||
fn build_explicit_role_inventory_item(role: &Value, item: &Value, index: usize) -> Value {
|
||
let category = normalize_explicit_starter_category(
|
||
read_optional_string_field(item, "category")
|
||
.unwrap_or_else(|| "专属物品".to_string())
|
||
.as_str(),
|
||
);
|
||
let role_id = read_optional_string_field(role, "id").unwrap_or_else(|| "role".to_string());
|
||
let role_name = read_optional_string_field(role, "name").unwrap_or_else(|| "角色".to_string());
|
||
json!({
|
||
"id": format!("custom-role-item:{role_id}:{}", index + 1),
|
||
"category": category,
|
||
"name": read_optional_string_field(item, "name").unwrap_or_else(|| "初始物品".to_string()),
|
||
"quantity": read_i32_field(item, "quantity").unwrap_or(1).max(1),
|
||
"rarity": read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string()),
|
||
"tags": read_field(item, "tags").cloned().unwrap_or(Value::Array(Vec::new())),
|
||
"description": read_optional_string_field(item, "description"),
|
||
"equipmentSlotId": infer_explicit_starter_slot(category.as_str()),
|
||
"runtimeMetadata": {
|
||
"origin": "ai_compiled",
|
||
"generationChannel": "discovery",
|
||
"seedKey": format!("{role_id}:{}", index + 1),
|
||
"relationAnchor": {
|
||
"type": "npc",
|
||
"npcId": role_id,
|
||
"npcName": role_name,
|
||
"roleText": read_optional_string_field(role, "role").unwrap_or_default()
|
||
},
|
||
"sourceReason": format!("{role_name}在自定义世界开局时自带的初始物品。")
|
||
}
|
||
})
|
||
}
|
||
|
||
fn normalize_explicit_starter_category(category: &str) -> String {
|
||
let normalized = category.trim();
|
||
if normalized == "专属物" {
|
||
"专属物品".to_string()
|
||
} else {
|
||
normalized.to_string()
|
||
}
|
||
}
|
||
|
||
fn infer_explicit_starter_slot(category: &str) -> Value {
|
||
match normalize_explicit_starter_category(category).as_str() {
|
||
"武器" => json!("weapon"),
|
||
"护甲" => json!("armor"),
|
||
"饰品" | "稀有品" | "专属物品" => json!("relic"),
|
||
_ => Value::Null,
|
||
}
|
||
}
|
||
|
||
fn merge_inventory_item(items: &mut Vec<Value>, item: Value) {
|
||
let key = format!(
|
||
"{}:{}",
|
||
read_optional_string_field(&item, "category").unwrap_or_default(),
|
||
read_optional_string_field(&item, "name").unwrap_or_default()
|
||
);
|
||
if items.iter().any(|entry| {
|
||
format!(
|
||
"{}:{}",
|
||
read_optional_string_field(entry, "category").unwrap_or_default(),
|
||
read_optional_string_field(entry, "name").unwrap_or_default()
|
||
) == key
|
||
}) {
|
||
return;
|
||
}
|
||
items.push(item);
|
||
}
|
||
|
||
fn resolve_custom_character_role(profile: &Value, character: &Value) -> Option<Value> {
|
||
read_optional_string_field(character, "id")
|
||
.and_then(|id| find_custom_world_role_by_reference(profile, id.as_str()))
|
||
.or_else(|| {
|
||
read_optional_string_field(character, "name")
|
||
.and_then(|name| find_custom_world_role_by_reference(profile, name.as_str()))
|
||
})
|
||
}
|
||
|
||
fn find_custom_world_role_by_reference(profile: &Value, reference: &str) -> Option<Value> {
|
||
let normalized_reference = normalize_role_reference(reference);
|
||
if normalized_reference.is_empty() {
|
||
return None;
|
||
}
|
||
read_array_field(profile, "storyNpcs")
|
||
.into_iter()
|
||
.chain(read_array_field(profile, "playableNpcs"))
|
||
.find(|role| role_reference_aliases(role).contains(&normalized_reference))
|
||
.cloned()
|
||
}
|
||
|
||
fn resolve_custom_role_id_reference(profile: &Value, reference: &str) -> String {
|
||
find_custom_world_role_by_reference(profile, reference)
|
||
.and_then(|role| read_optional_string_field(&role, "id"))
|
||
.unwrap_or_else(|| reference.trim().to_string())
|
||
}
|
||
|
||
fn do_role_references_match(profile: &Value, left: Option<&str>, right: Option<&str>) -> bool {
|
||
let left = left.map(|value| resolve_custom_role_id_reference(profile, value));
|
||
let right = right.map(|value| resolve_custom_role_id_reference(profile, value));
|
||
matches!((left, right), (Some(left), Some(right)) if !left.is_empty() && left == right)
|
||
}
|
||
|
||
fn role_reference_aliases(role: &Value) -> Vec<String> {
|
||
let name = read_optional_string_field(role, "name").unwrap_or_default();
|
||
let title = read_optional_string_field(role, "title").unwrap_or_default();
|
||
let role_text = read_optional_string_field(role, "role").unwrap_or_default();
|
||
[
|
||
read_optional_string_field(role, "id").unwrap_or_default(),
|
||
name.clone(),
|
||
title.clone(),
|
||
format!("{name}{title}"),
|
||
format!("{title}{name}"),
|
||
format!("{role_text}{name}"),
|
||
format!("{name}{role_text}"),
|
||
]
|
||
.into_iter()
|
||
.map(|value| normalize_role_reference(value.as_str()))
|
||
.filter(|value| !value.is_empty())
|
||
.collect()
|
||
}
|
||
|
||
fn normalize_role_reference(value: &str) -> String {
|
||
value
|
||
.trim()
|
||
.replace("character-npc-", "")
|
||
.replace("character-npc:", "")
|
||
.replace("playable-", "")
|
||
.replace("story-", "")
|
||
.replace("role-", "")
|
||
.replace("npc-", "")
|
||
.replace([' ', '(', ')', '(', ')'], "")
|
||
}
|
||
|
||
fn read_equipment_total_bonuses(equipment: &Value) -> EquipmentBonusSummary {
|
||
let mut summary = EquipmentBonusSummary::default();
|
||
for slot in ["weapon", "armor", "relic"] {
|
||
let Some(item) = read_field(equipment, slot) else {
|
||
continue;
|
||
};
|
||
let Some(item_object) = item.as_object() else {
|
||
continue;
|
||
};
|
||
if let Some(stat_profile) = item_object.get("statProfile") {
|
||
summary.max_hp_bonus += read_i32_field(stat_profile, "maxHpBonus").unwrap_or(0);
|
||
} else if slot == "armor" {
|
||
summary.max_hp_bonus += 14;
|
||
}
|
||
}
|
||
summary
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct EquipmentBonusSummary {
|
||
max_hp_bonus: i32,
|
||
}
|
||
|
||
fn write_field(target: &mut Value, key: &str, value: Value) {
|
||
let object = ensure_json_object(target);
|
||
object.insert(key.to_string(), value);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod bootstrap_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn custom_world_bootstrap_builds_opening_act_state_on_server() {
|
||
let payload = RuntimeStoryBootstrapRequest {
|
||
world_type: "CUSTOM".to_string(),
|
||
runtime_mode: Some("play".to_string()),
|
||
disable_persistence: Some(true),
|
||
character: json!({
|
||
"id": "player-1",
|
||
"name": "沈砺",
|
||
"resourceProfile": { "maxHp": 188, "maxMana": 999 },
|
||
"skills": [{ "id": "skill-1" }]
|
||
}),
|
||
custom_world_profile: Some(json!({
|
||
"id": "profile-1",
|
||
"name": "回潮群岛",
|
||
"summary": "潮雾里有旧账。",
|
||
"camp": {
|
||
"id": "camp-1",
|
||
"name": "回潮暂栖所",
|
||
"description": "一间靠海的暂栖所。",
|
||
"sceneNpcIds": ["story-act-only"]
|
||
},
|
||
"landmarks": [],
|
||
"playableNpcs": [{
|
||
"id": "player-1",
|
||
"name": "沈砺",
|
||
"role": "主角",
|
||
"initialItems": [{
|
||
"name": "旧潮短刃",
|
||
"category": "武器",
|
||
"quantity": 1,
|
||
"rarity": "rare",
|
||
"tags": ["weapon"],
|
||
"description": "旧账留下的短刃。"
|
||
}]
|
||
}],
|
||
"storyNpcs": [{
|
||
"id": "story-act-only",
|
||
"name": "陆衡",
|
||
"title": "账房",
|
||
"role": "守账人",
|
||
"description": "守着账本的人。",
|
||
"backstory": "",
|
||
"personality": "",
|
||
"motivation": "",
|
||
"combatStyle": "",
|
||
"initialAffinity": 12,
|
||
"relationshipHooks": [],
|
||
"tags": [],
|
||
"initialItems": [],
|
||
"skills": [],
|
||
"backstoryReveal": { "publicSummary": "", "chapters": [] }
|
||
}],
|
||
"sceneChapterBlueprints": [{
|
||
"id": "chapter-1",
|
||
"sceneId": "camp-1",
|
||
"title": "开局",
|
||
"linkedLandmarkIds": [],
|
||
"acts": [{
|
||
"id": "act-1",
|
||
"sceneId": "camp-1",
|
||
"title": "对账",
|
||
"primaryNpcId": "story-primary-only",
|
||
"oppositeNpcId": "character-npc-story-act-only",
|
||
"encounterNpcIds": []
|
||
}]
|
||
}]
|
||
})),
|
||
};
|
||
|
||
let state = build_initial_runtime_game_state(&payload, "runtime-test")
|
||
.expect("bootstrap should build state");
|
||
|
||
assert_eq!(state["runtimeSessionId"], json!("runtime-test"));
|
||
assert_eq!(state["currentScene"], json!("Story"));
|
||
assert_eq!(
|
||
state["currentScenePreset"]["id"],
|
||
json!("custom-scene-camp")
|
||
);
|
||
assert_eq!(
|
||
state["storyEngineMemory"]["currentSceneActState"]["currentActId"],
|
||
json!("act-1")
|
||
);
|
||
assert_eq!(state["currentEncounter"]["id"], json!("story-act-only"));
|
||
assert_eq!(state["playerInventory"][0]["name"], json!("旧潮短刃"));
|
||
assert_eq!(
|
||
state["playerEquipment"]["weapon"]["name"],
|
||
json!("旧潮短刃")
|
||
);
|
||
}
|
||
}
|