408 lines
13 KiB
Rust
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")
|
|
);
|
|
}
|