sb-api: responses module + handler rewiring (WI-2, issue #2)

This commit is contained in:
Jan-Peter von Hunnius 2026-10-03 12:06:05 +02:00
parent 6d9f02b77f
commit 5d5a594a7a
2 changed files with 339 additions and 221 deletions

View file

@ -0,0 +1,239 @@
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Serialize)]
pub struct AdventureRunResponse {
pub combat_log: serde_json::Value,
pub enemy: String,
pub loot: Vec<String>,
pub zone: String,
}
#[derive(Debug, Serialize)]
pub struct HealResponse {
pub gold: i32,
pub healed: bool,
pub hp: i32,
pub max_hp: i32,
}
#[derive(Debug, Serialize)]
pub struct SpellEntryResponse {
pub code: String,
pub combat_damage_max: i32,
pub combat_damage_min: i32,
pub combat_effect: String,
pub description: String,
pub heal_max: i32,
pub heal_min: i32,
pub id: String,
pub known: bool,
pub name: String,
pub range: String,
pub spell_level: i32,
pub spell_points: i32,
}
#[derive(Debug, Serialize)]
pub struct SpellbookResponse {
pub caster_class: String,
pub learn_costs: BTreeMap<String, i32>,
pub max_spell_points: i32,
pub spell_level: i32,
pub spell_points: i32,
pub spells: Vec<SpellEntryResponse>,
}
#[derive(Debug, Serialize)]
pub struct ActivateSpellResponse {
pub active_spell: Option<String>,
pub spell_points: i32,
}
#[derive(Debug, Serialize)]
pub struct LearnSpellsResponse {
pub gold: i32,
pub learned_count: usize,
pub max_spell_points: i32,
pub spell_points: i32,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum LearnSpellResponse {
Activate(ActivateSpellResponse),
Learn(LearnSpellsResponse),
}
impl From<sb_core::adventure::AdventureOutcome> for AdventureRunResponse {
fn from(outcome: sb_core::adventure::AdventureOutcome) -> Self {
AdventureRunResponse {
// to_value mirrors exactly what json! does to &log today: struct →
// Value::Object with BTreeMap ordering — nested keys sorted, byte-identical wire.
combat_log: serde_json::to_value(&outcome.combat_log)
.expect("CombatLog graph contains no map types; to_value is infallible"),
enemy: outcome.enemy_name,
loot: outcome.loot_item_names,
zone: outcome.zone_id,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use sb_shared::{CombatLog, CombatRound, CritEntry, CritResult};
fn representative_log() -> CombatLog {
CombatLog {
rounds: vec![
CombatRound {
attacker: "Test".into(),
defender: "Giant Rat".into(),
raw_roll: 67,
ob: 15,
db: 0,
result: 82,
damage: 12,
crit: None,
defender_hp: 0,
},
CombatRound {
attacker: "Giant Rat".into(),
defender: "Test".into(),
raw_roll: 99,
ob: 3,
db: 5,
result: 97,
damage: 18,
crit: Some(CritResult {
severity: "A".into(),
crit: CritEntry {
name: "critical".into(),
damage_multiplier: 2.0,
stun_rounds: 1,
bleed_per_round: 2,
bleed_rounds: 3,
effects: vec!["stun".into()],
},
}),
defender_hp: 62,
},
],
winner: "Test".into(),
loser: "Giant Rat".into(),
xp_gained: 5,
gold_gained: 2,
}
}
fn representative_outcome() -> sb_core::adventure::AdventureOutcome {
sb_core::adventure::AdventureOutcome {
zone_id: "skara_brae_streets".into(),
enemy_name: "Giant Rat".into(),
combat_log: representative_log(),
loot_item_names: vec!["Iron Sword".into()],
}
}
const GOLDEN_W1: &str = r#"{"combat_log":{"gold_gained":2,"loser":"Giant Rat","rounds":[{"attacker":"Test","crit":null,"damage":12,"db":0,"defender":"Giant Rat","defender_hp":0,"ob":15,"raw_roll":67,"result":82},{"attacker":"Giant Rat","crit":{"crit":{"bleed_per_round":2,"bleed_rounds":3,"damage_multiplier":2.0,"effects":["stun"],"name":"critical","stun_rounds":1},"severity":"A"},"damage":18,"db":5,"defender":"Test","defender_hp":62,"ob":3,"raw_roll":99,"result":97}],"winner":"Test","xp_gained":5},"enemy":"Giant Rat","loot":["Iron Sword"],"zone":"skara_brae_streets"}"#;
const GOLDEN_W2: &str = r#"{"gold":123,"healed":true,"hp":45,"max_hp":80}"#;
const GOLDEN_W3: &str = r#"{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}"#;
const GOLDEN_W4: &str = r#"{"caster_class":"mage","learn_costs":{"2":1000,"3":2000,"4":4000,"5":7000,"6":10000,"7":20000},"max_spell_points":40,"spell_level":4,"spell_points":12,"spells":[{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}]}"#;
const GOLDEN_W5: &str = r#"{"active_spell":"mage_armor","spell_points":12}"#;
const GOLDEN_W5_NULL: &str = r#"{"active_spell":null,"spell_points":12}"#;
const GOLDEN_W6: &str =
r#"{"gold":19700,"learned_count":4,"max_spell_points":40,"spell_points":40}"#;
#[test]
fn wire_identity_adventure_response() {
let dto: AdventureRunResponse = representative_outcome().into();
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W1);
}
#[test]
fn wire_identity_heal_response() {
let dto = HealResponse {
gold: 123,
healed: true,
hp: 45,
max_hp: 80,
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W2);
}
fn representative_spell_entry() -> SpellEntryResponse {
SpellEntryResponse {
code: "fire_bolt".into(),
combat_damage_max: 6,
combat_damage_min: 2,
combat_effect: "damage".into(),
description: "Hurls a mote of fire at the target.".into(),
heal_max: 0,
heal_min: 0,
id: "spell_fire_bolt".into(),
known: true,
name: "Fire Bolt".into(),
range: "short".into(),
spell_level: 1,
spell_points: 10,
}
}
#[test]
fn wire_identity_spell_entry_response() {
let dto = representative_spell_entry();
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W3);
}
#[test]
fn wire_identity_spellbook_response() {
let dto = SpellbookResponse {
caster_class: "mage".into(),
learn_costs: BTreeMap::from([
("2".into(), 1000),
("3".into(), 2000),
("4".into(), 4000),
("5".into(), 7000),
("6".into(), 10000),
("7".into(), 20000),
]),
max_spell_points: 40,
spell_level: 4,
spell_points: 12,
spells: vec![representative_spell_entry()],
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W4);
}
#[test]
fn wire_identity_activate_spell_response() {
let dto = ActivateSpellResponse {
active_spell: Some("mage_armor".into()),
spell_points: 12,
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5);
let dto = ActivateSpellResponse {
active_spell: None,
spell_points: 12,
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5_NULL);
}
#[test]
fn wire_identity_learn_response() {
let dto = LearnSpellResponse::Learn(LearnSpellsResponse {
gold: 19700,
learned_count: 4,
max_spell_points: 40,
spell_points: 40,
});
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W6);
}
}