From cb5cc4320e5ba26870c337a8e81c9ec62cccce23 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:48:42 -0800 Subject: [PATCH] Let NPC shots hit an ally held by their enemy An NPC's shots no longer pass over one of its own side while someone off that side holds them in an aggressive grab, so a grabbed NPC works as a human shield. Tests cover the held, dragged and friendly-held cases, and wait for late pellets between checks. --- .../projectiles/projectile_npc_allies.dm | 21 ++++++++ .../voidcrew_npc_fire_over_allies.dm | 50 +++++++++++++++++++ 2 files changed, 71 insertions(+) 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)