diff --git a/voidcrew/edits/modules/projectiles/projectile_npc_allies.dm b/voidcrew/edits/modules/projectiles/projectile_npc_allies.dm index 444661e5361..840f3821b9d 100644 --- a/voidcrew/edits/modules/projectiles/projectile_npc_allies.dm +++ b/voidcrew/edits/modules/projectiles/projectile_npc_allies.dm @@ -26,8 +26,29 @@ return ..() // A reflected, parried or deflected shot takes a new firer: it stops sparing the NPC's side. +// An ally held by the other side is a shield, not an ally: the shot can hit them (see npc_shot_shield()). /obj/projectile/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) if(npc_side_shooter && firer && firer.weak_reference != npc_side_shooter) npc_side_shooter = null ignored_factions = null + if(npc_side_shooter && npc_shot_shield(target)) + var/list/sides = ignored_factions + ignored_factions = null + . = ..() + ignored_factions = sides + return . return ..() + +/** + * Whether `target` is one of the NPC's side held in an aggressive grab or tighter by a living mob that is + * not on that side. tg's grab states are the only test: a passive pull (dragging a body or a cuffed + * prisoner) is not a shield. tg's own rules still pass over a held body that is dead or lying helpless. + */ +/obj/projectile/proc/npc_shot_shield(atom/target) + if(!isliving(target) || !length(ignored_factions)) + return FALSE + var/mob/living/held = target + var/mob/living/holder = held.pulledby + if(!isliving(holder) || holder.grab_state < GRAB_AGGRESSIVE) + return FALSE + return faction_check(held.faction, ignored_factions) && !faction_check(holder.faction, ignored_factions) diff --git a/voidcrew/modules/unit_tests/voidcrew_npc_fire_over_allies.dm b/voidcrew/modules/unit_tests/voidcrew_npc_fire_over_allies.dm index 643c3119de8..5f647869d82 100644 --- a/voidcrew/modules/unit_tests/voidcrew_npc_fire_over_allies.dm +++ b/voidcrew/modules/unit_tests/voidcrew_npc_fire_over_allies.dm @@ -27,6 +27,9 @@ wait_for_hit(target) TEST_ASSERT(target.getBruteLoss() > 0, "The NPC's buckshot never reached the target behind its ally") TEST_ASSERT_EQUAL(ally.getBruteLoss(), 0, "The NPC's buckshot hit its own ally on the way") + wait_for_quiet() + ally.fully_heal() + target.fully_heal() // Aimed at its ally, it hits its ally var/obj/item/ammo_casing/c38/aimed = allocate(/obj/item/ammo_casing/c38) @@ -56,9 +59,56 @@ TEST_ASSERT(turned.can_hit_target(ally, FALSE, TRUE), "A reflected NPC shot still passes over the NPC's side") turned.firer = null + // An ally held by the NPC's enemy is a shield: the shot hits them + wait_for_quiet() + var/mob/living/carbon/human/consistent/holder = allocate(/mob/living/carbon/human/consistent, locate(start.x + 2, start.y + 1, start.z)) + holder.faction = list("npc_fire_test_other") + ally.fully_heal() + target.fully_heal() + TEST_ASSERT(holder.start_pulling(ally, supress_message = TRUE), "Test fixture: the enemy couldn't grab the ally") + holder.setGrabState(GRAB_AGGRESSIVE) + TEST_ASSERT_EQUAL(ally.body_position, STANDING_UP, "Test fixture: the held ally isn't standing") + var/obj/item/ammo_casing/c38/shield_round = allocate(/obj/item/ammo_casing/c38) + TEST_ASSERT(shield_round.fire_casing(target, shooter, null, 0, TRUE, BODY_ZONE_CHEST, 0, shooter), "The NPC's round at the shield didn't fire") + wait_for_hit(ally) + TEST_ASSERT(ally.getBruteLoss() > 0, "The NPC's bullet passed over its ally held by the enemy") + TEST_ASSERT_EQUAL(target.getBruteLoss(), 0, "The NPC's bullet went through its ally held by the enemy") + + // Only dragged along (a passive pull), the ally is still passed over + ally.fully_heal() + holder.setGrabState(GRAB_PASSIVE) + var/obj/item/ammo_casing/c38/pulled_round = allocate(/obj/item/ammo_casing/c38) + TEST_ASSERT(pulled_round.fire_casing(target, shooter, null, 0, TRUE, BODY_ZONE_CHEST, 0, shooter), "The NPC's round past the dragged ally didn't fire") + wait_for_hit(target) + TEST_ASSERT(target.getBruteLoss() > 0, "The NPC's bullet never passed its ally dragged by the enemy") + TEST_ASSERT_EQUAL(ally.getBruteLoss(), 0, "The NPC's bullet hit its ally only dragged by the enemy") + + // Held by someone on its own side, the ally is still passed over + target.fully_heal() + holder.faction = list("npc_fire_test_side") + holder.setGrabState(GRAB_AGGRESSIVE) + var/obj/item/ammo_casing/c38/friendly_round = allocate(/obj/item/ammo_casing/c38) + TEST_ASSERT(friendly_round.fire_casing(target, shooter, null, 0, TRUE, BODY_ZONE_CHEST, 0, shooter), "The NPC's round past the friendly-held ally didn't fire") + wait_for_hit(target) + TEST_ASSERT(target.getBruteLoss() > 0, "The NPC's bullet never passed its ally held by its own side") + TEST_ASSERT_EQUAL(ally.getBruteLoss(), 0, "The NPC's bullet hit its ally held by its own side") + holder.stop_pulling() + /// Waits up to two seconds for `victim` to take brute damage. /datum/unit_test/voidcrew_npc_fire_over_allies/proc/wait_for_hit(mob/living/victim) for(var/i in 1 to 20) if(victim.getBruteLoss() > 0) return sleep(0.1 SECONDS) + +/// Waits up to three seconds for every shot in the test room to land, so a late pellet can't hit the next check. +/datum/unit_test/voidcrew_npc_fire_over_allies/proc/wait_for_quiet() + for(var/i in 1 to 30) + var/flying = FALSE + for(var/turf/spot as anything in block(run_loc_floor_bottom_left, run_loc_floor_top_right)) + if(locate(/obj/projectile) in spot) + flying = TRUE + break + if(!flying) + return + sleep(0.1 SECONDS)