1086 lines
42 KiB
Rust
1086 lines
42 KiB
Rust
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());
|
||
}
|
||
}
|