1
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 与 slots;slots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f。",
|
||||
"attributeSchema 必须是对象,且只包含 slots;slots 必须恰好 6 个,每个 slot 只保留 name。",
|
||||
"camp 必须是对象,且只包含:name、description。",
|
||||
"原始文本:",
|
||||
response_text.trim(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user