diff --git a/native/src/sql/sim/attacks.rs b/native/src/sql/sim/attacks.rs index 0f23718..3676d1b 100644 --- a/native/src/sql/sim/attacks.rs +++ b/native/src/sql/sim/attacks.rs @@ -6,7 +6,7 @@ //! fireball. `A_Scream`, `A_Pain`, `A_XScream` and `A_Fall` leave a flag //! or nothing at all. -use super::{inter, maputl, sight}; +use super::{inter, maputl, shoot, sight}; use crate::sql::Statement; use crate::sql::{bind, fixed}; @@ -16,10 +16,16 @@ const FRACUNIT: i64 = 1 << 16; const ANGLE_WRAP: i64 = 1 << 32; /// `p_enemy.c`: what the fuzz a `MF_SHADOW` target draws is shifted by. const FACE_SHIFT: u32 = 21; -/// `p_local.h`: how far a claw reaches, and how far short of it -/// `P_CheckMeleeRange` stops. +/// `p_local.h`: how far a claw reaches, how far short of it +/// `P_CheckMeleeRange` stops, and how far a bullet reaches. const MELEERANGE: i64 = 64 * FRACUNIT; const MELEE_SLOP: i64 = 20 * FRACUNIT; +const MISSILERANGE: i64 = 32 * 64 * FRACUNIT; +/// `p_enemy.c`: what the fuzz a shot's own angle takes is shifted by. +const SPREAD_SHIFT: u32 = 20; +/// `p_mobj.c`: how many numbers `P_SpawnPuff` and `P_SpawnBlood` draw +/// between them, which every shot that reaches something pays. +const DEBRIS_DRAWS: i64 = 4; /// `p_local.h`: the furthest a thing's edge reaches from the cell its /// origin sits in. const MAXRADIUS: i64 = 32 * FRACUNIT; @@ -95,7 +101,13 @@ pub fn constants(db: &str) -> Vec<(String, String)> { super::table_column(db, "mobjinfo", "deathsound"), ), ]; - for name in ["A_TroopAttack", "A_SargAttack", "A_Explode"] { + for name in [ + "A_TroopAttack", + "A_SargAttack", + "A_Explode", + "A_PosAttack", + "A_SPosAttack", + ] { constants.push(( name.to_lowercase(), format!("assumeNotNull((SELECT id FROM {db}.action_functions WHERE name = '{name}'))"), @@ -122,9 +134,12 @@ pub fn fallen(flags: &str) -> String { pub struct Attacking<'a> { pub m_x: &'a str, pub m_y: &'a str, + pub m_z: &'a str, pub m_angle: &'a str, + pub m_height: &'a str, pub m_flags: &'a str, pub m_type: &'a str, + pub m_health: &'a str, pub m_target: &'a str, pub prndindex: &'a str, } @@ -200,13 +215,71 @@ pub fn no_attack() -> String { .to_owned() } +/// `A_FaceTarget`, as the values a routine that begins with it reads. +/// +/// `slot` names the thing attacking and `base` how many numbers the tic +/// drew before the call. The values are `fc_slot`, `fc_target`, `fc_runs`, +/// `fc_angle` and `fc_flags`, plus `fc_fuzzy`, which is 1 where the target +/// carries `MF_SHADOW` and the angle is turned by the first two numbers the +/// call draws. A thing with no target runs none of it. +fn face_target(slot: &str, base: &str, world: &Attacking<'_>) -> Vec<(String, String)> { + let at = |array: &str| format!("{array}[fc_slot]"); + let on = |array: &str| format!("{array}[fc_target]"); + let across = |array: &str| format!("toInt32(toInt64({}) - toInt64({}))", on(array), at(array)); + let draw = |nth: &str| { + format!( + "toInt64(rnd[1 + bitAnd(toUInt32({}) + toUInt32({base}) + {nth}, 255)])", + world.prndindex, + ) + }; + vec![ + ("fc_slot".to_owned(), format!("toUInt32({slot})")), + ( + "fc_target".to_owned(), + format!("toUInt32({})", at(world.m_target)), + ), + ("fc_runs".to_owned(), "toUInt8(fc_target != 0)".to_owned()), + ( + "fc_fuzzy".to_owned(), + format!( + "toUInt8(fc_runs = 1 AND bitAnd({}, {MF_SHADOW}) != 0)", + on(world.m_flags) + ), + ), + ( + "fc_aimed".to_owned(), + fixed::point_to_angle(&across(world.m_x), &across(world.m_y), "tantoangle"), + ), + ( + "fc_angle".to_owned(), + format!( + "toUInt32(if(fc_runs = 0, {held}, bitAnd(toInt64(fc_aimed) \ + + if(fc_fuzzy = 1, bitShiftLeft({} - {}, {FACE_SHIFT}), 0) + {ANGLE_WRAP}, {})))", + draw("1"), + draw("2"), + ANGLE_WRAP - 1, + held = at(world.m_angle), + ), + ), + ( + "fc_flags".to_owned(), + format!( + "toInt32(if(fc_runs = 1, bitAnd({held}, {}), {held}))", + !MF_AMBUSH, + held = at(world.m_flags), + ), + ), + ] +} + /// What one attack works out, as the values a body reads and the /// [`attacked`] tuple it answers with. fn attacks(world: &Attacking<'_>) -> (Vec<(String, String)>, String) { let a = |field: usize| format!("ak_ask.{field}"); - let at = |array: &str| format!("{array}[ak_slot]"); - let on = |array: &str| format!("{array}[ak_target]"); - let mut values: Vec<(String, String)> = Vec::new(); + let at = |array: &str| format!("{array}[fc_slot]"); + let on = |array: &str| format!("{array}[fc_target]"); + let mut values: Vec<(String, String)> = + face_target(&a(striking::SLOT), &a(striking::BASE), world); let mut value = |name: &str, expr: String| values.push((name.to_owned(), expr)); let draw = |nth: &str| { format!( @@ -217,37 +290,11 @@ fn attacks(world: &Attacking<'_>) -> (Vec<(String, String)>, String) { }; let across = |array: &str| format!("toInt32(toInt64({}) - toInt64({}))", on(array), at(array)); - value("ak_slot", format!("toUInt32({})", a(striking::SLOT))); - value("ak_target", format!("toUInt32({})", at(world.m_target))); - value("ak_runs", "toUInt8(ak_target != 0)".to_owned()); - // `A_FaceTarget`: the angle at the target, with the fuzz a `MF_SHADOW` - // one draws, and the ambush flag off. - value( - "ak_fuzzy", - format!( - "toUInt8(ak_runs = 1 AND bitAnd({}, {MF_SHADOW}) != 0)", - on(world.m_flags) - ), - ); - value( - "ak_aimed", - fixed::point_to_angle(&across(world.m_x), &across(world.m_y), "tantoangle"), - ); - value( - "ak_angle", - format!( - "toUInt32(bitAnd(toInt64(ak_aimed) \ - + if(ak_fuzzy = 1, bitShiftLeft({} - {}, {FACE_SHIFT}), 0) + {ANGLE_WRAP}, {}))", - draw("1"), - draw("2"), - ANGLE_WRAP - 1, - ), - ); // `P_CheckMeleeRange`, which the sight answer finishes. value( "ak_near", format!( - "toUInt8(ak_runs = 1 AND toInt64({}) < {MELEERANGE} - {MELEE_SLOP} \ + "toUInt8(fc_runs = 1 AND toInt64({}) < {MELEERANGE} - {MELEE_SLOP} \ + toInt64(mobj_radius[1 + {}]) AND {} = 1)", fixed::aprox_distance(&across(world.m_x), &across(world.m_y)), on(world.m_type), @@ -263,22 +310,18 @@ fn attacks(world: &Attacking<'_>) -> (Vec<(String, String)>, String) { "toInt32(if(ak_near = 0, 0, multiIf(\ ak_routine = a_troopattack, ({} % 8 + 1) * 3, \ ak_routine = a_sargattack, ({} % 10 + 1) * 4, 0)))", - draw("1 + 2 * toUInt32(ak_fuzzy)"), - draw("1 + 2 * toUInt32(ak_fuzzy)"), + draw("1 + 2 * toUInt32(fc_fuzzy)"), + draw("1 + 2 * toUInt32(fc_fuzzy)"), ), ); let members = [ - format!("toUInt32(if(ak_runs = 1, ak_angle, {}))", at(world.m_angle),), - format!( - "toInt32(if(ak_runs = 1, bitAnd({held}, {}), {held}))", - !MF_AMBUSH, - held = at(world.m_flags), - ), + "toUInt32(fc_angle)".to_owned(), + "toInt32(fc_flags)".to_owned(), "toUInt8(ak_near)".to_owned(), "toInt32(ak_damage)".to_owned(), - "toUInt8(ak_runs = 1 AND ak_near = 0 AND ak_routine = a_troopattack)".to_owned(), - "toUInt32(if(ak_runs = 0, 0, 2 * toUInt32(ak_fuzzy) + toUInt32(ak_near)))".to_owned(), - "toUInt8(ak_runs = 1 AND ak_routine != a_troopattack AND ak_routine != a_sargattack)" + "toUInt8(fc_runs = 1 AND ak_near = 0 AND ak_routine = a_troopattack)".to_owned(), + "toUInt32(if(fc_runs = 0, 0, 2 * toUInt32(fc_fuzzy) + toUInt32(ak_near)))".to_owned(), + "toUInt8(fc_runs = 1 AND ak_routine != a_troopattack AND ak_routine != a_sargattack)" .to_owned(), ]; (values, format!("({})", members.join(", "))) @@ -490,6 +533,288 @@ fn bombs(world: &Blast<'_>, hurting: &inter::Hurting<'_>) -> (Vec<(String, Strin (values, format!("({})", members.join(", "))) } +// --------------------------------------------------------------------------- +// A_PosAttack and A_SPosAttack +// --------------------------------------------------------------------------- + +/// Where each field of a gunshot ask sits in its tuple. +pub mod gunning { + /// The slot firing. + pub const SLOT: usize = 1; + /// The `action_functions` id of the routine the frame carries. + pub const ROUTINE: usize = 2; + /// How many numbers the tic drew before this call's own. + pub const BASE: usize = 3; +} + +/// Where each field of what a gunshot leaves sits in its tuple. +/// +/// The four arrays run together, one entry per shot in the order they were +/// fired. A caller reads a shot's own answer out of [`SHOTS`] and hands the +/// two bases to `mobj::spawn_debris` and `inter::damage_mobj`. +pub mod gunned { + pub const ANGLE: usize = 1; + pub const FLAGS: usize = 2; + /// The slope the one `P_AimLineAttack` found, which every shot leaves + /// at. + pub const SLOPE: usize = 3; + /// What each shot reached, as a `shoot::reached` answer. + pub const SHOTS: usize = 4; + /// What each shot rolled. + pub const DAMAGE: usize = 5; + /// How many numbers the tic had drawn before each shot's own puff or + /// blood spot. + pub const SPAWN_BASE: usize = 6; + /// How many it had drawn before each shot's damage call. + pub const HURT_BASE: usize = 7; + /// How many numbers the call drew, the shots' puffs and damage + /// included. + pub const DRAWS: usize = 8; + /// 1 where the call reached a path this does not write. + pub const STUCK: usize = 9; +} + +/// Where the fold holds what the shots have left. +mod gun { + pub const SLOPE: usize = 1; + pub const SHOTS: usize = 2; + pub const DAMAGE: usize = 3; + pub const SPAWN_BASE: usize = 4; + pub const HURT_BASE: usize = 5; + pub const DRAWS: usize = 6; + pub const STUCK: usize = 7; +} + +/// `A_PosAttack` and `A_SPosAttack` over every ask in `asks`, as a +/// [`gunned`] tuple each. +/// +/// Both run `A_FaceTarget`, take one slope from `P_AimLineAttack` at the +/// angle the face left, and then fire: one shot for the zombieman and three +/// for the shotgun guy. Each shot turns the angle by two numbers and rolls +/// its damage from a third, and the puff or blood spot it leaves draws four +/// more before the next shot turns its own angle. +/// +/// The shots are a fold because how many numbers a shot draws depends on +/// what the shot before it reached. Step zero of that fold is the aim and +/// every step after it is a shot, so the trace, its intercepts and their +/// order are in the statement once. +/// +/// The asks are a fold too, so a tic with nothing shooting runs none of +/// it. +pub fn hitscan( + asks: &str, + world: &Attacking<'_>, + targets: &shoot::Targets<'_>, + hurting: &inter::Hurting<'_>, +) -> String { + let (values, body) = gunshots(world, targets, hurting); + format!( + "arrayFold((gn_held, gn_ask) -> arrayPushBack(gn_held, {}), {asks}, \ + CAST([], 'Array({})'))", + bind::chain_in("gna", &values, &body), + gunned_type(), + ) +} + +/// The ClickHouse type of a [`gunned`] tuple, which the fold's empty list +/// has to carry. +fn gunned_type() -> String { + format!( + "Tuple(UInt32, Int32, Int32, Array({}), Array(Int32), Array(UInt32), Array(UInt32), \ + UInt32, UInt8)", + shoot::REACHED_TYPE, + ) +} + +/// What one gunshot works out, as the values a body reads and the +/// [`gunned`] tuple it answers with. +fn gunshots( + world: &Attacking<'_>, + targets: &shoot::Targets<'_>, + hurting: &inter::Hurting<'_>, +) -> (Vec<(String, String)>, String) { + let a = |field: usize| format!("gn_ask.{field}"); + let mut values: Vec<(String, String)> = + face_target(&a(gunning::SLOT), &a(gunning::BASE), world); + let mut value = |name: &str, expr: String| values.push((name.to_owned(), expr)); + + value("gn_routine", format!("toInt32({})", a(gunning::ROUTINE))); + // The zombieman fires once and the shotgun guy three times. + value( + "gn_shots", + "toInt64(multiIf(gn_routine = a_posattack, 1, gn_routine = a_sposattack, 3, 0))".to_owned(), + ); + // What the face itself drew, which is where the first shot's own + // numbers start. + value( + "gn_faced", + "toUInt32(if(fc_runs = 0, 0, 2 * toUInt32(fc_fuzzy)))".to_owned(), + ); + value( + "gn_ran", + gun_fold(&a(gunning::BASE), world, targets, hurting), + ); + + let held = |field: usize| format!("gn_ran.{field}"); + let members = [ + "toUInt32(fc_angle)".to_owned(), + "toInt32(fc_flags)".to_owned(), + format!("toInt32({})", held(gun::SLOPE)), + held(gun::SHOTS), + held(gun::DAMAGE), + held(gun::SPAWN_BASE), + held(gun::HURT_BASE), + format!("toUInt32({})", held(gun::DRAWS)), + format!( + "toUInt8({} = 1 OR (fc_runs = 1 AND gn_shots = 0))", + held(gun::STUCK) + ), + ]; + (values, format!("({})", members.join(", "))) +} + +/// The aim and the shots, as one fold whose first step is the aim. +/// +/// A thing with no target folds over nothing and the whole of the walk +/// costs the empty list. +fn gun_fold( + base: &str, + world: &Attacking<'_>, + targets: &shoot::Targets<'_>, + hurting: &inter::Hurting<'_>, +) -> String { + let (values, body) = gun_step(base, world, targets, hurting); + format!( + "arrayFold((gs_at, gs_step) -> {}, range(if(fc_runs = 1, 1 + gn_shots, 0)), \ + (toInt32(0), CAST([], 'Array({})'), CAST([], 'Array(Int32)'), \ + CAST([], 'Array(UInt32)'), CAST([], 'Array(UInt32)'), toInt64(gn_faced), toUInt8(0)))", + bind::chain_in("gsa", &values, &body), + shoot::REACHED_TYPE, + ) +} + +/// One step of the fold: the aim at step zero, and a shot at every step +/// after it. +fn gun_step( + base: &str, + world: &Attacking<'_>, + targets: &shoot::Targets<'_>, + hurting: &inter::Hurting<'_>, +) -> (Vec<(String, String)>, String) { + let at = |array: &str| format!("{array}[fc_slot]"); + let held = |field: usize| format!("gs_at.{field}"); + let mut values: Vec<(String, String)> = Vec::new(); + let mut value = |name: &str, expr: String| values.push((name.to_owned(), expr)); + // The numbers this step draws sit behind the ask's own and everything + // the steps before it drew. + let draw = |nth: &str| { + format!( + "toInt64(rnd[1 + bitAnd(toUInt32({}) + toUInt32({base}) + toUInt32({}) + {nth}, \ + 255)])", + world.prndindex, + held(gun::DRAWS), + ) + }; + + value( + "gs_angle", + format!( + "toUInt32(bitAnd(toInt64(fc_angle) + bitShiftLeft({} - {}, {SPREAD_SHIFT}) \ + + {ANGLE_WRAP}, {}))", + draw("1"), + draw("2"), + ANGLE_WRAP - 1, + ), + ); + value("gs_damage", format!("toInt32(({} % 5 + 1) * 3)", draw("3"))); + // One walk serves the aim and the shots. Step zero asks the angle the + // face left and every step after it asks the one that shot leaves at. + let ask = |angle: &str, slope: &str, aims: bool| { + let shooter = "fc_slot"; + let (x, y, z, height) = ( + at(world.m_x), + at(world.m_y), + at(world.m_z), + at(world.m_height), + ); + let range = MISSILERANGE.to_string(); + if aims { + shoot::asking(shooter, &x, &y, &z, &height, angle, &range) + } else { + shoot::shooting(shooter, &x, &y, &z, &height, angle, &range, slope) + } + }; + // `shoot::traverse` names its list twice, so it is bound once here. + value( + "gs_ask", + format!( + "[if(gs_step = 0, {}, {})]", + ask("fc_angle", "0", true), + ask("gs_angle", &held(gun::SLOPE), false), + ), + ); + value( + "gs_reached", + format!("{}[1]", shoot::traverse("gs_ask", targets)), + ); + value( + "gs_kind", + format!("toUInt8(gs_reached.{})", shoot::reached::KIND), + ); + value( + "gs_id", + format!("toInt32(gs_reached.{})", shoot::reached::ID), + ); + // `P_SpawnPuff` for a wall and for a thing that cannot bleed, and + // `P_SpawnBlood` for one that can; both draw the same four numbers. + value( + "gs_spawn_draws", + format!("toInt64(if(gs_kind = 0, 0, {DEBRIS_DRAWS}))"), + ); + value( + "gs_hurt_draws", + format!( + "toInt64(if(gs_kind != 2, 0, {}[1]))", + inter::draws( + "[(toUInt32(gs_id), toUInt32(fc_slot), toUInt32(fc_slot), gs_damage, \ + toUInt32(0))]", + hurting, + ) + ), + ); + // A shot that kills what it hit changes what a later shot of the same + // call would reach, and a special line the shot crossed is + // `P_ShootSpecialLine`, which this does not run. + value( + "gs_stuck", + format!( + "toUInt8(notEmpty(gs_reached.{}) \ + OR (gs_kind = 2 AND gs_step < gn_shots AND toInt64({}[gs_id]) - gs_damage <= 0))", + shoot::reached::SPECHIT, + world.m_health, + ), + ); + + let body = format!( + "if(gs_step = 0, (toInt32(gs_reached.{slope}), {shots}, {damage}, {spawn}, {hurt}, \ + {draws}, {stuck}), \ + ({}, arrayPushBack({shots}, gs_reached), arrayPushBack({damage}, gs_damage), \ + arrayPushBack({spawn}, toUInt32({draws} + 3)), \ + arrayPushBack({hurt}, toUInt32({draws} + 3 + gs_spawn_draws)), \ + toInt64({draws} + 3 + gs_spawn_draws + gs_hurt_draws), \ + toUInt8({stuck} = 1 OR gs_stuck = 1)))", + held(gun::SLOPE), + slope = shoot::reached::SLOPE, + shots = held(gun::SHOTS), + damage = held(gun::DAMAGE), + spawn = held(gun::SPAWN_BASE), + hurt = held(gun::HURT_BASE), + draws = held(gun::DRAWS), + stuck = held(gun::STUCK), + ); + (values, body) +} + #[cfg(test)] mod tests { use super::*; @@ -499,9 +824,12 @@ mod tests { Attacking { m_x: "m_x", m_y: "m_y", + m_z: "m_z", m_angle: "m_angle", + m_height: "m_height", m_flags: "m_flags", m_type: "m_type", + m_health: "m_health", m_target: "m_target", prndindex: "prndindex", } @@ -524,11 +852,11 @@ mod tests { let (_, body) = attacks(&world()); assert!( body.contains( - "toUInt32(if(ak_runs = 0, 0, 2 * toUInt32(ak_fuzzy) + toUInt32(ak_near)))" + "toUInt32(if(fc_runs = 0, 0, 2 * toUInt32(fc_fuzzy) + toUInt32(ak_near)))" ), "{body}" ); - assert!(named("ak_damage").contains("1 + 2 * toUInt32(ak_fuzzy)")); + assert!(named("ak_damage").contains("1 + 2 * toUInt32(fc_fuzzy)")); } /// `P_CheckMeleeRange` measures against the target's own type rather @@ -536,7 +864,7 @@ mod tests { #[test] fn the_reach_is_measured_against_the_type_s_own_radius() { assert!( - named("ak_near").contains("mobj_radius[1 + m_type[ak_target]]"), + named("ak_near").contains("mobj_radius[1 + m_type[fc_target]]"), "{}", named("ak_near") ); @@ -763,6 +1091,139 @@ mod tests { assert!(!sql.contains("arrayMap(rd_ask ->"), "the body is a fold's"); } + fn gunned_value(name: &str) -> String { + let (values, _) = gunshots(&world(), &targets(), &hurting()); + values + .iter() + .find(|(held, _)| held == name) + .map(|(_, expr)| expr.clone()) + .unwrap_or_else(|| panic!("the call names {name}")) + } + + fn targets() -> shoot::Targets<'static> { + shoot::Targets { + m_x: "m_x", + m_y: "m_y", + m_z: "m_z", + m_radius: "m_radius", + m_height: "m_height", + m_flags: "m_flags", + m_linkseq: "m_linkseq", + alive: "m_alive", + floorheight: "floorheight", + ceilingheight: "ceilingheight", + line_special: "line_special", + } + } + + /// The zombieman fires once and the shotgun guy three times. + #[test] + fn each_routine_fires_what_the_engine_fires() { + let shots = gunned_value("gn_shots"); + assert!(shots.contains("gn_routine = a_posattack, 1"), "{shots}"); + assert!(shots.contains("gn_routine = a_sposattack, 3"), "{shots}"); + } + + /// One walk serves the aim and every shot, so the trace and its + /// intercepts are in the statement once. + #[test] + fn one_walk_serves_the_aim_and_the_shots() { + let sql = hitscan("asks", &world(), &targets(), &hurting()); + assert_eq!( + sql.matches("arrayMap((am_ask, am_hits)").count(), + 1, + "one walk" + ); + assert_eq!( + sql.matches("arrayFold((gs_at, gs_step)").count(), + 1, + "one fold" + ); + } + + fn step_value(name: &str) -> String { + let (values, _) = gun_step("gn_ask.3", &world(), &targets(), &hurting()); + values + .iter() + .find(|(held, _)| held == name) + .map(|(_, expr)| expr.clone()) + .unwrap_or_else(|| panic!("the step names {name}")) + } + + /// A shot turns its angle by the first two numbers it draws, the + /// earlier one on the left, and rolls its damage from the third. + #[test] + fn a_shot_draws_the_spread_before_the_damage() { + let angle = step_value("gs_angle"); + let first = angle.find("+ 1, 255").expect("the first of the spread"); + let second = angle.find("+ 2, 255").expect("the second"); + assert!( + first < second, + "the earlier draw is the left operand: {angle}" + ); + assert!(angle.contains(&format!("{SPREAD_SHIFT})")), "{angle}"); + let damage = step_value("gs_damage"); + assert!(damage.contains("+ 3, 255"), "{damage}"); + assert!(damage.contains("% 5 + 1) * 3"), "{damage}"); + } + + /// The aim draws nothing, so the first shot's numbers sit right behind + /// the face's. + #[test] + fn the_aim_draws_nothing() { + let (_, body) = gunshots(&world(), &targets(), &hurting()); + assert!(body.contains("toUInt32(fc_angle)"), "{body}"); + let faced = gunned_value("gn_faced"); + assert_eq!( + faced, + "toUInt32(if(fc_runs = 0, 0, 2 * toUInt32(fc_fuzzy)))" + ); + } + + /// A shot's puff sits behind its own three numbers and its damage call + /// behind the puff. + #[test] + fn the_bases_follow_the_engine_s_order() { + let (_, body) = gun_step("gn_ask.3", &world(), &targets(), &hurting()); + assert!( + body.contains("+ 3)"), + "the puff sits behind the shot: {body}" + ); + assert!( + body.contains("+ 3 + gs_spawn_draws)"), + "the damage sits behind the puff: {body}" + ); + assert!( + step_value("gs_spawn_draws").contains(&format!("gs_kind = 0, 0, {DEBRIS_DRAWS}")), + "{}", + step_value("gs_spawn_draws") + ); + } + + /// A routine this does not write, and a shot that kills something a + /// later shot of the same call would reach, leave the call stuck. + #[test] + fn what_it_cannot_answer_for_leaves_the_call_stuck() { + let (_, body) = gunshots(&world(), &targets(), &hurting()); + assert!(body.contains("fc_runs = 1 AND gn_shots = 0"), "{body}"); + assert!( + step_value("gs_stuck").contains("gs_step < gn_shots"), + "{}", + step_value("gs_stuck") + ); + } + + #[test] + fn the_gunshot_expression_balances_its_parentheses() { + let sql = hitscan("asks", &world(), &targets(), &hurting()); + let depth = sql.chars().fold(0i32, |d, c| match c { + '(' => d + 1, + ')' => d - 1, + _ => d, + }); + assert_eq!(depth, 0); + } + #[test] fn the_blast_expression_balances_its_parentheses() { let sql = radius_attack("asks", &blast(), &hurting()); diff --git a/native/src/sql/sim/mobj.rs b/native/src/sql/sim/mobj.rs index 9d14228..1c98846 100644 --- a/native/src/sql/sim/mobj.rs +++ b/native/src/sql/sim/mobj.rs @@ -831,9 +831,12 @@ fn strikes(state: &State) -> Vec<(String, String)> { let world = attacks::Attacking { m_x: &s("m_x"), m_y: &s("m_y"), + m_z: "mk_m_z", m_angle: "cq_m_angle", + m_height: &s("m_height"), m_flags: "cq_m_flags", m_type: &s("m_type"), + m_health: &s("m_health"), m_target: "mk_m_target", prndindex: &s("prndindex"), }; diff --git a/native/src/sql/sim/shoot.rs b/native/src/sql/sim/shoot.rs index 4d40a26..5323e66 100644 --- a/native/src/sql/sim/shoot.rs +++ b/native/src/sql/sim/shoot.rs @@ -79,6 +79,11 @@ pub mod reached { pub const WIDTH: usize = 8; } +/// The ClickHouse type of a [`reached`] answer, for a caller that carries +/// a list of them through a fold. +pub const REACHED_TYPE: &str = + "Tuple(Int32, Int32, UInt8, Int32, Int32, Int32, Int32, Array(Int32))"; + /// One aim, as the tuple [`traverse`] reads. An aim works its own slope /// out, so the one it carries is never read. pub fn asking( @@ -635,6 +640,12 @@ mod tests { } } + /// The declared type lists one member per field the answer keeps. + #[test] + fn the_answer_type_has_one_member_per_field() { + assert_eq!(REACHED_TYPE.matches(", ").count() + 1, reached::WIDTH); + } + /// One walk serves an aim and a shot, so the blockmap walk, the /// intercept fractions and their order are in the statement once /// however many of each a caller asks for. diff --git a/native/tests/sim_claw_live.rs b/native/tests/sim_claw_live.rs index 1fc01d3..5095209 100644 --- a/native/tests/sim_claw_live.rs +++ b/native/tests/sim_claw_live.rs @@ -270,13 +270,20 @@ async fn ask_with( let (m_x, m_y, m_angle) = (of(&|f| f.x), of(&|f| f.y), of(&|f| f.angle)); let (m_flags, m_type) = (of(&|f| f.flags), of(&|f| f.kind)); let m_target = of(&|f| f.target as i64); + // The claw reads none of these; the gunshot beside it does. + let m_z = of(&|_| 0); + let m_height = of(&|_| 56 * FRACUNIT); + let m_health = of(&|_| 100); let prndindex = prnd.to_string(); let world = attacks::Attacking { m_x: &m_x, m_y: &m_y, + m_z: &m_z, m_angle: &m_angle, + m_height: &m_height, m_flags: &m_flags, m_type: &m_type, + m_health: &m_health, m_target: &m_target, prndindex: &prndindex, }; diff --git a/native/tests/sim_gunshot_live.rs b/native/tests/sim_gunshot_live.rs new file mode 100644 index 0000000..1fc8f5d --- /dev/null +++ b/native/tests/sim_gunshot_live.rs @@ -0,0 +1,671 @@ +//! `A_PosAttack` and `A_SPosAttack` against a real ClickHouse server. +//! +//! `demo3` reaches the zombieman's shot at gametic 300 and the shotgun +//! guy's at 612, both past the first divergence, so the world is seeded on +//! top of the level's own: `E1M7`'s things stand where `P_SetupLevel` left +//! them and a few of them are pointed at the player. +//! +//! Every number the routines work out is compared against +//! `native/tests/support/attacks.rs`, a reader written from `p_enemy.c`. +//! What each shot reaches comes from `P_LineAttack`'s own walk, which +//! `sim_hitscan_live` checks against its own reader; what this checks is +//! the face, the spread, the damage roll and the order the three run in. +//! +//! Needs a reachable ClickHouse (`CLICKHOUSE_HOST` / `CLICKHOUSE_HTTP_PORT` +//! / `CLICKHOUSE_PASSWORD`, defaulting to `localhost:8123` with no +//! password). Behind the `clickhouse-tests` feature, so a run without a +//! server visibly excludes them. +#![cfg(feature = "clickhouse-tests")] + +use clickdoom_native::sql::sim::{self, attacks, inter, shoot}; +use clickdoom_native::{load, sql, tables, wad::Wad}; +use clickhouse::Row; +use serde::Deserialize; + +mod support; + +use support::attacks::{Fighter, Gunned, World as Oracle}; +use support::db::Fixture; +use support::mobj::thing_type; + +/// `p_mobj.h` +const MF_SHADOW: i64 = 0x4_0000; + +/// The random indices the fan fires from, and the draw offsets it fires +/// at. +const INDICES: [i64; 3] = [0, 61, 137]; +const BASES: [i64; 2] = [0, 17]; + +/// The `action_functions` id the engine's own table gives a routine. +fn action(name: &str) -> i64 { + let actions = tables::table("action_functions").unwrap(); + let at = actions + .texts("name") + .unwrap() + .iter() + .position(|held| *held == name) + .expect("the engine carries the routine"); + actions.ints("id").unwrap()[at] +} + +/// One case: who fires, with what routine, and what the case is called. +struct Case { + what: &'static str, + slot: usize, + routine: &'static str, + shots: usize, +} + +#[tokio::test] +async fn a_gunshot_leaves_what_the_engine_leaves() { + let bytes = support::doom1(); + let wad = Wad::parse(&bytes).unwrap(); + let fixture = Fixture::create("sim_gunshot").await; + let db = fixture.database.clone(); + let mut plan = load::plan(&db, &wad); + plan.extend(sql::level_statements(&db, support::MAP, support::DEMO)); + plan.extend(sim::load_statements(&db)); + if let Err(error) = fixture.execute(&plan).await { + fixture.finish().await; + panic!("{error}"); + } + + let (mut world, player) = level(&fixture, &db).await; + let standing = standing(&fixture, &db).await; + let health = &standing.health; + let cases = seeded(&mut world, player); + + let mut coverage = Coverage::default(); + for shadow in [false, true] { + world[player - 1].flags = if shadow { + world[player - 1].flags | MF_SHADOW + } else { + world[player - 1].flags & !MF_SHADOW + }; + for prnd in INDICES { + for base in BASES { + let ours = ask_server(&fixture, &db, &world, &cases, prnd, base).await; + let oracle = Oracle { + fighters: world.clone(), + prndindex: prnd, + }; + for (at, case) in cases.iter().enumerate() { + let shots = if world[case.slot - 1].target == 0 { + 0 + } else { + case.shots + }; + assert_eq!( + ours[at].kinds.len(), + shots, + "{} fires what the routine fires", + case.what + ); + let want = oracle.gunshot( + case.slot, + shots, + base, + &support::attacks::Answered { + kinds: &ours[at].kinds, + hurt: &ours[at].hurt, + slope: ours[at].gunned.slope, + }, + ); + assert_eq!( + ours[at].gunned, want, + "shadow {shadow}, index {prnd}, base {base}: {}", + case.what + ); + // `P_ShootSpecialLine` is not written, and a shot that + // kills what a later shot of the same call would reach + // changes what that shot is told. + let crossed = ours[at].crossed.iter().any(|held| *held > 0); + let killed = (0..ours[at].kinds.len()).any(|shot| { + ours[at].kinds[shot] == 2 + && shot + 1 < ours[at].kinds.len() + && health[(ours[at].ids[shot] - 1) as usize] + - ours[at].gunned.damage[shot] + <= 0 + }); + assert_eq!( + ours[at].stuck == 1, + crossed || killed, + "{} says what it could not answer for", + case.what + ); + coverage.count(&ours[at]); + } + // Where each shot went, against the angles the reader + // works out on its own. The answer carries no angle, so + // this asks the same walk for the oracle's own and + // compares what it reached. + let angles: Vec = cases + .iter() + .enumerate() + .filter(|(at, _)| !ours[*at].kinds.is_empty()) + .flat_map(|(at, case)| { + // The first shot's own numbers sit behind what + // `A_FaceTarget` drew, which is the three the shot + // itself draws before its puff. + let mut drawn = ours[at].gunned.spawn_base[0] - 3; + (0..ours[at].kinds.len()) + .map(|shot| { + let angle = oracle.shot_angle(case.slot, base, drawn); + drawn = ours[at].gunned.spawn_base[shot] + + if ours[at].kinds[shot] == 0 { 0 } else { 4 } + + ours[at].hurt[shot]; + Shot { + slot: case.slot, + angle, + slope: i64::from(ours[at].gunned.slope), + } + }) + .collect::>() + }) + .collect(); + let walked = walk(&fixture, &db, &world, &standing, &angles).await; + let mut reached: Vec<(i64, i64)> = Vec::new(); + for held in &ours { + for shot in 0..held.kinds.len() { + reached.push((held.kinds[shot], held.ids[shot])); + } + } + assert_eq!( + walked, reached, + "shadow {shadow}, index {prnd}, base {base}: where the shots went" + ); + } + } + } + fixture.finish().await; + coverage.check(); +} + +/// That the fan reached every arm. +#[derive(Default)] +struct Coverage { + nothing: usize, + wall: usize, + thing: usize, + one: usize, + three: usize, + quiet: usize, + fuzzed: usize, +} + +impl Coverage { + fn count(&mut self, ours: &Fired) { + self.nothing += ours.kinds.iter().filter(|k| **k == 0).count(); + self.wall += ours.kinds.iter().filter(|k| **k == 1).count(); + self.thing += ours.kinds.iter().filter(|k| **k == 2).count(); + match ours.kinds.len() { + 0 => self.quiet += 1, + 1 => self.one += 1, + _ => self.three += 1, + } + if ours.gunned.draws >= 2 && !ours.kinds.is_empty() { + let least = 3 * ours.kinds.len() as i64; + if ours.gunned.spawn_base.first() == Some(&5) || ours.gunned.draws > least + 8 { + self.fuzzed += 1; + } + } + } + + /// A shot that reaches nothing at all is not among them: the level is + /// closed and `MISSILERANGE` is longer than any sight line on it, so + /// every shot ends on a wall or on a thing. + fn check(&self) { + assert!( + self.nothing == 0 + && self.wall > 0 + && self.thing > 0 + && self.one > 0 + && self.three > 0 + && self.quiet > 0 + && self.fuzzed > 0, + "the fan reaches every arm: nothing {}, wall {}, thing {}, one shot {}, \ + three shots {}, no target {}, fuzzy {}", + self.nothing, + self.wall, + self.thing, + self.one, + self.three, + self.quiet, + self.fuzzed + ); + } +} + +/// What every thing on the list stands at, which is what a shot leaves +/// from and what it has to take off to kill. +struct Standing { + z: Vec, + height: Vec, + health: Vec, +} + +async fn standing(fixture: &Fixture, db: &str) -> Standing { + #[derive(Row, Deserialize)] + struct Held { + z: Vec, + height: Vec, + health: Vec, + } + let row: Held = fixture + .scalar(&format!( + "SELECT m_z AS z, m_height AS height, m_health AS health \ + FROM {db}.native_state WHERE tic = 0" + )) + .await; + Standing { + z: row.z.iter().map(|v| i64::from(*v)).collect(), + height: row.height.iter().map(|v| i64::from(*v)).collect(), + health: row.health.iter().map(|v| i64::from(*v)).collect(), + } +} + +/// The things `P_SetupLevel` left and the slot the player stands in. +async fn level(fixture: &Fixture, db: &str) -> (Vec, usize) { + #[derive(Row, Deserialize)] + struct Arrays { + x: Vec, + y: Vec, + angle: Vec, + kind: Vec, + flags: Vec, + player: Vec, + } + let row: Arrays = fixture + .scalar(&format!( + "SELECT m_x AS x, m_y AS y, m_angle AS angle, m_type AS kind, m_flags AS flags, \ + [p_mo] AS player FROM {db}.native_state WHERE tic = 0" + )) + .await; + let world = (0..row.x.len()) + .map(|at| Fighter { + x: i64::from(row.x[at]), + y: i64::from(row.y[at]), + angle: i64::from(row.angle[at]), + kind: i64::from(row.kind[at]), + flags: i64::from(row.flags[at]), + target: 0, + }) + .collect(); + (world, row.player[0] as usize) +} + +/// The cases, and the targets the world needs for them. +/// +/// The shooters are the level's own zombiemen and shotgun guys, pointed at +/// the player, plus one of each left with no target at all. +fn seeded(world: &mut [Fighter], player: usize) -> Vec { + let of = |kind: &str, world: &[Fighter]| -> Vec { + let want = thing_type(kind); + (1..=world.len()) + .filter(|slot| world[slot - 1].kind == want) + .collect() + }; + let zombies = of("MT_POSSESSED", world); + let guys = of("MT_SHOTGUY", world); + assert!( + zombies.len() > 3 && guys.len() > 3, + "the level holds enough of each: {} and {}", + zombies.len(), + guys.len() + ); + let mut cases: Vec = Vec::new(); + for slot in zombies.iter().take(3) { + world[slot - 1].target = player; + cases.push(Case { + what: "a zombieman firing at the player", + slot: *slot, + routine: "A_PosAttack", + shots: 1, + }); + } + for slot in guys.iter().take(3) { + world[slot - 1].target = player; + cases.push(Case { + what: "a shotgun guy firing at the player", + slot: *slot, + routine: "A_SPosAttack", + shots: 3, + }); + } + cases.push(Case { + what: "a zombieman with no target", + slot: zombies[3], + routine: "A_PosAttack", + shots: 1, + }); + cases.push(Case { + what: "a shotgun guy with no target", + slot: guys[3], + routine: "A_SPosAttack", + shots: 3, + }); + cases +} + +/// One shot the reader worked the angle out for. +struct Shot { + slot: usize, + angle: i64, + slope: i64, +} + +/// `P_LineAttack` at each angle the reader worked out, as what it reached. +async fn walk( + fixture: &Fixture, + db: &str, + world: &[Fighter], + standing: &Standing, + angles: &[Shot], +) -> Vec<(i64, i64)> { + if angles.is_empty() { + return Vec::new(); + } + let arrays = Arrays::of(fixture, db, world).await; + let list = format!( + "[{}]", + angles + .iter() + .map(|shot| shoot::shooting( + &shot.slot.to_string(), + &world[shot.slot - 1].x.to_string(), + &world[shot.slot - 1].y.to_string(), + &standing.z[shot.slot - 1].to_string(), + &standing.height[shot.slot - 1].to_string(), + &shot.angle.to_string(), + &(32 * 64 * (1 << 16)).to_string(), + &shot.slope.to_string(), + )) + .collect::>() + .join(", ") + ); + #[derive(Row, Deserialize)] + struct Walked { + kinds: Vec, + ids: Vec, + } + let ours: Walked = fixture + .scalar(&format!( + "SELECT arrayMap(s -> toUInt8(s.{}), w) AS kinds, \ + arrayMap(s -> toInt32(s.{}), w) AS ids\nFROM\n(\n WITH\n{},\n \ + ({list}) AS w_asks\n SELECT {} AS w\n)", + shoot::reached::KIND, + shoot::reached::ID, + sim::constants(db) + .into_iter() + .map(|(name, expr)| format!(" ({expr}) AS {name}")) + .collect::>() + .join(",\n"), + shoot::traverse("w_asks", &arrays.targets()), + )) + .await; + (0..ours.kinds.len()) + .map(|at| (i64::from(ours.kinds[at]), i64::from(ours.ids[at]))) + .collect() +} + +/// One answer, as the parts the comparison reads. +struct Fired { + gunned: Gunned, + kinds: Vec, + ids: Vec, + /// How many special lines each shot crossed. + crossed: Vec, + hurt: Vec, + stuck: u8, +} + +fn literal(of: &[i64]) -> String { + format!( + "[{}]", + of.iter().map(i64::to_string).collect::>().join(", ") + ) +} + +fn at_zero(db: &str, column: &str) -> String { + format!("joinGet('{db}.native_state', '{column}', toUInt32(0))") +} + +/// The array literals the primitive reads the world through. +struct Arrays { + m_x: String, + m_y: String, + m_z: String, + m_angle: String, + m_radius: String, + m_height: String, + m_flags: String, + m_type: String, + m_health: String, + m_target: String, + m_linkseq: String, + m_state: String, + m_tics: String, + m_threshold: String, + m_player: String, + alive: String, + zero: String, + floorheight: String, + ceilingheight: String, + line_special: String, +} + +impl Arrays { + async fn of(fixture: &Fixture, db: &str, world: &[Fighter]) -> Arrays { + #[derive(Row, Deserialize)] + struct Held { + z: Vec, + radius: Vec, + height: Vec, + health: Vec, + linkseq: Vec, + state: Vec, + tics: Vec, + threshold: Vec, + player: Vec, + } + let row: Held = fixture + .scalar(&format!( + "SELECT m_z AS z, m_radius AS radius, m_height AS height, m_health AS health, \ + m_linkseq AS linkseq, m_state AS state, m_tics AS tics, \ + m_threshold AS threshold, m_player AS player \ + FROM {db}.native_state WHERE tic = 0" + )) + .await; + let of = + |get: &dyn Fn(usize) -> i64| literal(&(0..world.len()).map(get).collect::>()); + Arrays { + m_x: of(&|at| world[at].x), + m_y: of(&|at| world[at].y), + m_z: of(&|at| i64::from(row.z[at])), + m_angle: of(&|at| world[at].angle), + m_radius: of(&|at| i64::from(row.radius[at])), + m_height: of(&|at| i64::from(row.height[at])), + m_flags: of(&|at| world[at].flags), + m_type: of(&|at| world[at].kind), + m_health: of(&|at| i64::from(row.health[at])), + m_target: format!( + "CAST({}, 'Array(UInt32)')", + of(&|at| world[at].target as i64) + ), + m_linkseq: format!( + "CAST({}, 'Array(UInt32)')", + of(&|at| i64::from(row.linkseq[at])) + ), + m_state: of(&|at| i64::from(row.state[at])), + m_tics: of(&|at| i64::from(row.tics[at])), + m_threshold: of(&|at| i64::from(row.threshold[at])), + m_player: of(&|at| i64::from(row.player[at])), + alive: format!("CAST({}, 'Array(UInt8)')", of(&|_| 1)), + zero: of(&|_| 0), + floorheight: at_zero(db, "sec_floorheight"), + ceilingheight: at_zero(db, "sec_ceilingheight"), + line_special: at_zero(db, "line_special"), + } + } + + fn attacking<'a>(&'a self, prndindex: &'a str) -> attacks::Attacking<'a> { + attacks::Attacking { + m_x: &self.m_x, + m_y: &self.m_y, + m_z: &self.m_z, + m_angle: &self.m_angle, + m_height: &self.m_height, + m_flags: &self.m_flags, + m_type: &self.m_type, + m_health: &self.m_health, + m_target: &self.m_target, + prndindex, + } + } + + fn targets(&self) -> shoot::Targets<'_> { + shoot::Targets { + m_x: &self.m_x, + m_y: &self.m_y, + m_z: &self.m_z, + m_radius: &self.m_radius, + m_height: &self.m_height, + m_flags: &self.m_flags, + m_linkseq: &self.m_linkseq, + alive: &self.alive, + floorheight: &self.floorheight, + ceilingheight: &self.ceilingheight, + line_special: &self.line_special, + } + } + + fn hurting<'a>(&'a self, prndindex: &'a str) -> inter::Hurting<'a> { + inter::Hurting { + m_x: &self.m_x, + m_y: &self.m_y, + m_z: &self.m_z, + m_momx: &self.zero, + m_momy: &self.zero, + m_momz: &self.zero, + m_reactiontime: &self.zero, + m_type: &self.m_type, + m_state: &self.m_state, + m_tics: &self.m_tics, + m_flags: &self.m_flags, + m_health: &self.m_health, + m_height: &self.m_height, + m_target: &self.m_target, + m_threshold: &self.m_threshold, + m_player: &self.m_player, + prndindex, + readyweapon: "0", + } + } +} + +async fn ask_server( + fixture: &Fixture, + db: &str, + world: &[Fighter], + cases: &[Case], + prnd: i64, + base: i64, +) -> Vec { + let arrays = Arrays::of(fixture, db, world).await; + let prndindex = prnd.to_string(); + let list = format!( + "[{}]", + cases + .iter() + .map(|case| format!( + "(toUInt32({}), toInt32({}), toUInt32({base}))", + case.slot, + action(case.routine) + )) + .collect::>() + .join(", ") + ); + #[derive(Row, Deserialize)] + struct Answer { + angle: Vec, + flags: Vec, + damage: Vec>, + spawn_base: Vec>, + hurt_base: Vec>, + draws: Vec, + slope: Vec, + stuck: Vec, + kinds: Vec>, + ids: Vec>, + crossed: Vec>, + } + let column = |name: &str, field: usize| format!("arrayMap(t -> t.{field}, gn) AS {name}"); + let sql = format!( + "SELECT\n {},\n arrayMap(t -> arrayMap(s -> toUInt8(s.{kind}), t.{shots}), gn) AS kinds,\ + \n arrayMap(t -> arrayMap(s -> toInt32(s.{id}), t.{shots}), gn) AS ids,\ + \n arrayMap(t -> arrayMap(s -> toUInt64(length(s.{spechit})), t.{shots}), gn) AS crossed\ + \nFROM\n(\n WITH\n{},\n ({list}) AS gn_asks\n SELECT {} AS gn\n)", + [ + column("angle", attacks::gunned::ANGLE), + column("flags", attacks::gunned::FLAGS), + column("damage", attacks::gunned::DAMAGE), + column("spawn_base", attacks::gunned::SPAWN_BASE), + column("hurt_base", attacks::gunned::HURT_BASE), + column("draws", attacks::gunned::DRAWS), + column("slope", attacks::gunned::SLOPE), + column("stuck", attacks::gunned::STUCK), + ] + .join(",\n "), + sim::constants(db) + .into_iter() + .map(|(name, expr)| format!(" ({expr}) AS {name}")) + .collect::>() + .join(",\n"), + attacks::hitscan( + "gn_asks", + &arrays.attacking(&prndindex), + &arrays.targets(), + &arrays.hurting(&prndindex), + ), + kind = shoot::reached::KIND, + id = shoot::reached::ID, + spechit = shoot::reached::SPECHIT, + shots = attacks::gunned::SHOTS, + ); + let ours: Answer = fixture.scalar(&sql).await; + (0..ours.angle.len()) + .map(|at| { + let kinds: Vec = ours.kinds[at].iter().map(|k| i64::from(*k)).collect(); + let spawn: Vec = ours.spawn_base[at].iter().map(|b| i64::from(*b)).collect(); + let hurt_base: Vec = ours.hurt_base[at].iter().map(|b| i64::from(*b)).collect(); + // What each shot's damage call drew, which is the gap the + // answer leaves between one shot's damage base and the next + // shot's own numbers. + let mut hurt: Vec = Vec::new(); + for shot in 0..kinds.len() { + let after = if shot + 1 < kinds.len() { + spawn[shot + 1] - 3 + } else { + i64::from(ours.draws[at]) + }; + hurt.push(after - hurt_base[shot]); + } + Fired { + ids: ours.ids[at].iter().map(|k| i64::from(*k)).collect(), + crossed: ours.crossed[at].clone(), + gunned: Gunned { + angle: i64::from(ours.angle[at]), + flags: i64::from(ours.flags[at]), + damage: ours.damage[at].iter().map(|d| i64::from(*d)).collect(), + spawn_base: spawn, + hurt_base, + draws: i64::from(ours.draws[at]), + slope: ours.slope[at], + }, + kinds, + hurt, + stuck: ours.stuck[at], + } + }) + .collect() +} diff --git a/native/tests/support/attacks.rs b/native/tests/support/attacks.rs index 708f9f5..2ffed01 100644 --- a/native/tests/support/attacks.rs +++ b/native/tests/support/attacks.rs @@ -277,3 +277,98 @@ impl Blasting<'_> { (asks, drawn) } } + +// --------------------------------------------------------------------------- +// A_PosAttack and A_SPosAttack +// --------------------------------------------------------------------------- + +/// `p_enemy.c`: what the fuzz a shot's own angle takes is shifted by. +const SPREAD_SHIFT: u32 = 20; +/// `p_mobj.c`: how many numbers a puff or a blood spot draws. +const DEBRIS_DRAWS: i64 = 4; + +/// What the reader takes off the answer rather than working out: the walk +/// and the damage are what their own suites check. +pub struct Answered<'a> { + /// What each shot reached, 0 nothing. + pub kinds: &'a [i64], + /// How many numbers each shot's damage call drew. + pub hurt: &'a [i64], + /// The slope the one `P_AimLineAttack` found. + pub slope: i32, +} + +/// What one `A_PosAttack` or `A_SPosAttack` leaves. +#[derive(Debug, PartialEq, Eq)] +pub struct Gunned { + pub angle: i64, + pub flags: i64, + /// The slope the one `P_AimLineAttack` found, which the reader takes + /// off the answer rather than working out. + pub slope: i32, + pub damage: Vec, + pub spawn_base: Vec, + pub hurt_base: Vec, + pub draws: i64, +} + +impl World { + /// `A_PosAttack` for one shot and `A_SPosAttack` for three. + /// + /// `kinds` says what each shot reached, 0 nothing, and `hurt` how many + /// numbers each shot's damage call drew. Both are read off the answer, + /// because `P_LineAttack`'s walk and `P_DamageMobj` are what their own + /// suites check; what this follows is `A_FaceTarget`, the spread, the + /// damage roll and the order the three run in. + pub fn gunshot(&self, slot: usize, shots: usize, base: i64, from: &Answered) -> Gunned { + let (kinds, hurt) = (from.kinds, from.hurt); + let it = &self.fighters[slot - 1]; + let mut answer = Gunned { + angle: it.angle, + flags: it.flags, + slope: from.slope, + damage: Vec::new(), + spawn_base: Vec::new(), + hurt_base: Vec::new(), + draws: 0, + }; + if it.target == 0 { + return answer; + } + let at = &self.fighters[it.target - 1]; + + // `A_FaceTarget`. + answer.flags = it.flags & !MF_AMBUSH; + let mut angle = point_to_angle(at.x - it.x, at.y - it.y); + if at.flags & MF_SHADOW != 0 { + angle += (self.draw(base, 1) - self.draw(base, 2)) << FACE_SHIFT; + answer.draws += 2; + } + answer.angle = angle.rem_euclid(ANGLE_WRAP); + + // `P_AimLineAttack` draws nothing, so the first shot's numbers sit + // right behind the face's. + for shot in 0..shots { + let drawn = answer.draws; + let _spread = (self.draw(base, drawn + 1) - self.draw(base, drawn + 2)) << SPREAD_SHIFT; + answer.damage.push((self.draw(base, drawn + 3) % 5 + 1) * 3); + answer.spawn_base.push(drawn + 3); + let spawn = if kinds[shot] == 0 { 0 } else { DEBRIS_DRAWS }; + answer.hurt_base.push(drawn + 3 + spawn); + answer.draws = drawn + 3 + spawn + hurt[shot]; + } + answer + } + + /// The angle one shot leaves at, which the answer does not carry. + pub fn shot_angle(&self, slot: usize, base: i64, drawn: i64) -> i64 { + let it = &self.fighters[slot - 1]; + let at = &self.fighters[it.target - 1]; + let mut angle = point_to_angle(at.x - it.x, at.y - it.y); + if at.flags & MF_SHADOW != 0 { + angle += (self.draw(base, 1) - self.draw(base, 2)) << FACE_SHIFT; + } + let spread = (self.draw(base, drawn + 1) - self.draw(base, drawn + 2)) << SPREAD_SHIFT; + (angle + spread).rem_euclid(ANGLE_WRAP) + } +}