This commit is contained in:
2026-04-28 20:25:37 +08:00
parent f0471a4f8d
commit 0f013b6eee
45 changed files with 1117 additions and 1047 deletions

View File

@@ -870,24 +870,6 @@ fn normalize_world_attribute_schema(
normalized_slots.push(json!({
"slotId": slot_id,
"name": name,
"definition": json_map_text(raw_slot, "definition")
.or_else(|| json_map_text(&fallback_slot, "definition"))
.unwrap_or_else(|| "这个维度用于描述角色在当前世界中的关键表现。".to_string()),
"positiveSignals": json_map_string_array(raw_slot, "positiveSignals")
.or_else(|| json_map_string_array(&fallback_slot, "positiveSignals"))
.unwrap_or_else(|| vec!["稳定".to_string(), "主动".to_string()]),
"negativeSignals": json_map_string_array(raw_slot, "negativeSignals")
.or_else(|| json_map_string_array(&fallback_slot, "negativeSignals"))
.unwrap_or_else(|| vec!["失衡".to_string(), "被动".to_string()]),
"combatUseText": json_map_text(raw_slot, "combatUseText")
.or_else(|| json_map_text(&fallback_slot, "combatUseText"))
.unwrap_or_else(|| "影响战斗中的推进、承压与应对。".to_string()),
"socialUseText": json_map_text(raw_slot, "socialUseText")
.or_else(|| json_map_text(&fallback_slot, "socialUseText"))
.unwrap_or_else(|| "影响对话中的判断、牵引与立场。".to_string()),
"explorationUseText": json_map_text(raw_slot, "explorationUseText")
.or_else(|| json_map_text(&fallback_slot, "explorationUseText"))
.unwrap_or_else(|| "影响探索中的观察、穿行与续航。".to_string()),
}));
}
@@ -901,9 +883,6 @@ fn normalize_world_attribute_schema(
.and_then(JsonValue::as_i64)
.filter(|value| *value > 0)
.unwrap_or(1),
"schemaName": json_map_text(schema, "schemaName")
.filter(|value| !is_invalid_attribute_schema_name(value))
.unwrap_or_else(|| build_attribute_schema_name(framework, setting_text)),
"generatedFrom": {
"worldType": "CUSTOM",
"worldName": framework_world_name(framework, setting_text),
@@ -945,7 +924,6 @@ fn build_fallback_world_attribute_schema(framework: &JsonValue, setting_text: &s
"id": build_attribute_schema_id(framework, setting_text),
"worldId": format!("custom:{world_name}"),
"schemaVersion": 1,
"schemaName": build_attribute_schema_name(framework, setting_text),
"generatedFrom": {
"worldType": "CUSTOM",
"worldName": world_name,
@@ -954,35 +932,20 @@ fn build_fallback_world_attribute_schema(framework: &JsonValue, setting_text: &s
"conflictCore": conflict_core,
},
"slots": [
build_attribute_slot("axis_a", format!("{prefix}"), format!("承受{prefix}压、正面冲击与长期消耗的底子。"), ["承压", "稳阵"], ["虚浮", "易散"], "顶住正面压力并守住行动空间。", "在强压场面里保持可信和稳固。", "穿过危险环境时维持身体与装备状态。"),
build_attribute_slot("axis_b", format!("{prefix_alt}"), format!("顺应{prefix_alt}势、换位穿行与抢占时机的能力。"), ["借势", "轻快"], ["迟滞", "失位"], "切线换位、闪避、追击和抢先手。", "反应灵活,能顺势调整话术。", "穿越复杂地形、封锁线与危险通路。"),
build_attribute_slot("axis_c", format!("{prefix}"), "看清局势、线索、虚实与隐藏代价的能力。", ["洞察", "辨伪"], ["误读", "迟钝"], "识破破绽并判断战局变化。", "听出隐瞒、试探与交换空间。", "整理线索、辨认路径并推断风险。"),
build_attribute_slot("axis_d", format!("{prefix_alt}"), "在高压变化里仍能推进目标和拍板的胆气。", ["果断", "压前"], ["犹疑", "退缩"], "顶着高压窗口推进突破口。", "在谈判或对峙中定调。", "面对未知异象仍敢继续前探。"),
build_attribute_slot("axis_e", format!("{prefix}"), "与人、物、誓约、地方关系建立牵引的能力。", ["协同", "守诺"], ["疏离", "失信"], "借同伴协同与牵制形成连锁。", "安抚、结盟、交换与维系信任。", "从人情、传闻和旧物中打开线索。"),
build_attribute_slot("axis_f", format!("{prefix_alt}"), "在长线消耗和局势反复中回稳节奏的能力。", ["回稳", "续航"], ["紊乱", "断续"], "久战不乱,把节奏重新拉回手里。", "情绪稳定,不轻易被带偏。", "在漫长探索与恶劣环境里保有余力。"),
build_attribute_slot("axis_a", format!("{prefix}")),
build_attribute_slot("axis_b", format!("{prefix_alt}")),
build_attribute_slot("axis_c", format!("{prefix}")),
build_attribute_slot("axis_d", format!("{prefix_alt}")),
build_attribute_slot("axis_e", format!("{prefix}")),
build_attribute_slot("axis_f", format!("{prefix_alt}")),
],
})
}
fn build_attribute_slot(
slot_id: &str,
name: String,
definition: impl Into<String>,
positive_signals: [&str; 2],
negative_signals: [&str; 2],
combat_use_text: &str,
social_use_text: &str,
exploration_use_text: &str,
) -> JsonValue {
fn build_attribute_slot(slot_id: &str, name: String) -> JsonValue {
json!({
"slotId": slot_id,
"name": name,
"definition": definition.into(),
"positiveSignals": positive_signals,
"negativeSignals": negative_signals,
"combatUseText": combat_use_text,
"socialUseText": social_use_text,
"explorationUseText": exploration_use_text,
})
}
@@ -1008,20 +971,6 @@ fn build_attribute_schema_id(framework: &JsonValue, setting_text: &str) -> Strin
)
}
fn build_attribute_schema_name(framework: &JsonValue, setting_text: &str) -> String {
let source = [
framework_world_name(framework, setting_text),
json_text(framework, "summary").unwrap_or_default(),
json_text(framework, "tone").unwrap_or_default(),
]
.join("");
let terms = collect_attribute_theme_terms(source.as_str());
format!(
"{}六维",
terms.first().cloned().unwrap_or_else(|| "叙境".to_string())
)
}
fn collect_attribute_theme_terms(source: &str) -> Vec<String> {
let mut terms = Vec::new();
let chinese_chars = source
@@ -1062,12 +1011,6 @@ fn is_invalid_attribute_name(name: &str, seen_names: &[String]) -> bool {
.any(|banned| trimmed.contains(banned))
}
fn is_invalid_attribute_schema_name(name: &str) -> bool {
BANNED_ATTRIBUTE_NAMES
.iter()
.any(|banned| name.trim().contains(banned))
}
fn json_map_text(map: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
map.get(key)
.and_then(JsonValue::as_str)
@@ -1076,18 +1019,6 @@ fn json_map_text(map: &JsonMap<String, JsonValue>, key: &str) -> Option<String>
.map(ToOwned::to_owned)
}
fn json_map_string_array(map: &JsonMap<String, JsonValue>, key: &str) -> Option<Vec<String>> {
let items = map
.get(key)?
.as_array()?
.iter()
.filter_map(|entry| entry.as_str().map(str::trim))
.filter(|entry| !entry.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if items.is_empty() { None } else { Some(items) }
}
fn first_json_string(value: &JsonValue, key: &str) -> Option<String> {
value
.get(key)
@@ -2492,7 +2423,7 @@ mod tests {
request_capture.clone(),
vec![
llm_response(
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"schemaName":"雾港六维","slots":[{"slotId":"axis_a","name":"灯骨","definition":"承受封航压力与潮湿险境的底子。","positiveSignals":["承压"],"negativeSignals":["虚浮"],"combatUseText":"顶住正面压迫。","socialUseText":"在质问中稳住姿态。","explorationUseText":"穿过潮湿险境。"},{"slotId":"axis_b","name":"潮步","definition":"顺潮换位与穿行的能力。","positiveSignals":["轻快"],"negativeSignals":["迟滞"],"combatUseText":"切线换位。","socialUseText":"顺势调整说法。","explorationUseText":"穿越雾港通路。"},{"slotId":"axis_c","name":"灯识","definition":"辨认灯号和旧档错页的能力。","positiveSignals":["辨伪"],"negativeSignals":["误读"],"combatUseText":"看破破绽。","socialUseText":"听出遮掩。","explorationUseText":"辨认旧档线索。"},{"slotId":"axis_d","name":"雾魄","definition":"在海雾和旧案压力中推进的胆气。","positiveSignals":["果断"],"negativeSignals":["退缩"],"combatUseText":"压上突破口。","socialUseText":"在对峙中定调。","explorationUseText":"敢进陌生雾区。"},{"slotId":"axis_e","name":"旧约","definition":"维系旧友、信物与地方关系的能力。","positiveSignals":["守诺"],"negativeSignals":["疏离"],"combatUseText":"借同伴协同。","socialUseText":"建立信任交换。","explorationUseText":"从人情旧物找线索。"},{"slotId":"axis_f","name":"回澜","definition":"长线消耗中回稳节奏的能力。","positiveSignals":["回稳"],"negativeSignals":["紊乱"],"combatUseText":"久战不乱。","socialUseText":"不被情绪带偏。","explorationUseText":"远行中保有余力。"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"slots":[{"name":"灯骨"},{"name":"潮步"},{"name":"灯识"},{"name":"雾魄"},{"name":"旧约"},{"name":"回澜"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
),
llm_response(
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
@@ -2595,6 +2526,16 @@ mod tests {
.and_then(JsonValue::as_str),
Some("灯骨")
);
assert_eq!(
draft_profile
.get("attributeSchema")
.and_then(|schema| schema.get("slots"))
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(JsonValue::as_object)
.map(|entry| entry.contains_key("definition")),
Some(false)
);
assert!(
draft_profile
.get("worldHook")

View File

@@ -21,14 +21,13 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
" \"attributeSchema\": {".to_string(),
" \"schemaName\": \"本世界六维名称\",".to_string(),
" \"slots\": [".to_string(),
" { \"slotId\": \"axis_a\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_b\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_c\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_d\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_e\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_f\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" }".to_string(),
" { \"name\": \"维度名\" },".to_string(),
" { \"name\": \"维度名\" },".to_string(),
" { \"name\": \"维度名\" },".to_string(),
" { \"name\": \"维度名\" },".to_string(),
" { \"name\": \"维度名\" },".to_string(),
" { \"name\": \"维度名\" }".to_string(),
" ]".to_string(),
" },".to_string(),
" \"camp\": {".to_string(),
@@ -45,9 +44,9 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
"- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- attributeSchema 必须是本世界专属的角色六维属性体系slots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f,维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
"- attributeSchema 必须是本世界专属的角色六维名称体系slots 必须恰好 6 个,每个 slot 只输出 name,维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
"- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。".to_string(),
"- 每个属性维度definition都要像RPG游戏属性名同时能服务战斗、社交、探索三种场景definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题".to_string(),
"- 不要在 attributeSchema.slots 内输出 definition、positiveSignals、negativeSignals、combatUseText、socialUseText、explorationUseText 或其他说明字段".to_string(),
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
"- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
@@ -61,7 +60,7 @@ pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &st
"顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。",
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。",
"attributeSchema 必须是对象,且包含 schemaName 与 slotsslots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f",
"attributeSchema 必须是对象,且包含 slotsslots 必须恰好 6 个,每个 slot 只保留 name",
"camp 必须是对象且只包含name、description。",
"原始文本:",
response_text.trim(),

View File

@@ -678,22 +678,7 @@ fn resolve_runtime_story_choice_action(
2,
"你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。",
),
"camp_travel_home_scene" => {
clear_encounter_state(game_state);
Ok(StoryResolution {
action_text: resolve_action_text("返回营地", request),
result_text: "你主动结束了当前遭遇,把节奏带回了更安全的营地。".to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
],
battle: None,
toast: None,
})
}
"camp_travel_home_scene" => resolve_camp_travel_home_scene_action(game_state, request),
"idle_call_out" => Ok(simple_story_resolution(
game_state,
resolve_action_text("主动出声试探", request),
@@ -854,6 +839,557 @@ fn resolve_idle_travel_next_scene_action(
})
}
fn resolve_camp_travel_home_scene_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let target_scene = resolve_camp_travel_target_scene(game_state, request)
.ok_or_else(|| "无法解析离营后的目标场景".to_string())?;
let target_scene_name =
read_optional_string_field(&target_scene, "name").unwrap_or_else(|| "前方场景".to_string());
let companion_name = read_object_field(game_state, "currentEncounter")
.and_then(|encounter| {
read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
})
.unwrap_or_else(|| "同伴".to_string());
ensure_json_object(game_state).insert("currentScenePreset".to_string(), target_scene);
reset_scene_travel_runtime_state(game_state);
increment_runtime_stat(game_state, "scenesTraveled", 1);
ensure_scene_encounter_preview(game_state);
let encounter_id = read_object_field(game_state, "currentEncounter")
.and_then(|encounter| read_optional_string_field(encounter, "id"));
Ok(StoryResolution {
action_text: resolve_action_text(&format!("前往{target_scene_name}"), request),
result_text: format!(
"你和{companion_name}离开营地,正式踏入{target_scene_name},把冒险推进到新的现场。"
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged { encounter_id },
],
battle: None,
toast: None,
})
}
fn resolve_camp_travel_target_scene(
game_state: &Value,
request: &RuntimeStoryActionRequest,
) -> Option<Value> {
resolve_payload_target_scene(game_state, request)
.or_else(|| resolve_character_home_scene(game_state))
.or_else(|| resolve_current_scene_forward_scene(game_state))
.or_else(|| resolve_default_first_adventure_scene(game_state))
}
fn resolve_payload_target_scene(
game_state: &Value,
request: &RuntimeStoryActionRequest,
) -> Option<Value> {
// 中文注释:旧前端如果补传 targetSceneId后端可以接收
// 但正式主链不依赖前端,缺省时仍由服务端自行解析目标场景。
let target_scene_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "targetSceneId"))
.or_else(|| request.action.target_id.clone())?;
resolve_scene_preset_by_id(game_state, target_scene_id.as_str())
}
fn resolve_character_home_scene(game_state: &Value) -> Option<Value> {
let character_id = read_object_field(game_state, "playerCharacter")
.and_then(|character| read_optional_string_field(character, "id"));
let world_type = current_world_type(game_state);
let Some(character_id) = character_id else {
return None;
};
if world_type.as_deref() == Some("CUSTOM") {
return resolve_custom_character_home_scene(game_state, character_id.as_str());
}
let scene_id = match (character_id.as_str(), world_type.as_deref()) {
("sword-princess", Some("XIANXIA")) => "xianxia-celestial-corridor",
("sword-princess", _) => "wuxia-palace-court",
("archer-hero", Some("XIANXIA")) => "xianxia-star-vessel",
("archer-hero", _) => "wuxia-border-camp",
("girl-hero", Some("XIANXIA")) => "xianxia-waterfall-cliff",
("girl-hero", _) => "wuxia-rain-street",
("punch-hero", Some("XIANXIA")) => "xianxia-molten-realm",
("punch-hero", _) => "wuxia-forge-works",
("fighter-4", Some("XIANXIA")) => "xianxia-thunder-altar",
("fighter-4", _) => "wuxia-mountain-gate",
_ => return None,
};
resolve_builtin_scene_preset(world_type.as_deref().unwrap_or("WUXIA"), scene_id)
}
fn resolve_custom_character_home_scene(game_state: &Value, character_id: &str) -> Option<Value> {
let profile = read_object_field(game_state, "customWorldProfile")?;
let role_id = find_custom_world_role_id_by_reference(profile, character_id)
.or_else(|| {
read_object_field(game_state, "playerCharacter")
.and_then(|character| read_optional_string_field(character, "name"))
.and_then(|name| find_custom_world_role_id_by_reference(profile, name.as_str()))
})
.unwrap_or_else(|| character_id.to_string());
read_array_field(profile, "landmarks")
.into_iter()
.enumerate()
.find_map(|(index, landmark)| {
read_array_field(landmark, "sceneNpcIds")
.into_iter()
.filter_map(Value::as_str)
.any(|npc_id| custom_role_references_equal(profile, npc_id, role_id.as_str()))
.then(|| {
bootstrap::build_custom_scene_preset(
profile,
format!("custom-scene-landmark-{}", index + 1).as_str(),
)
})
.flatten()
})
}
fn resolve_current_scene_forward_scene(game_state: &Value) -> Option<Value> {
let current_scene = read_object_field(game_state, "currentScenePreset")?;
let current_scene_id = read_optional_string_field(current_scene, "id");
read_optional_string_field(current_scene, "forwardSceneId")
.or_else(|| {
read_array_field(current_scene, "connectedSceneIds")
.into_iter()
.filter_map(Value::as_str)
.find(|scene_id| Some(*scene_id) != current_scene_id.as_deref())
.map(str::to_string)
})
.or_else(|| {
read_array_field(current_scene, "connections")
.into_iter()
.find_map(|connection| {
read_optional_string_field(connection, "sceneId")
.filter(|scene_id| Some(scene_id.as_str()) != current_scene_id.as_deref())
})
})
.and_then(|scene_id| resolve_scene_preset_by_id(game_state, scene_id.as_str()))
}
fn resolve_default_first_adventure_scene(game_state: &Value) -> Option<Value> {
if current_world_type(game_state).as_deref() == Some("CUSTOM") {
let profile = read_object_field(game_state, "customWorldProfile")?;
if !read_array_field(profile, "landmarks").is_empty() {
return bootstrap::build_custom_scene_preset(profile, "custom-scene-landmark-1");
}
return bootstrap::build_custom_scene_preset(profile, "custom-scene-camp");
}
resolve_builtin_scene_preset(
current_world_type(game_state).as_deref().unwrap_or("WUXIA"),
if current_world_type(game_state).as_deref() == Some("XIANXIA") {
"xianxia-cloud-gate"
} else {
"wuxia-bamboo-road"
},
)
}
fn resolve_scene_preset_by_id(game_state: &Value, scene_id: &str) -> Option<Value> {
if current_world_type(game_state).as_deref() == Some("CUSTOM") {
return read_object_field(game_state, "customWorldProfile")
.and_then(|profile| bootstrap::build_custom_scene_preset(profile, scene_id));
}
resolve_builtin_scene_preset(
current_world_type(game_state).as_deref().unwrap_or("WUXIA"),
scene_id,
)
}
fn reset_scene_travel_runtime_state(game_state: &mut Value) {
clear_encounter_state(game_state);
write_i32_field(game_state, "playerX", 0);
write_i32_field(game_state, "playerOffsetY", 0);
write_string_field(game_state, "playerFacing", "right");
write_string_field(game_state, "animationState", "idle");
write_string_field(game_state, "playerActionMode", "idle");
write_bool_field(game_state, "scrollWorld", false);
write_null_field(game_state, "lastObserveSignsSceneId");
write_null_field(game_state, "lastObserveSignsReport");
write_null_field(game_state, "currentBattleNpcId");
write_null_field(game_state, "currentNpcBattleMode");
write_null_field(game_state, "currentNpcBattleOutcome");
write_null_field(game_state, "sparReturnEncounter");
write_null_field(game_state, "sparPlayerHpBefore");
write_null_field(game_state, "sparPlayerMaxHpBefore");
write_null_field(game_state, "sparStoryHistoryBefore");
ensure_json_object(game_state).insert("activeCombatEffects".to_string(), Value::Array(vec![]));
}
fn resolve_builtin_scene_preset(world_type: &str, scene_id: &str) -> Option<Value> {
let scene = builtin_scene_definition(world_type, scene_id)?;
Some(build_builtin_scene_preset_from_definition(
world_type, scene,
))
}
fn build_builtin_scene_preset_from_definition(
world_type: &str,
scene: BuiltinSceneDefinition,
) -> Value {
let connections =
build_builtin_scene_connections(&scene.connected_scene_ids, scene.forward_scene_id);
let narrative_residues = scene
.treasure_hints
.iter()
.take(2)
.enumerate()
.map(|(index, hint)| {
json!({
"id": format!("residue:{}:{}", scene.id, index + 1),
"title": format!("{}的残痕 {}", scene.name, index + 1),
"visibleClue": hint,
"linkedFactIds": [],
"linkedThreadIds": []
})
})
.collect::<Vec<_>>();
json!({
"id": scene.id,
"name": scene.name,
"description": scene.description,
"imageSrc": "",
"worldType": world_type,
"forwardSceneId": scene.forward_scene_id,
"connectedSceneIds": scene.connected_scene_ids,
"connections": connections,
"npcs": [build_builtin_scene_npc(scene.npc_id, scene.npc_name, scene.npc_role, scene.npc_avatar, scene.npc_description)],
"treasureHints": scene.treasure_hints,
"narrativeResidues": narrative_residues
})
}
fn build_builtin_scene_connections(
connected_scene_ids: &[&str],
forward_scene_id: &str,
) -> Vec<Value> {
connected_scene_ids
.iter()
.enumerate()
.map(|(index, scene_id)| {
let relative_position = if *scene_id == forward_scene_id {
"forward"
} else if index % 2 == 0 {
"left"
} else {
"right"
};
json!({
"sceneId": scene_id,
"relativePosition": relative_position,
"summary": if relative_position == "forward" {
"沿主路继续深入前方区域"
} else {
"这里分出一条支路"
}
})
})
.collect()
}
fn build_builtin_scene_npc(
id: &str,
name: &str,
role: &str,
avatar: &str,
description: &str,
) -> Value {
json!({
"id": id,
"name": name,
"description": description,
"avatar": avatar,
"role": role,
"gender": "unknown",
"initialAffinity": 18,
"hostile": false,
"functions": ["trade", "fight", "spar", "help", "chat", "recruit", "gift"]
})
}
struct BuiltinSceneDefinition {
id: &'static str,
name: &'static str,
description: &'static str,
connected_scene_ids: Vec<&'static str>,
forward_scene_id: &'static str,
treasure_hints: Vec<&'static str>,
npc_id: &'static str,
npc_name: &'static str,
npc_role: &'static str,
npc_avatar: &'static str,
npc_description: &'static str,
}
fn builtin_scene_definition(world_type: &str, scene_id: &str) -> Option<BuiltinSceneDefinition> {
match (world_type, scene_id) {
(_, "wuxia-bamboo-road") => Some(BuiltinSceneDefinition {
id: "wuxia-bamboo-road",
name: "竹林古道",
description: "风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。",
connected_scene_ids: vec![
"wuxia-mountain-gate",
"wuxia-mist-woods",
"wuxia-ferry-bridge",
],
forward_scene_id: "wuxia-mountain-gate",
treasure_hints: vec!["竹根旁半埋的刀鞘", "倒竹间的旧药囊"],
npc_id: "wuxia-npc-bamboo-woodcutter",
npc_name: "樵夫老周",
npc_role: "樵夫",
npc_avatar: "",
npc_description: "常在竹海边缘砍柴,对附近路数和兽踪了如指掌。",
}),
(_, "wuxia-mountain-gate") => Some(BuiltinSceneDefinition {
id: "wuxia-mountain-gate",
name: "山门石阶",
description: "青石阶层层向上,旧山门半开半掩,守山人与伏兽都能藏得很稳。",
connected_scene_ids: vec![
"wuxia-temple-forecourt",
"wuxia-border-camp",
"wuxia-bamboo-road",
],
forward_scene_id: "wuxia-temple-forecourt",
treasure_hints: vec!["裂缝里的铜钥", "石狮座下遗落的令牌"],
npc_id: "wuxia-npc-gate-disciple",
npc_name: "守山弟子",
npc_role: "门派弟子",
npc_avatar: "",
npc_description: "一直盯着石阶尽头的动静,像在等某位重要来客。",
}),
(_, "wuxia-rain-street") => Some(BuiltinSceneDefinition {
id: "wuxia-rain-street",
name: "雨夜长街",
description: "长街积水映灯,屋檐下尽是藏身空隙,最易碰见追踪者与夜行客。",
connected_scene_ids: vec![
"wuxia-ferry-bridge",
"wuxia-palace-court",
"wuxia-ruined-village",
],
forward_scene_id: "wuxia-ferry-bridge",
treasure_hints: vec!["灯檐下浸湿的布包", "排水沟边翻起的账册残页"],
npc_id: "wuxia-npc-night-vendor",
npc_name: "夜灯摊主",
npc_role: "摊主",
npc_avatar: "",
npc_description: "深夜仍在街口守着灯摊,见过太多不该见的人。",
}),
(_, "wuxia-border-camp") => Some(BuiltinSceneDefinition {
id: "wuxia-border-camp",
name: "边关营地",
description: "营火与旌旗都带着风沙味,士卒、斥候和异兽都可能在这里短暂停留。",
connected_scene_ids: vec![
"wuxia-ferry-bridge",
"wuxia-mountain-gate",
"wuxia-ruined-village",
],
forward_scene_id: "wuxia-rain-street",
treasure_hints: vec!["废营帐里的箭囊", "火盆旁埋着的军需匣"],
npc_id: "wuxia-npc-quartermaster",
npc_name: "军需官",
npc_role: "营地官",
npc_avatar: "",
npc_description: "管着兵器和粮草,对各路来客始终保持戒心。",
}),
(_, "wuxia-forge-works") => Some(BuiltinSceneDefinition {
id: "wuxia-forge-works",
name: "铸坊工场",
description: "火星、铁水与重锤声混在一起,热浪里最容易引来重甲怪物与寻刀之人。",
connected_scene_ids: vec![
"wuxia-mine-depths",
"wuxia-palace-court",
"wuxia-border-camp",
],
forward_scene_id: "wuxia-palace-court",
treasure_hints: vec!["淬火池旁的铁匣", "风箱后压着的旧兵谱"],
npc_id: "wuxia-npc-blacksmith",
npc_name: "老铸匠",
npc_role: "铸匠",
npc_avatar: "",
npc_description: "看一眼兵器缺口就知道你刚从什么地方杀出来。",
}),
(_, "wuxia-palace-court") => Some(BuiltinSceneDefinition {
id: "wuxia-palace-court",
name: "宫苑内庭",
description: "回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。",
connected_scene_ids: vec![
"wuxia-forge-works",
"wuxia-rain-street",
"wuxia-crypt-passage",
],
forward_scene_id: "wuxia-rain-street",
treasure_hints: vec!["回廊暗格里的香囊", "花圃石座下的旧金牌"],
npc_id: "wuxia-npc-maid",
npc_name: "旧宫侍女",
npc_role: "宫人",
npc_avatar: "",
npc_description: "嘴上说得少,却总知道哪条回廊最近不该过去。",
}),
("XIANXIA", "xianxia-cloud-gate") => Some(BuiltinSceneDefinition {
id: "xianxia-cloud-gate",
name: "云海仙门",
description: "云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。",
connected_scene_ids: vec![
"xianxia-floating-isle",
"xianxia-celestial-corridor",
"xianxia-star-vessel",
],
forward_scene_id: "xianxia-celestial-corridor",
treasure_hints: vec!["云阶尽头的灵符匣", "门阙阴影里的玉牌"],
npc_id: "xianxia-npc-gate-attendant",
npc_name: "守门灵官",
npc_role: "门官",
npc_avatar: "",
npc_description: "站在门阙侧旁观来者,像在等一份迟迟未到的回报。",
}),
("XIANXIA", "xianxia-celestial-corridor") => Some(BuiltinSceneDefinition {
id: "xianxia-celestial-corridor",
name: "天宫长廊",
description: "廊柱之间回响着空灵风声,禁制和书妖都喜欢寄在这类高处回廊里。",
connected_scene_ids: vec![
"xianxia-cloud-gate",
"xianxia-thunder-altar",
"xianxia-ancient-ruins",
],
forward_scene_id: "xianxia-thunder-altar",
treasure_hints: vec!["廊柱暗槽里的玉简", "风铃后藏着的封签"],
npc_id: "xianxia-npc-palace-page",
npc_name: "抄经侍者",
npc_role: "侍者",
npc_avatar: "",
npc_description: "抱着卷册在廊下快步穿行,像是在躲某种会翻页的东西。",
}),
("XIANXIA", "xianxia-star-vessel") => Some(BuiltinSceneDefinition {
id: "xianxia-star-vessel",
name: "星舟甲板",
description: "甲板横在高天之上,风压和星光都很强,飞行异物最爱在这里盘旋。",
connected_scene_ids: vec![
"xianxia-thunder-altar",
"xianxia-cloud-gate",
"xianxia-floating-isle",
],
forward_scene_id: "xianxia-floating-isle",
treasure_hints: vec!["舵台后的星图匣", "甲板缝里卡着的灵罗盘"],
npc_id: "xianxia-npc-helmsman",
npc_name: "星舟舵手",
npc_role: "舵手",
npc_avatar: "",
npc_description: "守着老旧星舟的航线图,对高空中的异动异常敏感。",
}),
("XIANXIA", "xianxia-waterfall-cliff") => Some(BuiltinSceneDefinition {
id: "xianxia-waterfall-cliff",
name: "飞瀑仙崖",
description: "瀑声压住一切杂音,崖边潮气浓重,飞蝠、水灵与章影都很容易现身。",
connected_scene_ids: vec![
"xianxia-sacred-tree",
"xianxia-molten-realm",
"xianxia-floating-isle",
],
forward_scene_id: "xianxia-cloud-gate",
treasure_hints: vec!["瀑幕后闪着光的石匣", "崖边藤上挂着的护身铃"],
npc_id: "xianxia-npc-cliff-scout",
npc_name: "崖巡女修",
npc_role: "巡修",
npc_avatar: "",
npc_description: "长期在飞瀑边巡看,脚步轻得像从不曾碰到过石面。",
}),
("XIANXIA", "xianxia-molten-realm") => Some(BuiltinSceneDefinition {
id: "xianxia-molten-realm",
name: "熔岩秘境",
description: "热浪裹着赤光翻涌,附近的异章与泥灵都容易被灼气激得发狂。",
connected_scene_ids: vec![
"xianxia-thunder-altar",
"xianxia-waterfall-cliff",
"xianxia-jade-cavern",
],
forward_scene_id: "xianxia-waterfall-cliff",
treasure_hints: vec!["熔岩边冷却的矿匣", "焦岩后藏着的火纹石"],
npc_id: "xianxia-npc-fire-forger",
npc_name: "熔炉匠修",
npc_role: "炼匠",
npc_avatar: "",
npc_description: "在热浪里锻器不歇,见惯灵火失控的后果。",
}),
("XIANXIA", "xianxia-thunder-altar") => Some(BuiltinSceneDefinition {
id: "xianxia-thunder-altar",
name: "雷殿祭坛",
description: "祭坛上方雷纹未散,灵书、飞蛾与雷意余波总会把来者围在中心。",
connected_scene_ids: vec![
"xianxia-celestial-corridor",
"xianxia-molten-realm",
"xianxia-star-vessel",
],
forward_scene_id: "xianxia-star-vessel",
treasure_hints: vec!["祭坛角落的雷纹匣", "断碑背面的青铜铃"],
npc_id: "xianxia-npc-thunder-keeper",
npc_name: "祭雷守使",
npc_role: "守使",
npc_avatar: "",
npc_description: "总站在祭坛边缘看天,像在确认下一道雷会落到哪里。",
}),
_ => None,
}
}
fn find_custom_world_role_id_by_reference(profile: &Value, reference: &str) -> Option<String> {
let normalized_reference = normalize_custom_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| custom_role_aliases(role).contains(&normalized_reference))
.and_then(|role| read_optional_string_field(role, "id"))
}
fn custom_role_references_equal(profile: &Value, left: &str, right: &str) -> bool {
let left = find_custom_world_role_id_by_reference(profile, left)
.unwrap_or_else(|| left.trim().to_string());
let right = find_custom_world_role_id_by_reference(profile, right)
.unwrap_or_else(|| right.trim().to_string());
!left.trim().is_empty() && left == right
}
fn custom_role_aliases(role: &Value) -> Vec<String> {
[
read_optional_string_field(role, "id"),
read_optional_string_field(role, "name"),
read_optional_string_field(role, "title"),
]
.into_iter()
.flatten()
.map(|value| normalize_custom_role_reference(value.as_str()))
.filter(|value| !value.is_empty())
.collect()
}
fn normalize_custom_role_reference(value: &str) -> String {
value
.trim()
.to_lowercase()
.chars()
.filter(|ch| ch.is_alphanumeric())
.collect()
}
fn resolve_next_scene_preset(game_state: &Value) -> Option<Value> {
let current_scene = read_object_field(game_state, "currentScenePreset")?;
let current_scene_id = read_optional_string_field(current_scene, "id");

View File

@@ -2141,6 +2141,176 @@ async fn runtime_story_route_boundary_projects_story_engine_state() {
);
}
#[tokio::test]
async fn runtime_story_route_boundary_camp_travel_home_scene_is_server_owned() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let mut game_state = build_runtime_story_boundary_game_state_fixture();
let root = ensure_json_object(&mut game_state);
root.insert("worldType".to_string(), json!("WUXIA"));
root.insert(
"playerCharacter".to_string(),
json!({
"id": "sword-princess",
"name": "青璃",
"title": "试剑客",
"description": "准备离营的角色。",
"personality": "谨慎",
"attributes": {
"strength": 8,
"spirit": 6
},
"skills": []
}),
);
root.insert(
"currentScenePreset".to_string(),
json!({
"id": "wuxia-border-camp",
"name": "边关营地",
"description": "营火未熄。",
"imageSrc": "",
"connectedSceneIds": ["wuxia-palace-court"],
"connections": [{
"sceneId": "wuxia-palace-court",
"relativePosition": "forward",
"summary": "沿旧宫线索离营"
}],
"forwardSceneId": "wuxia-palace-court",
"treasureHints": [],
"npcs": []
}),
);
root.insert(
"currentEncounter".to_string(),
json!({
"kind": "npc",
"id": "npc-camp-companion",
"npcName": "营地同伴",
"npcDescription": "准备一起出发的同伴",
"npcAvatar": "",
"context": "营地",
"hostile": false
}),
);
root.insert(
"runtimeStats".to_string(),
json!({
"playTimeMs": 0,
"lastPlayTickAt": null,
"hostileNpcsDefeated": 0,
"questsAccepted": 0,
"itemsUsed": 0,
"scenesTraveled": 2
}),
);
seed_runtime_story_snapshot(
&state,
game_state,
Some(json!({
"text": "营地对话已经结束。",
"options": []
})),
)
.await;
let app = build_router(state);
let action_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/story/actions/resolve")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"sessionId": "runtime-main",
"clientVersion": 0,
"action": {
"type": "story_choice",
"functionId": "camp_travel_home_scene",
"payload": {
"optionText": "前往宫苑内庭"
}
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(action_response.status(), StatusCode::OK);
let action_payload: Value = serde_json::from_slice(
&action_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response should be json");
let action_state = &action_payload["data"]["snapshot"]["gameState"];
assert_eq!(
action_state["currentScenePreset"]["id"],
json!("wuxia-palace-court")
);
assert_eq!(action_state["runtimeStats"]["scenesTraveled"], json!(3));
assert_eq!(action_state["inBattle"], json!(false));
assert_eq!(action_state["npcInteractionActive"], json!(false));
assert_eq!(action_state["sceneHostileNpcs"], json!([]));
assert_eq!(
action_state["currentEncounter"]["id"],
json!("wuxia-npc-maid")
);
assert_eq!(
action_state["storyHistory"]
.as_array()
.expect("story history should be array")
.len(),
2
);
assert!(
action_payload["data"]["presentation"]["resultText"]
.as_str()
.is_some_and(|text| text.contains("宫苑内庭"))
);
let state_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/story/state/runtime-main")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(state_response.status(), StatusCode::OK);
let state_payload: Value = serde_json::from_slice(
&state_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response should be json");
assert_eq!(
state_payload["data"]["snapshot"]["gameState"]["currentScenePreset"]["id"],
json!("wuxia-palace-court")
);
assert_eq!(
state_payload["data"]["snapshot"]["gameState"]["currentEncounter"]["id"],
json!("wuxia-npc-maid")
);
}
#[test]
fn runtime_story_npc_help_is_one_shot_and_restores_resources() {
let request = RuntimeStoryActionRequest {