use serde_json::{Map, Value, json}; use shared_contracts::story::{BeginStoryRuntimeSessionRequest, StoryRuntimeSnapshotPayload}; use crate::{ apply_equipment_loadout_to_state, build_static_runtime_story_option, build_story_option_from_runtime_option, ensure_json_object, normalize_required_string, read_array_field, read_bool_field, read_field, read_i32_field, read_object_field, read_optional_string_field, }; const PLAYER_BASE_MAX_HP: i32 = 180; const DEFAULT_PLAYER_MAX_MANA: i32 = 999; pub struct RuntimeStoryBootstrapSeed { pub runtime_session_id: String, pub story_session_id: String, pub actor_user_id: String, pub now_micros: i64, } pub struct RuntimeStoryBootstrapBuild { pub runtime_session_id: String, pub story_session_id: String, pub world_profile_id: String, pub initial_prompt: String, pub opening_summary: Option, pub snapshot: StoryRuntimeSnapshotPayload, } /// 构造正式 runtime story 开局快照。 /// /// 中文注释:这里只做纯 JSON 初始状态装配;用户身份、持久化和 story session /// 创建都由 API / SpacetimeDB 外层处理。 pub fn build_runtime_story_bootstrap( payload: &BeginStoryRuntimeSessionRequest, seed: RuntimeStoryBootstrapSeed, ) -> Result { let mut game_state = build_initial_runtime_game_state(payload, &seed)?; let opening_text = build_opening_story_text(&game_state); let options = build_opening_story_options(); let current_story = json!({ "text": opening_text, "options": options .iter() .map(build_story_option_from_runtime_option) .collect::>(), "streaming": false }); ensure_json_object(&mut game_state).insert( "storySessionId".to_string(), Value::String(seed.story_session_id.clone()), ); Ok(RuntimeStoryBootstrapBuild { runtime_session_id: seed.runtime_session_id, story_session_id: seed.story_session_id, world_profile_id: resolve_world_profile_id(payload), initial_prompt: opening_text.clone(), opening_summary: Some(opening_text), snapshot: StoryRuntimeSnapshotPayload { saved_at: None, bottom_tab: "adventure".to_string(), game_state, current_story: Some(current_story), }, }) } fn build_initial_runtime_game_state( payload: &BeginStoryRuntimeSessionRequest, seed: &RuntimeStoryBootstrapSeed, ) -> 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 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, payload.custom_world_profile.as_ref()); let initial_encounter = resolve_initial_encounter( &world_type, payload.custom_world_profile.as_ref(), &character, initial_scene_preset.as_ref(), ); let initial_inventory = build_initial_player_inventory( &world_type, payload.custom_world_profile.as_ref(), &character, ); let initial_equipment = build_initial_player_equipment(&world_type, &character, &initial_inventory); let mut npc_states = Map::new(); if let Some(encounter) = initial_encounter.as_ref() { let npc_id = read_optional_string_field(encounter, "id") .or_else(|| read_optional_string_field(encounter, "npcName")) .or_else(|| read_optional_string_field(encounter, "name")) .unwrap_or_else(|| "npc_current".to_string()); npc_states.insert(npc_id, build_initial_npc_state_value(encounter)); } let player_max_hp = resolve_character_max_hp(&character); let player_max_mana = resolve_character_max_mana(&character); let mut game_state = json!({ "worldType": world_type, "customWorldProfile": custom_world_profile, "playerCharacter": character, "runtimeSessionId": seed.runtime_session_id, "storySessionId": seed.story_session_id, "actorUserId": seed.actor_user_id, "runtimeActionVersion": 1, "runtimeMode": normalize_runtime_mode(payload.runtime_mode.as_deref()), "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": build_opening_story_engine_memory(payload.custom_world_profile.as_ref(), initial_scene_preset.as_ref()), "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, "playerMaxHp": player_max_hp, "playerMana": player_max_mana, "playerMaxMana": player_max_mana, "playerSkillCooldowns": build_character_skill_cooldowns(&payload.character), "activeBuildBuffs": [], "activeCombatEffects": [], "playerCurrency": resolve_initial_player_currency(&world_type, 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 }); apply_equipment_loadout_to_state(&mut game_state); Ok(game_state) } pub fn generate_runtime_session_id( actor_user_id: &str, profile: Option<&Value>, character: &Value, now_micros: i64, ) -> String { let profile_id = profile .and_then(|profile| read_optional_string_field(profile, "id")) .or_else(|| 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), sanitize_id_segment(&character_id) ) } 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 resolve_world_profile_id(payload: &BeginStoryRuntimeSessionRequest) -> String { payload .custom_world_profile .as_ref() .and_then(|profile| read_optional_string_field(profile, "id")) .or_else(|| { payload .custom_world_profile .as_ref() .and_then(|profile| read_optional_string_field(profile, "name")) }) .unwrap_or_else(|| payload.world_type.trim().to_string()) } 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 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 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())) })) } 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)); } 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, "npcName": 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(), "npcDescription": read_optional_string_field(&role, "description").unwrap_or_default(), "initialAffinity": initial_affinity, "hostile": 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?; resolve_custom_opening_role(profile, character) .map(build_opening_encounter_from_custom_role) .or_else(|| { scene_preset .and_then(|scene| read_array_field(scene, "npcs").into_iter().next()) .map(build_encounter_from_scene_npc) }) } else { scene_preset .and_then(|scene| read_array_field(scene, "npcs").into_iter().next()) .map(build_encounter_from_scene_npc) } } fn resolve_custom_opening_role(profile: &Value, character: &Value) -> Option { let player_id = read_optional_string_field(character, "id"); read_array_field(profile, "storyNpcs") .into_iter() .find(|role| { player_id .as_deref() .is_none_or(|id| read_optional_string_field(role, "id").as_deref() != Some(id)) }) .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) } pub fn build_encounter_from_scene_npc(npc: &Value) -> Value { let npc_name = read_optional_string_field(npc, "npcName") .or_else(|| read_optional_string_field(npc, "name")) .unwrap_or_else(|| "当前角色".to_string()); json!({ "id": read_optional_string_field(npc, "id").unwrap_or_else(|| npc_name.clone()), "kind": "npc", "npcName": npc_name, "npcDescription": read_optional_string_field(npc, "npcDescription") .or_else(|| read_optional_string_field(npc, "description")) .unwrap_or_default(), "npcAvatar": read_optional_string_field(npc, "avatar").unwrap_or_default(), "context": read_optional_string_field(npc, "role").unwrap_or_default(), "hostile": read_bool_field(npc, "hostile").unwrap_or(false), "initialAffinity": read_i32_field(npc, "initialAffinity").unwrap_or(18), "characterId": read_optional_string_field(npc, "characterId"), "functions": read_field(npc, "functions").cloned().unwrap_or(Value::Array(Vec::new())), "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_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": { "affinity": affinity, "stance": relation_stance_key(affinity) }, "revealedFacts": [], "knownAttributeRumors": [], "firstMeaningfulContactResolved": false, "seenBackstoryChapterIds": [], "tradeStockSignature": Value::Null, "stanceProfile": { "trust": affinity.clamp(0, 100), "warmth": affinity.clamp(0, 100), "ideologicalFit": 50, "fearOrGuard": if affinity < 0 { 80 } else { 20 }, "loyalty": 0, "currentConflictTag": Value::Null, "recentApprovals": [], "recentDisapprovals": [] } }) } fn relation_stance_key(affinity: i32) -> &'static str { if affinity < 0 { "hostile" } else if affinity < 15 { "guarded" } else if affinity < 30 { "neutral" } else if affinity < 60 { "cooperative" } else { "bonded" } } 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.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, 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, character, slot, label), ); } } equipment } fn build_fallback_equipment_item( world_type: &str, 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 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 build_opening_story_options() -> Vec { vec![ build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"), build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"), build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"), build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"), ] } fn build_opening_story_text(game_state: &Value) -> String { let scene_name = read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "name")) .unwrap_or_else(|| "开局之地".to_string()); if let Some(npc_name) = read_object_field(game_state, "currentEncounter") .and_then(|encounter| read_optional_string_field(encounter, "npcName")) { return format!("{scene_name} 的风声还没有落下,{npc_name} 已经注意到了你的到来。"); } format!("{scene_name} 的第一轮动静已经展开,你可以开始判断下一步。") } 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 tests { use serde_json::json; use super::*; #[test] fn custom_world_bootstrap_builds_story_session_and_runtime_state() { let payload = BeginStoryRuntimeSessionRequest { world_type: "CUSTOM".to_string(), runtime_mode: Some("play".to_string()), disable_persistence: Some(false), 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": "守着账本的人。", "initialAffinity": 12, "initialItems": [], "skills": [] }], "sceneChapterBlueprints": [{ "id": "chapter-1", "sceneId": "camp-1", "linkedLandmarkIds": [], "acts": [{ "id": "act-1", "sceneId": "camp-1", "primaryNpcId": "story-act-only" }] }] })), }; let built = build_runtime_story_bootstrap( &payload, RuntimeStoryBootstrapSeed { runtime_session_id: "runtime-test".to_string(), story_session_id: "storysess-test".to_string(), actor_user_id: "user-1".to_string(), now_micros: 1, }, ) .expect("bootstrap should build state"); assert_eq!(built.world_profile_id, "profile-1"); assert_eq!( built.snapshot.game_state["runtimeSessionId"], json!("runtime-test") ); assert_eq!( built.snapshot.game_state["storySessionId"], json!("storysess-test") ); assert_eq!( built.snapshot.game_state["currentScenePreset"]["id"], json!("custom-scene-camp") ); assert_eq!( built.snapshot.game_state["currentEncounter"]["id"], json!("story-act-only") ); assert_eq!( built.snapshot.game_state["playerInventory"][0]["name"], json!("旧潮短刃") ); assert_eq!( built.snapshot.game_state["playerEquipment"]["weapon"]["name"], json!("旧潮短刃") ); assert!(built.snapshot.current_story.is_some()); } }