diff --git a/code/datums/elements/strippable.dm b/code/datums/elements/strippable.dm
index d30135dcf2a..5c4385105c4 100644
--- a/code/datums/elements/strippable.dm
+++ b/code/datums/elements/strippable.dm
@@ -16,7 +16,9 @@
if(!isatom(target))
return ELEMENT_INCOMPATIBLE
- RegisterSignal(target, COMSIG_DO_MOB_STRIP, PROC_REF(mouse_drop_onto))
+ // TODO: override = TRUE because strippable can get reattached to dead mobs after
+ // revival. Will fix for basic mobs probably maybe.
+ RegisterSignal(target, COMSIG_DO_MOB_STRIP, PROC_REF(mouse_drop_onto), override = TRUE)
src.items = items
diff --git a/code/modules/martial_arts/martial.dm b/code/modules/martial_arts/martial.dm
index fa82a4f47ee..209329cf7f7 100644
--- a/code/modules/martial_arts/martial.dm
+++ b/code/modules/martial_arts/martial.dm
@@ -73,7 +73,8 @@
streak += intent_to_streak(step)
var/mob/living/carbon/human/owner = locateUID(owner_UID)
if(istype(owner) && !QDELETED(owner))
- owner.hud_used.combo_display.update_icon(ALL, streak)
+ if(owner.hud_used)
+ owner.hud_used.combo_display.update_icon(ALL, streak)
return check_combos(step, user, target, could_start_new_combo)
return FALSE
diff --git a/code/modules/mob/living/simple_animal/friendly/dog.dm b/code/modules/mob/living/simple_animal/friendly/dog.dm
index 07b75d1cb4a..76774275d45 100644
--- a/code/modules/mob/living/simple_animal/friendly/dog.dm
+++ b/code/modules/mob/living/simple_animal/friendly/dog.dm
@@ -22,6 +22,7 @@
var/last_eaten = 0
footstep_type = FOOTSTEP_MOB_CLAW
var/next_spin_message = 0
+ var/razor_shave_delay = 5 SECONDS
/mob/living/simple_animal/pet/dog/npc_safe(mob/user)
return TRUE
@@ -193,7 +194,7 @@
to_chat(user, "You can't shave this corgi, it doesn't have a fur coat!")
return
user.visible_message("[user] starts to shave [src] using \the [O].", "You start to shave [src] using \the [O]...")
- if(do_after(user, 50, target = src))
+ if(do_after(user, razor_shave_delay, target = src))
user.visible_message("[user] shaves [src]'s hair using \the [O].")
playsound(loc, O.usesound, 20, TRUE)
shaved = TRUE
diff --git a/code/modules/mob/mob_misc_procs.dm b/code/modules/mob/mob_misc_procs.dm
index 9ffcbc99bc4..b092e7b01cc 100644
--- a/code/modules/mob/mob_misc_procs.dm
+++ b/code/modules/mob/mob_misc_procs.dm
@@ -211,7 +211,9 @@
// Do not use this if someone is intentionally trying to hit a specific body part.
// Use get_zone_with_miss_chance() for that.
/proc/ran_zone(zone, probability = 80)
-
+#ifdef GAME_TESTS
+ probability = 100
+#endif
zone = check_zone(zone)
if(prob(probability))
diff --git a/code/modules/tgui/tgui_input/alert_input.dm b/code/modules/tgui/tgui_input/alert_input.dm
index f349dd48c76..53bb23f56f8 100644
--- a/code/modules/tgui/tgui_input/alert_input.dm
+++ b/code/modules/tgui/tgui_input/alert_input.dm
@@ -20,8 +20,10 @@
var/client/client = user
user = client.mob
+#ifndef GAME_TESTS
if(isnull(user.client))
return
+#endif
// A gentle nudge - you should not be using TGUI alert for anything other than a simple message.
if(length(buttons) > 3)
@@ -37,11 +39,15 @@
var/datum/tgui_alert/alert = new(user, message, title, buttons, timeout, autofocus, ui_state)
+#ifdef GAME_TESTS
+ LAZYADD(GLOB.game_test_tguis[user.mind.key], alert)
+#else
alert.ui_interact(user)
alert.wait()
if(alert)
. = alert.choice
qdel(alert)
+#endif
/**
* # tgui_alert
@@ -87,6 +93,10 @@
SStgui.close_uis(src)
state = null
deltimer(deletion_timer)
+#ifdef GAME_TESTS
+ for(var/test_user_key in GLOB.game_test_tguis)
+ GLOB.game_test_tguis[test_user_key] -= src
+#endif
return ..()
/**
diff --git a/code/tests/_game_test.dm b/code/tests/_game_test.dm
index 24a5d2a32e9..f6413bd89cd 100644
--- a/code/tests/_game_test.dm
+++ b/code/tests/_game_test.dm
@@ -1,4 +1,5 @@
GLOBAL_LIST_EMPTY(game_test_chats)
+GLOBAL_LIST_EMPTY(game_test_tguis)
/// For advanced cases, fail unconditionally but don't return (so a test can return multiple results)
#define TEST_FAIL(reason) (Fail(reason || "No reason", __FILE__, __LINE__))
@@ -21,6 +22,8 @@ GLOBAL_LIST_EMPTY(game_test_chats)
#define TEST_ASSERT_NOT_CHATLOG(puppet, text) if(puppet.any_chatlog_has_text(text)) { return Fail("Didn't expect `[text]` in any chatlog but got [jointext(puppet.get_chatlogs(), "\n")]", __FILE__, __LINE__) }
+#define TEST_ASSERT_SUBSTRING(haystack, needle) if(!findtextEx(haystack, needle)) { return Fail("`[needle]` not found in string `[haystack]`", __FILE__, __LINE__) }
+
/// Asserts that the two parameters passed are equal, fails otherwise
/// Optionally allows an additional message in the case of a failure
#define TEST_ASSERT_EQUAL(a, b, message) do { \
diff --git a/code/tests/_game_test_puppeteer.dm b/code/tests/_game_test_puppeteer.dm
index 9074310ee69..25b956a75d0 100644
--- a/code/tests/_game_test_puppeteer.dm
+++ b/code/tests/_game_test_puppeteer.dm
@@ -129,6 +129,14 @@
if(istype(A, atom_type))
return A
+/datum/test_puppeteer/proc/get_last_tgui()
+ if(!(puppet.mind.key in GLOB.game_test_tguis))
+ return null
+ var/list/tgui_list = GLOB.game_test_tguis[puppet.mind.key]
+ if(!length(tgui_list))
+ return null
+ return tgui_list[length(tgui_list)]
+
// No we don't technically need to put these things into an actual backpack and
// so forth, we could just leave them lying around and teleport them to the
// player but this keeps things realistic and may surface issues we wouldn't
diff --git a/code/tests/attack_chain/test_attack_chain_borgs.dm b/code/tests/attack_chain/test_attack_chain_borgs.dm
new file mode 100644
index 00000000000..5cf8721b786
--- /dev/null
+++ b/code/tests/attack_chain/test_attack_chain_borgs.dm
@@ -0,0 +1,36 @@
+/datum/game_test/attack_chain_borgs/Run()
+ var/datum/test_puppeteer/player = new(src)
+ player.puppet.name = "Player"
+
+ var/mob/living/silicon/robot/borg = player.spawn_mob_nearby(/mob/living/silicon/robot)
+ borg.name = "BORGO"
+
+ player.set_intent(INTENT_HARM)
+ player.click_on(borg)
+ TEST_ASSERT_LAST_CHATLOG(player, "but doesn't leave a dent")
+
+ player.set_intent(INTENT_HELP)
+ player.spawn_obj_in_hand(/obj/item/card/id/roboticist)
+ player.click_on(borg)
+ TEST_ASSERT_LAST_CHATLOG(player, "You unlock BORGO's interface")
+ player.puppet.swap_hand()
+ var/obj/item/crowbar = player.spawn_obj_in_hand(/obj/item/crowbar)
+ crowbar.attack_verb = list("bashed")
+ player.click_on(borg)
+ TEST_ASSERT_LAST_CHATLOG(player, "You open the cover")
+ player.put_away(crowbar)
+ player.click_on(borg)
+ TEST_ASSERT_LAST_CHATLOG(player, "You remove the high-capacity power cell")
+ player.click_on(borg)
+ TEST_ASSERT_LAST_CHATLOG(player, "You insert the power cell")
+ player.retrieve(crowbar)
+ player.click_on(borg)
+ TEST_ASSERT_LAST_CHATLOG(player, "You close the cover")
+ player.puppet.swap_hand()
+ player.click_on(borg)
+ TEST_ASSERT_LAST_CHATLOG(player, "You lock BORGO's interface")
+ player.puppet.swap_hand()
+ player.set_intent(INTENT_HARM)
+ player.click_on(borg)
+ TEST_ASSERT_LAST_CHATLOG(player, "Player has bashed BORGO with the crowbar")
+ TEST_ASSERT(borg.health <= borg.maxHealth, "borg did not take damage")
diff --git a/code/tests/attack_chain/test_attack_chain_mobs.dm b/code/tests/attack_chain/test_attack_chain_mobs.dm
new file mode 100644
index 00000000000..fd69cfd3bb0
--- /dev/null
+++ b/code/tests/attack_chain/test_attack_chain_mobs.dm
@@ -0,0 +1,120 @@
+/datum/game_test/attack_chain_mobs/Run()
+ var/datum/test_puppeteer/player = new(src)
+ player.puppet.name = "Player"
+ // To ensure punches do damage without knockdowns
+ player.puppet.dna.species.punchdamagelow = 5
+ player.puppet.dna.species.punchdamagehigh = 5
+
+ var/mob/living/simple_animal/hostile/morph/morph = player.spawn_mob_nearby(/mob/living/simple_animal/hostile/morph)
+ morph.ambush_prepared = TRUE
+ player.spawn_obj_in_hand(/obj/item/screwdriver)
+ player.click_on(morph)
+
+ TEST_ASSERT_ANY_CHATLOG(player, "You try to use")
+ TEST_ASSERT_ANY_CHATLOG(player, "suddenly collapses in on itself")
+ qdel(morph)
+ // Get us back up
+ player.rejuvenate()
+
+ var/datum/test_puppeteer/victim = player.spawn_puppet_nearby()
+ victim.puppet.name = "Victim"
+ player.click_on(victim)
+ TEST_ASSERT_LAST_CHATLOG(player, "You hug Victim")
+ player.set_intent(INTENT_HARM)
+ player.click_on(victim)
+ TEST_ASSERT_LAST_CHATLOG(player, "Player punched Victim")
+ victim.rejuvenate()
+ var/obj/knife = player.spawn_obj_in_hand(/obj/item/kitchen/knife)
+ player.puppet.zone_selected = BODY_ZONE_PRECISE_L_FOOT
+ player.click_on(victim)
+ TEST_ASSERT_LAST_CHATLOG(player, "Victim in the left foot")
+ victim.rejuvenate()
+ player.put_away(knife)
+
+ // Test CQC attack interception
+ var/datum/martial_art/cqc/cqc = new()
+ cqc.teach(player.puppet)
+ player.puppet.swap_hand()
+ victim.puppet.lay_down()
+ player.click_on(victim)
+ TEST_ASSERT_LAST_CHATLOG(player, "stomps on Victim")
+ victim.rejuvenate()
+ cqc.remove(player.puppet)
+
+ player.set_intent(INTENT_HELP)
+ var/mob/mining_drone = player.spawn_mob_nearby(/mob/living/simple_animal/hostile/mining_drone)
+ var/obj/scanner = player.spawn_obj_in_hand(/obj/item/mining_scanner)
+ player.click_on(mining_drone)
+ TEST_ASSERT_LAST_CHATLOG(player, "drop any collected ore.")
+ qdel(scanner)
+ player.spawn_obj_in_hand(/obj/item/borg/upgrade/modkit/cooldown/minebot)
+ player.click_on(mining_drone)
+ TEST_ASSERT_LAST_CHATLOG(player, "You install the modkit")
+ player.retrieve(knife)
+ player.set_intent(INTENT_HARM)
+ player.click_on(mining_drone)
+ TEST_ASSERT_LAST_CHATLOG(player, "the nanotrasen minebot with the kitchen knife")
+ qdel(mining_drone)
+
+ RegisterSignal(victim.puppet, COMSIG_HUMAN_ATTACKED, PROC_REF(cancel_attack_chain))
+ player.click_on(victim)
+ TEST_ASSERT_LAST_CHATLOG(player, "Attack chain cancelled by signal")
+ UnregisterSignal(victim.puppet, COMSIG_HUMAN_ATTACKED)
+
+ var/mob/living/simple_animal/pet/dog/corgi/corgi = player.spawn_mob_nearby(/mob/living/simple_animal/pet/dog/corgi)
+ corgi.anchored = TRUE
+ player.click_on(corgi)
+ TEST_ASSERT_LAST_CHATLOG(player, "corgi with the kitchen knife")
+ player.put_away(knife)
+ corgi.rejuvenate()
+ player.set_intent(INTENT_HELP)
+ var/obj/collar = player.spawn_obj_in_hand(/obj/item/petcollar)
+ player.click_on(corgi)
+ TEST_ASSERT(collar in corgi.contents, "did not put collar on corgi")
+ var/obj/razor = player.spawn_obj_in_hand(/obj/item/razor)
+ corgi.razor_shave_delay = 0
+ player.click_on(corgi)
+ TEST_ASSERT(corgi.shaved, "corgi was not shaved")
+ player.put_away(razor)
+ corgi.death()
+ var/obj/laz_injector = player.spawn_obj_in_hand(/obj/item/lazarus_injector)
+ player.click_on(corgi)
+ TEST_ASSERT_LAST_CHATLOG(player, "injects the corgi")
+ qdel(laz_injector)
+
+ var/mob/slime = player.spawn_mob_nearby(/mob/living/simple_animal/slime)
+ slime.anchored = TRUE
+
+ player.spawn_obj_in_hand(/obj/item/slimepotion/slime/docility)
+ player.click_on(corgi)
+ TEST_ASSERT_LAST_CHATLOG(player, "only works on slimes!")
+ player.click_on(slime)
+ TEST_ASSERT_LAST_CHATLOG(player, "You feed the slime the potion")
+
+ var/obj/mind_transfer_slime_potion = player.spawn_obj_in_hand(/obj/item/slimepotion/transference)
+ player.click_on(corgi)
+ var/datum/tgui_alert/alert = player.get_last_tgui()
+ TEST_ASSERT_NOTNULL(alert, "no TGUI")
+ TEST_ASSERT_SUBSTRING(alert.message, "transfer your consciousness to the corgi")
+ alert.set_choice("No")
+
+ player.click_on(slime)
+ alert = player.get_last_tgui()
+ TEST_ASSERT_NOTNULL(alert, "no TGUI")
+ TEST_ASSERT_SUBSTRING(alert.message, "transfer your consciousness to the pet slime")
+ alert.set_choice("No")
+
+ qdel(mind_transfer_slime_potion)
+ qdel(corgi)
+ qdel(slime)
+
+ victim.puppet.death()
+ player.spawn_obj_in_hand(/obj/item/kitchen/knife/butcher/meatcleaver)
+ player.set_intent(INTENT_HARM)
+ player.click_on(victim)
+ // Even before this test, butchering items included a second attack message
+ TEST_ASSERT_ANY_CHATLOG(player, "You hack off a chunk of meat from Victim")
+
+/datum/game_test/attack_chain_mobs/proc/cancel_attack_chain(datum/source, mob/user)
+ to_chat(user, "Attack chain cancelled by signal")
+ return COMPONENT_CANCEL_ATTACK_CHAIN
diff --git a/code/tests/game_tests.dm b/code/tests/game_tests.dm
index 9795c4ccb9a..7fa7da132d9 100644
--- a/code/tests/game_tests.dm
+++ b/code/tests/game_tests.dm
@@ -10,9 +10,11 @@
#ifdef GAME_TESTS
#include "atmos\test_ventcrawl.dm"
+#include "attack_chain\test_attack_chain_borgs.dm"
#include "attack_chain\test_attack_chain_cult_dagger.dm"
#include "attack_chain\test_attack_chain_machinery.dm"
#include "attack_chain\test_attack_chain_stunbaton.dm"
+#include "attack_chain\test_attack_chain_mobs.dm"
#include "attack_chain\test_attack_chain_turf.dm"
#include "attack_chain\test_attack_chain_vehicles.dm"
#include "games\test_cards.dm"