Files
Genarrative/server-rs/crates/api-server/src/runtime_story/compat/bootstrap.rs
2026-04-28 19:36:39 +08:00

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