Files
Genarrative/server-rs/crates/module-runtime-story/src/battle_tests.rs
2026-05-22 03:14:11 +08:00

408 lines
13 KiB
Rust

use serde_json::json;
use shared_contracts::runtime_story::{
RuntimeStoryActionRequest, RuntimeStoryChoiceAction, RuntimeStoryPatch,
};
use crate::{
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 {
json!({
"inBattle": true,
"npcInteractionActive": false,
"playerHp": 4,
"playerMaxHp": 40,
"playerMana": 10,
"playerMaxMana": 10,
"playerSkillCooldowns": {},
"runtimeStats": {
"hostileNpcsDefeated": 0,
"itemsUsed": 0,
"questsAccepted": 0,
"scenesTraveled": 0,
"playTimeMs": 0,
"lastPlayTickAt": null
},
"currentNpcBattleMode": "fight",
"currentNpcBattleOutcome": null,
"currentEncounter": {
"kind": "npc",
"id": "npc_bandit_01",
"npcName": "断桥匪首",
"hostile": true,
"hp": 8,
"experienceReward": 24
},
"sceneHostileNpcs": [{
"id": "npc_bandit_01",
"name": "断桥匪首",
"hp": 8,
"maxHp": 80,
"experienceReward": 24
}]
})
}
fn build_request(function_id: &str, option_text: &str) -> RuntimeStoryActionRequest {
RuntimeStoryActionRequest {
session_id: "runtime-main".to_string(),
client_version: Some(0),
action: RuntimeStoryChoiceAction {
action_type: "story_choice".to_string(),
function_id: function_id.to_string(),
target_id: None,
payload: Some(json!({
"optionText": option_text
})),
},
}
}
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", "全力压制");
let mut game_state = build_battle_fixture();
let resolution = resolve_battle_action(&mut game_state, &request, "battle_all_in_crush")
.expect("battle action should resolve");
assert_eq!(read_i32_field(&game_state, "playerHp"), Some(0));
assert_eq!(
read_optional_string_field(&game_state, "currentNpcBattleOutcome"),
Some("fight_defeat".to_string())
);
assert_eq!(read_bool_field(&game_state, "inBattle"), Some(false));
assert!(resolution.result_text.contains("败北"));
assert!(matches!(
resolution.patches.first(),
Some(RuntimeStoryPatch::BattleResolved { outcome, .. }) if outcome == "defeat"
));
assert_eq!(
resolution.patches.get(1),
Some(&build_status_patch(&game_state))
);
assert_eq!(
resolution.battle.and_then(|battle| battle.outcome),
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")
);
}