diff --git a/native/src/sql/sim/attacks.rs b/native/src/sql/sim/attacks.rs index 1dc222f..0f23718 100644 --- a/native/src/sql/sim/attacks.rs +++ b/native/src/sql/sim/attacks.rs @@ -176,6 +176,30 @@ pub fn attack(asks: &str, world: &Attacking<'_>) -> String { ) } +/// [`attack`] over an ask list that carries at most one, folded rather +/// than mapped. +/// +/// A map runs every function in its body once even on an empty list, and +/// this body is the whole routine. A fold runs its body only where the +/// list has an element, so a tic reaching no attack pays for the fold and +/// nothing under it. The answer is the last ask in the list, and +/// [`no_attack`] is what an empty one gives. +pub fn attack_fold(asks: &str, world: &Attacking<'_>) -> String { + let (values, body) = attacks(world); + format!( + "arrayFold((ak_held, ak_ask) -> {}, {asks}, {})", + bind::chain_in("aka", &values, &body), + no_attack(), + ) +} + +/// The [`attacked`] tuple for a tic that reached no attack: no turn, no +/// claw, no missile and no draw. +pub fn no_attack() -> String { + "(toUInt32(0), toInt32(0), toUInt8(0), toInt32(0), toUInt8(0), toUInt32(0), toUInt8(0))" + .to_owned() +} + /// 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) { diff --git a/native/src/sql/sim/enemy.rs b/native/src/sql/sim/enemy.rs index 022ecad..5278c0e 100644 --- a/native/src/sql/sim/enemy.rs +++ b/native/src/sql/sim/enemy.rs @@ -872,9 +872,11 @@ fn chased(state: &Chasing<'_>) -> String { ), ); // `A_FaceTarget` points the thing at what it is about to attack and - // takes it off ambush. A target it cannot see clearly turns the angle - // by a random amount, which is two draws; nothing on this map carries - // that flag and `cs_stuck` refuses one that does. + // takes it off ambush. A target carrying `MF_SHADOW` turns the angle + // by a random amount instead, which is two draws, and `cs_stuck` + // refuses that tic. No target of a face-target on this map carries the + // flag, because monsters face the player and the player has no blur + // sphere. let target = |array: &str| format!("{array}[{}[k]]", state.m_target); value( "cc_faced", diff --git a/native/src/sql/sim/inter.rs b/native/src/sql/sim/inter.rs index 2dee9b0..5864dcb 100644 --- a/native/src/sql/sim/inter.rs +++ b/native/src/sql/sim/inter.rs @@ -1009,6 +1009,23 @@ fn reach(world: &Hurting<'_>) -> Vec<(String, String)> { ] } +/// [`damage_mobj`] over an ask list that carries at most one, folded +/// rather than mapped. +/// +/// A map runs every function in its body once even on an empty list, and +/// this body is the whole routine. A fold runs its body only where the +/// list has an element, so a caller with nothing to hurt pays for the fold +/// and nothing under it. The answer is the last ask in the list, and +/// [`no_hurt`] is what an empty one gives. +pub fn damage_fold(asks: &str, world: &Hurting<'_>) -> String { + let (values, body) = damaged(world); + format!( + "arrayFold((dm_held, dm_ask) -> {}, {asks}, {})", + bind::chain_in("dma", &values, &body), + no_hurt(), + ) +} + /// What one call works out, as the values a body reads and the [`hurt`] /// tuple it answers with. fn damaged(world: &Hurting<'_>) -> (Vec<(String, String)>, String) { diff --git a/native/src/sql/sim/mobj.rs b/native/src/sql/sim/mobj.rs index 7a0d237..9d14228 100644 --- a/native/src/sql/sim/mobj.rs +++ b/native/src/sql/sim/mobj.rs @@ -1,7 +1,7 @@ //! What a thing does with its momentum and its states, from `p_mobj.c`. use super::map::{self, World, answer}; -use super::{State, enemy, inter, maputl, sight}; +use super::{State, attacks, enemy, inter, maputl, sight}; use crate::sql::Statement; use crate::sql::bind; use crate::sql::fixed; @@ -256,6 +256,16 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { s("m_target") ), ); + bind( + "mt_attackers", + format!( + "arrayFilter((k, c, n, t) -> c = 1 AND n != 0 AND t != 0 \ + AND (state_action[1 + n] = a_troopattack \ + OR state_action[1 + n] = a_sargattack), \ + mt_slots, mt_cycles, mt_next, {})", + s("m_target") + ), + ); let pairs = |slot: &str, other: &dyn Fn(&str) -> String| { sight::asking( &format!("{}[{slot}]", s("m_subsector")), @@ -280,10 +290,11 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { "mt_pairs", format!( "arrayConcat(arrayMap(k -> {}, mt_lookers), arrayMap(k -> {}, mt_chasers), \ - arrayMap(k -> {}, mt_hearers))", + arrayMap(k -> {}, mt_hearers), arrayMap(k -> {}, mt_attackers))", pairs("k", &player), pairs("k", &target), pairs("k", &heard), + pairs("k", &target), ), ); bind("mt_seen", sight::check_sight("mt_pairs")); @@ -299,6 +310,12 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { "arraySlice(mt_seen, 1 + length(mt_lookers) + length(mt_chasers), length(mt_hearers))" .to_owned(), ); + bind( + "mt_attack_seen", + "arraySlice(mt_seen, 1 + length(mt_lookers) + length(mt_chasers) \ + + length(mt_hearers), length(mt_attackers))" + .to_owned(), + ); bind( "mt_heard_seen", "arrayMap(k -> toUInt8(mt_hearer_seen[indexOf(mt_hearers, k)]), mt_slots)".to_owned(), @@ -569,7 +586,7 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { enemy::chased::STATE ), ); - bind("mk_m_flags", { + bind("cq_m_flags", { let held = s("m_flags"); format!( "arrayMap((k, c) -> toInt32(if(indexOf(mt_movers, k) = 0, {held}[k], c.{})), \ @@ -597,16 +614,39 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { ); } for (column, member, cast, _) in &held { - bind( - &format!("mk_{column}"), - format!("arrayMap(c -> {cast}(c.{member}), cw_slot)"), - ); + let name = if *column == "m_angle" { + "cq_m_angle".to_owned() + } else { + format!("mk_{column}") + }; + bind(&name, format!("arrayMap(c -> {cast}(c.{member}), cw_slot)")); } + // The attack reads what the state cycle and the chase left, and what + // it leaves stands over them. + for (name, expr) in strikes(state) { + bind(&name, expr); + } + bind( + "mk_m_angle", + format!( + "arrayMap((k, v) -> toUInt32(if(k = at_one AND at_one != 0, at_struck.{}, v)), \ + mt_slots, cq_m_angle)", + attacks::attacked::ANGLE + ), + ); + bind( + "mk_m_flags", + format!( + "arrayMap((k, v) -> toInt32(if(k = at_one AND at_one != 0, at_struck.{}, v)), \ + mt_slots, cq_m_flags)", + attacks::attacked::FLAGS + ), + ); bind( "now_prndindex", format!( "toUInt8(bitAnd(toUInt32({}) + arraySum(mt_shouts) \ - + arraySum(arrayMap(c -> c.{}, cw_chased)), 255))", + + arraySum(arrayMap(c -> c.{}, cw_chased)) + at_draws, 255))", s("prndindex"), enemy::chased::DRAWS ), @@ -620,7 +660,7 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { "toUInt8({} = 1 OR arrayExists(a -> a.{} = 1, mt_two) \ OR arrayExists(c -> c.{} = 1, cw_chased) OR cw_crowded = 1 \ OR tx_crowded = 1 OR tx_unrun = 1 OR tx_crossed = 1 \ - OR tz_unrun = 1)", + OR tz_unrun = 1 OR at_unrun = 1)", s("unresolved"), cycled::STUCK, enemy::chased::STUCK @@ -672,6 +712,15 @@ fn removed(state: &State, player: &str) -> Vec<(String, String)> { } else { s(column) }; + // What the claw left, before the renumbering, because the target + // the damage sets is a slot like any other. + let held = match clawed(column) { + Some(member) => format!( + "arrayMap((k, v) -> toInt32(if(at_clawed = 1 AND k = at_target, \ + mt_hurt.{member}, v)), mt_slots, {held})" + ), + None => held, + }; let held = if POINTERS.contains(&column) { format!("arrayMap(t -> {}, {held})", moved_slot("t")) } else { @@ -699,6 +748,24 @@ fn removed(state: &State, player: &str) -> Vec<(String, String)> { bindings } +/// Where a column the claw moves sits in the answer `P_DamageMobj` gives. +fn clawed(column: &str) -> Option { + Some(match column { + "m_health" => inter::hurt::HEALTH, + "m_flags" => inter::hurt::FLAGS, + "m_state" => inter::hurt::STATE, + "m_tics" => inter::hurt::TICS, + "m_momx" => inter::hurt::MOMX, + "m_momy" => inter::hurt::MOMY, + "m_momz" => inter::hurt::MOMZ, + "m_height" => inter::hurt::HEIGHT, + "m_reactiontime" => inter::hurt::REACTIONTIME, + "m_target" => inter::hurt::TARGET, + "m_threshold" => inter::hurt::THRESHOLD, + _ => return None, + }) +} + /// The mobj array columns the thinker writes, which the compaction reads /// from it rather than from the tic's own start. const THINKER_COLUMNS: [&str; 21] = [ @@ -730,6 +797,131 @@ const THINKER_COLUMNS: [&str; 21] = [ /// written beside them. const POINTERS: [&str; 2] = ["m_target", "m_tracer"]; +/// `A_TroopAttack` and `A_SargAttack` for the things whose state cycle +/// reached one, and the damage a claw that lands does. +/// +/// The routine turns the thing towards its target, takes it off ambush and +/// either claws the target or, for an imp out of reach, throws a fireball. +/// Both the routine and `P_DamageMobj` are folded over their ask lists +/// rather than mapped, so a tic that reaches neither runs neither body. +/// +/// Three cases say the tic could not be produced: more than one thing +/// reaching a routine, because the second would draw from an index the +/// first moves; the fireball, which wants a missile spawned; and a claw +/// that kills, which owes the kill count and whatever the corpse drops. +fn strikes(state: &State) -> Vec<(String, String)> { + let s = |column: &str| state.get(column); + let mut bindings: Vec<(String, String)> = Vec::new(); + let mut bind = |name: &str, expr: String| bindings.push((name.to_owned(), expr)); + + // One ask per attacker: the slot, the routine its frame carries, the + // sight `P_CheckMeleeRange` needs, and how many numbers the tic drew + // before it. + bind( + "at_asks", + format!( + "arrayMap(k -> (toUInt32(k), toInt32(state_action[1 + mt_next[k]]), \ + toUInt8(mt_attack_seen[indexOf(mt_attackers, k)]), \ + toUInt32(arraySum(arraySlice(mt_shouts, 1, k)) \ + + arraySum(arrayMap(c -> toUInt32(c.{}), arraySlice(cw_slot, 1, k))))), \ + mt_attackers)", + enemy::chased::DRAWS + ), + ); + let world = attacks::Attacking { + m_x: &s("m_x"), + m_y: &s("m_y"), + m_angle: "cq_m_angle", + m_flags: "cq_m_flags", + m_type: &s("m_type"), + m_target: "mk_m_target", + prndindex: &s("prndindex"), + }; + bind("at_struck", attacks::attack_fold("at_asks", &world)); + // The one attacker a tic carries and what it drew before its own call. + // A tic reaching more than one is refused below and reads neither. + bind( + "at_one", + "toUInt32(if(length(mt_attackers) = 1, mt_attackers[1], 0))".to_owned(), + ); + bind( + "at_base", + format!( + "toUInt32(arraySum(arrayMap(a -> toUInt32(a.{}), at_asks)))", + attacks::striking::BASE + ), + ); + + // The thinker stage's `P_DamageMobj` ask list, and the one call over + // it. A missile's impact, a barrel's blast and a monster's hitscan all + // hurt things in this stage; each joins this list rather than standing + // up a call of its own, because the routine is among the largest + // things the statement carries and every copy costs a tic that hurts + // nothing. + bind( + "mt_hurt_asks", + format!( + "arraySlice([{}], 1, at_struck.{})", + attacks::claw_ask("at_struck", "greatest(at_one, 1)", "mk_m_target", "at_base"), + attacks::attacked::CLAWED, + ), + ); + // The target as the stage has left it so far. `m_health` and + // `m_height` have no writer ahead of this one, so they stand as the + // tic started. + let hurting = inter::Hurting { + m_x: "mk_m_x", + m_y: "mk_m_y", + m_z: "mk_m_z", + m_momx: "mk_m_momx", + m_momy: "mk_m_momy", + m_momz: "mk_m_momz", + m_reactiontime: "mk_m_reactiontime", + m_type: &s("m_type"), + m_state: "mk_m_state", + m_tics: "mk_m_tics", + m_flags: "cq_m_flags", + m_health: &s("m_health"), + m_height: &s("m_height"), + m_target: "mk_m_target", + m_threshold: "mk_m_threshold", + m_player: &s("m_player"), + prndindex: &s("prndindex"), + readyweapon: &s("p_readyweapon"), + }; + bind("mt_hurt", inter::damage_fold("mt_hurt_asks", &hurting)); + + bind( + "at_clawed", + format!("toUInt8(at_struck.{})", attacks::attacked::CLAWED), + ); + bind( + "at_target", + "toUInt32(if(at_clawed = 1, mk_m_target[greatest(at_one, 1)], 0))".to_owned(), + ); + bind( + "at_draws", + format!( + "toUInt32(toUInt32(at_struck.{}) + toUInt32(mt_hurt.{}))", + attacks::attacked::DRAWS, + inter::hurt::DRAWS, + ), + ); + bind( + "at_unrun", + format!( + "toUInt8(length(mt_attackers) > 1 \ + OR at_struck.{throws} = 1 OR at_struck.{stuck} = 1 \ + OR mt_hurt.{counted} = 1 OR mt_hurt.{drop} != -1)", + throws = attacks::attacked::THROWS, + stuck = attacks::attacked::STUCK, + counted = inter::hurt::COUNTED, + drop = inter::hurt::DROP, + ), + ); + bindings +} + /// The first state a cycle enters, and `A_Look` where the state carries /// it. fn entry_one(slot: &str, state: &State) -> String { @@ -754,7 +946,9 @@ fn entry_one(slot: &str, state: &State) -> String { "toUInt8(multiIf(k = {slot}, 0, c = 0, 0, n = 0, 0, \ state_action[1 + n] != 0 AND state_action[1 + n] != a_look \ AND state_action[1 + n] != a_chase \ - AND state_action[1 + n] != a_facetarget, 1, 0))" + AND state_action[1 + n] != a_facetarget \ + AND state_action[1 + n] != a_troopattack \ + AND state_action[1 + n] != a_sargattack, 1, 0))" ), format!("toUInt8({enters})"), format!( diff --git a/native/tests/sim_parity_live.rs b/native/tests/sim_parity_live.rs index 02ec742..623e618 100644 --- a/native/tests/sim_parity_live.rs +++ b/native/tests/sim_parity_live.rs @@ -221,14 +221,20 @@ const FLASH: [(u32, i32, i32, i32); 6] = [ /// missile frames, marks it as having just attacked and turns it towards /// the player, and at 161 the frame after that turns it again. Both frames /// carry `A_FaceTarget`; the first is entered by the attack and the second -/// by the tic count running out. -const ATTACK: [(u32, i32, i32, u32); 4] = [ +/// by the tic count running out. At 169 the imp reaches the frame carrying +/// `A_TroopAttack`, which turns it once more. +const ATTACK: [(u32, i32, i32, u32); 5] = [ (152, 444, 4194310, 1610612736), (153, 452, 4194438, 1413509120), (161, 453, 4194438, 1393606112), (168, 453, 4194438, 1393606112), + (FIREBALL, 454, 4194438, 1381806976), ]; +/// The gametic the imp's `A_TroopAttack` runs on. Its target stands far +/// past `MELEERANGE`, so the routine throws a fireball. +const FIREBALL: u32 = 169; + const THRUST: [(u32, usize, i32, i32, i32, i32); 4] = [ (142, 118, 8272000, -4310912, 0, 0), (142, 258, 13992912, 4297488, 0, 0), @@ -573,6 +579,18 @@ async fn the_tic_matches_the_engine_where_the_fixture_reaches() { "the imp winding up its attack at gametic {tic}" ); } + // The routine turns the imp and then wants a missile, which is the + // branch this tic does not spawn. + assert_eq!( + at(FIREBALL).unresolved, + 1, + "the tic the imp throws its fireball says it could not be produced" + ); + assert_eq!( + at(FIREBALL - 1).unresolved, + 0, + "and the tic before it is one the run carried through" + ); for (tic, slot, x, y, momx, momy) in THRUST { let row = at(tic); let place = if slot == 118 { 0 } else { 1 }; diff --git a/native/tests/sim_troop_live.rs b/native/tests/sim_troop_live.rs new file mode 100644 index 0000000..baba052 --- /dev/null +++ b/native/tests/sim_troop_live.rs @@ -0,0 +1,249 @@ +//! `A_TroopAttack` reached through a tic, against a real ClickHouse +//! server. +//! +//! `native/tests/sim_claw_live.rs` reads the routine itself against a +//! reader written from `p_enemy.c`. This reads what a tic does with it: +//! the frame the state cycle enters, the angle and the flags it leaves on +//! the attacker, and the damage that reaches the target. +//! +//! `demo3` reaches the routine once and the imp throws a fireball, so the +//! claw is seeded: an imp put beside another imp, one tic from the frame +//! the routine sits on. +//! +//! Every arm is a row seeded into one session, because a session pays the +//! tic statement's analysis once. +//! +//! 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; +use clickdoom_native::sql::sim::tick::Input; +use clickdoom_native::{load, sql, wad::Wad}; +use clickhouse::Row; +use serde::Deserialize; + +mod support; + +use support::db::Fixture; +use support::seed; + +/// The tic every arm copies its row from. Gametic 40 is early enough that +/// no monster has woken and the list still holds the level's own things. +const BEFORE: u32 = 40; + +/// The imp that runs the routine and the imp it swings at. Both are +/// `MT_TROOP` on the level's own list, and at gametic 40 both stand +/// still. +const ATTACKER: usize = 116; +const TARGET: usize = 117; + +/// `states.tsv`: the frame carrying `A_FaceTarget`, and the frame after it +/// carrying `A_TroopAttack`. Seeding the first with one tic of wait left +/// puts the routine on the tic the arm runs. +const FACE: i32 = 453; +const ATTACK: i32 = 454; + +/// `p_local.h` +const BASETHRESHOLD: i32 = 100; + +/// `p_mobj.h` +const MF_AMBUSH: i64 = 32; + +/// The angle `R_PointToAngle2` gives for a target due west. The octant it +/// lands in counts down from half a turn, so the answer is a unit short of +/// it. +const DUE_WEST: u32 = 0x7fff_ffff; + +/// How far from its target each arm stands the attacker. `MELEERANGE` is +/// sixty four units and `P_CheckMeleeRange` measures against that less +/// twenty, plus the target's radius. Four units is inside the claw's +/// reach and close enough that the two share a sector whatever the map +/// looks like there; four hundred is outside it. +const NEAR: i64 = 4 * 65536; +const FAR: i64 = 400 * 65536; + +/// One arm per seeded row: its name, where the copy of `BEFORE` lands and +/// how far the attacker stands from its target. The tics are far apart so +/// the arms cannot read each other's rows. +/// +/// `ambush` stands where `claw` stands and differs from it by one flag, so +/// what it reads is the flag and nothing else. +const ARMS: [(&str, u32, i64); 3] = [ + ("claw", 200, NEAR), + ("fireball", 300, FAR), + ("ambush", 400, NEAR), +]; + +#[derive(Row, Deserialize)] +struct Clawed { + tic: u32, + state: i32, + angle: u32, + attacker_flags: i32, + health: i32, + hunts: u32, + threshold: i32, + prndindex: u8, + unresolved: u8, +} + +/// A column of one slot replaced, leaving every other slot alone. +fn put(column: &'static str, slot: usize, value: String, cast: &str) -> (&'static str, String) { + ( + column, + format!( + "arrayMap((v, k) -> {cast}(if(k = {slot}, {value}, v)), \ + p.{column}, arrayEnumerate(p.{column}))" + ), + ) +} + +#[tokio::test] +async fn a_tic_carries_the_imps_attack_through() { + let bytes = support::doom1(); + let wad = Wad::parse(&bytes).unwrap(); + let fixture = Fixture::create("sim_troop").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)); + plan.push(sim::tick::demo_statement(&db, 1, BEFORE)); + if let Err(error) = fixture.execute(&plan).await { + fixture.finish().await; + panic!("{error}"); + } + + let mut statements: Vec = Vec::new(); + for (name, at, away) in ARMS { + let flags = if name == "ambush" { MF_AMBUSH } else { 0 }; + let overrides = [ + // The attacker beside its target, in the frame whose next + // carries the routine, with one tic of wait left on it. + put( + "m_x", + ATTACKER, + format!("p.m_x[{TARGET}] + {away}"), + "toInt32", + ), + put("m_y", ATTACKER, format!("p.m_y[{TARGET}]"), "toInt32"), + put("m_z", ATTACKER, format!("p.m_z[{TARGET}]"), "toInt32"), + put("m_state", ATTACKER, FACE.to_string(), "toInt32"), + put("m_tics", ATTACKER, "1".to_owned(), "toInt32"), + put("m_target", ATTACKER, TARGET.to_string(), "toUInt32"), + put( + "m_flags", + ATTACKER, + format!("bitOr(toInt64(p.m_flags[{ATTACKER}]), {flags})"), + "toInt32", + ), + // Nothing else has this one's attention, so what the claw + // leaves is the whole of what moves its pointer. + put("m_threshold", TARGET, "0".to_owned(), "toInt32"), + ]; + statements.extend( + seed::row(&db, at, BEFORE, &overrides) + .into_iter() + .map(sql::Statement::sql), + ); + statements.push(sim::tick::run_statement( + &db, + &[Input::keys(at + 1, 0, (0, 0))], + )); + } + if let Err(error) = fixture.execute(&statements).await { + fixture.finish().await; + panic!("{error}"); + } + + let wanted: Vec = ARMS + .iter() + .flat_map(|(_, at, _)| [at.to_string(), (at + 1).to_string()]) + .collect(); + let rows: Vec = fixture + .rows(&format!( + "SELECT tic, m_state[{ATTACKER}] AS state, m_angle[{ATTACKER}] AS angle, \ + m_flags[{ATTACKER}] AS attacker_flags, m_health[{TARGET}] AS health, \ + m_target[{TARGET}] AS hunts, m_threshold[{TARGET}] AS threshold, \ + prndindex, unresolved \ + FROM {db}.native_state WHERE tic IN ({}) ORDER BY tic", + wanted.join(", ") + )) + .await; + fixture.finish().await; + assert_eq!( + rows.len(), + ARMS.len() * 2, + "a seeded row and a tic from it for every arm" + ); + let at = |tic: u32| { + rows.iter() + .find(|row| row.tic == tic) + .unwrap_or_else(|| panic!("no row for tic {tic}")) + }; + + // The imp within reach claws its target. + let (before, after) = (at(200), at(201)); + assert_eq!( + before.state, FACE, + "the seeded row is a tic from the routine" + ); + assert_eq!(after.state, ATTACK, "and the cycle reaches it"); + assert_eq!(after.unresolved, 0, "the claw is a branch this runs"); + let taken = before.health - after.health; + assert!( + (3..=24).contains(&taken) && taken % 3 == 0, + "the damage is three times one to eight: {taken}" + ); + assert_ne!( + after.prndindex, before.prndindex, + "and the damage draws for itself" + ); + // `A_FaceTarget` runs first, and the target stands four units back + // along the x axis, so the attacker ends up pointing straight down + // it. + assert_eq!(after.angle, DUE_WEST, "the attacker turns onto its target"); + // `P_DamageMobj` turns a thing with nothing else on its mind onto + // whatever hit it, which is how the claw is told apart from a hit + // that only moved a number. + assert_eq!( + after.hunts, ATTACKER as u32, + "the target turns on the imp that clawed it" + ); + assert_eq!( + after.threshold, BASETHRESHOLD, + "and holds that for the threshold's worth of tics" + ); + + // The imp out of reach throws a fireball, which this tic does not + // spawn. + let (before, after) = (at(300), at(301)); + assert_eq!(after.state, ATTACK, "the cycle reaches the routine"); + assert_eq!( + after.health, before.health, + "nothing reaches a target four hundred units away" + ); + assert_eq!( + after.unresolved, 1, + "and the fireball says the tic could not be produced" + ); + + // `A_FaceTarget` takes the thing off ambush, and the tic carries that + // through with the rest of what the routine leaves. + let (before, after) = (at(400), at(401)); + assert_eq!( + before.attacker_flags & MF_AMBUSH as i32, + MF_AMBUSH as i32, + "the seeded row puts the attacker on ambush" + ); + assert_eq!( + after.attacker_flags & MF_AMBUSH as i32, + 0, + "and the routine takes it off" + ); + assert_eq!(after.unresolved, 0, "on a tic that runs"); + assert!(before.health - after.health > 0, "and the claw still lands"); +}