fix: preserve rpg custom world detail profiles

This commit is contained in:
kdletters
2026-05-22 03:14:11 +08:00
parent a9d23a8a44
commit d74457faa2
19 changed files with 2726 additions and 109 deletions

View File

@@ -5,8 +5,8 @@ use shared_contracts::runtime_story::{
};
use crate::{
battle::resolve_battle_action, build_status_patch, read_bool_field, read_i32_field,
read_optional_string_field,
StoryRuntimeActionResolveInput, battle::resolve_battle_action, build_status_patch,
read_bool_field, read_i32_field, read_optional_string_field, resolve_story_runtime_action,
};
fn build_battle_fixture() -> serde_json::Value {
@@ -61,6 +61,115 @@ fn build_request(function_id: &str, option_text: &str) -> RuntimeStoryActionRequ
}
}
fn build_runtime_action_request(
function_id: &str,
action_text: &str,
payload: Option<serde_json::Value>,
) -> shared_contracts::story::ResolveStoryRuntimeActionRequest {
shared_contracts::story::ResolveStoryRuntimeActionRequest {
story_session_id: "storysess-1".to_string(),
client_version: Some(1),
function_id: function_id.to_string(),
action_text: action_text.to_string(),
target_id: None,
payload,
}
}
fn build_custom_world_profile_with_two_landmarks() -> serde_json::Value {
json!({
"id": "profile-1",
"name": "雾桥旧约",
"summary": "雾桥边的旧约正在复苏。",
"camp": {
"id": "camp-1",
"name": "雾桥营地",
"description": "营火压着雾气。",
"connections": [
{
"targetLandmarkId": "landmark-1",
"relativePosition": "forward",
"summary": "沿桥面继续前进"
},
{
"targetLandmarkId": "landmark-2",
"relativePosition": "right",
"summary": "转入雾中支路"
}
]
},
"landmarks": [
{
"id": "landmark-1",
"name": "断桥口",
"description": "桥口挂着旧灯。"
},
{
"id": "landmark-2",
"name": "雾中渡",
"description": "渡口只有潮声。"
}
],
"storyNpcs": [
{
"id": "npc-bridge",
"name": "桥影",
"description": "桥下逼来的敌影",
"initialAffinity": -20
},
{
"id": "npc-ferryman",
"name": "摆渡人",
"description": "守着雾中渡的人",
"initialAffinity": 0
}
],
"sceneChapterBlueprints": [
{
"id": "chapter-camp",
"sceneId": "camp-1",
"linkedLandmarkIds": ["camp-1"],
"acts": [
{
"id": "act-camp-1",
"sceneId": "camp-1",
"oppositeNpcId": "npc-bridge"
},
{
"id": "act-camp-2",
"sceneId": "camp-1",
"oppositeNpcId": "npc-ferryman"
}
]
},
{
"id": "chapter-landmark-1",
"sceneId": "landmark-1",
"linkedLandmarkIds": ["landmark-1"],
"acts": [
{
"id": "act-landmark-1",
"sceneId": "landmark-1",
"oppositeNpcId": "npc-ferryman"
}
]
}
]
})
}
fn build_story_runtime_snapshot(
game_state: serde_json::Value,
current_story: Option<serde_json::Value>,
) -> shared_contracts::story::StoryRuntimeSnapshotPayload {
shared_contracts::story::StoryRuntimeSnapshotPayload {
saved_at: None,
bottom_tab: "adventure".to_string(),
game_state,
current_story,
}
}
#[test]
fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() {
let request = build_request("battle_all_in_crush", "全力压制");
@@ -89,3 +198,210 @@ fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() {
Some("defeat".to_string())
);
}
#[test]
fn terminal_battle_action_persists_post_battle_continue_story() {
let mut game_state = build_battle_fixture();
game_state["runtimeSessionId"] = json!("runtime-1");
game_state["currentScene"] = json!("Story");
game_state["worldType"] = json!("CUSTOM");
game_state["playerHp"] = json!(30);
game_state["customWorldProfile"] = build_custom_world_profile_with_two_landmarks();
game_state["currentScenePreset"] = json!({
"id": "custom-scene-camp",
"name": "雾桥营地",
"description": "营火压着雾气。",
"connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"],
"forwardSceneId": "custom-scene-landmark-1",
"treasureHints": [],
"npcs": []
});
game_state["storyEngineMemory"] = json!({
"currentSceneActState": {
"sceneId": "camp-1",
"chapterId": "chapter-camp",
"currentActId": "act-camp-1",
"currentActIndex": 0,
"completedActIds": [],
"visitedActIds": ["act-camp-1"]
}
});
let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput {
story_session_id: "storysess-1".to_string(),
runtime_session_id: "runtime-1".to_string(),
snapshot: build_story_runtime_snapshot(game_state, None),
request: build_runtime_action_request("battle_all_in_crush", "全力压制", None),
})
.expect("terminal battle should resolve");
assert_eq!(
output.presentation.battle.unwrap().outcome.as_deref(),
Some("victory")
);
assert_eq!(
output.presentation.options[0].function_id,
"story_continue_adventure"
);
assert_eq!(
output.snapshot.current_story.as_ref().unwrap()["options"][0]["functionId"],
json!("story_continue_adventure")
);
assert!(
output.snapshot.current_story.as_ref().unwrap()["deferredOptions"]
.as_array()
.is_some_and(|items| {
items
.iter()
.any(|item| item["functionId"] == json!("idle_travel_next_scene"))
})
);
assert_eq!(
output.snapshot.current_story.as_ref().unwrap()["deferredRuntimeState"]["storyEngineMemory"]
["currentSceneActState"]["currentActId"],
json!("act-camp-2")
);
assert_eq!(
output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"],
json!("act-camp-2")
);
}
#[test]
fn idle_travel_next_scene_changes_scene_from_target_payload() {
let game_state = json!({
"runtimeSessionId": "runtime-1",
"runtimeActionVersion": 1,
"currentScene": "Story",
"worldType": "CUSTOM",
"customWorldProfile": build_custom_world_profile_with_two_landmarks(),
"playerHp": 30,
"playerMaxHp": 40,
"playerMana": 10,
"playerMaxMana": 20,
"playerCurrency": 0,
"playerInventory": [],
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
"runtimeStats": {
"hostileNpcsDefeated": 0,
"itemsUsed": 0,
"questsAccepted": 0,
"scenesTraveled": 0,
"playTimeMs": 0,
"lastPlayTickAt": null
},
"currentScenePreset": {
"id": "custom-scene-camp",
"name": "雾桥营地",
"description": "营火压着雾气。",
"connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"],
"connections": [
{
"sceneId": "custom-scene-landmark-1",
"relativePosition": "forward",
"summary": "沿桥面继续前进"
}
],
"forwardSceneId": "custom-scene-landmark-1",
"treasureHints": [],
"npcs": []
},
"currentEncounter": null,
"npcInteractionActive": false,
"sceneHostileNpcs": [],
"inBattle": false,
"storyHistory": [],
"storyEngineMemory": {}
});
let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput {
story_session_id: "storysess-1".to_string(),
runtime_session_id: "runtime-1".to_string(),
snapshot: build_story_runtime_snapshot(game_state, None),
request: build_runtime_action_request(
"idle_travel_next_scene",
"向前走,前往断桥口",
Some(json!({ "targetSceneId": "custom-scene-landmark-1" })),
),
})
.expect("travel action should resolve");
assert_eq!(
output.snapshot.game_state["currentScenePreset"]["id"],
json!("custom-scene-landmark-1")
);
assert_eq!(
output.snapshot.game_state["runtimeStats"]["scenesTraveled"],
json!(1)
);
assert_eq!(
output.snapshot.game_state["currentEncounter"]["id"],
json!("npc-ferryman")
);
assert_eq!(
output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"],
json!("act-landmark-1")
);
assert!(output.presentation.options.iter().any(|option| {
option.function_id == "idle_travel_next_scene"
|| option.function_id == "idle_explore_forward"
}));
}
#[test]
fn idle_travel_next_scene_normalizes_custom_landmark_id_payload() {
let game_state = json!({
"runtimeSessionId": "runtime-1",
"runtimeActionVersion": 1,
"currentScene": "Story",
"worldType": "CUSTOM",
"customWorldProfile": build_custom_world_profile_with_two_landmarks(),
"playerHp": 30,
"playerMaxHp": 40,
"playerMana": 10,
"playerMaxMana": 20,
"playerCurrency": 0,
"playerInventory": [],
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
"runtimeStats": {
"hostileNpcsDefeated": 0,
"itemsUsed": 0,
"questsAccepted": 0,
"scenesTraveled": 0,
"playTimeMs": 0,
"lastPlayTickAt": null
},
"currentScenePreset": {
"id": "custom-scene-camp",
"name": "雾桥营地",
"description": "营火压着雾气。",
"connectedSceneIds": ["landmark-1", "landmark-2"],
"forwardSceneId": "landmark-2",
"treasureHints": [],
"npcs": []
},
"currentEncounter": null,
"npcInteractionActive": false,
"sceneHostileNpcs": [],
"inBattle": false,
"storyHistory": [],
"storyEngineMemory": {}
});
let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput {
story_session_id: "storysess-1".to_string(),
runtime_session_id: "runtime-1".to_string(),
snapshot: build_story_runtime_snapshot(game_state, None),
request: build_runtime_action_request(
"idle_travel_next_scene",
"前往雾中渡",
Some(json!({ "targetSceneId": "landmark-2" })),
),
})
.expect("raw custom landmark id should resolve");
assert_eq!(
output.snapshot.game_state["currentScenePreset"]["id"],
json!("custom-scene-landmark-2")
);
}