Files
Genarrative/server-rs/crates/module-runtime-story/src/bootstrap.rs

1086 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
pub snapshot: StoryRuntimeSnapshotPayload,
}
/// 构造正式 runtime story 开局快照。
///
/// 中文注释:这里只做纯 JSON 初始状态装配;用户身份、持久化和 story session
/// 创建都由 API / SpacetimeDB 外层处理。
pub fn build_runtime_story_bootstrap(
payload: &BeginStoryRuntimeSessionRequest,
seed: RuntimeStoryBootstrapSeed,
) -> Result<RuntimeStoryBootstrapBuild, String> {
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::<Vec<_>>(),
"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<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 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::<String>();
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<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 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<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()))
}))
}
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));
}
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,
"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<Value> {
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<Value> {
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<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,
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<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 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 build_opening_story_options() -> Vec<shared_contracts::runtime_story::RuntimeStoryOptionView> {
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());
}
}