From 4b114015475ac0442092ec22868b279ef606c111 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:22:34 -0800 Subject: [PATCH 001/152] Add stealth awareness, patrols and admin tools for scav guards --- tgstation.dme | 7 + voidcrew/_DEFINES/stealth.dm | 104 +++ voidcrew/modules/stealth/scav_guards.dm | 64 ++ voidcrew/modules/stealth/stealth_admin.dm | 248 ++++++ voidcrew/modules/stealth/stealth_ai.dm | 805 ++++++++++++++++++ voidcrew/modules/stealth/stealth_patrol.dm | 373 ++++++++ .../modules/stealth/stealth_perception.dm | 115 +++ voidcrew/modules/stealth/stealth_test_ruin.dm | 10 + 8 files changed, 1726 insertions(+) create mode 100644 voidcrew/_DEFINES/stealth.dm create mode 100644 voidcrew/modules/stealth/scav_guards.dm create mode 100644 voidcrew/modules/stealth/stealth_admin.dm create mode 100644 voidcrew/modules/stealth/stealth_ai.dm create mode 100644 voidcrew/modules/stealth/stealth_patrol.dm create mode 100644 voidcrew/modules/stealth/stealth_perception.dm create mode 100644 voidcrew/modules/stealth/stealth_test_ruin.dm diff --git a/tgstation.dme b/tgstation.dme index 79045ef1f52..5436811288d 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6741,6 +6741,7 @@ #include "voidcrew\_DEFINES\ship_signals.dm" #include "voidcrew\_DEFINES\shuttles.dm" #include "voidcrew\_DEFINES\sound.dm" +#include "voidcrew\_DEFINES\stealth.dm" #include "voidcrew\_DEFINES\subsystems.dm" #include "voidcrew\_DEFINES\techweb_nodes.dm" #include "voidcrew\_DEFINES\trade.dm" @@ -7726,6 +7727,12 @@ #include "voidcrew\modules\shuttle\survey\survey_data_disk.dm" #include "voidcrew\modules\shuttle\survey\survey_designs.dm" #include "voidcrew\modules\slime_extract_piles\slime_extract_piles.dm" +#include "voidcrew\modules\stealth\scav_guards.dm" +#include "voidcrew\modules\stealth\stealth_admin.dm" +#include "voidcrew\modules\stealth\stealth_ai.dm" +#include "voidcrew\modules\stealth\stealth_patrol.dm" +#include "voidcrew\modules\stealth\stealth_perception.dm" +#include "voidcrew\modules\stealth\stealth_test_ruin.dm" #include "voidcrew\modules\storage\smart_locker.dm" #include "voidcrew\modules\storage\wall_locker.dm" #include "voidcrew\modules\surgery\experimental_dissection.dm" diff --git a/voidcrew/_DEFINES/stealth.dm b/voidcrew/_DEFINES/stealth.dm new file mode 100644 index 00000000000..6fefe3e1bc7 --- /dev/null +++ b/voidcrew/_DEFINES/stealth.dm @@ -0,0 +1,104 @@ +// Stealth layer for chosen human NPCs (voidcrew/modules/stealth/). Awareness is AI state only. + +// ===== Awareness states (BB_STEALTH_STATE) ===== +#define STEALTH_UNAWARE "unaware" +#define STEALTH_SUSPICIOUS "suspicious" +#define STEALTH_ALERT "alert" +#define STEALTH_SEARCHING "searching" + +// ===== Blackboard keys ===== +/// One of the STEALTH_* states +#define BB_STEALTH_STATE "stealth_state" +/// world.time of the last state change +#define BB_STEALTH_STATE_SINCE "stealth_state_since" +/// world.time the guard last stopped being unaware (stealth_was_unaware()) +#define BB_STEALTH_UNAWARE_LOST_AT "stealth_unaware_lost_at" +/// TRUE once alerted, searching or hit. Never cleared by time or dormancy. +#define BB_STEALTH_WARY "stealth_wary" +/// REF(mob) => list(notice, turf last scan, world.time last perceived) +#define BB_STEALTH_NOTICE "stealth_notice" +/// The candidate with the highest notice +#define BB_STEALTH_FOCUS "stealth_focus" +/// Where it last saw (or was hit from) whoever it is after +#define BB_STEALTH_LAST_SEEN_TURF "stealth_last_seen_turf" +/// world.time of that sighting +#define BB_STEALTH_LAST_SEEN_AT "stealth_last_seen_at" +/// REFs of fallen group members it has already reacted to +#define BB_STEALTH_BODIES_SEEN "stealth_bodies_seen" +/// Its claimed stop, or the sentry's post +#define BB_STEALTH_HOME_TURF "stealth_home_turf" + +// Notice entry fields +#define STEALTH_ENTRY_NOTICE 1 +#define STEALTH_ENTRY_TURF 2 +#define STEALTH_ENTRY_SEEN_AT 3 + +// ===== Bark kinds ===== +#define STEALTH_BARK_SUSPICIOUS "stealth_suspicious" +#define STEALTH_BARK_GIVE_UP "stealth_give_up" +#define STEALTH_BARK_LOST "stealth_lost" +#define STEALTH_BARK_SEARCH "stealth_search" +#define STEALTH_BARK_HIT_UNSEEN "stealth_hit_unseen" +#define STEALTH_BARK_BODY "stealth_body" +#define STEALTH_BARK_CHATTER "stealth_chatter" + +// ===== What a stealth goto is for ===== +#define STEALTH_GOTO_PATROL "patrol" +#define STEALTH_GOTO_INVESTIGATE "investigate" +#define STEALTH_GOTO_BODY "body" +#define STEALTH_GOTO_SEARCH "search" +#define STEALTH_GOTO_HOME "home" + +// ===== Numbers (first playtest defaults; the owner tunes them) ===== +#define STEALTH_SCAN_INTERVAL (1 SECONDS) +#define STEALTH_SIGHT_RANGE 8 +#define STEALTH_ALERT_MAX_RANGE 7 +#define STEALTH_DARK_SIGHT_RANGE 4 +#define STEALTH_DARK_LUMCOUNT 0.2 +#define STEALTH_DARK_MULT 0.5 +#define STEALTH_CLOSE_RANGE 2 +#define STEALTH_CONE_HALF_ANGLE 60 +#define STEALTH_WARY_HALF_ANGLE 75 +#define STEALTH_SEARCH_HALF_ANGLE 90 +#define STEALTH_NOTICE_RATE 1.0 +#define STEALTH_NOTICE_MIN_FALLOFF 0.25 +#define STEALTH_SUSPICIOUS_AT 1.0 +#define STEALTH_ALERT_AT 2.5 +#define STEALTH_NOTICE_DECAY 0.34 +#define STEALTH_NOTICE_FORGET (20 SECONDS) +#define STEALTH_BEHIND_RATE 0.4 +#define STEALTH_RUN_MULT 2 +#define STEALTH_STILL_MULT 0.5 +#define STEALTH_LYING_MULT 0.5 +#define STEALTH_SUSPICIOUS_MULT 1.5 +#define STEALTH_SEARCHING_MULT 2 +#define STEALTH_WARY_MULT 1.5 +#define STEALTH_MAX_CANDIDATES 8 +#define STEALTH_INVESTIGATE_DELAY (2 SECONDS) +#define STEALTH_LOOK_AROUND_TIME (4 SECONDS) +#define STEALTH_SUSPICIOUS_TIMEOUT (12 SECONDS) +#define STEALTH_LOSE_TARGET_TIME (4 SECONDS) +#define STEALTH_SEARCH_TIME (40 SECONDS) +#define STEALTH_SEARCH_POINTS 3 +#define STEALTH_SEARCH_RADIUS 5 +#define STEALTH_BODY_SCAN_INTERVAL (2 SECONDS) +#define STEALTH_BARK_KIND_COOLDOWN (8 SECONDS) +#define STEALTH_GROUP_RADIUS 48 +#define STEALTH_ROUTE_CLAIM_RANGE 2 +#define STEALTH_PATROL_DEFAULT_WAIT (6 SECONDS) +#define STEALTH_LOOK_TURN_INTERVAL (3 SECONDS) +#define STEALTH_LOOK_TURN_CHANCE 50 +#define STEALTH_LOOK_TURN_ARC 90 +#define STEALTH_SENTRY_TURN_INTERVAL (8 SECONDS) +#define STEALTH_CHATTER_RANGE 3 +#define STEALTH_CHATTER_COOLDOWN (45 SECONDS) +#define STEALTH_PATROL_MAX_FAILS 3 +#define STEALTH_PATROL_SKIP_TIME (2 MINUTES) +#define STEALTH_PATROL_MAX_PATH 60 +#define STEALTH_LEASH_RANGE 24 +#define STEALTH_ADMIN_OVERLAY_TIME (5 MINUTES) +/// Seconds of search between "Come on out." lines +#define STEALTH_SEARCH_BARK_INTERVAL (12 SECONDS) + +/// The scav den's side +#define FACTION_SCAV "scav" diff --git a/voidcrew/modules/stealth/scav_guards.dm b/voidcrew/modules/stealth/scav_guards.dm new file mode 100644 index 00000000000..cc54f580537 --- /dev/null +++ b/voidcrew/modules/stealth/scav_guards.dm @@ -0,0 +1,64 @@ +/** + * # Scav den guards + * + * The first NPCs with the stealth controller (owner decisions 6 and 11). Placeholder dress until step 4 + * gives them their own look: the melee guard borrows the Syndicate sword table, the gunner the pistol one. + */ + +/datum/human_npc_voice/scav + lines = list( + RUIN_NPC_BARK_SPOT = list("There! Get 'em!", "Intruder!", "Got you now!", "Over here!"), + HUMAN_NPC_BARK_DOWNED = list("Ugh... I'm hit...", "Somebody..."), + HUMAN_NPC_BARK_RECOVER = list("Still here.", "That all?"), + HUMAN_NPC_BARK_CUFFED = list("You don't know who you're messing with."), + STEALTH_BARK_SUSPICIOUS = list("Hm?", "Who's there?", "Thought I saw something."), + STEALTH_BARK_GIVE_UP = list("Nothing.", "Jumpy tonight.", "Must be the rats."), + STEALTH_BARK_LOST = list("Lost them!", "Where'd they go?"), + STEALTH_BARK_SEARCH = list("Come on out.", "I know you're here."), + STEALTH_BARK_HIT_UNSEEN = list("Contact!", "Where's that coming from?"), + STEALTH_BARK_BODY = list("Man down!", "Who did this?"), + STEALTH_BARK_CHATTER = list("Shipment's late again.", "My feet are killing me.", "You hear that? No? Good."), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + STEALTH_BARK_SUSPICIOUS = list("squints into the dark.", "turns, frowning."), + STEALTH_BARK_GIVE_UP = list("shrugs."), + STEALTH_BARK_SEARCH = list("checks behind a crate."), + STEALTH_BARK_HIT_UNSEEN = list("ducks."), + STEALTH_BARK_CHATTER = list("yawns.", "stretches."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 100) + +// A line and an emote together read as a script: for the stealth kinds, now one and now the other. +/datum/human_npc_voice/scav/emotes_for(kind) + . = ..() + if(length(.) && length(lines_for(kind)) && prob(60)) + return null + +// The spot line always comes, even right after a stealth line. +/datum/human_npc_voice/scav/bark(mob/living/carbon/human/npc/speaker, kind, force = FALSE) + return ..(speaker, kind, force || kind == RUIN_NPC_BARK_SPOT) + +/datum/human_npc_voice/scav/lines_for(kind) + . = ..() + if(length(.) && length(emotes[kind]) && prob(30)) + return null + +/mob/living/carbon/human/npc/ruin/trooper/scav + name = "scav guard" + npc_name = "Scav Guard" + faction = list(FACTION_SCAV) + ai_controller = /datum/ai_controller/basic_controller/human_npc/stealth + npc_voice = /datum/human_npc_voice/scav + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/sword + +/mob/living/carbon/human/npc/ruin/trooper/scav/gunner + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pistol + +/obj/effect/spawner/human_npc/ruin/scav + name = "scav guard spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/scav + +/obj/effect/spawner/human_npc/ruin/scav/gunner + name = "scav gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/scav/gunner diff --git a/voidcrew/modules/stealth/stealth_admin.dm b/voidcrew/modules/stealth/stealth_admin.dm new file mode 100644 index 00000000000..fc3e9d3c847 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_admin.dm @@ -0,0 +1,248 @@ +/** + * # Stealth guard admin tools + * + * "Stealth Guards" (Debug): lists every stealth guard on your z-level, shows and forces its awareness, + * shows its sight to you alone, and acts on whole groups. Every action is logged (abuse review P16). + * + * The AI sleeps unless a living player is on the level. An admin watching as a ghost needs + * "Force Z-Level Active" (npc_ships/code/admin_verbs.dm). + */ + +/// The stealth guards on `z`, by group. +/proc/stealth_guards_on_level(z) + . = list() + for(var/datum/stealth_group/group as anything in GLOB.stealth_groups) + for(var/mob/living/guard as anything in group.living_members()) + var/turf/where = get_turf(guard) + if(where?.z == z) + . += guard + +/// One line about `guard`, for admins. +/proc/stealth_guard_summary(mob/living/carbon/human/npc/guard) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + if(!istype(stealth_ai)) + return "[guard] (no stealth AI)" + var/atom/focus = stealth_ai.blackboard[BB_STEALTH_FOCUS] + var/focus_text = "none" + if(focus) + var/list/entry = stealth_ai.blackboard[BB_STEALTH_NOTICE]?[REF(focus)] + focus_text = "[focus] [entry ? round(entry[STEALTH_ENTRY_NOTICE], 0.01) : "-"]" + var/since = round((world.time - stealth_ai.blackboard[BB_STEALTH_STATE_SINCE]) / (1 SECONDS)) + var/route_text = stealth_ai.route_id ? "route [stealth_ai.route_id] stop [stealth_ai.route_index]" : "sentry" + var/status_text = list("[AI_STATUS_ON]" = "on", "[AI_STATUS_IDLE]" = "idle", "[AI_STATUS_OFF]" = "off")["[stealth_ai.ai_status]"] || "off" + return "[guard.real_name] [REF(guard)] | [stealth_ai.group?.id || "no group"] | [stealth_ai.stealth_state()] [since]s | focus [focus_text] | [stealth_ai.blackboard[BB_STEALTH_WARY] ? "wary" : "calm"] | [route_text] | AI [status_text]" + +ADMIN_VERB(stealth_guards, R_DEBUG, "Stealth Guards", "View and force the awareness of stealth guards on your z-level.", ADMIN_CATEGORY_DEBUG) + var/turf/here = get_turf(user.mob) + if(isnull(here)) + to_chat(user, span_warning("You need to be standing somewhere."), confidential = TRUE) + return + var/list/choices = list() + var/list/groups = list() + for(var/mob/living/carbon/human/npc/guard as anything in stealth_guards_on_level(here.z)) + choices[stealth_guard_summary(guard)] = guard + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + if(stealth_ai.group) + groups["Whole group: [stealth_ai.group.id]"] = stealth_ai.group + if(!length(choices)) + to_chat(user, span_notice("No stealth guards on this z-level."), confidential = TRUE) + return + var/list/all_choices = sort_list(choices) + sort_list(groups) + var/choice = tgui_input_list(user, "Which guard or group?", "Stealth Guards", all_choices) + if(isnull(choice)) + return + if(groups[choice]) + stealth_admin_group(user, groups[choice]) + else if(choices[choice]) + stealth_admin_guard(user, choices[choice]) + BLACKBOX_LOG_ADMIN_VERB("Stealth Guards") + +/proc/stealth_admin_log(client/user, text, atom/where) + log_admin("[key_name(user)] stealth guards: [text][where ? " at [AREACOORD(where)]" : ""]") + message_admins("[key_name_admin(user)] stealth guards: [text][where ? " at [ADMIN_VERBOSEJMP(where)]" : ""]") + +/proc/stealth_admin_guard(client/user, mob/living/carbon/human/npc/guard) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + if(QDELETED(guard) || !istype(stealth_ai)) + return + var/list/actions = list("Force Unaware", "Force Suspicious at my turf", "Force Alert on me", "Force Alert on a mob I pick", "Force Searching at my turf", "Toggle Wary", "Show its sight", "Jump to it", "Next stop") + var/action = tgui_input_list(user, stealth_guard_summary(guard), "Stealth Guard", actions) + if(isnull(action) || QDELETED(guard)) + return + var/turf/here = get_turf(user.mob) + switch(action) + if("Force Unaware") + stealth_ai.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + stealth_ai.stealth_go_unaware() + if("Force Suspicious at my turf") + stealth_ai.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + stealth_ai.stealth_go_suspicious(here) + if("Force Alert on me") + if(!isliving(user.mob)) + to_chat(user, span_warning("You need a living body for that."), confidential = TRUE) + return + stealth_ai.stealth_go_alert(user.mob) + if("Force Alert on a mob I pick") + var/list/mobs = list() + for(var/mob/living/someone in range(HUMAN_NPC_SIGHT_RANGE * 2, guard)) + if(someone != guard) + mobs["[someone] ([REF(someone)])"] = someone + var/picked = tgui_input_list(user, "Alert on whom?", "Stealth Guard", sort_list(mobs)) + if(isnull(picked) || QDELETED(mobs[picked])) + return + stealth_ai.stealth_go_alert(mobs[picked]) + if("Force Searching at my turf") + stealth_ai.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + stealth_ai.stealth_go_searching(here) + if("Toggle Wary") + stealth_ai.blackboard[BB_STEALTH_WARY] = !stealth_ai.blackboard[BB_STEALTH_WARY] + if("Show its sight") + stealth_toggle_sight_overlay(user, guard) + if("Jump to it") + user.mob.forceMove(get_turf(guard)) + if("Next stop") + var/next = stealth_ai.stealth_next_index() + if(next) + stealth_ai.stealth_drop_walk() + stealth_ai.at_stop = FALSE + stealth_ai.route_index = next + stealth_admin_log(user, "[action] on [key_name(guard)]", guard) + +/proc/stealth_admin_group(client/user, datum/stealth_group/group) + var/list/actions = list("Calm all", "Clear wary", "Alert all on me", "Search all at my turf", "Show every route") + var/action = tgui_input_list(user, "[group.id]: [length(group.living_members())] guards", "Stealth Group", actions) + if(isnull(action) || QDELETED(group)) + return + var/turf/here = get_turf(user.mob) + if(action == "Show every route") + stealth_show_routes(user, group) + else + for(var/mob/living/guard as anything in group.living_members()) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + if(!istype(stealth_ai) || guard.stat == DEAD) + continue + switch(action) + if("Calm all") + stealth_ai.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + stealth_ai.stealth_go_unaware() + if("Clear wary") + stealth_ai.blackboard[BB_STEALTH_WARY] = FALSE + if("Alert all on me") + if(isliving(user.mob)) + stealth_ai.stealth_go_alert(user.mob) + if("Search all at my turf") + stealth_ai.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + stealth_ai.stealth_go_searching(here) + stealth_admin_log(user, "[action] on [group.id]", group.home) + +/// Numbered markers on every stop of every route in `group`, for `user` alone, for a minute. +/proc/stealth_show_routes(client/user, datum/stealth_group/group) + var/list/images = list() + for(var/route in group.routes) + var/list/stops = group.routes[route] + for(var/index in 1 to length(stops)) + var/datum/stealth_stop/stop = stops[index] + var/image/marker = image('icons/effects/alphacolors.dmi', stop.spot, "white") + SET_PLANE(marker, ABOVE_LIGHTING_PLANE, stop.spot) + marker.color = stop.chatter ? "#ffaa00" : "#00aaff" + marker.alpha = 110 + marker.maptext = MAPTEXT("[route][index]") + images += marker + user.images |= images + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(stealth_remove_images), WEAKREF(user), images), 60 SECONDS) + +/proc/stealth_remove_images(datum/weakref/user_ref, list/images) + var/client/user = user_ref?.resolve() + user?.images -= images + +// ===== Sight overlay ===== + +/// Admins watching one guard's sight: admin ckey => overlay +GLOBAL_LIST_EMPTY(stealth_sight_overlays) + +/proc/stealth_toggle_sight_overlay(client/user, mob/living/carbon/human/npc/guard) + var/datum/stealth_sight_overlay/old = GLOB.stealth_sight_overlays[user.ckey] + var/same = old?.guard_ref?.resolve() == guard + if(old) + qdel(old) + if(same) + return + new /datum/stealth_sight_overlay(user, guard) + +/** + * The tiles one guard sees, shown to one admin: green for its lit cone, blue for how far it sees into + * the dark, yellow for the close radius it feels all round. Rebuilt each scan while watched; ends when + * the guard dies, on a second toggle, or after STEALTH_ADMIN_OVERLAY_TIME. + */ +/datum/stealth_sight_overlay + var/datum/weakref/user_ref + var/datum/weakref/guard_ref + var/ckey + var/list/image/images = list() + +/datum/stealth_sight_overlay/New(client/user, mob/living/carbon/human/npc/guard) + user_ref = WEAKREF(user) + guard_ref = WEAKREF(guard) + ckey = user.ckey + GLOB.stealth_sight_overlays[ckey] = src + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + LAZYADD(stealth_ai.watchers, src) + RegisterSignal(guard, COMSIG_LIVING_DEATH, PROC_REF(on_guard_gone)) + RegisterSignal(guard, COMSIG_QDELETING, PROC_REF(on_guard_gone)) + QDEL_IN(src, STEALTH_ADMIN_OVERLAY_TIME) + refresh() + +/datum/stealth_sight_overlay/Destroy() + clear() + if(GLOB.stealth_sight_overlays[ckey] == src) + GLOB.stealth_sight_overlays -= ckey + var/mob/living/carbon/human/npc/guard = guard_ref?.resolve() + if(guard) + UnregisterSignal(guard, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING)) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + if(istype(stealth_ai)) + LAZYREMOVE(stealth_ai.watchers, src) + return ..() + +/datum/stealth_sight_overlay/proc/on_guard_gone(datum/source) + SIGNAL_HANDLER + qdel(src) + +/datum/stealth_sight_overlay/proc/clear() + var/client/user = user_ref?.resolve() + user?.images -= images + images.Cut() + +/datum/stealth_sight_overlay/proc/refresh() + clear() + var/client/user = user_ref?.resolve() + var/mob/living/carbon/human/npc/guard = guard_ref?.resolve() + if(isnull(user) || QDELETED(guard)) + return + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + if(!istype(stealth_ai)) + return + var/turf/center = get_turf(guard) + if(isnull(center)) + return + var/eyes = stealth_ai.stealth_eyes_open() + var/half_angle = stealth_ai.stealth_half_angle() + var/night = HAS_TRAIT(guard, TRAIT_NIGHT_VISION) + for(var/turf/tile as anything in RANGE_TURFS(STEALTH_SIGHT_RANGE, center)) + var/dist = get_dist(center, tile) + var/colour + if(eyes && stealth_in_cone(guard, tile, half_angle) && can_see(center, tile, STEALTH_SIGHT_RANGE)) + if(dist <= STEALTH_DARK_SIGHT_RANGE || night) + colour = "#3399ff" + else + colour = "#33cc33" + if(dist <= STEALTH_CLOSE_RANGE) + colour = colour || "#ffcc00" + if(isnull(colour)) + continue + var/image/marker = image('icons/effects/alphacolors.dmi', tile, "white") + SET_PLANE(marker, ABOVE_LIGHTING_PLANE, tile) + marker.color = colour + marker.alpha = 70 + images += marker + user.images |= images diff --git a/voidcrew/modules/stealth/stealth_ai.dm b/voidcrew/modules/stealth/stealth_ai.dm new file mode 100644 index 00000000000..b766d7e25e5 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_ai.dm @@ -0,0 +1,805 @@ +/** + * # Stealth guards: awareness + * + * A human NPC type opts in with `ai_controller = /datum/ai_controller/basic_controller/human_npc/stealth`. + * Nothing changes for any other human NPC (owner decision 4). + * + * The guard is unaware, suspicious, alert or searching (BB_STEALTH_STATE). That is AI state only: the + * body takes damage, wounds, stuns and crit through tg exactly as before, and players never get a state. + * + * - Unaware: walks its patrol (stealth_patrol.dm) or stands its post. + * - Suspicious: saw something. Turns to it, says so, stares a moment, walks over and looks around, then + * gives up and goes back to its route. + * - Alert: has a target and fights as every human NPC does. It keeps the target all round with tg's + * can_attack() (no cone, no darkness); out of sight for STEALTH_LOSE_TARGET_TIME, it searches. + * - Searching: runs to where it last saw someone and sweeps the spots round it, then gives up. + * + * Wary (BB_STEALTH_WARY) is set by any alert, search, hit or surrender and is never cleared: a wider cone + * and quicker notice for the rest of its life (abuse review P4). + * + * Every attack or touch gets a reaction, even from out of sight (P3): alert on the attacker if it can see + * them, otherwise a search at their turf. Detection is a once-a-second scan owned by this controller, never + * tg's movement-triggered target field (P1). Nothing here runs while the AI is off (no player on the + * level); timers are world.time stamps, so a guard that wakes after its search ran out steps straight + * back to unaware and stays wary (T1). + */ +/datum/ai_controller/basic_controller/human_npc/stealth + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/human_npc, + BB_TARGET_MINIMUM_STAT = HARD_CRIT, + BB_AGGRO_RANGE = HUMAN_NPC_SIGHT_RANGE, + BB_HUMAN_NPC_SHOT_DELAY = 3 SECONDS, + BB_HUMAN_NPC_FIRE_RANGE = 7, + BB_HUMAN_NPC_KEEP_AWAY = 2, + BB_STEALTH_STATE = STEALTH_UNAWARE, + ) + // The patrol subtree decides what an unaware guard does; no random walk. + idle_behavior = null + ai_movement = /datum/ai_movement/jps/stealth + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, + /datum/ai_planning_subtree/human_npc_surrender, + /datum/ai_planning_subtree/stealth_perception, + /datum/ai_planning_subtree/stealth_react, + /datum/ai_planning_subtree/human_npc_arm, + /datum/ai_planning_subtree/human_npc_shoot, + /datum/ai_planning_subtree/human_npc_melee, + /datum/ai_planning_subtree/stealth_patrol, + ) + /// Its patrol group (stealth_patrol.dm) + var/datum/stealth_group/group + /// Whether it has joined a group and claimed a route + var/stealth_ready = FALSE + /// The route id it walks, or null for a sentry + var/route_id + /// Index of the stop it is heading for or standing at + var/route_index = 1 + /// +1 or -1 along the route + var/route_step = 1 + /// The way a sentry faces at its post + var/sentry_dir = SOUTH + /// Stop index => failed tries to reach it + var/list/stop_fails = list() + /// Stop index => world.time it may be tried again + var/list/stop_skip_until = list() + /// world.time it leaves the stop it stands at + var/wait_until = 0 + /// Whether it has done its arrival at the current stop + var/at_stop = FALSE + /// Where its current stealth walk goes + var/turf/goto_turf + /// What that walk is for (STEALTH_GOTO_*) + var/goto_purpose + /// "arrived" or "failed" once the walk is over, else null + var/goto_result + /// Set while it drops its own walk, so a dropped walk doesn't count as a failure + var/dropping_walk = FALSE + /// world.time its current walk began + var/goto_started = 0 + /// Set when a walk ran out of time + var/goto_timed_out = FALSE + /// world.time its current look round ends + var/look_until = 0 + /// The way it faces while standing (a stop's facing, a sentry's post, where it saw something) + var/stand_dir = SOUTH + /// world.time it may next turn its head while standing + var/next_turn = 0 + /// Suspicious: where it walks to look + var/turf/investigate_turf + /// Suspicious: whether it has walked over and looked + var/investigated = FALSE + /// world.time of the last notice gain on its focus (suspicion holds STEALTH_SUSPICIOUS_TIMEOUT after it) + var/last_gain = 0 + /// Searching: the spot it searches round + var/turf/search_center + /// Searching: the spots still to check + var/list/search_queue = list() + /// Searching: whether it is looking round at the spot it reached + var/search_looking = FALSE + /// world.time of its next search line + var/next_search_bark = 0 + /// Alert: world.time it first failed to see its target in this stretch, or 0 + var/lost_since = 0 + /// world.time of its next look for fallen friends + var/next_body_scan = 0 + /// Bark kind => world.time it may say that kind again + var/list/bark_ready_at = list() + /// Its stat at the last change, to tell recovery from crit + var/last_stat = CONSCIOUS + /// Admins watching its sight overlay (stealth_admin.dm) + var/list/datum/stealth_sight_overlay/watchers + +/datum/ai_controller/basic_controller/human_npc/stealth/TryPossessPawn(atom/new_pawn) + . = ..() + if(. & AI_CONTROLLER_INCOMPATIBLE) + return + blackboard[BB_STEALTH_NOTICE] = list() + blackboard[BB_STEALTH_BODIES_SEEN] = list() + blackboard[BB_STEALTH_STATE_SINCE] = world.time + RegisterSignal(new_pawn, COMSIG_LIVING_GET_PULLED, PROC_REF(on_pulled)) + RegisterSignal(new_pawn, COMSIG_ATOM_BUMPED, PROC_REF(on_bumped)) + RegisterSignal(new_pawn, COMSIG_CARBON_HELP_ACT, PROC_REF(on_helped)) + RegisterSignal(new_pawn, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_projectile_prehit)) + RegisterSignal(new_pawn, COMSIG_MOVABLE_Z_CHANGED, PROC_REF(on_z_changed)) + RegisterSignal(new_pawn, COMSIG_LIVING_STATUS_REMOVED, PROC_REF(on_status_removed)) + RegisterSignal(new_pawn, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), PROC_REF(on_target_set)) + +/datum/ai_controller/basic_controller/human_npc/stealth/UnpossessPawn(destroy) + if(pawn) + UnregisterSignal(pawn, list( + COMSIG_LIVING_GET_PULLED, + COMSIG_ATOM_BUMPED, + COMSIG_CARBON_HELP_ACT, + COMSIG_PROJECTILE_PREHIT, + COMSIG_MOVABLE_Z_CHANGED, + COMSIG_LIVING_STATUS_REMOVED, + COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), + )) + group?.remove_member(pawn) + group = null + for(var/datum/stealth_sight_overlay/overlay as anything in watchers?.Copy()) + qdel(overlay) + watchers = null + goto_turf = null + investigate_turf = null + search_center = null + search_queue = null + return ..() + +// ===== State ===== + +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_state() + return blackboard[BB_STEALTH_STATE] + +/// Changes state, stamping the time, and drops whatever walk it was on. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_set_state(new_state) + var/old_state = blackboard[BB_STEALTH_STATE] + if(old_state == STEALTH_UNAWARE && new_state != STEALTH_UNAWARE) + blackboard[BB_STEALTH_UNAWARE_LOST_AT] = world.time + blackboard[BB_STEALTH_STATE] = new_state + blackboard[BB_STEALTH_STATE_SINCE] = world.time + look_until = 0 + lost_since = 0 + stealth_drop_walk() + var/run = (new_state == STEALTH_ALERT || new_state == STEALTH_SEARCHING) + stealth_set_pace(run) + human_npc_set_ai_movement(src, new_state == STEALTH_ALERT ? /datum/ai_movement/basic_avoidance : /datum/ai_movement/jps/stealth) + for(var/datum/stealth_sight_overlay/overlay as anything in watchers) + overlay.refresh() + +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_set_wary() + blackboard[BB_STEALTH_WARY] = TRUE + +/// Walks (calm) or runs, through tg's own move intent, as bounty_ai_set_pace() does. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_set_pace(run) + var/mob/living/body = pawn + if(!isliving(body) || QDELETED(body)) + return + if(run != (body.move_intent == MOVE_INTENT_RUN)) + body.toggle_move_intent() + +/// Ends its own stealth walk, if it has one, without counting it as a failed path. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_drop_walk() + var/datum/ai_behavior/walk = GET_AI_BEHAVIOR(/datum/ai_behavior/stealth_goto) + if(walk && current_behaviors[walk]) + dropping_walk = TRUE + fail_behavior(walk) + dropping_walk = FALSE + goto_turf = null + goto_result = null + +/// `where` pulled back to within STEALTH_LEASH_RANGE of home (abuse R3). Null if nowhere sensible. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_leash(turf/where) + var/turf/home = blackboard[BB_STEALTH_HOME_TURF] + if(isnull(where) || isnull(home) || where.z != home.z) + return where + if(get_dist(home, where) <= STEALTH_LEASH_RANGE) + return where + var/turf/clamped = get_ranged_target_turf_direct(home, where, STEALTH_LEASH_RANGE) + if(isnull(clamped) || clamped.is_blocked_turf(exclude_mobs = TRUE)) + return null + return clamped + +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_unaware() + var/list/notice = blackboard[BB_STEALTH_NOTICE] + notice?.Cut() + clear_blackboard_key(BB_STEALTH_FOCUS) + search_queue?.Cut() + stealth_set_state(STEALTH_UNAWARE) + stealth_rejoin_route() + +/// Suspicious of something at `where`. `purpose` is STEALTH_GOTO_INVESTIGATE or STEALTH_GOTO_BODY. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_suspicious(turf/where, purpose = STEALTH_GOTO_INVESTIGATE) + var/mob/living/body = pawn + stealth_set_state(STEALTH_SUSPICIOUS) + goto_purpose = purpose + investigate_turf = stealth_leash(where) + investigated = FALSE + last_gain = world.time + set_blackboard_key(BB_STEALTH_LAST_SEEN_TURF, where) + blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time + if(where && get_turf(body) != where) + stand_dir = get_dir(body, where) + body.setDir(stand_dir) + stealth_bark(purpose == STEALTH_GOTO_BODY ? STEALTH_BARK_BODY : STEALTH_BARK_SUSPICIOUS) + +/// Searching round `where`. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_searching(turf/where) + stealth_set_wary() + if(isnull(where)) + where = get_turf(pawn) + stealth_set_state(STEALTH_SEARCHING) + search_center = stealth_leash(where) || get_turf(pawn) + search_queue = stealth_search_spots(search_center) + search_looking = FALSE + next_search_bark = world.time + STEALTH_SEARCH_BARK_INTERVAL + set_blackboard_key(BB_STEALTH_LAST_SEEN_TURF, where) + blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time + +/// Alert on `target` (a mob, or the mech or vehicle someone sits in). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_alert(atom/target) + if(QDELETED(target)) + return + if(stealth_state() != STEALTH_ALERT) + stealth_set_state(STEALTH_ALERT) + stealth_set_wary() + set_blackboard_key(BB_STEALTH_FOCUS, target) + set_blackboard_key(BB_STEALTH_LAST_SEEN_TURF, get_turf(target)) + blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time + set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target) + +/// The spots a search checks: the centre first, then a few open tiles round it in its line of sight. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_search_spots(turf/center) + . = list() + if(isnull(center)) + return + . += center + var/list/pool = list() + var/datum/region = map_region_for_turf(center) + for(var/turf/open/spot in RANGE_TURFS(STEALTH_SEARCH_RADIUS, center)) + if(spot == center || spot.is_blocked_turf(exclude_mobs = TRUE)) + continue + if(map_region_excludes_turf(region, spot) || !stealth_leash(spot)) + continue + pool += spot + var/picked = 0 + while(length(pool) && picked < STEALTH_SEARCH_POINTS) + var/turf/spot = pick_n_take(pool) + if(!can_see(center, spot, STEALTH_SEARCH_RADIUS)) + continue + . += spot + picked++ + +/// Says a stealth line, at most once per STEALTH_BARK_KIND_COOLDOWN for each kind (abuse P14). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_bark(kind) + var/mob/living/carbon/human/npc/body = pawn + if(!ishumannpc(body) || body.stat != CONSCIOUS) + return FALSE + if(world.time < bark_ready_at[kind]) + return FALSE + bark_ready_at[kind] = world.time + STEALTH_BARK_KIND_COOLDOWN + return body.npc_bark(kind, force = TRUE) + +/// Moves every awareness timestamp back by `time`, as if that long had passed (unit tests, admin tools). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_rewind(time) + blackboard[BB_STEALTH_STATE_SINCE] -= time + if(blackboard[BB_STEALTH_LAST_SEEN_AT]) + blackboard[BB_STEALTH_LAST_SEEN_AT] -= time + if(blackboard[BB_STEALTH_UNAWARE_LOST_AT]) + blackboard[BB_STEALTH_UNAWARE_LOST_AT] -= time + last_gain -= time + if(lost_since) + lost_since -= time + wait_until -= time + look_until -= time + next_body_scan -= time + for(var/kind in bark_ready_at) + bark_ready_at[kind] -= time + +// ===== The scan ===== + +/** + * One scan: expires a suspicion or search whose time has run out (also how a guard catches up after + * sleeping), keeps an alert guard's target, or looks for someone new and for fallen friends. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_scan_once() + var/mob/living/carbon/human/guard = pawn + if(QDELETED(guard) || guard.stat != CONSCIOUS) + return + stealth_setup() + var/state = stealth_state() + if(state == STEALTH_SEARCHING && world.time - blackboard[BB_STEALTH_STATE_SINCE] >= STEALTH_SEARCH_TIME) + stealth_bark(STEALTH_BARK_GIVE_UP) + stealth_go_unaware() + else if(state == STEALTH_SUSPICIOUS && world.time - last_gain >= STEALTH_SUSPICIOUS_TIMEOUT) + stealth_bark(STEALTH_BARK_GIVE_UP) + stealth_go_unaware() + if(stealth_state() == STEALTH_ALERT) + stealth_keep_target() + else + stealth_look() + if(stealth_state() != STEALTH_ALERT && world.time >= next_body_scan) + stealth_check_bodies() + for(var/datum/stealth_sight_overlay/overlay as anything in watchers) + overlay.refresh() + +/// The cone half-angle it sees with now. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_half_angle() + if(stealth_state() == STEALTH_SEARCHING) + return STEALTH_SEARCH_HALF_ANGLE + if(blackboard[BB_STEALTH_WARY]) + return STEALTH_WARY_HALF_ANGLE + return STEALTH_CONE_HALF_ANGLE + +/// Whether its eyes work: not blind, not lying down (the close radius still works either way). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_eyes_open() + var/mob/living/guard = pawn + return !guard.is_blind() && guard.body_position != LYING_DOWN + +/// How far it sees onto `tile` in its cone: shorter in the dark unless it sees in the dark. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_range_onto(turf/tile) + if(!HAS_TRAIT(pawn, TRAIT_NIGHT_VISION) && stealth_tile_is_dark(tile)) + return STEALTH_DARK_SIGHT_RANGE + return STEALTH_SIGHT_RANGE + +/// Looks for someone new: raises notice on whoever it perceives, decays the rest, changes state. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_look() + var/mob/living/carbon/human/guard = pawn + var/list/notice = blackboard[BB_STEALTH_NOTICE] + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) + if(isnull(strategy) || !isturf(guard.loc)) + return + var/state = stealth_state() + var/wary = blackboard[BB_STEALTH_WARY] + var/atom/focus = blackboard[BB_STEALTH_FOCUS] + var/eyes = stealth_eyes_open() + var/half_angle = stealth_half_angle() + var/scan_seconds = STEALTH_SCAN_INTERVAL / (1 SECONDS) + var/list/perceived = list() + for(var/mob/living/candidate as anything in stealth_candidates(guard, HUMAN_NPC_SIGHT_RANGE)) + // A mech or vehicle is never hidden: seen all round, no darkness, straight to alert (abuse P9). + var/atom/vehicle = stealth_vehicle_of(candidate) + if(vehicle) + if(strategy.can_attack(guard, vehicle, HUMAN_NPC_SIGHT_RANGE)) + stealth_go_alert(vehicle) + return + continue + // In a locker, a crate or anything else: unseen. + if(!isturf(candidate.loc)) + continue + var/turf/where = candidate.loc + var/dist = get_dist(guard, where) + if(dist > STEALTH_SIGHT_RANGE) + continue + if(!strategy.can_attack(guard, candidate, STEALTH_SIGHT_RANGE)) + continue + var/rate = 0 + if(eyes && !stealth_is_cloaked(candidate) && stealth_in_cone(guard, where, half_angle)) + var/cap = stealth_range_onto(where) + if(dist <= cap) + rate = STEALTH_NOTICE_RATE * clamp((cap + 1 - dist) / cap, STEALTH_NOTICE_MIN_FALLOFF, 1) + if(cap < STEALTH_SIGHT_RANGE) + rate *= STEALTH_DARK_MULT + var/felt = FALSE + if(!rate && dist <= STEALTH_CLOSE_RANGE) + // At its shoulder: light doesn't matter (abuse P7). + rate = STEALTH_BEHIND_RATE + felt = TRUE + if(!rate) + continue + var/key = REF(candidate) + var/list/entry = notice[key] + if(isnull(entry)) + entry = list(0, null, 0) + notice[key] = entry + // First seen counts as moving: it just came into view. + var/moved = entry[STEALTH_ENTRY_TURF] != where + // STEP 2 STAND-IN: move intent stands in for "running is loud" until real footstep hearing lands. + var/running = moved && candidate.move_intent == MOVE_INTENT_RUN + if(running) + rate *= STEALTH_RUN_MULT + else if(!moved) + rate *= STEALTH_STILL_MULT + if(candidate.body_position == LYING_DOWN) + rate *= STEALTH_LYING_MULT + if(state == STEALTH_SUSPICIOUS && focus == candidate) + rate *= STEALTH_SUSPICIOUS_MULT + if(state == STEALTH_SEARCHING) + rate *= STEALTH_SEARCHING_MULT + if(wary) + rate *= STEALTH_WARY_MULT + entry[STEALTH_ENTRY_NOTICE] += rate * scan_seconds + if(felt && running) + entry[STEALTH_ENTRY_NOTICE] = max(entry[STEALTH_ENTRY_NOTICE], STEALTH_SUSPICIOUS_AT) + guard.setDir(get_dir(guard, where)) + entry[STEALTH_ENTRY_TURF] = where + entry[STEALTH_ENTRY_SEEN_AT] = world.time + perceived[key] = candidate + // Decay whoever it didn't perceive; forget anyone unseen for long enough. + var/best_key + var/best_notice = 0 + for(var/key in notice) + var/list/entry = notice[key] + if(!perceived[key]) + entry[STEALTH_ENTRY_NOTICE] = max(0, entry[STEALTH_ENTRY_NOTICE] - STEALTH_NOTICE_DECAY * scan_seconds) + if(world.time - entry[STEALTH_ENTRY_SEEN_AT] > STEALTH_NOTICE_FORGET) + notice -= key + continue + if(entry[STEALTH_ENTRY_NOTICE] > best_notice) + best_notice = entry[STEALTH_ENTRY_NOTICE] + best_key = key + if(isnull(best_key)) + clear_blackboard_key(BB_STEALTH_FOCUS) + return + var/mob/living/best = perceived[best_key] || locate(best_key) + if(!isliving(best) || QDELETED(best)) + notice -= best_key + return + set_blackboard_key(BB_STEALTH_FOCUS, best) + var/list/best_entry = notice[best_key] + var/turf/best_turf = best_entry[STEALTH_ENTRY_TURF] + var/seen_now = !!perceived[best_key] + switch(state) + if(STEALTH_UNAWARE) + if(best_notice >= STEALTH_SUSPICIOUS_AT) + stealth_go_suspicious(best_turf) + if(STEALTH_SUSPICIOUS, STEALTH_SEARCHING) + if(!seen_now) + return + // No alert by sight from off-screen (abuse P8). + if(best_notice >= STEALTH_ALERT_AT && get_dist(guard, best_turf) <= STEALTH_ALERT_MAX_RANGE) + stealth_go_alert(best) + return + if(state == STEALTH_SEARCHING) + if(best_notice >= STEALTH_SUSPICIOUS_AT) + stealth_go_suspicious(best_turf) + return + last_gain = world.time + set_blackboard_key(BB_STEALTH_LAST_SEEN_TURF, best_turf) + blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time + +/** + * Alert: keeps its target with tg's all-round can_attack() at the NPC's sight range (abuse P2). Out of + * sight for STEALTH_LOSE_TARGET_TIME, it searches where it last saw them, not where they are now. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_keep_target() + var/mob/living/guard = pawn + var/atom/target = blackboard[BB_BASIC_MOB_CURRENT_TARGET] + var/atom/remembered = target || blackboard[BB_STEALTH_FOCUS] + if(QDELETED(remembered)) + stealth_go_searching(blackboard[BB_STEALTH_LAST_SEEN_TURF]) + return + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) + if(strategy?.can_attack(guard, remembered, HUMAN_NPC_SIGHT_RANGE)) + lost_since = 0 + set_blackboard_key(BB_STEALTH_LAST_SEEN_TURF, get_turf(remembered)) + blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time + if(!target) + set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, remembered) + return + // In plain sight but no longer worth fighting (down, dead, an ally now): look round where they fell. + if(get_dist(guard, remembered) <= HUMAN_NPC_SIGHT_RANGE && can_see(guard, remembered, HUMAN_NPC_SIGHT_RANGE)) + clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + clear_blackboard_key(BB_STEALTH_FOCUS) + stealth_go_searching(get_turf(remembered)) + return + if(!lost_since) + lost_since = world.time + if(world.time - lost_since < STEALTH_LOSE_TARGET_TIME) + return + clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + stealth_bark(STEALTH_BARK_LOST) + stealth_go_searching(blackboard[BB_STEALTH_LAST_SEEN_TURF]) + +/** + * Fallen friends (abuse P13): group members that are dead, in crit, cuffed or lying down, in its sight. + * It reacts to each body once. The first guard to find one is suspicious and walks over; a guard that + * finds a body someone already reported just searches there. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_check_bodies() + next_body_scan = world.time + STEALTH_BODY_SCAN_INTERVAL + if(isnull(group)) + return + var/mob/living/guard = pawn + var/list/seen = blackboard[BB_STEALTH_BODIES_SEEN] + var/eyes = stealth_eyes_open() + var/half_angle = stealth_half_angle() + for(var/mob/living/member as anything in group.living_members()) + if(member == guard) + continue + if(!(member.stat == DEAD || HAS_TRAIT(member, TRAIT_CRITICAL_CONDITION) || HAS_TRAIT(member, TRAIT_RESTRAINED) || member.body_position == LYING_DOWN)) + continue + var/key = REF(member) + if(key in seen) + continue + var/turf/where = get_turf(member) + if(isnull(where) || where.z != guard.z) + continue + var/dist = get_dist(guard, where) + if(dist > STEALTH_SIGHT_RANGE) + continue + var/in_sight = dist <= STEALTH_CLOSE_RANGE + if(!in_sight && eyes && stealth_in_cone(guard, where, half_angle)) + in_sight = dist <= stealth_range_onto(where) + if(!in_sight || !can_see(guard, where, STEALTH_SIGHT_RANGE)) + continue + seen += key + if((key in group.reported_bodies) || stealth_state() == STEALTH_SEARCHING) + stealth_go_searching(where) + else + group.reported_bodies += key + stealth_go_suspicious(where, STEALTH_GOTO_BODY) + return + +// ===== Touches, hits and recovery ===== + +/** + * Anyone touching or hurting it (abuse P3): alert on them if it can see them, otherwise a search at + * their turf. Always wary. Allies are ignored. Returns TRUE if it took a target. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_touched(atom/source) + var/mob/living/guard = pawn + if(QDELETED(source) || QDELETED(guard) || source == guard || guard.stat == DEAD) + return FALSE + if(ismovable(source) && guard.faction_check_atom(source)) + return FALSE + stealth_set_wary() + if(stealth_state() == STEALTH_ALERT && blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return FALSE + var/atom/who = isliving(source) ? bounty_ai_target_for(source) : source + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) + if(strategy?.can_attack(guard, who)) + stealth_go_alert(who) + return TRUE + stealth_go_searching(get_turf(source)) + stealth_bark(STEALTH_BARK_HIT_UNSEEN) + return FALSE + +/datum/ai_controller/basic_controller/human_npc/stealth/on_pawn_attacked(mob/living/source, atom/attacker, attack_flags) + if(stealth_touched(attacker)) + return + if(!blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return ..() + +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_pulled(mob/living/source, atom/movable/puller) + SIGNAL_HANDLER + stealth_touched(puller) + +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_bumped(atom/source, atom/movable/bumper) + SIGNAL_HANDLER + if(isliving(bumper)) + stealth_touched(bumper) + +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_helped(mob/living/source, mob/living/helper) + SIGNAL_HANDLER + stealth_touched(helper) + +/// relay_attackers only reports a mob firer; a turret or other machine shooting it counts the same. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_projectile_prehit(atom/source, obj/projectile/hit_projectile) + SIGNAL_HANDLER + if(!hit_projectile.is_hostile_projectile() || isnull(hit_projectile.firer) || ismob(hit_projectile.firer)) + return + stealth_touched(hit_projectile.firer) + +/// Anything else that sets a target (an admin, another subtree) makes it alert. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_target_set(datum/source) + SIGNAL_HANDLER + var/atom/target = blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(isnull(target) || stealth_state() == STEALTH_ALERT) + return + stealth_set_state(STEALTH_ALERT) + stealth_set_wary() + set_blackboard_key(BB_STEALTH_FOCUS, target) + +/// Carried off the level: drops its route and holds where it is (abuse R3). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_z_changed(datum/source) + SIGNAL_HANDLER + stealth_become_sentry(get_turf(pawn)) + +/datum/ai_controller/basic_controller/human_npc/stealth/on_stat_changed(mob/living/source, new_stat) + . = ..() + var/was = last_stat + last_stat = new_stat + if(new_stat == CONSCIOUS && was != CONSCIOUS && was != DEAD) + stealth_came_round() + +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_status_removed(mob/living/source, datum/status_effect/effect) + SIGNAL_HANDLER + if(istype(effect, /datum/status_effect/incapacitating/stamcrit)) + stealth_came_round() + +/// Back on its feet after crit or stamcrit: it knows something happened. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_came_round() + var/mob/living/guard = pawn + if(QDELETED(guard) || guard.stat == DEAD) + return + stealth_set_wary() + if(blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + stealth_go_searching(get_turf(guard)) + +// A guard that gives up is wary from then on. +/mob/living/carbon/human/npc/npc_surrender() + . = ..() + if(.) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = ai_controller + if(istype(stealth_ai)) + stealth_ai.stealth_set_wary() + +/** + * Step 3 hook. TRUE if this guard was unaware when the current blow began: unaware now, or it stopped + * being unaware during this same tick (relay_attackers flips it before damage for hands and bullets, + * abuse P12). FALSE for any NPC without the stealth controller. + */ +/mob/living/carbon/human/npc/proc/stealth_was_unaware() + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = ai_controller + if(!istype(stealth_ai)) + return FALSE + if(stealth_ai.stealth_state() == STEALTH_UNAWARE) + return TRUE + return stealth_ai.blackboard[BB_STEALTH_UNAWARE_LOST_AT] == world.time + +// ===== Planning ===== + +/// Pathfinding movement with room for a long patrol leg. +/datum/ai_movement/jps/stealth + maximum_length = STEALTH_PATROL_MAX_PATH + +/// Queues the scan every plan. Never ends planning. +/datum/ai_planning_subtree/stealth_perception + +/datum/ai_planning_subtree/stealth_perception/SelectBehaviors(datum/ai_controller/basic_controller/human_npc/stealth/controller, seconds_per_tick) + var/mob/living/guard = controller.pawn + if(guard.stat != CONSCIOUS || !istype(controller)) + return + controller.stealth_setup() + controller.queue_behavior(/datum/ai_behavior/stealth_scan) + +/datum/ai_behavior/stealth_scan + action_cooldown = STEALTH_SCAN_INTERVAL + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/stealth_scan/perform(seconds_per_tick, datum/ai_controller/basic_controller/human_npc/stealth/controller) + if(istype(controller)) + controller.stealth_scan_once() + return AI_BEHAVIOR_DELAY + +/// Suspicious: stare, walk over, look round. Searching: sweep the spots. Ends planning while it acts. +/datum/ai_planning_subtree/stealth_react + +/datum/ai_planning_subtree/stealth_react/SelectBehaviors(datum/ai_controller/basic_controller/human_npc/stealth/controller, seconds_per_tick) + if(!istype(controller) || controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/mob/living/guard = controller.pawn + switch(controller.stealth_state()) + if(STEALTH_SUSPICIOUS) + if(world.time < controller.blackboard[BB_STEALTH_STATE_SINCE] + STEALTH_INVESTIGATE_DELAY) + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING + if(!controller.investigated) + var/turf/dest = controller.investigate_turf + if(dest && get_turf(guard) != dest && controller.goto_result != "failed") + controller.stealth_walk(dest, controller.goto_purpose) + return SUBTREE_RETURN_FINISH_PLANNING + controller.investigated = TRUE + controller.stealth_drop_walk() + if(controller.goto_purpose == STEALTH_GOTO_BODY) + controller.stealth_go_searching(get_turf(guard)) + return SUBTREE_RETURN_FINISH_PLANNING + controller.look_until = world.time + STEALTH_LOOK_AROUND_TIME + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING + if(STEALTH_SEARCHING) + if(world.time >= controller.next_search_bark) + controller.next_search_bark = world.time + STEALTH_SEARCH_BARK_INTERVAL + controller.stealth_bark(STEALTH_BARK_SEARCH) + if(controller.search_looking) + if(world.time < controller.look_until) + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING + controller.search_looking = FALSE + if(length(controller.search_queue)) + controller.search_queue.Cut(1, 2) + if(!length(controller.search_queue)) + controller.search_queue = controller.stealth_search_spots(controller.search_center) + if(!length(controller.search_queue)) + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING + var/turf/spot = controller.search_queue[1] + if(get_turf(guard) == spot || (controller.goto_turf == spot && controller.goto_result)) + controller.stealth_drop_walk() + controller.search_looking = TRUE + controller.look_until = world.time + STEALTH_LOOK_AROUND_TIME + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING + controller.stealth_walk(spot, STEALTH_GOTO_SEARCH) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Starts (or keeps) its stealth walk to `dest`. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_walk(turf/dest, purpose) + var/datum/ai_behavior/walk = GET_AI_BEHAVIOR(/datum/ai_behavior/stealth_goto) + if(goto_turf != dest && current_behaviors[walk]) + stealth_drop_walk() + if(goto_turf != dest) + goto_result = null + goto_turf = dest + goto_purpose = purpose + queue_behavior(/datum/ai_behavior/stealth_goto) + +/** + * Walks to the controller's goto_turf with its pathfinding movement. Ends on arrival, or as a failure + * when the pathfinder gives up or the leg takes far too long (abuse R2). + */ +/datum/ai_behavior/stealth_goto + required_distance = 0 + action_cooldown = 0.5 SECONDS + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/stealth_goto/setup(datum/ai_controller/basic_controller/human_npc/stealth/controller) + . = ..() + if(!istype(controller) || isnull(controller.goto_turf)) + return FALSE + controller.goto_started = world.time + set_movement_target(controller, controller.goto_turf) + +/datum/ai_behavior/stealth_goto/perform(seconds_per_tick, datum/ai_controller/basic_controller/human_npc/stealth/controller) + var/turf/dest = controller.goto_turf + if(isnull(dest)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/turf/here = get_turf(controller.pawn) + if(here == dest) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + // Someone stands on the spot: next to it will do. + if(get_dist(here, dest) <= 1 && dest.is_blocked_turf(exclude_mobs = FALSE) && !dest.is_blocked_turf(exclude_mobs = TRUE)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(world.time - controller.goto_started > STEALTH_PATROL_MAX_PATH * max(controller.movement_delay, 0.4 SECONDS) * 1.5) + controller.goto_timed_out = TRUE + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_INSTANT + +/datum/ai_behavior/stealth_goto/finish_action(datum/ai_controller/basic_controller/human_npc/stealth/controller, succeeded) + if(istype(controller) && !controller.dropping_walk) + if(succeeded) + controller.goto_result = "arrived" + else + // Only a real path failure counts; being re-planned away from a walk does not. + var/path_failed = controller.goto_timed_out || controller.consecutive_pathing_attempts >= controller.ai_movement.max_pathing_attempts + if(path_failed) + controller.goto_result = "failed" + controller.stealth_walk_failed() + controller.goto_timed_out = FALSE + return ..() + +/** + * Standing: at a stop, a sentry's post, staring at something, or looking round. Turns its head now and + * then. Never finishes by itself; planning replaces it. + */ +/datum/ai_behavior/stealth_stand + action_cooldown = 1 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/stealth_stand/perform(seconds_per_tick, datum/ai_controller/basic_controller/human_npc/stealth/controller) + var/mob/living/guard = controller.pawn + if(!istype(controller) || guard.stat != CONSCIOUS || !isturf(guard.loc)) + return AI_BEHAVIOR_DELAY + if(world.time < controller.look_until) + // Looking round: two other ways over STEALTH_LOOK_AROUND_TIME. + if(world.time >= controller.next_turn) + controller.next_turn = world.time + STEALTH_LOOK_AROUND_TIME / 2 + guard.setDir(turn(controller.stand_dir, pick(90, -90, 180))) + return AI_BEHAVIOR_DELAY + if(world.time < controller.next_turn) + return AI_BEHAVIOR_DELAY + var/state = controller.stealth_state() + if(state == STEALTH_SUSPICIOUS && !controller.investigated) + guard.setDir(controller.stand_dir) + return AI_BEHAVIOR_DELAY + var/sentry = isnull(controller.route_id) + controller.next_turn = world.time + (sentry ? STEALTH_SENTRY_TURN_INTERVAL : STEALTH_LOOK_TURN_INTERVAL) + if(guard.dir != controller.stand_dir) + guard.setDir(controller.stand_dir) + else if(sentry || prob(STEALTH_LOOK_TURN_CHANCE)) + guard.setDir(turn(controller.stand_dir, pick(STEALTH_LOOK_TURN_ARC, -STEALTH_LOOK_TURN_ARC))) + return AI_BEHAVIOR_DELAY diff --git a/voidcrew/modules/stealth/stealth_patrol.dm b/voidcrew/modules/stealth/stealth_patrol.dm new file mode 100644 index 00000000000..ed476f71dd4 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_patrol.dm @@ -0,0 +1,373 @@ +/** + * # Stealth patrols + * + * A template maps one group marker (/obj/effect/landmark/stealth_group) and any number of patrol points + * (/obj/effect/landmark/stealth_patrol). Points and guards belong to the nearest marker in their own map + * region within STEALTH_GROUP_RADIUS, so two dens on one packed planet never share routes (abuse R1). + * Nothing is matched by z-level alone, and no group processes on a timer. + * + * A guard claims the route with a stop within STEALTH_ROUTE_CLAIM_RANGE of where it spawned, starting at + * that stop. With none in range it is a sentry at its spawn turf, facing the way the spawner faced. + */ + +/// Every live patrol group +GLOBAL_LIST_EMPTY(stealth_groups) +/// Patrol points not yet handed to a group +GLOBAL_LIST_EMPTY(stealth_unassigned_points) + +/datum/stealth_stop + var/turf/spot + var/wait = STEALTH_PATROL_DEFAULT_WAIT + var/dir = SOUTH + var/chatter = FALSE + var/order = 0 + +/datum/stealth_group + /// A unique id, for admins + var/id + /// Weakref to its map region (a footprint or reservation), or null on an unpacked level + var/datum/weakref/region_ref + /// Whether its region was null when made (so a null region is a match, not a lost one) + var/no_region = FALSE + /// Where its marker stood + var/turf/home + /// Its marker, while it exists + var/obj/effect/landmark/stealth_group/marker + /// Route id => list of /datum/stealth_stop in order + var/list/routes = list() + /// Route id => "loop" or "pingpong" + var/list/route_modes = list() + /// Weakrefs to its guards + var/list/datum/weakref/members = list() + /// REFs of fallen members someone has already reported + var/list/reported_bodies = list() + /// world.time chatter is next allowed + var/next_chatter = 0 + +GLOBAL_VAR_INIT(stealth_group_count, 0) + +/datum/stealth_group/New(turf/where) + id = "group-[++GLOB.stealth_group_count]" + home = where + var/datum/region = map_region_for_turf(where) + if(region) + region_ref = WEAKREF(region) + else + no_region = TRUE + GLOB.stealth_groups += src + +/datum/stealth_group/Destroy() + GLOB.stealth_groups -= src + if(marker) + marker.group = null + marker = null + members = null + routes = null + home = null + return ..() + +/// Whether `tile` is in this group's map region. +/datum/stealth_group/proc/region_matches(turf/tile) + var/datum/region = map_region_for_turf(tile) + if(no_region) + return isnull(region) + return !isnull(region) && region == region_ref?.resolve() + +/datum/stealth_group/proc/add_member(mob/living/guard) + members |= WEAKREF(guard) + +/datum/stealth_group/proc/remove_member(mob/living/guard) + if(isnull(members)) + return + members -= WEAKREF(guard) + prune() + +/// Its guards that still exist. +/datum/stealth_group/proc/living_members() + . = list() + for(var/datum/weakref/ref as anything in members?.Copy()) + var/mob/living/guard = ref?.resolve() + if(QDELETED(guard)) + members -= ref + continue + . += guard + +/// Removes the group once its marker and every member are gone. +/datum/stealth_group/proc/prune() + if(QDELETED(src)) + return + if(marker || length(living_members())) + return + qdel(src) + +/datum/stealth_group/proc/add_point(obj/effect/landmark/stealth_patrol/point) + var/datum/stealth_stop/stop = new + stop.spot = get_turf(point) + stop.wait = point.wait < 0 ? STEALTH_PATROL_DEFAULT_WAIT : point.wait + stop.dir = point.dir + stop.chatter = point.chatter + stop.order = point.order + var/list/route = routes[point.route] + if(isnull(route)) + routes[point.route] = route = list() + var/index = 1 + while(index <= length(route)) + var/datum/stealth_stop/other = route[index] + if(other.order > stop.order) + break + index++ + route.Insert(index, stop) + if(point.order == 1 || isnull(route_modes[point.route])) + route_modes[point.route] = point.route_mode + +/// The nearest group to `tile` in its own region within STEALTH_GROUP_RADIUS, or null. +/proc/stealth_group_for(turf/tile) + if(isnull(tile)) + return null + stealth_assign_points() + var/datum/stealth_group/best + var/best_dist = INFINITY + for(var/datum/stealth_group/group as anything in GLOB.stealth_groups) + if(group.home?.z != tile.z) + continue + var/dist = get_dist(group.home, tile) + if(dist > STEALTH_GROUP_RADIUS || dist >= best_dist || !group.region_matches(tile)) + continue + best = group + best_dist = dist + return best + +/// Hands every loaded patrol point to its group. Points with no group yet wait for one. +/proc/stealth_assign_points() + for(var/obj/effect/landmark/stealth_patrol/point as anything in GLOB.stealth_unassigned_points) + if(QDELETED(point)) + GLOB.stealth_unassigned_points -= point + continue + var/turf/spot = get_turf(point) + var/datum/stealth_group/best + var/best_dist = INFINITY + for(var/datum/stealth_group/group as anything in GLOB.stealth_groups) + if(group.home?.z != spot?.z || !group.marker) + continue + var/dist = get_dist(group.home, spot) + if(dist > STEALTH_GROUP_RADIUS || dist >= best_dist || !group.region_matches(spot)) + continue + best = group + best_dist = dist + if(best) + best.add_point(point) + GLOB.stealth_unassigned_points -= point + +// ===== Landmarks ===== + +/// One per template: the patrol group its points and guards join. +/obj/effect/landmark/stealth_group + name = "stealth patrol group" + icon_state = "x" + var/datum/stealth_group/group + +/obj/effect/landmark/stealth_group/Initialize(mapload) + . = ..() + group = new(get_turf(src)) + group.marker = src + +/obj/effect/landmark/stealth_group/Destroy() + var/datum/stealth_group/old = group + group = null + if(old) + old.marker = null + old.prune() + return ..() + +/// A stop on a guard route. Its dir is the way the guard faces there. +/obj/effect/landmark/stealth_patrol + name = "stealth patrol point" + icon_state = "x3" + /// The route it belongs to ("a", "b", ...) + var/route = "a" + /// Its place on the route, from 1 + var/order = 1 + /// Deciseconds to stand here; -1 for STEALTH_PATROL_DEFAULT_WAIT, 0 to walk straight through + var/wait = -1 + /// TRUE: guards here talk to each other + var/chatter = FALSE + /// "loop" or "pingpong", read from the order 1 point + var/route_mode = "loop" + +/obj/effect/landmark/stealth_patrol/Initialize(mapload) + . = ..() + GLOB.stealth_unassigned_points += src + +/obj/effect/landmark/stealth_patrol/Destroy() + GLOB.stealth_unassigned_points -= src + return ..() + +// ===== Guards on routes ===== + +/// Joins a group and claims a route, once, on its first plan (after its map has loaded). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_setup() + if(stealth_ready) + return + stealth_ready = TRUE + var/mob/living/guard = pawn + var/turf/spawn_turf = get_turf(guard) + set_blackboard_key(BB_STEALTH_HOME_TURF, spawn_turf) + sentry_dir = guard.dir + stand_dir = guard.dir + group = stealth_group_for(spawn_turf) + if(isnull(group)) + group = new(spawn_turf) + group.add_member(guard) + // The route with a stop nearest its spawn, within reach. + var/best_dist = INFINITY + for(var/route in group.routes) + var/list/stops = group.routes[route] + for(var/index in 1 to length(stops)) + var/datum/stealth_stop/stop = stops[index] + var/dist = get_dist(stop.spot, spawn_turf) + if(dist > STEALTH_ROUTE_CLAIM_RANGE || dist >= best_dist) + continue + best_dist = dist + route_id = route + route_index = index + if(route_id) + var/datum/stealth_stop/start = group.routes[route_id][route_index] + set_blackboard_key(BB_STEALTH_HOME_TURF, start.spot) + +/// Drops its route and holds `where` as a sentry from now on (abuse R2, R3). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_become_sentry(turf/where) + route_id = null + at_stop = FALSE + if(where) + set_blackboard_key(BB_STEALTH_HOME_TURF, where) + sentry_dir = pawn.dir + stand_dir = pawn.dir + +/// Its route's stops, or null. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_stops() + if(isnull(route_id) || isnull(group)) + return null + return group.routes[route_id] + +/// Back from a suspicion or a search: heads for the nearest stop of its route. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_rejoin_route() + at_stop = FALSE + wait_until = 0 + var/list/stops = stealth_stops() + if(!length(stops)) + return + var/turf/here = get_turf(pawn) + var/best_dist = INFINITY + for(var/index in 1 to length(stops)) + var/datum/stealth_stop/stop = stops[index] + var/dist = get_dist(stop.spot, here) + if(dist < best_dist) + best_dist = dist + route_index = index + +/// The next stop after the current one that isn't being skipped, or 0 if every stop is skipped. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_next_index() + var/list/stops = stealth_stops() + var/count = length(stops) + if(!count) + return 0 + var/pingpong = group.route_modes[route_id] == "pingpong" + var/index = route_index + for(var/tries in 1 to count * 2) + index += route_step + if(index > count || index < 1) + if(pingpong && count > 1) + route_step = -route_step + index += route_step * 2 + else + index = index > count ? 1 : count + if(world.time >= stop_skip_until["[index]"]) + return index + return 0 + +/// A walk the pathfinder gave up on (abuse R2): a stop that fails often enough is skipped for a while. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_walk_failed() + if(goto_purpose == STEALTH_GOTO_HOME) + // It can't get back to its post: this is its post now. + stealth_become_sentry(get_turf(pawn)) + return + if(goto_purpose != STEALTH_GOTO_PATROL || isnull(route_id)) + return + var/key = "[route_index]" + stop_fails[key]++ + if(stop_fails[key] < STEALTH_PATROL_MAX_FAILS) + return + stop_fails[key] = 0 + stop_skip_until[key] = world.time + STEALTH_PATROL_SKIP_TIME + var/next = stealth_next_index() + if(!next) + stealth_become_sentry(get_turf(pawn)) + return + route_index = next + +/// Unaware with no target: walks its route or holds its post. +/datum/ai_planning_subtree/stealth_patrol + +/datum/ai_planning_subtree/stealth_patrol/SelectBehaviors(datum/ai_controller/basic_controller/human_npc/stealth/controller, seconds_per_tick) + if(!istype(controller) || controller.stealth_state() != STEALTH_UNAWARE || controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/mob/living/guard = controller.pawn + if(guard.stat != CONSCIOUS) + return + var/turf/here = get_turf(guard) + var/list/stops = controller.stealth_stops() + if(!length(stops)) + controller.route_id = null + var/turf/post = controller.blackboard[BB_STEALTH_HOME_TURF] + if(post && here != post && controller.goto_result != "failed") + controller.stealth_walk(post, STEALTH_GOTO_HOME) + return SUBTREE_RETURN_FINISH_PLANNING + controller.stand_dir = controller.sentry_dir + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING + if(controller.at_stop) + if(world.time < controller.wait_until) + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING + controller.at_stop = FALSE + var/next = controller.stealth_next_index() + if(!next) + controller.stealth_become_sentry(here) + return + controller.route_index = next + var/datum/stealth_stop/stop = stops[controller.route_index] + // Leash (abuse R3): a stop outside its region or too far from it means it was carried off. + if(!controller.group.region_matches(stop.spot) || stop.spot.z != here.z || get_dist(stop.spot, here) > STEALTH_LEASH_RANGE) + controller.stealth_become_sentry(here) + return + if(here == stop.spot || (controller.goto_turf == stop.spot && controller.goto_result == "arrived")) + controller.stealth_drop_walk() + controller.stealth_arrive(stop) + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING + if(controller.goto_turf == stop.spot && controller.goto_result == "failed") + controller.goto_result = null + controller.stealth_walk(stop.spot, STEALTH_GOTO_PATROL) + return SUBTREE_RETURN_FINISH_PLANNING + +/// At a stop: faces its way, waits, and talks if it's a chatter stop and a friend is near. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_arrive(datum/stealth_stop/stop) + at_stop = TRUE + stop_fails["[route_index]"] = 0 + wait_until = world.time + stop.wait + stand_dir = stop.dir + pawn.setDir(stop.dir) + next_turn = world.time + STEALTH_LOOK_TURN_INTERVAL + if(!stop.chatter || world.time < group.next_chatter) + return + var/mob/living/guard = pawn + for(var/mob/living/friend as anything in group.living_members()) + if(friend == guard || friend.stat != CONSCIOUS || get_dist(friend, guard) > STEALTH_CHATTER_RANGE) + continue + var/datum/ai_controller/basic_controller/human_npc/stealth/friend_ai = friend.ai_controller + if(!istype(friend_ai) || friend_ai.stealth_state() != STEALTH_UNAWARE) + continue + group.next_chatter = world.time + STEALTH_CHATTER_COOLDOWN + var/datum/ai_controller/basic_controller/human_npc/stealth/speaker = pick(src, friend_ai) + speaker.stealth_bark(STEALTH_BARK_CHATTER) + return diff --git a/voidcrew/modules/stealth/stealth_perception.dm b/voidcrew/modules/stealth/stealth_perception.dm new file mode 100644 index 00000000000..d3bc47df7a7 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_perception.dm @@ -0,0 +1,115 @@ +/** + * # Stealth perception + * + * Sight for guards that opted into stealth (stealth_ai.dm). It is AI math only: no fov_view on the body + * (a player who later takes it over must not inherit it), and the cone and darkness are never added to + * can_attack(), so an alert guard keeps tg's all-round check and an attack from behind still lands on a + * guard that can turn round (abuse review P1, P2). + * + * A scan, once a second while the AI is awake: the nearest few living mobs on the HEARING grid (every mob + * is hearing-sensitive, so SSD bodies and other NPCs count), each passed through the guard's own + * can_attack() first (faction, stat, line of sight, invisibility, the packed-level cordon, Ghostskin + * camo), then the cone, light and movement rules below raise or decay its "notice". + */ + +#ifdef UNIT_TESTS +/// Turfs the unit tests treat as dark, since the test level is fully lit +GLOBAL_LIST_EMPTY(stealth_test_dark_turfs) +#endif + +/// Whether `target` is within `half_angle` degrees of `viewer`'s facing. Integer math, no trig per call. +/// The same tile is in the cone; a target exactly side-on is not, even at 90 degrees. +/proc/stealth_in_cone(atom/viewer, atom/target, half_angle) + var/turf/from = get_turf(viewer) + var/turf/target_turf = get_turf(target) + if(isnull(from) || isnull(target_turf)) + return FALSE + var/dx = target_turf.x - from.x + var/dy = target_turf.y - from.y + if(!dx && !dy) + return TRUE + var/facing = viewer.dir + var/sx = (facing & EAST) ? 1 : ((facing & WEST) ? -1 : 0) + var/sy = (facing & NORTH) ? 1 : ((facing & SOUTH) ? -1 : 0) + var/facing_len2 = sx * sx + sy * sy + if(!facing_len2) + return FALSE + var/forward = dx * sx + dy * sy + if(forward <= 0) + return FALSE + return forward * forward >= (dx * dx + dy * dy) * facing_len2 * stealth_cos2(half_angle) + +/// cos^2 of `angle` degrees, from a table for the angles the stealth layer uses. +/proc/stealth_cos2(angle) + switch(angle) + if(60) + return 0.25 + if(75) + return 0.0669873 + if(90) + return 0 + var/c = cos(angle) + return c * c + +/** + * Whether `tile` is dark enough to hide in. A turf with no lighting object (planet ground lit only by its + * area's ambient light) reads its area's base light, which get_lumcount() skips (it returns 1 there). + * Same rule as the bounty AI's ai_tile_is_dark() (bounty_ai_meek.dm), which is a type proc. + */ +/proc/stealth_tile_is_dark(turf/tile) + if(!isturf(tile)) + return FALSE +#ifdef UNIT_TESTS + if(tile in GLOB.stealth_test_dark_turfs) + return TRUE +#endif + if(!tile.lighting_object) + var/area/tile_area = tile.loc + return (tile_area.base_lighting_alpha / 255) + tile.dynamic_lumcount < STEALTH_DARK_LUMCOUNT + return tile.get_lumcount() < STEALTH_DARK_LUMCOUNT + +/// The vehicle `who` sits in (a mech or another vehicle), or null. +/proc/stealth_vehicle_of(mob/living/who) + var/atom/holder = who.loc + if(ismecha(holder) || isvehicle(holder)) + return holder + return null + +/** + * Whether a cloak hides `who` from a guard's sight beyond the close radius (owner decision 20: cyberware + * cloaks and boon abilities). Ghostskin camo is already refused by can_attack() past its spot range + * (cyberware_stealth.dm); this adds the heretic shadow cloak a Vestige boon grants. + */ +/proc/stealth_is_cloaked(mob/living/who) + if(HAS_TRAIT(who, TRAIT_CYBER_CAMO)) + return TRUE + return !!who.has_status_effect(/datum/status_effect/shadow_cloak) + +/** + * The living mobs a guard might see, nearest first, at most STEALTH_MAX_CANDIDATES. Uses the spatial + * grid, never view() or hearers(). + */ +/proc/stealth_candidates(mob/living/guard, range) + var/turf/center = get_turf(guard) + if(isnull(center)) + return list() + var/list/buckets = new /list(range + 1) + for(var/mob/living/found in SSspatial_grid.orthogonal_range_search(center, SPATIAL_GRID_CONTENTS_TYPE_HEARING, range)) + if(found == guard) + continue + var/turf/found_turf = get_turf(found) + if(isnull(found_turf) || found_turf.z != center.z) + continue + var/dist = get_dist(center, found_turf) + if(dist > range) + continue + var/list/bucket = buckets[dist + 1] + if(isnull(bucket)) + buckets[dist + 1] = bucket = list() + bucket += found + . = list() + for(var/list/bucket in buckets) + for(var/mob/living/found as anything in bucket) + . += found + if(length(.) >= STEALTH_MAX_CANDIDATES) + return . diff --git a/voidcrew/modules/stealth/stealth_test_ruin.dm b/voidcrew/modules/stealth/stealth_test_ruin.dm new file mode 100644 index 00000000000..69c376eca47 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_test_ruin.dm @@ -0,0 +1,10 @@ +/** + * The stealth playtest room (temporary: deleted when step 4's scav den lands). Never seeds on a planet; + * an admin places it with "Map Template - Place". + */ +/datum/map_template/ruin/jungle/stealth_test + name = "Stealth Test Room" + id = "jungle-stealth-test" + description = "A guarded compound for testing guard sight and patrols." + suffix = "jungle_stealth_test.dmm" + unpickable = TRUE From d8652f37ac921f6943b8c6bcef43fc3efcca412c Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:25:30 -0800 Subject: [PATCH 002/152] Add the stealth test room map --- .../JungleRuins/jungle_stealth_test.dmm | 846 ++++++++++++++++++ 1 file changed, 846 insertions(+) create mode 100644 _maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm diff --git a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm new file mode 100644 index 00000000000..d882f79dcc5 --- /dev/null +++ b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm @@ -0,0 +1,846 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"a" = ( +/obj/effect/spawner/human_npc/ruin/scav{ + dir = 2 + }, +/turf/open/floor/iron, +/area/ruin/powered) +"f" = ( +/obj/effect/landmark/stealth_patrol{ + route = "b"; + order = 3; + dir = 1 + }, +/turf/open/floor/plating, +/area/ruin/powered) +"h" = ( +/obj/effect/landmark/stealth_patrol{ + route = "b"; + order = 1; + dir = 4; + route_mode = "pingpong" + }, +/obj/effect/spawner/human_npc/ruin/scav{ + dir = 4 + }, +/turf/open/floor/plating, +/area/ruin/powered) +"i" = ( +/turf/open/misc/grass/jungle, +/area/overmap_encounter/planet_ruin) +"l" = ( +/obj/machinery/light/directional/north, +/turf/open/floor/iron, +/area/ruin/powered) +"o" = ( +/obj/effect/landmark/stealth_patrol{ + route = "b"; + order = 2; + dir = 8 + }, +/turf/open/floor/plating, +/area/ruin/powered) +"p" = ( +/obj/effect/spawner/structure/window, +/turf/open/floor/iron, +/area/ruin/powered) +"q" = ( +/obj/effect/landmark/stealth_patrol{ + route = "a"; + order = 3; + dir = 2 + }, +/obj/effect/spawner/human_npc/ruin/scav{ + dir = 2 + }, +/turf/open/floor/iron, +/area/ruin/powered) +"s" = ( +/obj/structure/closet/crate, +/turf/open/floor/plating, +/area/ruin/powered) +"t" = ( +/obj/machinery/door/airlock/public, +/turf/open/floor/iron, +/area/ruin/powered) +"v" = ( +/obj/effect/landmark/stealth_patrol{ + route = "a"; + order = 4; + dir = 4; + chatter = 1 + }, +/turf/open/floor/iron, +/area/ruin/powered) +"x" = ( +/turf/open/floor/iron, +/area/ruin/powered) +"z" = ( +/obj/structure/table, +/turf/open/floor/iron, +/area/ruin/powered) +"B" = ( +/obj/structure/closet/crate/large, +/turf/open/floor/plating, +/area/ruin/powered) +"F" = ( +/obj/machinery/light_switch/directional/west, +/turf/open/floor/iron, +/area/ruin/powered) +"I" = ( +/obj/effect/landmark/stealth_patrol{ + route = "a"; + order = 2; + dir = 4 + }, +/obj/effect/spawner/human_npc/ruin/scav/gunner{ + dir = 8 + }, +/turf/open/floor/iron, +/area/ruin/powered) +"J" = ( +/turf/closed/wall, +/area/ruin/powered) +"P" = ( +/turf/open/floor/plating, +/area/ruin/powered) +"Q" = ( +/obj/effect/landmark/stealth_group, +/turf/open/floor/iron, +/area/ruin/powered) +"V" = ( +/obj/effect/landmark/stealth_patrol{ + route = "a"; + order = 1; + dir = 8 + }, +/obj/effect/spawner/human_npc/ruin/scav{ + dir = 4 + }, +/turf/open/floor/iron, +/area/ruin/powered) +"W" = ( +/obj/machinery/light/directional/south, +/turf/open/floor/iron, +/area/ruin/powered) + +(1,1,1) = {" +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +"} +(2,1,1) = {" +i +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +i +i +"} +(3,1,1) = {" +i +J +x +x +J +P +P +P +P +P +P +P +P +P +P +P +P +P +s +J +i +i +"} +(4,1,1) = {" +i +J +l +x +J +P +P +P +P +P +P +s +P +P +P +P +P +P +P +J +i +i +"} +(5,1,1) = {" +i +J +x +V +J +P +P +h +P +P +P +P +P +P +P +P +P +P +P +J +i +i +"} +(6,1,1) = {" +i +J +x +x +J +P +P +P +P +P +P +P +P +P +P +P +f +P +P +J +i +i +"} +(7,1,1) = {" +i +J +x +x +J +P +P +P +P +P +P +P +P +P +P +P +P +P +P +J +i +i +"} +(8,1,1) = {" +i +J +x +x +t +P +P +P +P +P +P +P +B +P +P +P +P +P +P +J +i +i +"} +(9,1,1) = {" +i +J +x +x +J +P +P +P +P +P +P +P +B +P +P +P +P +P +P +J +i +i +"} +(10,1,1) = {" +i +J +l +x +J +P +B +B +P +P +P +P +P +P +P +P +P +P +P +J +i +i +"} +(11,1,1) = {" +i +J +x +x +J +P +P +P +P +P +P +P +P +P +P +P +P +P +P +J +i +i +"} +(12,1,1) = {" +i +J +x +x +J +P +P +P +P +P +P +P +P +P +P +P +P +P +P +J +i +i +"} +(13,1,1) = {" +i +J +x +x +J +P +P +P +P +P +o +P +P +P +P +B +P +P +P +J +i +i +"} +(14,1,1) = {" +i +J +x +x +J +P +P +P +P +P +P +P +P +P +P +s +P +P +P +J +i +i +"} +(15,1,1) = {" +i +J +x +x +J +P +P +P +P +P +P +P +P +P +P +P +P +P +B +J +i +i +"} +(16,1,1) = {" +i +J +l +x +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +i +i +"} +(17,1,1) = {" +i +J +x +x +J +x +x +x +x +x +Q +x +F +x +x +x +x +x +x +J +i +i +"} +(18,1,1) = {" +i +J +x +x +J +l +x +x +x +x +x +x +x +x +x +x +x +x +W +J +i +i +"} +(19,1,1) = {" +i +J +x +x +p +x +x +x +x +x +x +x +x +x +v +x +x +x +x +J +i +i +"} +(20,1,1) = {" +i +J +x +x +p +x +x +x +x +x +x +x +x +x +x +x +x +x +x +J +i +i +"} +(21,1,1) = {" +i +J +x +x +p +x +x +x +x +x +x +x +x +x +x +x +x +x +x +J +i +i +"} +(22,1,1) = {" +i +J +l +x +J +x +x +x +x +x +x +x +x +x +x +x +x +a +x +t +i +i +"} +(23,1,1) = {" +i +J +x +x +J +x +x +x +x +x +x +x +x +x +x +x +x +x +x +J +i +i +"} +(24,1,1) = {" +i +J +x +x +p +x +x +x +x +x +z +z +x +x +x +x +x +x +x +J +i +i +"} +(25,1,1) = {" +i +J +x +x +p +x +x +x +x +x +z +z +x +x +x +x +x +x +x +J +i +i +"} +(26,1,1) = {" +i +J +I +x +p +x +x +x +x +x +x +x +x +x +x +x +x +x +x +J +i +i +"} +(27,1,1) = {" +i +J +x +x +t +x +x +q +x +x +x +x +x +x +x +x +x +x +x +J +i +i +"} +(28,1,1) = {" +i +J +x +x +J +l +x +x +x +x +x +x +x +x +x +x +x +x +W +J +i +i +"} +(29,1,1) = {" +i +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +J +i +i +"} +(30,1,1) = {" +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +i +"} From a1358a6bb0c91b0eda13d1731776cbff71468d0c Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:30:04 -0800 Subject: [PATCH 003/152] Add stealth guard unit tests --- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../modules/unit_tests/voidcrew_stealth.dm | 321 ++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 voidcrew/modules/unit_tests/voidcrew_stealth.dm diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index eb7c190c9f0..19067100870 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -234,6 +234,7 @@ #include "voidcrew_simple_mob_ai.dm" #include "voidcrew_slime_extract_piles.dm" #include "voidcrew_smart_locker.dm" +#include "voidcrew_stealth.dm" #include "voidcrew_survey_archive.dm" #include "voidcrew_survey_capabilities.dm" #include "voidcrew_survey_identity.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth.dm b/voidcrew/modules/unit_tests/voidcrew_stealth.dm new file mode 100644 index 00000000000..942314a1ee1 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_stealth.dm @@ -0,0 +1,321 @@ +// Stealth guards (voidcrew/modules/stealth/). Unit tests compile before voidcrew/_DEFINES, so values from +// there are written out: states "unaware", "suspicious", "alert", "searching"; blackboard keys +// "stealth_state", "stealth_wary", "stealth_last_seen_turf"; STEALTH_PATROL_MAX_FAILS 3, +// STEALTH_LOSE_TARGET_TIME 4 s, STEALTH_SEARCH_TIME 40 s. + +#define STEALTH_TEST_GUARD /mob/living/carbon/human/npc/ruin/trooper/scav + +/// A floored block of `width` x `height` tiles for one test. Released by the caller. +/datum/unit_test/proc/stealth_block(width, height) + var/datum/turf_reservation/block = SSmapping.request_turf_block_reservation(width, height, 1) + if(isnull(block)) + return null + var/turf/corner = block.bottom_left_turfs[1] + for(var/turf/spot as anything in block(corner.x, corner.y, corner.z, corner.x + width - 1, corner.y + height - 1, corner.z)) + spot.ChangeTurf(/turf/open/floor/iron) + return block + +/datum/unit_test/proc/stealth_at(datum/turf_reservation/block, dx, dy) + var/turf/corner = block.bottom_left_turfs[1] + return locate(corner.x + dx, corner.y + dy, corner.z) + +/// A guard at (dx, dy) of `block`, facing `facing`. +/datum/unit_test/proc/stealth_guard(datum/turf_reservation/block, dx, dy, facing = EAST) + var/mob/living/carbon/human/npc/guard = allocate(STEALTH_TEST_GUARD, stealth_at(block, dx, dy)) + guard.setDir(facing) + return guard + +/// A walking, ordinary person at (dx, dy) of `block`. +/datum/unit_test/proc/stealth_person(datum/turf_reservation/block, dx, dy) + var/mob/living/carbon/human/consistent/person = allocate(/mob/living/carbon/human/consistent, stealth_at(block, dx, dy)) + if(person.move_intent == MOVE_INTENT_RUN) + person.toggle_move_intent() + return person + +/datum/unit_test/proc/stealth_ai(mob/living/guard) + return guard.ai_controller + +/datum/unit_test/proc/stealth_scans(mob/living/guard, count) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + for(var/i in 1 to count) + stealth_ai.stealth_scan_once() + +/// Cone math against a guard facing east. +/datum/unit_test/voidcrew_stealth_cone + +/datum/unit_test/voidcrew_stealth_cone/Run() + var/datum/turf_reservation/block = stealth_block(9, 9) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/obj/item/viewer = allocate(/obj/item/pen, stealth_at(block, 4, 4)) + viewer.setDir(EAST) + // offset => expected at 60, 75, 90 + var/list/cases = list( + list(3, 0, TRUE, TRUE, TRUE), + list(3, 2, TRUE, TRUE, TRUE), + list(2, 3, FALSE, TRUE, TRUE), + list(0, 3, FALSE, FALSE, FALSE), + list(-2, 0, FALSE, FALSE, FALSE), + list(0, 0, TRUE, TRUE, TRUE), + ) + var/list/angles = list(60, 75, 90) + for(var/list/test_case as anything in cases) + var/turf/spot = stealth_at(block, 4 + test_case[1], 4 + test_case[2]) + for(var/i in 1 to 3) + TEST_ASSERT_EQUAL(!!stealth_in_cone(viewer, spot, angles[i]), test_case[2 + i], "Offset ([test_case[1]], [test_case[2]]) at half-angle [angles[i]]") + qdel(block) + +/// The dark helper reads the test list; ordinary test floor is lit. +/datum/unit_test/voidcrew_stealth_dark + +/datum/unit_test/voidcrew_stealth_dark/Run() + var/datum/turf_reservation/block = stealth_block(3, 3) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/turf/lit = stealth_at(block, 0, 0) + var/turf/dark = stealth_at(block, 1, 0) + GLOB.stealth_test_dark_turfs += dark + TEST_ASSERT(!stealth_tile_is_dark(lit), "Ordinary test floor reads as dark") + TEST_ASSERT(stealth_tile_is_dark(dark), "A turf in the test dark list reads as lit") + GLOB.stealth_test_dark_turfs -= dark + qdel(block) + +/// Sight: behind stays unaware, in front goes suspicious then alert, dark at range and off-screen never alert. +/datum/unit_test/voidcrew_stealth_sight + +/datum/unit_test/voidcrew_stealth_sight/Run() + var/datum/turf_reservation/block = stealth_block(20, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + // Behind, 3 tiles, lit, still. + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 6, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/mob/living/carbon/human/person = stealth_person(block, 3, 2) + stealth_scans(guard, 10) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A guard noticed someone standing 3 tiles behind it") + qdel(guard) + // In front, 2 tiles. + guard = stealth_guard(block, 6, 2) + stealth_ai = stealth_ai(guard) + person.forceMove(stealth_at(block, 8, 2)) + stealth_scans(guard, 2) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "suspicious", "A guard wasn't suspicious of someone 2 tiles in front after two scans") + stealth_scans(guard, 2) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard wasn't alert on someone 2 tiles in front after four scans") + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], person, "The alert guard has no target") + qdel(guard) + // In front, 6 tiles, dark. + guard = stealth_guard(block, 6, 2) + stealth_ai = stealth_ai(guard) + var/turf/dark = stealth_at(block, 12, 2) + GLOB.stealth_test_dark_turfs += dark + person.forceMove(dark) + stealth_scans(guard, 10) + GLOB.stealth_test_dark_turfs -= dark + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A guard saw someone 6 tiles off in the dark") + qdel(guard) + // In front, 8 tiles, lit: never alert from off-screen. + guard = stealth_guard(block, 6, 2) + stealth_ai = stealth_ai(guard) + person.forceMove(stealth_at(block, 14, 2)) + for(var/i in 1 to 30) + stealth_ai.stealth_scan_once() + TEST_ASSERT(stealth_ai.stealth_state() != "alert", "A guard went alert on someone 8 tiles off") + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "suspicious", "A guard never grew suspicious of someone 8 tiles in front in the light") + qdel(guard) + qdel(block) + +/// Alert: keeps its target all round; loses it behind a wall and searches where it last saw it. +/datum/unit_test/voidcrew_stealth_alert + +/datum/unit_test/voidcrew_stealth_alert/Run() + var/datum/turf_reservation/block = stealth_block(16, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 6, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/mob/living/carbon/human/person = stealth_person(block, 9, 2) + stealth_ai.stealth_go_alert(person) + person.forceMove(stealth_at(block, 3, 2)) + stealth_scans(guard, 5) + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], person, "An alert guard dropped a target that stepped behind it") + // Behind a wall now. + var/turf/last_seen = stealth_at(block, 9, 2) + person.forceMove(last_seen) + stealth_scans(guard, 1) + for(var/dy in 0 to 4) + var/turf/wall_spot = stealth_at(block, 11, dy) + wall_spot.ChangeTurf(/turf/closed/wall) + person.forceMove(stealth_at(block, 13, 2)) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard gave up on a target the moment it went out of sight") + stealth_ai.stealth_rewind(5 SECONDS) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "searching", "A guard that lost its target isn't searching") + TEST_ASSERT_EQUAL(stealth_ai.blackboard["stealth_last_seen_turf"], last_seen, "The search isn't at the last place it saw them") + TEST_ASSERT_NULL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], "A searching guard still has a target") + qdel(block) + +/// Hit from out of sight: searches the attacker's turf, wary. Pulled: alert on the puller. The step 3 hook. +/datum/unit_test/voidcrew_stealth_touched + +/datum/unit_test/voidcrew_stealth_touched/Run() + var/datum/turf_reservation/block = stealth_block(20, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 15, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/mob/living/carbon/human/person = stealth_person(block, 1, 2) + SEND_SIGNAL(guard, COMSIG_ATOM_WAS_ATTACKED, person, ATTACKER_DAMAGING_ATTACK) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "searching", "A guard hit from out of sight isn't searching") + TEST_ASSERT_EQUAL(stealth_ai.blackboard["stealth_last_seen_turf"], get_turf(person), "The search isn't at the attacker's turf") + TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "A guard that was hit isn't wary") + qdel(guard) + // Pulled. + guard = stealth_guard(block, 15, 2) + stealth_ai = stealth_ai(guard) + person.forceMove(stealth_at(block, 14, 2)) + person.start_pulling(guard) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A pulled guard isn't alert") + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], person, "A pulled guard isn't after the puller") + person.stop_pulling() + qdel(guard) + // The hook: unaware when the blow began, even though the blow made it alert. + guard = stealth_guard(block, 15, 2) + stealth_ai = stealth_ai(guard) + TEST_ASSERT(guard.stealth_was_unaware(), "An unaware guard doesn't read as unaware") + SEND_SIGNAL(guard, COMSIG_ATOM_WAS_ATTACKED, person, ATTACKER_DAMAGING_ATTACK) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard hit by someone beside it isn't alert") + TEST_ASSERT(guard.stealth_was_unaware(), "The hook forgot the guard was unaware in the same tick") + stealth_ai.stealth_rewind(1) + TEST_ASSERT(!guard.stealth_was_unaware(), "The hook still reads unaware after time passed") + var/mob/living/carbon/human/npc/plain = allocate(/mob/living/carbon/human/npc/ruin/trooper/syndicate, stealth_at(block, 3, 2)) + TEST_ASSERT(!plain.stealth_was_unaware(), "An NPC without stealth reads as unaware") + qdel(block) + +/// A fallen friend in view: suspicious once; the same body never sets it off again. +/datum/unit_test/voidcrew_stealth_bodies + +/datum/unit_test/voidcrew_stealth_bodies/Run() + var/datum/turf_reservation/block = stealth_block(12, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + allocate(/obj/effect/landmark/stealth_group, stealth_at(block, 6, 4)) + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/mob/living/carbon/human/npc/friend = stealth_guard(block, 5, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/datum/ai_controller/basic_controller/human_npc/stealth/friend_ai = stealth_ai(friend) + stealth_ai.stealth_setup() + friend_ai.stealth_setup() + TEST_ASSERT_EQUAL(stealth_ai.group, friend_ai.group, "Two guards by one marker aren't in one group") + friend.death() + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "suspicious", "A guard didn't react to a dead friend in view") + stealth_ai.stealth_go_unaware() + stealth_ai.next_body_scan = 0 + stealth_scans(guard, 3) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "The same body set a guard off twice") + qdel(block) + +/// Routes: each guard claims only its own group's stops, in order; a guard far from any stop is a sentry. +/// A stop it can't reach is skipped after STEALTH_PATROL_MAX_FAILS failures. +/datum/unit_test/voidcrew_stealth_routes + +/datum/unit_test/voidcrew_stealth_routes/Run() + var/datum/turf_reservation/first = stealth_block(10, 5) + var/datum/turf_reservation/second = stealth_block(10, 5) + TEST_ASSERT(first && second, "No room for the test") + var/list/guards = list() + for(var/datum/turf_reservation/block in list(first, second)) + allocate(/obj/effect/landmark/stealth_group, stealth_at(block, 9, 4)) + // Made out of order, so the group has to sort them. + var/obj/effect/landmark/stealth_patrol/third = allocate(/obj/effect/landmark/stealth_patrol, stealth_at(block, 7, 0)) + third.order = 3 + var/obj/effect/landmark/stealth_patrol/second_stop = allocate(/obj/effect/landmark/stealth_patrol, stealth_at(block, 4, 0)) + second_stop.order = 2 + var/obj/effect/landmark/stealth_patrol/first_stop = allocate(/obj/effect/landmark/stealth_patrol, stealth_at(block, 1, 0)) + first_stop.order = 1 + guards += stealth_guard(block, 1, 0) + var/list/stop_columns = list(1, 4, 7) + for(var/i in 1 to 2) + var/datum/turf_reservation/block = i == 1 ? first : second + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guards[i]) + stealth_ai.stealth_setup() + TEST_ASSERT_EQUAL(stealth_ai.route_id, "a", "Guard [i] didn't claim the route it spawned on") + var/list/stops = stealth_ai.stealth_stops() + TEST_ASSERT_EQUAL(length(stops), 3, "Guard [i]'s route has [length(stops)] stops, not its own group's 3") + for(var/index in 1 to length(stops)) + var/datum/stealth_stop/stop = stops[index] + TEST_ASSERT_EQUAL(stop.order, index, "Guard [i]'s stop [index] is out of order") + TEST_ASSERT_EQUAL(stop.spot, stealth_at(block, stop_columns[index], 0), "Guard [i]'s stop [index] isn't in its own block") + var/mob/living/carbon/human/npc/sentry = stealth_guard(first, 1, 4, SOUTH) + var/datum/ai_controller/basic_controller/human_npc/stealth/sentry_ai = stealth_ai(sentry) + // Four tiles from the nearest stop, beyond STEALTH_ROUTE_CLAIM_RANGE (2). + sentry_ai.stealth_setup() + TEST_ASSERT_NULL(sentry_ai.route_id, "A guard away from every stop claimed a route") + TEST_ASSERT_EQUAL(sentry_ai.sentry_dir, SOUTH, "The sentry doesn't face the way it spawned") + // Unreachable stop. + var/datum/ai_controller/basic_controller/human_npc/stealth/walker = stealth_ai(guards[1]) + walker.route_index = 2 + walker.goto_purpose = "patrol" + for(var/i in 1 to 3) + walker.stealth_walk_failed() + TEST_ASSERT(walker.stop_skip_until["2"] > world.time, "A stop that failed 3 times isn't skipped") + TEST_ASSERT(walker.route_index != 2, "The guard still heads for a skipped stop") + qdel(first) + qdel(second) + +/// A search that ran out while the guard slept ends on waking: unaware, still wary. +/datum/unit_test/voidcrew_stealth_wake + +/datum/unit_test/voidcrew_stealth_wake/Run() + var/datum/turf_reservation/block = stealth_block(5, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + stealth_ai.stealth_go_searching(stealth_at(block, 3, 3)) + stealth_ai.stealth_rewind(41 SECONDS) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A search past its time didn't end on waking") + TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "A guard that searched isn't wary") + qdel(block) + +/// Someone in a locker in front of a guard is unseen; a mech in range is alert at once. +/datum/unit_test/voidcrew_stealth_containers + +/datum/unit_test/voidcrew_stealth_containers/Run() + var/datum/turf_reservation/block = stealth_block(10, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/mob/living/carbon/human/person = stealth_person(block, 4, 2) + var/obj/structure/closet/locker = allocate(/obj/structure/closet, stealth_at(block, 4, 2)) + locker.close() + person.forceMove(locker) + stealth_scans(guard, 10) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A guard saw someone shut in a locker") + person.forceMove(stealth_at(block, 6, 2)) + var/obj/vehicle/sealed/mecha/ripley/mech = allocate(/obj/vehicle/sealed/mecha/ripley, stealth_at(block, 6, 2)) + person.forceMove(mech) + mech.add_occupant(person) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard wasn't alert on a mech in front of it") + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], mech, "The guard isn't after the mech") + mech.remove_occupant(person) + person.forceMove(stealth_at(block, 8, 2)) + qdel(block) + +/// Deleting a guard takes it out of its group; with the marker and every guard gone the group goes too. +/datum/unit_test/voidcrew_stealth_cleanup + +/datum/unit_test/voidcrew_stealth_cleanup/Run() + var/datum/turf_reservation/block = stealth_block(6, 4) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/obj/effect/landmark/stealth_group/marker = new(stealth_at(block, 5, 3)) + var/datum/stealth_group/group = marker.group + var/mob/living/carbon/human/npc/guard = new STEALTH_TEST_GUARD(stealth_at(block, 1, 1)) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + stealth_ai.stealth_setup() + TEST_ASSERT_EQUAL(stealth_ai.group, group, "The guard didn't join the marker's group") + qdel(guard) + TEST_ASSERT_EQUAL(length(group.living_members()), 0, "A deleted guard is still in its group") + TEST_ASSERT(group in GLOB.stealth_groups, "A group with its marker standing was removed") + qdel(marker) + TEST_ASSERT(!(group in GLOB.stealth_groups), "A group with no marker and no guards is still registered") + qdel(block) + +#undef STEALTH_TEST_GUARD From 3b8f10ee080a784825c47c6ec98b22c428eb0432 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:30:35 -0800 Subject: [PATCH 004/152] Hide bodies in containers from stealth guards --- voidcrew/modules/stealth/stealth_ai.dm | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/stealth/stealth_ai.dm b/voidcrew/modules/stealth/stealth_ai.dm index b766d7e25e5..82ac03fad38 100644 --- a/voidcrew/modules/stealth/stealth_ai.dm +++ b/voidcrew/modules/stealth/stealth_ai.dm @@ -514,7 +514,10 @@ var/key = REF(member) if(key in seen) continue - var/turf/where = get_turf(member) + // A body stuffed in a locker, a crate or a bag is hidden, as a living person there is. + if(!isturf(member.loc)) + continue + var/turf/where = member.loc if(isnull(where) || where.z != guard.z) continue var/dist = get_dist(guard, where) From eba30b4b0510a4e3599bac48d725f023fc654816 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:09:57 -0800 Subject: [PATCH 005/152] Fix stealth z-change hook and test lighting --- voidcrew/modules/stealth/stealth_ai.dm | 12 ++++++------ voidcrew/modules/stealth/stealth_perception.dm | 4 ++-- voidcrew/modules/unit_tests/voidcrew_stealth.dm | 16 +++++++++------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/voidcrew/modules/stealth/stealth_ai.dm b/voidcrew/modules/stealth/stealth_ai.dm index 82ac03fad38..9580dff84bd 100644 --- a/voidcrew/modules/stealth/stealth_ai.dm +++ b/voidcrew/modules/stealth/stealth_ai.dm @@ -122,7 +122,6 @@ RegisterSignal(new_pawn, COMSIG_ATOM_BUMPED, PROC_REF(on_bumped)) RegisterSignal(new_pawn, COMSIG_CARBON_HELP_ACT, PROC_REF(on_helped)) RegisterSignal(new_pawn, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_projectile_prehit)) - RegisterSignal(new_pawn, COMSIG_MOVABLE_Z_CHANGED, PROC_REF(on_z_changed)) RegisterSignal(new_pawn, COMSIG_LIVING_STATUS_REMOVED, PROC_REF(on_status_removed)) RegisterSignal(new_pawn, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), PROC_REF(on_target_set)) @@ -133,7 +132,6 @@ COMSIG_ATOM_BUMPED, COMSIG_CARBON_HELP_ACT, COMSIG_PROJECTILE_PREHIT, - COMSIG_MOVABLE_Z_CHANGED, COMSIG_LIVING_STATUS_REMOVED, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), )) @@ -596,10 +594,12 @@ stealth_set_wary() set_blackboard_key(BB_STEALTH_FOCUS, target) -/// Carried off the level: drops its route and holds where it is (abuse R3). -/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_z_changed(datum/source) - SIGNAL_HANDLER - stealth_become_sentry(get_turf(pawn)) +/// Carried off the level: drops its route and holds where it is (abuse R3). The base controller already +/// listens for the level change. +/datum/ai_controller/basic_controller/human_npc/stealth/on_changed_z_level(atom/source, turf/old_turf, turf/new_turf, same_z_layer, notify_contents) + . = ..() + if(stealth_ready && new_turf) + stealth_become_sentry(new_turf) /datum/ai_controller/basic_controller/human_npc/stealth/on_stat_changed(mob/living/source, new_stat) . = ..() diff --git a/voidcrew/modules/stealth/stealth_perception.dm b/voidcrew/modules/stealth/stealth_perception.dm index d3bc47df7a7..8b1054fffbf 100644 --- a/voidcrew/modules/stealth/stealth_perception.dm +++ b/voidcrew/modules/stealth/stealth_perception.dm @@ -13,7 +13,7 @@ */ #ifdef UNIT_TESTS -/// Turfs the unit tests treat as dark, since the test level is fully lit +/// Unit tests: turf => TRUE to treat it as dark, FALSE as lit (reserved test space has no light of its own) GLOBAL_LIST_EMPTY(stealth_test_dark_turfs) #endif @@ -61,7 +61,7 @@ GLOBAL_LIST_EMPTY(stealth_test_dark_turfs) return FALSE #ifdef UNIT_TESTS if(tile in GLOB.stealth_test_dark_turfs) - return TRUE + return GLOB.stealth_test_dark_turfs[tile] #endif if(!tile.lighting_object) var/area/tile_area = tile.loc diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth.dm b/voidcrew/modules/unit_tests/voidcrew_stealth.dm index 942314a1ee1..72adddda140 100644 --- a/voidcrew/modules/unit_tests/voidcrew_stealth.dm +++ b/voidcrew/modules/unit_tests/voidcrew_stealth.dm @@ -13,6 +13,7 @@ var/turf/corner = block.bottom_left_turfs[1] for(var/turf/spot as anything in block(corner.x, corner.y, corner.z, corner.x + width - 1, corner.y + height - 1, corner.z)) spot.ChangeTurf(/turf/open/floor/iron) + GLOB.stealth_test_dark_turfs[spot] = FALSE return block /datum/unit_test/proc/stealth_at(datum/turf_reservation/block, dx, dy) @@ -52,7 +53,8 @@ var/list/cases = list( list(3, 0, TRUE, TRUE, TRUE), list(3, 2, TRUE, TRUE, TRUE), - list(2, 3, FALSE, TRUE, TRUE), + list(2, 3, TRUE, TRUE, TRUE), + list(1, 3, FALSE, TRUE, TRUE), list(0, 3, FALSE, FALSE, FALSE), list(-2, 0, FALSE, FALSE, FALSE), list(0, 0, TRUE, TRUE, TRUE), @@ -64,7 +66,7 @@ TEST_ASSERT_EQUAL(!!stealth_in_cone(viewer, spot, angles[i]), test_case[2 + i], "Offset ([test_case[1]], [test_case[2]]) at half-angle [angles[i]]") qdel(block) -/// The dark helper reads the test list; ordinary test floor is lit. +/// The dark helper reads the test lists: a dark-listed turf is dark, a lit-listed one is not. /datum/unit_test/voidcrew_stealth_dark /datum/unit_test/voidcrew_stealth_dark/Run() @@ -72,10 +74,10 @@ TEST_ASSERT_NOTNULL(block, "No room for the test") var/turf/lit = stealth_at(block, 0, 0) var/turf/dark = stealth_at(block, 1, 0) - GLOB.stealth_test_dark_turfs += dark - TEST_ASSERT(!stealth_tile_is_dark(lit), "Ordinary test floor reads as dark") + GLOB.stealth_test_dark_turfs[dark] = TRUE + TEST_ASSERT(!stealth_tile_is_dark(lit), "A lit test turf reads as dark") TEST_ASSERT(stealth_tile_is_dark(dark), "A turf in the test dark list reads as lit") - GLOB.stealth_test_dark_turfs -= dark + GLOB.stealth_test_dark_turfs[dark] = FALSE qdel(block) /// Sight: behind stays unaware, in front goes suspicious then alert, dark at range and off-screen never alert. @@ -105,10 +107,10 @@ guard = stealth_guard(block, 6, 2) stealth_ai = stealth_ai(guard) var/turf/dark = stealth_at(block, 12, 2) - GLOB.stealth_test_dark_turfs += dark + GLOB.stealth_test_dark_turfs[dark] = TRUE person.forceMove(dark) stealth_scans(guard, 10) - GLOB.stealth_test_dark_turfs -= dark + GLOB.stealth_test_dark_turfs[dark] = FALSE TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A guard saw someone 6 tiles off in the dark") qdel(guard) // In front, 8 tiles, lit: never alert from off-screen. From b165c92872d7e11a443b07ca9f8f71ad2b32ba53 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:50:14 -0800 Subject: [PATCH 006/152] Keep AI awake on a forced-active z-level while ghosted --- voidcrew/modules/npc_ships/code/admin_verbs.dm | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/voidcrew/modules/npc_ships/code/admin_verbs.dm b/voidcrew/modules/npc_ships/code/admin_verbs.dm index c62568887a6..04e08c76142 100644 --- a/voidcrew/modules/npc_ships/code/admin_verbs.dm +++ b/voidcrew/modules/npc_ships/code/admin_verbs.dm @@ -281,6 +281,14 @@ ADMIN_VERB(force_z_level_active, R_DEBUG, "Force Z-Level Active", "Keep a z-leve message_admins("[key_name_admin(user)] forced z-level [z_level] active for AI debugging.") BLACKBOX_LOG_ADMIN_VERB("Force Z-Level Active") +// tg idles an AI unless a living mob with a client is in its nearby grid cells. A ghost isn't living and the +// activator dummy has no client, so on a forced-active level the AI would sit idle and never plan. +/datum/ai_controller/should_idle() + var/turf/pawn_turf = get_turf(pawn) + if(pawn_turf && GLOB.z_level_activator_dummies["[pawn_turf.z]"]) + return FALSE + return ..() + /// Global list of room visualization overlays (ship_ref -> list of overlay images) GLOBAL_LIST_EMPTY(room_visualization_overlays) From 7c3ac8f6435b4ce6bdc84a2e30595e860c5ec54e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:23:38 -0800 Subject: [PATCH 007/152] Stealth guards calm down after downing their target, wary fades, admin view of every guard's sight Guards with nobody else noticed say a line, look round for a few seconds and go back to their route after killing or downing their target. Hits from a second attacker during a fight are remembered. Wary now fades after three minutes with nothing new. Admins can show the sight of every guard on their level at once. The notice map moves off the blackboard, whose qdel sweep runtimed on it. --- voidcrew/_DEFINES/stealth.dm | 12 +- voidcrew/modules/stealth/scav_guards.dm | 2 + voidcrew/modules/stealth/stealth_admin.dm | 80 ++++++++++-- voidcrew/modules/stealth/stealth_ai.dm | 123 ++++++++++++++++-- .../modules/unit_tests/voidcrew_stealth.dm | 86 +++++++++++- 5 files changed, 278 insertions(+), 25 deletions(-) diff --git a/voidcrew/_DEFINES/stealth.dm b/voidcrew/_DEFINES/stealth.dm index 6fefe3e1bc7..f479200fef1 100644 --- a/voidcrew/_DEFINES/stealth.dm +++ b/voidcrew/_DEFINES/stealth.dm @@ -13,10 +13,8 @@ #define BB_STEALTH_STATE_SINCE "stealth_state_since" /// world.time the guard last stopped being unaware (stealth_was_unaware()) #define BB_STEALTH_UNAWARE_LOST_AT "stealth_unaware_lost_at" -/// TRUE once alerted, searching or hit. Never cleared by time or dormancy. +/// TRUE once alerted, searching or hit. Fades after STEALTH_WARY_TIME with nothing new (checked each scan). #define BB_STEALTH_WARY "stealth_wary" -/// REF(mob) => list(notice, turf last scan, world.time last perceived) -#define BB_STEALTH_NOTICE "stealth_notice" /// The candidate with the highest notice #define BB_STEALTH_FOCUS "stealth_focus" /// Where it last saw (or was hit from) whoever it is after @@ -28,7 +26,7 @@ /// Its claimed stop, or the sentry's post #define BB_STEALTH_HOME_TURF "stealth_home_turf" -// Notice entry fields +// Notice entry fields (the controller's notice_map) #define STEALTH_ENTRY_NOTICE 1 #define STEALTH_ENTRY_TURF 2 #define STEALTH_ENTRY_SEEN_AT 3 @@ -41,6 +39,8 @@ #define STEALTH_BARK_HIT_UNSEEN "stealth_hit_unseen" #define STEALTH_BARK_BODY "stealth_body" #define STEALTH_BARK_CHATTER "stealth_chatter" +/// It just put its target down +#define STEALTH_BARK_DOWNED "stealth_target_downed" // ===== What a stealth goto is for ===== #define STEALTH_GOTO_PATROL "patrol" @@ -79,6 +79,10 @@ #define STEALTH_SUSPICIOUS_TIMEOUT (12 SECONDS) #define STEALTH_LOSE_TARGET_TIME (4 SECONDS) #define STEALTH_SEARCH_TIME (40 SECONDS) +/// After putting its target down with nobody else about: how long it looks round before calming down +#define STEALTH_CALM_LOOK_TIME (6 SECONDS) +/// How long wary lasts after the last notice gain, hit, touch, alert or search +#define STEALTH_WARY_TIME (3 MINUTES) #define STEALTH_SEARCH_POINTS 3 #define STEALTH_SEARCH_RADIUS 5 #define STEALTH_BODY_SCAN_INTERVAL (2 SECONDS) diff --git a/voidcrew/modules/stealth/scav_guards.dm b/voidcrew/modules/stealth/scav_guards.dm index cc54f580537..82bf667f0de 100644 --- a/voidcrew/modules/stealth/scav_guards.dm +++ b/voidcrew/modules/stealth/scav_guards.dm @@ -18,6 +18,7 @@ STEALTH_BARK_HIT_UNSEEN = list("Contact!", "Where's that coming from?"), STEALTH_BARK_BODY = list("Man down!", "Who did this?"), STEALTH_BARK_CHATTER = list("Shipment's late again.", "My feet are killing me.", "You hear that? No? Good."), + STEALTH_BARK_DOWNED = list("Got 'em.", "Stay down.", "That's handled."), ) emotes = list( HUMAN_NPC_BARK_DOWNED = list("collapses."), @@ -26,6 +27,7 @@ STEALTH_BARK_SEARCH = list("checks behind a crate."), STEALTH_BARK_HIT_UNSEEN = list("ducks."), STEALTH_BARK_CHATTER = list("yawns.", "stretches."), + STEALTH_BARK_DOWNED = list("spits."), ) chances = list(RUIN_NPC_BARK_SPOT = 100) diff --git a/voidcrew/modules/stealth/stealth_admin.dm b/voidcrew/modules/stealth/stealth_admin.dm index fc3e9d3c847..d0616631683 100644 --- a/voidcrew/modules/stealth/stealth_admin.dm +++ b/voidcrew/modules/stealth/stealth_admin.dm @@ -2,7 +2,8 @@ * # Stealth guard admin tools * * "Stealth Guards" (Debug): lists every stealth guard on your z-level, shows and forces its awareness, - * shows its sight to you alone, and acts on whole groups. Every action is logged (abuse review P16). + * shows its sight to you alone (one guard, or every guard on the level at once), and acts on whole groups. + * Every action is logged (abuse review P16). * * The AI sleeps unless a living player is on the level. An admin watching as a ghost needs * "Force Z-Level Active" (npc_ships/code/admin_verbs.dm). @@ -25,12 +26,16 @@ var/atom/focus = stealth_ai.blackboard[BB_STEALTH_FOCUS] var/focus_text = "none" if(focus) - var/list/entry = stealth_ai.blackboard[BB_STEALTH_NOTICE]?[REF(focus)] + var/list/entry = stealth_ai.notice_map?[REF(focus)] focus_text = "[focus] [entry ? round(entry[STEALTH_ENTRY_NOTICE], 0.01) : "-"]" var/since = round((world.time - stealth_ai.blackboard[BB_STEALTH_STATE_SINCE]) / (1 SECONDS)) var/route_text = stealth_ai.route_id ? "route [stealth_ai.route_id] stop [stealth_ai.route_index]" : "sentry" var/status_text = list("[AI_STATUS_ON]" = "on", "[AI_STATUS_IDLE]" = "idle", "[AI_STATUS_OFF]" = "off")["[stealth_ai.ai_status]"] || "off" - return "[guard.real_name] [REF(guard)] | [stealth_ai.group?.id || "no group"] | [stealth_ai.stealth_state()] [since]s | focus [focus_text] | [stealth_ai.blackboard[BB_STEALTH_WARY] ? "wary" : "calm"] | [route_text] | AI [status_text]" + var/state_text = stealth_ai.calm_until ? "[stealth_ai.stealth_state()] (calming)" : stealth_ai.stealth_state() + var/wary_text = "calm" + if(stealth_ai.blackboard[BB_STEALTH_WARY]) + wary_text = "wary [max(0, round((STEALTH_WARY_TIME - (world.time - stealth_ai.wary_at)) / (1 SECONDS)))]s" + return "[guard.real_name] [REF(guard)] | [stealth_ai.group?.id || "no group"] | [state_text] [since]s | focus [focus_text] | [wary_text] | [route_text] | AI [status_text]" ADMIN_VERB(stealth_guards, R_DEBUG, "Stealth Guards", "View and force the awareness of stealth guards on your z-level.", ADMIN_CATEGORY_DEBUG) var/turf/here = get_turf(user.mob) @@ -47,11 +52,15 @@ ADMIN_VERB(stealth_guards, R_DEBUG, "Stealth Guards", "View and force the awaren if(!length(choices)) to_chat(user, span_notice("No stealth guards on this z-level."), confidential = TRUE) return - var/list/all_choices = sort_list(choices) + sort_list(groups) + var/level_choice = "Every guard on this level: toggle sight" + var/list/all_choices = list(level_choice) + sort_list(choices) + sort_list(groups) var/choice = tgui_input_list(user, "Which guard or group?", "Stealth Guards", all_choices) if(isnull(choice)) return - if(groups[choice]) + if(choice == level_choice) + var/shown = stealth_toggle_level_sight(user, here.z) + stealth_admin_log(user, "[shown ? "showed" : "hid"] the sight of every guard on z[here.z]", here) + else if(groups[choice]) stealth_admin_group(user, groups[choice]) else if(choices[choice]) stealth_admin_guard(user, choices[choice]) @@ -95,7 +104,10 @@ ADMIN_VERB(stealth_guards, R_DEBUG, "Stealth Guards", "View and force the awaren stealth_ai.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) stealth_ai.stealth_go_searching(here) if("Toggle Wary") - stealth_ai.blackboard[BB_STEALTH_WARY] = !stealth_ai.blackboard[BB_STEALTH_WARY] + if(stealth_ai.blackboard[BB_STEALTH_WARY]) + stealth_ai.stealth_clear_wary() + else + stealth_ai.stealth_set_wary() if("Show its sight") stealth_toggle_sight_overlay(user, guard) if("Jump to it") @@ -126,7 +138,7 @@ ADMIN_VERB(stealth_guards, R_DEBUG, "Stealth Guards", "View and force the awaren stealth_ai.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) stealth_ai.stealth_go_unaware() if("Clear wary") - stealth_ai.blackboard[BB_STEALTH_WARY] = FALSE + stealth_ai.stealth_clear_wary() if("Alert all on me") if(isliving(user.mob)) stealth_ai.stealth_go_alert(user.mob) @@ -160,6 +172,45 @@ ADMIN_VERB(stealth_guards, R_DEBUG, "Stealth Guards", "View and force the awaren /// Admins watching one guard's sight: admin ckey => overlay GLOBAL_LIST_EMPTY(stealth_sight_overlays) +/// The GLOB.stealth_sight_overlays key for `user`'s whole-level view. +#define STEALTH_LEVEL_OVERLAY_KEY(user) "level:[user.ckey]" + +/// Shows or hides the sight of every stealth guard on `z` to `user`. TRUE if now shown. +/proc/stealth_toggle_level_sight(client/user, z) + var/datum/stealth_level_overlay/old = GLOB.stealth_sight_overlays[STEALTH_LEVEL_OVERLAY_KEY(user)] + if(old) + qdel(old) + return FALSE + new /datum/stealth_level_overlay(user, z) + return TRUE + +/** + * Every guard's sight on one level, for one admin: one child overlay per guard. Ends on a second toggle + * or after STEALTH_ADMIN_OVERLAY_TIME; a guard that dies or is deleted drops its own child. + */ +/datum/stealth_level_overlay + var/key + var/list/datum/stealth_sight_overlay/children = list() + +/datum/stealth_level_overlay/New(client/user, z) + key = STEALTH_LEVEL_OVERLAY_KEY(user) + GLOB.stealth_sight_overlays[key] = src + for(var/mob/living/carbon/human/npc/guard as anything in stealth_guards_on_level(z)) + if(guard.stat == DEAD || !istype(guard.ai_controller, /datum/ai_controller/basic_controller/human_npc/stealth)) + continue + children += new /datum/stealth_sight_overlay(user, guard, src) + QDEL_IN(src, STEALTH_ADMIN_OVERLAY_TIME) + +/datum/stealth_level_overlay/Destroy() + if(GLOB.stealth_sight_overlays[key] == src) + GLOB.stealth_sight_overlays -= key + for(var/datum/stealth_sight_overlay/child as anything in children.Copy()) + qdel(child) + children.Cut() + return ..() + +#undef STEALTH_LEVEL_OVERLAY_KEY + /proc/stealth_toggle_sight_overlay(client/user, mob/living/carbon/human/npc/guard) var/datum/stealth_sight_overlay/old = GLOB.stealth_sight_overlays[user.ckey] var/same = old?.guard_ref?.resolve() == guard @@ -179,22 +230,29 @@ GLOBAL_LIST_EMPTY(stealth_sight_overlays) var/datum/weakref/guard_ref var/ckey var/list/image/images = list() + /// The whole-level view this belongs to, or null for a single guard's + var/datum/stealth_level_overlay/parent -/datum/stealth_sight_overlay/New(client/user, mob/living/carbon/human/npc/guard) +/datum/stealth_sight_overlay/New(client/user, mob/living/carbon/human/npc/guard, datum/stealth_level_overlay/parent) user_ref = WEAKREF(user) guard_ref = WEAKREF(guard) ckey = user.ckey - GLOB.stealth_sight_overlays[ckey] = src + src.parent = parent + if(isnull(parent)) + GLOB.stealth_sight_overlays[ckey] = src + QDEL_IN(src, STEALTH_ADMIN_OVERLAY_TIME) var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller LAZYADD(stealth_ai.watchers, src) RegisterSignal(guard, COMSIG_LIVING_DEATH, PROC_REF(on_guard_gone)) RegisterSignal(guard, COMSIG_QDELETING, PROC_REF(on_guard_gone)) - QDEL_IN(src, STEALTH_ADMIN_OVERLAY_TIME) refresh() /datum/stealth_sight_overlay/Destroy() clear() - if(GLOB.stealth_sight_overlays[ckey] == src) + if(parent) + parent.children -= src + parent = null + else if(GLOB.stealth_sight_overlays[ckey] == src) GLOB.stealth_sight_overlays -= ckey var/mob/living/carbon/human/npc/guard = guard_ref?.resolve() if(guard) diff --git a/voidcrew/modules/stealth/stealth_ai.dm b/voidcrew/modules/stealth/stealth_ai.dm index 9580dff84bd..8d235f45e69 100644 --- a/voidcrew/modules/stealth/stealth_ai.dm +++ b/voidcrew/modules/stealth/stealth_ai.dm @@ -14,8 +14,12 @@ * can_attack() (no cone, no darkness); out of sight for STEALTH_LOSE_TARGET_TIME, it searches. * - Searching: runs to where it last saw someone and sweeps the spots round it, then gives up. * - * Wary (BB_STEALTH_WARY) is set by any alert, search, hit or surrender and is never cleared: a wider cone - * and quicker notice for the rest of its life (abuse review P4). + * Wary (BB_STEALTH_WARY) is set by any alert, search, hit or surrender: a wider cone and quicker notice + * (abuse review P4). It fades after STEALTH_WARY_TIME with nothing new: every notice gain, hit, touch and + * scan spent alert or searching starts that clock again (owner decision 23). + * + * Putting its target down (dead or in crit) with nobody else noticed ends the alarm: a short line, a look + * round where it stands for STEALTH_CALM_LOOK_TIME, then back to its route (decision 23). * * Every attack or touch gets a reaction, even from out of sight (P3): alert on the attacker if it can see * them, otherwise a search at their turf. Detection is a once-a-second scan owned by this controller, never @@ -110,12 +114,19 @@ var/last_stat = CONSCIOUS /// Admins watching its sight overlay (stealth_admin.dm) var/list/datum/stealth_sight_overlay/watchers + /// REF(mob) => list(notice, turf last scan, world.time last perceived). Not a blackboard key: the + /// blackboard's qdel sweep indexes every nested list by its values, and these hold plain numbers. + var/list/notice_map + /// world.time wary was last set or refreshed + var/wary_at = 0 + /// Searching after putting its target down: world.time it calms down, or 0 + var/calm_until = 0 /datum/ai_controller/basic_controller/human_npc/stealth/TryPossessPawn(atom/new_pawn) . = ..() if(. & AI_CONTROLLER_INCOMPATIBLE) return - blackboard[BB_STEALTH_NOTICE] = list() + notice_map = list() blackboard[BB_STEALTH_BODIES_SEEN] = list() blackboard[BB_STEALTH_STATE_SINCE] = world.time RegisterSignal(new_pawn, COMSIG_LIVING_GET_PULLED, PROC_REF(on_pulled)) @@ -160,6 +171,9 @@ blackboard[BB_STEALTH_STATE_SINCE] = world.time look_until = 0 lost_since = 0 + calm_until = 0 + if(new_state == STEALTH_ALERT || new_state == STEALTH_SEARCHING) + stealth_set_wary() stealth_drop_walk() var/run = (new_state == STEALTH_ALERT || new_state == STEALTH_SEARCHING) stealth_set_pace(run) @@ -167,8 +181,24 @@ for(var/datum/stealth_sight_overlay/overlay as anything in watchers) overlay.refresh() +/// Makes it wary, or starts the wary clock again. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_set_wary() blackboard[BB_STEALTH_WARY] = TRUE + wary_at = world.time + +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_clear_wary() + blackboard[BB_STEALTH_WARY] = FALSE + wary_at = 0 + +/// Each scan: alert or searching keeps it wary; otherwise wary fades once STEALTH_WARY_TIME passes with nothing new. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_update_wary() + if(!blackboard[BB_STEALTH_WARY]) + return + var/state = stealth_state() + if(state == STEALTH_ALERT || state == STEALTH_SEARCHING) + wary_at = world.time + else if(world.time - wary_at >= STEALTH_WARY_TIME) + stealth_clear_wary() /// Walks (calm) or runs, through tg's own move intent, as bounty_ai_set_pace() does. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_set_pace(run) @@ -201,7 +231,7 @@ return clamped /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_unaware() - var/list/notice = blackboard[BB_STEALTH_NOTICE] + var/list/notice = notice_map notice?.Cut() clear_blackboard_key(BB_STEALTH_FOCUS) search_queue?.Cut() @@ -288,6 +318,9 @@ if(blackboard[BB_STEALTH_UNAWARE_LOST_AT]) blackboard[BB_STEALTH_UNAWARE_LOST_AT] -= time last_gain -= time + wary_at -= time + if(calm_until) + calm_until -= time if(lost_since) lost_since -= time wait_until -= time @@ -307,8 +340,12 @@ if(QDELETED(guard) || guard.stat != CONSCIOUS) return stealth_setup() + stealth_update_wary() var/state = stealth_state() - if(state == STEALTH_SEARCHING && world.time - blackboard[BB_STEALTH_STATE_SINCE] >= STEALTH_SEARCH_TIME) + if(state == STEALTH_SEARCHING && calm_until) + if(world.time >= calm_until) + stealth_go_unaware() + else if(state == STEALTH_SEARCHING && world.time - blackboard[BB_STEALTH_STATE_SINCE] >= STEALTH_SEARCH_TIME) stealth_bark(STEALTH_BARK_GIVE_UP) stealth_go_unaware() else if(state == STEALTH_SUSPICIOUS && world.time - last_gain >= STEALTH_SUSPICIOUS_TIMEOUT) @@ -345,7 +382,7 @@ /// Looks for someone new: raises notice on whoever it perceives, decays the rest, changes state. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_look() var/mob/living/carbon/human/guard = pawn - var/list/notice = blackboard[BB_STEALTH_NOTICE] + var/list/notice = notice_map var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) if(isnull(strategy) || !isturf(guard.loc)) return @@ -408,6 +445,7 @@ rate *= STEALTH_SEARCHING_MULT if(wary) rate *= STEALTH_WARY_MULT + wary_at = world.time entry[STEALTH_ENTRY_NOTICE] += rate * scan_seconds if(felt && running) entry[STEALTH_ENTRY_NOTICE] = max(entry[STEALTH_ENTRY_NOTICE], STEALTH_SUSPICIOUS_AT) @@ -477,11 +515,16 @@ if(!target) set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, remembered) return - // In plain sight but no longer worth fighting (down, dead, an ally now): look round where they fell. + // In plain sight but no longer worth fighting. if(get_dist(guard, remembered) <= HUMAN_NPC_SIGHT_RANGE && can_see(guard, remembered, HUMAN_NPC_SIGHT_RANGE)) clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) clear_blackboard_key(BB_STEALTH_FOCUS) - stealth_go_searching(get_turf(remembered)) + var/mob/living/fallen = remembered + if(isliving(fallen) && (fallen.stat != CONSCIOUS || HAS_TRAIT(fallen, TRAIT_INCAPACITATED))) + stealth_target_downed(fallen) + else + // An ally now, or out of reach: look round where it last was. + stealth_go_searching(get_turf(remembered)) return if(!lost_since) lost_since = world.time @@ -491,6 +534,54 @@ stealth_bark(STEALTH_BARK_LOST) stealth_go_searching(blackboard[BB_STEALTH_LAST_SEEN_TURF]) +/** + * Its target is down (decision 23). Anyone else it has noticed keeps the alarm going as usual: alert on + * someone it can see who hit it or was nearly spotted, otherwise suspicious or searching where they were. + * With nobody else it says so, looks round where it stands, and calms down once STEALTH_CALM_LOOK_TIME is + * up. The fallen one is forgotten: if they get up, they are noticed afresh (against a wary guard). + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_target_downed(mob/living/fallen) + var/mob/living/guard = pawn + var/list/notice = notice_map + notice -= REF(fallen) + stealth_set_state(STEALTH_SEARCHING) + search_center = get_turf(guard) + search_queue?.Cut() + search_looking = FALSE + calm_until = world.time + STEALTH_CALM_LOOK_TIME + look_until = calm_until + stand_dir = guard.dir + // Anyone it noticed lately or who hit it during the fight, read before this look decays them. + var/best_notice = 0 + var/turf/best_turf + var/mob/living/best + for(var/key in notice) + var/list/entry = notice[key] + if(entry[STEALTH_ENTRY_NOTICE] < STEALTH_SUSPICIOUS_AT || entry[STEALTH_ENTRY_NOTICE] <= best_notice) + continue + if(world.time - entry[STEALTH_ENTRY_SEEN_AT] > STEALTH_NOTICE_FORGET) + continue + var/mob/living/someone = locate(key) + if(!isliving(someone) || QDELETED(someone) || someone.stat == DEAD) + continue + best_notice = entry[STEALTH_ENTRY_NOTICE] + best_turf = entry[STEALTH_ENTRY_TURF] + best = someone + // One look now: anyone else in view goes the normal way (suspicious or alert). + stealth_look() + if(stealth_state() != STEALTH_SEARCHING || !calm_until) + return + if(best) + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) + if(best_notice >= STEALTH_ALERT_AT && strategy?.can_attack(guard, best, HUMAN_NPC_SIGHT_RANGE)) + stealth_go_alert(best) + else if(best_notice >= STEALTH_ALERT_AT) + stealth_go_searching(best_turf) + else + stealth_go_suspicious(best_turf) + return + stealth_bark(STEALTH_BARK_DOWNED) + /** * Fallen friends (abuse P13): group members that are dead, in crit, cuffed or lying down, in its sight. * It reacts to each body once. The first guard to find one is suspicious and walks over; a guard that @@ -548,6 +639,16 @@ return FALSE stealth_set_wary() if(stealth_state() == STEALTH_ALERT && blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + // Busy with someone else: it remembers who else is hurting it for when that fight ends. + if(isliving(source) && source != blackboard[BB_BASIC_MOB_CURRENT_TARGET]) + var/list/notice = notice_map + var/list/entry = notice[REF(source)] + if(isnull(entry)) + entry = list(0, null, 0) + notice[REF(source)] = entry + entry[STEALTH_ENTRY_NOTICE] = max(entry[STEALTH_ENTRY_NOTICE], STEALTH_ALERT_AT) + entry[STEALTH_ENTRY_TURF] = get_turf(source) + entry[STEALTH_ENTRY_SEEN_AT] = world.time return FALSE var/atom/who = isliving(source) ? bounty_ai_target_for(source) : source var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) @@ -623,7 +724,7 @@ return stealth_go_searching(get_turf(guard)) -// A guard that gives up is wary from then on. +// A guard that gives up is wary. /mob/living/carbon/human/npc/npc_surrender() . = ..() if(.) @@ -695,6 +796,10 @@ controller.queue_behavior(/datum/ai_behavior/stealth_stand) return SUBTREE_RETURN_FINISH_PLANNING if(STEALTH_SEARCHING) + // Its target is down: it looks round where it stands until it calms down. + if(controller.calm_until) + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING if(world.time >= controller.next_search_bark) controller.next_search_bark = world.time + STEALTH_SEARCH_BARK_INTERVAL controller.stealth_bark(STEALTH_BARK_SEARCH) diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth.dm b/voidcrew/modules/unit_tests/voidcrew_stealth.dm index 72adddda140..99debed4084 100644 --- a/voidcrew/modules/unit_tests/voidcrew_stealth.dm +++ b/voidcrew/modules/unit_tests/voidcrew_stealth.dm @@ -1,7 +1,7 @@ // Stealth guards (voidcrew/modules/stealth/). Unit tests compile before voidcrew/_DEFINES, so values from // there are written out: states "unaware", "suspicious", "alert", "searching"; blackboard keys // "stealth_state", "stealth_wary", "stealth_last_seen_turf"; STEALTH_PATROL_MAX_FAILS 3, -// STEALTH_LOSE_TARGET_TIME 4 s, STEALTH_SEARCH_TIME 40 s. +// STEALTH_LOSE_TARGET_TIME 4 s, STEALTH_SEARCH_TIME 40 s, STEALTH_CALM_LOOK_TIME 6 s, STEALTH_WARY_TIME 3 min. #define STEALTH_TEST_GUARD /mob/living/carbon/human/npc/ruin/trooper/scav @@ -320,4 +320,88 @@ TEST_ASSERT(!(group in GLOB.stealth_groups), "A group with no marker and no guards is still registered") qdel(block) +/// Its target dead with nobody else about: a short look round, then unaware. Anyone else in view or hurting it +/// keeps the alarm going. +/datum/unit_test/voidcrew_stealth_calm + +/datum/unit_test/voidcrew_stealth_calm/Run() + var/datum/turf_reservation/block = stealth_block(12, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/mob/living/carbon/human/target = stealth_person(block, 4, 2) + stealth_ai.stealth_go_alert(target) + stealth_scans(guard, 1) + target.death() + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "searching", "A guard that killed its target isn't looking round") + TEST_ASSERT(stealth_ai.calm_until, "A guard that killed its target with nobody about isn't calming down") + TEST_ASSERT_NULL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], "A guard still has a dead target") + stealth_ai.stealth_rewind(7 SECONDS) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A guard didn't calm down after its target died") + TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "A guard that just fought isn't wary") + qdel(guard) + qdel(target) + // Someone else in plain view: no calming down. + guard = stealth_guard(block, 2, 2) + stealth_ai = stealth_ai(guard) + target = stealth_person(block, 4, 2) + var/mob/living/carbon/human/bystander = stealth_person(block, 5, 3) + stealth_ai.stealth_go_alert(target) + target.death() + stealth_scans(guard, 1) + TEST_ASSERT(stealth_ai.stealth_state() == "suspicious" || stealth_ai.stealth_state() == "alert", "A guard calmed down with someone else in front of it") + TEST_ASSERT(!stealth_ai.calm_until, "A guard is calming down with someone else in front of it") + qdel(guard) + qdel(target) + qdel(bystander) + // Someone behind it hit it during the fight: it turns on them. + guard = stealth_guard(block, 6, 2) + stealth_ai = stealth_ai(guard) + target = stealth_person(block, 8, 2) + var/mob/living/carbon/human/flanker = stealth_person(block, 3, 2) + stealth_ai.stealth_go_alert(target) + SEND_SIGNAL(guard, COMSIG_ATOM_WAS_ATTACKED, flanker, ATTACKER_DAMAGING_ATTACK) + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], target, "A hit from someone else pulled the guard off its target") + target.death() + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard calmed down while someone else was hurting it") + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], flanker, "The guard didn't turn on whoever else hit it") + qdel(block) + +/// Wary fades after STEALTH_WARY_TIME with nothing new; a notice gain starts the clock again. +/datum/unit_test/voidcrew_stealth_wary_fade + +/datum/unit_test/voidcrew_stealth_wary_fade/Run() + var/datum/turf_reservation/block = stealth_block(12, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + stealth_ai.stealth_go_searching(stealth_at(block, 3, 3)) + stealth_ai.stealth_rewind(41 SECONDS) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "The search didn't end") + TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "A guard that searched isn't wary") + stealth_ai.stealth_rewind(2 MINUTES) + stealth_scans(guard, 1) + TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "Wary faded too soon") + // A glimpse of someone far off starts the clock again. + var/mob/living/carbon/human/person = stealth_person(block, 9, 2) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A glimpse at the edge of sight made the guard suspicious") + qdel(person) + stealth_ai.stealth_rewind(2 MINUTES) + stealth_scans(guard, 1) + TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "A notice gain didn't refresh wary") + stealth_ai.stealth_rewind(70 SECONDS) + stealth_scans(guard, 1) + TEST_ASSERT(!stealth_ai.blackboard["stealth_wary"], "Wary didn't fade with nothing new") + TEST_ASSERT(stealth_ai.stealth_half_angle() == 60, "A guard no longer wary still has the wary cone") + // A hit makes it wary again. + var/mob/living/carbon/human/attacker = stealth_person(block, 11, 4) + SEND_SIGNAL(guard, COMSIG_ATOM_WAS_ATTACKED, attacker, ATTACKER_DAMAGING_ATTACK) + TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "A hit didn't make the guard wary again") + qdel(block) + #undef STEALTH_TEST_GUARD From b52043a50c6e46a7c7d2a37b3bbb343a4b397b91 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:34:32 -0800 Subject: [PATCH 008/152] Human NPC gunners check the bullet's real path for allies The ally check walked get_line(), which takes one turf per step. A shot at an angle clips turfs beside that line, so a gunner fired past a guard standing next to its target and hit him. The check now walks every turf the straight line passes through. --- voidcrew/modules/human_npcs/human_npc_ai.dm | 38 ++++++++++++++++++- .../modules/unit_tests/voidcrew_human_npcs.dm | 38 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/human_npcs/human_npc_ai.dm b/voidcrew/modules/human_npcs/human_npc_ai.dm index b2aea64e703..fb42989a309 100644 --- a/voidcrew/modules/human_npcs/human_npc_ai.dm +++ b/voidcrew/modules/human_npcs/human_npc_ai.dm @@ -513,7 +513,7 @@ /proc/human_npc_ally_in_line(mob/living/shooter, atom/target) var/turf/shooter_turf = get_turf(shooter) var/turf/target_turf = get_turf(target) - for(var/turf/line_turf as anything in get_line(shooter_turf, target_turf)) + for(var/turf/line_turf as anything in human_npc_shot_path(shooter_turf, target_turf)) if(line_turf == shooter_turf || line_turf == target_turf) continue for(var/mob/living/other in line_turf) @@ -523,6 +523,42 @@ return TRUE return FALSE +/** + * Every turf a shot from the centre of `start_turf` to the centre of `end_turf` passes through, in order. A bullet + * flies the straight line, so at an angle it clips turfs that get_line()'s one-turf-per-step line skips + * (someone beside the target, a tile off that line). Where the line runs exactly through a corner both + * turfs beside it count. + */ +/proc/human_npc_shot_path(turf/start_turf, turf/end_turf) + . = list(start_turf) + if(isnull(start_turf) || isnull(end_turf) || start_turf.z != end_turf.z) + return + var/x_sign = SIGN(end_turf.x - start_turf.x) + var/y_sign = SIGN(end_turf.y - start_turf.y) + var/x_count = abs(end_turf.x - start_turf.x) + var/y_count = abs(end_turf.y - start_turf.y) + var/x = start_turf.x + var/y = start_turf.y + var/x_done = 0 + var/y_done = 0 + while(x_done < x_count || y_done < y_count) + // Which grid line the shot crosses next: a vertical one (< 0), a horizontal one (> 0) or both at a corner. + var/decision = (1 + 2 * x_done) * y_count - (1 + 2 * y_done) * x_count + if(decision == 0) + . += locate(x + x_sign, y, start_turf.z) + . += locate(x, y + y_sign, start_turf.z) + x += x_sign + y += y_sign + x_done++ + y_done++ + else if(decision < 0) + x += x_sign + x_done++ + else + y += y_sign + y_done++ + . += locate(x, y, start_turf.z) + /// One step sideways, keeping no further from the target, for a clear shot. /proc/human_npc_sidestep(mob/living/shooter, atom/target) var/turf/our_turf = get_turf(shooter) diff --git a/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm b/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm index 7916bed68e1..d7fa9db4e58 100644 --- a/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm +++ b/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm @@ -431,3 +431,41 @@ ware.forceMove(elsewhere) TEST_ASSERT(human_npc_wait_deleted(ware), "Chrome taken out of a living NPC stayed working") TEST_ASSERT_NOTNULL(locate(/obj/item/burnt_chrome) in elsewhere, "Chrome taken out of a living NPC left no scrap") + +/// A gunner holds fire with an ally anywhere on the bullet's path, including beside the target where a +/// diagonal shot clips the corner of their turf (owner playtest: a gunner shot the knife guard beside its target). +/datum/unit_test/voidcrew_human_npc_ally_in_line + +/datum/unit_test/voidcrew_human_npc_ally_in_line/Run() + var/turf/corner = run_loc_floor_bottom_left + TEST_ASSERT(run_loc_floor_top_right.x - corner.x >= 3 && run_loc_floor_top_right.y - corner.y >= 4, "Test fixture: the test room is too small") + var/mob/living/carbon/human/npc/sample/gunner/shooter = allocate(/mob/living/carbon/human/npc/sample/gunner, locate(corner.x, corner.y + 4, corner.z)) + var/mob/living/carbon/human/consistent/target = allocate(/mob/living/carbon/human/consistent, locate(corner.x + 3, corner.y, corner.z)) + var/mob/living/carbon/human/consistent/ally = allocate(/mob/living/carbon/human/consistent, locate(corner.x + 3, corner.y + 1, corner.z)) + ally.faction = shooter.faction.Copy() + TEST_ASSERT(!(ally.loc in get_line(shooter, target)), "Test fixture: the ally is on get_line(), so this doesn't test the clipped corner") + TEST_ASSERT(human_npc_ally_in_line(shooter, target), "A gunner fired past an ally standing beside its target") + // Straight down a corridor. + ally.forceMove(locate(corner.x + 2, corner.y + 2, corner.z)) + TEST_ASSERT(human_npc_ally_in_line(shooter, target), "A gunner fired through an ally between it and its target") + // Lying down: shots pass over. + ally.set_body_position(LYING_DOWN) + TEST_ASSERT(!human_npc_ally_in_line(shooter, target), "A gunner held fire for an ally lying down") + ally.set_body_position(STANDING_UP) + // Well off the path. + ally.forceMove(locate(corner.x + 3, corner.y + 4, corner.z)) + TEST_ASSERT(!human_npc_ally_in_line(shooter, target), "A gunner held fire for an ally nowhere near the path") + // A stranger on the path is not an ally. + ally.faction = list("ally_in_line_stranger") + ally.forceMove(locate(corner.x + 2, corner.y + 2, corner.z)) + TEST_ASSERT(!human_npc_ally_in_line(shooter, target), "A gunner held fire for someone not on its side") + +/// An ally's stray shot doesn't turn a human NPC on them. +/datum/unit_test/voidcrew_human_npc_ally_stray_hit + +/datum/unit_test/voidcrew_human_npc_ally_stray_hit/Run() + var/mob/living/carbon/human/npc/sample/gunner/npc = allocate(/mob/living/carbon/human/npc/sample/gunner, run_loc_floor_bottom_left) + var/mob/living/carbon/human/consistent/ally = allocate(/mob/living/carbon/human/consistent, locate(run_loc_floor_bottom_left.x + 1, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z)) + ally.faction = npc.faction.Copy() + SEND_SIGNAL(npc, COMSIG_ATOM_WAS_ATTACKED, ally, ATTACKER_DAMAGING_ATTACK) + TEST_ASSERT_NULL(npc.ai_controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET], "An ally's stray shot made the NPC target them") From f05057abf1bdeea21e3bae7018347850471be244 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:58:34 -0800 Subject: [PATCH 009/152] Stealth guards show a ? or ! over their heads, and wary wears off after 60 s The ? fills as a guard notices you and only you see it. Alert is a red ! for everyone; it drains while the guard has lost you, then turns into a yellow ? that drains over the search. Drawn on the guard, so never through walls, and above the lighting so it reads in the dark. Built only with a player nearby. Wary now fades after 60 seconds with nothing new. --- tgstation.dme | 1 + voidcrew/_DEFINES/stealth.dm | 12 +- voidcrew/icons/effects/stealth_meter.dmi | Bin 0 -> 869 bytes voidcrew/modules/stealth/stealth_ai.dm | 7 + voidcrew/modules/stealth/stealth_meter.dm | 247 ++++++++++++++++++ .../modules/unit_tests/voidcrew_stealth.dm | 93 ++++++- 6 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 voidcrew/icons/effects/stealth_meter.dmi create mode 100644 voidcrew/modules/stealth/stealth_meter.dm diff --git a/tgstation.dme b/tgstation.dme index 5436811288d..40fb9422b1e 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7730,6 +7730,7 @@ #include "voidcrew\modules\stealth\scav_guards.dm" #include "voidcrew\modules\stealth\stealth_admin.dm" #include "voidcrew\modules\stealth\stealth_ai.dm" +#include "voidcrew\modules\stealth\stealth_meter.dm" #include "voidcrew\modules\stealth\stealth_patrol.dm" #include "voidcrew\modules\stealth\stealth_perception.dm" #include "voidcrew\modules\stealth\stealth_test_ruin.dm" diff --git a/voidcrew/_DEFINES/stealth.dm b/voidcrew/_DEFINES/stealth.dm index f479200fef1..b2a833d134e 100644 --- a/voidcrew/_DEFINES/stealth.dm +++ b/voidcrew/_DEFINES/stealth.dm @@ -82,7 +82,7 @@ /// After putting its target down with nobody else about: how long it looks round before calming down #define STEALTH_CALM_LOOK_TIME (6 SECONDS) /// How long wary lasts after the last notice gain, hit, touch, alert or search -#define STEALTH_WARY_TIME (3 MINUTES) +#define STEALTH_WARY_TIME (60 SECONDS) #define STEALTH_SEARCH_POINTS 3 #define STEALTH_SEARCH_RADIUS 5 #define STEALTH_BODY_SCAN_INTERVAL (2 SECONDS) @@ -104,5 +104,15 @@ /// Seconds of search between "Come on out." lines #define STEALTH_SEARCH_BARK_INTERVAL (12 SECONDS) +// The mark over a guard's head (stealth_meter.dm, owner decision 27) +/// Pixel rows in the "?" and "!" glyphs: fill and drain steps +#define STEALTH_METER_ROWS 7 +/// Tiles from the guard within which a living player makes the mark worth drawing +#define STEALTH_METER_RANGE 10 +/// pixel_z of the glyph's bottom over a standing, medium-height guard: 1 px clear of the head +#define STEALTH_METER_HEAD_Z 30 +/// pixel_z of the glyph's bottom over a guard lying down +#define STEALTH_METER_LYING_Z 17 + /// The scav den's side #define FACTION_SCAV "scav" diff --git a/voidcrew/icons/effects/stealth_meter.dmi b/voidcrew/icons/effects/stealth_meter.dmi new file mode 100644 index 0000000000000000000000000000000000000000..d22aa60e03b7204cc951054b9d79489358601a88 GIT binary patch literal 869 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K55;)j^Mqo;508us>{dHL)>e67LG zo8|3w25&cB=l4E%KW($|*;N-_y4S-)7QQ%hyl#1)QbnFi8;v^ zb5bDY@6;2%=3DOT|7JMD_rZ5%U;26m2BvIJ7srr_IY1}n-8K+m2w3@HhN5$u((x0T zMo&0woz7oO{=y?`lkw(;=5Y?2louBa9k_2TRkqL#2-e!Kxp%vSpg{fe=k=2owBIiU z1r8=SP=9UupG#-Y{+_@5{Ji<)uJ2X%{aL+#$G_L!&G+xy%gpCJTz08lApYOg`;~vc zZ>hDk+g*D@m2g_-TCqO=mNHnPwhATo3~B>?(_cmZyzte-XWQNRNnmGlppQo z7QucsB0za>-WhlJT$jeEn8!`AeEdDQPieVz;oEA4kIA3wcL{gy&({0DSy$Ec|IbJJ z@BV!f|F?9jKf~ks+2RX+d|Ll++4uO1stMEj9&FW5ICe>J#W&WTAD{Lw`E`3sjpX(v zrzJi<-M{r0Gp5f#BL6()ZRf|-{0y%zoV@YZi=R&(ov!cx`n{sIw5II)U-zv)>ZSXl zOHFRf`F!%|^#8?6pWUwAaqoJ3m8^L(Z~y+sPmisat@oQP<}TD(Z~4=ox1u1UO{(az z>rU=I`{kRpHRVnJnatnA_A&l*{SD67VoxmoFSlO!lk@)g|i`AWV9p4n#&ieN9_VvH71^zl}q%mg9U`x^BKENV1 z;hO1Qh71Re7=~vv{Owv_9$3aK{6p8_i|(2qpWa6|91w1hy}>7=x$EC9)*q~;CFP>) SiUl5l+~Dcz=d#Wzp$P!?RFvBQ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/stealth/stealth_ai.dm b/voidcrew/modules/stealth/stealth_ai.dm index 8d235f45e69..cfbc9fa46ee 100644 --- a/voidcrew/modules/stealth/stealth_ai.dm +++ b/voidcrew/modules/stealth/stealth_ai.dm @@ -151,6 +151,7 @@ for(var/datum/stealth_sight_overlay/overlay as anything in watchers?.Copy()) qdel(overlay) watchers = null + QDEL_NULL(meter) goto_turf = null investigate_turf = null search_center = null @@ -180,6 +181,7 @@ human_npc_set_ai_movement(src, new_state == STEALTH_ALERT ? /datum/ai_movement/basic_avoidance : /datum/ai_movement/jps/stealth) for(var/datum/stealth_sight_overlay/overlay as anything in watchers) overlay.refresh() + stealth_meter_update() /// Makes it wary, or starts the wary clock again. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_set_wary() @@ -359,6 +361,7 @@ stealth_check_bodies() for(var/datum/stealth_sight_overlay/overlay as anything in watchers) overlay.refresh() + stealth_meter_update() /// The cone half-angle it sees with now. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_half_angle() @@ -699,6 +702,8 @@ /// listens for the level change. /datum/ai_controller/basic_controller/human_npc/stealth/on_changed_z_level(atom/source, turf/old_turf, turf/new_turf, same_z_layer, notify_contents) . = ..() + // Its mark's plane belongs to the old level; the next scan builds a new one. + QDEL_NULL(meter) if(stealth_ready && new_turf) stealth_become_sentry(new_turf) @@ -706,6 +711,8 @@ . = ..() var/was = last_stat last_stat = new_stat + if(new_stat != CONSCIOUS) + QDEL_NULL(meter) if(new_stat == CONSCIOUS && was != CONSCIOUS && was != DEAD) stealth_came_round() diff --git a/voidcrew/modules/stealth/stealth_meter.dm b/voidcrew/modules/stealth/stealth_meter.dm new file mode 100644 index 00000000000..1de75bf3a18 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_meter.dm @@ -0,0 +1,247 @@ +/** + * # Stealth guards: the mark over the head (owner decisions 24 and 27, draft D) + * + * - Its notice of you above 0: a "?" only you see (a client image). It fills yellow from the bottom to + * STEALTH_SUSPICIOUS_AT, then red rises through the yellow toward STEALTH_ALERT_AT. + * - Alert: a solid red "!" everyone sees. Target out of sight: the red drains from the top over + * STEALTH_LOSE_TARGET_TIME and refills when it sees them again. + * - Searching: a yellow "?" everyone sees, draining from the top over the search (or until calm_until + * after it put its target down). Empty: gone. + * - While a public mark shows, nobody sees their own "?" on that guard. + * + * Both are drawn on the guard (vis_contents for the public mark, an image located on the guard for the + * private one), so they show only when the guard itself is in view, never through walls; above the + * lighting so they read in the dark. Fills round to the nearest row; drains round up, so the last row + * stays lit until the time really runs out. + * + * Cost: the meter is built only while a living player is within STEALTH_METER_RANGE of a conscious guard, + * updated by the guard's 1 s scan and its state changes, and changes an icon_state only when the step + * changes. A drain steps on a timer at each row change (at most STEALTH_METER_ROWS per drain); animate() + * is not used, because a chained icon_state animation can't snap back when the target is seen again. + * Dormant guards don't scan, so they build nothing. + */ +/datum/stealth_meter + /// The guard's controller, which owns this meter + var/datum/ai_controller/basic_controller/human_npc/stealth/controller + var/mob/living/guard + /// The public mark, in the guard's vis_contents while it shows + var/obj/effect/abstract/stealth_mark/mark + /// icon_state of the public mark, or null + var/public_state + /// REF(player) => list(image, client it was given to) + var/list/private_marks = list() + /// Timer for the next drain step + var/drain_timer + +/datum/stealth_meter/New(datum/ai_controller/basic_controller/human_npc/stealth/controller, mob/living/guard) + src.controller = controller + src.guard = guard + RegisterSignal(guard, COMSIG_LIVING_SET_BODY_POSITION, PROC_REF(on_body_position)) + +/datum/stealth_meter/Destroy() + if(drain_timer) + deltimer(drain_timer) + drain_timer = null + set_public(null) + for(var/key in private_marks.Copy()) + remove_private(key) + if(guard) + UnregisterSignal(guard, COMSIG_LIVING_SET_BODY_POSITION) + controller = null + guard = null + return ..() + +/// Reads the controller and shows what it says. Changes nothing that hasn't changed. +/datum/stealth_meter/proc/update() + if(QDELETED(guard) || isnull(controller)) + return + var/new_public = stealth_meter_public_state(controller.stealth_state(), world.time, controller.lost_since, controller.blackboard[BB_STEALTH_STATE_SINCE], controller.calm_until) + set_public(new_public) + update_private(new_public ? null : controller.notice_map) + schedule_drain() + +/datum/stealth_meter/proc/set_public(new_state) + if(new_state == public_state) + return + public_state = new_state + if(isnull(new_state)) + if(mark) + guard?.vis_contents -= mark + QDEL_NULL(mark) + return + if(isnull(mark)) + mark = new + SET_PLANE_EXPLICIT(mark, ABOVE_LIGHTING_PLANE, guard) + mark.pixel_z = stealth_meter_offset(guard) + guard.vis_contents += mark + mark.icon_state = new_state + +/// Gives each nearby player their own "?" for their notice entry, and takes away the rest. +/datum/stealth_meter/proc/update_private(list/notice) + var/list/wanted = list() + var/turf/here = get_turf(guard) + if(length(notice) && here) + for(var/key in notice) + var/list/entry = notice[key] + var/state = stealth_meter_notice_state(entry[STEALTH_ENTRY_NOTICE]) + if(isnull(state)) + continue + var/mob/viewer = locate(key) + if(!ismob(viewer) || isnull(viewer.client) || viewer.z != here.z || get_dist(viewer, here) > STEALTH_METER_RANGE) + continue + wanted[key] = state + for(var/key in private_marks.Copy()) + var/list/held = private_marks[key] + var/mob/viewer = locate(key) + if(!wanted[key] || !ismob(viewer) || viewer.client != held[2]) + remove_private(key) + for(var/key in wanted) + var/list/held = private_marks[key] + if(held) + var/image/shown = held[1] + if(shown.icon_state != wanted[key]) + shown.icon_state = wanted[key] + continue + var/mob/viewer = locate(key) + var/image/shown = image('voidcrew/icons/effects/stealth_meter.dmi', guard, wanted[key]) + shown.appearance_flags = RESET_COLOR | RESET_ALPHA | RESET_TRANSFORM | KEEP_APART + shown.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + SET_PLANE_EXPLICIT(shown, ABOVE_LIGHTING_PLANE, guard) + shown.pixel_z = stealth_meter_offset(guard) + viewer.client.images += shown + private_marks[key] = list(shown, viewer.client) + +/datum/stealth_meter/proc/remove_private(key) + var/list/held = private_marks[key] + private_marks -= key + if(!held) + return + var/image/shown = held[1] + var/client/given_to = held[2] + given_to?.images -= shown + shown.loc = null + +/// A draining mark steps at each row change rather than waiting for the next scan. +/datum/stealth_meter/proc/schedule_drain() + if(drain_timer) + deltimer(drain_timer) + drain_timer = null + var/delay = stealth_meter_next_step(controller.stealth_state(), world.time, controller.lost_since, controller.blackboard[BB_STEALTH_STATE_SINCE], controller.calm_until) + if(delay > 0) + drain_timer = addtimer(CALLBACK(src, PROC_REF(on_drain_step)), delay, TIMER_STOPPABLE) + +/datum/stealth_meter/proc/on_drain_step() + drain_timer = null + controller?.stealth_meter_update() + +/datum/stealth_meter/proc/on_body_position(datum/source) + SIGNAL_HANDLER + var/offset = stealth_meter_offset(guard) + if(mark) + mark.pixel_z = offset + for(var/key in private_marks) + var/list/held = private_marks[key] + var/image/shown = held[1] + shown.pixel_z = offset + +/// The public mark. Lives only in its guard's vis_contents. +/obj/effect/abstract/stealth_mark + name = "" + icon = 'voidcrew/icons/effects/stealth_meter.dmi' + icon_state = null + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + appearance_flags = RESET_COLOR | RESET_ALPHA | RESET_TRANSFORM | KEEP_APART + anchored = TRUE + +// ===== The controller's side ===== + +/datum/ai_controller/basic_controller/human_npc/stealth + /// The mark over its head while anyone is near to see it (stealth_meter.dm) + var/datum/stealth_meter/meter + +/// Builds, updates or drops the mark. Nothing while it isn't conscious or nobody living is near. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_meter_update() + var/mob/living/guard = pawn + if(QDELETED(guard) || guard.stat != CONSCIOUS || !stealth_meter_viewers_near(guard)) + QDEL_NULL(meter) + return + if(isnull(meter)) + meter = new(src, guard) + meter.update() + +/// Whether a living player is close enough to the guard to see its mark. +/proc/stealth_meter_viewers_near(mob/living/guard) + var/turf/here = get_turf(guard) + if(isnull(here) || here.z > length(SSmobs.clients_by_zlevel)) + return FALSE + for(var/mob/viewer as anything in SSmobs.clients_by_zlevel[here.z]) + if(get_dist(viewer, here) <= STEALTH_METER_RANGE) + return TRUE + return FALSE + +/// pixel_z for the glyph's bottom over `guard`: clear of its head, lower when it lies down. +/proc/stealth_meter_offset(mob/living/guard) + if(guard.body_position == LYING_DOWN) + return STEALTH_METER_LYING_Z + . = STEALTH_METER_HEAD_Z + if(ishuman(guard)) + var/mob/living/carbon/human/person = guard + var/list/offsets = GLOB.human_heights_to_offsets["[person.mob_height]"] + if(length(offsets)) + . += offsets[1] + offsets[2] + +// ===== Pure: state to icon_state ===== + +/// The private "?" for a notice level, or null for none. Fills round to the nearest row. +/proc/stealth_meter_notice_state(notice) + if(!(notice > 0)) + return null + if(notice < STEALTH_SUSPICIOUS_AT) + return "q_y[clamp(round(notice / STEALTH_SUSPICIOUS_AT * STEALTH_METER_ROWS, 1), 0, STEALTH_METER_ROWS)]" + var/red = clamp(round((notice - STEALTH_SUSPICIOUS_AT) / (STEALTH_ALERT_AT - STEALTH_SUSPICIOUS_AT) * STEALTH_METER_ROWS, 1), 0, STEALTH_METER_ROWS) + return red ? "q_r[red]" : "q_y[STEALTH_METER_ROWS]" + +/// Rows still lit with `time_left` of `total`: rounded up, 0 once the time is up. +/proc/stealth_meter_drain_rows(time_left, total) + if(total <= 0 || time_left <= 0) + return 0 + return clamp(CEILING(time_left / total * STEALTH_METER_ROWS - 0.001, 1), 0, STEALTH_METER_ROWS) + +/// When a public drain ends and how long it lasts, as list(end, total), or null when nothing drains. +/proc/stealth_meter_drain_span(state, lost_since, state_since, calm_until) + switch(state) + if(STEALTH_ALERT) + if(lost_since) + return list(lost_since + STEALTH_LOSE_TARGET_TIME, STEALTH_LOSE_TARGET_TIME) + if(STEALTH_SEARCHING) + var/end = calm_until || (state_since + STEALTH_SEARCH_TIME) + return list(end, end - state_since) + return null + +/// The public mark for a guard's state, or null for none. +/proc/stealth_meter_public_state(state, now, lost_since, state_since, calm_until) + switch(state) + if(STEALTH_ALERT) + if(!lost_since) + return "ex_r[STEALTH_METER_ROWS]" + var/list/span = stealth_meter_drain_span(state, lost_since, state_since, calm_until) + var/rows = stealth_meter_drain_rows(span[1] - now, span[2]) + // Drained: the full searching "?" it is about to become. + return rows ? "ex_r[rows]" : "q_y[STEALTH_METER_ROWS]" + if(STEALTH_SEARCHING) + var/list/span = stealth_meter_drain_span(state, lost_since, state_since, calm_until) + var/rows = stealth_meter_drain_rows(span[1] - now, span[2]) + return rows ? "q_y[rows]" : null + return null + +/// Deciseconds until a draining public mark loses its next row, or 0 when nothing drains. +/proc/stealth_meter_next_step(state, now, lost_since, state_since, calm_until) + var/list/span = stealth_meter_drain_span(state, lost_since, state_since, calm_until) + if(isnull(span) || span[2] <= 0) + return 0 + var/time_left = span[1] - now + var/rows = stealth_meter_drain_rows(time_left, span[2]) + if(rows <= 0) + return 0 + // The row goes when the time left reaches (rows - 1) sevenths; a tick later to land past it. + return max(world.tick_lag, time_left - (rows - 1) * span[2] / STEALTH_METER_ROWS + world.tick_lag) diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth.dm b/voidcrew/modules/unit_tests/voidcrew_stealth.dm index 99debed4084..335867c5e48 100644 --- a/voidcrew/modules/unit_tests/voidcrew_stealth.dm +++ b/voidcrew/modules/unit_tests/voidcrew_stealth.dm @@ -1,7 +1,7 @@ // Stealth guards (voidcrew/modules/stealth/). Unit tests compile before voidcrew/_DEFINES, so values from // there are written out: states "unaware", "suspicious", "alert", "searching"; blackboard keys // "stealth_state", "stealth_wary", "stealth_last_seen_turf"; STEALTH_PATROL_MAX_FAILS 3, -// STEALTH_LOSE_TARGET_TIME 4 s, STEALTH_SEARCH_TIME 40 s, STEALTH_CALM_LOOK_TIME 6 s, STEALTH_WARY_TIME 3 min. +// STEALTH_LOSE_TARGET_TIME 4 s, STEALTH_SEARCH_TIME 40 s, STEALTH_CALM_LOOK_TIME 6 s, STEALTH_WARY_TIME 60 s. #define STEALTH_TEST_GUARD /mob/living/carbon/human/npc/ruin/trooper/scav @@ -383,7 +383,7 @@ stealth_scans(guard, 1) TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "The search didn't end") TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "A guard that searched isn't wary") - stealth_ai.stealth_rewind(2 MINUTES) + stealth_ai.stealth_rewind(40 SECONDS) stealth_scans(guard, 1) TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "Wary faded too soon") // A glimpse of someone far off starts the clock again. @@ -391,10 +391,10 @@ stealth_scans(guard, 1) TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A glimpse at the edge of sight made the guard suspicious") qdel(person) - stealth_ai.stealth_rewind(2 MINUTES) + stealth_ai.stealth_rewind(40 SECONDS) stealth_scans(guard, 1) TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "A notice gain didn't refresh wary") - stealth_ai.stealth_rewind(70 SECONDS) + stealth_ai.stealth_rewind(30 SECONDS) stealth_scans(guard, 1) TEST_ASSERT(!stealth_ai.blackboard["stealth_wary"], "Wary didn't fade with nothing new") TEST_ASSERT(stealth_ai.stealth_half_angle() == 60, "A guard no longer wary still has the wary cone") @@ -404,4 +404,89 @@ TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "A hit didn't make the guard wary again") qdel(block) +/// The mark over a guard's head: notice and state to icon_state (draft D: fills round to nearest, drains round up). +/datum/unit_test/voidcrew_stealth_meter_states + +/datum/unit_test/voidcrew_stealth_meter_states/Run() + // Private "?": yellow to 1.0, then red to 2.5. + TEST_ASSERT_NULL(stealth_meter_notice_state(0), "No notice still shows a mark") + TEST_ASSERT_EQUAL(stealth_meter_notice_state(0.05), "q_y0", "A little notice isn't an empty grey ?") + TEST_ASSERT_EQUAL(stealth_meter_notice_state(0.3), "q_y2", "0.3 notice") + TEST_ASSERT_EQUAL(stealth_meter_notice_state(0.5), "q_y4", "Half notice doesn't round to the nearest row") + TEST_ASSERT_EQUAL(stealth_meter_notice_state(1), "q_y7", "Suspicious isn't a full yellow ?") + TEST_ASSERT_EQUAL(stealth_meter_notice_state(1.75), "q_r4", "Halfway to alert isn't half red") + TEST_ASSERT_EQUAL(stealth_meter_notice_state(2.5), "q_r7", "Alert notice isn't a full red ?") + TEST_ASSERT_EQUAL(stealth_meter_notice_state(4), "q_r7", "Notice past alert overflows the glyph") + // Public marks. Times in deciseconds; now = 10000. + TEST_ASSERT_NULL(stealth_meter_public_state("unaware", 10000, 0, 9000, 0), "An unaware guard has a public mark") + TEST_ASSERT_NULL(stealth_meter_public_state("suspicious", 10000, 0, 9000, 0), "A suspicious guard has a public mark") + TEST_ASSERT_EQUAL(stealth_meter_public_state("alert", 10000, 0, 9000, 0), "ex_r7", "Alert with the target in sight isn't a full red !") + TEST_ASSERT_EQUAL(stealth_meter_public_state("alert", 10000, 9980, 9000, 0), "ex_r4", "Target lost 2 of 4 s: the ! doesn't drain rounding up") + TEST_ASSERT_EQUAL(stealth_meter_public_state("alert", 10000, 9961, 9000, 0), "ex_r1", "The last row of the ! went out early") + TEST_ASSERT_EQUAL(stealth_meter_public_state("alert", 10000, 9960, 9000, 0), "q_y7", "A drained ! doesn't become the yellow ?") + TEST_ASSERT_EQUAL(stealth_meter_public_state("searching", 10000, 0, 10000, 0), "q_y7", "A fresh search isn't a full yellow ?") + TEST_ASSERT_EQUAL(stealth_meter_public_state("searching", 10000, 0, 9800, 0), "q_y4", "Halfway through the 40 s search") + TEST_ASSERT_NULL(stealth_meter_public_state("searching", 10000, 0, 9600, 0), "A finished search still shows") + TEST_ASSERT_EQUAL(stealth_meter_public_state("searching", 10000, 0, 9970, 10030), "q_y4", "Calming down doesn't drain to calm_until") + // The drain steps at each row: the full ! loses its top row after 1/7 of 4 s. + var/step = stealth_meter_next_step("alert", 10000, 10000, 9000, 0) + TEST_ASSERT(step > 40 / 7 && step < 40 / 7 + 1, "The first drain step isn't at 1/7 of the lose time (got [step])") + TEST_ASSERT_EQUAL(stealth_meter_next_step("alert", 10000, 0, 9000, 0), 0, "A steady ! schedules a drain") + +/// The mark goes with its guard: death, deletion, and the public mark hides the private one. +/datum/unit_test/voidcrew_stealth_meter_cleanup + +/datum/unit_test/voidcrew_stealth_meter_cleanup/Run() + var/datum/turf_reservation/block = stealth_block(8, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/mob/living/carbon/human/person = stealth_person(block, 4, 2) + stealth_ai.stealth_go_alert(person) + // No client is near in a test, so build it by hand. + stealth_ai.meter = new /datum/stealth_meter(stealth_ai, guard) + var/datum/stealth_meter/meter = stealth_ai.meter + meter.update() + var/obj/effect/abstract/stealth_mark/mark = meter.mark + TEST_ASSERT_NOTNULL(mark, "An alert guard has no mark") + TEST_ASSERT_EQUAL(mark.icon_state, "ex_r7", "An alert guard's mark isn't a full red !") + TEST_ASSERT(mark in guard.vis_contents, "The mark isn't drawn on the guard") + TEST_ASSERT_EQUAL(mark.pixel_z, stealth_meter_offset(guard), "The mark isn't just over a standing guard's head") + TEST_ASSERT(mark.pixel_z >= 25, "The mark sits in a standing guard's face") + guard.set_body_position(LYING_DOWN) + TEST_ASSERT_EQUAL(mark.pixel_z, 17, "The mark didn't drop when the guard lay down") + guard.set_body_position(STANDING_UP) + guard.death() + TEST_ASSERT(QDELETED(meter), "A dead guard kept its mark") + TEST_ASSERT(!(mark in guard.vis_contents), "A dead guard still draws its mark") + TEST_ASSERT(QDELETED(mark), "A dead guard's mark wasn't deleted") + // Deletion. + var/mob/living/carbon/human/npc/second = stealth_guard(block, 6, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/second_ai = stealth_ai(second) + second_ai.stealth_go_searching(stealth_at(block, 6, 3)) + second_ai.meter = new /datum/stealth_meter(second_ai, second) + meter = second_ai.meter + meter.update() + mark = meter.mark + TEST_ASSERT_EQUAL(mark?.icon_state, "q_y7", "A searching guard's mark isn't a full yellow ?") + qdel(second) + TEST_ASSERT(QDELETED(meter), "A deleted guard's meter lived on") + TEST_ASSERT(QDELETED(mark), "A deleted guard's mark lived on") + qdel(block) + +/// A stealth guard hit by an ally's stray shot doesn't turn on them or raise the alarm. +/datum/unit_test/voidcrew_stealth_ally_hit + +/datum/unit_test/voidcrew_stealth_ally_hit/Run() + var/datum/turf_reservation/block = stealth_block(6, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/mob/living/carbon/human/npc/ally = stealth_guard(block, 4, 2, WEST) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + SEND_SIGNAL(guard, COMSIG_ATOM_WAS_ATTACKED, ally, ATTACKER_DAMAGING_ATTACK) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "An ally's stray shot raised the alarm") + TEST_ASSERT_NULL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], "An ally's stray shot made the guard target them") + TEST_ASSERT(!stealth_ai.blackboard["stealth_wary"], "An ally's stray shot made the guard wary") + qdel(block) + #undef STEALTH_TEST_GUARD From baeb3bd89f1cffac928b99b93e5c5f599978824f Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:47:50 -0800 Subject: [PATCH 010/152] Raise the guard's head mark clear of its head Standing marks sit 6 px higher and follow the head's height offset; a lying guard's mark clears its body. No own ? shows in the moment after a search ends. --- voidcrew/_DEFINES/stealth.dm | 8 ++++---- voidcrew/modules/stealth/stealth_meter.dm | 13 +++++++++---- voidcrew/modules/unit_tests/voidcrew_stealth.dm | 4 ++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/voidcrew/_DEFINES/stealth.dm b/voidcrew/_DEFINES/stealth.dm index b2a833d134e..76b3bb2e977 100644 --- a/voidcrew/_DEFINES/stealth.dm +++ b/voidcrew/_DEFINES/stealth.dm @@ -109,10 +109,10 @@ #define STEALTH_METER_ROWS 7 /// Tiles from the guard within which a living player makes the mark worth drawing #define STEALTH_METER_RANGE 10 -/// pixel_z of the glyph's bottom over a standing, medium-height guard: 1 px clear of the head -#define STEALTH_METER_HEAD_Z 30 -/// pixel_z of the glyph's bottom over a guard lying down -#define STEALTH_METER_LYING_Z 17 +/// pixel_z of the glyph's bottom over a standing, medium-height guard: a clear gap over the hair +#define STEALTH_METER_HEAD_Z 36 +/// pixel_z of the glyph's bottom over a guard lying down (its sprite also drops PIXEL_Y_OFFSET_LYING) +#define STEALTH_METER_LYING_Z 29 /// The scav den's side #define FACTION_SCAV "scav" diff --git a/voidcrew/modules/stealth/stealth_meter.dm b/voidcrew/modules/stealth/stealth_meter.dm index 1de75bf3a18..2ef21c83009 100644 --- a/voidcrew/modules/stealth/stealth_meter.dm +++ b/voidcrew/modules/stealth/stealth_meter.dm @@ -7,7 +7,7 @@ * STEALTH_LOSE_TARGET_TIME and refills when it sees them again. * - Searching: a yellow "?" everyone sees, draining from the top over the search (or until calm_until * after it put its target down). Empty: gone. - * - While a public mark shows, nobody sees their own "?" on that guard. + * - While alert or searching, nobody sees their own "?" on that guard. * * Both are drawn on the guard (vis_contents for the public mark, an image located on the guard for the * private one), so they show only when the guard itself is in view, never through walls; above the @@ -55,9 +55,13 @@ /datum/stealth_meter/proc/update() if(QDELETED(guard) || isnull(controller)) return - var/new_public = stealth_meter_public_state(controller.stealth_state(), world.time, controller.lost_since, controller.blackboard[BB_STEALTH_STATE_SINCE], controller.calm_until) + var/state = controller.stealth_state() + var/new_public = stealth_meter_public_state(state, world.time, controller.lost_since, controller.blackboard[BB_STEALTH_STATE_SINCE], controller.calm_until) set_public(new_public) - update_private(new_public ? null : controller.notice_map) + // Alert and searching own the mark even once it has drained: no private "?" in the moment before the scan + // sends it back to unaware (and clears its notice). + var/public_owned = new_public || state == STEALTH_ALERT || state == STEALTH_SEARCHING + update_private(public_owned ? null : controller.notice_map) schedule_drain() /datum/stealth_meter/proc/set_public(new_state) @@ -186,9 +190,10 @@ . = STEALTH_METER_HEAD_Z if(ishuman(guard)) var/mob/living/carbon/human/person = guard + // The head rides the upper body offset (measured in a live client: a dwarf's head sits 5 px lower, not 9). var/list/offsets = GLOB.human_heights_to_offsets["[person.mob_height]"] if(length(offsets)) - . += offsets[1] + offsets[2] + . += offsets[1] // ===== Pure: state to icon_state ===== diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth.dm b/voidcrew/modules/unit_tests/voidcrew_stealth.dm index 335867c5e48..5fd22bed15f 100644 --- a/voidcrew/modules/unit_tests/voidcrew_stealth.dm +++ b/voidcrew/modules/unit_tests/voidcrew_stealth.dm @@ -452,9 +452,9 @@ TEST_ASSERT_EQUAL(mark.icon_state, "ex_r7", "An alert guard's mark isn't a full red !") TEST_ASSERT(mark in guard.vis_contents, "The mark isn't drawn on the guard") TEST_ASSERT_EQUAL(mark.pixel_z, stealth_meter_offset(guard), "The mark isn't just over a standing guard's head") - TEST_ASSERT(mark.pixel_z >= 25, "The mark sits in a standing guard's face") + TEST_ASSERT(mark.pixel_z > 29, "The mark sits in a standing guard's face") guard.set_body_position(LYING_DOWN) - TEST_ASSERT_EQUAL(mark.pixel_z, 17, "The mark didn't drop when the guard lay down") + TEST_ASSERT_EQUAL(mark.pixel_z, 29, "The mark didn't drop when the guard lay down") guard.set_body_position(STANDING_UP) guard.death() TEST_ASSERT(QDELETED(meter), "A dead guard kept its mark") From 4a601457cabc01cc510985ce80162403bf850611 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:03:11 -0800 Subject: [PATCH 011/152] Scav guard barks roll once for a line or an emote --- voidcrew/modules/stealth/scav_guards.dm | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/voidcrew/modules/stealth/scav_guards.dm b/voidcrew/modules/stealth/scav_guards.dm index 82bf667f0de..5566367635e 100644 --- a/voidcrew/modules/stealth/scav_guards.dm +++ b/voidcrew/modules/stealth/scav_guards.dm @@ -31,20 +31,24 @@ ) chances = list(RUIN_NPC_BARK_SPOT = 100) -// A line and an emote together read as a script: for the stealth kinds, now one and now the other. -/datum/human_npc_voice/scav/emotes_for(kind) - . = ..() - if(length(.) && length(lines_for(kind)) && prob(60)) - return null +/datum/human_npc_voice/scav + /// For the bark being said now: "line" or "emote" when its kind has both, else null + var/say_as +// A line and an emote together read as a script: a kind with both gets one or the other, picked once a bark. // The spot line always comes, even right after a stealth line. /datum/human_npc_voice/scav/bark(mob/living/carbon/human/npc/speaker, kind, force = FALSE) - return ..(speaker, kind, force || kind == RUIN_NPC_BARK_SPOT) + say_as = null + if(length(lines[kind]) && length(emotes[kind])) + say_as = (human_npc_can_talk(speaker) && prob(70)) ? "line" : "emote" + . = ..(speaker, kind, force || kind == RUIN_NPC_BARK_SPOT) + say_as = null + +/datum/human_npc_voice/scav/emotes_for(kind) + return say_as == "line" ? null : ..() /datum/human_npc_voice/scav/lines_for(kind) - . = ..() - if(length(.) && length(emotes[kind]) && prob(30)) - return null + return say_as == "emote" ? null : ..() /mob/living/carbon/human/npc/ruin/trooper/scav name = "scav guard" From 5f714190c16f9995a2e7e199054b3610acaa9633 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:03:11 -0800 Subject: [PATCH 012/152] Stealth guards: fix the review findings Guards walk back to their route or post after a chase instead of turning sentry, in legs when it is far. Only being carried to another level or map region makes a sentry; a guard that can't get back waits and retries. The AI stays fully awake while any living player is on the level, the scan runs from planning instead of the behaviour queue, and long walks are no longer cancelled every step. An alert guard still notices others and turns on someone clearly nearer or on whoever hits it, at most once every few seconds. A glimpse while searching is an alert. Notice is capped, and only what it sees now decides its state. The candidate cap counts only those it can perceive. A box that moves at its shoulder or bumps it is noticed. Blasts, fire, gas and stuns with nobody to blame send it and nearby friends searching. Cuffs, syringes, the strip menu and thrown items count as touches. The head mark exists only while the scan runs, so it clears on cuffs, stuns and player control. A player in the body gets no reactions. --- voidcrew/_DEFINES/stealth.dm | 23 +- voidcrew/modules/stealth/stealth_ai.dm | 710 ++++++++++++++---- voidcrew/modules/stealth/stealth_meter.dm | 60 +- voidcrew/modules/stealth/stealth_patrol.dm | 144 ++-- .../modules/stealth/stealth_perception.dm | 44 +- 5 files changed, 763 insertions(+), 218 deletions(-) diff --git a/voidcrew/_DEFINES/stealth.dm b/voidcrew/_DEFINES/stealth.dm index 76b3bb2e977..28a9ff9ab02 100644 --- a/voidcrew/_DEFINES/stealth.dm +++ b/voidcrew/_DEFINES/stealth.dm @@ -17,14 +17,10 @@ #define BB_STEALTH_WARY "stealth_wary" /// The candidate with the highest notice #define BB_STEALTH_FOCUS "stealth_focus" -/// Where it last saw (or was hit from) whoever it is after -#define BB_STEALTH_LAST_SEEN_TURF "stealth_last_seen_turf" -/// world.time of that sighting +/// world.time it last saw (or was hit from) whoever it is after; the turf is the controller's last_seen_turf #define BB_STEALTH_LAST_SEEN_AT "stealth_last_seen_at" /// REFs of fallen group members it has already reacted to #define BB_STEALTH_BODIES_SEEN "stealth_bodies_seen" -/// Its claimed stop, or the sentry's post -#define BB_STEALTH_HOME_TURF "stealth_home_turf" // Notice entry fields (the controller's notice_map) #define STEALTH_ENTRY_NOTICE 1 @@ -64,6 +60,8 @@ #define STEALTH_NOTICE_MIN_FALLOFF 0.25 #define STEALTH_SUSPICIOUS_AT 1.0 #define STEALTH_ALERT_AT 2.5 +/// Notice never climbs past this, so nobody banks a lead that hides someone else +#define STEALTH_NOTICE_MAX STEALTH_ALERT_AT #define STEALTH_NOTICE_DECAY 0.34 #define STEALTH_NOTICE_FORGET (20 SECONDS) #define STEALTH_BEHIND_RATE 0.4 @@ -100,6 +98,21 @@ #define STEALTH_PATROL_SKIP_TIME (2 MINUTES) #define STEALTH_PATROL_MAX_PATH 60 #define STEALTH_LEASH_RANGE 24 +/// Tiles a single walk covers; a destination further off is walked in legs +#define STEALTH_WALK_LEG 40 +/// A sentry that can't get back to its post tries again this often +#define STEALTH_HOME_RETRY_TIME (30 SECONDS) +/// Alert: someone else must be this many tiles nearer than its target before it turns on them +#define STEALTH_SWITCH_MARGIN 2 +/// Alert: the shortest time between two changes of target +#define STEALTH_SWITCH_COOLDOWN (4 SECONDS) +/// Health lost within STEALTH_HARM_WINDOW, with nobody to blame, that sends it looking +#define STEALTH_HARM_HEALTH 10 +#define STEALTH_HARM_WINDOW (10 SECONDS) +/// The shortest time between two reactions to harm nobody can be blamed for +#define STEALTH_HARM_COOLDOWN (10 SECONDS) +/// How far its group hears it being blown up, burned, gassed or stunned +#define STEALTH_HARM_ALERT_RANGE 9 #define STEALTH_ADMIN_OVERLAY_TIME (5 MINUTES) /// Seconds of search between "Come on out." lines #define STEALTH_SEARCH_BARK_INTERVAL (12 SECONDS) diff --git a/voidcrew/modules/stealth/stealth_ai.dm b/voidcrew/modules/stealth/stealth_ai.dm index cfbc9fa46ee..fd3dcbc1e69 100644 --- a/voidcrew/modules/stealth/stealth_ai.dm +++ b/voidcrew/modules/stealth/stealth_ai.dm @@ -1,3 +1,23 @@ +/// Signals that mean someone has their hands on it: each names them first. +#define STEALTH_HANDLED_SIGNALS list(\ + COMSIG_CARBON_HELP_ACT,\ + COMSIG_CARBON_CUFF_ATTEMPTED,\ + COMSIG_LIVING_TRY_SYRINGE_INJECT,\ + COMSIG_LIVING_TRY_SYRINGE_WITHDRAW,\ + COMSIG_BEING_STRIPPED,\ + COMSIG_ATOM_ITEM_INTERACTION,\ + COMSIG_ATOM_ITEM_INTERACTION_SECONDARY,\ +) +/// tg's signals for a stun, knockdown or sleep about to land: each carries its length first. +#define STEALTH_STUN_SIGNALS list(\ + COMSIG_LIVING_STATUS_STUN,\ + COMSIG_LIVING_STATUS_KNOCKDOWN,\ + COMSIG_LIVING_STATUS_IMMOBILIZE,\ + COMSIG_LIVING_STATUS_PARALYZE,\ + COMSIG_LIVING_STATUS_UNCONSCIOUS,\ + COMSIG_LIVING_STATUS_SLEEP,\ +) + /** * # Stealth guards: awareness * @@ -11,8 +31,11 @@ * - Suspicious: saw something. Turns to it, says so, stares a moment, walks over and looks around, then * gives up and goes back to its route. * - Alert: has a target and fights as every human NPC does. It keeps the target all round with tg's - * can_attack() (no cone, no darkness); out of sight for STEALTH_LOSE_TARGET_TIME, it searches. - * - Searching: runs to where it last saw someone and sweeps the spots round it, then gives up. + * can_attack() (no cone, no darkness); out of sight for STEALTH_LOSE_TARGET_TIME, it searches. It still + * notices others, and turns on someone clearly nearer or on anyone who hits it, at most once per + * STEALTH_SWITCH_COOLDOWN (decision 29). + * - Searching: runs to where it last saw someone and sweeps the spots round it, then gives up. A glimpse + * of anyone within STEALTH_ALERT_MAX_RANGE while it searches is an alert at once. * * Wary (BB_STEALTH_WARY) is set by any alert, search, hit or surrender: a wider cone and quicker notice * (abuse review P4). It fades after STEALTH_WARY_TIME with nothing new: every notice gain, hit, touch and @@ -22,10 +45,16 @@ * round where it stands for STEALTH_CALM_LOOK_TIME, then back to its route (decision 23). * * Every attack or touch gets a reaction, even from out of sight (P3): alert on the attacker if it can see - * them, otherwise a search at their turf. Detection is a once-a-second scan owned by this controller, never - * tg's movement-triggered target field (P1). Nothing here runs while the AI is off (no player on the - * level); timers are world.time stamps, so a guard that wakes after its search ran out steps straight - * back to unaware and stays wary (T1). + * them, otherwise a search at their turf. Harm that names nobody (a blast, fire, gas, a flashbang) sends + * it and the friends near it searching where it happened. Detection is a once-a-second scan owned by this + * controller, never tg's movement-triggered target field (P1). + * + * The AI runs while any living player is on its level (can_idle is off: tg's idle mode starved its walks). + * With no player on the level tg switches it off and nothing here runs; timers are world.time stamps, so a + * guard that wakes after its search ran out steps straight back to unaware and stays wary (T1). + * + * A player in the body (an admin driving it) gets none of this: no reactions, no forced run, no marks. The + * guard picks up again, unaware, when they leave. */ /datum/ai_controller/basic_controller/human_npc/stealth blackboard = list( @@ -40,12 +69,16 @@ // The patrol subtree decides what an unaware guard does; no random walk. idle_behavior = null ai_movement = /datum/ai_movement/jps/stealth + // tg drops every action, on each step, while the walk's end is further off than this. Its 14 is shorter + // than a patrol leg or a walk home. The alert chase never leaned on it: stealth_keep_target() ends a chase. + max_target_distance = STEALTH_PATROL_MAX_PATH + can_idle = FALSE planning_subtrees = list( + /datum/ai_planning_subtree/stealth_perception, /datum/ai_planning_subtree/human_npc_soft_crit, /datum/ai_planning_subtree/escape_captivity, /datum/ai_planning_subtree/human_npc_walk_out, /datum/ai_planning_subtree/human_npc_surrender, - /datum/ai_planning_subtree/stealth_perception, /datum/ai_planning_subtree/stealth_react, /datum/ai_planning_subtree/human_npc_arm, /datum/ai_planning_subtree/human_npc_shoot, @@ -64,6 +97,11 @@ var/route_step = 1 /// The way a sentry faces at its post var/sentry_dir = SOUTH + /// The stop it last reached, or the sentry's post. Investigations and searches stay within + /// STEALTH_LEASH_RANGE of it. A var, not a blackboard key: the blackboard drops a turf when it changes. + var/turf/home_turf + /// world.time a sentry next tries to walk back to a post it couldn't reach + var/home_retry_at = 0 /// Stop index => failed tries to reach it var/list/stop_fails = list() /// Stop index => world.time it may be tried again @@ -72,11 +110,13 @@ var/wait_until = 0 /// Whether it has done its arrival at the current stop var/at_stop = FALSE - /// Where its current stealth walk goes + /// Where its current stealth walk ends + var/turf/goto_dest + /// The turf it is walking to now: goto_dest, an open tile beside it, or the end of one leg of a long walk var/turf/goto_turf /// What that walk is for (STEALTH_GOTO_*) var/goto_purpose - /// "arrived" or "failed" once the walk is over, else null + /// "arrived" or "failed" once the walk to goto_dest is over, else null var/goto_result /// Set while it drops its own walk, so a dropped walk doesn't count as a failure var/dropping_walk = FALSE @@ -94,8 +134,10 @@ var/turf/investigate_turf /// Suspicious: whether it has walked over and looked var/investigated = FALSE - /// world.time of the last notice gain on its focus (suspicion holds STEALTH_SUSPICIOUS_TIMEOUT after it) + /// world.time of the last notice gain it saw (suspicion holds STEALTH_SUSPICIOUS_TIMEOUT after it) var/last_gain = 0 + /// Where it last saw (or was hit from) whoever it is after + var/turf/last_seen_turf /// Searching: the spot it searches round var/turf/search_center /// Searching: the spots still to check @@ -106,6 +148,10 @@ var/next_search_bark = 0 /// Alert: world.time it first failed to see its target in this stretch, or 0 var/lost_since = 0 + /// Alert: world.time it may next change target + var/next_switch_at = 0 + /// world.time of its next scan + var/next_scan = 0 /// world.time of its next look for fallen friends var/next_body_scan = 0 /// Bark kind => world.time it may say that kind again @@ -117,10 +163,25 @@ /// REF(mob) => list(notice, turf last scan, world.time last perceived). Not a blackboard key: the /// blackboard's qdel sweep indexes every nested list by its values, and these hold plain numbers. var/list/notice_map + /// REF(mob) => the turf the box, locker or bag it hides in stood on at the last scan (only those close by) + var/list/hidden_spots /// world.time wary was last set or refreshed var/wary_at = 0 /// Searching after putting its target down: world.time it calms down, or 0 var/calm_until = 0 + /// world.time someone last touched or hit it: harm at about that time is theirs, not a mystery + var/explained_at = 0 + /// Whether harm with nobody named is waiting to be judged + var/harm_pending = FALSE + /// How long before that harm a touch still explains it + var/harm_window = 0 + /// Where it stood when that harm landed + var/turf/harm_turf + /// world.time it may next react to harm with nobody named + var/next_harm_at = 0 + /// Its health when it last had nothing to explain, and when that was + var/health_mark + var/health_mark_at = 0 /datum/ai_controller/basic_controller/human_npc/stealth/TryPossessPawn(atom/new_pawn) . = ..() @@ -131,8 +192,11 @@ blackboard[BB_STEALTH_STATE_SINCE] = world.time RegisterSignal(new_pawn, COMSIG_LIVING_GET_PULLED, PROC_REF(on_pulled)) RegisterSignal(new_pawn, COMSIG_ATOM_BUMPED, PROC_REF(on_bumped)) - RegisterSignal(new_pawn, COMSIG_CARBON_HELP_ACT, PROC_REF(on_helped)) + RegisterSignals(new_pawn, STEALTH_HANDLED_SIGNALS, PROC_REF(on_handled)) + RegisterSignal(new_pawn, COMSIG_ATOM_PREHITBY, PROC_REF(on_prehitby)) RegisterSignal(new_pawn, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_projectile_prehit)) + RegisterSignals(new_pawn, list(COMSIG_ATOM_EX_ACT, COMSIG_LIVING_IGNITED, COMSIG_MOB_FLASHED), PROC_REF(on_harmed)) + RegisterSignals(new_pawn, STEALTH_STUN_SIGNALS, PROC_REF(on_stunned)) RegisterSignal(new_pawn, COMSIG_LIVING_STATUS_REMOVED, PROC_REF(on_status_removed)) RegisterSignal(new_pawn, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), PROC_REF(on_target_set)) @@ -141,28 +205,82 @@ UnregisterSignal(pawn, list( COMSIG_LIVING_GET_PULLED, COMSIG_ATOM_BUMPED, - COMSIG_CARBON_HELP_ACT, + COMSIG_ATOM_PREHITBY, COMSIG_PROJECTILE_PREHIT, + COMSIG_ATOM_EX_ACT, + COMSIG_LIVING_IGNITED, + COMSIG_MOB_FLASHED, COMSIG_LIVING_STATUS_REMOVED, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), - )) + ) + STEALTH_HANDLED_SIGNALS + STEALTH_STUN_SIGNALS) group?.remove_member(pawn) group = null for(var/datum/stealth_sight_overlay/overlay as anything in watchers?.Copy()) qdel(overlay) watchers = null QDEL_NULL(meter) + home_turf = null + last_seen_turf = null + harm_turf = null goto_turf = null + goto_dest = null investigate_turf = null search_center = null search_queue = null + hidden_spots = null return ..() +// With the AI off (nobody on the level, stunned, in crit, a player in the body) nothing scans, so no mark. +/datum/ai_controller/basic_controller/human_npc/stealth/set_ai_status(new_ai_status, additional_flags = NONE) + . = ..() + if(ai_status == AI_STATUS_OFF) + QDEL_NULL(meter) + +/datum/ai_controller/basic_controller/human_npc/stealth/on_sentience_gained() + . = ..() + QDEL_NULL(meter) + +// The player has left the body: it is a guard again, with nothing on its mind. +/datum/ai_controller/basic_controller/human_npc/stealth/on_sentience_lost() + . = ..() + addtimer(CALLBACK(src, PROC_REF(stealth_take_back)), 1) + +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_take_back() + var/mob/living/guard = pawn + if(QDELETED(guard) || guard.client) + return + clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + if(stealth_ready && guard.stat != DEAD) + stealth_go_unaware() + reset_ai_status() + // ===== State ===== /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_state() return blackboard[BB_STEALTH_STATE] +/// Whether a player is in the body. Nothing here reacts, moves or marks it then. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_driven() + var/mob/living/guard = pawn + return !isnull(guard?.client) + +/// Cuffed, held hard, strapped to something or shut in something: tg's escape_captivity has it and it doesn't scan. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_captive() + var/mob/living/guard = pawn + if(!isturf(guard.loc) || HAS_TRAIT(guard, TRAIT_RESTRAINED)) + return TRUE + if(guard.buckled && !ismob(guard.buckled)) + return TRUE + var/mob/living/holder = guard.pulledby + return istype(holder) && holder.grab_state > GRAB_PASSIVE + +/// Whether its scan is running. Its head mark shows only then, so a mark never outlives the scan that feeds it. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_scanning() + var/mob/living/guard = pawn + if(QDELETED(guard) || guard.stat != CONSCIOUS || guard.client || ai_status == AI_STATUS_OFF) + return FALSE + return !stealth_captive() + /// Changes state, stamping the time, and drops whatever walk it was on. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_set_state(new_state) var/old_state = blackboard[BB_STEALTH_STATE] @@ -176,8 +294,8 @@ if(new_state == STEALTH_ALERT || new_state == STEALTH_SEARCHING) stealth_set_wary() stealth_drop_walk() - var/run = (new_state == STEALTH_ALERT || new_state == STEALTH_SEARCHING) - stealth_set_pace(run) + if(!stealth_driven()) + stealth_set_pace(new_state == STEALTH_ALERT || new_state == STEALTH_SEARCHING) human_npc_set_ai_movement(src, new_state == STEALTH_ALERT ? /datum/ai_movement/basic_avoidance : /datum/ai_movement/jps/stealth) for(var/datum/stealth_sight_overlay/overlay as anything in watchers) overlay.refresh() @@ -210,27 +328,43 @@ if(run != (body.move_intent == MOVE_INTENT_RUN)) body.toggle_move_intent() -/// Ends its own stealth walk, if it has one, without counting it as a failed path. -/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_drop_walk() - var/datum/ai_behavior/walk = GET_AI_BEHAVIOR(/datum/ai_behavior/stealth_goto) - if(walk && current_behaviors[walk]) - dropping_walk = TRUE - fail_behavior(walk) - dropping_walk = FALSE - goto_turf = null - goto_result = null +/// The way `guard` faces, as one of the four ways its sprite can show (a walk can leave it on a diagonal). +/proc/stealth_facing(atom/guard) + var/facing = guard.dir + if(facing & (facing - 1)) + return facing & (EAST | WEST) + return facing + +/// The way from `origin` to `goal`, as one of the four ways a sprite can face: along the longer side. +/proc/stealth_dir_to(atom/origin, atom/goal) + var/turf/start = get_turf(origin) + var/turf/end = get_turf(goal) + var/dx = end.x - start.x + var/dy = end.y - start.y + if(abs(dx) >= abs(dy)) + return dx >= 0 ? EAST : WEST + return dy > 0 ? NORTH : SOUTH + +/// The nearest open tile to `spot` within `reach` tiles (`spot` itself if it is open), or null. +/proc/stealth_open_near(turf/spot, reach = 2) + if(isnull(spot)) + return null + if(isopenturf(spot) && !spot.is_blocked_turf(exclude_mobs = TRUE)) + return spot + for(var/ring in 1 to reach) + for(var/turf/open/near in RANGE_TURFS(ring, spot)) + if(get_dist(near, spot) == ring && !near.is_blocked_turf(exclude_mobs = TRUE)) + return near + return null /// `where` pulled back to within STEALTH_LEASH_RANGE of home (abuse R3). Null if nowhere sensible. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_leash(turf/where) - var/turf/home = blackboard[BB_STEALTH_HOME_TURF] + var/turf/home = home_turf if(isnull(where) || isnull(home) || where.z != home.z) return where if(get_dist(home, where) <= STEALTH_LEASH_RANGE) return where - var/turf/clamped = get_ranged_target_turf_direct(home, where, STEALTH_LEASH_RANGE) - if(isnull(clamped) || clamped.is_blocked_turf(exclude_mobs = TRUE)) - return null - return clamped + return stealth_open_near(get_ranged_target_turf_direct(home, where, STEALTH_LEASH_RANGE)) /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_unaware() var/list/notice = notice_map @@ -242,18 +376,23 @@ /// Suspicious of something at `where`. `purpose` is STEALTH_GOTO_INVESTIGATE or STEALTH_GOTO_BODY. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_suspicious(turf/where, purpose = STEALTH_GOTO_INVESTIGATE) - var/mob/living/body = pawn stealth_set_state(STEALTH_SUSPICIOUS) goto_purpose = purpose - investigate_turf = stealth_leash(where) investigated = FALSE last_gain = world.time - set_blackboard_key(BB_STEALTH_LAST_SEEN_TURF, where) + stealth_aim_at(where) + stealth_bark(purpose == STEALTH_GOTO_BODY ? STEALTH_BARK_BODY : STEALTH_BARK_SUSPICIOUS) + +/// Suspicious: turns to `where` and makes it the place to walk over to. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_aim_at(turf/where) + var/mob/living/body = pawn + investigate_turf = stealth_leash(where) + last_seen_turf = where blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time if(where && get_turf(body) != where) - stand_dir = get_dir(body, where) + // Never a diagonal: the sprite can't show one, and the cone would point where the guard doesn't look. + stand_dir = stealth_dir_to(body, where) body.setDir(stand_dir) - stealth_bark(purpose == STEALTH_GOTO_BODY ? STEALTH_BARK_BODY : STEALTH_BARK_SUSPICIOUS) /// Searching round `where`. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_searching(turf/where) @@ -265,21 +404,45 @@ search_queue = stealth_search_spots(search_center) search_looking = FALSE next_search_bark = world.time + STEALTH_SEARCH_BARK_INTERVAL - set_blackboard_key(BB_STEALTH_LAST_SEEN_TURF, where) + last_seen_turf = where blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time -/// Alert on `target` (a mob, or the mech or vehicle someone sits in). +/// Alert on `target` (a mob, or the mech someone sits in). /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_alert(atom/target) if(QDELETED(target)) return if(stealth_state() != STEALTH_ALERT) stealth_set_state(STEALTH_ALERT) stealth_set_wary() + lost_since = 0 set_blackboard_key(BB_STEALTH_FOCUS, target) - set_blackboard_key(BB_STEALTH_LAST_SEEN_TURF, get_turf(target)) + last_seen_turf = get_turf(target) blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target) +/// Alert: turns from its target to `new_target`, and remembers the old one for when this fight ends. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_switch_target(atom/new_target) + var/atom/old_target = blackboard[BB_BASIC_MOB_CURRENT_TARGET] + var/mob/living/old_mob = ismecha(old_target) ? bounty_ai_pilot_of(old_target) : old_target + if(isliving(old_mob) && !QDELETED(old_mob)) + stealth_remember(old_mob) + next_switch_at = world.time + STEALTH_SWITCH_COOLDOWN + // What it was doing was aimed at the old one. + CancelActions() + stealth_go_alert(new_target) + +/// Notes `who` as someone it knows is there and hostile, at alert-level notice. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_remember(mob/living/who) + var/list/notice = notice_map + var/key = REF(who) + var/list/entry = notice[key] + if(isnull(entry)) + entry = list(0, null, 0) + notice[key] = entry + entry[STEALTH_ENTRY_NOTICE] = max(entry[STEALTH_ENTRY_NOTICE], STEALTH_ALERT_AT) + entry[STEALTH_ENTRY_TURF] = get_turf(who) + entry[STEALTH_ENTRY_SEEN_AT] = world.time + /// The spots a search checks: the centre first, then a few open tiles round it in its line of sight. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_search_spots(turf/center) . = list() @@ -288,10 +451,13 @@ . += center var/list/pool = list() var/datum/region = map_region_for_turf(center) + var/turf/home = home_turf + // No spot further from home than the leash, or than the centre if that is already past it. + var/reach = (home && home.z == center.z) ? max(STEALTH_LEASH_RANGE, get_dist(home, center)) : INFINITY for(var/turf/open/spot in RANGE_TURFS(STEALTH_SEARCH_RADIUS, center)) if(spot == center || spot.is_blocked_turf(exclude_mobs = TRUE)) continue - if(map_region_excludes_turf(region, spot) || !stealth_leash(spot)) + if(map_region_excludes_turf(region, spot) || (home && get_dist(home, spot) > reach)) continue pool += spot var/picked = 0 @@ -305,12 +471,14 @@ /// Says a stealth line, at most once per STEALTH_BARK_KIND_COOLDOWN for each kind (abuse P14). /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_bark(kind) var/mob/living/carbon/human/npc/body = pawn - if(!ishumannpc(body) || body.stat != CONSCIOUS) + if(!ishumannpc(body) || body.stat != CONSCIOUS || body.client) return FALSE if(world.time < bark_ready_at[kind]) return FALSE + if(!body.npc_bark(kind, force = TRUE)) + return FALSE bark_ready_at[kind] = world.time + STEALTH_BARK_KIND_COOLDOWN - return body.npc_bark(kind, force = TRUE) + return TRUE /// Moves every awareness timestamp back by `time`, as if that long had passed (unit tests, admin tools). /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_rewind(time) @@ -327,22 +495,34 @@ lost_since -= time wait_until -= time look_until -= time + next_scan -= time next_body_scan -= time + next_switch_at -= time + next_harm_at -= time + home_retry_at -= time + health_mark_at -= time + if(explained_at) + explained_at -= time for(var/kind in bark_ready_at) bark_ready_at[kind] -= time + for(var/key in stop_skip_until) + stop_skip_until[key] -= time // ===== The scan ===== /** * One scan: expires a suspicion or search whose time has run out (also how a guard catches up after - * sleeping), keeps an alert guard's target, or looks for someone new and for fallen friends. + * sleeping), keeps an alert guard's target, and looks for someone new and for fallen friends. */ /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_scan_once() var/mob/living/carbon/human/guard = pawn - if(QDELETED(guard) || guard.stat != CONSCIOUS) + if(QDELETED(guard) || guard.stat != CONSCIOUS || guard.client) return + next_scan = world.time + STEALTH_SCAN_INTERVAL stealth_setup() stealth_update_wary() + stealth_uncover_eyes() + stealth_check_health() var/state = stealth_state() if(state == STEALTH_SEARCHING && calm_until) if(world.time >= calm_until) @@ -355,6 +535,9 @@ stealth_go_unaware() if(stealth_state() == STEALTH_ALERT) stealth_keep_target() + // Still fighting: it weighs everyone else. (Losing or downing its target has already looked round.) + if(stealth_state() == STEALTH_ALERT) + stealth_look() else stealth_look() if(stealth_state() != STEALTH_ALERT && world.time >= next_body_scan) @@ -376,13 +559,25 @@ var/mob/living/guard = pawn return !guard.is_blind() && guard.body_position != LYING_DOWN +/// Something over its eyes that blinds it (a blindfold someone put on it): with a hand free it pulls it off, as anyone would. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_uncover_eyes() + var/mob/living/carbon/human/guard = pawn + if(!ishuman(guard) || !guard.is_blind_from(EYES_COVERED) || HAS_TRAIT(guard, TRAIT_HANDS_BLOCKED)) + return + for(var/obj/item/clothing/worn in list(guard.glasses, guard.wear_mask, guard.head)) + if(worn.tint >= TINT_BLIND && guard.dropItemToGround(worn)) + return + /// How far it sees onto `tile` in its cone: shorter in the dark unless it sees in the dark. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_range_onto(turf/tile) if(!HAS_TRAIT(pawn, TRAIT_NIGHT_VISION) && stealth_tile_is_dark(tile)) return STEALTH_DARK_SIGHT_RANGE return STEALTH_SIGHT_RANGE -/// Looks for someone new: raises notice on whoever it perceives, decays the rest, changes state. +/** + * Looks for people: raises notice on whoever it perceives, decays the rest, changes state. Alert, it leaves + * its own target alone (stealth_keep_target() has it) and weighs everyone else as someone to turn on. + */ /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_look() var/mob/living/carbon/human/guard = pawn var/list/notice = notice_map @@ -390,44 +585,76 @@ if(isnull(strategy) || !isturf(guard.loc)) return var/state = stealth_state() + var/alert = state == STEALTH_ALERT + var/atom/target = alert ? blackboard[BB_BASIC_MOB_CURRENT_TARGET] : null var/wary = blackboard[BB_STEALTH_WARY] var/atom/focus = blackboard[BB_STEALTH_FOCUS] var/eyes = stealth_eyes_open() var/half_angle = stealth_half_angle() var/scan_seconds = STEALTH_SCAN_INTERVAL / (1 SECONDS) var/list/perceived = list() + var/list/was_hidden = hidden_spots + var/list/now_hidden + var/judged = 0 for(var/mob/living/candidate as anything in stealth_candidates(guard, HUMAN_NPC_SIGHT_RANGE)) - // A mech or vehicle is never hidden: seen all round, no darkness, straight to alert (abuse P9). - var/atom/vehicle = stealth_vehicle_of(candidate) - if(vehicle) - if(strategy.can_attack(guard, vehicle, HUMAN_NPC_SIGHT_RANGE)) - stealth_go_alert(vehicle) - return - continue - // In a locker, a crate or anything else: unseen. - if(!isturf(candidate.loc)) - continue + // Only those it could perceive count toward the cap, so bystanders it can't see never crowd a player out. + if(judged >= STEALTH_MAX_CANDIDATES) + break + var/key = REF(candidate) var/turf/where = candidate.loc + var/boxed = FALSE + if(!isturf(where)) + var/atom/movable/shell = candidate.loc + // A mech is never hidden: seen all round, no darkness, straight to alert (abuse P9). + if(ismecha(shell)) + if(shell == target || !strategy.can_attack(guard, shell, HUMAN_NPC_SIGHT_RANGE)) + continue + judged++ + if(!alert) + stealth_go_alert(shell) + return + stealth_remember(candidate) + perceived[key] = candidate + continue + shell = stealth_shell_of(candidate) + if(isnull(shell)) + continue + where = shell.loc + // Any other vehicle is as plain as its driver would be on that tile. A box, a locker or a bag hides them. + boxed = !isvehicle(shell) + if(candidate == target) + continue var/dist = get_dist(guard, where) if(dist > STEALTH_SIGHT_RANGE) continue + if(boxed) + // Hidden, unless the thing they hide in moves at its shoulder (decision 29). + if(dist > STEALTH_CLOSE_RANGE + 1) + continue + LAZYSET(now_hidden, key, where) + var/turf/stood = was_hidden?[key] + if(dist > STEALTH_CLOSE_RANGE || isnull(stood) || stood == where) + continue if(!strategy.can_attack(guard, candidate, STEALTH_SIGHT_RANGE)) continue + judged++ var/rate = 0 - if(eyes && !stealth_is_cloaked(candidate) && stealth_in_cone(guard, where, half_angle)) + var/felt = FALSE + if(boxed) + rate = STEALTH_NOTICE_RATE + felt = TRUE + else if(eyes && !stealth_is_cloaked(candidate) && stealth_in_cone(guard, where, half_angle)) var/cap = stealth_range_onto(where) if(dist <= cap) rate = STEALTH_NOTICE_RATE * clamp((cap + 1 - dist) / cap, STEALTH_NOTICE_MIN_FALLOFF, 1) if(cap < STEALTH_SIGHT_RANGE) rate *= STEALTH_DARK_MULT - var/felt = FALSE if(!rate && dist <= STEALTH_CLOSE_RANGE) // At its shoulder: light doesn't matter (abuse P7). rate = STEALTH_BEHIND_RATE felt = TRUE if(!rate) continue - var/key = REF(candidate) var/list/entry = notice[key] if(isnull(entry)) entry = list(0, null, 0) @@ -449,19 +676,32 @@ if(wary) rate *= STEALTH_WARY_MULT wary_at = world.time - entry[STEALTH_ENTRY_NOTICE] += rate * scan_seconds + entry[STEALTH_ENTRY_NOTICE] = min(entry[STEALTH_ENTRY_NOTICE] + rate * scan_seconds, STEALTH_NOTICE_MAX) if(felt && running) entry[STEALTH_ENTRY_NOTICE] = max(entry[STEALTH_ENTRY_NOTICE], STEALTH_SUSPICIOUS_AT) - guard.setDir(get_dir(guard, where)) + if(!alert && where != guard.loc) + guard.setDir(stealth_dir_to(guard, where)) entry[STEALTH_ENTRY_TURF] = where entry[STEALTH_ENTRY_SEEN_AT] = world.time perceived[key] = candidate + hidden_spots = now_hidden // Decay whoever it didn't perceive; forget anyone unseen for long enough. var/best_key var/best_notice = 0 + var/seen_key + var/seen_notice = 0 + // What it knows of its own target keeps while it fights them. + var/mob/living/target_mob = ismecha(target) ? bounty_ai_pilot_of(target) : target + var/target_key = isliving(target_mob) ? REF(target_mob) : null for(var/key in notice) var/list/entry = notice[key] - if(!perceived[key]) + if(key == target_key) + continue + if(perceived[key]) + if(entry[STEALTH_ENTRY_NOTICE] > seen_notice) + seen_notice = entry[STEALTH_ENTRY_NOTICE] + seen_key = key + else entry[STEALTH_ENTRY_NOTICE] = max(0, entry[STEALTH_ENTRY_NOTICE] - STEALTH_NOTICE_DECAY * scan_seconds) if(world.time - entry[STEALTH_ENTRY_SEEN_AT] > STEALTH_NOTICE_FORGET) notice -= key @@ -469,35 +709,89 @@ if(entry[STEALTH_ENTRY_NOTICE] > best_notice) best_notice = entry[STEALTH_ENTRY_NOTICE] best_key = key - if(isnull(best_key)) + if(alert) + stealth_weigh_switch(perceived, strategy) + return + // Whoever it sees now decides what it does; a higher entry it can't see no longer speaks for them. + var/judged_key = seen_key || best_key + if(isnull(judged_key)) clear_blackboard_key(BB_STEALTH_FOCUS) return - var/mob/living/best = perceived[best_key] || locate(best_key) + var/mob/living/best = perceived[judged_key] || locate(judged_key) if(!isliving(best) || QDELETED(best)) - notice -= best_key + notice -= judged_key return set_blackboard_key(BB_STEALTH_FOCUS, best) - var/list/best_entry = notice[best_key] + var/list/best_entry = notice[judged_key] + var/level = best_entry[STEALTH_ENTRY_NOTICE] var/turf/best_turf = best_entry[STEALTH_ENTRY_TURF] - var/seen_now = !!perceived[best_key] + var/seen_now = !isnull(seen_key) + // No alert by sight from off-screen (abuse P8). + var/in_reach = get_dist(guard, best_turf) <= STEALTH_ALERT_MAX_RANGE switch(state) if(STEALTH_UNAWARE) - if(best_notice >= STEALTH_SUSPICIOUS_AT) + if(level >= STEALTH_SUSPICIOUS_AT) stealth_go_suspicious(best_turf) - if(STEALTH_SUSPICIOUS, STEALTH_SEARCHING) + if(STEALTH_SUSPICIOUS) if(!seen_now) return - // No alert by sight from off-screen (abuse P8). - if(best_notice >= STEALTH_ALERT_AT && get_dist(guard, best_turf) <= STEALTH_ALERT_MAX_RANGE) + if(level >= STEALTH_ALERT_AT && in_reach) stealth_go_alert(best) return - if(state == STEALTH_SEARCHING) - if(best_notice >= STEALTH_SUSPICIOUS_AT) - stealth_go_suspicious(best_turf) - return last_gain = world.time - set_blackboard_key(BB_STEALTH_LAST_SEEN_TURF, best_turf) - blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time + // It looks at, and walks to, where they are now, not where it first saw them. + if(!investigated && level >= STEALTH_SUSPICIOUS_AT && last_seen_turf != best_turf) + stealth_aim_at(best_turf) + else + last_seen_turf = best_turf + blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time + if(STEALTH_SEARCHING) + if(!seen_now || level < STEALTH_SUSPICIOUS_AT) + return + // It is primed: a glimpse is enough. + if(in_reach) + stealth_go_alert(best) + return + // Too far to be sure: the search moves to where it saw them. + var/turf/new_center = stealth_leash(best_turf) + if(calm_until || (new_center && get_dist(new_center, search_center) > 1)) + stealth_go_searching(best_turf) + +/** + * Alert: whether to turn on someone else it has noticed. They must be at alert-level notice, in reach, and + * either clearly nearer than its target or in view while the target is not. Never more than once per + * STEALTH_SWITCH_COOLDOWN, so two people can't make it flap between them. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_weigh_switch(list/perceived, datum/targeting_strategy/strategy) + if(world.time < next_switch_at || !length(perceived)) + return + var/mob/living/guard = pawn + var/list/notice = notice_map + var/atom/target = blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(QDELETED(target)) + return + var/target_dist = get_dist(guard, target) + var/target_unseen = !!lost_since + var/atom/pick + var/pick_dist = INFINITY + for(var/key in perceived) + var/list/entry = notice[key] + if(isnull(entry) || entry[STEALTH_ENTRY_NOTICE] < STEALTH_ALERT_AT) + continue + var/atom/thing = bounty_ai_target_for(perceived[key]) + if(thing == target) + continue + var/dist = get_dist(guard, thing) + if(dist > STEALTH_ALERT_MAX_RANGE || dist >= pick_dist) + continue + if(!target_unseen && dist + STEALTH_SWITCH_MARGIN > target_dist) + continue + if(!strategy.can_attack(guard, thing, HUMAN_NPC_SIGHT_RANGE)) + continue + pick = thing + pick_dist = dist + if(pick) + stealth_switch_target(pick) /** * Alert: keeps its target with tg's all-round can_attack() at the NPC's sight range (abuse P2). Out of @@ -508,12 +802,12 @@ var/atom/target = blackboard[BB_BASIC_MOB_CURRENT_TARGET] var/atom/remembered = target || blackboard[BB_STEALTH_FOCUS] if(QDELETED(remembered)) - stealth_go_searching(blackboard[BB_STEALTH_LAST_SEEN_TURF]) + stealth_go_searching(last_seen_turf) return var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) if(strategy?.can_attack(guard, remembered, HUMAN_NPC_SIGHT_RANGE)) lost_since = 0 - set_blackboard_key(BB_STEALTH_LAST_SEEN_TURF, get_turf(remembered)) + last_seen_turf = get_turf(remembered) blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time if(!target) set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, remembered) @@ -535,7 +829,7 @@ return clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) stealth_bark(STEALTH_BARK_LOST) - stealth_go_searching(blackboard[BB_STEALTH_LAST_SEEN_TURF]) + stealth_go_searching(last_seen_turf) /** * Its target is down (decision 23). Anyone else it has noticed keeps the alarm going as usual: alert on @@ -553,7 +847,7 @@ search_looking = FALSE calm_until = world.time + STEALTH_CALM_LOOK_TIME look_until = calm_until - stand_dir = guard.dir + stand_dir = stealth_facing(guard) // Anyone it noticed lately or who hit it during the fight, read before this look decays them. var/best_notice = 0 var/turf/best_turf @@ -570,7 +864,7 @@ best_notice = entry[STEALTH_ENTRY_NOTICE] best_turf = entry[STEALTH_ENTRY_TURF] best = someone - // One look now: anyone else in view goes the normal way (suspicious or alert). + // One look now: anyone else in view goes the normal way (alert, or the search moves to them). stealth_look() if(stealth_state() != STEALTH_SEARCHING || !calm_until) return @@ -587,8 +881,9 @@ /** * Fallen friends (abuse P13): group members that are dead, in crit, cuffed or lying down, in its sight. - * It reacts to each body once. The first guard to find one is suspicious and walks over; a guard that - * finds a body someone already reported just searches there. + * It reacts to each fall once: a friend who gets back up and goes down again is news again. The first + * guard to find one is suspicious and walks over; a guard that finds a body someone already reported just + * searches there. */ /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_check_bodies() next_body_scan = world.time + STEALTH_BODY_SCAN_INTERVAL @@ -601,9 +896,12 @@ for(var/mob/living/member as anything in group.living_members()) if(member == guard) continue + var/key = REF(member) if(!(member.stat == DEAD || HAS_TRAIT(member, TRAIT_CRITICAL_CONDITION) || HAS_TRAIT(member, TRAIT_RESTRAINED) || member.body_position == LYING_DOWN)) + // On its feet again. + seen -= key + group.reported_bodies -= key continue - var/key = REF(member) if(key in seen) continue // A body stuffed in a locker, a crate or a bag is hidden, as a living person there is. @@ -632,29 +930,32 @@ /** * Anyone touching or hurting it (abuse P3): alert on them if it can see them, otherwise a search at - * their turf. Always wary. Allies are ignored. Returns TRUE if it took a target. + * their turf. Always wary. Allies are ignored. Alert on someone else already, it turns on whoever hit it + * (at most once per STEALTH_SWITCH_COOLDOWN, unless they are clearly nearer) and otherwise remembers them + * for when that fight ends. Returns TRUE if it took a target. */ /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_touched(atom/source) var/mob/living/guard = pawn - if(QDELETED(source) || QDELETED(guard) || source == guard || guard.stat == DEAD) + if(QDELETED(source) || QDELETED(guard) || source == guard || guard.stat == DEAD || guard.client) return FALSE + // Whatever hurts it at this moment has a name, an ally's stray shot included. + explained_at = world.time if(ismovable(source) && guard.faction_check_atom(source)) return FALSE stealth_set_wary() - if(stealth_state() == STEALTH_ALERT && blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) - // Busy with someone else: it remembers who else is hurting it for when that fight ends. - if(isliving(source) && source != blackboard[BB_BASIC_MOB_CURRENT_TARGET]) - var/list/notice = notice_map - var/list/entry = notice[REF(source)] - if(isnull(entry)) - entry = list(0, null, 0) - notice[REF(source)] = entry - entry[STEALTH_ENTRY_NOTICE] = max(entry[STEALTH_ENTRY_NOTICE], STEALTH_ALERT_AT) - entry[STEALTH_ENTRY_TURF] = get_turf(source) - entry[STEALTH_ENTRY_SEEN_AT] = world.time - return FALSE var/atom/who = isliving(source) ? bounty_ai_target_for(source) : source var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) + var/atom/target = blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(stealth_state() == STEALTH_ALERT && !QDELETED(target)) + if(who == target) + return FALSE + if(isliving(source)) + stealth_remember(source) + var/nearer = get_dist(guard, who) + STEALTH_SWITCH_MARGIN <= get_dist(guard, target) + if((nearer || world.time >= next_switch_at) && strategy?.can_attack(guard, who)) + stealth_switch_target(who) + return TRUE + return FALSE if(strategy?.can_attack(guard, who)) stealth_go_alert(who) return TRUE @@ -663,6 +964,8 @@ return FALSE /datum/ai_controller/basic_controller/human_npc/stealth/on_pawn_attacked(mob/living/source, atom/attacker, attack_flags) + if(source.client) + return if(stealth_touched(attacker)) return if(!blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) @@ -672,14 +975,30 @@ SIGNAL_HANDLER stealth_touched(puller) +/// Someone walked into it, or something with someone inside did (a box on the move). /datum/ai_controller/basic_controller/human_npc/stealth/proc/on_bumped(atom/source, atom/movable/bumper) SIGNAL_HANDLER if(isliving(bumper)) stealth_touched(bumper) + return + if(!ismovable(bumper)) + return + for(var/mob/living/inside in bumper.get_all_contents()) + stealth_touched(inside) + return -/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_helped(mob/living/source, mob/living/helper) +/// A help-click, cuffs, a syringe, a hand in its pockets or anything else used on it. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_handled(mob/living/source, mob/living/toucher) SIGNAL_HANDLER - stealth_touched(helper) + if(ismob(toucher)) + stealth_touched(toucher) + +/// Anything thrown that lands on it, harmless or not (relay_attackers only reports what hurts). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_prehitby(atom/source, atom/movable/hit_atom, datum/thrownthing/throwingdatum) + SIGNAL_HANDLER + var/mob/thrower = throwingdatum?.get_thrower() + if(ismob(thrower)) + stealth_touched(thrower) /// relay_attackers only reports a mob firer; a turret or other machine shooting it counts the same. /datum/ai_controller/basic_controller/human_npc/stealth/proc/on_projectile_prehit(atom/source, obj/projectile/hit_projectile) @@ -692,12 +1011,92 @@ /datum/ai_controller/basic_controller/human_npc/stealth/proc/on_target_set(datum/source) SIGNAL_HANDLER var/atom/target = blackboard[BB_BASIC_MOB_CURRENT_TARGET] - if(isnull(target) || stealth_state() == STEALTH_ALERT) + if(isnull(target) || stealth_state() == STEALTH_ALERT || stealth_driven()) return stealth_set_state(STEALTH_ALERT) stealth_set_wary() set_blackboard_key(BB_STEALTH_FOCUS, target) +/// A blast, flames or a flash on it. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_harmed(datum/source) + SIGNAL_HANDLER + stealth_note_harm() + +/// A stun, a knockdown or sleep about to land on it. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_stunned(datum/source, amount) + SIGNAL_HANDLER + if(amount > 0) + stealth_note_harm() + +/** + * Something hurt or stunned it. Judged a moment later, once the blow behind it has had the chance to name + * who dealt it (tg reports an item hit's attacker after its damage). Harm someone is named for within + * `window` is theirs and stealth_touched() has dealt with it. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_note_harm(window = 0.2 SECONDS) + var/mob/living/carbon/human/npc/guard = pawn + if(harm_pending || QDELETED(guard) || guard.stat == DEAD || guard.client) + return + // Its own surrender puts it on the floor. + if(ishumannpc(guard) && guard.npc_surrendering) + return + harm_pending = TRUE + harm_window = window + harm_turf = get_turf(guard) + addtimer(CALLBACK(src, PROC_REF(stealth_judge_harm)), 0.2 SECONDS) + +/// Harm nobody was named for (a blast, fire, gas, a flashbang): it and the friends near it go looking there. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_judge_harm() + harm_pending = FALSE + var/turf/where = harm_turf + harm_turf = null + var/mob/living/guard = pawn + if(QDELETED(guard) || guard.stat == DEAD || guard.client || isnull(where)) + return + if(explained_at && world.time - explained_at <= harm_window + 0.2 SECONDS) + return + if(world.time < next_harm_at) + return + next_harm_at = world.time + STEALTH_HARM_COOLDOWN + stealth_setup() + stealth_trouble_at(where) + for(var/mob/living/friend as anything in group?.living_members()) + if(friend == guard) + continue + var/turf/friend_turf = get_turf(friend) + if(friend_turf?.z != where.z || get_dist(friend_turf, where) > STEALTH_HARM_ALERT_RANGE) + continue + var/datum/ai_controller/basic_controller/human_npc/stealth/friend_ai = friend.ai_controller + if(istype(friend_ai)) + friend_ai.stealth_trouble_at(where) + +/// Trouble at `where` it can't put a face to: wary, and searching there unless it already has someone to fight. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_trouble_at(turf/where) + var/mob/living/guard = pawn + if(QDELETED(guard) || guard.stat != CONSCIOUS || guard.client) + return + stealth_set_wary() + if(blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + if(stealth_state() == STEALTH_SEARCHING && !calm_until) + return + stealth_go_searching(where) + stealth_bark(STEALTH_BARK_HIT_UNSEEN) + +/// Each scan: health lost with nobody to blame (gas, acid, bleeding, anything without a signal of its own). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_check_health() + var/mob/living/guard = pawn + var/health = guard.health + if(isnull(health_mark) || health > health_mark || world.time - health_mark_at > STEALTH_HARM_WINDOW) + health_mark = health + health_mark_at = world.time + return + if(health_mark - health < STEALTH_HARM_HEALTH) + return + health_mark = health + health_mark_at = world.time + stealth_note_harm(STEALTH_HARM_WINDOW) + /// Carried off the level: drops its route and holds where it is (abuse R3). The base controller already /// listens for the level change. /datum/ai_controller/basic_controller/human_npc/stealth/on_changed_z_level(atom/source, turf/old_turf, turf/new_turf, same_z_layer, notify_contents) @@ -718,17 +1117,20 @@ /datum/ai_controller/basic_controller/human_npc/stealth/proc/on_status_removed(mob/living/source, datum/status_effect/effect) SIGNAL_HANDLER - if(istype(effect, /datum/status_effect/incapacitating/stamcrit)) + if(istype(effect, /datum/status_effect/incapacitating)) stealth_came_round() -/// Back on its feet after crit or stamcrit: it knows something happened. +/// Back on its feet after crit, a stun, a knockdown or sleep: it knows something happened. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_came_round() var/mob/living/guard = pawn - if(QDELETED(guard) || guard.stat == DEAD) + if(QDELETED(guard) || guard.stat != CONSCIOUS || guard.client) return stealth_set_wary() if(blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) return + // Already looking, and only just: one stun often ends as several effects. + if(stealth_state() == STEALTH_SEARCHING && !calm_until && world.time - blackboard[BB_STEALTH_STATE_SINCE] < STEALTH_LOSE_TARGET_TIME) + return stealth_go_searching(get_turf(guard)) // A guard that gives up is wary. @@ -758,24 +1160,22 @@ /datum/ai_movement/jps/stealth maximum_length = STEALTH_PATROL_MAX_PATH -/// Queues the scan every plan. Never ends planning. +/** + * Runs the scan once a second, straight from planning: it never waits in the behaviour queue, where a walk + * or tg's idle mode could starve it or be starved by it. First in the list, and never ends planning. With + * the scan stopped (cuffed, held, a player in the body) it takes the head mark down. + */ /datum/ai_planning_subtree/stealth_perception /datum/ai_planning_subtree/stealth_perception/SelectBehaviors(datum/ai_controller/basic_controller/human_npc/stealth/controller, seconds_per_tick) - var/mob/living/guard = controller.pawn - if(guard.stat != CONSCIOUS || !istype(controller)) + if(!istype(controller)) + return + if(!controller.stealth_scanning()) + QDEL_NULL(controller.meter) return controller.stealth_setup() - controller.queue_behavior(/datum/ai_behavior/stealth_scan) - -/datum/ai_behavior/stealth_scan - action_cooldown = STEALTH_SCAN_INTERVAL - behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION - -/datum/ai_behavior/stealth_scan/perform(seconds_per_tick, datum/ai_controller/basic_controller/human_npc/stealth/controller) - if(istype(controller)) + if(world.time >= controller.next_scan && !controller.blackboard_key_exists(BB_HUMAN_NPC_EXIT)) controller.stealth_scan_once() - return AI_BEHAVIOR_DELAY /// Suspicious: stare, walk over, look round. Searching: sweep the spots. Ends planning while it acts. /datum/ai_planning_subtree/stealth_react @@ -784,6 +1184,8 @@ if(!istype(controller) || controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) return var/mob/living/guard = controller.pawn + if(guard.stat != CONSCIOUS) + return switch(controller.stealth_state()) if(STEALTH_SUSPICIOUS) if(world.time < controller.blackboard[BB_STEALTH_STATE_SINCE] + STEALTH_INVESTIGATE_DELAY) @@ -791,7 +1193,7 @@ return SUBTREE_RETURN_FINISH_PLANNING if(!controller.investigated) var/turf/dest = controller.investigate_turf - if(dest && get_turf(guard) != dest && controller.goto_result != "failed") + if(dest && !controller.stealth_walk_over(dest)) controller.stealth_walk(dest, controller.goto_purpose) return SUBTREE_RETURN_FINISH_PLANNING controller.investigated = TRUE @@ -799,6 +1201,7 @@ if(controller.goto_purpose == STEALTH_GOTO_BODY) controller.stealth_go_searching(get_turf(guard)) return SUBTREE_RETURN_FINISH_PLANNING + controller.stand_dir = stealth_facing(guard) controller.look_until = world.time + STEALTH_LOOK_AROUND_TIME controller.queue_behavior(/datum/ai_behavior/stealth_stand) return SUBTREE_RETURN_FINISH_PLANNING @@ -823,26 +1226,68 @@ controller.queue_behavior(/datum/ai_behavior/stealth_stand) return SUBTREE_RETURN_FINISH_PLANNING var/turf/spot = controller.search_queue[1] - if(get_turf(guard) == spot || (controller.goto_turf == spot && controller.goto_result)) + if(controller.stealth_walk_over(spot)) controller.stealth_drop_walk() controller.search_looking = TRUE + controller.stand_dir = stealth_facing(guard) controller.look_until = world.time + STEALTH_LOOK_AROUND_TIME controller.queue_behavior(/datum/ai_behavior/stealth_stand) return SUBTREE_RETURN_FINISH_PLANNING controller.stealth_walk(spot, STEALTH_GOTO_SEARCH) return SUBTREE_RETURN_FINISH_PLANNING -/// Starts (or keeps) its stealth walk to `dest`. -/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_walk(turf/dest, purpose) +// ===== Walking ===== + +/// Whether its walk to `dest` got there: on the tile, or as near as it could stand. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_walk_arrived(turf/dest) + return get_turf(pawn) == dest || (goto_dest == dest && goto_result == "arrived") + +/// Whether its walk to `dest` is over, there or not. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_walk_over(turf/dest) + return get_turf(pawn) == dest || (goto_dest == dest && !isnull(goto_result)) + +/// Whether the pathfinder gave up on its walk to `dest`. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_walk_gave_up(turf/dest) + return goto_dest == dest && goto_result == "failed" + +/// Ends its own stealth walk, if it has one, without counting it as a failed path. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_drop_walk() var/datum/ai_behavior/walk = GET_AI_BEHAVIOR(/datum/ai_behavior/stealth_goto) - if(goto_turf != dest && current_behaviors[walk]) + if(walk && current_behaviors[walk]) + dropping_walk = TRUE + fail_behavior(walk) + dropping_walk = FALSE + goto_turf = null + goto_dest = null + goto_result = null + +/// Starts (or keeps) its stealth walk to `dest`. A walk already over stays over until it is dropped. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_walk(turf/dest, purpose) + if(goto_dest != dest) stealth_drop_walk() - if(goto_turf != dest) - goto_result = null - goto_turf = dest + goto_dest = dest goto_purpose = purpose + if(goto_result) + return + if(isnull(goto_turf)) + goto_turf = stealth_next_leg(dest) queue_behavior(/datum/ai_behavior/stealth_goto) +/** + * The turf to walk to now on the way to `dest`: `dest`, an open tile beside it when something stands on it + * (a crate dropped on a patrol stop), or, when it is more than STEALTH_WALK_LEG off, a point part of the + * way there. A guard drawn a long way out comes home in legs instead of asking for a path too long to find. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_next_leg(turf/dest) + var/turf/here = get_turf(pawn) + if(isnull(here) || here.z != dest.z) + return dest + if(get_dist(here, dest) > STEALTH_WALK_LEG) + return stealth_open_near(get_ranged_target_turf_direct(here, dest, STEALTH_WALK_LEG - 10), 3) || dest + if(dest.is_blocked_turf(exclude_mobs = TRUE)) + return stealth_open_near(dest, 1) || dest + return dest + /** * Walks to the controller's goto_turf with its pathfinding movement. Ends on arrival, or as a failure * when the pathfinder gives up or the leg takes far too long (abuse R2). @@ -856,28 +1301,34 @@ . = ..() if(!istype(controller) || isnull(controller.goto_turf)) return FALSE + // Stamped each time the walk starts or starts again (after a stun, or the level waking), so time spent + // not walking never counts against it. controller.goto_started = world.time set_movement_target(controller, controller.goto_turf) /datum/ai_behavior/stealth_goto/perform(seconds_per_tick, datum/ai_controller/basic_controller/human_npc/stealth/controller) var/turf/dest = controller.goto_turf if(isnull(dest)) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED var/turf/here = get_turf(controller.pawn) if(here == dest) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED // Someone stands on the spot: next to it will do. if(get_dist(here, dest) <= 1 && dest.is_blocked_turf(exclude_mobs = FALSE) && !dest.is_blocked_turf(exclude_mobs = TRUE)) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED if(world.time - controller.goto_started > STEALTH_PATROL_MAX_PATH * max(controller.movement_delay, 0.4 SECONDS) * 1.5) controller.goto_timed_out = TRUE - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - return AI_BEHAVIOR_INSTANT + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY /datum/ai_behavior/stealth_goto/finish_action(datum/ai_controller/basic_controller/human_npc/stealth/controller, succeeded) if(istype(controller) && !controller.dropping_walk) if(succeeded) - controller.goto_result = "arrived" + if(controller.goto_turf == controller.goto_dest || get_dist(controller.goto_turf, controller.goto_dest) <= 1) + controller.goto_result = "arrived" + else + // One leg of a long walk: the next plan sets out on the next. + controller.goto_turf = null else // Only a real path failure counts; being re-planned away from a walk does not. var/path_failed = controller.goto_timed_out || controller.consecutive_pathing_attempts >= controller.ai_movement.max_pathing_attempts @@ -918,3 +1369,6 @@ else if(sentry || prob(STEALTH_LOOK_TURN_CHANCE)) guard.setDir(turn(controller.stand_dir, pick(STEALTH_LOOK_TURN_ARC, -STEALTH_LOOK_TURN_ARC))) return AI_BEHAVIOR_DELAY + +#undef STEALTH_HANDLED_SIGNALS +#undef STEALTH_STUN_SIGNALS diff --git a/voidcrew/modules/stealth/stealth_meter.dm b/voidcrew/modules/stealth/stealth_meter.dm index 2ef21c83009..d2f070ab5fa 100644 --- a/voidcrew/modules/stealth/stealth_meter.dm +++ b/voidcrew/modules/stealth/stealth_meter.dm @@ -14,11 +14,14 @@ * lighting so they read in the dark. Fills round to the nearest row; drains round up, so the last row * stays lit until the time really runs out. * - * Cost: the meter is built only while a living player is within STEALTH_METER_RANGE of a conscious guard, - * updated by the guard's 1 s scan and its state changes, and changes an icon_state only when the step - * changes. A drain steps on a timer at each row change (at most STEALTH_METER_ROWS per drain); animate() - * is not used, because a chained icon_state animation can't snap back when the target is seen again. - * Dormant guards don't scan, so they build nothing. + * Cost: the meter is built only while a living player is within STEALTH_METER_RANGE of a guard whose scan + * is running (or an admin is watching its sight), updated by the guard's 1 s scan and its state changes, + * and changes an icon_state only when the step changes. A drain steps on a timer at each row change (at + * most STEALTH_METER_ROWS per drain); animate() is not used, because a chained icon_state animation can't + * snap back when the target is seen again. + * + * The mark lives only while the scan that feeds it runs. A guard that is cuffed, held, stunned, down, asleep + * with its level, or driven by a player has no mark, so none is ever left stale over its head. */ /datum/stealth_meter /// The guard's controller, which owns this meter @@ -28,7 +31,7 @@ var/obj/effect/abstract/stealth_mark/mark /// icon_state of the public mark, or null var/public_state - /// REF(player) => list(image, client it was given to) + /// REF(player), or "watch:ckey" for an admin watching its sight => list(image, client it was given to) var/list/private_marks = list() /// Timer for the next drain step var/drain_timer @@ -80,40 +83,58 @@ guard.vis_contents += mark mark.icon_state = new_state -/// Gives each nearby player their own "?" for their notice entry, and takes away the rest. +/// Gives each nearby player their own "?" for their notice entry, and takes away the rest. An admin watching +/// the guard's sight gets the fullest one, whoever it is for. /datum/stealth_meter/proc/update_private(list/notice) + // key => list(icon_state, client to show it to) var/list/wanted = list() var/turf/here = get_turf(guard) if(length(notice) && here) + var/top_state + var/top_notice = 0 for(var/key in notice) var/list/entry = notice[key] var/state = stealth_meter_notice_state(entry[STEALTH_ENTRY_NOTICE]) if(isnull(state)) continue + if(entry[STEALTH_ENTRY_NOTICE] > top_notice) + top_notice = entry[STEALTH_ENTRY_NOTICE] + top_state = state var/mob/viewer = locate(key) - if(!ismob(viewer) || isnull(viewer.client) || viewer.z != here.z || get_dist(viewer, here) > STEALTH_METER_RANGE) + if(!ismob(viewer) || isnull(viewer.client)) + continue + // By its turf: someone hiding in a box has no z of their own. + var/turf/viewer_turf = get_turf(viewer) + if(viewer_turf?.z != here.z || get_dist(viewer_turf, here) > STEALTH_METER_RANGE) continue - wanted[key] = state + wanted[key] = list(state, viewer.client) + if(top_state) + for(var/datum/stealth_sight_overlay/overlay as anything in controller.watchers) + var/client/admin = overlay.user_ref?.resolve() + if(isnull(admin) || wanted[REF(admin.mob)]) + continue + wanted["watch:[admin.ckey]"] = list(top_state, admin) for(var/key in private_marks.Copy()) var/list/held = private_marks[key] - var/mob/viewer = locate(key) - if(!wanted[key] || !ismob(viewer) || viewer.client != held[2]) + var/list/want = wanted[key] + if(isnull(want) || want[2] != held[2]) remove_private(key) for(var/key in wanted) + var/list/want = wanted[key] var/list/held = private_marks[key] if(held) var/image/shown = held[1] - if(shown.icon_state != wanted[key]) - shown.icon_state = wanted[key] + if(shown.icon_state != want[1]) + shown.icon_state = want[1] continue - var/mob/viewer = locate(key) - var/image/shown = image('voidcrew/icons/effects/stealth_meter.dmi', guard, wanted[key]) + var/client/viewer = want[2] + var/image/shown = image('voidcrew/icons/effects/stealth_meter.dmi', guard, want[1]) shown.appearance_flags = RESET_COLOR | RESET_ALPHA | RESET_TRANSFORM | KEEP_APART shown.mouse_opacity = MOUSE_OPACITY_TRANSPARENT SET_PLANE_EXPLICIT(shown, ABOVE_LIGHTING_PLANE, guard) shown.pixel_z = stealth_meter_offset(guard) - viewer.client.images += shown - private_marks[key] = list(shown, viewer.client) + viewer.images += shown + private_marks[key] = list(shown, viewer) /datum/stealth_meter/proc/remove_private(key) var/list/held = private_marks[key] @@ -163,10 +184,11 @@ /// The mark over its head while anyone is near to see it (stealth_meter.dm) var/datum/stealth_meter/meter -/// Builds, updates or drops the mark. Nothing while it isn't conscious or nobody living is near. +/// Builds, updates or drops the mark. Nothing while its scan isn't running (stealth_scanning()), or with no +/// living player near and no admin watching its sight. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_meter_update() var/mob/living/guard = pawn - if(QDELETED(guard) || guard.stat != CONSCIOUS || !stealth_meter_viewers_near(guard)) + if(!stealth_scanning() || (!length(watchers) && !stealth_meter_viewers_near(guard))) QDEL_NULL(meter) return if(isnull(meter)) diff --git a/voidcrew/modules/stealth/stealth_patrol.dm b/voidcrew/modules/stealth/stealth_patrol.dm index ed476f71dd4..6fe4a9d518b 100644 --- a/voidcrew/modules/stealth/stealth_patrol.dm +++ b/voidcrew/modules/stealth/stealth_patrol.dm @@ -8,6 +8,9 @@ * * A guard claims the route with a stop within STEALTH_ROUTE_CLAIM_RANGE of where it spawned, starting at * that stop. With none in range it is a sentry at its spawn turf, facing the way the spawner faced. + * + * A guard drawn off its route or post (a chase, a search) walks back, however far. One that can't get back + * waits and tries again. Only being carried to another level or map region makes it a sentry where it is. */ /// Every live patrol group @@ -211,9 +214,9 @@ GLOBAL_VAR_INIT(stealth_group_count, 0) stealth_ready = TRUE var/mob/living/guard = pawn var/turf/spawn_turf = get_turf(guard) - set_blackboard_key(BB_STEALTH_HOME_TURF, spawn_turf) - sentry_dir = guard.dir - stand_dir = guard.dir + home_turf = spawn_turf + sentry_dir = stealth_facing(guard) + stand_dir = sentry_dir group = stealth_group_for(spawn_turf) if(isnull(group)) group = new(spawn_turf) @@ -232,16 +235,19 @@ GLOBAL_VAR_INIT(stealth_group_count, 0) route_index = index if(route_id) var/datum/stealth_stop/start = group.routes[route_id][route_index] - set_blackboard_key(BB_STEALTH_HOME_TURF, start.spot) + home_turf = start.spot -/// Drops its route and holds `where` as a sentry from now on (abuse R2, R3). +/// Drops its route and holds `where` as a sentry from now on: it was carried off its level or out of its +/// region (abuse R3). A guard that only walked or was chased away keeps its route and walks back. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_become_sentry(turf/where) route_id = null at_stop = FALSE + home_retry_at = 0 + stealth_drop_walk() if(where) - set_blackboard_key(BB_STEALTH_HOME_TURF, where) - sentry_dir = pawn.dir - stand_dir = pawn.dir + home_turf = where + sentry_dir = stealth_facing(pawn) + stand_dir = sentry_dir /// Its route's stops, or null. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_stops() @@ -249,16 +255,19 @@ GLOBAL_VAR_INIT(stealth_group_count, 0) return null return group.routes[route_id] -/// Back from a suspicion or a search: heads for the nearest stop of its route. +/// Back from a suspicion, a search or a chase: heads for the nearest stop of its route it can still try. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_rejoin_route() at_stop = FALSE wait_until = 0 + home_retry_at = 0 var/list/stops = stealth_stops() if(!length(stops)) return var/turf/here = get_turf(pawn) var/best_dist = INFINITY for(var/index in 1 to length(stops)) + if(world.time < stop_skip_until["[index]"]) + continue var/datum/stealth_stop/stop = stops[index] var/dist = get_dist(stop.spot, here) if(dist < best_dist) @@ -285,11 +294,15 @@ GLOBAL_VAR_INIT(stealth_group_count, 0) return index return 0 -/// A walk the pathfinder gave up on (abuse R2): a stop that fails often enough is skipped for a while. +/** + * A walk the pathfinder gave up on (abuse R2). A stop that fails often enough is skipped for a while; with + * every stop skipped it waits where it is and tries again when the first skip runs out. A sentry that can't + * reach its post waits and tries again too. Neither gives up its route or post for good: only being carried + * off does that. + */ /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_walk_failed() if(goto_purpose == STEALTH_GOTO_HOME) - // It can't get back to its post: this is its post now. - stealth_become_sentry(get_turf(pawn)) + home_retry_at = world.time + STEALTH_HOME_RETRY_TIME return if(goto_purpose != STEALTH_GOTO_PATROL || isnull(route_id)) return @@ -300,59 +313,92 @@ GLOBAL_VAR_INIT(stealth_group_count, 0) stop_fails[key] = 0 stop_skip_until[key] = world.time + STEALTH_PATROL_SKIP_TIME var/next = stealth_next_index() - if(!next) - stealth_become_sentry(get_turf(pawn)) - return - route_index = next + if(next) + route_index = next -/// Unaware with no target: walks its route or holds its post. +/// Unaware with no target: walks its route or holds its post. Last in the list: it always leaves the guard +/// something to do, so planning never comes up empty. /datum/ai_planning_subtree/stealth_patrol /datum/ai_planning_subtree/stealth_patrol/SelectBehaviors(datum/ai_controller/basic_controller/human_npc/stealth/controller, seconds_per_tick) - if(!istype(controller) || controller.stealth_state() != STEALTH_UNAWARE || controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + if(!istype(controller)) return var/mob/living/guard = controller.pawn if(guard.stat != CONSCIOUS) return - var/turf/here = get_turf(guard) - var/list/stops = controller.stealth_stops() - if(!length(stops)) - controller.route_id = null - var/turf/post = controller.blackboard[BB_STEALTH_HOME_TURF] - if(post && here != post && controller.goto_result != "failed") - controller.stealth_walk(post, STEALTH_GOTO_HOME) - return SUBTREE_RETURN_FINISH_PLANNING - controller.stand_dir = controller.sentry_dir + if(controller.stealth_state() == STEALTH_UNAWARE && !controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + if(length(controller.stealth_stops())) + controller.stealth_plan_route() + else + controller.stealth_plan_post() + // Alert with nobody in reach of the fighting subtrees, or holding: it stands and looks about. + if(!length(controller.planned_behaviors)) controller.queue_behavior(/datum/ai_behavior/stealth_stand) - return SUBTREE_RETURN_FINISH_PLANNING - if(controller.at_stop) - if(world.time < controller.wait_until) - controller.queue_behavior(/datum/ai_behavior/stealth_stand) - return SUBTREE_RETURN_FINISH_PLANNING - controller.at_stop = FALSE - var/next = controller.stealth_next_index() + return SUBTREE_RETURN_FINISH_PLANNING + +/// A sentry: stands its post, or walks back to it. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_plan_post() + var/mob/living/guard = pawn + var/turf/here = get_turf(guard) + route_id = null + var/turf/post = home_turf + if(post && here != post) + if(post.z != here.z || map_region_for_turf(post) != map_region_for_turf(here)) + // Carried off: this is its post now. + stealth_become_sentry(here) + else if(world.time >= home_retry_at) + if(stealth_walk_gave_up(post)) + stealth_drop_walk() + if(!stealth_walk_arrived(post)) + stealth_walk(post, STEALTH_GOTO_HOME) + return + stand_dir = sentry_dir + queue_behavior(/datum/ai_behavior/stealth_stand) + +/// A route guard: waits at its stop, then walks to the next one it can reach. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_plan_route() + var/mob/living/guard = pawn + var/turf/here = get_turf(guard) + var/list/stops = stealth_stops() + if(at_stop) + if(world.time < wait_until) + queue_behavior(/datum/ai_behavior/stealth_stand) + return + var/next = stealth_next_index() if(!next) - controller.stealth_become_sentry(here) + // Every other stop is shut off: it stays here until one opens. + queue_behavior(/datum/ai_behavior/stealth_stand) return - controller.route_index = next - var/datum/stealth_stop/stop = stops[controller.route_index] - // Leash (abuse R3): a stop outside its region or too far from it means it was carried off. - if(!controller.group.region_matches(stop.spot) || stop.spot.z != here.z || get_dist(stop.spot, here) > STEALTH_LEASH_RANGE) - controller.stealth_become_sentry(here) + at_stop = FALSE + route_index = next + if(world.time < stop_skip_until["[route_index]"]) + var/next = stealth_next_index() + if(!next) + // Shut in: it waits, and tries again when the first skip runs out. + stand_dir = stealth_facing(guard) + queue_behavior(/datum/ai_behavior/stealth_stand) + return + route_index = next + var/datum/stealth_stop/stop = stops[route_index] + // Leash (abuse R3): a stop on another level or in another region means it was carried off. + if(stop.spot.z != here.z || !group.region_matches(here)) + stealth_become_sentry(here) + queue_behavior(/datum/ai_behavior/stealth_stand) return - if(here == stop.spot || (controller.goto_turf == stop.spot && controller.goto_result == "arrived")) - controller.stealth_drop_walk() - controller.stealth_arrive(stop) - controller.queue_behavior(/datum/ai_behavior/stealth_stand) - return SUBTREE_RETURN_FINISH_PLANNING - if(controller.goto_turf == stop.spot && controller.goto_result == "failed") - controller.goto_result = null - controller.stealth_walk(stop.spot, STEALTH_GOTO_PATROL) - return SUBTREE_RETURN_FINISH_PLANNING + if(stealth_walk_arrived(stop.spot)) + stealth_drop_walk() + stealth_arrive(stop) + queue_behavior(/datum/ai_behavior/stealth_stand) + return + // A failed try that hasn't yet shut the stop off: it tries again. + if(stealth_walk_gave_up(stop.spot)) + stealth_drop_walk() + stealth_walk(stop.spot, STEALTH_GOTO_PATROL) /// At a stop: faces its way, waits, and talks if it's a chatter stop and a friend is near. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_arrive(datum/stealth_stop/stop) at_stop = TRUE + home_turf = stop.spot stop_fails["[route_index]"] = 0 wait_until = world.time + stop.wait stand_dir = stop.dir diff --git a/voidcrew/modules/stealth/stealth_perception.dm b/voidcrew/modules/stealth/stealth_perception.dm index 8b1054fffbf..acc23fd0ab9 100644 --- a/voidcrew/modules/stealth/stealth_perception.dm +++ b/voidcrew/modules/stealth/stealth_perception.dm @@ -6,10 +6,12 @@ * can_attack(), so an alert guard keeps tg's all-round check and an attack from behind still lands on a * guard that can turn round (abuse review P1, P2). * - * A scan, once a second while the AI is awake: the nearest few living mobs on the HEARING grid (every mob - * is hearing-sensitive, so SSD bodies and other NPCs count), each passed through the guard's own - * can_attack() first (faction, stat, line of sight, invisibility, the packed-level cordon, Ghostskin - * camo), then the cone, light and movement rules below raise or decay its "notice". + * A scan, once a second while the AI is awake: the living mobs near it on the HEARING grid (every mob is + * hearing-sensitive, so SSD bodies and other NPCs count), nearest first, without its own side or the dead. + * Each goes through the guard's own can_attack() (faction, stat, line of sight, invisibility, the + * packed-level cordon, Ghostskin camo), and the first STEALTH_MAX_CANDIDATES that pass get the cone, light + * and movement rules that raise or decay its "notice". Only mobs it could perceive count toward that cap, + * so a pile of corpses, mice or its own friends can't crowd a player out. */ #ifdef UNIT_TESTS @@ -68,12 +70,21 @@ GLOBAL_LIST_EMPTY(stealth_test_dark_turfs) return (tile_area.base_lighting_alpha / 255) + tile.dynamic_lumcount < STEALTH_DARK_LUMCOUNT return tile.get_lumcount() < STEALTH_DARK_LUMCOUNT -/// The vehicle `who` sits in (a mech or another vehicle), or null. -/proc/stealth_vehicle_of(mob/living/who) - var/atom/holder = who.loc - if(ismecha(holder) || isvehicle(holder)) - return holder - return null +/** + * What `who` is shut inside, as it stands on a turf: the box, locker, bag or car, or the outermost of them. + * Null on a turf of their own, or when someone is carrying them (the carrier is the one to see). + */ +/proc/stealth_shell_of(mob/living/who) + if(isturf(who.loc)) + return null + var/atom/movable/shell = who.loc + while(shell && !isturf(shell.loc)) + shell = shell.loc + if(!ismovable(shell)) + return null + if(isnull(shell) || ismob(shell)) + return null + return shell /** * Whether a cloak hides `who` from a guard's sight beyond the close radius (owner decision 20: cyberware @@ -86,8 +97,8 @@ GLOBAL_LIST_EMPTY(stealth_test_dark_turfs) return !!who.has_status_effect(/datum/status_effect/shadow_cloak) /** - * The living mobs a guard might see, nearest first, at most STEALTH_MAX_CANDIDATES. Uses the spatial - * grid, never view() or hearers(). + * The living mobs a guard might notice within `range`, nearest first: not itself, not its own side, not the + * dead. Uses the spatial grid, never view() or hearers(). The caller caps how many it judges. */ /proc/stealth_candidates(mob/living/guard, range) var/turf/center = get_turf(guard) @@ -95,7 +106,7 @@ GLOBAL_LIST_EMPTY(stealth_test_dark_turfs) return list() var/list/buckets = new /list(range + 1) for(var/mob/living/found in SSspatial_grid.orthogonal_range_search(center, SPATIAL_GRID_CONTENTS_TYPE_HEARING, range)) - if(found == guard) + if(found == guard || found.stat == DEAD) continue var/turf/found_turf = get_turf(found) if(isnull(found_turf) || found_turf.z != center.z) @@ -103,13 +114,12 @@ GLOBAL_LIST_EMPTY(stealth_test_dark_turfs) var/dist = get_dist(center, found_turf) if(dist > range) continue + if(guard.faction_check_atom(found)) + continue var/list/bucket = buckets[dist + 1] if(isnull(bucket)) buckets[dist + 1] = bucket = list() bucket += found . = list() for(var/list/bucket in buckets) - for(var/mob/living/found as anything in bucket) - . += found - if(length(.) >= STEALTH_MAX_CANDIDATES) - return . + . += bucket From 1bd1a7a5d136e4ed0bb3224cfdcd62c89262966d Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:03:12 -0800 Subject: [PATCH 013/152] Stealth tests that run the real guard AI: patrol, walk home, target switch, box, blast, cuffs --- .../modules/unit_tests/voidcrew_stealth.dm | 402 +++++++++++++++++- 1 file changed, 394 insertions(+), 8 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth.dm b/voidcrew/modules/unit_tests/voidcrew_stealth.dm index 5fd22bed15f..4b5dd0f34c6 100644 --- a/voidcrew/modules/unit_tests/voidcrew_stealth.dm +++ b/voidcrew/modules/unit_tests/voidcrew_stealth.dm @@ -1,7 +1,10 @@ // Stealth guards (voidcrew/modules/stealth/). Unit tests compile before voidcrew/_DEFINES, so values from // there are written out: states "unaware", "suspicious", "alert", "searching"; blackboard keys -// "stealth_state", "stealth_wary", "stealth_last_seen_turf"; STEALTH_PATROL_MAX_FAILS 3, -// STEALTH_LOSE_TARGET_TIME 4 s, STEALTH_SEARCH_TIME 40 s, STEALTH_CALM_LOOK_TIME 6 s, STEALTH_WARY_TIME 60 s. +// "stealth_state", "stealth_wary"; STEALTH_PATROL_MAX_FAILS 3, STEALTH_LOSE_TARGET_TIME 4 s, +// STEALTH_SEARCH_TIME 40 s, STEALTH_CALM_LOOK_TIME 6 s, STEALTH_WARY_TIME 60 s, STEALTH_SWITCH_COOLDOWN 4 s. +// +// The tests down to voidcrew_stealth_body_again call the scan and the state procs directly, with the AI off. The +// voidcrew_stealth_live tests at the bottom run the real AI: planning, the scan and real walking. #define STEALTH_TEST_GUARD /mob/living/carbon/human/npc/ruin/trooper/scav @@ -150,7 +153,7 @@ stealth_ai.stealth_rewind(5 SECONDS) stealth_scans(guard, 1) TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "searching", "A guard that lost its target isn't searching") - TEST_ASSERT_EQUAL(stealth_ai.blackboard["stealth_last_seen_turf"], last_seen, "The search isn't at the last place it saw them") + TEST_ASSERT_EQUAL(stealth_ai.last_seen_turf, last_seen, "The search isn't at the last place it saw them") TEST_ASSERT_NULL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], "A searching guard still has a target") qdel(block) @@ -165,7 +168,7 @@ var/mob/living/carbon/human/person = stealth_person(block, 1, 2) SEND_SIGNAL(guard, COMSIG_ATOM_WAS_ATTACKED, person, ATTACKER_DAMAGING_ATTACK) TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "searching", "A guard hit from out of sight isn't searching") - TEST_ASSERT_EQUAL(stealth_ai.blackboard["stealth_last_seen_turf"], get_turf(person), "The search isn't at the attacker's turf") + TEST_ASSERT_EQUAL(stealth_ai.last_seen_turf, get_turf(person), "The search isn't at the attacker's turf") TEST_ASSERT(stealth_ai.blackboard["stealth_wary"], "A guard that was hit isn't wary") qdel(guard) // Pulled. @@ -356,18 +359,21 @@ qdel(guard) qdel(target) qdel(bystander) - // Someone behind it hit it during the fight: it turns on them. + // Someone behind it hits it during the fight: it turns on them, and not straight back when the first one hits it. guard = stealth_guard(block, 6, 2) stealth_ai = stealth_ai(guard) target = stealth_person(block, 8, 2) var/mob/living/carbon/human/flanker = stealth_person(block, 3, 2) stealth_ai.stealth_go_alert(target) SEND_SIGNAL(guard, COMSIG_ATOM_WAS_ATTACKED, flanker, ATTACKER_DAMAGING_ATTACK) - TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], target, "A hit from someone else pulled the guard off its target") - target.death() + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], flanker, "A guard didn't turn on someone else who hit it") + SEND_SIGNAL(guard, COMSIG_ATOM_WAS_ATTACKED, target, ATTACKER_DAMAGING_ATTACK) + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], flanker, "A guard hit by two people flapped straight back to the first") + // The one it turned on dies: the other is still there, and remembered. + flanker.death() stealth_scans(guard, 1) TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard calmed down while someone else was hurting it") - TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], flanker, "The guard didn't turn on whoever else hit it") + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], target, "The guard didn't go back to whoever else hit it") qdel(block) /// Wary fades after STEALTH_WARY_TIME with nothing new; a notice gain starts the clock again. @@ -489,4 +495,384 @@ TEST_ASSERT(!stealth_ai.blackboard["stealth_wary"], "An ally's stray shot made the guard wary") qdel(block) +/// The candidate cap counts only those it could perceive: a crowd of its own side at its feet doesn't hide someone further off. +/datum/unit_test/voidcrew_stealth_cap + +/datum/unit_test/voidcrew_stealth_cap/Run() + var/datum/turf_reservation/block = stealth_block(10, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + // More than STEALTH_MAX_CANDIDATES (8) of its own side, all nearer than the person. + for(var/i in 1 to 10) + var/mob/living/basic/mouse/mouse = allocate(/mob/living/basic/mouse, stealth_at(block, 3, 2)) + mouse.faction = list("scav") // FACTION_SCAV + stealth_person(block, 5, 2) + stealth_scans(guard, 3) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "suspicious", "A crowd of its own side hid someone 3 tiles in front of a guard") + qdel(block) + +/// Notice stops at STEALTH_ALERT_AT (2.5), so someone out of reach can't bank a lead that hides a second person. +/datum/unit_test/voidcrew_stealth_notice_cap + +/datum/unit_test/voidcrew_stealth_notice_cap/Run() + var/datum/turf_reservation/block = stealth_block(20, 7) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 6, 3) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + // Eight tiles off: noticed, never alert (STEALTH_ALERT_MAX_RANGE 7). + var/mob/living/carbon/human/far = stealth_person(block, 14, 3) + stealth_scans(guard, 30) + var/list/entry = stealth_ai.notice_map[REF(far)] + TEST_ASSERT_NOTNULL(entry, "A guard never noticed someone 8 tiles in front of it") + TEST_ASSERT(entry[1] <= 2.5, "Notice climbed past the alert level ([entry[1]])") + // They step out of sight behind it; someone else walks up in front. + far.forceMove(stealth_at(block, 0, 3)) + var/mob/living/carbon/human/near = stealth_person(block, 8, 3) + stealth_scans(guard, 5) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "Someone it could no longer see kept a guard from alerting on the person in front of it") + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], near, "The guard isn't after the person in front of it") + qdel(block) + +/// A friend who went down, got up and went down again is news again. +/datum/unit_test/voidcrew_stealth_body_again + +/datum/unit_test/voidcrew_stealth_body_again/Run() + var/datum/turf_reservation/block = stealth_block(12, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + allocate(/obj/effect/landmark/stealth_group, stealth_at(block, 6, 4)) + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/mob/living/carbon/human/npc/friend = stealth_guard(block, 5, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/datum/ai_controller/basic_controller/human_npc/stealth/friend_ai = stealth_ai(friend) + stealth_ai.stealth_setup() + friend_ai.stealth_setup() + friend.set_body_position(LYING_DOWN) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "suspicious", "A guard didn't react to a friend on the floor") + stealth_ai.stealth_go_unaware() + friend.set_body_position(STANDING_UP) + stealth_ai.next_body_scan = 0 + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A friend back on its feet set a guard off") + friend.death() + stealth_ai.next_body_scan = 0 + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "suspicious", "A guard ignored a friend killed after getting back up") + qdel(block) + +// ===== The real AI ===== + +/** + * Tests that run the guard's real AI: planning, the scan and real walking, over a few seconds of game time. A + * person on SSmobs' list of players keeps the level awake, as a player there does. Every wait is on game time + * with a long deadline and ends as soon as the thing happens, so a slow machine only makes them slower. + */ +/datum/unit_test/voidcrew_stealth_live + abstract_type = /datum/unit_test/voidcrew_stealth_live + /// The walled room this test runs in + var/datum/turf_reservation/room + /// person => the z-level they were listed on + var/list/players = list() + +/datum/unit_test/voidcrew_stealth_live/Destroy() + for(var/mob/living/person as anything in players) + SSmobs.clients_by_zlevel[players[person]] -= person + players = null + . = ..() + QDEL_NULL(room) + +/// Builds a lit, walled, floored room `width` x `height` inside (walls keep its air in). FALSE if there is no room. +/datum/unit_test/voidcrew_stealth_live/proc/make_room(width, height) + room = SSmapping.request_turf_block_reservation(width + 2, height + 2, 1) + if(isnull(room)) + return FALSE + var/turf/corner = room.bottom_left_turfs[1] + for(var/turf/spot as anything in block(corner.x, corner.y, corner.z, corner.x + width + 1, corner.y + height + 1, corner.z)) + var/edge = spot.x == corner.x || spot.y == corner.y || spot.x == corner.x + width + 1 || spot.y == corner.y + height + 1 + spot.ChangeTurf(edge ? /turf/closed/wall : /turf/open/floor/iron) + GLOB.stealth_test_dark_turfs[spot] = FALSE + return TRUE + +/// The floor (dx, dy) from the room's inside corner. +/datum/unit_test/voidcrew_stealth_live/proc/at(dx, dy) + var/turf/corner = room.bottom_left_turfs[1] + return locate(corner.x + 1 + dx, corner.y + 1 + dy, corner.z) + +/datum/unit_test/voidcrew_stealth_live/proc/guard_at(dx, dy, facing = EAST) + var/mob/living/carbon/human/npc/guard = allocate(STEALTH_TEST_GUARD, at(dx, dy)) + guard.setDir(facing) + guard.AddElement(/datum/element/forced_gravity, 1) + return guard + +/// A walking person the guards can't hurt (the tests are about what the guards do, not how the fight goes). +/// `listed` puts them on SSmobs' list of players. +/datum/unit_test/voidcrew_stealth_live/proc/person_at(dx, dy, listed = TRUE) + var/mob/living/carbon/human/consistent/person = allocate(/mob/living/carbon/human/consistent, at(dx, dy)) + if(person.move_intent == MOVE_INTENT_RUN) + person.toggle_move_intent() + person.AddElement(/datum/element/forced_gravity, 1) + person.physiology.brute_mod = 0 + person.physiology.burn_mod = 0 + person.physiology.stamina_mod = 0 + if(listed) + var/turf/where = get_turf(person) + SSmobs.clients_by_zlevel[where.z] += person + players[person] = where.z + return person + +/// Switches a guard's AI on, as someone arriving on its level does. +/datum/unit_test/voidcrew_stealth_live/proc/wake(mob/living/guard) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + stealth_ai.reset_ai_status() + return stealth_ai + +/// A stop on route "a". +/datum/unit_test/voidcrew_stealth_live/proc/stop_at(dx, dy, order, wait = 5) + var/obj/effect/landmark/stealth_patrol/stop = allocate(/obj/effect/landmark/stealth_patrol, at(dx, dy)) + stop.order = order + stop.wait = wait + return stop + +/// A route leg longer than tg's 14-tile walk limit, with the only player far off behind a wall: it gets there. +/datum/unit_test/voidcrew_stealth_live/patrol + +/datum/unit_test/voidcrew_stealth_live/patrol/Run() + TEST_ASSERT(make_room(40, 5), "No room for the test") + allocate(/obj/effect/landmark/stealth_group, at(0, 4)) + stop_at(1, 2, 1) + stop_at(26, 2, 2) + // A wall between the far stop and the player, who is 37 tiles from the guard: past tg's idle radius. + for(var/dy in 0 to 4) + var/turf/wall_spot = at(32, dy) + wall_spot.ChangeTurf(/turf/closed/wall) + person_at(38, 2) + var/mob/living/carbon/human/npc/guard = guard_at(1, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = wake(guard) + TEST_ASSERT_EQUAL(stealth_ai.ai_status, AI_STATUS_ON, "A guard with a player far off on its level isn't fully awake") + var/turf/far_stop = at(26, 2) + var/deadline = world.time + 90 SECONDS + while(get_turf(guard) != far_stop && world.time < deadline) + sleep(2) + TEST_ASSERT_EQUAL(stealth_ai.route_id, "a", "The guard never claimed its route") + TEST_ASSERT_EQUAL(get_turf(guard), far_stop, "A guard didn't finish a 25-tile route leg (it is [get_dist(guard, far_stop)] tiles short)") + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A patrolling guard with nobody in sight isn't unaware") + // And back: the route goes on. + var/turf/near_stop = at(1, 2) + deadline = world.time + 90 SECONDS + while(get_turf(guard) != near_stop && world.time < deadline) + sleep(2) + TEST_ASSERT_EQUAL(get_turf(guard), near_stop, "A guard didn't walk back along its route") + TEST_ASSERT_NOTNULL(stealth_ai.route_id, "A guard gave up its route") + +/// Left far from its route by a chase, a guard walks back to it; a sentry walks back to its post. +/datum/unit_test/voidcrew_stealth_live/walk_home + +/datum/unit_test/voidcrew_stealth_live/walk_home/Run() + TEST_ASSERT(make_room(52, 5), "No room for the test") + allocate(/obj/effect/landmark/stealth_group, at(0, 2)) + stop_at(1, 0, 1, 50) + stop_at(4, 0, 2, 50) + for(var/dy in 0 to 4) + var/turf/wall_spot = at(49, dy) + wall_spot.ChangeTurf(/turf/closed/wall) + person_at(50, 2) + var/mob/living/carbon/human/npc/walker = guard_at(1, 0) + // Four tiles from the nearest stop: past STEALTH_ROUTE_CLAIM_RANGE (2), so a sentry. + var/mob/living/carbon/human/npc/sentry = guard_at(1, 4, SOUTH) + var/datum/ai_controller/basic_controller/human_npc/stealth/walker_ai = wake(walker) + var/datum/ai_controller/basic_controller/human_npc/stealth/sentry_ai = wake(sentry) + walker_ai.stealth_setup() + sentry_ai.stealth_setup() + TEST_ASSERT_EQUAL(walker_ai.route_id, "a", "The guard on the route didn't claim it") + TEST_ASSERT_NULL(sentry_ai.route_id, "The guard off the route claimed it") + var/turf/post = at(1, 4) + // Where a long chase ends: 43 tiles from the route. Further than STEALTH_LEASH_RANGE (24), and than one walk + // (STEALTH_WALK_LEG 40), so the way back is in two legs. + walker.forceMove(at(47, 0)) + sentry.forceMove(at(47, 4)) + walker_ai.stealth_go_searching(at(47, 0)) + sentry_ai.stealth_go_searching(at(47, 4)) + walker_ai.stealth_rewind(41 SECONDS) + sentry_ai.stealth_rewind(41 SECONDS) + var/deadline = world.time + 120 SECONDS + while(world.time < deadline && !(get_turf(sentry) == post && walker_ai.at_stop)) + sleep(2) + TEST_ASSERT_EQUAL(walker_ai.route_id, "a", "A guard left far from its route became a sentry") + TEST_ASSERT(walker_ai.at_stop, "A guard left far from its route never got back to a stop (it is at [walker.x - at(0, 0).x], [walker.y - at(0, 0).y])") + TEST_ASSERT(get_dist(walker, at(4, 0)) <= 3, "The guard is at a stop that isn't on its route") + TEST_ASSERT_EQUAL(get_turf(sentry), post, "A sentry left far from its post didn't walk back (it is [get_dist(sentry, post)] tiles off)") + TEST_ASSERT_EQUAL(sentry_ai.home_turf, post, "A sentry's post moved") + +/// Searching, it goes straight to alert on someone it glimpses, never down to suspicious. +/datum/unit_test/voidcrew_stealth_live/search_to_alert + +/datum/unit_test/voidcrew_stealth_live/search_to_alert/Run() + TEST_ASSERT(make_room(14, 5), "No room for the test") + var/mob/living/carbon/human/npc/guard = guard_at(3, 2) + var/mob/living/carbon/human/person = person_at(12, 0) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = wake(guard) + stealth_ai.stealth_go_searching(at(3, 2)) + // Three tiles in front of it: one scan's worth of notice is past suspicious but short of alert. + person.forceMove(at(6, 2)) + var/went_suspicious = FALSE + var/deadline = world.time + 20 SECONDS + while(stealth_ai.stealth_state() != "alert" && world.time < deadline) + if(stealth_ai.stealth_state() == "suspicious") + went_suspicious = TRUE + sleep(1) + TEST_ASSERT(!went_suspicious, "A searching guard that glimpsed someone dropped to suspicious") + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A searching guard didn't go alert on someone three tiles in front of it") + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], person, "The guard isn't after the person it glimpsed") + +/// Alert on a decoy it can't reach, it still notices someone nearer and turns on them; it doesn't flap back. +/datum/unit_test/voidcrew_stealth_live/switch_target + +/datum/unit_test/voidcrew_stealth_live/switch_target/Run() + TEST_ASSERT(make_room(14, 5), "No room for the test") + // A glass wall: the decoy beyond it is in plain sight and out of reach. + for(var/dy in 0 to 4) + allocate(/obj/structure/window/reinforced/fulltile, at(8, dy)) + var/mob/living/carbon/human/npc/guard = guard_at(3, 2) + var/mob/living/carbon/human/decoy = person_at(11, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = wake(guard) + stealth_ai.stealth_go_alert(decoy) + sleep(3 SECONDS) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard gave up on someone standing in plain sight behind glass") + // tg drops the target key each time the walk at the glass fails; the next scan takes it back. Its focus stays. + TEST_ASSERT_EQUAL(stealth_ai.blackboard["stealth_focus"], decoy, "A guard forgot the person behind the glass") + // Someone else beside it, on its side of the glass. + var/mob/living/carbon/human/intruder = person_at(1, 2, FALSE) + intruder.forceMove(get_step(guard, WEST) || at(1, 2)) + var/deadline = world.time + 30 SECONDS + while(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET] != intruder && world.time < deadline) + // Kept at its shoulder, wherever it has got to. + if(get_dist(intruder, guard) > 1) + var/turf/beside = get_step(guard, WEST) + if(beside && !beside.is_blocked_turf()) + intruder.forceMove(beside) + sleep(2) + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], intruder, "A guard busy with a decoy never turned on someone at its shoulder") + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard that changed target isn't alert") + // The decoy hits it at once: too soon to turn back (STEALTH_SWITCH_COOLDOWN 4 s), and the decoy is no nearer. + SEND_SIGNAL(guard, COMSIG_ATOM_WAS_ATTACKED, decoy, ATTACKER_DAMAGING_ATTACK) + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], intruder, "A guard flapped back to its old target the moment it was hit") + var/flapped = FALSE + deadline = world.time + 3 SECONDS + while(world.time < deadline) + if(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET] == decoy) + flapped = TRUE + sleep(2) + TEST_ASSERT(!flapped, "A guard flapped back to the decoy while someone stood nearer") + // Later, a hit from the decoy does turn it. + stealth_ai.stealth_rewind(5 SECONDS) + SEND_SIGNAL(guard, COMSIG_ATOM_WAS_ATTACKED, decoy, ATTACKER_DAMAGING_ATTACK) + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], decoy, "A guard didn't turn on someone who hit it once the cooldown had passed") + +/// Someone in a cardboard box: unseen while it sits still, noticed when it moves at the guard's shoulder, found when it bumps it. +/datum/unit_test/voidcrew_stealth_live/box + +/datum/unit_test/voidcrew_stealth_live/box/Run() + TEST_ASSERT(make_room(16, 7), "No room for the test") + person_at(15, 6) + var/mob/living/carbon/human/npc/guard = guard_at(3, 3, WEST) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = wake(guard) + var/mob/living/carbon/human/stowaway = person_at(6, 3, FALSE) + var/obj/structure/closet/cardboard/box = allocate(/obj/structure/closet/cardboard, at(6, 3)) + stowaway.forceMove(box) + // Still, three tiles behind it, then still at two. + sleep(3 SECONDS) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A guard noticed a box that never moved") + TEST_ASSERT(LAZYACCESS(stealth_ai.hidden_spots, REF(stowaway)), "The guard's scan never found the person in the box (the test can't tell hidden from missing)") + // It shuffles about within two tiles. + var/list/spots = list(at(5, 3), at(5, 4), at(5, 3), at(5, 2)) + var/spot_index = 0 + var/deadline = world.time + 30 SECONDS + while(stealth_ai.stealth_state() == "unaware" && world.time < deadline) + spot_index = (spot_index % length(spots)) + 1 + box.forceMove(spots[spot_index]) + sleep(1.2 SECONDS) + TEST_ASSERT(stealth_ai.stealth_state() != "unaware", "A guard ignored a box shuffling about at its shoulder") + // A second guard, and a box that walks into it. + var/mob/living/carbon/human/npc/second = guard_at(12, 1, WEST) + var/datum/ai_controller/basic_controller/human_npc/stealth/second_ai = wake(second) + second_ai.stealth_setup() + var/mob/living/carbon/human/second_person = person_at(14, 1, FALSE) + var/obj/structure/closet/cardboard/second_box = allocate(/obj/structure/closet/cardboard, at(13, 1)) + second_person.forceMove(second_box) + step(second_box, WEST) + TEST_ASSERT_EQUAL(second_ai.stealth_state(), "alert", "A box that walked into a guard wasn't found out") + TEST_ASSERT_EQUAL(second_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], second_person, "The guard isn't after whoever is in the box") + +/// A blast with nobody to blame: the guard it hits and the friend near it both go looking there. A hit with a name doesn't rouse the friend. +/datum/unit_test/voidcrew_stealth_live/blast + +/datum/unit_test/voidcrew_stealth_live/blast/Run() + TEST_ASSERT(make_room(30, 5), "No room for the test") + allocate(/obj/effect/landmark/stealth_group, at(0, 4)) + for(var/dy in 0 to 4) + var/turf/wall_spot = at(20, dy) + wall_spot.ChangeTurf(/turf/closed/wall) + var/mob/living/carbon/human/person = person_at(25, 2) + var/mob/living/carbon/human/npc/struck = guard_at(3, 2, WEST) + var/mob/living/carbon/human/npc/friend = guard_at(8, 2, EAST) + var/datum/ai_controller/basic_controller/human_npc/stealth/struck_ai = wake(struck) + var/datum/ai_controller/basic_controller/human_npc/stealth/friend_ai = wake(friend) + sleep(2 SECONDS) + TEST_ASSERT_EQUAL(struck_ai.group, friend_ai.group, "Two guards by one marker aren't in one group") + TEST_ASSERT_EQUAL(friend_ai.stealth_state(), "unaware", "A guard with nothing going on isn't unaware") + // A named hit first: the friend, facing away, hears nothing of it. + SEND_SIGNAL(struck, COMSIG_ATOM_WAS_ATTACKED, person, ATTACKER_DAMAGING_ATTACK) + struck.Stun(0.5 SECONDS) + sleep(1 SECONDS) + TEST_ASSERT_EQUAL(struck_ai.stealth_state(), "searching", "A guard hit from out of sight isn't searching") + TEST_ASSERT_EQUAL(friend_ai.stealth_state(), "unaware", "A hit with a name on it roused the guard's friend") + // Now a blast. + struck_ai.stealth_go_unaware() + struck_ai.stealth_clear_wary() + struck_ai.stealth_rewind(30 SECONDS) + var/turf/where_hit = get_turf(struck) + EX_ACT(struck, EXPLODE_LIGHT) + var/deadline = world.time + 5 SECONDS + while(friend_ai.stealth_state() != "searching" && world.time < deadline) + sleep(1) + TEST_ASSERT_EQUAL(friend_ai.stealth_state(), "searching", "A blast on a guard didn't send its friend looking") + TEST_ASSERT_EQUAL(friend_ai.last_seen_turf, where_hit, "The friend isn't looking where the blast was") + TEST_ASSERT(friend_ai.blackboard["stealth_wary"], "The friend isn't wary after the blast") + TEST_ASSERT(struck_ai.blackboard["stealth_wary"], "A guard caught in a blast isn't wary") + TEST_ASSERT(struck.stat != CONSCIOUS || struck_ai.stealth_state() == "searching", "A guard caught in a blast went back to its post") + +/// The mark over an alert guard's head goes when it is cuffed, and stays gone while it is. +/datum/unit_test/voidcrew_stealth_live/cuffed_mark + +/datum/unit_test/voidcrew_stealth_live/cuffed_mark/Run() + TEST_ASSERT(make_room(10, 5), "No room for the test") + var/mob/living/carbon/human/npc/guard = guard_at(3, 2) + var/mob/living/carbon/human/person = person_at(5, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = wake(guard) + stealth_ai.stealth_go_alert(person) + var/deadline = world.time + 10 SECONDS + while(isnull(stealth_ai.meter?.mark) && world.time < deadline) + sleep(1) + TEST_ASSERT_NOTNULL(stealth_ai.meter?.mark, "An alert guard with someone beside it has no mark") + var/obj/effect/abstract/stealth_mark/mark = stealth_ai.meter.mark + TEST_ASSERT(mark in guard.vis_contents, "The mark isn't drawn on the guard") + guard.set_handcuffed(new /obj/item/restraints/handcuffs(guard)) + guard.update_handcuffed() + TEST_ASSERT(HAS_TRAIT(guard, TRAIT_RESTRAINED), "The cuffs didn't go on") + deadline = world.time + 5 SECONDS + while(!isnull(stealth_ai.meter) && world.time < deadline) + sleep(1) + TEST_ASSERT_NULL(stealth_ai.meter, "A cuffed guard kept its mark") + TEST_ASSERT(!(mark in guard.vis_contents), "A cuffed guard still draws its mark") + // Hitting it while it is cuffed doesn't bring the mark back. + SEND_SIGNAL(guard, COMSIG_ATOM_WAS_ATTACKED, person, ATTACKER_DAMAGING_ATTACK) + var/came_back = FALSE + deadline = world.time + 3 SECONDS + while(world.time < deadline) + if(stealth_ai.meter) + came_back = TRUE + sleep(1) + TEST_ASSERT(!came_back, "A mark came back over a cuffed guard") + #undef STEALTH_TEST_GUARD From 38eec77e2e2eb53ece4ca04332f1ea1e706792c3 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:54:17 -0800 Subject: [PATCH 014/152] Stealth guards hear: gunfire by loudness, footsteps, thrown things, knocks, glass, doors, bodies, shouts and radios Guards react to noise as a group. A small noise sends one guard to look, a second at the same spot two, a third everyone; a guard that never comes back is missed. Gunfire, blasts and screams send the nearest few running while sentries hold. Suppressed guns are heard only close by and in the open. Running is heard and walking is not, by tg's own footstep rules, and running no longer makes you easier to see. Guards who see or feel someone shout for help; lookouts carry an earpiece and call it in after three seconds unless they are dropped, jammed or EMPed first. A guard that heard something shows a grey ?. No sound ever gives a guard a target. Adds the Stealth Noise admin verb and a noise view. --- tgstation.dme | 6 + voidcrew/_DEFINES/stealth.dm | 1 - voidcrew/_DEFINES/stealth_hearing.dm | 165 ++++++ voidcrew/modules/stealth/scav_guards.dm | 55 +- voidcrew/modules/stealth/stealth_admin.dm | 25 +- .../modules/stealth/stealth_admin_noise.dm | 141 +++++ voidcrew/modules/stealth/stealth_ai.dm | 86 ++- voidcrew/modules/stealth/stealth_hearing.dm | 239 +++++++++ voidcrew/modules/stealth/stealth_meter.dm | 39 +- voidcrew/modules/stealth/stealth_noise.dm | 498 ++++++++++++++++++ .../modules/stealth/stealth_noise_sources.dm | 264 ++++++++++ voidcrew/modules/stealth/stealth_patrol.dm | 15 + voidcrew/modules/stealth/stealth_radio.dm | 149 ++++++ 13 files changed, 1647 insertions(+), 36 deletions(-) create mode 100644 voidcrew/_DEFINES/stealth_hearing.dm create mode 100644 voidcrew/modules/stealth/stealth_admin_noise.dm create mode 100644 voidcrew/modules/stealth/stealth_hearing.dm create mode 100644 voidcrew/modules/stealth/stealth_noise.dm create mode 100644 voidcrew/modules/stealth/stealth_noise_sources.dm create mode 100644 voidcrew/modules/stealth/stealth_radio.dm diff --git a/tgstation.dme b/tgstation.dme index 40fb9422b1e..25ed4aac37d 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6742,6 +6742,7 @@ #include "voidcrew\_DEFINES\shuttles.dm" #include "voidcrew\_DEFINES\sound.dm" #include "voidcrew\_DEFINES\stealth.dm" +#include "voidcrew\_DEFINES\stealth_hearing.dm" #include "voidcrew\_DEFINES\subsystems.dm" #include "voidcrew\_DEFINES\techweb_nodes.dm" #include "voidcrew\_DEFINES\trade.dm" @@ -7729,10 +7730,15 @@ #include "voidcrew\modules\slime_extract_piles\slime_extract_piles.dm" #include "voidcrew\modules\stealth\scav_guards.dm" #include "voidcrew\modules\stealth\stealth_admin.dm" +#include "voidcrew\modules\stealth\stealth_admin_noise.dm" #include "voidcrew\modules\stealth\stealth_ai.dm" +#include "voidcrew\modules\stealth\stealth_hearing.dm" #include "voidcrew\modules\stealth\stealth_meter.dm" +#include "voidcrew\modules\stealth\stealth_noise.dm" +#include "voidcrew\modules\stealth\stealth_noise_sources.dm" #include "voidcrew\modules\stealth\stealth_patrol.dm" #include "voidcrew\modules\stealth\stealth_perception.dm" +#include "voidcrew\modules\stealth\stealth_radio.dm" #include "voidcrew\modules\stealth\stealth_test_ruin.dm" #include "voidcrew\modules\storage\smart_locker.dm" #include "voidcrew\modules\storage\wall_locker.dm" diff --git a/voidcrew/_DEFINES/stealth.dm b/voidcrew/_DEFINES/stealth.dm index 28a9ff9ab02..53bc8633070 100644 --- a/voidcrew/_DEFINES/stealth.dm +++ b/voidcrew/_DEFINES/stealth.dm @@ -65,7 +65,6 @@ #define STEALTH_NOTICE_DECAY 0.34 #define STEALTH_NOTICE_FORGET (20 SECONDS) #define STEALTH_BEHIND_RATE 0.4 -#define STEALTH_RUN_MULT 2 #define STEALTH_STILL_MULT 0.5 #define STEALTH_LYING_MULT 0.5 #define STEALTH_SUSPICIOUS_MULT 1.5 diff --git a/voidcrew/_DEFINES/stealth_hearing.dm b/voidcrew/_DEFINES/stealth_hearing.dm new file mode 100644 index 00000000000..305e42b692a --- /dev/null +++ b/voidcrew/_DEFINES/stealth_hearing.dm @@ -0,0 +1,165 @@ +// Stealth guards: hearing (voidcrew/modules/stealth/stealth_noise.dm and friends). Step 2 of the stealth layer. + +// ===== Noise kinds (stealth_noise()) ===== +#define STEALTH_NOISE_GUN_QUIET "gun_quiet" +#define STEALTH_NOISE_GUN_PISTOL "gun_pistol" +#define STEALTH_NOISE_GUN_SMG "gun_smg" +#define STEALTH_NOISE_GUN_LONG "gun_long" +#define STEALTH_NOISE_IMPACT "impact" +#define STEALTH_NOISE_BLAST "blast" +#define STEALTH_NOISE_GLASS "glass" +#define STEALTH_NOISE_THROWN "thrown" +#define STEALTH_NOISE_KNOCK "knock" +#define STEALTH_NOISE_BASH "bash" +#define STEALTH_NOISE_DOOR "door" +#define STEALTH_NOISE_THUD "thud" +#define STEALTH_NOISE_VOICE "voice" +#define STEALTH_NOISE_SCREAM "scream" +#define STEALTH_NOISE_SHOUT "shout" + +/// Every kind, for the admin verb and the tests +#define STEALTH_NOISE_KINDS list(\ + STEALTH_NOISE_GUN_QUIET,\ + STEALTH_NOISE_GUN_PISTOL,\ + STEALTH_NOISE_GUN_SMG,\ + STEALTH_NOISE_GUN_LONG,\ + STEALTH_NOISE_IMPACT,\ + STEALTH_NOISE_BLAST,\ + STEALTH_NOISE_GLASS,\ + STEALTH_NOISE_THROWN,\ + STEALTH_NOISE_KNOCK,\ + STEALTH_NOISE_BASH,\ + STEALTH_NOISE_DOOR,\ + STEALTH_NOISE_THUD,\ + STEALTH_NOISE_VOICE,\ + STEALTH_NOISE_SCREAM,\ + STEALTH_NOISE_SHOUT,\ +) + +// ===== What a guard does about a noise ===== +/// Turns to face it and carries on +#define STEALTH_HEAR_GLANCE 1 +/// One guard goes to look, then two, then everyone +#define STEALTH_HEAR_INVESTIGATE 2 +/// The nearest few run to it and search; the rest hold, wary +#define STEALTH_HEAR_ALARM 3 +/// A friend calling: whoever hears it runs to the one shouting +#define STEALTH_HEAR_SHOUT 4 + +// Fields of a stealth_noise_profile() list +#define STEALTH_PROFILE_REACH 1 +#define STEALTH_PROFILE_LEVEL 2 +#define STEALTH_PROFILE_FLAGS 3 + +// ===== Noise flags ===== +/// Unheard through any wall or closed door +#define STEALTH_NOISE_LOS_ONLY (1<<0) +/// Walls and doors take half as much off it +#define STEALTH_NOISE_LOUD (1<<1) +/// Heard even when one of their own made it +#define STEALTH_NOISE_ALLIES_COUNT (1<<2) +/// Heard only by the group of the guard that made it +#define STEALTH_NOISE_GROUP_ONLY (1<<3) + +// ===== Radio call kinds, lowest first: a pending call is only replaced by a more urgent one ===== +#define STEALTH_CALL_REPORT 1 +#define STEALTH_CALL_MISSING 2 +#define STEALTH_CALL_ALARM 3 + +// Fields of a controller's pending_call +#define STEALTH_CALL_KIND 1 +#define STEALTH_CALL_WHERE 2 +#define STEALTH_CALL_DUE 3 +#define STEALTH_CALL_NAME 4 + +// ===== Bark kinds ===== +#define STEALTH_BARK_HEARD "stealth_heard" +#define STEALTH_BARK_HEARD_AGAIN "stealth_heard_again" +#define STEALTH_BARK_GUNFIRE "stealth_gunfire" +#define STEALTH_BARK_HOLD "stealth_hold" +#define STEALTH_BARK_HELP "stealth_help" +#define STEALTH_BARK_MISSING "stealth_missing" +#define STEALTH_BARK_RADIO_REPORT "stealth_radio_report" +#define STEALTH_BARK_RADIO_ALARM "stealth_radio_alarm" +#define STEALTH_BARK_RADIO_MISSING "stealth_radio_missing" +#define STEALTH_BARK_COPY "stealth_copy" +#define STEALTH_BARK_RADIO_DEAD "stealth_radio_dead" + +// ===== Numbers (first playtest defaults; the owner tunes them) ===== +// Reach in open air, in tiles. Each is at or under how far a player hears the same tg sound. +#define STEALTH_NOISE_RANGE_GUN_QUIET 4 +#define STEALTH_NOISE_RANGE_GUN_PISTOL 9 +#define STEALTH_NOISE_RANGE_GUN_SMG 11 +#define STEALTH_NOISE_RANGE_GUN_LONG 14 +#define STEALTH_NOISE_RANGE_IMPACT 5 +#define STEALTH_NOISE_RANGE_IMPACT_QUIET 1 +#define STEALTH_NOISE_RANGE_BLAST 16 +#define STEALTH_NOISE_RANGE_GLASS 8 +#define STEALTH_NOISE_RANGE_THROWN_SMALL 4 +#define STEALTH_NOISE_RANGE_THROWN_MEDIUM 6 +#define STEALTH_NOISE_RANGE_THROWN_HEAVY 8 +#define STEALTH_NOISE_RANGE_KNOCK 5 +#define STEALTH_NOISE_RANGE_BASH 7 +#define STEALTH_NOISE_RANGE_DOOR 4 +#define STEALTH_NOISE_RANGE_THUD 4 +#define STEALTH_NOISE_RANGE_VOICE 5 +#define STEALTH_NOISE_RANGE_SCREAM 9 +#define STEALTH_NOISE_RANGE_SHOUT 9 +/// Tiles of reach a wall eats +#define STEALTH_MUFFLE_WALL 3 +/// Tiles of reach a closed solid door eats +#define STEALTH_MUFFLE_DOOR 2 +/// Gunfire and blasts go through walls better +#define STEALTH_MUFFLE_LOUD_MULT 0.5 +/// Past this many walls and doors nothing is heard +#define STEALTH_MUFFLE_MAX_BLOCKERS 3 +/// Inside this many muffled tiles a guard knows the spot +#define STEALTH_NOISE_EXACT_RANGE 6 +/// One tile of error per this many tiles past exact +#define STEALTH_NOISE_FUZZ_STEP 3 +#define STEALTH_NOISE_FUZZ_MAX 3 +/// The same kind of noise this soon and this near an earlier one is the same noise +#define STEALTH_NOISE_COALESCE (0.5 SECONDS) +#define STEALTH_NOISE_COALESCE_RANGE 3 +#define STEALTH_GLANCE_TIME (3 SECONDS) +/// How long a guard told to hold faces the noise before it goes back to its round +#define STEALTH_HOLD_TIME (10 SECONDS) +/// A search moves to a newer noise at most this often +#define STEALTH_RECENTRE_COOLDOWN (4 SECONDS) +/// "The same spot" for counting lures +#define STEALTH_LURE_RADIUS 6 +#define STEALTH_LURE_MEMORY (3 MINUTES) +/// A sentry leaves its post only for a noise this close +#define STEALTH_SENTRY_INVESTIGATE_RANGE 4 +/// How long a guard that went to look has before its friends miss it +#define STEALTH_CHECKIN_TIME (30 SECONDS) +#define STEALTH_ALARM_RESPONDERS 4 +/// After two alarms in memory +#define STEALTH_ALARM_RESPONDERS_LATER 2 +#define STEALTH_ALARM_MEMORY (3 MINUTES) +/// While alert with its target in view +#define STEALTH_SHOUT_INTERVAL (10 SECONDS) +/// Hand to the earpiece, then this long before the call goes out +#define STEALTH_RADIO_CALL_TIME (3 SECONDS) +/// A call this far past due (its level slept) is never made +#define STEALTH_RADIO_CALL_STALE (5 SECONDS) +/// One alarm delivery per group per this long +#define STEALTH_RADIO_GROUP_COOLDOWN (10 SECONDS) +/// Carriers that move on a call +#define STEALTH_RADIO_RESPONDERS 4 +/// Who a carrier's answer carries to, in muffled tiles +#define STEALTH_RELAY_VOICE_RANGE 5 +#define STEALTH_RELAY_VOICE_RESPONDERS 2 +/// How far a running step on plain floor in shoes carries to a guard +#define STEALTH_FOOTSTEP_RANGE 5 +/// How far a player hears that same step (tg: volume 75, range 17 - 1 - 6) +#define STEALTH_FOOTSTEP_PLAYER_RANGE 8 +/// The furthest any step is listened for +#define STEALTH_FOOTSTEP_MAX_RANGE 7 +#define STEALTH_FOOTSTEP_CRAWL_RANGE 2 +/// Notice a heard step adds +#define STEALTH_FOOTSTEP_NOTICE 0.5 +/// How long the admin noise view keeps one event on screen +#define STEALTH_NOISE_VIEW_TIME (4 SECONDS) +/// icon_state of the empty grey "?" over a guard that heard something (stealth_meter.dmi) +#define STEALTH_METER_HEARD_STATE "q_y0" diff --git a/voidcrew/modules/stealth/scav_guards.dm b/voidcrew/modules/stealth/scav_guards.dm index 5566367635e..69f98b05c67 100644 --- a/voidcrew/modules/stealth/scav_guards.dm +++ b/voidcrew/modules/stealth/scav_guards.dm @@ -17,8 +17,19 @@ STEALTH_BARK_SEARCH = list("Come on out.", "I know you're here."), STEALTH_BARK_HIT_UNSEEN = list("Contact!", "Where's that coming from?"), STEALTH_BARK_BODY = list("Man down!", "Who did this?"), - STEALTH_BARK_CHATTER = list("Shipment's late again.", "My feet are killing me.", "You hear that? No? Good."), + STEALTH_BARK_CHATTER = list("Shipment's late again.", "My feet are killing me.", "You hear that? No? Good.", "Radio check.", "Quiet tonight. I hate quiet."), STEALTH_BARK_DOWNED = list("Got 'em.", "Stay down.", "That's handled."), + STEALTH_BARK_HEARD = list("What was that?", "You hear that?", "Hm."), + STEALTH_BARK_HEARD_AGAIN = list("Again?", "That's twice now."), + STEALTH_BARK_GUNFIRE = list("Shots!", "That's gunfire!", "Shots, %SIDE% side!"), + STEALTH_BARK_HOLD = list("Stay put.", "Eyes open."), + STEALTH_BARK_HELP = list("Over here!", "Need a hand!"), + STEALTH_BARK_MISSING = list("%NAME%? ...%NAME%!", "Where's %NAME% got to?", "Should've been back by now."), + STEALTH_BARK_RADIO_REPORT = list("Checking something, %SIDE% side.", "Checking something."), + STEALTH_BARK_RADIO_ALARM = list("Contact, %SIDE% side! Get over here!", "Trouble, %SIDE% side!", "Trouble! Get over here!"), + STEALTH_BARK_RADIO_MISSING = list("%NAME%, come in.", "%NAME%'s not answering.", "Got one not answering."), + STEALTH_BARK_COPY = list("Copy.", "Moving.", "On my way."), + STEALTH_BARK_RADIO_DEAD = list("Radio's dead.", "Nothing but static."), ) emotes = list( HUMAN_NPC_BARK_DOWNED = list("collapses."), @@ -28,12 +39,20 @@ STEALTH_BARK_HIT_UNSEEN = list("ducks."), STEALTH_BARK_CHATTER = list("yawns.", "stretches."), STEALTH_BARK_DOWNED = list("spits."), + STEALTH_BARK_HEARD = list("cocks their head.", "looks round."), + STEALTH_BARK_GUNFIRE = list("flinches."), + STEALTH_BARK_HOLD = list("shifts their grip."), + STEALTH_BARK_RADIO_DEAD = list("taps their earpiece."), ) chances = list(RUIN_NPC_BARK_SPOT = 100) /datum/human_npc_voice/scav /// For the bark being said now: "line" or "emote" when its kind has both, else null var/say_as + /// For the bark being said now: the side of the den a line names ("east"), or null + var/fill_side + /// For the bark being said now: the friend a line asks after, or null. A guard's own name, never player text. + var/fill_name // A line and an emote together read as a script: a kind with both gets one or the other, picked once a bark. // The spot line always comes, even right after a stealth line. @@ -47,8 +66,24 @@ /datum/human_npc_voice/scav/emotes_for(kind) return say_as == "line" ? null : ..() +// A line that names a side of the den ("%SIDE%") or a friend ("%NAME%") is only said when there is one to name. /datum/human_npc_voice/scav/lines_for(kind) - return say_as == "emote" ? null : ..() + if(say_as == "emote") + return null + var/list/written = ..() + if(!length(written)) + return written + . = list() + for(var/line in written) + if(findtext(line, "%SIDE%")) + if(!fill_side) + continue + line = replacetext(line, "%SIDE%", fill_side) + if(findtext(line, "%NAME%")) + if(!fill_name) + continue + line = replacetext(line, "%NAME%", fill_name) // xss-safe: an NPC guard's own name, set by code, never player text + . += line /mob/living/carbon/human/npc/ruin/trooper/scav name = "scav guard" @@ -61,6 +96,18 @@ /mob/living/carbon/human/npc/ruin/trooper/scav/gunner npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pistol +/// A gunner with an earpiece: on its group's radio net (stealth_radio.dm). +/mob/living/carbon/human/npc/ruin/trooper/scav/lookout + name = "scav lookout" + npc_name = "Scav Lookout" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pistol + stealth_radio = TRUE + +/mob/living/carbon/human/npc/ruin/trooper/scav/lookout/npc_dress(datum/human_npc_order/order) + . = ..() + if(isnull(ears)) + equip_to_slot_or_del(new /obj/item/radio/headset(src), ITEM_SLOT_EARS) + /obj/effect/spawner/human_npc/ruin/scav name = "scav guard spawner" npc_type = /mob/living/carbon/human/npc/ruin/trooper/scav @@ -68,3 +115,7 @@ /obj/effect/spawner/human_npc/ruin/scav/gunner name = "scav gunner spawner" npc_type = /mob/living/carbon/human/npc/ruin/trooper/scav/gunner + +/obj/effect/spawner/human_npc/ruin/scav/lookout + name = "scav lookout spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/scav/lookout diff --git a/voidcrew/modules/stealth/stealth_admin.dm b/voidcrew/modules/stealth/stealth_admin.dm index d0616631683..3e695d07f17 100644 --- a/voidcrew/modules/stealth/stealth_admin.dm +++ b/voidcrew/modules/stealth/stealth_admin.dm @@ -35,7 +35,7 @@ var/wary_text = "calm" if(stealth_ai.blackboard[BB_STEALTH_WARY]) wary_text = "wary [max(0, round((STEALTH_WARY_TIME - (world.time - stealth_ai.wary_at)) / (1 SECONDS)))]s" - return "[guard.real_name] [REF(guard)] | [stealth_ai.group?.id || "no group"] | [state_text] [since]s | focus [focus_text] | [wary_text] | [route_text] | AI [status_text]" + return "[guard.real_name] [REF(guard)] | [stealth_ai.group?.id || "no group"] | [state_text] [since]s | focus [focus_text] | [wary_text] | [route_text] | AI [status_text] | [stealth_guard_hearing_summary(guard, stealth_ai)]" ADMIN_VERB(stealth_guards, R_DEBUG, "Stealth Guards", "View and force the awareness of stealth guards on your z-level.", ADMIN_CATEGORY_DEBUG) var/turf/here = get_turf(user.mob) @@ -74,7 +74,7 @@ ADMIN_VERB(stealth_guards, R_DEBUG, "Stealth Guards", "View and force the awaren var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller if(QDELETED(guard) || !istype(stealth_ai)) return - var/list/actions = list("Force Unaware", "Force Suspicious at my turf", "Force Alert on me", "Force Alert on a mob I pick", "Force Searching at my turf", "Toggle Wary", "Show its sight", "Jump to it", "Next stop") + var/list/actions = list("Force Unaware", "Force Suspicious at my turf", "Force Alert on me", "Force Alert on a mob I pick", "Force Searching at my turf", "Toggle Wary", "Show its sight", "Jump to it", "Next stop", "Hear a noise here", "Start radio call (alarm at my turf)") var/action = tgui_input_list(user, stealth_guard_summary(guard), "Stealth Guard", actions) if(isnull(action) || QDELETED(guard)) return @@ -112,6 +112,17 @@ ADMIN_VERB(stealth_guards, R_DEBUG, "Stealth Guards", "View and force the awaren stealth_toggle_sight_overlay(user, guard) if("Jump to it") user.mob.forceMove(get_turf(guard)) + if("Hear a noise here") + // The hearing for this one guard, whatever the distance: it goes to look. + stealth_ai.last_heard = list("admin", world.time) + if(stealth_ai.stealth_can_investigate()) + stealth_ai.stealth_go_heard(here) + else + stealth_ai.stealth_respond(here) + if("Start radio call (alarm at my turf)") + if(!stealth_ai.stealth_start_call(STEALTH_CALL_ALARM, here)) + to_chat(user, span_warning("It carries no radio."), confidential = TRUE) + return if("Next stop") var/next = stealth_ai.stealth_next_index() if(next) @@ -121,13 +132,21 @@ ADMIN_VERB(stealth_guards, R_DEBUG, "Stealth Guards", "View and force the awaren stealth_admin_log(user, "[action] on [key_name(guard)]", guard) /proc/stealth_admin_group(client/user, datum/stealth_group/group) - var/list/actions = list("Calm all", "Clear wary", "Alert all on me", "Search all at my turf", "Show every route") + var/list/actions = list("Calm all", "Clear wary", "Alert all on me", "Search all at my turf", "Show every route", "Radio alarm at my turf", "Show noise memory", "Forget noises") var/action = tgui_input_list(user, "[group.id]: [length(group.living_members())] guards", "Stealth Group", actions) if(isnull(action) || QDELETED(group)) return var/turf/here = get_turf(user.mob) if(action == "Show every route") stealth_show_routes(user, group) + else if(action == "Radio alarm at my turf") + group.next_radio_alarm = 0 + var/told = group.radio_call(null, STEALTH_CALL_ALARM, here) + to_chat(user, span_notice("[told] guard\s with a working radio got it."), confidential = TRUE) + else if(action == "Show noise memory") + stealth_show_noise_memory(user, group) + else if(action == "Forget noises") + group.forget_noises() else for(var/mob/living/guard as anything in group.living_members()) var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller diff --git a/voidcrew/modules/stealth/stealth_admin_noise.dm b/voidcrew/modules/stealth/stealth_admin_noise.dm new file mode 100644 index 00000000000..18da8449950 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_admin_noise.dm @@ -0,0 +1,141 @@ +/** + * # Stealth guard admin tools: noise + * + * "Stealth Noise" (Debug): makes a real stealth_noise() where you stand, of any kind and reach, with or + * without you as its maker, and says how many guards heard it. It can also switch on the noise view: for + * STEALTH_ADMIN_OVERLAY_TIME every noise on your level is drawn for you alone, with its kind and reach at the + * source, a ring at the edge of its reach, and over each guard what it made of it ("heard 6.5/9 -> goes", + * "muffled 11.5/9", "deaf", "busy"). Images are built only while someone watches. Every use is logged. + */ + +/// One admin's noise view. Kept in GLOB.stealth_sight_overlays under "noise:". +/datum/stealth_noise_view + var/datum/weakref/user_ref + var/key + +/datum/stealth_noise_view/New(client/user) + user_ref = WEAKREF(user) + key = "noise:[user.ckey]" + GLOB.stealth_sight_overlays[key] = src + QDEL_IN(src, STEALTH_ADMIN_OVERLAY_TIME) + +/datum/stealth_noise_view/Destroy() + if(GLOB.stealth_sight_overlays[key] == src) + GLOB.stealth_sight_overlays -= key + return ..() + +/// Draws one noise for its admin: the source, its reach, and what each guard made of it (`notes`, guard => text). +/datum/stealth_noise_view/proc/show(turf/where, kind, reach, list/notes, coalesced) + var/client/user = user_ref?.resolve() + if(isnull(user)) + qdel(src) + return + var/list/images = list() + var/image/marker = image('icons/effects/alphacolors.dmi', where, "white") + SET_PLANE(marker, ABOVE_LIGHTING_PLANE, where) + marker.color = coalesced ? "#888888" : "#ff66cc" + marker.alpha = coalesced ? 60 : 140 + if(!coalesced) + marker.maptext = MAPTEXT("[kind] [reach]") + marker.maptext_width = 96 + images += marker + if(!coalesced) + for(var/turf/edge as anything in RANGE_TURFS(reach, where)) + if(get_dist(edge, where) != reach) + continue + var/image/tint = image('icons/effects/alphacolors.dmi', edge, "white") + SET_PLANE(tint, ABOVE_LIGHTING_PLANE, edge) + tint.color = "#ff66cc" + tint.alpha = 40 + images += tint + for(var/mob/living/guard as anything in notes) + var/turf/guard_turf = get_turf(guard) + if(isnull(guard_turf)) + continue + var/image/label = new + label.loc = guard_turf + SET_PLANE(label, ABOVE_LIGHTING_PLANE, guard_turf) + label.maptext = MAPTEXT("[notes[guard]]") + label.maptext_width = 128 + label.maptext_y = 34 + images += label + user.images |= images + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(stealth_remove_images), user_ref, images), STEALTH_NOISE_VIEW_TIME) + +/// The noise views of admins standing on `z`. +/proc/stealth_noise_views(z) + . = list() + for(var/key in GLOB.stealth_sight_overlays) + var/datum/stealth_noise_view/view = GLOB.stealth_sight_overlays[key] + if(!istype(view)) + continue + var/client/user = view.user_ref?.resolve() + var/turf/user_turf = get_turf(user?.mob) + if(user_turf?.z == z) + . += view + +/// Switches `user`'s noise view on or off. TRUE if now on. +/proc/stealth_toggle_noise_view(client/user) + var/datum/stealth_noise_view/old = GLOB.stealth_sight_overlays["noise:[user.ckey]"] + if(old) + qdel(old) + return FALSE + new /datum/stealth_noise_view(user) + return TRUE + +ADMIN_VERB(stealth_make_noise, R_DEBUG, "Stealth Noise", "Make a noise stealth guards can hear at your turf, or watch the noises on your z-level.", ADMIN_CATEGORY_DEBUG) + var/turf/here = get_turf(user.mob) + if(isnull(here)) + to_chat(user, span_warning("You need to be standing somewhere."), confidential = TRUE) + return + var/view_choice = "Toggle the noise view" + var/choice = tgui_input_list(user, "Which noise?", "Stealth Noise", list(view_choice) + STEALTH_NOISE_KINDS) + if(isnull(choice)) + return + if(choice == view_choice) + var/shown = stealth_toggle_noise_view(user) + to_chat(user, span_notice("Noise view [shown ? "on" : "off"]."), confidential = TRUE) + stealth_admin_log(user, "[shown ? "showed" : "hid"] the noise view", here) + return + var/list/profile = stealth_noise_profile(choice) + var/reach = tgui_input_number(user, "Reach in tiles (0 for its usual [profile[STEALTH_PROFILE_REACH]])", "Stealth Noise", 0, 40, 0) + if(isnull(reach)) + return + var/as_me = isliving(user.mob) && tgui_alert(user, "Who made it?", "Stealth Noise", list("Nobody", "Me")) == "Me" + here = get_turf(user.mob) + var/heard = stealth_noise(here, choice, reach || null, as_me ? user.mob : null) + to_chat(user, span_notice("[heard] guard\s heard it."), confidential = TRUE) + stealth_admin_log(user, "made a [choice] noise[reach ? " (reach [reach])" : ""][as_me ? " as themselves" : ""], heard by [heard]", here) + BLACKBOX_LOG_ADMIN_VERB("Stealth Noise") + +/// The radio, the call on its way out and the last thing heard, for the guard summary. +/proc/stealth_guard_hearing_summary(mob/living/carbon/human/npc/guard, datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai) + var/radio_text = "none" + if(guard.stealth_radio) + var/obj/item/radio/earpiece = guard.ears + if(!istype(earpiece)) + radio_text = "none" + else if(!earpiece.is_on()) + radio_text = "off" + else if(is_within_radio_jammer_range(guard)) + radio_text = "jammed" + else + radio_text = "ok" + . = "radio [radio_text]" + if(stealth_ai.pending_call) + . += " (calling in [max(0, round((stealth_ai.pending_call[STEALTH_CALL_DUE] - world.time) / (1 SECONDS)))]s)" + if(stealth_ai.last_heard) + . += " | heard [stealth_ai.last_heard[1]] [round((world.time - stealth_ai.last_heard[2]) / (1 SECONDS))]s ago" + +/// What `group` remembers of noises, as chat text for `user`. +/proc/stealth_show_noise_memory(client/user, datum/stealth_group/group) + var/list/lines = list("[group.id]: [group.alarm_count()] alarms in memory") + // Prunes as it counts. + group.lure_count(group.home) + for(var/list/lure as anything in group.lures) + var/turf/spot = lure[1] + lines += "lure at ([spot.x], [spot.y]), [round((world.time - lure[2]) / (1 SECONDS))]s ago, [group.lure_count(spot)] near it" + for(var/datum/stealth_check/check as anything in group.checks) + var/mob/living/gone = check.who?.resolve() + lines += "out looking: [gone ? gone.real_name : "(gone)"] at ([check.where.x], [check.where.y]), missed in [round((check.due - world.time) / (1 SECONDS))]s, [length(check.waiters)] waiting" + to_chat(user, span_notice(lines.Join("
")), confidential = TRUE) diff --git a/voidcrew/modules/stealth/stealth_ai.dm b/voidcrew/modules/stealth/stealth_ai.dm index fd3dcbc1e69..82377896aa5 100644 --- a/voidcrew/modules/stealth/stealth_ai.dm +++ b/voidcrew/modules/stealth/stealth_ai.dm @@ -228,6 +228,9 @@ search_center = null search_queue = null hidden_spots = null + pending_call = null + heard_map = null + heard_now = null return ..() // With the AI off (nobody on the level, stunned, in crit, a player in the body) nothing scans, so no mark. @@ -239,6 +242,7 @@ /datum/ai_controller/basic_controller/human_npc/stealth/on_sentience_gained() . = ..() QDEL_NULL(meter) + stealth_drop_call() // The player has left the body: it is a guard again, with nothing on its mind. /datum/ai_controller/basic_controller/human_npc/stealth/on_sentience_lost() @@ -291,6 +295,9 @@ look_until = 0 lost_since = 0 calm_until = 0 + suspicion_heard = FALSE + if(new_state != STEALTH_UNAWARE) + glance_until = 0 if(new_state == STEALTH_ALERT || new_state == STEALTH_SEARCHING) stealth_set_wary() stealth_drop_walk() @@ -374,14 +381,15 @@ stealth_set_state(STEALTH_UNAWARE) stealth_rejoin_route() -/// Suspicious of something at `where`. `purpose` is STEALTH_GOTO_INVESTIGATE or STEALTH_GOTO_BODY. -/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_suspicious(turf/where, purpose = STEALTH_GOTO_INVESTIGATE) +/// Suspicious of something at `where`. `purpose` is STEALTH_GOTO_INVESTIGATE or STEALTH_GOTO_BODY. `line` is +/// the bark kind it says, or null for the usual one. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_suspicious(turf/where, purpose = STEALTH_GOTO_INVESTIGATE, line = null) stealth_set_state(STEALTH_SUSPICIOUS) goto_purpose = purpose investigated = FALSE last_gain = world.time stealth_aim_at(where) - stealth_bark(purpose == STEALTH_GOTO_BODY ? STEALTH_BARK_BODY : STEALTH_BARK_SUSPICIOUS) + stealth_bark(line || (purpose == STEALTH_GOTO_BODY ? STEALTH_BARK_BODY : STEALTH_BARK_SUSPICIOUS)) /// Suspicious: turns to `where` and makes it the place to walk over to. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_aim_at(turf/where) @@ -411,7 +419,8 @@ /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_alert(atom/target) if(QDELETED(target)) return - if(stealth_state() != STEALTH_ALERT) + var/news = stealth_state() != STEALTH_ALERT + if(news) stealth_set_state(STEALTH_ALERT) stealth_set_wary() lost_since = 0 @@ -419,6 +428,9 @@ last_seen_turf = get_turf(target) blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target) + // It has seen or felt them itself: it calls for help (decision 8). + if(news) + stealth_first_hand(last_seen_turf) /// Alert: turns from its target to `new_target`, and remembers the old one for when this fight ends. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_switch_target(atom/new_target) @@ -468,17 +480,28 @@ . += spot picked++ -/// Says a stealth line, at most once per STEALTH_BARK_KIND_COOLDOWN for each kind (abuse P14). -/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_bark(kind) +/** + * Says a stealth line, at most once per STEALTH_BARK_KIND_COOLDOWN for each kind (abuse P14). `about` is the + * place the line is about: a line can name its side of the den. `name` is the friend a line asks after. + * `always` skips the cooldown (a radio call is said every time it is made). + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_bark(kind, turf/about, name, always = FALSE) var/mob/living/carbon/human/npc/body = pawn if(!ishumannpc(body) || body.stat != CONSCIOUS || body.client) return FALSE - if(world.time < bark_ready_at[kind]) - return FALSE - if(!body.npc_bark(kind, force = TRUE)) + if(!always && world.time < bark_ready_at[kind]) return FALSE - bark_ready_at[kind] = world.time + STEALTH_BARK_KIND_COOLDOWN - return TRUE + var/datum/human_npc_voice/scav/voice = ispath(body.npc_voice, /datum/human_npc_voice/scav) ? human_npc_singleton(body.npc_voice) : null + if(voice) + var/turf/centre = group?.home + voice.fill_side = (about && centre && about.z == centre.z && about != centre) ? dir2text(get_dir(centre, about)) : null + voice.fill_name = name + . = body.npc_bark(kind, force = TRUE) + if(voice) + voice.fill_side = null + voice.fill_name = null + if(.) + bark_ready_at[kind] = world.time + STEALTH_BARK_KIND_COOLDOWN /// Moves every awareness timestamp back by `time`, as if that long had passed (unit tests, admin tools). /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_rewind(time) @@ -501,6 +524,11 @@ next_harm_at -= time home_retry_at -= time health_mark_at -= time + glance_until -= time + next_shout -= time + next_recentre -= time + if(pending_call) + pending_call[STEALTH_CALL_DUE] -= time if(explained_at) explained_at -= time for(var/kind in bark_ready_at) @@ -533,6 +561,10 @@ else if(state == STEALTH_SUSPICIOUS && world.time - last_gain >= STEALTH_SUSPICIOUS_TIMEOUT) stealth_bark(STEALTH_BARK_GIVE_UP) stealth_go_unaware() + // Its ears, its radio and its missing friends (stealth_hearing.dm, stealth_radio.dm, stealth_noise.dm). + stealth_hear_footsteps() + stealth_deliver_call() + group?.check_overdue() if(stealth_state() == STEALTH_ALERT) stealth_keep_target() // Still fighting: it weighs everyone else. (Losing or downing its target has already looked round.) @@ -542,6 +574,7 @@ stealth_look() if(stealth_state() != STEALTH_ALERT && world.time >= next_body_scan) stealth_check_bodies() + heard_now = null for(var/datum/stealth_sight_overlay/overlay as anything in watchers) overlay.refresh() stealth_meter_update() @@ -639,10 +672,8 @@ continue judged++ var/rate = 0 - var/felt = FALSE if(boxed) rate = STEALTH_NOTICE_RATE - felt = TRUE else if(eyes && !stealth_is_cloaked(candidate) && stealth_in_cone(guard, where, half_angle)) var/cap = stealth_range_onto(where) if(dist <= cap) @@ -652,20 +683,15 @@ if(!rate && dist <= STEALTH_CLOSE_RANGE) // At its shoulder: light doesn't matter (abuse P7). rate = STEALTH_BEHIND_RATE - felt = TRUE if(!rate) continue var/list/entry = notice[key] if(isnull(entry)) entry = list(0, null, 0) notice[key] = entry - // First seen counts as moving: it just came into view. - var/moved = entry[STEALTH_ENTRY_TURF] != where - // STEP 2 STAND-IN: move intent stands in for "running is loud" until real footstep hearing lands. - var/running = moved && candidate.move_intent == MOVE_INTENT_RUN - if(running) - rate *= STEALTH_RUN_MULT - else if(!moved) + // First seen counts as moving: it just came into view. Sight knows moved or still, nothing more: + // running is heard, not seen (stealth_hear_footsteps()). + if(entry[STEALTH_ENTRY_TURF] == where) rate *= STEALTH_STILL_MULT if(candidate.body_position == LYING_DOWN) rate *= STEALTH_LYING_MULT @@ -677,10 +703,6 @@ rate *= STEALTH_WARY_MULT wary_at = world.time entry[STEALTH_ENTRY_NOTICE] = min(entry[STEALTH_ENTRY_NOTICE] + rate * scan_seconds, STEALTH_NOTICE_MAX) - if(felt && running) - entry[STEALTH_ENTRY_NOTICE] = max(entry[STEALTH_ENTRY_NOTICE], STEALTH_SUSPICIOUS_AT) - if(!alert && where != guard.loc) - guard.setDir(stealth_dir_to(guard, where)) entry[STEALTH_ENTRY_TURF] = where entry[STEALTH_ENTRY_SEEN_AT] = world.time perceived[key] = candidate @@ -701,7 +723,8 @@ if(entry[STEALTH_ENTRY_NOTICE] > seen_notice) seen_notice = entry[STEALTH_ENTRY_NOTICE] seen_key = key - else + else if(!heard_now?[key]) + // Unseen and unheard this scan. entry[STEALTH_ENTRY_NOTICE] = max(0, entry[STEALTH_ENTRY_NOTICE] - STEALTH_NOTICE_DECAY * scan_seconds) if(world.time - entry[STEALTH_ENTRY_SEEN_AT] > STEALTH_NOTICE_FORGET) notice -= key @@ -735,6 +758,8 @@ if(STEALTH_SUSPICIOUS) if(!seen_now) return + // It has someone in sight now: their own "?" takes over from the heard one. + suspicion_heard = FALSE if(level >= STEALTH_ALERT_AT && in_reach) stealth_go_alert(best) return @@ -809,6 +834,8 @@ lost_since = 0 last_seen_turf = get_turf(remembered) blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time + // Still has them in view: it keeps calling. + stealth_shout() if(!target) set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, remembered) return @@ -924,6 +951,7 @@ else group.reported_bodies += key stealth_go_suspicious(where, STEALTH_GOTO_BODY) + stealth_first_hand(where) return // ===== Touches, hits and recovery ===== @@ -961,6 +989,7 @@ return TRUE stealth_go_searching(get_turf(source)) stealth_bark(STEALTH_BARK_HIT_UNSEEN) + stealth_first_hand(get_turf(source)) return FALSE /datum/ai_controller/basic_controller/human_npc/stealth/on_pawn_attacked(mob/living/source, atom/attacker, attack_flags) @@ -1026,6 +1055,8 @@ /datum/ai_controller/basic_controller/human_npc/stealth/proc/on_stunned(datum/source, amount) SIGNAL_HANDLER if(amount > 0) + // Whatever it was about to say on the radio is lost. + stealth_drop_call() stealth_note_harm() /** @@ -1060,6 +1091,8 @@ next_harm_at = world.time + STEALTH_HARM_COOLDOWN stealth_setup() stealth_trouble_at(where) + if(guard.stat == CONSCIOUS) + stealth_first_hand(where) for(var/mob/living/friend as anything in group?.living_members()) if(friend == guard) continue @@ -1112,6 +1145,7 @@ last_stat = new_stat if(new_stat != CONSCIOUS) QDEL_NULL(meter) + stealth_drop_call() if(new_stat == CONSCIOUS && was != CONSCIOUS && was != DEAD) stealth_came_round() diff --git a/voidcrew/modules/stealth/stealth_hearing.dm b/voidcrew/modules/stealth/stealth_hearing.dm new file mode 100644 index 00000000000..7d515e7381a --- /dev/null +++ b/voidcrew/modules/stealth/stealth_hearing.dm @@ -0,0 +1,239 @@ +/** + * # Stealth guards: what a guard does about what it hears + * + * The group decides who goes (stealth_noise.dm); these are the things one guard can be told to do, the + * shout that passes first-hand knowledge on by earshot, and the footsteps its own scan listens for. + * + * - stealth_go_heard(): suspicious of a noise. The same stare, walk over and look round as a sighting, with + * a heard line and the grey "?" (stealth_meter.dm). It never makes a guard wary by itself. + * - stealth_glance(): turns to face a noise for a moment without leaving its state, route or post. + * - stealth_hold(): stays where it is, wary, facing the noise. + * - stealth_respond(): runs to a spot and searches there (an alarm, a shout, a radio call, a missing friend). + * - stealth_shout(): calls for help. Only for what it learned first hand: a guard that came because of a + * shout or a call never shouts onward, so one sighting can't chain the whole den awake. + * + * Footsteps (owner decision 13) follow tg's own rules: walking is silent, running is heard, and anything + * that silences a player's steps for players silences them for guards (stealth_footstep_reach()). They are + * pulled by the guard's scan, so a walker pays nothing per step. Steps alone can make a guard turn and + * look, never alert. + */ +/datum/ai_controller/basic_controller/human_npc/stealth + /// Suspicious because of a noise, not a sighting: it shows the grey "?" + var/suspicion_heard = FALSE + /// world.time its glance at a noise ends + var/glance_until = 0 + /// The way it glances + var/glance_dir = SOUTH + /// REF(mob) => the turf it stood on at the last scan, for everyone in earshot of a footstep + var/list/heard_map + /// REF(mob) => TRUE for everyone whose steps it heard this scan (their notice doesn't decay) + var/list/heard_now + /// Its radio call on the way out: list(kind, turf, world.time due, name), or null + var/list/pending_call + /// world.time it may next shout for help + var/next_shout = 0 + /// world.time its search may next move to a newer noise + var/next_recentre = 0 + /// list(noise kind, world.time) of the last thing it heard, for admins + var/list/last_heard + +/// Whether it can be sent to look at a noise: unaware, or suspicious and not yet on its way. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_can_investigate() + switch(stealth_state()) + if(STEALTH_UNAWARE) + return TRUE + if(STEALTH_SUSPICIOUS) + return !investigated && isnull(goto_dest) && goto_purpose != STEALTH_GOTO_BODY + return FALSE + +/// Heard something worth a look at `where`. Suspicious with the heard flag set: same stare, walk and look +/// round as a sighting, a heard line instead of "Hm?", and the heard mark. Already suspicious, it re-aims. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_go_heard(turf/where, again = FALSE) + var/turf/spot = stealth_hear_spot(where, get_turf(pawn)) + if(isnull(spot) || stealth_driven()) + return + if(stealth_state() == STEALTH_SUSPICIOUS) + last_gain = world.time + if(!investigated) + stealth_aim_at(spot) + return + stealth_go_suspicious(spot, STEALTH_GOTO_INVESTIGATE, again ? STEALTH_BARK_HEARD_AGAIN : STEALTH_BARK_HEARD) + suspicion_heard = TRUE + stealth_meter_update() + +/// Turns to face `where` for `time` without leaving its state, route or post. No line: the turn is the read. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_glance(turf/where, time = STEALTH_GLANCE_TIME) + var/mob/living/guard = pawn + if(isnull(where) || stealth_driven() || get_turf(guard) == where) + return + glance_dir = stealth_dir_to(guard, where) + glance_until = max(glance_until, world.time + time) + if(stealth_state() == STEALTH_UNAWARE && !blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + stealth_drop_walk() + guard.setDir(glance_dir) + +/// Stays where it is, wary, facing `where` (sentries, and guards past the responder cap). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_hold(turf/where) + if(stealth_driven()) + return + stealth_set_wary() + if(stealth_state() != STEALTH_UNAWARE) + return + stealth_glance(where, STEALTH_HOLD_TIME) + stealth_bark(STEALTH_BARK_HOLD) + +/** + * Runs to `where` and searches there: an alarm, a friend's shout, a radio call, a missing friend. Wary. + * Not while it fights. Already searching, it moves its search there only if that is somewhere else, and at + * most once per STEALTH_RECENTRE_COOLDOWN. Returns TRUE if it is now searching at or near `where`. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_respond(turf/where) + var/mob/living/guard = pawn + if(isnull(where) || QDELETED(guard) || guard.stat != CONSCIOUS || stealth_driven()) + return FALSE + if(stealth_state() == STEALTH_ALERT || blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return FALSE + var/turf/spot = stealth_hear_spot(where, get_turf(guard)) + if(stealth_state() == STEALTH_SEARCHING && !calm_until) + if(search_center && get_dist(search_center, spot) <= STEALTH_SEARCH_RADIUS) + return TRUE + if(world.time < next_recentre) + return FALSE + next_recentre = world.time + STEALTH_RECENTRE_COOLDOWN + stealth_go_searching(spot) + return TRUE + +/// Calls for help: a noise its own group hears. It has to be able to speak (tg's can_speak()), so a muted +/// or muzzled guard calls nobody. At most once per STEALTH_SHOUT_INTERVAL. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_shout() + var/mob/living/guard = pawn + if(world.time < next_shout || QDELETED(guard) || stealth_driven() || !human_npc_can_talk(guard)) + return FALSE + next_shout = world.time + STEALTH_SHOUT_INTERVAL + stealth_noise(guard, STEALTH_NOISE_SHOUT, null, guard) + return TRUE + +/// It has just learned something first hand at `where` (a sighting, a touch, a hit from nowhere, a body): +/// it shouts, and calls it in if it carries a radio. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_first_hand(turf/where) + var/mob/living/carbon/human/npc/guard = pawn + if(QDELETED(guard) || guard.stat != CONSCIOUS || stealth_driven() || !stealth_ready) + return + stealth_shout() + if(ishumannpc(guard) && guard.stealth_radio) + stealth_start_call(STEALTH_CALL_ALARM, where || get_turf(guard)) + +// ===== Footsteps ===== + +/** + * How far `walker`'s last step carried to a guard, in tiles, by tg's own footstep rules (the footstep + * element's prepare_step() and play_humanstep()). 0 for silent: walking, silent shoes or any other source of + * TRAIT_SILENT_FOOTSTEPS, in a box, a locker or a mech, buckled, thrown, flying, crawling in a vent, held + * still, or on a turf with no footstep sound. + * + * A running step on plain floor in shoes carries STEALTH_FOOTSTEP_RANGE. Any other floor or footwear scales + * that by how far tg plays its sound to players compared with the plain one (tg's own tables: plating and + * wood ring a little further, bare feet on carpet and the Light Step quirk a little less). A living mob that + * is not a carbon counts as running whenever it moves. + */ +/proc/stealth_footstep_reach(mob/living/walker) + if(SHOULD_DISABLE_FOOTSTEPS(walker)) + return 0 + var/turf/open/ground = walker.loc + if(!istype(ground) || isnull(ground.footstep)) + return 0 + if(walker.buckled || walker.throwing || (walker.movement_type & (VENTCRAWLING | FLYING)) || HAS_TRAIT(walker, TRAIT_IMMOBILIZED)) + return 0 + if(walker.body_position == LYING_DOWN) + return STEALTH_FOOTSTEP_CRAWL_RANGE + if(!iscarbon(walker)) + return STEALTH_FOOTSTEP_RANGE + if(walker.move_intent == MOVE_INTENT_WALK) + return 0 + if(!ishuman(walker)) + return STEALTH_FOOTSTEP_RANGE + var/mob/living/carbon/human/person = walker + // The sound tg plays for this floor and what is on its feet: list(sounds, volume, extra range). + var/list/step_sound + if((person.wear_suit?.body_parts_covered | person.w_uniform?.body_parts_covered | person.shoes?.body_parts_covered) & FEET) + step_sound = GLOB.footstep[ground.footstep] + else + var/obj/item/bodypart/leg/leg = person.get_bodypart(BODY_ZONE_R_LEG) || person.get_bodypart(BODY_ZONE_L_LEG) + switch(leg?.footstep_type) + if(FOOTSTEP_MOB_CLAW) + step_sound = GLOB.clawfootstep[ground.clawfootstep] + if(FOOTSTEP_MOB_BAREFOOT) + step_sound = GLOB.barefootstep[ground.barefootstep] + if(FOOTSTEP_MOB_HEAVY) + step_sound = GLOB.heavyfootstep[ground.heavyfootstep] + if(FOOTSTEP_MOB_SHOE) + step_sound = GLOB.footstep[ground.footstep] + if(length(step_sound) < 3) + return 0 + var/volume = step_sound[2] + // The human footstep element plays at -6 range (human.dm). + var/sound_range = SOUND_RANGE + step_sound[3] - 6 + if(HAS_TRAIT(person, TRAIT_LIGHT_STEP)) + volume *= 0.6 + sound_range -= 2 + if(volume <= SOUND_AUDIBLE_VOLUME_MIN || sound_range <= 1) + return 0 + var/audible = CALCULATE_MAX_SOUND_AUDIBLE_DISTANCE(volume, sound_range, 1, SOUND_FALLOFF_EXPONENT) + return clamp(round(STEALTH_FOOTSTEP_RANGE * audible / STEALTH_FOOTSTEP_PLAYER_RANGE, 1), 0, STEALTH_FOOTSTEP_MAX_RANGE) + +/** + * The scan's ear for footsteps, before it looks. Everyone near whose turf changed since the last scan and + * whose step carries this far (walls and doors muffle it) is heard: no line of sight needed. + * - Unaware or suspicious: their notice gains STEALTH_FOOTSTEP_NOTICE, never past STEALTH_SUSPICIOUS_AT; at + * its shoulder it jumps there at once. At that level it turns to the steps and goes to look. From there + * sight decides: steps alone never make it alert. + * - Searching: the search moves to the steps. Someone hunted who runs is followed by ear; one who walks isn't. + * Footsteps are its own senses, not a dispatch: they open no check and count as no lure. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_hear_footsteps() + heard_now = null + var/mob/living/carbon/human/guard = pawn + var/state = stealth_state() + if(state == STEALTH_ALERT || !isturf(guard.loc) || !guard.can_hear()) + heard_map = null + return + var/turf/here = guard.loc + var/list/was = heard_map + var/list/now = list() + var/list/notice = notice_map + for(var/mob/living/walker as anything in stealth_candidates(guard, STEALTH_FOOTSTEP_MAX_RANGE)) + var/key = REF(walker) + var/turf/where = get_turf(walker) + now[key] = where + var/turf/stood = was?[key] + if(isnull(stood) || stood == where) + continue + var/reach = stealth_footstep_reach(walker) + if(!reach || get_dist(here, where) > reach || stealth_muffled_dist(where, here) > reach) + continue + LAZYSET(heard_now, key, TRUE) + last_heard = list("footsteps", world.time) + if(state == STEALTH_SEARCHING) + if(!calm_until && world.time >= next_recentre && get_dist(search_center, where) > 1) + next_recentre = world.time + STEALTH_RECENTRE_COOLDOWN + stealth_go_searching(where) + state = stealth_state() + continue + var/list/entry = notice[key] + if(isnull(entry)) + entry = list(0, where, 0) + notice[key] = entry + if(entry[STEALTH_ENTRY_NOTICE] < STEALTH_SUSPICIOUS_AT) + var/gained = get_dist(here, where) <= STEALTH_CLOSE_RANGE ? STEALTH_SUSPICIOUS_AT : entry[STEALTH_ENTRY_NOTICE] + STEALTH_FOOTSTEP_NOTICE + entry[STEALTH_ENTRY_NOTICE] = min(gained, STEALTH_SUSPICIOUS_AT) + entry[STEALTH_ENTRY_SEEN_AT] = world.time + if(blackboard[BB_STEALTH_WARY]) + wary_at = world.time + if(entry[STEALTH_ENTRY_NOTICE] >= STEALTH_SUSPICIOUS_AT) + stealth_go_heard(where) + if(state == STEALTH_SUSPICIOUS && investigated && where != here) + // Looking round already: it turns to the steps. + stand_dir = stealth_dir_to(guard, where) + guard.setDir(stand_dir) + state = stealth_state() + heard_map = now diff --git a/voidcrew/modules/stealth/stealth_meter.dm b/voidcrew/modules/stealth/stealth_meter.dm index d2f070ab5fa..07a5f796015 100644 --- a/voidcrew/modules/stealth/stealth_meter.dm +++ b/voidcrew/modules/stealth/stealth_meter.dm @@ -8,6 +8,9 @@ * - Searching: a yellow "?" everyone sees, draining from the top over the search (or until calm_until * after it put its target down). Empty: gone. * - While alert or searching, nobody sees their own "?" on that guard. + * - Suspicious of a noise (stealth_hearing.dm): an empty grey "?" for every player near who has no fill of + * their own on that guard. Someone it has started to see gets their own filling "?" instead, so nothing + * stacks. A glance, a hold and a radio call show no mark: the turn, the raised hand and the line are the read. * * Both are drawn on the guard (vis_contents for the public mark, an image located on the guard for the * private one), so they show only when the guard itself is in view, never through walls; above the @@ -64,7 +67,7 @@ // Alert and searching own the mark even once it has drained: no private "?" in the moment before the scan // sends it back to unaware (and clears its notice). var/public_owned = new_public || state == STEALTH_ALERT || state == STEALTH_SEARCHING - update_private(public_owned ? null : controller.notice_map) + update_private(public_owned ? null : controller.notice_map, !public_owned && stealth_meter_heard(state, controller.suspicion_heard)) schedule_drain() /datum/stealth_meter/proc/set_public(new_state) @@ -83,12 +86,36 @@ guard.vis_contents += mark mark.icon_state = new_state +/// The players near the guard who get the empty grey "?" of a heard noise: every living player within +/// STEALTH_METER_RANGE with no fill of their own in `notice`. +/datum/stealth_meter/proc/heard_viewers(list/notice) + . = list() + var/turf/here = get_turf(guard) + if(isnull(here) || here.z > length(SSmobs.clients_by_zlevel)) + return + for(var/mob/living/viewer as anything in SSmobs.clients_by_zlevel[here.z]) + if(viewer == guard || get_dist(get_turf(viewer), here) > STEALTH_METER_RANGE) + continue + var/list/entry = notice?[REF(viewer)] + if(entry && stealth_meter_notice_state(entry[STEALTH_ENTRY_NOTICE])) + continue + . += viewer + /// Gives each nearby player their own "?" for their notice entry, and takes away the rest. An admin watching -/// the guard's sight gets the fullest one, whoever it is for. -/datum/stealth_meter/proc/update_private(list/notice) +/// the guard's sight gets the fullest one, whoever it is for. `heard`: the guard is suspicious of a noise, so +/// everyone near without a fill of their own gets the empty "?". +/datum/stealth_meter/proc/update_private(list/notice, heard = FALSE) // key => list(icon_state, client to show it to) var/list/wanted = list() var/turf/here = get_turf(guard) + if(heard && here) + for(var/mob/living/viewer as anything in heard_viewers(notice)) + if(viewer.client) + wanted[REF(viewer)] = list(STEALTH_METER_HEARD_STATE, viewer.client) + for(var/datum/stealth_sight_overlay/overlay as anything in controller.watchers) + var/client/admin = overlay.user_ref?.resolve() + if(admin && !wanted[REF(admin.mob)]) + wanted["watch:[admin.ckey]"] = list(STEALTH_METER_HEARD_STATE, admin) if(length(notice) && here) var/top_state var/top_notice = 0 @@ -111,7 +138,7 @@ if(top_state) for(var/datum/stealth_sight_overlay/overlay as anything in controller.watchers) var/client/admin = overlay.user_ref?.resolve() - if(isnull(admin) || wanted[REF(admin.mob)]) + if(isnull(admin) || (wanted[REF(admin.mob)] && wanted[REF(admin.mob)][1] != STEALTH_METER_HEARD_STATE)) continue wanted["watch:[admin.ckey]"] = list(top_state, admin) for(var/key in private_marks.Copy()) @@ -219,6 +246,10 @@ // ===== Pure: state to icon_state ===== +/// Whether a guard in `state` shows the heard "?". +/proc/stealth_meter_heard(state, suspicion_heard) + return suspicion_heard && state == STEALTH_SUSPICIOUS + /// The private "?" for a notice level, or null for none. Fills round to the nearest row. /proc/stealth_meter_notice_state(notice) if(!(notice > 0)) diff --git a/voidcrew/modules/stealth/stealth_noise.dm b/voidcrew/modules/stealth/stealth_noise.dm new file mode 100644 index 00000000000..1ff6aabe530 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_noise.dm @@ -0,0 +1,498 @@ +/** + * # Stealth guards: noise + * + * stealth_noise() is the only way a guard hears anything but footsteps (those its own scan listens for, + * stealth_hearing.dm). The sources are tg's own actions (stealth_noise_sources.dm). + * + * No sound ever gives a guard a target. Hearing turns a guard, moves it, makes it search and makes it wary; + * only sight and touch set BB_BASIC_MOB_CURRENT_TARGET. + * + * A noise reaches the stealth groups of its own level and map region. Walls and closed doors on the straight + * line to each guard eat reach (stealth_muffled_dist()). What the hearers do is decided for the group as a + * whole (dispatch_noise()): a door makes them glance; a small noise sends one guard to look, the next one at + * the same spot two, the third everyone; gunfire, blasts and screams send the nearest few running while + * sentries hold their posts. A guard that went to look and isn't back in STEALTH_CHECKIN_TIME is missed by + * the ones who watched it go. + * + * Cost: with no group alive, one length() check. With no living player on the level, or in airless space, + * the noise is dropped before any guard is looked at. The same kind of noise within STEALTH_NOISE_COALESCE + * and STEALTH_NOISE_COALESCE_RANGE of the last is dropped before any per-guard work. Listeners come from the + * group's roster, never from view() or hearers(). Nothing here runs on a timer: lures, alarms and checks + * are small lists of world.time stamps on the group, pruned when read. + */ + +/// A guard that went to look at a noise, and the ones who know it went. +/datum/stealth_check + /// Weakref to the guard that went + var/datum/weakref/who + /// What its friends call it, or null if it has no name of its own + var/name + /// Where the noise was + var/turf/where + /// world.time it is missed + var/due = 0 + /// Weakrefs to the guards that saw or heard it go + var/list/datum/weakref/waiters = list() + +/datum/stealth_group + /// Noise kind => list(world.time, turf) of the last one its guards heard + var/list/noise_stamps = list() + /// list(turf, world.time) for each small noise it sent someone to + var/list/lures = list() + /// list(world.time, weight) for each alarm it answered (a lure counts half) + var/list/alarms = list() + /// Guards out looking at a noise + var/list/datum/stealth_check/checks = list() + /// world.time the checks are next looked over + var/next_overdue_check = 0 + /// world.time the next radio alarm may go out + var/next_radio_alarm = 0 + /// Admin noise view only: guard => what it made of the noise being dealt with, else null + var/list/noise_notes + /// Reach of the noise being dealt with + var/noise_reach = 0 + +/// list(reach, level, flags) for a STEALTH_NOISE_* kind, or null. +/proc/stealth_noise_profile(kind) + var/static/list/profiles + if(isnull(profiles)) + profiles = list( + STEALTH_NOISE_GUN_QUIET = list(STEALTH_NOISE_RANGE_GUN_QUIET, STEALTH_HEAR_INVESTIGATE, STEALTH_NOISE_LOS_ONLY), + STEALTH_NOISE_GUN_PISTOL = list(STEALTH_NOISE_RANGE_GUN_PISTOL, STEALTH_HEAR_ALARM, STEALTH_NOISE_LOUD), + STEALTH_NOISE_GUN_SMG = list(STEALTH_NOISE_RANGE_GUN_SMG, STEALTH_HEAR_ALARM, STEALTH_NOISE_LOUD), + STEALTH_NOISE_GUN_LONG = list(STEALTH_NOISE_RANGE_GUN_LONG, STEALTH_HEAR_ALARM, STEALTH_NOISE_LOUD), + STEALTH_NOISE_IMPACT = list(STEALTH_NOISE_RANGE_IMPACT, STEALTH_HEAR_INVESTIGATE, NONE), + STEALTH_NOISE_BLAST = list(STEALTH_NOISE_RANGE_BLAST, STEALTH_HEAR_ALARM, STEALTH_NOISE_LOUD), + STEALTH_NOISE_GLASS = list(STEALTH_NOISE_RANGE_GLASS, STEALTH_HEAR_INVESTIGATE, NONE), + STEALTH_NOISE_THROWN = list(STEALTH_NOISE_RANGE_THROWN_SMALL, STEALTH_HEAR_INVESTIGATE, NONE), + STEALTH_NOISE_KNOCK = list(STEALTH_NOISE_RANGE_KNOCK, STEALTH_HEAR_INVESTIGATE, NONE), + STEALTH_NOISE_BASH = list(STEALTH_NOISE_RANGE_BASH, STEALTH_HEAR_INVESTIGATE, NONE), + STEALTH_NOISE_DOOR = list(STEALTH_NOISE_RANGE_DOOR, STEALTH_HEAR_GLANCE, NONE), + STEALTH_NOISE_THUD = list(STEALTH_NOISE_RANGE_THUD, STEALTH_HEAR_INVESTIGATE, STEALTH_NOISE_ALLIES_COUNT), + STEALTH_NOISE_VOICE = list(STEALTH_NOISE_RANGE_VOICE, STEALTH_HEAR_INVESTIGATE, NONE), + STEALTH_NOISE_SCREAM = list(STEALTH_NOISE_RANGE_SCREAM, STEALTH_HEAR_ALARM, STEALTH_NOISE_LOUD | STEALTH_NOISE_ALLIES_COUNT), + STEALTH_NOISE_SHOUT = list(STEALTH_NOISE_RANGE_SHOUT, STEALTH_HEAR_SHOUT, STEALTH_NOISE_LOUD | STEALTH_NOISE_GROUP_ONLY), + ) + return profiles[kind] + +/// Whether `kind` is a gunshot loud enough to be a call for help when one of their own fires it. +/proc/stealth_noise_is_gunfire(kind) + return kind == STEALTH_NOISE_GUN_PISTOL || kind == STEALTH_NOISE_GUN_SMG || kind == STEALTH_NOISE_GUN_LONG + +/** + * Something made a noise guards can hear. Does nothing unless a stealth group has awake members on that level. + * source: where it happened (any atom; its turf is used). kind: STEALTH_NOISE_*. reach: tiles in open air, or + * null for the kind's default. actor: the living mob that made it, if known. only: one group to tell, or null + * for every group in earshot. + * Returns the number of guards that heard it (tests and the admin view). + */ +/proc/stealth_noise(atom/source, kind, reach = null, mob/living/actor = null, datum/stealth_group/only = null) + if(!length(GLOB.stealth_groups)) + return 0 + var/turf/where = get_turf(source) + var/list/profile = stealth_noise_profile(kind) + if(isnull(where) || isnull(profile)) + return 0 + // Nobody to play for: guards sleep with their level. + if(where.z > length(SSmobs.clients_by_zlevel) || !length(SSmobs.clients_by_zlevel[where.z])) + return 0 + if(isnull(reach)) + reach = profile[STEALTH_PROFILE_REACH] + if(reach <= 0) + return 0 + // A door nobody is named for may be one of their own going through. + var/door = isnull(actor) && istype(source, /obj/machinery/door) + var/aired = FALSE + . = 0 + for(var/datum/stealth_group/group as anything in (only ? list(only) : GLOB.stealth_groups.Copy())) + if(QDELETED(group) || group.home?.z != where.z || !group.region_matches(where)) + continue + if(!aired) + aired = TRUE + if(!stealth_noise_carries(where, actor)) + return 0 + . += group.hear(where, kind, reach, profile[STEALTH_PROFILE_LEVEL], profile[STEALTH_PROFILE_FLAGS], actor, door) + +/// Whether there is air at `where` to carry a sound, as for players (playsound_local scales by pressure). A +/// noise on a wall or a window tile is judged where its maker stands. +/proc/stealth_noise_carries(turf/where, mob/living/actor) + var/turf/air_turf = where + if(isclosedturf(air_turf)) + air_turf = get_turf(actor) + if(isnull(air_turf) || isclosedturf(air_turf)) + return TRUE + var/datum/gas_mixture/air = air_turf.return_air() + return !isnull(air) && air.return_pressure() >= SOUND_MINIMUM_PRESSURE + +/** + * How far off a sound from `origin` seems at `ear`: the distance plus STEALTH_MUFFLE_WALL for each wall and + * STEALTH_MUFFLE_DOOR for each closed solid door (or anything else you can't see through) on the straight + * line between them. Glass costs nothing. Loud sounds lose half as much. INFINITY when unheard: more than + * STEALTH_MUFFLE_MAX_BLOCKERS in the way, or any at all for a STEALTH_NOISE_LOS_ONLY sound. The two end + * turfs are never counted, so a knock on a wall is heard on both sides of it. + */ +/proc/stealth_muffled_dist(turf/origin, turf/ear, flags = NONE) + if(isnull(origin) || isnull(ear) || origin.z != ear.z) + return INFINITY + var/dist = get_dist(origin, ear) + if(dist <= 1) + return dist + var/list/line = get_line(origin, ear) + var/blockers = 0 + var/penalty = 0 + for(var/index in 2 to length(line) - 1) + var/turf/between = line[index] + var/cost = 0 + if(isclosedturf(between)) + cost = STEALTH_MUFFLE_WALL + else if(between.directional_opacity == ALL_CARDINALS) + cost = STEALTH_MUFFLE_DOOR + if(!cost) + continue + if(flags & STEALTH_NOISE_LOS_ONLY) + return INFINITY + blockers++ + if(blockers > STEALTH_MUFFLE_MAX_BLOCKERS) + return INFINITY + penalty += cost + if(flags & STEALTH_NOISE_LOUD) + penalty *= STEALTH_MUFFLE_LOUD_MULT + return dist + penalty + +/// Where a guard standing on `listener` goes to look at a noise at `where`: the spot, or, when the spot is a +/// wall or a window tile, the open tile beside it nearest the guard (its own side of the wall). +/proc/stealth_hear_spot(turf/where, turf/listener) + if(isnull(where)) + return null + if(isopenturf(where) && !where.is_blocked_turf(exclude_mobs = TRUE)) + return where + var/turf/best + var/best_dist = INFINITY + for(var/turf/open/near in RANGE_TURFS(1, where)) + if(near == where || near.is_blocked_turf(exclude_mobs = TRUE)) + continue + // Straight-line distance: of two tiles as many steps away, the one more directly on its side. + var/dist = listener ? get_dist_euclidean(near, listener) : 0 + if(dist < best_dist) + best = near + best_dist = dist + return best || where + +// ===== The group's side ===== + +/// Its guards hear a noise at `where`, those that can. Returns how many did. +/datum/stealth_group/proc/hear(turf/where, kind, reach, level, flags, mob/living/actor, door = FALSE) + var/list/views = length(GLOB.stealth_sight_overlays) ? stealth_noise_views(where.z) : null + // The same noise again (full auto, a burst, pellets): one event, not ten. + var/list/stamp = noise_stamps[kind] + if(stamp && world.time - stamp[1] < STEALTH_NOISE_COALESCE && get_dist(stamp[2], where) <= STEALTH_NOISE_COALESCE_RANGE) + for(var/datum/stealth_noise_view/view as anything in views) + view.show(where, kind, reach, null, TRUE) + return 0 + var/list/guards = living_members() + if(!length(guards)) + return 0 + if((flags & STEALTH_NOISE_GROUP_ONLY) && !(actor in guards)) + return 0 + if(door && member_beside(where, guards)) + return 0 + var/mob/living/any_guard = guards[1] + var/ally = !isnull(actor) && any_guard.faction_check_atom(actor) + var/gunfire = stealth_noise_is_gunfire(kind) + // One of their own making an everyday noise. + if(ally && !gunfire && !(flags & (STEALTH_NOISE_ALLIES_COUNT | STEALTH_NOISE_GROUP_ONLY))) + return 0 + // An ally's gunfire means an ally is in a fight: it is a call for help from where they stand. + if(ally && gunfire) + level = STEALTH_HEAR_SHOUT + noise_notes = views ? list() : null + noise_reach = reach + // list(guard, muffled distance), nearest first + var/list/hearers = list() + for(var/mob/living/carbon/human/npc/guard as anything in guards) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + if(guard == actor || !istype(guard_ai)) + continue + var/turf/guard_turf = get_turf(guard) + if(guard_turf?.z != where.z) + continue + var/dist = get_dist(guard_turf, where) + if(dist > reach) + continue + if(guard_ai.ai_status == AI_STATUS_OFF || guard.client || guard.stat != CONSCIOUS || guard_ai.stealth_captive() || guard_ai.blackboard_key_exists(BB_HUMAN_NPC_EXIT)) + note(guard, "busy") + continue + if(!guard.can_hear()) + note(guard, "deaf") + continue + // Fighting: only an alarm made by someone else tells it anything, and that before the line walk. + if(guard_ai.stealth_state() == STEALTH_ALERT && (level != STEALTH_HEAR_ALARM || !isliving(actor))) + note(guard, "busy") + continue + var/muffled = stealth_muffled_dist(where, guard_turf, flags) + if(muffled > reach) + note(guard, muffled == INFINITY ? "muffled" : "muffled [muffled]/[reach]") + continue + var/index = 1 + while(index <= length(hearers) && hearers[index][2] <= muffled) + index++ + hearers.Insert(index, null) + hearers[index] = list(guard, muffled) + guard_ai.last_heard = list(kind, world.time) + note(guard, "heard [muffled]/[reach]") + if(length(hearers)) + noise_stamps[kind] = list(world.time, where) + // Far off, they misjudge the spot: all of them by the same error, so they end up in one place. + var/turf/spot = where + var/list/nearest = hearers[1] + var/fuzz = min(STEALTH_NOISE_FUZZ_MAX, round((nearest[2] - STEALTH_NOISE_EXACT_RANGE) / STEALTH_NOISE_FUZZ_STEP, 1)) + if(fuzz > 0 && level != STEALTH_HEAR_SHOUT) + var/turf/guess = locate(clamp(where.x + rand(-fuzz, fuzz), 1, world.maxx), clamp(where.y + rand(-fuzz, fuzz), 1, world.maxy), where.z) + guess = stealth_open_near(guess, 2) + if(guess && region_matches(guess)) + spot = guess + dispatch_noise(hearers, where, kind, level, actor, spot) + for(var/datum/stealth_noise_view/view as anything in views) + view.show(where, kind, reach, noise_notes, FALSE) + noise_notes = null + return length(hearers) + +/// Admin noise view: what `guard` made of the noise being dealt with. +/datum/stealth_group/proc/note(mob/living/guard, text, append = FALSE) + if(isnull(noise_notes)) + return + noise_notes[guard] = append ? "[noise_notes[guard]] -> [text]" : text + +/// Whether a conscious guard of the group stands within a tile of `where`. +/datum/stealth_group/proc/member_beside(turf/where, list/guards) + for(var/mob/living/guard as anything in guards) + if(guard.stat == CONSCIOUS && guard.z == where.z && get_dist(guard, where) <= 1) + return TRUE + return FALSE + +/// How many small noises it sent someone to within STEALTH_LURE_RADIUS of `where` and STEALTH_LURE_MEMORY. +/datum/stealth_group/proc/lure_count(turf/where) + . = 0 + for(var/list/lure as anything in lures) + if(world.time - lure[2] > STEALTH_LURE_MEMORY) + lures -= list(lure) + continue + if(get_dist(lure[1], where) <= STEALTH_LURE_RADIUS) + .++ + +/// How many alarms it answered within STEALTH_ALARM_MEMORY (a lure counts half). +/datum/stealth_group/proc/alarm_count() + . = 0 + for(var/list/alarm as anything in alarms) + if(world.time - alarm[1] > STEALTH_ALARM_MEMORY) + alarms -= list(alarm) + continue + . += alarm[2] + +/// Forgets every noise (admin tools). +/datum/stealth_group/proc/forget_noises() + noise_stamps = list() + lures = list() + alarms = list() + checks = list() + next_radio_alarm = 0 + +/// Moves every noise stamp back by `time`, as if that long had passed (unit tests, admin tools). +/datum/stealth_group/proc/rewind(time) + for(var/kind in noise_stamps) + var/list/stamp = noise_stamps[kind] + stamp[1] -= time + for(var/list/lure as anything in lures) + lure[2] -= time + for(var/list/alarm as anything in alarms) + alarm[1] -= time + for(var/datum/stealth_check/check as anything in checks) + check.due -= time + next_overdue_check -= time + next_radio_alarm -= time + +/** + * What the guards that heard a noise do, decided for the group. `hearers` is list(guard, muffled distance), + * nearest first. `where` is where it really was (lures are counted there); `spot` is where they think it was. + */ +/datum/stealth_group/proc/dispatch_noise(list/hearers, turf/where, kind, level, mob/living/actor, turf/spot) + spot ||= where + switch(level) + if(STEALTH_HEAR_GLANCE) + for(var/list/hearer as anything in hearers) + var/mob/living/guard = hearer[1] + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + if(guard_ai.stealth_state() == STEALTH_UNAWARE) + guard_ai.stealth_glance(spot) + note(guard, "glance", TRUE) + if(STEALTH_HEAR_INVESTIGATE) + dispatch_investigate(hearers, where, spot) + if(STEALTH_HEAR_ALARM) + dispatch_alarm(hearers, where, spot, kind, actor) + if(STEALTH_HEAR_SHOUT) + dispatch_shout(hearers, spot) + +/// A small noise: one guard goes to look, the rest glance. The second time at one spot two go; from the third +/// everyone who hears it searches there, and they call it in. +/datum/stealth_group/proc/dispatch_investigate(list/hearers, turf/where, turf/spot) + var/earlier = lure_count(where) + lures += list(list(where, world.time)) + alarms += list(list(world.time, 0.5)) + if(earlier >= 2) + var/shouted = FALSE + for(var/list/hearer as anything in hearers) + var/mob/living/carbon/human/npc/guard = hearer[1] + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + // A sentry still doesn't leave its door for something far off. + if(isnull(guard_ai.route_id) && get_dist(guard, spot) > STEALTH_SENTRY_INVESTIGATE_RANGE) + guard_ai.stealth_hold(spot) + note(guard, "holds", TRUE) + else if(guard_ai.stealth_respond(spot)) + note(guard, "searches", TRUE) + if(!shouted) + shouted = TRUE + guard_ai.stealth_bark(STEALTH_BARK_HELP) + guard_ai.stealth_shout() + if(guard.stealth_radio) + guard_ai.stealth_start_call(STEALTH_CALL_ALARM, spot) + return + var/wanted = earlier + 1 + var/list/went = list() + var/list/stayed = list() + for(var/list/hearer as anything in hearers) + var/mob/living/carbon/human/npc/guard = hearer[1] + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + var/sentry_too_far = isnull(guard_ai.route_id) && get_dist(guard, spot) > STEALTH_SENTRY_INVESTIGATE_RANGE + if(length(went) < wanted && !sentry_too_far && guard_ai.stealth_can_investigate()) + guard_ai.stealth_go_heard(spot, earlier >= 1 && !length(went)) + went += guard + note(guard, "goes", TRUE) + if(guard.stealth_radio) + guard_ai.stealth_start_call(STEALTH_CALL_REPORT, spot) + continue + stayed += WEAKREF(guard) + switch(guard_ai.stealth_state()) + if(STEALTH_UNAWARE) + guard_ai.stealth_glance(spot) + note(guard, "glance", TRUE) + if(STEALTH_SEARCHING) + if(guard_ai.stealth_respond(spot)) + note(guard, "searches", TRUE) + for(var/mob/living/carbon/human/npc/guard as anything in went) + var/datum/stealth_check/check = new + check.who = WEAKREF(guard) + check.name = stealth_first_name(guard) + check.where = where + check.due = world.time + STEALTH_CHECKIN_TIME + check.waiters = stayed.Copy() + checks += check + +/// Gunfire, a blast, a scream: the nearest few that aren't sentries run to it and search; every other hearer +/// holds where it is, wary. After two alarms in memory fewer go: it may be a draw. +/datum/stealth_group/proc/dispatch_alarm(list/hearers, turf/where, turf/spot, kind, mob/living/actor) + var/cap = alarm_count() >= 2 ? STEALTH_ALARM_RESPONDERS_LATER : STEALTH_ALARM_RESPONDERS + alarms += list(list(world.time, 1)) + var/responders = 0 + for(var/list/hearer as anything in hearers) + var/mob/living/carbon/human/npc/guard = hearer[1] + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + if(guard_ai.stealth_state() == STEALTH_ALERT) + // Fighting someone else: it now knows there is another one about (never a target from sound). + var/atom/target = guard_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(isliving(actor) && actor != target && bounty_ai_target_for(actor) != target && !guard.faction_check_atom(actor)) + guard_ai.stealth_remember(actor) + note(guard, "notes", TRUE) + continue + if(!isnull(guard_ai.route_id) && responders < cap) + if(guard_ai.stealth_respond(spot)) + if(!responders) + guard_ai.stealth_bark(stealth_noise_is_gunfire(kind) ? STEALTH_BARK_GUNFIRE : STEALTH_BARK_HEARD, spot) + note(guard, "goes", TRUE) + responders++ + else + guard_ai.stealth_hold(spot) + note(guard, "holds", TRUE) + if(guard.stealth_radio) + guard_ai.stealth_start_call(STEALTH_CALL_ALARM, spot) + +/// A friend calling for help from `spot`: whoever hears it and isn't a sentry runs to them. They heard a +/// friend, they saw nobody, so they search where the friend stood. +/datum/stealth_group/proc/dispatch_shout(list/hearers, turf/spot) + for(var/list/hearer as anything in hearers) + var/mob/living/carbon/human/npc/guard = hearer[1] + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + if(guard_ai.stealth_state() == STEALTH_ALERT) + continue + if(isnull(guard_ai.route_id)) + guard_ai.stealth_hold(spot) + note(guard, "holds", TRUE) + else if(guard_ai.stealth_respond(spot)) + note(guard, "goes", TRUE) + if(guard.stealth_radio) + guard_ai.stealth_start_call(STEALTH_CALL_ALARM, spot) + +/** + * The guard who never came back (abuse P6). Run from each member's scan, at most once per + * STEALTH_SCAN_INTERVAL. A guard that went to look and is back on its round, or found someone, closes its + * check. One that is past due and gone, down, cuffed or still out is missed: the nearest two who know it + * went search where it went, and the next noise there brings everyone. + */ +/datum/stealth_group/proc/check_overdue() + if(world.time < next_overdue_check || !length(checks)) + return + next_overdue_check = world.time + STEALTH_SCAN_INTERVAL + for(var/datum/stealth_check/check as anything in checks.Copy()) + var/mob/living/carbon/human/npc/gone = check.who?.resolve() + var/datum/ai_controller/basic_controller/human_npc/stealth/gone_ai = gone?.ai_controller + var/down = QDELETED(gone) || !istype(gone_ai) || gone.stat != CONSCIOUS || HAS_TRAIT(gone, TRAIT_RESTRAINED) + if(!down) + var/state = gone_ai.stealth_state() + if(state == STEALTH_UNAWARE || state == STEALTH_ALERT) + checks -= check + continue + if(world.time < check.due) + continue + checks -= check + // Who misses it: conscious, free, not fighting. Nearest to the spot first. + var/list/missers = list() + for(var/datum/weakref/waiter_ref as anything in check.waiters) + var/mob/living/carbon/human/npc/waiter = waiter_ref.resolve() + var/datum/ai_controller/basic_controller/human_npc/stealth/waiter_ai = waiter?.ai_controller + if(QDELETED(waiter) || waiter == gone || !istype(waiter_ai) || waiter.stat != CONSCIOUS || waiter.client) + continue + if(waiter_ai.ai_status == AI_STATUS_OFF || waiter_ai.stealth_captive() || waiter_ai.stealth_state() == STEALTH_ALERT) + continue + var/dist = get_dist(waiter, check.where) + var/index = 1 + while(index <= length(missers) && get_dist(missers[index], check.where) <= dist) + index++ + missers.Insert(index, waiter) + if(!length(missers)) + continue + // The next noise there brings everyone. + lures += list(list(check.where, world.time), list(check.where, world.time)) + var/sent = 0 + for(var/mob/living/carbon/human/npc/waiter as anything in missers) + var/datum/ai_controller/basic_controller/human_npc/stealth/waiter_ai = waiter.ai_controller + if(sent < 2 && (!isnull(waiter_ai.route_id) || get_dist(waiter, check.where) <= STEALTH_SENTRY_INVESTIGATE_RANGE)) + if(waiter_ai.stealth_respond(check.where)) + sent++ + waiter_ai.stealth_bark(STEALTH_BARK_MISSING, check.where, check.name) + if(waiter.stealth_radio) + waiter_ai.stealth_start_call(STEALTH_CALL_MISSING, check.where, check.name) + +/// A blast anywhere: its own guards hear it if it is near enough. Registered per group, not per guard. +/datum/stealth_group/proc/on_explosion(datum/source, turf/epicenter) + SIGNAL_HANDLER + if(isnull(epicenter) || epicenter.z != home?.z) + return + INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(stealth_noise), epicenter, STEALTH_NOISE_BLAST, null, null, src) + +/// What its friends call `guard`: the first word of its name, or null when it has only its type's name +/// ("Scav Guard"). An NPC's name, never player text. +/proc/stealth_first_name(mob/living/carbon/human/npc/guard) + var/full = guard.real_name + if(!istext(full) || !length(full) || full == guard.npc_name || full == initial(guard.name)) + return null + var/space = findtext(full, " ") + return space ? copytext(full, 1, space) : full diff --git a/voidcrew/modules/stealth/stealth_noise_sources.dm b/voidcrew/modules/stealth/stealth_noise_sources.dm new file mode 100644 index 00000000000..b76fcb454e6 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_noise_sources.dm @@ -0,0 +1,264 @@ +/** + * # Stealth guards: where noise comes from + * + * Every hook that turns one of tg's own sounds into a stealth_noise() guards can hear. Each is a chained + * override: the same proc defined again on the same type in this later-compiled file, calling ..() (the + * model is cyberware_stealth.dm). No upstream file is edited. Each hook costs one call that returns at once + * while no stealth group is alive, and does no work per object in the room. + * + * Every reach is at or under how far a player hears the same tg sound (abuse H2): a guard never hears what + * a player standing in its place could not. + * + * Not hooked: mech and ship weapons (a mech is seen all round anyway), speech, machines. + */ + +// ===== Guns (owner decisions 2 and 7) ===== + +/obj/item/gun + /// How loud it is to stealth guards when it isn't suppressed (STEALTH_NOISE_GUN_*). A gun that sets + /// nothing is as loud as a pistol. + var/stealth_loudness = STEALTH_NOISE_GUN_PISTOL + +// Quiet by nature +/obj/item/gun/ballistic/bow + stealth_loudness = STEALTH_NOISE_GUN_QUIET + +/obj/item/gun/ballistic/rifle/rebarxbow + stealth_loudness = STEALTH_NOISE_GUN_QUIET + +/obj/item/gun/syringe + stealth_loudness = STEALTH_NOISE_GUN_QUIET + +/obj/item/gun/chem + stealth_loudness = STEALTH_NOISE_GUN_QUIET + +// SMGs: the automatic root, with its pistols set back +/obj/item/gun/ballistic/automatic + stealth_loudness = STEALTH_NOISE_GUN_SMG + +/obj/item/gun/ballistic/automatic/pistol + stealth_loudness = STEALTH_NOISE_GUN_PISTOL + +/obj/item/gun/ballistic/automatic/gyropistol + stealth_loudness = STEALTH_NOISE_GUN_PISTOL + +/obj/item/gun/ballistic/automatic/toy + stealth_loudness = STEALTH_NOISE_GUN_QUIET + +// Long guns, launchers and heavy energy +/obj/item/gun/ballistic/rifle + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/ballistic/shotgun + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/ballistic/shotgun/toy + stealth_loudness = STEALTH_NOISE_GUN_QUIET + +/obj/item/gun/ballistic/automatic/ar + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/ballistic/automatic/battle_rifle + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/ballistic/automatic/m90 + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/ballistic/automatic/l6_saw + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/ballistic/automatic/smartgun + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/ballistic/rocketlauncher + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/ballistic/revolver/grenadelauncher + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/grenadelauncher + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/blastcannon + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/energy/lasercannon + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/energy/pulse + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/energy/ionrifle + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/energy/tesla_cannon + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/obj/item/gun/energy/minigun + stealth_loudness = STEALTH_NOISE_GUN_LONG + +/** + * list(noise kind, reach) for a shot from this gun now. Any suppressed gun is in the quietest tier, whatever + * it is (owner decision 7): a fitted suppressor, an integral one, the syringe gun. The reach is never more + * than how far tg plays the gun's own sound to players, so a quiet custom gun doesn't carry further for + * guards than for people. + */ +/obj/item/gun/proc/stealth_shot_noise() + var/kind = suppressed ? STEALTH_NOISE_GUN_QUIET : stealth_loudness + var/list/profile = stealth_noise_profile(kind) + var/volume = suppressed ? suppressed_volume : fire_sound_volume + if(isnull(profile) || volume <= SOUND_AUDIBLE_VOLUME_MIN) + return list(kind, 0) + // As fire_sounds() plays it. Plain numbers: the macro doesn't bracket its arguments. + var/sound_range = SOUND_RANGE + var/falloff = SOUND_DEFAULT_FALLOFF_DISTANCE + if(suppressed) + sound_range += SILENCED_SOUND_EXTRARANGE + falloff = 0 + var/audible = CALCULATE_MAX_SOUND_AUDIBLE_DISTANCE(volume, sound_range, falloff, SOUND_FALLOFF_EXPONENT) + return list(kind, min(profile[STEALTH_PROFILE_REACH], audible)) + +// Once per shot, bursts included, with the shooter. fire_sounds() is the wrong place: five types override it +// without calling the parent. +/obj/item/gun/shoot_live_shot(mob/living/user, pointblank = FALSE, atom/pbtarget = null, message = TRUE) + . = ..() + if(!length(GLOB.stealth_groups)) + return + var/list/noise = stealth_shot_noise() + stealth_noise(src, noise[1], noise[2], isliving(user) ? user : null) + +// A round striking a wall, a window, a crate. A suppressed round's strike is nearly silent in tg (volume 5), +// so it is no lure; what it breaks is. +/obj/projectile/on_hit(atom/target, blocked = 0, pierce_hit) + . = ..() + if(!length(GLOB.stealth_groups) || isliving(target) || isnull(target)) + return + stealth_noise(target, STEALTH_NOISE_IMPACT, suppressed ? STEALTH_NOISE_RANGE_IMPACT_QUIET : STEALTH_NOISE_RANGE_IMPACT, isliving(firer) ? firer : null) + +// ===== Thrown things ===== + +/obj/item + /// The noise it makes when a throw ends (STEALTH_NOISE_*), or null for a plain landing by its weight + var/stealth_throw_noise + +/// The noise this makes landing from a throw. +/obj/item/proc/stealth_throw_kind() + return stealth_throw_noise || STEALTH_NOISE_THROWN + +// A glass or a bottle smashes; a carton or a plastic bottle only lands. +/obj/item/reagent_containers/cup/glass/stealth_throw_kind() + return isGlass ? STEALTH_NOISE_GLASS : ..() + +// Landing, or hitting something that isn't a person. A throw that lands on a guard is a touch, which the +// guard's own controller deals with. +/obj/item/throw_impact(atom/hit_atom, datum/thrownthing/throwingdatum) + // Read first: a bottle is gone once it has smashed. + var/noisy = length(GLOB.stealth_groups) && !isliving(hit_atom) + var/kind = noisy ? stealth_throw_kind() : null + var/size = w_class + . = ..() + if(!noisy) + return + var/reach + if(kind == STEALTH_NOISE_THROWN) + if(size <= WEIGHT_CLASS_SMALL) + reach = STEALTH_NOISE_RANGE_THROWN_SMALL + else if(size == WEIGHT_CLASS_NORMAL) + reach = STEALTH_NOISE_RANGE_THROWN_MEDIUM + else + reach = STEALTH_NOISE_RANGE_THROWN_HEAVY + var/mob/living/thrower = throwingdatum?.get_thrower() + stealth_noise(hit_atom, kind, reach, isliving(thrower) ? thrower : null) + +// ===== Glass ===== + +/obj/structure/window/atom_deconstruct(disassembled = TRUE) + if(!disassembled) + stealth_noise(src, STEALTH_NOISE_GLASS) + return ..() + +// Shot out or smashed: only a tube that was whole makes the sound (tg's own rule). +/obj/machinery/light/break_light_tube(skip_sound_and_sparks = FALSE) + var/whole = !skip_sound_and_sparks && (status == LIGHT_OK || status == LIGHT_BURNED) + . = ..() + if(whole) + stealth_noise(src, STEALTH_NOISE_GLASS) + +// ===== Knocks ===== + +// Knuckles on a window with combat mode off, a fist with it on. Repeats the parent's two guards. +/obj/structure/window/attack_hand(mob/living/user, list/modifiers) + . = ..() + if(. || !length(GLOB.stealth_groups) || !can_be_reached(user)) + return + stealth_noise(src, user.combat_mode ? STEALTH_NOISE_BASH : STEALTH_NOISE_KNOCK, null, user) + +// "You push the wall but nothing happens!" +/turf/closed/wall/attack_hand(mob/user, list/modifiers) + . = ..() + if(. || !length(GLOB.stealth_groups)) + return + stealth_noise(src, STEALTH_NOISE_KNOCK, null, isliving(user) ? user : null) + +// An airlock opening or closing, refusing someone, or being forced. Nobody is named: a door with one of the +// group beside it is one of their own going through (stealth_noise()). +/obj/machinery/door/airlock/animation_effects(animation, force_type = DEFAULT_DOOR_CHECKS) + . = ..() + if(!length(GLOB.stealth_groups)) + return + if(force_type == BYPASS_DOOR_CHECKS) + stealth_noise(src, STEALTH_NOISE_BASH) + return + switch(animation) + if(DOOR_OPENING_ANIMATION, DOOR_CLOSING_ANIMATION) + stealth_noise(src, STEALTH_NOISE_DOOR) + if(DOOR_DENY_ANIMATION) + stealth_noise(src, STEALTH_NOISE_KNOCK) + +// Tools at work: a wrench or a crowbar is a knock, a welder or a drill is louder. Breaking in is a choice +// between slow and quiet, and fast and loud. Guards don't use tools. +/obj/item/play_tool_sound(atom/target, volume = 50) + . = ..() + if(!length(GLOB.stealth_groups) || !target || !usesound || !volume) + return + var/loud = tool_behaviour == TOOL_WELDER || tool_behaviour == TOOL_DRILL || tool_behaviour == TOOL_MINING + stealth_noise(target, loud ? STEALTH_NOISE_BASH : STEALTH_NOISE_KNOCK, null, isliving(loc) ? loc : null) + +// ===== Bodies and voices ===== + +// A body hitting the floor. Someone held in an aggressive grab or tighter is let down by whoever holds them: +// no thud. That is a rule about noise only; what happened to the body is whatever tg did to it. +/turf/handle_fall(mob/faller) + . = ..() + if(!length(GLOB.stealth_groups) || !isliving(faller) || !has_gravity(src)) + return + if(faller.pulledby && faller.pulledby.grab_state >= GRAB_AGGRESSIVE) + return + stealth_noise(src, STEALTH_NOISE_THUD, null, faller) + +// A whistle, a clap, a snap, a cough, a laugh, a scream: any emote whose sound tg plays now, under tg's own +// rules for when it plays (its cooldowns included). +/datum/emote/run_emote(mob/user, params, type_override, intentional = FALSE) + var/sounded = FALSE + if(length(GLOB.stealth_groups) && isliving(user)) + sounded = get_sound(user) && should_play_sound(user, intentional) && TIMER_COOLDOWN_FINISHED(user, "general_emote_audio_cooldown") && TIMER_COOLDOWN_FINISHED(user, type) + . = ..() + if(!sounded || QDELETED(user)) + return + var/kind = STEALTH_NOISE_VOICE + switch(key) + if("scream", "screech") + kind = STEALTH_NOISE_SCREAM + if("deathgasp") + kind = STEALTH_NOISE_THUD + stealth_noise(user, kind, null, user) + +// ===== Blasts ===== +// Explosions: each group listens for COMSIG_GLOB_EXPLOSION (stealth_noise.dm). + +// The parent deletes the grenade, and deafens whoever it catches before anyone hears it. +/obj/item/grenade/flashbang/detonate(mob/living/lanced_by) + var/turf/where = get_turf(src) + . = ..() + if(. && where) + stealth_noise(where, STEALTH_NOISE_BLAST) diff --git a/voidcrew/modules/stealth/stealth_patrol.dm b/voidcrew/modules/stealth/stealth_patrol.dm index 6fe4a9d518b..bcf40901622 100644 --- a/voidcrew/modules/stealth/stealth_patrol.dm +++ b/voidcrew/modules/stealth/stealth_patrol.dm @@ -58,9 +58,16 @@ GLOBAL_VAR_INIT(stealth_group_count, 0) else no_region = TRUE GLOB.stealth_groups += src + RegisterSignal(SSdcs, COMSIG_GLOB_EXPLOSION, PROC_REF(on_explosion)) /datum/stealth_group/Destroy() GLOB.stealth_groups -= src + UnregisterSignal(SSdcs, COMSIG_GLOB_EXPLOSION) + noise_stamps = null + lures = null + alarms = null + checks = null + noise_notes = null if(marker) marker.group = null marker = null @@ -327,6 +334,14 @@ GLOBAL_VAR_INIT(stealth_group_count, 0) if(guard.stat != CONSCIOUS) return if(controller.stealth_state() == STEALTH_UNAWARE && !controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + // Glancing at a noise, or holding for one: it stops and faces it, then carries on. + if(world.time < controller.glance_until) + controller.stealth_drop_walk() + controller.next_turn = controller.glance_until + if(guard.dir != controller.glance_dir) + guard.setDir(controller.glance_dir) + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING if(length(controller.stealth_stops())) controller.stealth_plan_route() else diff --git a/voidcrew/modules/stealth/stealth_radio.dm b/voidcrew/modules/stealth/stealth_radio.dm new file mode 100644 index 00000000000..4b024e31ddd --- /dev/null +++ b/voidcrew/modules/stealth/stealth_radio.dm @@ -0,0 +1,149 @@ +/** + * # Stealth guards: radios (owner decision 8) + * + * Some guard types carry an earpiece (`stealth_radio` on the type, and a working headset in its ears). A + * carrier that learns of trouble raises a hand to its earpiece, and STEALTH_RADIO_CALL_TIME later says it + * out loud; every other carrier in its group then knows, wherever it stands, and passes it on by voice to + * whoever stands next to it. + * + * Players stop a call with tg's own tools and nothing else: drop, stun or silence the speaker before the call + * is due; run a radio jammer near the speaker or the receiver; EMP the earpiece (tg switches a radio off for + * 20 s); deafen the receiver. The headset can't be stripped: it is locked gear like the rest of the kit. + * + * No timers: a pending call is a stamp the guard's scan reads. One alarm delivery per group per + * STEALTH_RADIO_GROUP_COOLDOWN, so a den full of carriers doesn't echo itself. + */ + +/mob/living/carbon/human/npc + /// Whether this type is on its group's radio net (it still needs a working headset in its ears) + var/stealth_radio = FALSE + +/// Its working headset, or null: on the net, headset in the ears and switched on, not in a jammer's range. +/mob/living/carbon/human/npc/proc/stealth_radio_ready() + if(!stealth_radio) + return null + var/obj/item/radio/earpiece = ears + if(!istype(earpiece) || !earpiece.is_on() || is_within_radio_jammer_range(src)) + return null + return earpiece + +/** + * Starts a radio call about `where`: a hand to the earpiece now, the call STEALTH_RADIO_CALL_TIME later if it + * can still make it (stealth_deliver_call()). A call already on its way out is kept unless this one is more + * urgent, so a run of noises never puts the call off. Returns TRUE if a call is now pending. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_start_call(kind, turf/where, name) + var/mob/living/carbon/human/npc/guard = pawn + if(!ishumannpc(guard) || !guard.stealth_radio || !istype(guard.ears, /obj/item/radio)) + return FALSE + if(guard.stat != CONSCIOUS || stealth_driven() || isnull(where)) + return FALSE + if(pending_call) + if(pending_call[STEALTH_CALL_KIND] >= kind) + return TRUE + pending_call = list(kind, where, world.time + STEALTH_RADIO_CALL_TIME, name) + guard.visible_message(span_emote("[guard] raises a hand to [guard.p_their()] earpiece.")) + return TRUE + +/// The scan: makes its pending call once it is due, if it still can. A call it can't make is lost, never +/// retried; if only the radio failed, it says so. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_deliver_call() + if(isnull(pending_call) || world.time < pending_call[STEALTH_CALL_DUE]) + return + var/list/call_made = pending_call + pending_call = null + // Its level slept through it: nobody finishes a call that late. + if(world.time > call_made[STEALTH_CALL_DUE] + STEALTH_RADIO_CALL_STALE) + return + var/mob/living/carbon/human/npc/guard = pawn + if(guard.stat != CONSCIOUS || HAS_TRAIT(guard, TRAIT_INCAPACITATED) || !human_npc_can_talk(guard)) + return + if(!guard.stealth_radio_ready()) + stealth_bark(STEALTH_BARK_RADIO_DEAD) + return + var/turf/where = call_made[STEALTH_CALL_WHERE] + var/bark_kind = STEALTH_BARK_RADIO_ALARM + switch(call_made[STEALTH_CALL_KIND]) + if(STEALTH_CALL_REPORT) + bark_kind = STEALTH_BARK_RADIO_REPORT + if(STEALTH_CALL_MISSING) + bark_kind = STEALTH_BARK_RADIO_MISSING + // Said out loud, always: anyone in earshot of the speaker hears where the others are being sent. + stealth_bark(bark_kind, where, call_made[STEALTH_CALL_NAME], TRUE) + group?.radio_call(guard, call_made[STEALTH_CALL_KIND], where) + +/// A call that can't go out any more (the speaker was stunned, dropped or taken over). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_drop_call() + pending_call = null + +/// Whether `guard` can take a radio call now. +/datum/stealth_group/proc/radio_receiver(mob/living/carbon/human/npc/guard) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + if(!istype(guard_ai) || guard.stat != CONSCIOUS || guard.client || guard_ai.ai_status == AI_STATUS_OFF) + return FALSE + return guard.stealth_radio_ready() && guard.can_hear() && !guard_ai.stealth_captive() + +/** + * `speaker` has made its call. Every other member with a working radio that can hear gets it. + * - A report ("checking something"): they know it went, so they miss it if it doesn't come back. Nobody moves. + * - An alarm or a missing friend: at most one per STEALTH_RADIO_GROUP_COOLDOWN. The nearest + * STEALTH_RADIO_RESPONDERS receivers that aren't sentries search at the spot and answer; each brings up to + * STEALTH_RELAY_VOICE_RESPONDERS friends without radios from within earshot of its answer. Sentries and the + * rest hold. + * Returns how many receivers got it. + */ +/datum/stealth_group/proc/radio_call(mob/living/carbon/human/npc/speaker, kind, turf/where) + . = 0 + var/list/guards = living_members() + if(kind == STEALTH_CALL_REPORT) + var/list/speaker_checks = list() + for(var/datum/stealth_check/check as anything in checks) + if(check.who?.resolve() == speaker) + speaker_checks += check + for(var/mob/living/carbon/human/npc/guard as anything in guards) + if(guard == speaker || !radio_receiver(guard)) + continue + .++ + for(var/datum/stealth_check/check as anything in speaker_checks) + check.waiters |= WEAKREF(guard) + return + if(world.time < next_radio_alarm) + return 0 + next_radio_alarm = world.time + STEALTH_RADIO_GROUP_COOLDOWN + // Receivers, nearest to the spot first. + var/list/receivers = list() + for(var/mob/living/carbon/human/npc/guard as anything in guards) + if(guard == speaker || !radio_receiver(guard)) + continue + var/dist = get_dist(guard, where) + var/index = 1 + while(index <= length(receivers) && get_dist(receivers[index], where) <= dist) + index++ + receivers.Insert(index, guard) + var/moved = 0 + for(var/mob/living/carbon/human/npc/receiver as anything in receivers) + .++ + var/datum/ai_controller/basic_controller/human_npc/stealth/receiver_ai = receiver.ai_controller + if(receiver_ai.stealth_state() == STEALTH_ALERT) + continue + if(isnull(receiver_ai.route_id) || moved >= STEALTH_RADIO_RESPONDERS || !receiver_ai.stealth_respond(where)) + receiver_ai.stealth_hold(where) + continue + moved++ + receiver_ai.stealth_bark(STEALTH_BARK_COPY, null, null, TRUE) + // Its answer carries to whoever stands near it. + var/turf/receiver_turf = get_turf(receiver) + var/brought = 0 + for(var/mob/living/carbon/human/npc/friend as anything in guards) + if(brought >= STEALTH_RELAY_VOICE_RESPONDERS) + break + var/datum/ai_controller/basic_controller/human_npc/stealth/friend_ai = friend.ai_controller + if(friend == receiver || friend == speaker || friend.stealth_radio || !istype(friend_ai) || isnull(friend_ai.route_id)) + continue + if(friend.stat != CONSCIOUS || friend.client || friend_ai.ai_status == AI_STATUS_OFF || !friend.can_hear()) + continue + var/turf/friend_turf = get_turf(friend) + if(get_dist(friend_turf, receiver_turf) > STEALTH_RELAY_VOICE_RANGE || stealth_muffled_dist(receiver_turf, friend_turf) > STEALTH_RELAY_VOICE_RANGE) + continue + if(friend_ai.stealth_respond(where)) + brought++ From f1a9046d84285e27859959853bcff93095b9dded Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:54:17 -0800 Subject: [PATCH 015/152] Stealth test room: two lookouts, throwables, a crate of guns and gadgets, a window, a locked door, carpet and plating --- .../JungleRuins/jungle_stealth_test.dmm | 76 ++++++++++++++----- 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm index d882f79dcc5..b802a996b66 100644 --- a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm +++ b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm @@ -1,10 +1,45 @@ //MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE "a" = ( -/obj/effect/spawner/human_npc/ruin/scav{ +/obj/effect/spawner/human_npc/ruin/scav/lookout{ dir = 2 }, /turf/open/floor/iron, /area/ruin/powered) +"b" = ( +/obj/structure/table, +/obj/item/reagent_containers/cup/glass/bottle/beer, +/obj/item/reagent_containers/cup/glass/bottle/beer, +/obj/item/reagent_containers/cup/glass/bottle/beer, +/turf/open/floor/iron, +/area/ruin/powered) +"c" = ( +/obj/structure/table, +/obj/item/storage/toolbox/mechanical, +/obj/item/coin/iron, +/obj/item/coin/iron, +/obj/item/coin/iron, +/turf/open/floor/iron, +/area/ruin/powered) +"d" = ( +/obj/structure/closet/crate, +/obj/item/gun/ballistic/automatic/pistol, +/obj/item/suppressor, +/obj/item/gun/ballistic/automatic/wt550, +/obj/item/gun/ballistic/rifle/boltaction, +/obj/item/gun/ballistic/bow/longbow, +/obj/item/storage/bag/quiver/full, +/obj/item/jammer, +/obj/item/grenade/flashbang, +/obj/item/grenade/empgrenade, +/turf/open/floor/iron, +/area/ruin/powered) +"e" = ( +/obj/machinery/door/airlock/security{ + name = "Strongroom"; + req_access = list("armory") + }, +/turf/open/floor/iron, +/area/ruin/powered) "f" = ( /obj/effect/landmark/stealth_patrol{ route = "b"; @@ -13,6 +48,9 @@ }, /turf/open/floor/plating, /area/ruin/powered) +"g" = ( +/turf/open/floor/carpet, +/area/ruin/powered) "h" = ( /obj/effect/landmark/stealth_patrol{ route = "b"; @@ -93,7 +131,7 @@ order = 2; dir = 4 }, -/obj/effect/spawner/human_npc/ruin/scav/gunner{ +/obj/effect/spawner/human_npc/ruin/scav/lookout{ dir = 8 }, /turf/open/floor/iron, @@ -367,8 +405,8 @@ i (11,1,1) = {" i J -x -x +g +g J P P @@ -391,9 +429,9 @@ i (12,1,1) = {" i J -x -x -J +g +g +p P P P @@ -415,8 +453,8 @@ i (13,1,1) = {" i J -x -x +g +g J P P @@ -494,7 +532,7 @@ J J J J -J +e J J J @@ -511,8 +549,8 @@ i (17,1,1) = {" i J -x -x +P +P J x x @@ -535,8 +573,8 @@ i (18,1,1) = {" i J -x -x +P +P J l x @@ -559,8 +597,8 @@ i (19,1,1) = {" i J -x -x +P +P p x x @@ -599,7 +637,7 @@ x x x x -x +b J i i @@ -623,7 +661,7 @@ x x x x -x +c J i i @@ -695,7 +733,7 @@ x x x x -x +d J i i From 72558e627bdff9dad2d25ceabc89a7b5a0e686f0 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:20:53 -0800 Subject: [PATCH 016/152] Stealth hearing tests: muffling, gun tiers, footsteps, lures, the missing guard, alarms, shouts, radios, and real shots, throws, knocks, falls, emotes and blasts --- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_stealth_hearing.dm | 799 ++++++++++++++++++ 2 files changed, 800 insertions(+) create mode 100644 voidcrew/modules/unit_tests/voidcrew_stealth_hearing.dm diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 19067100870..ccab4290d6d 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -235,6 +235,7 @@ #include "voidcrew_slime_extract_piles.dm" #include "voidcrew_smart_locker.dm" #include "voidcrew_stealth.dm" +#include "voidcrew_stealth_hearing.dm" #include "voidcrew_survey_archive.dm" #include "voidcrew_survey_capabilities.dm" #include "voidcrew_survey_identity.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth_hearing.dm b/voidcrew/modules/unit_tests/voidcrew_stealth_hearing.dm new file mode 100644 index 00000000000..641f866b05b --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_stealth_hearing.dm @@ -0,0 +1,799 @@ +// Stealth guards: hearing (voidcrew/modules/stealth/stealth_noise.dm and friends). Unit tests compile before +// voidcrew/_DEFINES, so values from there are written out: noise kinds "gun_quiet", "gun_pistol", "gun_smg", +// "gun_long", "impact", "blast", "glass", "thrown", "knock", "bash", "door", "thud", "voice", "scream", "shout"; +// flags LOS_ONLY 1, LOUD 2; call kinds REPORT 1, MISSING 2, ALARM 3; STEALTH_CHECKIN_TIME 30 s, +// STEALTH_RADIO_CALL_TIME 3 s, the heard mark "q_y0". +// +// Every test runs in the walled, lit room of voidcrew_stealth_live with a person on SSmobs' list of players, so +// the level is awake and guards hear. Those down to hear/cleanup never sleep: the AI is on but never gets a +// turn, so they pin what one noise does. The ones after them use real tg actions, and the last three let the +// real AI run. + +#define STEALTH_HEAR_GUARD /mob/living/carbon/human/npc/ruin/trooper/scav +#define STEALTH_HEAR_LOOKOUT /mob/living/carbon/human/npc/ruin/trooper/scav/lookout + +/datum/unit_test/voidcrew_stealth_live/hear + abstract_type = /datum/unit_test/voidcrew_stealth_live/hear + /// Stops handed out on route "a" so far + var/stops_made = 0 + /// The group's marker + var/obj/effect/landmark/stealth_group/marker + +/// The room, its group marker in the top left corner, and a player far off in the bottom right one. +/datum/unit_test/voidcrew_stealth_live/hear/proc/hear_room(width, height) + if(!make_room(width, height)) + return FALSE + marker = allocate(/obj/effect/landmark/stealth_group, at(0, height - 1)) + person_at(width - 1, 0) + return TRUE + +/// Gives the room's floor gravity, for tests where something is thrown or falls (reserved space has none). +/datum/unit_test/voidcrew_stealth_live/hear/proc/ground() + var/turf/corner = room.bottom_left_turfs[1] + var/turf/far_corner = room.top_right_turfs[1] + for(var/turf/open/spot in block(corner.x, corner.y, corner.z, far_corner.x, far_corner.y, corner.z)) + spot.AddElement(/datum/element/forced_gravity, 1) + +/// A sentry at (dx, dy): awake and in the group. +/datum/unit_test/voidcrew_stealth_live/hear/proc/sentry(dx, dy, facing = EAST, guard_type = STEALTH_HEAR_GUARD) + var/mob/living/carbon/human/npc/guard = allocate(guard_type, at(dx, dy)) + guard.setDir(facing) + guard.AddElement(/datum/element/forced_gravity, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = wake(guard) + stealth_ai.stealth_setup() + return guard + +/// A guard with a stop of route "a" under it, so it walks a route (it waits there ten minutes). +/datum/unit_test/voidcrew_stealth_live/hear/proc/walker(dx, dy, facing = EAST, guard_type = STEALTH_HEAR_GUARD) + stop_at(dx, dy, ++stops_made, 10 MINUTES) + return sentry(dx, dy, facing, guard_type) + +/// Back to nothing on its mind, with every clock run out. +/datum/unit_test/voidcrew_stealth_live/hear/proc/settle(mob/living/guard) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + stealth_ai.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + stealth_ai.stealth_go_unaware() + stealth_ai.stealth_clear_wary() + stealth_ai.stealth_drop_call() + stealth_ai.stealth_rewind(2 MINUTES) + stealth_ai.glance_until = 0 + +/datum/unit_test/voidcrew_stealth_live/hear/proc/state_of(mob/living/guard) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + return stealth_ai.stealth_state() + +/datum/unit_test/voidcrew_stealth_live/hear/proc/no_targets(list/guards) + for(var/mob/living/guard as anything in guards) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + TEST_ASSERT(!stealth_ai.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET), "A sound gave [guard] a target") + +/// Walls and doors on the line eat reach; glass and the two ends don't. +/datum/unit_test/voidcrew_stealth_live/hear/muffle + +/datum/unit_test/voidcrew_stealth_live/hear/muffle/Run() + TEST_ASSERT(hear_room(20, 3), "No room for the test") + var/turf/west = at(0, 1) + var/turf/east = at(8, 1) + TEST_ASSERT_EQUAL(stealth_muffled_dist(west, east, 0), 8, "An open line isn't its plain distance") + var/turf/wall_spot = at(4, 1) + wall_spot.ChangeTurf(/turf/closed/wall) + TEST_ASSERT_EQUAL(stealth_muffled_dist(west, east, 0), 11, "One wall didn't cost 3 tiles") + TEST_ASSERT_EQUAL(stealth_muffled_dist(west, east, 2), 9.5, "A loud sound didn't lose half as much to a wall") + TEST_ASSERT_EQUAL(stealth_muffled_dist(west, east, 1), INFINITY, "A line-of-sight sound went through a wall") + TEST_ASSERT_EQUAL(stealth_muffled_dist(wall_spot, west, 0), 4, "A sound on a wall is muffled on its west side") + TEST_ASSERT_EQUAL(stealth_muffled_dist(wall_spot, east, 0), 4, "A sound on a wall is muffled on its east side") + var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, at(6, 1)) + TEST_ASSERT(door.density, "The airlock isn't shut") + TEST_ASSERT_EQUAL(stealth_muffled_dist(west, east, 0), 13, "A wall and a closed airlock didn't cost 5 tiles") + qdel(door) + allocate(/obj/structure/window/fulltile, at(6, 1)) + TEST_ASSERT_EQUAL(stealth_muffled_dist(west, east, 0), 11, "A window muffled a sound") + for(var/dx in list(2, 3, 5)) + var/turf/more = at(dx, 1) + more.ChangeTurf(/turf/closed/wall) + TEST_ASSERT_EQUAL(stealth_muffled_dist(west, east, 2), INFINITY, "A sound was heard through four walls") + +/// Every kind has a profile; a gun is never heard further than tg plays its own sound; tiers and suppressors. +/datum/unit_test/voidcrew_stealth_live/hear/guns + +/datum/unit_test/voidcrew_stealth_live/hear/guns/Run() + TEST_ASSERT(hear_room(5, 3), "No room for the test") + for(var/kind in list("gun_quiet", "gun_pistol", "gun_smg", "gun_long", "impact", "blast", "glass", "thrown", "knock", "bash", "door", "thud", "voice", "scream", "shout")) + var/list/profile = stealth_noise_profile(kind) + TEST_ASSERT_EQUAL(length(profile), 3, "Noise kind [kind] has no profile") + TEST_ASSERT(profile[1] > 0 && profile[2] > 0 && isnum(profile[3]), "Noise kind [kind] has a bad profile") + var/turf/here = at(1, 1) + var/list/expected = list( + /obj/item/gun/ballistic/automatic/pistol = "gun_pistol", + /obj/item/gun/ballistic/revolver = "gun_pistol", + /obj/item/gun/energy/laser = "gun_pistol", + /obj/item/gun/ballistic/automatic/c20r = "gun_smg", + /obj/item/gun/ballistic/rifle/boltaction = "gun_long", + /obj/item/gun/ballistic/shotgun = "gun_long", + /obj/item/gun/ballistic/bow/longbow = "gun_quiet", + /obj/item/gun/ballistic/automatic/pistol/suppressed = "gun_quiet", + ) + for(var/gun_type in expected) + var/obj/item/gun/gun = allocate(gun_type, here) + var/list/noise = gun.stealth_shot_noise() + TEST_ASSERT_EQUAL(noise[1], expected[gun_type], "[gun_type] is the wrong loudness") + var/obj/item/gun/ballistic/automatic/pistol/pistol = allocate(/obj/item/gun/ballistic/automatic/pistol, here) + TEST_ASSERT_EQUAL(pistol.stealth_shot_noise()[2], 9, "A pistol's reach isn't 9") + pistol.fire_sound_volume = 10 + TEST_ASSERT(pistol.stealth_shot_noise()[2] < 9, "A quiet custom gun is heard as far as a loud one") + pistol.install_suppressor(new /obj/item/suppressor(pistol)) + var/list/quiet = pistol.stealth_shot_noise() + TEST_ASSERT_EQUAL(quiet[1], "gun_quiet", "A pistol with a suppressor fitted isn't quiet") + TEST_ASSERT_EQUAL(quiet[2], 4, "A suppressed shot's reach isn't 4") + +/// tg's footstep rules: running is heard, walking and every silencer are not. +/datum/unit_test/voidcrew_stealth_live/hear/footstep_reach + +/datum/unit_test/voidcrew_stealth_live/hear/footstep_reach/Run() + TEST_ASSERT(hear_room(6, 3), "No room for the test") + var/mob/living/carbon/human/runner = person_at(1, 1, FALSE) + TEST_ASSERT_EQUAL(stealth_footstep_reach(runner), 0, "A walking step is heard") + runner.toggle_move_intent() + TEST_ASSERT_EQUAL(stealth_footstep_reach(runner), 5, "A running step barefoot on plain floor doesn't carry 5") + runner.equip_to_slot_or_del(new /obj/item/clothing/shoes/sneakers/black(runner), ITEM_SLOT_FEET) + TEST_ASSERT_EQUAL(stealth_footstep_reach(runner), 5, "A running step in shoes on plain floor doesn't carry 5") + var/turf/plating = at(3, 1) + plating.ChangeTurf(/turf/open/floor/plating) + runner.forceMove(plating) + TEST_ASSERT(stealth_footstep_reach(runner) > 5, "Plating is no louder than floor") + runner.forceMove(at(1, 1)) + ADD_TRAIT(runner, TRAIT_LIGHT_STEP, TRAIT_SOURCE_UNIT_TESTS) + TEST_ASSERT_EQUAL(stealth_footstep_reach(runner), 3, "A light step doesn't carry 3") + REMOVE_TRAIT(runner, TRAIT_LIGHT_STEP, TRAIT_SOURCE_UNIT_TESTS) + ADD_TRAIT(runner, TRAIT_SILENT_FOOTSTEPS, TRAIT_SOURCE_UNIT_TESTS) + TEST_ASSERT_EQUAL(stealth_footstep_reach(runner), 0, "Silent footsteps are heard") + REMOVE_TRAIT(runner, TRAIT_SILENT_FOOTSTEPS, TRAIT_SOURCE_UNIT_TESTS) + runner.set_body_position(LYING_DOWN) + TEST_ASSERT_EQUAL(stealth_footstep_reach(runner), 2, "Crawling doesn't carry 2") + runner.set_body_position(STANDING_UP) + var/obj/structure/closet/locker = allocate(/obj/structure/closet, at(1, 1)) + runner.forceMove(locker) + TEST_ASSERT_EQUAL(stealth_footstep_reach(runner), 0, "Someone in a locker makes footsteps") + runner.forceMove(at(1, 1)) + // The real shoes. + qdel(runner.shoes) + runner.equip_to_slot_or_del(new /obj/item/clothing/shoes/laceup/winters_loafers(runner), ITEM_SLOT_FEET) + TEST_ASSERT_NOTNULL(runner.shoes, "The loafers didn't go on") + TEST_ASSERT_EQUAL(stealth_footstep_reach(runner), 0, "Running in Winter's loafers is heard") + +/// One guard goes, then two, then everyone; somewhere else, one again. No sound gives anyone a target. +/datum/unit_test/voidcrew_stealth_live/hear/dispatch + +/datum/unit_test/voidcrew_stealth_live/hear/dispatch/Run() + TEST_ASSERT(hear_room(30, 3), "No room for the test") + var/mob/living/carbon/human/npc/first = walker(5, 1) + var/mob/living/carbon/human/npc/second = walker(7, 1) + var/mob/living/carbon/human/npc/third = walker(9, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/first_ai = first.ai_controller + var/datum/ai_controller/basic_controller/human_npc/stealth/second_ai = second.ai_controller + var/datum/ai_controller/basic_controller/human_npc/stealth/third_ai = third.ai_controller + var/datum/stealth_group/group = first_ai.group + TEST_ASSERT_EQUAL(group, marker.group, "The guards didn't join the marker's group") + TEST_ASSERT_NOTNULL(third_ai.route_id, "A guard on a stop claimed no route") + var/turf/spot = at(4, 1) + TEST_ASSERT_EQUAL(stealth_noise(spot, "knock"), 3, "Three guards within 5 tiles didn't all hear a knock") + TEST_ASSERT_EQUAL(first_ai.stealth_state(), "suspicious", "The nearest guard didn't go to look") + TEST_ASSERT(first_ai.suspicion_heard, "A guard that heard something isn't marked as having heard it") + TEST_ASSERT_EQUAL(first_ai.investigate_turf, spot, "It isn't going to where the noise was") + TEST_ASSERT(!first_ai.blackboard["stealth_wary"], "A noise alone made a guard wary") + TEST_ASSERT_EQUAL(second_ai.stealth_state(), "unaware", "A second guard went to the first noise") + TEST_ASSERT(second_ai.glance_until > world.time && third_ai.glance_until > world.time, "The others didn't glance") + TEST_ASSERT_EQUAL(second.dir, WEST, "A glancing guard didn't turn to the noise") + // The same noise at once is the same noise. + TEST_ASSERT_EQUAL(stealth_noise(spot, "knock"), 0, "The same noise in the same moment was heard twice") + group.rewind(2 SECONDS) + stealth_noise(spot, "knock") + TEST_ASSERT_EQUAL(second_ai.stealth_state(), "suspicious", "Two guards didn't go to the second noise") + TEST_ASSERT_EQUAL(third_ai.stealth_state(), "unaware", "Three guards went to the second noise") + group.rewind(2 SECONDS) + stealth_noise(spot, "knock") + for(var/mob/living/guard as anything in list(first, second, third)) + TEST_ASSERT_EQUAL(state_of(guard), "searching", "Not every guard searched at the third noise") + TEST_ASSERT(third_ai.blackboard["stealth_wary"], "A guard searching for a noise isn't wary") + // Ten tiles off it is a new spot. + for(var/mob/living/guard as anything in list(first, second, third)) + settle(guard) + group.rewind(2 SECONDS) + TEST_ASSERT_EQUAL(stealth_noise(at(15, 1), "glass"), 2, "A smash 6 and 8 tiles off wasn't heard by two") + TEST_ASSERT_EQUAL(third_ai.stealth_state(), "suspicious", "Nobody went to a noise somewhere new") + TEST_ASSERT_EQUAL(second_ai.stealth_state(), "unaware", "Two went to the first noise at a new spot") + no_targets(list(first, second, third)) + +/// A sentry leaves its post only for a noise close by. +/datum/unit_test/voidcrew_stealth_live/hear/sentry_rule + +/datum/unit_test/voidcrew_stealth_live/hear/sentry_rule/Run() + TEST_ASSERT(hear_room(20, 3), "No room for the test") + var/mob/living/carbon/human/npc/post_guard = sentry(6, 1) + var/mob/living/carbon/human/npc/route_guard = walker(7, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/post_ai = post_guard.ai_controller + TEST_ASSERT_NULL(post_ai.route_id, "A guard with no stop near it claimed a route") + TEST_ASSERT_EQUAL(stealth_noise(at(0, 1), "glass"), 2, "Both guards should hear a smash 6 and 7 tiles off") + TEST_ASSERT_EQUAL(state_of(post_guard), "unaware", "A sentry left its post for a noise 6 tiles off") + TEST_ASSERT(post_ai.glance_until > world.time, "The sentry didn't glance") + TEST_ASSERT_EQUAL(state_of(route_guard), "suspicious", "The route guard didn't go in the sentry's place") + settle(post_guard) + settle(route_guard) + post_ai.group.forget_noises() + stealth_noise(at(3, 1), "glass") + TEST_ASSERT_EQUAL(state_of(post_guard), "suspicious", "A sentry didn't look at a noise 3 tiles off") + no_targets(list(post_guard, route_guard)) + +/// The guard who never came back is missed by the ones who watched it go, once it is overdue. +/datum/unit_test/voidcrew_stealth_live/hear/missing + +/datum/unit_test/voidcrew_stealth_live/hear/missing/Run() + TEST_ASSERT(hear_room(20, 3), "No room for the test") + var/mob/living/carbon/human/npc/first = walker(5, 1) + var/mob/living/carbon/human/npc/second = walker(7, 1) + var/mob/living/carbon/human/npc/third = walker(9, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/first_ai = first.ai_controller + var/datum/stealth_group/group = first_ai.group + var/turf/spot = at(4, 1) + stealth_noise(spot, "knock") + TEST_ASSERT_EQUAL(length(group.checks), 1, "No check was opened on the guard that went") + first.death() + group.check_overdue() + TEST_ASSERT_EQUAL(state_of(second), "unaware", "A guard knew its friend was down before it was overdue") + group.rewind(31 SECONDS) + group.check_overdue() + TEST_ASSERT_EQUAL(state_of(second), "searching", "Nobody came looking for the guard that never came back") + TEST_ASSERT_EQUAL(state_of(third), "searching", "Only one guard came looking") + TEST_ASSERT(group.lure_count(spot) >= 2, "The next noise at that spot won't bring everyone") + TEST_ASSERT_EQUAL(length(group.checks), 0, "A failed check wasn't closed") + // One that comes back closes its check. + settle(second) + settle(third) + group.forget_noises() + stealth_noise(spot, "knock") + TEST_ASSERT_EQUAL(state_of(second), "suspicious", "The next guard didn't go to look") + settle(second) + group.rewind(31 SECONDS) + group.check_overdue() + TEST_ASSERT_EQUAL(state_of(third), "unaware", "A guard was missed after it came back") + no_targets(list(second, third)) + +/// Gunfire: the nearest four that aren't sentries run to it, the rest hold, everyone is wary. After two alarms, two go. +/datum/unit_test/voidcrew_stealth_live/hear/alarm + +/datum/unit_test/voidcrew_stealth_live/hear/alarm/Run() + TEST_ASSERT(hear_room(30, 5), "No room for the test") + // The sentries first: a guard made beside a stop would claim its route. + var/list/sentries = list(sentry(4, 3), sentry(6, 3)) + var/list/route_guards = list() + for(var/dx in 3 to 8) + route_guards += walker(dx, 1) + var/mob/living/any_guard = route_guards[1] + var/datum/ai_controller/basic_controller/human_npc/stealth/any_ai = any_guard.ai_controller + var/datum/stealth_group/group = any_ai.group + var/turf/spot = at(1, 1) + TEST_ASSERT_EQUAL(stealth_noise(spot, "gun_long"), 8, "Eight guards within 14 tiles didn't all hear a rifle") + var/searching = 0 + for(var/mob/living/guard as anything in route_guards) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + TEST_ASSERT(guard_ai.blackboard["stealth_wary"], "A guard that heard gunfire isn't wary") + if(guard_ai.stealth_state() == "searching") + searching++ + TEST_ASSERT_EQUAL(searching, 4, "Not exactly four guards ran to the gunfire") + TEST_ASSERT_EQUAL(state_of(route_guards[1]), "searching", "The nearest guard didn't go") + TEST_ASSERT_EQUAL(state_of(route_guards[6]), "unaware", "The furthest guard didn't hold") + for(var/mob/living/guard as anything in sentries) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "unaware", "A sentry left its post for gunfire") + TEST_ASSERT(guard_ai.blackboard["stealth_wary"], "A sentry that heard gunfire isn't wary") + // A second, then a third: by then they have learned it may be a draw. + for(var/round_number in 2 to 3) + for(var/mob/living/guard as anything in route_guards + sentries) + settle(guard) + group.rewind(2 SECONDS) + stealth_noise(spot, "gun_long") + searching = 0 + for(var/mob/living/guard as anything in route_guards) + if(state_of(guard) == "searching") + searching++ + TEST_ASSERT_EQUAL(searching, 2, "After two alarms in memory, not exactly two guards went to the third") + no_targets(route_guards + sentries) + +/// Their own side: a guard doesn't hear itself, a friend's gunfire is a call for help, their own door is nothing, a friend's fall is heard. +/datum/unit_test/voidcrew_stealth_live/hear/allies + +/datum/unit_test/voidcrew_stealth_live/hear/allies/Run() + TEST_ASSERT(hear_room(20, 3), "No room for the test") + var/mob/living/carbon/human/npc/shooter = walker(3, 1) + var/mob/living/carbon/human/npc/friend = walker(8, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/friend_ai = friend.ai_controller + TEST_ASSERT_EQUAL(stealth_noise(shooter, "voice", null, shooter), 0, "A guard's whistle was heard by its friend") + TEST_ASSERT_EQUAL(stealth_noise(shooter, "gun_pistol", null, shooter), 1, "A guard's gunfire wasn't heard by its friend") + TEST_ASSERT_EQUAL(state_of(shooter), "unaware", "A guard reacted to its own shot") + TEST_ASSERT_EQUAL(friend_ai.stealth_state(), "searching", "A guard didn't run to a friend's gunfire") + TEST_ASSERT_EQUAL(friend_ai.search_center, get_turf(shooter), "It isn't looking where its friend fired from") + settle(friend) + var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, at(9, 1)) + TEST_ASSERT_EQUAL(stealth_noise(door, "door"), 0, "A door with one of their own beside it was heard") + door.forceMove(at(11, 1)) + TEST_ASSERT_EQUAL(stealth_noise(door, "door"), 1, "A door with nobody beside it wasn't heard") + TEST_ASSERT(friend_ai.glance_until > world.time, "A guard didn't glance at a door") + friend_ai.group.forget_noises() + TEST_ASSERT_EQUAL(stealth_noise(at(6, 1), "thud", null, shooter), 1, "A friend's fall wasn't heard") + TEST_ASSERT_EQUAL(friend_ai.stealth_state(), "suspicious", "A guard didn't look at a friend's fall") + no_targets(list(shooter, friend)) + +/// The deaf hear nothing; with no player on the level nothing is heard at all; ten shots in a tick are one noise. +/datum/unit_test/voidcrew_stealth_live/hear/deaf_dormant + +/datum/unit_test/voidcrew_stealth_live/hear/deaf_dormant/Run() + TEST_ASSERT(hear_room(20, 3), "No room for the test") + var/mob/living/carbon/human/npc/guard = walker(5, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + var/datum/stealth_group/group = guard_ai.group + var/turf/spot = at(3, 1) + ADD_TRAIT(guard, TRAIT_DEAF, TRAIT_SOURCE_UNIT_TESTS) + TEST_ASSERT_EQUAL(stealth_noise(spot, "gun_long"), 0, "A deaf guard heard a rifle") + REMOVE_TRAIT(guard, TRAIT_DEAF, TRAIT_SOURCE_UNIT_TESTS) + // Nobody on the level. + var/list/were_listed = players.Copy() + for(var/mob/living/person as anything in were_listed) + SSmobs.clients_by_zlevel[were_listed[person]] -= person + TEST_ASSERT_EQUAL(stealth_noise(spot, "gun_long"), 0, "A guard on an empty level heard a rifle") + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "unaware", "A noise on an empty level changed a guard") + for(var/mob/living/person as anything in were_listed) + SSmobs.clients_by_zlevel[were_listed[person]] += person + var/heard = 0 + for(var/shot in 1 to 10) + heard += stealth_noise(at(3 + (shot % 3), 1), "gun_long") + TEST_ASSERT_EQUAL(heard, 1, "Ten shots in one tick weren't one noise") + TEST_ASSERT_EQUAL(length(group.alarms), 1, "Ten shots in one tick were more than one alarm") + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "searching", "A guard didn't run to gunfire") + no_targets(list(guard)) + +/// A guard that sees someone shouts; a friend in earshot runs to the shouter and doesn't shout on. A muted guard calls nobody. +/datum/unit_test/voidcrew_stealth_live/hear/shout + +/datum/unit_test/voidcrew_stealth_live/hear/shout/Run() + TEST_ASSERT(hear_room(24, 3), "No room for the test") + var/mob/living/carbon/human/npc/spotter = walker(10, 1, EAST) + var/mob/living/carbon/human/npc/friend = walker(4, 1, WEST) + var/datum/ai_controller/basic_controller/human_npc/stealth/spotter_ai = spotter.ai_controller + var/datum/ai_controller/basic_controller/human_npc/stealth/friend_ai = friend.ai_controller + var/mob/living/carbon/human/intruder = person_at(12, 1, FALSE) + ADD_TRAIT(spotter, TRAIT_MUTE, TRAIT_SOURCE_UNIT_TESTS) + stealth_scans(spotter, 4) + TEST_ASSERT_EQUAL(spotter_ai.stealth_state(), "alert", "A guard isn't alert on someone 2 tiles in front of it") + TEST_ASSERT_EQUAL(friend_ai.stealth_state(), "unaware", "A muted guard called its friend") + REMOVE_TRAIT(spotter, TRAIT_MUTE, TRAIT_SOURCE_UNIT_TESTS) + settle(spotter) + spotter.setDir(EAST) + stealth_scans(spotter, 4) + TEST_ASSERT_EQUAL(spotter_ai.stealth_state(), "alert", "A guard isn't alert on someone 2 tiles in front of it") + TEST_ASSERT_EQUAL(friend_ai.stealth_state(), "searching", "A guard in earshot of a shout didn't come") + TEST_ASSERT_EQUAL(friend_ai.search_center, get_turf(spotter), "It ran to the intruder, not to the friend who shouted") + TEST_ASSERT(!friend_ai.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET), "A shout gave a guard a target") + TEST_ASSERT(!friend_ai.next_shout, "A guard that came for a shout shouted on") + TEST_ASSERT_EQUAL(spotter_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], intruder, "The guard that saw them has no target") + +/// A radio call takes three seconds, reaches the other lookout anywhere, and is stopped by a knockout, a jammer or an EMP. +/datum/unit_test/voidcrew_stealth_live/hear/radio + +/datum/unit_test/voidcrew_stealth_live/hear/radio/Run() + TEST_ASSERT(hear_room(40, 3), "No room for the test") + var/mob/living/carbon/human/npc/speaker = walker(3, 1, WEST, STEALTH_HEAR_LOOKOUT) + var/mob/living/carbon/human/npc/listener = walker(34, 1, EAST, STEALTH_HEAR_LOOKOUT) + var/mob/living/carbon/human/npc/neighbour = walker(31, 1, WEST) + var/datum/ai_controller/basic_controller/human_npc/stealth/speaker_ai = speaker.ai_controller + var/datum/ai_controller/basic_controller/human_npc/stealth/listener_ai = listener.ai_controller + var/datum/stealth_group/group = speaker_ai.group + TEST_ASSERT_NOTNULL(speaker.stealth_radio_ready(), "A lookout has no working radio") + TEST_ASSERT_NULL(neighbour.stealth_radio_ready(), "A plain guard has a radio") + var/turf/spot = at(5, 1) + // The call goes out after three seconds, not before. + TEST_ASSERT(speaker_ai.stealth_start_call(3, spot), "A lookout couldn't start a call") + stealth_scans(speaker, 1) + TEST_ASSERT_EQUAL(listener_ai.stealth_state(), "unaware", "A radio call arrived at once") + speaker_ai.stealth_rewind(3.1 SECONDS) + stealth_scans(speaker, 1) + TEST_ASSERT_NULL(speaker_ai.pending_call, "A call that was made is still pending") + TEST_ASSERT_EQUAL(listener_ai.stealth_state(), "searching", "The other lookout didn't answer a radio alarm") + TEST_ASSERT_EQUAL(state_of(neighbour), "searching", "The guard beside the lookout that answered didn't come") + // Knocked out inside the three seconds. + for(var/mob/living/guard as anything in list(speaker, listener, neighbour)) + settle(guard) + group.forget_noises() + speaker_ai.stealth_start_call(3, spot) + speaker.Unconscious(4 SECONDS) + TEST_ASSERT_NULL(speaker_ai.pending_call, "A knocked-out guard kept its call") + speaker.SetUnconscious(0) + speaker.update_stat() + settle(speaker) + TEST_ASSERT_EQUAL(listener_ai.stealth_state(), "unaware", "A call from a knocked-out guard arrived") + // A jammer beside the speaker. + var/obj/item/jammer/jammer = allocate(/obj/item/jammer, get_turf(speaker)) + GLOB.active_jammers |= jammer + TEST_ASSERT_NULL(speaker.stealth_radio_ready(), "A jammed lookout's radio works") + speaker_ai.stealth_start_call(3, spot) + speaker_ai.stealth_rewind(3.1 SECONDS) + stealth_scans(speaker, 1) + GLOB.active_jammers -= jammer + TEST_ASSERT_EQUAL(listener_ai.stealth_state(), "unaware", "A jammed call arrived") + TEST_ASSERT(speaker_ai.bark_ready_at["stealth_radio_dead"], "A lookout with a dead radio didn't say so") + // The receiver's earpiece hit by an EMP. + settle(speaker) + var/obj/item/radio/earpiece = listener.ears + earpiece.emp_act(EMP_HEAVY) + TEST_ASSERT_NULL(listener.stealth_radio_ready(), "An EMPed earpiece still works") + speaker_ai.stealth_start_call(3, spot) + speaker_ai.stealth_rewind(3.1 SECONDS) + stealth_scans(speaker, 1) + TEST_ASSERT_EQUAL(listener_ai.stealth_state(), "unaware", "A lookout with a dead earpiece got the call") + TEST_ASSERT_EQUAL(state_of(neighbour), "unaware", "The call reached a guard with no radio") + no_targets(list(speaker, listener, neighbour)) + +/// A lone lookout that reports in is missed by the other lookout; a guard with no radio that goes alone is missed by nobody. +/datum/unit_test/voidcrew_stealth_live/hear/report + +/datum/unit_test/voidcrew_stealth_live/hear/report/Run() + TEST_ASSERT(hear_room(40, 3), "No room for the test") + var/mob/living/carbon/human/npc/scout = walker(3, 1, WEST, STEALTH_HEAR_LOOKOUT) + var/mob/living/carbon/human/npc/listener = walker(34, 1, EAST, STEALTH_HEAR_LOOKOUT) + var/mob/living/carbon/human/npc/loner = walker(18, 1, WEST) + var/datum/ai_controller/basic_controller/human_npc/stealth/scout_ai = scout.ai_controller + var/datum/stealth_group/group = scout_ai.group + TEST_ASSERT_EQUAL(stealth_noise(at(5, 1), "knock"), 1, "Only the lookout should hear this knock") + TEST_ASSERT_NOTNULL(scout_ai.pending_call, "A lookout sent to look didn't reach for its radio") + scout_ai.stealth_rewind(3.1 SECONDS) + stealth_scans(scout, 1) + TEST_ASSERT_EQUAL(state_of(listener), "unaware", "A report moved someone") + scout.death() + group.rewind(31 SECONDS) + group.check_overdue() + TEST_ASSERT_EQUAL(state_of(listener), "searching", "A lookout that reported and never came back wasn't missed") + // No radio, nobody else in earshot: nobody knows it went. + settle(listener) + group.forget_noises() + TEST_ASSERT_EQUAL(stealth_noise(at(20, 1), "knock"), 1, "Only the plain guard should hear this knock") + TEST_ASSERT_EQUAL(state_of(loner), "suspicious", "The plain guard didn't go to look") + loner.death() + group.rewind(31 SECONDS) + group.check_overdue() + TEST_ASSERT_EQUAL(state_of(listener), "unaware", "A guard nobody saw leave was missed") + +/// The grey "?" of a heard noise: for players near with no fill of their own, never for a glance, gone when it gives up. +/datum/unit_test/voidcrew_stealth_live/hear/mark + +/datum/unit_test/voidcrew_stealth_live/hear/mark/Run() + TEST_ASSERT(hear_room(20, 3), "No room for the test") + var/mob/living/carbon/human/npc/first = walker(8, 1) + var/mob/living/carbon/human/npc/second = walker(10, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/first_ai = first.ai_controller + var/datum/ai_controller/basic_controller/human_npc/stealth/second_ai = second.ai_controller + var/mob/living/carbon/human/watcher = person_at(4, 1) + stealth_noise(at(7, 1), "knock") + TEST_ASSERT(first_ai.suspicion_heard, "The guard that went isn't marked as having heard") + TEST_ASSERT(stealth_meter_heard(first_ai.stealth_state(), first_ai.suspicion_heard), "A heard suspicion shows no mark") + TEST_ASSERT_NOTNULL(first_ai.meter, "No mark was built for a heard suspicion with nobody noticed") + TEST_ASSERT(watcher in first_ai.meter.heard_viewers(first_ai.notice_map), "A player near the guard doesn't get the heard mark") + first_ai.notice_map[REF(watcher)] = list(0.5, get_turf(watcher), world.time) + TEST_ASSERT(!(watcher in first_ai.meter.heard_viewers(first_ai.notice_map)), "A player with their own fill gets the heard mark too") + TEST_ASSERT(!second_ai.suspicion_heard, "A glance shows the heard mark") + first_ai.stealth_go_unaware() + TEST_ASSERT(!first_ai.suspicion_heard, "The heard mark outlived the suspicion") + TEST_ASSERT(!stealth_meter_heard(first_ai.stealth_state(), first_ai.suspicion_heard), "An unaware guard shows the heard mark") + +/// Deleting a guard with a call pending and a check open, then its group: nothing left behind. +/datum/unit_test/voidcrew_stealth_live/hear/cleanup + +/datum/unit_test/voidcrew_stealth_live/hear/cleanup/Run() + TEST_ASSERT(hear_room(20, 3), "No room for the test") + stop_at(5, 1, 1, 10 MINUTES) + stop_at(7, 1, 2, 10 MINUTES) + var/mob/living/carbon/human/npc/scout = new STEALTH_HEAR_LOOKOUT(at(5, 1)) + var/mob/living/carbon/human/npc/friend = new STEALTH_HEAR_GUARD(at(7, 1)) + var/datum/ai_controller/basic_controller/human_npc/stealth/scout_ai = wake(scout) + var/datum/ai_controller/basic_controller/human_npc/stealth/friend_ai = wake(friend) + scout_ai.stealth_setup() + friend_ai.stealth_setup() + var/datum/stealth_group/group = scout_ai.group + stealth_noise(at(4, 1), "knock") + TEST_ASSERT_NOTNULL(scout_ai.pending_call, "The lookout has no call pending") + TEST_ASSERT_EQUAL(length(group.checks), 1, "No check is open") + qdel(scout) + group.rewind(31 SECONDS) + group.check_overdue() + TEST_ASSERT_EQUAL(friend_ai.stealth_state(), "searching", "A deleted investigator wasn't missed") + qdel(friend) + QDEL_NULL(marker) + TEST_ASSERT(!(group in GLOB.stealth_groups), "A group with no marker and no guards is still registered") + TEST_ASSERT(QDELETED(group), "The group wasn't deleted") + // The explosion listener went with it. + SEND_GLOBAL_SIGNAL(COMSIG_GLOB_EXPLOSION, at(4, 1), 0, 0, 1) + +// ===== Through the real tg action ===== + +/// A real pistol shot sends a guard running; suppressed, it is unheard at 6 tiles and a curiosity at 3. +/datum/unit_test/voidcrew_stealth_live/hear/real_gunshot + +/datum/unit_test/voidcrew_stealth_live/hear/real_gunshot/Run() + TEST_ASSERT(hear_room(30, 3), "No room for the test") + var/mob/living/carbon/human/npc/guard = walker(3, 1, WEST) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + var/mob/living/carbon/human/shooter = person_at(10, 1, FALSE) + var/obj/item/gun/ballistic/automatic/pistol/pistol = allocate(/obj/item/gun/ballistic/automatic/pistol, get_turf(shooter)) + shooter.put_in_active_hand(pistol) + var/turf/far_wall = at(25, 1) + TEST_ASSERT(pistol.process_fire(far_wall, shooter), "The pistol didn't fire") + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "searching", "A guard 7 tiles behind a pistol shot didn't run to it") + TEST_ASSERT(get_dist(guard_ai.search_center, shooter) <= 1, "It isn't searching where the shot came from") + TEST_ASSERT(!guard_ai.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET), "A gunshot gave a guard a target") + // Suppressed, 6 tiles: nothing. + settle(guard) + guard_ai.group.forget_noises() + pistol.install_suppressor(new /obj/item/suppressor(pistol)) + shooter.forceMove(at(9, 1)) + // No time passes in this test: the gun's own delay between shots is cleared by hand. + pistol.reset_fire_cd() + TEST_ASSERT(pistol.process_fire(far_wall, shooter), "The suppressed pistol didn't fire") + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "unaware", "A guard heard a suppressed shot 6 tiles off") + // 3 tiles, in the open: it turns to look. + guard_ai.group.forget_noises() + shooter.forceMove(at(6, 1)) + pistol.reset_fire_cd() + TEST_ASSERT(pistol.process_fire(far_wall, shooter), "The suppressed pistol didn't fire again") + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "suspicious", "A guard 3 tiles from a suppressed shot didn't look") + TEST_ASSERT(guard_ai.suspicion_heard, "It isn't marked as having heard it") + TEST_ASSERT(!guard_ai.blackboard["stealth_wary"], "A suppressed shot made a guard wary") + +/// A real throw: the guard looks where the thing landed. A bottle smashes and carries further. +/datum/unit_test/voidcrew_stealth_live/hear/real_throw + +/datum/unit_test/voidcrew_stealth_live/hear/real_throw/Run() + TEST_ASSERT(hear_room(30, 7), "No room for the test") + ground() + var/mob/living/carbon/human/npc/guard = sentry(9, 5, EAST) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + var/mob/living/carbon/human/thrower = person_at(0, 2, FALSE) + var/obj/item/coin/iron/coin = allocate(/obj/item/coin/iron, get_turf(thrower)) + var/turf/landing = at(9, 2) + coin.throw_at(landing, 12, 2, thrower) + var/deadline = world.time + 10 SECONDS + while((coin.throwing || guard_ai.stealth_state() == "unaware") && world.time < deadline) + sleep(1) + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "suspicious", "A guard 3 tiles from a thrown coin's landing didn't look (it landed [get_dist(coin, guard)] tiles off)") + TEST_ASSERT(get_dist(guard_ai.investigate_turf, get_turf(coin)) <= 1, "It isn't looking where the coin landed") + TEST_ASSERT_EQUAL(guard_ai.last_heard[1], "thrown", "It heard something other than a landing") + // A bottle, 7 tiles from the guard: only breaking glass carries that far. + settle(guard) + guard_ai.group.forget_noises() + // On a route now: a sentry wouldn't leave its post for something this far off. + guard.forceMove(at(16, 2)) + stop_at(16, 2, 1, 10 MINUTES) + stealth_assign_points() + guard_ai.route_id = "a" + guard_ai.route_index = 1 + guard_ai.home_turf = at(16, 2) + guard.setDir(EAST) + var/obj/item/reagent_containers/cup/glass/bottle/beer/bottle = allocate(/obj/item/reagent_containers/cup/glass/bottle/beer, get_turf(thrower)) + TEST_ASSERT_EQUAL(bottle.stealth_throw_kind(), "glass", "A glass bottle doesn't smash as glass") + bottle.throw_at(landing, 12, 2, thrower) + deadline = world.time + 10 SECONDS + while(guard_ai.stealth_state() == "unaware" && world.time < deadline) + sleep(1) + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "suspicious", "A guard 7 tiles from a smashed bottle didn't look") + TEST_ASSERT_EQUAL(guard_ai.last_heard[1], "glass", "It heard something other than breaking glass") + +/// Knocking on a wall or a window, and a door that refuses: the guard on the far side looks at its own side of it. +/datum/unit_test/voidcrew_stealth_live/hear/real_knock + +/datum/unit_test/voidcrew_stealth_live/hear/real_knock/Run() + TEST_ASSERT(hear_room(20, 5), "No room for the test") + for(var/dy in 0 to 4) + var/turf/wall_spot = at(10, dy) + wall_spot.ChangeTurf(/turf/closed/wall) + var/mob/living/carbon/human/npc/guard = sentry(13, 2, EAST) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + var/mob/living/carbon/human/knocker = person_at(9, 2, FALSE) + var/turf/closed/wall/wall = at(10, 2) + wall.attack_hand(knocker, list()) + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "suspicious", "A guard 3 tiles behind a wall didn't hear a push on it") + TEST_ASSERT_EQUAL(guard_ai.investigate_turf, at(11, 2), "It isn't going to its own side of the wall") + // A window in the wall. + settle(guard) + guard_ai.group.forget_noises() + var/turf/pane_spot = at(10, 3) + pane_spot.ChangeTurf(/turf/open/floor/iron) + var/obj/structure/window/reinforced/fulltile/pane = allocate(/obj/structure/window/reinforced/fulltile, pane_spot) + knocker.forceMove(at(9, 3)) + knocker.set_combat_mode(FALSE) + pane.attack_hand(knocker, list()) + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "suspicious", "A guard didn't hear a knock on a window") + TEST_ASSERT_EQUAL(guard_ai.last_heard[1], "knock", "A knock with combat mode off wasn't a knock") + TEST_ASSERT_EQUAL(guard_ai.investigate_turf?.x, pane_spot.x + 1, "It isn't going to its own side of the window") + // A door that refuses. + settle(guard) + guard_ai.group.forget_noises() + var/turf/door_spot = at(10, 1) + door_spot.ChangeTurf(/turf/open/floor/iron) + var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, door_spot) + door.animation_effects(DOOR_DENY_ANIMATION) + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "suspicious", "A guard didn't hear a door refuse someone") + TEST_ASSERT_EQUAL(guard_ai.investigate_turf?.x, door_spot.x + 1, "It isn't going to its own side of the door") + +/// A body that drops is heard; one held when it drops is lowered without a sound. A whistle is heard; a nod is not. +/datum/unit_test/voidcrew_stealth_live/hear/real_fall_emote + +/datum/unit_test/voidcrew_stealth_live/hear/real_fall_emote/Run() + TEST_ASSERT(hear_room(20, 5), "No room for the test") + var/mob/living/carbon/human/npc/guard = sentry(10, 2, EAST) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + var/mob/living/carbon/human/holder = person_at(6, 2, FALSE) + var/mob/living/carbon/human/held = person_at(7, 2, FALSE) + ground() + holder.start_pulling(held) + holder.setGrabState(GRAB_AGGRESSIVE) + TEST_ASSERT_EQUAL(held.pulledby, holder, "The grab didn't take") + held.Knockdown(10 SECONDS) + TEST_ASSERT_EQUAL(held.body_position, LYING_DOWN, "The held one didn't go down") + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "unaware", "A body lowered by whoever held it was heard") + var/mob/living/carbon/human/dropped = person_at(7, 3, FALSE) + dropped.Knockdown(10 SECONDS) + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "suspicious", "A body dropping 3 tiles behind a guard wasn't heard") + TEST_ASSERT_EQUAL(guard_ai.last_heard[1], "thud", "It heard something other than a thud") + // Emotes. + settle(guard) + guard_ai.group.forget_noises() + guard.setDir(EAST) + var/mob/living/carbon/human/whistler = person_at(6, 4, FALSE) + whistler.emote("nod") + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "unaware", "A silent emote was heard") + whistler.emote("whistle") + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "suspicious", "A whistle 4 tiles off wasn't heard") + TEST_ASSERT_EQUAL(guard_ai.last_heard[1], "voice", "It heard something other than a voice") + +/// A real explosion 12 tiles off, the other side of a wall: the guard runs to it. +/datum/unit_test/voidcrew_stealth_live/hear/real_blast + +/datum/unit_test/voidcrew_stealth_live/hear/real_blast/Run() + TEST_ASSERT(hear_room(30, 5), "No room for the test") + for(var/dy in 0 to 4) + var/turf/wall_spot = at(10, dy) + wall_spot.ChangeTurf(/turf/closed/wall) + var/mob/living/carbon/human/npc/guard = walker(16, 2, EAST) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = guard.ai_controller + explosion(at(4, 2), devastation_range = 0, heavy_impact_range = 0, light_impact_range = 1) + var/deadline = world.time + 10 SECONDS + while(guard_ai.stealth_state() != "searching" && world.time < deadline) + sleep(1) + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "searching", "A guard 12 tiles from a blast behind a wall didn't run to it") + TEST_ASSERT_EQUAL(guard_ai.last_heard[1], "blast", "It heard something other than a blast") + TEST_ASSERT(!guard_ai.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET), "A blast gave a guard a target") + +// ===== With the real AI running ===== + +/// A bottle thrown off a guard's route: it walks over, finds nothing, and goes back to its round. +/datum/unit_test/voidcrew_stealth_live/hear/live_lure + +/datum/unit_test/voidcrew_stealth_live/hear/live_lure/Run() + TEST_ASSERT(make_room(34, 9), "No room for the test") + marker = allocate(/obj/effect/landmark/stealth_group, at(0, 8)) + // The player who keeps the level awake, walled off at the far end. + for(var/dy in 0 to 8) + var/turf/wall_spot = at(24, dy) + wall_spot.ChangeTurf(/turf/closed/wall) + person_at(30, 2) + // A wall hides the thrower from the route; the top two rows are open to throw along. + for(var/dy in 0 to 6) + var/turf/wall_spot = at(11, dy) + wall_spot.ChangeTurf(/turf/closed/wall) + ground() + stop_at(2, 2, 1) + stop_at(8, 2, 2) + var/mob/living/carbon/human/npc/guard = guard_at(2, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = wake(guard) + var/mob/living/carbon/human/thrower = person_at(16, 7, FALSE) + sleep(3 SECONDS) + TEST_ASSERT_EQUAL(guard_ai.route_id, "a", "The guard never claimed its route") + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "unaware", "A patrolling guard with nobody in sight isn't unaware") + var/turf/landing = at(5, 7) + var/obj/item/reagent_containers/cup/glass/bottle/beer/bottle = allocate(/obj/item/reagent_containers/cup/glass/bottle/beer, get_turf(thrower)) + bottle.throw_at(landing, 14, 2, thrower) + var/start_time = world.time + var/found = FALSE + var/nearest = INFINITY + var/deadline = world.time + 25 SECONDS + while(world.time < deadline) + nearest = min(nearest, get_dist(guard, landing)) + if(nearest <= 1) + found = TRUE + break + sleep(2) + TEST_ASSERT(found, "The guard never walked to the smashed bottle: it got within [nearest] tiles, state [guard_ai.stealth_state()], heard [guard_ai.last_heard ? guard_ai.last_heard[1] : "nothing"]") + var/back = FALSE + deadline = start_time + 60 SECONDS + while(world.time < deadline) + if(guard_ai.stealth_state() == "unaware" && (get_dist(guard, at(2, 2)) <= 1 || get_dist(guard, at(8, 2)) <= 1)) + back = TRUE + break + sleep(2) + TEST_ASSERT(back, "The guard didn't go back to its round: state [guard_ai.stealth_state()], [get_dist(guard, at(2, 2))] and [get_dist(guard, at(8, 2))] tiles from its stops") + TEST_ASSERT(!guard_ai.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET), "A smashed bottle gave a guard a target") + +/// Someone walking up behind a guard is not heard; the same person running makes it turn. +/datum/unit_test/voidcrew_stealth_live/hear/live_footsteps + +/datum/unit_test/voidcrew_stealth_live/hear/live_footsteps/Run() + TEST_ASSERT(make_room(20, 3), "No room for the test") + // The player first: a guard woken on an empty level stays asleep. + var/mob/living/carbon/human/runner = person_at(5, 1) + var/mob/living/carbon/human/npc/guard = guard_at(10, 1, EAST) + var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai = wake(guard) + sleep(2 SECONDS) + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "unaware", "A guard noticed someone standing 5 tiles behind it") + // Walking, to one tile behind it. + for(var/step_number in 1 to 4) + runner.Move(get_step(runner, EAST), EAST) + sleep(3) + TEST_ASSERT_EQUAL(get_dist(runner, guard), 1, "The walker didn't get behind the guard") + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "unaware", "A guard heard someone walk up behind it") + // Back, and now running. + runner.forceMove(at(5, 1)) + guard_ai.stealth_go_unaware() + guard_ai.stealth_clear_wary() + guard.setDir(EAST) + sleep(2 SECONDS) + TEST_ASSERT_EQUAL(guard_ai.stealth_state(), "unaware", "The guard didn't settle") + runner.toggle_move_intent() + TEST_ASSERT_EQUAL(runner.move_intent, MOVE_INTENT_RUN, "The runner isn't running") + var/heard = FALSE + for(var/step_number in 1 to 3) + runner.Move(get_step(runner, EAST), EAST) + for(var/tick in 1 to 6) + if(guard_ai.stealth_state() != "unaware") + heard = TRUE + break + sleep(1) + if(heard) + break + var/deadline = world.time + 3 SECONDS + while(!heard && world.time < deadline) + heard = guard_ai.stealth_state() != "unaware" + sleep(1) + TEST_ASSERT(heard, "A guard never heard someone run up behind it (they are [get_dist(runner, guard)] tiles off)") + TEST_ASSERT(get_dist(runner, guard) >= 2, "The runner was next to the guard before it turned") + TEST_ASSERT_EQUAL(guard.dir, WEST, "The guard didn't turn to the footsteps") + TEST_ASSERT(guard_ai.last_heard?[1] == "footsteps", "It turned for something other than footsteps") + +/// A lookout that sees someone reaches for its radio; stunned before the call goes out, the other lookout never hears of it. +/datum/unit_test/voidcrew_stealth_live/hear/live_call + +/datum/unit_test/voidcrew_stealth_live/hear/live_call/Run() + TEST_ASSERT(make_room(40, 3), "No room for the test") + marker = allocate(/obj/effect/landmark/stealth_group, at(0, 2)) + var/mob/living/carbon/human/npc/scout = allocate(STEALTH_HEAR_LOOKOUT, at(4, 1)) + scout.setDir(EAST) + scout.AddElement(/datum/element/forced_gravity, 1) + var/mob/living/carbon/human/npc/listener = allocate(STEALTH_HEAR_LOOKOUT, at(36, 1)) + listener.setDir(EAST) + listener.AddElement(/datum/element/forced_gravity, 1) + person_at(6, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/scout_ai = wake(scout) + var/datum/ai_controller/basic_controller/human_npc/stealth/listener_ai = wake(listener) + var/deadline = world.time + 15 SECONDS + while(isnull(scout_ai.pending_call) && world.time < deadline) + sleep(1) + TEST_ASSERT_EQUAL(scout_ai.stealth_state(), "alert", "A lookout isn't alert on someone 2 tiles in front of it") + TEST_ASSERT_NOTNULL(scout_ai.pending_call, "A lookout that saw someone didn't reach for its radio") + scout.Stun(8 SECONDS) + TEST_ASSERT_NULL(scout_ai.pending_call, "A stunned lookout kept its call") + var/found = FALSE + deadline = world.time + 6 SECONDS + while(world.time < deadline) + if(listener_ai.stealth_state() != "unaware") + found = TRUE + sleep(2) + TEST_ASSERT(!found, "The other lookout heard of a call that was never made") + +#undef STEALTH_HEAR_GUARD +#undef STEALTH_HEAR_LOOKOUT From c42118c2b216eac53903231bc30359b36ce2ab85 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:54:30 -0800 Subject: [PATCH 017/152] An admin watching a guard's sight sees one mark at a time --- voidcrew/modules/stealth/stealth_meter.dm | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/stealth/stealth_meter.dm b/voidcrew/modules/stealth/stealth_meter.dm index 07a5f796015..1c1cad1c745 100644 --- a/voidcrew/modules/stealth/stealth_meter.dm +++ b/voidcrew/modules/stealth/stealth_meter.dm @@ -138,8 +138,13 @@ if(top_state) for(var/datum/stealth_sight_overlay/overlay as anything in controller.watchers) var/client/admin = overlay.user_ref?.resolve() - if(isnull(admin) || (wanted[REF(admin.mob)] && wanted[REF(admin.mob)][1] != STEALTH_METER_HEARD_STATE)) + if(isnull(admin)) continue + var/list/own = wanted[REF(admin.mob)] + if(own && own[1] != STEALTH_METER_HEARD_STATE) + continue + // The fullest fill takes the place of the admin's empty heard "?": one glyph at a time. + wanted -= REF(admin.mob) wanted["watch:[admin.ckey]"] = list(top_state, admin) for(var/key in private_marks.Copy()) var/list/held = private_marks[key] From bd574cc36bb77d495fd99a10b69cde50d279a778 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:40:21 -0800 Subject: [PATCH 018/152] Scav guards carry seclites and see someone at their elbow in the dark Guards switch a belt seclite on in the dark and whenever they are on edge; an EMP cuts it out. A body found mid-search is called out, a quick volley from one spot counts as one noise, and sentries keep facing their post. --- tgstation.dme | 1 + voidcrew/_DEFINES/stealth.dm | 12 ++ voidcrew/_DEFINES/stealth_hearing.dm | 4 +- voidcrew/modules/stealth/scav_guards.dm | 5 + voidcrew/modules/stealth/stealth_ai.dm | 29 ++- voidcrew/modules/stealth/stealth_light.dm | 82 +++++++ voidcrew/modules/stealth/stealth_patrol.dm | 3 + .../modules/stealth/stealth_perception.dm | 9 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_stealth_hearing.dm | 8 +- .../unit_tests/voidcrew_stealth_light.dm | 204 ++++++++++++++++++ 11 files changed, 345 insertions(+), 13 deletions(-) create mode 100644 voidcrew/modules/stealth/stealth_light.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_stealth_light.dm diff --git a/tgstation.dme b/tgstation.dme index 25ed4aac37d..7cb1a1ac6c8 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7733,6 +7733,7 @@ #include "voidcrew\modules\stealth\stealth_admin_noise.dm" #include "voidcrew\modules\stealth\stealth_ai.dm" #include "voidcrew\modules\stealth\stealth_hearing.dm" +#include "voidcrew\modules\stealth\stealth_light.dm" #include "voidcrew\modules\stealth\stealth_meter.dm" #include "voidcrew\modules\stealth\stealth_noise.dm" #include "voidcrew\modules\stealth\stealth_noise_sources.dm" diff --git a/voidcrew/_DEFINES/stealth.dm b/voidcrew/_DEFINES/stealth.dm index 53bc8633070..fe6fb0255b3 100644 --- a/voidcrew/_DEFINES/stealth.dm +++ b/voidcrew/_DEFINES/stealth.dm @@ -53,6 +53,8 @@ #define STEALTH_DARK_LUMCOUNT 0.2 #define STEALTH_DARK_MULT 0.5 #define STEALTH_CLOSE_RANGE 2 +/// Elbow to elbow: someone this close in its cone is seen whatever the light (decision 31) +#define STEALTH_ADJACENT_RANGE 1 #define STEALTH_CONE_HALF_ANGLE 60 #define STEALTH_WARY_HALF_ANGLE 75 #define STEALTH_SEARCH_HALF_ANGLE 90 @@ -91,6 +93,8 @@ #define STEALTH_LOOK_TURN_CHANCE 50 #define STEALTH_LOOK_TURN_ARC 90 #define STEALTH_SENTRY_TURN_INTERVAL (8 SECONDS) +/// How long a sentry looks aside before it faces its post again +#define STEALTH_SENTRY_GLANCE_TIME (2 SECONDS) #define STEALTH_CHATTER_RANGE 3 #define STEALTH_CHATTER_COOLDOWN (45 SECONDS) #define STEALTH_PATROL_MAX_FAILS 3 @@ -127,4 +131,12 @@ #define STEALTH_METER_LYING_Z 29 /// The scav den's side +// ===== Flashlights (decision 31, stealth_light.dm) ===== +/// It keeps its light on this long after it last wanted it +#define STEALTH_LIGHT_HOLD (5 SECONDS) +/// A heavy EMP cuts a guard's seclite out for this long +#define STEALTH_LIGHT_EMP_HEAVY (40 SECONDS) +/// A light EMP cuts a guard's seclite out for this long +#define STEALTH_LIGHT_EMP_LIGHT (20 SECONDS) + #define FACTION_SCAV "scav" diff --git a/voidcrew/_DEFINES/stealth_hearing.dm b/voidcrew/_DEFINES/stealth_hearing.dm index 305e42b692a..4b8d3488bbe 100644 --- a/voidcrew/_DEFINES/stealth_hearing.dm +++ b/voidcrew/_DEFINES/stealth_hearing.dm @@ -118,8 +118,8 @@ /// One tile of error per this many tiles past exact #define STEALTH_NOISE_FUZZ_STEP 3 #define STEALTH_NOISE_FUZZ_MAX 3 -/// The same kind of noise this soon and this near an earlier one is the same noise -#define STEALTH_NOISE_COALESCE (0.5 SECONDS) +/// The same kind of noise this soon and this near an earlier one is the same noise (a volley is one event, decision 31) +#define STEALTH_NOISE_COALESCE (3 SECONDS) #define STEALTH_NOISE_COALESCE_RANGE 3 #define STEALTH_GLANCE_TIME (3 SECONDS) /// How long a guard told to hold faces the noise before it goes back to its round diff --git a/voidcrew/modules/stealth/scav_guards.dm b/voidcrew/modules/stealth/scav_guards.dm index 69f98b05c67..0cfc70dbe50 100644 --- a/voidcrew/modules/stealth/scav_guards.dm +++ b/voidcrew/modules/stealth/scav_guards.dm @@ -93,6 +93,11 @@ npc_voice = /datum/human_npc_voice/scav npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/sword +/// Every scav guard carries a seclite it switches on in the dark (stealth_light.dm, decision 31). +/mob/living/carbon/human/npc/ruin/trooper/scav/npc_dress(datum/human_npc_order/order) + . = ..() + stealth_give_light() + /mob/living/carbon/human/npc/ruin/trooper/scav/gunner npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pistol diff --git a/voidcrew/modules/stealth/stealth_ai.dm b/voidcrew/modules/stealth/stealth_ai.dm index 82377896aa5..37d7c475a88 100644 --- a/voidcrew/modules/stealth/stealth_ai.dm +++ b/voidcrew/modules/stealth/stealth_ai.dm @@ -154,6 +154,8 @@ var/next_scan = 0 /// world.time of its next look for fallen friends var/next_body_scan = 0 + /// Whether its search is over a fallen friend (it doesn't end it with "Nothing.") + var/search_for_body = FALSE /// Bark kind => world.time it may say that kind again var/list/bark_ready_at = list() /// Its stat at the last change, to tell recovery from crit @@ -304,6 +306,7 @@ if(!stealth_driven()) stealth_set_pace(new_state == STEALTH_ALERT || new_state == STEALTH_SEARCHING) human_npc_set_ai_movement(src, new_state == STEALTH_ALERT ? /datum/ai_movement/basic_avoidance : /datum/ai_movement/jps/stealth) + stealth_update_light() for(var/datum/stealth_sight_overlay/overlay as anything in watchers) overlay.refresh() stealth_meter_update() @@ -408,6 +411,7 @@ if(isnull(where)) where = get_turf(pawn) stealth_set_state(STEALTH_SEARCHING) + search_for_body = FALSE search_center = stealth_leash(where) || get_turf(pawn) search_queue = stealth_search_spots(search_center) search_looking = FALSE @@ -556,7 +560,9 @@ if(world.time >= calm_until) stealth_go_unaware() else if(state == STEALTH_SEARCHING && world.time - blackboard[BB_STEALTH_STATE_SINCE] >= STEALTH_SEARCH_TIME) - stealth_bark(STEALTH_BARK_GIVE_UP) + // "Nothing." over a dead friend reads wrong: a search over a body ends without a word. + if(!search_for_body) + stealth_bark(STEALTH_BARK_GIVE_UP) stealth_go_unaware() else if(state == STEALTH_SUSPICIOUS && world.time - last_gain >= STEALTH_SUSPICIOUS_TIMEOUT) stealth_bark(STEALTH_BARK_GIVE_UP) @@ -575,6 +581,7 @@ if(stealth_state() != STEALTH_ALERT && world.time >= next_body_scan) stealth_check_bodies() heard_now = null + stealth_update_light() for(var/datum/stealth_sight_overlay/overlay as anything in watchers) overlay.refresh() stealth_meter_update() @@ -675,7 +682,9 @@ if(boxed) rate = STEALTH_NOTICE_RATE else if(eyes && !stealth_is_cloaked(candidate) && stealth_in_cone(guard, where, half_angle)) - var/cap = stealth_range_onto(where) + // Elbow to elbow in front of it: seen whatever the light (decision 31). Behind or side-on, the + // close radius below still applies. + var/cap = dist <= STEALTH_ADJACENT_RANGE ? STEALTH_SIGHT_RANGE : stealth_range_onto(where) if(dist <= cap) rate = STEALTH_NOTICE_RATE * clamp((cap + 1 - dist) / cap, STEALTH_NOTICE_MIN_FALLOFF, 1) if(cap < STEALTH_SIGHT_RANGE) @@ -946,10 +955,17 @@ if(!in_sight || !can_see(guard, where, STEALTH_SIGHT_RANGE)) continue seen += key - if((key in group.reported_bodies) || stealth_state() == STEALTH_SEARCHING) + var/news = !(key in group.reported_bodies) + if(news) + group.reported_bodies += key + if(!news || stealth_state() == STEALTH_SEARCHING) stealth_go_searching(where) + search_for_body = TRUE + // Already searching, it still says what it found (decision 31: guards that searched beside a + // body without a word looked blind). + if(news) + stealth_bark(STEALTH_BARK_BODY) else - group.reported_bodies += key stealth_go_suspicious(where, STEALTH_GOTO_BODY) stealth_first_hand(where) return @@ -1234,6 +1250,7 @@ controller.stealth_drop_walk() if(controller.goto_purpose == STEALTH_GOTO_BODY) controller.stealth_go_searching(get_turf(guard)) + controller.search_for_body = TRUE return SUBTREE_RETURN_FINISH_PLANNING controller.stand_dir = stealth_facing(guard) controller.look_until = world.time + STEALTH_LOOK_AROUND_TIME @@ -1396,12 +1413,16 @@ if(state == STEALTH_SUSPICIOUS && !controller.investigated) guard.setDir(controller.stand_dir) return AI_BEHAVIOR_DELAY + // A sentry faces its post and only glances aside now and then (decision 31: it used to spend half its + // time, the first eight seconds included, facing sideways). var/sentry = isnull(controller.route_id) controller.next_turn = world.time + (sentry ? STEALTH_SENTRY_TURN_INTERVAL : STEALTH_LOOK_TURN_INTERVAL) if(guard.dir != controller.stand_dir) guard.setDir(controller.stand_dir) else if(sentry || prob(STEALTH_LOOK_TURN_CHANCE)) guard.setDir(turn(controller.stand_dir, pick(STEALTH_LOOK_TURN_ARC, -STEALTH_LOOK_TURN_ARC))) + if(sentry) + controller.next_turn = world.time + STEALTH_SENTRY_GLANCE_TIME return AI_BEHAVIOR_DELAY #undef STEALTH_HANDLED_SIGNALS diff --git a/voidcrew/modules/stealth/stealth_light.dm b/voidcrew/modules/stealth/stealth_light.dm new file mode 100644 index 00000000000..4974e177df8 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_light.dm @@ -0,0 +1,82 @@ +/** + * # Stealth guards: flashlights (owner decision 31) + * + * A scav guard carries a real seclite clipped to its belt (or its vest, or a pocket). tg's own overlay + * light draws it, cast the way the guard faces, and adds to the lit turfs' dynamic_lumcount, which the + * darkness rule (stealth_tile_is_dark()) already reads. So the light shows a guard what it lights and + * nothing more: there is no "sees in the dark" rule. + * + * The guard switches it with the light's own toggle (its click and all): on when it stands somewhere dark, + * always on while suspicious, alert or searching, off in lit places while unaware so a lit patrol looks + * the same as before. It keeps it on for STEALTH_LIGHT_HOLD after it last wanted it, so walking along the + * edge of a lit room doesn't flick it on and off. + * + * Beating it is tg's: you see the beam coming; an EMP or the saboteur gun cuts it out for a while (the + * scav seclite takes an EMP as tg's saboteur hit; a stock seclite ignores EMPs); a guard that is down, + * stunned, cuffed, asleep or dead doesn't touch it. A light that is gone from its body (deleted, burnt, + * forced off) is gone: the guard goes without. Like the rest of its kit it is locked gear (human NPC + * decision D3), so it can't be stripped or looted. + */ + +/// A guard's seclite: an EMP blinds it for a while, as tg's saboteur gun does. +/obj/item/flashlight/seclite/scav + +/obj/item/flashlight/seclite/scav/emp_act(severity) + . = ..() + if(. & EMP_PROTECT_SELF) + return + on_saboteur(null, severity == EMP_HEAVY ? STEALTH_LIGHT_EMP_HEAVY : STEALTH_LIGHT_EMP_LIGHT) + +/mob/living/carbon/human/npc + /// Weakref to the flashlight a stealth guard switches on in the dark, or null for none + var/datum/weakref/stealth_light + +/// Clips a seclite on: belt, vest, then a pocket. Returns it, or null if there was nowhere to put it. +/mob/living/carbon/human/npc/proc/stealth_give_light() + var/obj/item/flashlight/seclite/scav/light = new(src) + for(var/slot in list(ITEM_SLOT_BELT, ITEM_SLOT_SUITSTORE, ITEM_SLOT_LPOCKET, ITEM_SLOT_RPOCKET)) + if(equip_to_slot_if_possible(light, slot, disable_warning = TRUE)) + stealth_light = WEAKREF(light) + return light + qdel(light) + return null + +/// How much `light` adds to `tile` right now (tg's overlay light: a flat share on every turf it reaches). +/proc/stealth_light_share(obj/item/flashlight/light, turf/tile) + if(!light?.light_on) + return 0 + var/datum/component/overlay_lighting/glow = light.GetComponent(/datum/component/overlay_lighting) + if(isnull(glow) || !(tile in glow.affected_turfs)) + return 0 + return glow.lum_power + +/datum/ai_controller/basic_controller/human_npc/stealth + /// world.time until which it keeps its light on after it last wanted it + var/light_hold_until = 0 + +/// Its flashlight, if it still has it on its body; else null. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_light() + var/mob/living/carbon/human/npc/guard = pawn + if(!ishumannpc(guard)) + return null + var/obj/item/flashlight/light = guard.stealth_light?.resolve() + if(QDELETED(light) || light.loc != guard) + return null + return light + +/// Switches its light on or off for where it stands and what it is doing. Does nothing while it can't use +/// its hands, while a player has the body, or while the light is cut out. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_update_light() + var/mob/living/carbon/human/npc/guard = pawn + if(QDELETED(guard) || guard.stat != CONSCIOUS || stealth_driven() || stealth_captive() || HAS_TRAIT(guard, TRAIT_HANDS_BLOCKED)) + return + var/obj/item/flashlight/light = stealth_light() + if(isnull(light) || !COOLDOWN_FINISHED(light, disabled_time)) + return + var/wanted = stealth_state() != STEALTH_UNAWARE || stealth_tile_is_dark(get_turf(guard), light) + if(wanted) + light_hold_until = world.time + STEALTH_LIGHT_HOLD + else if(world.time < light_hold_until) + return + if(wanted != light.light_on) + light.toggle_light(guard) diff --git a/voidcrew/modules/stealth/stealth_patrol.dm b/voidcrew/modules/stealth/stealth_patrol.dm index bcf40901622..e0c195ca91b 100644 --- a/voidcrew/modules/stealth/stealth_patrol.dm +++ b/voidcrew/modules/stealth/stealth_patrol.dm @@ -224,6 +224,8 @@ GLOBAL_VAR_INIT(stealth_group_count, 0) home_turf = spawn_turf sentry_dir = stealth_facing(guard) stand_dir = sentry_dir + // A sentry's first look aside comes after a full turn at its post, not on its first step. + next_turn = world.time + STEALTH_SENTRY_TURN_INTERVAL group = stealth_group_for(spawn_turf) if(isnull(group)) group = new(spawn_turf) @@ -255,6 +257,7 @@ GLOBAL_VAR_INIT(stealth_group_count, 0) home_turf = where sentry_dir = stealth_facing(pawn) stand_dir = sentry_dir + next_turn = world.time + STEALTH_SENTRY_TURN_INTERVAL /// Its route's stops, or null. /datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_stops() diff --git a/voidcrew/modules/stealth/stealth_perception.dm b/voidcrew/modules/stealth/stealth_perception.dm index acc23fd0ab9..713b8af189a 100644 --- a/voidcrew/modules/stealth/stealth_perception.dm +++ b/voidcrew/modules/stealth/stealth_perception.dm @@ -57,18 +57,21 @@ GLOBAL_LIST_EMPTY(stealth_test_dark_turfs) * Whether `tile` is dark enough to hide in. A turf with no lighting object (planet ground lit only by its * area's ambient light) reads its area's base light, which get_lumcount() skips (it returns 1 there). * Same rule as the bounty AI's ai_tile_is_dark() (bounty_ai_meek.dm), which is a type proc. + * A flashlight's light counts like any other (tg adds it to dynamic_lumcount). `own_light` leaves out what + * that one light adds, so a guard can tell whether it needs its own (stealth_light.dm). */ -/proc/stealth_tile_is_dark(turf/tile) +/proc/stealth_tile_is_dark(turf/tile, obj/item/flashlight/own_light) if(!isturf(tile)) return FALSE #ifdef UNIT_TESTS if(tile in GLOB.stealth_test_dark_turfs) return GLOB.stealth_test_dark_turfs[tile] #endif + var/own = own_light ? stealth_light_share(own_light, tile) : 0 if(!tile.lighting_object) var/area/tile_area = tile.loc - return (tile_area.base_lighting_alpha / 255) + tile.dynamic_lumcount < STEALTH_DARK_LUMCOUNT - return tile.get_lumcount() < STEALTH_DARK_LUMCOUNT + return (tile_area.base_lighting_alpha / 255) + tile.dynamic_lumcount - own < STEALTH_DARK_LUMCOUNT + return tile.get_lumcount() - own < STEALTH_DARK_LUMCOUNT /** * What `who` is shut inside, as it stands on a turf: the box, locker, bag or car, or the outermost of them. diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index ccab4290d6d..db8e9a41f4e 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -236,6 +236,7 @@ #include "voidcrew_smart_locker.dm" #include "voidcrew_stealth.dm" #include "voidcrew_stealth_hearing.dm" +#include "voidcrew_stealth_light.dm" #include "voidcrew_survey_archive.dm" #include "voidcrew_survey_capabilities.dm" #include "voidcrew_survey_identity.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth_hearing.dm b/voidcrew/modules/unit_tests/voidcrew_stealth_hearing.dm index 641f866b05b..d5fc0bfddbe 100644 --- a/voidcrew/modules/unit_tests/voidcrew_stealth_hearing.dm +++ b/voidcrew/modules/unit_tests/voidcrew_stealth_hearing.dm @@ -186,11 +186,11 @@ TEST_ASSERT_EQUAL(second.dir, WEST, "A glancing guard didn't turn to the noise") // The same noise at once is the same noise. TEST_ASSERT_EQUAL(stealth_noise(spot, "knock"), 0, "The same noise in the same moment was heard twice") - group.rewind(2 SECONDS) + group.rewind(4 SECONDS) stealth_noise(spot, "knock") TEST_ASSERT_EQUAL(second_ai.stealth_state(), "suspicious", "Two guards didn't go to the second noise") TEST_ASSERT_EQUAL(third_ai.stealth_state(), "unaware", "Three guards went to the second noise") - group.rewind(2 SECONDS) + group.rewind(4 SECONDS) stealth_noise(spot, "knock") for(var/mob/living/guard as anything in list(first, second, third)) TEST_ASSERT_EQUAL(state_of(guard), "searching", "Not every guard searched at the third noise") @@ -198,7 +198,7 @@ // Ten tiles off it is a new spot. for(var/mob/living/guard as anything in list(first, second, third)) settle(guard) - group.rewind(2 SECONDS) + group.rewind(4 SECONDS) TEST_ASSERT_EQUAL(stealth_noise(at(15, 1), "glass"), 2, "A smash 6 and 8 tiles off wasn't heard by two") TEST_ASSERT_EQUAL(third_ai.stealth_state(), "suspicious", "Nobody went to a noise somewhere new") TEST_ASSERT_EQUAL(second_ai.stealth_state(), "unaware", "Two went to the first noise at a new spot") @@ -290,7 +290,7 @@ for(var/round_number in 2 to 3) for(var/mob/living/guard as anything in route_guards + sentries) settle(guard) - group.rewind(2 SECONDS) + group.rewind(4 SECONDS) stealth_noise(spot, "gun_long") searching = 0 for(var/mob/living/guard as anything in route_guards) diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth_light.dm b/voidcrew/modules/unit_tests/voidcrew_stealth_light.dm new file mode 100644 index 00000000000..4b1f5045302 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_stealth_light.dm @@ -0,0 +1,204 @@ +// Stealth guards: flashlights, elbow-to-elbow sight, volleys and sentry facing (owner decision 31, +// voidcrew/modules/stealth/stealth_light.dm). Values from voidcrew/_DEFINES are written out: STEALTH_LIGHT_HOLD +// 5 s, STEALTH_NOISE_COALESCE 3 s, STEALTH_SENTRY_TURN_INTERVAL 8 s, STEALTH_SENTRY_GLANCE_TIME 2 s. + +/// The guard switches its seclite on in the dark and while on edge, off in the light, and can't while it is cut out. +/datum/unit_test/voidcrew_stealth_light_switch + +/datum/unit_test/voidcrew_stealth_light_switch/Run() + var/datum/turf_reservation/block = stealth_block(9, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/obj/item/flashlight/light = stealth_ai.stealth_light() + TEST_ASSERT_NOTNULL(light, "A scav guard has no flashlight on its body") + TEST_ASSERT(!light.light_on, "A guard's light starts on") + var/turf/here = get_turf(guard) + stealth_ai.stealth_update_light() + TEST_ASSERT(!light.light_on, "An unaware guard in the light switched its light on") + GLOB.stealth_test_dark_turfs[here] = TRUE + stealth_ai.stealth_update_light() + TEST_ASSERT(light.light_on, "A guard standing in the dark didn't switch its light on") + // Back in the light it keeps it on a moment, then switches it off. + GLOB.stealth_test_dark_turfs[here] = FALSE + stealth_ai.stealth_update_light() + TEST_ASSERT(light.light_on, "A guard switched its light off the moment it stepped into the light") + stealth_ai.light_hold_until = 0 + stealth_ai.stealth_update_light() + TEST_ASSERT(!light.light_on, "An unaware guard in the light kept its light on") + // On edge, it is on even in the light. + stealth_ai.stealth_go_suspicious(stealth_at(block, 6, 2)) + TEST_ASSERT(light.light_on, "A suspicious guard's light isn't on") + stealth_ai.stealth_go_searching(stealth_at(block, 6, 2)) + TEST_ASSERT(light.light_on, "A searching guard's light isn't on") + stealth_ai.stealth_go_unaware() + stealth_ai.light_hold_until = 0 + stealth_ai.stealth_update_light() + TEST_ASSERT(!light.light_on, "A calm guard in the light kept its light on") + // An EMP cuts it out, and the guard can't switch it back on until it recovers. + GLOB.stealth_test_dark_turfs[here] = TRUE + stealth_ai.stealth_update_light() + TEST_ASSERT(light.light_on, "The light didn't come on in the dark") + guard.emp_act(EMP_LIGHT) + TEST_ASSERT(!light.light_on, "An EMP didn't cut the guard's light out") + stealth_ai.stealth_update_light() + TEST_ASSERT(!light.light_on, "A guard switched an EMP'd light straight back on") + light.disabled_time = 0 + stealth_ai.stealth_update_light() + TEST_ASSERT(light.light_on, "The light didn't come back on once the EMP wore off") + // Gone from its body, it goes without. + light.forceMove(stealth_at(block, 4, 4)) + TEST_ASSERT_NULL(stealth_ai.stealth_light(), "A guard still counts a light that left its body") + GLOB.stealth_test_dark_turfs[here] = FALSE + qdel(block) + +/// Real lighting: in a dark room the guard sees someone 5 tiles ahead only by its own light. +/datum/unit_test/voidcrew_stealth_light_sight + +/datum/unit_test/voidcrew_stealth_light_sight/Run() + var/datum/turf_reservation/block = stealth_block(12, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + stealth_unlist(block, 12, 5) + var/mob/living/carbon/human/person = stealth_person(block, 6, 2) + var/turf/spot = get_turf(person) + TEST_ASSERT(stealth_tile_is_dark(spot), "Reserved test space isn't dark, so the light can't be tested") + // Without a light: 5 tiles off in the dark is past sight. + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 1, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + qdel(stealth_ai.stealth_light()) + stealth_scans(guard, 5) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A guard with no light saw someone 5 tiles off in the dark") + qdel(guard) + // With one: it switches it on, the light reaches them, and it notices them. + guard = stealth_guard(block, 1, 2) + stealth_ai = stealth_ai(guard) + var/obj/item/flashlight/light = stealth_ai.stealth_light() + stealth_ai.stealth_update_light() + TEST_ASSERT(light.light_on, "A guard standing in the dark didn't switch its light on") + TEST_ASSERT(!stealth_tile_is_dark(spot), "The guard's light doesn't reach 5 tiles ahead") + TEST_ASSERT(stealth_tile_is_dark(get_turf(guard), light), "A guard counts its own light as the room's") + stealth_scans(guard, 5) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "suspicious", "A guard didn't notice someone its light was on") + qdel(guard) + qdel(block) + +/// Real lighting: a searching guard finds a body in the dark by its light. +/datum/unit_test/voidcrew_stealth_light_body + +/datum/unit_test/voidcrew_stealth_light_body/Run() + var/datum/turf_reservation/block = stealth_block(12, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + stealth_unlist(block, 12, 5) + allocate(/obj/effect/landmark/stealth_group, stealth_at(block, 7, 4)) + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 1, 2) + var/mob/living/carbon/human/npc/friend = stealth_guard(block, 6, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/datum/ai_controller/basic_controller/human_npc/stealth/friend_ai = stealth_ai(friend) + stealth_ai.stealth_setup() + friend_ai.stealth_setup() + qdel(friend_ai.stealth_light()) + friend.death() + TEST_ASSERT(stealth_tile_is_dark(get_turf(friend)), "Reserved test space isn't dark, so the light can't be tested") + // Its light cut out, it searches past the body 5 tiles off in the dark. + var/obj/item/flashlight/light = stealth_ai.stealth_light() + light.on_saboteur(null, 1 MINUTES) + stealth_ai.stealth_go_searching(get_turf(guard)) + stealth_ai.stealth_check_bodies() + TEST_ASSERT(!(REF(friend) in stealth_ai.blackboard["stealth_bodies_seen"]), "A guard saw a body 5 tiles off in the dark without a light") + // With it on, it finds it. + light.disabled_time = 0 + stealth_ai.stealth_update_light() + TEST_ASSERT(light.light_on, "A searching guard's light didn't come on") + stealth_ai.next_body_scan = 0 + stealth_ai.stealth_check_bodies() + TEST_ASSERT(REF(friend) in stealth_ai.blackboard["stealth_bodies_seen"], "A searching guard with its light on didn't find the body") + TEST_ASSERT(get_dist(stealth_ai.search_center, friend) <= 1, "The search didn't move to the body") + TEST_ASSERT(stealth_ai.search_for_body, "The search isn't marked as over a body") + qdel(block) + +/// Elbow to elbow: someone on the next tile in front is seen in the dark; behind or side-on is not. +/datum/unit_test/voidcrew_stealth_adjacent + +/datum/unit_test/voidcrew_stealth_adjacent/Run() + var/datum/turf_reservation/block = stealth_block(7, 7) + TEST_ASSERT_NOTNULL(block, "No room for the test") + for(var/dx in 0 to 6) + for(var/dy in 0 to 6) + GLOB.stealth_test_dark_turfs[stealth_at(block, dx, dy)] = TRUE + var/mob/living/carbon/human/person = stealth_person(block, 4, 3) + // offset from the guard => whether two scans make it suspicious + var/list/cases = list( + list(1, 0, TRUE), + list(-1, 0, FALSE), + list(0, 1, FALSE), + list(2, 0, FALSE), + ) + for(var/list/test_case as anything in cases) + person.forceMove(stealth_at(block, 3 + test_case[1], 3 + test_case[2])) + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 3, 3) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + stealth_scans(guard, 2) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state() == "suspicious", test_case[3], "Offset ([test_case[1]], [test_case[2]]) in the dark: state [stealth_ai.stealth_state()]") + qdel(guard) + for(var/dx in 0 to 6) + for(var/dy in 0 to 6) + GLOB.stealth_test_dark_turfs[stealth_at(block, dx, dy)] = FALSE + qdel(block) + +/// Takes a test block's turfs off the test light list, so they read the real light. +/datum/unit_test/proc/stealth_unlist(datum/turf_reservation/block, width, height) + for(var/dx in 0 to width - 1) + for(var/dy in 0 to height - 1) + GLOB.stealth_test_dark_turfs -= stealth_at(block, dx, dy) + +/// A quick volley from one spot is one noise; the same gun 3 s later is a new one; a loud gun still alarms. +/datum/unit_test/voidcrew_stealth_live/hear/volley + +/datum/unit_test/voidcrew_stealth_live/hear/volley/Run() + TEST_ASSERT(hear_room(20, 3), "No room for the test") + var/mob/living/carbon/human/npc/first = walker(5, 1) + var/mob/living/carbon/human/npc/second = walker(7, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/first_ai = first.ai_controller + var/datum/stealth_group/group = first_ai.group + var/turf/spot = at(4, 1) + TEST_ASSERT(stealth_noise(spot, "gun_quiet") > 0, "A suppressed shot a tile off wasn't heard") + for(var/i in 1 to 2) + group.rewind(0.5 SECONDS) + TEST_ASSERT_EQUAL(stealth_noise(spot, "gun_quiet"), 0, "Shot [i + 1] of a volley counted as a new noise") + TEST_ASSERT_EQUAL(group.lure_count(spot), 1, "A volley counted as more than one lure") + TEST_ASSERT_EQUAL(state_of(second), "unaware", "A volley sent a second guard") + // A loud gun from the same spot is still an alarm. + TEST_ASSERT(stealth_noise(spot, "gun_long") > 0, "A rifle after the volley wasn't heard") + TEST_ASSERT_EQUAL(state_of(second), "searching", "A rifle shot after a quiet volley didn't send the guards running") + // The same quiet gun later is a new noise. + settle(first) + settle(second) + group.rewind(3.5 SECONDS) + TEST_ASSERT(stealth_noise(spot, "gun_quiet") > 0, "A shot 3.5 s after the volley was merged into it") + no_targets(list(first, second)) + +/// A sentry faces the way it was placed, glancing aside only now and then. +/datum/unit_test/voidcrew_stealth_live/sentry_facing + +/datum/unit_test/voidcrew_stealth_live/sentry_facing/Run() + TEST_ASSERT(make_room(14, 5), "No room for the test") + person_at(13, 4) + var/mob/living/carbon/human/npc/guard = guard_at(1, 2, SOUTH) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = wake(guard) + var/deadline = world.time + 10 SECONDS + while(!stealth_ai.stealth_ready && world.time < deadline) + sleep(1) + TEST_ASSERT(stealth_ai.stealth_ready, "The sentry never planned") + sleep(1 SECONDS) + TEST_ASSERT_NULL(stealth_ai.route_id, "A guard with no stop near it claimed a route") + TEST_ASSERT_EQUAL(stealth_ai.sentry_dir, SOUTH, "A sentry placed facing south took [dir2text(stealth_ai.sentry_dir)] as its post") + TEST_ASSERT_EQUAL(guard.dir, SOUTH, "A sentry placed facing south faces [dir2text(guard.dir)] a second later") + var/total = 0 + var/count = 0 + for(var/i in 1 to 24) + sleep(0.5 SECONDS) + total++ + if(guard.dir == SOUTH) + count++ + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "The sentry was disturbed during the test") + TEST_ASSERT(count >= total * 0.7, "A sentry faced its post only [count] of [total] looks over 12 s") From 0237c3ad2045a818827b7eda8152229b6770828a Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:45:05 -0800 Subject: [PATCH 019/152] Guards react at once to someone right in front, stay blind behind in the dark, and stop flickering lights Point-blank sight within 2 tiles in the cone is an alert on that look, with a quick look between scans; 3-4 tiles fill three times faster. The close radius behind a guard needs light that isn't its own beam. A guard's own spot is judged without any guard seclites. No missing-friend line over a reported body. An AI-switched light is re-laid as a turn would. Diagonal AI steps cost what a player's do. The test room's east room gets a floor light in the middle. --- .../JungleRuins/jungle_stealth_test.dmm | 6 +- voidcrew/_DEFINES/stealth.dm | 5 + voidcrew/modules/stealth/stealth_ai.dm | 106 +++++++++- voidcrew/modules/stealth/stealth_light.dm | 37 +++- voidcrew/modules/stealth/stealth_noise.dm | 4 + .../modules/stealth/stealth_perception.dm | 15 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../modules/unit_tests/voidcrew_stealth.dm | 10 +- .../unit_tests/voidcrew_stealth_d32.dm | 189 ++++++++++++++++++ .../unit_tests/voidcrew_stealth_light.dm | 15 +- 10 files changed, 366 insertions(+), 22 deletions(-) create mode 100644 voidcrew/modules/unit_tests/voidcrew_stealth_d32.dm diff --git a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm index b802a996b66..e3d43e91751 100644 --- a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm +++ b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_stealth_test.dmm @@ -113,6 +113,10 @@ "x" = ( /turf/open/floor/iron, /area/ruin/powered) +"y" = ( +/obj/machinery/light/floor, +/turf/open/floor/iron, +/area/ruin/powered) "z" = ( /obj/structure/table, /turf/open/floor/iron, @@ -678,7 +682,7 @@ x x x x -x +y x x x diff --git a/voidcrew/_DEFINES/stealth.dm b/voidcrew/_DEFINES/stealth.dm index fe6fb0255b3..6b6431e79c8 100644 --- a/voidcrew/_DEFINES/stealth.dm +++ b/voidcrew/_DEFINES/stealth.dm @@ -55,6 +55,11 @@ #define STEALTH_CLOSE_RANGE 2 /// Elbow to elbow: someone this close in its cone is seen whatever the light (decision 31) #define STEALTH_ADJACENT_RANGE 1 +/// Point-blank (decision 32): someone it can see in its cone this close is an alert on that look +#define STEALTH_POINT_BLANK_RANGE 2 +/// Near (decision 32): in its cone up to this far, notice fills STEALTH_NEAR_MULT times faster +#define STEALTH_NEAR_RANGE 4 +#define STEALTH_NEAR_MULT 3 #define STEALTH_CONE_HALF_ANGLE 60 #define STEALTH_WARY_HALF_ANGLE 75 #define STEALTH_SEARCH_HALF_ANGLE 90 diff --git a/voidcrew/modules/stealth/stealth_ai.dm b/voidcrew/modules/stealth/stealth_ai.dm index 37d7c475a88..6db4c27db10 100644 --- a/voidcrew/modules/stealth/stealth_ai.dm +++ b/voidcrew/modules/stealth/stealth_ai.dm @@ -152,6 +152,8 @@ var/next_switch_at = 0 /// world.time of its next scan var/next_scan = 0 + /// The turf its last AI step ended on, to tell a diagonal step (stealth_step_cost()) + var/turf/last_step_turf /// world.time of its next look for fallen friends var/next_body_scan = 0 /// Whether its search is over a fallen friend (it doesn't end it with "Nothing.") @@ -305,7 +307,7 @@ stealth_drop_walk() if(!stealth_driven()) stealth_set_pace(new_state == STEALTH_ALERT || new_state == STEALTH_SEARCHING) - human_npc_set_ai_movement(src, new_state == STEALTH_ALERT ? /datum/ai_movement/basic_avoidance : /datum/ai_movement/jps/stealth) + human_npc_set_ai_movement(src, new_state == STEALTH_ALERT ? /datum/ai_movement/basic_avoidance/stealth : /datum/ai_movement/jps/stealth) stealth_update_light() for(var/datum/stealth_sight_overlay/overlay as anything in watchers) overlay.refresh() @@ -636,6 +638,11 @@ var/list/was_hidden = hidden_spots var/list/now_hidden var/judged = 0 + // Someone it plainly sees within STEALTH_POINT_BLANK_RANGE: an alert on this look (decision 32). Nearest first. + var/mob/living/point_blank + // The close radius out of its cone works by the light that is there, not by its own beam (decision 32). + var/obj/item/flashlight/own_light = stealth_light() + var/night_vision = HAS_TRAIT(guard, TRAIT_NIGHT_VISION) for(var/mob/living/candidate as anything in stealth_candidates(guard, HUMAN_NPC_SIGHT_RANGE)) // Only those it could perceive count toward the cap, so bystanders it can't see never crowd a player out. if(judged >= STEALTH_MAX_CANDIDATES) @@ -679,6 +686,7 @@ continue judged++ var/rate = 0 + var/plain_sight = FALSE if(boxed) rate = STEALTH_NOTICE_RATE else if(eyes && !stealth_is_cloaked(candidate) && stealth_in_cone(guard, where, half_angle)) @@ -689,8 +697,13 @@ rate = STEALTH_NOTICE_RATE * clamp((cap + 1 - dist) / cap, STEALTH_NOTICE_MIN_FALLOFF, 1) if(cap < STEALTH_SIGHT_RANGE) rate *= STEALTH_DARK_MULT - if(!rate && dist <= STEALTH_CLOSE_RANGE) - // At its shoulder: light doesn't matter (abuse P7). + // Close in front of it, seen plainly: no slow double take (decision 32). + plain_sight = cap == STEALTH_SIGHT_RANGE && dist <= STEALTH_POINT_BLANK_RANGE + if(dist <= STEALTH_NEAR_RANGE) + rate *= STEALTH_NEAR_MULT + if(!rate && dist <= STEALTH_CLOSE_RANGE && (night_vision || !stealth_tile_is_dark(where, own_light))) + // At its shoulder, in light that is there anyway (abuse P7). Its own beam points ahead, so it + // doesn't count here: someone standing still right behind it in the dark is not seen (decision 32). rate = STEALTH_BEHIND_RATE if(!rate) continue @@ -712,6 +725,10 @@ rate *= STEALTH_WARY_MULT wary_at = world.time entry[STEALTH_ENTRY_NOTICE] = min(entry[STEALTH_ENTRY_NOTICE] + rate * scan_seconds, STEALTH_NOTICE_MAX) + if(plain_sight) + entry[STEALTH_ENTRY_NOTICE] = STEALTH_NOTICE_MAX + if(isnull(point_blank)) + point_blank = candidate entry[STEALTH_ENTRY_TURF] = where entry[STEALTH_ENTRY_SEEN_AT] = world.time perceived[key] = candidate @@ -744,6 +761,9 @@ if(alert) stealth_weigh_switch(perceived, strategy) return + if(point_blank) + stealth_spot_point_blank(point_blank) + return // Whoever it sees now decides what it does; a higher entry it can't see no longer speaks for them. var/judged_key = seen_key || best_key if(isnull(judged_key)) @@ -791,6 +811,50 @@ if(calm_until || (new_center && get_dist(new_center, search_center) > 1)) stealth_go_searching(best_turf) +/// Someone it plainly sees right in front of it: it turns on them and fights now, no double take (decision 32). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_spot_point_blank(mob/living/who) + var/mob/living/guard = pawn + stealth_go_alert(who) + if(stealth_state() == STEALTH_ALERT && !QDELETED(guard) && !QDELETED(who)) + guard.face_atom(who) + +/** + * Between scans (planning runs every half second, the scan once a second): someone it plainly sees within + * STEALTH_POINT_BLANK_RANGE is an alert now, not at the next scan. The same rules as the point-blank part of + * stealth_look(): eyes open, not cloaked, on a turf of their own, in its cone, and lit or at its elbow. + * One spatial grid lookup of two tiles; nothing else. Returns TRUE if it went alert. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_quick_look() + var/mob/living/carbon/human/guard = pawn + if(QDELETED(guard) || guard.stat != CONSCIOUS || guard.client || !isturf(guard.loc) || stealth_state() == STEALTH_ALERT) + return FALSE + if(!stealth_eyes_open()) + return FALSE + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) + if(isnull(strategy)) + return FALSE + var/half_angle = stealth_half_angle() + for(var/mob/living/candidate as anything in stealth_candidates(guard, STEALTH_POINT_BLANK_RANGE)) + var/turf/where = candidate.loc + if(!isturf(where) || stealth_is_cloaked(candidate) || !stealth_in_cone(guard, where, half_angle)) + continue + var/dist = get_dist(guard, where) + if(dist > STEALTH_ADJACENT_RANGE && stealth_range_onto(where) < STEALTH_SIGHT_RANGE) + continue + if(!strategy.can_attack(guard, candidate, STEALTH_SIGHT_RANGE)) + continue + var/key = REF(candidate) + var/list/entry = notice_map[key] + if(isnull(entry)) + entry = list(0, null, 0) + notice_map[key] = entry + entry[STEALTH_ENTRY_NOTICE] = STEALTH_NOTICE_MAX + entry[STEALTH_ENTRY_TURF] = where + entry[STEALTH_ENTRY_SEEN_AT] = world.time + stealth_spot_point_blank(candidate) + return TRUE + return FALSE + /** * Alert: whether to turn on someone else it has noticed. They must be at alert-level notice, in reach, and * either clearly nearer than its target or in view while the target is not. Never more than once per @@ -1210,6 +1274,35 @@ /datum/ai_movement/jps/stealth maximum_length = STEALTH_PATROL_MAX_PATH +/datum/ai_movement/jps/stealth/post_move(datum/move_loop/source, succeeded) + . = ..() + stealth_step_cost(source) + +/// tg's plain chase movement, with a diagonal step costing what it costs a player. +/datum/ai_movement/basic_avoidance/stealth + +/datum/ai_movement/basic_avoidance/stealth/post_move(datum/move_loop/source, succeeded) + . = ..() + stealth_step_cost(source) + +/** + * A player's diagonal step waits sqrt(2) times their move delay (tg's client Move()); tg's AI move loops wait + * the plain delay whatever the step, so a guard cutting corners outran a player at the same run speed + * (decision 32, fix 7). After a diagonal step, the loop waits as long as a player would. + */ +/proc/stealth_step_cost(datum/move_loop/source) + var/datum/ai_controller/basic_controller/human_npc/stealth/controller = source.extra_info + var/atom/movable/moving = source.moving + if(!istype(controller) || QDELETED(moving)) + return + var/turf/now = get_turf(moving) + var/turf/before = controller.last_step_turf + controller.last_step_turf = now + if(isnull(before) || isnull(now) || before == now || before.z != now.z || get_dist(before, now) != 1) + return + if(before.x != now.x && before.y != now.y) + source.delay = controller.movement_delay * sqrt(2) + /** * Runs the scan once a second, straight from planning: it never waits in the behaviour queue, where a walk * or tg's idle mode could starve it or be starved by it. First in the list, and never ends planning. With @@ -1224,8 +1317,13 @@ QDEL_NULL(controller.meter) return controller.stealth_setup() - if(world.time >= controller.next_scan && !controller.blackboard_key_exists(BB_HUMAN_NPC_EXIT)) + if(controller.blackboard_key_exists(BB_HUMAN_NPC_EXIT)) + return + if(world.time >= controller.next_scan) controller.stealth_scan_once() + else + // Someone stepping in front of it doesn't wait up to a second for the next scan (decision 32). + controller.stealth_quick_look() /// Suspicious: stare, walk over, look round. Searching: sweep the spots. Ends planning while it acts. /datum/ai_planning_subtree/stealth_react diff --git a/voidcrew/modules/stealth/stealth_light.dm b/voidcrew/modules/stealth/stealth_light.dm index 4974e177df8..58171b43193 100644 --- a/voidcrew/modules/stealth/stealth_light.dm +++ b/voidcrew/modules/stealth/stealth_light.dm @@ -18,9 +18,20 @@ * decision D3), so it can't be stripped or looted. */ +/// Every guard's seclite, so a guard can leave them all out when it judges how dark its spot is +GLOBAL_LIST_EMPTY(stealth_guard_lights) + /// A guard's seclite: an EMP blinds it for a while, as tg's saboteur gun does. /obj/item/flashlight/seclite/scav +/obj/item/flashlight/seclite/scav/Initialize(mapload) + . = ..() + GLOB.stealth_guard_lights += src + +/obj/item/flashlight/seclite/scav/Destroy() + GLOB.stealth_guard_lights -= src + return ..() + /obj/item/flashlight/seclite/scav/emp_act(severity) . = ..() if(. & EMP_PROTECT_SELF) @@ -73,10 +84,34 @@ var/obj/item/flashlight/light = stealth_light() if(isnull(light) || !COOLDOWN_FINISHED(light, disabled_time)) return - var/wanted = stealth_state() != STEALTH_UNAWARE || stealth_tile_is_dark(get_turf(guard), light) + // Its own spot judged by the room's light alone: a friend's passing beam doesn't switch it off (decision 32). + var/wanted = stealth_state() != STEALTH_UNAWARE || stealth_tile_is_dark(get_turf(guard), light, no_guard_lights = TRUE) if(wanted) light_hold_until = world.time + STEALTH_LIGHT_HOLD else if(world.time < light_hold_until) return if(wanted != light.light_on) light.toggle_light(guard) + if(light.light_on) + stealth_redraw_light(light) + +/** + * After switching it on: has tg's light lay its beam out again, as it does when the guard turns. The fl_sentry + * clip (decision 32) caught a light that read on but drew nothing until the guard next turned; turning re-runs + * this same update. Done now and again a tick later, so a facing change in the same tick can't race it. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_redraw_light(obj/item/flashlight/light) + stealth_relay_light(light) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(stealth_relay_light), light), 1) + +/// Re-lays an overlay light that is on: the holder check, then the beam and the lit turfs, as a turn would. +/proc/stealth_relay_light(obj/item/flashlight/light) + if(QDELETED(light) || !light.light_on) + return + var/datum/component/overlay_lighting/glow = light.GetComponent(/datum/component/overlay_lighting) + // (1<<0) is tg's LIGHTING_ON, which overlay_lighting.dm undefines at its end. + if(isnull(glow) || !(glow.overlay_lighting_flags & (1<<0))) + return + glow.recheck_holder() + if(glow.current_holder) + glow.make_luminosity_update() diff --git a/voidcrew/modules/stealth/stealth_noise.dm b/voidcrew/modules/stealth/stealth_noise.dm index 1ff6aabe530..99e25ca69ec 100644 --- a/voidcrew/modules/stealth/stealth_noise.dm +++ b/voidcrew/modules/stealth/stealth_noise.dm @@ -445,6 +445,10 @@ var/mob/living/carbon/human/npc/gone = check.who?.resolve() var/datum/ai_controller/basic_controller/human_npc/stealth/gone_ai = gone?.ai_controller var/down = QDELETED(gone) || !istype(gone_ai) || gone.stat != CONSCIOUS || HAS_TRAIT(gone, TRAIT_RESTRAINED) + // Already found and called out as a body: nobody asks where it got to (decision 32). + if(down && !QDELETED(gone) && (REF(gone) in reported_bodies)) + checks -= check + continue if(!down) var/state = gone_ai.stealth_state() if(state == STEALTH_UNAWARE || state == STEALTH_ALERT) diff --git a/voidcrew/modules/stealth/stealth_perception.dm b/voidcrew/modules/stealth/stealth_perception.dm index 713b8af189a..65d563afc90 100644 --- a/voidcrew/modules/stealth/stealth_perception.dm +++ b/voidcrew/modules/stealth/stealth_perception.dm @@ -58,16 +58,25 @@ GLOBAL_LIST_EMPTY(stealth_test_dark_turfs) * area's ambient light) reads its area's base light, which get_lumcount() skips (it returns 1 there). * Same rule as the bounty AI's ai_tile_is_dark() (bounty_ai_meek.dm), which is a type proc. * A flashlight's light counts like any other (tg adds it to dynamic_lumcount). `own_light` leaves out what - * that one light adds, so a guard can tell whether it needs its own (stealth_light.dm). + * that one light adds: a guard's own beam never lights the tile behind it (decision 32). `no_guard_lights` + * leaves out every guard's seclite, so a guard deciding whether its spot needs a light judges by the room's + * own light, not by a friend walking past with one (decision 32: no flicker when guards meet). */ -/proc/stealth_tile_is_dark(turf/tile, obj/item/flashlight/own_light) +/proc/stealth_tile_is_dark(turf/tile, obj/item/flashlight/own_light, no_guard_lights = FALSE) if(!isturf(tile)) return FALSE #ifdef UNIT_TESTS if(tile in GLOB.stealth_test_dark_turfs) return GLOB.stealth_test_dark_turfs[tile] #endif - var/own = own_light ? stealth_light_share(own_light, tile) : 0 + var/own = 0 + if(no_guard_lights) + for(var/obj/item/flashlight/guard_light as anything in GLOB.stealth_guard_lights) + own += stealth_light_share(guard_light, tile) + if(own_light && !(own_light in GLOB.stealth_guard_lights)) + own += stealth_light_share(own_light, tile) + else if(own_light) + own = stealth_light_share(own_light, tile) if(!tile.lighting_object) var/area/tile_area = tile.loc return (tile_area.base_lighting_alpha / 255) + tile.dynamic_lumcount - own < STEALTH_DARK_LUMCOUNT diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index db8e9a41f4e..4382f84b5fc 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -235,6 +235,7 @@ #include "voidcrew_slime_extract_piles.dm" #include "voidcrew_smart_locker.dm" #include "voidcrew_stealth.dm" +#include "voidcrew_stealth_d32.dm" #include "voidcrew_stealth_hearing.dm" #include "voidcrew_stealth_light.dm" #include "voidcrew_survey_archive.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth.dm b/voidcrew/modules/unit_tests/voidcrew_stealth.dm index 4b5dd0f34c6..3f6ffd357a6 100644 --- a/voidcrew/modules/unit_tests/voidcrew_stealth.dm +++ b/voidcrew/modules/unit_tests/voidcrew_stealth.dm @@ -96,14 +96,12 @@ stealth_scans(guard, 10) TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A guard noticed someone standing 3 tiles behind it") qdel(guard) - // In front, 2 tiles. + // In front, 2 tiles, lit: an alert on the first look (decision 32). guard = stealth_guard(block, 6, 2) stealth_ai = stealth_ai(guard) person.forceMove(stealth_at(block, 8, 2)) - stealth_scans(guard, 2) - TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "suspicious", "A guard wasn't suspicious of someone 2 tiles in front after two scans") - stealth_scans(guard, 2) - TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard wasn't alert on someone 2 tiles in front after four scans") + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard wasn't alert on someone 2 tiles in front after one scan") TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], person, "The alert guard has no target") qdel(guard) // In front, 6 tiles, dark. @@ -508,7 +506,7 @@ var/mob/living/basic/mouse/mouse = allocate(/mob/living/basic/mouse, stealth_at(block, 3, 2)) mouse.faction = list("scav") // FACTION_SCAV stealth_person(block, 5, 2) - stealth_scans(guard, 3) + stealth_scans(guard, 1) TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "suspicious", "A crowd of its own side hid someone 3 tiles in front of a guard") qdel(block) diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth_d32.dm b/voidcrew/modules/unit_tests/voidcrew_stealth_d32.dm new file mode 100644 index 00000000000..a3a30f04c60 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_stealth_d32.dm @@ -0,0 +1,189 @@ +// Stealth guards, owner decision 32 (voidcrew/modules/stealth/): point-blank sight, behind in the dark, the overdue +// line over a body already reported, guards' lights ignoring each other, and a running guard's pace. Values from +// voidcrew/_DEFINES are written out: STEALTH_POINT_BLANK_RANGE 2, STEALTH_NEAR_RANGE 4, STEALTH_NEAR_MULT 3, +// STEALTH_ALERT_AT 2.5, STEALTH_LIGHT_HOLD 5 s. States are written as text: "unaware", "suspicious", "alert". + +/// Point-blank: seen plainly within 2 tiles in front is an alert on that look; 3-4 tiles fill fast; further is as before. +/datum/unit_test/voidcrew_stealth_point_blank + +/datum/unit_test/voidcrew_stealth_point_blank/Run() + var/datum/turf_reservation/block = stealth_block(12, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/person = stealth_person(block, 0, 0) + // Lit, one and two tiles ahead (and two ahead, one aside): alert on the first scan, after them. + for(var/list/offset as anything in list(list(1, 0), list(2, 0), list(2, 1))) + person.forceMove(stealth_at(block, 2 + offset[1], 2 + offset[2])) + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "Someone at offset ([offset[1]], [offset[2]]) in the light wasn't an alert on the first look") + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], person, "A point-blank alert has no target") + qdel(guard) + // Three ahead: suspicious on the first look, alert on the second. + person.forceMove(stealth_at(block, 5, 2)) + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 2, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "suspicious", "Someone 3 tiles ahead in the light: [stealth_ai.stealth_state()] after one look") + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "Someone 3 tiles ahead in the light: [stealth_ai.stealth_state()] after two looks") + qdel(guard) + // Six ahead: unchanged (0.375 a look, so one look is not suspicious). + person.forceMove(stealth_at(block, 8, 2)) + guard = stealth_guard(block, 2, 2) + stealth_ai = stealth_ai(guard) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "Someone 6 tiles ahead was noticed in one look") + qdel(guard) + // Two ahead in the dark (not at its elbow): no instant alert. + var/turf/dark = stealth_at(block, 4, 2) + GLOB.stealth_test_dark_turfs[dark] = TRUE + person.forceMove(dark) + guard = stealth_guard(block, 2, 2) + stealth_ai = stealth_ai(guard) + stealth_scans(guard, 1) + TEST_ASSERT(stealth_ai.stealth_state() != "alert", "Someone 2 tiles ahead in the dark was an alert on the first look") + GLOB.stealth_test_dark_turfs[dark] = FALSE + qdel(guard) + // Between scans: someone who steps in front of it is an alert without waiting for the next scan. + person.forceMove(stealth_at(block, 1, 2)) + guard = stealth_guard(block, 2, 2) + stealth_ai = stealth_ai(guard) + stealth_scans(guard, 1) + TEST_ASSERT(stealth_ai.next_scan > world.time, "The scan didn't set its next time") + TEST_ASSERT(!stealth_ai.stealth_quick_look(), "The quick look saw someone behind it") + person.forceMove(stealth_at(block, 3, 2)) + TEST_ASSERT(stealth_ai.stealth_quick_look(), "The quick look missed someone a tile in front") + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "The quick look didn't make it alert") + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], person, "The quick look's alert has no target") + qdel(guard) + qdel(block) + +/// Real lighting: standing still right behind a guard in the dark, its own light on, is never seen; two ahead is. +/datum/unit_test/voidcrew_stealth_behind_dark + +/datum/unit_test/voidcrew_stealth_behind_dark/Run() + var/datum/turf_reservation/block = stealth_block(9, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + stealth_unlist(block, 9, 5) + var/mob/living/carbon/human/person = stealth_person(block, 3, 2) + var/turf/behind = get_turf(person) + TEST_ASSERT(stealth_tile_is_dark(behind), "Reserved test space isn't dark, so the light can't be tested") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 4, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/obj/item/flashlight/light = stealth_ai.stealth_light() + TEST_ASSERT_NOTNULL(light, "A scav guard has no flashlight") + stealth_ai.stealth_update_light() + TEST_ASSERT(light.light_on, "A guard in the dark didn't switch its light on") + // The trap this guards against: tg counts the tile behind as lit by the guard's own light. + TEST_ASSERT(!stealth_tile_is_dark(behind), "The guard's light doesn't reach the tile behind it, so this test proves nothing") + TEST_ASSERT(stealth_tile_is_dark(behind, light), "Leaving the guard's own light out, the tile behind it isn't dark") + stealth_scans(guard, 10) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "unaware", "A guard saw someone standing still right behind it in the dark") + var/list/entry = stealth_ai.notice_map[REF(person)] + TEST_ASSERT(isnull(entry) || entry[1] == 0, "A guard built notice on someone right behind it in the dark ([entry?[1]])") + // Two tiles ahead, in its own beam: an alert. + person.forceMove(stealth_at(block, 6, 2)) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard didn't see someone 2 tiles ahead in its own light") + qdel(guard) + qdel(block) + +/// A friend's passing light doesn't make a guard switch its own off: its spot is judged by the room's light. +/datum/unit_test/voidcrew_stealth_light_friends + +/datum/unit_test/voidcrew_stealth_light_friends/Run() + var/datum/turf_reservation/block = stealth_block(12, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + stealth_unlist(block, 12, 5) + var/mob/living/carbon/human/npc/first = stealth_guard(block, 2, 2) + var/mob/living/carbon/human/npc/second = stealth_guard(block, 4, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/first_ai = stealth_ai(first) + var/datum/ai_controller/basic_controller/human_npc/stealth/second_ai = stealth_ai(second) + var/obj/item/flashlight/first_light = first_ai.stealth_light() + var/obj/item/flashlight/second_light = second_ai.stealth_light() + TEST_ASSERT(first_light in GLOB.stealth_guard_lights, "A guard's seclite isn't on the guard light list") + first_ai.stealth_update_light() + TEST_ASSERT(first_light.light_on, "The first guard didn't switch its light on in the dark") + var/turf/spot = get_turf(second) + TEST_ASSERT(!stealth_tile_is_dark(spot, second_light), "The first guard's light doesn't reach the second, so this test proves nothing") + TEST_ASSERT(stealth_tile_is_dark(spot, second_light, no_guard_lights = TRUE), "Without guard lights the second guard's spot isn't dark") + second_ai.stealth_update_light() + TEST_ASSERT(second_light.light_on, "A guard in the dark left its light off because a friend's light was on it") + second_ai.light_hold_until = 0 + second_ai.stealth_update_light() + TEST_ASSERT(second_light.light_on, "A guard switched its light off because a friend's light was on it") + first_ai.light_hold_until = 0 + first_ai.stealth_update_light() + TEST_ASSERT(first_light.light_on, "A guard switched its light off because a friend's light was on it") + qdel(block) + +/// A guard already found and called out as a body is not asked after as missing. +/datum/unit_test/voidcrew_stealth_live/hear/missing_reported + +/datum/unit_test/voidcrew_stealth_live/hear/missing_reported/Run() + TEST_ASSERT(hear_room(20, 3), "No room for the test") + var/mob/living/carbon/human/npc/first = walker(5, 1) + var/mob/living/carbon/human/npc/second = walker(7, 1) + var/mob/living/carbon/human/npc/third = walker(9, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/first_ai = first.ai_controller + var/datum/stealth_group/group = first_ai.group + stealth_noise(at(4, 1), "knock") + TEST_ASSERT_EQUAL(length(group.checks), 1, "No check was opened on the guard that went") + first.death() + group.reported_bodies += REF(first) + group.rewind(31 SECONDS) + group.check_overdue() + TEST_ASSERT_EQUAL(length(group.checks), 0, "The check on a reported body wasn't closed") + TEST_ASSERT_EQUAL(state_of(second), "unaware", "A guard went looking for a friend whose body was already reported") + TEST_ASSERT_EQUAL(state_of(third), "unaware", "A guard went looking for a friend whose body was already reported") + no_targets(list(second, third)) + +/** + * A running guard never steps faster than a player running: the delay follows its move intent, its kit doesn't + * make it quicker than a bare player, and a diagonal step costs what a player's does. Logs the measured times. + */ +/datum/unit_test/voidcrew_stealth_live/run_speed + +/datum/unit_test/voidcrew_stealth_live/run_speed/Run() + TEST_ASSERT(make_room(16, 16), "No room for the test") + var/mob/living/carbon/human/person = person_at(15, 15, FALSE) + if(person.move_intent != MOVE_INTENT_RUN) + person.toggle_move_intent() + var/player_run = person.cached_multiplicative_slowdown + var/mob/living/carbon/human/npc/guard = guard_at(1, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + stealth_ai.stealth_set_pace(FALSE) + var/walk_delay = stealth_ai.movement_delay + stealth_ai.stealth_set_pace(TRUE) + TEST_ASSERT_EQUAL(guard.move_intent, MOVE_INTENT_RUN, "A running guard isn't on run") + TEST_ASSERT_EQUAL(stealth_ai.movement_delay, guard.cached_multiplicative_slowdown, "The guard's AI step delay didn't follow its move intent") + TEST_ASSERT(stealth_ai.movement_delay < walk_delay, "Running isn't faster than walking for a guard ([stealth_ai.movement_delay] vs [walk_delay])") + TEST_ASSERT(stealth_ai.movement_delay >= player_run, "A running guard's step delay [stealth_ai.movement_delay] is under a running player's [player_run]") + var/stock_diagonal = time_walk(guard, /datum/ai_movement/basic_avoidance, at(1, 1), at(13, 13)) + var/stealth_diagonal = time_walk(guard, /datum/ai_movement/basic_avoidance/stealth, at(1, 1), at(13, 13)) + var/stealth_straight = time_walk(guard, /datum/ai_movement/basic_avoidance/stealth, at(1, 1), at(13, 1)) + // The path movement (jps/stealth) shares stealth_step_cost(); it isn't timed here because tg's JPS loop stops at the + // controller's minimum distance, which with no behaviour running is its whole target range. + TEST_NOTICE(src, "Run speed: player run delay [player_run] ds, guard walk [walk_delay] ds, guard run [stealth_ai.movement_delay] ds. 12 tiles: stock tg diagonal [stock_diagonal] ds, guard diagonal [stealth_diagonal] ds, guard straight [stealth_straight] ds; a player takes [12 * player_run] ds straight, [round(12 * player_run * sqrt(2), 0.1)] ds diagonal.") + TEST_ASSERT(stealth_diagonal >= 0, "The guard never finished the diagonal run") + TEST_ASSERT(stealth_straight >= 0, "The guard never finished the straight run") + // One tick of slack for the move loop's timing. + var/slack = world.tick_lag + TEST_ASSERT(stealth_straight >= 12 * player_run - slack, "12 straight tiles took a running guard [stealth_straight] ds, a player [12 * player_run] ds") + TEST_ASSERT(stealth_diagonal >= 12 * player_run * sqrt(2) - slack, "12 diagonal tiles took a running guard [stealth_diagonal] ds, a player [12 * player_run * sqrt(2)] ds") + +/// Moves `guard` from `start` to `goal` with `movement_type`'s own move loop and returns how long it took, or -1. +/datum/unit_test/voidcrew_stealth_live/run_speed/proc/time_walk(mob/living/carbon/human/npc/guard, movement_type, turf/start, turf/goal) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = guard.ai_controller + guard.forceMove(start) + stealth_ai.last_step_turf = start + var/datum/ai_movement/mover = SSai_movement.movement_types[movement_type] + var/began = world.time + mover.start_moving_towards(stealth_ai, goal, 0) + var/deadline = world.time + 15 SECONDS + while(get_turf(guard) != goal && world.time < deadline) + sleep(world.tick_lag) + var/took = world.time - began + mover.stop_moving_towards(stealth_ai) + return get_turf(guard) == goal ? took : -1 diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth_light.dm b/voidcrew/modules/unit_tests/voidcrew_stealth_light.dm index 4b1f5045302..04df8fc2db3 100644 --- a/voidcrew/modules/unit_tests/voidcrew_stealth_light.dm +++ b/voidcrew/modules/unit_tests/voidcrew_stealth_light.dm @@ -116,7 +116,8 @@ TEST_ASSERT(stealth_ai.search_for_body, "The search isn't marked as over a body") qdel(block) -/// Elbow to elbow: someone on the next tile in front is seen in the dark; behind or side-on is not. +/// Elbow to elbow: someone on the next tile in front is seen in the dark (an alert, decision 32); behind or side-on +/// is not; two ahead in the dark fills (3x near, decision 32) to suspicious. /datum/unit_test/voidcrew_stealth_adjacent /datum/unit_test/voidcrew_stealth_adjacent/Run() @@ -126,19 +127,19 @@ for(var/dy in 0 to 6) GLOB.stealth_test_dark_turfs[stealth_at(block, dx, dy)] = TRUE var/mob/living/carbon/human/person = stealth_person(block, 4, 3) - // offset from the guard => whether two scans make it suspicious + // offset from the guard => its state after two scans var/list/cases = list( - list(1, 0, TRUE), - list(-1, 0, FALSE), - list(0, 1, FALSE), - list(2, 0, FALSE), + list(1, 0, "alert"), + list(-1, 0, "unaware"), + list(0, 1, "unaware"), + list(2, 0, "suspicious"), ) for(var/list/test_case as anything in cases) person.forceMove(stealth_at(block, 3 + test_case[1], 3 + test_case[2])) var/mob/living/carbon/human/npc/guard = stealth_guard(block, 3, 3) var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) stealth_scans(guard, 2) - TEST_ASSERT_EQUAL(stealth_ai.stealth_state() == "suspicious", test_case[3], "Offset ([test_case[1]], [test_case[2]]) in the dark: state [stealth_ai.stealth_state()]") + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), test_case[3], "Offset ([test_case[1]], [test_case[2]]) in the dark: state [stealth_ai.stealth_state()]") qdel(guard) for(var/dx in 0 to 6) for(var/dy in 0 to 6) From 66a1cfb02365d2b5872d9623b25a4fa50c2fa8e6 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:10:58 -0800 Subject: [PATCH 020/152] Guards open the locker or crate they saw their target get into --- tgstation.dme | 1 + voidcrew/_DEFINES/stealth.dm | 13 + voidcrew/modules/stealth/scav_guards.dm | 2 + voidcrew/modules/stealth/stealth_ai.dm | 25 +- voidcrew/modules/stealth/stealth_closet.dm | 286 ++++++++++++++++++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_stealth_closet.dm | 161 ++++++++++ 7 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 voidcrew/modules/stealth/stealth_closet.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_stealth_closet.dm diff --git a/tgstation.dme b/tgstation.dme index 7cb1a1ac6c8..e06f3afb0de 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7732,6 +7732,7 @@ #include "voidcrew\modules\stealth\stealth_admin.dm" #include "voidcrew\modules\stealth\stealth_admin_noise.dm" #include "voidcrew\modules\stealth\stealth_ai.dm" +#include "voidcrew\modules\stealth\stealth_closet.dm" #include "voidcrew\modules\stealth\stealth_hearing.dm" #include "voidcrew\modules\stealth\stealth_light.dm" #include "voidcrew\modules\stealth\stealth_meter.dm" diff --git a/voidcrew/_DEFINES/stealth.dm b/voidcrew/_DEFINES/stealth.dm index 6b6431e79c8..ccddb93ce1e 100644 --- a/voidcrew/_DEFINES/stealth.dm +++ b/voidcrew/_DEFINES/stealth.dm @@ -37,6 +37,10 @@ #define STEALTH_BARK_CHATTER "stealth_chatter" /// It just put its target down #define STEALTH_BARK_DOWNED "stealth_target_downed" +/// It yanks open the locker or crate it saw its target get into (decision 33) +#define STEALTH_BARK_CLOSET_OPEN "stealth_closet_open" +/// It gives up on a locker or crate it can't get open (decision 33) +#define STEALTH_BARK_CLOSET_GIVE_UP "stealth_closet_give_up" // ===== What a stealth goto is for ===== #define STEALTH_GOTO_PATROL "patrol" @@ -44,6 +48,7 @@ #define STEALTH_GOTO_BODY "body" #define STEALTH_GOTO_SEARCH "search" #define STEALTH_GOTO_HOME "home" +#define STEALTH_GOTO_CLOSET "closet" // ===== Numbers (first playtest defaults; the owner tunes them) ===== #define STEALTH_SCAN_INTERVAL (1 SECONDS) @@ -125,6 +130,14 @@ /// Seconds of search between "Come on out." lines #define STEALTH_SEARCH_BARK_INTERVAL (12 SECONDS) +// Lockers and crates it saw someone get into (decision 33, stealth_closet.dm) +/// Seen on open ground this recently and now in a locker in view: it saw them get in +#define STEALTH_CLOSET_SEEN_WINDOW (1.5 SECONDS) +/// After this long on one locker (walking, opening, bashing) it gives up and searches round it +#define STEALTH_CLOSET_GIVE_UP_TIME (45 SECONDS) +/// A guard that isn't the one opening it stands this close and covers it +#define STEALTH_CLOSET_COVER_RANGE 3 + // The mark over a guard's head (stealth_meter.dm, owner decision 27) /// Pixel rows in the "?" and "!" glyphs: fill and drain steps #define STEALTH_METER_ROWS 7 diff --git a/voidcrew/modules/stealth/scav_guards.dm b/voidcrew/modules/stealth/scav_guards.dm index 0cfc70dbe50..24ec9e2aea9 100644 --- a/voidcrew/modules/stealth/scav_guards.dm +++ b/voidcrew/modules/stealth/scav_guards.dm @@ -19,6 +19,8 @@ STEALTH_BARK_BODY = list("Man down!", "Who did this?"), STEALTH_BARK_CHATTER = list("Shipment's late again.", "My feet are killing me.", "You hear that? No? Good.", "Radio check.", "Quiet tonight. I hate quiet."), STEALTH_BARK_DOWNED = list("Got 'em.", "Stay down.", "That's handled."), + STEALTH_BARK_CLOSET_OPEN = list("Out you come.", "Thought so.", "Found you."), + STEALTH_BARK_CLOSET_GIVE_UP = list("Stay in there, then.", "Fine. Rot in there."), STEALTH_BARK_HEARD = list("What was that?", "You hear that?", "Hm."), STEALTH_BARK_HEARD_AGAIN = list("Again?", "That's twice now."), STEALTH_BARK_GUNFIRE = list("Shots!", "That's gunfire!", "Shots, %SIDE% side!"), diff --git a/voidcrew/modules/stealth/stealth_ai.dm b/voidcrew/modules/stealth/stealth_ai.dm index 6db4c27db10..d628eb8557d 100644 --- a/voidcrew/modules/stealth/stealth_ai.dm +++ b/voidcrew/modules/stealth/stealth_ai.dm @@ -81,6 +81,7 @@ /datum/ai_planning_subtree/human_npc_surrender, /datum/ai_planning_subtree/stealth_react, /datum/ai_planning_subtree/human_npc_arm, + /datum/ai_planning_subtree/stealth_closet, /datum/ai_planning_subtree/human_npc_shoot, /datum/ai_planning_subtree/human_npc_melee, /datum/ai_planning_subtree/stealth_patrol, @@ -219,6 +220,7 @@ ) + STEALTH_HANDLED_SIGNALS + STEALTH_STUN_SIGNALS) group?.remove_member(pawn) group = null + stealth_drop_closet() for(var/datum/stealth_sight_overlay/overlay as anything in watchers?.Copy()) qdel(overlay) watchers = null @@ -247,6 +249,7 @@ . = ..() QDEL_NULL(meter) stealth_drop_call() + stealth_drop_closet() // The player has left the body: it is a guard again, with nothing on its mind. /datum/ai_controller/basic_controller/human_npc/stealth/on_sentience_lost() @@ -296,6 +299,9 @@ blackboard[BB_STEALTH_UNAWARE_LOST_AT] = world.time blackboard[BB_STEALTH_STATE] = new_state blackboard[BB_STEALTH_STATE_SINCE] = world.time + // Only an alert guard goes after a locker it saw someone get into (decision 33). + if(new_state != STEALTH_ALERT) + stealth_drop_closet() look_until = 0 lost_since = 0 calm_until = 0 @@ -434,6 +440,8 @@ last_seen_turf = get_turf(target) blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target) + // Every alert is first hand: if they are in a locker or a box, it is the one it just saw or felt (decision 33). + stealth_closet_alerted(target) // It has seen or felt them itself: it calls for help (decision 8). if(news) stealth_first_hand(last_seen_turf) @@ -537,6 +545,10 @@ pending_call[STEALTH_CALL_DUE] -= time if(explained_at) explained_at -= time + if(shut_in_at) + shut_in_at -= time + if(target_free_seen_at) + target_free_seen_at -= time for(var/kind in bark_ready_at) bark_ready_at[kind] -= time for(var/key in stop_skip_until) @@ -675,6 +687,11 @@ if(dist > STEALTH_SIGHT_RANGE) continue if(boxed) + // Searching, it saw them on open ground a moment ago, and now they are in a locker it can see (decision 33). + if(state == STEALTH_SEARCHING && eyes && dist <= STEALTH_ALERT_MAX_RANGE && stealth_closet_glimpsed(candidate, stealth_shell_of(candidate))) + if(strategy.can_attack(guard, candidate, STEALTH_SIGHT_RANGE)) + stealth_go_alert(candidate) + return // Hidden, unless the thing they hide in moves at its shoulder (decision 29). if(dist > STEALTH_CLOSE_RANGE + 1) continue @@ -902,8 +919,13 @@ if(QDELETED(remembered)) stealth_go_searching(last_seen_turf) return + // In a locker or a box: tracked only if it saw them get in (decision 33, stealth_closet.dm). + if(stealth_closet_track(remembered)) + return var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) if(strategy?.can_attack(guard, remembered, HUMAN_NPC_SIGHT_RANGE)) + if(isturf(remembered.loc)) + target_free_seen_at = world.time lost_since = 0 last_seen_turf = get_turf(remembered) blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time @@ -970,7 +992,7 @@ return if(best) var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) - if(best_notice >= STEALTH_ALERT_AT && strategy?.can_attack(guard, best, HUMAN_NPC_SIGHT_RANGE)) + if(best_notice >= STEALTH_ALERT_AT && isnull(stealth_shell_of(best)) && strategy?.can_attack(guard, best, HUMAN_NPC_SIGHT_RANGE)) stealth_go_alert(best) else if(best_notice >= STEALTH_ALERT_AT) stealth_go_searching(best_turf) @@ -1226,6 +1248,7 @@ if(new_stat != CONSCIOUS) QDEL_NULL(meter) stealth_drop_call() + stealth_drop_closet() if(new_stat == CONSCIOUS && was != CONSCIOUS && was != DEAD) stealth_came_round() diff --git a/voidcrew/modules/stealth/stealth_closet.dm b/voidcrew/modules/stealth/stealth_closet.dm new file mode 100644 index 00000000000..537bef8e827 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_closet.dm @@ -0,0 +1,286 @@ +/** + * # Lockers and crates a guard saw someone get into (owner decision 33) + * + * An alert guard, or a searching one that glimpses someone, that sees its target get into a locker, a crate, a + * coffin, a body bag or a cardboard box (any /obj/structure/closet), or sees the door shut on them, remembers + * that container. It walks up and opens it with tg's own open(). If it won't open (locked or welded), it hits it + * with what it holds, through tg's click path: point-blank shots with a loaded gun, or its melee weapon when that + * can dent it. A locker that breaks bursts open through tg's atom_break(). It gives up after + * STEALTH_CLOSET_GIVE_UP_TIME and searches round it. + * + * Someone who gets in unseen is still hidden (decision 22): an alert guard whose target is in a container it + * didn't see them get into treats them as out of sight, and never shoots at the container (tg's own + * find_hidden_mobs() would have it shoot any locker its target is in, from across the room). + * + * With several guards on one container the one that claimed it first opens it; the rest stand close and cover it. + */ + +/datum/ai_controller/basic_controller/human_npc/stealth + /// The locker or crate it saw its target get into, or null. Cleared when it is deleted. + var/obj/structure/closet/shut_in + /// Who it saw get in + var/datum/weakref/shut_who + /// world.time it saw them get in + var/shut_in_at = 0 + /// Alert: world.time it last saw its target on open ground + var/target_free_seen_at = 0 + +/// The locker, crate or box `who` is shut in (the outermost, if one is inside another), or null. +/proc/stealth_closet_of(mob/living/who) + if(!isliving(who)) + return null + var/atom/movable/shell = stealth_shell_of(who) + return istype(shell, /obj/structure/closet) ? shell : null + +/// Whether it has `box` in view (all round, as it keeps an alert target in view). +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_sees_closet(obj/structure/closet/box) + var/mob/living/guard = pawn + if(QDELETED(box) || !isturf(guard.loc) || get_dist(guard, box) > HUMAN_NPC_SIGHT_RANGE) + return FALSE + return can_see(guard, box, HUMAN_NPC_SIGHT_RANGE) + +/// Remembers that it saw `who` get into `box`. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_claim_closet(obj/structure/closet/box, mob/living/who) + if(shut_in == box) + shut_who = WEAKREF(who) + return + stealth_drop_closet() + shut_in = box + shut_who = WEAKREF(who) + shut_in_at = world.time + RegisterSignal(box, COMSIG_QDELETING, PROC_REF(on_closet_deleted)) + +/// Forgets the container it was after. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_drop_closet() + if(shut_in) + UnregisterSignal(shut_in, COMSIG_QDELETING) + shut_in = null + shut_who = null + shut_in_at = 0 + +/datum/ai_controller/basic_controller/human_npc/stealth/proc/on_closet_deleted(datum/source) + SIGNAL_HANDLER + stealth_drop_closet() + +/** + * Its claim, still good: `who` is still inside it (or inside something that went into it, which it then goes + * after instead). Drops a claim that no longer holds and returns null. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_closet_check(mob/living/who) + var/obj/structure/closet/box = shut_in + if(isnull(box)) + return null + if(!isliving(who) || QDELETED(who) || who.stat == DEAD || shut_who?.resolve() != who) + stealth_drop_closet() + return null + var/atom/holder = who.loc + while(holder && holder != box && !isturf(holder)) + holder = holder.loc + var/obj/structure/closet/outer = stealth_closet_of(who) + if(holder != box || isnull(outer)) + // They got out, or someone opened it, or it went into something that isn't a locker. + stealth_drop_closet() + return null + if(outer != box) + // The box it watched was shut inside another one: that is the one to open now. + var/claimed_at = shut_in_at + stealth_claim_closet(outer, who) + shut_in_at = claimed_at + return shut_in + +/// Searching: whether it saw `candidate` on open ground a moment ago, close to the `shell` it can see them in now. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_closet_glimpsed(mob/living/candidate, atom/movable/shell) + if(!istype(shell, /obj/structure/closet)) + return FALSE + var/list/entry = notice_map[REF(candidate)] + if(isnull(entry) || entry[STEALTH_ENTRY_NOTICE] < STEALTH_SUSPICIOUS_AT) + return FALSE + if(world.time - entry[STEALTH_ENTRY_SEEN_AT] > STEALTH_CLOSET_SEEN_WINDOW) + return FALSE + var/turf/was = entry[STEALTH_ENTRY_TURF] + if(isnull(was) || get_dist(was, shell) > 2) + return FALSE + return can_see(pawn, shell, STEALTH_SIGHT_RANGE) + +/// Just went alert on `target` (always first hand: sight, a touch, a hit). If they are in a container, that's the one. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_closet_alerted(atom/target) + var/mob/living/who = target + if(!isliving(who)) + stealth_drop_closet() + return + if(isturf(who.loc)) + stealth_drop_closet() + target_free_seen_at = world.time + return + var/obj/structure/closet/box = stealth_closet_of(who) + if(box && stealth_sees_closet(box)) + stealth_claim_closet(box, who) + else if(shut_in && shut_who?.resolve() != who) + stealth_drop_closet() + +/** + * Alert, from stealth_keep_target(): its target `remembered` is in a container. Returns FALSE if they are not + * (the usual can_attack() tracking goes on). A container it saw them get into is kept in view like the target + * itself; any other hides them, and they are lost after STEALTH_LOSE_TARGET_TIME as if they had run off. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_closet_track(atom/remembered) + var/mob/living/guard = pawn + var/mob/living/who = remembered + var/obj/structure/closet/outer = stealth_closet_of(who) + if(isnull(outer)) + if(shut_in) + stealth_drop_closet() + return FALSE + var/obj/structure/closet/box = stealth_closet_check(who) + // On open ground a moment ago, and the door shut on them in front of it. + if(isnull(box) && world.time - target_free_seen_at <= STEALTH_CLOSET_SEEN_WINDOW && stealth_eyes_open() && stealth_sees_closet(outer)) + stealth_claim_closet(outer, who) + box = outer + if(box && stealth_sees_closet(box)) + lost_since = 0 + last_seen_turf = get_turf(box) + blackboard[BB_STEALTH_LAST_SEEN_AT] = world.time + if(!blackboard[BB_BASIC_MOB_CURRENT_TARGET]) + set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, who) + return TRUE + if(!lost_since) + lost_since = world.time + if(world.time - lost_since < STEALTH_LOSE_TARGET_TIME) + return TRUE + stealth_drop_closet() + clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + stealth_bark(STEALTH_BARK_LOST) + stealth_go_searching(last_seen_turf || get_turf(guard)) + return TRUE + +/// Whether it is the one to open `box`: of its group's guards after it, the one that saw them get in first. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_closet_opener(obj/structure/closet/box) + if(isnull(group)) + return TRUE + var/seen_self = FALSE + for(var/datum/weakref/ref as anything in group.members) + var/mob/living/member = ref?.resolve() + if(isnull(member)) + continue + if(member == pawn) + seen_self = TRUE + continue + var/datum/ai_controller/basic_controller/human_npc/stealth/other = member.ai_controller + if(!istype(other) || other.shut_in != box || member.stat != CONSCIOUS || member.client || other.stealth_captive()) + continue + if(other.shut_in_at < shut_in_at || (other.shut_in_at == shut_in_at && !seen_self)) + return FALSE + return TRUE + +/// Out of time on its container: it says so and searches round it. +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_closet_give_up() + var/turf/where = get_turf(shut_in) || last_seen_turf + stealth_drop_closet() + clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + stealth_bark(STEALTH_BARK_CLOSET_GIVE_UP) + stealth_go_searching(where) + // It knows someone is in there: its search doesn't end with "Nothing." + search_for_body = TRUE + +/** + * Beside its container: opens it with tg's open(), as a hand on the door would. Locked or welded shut, it hits + * it once with what it holds, through tg's click path: a point-blank shot from a loaded gun, or its melee + * weapon if that hits hard enough to dent it (a gun butt can't: a secure locker shrugs off anything under its + * damage_deflection). Bare-handed it can only wait. + */ +/datum/ai_controller/basic_controller/human_npc/stealth/proc/stealth_try_closet() + var/mob/living/carbon/human/guard = pawn + var/obj/structure/closet/box = shut_in + if(QDELETED(box) || !guard.Adjacent(box) || HAS_TRAIT(guard, TRAIT_HANDS_BLOCKED) || guard.incapacitated) + return + var/mob/living/who = shut_who?.resolve() + guard.face_atom(box) + if(box.open(guard)) + stealth_drop_closet() + stealth_bark(STEALTH_BARK_CLOSET_OPEN) + if(isliving(who) && !QDELETED(who)) + // Out on the floor in front of it, or still in a box that was inside: either way, in plain view. + if(isturf(who.loc)) + target_free_seen_at = world.time + lost_since = 0 + else + stealth_closet_alerted(who) + return + if(world.time < guard.next_move) + return + var/obj/item/gun/gun = human_npc_loaded_gun(guard) + if(gun) + if(world.time < blackboard[BB_HUMAN_NPC_NEXT_SHOT] || !human_npc_ready_item(guard, gun)) + return + guard.zone_selected = BODY_ZONE_CHEST + ai_interact(target = box, combat_mode = TRUE) + set_blackboard_key(BB_HUMAN_NPC_NEXT_SHOT, world.time + (blackboard[BB_HUMAN_NPC_SHOT_DELAY] || 3 SECONDS)) + return + var/obj/item/weapon = human_npc_held_melee_weapon(guard) + if(isnull(weapon) || (weapon.item_flags & NOBLUDGEON) || weapon.force < max(box.damage_deflection, 1)) + return + human_npc_ready_item(guard, weapon) + human_npc_ready_weapon(guard, weapon) + ai_interact(target = box, combat_mode = TRUE) + +/** + * Alert with its target in a container. One it saw them get into: the guard that saw first walks over and opens + * it, the others close in to STEALTH_CLOSET_COVER_RANGE and cover it. One it didn't: it holds its fire and watches + * where it last saw them, rather than shoot whatever its target hides in. Ends planning either way, so the + * shooting and melee subtrees after it never aim at the container from range. + */ +/datum/ai_planning_subtree/stealth_closet + +/datum/ai_planning_subtree/stealth_closet/SelectBehaviors(datum/ai_controller/basic_controller/human_npc/stealth/controller, seconds_per_tick) + if(!istype(controller) || controller.stealth_state() != STEALTH_ALERT) + return + var/mob/living/guard = controller.pawn + if(guard.stat != CONSCIOUS) + return + var/mob/living/who = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] || controller.blackboard[BB_STEALTH_FOCUS] + var/obj/structure/closet/outer = stealth_closet_of(who) + if(isnull(outer)) + return + var/obj/structure/closet/box = controller.stealth_closet_check(who) + if(isnull(box)) + controller.stealth_drop_walk() + var/turf/last = controller.last_seen_turf + if(last && last != get_turf(guard)) + controller.stand_dir = stealth_dir_to(guard, last) + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING + if(world.time - controller.shut_in_at >= STEALTH_CLOSET_GIVE_UP_TIME) + controller.stealth_drop_walk() + controller.stealth_closet_give_up() + return SUBTREE_RETURN_FINISH_PLANNING + var/turf/spot = get_turf(box) + if(!controller.stealth_closet_opener(box)) + if(get_dist(guard, box) > STEALTH_CLOSET_COVER_RANGE && !controller.stealth_walk_gave_up(spot)) + controller.stealth_walk(spot, STEALTH_GOTO_CLOSET) + return SUBTREE_RETURN_FINISH_PLANNING + controller.stealth_drop_walk() + if(spot != get_turf(guard)) + controller.stand_dir = stealth_dir_to(guard, box) + controller.queue_behavior(/datum/ai_behavior/stealth_stand) + return SUBTREE_RETURN_FINISH_PLANNING + if(!guard.Adjacent(box)) + if(controller.stealth_walk_gave_up(spot)) + // No way to it: it gives up now rather than stand there. + controller.stealth_drop_walk() + controller.stealth_closet_give_up() + return SUBTREE_RETURN_FINISH_PLANNING + controller.stealth_walk(spot, STEALTH_GOTO_CLOSET) + return SUBTREE_RETURN_FINISH_PLANNING + controller.stealth_drop_walk() + controller.queue_behavior(/datum/ai_behavior/stealth_open_closet) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Beside the container: one try a second (stealth_try_closet()). Never finishes by itself; planning replaces it. +/datum/ai_behavior/stealth_open_closet + action_cooldown = 1 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/stealth_open_closet/perform(seconds_per_tick, datum/ai_controller/basic_controller/human_npc/stealth/controller) + if(istype(controller)) + controller.stealth_try_closet() + return AI_BEHAVIOR_DELAY diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 4382f84b5fc..0d2998b9e90 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -235,6 +235,7 @@ #include "voidcrew_slime_extract_piles.dm" #include "voidcrew_smart_locker.dm" #include "voidcrew_stealth.dm" +#include "voidcrew_stealth_closet.dm" #include "voidcrew_stealth_d32.dm" #include "voidcrew_stealth_hearing.dm" #include "voidcrew_stealth_light.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth_closet.dm b/voidcrew/modules/unit_tests/voidcrew_stealth_closet.dm new file mode 100644 index 00000000000..b556d50eb61 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_stealth_closet.dm @@ -0,0 +1,161 @@ +// Stealth guards, owner decision 33 (voidcrew/modules/stealth/stealth_closet.dm): a guard that saw its target get into +// a locker opens it; one that didn't leaves it shut. Values from voidcrew/_DEFINES are written out: +// STEALTH_LOSE_TARGET_TIME 4 s, STEALTH_CLOSET_SEEN_WINDOW 1.5 s. States are written as text. + +/// Claims, with the AI off: the locker it sees someone in when it goes alert; dropped when they get out, when it is +/// deleted, and when the guard stops being alert. +/datum/unit_test/voidcrew_stealth_closet_claim + +/datum/unit_test/voidcrew_stealth_closet_claim/Run() + var/datum/turf_reservation/block = stealth_block(8, 3) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 1, 1) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/mob/living/carbon/human/person = stealth_person(block, 5, 1) + var/obj/structure/closet/locker = allocate(/obj/structure/closet, stealth_at(block, 4, 1)) + person.forceMove(locker) + stealth_ai.stealth_go_alert(person) + TEST_ASSERT_EQUAL(stealth_ai.shut_in, locker, "A guard alerted on someone in a locker in plain view didn't claim it") + // Out of it: the claim goes. + person.forceMove(stealth_at(block, 5, 1)) + TEST_ASSERT_NULL(stealth_ai.stealth_closet_check(person), "A claim held after its target got out") + TEST_ASSERT_NULL(stealth_ai.shut_in, "A guard kept a locker its target had left") + // Back in, then the locker is destroyed. + person.forceMove(locker) + stealth_ai.stealth_go_alert(person) + TEST_ASSERT_EQUAL(stealth_ai.shut_in, locker, "A guard didn't claim the locker the second time") + qdel(locker) + TEST_ASSERT_NULL(stealth_ai.shut_in, "A guard kept a deleted locker") + // A new one, and the guard calms down. + var/obj/structure/closet/second = allocate(/obj/structure/closet, stealth_at(block, 4, 1)) + person.forceMove(second) + stealth_ai.stealth_go_alert(person) + TEST_ASSERT_EQUAL(stealth_ai.shut_in, second, "A guard didn't claim the second locker") + stealth_ai.stealth_go_searching(stealth_at(block, 4, 1)) + TEST_ASSERT_NULL(stealth_ai.shut_in, "A guard that stopped being alert kept its locker") + +/// Real AI: alert on someone who walks into an open locker and pulls the door shut in front of it, it opens the locker. +/datum/unit_test/voidcrew_stealth_live/closet_seen + +/datum/unit_test/voidcrew_stealth_live/closet_seen/Run() + TEST_ASSERT(make_room(12, 5), "No room for the test") + var/mob/living/carbon/human/npc/guard = guard_at(2, 2, EAST) + var/mob/living/carbon/human/person = person_at(6, 2) + var/obj/structure/closet/locker = allocate(/obj/structure/closet, at(7, 2)) + locker.open() + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = wake(guard) + stealth_ai.stealth_go_alert(person) + step(person, EAST) + TEST_ASSERT_EQUAL(get_turf(person), at(7, 2), "The person couldn't step into the open locker") + TEST_ASSERT(locker.close(person), "The person couldn't shut the locker on themselves") + TEST_ASSERT_EQUAL(person.loc, locker, "The person isn't in the locker") + var/start_integrity = locker.get_integrity() + var/deadline = world.time + 30 SECONDS + while(!locker.opened && world.time < deadline) + sleep(2) + TEST_ASSERT(locker.opened, "A guard that saw someone shut themselves in a locker never opened it (claim: [stealth_ai.shut_in ? "yes" : "no"], state [stealth_ai.stealth_state()])") + TEST_ASSERT(get_dist(guard, locker) <= 1, "The locker opened with the guard [get_dist(guard, locker)] tiles away") + TEST_ASSERT_EQUAL(locker.get_integrity(), start_integrity, "A guard hit a locker it could just open") + TEST_ASSERT(isturf(person.loc), "The person is still shut in after the locker opened") + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "The guard isn't alert on whoever was in the locker") + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], person, "The guard isn't after whoever was in the locker") + +/// Real AI: alert on someone who got into a locker behind a wall, it can see the locker once the wall is gone, but +/// leaves it shut: it loses them, searches, and never hits or opens it. +/datum/unit_test/voidcrew_stealth_live/closet_unseen + +/datum/unit_test/voidcrew_stealth_live/closet_unseen/Run() + TEST_ASSERT(make_room(12, 5), "No room for the test") + for(var/dy in 0 to 4) + var/turf/wall_spot = at(6, dy) + wall_spot.ChangeTurf(/turf/closed/wall) + var/mob/living/carbon/human/npc/guard = guard_at(2, 2, EAST) + var/mob/living/carbon/human/person = person_at(9, 2) + var/obj/structure/closet/locker = allocate(/obj/structure/closet, at(9, 2)) + person.forceMove(locker) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = wake(guard) + stealth_ai.stealth_go_alert(person) + TEST_ASSERT_NULL(stealth_ai.shut_in, "A guard claimed a locker behind a wall") + for(var/dy in 0 to 4) + var/turf/gap = at(6, dy) + gap.ChangeTurf(/turf/open/floor/iron) + GLOB.stealth_test_dark_turfs[gap] = FALSE + var/start_integrity = locker.get_integrity() + var/deadline = world.time + 15 SECONDS + while(stealth_ai.stealth_state() == "alert" && world.time < deadline) + sleep(2) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "searching", "A guard whose target hid unseen didn't lose them") + sleep(6 SECONDS) + TEST_ASSERT(!locker.opened, "A guard opened a locker it never saw anyone get into") + TEST_ASSERT_EQUAL(person.loc, locker, "The person was taken out of the locker") + TEST_ASSERT_EQUAL(locker.get_integrity(), start_integrity, "A guard hit a locker it never saw anyone get into") + TEST_ASSERT_NULL(stealth_ai.shut_in, "A searching guard claimed a locker") + +/// Real AI: a gunner that saw someone shut in a locker that is then locked shoots it open from beside it, not from range. +/datum/unit_test/voidcrew_stealth_live/closet_locked + +/datum/unit_test/voidcrew_stealth_live/closet_locked/Run() + TEST_ASSERT(make_room(12, 5), "No room for the test") + var/mob/living/carbon/human/npc/guard = allocate(/mob/living/carbon/human/npc/ruin/trooper/scav/gunner, at(2, 2)) + guard.setDir(EAST) + guard.AddElement(/datum/element/forced_gravity, 1) + var/mob/living/carbon/human/person = person_at(6, 2) + var/obj/structure/closet/secure_closet/locker = allocate(/obj/structure/closet/secure_closet, at(7, 2)) + locker.locked = FALSE + locker.open() + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = wake(guard) + stealth_ai.stealth_go_alert(person) + step(person, EAST) + TEST_ASSERT(locker.close(person), "The person couldn't shut the locker on themselves") + locker.locked = TRUE + locker.update_appearance() + // Near breaking, so the test takes a couple of shots, not a magazine. + locker.update_integrity(locker.max_integrity * locker.integrity_failure + 25) + var/start_integrity = locker.get_integrity() + var/first_hit_dist + var/deadline = world.time + 40 SECONDS + while(!locker.opened && world.time < deadline) + if(isnull(first_hit_dist) && locker.get_integrity() < start_integrity) + first_hit_dist = get_dist(guard, locker) + sleep(1) + TEST_ASSERT(locker.opened, "A gunner never broke open a locked locker it saw someone get into (claim: [stealth_ai.shut_in ? "yes" : "no"], integrity [locker.get_integrity()]/[start_integrity])") + TEST_ASSERT_NOTNULL(first_hit_dist, "The locked locker opened without being hit") + TEST_ASSERT(first_hit_dist <= 1, "A gunner shot a locker from [first_hit_dist] tiles away") + TEST_ASSERT(isturf(person.loc), "The person is still shut in after the locker broke") + TEST_ASSERT_EQUAL(stealth_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET], person, "The gunner isn't after whoever was in the locker") + +/// Real AI: two guards saw it; only one opens the locker, the other covers it. +/datum/unit_test/voidcrew_stealth_live/closet_two_guards + +/datum/unit_test/voidcrew_stealth_live/closet_two_guards/Run() + TEST_ASSERT(make_room(12, 5), "No room for the test") + allocate(/obj/effect/landmark/stealth_group, at(0, 4)) + var/mob/living/carbon/human/npc/first = guard_at(2, 1, EAST) + var/mob/living/carbon/human/npc/second = guard_at(2, 3, EAST) + var/mob/living/carbon/human/person = person_at(6, 2) + var/obj/structure/closet/locker = allocate(/obj/structure/closet, at(7, 2)) + locker.open() + var/datum/ai_controller/basic_controller/human_npc/stealth/first_ai = wake(first) + var/datum/ai_controller/basic_controller/human_npc/stealth/second_ai = wake(second) + first_ai.stealth_setup() + second_ai.stealth_setup() + TEST_ASSERT_NOTNULL(first_ai.group, "The guards have no group") + TEST_ASSERT_EQUAL(first_ai.group, second_ai.group, "The guards aren't in one group") + first_ai.stealth_go_alert(person) + second_ai.stealth_go_alert(person) + step(person, EAST) + TEST_ASSERT(locker.close(person), "The person couldn't shut the locker on themselves") + var/datum/ai_behavior/opening = GET_AI_BEHAVIOR(/datum/ai_behavior/stealth_open_closet) + var/list/openers = list() + var/both_claimed = FALSE + var/deadline = world.time + 30 SECONDS + while(!locker.opened && world.time < deadline) + if(first_ai.shut_in == locker && second_ai.shut_in == locker) + both_claimed = TRUE + for(var/datum/ai_controller/basic_controller/human_npc/stealth/guard_ai as anything in list(first_ai, second_ai)) + if(opening in guard_ai.current_behaviors) + openers |= guard_ai + sleep(1) + TEST_ASSERT(locker.opened, "Two guards that saw someone shut in a locker never opened it") + TEST_ASSERT(both_claimed, "The two guards never both went after the locker (the test can't tell one opener from one watcher)") + TEST_ASSERT_EQUAL(length(openers), 1, "[length(openers)] guards tried to open one locker") From f66dc95323d24e9868aabd53a557aaed86e4a6dd Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:43:03 -0800 Subject: [PATCH 021/152] Stealth guards take bigger crits when caught unaware, a head shot can drop them, a matte knife backstabs, and their seclites can be looted --- tgstation.dme | 2 + voidcrew/_DEFINES/stealth.dm | 26 +++ .../modules/hyperviolence/crits/crit_roll.dm | 16 +- voidcrew/modules/stealth/stealth_ai.dm | 17 ++ voidcrew/modules/stealth/stealth_crits.dm | 122 ++++++++++++ voidcrew/modules/stealth/stealth_knife.dm | 13 ++ voidcrew/modules/stealth/stealth_light.dm | 17 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_stealth_crits.dm | 186 ++++++++++++++++++ 9 files changed, 393 insertions(+), 7 deletions(-) create mode 100644 voidcrew/modules/stealth/stealth_crits.dm create mode 100644 voidcrew/modules/stealth/stealth_knife.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_stealth_crits.dm diff --git a/tgstation.dme b/tgstation.dme index e06f3afb0de..605ee7f38c9 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7733,7 +7733,9 @@ #include "voidcrew\modules\stealth\stealth_admin_noise.dm" #include "voidcrew\modules\stealth\stealth_ai.dm" #include "voidcrew\modules\stealth\stealth_closet.dm" +#include "voidcrew\modules\stealth\stealth_crits.dm" #include "voidcrew\modules\stealth\stealth_hearing.dm" +#include "voidcrew\modules\stealth\stealth_knife.dm" #include "voidcrew\modules\stealth\stealth_light.dm" #include "voidcrew\modules\stealth\stealth_meter.dm" #include "voidcrew\modules\stealth\stealth_noise.dm" diff --git a/voidcrew/_DEFINES/stealth.dm b/voidcrew/_DEFINES/stealth.dm index ccddb93ce1e..ac36f1e9470 100644 --- a/voidcrew/_DEFINES/stealth.dm +++ b/voidcrew/_DEFINES/stealth.dm @@ -13,6 +13,8 @@ #define BB_STEALTH_STATE_SINCE "stealth_state_since" /// world.time the guard last stopped being unaware (stealth_was_unaware()) #define BB_STEALTH_UNAWARE_LOST_AT "stealth_unaware_lost_at" +/// The state before the last change, so a blow can be judged by the state it began in (stealth_state_at_blow()) +#define BB_STEALTH_PREV_STATE "stealth_prev_state" /// TRUE once alerted, searching or hit. Fades after STEALTH_WARY_TIME with nothing new (checked each scan). #define BB_STEALTH_WARY "stealth_wary" /// The candidate with the highest notice @@ -158,3 +160,27 @@ #define STEALTH_LIGHT_EMP_LIGHT (20 SECONDS) #define FACTION_SCAV "scav" + +// ===== Stealth crits and the backstab knife (step 3a, stealth_crits.dm) ===== +// Roll bonuses for hyperviolence's aimed crit roll (crit_roll.dm), on a stealth guard only. They go in before wound armour, +// so armour scales them like the rest of the roll. Numbers argued in data/stealth/STATUS.md, "Step 3a". +/// Added when the guard was unaware of everyone as the blow began +#define STEALTH_CRIT_UNAWARE_BONUS 30 +/// Added when it was only suspicious +#define STEALTH_CRIT_SUSPICIOUS_BONUS 15 +/// Added on top of either when the attacker aimed at the head (head, eyes or mouth) +#define STEALTH_CRIT_HEAD_BONUS 35 +/// Added when the backstab knife strikes from behind (tg's check_behind()), whatever the guard's state +#define STEALTH_BACKSTAB_BONUS 40 +/// The head shot crit (a bullet through the skull) needs a roll this high, and only a stealth hit can roll it +#define STEALTH_HEAD_SHOT_THRESHOLD 80 +/// Least damage after armour a head shot crit needs +#define STEALTH_HEAD_SHOT_MIN_DAMAGE 20 +/// A helmet this good against the bullet (after its armour penetration) stops a head shot crit outright +#define STEALTH_HEAD_SHOT_HELMET_ARMOUR 30 +/// Brain damage a head shot crit deals (brains die at 200) +#define STEALTH_HEAD_SHOT_BRAIN_DAMAGE 40 +/// How long a head shot crit knocks the guard down +#define STEALTH_HEAD_SHOT_KNOCKDOWN (4 SECONDS) +/// A bullet through the head +#define WOUND_SERIES_HV_HEAD_SHOT "wound_series_hv_head_shot" diff --git a/voidcrew/modules/hyperviolence/crits/crit_roll.dm b/voidcrew/modules/hyperviolence/crits/crit_roll.dm index bbcbdc16792..178d70354b9 100644 --- a/voidcrew/modules/hyperviolence/crits/crit_roll.dm +++ b/voidcrew/modules/hyperviolence/crits/crit_roll.dm @@ -18,7 +18,8 @@ // * The roll is tg's own wound roll: rand(1, min(damage, 25) ^ 1.4), so 1 to 90. On top of the // weapon's wound_bonus we add up to +30 for how battered the limb already is (its damage over // its max damage) and +25 if the victim is helpless (lying down, in crit, stunned, cuffed or -// held in an aggressive grab), +15 more if the attacker is the one holding them. Then tg's +// held in an aggressive grab), +15 more if the attacker is the one holding them, plus any +// situation bonus (hv_crit_situation_bonus(): stealth guards caught unaware). Then tg's // check_woundings_mods() adds the limb's existing wound penalties and scales everything by // wound armour (worn WOUND ratings plus the limb's own resistance: head 5%, chest 10%). // * Each crit has a threshold and a minimum damage after armour (crit_types.dm). A fresh, @@ -222,9 +223,14 @@ GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) if(attacker && attacker.pulling == src && attacker.grab_state >= GRAB_AGGRESSIVE) . += HV_CRIT_HELD_BONUS -/// The crit roll for this limb. tg's wound roll plus the battered and helpless bonuses, run +/// Roll bonus from how the blow was struck, on top of the helpless bonus: 0 here. The stealth layer adds one for a guard +/// caught unaware or stabbed in the back (voidcrew/modules/stealth/stealth_crits.dm). `source` is the weapon or projectile. +/mob/living/proc/hv_crit_situation_bonus(mob/living/attacker, atom/source, obj/item/bodypart/limb) + return 0 + +/// The crit roll for this limb. tg's wound roll plus the battered, helpless and situation bonuses, run /// through check_woundings_mods() so wound armour and existing wounds count as they do for tg. -/obj/item/bodypart/proc/hv_crit_roll(wounding_type, damage, wound_bonus, exposed_wound_bonus, mob/living/attacker) +/obj/item/bodypart/proc/hv_crit_roll(wounding_type, damage, wound_bonus, exposed_wound_bonus, mob/living/attacker, atom/source) var/easily_wounded = HAS_TRAIT(owner, TRAIT_EASILY_WOUNDED) var/hardly_wounded = HAS_TRAIT(owner, TRAIT_HARDLY_WOUNDED) if(easily_wounded && !hardly_wounded) @@ -234,7 +240,7 @@ GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) if(hardly_wounded && !easily_wounded) damage *= 0.85 var/base_roll = rand(1, max(1, round(damage ** WOUND_DAMAGE_EXPONENT))) - var/bonus = wound_bonus + round(HV_CRIT_BATTERED_BONUS * get_damage() / max(max_damage, 1)) + owner.hv_crit_helpless_bonus(attacker) + var/bonus = wound_bonus + round(HV_CRIT_BATTERED_BONUS * get_damage() / max(max_damage, 1)) + owner.hv_crit_helpless_bonus(attacker) + owner.hv_crit_situation_bonus(attacker, source, src) return check_woundings_mods(wounding_type, base_roll, damage, bonus, exposed_wound_bonus, FALSE) // --------------------------------------------------------------------------------------------- @@ -304,7 +310,7 @@ GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) if(damage < crit.min_damage) continue if(isnull(roll)) - roll = isnull(forced_roll) ? limb.hv_crit_roll(wounding_type, damage, wound_bonus, exposed_wound_bonus, attacker) : forced_roll + roll = isnull(forced_roll) ? limb.hv_crit_roll(wounding_type, damage, wound_bonus, exposed_wound_bonus, attacker, source) : forced_roll if(roll < crit.threshold) continue if(!crit.apply_carbon(src, limb, damage, attack_direction, source, attacker)) diff --git a/voidcrew/modules/stealth/stealth_ai.dm b/voidcrew/modules/stealth/stealth_ai.dm index d628eb8557d..fc7af3be449 100644 --- a/voidcrew/modules/stealth/stealth_ai.dm +++ b/voidcrew/modules/stealth/stealth_ai.dm @@ -297,6 +297,8 @@ var/old_state = blackboard[BB_STEALTH_STATE] if(old_state == STEALTH_UNAWARE && new_state != STEALTH_UNAWARE) blackboard[BB_STEALTH_UNAWARE_LOST_AT] = world.time + if(old_state != new_state) + blackboard[BB_STEALTH_PREV_STATE] = old_state blackboard[BB_STEALTH_STATE] = new_state blackboard[BB_STEALTH_STATE_SINCE] = world.time // Only an alert guard goes after a locker it saw someone get into (decision 33). @@ -1291,6 +1293,21 @@ return TRUE return stealth_ai.blackboard[BB_STEALTH_UNAWARE_LOST_AT] == world.time +/** + * The state this guard was in when the current blow began (step 3a): unaware if stealth_was_unaware(), else the state + * before a change made this same tick (a bullet's prehit alerts it before the damage lands), else its state now. + * Null for any NPC without the stealth controller. + */ +/mob/living/carbon/human/npc/proc/stealth_state_at_blow() + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = ai_controller + if(!istype(stealth_ai)) + return null + if(stealth_was_unaware()) + return STEALTH_UNAWARE + if(stealth_ai.blackboard[BB_STEALTH_STATE_SINCE] == world.time && !isnull(stealth_ai.blackboard[BB_STEALTH_PREV_STATE])) + return stealth_ai.blackboard[BB_STEALTH_PREV_STATE] + return stealth_ai.stealth_state() + // ===== Planning ===== /// Pathfinding movement with room for a long patrol leg. diff --git a/voidcrew/modules/stealth/stealth_crits.dm b/voidcrew/modules/stealth/stealth_crits.dm new file mode 100644 index 00000000000..f40e86720f4 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_crits.dm @@ -0,0 +1,122 @@ +/** + * # Stealth guards: crits on a guard caught unaware, and the head shot (step 3a, owner decisions 9, 16, 19) + * + * Nothing here deals damage of its own. A blow on a stealth guard that never saw it coming adds to hyperviolence's + * aimed crit roll (crit_roll.dm, hv_crit_situation_bonus()), so it only matters on an aimed hit that could crit + * anyway, it goes in before wound armour scales the roll, and every crit's own armour block still holds. + * + * - Who: a guard with the stealth controller, conscious, not held or cuffed, with no player in the body. Never a + * player (decision 19), never any other NPC. + * - When: by its state as the blow began (stealth_state_at_blow()). Unaware of everyone gets the full bonus; only + * suspicious, a smaller one; searching or alert, none. Searching gets none so that peek, shoot, hide and wait out + * the search can't farm it. + * - Head: aimed at the head, eyes or mouth, the bonus goes up again. + * - Backstab: the matte combat knife (stealth_knife.dm) adds STEALTH_BACKSTAB_BONUS from behind (tg's check_behind()), + * whatever the guard's state. + * + * hyperviolence had no head crit a bullet could cause, so a pistol shot to an unaware guard's head could do nothing a + * shot to an alert one couldn't. The head shot crit below is that crit, and only a stealth hit rolls it: everyone + * else's head shots (players on players included) are exactly as before. Numbers: data/stealth/STATUS.md, "Step 3a". + */ + +/// The roll bonus for this blow (STEALTH_CRIT_* and STEALTH_BACKSTAB_BONUS), or 0. No side effects. +/mob/living/carbon/human/npc/proc/stealth_crit_bonus(mob/living/attacker, atom/source, obj/item/bodypart/limb) + if(isnull(attacker) || attacker == src || client || stat != CONSCIOUS) + return 0 + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = ai_controller + if(!istype(stealth_ai) || stealth_ai.stealth_captive()) + return 0 + . = 0 + switch(stealth_state_at_blow()) + if(STEALTH_UNAWARE) + . = STEALTH_CRIT_UNAWARE_BONUS + if(STEALTH_SUSPICIOUS) + . = STEALTH_CRIT_SUSPICIOUS_BONUS + if(. && limb?.body_zone == BODY_ZONE_HEAD) + . += STEALTH_CRIT_HEAD_BONUS + if(istype(source, /obj/item/knife/combat/matte) && check_behind(attacker, src)) + . += STEALTH_BACKSTAB_BONUS + +/mob/living/carbon/human/npc/hv_crit_situation_bonus(mob/living/attacker, atom/source, obj/item/bodypart/limb) + . = ..() + var/bonus = stealth_crit_bonus(attacker, source, limb) + if(!bonus) + return . + log_combat(attacker, src, "struck a [stealth_state_at_blow()] stealth guard", source, "(crit roll +[bonus], [limb?.body_zone])") + return . + bonus + +// --------------------------------------------------------------------------------------------- +// Shot through the head: a bullet aimed at the head of a guard who didn't see it coming. A good enough helmet stops it. + +/datum/hv_crit/head_shot + name = "shot through the head" + limb_zone = BODY_ZONE_HEAD + aim_zones = list(BODY_ZONE_HEAD, BODY_ZONE_PRECISE_EYES, BODY_ZONE_PRECISE_MOUTH) + wounding_types = list(WOUND_PIERCE) + min_damage = STEALTH_HEAD_SHOT_MIN_DAMAGE + threshold = STEALTH_HEAD_SHOT_THRESHOLD + allow_projectiles = TRUE + wound_type = /datum/wound/pierce/bleed/critical/hv_head_shot + line_severity = HV_LINE_HUGE + line_range = DEFAULT_MESSAGE_RANGE + hit_sound = 'sound/effects/wounds/pierce3.ogg' + shake = 6 + +/datum/hv_crit/head_shot/matches(hit_zone, aimed_zone, wounding_type, from_projectile) + return from_projectile && ..() + +/datum/hv_crit/head_shot/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + if(!ishumannpc(victim) || !isprojectile(source)) + return TRUE + var/mob/living/carbon/human/npc/guard = victim + var/obj/projectile/shot = source + if(!isliving(shot.firer) || guard.stealth_crit_bonus(shot.firer, source, limb) <= 0) + return TRUE + return hv_crit_cover_rating(victim, limb, hv_crit_attack_flag(source), hv_crit_armour_pen(source)) >= STEALTH_HEAD_SHOT_HELMET_ARMOUR + +/datum/hv_crit/head_shot/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + if(!hv_crit_apply_wound(limb, wound_type, attack_direction, source)) + return FALSE + victim.hv_blood_spray(attack_direction, 2) + announce(victim, attack_direction) + return TRUE + +/datum/hv_crit/head_shot/hit_line(mob/living/victim) + return "[victim] is shot through the head!" + +/datum/hv_crit/head_shot/self_line(mob/living/victim) + return "Something punches through your skull!" + +/** + * tg's ruptured cavity on the head, with a knock to the brain: brain damage, a knockdown, confusion and blurred eyes. + * Treated like any critical puncture (gauze, sutures, cautery, surgery); the brain damage heals like any other. + */ +/datum/wound/pierce/bleed/critical/hv_head_shot + name = "Penetrating Head Wound" + undiagnosed_name = "Head Wound" + desc = "Patient has been shot through the skull. The wound is bleeding heavily and the brain is injured." + simple_desc = "A bullet has gone through the skull." + examine_desc = "has a bloody hole punched through it" + occur_text = "is punched through" + +/datum/wound/pierce/bleed/critical/hv_head_shot/wound_injury(datum/wound/old_wound = null, attack_direction = null) + . = ..() + if(!victim) + return + victim.adjustOrganLoss(ORGAN_SLOT_BRAIN, STEALTH_HEAD_SHOT_BRAIN_DAMAGE) + if(victim.stat == DEAD) + return + victim.Knockdown(STEALTH_HEAD_SHOT_KNOCKDOWN) + victim.set_confusion_if_lower(15 SECONDS) + victim.adjust_eye_blur(10 SECONDS) + +/datum/wound_pregen_data/flesh_pierce/hv_head_shot + abstract = FALSE + wound_path_to_generate = /datum/wound/pierce/bleed/critical/hv_head_shot + required_wounding_types = list(WOUND_PIERCE) + wound_series = WOUND_SERIES_HV_HEAD_SHOT + required_limb_biostate = (BIO_FLESH|BIO_BLOODED) + ignore_cannot_bleed = FALSE + viable_zones = list(BODY_ZONE_HEAD) + can_be_randomly_generated = FALSE + threshold_minimum = 100 diff --git a/voidcrew/modules/stealth/stealth_knife.dm b/voidcrew/modules/stealth/stealth_knife.dm new file mode 100644 index 00000000000..83161cb34d5 --- /dev/null +++ b/voidcrew/modules/stealth/stealth_knife.dm @@ -0,0 +1,13 @@ +/** + * # The matte combat knife (step 3a, owner decisions 5 and 12) + * + * tg's combat knife with a blackened blade. Its one difference: struck into a stealth guard from behind (tg's + * check_behind(), the heretic blade's backstab test), it adds STEALTH_BACKSTAB_BONUS to the aimed crit roll + * (stealth_crits.dm). On anyone else, players included, it is a plain combat knife. No new attack or animation. + * + * Players get it as scav den loot (step 4). It has no buy, print or craft route. + */ +/obj/item/knife/combat/matte + name = "matte combat knife" + desc = "A combat knife with a blackened blade that doesn't catch the light." + color = "#6E6E7D" diff --git a/voidcrew/modules/stealth/stealth_light.dm b/voidcrew/modules/stealth/stealth_light.dm index 58171b43193..745db2f5018 100644 --- a/voidcrew/modules/stealth/stealth_light.dm +++ b/voidcrew/modules/stealth/stealth_light.dm @@ -14,8 +14,9 @@ * Beating it is tg's: you see the beam coming; an EMP or the saboteur gun cuts it out for a while (the * scav seclite takes an EMP as tg's saboteur hit; a stock seclite ignores EMPs); a guard that is down, * stunned, cuffed, asleep or dead doesn't touch it. A light that is gone from its body (deleted, burnt, - * forced off) is gone: the guard goes without. Like the rest of its kit it is locked gear (human NPC - * decision D3), so it can't be stripped or looted. + * forced off) is gone: the guard goes without. Unlike the rest of its kit it is not locked gear (decision 34 + * amends human NPC decision D3): it can be stripped, knocked loose and looted like anyone's seclite. Once it has left + * a guard's body it is an ordinary seclite to the guards too (off the guard light list). */ /// Every guard's seclite, so a guard can leave them all out when it judges how dark its spot is @@ -32,6 +33,18 @@ GLOBAL_LIST_EMPTY(stealth_guard_lights) GLOB.stealth_guard_lights -= src return ..() +/// Off a guard's body (looted, stripped, dropped), it is just a seclite: no guard leaves its light out any more. +/obj/item/flashlight/seclite/scav/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) + . = ..() + if(!ishumannpc(loc)) + GLOB.stealth_guard_lights -= src + +/// Decision 34: a guard's seclite is loot like anyone's, so it is never locked to the body (D3 holds for the rest). +/mob/living/carbon/human/npc/npc_lock_item(obj/item/item) + if(istype(item, /obj/item/flashlight/seclite/scav)) + return FALSE + return ..() + /obj/item/flashlight/seclite/scav/emp_act(severity) . = ..() if(. & EMP_PROTECT_SELF) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 0d2998b9e90..f3835654a04 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -236,6 +236,7 @@ #include "voidcrew_smart_locker.dm" #include "voidcrew_stealth.dm" #include "voidcrew_stealth_closet.dm" +#include "voidcrew_stealth_crits.dm" #include "voidcrew_stealth_d32.dm" #include "voidcrew_stealth_hearing.dm" #include "voidcrew_stealth_light.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_stealth_crits.dm b/voidcrew/modules/unit_tests/voidcrew_stealth_crits.dm new file mode 100644 index 00000000000..28ba80d29f8 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_stealth_crits.dm @@ -0,0 +1,186 @@ +// Stealth crits, the head shot, the backstab knife and lootable seclites (step 3a, voidcrew/modules/stealth/stealth_crits.dm, +// stealth_knife.dm, stealth_light.dm). Values from voidcrew/_DEFINES are written out: STEALTH_CRIT_UNAWARE_BONUS 30, +// STEALTH_CRIT_SUSPICIOUS_BONUS 15, STEALTH_CRIT_HEAD_BONUS 35, STEALTH_BACKSTAB_BONUS 40, STEALTH_HEAD_SHOT_BRAIN_DAMAGE 40. + +/// The bonus by state: full when unaware, smaller when suspicious, none when searching or alert, more on the head; +/// never on a player or an NPC without the stealth controller. +/datum/unit_test/voidcrew_stealth_crit_bonus + +/datum/unit_test/voidcrew_stealth_crit_bonus/Run() + var/datum/turf_reservation/block = stealth_block(8, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 4, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/mob/living/carbon/human/person = stealth_person(block, 5, 2) + var/obj/item/knife/combat/knife = allocate(/obj/item/knife/combat) + person.put_in_active_hand(knife) + var/obj/item/bodypart/head = guard.get_bodypart(BODY_ZONE_HEAD) + var/obj/item/bodypart/chest = guard.get_bodypart(BODY_ZONE_CHEST) + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, chest), 30, "Wrong bonus on an unaware guard") + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, head), 65, "Wrong bonus on an unaware guard's head") + TEST_ASSERT_EQUAL(guard.hv_crit_situation_bonus(person, knife, head), 65, "The crit roll doesn't get the stealth bonus") + stealth_ai.stealth_go_suspicious(stealth_at(block, 1, 2)) + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, chest), 30, "A guard that turned suspicious this tick lost the unaware bonus") + stealth_ai.stealth_rewind(1) + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, chest), 15, "Wrong bonus on a suspicious guard") + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, head), 50, "Wrong bonus on a suspicious guard's head") + // The blow that alerts it still counts as one on a suspicious guard (a bullet's prehit alerts it first). + stealth_ai.stealth_go_alert(person) + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, chest), 15, "The blow that alerted a suspicious guard lost its bonus") + stealth_ai.stealth_rewind(1) + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, head), 0, "An alert guard gives a stealth bonus") + stealth_ai.stealth_go_searching(stealth_at(block, 1, 2)) + stealth_ai.stealth_rewind(1) + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, head), 0, "A searching guard gives a stealth bonus") + // Unaware, but down or held: the helpless bonus is the one that counts there. + stealth_ai.stealth_go_unaware() + stealth_ai.stealth_rewind(1) + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, chest), 30, "Back to unaware, no bonus") + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(guard.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the guard can't be cuffed") + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, chest), 0, "A cuffed guard gives a stealth bonus") + // Never on a player, nor on an NPC without the stealth controller. + var/mob/living/carbon/human/player = stealth_person(block, 2, 4) + TEST_ASSERT_EQUAL(player.hv_crit_situation_bonus(person, knife, player.get_bodypart(BODY_ZONE_HEAD)), 0, "A person gets a stealth bonus") + var/mob/living/carbon/human/npc/plain = allocate(/mob/living/carbon/human/npc/ruin/trooper/syndicate, stealth_at(block, 1, 4)) + TEST_ASSERT_EQUAL(plain.stealth_crit_bonus(person, knife, plain.get_bodypart(BODY_ZONE_HEAD)), 0, "An NPC without stealth gets a stealth bonus") + qdel(block) + +/// The matte knife adds its bonus from behind only, on an alert guard too; a plain knife never does. +/datum/unit_test/voidcrew_stealth_backstab + +/datum/unit_test/voidcrew_stealth_backstab/Run() + var/datum/turf_reservation/block = stealth_block(8, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 4, 2) // facing east + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/mob/living/carbon/human/person = stealth_person(block, 3, 2) // behind it + var/obj/item/knife/combat/matte/knife = allocate(/obj/item/knife/combat/matte) + var/obj/item/knife/combat/blade = allocate(/obj/item/knife/combat) + person.put_in_active_hand(knife) + var/obj/item/bodypart/head = guard.get_bodypart(BODY_ZONE_HEAD) + var/obj/item/bodypart/chest = guard.get_bodypart(BODY_ZONE_CHEST) + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, head), 105, "Wrong bonus for a backstab to an unaware guard's head") + stealth_ai.stealth_go_alert(person) + stealth_ai.stealth_rewind(1) + guard.setDir(EAST) + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, chest), 40, "Wrong backstab bonus on an alert guard") + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, blade, chest), 0, "A plain knife backstabs") + // From the front and from the side: nothing. + person.forceMove(stealth_at(block, 5, 2)) + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, chest), 0, "The knife backstabs from the front") + person.forceMove(stealth_at(block, 4, 3)) + TEST_ASSERT_EQUAL(guard.stealth_crit_bonus(person, knife, chest), 0, "The knife backstabs from the side") + // A player from behind: a plain knife. + var/mob/living/carbon/human/player = stealth_person(block, 6, 4) + player.setDir(EAST) + person.forceMove(stealth_at(block, 5, 4)) + TEST_ASSERT_EQUAL(player.hv_crit_situation_bonus(person, knife, player.get_bodypart(BODY_ZONE_CHEST)), 0, "The knife backstabs a person") + qdel(block) + +/// Wound armour scales the bonus with the rest of the roll, and a helmet stops the head shot outright. +/datum/unit_test/voidcrew_stealth_crit_armour + +/datum/unit_test/voidcrew_stealth_crit_armour/Run() + var/datum/turf_reservation/block = stealth_block(8, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 4, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/mob/living/carbon/human/person = stealth_person(block, 6, 2) + var/obj/item/knife/combat/knife = allocate(/obj/item/knife/combat) + person.put_in_active_hand(knife) + var/obj/item/bodypart/head = guard.get_bodypart(BODY_ZONE_HEAD) + if(guard.head) + qdel(guard.head) + // 1 damage makes tg's own roll 1, so the rest of the roll is exact. + var/bare = head.hv_crit_roll(WOUND_SLASH, 1, 0, 0, person, knife) + TEST_ASSERT(bare > 55, "An unaware bare head rolled only [bare]") + var/obj/item/clothing/head/helmet/swat/helmet = new(guard) + TEST_ASSERT(guard.equip_to_slot_if_possible(helmet, ITEM_SLOT_HEAD, disable_warning = TRUE), "Couldn't put a helmet on the guard") + var/armoured = head.hv_crit_roll(WOUND_SLASH, 1, 0, 0, person, knife) + TEST_ASSERT(armoured < bare, "A helmet didn't lower the stealth roll ([armoured] vs [bare])") + // The head shot: a 9mm through a SWAT helmet is stopped; with the helmet off it isn't. + var/datum/hv_crit/head_shot/crit = locate() in GLOB.hv_crits + TEST_ASSERT_NOTNULL(crit, "No head shot crit") + var/obj/projectile/bullet/c9mm/shot = allocate(/obj/projectile/bullet/c9mm) + shot.firer = person + TEST_ASSERT(crit.blocked_on_carbon(guard, head, shot), "A SWAT helmet doesn't stop a 9mm head shot") + qdel(helmet) + TEST_ASSERT(!crit.blocked_on_carbon(guard, head, shot), "A bare-headed unaware guard is safe from a head shot") + // Alert: no stealth hit, so no head shot. + stealth_ai.stealth_go_alert(person) + stealth_ai.stealth_rewind(1) + TEST_ASSERT(crit.blocked_on_carbon(guard, head, shot), "An alert guard can be head shot") + var/roll = head.hv_crit_roll(WOUND_SLASH, 1, 0, 0, person, knife) + TEST_ASSERT(roll < 5, "An alert guard's roll still has a bonus ([roll])") + qdel(block) + +/// A head shot lands on an unaware guard and does real harm through tg's wound; never on a person or an alert guard. +/datum/unit_test/voidcrew_stealth_head_shot + +/datum/unit_test/voidcrew_stealth_head_shot/Run() + var/datum/turf_reservation/block = stealth_block(8, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 4, 2) + var/mob/living/carbon/human/person = stealth_person(block, 1, 2) + var/obj/projectile/bullet/c9mm/shot = allocate(/obj/projectile/bullet/c9mm) + shot.firer = person + if(guard.head) + qdel(guard.head) + var/obj/item/bodypart/head = guard.get_bodypart(BODY_ZONE_HEAD) + var/datum/hv_crit/crit = guard.hv_try_aimed_crit(head, BODY_ZONE_HEAD, WOUND_PIERCE, 30, 0, 0, EAST, shot, person, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/head_shot), "A forced head shot on an unaware guard didn't land (got [crit])") + TEST_ASSERT_NOTNULL(head.get_wound_type(/datum/wound/pierce/bleed/critical/hv_head_shot), "No head shot wound") + TEST_ASSERT(guard.get_organ_loss(ORGAN_SLOT_BRAIN) >= 40, "The head shot did no brain damage") + TEST_ASSERT(guard.IsKnockdown(), "The head shot didn't knock the guard down") + // Not from a knife. + var/mob/living/carbon/human/npc/second = stealth_guard(block, 6, 3) + if(second.head) + qdel(second.head) + var/obj/item/knife/combat/knife = allocate(/obj/item/knife/combat) + crit = second.hv_try_aimed_crit(second.get_bodypart(BODY_ZONE_HEAD), BODY_ZONE_HEAD, WOUND_PIERCE, 30, 0, 0, EAST, knife, person, 500) + TEST_ASSERT(!istype(crit, /datum/hv_crit/head_shot), "A knife caused a head shot") + // Never on a person. + var/mob/living/carbon/human/player = stealth_person(block, 2, 4) + crit = player.hv_try_aimed_crit(player.get_bodypart(BODY_ZONE_HEAD), BODY_ZONE_HEAD, WOUND_PIERCE, 30, 0, 0, EAST, shot, person, 500) + TEST_ASSERT_NULL(crit, "A person took a head shot crit") + // Never on an alert guard. + var/mob/living/carbon/human/npc/third = stealth_guard(block, 1, 4) + if(third.head) + qdel(third.head) + var/datum/ai_controller/basic_controller/human_npc/stealth/third_ai = stealth_ai(third) + third_ai.stealth_go_alert(person) + third_ai.stealth_rewind(1) + crit = third.hv_try_aimed_crit(third.get_bodypart(BODY_ZONE_HEAD), BODY_ZONE_HEAD, WOUND_PIERCE, 30, 0, 0, EAST, shot, person, 500) + TEST_ASSERT_NULL(crit, "An alert guard took a head shot crit") + qdel(block) + +/// Decision 34: the seclite strips and drops like anyone's; the rest of the kit stays locked; the guard carries on without it. +/datum/unit_test/voidcrew_stealth_seclite_loot + +/datum/unit_test/voidcrew_stealth_seclite_loot/Run() + var/datum/turf_reservation/block = stealth_block(8, 5) + TEST_ASSERT_NOTNULL(block, "No room for the test") + var/mob/living/carbon/human/npc/guard = stealth_guard(block, 4, 2) + var/datum/ai_controller/basic_controller/human_npc/stealth/stealth_ai = stealth_ai(guard) + var/obj/item/flashlight/light = stealth_ai.stealth_light() + TEST_ASSERT_NOTNULL(light, "A scav guard has no flashlight") + TEST_ASSERT(!HAS_TRAIT(light, TRAIT_NO_STRIP), "The seclite can't be stripped") + TEST_ASSERT(!human_npc_gear_locked(light), "The seclite is locked gear") + TEST_ASSERT(human_npc_gear_locked(guard.w_uniform), "The rest of the guard's kit isn't locked any more") + TEST_ASSERT(light in GLOB.stealth_guard_lights, "The seclite isn't on the guard light list") + var/mob/living/carbon/human/person = stealth_person(block, 3, 2) + TEST_ASSERT(guard.dropItemToGround(light), "The seclite can't be taken off") + TEST_ASSERT(person.put_in_hands(light), "The seclite can't be picked up") + TEST_ASSERT(!QDELETED(light), "The looted seclite was deleted") + TEST_ASSERT(!(light in GLOB.stealth_guard_lights), "A looted seclite still counts as a guard's") + // The guard goes without: no light to switch, and it still scans and sees. + TEST_ASSERT_NULL(stealth_ai.stealth_light(), "The guard still counts the light it lost") + GLOB.stealth_test_dark_turfs[get_turf(guard)] = TRUE + stealth_ai.stealth_update_light() + TEST_ASSERT(!light.light_on, "The guard switched on a light a player holds") + GLOB.stealth_test_dark_turfs[get_turf(guard)] = FALSE + person.forceMove(stealth_at(block, 5, 2)) + stealth_scans(guard, 1) + TEST_ASSERT_EQUAL(stealth_ai.stealth_state(), "alert", "A guard without its light didn't see someone in front of it") + qdel(block) From a786485b361c021fc5b9f6b827305113d5ecb213 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:05:44 -0800 Subject: [PATCH 022/152] Outpost police: seams, stubs and turret removal Adds the outpost police module skeleton (defines, the police datum with its tick, roster and interior link, shared datums, officer types and brain, the station's map kit types) with every cross-package proc stubbed. Trader outpost crimes now go to the police with a crime kind and the item used; the strike ladder, armed-trespass state and the outpost turrets (38 on the three maps) are removed. Tests that no longer compile are guarded for porting. --- tgstation.dme | 14 + voidcrew/_DEFINES/ambient_npcs.dm | 2 +- voidcrew/_DEFINES/outpost_police.dm | 207 ++++++++ voidcrew/_DEFINES/trade.dm | 19 +- voidcrew/_HELPERS/hostile_creatures.dm | 6 +- .../outposts/trader_outpost_black_market.dmm | 9 - .../outposts/trader_outpost_general.dmm | 17 - .../outposts/trader_outpost_outfitter.dmm | 19 +- voidcrew/mapping/shuttles/police_cutter.dm | 4 + .../modules/admin/overmap_management_bans.dm | 59 +-- .../modules/ambient_npcs/ambient_activity.dm | 2 +- voidcrew/modules/ambient_npcs/ambient_npc.dm | 6 +- .../modules/ambient_npcs/ambient_subsystem.dm | 6 +- .../ambient_npcs/strings/outpost_police.json | 5 + voidcrew/modules/bounties/bounty_outpost.dm | 2 +- .../modules/cyberware/ware_line_widowline.dm | 3 - .../modules/cyberware/ware_pro_utility.dm | 6 - .../modules/outpost_police/police_arrest.dm | 25 + .../modules/outpost_police/police_cell.dm | 26 + .../modules/outpost_police/police_core.dm | 452 ++++++++++++++++++ .../modules/outpost_police/police_cutter.dm | 13 + .../modules/outpost_police/police_dispatch.dm | 29 ++ .../modules/outpost_police/police_force.dm | 29 ++ voidcrew/modules/outpost_police/police_hud.dm | 12 + .../modules/outpost_police/police_life.dm | 28 ++ .../outpost_police/police_perception.dm | 37 ++ .../modules/outpost_police/police_property.dm | 57 +++ .../modules/outpost_police/police_units.dm | 50 ++ .../modules/outpost_police/police_wanted.dm | 83 ++++ .../overmap/code/modules/overmap/_overmap.dm | 5 +- .../outpost_prison_security.dm | 7 +- voidcrew/modules/trade/outpost.dm | 157 ++---- voidcrew/modules/trade/outpost_checkpoint.dm | 36 +- voidcrew/modules/trade/outpost_security.dm | 182 +------ voidcrew/modules/trade/outpost_trespass.dm | 68 +-- voidcrew/modules/trade/trader_npc.dm | 7 +- voidcrew/modules/unit_tests/_unit_tests.dm | 11 +- .../unit_tests/voidcrew_ambient_outposts.dm | 6 + .../unit_tests/voidcrew_bounty_kingpin.dm | 6 + .../unit_tests/voidcrew_bounty_outpost.dm | 6 + .../unit_tests/voidcrew_cyberware_camo.dm | 3 + .../voidcrew_outpost_police_arrest.dm | 5 + .../voidcrew_outpost_police_cell.dm | 5 + .../voidcrew_outpost_police_cutter.dm | 5 + .../voidcrew_outpost_police_dispatch.dm | 5 + .../voidcrew_outpost_police_helpers.dm | 65 +++ .../unit_tests/voidcrew_outpost_police_hud.dm | 5 + .../voidcrew_outpost_police_life.dm | 5 + .../voidcrew_outpost_police_property.dm | 5 + .../voidcrew_outpost_police_units.dm | 5 + .../voidcrew_outpost_police_wanted.dm | 5 + .../voidcrew_outpost_self_defense.dm | 3 + .../voidcrew_outpost_turret_targeting.dm | 98 ---- .../voidcrew_overmap_management_bans.dm | 3 + .../unit_tests/voidcrew_trader_checkpoint.dm | 9 + 55 files changed, 1379 insertions(+), 565 deletions(-) create mode 100644 voidcrew/_DEFINES/outpost_police.dm create mode 100644 voidcrew/mapping/shuttles/police_cutter.dm create mode 100644 voidcrew/modules/ambient_npcs/strings/outpost_police.json create mode 100644 voidcrew/modules/outpost_police/police_arrest.dm create mode 100644 voidcrew/modules/outpost_police/police_cell.dm create mode 100644 voidcrew/modules/outpost_police/police_core.dm create mode 100644 voidcrew/modules/outpost_police/police_cutter.dm create mode 100644 voidcrew/modules/outpost_police/police_dispatch.dm create mode 100644 voidcrew/modules/outpost_police/police_force.dm create mode 100644 voidcrew/modules/outpost_police/police_hud.dm create mode 100644 voidcrew/modules/outpost_police/police_life.dm create mode 100644 voidcrew/modules/outpost_police/police_perception.dm create mode 100644 voidcrew/modules/outpost_police/police_property.dm create mode 100644 voidcrew/modules/outpost_police/police_units.dm create mode 100644 voidcrew/modules/outpost_police/police_wanted.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_outpost_police_arrest.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_outpost_police_cell.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_outpost_police_cutter.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_outpost_police_dispatch.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_outpost_police_helpers.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_outpost_police_hud.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_outpost_police_life.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_outpost_police_property.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_outpost_police_units.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_outpost_police_wanted.dm delete mode 100644 voidcrew/modules/unit_tests/voidcrew_outpost_turret_targeting.dm diff --git a/tgstation.dme b/tgstation.dme index 1f755f76e64..05da8141927 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6720,6 +6720,7 @@ #include "voidcrew\_DEFINES\outpost_market_shop.dm" #include "voidcrew\_DEFINES\outpost_market_storage.dm" #include "voidcrew\_DEFINES\outpost_market_teleporter.dm" +#include "voidcrew\_DEFINES\outpost_police.dm" #include "voidcrew\_DEFINES\outpost_prison_boss_moves.dm" #include "voidcrew\_DEFINES\outpost_prison_bounty.dm" #include "voidcrew\_DEFINES\outpost_prison_changeling.dm" @@ -7023,6 +7024,7 @@ #include "voidcrew\mapping\shuttles\pirate_irs.dm" #include "voidcrew\mapping\shuttles\pirate_medieval.dm" #include "voidcrew\mapping\shuttles\pirate_silverscale.dm" +#include "voidcrew\mapping\shuttles\police_cutter.dm" #include "voidcrew\mapping\shuttles\pubby.dm" #include "voidcrew\mapping\shuttles\scarab.dm" #include "voidcrew\mapping\shuttles\spitfire.dm" @@ -7564,6 +7566,18 @@ #include "voidcrew\modules\outfits\pirate_factions.dm" #include "voidcrew\modules\outfits\syndicate.dm" #include "voidcrew\modules\outfits\western.dm" +#include "voidcrew\modules\outpost_police\police_arrest.dm" +#include "voidcrew\modules\outpost_police\police_cell.dm" +#include "voidcrew\modules\outpost_police\police_core.dm" +#include "voidcrew\modules\outpost_police\police_cutter.dm" +#include "voidcrew\modules\outpost_police\police_dispatch.dm" +#include "voidcrew\modules\outpost_police\police_force.dm" +#include "voidcrew\modules\outpost_police\police_hud.dm" +#include "voidcrew\modules\outpost_police\police_life.dm" +#include "voidcrew\modules\outpost_police\police_perception.dm" +#include "voidcrew\modules\outpost_police\police_property.dm" +#include "voidcrew\modules\outpost_police\police_units.dm" +#include "voidcrew\modules\outpost_police\police_wanted.dm" #include "voidcrew\modules\overmap\code\controllers\subsystem\overmap.dm" #include "voidcrew\modules\overmap\code\controllers\subsystem\shuttle.dm" #include "voidcrew\modules\overmap\code\controllers\subsystem\worldgen_queue.dm" diff --git a/voidcrew/_DEFINES/ambient_npcs.dm b/voidcrew/_DEFINES/ambient_npcs.dm index 9500e8aa4c0..f936e0febde 100644 --- a/voidcrew/_DEFINES/ambient_npcs.dm +++ b/voidcrew/_DEFINES/ambient_npcs.dm @@ -38,7 +38,7 @@ #define AMBIENT_PAUSED_TRAIT "ambient_paused" /** * A killed outpost NPC's place in their role stays empty this long, made in place or off the lift. - * Longer than the outpost's mark on the killer (OUTPOST_AGGRESSION_MARK_DURATION, 15 minutes) and + * Longer than the outpost police's bar on a killer (OUTPOST_POLICE_BAR_TIME, 15 minutes) and * well past any one visit (a customer stays 4 to 8), so killing never brings anyone sooner than * leaving would: at most three refills of one place an hour, whatever the killer does. */ diff --git a/voidcrew/_DEFINES/outpost_police.dm b/voidcrew/_DEFINES/outpost_police.dm new file mode 100644 index 00000000000..995a906a056 --- /dev/null +++ b/voidcrew/_DEFINES/outpost_police.dm @@ -0,0 +1,207 @@ +// Outpost police (voidcrew/modules/outpost_police/). Owner: P0 seams, frozen: packages never edit this file. +// Unit tests are compiled before this file, so a test uses the literal value with a comment naming the define. + +// ===== WANTED LEVEL ===== + +/// Heat at which each star is reached, from one to five +#define OUTPOST_POLICE_STAR_HEAT list(10, 25, 50, 90, 140) +/// Heat never goes above this +#define OUTPOST_POLICE_HEAT_CAP 180 + +// ===== CRIMES ===== +// Keys of GLOB.outpost_crimes (police_core.dm): crime -> list(points, ceiling, floor), ceiling and floor in stars. +// None of these strings is the name of a report_crime() or register_aggression() argument (traps: dm-language.md). + +#define OUTPOST_CRIME_ASSAULT_UNARMED "unarmed" +#define OUTPOST_CRIME_ASSAULT_ARMED "armed" +#define OUTPOST_CRIME_ASSAULT_SHOT "shot" +#define OUTPOST_CRIME_MURDER "murder" +#define OUTPOST_CRIME_VANDALISM "vandalism" +#define OUTPOST_CRIME_ASSAULT_STAFF "staff" +#define OUTPOST_CRIME_ASSAULT_CIVILIAN "civilian" +#define OUTPOST_CRIME_KILL_CIVILIAN "civilian_kill" +#define OUTPOST_CRIME_WEAPON_DRAWN "weapon_drawn" +#define OUTPOST_CRIME_GUNFIRE "gunfire" +#define OUTPOST_CRIME_CHECKPOINT_BREACH "breach" +#define OUTPOST_CRIME_TRESPASS "trespass" +#define OUTPOST_CRIME_RESIST "resist" +#define OUTPOST_CRIME_ASSAULT_OFFICER "officer" +#define OUTPOST_CRIME_KILL_OFFICER "officer_kill" +#define OUTPOST_CRIME_SLIP_CUFFS "slip_cuffs" +#define OUTPOST_CRIME_ABET "abet" +#define OUTPOST_CRIME_JAILBREAK "jailbreak" +#define OUTPOST_CRIME_ACCOMPLICE "accomplice" +#define OUTPOST_CRIME_LOCKER_THEFT "locker_theft" + +// ===== WITNESSES (police_perception.dm) ===== + +/// What /datum/outpost_police_perception/proc/witness_of() returns +#define POLICE_WITNESS_NONE 0 +#define POLICE_WITNESS_SAW 1 +#define POLICE_WITNESS_HEARD 2 +#define POLICE_WITNESS_CAMERA 3 + +/// How far a witness sees a crime +#define OUTPOST_POLICE_WITNESS_RANGE 9 +/// A death this soon after the offender's last counted hit on the victim is a killing +#define OUTPOST_POLICE_KILL_WINDOW (30 SECONDS) + +// ===== FORCE ===== + +/// What /datum/outpost_police/proc/force_level() returns, and an officer's `force` +#define POLICE_FORCE_NONE 0 +#define POLICE_FORCE_ARREST 1 +#define POLICE_FORCE_STUN 2 +#define POLICE_FORCE_LETHAL 3 + +// ===== UNITS AND SECTORS ===== + +#define POLICE_UNIT_BEAT "beat" +#define POLICE_UNIT_SERGEANT "sergeant" +#define POLICE_UNIT_OFFICER "officer" +#define POLICE_UNIT_TACTICAL "tactical" +#define POLICE_UNIT_MARSHAL "marshal" + +/// Where an officer or suspect is (sector_of()). A hangar's sector is its /datum/outpost_berth itself. +#define POLICE_SECTOR_CONCOURSE "concourse" +#define POLICE_SECTOR_LOBBY "lobby" +#define POLICE_SECTOR_OUTSIDE "outside" + +// ===== FADING AND RECORD ===== + +/// Hidden from every officer this long at one star before heat starts to drain +#define OUTPOST_POLICE_HIDE_1 (30 SECONDS) +/// The same at two stars +#define OUTPOST_POLICE_HIDE_2 (45 SECONDS) +/// Away from the outpost this long before heat starts to drain +#define OUTPOST_POLICE_AWAY_GRACE (10 SECONDS) +/// Heat lost per second while fading +#define OUTPOST_POLICE_FADE_PER_SECOND 1 +/// A faded warrant leaves a prior this long +#define OUTPOST_POLICE_PRIOR_TIME (20 MINUTES) +/// A faded warrant of three stars or more bars its holder this long +#define OUTPOST_POLICE_BAR_TIME (15 MINUTES) + +// ===== RESPONSE ===== + +/// Officers on the scene per star level +#define OUTPOST_POLICE_UNITS list(2, 3, 4, 7, 10) +/// Officers per wave per star level +#define OUTPOST_POLICE_WAVE list(1, 1, 2, 3, 4) +/// Time between waves per star level +#define OUTPOST_POLICE_CADENCE list(15 SECONDS, 15 SECONDS, 20 SECONDS, 15 SECONDS, 12 SECONDS) +/// Most living officers at one outpost +#define OUTPOST_POLICE_CAP_OUTPOST 12 +/// Most living officers in the world +#define OUTPOST_POLICE_CAP_WORLD 24 +/// Most officer bodies lying about at one outpost +#define OUTPOST_POLICE_CORPSE_CAP 8 +/// An officer's body lies this long +#define OUTPOST_POLICE_CORPSE_LIFETIME (3 MINUTES) + +// ===== ARREST, FINES, CELLS ===== + +/// Fine at one and two stars, before the zone multiplier +#define OUTPOST_POLICE_FINES list(300, 800) +/// Time in a cell after a paid fine, at one and two stars +#define OUTPOST_POLICE_HOLDS list(30 SECONDS, 60 SECONDS) +/// Jail term when the fine is not paid, at one and two stars (no debt afterwards) +#define OUTPOST_POLICE_JAIL list(3 MINUTES, 6 MINUTES) +/// A jailed prisoner's fine is tried again this often +#define OUTPOST_POLICE_BAIL_RETRY (30 SECONDS) +/// Searching one item off a suspect +#define OUTPOST_POLICE_SEARCH_ITEM (3 SECONDS) +/// A whole search takes no longer than this +#define OUTPOST_POLICE_SEARCH_MAX (9 SECONDS) +/// Stripping a prisoner's gear into a locker +#define OUTPOST_POLICE_STRIP_TIME (8 SECONDS) +/// Crime tools a warrant remembers +#define OUTPOST_POLICE_EVIDENCE_MAX 8 +/// Property lockers per station +#define OUTPOST_POLICE_LOCKERS 4 +/// A cell door held open for a prisoner in crit or dead, at most +#define OUTPOST_POLICE_CELL_MEDIC_OPEN (2 MINUTES) +/// A cell door stays open this long on release or when forced +#define OUTPOST_POLICE_CELL_OPEN (10 SECONDS) +/// Forcing a cell door takes this long +#define OUTPOST_POLICE_CELL_FORCE_TIME (12 SECONDS) +/// A prisoner carried out of a cell has this long to walk back in +#define OUTPOST_POLICE_CELL_RETURN (20 SECONDS) + +// ===== FOUR AND FIVE STARS ===== + +/// The cutter's fine at four and five stars +#define OUTPOST_POLICE_CUTTER_FINES list(6000, 15000) +/// Embargo length at four and five stars +#define OUTPOST_POLICE_EMBARGO list(15 MINUTES, 30 MINUTES) +/// Added to the embargo per officer killed +#define OUTPOST_POLICE_EMBARGO_PER_KILL (5 MINUTES) +/// The embargo never runs longer than this +#define OUTPOST_POLICE_EMBARGO_MAX (120 MINUTES) +/// The cutter gives up after this long without its quarry +#define OUTPOST_POLICE_CUTTER_GIVEUP (6 MINUTES) +/// Docked time at the wanted outpost not counted toward giving up, at most +#define OUTPOST_POLICE_CUTTER_LURK_MAX (20 MINUTES) + +// ===== LIFTS AND AI ===== + +/// A lift point whose unit has had no path this long is skipped +#define OUTPOST_POLICE_LIFT_BLOCKED (10 SECONDS) +/// ...for this long +#define OUTPOST_POLICE_LIFT_SKIP (2 MINUTES) +/// A unit built out of sight is at least this far from every player +#define OUTPOST_POLICE_UNSEEN_SPAWN_DIST 12 +/// A case step stuck this long goes back to the start +#define OUTPOST_POLICE_STEP_TIMEOUT (60 SECONDS) +/// An officer stands by a suspect in crit this long +#define OUTPOST_POLICE_CRIT_WAIT (45 SECONDS) +/// Standing in an officer's line of fire or pulling an officer this long is resisting +#define OUTPOST_POLICE_BLOCK_TIME (5 SECONDS) +/// The police tick stops after this long with nothing to do (no warrant, no response unit, no prisoner) +#define OUTPOST_POLICE_IDLE_STOP (30 SECONDS) + +// ===== WHAT OFFICERS SAY (JSON contexts in AMBIENT_STRINGS_POLICE) ===== + +#define POLICE_LINE_WARN "warn" +#define POLICE_LINE_ORDER "order" +#define POLICE_LINE_CHASE "chase" +#define POLICE_LINE_STUN "stun" +#define POLICE_LINE_SHOTS "shots" +#define POLICE_LINE_ALL_UNITS "all_units" +#define POLICE_LINE_MARSHAL "marshal" +#define POLICE_LINE_LOST "lost" +#define POLICE_LINE_CUFF "cuff" +#define POLICE_LINE_WALK "walk" +#define POLICE_LINE_MAKE_WAY "make_way" +#define POLICE_LINE_BOOK "book" +#define POLICE_LINE_RELEASE "release" +#define POLICE_LINE_MEDIC "medic" +#define POLICE_LINE_OFFICER_DOWN "officer_down" +#define POLICE_LINE_CLEAR "clear" +#define POLICE_LINE_KNOCK "knock" +#define POLICE_LINE_CELL_ALARM "cell_alarm" +#define POLICE_LINE_MOVE_ALONG "move_along" +#define POLICE_LINE_RADIO_CALL "radio_call" +#define POLICE_LINE_RADIO_ACK "radio_ack" +#define POLICE_LINE_SEARCH "search" +#define POLICE_LINE_EVIDENCE "evidence" +#define POLICE_LINE_STRIP "strip" +#define POLICE_LINE_BAIL "bail" +#define POLICE_LINE_LOCKER_THIEF "locker_thief" + +/// The officers' dialogue file in AMBIENT_STRINGS_DIR +#define AMBIENT_STRINGS_POLICE "outpost_police.json" + +/// Access on officers' IDs: police doors open for it +#define ACCESS_OUTPOST_POLICE "outpost_police" + +// ===== SIGNALS (all sent on the /datum/outpost_police) ===== + +/// A warrant's stars changed: (datum/mind/mind, old_stars, new_stars) +#define COMSIG_OUTPOST_POLICE_STARS "outpost_police_stars" +/// An officer went down: (mob/living/officer, mob/living/killer or null) +#define COMSIG_OUTPOST_POLICE_OFFICER_DOWN "outpost_police_officer_down" +/// A suspect was booked into a cell: (mob/living/suspect, jailed TRUE/FALSE) +#define COMSIG_OUTPOST_POLICE_BOOKED "outpost_police_booked" +/// A prisoner was let out: (mob/living/suspect) +#define COMSIG_OUTPOST_POLICE_RELEASED "outpost_police_released" diff --git a/voidcrew/_DEFINES/trade.dm b/voidcrew/_DEFINES/trade.dm index c02fd50c127..7377fc65a03 100644 --- a/voidcrew/_DEFINES/trade.dm +++ b/voidcrew/_DEFINES/trade.dm @@ -1,22 +1,13 @@ // Trade economy defines (vouchers, trader outposts) -/// How long an outpost trade embargo against a ship lasts after aggression +/// How long an outpost trade embargo against a ship lasts by default (admins set their own) #define OUTPOST_EMBARGO_DURATION (15 MINUTES) -/// How long a marked aggressor stays a turret target and is refused service. -/// Matches the embargo their crew serves for the same offense, so both lift -/// together. Also how long unspent warning strikes linger before they reset. -#define OUTPOST_AGGRESSION_MARK_DURATION OUTPOST_EMBARGO_DURATION - -/// How many violent infractions at an outpost it takes before turrets open fire. -/// Earlier offenses only issue a warning; the final strike marks the aggressor. -#define OUTPOST_AGGRESSION_STRIKES 3 - -/// Grace window after an infraction during which further hits don't add strikes. +/// Grace window after a crime during which further hits from the same offender fold into it. /// A single swing reaches register_aggression through more than one route (the /// machine's own attacked_by override and the outpost_property relay), all in the /// same tick. Kept under CLICK_CD_MELEE (0.8 s) so it only folds those duplicates -/// together: every real swing is its own strike. +/// together: every real swing is its own crime (outpost_police/police_wanted.dm). #define OUTPOST_AGGRESSION_GRACE (0.5 SECONDS) /// How long a victim may retaliate against their attacker after the latest unprovoked hit. @@ -169,10 +160,6 @@ #define OUTPOST_CHECKPOINT_FORCED_OPEN_TIME (10 SECONDS) /// Minimum gap between two lines from the checkpoint about the same thing #define OUTPOST_CHECKPOINT_LINE_COOLDOWN (4 SECONDS) -/// Turrets treat an armed trespasser as hostile until this long after they were last seen armed inside -#define OUTPOST_TRESPASS_HOSTILE_DURATION (10 MINUTES) -/// An armed trespasser is refused by the traders, the permit desk and the checkpoint until this long after they were last seen armed inside -#define OUTPOST_TRESPASS_BAR_DURATION (30 MINUTES) /// How often the outpost looks for drawn guns past the checkpoint #define OUTPOST_ARMED_SWEEP_INTERVAL (2 SECONDS) /// A gun has to stay in hand this long before it counts as drawn (a gun shoved into someone's hand can be dropped) diff --git a/voidcrew/_HELPERS/hostile_creatures.dm b/voidcrew/_HELPERS/hostile_creatures.dm index 0561a69e0c2..aad9b869d9d 100644 --- a/voidcrew/_HELPERS/hostile_creatures.dm +++ b/voidcrew/_HELPERS/hostile_creatures.dm @@ -1,6 +1,6 @@ /** * Shared "would this creature start a fight" classification for automated defenses - * (hull defense turrets, outpost enforcement turrets). Reads the creature's own AI + * (hull defense turrets, trader outpost police officers). Reads the creature's own AI * rather than its type or faction, so livestock, pets and passive fauna are left * alone while anything that goes looking for a target is treated as hostile. */ @@ -39,7 +39,7 @@ GLOBAL_LIST_INIT(turret_retaliating_subtrees, typecacheof(list( /proc/creature_threatens_people(mob/living/creature) // No strategy to look up without a running controller: none at all, or a mob that has not // initialized yet, whose ai_controller is still a type path (a trader outpost's crew while - // the outpost loads, with its turrets already scanning). + // the outpost loads, with its officers already about). var/datum/ai_controller/controller = creature.ai_controller var/strategy_type = istype(controller) ? controller.blackboard[BB_TARGETING_STRATEGY] : null var/datum/targeting_strategy/basic/of_size/sizer = strategy_type ? GET_TARGETING_STRATEGY(strategy_type) : null @@ -75,7 +75,7 @@ GLOBAL_LIST_INIT(turret_retaliating_subtrees, typecacheof(list( if(istype(creature, /mob/living/carbon/human/npc/outpost_prisoner)) return is_loose_outpost_prisoner(creature) // Bounty criminals, decoys and companions (voidcrew/modules/bounties/bounty_criminal.dm), real human bodies: - // trader-outpost turrets leave them to the hunters, and no turret shoots a criminal already in crit, + // trader-outpost officers leave them to the hunters, and no turret shoots a criminal already in crit, // stunned or cuffed (tg's states). if(istype(creature, /mob/living/carbon/human/npc/bounty_criminal) || istype(creature, /mob/living/carbon/human/npc/bounty_companion)) if(bounty_turret_ignores(creature, istype(get_area(creature), /area/voidcrew/trader_outpost))) diff --git a/voidcrew/_maps/map_files/outposts/trader_outpost_black_market.dmm b/voidcrew/_maps/map_files/outposts/trader_outpost_black_market.dmm index eccfdcf91d7..42088a032bf 100644 --- a/voidcrew/_maps/map_files/outposts/trader_outpost_black_market.dmm +++ b/voidcrew/_maps/map_files/outposts/trader_outpost_black_market.dmm @@ -377,7 +377,6 @@ /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 6 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/tile/dark_red/anticorner/contrasted, /obj/structure/sign/poster/contraband/syndiemoth/directional/east{ pixel_w = -1; @@ -626,7 +625,6 @@ /area/voidcrew/trader_outpost/checkpoint) "bz" = ( /obj/effect/turf_decal/siding/thinplating_new/dark, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/trimline/dark_red/filled/line, /obj/effect/turf_decal/trimline/dark_red/filled/mid_joiner, /obj/effect/decal/cleanable/cobweb, @@ -1001,7 +999,6 @@ /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) "dW" = ( -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 8 }, @@ -1805,7 +1802,6 @@ /area/voidcrew/trader_outpost) "qp" = ( /obj/effect/turf_decal/siding/wideplating_new/dark, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/trimline/dark_red/filled/corner{ dir = 4 }, @@ -2233,7 +2229,6 @@ /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 10 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/tile/dark_red/anticorner/contrasted{ dir = 8 }, @@ -2738,7 +2733,6 @@ /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 4 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/trimline/dark_red/filled/line{ dir = 4 }, @@ -3049,7 +3043,6 @@ /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 1 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/trimline/dark_red/filled/line{ dir = 1 }, @@ -3618,7 +3611,6 @@ /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) "Uq" = ( -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/trimline/dark_red/filled/line{ dir = 1 }, @@ -3735,7 +3727,6 @@ /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 8 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/trimline/dark_red/filled/line{ dir = 8 }, diff --git a/voidcrew/_maps/map_files/outposts/trader_outpost_general.dmm b/voidcrew/_maps/map_files/outposts/trader_outpost_general.dmm index a9ddd090019..8716135db18 100644 --- a/voidcrew/_maps/map_files/outposts/trader_outpost_general.dmm +++ b/voidcrew/_maps/map_files/outposts/trader_outpost_general.dmm @@ -41,7 +41,6 @@ /obj/effect/turf_decal/tile/neutral/anticorner{ dir = 8 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red/corners{ dir = 4 }, @@ -858,7 +857,6 @@ /area/voidcrew/trader_outpost) "df" = ( /obj/structure/flora/bush/flowers_pp/style_random, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red, /turf/open/floor/grass, /area/voidcrew/trader_outpost) @@ -924,7 +922,6 @@ "dT" = ( /obj/structure/flora/bush/flowers_yw/style_random, /obj/structure/railing/unbreakable, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red, /obj/machinery/light/blacklight/directional/north, /obj/structure/sign/picture_frame/portrait/bar{ @@ -1144,7 +1141,6 @@ /obj/effect/turf_decal/tile/neutral/anticorner/contrasted{ dir = 1 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red/corners, /obj/machinery/light/directional/west, /obj/item/radio/intercom/directional/west, @@ -1190,7 +1186,6 @@ icon_state = "railing" }, /obj/structure/flora/bush/flowers_yw/style_random, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red, /obj/machinery/light/floor, /turf/open/indestructible/grass, @@ -1554,7 +1549,6 @@ /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 5 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red/corners{ dir = 8 }, @@ -1690,7 +1684,6 @@ /obj/effect/turf_decal/tile/neutral/anticorner{ dir = 1 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red/corners, /obj/structure/extinguisher_cabinet/directional/north, /obj/structure/sign/painting/library{ @@ -2837,7 +2830,6 @@ /obj/effect/turf_decal/siding/wideplating_new{ dir = 10 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/tile/neutral/anticorner/contrasted{ dir = 8 }, @@ -2850,7 +2842,6 @@ /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 6 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red/corners{ dir = 1 }, @@ -3143,7 +3134,6 @@ /obj/effect/turf_decal/siding/wideplating_new{ dir = 6 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/tile/neutral/anticorner/contrasted, /obj/effect/turf_decal/box/red/corners{ dir = 1 @@ -3226,7 +3216,6 @@ /obj/effect/turf_decal/tile/neutral/anticorner/contrasted{ dir = 8 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red/corners{ dir = 4 }, @@ -3801,7 +3790,6 @@ icon_state = "railing" }, /obj/structure/flora/bush/flowers_yw/style_random, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red, /obj/machinery/light/floor, /turf/open/indestructible/grass, @@ -3897,7 +3885,6 @@ /obj/effect/turf_decal/tile/neutral/anticorner/contrasted{ dir = 4 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red/corners{ dir = 8 }, @@ -4056,7 +4043,6 @@ /obj/effect/turf_decal/siding/wideplating_new{ dir = 5 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/tile/neutral/anticorner/contrasted{ dir = 4 }, @@ -4294,7 +4280,6 @@ /obj/effect/turf_decal/siding/wideplating_new{ dir = 9 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/tile/neutral/anticorner/contrasted{ dir = 1 }, @@ -4692,7 +4677,6 @@ /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) "TM" = ( -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red, /obj/machinery/light/floor, /obj/effect/turf_decal/siding/wideplating_new/light{ @@ -5143,7 +5127,6 @@ dir = 6 }, /obj/effect/turf_decal/tile/neutral/anticorner/contrasted, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red/corners{ dir = 1 }, diff --git a/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm b/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm index 82854e88a5b..015db539cba 100644 --- a/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm +++ b/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm @@ -193,11 +193,6 @@ /obj/machinery/light/directional/north, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) -"aC" = ( -/obj/structure/lattice/catwalk, -/obj/machinery/porta_turret/outpost, -/turf/open/space/basic, -/area/space) "aD" = ( /obj/effect/turf_decal/bot, /turf/open/indestructible/dark/smooth_large, @@ -597,7 +592,6 @@ /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) "bM" = ( -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red/corners{ dir = 1 }, @@ -767,7 +761,6 @@ /obj/structure/railing/unbreakable{ dir = 6 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/tile/yellow/anticorner/contrasted, /obj/effect/turf_decal/siding/thinplating_new{ dir = 6 @@ -871,7 +864,6 @@ /turf/open/indestructible, /area/voidcrew/trader_outpost) "ez" = ( -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/siding/thinplating_new{ dir = 10 }, @@ -1858,7 +1850,6 @@ /turf/open/indestructible, /area/voidcrew/trader_outpost) "sd" = ( -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red/corners{ dir = 1 }, @@ -2139,7 +2130,6 @@ /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) "uD" = ( -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/tile/yellow/anticorner/contrasted{ dir = 1 }, @@ -2657,7 +2647,6 @@ /obj/effect/turf_decal/siding/thinplating_new{ dir = 1 }, -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/box/red/corners{ dir = 8 }, @@ -2673,7 +2662,6 @@ /turf/open/indestructible/hoteltile, /area/voidcrew/trader_outpost) "Cm" = ( -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 4 }, @@ -3072,7 +3060,6 @@ /turf/open/indestructible, /area/voidcrew/trader_outpost) "HV" = ( -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/tile/yellow/anticorner/contrasted{ dir = 8 }, @@ -3460,7 +3447,6 @@ /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) "NJ" = ( -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/trimline/red/filled/line{ dir = 4 }, @@ -3485,7 +3471,6 @@ /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) "Ob" = ( -/obj/machinery/porta_turret/outpost, /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 4 }, @@ -4461,7 +4446,7 @@ ao ao ao ao -aC +cp cp cp cp @@ -5701,7 +5686,7 @@ ao ao ao ao -aC +cp cp cp cp diff --git a/voidcrew/mapping/shuttles/police_cutter.dm b/voidcrew/mapping/shuttles/police_cutter.dm new file mode 100644 index 00000000000..00be6a3cb1a --- /dev/null +++ b/voidcrew/mapping/shuttles/police_cutter.dm @@ -0,0 +1,4 @@ +/** + * Police cutter: the template datum for the outpost police's pursuit ship (voidcrew/modules/outpost_police/police_cutter.dm). + * Owner: P9, who declares /datum/map_template/shuttle/voidcrew/police_cutter here, mirroring nt_patrol.dm. + */ diff --git a/voidcrew/modules/admin/overmap_management_bans.dm b/voidcrew/modules/admin/overmap_management_bans.dm index 7cbaae777ca..2544fc75264 100644 --- a/voidcrew/modules/admin/overmap_management_bans.dm +++ b/voidcrew/modules/admin/overmap_management_bans.dm @@ -4,11 +4,9 @@ * One list of everyone an outpost is refusing, across every trader outpost and player outpost, so an * admin can lift a restriction or impose one without hunting through variables. * - * Trader outposts (voidcrew/modules/trade/outpost.dm, outpost_trespass.dm): - * - "embargo": a ship under trade embargo. Its crew are refused service and shot on sight. - * - "aggressor": a person marked for violence. Refused service and shot on sight. - * - "warning": a person with warning strikes who has not been marked yet. - * - "trespass": a person refused service after armed trespass (turrets only for the shorter hostile window). + * Trader outposts (voidcrew/modules/trade/outpost.dm): + * - "embargo": a ship under trade embargo. Its crew are refused service. + * Wanted people at trader outposts are the outpost police's (voidcrew/modules/outpost_police/). * Player outposts (voidcrew/modules/player_outposts/): * - "ship_ban": a ship the owners denied docking. */ @@ -81,19 +79,6 @@ continue if(trader.is_ship_embargoed(embargoed)) entries += list(ban_entry(trader, "embargo", embargoed, embargoed.name, "Trade embargo", outpost_ban_seconds_left(trader.embargoed_ships[embargoed]))) - for(var/datum/mind/marked as anything in trader.aggressor_minds.Copy()) - if(trader.is_marked_aggressor(marked)) - entries += list(ban_entry(trader, "aggressor", marked, outpost_ban_mind_label(marked), "Marked for violence", outpost_ban_seconds_left(trader.aggressor_minds[marked]))) - for(var/datum/mind/warned as anything in trader.aggressor_strikes.Copy()) - var/last_strike = trader.aggressor_strike_times[warned] - if(trader.aggressor_minds[warned] || !last_strike || world.time >= last_strike + OUTPOST_AGGRESSION_MARK_DURATION) - continue - var/strikes = trader.aggressor_strikes[warned] - entries += list(ban_entry(trader, "warning", warned, outpost_ban_mind_label(warned), "[strikes] warning[strikes == 1 ? "" : "s"]", outpost_ban_seconds_left(last_strike + OUTPOST_AGGRESSION_MARK_DURATION))) - for(var/datum/mind/trespasser as anything in trader.trespass_bars.Copy()) - if(trader.is_trespass_barred(trespasser)) - var/label = trader.is_armed_trespasser(trespasser) ? "Armed trespass, turrets hostile" : "Armed trespass" - entries += list(ban_entry(trader, "trespass", trespasser, outpost_ban_mind_label(trespasser), label, outpost_ban_seconds_left(trader.trespass_bars[trespasser]))) var/list/ships = list() for(var/obj/structure/overmap/ship/ship as anything in SSovermap.simulated_ships) @@ -139,20 +124,6 @@ trader.embargoed_ships -= ship target_name = ship.name ship.ship_notify("[trader.admin_name()] has lifted the trade embargo on your vessel.", "TRADE EMBARGO", SHIP_NOTIFY_NOTICE) - if("aggressor", "warning") - var/datum/mind/marked = trader && (locate(target_ref) in (kind == "aggressor" ? trader.aggressor_minds : trader.aggressor_strikes)) - if(marked) - trader.aggressor_minds -= marked - trader.aggressor_strikes -= marked - trader.aggressor_strike_times -= marked - target_name = outpost_ban_mind_label(marked) - if("trespass") - var/datum/mind/trespasser = trader && (locate(target_ref) in trader.trespass_bars) - if(trespasser) - trader.trespass_bars -= trespasser - trader.armed_trespassers -= trespasser - trader.armed_sightings -= trespasser - target_name = outpost_ban_mind_label(trespasser) if("ship_ban") var/obj/structure/overmap/ship/ship = home && (locate(target_ref) in home.banned_ships) if(ship) @@ -182,24 +153,6 @@ home.approved_ships -= ship if(ship in home.pending_dock_requests) home.deny_dock_request(ship) - if("aggressor", "trespass") - var/datum/mind/target = locate(target_ref) in ban_candidate_minds() - if(!target || !trader) - error = "Choose a person and a trader outpost." - return - target_name = outpost_ban_mind_label(target) - var/mob/living/body = target.current - var/at_outpost = body && get_trader_outpost_for_turf(get_turf(body)) == trader - if(kind == "aggressor") - trader.aggressor_minds[target] = world.time + duration - trader.aggressor_strikes[target] = OUTPOST_AGGRESSION_STRIKES - trader.aggressor_strike_times[target] = world.time - if(at_outpost) - to_chat(body, span_userdanger("Outpost defense systems lock onto you.")) - else - trader.trespass_bars[target] = world.time + duration - trader.weapon_permits -= target - trader.permit_lapse_told -= target else error = "Unknown restriction." return @@ -212,12 +165,6 @@ switch(kind) if("embargo") return "a trade embargo" - if("aggressor") - return "a violence mark" - if("warning") - return "the warnings" - if("trespass") - return "a refused-service bar" if("ship_ban") return "a docking ban" return "a restriction" diff --git a/voidcrew/modules/ambient_npcs/ambient_activity.dm b/voidcrew/modules/ambient_npcs/ambient_activity.dm index f0844a4b0ad..e090873f708 100644 --- a/voidcrew/modules/ambient_npcs/ambient_activity.dm +++ b/voidcrew/modules/ambient_npcs/ambient_activity.dm @@ -357,7 +357,7 @@ // ========================================================================= /** - * An ambient NPC's brain: passive (no targeting, so trader outpost turrets read it as harmless), + * An ambient NPC's brain: passive (no targeting, so trader outpost officers read it as harmless), * long paths for crossing a concourse, and nothing at all while idle (nobody near). First it gets * free of anything holding it, the way a person would. */ diff --git a/voidcrew/modules/ambient_npcs/ambient_npc.dm b/voidcrew/modules/ambient_npcs/ambient_npc.dm index 7de4be10de8..698e7cb3d5b 100644 --- a/voidcrew/modules/ambient_npcs/ambient_npc.dm +++ b/voidcrew/modules/ambient_npcs/ambient_npc.dm @@ -16,9 +16,9 @@ * - a leash they walk back to, or walk out from (they never vanish in front of players); * - killable: a body drops a little cash (and any `death_loot`) once, never again after a revive. It * lies until nobody is watching (SShuman_npcs' corpse rules; a trader outpost takes it sooner, as - * carried off). At a trader outpost, hurting one is violence there (the outpost's strikes, and - * everyone near ducks), and turrets never shoot them (FACTION_TURRET while they belong to it); - * - a passive AI: no targeting subtree, so outpost turrets never read them as wild hostiles; + * carried off). At a trader outpost, hurting one is violence there (a crime for its police, and + * everyone near ducks), and they carry its staff faction (FACTION_TURRET while they belong to it); + * - a passive AI: no targeting subtree, so outpost officers never read them as wild hostiles; * - dialogue from a JSON file with per-NPC and per-place cooldowns, replies to other NPCs, and an * answer for a player who talks to them (an empty hand), which falls through to tg's help (shaking * awake, patting out flames, helping up) while they are down, on fire or asleep; diff --git a/voidcrew/modules/ambient_npcs/ambient_subsystem.dm b/voidcrew/modules/ambient_npcs/ambient_subsystem.dm index e99e2d4e547..09c1354f403 100644 --- a/voidcrew/modules/ambient_npcs/ambient_subsystem.dm +++ b/voidcrew/modules/ambient_npcs/ambient_subsystem.dm @@ -673,7 +673,7 @@ SUBSYSTEM_DEF(ambient_npcs) for(var/mob/living/carbon/human/npc/ambient_npc/npc as anything in living_npcs()) npc.react_convoy(outpost) -// Its turrets never shoot its own people; with nobody on the concourse, whoever joins holds still until someone comes +// Its own people carry the staff faction (FACTION_TURRET); with nobody on the concourse, whoever joins holds still until someone comes /datum/ambient_place/outpost/add_npc(mob/living/carbon/human/npc/ambient_npc/npc) . = ..() npc.faction |= FACTION_TURRET @@ -699,12 +699,12 @@ SUBSYSTEM_DEF(ambient_npcs) until += opens_at refill_at = refill_at ? min(refill_at, opens_at) : opens_at -// A real blow is violence at the outpost, as against a visitor: its strikes, and everyone near ducks (register_aggression()) +// A real blow is violence at the outpost, as against a visitor: a crime for its police, and everyone near ducks (register_aggression()) /datum/ambient_place/outpost/npc_attacked(mob/living/carbon/human/npc/ambient_npc/npc, atom/attacker, attack_flags) if(!(attack_flags & ATTACKER_DAMAGING_ATTACK) || !isliving(attacker)) return var/obj/structure/overmap/trader_outpost/outpost = outpost() - outpost?.register_aggression(attacker) + outpost?.register_aggression(attacker, OUTPOST_CRIME_ASSAULT_CIVILIAN, npc) // Its people drop no more than AMBIENT_OUTPOST_CASH_CAP between them in a round /datum/ambient_place/outpost/cash_for(mob/living/carbon/human/npc/ambient_npc/npc, amount) diff --git a/voidcrew/modules/ambient_npcs/strings/outpost_police.json b/voidcrew/modules/ambient_npcs/strings/outpost_police.json new file mode 100644 index 00000000000..3428940bb71 --- /dev/null +++ b/voidcrew/modules/ambient_npcs/strings/outpost_police.json @@ -0,0 +1,5 @@ +{ + "halcyon": {}, + "quartermain": {}, + "undertow": {} +} diff --git a/voidcrew/modules/bounties/bounty_outpost.dm b/voidcrew/modules/bounties/bounty_outpost.dm index 9ac8cecbc28..d1a956c3dc6 100644 --- a/voidcrew/modules/bounties/bounty_outpost.dm +++ b/voidcrew/modules/bounties/bounty_outpost.dm @@ -988,7 +988,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( owner.on_confronted(attacker) return var/obj/structure/overmap/trader_outpost/outpost = get_trader_outpost_for_turf(get_turf(owner)) - outpost?.register_aggression(attacker) + outpost?.register_aggression(attacker, OUTPOST_CRIME_ASSAULT_CIVILIAN, owner) if(!leaving && COOLDOWN_FINISHED(src, hit_grace)) COOLDOWN_START(src, hit_grace, BOUNTY_OUTPOST_HIT_GRACE) var/datum/criminal_bounty/posting = posting_ref?.resolve() diff --git a/voidcrew/modules/cyberware/ware_line_widowline.dm b/voidcrew/modules/cyberware/ware_line_widowline.dm index f0b84787df1..1010e8fc815 100644 --- a/voidcrew/modules/cyberware/ware_line_widowline.dm +++ b/voidcrew/modules/cyberware/ware_line_widowline.dm @@ -155,9 +155,6 @@ if(machine.machine_stat & BROKEN) return FALSE if(istype(machine, /obj/machinery/porta_turret)) - // Outpost sanctuary turrets are immune, as against the Icepick Jack. - if(istype(machine, /obj/machinery/porta_turret/outpost)) - return FALSE var/obj/machinery/porta_turret/turret = machine if(!COOLDOWN_FINISHED(turret, disabled_time)) return FALSE diff --git a/voidcrew/modules/cyberware/ware_pro_utility.dm b/voidcrew/modules/cyberware/ware_pro_utility.dm index 5bc0bbe3499..cf4e8d75f4b 100644 --- a/voidcrew/modules/cyberware/ware_pro_utility.dm +++ b/voidcrew/modules/cyberware/ware_pro_utility.dm @@ -238,9 +238,6 @@ /datum/action/cooldown/cyberware/icepick/suppress/Activate(atom/target) var/mob/living/carbon/bearer = organ.owner if(istype(target, /obj/machinery/porta_turret)) - if(istype(target, /obj/machinery/porta_turret/outpost)) - bearer.balloon_alert(bearer, "hardened firmware!") - return FALSE if(!spike_in_range(bearer, target, CYBERWARE_ICEPICK_TURRET_RANGE)) return FALSE bearer.balloon_alert(bearer, "spiking turret...") @@ -307,9 +304,6 @@ if(!istype(target, /obj/machinery/porta_turret)) bearer.balloon_alert(bearer, "no targeting bus!") return FALSE - if(istype(target, /obj/machinery/porta_turret/outpost)) - bearer.balloon_alert(bearer, "hardened firmware!") - return FALSE if(istype(target, /obj/machinery/porta_turret/ship_defense)) // Hull defense turrets can't be aimed at people at all, there is // nothing for a subverted IFF to shoot. Suppress them instead. diff --git a/voidcrew/modules/outpost_police/police_arrest.dm b/voidcrew/modules/outpost_police/police_arrest.dm new file mode 100644 index 00000000000..c9368d0fb69 --- /dev/null +++ b/voidcrew/modules/outpost_police/police_arrest.dm @@ -0,0 +1,25 @@ +/** + * # Outpost police: arrests + * + * Owner: P5. One /datum/outpost_police_case per suspect at one or two stars: respond, order, subdue, cuff, + * search, escort, desk, book. Every step on the suspect's body is the tg action a player officer would take. + * Spec 6 (the step table and 6.4). + */ + +/// Owns the officer while it has a case step to do +/datum/ai_planning_subtree/outpost_police_case + +/datum/ai_planning_subtree/outpost_police_case/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + return + +/// Opens an arrest for `warrant`. Returns the case, or null. +/datum/outpost_police/proc/open_case(datum/outpost_warrant/warrant) + return null + +/// Closes `who`'s arrest, if any +/datum/outpost_police/proc/close_case(datum/mind/who) + return + +/// Once a second (police_core.dm tick()): start, reassign and close cases +/datum/outpost_police/proc/tick_cases() + return diff --git a/voidcrew/modules/outpost_police/police_cell.dm b/voidcrew/modules/outpost_police/police_cell.dm new file mode 100644 index 00000000000..c995c4bca4e --- /dev/null +++ b/voidcrew/modules/outpost_police/police_cell.dm @@ -0,0 +1,26 @@ +/** + * # Outpost police: cells + * + * Owner: P6. The holding cells (/datum/outpost_police_cell), booking, processing, jail terms, bail and + * release, and the police doors and cell doors (types in police_core.dm). Spec 6.2-6.4 and the cell half of 7. + */ + +/// Builds one cell per cell_id from the interior's landmarks, doors and signs (called by link_interior()) +/datum/outpost_police/proc/build_cells(list/cell_landmarks, list/cell_doors, list/cell_signs) + return + +/// Whether `who` is in police custody: from the cuffs going on to release +/datum/outpost_police/proc/in_custody(mob/living/who) + return FALSE + +/// Books `suspect`, brought to a cell by `officer`: the fine, then processing or jail. Sleeps. TRUE once the door is shut. +/datum/outpost_police/proc/book(mob/living/carbon/suspect, mob/living/officer) + return FALSE + +/// The lobby tile outside a cell door, or null +/datum/outpost_police/proc/cell_door_turf() + return null + +/// Once a second (police_core.dm tick()): signs, bail, releases, containment +/datum/outpost_police/proc/tick_cells() + return diff --git a/voidcrew/modules/outpost_police/police_core.dm b/voidcrew/modules/outpost_police/police_core.dm new file mode 100644 index 00000000000..cb51d522496 --- /dev/null +++ b/voidcrew/modules/outpost_police/police_core.dm @@ -0,0 +1,452 @@ +/** + * # Outpost police: the core + * + * Owner: P0 seams, frozen. Packages never edit this file; they fill the stubs in their own files. + * + * One /datum/outpost_police per trader outpost (`outpost.police`). It holds warrants (mind -> heat, this + * outpost only), the officers on the roster, the station's parts (lift tiles, desk, cells, property lockers, + * evidence chute) and a one-second tick that runs while there is anything to do. Every crime at the outpost + * enters through report_crime() (police_wanted.dm). Officers are ambient outpost NPCs with a human NPC's + * fighting brain (/datum/ai_controller/basic_controller/human_npc/outpost_police below). + * + * Who owns what (spec and build plan in the parent checkout's data/outpost-police/): + * - police_wanted.dm, police_perception.dm: warrants, heat, stars, fines, embargoes, witnesses (P1) + * - police_units.dm: officer bodies, profiles, police cuffs (P2) + * - police_life.dm: routines, roles, speech (P3) + * - police_dispatch.dm, police_force.dm: sectors, lifts, waves, targeting, fire rules (P4) + * - police_arrest.dm: the arrest case (P5) + * - police_cell.dm: cells, police doors and cell doors (P6) + * - police_property.dm: confiscation, evidence chute, property lockers, the jail strip (P12) + * - police_hud.dm: the wanted alert (P8) + * - police_cutter.dm: the cutter (P9) + * + * Include order: this module comes after ambient_npcs, bounties, human_npcs and npc_ships, and before + * overmap, player_outposts and trade. An override placed here of a proc that is first declared (proc/) in + * one of those later modules never runs (traps: dm-language.md); add new procs or subtypes instead. + */ + +/** + * Every crime the police know: crime -> list(points, ceiling, floor). Points are heat; ceiling and floor are + * stars. A crime never takes a warrant past its ceiling, and always takes it to at least its floor. + */ +GLOBAL_LIST_INIT(outpost_crimes, list( + (OUTPOST_CRIME_ASSAULT_UNARMED) = list(6, 2, 0), + (OUTPOST_CRIME_ASSAULT_ARMED) = list(9, 3, 0), + (OUTPOST_CRIME_ASSAULT_SHOT) = list(25, 3, 0), + (OUTPOST_CRIME_MURDER) = list(60, 5, 3), + (OUTPOST_CRIME_VANDALISM) = list(5, 2, 0), + (OUTPOST_CRIME_ASSAULT_STAFF) = list(8, 2, 0), + (OUTPOST_CRIME_ASSAULT_CIVILIAN) = list(12, 3, 0), + (OUTPOST_CRIME_KILL_CIVILIAN) = list(40, 4, 0), + (OUTPOST_CRIME_WEAPON_DRAWN) = list(0, 1, 1), + (OUTPOST_CRIME_GUNFIRE) = list(15, 2, 0), + (OUTPOST_CRIME_CHECKPOINT_BREACH) = list(0, 2, 2), + (OUTPOST_CRIME_TRESPASS) = list(0, 2, 2), + (OUTPOST_CRIME_RESIST) = list(8, 3, 0), + (OUTPOST_CRIME_ASSAULT_OFFICER) = list(30, 4, 3), + (OUTPOST_CRIME_KILL_OFFICER) = list(45, 5, 3), + (OUTPOST_CRIME_SLIP_CUFFS) = list(10, 3, 0), + (OUTPOST_CRIME_ABET) = list(15, 2, 0), + (OUTPOST_CRIME_JAILBREAK) = list(0, 3, 3), + (OUTPOST_CRIME_ACCOMPLICE) = list(0, 2, 2), + (OUTPOST_CRIME_LOCKER_THEFT) = list(0, 2, 2), +)) + +// ========================================================================= +// THE OUTPOST'S POLICE +// ========================================================================= + +/obj/structure/overmap/trader_outpost + /// This outpost's police (police_core.dm). Made by ensure_police(), deleted in Destroy(). + var/datum/outpost_police/police + +/** + * Makes the police on first need. SSovermap pre-loads an outpost's interior before SSatoms has run its + * Initialize (see ensure_main_shop()), so both Initialize and the interior link come through here. + */ +/obj/structure/overmap/trader_outpost/proc/ensure_police() + if(!police) + police = new /datum/outpost_police(src) + return police + +/datum/outpost_police + /// The outpost these officers keep + var/obj/structure/overmap/trader_outpost/outpost + /// The zone's profile: names, numbers and kit (police_units.dm, make_profile()) + var/datum/outpost_police_profile/profile + /// Warrants here: mind -> /datum/outpost_warrant. Written by police_wanted.dm only. + var/list/warrants = list() + /// The witness test (police_perception.dm, make_perception()). The one seam the stealth merge swaps. + var/datum/outpost_police_perception/perception + /// The station's property lockers (behaviour in police_property.dm) + var/list/obj/structure/closet/secure_closet/personal/outpost_police/lockers = list() + /// The evidence chute at the desk + var/obj/machinery/outpost_police_evidence/chute + /// Every officer on the roster, alive or dead. Use add_officer() and remove_officer(). + var/list/mob/living/carbon/human/npc/ambient_npc/outpost/police/officers = list() + /// The police lift tiles behind the police doors on the concourse + var/list/turf/lift_turfs = list() + /// The cells, built by build_cells() (police_cell.dm) + var/list/datum/outpost_police_cell/cells = list() + /// The sergeant's tile at the desk + var/turf/desk_turf + /// Open arrests: mind -> /datum/outpost_police_case. Written by police_arrest.dm only. + var/list/cases = list() + /// The looping one-second tick (wake()), or null while asleep + var/tick_timer + /// world.time the tick last found nothing to do, or 0 + var/idle_since = 0 + +/datum/outpost_police/New(obj/structure/overmap/trader_outpost/new_outpost) + . = ..() + outpost = new_outpost + profile = make_profile() + perception = make_perception() + +/datum/outpost_police/Destroy() + if(tick_timer) + deltimer(tick_timer) + tick_timer = null + // Typed: a hard-deleted officer leaves a null in the list + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers.Copy()) + remove_officer(officer) + if(officer.police == src) + officer.police = null + officers.Cut() + QDEL_LIST_ASSOC_VAL(cases) + QDEL_LIST_ASSOC_VAL(warrants) + QDEL_LIST(cells) + QDEL_NULL(perception) + QDEL_NULL(profile) + lockers.Cut() + lift_turfs.Cut() + chute = null + desk_turf = null + outpost = null + return ..() + +// ----- the tick ----- + +/// Starts the tick if it is not running. Safe to call from signal handlers: it never sleeps. +/datum/outpost_police/proc/wake() + idle_since = 0 + if(tick_timer || QDELETED(src)) + return + tick_timer = addtimer(CALLBACK(src, PROC_REF(tick)), 1 SECONDS, TIMER_STOPPABLE | TIMER_LOOP | TIMER_DELETE_ME) + +/// Once a second while awake. Each step belongs to its package; the order is frozen. +/datum/outpost_police/proc/tick() + tick_warrants() // P1, police_wanted.dm + tick_dispatch() // P4, police_dispatch.dm + tick_cases() // P5, police_arrest.dm + tick_cells() // P6, police_cell.dm + tick_life() // P3, police_life.dm + if(has_work()) + idle_since = 0 + return + if(!idle_since) + idle_since = world.time + else if(world.time - idle_since >= OUTPOST_POLICE_IDLE_STOP) + sleep_tick() + +/// Stops the tick (TIMER_DELETE_ME: this runs from the timer's own callback) +/datum/outpost_police/proc/sleep_tick() + if(tick_timer) + deltimer(tick_timer) + tick_timer = null + idle_since = 0 + +/// Whether there is a warrant, a response unit or a prisoner to look after +/datum/outpost_police/proc/has_work() + if(length(warrants) || length(cases)) + return TRUE + for(var/datum/outpost_police_cell/cell as anything in cells) + if(length(cell.prisoners)) + return TRUE + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers) + if(officer.stat != DEAD && officer.unit_kind != POLICE_UNIT_BEAT && officer.unit_kind != POLICE_UNIT_SERGEANT) + return TRUE + return FALSE + +// ----- the roster ----- + +/// `officer` is one of ours now +/datum/outpost_police/proc/add_officer(mob/living/carbon/human/npc/ambient_npc/outpost/police/officer) + if(QDELETED(officer) || (officer in officers)) + return + if(officer.police && officer.police != src) + officer.police.remove_officer(officer) + officer.police = src + officers += officer + RegisterSignal(officer, COMSIG_QDELETING, PROC_REF(on_officer_deleted)) + +/// `officer` leaves the roster (and so the caps). Their `police` stays set; the caller decides what they do next. +/datum/outpost_police/proc/remove_officer(mob/living/carbon/human/npc/ambient_npc/outpost/police/officer) + if(!(officer in officers)) + return + officers -= officer + UnregisterSignal(officer, COMSIG_QDELETING) + +/datum/outpost_police/proc/on_officer_deleted(mob/living/carbon/human/npc/ambient_npc/outpost/police/officer) + SIGNAL_HANDLER + remove_officer(officer) + officer.police = null + +// ----- the station ----- + +/** + * The outpost's interior link (trade/outpost.dm) hands over everything the template placed for the police: + * `landmarks` (lift, desk and cell landmarks), `doors` (police doors and cell doors), `signs` (cell signs), + * `lockers` (property lockers) and the evidence `chute`. The cells and the property are built from them, + * then the landmarks are deleted. + */ +/datum/outpost_police/proc/link_interior(list/landmarks, list/doors, list/signs, list/new_lockers, obj/machinery/outpost_police_evidence/new_chute) + var/list/cell_landmarks = list() + var/list/cell_doors = list() + for(var/obj/effect/landmark/mark as anything in landmarks) + if(istype(mark, /obj/effect/landmark/outpost_police_lift)) + lift_turfs |= get_turf(mark) + else if(istype(mark, /obj/effect/landmark/outpost_police_desk)) + if(desk_turf) + log_mapping("OUTPOST POLICE: [outpost] has more than one desk landmark ([AREACOORD(mark)]).") + desk_turf = get_turf(mark) + else if(istype(mark, /obj/effect/landmark/outpost_police_cell)) + cell_landmarks += mark + for(var/obj/machinery/door/airlock/outpost/door as anything in doors) + door.outpost = outpost + if(istype(door, /obj/machinery/door/airlock/outpost/glass/police_cell)) + cell_doors += door + for(var/obj/structure/closet/secure_closet/personal/outpost_police/locker as anything in new_lockers) + lockers |= locker + if(new_chute) + if(chute && chute != new_chute) + log_mapping("OUTPOST POLICE: [outpost] has more than one evidence chute ([AREACOORD(new_chute)]).") + chute = new_chute + // The cells read their landmarks' tiles, so the landmarks go only after + build_cells(cell_landmarks, cell_doors, signs) // P6, police_cell.dm + build_property(lockers, chute) // P12, police_property.dm + for(var/obj/effect/landmark/mark as anything in landmarks) + qdel(mark) + +// ========================================================================= +// SHARED DATUMS (vars only; behaviour lives with each owner) +// ========================================================================= + +/// One person's record at one outpost. Every var is written by police_wanted.dm (P1) only; everyone reads. +/datum/outpost_warrant + /// Whose record this is + var/datum/mind/mind + /// Current heat (0 to OUTPOST_POLICE_HEAT_CAP) + var/heat = 0 + /// Highest heat this incident + var/peak = 0 + /// Heat a faded warrant left behind: the next crime starts from it + var/prior = 0 + /// world.time the prior was set + var/prior_set_at = 0 + /// world.time of the last counted crime + var/last_crime_at = 0 + /// world.time an officer last saw them + var/last_seen_at = 0 + /// Where they were last seen + var/turf/last_seen_turf + /// world.time since when no officer has seen them, or 0 + var/unseen_since = 0 + /// world.time since when they have been away from the outpost, or 0 + var/away_since = 0 + /// world.time until which they are refused service (a faded warrant of three stars or more) + var/barred_until = 0 + /// The crew they were on at their first crime (abuse M7): fine, bail, embargo and accomplices use it + var/datum/voidcrew_crew_record/crew + /// Officers they killed this incident + var/officers_killed = 0 + /// Victims hit lately: weakref -> world.time of the last counted hit (for killings) + var/list/recent_victims + /// Weakrefs to the items used in counted crimes, OUTPOST_POLICE_EVIDENCE_MAX at most, oldest first + var/list/evidence + +/// A zone's police: what they are called, how many walk the beat, the fine, the comply time, mercy, voice and kit. +/// police_units.dm (P2) sets the values on the three subtypes and picks one in make_profile(). +/datum/outpost_police_profile + /// What the force is called ("Halcyon Port Authority"), shown on the wanted alert + var/force_name = "Outpost Police" + /// Beat officers on the concourse + var/beat_count = 2 + /// The fine is multiplied by this + var/fine_multiplier = 1 + /// How long a suspect is given to comply with an order + var/comply_time = 3 SECONDS + /// Whether officers leave a downed suspect alone at three stars and up + var/spares_downed = TRUE + /// Section of AMBIENT_STRINGS_POLICE the officers speak from + var/voice_section = "halcyon" + /// POLICE_UNIT_* -> /datum/outfit type + var/list/outfits = list() + +/// One arrest in progress. Vars written by police_arrest.dm (P5) only. +/datum/outpost_police_case + /// The warrant being served + var/datum/outpost_warrant/warrant + /// The step it is on (P5's names) + var/step + /// Weakrefs to the arresting officer, then the cover officer + var/list/officers = list() + /// world.time the current step started + var/step_started_at = 0 + /// Times a step timed out and the case went back to the start + var/resets = 0 + +/// One holding cell. Vars written by police_cell.dm (P6) only. +/datum/outpost_police_cell + /// The id its landmarks, door and sign share + var/cell_id + /// The cell's floor + var/list/turf/tiles = list() + /// Its door + var/obj/machinery/door/airlock/outpost/glass/police_cell/door + /// Its sign + var/obj/machinery/status_display/outpost_berth/police_cell/sign + /// Prisoners: mind -> world.time they are let out + var/list/prisoners = list() + /// Prisoners serving a jail term rather than processing: mind -> TRUE + var/list/jailed = list() + +/// The witness test. Procs in police_perception.dm (P1), the only police file that names can_see. +/datum/outpost_police_perception + /// The police it works for + var/datum/outpost_police/police + /// Whoever witnessed the last crime witness_of() was asked about + var/atom/last_witness + +/datum/outpost_police_perception/New(datum/outpost_police/new_police) + . = ..() + police = new_police + +/datum/outpost_police_perception/Destroy() + police = null + last_witness = null + return ..() + +// ========================================================================= +// OFFICERS +// ========================================================================= + +/** + * An outpost police officer: an ambient outpost NPC (routines, talk, the empty-outpost pause) with a human + * NPC's fighting brain. Bodies, kit and profiles are police_units.dm's (P2). + */ +/mob/living/carbon/human/npc/ambient_npc/outpost/police + name = "outpost officer" + desc = "Keeps the peace here." + ai_controller = /datum/ai_controller/basic_controller/human_npc/outpost_police + /// The police they serve + var/datum/outpost_police/police + /// POLICE_UNIT_* + var/unit_kind = POLICE_UNIT_BEAT + /// The arrest they are working, if any. Written by police_arrest.dm (P5). + var/datum/outpost_police_case/case + /// How hard they go at their target, POLICE_FORCE_*. Written by police_force.dm (P4). + var/force = POLICE_FORCE_NONE + +/mob/living/carbon/human/npc/ambient_npc/outpost/police/Destroy() + police?.remove_officer(src) + police = null + case = null + return ..() + +/// Walks the concourse +/mob/living/carbon/human/npc/ambient_npc/outpost/police/beat + unit_kind = POLICE_UNIT_BEAT + +/// Runs the desk +/mob/living/carbon/human/npc/ambient_npc/outpost/police/sergeant + unit_kind = POLICE_UNIT_SERGEANT + +/// A response officer, sent by dispatch +/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer + unit_kind = POLICE_UNIT_OFFICER + +/// The heavy response +/mob/living/carbon/human/npc/ambient_npc/outpost/police/tactical + unit_kind = POLICE_UNIT_TACTICAL + +/// The top of the ladder +/mob/living/carbon/human/npc/ambient_npc/outpost/police/marshal + unit_kind = POLICE_UNIT_MARSHAL + +/** + * The officers' brain. Subtree order is frozen; each police subtree is its owner's (police_arrest.dm P5, + * police_force.dm P4). Routines run last, only when nothing else planned. + */ +/datum/ai_controller/basic_controller/human_npc/outpost_police + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/human_npc/outpost_police, + BB_TARGET_MINIMUM_STAT = HARD_CRIT, + BB_AGGRO_RANGE = HUMAN_NPC_SIGHT_RANGE, + BB_HUMAN_NPC_SHOT_DELAY = 3 SECONDS, + BB_HUMAN_NPC_FIRE_RANGE = 7, + BB_HUMAN_NPC_KEEP_AWAY = 2, + ) + ai_movement = /datum/ai_movement/jps/ambient + idle_behavior = null + // Routines cross the whole concourse, as the ambient controller's do + max_target_distance = AMBIENT_PATH_LENGTH + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/ambient_escape, + /datum/ai_planning_subtree/human_npc_walk_out, + /datum/ai_planning_subtree/outpost_police_case, + /datum/ai_planning_subtree/outpost_police_force, + /datum/ai_planning_subtree/simple_find_target, + /datum/ai_planning_subtree/npc_combat_sense, + /datum/ai_planning_subtree/human_npc_arm, + /datum/ai_planning_subtree/human_npc_shoot, + /datum/ai_planning_subtree/human_npc_melee, + /datum/ai_planning_subtree/outpost_police_search, + /datum/ai_planning_subtree/ambient_routine, + ) + +// ========================================================================= +// THE MAP KIT (placed by P7; behaviour by P6 and P12) +// ========================================================================= + +/// A police lift tile, behind a police door on the concourse. Consumed at link. +/obj/effect/landmark/outpost_police_lift + name = "outpost police lift" + +/// The sergeant's tile at the desk. Consumed at link. +/obj/effect/landmark/outpost_police_desk + name = "outpost police desk" + +/// One floor tile of a holding cell; the tiles of one cell share a `cell_id`. Consumed at link. +/obj/effect/landmark/outpost_police_cell + name = "outpost police cell" + /// Which cell + var/cell_id + +/// A door officers use between the concourse and their lift. Behaviour in police_cell.dm (P6). +/obj/machinery/door/airlock/outpost/police + name = "police door" + req_access = list(ACCESS_OUTPOST_POLICE) + +/// A holding cell's glass door. Opens only through its cell (police_cell.dm, P6). +/obj/machinery/door/airlock/outpost/glass/police_cell + name = "cell door" + /// Which cell + var/cell_id + +/// The sign over a cell door. Text from police_cell.dm (P6). +/obj/machinery/status_display/outpost_berth/police_cell + name = "cell display" + /// Which cell + var/cell_id + +MAPPING_DIRECTIONAL_HELPERS(/obj/machinery/status_display/outpost_berth/police_cell, 32) + +/** + * Whether outpost property lets a crowbar get to work on this (see /datum/element/outpost_property). Only a + * checkpoint's inner door says yes (trade/outpost_checkpoint.dm), and a cell door (police_cell.dm, P6). + * Declared here rather than in trade/ so the police module, included earlier, can override it. + */ +/obj/proc/outpost_forceable() + return FALSE diff --git a/voidcrew/modules/outpost_police/police_cutter.dm b/voidcrew/modules/outpost_police/police_cutter.dm new file mode 100644 index 00000000000..ba36a91fcbc --- /dev/null +++ b/voidcrew/modules/outpost_police/police_cutter.dm @@ -0,0 +1,13 @@ +/** + * # Outpost police: the cutter + * + * Owner: P9. At four and five stars a police cutter chases the crew's ship when it undocks (spec 9). Its ship + * subtype (/obj/structure/overmap/ship/npc/pirate/nt_patrol/police), director (/datum/nt_patrol_director/police) + * and template (/datum/map_template/shuttle/voidcrew/police_cutter, voidcrew/mapping/shuttles/police_cutter.dm) + * are P9's to declare: subtypes of those are enumerated by ship lists, admin spawners and tests, so P0 left + * them out. + */ + +/// Sends the cutter after `ship` once per incident +/datum/outpost_police/proc/start_pursuit(obj/structure/overmap/ship/ship, stars, officers_killed) + return diff --git a/voidcrew/modules/outpost_police/police_dispatch.dm b/voidcrew/modules/outpost_police/police_dispatch.dm new file mode 100644 index 00000000000..e8ead8f4d1d --- /dev/null +++ b/voidcrew/modules/outpost_police/police_dispatch.dm @@ -0,0 +1,29 @@ +/** + * # Outpost police: dispatch + * + * Owner: P4. Sectors, the police lifts, ordering units and topping the scene up in waves. Spec 5.2-5.4. + */ + +/// The highest stars among the suspects at the outpost +/datum/outpost_police/proc/alert_level() + return 0 + +/// Which sector `where` is in: POLICE_SECTOR_*, or the /datum/outpost_berth of a hangar +/datum/outpost_police/proc/sector_of(turf/where) + return POLICE_SECTOR_CONCOURSE + +/// A lift point serving `sector`, out of `avoid_sight_of`'s sight if possible; null if none +/datum/outpost_police/proc/lift_point(sector, atom/avoid_sight_of) + return null + +/// `officer` rides the lift to `sector`, with whoever they pull. Sleeps the ride. TRUE on arrival. +/datum/outpost_police/proc/ride(mob/living/officer, sector) + return FALSE + +/// Queues one unit of `unit_kind` (POLICE_UNIT_*) for `sector`. Returns the spawn order, or null. +/datum/outpost_police/proc/order_unit(unit_kind, sector) + return null + +/// Once a second (police_core.dm tick()): responders, waves, caps, stand-downs, bodies, cases to open +/datum/outpost_police/proc/tick_dispatch() + return diff --git a/voidcrew/modules/outpost_police/police_force.dm b/voidcrew/modules/outpost_police/police_force.dm new file mode 100644 index 00000000000..726c8d6e613 --- /dev/null +++ b/voidcrew/modules/outpost_police/police_force.dm @@ -0,0 +1,29 @@ +/** + * # Outpost police: force + * + * Owner: P4. Who officers fight and how hard: the targeting strategy, the weapon mode for the current target, + * hold-fire and body-block rules, and looking for a suspect who got away. Spec 8. Every "does this officer + * see that suspect" test outside the targeting strategy goes through police.perception.sees(). + */ + +/// Whom an officer fights. P0 stub: nobody. +/datum/targeting_strategy/basic/human_npc/outpost_police + +/datum/targeting_strategy/basic/human_npc/outpost_police/can_attack(mob/living/living_mob, atom/the_target, vision_range) + return FALSE + +/// The weapon mode for the current target, hold fire and body blocks +/datum/ai_planning_subtree/outpost_police_force + +/datum/ai_planning_subtree/outpost_police_force/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + return + +/// Walks to where a lost suspect was last seen and looks about +/datum/ai_planning_subtree/outpost_police_search + +/datum/ai_planning_subtree/outpost_police_search/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + return + +/// Baton out at ARREST, gun on disable at STUN, gun on kill at LETHAL (POLICE_FORCE_*) +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/set_force(level) + return diff --git a/voidcrew/modules/outpost_police/police_hud.dm b/voidcrew/modules/outpost_police/police_hud.dm new file mode 100644 index 00000000000..5ee3f6f56bc --- /dev/null +++ b/voidcrew/modules/outpost_police/police_hud.dm @@ -0,0 +1,12 @@ +/** + * # Outpost police: the wanted alert + * + * Owner: P8. Stars as a HUD alert icon (owner decision 10), no chat text. Spec 12. + */ + +/atom/movable/screen/alert/outpost_wanted + name = "Wanted" + +/// Throws or clears `who`'s wanted alert +/datum/outpost_police/proc/update_hud(datum/mind/who) + return diff --git a/voidcrew/modules/outpost_police/police_life.dm b/voidcrew/modules/outpost_police/police_life.dm new file mode 100644 index 00000000000..68f30f92600 --- /dev/null +++ b/voidcrew/modules/outpost_police/police_life.dm @@ -0,0 +1,28 @@ +/** + * # Outpost police: life and voice + * + * Owner: P3. The officers' routines (beat, coffee, chat, the sergeant's desk), the ambient roles that bring + * them, what they say and the radio. Spec 11. Lines come from AMBIENT_STRINGS_POLICE by profile section. + */ + +// ===== ROLES (no npc_type until P3 fills them, so nobody comes) ===== + +/datum/ambient_outpost_role/police_beat + name = "police beat" + +/datum/ambient_outpost_role/police_sergeant + name = "police sergeant" + +// ===== VOICE ===== + +/// Says one line of kind `line` (POLICE_LINE_*); `values` fill %PLACE% and %LOOK%; on the radio if `radio` +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/police_say(line, list/values, radio = FALSE) + return + +/// The sergeant (or the nearest officer) says `line` on the radio +/datum/outpost_police/proc/say_dispatch(line, list/values) + return + +/// Once a second (police_core.dm tick()): dispatch chatter, officer-down calls, the all-clear +/datum/outpost_police/proc/tick_life() + return diff --git a/voidcrew/modules/outpost_police/police_perception.dm b/voidcrew/modules/outpost_police/police_perception.dm new file mode 100644 index 00000000000..2d8732dfb40 --- /dev/null +++ b/voidcrew/modules/outpost_police/police_perception.dm @@ -0,0 +1,37 @@ +/** + * # Outpost police: witnesses + * + * Owner: P1. The whole "did anyone see it" test (owner decision 6, spec 3 and 18). This is the only police + * file that names can_see, cyberware_camo_hides or a witness range; the stealth merge adds one subtype here + * and changes one line in make_perception(), nothing else. + */ + +/// The witness test this police uses +/datum/outpost_police/proc/make_perception() + // The stealth merge returns its subtype here (spec 18) + return new /datum/outpost_police_perception(src) + +/// Who, if anyone, witnessed `offender` doing `crime`: POLICE_WITNESS_*. Sets `last_witness`. +/datum/outpost_police_perception/proc/witness_of(mob/living/offender, crime, atom/victim, obj/item/source) + last_witness = null + return POLICE_WITNESS_SAW + +/// Whether `viewer` (an officer, an NPC, a camera) sees `target` now +/datum/outpost_police_perception/proc/sees(atom/viewer, mob/living/target) + return FALSE + +/// Whether `listener` hears `offender` doing `crime` with `source` +/datum/outpost_police_perception/proc/hears(atom/listener, mob/living/offender, crime, obj/item/source) + return FALSE + +/// What watches for the police besides people: the checkpoint scanners and panels +/datum/outpost_police_perception/proc/cameras() + return list() + +/// How long `witness` takes to report `crime`; 0 is at once +/datum/outpost_police_perception/proc/report_delay(atom/witness, crime) + return 0 + +/// Whether `witness` can still make its report +/datum/outpost_police_perception/proc/can_report(atom/witness) + return TRUE diff --git a/voidcrew/modules/outpost_police/police_property.dm b/voidcrew/modules/outpost_police/police_property.dm new file mode 100644 index 00000000000..fd11031d4f6 --- /dev/null +++ b/voidcrew/modules/outpost_police/police_property.dm @@ -0,0 +1,57 @@ +/** + * # Outpost police: property + * + * Owner: P12. Confiscation (the offending items), the evidence chute, the property lockers and the jail strip. + * Spec 6.1, 6.3 and the locker half of 7. + */ + +/// A prisoner's gear, locked to them (owner decision 7) +/obj/structure/closet/secure_closet/personal/outpost_police + name = "property locker" + /// Weakref to the mind of the prisoner whose gear this holds + var/datum/weakref/owner_mind + /// Weakrefs to what was put in it at the strip + var/list/stored_items + /// world.time its owner was let out, or 0 while they are still held + var/released_at = 0 + +// Nothing comes with it (abuse M18) +/obj/structure/closet/secure_closet/personal/outpost_police/PopulateContents() + return + +/// The chute at the desk: crime tools go down it for good +/obj/machinery/outpost_police_evidence + name = "evidence chute" + icon = 'icons/obj/pipes_n_cables/disposal.dmi' + icon_state = "intake" + density = TRUE + anchored = TRUE + use_power = NO_POWER_USE + +/// The case an officer carries what they took in +/obj/item/outpost_sealed_case/evidence + +/// What a jailed prisoner wears +/obj/item/clothing/under/rank/prisoner/outpost_police + +/obj/item/clothing/shoes/sneakers/orange/outpost_police + +/// The Search step: takes the offending items off `suspect` into an evidence case in `officer`'s hand. Sleeps. Returns the case, or null. +/datum/outpost_police/proc/confiscate(mob/living/carbon/suspect, mob/living/officer) + return null + +/// At the desk: crime tools down the chute, stolen property back to its locker +/datum/outpost_police/proc/dispose_evidence(mob/living/officer) + return + +/// The jail strip: gear into a property locker, the prison jumpsuit on. Sleeps. TRUE if stripped, FALSE if no locker was free. +/datum/outpost_police/proc/store_property(mob/living/carbon/prisoner, mob/living/officer) + return FALSE + +/// For a later theft system: `thing` was stolen by `thief`. Unused in this build. +/datum/outpost_police/proc/mark_stolen(obj/item/thing, datum/mind/thief) + return + +/// Resets and locks the station's lockers (called by link_interior()) +/datum/outpost_police/proc/build_property(list/locker_list, obj/machinery/outpost_police_evidence/evidence_chute) + return diff --git a/voidcrew/modules/outpost_police/police_units.dm b/voidcrew/modules/outpost_police/police_units.dm new file mode 100644 index 00000000000..368d67a14e5 --- /dev/null +++ b/voidcrew/modules/outpost_police/police_units.dm @@ -0,0 +1,50 @@ +/** + * # Outpost police: units + * + * Owner: P2. The officer bodies (types are declared in police_core.dm: /police/beat, /sergeant, /officer, + * /tactical, /marshal), their kit, the three zone profiles and the police cuffs. Spec 5.1 and 5.5. + */ + +// ===== PROFILES ===== + +/// Waystation Halcyon (green) +/datum/outpost_police_profile/halcyon + voice_section = "halcyon" + +/// Quartermain Depot (yellow) +/datum/outpost_police_profile/quartermain + voice_section = "quartermain" + +/// The Undertow Exchange (red) +/datum/outpost_police_profile/undertow + voice_section = "undertow" + +/// The profile for this outpost's zone (called once, from /datum/outpost_police/New()) +/datum/outpost_police/proc/make_profile() + if(istype(outpost, /obj/structure/overmap/trader_outpost/general)) + return new /datum/outpost_police_profile/halcyon + if(istype(outpost, /obj/structure/overmap/trader_outpost/outfitter)) + return new /datum/outpost_police_profile/quartermain + if(istype(outpost, /obj/structure/overmap/trader_outpost/black_market)) + return new /datum/outpost_police_profile/undertow + return new /datum/outpost_police_profile + +// ===== KIT ===== + +/// Police cuffs: the pair put on a suspect is /used +/obj/item/restraints/handcuffs/outpost_police + +/// The pair on a suspect; gone when it comes off +/obj/item/restraints/handcuffs/outpost_police/used + +/// Their baton, or null +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/get_baton() + return locate(/obj/item/melee/baton) in get_all_contents() + +/// Their cuffs, or null +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/get_cuffs() + return locate(/obj/item/restraints/handcuffs) in get_all_contents() + +/// Their sidearm, or null +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/get_sidearm() + return locate(/obj/item/gun/energy) in get_all_contents() diff --git a/voidcrew/modules/outpost_police/police_wanted.dm b/voidcrew/modules/outpost_police/police_wanted.dm new file mode 100644 index 00000000000..05cbf9f9c29 --- /dev/null +++ b/voidcrew/modules/outpost_police/police_wanted.dm @@ -0,0 +1,83 @@ +/** + * # Outpost police: warrants, heat and stars + * + * Owner: P1 (wanted core). Every crime at a trader outpost reaches report_crime(); this file turns it into + * heat on the offender's warrant (GLOB.outpost_crimes), fades it, fines it, bars and embargoes for it. + * Spec sections 2, 4, 6.2, 9 (the embargo half) and 15. + * + * P0 left stubs with the frozen signatures (build plan section 2). report_crime() runs inside signal + * handlers (the PvP watcher, outpost property, the armed sweep): it and everything it calls must never sleep. + */ + +/** + * `offender` did `crime` (OUTPOST_CRIME_*) to `victim` (or to nobody in particular) with `source` (the item + * used, or null). Returns the offender's stars here after it, 0 for a counted crime still under one star, or + * null when it did not count (no mind, no witness, folded into the last one). A counted `source` joins the + * warrant's evidence. Never sleeps. + */ +/datum/outpost_police/proc/report_crime(mob/living/offender, crime, atom/victim, obj/item/source) + SHOULD_NOT_SLEEP(TRUE) + if(!offender?.mind) + return null + log_game("OUTPOST POLICE: [key_name(offender)] committed [crime][victim ? " against [victim]" : ""][source ? " with [source] ([source.type])" : ""] at [outpost] ([AREACOORD(offender)]).") + return null + +/// Stars `who` has here, 0 to 5 +/datum/outpost_police/proc/get_stars(datum/mind/who) + return 0 + +/// `who`'s warrant here, or null +/datum/outpost_police/proc/get_warrant(datum/mind/who) + return null + +/// How hard officers may go at `target`: POLICE_FORCE_* (NONE for no mind, in custody or dead) +/datum/outpost_police/proc/force_level(mob/living/target) + return POLICE_FORCE_NONE + +/** + * Whether `user` is refused service here (shops, the permit desk, the checkpoint): stars, a bar, or crew of + * an embargoed ship. P0 stub: the embargo half only. + */ +/datum/outpost_police/proc/is_barred(mob/user) + if(!user?.mind || !outpost) + return FALSE + var/datum/voidcrew_crew_record/crew = user.mind.get_reputation_crew() + for(var/datum/team/voidcrew/team in crew?.teams) + if(team.ship && outpost.is_ship_embargoed(team.ship)) + return TRUE + return FALSE + +/// An officer (or the checkpoint's scan) sees `who` at `where` now +/datum/outpost_police/proc/note_seen(datum/mind/who, turf/where) + return + +/** + * The fine for `warrant`'s stars and the zone, from its remembered crew's ship accounts pooled, richest first, + * all or nothing (abuse M6, M7). TRUE with the money gone, or FALSE with nothing taken. Never sleeps. + */ +/datum/outpost_police/proc/charge_fine(datum/outpost_warrant/warrant) + return FALSE + +/// `who`'s heat goes to 0 (`served`: they did their time); their prior stays +/datum/outpost_police/proc/close_warrant(datum/mind/who, served) + return + +/// Clears every warrant, prior and bar of `crew` here and lifts its embargo +/datum/outpost_police/proc/pardon_crew(datum/voidcrew_crew_record/crew) + return + +/// Warrants of one star or more whose holder is at this outpost +/datum/outpost_police/proc/suspects_at_outpost() + return list() + +/// An officer was hit (relay_attackers): ASSAULT_OFFICER or RESIST (abuse M3). From the officer's body (P2). +/datum/outpost_police/proc/officer_struck(mob/living/officer, atom/attacker, attack_flags) + return + +/// Someone tried to cuff an officer: ASSAULT_OFFICER (abuse M3). From the officer's body (P2). +/datum/outpost_police/proc/officer_cuff_attempt(mob/living/officer, mob/living/user) + return + +/// Once a second (police_core.dm tick()): sightings, fading, priors, bars, accomplices, pending reports +/datum/outpost_police/proc/tick_warrants() + return diff --git a/voidcrew/modules/overmap/code/modules/overmap/_overmap.dm b/voidcrew/modules/overmap/code/modules/overmap/_overmap.dm index 1ad6965e29c..ce6359881e3 100644 --- a/voidcrew/modules/overmap/code/modules/overmap/_overmap.dm +++ b/voidcrew/modules/overmap/code/modules/overmap/_overmap.dm @@ -257,8 +257,9 @@ GLOBAL_LIST_EMPTY(overmap_objects) // The berth lifecycle procs themselves live in voidcrew/modules/trade/outpost_hangar.dm. /// Called when someone attacks host-owned service machinery (elevator panels, -/// outpost doors). Trader outposts escalate to embargo/turrets; no-op by default. -/obj/structure/overmap/proc/register_aggression(mob/living/offender) +/// outpost doors). Trader outposts hand it to their police (OUTPOST_CRIME_* `crime`, +/// the `victim` and the item used as `source`); no-op by default. +/obj/structure/overmap/proc/register_aggression(mob/living/offender, crime = OUTPOST_CRIME_VANDALISM, atom/victim, obj/item/source) return /** * When something crosses another overmap object, add it to the nearby objects list, which are used by events and docking diff --git a/voidcrew/modules/player_outposts/outpost_prison_security.dm b/voidcrew/modules/player_outposts/outpost_prison_security.dm index f8f5351b6e9..979032c6371 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_security.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_security.dm @@ -22,7 +22,7 @@ * On a player outpost a turret's controls, and a turret control panel's, answer only to the * outpost's members, as a hull turret's do there (ship_defense_turret.dm). * - * Hull defense turrets and trader outpost turrets keep their own rules (uses_outpost_turret_rules()). + * Hull defense turrets keep their own rules (uses_outpost_turret_rules()). * Numbers in voidcrew/_DEFINES/outpost_prison_security.dm. */ @@ -108,7 +108,7 @@ /** * Whether the prison's rules and the outpost's control lock hold for this turret: for every - * turret players can build or bring. Hull defense turrets and trader outpost turrets have their own. + * turret players can build or bring. Hull defense turrets have their own. */ /obj/machinery/porta_turret/proc/uses_outpost_turret_rules() return TRUE @@ -116,9 +116,6 @@ /obj/machinery/porta_turret/ship_defense/uses_outpost_turret_rules() return FALSE -/obj/machinery/porta_turret/outpost/uses_outpost_turret_rules() - return FALSE - /// The stock scan found `targets`; the prison's mobs the rule spares are dropped before one is picked /obj/machinery/porta_turret/tryToShootAt(list/atom/movable/targets) if(!uses_outpost_turret_rules()) diff --git a/voidcrew/modules/trade/outpost.dm b/voidcrew/modules/trade/outpost.dm index b74a47b3ce5..4abc3224b6b 100644 --- a/voidcrew/modules/trade/outpost.dm +++ b/voidcrew/modules/trade/outpost.dm @@ -7,8 +7,8 @@ * Unlike space ruins, outposts never unload, never move and never respawn. * The interior is lazy-loaded on first dock and then stays for the round. * The structure is indestructible by construction (CentCom-style turfs and - * machinery); the deterrent against aggression is economic (turrets plus a - * per-ship trade embargo), not HP. + * machinery); the deterrent against aggression is its police (voidcrew/modules/outpost_police/) + * and a per-ship trade embargo, not HP. */ /// All trader outposts on the overmap (used by vouchers/missions to list trader locations) @@ -71,12 +71,6 @@ GLOBAL_LIST_EMPTY(trader_outposts) // template_bottom_left) live on /obj/structure/overmap, see _overmap.dm. /// Ships under trade embargo: ship -> world.time the embargo ends var/list/embargoed_ships = list() - /// Minds that committed violence here: mind -> world.time the mark lapses (turret targets, refused service) - var/list/aggressor_minds = list() - /// Warning strikes accrued before turrets engage: mind -> infraction count - var/list/aggressor_strikes = list() - /// Last time each mind took a strike: mind -> world.time (see OUTPOST_AGGRESSION_GRACE) - var/list/aggressor_strike_times = list() /// Personal retaliation exemptions: defender mind -> (attacker mind -> expiry time). var/list/self_defense_targets = list() /// Posted (not yet accepted) contracts (see outpost_missions.dm / outpost_quests.dm) @@ -85,8 +79,6 @@ GLOBAL_LIST_EMPTY(trader_outposts) var/mob/living/basic/outpost_trader/trader /// All linked trader NPCs, main trader and vendor stalls alike var/list/mob/living/basic/outpost_trader/traders = list() - /// Linked defense turrets - var/list/obj/machinery/porta_turret/outpost/turrets = list() /// Looping timer id for the supply convoy restock var/restock_timer /// Board clock is independent of visits, acceptance and the supply convoy. @@ -105,6 +97,7 @@ GLOBAL_LIST_EMPTY(trader_outposts) GLOB.trader_outposts += src berths = new /list(OUTPOST_MAX_BERTHS) ensure_main_shop() + ensure_police() if(SSovermap_zones.initialized) start_contract_offers() else @@ -159,12 +152,9 @@ GLOBAL_LIST_EMPTY(trader_outposts) template_bottom_left = null embargoed_ships.Cut() clear_checkpoint_state() - aggressor_minds.Cut() - aggressor_strikes.Cut() - aggressor_strike_times.Cut() + QDEL_NULL(police) self_defense_targets.Cut() QDEL_LIST(shop_offers) - turrets.Cut() traders.Cut() trader = null loaded = FALSE @@ -236,6 +226,12 @@ GLOBAL_LIST_EMPTY(trader_outposts) var/list/obj/machinery/door/airlock/outpost/checkpoint/checkpoint_doors = list() var/list/obj/effect/landmark/outpost_checkpoint_chamber/checkpoint_chambers = list() var/list/obj/machinery/outpost_checkpoint_sensor/checkpoint_sensors = list() + // The police station's parts (voidcrew/modules/outpost_police/police_core.dm, link_interior()) + var/list/obj/effect/landmark/police_landmarks = list() + var/list/obj/machinery/door/airlock/outpost/police_doors = list() + var/list/obj/machinery/status_display/outpost_berth/police_cell/police_signs = list() + var/list/obj/structure/closet/secure_closet/personal/outpost_police/police_lockers = list() + var/obj/machinery/outpost_police_evidence/police_chute for(var/turf/interior_turf as anything in block(template_bottom_left, top_right)) // block() iterates y-major then x, same order the hangar-side alcove // collects in, so the elevator can map alcove turf i to alcove turf i. @@ -247,6 +243,14 @@ GLOBAL_LIST_EMPTY(trader_outposts) qdel(pad_mark) for(var/obj/effect/landmark/outpost_checkpoint_chamber/chamber_mark in interior_turf) checkpoint_chambers += chamber_mark + for(var/obj/effect/landmark/outpost_police_lift/police_mark in interior_turf) + police_landmarks += police_mark + for(var/obj/effect/landmark/outpost_police_desk/police_mark in interior_turf) + police_landmarks += police_mark + for(var/obj/effect/landmark/outpost_police_cell/police_mark in interior_turf) + police_landmarks += police_mark + for(var/obj/structure/closet/secure_closet/personal/outpost_police/locker in interior_turf) + police_lockers += locker for(var/mob/living/basic/outpost_trader/npc in interior_turf) npc.outpost = src npc.shop = get_shop(npc.shop_type) @@ -255,15 +259,17 @@ GLOBAL_LIST_EMPTY(trader_outposts) if(isnull(npc.shop_type)) // the main shop's trader is the outpost's face trader = npc for(var/obj/machinery/machine in interior_turf) - if(istype(machine, /obj/machinery/porta_turret/outpost)) - var/obj/machinery/porta_turret/outpost/turret = machine - turret.outpost = src - turrets += turret - else if(istype(machine, /obj/machinery/door/airlock/outpost)) + if(istype(machine, /obj/machinery/door/airlock/outpost)) var/obj/machinery/door/airlock/outpost/door = machine door.outpost = src if(istype(door, /obj/machinery/door/airlock/outpost/checkpoint)) checkpoint_doors += door + else if(istype(door, /obj/machinery/door/airlock/outpost/police) || istype(door, /obj/machinery/door/airlock/outpost/glass/police_cell)) + police_doors += door + else if(istype(machine, /obj/machinery/status_display/outpost_berth/police_cell)) + police_signs += machine + else if(istype(machine, /obj/machinery/outpost_police_evidence)) + police_chute = machine else if(istype(machine, /obj/machinery/outpost_checkpoint_sensor)) checkpoint_sensors += machine else if(istype(machine, /obj/machinery/outpost_elevator)) @@ -283,6 +289,7 @@ GLOBAL_LIST_EMPTY(trader_outposts) // Only parts collected from this concourse reservation form a checkpoint // (outpost_checkpoint.dm); the guards linked above are matched to theirs. build_checkpoints(checkpoint_doors, checkpoint_chambers, checkpoint_sensors) + ensure_police().link_interior(police_landmarks, police_doors, police_signs, police_lockers, police_chute) start_armed_sweep() // The traders' names/appearances depend on their shops, so setup runs // post-link. In the roundstart pre-load path the mobs haven't initialized @@ -432,8 +439,10 @@ GLOBAL_LIST_EMPTY(trader_outposts) * Only the attacked person may retaliate, and only against their attacker. * Check the exemption before recording aggression so defending oneself never * gives the original attacker a reciprocal exemption or extends the fight. + * The victim's window opens before the police hear of it, seen or not (abuse M14). + * `crime` is an OUTPOST_CRIME_* and `source` the item the hit was done with, if any. */ -/obj/structure/overmap/trader_outpost/proc/register_pvp_aggression(mob/living/victim, mob/living/offender) +/obj/structure/overmap/trader_outpost/proc/register_pvp_aggression(mob/living/victim, mob/living/offender, crime = OUTPOST_CRIME_ASSAULT_ARMED, obj/item/source) if(!victim?.mind || !offender?.mind || victim.mind == offender.mind) return var/list/allowed_targets = self_defense_targets[offender.mind] @@ -449,58 +458,35 @@ GLOBAL_LIST_EMPTY(trader_outposts) if(!victim_targets) victim_targets = list() self_defense_targets[victim.mind] = victim_targets - // Even hits inside the strike grace period or from an already marked - // aggressor renew the victim's permission to defend themselves. + // Even hits the police fold together or never see renew the victim's + // permission to defend themselves. victim_targets[offender.mind] = world.time + OUTPOST_SELF_DEFENSE_DURATION - register_aggression(offender) + register_aggression(offender, crime, victim, source) /** - * Called when someone attacks outpost property or another visitor. The first - * infractions only issue a warning; once the offender racks up - * OUTPOST_AGGRESSION_STRIKES the outpost marks them and embargoes every ship - * whose crew they belong to. Idempotent once marked, confirmed aggressors - * short-circuit here until the mark lapses. + * Called when someone attacks outpost property, its people or another visitor. The + * outpost police decide whether it counted (a witness, the dedupe) and what it does + * to the offender's wanted level (voidcrew/modules/outpost_police/police_wanted.dm). + * A counted crime tells the bystanders to duck; the trader has a word for a scuffle + * still under one star, and another when someone first reaches three. */ -/obj/structure/overmap/trader_outpost/register_aggression(mob/living/offender) +/obj/structure/overmap/trader_outpost/register_aggression(mob/living/offender, crime = OUTPOST_CRIME_VANDALISM, atom/victim, obj/item/source) if(!istype(offender) || !offender.mind) return if(bounty_kingpin_excuses_aggression(src, offender)) return // BOUNTY P9 (kingpin): no property strikes for hunters in his shootout; PvP still counts - // World population (voidcrew/modules/ambient_npcs): bystanders near the fight duck and leave - SEND_SIGNAL(src, COMSIG_TRADER_OUTPOST_VIOLENCE, offender) - if(is_marked_aggressor(offender.mind)) + if(!police) return - - // One swing can arrive here down several routes in the same tick; fold those - // together without letting a second real swing ride along for free. - var/last_strike = aggressor_strike_times[offender.mind] - if(last_strike && world.time < last_strike + OUTPOST_AGGRESSION_GRACE) + var/stars_before = police.get_stars(offender.mind) + var/stars_after = police.report_crime(offender, crime, victim, source) + if(isnull(stars_after)) return - // Warnings go stale on the same clock as a mark: a scuffle hours ago should - // not turn today's first shove into the final strike. - if(last_strike && world.time >= last_strike + OUTPOST_AGGRESSION_MARK_DURATION) - aggressor_strikes -= offender.mind - aggressor_strike_times[offender.mind] = world.time - - var/strikes = aggressor_strikes[offender.mind] + 1 - aggressor_strikes[offender.mind] = strikes - - // Not over the line yet: warn and give them a chance to stand down. - if(strikes < OUTPOST_AGGRESSION_STRIKES) - to_chat(offender, span_userdanger("Outpost defense systems train on you in warning.")) - if(trader) - trader.speak_line(TRADER_LINE_WARNING) + // World population (voidcrew/modules/ambient_npcs): bystanders near the fight duck and leave + SEND_SIGNAL(src, COMSIG_TRADER_OUTPOST_VIOLENCE, offender) + if(!trader) return - - // Final strike: mark them and embargo their crew's ships. - aggressor_minds[offender.mind] = world.time + OUTPOST_AGGRESSION_MARK_DURATION - - var/datum/voidcrew_crew_record/crew = offender.mind.get_reputation_crew() - for(var/datum/team/voidcrew/team in crew?.teams) - if(team.ship) - embargo_ship(team.ship) - - to_chat(offender, span_userdanger("Outpost defense systems lock onto you.")) - if(trader) + if(stars_after < 1) + trader.speak_line(TRADER_LINE_WARNING) + else if(stars_before < 3 && stars_after >= 3) trader.speak_line(TRADER_LINE_AGGRESSION) /** @@ -524,52 +510,13 @@ GLOBAL_LIST_EMPTY(trader_outposts) return TRUE /** - * Whether this mind is currently marked as an aggressor. Lapsed marks are - * pruned lazily, along with the strike ladder that produced them, so someone - * coming back after serving their time starts from a clean record. - */ -/obj/structure/overmap/trader_outpost/proc/is_marked_aggressor(datum/mind/mind) - var/mark_ends = aggressor_minds[mind] - if(!mark_ends) - return FALSE - if(world.time >= mark_ends) - aggressor_minds -= mind - aggressor_strikes -= mind - aggressor_strike_times -= mind - return FALSE - return TRUE - -/** - * Whether this user is refused service: personally marked as an aggressor, - * crew of an embargoed ship, or recently caught armed past the checkpoint - * (outpost_trespass.dm). + * Whether this user is refused service: the police answer it (stars, a bar, or crew + * of an embargoed ship; voidcrew/modules/outpost_police/police_wanted.dm). */ /obj/structure/overmap/trader_outpost/proc/is_user_barred(mob/user) - if(!user?.mind) - return FALSE - return is_aggression_barred(user) || is_trespass_barred(user.mind) - -/// Marked as an aggressor, or crew of an embargoed ship -/obj/structure/overmap/trader_outpost/proc/is_aggression_barred(mob/user) - if(!user?.mind) + if(!user?.mind || !police) return FALSE - if(is_marked_aggressor(user.mind)) - return TRUE - var/datum/voidcrew_crew_record/crew = user.mind.get_reputation_crew() - for(var/datum/team/voidcrew/team in crew?.teams) - if(team.ship && is_ship_embargoed(team.ship)) - return TRUE - return FALSE - -/** - * Whether the outpost has barred this mob from the sanctuary: marked aggressors - * AND crew of embargoed ships, an embargo means shot on sight, not just refused - * service. Armed trespassers too, for their shorter hostile window only - * (outpost_trespass.dm). Per-mind, so it holds for a player in any body. The turrets layer - * their own on-sight rules (xenos, hostile wildlife) on top in valid_target(). - */ -/obj/structure/overmap/trader_outpost/proc/is_turret_target(mob/living/target) - return istype(target) && (is_aggression_barred(target) || is_armed_trespasser(target.mind)) + return police.is_barred(user) /** * Resolves which trader outpost protects a turf by checking its concourse and diff --git a/voidcrew/modules/trade/outpost_checkpoint.dm b/voidcrew/modules/trade/outpost_checkpoint.dm index e37848d65c4..a43e201eb95 100644 --- a/voidcrew/modules/trade/outpost_checkpoint.dm +++ b/voidcrew/modules/trade/outpost_checkpoint.dm @@ -19,7 +19,8 @@ * way (the exterior hatches need that; the lobby checkpoints have a one-way exit gate as well). * - The inner door can be pried open with a crowbar or jaws of life, or hacked with an emag. It takes * OUTPOST_CHECKPOINT_FORCE_TIME and raises the alarm; anyone who then goes through it from the - * chamber is flagged as an armed trespasser (outpost_trespass.dm). + * chamber is reported to the outpost police as a breach (voidcrew/modules/outpost_police/), the + * forcer's tool with them. * - A door never closes on anyone in its doorway: it waits for them to move. * - NPCs never work the doors by walking into them, and pathing never takes an NPC through a chamber. * Someone off the lift crosses by asking each door once (outpost_checkpoint_npcs.dm). @@ -113,6 +114,10 @@ var/phase_timer /// Someone is prying or hacking the inner door right now var/forcing = FALSE + /// Who forced the inner door open, while it is breached + var/datum/weakref/breacher_ref + /// What they forced it with, while it is breached: evidence for the police + var/datum/weakref/breach_tool_ref /// Scan as soon as the outer door has closed (someone asked to go through) var/scan_when_closed = FALSE /// The current cycle was asked for from the concourse side @@ -529,6 +534,8 @@ if(!succeeded) update_sensor() return FALSE + breacher_ref = WEAKREF(user) + breach_tool_ref = WEAKREF(tool) breach(user) return TRUE @@ -547,22 +554,32 @@ /datum/outpost_checkpoint/proc/end_breach() phase_timer = null + breacher_ref = null + breach_tool_ref = null if(!QDELETED(outer_door) && !outer_door.density) close_door(outer_door) close_door(inner_door) -/// Someone stepped into the forced inner doorway +/// Someone stepped into the forced inner doorway: a crime for the police (outpost_police/police_wanted.dm) /datum/outpost_checkpoint/proc/on_breach_entry(atom/movable/arrived, atom/old_loc) - if(state != CHECKPOINT_FORCED || !outpost) + if(state != CHECKPOINT_FORCED || !outpost?.police) return var/turf/came_from = get_turf(old_loc) if(came_from && !(came_from in chamber_turfs)) return // from the concourse side: already inside - var/flagged = FALSE + var/datum/outpost_police/police = outpost.police + var/mob/living/breacher = breacher_ref?.resolve() + var/obj/item/breach_tool = breach_tool_ref?.resolve() + var/raised = FALSE for(var/mob/living/person in arrived.get_all_contents()) - if(person.mind && outpost.flag_trespasser(person)) - flagged = TRUE - if(flagged) + if(!person.mind) + continue + var/stars_before = police.get_stars(person.mind) + // The forcer's crowbar or emag is evidence; nobody else brought it + var/stars_after = police.report_crime(person, OUTPOST_CRIME_CHECKPOINT_BREACH, null, person == breacher ? breach_tool : null) + if(!isnull(stars_after) && stars_after > stars_before) + raised = TRUE + if(raised) announce(CHECKPOINT_LINE_INTRUDER, urgent = TRUE) // ========================================================================= @@ -753,9 +770,8 @@ overlays_file = 'icons/obj/doors/airlocks/centcom/overlays.dmi' can_be_glass = FALSE -/// Whether outpost property lets a crowbar get to work on this. Only a checkpoint's inner door says yes. -/obj/proc/outpost_forceable() - return FALSE +// /obj/proc/outpost_forceable() is declared in voidcrew/modules/outpost_police/police_core.dm, which is +// included before this module, so the police cell door can override it too. // ========================================================================= // THE WALL SCANNER diff --git a/voidcrew/modules/trade/outpost_security.dm b/voidcrew/modules/trade/outpost_security.dm index 9b89bbd6146..58079aa204e 100644 --- a/voidcrew/modules/trade/outpost_security.dm +++ b/voidcrew/modules/trade/outpost_security.dm @@ -1,14 +1,10 @@ /** * # Outpost Security * - * The economic-deterrent enforcement arm of a trader outpost: indestructible - * lethal turrets that engage people who attacked outpost property or another - * visitor (plus xenos and hostile wildlife, on sight), and the indestructible - * airlocks of the sanctuary interior. - * - * Aggression accrues warning strikes (see trader_outpost.register_aggression); - * the early hits only issue a warning, and only once the offender crosses - * OUTPOST_AGGRESSION_STRIKES is their mind marked and shot on sight. + * How violence at a trader outpost reaches its police (voidcrew/modules/outpost_police/): + * a watcher on every living mob for player-on-player attacks, the indestructible airlocks + * and walls of the sanctuary interior, and outpost property. Each route names its crime + * (OUTPOST_CRIME_*) and the item it was done with; the police decide what it costs. */ // ========================================================================= @@ -74,35 +70,36 @@ GLOBAL_DATUM_INIT(outpost_pvp_enforcement, /datum/outpost_pvp_enforcement, new) RegisterSignal(living_mob, COMSIG_MOB_FIRED_GUN, PROC_REF(on_outpost_gun_fired)) /** - * Checks personal self-defense before applying strikes and embargoes. Both - * parties need minds so outpost NPCs, fauna, and ordinary interactions with - * them retain their existing behavior. + * Checks personal self-defense before the police hear of it. Both parties need + * minds so outpost NPCs, fauna, and ordinary interactions with them retain their + * existing behavior. `crime` is an OUTPOST_CRIME_*; `source` the item used, if any. */ -/datum/outpost_pvp_enforcement/proc/register_pvp_aggression(mob/living/victim, mob/living/offender) +/datum/outpost_pvp_enforcement/proc/register_pvp_aggression(mob/living/victim, mob/living/offender, crime = OUTPOST_CRIME_ASSAULT_ARMED, obj/item/source) if(!victim.mind || !offender?.mind || victim == offender) return var/obj/structure/overmap/trader_outpost/guarding_outpost = get_trader_outpost_for_turf(get_turf(victim)) - guarding_outpost?.register_pvp_aggression(victim, offender) + guarding_outpost?.register_pvp_aggression(victim, offender, crime, source) /datum/outpost_pvp_enforcement/proc/on_outpost_pvp_item_attack(mob/living/victim, obj/item/weapon, mob/living/offender, list/modifiers, list/attack_modifiers) SIGNAL_HANDLER if(weapon.force) - register_pvp_aggression(victim, offender) + register_pvp_aggression(victim, offender, OUTPOST_CRIME_ASSAULT_ARMED, weapon) /datum/outpost_pvp_enforcement/proc/on_outpost_pvp_unarmed_attack(mob/living/victim, mob/living/offender, list/modifiers) SIGNAL_HANDLER if(offender.combat_mode || LAZYACCESS(modifiers, RIGHT_CLICK)) - register_pvp_aggression(victim, offender) + register_pvp_aggression(victim, offender, OUTPOST_CRIME_ASSAULT_UNARMED) /datum/outpost_pvp_enforcement/proc/on_outpost_pvp_npc_attack(mob/living/victim, mob/living/offender) SIGNAL_HANDLER if(offender.melee_damage_upper > 0) - register_pvp_aggression(victim, offender) + register_pvp_aggression(victim, offender, OUTPOST_CRIME_ASSAULT_ARMED) /datum/outpost_pvp_enforcement/proc/on_outpost_pvp_projectile(mob/living/victim, obj/projectile/hitting_projectile) SIGNAL_HANDLER if(hitting_projectile.is_hostile_projectile() && isliving(hitting_projectile.firer)) - register_pvp_aggression(victim, hitting_projectile.firer) + var/obj/item/fired_from = hitting_projectile.fired_from + register_pvp_aggression(victim, hitting_projectile.firer, OUTPOST_CRIME_ASSAULT_SHOT, isitem(fired_from) ? fired_from : null) /datum/outpost_pvp_enforcement/proc/on_outpost_pvp_thrown_item(mob/living/victim, atom/movable/hitting_atom, datum/thrownthing/throwingdatum) SIGNAL_HANDLER @@ -111,17 +108,17 @@ GLOBAL_DATUM_INIT(outpost_pvp_enforcement, /datum/outpost_pvp_enforcement, new) var/obj/item/thrown_item = hitting_atom var/mob/living/offender = throwingdatum?.get_thrower() if(thrown_item.throwforce && istype(offender)) - register_pvp_aggression(victim, offender) + register_pvp_aggression(victim, offender, OUTPOST_CRIME_ASSAULT_ARMED, thrown_item) /datum/outpost_pvp_enforcement/proc/on_outpost_pvp_hulk_attack(mob/living/victim, mob/living/offender) SIGNAL_HANDLER - register_pvp_aggression(victim, offender) + register_pvp_aggression(victim, offender, OUTPOST_CRIME_ASSAULT_ARMED) /datum/outpost_pvp_enforcement/proc/on_outpost_pvp_mech_attack(mob/living/victim, obj/vehicle/sealed/mecha/mecha_attacker, mob/living/pilot) SIGNAL_HANDLER - register_pvp_aggression(victim, pilot) + register_pvp_aggression(victim, pilot, OUTPOST_CRIME_ASSAULT_ARMED) -/// Firing a gun past a trader outpost's checkpoint without a permit marks the shooter as an armed trespasser (outpost_trespass.dm) +/// Firing a gun past a trader outpost's checkpoint without a permit is gunfire for the police; the gun is evidence /datum/outpost_pvp_enforcement/proc/on_outpost_gun_fired(mob/living/shooter, obj/item/gun/gun, atom/target) SIGNAL_HANDLER if(!outpost_checkpoint_weapon(gun)) @@ -130,148 +127,7 @@ GLOBAL_DATUM_INIT(outpost_pvp_enforcement, /datum/outpost_pvp_enforcement, new) var/obj/structure/overmap/trader_outpost/outpost = get_trader_outpost_for_turf(where) if(!outpost?.is_secured_turf(where) || !shooter.mind || outpost.has_weapons_permit(shooter, OUTPOST_WEAPONS_PERMIT_GRACE)) return - outpost.flag_trespasser(shooter) - -/obj/machinery/porta_turret/outpost - name = "outpost defense turret" - desc = "An over-engineered defense turret bearing a polite brass plaque: 'Violence is bad for business.'" - installation = null - uses_stored = FALSE // without this, process() PROCESS_KILLs over the null stored_gun and the turret never scans - max_integrity = 500 - always_up = TRUE - has_cover = FALSE - use_power = NO_POWER_USE - resistance_flags = INDESTRUCTIBLE | LAVA_PROOF | FIRE_PROOF | UNACIDABLE | ACID_PROOF - scan_range = 9 - stun_projectile = /obj/projectile/beam/laser/outpost - lethal_projectile = /obj/projectile/beam/laser/outpost - lethal_projectile_sound = 'sound/items/weapons/plasma_cutter.ogg' - stun_projectile_sound = 'sound/items/weapons/plasma_cutter.ogg' - icon_state = "syndie_off" - base_icon_state = "syndie" - // Players' default faction IS "neutral", including FACTION_NEUTRAL here (like - // the pacifist centcom turrets do) would faction-exempt every player from targeting - faction = list(FACTION_TURRET) - mode = 1 // TURRET_LETHAL, the define is file-local to portable_turret.dm - turret_flags = NONE - - /// The outpost this turret defends (set by the outpost on interior load) - var/obj/structure/overmap/trader_outpost/outpost - -/obj/machinery/porta_turret/outpost/Initialize(mapload) - . = ..() - AddElement(/datum/element/empprotection, EMP_PROTECT_SELF | EMP_PROTECT_WIRES) - -/obj/machinery/porta_turret/outpost/Destroy() - outpost = null - return ..() - -/obj/machinery/porta_turret/outpost/setup() - return - -// No ID unlocks the controls: the inherited ACCESS_SECURITY req_access would let -// any captain or security crew swipe the enforcement offline. Keeping the swipe -// denied also keeps `on` TRUE forever, which is what blocks the wrench-unanchor -// path to walking off with an indestructible lethal turret. -/obj/machinery/porta_turret/outpost/allowed(mob/accessor) - return FALSE - -// No settings UI at all: the power toggle in ui_act() isn't gated on `locked`, -// so the panel must never open in the first place -/obj/machinery/porta_turret/outpost/ui_interact(mob/user, datum/tgui/ui) - return - -/obj/machinery/porta_turret/outpost/emag_act(mob/user, obj/item/card/emag/emag_card) - return FALSE - -/** - * Is this something the turret is willing to shoot? - * - * Three kinds of target, checked in order: - * - Anyone the outpost has barred: marked aggressors and embargoed crews. Resolved - * per-mind by the outpost, so it covers players riding a xeno or a spider just as - * well as a human with a gun. - * - Xenomorphs, on sight. No warning strikes, no faction pass, player-driven or not; - * a hive does not shop here. - * - Wild hostiles: NPC creatures whose own AI goes looking for a fight (carp, spiders, - * boarding troopers, ...). Judged by is_hostile_creature(), so a shopper's corgi or a - * pet carp is left alone. Anything with a player behind it is not a wild creature - * and only becomes a target through the strike ladder above. - * - * Everything else shops in peace. - */ -/obj/machinery/porta_turret/outpost/proc/valid_target(mob/living/creature) - if(!istype(creature) || creature.stat == DEAD) - return FALSE - if(creature.invisibility > SEE_INVISIBLE_LIVING) - return FALSE - if(outpost?.is_turret_target(creature)) - return TRUE - if(isalien(creature) || istype(creature, /mob/living/basic/alien)) // Player xenos and the NPC hive alike. - return TRUE - if(creature.client || creature.mind) // Player-driven, so the strike ladder decides, not the wildlife rule. - return FALSE - if(in_faction(creature)) // Traders, ambient outpost NPCs, bots and the other turrets. - return FALSE - if(HAS_TRAIT(creature, TRAIT_OUTPOST_RESIDENT)) // Whatever the outpost was built with. - return FALSE - return is_hostile_creature(creature) - -// The stock scan only ever asks assess_perp() about humans; with turret_flags NONE it -// never so much as looks at an animal or a xeno. Run our own scan over every living -// mob in range instead, plus the stock mech sweep so a marked aggressor cannot hide in -// a ripley. -/obj/machinery/porta_turret/outpost/process() - if(!on || (machine_stat & (NOPOWER|BROKEN))) - return PROCESS_KILL - - var/list/targets = list() - for(var/mob/living/creature in view(scan_range, base)) - if(valid_target(creature)) - targets += creature - - for(var/obj/vehicle/sealed/mecha/mech as anything in GLOB.mechas_list) - if(get_dist(mech, base) >= scan_range || !can_see(base, mech, scan_range)) - continue - for(var/mob/living/occupant as anything in mech.occupants) - if(valid_target(occupant)) - targets += mech - break - - if(length(targets)) - tryToShootAt(targets) - -// Shooting the turret itself is also aggression -/obj/machinery/porta_turret/outpost/attacked_by(obj/item/attacking_item, mob/living/user, list/modifiers, list/attack_modifiers) - if(attacking_item.force && outpost) - outpost.register_aggression(user) - return ..() - -/obj/machinery/porta_turret/outpost/bullet_act(obj/projectile/hitting_projectile, def_zone, piercing_hit = FALSE) - if(outpost && isliving(hitting_projectile.firer)) - outpost.register_aggression(hitting_projectile.firer) - return ..() - -/** - * # Outpost Defense Laser - * - * Fired only by outpost turrets. Phases harmlessly through bystanders and only - * impacts valid turret targets (marked aggressors, embargoed crew, xenos and - * hostile wildlife) so enforcement never catches innocent shoppers in the - * crossfire. Dense obstacles (walls, structures) still stop it as normal. - */ -/obj/projectile/beam/laser/outpost - name = "outpost defense laser" - -/obj/projectile/beam/laser/outpost/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) - // Let bystanders through: skip any living mob the firing turret would not shoot at. - // The aimed offender arrives as direct_target and any other valid target in the - // path passes valid_target(), so both are still hit by the parent check. - if(isliving(target) && !direct_target) - var/obj/machinery/porta_turret/outpost/turret = firer - if(istype(turret) && !turret.valid_target(target)) - return FALSE - return ..() + outpost.police?.report_crime(shooter, OUTPOST_CRIME_GUNFIRE, null, gun) /** * # Outpost Airlock diff --git a/voidcrew/modules/trade/outpost_trespass.dm b/voidcrew/modules/trade/outpost_trespass.dm index 9074fbc0acd..d569a785e67 100644 --- a/voidcrew/modules/trade/outpost_trespass.dm +++ b/voidcrew/modules/trade/outpost_trespass.dm @@ -2,16 +2,12 @@ * # Armed trespass at trader outposts * * Past a trader outpost's checkpoint (outpost_checkpoint.dm), a weapon is only carried openly with a - * weapons permit bought from the checkpoint guard. Anyone else is an armed trespasser when they are: - * - seen with a gun drawn (held in hand for OUTPOST_ARMED_SIGHTING_GRACE on the outpost's sweep), - * - heard firing one, or - * - caught going through a forced checkpoint door. - * A gun kept holstered or smuggled in a bag is not noticed until it comes out. - * - * An armed trespasser is personal (by mind, never their crew): the outpost's turrets treat them as - * hostile until OUTPOST_TRESPASS_HOSTILE_DURATION after they were last seen armed inside, and every - * trader, the permit desk and the checkpoint refuse them until OUTPOST_TRESPASS_BAR_DURATION after - * it. Attacks still go through the ordinary aggression rules (outpost.dm). + * weapons permit bought from the checkpoint guard. Anyone else seen with a gun drawn (held in hand for + * OUTPOST_ARMED_SIGHTING_GRACE on the outpost's sweep) is reported to the outpost police with each + * drawn weapon (voidcrew/modules/outpost_police/), and loses any permit they had. Firing one is + * reported as gunfire (outpost_security.dm), going through a forced checkpoint door as a breach + * (outpost_checkpoint.dm). A gun kept holstered or smuggled in a bag is not noticed until it comes out. + * What it costs them, and whether anyone refuses them service, is the police's business. * * Goods bought or paid out past the checkpoint come sealed when they hold a gun, so nobody is * flagged for carrying home what they just bought. @@ -24,10 +20,6 @@ var/list/weapon_permits = list() /// Permit holders already told their permit ran out: mind -> TRUE var/list/permit_lapse_told = list() - /// Armed trespassers: mind -> world.time they were last seen armed past the checkpoint - var/list/armed_trespassers = list() - /// Refused service after armed trespass: mind -> world.time the bar lifts - var/list/trespass_bars = list() /// Someone currently holding a gun past the checkpoint: mind -> world.time first seen doing it var/list/armed_sightings = list() /// Looping timer for armed_sweep() @@ -43,8 +35,6 @@ QDEL_LIST_ASSOC_VAL(checkpoints) weapon_permits.Cut() permit_lapse_told.Cut() - armed_trespassers.Cut() - trespass_bars.Cut() armed_sightings.Cut() network_pad_spot = null @@ -112,41 +102,35 @@ // ===== TRESPASS ===== -/// Whether the turrets treat `offender_mind` as hostile for armed trespass -/obj/structure/overmap/trader_outpost/proc/is_armed_trespasser(datum/mind/offender_mind) - var/last_seen = offender_mind && armed_trespassers[offender_mind] - return last_seen && world.time < last_seen + OUTPOST_TRESPASS_HOSTILE_DURATION - -/// Whether `offender_mind` is still refused service for armed trespass -/obj/structure/overmap/trader_outpost/proc/is_trespass_barred(datum/mind/offender_mind) - var/lifts_at = offender_mind && trespass_bars[offender_mind] - if(!lifts_at) - return FALSE - if(world.time >= lifts_at) - trespass_bars -= offender_mind - armed_trespassers -= offender_mind - return FALSE - return TRUE - /** - * `offender` was seen armed past the checkpoint without a permit (or came through a forced door). - * Starts or extends both clocks and voids any permit. Returns TRUE if they were not already hostile. - * Never sleeps: it runs from signal handlers. + * `offender` was seen armed past the checkpoint without a permit. Voids any permit, then reports a drawn + * weapon to the police once per weapon in hand, each as evidence. Returns TRUE if they have stars here + * afterwards. Never sleeps: it runs from signal handlers. */ /obj/structure/overmap/trader_outpost/proc/flag_trespasser(mob/living/offender) var/datum/mind/offender_mind = offender?.mind if(!offender_mind) return FALSE - var/newly_hostile = !is_armed_trespasser(offender_mind) - armed_trespassers[offender_mind] = world.time - trespass_bars[offender_mind] = max(trespass_bars[offender_mind], world.time + OUTPOST_TRESPASS_BAR_DURATION) weapon_permits -= offender_mind permit_lapse_told -= offender_mind armed_sightings -= offender_mind - if(newly_hostile) - log_game("OUTPOST TRESPASS: [key_name(offender)] was marked as an armed trespasser at [name] ([AREACOORD(offender)]).") - to_chat(offender, span_userdanger("The outpost's turrets turn toward you.")) - return newly_hostile + if(!police) + return FALSE + var/list/obj/item/drawn = outpost_drawn_weapons(offender) + if(!length(drawn)) // a mech's guns: nothing to take off them + return police.report_crime(offender, OUTPOST_CRIME_WEAPON_DRAWN) > 0 + var/wanted = FALSE + for(var/obj/item/weapon as anything in drawn) + if(police.report_crime(offender, OUTPOST_CRIME_WEAPON_DRAWN, null, weapon) > 0) + wanted = TRUE + return wanted + +/// The weapons `visitor` holds in hand +/proc/outpost_drawn_weapons(mob/living/visitor) + . = list() + for(var/obj/item/held in visitor.held_items) + if(outpost_checkpoint_weapon(held)) + . += held /// Whether `visitor` has a weapon out: in hand, or mounted on the mech they sit in /proc/outpost_weapon_drawn(mob/living/visitor) diff --git a/voidcrew/modules/trade/trader_npc.dm b/voidcrew/modules/trade/trader_npc.dm index 041d4522b73..5b562d9d5f4 100644 --- a/voidcrew/modules/trade/trader_npc.dm +++ b/voidcrew/modules/trade/trader_npc.dm @@ -143,7 +143,7 @@ /mob/living/basic/outpost_trader/attack_hand(mob/living/carbon/human/user, list/modifiers) if(user.combat_mode) . = ..() - outpost?.register_aggression(user) + outpost?.register_aggression(user, OUTPOST_CRIME_ASSAULT_STAFF, src) return // show_radial_menu sleeps; don't hold up the click chain INVOKE_ASYNC(src, PROC_REF(open_trader_menu), user) @@ -260,12 +260,13 @@ /mob/living/basic/outpost_trader/attacked_by(obj/item/attacking_item, mob/living/user, list/modifiers, list/attack_modifiers) if(attacking_item.force && outpost) - outpost.register_aggression(user) + outpost.register_aggression(user, OUTPOST_CRIME_ASSAULT_STAFF, src, attacking_item) return ..() /mob/living/basic/outpost_trader/bullet_act(obj/projectile/hitting_projectile, def_zone, piercing_hit = FALSE, blocked = 0) if(outpost && isliving(hitting_projectile.firer)) - outpost.register_aggression(hitting_projectile.firer) + var/obj/item/fired_from = hitting_projectile.fired_from + outpost.register_aggression(hitting_projectile.firer, OUTPOST_CRIME_ASSAULT_STAFF, src, isitem(fired_from) ? fired_from : null) return ..() // ===== VENDOR STALL VARIANTS (the outposts' side businesses) ===== diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 412f552333c..36372dfbbe4 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -192,6 +192,16 @@ #include "voidcrew_outpost_network.dm" #include "voidcrew_outpost_npc_looks.dm" #include "voidcrew_outpost_patrol.dm" +#include "voidcrew_outpost_police_arrest.dm" +#include "voidcrew_outpost_police_cell.dm" +#include "voidcrew_outpost_police_cutter.dm" +#include "voidcrew_outpost_police_dispatch.dm" +#include "voidcrew_outpost_police_helpers.dm" +#include "voidcrew_outpost_police_hud.dm" +#include "voidcrew_outpost_police_life.dm" +#include "voidcrew_outpost_police_property.dm" +#include "voidcrew_outpost_police_units.dm" +#include "voidcrew_outpost_police_wanted.dm" #include "voidcrew_outpost_prison_admin.dm" #include "voidcrew_outpost_prison_bounty.dm" #include "voidcrew_outpost_prison_breakout.dm" @@ -230,7 +240,6 @@ #include "voidcrew_outpost_storage.dm" #include "voidcrew_outpost_styles.dm" #include "voidcrew_outpost_turret_access.dm" -#include "voidcrew_outpost_turret_targeting.dm" #include "voidcrew_outpost_upgrade_snaps.dm" #include "voidcrew_outpost_upgrades.dm" #include "voidcrew_outpost_yard_droids.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_ambient_outposts.dm b/voidcrew/modules/unit_tests/voidcrew_ambient_outposts.dm index 4a5ebdc2f89..77cc7ff7c29 100644 --- a/voidcrew/modules/unit_tests/voidcrew_ambient_outposts.dm +++ b/voidcrew/modules/unit_tests/voidcrew_ambient_outposts.dm @@ -378,6 +378,8 @@ // DRINKERS (owner item 2) // ========================================================================= +// POLICE-PORT: reads aggressor_strikes; port to police.get_stars()/warrant.heat (P10) +#ifdef POLICE_TESTS_PORTED /** * A drinker orders at the bar, sits, really drinks a real glass (the drink goes into them, and tg's * drunkenness sets their stage), is cut off when done for, sleeps it off on a sofa for real (tg's @@ -491,6 +493,7 @@ crew.on_sip(FALSE) crew.on_sip(FALSE) TEST_ASSERT(crew.glass_is_strong(), "The crew room never moved on to something stronger") +#endif // ========================================================================= // STAFF (owner item 4) @@ -834,6 +837,8 @@ // KILLING // ========================================================================= +// POLICE-PORT: reads aggressor_strikes; port to police.get_stars()/warrant.heat (P10) +#ifdef POLICE_TESTS_PORTED /** * Every outpost NPC can be killed: they die of their wounds and drop 5 to 30 cr once, never again * after a revive, and the outpost's turrets count them as its own. A killed person's place stays @@ -926,6 +931,7 @@ TEST_ASSERT(!body.npc_corpse_expired(), "A fresh body is already up to be taken away") body.npc_died_at = world.time - 3001 // AMBIENT_OUTPOST_BODY_TIME TEST_ASSERT(body.npc_corpse_expired(), "A body nobody is near is not taken away after five minutes") +#endif /// Nobody new comes up the lift while someone is still in it /datum/unit_test/voidcrew_ambient_outpost_lift_one_at_a_time diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm index 59b600500ff..fd9059b3a68 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm @@ -972,6 +972,8 @@ // ===== THE MAP ===== +// POLICE-PORT: uses the removed outpost turret and strike ladder; port to an officer and warrant.heat (P10) +#ifdef POLICE_TESTS_PORTED /** * The black market's lounge: the seat on the corp sofa, no table of his own in front of it (he lost * his coffee table, and no lounge table stands at any of his six goon posts either), six goon posts @@ -1071,6 +1073,7 @@ TEST_ASSERT(length(loiter) >= 40, "The Undertow has only [length(loiter)] loiter tiles") TEST_ASSERT(!loiter[refuge], "The kingpin's refuge, mid-hallway, is a loiter tile") qdel(outpost) +#endif // ===== H1: SHOTS FROM MECHS, AND ANY DAMAGE ===== @@ -1212,6 +1215,8 @@ // ===== OUTPOST SECURITY ===== +// POLICE-PORT: uses the removed outpost turret and strike ladder; port to an officer and warrant.heat (P10) +#ifdef POLICE_TESTS_PORTED /** * During the shootout a hunter's stray shot, just after firing at his crew, takes no outpost strike: * three of them leave the turret holding fire, where three strikes mark anyone else. Hits away from @@ -1280,6 +1285,7 @@ outpost.aggressor_strike_times -= hunter.mind outpost.register_aggression(hunter) TEST_ASSERT_EQUAL(outpost.aggressor_strikes[hunter.mind], 1, "A hunter's property strike didn't count outside the shootout") +#endif // ===== THE LOW ONES ===== diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm index 562af32cb1c..6c411e5aeee 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm @@ -452,6 +452,8 @@ warrant.show_warrant(decoys[2], hunter) TEST_ASSERT_EQUAL(posting.alert, 1, "An out-of-date warrant raised the alert") +// POLICE-PORT: reads aggressor_strikes or the removed outpost turret; port to warrant.heat and force_level()/can_attack() (P10) +#ifdef POLICE_TESTS_PORTED /// Restraints reached for on someone blending in cost exactly what a punch costs: the decoy's wrists are a strike and the alert and it backs away, the fugitive's end its act. Down or held, the cuffs go on. A decoy confronted any other way raises the alert too (C1) /datum/unit_test/voidcrew_bounty_outpost/cuffs @@ -521,6 +523,7 @@ var/mob/living/carbon/human/npc/bounty_criminal/decoy/next_decoy = next_decoys[1] next_decoy.on_confronted(make_hunter()) TEST_ASSERT_EQUAL(next.alert, 1, "Confronting a decoy another way cost nothing") +#endif /// Two alerts: the decoys leave, and the fugitive walks out for real, still the posting's until it has gone; the bounty relists when it does. A fugitive caught on the way stays the posting's. An exposed fugitive stays /datum/unit_test/voidcrew_bounty_outpost/alert @@ -607,6 +610,8 @@ planet.placement_kind = "planet" // BOUNTY_PLACEMENT_PLANET TEST_ASSERT(!planet.raise_alert("a test"), "A planet bounty's alert rose") +// POLICE-PORT: reads aggressor_strikes or the removed outpost turret; port to warrant.heat and force_level()/can_attack() (P10) +#ifdef POLICE_TESTS_PORTED /// A deliberate hit on a decoy is an outpost strike and raises the alert; hitting the fugitive only exposes it. A shot hits whoever it hits /datum/unit_test/voidcrew_bounty_outpost/decoy_hit @@ -771,6 +776,7 @@ TEST_ASSERT(!next_fugitive.outpost_turret_target(), "Punching someone on its grudge list made the fugitive a turret target") SEND_SIGNAL(next_fugitive, COMSIG_LIVING_UNARMED_ATTACK, bystander, TRUE, list()) TEST_ASSERT(next_fugitive.outpost_turret_target(), "The fugitive punched a bystander and the turrets still leave it be") +#endif /// A hand on someone blending in is a hand on a person: they can be grabbed like anyone, and it is no hit. Standing or down, the fugitive and a decoy alike /datum/unit_test/voidcrew_bounty_outpost/grab diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm index d6f979fb5e5..049fed0ee61 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm @@ -168,6 +168,8 @@ TEST_ASSERT(bear.CanAttack(flicker), "A bear was fooled by the Flickerskin") TEST_ASSERT(boss.CanAttack(flicker), "A megafauna was fooled by the Flickerskin") +// POLICE-PORT: lists the removed outpost turret; port to the police targeting strategy (P10) +#ifdef POLICE_TESTS_PORTED /// Every turret type that can be handed a camo'd human drops them from the list it shoots at. /// tryToShootAt() returns 1 once it has settled on a target, and nothing when it has none. /datum/unit_test/voidcrew_cyberware_camo/turrets @@ -198,6 +200,7 @@ ghost.forceMove(spot(0, 2)) TEST_ASSERT(turret.tryToShootAt(list(ghost)), "[turret_type] missed a camo'd human 2 tiles off") ghost.forceMove(spot(4, 4)) +#endif /// Human NPCs: tg's basic targeting through the human NPC strategy, which pirate crews and boarders, /// ruin troopers and the club's people all run on. A regular NPC is fooled beyond range; a boss diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_police_arrest.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_police_arrest.dm new file mode 100644 index 00000000000..0b613424150 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_police_arrest.dm @@ -0,0 +1,5 @@ +/** + * Outpost police tests: police_arrest.dm (voidcrew/modules/outpost_police/). Owner: P5. + * Helpers are in voidcrew_outpost_police_helpers.dm. Fork defines are included after the tests: use + * literal values with a comment naming the define. + */ diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_police_cell.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_police_cell.dm new file mode 100644 index 00000000000..9f6b09a838c --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_police_cell.dm @@ -0,0 +1,5 @@ +/** + * Outpost police tests: police_cell.dm (voidcrew/modules/outpost_police/). Owner: P6. + * Helpers are in voidcrew_outpost_police_helpers.dm. Fork defines are included after the tests: use + * literal values with a comment naming the define. + */ diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_police_cutter.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_police_cutter.dm new file mode 100644 index 00000000000..3b457493643 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_police_cutter.dm @@ -0,0 +1,5 @@ +/** + * Outpost police tests: police_cutter.dm (voidcrew/modules/outpost_police/). Owner: P9. + * Helpers are in voidcrew_outpost_police_helpers.dm. Fork defines are included after the tests: use + * literal values with a comment naming the define. + */ diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_police_dispatch.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_police_dispatch.dm new file mode 100644 index 00000000000..41629e969b8 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_police_dispatch.dm @@ -0,0 +1,5 @@ +/** + * Outpost police tests: police_dispatch.dm (voidcrew/modules/outpost_police/). Owner: P4. + * Helpers are in voidcrew_outpost_police_helpers.dm. Fork defines are included after the tests: use + * literal values with a comment naming the define. + */ diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_police_helpers.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_police_helpers.dm new file mode 100644 index 00000000000..3ea730f2ace --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_police_helpers.dm @@ -0,0 +1,65 @@ +/** + * Outpost police (voidcrew/modules/outpost_police/): helpers every police test uses, and P0's smoke test. + * Owner: P0 seams, frozen. Package tests live in voidcrew_outpost_police_.dm. + * + * Fork defines are included after the tests, so a test uses the literal value with a comment naming the + * define. Tests pin other packages' outputs (warrant.heat, officer.force, cell.prisoners) directly and + * call a proc with the mobs already in place; no test waits for an AI walk. + */ + +/// A trader outpost of `outpost_type` with its interior loaded (null, and the test failed, if it did not load) +/datum/unit_test/proc/police_loaded_outpost(outpost_type = /obj/structure/overmap/trader_outpost/general) + var/obj/structure/overmap/trader_outpost/outpost = allocate(outpost_type) + if(!outpost.load_level()) + TEST_FAIL("[outpost_type]'s interior failed to load") + return null + return outpost + +/// A free public floor tile on `outpost`'s concourse +/datum/unit_test/proc/police_floor(obj/structure/overmap/trader_outpost/outpost) + var/datum/ambient_place/outpost/place = SSambient_npcs.outpost_place(outpost) + return place?.settle_turf() + +/// An officer of `officer_type` at `where`, in `outpost`'s ambient place and on its police roster +/datum/unit_test/proc/police_officer(obj/structure/overmap/trader_outpost/outpost, officer_type = /mob/living/carbon/human/npc/ambient_npc/outpost/police/beat, turf/where) + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = allocate(officer_type, where || police_floor(outpost)) + officer.set_place(SSambient_npcs.outpost_place(outpost)) + outpost.police.add_officer(officer) + return officer + +/// `mind`'s warrant at `police`, made if missing, with its heat pinned to `heat` +/datum/unit_test/proc/police_pin_warrant(datum/outpost_police/police, datum/mind/mind, heat = 0) + var/datum/outpost_warrant/warrant = police.warrants[mind] + if(!warrant) + warrant = new + warrant.mind = mind + police.warrants[mind] = warrant + warrant.heat = heat + warrant.peak = max(warrant.peak, heat) + return warrant + +/// A loaded trader outpost has police; a beat officer built there joins the roster, plans once without a runtime and holds still with nobody about +/datum/unit_test/voidcrew_outpost_police_smoke + +/datum/unit_test/voidcrew_outpost_police_smoke/Run() + var/obj/structure/overmap/trader_outpost/outpost = police_loaded_outpost() + if(!outpost) + return + var/datum/outpost_police/police = outpost.police + TEST_ASSERT_NOTNULL(police, "A loaded trader outpost has no police") + TEST_ASSERT_EQUAL(police.outpost, outpost, "The police do not know their outpost") + TEST_ASSERT_NOTNULL(police.perception, "The police have no witness test") + TEST_ASSERT_NOTNULL(police.profile, "The police have no profile") + var/turf/where = police_floor(outpost) + TEST_ASSERT_NOTNULL(where, "No free floor on the concourse for an officer") + + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = police_officer(outpost, /mob/living/carbon/human/npc/ambient_npc/outpost/police/beat, where) + TEST_ASSERT(officer.flags_1 & INITIALIZED_1, "A beat officer did not initialise") + TEST_ASSERT(officer in police.officers, "A beat officer is not on the roster") + TEST_ASSERT_EQUAL(officer.police, police, "A beat officer does not know their police") + TEST_ASSERT(istype(officer.ai_controller, /datum/ai_controller/basic_controller/human_npc/outpost_police), "A beat officer has [officer.ai_controller?.type], not the police brain") + officer.ai_controller.SelectBehaviors(1) + TEST_ASSERT(HAS_TRAIT(officer, TRAIT_AI_PAUSED), "A beat officer at an outpost with nobody on it is not holding still") + + qdel(officer) + TEST_ASSERT(!(officer in police.officers), "A deleted officer is still on the roster") diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_police_hud.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_police_hud.dm new file mode 100644 index 00000000000..5394eb93ddb --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_police_hud.dm @@ -0,0 +1,5 @@ +/** + * Outpost police tests: police_hud.dm (voidcrew/modules/outpost_police/). Owner: P8. + * Helpers are in voidcrew_outpost_police_helpers.dm. Fork defines are included after the tests: use + * literal values with a comment naming the define. + */ diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_police_life.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_police_life.dm new file mode 100644 index 00000000000..e90fbd35974 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_police_life.dm @@ -0,0 +1,5 @@ +/** + * Outpost police tests: police_life.dm (voidcrew/modules/outpost_police/). Owner: P3. + * Helpers are in voidcrew_outpost_police_helpers.dm. Fork defines are included after the tests: use + * literal values with a comment naming the define. + */ diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_police_property.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_police_property.dm new file mode 100644 index 00000000000..11663b4e228 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_police_property.dm @@ -0,0 +1,5 @@ +/** + * Outpost police tests: police_property.dm (voidcrew/modules/outpost_police/). Owner: P12. + * Helpers are in voidcrew_outpost_police_helpers.dm. Fork defines are included after the tests: use + * literal values with a comment naming the define. + */ diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_police_units.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_police_units.dm new file mode 100644 index 00000000000..2ea5f5813b2 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_police_units.dm @@ -0,0 +1,5 @@ +/** + * Outpost police tests: police_units.dm (voidcrew/modules/outpost_police/). Owner: P2. + * Helpers are in voidcrew_outpost_police_helpers.dm. Fork defines are included after the tests: use + * literal values with a comment naming the define. + */ diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_police_wanted.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_police_wanted.dm new file mode 100644 index 00000000000..6c89e41b33c --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_police_wanted.dm @@ -0,0 +1,5 @@ +/** + * Outpost police tests: police_wanted.dm (voidcrew/modules/outpost_police/). Owner: P1. + * Helpers are in voidcrew_outpost_police_helpers.dm. Fork defines are included after the tests: use + * literal values with a comment naming the define. + */ diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_self_defense.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_self_defense.dm index 9dc287e8c5f..d6dc5ccf846 100644 --- a/voidcrew/modules/unit_tests/voidcrew_outpost_self_defense.dm +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_self_defense.dm @@ -1,3 +1,5 @@ +// POLICE-PORT: reads aggressor_strikes; port to warrant.heat (P10) +#ifdef POLICE_TESTS_PORTED /// Personal retaliation must not punish the victim or excuse the original attacker. /datum/unit_test/voidcrew_outpost_self_defense/Run() var/obj/structure/overmap/trader_outpost/outpost = allocate(/obj/structure/overmap/trader_outpost) @@ -61,3 +63,4 @@ TEST_ASSERT(!LAZYACCESS(defender_targets, attacker.mind), "Expired retaliation permission must be pruned") var/list/attacker_targets = outpost.self_defense_targets[attacker.mind] TEST_ASSERT(attacker_targets[defender.mind] > world.time, "A new attack after expiry must let its victim defend themselves") +#endif diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_turret_targeting.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_turret_targeting.dm deleted file mode 100644 index 143347e35a8..00000000000 --- a/voidcrew/modules/unit_tests/voidcrew_outpost_turret_targeting.dm +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Outpost defense turrets shoot marked aggressors, xenos and hostile wildlife, and - * nothing else. - * - * The stock turret scan only ever looks at humans, so before the outpost turret grew its - * own scan a xeno or a carp could stroll through the concourse untouched. The wildlife - * rule is read off the mob's AI planning subtrees (voidcrew/_HELPERS/hostile_creatures.dm), - * so an upstream merge that reshuffles the basic-mob AI tree can silently flip a shopper's - * pet into a target. Pin both ends down here. - */ -/datum/unit_test/voidcrew_outpost_turret_targeting - -/datum/unit_test/voidcrew_outpost_turret_targeting/Run() - var/obj/structure/overmap/trader_outpost/outpost = allocate(/obj/structure/overmap/trader_outpost) - outpost.outpost_template = allocate(/datum/map_template/trader_outpost) - outpost.outpost_template.width = 1 - outpost.outpost_template.height = 1 - outpost.template_bottom_left = run_loc_floor_bottom_left - var/obj/machinery/porta_turret/outpost/turret = allocate(/obj/machinery/porta_turret/outpost) - turret.outpost = outpost - - // Xenos are shot on sight: no warning strikes, whether or not a player is driving. - var/static/list/xenos = list( - /mob/living/carbon/alien/larva, - /mob/living/carbon/alien/adult/drone, - /mob/living/basic/alien/drone, - ) - for(var/mob_type in xenos) - var/mob/living/xeno = allocate(mob_type) - TEST_ASSERT(turret.valid_target(xeno), "the turret refused to shoot [mob_type] on sight") - var/mob/living/carbon/alien/adult/drone/player_xeno = allocate(/mob/living/carbon/alien/adult/drone) - player_xeno.mind_initialize() - TEST_ASSERT(turret.valid_target(player_xeno), "the turret made a player-driven xeno earn strikes before firing") - - // Wild hostiles that come looking for a fight. - var/static/list/threats = list( - /mob/living/basic/bear, - /mob/living/basic/carp, - /mob/living/basic/spider/giant, - /mob/living/basic/trooper/pirate/faction/silverscale/melee, - ) - for(var/mob_type in threats) - var/mob/living/threat = allocate(mob_type) - TEST_ASSERT(turret.valid_target(threat), "the turret refused to shoot [mob_type], which hunts targets unprovoked") - - // Shoppers, their pets, livestock and the outpost's own staff. - var/static/list/bystanders = list( - /mob/living/carbon/human/consistent, - /mob/living/basic/cow, - /mob/living/basic/goat, - /mob/living/basic/pet/dog/corgi, - /mob/living/basic/pet/cat, - /mob/living/basic/carp/pet, - /mob/living/basic/outpost_trader, - /mob/living/carbon/human/npc/ambient_npc, - ) - for(var/mob_type in bystanders) - var/mob/living/bystander = allocate(mob_type) - TEST_ASSERT(!turret.valid_target(bystander), "the turret opened fire on [mob_type], which never started anything") - - // A player riding a wild creature is not wildlife: no shot until the strike ladder says so. - var/mob/living/basic/bear/possessed = allocate(/mob/living/basic/bear) - possessed.mind_initialize() - TEST_ASSERT(!turret.valid_target(possessed), "the turret shot a player-driven bear that had done nothing") - outpost.aggressor_minds[possessed.mind] = world.time + 1 MINUTES - TEST_ASSERT(turret.valid_target(possessed), "the turret ignored a marked aggressor because they were riding a bear") - - // The strike ladder still works for people. - var/mob/living/carbon/human/aggressor = allocate(/mob/living/carbon/human/consistent) - aggressor.mind_initialize() - TEST_ASSERT(!turret.valid_target(aggressor), "the turret shot a shopper with a clean record") - outpost.aggressor_minds[aggressor.mind] = world.time + 1 MINUTES - TEST_ASSERT(turret.valid_target(aggressor), "the turret held fire on a marked aggressor") - - // The mark lapses, and takes the strike ladder with it. - outpost.aggressor_minds[aggressor.mind] = world.time - outpost.aggressor_strikes[aggressor.mind] = 3 - TEST_ASSERT(!turret.valid_target(aggressor), "the turret kept shooting an aggressor whose mark had lapsed") - TEST_ASSERT_NULL(outpost.aggressor_minds[aggressor.mind], "a lapsed mark was not pruned") - TEST_ASSERT_NULL(outpost.aggressor_strikes[aggressor.mind], "a lapsed mark did not reset the strike ladder") - - // Unspent warnings go stale on the same clock. Fork defines are included after - // unit tests: OUTPOST_AGGRESSION_MARK_DURATION = OUTPOST_EMBARGO_DURATION = 15 minutes. - outpost.register_aggression(aggressor) - outpost.register_aggression(aggressor) // same tick, so the same-swing grace folds it in - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[aggressor.mind], 1, "a fresh infraction did not earn exactly one strike") - outpost.aggressor_strike_times[aggressor.mind] = world.time - 0.8 SECONDS // one melee cooldown later - outpost.register_aggression(aggressor) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[aggressor.mind], 2, "a second swing after the melee cooldown was folded into the first") - outpost.aggressor_strike_times[aggressor.mind] = world.time - 15 MINUTES - outpost.register_aggression(aggressor) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[aggressor.mind], 1, "a warning from 15 minutes ago still counted toward the ladder") - TEST_ASSERT(!turret.valid_target(aggressor), "the turret shot someone with a single fresh warning") - - // Corpses are not targets, whatever they were. - var/mob/living/basic/carp/dead_carp = allocate(/mob/living/basic/carp) - dead_carp.death() - TEST_ASSERT(!turret.valid_target(dead_carp), "the turret kept shooting a dead carp") diff --git a/voidcrew/modules/unit_tests/voidcrew_overmap_management_bans.dm b/voidcrew/modules/unit_tests/voidcrew_overmap_management_bans.dm index 10a80dd9cdf..ed0a49cbe0e 100644 --- a/voidcrew/modules/unit_tests/voidcrew_overmap_management_bans.dm +++ b/voidcrew/modules/unit_tests/voidcrew_overmap_management_bans.dm @@ -1,3 +1,5 @@ +// POLICE-PORT: uses the removed aggressor and trespass rows; port to the wanted rows (P8) +#ifdef POLICE_TESTS_PORTED /// The outpost bans view lists every trader restriction, and lifting or adding one changes what the outpost enforces. /datum/unit_test/voidcrew_overmap_management_bans @@ -50,3 +52,4 @@ if(entry["outpost_ref"] == REF(outpost)) kinds["[entry["kind"]]:[entry["target_ref"]]"] = TRUE return kinds +#endif diff --git a/voidcrew/modules/unit_tests/voidcrew_trader_checkpoint.dm b/voidcrew/modules/unit_tests/voidcrew_trader_checkpoint.dm index cd4f86bd45e..7043a1ccfe5 100644 --- a/voidcrew/modules/unit_tests/voidcrew_trader_checkpoint.dm +++ b/voidcrew/modules/unit_tests/voidcrew_trader_checkpoint.dm @@ -115,6 +115,8 @@ // ===== THE CHAMBER ===== +// POLICE-PORT: uses is_armed_trespasser/trespass_bars or the removed outpost turret; port to warrant.heat and get_stars() (P10) +#ifdef POLICE_TESTS_PORTED /// Work tools and disablers pass every checkpoint weapon check; a laser remains restricted. /datum/unit_test/voidcrew_trader_checkpoint/gun_classification abstract_type = /datum/unit_test/voidcrew_trader_checkpoint/gun_classification @@ -165,6 +167,7 @@ TEST_ASSERT_EQUAL(goods.loc, handed_over, "[gun_type] was left outside its sealed case") else TEST_ASSERT_EQUAL(handed_over, goods, "[gun_type] was needlessly sealed") +#endif /// Clean people pass together, and a clean result is never stored on the doors /datum/unit_test/voidcrew_trader_checkpoint/clean_group/Run() @@ -467,6 +470,8 @@ // ===== FORCING THE INNER DOOR ===== +// POLICE-PORT: uses is_armed_trespasser/trespass_bars or the removed outpost turret; port to warrant.heat and get_stars() (P10) +#ifdef POLICE_TESTS_PORTED /// Prying starts a long, alarmed attempt; a breached door flags anyone who goes through it from the chamber /datum/unit_test/voidcrew_trader_checkpoint/forced_entry/Run() TEST_ASSERT(fixture(), "Could not prepare the checkpoint") @@ -508,9 +513,12 @@ insider.forceMove(secured) TEST_ASSERT(wait_shut(inner), "The forced door did not shut again") TEST_ASSERT_EQUAL(checkpoint.state, "idle", "The checkpoint did not settle after the breach") // CHECKPOINT_IDLE +#endif // ===== ARMED TRESPASS ===== +// POLICE-PORT: uses is_armed_trespasser/trespass_bars or the removed outpost turret; port to warrant.heat and get_stars() (P10) +#ifdef POLICE_TESTS_PORTED /// A gun drawn past the checkpoint flags its holder once it stays out; holstered, permitted or in the lobby it does not /datum/unit_test/voidcrew_trader_checkpoint/drawn_gun/Run() TEST_ASSERT(fixture(FALSE), "Could not prepare the checkpoint") @@ -555,6 +563,7 @@ var/obj/item/gun/ballistic/automatic/pistol/gun = allocate(/obj/item/gun/ballistic/automatic/pistol) SEND_SIGNAL(shooter, COMSIG_MOB_FIRED_GUN, gun, lobby, null, null, list(0, 0)) TEST_ASSERT(host.is_armed_trespasser(shooter.mind), "Firing past the checkpoint was not flagged") +#endif /// The traders and the permit desk refuse an armed trespasser, and the desk sells them nothing /datum/unit_test/voidcrew_trader_checkpoint/refused_service/Run() From 7f3c35ec691b422b08ad957c4bdd36244d6e66f1 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:10:47 -0800 Subject: [PATCH 023/152] Wiki: outpost Port Authority replaces turrets and strikes --- wiki/content/bounty-hunting.md | 8 ++--- wiki/content/glossary.md | 2 +- wiki/content/trader-outposts.md | 60 +++++++++++++++++++++++++++------ 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/wiki/content/bounty-hunting.md b/wiki/content/bounty-hunting.md index c62f13f3166..b4665bbcf9d 100644 --- a/wiki/content/bounty-hunting.md +++ b/wiki/content/bounty-hunting.md @@ -182,9 +182,9 @@ Guns, disablers included, don't get past the outpost's checkpoint unless you buy - Examine a patron to see their distinguishing features: hair, a scar, a tattoo, a scarf, a limp, glasses, a missing ear. Each look-alike shares one or two of the fugitive's features, never all of them. The warrant lists all of them but one. - **Ask the traders.** While your ship hunts the bounty, every trader, the barkeep included, has an **Ask about the wanted person** option. Each gives your ship one clue, and each trader a different one: where the fugitive was seen, how their hair looks now, one of their features, or one patron who isn't them. - **Show the warrant** to the one you think it is. The right person drops the act: a meek one runs, a normal one fights. Anyone else protests and shows you a docking pass. Your ship can show a warrant once every 30 seconds. -- **The alert.** Showing the warrant to the wrong patron raises the alert. So does hitting a look-alike on purpose: a punch, a shove, a baton, a thrown item, a shot aimed at them, or reaching for their wrists with cuffs. Hitting a look-alike is also an outpost strike. At two alerts, the fugitive and the look-alikes walk out to the hangar and leave, and the bounty lists at a different trader outpost 5 minutes later. Stop the fugitive before they reach the lift and they're still yours to take. +- **The alert.** Showing the warrant to the wrong patron raises the alert. So does hitting a look-alike on purpose: a punch, a shove, a baton, a thrown item, a shot aimed at them, or reaching for their wrists with cuffs. Hitting a look-alike is also a crime at the outpost. At two alerts, the fugitive and the look-alikes walk out to the hangar and leave, and the bounty lists at a different trader outpost 5 minutes later. Stop the fugitive before they reach the lift and they're still yours to take. - Hitting the right patron on purpose exposes them, the same as the warrant. -- Shots hit whoever is in the way, look-alikes included, and hitting a look-alike is an outpost strike. The outpost's turrets leave the patrons alone. An exposed fugitive only fights the people who came for it. If it hurts a bystander, the turrets can fire on it. +- Shots hit whoever is in the way, look-alikes included, and hitting a look-alike is a crime at the outpost. Officers leave the patrons alone. An exposed fugitive only fights the people who came for it. If it hurts a bystander, officers can shoot it. ## Pirate ships @@ -216,7 +216,7 @@ A crime boss holds court in the lounge at the Undertow Exchange from the start o - He never leaves the sofa. Before each shot he aims his .357 revolver at someone with a red line for half a second, and a hit spoils his aim. He reloads with a speedloader. - Once his goons are down and he's badly hurt, he may give up. He wears an armoured coat, and his goons wear armour vests. - When he falls, half the goons left run for it. -- The outpost's turrets stay out of it. Fighting his crew earns no outpost strikes, but hitting other players still does. +- Officers stay out of it. Fighting his crew is not a crime, but hitting other players still is. Once he's down or cuffed, unbuckle him from the sofa and drag him to your ship's pad in the Undertow's hangar. @@ -269,7 +269,7 @@ The warden's console has a **Bounty prisoners** setting: **All**, **No Most Want - Nobody owns a catch. Whoever gets the criminal onto their own ship's pad and presses Turn In is paid. Cuffing them first gives no claim, and anyone can take the cuffs off with an empty hand. - When a bounty is turned in, every other crew hunting it is told who claimed it. -- Normal PvP rules apply. At trader outposts, strikes work as usual. +- Normal PvP rules apply. At trader outposts, the [Port Authority](trader-outposts.md#the-port-authority) works as usual. ## FAQ diff --git a/wiki/content/glossary.md b/wiki/content/glossary.md index f6a28516a56..5332d2906c2 100644 --- a/wiki/content/glossary.md +++ b/wiki/content/glossary.md @@ -61,7 +61,7 @@ Voidcrew adds a vocabulary that /tg/station does not have, and most of it turns | **Voucher** | A trade voucher: a physical chit earned from dangerous work, never bought with credits, and spent at any trader for restricted goods. Being an item, it can be stolen off you. | | **Chrome** | Cyberware, implantable robotic organs sold at the Chop Shop on the Undertow Exchange and the Back Bay at Quartermain Depot. See [Cyberware](cyberware.md). | | **Neural load** | The shared cost of chrome, implants, robotic organs, surgical enhancements and installed nanite programs against 20 capacity. Overload disables chrome and beneficial nanite programs. | -| **Trader outpost** | One of three permanent markets, one per zone: general store in green, outfitter in yellow, black market in red. Turrets shoot people who keep starting fights. See [Trader Outposts](trader-outposts.md). | +| **Trader outpost** | One of three permanent markets, one per zone: general store in green, outfitter in yellow, black market in red. Port Authority officers arrest, and past a point shoot, people who start fights. See [Trader Outposts](trader-outposts.md). | | **Player outpost** | A base a crew plants for itself using an outpost deed. See [Player Outposts](player-outposts.md). | | **Custom slot** | A saved spawn loadout tied to your account, swapped onto a ship's job at the cryogenic oversight console. The first is free; the second and third are bought with your persistent account credits. | diff --git a/wiki/content/trader-outposts.md b/wiki/content/trader-outposts.md index 6076b71c52f..ce9d74d704f 100644 --- a/wiki/content/trader-outposts.md +++ b/wiki/content/trader-outposts.md @@ -2,7 +2,7 @@ title: Trader Outposts category: Economy order: 3 -blurb: The three permanent markets on the overmap, who runs them, what they stock, and what happens if you start a fight there. +blurb: The three permanent markets on the overmap, who runs them, what they stock, and who polices them and what happens if you start a fight there. --- Three trader outposts sit permanently on the [overmap](overmap.md), one per zone band. They never move, never unload, and cannot be destroyed. Each is a market, a contract board, a repair stop and a safe room in one, and each stocks a different tier of goods depending on how dangerous the space around it is. @@ -41,7 +41,7 @@ No combat guns past the checkpoint. Work tools and disablers are fine, including Combat guns bought inside the outpost, or paid out by a contract there, come in a **sealed case** (cache crates come with a sealed lid). The seal breaks once you are back outside the checkpoint. -**Getting a gun in anyway.** It can be done, but the outpost does not take it lightly. Anyone seen inside with a combat gun in hand, or firing one, without a permit, and anyone who comes through a checkpoint door that was pried or hacked open, is an **armed trespasser**: the turrets treat you as hostile, and the traders, the guard and the checkpoint all refuse you for a good while after. Forcing the inner door takes a long time and sets off the alarm. This only applies to the person who did it, not their whole crew. +**Getting a gun in anyway.** It can be done, but the outpost does not take it lightly. A combat gun in hand without a permit, or fired inside, counts as a crime and the [Port Authority](#the-port-authority) answers it. So does coming through a checkpoint door that was pried or hacked open, which also takes a long time and sets off the alarm. This only applies to the person who did it, not their whole crew. ## Shopping @@ -144,19 +144,59 @@ Turn them in at any outpost trader or at your own mission pad. Except courier ru ## Violence -Outposts are indestructible. Walls, doors, machines and traders all shrug off weapons fire, so there is nothing to break into and nothing to rob. The deterrent is economic instead. +Outposts are indestructible. Walls, doors, machines and traders all shrug off weapons fire, so there is nothing to break into and nothing to rob. What stops you is the **Port Authority**: officers who live at each outpost and answer anything they see. -Attacking outpost property (or attacking another visitor inside the outpost) earns you a **strike**. The first two only get you a warning, with the turrets swinging around to point at you. On the **third**, three things happen at once: +The shoppers, drinkers, staff and odd regulars milling around each concourse can be hurt like anyone else, and doing it is a crime. They carry a little cash on them. -- You are personally marked. Every trader on the station refuses to serve you for 15 minutes. -- Every ship your crew belongs to is put under a **trade embargo** for 15 minutes. -- The outpost's turrets go lethal on you and anyone else aboard your embargoed ship. +## The Port Authority -The shoppers, drinkers, staff and odd regulars milling around each concourse can be hurt like anyone else, and doing it counts as violence at the outpost. They carry a little cash on them. +Officers walk the concourse, drink coffee at the station by the checkpoint and talk to each other. They are people, not turrets: they can be stunned, cuffed, wounded and killed like anyone. They stay inside the outpost and its hangars, and they do not board docked ships. + +A crime counts only if someone saw it: an officer, a trader, a guard, a bystander from another crew or a checkpoint scanner. Fighting back against someone who attacked you first is not a crime. Hunters going after a bounty fugitive are left alone for it. + +### Crimes and stars + +Crimes pile up against the person who did them, shown as **stars** on your alert icon. Small things come first: one punch earns a word from the nearest officer, two earn a star. Weapons, gunfire, vandalism, hitting staff or civilians, and fighting an officer climb faster. Killing a visitor is at least three stars. Killing an officer is at least three, and a lot more besides. + +| Stars | What the officers do | +|---|---| +| 1-2 | Order you to stop, stun and cuff you, take what you used, and walk you to the desk by the lobby | +| 3 | Shoot to kill. Nobody takes a surrender | +| 4 | As 3, plus your crewmates at the outpost are arrested, the crew is under embargo and a cutter is sent after your ship | +| 5 | As 4, with the marshal on scene, all three outposts embargoed | + +More officers arrive through the staff lifts as your stars rise, and they keep coming. Officers in green and yellow space spare someone who is down. In red space they finish the job. + +Stars fade if you stay out of sight of the officers long enough, or if you leave the outpost. At 3 stars and up they will not fade while you are still in the outpost. After a warrant fades, the officers remember you for a while. + +### Being arrested + +Stand still, drop your weapon and put your hands down, or use `*surrender` at 1-2 stars, and the officer cuffs you without a fight. Otherwise they stun you first. + +- **What they take.** Every weapon or tool you used in a crime goes down the evidence chute at the desk and is destroyed. It does not come back. Anything you stole from a prisoner's locker goes back to the locker. Implants, cyberware, ship keys and mission items are never taken. +- **The fine.** 300 credits at 1 star and 800 at 2, higher at outposts in the dangerous zones, paid from your crew's ship accounts together. It is paid in full or not at all. +- **If it is paid.** You wait in a cell, about half a minute at 1 star and a minute at 2, then the door opens onto the lobby. +- **If it is not.** You go to jail for a few minutes. Your gear comes off, you are given a prison jumpsuit and shoes, and your things go into a property locker outside the cells. You keep your ID and your implants. The fine is tried again every half minute, and paying it ends the term. There is no debt afterwards. + +If you are badly hurt or die in the cells, the door opens at once and a medic is called. + +### Getting your things back + +Your locker opens for you, or for the ID you booked in with. It is free to take your gear any time after you are out. Emags open them too. Anyone can empty a locker, but an officer who sees someone doing it treats it as theft (2 stars) and takes the goods back. Lockers nobody collects stay shut for the rest of the round. If every locker is in use, the one left longest is emptied onto the floor. + +### Breaking out + +You can force a cell door with a crowbar or jaws from either side. It takes a few seconds and sets off an alarm, and it is 3 stars. So is walking out of a cell before your time is up. + +### Embargo and the cutter + +At 4 stars every ship of your crew is embargoed at that outpost: no shops, no checkpoint, no trade. At 5 stars it is all three outposts. Each officer you kill adds time to it. + +When an embargoed ship undocks, a police cutter goes after it. It hails you with a fine, 6,000 credits at 4 stars and 15,000 at 5. **Pay it** and the cutter leaves, the embargo lifts and your record at that outpost is wiped. Refuse and the cutter attacks and boards. If you beat it or outrun it, the embargo simply runs its time. !!! danger "The embargo covers your whole crew" - One person losing their temper bars everybody on the ship, including people who were still shopping. Embargoed crew are shot on sight, not merely refused service. + One person losing their temper bars every ship of the crew, including people who were still shopping. -Carrying a gun past the checkpoint without a permit is a separate matter from strikes: see [The checkpoint](#the-checkpoint). It marks only you, and the turrets go for you straight away. +The officers also shoot hostile wildlife and xenos that get loose in the outpost. Fuel and gas sold at outposts, and the exotic gases Vex buys by the tank, are covered on [Fuel & Gas](gas-economy.md). Deeds for founding your own station are sold at Halcyon and Quartermain and covered on [Player Outposts](player-outposts.md). From 68a3de8e492610135b3aa325e3c282feb5a4222c Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:28:04 -0800 Subject: [PATCH 024/152] Outpost police: dispatch, lifts, targeting and fire discipline --- .../modules/outpost_police/police_dispatch.dm | 850 +++++++++++++++++- .../modules/outpost_police/police_force.dm | 380 +++++++- 2 files changed, 1213 insertions(+), 17 deletions(-) diff --git a/voidcrew/modules/outpost_police/police_dispatch.dm b/voidcrew/modules/outpost_police/police_dispatch.dm index e8ead8f4d1d..13388b43b5e 100644 --- a/voidcrew/modules/outpost_police/police_dispatch.dm +++ b/voidcrew/modules/outpost_police/police_dispatch.dm @@ -2,28 +2,862 @@ * # Outpost police: dispatch * * Owner: P4. Sectors, the police lifts, ordering units and topping the scene up in waves. Spec 5.2-5.4. + * + * - Sectors: the concourse (past the checkpoint), the lobby (the checkpoint area inside the outpost's box), + * each hangar berth (the berth datum itself) and outside. + * - Lift points: the police lift tiles behind the police doors (concourse), the public lift alcove (lobby) and + * each berth's alcove. Officers ride between them (ride()) the way the outpost lift moves people, taking + * whoever they pull. They never use a checkpoint. + * - Once a second (tick_dispatch()): officers carried off the outpost leave the roster (abuse M12); waves leave + * their door together (M13); a point whose unit could not get out is skipped (M2); the scene is topped up to + * the alert level's count, beat officers first, then by lift in waves; surplus response units stand down by + * lift; arrests are opened at one and two stars; officer bodies past the cap are collected; nobody at the + * outpost for a minute sends the response units away unseen. + * + * Every "does this point or officer see that person" test here goes through police.perception.sees() (spec 3 + * and 18); this file never calls can_see. */ +/// Trait source that holds a wave behind its door until it leaves together +#define POLICE_WAVE_TRAIT "outpost_police_wave" +/// A wave forms up behind its door this long before it may leave +#define POLICE_WAVE_FORM_UP (3 SECONDS) +/// A wave leaves without members still waiting to be built after this long +#define POLICE_WAVE_WAIT_MAX (15 SECONDS) +/// Response units are sent away unseen after nobody has been at the outpost this long (spec 5.4) +#define POLICE_EMPTY_RECALL (60 SECONDS) +/// Tiles looked at when picking one out of sight (M2) +#define POLICE_UNSEEN_TRIES 60 + +// ========================================================================= +// P4'S OWN STATE (vars on shared types, declared here; every name starts with dispatch_) +// ========================================================================= + +/datum/outpost_police + /// Spawn orders not built yet: order -> POLICE_UNIT_* + var/list/datum/human_npc_order/dispatch_orders = list() + /// Waves not yet done with (/datum/outpost_police_wave) + var/list/datum/outpost_police_wave/dispatch_waves = list() + /// world.time the next wave may be sent + var/dispatch_next_wave = 0 + /// Lift points skipped (M2): turf -> world.time they come back + var/list/turf/dispatch_skipped = list() + /// Officers carried off the outpost (M12): off the roster, deleted once nobody is near + var/list/mob/living/carbon/human/npc/ambient_npc/outpost/police/dispatch_strays = list() + /// world.time since when no player has been at the outpost, or 0 + var/dispatch_empty_since = 0 + /// Where officers hold a docked ship's airlock at three stars and up: mind -> list(last seen turf, post turf) + var/list/dispatch_holds = list() + /// Build units at once rather than through the human NPC queue (unit tests) + var/dispatch_build_now = FALSE + +/mob/living/carbon/human/npc/ambient_npc/outpost/police + /// The wave they came in, while it is forming up + var/datum/outpost_police_wave/dispatch_wave + /// Weakref to the mind of the suspect they are responding to, or null + var/datum/weakref/dispatch_mind + /// Mid-ride on a lift + var/dispatch_riding = FALSE + /// world.time since when a person has been pulling them, or 0 (M11) + var/dispatch_pull_since = 0 + /// Weakref to who stands in their line of fire (M11) + var/datum/weakref/dispatch_block_ref + /// world.time since when they have stood there, or since they were last charged for it + var/dispatch_block_since = 0 + /// The last-seen tile they are searching from + var/turf/dispatch_search_from + /// Where they look next while searching + var/turf/dispatch_post + /// world.time until which they look about where they stand + var/dispatch_search_until = 0 + /// Weakref to the mind they last reported lost + var/datum/weakref/dispatch_lost_ref + +/mob/living/carbon/human/npc/ambient_npc/outpost/police/Destroy() + if(dispatch_wave) + dispatch_wave.members -= WEAKREF(src) + dispatch_wave = null + police?.dispatch_strays -= src + dispatch_mind = null + dispatch_block_ref = null + dispatch_lost_ref = null + dispatch_search_from = null + dispatch_post = null + return ..() + +/// Whether they are responding to a suspect +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/dispatch_responding() + return !isnull(dispatch_mind?.resolve()) + +/// Officers sent together, who leave their lift door together (M13) +/datum/outpost_police_wave + /// The sector they are for + var/sector + /// The lift point they form up on, or null when built out of sight (M2) + var/turf/point + /// world.time they may leave + var/release_at = 0 + /// world.time they left, or 0 + var/released_at = 0 + /// Whether the stuck check (M2) has been made + var/checked = FALSE + /// Their spawn orders + var/list/datum/human_npc_order/orders = list() + /// Weakrefs to the members built so far + var/list/datum/weakref/members = list() + +/datum/outpost_police_wave/Destroy() + orders.Cut() + members.Cut() + point = null + sector = null + return ..() + +/// The members still about +/datum/outpost_police_wave/proc/living_members() + . = list() + for(var/datum/weakref/ref as anything in members) + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = ref.resolve() + if(!QDELETED(officer)) + . += officer + +/// Whether any of its orders is still waiting to be built +/datum/outpost_police_wave/proc/pending() + for(var/datum/human_npc_order/order as anything in orders) + if(!QDELETED(order) && !order.done) + return TRUE + return FALSE + +// ========================================================================= +// THE API (build plan section 2) +// ========================================================================= + /// The highest stars among the suspects at the outpost /datum/outpost_police/proc/alert_level() - return 0 + var/best = 0 + for(var/datum/outpost_warrant/warrant as anything in suspects_at_outpost()) + best = max(best, get_stars(warrant.mind)) + return clamp(best, 0, 5) /// Which sector `where` is in: POLICE_SECTOR_*, or the /datum/outpost_berth of a hangar /datum/outpost_police/proc/sector_of(turf/where) - return POLICE_SECTOR_CONCOURSE + where = get_turf(where) + if(!where || !outpost) + return POLICE_SECTOR_OUTSIDE + if(outpost.is_secured_turf(where)) + return POLICE_SECTOR_CONCOURSE + if(dispatch_in_box(where)) + if(istype(where.loc, /area/voidcrew/trader_outpost/checkpoint)) + return POLICE_SECTOR_LOBBY + // The police lift closets open onto the concourse, whatever area the map gives them + if(where in lift_turfs) + return POLICE_SECTOR_CONCOURSE + return POLICE_SECTOR_OUTSIDE + for(var/datum/outpost_berth/berth as anything in outpost.berths) + if(berth?.contains_turf(where)) + return berth + return POLICE_SECTOR_OUTSIDE -/// A lift point serving `sector`, out of `avoid_sight_of`'s sight if possible; null if none +/** + * A lift point serving `sector`: one no suspect at the outpost (nor `avoid_sight_of`) sees, the farthest from + * them first; if every point is watched, the farthest (abuse M13). Points skipped for being blocked (M2) are + * left out. Null if the sector has none left. + */ /datum/outpost_police/proc/lift_point(sector, atom/avoid_sight_of) - return null + var/list/watchers = dispatch_suspect_mobs() + if(isliving(avoid_sight_of)) + watchers |= avoid_sight_of + var/turf/best + var/best_watched = TRUE + var/best_distance = -1 + for(var/turf/point as anything in dispatch_lift_tiles(sector)) + if(!point || dispatch_skipped[point] > world.time) + continue + var/watched = FALSE + var/distance = INFINITY + for(var/mob/living/watcher as anything in watchers) + if(perception.sees(point, watcher)) + watched = TRUE + var/turf/watcher_turf = get_turf(watcher) + if(watcher_turf?.z == point.z) + distance = min(distance, get_dist(watcher_turf, point)) + if(!best || (best_watched && !watched) || (best_watched == watched && distance > best_distance)) + best = point + best_watched = watched + best_distance = distance + return best -/// `officer` rides the lift to `sector`, with whoever they pull. Sleeps the ride. TRUE on arrival. +/** + * `officer` rides the lift from the lift point they stand on to a point of `sector`, with whoever they pull. + * The car takes OUTPOST_ELEVATOR_TRAVEL_TIME, as the outpost's own lift does; they must still be awake and on + * the tile when it arrives. Sleeps the ride. TRUE on arrival. + */ /datum/outpost_police/proc/ride(mob/living/officer, sector) - return FALSE + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/rider = officer + if(QDELETED(officer) || officer.stat != CONSCIOUS || (istype(rider) && rider.dispatch_riding)) + return FALSE + var/turf/start = get_turf(officer) + if(!start || !dispatch_is_lift_tile(start)) + return FALSE + var/turf/destination = lift_point(sector) + if(!destination || destination == start) + return FALSE + if(istype(rider)) + rider.dispatch_riding = TRUE + playsound(start, 'sound/machines/chime.ogg', 50, TRUE) + sleep(OUTPOST_ELEVATOR_TRAVEL_TIME) + if(istype(rider) && !QDELETED(rider)) + rider.dispatch_riding = FALSE + if(QDELETED(src) || QDELETED(officer) || officer.stat != CONSCIOUS || get_turf(officer) != start) + return FALSE + // The far end may have gone during the ride (a berth torn down) + if(!(destination in dispatch_lift_tiles(sector))) + destination = lift_point(sector) + if(!destination || destination == start) + return FALSE + var/atom/movable/pulled = officer.pulling + if(pulled) + var/mob/pulled_mob = pulled + if(pulled.anchored || get_dist(pulled, start) > 1 || (ismob(pulled) && pulled_mob.buckled)) + pulled = null + officer.forceMove(destination) + if(pulled && !QDELETED(pulled)) + pulled.forceMove(destination) + officer.start_pulling(pulled, supress_message = TRUE) + playsound(destination, 'sound/machines/ding.ogg', 50, TRUE) + return TRUE /// Queues one unit of `unit_kind` (POLICE_UNIT_*) for `sector`. Returns the spawn order, or null. /datum/outpost_police/proc/order_unit(unit_kind, sector) - return null + var/datum/outpost_police_wave/wave = dispatch_new_wave(sector, lift_point(sector)) + var/datum/human_npc_order/order = dispatch_queue_unit(unit_kind, wave) + if(!order && !length(wave.members)) + dispatch_waves -= wave + qdel(wave) + return order + +// ========================================================================= +// THE TICK +// ========================================================================= /// Once a second (police_core.dm tick()): responders, waves, caps, stand-downs, bodies, cases to open /datum/outpost_police/proc/tick_dispatch() - return + if(!outpost) + return + dispatch_prune_orders() + dispatch_check_strays() + dispatch_check_waves() + dispatch_collect_bodies() + if(dispatch_recall_when_empty()) + return + dispatch_update_holds() + dispatch_respond(alert_level()) + dispatch_open_cases() + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers) + if(officer.stat == DEAD) + continue + officer.dispatch_check_pull() + officer.check_body_block() + +/// Drops orders that were built or dropped +/datum/outpost_police/proc/dispatch_prune_orders() + for(var/datum/human_npc_order/order as anything in dispatch_orders.Copy()) + if(QDELETED(order) || order.done) + dispatch_orders -= order + +/** + * Abuse M12: an officer carried off the outpost (aboard a ship that undocked, dragged out of the box) leaves + * the roster and the caps but keeps their AI. Once no player is near enough to see them they are deleted; + * back at the outpost, they rejoin. + */ +/datum/outpost_police/proc/dispatch_check_strays() + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers.Copy()) + if(officer.dispatch_riding || dispatch_at_outpost(officer)) + continue + remove_officer(officer) + officer.dispatch_mind = null + dispatch_strays |= officer + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/stray as anything in dispatch_strays.Copy()) + if(QDELETED(stray)) + dispatch_strays -= stray + continue + if(dispatch_at_outpost(stray)) + dispatch_strays -= stray + add_officer(stray) + continue + if(!human_npc_watched(stray)) + dispatch_strays -= stray + qdel(stray) + +/// Waves leave together once formed up (M13); a point whose unit never got out is skipped (M2) +/datum/outpost_police/proc/dispatch_check_waves() + for(var/datum/outpost_police_wave/wave as anything in dispatch_waves.Copy()) + var/pending = wave.pending() + if(!wave.released_at) + if(world.time >= wave.release_at && (!pending || world.time >= wave.release_at + POLICE_WAVE_WAIT_MAX)) + dispatch_release_wave(wave) + continue + if(!wave.checked && world.time >= wave.released_at + OUTPOST_POLICE_LIFT_BLOCKED) + wave.checked = TRUE + dispatch_check_stuck(wave) + if(wave.checked && !pending) + dispatch_waves -= wave + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/member as anything in wave.living_members()) + if(member.dispatch_wave == wave) + member.dispatch_wave = null + qdel(wave) + +/// The wave leaves its door: everyone built so far goes at once +/datum/outpost_police/proc/dispatch_release_wave(datum/outpost_police_wave/wave) + wave.released_at = world.time + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/member as anything in wave.living_members()) + REMOVE_TRAIT(member, TRAIT_AI_PAUSED, POLICE_WAVE_TRAIT) + +/** + * Abuse M2. Members still on their lift point a while after leaving, with a suspect to go to, could not get + * out (a crate or a wall against the door). The point is skipped for OUTPOST_POLICE_LIFT_SKIP, and each stuck + * member rides to another point of the sector, or, with none left, is put on a tile out of every player's + * sight, as a unit built there would be. + */ +/datum/outpost_police/proc/dispatch_check_stuck(datum/outpost_police_wave/wave) + if(!wave.point) + return + var/list/stuck = list() + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/member as anything in wave.living_members()) + if(member.stat != CONSCIOUS || member.case || HAS_TRAIT(member, TRAIT_RESTRAINED) || member.pulledby) + continue + if(get_dist(member, wave.point) > 1) + continue + var/datum/mind/mind = member.dispatch_mind?.resolve() + var/mob/living/suspect = mind?.current + if(!isliving(suspect) || get_dist(suspect, wave.point) <= 2) + continue + stuck += member + if(!length(stuck)) + return + dispatch_skipped[wave.point] = world.time + OUTPOST_POLICE_LIFT_SKIP + log_game("OUTPOST POLICE: [outpost] lift point [AREACOORD(wave.point)] is blocked; skipped for a while.") + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/member as anything in stuck) + var/turf/other = lift_point(wave.sector) + if(other && other != get_turf(member) && dispatch_is_lift_tile(get_turf(member))) + INVOKE_ASYNC(src, PROC_REF(ride), member, wave.sector) + continue + var/turf/hidden = dispatch_unseen_tile(wave.sector) + if(hidden && !human_npc_watched(member)) + member.forceMove(hidden) + +/** + * Officer bodies past OUTPOST_POLICE_CORPSE_CAP at this outpost: the oldest is collected (deleted) even if + * someone is watching. A documented performance exception, like the prison guards' body collection (spec + * 5.4); the rest go by the base corpse sweep. + */ +/datum/outpost_police/proc/dispatch_collect_bodies() + var/list/bodies = list() + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers) + if(officer.stat == DEAD) + bodies += officer + while(length(bodies) > OUTPOST_POLICE_CORPSE_CAP) + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/oldest = bodies[1] + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/body as anything in bodies) + if(body.timeofdeath < oldest.timeofdeath) + oldest = body + bodies -= oldest + qdel(oldest) + +/// Nobody at the outpost for POLICE_EMPTY_RECALL: response units go, unseen (spec 5.4). TRUE while nobody is there. +/datum/outpost_police/proc/dispatch_recall_when_empty() + // A wanted person here (connected or not) keeps the response going + if(length(dispatch_players_at_outpost()) || length(suspects_at_outpost())) + dispatch_empty_since = 0 + return FALSE + if(!dispatch_empty_since) + dispatch_empty_since = world.time + return TRUE + if(world.time - dispatch_empty_since < POLICE_EMPTY_RECALL) + return TRUE + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers.Copy()) + if(officer.stat == DEAD || !dispatch_is_response_unit(officer)) + continue + if(!human_npc_watched(officer)) + qdel(officer) + for(var/datum/human_npc_order/order as anything in dispatch_orders.Copy()) + qdel(order) + dispatch_orders.Cut() + return TRUE + +/** + * Tops the scene up to the alert level's count (spec 5.2): beat officers nearest the suspects first, then + * response units already about, then new units by lift in waves at the level's cadence. Surplus response + * units stand down; beat officers go back to their beat. + */ +/datum/outpost_police/proc/dispatch_respond(level) + var/list/datum/outpost_warrant/suspects = suspects_at_outpost() + if(level <= 0 || !length(suspects)) + dispatch_stand_down(0) + return + var/wanted = OUTPOST_POLICE_UNITS[level] + var/list/on_scene = list() + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers) + if(!dispatch_can_respond(officer)) + continue + if(officer.case) + on_scene += officer + continue + var/datum/mind/mind = officer.dispatch_mind?.resolve() + if(!mind) + continue + // Responding still: to whichever suspect is nearest now + var/datum/outpost_warrant/nearest = dispatch_nearest_suspect(officer, suspects) + if(!nearest) + officer.dispatch_mind = null + continue + if(nearest.mind != mind) + officer.dispatch_mind = WEAKREF(nearest.mind) + on_scene += officer + // Beat officers first, nearest first; then response units already here + if(length(on_scene) < wanted) + var/list/idle = list() + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers) + if((officer in on_scene) || !dispatch_can_respond(officer) || officer.unit_kind == POLICE_UNIT_SERGEANT) + continue + idle += officer + while(length(on_scene) < wanted && length(idle)) + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/pick = dispatch_closest_to_suspects(idle, suspects) + idle -= pick + var/datum/outpost_warrant/nearest = dispatch_nearest_suspect(pick, suspects) + if(!nearest) + continue + pick.dispatch_mind = WEAKREF(nearest.mind) + on_scene += pick + // New units by lift + var/short = wanted - length(on_scene) - length(dispatch_orders) + if(short > 0 && world.time >= dispatch_next_wave) + var/datum/outpost_warrant/top = dispatch_top_suspect(suspects) + var/mob/living/suspect = top?.mind?.current + var/sector = sector_of(top?.last_seen_turf || get_turf(suspect)) + if(sector == POLICE_SECTOR_OUTSIDE && suspect) + sector = sector_of(suspect) + if(sector != POLICE_SECTOR_OUTSIDE) + dispatch_send_wave(level, min(short, OUTPOST_POLICE_WAVE[level]), sector, top) + dispatch_next_wave = world.time + OUTPOST_POLICE_CADENCE[level] + if(length(on_scene) > wanted) + dispatch_stand_down(wanted, on_scene) + +/** + * Sends a wave of `size` units for `sector`, all from one lift point (no suspect watching it if possible). + * The kinds fill the level's make-up: a marshal at five stars, tactical at four and five, officers otherwise. + */ +/datum/outpost_police/proc/dispatch_send_wave(level, size, sector, datum/outpost_warrant/top) + var/datum/outpost_police_wave/wave = dispatch_new_wave(sector, lift_point(sector, top?.mind?.current)) + for(var/i in 1 to size) + if(!dispatch_queue_unit(dispatch_next_kind(level), wave, top)) + break + if(!length(wave.orders) && !length(wave.members)) + dispatch_waves -= wave + qdel(wave) + return null + INVOKE_ASYNC(src, PROC_REF(say_dispatch), level >= 5 ? POLICE_LINE_MARSHAL : POLICE_LINE_ALL_UNITS, null) + return wave + +/// The kind the level's make-up is shortest of (spec 5.2: 4 stars two tactical, 5 stars four tactical and a marshal) +/datum/outpost_police/proc/dispatch_next_kind(level) + var/tactical = 0 + var/marshals = 0 + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers) + if(officer.stat == DEAD) + continue + if(officer.unit_kind == POLICE_UNIT_TACTICAL) + tactical++ + else if(officer.unit_kind == POLICE_UNIT_MARSHAL) + marshals++ + for(var/datum/human_npc_order/order as anything in dispatch_orders) + if(dispatch_orders[order] == POLICE_UNIT_TACTICAL) + tactical++ + else if(dispatch_orders[order] == POLICE_UNIT_MARSHAL) + marshals++ + if(level >= 5 && !marshals) + return POLICE_UNIT_MARSHAL + if(tactical < (level >= 5 ? 4 : (level >= 4 ? 2 : 0))) + return POLICE_UNIT_TACTICAL + return POLICE_UNIT_OFFICER + +/** + * Brings the scene down to `keep` officers: surplus response units (farthest first) call it in and leave by + * the nearest lift point; then surplus beat officers go back to their beat. `on_scene` is who is there now + * (null: everyone responding). Officers on an arrest are never stood down here. + */ +/datum/outpost_police/proc/dispatch_stand_down(keep, list/on_scene) + if(isnull(on_scene)) + on_scene = list() + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers) + if(officer.stat != DEAD && (officer.case || officer.dispatch_responding() || (dispatch_is_response_unit(officer) && !officer.npc_exit))) + on_scene += officer + var/list/candidates = list() + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer as anything in on_scene) + if(!officer.case) + candidates += officer + var/list/datum/outpost_warrant/suspects = suspects_at_outpost() + var/surplus = length(on_scene) - keep + var/called_in = FALSE + // Response units go first, then beat officers + for(var/pass in 1 to 2) + while(surplus > 0) + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/farthest + var/farthest_distance = -1 + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer as anything in candidates) + if(dispatch_is_response_unit(officer) != (pass == 1)) + continue + var/datum/outpost_warrant/nearest = dispatch_nearest_suspect(officer, suspects) + var/turf/known = nearest ? dispatch_suspect_turf(nearest) : null + var/distance = (known && known.z == officer.z) ? get_dist(officer, known) : 1000 + if(distance > farthest_distance) + farthest = officer + farthest_distance = distance + if(!farthest) + break + candidates -= farthest + surplus-- + farthest.dispatch_mind = null + if(!dispatch_is_response_unit(farthest)) + continue + var/turf/exit = dispatch_nearest_lift_point(sector_of(farthest), farthest) || dispatch_nearest_lift_point(null, farthest) + farthest.npc_walk_out(exit || get_turf(farthest)) + if(!called_in) + called_in = TRUE + INVOKE_ASYNC(farthest, TYPE_PROC_REF(/mob/living/carbon/human/npc/ambient_npc/outpost/police, police_say), POLICE_LINE_CLEAR, null, TRUE) + +/// Opens an arrest for each suspect of one or two stars who has none, at most one case per two officers +/datum/outpost_police/proc/dispatch_open_cases() + var/available = 0 + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers) + if(dispatch_can_respond(officer) && officer.unit_kind != POLICE_UNIT_SERGEANT) + available++ + var/allowed = round(available / 2) + if(length(cases) >= allowed) + return + for(var/datum/outpost_warrant/warrant as anything in suspects_at_outpost()) + if(length(cases) >= allowed) + return + var/stars = get_stars(warrant.mind) + if(stars < 1 || stars > 2 || cases[warrant.mind]) + continue + var/mob/living/suspect = warrant.mind?.current + if(!isliving(suspect) || suspect.stat == DEAD || in_custody(suspect)) + continue + open_case(warrant) + +/** + * Where officers hold a docked ship at three stars and up (spec 8: they follow through open doors and never + * breach): beside the ship's outer airlock nearest where the suspect was last seen aboard. + */ +/datum/outpost_police/proc/dispatch_update_holds() + for(var/datum/mind/mind as anything in dispatch_holds.Copy()) + if(!get_warrant(mind) || get_stars(mind) < 3) + dispatch_holds -= mind + for(var/datum/outpost_warrant/warrant as anything in suspects_at_outpost()) + if(get_stars(warrant.mind) < 3 || !warrant.last_seen_turf) + continue + var/list/held = dispatch_holds[warrant.mind] + if(held && held[1] == warrant.last_seen_turf) + continue + var/turf/post = dispatch_hold_post(warrant.last_seen_turf) + if(post) + dispatch_holds[warrant.mind] = list(warrant.last_seen_turf, post) + else + dispatch_holds -= warrant.mind + +/// A hangar tile outside the outer airlock of the docked ship `aboard` is on, nearest `aboard`; null if `aboard` is not on a docked ship +/datum/outpost_police/proc/dispatch_hold_post(turf/aboard) + var/datum/outpost_berth/berth = sector_of(aboard) + if(!istype(berth) || !berth.ship?.shuttle) + return null + var/list/turf/hull = berth.ship.shuttle.return_turfs() + if(!(aboard in hull)) + return null + var/turf/best + var/best_distance = INFINITY + for(var/turf/hull_turf as anything in hull) + if(!(locate(/obj/machinery/door/airlock) in hull_turf)) + continue + var/distance = get_dist(hull_turf, aboard) + if(distance >= best_distance) + continue + for(var/direction in GLOB.cardinals) + var/turf/outside = get_step(hull_turf, direction) + if(!outside || (outside in hull) || !isopenturf(outside) || outside.is_blocked_turf()) + continue + best = outside + best_distance = distance + break + return best + +// ========================================================================= +// HELPERS +// ========================================================================= + +/// Inside the outpost's own box (concourse, lobby, closets), not a hangar +/datum/outpost_police/proc/dispatch_in_box(turf/where) + var/turf/bottom_left = outpost?.template_bottom_left + if(!bottom_left || !outpost.outpost_template || where.z != bottom_left.z) + return FALSE + return where.x >= bottom_left.x && where.y >= bottom_left.y && where.x < bottom_left.x + outpost.outpost_template.width && where.y < bottom_left.y + outpost.outpost_template.height + +/// Whether `thing` is at this outpost: its box, any of its berths and the ships docked there +/datum/outpost_police/proc/dispatch_at_outpost(atom/thing) + var/turf/where = get_turf(thing) + return where && get_trader_outpost_for_turf(where) == outpost + +/// The lift tiles of `sector` +/datum/outpost_police/proc/dispatch_lift_tiles(sector) + if(sector == POLICE_SECTOR_CONCOURSE) + return lift_turfs + if(sector == POLICE_SECTOR_LOBBY) + return outpost?.lobby_alcove_turfs || list() + var/datum/outpost_berth/berth = sector + if(istype(berth) && (berth in outpost?.berths)) + return berth.alcove_turfs + return list() + +/// Whether `where` is any lift tile of this outpost +/datum/outpost_police/proc/dispatch_is_lift_tile(turf/where) + if((where in lift_turfs) || (where in outpost?.lobby_alcove_turfs)) + return TRUE + for(var/datum/outpost_berth/berth as anything in outpost?.berths) + if(berth && (where in berth.alcove_turfs)) + return TRUE + return FALSE + +/// The lift point of `sector` nearest `from` (of any sector for null); skipped points left out +/datum/outpost_police/proc/dispatch_nearest_lift_point(sector, atom/from) + var/turf/from_turf = get_turf(from) + if(!from_turf) + return null + var/list/turf/points + if(isnull(sector)) + points = lift_turfs.Copy() + points |= outpost?.lobby_alcove_turfs + for(var/datum/outpost_berth/berth as anything in outpost?.berths) + if(berth) + points |= berth.alcove_turfs + else + points = dispatch_lift_tiles(sector) + var/turf/best + var/best_distance = INFINITY + for(var/turf/point as anything in points) + if(!point || point.z != from_turf.z || dispatch_skipped[point] > world.time) + continue + var/distance = get_dist(from_turf, point) + if(distance < best_distance) + best = point + best_distance = distance + return best + +/// Living suspects at the outpost +/datum/outpost_police/proc/dispatch_suspect_mobs() + . = list() + for(var/datum/outpost_warrant/warrant as anything in suspects_at_outpost()) + var/mob/living/suspect = warrant.mind?.current + if(isliving(suspect) && suspect.stat != DEAD) + . += suspect + +/// Living players with a client at the outpost (its box, its berths and the ships docked there) +/datum/outpost_police/proc/dispatch_players_at_outpost() + . = list() + for(var/mob/living/player as anything in GLOB.alive_player_list) + if(player.client && dispatch_at_outpost(player)) + . += player + +/// Whether `officer` can be sent somewhere: alive, up, free and not walking out +/datum/outpost_police/proc/dispatch_can_respond(mob/living/carbon/human/npc/ambient_npc/outpost/police/officer) + if(QDELETED(officer) || officer.stat != CONSCIOUS || officer.npc_exit || officer.fading) + return FALSE + return !human_npc_out_of_fight(officer) + +/// A unit sent by dispatch (not a beat officer or the sergeant) +/datum/outpost_police/proc/dispatch_is_response_unit(mob/living/carbon/human/npc/ambient_npc/outpost/police/officer) + return officer.unit_kind != POLICE_UNIT_BEAT && officer.unit_kind != POLICE_UNIT_SERGEANT + +/// Where the police think `warrant`'s holder is: where they were last seen, else where they are +/datum/outpost_police/proc/dispatch_suspect_turf(datum/outpost_warrant/warrant) + return warrant.last_seen_turf || get_turf(warrant.mind?.current) + +/// Of `suspects`, the one nearest `officer` by where they are thought to be (another level counts as far away) +/datum/outpost_police/proc/dispatch_nearest_suspect(atom/officer, list/datum/outpost_warrant/suspects) + var/turf/from = get_turf(officer) + var/datum/outpost_warrant/best + var/best_distance = INFINITY + for(var/datum/outpost_warrant/warrant as anything in suspects) + var/turf/known = dispatch_suspect_turf(warrant) + if(!known) + continue + var/distance = (from && known.z == from.z) ? get_dist(from, known) : 1000 + if(!best || distance < best_distance) + best = warrant + best_distance = distance + return best + +/// Of `candidates`, the officer nearest any of `suspects` +/datum/outpost_police/proc/dispatch_closest_to_suspects(list/candidates, list/datum/outpost_warrant/suspects) + var/best + var/best_distance = INFINITY + for(var/atom/candidate as anything in candidates) + var/datum/outpost_warrant/nearest = dispatch_nearest_suspect(candidate, suspects) + var/turf/known = nearest ? dispatch_suspect_turf(nearest) : null + var/turf/from = get_turf(candidate) + var/distance = (known && from && known.z == from.z) ? get_dist(from, known) : 1000 + if(!best || distance < best_distance) + best = candidate + best_distance = distance + return best + +/// The suspect with the most stars (ties: the first) +/datum/outpost_police/proc/dispatch_top_suspect(list/datum/outpost_warrant/suspects) + var/datum/outpost_warrant/best + var/best_stars = 0 + for(var/datum/outpost_warrant/warrant as anything in suspects) + var/stars = get_stars(warrant.mind) + if(stars > best_stars) + best = warrant + best_stars = stars + return best + +/// Living officers on the roster plus units ordered and not built yet +/datum/outpost_police/proc/dispatch_live_count() + . = length(dispatch_orders) + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers) + if(officer.stat != DEAD) + .++ + +/// The same over every trader outpost +/proc/outpost_police_world_count() + . = 0 + for(var/obj/structure/overmap/trader_outpost/outpost as anything in GLOB.trader_outposts) + if(outpost?.police) + . += outpost.police.dispatch_live_count() + +/// A new wave for `sector` from `point` (null: built out of sight) +/datum/outpost_police/proc/dispatch_new_wave(sector, turf/point) + var/datum/outpost_police_wave/wave = new + wave.sector = sector + wave.point = point + wave.release_at = world.time + (point ? POLICE_WAVE_FORM_UP : 0) + dispatch_waves += wave + return wave + +/** + * Queues one unit of `unit_kind` into `wave`, on its lift point, or (none left, M2) on a tile out of every + * player's sight. Stops at OUTPOST_POLICE_CAP_OUTPOST here and OUTPOST_POLICE_CAP_WORLD everywhere, units + * waiting to be built included. Returns the order, or null. + */ +/datum/outpost_police/proc/dispatch_queue_unit(unit_kind, datum/outpost_police_wave/wave, datum/outpost_warrant/for_warrant) + var/unit_type = dispatch_unit_type(unit_kind) + if(!unit_type || !wave) + return null + if(dispatch_live_count() >= OUTPOST_POLICE_CAP_OUTPOST || outpost_police_world_count() >= OUTPOST_POLICE_CAP_WORLD) + return null + var/turf/where = wave.point + if(!where) + where = dispatch_unseen_tile(wave.sector) + if(!where) + return null + wave.release_at = world.time + var/outfit = profile?.outfits?[unit_kind] + var/datum/weakref/mind_ref = for_warrant?.mind ? WEAKREF(for_warrant.mind) : null + var/datum/human_npc_order/order = spawn_human_npc(unit_type, where, CALLBACK(src, PROC_REF(dispatch_unit_built), wave, mind_ref), outfit = ispath(outfit, /datum/outfit) ? outfit : null, immediate = dispatch_build_now) + if(!order) + return null + wave.orders += order + if(!order.done) + dispatch_orders[order] = unit_kind + wake() + return order + +/// The officer type of POLICE_UNIT_* +/datum/outpost_police/proc/dispatch_unit_type(unit_kind) + switch(unit_kind) + if(POLICE_UNIT_BEAT) + return /mob/living/carbon/human/npc/ambient_npc/outpost/police/beat + if(POLICE_UNIT_SERGEANT) + return /mob/living/carbon/human/npc/ambient_npc/outpost/police/sergeant + if(POLICE_UNIT_OFFICER) + return /mob/living/carbon/human/npc/ambient_npc/outpost/police/officer + if(POLICE_UNIT_TACTICAL) + return /mob/living/carbon/human/npc/ambient_npc/outpost/police/tactical + if(POLICE_UNIT_MARSHAL) + return /mob/living/carbon/human/npc/ambient_npc/outpost/police/marshal + return null + +/// A dispatched unit was built: on the roster, in its wave, held behind the door until the wave leaves +/datum/outpost_police/proc/dispatch_unit_built(datum/outpost_police_wave/wave, datum/weakref/mind_ref, mob/living/carbon/human/npc/ambient_npc/outpost/police/officer) + if(QDELETED(src) || !istype(officer)) + return + add_officer(officer) + // Outpost staff, as the ambient people they work beside are while they belong to it + officer.faction |= FACTION_TURRET + officer.dispatch_mind = mind_ref + if(QDELETED(wave)) + return + officer.dispatch_wave = wave + wave.members += WEAKREF(officer) + if(!wave.released_at) + ADD_TRAIT(officer, TRAIT_AI_PAUSED, POLICE_WAVE_TRAIT) + +/** + * A free tile of `sector` at least OUTPOST_POLICE_UNSEEN_SPAWN_DIST from, and out of sight of, every player on + * its level (abuse M2), or null. Tries up to POLICE_UNSEEN_TRIES tiles. + */ +/datum/outpost_police/proc/dispatch_unseen_tile(sector) + var/list/turf/pool = list() + var/datum/outpost_berth/berth = sector + if(istype(berth)) + pool = shuffle(berth.get_block()) + else if(sector == POLICE_SECTOR_LOBBY && length(outpost?.lobby_alcove_turfs)) + pool = shuffle(RANGE_TURFS(12, pick(outpost.lobby_alcove_turfs))) + else if(sector == POLICE_SECTOR_CONCOURSE && outpost?.template_bottom_left && outpost.outpost_template) + var/turf/bottom_left = outpost.template_bottom_left + for(var/i in 1 to POLICE_UNSEEN_TRIES) + var/turf/tile = locate(bottom_left.x + rand(0, outpost.outpost_template.width - 1), bottom_left.y + rand(0, outpost.outpost_template.height - 1), bottom_left.z) + if(tile) + pool += tile + if(length(pool) > POLICE_UNSEEN_TRIES) + pool.Cut(POLICE_UNSEEN_TRIES + 1) + for(var/turf/tile as anything in pool) + if(!isopenturf(tile) || isspaceturf(tile) || tile.is_blocked_turf(exclude_mobs = FALSE) || sector_of(tile) != sector) + continue + if(locate(/obj/effect/landmark/outpost_checkpoint_chamber) in tile) + continue + if(!dispatch_tile_watched(tile)) + return tile + return null + +/// Whether a living player on `tile`'s level is within OUTPOST_POLICE_UNSEEN_SPAWN_DIST of it or sees it +/datum/outpost_police/proc/dispatch_tile_watched(turf/tile) + if(!islist(SSmobs.clients_by_zlevel) || tile.z > length(SSmobs.clients_by_zlevel)) + return FALSE + for(var/mob/living/player in SSmobs.clients_by_zlevel[tile.z]) + if(player.stat == DEAD) + continue + if(get_dist(player, tile) < OUTPOST_POLICE_UNSEEN_SPAWN_DIST || perception.sees(tile, player)) + return TRUE + return FALSE + +// ===== on the officer ===== + +/// Abuse M11: a person pulling an officer resists, once every OUTPOST_POLICE_BLOCK_TIME +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/dispatch_check_pull() + var/mob/living/puller = pulledby + if(!isliving(puller) || !puller.mind || istype(puller, /mob/living/carbon/human/npc/ambient_npc/outpost/police)) + dispatch_pull_since = 0 + return + if(!dispatch_pull_since) + dispatch_pull_since = world.time + return + if(world.time - dispatch_pull_since >= OUTPOST_POLICE_BLOCK_TIME) + dispatch_pull_since = world.time + police?.report_crime(puller, OUTPOST_CRIME_RESIST, src) + +#undef POLICE_WAVE_TRAIT +#undef POLICE_WAVE_FORM_UP +#undef POLICE_WAVE_WAIT_MAX +#undef POLICE_EMPTY_RECALL +#undef POLICE_UNSEEN_TRIES diff --git a/voidcrew/modules/outpost_police/police_force.dm b/voidcrew/modules/outpost_police/police_force.dm index 726c8d6e613..8a7c9d3c063 100644 --- a/voidcrew/modules/outpost_police/police_force.dm +++ b/voidcrew/modules/outpost_police/police_force.dm @@ -4,26 +4,388 @@ * Owner: P4. Who officers fight and how hard: the targeting strategy, the weapon mode for the current target, * hold-fire and body-block rules, and looking for a suspect who got away. Spec 8. Every "does this officer * see that suspect" test outside the targeting strategy goes through police.perception.sees(). + * + * - Targeting (/datum/targeting_strategy/basic/human_npc/outpost_police): a person is a target only at STUN or + * LETHAL force by their warrant, and never in police custody; a stunned person is left for the cuffs. A + * creature by the old turret rule: xenos always, otherwise not on the outpost's side, not + * TRAIT_OUTPOST_RESIDENT, and is_hostile_creature(). A mech when anyone aboard is a target. + * - Force (outpost_police_force subtree, set_force()): the baton at ARREST, the energy gun on disable at STUN + * and on kill at LETHAL or for a creature, switched with the gun's own select_fire(). The officer shoots + * through the human NPC base's shot (the real gun), but holds fire while anyone they would not shoot stands + * in the line (abuse M10), stepping aside instead. + * - Body-blocking (M11): a person who stands in the line within 2 tiles of an officer firing to kill for + * OUTPOST_POLICE_BLOCK_TIME, or pulls an officer (police_dispatch.dm), resists, again every 5 seconds. + * - Search (outpost_police_search subtree): an officer responding to a suspect they cannot shoot or do not see + * goes where the suspect was last seen, by lift if that is another sector, looks about, says so, and keeps + * looking nearby. At three stars a suspect seen last aboard a docked ship is waited for at its airlock. */ -/// Whom an officer fights. P0 stub: nobody. +/// Where a searching officer is walking +#define BB_POLICE_DESTINATION "outpost_police_destination" +/// How long an officer looks about a spot before moving on +#define POLICE_LOOK_TIME (6 SECONDS) +/// How far from the last sighting an officer looks +#define POLICE_SEARCH_RADIUS 5 + +// ========================================================================= +// TARGETING +// ========================================================================= + +/// Whom an officer fights. Policy in police_will_fight(); range, sight and the downed rule from the base. /datum/targeting_strategy/basic/human_npc/outpost_police /datum/targeting_strategy/basic/human_npc/outpost_police/can_attack(mob/living/living_mob, atom/the_target, vision_range) - return FALSE + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = living_mob + if(!istype(officer) || !officer.police_will_fight(the_target)) + return FALSE + return ..() -/// The weapon mode for the current target, hold fire and body blocks +// A person shares no side with the police: their warrant decides (players' default faction is the officers' own) +/datum/targeting_strategy/basic/human_npc/outpost_police/faction_check(datum/ai_controller/controller, mob/living/living_mob, mob/living/the_target) + if(the_target.mind || the_target.client) + return FALSE + return ..() + +/** + * Whether this officer would fight `target`, sight and range aside: a person by their warrant (STUN or LETHAL, + * not in custody, not already stunned at STUN), a creature by the old turret rule, a mech by who is aboard. + */ +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/police_will_fight(atom/target) + if(!police || QDELETED(target)) + return FALSE + if(ismecha(target)) + var/obj/vehicle/sealed/mecha/mech = target + for(var/mob/living/occupant in mech.occupants) + if(police_will_fight(occupant)) + return TRUE + return FALSE + if(!isliving(target)) + return FALSE + var/mob/living/living_target = target + if(living_target == src || living_target.stat == DEAD) + return FALSE + if(istype(living_target, /mob/living/carbon/human/npc/ambient_npc/outpost/police)) + return FALSE + if(living_target.mind || living_target.client) + if(police.in_custody(living_target)) + return FALSE + var/level = police.force_level(living_target) + if(level >= POLICE_FORCE_LETHAL) + return TRUE + if(level == POLICE_FORCE_STUN) + // Down from the disabler already: the cuffs are the arrest's job + return living_target.stat == CONSCIOUS && !HAS_TRAIT(living_target, TRAIT_INCAPACITATED) && !HAS_TRAIT(living_target, TRAIT_RESTRAINED) + return FALSE + // Wildlife, by the rule the outpost turrets had + if(isalien(living_target) || istype(living_target, /mob/living/basic/alien)) + return TRUE + if(faction_check_atom(living_target)) + return FALSE + if(HAS_TRAIT(living_target, TRAIT_OUTPOST_RESIDENT)) + return FALSE + return is_hostile_creature(living_target) + +/// How hard they go at `target`: a person by their warrant, anything else to kill +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/force_for(atom/target) + var/mob/living/living_target = target + if(isliving(living_target) && (living_target.mind || living_target.client)) + return police?.force_level(living_target) || POLICE_FORCE_NONE + return POLICE_FORCE_LETHAL + +// ========================================================================= +// FIRE DISCIPLINE +// ========================================================================= + +/** + * The tiles a shot from `start_turf` at `end_turf` crosses. The one line helper of this module: the stealth merge swaps + * it for human_npc_shot_path() (spec 18). + */ +/proc/outpost_police_shot_line(turf/start_turf, turf/end_turf) + return get_line(start_turf, end_turf) + +/** + * Whoever stands in this officer's line of fire at `target` and is not someone they would shoot: anyone up + * and living (shots pass over the lying). `within` > 0 looks only that many tiles out. Null if clear. + */ +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/bystander_in_line(atom/target, within = 0) + var/turf/from = get_turf(src) + var/turf/end_turf = get_turf(target) + if(!from || !end_turf) + return null + for(var/turf/line_turf as anything in outpost_police_shot_line(from, end_turf)) + if(line_turf == from || line_turf == end_turf) + continue + if(within && get_dist(from, line_turf) > within) + continue + for(var/mob/living/other in line_turf) + if(other == target || other.stat == DEAD || other.body_position == LYING_DOWN) + continue + if(police_will_fight(other)) + continue + return other + return null + +/** + * Abuse M11, once a second from dispatch: a person in the line of fire within 2 tiles of an officer firing to + * kill, for OUTPOST_POLICE_BLOCK_TIME, resists; again each OUTPOST_POLICE_BLOCK_TIME they stay. + */ +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/check_body_block() + var/mob/living/blocker + var/atom/target = ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(force == POLICE_FORCE_LETHAL && stat == CONSCIOUS && !QDELETED(target)) + blocker = bystander_in_line(target, 2) + if(blocker && !blocker.mind) + blocker = null + if(!blocker) + dispatch_block_ref = null + dispatch_block_since = 0 + return + if(dispatch_block_ref?.resolve() != blocker) + dispatch_block_ref = WEAKREF(blocker) + dispatch_block_since = world.time + return + if(world.time - dispatch_block_since >= OUTPOST_POLICE_BLOCK_TIME) + dispatch_block_since = world.time + police?.report_crime(blocker, OUTPOST_CRIME_RESIST, src) + +// ========================================================================= +// FORCE +// ========================================================================= + +/** + * The weapon mode for the current target, then the shot with fire discipline. With a person of one star (no + * target: the arrest's job) or nothing to fight, the weapons go away. With no loaded gun it leaves the target + * to the base's arm and melee subtrees (the baton). + */ /datum/ai_planning_subtree/outpost_police_force /datum/ai_planning_subtree/outpost_police_force/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - return + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = controller.pawn + if(!istype(officer) || !officer.police) + return + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!isnull(target) && !officer.police_will_fight(target)) + controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + target = null + if(isnull(target)) + if(officer.force != POLICE_FORCE_NONE && !officer.case && !officer.dispatch_responding()) + officer.set_force(POLICE_FORCE_NONE) + return + var/level = officer.force_for(target) + if(level != officer.force) + officer.set_force(level) + if(level < POLICE_FORCE_STUN || HAS_TRAIT(officer, TRAIT_HANDS_BLOCKED) || LAZYLEN(officer.do_afters)) + return + if(isnull(human_npc_loaded_gun(officer))) + return + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[BB_TARGETING_STRATEGY]) + var/fire_range = controller.blackboard[BB_HUMAN_NPC_FIRE_RANGE] || 7 + if(get_dist(officer, target) <= fire_range && strategy?.can_attack(officer, target, fire_range)) + var/keep_away = controller.blackboard[BB_HUMAN_NPC_KEEP_AWAY] + if(keep_away && get_dist(officer, target) < keep_away) + controller.queue_behavior(/datum/ai_behavior/step_away, BB_BASIC_MOB_CURRENT_TARGET) + controller.queue_behavior(/datum/ai_behavior/human_npc_shoot/outpost_police, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + else + controller.queue_behavior(/datum/ai_behavior/human_npc_close_in, BB_BASIC_MOB_CURRENT_TARGET, fire_range) + return SUBTREE_RETURN_FINISH_PLANNING -/// Walks to where a lost suspect was last seen and looks about +/// The base's shot, held while a bystander is in the line (abuse M10): a step aside instead +/datum/ai_behavior/human_npc_shoot/outpost_police + +/datum/ai_behavior/human_npc_shoot/outpost_police/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = controller.pawn + var/atom/target = controller.blackboard[target_key] + if(istype(officer) && !QDELETED(target) && officer.bystander_in_line(target)) + human_npc_sidestep(officer, target) + return AI_BEHAVIOR_DELAY + return ..() + +/// Baton out at ARREST, gun on disable at STUN, gun on kill at LETHAL (POLICE_FORCE_*); both put away at NONE +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/set_force(level) + force = level + var/obj/item/baton = get_baton() + var/obj/item/gun/energy/gun = get_sidearm() + switch(level) + if(POLICE_FORCE_ARREST) + if(gun && is_holding(gun)) + gun.equip_to_best_slot(src) + if(baton && police_draw(baton)) + human_npc_ready_weapon(src, baton) + if(POLICE_FORCE_STUN, POLICE_FORCE_LETHAL) + if(gun) + outpost_police_set_gun_mode(gun, lethal = (level == POLICE_FORCE_LETHAL)) + police_draw(gun, baton) + else + for(var/obj/item/weapon in list(gun, baton)) + if(is_holding(weapon)) + weapon.equip_to_best_slot(src) + +/** + * Takes `weapon` in hand, making room first: a routine's prop goes, then `stow` (the other weapon) goes back on + * the belt. Returns TRUE if it is in hand. + */ +/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/police_draw(obj/item/weapon, obj/item/stow) + if(QDELETED(weapon)) + return FALSE + if(is_holding(weapon)) + return TRUE + if(!length(get_empty_held_indexes())) + put_away_prop() + if(!length(get_empty_held_indexes()) && stow && is_holding(stow)) + stow.equip_to_best_slot(src) + if(!length(get_empty_held_indexes())) + for(var/obj/item/held in held_items) + if(held == weapon || held == stow) + continue + if(HAS_TRAIT(held, TRAIT_AMBIENT_PROP)) + qdel(held) + else + held.equip_to_best_slot(src) + break + return human_npc_take_in_hand(src, weapon) + +/// Switches `gun` with its own select_fire() to its first disabling (`lethal` FALSE) or killing mode +/proc/outpost_police_set_gun_mode(obj/item/gun/energy/gun, lethal) + if(!istype(gun) || length(gun.ammo_type) < 2) + return + for(var/i in 1 to length(gun.ammo_type)) + if(outpost_police_mode_lethal(gun) == lethal) + return + gun.select_fire(null) + +/// Whether `gun`'s selected mode kills (brute or burn) rather than disables (stamina) +/proc/outpost_police_mode_lethal(obj/item/gun/energy/gun) + var/obj/item/ammo_casing/energy/shot = gun.ammo_type[gun.select] + var/obj/projectile/projectile_type = ispath(shot) ? initial(shot.projectile_type) : shot.projectile_type + if(!ispath(projectile_type, /obj/projectile)) + return FALSE + return initial(projectile_type.damage_type) != STAMINA && initial(projectile_type.damage) > 0 + +// ========================================================================= +// SEARCH +// ========================================================================= + +/** + * An officer responding to a suspect (dispatch_mind) with nothing to fight: to the suspect if they see them, + * else to where they were last seen (at three stars aboard a docked ship, the ship's airlock), by lift when + * that is another sector; there, they look about, say they lost them, and look again nearby. + */ /datum/ai_planning_subtree/outpost_police_search /datum/ai_planning_subtree/outpost_police_search/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - return + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = controller.pawn + if(!istype(officer) || !officer.police || officer.case || officer.dispatch_riding || officer.npc_exit) + return + if(controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) || LAZYLEN(officer.do_afters)) + return + var/datum/mind/mind = officer.dispatch_mind?.resolve() + var/datum/outpost_police/police = officer.police + var/datum/outpost_warrant/warrant = mind ? police.get_warrant(mind) : null + if(!warrant) + return + var/mob/living/suspect = mind.current + var/seen = isliving(suspect) && police.perception.sees(officer, suspect) + var/turf/goal + var/goal_distance = 1 + if(seen) + goal = get_turf(suspect) + goal_distance = 2 + officer.dispatch_search_from = null + officer.dispatch_post = null + officer.dispatch_search_until = 0 + officer.dispatch_lost_ref = null + else + var/list/held = police.dispatch_holds[mind] + if(held && police.get_stars(mind) >= 3) + goal = held[2] + else + goal = warrant.last_seen_turf + if(goal && goal != officer.dispatch_search_from) + officer.dispatch_search_from = goal + officer.dispatch_post = null + officer.dispatch_search_until = 0 + goal = officer.dispatch_post || goal + if(!goal) + return + // Another sector: the lift + var/my_sector = police.sector_of(officer) + var/goal_sector = police.sector_of(goal) + if(goal_sector != my_sector && goal_sector != POLICE_SECTOR_OUTSIDE) + var/turf/lift = police.dispatch_nearest_lift_point(my_sector, officer) + if(!lift) + return + if(get_turf(officer) == lift) + controller.queue_behavior(/datum/ai_behavior/outpost_police_ride, goal_sector) + return SUBTREE_RETURN_FINISH_PLANNING + controller.set_blackboard_key(BB_POLICE_DESTINATION, lift) + controller.queue_behavior(/datum/ai_behavior/travel_towards/outpost_police, BB_POLICE_DESTINATION) + return SUBTREE_RETURN_FINISH_PLANNING + if(get_dist(officer, goal) > goal_distance) + controller.set_blackboard_key(BB_POLICE_DESTINATION, goal) + controller.queue_behavior(goal_distance > 1 ? /datum/ai_behavior/travel_towards/outpost_police/near : /datum/ai_behavior/travel_towards/outpost_police, BB_POLICE_DESTINATION) + return SUBTREE_RETURN_FINISH_PLANNING + if(seen) + return SUBTREE_RETURN_FINISH_PLANNING + // There, and no sign of them: look about, then try somewhere close by + if(!officer.dispatch_search_until) + officer.dispatch_search_until = world.time + POLICE_LOOK_TIME + if(officer.dispatch_lost_ref?.resolve() != mind) + officer.dispatch_lost_ref = WEAKREF(mind) + INVOKE_ASYNC(officer, TYPE_PROC_REF(/mob/living/carbon/human/npc/ambient_npc/outpost/police, police_say), POLICE_LINE_LOST, null, FALSE) + if(world.time < officer.dispatch_search_until) + controller.queue_behavior(/datum/ai_behavior/outpost_police_look_about) + return SUBTREE_RETURN_FINISH_PLANNING + officer.dispatch_search_until = 0 + officer.dispatch_post = police_search_spot(police, officer.dispatch_search_from || goal, my_sector) + return SUBTREE_RETURN_FINISH_PLANNING -/// Baton out at ARREST, gun on disable at STUN, gun on kill at LETHAL (POLICE_FORCE_*) -/mob/living/carbon/human/npc/ambient_npc/outpost/police/proc/set_force(level) - return +/// A free tile of `sector` within POLICE_SEARCH_RADIUS of `around` to look from next, or null +/proc/police_search_spot(datum/outpost_police/police, turf/around, sector) + if(!around) + return null + for(var/turf/spot as anything in shuffle(RANGE_TURFS(POLICE_SEARCH_RADIUS, around))) + if(!isopenturf(spot) || isspaceturf(spot) || spot.is_blocked_turf() || police.sector_of(spot) != sector) + continue + return spot + return null + +/// Walks to the spot in the blackboard key; planning goes on meanwhile +/datum/ai_behavior/travel_towards/outpost_police + action_cooldown = 0.5 SECONDS + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION | AI_BEHAVIOR_MOVE_AND_PERFORM + +/datum/ai_behavior/travel_towards/outpost_police/perform(seconds_per_tick, datum/ai_controller/controller, target_key) + var/atom/target = controller.blackboard[target_key] + if(QDELETED(target) || controller.current_movement_target != target) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED + if(get_dist(controller.pawn, target) <= required_distance) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_DELAY + +/// The same, stopping a couple of tiles short (closing on someone they can see) +/datum/ai_behavior/travel_towards/outpost_police/near + required_distance = 2 + +/// Takes the lift they stand on to a sector (police.ride(), which sleeps, so it runs on its own) +/datum/ai_behavior/outpost_police_ride + action_cooldown = 1 SECONDS + +/datum/ai_behavior/outpost_police_ride/perform(seconds_per_tick, datum/ai_controller/controller, sector) + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = controller.pawn + if(!istype(officer) || !officer.police || officer.dispatch_riding) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + INVOKE_ASYNC(officer.police, TYPE_PROC_REF(/datum/outpost_police, ride), officer, sector) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED + +/// Turns to look one way and another +/datum/ai_behavior/outpost_police_look_about + action_cooldown = 1.5 SECONDS + +/datum/ai_behavior/outpost_police_look_about/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/officer = controller.pawn + officer.setDir(pick(GLOB.cardinals)) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED + +#undef BB_POLICE_DESTINATION +#undef POLICE_LOOK_TIME +#undef POLICE_SEARCH_RADIUS From 0500c60712e4d04f14de64fee2fbd31d2b2ffd37 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:34:14 -0800 Subject: [PATCH 025/152] Outpost police: port the old turret and strike tests to warrants and officers The tests that read the strike ladder or the outpost turrets now read the police's warrants, stars and evidence, and an officer's targeting strategy. Crimes that need a witness get an officer to see them. The guards are gone. The police types are left out of the death cash, crowd and combat sense checks that would not fit them. --- .../unit_tests/voidcrew_ambient_outposts.dm | 47 ++++++---- .../unit_tests/voidcrew_bounty_kingpin.dm | 77 ++++++++-------- .../unit_tests/voidcrew_bounty_outpost.dm | 89 ++++++++++--------- .../unit_tests/voidcrew_cyberware_camo.dm | 32 ++++++- .../unit_tests/voidcrew_gorilla_gore.dm | 2 +- .../voidcrew_human_npcs_combat_sense.dm | 4 + .../voidcrew_outpost_self_defense.dm | 79 +++++++++++----- .../unit_tests/voidcrew_trader_checkpoint.dm | 71 ++++++++------- 8 files changed, 241 insertions(+), 160 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_ambient_outposts.dm b/voidcrew/modules/unit_tests/voidcrew_ambient_outposts.dm index 77cc7ff7c29..7ce8e475d22 100644 --- a/voidcrew/modules/unit_tests/voidcrew_ambient_outposts.dm +++ b/voidcrew/modules/unit_tests/voidcrew_ambient_outposts.dm @@ -90,16 +90,16 @@ pa_trader(outpost, pa_tile(2, 4), null, /datum/outpost_shop/vendor/bait_shop) TEST_ASSERT_EQUAL(customers.wanted(place), 2, "An outpost with a trader wants [customers.wanted(place)] customers, not 2") - // Every one of them can come to an outpost with nothing in it and not fall over + // Every one of them can come to an outpost with nothing in it and not fall over (an officer comes on its station's roster) for(var/npc_type in subtypesof(/mob/living/carbon/human/npc/ambient_npc/outpost)) - var/mob/living/carbon/human/npc/ambient_npc/npc = pa_npc(npc_type, pa_tile(1, 1), place) + var/mob/living/carbon/human/npc/ambient_npc/npc = ispath(npc_type, /mob/living/carbon/human/npc/ambient_npc/outpost/police) ? police_officer(outpost, npc_type, pa_tile(1, 1)) : pa_npc(npc_type, pa_tile(1, 1), place) npc.pick_activity() npc.end_activity() qdel(npc) // ...or be found there already, holding still, and never on or beside the lift for(var/npc_type in subtypesof(/mob/living/carbon/human/npc/ambient_npc/outpost)) - var/mob/living/carbon/human/npc/ambient_npc/npc = pa_npc(npc_type, pa_tile(1, 1), place) + var/mob/living/carbon/human/npc/ambient_npc/npc = ispath(npc_type, /mob/living/carbon/human/npc/ambient_npc/outpost/police) ? police_officer(outpost, npc_type, pa_tile(1, 1)) : pa_npc(npc_type, pa_tile(1, 1), place) npc.settle_in() TEST_ASSERT(get_dist(npc, pa_tile(4, 4)) > 1, "[npc_type] was found on or beside the lift") TEST_ASSERT(HAS_TRAIT(npc, TRAIT_AI_PAUSED), "[npc_type] at an empty outpost is not holding still") @@ -170,7 +170,8 @@ TEST_ASSERT(!barback_role.applies_to(halcyon_outpost), "A barback still applies to Halcyon") TEST_ASSERT(barback_role.applies_to(undertow_outpost), "A barback does not apply to the Undertow") - // The floor crowd matches spec exactly, and never crosses the transient cap + // The floor crowd matches spec exactly, and never crosses the transient cap. The police roles are left + // out of the sum: how officers sit with the cap is outpost_police/police_life.dm's call, and its tests' var/list/expected_crowd = list() expected_crowd[halcyon_place] = 7 expected_crowd[quartermain_place] = 7 @@ -179,6 +180,8 @@ var/obj/structure/overmap/trader_outpost/crowd_outpost = crowd_place.outpost() var/total = 0 for(var/role_type in subtypesof(/datum/ambient_outpost_role)) + if(ispath(role_type, /datum/ambient_outpost_role/police_beat) || ispath(role_type, /datum/ambient_outpost_role/police_sergeant)) + continue var/datum/ambient_outpost_role/role = allocate(role_type) if(!role.npc_type || !role.applies_to(crowd_outpost)) continue @@ -378,13 +381,11 @@ // DRINKERS (owner item 2) // ========================================================================= -// POLICE-PORT: reads aggressor_strikes; port to police.get_stars()/warrant.heat (P10) -#ifdef POLICE_TESTS_PORTED /** * A drinker orders at the bar, sits, really drinks a real glass (the drink goes into them, and tg's * drunkenness sets their stage), is cut off when done for, sleeps it off on a sofa for real (tg's * Sleeping) and leaves; a drink handed to them is drunk the same way; poison anyone could see is - * refused, poison hidden in a drink is not, and counts as violence at the outpost. + * refused, poison hidden in a drink is not, and is a crime at the outpost. */ /datum/unit_test/voidcrew_ambient_outpost_drinkers @@ -472,13 +473,14 @@ plasma.reagents.add_reagent(/datum/reagent/toxin/plasma, 30) TEST_ASSERT(!regular.accept_drink(player, plasma), "A drinker drank a glass of plasma") TEST_ASSERT_EQUAL(plasma.reagents.total_volume, 30, "A drinker sipped the plasma") - // Poison hidden in a beer goes down, and the outpost counts it as violence + // Poison hidden in a beer goes down, and the police count it as a crime: the drinker and the barkeep saw it LAZYREMOVE(regular.reaction_cooldowns, "handed") var/obj/item/reagent_containers/cup/glass/drinkingglass/spiked = allocate(/obj/item/reagent_containers/cup/glass/drinkingglass, pa_tile(4, 0)) spiked.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, 25) spiked.reagents.add_reagent(/datum/reagent/toxin, 5) TEST_ASSERT(regular.accept_drink(player, spiked), "A drinker turned down a spiked beer they couldn't tell from a beer") - TEST_ASSERT(outpost.aggressor_strikes[player.mind] >= 1, "Spiking a drinker's beer is not violence at the outpost") + TEST_ASSERT_NOTNULL(outpost.police.get_warrant(player.mind), "Spiking a drinker's beer is not a crime at the outpost") + TEST_ASSERT(outpost.police.get_stars(player.mind) >= 1, "Spiking a drinker's beer is not worth a star") // ASSAULT_CIVILIAN: 12 heat, one star is 10 // Coffee is welcome too LAZYREMOVE(regular.reaction_cooldowns, "handed") var/obj/item/reagent_containers/cup/glass/drinkingglass/coffee = allocate(/obj/item/reagent_containers/cup/glass/drinkingglass, pa_tile(4, 0)) @@ -493,7 +495,6 @@ crew.on_sip(FALSE) crew.on_sip(FALSE) TEST_ASSERT(crew.glass_is_strong(), "The crew room never moved on to something stronger") -#endif // ========================================================================= // STAFF (owner item 4) @@ -837,13 +838,12 @@ // KILLING // ========================================================================= -// POLICE-PORT: reads aggressor_strikes; port to police.get_stars()/warrant.heat (P10) -#ifdef POLICE_TESTS_PORTED /** * Every outpost NPC can be killed: they die of their wounds and drop 5 to 30 cr once, never again - * after a revive, and the outpost's turrets count them as its own. A killed person's place stays - * empty a good while, made up neither in place nor off the lift; the outpost's people drop no more - * than 300 cr between them; a blow to one is violence at the outpost; bodies are taken away. + * after a revive, and carry the outpost staff's faction (FACTION_TURRET). The station's officers die + * the same way and drop nothing at all. A killed person's place stays empty a good while, made up + * neither in place nor off the lift; the outpost's people drop no more than 300 cr between them; + * a blow to one is a crime at the outpost; bodies are taken away. */ /datum/unit_test/voidcrew_ambient_outpost_deaths @@ -857,9 +857,18 @@ for(var/npc_type in types) place.cash_dropped = 0 var/cash_before = ambient_test_cash_on(spot) + // An officer comes on its station's roster, carries no cash and drops nothing; their kit is the units test's + if(ispath(npc_type, /mob/living/carbon/human/npc/ambient_npc/outpost/police)) + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = police_officer(outpost, npc_type, spot) + TEST_ASSERT(!HAS_TRAIT(officer, TRAIT_GODMODE), "[npc_type] cannot be hurt") + officer.apply_damage(officer.maxHealth * 2, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND) + TEST_ASSERT_EQUAL(officer.stat, DEAD, "[npc_type] did not die of their wounds") + TEST_ASSERT_EQUAL(ambient_test_cash_on(spot), cash_before, "[npc_type] dropped cash") + qdel(officer) + continue var/mob/living/carbon/human/npc/ambient_npc/npc = pa_npc(npc_type, spot, place) TEST_ASSERT(!HAS_TRAIT(npc, TRAIT_GODMODE), "[npc_type] cannot be hurt") - TEST_ASSERT(FACTION_TURRET in npc.faction, "The outpost's turrets would shoot [npc_type]") + TEST_ASSERT(FACTION_TURRET in npc.faction, "The outpost's staff would not count [npc_type] as their own") npc.apply_damage(npc.maxHealth * 2, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND) // a limb takes no more than its own max damage TEST_ASSERT_EQUAL(npc.stat, DEAD, "[npc_type] did not die of their wounds") var/dropped = ambient_test_cash_on(spot) - cash_before @@ -913,13 +922,14 @@ broke.death() TEST_ASSERT_EQUAL(ambient_test_cash_on(purse_spot), last_cash, "Someone dropped cash past the outpost's cap") - // A real blow to one of them is violence at the outpost: a strike, and everyone near ducks and goes + // A real blow to one of them is violence at the outpost: a crime, seen by the two customers, and everyone near ducks and goes var/mob/living/carbon/human/consistent/brute = allocate(/mob/living/carbon/human/consistent, pa_tile(4, 0)) brute.mind_initialize() var/mob/living/carbon/human/npc/ambient_npc/outpost/customer/witness = pa_npc(/mob/living/carbon/human/npc/ambient_npc/outpost/customer, pa_tile(2, 0), place) var/mob/living/carbon/human/npc/ambient_npc/outpost/customer/target = pa_npc(/mob/living/carbon/human/npc/ambient_npc/outpost/customer, pa_tile(3, 1), place) SEND_SIGNAL(target, COMSIG_ATOM_WAS_ATTACKED, brute, ATTACKER_DAMAGING_ATTACK) - TEST_ASSERT(outpost.aggressor_strikes[brute.mind], "A blow to one of the outpost's people was no strike") + TEST_ASSERT_NOTNULL(outpost.police.get_warrant(brute.mind), "A blow to one of the outpost's people was no crime") + TEST_ASSERT(outpost.police.get_stars(brute.mind) >= 1, "A blow to one of the outpost's people was not worth a star") // ASSAULT_CIVILIAN: 12 heat, one star is 10 TEST_ASSERT(istype(witness.activity, /datum/ambient_activity/leave), "Nobody near ducked and left when one of the outpost's people was hit") // With players there a body lies a while; after five minutes the human NPC corpse sweep takes it @@ -931,7 +941,6 @@ TEST_ASSERT(!body.npc_corpse_expired(), "A fresh body is already up to be taken away") body.npc_died_at = world.time - 3001 // AMBIENT_OUTPOST_BODY_TIME TEST_ASSERT(body.npc_corpse_expired(), "A body nobody is near is not taken away after five minutes") -#endif /// Nobody new comes up the lift while someone is still in it /datum/unit_test/voidcrew_ambient_outpost_lift_one_at_a_time diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm index fd9059b3a68..19725eab2dc 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm @@ -972,15 +972,13 @@ // ===== THE MAP ===== -// POLICE-PORT: uses the removed outpost turret and strike ladder; port to an officer and warrant.heat (P10) -#ifdef POLICE_TESTS_PORTED /** * The black market's lounge: the seat on the corp sofa, no table of his own in front of it (he lost * his coffee table, and no lounge table stands at any of his six goon posts either), six goon posts * and the refuge. SSbounty_kingpin seats him and his crew there on no posting, as it does at the start * of the round, his goons arriving through the human NPC spawn queue: the barkeep is the bar's own, - * the crew's way out is the refuge, never the hangar lift (M3, M4, M5), and his posting is at that - * outpost. + * the crew's way out is the refuge, never the hangar lift (M3, M4, M5), the station's officers leave + * his crew alone, and his posting is at that outpost. */ /datum/unit_test/voidcrew_bounty_kingpin/map @@ -1050,13 +1048,21 @@ TEST_ASSERT_NOTNULL(barkeep, "No trader in sight of the sofa is the barkeep") var/matrix/standing = matrix(barkeep.transform) - var/obj/machinery/porta_turret/outpost/turret = locate() in range(12, seat_turf) - TEST_ASSERT_NOTNULL(turret, "No lounge turret near the sofa") + // An officer on the floor beside the sofa leaves his crew alone: the goons carry the outpost staff's faction, and he is exempt by his own rule + var/turf/officer_spot + for(var/turf/open/candidate in oview(3, seat_turf)) + if(!candidate.is_blocked_turf(TRUE)) + officer_spot = candidate + break + TEST_ASSERT_NOTNULL(officer_spot, "No free floor beside the sofa for an officer") + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = police_officer(outpost, /mob/living/carbon/human/npc/ambient_npc/outpost/police/beat, officer_spot) + TEST_ASSERT(FACTION_TURRET in officer.faction, "An officer is not of the outpost staff's faction") var/mob/living/carbon/human/hunter = kingpin_test_person(crew.talk_spot(), null) crew.start_shootout(hunter, TRUE) for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in crew.goons()) - TEST_ASSERT(turret.in_faction(goon), "The lounge turret doesn't count [goon] as its own") - TEST_ASSERT(bounty_turret_ignores(kingpin, TRUE), "The lounge turret doesn't leave the kingpin alone") + TEST_ASSERT(FACTION_TURRET in goon.faction, "[goon] isn't left alone by the officers") + TEST_ASSERT(!police_port_may_attack(officer, goon), "The station's officers would shoot [goon]") + TEST_ASSERT(bounty_turret_ignores(kingpin, TRUE), "The officers don't leave the kingpin alone") TEST_ASSERT(barkeep.transform ~= standing, "The shootout changed how the barkeep looks") // A shootout puts nobody on the board; the clock does, adopting him at this outpost TEST_ASSERT_NULL(bounty_kingpin_open_posting(), "The shootout put him on the board") @@ -1073,7 +1079,6 @@ TEST_ASSERT(length(loiter) >= 40, "The Undertow has only [length(loiter)] loiter tiles") TEST_ASSERT(!loiter[refuge], "The kingpin's refuge, mid-hallway, is a loiter tile") qdel(outpost) -#endif // ===== H1: SHOTS FROM MECHS, AND ANY DAMAGE ===== @@ -1215,12 +1220,11 @@ // ===== OUTPOST SECURITY ===== -// POLICE-PORT: uses the removed outpost turret and strike ladder; port to an officer and warrant.heat (P10) -#ifdef POLICE_TESTS_PORTED /** - * During the shootout a hunter's stray shot, just after firing at his crew, takes no outpost strike: - * three of them leave the turret holding fire, where three strikes mark anyone else. Hits away from - * the fight still count, PvP still counts, and strikes count again after it (L1, L11). + * During the shootout a hunter's stray shot, just after firing at his crew, is no crime at the outpost: + * three of them leave the police with nothing on him, where the same hit from anyone else is on record. + * Hits away from the fight still count, PvP still counts, and a hunter's hits count again after it + * (L1, L11). Nothing here waits: the goons' AI must not get a turn. */ /datum/unit_test/voidcrew_bounty_kingpin/security @@ -1230,8 +1234,10 @@ outpost.outpost_template.width = 5 outpost.outpost_template.height = 5 outpost.template_bottom_left = run_loc_floor_bottom_left - var/obj/machinery/porta_turret/outpost/turret = allocate(/obj/machinery/porta_turret/outpost, kingpin_test_spot(4, 4)) - turret.outpost = outpost + var/datum/outpost_police/police = outpost.police + TEST_ASSERT_NOTNULL(police, "The test outpost has no police") + // An officer in the far corner: every crime below is in its sight, and it is the one the goons must be safe from + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = police_port_witness(outpost, kingpin_test_spot(4, 4)) var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 2) @@ -1254,38 +1260,33 @@ TEST_ASSERT(crew.engaged(hunter), "A hunter who just shot a goon isn't counted in the fight") TEST_ASSERT(!crew.engaged(idle_hunter), "A hunter who fired at nobody is counted in the fight") - // Three stray hits into outpost property: no strike, and the turret holds fire (three would mark them) + // Three stray hits into outpost property: no crime, the hunter is nobody to the police and the officers leave him be for(var/i in 1 to 3) - outpost.aggressor_strike_times -= hunter.mind outpost.register_aggression(hunter) - TEST_ASSERT_NULL(outpost.aggressor_strikes[hunter.mind], "A hunter's stray shot during the shootout got a property strike") - TEST_ASSERT(!outpost.is_marked_aggressor(hunter.mind), "A hunter was marked for stray shots during the shootout") - TEST_ASSERT(!turret.valid_target(hunter), "The turret turned on a hunter during the shootout") - // The same three hits from someone not in the fight mark them, and the turret fires - for(var/i in 1 to 3) - outpost.aggressor_strike_times -= bystander.mind - outpost.register_aggression(bystander) - TEST_ASSERT(outpost.is_marked_aggressor(bystander.mind), "Three strikes didn't mark a bystander during the shootout") - TEST_ASSERT(turret.valid_target(bystander), "The turret held fire on a marked bystander") - // A hunter who hasn't been shooting at his crew gets their strike + TEST_ASSERT_NULL(police.get_warrant(hunter.mind), "A hunter's stray shot during the shootout was put on his record") + TEST_ASSERT(!outpost.is_user_barred(hunter), "A hunter was barred for stray shots during the shootout") + TEST_ASSERT_EQUAL(police.force_level(hunter), 0, "The police went for a hunter over stray shots during the shootout") // POLICE_FORCE_NONE + TEST_ASSERT(!police_port_may_attack(officer, hunter), "An officer turned on a hunter during the shootout") + // The same hit from someone not in the fight is on record + outpost.register_aggression(bystander) + TEST_ASSERT_NOTNULL(police.get_warrant(bystander.mind), "A bystander's hit on outpost property was excused during the shootout") + // A hunter who hasn't been shooting at his crew is on record too outpost.register_aggression(idle_hunter) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[idle_hunter.mind], 1, "A hunter not firing at his crew was excused a strike") - // PvP enforcement stays: a hunter who hits a person gets the strike + TEST_ASSERT_NOTNULL(police.get_warrant(idle_hunter.mind), "A hunter not firing at his crew was excused a crime") + // PvP enforcement stays: a hunter who hits a person is on record outpost.register_pvp_aggression(victim, brawler) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[brawler.mind], 1, "A hunter's PvP hit didn't count during the shootout") + TEST_ASSERT_NOTNULL(police.get_warrant(brawler.mind), "A hunter's PvP hit didn't count during the shootout") - // The turret leaves his crew alone: the goons share its faction, P2 exempts the kingpin - TEST_ASSERT(turret.in_faction(goon), "The goons don't share the turrets' faction") - TEST_ASSERT(!turret.valid_target(goon), "The turret would shoot a goon") - TEST_ASSERT(bounty_turret_ignores(kingpin, TRUE), "Trader-outpost turrets don't leave the kingpin alone") + // The officers leave his crew alone: the goons carry the outpost staff's faction, and he is exempt by his own rule + TEST_ASSERT(FACTION_TURRET in goon.faction, "The goons aren't of the outpost staff's faction") + TEST_ASSERT(!police_port_may_attack(officer, goon), "The officers would shoot a goon") + TEST_ASSERT(bounty_turret_ignores(kingpin, TRUE), "The officers don't leave the kingpin alone") // After it, a hunter's property hit counts again crew.stand_down() TEST_ASSERT(!crew.is_fighting(), "The crew didn't stand down") - outpost.aggressor_strike_times -= hunter.mind outpost.register_aggression(hunter) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[hunter.mind], 1, "A hunter's property strike didn't count outside the shootout") -#endif + TEST_ASSERT_NOTNULL(police.get_warrant(hunter.mind), "A hunter's property hit didn't count outside the shootout") // ===== THE LOW ONES ===== diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm index 6c411e5aeee..ae0533b9a2a 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm @@ -119,6 +119,10 @@ outpost_test_minds += hunter.mind return hunter +/// An officer in the test room's far corner, to witness a crime there (a crime counts only if somebody saw it) +/datum/unit_test/voidcrew_bounty_outpost/proc/make_witness() + return police_port_witness(outpost, locate(run_loc_floor_bottom_left.x + 4, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z)) + /// The posting's decoys, resolved /datum/unit_test/voidcrew_bounty_outpost/proc/decoys_of(datum/criminal_bounty/posting) . = list() @@ -452,9 +456,7 @@ warrant.show_warrant(decoys[2], hunter) TEST_ASSERT_EQUAL(posting.alert, 1, "An out-of-date warrant raised the alert") -// POLICE-PORT: reads aggressor_strikes or the removed outpost turret; port to warrant.heat and force_level()/can_attack() (P10) -#ifdef POLICE_TESTS_PORTED -/// Restraints reached for on someone blending in cost exactly what a punch costs: the decoy's wrists are a strike and the alert and it backs away, the fugitive's end its act. Down or held, the cuffs go on. A decoy confronted any other way raises the alert too (C1) +/// Restraints reached for on someone blending in cost exactly what a punch costs: the decoy's wrists are a crime and the alert and it backs away, the fugitive's end its act. Down or held, the cuffs go on. A decoy confronted any other way raises the alert too (C1) /datum/unit_test/voidcrew_bounty_outpost/cuffs /datum/unit_test/voidcrew_bounty_outpost/cuffs/Run() @@ -464,12 +466,13 @@ TEST_ASSERT(posting.setup_outpost_blend(TRUE), "The posting could not be set up to blend in") var/list/decoys = decoys_of(posting) TEST_ASSERT(length(decoys) >= 2, "Too few decoys to test with") + make_witness() - // A decoy's wrists, the way tg's cuffs reach for them: a strike and the alert, and the cuffs don't go on + // A decoy's wrists, the way tg's cuffs reach for them: a crime and the alert, and the cuffs don't go on var/mob/living/carbon/human/cuffer = make_hunter() var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoys[1] SEND_SIGNAL(decoy, COMSIG_CARBON_CUFF_ATTEMPTED, cuffer) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[cuffer.mind], 1, "Cuffs on a decoy were not an outpost strike, as a punch is") + TEST_ASSERT_NOTNULL(outpost.police.get_warrant(cuffer.mind), "Cuffs on a decoy were not a crime, as a punch is") TEST_ASSERT_EQUAL(posting.alert, 1, "Cuffs on a decoy did not raise the alert, as a punch does") TEST_ASSERT(!decoy.is_restrained(), "A decoy on its feet was cuffed") TEST_ASSERT(decoy.blended, "A decoy stopped blending in") @@ -480,8 +483,9 @@ SEND_SIGNAL(fugitive, COMSIG_CARBON_CUFF_ATTEMPTED, other_cuffer) TEST_ASSERT(!fugitive.blended, "Cuffs on the fugitive did not end its act") TEST_ASSERT(fugitive.body_has_grudge(other_cuffer), "The fugitive holds no grudge against whoever reached for its wrists") - TEST_ASSERT_NULL(outpost.aggressor_strikes[other_cuffer.mind], "Reaching for the fugitive's wrists was an outpost strike") + TEST_ASSERT_NULL(outpost.police.get_warrant(other_cuffer.mind), "Reaching for the fugitive's wrists was a crime") TEST_ASSERT_EQUAL(posting.alert, 1, "Reaching for the fugitive's wrists raised the alert") + outpost.police.sleep_tick() // the first cuffer is wanted now: the police tick must not order units while this waits sleep(10) TEST_ASSERT_EQUAL(fugitive.confronted_by, other_cuffer, "The fugitive did not answer the reach for its wrists") @@ -494,7 +498,7 @@ TEST_ASSERT_NULL(posting.relisted_for, "An exposed fugitive slipped away") TEST_ASSERT(!is_leaving(fugitive), "An exposed fugitive set off for the lift") - // A decoy that can't answer (knocked out, say) is cuffed as anyone is, and it still costs the same + // A decoy that can't answer (knocked out, say) is cuffed as anyone is, and it still costs the same (a crime) var/datum/criminal_bounty/outpost_test/helpless_posting = make_posting(run_loc_floor_bottom_left) TEST_ASSERT(helpless_posting.setup_outpost_blend(TRUE), "The second posting could not be set up to blend in") var/mob/living/carbon/human/npc/bounty_criminal/decoy/helpless = decoys_of(helpless_posting)[1] @@ -502,7 +506,7 @@ var/mob/living/carbon/human/fourth_cuffer = make_hunter() var/helpless_answer = SEND_SIGNAL(helpless, "bounty_cuff_reached", fourth_cuffer) TEST_ASSERT(!(helpless_answer & 1), "A decoy that could do nothing about it took the reaction over") - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[fourth_cuffer.mind], 1, "Cuffs on a helpless decoy were free") + TEST_ASSERT_NOTNULL(outpost.police.get_warrant(fourth_cuffer.mind), "Cuffs on a helpless decoy were free") TEST_ASSERT_EQUAL(helpless_posting.alert, 1, "Cuffs on a helpless decoy did not raise the alert") helpless_posting.status = "closed" // BOUNTY_POSTING_CLOSED: the outpost has room again @@ -523,7 +527,6 @@ var/mob/living/carbon/human/npc/bounty_criminal/decoy/next_decoy = next_decoys[1] next_decoy.on_confronted(make_hunter()) TEST_ASSERT_EQUAL(next.alert, 1, "Confronting a decoy another way cost nothing") -#endif /// Two alerts: the decoys leave, and the fugitive walks out for real, still the posting's until it has gone; the bounty relists when it does. A fugitive caught on the way stays the posting's. An exposed fugitive stays /datum/unit_test/voidcrew_bounty_outpost/alert @@ -610,9 +613,7 @@ planet.placement_kind = "planet" // BOUNTY_PLACEMENT_PLANET TEST_ASSERT(!planet.raise_alert("a test"), "A planet bounty's alert rose") -// POLICE-PORT: reads aggressor_strikes or the removed outpost turret; port to warrant.heat and force_level()/can_attack() (P10) -#ifdef POLICE_TESTS_PORTED -/// A deliberate hit on a decoy is an outpost strike and raises the alert; hitting the fugitive only exposes it. A shot hits whoever it hits +/// A deliberate hit on a decoy is a crime at the outpost and raises the alert; hitting the fugitive only exposes it. A shot hits whoever it hits /datum/unit_test/voidcrew_bounty_outpost/decoy_hit /datum/unit_test/voidcrew_bounty_outpost/decoy_hit/Run() @@ -623,6 +624,7 @@ var/list/decoys = decoys_of(posting) TEST_ASSERT(length(decoys) >= 2, "Too few decoys to test with") var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoys[1] + make_witness() // Someone with no mind behind them never counts var/mob/living/carbon/human/npc = allocate(/mob/living/carbon/human/consistent) @@ -634,15 +636,15 @@ var/mob/living/carbon/human/hunter = make_hunter() decoy.attack_hand(hunter, list()) TEST_ASSERT_EQUAL(posting.alert, 0, "Patting a decoy raised the alert") - TEST_ASSERT_NULL(outpost.aggressor_strikes[hunter.mind], "Patting a decoy was an outpost strike") + TEST_ASSERT_NULL(outpost.police.get_warrant(hunter.mind), "Patting a decoy was a crime") - // A punch is a strike and an alert + // A punch is a crime and an alert hunter.combat_mode = TRUE decoy.attack_hand(hunter, list()) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[hunter.mind], 1, "Punching a decoy was not an outpost strike") + TEST_ASSERT_NOTNULL(outpost.police.get_warrant(hunter.mind), "Punching a decoy was not a crime") TEST_ASSERT_EQUAL(posting.alert, 1, "Punching a decoy did not raise the alert") - // A shot at someone else hits whoever it hits: it goes through nobody, and it is nobody's strike + // A shot at someone else hits whoever it hits: it goes through nobody, and it is nobody's crime var/mob/living/carbon/human/shooter = make_hunter() var/obj/projectile/bullet/stray = allocate(/obj/projectile/bullet) stray.firer = shooter @@ -651,18 +653,18 @@ TEST_ASSERT(!(SEND_SIGNAL(other_decoy, COMSIG_PROJECTILE_PREHIT, stray) & PROJECTILE_INTERRUPT_HIT_PHASE), "A shot at someone else passed through a decoy") TEST_ASSERT(!(SEND_SIGNAL(other_decoy, COMSIG_PROJECTILE_PREHIT, stray) & PROJECTILE_INTERRUPT_HIT), "A shot at someone else was stopped by a decoy") TEST_ASSERT_EQUAL(posting.alert, 1, "A stray shot that landed on a decoy raised the alert") - TEST_ASSERT_NULL(outpost.aggressor_strikes[shooter.mind], "A stray shot that landed on a decoy was an outpost strike") + TEST_ASSERT_NULL(outpost.police.get_warrant(shooter.mind), "A stray shot that landed on a decoy was a crime") // One aimed at it counts, and the second alert sends the fugitive off var/obj/projectile/bullet/aimed = allocate(/obj/projectile/bullet) aimed.firer = shooter aimed.original = other_decoy TEST_ASSERT(!(SEND_SIGNAL(other_decoy, COMSIG_PROJECTILE_PREHIT, aimed) & PROJECTILE_INTERRUPT_HIT_PHASE), "A shot aimed at a decoy passed through it") - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[shooter.mind], 1, "Shooting a decoy was not an outpost strike") + TEST_ASSERT_NOTNULL(outpost.police.get_warrant(shooter.mind), "Shooting a decoy was not a crime") TEST_ASSERT(is_leaving(fugitive), "Two hits on decoys did not send the fugitive off") SShuman_npcs.sweep_leavers() TEST_ASSERT_NOTNULL(posting.relisted_for, "Two hits on decoys did not relist the bounty once the fugitive had gone") - // Hitting the (next) fugitive exposes it: no strike, no alert + // Hitting the (next) fugitive exposes it: no crime, no alert var/datum/criminal_bounty/outpost_test/next = make_posting(run_loc_floor_bottom_left) var/mob/living/carbon/human/npc/bounty_criminal/next_fugitive = next.criminal() TEST_ASSERT(next.setup_outpost_blend(TRUE), "The second posting could not be set up to blend in") @@ -672,7 +674,7 @@ TEST_ASSERT(!next_fugitive.blended, "Punching the fugitive did not expose it") TEST_ASSERT(next_fugitive.body_has_grudge(brawler), "The fugitive holds no grudge against whoever hit it") TEST_ASSERT_EQUAL(next.alert, 0, "Hitting the fugitive raised the alert") - TEST_ASSERT_NULL(outpost.aggressor_strikes[brawler.mind], "Hitting the fugitive was an outpost strike") + TEST_ASSERT_NULL(outpost.police.get_warrant(brawler.mind), "Hitting the fugitive was a crime") /// Things thrown straight at a decoy and a mech's fist count as hits; a throw at someone else that lands on one doesn't (H1) /datum/unit_test/voidcrew_bounty_outpost/thrown @@ -688,6 +690,7 @@ var/mob/living/carbon/human/npc/bounty_criminal/decoy/other_decoy = decoys[2] var/obj/item/storage/toolbox/toolbox = allocate(/obj/item/storage/toolbox) TEST_ASSERT(toolbox.throwforce > 0, "The toolbox is too light to test with") + make_witness() // Thrown at someone else, landing on a decoy: nobody is framed with it var/mob/living/carbon/human/thrower = make_hunter() @@ -696,29 +699,29 @@ SEND_SIGNAL(decoy, COMSIG_ATOM_PREHITBY, toolbox, stray) qdel(stray) TEST_ASSERT_EQUAL(posting.alert, 0, "A throw at someone else that hit a decoy raised the alert") - TEST_ASSERT_NULL(outpost.aggressor_strikes[thrower.mind], "A throw at someone else that hit a decoy was a strike") + TEST_ASSERT_NULL(outpost.police.get_warrant(thrower.mind), "A throw at someone else that hit a decoy was a crime") - // Thrown at the decoy: a strike and the alert + // Thrown at the decoy: a crime and the alert var/datum/thrownthing/aimed = new(toolbox, decoy, NORTH, 7, 1, thrower) SEND_SIGNAL(decoy, COMSIG_ATOM_PREHITBY, toolbox, aimed) qdel(aimed) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[thrower.mind], 1, "A toolbox thrown at a decoy was not a strike") + TEST_ASSERT_NOTNULL(outpost.police.get_warrant(thrower.mind), "A toolbox thrown at a decoy was not a crime") TEST_ASSERT_EQUAL(posting.alert, 1, "A toolbox thrown at a decoy did not raise the alert") // A mech's fist is its pilot's var/mob/living/carbon/human/pilot = make_hunter() SEND_SIGNAL(other_decoy, COMSIG_ATOM_ATTACK_MECH, null, pilot, 0) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[pilot.mind], 1, "A mech punching a decoy was not a strike for its pilot") + TEST_ASSERT_NOTNULL(outpost.police.get_warrant(pilot.mind), "A mech punching a decoy was not a crime for its pilot") TEST_ASSERT(is_leaving(fugitive), "Two hits on decoys did not send the fugitive off") - // A decoy that is already walking out: a hit is still the hitter's strike, but no further alert + // A decoy that is already walking out: a hit is still the hitter's crime, but no further alert var/mob/living/carbon/human/late = make_hunter() var/datum/thrownthing/late_throw = new(toolbox, other_decoy, NORTH, 7, 1, late) SEND_SIGNAL(other_decoy, COMSIG_ATOM_PREHITBY, toolbox, late_throw) qdel(late_throw) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[late.mind], 1, "A hit on a decoy walking out was free") + TEST_ASSERT_NOTNULL(outpost.police.get_warrant(late.mind), "A hit on a decoy walking out was free") -/// While blending in the turrets leave them alone and the lift never carries them; a hand on them is a hand on a person; the fugitive's shots and blows at the outpost are judged by who they hit, and hurting a bystander ends the turrets' leave +/// While blending in the officers leave them alone and the lift never carries them; a hand on them is a hand on a person; the fugitive's shots and blows at the outpost are judged by who they hit, and hurting a bystander ends the officers' leave /datum/unit_test/voidcrew_bounty_outpost/security /datum/unit_test/voidcrew_bounty_outpost/security/Run() @@ -740,32 +743,31 @@ decoy.Move(lift, EAST) TEST_ASSERT_EQUAL(decoy.loc, beside_lift, "A decoy stepped onto the hangar lift") - // The turrets: this one does shoot a hostile creature, and skips the patrons by faction - var/obj/machinery/porta_turret/outpost/turret = allocate(/obj/machinery/porta_turret/outpost) - turret.outpost = outpost - TEST_ASSERT(turret.valid_target(allocate(/mob/living/basic/carp)), "The test turret won't shoot anything, so it proves nothing") - TEST_ASSERT(turret.in_faction(decoy) && turret.in_faction(fugitive), "The patrons are not in the turrets' faction") - TEST_ASSERT(!turret.valid_target(decoy), "The outpost turrets target a decoy") - TEST_ASSERT(!turret.valid_target(fugitive), "The outpost turrets target a fugitive blending in") + // The officers: this one does shoot a hostile creature, and skips the patrons by faction + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = make_witness() + TEST_ASSERT(police_port_may_attack(officer, allocate(/mob/living/basic/carp)), "The test officer won't shoot anything, so it proves nothing") + TEST_ASSERT((FACTION_TURRET in decoy.faction) && (FACTION_TURRET in fugitive.faction), "The patrons are not of the outpost staff's faction") + TEST_ASSERT(!police_port_may_attack(officer, decoy), "The officers target a decoy") + TEST_ASSERT(!police_port_may_attack(officer, fugitive), "The officers target a fugitive blending in") // The fugitive's shots go where they go: one that lands on a person on its grudge list is the chase, one that lands on anyone else is not var/mob/living/carbon/human/bystander = make_hunter() posting.outpost_confront(fugitive, hunter) - TEST_ASSERT(!turret.in_faction(fugitive), "An exposed fugitive is still in the turrets' faction") + TEST_ASSERT(!(FACTION_TURRET in fugitive.faction), "An exposed fugitive is still of the outpost staff's faction") var/obj/projectile/bullet/shot = allocate(/obj/projectile/bullet) SEND_SIGNAL(fugitive, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, shot, fugitive, hunter) TEST_ASSERT(!(SEND_SIGNAL(shot, COMSIG_PROJECTILE_SELF_PREHIT, bystander) & PROJECTILE_INTERRUPT_HIT_PHASE), "The fugitive's shot passed through a bystander") SEND_SIGNAL(shot, COMSIG_PROJECTILE_SELF_ON_HIT, fugitive, hunter, 0, null, 0, FALSE) - TEST_ASSERT(!fugitive.outpost_turret_target(), "Fighting its grudge list made the fugitive a turret target") - // Hurting someone who never crossed it ends the turrets' leave + TEST_ASSERT(!fugitive.outpost_turret_target(), "Fighting its grudge list made the fugitive a target for the officers") + // Hurting someone who never crossed it ends the officers' leave SEND_SIGNAL(shot, COMSIG_PROJECTILE_SELF_ON_HIT, fugitive, bystander, 0, null, 0, FALSE) - TEST_ASSERT(fugitive.outpost_turret_target(), "The fugitive hurt a bystander with a shot and the turrets still leave it be") - TEST_ASSERT(!decoy.outpost_turret_target(), "A decoy is a turret target") + TEST_ASSERT(fugitive.outpost_turret_target(), "The fugitive hurt a bystander with a shot and the officers still leave it be") + TEST_ASSERT(!decoy.outpost_turret_target(), "A decoy is a target for the officers") // But not once the hunters have it: stunned, cuffed, in crit or dead (tg's states) fugitive.Paralyze(10 SECONDS) - TEST_ASSERT(!fugitive.outpost_turret_target(), "The turrets fire on a fugitive the hunters have already put down") + TEST_ASSERT(!fugitive.outpost_turret_target(), "The officers go for a fugitive the hunters have already put down") fugitive.SetParalyzed(0) - TEST_ASSERT(fugitive.outpost_turret_target(), "A fugitive on its feet again is not a turret target") + TEST_ASSERT(fugitive.outpost_turret_target(), "A fugitive on its feet again is not a target for the officers") // Its blows count the same: a punch at a bystander var/datum/criminal_bounty/outpost_test/next = make_posting(run_loc_floor_bottom_left) @@ -773,10 +775,9 @@ TEST_ASSERT(next.setup_outpost_blend(TRUE), "The second posting could not be set up to blend in") next.outpost_confront(next_fugitive, hunter) SEND_SIGNAL(next_fugitive, COMSIG_LIVING_UNARMED_ATTACK, hunter, TRUE, list()) - TEST_ASSERT(!next_fugitive.outpost_turret_target(), "Punching someone on its grudge list made the fugitive a turret target") + TEST_ASSERT(!next_fugitive.outpost_turret_target(), "Punching someone on its grudge list made the fugitive a target for the officers") SEND_SIGNAL(next_fugitive, COMSIG_LIVING_UNARMED_ATTACK, bystander, TRUE, list()) - TEST_ASSERT(next_fugitive.outpost_turret_target(), "The fugitive punched a bystander and the turrets still leave it be") -#endif + TEST_ASSERT(next_fugitive.outpost_turret_target(), "The fugitive punched a bystander and the officers still leave it be") /// A hand on someone blending in is a hand on a person: they can be grabbed like anyone, and it is no hit. Standing or down, the fugitive and a decoy alike /datum/unit_test/voidcrew_bounty_outpost/grab diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm index 049fed0ee61..6c226991fcb 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm @@ -168,10 +168,9 @@ TEST_ASSERT(bear.CanAttack(flicker), "A bear was fooled by the Flickerskin") TEST_ASSERT(boss.CanAttack(flicker), "A megafauna was fooled by the Flickerskin") -// POLICE-PORT: lists the removed outpost turret; port to the police targeting strategy (P10) -#ifdef POLICE_TESTS_PORTED /// Every turret type that can be handed a camo'd human drops them from the list it shoots at. /// tryToShootAt() returns 1 once it has settled on a target, and nothing when it has none. +/// (The trader outposts' own turret is gone: their officers have the next test.) /datum/unit_test/voidcrew_cyberware_camo/turrets /datum/unit_test/voidcrew_cyberware_camo/turrets/Run() @@ -184,7 +183,6 @@ /obj/machinery/porta_turret/ai, /obj/machinery/porta_turret/ruin, /obj/machinery/porta_turret/syndicate, - /obj/machinery/porta_turret/outpost, /obj/machinery/porta_turret/ship_defense, ) for(var/turret_type in turret_types) @@ -200,7 +198,33 @@ ghost.forceMove(spot(0, 2)) TEST_ASSERT(turret.tryToShootAt(list(ghost)), "[turret_type] missed a camo'd human 2 tiles off") ghost.forceMove(spot(4, 4)) -#endif + +/** + * A trader outpost's officers: their targeting strategy (the human NPC one, with the police rules on top) + * is fooled by camo beyond range like any other human NPC's, so a wanted human under the field is not + * picked from across the room, and is from 2 tiles. The street weave fools nobody. Every suspect is + * wanted at three stars (shoot on sight), so the police rules alone would not rule them out. + */ +/datum/unit_test/voidcrew_cyberware_camo/officers + +/datum/unit_test/voidcrew_cyberware_camo/officers/Run() + var/obj/structure/overmap/trader_outpost/outpost = ambient_test_outpost() + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = police_officer(outpost, /mob/living/carbon/human/npc/ambient_npc/outpost/police/officer, spot(0, 0)) + officer.ai_controller?.set_ai_status(AI_STATUS_OFF) + var/mob/living/carbon/human/consistent/plain = allocate(/mob/living/carbon/human/consistent, spot(4, 4)) + var/mob/living/carbon/human/consistent/ghost = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin, spot(4, 4)) + var/mob/living/carbon/human/consistent/flicker = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin/street, spot(4, 3)) + for(var/mob/living/carbon/human/consistent/suspect in list(plain, ghost, flicker)) + suspect.mind_initialize() + police_pin_warrant(outpost.police, suspect.mind, 50) // the third star + TEST_ASSERT_EQUAL(outpost.police.force_level(suspect), 3, "Fixture: a three star suspect isn't shot on sight") // POLICE_FORCE_LETHAL + TEST_ASSERT(police_port_may_attack(officer, plain), "Fixture: an officer won't target a wanted human to begin with") + TEST_ASSERT(cyberware_camo_hides(ghost, officer), "Camo didn't hide its bearer from an officer") + TEST_ASSERT(!police_port_may_attack(officer, ghost), "An officer targeted a camo'd human 4 tiles off") + ghost.forceMove(spot(2, 0)) + TEST_ASSERT(police_port_may_attack(officer, ghost), "An officer missed a camo'd human 2 tiles off") + ghost.forceMove(spot(4, 4)) + TEST_ASSERT(police_port_may_attack(officer, flicker), "An officer was fooled by the Flickerskin") /// Human NPCs: tg's basic targeting through the human NPC strategy, which pirate crews and boarders, /// ruin troopers and the club's people all run on. A regular NPC is fooled beyond range; a boss diff --git a/voidcrew/modules/unit_tests/voidcrew_gorilla_gore.dm b/voidcrew/modules/unit_tests/voidcrew_gorilla_gore.dm index 6ad3771b39c..7ae1028d9d6 100644 --- a/voidcrew/modules/unit_tests/voidcrew_gorilla_gore.dm +++ b/voidcrew/modules/unit_tests/voidcrew_gorilla_gore.dm @@ -200,7 +200,7 @@ /datum/outpost_pvp_enforcement/gorilla_spy/New() return -/datum/outpost_pvp_enforcement/gorilla_spy/register_pvp_aggression(mob/living/victim, mob/living/offender) +/datum/outpost_pvp_enforcement/gorilla_spy/register_pvp_aggression(mob/living/victim, mob/living/offender, crime = "armed", obj/item/source) // crime defaults to OUTPOST_CRIME_ASSAULT_ARMED, as the parent's does hits += victim /// Three piston hits on one arm snap it (four from Brawler myomers); heavy armour or the Atlas frame stops it. diff --git a/voidcrew/modules/unit_tests/voidcrew_human_npcs_combat_sense.dm b/voidcrew/modules/unit_tests/voidcrew_human_npcs_combat_sense.dm index 987bd53af49..6277b73e6d5 100644 --- a/voidcrew/modules/unit_tests/voidcrew_human_npcs_combat_sense.dm +++ b/voidcrew/modules/unit_tests/voidcrew_human_npcs_combat_sense.dm @@ -328,6 +328,10 @@ var/mob/living/carbon/human/npc/npc_path = npc_type var/rules = initial(npc_path.npc_combat_sense) for(var/civilian_root in never) + // The station's officers are ambient NPCs with a fighting brain: whether they have combat sense is + // outpost_police/police_units.dm's call, and one with rules must still plan it (checked below) + if(ispath(npc_type, /mob/living/carbon/human/npc/ambient_npc/outpost/police)) + break if(ispath(npc_type, civilian_root)) TEST_ASSERT_NULL(rules, "[npc_type] is a civilian or a prisoner and has combat sense") if(isnull(rules)) diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_self_defense.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_self_defense.dm index d6dc5ccf846..2fa02cfe499 100644 --- a/voidcrew/modules/unit_tests/voidcrew_outpost_self_defense.dm +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_self_defense.dm @@ -1,5 +1,29 @@ -// POLICE-PORT: reads aggressor_strikes; port to warrant.heat (P10) -#ifdef POLICE_TESTS_PORTED +/** + * Helpers the ported outpost tests share (the self-defence, bounty, kingpin, checkpoint, ambient and camo + * files). Owner: P10. Fork defines are included after the tests, so these use literals with the define named. + */ + +/** + * Lets the police's dedupe on one offender's reports lapse (OUTPOST_AGGRESSION_GRACE, half a second), so the + * next report from them counts. The police tick is stopped first: a test that waits must not have units + * ordered by a warrant it only made to count. + */ +/datum/unit_test/proc/police_port_wait_out_dedupe(datum/outpost_police/police) + police.sleep_tick() + sleep(6) // a little over OUTPOST_AGGRESSION_GRACE (5 deciseconds) + +/// An officer to witness a crime at `outpost`, at `where` (a crime counts only if somebody saw it: spec 3) +/datum/unit_test/proc/police_port_witness(obj/structure/overmap/trader_outpost/outpost, turf/where) + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = police_officer(outpost, /mob/living/carbon/human/npc/ambient_npc/outpost/police/beat, where) + TEST_ASSERT_NOTNULL(officer, "No officer could be put at [outpost.type] to see the crime") + return officer + +/// Whether `officer`'s targeting strategy would attack `target` as things stand: the question the outpost turret's valid_target() used to answer +/datum/unit_test/proc/police_port_may_attack(mob/living/carbon/human/npc/ambient_npc/outpost/police/officer, atom/target) + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(officer.ai_controller?.blackboard[BB_TARGETING_STRATEGY]) + TEST_ASSERT_NOTNULL(strategy, "[officer.type] has no targeting strategy") + return strategy.can_attack(officer, target) + /// Personal retaliation must not punish the victim or excuse the original attacker. /datum/unit_test/voidcrew_outpost_self_defense/Run() var/obj/structure/overmap/trader_outpost/outpost = allocate(/obj/structure/overmap/trader_outpost) @@ -7,17 +31,24 @@ outpost.outpost_template.width = 1 outpost.outpost_template.height = 1 outpost.template_bottom_left = run_loc_floor_bottom_left + var/datum/outpost_police/police = outpost.police + TEST_ASSERT_NOTNULL(police, "The outpost has no police") var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) var/mob/living/carbon/human/defender = allocate(/mob/living/carbon/human/consistent) var/mob/living/carbon/human/bystander = allocate(/mob/living/carbon/human/consistent) attacker.mind_initialize() defender.mind_initialize() bystander.mind_initialize() + // A crime counts only if somebody sees it + police_port_witness(outpost, run_loc_floor_bottom_left) var/datum/outpost_pvp_enforcement/enforcement = GLOB.outpost_pvp_enforcement - // Exercise the normal location-based route, including the first warning hit. + // Exercise the normal location-based route, including the first hit. enforcement.register_pvp_aggression(defender, attacker) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[attacker.mind], 1, "The initiating attack must still earn a strike") + var/datum/outpost_warrant/attacker_warrant = police.get_warrant(attacker.mind) + TEST_ASSERT_NOTNULL(attacker_warrant, "The initiating attack must still be a crime") + var/first_heat = attacker_warrant.heat + TEST_ASSERT(first_heat > 0, "The initiating attack must still add heat") var/list/defender_targets = outpost.self_defense_targets[defender.mind] // Fork defines are included after unit tests: OUTPOST_SELF_DEFENSE_DURATION = 2 minutes. TEST_ASSERT_EQUAL(defender_targets[attacker.mind], world.time + 2 MINUTES, "The first attack must immediately allow personal retaliation") @@ -25,42 +56,44 @@ var/original_expiry = defender_targets[attacker.mind] for(var/hit in 1 to 4) enforcement.register_pvp_aggression(attacker, defender) - TEST_ASSERT_NULL(outpost.aggressor_strikes[defender.mind], "Returning fire must not earn the victim strikes") + TEST_ASSERT_NULL(police.get_warrant(defender.mind), "Returning fire must not put the victim on record") + TEST_ASSERT_EQUAL(attacker_warrant.heat, first_heat, "Returning fire must not add to the aggressor's heat") TEST_ASSERT_NULL(outpost.self_defense_targets[attacker.mind], "Lawful retaliation must not give the initiator a self-defense exemption") TEST_ASSERT_EQUAL(defender_targets[attacker.mind], original_expiry, "Retaliation must not prolong its own exemption") enforcement.register_pvp_aggression(defender, attacker) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[attacker.mind], 1, "Attacks during the strike grace period must not add another strike") - TEST_ASSERT_EQUAL(defender_targets[attacker.mind], world.time + 2 MINUTES, "Attacks during the strike grace period must still refresh self-defense") + TEST_ASSERT_EQUAL(attacker_warrant.heat, first_heat, "Attacks inside the report grace period must not add more heat") + TEST_ASSERT_EQUAL(defender_targets[attacker.mind], world.time + 2 MINUTES, "Attacks inside the report grace period must still refresh self-defense") - // Further initiating attacks still escalate through the normal warning ladder. - outpost.aggressor_strike_times.Cut() - enforcement.register_pvp_aggression(defender, attacker) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[attacker.mind], 2, "The initiator's second attack must still earn a strike after retaliation") - outpost.aggressor_strike_times.Cut() + // Further initiating attacks still escalate: two armed hits are one star (spec 2), and the defender is no suspect + police_port_wait_out_dedupe(police) enforcement.register_pvp_aggression(defender, attacker) - TEST_ASSERT(outpost.is_user_barred(attacker), "Repeated initiating attacks must still trigger enforcement") - TEST_ASSERT(!outpost.is_user_barred(defender), "Defending oneself must not trigger enforcement") + TEST_ASSERT(attacker_warrant.heat > first_heat, "The initiator's second attack must still add heat after retaliation") + TEST_ASSERT_EQUAL(police.get_stars(attacker.mind), 1, "Two initiating attacks are not one star") + TEST_ASSERT(outpost.is_user_barred(attacker), "Repeated initiating attacks must still bring the police down on the initiator") + TEST_ASSERT(!outpost.is_user_barred(defender), "Defending oneself must not bring the police down") defender_targets[attacker.mind] = world.time + 1 SECONDS enforcement.register_pvp_aggression(defender, attacker) - TEST_ASSERT_EQUAL(defender_targets[attacker.mind], world.time + 2 MINUTES, "An already marked aggressor must still refresh their victim's exemption") + TEST_ASSERT_EQUAL(defender_targets[attacker.mind], world.time + 2 MINUTES, "An already wanted aggressor must still refresh their victim's exemption") // A third person cannot join the fight under someone else's exemption. enforcement.register_pvp_aggression(attacker, bystander) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[bystander.mind], 1, "A bystander must not inherit the victim's exemption") + TEST_ASSERT_NOTNULL(police.get_warrant(bystander.mind), "A bystander must not inherit the victim's exemption") enforcement.register_pvp_aggression(bystander, defender) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[defender.mind], 1, "A victim's exemption must not cover attacks on unrelated people") + var/datum/outpost_warrant/defender_warrant = police.get_warrant(defender.mind) + TEST_ASSERT_NOTNULL(defender_warrant, "A victim's exemption must not cover attacks on unrelated people") - // Property damage goes directly to the existing enforcement path. - outpost.aggressor_strike_times.Cut() + // Property damage goes directly to the existing police path. + police_port_wait_out_dedupe(police) + var/heat_before_property = defender_warrant.heat outpost.register_aggression(defender) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[defender.mind], 2, "Self-defense must not excuse damaging outpost property") + TEST_ASSERT(defender_warrant.heat > heat_before_property, "Self-defense must not excuse damaging outpost property") // Expiry is exclusive: attacking at the deadline is a fresh infraction. + police_port_wait_out_dedupe(police) + var/heat_before_expiry = defender_warrant.heat defender_targets[attacker.mind] = world.time - outpost.aggressor_strike_times.Cut() enforcement.register_pvp_aggression(attacker, defender) - TEST_ASSERT_EQUAL(outpost.aggressor_strikes[defender.mind], 3, "Retaliation at the expiry time must no longer be exempt") + TEST_ASSERT(defender_warrant.heat > heat_before_expiry, "Retaliation at the expiry time must no longer be exempt") TEST_ASSERT(!LAZYACCESS(defender_targets, attacker.mind), "Expired retaliation permission must be pruned") var/list/attacker_targets = outpost.self_defense_targets[attacker.mind] TEST_ASSERT(attacker_targets[defender.mind] > world.time, "A new attack after expiry must let its victim defend themselves") -#endif diff --git a/voidcrew/modules/unit_tests/voidcrew_trader_checkpoint.dm b/voidcrew/modules/unit_tests/voidcrew_trader_checkpoint.dm index 7043a1ccfe5..84d32cf4eb7 100644 --- a/voidcrew/modules/unit_tests/voidcrew_trader_checkpoint.dm +++ b/voidcrew/modules/unit_tests/voidcrew_trader_checkpoint.dm @@ -115,8 +115,6 @@ // ===== THE CHAMBER ===== -// POLICE-PORT: uses is_armed_trespasser/trespass_bars or the removed outpost turret; port to warrant.heat and get_stars() (P10) -#ifdef POLICE_TESTS_PORTED /// Work tools and disablers pass every checkpoint weapon check; a laser remains restricted. /datum/unit_test/voidcrew_trader_checkpoint/gun_classification abstract_type = /datum/unit_test/voidcrew_trader_checkpoint/gun_classification @@ -138,6 +136,9 @@ /datum/unit_test/voidcrew_trader_checkpoint/gun_classification/Run() TEST_ASSERT(fixture(FALSE), "Could not prepare the checkpoint") + var/datum/outpost_police/police = host.police + // Firing a gun is a crime only if seen: an officer stands on the concourse + police_port_witness(host, secured) var/mob/living/carbon/human/person = visitor(chamber[1]) var/obj/item/gun/held_gun = allocate(gun_type) TEST_ASSERT(person.put_in_hands(held_gun), "Could not hold [gun_type] for the scan") @@ -146,19 +147,22 @@ TEST_ASSERT_EQUAL(checkpoint.state, expected_state, "The scan misclassified [gun_type]") person.forceMove(secured) host.armed_sweep(list(person)) - TEST_ASSERT(!host.is_armed_trespasser(person.mind), "[gun_type] was flagged before the sighting grace") + TEST_ASSERT_NULL(police.get_warrant(person.mind), "[gun_type] was reported before the sighting grace") if(!restricted) TEST_ASSERT(!length(host.armed_sightings), "Holding [gun_type] started an armed sighting") host.armed_sightings[person.mind] = world.time - 2 SECONDS // OUTPOST_ARMED_SIGHTING_GRACE host.armed_sweep(list(person)) - TEST_ASSERT_EQUAL(!!host.is_armed_trespasser(person.mind), restricted, "Holding [gun_type] was misclassified after the sighting grace") + TEST_ASSERT_EQUAL(!!police.get_stars(person.mind), restricted, "Holding [gun_type] was misclassified after the sighting grace") TEST_ASSERT(!length(host.armed_sightings), "An armed sighting of [gun_type] was left pending") + if(restricted) + // The gun in hand is what the officers take at the arrest + TEST_ASSERT(WEAKREF(held_gun) in police.get_warrant(person.mind)?.evidence, "The drawn [gun_type] is not evidence against its holder") // A separate visitor proves the fired-gun signal independently of the holding check. var/mob/living/carbon/human/shooter = visitor(secured) var/obj/item/gun/fired_gun = allocate(gun_type) TEST_ASSERT(shooter.put_in_hands(fired_gun), "Could not hold [gun_type] for firing") SEND_SIGNAL(shooter, COMSIG_MOB_FIRED_GUN, fired_gun, lobby, null, null, list(0, 0)) - TEST_ASSERT_EQUAL(!!host.is_armed_trespasser(shooter.mind), restricted, "Firing [gun_type] was misclassified") + TEST_ASSERT_EQUAL(!!police.get_stars(shooter.mind), restricted, "Firing [gun_type] was misclassified") var/obj/item/gun/goods = allocate(gun_type, secured) var/obj/item/handed_over = outpost_seal_goods(goods) if(restricted) @@ -167,7 +171,6 @@ TEST_ASSERT_EQUAL(goods.loc, handed_over, "[gun_type] was left outside its sealed case") else TEST_ASSERT_EQUAL(handed_over, goods, "[gun_type] was needlessly sealed") -#endif /// Clean people pass together, and a clean result is never stored on the doors /datum/unit_test/voidcrew_trader_checkpoint/clean_group/Run() @@ -470,9 +473,7 @@ // ===== FORCING THE INNER DOOR ===== -// POLICE-PORT: uses is_armed_trespasser/trespass_bars or the removed outpost turret; port to warrant.heat and get_stars() (P10) -#ifdef POLICE_TESTS_PORTED -/// Prying starts a long, alarmed attempt; a breached door flags anyone who goes through it from the chamber +/// Prying starts a long, alarmed attempt; a breached door puts anyone who goes through it from the chamber on record /datum/unit_test/voidcrew_trader_checkpoint/forced_entry/Run() TEST_ASSERT(fixture(), "Could not prepare the checkpoint") TEST_ASSERT(inner.outpost_forceable() && !outer.outpost_forceable(), "Only the inner door may be forced") @@ -492,20 +493,27 @@ sleep(2 SECONDS) TEST_ASSERT(!checkpoint.forcing, "Walking away did not stop the attempt") TEST_ASSERT(inner.density, "An abandoned attempt opened the door") - // When it gives, whoever goes through from the chamber is an armed trespasser; someone already inside is not + // When it gives, whoever goes through from the chamber is on record at two stars; someone already inside is not var/mob/living/carbon/human/insider = visitor(secured) burglar.forceMove(chamber[1]) + // force_inner() notes who forced the door and with what when it succeeds: the crowbar is evidence + checkpoint.breacher_ref = WEAKREF(burglar) + checkpoint.breach_tool_ref = WEAKREF(crowbar) checkpoint.breach(burglar) sleep(2 SECONDS) TEST_ASSERT(!inner.density, "A breach did not open the inner door") TEST_ASSERT(checkpoint.request_outer(visitor()), "The outer door stayed interlocked during a breach") burglar.Move(get_turf(inner), NORTH) TEST_ASSERT_EQUAL(get_turf(burglar), get_turf(inner), "Could not walk into the breached doorway") - TEST_ASSERT(host.is_armed_trespasser(burglar.mind), "Going through a forced door was not flagged") + var/datum/outpost_warrant/warrant = host.police.get_warrant(burglar.mind) + TEST_ASSERT_NOTNULL(warrant, "Going through a forced door was not a crime") + TEST_ASSERT_EQUAL(host.police.get_stars(burglar.mind), 2, "Going through a forced door was not two stars") // CHECKPOINT_BREACH: floor and ceiling of two + TEST_ASSERT(WEAKREF(crowbar) in warrant.evidence, "The crowbar that forced the door is not evidence") TEST_ASSERT(host.is_user_barred(burglar), "Going through a forced door did not bar them") + host.police.sleep_tick() // wanted now: the police tick must not order units while this waits burglar.forceMove(secured) insider.Move(get_turf(inner), SOUTH) - TEST_ASSERT(!host.is_armed_trespasser(insider.mind), "Stepping into the doorway from inside was flagged") + TEST_ASSERT_NULL(host.police.get_warrant(insider.mind), "Stepping into the doorway from inside was a crime") // It does not shut on whoever stands in it checkpoint.end_breach() sleep(2 SECONDS) @@ -513,15 +521,14 @@ insider.forceMove(secured) TEST_ASSERT(wait_shut(inner), "The forced door did not shut again") TEST_ASSERT_EQUAL(checkpoint.state, "idle", "The checkpoint did not settle after the breach") // CHECKPOINT_IDLE -#endif // ===== ARMED TRESPASS ===== -// POLICE-PORT: uses is_armed_trespasser/trespass_bars or the removed outpost turret; port to warrant.heat and get_stars() (P10) -#ifdef POLICE_TESTS_PORTED -/// A gun drawn past the checkpoint flags its holder once it stays out; holstered, permitted or in the lobby it does not +/// A gun drawn past the checkpoint puts its holder on record once it stays out, as one star and an arrest, never a shooting; holstered, permitted or in the lobby it does not /datum/unit_test/voidcrew_trader_checkpoint/drawn_gun/Run() TEST_ASSERT(fixture(FALSE), "Could not prepare the checkpoint") + var/datum/outpost_police/police = host.police + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = police_port_witness(host, secured) var/mob/living/carbon/human/person = visitor(secured) var/obj/item/gun/ballistic/automatic/pistol/gun = allocate(/obj/item/gun/ballistic/automatic/pistol) var/obj/item/storage/backpack/bag = allocate(/obj/item/storage/backpack) @@ -531,18 +538,16 @@ TEST_ASSERT(!length(host.armed_sightings), "A holstered gun was noticed") person.put_in_hands(gun) host.armed_sweep(list(person)) - TEST_ASSERT(!host.is_armed_trespasser(person.mind), "A gun was flagged the moment it came out") + TEST_ASSERT_NULL(police.get_warrant(person.mind), "A gun was reported the moment it came out") host.armed_sightings[person.mind] = world.time - 2 SECONDS // OUTPOST_ARMED_SIGHTING_GRACE host.armed_sweep(list(person)) - TEST_ASSERT(host.is_armed_trespasser(person.mind), "A gun kept out past the checkpoint was not flagged") - var/obj/machinery/porta_turret/outpost/turret = allocate(/obj/machinery/porta_turret/outpost, secured) - turret.outpost = host - TEST_ASSERT(turret.valid_target(person), "The turrets ignore an armed trespasser") - // The hostile window ends before the bar does - host.armed_trespassers[person.mind] = world.time - 10 MINUTES - 1 // OUTPOST_TRESPASS_HOSTILE_DURATION - TEST_ASSERT(!turret.valid_target(person), "The turrets kept shooting after the hostile window") - TEST_ASSERT(host.is_user_barred(person), "The bar ended with the hostile window") - host.trespass_bars[person.mind] = world.time - 1 + TEST_ASSERT_EQUAL(police.get_stars(person.mind), 1, "A gun kept out past the checkpoint was not one star") // WEAPON_DRAWN: floor and ceiling of one + TEST_ASSERT(host.is_user_barred(person), "A gun kept out past the checkpoint did not bar its holder") + // One star is the baton and the cuffs: the officers do not shoot for a drawn gun + TEST_ASSERT_EQUAL(police.force_level(person), 1, "A drawn gun is more than an arrest") // POLICE_FORCE_ARREST + TEST_ASSERT(!police_port_may_attack(officer, person), "An officer would shoot someone for a drawn gun") + // Their record closing lifts the bar + police.close_warrant(person.mind, TRUE) TEST_ASSERT(!host.is_user_barred(person), "The bar did not lift") // A permit holder, and anyone in the lobby, may carry openly var/mob/living/carbon/human/holder = visitor(secured) @@ -553,17 +558,21 @@ host.armed_sightings[holder.mind] = world.time - 2 SECONDS host.armed_sightings[arrival.mind] = world.time - 2 SECONDS host.armed_sweep(list(holder, arrival)) - TEST_ASSERT(!host.is_armed_trespasser(holder.mind), "A permit holder was flagged for carrying") - TEST_ASSERT(!host.is_armed_trespasser(arrival.mind), "Someone in the checkpoint lobby was flagged") + TEST_ASSERT_NULL(police.get_warrant(holder.mind), "A permit holder was reported for carrying") + TEST_ASSERT_NULL(police.get_warrant(arrival.mind), "Someone in the checkpoint lobby was reported") -/// Firing a gun past the checkpoint without a permit flags the shooter at once +/// Firing a gun past the checkpoint without a permit is gunfire, and the gun is evidence /datum/unit_test/voidcrew_trader_checkpoint/fired_gun/Run() TEST_ASSERT(fixture(FALSE), "Could not prepare the checkpoint") + // Gunfire is a crime only if seen + police_port_witness(host, secured) var/mob/living/carbon/human/shooter = visitor(secured) var/obj/item/gun/ballistic/automatic/pistol/gun = allocate(/obj/item/gun/ballistic/automatic/pistol) SEND_SIGNAL(shooter, COMSIG_MOB_FIRED_GUN, gun, lobby, null, null, list(0, 0)) - TEST_ASSERT(host.is_armed_trespasser(shooter.mind), "Firing past the checkpoint was not flagged") -#endif + var/datum/outpost_warrant/warrant = host.police.get_warrant(shooter.mind) + TEST_ASSERT_NOTNULL(warrant, "Firing past the checkpoint was not a crime") + TEST_ASSERT(host.police.get_stars(shooter.mind) >= 1, "Firing past the checkpoint was not worth a star") // GUNFIRE: 15 heat, one star is 10 + TEST_ASSERT(WEAKREF(gun) in warrant.evidence, "The gun that was fired is not evidence") /// The traders and the permit desk refuse an armed trespasser, and the desk sells them nothing /datum/unit_test/voidcrew_trader_checkpoint/refused_service/Run() From 619edf1da75e2e3a8ce4c8d0ff8e1f9dddc07422 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:35:42 -0800 Subject: [PATCH 026/152] Halcyon: police station in the checkpoint lobby and three police lifts --- .../outposts/trader_outpost_general.dmm | 432 ++++++++++-------- 1 file changed, 233 insertions(+), 199 deletions(-) diff --git a/voidcrew/_maps/map_files/outposts/trader_outpost_general.dmm b/voidcrew/_maps/map_files/outposts/trader_outpost_general.dmm index 8716135db18..a2b4762265f 100644 --- a/voidcrew/_maps/map_files/outposts/trader_outpost_general.dmm +++ b/voidcrew/_maps/map_files/outposts/trader_outpost_general.dmm @@ -17,13 +17,6 @@ }, /turf/open/indestructible/dark/textured, /area/voidcrew/trader_outpost/checkpoint) -"ad" = ( -/obj/effect/turf_decal/siding/thinplating_new{ - dir = 4 - }, -/obj/effect/turf_decal/tile/neutral/full, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost) "ae" = ( /obj/effect/turf_decal/siding/wideplating_new/dark, /obj/effect/turf_decal/tile/neutral/half/contrasted, @@ -108,13 +101,6 @@ "as" = ( /turf/template_noop, /area/template_noop) -"at" = ( -/obj/effect/landmark/outpost_elevator_alcove, -/obj/effect/turf_decal/stripes/line{ - dir = 1 - }, -/turf/open/indestructible/dark/smooth_large, -/area/voidcrew/trader_outpost) "au" = ( /obj/structure/chair/stool/directional/north, /obj/effect/turf_decal/trimline/neutral/filled/line{ @@ -173,15 +159,6 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) -"aC" = ( -/obj/effect/turf_decal/siding/wideplating_new/corner{ - dir = 1 - }, -/obj/effect/turf_decal/siding/thinplating_new/corner, -/obj/effect/turf_decal/tile/neutral/full, -/obj/machinery/light/floor, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost) "aD" = ( /obj/structure/table/reinforced, /turf/open/indestructible/dark/smooth_large, @@ -213,28 +190,6 @@ /obj/effect/decal/cleanable/cobweb/cobweb2, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) -"aI" = ( -/obj/effect/turf_decal/siding/wideplating_new/corner{ - dir = 4 - }, -/obj/effect/turf_decal/siding/thinplating_new/corner{ - dir = 8 - }, -/obj/effect/turf_decal/tile/neutral/full, -/obj/machinery/light/floor, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost) -"aJ" = ( -/obj/effect/turf_decal/siding/wideplating_new/corner{ - dir = 8 - }, -/obj/effect/turf_decal/siding/thinplating_new/corner{ - dir = 4 - }, -/obj/effect/turf_decal/tile/neutral/full, -/obj/machinery/light/floor, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost) "aK" = ( /obj/effect/turf_decal/siding/wideplating_new/corner{ dir = 1 @@ -244,15 +199,6 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) -"aL" = ( -/obj/effect/turf_decal/siding/wideplating_new/corner, -/obj/effect/turf_decal/siding/thinplating_new/corner{ - dir = 1 - }, -/obj/effect/turf_decal/tile/neutral/full, -/obj/machinery/light/floor, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost) "aN" = ( /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 9 @@ -339,11 +285,6 @@ /obj/effect/turf_decal/tile/red/full, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost/checkpoint) -"bd" = ( -/obj/item/kirbyplants/organic/plant22, -/obj/effect/turf_decal/tile/neutral/full, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost/checkpoint) "be" = ( /obj/effect/turf_decal/tile/neutral/full, /turf/open/indestructible/large, @@ -416,11 +357,6 @@ /obj/effect/turf_decal/tile/neutral/full, /turf/open/indestructible/large, /area/voidcrew/trader_outpost/checkpoint) -"bn" = ( -/obj/structure/sign/warning/secure_area/directional/north, -/obj/effect/turf_decal/tile/neutral/full, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost/checkpoint) "bo" = ( /obj/structure/railing/unbreakable{ dir = 4 @@ -447,11 +383,6 @@ /obj/effect/turf_decal/tile/neutral/anticorner, /turf/open/indestructible/stone, /area/voidcrew/trader_outpost) -"bq" = ( -/obj/structure/sign/departments/security/directional/north, -/obj/effect/turf_decal/tile/neutral/full, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost/checkpoint) "br" = ( /obj/effect/turf_decal/delivery, /obj/effect/turf_decal/tile/neutral/full, @@ -517,13 +448,6 @@ }, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) -"bA" = ( -/obj/structure/chair/sofa/bench/tram/left{ - dir = 4 - }, -/obj/effect/turf_decal/tile/neutral/full, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost/checkpoint) "bB" = ( /obj/effect/turf_decal/weather/dirt{ dir = 1 @@ -537,13 +461,6 @@ /obj/effect/turf_decal/tile/neutral/full, /turf/open/indestructible/large, /area/voidcrew/trader_outpost/checkpoint) -"bD" = ( -/obj/structure/chair/sofa/bench/tram/right{ - dir = 4 - }, -/obj/effect/turf_decal/tile/neutral/full, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost/checkpoint) "bE" = ( /obj/effect/turf_decal/siding/wideplating_new{ dir = 5 @@ -720,19 +637,6 @@ /obj/item/kirbyplants/organic/plant22, /turf/open/indestructible, /area/voidcrew/trader_outpost) -"bW" = ( -/obj/effect/turf_decal/siding/wideplating_new/corner, -/obj/effect/turf_decal/siding/wideplating_new/corner{ - dir = 4 - }, -/obj/effect/turf_decal/tile/neutral/half{ - dir = 4 - }, -/obj/effect/turf_decal/arrows{ - dir = 4 - }, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost) "bY" = ( /obj/effect/turf_decal/siding/thinplating_new/light{ dir = 10 @@ -1298,6 +1202,23 @@ }, /turf/open/indestructible/hotelwood, /area/voidcrew/trader_outpost) +"hS" = ( +/obj/effect/turf_decal/siding/wideplating_new/corner, +/obj/effect/turf_decal/siding/wideplating_new/corner{ + dir = 4 + }, +/obj/effect/turf_decal/tile/neutral/half{ + dir = 4 + }, +/turf/open/indestructible/large, +/area/voidcrew/trader_outpost) +"hV" = ( +/obj/structure/chair/sofa/bench/tram/left, +/obj/effect/landmark/outpost_police_cell{ + cell_id = "halcyon_cell_1" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "hZ" = ( /obj/structure/chair/stool/directional/west, /mob/living/basic/outpost_trader/bait_shop, @@ -1318,13 +1239,6 @@ }, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) -"ib" = ( -/obj/effect/turf_decal/siding/thinplating_new{ - dir = 8 - }, -/obj/effect/turf_decal/tile/neutral/full, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost) "id" = ( /obj/structure/table, /turf/open/indestructible/dark/smooth_large, @@ -1478,6 +1392,13 @@ /obj/item/seeds/cotton, /turf/open/floor/grass, /area/voidcrew/trader_outpost) +"kC" = ( +/obj/structure/closet/secure_closet/outpost_rental/checkpoint, +/obj/effect/turf_decal/bot, +/obj/structure/sign/warning/secure_area/directional/north, +/obj/effect/turf_decal/tile/neutral/full, +/turf/open/indestructible/large, +/area/voidcrew/trader_outpost/checkpoint) "kK" = ( /obj/effect/turf_decal/siding/wood{ dir = 1 @@ -1666,6 +1587,13 @@ /obj/item/seeds/wheat, /turf/open/floor/grass, /area/voidcrew/trader_outpost) +"mB" = ( +/obj/structure/table/reinforced, +/obj/item/paper_bin, +/obj/item/pen, +/obj/effect/turf_decal/tile/neutral/full, +/turf/open/indestructible/large, +/area/voidcrew/trader_outpost/checkpoint) "mG" = ( /obj/structure/flora/ausbushes/reedbush{ pixel_w = 8; @@ -1677,20 +1605,6 @@ /obj/structure/flora/bush/ferny/style_random, /turf/open/floor/grass, /area/voidcrew/trader_outpost) -"mL" = ( -/obj/effect/turf_decal/siding/wideplating_new/dark{ - dir = 9 - }, -/obj/effect/turf_decal/tile/neutral/anticorner{ - dir = 1 - }, -/obj/effect/turf_decal/box/red/corners, -/obj/structure/extinguisher_cabinet/directional/north, -/obj/structure/sign/painting/library{ - pixel_w = -32 - }, -/turf/open/indestructible/stone, -/area/voidcrew/trader_outpost) "nb" = ( /obj/structure/railing/corner/unbreakable{ color = "#5d341f"; @@ -1848,6 +1762,11 @@ /obj/structure/flora/bush/sparsegrass/style_random, /turf/open/misc/dirt/station, /area/voidcrew/trader_outpost) +"pf" = ( +/obj/machinery/outpost_police_evidence, +/obj/effect/turf_decal/tile/neutral/full, +/turf/open/indestructible/large, +/area/voidcrew/trader_outpost/checkpoint) "pw" = ( /obj/effect/turf_decal/siding/thinplating_new/dark/corner{ dir = 1 @@ -1890,6 +1809,13 @@ /obj/machinery/newscaster/directional/west, /turf/open/indestructible/stone, /area/voidcrew/trader_outpost) +"pX" = ( +/obj/machinery/status_display/outpost_berth/police_cell/directional/south{ + cell_id = "halcyon_cell_2" + }, +/obj/effect/turf_decal/tile/neutral/full, +/turf/open/indestructible/large, +/area/voidcrew/trader_outpost/checkpoint) "qg" = ( /obj/structure/railing/unbreakable{ color = "#5d341f"; @@ -1906,6 +1832,19 @@ }, /turf/open/indestructible/hotelwood, /area/voidcrew/trader_outpost) +"qo" = ( +/obj/structure/closet/secure_closet/personal/outpost_police, +/obj/effect/turf_decal/bot, +/obj/effect/turf_decal/tile/neutral/full, +/turf/open/indestructible/large, +/area/voidcrew/trader_outpost/checkpoint) +"qt" = ( +/obj/structure/chair/sofa/bench/tram/left, +/obj/effect/landmark/outpost_police_cell{ + cell_id = "halcyon_cell_2" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "qy" = ( /obj/effect/turf_decal/tile/neutral{ dir = 8 @@ -2013,6 +1952,11 @@ }, /turf/open/indestructible/hotelwood, /area/voidcrew/trader_outpost) +"sb" = ( +/obj/machinery/door/airlock/outpost/police, +/obj/effect/turf_decal/delivery/red, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost) "sk" = ( /obj/effect/turf_decal/siding/wideplating_new{ dir = 4 @@ -2181,6 +2125,13 @@ }, /turf/open/water/outpost_pond, /area/voidcrew/trader_outpost) +"tB" = ( +/obj/machinery/door/airlock/outpost/glass/police_cell{ + cell_id = "halcyon_cell_2" + }, +/obj/effect/turf_decal/delivery/red, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "tC" = ( /obj/structure/flora/rock/pile{ pixel_w = 6; @@ -2519,6 +2470,9 @@ /obj/machinery/light/floor, /turf/open/indestructible/grass, /area/voidcrew/trader_outpost) +"wZ" = ( +/turf/closed/indestructible/reinforced/titanium/outpost, +/area/voidcrew/trader_outpost/checkpoint) "xh" = ( /obj/effect/turf_decal/tile/blue/opposingcorners, /obj/effect/turf_decal/siding/thinplating_new/light{ @@ -2570,6 +2524,11 @@ }, /turf/open/indestructible/kitchen, /area/voidcrew/trader_outpost) +"xE" = ( +/obj/machinery/vending/coffee, +/obj/effect/turf_decal/tile/neutral/full, +/turf/open/indestructible/large, +/area/voidcrew/trader_outpost/checkpoint) "xN" = ( /obj/machinery/hydroponics/soil, /obj/item/seeds/wheat/oat, @@ -2770,6 +2729,10 @@ /obj/machinery/light/directional/east, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) +"zw" = ( +/obj/effect/spawner/structure/window/reinforced/indestructible, +/turf/open/indestructible/plating, +/area/voidcrew/trader_outpost/checkpoint) "zy" = ( /obj/structure/table/reinforced, /obj/machinery/processor/outpost, @@ -2783,13 +2746,6 @@ /obj/machinery/light/floor, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) -"zB" = ( -/obj/effect/turf_decal/siding/thinplating_new{ - dir = 1 - }, -/obj/effect/turf_decal/tile/neutral/full, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost) "zC" = ( /obj/effect/turf_decal/trimline/blue/end{ dir = 1 @@ -2848,15 +2804,8 @@ /obj/effect/turf_decal/tile/neutral/anticorner, /turf/open/indestructible/stone, /area/voidcrew/trader_outpost) -"Aa" = ( -/obj/effect/turf_decal/siding/wideplating_new/corner{ - dir = 4 - }, -/obj/effect/turf_decal/siding/wideplating_new/corner{ - dir = 1 - }, -/obj/effect/turf_decal/tile/neutral/full, -/turf/open/indestructible/large, +"Aj" = ( +/turf/open/indestructible/dark/textured, /area/voidcrew/trader_outpost) "Al" = ( /obj/effect/turf_decal/siding/thinplating_new/dark{ @@ -3140,6 +3089,14 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"Df" = ( +/obj/structure/chair/sofa/bench/tram/right, +/obj/machinery/light/small/directional/west, +/obj/effect/landmark/outpost_police_cell{ + cell_id = "halcyon_cell_2" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "Dl" = ( /obj/structure/aquarium/prefilled, /turf/open/indestructible/dark/smooth_large, @@ -3364,6 +3321,18 @@ }, /turf/open/indestructible/hotelwood, /area/voidcrew/trader_outpost) +"Fm" = ( +/obj/effect/turf_decal/arrows{ + dir = 4 + }, +/obj/effect/turf_decal/siding/wideplating_new{ + dir = 4 + }, +/obj/effect/turf_decal/tile/neutral/half/contrasted{ + dir = 4 + }, +/turf/open/indestructible, +/area/voidcrew/trader_outpost) "Fv" = ( /obj/structure/flora/deadgrass/tall{ pixel_w = 0; @@ -3446,6 +3415,14 @@ }, /turf/open/indestructible/hotelwood, /area/voidcrew/trader_outpost) +"Gm" = ( +/obj/structure/chair/office{ + dir = 1 + }, +/obj/effect/landmark/outpost_police_desk, +/obj/effect/turf_decal/tile/neutral/full, +/turf/open/indestructible/large, +/area/voidcrew/trader_outpost/checkpoint) "Gv" = ( /obj/effect/turf_decal/siding/wideplating_new/light, /obj/effect/turf_decal/tile/blue/opposingcorners, @@ -3711,6 +3688,12 @@ }, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) +"IX" = ( +/obj/effect/landmark/outpost_police_lift, +/obj/effect/turf_decal/box/red, +/obj/machinery/light/small/directional/west, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost) "Jb" = ( /obj/structure/table, /obj/item/shovel/spade{ @@ -3984,6 +3967,20 @@ }, /turf/open/indestructible/stone, /area/voidcrew/trader_outpost) +"Mc" = ( +/obj/structure/closet/secure_closet/outpost_rental/checkpoint, +/obj/effect/turf_decal/bot, +/obj/structure/sign/departments/security/directional/north, +/obj/effect/turf_decal/tile/neutral/full, +/turf/open/indestructible/large, +/area/voidcrew/trader_outpost/checkpoint) +"Me" = ( +/obj/machinery/status_display/outpost_berth/police_cell/directional/north{ + cell_id = "halcyon_cell_1" + }, +/obj/effect/turf_decal/tile/neutral/full, +/turf/open/indestructible/large, +/area/voidcrew/trader_outpost/checkpoint) "Mm" = ( /obj/structure/chair{ dir = 4 @@ -4418,6 +4415,13 @@ /obj/machinery/newscaster/directional/north, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) +"Qs" = ( +/obj/structure/chair/sofa/bench/tram/right, +/obj/effect/landmark/outpost_police_cell{ + cell_id = "halcyon_cell_1" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "QG" = ( /obj/effect/turf_decal/siding/wood{ dir = 1 @@ -4523,6 +4527,12 @@ }, /turf/open/water/outpost_pond, /area/voidcrew/trader_outpost) +"RS" = ( +/obj/effect/landmark/outpost_police_lift, +/obj/effect/turf_decal/box/red, +/obj/machinery/light/small/directional/east, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost) "RZ" = ( /obj/structure/closet/outpost_medical, /obj/effect/turf_decal/trimline/blue/filled/line, @@ -4552,6 +4562,13 @@ }, /turf/open/water/outpost_pond, /area/voidcrew/trader_outpost) +"Si" = ( +/obj/machinery/door/airlock/outpost/glass/police_cell{ + cell_id = "halcyon_cell_1" + }, +/obj/effect/turf_decal/delivery/red, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "Sk" = ( /obj/structure/chair{ dir = 4 @@ -4712,14 +4729,6 @@ }, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) -"Uo" = ( -/obj/structure/table/reinforced, -/obj/effect/turf_decal/siding/thinplating_new{ - dir = 1 - }, -/obj/effect/turf_decal/tile/neutral/full, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost) "Up" = ( /obj/structure/flora/rock/pile/style_3{ pixel_w = -6; @@ -4854,11 +4863,6 @@ }, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) -"Vv" = ( -/obj/effect/turf_decal/siding/thinplating_new, -/obj/effect/turf_decal/tile/neutral/full, -/turf/open/indestructible/large, -/area/voidcrew/trader_outpost) "Vy" = ( /obj/structure/table/reinforced, /obj/item/reagent_containers/cup/glass/bottle/wine{ @@ -5014,6 +5018,13 @@ /obj/structure/table, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"WH" = ( +/obj/machinery/light/small/directional/east, +/obj/effect/landmark/outpost_police_cell{ + cell_id = "halcyon_cell_1" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "WN" = ( /obj/effect/turf_decal/siding/wideplating_new{ dir = 8 @@ -5249,6 +5260,17 @@ }, /turf/open/indestructible/hotelwood, /area/voidcrew/trader_outpost) +"Zd" = ( +/obj/effect/turf_decal/siding/wideplating_new/dark{ + dir = 9 + }, +/obj/effect/turf_decal/tile/neutral/anticorner{ + dir = 1 + }, +/obj/effect/turf_decal/box/red/corners, +/obj/structure/extinguisher_cabinet/directional/north, +/turf/open/indestructible/stone, +/area/voidcrew/trader_outpost) "Zf" = ( /obj/item/kirbyplants/organic/plant22, /obj/effect/turf_decal/siding/thinplating_new/dark{ @@ -5301,6 +5323,12 @@ }, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) +"ZA" = ( +/obj/effect/landmark/outpost_police_cell{ + cell_id = "halcyon_cell_2" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "ZB" = ( /obj/effect/turf_decal/siding/thinplating_new/dark/corner{ dir = 4 @@ -5340,6 +5368,12 @@ /obj/machinery/light/floor, /turf/open/indestructible/grass, /area/voidcrew/trader_outpost) +"ZT" = ( +/obj/effect/landmark/outpost_police_cell{ + cell_id = "halcyon_cell_1" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) (1,1,1) = {" as @@ -7031,7 +7065,7 @@ ul vt as as -as +ak as as as @@ -7096,9 +7130,9 @@ Rr ak Ap Pl -Pl -Pl -Pl +ak +IX +ak Pl Pl jW @@ -7163,8 +7197,8 @@ ak ak ul ul -as -as +Aj +ak as as Pl @@ -7229,7 +7263,7 @@ GC yJ zJ ul -ul +sb ul as as @@ -7354,9 +7388,9 @@ kM yZ yZ yZ -bW -yZ +hS yZ +Fm yZ Oc ef @@ -7420,9 +7454,9 @@ bR ak ul ak -bx -ak ul +ak +bx ul bQ yZ @@ -7483,13 +7517,13 @@ ef JW ak ak -bi -be +Df +ZA +wZ +qo bs by -be -bA -bD +xE ak ak eJ @@ -7548,15 +7582,15 @@ ef kM bR ak -bd -be -be +wZ +qt +ZA +wZ +qo be be -be -be -be -bd +Gm +pf ak bQ Oc @@ -7613,15 +7647,15 @@ FW ef JW ul -aT -be -bj -be -bt -bt -be +Qs +ZT +zw +tB +wZ +qo be be +mB be aT ul @@ -7679,12 +7713,12 @@ IO ef JW ul -aT -be -be -be -bt -bt +hV +WH +Si +pX +wZ +qo bj be be @@ -7748,7 +7782,7 @@ ak ak ak ak -bn +Me be be be @@ -7880,7 +7914,7 @@ ak al ac ak -be +kC be be be @@ -7946,7 +7980,7 @@ ak ul ul ak -bq +Mc bt bt bj @@ -8144,8 +8178,8 @@ JW ak ak bm +bi be -bd be be bC @@ -8403,7 +8437,7 @@ Pl as as ul -ul +sb ul MC KD @@ -8468,8 +8502,8 @@ as Pl as as -as -as +ak +Aj ul ul ak @@ -8534,9 +8568,9 @@ Pl jW Pl Pl -Pl -Pl -Pl +ak +RS +ak Pl Ap ak @@ -8601,7 +8635,7 @@ Pl as as as -as +ak as as vt @@ -8670,7 +8704,7 @@ as as as as -vt +ak ul tv eJ @@ -8735,8 +8769,8 @@ Pl Pl Pl Pl -Pl -Ap +ak +IX ak wV eJ @@ -8801,8 +8835,8 @@ as as as as -as ak +Aj ak ak hH @@ -8868,7 +8902,7 @@ as as as ak -ak +sb Mm CM ju @@ -8934,7 +8968,7 @@ as as ak ak -mL +Zd ju JV JV From 19726cc49c2a1cd622c00b07ee0d682cb977ed61 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:40:58 -0800 Subject: [PATCH 027/152] Quartermain: police station dug south of the checkpoint lobby and two police lifts --- .../outposts/trader_outpost_outfitter.dmm | 368 +++++++++++------- 1 file changed, 232 insertions(+), 136 deletions(-) diff --git a/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm b/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm index 015db539cba..5008d1a343e 100644 --- a/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm +++ b/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm @@ -145,13 +145,6 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) -"aw" = ( -/obj/effect/landmark/outpost_elevator_alcove, -/obj/effect/turf_decal/stripes/line{ - dir = 1 - }, -/turf/open/indestructible/dark/textured, -/area/voidcrew/trader_outpost) "ay" = ( /obj/effect/decal/cleanable/dirt/dust, /obj/machinery/light/floor, @@ -302,17 +295,6 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible, /area/voidcrew/trader_outpost) -"aR" = ( -/obj/machinery/door/airlock/outpost{ - name = "West Yard Gate" - }, -/obj/effect/turf_decal/bot, -/obj/effect/mapping_helpers/airlock/cyclelink_helper{ - dir = 8 - }, -/obj/effect/decal/cleanable/dirt/dust, -/turf/open/indestructible/plating, -/area/voidcrew/trader_outpost) "aT" = ( /obj/effect/turf_decal/trimline/red/filled/line{ dir = 8 @@ -500,11 +482,6 @@ /obj/effect/turf_decal/delivery, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost/checkpoint) -"bz" = ( -/obj/structure/extinguisher_cabinet/directional/south, -/obj/machinery/light/floor, -/turf/open/indestructible/dark, -/area/voidcrew/trader_outpost/checkpoint) "bA" = ( /obj/structure/closet/secure_closet/outpost_rental/checkpoint, /obj/effect/turf_decal/bot, @@ -610,17 +587,6 @@ /obj/effect/turf_decal/trimline/yellow/warning, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) -"bO" = ( -/obj/effect/turf_decal/bot, -/obj/machinery/door/airlock/outpost{ - name = "East Yard Gate" - }, -/obj/effect/mapping_helpers/airlock/cyclelink_helper{ - dir = 4 - }, -/obj/effect/decal/cleanable/dirt/dust, -/turf/open/indestructible/plating, -/area/voidcrew/trader_outpost) "bP" = ( /obj/structure/table, /obj/effect/spawner/random/engineering/material_cheap, @@ -676,6 +642,12 @@ /obj/effect/turf_decal/siding/thinplating_new, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"cd" = ( +/obj/effect/landmark/outpost_police_cell{ + cell_id = "quartermain_cell_2" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "cg" = ( /obj/effect/turf_decal/tile/dark/full, /obj/effect/invisible_wall, @@ -688,6 +660,18 @@ /obj/structure/lattice/catwalk, /turf/open/space/basic, /area/space) +"ct" = ( +/obj/machinery/light/small/directional/west, +/obj/effect/landmark/outpost_police_cell{ + cell_id = "quartermain_cell_1" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) +"cy" = ( +/obj/structure/extinguisher_cabinet/directional/south, +/obj/effect/decal/cleanable/dirt, +/turf/open/indestructible/dark, +/area/voidcrew/trader_outpost/checkpoint) "cz" = ( /obj/effect/turf_decal/siding/wideplating_new/dark/corner, /obj/effect/turf_decal/siding/wideplating_new/dark/corner{ @@ -981,6 +965,13 @@ /obj/structure/table/reinforced, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"fM" = ( +/obj/machinery/door/airlock/outpost/glass/police_cell{ + cell_id = "quartermain_cell_2" + }, +/obj/effect/turf_decal/delivery/red, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "fR" = ( /obj/structure/rack, /obj/effect/spawner/random/engineering/material, @@ -1080,6 +1071,11 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"hz" = ( +/obj/structure/closet/secure_closet/personal/outpost_police, +/obj/effect/turf_decal/bot, +/turf/open/indestructible/dark, +/area/voidcrew/trader_outpost/checkpoint) "hE" = ( /obj/effect/turf_decal/tile/yellow/half/contrasted{ dir = 4 @@ -1187,10 +1183,6 @@ }, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) -"iT" = ( -/obj/effect/turf_decal/stripes, -/turf/open/indestructible/dark, -/area/voidcrew/trader_outpost) "iU" = ( /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 8 @@ -1253,6 +1245,15 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible/white/smooth_large, /area/voidcrew/trader_outpost) +"jw" = ( +/obj/machinery/door/airlock/outpost/police, +/obj/effect/turf_decal/delivery/red, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost) +"jz" = ( +/obj/machinery/light/directional/south, +/turf/open/indestructible/dark, +/area/voidcrew/trader_outpost/checkpoint) "jA" = ( /obj/item/kirbyplants/organic/plant21, /obj/effect/turf_decal/siding/thinplating_new{ @@ -1401,6 +1402,10 @@ }, /turf/open/indestructible/freezer, /area/voidcrew/trader_outpost) +"lj" = ( +/obj/machinery/outpost_police_evidence, +/turf/open/indestructible/dark, +/area/voidcrew/trader_outpost/checkpoint) "lw" = ( /obj/structure/rack, /obj/effect/turf_decal/trimline/red/filled/line{ @@ -1474,6 +1479,10 @@ /obj/structure/chair, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"mM" = ( +/obj/machinery/vending/coffee, +/turf/open/indestructible/dark, +/area/voidcrew/trader_outpost/checkpoint) "mO" = ( /obj/item/paper_bin{ pixel_w = 5; @@ -1566,6 +1575,10 @@ /obj/machinery/light/small/directional/west, /turf/open/indestructible/freezer, /area/voidcrew/trader_outpost) +"oh" = ( +/obj/effect/spawner/structure/window/reinforced/indestructible, +/turf/open/indestructible/plating, +/area/voidcrew/trader_outpost/checkpoint) "oi" = ( /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 1 @@ -1602,6 +1615,12 @@ /obj/effect/decal/cleanable/dirt/dust, /turf/open/indestructible/dark/textured, /area/voidcrew/trader_outpost) +"oH" = ( +/obj/effect/landmark/outpost_police_lift, +/obj/effect/turf_decal/box/red, +/obj/machinery/light/small/directional/east, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost) "oU" = ( /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 8 @@ -1783,6 +1802,12 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"qX" = ( +/obj/machinery/status_display/outpost_berth/police_cell/directional/east{ + cell_id = "quartermain_cell_2" + }, +/turf/open/indestructible/dark, +/area/voidcrew/trader_outpost/checkpoint) "qZ" = ( /obj/machinery/door/airlock/maintenance{ name = "Back Bay" @@ -1896,16 +1921,6 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) -"sp" = ( -/obj/effect/turf_decal/tile/yellow/half/contrasted{ - dir = 8 - }, -/obj/effect/turf_decal/siding/thinplating_new{ - dir = 9 - }, -/obj/structure/extinguisher_cabinet/directional/west, -/turf/open/indestructible, -/area/voidcrew/trader_outpost) "sr" = ( /obj/item/kirbyplants/organic/plant22, /obj/effect/turf_decal/siding/wideplating_new/dark{ @@ -2246,6 +2261,15 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"wj" = ( +/obj/structure/chair/sofa/bench/tram/left{ + dir = 1 + }, +/obj/effect/landmark/outpost_police_cell{ + cell_id = "quartermain_cell_2" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "wF" = ( /obj/structure/closet/outpost_medical, /obj/effect/turf_decal/trimline/blue/filled/line{ @@ -2720,6 +2744,12 @@ /obj/item/kirbyplants/organic/plant22, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"CR" = ( +/obj/effect/landmark/outpost_police_cell{ + cell_id = "quartermain_cell_1" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "CS" = ( /obj/effect/turf_decal/siding/thinplating_new{ dir = 6 @@ -2746,6 +2776,13 @@ }, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"Ds" = ( +/obj/machinery/light/small/directional/east, +/obj/effect/landmark/outpost_police_cell{ + cell_id = "quartermain_cell_2" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "Dy" = ( /mob/living/basic/outpost_trader, /obj/structure/chair/office/light{ @@ -3081,6 +3118,13 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"Ig" = ( +/obj/machinery/door/airlock/outpost/glass/police_cell{ + cell_id = "quartermain_cell_1" + }, +/obj/effect/turf_decal/delivery/red, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "Iu" = ( /obj/item/cigbutt, /turf/open/indestructible/freezer, @@ -3093,6 +3137,12 @@ /obj/structure/closet/crate/secure, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) +"IP" = ( +/obj/structure/table/reinforced, +/obj/item/paper_bin, +/obj/item/pen, +/turf/open/indestructible/dark, +/area/voidcrew/trader_outpost/checkpoint) "IS" = ( /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 4 @@ -3380,13 +3430,6 @@ /obj/machinery/light/directional/west, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) -"LQ" = ( -/obj/effect/turf_decal/bot, -/obj/machinery/door/airlock/outpost{ - name = "Hangar Elevator" - }, -/turf/open/indestructible/dark/textured, -/area/voidcrew/trader_outpost) "LY" = ( /obj/structure/chair/sofa/corp/corner{ dir = 4 @@ -3421,6 +3464,15 @@ /obj/machinery/light/small/directional/south, /turf/open/indestructible/hoteltile, /area/voidcrew/trader_outpost) +"MU" = ( +/obj/structure/chair/sofa/bench/tram/left{ + dir = 1 + }, +/obj/effect/landmark/outpost_police_cell{ + cell_id = "quartermain_cell_1" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "MV" = ( /obj/structure/table/reinforced, /obj/effect/turf_decal/trimline/yellow/filled/line{ @@ -3568,6 +3620,15 @@ }, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"Qg" = ( +/obj/structure/chair/sofa/bench/tram/right{ + dir = 1 + }, +/obj/effect/landmark/outpost_police_cell{ + cell_id = "quartermain_cell_2" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "QD" = ( /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 8 @@ -3715,6 +3776,12 @@ /obj/machinery/light/directional/west, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"SK" = ( +/obj/effect/landmark/outpost_police_lift, +/obj/effect/turf_decal/box/red, +/obj/machinery/light/small/directional/west, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost) "SP" = ( /obj/effect/turf_decal/siding/thinplating_new{ dir = 1 @@ -3770,6 +3837,15 @@ }, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"TC" = ( +/obj/structure/chair/sofa/bench/tram/right{ + dir = 1 + }, +/obj/effect/landmark/outpost_police_cell{ + cell_id = "quartermain_cell_1" + }, +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost/checkpoint) "TG" = ( /turf/open/indestructible{ base_icon_state = "stairs-m"; @@ -3830,6 +3906,12 @@ }, /turf/open/indestructible/white/smooth_large, /area/voidcrew/trader_outpost) +"UY" = ( +/obj/machinery/status_display/outpost_berth/police_cell/directional/west{ + cell_id = "quartermain_cell_1" + }, +/turf/open/indestructible/dark, +/area/voidcrew/trader_outpost/checkpoint) "Vb" = ( /obj/structure/closet/secure_closet/outpost_rental, /obj/effect/turf_decal/siding/wideplating_new/dark{ @@ -3956,6 +4038,9 @@ "Xe" = ( /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"Xm" = ( +/turf/open/indestructible/dark/textured, +/area/voidcrew/trader_outpost) "Xt" = ( /obj/machinery/suit_storage_unit, /obj/effect/turf_decal/trimline/yellow/filled/line, @@ -3985,6 +4070,15 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"XW" = ( +/obj/effect/turf_decal/tile/yellow/half/contrasted{ + dir = 8 + }, +/obj/effect/turf_decal/siding/thinplating_new{ + dir = 9 + }, +/turf/open/indestructible, +/area/voidcrew/trader_outpost) "XZ" = ( /obj/effect/decal/cleanable/dirt, /turf/open/indestructible/dark, @@ -4048,15 +4142,17 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"ZL" = ( +/obj/structure/chair/office{ + dir = 1 + }, +/obj/effect/landmark/outpost_police_desk, +/turf/open/indestructible/dark, +/area/voidcrew/trader_outpost/checkpoint) "ZP" = ( /obj/structure/table, /turf/open/indestructible/freezer, /area/voidcrew/trader_outpost) -"ZV" = ( -/obj/effect/decal/cleanable/dirt/dust, -/obj/machinery/light/floor, -/turf/open/indestructible/plating, -/area/voidcrew/trader_outpost) (1,1,1) = {" ao @@ -4453,7 +4549,7 @@ cp aV aV aV -aV +dU dU iF Bv @@ -4494,8 +4590,8 @@ dU aF aV aV -aV -aV +dU +SK dU dU dU @@ -4514,11 +4610,11 @@ dU dU dU aV -ao -ao -ao -ao -ao +dU +dU +dU +dU +dU "} (12,1,1) = {" ao @@ -4536,8 +4632,8 @@ dU aV aV aV -aV -aV +dU +Xm dU zR Ce @@ -4556,11 +4652,11 @@ aV aV aV aV -aF -ao -ao -ao -ao +dU +dU +ct +MU +dU "} (13,1,1) = {" ao @@ -4579,7 +4675,7 @@ lE lE lE dU -dU +jw dU dU dU @@ -4598,11 +4694,11 @@ dU dU dU dU -aV -ao -ao -ao -ao +dU +dU +CR +TC +dU "} (14,1,1) = {" ao @@ -4621,7 +4717,7 @@ HF HF nO Tf -sp +XW HV dU La @@ -4640,11 +4736,11 @@ bE bD bB dU -aV -ao -ao -ao -ao +hz +UY +Ig +oh +dU "} (15,1,1) = {" ao @@ -4682,11 +4778,11 @@ bH bG bF dU -aV -aV -ao -ao -ao +hz +bm +bm +IP +dU "} (16,1,1) = {" ao @@ -4724,11 +4820,11 @@ bK bJ bI dU -aV -aV -ao -ao -ao +hz +bm +ZL +lj +dU "} (17,1,1) = {" ao @@ -4764,13 +4860,13 @@ bh bm bm bm -bn +cy +dU +hz +bm +bm +mM dU -aV -aV -ao -ao -ao "} (18,1,1) = {" ao @@ -4807,12 +4903,12 @@ bm bq bt bm +bm +bm +bm +bm +jz dU -aV -aV -aF -ao -ao "} (19,1,1) = {" ao @@ -4848,13 +4944,13 @@ dU dU dU bu -bz -dU +bq +bm +bm +qX +fM +oh dU -aV -aV -ao -ao "} (20,1,1) = {" ao @@ -4892,11 +4988,11 @@ br bv bm bA -aa -aV -aV -ao -ao +dU +dU +cd +wj +dU "} (21,1,1) = {" ao @@ -4934,11 +5030,11 @@ dU bw bm bA -aa -aV -aV -aV -ao +dU +dU +Ds +Qg +dU "} (22,1,1) = {" ao @@ -4977,10 +5073,10 @@ bx bq bA aa -aV -aV -aV -ao +dU +dU +dU +dU "} (23,1,1) = {" ao @@ -5555,7 +5651,7 @@ sr dU dU dU -dU +jw dU dU dU @@ -5596,9 +5692,9 @@ lE dU dU aV -aV -aV -aV +dU +Xm +dU aV aV aV @@ -5638,9 +5734,9 @@ oU bf dU dU -aV -aV -ao +dU +oH +dU ao ao aF @@ -5681,7 +5777,7 @@ Tg Fx dU aV -ao +dU ao ao ao From b18dc8744e66d331f2305fb1c4aa97ce1bd64b57 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:41:04 -0800 Subject: [PATCH 028/152] Outpost police: wanted core and the witness seam --- .../outpost_police/police_perception.dm | 127 ++- .../modules/outpost_police/police_wanted.dm | 872 +++++++++++++++++- .../voidcrew_outpost_police_wanted.dm | 635 ++++++++++++- 3 files changed, 1594 insertions(+), 40 deletions(-) diff --git a/voidcrew/modules/outpost_police/police_perception.dm b/voidcrew/modules/outpost_police/police_perception.dm index 2d8732dfb40..9c22e09ff16 100644 --- a/voidcrew/modules/outpost_police/police_perception.dm +++ b/voidcrew/modules/outpost_police/police_perception.dm @@ -4,29 +4,142 @@ * Owner: P1. The whole "did anyone see it" test (owner decision 6, spec 3 and 18). This is the only police * file that names can_see, cyberware_camo_hides or a witness range; the stealth merge adds one subtype here * and changes one line in make_perception(), nothing else. + * + * A crime counts when witness_of() names a witness. report_crime() (police_wanted.dm) asks that one question, + * asks report_delay() how long the witness takes to report and can_report() whether they still can. Today + * sight within OUTPOST_POLICE_WITNESS_RANGE is all there is, the delay is 0 and a witness can always report. */ /// The witness test this police uses /datum/outpost_police/proc/make_perception() - // The stealth merge returns its subtype here (spec 18) + // The stealth merge returns its subtype here, under #ifdef STEALTH_SIGHT_RANGE (spec 18) + hook_events() // police_wanted.dm: police_core.dm's New() calls this once, so the event hooks start here return new /datum/outpost_police_perception(src) -/// Who, if anyone, witnessed `offender` doing `crime`: POLICE_WITNESS_*. Sets `last_witness`. +/** + * Who, if anyone, witnessed `offender` doing `crime` to `victim` with `source`: POLICE_WITNESS_*. Sets + * `last_witness`. Candidates in order: this outpost's officers, its traders and checkpoint guards, the + * ambient people of its place, players not on the offender's crew (the victim first), then cameras. The first + * that sees wins. Crimes done to an officer, and crimes the scanner or the cell see, need no one in sight. + */ /datum/outpost_police_perception/proc/witness_of(mob/living/offender, crime, atom/victim, obj/item/source) last_witness = null - return POLICE_WITNESS_SAW + if(!offender || !police?.outpost) + return POLICE_WITNESS_NONE + switch(crime) + if(OUTPOST_CRIME_RESIST, OUTPOST_CRIME_ASSAULT_OFFICER, OUTPOST_CRIME_KILL_OFFICER) + // Always counts while an officer of this outpost is alive + last_witness = living_officer_near(offender, victim) + return last_witness ? POLICE_WITNESS_SAW : POLICE_WITNESS_NONE + if(OUTPOST_CRIME_WEAPON_DRAWN, OUTPOST_CRIME_CHECKPOINT_BREACH, OUTPOST_CRIME_JAILBREAK, OUTPOST_CRIME_TRESPASS, OUTPOST_CRIME_ACCOMPLICE) + // The scanner, the cell and the officers' own sighting see these + return POLICE_WITNESS_CAMERA + + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in police.officers) + if(sees(officer, offender)) + last_witness = officer + return POLICE_WITNESS_SAW + for(var/mob/living/basic/outpost_trader/trader in police.outpost.traders) + if(sees(trader, offender)) + last_witness = trader + return POLICE_WITNESS_SAW + var/datum/ambient_place/outpost/place = SSambient_npcs.outposts?[police.outpost] + if(place) + for(var/mob/living/carbon/human/npc/ambient_npc/person in place.npcs) + if(sees(person, offender)) + last_witness = person + return POLICE_WITNESS_SAW + var/datum/voidcrew_crew_record/own_crew = offender.mind?.get_reputation_crew() + var/mob/living/watching_victim = isliving(victim) ? victim : null + if(watching_victim?.mind && watching_victim != offender && !on_crew(watching_victim, own_crew) && sees(watching_victim, offender)) + last_witness = watching_victim + return POLICE_WITNESS_SAW + for(var/mob/living/player in GLOB.alive_player_list) + if(player == offender || player == watching_victim || on_crew(player, own_crew)) + continue + if(sees(player, offender) && get_trader_outpost_for_turf(get_turf(player)) == police.outpost) + last_witness = player + return POLICE_WITNESS_SAW + for(var/atom/camera as anything in cameras()) + if(sees(camera, offender)) + last_witness = camera + return POLICE_WITNESS_CAMERA + return POLICE_WITNESS_NONE + +/// Whether `person` belongs to `crew` (a null crew has no members) +/datum/outpost_police_perception/proc/on_crew(mob/living/person, datum/voidcrew_crew_record/crew) + return !!crew && person.mind?.get_reputation_crew() == crew + +/// The living officer of this outpost who answers an attack on `victim`: that officer if it is one, else the nearest to `offender`; null if none lives +/datum/outpost_police_perception/proc/living_officer_near(mob/living/offender, atom/victim) + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/nearest + var/nearest_dist = INFINITY + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in police.officers) + if(officer.stat == DEAD || QDELETED(officer)) + continue + if(officer == victim) + return officer + var/dist = get_dist(officer, offender) + if(!nearest || dist < nearest_dist) + nearest = officer + nearest_dist = dist + return nearest -/// Whether `viewer` (an officer, an NPC, a camera) sees `target` now +/** + * Whether `viewer` (a conscious living mob, or a camera) sees `target` now: within the witness range with a + * clear line, and not past the optical camo of a target an NPC or camera cannot spot. Also the test for + * "an officer sees the suspect" (police_wanted.dm tick_warrants()). + */ /datum/outpost_police_perception/proc/sees(atom/viewer, mob/living/target) + if(QDELETED(viewer) || QDELETED(target)) + return FALSE + var/mob/living/watcher = isliving(viewer) ? viewer : null + if(watcher) + if(watcher.stat != CONSCIOUS) + return FALSE + else if(!is_camera(viewer)) + return FALSE + if(!can_see(viewer, target, OUTPOST_POLICE_WITNESS_RANGE)) + return FALSE + // Optical camo fools AI and cameras; a player with eyes is not fooled + if((!watcher || !watcher.mind) && cyberware_camo_hides(target, viewer)) + return FALSE + return TRUE + +/// Whether `thing` is one of the outpost's cameras +/datum/outpost_police_perception/proc/is_camera(atom/thing) + return istype(thing, /obj/machinery/outpost_checkpoint_sensor) || istype(thing, /obj/machinery/outpost_elevator) + +/// Whether a checkpoint scanner has `target` in its chamber (the scan counts as seeing them) +/datum/outpost_police_perception/proc/scanned(mob/living/target) + var/turf/where = get_turf(target) + if(!where || !police?.outpost) + return FALSE + for(var/checkpoint_id in police.outpost.checkpoints) + var/datum/outpost_checkpoint/checkpoint = police.outpost.checkpoints[checkpoint_id] + if(checkpoint?.sensor && (where in checkpoint.chamber_turfs)) + return TRUE return FALSE -/// Whether `listener` hears `offender` doing `crime` with `source` +/// Whether `listener` hears `offender` doing `crime` with `source`. Nothing is heard in this build. /datum/outpost_police_perception/proc/hears(atom/listener, mob/living/offender, crime, obj/item/source) return FALSE -/// What watches for the police besides people: the checkpoint scanners and panels +/// What watches for the police besides people: the checkpoint wall scanners and the lift panels /datum/outpost_police_perception/proc/cameras() - return list() + . = list() + var/obj/structure/overmap/trader_outpost/outpost = police?.outpost + if(!outpost) + return + for(var/checkpoint_id in outpost.checkpoints) + var/datum/outpost_checkpoint/checkpoint = outpost.checkpoints[checkpoint_id] + if(checkpoint?.sensor) + . += checkpoint.sensor + for(var/obj/machinery/outpost_elevator/lobby_panel in outpost.lobby_panels) + . += lobby_panel + for(var/datum/outpost_berth/berth as anything in outpost.berths) + if(berth?.panel) + . += berth.panel /// How long `witness` takes to report `crime`; 0 is at once /datum/outpost_police_perception/proc/report_delay(atom/witness, crime) diff --git a/voidcrew/modules/outpost_police/police_wanted.dm b/voidcrew/modules/outpost_police/police_wanted.dm index 05cbf9f9c29..4ee26c0aa85 100644 --- a/voidcrew/modules/outpost_police/police_wanted.dm +++ b/voidcrew/modules/outpost_police/police_wanted.dm @@ -5,42 +5,346 @@ * heat on the offender's warrant (GLOB.outpost_crimes), fades it, fines it, bars and embargoes for it. * Spec sections 2, 4, 6.2, 9 (the embargo half) and 15. * - * P0 left stubs with the frozen signatures (build plan section 2). report_crime() runs inside signal - * handlers (the PvP watcher, outpost property, the armed sweep): it and everything it calls must never sleep. + * report_crime() runs inside signal handlers (the PvP watcher, outpost property, the armed sweep): it and + * everything it calls must never sleep. + * + * Round metrics written here (METRIC_COMBAT, voidcrew/modules/metrics; the event catalog lives in the kit + * vault page for metrics): + * police_warrant_ended A wanted record ended. ckey = the suspect, subject = how ("faded", "served", + * "closed", "pardoned"), points = peak stars, quantity = officers they killed. + * police_officer_killed An officer was killed by someone with a record. ckey = the killer, quantity = 1. + * police_embargo A four or five star incident put a crew under embargo. ckey = the suspect, + * points = stars, quantity = minutes. + * Fines show up as the ship account's own ship_account_change row ("Fine: "). */ +// ========================================================================= +// P1'S OWN STATE (vars on the shared types, kept here because police_core.dm is frozen) +// ========================================================================= + +/datum/outpost_police + /// mind -> list(world.time, TRUE if that report counted): folds the same offender's reports within a half second + var/list/recent_reports = list() + /// Reports waiting on a witness's delay (/datum/outpost_pending_report), confirmed in tick_warrants() + var/list/pending_reports = list() + /// Four and five star incidents: crew record -> /datum/outpost_incident + var/list/incidents = list() + /// Crew records whose deletion we listen for + var/list/watched_crews = list() + /// Whether the death hook is registered + var/events_hooked = FALSE + +/datum/outpost_warrant + /// The fine's star level, fixed at the first charge so bail asks for the same sum (1 or 2), or 0 + var/fine_level = 0 + +/datum/outpost_warrant/Destroy() + mind = null + crew = null + last_seen_turf = null + evidence = null + recent_victims = null + return ..() + +/// A report that waits for its witness to make the call (perception.report_delay() above 0) +/datum/outpost_pending_report + /// world.time the witness finishes reporting + var/due = 0 + var/crime + var/datum/weakref/offender + var/datum/weakref/victim + var/datum/weakref/source + var/datum/weakref/witness + +/// One four or five star incident of one crew at this outpost: the embargo, the cutter's single dispatch +/datum/outpost_incident + var/datum/voidcrew_crew_record/crew + /// Highest tier applied: 4 or 5 stars + var/stars = 0 + var/started_at = 0 + /// world.time the embargo ends + var/ends_at = 0 + var/officers_killed = 0 + /// The cutter has been sent for this incident + var/pursued = FALSE + /// The crew's ships we listen to + var/list/obj/structure/overmap/ship/watched = list() + /// ship -> TRUE while it sits in a berth here + var/list/docked = list() + +// ========================================================================= +// REPORTING A CRIME +// ========================================================================= + /** * `offender` did `crime` (OUTPOST_CRIME_*) to `victim` (or to nobody in particular) with `source` (the item * used, or null). Returns the offender's stars here after it, 0 for a counted crime still under one star, or - * null when it did not count (no mind, no witness, folded into the last one). A counted `source` joins the - * warrant's evidence. Never sleeps. + * null when it did not count (no mind, no witness, folded into the last one, waiting on a witness's report). + * A counted `source` joins the warrant's evidence. Never sleeps. */ /datum/outpost_police/proc/report_crime(mob/living/offender, crime, atom/victim, obj/item/source) SHOULD_NOT_SLEEP(TRUE) - if(!offender?.mind) + if(QDELETED(src) || !offender?.mind) return null - log_game("OUTPOST POLICE: [key_name(offender)] committed [crime][victim ? " against [victim]" : ""][source ? " with [source] ([source.type])" : ""] at [outpost] ([AREACOORD(offender)]).") - return null + if(!GLOB.outpost_crimes[crime]) + stack_trace("OUTPOST POLICE: unknown crime '[crime]'") + return null + switch(crime) + if(OUTPOST_CRIME_WEAPON_DRAWN, OUTPOST_CRIME_GUNFIRE, OUTPOST_CRIME_VANDALISM) + // A hunter in the kingpin's shootout is excused strays from that fight (abuse M16) + if(bounty_kingpin_excuses_aggression(outpost, offender)) + return null + var/datum/mind/mind = offender.mind + // One report per offender per half second; a folded one still lends its weapon and victim to a counted crime + prune_recent_reports() + var/list/last = recent_reports[mind] + if(last) + if(last[2]) + fold_crime(mind, victim, source) + return null + var/witnessed = perception.witness_of(offender, crime, victim, source) + if(witnessed == POLICE_WITNESS_NONE) + recent_reports[mind] = list(world.time, FALSE) + log_game("OUTPOST POLICE: [key_name(offender)] committed [crime] at [outpost] ([AREACOORD(offender)]) and nobody saw.") + return null + var/atom/witness = perception.last_witness + var/delay = perception.report_delay(witness, crime) + if(delay > 0) + recent_reports[mind] = list(world.time, FALSE) + queue_report(offender, crime, victim, source, witness, delay) + return null + recent_reports[mind] = list(world.time, TRUE) + return count_crime(offender, crime, victim, source) + +/// Forgets reports older than the grace +/datum/outpost_police/proc/prune_recent_reports() + for(var/datum/mind/mind as anything in recent_reports.Copy()) + var/list/seen = recent_reports[mind] + if(QDELETED(mind) || world.time - seen[1] >= OUTPOST_AGGRESSION_GRACE) + recent_reports -= mind + +/// A report folded into the offender's last counted one: its weapon is evidence, its victim a victim +/datum/outpost_police/proc/fold_crime(datum/mind/mind, atom/victim, obj/item/source) + var/datum/outpost_warrant/warrant = warrants[mind] + if(!warrant) + return + if(source) + note_evidence(warrant, source) + if(isliving(victim)) + record_victim(warrant, victim) + +/// Queues a report that its witness will make in `delay` deciseconds, if they can still make it then +/datum/outpost_police/proc/queue_report(mob/living/offender, crime, atom/victim, obj/item/source, atom/witness, delay) + var/datum/outpost_pending_report/report = new + report.due = world.time + delay + report.crime = crime + report.offender = WEAKREF(offender) + if(victim) + report.victim = WEAKREF(victim) + if(source) + report.source = WEAKREF(source) + if(witness) + report.witness = WEAKREF(witness) + pending_reports += report + wake() + +/// Confirms or drops the reports whose witness has finished their call +/datum/outpost_police/proc/process_pending_reports() + for(var/datum/outpost_pending_report/report as anything in pending_reports.Copy()) + if(world.time < report.due) + continue + pending_reports -= report + var/mob/living/offender = report.offender?.resolve() + var/atom/witness = report.witness?.resolve() + var/lost_witness = report.witness && (QDELETED(witness) || !perception.can_report(witness)) + if(QDELETED(offender) || lost_witness) + log_game("OUTPOST POLICE: a report of [report.crime] at [outpost] was never made.") + else + count_crime(offender, report.crime, report.victim?.resolve(), report.source?.resolve()) + qdel(report) + +/** + * Puts a witnessed crime on `offender`'s warrant. `derived` marks a crime that follows from one already + * counted (a killing, an accomplice): it is not seen again and does not say where the offender is. + * Returns the stars after. + */ +/datum/outpost_police/proc/count_crime(mob/living/offender, crime, atom/victim, obj/item/source, derived = FALSE) + SHOULD_NOT_SLEEP(TRUE) + var/datum/mind/mind = offender?.mind + if(!mind || QDELETED(src)) + return null + var/datum/outpost_warrant/warrant = ensure_warrant(mind) + var/old_stars = stars_of(warrant.heat) + warrant.heat = heat_after(max(warrant.heat, current_prior(warrant)), crime) + warrant.peak = max(warrant.peak, warrant.heat) + warrant.last_crime_at = world.time + if(!derived) + warrant.last_seen_at = world.time + warrant.last_seen_turf = get_turf(offender) + warrant.unseen_since = 0 + if(source) + note_evidence(warrant, source) + if(isliving(victim) && victim != offender) + record_victim(warrant, victim) + log_game("OUTPOST POLICE: [key_name(offender)] committed [crime][victim ? " against [victim]" : ""][source ? " with [source] ([source.type])" : ""] at [outpost] ([AREACOORD(offender)]): [warrant.heat] heat.") + var/new_stars = stars_of(warrant.heat) + if(new_stars >= 3) + warrant.barred_until = max(warrant.barred_until, world.time + OUTPOST_POLICE_BAR_TIME) + if(new_stars != old_stars) + stars_changed(warrant, old_stars, new_stars, offender, crime != OUTPOST_CRIME_ACCOMPLICE) + wake() + return new_stars + +/// `warrant` went from `old_stars` to `new_stars`: tell the listeners and the HUD; a rise is called in on the radio +/datum/outpost_police/proc/stars_changed(datum/outpost_warrant/warrant, old_stars, new_stars, mob/living/suspect, call_in = TRUE) + SEND_SIGNAL(src, COMSIG_OUTPOST_POLICE_STARS, warrant.mind, old_stars, new_stars) + update_hud(warrant.mind) + if(new_stars > old_stars && new_stars >= 1 && call_in && suspect) + say_dispatch(POLICE_LINE_RADIO_CALL, dispatch_values(suspect)) + if(new_stars >= 4) + escalate_incident(warrant, new_stars) + +/// The warrant of `mind`, made if they have none; it remembers the crew they had at their first crime (abuse M7) +/datum/outpost_police/proc/ensure_warrant(datum/mind/mind) + var/datum/outpost_warrant/warrant = warrants[mind] + if(!warrant) + warrant = new + warrant.mind = mind + warrants[mind] = warrant + if(!warrant.crew) + set_warrant_crew(warrant, mind.get_reputation_crew()) + return warrant + +/// Remembers `crew` on `warrant` and watches for the record being deleted +/datum/outpost_police/proc/set_warrant_crew(datum/outpost_warrant/warrant, datum/voidcrew_crew_record/crew) + if(QDELETED(crew)) + return + warrant.crew = crew + watch_crew(crew) + +/datum/outpost_police/proc/watch_crew(datum/voidcrew_crew_record/crew) + if(crew in watched_crews) + return + watched_crews += crew + RegisterSignal(crew, COMSIG_QDELETING, PROC_REF(on_crew_deleted)) + +/datum/outpost_police/proc/on_crew_deleted(datum/voidcrew_crew_record/crew) + SIGNAL_HANDLER + watched_crews -= crew + for(var/datum/mind/mind as anything in warrants) + var/datum/outpost_warrant/warrant = warrants[mind] + if(warrant?.crew == crew) + warrant.crew = null + var/datum/outpost_incident/incident = incidents[crew] + if(incident) + end_incident(incident) + +/// `item` is evidence on `warrant`: newest last, the oldest out past OUTPOST_POLICE_EVIDENCE_MAX +/datum/outpost_police/proc/note_evidence(datum/outpost_warrant/warrant, obj/item/item) + var/datum/weakref/ref = WEAKREF(item) + if(!ref) + return + if(!warrant.evidence) + warrant.evidence = list() + warrant.evidence -= ref + warrant.evidence += ref + while(length(warrant.evidence) > OUTPOST_POLICE_EVIDENCE_MAX) + warrant.evidence.Cut(1, 2) + +/// `victim` was hit by this warrant's holder just now: a death inside OUTPOST_POLICE_KILL_WINDOW is theirs +/datum/outpost_police/proc/record_victim(datum/outpost_warrant/warrant, mob/living/victim) + if(!warrant.recent_victims) + warrant.recent_victims = list() + prune_victims(warrant) + warrant.recent_victims[WEAKREF(victim)] = world.time + while(length(warrant.recent_victims) > 24) + warrant.recent_victims.Cut(1, 2) + +/datum/outpost_police/proc/prune_victims(datum/outpost_warrant/warrant) + if(!length(warrant.recent_victims)) + return + for(var/datum/weakref/ref as anything in warrant.recent_victims.Copy()) + if(world.time - warrant.recent_victims[ref] > OUTPOST_POLICE_KILL_WINDOW) + warrant.recent_victims -= ref + +// ========================================================================= +// HEAT AND STARS +// ========================================================================= + +/// Stars (0 to 5) a given heat is worth +/datum/outpost_police/proc/stars_of(heat) + var/list/thresholds = OUTPOST_POLICE_STAR_HEAT + . = 0 + for(var/star in 1 to length(thresholds)) + if(heat >= thresholds[star]) + . = star + +/// Heat after `crime` on top of `heat`: its points up to its ceiling, never below its floor, never past the cap +/datum/outpost_police/proc/heat_after(heat, crime) + var/list/entry = GLOB.outpost_crimes[crime] + if(!entry) + return heat + var/list/thresholds = OUTPOST_POLICE_STAR_HEAT + var/points = entry[1] + var/ceiling_stars = entry[2] + var/floor_stars = entry[3] + var/top = ceiling_stars < 5 ? thresholds[ceiling_stars + 1] - 1 : OUTPOST_POLICE_HEAT_CAP + var/new_heat = heat + if(points) + new_heat = max(heat, min(heat + points, top)) + if(floor_stars) + new_heat = max(new_heat, thresholds[floor_stars]) + return min(new_heat, OUTPOST_POLICE_HEAT_CAP) + +/// The prior a faded warrant left, worn down linearly over OUTPOST_POLICE_PRIOR_TIME +/datum/outpost_police/proc/current_prior(datum/outpost_warrant/warrant) + if(warrant.prior <= 0) + return 0 + var/elapsed = world.time - warrant.prior_set_at + if(elapsed >= OUTPOST_POLICE_PRIOR_TIME) + return 0 + return round(warrant.prior * (1 - elapsed / OUTPOST_POLICE_PRIOR_TIME)) /// Stars `who` has here, 0 to 5 /datum/outpost_police/proc/get_stars(datum/mind/who) - return 0 + var/datum/outpost_warrant/warrant = warrants[who] + return warrant ? stars_of(warrant.heat) : 0 /// `who`'s warrant here, or null /datum/outpost_police/proc/get_warrant(datum/mind/who) - return null + return warrants[who] /// How hard officers may go at `target`: POLICE_FORCE_* (NONE for no mind, in custody or dead) /datum/outpost_police/proc/force_level(mob/living/target) - return POLICE_FORCE_NONE + if(!target?.mind || target.stat == DEAD || in_custody(target)) + return POLICE_FORCE_NONE + switch(get_stars(target.mind)) + if(0) + return POLICE_FORCE_NONE + if(1) + return POLICE_FORCE_ARREST + if(2) + return POLICE_FORCE_STUN + return POLICE_FORCE_LETHAL /** - * Whether `user` is refused service here (shops, the permit desk, the checkpoint): stars, a bar, or crew of - * an embargoed ship. P0 stub: the embargo half only. + * Whether `user` is refused service here (shops, the permit desk, the checkpoint): stars, a bar, or crew + * of an embargoed ship. */ /datum/outpost_police/proc/is_barred(mob/user) if(!user?.mind || !outpost) return FALSE + if(get_stars(user.mind) >= 1) + return TRUE + return barred_by_record(user) + +/// Whether a faded record's bar, or an embargo on their crew's ship, refuses `user` +/datum/outpost_police/proc/barred_by_record(mob/user) + if(!user?.mind || !outpost) + return FALSE + var/datum/outpost_warrant/warrant = warrants[user.mind] + if(warrant && warrant.barred_until > world.time) + return TRUE var/datum/voidcrew_crew_record/crew = user.mind.get_reputation_crew() for(var/datum/team/voidcrew/team in crew?.teams) if(team.ship && outpost.is_ship_embargoed(team.ship)) @@ -49,35 +353,539 @@ /// An officer (or the checkpoint's scan) sees `who` at `where` now /datum/outpost_police/proc/note_seen(datum/mind/who, turf/where) - return + var/datum/outpost_warrant/warrant = warrants[who] + if(!warrant) + return + var/was_unseen = warrant.unseen_since + warrant.last_seen_at = world.time + warrant.last_seen_turf = where + warrant.unseen_since = 0 + if(was_unseen) + update_hud(who) -/** - * The fine for `warrant`'s stars and the zone, from its remembered crew's ship accounts pooled, richest first, - * all or nothing (abuse M6, M7). TRUE with the money gone, or FALSE with nothing taken. Never sleeps. - */ -/datum/outpost_police/proc/charge_fine(datum/outpost_warrant/warrant) - return FALSE +/// Warrants of one star or more whose holder is at this outpost +/datum/outpost_police/proc/suspects_at_outpost() + . = list() + for(var/datum/mind/mind as anything in warrants) + var/datum/outpost_warrant/warrant = warrants[mind] + if(!warrant || stars_of(warrant.heat) < 1) + continue + if(holder_at_outpost(mind.current)) + . += warrant + +/// Whether `body` is a living mob standing in this outpost's concourse, lobby, hangars or a ship docked in one +/datum/outpost_police/proc/holder_at_outpost(mob/body) + if(!isliving(body)) + return FALSE + var/turf/where = get_turf(body) + return !!where && get_trader_outpost_for_turf(where) == outpost -/// `who`'s heat goes to 0 (`served`: they did their time); their prior stays +/// `who`'s heat goes to 0; their prior stays. `served`: they did their time (a bar from an earlier record stays); +/// not served (an admin lifting it, a case dropped): a bar goes too. An emptied warrant is dropped on the next tick. /datum/outpost_police/proc/close_warrant(datum/mind/who, served) - return + var/datum/outpost_warrant/warrant = warrants[who] + if(!warrant) + return + var/old_stars = stars_of(warrant.heat) + if(warrant.peak > 0) + warrant_ended(warrant, served ? "served" : "closed") + warrant.heat = 0 + warrant.peak = 0 + warrant.unseen_since = 0 + warrant.away_since = 0 + warrant.fine_level = 0 + warrant.evidence = null + if(!served) + warrant.barred_until = 0 + if(old_stars) + stars_changed(warrant, old_stars, 0, who.current, FALSE) + else + update_hud(who) + wake() /// Clears every warrant, prior and bar of `crew` here and lifts its embargo /datum/outpost_police/proc/pardon_crew(datum/voidcrew_crew_record/crew) - return + if(!crew) + return + for(var/datum/mind/mind as anything in warrants.Copy()) + var/datum/outpost_warrant/warrant = warrants[mind] + if(warrant && (warrant.crew == crew || mind.get_reputation_crew() == crew)) + if(warrant.peak > 0) + warrant_ended(warrant, "pardoned") + remove_warrant(warrant) + var/datum/outpost_incident/incident = incidents[crew] + var/everywhere = incident?.stars >= 5 + for(var/obj/structure/overmap/ship/ship as anything in crew_ship_list(crew)) + outpost.embargoed_ships -= ship + if(everywhere) + for(var/obj/structure/overmap/trader_outpost/other as anything in GLOB.trader_outposts) + other.embargoed_ships -= ship + if(incident) + end_incident(incident) -/// Warrants of one star or more whose holder is at this outpost -/datum/outpost_police/proc/suspects_at_outpost() - return list() +/// Drops `warrant` and clears its holder's HUD +/datum/outpost_police/proc/remove_warrant(datum/outpost_warrant/warrant) + var/datum/mind/mind = warrant.mind + if(mind) + warrants -= mind + if(!QDELETED(mind)) + update_hud(mind) + qdel(warrant) -/// An officer was hit (relay_attackers): ASSAULT_OFFICER or RESIST (abuse M3). From the officer's body (P2). -/datum/outpost_police/proc/officer_struck(mob/living/officer, atom/attacker, attack_flags) - return +/// A record ended: count it +/datum/outpost_police/proc/warrant_ended(datum/outpost_warrant/warrant, how) + var/mob/living/body = warrant.mind?.current + record_metric(METRIC_COMBAT, "police_warrant_ended", actor = isliving(body) ? body : null, subject = how, points = stars_of(warrant.peak), quantity = warrant.officers_killed) -/// Someone tried to cuff an officer: ASSAULT_OFFICER (abuse M3). From the officer's body (P2). -/datum/outpost_police/proc/officer_cuff_attempt(mob/living/officer, mob/living/user) - return +// ========================================================================= +// THE ONE-SECOND TICK +// ========================================================================= /// Once a second (police_core.dm tick()): sightings, fading, priors, bars, accomplices, pending reports /datum/outpost_police/proc/tick_warrants() - return + if(QDELETED(src) || !outpost) + return + process_pending_reports() + var/list/stand_at_four = list() + for(var/datum/mind/mind as anything in warrants.Copy()) + var/datum/outpost_warrant/warrant = warrants[mind] + if(!warrant) + continue + if(QDELETED(mind)) + warrants -= mind + qdel(warrant) + continue + tick_warrant(warrant) + if(QDELETED(warrant)) + continue + var/stars = stars_of(warrant.heat) + if(stars >= 4) + // Holds the embargo while they stay at four or five (it lapses and is laid again after its time) + escalate_incident(warrant, stars) + if(holder_at_outpost(mind.current)) + stand_at_four += warrant + for(var/datum/outpost_warrant/leader as anything in stand_at_four) + abet_crew(leader) + for(var/datum/voidcrew_crew_record/crew as anything in incidents.Copy()) + var/datum/outpost_incident/incident = incidents[crew] + if(!incident) + continue + if(world.time >= incident.ends_at) + end_incident(incident) + continue + sync_incident(incident) + +/// One warrant's clocks for one second +/datum/outpost_police/proc/tick_warrant(datum/outpost_warrant/warrant) + var/mob/living/body = warrant.mind.current + if(!isliving(body)) + body = null + prune_victims(warrant) + if(body && in_custody(body)) + // The cells keep the prisoner's time (police_cell.dm); nothing fades and nothing is dropped meanwhile + return + var/turf/where = body ? get_turf(body) : null + var/here = !!where && get_trader_outpost_for_turf(where) == outpost + var/downed = !body || body.stat == DEAD || body.stat == HARD_CRIT + var/old_stars = stars_of(warrant.heat) + var/was_away = !!warrant.away_since + var/was_unseen = !!warrant.unseen_since + + // Away: not at the outpost, or dead or in crit. Cleared only by a return. + if(!here || downed) + if(!warrant.away_since) + warrant.away_since = world.time + else + warrant.away_since = 0 + // Seen: an officer sees them, or a checkpoint scan has them. Cleared only by a sighting. + var/seen = FALSE + if(here && !downed) + seen = suspect_seen(body) + if(seen) + warrant.last_seen_at = world.time + warrant.last_seen_turf = where + warrant.unseen_since = 0 + else if(!warrant.unseen_since) + warrant.unseen_since = world.time + if(was_away != !!warrant.away_since || was_unseen != !!warrant.unseen_since) + update_hud(warrant.mind) + + // A faded record's bar runs from the last moment at three stars + if(old_stars >= 3) + warrant.barred_until = max(warrant.barred_until, world.time + OUTPOST_POLICE_BAR_TIME) + // A barred person an officer sees on secured turf is an arrest + if(here && !downed && seen && old_stars < 2 && barred_by_record(body) && outpost.is_secured_turf(where)) + count_crime(body, OUTPOST_CRIME_TRESPASS) + old_stars = stars_of(warrant.heat) + + // Fading + if(warrant.heat > 0) + var/draining = FALSE + if(warrant.away_since && world.time - warrant.away_since >= OUTPOST_POLICE_AWAY_GRACE) + draining = TRUE + else if(!warrant.away_since && !seen && warrant.unseen_since && old_stars <= 2) + var/hide_time = old_stars >= 2 ? OUTPOST_POLICE_HIDE_2 : OUTPOST_POLICE_HIDE_1 + if(world.time - warrant.unseen_since >= hide_time) + draining = TRUE + if(draining) + warrant.heat = max(0, warrant.heat - OUTPOST_POLICE_FADE_PER_SECOND) + if(warrant.heat <= 0) + warrant_faded(warrant) + var/new_stars = stars_of(warrant.heat) + if(new_stars != old_stars) + stars_changed(warrant, old_stars, new_stars, body, FALSE) + + if(warrant.heat <= 0 && current_prior(warrant) <= 0 && warrant.barred_until <= world.time) + remove_warrant(warrant) + +/// Heat just reached 0 by fading: the peak becomes the prior +/datum/outpost_police/proc/warrant_faded(datum/outpost_warrant/warrant) + if(warrant.peak > 0) + warrant.prior = warrant.peak + warrant.prior_set_at = world.time + warrant_ended(warrant, "faded") + warrant.peak = 0 + warrant.evidence = null + warrant.fine_level = 0 + +/// Whether an officer sees `body` now, or a checkpoint scan has them +/datum/outpost_police/proc/suspect_seen(mob/living/body) + for(var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer in officers) + if(officer.stat != DEAD && perception.sees(officer, body)) + return TRUE + return perception.scanned(body) + +/// Every living crewmate of someone at four stars or more, at the outpost, is an accomplice: two stars, arrested +/datum/outpost_police/proc/abet_crew(datum/outpost_warrant/leader) + var/datum/voidcrew_crew_record/crew = leader.crew + if(QDELETED(crew)) + return + for(var/datum/mind/mate as anything in crew.minds) + if(mate == leader.mind || get_stars(mate) >= 2) + continue + var/mob/living/body = mate.current + if(!isliving(body) || body.stat == DEAD || !holder_at_outpost(body)) + continue + count_crime(body, OUTPOST_CRIME_ACCOMPLICE, derived = TRUE) + +// ========================================================================= +// KILLINGS +// ========================================================================= + +/// Starts listening for deaths (police_core.dm's New() reaches this through make_perception()) +/datum/outpost_police/proc/hook_events() + if(events_hooked) + return + events_hooked = TRUE + RegisterSignal(SSdcs, COMSIG_GLOB_MOB_DEATH, PROC_REF(on_wanted_death)) + +/** + * Someone died. An officer of ours going down is announced whoever did it; a visitor, an ambient person or an + * officer who dies within OUTPOST_POLICE_KILL_WINDOW of a counted hit from someone with a record at this + * outpost is that person's MURDER, KILL_CIVILIAN or KILL_OFFICER. The hit was seen already, so the killing is + * not tested for witnesses again. + */ +/datum/outpost_police/proc/on_wanted_death(datum/source, mob/living/dead, gibbed) + SIGNAL_HANDLER + if(QDELETED(src) || !outpost || !istype(dead)) + return + var/is_ours = (dead in officers) + if(!is_ours && !length(warrants)) + return + var/crime + if(is_ours) + crime = OUTPOST_CRIME_KILL_OFFICER + else if(istype(dead, /mob/living/carbon/human/npc/ambient_npc/outpost/police)) + return + else + var/turf/where = get_turf(dead) + if(!where || get_trader_outpost_for_turf(where) != outpost) + return + if(istype(dead, /mob/living/carbon/human/npc/ambient_npc)) + crime = OUTPOST_CRIME_KILL_CIVILIAN + else if(dead.mind) + crime = OUTPOST_CRIME_MURDER + else + return + var/datum/weakref/dead_ref = WEAKREF(dead) + var/mob/living/killer + for(var/datum/mind/mind as anything in warrants.Copy()) + var/datum/outpost_warrant/warrant = warrants[mind] + var/hit_at = warrant?.recent_victims?[dead_ref] + if(!hit_at || world.time - hit_at > OUTPOST_POLICE_KILL_WINDOW) + continue + warrant.recent_victims -= dead_ref + var/mob/living/offender = mind.current + if(!isliving(offender)) + continue + count_crime(offender, crime, dead, derived = TRUE) + if(is_ours) + killer = offender + warrant.officers_killed++ + officer_killed_by(warrant) + if(is_ours) + SEND_SIGNAL(src, COMSIG_OUTPOST_POLICE_OFFICER_DOWN, dead, killer) + +/// `warrant`'s holder killed an officer: the embargo of their crew runs longer +/datum/outpost_police/proc/officer_killed_by(datum/outpost_warrant/warrant) + record_metric(METRIC_COMBAT, "police_officer_killed", actor = warrant.mind?.current, quantity = 1) + var/datum/outpost_incident/incident = warrant.crew ? incidents[warrant.crew] : null + if(!incident) + return + incident.officers_killed++ + incident.ends_at = min(incident.ends_at + OUTPOST_POLICE_EMBARGO_PER_KILL, incident.started_at + OUTPOST_POLICE_EMBARGO_MAX) + var/list/places = incident.stars >= 5 ? GLOB.trader_outposts : list(outpost) + for(var/obj/structure/overmap/trader_outpost/place as anything in places) + for(var/obj/structure/overmap/ship/ship as anything in crew_ship_list(incident.crew)) + if(place.embargoed_ships[ship]) + place.embargoed_ships[ship] = incident.ends_at + +// ========================================================================= +// OFFICERS STRUCK (abuse M3; called from the officer's body, police_units.dm) +// ========================================================================= + +/** + * An officer was hit (relay_attackers): a stamina weapon or disabler, or a weapon or bullet, is ASSAULT_OFFICER; + * a shove, or bare fists, is RESIST (abuse M3). `source` is the weapon when the caller knows it; without it the + * attacker's held weapon is taken. + */ +/datum/outpost_police/proc/officer_struck(mob/living/officer, atom/attacker, attack_flags, obj/item/source) + SHOULD_NOT_SLEEP(TRUE) + if(!isliving(attacker) || attacker == officer) + return + var/mob/living/hitter = attacker + if(!source) + source = held_weapon_of(hitter) + var/crime + if(attack_flags & ATTACKER_STAMINA_ATTACK) + crime = OUTPOST_CRIME_ASSAULT_OFFICER + else if(attack_flags & ATTACKER_SHOVING) + crime = OUTPOST_CRIME_RESIST + else if(attack_flags & ATTACKER_DAMAGING_ATTACK) + crime = source ? OUTPOST_CRIME_ASSAULT_OFFICER : OUTPOST_CRIME_RESIST + else + return + report_crime(hitter, crime, officer, crime == OUTPOST_CRIME_ASSAULT_OFFICER ? source : null) + +/// Someone tried to cuff an officer: ASSAULT_OFFICER (abuse M3) +/datum/outpost_police/proc/officer_cuff_attempt(mob/living/officer, mob/living/user) + SHOULD_NOT_SLEEP(TRUE) + if(!isliving(user) || user == officer) + return + report_crime(user, OUTPOST_CRIME_ASSAULT_OFFICER, officer) + +/// The weapon `hitter` holds, if it is one +/datum/outpost_police/proc/held_weapon_of(mob/living/hitter) + var/obj/item/held = hitter.get_active_held_item() + if(held && (held.force > 0 || istype(held, /obj/item/gun))) + return held + return null + +// ========================================================================= +// THE FINE (abuse M6, M7) +// ========================================================================= + +/// What `warrant` owes: the fine for its stars at booking (1 or 2), times the zone. The level is fixed at the first ask. +/datum/outpost_police/proc/fine_amount(datum/outpost_warrant/warrant) + if(!warrant.fine_level) + warrant.fine_level = clamp(stars_of(warrant.heat), 1, 2) + var/list/fines = OUTPOST_POLICE_FINES + return round(fines[warrant.fine_level] * profile.fine_multiplier) + +/** + * The fine for `warrant`'s stars and the zone, from the ship accounts of the crew it remembers, pooled, richest + * first, all or nothing. TRUE with the money gone, or FALSE with nothing taken. Never sleeps; no debt anywhere. + */ +/datum/outpost_police/proc/charge_fine(datum/outpost_warrant/warrant) + SHOULD_NOT_SLEEP(TRUE) + if(!warrant || QDELETED(warrant.crew)) + return FALSE + var/amount = fine_amount(warrant) + if(amount <= 0) + return TRUE + var/list/datum/bank_account/accounts = list() + var/pool = 0 + for(var/obj/structure/overmap/ship/ship as anything in crew_ship_list(warrant.crew)) + var/datum/bank_account/account = ship.ship_account + // A locked account can't be spent from, whatever it holds + if(!account || (account in accounts) || account.account_balance <= 0 || !account.has_money(account.account_balance)) + continue + accounts += account + pool += account.account_balance + if(pool < amount) + return FALSE + // Richest first + var/list/datum/bank_account/ordered = list() + while(length(accounts)) + var/datum/bank_account/richest = accounts[1] + for(var/datum/bank_account/account as anything in accounts) + if(account.account_balance > richest.account_balance) + richest = account + ordered += richest + accounts -= richest + var/reason = "Fine: [profile.force_name]" + var/remaining = amount + var/list/taken = list() + for(var/datum/bank_account/account as anything in ordered) + var/part = min(remaining, account.account_balance) + if(part <= 0) + continue + if(!account.adjust_money(-part, reason)) + for(var/datum/bank_account/refund as anything in taken) + refund.adjust_money(taken[refund], "[reason] (refunded)") + return FALSE + taken[account] = part + remaining -= part + if(remaining <= 0) + break + if(remaining > 0) + for(var/datum/bank_account/refund as anything in taken) + refund.adjust_money(taken[refund], "[reason] (refunded)") + return FALSE + return TRUE + +/// The ships of `crew`'s hulls that still exist +/datum/outpost_police/proc/crew_ship_list(datum/voidcrew_crew_record/crew) + . = list() + for(var/datum/team/voidcrew/team in crew?.teams) + if(team.ship && !QDELETED(team.ship)) + . += team.ship + +// ========================================================================= +// FOUR AND FIVE STARS: THE EMBARGO AND THE CUTTER (spec 9) +// ========================================================================= + +/// `warrant` reached four or five stars: its crew is embargoed, and the cutter follows its ship out +/datum/outpost_police/proc/escalate_incident(datum/outpost_warrant/warrant, stars) + var/datum/voidcrew_crew_record/crew = warrant.crew + if(QDELETED(crew)) + return + var/datum/outpost_incident/incident = incidents[crew] + if(incident && world.time >= incident.ends_at) + end_incident(incident) + incident = null + if(!incident) + incident = new + incident.crew = crew + incident.started_at = world.time + incident.officers_killed = warrant.officers_killed + incidents[crew] = incident + watch_crew(crew) + if(stars <= incident.stars) + return + incident.stars = stars + var/list/lengths = OUTPOST_POLICE_EMBARGO + var/length_for = lengths[stars - 3] + incident.officers_killed * OUTPOST_POLICE_EMBARGO_PER_KILL + incident.ends_at = min(max(incident.ends_at, world.time + length_for), incident.started_at + OUTPOST_POLICE_EMBARGO_MAX) + var/list/places = stars >= 5 ? GLOB.trader_outposts : list(outpost) + for(var/obj/structure/overmap/trader_outpost/place as anything in places) + for(var/obj/structure/overmap/ship/ship as anything in crew_ship_list(crew)) + place.embargo_ship(ship, max(1, incident.ends_at - world.time)) + sync_incident(incident) + record_metric(METRIC_COMBAT, "police_embargo", actor = warrant.mind?.current, points = stars, quantity = round((incident.ends_at - world.time) / (1 MINUTES))) + +/// Listens to every ship of the incident's crew, and notes the ones already in a berth here +/datum/outpost_police/proc/sync_incident(datum/outpost_incident/incident) + for(var/obj/structure/overmap/ship/ship as anything in crew_ship_list(incident.crew)) + if(ship in incident.watched) + continue + incident.watched += ship + RegisterSignal(ship, COMSIG_VOIDCREW_SHIP_DOCKED, PROC_REF(on_watched_ship_docked)) + RegisterSignal(ship, COMSIG_VOIDCREW_SHIP_UNDOCKED, PROC_REF(on_watched_ship_undocked)) + RegisterSignal(ship, COMSIG_QDELETING, PROC_REF(on_watched_ship_deleted)) + if(ship_in_berth(ship)) + incident.docked[ship] = TRUE + // Crewmates who walked in past the embargo are barred from secured turf: an arrest + for(var/datum/mind/mate as anything in incident.crew.minds) + var/mob/living/body = mate.current + if(!isliving(body) || body.stat == DEAD || warrants[mate] || !holder_at_outpost(body)) + continue + if(outpost.is_secured_turf(get_turf(body)) && barred_by_record(body) && suspect_seen(body)) + count_crime(body, OUTPOST_CRIME_TRESPASS) + +/// The incident is over: stop listening and forget it +/datum/outpost_police/proc/end_incident(datum/outpost_incident/incident) + for(var/obj/structure/overmap/ship/ship as anything in incident.watched) + UnregisterSignal(ship, list(COMSIG_VOIDCREW_SHIP_DOCKED, COMSIG_VOIDCREW_SHIP_UNDOCKED, COMSIG_QDELETING)) + incident.watched.Cut() + incident.docked.Cut() + if(incidents[incident.crew] == incident) + incidents -= incident.crew + incident.crew = null + qdel(incident) + +/// Whether `ship` holds one of this outpost's hangar berths +/datum/outpost_police/proc/ship_in_berth(obj/structure/overmap/ship/ship) + for(var/datum/outpost_berth/berth as anything in outpost.berths) + if(berth?.ship == ship) + return TRUE + return FALSE + +/// The incident that listens to `ship`, or null +/datum/outpost_police/proc/incident_of(obj/structure/overmap/ship/ship) + for(var/crew in incidents) + var/datum/outpost_incident/incident = incidents[crew] + if(incident && (ship in incident.watched)) + return incident + return null + +/datum/outpost_police/proc/on_watched_ship_docked(obj/structure/overmap/ship/ship) + SIGNAL_HANDLER + var/datum/outpost_incident/incident = incident_of(ship) + if(incident && ship_in_berth(ship)) + incident.docked[ship] = TRUE + +/// A ship of the crew finished undocking: if it left this outpost, the cutter follows, once per incident +/datum/outpost_police/proc/on_watched_ship_undocked(obj/structure/overmap/ship/ship) + SIGNAL_HANDLER + var/datum/outpost_incident/incident = incident_of(ship) + if(!incident || !incident.docked[ship]) + return + incident.docked -= ship + if(incident.pursued || world.time >= incident.ends_at) + return + incident.pursued = TRUE + INVOKE_ASYNC(src, PROC_REF(start_pursuit), ship, incident.stars, incident.officers_killed) + +/datum/outpost_police/proc/on_watched_ship_deleted(obj/structure/overmap/ship/ship) + SIGNAL_HANDLER + var/datum/outpost_incident/incident = incident_of(ship) + if(!incident) + return + incident.watched -= ship + incident.docked -= ship + +// ========================================================================= +// WHAT THE SERGEANT SAYS ON THE RADIO (M15: type names and catalog names only) +// ========================================================================= + +/// The tokens the radio call fills: the nearest shop's catalog name and what the suspect wears, by type name +/datum/outpost_police/proc/dispatch_values(mob/living/suspect) + return list("%PLACE%" = nearest_shop_name(suspect), "%LOOK%" = suspect_look(suspect)) + +/// The catalog name of the shop whose trader stands nearest `suspect` +/datum/outpost_police/proc/nearest_shop_name(mob/living/suspect) + var/best_name = outpost.name + var/best_dist = INFINITY + for(var/datum/outpost_shop/shop as anything in outpost.get_all_shops()) + if(!shop?.trader_npc) + continue + var/dist = get_dist(shop.trader_npc, suspect) + if(dist < best_dist) + best_dist = dist + best_name = shop.outpost_name + return police_plain_name(best_name) + +/// What `suspect` wears over their uniform, or the uniform: the item's type name +/datum/outpost_police/proc/suspect_look(mob/living/suspect) + if(!ishuman(suspect)) + return "someone" + var/mob/living/carbon/human/person = suspect + var/obj/item/clothing/worn = person.wear_suit || person.w_uniform + if(!worn) + return "someone" + return police_plain_name(initial(worn.name)) + +/// `text` without the \proper and \improper markers +/proc/police_plain_name(text) + return replacetext(replacetext("[text]", "\proper", ""), "\improper", "") diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_police_wanted.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_police_wanted.dm index 6c89e41b33c..3a62d066e1b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_outpost_police_wanted.dm +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_police_wanted.dm @@ -1,5 +1,638 @@ /** - * Outpost police tests: police_wanted.dm (voidcrew/modules/outpost_police/). Owner: P1. + * Outpost police tests: police_wanted.dm and police_perception.dm (voidcrew/modules/outpost_police/). Owner: P1. * Helpers are in voidcrew_outpost_police_helpers.dm. Fork defines are included after the tests: use * literal values with a comment naming the define. + * + * Every test builds a fake trader outpost around the test room (the self-defense test's pattern), calls the + * police procs with the mobs already in place and steps the clocks by writing the warrant's start times; none + * of them waits. */ + +// ===== FIXTURES ===== + +/// A fake trader outpost over the test room (a `size` by `size` template from the room's corner), with its police +/datum/unit_test/proc/wanted_outpost(size = 20) + var/obj/structure/overmap/trader_outpost/outpost = allocate(/obj/structure/overmap/trader_outpost) + outpost.outpost_template = allocate(/datum/map_template/trader_outpost) + outpost.outpost_template.width = size + outpost.outpost_template.height = size + outpost.template_bottom_left = run_loc_floor_bottom_left + return outpost + +/// The test room's tile `dx`, `dy` from its corner +/datum/unit_test/proc/wanted_spot(dx, dy) + return locate(run_loc_floor_bottom_left.x + dx, run_loc_floor_bottom_left.y + dy, run_loc_floor_bottom_left.z) + +/// A conscious human with a mind at `where` +/datum/unit_test/proc/wanted_person(turf/where) + var/mob/living/carbon/human/person = allocate(/mob/living/carbon/human/consistent, where) + person.mind_initialize() + allocated += person.mind + return person + +/** + * A ship with an account holding `balance`. With `person`, they join its crew; with `join_record`, the ship's + * team takes that crew record (a second ship of one crew). + */ +/datum/unit_test/proc/wanted_ship(mob/living/carbon/human/person, balance = 0, datum/voidcrew_crew_record/join_record) + var/obj/structure/overmap/ship/ship = allocate(/obj/structure/overmap/ship) + var/datum/team/voidcrew/team = allocate(/datum/team/voidcrew) + team.ship = ship + ship.ship_team = team + var/datum/bank_account/account = allocate(/datum/bank_account, "Police test [REF(ship)]", null, 1, FALSE) + account.account_balance = balance + ship.ship_account = account + if(person) + team.join_member(person.mind) + else if(join_record) + team.set_crew_record(join_record) + return ship + +/// Takes the ships' teams and their crew records apart, or none of them is ever collected +/datum/unit_test/proc/wanted_cleanup(list/ships) + for(var/obj/structure/overmap/ship/ship as anything in ships) + var/datum/team/voidcrew/team = ship.ship_team + if(!team) + continue + var/datum/voidcrew_crew_record/record = team.crew_record + for(var/datum/mind/member as anything in team.members.Copy()) + team.remove_member(member) + team.ship = null + ship.ship_team = null + if(record) + qdel(record) + +/// A beat officer on `outpost`'s roster at `where` +/datum/unit_test/proc/wanted_officer(obj/structure/overmap/trader_outpost/outpost, turf/where) + return police_officer(outpost, /mob/living/carbon/human/npc/ambient_npc/outpost/police/beat, where) + +// ===== THE HEAT TABLE ===== + +/// The sim's 22 rows (balance/output_final.txt): the same heat and the same stars +/datum/unit_test/voidcrew_outpost_police_heat_table + +/datum/unit_test/voidcrew_outpost_police_heat_table/Run() + var/obj/structure/overmap/trader_outpost/outpost = wanted_outpost() + var/datum/outpost_police/police = outpost.police + TEST_ASSERT_NOTNULL(police, "The fake outpost has no police") + // name, crimes (OUTPOST_CRIME_*), heat, stars + var/list/rows = list( + list("one shove", list("unarmed"), 6, 0), + list("two punches", list("unarmed", "unarmed"), 12, 1), + list("bar fight, 6 punches", list("unarmed", "unarmed", "unarmed", "unarmed", "unarmed", "unarmed"), 36, 2), + list("one stab", list("armed"), 9, 0), + list("two stabs", list("armed", "armed"), 18, 1), + list("three stabs", list("armed", "armed", "armed"), 27, 2), + list("one shot into a visitor", list("shot"), 25, 2), + list("two shots into a visitor", list("shot", "shot"), 50, 3), + list("shoot a visitor dead", list("shot", "shot", "murder"), 110, 4), + list("kick a vending machine 10x", list("vandalism", "vandalism", "vandalism", "vandalism", "vandalism", "vandalism", "vandalism", "vandalism", "vandalism", "vandalism"), 49, 2), + list("gun drawn past the checkpoint", list("weapon_drawn"), 10, 1), + list("gun drawn, then fired twice", list("weapon_drawn", "gunfire", "gunfire"), 40, 2), + list("forced checkpoint", list("breach"), 25, 2), + list("punch an officer once", list("resist"), 8, 0), + list("2 punches, then shove the officer 3x", list("unarmed", "unarmed", "resist", "resist", "resist"), 36, 2), + list("shoot an officer", list("officer"), 50, 3), + list("kill one officer", list("officer", "officer_kill"), 95, 4), + list("kill two officers", list("officer", "officer_kill", "officer_kill"), 140, 5), + list("kill three officers", list("officer", "officer_kill", "officer_kill", "officer_kill"), 180, 5), + list("murder, then kill one officer", list("shot", "murder", "officer", "officer_kill"), 160, 5), + list("break out of the cell", list("unarmed", "unarmed", "jailbreak"), 50, 3), + list("crewmate of a 4-star offender", list("accomplice"), 25, 2), + ) + for(var/list/row as anything in rows) + var/heat = 0 + for(var/crime in row[2]) + heat = police.heat_after(heat, crime) + TEST_ASSERT_EQUAL(heat, row[3], "'[row[1]]' came to [heat] heat, not [row[3]]") + TEST_ASSERT_EQUAL(police.stars_of(heat), row[4], "'[row[1]]' is [police.stars_of(heat)] stars, not [row[4]]") + +/// Ceilings, floors and the cap +/datum/unit_test/voidcrew_outpost_police_ceilings + +/datum/unit_test/voidcrew_outpost_police_ceilings/Run() + var/datum/outpost_police/police = wanted_outpost().police + TEST_ASSERT_EQUAL(police.heat_after(60, "vandalism"), 60, "A crime with a two-star ceiling lowered a three-star warrant") + TEST_ASSERT_EQUAL(police.heat_after(40, "gunfire"), 49, "A two-star ceiling let heat reach three stars") + TEST_ASSERT_EQUAL(police.heat_after(30, "gunfire"), 45, "A crime under its ceiling did not add its points") + TEST_ASSERT_EQUAL(police.heat_after(0, "jailbreak"), 50, "A three-star floor did not lift an empty warrant") + TEST_ASSERT_EQUAL(police.heat_after(0, "weapon_drawn"), 10, "A drawn gun is not one star") + TEST_ASSERT_EQUAL(police.heat_after(175, "officer_kill"), 180, "Heat went past the cap") + TEST_ASSERT_EQUAL(police.heat_after(0, "murder"), 60, "A killing is not at least three stars") + +// ===== WITNESSES ===== + +/// A crime counts only when someone saw it; the victim is a witness unless they are a crewmate or cannot see +/datum/unit_test/voidcrew_outpost_police_witness + +/datum/unit_test/voidcrew_outpost_police_witness/Run() + var/obj/structure/overmap/trader_outpost/outpost = wanted_outpost() + var/datum/outpost_police/police = outpost.police + var/mob/living/carbon/human/stranger = wanted_person(wanted_spot(1, 0)) + + // A conscious victim is a witness + var/mob/living/carbon/human/puncher = wanted_person(wanted_spot(0, 0)) + var/result = police.report_crime(puncher, "unarmed", stranger) + TEST_ASSERT_NOTNULL(result, "A punch in front of its victim did not count") + TEST_ASSERT_EQUAL(result, 0, "One punch is not under one star") + TEST_ASSERT_EQUAL(police.warrants[puncher.mind].heat, 6, "A counted punch is not 6 heat") + + // Nobody there, no heat + var/mob/living/carbon/human/hidden = wanted_person(wanted_spot(0, 1)) + var/mob/living/carbon/human/mindless = allocate(/mob/living/carbon/human/consistent, wanted_spot(1, 1)) + TEST_ASSERT_NULL(police.report_crime(hidden, "unarmed", mindless), "A punch on someone with no mind, seen by nobody, counted") + TEST_ASSERT_NULL(police.warrants[hidden.mind], "An unseen crime made a warrant") + TEST_ASSERT_NULL(police.report_crime(hidden, "armed"), "A crime against nobody, seen by nobody, counted") + + // A crewmate victim is not a witness + var/mob/living/carbon/human/captain = wanted_person(wanted_spot(0, 2)) + var/mob/living/carbon/human/crewmate = wanted_person(wanted_spot(1, 2)) + var/obj/structure/overmap/ship/ship = wanted_ship(captain) + ship.ship_team.join_member(crewmate.mind) + TEST_ASSERT_NULL(police.report_crime(captain, "unarmed", crewmate), "A crewmate victim witnessed their own crewmate") + + // An unconscious victim is not a witness + var/mob/living/carbon/human/knocked = wanted_person(wanted_spot(2, 0)) + var/mob/living/carbon/human/sleeper = wanted_person(wanted_spot(2, 1)) + sleeper.stat = UNCONSCIOUS + TEST_ASSERT_NULL(police.report_crime(knocked, "unarmed", sleeper), "An unconscious victim witnessed the crime") + sleeper.stat = CONSCIOUS + + // The scanner and the cell need no one in sight + var/mob/living/carbon/human/armed = wanted_person(wanted_spot(2, 2)) + TEST_ASSERT_EQUAL(police.report_crime(armed, "weapon_drawn"), 1, "A drawn gun with nobody about is not one star") // OUTPOST_CRIME_WEAPON_DRAWN + + // A crime on an officer needs a living officer + var/mob/living/carbon/human/brawler = wanted_person(wanted_spot(3, 0)) + TEST_ASSERT_NULL(police.report_crime(brawler, "resist"), "A shove on an officer counted with no officer alive") // OUTPOST_CRIME_RESIST + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = wanted_officer(outpost, wanted_spot(3, 3)) + var/mob/living/carbon/human/brawler_two = wanted_person(wanted_spot(3, 1)) + TEST_ASSERT_NOTNULL(police.report_crime(brawler_two, "resist", officer), "A shove on an officer did not count with an officer alive") + officer.stat = DEAD + var/mob/living/carbon/human/brawler_three = wanted_person(wanted_spot(3, 2)) + TEST_ASSERT_NULL(police.report_crime(brawler_three, "resist", officer), "A shove on a dead officer, with none left alive, counted") + officer.stat = CONSCIOUS + wanted_cleanup(list(ship)) + +/// A person with no mind cannot have a record +/datum/unit_test/voidcrew_outpost_police_no_mind + +/datum/unit_test/voidcrew_outpost_police_no_mind/Run() + var/datum/outpost_police/police = wanted_outpost().police + var/mob/living/carbon/human/mindless = allocate(/mob/living/carbon/human/consistent, wanted_spot(0, 0)) + var/mob/living/carbon/human/victim = wanted_person(wanted_spot(1, 0)) + TEST_ASSERT_NULL(police.report_crime(mindless, "unarmed", victim), "Someone with no mind got a record") + TEST_ASSERT_EQUAL(length(police.warrants), 0, "A mindless offender made a warrant") + +// ===== DEDUPE AND EVIDENCE ===== + +/// Same offender within half a second folds; a folded report still lends its weapon; only a counted crime's weapon is evidence, eight at most +/datum/unit_test/voidcrew_outpost_police_dedupe + +/datum/unit_test/voidcrew_outpost_police_dedupe/Run() + var/datum/outpost_police/police = wanted_outpost().police + var/mob/living/carbon/human/stabber = wanted_person(wanted_spot(0, 0)) + var/mob/living/carbon/human/victim = wanted_person(wanted_spot(1, 0)) + var/obj/item/crowbar/first = allocate(/obj/item/crowbar) + var/obj/item/wrench/second = allocate(/obj/item/wrench) + + TEST_ASSERT_EQUAL(police.report_crime(stabber, "armed", victim, first), 0, "The first stab did not count") + var/datum/outpost_warrant/warrant = police.warrants[stabber.mind] + TEST_ASSERT_EQUAL(warrant.heat, 9, "One stab is not 9 heat") + TEST_ASSERT(WEAKREF(first) in warrant.evidence, "The weapon of a counted crime is not evidence") + // OUTPOST_AGGRESSION_GRACE is half a second + TEST_ASSERT_NULL(police.report_crime(stabber, "armed", victim, second), "A second report in the same instant counted") + TEST_ASSERT_EQUAL(warrant.heat, 9, "A folded report added heat") + TEST_ASSERT(WEAKREF(second) in warrant.evidence, "A folded report's weapon was lost") + police.recent_reports.Cut() + TEST_ASSERT_EQUAL(police.report_crime(stabber, "armed", victim, first), 1, "A report after the grace did not count") + TEST_ASSERT_EQUAL(warrant.heat, 18, "Two stabs are not 18 heat") + TEST_ASSERT_EQUAL(length(warrant.evidence), 2, "The same weapon was listed twice") + + // An unwitnessed crime's weapon is not evidence + var/mob/living/carbon/human/lurker = wanted_person(wanted_spot(0, 1)) + TEST_ASSERT_NULL(police.report_crime(lurker, "armed", null, first), "An unseen stab counted") + TEST_ASSERT_NULL(police.warrants[lurker.mind], "An unseen stab made a warrant") + + // Eight at most, the oldest out + var/list/tools = list() + for(var/i in 1 to 9) + var/obj/item/crowbar/tool = allocate(/obj/item/crowbar) + tools += tool + police.recent_reports.Cut() + police.report_crime(stabber, "armed", victim, tool) + TEST_ASSERT_EQUAL(length(warrant.evidence), 8, "A warrant remembers [length(warrant.evidence)] weapons, not 8") // OUTPOST_POLICE_EVIDENCE_MAX + TEST_ASSERT(!(WEAKREF(tools[1]) in warrant.evidence), "The oldest weapon was kept past the cap") + TEST_ASSERT(WEAKREF(tools[9]) in warrant.evidence, "The newest weapon was dropped") + +// ===== A WITNESS WHO TAKES TIME TO REPORT ===== + +/// A perception that makes its witness wait three seconds, and can be told the witness can no longer report +/datum/outpost_police_perception/test_delayed + var/witness_can_report = TRUE + +/datum/outpost_police_perception/test_delayed/report_delay(atom/witness, crime) + return 3 SECONDS + +/datum/outpost_police_perception/test_delayed/can_report(atom/witness) + return witness_can_report + +/// A report with a delay is queued and confirmed on the tick if its witness can still report; otherwise it is dropped +/datum/unit_test/voidcrew_outpost_police_pending + +/datum/unit_test/voidcrew_outpost_police_pending/Run() + var/datum/outpost_police/police = wanted_outpost().police + var/datum/outpost_police_perception/test_delayed/slow = new(police) + qdel(police.perception) + police.perception = slow + + var/mob/living/carbon/human/victim = wanted_person(wanted_spot(1, 0)) + var/mob/living/carbon/human/reported = wanted_person(wanted_spot(0, 0)) + TEST_ASSERT_NULL(police.report_crime(reported, "unarmed", victim), "A report with a delay counted at once") + TEST_ASSERT_EQUAL(length(police.pending_reports), 1, "A report with a delay was not queued") + TEST_ASSERT_NULL(police.warrants[reported.mind], "A queued report made a warrant before its witness finished") + var/datum/outpost_pending_report/report = police.pending_reports[1] + report.due = world.time + police.tick_warrants() + TEST_ASSERT_EQUAL(length(police.pending_reports), 0, "A due report stayed queued") + TEST_ASSERT_EQUAL(police.warrants[reported.mind]?.heat, 6, "A due report was not confirmed") + + var/mob/living/carbon/human/silenced = wanted_person(wanted_spot(0, 1)) + TEST_ASSERT_NULL(police.report_crime(silenced, "unarmed", victim), "A second report with a delay counted at once") + slow.witness_can_report = FALSE + var/datum/outpost_pending_report/dropped = police.pending_reports[1] + dropped.due = world.time + police.tick_warrants() + TEST_ASSERT_NULL(police.warrants[silenced.mind], "A witness who could no longer report still got a warrant made") + TEST_ASSERT_EQUAL(length(police.pending_reports), 0, "A dropped report stayed queued") + +// ===== SELF-DEFENCE (abuse M14) ===== + +/// The victim's self-defence window opens whether or not anyone saw the first hit, and hitting back inside it records nothing +/datum/unit_test/voidcrew_outpost_police_self_defence + +/datum/unit_test/voidcrew_outpost_police_self_defence/Run() + var/obj/structure/overmap/trader_outpost/outpost = wanted_outpost() + var/datum/outpost_police/police = outpost.police + var/mob/living/carbon/human/attacker = wanted_person(wanted_spot(0, 0)) + var/mob/living/carbon/human/defender = wanted_person(wanted_spot(1, 0)) + // The only person who could have seen it is out cold + defender.stat = UNCONSCIOUS + outpost.register_pvp_aggression(defender, attacker, "unarmed") + TEST_ASSERT_NULL(police.warrants[attacker.mind], "An unseen first hit made a warrant") + var/list/defender_targets = outpost.self_defense_targets[defender.mind] + // OUTPOST_SELF_DEFENSE_DURATION is 2 minutes + TEST_ASSERT_EQUAL(defender_targets[attacker.mind], world.time + 2 MINUTES, "An unseen first hit did not open the victim's window") + defender.stat = CONSCIOUS + outpost.register_pvp_aggression(attacker, defender, "unarmed") + TEST_ASSERT_NULL(police.warrants[defender.mind], "Hitting back inside the window made the defender a suspect") + TEST_ASSERT_NULL(outpost.self_defense_targets[attacker.mind], "Hitting back gave the attacker a window of their own") + // A hit by the attacker, with the defender watching, counts + outpost.register_pvp_aggression(defender, attacker, "unarmed") + TEST_ASSERT_NOTNULL(police.warrants[attacker.mind], "A seen hit by the first aggressor did not count") + +// ===== FADING, PRIORS AND THE BAR ===== + +/// Hide 30 s at one star and 45 at two, nothing at three at the outpost, away 10 s whatever the level; the prior; the bar; an empty warrant is dropped +/datum/unit_test/voidcrew_outpost_police_fade + +/datum/unit_test/voidcrew_outpost_police_fade/Run() + // A template of one tile: the corner is the outpost, the rest of the room is away + var/obj/structure/overmap/trader_outpost/outpost = wanted_outpost(1) + var/datum/outpost_police/police = outpost.police + var/mob/living/carbon/human/suspect = wanted_person(wanted_spot(0, 0)) + var/mob/living/carbon/human/witness = wanted_person(wanted_spot(3, 3)) + var/datum/outpost_warrant/warrant = police_pin_warrant(police, suspect.mind, 15) + + // One star: hidden 29 s, nothing; 30 s, a point a second + warrant.unseen_since = world.time - 29 SECONDS // OUTPOST_POLICE_HIDE_1 is 30 seconds + police.tick_warrants() + TEST_ASSERT_EQUAL(warrant.heat, 15, "Heat faded before the one-star hide time") + warrant.unseen_since = world.time - 30 SECONDS + police.tick_warrants() + TEST_ASSERT_EQUAL(warrant.heat, 14, "Heat did not fade after the one-star hide time") + + // Two stars: 45 s + warrant.heat = 30 + warrant.unseen_since = world.time - 44 SECONDS // OUTPOST_POLICE_HIDE_2 is 45 seconds + police.tick_warrants() + TEST_ASSERT_EQUAL(warrant.heat, 30, "Heat faded before the two-star hide time") + warrant.unseen_since = world.time - 45 SECONDS + police.tick_warrants() + TEST_ASSERT_EQUAL(warrant.heat, 29, "Heat did not fade after the two-star hide time") + + // Three stars at the outpost never fade, and the bar starts to run + warrant.heat = 60 + warrant.unseen_since = world.time - 10 MINUTES + police.tick_warrants() + TEST_ASSERT_EQUAL(warrant.heat, 60, "Heat faded at three stars with the suspect at the outpost") + TEST_ASSERT_EQUAL(warrant.barred_until, world.time + 15 MINUTES, "Three stars did not start the 15 minute bar") // OUTPOST_POLICE_BAR_TIME + + // Away: 9 s, nothing; 10 s, a point a second, at any level + suspect.forceMove(wanted_spot(2, 2)) + police.tick_warrants() + TEST_ASSERT(warrant.away_since, "Leaving the outpost did not start the away clock") + warrant.away_since = world.time - 9 SECONDS // OUTPOST_POLICE_AWAY_GRACE is 10 seconds + police.tick_warrants() + TEST_ASSERT_EQUAL(warrant.heat, 60, "Heat faded before the away grace") + warrant.away_since = world.time - 10 SECONDS + police.tick_warrants() + TEST_ASSERT_EQUAL(warrant.heat, 59, "Heat did not fade away from the outpost") + + // Fading to nothing leaves a prior, and the next crime starts from it + warrant.heat = 1 + warrant.peak = 36 + police.tick_warrants() + TEST_ASSERT_EQUAL(warrant.heat, 0, "Heat did not reach 0") + TEST_ASSERT_EQUAL(warrant.prior, 36, "A faded warrant did not leave its peak as a prior") + TEST_ASSERT_NOTNULL(police.warrants[suspect.mind], "A warrant with a prior was dropped") + TEST_ASSERT_EQUAL(police.report_crime(suspect, "unarmed", witness), 2, "A punch on top of a prior of 36 is not two stars") + TEST_ASSERT_EQUAL(warrant.heat, 42, "The next crime did not start from the prior") + warrant.prior_set_at = world.time - 10 MINUTES // half of OUTPOST_POLICE_PRIOR_TIME + TEST_ASSERT_EQUAL(police.current_prior(warrant), 18, "The prior did not wear down linearly") + warrant.prior_set_at = world.time - 20 MINUTES + TEST_ASSERT_EQUAL(police.current_prior(warrant), 0, "The prior outlived its time") + + // A bar refuses service with no stars, and the empty warrant goes when it ends + warrant.heat = 0 + warrant.prior = 0 + warrant.barred_until = world.time + 5 MINUTES + TEST_ASSERT_EQUAL(police.get_stars(suspect.mind), 0, "A barred person with no heat has stars") + TEST_ASSERT(police.is_barred(suspect), "A bar did not refuse service") + police.tick_warrants() + TEST_ASSERT_NOTNULL(police.warrants[suspect.mind], "A warrant was dropped while its bar ran") + warrant.barred_until = world.time - 1 + police.tick_warrants() + TEST_ASSERT_NULL(police.warrants[suspect.mind], "A warrant with no heat, prior or bar was kept") + TEST_ASSERT(!police.is_barred(suspect), "A finished bar still refused service") + +/// An officer in sight keeps the suspect seen: nothing fades at one star while they stay in view +/datum/unit_test/voidcrew_outpost_police_seen + +/datum/unit_test/voidcrew_outpost_police_seen/Run() + var/obj/structure/overmap/trader_outpost/outpost = wanted_outpost() + var/datum/outpost_police/police = outpost.police + var/mob/living/carbon/human/suspect = wanted_person(wanted_spot(0, 0)) + var/datum/outpost_warrant/warrant = police_pin_warrant(police, suspect.mind, 15) + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = wanted_officer(outpost, wanted_spot(2, 0)) + warrant.unseen_since = world.time - 10 MINUTES + police.tick_warrants() + TEST_ASSERT_EQUAL(warrant.heat, 15, "Heat faded while an officer watched") + TEST_ASSERT_EQUAL(warrant.unseen_since, 0, "An officer in view did not clear the unseen clock") + TEST_ASSERT_EQUAL(warrant.last_seen_turf, get_turf(suspect), "The sighting did not record where the suspect was") + // The officer is knocked out: seen no more, and the clock starts + officer.stat = UNCONSCIOUS + police.tick_warrants() + TEST_ASSERT(warrant.unseen_since, "An unconscious officer still saw the suspect") + officer.stat = CONSCIOUS + +// ===== THE RECORD OF A CREW: ACCOMPLICES, THE EMBARGO, THE CUTTER ===== + +/// Four stars puts the crew's ships under embargo here and makes the crewmates at the outpost accomplices; five stars spreads the embargo; a dead officer lengthens it; paying clears it +/datum/unit_test/voidcrew_outpost_police_incident + /// What COMSIG_OUTPOST_POLICE_OFFICER_DOWN told us + var/list/down_log = list() + +/datum/unit_test/voidcrew_outpost_police_incident/proc/on_down(datum/source, mob/living/officer, mob/living/killer) + SIGNAL_HANDLER + down_log += list(list(officer, killer)) + +/datum/unit_test/voidcrew_outpost_police_incident/Run() + var/obj/structure/overmap/trader_outpost/outpost = wanted_outpost() + var/obj/structure/overmap/trader_outpost/other = wanted_outpost() + var/datum/outpost_police/police = outpost.police + var/mob/living/carbon/human/leader = wanted_person(wanted_spot(0, 0)) + var/mob/living/carbon/human/mate = wanted_person(wanted_spot(1, 0)) + var/mob/living/carbon/human/absent = wanted_person(wanted_spot(1, 1)) + var/obj/structure/overmap/ship/ship = wanted_ship(leader) + ship.ship_team.join_member(mate.mind) + ship.ship_team.join_member(absent.mind) + absent.moveToNullspace() + var/datum/voidcrew_crew_record/crew = leader.mind.get_reputation_crew() + TEST_ASSERT_NOTNULL(crew, "The test crew has no record") + RegisterSignal(police, "outpost_police_officer_down", PROC_REF(on_down)) // COMSIG_OUTPOST_POLICE_OFFICER_DOWN + + // Four stars + var/datum/outpost_warrant/warrant = police_pin_warrant(police, leader.mind, 100) + warrant.crew = crew + police.tick_warrants() + TEST_ASSERT(outpost.is_ship_embargoed(ship), "Four stars did not embargo the crew's ship here") + TEST_ASSERT(!other.is_ship_embargoed(ship), "Four stars embargoed the ship at another outpost") + var/datum/outpost_incident/incident = police.incidents[crew] + TEST_ASSERT_NOTNULL(incident, "Four stars made no incident") + TEST_ASSERT_EQUAL(incident.ends_at, world.time + 15 MINUTES, "The four-star embargo is not 15 minutes") // OUTPOST_POLICE_EMBARGO + TEST_ASSERT_EQUAL(police.get_stars(mate.mind), 2, "A crewmate at the outpost is not an accomplice at two stars") + TEST_ASSERT_EQUAL(police.force_level(mate), 2, "An accomplice would be shot, not arrested") // POLICE_FORCE_STUN + TEST_ASSERT_NULL(police.get_warrant(absent.mind), "A crewmate away from the outpost is an accomplice") + + // Five stars + warrant.heat = 150 + police.tick_warrants() + TEST_ASSERT(other.is_ship_embargoed(ship), "Five stars did not embargo the ship at the other outposts") + TEST_ASSERT_EQUAL(incident.ends_at, world.time + 30 MINUTES, "The five-star embargo is not 30 minutes") + + // An officer killed by the leader: five minutes more, announced + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = wanted_officer(outpost, wanted_spot(3, 3)) + police.record_victim(warrant, officer) + police.on_wanted_death(null, officer, FALSE) + TEST_ASSERT_EQUAL(warrant.officers_killed, 1, "A dead officer was not counted against their killer") + TEST_ASSERT_EQUAL(incident.ends_at, world.time + 35 MINUTES, "A dead officer did not add 5 minutes") // OUTPOST_POLICE_EMBARGO_PER_KILL + TEST_ASSERT_EQUAL(other.embargoed_ships[ship], incident.ends_at, "The longer embargo did not reach the other outposts") + TEST_ASSERT_EQUAL(warrant.heat, 180, "Killing an officer did not take the warrant to the cap") + TEST_ASSERT_EQUAL(length(down_log), 1, "The officer's death was not announced once") + TEST_ASSERT_EQUAL(down_log[1][2], leader, "The announcement did not name the killer") + + // The cutter follows the ship out, once + incident.docked[ship] = TRUE + SEND_SIGNAL(ship, "voidcrew_ship_undocked") // COMSIG_VOIDCREW_SHIP_UNDOCKED + TEST_ASSERT(incident.pursued, "A ship leaving the outpost did not send the cutter") + TEST_ASSERT_EQUAL(length(incident.docked), 0, "A ship that left is still counted as docked") + + // Paying wipes it + police.pardon_crew(crew) + TEST_ASSERT(!outpost.is_ship_embargoed(ship), "A pardon left the embargo here") + TEST_ASSERT(!other.is_ship_embargoed(ship), "A five-star pardon left the embargo at the other outposts") + TEST_ASSERT_EQUAL(length(police.warrants), 0, "A pardon left warrants") + TEST_ASSERT_EQUAL(length(police.incidents), 0, "A pardon left the incident") + wanted_cleanup(list(ship)) + +/// A killing is the killer's MURDER when the victim died within 30 seconds of a counted hit +/datum/unit_test/voidcrew_outpost_police_murder + +/datum/unit_test/voidcrew_outpost_police_murder/Run() + var/obj/structure/overmap/trader_outpost/outpost = wanted_outpost() + var/datum/outpost_police/police = outpost.police + var/mob/living/carbon/human/killer = wanted_person(wanted_spot(0, 0)) + var/mob/living/carbon/human/victim = wanted_person(wanted_spot(1, 0)) + police.report_crime(killer, "unarmed", victim) + var/datum/outpost_warrant/warrant = police.warrants[killer.mind] + TEST_ASSERT_EQUAL(warrant.heat, 6, "The test's first hit is not 6 heat") + police.on_wanted_death(null, victim, FALSE) + TEST_ASSERT_EQUAL(warrant.heat, 66, "A victim dying right after a hit was not a murder") // OUTPOST_CRIME_MURDER: 60 points + TEST_ASSERT_EQUAL(police.get_stars(killer.mind), 3, "A murder is not three stars") + + // Too late, nothing + var/mob/living/carbon/human/other_killer = wanted_person(wanted_spot(0, 1)) + var/mob/living/carbon/human/other_victim = wanted_person(wanted_spot(1, 1)) + police.report_crime(other_killer, "unarmed", other_victim) + var/datum/outpost_warrant/other_warrant = police.warrants[other_killer.mind] + other_warrant.recent_victims[WEAKREF(other_victim)] = world.time - 31 SECONDS // OUTPOST_POLICE_KILL_WINDOW is 30 seconds + police.on_wanted_death(null, other_victim, FALSE) + TEST_ASSERT_EQUAL(other_warrant.heat, 6, "A death after the window was a murder") + +// ===== STRIKES ON OFFICERS (abuse M3) ===== + +/// A stamina weapon or a weapon hit on an officer is an assault; a shove or bare fists are resisting; a cuff attempt is an assault +/datum/unit_test/voidcrew_outpost_police_officer_struck + +/datum/unit_test/voidcrew_outpost_police_officer_struck/Run() + var/obj/structure/overmap/trader_outpost/outpost = wanted_outpost() + var/datum/outpost_police/police = outpost.police + var/mob/living/carbon/human/npc/ambient_npc/outpost/police/officer = wanted_officer(outpost, wanted_spot(3, 3)) + + var/mob/living/carbon/human/stunner = wanted_person(wanted_spot(0, 0)) + police.officer_struck(officer, stunner, ATTACKER_STAMINA_ATTACK) + TEST_ASSERT_EQUAL(police.warrants[stunner.mind]?.heat, 50, "A stamina hit on an officer was not an assault with a three-star floor") // OUTPOST_CRIME_ASSAULT_OFFICER + + var/mob/living/carbon/human/shover = wanted_person(wanted_spot(0, 1)) + police.officer_struck(officer, shover, ATTACKER_SHOVING) + TEST_ASSERT_EQUAL(police.warrants[shover.mind]?.heat, 8, "A shove on an officer was not resisting") // OUTPOST_CRIME_RESIST + + var/mob/living/carbon/human/fighter = wanted_person(wanted_spot(0, 2)) + police.officer_struck(officer, fighter, ATTACKER_DAMAGING_ATTACK) + TEST_ASSERT_EQUAL(police.warrants[fighter.mind]?.heat, 8, "Bare fists on an officer were not resisting") + + var/mob/living/carbon/human/swinger = wanted_person(wanted_spot(1, 0)) + var/obj/item/crowbar/bar = allocate(/obj/item/crowbar) + swinger.put_in_active_hand(bar) + police.officer_struck(officer, swinger, ATTACKER_DAMAGING_ATTACK) + TEST_ASSERT_EQUAL(police.warrants[swinger.mind]?.heat, 50, "A weapon hit on an officer was not an assault") + TEST_ASSERT(WEAKREF(bar) in police.warrants[swinger.mind]?.evidence, "The weapon that hit an officer is not evidence") + + var/mob/living/carbon/human/cuffer = wanted_person(wanted_spot(1, 1)) + police.officer_cuff_attempt(officer, cuffer) + TEST_ASSERT_EQUAL(police.warrants[cuffer.mind]?.heat, 50, "Trying to cuff an officer was not an assault") + +// ===== THE FINE (abuse M6, M7) ===== + +/// All or nothing from the remembered crew's accounts pooled, richest first; one credit short takes nothing; leaving the crew does not help +/datum/unit_test/voidcrew_outpost_police_fine + +/datum/unit_test/voidcrew_outpost_police_fine/Run() + var/obj/structure/overmap/trader_outpost/outpost = wanted_outpost() + var/datum/outpost_police/police = outpost.police + police.profile.fine_multiplier = 1 + var/mob/living/carbon/human/payer = wanted_person(wanted_spot(0, 0)) + var/mob/living/carbon/human/witness = wanted_person(wanted_spot(1, 0)) + var/obj/structure/overmap/ship/rich = wanted_ship(payer, 500) + var/obj/structure/overmap/ship/poor = wanted_ship(null, 400, payer.mind.get_reputation_crew()) + + // One star: 300, from the richer ship alone + police.report_crime(payer, "unarmed", witness) + police.recent_reports.Cut() + police.report_crime(payer, "unarmed", witness) + var/datum/outpost_warrant/warrant = police.warrants[payer.mind] + TEST_ASSERT_EQUAL(police.get_stars(payer.mind), 1, "The test warrant is not one star") + // The offender leaves the crew; the warrant remembers it + payer.mind.leave_reputation_crew() + TEST_ASSERT(police.charge_fine(warrant), "The fine was not charged to the crew the warrant remembers") // OUTPOST_POLICE_FINES: 300 at one star + TEST_ASSERT_EQUAL(rich.ship_account.account_balance, 200, "The richer ship did not pay the whole fine") + TEST_ASSERT_EQUAL(poor.ship_account.account_balance, 400, "The poorer ship paid part of a fine the richer one covered") + TEST_ASSERT_EQUAL(warrant.fine_level, 1, "The fine's level was not fixed at the first charge") + + // Two stars: 800 across both, all or nothing + rich.ship_account.account_balance = 500 + poor.ship_account.account_balance = 299 + warrant.heat = 30 + warrant.fine_level = 0 + TEST_ASSERT(!police.charge_fine(warrant), "A fine one credit beyond the pooled accounts was charged") + TEST_ASSERT_EQUAL(rich.ship_account.account_balance, 500, "A fine that could not be paid took money from the richer ship") + TEST_ASSERT_EQUAL(poor.ship_account.account_balance, 299, "A fine that could not be paid took money from the poorer ship") + rich.ship_account.account_balance = 501 + TEST_ASSERT(police.charge_fine(warrant), "A fine the pooled accounts exactly cover was refused") + TEST_ASSERT_EQUAL(rich.ship_account.account_balance, 0, "The richer ship was not drawn first") + TEST_ASSERT_EQUAL(poor.ship_account.account_balance, 0, "The poorer ship did not pay the rest") + + // No crew, no fine + warrant.crew = null + TEST_ASSERT(!police.charge_fine(warrant), "A warrant with no remembered crew charged a fine") + wanted_cleanup(list(rich, poor)) + +// ===== WHO IS REFUSED SERVICE ===== + +/// Stars, the bar and the embargo each refuse service; force follows the stars +/datum/unit_test/voidcrew_outpost_police_barred + +/datum/unit_test/voidcrew_outpost_police_barred/Run() + var/obj/structure/overmap/trader_outpost/outpost = wanted_outpost() + var/datum/outpost_police/police = outpost.police + var/mob/living/carbon/human/clean = wanted_person(wanted_spot(0, 0)) + var/mob/living/carbon/human/starred = wanted_person(wanted_spot(1, 0)) + var/mob/living/carbon/human/barred = wanted_person(wanted_spot(0, 1)) + var/mob/living/carbon/human/embargoed = wanted_person(wanted_spot(1, 1)) + TEST_ASSERT(!police.is_barred(clean), "A clean visitor is refused service") + TEST_ASSERT_EQUAL(police.force_level(clean), 0, "A clean visitor can be arrested") // POLICE_FORCE_NONE + + police_pin_warrant(police, starred.mind, 10) + TEST_ASSERT(police.is_barred(starred), "A star did not refuse service") + TEST_ASSERT_EQUAL(police.force_level(starred), 1, "One star is not an arrest") // POLICE_FORCE_ARREST + police.warrants[starred.mind].heat = 25 + TEST_ASSERT_EQUAL(police.force_level(starred), 2, "Two stars are not a stun") // POLICE_FORCE_STUN + police.warrants[starred.mind].heat = 50 + TEST_ASSERT_EQUAL(police.force_level(starred), 3, "Three stars are not lethal") // POLICE_FORCE_LETHAL + starred.stat = DEAD + TEST_ASSERT_EQUAL(police.force_level(starred), 0, "A dead suspect can be arrested") // POLICE_FORCE_NONE + starred.stat = CONSCIOUS + + var/datum/outpost_warrant/bar_warrant = police_pin_warrant(police, barred.mind, 0) + bar_warrant.barred_until = world.time + 5 MINUTES + TEST_ASSERT(police.is_barred(barred), "A bar did not refuse service") + TEST_ASSERT_EQUAL(police.force_level(barred), 0, "A bar with no stars can be arrested") // POLICE_FORCE_NONE + + var/obj/structure/overmap/ship/ship = wanted_ship(embargoed) + outpost.embargo_ship(ship, 15 MINUTES) + TEST_ASSERT(police.is_barred(embargoed), "An embargo did not refuse the crew service") + outpost.embargoed_ships -= ship + TEST_ASSERT(!police.is_barred(embargoed), "A lifted embargo still refuses service") + wanted_cleanup(list(ship)) + +// ===== THE KINGPIN'S SHOOTOUT (abuse M16) ===== + +/// A hunter engaged in the kingpin's shootout is excused a drawn gun, gunfire and vandalism; one who is not is not; a hit on a person still counts +/datum/unit_test/voidcrew_bounty_kingpin/police_excuse + +/datum/unit_test/voidcrew_bounty_kingpin/police_excuse/Run() + kingpin_test_begin() + var/obj/structure/overmap/trader_outpost/outpost = allocate(/obj/structure/overmap/trader_outpost) + outpost.outpost_template = allocate(/datum/map_template/trader_outpost) + outpost.outpost_template.width = 5 + outpost.outpost_template.height = 5 + outpost.template_bottom_left = run_loc_floor_bottom_left + var/datum/outpost_police/police = outpost.police + + var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 2) + var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew + var/list/goons = crew.goons() + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = goons[1] + var/mob/living/carbon/human/hunter = kingpin_test_person(kingpin_test_spot(2, 2), null) + var/mob/living/carbon/human/idle_hunter = kingpin_test_person(kingpin_test_spot(1, 3), null) + var/mob/living/carbon/human/brawler = kingpin_test_person(kingpin_test_spot(1, 2), null) + var/mob/living/carbon/human/victim = kingpin_test_person(kingpin_test_spot(3, 2), null) + TEST_ASSERT_EQUAL(get_trader_outpost_for_turf(crew.crew_seat), outpost, "The test lounge isn't at the test outpost") + crew.start_shootout(hunter, TRUE) + crew.add_hunter(idle_hunter) + crew.add_hunter(brawler) + crew.member_attacked(goon, list(hunter, brawler)) + TEST_ASSERT(crew.engaged(hunter), "A hunter who just shot a goon isn't counted in the fight") + + for(var/stray in list("weapon_drawn", "gunfire", "vandalism")) // OUTPOST_CRIME_WEAPON_DRAWN, _GUNFIRE, _VANDALISM + police.recent_reports.Cut() + TEST_ASSERT_NULL(police.report_crime(hunter, stray), "An engaged hunter was reported for '[stray]'") + TEST_ASSERT_NULL(police.warrants[hunter.mind], "An engaged hunter's stray fire made a warrant") + TEST_ASSERT_NOTNULL(police.report_crime(idle_hunter, "weapon_drawn"), "A hunter not in the fight was excused a drawn gun") + // A hit on a person still counts + outpost.register_pvp_aggression(victim, brawler, "unarmed") + TEST_ASSERT_NOTNULL(police.warrants[brawler.mind], "An engaged hunter's hit on a person was excused") + crew.stand_down() + kingpin_test_end() From de481d7d8c8bbca1c133e9d48328e0b3a4e2cb71 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:45:04 -0800 Subject: [PATCH 029/152] Outpost police: wanted star alert and admin warrant rows The wanted alert shows one to five stars (a blinking set while the suspect is unseen or away) at the outpost the person is at, and clears at none. The overmap management bans view lists warrants and bars next to embargoes: lift any row (a prisoner's lift lets them out of the cell), add a warrant at one to five stars. Long names are cut with a mark and the add form shrinks with the window. The bans test is ported to the new rows. --- .../interfaces/OvermapManagement.tsx | 174 +++++++++++------- .../modules/admin/overmap_management_bans.dm | 112 ++++++++++- .../modules/outpost_police/icons/wanted.dmi | Bin 0 -> 937 bytes voidcrew/modules/outpost_police/police_hud.dm | 95 +++++++++- .../unit_tests/voidcrew_outpost_police_hud.dm | 72 ++++++++ .../voidcrew_overmap_management_bans.dm | 106 +++++++---- 6 files changed, 452 insertions(+), 107 deletions(-) create mode 100644 voidcrew/modules/outpost_police/icons/wanted.dmi diff --git a/tgui/packages/voidcrew_tgui/interfaces/OvermapManagement.tsx b/tgui/packages/voidcrew_tgui/interfaces/OvermapManagement.tsx index ac76f57597d..8047e7911b3 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/OvermapManagement.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/OvermapManagement.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; import { Box, Button, @@ -123,6 +123,13 @@ const duration = (seconds: number) => const wrapping = { whiteSpace: 'normal', overflowWrap: 'anywhere' } as const; +// One line, cut with a mark: the full text is on hover +const cut = { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} as const; + export const OvermapManagement = () => { const { act, data } = useBackend(); const [search, setSearch] = useState(''); @@ -698,11 +705,28 @@ const ContactDetails = ({ contact }: { contact: Details }) => { ); }; +// A label and a field. The field may shrink, so a long name is cut with a mark and never pushes past the window. +const FormRow = ({ + label, + children, +}: { + label: string; + children: ReactNode; +}) => ( + + + {label}: + + + {children} + + +); + const BAN_KINDS = { trader: [ { value: 'embargo', displayText: 'Trade embargo (ship)' }, - { value: 'aggressor', displayText: 'Marked for violence (person)' }, - { value: 'trespass', displayText: 'Refused service (person)' }, + { value: 'wanted', displayText: 'Warrant (person)' }, ], player: [{ value: 'ship_ban', displayText: 'Denied docking (ship)' }], }; @@ -717,6 +741,7 @@ const OutpostBans = ({ bans }: { bans: Bans }) => { const [kind, setKind] = useState(null); const [targetRef, setTargetRef] = useState(null); const [minutes, setMinutes] = useState(15); + const [stars, setStars] = useState(3); const query = search.trim().toLowerCase(); const entries = bans.entries @@ -729,7 +754,8 @@ const OutpostBans = ({ bans }: { bans: Bans }) => { ) .sort( (a, b) => - a.outpost.localeCompare(b.outpost) || a.name.localeCompare(b.name), + (a.outpost || '').localeCompare(b.outpost || '') || + (a.name || '').localeCompare(b.name || ''), ); const outposts = [...bans.outposts].sort((a, b) => a.name.localeCompare(b.name), @@ -787,7 +813,7 @@ const OutpostBans = ({ bans }: { bans: Bans }) => { - + {entries.length === 0 && ( {bans.entries.length > 0 ? 'No matching bans.' : 'Nobody is banned.'} @@ -797,14 +823,17 @@ const OutpostBans = ({ bans }: { bans: Bans }) => { - - {entry.name} + + {entry.name || 'Unknown'} - - {entry.label} · {entry.outpost} - {entry.seconds !== null + + {entry.label || 'Restricted'} ·{' '} + {entry.outpost || 'Unknown outpost'} + {entry.seconds !== null && entry.seconds !== undefined ? ` · ${duration(entry.seconds)} left` - : ' · Until lifted'} + : entry.kind === 'wanted' + ? ' · At large' + : ' · Until lifted'} @@ -827,65 +856,75 @@ const OutpostBans = ({ bans }: { bans: Bans }) => {
- - - ({ - value: option.ref, - displayText: option.name, - }))} - onSelected={(value) => { - setOutpostRef(value); - setKind(null); - setTargetRef(null); - }} - /> - - - { - setKind(value); - setTargetRef(null); - setMinutes(value === 'trespass' ? 30 : 15); - }} + + ({ + value: option.ref, + displayText: option.name, + }))} + onSelected={(value) => { + setOutpostRef(value); + setKind(null); + setTargetRef(null); + }} + /> + + + { + setKind(value); + setTargetRef(null); + setMinutes(15); + setStars(3); + }} + /> + + + + + {kind === 'embargo' && ( + + - - - + )} + {kind === 'wanted' && ( + + - - {!!kind && kind !== 'ship_ban' && ( - - - - )} - + + )}