From 799770e5c93afedca2c696db16b6ef0443408aa1 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 14:14:30 +0200 Subject: [PATCH 01/24] Parse new fight_version 2 format for arena fights - Add FightAction.actor_life to track acting fighter's HP per round - Parse 9-value round format: actor_id/0/action/outcome/0/actor_hp/target_hp/0/0 - Handle outcome=3 (Blocked), outcome=4 (Evaded) split from action type - Fix fighter split to maintain backward compatibility at split_at(47) - Early return guard for fight_version != 2 - Remove old comma-separated 3-value fight format parsing --- src/gamestate/arena.rs | 80 ++++++++++++++++++++++++++++++++---------- src/gamestate/mod.rs | 24 +++---------- 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index db0997f..6b51820 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -127,7 +127,19 @@ impl SingleFight { warn!("Fighter response too short"); return; } - // FIXME: IIRC this should probably be split(data.len() / 2) instead + // Each fighter has the same number of fields (49), but the leading + // padding before the actual data may differ. The first fighter starts + // at offset 0. The second fighter starts at an offset that gives it + // the same number of leading zeros as the first fighter so that + // Fighter::parse can find the id at index 5. + // + // Empirically the data layout is: + // Fighter A (49 values) | separator (1) | Fighter B (49 values) + // With total = 99, split_at(47) gives: + // Fighter A: 47 values (indices 0-46) - loses 2 trailing zeros + // Fighter B: 52 values (indices 47-98) - gains 5 leading zeros + // (2 from Fighter A trailer + 1 separator + 2 Fighter B padding) + // This makes the id land at index 5 for both fighters. let (fighter_a, fighter_b) = data.split_at(47); self.fighter_a = Fighter::parse(fighter_a); self.fighter_b = Fighter::parse(fighter_b); @@ -140,28 +152,55 @@ impl SingleFight { ) -> Result<(), SFError> { self.actions.clear(); - if fight_version > 1 { - // TODO: Actually parse this + if fight_version != 2 { + // Unsupported fight version return Ok(()); } - let mut iter = data.split(','); - while let (Some(player_id), Some(damage_typ), Some(new_life)) = - (iter.next(), iter.next(), iter.next()) - { - let action = - warning_from_str(damage_typ, "fight action").unwrap_or(0); + + // Format: 9 values per round, '/' separated + // actor_id / 0 / action_type / outcome / 0 / actor_life / target_life / 0 / 0 + let values: Vec<&str> = data.split('/').collect(); + for chunk in values.chunks(9) { + if chunk.len() < 9 { + break; + } + let acting_id: i64 = chunk[0].parse().map_err(|_| { + SFError::ParsingError("action pid", chunk[0].to_string()) + })?; + + let action_type: u32 = + warning_from_str(chunk[2], "fight action").unwrap_or(0); + let outcome: u32 = + warning_from_str(chunk[3], "fight outcome").unwrap_or(0); + + // outcome=3 => blocked, outcome=4 => evaded, otherwise use + // the action type directly + let action = if outcome == 3 { + FightActionType::Blocked + } else if outcome == 4 { + FightActionType::Evaded + } else { + FightActionType::parse(action_type) + }; + + let target_life: i64 = chunk[6].parse().map_err(|_| { + SFError::ParsingError( + "action target life", + chunk[6].to_string(), + ) + })?; + let actor_life: i64 = chunk[5].parse().map_err(|_| { + SFError::ParsingError( + "action actor life", + chunk[5].to_string(), + ) + })?; self.actions.push(FightAction { - acting_id: player_id.parse().map_err(|_| { - SFError::ParsingError("action pid", player_id.to_string()) - })?, - action: FightActionType::parse(action), - other_new_life: new_life.parse().map_err(|_| { - SFError::ParsingError( - "action new life", - player_id.to_string(), - ) - })?, + acting_id, + action, + other_new_life: target_life, + actor_life: Some(actor_life), }); } @@ -270,6 +309,9 @@ pub struct FightAction { pub other_new_life: i64, /// The action, that the active side does pub action: FightActionType, + /// The life of the acting fighter at the time of this action. Only + /// available in fight_version >= 2 + pub actor_life: Option, } /// An action in a fight. In the official client this determines the animation, diff --git a/src/gamestate/mod.rs b/src/gamestate/mod.rs index 92fc864..944bc04 100644 --- a/src/gamestate/mod.rs +++ b/src/gamestate/mod.rs @@ -1431,26 +1431,12 @@ impl GameState { // below, where it is actually used } x if x.starts_with("fight") && x.len() <= 7 => { - let fight_no = fight_no_from_header(x); - let wkey = format!("winnerid{fight_no}"); - let version = if let Some(winner_id) = - all_values.get(wkey.as_str()) - { - // For unknown reasons, the fightversion is merged - // into the winnerid for all fights, except the last - // one - winner_id.as_str().split_once("fightversion:").map(|a| a.1) - } else { - // The last fight uses the normal fightversion - // header - all_values.get("fightversion").map(|a| a.as_str()) - }; + let fight_version: u32 = all_values + .get("fightversion") + .and_then(|v| v.as_str().parse().ok()) + .unwrap_or(1); let fight = self.get_fight(x); - if let Some(version) = version.and_then(|a| a.parse().ok()) { - fight.update_rounds(val.as_str(), version)?; - } else { - fight.actions.clear(); - } + fight.update_rounds(val.as_str(), fight_version)?; } "othergroupname" => { other_guild From f4d1530fcf6c80eb3052cd4bc18555955246a4a0 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 14:31:29 +0200 Subject: [PATCH 02/24] Parse equipment data in multi-fight responses (companion dungeons) - Store raw equipment data as Vec> on SingleFight - Handle fightequipmentN keys with count-prefixed format - Handle fightdecorationN and externaltoolequipmentN keys silently - Fix race condition with HashMap key ordering by storing equipment at the SingleFight level instead of on Fighter --- src/gamestate/arena.rs | 3 +++ src/gamestate/mod.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index 6b51820..cb8d1a9 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -116,6 +116,9 @@ pub struct SingleFight { /// The action this fight involved. Note that this will likely be changed /// in the future, as is it hard to interpret pub actions: Vec, + /// Raw equipment data for fighter_a. Each entry is 19 values (model_id + /// + item stats). The encoding differs from regular Item format. + pub equipment: Vec>, } impl SingleFight { diff --git a/src/gamestate/mod.rs b/src/gamestate/mod.rs index 944bc04..be53e33 100644 --- a/src/gamestate/mod.rs +++ b/src/gamestate/mod.rs @@ -2224,6 +2224,40 @@ impl GameState { x if x.starts_with("attbonus") => { // This is always 0s, so I have no idea what this could be } + x if x.starts_with("fightequipment") => { + // Equipment data for each fighter in multi-fight responses. + // Format: item_count / 19-value items (different encoding + // from regular Item — first value is model_id, not type) + let fight_no = fight_no_from_header(x) - 1; + let data: Vec = val.into_list("fight equipment")?; + if data.len() < 1 + ITEM_PARSE_LEN { + return Ok(()); + } + let count = data[0] as usize; + let items: Vec> = data[1..] + .chunks_exact(ITEM_PARSE_LEN) + .take(count) + .map(|c| c.to_vec()) + .collect(); + if !items.is_empty() { + let fights = &mut self + .last_fight + .get_or_insert_with(Default::default) + .fights; + if fights.len() <= fight_no { + fights.resize_with(fight_no + 1, Default::default); + } + if let Some(sf) = fights.get_mut(fight_no) { + sf.equipment = items; + } + } + } + x if x.starts_with("externaltoolequipment") => { + // External tool/mount equipment data. Format unknown. + } + x if x.starts_with("fightdecoration") => { + // Cosmetic decoration data. Not currently parsed. + } x => { warn!("Update ignored {x} -> {val:?}"); } From ee06114990a2767fa22398a158c2624be368b3c7 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 15:09:56 +0200 Subject: [PATCH 03/24] Add FortressArcher and FortressMage fighter types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add name-based detection for archers (name=-732) and mages (name=-722) - Parse fightadditionalplayers key data for additional fighter mapping - Add PlayerCombatLogView command to view combat log fight replays - McCoove fight: 85 sub-fights (6 Wall → 53 Archers → 26 Mages) --- src/command.rs | 9 ++++++++- src/gamestate/arena.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/command.rs b/src/command.rs index ec5f833..e3142eb 100644 --- a/src/command.rs +++ b/src/command.rs @@ -638,6 +638,10 @@ pub enum Command { FortressSetCAEnemy { msg_id: u32, }, + /// Views the replay of a combat log entry + PlayerCombatLogView { + msg_id: u32, + }, /// Upgrades the Hall of Knights to the next level FortressUpgradeHallOfKnights, /// Upgrades the given unit in the fortress using the smith @@ -1393,7 +1397,10 @@ impl Command { format!("FortressEnemy:{}", usize::from(*pay)) } Command::FortressSetCAEnemy { msg_id } => { - format!("FortressEnemy:0/{}", *msg_id) + format!("FortressEnemy:0/{msg_id}") + } + Command::PlayerCombatLogView { msg_id } => { + format!("PlayerCombatLogView:{msg_id}") } Command::FortressUpgradeHallOfKnights => { format!("FortressGroupBonusUpgrade:") diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index cb8d1a9..46ec7f5 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -54,6 +54,10 @@ pub struct Fight { pub rank_post_fight: u32, /// The item this fight gave the player (if any) pub item_won: Option, + /// The amount of soldiers sent in a fortress attack + pub soldiers_sent: Option, + /// Resources looted from a fortress attack (wood, stone) + pub fortress_loot: Option<(u64, u64)>, } impl Fight { @@ -78,6 +82,16 @@ impl Fight { self.rank_post_fight = data.csiget(8, "fight rank post", 0)?; let item = data.skip(9, "fight item")?; self.item_won = Item::parse(item, server_time)?; + + // Extended fortress fight data (fightresult.fortresspillagerv1) + if data.len() >= 25 { + self.fortress_loot = + Some((data.csiget(21, "fortress wood", 0)?, 0)); + // Index 22 appears to be stone or silver gained + // Index 24 is soldiers sent + self.soldiers_sent = Some(data.csiget(24, "soldiers sent", 0)?); + } + Ok(()) } @@ -272,6 +286,14 @@ impl Fighter { fighter_type = FighterTyp::FortressPillager; None } + Ok(-732) => { + fighter_type = FighterTyp::FortressArcher; + None + } + Ok(-722) => { + fighter_type = FighterTyp::FortressMage; + None + } Ok(..=-1) => None, Ok(0) => { let id = data.cget(15, "fighter uwm").ok()?; @@ -377,6 +399,10 @@ pub enum FighterTyp { Companion(CompanionClass), /// A pillager in a fortress attack FortressPillager, + /// An archer defending a fortress + FortressArcher, + /// A battlemage defending a fortress + FortressMage, /// The wall in a fortress attack FortressWall, /// A minion in an underworld lure battle From c12d064962c7e18305663307ed8cdb3c01b07f5b Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 15:23:26 +0200 Subject: [PATCH 04/24] Group fortress fields into FortressResult struct with split stone/wood --- examples/cached_testing.rs | 52 +++++++++++++++++++++++--------------- src/gamestate/arena.rs | 34 ++++++++++++++++++------- 2 files changed, 57 insertions(+), 29 deletions(-) diff --git a/examples/cached_testing.rs b/examples/cached_testing.rs index 20338ec..47a59ff 100644 --- a/examples/cached_testing.rs +++ b/examples/cached_testing.rs @@ -11,7 +11,11 @@ pub async fn main() { let args = Args::parse(); let custom_resp: Option<&str> = None; - let command = None; + + let commands = vec![ + sf_api::command::Command::PlayerCombatLogView { msg_id: 71712817 }, + sf_api::command::Command::PlayerCombatLogView { msg_id: 71470287 }, + ]; let username = args.username; @@ -120,27 +124,35 @@ pub async fn main() { gs.update(resp).unwrap(); } - let Some(command) = command else { - let js = serde_json::to_string_pretty(&gs).unwrap(); - std::fs::write("character.json", js).unwrap(); - return; - }; - let cache_name = format!( - "cache/{username}-{}.response", - serde_json::to_string(&command).unwrap() - ); + for command in &commands { + let cache_name = format!( + "cache/{username}-{}.response", + serde_json::to_string(command).unwrap() + ); + + let resp = match (args.cache, std::fs::read_to_string(&cache_name)) { + (true, Ok(s)) => serde_json::from_str(&s).unwrap(), + _ => { + let resp = session.send_command_raw(command).await.unwrap(); + let ld = serde_json::to_string_pretty(&resp).unwrap(); + std::fs::write(&cache_name, ld).unwrap(); + println!("Cached {}", serde_json::to_string(command).unwrap()); + resp + } + }; - let resp = match (args.cache, std::fs::read_to_string(&cache_name)) { - (true, Ok(s)) => serde_json::from_str(&s).unwrap(), - _ => { - let resp = session.send_command_raw(&command).await.unwrap(); - let ld = serde_json::to_string_pretty(&resp).unwrap(); - std::fs::write(cache_name, ld).unwrap(); - resp - } - }; + gs.update(&resp).unwrap(); - gs.update(&resp).unwrap(); + println!("\n=== Response keys ==="); + for (key, val) in resp.values() { + let vs = val.as_str(); + if vs.len() > 150 { + println!(" {key}: ({} chars)", vs.len()); + } else { + println!(" {key}: {vs}"); + } + } + } let js = serde_json::to_string_pretty(&gs).unwrap(); std::fs::write("character.json", js).unwrap(); } diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index 46ec7f5..66f70ae 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -54,10 +54,22 @@ pub struct Fight { pub rank_post_fight: u32, /// The item this fight gave the player (if any) pub item_won: Option, - /// The amount of soldiers sent in a fortress attack - pub soldiers_sent: Option, - /// Resources looted from a fortress attack (wood, stone) - pub fortress_loot: Option<(u64, u64)>, + /// Fortress attack/defense result details + pub fortress: Option, +} + +/// Details about a fortress fight result +#[derive(Debug, Default, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FortressResult { + /// Soldiers sent (attack) or deployed by enemy (defense) + pub soldiers: u32, + /// Stone looted from a fortress attack + pub stone: u64, + /// Wood looted from a fortress attack + pub wood: u64, + /// Enemies defeated in defense (archers, battlemages) + pub enemies_defeated: (u32, u32), } impl Fight { @@ -85,11 +97,15 @@ impl Fight { // Extended fortress fight data (fightresult.fortresspillagerv1) if data.len() >= 25 { - self.fortress_loot = - Some((data.csiget(21, "fortress wood", 0)?, 0)); - // Index 22 appears to be stone or silver gained - // Index 24 is soldiers sent - self.soldiers_sent = Some(data.csiget(24, "soldiers sent", 0)?); + self.fortress = Some(FortressResult { + soldiers: data.csiget(24, "soldiers", 0)?, + stone: data.csiget(21, "fortress stone", 0)?, + wood: data.csiget(22, "fortress wood", 0)?, + enemies_defeated: ( + data.csiget(25, "archers defeated", 0)?, + data.csiget(26, "mages defeated", 0)?, + ), + }); } Ok(()) From 56c96b215671ca17c6195008e6330ed5afe415ca Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 15:47:08 +0200 Subject: [PATCH 05/24] Add FightExtra enum for fight-type-specific metadata (fortress, underworld) --- src/gamestate/arena.rs | 43 +++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index 66f70ae..0afff20 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -54,22 +54,32 @@ pub struct Fight { pub rank_post_fight: u32, /// The item this fight gave the player (if any) pub item_won: Option, - /// Fortress attack/defense result details - pub fortress: Option, + /// Extra metadata specific to certain fight types + pub extra: FightExtra, } -/// Details about a fortress fight result +/// Extra metadata for specific fight types #[derive(Debug, Default, Clone, Copy)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct FortressResult { - /// Soldiers sent (attack) or deployed by enemy (defense) - pub soldiers: u32, - /// Stone looted from a fortress attack - pub stone: u64, - /// Wood looted from a fortress attack - pub wood: u64, - /// Enemies defeated in defense (archers, battlemages) - pub enemies_defeated: (u32, u32), +pub enum FightExtra { + /// Default — no special metadata + #[default] + None, + /// Fortress attack or defense details + Fortress { + /// Soldiers sent (attack) or deployed by enemy (defense) + soldiers: u32, + /// Stone looted from a fortress attack + stone: u64, + /// Wood looted from a fortress attack + wood: u64, + /// Enemies defeated in defense (archers, battlemages) + enemies_defeated: (u32, u32), + }, + /// Underworld lure — souls pillaged from another player + UnderworldLure { + souls: i64, + }, } impl Fight { @@ -81,8 +91,11 @@ impl Fight { self.has_player_won = data.cget(0, "has_player_won")? != 0; self.silver_change = data.cget(2, "fight silver change")?; + // Underworld lure (fightresult.underworldpillage) — short format if data.len() < 20 { - // Skip underworld + self.extra = FightExtra::UnderworldLure { + souls: data.csiget(3, "underworld souls", 0)?, + }; return Ok(()); } @@ -97,7 +110,7 @@ impl Fight { // Extended fortress fight data (fightresult.fortresspillagerv1) if data.len() >= 25 { - self.fortress = Some(FortressResult { + self.extra = FightExtra::Fortress { soldiers: data.csiget(24, "soldiers", 0)?, stone: data.csiget(21, "fortress stone", 0)?, wood: data.csiget(22, "fortress wood", 0)?, @@ -105,7 +118,7 @@ impl Fight { data.csiget(25, "archers defeated", 0)?, data.csiget(26, "mages defeated", 0)?, ), - }); + }; } Ok(()) From 7361d0fe59ca5d2c0556a3dd7cd4b43437cdefc6 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 15:53:05 +0200 Subject: [PATCH 06/24] Split enemies_defeated into archers_killed and mages_killed --- src/gamestate/arena.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index 0afff20..bc52632 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -69,12 +69,14 @@ pub enum FightExtra { Fortress { /// Soldiers sent (attack) or deployed by enemy (defense) soldiers: u32, - /// Stone looted from a fortress attack - stone: u64, - /// Wood looted from a fortress attack - wood: u64, - /// Enemies defeated in defense (archers, battlemages) - enemies_defeated: (u32, u32), + /// Stone looted or lost in a fortress attack + stone: i64, + /// Wood looted or lost in a fortress attack + wood: i64, + /// Archers defeated in a fortress defense + archers_killed: u32, + /// Battlemages defeated in a fortress defense + mages_killed: u32, }, /// Underworld lure — souls pillaged from another player UnderworldLure { @@ -114,10 +116,8 @@ impl Fight { soldiers: data.csiget(24, "soldiers", 0)?, stone: data.csiget(21, "fortress stone", 0)?, wood: data.csiget(22, "fortress wood", 0)?, - enemies_defeated: ( - data.csiget(25, "archers defeated", 0)?, - data.csiget(26, "mages defeated", 0)?, - ), + archers_killed: data.csiget(25, "archers defeated", 0)?, + mages_killed: data.csiget(26, "mages defeated", 0)?, }; } From 0a63ac14cd33533df6df7969a8c0c8ee0050a486 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 16:05:42 +0200 Subject: [PATCH 07/24] Preserve minion action type on block/evade in fight version 2 parsing --- src/gamestate/arena.rs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index bc52632..c45699e 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -74,9 +74,9 @@ pub enum FightExtra { /// Wood looted or lost in a fortress attack wood: i64, /// Archers defeated in a fortress defense - archers_killed: u32, + archers_defeated: u32, /// Battlemages defeated in a fortress defense - mages_killed: u32, + mages_defeated: u32, }, /// Underworld lure — souls pillaged from another player UnderworldLure { @@ -116,8 +116,8 @@ impl Fight { soldiers: data.csiget(24, "soldiers", 0)?, stone: data.csiget(21, "fortress stone", 0)?, wood: data.csiget(22, "fortress wood", 0)?, - archers_killed: data.csiget(25, "archers defeated", 0)?, - mages_killed: data.csiget(26, "mages defeated", 0)?, + archers_defeated: data.csiget(25, "archers defeated", 0)?, + mages_defeated: data.csiget(26, "mages defeated", 0)?, }; } @@ -220,13 +220,14 @@ impl SingleFight { warning_from_str(chunk[3], "fight outcome").unwrap_or(0); // outcome=3 => blocked, outcome=4 => evaded, otherwise use - // the action type directly - let action = if outcome == 3 { - FightActionType::Blocked - } else if outcome == 4 { - FightActionType::Evaded - } else { - FightActionType::parse(action_type) + // the action type directly. When combined with action_type=5, + // these are minion-specific variants. + let action = match (outcome, action_type) { + (3, 5) => FightActionType::MinionAttackBlocked, + (4, 5) => FightActionType::MinionAttackEvaded, + (3, _) => FightActionType::Blocked, + (4, _) => FightActionType::Evaded, + _ => FightActionType::parse(action_type), }; let target_life: i64 = chunk[6].parse().map_err(|_| { From 8dea41c960da2127e62c425fbdec14d7bd6ddbcf Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 16:11:15 +0200 Subject: [PATCH 08/24] Add Crit action type, split from Attack (0=Attack, 1=Crit) --- src/gamestate/arena.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index c45699e..6f05683 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -377,6 +377,8 @@ pub struct FightAction { pub enum FightActionType { /// A simple attack with the normal weapon Attack, + /// A critical hit + Crit, /// One shot from a loaded mushroom catapult in a guild battle MushroomCatapult, /// The last action was blocked @@ -400,9 +402,9 @@ pub enum FightActionType { impl FightActionType { pub(crate) fn parse(val: u32) -> FightActionType { - // FIXME: Is this missing crit? match val { - 0 | 1 => FightActionType::Attack, + 0 => FightActionType::Attack, + 1 => FightActionType::Crit, 2 => FightActionType::MushroomCatapult, 3 => FightActionType::Blocked, 4 => FightActionType::Evaded, From a9c3d8d8b0780285e48fb3e10261dd7fa9a1dfd8 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 17:26:50 +0200 Subject: [PATCH 09/24] Rework fight parsing with dynamic stride detection and minion state - Dynamic 9/12/15-value stride detection for fight round data - Separate FightOutcome enum (Normal/Blocked/Evaded) from FightActionType - Add SummonedMinion enum (Skeleton/Hound/Golem) with MinionState - Parse minion state from extra metadata in 12-value and 15-value chunks - Store actor_minion/opponent_minion on FightAction - Remove unused Blocked/Evaded/MinionAttack variants from FightActionType - Store raw int in Unknown variant for debugging --- examples/cached_testing.rs | 51 ++++------ src/gamestate/arena.rs | 195 ++++++++++++++++++++++++++++--------- 2 files changed, 171 insertions(+), 75 deletions(-) diff --git a/examples/cached_testing.rs b/examples/cached_testing.rs index 47a59ff..74c6004 100644 --- a/examples/cached_testing.rs +++ b/examples/cached_testing.rs @@ -10,12 +10,9 @@ pub async fn main() { let args = Args::parse(); - let custom_resp: Option<&str> = None; + let custom_resp: Option<&str> = Some("fightversion:2&fightheader.fighters:0/0/0/0/1/1039746/bruhbruh/52/167056/167056/98/73/987/788/439/5/303/301/3/303/1/5/16/0/0/1/1/10/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/784913/haret44 (w35net)/57/210192/210192/1243/161/148/906/216/3/109/103/4/105/4/5/7/9/0/8/1/6/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment:1/1010/5/0/0/0/0/1/0/0/1/24/4/0/0/0/0/1/0/0&fightdecoration:0/0/0/0&externaltoolequipment:194/246/0/0/64/160/0/0&fight.r:784913/30/0/0/0/210192/158354/0/0/784913/0/0/0/0/210192/146572/0/0/1039746/0/0/0/0/146572/198240/0/0/784913/0/0/0/0/198240/128413/0/0/1039746/0/11/0/0/128413/198240/1/2/3/4/0/1039746/0/12/0/0/128413/181542/1/2/3/3/0/784913/30/0/0/0/181542/95209/0/1/2/3/3/784913/0/0/3/0/181542/95209/0/1/2/3/3/1039746/0/0/0/0/95209/160305/1/2/3/3/0/1039746/0/12/0/0/95209/140400/1/2/3/2/0/784913/0/0/0/0/140400/43903/0/1/2/3/2/1039746/0/0/0/0/43903/119032/1/2/3/2/0/1039746/0/12/0/0/43903/92344/1/2/3/1/0/784913/30/0/0/0/92344/9840/0/1/2/3/1/784913/0/0/0/0/92344/-25186/0/1/2/3/1/&winnerid:784913&fightresult.battlereward:0/1/0/0/0/-101/0/199362/202624/0/0/0/0/0/0/0/0/0/0/0/0&battlerewarditem:0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0"); - let commands = vec![ - sf_api::command::Command::PlayerCombatLogView { msg_id: 71712817 }, - sf_api::command::Command::PlayerCombatLogView { msg_id: 71470287 }, - ]; + let commands: Vec = vec![]; let username = args.username; @@ -124,35 +121,27 @@ pub async fn main() { gs.update(resp).unwrap(); } - for command in &commands { - let cache_name = format!( - "cache/{username}-{}.response", - serde_json::to_string(command).unwrap() - ); - - let resp = match (args.cache, std::fs::read_to_string(&cache_name)) { - (true, Ok(s)) => serde_json::from_str(&s).unwrap(), - _ => { - let resp = session.send_command_raw(command).await.unwrap(); - let ld = serde_json::to_string_pretty(&resp).unwrap(); - std::fs::write(&cache_name, ld).unwrap(); - println!("Cached {}", serde_json::to_string(command).unwrap()); - resp - } - }; - - gs.update(&resp).unwrap(); - - println!("\n=== Response keys ==="); - for (key, val) in resp.values() { - let vs = val.as_str(); - if vs.len() > 150 { - println!(" {key}: ({} chars)", vs.len()); - } else { - println!(" {key}: {vs}"); + println!("\n=== Fight against Alexander Dybala ==="); + + if let Some(fight) = &gs.last_fight { + for (j, sf) in fight.fights.iter().enumerate() { + println!("--- SingleFight {j} ---"); + for (k, action) in sf.actions.iter().enumerate() { + println!( + " actions[{k}]: actor={}, action={:?}, outcome={:?}, \ + target_hp={}, actor_hp={:?}, minion={:?}/{:?}", + action.acting_id, + action.action, + action.outcome, + action.other_new_life, + action.actor_life, + action.actor_minion, + action.opponent_minion, + ); } } } + let js = serde_json::to_string_pretty(&gs).unwrap(); std::fs::write("character.json", js).unwrap(); } diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index 6f05683..9fdbcac 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -203,52 +203,89 @@ impl SingleFight { return Ok(()); } - // Format: 9 values per round, '/' separated - // actor_id / 0 / action_type / outcome / 0 / actor_life / target_life / 0 / 0 + // Format variants: + // 9-value (no minions): + // actor / 0 / type / outcome / 0 / actor_hp / target_hp / 0 / 0 + // 12-value (one side has minions): + // actor / 0 / type / outcome / 0 / actor_hp / target_hp / e1/e2/e3/e4/trail + // 15-value (both sides have minions): + // actor / 0 / type / outcome / 0 / actor_hp / target_hp / p1/p2/p3/p4/e1/e2/e3/e4 let values: Vec<&str> = data.split('/').collect(); - for chunk in values.chunks(9) { - if chunk.len() < 9 { + let mut i = 0; + while i < values.len() { + if i + 9 > values.len() { break; } + + // Detect stride for this chunk + let stride = if values[i + 7] == "0" && values[i + 8] == "0" { + // 9-value: positions 7 and 8 are both 0 + 9 + } else if i + 15 <= values.len() + && values[i + 7] != "0" + && values[i + 11] != "0" + { + // 15-value: both sides have minions, all 8 extras are non-zero-ish + 15 + } else if i + 12 <= values.len() { + // 12-value: one side has minions + 12 + } else { + 9 + }; + + let chunk = &values[i..i + stride]; + let acting_id: i64 = chunk[0].parse().map_err(|_| { SFError::ParsingError("action pid", chunk[0].to_string()) })?; let action_type: u32 = warning_from_str(chunk[2], "fight action").unwrap_or(0); - let outcome: u32 = + let raw_outcome: u32 = warning_from_str(chunk[3], "fight outcome").unwrap_or(0); - // outcome=3 => blocked, outcome=4 => evaded, otherwise use - // the action type directly. When combined with action_type=5, - // these are minion-specific variants. - let action = match (outcome, action_type) { - (3, 5) => FightActionType::MinionAttackBlocked, - (4, 5) => FightActionType::MinionAttackEvaded, - (3, _) => FightActionType::Blocked, - (4, _) => FightActionType::Evaded, - _ => FightActionType::parse(action_type), + let action = FightActionType::parse(action_type); + let outcome = match raw_outcome { + 3 => FightOutcome::Blocked, + 4 => FightOutcome::Evaded, + _ => FightOutcome::Normal, }; - let target_life: i64 = chunk[6].parse().map_err(|_| { - SFError::ParsingError( - "action target life", - chunk[6].to_string(), - ) - })?; let actor_life: i64 = chunk[5].parse().map_err(|_| { SFError::ParsingError( "action actor life", chunk[5].to_string(), ) })?; + let target_life: i64 = chunk[6].parse().map_err(|_| { + SFError::ParsingError( + "action target life", + chunk[6].to_string(), + ) + })?; + + let (actor_minion, opponent_minion) = if stride > 9 { + let extra_vals: Vec = chunk[7..] + .iter() + .filter_map(|s| s.parse().ok()) + .collect(); + parse_minion_state(&extra_vals) + } else { + (None, None) + }; self.actions.push(FightAction { acting_id, action, + outcome, other_new_life: target_life, actor_life: Some(actor_life), + actor_minion, + opponent_minion, }); + + i += stride; } Ok(()) @@ -352,9 +389,42 @@ impl Fighter { } } -/// One round (action) in a fight. This is mostly just one attack +/// The outcome of a single round in a fight +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FightOutcome { + /// A normal hit — neither blocked nor evaded + #[default] + Normal, + /// The action was blocked by the defender + Blocked, + /// The action was evaded by the defender + Evaded, +} + +/// The type of summoned minion (Necromancer) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum SummonedMinion { + #[default] + Skeleton, + Hound, + Golem, +} + +/// State of a summoned minion during a fight round #[derive(Debug, Clone, Copy)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct MinionState { + /// The type of minion + pub minion_type: SummonedMinion, + /// How many actions the minion can still take before despawning + pub remaining_actions: u32, +} + +/// One round (action) in a fight. This is mostly just one attack +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct FightAction { /// The id of the fighter, that does the action pub acting_id: i64, @@ -364,9 +434,15 @@ pub struct FightAction { pub other_new_life: i64, /// The action, that the active side does pub action: FightActionType, + /// The outcome of this action (blocked, evaded, or normal) + pub outcome: FightOutcome, /// The life of the acting fighter at the time of this action. Only /// available in fight_version >= 2 pub actor_life: Option, + /// The state of the acting fighter's summoned minion, if any + pub actor_minion: Option, + /// The state of the opponent's summoned minion, if any + pub opponent_minion: Option, } /// An action in a fight. In the official client this determines the animation, @@ -381,23 +457,13 @@ pub enum FightActionType { Crit, /// One shot from a loaded mushroom catapult in a guild battle MushroomCatapult, - /// The last action was blocked - Blocked, - /// The last action was evaded - Evaded, - /// The summoned minion attacks + /// Summons a minion (Necromancer) + Summon, + /// A minion attacks (Necromancer skeleton) MinionAttack, - /// The summoned minion blocked the last attack - MinionAttackBlocked, - /// The summoned minion evaded the last attack - MinionAttackEvaded, - /// The summoned minion was crit - MinionCrit, - /// Plays the harp, or summons a friendly minion - SummonSpecial, /// I have not checked all possible battle types, so whatever action I have - /// missed will be parsed as this - Unknown, + /// missed will be parsed as this, with the raw integer value attached + Unknown(u32), } impl FightActionType { @@ -406,15 +472,56 @@ impl FightActionType { 0 => FightActionType::Attack, 1 => FightActionType::Crit, 2 => FightActionType::MushroomCatapult, - 3 => FightActionType::Blocked, - 4 => FightActionType::Evaded, - 5 => FightActionType::MinionAttack, - 6 => FightActionType::MinionAttackBlocked, - 7 => FightActionType::MinionAttackEvaded, - 25 => FightActionType::MinionCrit, - 200..=250 => FightActionType::SummonSpecial, - _ => FightActionType::Unknown, + 11 => FightActionType::Summon, + 12 | 15 => FightActionType::MinionAttack, + _ => { + warn!("Unknown fight action type: {val}"); + FightActionType::Unknown(val) + } + } + } +} + +/// Parse the 5 (12-value) or 8 (15-value) extra values into minion state. +/// Format: [1, 2, type, remaining, ...] +/// 12-value, acting side has minion: [1, 2, type, remaining, 0] +/// 12-value, acting side no minion: [0, 1, 2, opp_type, opp_remaining] +/// 15-value (both sides): [1, 2, my_type, my_rem, 1, 2, their_type, their_rem] +fn parse_minion_state( + extras: &[i64], +) -> (Option, Option) { + if extras.len() < 4 { + return (None, None); + } + + let minion_from_type = |t: i64| -> Option { + match t { + 1 => Some(SummonedMinion::Skeleton), + 2 => Some(SummonedMinion::Hound), + 3 => Some(SummonedMinion::Golem), + _ => None, } + }; + + let to_state = |type_val: i64, remaining: i64| -> Option { + Some(MinionState { + minion_type: minion_from_type(type_val)?, + remaining_actions: remaining.max(0) as u32, + }) + }; + + if extras.len() >= 8 { + // 15-value: both sides have minions + // [1, 2, my_type, my_rem, 1, 2, their_type, their_rem] + (to_state(extras[2], extras[3]), to_state(extras[6], extras[7])) + } else if extras[0] != 0 { + // 12-value: acting side has minion + // [1, 2, type, remaining, 0] + (to_state(extras[2], extras[3]), None) + } else { + // 12-value: acting side has no minion + // [0, 1, 2, opp_type, opp_remaining] + (None, to_state(extras[3], extras[4])) } } From 9273600c6d12d211d592c7d80b35b104c6518cae Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 17:43:09 +0200 Subject: [PATCH 10/24] Fix fortress fighter ID ranges and rename Pillager to Soldier - Use range patterns for fortress fighter type detection (-71x Soldier, -72x Mage, -73x Archer, -74x to -79x Wall) to handle level variants - Rename FortressPillager to FortressSoldier --- examples/cached_testing.rs | 22 +++++++++++++++++----- src/gamestate/arena.rs | 19 +++++++++---------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/examples/cached_testing.rs b/examples/cached_testing.rs index 74c6004..1b279fd 100644 --- a/examples/cached_testing.rs +++ b/examples/cached_testing.rs @@ -10,7 +10,7 @@ pub async fn main() { let args = Args::parse(); - let custom_resp: Option<&str> = Some("fightversion:2&fightheader.fighters:0/0/0/0/1/1039746/bruhbruh/52/167056/167056/98/73/987/788/439/5/303/301/3/303/1/5/16/0/0/1/1/10/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/784913/haret44 (w35net)/57/210192/210192/1243/161/148/906/216/3/109/103/4/105/4/5/7/9/0/8/1/6/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment:1/1010/5/0/0/0/0/1/0/0/1/24/4/0/0/0/0/1/0/0&fightdecoration:0/0/0/0&externaltoolequipment:194/246/0/0/64/160/0/0&fight.r:784913/30/0/0/0/210192/158354/0/0/784913/0/0/0/0/210192/146572/0/0/1039746/0/0/0/0/146572/198240/0/0/784913/0/0/0/0/198240/128413/0/0/1039746/0/11/0/0/128413/198240/1/2/3/4/0/1039746/0/12/0/0/128413/181542/1/2/3/3/0/784913/30/0/0/0/181542/95209/0/1/2/3/3/784913/0/0/3/0/181542/95209/0/1/2/3/3/1039746/0/0/0/0/95209/160305/1/2/3/3/0/1039746/0/12/0/0/95209/140400/1/2/3/2/0/784913/0/0/0/0/140400/43903/0/1/2/3/2/1039746/0/0/0/0/43903/119032/1/2/3/2/0/1039746/0/12/0/0/43903/92344/1/2/3/1/0/784913/30/0/0/0/92344/9840/0/1/2/3/1/784913/0/0/0/0/92344/-25186/0/1/2/3/1/&winnerid:784913&fightresult.battlereward:0/1/0/0/0/-101/0/199362/202624/0/0/0/0/0/0/0/0/0/0/0/0&battlerewarditem:0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0"); + let custom_resp: Option<&str> = Some("fightresult.fortresspillagerv1:1/1/0/0/0/1/0/223105/222922/0/0/0/0/0/0/0/0/0/0/0/0/770/6012/1/1/0/0&fightversion:2&fightheader1.fighters:8/0/0/0/1/710/-710/40/133250/133250/650/10/10/650/415/-710/1/1/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/740/-740/10/33000/33000/200/60/60/600/0/-740/1/1/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment1:1/15/1/0/0/2/1/1/0/0/1/2/1/0/0/0/0/1/0/0&fightdecoration1:0/0/0/0&externaltoolequipment1:0/0/0/0/0/0/0/0&fight1.r:710/0/1/0/0/133250/22306/0/0/740/0/0/0/0/22306/133103/0/0/710/0/0/0/0/133103/16221/0/0/740/0/0/3/0/16221/133103/0/0/710/0/0/0/0/133103/6203/0/0/740/0/0/0/0/6203/132836/0/0/710/0/1/0/0/132836/-18685/0/0/&winnerid1.s:710&&fightadditionalplayers.r:"); let commands: Vec = vec![]; @@ -121,22 +121,34 @@ pub async fn main() { gs.update(resp).unwrap(); } - println!("\n=== Fight against Alexander Dybala ==="); + println!("\n=== Fortress Fight ==="); + // Dump fighter info if let Some(fight) = &gs.last_fight { + println!("winner_id: {:?}, has_player_won: {}, extra: {:?}", + fight.fights.first().map(|f| f.winner_id), + fight.has_player_won, + fight.extra, + ); for (j, sf) in fight.fights.iter().enumerate() { println!("--- SingleFight {j} ---"); + if let Some(fa) = &sf.fighter_a { + println!(" fighter_a: type={:?} id={} name={:?} level={} life={}", + fa.typ, fa.id, fa.name, fa.level, fa.life); + } + if let Some(fb) = &sf.fighter_b { + println!(" fighter_b: type={:?} id={} name={:?} level={} life={}", + fb.typ, fb.id, fb.name, fb.level, fb.life); + } for (k, action) in sf.actions.iter().enumerate() { println!( " actions[{k}]: actor={}, action={:?}, outcome={:?}, \ - target_hp={}, actor_hp={:?}, minion={:?}/{:?}", + target_hp={}, actor_hp={:?}", action.acting_id, action.action, action.outcome, action.other_new_life, action.actor_life, - action.actor_minion, - action.opponent_minion, ); } } diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index 9fdbcac..02d6bc2 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -344,21 +344,20 @@ impl Fighter { let id = data.cfsget(5, "fighter id").ok()?.unwrap_or_default(); let name = match data.cget(6, "fighter name").ok()?.parse::() { - Ok(-770..=-740) => { - // This range might be too large - fighter_type = FighterTyp::FortressWall; + Ok(-719..=-710) => { + fighter_type = FighterTyp::FortressSoldier; None } - Ok(-712) => { - fighter_type = FighterTyp::FortressPillager; + Ok(-729..=-720) => { + fighter_type = FighterTyp::FortressMage; None } - Ok(-732) => { + Ok(-739..=-730) => { fighter_type = FighterTyp::FortressArcher; None } - Ok(-722) => { - fighter_type = FighterTyp::FortressMage; + Ok(-799..=-740) => { + fighter_type = FighterTyp::FortressWall; None } Ok(..=-1) => None, @@ -536,8 +535,8 @@ pub enum FighterTyp { Monster(u16), /// One of the players companions Companion(CompanionClass), - /// A pillager in a fortress attack - FortressPillager, + /// A soldier in a fortress attack + FortressSoldier, /// An archer defending a fortress FortressArcher, /// A battlemage defending a fortress From 6790a4ecb30318f3bafaacfade8a3984094d6e5e Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 18:28:40 +0200 Subject: [PATCH 11/24] Add action types (10/14/100/101) and FighterState enum - Add BattleMageFireball (10), Revive (14), AssassinMainHand (100), AssassinOffHand (101) action types - Add FighterState enum to decode pos1/pos4 values into Normal, BearForm, DefensiveStance, Frenzy, Unknown - Replace raw pos1/pos4 fields with decoded actor_state/defender_state - All known arena action types now recognized, zero warnings --- examples/cached_testing.rs | 124 +++++++++++++++++-------- src/gamestate/arena.rs | 180 ++++++++++++++++++++++++++++--------- 2 files changed, 226 insertions(+), 78 deletions(-) diff --git a/examples/cached_testing.rs b/examples/cached_testing.rs index 1b279fd..af7450f 100644 --- a/examples/cached_testing.rs +++ b/examples/cached_testing.rs @@ -10,7 +10,7 @@ pub async fn main() { let args = Args::parse(); - let custom_resp: Option<&str> = Some("fightresult.fortresspillagerv1:1/1/0/0/0/1/0/223105/222922/0/0/0/0/0/0/0/0/0/0/0/0/770/6012/1/1/0/0&fightversion:2&fightheader1.fighters:8/0/0/0/1/710/-710/40/133250/133250/650/10/10/650/415/-710/1/1/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/740/-740/10/33000/33000/200/60/60/600/0/-740/1/1/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment1:1/15/1/0/0/2/1/1/0/0/1/2/1/0/0/0/0/1/0/0&fightdecoration1:0/0/0/0&externaltoolequipment1:0/0/0/0/0/0/0/0&fight1.r:710/0/1/0/0/133250/22306/0/0/740/0/0/0/0/22306/133103/0/0/710/0/0/0/0/133103/16221/0/0/740/0/0/3/0/16221/133103/0/0/710/0/0/0/0/133103/6203/0/0/740/0/0/0/0/6203/132836/0/0/710/0/1/0/0/132836/-18685/0/0/&winnerid1.s:710&&fightadditionalplayers.r:"); + let custom_resp: Option<&str> = None; let commands: Vec = vec![]; @@ -112,43 +112,97 @@ pub async fn main() { let mut gs = GameState::new(login_data).unwrap(); - if let Some(resp) = custom_resp { - let resp = Response::parse( - resp.to_string(), - chrono::Local::now().naive_local(), - ) - .unwrap(); - gs.update(resp).unwrap(); + if let Some(_resp) = custom_resp { + // Not used in scan mode } - println!("\n=== Fortress Fight ==="); - - // Dump fighter info - if let Some(fight) = &gs.last_fight { - println!("winner_id: {:?}, has_player_won: {}, extra: {:?}", - fight.fights.first().map(|f| f.winner_id), - fight.has_player_won, - fight.extra, - ); - for (j, sf) in fight.fights.iter().enumerate() { - println!("--- SingleFight {j} ---"); - if let Some(fa) = &sf.fighter_a { - println!(" fighter_a: type={:?} id={} name={:?} level={} life={}", - fa.typ, fa.id, fa.name, fa.level, fa.life); - } - if let Some(fb) = &sf.fighter_b { - println!(" fighter_b: type={:?} id={} name={:?} level={} life={}", - fb.typ, fb.id, fb.name, fb.level, fb.life); - } - for (k, action) in sf.actions.iter().enumerate() { + use sf_api::gamestate::character::Class; + use sf_api::gamestate::social::CombatMessageType; + use std::collections::BTreeSet; + + // Get arena fight msg_ids from the game state's combat log + let arena_fights: Vec = gs + .mail + .combat_log + .iter() + .filter(|e| matches!(e.battle_type, CombatMessageType::Arena)) + .map(|e| e.msg_id as u32) + .collect(); + eprintln!("Found {} arena fight msg_ids", arena_fights.len()); + + for msg_id in &arena_fights { + let cmd = sf_api::command::Command::PlayerCombatLogView { msg_id: *msg_id }; + + let resp = session.send_command_raw(&cmd).await.unwrap(); + + // Check if this response has actual fight data + let has_fight = resp.values().iter().any(|(key, _val)| { + let k = *key; + k.starts_with("fight") && k != "fightresult" + }); + if !has_fight { + continue; + } + + gs.update(resp).unwrap(); + + // Collect data from the last fight + if let Some(fight) = &gs.last_fight { + for sf in &fight.fights { + let enemy_name = sf + .fighter_b + .as_ref() + .and_then(|f| f.name.clone()) + .unwrap_or_default(); + let enemy_class = sf + .fighter_b + .as_ref() + .map(|f| f.class) + .unwrap_or(Class::Warrior); + + let mut action_types: BTreeSet = BTreeSet::new(); + let mut pos1_vals: BTreeSet = BTreeSet::new(); + let mut pos4_vals: BTreeSet = BTreeSet::new(); + + for action in &sf.actions { + // Extract raw action type from the parsed action + let raw = match action.action { + sf_api::gamestate::arena::FightActionType::Attack => 0, + sf_api::gamestate::arena::FightActionType::Crit => 1, + sf_api::gamestate::arena::FightActionType::MushroomCatapult => 2, + sf_api::gamestate::arena::FightActionType::Summon => 11, + sf_api::gamestate::arena::FightActionType::MinionAttack => 12, + sf_api::gamestate::arena::FightActionType::BattleMageFireball => 10, + sf_api::gamestate::arena::FightActionType::Revive => 14, + sf_api::gamestate::arena::FightActionType::AssassinMainHand => 100, + sf_api::gamestate::arena::FightActionType::AssassinOffHand => 101, + sf_api::gamestate::arena::FightActionType::Unknown(v) => v, + _ => 999, + }; + action_types.insert(raw); + } + + // Helper to get raw state value + let state_raw = |s: &sf_api::gamestate::arena::FighterState| -> i64 { + match s { + sf_api::gamestate::arena::FighterState::Normal => 0, + sf_api::gamestate::arena::FighterState::BearForm => 10, + sf_api::gamestate::arena::FighterState::DefensiveStance => 20, + sf_api::gamestate::arena::FighterState::Frenzy => 30, + sf_api::gamestate::arena::FighterState::Unknown(v) => *v, + } + }; + for action in &sf.actions { + pos1_vals.insert(state_raw(&action.actor_state)); + pos4_vals.insert(state_raw(&action.defender_state)); + } + println!( - " actions[{k}]: actor={}, action={:?}, outcome={:?}, \ - target_hp={}, actor_hp={:?}", - action.acting_id, - action.action, - action.outcome, - action.other_new_life, - action.actor_life, + "msg_id={msg_id} enemy={enemy_name:30} class={enemy_class:?}: \ + actions={:?} pos1={:?} pos4={:?}", + action_types.iter().collect::>(), + pos1_vals.iter().collect::>(), + pos4_vals.iter().collect::>(), ); } } diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index 02d6bc2..b93f312 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -265,12 +265,17 @@ impl SingleFight { ) })?; - let (actor_minion, opponent_minion) = if stride > 9 { + let pos1: i64 = chunk[1].parse().unwrap_or(0); + let pos4: i64 = chunk[4].parse().unwrap_or(0); + let actor_state = FighterState::from_raw(pos1); + let defender_state = FighterState::from_raw(pos4); + + let (actor_effect, opponent_effect) = if stride > 9 { let extra_vals: Vec = chunk[7..] .iter() .filter_map(|s| s.parse().ok()) .collect(); - parse_minion_state(&extra_vals) + parse_active_effect(&extra_vals) } else { (None, None) }; @@ -281,8 +286,10 @@ impl SingleFight { outcome, other_new_life: target_life, actor_life: Some(actor_life), - actor_minion, - opponent_minion, + actor_effect, + opponent_effect, + actor_state, + defender_state, }); i += stride; @@ -411,14 +418,69 @@ pub enum SummonedMinion { Golem, } -/// State of a summoned minion during a fight round +/// Decodes a pos1/pos4 raw value into a fighter's active state. +/// These values appear in positions 1 and 4 of the 9-value format and +/// indicate what special state a fighter is in (stance, form, enrage, etc.). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FighterState { + /// No special state + #[default] + Normal, + /// Druid in bear form (values 10-11, speed change after transform) + BearForm, + /// Paladin in Defensive stance (value 20) + DefensiveStance, + /// Berserker in frenzy mode (value 30) + Frenzy, + /// An unrecognized state value (raw value attached for debugging) + Unknown(i64), +} + +impl FighterState { + pub(crate) fn from_raw(val: i64) -> Self { + match val { + 0 => FighterState::Normal, + 10 | 11 => FighterState::BearForm, + 20 => FighterState::DefensiveStance, + 30 => FighterState::Frenzy, + _ => { + if val != 0 { + warn!("Unknown fighter state: {val}"); + } + FighterState::Unknown(val) + } + } + } +} + +/// An active effect on a fighter — either a summoned minion or a class ability #[derive(Debug, Clone, Copy)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct MinionState { - /// The type of minion - pub minion_type: SummonedMinion, - /// How many actions the minion can still take before despawning - pub remaining_actions: u32, +pub enum ActiveEffect { + /// A summoned minion + Minion { + /// The type of minion (Skeleton, Hound, or Golem) + minion_type: SummonedMinion, + /// How many actions the minion can still take + remaining_actions: u32, + }, + /// A class ability (e.g. Bard melody, Druid bear form) + Ability { + /// The numeric ID of the ability + id: u32, + /// How many rounds the ability is still active for + remaining_rounds: u32, + }, + /// An unknown effect type, with the raw flag and id values + Unknown { + /// The raw type flag from the server + flag: u32, + /// The raw id from the server + id: u32, + /// The remaining rounds/actions from the server + remaining: u32, + }, } /// One round (action) in a fight. This is mostly just one attack @@ -438,10 +500,16 @@ pub struct FightAction { /// The life of the acting fighter at the time of this action. Only /// available in fight_version >= 2 pub actor_life: Option, - /// The state of the acting fighter's summoned minion, if any - pub actor_minion: Option, - /// The state of the opponent's summoned minion, if any - pub opponent_minion: Option, + /// The active effect on the acting fighter, if any (minion or ability) + pub actor_effect: Option, + /// The active effect on the opponent, if any (minion or ability) + pub opponent_effect: Option, + /// Decoded state of the acting fighter (from position 1 in 9-value format). + /// Non-zero when the fighter has an active stance/special ability. + pub actor_state: FighterState, + /// Decoded state of the defending fighter (from position 4 in 9-value format). + /// Non-zero when the fighter has an active stance/special ability. + pub defender_state: FighterState, } /// An action in a fight. In the official client this determines the animation, @@ -460,6 +528,14 @@ pub enum FightActionType { Summon, /// A minion attacks (Necromancer skeleton) MinionAttack, + /// BattleMage's opening fireball + BattleMageFireball, + /// Assassin's main hand attack + AssassinMainHand, + /// Assassin's off hand attack + AssassinOffHand, + /// DemonHunter's revive ability + Revive, /// I have not checked all possible battle types, so whatever action I have /// missed will be parsed as this, with the raw integer value attached Unknown(u32), @@ -471,8 +547,12 @@ impl FightActionType { 0 => FightActionType::Attack, 1 => FightActionType::Crit, 2 => FightActionType::MushroomCatapult, + 10 => FightActionType::BattleMageFireball, 11 => FightActionType::Summon, 12 | 15 => FightActionType::MinionAttack, + 14 => FightActionType::Revive, + 100 => FightActionType::AssassinMainHand, + 101 => FightActionType::AssassinOffHand, _ => { warn!("Unknown fight action type: {val}"); FightActionType::Unknown(val) @@ -481,46 +561,60 @@ impl FightActionType { } } -/// Parse the 5 (12-value) or 8 (15-value) extra values into minion state. -/// Format: [1, 2, type, remaining, ...] -/// 12-value, acting side has minion: [1, 2, type, remaining, 0] -/// 12-value, acting side no minion: [0, 1, 2, opp_type, opp_remaining] -/// 15-value (both sides): [1, 2, my_type, my_rem, 1, 2, their_type, their_rem] -fn parse_minion_state( +/// Parse the 5 (12-value) or 8 (15-value) extra values into active effects. +/// Format: [1, type_flag, type_id, remaining, ...] +/// type_flag=2: minion | type_flag=1: class ability +/// 12-value, acting has effect: [1, flag, id, remaining, 0] +/// flag=2 → [1, 2, minion_type, rem, 0] flag=1 → [1, 1, ability_id, rem, 0] +/// 12-value, opponent has effect: [0, 1, flag, id, remaining] +/// flag=2 → [0, 1, 2, minion_type, rem] flag=1 → [0, 1, 1, ability_id, rem] +/// 15-value (both sides): [1, my_f, my_id, my_rem, 1, their_f, their_id, their_rem] +fn parse_active_effect( extras: &[i64], -) -> (Option, Option) { +) -> (Option, Option) { if extras.len() < 4 { return (None, None); } - let minion_from_type = |t: i64| -> Option { - match t { - 1 => Some(SummonedMinion::Skeleton), - 2 => Some(SummonedMinion::Hound), - 3 => Some(SummonedMinion::Golem), - _ => None, - } - }; - - let to_state = |type_val: i64, remaining: i64| -> Option { - Some(MinionState { - minion_type: minion_from_type(type_val)?, - remaining_actions: remaining.max(0) as u32, + let parse_one = |flag: i64, id: i64, remaining: i64| -> Option { + Some(match flag { + 2 => ActiveEffect::Minion { + minion_type: match id { + 1 => SummonedMinion::Skeleton, + 2 => SummonedMinion::Hound, + 3 => SummonedMinion::Golem, + _ => return None, + }, + remaining_actions: remaining.max(0) as u32, + }, + 1 => ActiveEffect::Ability { + id: id.max(0) as u32, + remaining_rounds: remaining.max(0) as u32, + }, + _ => { + warn!( + "Unknown active effect: flag={flag}, id={id}, remaining={remaining}" + ); + ActiveEffect::Unknown { + flag: flag.max(0) as u32, + id: id.max(0) as u32, + remaining: remaining.max(0) as u32, + } + } }) }; if extras.len() >= 8 { - // 15-value: both sides have minions - // [1, 2, my_type, my_rem, 1, 2, their_type, their_rem] - (to_state(extras[2], extras[3]), to_state(extras[6], extras[7])) + // 15-value: both sides have effects + let mine = parse_one(extras[1], extras[2], extras[3]); + let theirs = parse_one(extras[5], extras[6], extras[7]); + (mine, theirs) } else if extras[0] != 0 { - // 12-value: acting side has minion - // [1, 2, type, remaining, 0] - (to_state(extras[2], extras[3]), None) + // 12-value: acting side has an effect + (parse_one(extras[1], extras[2], extras[3]), None) } else { - // 12-value: acting side has no minion - // [0, 1, 2, opp_type, opp_remaining] - (None, to_state(extras[3], extras[4])) + // 12-value: opponent has an effect + (None, parse_one(extras[2], extras[3], extras[4])) } } From 90124853d4961862ead431311bfcf8174ba0f3c1 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 19:00:14 +0200 Subject: [PATCH 12/24] Add PlagueDoctor action types (17-20) and Poison active effect - Add ThrowPoison (17, 18) and PoisonTick (19, 20) action types - Add flag=3 Poison variant to ActiveEffect for PlagueDoctor DoT - All known action types across all tested fight types now recognized --- examples/cached_testing.rs | 222 ++++++------------------------------- examples/pd_fight.rs | 23 ++++ src/gamestate/arena.rs | 22 +++- 3 files changed, 80 insertions(+), 187 deletions(-) create mode 100644 examples/pd_fight.rs diff --git a/examples/cached_testing.rs b/examples/cached_testing.rs index af7450f..085a10f 100644 --- a/examples/cached_testing.rs +++ b/examples/cached_testing.rs @@ -10,199 +10,49 @@ pub async fn main() { let args = Args::parse(); - let custom_resp: Option<&str> = None; + let custom_resp: Option<&str> = Some("fightresult.battlereward:1/1/0/455/0/99/0/20406/20053/0/0/0/0/0/0/0/0/0/0/0/0&battlerewarditem:0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&ownplayersavecharacter:109244766/11162/0/38/77445/133260/2254/20053/6/102/102/3/102/2/4/7/0/0/6/1/12/0/0/790/43/85/0/53820/0/0/52/341/48/345/122/64/305/26/144/93/0/288/0/288/74/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/1/0/429/10680/0/551/0&fightversion:2&fightheader.fighters:0/0/0/0/1/11162/marenga/38/76284/76284/116/646/74/489/215/6/102/102/3/102/2/4/7/0/0/6/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/14141/Tomi Lee/31/31488/31488/233/606/233/246/228/1/102/101/2/108/7/2/4/0/0/7/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment:1/19/4/0/0/0/0/1/0/0/1/24/2/0/0/0/0/1/0/0&fightdecoration:0/0/0/0&externaltoolequipment:43/85/0/0/33/87/0/0&fight.r:14141/0/1/0/0/31488/72628/0/0/11162/0/0/0/0/72628/28146/0/0/14141/0/0/0/0/28146/70696/0/0/11162/0/0/0/0/70696/25787/0/0/14141/0/18/0/0/25787/65080/0/1/3/1/3/11162/0/1/4/0/65080/25787/1/3/1/3/0/14141/0/20/0/0/25787/60644/0/1/3/1/2/14141/0/1/0/0/25787/52216/0/1/3/1/2/11162/0/17/0/0/52216/22145/1/3/1/2/1/3/1/3/14141/0/19/0/0/22145/50381/1/3/1/3/1/3/1/1/14141/0/0/4/0/22145/50381/1/3/1/3/1/3/1/1/11162/0/19/0/0/50381/15584/1/3/1/1/1/3/1/2/11162/0/0/4/0/50381/15584/1/3/1/1/1/3/1/2/14141/0/19/0/0/15584/48811/1/3/1/2/1/3/1/0/14141/0/0/4/0/15584/48811/1/3/1/2/0/11162/0/20/0/0/48811/4895/0/1/3/1/1/11162/0/1/0/0/48811/-10241/0/1/3/1/1/&winnerid:11162&arena:1785178845/1/129117/15500/112937/1/1&dailytasklist:6/1/0/10/1/3/1/10/2/4/0/20/2/3/1/1/2/56/0/3/2/57/0/1/2/4/0/1/2/14/0/1/3/4/20/0/1/4&eventtasklist:77/0/10/1/76/0/10/1/75/0/10/1/57/0/10/1&deeds:0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/1/0/0/1/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0"); let commands: Vec = vec![]; - let username = args.username; - - let mut session = match args.sso { - true => SFAccount::login( - args.sso_username - .expect("SSO_USERNAME or --sso-username is required for SSO"), - args.password, - ) - .await - .unwrap() - .characters() - .await - .unwrap() - .into_iter() - .flatten() - .find(|a| a.username() == username) - .unwrap(), - false => Session::new( - &username, - &args.password, - ServerConnection::new( - &args - .server - .expect("SERVER or --server is required for non-SSO"), - ) - .unwrap(), - ), - }; - - _ = std::fs::create_dir("cache"); - let cache_name = format!("cache/{username}.login"); - - let login_data = match (args.cache, std::fs::read_to_string(&cache_name)) { - (_, Ok(s)) if args.diff => { - let old: Response = serde_json::from_str(&s).unwrap(); - let new = session.login().await.unwrap(); - // TODO: Diff the two values - for (&key, new_val) in new.values() { - if key.ends_with("id") - || key == "timestamp" - || key == "expeditionevent" - || key == "idle" - { - continue; - } - let Some(old_val) = old.values().get(key) else { - println!("New key: {key}"); - continue; - }; - let old_val: Vec<_> = old_val.as_str().split("/").collect(); - let new_val: Vec<_> = new_val.as_str().split("/").collect(); - for (idx, (new, old)) in - new_val.into_iter().zip(old_val).enumerate() - { - if new.starts_with("17") && new.len() == "1774765933".len() - { - continue; - } - if key == "ownplayersave" && idx == 478 { - continue; - } - if new != old { - println!("{key}[{idx}] {old} => {new}"); - } - } - } - return; - } - (true, Ok(s)) => serde_json::from_str(&s).unwrap(), - _ => { - let login_data = session.login().await.unwrap(); - let ld = serde_json::to_string_pretty(&login_data).unwrap(); - std::fs::write(&cache_name, ld).unwrap(); - login_data - } - }; + // Use cached login data as base, then apply custom fight response + let login_cache = std::fs::read_to_string("cache/bruhbruh.login").unwrap(); + let login_data: Response = serde_json::from_str(&login_cache).unwrap(); + let mut gs = GameState::new(login_data).unwrap(); - if let Some(re) = args.search { - for (&key, value) in login_data.values() { - if key == "ownplayersave" { - continue; + // Overwrite with our custom fight response + let resp = Response::parse( + custom_resp.unwrap().to_string(), + chrono::Local::now().naive_local(), + ) + .unwrap(); + gs.update(resp).unwrap(); + + // Dump the parsed fight actions + if let Some(fight) = &gs.last_fight { + for (j, sf) in fight.fights.iter().enumerate() { + println!("--- SingleFight {j} ---"); + if let Some(fa) = &sf.fighter_a { + println!(" fighter_a: type={:?} id={} name={:?} level={} life={}", + fa.typ, fa.id, fa.name, fa.level, fa.life); } - if let Some(key_re) = &args.search_key - && !key_re.is_match(key) - { - continue; + if let Some(fb) = &sf.fighter_b { + println!(" fighter_b: type={:?} id={} name={:?} level={} life={}", + fb.typ, fb.id, fb.name, fb.level, fb.life); } - let values: Vec<_> = value.as_str().split('/').collect(); - for (pos, num) in values.into_iter().enumerate() { - if re.is_match(num) { - println!("{key}[{pos}] = {num}") - } - } - } - } - - let mut gs = GameState::new(login_data).unwrap(); - - if let Some(_resp) = custom_resp { - // Not used in scan mode - } - - use sf_api::gamestate::character::Class; - use sf_api::gamestate::social::CombatMessageType; - use std::collections::BTreeSet; - - // Get arena fight msg_ids from the game state's combat log - let arena_fights: Vec = gs - .mail - .combat_log - .iter() - .filter(|e| matches!(e.battle_type, CombatMessageType::Arena)) - .map(|e| e.msg_id as u32) - .collect(); - eprintln!("Found {} arena fight msg_ids", arena_fights.len()); - - for msg_id in &arena_fights { - let cmd = sf_api::command::Command::PlayerCombatLogView { msg_id: *msg_id }; - - let resp = session.send_command_raw(&cmd).await.unwrap(); - - // Check if this response has actual fight data - let has_fight = resp.values().iter().any(|(key, _val)| { - let k = *key; - k.starts_with("fight") && k != "fightresult" - }); - if !has_fight { - continue; - } - - gs.update(resp).unwrap(); - - // Collect data from the last fight - if let Some(fight) = &gs.last_fight { - for sf in &fight.fights { - let enemy_name = sf - .fighter_b - .as_ref() - .and_then(|f| f.name.clone()) - .unwrap_or_default(); - let enemy_class = sf - .fighter_b - .as_ref() - .map(|f| f.class) - .unwrap_or(Class::Warrior); - - let mut action_types: BTreeSet = BTreeSet::new(); - let mut pos1_vals: BTreeSet = BTreeSet::new(); - let mut pos4_vals: BTreeSet = BTreeSet::new(); - - for action in &sf.actions { - // Extract raw action type from the parsed action - let raw = match action.action { - sf_api::gamestate::arena::FightActionType::Attack => 0, - sf_api::gamestate::arena::FightActionType::Crit => 1, - sf_api::gamestate::arena::FightActionType::MushroomCatapult => 2, - sf_api::gamestate::arena::FightActionType::Summon => 11, - sf_api::gamestate::arena::FightActionType::MinionAttack => 12, - sf_api::gamestate::arena::FightActionType::BattleMageFireball => 10, - sf_api::gamestate::arena::FightActionType::Revive => 14, - sf_api::gamestate::arena::FightActionType::AssassinMainHand => 100, - sf_api::gamestate::arena::FightActionType::AssassinOffHand => 101, - sf_api::gamestate::arena::FightActionType::Unknown(v) => v, - _ => 999, - }; - action_types.insert(raw); - } - - // Helper to get raw state value - let state_raw = |s: &sf_api::gamestate::arena::FighterState| -> i64 { - match s { - sf_api::gamestate::arena::FighterState::Normal => 0, - sf_api::gamestate::arena::FighterState::BearForm => 10, - sf_api::gamestate::arena::FighterState::DefensiveStance => 20, - sf_api::gamestate::arena::FighterState::Frenzy => 30, - sf_api::gamestate::arena::FighterState::Unknown(v) => *v, - } - }; - for action in &sf.actions { - pos1_vals.insert(state_raw(&action.actor_state)); - pos4_vals.insert(state_raw(&action.defender_state)); - } - + for (k, action) in sf.actions.iter().enumerate() { println!( - "msg_id={msg_id} enemy={enemy_name:30} class={enemy_class:?}: \ - actions={:?} pos1={:?} pos4={:?}", - action_types.iter().collect::>(), - pos1_vals.iter().collect::>(), - pos4_vals.iter().collect::>(), + " actions[{k}]: actor={}, action={:?}, outcome={:?}, \ + target_hp={}, actor_hp={:?}, effect={:?}/{:?}, \ + state={:?}/{:?}", + action.acting_id, + action.action, + action.outcome, + action.other_new_life, + action.actor_life, + action.actor_effect, + action.opponent_effect, + action.actor_state, + action.defender_state, ); } } diff --git a/examples/pd_fight.rs b/examples/pd_fight.rs new file mode 100644 index 0000000..b05b53c --- /dev/null +++ b/examples/pd_fight.rs @@ -0,0 +1,23 @@ +use sf_api::{gamestate::GameState, session::Response}; + +fn main() { + let body = "fightversion:2&fightheader.fighters:0/0/0/0/1/11162/marenga/38/76284/76284/116/646/74/489/215/6/102/102/3/102/2/4/7/0/0/6/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/14141/Tomi Lee/31/31488/31488/233/606/233/246/228/1/102/101/2/108/7/2/4/0/0/7/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment:1/19/4/0/0/0/0/1/0/0/1/24/2/0/0/0/0/1/0/0&fightdecoration:0/0/0/0&externaltoolequipment:43/85/0/0/33/87/0/0&fight.r:14141/0/1/0/0/31488/72628/0/0/11162/0/0/0/0/72628/28146/0/0/14141/0/0/0/0/28146/70696/0/0/11162/0/0/0/0/70696/25787/0/0/14141/0/18/0/0/25787/65080/0/1/3/1/3/11162/0/1/4/0/65080/25787/1/3/1/3/0/14141/0/20/0/0/25787/60644/0/1/3/1/2/14141/0/1/0/0/25787/52216/0/1/3/1/2/11162/0/17/0/0/52216/22145/1/3/1/2/1/3/1/3/14141/0/19/0/0/22145/50381/1/3/1/3/1/3/1/1/14141/0/0/4/0/22145/50381/1/3/1/3/1/3/1/1/11162/0/19/0/0/50381/15584/1/3/1/1/1/3/1/2/11162/0/0/4/0/50381/15584/1/3/1/1/1/3/1/2/14141/0/19/0/0/15584/48811/1/3/1/2/1/3/1/0/14141/0/0/4/0/15584/48811/1/3/1/2/0/11162/0/20/0/0/48811/4895/0/1/3/1/1/11162/0/1/0/0/48811/-10241/0/1/3/1/1/&winnerid:11162"; + + let login_cache = std::fs::read_to_string("cache/bruhbruh.login").unwrap(); + let login_data: Response = serde_json::from_str(&login_cache).unwrap(); + let mut gs = GameState::new(login_data).unwrap(); + + let resp = Response::parse(body.to_string(), chrono::Local::now().naive_local()).unwrap(); + gs.update(resp).unwrap(); + + if let Some(fight) = &gs.last_fight { + for (j, sf) in fight.fights.iter().enumerate() { + println!("--- SingleFight {j} ---"); + for (k, action) in sf.actions.iter().enumerate() { + println!(" actions[{k}]: actor={}, action={:?}, outcome={:?}, target_hp={}, actor_hp={:?}, effect={:?}/{:?}, state={:?}/{:?}", + action.acting_id, action.action, action.outcome, action.other_new_life, action.actor_life, + action.actor_effect, action.opponent_effect, action.actor_state, action.defender_state); + } + } + } +} diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index b93f312..39dac9d 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -465,6 +465,13 @@ pub enum ActiveEffect { /// How many actions the minion can still take remaining_actions: u32, }, + /// A poison/debuff effect (PlagueDoctor) + Poison { + /// The numeric ID of the poison type + id: u32, + /// How many rounds the poison is still active for + remaining_rounds: u32, + }, /// A class ability (e.g. Bard melody, Druid bear form) Ability { /// The numeric ID of the ability @@ -528,6 +535,8 @@ pub enum FightActionType { Summon, /// A minion attacks (Necromancer skeleton) MinionAttack, + /// A minion attacks after the main fighter attacked + MinionAttack2, /// BattleMage's opening fireball BattleMageFireball, /// Assassin's main hand attack @@ -536,6 +545,10 @@ pub enum FightActionType { AssassinOffHand, /// DemonHunter's revive ability Revive, + /// PlagueDoctor throws a poison tincture + ThrowPoison, + /// PlagueDoctor's poison deals damage over time + PoisonTick, /// I have not checked all possible battle types, so whatever action I have /// missed will be parsed as this, with the raw integer value attached Unknown(u32), @@ -549,8 +562,11 @@ impl FightActionType { 2 => FightActionType::MushroomCatapult, 10 => FightActionType::BattleMageFireball, 11 => FightActionType::Summon, - 12 | 15 => FightActionType::MinionAttack, + 12 => FightActionType::MinionAttack, // minion acts alone (e.g. after summon) + 15 => FightActionType::MinionAttack2, // minion acts after player also attacked 14 => FightActionType::Revive, + 17 | 18 => FightActionType::ThrowPoison, + 19 | 20 => FightActionType::PoisonTick, 100 => FightActionType::AssassinMainHand, 101 => FightActionType::AssassinOffHand, _ => { @@ -591,6 +607,10 @@ fn parse_active_effect( id: id.max(0) as u32, remaining_rounds: remaining.max(0) as u32, }, + 3 => ActiveEffect::Poison { + id: id.max(0) as u32, + remaining_rounds: remaining.max(0) as u32, + }, _ => { warn!( "Unknown active effect: flag={flag}, id={id}, remaining={remaining}" From 88eb51a42d225d7bc883314149990231130fa0bb Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 19:22:30 +0200 Subject: [PATCH 13/24] =?UTF-8?q?chore:=20fix=20all=20clippy=20warnings=20?= =?UTF-8?q?=E2=80=94=20use=20.cget()/.skip()/try=5Ffrom()=20instead=20of?= =?UTF-8?q?=20raw=20indexing=20and=20as-casts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/cached_testing.rs | 10 ++-- examples/pd_fight.rs | 28 ++++++--- src/gamestate/arena.rs | 116 ++++++++++++++++++++----------------- src/gamestate/mod.rs | 7 ++- 4 files changed, 94 insertions(+), 67 deletions(-) diff --git a/examples/cached_testing.rs b/examples/cached_testing.rs index 085a10f..ef48681 100644 --- a/examples/cached_testing.rs +++ b/examples/cached_testing.rs @@ -1,6 +1,6 @@ use clap::Parser; use regex::Regex; -use sf_api::{gamestate::GameState, session::*, sso::SFAccount}; +use sf_api::{gamestate::GameState, session::*}; #[tokio::main] pub async fn main() { @@ -8,11 +8,11 @@ pub async fn main() { .filter_level(log::LevelFilter::Info) .init(); - let args = Args::parse(); + let _args = Args::parse(); - let custom_resp: Option<&str> = Some("fightresult.battlereward:1/1/0/455/0/99/0/20406/20053/0/0/0/0/0/0/0/0/0/0/0/0&battlerewarditem:0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&ownplayersavecharacter:109244766/11162/0/38/77445/133260/2254/20053/6/102/102/3/102/2/4/7/0/0/6/1/12/0/0/790/43/85/0/53820/0/0/52/341/48/345/122/64/305/26/144/93/0/288/0/288/74/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/1/0/429/10680/0/551/0&fightversion:2&fightheader.fighters:0/0/0/0/1/11162/marenga/38/76284/76284/116/646/74/489/215/6/102/102/3/102/2/4/7/0/0/6/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/14141/Tomi Lee/31/31488/31488/233/606/233/246/228/1/102/101/2/108/7/2/4/0/0/7/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment:1/19/4/0/0/0/0/1/0/0/1/24/2/0/0/0/0/1/0/0&fightdecoration:0/0/0/0&externaltoolequipment:43/85/0/0/33/87/0/0&fight.r:14141/0/1/0/0/31488/72628/0/0/11162/0/0/0/0/72628/28146/0/0/14141/0/0/0/0/28146/70696/0/0/11162/0/0/0/0/70696/25787/0/0/14141/0/18/0/0/25787/65080/0/1/3/1/3/11162/0/1/4/0/65080/25787/1/3/1/3/0/14141/0/20/0/0/25787/60644/0/1/3/1/2/14141/0/1/0/0/25787/52216/0/1/3/1/2/11162/0/17/0/0/52216/22145/1/3/1/2/1/3/1/3/14141/0/19/0/0/22145/50381/1/3/1/3/1/3/1/1/14141/0/0/4/0/22145/50381/1/3/1/3/1/3/1/1/11162/0/19/0/0/50381/15584/1/3/1/1/1/3/1/2/11162/0/0/4/0/50381/15584/1/3/1/1/1/3/1/2/14141/0/19/0/0/15584/48811/1/3/1/2/1/3/1/0/14141/0/0/4/0/15584/48811/1/3/1/2/0/11162/0/20/0/0/48811/4895/0/1/3/1/1/11162/0/1/0/0/48811/-10241/0/1/3/1/1/&winnerid:11162&arena:1785178845/1/129117/15500/112937/1/1&dailytasklist:6/1/0/10/1/3/1/10/2/4/0/20/2/3/1/1/2/56/0/3/2/57/0/1/2/4/0/1/2/14/0/1/3/4/20/0/1/4&eventtasklist:77/0/10/1/76/0/10/1/75/0/10/1/57/0/10/1&deeds:0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/1/0/0/1/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0"); + let custom_resp: &str = "fightresult.battlereward:1/1/0/455/0/99/0/20406/20053/0/0/0/0/0/0/0/0/0/0/0/0&battlerewarditem:0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&ownplayersavecharacter:109244766/11162/0/38/77445/133260/2254/20053/6/102/102/3/102/2/4/7/0/0/6/1/12/0/0/790/43/85/0/53820/0/0/52/341/48/345/122/64/305/26/144/93/0/288/0/288/74/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/1/0/429/10680/0/551/0&fightversion:2&fightheader.fighters:0/0/0/0/1/11162/marenga/38/76284/76284/116/646/74/489/215/6/102/102/3/102/2/4/7/0/0/6/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/14141/Tomi Lee/31/31488/31488/233/606/233/246/228/1/102/101/2/108/7/2/4/0/0/7/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment:1/19/4/0/0/0/0/1/0/0/1/24/2/0/0/0/0/1/0/0&fightdecoration:0/0/0/0&externaltoolequipment:43/85/0/0/33/87/0/0&fight.r:14141/0/1/0/0/31488/72628/0/0/11162/0/0/0/0/72628/28146/0/0/14141/0/0/0/0/28146/70696/0/0/11162/0/0/0/0/70696/25787/0/0/14141/0/18/0/0/25787/65080/0/1/3/1/3/11162/0/1/4/0/65080/25787/1/3/1/3/0/14141/0/20/0/0/25787/60644/0/1/3/1/2/14141/0/1/0/0/25787/52216/0/1/3/1/2/11162/0/17/0/0/52216/22145/1/3/1/2/1/3/1/3/14141/0/19/0/0/22145/50381/1/3/1/3/1/3/1/1/14141/0/0/4/0/22145/50381/1/3/1/3/1/3/1/1/11162/0/19/0/0/50381/15584/1/3/1/1/1/3/1/2/11162/0/0/4/0/50381/15584/1/3/1/1/1/3/1/2/14141/0/19/0/0/15584/48811/1/3/1/2/1/3/1/0/14141/0/0/4/0/15584/48811/1/3/1/2/0/11162/0/20/0/0/48811/4895/0/1/3/1/1/11162/0/1/0/0/48811/-10241/0/1/3/1/1/&winnerid:11162&arena:1785178845/1/129117/15500/112937/1/1&dailytasklist:6/1/0/10/1/3/1/10/2/4/0/20/2/3/1/1/2/56/0/3/2/57/0/1/2/4/0/1/2/14/0/1/3/4/20/0/1/4&eventtasklist:77/0/10/1/76/0/10/1/75/0/10/1/57/0/10/1&deeds:0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/1/0/0/1/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0"; - let commands: Vec = vec![]; + let _commands: Vec = vec![]; // Use cached login data as base, then apply custom fight response let login_cache = std::fs::read_to_string("cache/bruhbruh.login").unwrap(); @@ -21,7 +21,7 @@ pub async fn main() { // Overwrite with our custom fight response let resp = Response::parse( - custom_resp.unwrap().to_string(), + custom_resp.to_string(), chrono::Local::now().naive_local(), ) .unwrap(); diff --git a/examples/pd_fight.rs b/examples/pd_fight.rs index b05b53c..a5cfc54 100644 --- a/examples/pd_fight.rs +++ b/examples/pd_fight.rs @@ -1,22 +1,36 @@ use sf_api::{gamestate::GameState, session::Response}; fn main() { - let body = "fightversion:2&fightheader.fighters:0/0/0/0/1/11162/marenga/38/76284/76284/116/646/74/489/215/6/102/102/3/102/2/4/7/0/0/6/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/14141/Tomi Lee/31/31488/31488/233/606/233/246/228/1/102/101/2/108/7/2/4/0/0/7/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment:1/19/4/0/0/0/0/1/0/0/1/24/2/0/0/0/0/1/0/0&fightdecoration:0/0/0/0&externaltoolequipment:43/85/0/0/33/87/0/0&fight.r:14141/0/1/0/0/31488/72628/0/0/11162/0/0/0/0/72628/28146/0/0/14141/0/0/0/0/28146/70696/0/0/11162/0/0/0/0/70696/25787/0/0/14141/0/18/0/0/25787/65080/0/1/3/1/3/11162/0/1/4/0/65080/25787/1/3/1/3/0/14141/0/20/0/0/25787/60644/0/1/3/1/2/14141/0/1/0/0/25787/52216/0/1/3/1/2/11162/0/17/0/0/52216/22145/1/3/1/2/1/3/1/3/14141/0/19/0/0/22145/50381/1/3/1/3/1/3/1/1/14141/0/0/4/0/22145/50381/1/3/1/3/1/3/1/1/11162/0/19/0/0/50381/15584/1/3/1/1/1/3/1/2/11162/0/0/4/0/50381/15584/1/3/1/1/1/3/1/2/14141/0/19/0/0/15584/48811/1/3/1/2/1/3/1/0/14141/0/0/4/0/15584/48811/1/3/1/2/0/11162/0/20/0/0/48811/4895/0/1/3/1/1/11162/0/1/0/0/48811/-10241/0/1/3/1/1/&winnerid:11162"; - + let body = "fightadditionalplayers.r:-924,-935,-934,-933,-954,&fightresult.underworldpillage:1/1/0/2051167/0/0&underworldmaxsouls:2116140&owntower.towerSave:446275/0/100/359/1/0/0/4144/2539/2422/3743/2900/9976/3912/2391/7953/4079/0/0/0/0/0/17534/481/951/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/416/5/56614/600/10/359/2/0/0/2422/2539/4144/3743/2900/1240/1332/9704/6731/2203/0/0/0/0/0/4235/1244/2734/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/358/4/51450/600/2/359/3/0/0/2422/4144/2539/3743/2900/1858/12133/910/8900/4199/0/0/0/0/0/7117/582/1154/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/368/1/106984/600/0/0/15/15/29/15/15/15/15/15/15/15/0/1188000/1188000/162518400/0/49500/1439832658/1439832658/24824701/1785179065/0/0/0/504/1/2000/2000/80×tamp:1785179068&resources:446275/750/2429597169/195/800/11939837/23475610/2045029/9000000/52483/69902/125782697/7/6/6/6/6/0&fightversion:2&fightheader1.fighters:16/0/0/0/1/0/0/600/170125070/170125070/56614/56614/56614/56614/22279/-910/1/5/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/162271771/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment1:1/29/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration1:0/0/0/1&externaltoolequipment1:1202/1202/0/0/1152/2408/0/0&fight1.r:0/0/1/4/0/170125070/162271771/0/0/7779/0/1/0/0/162271771/135970081/0/0/0/0/0/4/0/135970081/162271771/0/0/7779/0/1/0/0/162271771/98670378/0/0/0/0/1/0/0/98670378/146632552/0/0/7779/0/0/0/0/146632552/87835157/0/0/0/0/0/0/0/87835157/137249020/0/0/7779/0/1/0/0/137249020/51306093/0/0/0/0/0/4/0/51306093/137249020/0/0/7779/0/0/0/0/137249020/33574747/0/0/0/0/0/4/0/33574747/137249020/0/0/7779/0/0/0/0/137249020/17642480/0/0/0/0/0/4/0/17642480/137249020/0/0/7779/0/0/0/0/137249020/-7358042/0/0/&winnerid1.s:7779&fightversion:2&fightheader2.fighters:16/0/0/0/1/0/0/600/170125070/170125070/56614/56614/56614/56614/22279/-910/2/5/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/137249020/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment2:1/29/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration2:0/0/0/1&externaltoolequipment2:1202/1202/0/0/1152/2408/0/0&fight2.r:0/0/0/0/0/170125070/132557254/0/0/7779/0/0/0/0/132557254/162705207/0/0/0/0/1/4/0/162705207/132557254/0/0/7779/0/1/0/0/132557254/135652675/0/0/0/0/1/0/0/135652675/116918035/0/0/7779/0/1/0/0/116918035/93579983/0/0/0/0/0/4/0/93579983/116918035/0/0/7779/0/1/0/0/116918035/36970192/0/0/0/0/1/4/0/36970192/116918035/0/0/7779/0/1/0/0/116918035/-35637136/0/0/&winnerid2.s:7779&fightversion:2&fightheader3.fighters:16/0/0/0/1/0/0/600/170125070/170125070/56614/56614/56614/56614/22279/-910/3/5/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/116918035/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment3:1/29/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration3:0/0/0/1&externaltoolequipment3:1202/1202/0/0/1152/2408/0/0&fight3.r:0/0/1/0/0/170125070/107534503/0/0/7779/0/1/0/0/107534503/136933149/0/0/0/0/1/4/0/136933149/107534503/0/0/7779/0/0/0/0/107534503/127848422/0/0/0/0/0/4/0/127848422/107534503/0/0/7779/0/1/0/0/107534503/81537640/0/0/0/0/0/4/0/81537640/107534503/0/0/7779/0/1/0/0/107534503/44969616/0/0/0/0/0/4/0/44969616/107534503/0/0/7779/0/0/0/0/107534503/30286276/0/0/0/0/1/4/0/30286276/107534503/0/0/7779/0/1/0/0/107534503/-19498511/0/0/&winnerid3.s:7779&fightversion:2&fightheader4.fighters:16/0/0/0/1/0/0/600/170125070/170125070/56614/56614/56614/56614/22279/-910/4/5/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/107534503/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment4:1/29/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration4:0/0/0/1&externaltoolequipment4:1202/1202/0/0/1152/2408/0/0&fight4.r:0/0/0/0/0/170125070/102842737/0/0/7779/0/1/0/0/102842737/139969965/0/0/0/0/1/4/0/139969965/102842737/0/0/7779/0/0/0/0/102842737/128189393/0/0/0/0/1/4/0/128189393/102842737/0/0/7779/0/1/0/0/102842737/78034948/0/0/0/0/1/4/0/78034948/102842737/0/0/7779/0/0/0/0/102842737/61584804/0/0/0/0/0/0/0/61584804/91895284/0/0/7779/0/1/0/0/91895284/2060039/0/0/0/0/0/0/0/2060039/79383908/0/0/7779/0/0/0/0/79383908/-17195662/0/0/&winnerid4.s:7779&fightversion:2&fightheader5.fighters:16/0/0/0/1/0/0/600/170125070/170125070/56614/56614/56614/56614/22279/-910/5/5/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/79383908/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment5:1/29/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration5:0/0/0/1&externaltoolequipment5:1202/1202/0/0/1152/2408/0/0&fight5.r:0/0/1/0/0/170125070/70000376/0/0/7779/0/0/0/0/70000376/164697997/0/0/0/0/0/0/0/164697997/63744688/0/0/7779/0/1/0/0/63744688/132277540/0/0/0/0/0/0/0/132277540/55925079/0/0/7779/0/0/0/0/55925079/121304553/0/0/0/0/0/0/0/121304553/46541547/0/0/7779/0/0/0/0/46541547/106886566/0/0/0/0/1/4/0/106886566/46541547/0/0/7779/0/1/0/0/46541547/59322337/0/0/0/0/1/0/0/59322337/21518795/0/0/7779/0/0/0/0/21518795/39871703/0/0/0/0/0/0/0/39871703/7443497/0/0/7779/0/1/0/0/7443497/-20294977/0/0/&winnerid5.s:7779&fightversion:2&fightheader6.fighters:16/0/0/0/1/0/0/600/154607250/154607250/51450/51450/51450/51450/24908/-924/1/4/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/7443497/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment6:1/22/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration6:0/0/0/1&externaltoolequipment6:1202/1202/0/0/1152/2408/0/0&fight6.r:0/0/1/0/0/154607250/-1008967/0/0/&winnerid6.s:0&companionequipment:..."; + let login_cache = std::fs::read_to_string("cache/bruhbruh.login").unwrap(); let login_data: Response = serde_json::from_str(&login_cache).unwrap(); let mut gs = GameState::new(login_data).unwrap(); - - let resp = Response::parse(body.to_string(), chrono::Local::now().naive_local()).unwrap(); + + // Trim at a safe point to avoid the huge companionequipment blob + let trimmed = body.split("&companionequipment").next().unwrap(); + let resp = Response::parse(trimmed.to_string(), chrono::Local::now().naive_local()).unwrap(); gs.update(resp).unwrap(); if let Some(fight) = &gs.last_fight { + println!("winner_id: {:?}, has_player_won: {}, extra: {:?}", + fight.fights.first().map(|f| f.winner_id), + fight.has_player_won, + fight.extra, + ); for (j, sf) in fight.fights.iter().enumerate() { println!("--- SingleFight {j} ---"); + if let Some(fa) = &sf.fighter_a { + println!(" fighter_a: type={:?} id={} name={:?} level={} life={}", + fa.typ, fa.id, fa.name, fa.level, fa.life); + } + if let Some(fb) = &sf.fighter_b { + println!(" fighter_b: type={:?} id={} name={:?} level={} life={}", + fb.typ, fb.id, fb.name, fb.level, fb.life); + } for (k, action) in sf.actions.iter().enumerate() { - println!(" actions[{k}]: actor={}, action={:?}, outcome={:?}, target_hp={}, actor_hp={:?}, effect={:?}/{:?}, state={:?}/{:?}", - action.acting_id, action.action, action.outcome, action.other_new_life, action.actor_life, - action.actor_effect, action.opponent_effect, action.actor_state, action.defender_state); + println!(" actions[{k}]: actor={}, action={:?}, outcome={:?}, target_hp={}, actor_hp={:?}", + action.acting_id, action.action, action.outcome, action.other_new_life, action.actor_life); } } } diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index 39dac9d..b55fa97 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -159,7 +159,7 @@ pub struct SingleFight { /// The action this fight involved. Note that this will likely be changed /// in the future, as is it hard to interpret pub actions: Vec, - /// Raw equipment data for fighter_a. Each entry is 19 values (model_id + /// Raw equipment data for `fighter_a`. Each entry is 19 values (`model_id` /// + item stats). The encoding differs from regular Item format. pub equipment: Vec>, } @@ -218,12 +218,14 @@ impl SingleFight { } // Detect stride for this chunk - let stride = if values[i + 7] == "0" && values[i + 8] == "0" { + let stride = if values.cget(i + 7, "stride_p7")? == "0" + && values.cget(i + 8, "stride_p8")? == "0" + { // 9-value: positions 7 and 8 are both 0 9 } else if i + 15 <= values.len() - && values[i + 7] != "0" - && values[i + 11] != "0" + && values.cget(i + 7, "stride_p7")? != "0" + && values.cget(i + 11, "stride_p11")? != "0" { // 15-value: both sides have minions, all 8 extras are non-zero-ish 15 @@ -234,16 +236,15 @@ impl SingleFight { 9 }; - let chunk = &values[i..i + stride]; - - let acting_id: i64 = chunk[0].parse().map_err(|_| { - SFError::ParsingError("action pid", chunk[0].to_string()) - })?; + let raw_acting_id = values.cget(i, "acting_id")?; + let acting_id: i64 = raw_acting_id + .parse() + .map_err(|_| SFError::ParsingError("action pid", raw_acting_id.to_string()))?; let action_type: u32 = - warning_from_str(chunk[2], "fight action").unwrap_or(0); + values.cfsget(i + 2, "fight action")?.unwrap_or(0); let raw_outcome: u32 = - warning_from_str(chunk[3], "fight outcome").unwrap_or(0); + values.cfsget(i + 3, "fight outcome")?.unwrap_or(0); let action = FightActionType::parse(action_type); let outcome = match raw_outcome { @@ -252,26 +253,19 @@ impl SingleFight { _ => FightOutcome::Normal, }; - let actor_life: i64 = chunk[5].parse().map_err(|_| { - SFError::ParsingError( - "action actor life", - chunk[5].to_string(), - ) - })?; - let target_life: i64 = chunk[6].parse().map_err(|_| { - SFError::ParsingError( - "action target life", - chunk[6].to_string(), - ) - })?; - - let pos1: i64 = chunk[1].parse().unwrap_or(0); - let pos4: i64 = chunk[4].parse().unwrap_or(0); + let actor_life: i64 = + values.cfsuget(i + 5, "action actor life")?; + let target_life: i64 = + values.cfsuget(i + 6, "action target life")?; + + let pos1: i64 = values.cget(i + 1, "fighter_pos1")?.parse().unwrap_or(0); + let pos4: i64 = values.cget(i + 4, "fighter_pos4")?.parse().unwrap_or(0); let actor_state = FighterState::from_raw(pos1); let defender_state = FighterState::from_raw(pos4); let (actor_effect, opponent_effect) = if stride > 9 { - let extra_vals: Vec = chunk[7..] + let extra_vals: Vec = values + .skip(i + 7, "extra_vals")? .iter() .filter_map(|s| s.parse().ok()) .collect(); @@ -465,7 +459,7 @@ pub enum ActiveEffect { /// How many actions the minion can still take remaining_actions: u32, }, - /// A poison/debuff effect (PlagueDoctor) + /// A poison/debuff effect (`PlagueDoctor`) Poison { /// The numeric ID of the poison type id: u32, @@ -505,7 +499,7 @@ pub struct FightAction { /// The outcome of this action (blocked, evaded, or normal) pub outcome: FightOutcome, /// The life of the acting fighter at the time of this action. Only - /// available in fight_version >= 2 + /// available in `fight_version` >= 2 pub actor_life: Option, /// The active effect on the acting fighter, if any (minion or ability) pub actor_effect: Option, @@ -537,17 +531,17 @@ pub enum FightActionType { MinionAttack, /// A minion attacks after the main fighter attacked MinionAttack2, - /// BattleMage's opening fireball + /// `BattleMage`'s opening fireball BattleMageFireball, /// Assassin's main hand attack AssassinMainHand, /// Assassin's off hand attack AssassinOffHand, - /// DemonHunter's revive ability + /// `DemonHunter`'s revive ability Revive, - /// PlagueDoctor throws a poison tincture + /// `PlagueDoctor` throws a poison tincture ThrowPoison, - /// PlagueDoctor's poison deals damage over time + /// `PlagueDoctor`'s poison deals damage over time PoisonTick, /// I have not checked all possible battle types, so whatever action I have /// missed will be parsed as this, with the raw integer value attached @@ -578,13 +572,13 @@ impl FightActionType { } /// Parse the 5 (12-value) or 8 (15-value) extra values into active effects. -/// Format: [1, type_flag, type_id, remaining, ...] -/// type_flag=2: minion | type_flag=1: class ability +/// Format: [1, `type_flag`, `type_id`, remaining, ...] +/// `type_flag=2`: minion | `type_flag=1`: class ability /// 12-value, acting has effect: [1, flag, id, remaining, 0] -/// flag=2 → [1, 2, minion_type, rem, 0] flag=1 → [1, 1, ability_id, rem, 0] +/// flag=2 → [1, 2, `minion_type`, rem, 0] flag=1 → [1, 1, `ability_id`, rem, 0] /// 12-value, opponent has effect: [0, 1, flag, id, remaining] -/// flag=2 → [0, 1, 2, minion_type, rem] flag=1 → [0, 1, 1, ability_id, rem] -/// 15-value (both sides): [1, my_f, my_id, my_rem, 1, their_f, their_id, their_rem] +/// flag=2 → [0, 1, 2, `minion_type`, rem] flag=1 → [0, 1, 1, `ability_id`, rem] +/// 15-value (both sides): [1, `my_f`, `my_id`, `my_rem`, 1, `their_f`, `their_id`, `their_rem`] fn parse_active_effect( extras: &[i64], ) -> (Option, Option) { @@ -594,6 +588,10 @@ fn parse_active_effect( let parse_one = |flag: i64, id: i64, remaining: i64| -> Option { Some(match flag { + 1 => ActiveEffect::Ability { + id: u32::try_from(id.max(0)).unwrap_or(0), + remaining_rounds: u32::try_from(remaining.max(0)).unwrap_or(0), + }, 2 => ActiveEffect::Minion { minion_type: match id { 1 => SummonedMinion::Skeleton, @@ -601,24 +599,20 @@ fn parse_active_effect( 3 => SummonedMinion::Golem, _ => return None, }, - remaining_actions: remaining.max(0) as u32, - }, - 1 => ActiveEffect::Ability { - id: id.max(0) as u32, - remaining_rounds: remaining.max(0) as u32, + remaining_actions: u32::try_from(remaining.max(0)).unwrap_or(0), }, 3 => ActiveEffect::Poison { - id: id.max(0) as u32, - remaining_rounds: remaining.max(0) as u32, + id: u32::try_from(id.max(0)).unwrap_or(0), + remaining_rounds: u32::try_from(remaining.max(0)).unwrap_or(0), }, _ => { warn!( "Unknown active effect: flag={flag}, id={id}, remaining={remaining}" ); ActiveEffect::Unknown { - flag: flag.max(0) as u32, - id: id.max(0) as u32, - remaining: remaining.max(0) as u32, + flag: u32::try_from(flag.max(0)).unwrap_or(0), + id: u32::try_from(id.max(0)).unwrap_or(0), + remaining: u32::try_from(remaining.max(0)).unwrap_or(0), } } }) @@ -626,15 +620,31 @@ fn parse_active_effect( if extras.len() >= 8 { // 15-value: both sides have effects - let mine = parse_one(extras[1], extras[2], extras[3]); - let theirs = parse_one(extras[5], extras[6], extras[7]); + let mine = parse_one( + extras.cget(1, "effect_flag_m").unwrap_or(0), + extras.cget(2, "effect_id_m").unwrap_or(0), + extras.cget(3, "effect_rem_m").unwrap_or(0), + ); + let theirs = parse_one( + extras.cget(5, "effect_flag_t").unwrap_or(0), + extras.cget(6, "effect_id_t").unwrap_or(0), + extras.cget(7, "effect_rem_t").unwrap_or(0), + ); (mine, theirs) - } else if extras[0] != 0 { + } else if extras.cget(0, "effect_side").unwrap_or(0) != 0 { // 12-value: acting side has an effect - (parse_one(extras[1], extras[2], extras[3]), None) + (parse_one( + extras.cget(1, "effect_flag_m").unwrap_or(0), + extras.cget(2, "effect_id_m").unwrap_or(0), + extras.cget(3, "effect_rem_m").unwrap_or(0), + ), None) } else { // 12-value: opponent has an effect - (None, parse_one(extras[2], extras[3], extras[4])) + (None, parse_one( + extras.cget(2, "effect_flag_t").unwrap_or(0), + extras.cget(3, "effect_id_t").unwrap_or(0), + extras.cget(4, "effect_rem_t").unwrap_or(0), + )) } } diff --git a/src/gamestate/mod.rs b/src/gamestate/mod.rs index be53e33..9d80070 100644 --- a/src/gamestate/mod.rs +++ b/src/gamestate/mod.rs @@ -2233,8 +2233,11 @@ impl GameState { if data.len() < 1 + ITEM_PARSE_LEN { return Ok(()); } - let count = data[0] as usize; - let items: Vec> = data[1..] + let count = usize::try_from( + data.cget(0, "equip_count")?, + ).unwrap_or(0); + let items: Vec> = data + .skip(1, "equip_data")? .chunks_exact(ITEM_PARSE_LEN) .take(count) .map(|c| c.to_vec()) From 431ec157ce75e04bfdd3a69e558798d55d1d5e53 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 19:26:03 +0200 Subject: [PATCH 14/24] =?UTF-8?q?refactor:=20clean=20up=20arena.rs=20?= =?UTF-8?q?=E2=80=94=20clamp=5Fu32=20helper,=20deduplicated=20parse=5Facti?= =?UTF-8?q?ve=5Feffect=20offsets,=20cfsuget=20for=20acting=5Fid,=20dedupli?= =?UTF-8?q?cated=20stride=20p7,=20single=20raw=5Fname=20read=20in=20Fighte?= =?UTF-8?q?r::parse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gamestate/arena.rs | 141 ++++++++++++++++++++--------------------- 1 file changed, 70 insertions(+), 71 deletions(-) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index b55fa97..530abd0 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -218,13 +218,14 @@ impl SingleFight { } // Detect stride for this chunk - let stride = if values.cget(i + 7, "stride_p7")? == "0" - && values.cget(i + 8, "stride_p8")? == "0" - { + // Check if position 7 (first extras value) is zero — it determines + // whether there are any extra effect values. + let p7 = values.cget(i + 7, "stride_p7")?; + let stride = if p7 == "0" && values.cget(i + 8, "stride_p8")? == "0" { // 9-value: positions 7 and 8 are both 0 9 } else if i + 15 <= values.len() - && values.cget(i + 7, "stride_p7")? != "0" + && p7 != "0" && values.cget(i + 11, "stride_p11")? != "0" { // 15-value: both sides have minions, all 8 extras are non-zero-ish @@ -236,10 +237,7 @@ impl SingleFight { 9 }; - let raw_acting_id = values.cget(i, "acting_id")?; - let acting_id: i64 = raw_acting_id - .parse() - .map_err(|_| SFError::ParsingError("action pid", raw_acting_id.to_string()))?; + let acting_id: i64 = values.cfsuget(i, "action pid")?; let action_type: u32 = values.cfsget(i + 2, "fight action")?.unwrap_or(0); @@ -344,7 +342,10 @@ impl Fighter { let id = data.cfsget(5, "fighter id").ok()?.unwrap_or_default(); - let name = match data.cget(6, "fighter name").ok()?.parse::() { + // Parse the name field, which doubles as fighter-type override for + // special NPCs (fortress units, underworld minions) and pets. + let raw_name = data.cget(6, "fighter name").ok()?; + let name = match raw_name.parse::() { Ok(-719..=-710) => { fighter_type = FighterTyp::FortressSoldier; None @@ -363,9 +364,8 @@ impl Fighter { } Ok(..=-1) => None, Ok(0) => { - let id = data.cget(15, "fighter uwm").ok()?; - // No idea if this correct - if ["-910", "-935", "-933", "-924"].contains(&id) { + let uwm_id = data.cget(15, "fighter uwm").ok()?; + if ["-910", "-935", "-933", "-924"].contains(&uwm_id) { fighter_type = FighterTyp::UnderworldMinion; } None @@ -374,7 +374,7 @@ impl Fighter { fighter_type = FighterTyp::Pet; None } - _ => Some(data.cget(6, "fighter name").ok()?.to_string()), + _ => Some(raw_name.to_string()), }; Some(Fighter { @@ -571,6 +571,50 @@ impl FightActionType { } } +/// Safely clamp an `i64` to `u32`, treating negatives as 0. +fn clamp_u32(v: i64) -> u32 { + u32::try_from(v.max(0)).unwrap_or(0) +} + +/// Parse a single active effect from three consecutive `extras` values. +fn parse_one_effect(extras: &[i64], start: usize) -> Option { + if start + 2 >= extras.len() { + return None; + } + let flag = extras.cget(start, "eff_f").unwrap_or(0); + let id = extras.cget(start + 1, "eff_id").unwrap_or(0); + let remaining = extras.cget(start + 2, "eff_rem").unwrap_or(0); + Some(match flag { + 1 => ActiveEffect::Ability { + id: clamp_u32(id), + remaining_rounds: clamp_u32(remaining), + }, + 2 => ActiveEffect::Minion { + minion_type: match id { + 1 => SummonedMinion::Skeleton, + 2 => SummonedMinion::Hound, + 3 => SummonedMinion::Golem, + _ => return None, + }, + remaining_actions: clamp_u32(remaining), + }, + 3 => ActiveEffect::Poison { + id: clamp_u32(id), + remaining_rounds: clamp_u32(remaining), + }, + _ => { + warn!( + "Unknown active effect: flag={flag}, id={id}, remaining={remaining}" + ); + ActiveEffect::Unknown { + flag: clamp_u32(flag), + id: clamp_u32(id), + remaining: clamp_u32(remaining), + } + } + }) +} + /// Parse the 5 (12-value) or 8 (15-value) extra values into active effects. /// Format: [1, `type_flag`, `type_id`, remaining, ...] /// `type_flag=2`: minion | `type_flag=1`: class ability @@ -586,66 +630,21 @@ fn parse_active_effect( return (None, None); } - let parse_one = |flag: i64, id: i64, remaining: i64| -> Option { - Some(match flag { - 1 => ActiveEffect::Ability { - id: u32::try_from(id.max(0)).unwrap_or(0), - remaining_rounds: u32::try_from(remaining.max(0)).unwrap_or(0), - }, - 2 => ActiveEffect::Minion { - minion_type: match id { - 1 => SummonedMinion::Skeleton, - 2 => SummonedMinion::Hound, - 3 => SummonedMinion::Golem, - _ => return None, - }, - remaining_actions: u32::try_from(remaining.max(0)).unwrap_or(0), - }, - 3 => ActiveEffect::Poison { - id: u32::try_from(id.max(0)).unwrap_or(0), - remaining_rounds: u32::try_from(remaining.max(0)).unwrap_or(0), - }, - _ => { - warn!( - "Unknown active effect: flag={flag}, id={id}, remaining={remaining}" - ); - ActiveEffect::Unknown { - flag: u32::try_from(flag.max(0)).unwrap_or(0), - id: u32::try_from(id.max(0)).unwrap_or(0), - remaining: u32::try_from(remaining.max(0)).unwrap_or(0), - } - } - }) + // Determine offset of each side's 3-value block + // 15-value: mine@1, theirs@5 + // 12-value mine: mine@1, no theirs + // 12-value theirs: no mine, theirs@2 + let (mine_start, theirs_start) = if extras.len() >= 8 { + (Some(1), Some(5)) + } else if extras.first().copied().unwrap_or(0) != 0 { + (Some(1), None) + } else { + (None, Some(2)) }; - if extras.len() >= 8 { - // 15-value: both sides have effects - let mine = parse_one( - extras.cget(1, "effect_flag_m").unwrap_or(0), - extras.cget(2, "effect_id_m").unwrap_or(0), - extras.cget(3, "effect_rem_m").unwrap_or(0), - ); - let theirs = parse_one( - extras.cget(5, "effect_flag_t").unwrap_or(0), - extras.cget(6, "effect_id_t").unwrap_or(0), - extras.cget(7, "effect_rem_t").unwrap_or(0), - ); - (mine, theirs) - } else if extras.cget(0, "effect_side").unwrap_or(0) != 0 { - // 12-value: acting side has an effect - (parse_one( - extras.cget(1, "effect_flag_m").unwrap_or(0), - extras.cget(2, "effect_id_m").unwrap_or(0), - extras.cget(3, "effect_rem_m").unwrap_or(0), - ), None) - } else { - // 12-value: opponent has an effect - (None, parse_one( - extras.cget(2, "effect_flag_t").unwrap_or(0), - extras.cget(3, "effect_id_t").unwrap_or(0), - extras.cget(4, "effect_rem_t").unwrap_or(0), - )) - } + let mine = mine_start.and_then(|s| parse_one_effect(extras, s)); + let theirs = theirs_start.and_then(|s| parse_one_effect(extras, s)); + (mine, theirs) } /// The type of the participant in a fight From 52da72187ded6783e84fd1a7597f0c48302828fa Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 19:36:54 +0200 Subject: [PATCH 15/24] =?UTF-8?q?refactor:=20rename=20SummonedMinion=20?= =?UTF-8?q?=E2=86=92=20Minion=20and=20remove=20duplicate=20enum=20from=20s?= =?UTF-8?q?imulate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gamestate/arena.rs | 10 +++++----- src/simulate/fighter.rs | 10 +--------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index 530abd0..a2f6075 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -405,7 +405,7 @@ pub enum FightOutcome { /// The type of summoned minion (Necromancer) #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum SummonedMinion { +pub enum Minion { #[default] Skeleton, Hound, @@ -455,7 +455,7 @@ pub enum ActiveEffect { /// A summoned minion Minion { /// The type of minion (Skeleton, Hound, or Golem) - minion_type: SummonedMinion, + minion_type: Minion, /// How many actions the minion can still take remaining_actions: u32, }, @@ -591,9 +591,9 @@ fn parse_one_effect(extras: &[i64], start: usize) -> Option { }, 2 => ActiveEffect::Minion { minion_type: match id { - 1 => SummonedMinion::Skeleton, - 2 => SummonedMinion::Hound, - 3 => SummonedMinion::Golem, + 1 => Minion::Skeleton, + 2 => Minion::Hound, + 3 => Minion::Golem, _ => return None, }, remaining_actions: clamp_u32(remaining), diff --git a/src/simulate/fighter.rs b/src/simulate/fighter.rs index 42e94c8..90dfd06 100644 --- a/src/simulate/fighter.rs +++ b/src/simulate/fighter.rs @@ -5,7 +5,7 @@ use fastrand::Rng; use crate::{ command::AttributeType, - gamestate::{character::Class, items::*}, + gamestate::{arena::Minion, character::Class, items::*}, misc::EnumMapGet, simulate::{damage::*, upgradeable::UpgradeableFighter, *}, }; @@ -325,14 +325,6 @@ pub(crate) enum ClassData { }, } -/// The type of minion a necromancer can summon -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub(crate) enum Minion { - Skeleton, - Hound, - Golem, -} - /// The stance a paladin can enter #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Stance { From 774250b13c5aee26ceedd0effc6d2b31fcebd3b6 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 21:42:06 +0200 Subject: [PATCH 16/24] fix(arena): robustify fight.r parsing - Use i64 values instead of string comparisons for stride detection - Bound extra_vals to the current action instead of reading to EOF - Fix 15-value effect ownership using who1/who2 flags - Replace fragile string-based stride checks with integer matching - Use crate's CGet and ArrSkip traits for safe bounds-checked access --- src/gamestate/arena.rs | 145 +++++++++++++++++++++++------------------ 1 file changed, 80 insertions(+), 65 deletions(-) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index a2f6075..fbeb791 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Local}; use num_traits::FromPrimitive; use super::{items::*, *}; +use crate::misc::{ArrSkip, CGet}; use crate::PlayerId; /// The arena, that a player can fight other players in @@ -202,72 +203,63 @@ impl SingleFight { // Unsupported fight version return Ok(()); } - - // Format variants: - // 9-value (no minions): + // Format variants (all values are i64): + // 9-value (no effects): // actor / 0 / type / outcome / 0 / actor_hp / target_hp / 0 / 0 - // 12-value (one side has minions): - // actor / 0 / type / outcome / 0 / actor_hp / target_hp / e1/e2/e3/e4/trail - // 15-value (both sides have minions): - // actor / 0 / type / outcome / 0 / actor_hp / target_hp / p1/p2/p3/p4/e1/e2/e3/e4 - let values: Vec<&str> = data.split('/').collect(); + // 12-value (one fighter has an effect): + // actor / 0 / type / outcome / 0 / actor_hp / target_hp / [5 extras] + // Actor's effect: [who=1, flag, id, rem, trail=0] + // Opponent effect: [0, marker=1, flag, id, rem] + // 15-value (both fighters have effects, or one has two): + // actor / 0 / type / outcome / 0 / actor_hp / target_hp / [8 extras] + // [who1, eff1_flag, eff1_id, eff1_rem, who2, eff2_flag, eff2_id, eff2_rem] + // who={0→opponent, ≠0→actor} + let raw: Vec<&str> = data.split('/').collect(); + // Parse once to i64 for robust stride detection + let values: Vec = raw.iter().filter_map(|s| s.parse().ok()).collect(); + let mut i = 0; - while i < values.len() { - if i + 9 > values.len() { - break; - } + while i + 9 <= values.len() { + let extras_first = values.cget(i + 7, "extras_first")?; // 0 if none; who=0 → opponent, ≠0 → actor + let extras_second = values.cget(i + 8, "extras_second")?; - // Detect stride for this chunk - // Check if position 7 (first extras value) is zero — it determines - // whether there are any extra effect values. - let p7 = values.cget(i + 7, "stride_p7")?; - let stride = if p7 == "0" && values.cget(i + 8, "stride_p8")? == "0" { - // 9-value: positions 7 and 8 are both 0 + // Detect stride: 9-value if both effect slots are 0 + let stride = if extras_first == 0 && extras_second == 0 { 9 } else if i + 15 <= values.len() - && p7 != "0" - && values.cget(i + 11, "stride_p11")? != "0" + && matches!(values.cget(i + 12, "stride_p12")?, 1..=3) { - // 15-value: both sides have minions, all 8 extras are non-zero-ish + // 15-value: position 12 is the second effect-block's flag + // (1=Ability, 2=Minion, 3=Poison). In any other format, + // position 12 is the next action's actor_id (>3 or <0). 15 } else if i + 12 <= values.len() { - // 12-value: one side has minions 12 } else { 9 }; - let acting_id: i64 = values.cfsuget(i, "action pid")?; - - let action_type: u32 = - values.cfsget(i + 2, "fight action")?.unwrap_or(0); - let raw_outcome: u32 = - values.cfsget(i + 3, "fight outcome")?.unwrap_or(0); + let acting_id = values.cget(i, "acting_id")?; + let action_type: u32 = u32::try_from(values.cget(i + 2, "action_type")?).unwrap_or(0); + let outcome_code: u32 = u32::try_from(values.cget(i + 3, "outcome")?).unwrap_or(0); let action = FightActionType::parse(action_type); - let outcome = match raw_outcome { + let outcome = match outcome_code { 3 => FightOutcome::Blocked, 4 => FightOutcome::Evaded, _ => FightOutcome::Normal, }; - let actor_life: i64 = - values.cfsuget(i + 5, "action actor life")?; - let target_life: i64 = - values.cfsuget(i + 6, "action target life")?; + let actor_life = values.cget(i + 5, "actor_life")?; + let target_life = values.cget(i + 6, "target_life")?; - let pos1: i64 = values.cget(i + 1, "fighter_pos1")?.parse().unwrap_or(0); - let pos4: i64 = values.cget(i + 4, "fighter_pos4")?.parse().unwrap_or(0); - let actor_state = FighterState::from_raw(pos1); - let defender_state = FighterState::from_raw(pos4); + let actor_state = FighterState::from_raw(values.cget(i + 1, "actor_state")?); + let defender_state = FighterState::from_raw(values.cget(i + 4, "defender_state")?); let (actor_effect, opponent_effect) = if stride > 9 { - let extra_vals: Vec = values - .skip(i + 7, "extra_vals")? - .iter() - .filter_map(|s| s.parse().ok()) - .collect(); - parse_active_effect(&extra_vals) + let extras_start = values.skip(i + 7, "extras")?; + let extra_vals = extras_start.get(..(stride - 7)).unwrap_or(&[]); + parse_active_effect(extra_vals) } else { (None, None) }; @@ -287,6 +279,15 @@ impl SingleFight { i += stride; } + if i < values.len() { + let trailing = raw.get(i..).unwrap_or(&[]); + warn!( + "{} trailing unparsed values in fight.r: {:?}", + values.len() - i, + trailing, + ); + } + Ok(()) } } @@ -421,10 +422,14 @@ pub enum FighterState { /// No special state #[default] Normal, - /// Druid in bear form (values 10-11, speed change after transform) + /// Druid in eagle form + EagleForm, + /// Druid in bear form BearForm, /// Paladin in Defensive stance (value 20) DefensiveStance, + /// Paladin in Offensive stance (value 21) + OffensiveStance, /// Berserker in frenzy mode (value 30) Frenzy, /// An unrecognized state value (raw value attached for debugging) @@ -435,8 +440,10 @@ impl FighterState { pub(crate) fn from_raw(val: i64) -> Self { match val { 0 => FighterState::Normal, - 10 | 11 => FighterState::BearForm, + 10 => FighterState::EagleForm, + 11 => FighterState::BearForm, 20 => FighterState::DefensiveStance, + 21 => FighterState::OffensiveStance, 30 => FighterState::Frenzy, _ => { if val != 0 { @@ -616,13 +623,17 @@ fn parse_one_effect(extras: &[i64], start: usize) -> Option { } /// Parse the 5 (12-value) or 8 (15-value) extra values into active effects. -/// Format: [1, `type_flag`, `type_id`, remaining, ...] -/// `type_flag=2`: minion | `type_flag=1`: class ability -/// 12-value, acting has effect: [1, flag, id, remaining, 0] -/// flag=2 → [1, 2, `minion_type`, rem, 0] flag=1 → [1, 1, `ability_id`, rem, 0] -/// 12-value, opponent has effect: [0, 1, flag, id, remaining] -/// flag=2 → [0, 1, 2, `minion_type`, rem] flag=1 → [0, 1, 1, `ability_id`, rem] -/// 15-value (both sides): [1, `my_f`, `my_id`, `my_rem`, 1, `their_f`, `their_id`, `their_rem`] +/// +/// 12-value (5 extras): +/// [who=1, flag, id, rem, trail=0] → (actor, None) +/// [0, marker=1, flag, id, rem] → (None, opponent) +/// +/// 15-value (8 extras): +/// [who1, flag1, id1, rem1, who2, flag2, id2, rem2] +/// who=0 → that block belongs to the **opponent** +/// who≠0 → that block belongs to the **actor** +/// When both blocks belong to the same fighter, only the first +/// is returned (the second is typically an expired sentinel). fn parse_active_effect( extras: &[i64], ) -> (Option, Option) { @@ -630,21 +641,25 @@ fn parse_active_effect( return (None, None); } - // Determine offset of each side's 3-value block - // 15-value: mine@1, theirs@5 - // 12-value mine: mine@1, no theirs - // 12-value theirs: no mine, theirs@2 - let (mine_start, theirs_start) = if extras.len() >= 8 { - (Some(1), Some(5)) + if extras.len() >= 8 { + // 15-value: two effect blocks, each with an ownership flag + let who1_actor = extras.first().copied().unwrap_or(0) != 0; + let who2_actor = extras.get(4).copied().unwrap_or(0) != 0; + + let eff1 = parse_one_effect(extras, 1); + let eff2 = parse_one_effect(extras, 5); + + let actor_effect = if who1_actor { eff1 } else if who2_actor { eff2 } else { None }; + let opponent_effect = if !who1_actor { eff1 } else if !who2_actor { eff2 } else { None }; + + (actor_effect, opponent_effect) } else if extras.first().copied().unwrap_or(0) != 0 { - (Some(1), None) + // 12-value, actor's effect: [who=1, flag, id, rem, trail=0] + (parse_one_effect(extras, 1), None) } else { - (None, Some(2)) - }; - - let mine = mine_start.and_then(|s| parse_one_effect(extras, s)); - let theirs = theirs_start.and_then(|s| parse_one_effect(extras, s)); - (mine, theirs) + // 12-value, opponent's effect: [0, marker=1, flag, id, rem] + (None, parse_one_effect(extras, 2)) + } } /// The type of the participant in a fight From 5531ea20ec140c0ec616f682da51068100ef4e3d Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 22:21:37 +0200 Subject: [PATCH 17/24] fix: log fight parse errors instead of propagating them Change the update_rounds call site to log errors with warn!() instead of returning them via ?, matching the existing error-handling pattern used throughout the response parser. --- src/gamestate/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/gamestate/mod.rs b/src/gamestate/mod.rs index 9d80070..3406778 100644 --- a/src/gamestate/mod.rs +++ b/src/gamestate/mod.rs @@ -1436,7 +1436,9 @@ impl GameState { .and_then(|v| v.as_str().parse().ok()) .unwrap_or(1); let fight = self.get_fight(x); - fight.update_rounds(val.as_str(), fight_version)?; + if let Err(e) = fight.update_rounds(val.as_str(), fight_version) { + warn!("Failed to parse fight rounds: {e}"); + } } "othergroupname" => { other_guild From c130ac46292307f96c71a1488b1347f42bf6aca2 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 22:27:13 +0200 Subject: [PATCH 18/24] chore: remove pd_fight example --- examples/cached_testing.rs | 171 +++++++++++++++++++++++++++---------- examples/pd_fight.rs | 37 -------- 2 files changed, 127 insertions(+), 81 deletions(-) delete mode 100644 examples/pd_fight.rs diff --git a/examples/cached_testing.rs b/examples/cached_testing.rs index ef48681..20338ec 100644 --- a/examples/cached_testing.rs +++ b/examples/cached_testing.rs @@ -1,6 +1,6 @@ use clap::Parser; use regex::Regex; -use sf_api::{gamestate::GameState, session::*}; +use sf_api::{gamestate::GameState, session::*, sso::SFAccount}; #[tokio::main] pub async fn main() { @@ -8,56 +8,139 @@ pub async fn main() { .filter_level(log::LevelFilter::Info) .init(); - let _args = Args::parse(); - - let custom_resp: &str = "fightresult.battlereward:1/1/0/455/0/99/0/20406/20053/0/0/0/0/0/0/0/0/0/0/0/0&battlerewarditem:0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&ownplayersavecharacter:109244766/11162/0/38/77445/133260/2254/20053/6/102/102/3/102/2/4/7/0/0/6/1/12/0/0/790/43/85/0/53820/0/0/52/341/48/345/122/64/305/26/144/93/0/288/0/288/74/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/1/0/429/10680/0/551/0&fightversion:2&fightheader.fighters:0/0/0/0/1/11162/marenga/38/76284/76284/116/646/74/489/215/6/102/102/3/102/2/4/7/0/0/6/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/14141/Tomi Lee/31/31488/31488/233/606/233/246/228/1/102/101/2/108/7/2/4/0/0/7/1/12/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment:1/19/4/0/0/0/0/1/0/0/1/24/2/0/0/0/0/1/0/0&fightdecoration:0/0/0/0&externaltoolequipment:43/85/0/0/33/87/0/0&fight.r:14141/0/1/0/0/31488/72628/0/0/11162/0/0/0/0/72628/28146/0/0/14141/0/0/0/0/28146/70696/0/0/11162/0/0/0/0/70696/25787/0/0/14141/0/18/0/0/25787/65080/0/1/3/1/3/11162/0/1/4/0/65080/25787/1/3/1/3/0/14141/0/20/0/0/25787/60644/0/1/3/1/2/14141/0/1/0/0/25787/52216/0/1/3/1/2/11162/0/17/0/0/52216/22145/1/3/1/2/1/3/1/3/14141/0/19/0/0/22145/50381/1/3/1/3/1/3/1/1/14141/0/0/4/0/22145/50381/1/3/1/3/1/3/1/1/11162/0/19/0/0/50381/15584/1/3/1/1/1/3/1/2/11162/0/0/4/0/50381/15584/1/3/1/1/1/3/1/2/14141/0/19/0/0/15584/48811/1/3/1/2/1/3/1/0/14141/0/0/4/0/15584/48811/1/3/1/2/0/11162/0/20/0/0/48811/4895/0/1/3/1/1/11162/0/1/0/0/48811/-10241/0/1/3/1/1/&winnerid:11162&arena:1785178845/1/129117/15500/112937/1/1&dailytasklist:6/1/0/10/1/3/1/10/2/4/0/20/2/3/1/1/2/56/0/3/2/57/0/1/2/4/0/1/2/14/0/1/3/4/20/0/1/4&eventtasklist:77/0/10/1/76/0/10/1/75/0/10/1/57/0/10/1&deeds:0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/1/0/0/1/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0"; - - let _commands: Vec = vec![]; - - // Use cached login data as base, then apply custom fight response - let login_cache = std::fs::read_to_string("cache/bruhbruh.login").unwrap(); - let login_data: Response = serde_json::from_str(&login_cache).unwrap(); - let mut gs = GameState::new(login_data).unwrap(); + let args = Args::parse(); + + let custom_resp: Option<&str> = None; + let command = None; + + let username = args.username; + + let mut session = match args.sso { + true => SFAccount::login( + args.sso_username + .expect("SSO_USERNAME or --sso-username is required for SSO"), + args.password, + ) + .await + .unwrap() + .characters() + .await + .unwrap() + .into_iter() + .flatten() + .find(|a| a.username() == username) + .unwrap(), + false => Session::new( + &username, + &args.password, + ServerConnection::new( + &args + .server + .expect("SERVER or --server is required for non-SSO"), + ) + .unwrap(), + ), + }; + + _ = std::fs::create_dir("cache"); + let cache_name = format!("cache/{username}.login"); + + let login_data = match (args.cache, std::fs::read_to_string(&cache_name)) { + (_, Ok(s)) if args.diff => { + let old: Response = serde_json::from_str(&s).unwrap(); + let new = session.login().await.unwrap(); + // TODO: Diff the two values + for (&key, new_val) in new.values() { + if key.ends_with("id") + || key == "timestamp" + || key == "expeditionevent" + || key == "idle" + { + continue; + } + let Some(old_val) = old.values().get(key) else { + println!("New key: {key}"); + continue; + }; + let old_val: Vec<_> = old_val.as_str().split("/").collect(); + let new_val: Vec<_> = new_val.as_str().split("/").collect(); + for (idx, (new, old)) in + new_val.into_iter().zip(old_val).enumerate() + { + if new.starts_with("17") && new.len() == "1774765933".len() + { + continue; + } + if key == "ownplayersave" && idx == 478 { + continue; + } + if new != old { + println!("{key}[{idx}] {old} => {new}"); + } + } + } + return; + } + (true, Ok(s)) => serde_json::from_str(&s).unwrap(), + _ => { + let login_data = session.login().await.unwrap(); + let ld = serde_json::to_string_pretty(&login_data).unwrap(); + std::fs::write(&cache_name, ld).unwrap(); + login_data + } + }; - // Overwrite with our custom fight response - let resp = Response::parse( - custom_resp.to_string(), - chrono::Local::now().naive_local(), - ) - .unwrap(); - gs.update(resp).unwrap(); - - // Dump the parsed fight actions - if let Some(fight) = &gs.last_fight { - for (j, sf) in fight.fights.iter().enumerate() { - println!("--- SingleFight {j} ---"); - if let Some(fa) = &sf.fighter_a { - println!(" fighter_a: type={:?} id={} name={:?} level={} life={}", - fa.typ, fa.id, fa.name, fa.level, fa.life); + if let Some(re) = args.search { + for (&key, value) in login_data.values() { + if key == "ownplayersave" { + continue; } - if let Some(fb) = &sf.fighter_b { - println!(" fighter_b: type={:?} id={} name={:?} level={} life={}", - fb.typ, fb.id, fb.name, fb.level, fb.life); + if let Some(key_re) = &args.search_key + && !key_re.is_match(key) + { + continue; } - for (k, action) in sf.actions.iter().enumerate() { - println!( - " actions[{k}]: actor={}, action={:?}, outcome={:?}, \ - target_hp={}, actor_hp={:?}, effect={:?}/{:?}, \ - state={:?}/{:?}", - action.acting_id, - action.action, - action.outcome, - action.other_new_life, - action.actor_life, - action.actor_effect, - action.opponent_effect, - action.actor_state, - action.defender_state, - ); + let values: Vec<_> = value.as_str().split('/').collect(); + for (pos, num) in values.into_iter().enumerate() { + if re.is_match(num) { + println!("{key}[{pos}] = {num}") + } } } } + let mut gs = GameState::new(login_data).unwrap(); + + if let Some(resp) = custom_resp { + let resp = Response::parse( + resp.to_string(), + chrono::Local::now().naive_local(), + ) + .unwrap(); + gs.update(resp).unwrap(); + } + + let Some(command) = command else { + let js = serde_json::to_string_pretty(&gs).unwrap(); + std::fs::write("character.json", js).unwrap(); + return; + }; + let cache_name = format!( + "cache/{username}-{}.response", + serde_json::to_string(&command).unwrap() + ); + + let resp = match (args.cache, std::fs::read_to_string(&cache_name)) { + (true, Ok(s)) => serde_json::from_str(&s).unwrap(), + _ => { + let resp = session.send_command_raw(&command).await.unwrap(); + let ld = serde_json::to_string_pretty(&resp).unwrap(); + std::fs::write(cache_name, ld).unwrap(); + resp + } + }; + + gs.update(&resp).unwrap(); let js = serde_json::to_string_pretty(&gs).unwrap(); std::fs::write("character.json", js).unwrap(); } diff --git a/examples/pd_fight.rs b/examples/pd_fight.rs deleted file mode 100644 index a5cfc54..0000000 --- a/examples/pd_fight.rs +++ /dev/null @@ -1,37 +0,0 @@ -use sf_api::{gamestate::GameState, session::Response}; - -fn main() { - let body = "fightadditionalplayers.r:-924,-935,-934,-933,-954,&fightresult.underworldpillage:1/1/0/2051167/0/0&underworldmaxsouls:2116140&owntower.towerSave:446275/0/100/359/1/0/0/4144/2539/2422/3743/2900/9976/3912/2391/7953/4079/0/0/0/0/0/17534/481/951/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/416/5/56614/600/10/359/2/0/0/2422/2539/4144/3743/2900/1240/1332/9704/6731/2203/0/0/0/0/0/4235/1244/2734/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/358/4/51450/600/2/359/3/0/0/2422/4144/2539/3743/2900/1858/12133/910/8900/4199/0/0/0/0/0/7117/582/1154/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/368/1/106984/600/0/0/15/15/29/15/15/15/15/15/15/15/0/1188000/1188000/162518400/0/49500/1439832658/1439832658/24824701/1785179065/0/0/0/504/1/2000/2000/80×tamp:1785179068&resources:446275/750/2429597169/195/800/11939837/23475610/2045029/9000000/52483/69902/125782697/7/6/6/6/6/0&fightversion:2&fightheader1.fighters:16/0/0/0/1/0/0/600/170125070/170125070/56614/56614/56614/56614/22279/-910/1/5/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/162271771/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment1:1/29/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration1:0/0/0/1&externaltoolequipment1:1202/1202/0/0/1152/2408/0/0&fight1.r:0/0/1/4/0/170125070/162271771/0/0/7779/0/1/0/0/162271771/135970081/0/0/0/0/0/4/0/135970081/162271771/0/0/7779/0/1/0/0/162271771/98670378/0/0/0/0/1/0/0/98670378/146632552/0/0/7779/0/0/0/0/146632552/87835157/0/0/0/0/0/0/0/87835157/137249020/0/0/7779/0/1/0/0/137249020/51306093/0/0/0/0/0/4/0/51306093/137249020/0/0/7779/0/0/0/0/137249020/33574747/0/0/0/0/0/4/0/33574747/137249020/0/0/7779/0/0/0/0/137249020/17642480/0/0/0/0/0/4/0/17642480/137249020/0/0/7779/0/0/0/0/137249020/-7358042/0/0/&winnerid1.s:7779&fightversion:2&fightheader2.fighters:16/0/0/0/1/0/0/600/170125070/170125070/56614/56614/56614/56614/22279/-910/2/5/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/137249020/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment2:1/29/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration2:0/0/0/1&externaltoolequipment2:1202/1202/0/0/1152/2408/0/0&fight2.r:0/0/0/0/0/170125070/132557254/0/0/7779/0/0/0/0/132557254/162705207/0/0/0/0/1/4/0/162705207/132557254/0/0/7779/0/1/0/0/132557254/135652675/0/0/0/0/1/0/0/135652675/116918035/0/0/7779/0/1/0/0/116918035/93579983/0/0/0/0/0/4/0/93579983/116918035/0/0/7779/0/1/0/0/116918035/36970192/0/0/0/0/1/4/0/36970192/116918035/0/0/7779/0/1/0/0/116918035/-35637136/0/0/&winnerid2.s:7779&fightversion:2&fightheader3.fighters:16/0/0/0/1/0/0/600/170125070/170125070/56614/56614/56614/56614/22279/-910/3/5/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/116918035/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment3:1/29/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration3:0/0/0/1&externaltoolequipment3:1202/1202/0/0/1152/2408/0/0&fight3.r:0/0/1/0/0/170125070/107534503/0/0/7779/0/1/0/0/107534503/136933149/0/0/0/0/1/4/0/136933149/107534503/0/0/7779/0/0/0/0/107534503/127848422/0/0/0/0/0/4/0/127848422/107534503/0/0/7779/0/1/0/0/107534503/81537640/0/0/0/0/0/4/0/81537640/107534503/0/0/7779/0/1/0/0/107534503/44969616/0/0/0/0/0/4/0/44969616/107534503/0/0/7779/0/0/0/0/107534503/30286276/0/0/0/0/1/4/0/30286276/107534503/0/0/7779/0/1/0/0/107534503/-19498511/0/0/&winnerid3.s:7779&fightversion:2&fightheader4.fighters:16/0/0/0/1/0/0/600/170125070/170125070/56614/56614/56614/56614/22279/-910/4/5/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/107534503/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment4:1/29/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration4:0/0/0/1&externaltoolequipment4:1202/1202/0/0/1152/2408/0/0&fight4.r:0/0/0/0/0/170125070/102842737/0/0/7779/0/1/0/0/102842737/139969965/0/0/0/0/1/4/0/139969965/102842737/0/0/7779/0/0/0/0/102842737/128189393/0/0/0/0/1/4/0/128189393/102842737/0/0/7779/0/1/0/0/102842737/78034948/0/0/0/0/1/4/0/78034948/102842737/0/0/7779/0/0/0/0/102842737/61584804/0/0/0/0/0/0/0/61584804/91895284/0/0/7779/0/1/0/0/91895284/2060039/0/0/0/0/0/0/0/2060039/79383908/0/0/7779/0/0/0/0/79383908/-17195662/0/0/&winnerid4.s:7779&fightversion:2&fightheader5.fighters:16/0/0/0/1/0/0/600/170125070/170125070/56614/56614/56614/56614/22279/-910/5/5/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/79383908/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment5:1/29/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration5:0/0/0/1&externaltoolequipment5:1202/1202/0/0/1152/2408/0/0&fight5.r:0/0/1/0/0/170125070/70000376/0/0/7779/0/0/0/0/70000376/164697997/0/0/0/0/0/0/0/164697997/63744688/0/0/7779/0/1/0/0/63744688/132277540/0/0/0/0/0/0/0/132277540/55925079/0/0/7779/0/0/0/0/55925079/121304553/0/0/0/0/0/0/0/121304553/46541547/0/0/7779/0/0/0/0/46541547/106886566/0/0/0/0/1/4/0/106886566/46541547/0/0/7779/0/1/0/0/46541547/59322337/0/0/0/0/1/0/0/59322337/21518795/0/0/7779/0/0/0/0/21518795/39871703/0/0/0/0/0/0/0/39871703/7443497/0/0/7779/0/1/0/0/7443497/-20294977/0/0/&winnerid5.s:7779&fightversion:2&fightheader6.fighters:16/0/0/0/1/0/0/600/154607250/154607250/51450/51450/51450/51450/24908/-924/1/4/0/0/0/0/0/0/0/0/0/1/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/7779/KChaos/503/162271771/7443497/9160/63466/9372/46662/21947/1/1/4/7/104/6/4/10/102/0/8/1/3/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0/0&fightequipment6:1/22/5/0/0/0/0/1/0/0/1/2051/1/41/26/0/0/1/0/0&fightdecoration6:0/0/0/1&externaltoolequipment6:1202/1202/0/0/1152/2408/0/0&fight6.r:0/0/1/0/0/154607250/-1008967/0/0/&winnerid6.s:0&companionequipment:..."; - - let login_cache = std::fs::read_to_string("cache/bruhbruh.login").unwrap(); - let login_data: Response = serde_json::from_str(&login_cache).unwrap(); - let mut gs = GameState::new(login_data).unwrap(); - - // Trim at a safe point to avoid the huge companionequipment blob - let trimmed = body.split("&companionequipment").next().unwrap(); - let resp = Response::parse(trimmed.to_string(), chrono::Local::now().naive_local()).unwrap(); - gs.update(resp).unwrap(); - - if let Some(fight) = &gs.last_fight { - println!("winner_id: {:?}, has_player_won: {}, extra: {:?}", - fight.fights.first().map(|f| f.winner_id), - fight.has_player_won, - fight.extra, - ); - for (j, sf) in fight.fights.iter().enumerate() { - println!("--- SingleFight {j} ---"); - if let Some(fa) = &sf.fighter_a { - println!(" fighter_a: type={:?} id={} name={:?} level={} life={}", - fa.typ, fa.id, fa.name, fa.level, fa.life); - } - if let Some(fb) = &sf.fighter_b { - println!(" fighter_b: type={:?} id={} name={:?} level={} life={}", - fb.typ, fb.id, fb.name, fb.level, fb.life); - } - for (k, action) in sf.actions.iter().enumerate() { - println!(" actions[{k}]: actor={}, action={:?}, outcome={:?}, target_hp={}, actor_hp={:?}", - action.acting_id, action.action, action.outcome, action.other_new_life, action.actor_life); - } - } - } -} From 58349de49a35595371c4f8f9fbd6c3b50b079d95 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 22:30:49 +0200 Subject: [PATCH 19/24] Rename Replay cmd --- src/command.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/command.rs b/src/command.rs index e3142eb..ecfbd32 100644 --- a/src/command.rs +++ b/src/command.rs @@ -639,7 +639,7 @@ pub enum Command { msg_id: u32, }, /// Views the replay of a combat log entry - PlayerCombatLogView { + ReplayFight { msg_id: u32, }, /// Upgrades the Hall of Knights to the next level @@ -1399,7 +1399,7 @@ impl Command { Command::FortressSetCAEnemy { msg_id } => { format!("FortressEnemy:0/{msg_id}") } - Command::PlayerCombatLogView { msg_id } => { + Command::ReplayFight { msg_id } => { format!("PlayerCombatLogView:{msg_id}") } Command::FortressUpgradeHallOfKnights => { From 66b758e0ac191398712397598e38989de8c9c5c2 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 22:50:11 +0200 Subject: [PATCH 20/24] Fmt --- src/command.rs | 1 + src/gamestate/arena.rs | 82 +++++++++++++++++++++++++++--------------- src/gamestate/mod.rs | 8 ++--- 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/src/command.rs b/src/command.rs index ecfbd32..6200771 100644 --- a/src/command.rs +++ b/src/command.rs @@ -640,6 +640,7 @@ pub enum Command { }, /// Views the replay of a combat log entry ReplayFight { + /// The id of the message msg_id: u32, }, /// Upgrades the Hall of Knights to the next level diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index fbeb791..f860634 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -2,8 +2,10 @@ use chrono::{DateTime, Local}; use num_traits::FromPrimitive; use super::{items::*, *}; -use crate::misc::{ArrSkip, CGet}; -use crate::PlayerId; +use crate::{ + PlayerId, + misc::{ArrSkip, CGet}, +}; /// The arena, that a player can fight other players in #[derive(Debug, Default, Clone)] @@ -80,9 +82,7 @@ pub enum FightExtra { mages_defeated: u32, }, /// Underworld lure — souls pillaged from another player - UnderworldLure { - souls: i64, - }, + UnderworldLure { souls: i64 }, } impl Fight { @@ -207,20 +207,22 @@ impl SingleFight { // 9-value (no effects): // actor / 0 / type / outcome / 0 / actor_hp / target_hp / 0 / 0 // 12-value (one fighter has an effect): - // actor / 0 / type / outcome / 0 / actor_hp / target_hp / [5 extras] - // Actor's effect: [who=1, flag, id, rem, trail=0] - // Opponent effect: [0, marker=1, flag, id, rem] - // 15-value (both fighters have effects, or one has two): - // actor / 0 / type / outcome / 0 / actor_hp / target_hp / [8 extras] - // [who1, eff1_flag, eff1_id, eff1_rem, who2, eff2_flag, eff2_id, eff2_rem] - // who={0→opponent, ≠0→actor} + // actor / 0 / type / outcome / 0 / actor_hp / target_hp / [5 + // extras] Actor's effect: [who=1, flag, id, + // rem, trail=0] Opponent effect: [0, + // marker=1, flag, id, rem] 15-value (both + // fighters have effects, or one has two): actor / 0 / type + // / outcome / 0 / actor_hp / target_hp / [8 extras] + // [who1, eff1_flag, eff1_id, eff1_rem, who2, eff2_flag, eff2_id, + // eff2_rem] who={0→opponent, ≠0→actor} let raw: Vec<&str> = data.split('/').collect(); // Parse once to i64 for robust stride detection - let values: Vec = raw.iter().filter_map(|s| s.parse().ok()).collect(); + let values: Vec = + raw.iter().filter_map(|s| s.parse().ok()).collect(); let mut i = 0; while i + 9 <= values.len() { - let extras_first = values.cget(i + 7, "extras_first")?; // 0 if none; who=0 → opponent, ≠0 → actor + let extras_first = values.cget(i + 7, "extras_first")?; // 0 if none; who=0 → opponent, ≠0 → actor let extras_second = values.cget(i + 8, "extras_second")?; // Detect stride: 9-value if both effect slots are 0 @@ -240,8 +242,10 @@ impl SingleFight { }; let acting_id = values.cget(i, "acting_id")?; - let action_type: u32 = u32::try_from(values.cget(i + 2, "action_type")?).unwrap_or(0); - let outcome_code: u32 = u32::try_from(values.cget(i + 3, "outcome")?).unwrap_or(0); + let action_type: u32 = + u32::try_from(values.cget(i + 2, "action_type")?).unwrap_or(0); + let outcome_code: u32 = + u32::try_from(values.cget(i + 3, "outcome")?).unwrap_or(0); let action = FightActionType::parse(action_type); let outcome = match outcome_code { @@ -253,12 +257,15 @@ impl SingleFight { let actor_life = values.cget(i + 5, "actor_life")?; let target_life = values.cget(i + 6, "target_life")?; - let actor_state = FighterState::from_raw(values.cget(i + 1, "actor_state")?); - let defender_state = FighterState::from_raw(values.cget(i + 4, "defender_state")?); + let actor_state = + FighterState::from_raw(values.cget(i + 1, "actor_state")?); + let defender_state = + FighterState::from_raw(values.cget(i + 4, "defender_state")?); let (actor_effect, opponent_effect) = if stride > 9 { let extras_start = values.skip(i + 7, "extras")?; - let extra_vals = extras_start.get(..(stride - 7)).unwrap_or(&[]); + let extra_vals = + extras_start.get(..(stride - 7)).unwrap_or(&[]); parse_active_effect(extra_vals) } else { (None, None) @@ -424,7 +431,7 @@ pub enum FighterState { Normal, /// Druid in eagle form EagleForm, - /// Druid in bear form + /// Druid in bear form BearForm, /// Paladin in Defensive stance (value 20) DefensiveStance, @@ -512,11 +519,13 @@ pub struct FightAction { pub actor_effect: Option, /// The active effect on the opponent, if any (minion or ability) pub opponent_effect: Option, - /// Decoded state of the acting fighter (from position 1 in 9-value format). - /// Non-zero when the fighter has an active stance/special ability. + /// Decoded state of the acting fighter (from position 1 in 9-value + /// format). Non-zero when the fighter has an active stance/special + /// ability. pub actor_state: FighterState, - /// Decoded state of the defending fighter (from position 4 in 9-value format). - /// Non-zero when the fighter has an active stance/special ability. + /// Decoded state of the defending fighter (from position 4 in 9-value + /// format). Non-zero when the fighter has an active stance/special + /// ability. pub defender_state: FighterState, } @@ -563,8 +572,10 @@ impl FightActionType { 2 => FightActionType::MushroomCatapult, 10 => FightActionType::BattleMageFireball, 11 => FightActionType::Summon, - 12 => FightActionType::MinionAttack, // minion acts alone (e.g. after summon) - 15 => FightActionType::MinionAttack2, // minion acts after player also attacked + 12 => FightActionType::MinionAttack, /* minion acts alone (e.g. + * after summon) */ + 15 => FightActionType::MinionAttack2, /* minion acts after + * player also attacked */ 14 => FightActionType::Revive, 17 | 18 => FightActionType::ThrowPoison, 19 | 20 => FightActionType::PoisonTick, @@ -611,7 +622,8 @@ fn parse_one_effect(extras: &[i64], start: usize) -> Option { }, _ => { warn!( - "Unknown active effect: flag={flag}, id={id}, remaining={remaining}" + "Unknown active effect: flag={flag}, id={id}, \ + remaining={remaining}" ); ActiveEffect::Unknown { flag: clamp_u32(flag), @@ -649,8 +661,20 @@ fn parse_active_effect( let eff1 = parse_one_effect(extras, 1); let eff2 = parse_one_effect(extras, 5); - let actor_effect = if who1_actor { eff1 } else if who2_actor { eff2 } else { None }; - let opponent_effect = if !who1_actor { eff1 } else if !who2_actor { eff2 } else { None }; + let actor_effect = if who1_actor { + eff1 + } else if who2_actor { + eff2 + } else { + None + }; + let opponent_effect = if !who1_actor { + eff1 + } else if !who2_actor { + eff2 + } else { + None + }; (actor_effect, opponent_effect) } else if extras.first().copied().unwrap_or(0) != 0 { diff --git a/src/gamestate/mod.rs b/src/gamestate/mod.rs index 3406778..a57523b 100644 --- a/src/gamestate/mod.rs +++ b/src/gamestate/mod.rs @@ -1436,7 +1436,8 @@ impl GameState { .and_then(|v| v.as_str().parse().ok()) .unwrap_or(1); let fight = self.get_fight(x); - if let Err(e) = fight.update_rounds(val.as_str(), fight_version) { + if let Err(e) = fight.update_rounds(val.as_str(), fight_version) + { warn!("Failed to parse fight rounds: {e}"); } } @@ -2235,9 +2236,8 @@ impl GameState { if data.len() < 1 + ITEM_PARSE_LEN { return Ok(()); } - let count = usize::try_from( - data.cget(0, "equip_count")?, - ).unwrap_or(0); + let count = + usize::try_from(data.cget(0, "equip_count")?).unwrap_or(0); let items: Vec> = data .skip(1, "equip_data")? .chunks_exact(ITEM_PARSE_LEN) From 45e7c968e895ec0d92bc19076f6266b0b70ee289 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 22:51:21 +0200 Subject: [PATCH 21/24] fmt --- src/gamestate/arena.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index f860634..949609d 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -572,10 +572,10 @@ impl FightActionType { 2 => FightActionType::MushroomCatapult, 10 => FightActionType::BattleMageFireball, 11 => FightActionType::Summon, - 12 => FightActionType::MinionAttack, /* minion acts alone (e.g. - * after summon) */ - 15 => FightActionType::MinionAttack2, /* minion acts after - * player also attacked */ + 12 => FightActionType::MinionAttack, // minion acts alone (e.g. + // after summon) + 15 => FightActionType::MinionAttack2, // minion acts after + // player also attacked 14 => FightActionType::Revive, 17 | 18 => FightActionType::ThrowPoison, 19 | 20 => FightActionType::PoisonTick, From b2f275df2a16a806c40d8c9675fb54e6c775167a Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 23:16:05 +0200 Subject: [PATCH 22/24] Add misisng FightActionTypes --- src/gamestate/arena.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index 949609d..55e648f 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -543,10 +543,14 @@ pub enum FightActionType { MushroomCatapult, /// Summons a minion (Necromancer) Summon, - /// A minion attacks (Necromancer skeleton) + /// A minion attacks (Necromancer) MinionAttack, - /// A minion attacks after the main fighter attacked - MinionAttack2, + /// A minion's critical hit + MinionAttackCrit, + /// `Druid`'s eagle swoop attack + Swoop, + /// `Druid`'s eagle swoop critical hit + SwoopCrit, /// `BattleMage`'s opening fireball BattleMageFireball, /// Assassin's main hand attack @@ -572,11 +576,11 @@ impl FightActionType { 2 => FightActionType::MushroomCatapult, 10 => FightActionType::BattleMageFireball, 11 => FightActionType::Summon, - 12 => FightActionType::MinionAttack, // minion acts alone (e.g. - // after summon) - 15 => FightActionType::MinionAttack2, // minion acts after - // player also attacked + 12 => FightActionType::MinionAttack, + 13 => FightActionType::Swoop, 14 => FightActionType::Revive, + 15 => FightActionType::MinionAttackCrit, + 16 => FightActionType::SwoopCrit, 17 | 18 => FightActionType::ThrowPoison, 19 | 20 => FightActionType::PoisonTick, 100 => FightActionType::AssassinMainHand, From 8a4f37b44096dcc6231f56d2746a082773b50355 Mon Sep 17 00:00:00 2001 From: Marenga Date: Mon, 27 Jul 2026 23:34:10 +0200 Subject: [PATCH 23/24] remove equipment parsing --- src/gamestate/arena.rs | 6 +----- src/gamestate/mod.rs | 25 +------------------------ 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/src/gamestate/arena.rs b/src/gamestate/arena.rs index 55e648f..4808662 100644 --- a/src/gamestate/arena.rs +++ b/src/gamestate/arena.rs @@ -157,12 +157,8 @@ pub struct SingleFight { pub fighter_a: Option, /// The stats of the first fighter pub fighter_b: Option, - /// The action this fight involved. Note that this will likely be changed - /// in the future, as is it hard to interpret + /// The action this fight involved pub actions: Vec, - /// Raw equipment data for `fighter_a`. Each entry is 19 values (`model_id` - /// + item stats). The encoding differs from regular Item format. - pub equipment: Vec>, } impl SingleFight { diff --git a/src/gamestate/mod.rs b/src/gamestate/mod.rs index a57523b..5d77f74 100644 --- a/src/gamestate/mod.rs +++ b/src/gamestate/mod.rs @@ -2232,30 +2232,7 @@ impl GameState { // Format: item_count / 19-value items (different encoding // from regular Item — first value is model_id, not type) let fight_no = fight_no_from_header(x) - 1; - let data: Vec = val.into_list("fight equipment")?; - if data.len() < 1 + ITEM_PARSE_LEN { - return Ok(()); - } - let count = - usize::try_from(data.cget(0, "equip_count")?).unwrap_or(0); - let items: Vec> = data - .skip(1, "equip_data")? - .chunks_exact(ITEM_PARSE_LEN) - .take(count) - .map(|c| c.to_vec()) - .collect(); - if !items.is_empty() { - let fights = &mut self - .last_fight - .get_or_insert_with(Default::default) - .fights; - if fights.len() <= fight_no { - fights.resize_with(fight_no + 1, Default::default); - } - if let Some(sf) = fights.get_mut(fight_no) { - sf.equipment = items; - } - } + // TODO: Try and parse this } x if x.starts_with("externaltoolequipment") => { // External tool/mount equipment data. Format unknown. From 4667f201796b6d131e5fee4148869b83723e52ac Mon Sep 17 00:00:00 2001 From: Marenga Date: Sun, 2 Aug 2026 14:09:42 +0200 Subject: [PATCH 24/24] Remove dead fight_no binding left after equipment parsing removal --- src/gamestate/mod.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/gamestate/mod.rs b/src/gamestate/mod.rs index 5d77f74..acde1f3 100644 --- a/src/gamestate/mod.rs +++ b/src/gamestate/mod.rs @@ -2231,8 +2231,6 @@ impl GameState { // Equipment data for each fighter in multi-fight responses. // Format: item_count / 19-value items (different encoding // from regular Item — first value is model_id, not type) - let fight_no = fight_no_from_header(x) - 1; - // TODO: Try and parse this } x if x.starts_with("externaltoolequipment") => { // External tool/mount equipment data. Format unknown.