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, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, 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::(); if normalized.is_empty() { "unknown".to_string() } else { normalized } } fn build_initial_runtime_game_state( payload: &RuntimeStoryBootstrapRequest, session_id: &str, ) -> Result { 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 { 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 { 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 { 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::>(); 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::().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 { 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::().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 { 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::>() .join("|"), ), ]; let mut matches_scene = chapter_scene_ids .into_iter() .flatten() .flat_map(|entry| entry.split('|').map(str::to_string).collect::>()) .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 { 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, 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 { 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 { 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 { 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 { 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 { 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, 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 { 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 { 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 { 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!("旧潮短刃") ); } }