From 0aa20c13f3aa658b68434736852caea3a34d8e7a Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:50:50 -0800 Subject: [PATCH 001/472] Add hyperviolence shared hit-line hook --- tgstation.dme | 2 ++ voidcrew/_DEFINES/hyperviolence.dm | 6 ++++++ voidcrew/modules/hyperviolence/_core.dm | 10 ++++++++++ 3 files changed, 18 insertions(+) create mode 100644 voidcrew/_DEFINES/hyperviolence.dm create mode 100644 voidcrew/modules/hyperviolence/_core.dm diff --git a/tgstation.dme b/tgstation.dme index 1938407f825..2f7df14adc8 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6694,6 +6694,7 @@ #include "voidcrew\_DEFINES\dynamic_events.dm" #include "voidcrew\_DEFINES\flags.dm" #include "voidcrew\_DEFINES\hud.dm" +#include "voidcrew\_DEFINES\hyperviolence.dm" #include "voidcrew\_DEFINES\job_categories.dm" #include "voidcrew\_DEFINES\keybindings.dm" #include "voidcrew\_DEFINES\lich.dm" @@ -7228,6 +7229,7 @@ #include "voidcrew\modules\dynamic_events\events\wormholes.dm" #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" +#include "voidcrew\modules\hyperviolence\_core.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" #include "voidcrew\modules\intents\hud_intent.dm" diff --git a/voidcrew/_DEFINES/hyperviolence.dm b/voidcrew/_DEFINES/hyperviolence.dm new file mode 100644 index 00000000000..f0e0c2135e3 --- /dev/null +++ b/voidcrew/_DEFINES/hyperviolence.dm @@ -0,0 +1,6 @@ +// Hyperviolence (voidcrew/modules/hyperviolence/): shared defines for the gore parts. + +/// A plain bold line in the combined hit message. +#define HV_LINE_BOLD 1 +/// A big bold-italic line, for severed limbs, burst heads and kills. +#define HV_LINE_HUGE 2 diff --git a/voidcrew/modules/hyperviolence/_core.dm b/voidcrew/modules/hyperviolence/_core.dm new file mode 100644 index 00000000000..b24fb3b18b9 --- /dev/null +++ b/voidcrew/modules/hyperviolence/_core.dm @@ -0,0 +1,10 @@ +// Hyperviolence core: the shared hook every gore part uses to add a line to a hit's message. +// The combined-hit-message part replaces the body of hv_add_hit_line with batching; until +// then each line is shown on its own so every part works standalone. + +/// Adds a line ("An artery is torn!") to the combat message of the hit being resolved on src. +/mob/living/proc/hv_add_hit_line(text, severity = HV_LINE_BOLD) + if(!text) + return + var/shown = severity >= HV_LINE_HUGE ? "[text]" : "[text]" + visible_message(span_danger(shown), vision_distance = COMBAT_MESSAGE_RANGE) From 07b877ef3e18ebfbfa54fec3697af0cfbc51d062 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:54:34 -0800 Subject: [PATCH 002/472] Add hyperviolence blood spray and head burst hooks --- voidcrew/modules/hyperviolence/_core.dm | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/voidcrew/modules/hyperviolence/_core.dm b/voidcrew/modules/hyperviolence/_core.dm index b24fb3b18b9..0fd61071f56 100644 --- a/voidcrew/modules/hyperviolence/_core.dm +++ b/voidcrew/modules/hyperviolence/_core.dm @@ -8,3 +8,27 @@ return var/shown = severity >= HV_LINE_HUGE ? "[text]" : "[text]" visible_message(span_danger(shown), vision_distance = COMBAT_MESSAGE_RANGE) + +/// Sprays blood from src toward spray_dir. The blood part replaces this with directional spray +/// decals that paint walls and drench bystanders; the stub uses tg's flying splatter. +/mob/living/proc/hv_blood_spray(spray_dir, strength = 3) + return + +/mob/living/carbon/hv_blood_spray(spray_dir, strength = 3) + spray_blood(spray_dir || pick(GLOB.alldirs), strength) + +/// Bursts src's head: it comes off in pieces rather than dropping whole. burst_dir is the way the +/// shot or blow travelled. The brain-and-guts part replaces this to fling the brain out intact. +/// Returns TRUE if the head was burst. +/mob/living/carbon/proc/hv_burst_head(burst_dir) + var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) + if(!head || !head.can_dismember()) + return FALSE + hv_blood_spray(burst_dir, 4) + if(!head.dismember(BRUTE, silent = TRUE)) + return FALSE + head.drop_organs(null, TRUE) + qdel(head) + new /obj/effect/gibspawner/generic(get_turf(src), src) + hv_add_hit_line("[src]'s head bursts apart!", HV_LINE_HUGE) + return TRUE From fc7eb0e78fdf4a9740a022d4b867200bc5771758 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:35:28 -0800 Subject: [PATCH 003/472] Add gun feel: directional recoil kick, muzzle flash, flying casings, shouldering - Recoil: each shot kicks the shooter's camera back along the line of fire instead of tg's random shake. Per-class kick strengths, stronger from the hip; skipped for the darken-screen-shake preference and while scoped. - Muzzle flash: powder rounds show tg's muzzle_bullet sprite at the barrel, turned to the shot, plus a short light on the shooter. Suppressed guns, energy weapons, hitscan rounds and darts/arrows/etc. get none. - Casings: ejected casings fly out the right side, arc, bounce and skid to a stop via a movable_physics component and subsystem, then remove the component. At most 60 in flight; past that they drop the old way. - Shouldering: long guns fire with extra spread until both hands have been on them for 0.4 seconds. Carbon shooters only; pistols and NPC mobs unaffected. The physics component and subsystem are ported from Shiptest (shiptest-ss13/Shiptest, AGPL-3.0) and reworked; the recoil kick and muzzle flash follow Shiptest's gun code. --- code/modules/projectiles/gun.dm | 6 +- tgstation.dme | 5 + voidcrew/_DEFINES/gun_feel.dm | 106 +++++++ voidcrew/modules/gun_feel/casings.dm | 46 +++ voidcrew/modules/gun_feel/gun_feel.dm | 268 ++++++++++++++++++ voidcrew/modules/gun_feel/movable_physics.dm | 191 +++++++++++++ voidcrew/modules/gun_feel/muzzle_flash.dm | 113 ++++++++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../modules/unit_tests/voidcrew_gun_feel.dm | 72 +++++ 9 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 voidcrew/_DEFINES/gun_feel.dm create mode 100644 voidcrew/modules/gun_feel/casings.dm create mode 100644 voidcrew/modules/gun_feel/gun_feel.dm create mode 100644 voidcrew/modules/gun_feel/movable_physics.dm create mode 100644 voidcrew/modules/gun_feel/muzzle_flash.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_gun_feel.dm diff --git a/code/modules/projectiles/gun.dm b/code/modules/projectiles/gun.dm index 5d65e684704..03466b78181 100644 --- a/code/modules/projectiles/gun.dm +++ b/code/modules/projectiles/gun.dm @@ -208,8 +208,10 @@ playsound(src, fire_sound, fire_sound_volume, vary_fire_sound) /obj/item/gun/proc/shoot_live_shot(mob/living/user, pointblank = FALSE, atom/pbtarget = null, message = TRUE) - if(recoil && !tk_firing(user)) - shake_camera(user, recoil + 1, recoil) + // VOIDCREW EDIT REMOVAL START - gun_feel: voidcrew/modules/gun_feel kicks the camera away from the shot instead of shaking it at random + // if(recoil && !tk_firing(user)) + // shake_camera(user, recoil + 1, recoil) + // VOIDCREW EDIT REMOVAL END fire_sounds() if(suppressed || !message) return FALSE diff --git a/tgstation.dme b/tgstation.dme index 1938407f825..53fa5ec84cf 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6693,6 +6693,7 @@ #include "voidcrew\_DEFINES\drug_smuggling.dm" #include "voidcrew\_DEFINES\dynamic_events.dm" #include "voidcrew\_DEFINES\flags.dm" +#include "voidcrew\_DEFINES\gun_feel.dm" #include "voidcrew\_DEFINES\hud.dm" #include "voidcrew\_DEFINES\job_categories.dm" #include "voidcrew\_DEFINES\keybindings.dm" @@ -7227,6 +7228,10 @@ #include "voidcrew\modules\dynamic_events\events\vent_clog.dm" #include "voidcrew\modules\dynamic_events\events\wormholes.dm" #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" +#include "voidcrew\modules\gun_feel\casings.dm" +#include "voidcrew\modules\gun_feel\gun_feel.dm" +#include "voidcrew\modules\gun_feel\movable_physics.dm" +#include "voidcrew\modules\gun_feel\muzzle_flash.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" diff --git a/voidcrew/_DEFINES/gun_feel.dm b/voidcrew/_DEFINES/gun_feel.dm new file mode 100644 index 00000000000..e71e6efe630 --- /dev/null +++ b/voidcrew/_DEFINES/gun_feel.dm @@ -0,0 +1,106 @@ +// Tunables for voidcrew/modules/gun_feel: recoil kick, muzzle flash, flying casings and shouldering. + +// ---- Recoil kick ---- +// Kick strengths are in tiles of camera travel (1 = 32 px). Keep them well under 1. + +/// Random spread, in degrees, around "straight back" for each kick. +#define GUN_FEEL_RECOIL_DEVIATION 22.5 +/// Hard cap on any single kick, in tiles. +#define GUN_FEEL_MAX_KICK 1.5 +/// Tiles of kick added per point of a gun's upstream `recoil` var (nagant, sawn-offs, the sniper rifle...). +#define GUN_FEEL_LEGACY_RECOIL_KICK 0.15 +/// Animation tag for the kick, so a new shot supersedes the last kick instead of stacking on it. +#define GUN_FEEL_RECOIL_ANIM_TAG "gun_feel_recoil" + +// Gun classes, picked once per gun from its type and size. A subtype can preset `gun_feel_class`. +#define GUN_FEEL_CLASS_NONE "none" +#define GUN_FEEL_CLASS_PISTOL "pistol" +#define GUN_FEEL_CLASS_REVOLVER "revolver" +#define GUN_FEEL_CLASS_SMG "smg" +#define GUN_FEEL_CLASS_RIFLE "rifle" +#define GUN_FEEL_CLASS_SHOTGUN "shotgun" +#define GUN_FEEL_CLASS_MARKSMAN "marksman" +#define GUN_FEEL_CLASS_HEAVY "heavy" +#define GUN_FEEL_CLASS_LAUNCHER "launcher" +#define GUN_FEEL_CLASS_ENERGY "energy" +#define GUN_FEEL_CLASS_ENERGY_LONG "energy_long" + +// Kick when shouldered (KICK) and from the hip (KICK_HIP), in tiles. +// HIP_SPREAD is extra spread in degrees from the hip, fed into tg's bonus spread (rand(0, x), then +-0.7x). +#define GUN_FEEL_KICK_PISTOL 0.12 +#define GUN_FEEL_KICK_REVOLVER 0.18 +#define GUN_FEEL_KICK_SMG 0.08 +#define GUN_FEEL_KICK_HIP_SMG 0.2 +#define GUN_FEEL_HIP_SPREAD_SMG 10 +#define GUN_FEEL_KICK_RIFLE 0.12 +#define GUN_FEEL_KICK_HIP_RIFLE 0.3 +#define GUN_FEEL_HIP_SPREAD_RIFLE 20 +#define GUN_FEEL_KICK_SHOTGUN 0.3 +#define GUN_FEEL_KICK_HIP_SHOTGUN 0.55 +#define GUN_FEEL_HIP_SPREAD_SHOTGUN 12 +#define GUN_FEEL_KICK_MARKSMAN 0.35 +#define GUN_FEEL_KICK_HIP_MARKSMAN 0.7 +#define GUN_FEEL_HIP_SPREAD_MARKSMAN 30 +#define GUN_FEEL_KICK_HEAVY 0.18 +#define GUN_FEEL_KICK_HIP_HEAVY 0.45 +#define GUN_FEEL_HIP_SPREAD_HEAVY 30 +#define GUN_FEEL_KICK_LAUNCHER 0.4 +#define GUN_FEEL_KICK_HIP_LAUNCHER 0.7 +#define GUN_FEEL_HIP_SPREAD_LAUNCHER 15 +#define GUN_FEEL_HIP_SPREAD_ENERGY_LONG 10 + +// ---- Shouldering ---- + +/// How long a long gun must be held in both hands before it is shouldered. +#define GUN_FEEL_SHOULDER_TIME (0.4 SECONDS) + +// ---- Muzzle flash ---- + +#define GUN_FEEL_FLASH_ICON 'icons/obj/weapons/guns/projectiles_muzzle.dmi' +#define GUN_FEEL_FLASH_ICON_STATE "muzzle_bullet" +/// How long the flash sprite shows. muzzle_bullet is two frames of 0.1s. +#define GUN_FEEL_FLASH_TIME (0.2 SECONDS) +/// How long the flash light stays on after the last shot. +#define GUN_FEEL_FLASH_LIGHT_TIME (0.3 SECONDS) +#define GUN_FEEL_FLASH_LIGHT_RANGE 3 +#define GUN_FEEL_FLASH_LIGHT_POWER 1.2 +#define GUN_FEEL_FLASH_LIGHT_COLOR COLOR_VERY_SOFT_YELLOW +// Where the barrel sits relative to the shooter's centre, in pixels, by aim direction. +#define GUN_FEEL_FLASH_REACH_SIDE 12 +#define GUN_FEEL_FLASH_REACH_UP 8 +#define GUN_FEEL_FLASH_REACH_DOWN 6 +#define GUN_FEEL_FLASH_HAND_HEIGHT -2 +/// The bright core of muzzle_bullet sits this many pixels behind the icon centre. +#define GUN_FEEL_FLASH_HOTSPOT 4 + +// ---- Flying casings ---- + +/// Most casings allowed in flight at once. Past this, casings drop the old way. +#define GUN_FEEL_CASING_CAP 60 +/// Degrees clockwise from the shooter's facing that casings leave at: out to the right and a little back. +#define GUN_FEEL_CASING_EJECT_ANGLE 110 +#define GUN_FEEL_CASING_EJECT_SCATTER 25 +/// Height, in pixels, a casing leaves the gun at. +#define GUN_FEEL_CASING_START_HEIGHT 8 +// Launch speeds in pixels per physics step (0.05 s). +#define GUN_FEEL_CASING_SPEED_MIN 2.5 +#define GUN_FEEL_CASING_SPEED_MAX 4 +#define GUN_FEEL_CASING_LIFT_MIN 2 +#define GUN_FEEL_CASING_LIFT_MAX 3.5 + +// ---- Movable physics (shared by anything using /datum/component/movable_physics) ---- +// Per physics step (0.05 s), in pixels. + +#define MOVABLE_PHYSICS_GRAVITY 0.75 +#define MOVABLE_PHYSICS_AIR_DRAG 0.03 +#define MOVABLE_PHYSICS_GROUND_FRICTION 0.45 +/// Share of vertical speed kept on a floor bounce. +#define MOVABLE_PHYSICS_RESTITUTION 0.4 +/// Share of horizontal speed kept on a floor bounce. +#define MOVABLE_PHYSICS_BOUNCE_KEEP 0.65 +/// Share of horizontal speed kept when glancing off a wall. +#define MOVABLE_PHYSICS_WALL_KEEP 0.5 +/// A bounce slower than this just settles. +#define MOVABLE_PHYSICS_MIN_BOUNCE 1.2 +/// Anything still moving after this long settles where it is. +#define MOVABLE_PHYSICS_TIMEOUT (3 SECONDS) diff --git a/voidcrew/modules/gun_feel/casings.dm b/voidcrew/modules/gun_feel/casings.dm new file mode 100644 index 00000000000..efadcf4f656 --- /dev/null +++ b/voidcrew/modules/gun_feel/casings.dm @@ -0,0 +1,46 @@ +// Flying casings. A casing a ballistic gun spits out (when firing or racking) leaves the right side of +// the gun, arcs, bounces and skids to a stop instead of popping onto the shooter's tile. +// After Shiptest's /obj/item/ammo_casing/on_eject(). The flight is /datum/component/movable_physics. + +/obj/item/ammo_casing + /// Set only while a gun is ejecting this casing: the direction it should fly, in degrees clockwise from north. + var/gun_feel_eject_angle + +/obj/item/gun/ballistic/handle_chamber(empty_chamber = TRUE, from_firing = TRUE, chamber_next_round = TRUE) + var/obj/item/ammo_casing/casing = chambered + if(istype(casing)) + casing.gun_feel_eject_angle = gun_feel_eject_angle() + . = ..() + if(istype(casing) && !QDELETED(casing)) + casing.gun_feel_eject_angle = null + +/// Out of the right side of the gun and a little back, from the holder's facing. +/obj/item/gun/ballistic/proc/gun_feel_eject_angle() + var/atom/holder = ismob(loc) ? loc : src + var/facing = dir2angle(holder.dir) + if(isnull(facing)) + facing = rand(0, 359) + return SIMPLIFY_DEGREES(facing + GUN_FEEL_CASING_EJECT_ANGLE + (rand() * 2 - 1) * GUN_FEEL_CASING_EJECT_SCATTER) + +/obj/item/ammo_casing/bounce_away(still_warm = FALSE, bounce_delay = 3) + if(!isnull(gun_feel_eject_angle)) + var/angle = gun_feel_eject_angle + gun_feel_eject_angle = null + if(gun_feel_launch(angle, still_warm)) + update_appearance() + return + return ..() + +/// Sends this casing flying. Returns FALSE (and the caller drops it the old way) if it can't fly here. +/obj/item/ammo_casing/proc/gun_feel_launch(angle, still_warm) + var/turf/floor = loc + if(!isturf(floor) || floor.density || !has_gravity(floor)) + return FALSE + if(length(SSmovable_physics.processing) >= GUN_FEEL_CASING_CAP) + return FALSE + pixel_x = base_pixel_x + rand(-4, 4) + pixel_y = base_pixel_y + rand(-4, 4) + var/speed = GUN_FEEL_CASING_SPEED_MIN + (GUN_FEEL_CASING_SPEED_MAX - GUN_FEEL_CASING_SPEED_MIN) * rand() + var/lift = GUN_FEEL_CASING_LIFT_MIN + (GUN_FEEL_CASING_LIFT_MAX - GUN_FEEL_CASING_LIFT_MIN) * rand() + var/datum/component/movable_physics/flight = AddComponent(/datum/component/movable_physics, speed, lift, angle, GUN_FEEL_CASING_START_HEIGHT, !!still_warm, TRUE) + return !QDELETED(flight) diff --git a/voidcrew/modules/gun_feel/gun_feel.dm b/voidcrew/modules/gun_feel/gun_feel.dm new file mode 100644 index 00000000000..2ac8b878a60 --- /dev/null +++ b/voidcrew/modules/gun_feel/gun_feel.dm @@ -0,0 +1,268 @@ +// Gun feel: a directional recoil kick, a muzzle flash, flying casings and shouldering for every gun. +// The firing feel is modelled on Shiptest (shiptest-ss13/Shiptest), whose code is AGPL-3.0 like ours. +// +// Hooks, all chained onto the upstream procs (this file loads after code/, so these run first and call ..()): +// - shoot_live_shot(): kick the shooter's camera and show the muzzle flash, once per shot. +// - process_fire(): add hip-fire spread when a long gun is not shouldered. +// - equipped()/dropped(): track when a long gun is held in both hands, for the shoulder delay. +// The upstream random camera shake in shoot_live_shot() is removed (marked VOIDCREW EDIT in gun.dm). + +/obj/item/gun + /// Camera kick in tiles when shouldered (or for one-handed guns). Null picks a default from gun_feel_class. + var/recoil_kick + /// Camera kick in tiles when a long gun is fired from the hip. Null picks a default. + var/recoil_kick_hip + /// Extra spread in degrees when a long gun is fired from the hip. Null picks a default. + var/hip_spread + /// How long both hands must be on a long gun before it is shouldered. + var/shoulder_delay = GUN_FEEL_SHOULDER_TIME + /// GUN_FEEL_CLASS_*. Null until first needed; a subtype may preset it. + var/gun_feel_class + /// TRUE if this gun is shouldered with two hands (rifles, shotguns, SMGs...). Set with the class. + var/gun_feel_long = FALSE + /// TRUE once gun_feel_resolve() has filled in the values above. + var/gun_feel_resolved = FALSE + /// world.time both hands went onto this gun, or 0 while it is not held that way. + var/gun_feel_brace_start = 0 + /// The short-lived light from the last muzzle flash. + var/obj/effect/dummy/lighting_obj/moblight/gun_feel_light + +/obj/item/gun/Destroy() + gun_feel_clear_light() + return ..() + +/// Picks this gun's class and fills in any feel values its type left unset. +/obj/item/gun/proc/gun_feel_resolve() + if(gun_feel_resolved) + return + gun_feel_resolved = TRUE + if(isnull(gun_feel_class)) + gun_feel_class = gun_feel_pick_class() + var/kick = 0 + var/kick_hip = 0 + var/spread_hip = 0 + switch(gun_feel_class) + if(GUN_FEEL_CLASS_PISTOL) + kick = GUN_FEEL_KICK_PISTOL + kick_hip = GUN_FEEL_KICK_PISTOL + if(GUN_FEEL_CLASS_REVOLVER) + kick = GUN_FEEL_KICK_REVOLVER + kick_hip = GUN_FEEL_KICK_REVOLVER + if(GUN_FEEL_CLASS_SMG) + kick = GUN_FEEL_KICK_SMG + kick_hip = GUN_FEEL_KICK_HIP_SMG + spread_hip = GUN_FEEL_HIP_SPREAD_SMG + if(GUN_FEEL_CLASS_RIFLE) + kick = GUN_FEEL_KICK_RIFLE + kick_hip = GUN_FEEL_KICK_HIP_RIFLE + spread_hip = GUN_FEEL_HIP_SPREAD_RIFLE + if(GUN_FEEL_CLASS_SHOTGUN) + kick = GUN_FEEL_KICK_SHOTGUN + kick_hip = GUN_FEEL_KICK_HIP_SHOTGUN + spread_hip = GUN_FEEL_HIP_SPREAD_SHOTGUN + if(GUN_FEEL_CLASS_MARKSMAN) + kick = GUN_FEEL_KICK_MARKSMAN + kick_hip = GUN_FEEL_KICK_HIP_MARKSMAN + spread_hip = GUN_FEEL_HIP_SPREAD_MARKSMAN + if(GUN_FEEL_CLASS_HEAVY) + kick = GUN_FEEL_KICK_HEAVY + kick_hip = GUN_FEEL_KICK_HIP_HEAVY + spread_hip = GUN_FEEL_HIP_SPREAD_HEAVY + if(GUN_FEEL_CLASS_LAUNCHER) + kick = GUN_FEEL_KICK_LAUNCHER + kick_hip = GUN_FEEL_KICK_HIP_LAUNCHER + spread_hip = GUN_FEEL_HIP_SPREAD_LAUNCHER + if(GUN_FEEL_CLASS_ENERGY_LONG) + spread_hip = GUN_FEEL_HIP_SPREAD_ENERGY_LONG + if(isnull(recoil_kick)) + recoil_kick = kick + if(isnull(recoil_kick_hip)) + recoil_kick_hip = kick_hip + if(isnull(hip_spread)) + hip_spread = spread_hip + gun_feel_long = hip_spread > 0 + +/// Works out what kind of gun this is from its type, weight and size. +/obj/item/gun/proc/gun_feel_pick_class() + var/long_frame = weapon_weight >= WEAPON_MEDIUM || w_class >= WEIGHT_CLASS_BULKY + if(istype(src, /obj/item/gun/energy)) + return long_frame ? GUN_FEEL_CLASS_ENERGY_LONG : GUN_FEEL_CLASS_ENERGY + if(!istype(src, /obj/item/gun/ballistic) || istype(src, /obj/item/gun/ballistic/bow)) + return GUN_FEEL_CLASS_NONE + if(istype(src, /obj/item/gun/ballistic/rocketlauncher) || istype(src, /obj/item/gun/ballistic/revolver/grenadelauncher)) + return GUN_FEEL_CLASS_LAUNCHER + if(istype(src, /obj/item/gun/ballistic/shotgun)) + return GUN_FEEL_CLASS_SHOTGUN + if(istype(src, /obj/item/gun/ballistic/rifle)) + return GUN_FEEL_CLASS_MARKSMAN + if(istype(src, /obj/item/gun/ballistic/revolver)) + return GUN_FEEL_CLASS_REVOLVER + if(istype(src, /obj/item/gun/ballistic/automatic/pistol)) + return GUN_FEEL_CLASS_PISTOL + if(istype(src, /obj/item/gun/ballistic/automatic)) + if(weapon_weight >= WEAPON_HEAVY && w_class >= WEIGHT_CLASS_HUGE) + return GUN_FEEL_CLASS_HEAVY + if(long_frame) + return GUN_FEEL_CLASS_RIFLE + return GUN_FEEL_CLASS_SMG + return long_frame ? GUN_FEEL_CLASS_RIFLE : GUN_FEEL_CLASS_PISTOL + +// Classes and values for guns the type rules above would get wrong. +/obj/item/gun/ballistic/automatic/toy + gun_feel_class = GUN_FEEL_CLASS_NONE + +/obj/item/gun/ballistic/automatic/pistol/toy + gun_feel_class = GUN_FEEL_CLASS_NONE + +/obj/item/gun/ballistic/shotgun/toy + gun_feel_class = GUN_FEEL_CLASS_NONE + +/// Fires laser casings from a magazine; it should handle like a laser rifle, not a powder rifle. +/obj/item/gun/ballistic/automatic/laser + gun_feel_class = GUN_FEEL_CLASS_ENERGY_LONG + +/// A crossbow, not a rifle. +/obj/item/gun/ballistic/rifle/rebarxbow + recoil_kick = 0.06 + recoil_kick_hip = 0.12 + +// ---- Per shot ---- + +/obj/item/gun/shoot_live_shot(mob/living/user, pointblank = FALSE, atom/pbtarget = null, message = TRUE) + . = ..() + gun_feel_on_shot(user, pbtarget) + +/// Kick and flash for one shot that just left the barrel. +/obj/item/gun/proc/gun_feel_on_shot(mob/living/user, atom/target) + if(!isliving(user) || QDELETED(src) || tk_firing(user)) + return + gun_feel_resolve() + var/angle = gun_feel_fire_angle(user, target) + gun_feel_kick(user, angle, gun_feel_braced(user)) + if(gun_feel_wants_flash()) + gun_feel_muzzle_flash(user, angle) + +/// Direction of the shot in degrees clockwise from north. +/obj/item/gun/proc/gun_feel_fire_angle(mob/living/user, atom/target) + var/turf/shooter_turf = get_turf(user) + var/turf/target_turf = get_turf(target) + if(shooter_turf && target_turf && shooter_turf != target_turf) + return get_angle(shooter_turf, target_turf) + var/facing = dir2angle(user.dir) + return isnull(facing) ? 0 : facing + +/obj/item/gun/proc/gun_feel_kick(mob/living/user, angle, braced) + if(!user.client) + return + var/strength = braced ? recoil_kick : recoil_kick_hip + strength = min(strength + max(recoil, 0) * GUN_FEEL_LEGACY_RECOIL_KICK, GUN_FEEL_MAX_KICK) + if(strength <= 0) + return + var/kick_angle = angle + 180 + (rand() * 2 - 1) * GUN_FEEL_RECOIL_DEVIATION + gun_feel_recoil_camera(user, strength + 1, strength * 2 + 1, strength, kick_angle) + +/** + * Jolts a mob's camera a short way along `angle` and lets it spring back. + * + * After Shiptest's recoil_camera(). It runs as a named, parallel animation sequence that only adds + * a temporary offset on top of whatever else animates the client (explosion shakes, choking), and + * every value it writes ends at the client's current resting pixel offset, so it never shifts the + * view for good. A newer kick replaces the last one instead of stacking on it. + * + * Arguments: + * * recoiler - the mob whose camera kicks + * * duration - deciseconds to reach the full kick + * * backtime - deciseconds to settle back + * * strength - distance in tiles + * * angle - direction of the kick in degrees clockwise from north + */ +/proc/gun_feel_recoil_camera(mob/recoiler, duration, backtime, strength, angle) + var/client/viewer = recoiler?.client + if(!viewer) + return + // Players who asked for screen shake to be toned down get no kick at all. + if(viewer.prefs?.read_preference(/datum/preference/toggle/screen_shake_darken)) + return + // A scope re-aims the camera every tick and would cut the kick off anyway. + if(HAS_TRAIT(recoiler, TRAIT_USER_SCOPED)) + return + var/kick_x = round(sin(angle) * strength * ICON_SIZE_X, 1) + var/kick_y = round(cos(angle) * strength * ICON_SIZE_Y, 1) + if(!kick_x && !kick_y) + return + var/rest_x = viewer.pixel_x + var/rest_y = viewer.pixel_y + animate(viewer, pixel_x = rest_x + kick_x, pixel_y = rest_y + kick_y, time = duration, easing = CUBIC_EASING | EASE_OUT, tag = GUN_FEEL_RECOIL_ANIM_TAG) + animate(pixel_x = rest_x, pixel_y = rest_y, time = backtime, easing = BACK_EASING | EASE_OUT) + +// ---- Shouldering ---- + +/obj/item/gun/process_fire(atom/target, mob/living/user, message = TRUE, params = null, zone_override = "", bonus_spread = 0) + if(isliving(user)) + bonus_spread += gun_feel_hip_spread(user) + return ..(target, user, message, params, zone_override, bonus_spread) + +/// Extra spread for firing this gun right now: none when shouldered. +/obj/item/gun/proc/gun_feel_hip_spread(mob/living/user) + gun_feel_resolve() + if(!hip_spread || gun_feel_braced(user)) + return 0 + return hip_spread + +/// TRUE if this shot counts as shouldered. Only player-shaped (carbon) shooters of long guns can be unshouldered. +/obj/item/gun/proc/gun_feel_braced(mob/living/user) + // An underbarrel launcher or hook sits inside the gun that is actually held; it shares that gun's grip. + if(isgun(loc)) + var/obj/item/gun/carrier = loc + return carrier.gun_feel_braced(user) + gun_feel_resolve() + if(!gun_feel_long || !iscarbon(user) || tk_firing(user)) + return TRUE + if(!gun_feel_both_hands(user)) + gun_feel_brace_start = 0 + return FALSE + if(!gun_feel_brace_start) + gun_feel_brace_start = world.time + return world.time - gun_feel_brace_start >= shoulder_delay + +/// TRUE if the holder has this gun in one hand and the paired hand is free and working. +/obj/item/gun/proc/gun_feel_both_hands(mob/living/holder) + var/gun_hand = holder.get_held_index_of_item(src) + if(!gun_hand) + return FALSE + var/pair_hand = IS_RIGHT_INDEX(gun_hand) ? gun_hand - 1 : gun_hand + 1 + if(pair_hand < 1 || pair_hand > length(holder.held_items)) + return FALSE + return !holder.held_items[pair_hand] && holder.has_hand_for_held_index(pair_hand) + +/obj/item/gun/equipped(mob/user, slot, initial = FALSE) + . = ..() + if(!(slot & ITEM_SLOT_HANDS)) + return + gun_feel_resolve() + if(!gun_feel_long) + return + RegisterSignals(user, list(COMSIG_MOB_EQUIPPED_ITEM, COMSIG_MOB_UNEQUIPPED_ITEM), PROC_REF(gun_feel_hands_changed), override = TRUE) + gun_feel_brace_start = 0 + gun_feel_update_brace(user) + +/obj/item/gun/dropped(mob/user, silent = FALSE) + if(gun_feel_long && user) + UnregisterSignal(user, list(COMSIG_MOB_EQUIPPED_ITEM, COMSIG_MOB_UNEQUIPPED_ITEM)) + gun_feel_brace_start = 0 + return ..() + +/// Something went into or out of the holder's inventory; the off hand may have filled or freed up. +/obj/item/gun/proc/gun_feel_hands_changed(mob/living/source, obj/item/item) + SIGNAL_HANDLER + if(item == src) + return + gun_feel_update_brace(source) + +/// Starts the shoulder clock when both hands go onto the gun, and stops it when they don't. +/obj/item/gun/proc/gun_feel_update_brace(mob/living/holder) + if(!holder.is_holding(src) || !gun_feel_both_hands(holder)) + gun_feel_brace_start = 0 + return + if(!gun_feel_brace_start) + gun_feel_brace_start = world.time diff --git a/voidcrew/modules/gun_feel/movable_physics.dm b/voidcrew/modules/gun_feel/movable_physics.dm new file mode 100644 index 00000000000..2a6c4bdc760 --- /dev/null +++ b/voidcrew/modules/gun_feel/movable_physics.dm @@ -0,0 +1,191 @@ +/** + * Movable physics + * + * Ported from Shiptest (shiptest-ss13/Shiptest, code/datums/components/movable_physics.dm and + * code/controllers/subsystem/processing/movable_physics.dm), AGPL-3.0 like this codebase. + * + * Throws a movable along the floor by its pixel offsets: it arcs up on pixel_z under gravity, + * bounces, slides to a stop, and steps into the next turf with Move() whenever its offset crosses + * a tile edge. Walls and anything else that refuses the Move() reflect it. + * + * Changes from Shiptest: wall reflections use the right axis, bounces lose speed instead of + * gaining it, offsets are kept as floats and written as whole pixels, and the component always + * deletes itself once the movable settles (or after MOVABLE_PHYSICS_TIMEOUT), so a settled movable + * costs nothing. It also lets go the moment anything else moves, picks up or throws the movable. + */ +/datum/component/movable_physics + /// Speed along the floor, in pixels per step. + var/horizontal_velocity = 0 + /// Speed up (positive) or down along pixel_z, in pixels per step. + var/vertical_velocity = 0 + /// Direction of travel along the floor, in degrees clockwise from north. + var/angle_of_movement = 0 + /// Current height above the floor, in pixels. + var/height = 0 + /// Offsets from the movable's base pixel position, kept unrounded between steps. + var/offset_x = 0 + var/offset_y = 0 + var/gravity = MOVABLE_PHYSICS_GRAVITY + var/air_drag = MOVABLE_PHYSICS_AIR_DRAG + var/ground_friction = MOVABLE_PHYSICS_GROUND_FRICTION + /// Sound for floor bounces. Null uses the turf's bullet_bounce_sound. + var/bounce_sound + /// A hot casing sizzles on the first landing if the floor is wet or icy. + var/hot = FALSE + /// Spin on launch and on each real bounce. + var/spin = TRUE + var/bounces = 0 + /// The movable's animate_movement before we started; Move() must not glide while we steer it by pixels. + var/cached_animate_movement + /// TRUE while we are the ones calling Move(), so on_moved() knows the move was ours. + var/moving_self = FALSE + /// world.time after which we give up and settle wherever we are. + var/give_up_at = 0 + +/datum/component/movable_physics/Initialize(horizontal_velocity = 0, vertical_velocity = 0, angle_of_movement = 0, start_height = 0, hot = FALSE, spin = TRUE, bounce_sound = null) + var/atom/movable/body = parent + if(!ismovable(body) || !isturf(body.loc)) + return COMPONENT_INCOMPATIBLE + src.horizontal_velocity = max(horizontal_velocity, 0) + src.vertical_velocity = vertical_velocity + src.angle_of_movement = SIMPLIFY_DEGREES(angle_of_movement) + src.hot = hot + src.spin = spin + src.bounce_sound = bounce_sound + height = max(start_height, 0) + offset_x = body.pixel_x - body.base_pixel_x + offset_y = body.pixel_y - body.base_pixel_y + cached_animate_movement = body.animate_movement + body.animate_movement = NO_STEPS + body.pixel_z = body.base_pixel_z + round(height) + give_up_at = world.time + MOVABLE_PHYSICS_TIMEOUT + if(spin) + body.SpinAnimation(speed = 0.4 SECONDS, loops = 2) + START_PROCESSING(SSmovable_physics, src) + +/datum/component/movable_physics/RegisterWithParent() + RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(parent, COMSIG_MOVABLE_PRE_THROW, PROC_REF(on_thrown)) + +/datum/component/movable_physics/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_MOVABLE_PRE_THROW)) + +/datum/component/movable_physics/Destroy(force) + STOP_PROCESSING(SSmovable_physics, src) + var/atom/movable/body = parent + if(!QDELETED(body)) + body.animate_movement = cached_animate_movement + body.pixel_z = body.base_pixel_z + if(isturf(body.loc)) + body.pixel_x = body.base_pixel_x + round(clamp(offset_x, -16, 16)) + body.pixel_y = body.base_pixel_y + round(clamp(offset_y, -16, 16)) + else + body.pixel_x = body.base_pixel_x + body.pixel_y = body.base_pixel_y + return ..() + +/// Anything other than our own stepping that moves the body (a hand, a broom, a conveyor, a shuttle) ends the flight. +/datum/component/movable_physics/proc/on_moved(atom/movable/source, atom/old_loc, dir, forced, list/old_locs, momentum_change) + SIGNAL_HANDLER + if(moving_self) + return + qdel(src) + +/datum/component/movable_physics/proc/on_thrown(atom/movable/source, list/arguments) + SIGNAL_HANDLER + qdel(src) + +/datum/component/movable_physics/process(seconds_per_tick) + var/atom/movable/body = parent + if(QDELETED(body) || !isturf(body.loc) || world.time >= give_up_at) + qdel(src) + return + + if(height > 0 || vertical_velocity > 0) + vertical_velocity -= gravity + height += vertical_velocity + if(height <= 0) + height = 0 + land(body) + + horizontal_velocity = max(0, horizontal_velocity - (height > 0 ? air_drag : ground_friction)) + if(horizontal_velocity) + offset_x += sin(angle_of_movement) * horizontal_velocity + offset_y += cos(angle_of_movement) * horizontal_velocity + if(!cross_tiles(body)) + return + + body.pixel_x = body.base_pixel_x + round(offset_x) + body.pixel_y = body.base_pixel_y + round(offset_y) + body.pixel_z = body.base_pixel_z + round(height) + + if(!horizontal_velocity && height <= 0 && !vertical_velocity) + qdel(src) + +/// Hit the floor: make a noise, lose speed, maybe hop again. +/datum/component/movable_physics/proc/land(atom/movable/body) + bounces++ + var/turf/floor = body.loc + if(bounces <= 2) + var/volume = bounces == 1 ? 20 : 10 + if(hot && bounces == 1 && floor.bullet_sizzle) + playsound(body, 'sound/items/tools/welder.ogg', volume, TRUE) + else + var/landing_sound = bounce_sound || floor.bullet_bounce_sound + if(landing_sound) + playsound(body, landing_sound, volume, TRUE) + vertical_velocity = -vertical_velocity * MOVABLE_PHYSICS_RESTITUTION + if(vertical_velocity < MOVABLE_PHYSICS_MIN_BOUNCE) + vertical_velocity = 0 + horizontal_velocity *= MOVABLE_PHYSICS_BOUNCE_KEEP + angle_of_movement = SIMPLIFY_DEGREES(angle_of_movement + rand(-15, 15)) + if(spin && vertical_velocity) + body.SpinAnimation(speed = 0.3 SECONDS, loops = 1) + +/// Steps into the next turf when an offset passes a tile edge, or glances off whatever is in the way. +/// Returns FALSE if we stopped existing or lost the body on the way. +/datum/component/movable_physics/proc/cross_tiles(atom/movable/body) + var/stepped = FALSE + if(offset_x > 16 || offset_x < -16) + var/step_dir = offset_x > 0 ? EAST : WEST + if(step_body(body, step_dir)) + offset_x += step_dir == EAST ? -32 : 32 + stepped = TRUE + else + offset_x = clamp(offset_x, -16, 16) + angle_of_movement = SIMPLIFY_DEGREES(360 - angle_of_movement) + horizontal_velocity *= MOVABLE_PHYSICS_WALL_KEEP + if(QDELETED(src) || QDELETED(body) || !isturf(body.loc)) + return FALSE + if(offset_y > 16 || offset_y < -16) + var/step_dir = offset_y > 0 ? NORTH : SOUTH + if(step_body(body, step_dir)) + offset_y += step_dir == NORTH ? -32 : 32 + stepped = TRUE + else + offset_y = clamp(offset_y, -16, 16) + angle_of_movement = SIMPLIFY_DEGREES(180 - angle_of_movement) + horizontal_velocity *= MOVABLE_PHYSICS_WALL_KEEP + if(QDELETED(src) || QDELETED(body) || !isturf(body.loc)) + return FALSE + // Into space or a gravityless room: stop steering and let ordinary drift carry it. + if(stepped && !body.has_gravity()) + qdel(src) + return FALSE + return TRUE + +/// One Move() into the next turf. Dense turfs and blockers refuse it, so the body never ends up inside a wall. +/datum/component/movable_physics/proc/step_body(atom/movable/body, step_dir) + var/turf/next = get_step(body, step_dir) + if(!next) + return FALSE + moving_self = TRUE + . = body.Move(next, step_dir, 0, FALSE) + moving_self = FALSE + +/// Fast processing for movable physics. Runs every 0.05 s while anything is in flight and idles otherwise. +PROCESSING_SUBSYSTEM_DEF(movable_physics) + name = "Movable Physics" + flags = SS_NO_INIT|SS_KEEP_TIMING + wait = 0.05 SECONDS + stat_tag = "MP" diff --git a/voidcrew/modules/gun_feel/muzzle_flash.dm b/voidcrew/modules/gun_feel/muzzle_flash.dm new file mode 100644 index 00000000000..c2347138cc7 --- /dev/null +++ b/voidcrew/modules/gun_feel/muzzle_flash.dm @@ -0,0 +1,113 @@ +// Muzzle flash for powder guns: tg's muzzle_bullet sprite at the barrel, turned to the shot, plus a +// short light on the shooter. After Shiptest's handle_muzzle_flash(). Energy weapons already glow +// (their projectiles carry a light) and hitscan rounds already spawn a lit muzzle, so neither gets one. + +/obj/item/ammo_casing + /// FALSE for rounds that don't burn powder (darts, arrows, lenses, gumballs...). + var/gun_feel_flash = TRUE + +/obj/item/ammo_casing/energy + gun_feel_flash = FALSE + +/obj/item/ammo_casing/laser + gun_feel_flash = FALSE + +/obj/item/ammo_casing/foam_dart + gun_feel_flash = FALSE + +/obj/item/ammo_casing/arrow + gun_feel_flash = FALSE + +/obj/item/ammo_casing/rebar + gun_feel_flash = FALSE + +/obj/item/ammo_casing/harpoon + gun_feel_flash = FALSE + +/obj/item/ammo_casing/gumball + gun_feel_flash = FALSE + +/obj/item/ammo_casing/lollipop + gun_feel_flash = FALSE + +/obj/item/ammo_casing/pea + gun_feel_flash = FALSE + +/obj/item/ammo_casing/magic + gun_feel_flash = FALSE + +/obj/item/ammo_casing/syringegun + gun_feel_flash = FALSE + +/obj/item/ammo_casing/chemgun + gun_feel_flash = FALSE + +/obj/item/ammo_casing/dnainjector + gun_feel_flash = FALSE + +/// TRUE if the round that was just fired should flash. +/obj/item/gun/proc/gun_feel_wants_flash() + if(suppressed) + return FALSE + var/obj/item/ammo_casing/casing = chambered + if(!istype(casing) || !casing.gun_feel_flash || !casing.firing_effect_type) + return FALSE + var/obj/projectile/shot_type = casing.projectile_type + if(ispath(shot_type, /obj/projectile) && initial(shot_type.hitscan) && initial(shot_type.muzzle_type)) + return FALSE + return TRUE + +/obj/item/gun/proc/gun_feel_muzzle_flash(mob/living/user, angle) + if(!isturf(user.loc)) + return + new /obj/effect/temp_visual/gun_feel_muzzle_flash(user, angle) + if(QDELETED(gun_feel_light) || gun_feel_light.loc != user) + gun_feel_clear_light() + gun_feel_light = user.mob_light(GUN_FEEL_FLASH_LIGHT_RANGE, GUN_FEEL_FLASH_LIGHT_POWER, GUN_FEEL_FLASH_LIGHT_COLOR) + if(QDELETED(gun_feel_light)) + gun_feel_light = null + return + RegisterSignal(gun_feel_light, COMSIG_QDELETING, PROC_REF(gun_feel_light_deleted)) + // Each shot pushes the light-off back, so automatic fire keeps one light lit instead of making one per shot. + addtimer(CALLBACK(src, PROC_REF(gun_feel_clear_light)), GUN_FEEL_FLASH_LIGHT_TIME, TIMER_UNIQUE | TIMER_OVERRIDE) + +/obj/item/gun/proc/gun_feel_clear_light() + if(isnull(gun_feel_light)) + return + UnregisterSignal(gun_feel_light, COMSIG_QDELETING) + QDEL_NULL(gun_feel_light) + +/obj/item/gun/proc/gun_feel_light_deleted(datum/source) + SIGNAL_HANDLER + gun_feel_light = null + +/// The flash itself. It rides in the shooter's vis_contents so it stays at the barrel while they move. +/obj/effect/temp_visual/gun_feel_muzzle_flash + name = "muzzle flash" + icon = GUN_FEEL_FLASH_ICON + icon_state = GUN_FEEL_FLASH_ICON_STATE + layer = ABOVE_MOB_LAYER + duration = GUN_FEEL_FLASH_TIME + randomdir = FALSE + appearance_flags = KEEP_APART | RESET_COLOR | TILE_BOUND | PIXEL_SCALE + vis_flags = VIS_INHERIT_PLANE + +/obj/effect/temp_visual/gun_feel_muzzle_flash/Initialize(mapload, angle = 0) + . = ..() + var/side = sin(angle) + var/forward = cos(angle) + // The barrel: out to the side, up when aiming away from the viewer, down when aiming at them. + var/barrel_x = side * GUN_FEEL_FLASH_REACH_SIDE + var/barrel_y = GUN_FEEL_FLASH_HAND_HEIGHT + forward * (forward > 0 ? GUN_FEEL_FLASH_REACH_UP : GUN_FEEL_FLASH_REACH_DOWN) + // Push the sprite out so its bright core, not its centre, sits on the barrel. + pixel_x = round(barrel_x + side * GUN_FEEL_FLASH_HOTSPOT) + pixel_y = round(barrel_y + forward * GUN_FEEL_FLASH_HOTSPOT) + var/matrix/turned = matrix() + turned.Turn(angle) + transform = turned + // Aiming away from the viewer puts the barrel behind the shooter's body. + if(forward > 0.5) + layer = BELOW_MOB_LAYER + if(ismovable(loc)) + var/atom/movable/shooter = loc + shooter.vis_contents += src diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..9836da6aa77 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -86,6 +86,7 @@ #include "voidcrew_fresh_cores.dm" #include "voidcrew_gear_behaviour.dm" #include "voidcrew_gear_fixes.dm" +#include "voidcrew_gun_feel.dm" #include "voidcrew_helm_access.dm" #include "voidcrew_helpers.dm" #include "voidcrew_hull_containment.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_gun_feel.dm b/voidcrew/modules/unit_tests/voidcrew_gun_feel.dm new file mode 100644 index 00000000000..0d6024846f9 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_gun_feel.dm @@ -0,0 +1,72 @@ +// voidcrew/modules/gun_feel. Voidcrew defines are not visible here, so values are literals. + +/// Drives a movable_physics component to the end of its flight without waiting on the subsystem. +/datum/unit_test/proc/gun_feel_finish_flight(datum/component/movable_physics/flight) + for(var/step in 1 to 200) + if(QDELETED(flight)) + return TRUE + flight.process(0.05) + return QDELETED(flight) + +/// A fired pistol flashes, and its casing lands as an ordinary item on an open turf with no physics left on it. +/datum/unit_test/voidcrew_gun_feel_casing +/datum/unit_test/voidcrew_gun_feel_casing/Run() + var/mob/living/carbon/human/consistent/shooter = allocate(/mob/living/carbon/human/consistent) + var/obj/item/gun/ballistic/automatic/pistol/pistol = allocate(/obj/item/gun/ballistic/automatic/pistol) + TEST_ASSERT(shooter.put_in_hands(pistol), "The shooter could not hold the pistol.") + var/obj/item/ammo_casing/casing = pistol.chambered + TEST_ASSERT_NOTNULL(casing, "A new pistol should start with a round chambered.") + TEST_ASSERT(pistol.process_fire(run_loc_floor_top_right, shooter, FALSE, null, "", 0), "The pistol did not fire.") + TEST_ASSERT(!QDELETED(casing), "The fired casing was deleted instead of ejected.") + TEST_ASSERT_NOTNULL(locate(/obj/effect/temp_visual/gun_feel_muzzle_flash) in shooter.vis_contents, "A powder round should show a muzzle flash on the shooter.") + TEST_ASSERT_NOTNULL(pistol.gun_feel_light, "A powder round should light the shooter up.") + + // The test level may have no gravity, in which case the casing drops the old way and has no flight. + var/datum/component/movable_physics/flight = casing.GetComponent(/datum/component/movable_physics) + if(flight) + TEST_ASSERT(gun_feel_finish_flight(flight), "The casing's flight never ended.") + TEST_ASSERT(isturf(casing.loc), "The casing should end up on a turf, not in [casing.loc || "nullspace"].") + var/turf/landing = casing.loc + TEST_ASSERT(!landing.density, "The casing landed inside a dense turf ([landing.type]).") + TEST_ASSERT_NULL(casing.GetComponent(/datum/component/movable_physics), "A settled casing kept its physics component.") + TEST_ASSERT_EQUAL(casing.pixel_z, casing.base_pixel_z, "A settled casing was left floating.") + if(flight) + TEST_ASSERT(!(flight in SSmovable_physics.processing), "A finished flight is still being processed.") + +/// A movable thrown hard at the corner glances off walls and still settles on an open turf. +/datum/unit_test/voidcrew_gun_feel_physics_walls +/datum/unit_test/voidcrew_gun_feel_physics_walls/Run() + for(var/angle in list(0, 135, 225, 315)) + var/obj/item/ammo_casing/c9mm/casing = allocate(/obj/item/ammo_casing/c9mm) + var/datum/component/movable_physics/flight = casing.AddComponent(/datum/component/movable_physics, 10, 3, angle, 8, FALSE, FALSE) + TEST_ASSERT_NOTNULL(flight, "The physics component refused a casing on a floor.") + TEST_ASSERT(gun_feel_finish_flight(flight), "A flight at [angle] degrees never ended.") + TEST_ASSERT(isturf(casing.loc), "A flight at [angle] degrees left the casing in [casing.loc || "nullspace"].") + var/turf/landing = casing.loc + TEST_ASSERT(!landing.density, "A flight at [angle] degrees ended inside a dense turf ([landing.type]).") + TEST_ASSERT(abs(casing.pixel_x - casing.base_pixel_x) <= 16 && abs(casing.pixel_y - casing.base_pixel_y) <= 16, "A flight at [angle] degrees left the casing drawn off its tile.") + TEST_ASSERT_EQUAL(casing.pixel_z, casing.base_pixel_z, "A flight at [angle] degrees left the casing floating.") + +/// Long guns are hip-fired until both hands have been on them for the shoulder delay (0.4 s). +/datum/unit_test/voidcrew_gun_feel_shouldering +/datum/unit_test/voidcrew_gun_feel_shouldering/Run() + var/mob/living/carbon/human/consistent/shooter = allocate(/mob/living/carbon/human/consistent) + var/obj/item/gun/ballistic/automatic/wt550/rifle = allocate(/obj/item/gun/ballistic/automatic/wt550) + TEST_ASSERT(shooter.put_in_hands(rifle), "The shooter could not hold the rifle.") + TEST_ASSERT(rifle.gun_feel_long, "The WT-550 should be a long gun.") + TEST_ASSERT(rifle.gun_feel_hip_spread(shooter) > 0, "A long gun should not be shouldered the moment it is drawn.") + + rifle.gun_feel_brace_start = max(1, world.time - 1 SECONDS) + TEST_ASSERT_EQUAL(rifle.gun_feel_hip_spread(shooter), 0, "A long gun held in both hands past the delay should be shouldered.") + + var/obj/item/pen/pen = allocate(/obj/item/pen) + TEST_ASSERT(shooter.put_in_hands(pen), "The shooter could not hold a pen in the other hand.") + TEST_ASSERT_EQUAL(rifle.gun_feel_brace_start, 0, "Filling the off hand should drop the shoulder.") + TEST_ASSERT(rifle.gun_feel_hip_spread(shooter) > 0, "A long gun held one-handed should fire from the hip.") + + shooter.dropItemToGround(pen) + TEST_ASSERT(rifle.gun_feel_brace_start, "Freeing the off hand should start the shoulder clock.") + TEST_ASSERT(rifle.gun_feel_hip_spread(shooter) > 0, "A long gun should not be shouldered the moment the off hand frees up.") + + var/obj/item/gun/ballistic/automatic/pistol/pistol = allocate(/obj/item/gun/ballistic/automatic/pistol) + TEST_ASSERT_EQUAL(pistol.gun_feel_hip_spread(shooter), 0, "Pistols have no hip-fire penalty.") From 6355b38546801e5df326dbfb18819d9090f6858f Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:08:34 -0800 Subject: [PATCH 004/472] Gun feel: Shiptest's gun sounds, bullet streaks, impacts and hit reactions Presentation only; no change to damage, speed, fire rate or any other mechanic. - Powder guns play their shots at the recorded pitch instead of a random 0.73x-1.25x; the low-ammo click keeps its rising pitch. Energy weapons keep a narrow +-6% spread. - Our stock pistol, revolver, SMG, rifle, shotgun, sniper and LMG shots are swapped for Shiptest's fuller recordings of the same class, on player guns and on NPCs that shoot. SMGs, long automatics and LMGs get their own magazine and rack sounds instead of the shared ones. - Bullets draw the long glowing streak (.50 a thicker one, slugs too); pellets and special rounds keep their own sprites. - Bullets hitting walls and objects make a metal, glass, stone, wood or snow impact by what they hit; flesh hits use Shiptest's impact set at full volume. Suppressed rounds stay quiet. Pellets hitting one thing together make one sound. - A bullet passing within a tile of someone (past its first two tiles) plays them a whizz and gives them a small flinch. Suppressed rounds don't. - Mobs hit by an attack jolt a few pixels along the blow and spring back, rate limited, as a relative parallel animation that never touches the sprite's transform or other animations. - Troopers and Voidcrew's other human NPCs have blood, so they splatter when shot. Cosmetic: basic mobs never lose blood or die of it. - The shooter no longer gets a chat line for every shot. - The muzzle flash light is a forward cone that follows the shooter's facing. - Casings on a floor lie at random 22.5 degree angles with a little scatter; held or loaded casings stand straight. Sounds and the firing feel come from Shiptest (shiptest-ss13/Shiptest): code ideas under AGPL-3.0, sound files under CC BY-SA 3.0. Per-file sources are in voidcrew/modules/gun_feel/sound/attribution.txt. --- code/modules/projectiles/gun.dm | 2 +- code/modules/projectiles/guns/ballistic.dm | 4 +- .../projectiles/guns/ballistic/revolver.dm | 4 +- code/modules/projectiles/guns/energy.dm | 4 +- tgstation.dme | 4 + voidcrew/_DEFINES/gun_feel.dm | 64 ++++++ voidcrew/modules/gun_feel/casings.dm | 45 ++++ voidcrew/modules/gun_feel/flinch.dm | 59 ++++++ voidcrew/modules/gun_feel/impacts.dm | 198 ++++++++++++++++++ voidcrew/modules/gun_feel/muzzle_flash.dm | 13 +- voidcrew/modules/gun_feel/npc_blood.dm | 31 +++ .../modules/gun_feel/sound/attribution.txt | 28 +++ .../gun_feel/sound/hit/bullet_glass_01.ogg | Bin 0 -> 17048 bytes .../gun_feel/sound/hit/bullet_glass_02.ogg | Bin 0 -> 14464 bytes .../gun_feel/sound/hit/bullet_glass_03.ogg | Bin 0 -> 15348 bytes .../gun_feel/sound/hit/bullet_glass_04.ogg | Bin 0 -> 11694 bytes .../gun_feel/sound/hit/bullet_glass_05.ogg | Bin 0 -> 13164 bytes .../gun_feel/sound/hit/bullet_glass_06.ogg | Bin 0 -> 9215 bytes .../gun_feel/sound/hit/bullet_glass_07.ogg | Bin 0 -> 10614 bytes .../gun_feel/sound/hit/bullet_impact1.ogg | Bin 0 -> 9464 bytes .../gun_feel/sound/hit/bullet_impact2.ogg | Bin 0 -> 9105 bytes .../gun_feel/sound/hit/bullet_impact3.ogg | Bin 0 -> 10924 bytes .../gun_feel/sound/hit/bullet_masonry_01.ogg | Bin 0 -> 15896 bytes .../gun_feel/sound/hit/bullet_masonry_02.ogg | Bin 0 -> 20941 bytes .../gun_feel/sound/hit/bullet_masonry_03.ogg | Bin 0 -> 21259 bytes .../gun_feel/sound/hit/bullet_masonry_04.ogg | Bin 0 -> 18885 bytes .../gun_feel/sound/hit/bullet_masonry_05.ogg | Bin 0 -> 23554 bytes .../gun_feel/sound/hit/bullet_masonry_06.ogg | Bin 0 -> 17524 bytes .../gun_feel/sound/hit/bullet_metal_01.ogg | Bin 0 -> 16704 bytes .../gun_feel/sound/hit/bullet_metal_02.ogg | Bin 0 -> 12006 bytes .../gun_feel/sound/hit/bullet_metal_03.ogg | Bin 0 -> 13870 bytes .../gun_feel/sound/hit/bullet_metal_04.ogg | Bin 0 -> 15663 bytes .../gun_feel/sound/hit/bullet_metal_05.ogg | Bin 0 -> 13815 bytes .../gun_feel/sound/hit/bullet_metal_06.ogg | Bin 0 -> 14063 bytes .../gun_feel/sound/hit/bullet_miss1.ogg | Bin 0 -> 21417 bytes .../gun_feel/sound/hit/bullet_miss2.ogg | Bin 0 -> 19830 bytes .../gun_feel/sound/hit/bullet_miss3.ogg | Bin 0 -> 20657 bytes .../gun_feel/sound/hit/bullet_snow_01.ogg | Bin 0 -> 13783 bytes .../gun_feel/sound/hit/bullet_snow_02.ogg | Bin 0 -> 18088 bytes .../gun_feel/sound/hit/bullet_snow_03.ogg | Bin 0 -> 13532 bytes .../gun_feel/sound/hit/bullet_snow_04.ogg | Bin 0 -> 11728 bytes .../gun_feel/sound/hit/bullet_snow_05.ogg | Bin 0 -> 17937 bytes .../gun_feel/sound/hit/bullet_snow_06.ogg | Bin 0 -> 13631 bytes .../gun_feel/sound/hit/bullet_wood_01.ogg | Bin 0 -> 9254 bytes .../gun_feel/sound/hit/bullet_wood_02.ogg | Bin 0 -> 11210 bytes .../gun_feel/sound/hit/bullet_wood_03.ogg | Bin 0 -> 22309 bytes .../gun_feel/sound/hit/bullet_wood_04.ogg | Bin 0 -> 10953 bytes .../gun_feel/sound/hit/bullet_wood_05.ogg | Bin 0 -> 12239 bytes .../gun_feel/sound/hit/bullet_wood_06.ogg | Bin 0 -> 11322 bytes .../gun_feel/sound/hmg/cm40_reload.ogg | Bin 0 -> 12717 bytes .../gun_feel/sound/hmg/cm40_unload.ogg | Bin 0 -> 22752 bytes voidcrew/modules/gun_feel/sound/l6/shot.ogg | Bin 0 -> 36714 bytes .../modules/gun_feel/sound/pistol/shot.ogg | Bin 0 -> 15402 bytes .../gun_feel/sound/pistol/shot_alt.ogg | Bin 0 -> 29045 bytes .../modules/gun_feel/sound/revolver/shot.ogg | Bin 0 -> 45278 bytes .../gun_feel/sound/revolver/shot_light.ogg | Bin 0 -> 61848 bytes .../modules/gun_feel/sound/rifle/ar_cock.ogg | Bin 0 -> 14157 bytes .../gun_feel/sound/rifle/ar_reload.ogg | Bin 0 -> 7333 bytes .../gun_feel/sound/rifle/ar_unload.ogg | Bin 0 -> 6779 bytes .../modules/gun_feel/sound/rifle/shot.ogg | Bin 0 -> 37110 bytes .../modules/gun_feel/sound/shotgun/shot.ogg | Bin 0 -> 40937 bytes voidcrew/modules/gun_feel/sound/smg/shot.ogg | Bin 0 -> 19777 bytes .../modules/gun_feel/sound/smg/smg_reload.ogg | Bin 0 -> 14492 bytes .../modules/gun_feel/sound/smg/smg_unload.ogg | Bin 0 -> 14382 bytes .../modules/gun_feel/sound/sniper/shot.ogg | Bin 0 -> 54395 bytes voidcrew/modules/gun_feel/sounds.dm | 181 ++++++++++++++++ .../modules/unit_tests/voidcrew_gun_feel.dm | 96 +++++++++ 67 files changed, 723 insertions(+), 10 deletions(-) create mode 100644 voidcrew/modules/gun_feel/flinch.dm create mode 100644 voidcrew/modules/gun_feel/impacts.dm create mode 100644 voidcrew/modules/gun_feel/npc_blood.dm create mode 100644 voidcrew/modules/gun_feel/sound/attribution.txt create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_glass_01.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_glass_02.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_glass_03.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_glass_04.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_glass_05.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_glass_06.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_glass_07.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_impact1.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_impact2.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_impact3.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_masonry_01.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_masonry_02.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_masonry_03.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_masonry_04.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_masonry_05.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_masonry_06.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_metal_01.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_metal_02.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_metal_03.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_metal_04.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_metal_05.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_metal_06.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_miss1.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_miss2.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_miss3.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_snow_01.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_snow_02.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_snow_03.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_snow_04.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_snow_05.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_snow_06.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_wood_01.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_wood_02.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_wood_03.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_wood_04.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_wood_05.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hit/bullet_wood_06.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hmg/cm40_reload.ogg create mode 100644 voidcrew/modules/gun_feel/sound/hmg/cm40_unload.ogg create mode 100644 voidcrew/modules/gun_feel/sound/l6/shot.ogg create mode 100644 voidcrew/modules/gun_feel/sound/pistol/shot.ogg create mode 100644 voidcrew/modules/gun_feel/sound/pistol/shot_alt.ogg create mode 100644 voidcrew/modules/gun_feel/sound/revolver/shot.ogg create mode 100644 voidcrew/modules/gun_feel/sound/revolver/shot_light.ogg create mode 100644 voidcrew/modules/gun_feel/sound/rifle/ar_cock.ogg create mode 100644 voidcrew/modules/gun_feel/sound/rifle/ar_reload.ogg create mode 100644 voidcrew/modules/gun_feel/sound/rifle/ar_unload.ogg create mode 100644 voidcrew/modules/gun_feel/sound/rifle/shot.ogg create mode 100644 voidcrew/modules/gun_feel/sound/shotgun/shot.ogg create mode 100644 voidcrew/modules/gun_feel/sound/smg/shot.ogg create mode 100644 voidcrew/modules/gun_feel/sound/smg/smg_reload.ogg create mode 100644 voidcrew/modules/gun_feel/sound/smg/smg_unload.ogg create mode 100644 voidcrew/modules/gun_feel/sound/sniper/shot.ogg create mode 100644 voidcrew/modules/gun_feel/sounds.dm diff --git a/code/modules/projectiles/gun.dm b/code/modules/projectiles/gun.dm index 03466b78181..bf87608b7a1 100644 --- a/code/modules/projectiles/gun.dm +++ b/code/modules/projectiles/gun.dm @@ -247,7 +247,7 @@ else if(!tk_firing(user)) user.visible_message( span_danger("[user] fires [src]!"), - span_danger("You fire [src]!"), + null, // VOIDCREW EDIT CHANGE - gun_feel: no chat line to the shooter on every shot (voidcrew/modules/gun_feel). ORIGINAL: span_danger("You fire [src]!"), span_hear("You hear a gunshot!"), vision_distance = COMBAT_MESSAGE_RANGE, ignored_mobs = user, diff --git a/code/modules/projectiles/guns/ballistic.dm b/code/modules/projectiles/guns/ballistic.dm index 83f03c90e54..0d510ff1164 100644 --- a/code/modules/projectiles/guns/ballistic.dm +++ b/code/modules/projectiles/guns/ballistic.dm @@ -175,11 +175,11 @@ if(suppressed) playsound(src, suppressed_sound, suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0) if(play_click && click_on_low_ammo) - playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0, frequency = click_frequency_to_use) + playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', suppressed_volume, TRUE, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0, frequency = click_frequency_to_use) // VOIDCREW EDIT CHANGE - gun_feel: powder guns no longer vary their shot (voidcrew/modules/gun_feel/sounds.dm), but the low-ammo click keeps its rising pitch. ORIGINAL vary arg: vary_fire_sound else playsound(src, fire_sound, fire_sound_volume, vary_fire_sound) if(play_click && click_on_low_ammo) - playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', fire_sound_volume, vary_fire_sound, frequency = click_frequency_to_use) + playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', fire_sound_volume, TRUE, frequency = click_frequency_to_use) // VOIDCREW EDIT CHANGE - gun_feel: powder guns no longer vary their shot (voidcrew/modules/gun_feel/sounds.dm), but the low-ammo click keeps its rising pitch. ORIGINAL vary arg: vary_fire_sound /** diff --git a/code/modules/projectiles/guns/ballistic/revolver.dm b/code/modules/projectiles/guns/ballistic/revolver.dm index 06a8b50c566..c63d6205580 100644 --- a/code/modules/projectiles/guns/ballistic/revolver.dm +++ b/code/modules/projectiles/guns/ballistic/revolver.dm @@ -51,11 +51,11 @@ if(suppressed) playsound(src, suppressed_sound, suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0) if(play_click) - playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0, frequency = click_frequency_to_use) + playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', suppressed_volume, TRUE, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0, frequency = click_frequency_to_use) // VOIDCREW EDIT CHANGE - gun_feel: powder guns no longer vary their shot (voidcrew/modules/gun_feel/sounds.dm), but the low-ammo click keeps its rising pitch. ORIGINAL vary arg: vary_fire_sound else playsound(src, fire_sound, fire_sound_volume, vary_fire_sound) if(play_click) - playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', fire_sound_volume, vary_fire_sound, frequency = click_frequency_to_use) + playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', fire_sound_volume, TRUE, frequency = click_frequency_to_use) // VOIDCREW EDIT CHANGE - gun_feel: powder guns no longer vary their shot (voidcrew/modules/gun_feel/sounds.dm), but the low-ammo click keeps its rising pitch. ORIGINAL vary arg: vary_fire_sound /obj/item/gun/ballistic/revolver/verb/spin() set name = "Spin Chamber" diff --git a/code/modules/projectiles/guns/energy.dm b/code/modules/projectiles/guns/energy.dm index 734bce37122..2f3a69830ed 100644 --- a/code/modules/projectiles/guns/energy.dm +++ b/code/modules/projectiles/guns/energy.dm @@ -67,9 +67,9 @@ playing_sound.pitch = pitch_to_use if(suppressed) - playsound(src, playing_sound, suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0) + playsound(src, playing_sound, suppressed_volume, vary_fire_sound, ignore_walls = FALSE, extrarange = SILENCED_SOUND_EXTRARANGE, falloff_distance = 0, frequency = gun_feel_fire_frequency()) // VOIDCREW EDIT CHANGE - gun_feel: a narrow random pitch instead of 0.73x-1.25x (voidcrew/modules/gun_feel/sounds.dm) else - playsound(src, playing_sound, fire_sound_volume, vary_fire_sound) + playsound(src, playing_sound, fire_sound_volume, vary_fire_sound, frequency = gun_feel_fire_frequency()) // VOIDCREW EDIT CHANGE - gun_feel: a narrow random pitch instead of 0.73x-1.25x (voidcrew/modules/gun_feel/sounds.dm) /obj/item/gun/energy/emp_act(severity) . = ..() diff --git a/tgstation.dme b/tgstation.dme index 53fa5ec84cf..ff5c6569395 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7229,9 +7229,13 @@ #include "voidcrew\modules\dynamic_events\events\wormholes.dm" #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" #include "voidcrew\modules\gun_feel\casings.dm" +#include "voidcrew\modules\gun_feel\flinch.dm" #include "voidcrew\modules\gun_feel\gun_feel.dm" +#include "voidcrew\modules\gun_feel\impacts.dm" #include "voidcrew\modules\gun_feel\movable_physics.dm" #include "voidcrew\modules\gun_feel\muzzle_flash.dm" +#include "voidcrew\modules\gun_feel\npc_blood.dm" +#include "voidcrew\modules\gun_feel\sounds.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" diff --git a/voidcrew/_DEFINES/gun_feel.dm b/voidcrew/_DEFINES/gun_feel.dm index e71e6efe630..8b76680c54b 100644 --- a/voidcrew/_DEFINES/gun_feel.dm +++ b/voidcrew/_DEFINES/gun_feel.dm @@ -104,3 +104,67 @@ #define MOVABLE_PHYSICS_MIN_BOUNCE 1.2 /// Anything still moving after this long settles where it is. #define MOVABLE_PHYSICS_TIMEOUT (3 SECONDS) + +// ---- Sounds ---- + +/// A random playback speed of 1 +- spread, as a multiplier of the recording's own rate. +/// BYOND reads a sound frequency between -100 and 100 as a multiplier, so this works whatever the file's sample rate. +#define GUN_FEEL_PITCH(spread) (1 + (rand() * 2 - 1) * (spread)) +/// Pitch spread for energy weapon shots. Powder guns play at their recorded pitch every time. +#define GUN_FEEL_ENERGY_PITCH_SPREAD 0.06 +/// Pitch spread for bullet impacts and near misses. +#define GUN_FEEL_IMPACT_PITCH_SPREAD 0.08 + +// Sound effect keys for /datum/sound_effect. +#define SFX_GUN_FEEL_BULLET_FLESH "gun_feel_bullet_flesh" +#define SFX_GUN_FEEL_BULLET_MISS "gun_feel_bullet_miss" +#define SFX_GUN_FEEL_HIT_METAL "gun_feel_hit_metal" +#define SFX_GUN_FEEL_HIT_GLASS "gun_feel_hit_glass" +#define SFX_GUN_FEEL_HIT_STONE "gun_feel_hit_stone" +#define SFX_GUN_FEEL_HIT_WOOD "gun_feel_hit_wood" +#define SFX_GUN_FEEL_HIT_SNOW "gun_feel_hit_snow" + +// What a bullet hit is made of, for its impact sound. +#define GUN_FEEL_SURFACE_FLESH "flesh" +#define GUN_FEEL_SURFACE_METAL "metal" +#define GUN_FEEL_SURFACE_GLASS "glass" +#define GUN_FEEL_SURFACE_STONE "stone" +#define GUN_FEEL_SURFACE_WOOD "wood" +#define GUN_FEEL_SURFACE_SNOW "snow" + +/// Volume of a bullet hitting flesh. Fixed, like Shiptest, instead of scaling with damage. +#define GUN_FEEL_FLESH_HIT_VOLUME 100 +/// Volume of a suppressed bullet hitting anything. Matches upstream vol_by_damage(). +#define GUN_FEEL_SUPPRESSED_HIT_VOLUME 5 +/// One thing hit by several pellets or bullets within this many deciseconds makes one impact sound. +#define GUN_FEEL_IMPACT_SOUND_WINDOW 1 + +// ---- Near misses ---- + +/// A bullet must be at least this many tiles from where it was fired before it whizzes past anyone. +#define GUN_FEEL_NEAR_MISS_MIN_TRAVEL 3 +#define GUN_FEEL_NEAR_MISS_VOLUME 70 +/// Flinch strength, in pixels, for a bullet passing close by. +#define GUN_FEEL_NEAR_MISS_FLINCH 1 +/// One mob hears at most one whizz in this window (a shotgun blast is one whizz, not six). +#define GUN_FEEL_NEAR_MISS_COOLDOWN (0.1 SECONDS) + +// ---- Hit flinch ---- + +/// Flinch strength, in pixels, is BASE + damage * PER_DAMAGE, rounded and capped at MAX. +#define GUN_FEEL_FLINCH_BASE 1 +#define GUN_FEEL_FLINCH_PER_DAMAGE 0.08 +#define GUN_FEEL_FLINCH_MAX 3 +/// Vertical flinch is this share of the horizontal one; a sprite reads sideways jolts better. +#define GUN_FEEL_FLINCH_VERTICAL 0.5 +#define GUN_FEEL_FLINCH_OUT_TIME (0.1 SECONDS) +#define GUN_FEEL_FLINCH_BACK_TIME (0.25 SECONDS) +/// No new flinch starts until the last one has played out, so automatic fire never stacks them. +#define GUN_FEEL_FLINCH_COOLDOWN (0.35 SECONDS) + +// ---- Resting casings ---- + +/// Casings on the floor lie at a random multiple of this angle. +#define GUN_FEEL_CASING_ROTATION_STEP 22.5 +/// Pixel scatter for a casing that lands on a floor without flying there. +#define GUN_FEEL_CASING_DROP_SCATTER 8 diff --git a/voidcrew/modules/gun_feel/casings.dm b/voidcrew/modules/gun_feel/casings.dm index efadcf4f656..c0c5b43233e 100644 --- a/voidcrew/modules/gun_feel/casings.dm +++ b/voidcrew/modules/gun_feel/casings.dm @@ -44,3 +44,48 @@ var/lift = GUN_FEEL_CASING_LIFT_MIN + (GUN_FEEL_CASING_LIFT_MAX - GUN_FEEL_CASING_LIFT_MIN) * rand() var/datum/component/movable_physics/flight = AddComponent(/datum/component/movable_physics, speed, lift, angle, GUN_FEEL_CASING_START_HEIGHT, !!still_warm, TRUE) return !QDELETED(flight) + +// ---- Resting casings ---- +// Casings on a floor lie at a random angle in 22.5 degree steps, as in Shiptest, so piles look tossed +// rather than lined up. A casing in a hand, a pocket or a magazine stands straight again. +// The spins (throwing, ejecting, bouncing) all end on the current transform, so they keep the angle. + +/obj/item/ammo_casing + /// FALSE for rounds too big to lie at a random angle, like rockets. + var/gun_feel_tumbles = TRUE + +/obj/item/ammo_casing/rocket + gun_feel_tumbles = FALSE + +/obj/item/ammo_casing/Initialize(mapload) + . = ..() + if(isturf(loc)) + gun_feel_lie_down() + +/obj/item/ammo_casing/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) + . = ..() + var/on_floor = isturf(loc) + // Only when it lands from somewhere else or gets picked up; not on every step of a flight. + if(on_floor == isturf(old_loc)) + return + if(!on_floor) + gun_feel_stand_up() + return + gun_feel_lie_down() + // Tipped out of a gun, box or hand: land a little apart from the others. + if(!GetComponent(/datum/component/movable_physics)) + pixel_x = base_pixel_x + rand(-GUN_FEEL_CASING_DROP_SCATTER, GUN_FEEL_CASING_DROP_SCATTER) + pixel_y = base_pixel_y + rand(-GUN_FEEL_CASING_DROP_SCATTER, GUN_FEEL_CASING_DROP_SCATTER) + +/// Turns this casing to a random resting angle. +/obj/item/ammo_casing/proc/gun_feel_lie_down() + if(!gun_feel_tumbles) + return + var/matrix/resting = matrix() + resting.Turn(GUN_FEEL_CASING_ROTATION_STEP * rand(0, 15)) + transform = resting + +/// Stands this casing straight again, for hands, pockets and magazines. +/obj/item/ammo_casing/proc/gun_feel_stand_up() + if(gun_feel_tumbles) + transform = matrix() diff --git a/voidcrew/modules/gun_feel/flinch.dm b/voidcrew/modules/gun_feel/flinch.dm new file mode 100644 index 00000000000..498675e4457 --- /dev/null +++ b/voidcrew/modules/gun_feel/flinch.dm @@ -0,0 +1,59 @@ +// Hit flinch: a mob taking brute or burn damage from an attack jolts a few pixels along the blow and +// springs back. After Shiptest's shake_animation() in apply_damage(), but it only moves pixel_w and +// pixel_z, as a relative parallel animation that adds up to zero, the same way tg's shake_up_animation() +// works. So it never touches transform (lying down, size) and never stops or shifts any other animation. + +/mob/living + /// No new flinch starts until this world.time, so automatic fire never stacks them. + COOLDOWN_DECLARE(gun_feel_flinch_cooldown) + +/mob/living/apply_damage( + damage = 0, + damagetype = BRUTE, + def_zone = null, + blocked = 0, + forced = FALSE, + spread_damage = FALSE, + wound_bonus = 0, + exposed_wound_bonus = 0, + sharpness = NONE, + attack_direction = null, + attacking_item, + wound_clothing = TRUE, +) + . = ..() + // Only blows: something hit us, or came from a direction. Fire, cold and the like don't flinch. + if(. > 0 && (damagetype == BRUTE || damagetype == BURN) && (attacking_item || attack_direction)) + gun_feel_flinch(GUN_FEEL_FLINCH_BASE + . * GUN_FEEL_FLINCH_PER_DAMAGE, attack_direction) + +/** + * Jolts this mob's sprite a few pixels and lets it spring back. + * + * Arguments: + * * strength - pixels, rounded and capped at GUN_FEEL_FLINCH_MAX + * * push_dir - direction to jolt towards (the way the blow was travelling). Null picks a side. + * + * Returns TRUE if a flinch started. + */ +/mob/living/proc/gun_feel_flinch(strength, push_dir) + if(stat == DEAD || !isturf(loc) || !COOLDOWN_FINISHED(src, gun_feel_flinch_cooldown)) + return FALSE + var/pixels = clamp(round(strength, 1), 1, GUN_FEEL_FLINCH_MAX) + var/sign_x = 0 + var/sign_y = 0 + if(push_dir & EAST) + sign_x = 1 + else if(push_dir & WEST) + sign_x = -1 + if(push_dir & NORTH) + sign_y = 1 + else if(push_dir & SOUTH) + sign_y = -1 + if(!sign_x && !sign_y) + sign_x = pick(-1, 1) + var/shift_w = sign_x * pixels + var/shift_z = sign_y * max(1, round(pixels * GUN_FEEL_FLINCH_VERTICAL, 1)) + COOLDOWN_START(src, gun_feel_flinch_cooldown, GUN_FEEL_FLINCH_COOLDOWN) + animate(src, pixel_w = shift_w, pixel_z = shift_z, time = GUN_FEEL_FLINCH_OUT_TIME, easing = QUAD_EASING | EASE_OUT, flags = ANIMATION_PARALLEL | ANIMATION_RELATIVE) + animate(pixel_w = -shift_w, pixel_z = -shift_z, time = GUN_FEEL_FLINCH_BACK_TIME, easing = ELASTIC_EASING | EASE_OUT, flags = ANIMATION_RELATIVE) + return TRUE diff --git a/voidcrew/modules/gun_feel/impacts.dm b/voidcrew/modules/gun_feel/impacts.dm new file mode 100644 index 00000000000..71b134ef98f --- /dev/null +++ b/voidcrew/modules/gun_feel/impacts.dm @@ -0,0 +1,198 @@ +// Bullets: the long glowing streak sprite, an impact sound that matches what was hit, and a whizz and +// flinch for anyone a bullet passes close to. After Shiptest's bullet look, bullet_hit_sfx() and the +// near-miss check in its scan_moved_turf(). No ricochets: a bullet that hits something stops as before. + +/obj/projectile/bullet + // Shiptest's streak. tg ships the same "gauss" state in this icon file. + icon_state = "gauss" + // gun_feel_impact_sound() picks the hit sound from what was hit instead. + // A round that sets its own hitsound (cannonball, sonic slug...) keeps it. + hitsound = null + hitsound_wall = null + /// Mobs this bullet has already whizzed past, so it whizzes once per mob. + var/list/gun_feel_whizzed + +/// .50 rounds draw a thicker streak. +/obj/projectile/bullet/p50 + icon_state = "gaussstrong" + +// A slug is one heavy round, not a pellet. Shiptest draws slugs as a streak too. +/obj/projectile/bullet/shotgun_slug + icon_state = "gauss" + +/obj/projectile/bullet/shotgun_slug/milspec + icon_state = "gauss" + +/obj/projectile/bullet/Destroy() + gun_feel_whizzed = null + return ..() + +// ---- Impact sounds ---- + +/atom + /// world.time until which a bullet hitting this makes no new impact sound (pellets hitting together). + var/tmp/gun_feel_impact_quiet_until = 0 + +/obj/projectile/bullet/on_hit(atom/target, blocked = 0, pierce_hit) + . = ..() + if(. != BULLET_ACT_BLOCK) + gun_feel_impact_sound(target, blocked) + +/// Plays the sound of this bullet hitting `target`: flesh, or the surface it is made of. +/obj/projectile/bullet/proc/gun_feel_impact_sound(atom/target, blocked = 0) + if(hitsound || hitsound_wall || QDELETED(target)) + return + if(world.time < target.gun_feel_impact_quiet_until) + return + var/surface = gun_feel_surface_of(target) + // Fully stopped by armour: it rings off the plate. + if(surface == GUN_FEEL_SURFACE_FLESH && blocked >= 100) + surface = GUN_FEEL_SURFACE_METAL + var/sound_key = GLOB.gun_feel_surface_sounds[surface] + if(!sound_key) + return + target.gun_feel_impact_quiet_until = world.time + GUN_FEEL_IMPACT_SOUND_WINDOW + var/volume + if(suppressed) + volume = GUN_FEEL_SUPPRESSED_HIT_VOLUME + else if(surface == GUN_FEEL_SURFACE_FLESH) + volume = GUN_FEEL_FLESH_HIT_VOLUME + else + // The same loudness upstream gave a bullet hitting a wall. + volume = clamp(vol_by_damage() + 20, GUN_FEEL_SUPPRESSED_HIT_VOLUME, 100) + playsound(src, sound_key, volume, TRUE, -1, frequency = GUN_FEEL_PITCH(GUN_FEEL_IMPACT_PITCH_SPREAD)) + +/// Surface name to sound effect key. +GLOBAL_LIST_INIT(gun_feel_surface_sounds, list( + GUN_FEEL_SURFACE_FLESH = SFX_GUN_FEEL_BULLET_FLESH, + GUN_FEEL_SURFACE_METAL = SFX_GUN_FEEL_HIT_METAL, + GUN_FEEL_SURFACE_GLASS = SFX_GUN_FEEL_HIT_GLASS, + GUN_FEEL_SURFACE_STONE = SFX_GUN_FEEL_HIT_STONE, + GUN_FEEL_SURFACE_WOOD = SFX_GUN_FEEL_HIT_WOOD, + GUN_FEEL_SURFACE_SNOW = SFX_GUN_FEEL_HIT_SNOW, +)) + +/// Types whose surface can't be read from their materials, by surface. Subtypes count too. +GLOBAL_LIST_INIT(gun_feel_surface_types, list( + GUN_FEEL_SURFACE_GLASS = typecacheof(list( + /obj/machinery/door/window, + /obj/structure/displaycase, + /obj/structure/table/glass, + /obj/structure/window, + )), + GUN_FEEL_SURFACE_WOOD = typecacheof(list( + /obj/structure/barricade/wooden, + /obj/structure/bookcase, + /obj/structure/chair/wood, + /obj/structure/closet/crate/wooden, + /obj/structure/dresser, + /obj/structure/flora/tree, + /obj/structure/table/wood, + )), + GUN_FEEL_SURFACE_STONE = typecacheof(list( + /obj/structure/flora/rock, + /obj/structure/statue, + /turf/closed/indestructible/rock, + /turf/closed/mineral, + /turf/closed/wall/rock, + /turf/open/misc, + )), + GUN_FEEL_SURFACE_SNOW = typecacheof(list( + /obj/structure/barricade/sandbags, + /turf/closed/wall/ice, + )), +)) + +/// Surface by type, worked out once per type. +GLOBAL_LIST_EMPTY(gun_feel_surface_by_type) + +/// What a bullet hitting `target` should sound like: one of the GUN_FEEL_SURFACE_* names. +/proc/gun_feel_surface_of(atom/target) + if(isliving(target)) + var/mob/living/victim = target + return (victim.mob_biotypes & MOB_ROBOTIC) ? GUN_FEEL_SURFACE_METAL : GUN_FEEL_SURFACE_FLESH + // Materials vary per object (a table made of wood or of glass), so they are read every time. + var/from_materials = gun_feel_surface_of_materials(target.custom_materials) + if(from_materials) + return from_materials + var/surface = GLOB.gun_feel_surface_by_type[target.type] + if(isnull(surface)) + surface = gun_feel_surface_of_type(target.type) + GLOB.gun_feel_surface_by_type[target.type] = surface + return surface + +/// Surface for a type with no materials to go by. +/proc/gun_feel_surface_of_type(atom_type) + // Snowy and icy rock looks like stone by type, so check its name first. + if(ispath(atom_type, /turf/closed/mineral) || ispath(atom_type, /turf/closed/indestructible/rock) || ispath(atom_type, /turf/open/misc)) + var/type_name = "[atom_type]" + if(findtext(type_name, "snow") || findtext(type_name, "/ice") || findtext(type_name, "glacial")) + return GUN_FEEL_SURFACE_SNOW + for(var/surface in GLOB.gun_feel_surface_types) + var/list/types = GLOB.gun_feel_surface_types[surface] + if(types[atom_type]) + return surface + return GUN_FEEL_SURFACE_METAL + +/// Surface for the main material in a custom_materials list, or null if there is none. +/proc/gun_feel_surface_of_materials(list/materials) + if(!length(materials)) + return null + var/main_type + var/most = 0 + for(var/material in materials) + var/amount = materials[material] + if(!isnum(amount) || amount <= most) + continue + var/datum/material/as_datum = material + var/material_type = ispath(material) ? material : (istype(as_datum) ? as_datum.type : null) + if(!material_type) + continue + main_type = material_type + most = amount + if(!main_type) + return null + if(ispath(main_type, /datum/material/wood) || ispath(main_type, /datum/material/bamboo) || ispath(main_type, /datum/material/paper) || ispath(main_type, /datum/material/cardboard)) + return GUN_FEEL_SURFACE_WOOD + if(ispath(main_type, /datum/material/glass) || ispath(main_type, /datum/material/alloy/plasmaglass) || ispath(main_type, /datum/material/alloy/titaniumglass) || ispath(main_type, /datum/material/alloy/plastitaniumglass) || ispath(main_type, /datum/material/diamond)) + return GUN_FEEL_SURFACE_GLASS + if(ispath(main_type, /datum/material/sandstone) || ispath(main_type, /datum/material/sand) || ispath(main_type, /datum/material/bone)) + return GUN_FEEL_SURFACE_STONE + if(ispath(main_type, /datum/material/snow)) + return GUN_FEEL_SURFACE_SNOW + if(ispath(main_type, /datum/material/meat)) + return GUN_FEEL_SURFACE_FLESH + return GUN_FEEL_SURFACE_METAL + +// ---- Near misses ---- + +/mob/living + /// Stops one mob hearing a whole shotgun blast whizz past as six separate bullets. + COOLDOWN_DECLARE(gun_feel_whizz_cooldown) + +/obj/projectile/bullet/scan_moved_turf() + . = ..() + if(!QDELETED(src) && !deletion_queued) + gun_feel_near_miss() + +/// Whizzes past and flinches every conscious mob next to this bullet that it hasn't already passed. +/// Suppressed rounds don't: a whizz would give away a shot nobody heard. +/obj/projectile/bullet/proc/gun_feel_near_miss() + if(suppressed) + return + var/turf/here = loc + if(!isturf(here) || !starting || get_dist(starting, here) < GUN_FEEL_NEAR_MISS_MIN_TRAVEL) + return + for(var/turf/nearby as anything in RANGE_TURFS(1, here)) + for(var/mob/living/bystander in nearby) + if(bystander == firer || bystander.stat != CONSCIOUS) + continue + if(LAZYFIND(gun_feel_whizzed, bystander) || impacted[bystander.weak_reference]) + continue + LAZYADD(gun_feel_whizzed, bystander) + if(!COOLDOWN_FINISHED(bystander, gun_feel_whizz_cooldown)) + continue + COOLDOWN_START(bystander, gun_feel_whizz_cooldown, GUN_FEEL_NEAR_MISS_COOLDOWN) + if(bystander.client) + bystander.playsound_local(here, SFX_GUN_FEEL_BULLET_MISS, GUN_FEEL_NEAR_MISS_VOLUME, TRUE, GUN_FEEL_PITCH(GUN_FEEL_IMPACT_PITCH_SPREAD)) + bystander.gun_feel_flinch(GUN_FEEL_NEAR_MISS_FLINCH, nearby == here ? null : get_dir(here, bystander)) diff --git a/voidcrew/modules/gun_feel/muzzle_flash.dm b/voidcrew/modules/gun_feel/muzzle_flash.dm index c2347138cc7..cbf101e632b 100644 --- a/voidcrew/modules/gun_feel/muzzle_flash.dm +++ b/voidcrew/modules/gun_feel/muzzle_flash.dm @@ -1,6 +1,7 @@ // Muzzle flash for powder guns: tg's muzzle_bullet sprite at the barrel, turned to the shot, plus a -// short light on the shooter. After Shiptest's handle_muzzle_flash(). Energy weapons already glow -// (their projectiles carry a light) and hitscan rounds already spawn a lit muzzle, so neither gets one. +// short cone of light thrown ahead of the shooter. After Shiptest's handle_muzzle_flash() and its +// directional gun light. Energy weapons already glow (their projectiles carry a light) and hitscan +// rounds already spawn a lit muzzle, so neither gets one. /obj/item/ammo_casing /// FALSE for rounds that don't burn powder (darts, arrows, lenses, gumballs...). @@ -63,7 +64,7 @@ new /obj/effect/temp_visual/gun_feel_muzzle_flash(user, angle) if(QDELETED(gun_feel_light) || gun_feel_light.loc != user) gun_feel_clear_light() - gun_feel_light = user.mob_light(GUN_FEEL_FLASH_LIGHT_RANGE, GUN_FEEL_FLASH_LIGHT_POWER, GUN_FEEL_FLASH_LIGHT_COLOR) + gun_feel_light = user.mob_light(GUN_FEEL_FLASH_LIGHT_RANGE, GUN_FEEL_FLASH_LIGHT_POWER, GUN_FEEL_FLASH_LIGHT_COLOR, null, /obj/effect/dummy/lighting_obj/moblight/gun_feel_muzzle) if(QDELETED(gun_feel_light)) gun_feel_light = null return @@ -71,6 +72,12 @@ // Each shot pushes the light-off back, so automatic fire keeps one light lit instead of making one per shot. addtimer(CALLBACK(src, PROC_REF(gun_feel_clear_light)), GUN_FEEL_FLASH_LIGHT_TIME, TIMER_UNIQUE | TIMER_OVERRIDE) +/// The flash light. Directional, like a flashlight: it follows the shooter's facing, and firing turns +/// the shooter to face the target, so the cone points down the line of fire. +/obj/effect/dummy/lighting_obj/moblight/gun_feel_muzzle + name = "muzzle flash lighting" + light_system = OVERLAY_LIGHT_DIRECTIONAL + /obj/item/gun/proc/gun_feel_clear_light() if(isnull(gun_feel_light)) return diff --git a/voidcrew/modules/gun_feel/npc_blood.dm b/voidcrew/modules/gun_feel/npc_blood.dm new file mode 100644 index 00000000000..3aec88f9264 --- /dev/null +++ b/voidcrew/modules/gun_feel/npc_blood.dm @@ -0,0 +1,31 @@ +// Human NPCs bleed when shot: blood splatters off them and sometimes onto the floor, as it does off +// players. Basic mobs default to no blood, so create_projectile_hit_effects() showed nothing at all. +// +// The blood is only for show. Basic mobs never run handle_blood(), so they don't lose blood over time, +// never die of blood loss and have no wounds. Troopers are deleted on death and leave a separate corpse. + +/mob/living/basic + /// TRUE for NPCs that should have blood like a person. Only organic, non-robotic mobs get it. + var/gun_feel_bleeds = FALSE + +/mob/living/basic/Initialize(mapload) + . = ..() + if(gun_feel_bleeds && !blood_volume && (mob_biotypes & MOB_ORGANIC) && !(mob_biotypes & MOB_ROBOTIC)) + blood_volume = BLOOD_VOLUME_NORMAL + +// Pirates, Russians, Nanotrasen, Syndicate and the rest of the trooper family. +/mob/living/basic/trooper + gun_feel_bleeds = TRUE + +// Voidcrew's other human NPCs. +/mob/living/basic/ambient_npc + gun_feel_bleeds = TRUE + +/mob/living/basic/mission_survivor + gun_feel_bleeds = TRUE + +/mob/living/basic/outpost_trader + gun_feel_bleeds = TRUE + +/mob/living/basic/outpost_kessler_staff + gun_feel_bleeds = TRUE diff --git a/voidcrew/modules/gun_feel/sound/attribution.txt b/voidcrew/modules/gun_feel/sound/attribution.txt new file mode 100644 index 00000000000..9610998ac49 --- /dev/null +++ b/voidcrew/modules/gun_feel/sound/attribution.txt @@ -0,0 +1,28 @@ +All sounds in this folder come from Shiptest (https://github.com/shiptest-ss13/Shiptest), path +sound/weapons/gun/, taken unmodified on 2026-11-07. Shiptest licenses its assets +CC BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/) unless otherwise indicated; they are +used here under that licence. Shiptest took most of them from other SS13 codebases, listed per file +below where a byte-identical copy was found in the source repository. + +Fire sounds +pistol/shot.ogg - Shiptest PR #1601 "Gun rework" (rye-rice). Same file as TerraGov-Marine-Corps sound/weapons/guns/fire/tgmc/kinetic/gun_p14.ogg, added by TGMC PR #13409 (XSlayer300), whose description states its new sounds are CC BY-NC-SA 3.0. +pistol/shot_alt.ogg - Shiptest PR #56 "Eris gun noises" (rye-rice). From CEV-Eris sound/weapons/guns/fire/cal/40revolver.ogg. +revolver/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/revolver_fire.ogg. +revolver/shot_light.ogg - Shiptest PR #1601 (rye-rice). From TerraGov-Marine-Corps sound/weapons/guns/fire/revolver_light.ogg (TGMC PR #5261, XSlayer300). +smg/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/smg_fire.ogg. +rifle/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/batrifle_fire.ogg. +shotgun/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/shotgunp_fire.ogg. +sniper/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/sniper_fire.ogg. +l6/shot.ogg - Shiptest PR #56 (rye-rice). From CEV-Eris sound/weapons/guns/fire/lmg_fire.ogg. + +Handling sounds +smg/smg_reload.ogg, smg/smg_unload.ogg - Shiptest PR #1601 (rye-rice). From TerraGov-Marine-Corps sound/weapons/guns/interact/ (TGMC PR #2284, MoXuT0). +rifle/ar_reload.ogg, rifle/ar_unload.ogg, rifle/ar_cock.ogg - Shiptest PR #1601 (rye-rice). From CEV-Eris sound/weapons/guns/interact/batrifle_magin.ogg, batrifle_magout.ogg, batrifle_cock.ogg. +hmg/cm40_reload.ogg, hmg/cm40_unload.ogg - Shiptest PR #3238 (rye-rice), credited there to TerraGov-Marine-Corps. No byte-identical source copy found. + +Hit and near-miss sounds +hit/bullet_impact1-3.ogg - Shiptest PR #1601 (rye-rice). From TerraGov-Marine-Corps sound/bullets/ (TGMC PR #636, forwardslashN). +hit/bullet_miss1-3.ogg - Shiptest PR #1601 (rye-rice). From TerraGov-Marine-Corps sound/bullets/ (TGMC PR #636, forwardslashN). +hit/bullet_glass_01-07.ogg, hit/bullet_masonry_01-06.ogg, hit/bullet_metal_01-06.ogg, +hit/bullet_wood_01-06.ogg, hit/bullet_snow_01-06.ogg - Shiptest PR #1601 (rye-rice). From Skyrat-tg +modular_skyrat/modules/gunsgalore/sound/impact/impact_*.ogg (Skyrat-tg PR #11697 "The Impact Soundening", Gandalf2k15). diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_glass_01.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_01.ogg new file mode 100644 index 0000000000000000000000000000000000000000..30f6f1fb612bac58d9d09a74d83cc7616023915e GIT binary patch literal 17048 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%0D~VL>nfzKL{h zZGJ8AQBf;XnRt;ZP&w_iyn#E(hDsGH5ol}-Pv5JF?GA&nV%_W1*KRLK`E=t>q5Yx7 z0(^H!>0^z03BSfH`x5i&d?BPffe9QlP(gSU5}pBFa;QQDJVAIB@9hF1WJdcKPPe4N5gxGY{GeQayo!dIL@chW z_FFcw@kE1xa0I}>;s74rL-ncTb-9%{SGN+@lR2qITDCj_D#bvG;Ehfy==R>;dBuf@ zHdgeuZ@t5C;Z$~sbr=S=LOtokq$)^o+C{N#)Cwesa)QB^X_OJlw9uvq;ySHvFaPf8 zoG8U%)#VdWNESv#I>hhySpZPf+0w=egULcEi8DT=6$^eR;z3s)tTKCPS~kI>V*CJ9 z5V|Qyg<(_<=i2w*X%HARMNbXKR!A@Cljg(dVb=C>sPP1jP5rA_l&8l@#M5jL+c z8IPBQK*Q>N>dudf@euPeE+)k=m*8iJG%8P9exh1@5-FY($CgLYt^=7hQ3?ZLpgLFA zV9f@=h@H38_Q*}ZG4&6x@U)j@5RL;`ziwQinLT+BN&Ft(bjXjqQ%~$KLoTC4LprZ# zYyp@UOY`D)5_}B&->I1*_3$u=w4D$WI~t`BeLCmhS@oysO^5NQOf!+7_)a7f+AQGH9+NQc z`z`ACPd&0;R@>`FxMgFrYA2T3Q!&TgbPBB&1{zV1p($&7YMFY4s!Z*lGxTKYs8{DyI~PjzMy zT2uQ**4=fcV4u|4_t#u>J^EWiK^!w|pk(M;83PEkIB$}POMzP<;J$7rE*UzIQZ%9M z>pHIIrRo?)3DabwsiL%62TcdT%qW_KYc~3*D3#E`op<$;Zx9JduSgGI#nfu5sFa!< zTD`HacX{0F#{r&?O`uik$AC1I_eEp_UodA9P0DNofpJvsRij>8K{ z8dnF4)@6U9^d~Q@tQu&$E*{W@G}HRo1ZhD;F6h(D=EC|V-(J6dJC5-&A&S#>tx#l6DE&niqv%FF)M3xQ7Gq=pV8JL#wmGm!cWDqTinS62{@B2*+QN!q2B7u6sICJR6o0cFP+JqU z!Lg%oedg!8>H}9~)lN_2tYau%a;~}|3I*K#en0q5ut?OdiiD&WM1U9Ds&<#$8K7Ux z7H&DZm~tF_wRk6HrZI1uI&0~lC*Ho==vb^Y59DUEGJcNrwaQCx4Hq7{5@N^-)OzrW*L%czC3tI70=1Tz!@D8>3v~dz Axc~qF literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_glass_02.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_02.ogg new file mode 100644 index 0000000000000000000000000000000000000000..472f98e9801afc5e95ccfa92e44c6bc017f2fcb1 GIT binary patch literal 14464 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_glass_07.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_glass_07.ogg new file mode 100644 index 0000000000000000000000000000000000000000..110a45074d17d5e19953e7e99a5afd6e28b6547a GIT binary patch literal 10614 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 z zN1WET<`3PI=X(n@%z}xgzTD8fwE0En#9#AP>k5+_P`f`zpL4rmJoVW}qdwDV@5A;e z+dIFXkQT#QL+=m~gq_uHLYRxbWy`E3%(nog6)7rXYfWxO#3IRC%N-1+?WxPQ< zfZn_wfr01c0lvGj`bY{D()OD^M31QRrXjJDO5kGd#iu3&adf^^E5}v(rh;p4L&JnG zZeO)%l;;MahT=S1yRRv;tSpB_7-1u&J@U=T!G@iwGZ9yhKW03i-R-?vEz^BX*x)hS zbc9kkGO0_1(n_aja(uU}Bb3V{F z(44cSe)e_@HL`vWc!_)>0MonkknUEs)^Hi^jSAZzd&Uk_e_Ty&l3Jbr@{xs12uAd0 zO~Lr8-JV~4RL&mqrmJEw$nFHM8m_~@$+CdVJ0~BCgsHn?vvEqrJ38p4`1H2((#K$k zNjOE|o^E6zoe&1a()H|4UaEG$_x1IaZfg;D?q1ItP1B#(Z%ICF#8V_z`an#ASrE=- z>%^UA_fB~gRs*Hh$-r$G7wW|u?zqLMqi~#B+C;8|z8Ke$QeKUy+_175FGvV`Vwo4~ zD-KgYw^eAWXxb74#ESS9!!cbTVeHG#QU*A!U0qTXe8Wd;I>2VT8l0lmci^*A5I!H; z=+fai^t^NL*GQ+-=4Au|9gPPb)^FOj<@bV9MFnL4$95#|uOB`<`xp-X4;NDs<8=3P zS2sjq1rX%XeO(>`xVpJf$zcth&kKVD#S7Uhcj|`y;pa2mnvr-%$^fB}q^1Rpyom0X;z z$Yu_Y=4#^(Onv$C@bcFM)j!wljDJOF%J`yE@TQ}KF%9w6llUWd%f>>pJeau%W`pm) ziFtgqIK58pzJAK&w`ZS3r*FeNLY+^vC8mpnAY0Z z>>P&y&zz(brdBZm4)hPmPaOb?bIgFwaEjt8x?n$~!adhM@0g9-JJA{?L9+A|@P7bC Cl8(^; literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_impact1.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_impact1.ogg new file mode 100644 index 0000000000000000000000000000000000000000..4c0cd5b5eebb6108c2e0ba727ad77fe683c8baa6 GIT binary patch literal 9464 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_impact3.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_impact3.ogg new file mode 100644 index 0000000000000000000000000000000000000000..0356dc6c8819ce3ca226cdfa9a0f3ff39e5f13e0 GIT binary patch literal 10924 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 LqT*8gxAwmPw5==5 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_01.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_01.ogg new file mode 100644 index 0000000000000000000000000000000000000000..1a04e7688d637e612d1b6ccf0bee3b983e29af0d GIT binary patch literal 15896 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_02.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_02.ogg new file mode 100644 index 0000000000000000000000000000000000000000..770e9e242a7fa3cffb807f9583b2b84dc71c25a0 GIT binary patch literal 20941 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 raUFr literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_04.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_04.ogg new file mode 100644 index 0000000000000000000000000000000000000000..83cbc57ebd833b674b36023367a0118f99114665 GIT binary patch literal 18885 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

gp8M#3v%i^Q@AX*WDk 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 XP8RGBF9+}d5|0P)cm@E{BQ}r&W2$G> literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_05.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_masonry_05.ogg new file mode 100644 index 0000000000000000000000000000000000000000..6d9a67304659abd3169af004513ee25a4b9f42d7 GIT binary patch literal 23554 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 wftvKMK24|FcgiVJP;a5!9h{A=TwVvDB+6d@ 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�_41C1^-jVJ|w4!dJV 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 F{twym-E;r| literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_metal_03.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_03.ogg new file mode 100644 index 0000000000000000000000000000000000000000..9ede9f16129056db3f3fce60859176bd5a59f705 GIT binary patch literal 13870 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_metal_04.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_04.ogg new file mode 100644 index 0000000000000000000000000000000000000000..bf6824c8cefdbf31aab2677e6e5c3443182ea11a GIT binary patch literal 15663 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 hZhHG%K&x4={uf>n(t7{^ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_metal_05.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_metal_05.ogg new file mode 100644 index 0000000000000000000000000000000000000000..a45e3d6204e453419e5b5fd02c0b11787bb202a8 GIT binary patch literal 13815 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

`K_OMQp*2W9>LQ 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_miss1.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_miss1.ogg new file mode 100644 index 0000000000000000000000000000000000000000..dfff45a0cb5d1a345fed935ee319e919a5b1f256 GIT binary patch literal 21417 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 GFaQ9i651;O literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_miss2.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_miss2.ogg new file mode 100644 index 0000000000000000000000000000000000000000..54b1111280212e42cc1ab5db793550108dfa081c GIT binary patch literal 19830 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 Bm74$n literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_miss3.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_miss3.ogg new file mode 100644 index 0000000000000000000000000000000000000000..fbff6dde90465ac91b37723a1189bed3d92a7c01 GIT binary patch literal 20657 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

    IW0UU+Z-0H~ zsr;SzM8Bq&cJ=#s>P4AZ(KpSZe3M;q$BjxAL{c-sU`3pN@&cphDJ#i(Tp!+0Cqp9& z8#+a>TaaVWX-*v({C&^RiA`5xadicgKeeJ8;}4l!9|-g`u(BHYlI6z!+%)<_^-I3 Rd7M0O{PE9_u3j|ozW}%Q1`7ZH literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_snow_04.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_04.ogg new file mode 100644 index 0000000000000000000000000000000000000000..2bfb46d958b2271b7c7f4bb66af4e0156fc2d94b GIT binary patch literal 11728 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_snow_05.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_05.ogg new file mode 100644 index 0000000000000000000000000000000000000000..3752f95b3d62052ea76ca0e1f14d1b62fb691ebf GIT binary patch literal 17937 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 T@w*7Yh-jOLK1aJg2=o5~sxC{f literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_snow_06.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_snow_06.ogg new file mode 100644 index 0000000000000000000000000000000000000000..cac69cc7404c6066d109c919d21cca4ea91bee27 GIT binary patch literal 13631 zcmeHuXIN8B)9?v3K!5}Sh9;pJs-a3#10>YY15zvydXpw7Do69-)!u#)W$;6=G_=AK@CHu%?N0ihaQ8uzLVeu= z&<>uVVZK4f(Ong2OG6X1U69v_lWw7&NRWbLN+J^W`vwNPxt}-|c0B0hF{~zp^|x3# z149Q000n>;Ng}#C=3$yW0Kmbga!sUvMg5epdojjng6yZ#hjM?RXp~02unp1vF3{>8 z0sz1Q;8flSWEL8XXfHoTF;Z-1<@&dw-t$;TiLx7I`L-j)nFVG;+RLI)ZYNeyWdP1Z zA^~apufj~OEQ_2h$H?6&LPu2kMdM+ruSzp5bzV!CrWtUQm1fwQ)$|JcuipMzcm^z< z{-b!H&Au}ZH25wCY!Vc>0aSvr%^u`8BzNuiu+TsZs0_fo9%(Tu`rw1;1B93zpNyTV zj9sYGBUL3kwf*C21p5>AcJ`i+!aeOYx3m3D2?CSZo=!RcmUo7Zh5$uU+8I)Esn4}i zUzlXDP!g+|0FP~zxMGq^Oj4ztGC(Tty!_XBg}(U=Fmvp50Vf2@_S*nOzgz#k^!hKd z{`*Iq&wxCD19jOSBKkWUhf1^WXm|hsZHlv%?hREp4pZ+B(=$GSH_jrOW*;_h@!W2BClTb4ttDqx4!BBr{FTU{jiz0I}z7voP8-g@3c!X8OG-i`QU>?8a=S|Nb3F zs3dF6^k1yDp&{tb^uYl@Ovn@_iJtlDY)E1#L(W7#OZG%iM#)Ri zTs4y@e_H+I(}E@qW4SB8U;4iGa=~wp0*#9HNYiH%@$7D?XXgkBT;G`|Z>%#_c^1<%G}V1RlB+?s6$=t~GqFCF;p^$}@1H z{v*5p9y&M@;4i0106kGH)U7__AABY~ z5bzuo41Dk*5Y$;%UBdzE3GKkvDGco3L4uW^)rxE{a1@U;=om4Ua6DQ%F|( z5=@7YJApEn7YnoXH8>E`XSQyn54~_w+#g*({bS5r|tYZ z;l|WEKI-8<>HT^BA623j%<7}e>JR>sW$_uXQioU}fA4+(D5yhie+R$C|47Im;%!+E z{vm7l6Yu~Dd7$=37VZwkItuLk}v8UUvqga`Zx8Wc+?FvVy=fFx=C z47BM(^%xJQHFY0hB0t(mP@OxUZT$E4zReGGJ|AIqZaOq=8(|TT`8%cr(2*b_pUsBF zB%j?Po}vGj4}nw|A`QavF+jHgpR8vx{Z`U?N|Z2t4s{~{0)g8_gfx}#vQ=rB{NyAv)$k;~NyxX0`x zj6*`UJ2P#+ort=OAUj6gLk57e0`u0$;W2fams(&zL>~wNcM^QMr*Hmpo~-)4Peguj zjb?Cw%kDVEH<@0^WJ9tC)y}%Dc1<5MMILLP0W$zC4RkDMb3C6>MUFO>b^?fF0HCC&Q2II#syCvi2<#$ZnEk5AJUeG0|KBspgM!)4Nwt_wlx4L+#m^1!MQ-QF;gjn zx$_xtNRWvJ7zq*>=~QHKZpSUw`k3NUxT>678Oe;bm||^Kj@}LxSb-QZE%4(AO-QU} z0X~A!o_iJN!uTZ0P+|AAE0FHnAw^b(%5p)NrP*LE*tVz41MF9jX{!Ua z-3Q~K6JVFpEL($D1@R!~D_|E0fcvfORVa))*#1XoCIpgZ4P8m`Q|1r zd|S;zZMC3A6}bSwDi{Fd>)gUAne_S&aHC_|-Y+RCP&qK>Vv{I^8<@x?r2YH!hs2P; z2Kevj4&nYgWRzS2$M=um@!w;=|7U~*#Tr0IBrtbaQn;(63XmzRg2D}a0F)iHsD3i| zn$1(>gf#MfQ&YJQ-EQdvfqjycmb*d`4yWz8u68jUuj zj(kNg2X-8U;ayV`&ehG!1@Um5R-_$G<*P7w&gm2OzjtF9 zO@X&eIk;TE0iJ!<^r!N}cyz!65O2d&e*Aq_AXiTdz{yN&b(@VjE%*e3s{|zGF?dAz zJxSPij!SX|wC#zrb8xb?rJ&rwNdj^OL&DO(5XGPpP31iQAV4K%*uL4J*ha|7lui7m z;Mk74%__opw!_ICwS&)Bz+e)y6Xk<+AYz*Wd<4sczYt}hkvRWQYzqR>&>ha(g23Lt zDYgZH#AUxZgMz?*e_L>eE9uYUHUqPYe+eqfvE6BsD{Go?{ujSLCxPtW?7?L>mKngs zAhHF?*Mtu3j+Fw#ucDo~=K(qy9cgD)Lx__fEa*9EAR`c#=A4Ylv~=dFHGGI(D$P#T zretN3bl9_PVP^6Y;Y2w$xg1f0zTC`$IJ07RZ~+7K0U$PwgN3uG1IGr4qNPnBKmuzb z4?+@0V`6^se2fi1L(_#M5%nl(SrRj$_XJSa){({w2nmb$?%MUxGluJC+glt|A8G)< zz>Gmd*!MhM>k%moj^{s-Y$mpIp6g%+z$i~z1c%3~hlz^q7Dr1+N=eJ?TuJ?=*S62Anu78C-t+NPzRu<=Lb2=000dxm;?{-+o?yGSMgV)v8*WVJ`;?~wy(@@toHZt52+G5$_R97Y7w^)CH zAoCVvi}@RD)3V-yF!Ah_*Q=As&GPhLM*B9nHJ=(?04B&97A|L8n2?Z)G8qR=7{Hp2 z3w)rlbhlgvom=oDz$FJ!9B)$U*o_iJTn`r|ljo_YYF@zknKuR;3qD!f5?2T-26K4i z`QcTYpZ88i-QWGA;nbVPw_$I-!OC*dTczHQuL2$%`$V0JJ$K3{^4XH}<78z=jmpX# z)HLVkCyDa6GAJboI$Y8KQ7_3r?r%xf*0bw(vom&hnQz`}PNgjVuEYW|W*cHn3Z^4QP1^!*@6NW zuj6yOk^yvPP=Pymj40%3^v;3dTgx>Fb&PsA&HB(b~(yxd-;< z9jd(f?cH%S`V$OQ?)WoeA3QCR4MsXE-++;s)G)j^M5i|x$R(!{j7o2tLzB8y^rXb> zH7aT4$pZC#)MSl5lnR@BO{FV=gr68oEGtX!^>lM|bd%s=Vn-+FqbTV<^HeE)9E)~G zVPR;qn< zvNJ3@H{Z(9coy$wDPMF_o-D?kttnwbSObEu-f8ma_fgCPNrzZZ-%2q|Q(3STnY2F$ z0L7VgN!<&dQ+f9ElyC3XkQl{5vLjIMs3fmR_BNMu>})q6eBgM_FI#Zw<8)mUtGURO zAoxcvrfUVmxuIQD0k-#7@h`LeYFFADu1Yw(r^H%JZ`kD$JUC*oMOZQ{<$M(l&d!D= zC&cFMUfcW1VB~4+%E{2~A0|}Hb!q444Z{skW(5*Svp;(#C6pGa(CN9Ua$~VstiMr- z=mL zRq-*nU7_tjGp?!rB4^@8>?kBc~as@;9*3)TJen18Z)C zh@9y~Xk1z^h7-_Kc{nOu^wWD=nQGOO8dHoaHe`f1jZGN7>vy#}!OMR^AgPod=R2EZ z_2{ealtV|No5Yt)2_&n^x{mE!eMX**XVwbf4P!E zO}SFp>{`(#YG$}RQabnHg@Wgc3m2kpK01*fPGn+<7Ds8arm_F7#A_wEI%?#Fss%J` z#rp-7LtxSgDrMOAf=P>Qdq2j1%CXe{X>})N-jO8YGJ2!U&*q;&rUowf;kt-=am}V77;FeT3jTrk(b+2pZ>KzU3=riPNu|#=-<7`WBAL7|7%&snKz5Qv0M|IEGklAH9=Fz`u2w>jZ}vvlCA;K|Eq{^B=ccBCmO`u6qVr5{Y+w52EtX~(SvcA49cwzQ3xw+LD z8=zRD1evZ^&1DN))A79>a7`~(iD&lXyxcf$opn_+yanH>0O5zVh>^jZ| zMBy(ZC(gTFd;VzXNN z1nK@B>EZLdqnlhj9lRc}HY8uEe?Fd`)AbXY>5yvkNVIY^x#P;T% z_ANGmksA|xdX{!@h^JFQ$2_{B}yLoA-4= zmkYgpBp0sk*)Cc6IM>`n&mKfwI*pk%$3VnMAH_kqh0mWbeovEC9j3ic=4avTe%OGVbU!K)yyr`M(q&~E z$i0!bhmW+zp*3%796p(m60i{;xqA59n4~JCwm`q;wTQ5!?AuY^BVZ@e9*C0RQr`^8IEdegD9sTQ*0v|MP) zs>OO{>V~&1#Ip0~RUdm(?hCzj0@frRH{_+}SRbE?I@`rv64|%+C%p{xuIhmxb(*s!^;cF|(OX%yOS|$Zro+nkl?lfV32_!;}K{rXo7(w+hawn*j zke2fD^BA^|h(q}@Pkud(s^No4^%MFgq~i8#YJ8QW#m2h%=4wu03EYK|&)#q)3vy2P zEq#s9dXPVhWSuUCBtTNN&^Sz%0Ed*;e5uxl54NM_3q4=Ame*F8n5DDNU1tQ{ynF2F zs+-tZxv7LhvbTA*n0?CgPr7S;P%Eiego_Y0XWQVf-Ut+@U$F;Zjd!OFA<0Uqp$6`h zEAH3aak`yRo9>!xGV*6*i*U?GUnv?eJrr7yEUJF!L}4jX>muN0SS9aauk0S@23Npt z8+&`t%1JzaqR%hLnOnEY6(h*-b(?L0Uv?l1Ni+$lLCI`xa6Gf3Xf_>@gy<@ote@;u z+M{|>{??cKmDm~02)Krg5L1%lLP@|iv7o(0QF*%wT6Iz_P~@xNcGFwE2rhRcCHLb; z`!$1&P94*tpPjlBy65>y^1BszA8$boff6ML{x_^|HBZi+K!yF7b`W*tkHX6g;(4c+ z)PKz_4SAM@U&@b*rqvyoZ3`XIm$-Mq`)bgSknwLHb-majF))@9Mzz%qPF!3}#>ZdtSnT$iCxP)-Z$nKrqD3%8I(M25tcTV8Fy@UxQ*O zk=>oSUHCg^9TW_S0^_#T#O|@W-j?jce&&eOgZYT2WvfDfd^ysDXr#Oupd2n2Dz(-UjWvc8|kkl!v< zd=8!vo4+~FZ$z&!y(PvbFB7R*q|Aqhxu&Hd4<3GUF!0UbU9mt+;+>(BtZpr7f1O4OWyj||7U;p~yJ0^9coW^aC>c}^jozrpUILpFv zXsPHY+Q?})+(2; zcnXN|*xsUf-aSodI=so~4@JmI-TGpC3ok2$z}^>M#k%x)4Ne4U4&TUKyZoqTX8vXN z=bDv~ln@?e@{x#W)?Iv_I*m*onIaue+~m8N*#}SXBXrFy=*kSfcF9Ro{@H78x~ zKx#8o)1n#6+Bw@**I4E@4xh`VJLQDsE%w;KjuXqNt=7V{}J zgVCgCbL&%rG1er&!6Q$vfl5KS7=82@I(swu%lneW&EAWrGak)dIHY<+wa%WmL8Ivu zPMv$^haV|g?8wY*qQAD_*Qt%uVu9Z8d)oGS=?%_1USO?V5*BB4!vfl;8|JRfZ>qG| z=li^F;|OQ8eDdWqR8sSu33EMkR=yxXC%1_+JcJUT1sqO<$fKZddeOL1126uVqNlPe zoN$~cnvA<#r+@>pW2umSmDj-rTB7e?p)@MN1puL-P~VuF=sIVa*j(f18!ID7^_A1K zS0s)zsAXi#bc-+fg0EwFay}U)n@FHwh3)tx+)$38sMvA?ryRxXeNx@`1N+ud=KMDW zM~ty%>=z+i5(Jj%Dg-%LQ6OS(^wXcsy_pP*!m9J!5BKX&-97VAC?qA;^++98_qSZa zlf51eYu_@k$Ii$(9pp~9+c|G1mA~tF#>}Vv(TzHI%()}Hcw*Xj1MKhdyeTALS53$Bk2igOi}uFJX3G^+1cdF}{WTQ8*^0X$uLoA}c0 zDaS*&@27CXkJwcfAL?PgF+^%TSJJqyPIaQab9RMlcT;DRO|!-%yjiWs7FQE^xuv|! zjamernOeGL>ly^Xp=59sL1E_iAW!S{RglWSArsF99*mP$)K3gs`JCT+Uj>4+sV+!|*} zKZQL6Zu#u_&H;SfUG5OiJzp!@b>8=uMATPy4I_nqGSly$Zs3mp>pp`81XbTl`^)@N zRoC2StZ$&V&+Onq6&3vh1pO`6Etu*7ePi4f+qX}zetdubuBGoS0||z`<_UgNr=Dan z1?=&tu)cRIPH~u)Ea{)4AwP5;abbTVe^+{;N?&>95Q*V8A<0Il5iXe)mTy^SOja^d ze7y;HCA^~jL|vV_VxSfdtt-jJzIgN$HQ!zGz-p8Y#W;2%$i=;Ba`YjOAw1^8*2cM< zr)TO`&D9>gU9%;Lor)xDwhPbzm@GMl3gKD+w9A#|B%jDP6rvStRdrm|;%hgaZ0QMm zY{?(HFVgsmc+F>Uez|IPJLk}YB()PZ9EhI4shPGAGajq`a z8PKWZ$*a0rwMf3+V%7mnX!FhdsP0$ImF`mEdg0*(9PBWqShuUcEAiS&sD51_Mz)Gg z!-$tlOxYMoi$%i(5~{Q2ufFlns`tJ?ng|nSg_H_;r-_|(xBja>3%qL*9VR-&xSDka zqhzh&TGlkE0m82KdIA%@*ia63J`qn5n_U)tJQ42VQFym=-=x10g1cIy72zQi8&Gp2 z1w9P)*!UrQHm)^ey4*3)UP@Nd_+@Der)~(k%LM-1I-;~TwA+04ZCVruB>5@*#PHLM zTru`0);SeS$HbrNaU~TN-10G92yabegCL;f(??S637dCxL>=)VEH1f2O9H-noT7Ye z$!rmDfzuo^?ZS|e%}`R7lDdBJ2y^tU6zEBjdX|PcYOpwW#|?Qk0bE-UsLEMu+sI$qZzDdwYU+OR=n4$X_@V$k1@NFyz67 z`ez{^IgJI`LNEtKSwCr34@9y_F1l{3^U#ldT>Tw*rNfbL$>HH}Lku>SO)gpw$l{no z$f9l#r&F<=1Fc5s623FJX+kBEl5zA{W+z)9)j9H$@gvS|lbFs2Hc?C{6K)<&oj*N$l(R`LNt?p_Tyqhd6pU+MHHnw!QH7ro5#VP{2aHSDIFRRisEE#U z=IPLq(crrNwI>@Jtz7Mow71St?>4~NCA@D;9Vv+U%&k4#{QfyhX_A71DnVG&nwD2D zFNw-~hOjZ$1g`{(aBjSp1X3ur-#A@zS3=l%=!mzxeWZ}0w#ROe6JOX2b&oOWPfd!z z-ks%OnW<5>V^i#J&nA)JRd^00#`-qCtHVhD$IVY|*AJ>M-h5(FmZ}AViitjY^Wo`o zg2Cx^1vAk{ihNK?-^_JP$l*p4G+g*3<6dPD(Hnoz-RfHR2`!1l*OtCI!e9jMyu#hw z+BiFNp++guO~KE`UL;_$sorohG0uY4}#>k@s=D~y9$39*71P3687TV`8Sz%N8R5=bu zX$qCy#FjaF*;-WdR&IvY-`Lge7}UFxsV1!_tVcY^#AeS_&$1j6!wlc|0>TnJ62l>y z$Q;22;~NZl4p4f9uqM*|=b7Pxt64TSPQ~S^v}BJOOUKmD!8#hE4zqRgHIH*ET!%n&ye#h|MeWY%u{$*h#0m)XL0@oQt~Z7yUUK;U?oJ0DpO!Gzd;!i`(oiMy z_OyM8BCJg^srxl5^C)!O^J&5OQ@l~oMpVS2SdiJFFB>*>FD;JRswut6OrJ{lppm{f zm_yJtdhfIp-Vf8L>G!JoneiZ_^r7#d^it>DY^C6_cPEPtag|oe86A===HigD=vyzC ze8l+PYZ-R90*V~sP$HZZu=h{>?2Tdqyv3|^3S}KW;pkJ=5W%xokR~Zxni{xANL?fI zG(u0tigf_;sxadk(@Wq)lEM&Qv8RgW0E$#?-x_^bo5u9?YkPvgo@4}ziM!va?_^r< z8OsW72E%f-pH>g`P`Vy!+Rbe-^-=zk`s0V9waHZY=*P}#k4YxKpoqu8B#Bp*Pj23S zff~QpfG1XCq2&31KsJpK#|c5z8RKtj5Tb;5R)q4V+b6pL_OdJH=ca$&IWW>!-%0C* zpQ%c@t%KE~A+jOnqfl8mRhW_e&ZI{Y{-zr*H0~et9_BH8cX@JM<=Al)kz3rFXtmG7 zuA3b>KF&RJM{2DlQEA3WksMTRpNi=ieeg8(M-)dw^(zt=XBs{CEA8VA%+|vVc``El zMg9l5{HaF02j}RfhSH*Ar*oFSTdDg}Dcxcc?mf=3fh(^Ug0=RGzE0&C2TwBcjjiQ` z`o_jqvio(0FKRk>os44h^RCa8*X+c?x`kP@v582whHf}fS~{3&C`43$_4SC{e(=YgTGl}SA2uyj9!XW=E00f5?Kap> z`75N{(_Qk=xzKpfOW^e=&J5Fj!AYBpyz|Xg>1o{NN{5C8T<1_|hJC}*8w^KBh(2Hl z6ClR10e1uxv8=M}9?;bq*H}5MHrnviG060IH#mCYwIq$orpn48MF$%d?1Tf%rLNV@ zmn^KQ$UV;B#selVZ2Aj_JcljoL6&Z!ScgqhD^T zy_Ftu_(NPd-@ z42NXZ5&&4iYWF+ced;sm>eu`8G>vLCJa?H0pWSjeYqnd_VQu#3qa1#BviRWe9g_A6 zG~a=xx=CN*;}`Y@`vyq9|0~6qRkKqW&m?tT`b&$vtcv-G3+}6sW`+VxyJcUp>Py@A zF|Tz|+R@a~p;N$9mF@+Tq}Qg#DP!QM&8Sz9o!0e8g}IyvRV5{2_bgVar>=+LTqz`h z4J9TNmlVfa+Z1=iLRpFl2cE%leiTK?PWqRPGOiA_AG2|?$5_+>-ThVa&UB^Nl0xRjOhFJ=B+84qj{$~Te`Ic74}UkI&2LS^DfZ@ z(a(2Z58t|*zd?)e`eA*vWxY^x{Kuq+=NVVkQK%j?VV> z=P6htau1Tz9TOMlL!ORcBcOW-Azb;7TxbN4klLGg(6B`Ear!D<8V*b~!L5{x0I$a^6&zRkT=%p8u zo!P8yXvOYo6*XQ{la_09{X_Z9shnTQu@tI&Hjb- z7oI<6OcRqnFY2Emd^r0_v{mbJi&UJ6G#{LUi4u$wM)TpBa7#(yr7O3?Z#!&ZHZQZv zn9iRm(df@g*fr--#Rw>-d0P17X{*cL3aU2hIb?NE)N8i#d^z=@Y*e%6P#IPhOPTz7 zuxj;@%XQD-8F8M6(LAnNjgq;WvLW>WLeCIT*CJ6yyb90<1OkPEB(cuRoH1*JW6sv$ zEJ5->*RH7HLd)&%B`Bbg!LMuO-E4@ia&Jt~!!6Evm442-no4!Hb0`OgDZjZogI+9A zLjazP#-^8-*r1yXCFm_^(ND*;Hom`UwXEa~RH@zaKJFM-n0w~=u65B1!4}nwkf|>& zoEM%qOMQeHK;d|(V%(yq zd$flIrS-`4{3Dw!;SM}i!a-p{PI-Uv)7J}UL5THtR?1v8fvaRdLyPTLyxPxFQE6u z+oi`ziH-peTYl_6XI<2LsUO=k+VA%2dU)A^0fWWcUk>j&V7u9;Dxcf80E?Q3jd45z zpe(=vFBSwB?ybl{Y2s;jBX~W~bmxO%SLw--&%s)KK3Y%ez#WSdK&_d z^Xz7nWjhNquGHhmiEh#~BX>BSJ2^4ggk=;XVUqw1;9&!CcPcMt<*zA6Cp(1QD0Y{9 zc^X+8Kk9jIe8X%@Ts-B);a_`h36#BD7m-uj|LW)62o2rKp|CvR{x6vUI5=w_e8Jjx zRH*iuC#>vo$?l)K18~jW8|;ID*#idxXsKuSMK69*m*#g0VrC)IAFg+1MLyr1>+n8S zG$oQu=YB1>CuZ}lCA`fZn*8qPt%uW*ji=G)r7-F&z|Ktr3-0v4ZW_S8IA--M%=x zk8v8?`aqELdRO(HjOa(ZTF-QGb3&cemJDPiFIq?3mDvc|y#03mSoAybjm5a)4Bo4I zZ_Ay?kIVDbUiE*cQ+%R@S!93BL+h)xKdI{qsaWz|&CfN%zE}IbuNV!c!Bp<*B#pxJ z=k`qoAG^B2q*l#j98B%-cNgtEt(5@$^6W|ZSokWz-g#j|^|?``)#onxnzbW!9P7ba zqLxtOZqVqJFn~mYU6^*}u7c+JdHRkIHSvu%b$5_R0i8%@1-mS3+a3r!oB2g4E#xfH zt7`A#f$94DYuw%?gC#A8qk73asx_%JuL57j+__N#iW?~`YUIIm>7wqV!!-vEdV8j( zgv-m)*uTu3@yhnFRaP;!yZYh~!gb}`g-dzpDSfq3@o4;|0)l2bte1VtHc4pFzE%35 zfNgX1EbfeSz$$nBa00BYFX7C*CX@OJ@rSY(JKft|+tx3e`rr5B8;XvngQHaE{|r4rjh!S zbuA6*fsR!Pa*PCdR7)Vn&*tdGV|%`T*EMb0_hfh_4+}YIm+|#V>rh?ZVACO?nUX5X z0n=3savm+rQRegV`1Oy!ell^fkH%o3@jgANcvep4E)$Yh^rYwLUHRcA39f_B8A0=l zhHM^A$wmeUBZCDrav&DLc*ZW6oAOI;bzS0ne^c~G!fNBw!>2r57?DeQwZ*NL6EB~_ iSYot3o{SYb_2W4#q>*Xb_*d$4{rArUu3N`2^ZXy)RGsty literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_wood_01.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_01.ogg new file mode 100644 index 0000000000000000000000000000000000000000..559310853f95d43686db14d6380e83125c20cc34 GIT binary patch literal 9254 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 O>IXQm1T=IF@P7awQHpH< literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_wood_02.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_02.ogg new file mode 100644 index 0000000000000000000000000000000000000000..852c1d875cdeb7cc7ee72f98d7b0eb8892e49794 GIT binary patch literal 11210 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

    iRFBh=oDnnvv&VZvDloWd%j49L$Yp;qDF=F1T7j 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/smg/smg_unload.ogg b/voidcrew/modules/gun_feel/sound/smg/smg_unload.ogg new file mode 100644 index 0000000000000000000000000000000000000000..677b5a8f3d29a39c7729b6a48531e3d01d67f626 GIT binary patch literal 14382 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/sniper/shot.ogg b/voidcrew/modules/gun_feel/sound/sniper/shot.ogg new file mode 100644 index 0000000000000000000000000000000000000000..4c280c224a9f01a4582fc8233aa00612c9201435 GIT binary patch literal 54395 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 world.time, "A blow from a direction should flinch.") + +/// Troopers bleed when shot, and the blood doesn't hurt or kill them. +/datum/unit_test/voidcrew_gun_feel_npc_blood +/datum/unit_test/voidcrew_gun_feel_npc_blood/Run() + var/mob/living/basic/trooper/syndicate/trooper = allocate(/mob/living/basic/trooper/syndicate) + TEST_ASSERT_EQUAL(trooper.blood_volume, 560, "Troopers should have a normal blood volume (BLOOD_VOLUME_NORMAL).") + for(var/i in 1 to 10) + trooper.Life(2, i) + TEST_ASSERT_EQUAL(trooper.stat, CONSCIOUS, "A trooper's blood should not knock it out.") + TEST_ASSERT_EQUAL(trooper.blood_volume, 560, "A trooper should not lose blood over time.") + TEST_ASSERT_EQUAL(trooper.health, trooper.maxHealth, "A trooper's blood should not hurt it.") + + var/obj/projectile/bullet/c9mm/bullet = allocate(/obj/projectile/bullet/c9mm) + trooper.create_projectile_hit_effects(bullet, BODY_ZONE_CHEST, 0) + TEST_ASSERT_NOTNULL(locate(/obj/effect/temp_visual/dir_setting/bloodsplatter) in get_turf(trooper), "A shot trooper should splatter blood.") + + var/mob/living/basic/viscerator/robot = allocate(/mob/living/basic/viscerator) + TEST_ASSERT_EQUAL(robot.blood_volume, 0, "Machines should not get blood.") + +/// Bullets pick an impact sound from what they hit. +/datum/unit_test/voidcrew_gun_feel_surfaces +/datum/unit_test/voidcrew_gun_feel_surfaces/Run() + TEST_ASSERT_EQUAL(gun_feel_surface_of(allocate(/mob/living/carbon/human/consistent)), "flesh", "People should sound like flesh.") + TEST_ASSERT_EQUAL(gun_feel_surface_of(allocate(/obj/structure/window)), "glass", "Windows should sound like glass.") + TEST_ASSERT_EQUAL(gun_feel_surface_of(allocate(/obj/structure/table/wood)), "wood", "Wooden tables should sound like wood.") + TEST_ASSERT_EQUAL(gun_feel_surface_of_type(/turf/closed/wall), "metal", "Plain walls should sound like metal.") + TEST_ASSERT_EQUAL(gun_feel_surface_of_type(/turf/closed/mineral/random), "stone", "Rock should sound like stone.") + TEST_ASSERT_EQUAL(gun_feel_surface_of_type(/turf/closed/mineral/snowmountain), "snow", "Snowy rock should sound like snow.") + + var/obj/projectile/bullet/c9mm/round = allocate(/obj/projectile/bullet/c9mm) + TEST_ASSERT_EQUAL(round.icon_state, "gauss", "Bullets should draw the streak.") + TEST_ASSERT_NULL(round.hitsound, "Bullets should leave their hit sound to gun_feel.") + TEST_ASSERT_NULL(round.hitsound_wall, "Bullets should not ricochet-ping off walls any more.") + +/// Powder guns play their shots at the recorded pitch with Shiptest's recordings; lasers keep a small spread. +/datum/unit_test/voidcrew_gun_feel_sounds +/datum/unit_test/voidcrew_gun_feel_sounds/Run() + var/obj/item/gun/ballistic/automatic/pistol/pistol = allocate(/obj/item/gun/ballistic/automatic/pistol) + TEST_ASSERT(!pistol.vary_fire_sound, "Powder guns should not vary their shot's pitch.") + TEST_ASSERT_EQUAL("[pistol.fire_sound]", "voidcrew/modules/gun_feel/sound/pistol/shot.ogg", "The pistol should use the swapped pistol shot.") + TEST_ASSERT_NULL(pistol.gun_feel_fire_frequency(), "A powder gun should play its shot as recorded.") + + var/obj/item/gun/ballistic/automatic/wt550/rifle = allocate(/obj/item/gun/ballistic/automatic/wt550) + TEST_ASSERT_EQUAL("[rifle.load_sound]", "voidcrew/modules/gun_feel/sound/rifle/ar_reload.ogg", "Long automatics should use the rifle reload.") + + var/obj/item/gun/energy/laser/laser = allocate(/obj/item/gun/energy/laser) + TEST_ASSERT(laser.vary_fire_sound, "Energy weapons keep their pitch spread.") + var/frequency = laser.gun_feel_fire_frequency() + TEST_ASSERT(frequency >= 0.9 && frequency <= 1.1, "Energy pitch spread should be narrow, got [frequency].") + +/// Casings on a floor lie at an angle; a held casing stands straight. +/datum/unit_test/voidcrew_gun_feel_casing_rest +/datum/unit_test/voidcrew_gun_feel_casing_rest/Run() + var/mob/living/carbon/human/consistent/holder = allocate(/mob/living/carbon/human/consistent) + var/obj/item/ammo_casing/c9mm/casing = allocate(/obj/item/ammo_casing/c9mm) + TEST_ASSERT(holder.put_in_hands(casing), "The holder could not pick up the casing.") + TEST_ASSERT(gun_feel_same_matrix(casing.transform, matrix()), "A held casing should stand straight.") + holder.dropItemToGround(casing) + TEST_ASSERT(isturf(casing.loc), "The casing should be on the floor.") + var/matrix/resting = casing.transform + // A rotation keeps a = e and b = -d, and its scale stays 1. + TEST_ASSERT(abs(resting.a - resting.e) < 0.001 && abs(resting.b + resting.d) < 0.001 && abs(resting.a * resting.a + resting.b * resting.b - 1) < 0.001, "A dropped casing should only be turned, not scaled or skewed.") From 10894852e21bb7da9d4fec184cdf0445db6adeff Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:10:17 -0800 Subject: [PATCH 005/472] Gun feel: Shiptest's bullet speeds by caliber Pistols and revolvers 25 tiles a second, SMGs, rifles and machine guns 33, shotgun rounds 28.6 (rubber 22), .50 sniper 50. Converted from Shiptest's deciseconds-per-tile values. Damage is unchanged. Rockets, grenades, darts, arrows, rebar, foam, shrapnel, turrets and bounty NPC rounds keep their speeds. --- tgstation.dme | 1 + voidcrew/_DEFINES/gun_feel.dm | 13 ++++ voidcrew/modules/gun_feel/bullet_speed.dm | 73 +++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 voidcrew/modules/gun_feel/bullet_speed.dm diff --git a/tgstation.dme b/tgstation.dme index ff5c6569395..54f81972bf0 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7228,6 +7228,7 @@ #include "voidcrew\modules\dynamic_events\events\vent_clog.dm" #include "voidcrew\modules\dynamic_events\events\wormholes.dm" #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" +#include "voidcrew\modules\gun_feel\bullet_speed.dm" #include "voidcrew\modules\gun_feel\casings.dm" #include "voidcrew\modules\gun_feel\flinch.dm" #include "voidcrew\modules\gun_feel\gun_feel.dm" diff --git a/voidcrew/_DEFINES/gun_feel.dm b/voidcrew/_DEFINES/gun_feel.dm index 8b76680c54b..3af0ecc2bea 100644 --- a/voidcrew/_DEFINES/gun_feel.dm +++ b/voidcrew/_DEFINES/gun_feel.dm @@ -168,3 +168,16 @@ #define GUN_FEEL_CASING_ROTATION_STEP 22.5 /// Pixel scatter for a casing that lands on a floor without flying there. #define GUN_FEEL_CASING_DROP_SCATTER 8 + +// Bullet speeds, in tiles per decisecond (tiles a second / 10). Shiptest's speeds, converted from its +// deciseconds-per-tile values (code/__DEFINES/projectiles.dm there). tg's default bullet does 1.25. +/// Pistols and revolvers: 25 tiles a second. +#define GUN_FEEL_SPEED_PISTOL 2.5 +/// SMGs, rifles and machine guns: 33 tiles a second. +#define GUN_FEEL_SPEED_RIFLE 3.33 +/// Buckshot, slugs and other shotgun rounds: 28.6 tiles a second. +#define GUN_FEEL_SPEED_SHOTGUN 2.86 +/// Rubber shot, a little slower like Shiptest's rubber modifier: 22 tiles a second. +#define GUN_FEEL_SPEED_SHOTGUN_RUBBER 2.22 +/// .50 sniper rounds: 50 tiles a second. +#define GUN_FEEL_SPEED_SNIPER 5 diff --git a/voidcrew/modules/gun_feel/bullet_speed.dm b/voidcrew/modules/gun_feel/bullet_speed.dm new file mode 100644 index 00000000000..65ed287ed31 --- /dev/null +++ b/voidcrew/modules/gun_feel/bullet_speed.dm @@ -0,0 +1,73 @@ +// Bullet speeds by caliber, after Shiptest. Only the plain gun calibers change: rockets, grenades, +// darts, arrows, rebar, foam, shrapnel, turrets and the bounty NPCs' own rounds keep their speeds. +// Damage is unchanged, so this is a straight buff to how hard bullets are to dodge. + +// Pistols and revolvers +/obj/projectile/bullet/c9mm + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/incendiary/c9mm + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/c10mm + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/incendiary/c10mm + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/n762 + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/a50ae + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/c38 + speed = GUN_FEEL_SPEED_PISTOL +/obj/projectile/bullet/c357 + speed = GUN_FEEL_SPEED_PISTOL + +// SMGs +/obj/projectile/bullet/c45 + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/incendiary/c45 + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/c46x30mm + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/incendiary/c46x30mm + speed = GUN_FEEL_SPEED_RIFLE + +// Rifles and machine guns +/obj/projectile/bullet/a223 + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/strilka310 + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/a7mm + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/incendiary/a7mm + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/c3d + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/lmg + speed = GUN_FEEL_SPEED_RIFLE +/obj/projectile/bullet/incendiary/fnx99 + speed = GUN_FEEL_SPEED_RIFLE + +// Shotguns +/obj/projectile/bullet/shotgun_slug + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/shotgun_beanbag + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/incendiary/shotgun + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/shotgun_stunslug + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/shotgun_frag12 + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/shotgun_breaching + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/scattershot + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/pellet/shotgun_buckshot + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/pellet/flechette + speed = GUN_FEEL_SPEED_SHOTGUN +/obj/projectile/bullet/pellet/shotgun_rubbershot + speed = GUN_FEEL_SPEED_SHOTGUN_RUBBER + +// Sniper +/obj/projectile/bullet/p50 + speed = GUN_FEEL_SPEED_SNIPER From d4c459eb37ab69548cc61b62cee013b5a852a4f9 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:45:03 -0800 Subject: [PATCH 006/472] Gun feel: remove shouldering Drop the hip-fire spread, the shoulder delay and the harder kick for firing a long gun with the off hand full. Aiming is tg's again; each gun class has one camera kick. --- voidcrew/_DEFINES/gun_feel.dm | 23 +--- voidcrew/modules/gun_feel/gun_feel.dm | 119 +----------------- .../modules/unit_tests/voidcrew_gun_feel.dm | 25 ++-- 3 files changed, 14 insertions(+), 153 deletions(-) diff --git a/voidcrew/_DEFINES/gun_feel.dm b/voidcrew/_DEFINES/gun_feel.dm index 3af0ecc2bea..51798c3b82e 100644 --- a/voidcrew/_DEFINES/gun_feel.dm +++ b/voidcrew/_DEFINES/gun_feel.dm @@ -1,4 +1,4 @@ -// Tunables for voidcrew/modules/gun_feel: recoil kick, muzzle flash, flying casings and shouldering. +// Tunables for voidcrew/modules/gun_feel: recoil kick, muzzle flash, flying casings, sounds and bullet speeds. // ---- Recoil kick ---- // Kick strengths are in tiles of camera travel (1 = 32 px). Keep them well under 1. @@ -25,34 +25,15 @@ #define GUN_FEEL_CLASS_ENERGY "energy" #define GUN_FEEL_CLASS_ENERGY_LONG "energy_long" -// Kick when shouldered (KICK) and from the hip (KICK_HIP), in tiles. -// HIP_SPREAD is extra spread in degrees from the hip, fed into tg's bonus spread (rand(0, x), then +-0.7x). +// Camera kick per class, in tiles. #define GUN_FEEL_KICK_PISTOL 0.12 #define GUN_FEEL_KICK_REVOLVER 0.18 #define GUN_FEEL_KICK_SMG 0.08 -#define GUN_FEEL_KICK_HIP_SMG 0.2 -#define GUN_FEEL_HIP_SPREAD_SMG 10 #define GUN_FEEL_KICK_RIFLE 0.12 -#define GUN_FEEL_KICK_HIP_RIFLE 0.3 -#define GUN_FEEL_HIP_SPREAD_RIFLE 20 #define GUN_FEEL_KICK_SHOTGUN 0.3 -#define GUN_FEEL_KICK_HIP_SHOTGUN 0.55 -#define GUN_FEEL_HIP_SPREAD_SHOTGUN 12 #define GUN_FEEL_KICK_MARKSMAN 0.35 -#define GUN_FEEL_KICK_HIP_MARKSMAN 0.7 -#define GUN_FEEL_HIP_SPREAD_MARKSMAN 30 #define GUN_FEEL_KICK_HEAVY 0.18 -#define GUN_FEEL_KICK_HIP_HEAVY 0.45 -#define GUN_FEEL_HIP_SPREAD_HEAVY 30 #define GUN_FEEL_KICK_LAUNCHER 0.4 -#define GUN_FEEL_KICK_HIP_LAUNCHER 0.7 -#define GUN_FEEL_HIP_SPREAD_LAUNCHER 15 -#define GUN_FEEL_HIP_SPREAD_ENERGY_LONG 10 - -// ---- Shouldering ---- - -/// How long a long gun must be held in both hands before it is shouldered. -#define GUN_FEEL_SHOULDER_TIME (0.4 SECONDS) // ---- Muzzle flash ---- diff --git a/voidcrew/modules/gun_feel/gun_feel.dm b/voidcrew/modules/gun_feel/gun_feel.dm index 2ac8b878a60..c11c9ecc74c 100644 --- a/voidcrew/modules/gun_feel/gun_feel.dm +++ b/voidcrew/modules/gun_feel/gun_feel.dm @@ -1,29 +1,17 @@ -// Gun feel: a directional recoil kick, a muzzle flash, flying casings and shouldering for every gun. +// Gun feel: a directional recoil kick, a muzzle flash and flying casings for every gun. // The firing feel is modelled on Shiptest (shiptest-ss13/Shiptest), whose code is AGPL-3.0 like ours. // // Hooks, all chained onto the upstream procs (this file loads after code/, so these run first and call ..()): // - shoot_live_shot(): kick the shooter's camera and show the muzzle flash, once per shot. -// - process_fire(): add hip-fire spread when a long gun is not shouldered. -// - equipped()/dropped(): track when a long gun is held in both hands, for the shoulder delay. // The upstream random camera shake in shoot_live_shot() is removed (marked VOIDCREW EDIT in gun.dm). /obj/item/gun - /// Camera kick in tiles when shouldered (or for one-handed guns). Null picks a default from gun_feel_class. + /// Camera kick in tiles. Null picks a default from gun_feel_class. var/recoil_kick - /// Camera kick in tiles when a long gun is fired from the hip. Null picks a default. - var/recoil_kick_hip - /// Extra spread in degrees when a long gun is fired from the hip. Null picks a default. - var/hip_spread - /// How long both hands must be on a long gun before it is shouldered. - var/shoulder_delay = GUN_FEEL_SHOULDER_TIME /// GUN_FEEL_CLASS_*. Null until first needed; a subtype may preset it. var/gun_feel_class - /// TRUE if this gun is shouldered with two hands (rifles, shotguns, SMGs...). Set with the class. - var/gun_feel_long = FALSE /// TRUE once gun_feel_resolve() has filled in the values above. var/gun_feel_resolved = FALSE - /// world.time both hands went onto this gun, or 0 while it is not held that way. - var/gun_feel_brace_start = 0 /// The short-lived light from the last muzzle flash. var/obj/effect/dummy/lighting_obj/moblight/gun_feel_light @@ -39,48 +27,25 @@ if(isnull(gun_feel_class)) gun_feel_class = gun_feel_pick_class() var/kick = 0 - var/kick_hip = 0 - var/spread_hip = 0 switch(gun_feel_class) if(GUN_FEEL_CLASS_PISTOL) kick = GUN_FEEL_KICK_PISTOL - kick_hip = GUN_FEEL_KICK_PISTOL if(GUN_FEEL_CLASS_REVOLVER) kick = GUN_FEEL_KICK_REVOLVER - kick_hip = GUN_FEEL_KICK_REVOLVER if(GUN_FEEL_CLASS_SMG) kick = GUN_FEEL_KICK_SMG - kick_hip = GUN_FEEL_KICK_HIP_SMG - spread_hip = GUN_FEEL_HIP_SPREAD_SMG if(GUN_FEEL_CLASS_RIFLE) kick = GUN_FEEL_KICK_RIFLE - kick_hip = GUN_FEEL_KICK_HIP_RIFLE - spread_hip = GUN_FEEL_HIP_SPREAD_RIFLE if(GUN_FEEL_CLASS_SHOTGUN) kick = GUN_FEEL_KICK_SHOTGUN - kick_hip = GUN_FEEL_KICK_HIP_SHOTGUN - spread_hip = GUN_FEEL_HIP_SPREAD_SHOTGUN if(GUN_FEEL_CLASS_MARKSMAN) kick = GUN_FEEL_KICK_MARKSMAN - kick_hip = GUN_FEEL_KICK_HIP_MARKSMAN - spread_hip = GUN_FEEL_HIP_SPREAD_MARKSMAN if(GUN_FEEL_CLASS_HEAVY) kick = GUN_FEEL_KICK_HEAVY - kick_hip = GUN_FEEL_KICK_HIP_HEAVY - spread_hip = GUN_FEEL_HIP_SPREAD_HEAVY if(GUN_FEEL_CLASS_LAUNCHER) kick = GUN_FEEL_KICK_LAUNCHER - kick_hip = GUN_FEEL_KICK_HIP_LAUNCHER - spread_hip = GUN_FEEL_HIP_SPREAD_LAUNCHER - if(GUN_FEEL_CLASS_ENERGY_LONG) - spread_hip = GUN_FEEL_HIP_SPREAD_ENERGY_LONG if(isnull(recoil_kick)) recoil_kick = kick - if(isnull(recoil_kick_hip)) - recoil_kick_hip = kick_hip - if(isnull(hip_spread)) - hip_spread = spread_hip - gun_feel_long = hip_spread > 0 /// Works out what kind of gun this is from its type, weight and size. /obj/item/gun/proc/gun_feel_pick_class() @@ -124,7 +89,6 @@ /// A crossbow, not a rifle. /obj/item/gun/ballistic/rifle/rebarxbow recoil_kick = 0.06 - recoil_kick_hip = 0.12 // ---- Per shot ---- @@ -138,7 +102,7 @@ return gun_feel_resolve() var/angle = gun_feel_fire_angle(user, target) - gun_feel_kick(user, angle, gun_feel_braced(user)) + gun_feel_kick(user, angle) if(gun_feel_wants_flash()) gun_feel_muzzle_flash(user, angle) @@ -151,11 +115,10 @@ var/facing = dir2angle(user.dir) return isnull(facing) ? 0 : facing -/obj/item/gun/proc/gun_feel_kick(mob/living/user, angle, braced) +/obj/item/gun/proc/gun_feel_kick(mob/living/user, angle) if(!user.client) return - var/strength = braced ? recoil_kick : recoil_kick_hip - strength = min(strength + max(recoil, 0) * GUN_FEEL_LEGACY_RECOIL_KICK, GUN_FEEL_MAX_KICK) + var/strength = min(recoil_kick + max(recoil, 0) * GUN_FEEL_LEGACY_RECOIL_KICK, GUN_FEEL_MAX_KICK) if(strength <= 0) return var/kick_angle = angle + 180 + (rand() * 2 - 1) * GUN_FEEL_RECOIL_DEVIATION @@ -194,75 +157,3 @@ var/rest_y = viewer.pixel_y animate(viewer, pixel_x = rest_x + kick_x, pixel_y = rest_y + kick_y, time = duration, easing = CUBIC_EASING | EASE_OUT, tag = GUN_FEEL_RECOIL_ANIM_TAG) animate(pixel_x = rest_x, pixel_y = rest_y, time = backtime, easing = BACK_EASING | EASE_OUT) - -// ---- Shouldering ---- - -/obj/item/gun/process_fire(atom/target, mob/living/user, message = TRUE, params = null, zone_override = "", bonus_spread = 0) - if(isliving(user)) - bonus_spread += gun_feel_hip_spread(user) - return ..(target, user, message, params, zone_override, bonus_spread) - -/// Extra spread for firing this gun right now: none when shouldered. -/obj/item/gun/proc/gun_feel_hip_spread(mob/living/user) - gun_feel_resolve() - if(!hip_spread || gun_feel_braced(user)) - return 0 - return hip_spread - -/// TRUE if this shot counts as shouldered. Only player-shaped (carbon) shooters of long guns can be unshouldered. -/obj/item/gun/proc/gun_feel_braced(mob/living/user) - // An underbarrel launcher or hook sits inside the gun that is actually held; it shares that gun's grip. - if(isgun(loc)) - var/obj/item/gun/carrier = loc - return carrier.gun_feel_braced(user) - gun_feel_resolve() - if(!gun_feel_long || !iscarbon(user) || tk_firing(user)) - return TRUE - if(!gun_feel_both_hands(user)) - gun_feel_brace_start = 0 - return FALSE - if(!gun_feel_brace_start) - gun_feel_brace_start = world.time - return world.time - gun_feel_brace_start >= shoulder_delay - -/// TRUE if the holder has this gun in one hand and the paired hand is free and working. -/obj/item/gun/proc/gun_feel_both_hands(mob/living/holder) - var/gun_hand = holder.get_held_index_of_item(src) - if(!gun_hand) - return FALSE - var/pair_hand = IS_RIGHT_INDEX(gun_hand) ? gun_hand - 1 : gun_hand + 1 - if(pair_hand < 1 || pair_hand > length(holder.held_items)) - return FALSE - return !holder.held_items[pair_hand] && holder.has_hand_for_held_index(pair_hand) - -/obj/item/gun/equipped(mob/user, slot, initial = FALSE) - . = ..() - if(!(slot & ITEM_SLOT_HANDS)) - return - gun_feel_resolve() - if(!gun_feel_long) - return - RegisterSignals(user, list(COMSIG_MOB_EQUIPPED_ITEM, COMSIG_MOB_UNEQUIPPED_ITEM), PROC_REF(gun_feel_hands_changed), override = TRUE) - gun_feel_brace_start = 0 - gun_feel_update_brace(user) - -/obj/item/gun/dropped(mob/user, silent = FALSE) - if(gun_feel_long && user) - UnregisterSignal(user, list(COMSIG_MOB_EQUIPPED_ITEM, COMSIG_MOB_UNEQUIPPED_ITEM)) - gun_feel_brace_start = 0 - return ..() - -/// Something went into or out of the holder's inventory; the off hand may have filled or freed up. -/obj/item/gun/proc/gun_feel_hands_changed(mob/living/source, obj/item/item) - SIGNAL_HANDLER - if(item == src) - return - gun_feel_update_brace(source) - -/// Starts the shoulder clock when both hands go onto the gun, and stops it when they don't. -/obj/item/gun/proc/gun_feel_update_brace(mob/living/holder) - if(!holder.is_holding(src) || !gun_feel_both_hands(holder)) - gun_feel_brace_start = 0 - return - if(!gun_feel_brace_start) - gun_feel_brace_start = world.time diff --git a/voidcrew/modules/unit_tests/voidcrew_gun_feel.dm b/voidcrew/modules/unit_tests/voidcrew_gun_feel.dm index 6f3f38d5b94..6dde05e72f2 100644 --- a/voidcrew/modules/unit_tests/voidcrew_gun_feel.dm +++ b/voidcrew/modules/unit_tests/voidcrew_gun_feel.dm @@ -47,29 +47,18 @@ TEST_ASSERT(abs(casing.pixel_x - casing.base_pixel_x) <= 16 && abs(casing.pixel_y - casing.base_pixel_y) <= 16, "A flight at [angle] degrees left the casing drawn off its tile.") TEST_ASSERT_EQUAL(casing.pixel_z, casing.base_pixel_z, "A flight at [angle] degrees left the casing floating.") -/// Long guns are hip-fired until both hands have been on them for the shoulder delay (0.4 s). -/datum/unit_test/voidcrew_gun_feel_shouldering -/datum/unit_test/voidcrew_gun_feel_shouldering/Run() +/// Gun feel never changes a gun's spread, whatever is in the other hand. +/datum/unit_test/voidcrew_gun_feel_no_spread +/datum/unit_test/voidcrew_gun_feel_no_spread/Run() var/mob/living/carbon/human/consistent/shooter = allocate(/mob/living/carbon/human/consistent) var/obj/item/gun/ballistic/automatic/wt550/rifle = allocate(/obj/item/gun/ballistic/automatic/wt550) + var/start_spread = rifle.spread TEST_ASSERT(shooter.put_in_hands(rifle), "The shooter could not hold the rifle.") - TEST_ASSERT(rifle.gun_feel_long, "The WT-550 should be a long gun.") - TEST_ASSERT(rifle.gun_feel_hip_spread(shooter) > 0, "A long gun should not be shouldered the moment it is drawn.") - - rifle.gun_feel_brace_start = max(1, world.time - 1 SECONDS) - TEST_ASSERT_EQUAL(rifle.gun_feel_hip_spread(shooter), 0, "A long gun held in both hands past the delay should be shouldered.") - var/obj/item/pen/pen = allocate(/obj/item/pen) TEST_ASSERT(shooter.put_in_hands(pen), "The shooter could not hold a pen in the other hand.") - TEST_ASSERT_EQUAL(rifle.gun_feel_brace_start, 0, "Filling the off hand should drop the shoulder.") - TEST_ASSERT(rifle.gun_feel_hip_spread(shooter) > 0, "A long gun held one-handed should fire from the hip.") - - shooter.dropItemToGround(pen) - TEST_ASSERT(rifle.gun_feel_brace_start, "Freeing the off hand should start the shoulder clock.") - TEST_ASSERT(rifle.gun_feel_hip_spread(shooter) > 0, "A long gun should not be shouldered the moment the off hand frees up.") - - var/obj/item/gun/ballistic/automatic/pistol/pistol = allocate(/obj/item/gun/ballistic/automatic/pistol) - TEST_ASSERT_EQUAL(pistol.gun_feel_hip_spread(shooter), 0, "Pistols have no hip-fire penalty.") + rifle.gun_feel_resolve() + TEST_ASSERT(rifle.recoil_kick > 0, "The WT-550 should have a camera kick.") + TEST_ASSERT_EQUAL(rifle.spread, start_spread, "Gun feel should never change a gun's spread.") /// TRUE if two matrices are the same, element by element. /datum/unit_test/proc/gun_feel_same_matrix(matrix/first, matrix/second) From ce902fac937e0752c2ccf095b19cfab6081ee551 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:05:25 -0800 Subject: [PATCH 007/472] Mission waves arrive by drop pod or dig out of the ground Defender waves called by mission objectives (survey pylons, extraction alarms, expedition ambushes, claim defense) no longer appear next to the crew. Pirates, syndicates and robots drop in by pod with the usual landing marker; bugs, wildlife, undead and cult dig up after a short rumble. Mapped ruin guards still spawn instantly. --- tgstation.dme | 1 + voidcrew/_DEFINES/missions.dm | 7 ++ voidcrew/modules/loot/themes/expedition.dm | 2 + voidcrew/modules/loot/themes/occult.dm | 2 + voidcrew/modules/loot/themes/research.dm | 1 + voidcrew/modules/loot/themes/syndicate.dm | 1 + voidcrew/modules/loot/zone_mobs.dm | 23 ++++++- voidcrew/modules/loot/zone_mobs_arrival.dm | 65 +++++++++++++++++++ .../missions/missions/field_expedition.dm | 2 +- .../missions/missions/recovery_variants.dm | 4 +- .../modules/missions/objectives/site_ops.dm | 4 +- 11 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 voidcrew/modules/loot/zone_mobs_arrival.dm diff --git a/tgstation.dme b/tgstation.dme index 1938407f825..a06d0df2577 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7246,6 +7246,7 @@ #include "voidcrew\modules\loot\loot_guns.dm" #include "voidcrew\modules\loot\zone_loot.dm" #include "voidcrew\modules\loot\zone_mobs.dm" +#include "voidcrew\modules\loot\zone_mobs_arrival.dm" #include "voidcrew\modules\loot\zone_resolution.dm" #include "voidcrew\modules\loot\themes\_theme.dm" #include "voidcrew\modules\loot\themes\armory.dm" diff --git a/voidcrew/_DEFINES/missions.dm b/voidcrew/_DEFINES/missions.dm index 5f3388d0b53..add1428d01a 100644 --- a/voidcrew/_DEFINES/missions.dm +++ b/voidcrew/_DEFINES/missions.dm @@ -97,3 +97,10 @@ /obj/effect/zone_mobs/undead = 3,\ /obj/effect/zone_mobs/bug = 3,\ ) + +/// zone_mobs wave arrivals: a drop pod lands the mob +#define ZONE_MOBS_ARRIVE_POD "pod" +/// zone_mobs wave arrivals: the mob digs up out of the ground +#define ZONE_MOBS_ARRIVE_EMERGE "emerge" +/// How far a wave's arrivals scatter from the spot that called them +#define ZONE_MOBS_WAVE_SCATTER 4 diff --git a/voidcrew/modules/loot/themes/expedition.dm b/voidcrew/modules/loot/themes/expedition.dm index 5dfa9fbb1a7..0ac05941460 100644 --- a/voidcrew/modules/loot/themes/expedition.dm +++ b/voidcrew/modules/loot/themes/expedition.dm @@ -106,6 +106,7 @@ /// Planet fauna: territorial wildlife around and inside surface ruins. /obj/effect/zone_mobs/wildlife name = "zone mob spawner (wildlife)" + wave_arrival = ZONE_MOBS_ARRIVE_EMERGE mobs_green = list( /mob/living/basic/mining/bileworm = 8, /mob/living/basic/mining/lobstrosity = 6, @@ -149,6 +150,7 @@ /// asteroid ruin, everything in these tables survives space. /obj/effect/zone_mobs/asteroid name = "zone mob spawner (asteroid)" + wave_arrival = ZONE_MOBS_ARRIVE_EMERGE mobs_green = list( /mob/living/basic/mining/goldgrub = 8, /mob/living/basic/mining/hivelord = 6, diff --git a/voidcrew/modules/loot/themes/occult.dm b/voidcrew/modules/loot/themes/occult.dm index 694c55147a2..aa717cc19a6 100644 --- a/voidcrew/modules/loot/themes/occult.dm +++ b/voidcrew/modules/loot/themes/occult.dm @@ -106,6 +106,7 @@ /// Cult remnants: constructs still bound to a dead master's wards. /obj/effect/zone_mobs/cult name = "zone mob spawner (cult)" + wave_arrival = ZONE_MOBS_ARRIVE_EMERGE mobs_green = list( /mob/living/basic/construct/proteon/hostile = 10, ) @@ -134,6 +135,7 @@ /// The restless dead: graves, derelicts, plague sites. /obj/effect/zone_mobs/undead name = "zone mob spawner (undead)" + wave_arrival = ZONE_MOBS_ARRIVE_EMERGE mobs_green = list( /mob/living/basic/skeleton = 10, /mob/living/basic/zombie = 6, diff --git a/voidcrew/modules/loot/themes/research.dm b/voidcrew/modules/loot/themes/research.dm index dffb832ac37..4edd197299c 100644 --- a/voidcrew/modules/loot/themes/research.dm +++ b/voidcrew/modules/loot/themes/research.dm @@ -108,6 +108,7 @@ /// Infested nests: giant spiders. Webs sold separately. /obj/effect/zone_mobs/bug name = "zone mob spawner (bug)" + wave_arrival = ZONE_MOBS_ARRIVE_EMERGE mobs_green = list( /mob/living/basic/spider/giant/nurse = 8, /mob/living/basic/spider/giant = 6, diff --git a/voidcrew/modules/loot/themes/syndicate.dm b/voidcrew/modules/loot/themes/syndicate.dm index 1f63090058e..af5e4a179c9 100644 --- a/voidcrew/modules/loot/themes/syndicate.dm +++ b/voidcrew/modules/loot/themes/syndicate.dm @@ -95,6 +95,7 @@ /// Syndicate holdouts: operatives left guarding whatever the base was for. /obj/effect/zone_mobs/syndicate name = "zone mob spawner (syndicate)" + wave_pod_style = /datum/pod_style/syndicate mobs_green = list( /mob/living/basic/trooper/syndicate/melee = 14, ) diff --git a/voidcrew/modules/loot/zone_mobs.dm b/voidcrew/modules/loot/zone_mobs.dm index 284115aa4f7..fa12198c220 100644 --- a/voidcrew/modules/loot/zone_mobs.dm +++ b/voidcrew/modules/loot/zone_mobs.dm @@ -51,13 +51,24 @@ var/npc_role = "auto" /// How far spawns scatter from the marker var/scatter_radius = 2 + /// How a runtime wave gets on scene: ZONE_MOBS_ARRIVE_POD or ZONE_MOBS_ARRIVE_EMERGE + var/wave_arrival = ZONE_MOBS_ARRIVE_POD + /// Pod look for ZONE_MOBS_ARRIVE_POD waves + var/datum/pod_style/wave_pod_style = /datum/pod_style + /// Set for runtime waves: the pack shows up on screen instead of appearing in place + var/wave = FALSE /** * Runtime spawns (mission waves) can pass a count override: * new /obj/effect/zone_mobs/pirate(T, list(1, 2)) guarantees 1-2 spawns in * every band, regardless of the theme's defaults. + * Pass wave = TRUE when players may be watching: the pack drops in by pod or + * digs out of the ground (wave_arrival) instead of appearing next to them. */ -/obj/effect/zone_mobs/Initialize(mapload, list/count_override) +/obj/effect/zone_mobs/Initialize(mapload, list/count_override, wave = FALSE) + src.wave = wave + if(wave) + scatter_radius = max(scatter_radius, ZONE_MOBS_WAVE_SCATTER) if(npc_role == "auto" && npc_zone_boss_type(type)) npc_role = "boss" if(islist(count_override) && length(count_override) >= 2) @@ -107,7 +118,12 @@ continue var/turf/spot = length(open_turfs) ? pick_n_take(open_turfs) : get_turf(src) var/datum/npc_spawn_context/context = new(src, zone_type, npc_role) - spawn_npc_in_zone(mob_path, spot, context) + if(!wave) + spawn_npc_in_zone(mob_path, spot, context) + else if(wave_arrival == ZONE_MOBS_ARRIVE_EMERGE) + zone_mobs_emerge(mob_path, spot, context) + else + zone_mobs_pod_drop(mob_path, spot, context, wave_pod_style) /** * Open, unblocked turfs around the marker the pack can scatter onto. @@ -115,7 +131,8 @@ /obj/effect/zone_mobs/proc/get_scatter_turfs() var/list/result = list() for(var/turf/open/tile in RANGE_TURFS(scatter_radius, src)) - if(tile.is_blocked_turf(exclude_mobs = TRUE)) + // A wave never lands a pod or digs up under someone's feet + if(tile.is_blocked_turf(exclude_mobs = !wave)) continue result += tile return result diff --git a/voidcrew/modules/loot/zone_mobs_arrival.dm b/voidcrew/modules/loot/zone_mobs_arrival.dm new file mode 100644 index 00000000000..8c6e87d5cfe --- /dev/null +++ b/voidcrew/modules/loot/zone_mobs_arrival.dm @@ -0,0 +1,65 @@ +/** + * # Zone Mob Wave Arrivals + * + * Runtime waves (new /obj/effect/zone_mobs/(T, count, TRUE)) are called + * in while players are standing right there, so the pack has to visibly get + * there instead of appearing beside them. Crews drop in by pod; beasts, bugs + * and the dead dig up out of the ground after a short rumble. + */ + +/// How long the ground shakes before something digs out of it +#define ZONE_MOBS_EMERGE_TIME (2.5 SECONDS) +/// How far below the tile an emerging mob starts +#define ZONE_MOBS_EMERGE_DEPTH 16 + +/** + * Loads one mob into a pod and drops it on the spot. The pod shows its usual + * landing marker, does no damage or explosion, opens, then leaves, so it never + * stays behind as free furniture. + */ +/proc/zone_mobs_pod_drop(mob_path, turf/spot, datum/npc_spawn_context/context, datum/pod_style/style = /datum/pod_style) + var/obj/structure/closet/supplypod/podspawn/pod = new(null, style) + spawn_npc_in_zone(mob_path, pod, context) + new /obj/effect/pod_landingzone(spot, pod) + +/** + * The ground rumbles, then the mob climbs out of it and leaves rubble behind. + */ +/proc/zone_mobs_emerge(mob_path, turf/spot, datum/npc_spawn_context/context) + new /obj/effect/temp_visual/zone_mobs_emerge(spot) + playsound(spot, 'sound/effects/break_stone.ogg', 50, TRUE) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(zone_mobs_emerge_finish), mob_path, spot, context), ZONE_MOBS_EMERGE_TIME) + +/proc/zone_mobs_emerge_finish(mob_path, turf/spot, datum/npc_spawn_context/context) + if(!spot) + return + playsound(spot, 'sound/effects/shovel_dig.ogg', 60, TRUE) + new /obj/effect/decal/cleanable/rubble(spot) + var/mob/living/body = spawn_npc_in_zone(mob_path, spot, context) + if(!isliving(body) || QDELETED(body)) + return + // Parallel and relative so a mob's own idle animation (floating, bobbing) survives + var/final_alpha = body.alpha + body.alpha = 0 + body.pixel_z -= ZONE_MOBS_EMERGE_DEPTH + animate(body, pixel_z = ZONE_MOBS_EMERGE_DEPTH, time = 0.6 SECONDS, easing = QUAD_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(body, alpha = final_alpha, time = 0.4 SECONDS, flags = ANIMATION_PARALLEL) + +/// The shaking dirt that gives away something digging up +/obj/effect/temp_visual/zone_mobs_emerge + name = "shifting ground" + icon = 'icons/obj/debris.dmi' + icon_state = "rubble_bounce" + layer = ABOVE_OPEN_TURF_LAYER + plane = FLOOR_PLANE + randomdir = FALSE + duration = ZONE_MOBS_EMERGE_TIME + +/obj/effect/temp_visual/zone_mobs_emerge/Initialize(mapload) + . = ..() + animate(src, pixel_x = 1, time = 0.5, loop = -1) + animate(pixel_x = -1, time = 1) + animate(pixel_x = 0, time = 0.5) + +#undef ZONE_MOBS_EMERGE_TIME +#undef ZONE_MOBS_EMERGE_DEPTH diff --git a/voidcrew/modules/missions/missions/field_expedition.dm b/voidcrew/modules/missions/missions/field_expedition.dm index 81fd90250d9..5ca8eaec4e4 100644 --- a/voidcrew/modules/missions/missions/field_expedition.dm +++ b/voidcrew/modules/missions/missions/field_expedition.dm @@ -64,7 +64,7 @@ UnregisterSignal(source, COMSIG_ITEM_PICKUP) if(failed || completed || !wave_theme || !prob(ambush_chance)) return - new wave_theme(get_turf(taker), list(1, 2)) + new wave_theme(get_turf(taker), list(1, 2), TRUE) servant?.ship_notify("[name]: ground team disturbed something at the crash site.", "MISSION UPDATE", SHIP_NOTIFY_WARNING, 'voidcrew/sound/notify2.ogg', 50) /datum/mission/field_expedition/update_text() diff --git a/voidcrew/modules/missions/missions/recovery_variants.dm b/voidcrew/modules/missions/missions/recovery_variants.dm index 7a38e0c38bc..2a53a981637 100644 --- a/voidcrew/modules/missions/missions/recovery_variants.dm +++ b/voidcrew/modules/missions/missions/recovery_variants.dm @@ -189,7 +189,7 @@ return alarm_tripped = TRUE var/turf/site = get_turf(cargo) - new wave_theme(site, list(1, 2)) + new wave_theme(site, list(1, 2), TRUE) second_wave_timer = addtimer(CALLBACK(src, PROC_REF(second_wave), site), EXTRACTION_SECOND_WAVE_DELAY, TIMER_STOPPABLE) servant?.ship_notify("[name]: site security alarm triggered.", "MISSION UPDATE", SHIP_NOTIFY_WARNING, 'voidcrew/sound/notify2.ogg', 50) @@ -197,7 +197,7 @@ second_wave_timer = null if(failed || completed || !site) return - new wave_theme(site, list(1, 2)) + new wave_theme(site, list(1, 2), TRUE) /** * # Anchored Recovery Cargo diff --git a/voidcrew/modules/missions/objectives/site_ops.dm b/voidcrew/modules/missions/objectives/site_ops.dm index 0689c21430e..a77f06b6a49 100644 --- a/voidcrew/modules/missions/objectives/site_ops.dm +++ b/voidcrew/modules/missions/objectives/site_ops.dm @@ -163,7 +163,7 @@ points_done++ // Calibration is loud. Something heard it. if(wave_theme) - new wave_theme(get_turf(pylon), list(1, 2)) + new wave_theme(get_turf(pylon), list(1, 2), TRUE) // The finished pylon stays as a breadcrumb but stops being the objective mission.forget_quest_atom(pylon) @@ -555,7 +555,7 @@ // Periodic company if(wave_theme && beacon.next_wave_at <= world.time) beacon.next_wave_at = world.time + wave_interval - new wave_theme(get_turf(beacon), list(1, 2)) + new wave_theme(get_turf(beacon), list(1, 2), TRUE) notify_crew("Claim jumpers inbound on the beacon!", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') /// The clock ran out with the beacon alive: print the deed From 50a08fd7a45d10cc3fb80c3493d4012648f73183 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:11:11 -0800 Subject: [PATCH 008/472] Gun feel: wooden tables sound like wood when shot tg's wooden table inherits the base table's iron materials, so the impact lookup must check known types before materials. --- voidcrew/modules/gun_feel/impacts.dm | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/voidcrew/modules/gun_feel/impacts.dm b/voidcrew/modules/gun_feel/impacts.dm index 71b134ef98f..f4b20981c77 100644 --- a/voidcrew/modules/gun_feel/impacts.dm +++ b/voidcrew/modules/gun_feel/impacts.dm @@ -103,26 +103,29 @@ GLOBAL_LIST_INIT(gun_feel_surface_types, list( )), )) -/// Surface by type, worked out once per type. +/// Listed surface by type, worked out once per type. FALSE caches "not listed". GLOBAL_LIST_EMPTY(gun_feel_surface_by_type) -/// What a bullet hitting `target` should sound like: one of the GUN_FEEL_SURFACE_* names. /proc/gun_feel_surface_of(atom/target) if(isliving(target)) var/mob/living/victim = target return (victim.mob_biotypes & MOB_ROBOTIC) ? GUN_FEEL_SURFACE_METAL : GUN_FEEL_SURFACE_FLESH - // Materials vary per object (a table made of wood or of glass), so they are read every time. - var/from_materials = gun_feel_surface_of_materials(target.custom_materials) - if(from_materials) - return from_materials - var/surface = GLOB.gun_feel_surface_by_type[target.type] - if(isnull(surface)) - surface = gun_feel_surface_of_type(target.type) - GLOB.gun_feel_surface_by_type[target.type] = surface - return surface + // Known types first: some never set their own materials (tg's wooden table inherits the base table's iron). + var/listed = GLOB.gun_feel_surface_by_type[target.type] + if(isnull(listed)) + listed = gun_feel_listed_surface(target.type) || FALSE + GLOB.gun_feel_surface_by_type[target.type] = listed + if(listed) + return listed + // Otherwise go by what it's made of (a generic table built from wood or glass). + return gun_feel_surface_of_materials(target.custom_materials) || GUN_FEEL_SURFACE_METAL /// Surface for a type with no materials to go by. /proc/gun_feel_surface_of_type(atom_type) + return gun_feel_listed_surface(atom_type) || GUN_FEEL_SURFACE_METAL + +/// Surface for a type on the lists above, or null if it isn't listed. +/proc/gun_feel_listed_surface(atom_type) // Snowy and icy rock looks like stone by type, so check its name first. if(ispath(atom_type, /turf/closed/mineral) || ispath(atom_type, /turf/closed/indestructible/rock) || ispath(atom_type, /turf/open/misc)) var/type_name = "[atom_type]" @@ -132,7 +135,7 @@ GLOBAL_LIST_EMPTY(gun_feel_surface_by_type) var/list/types = GLOB.gun_feel_surface_types[surface] if(types[atom_type]) return surface - return GUN_FEEL_SURFACE_METAL + return null /// Surface for the main material in a custom_materials list, or null if there is none. /proc/gun_feel_surface_of_materials(list/materials) From 87172e2c7daab741fe4611c1cfdf9ea4f6cfa232 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:19:06 -0800 Subject: [PATCH 009/472] Add hyperviolence directional blood spray, growing pools and zero-G floating blood Spray art (squirt_greyscale, splatter_greyscale, splatterbloodalt_greyscale) copied from Azure Peak (Azure-Peak/Azure-Peak @ 023a346, CC BY-SA 3.0); spray, wall splatter and drench adapted from its code/modules/mob/living/blood.dm (AGPL-3.0). Floating blood globules (mfloor1-7, mgibbl1-5) recoloured from Paradise (ParadiseSS13/Paradise @ d2a2bb03d588, icons/effects/blood_weightless.dmi, CC BY-SA 3.0); the float-and-splat idea follows its blood decals (AGPL-3.0). Pool art is original. --- tgstation.dme | 4 + voidcrew/modules/hyperviolence/_core.dm | 10 +- .../hyperviolence/blood/_blood_defines.dm | 29 +++ .../hyperviolence/blood/blood_pools.dm | 62 +++++ .../hyperviolence/blood/blood_spray.dm | 215 ++++++++++++++++++ .../hyperviolence/blood/blood_weightless.dm | 169 ++++++++++++++ .../hyperviolence/blood/icons/blood_pool.dmi | Bin 0 -> 3386 bytes .../hyperviolence/blood/icons/blood_spray.dmi | Bin 0 -> 5118 bytes .../blood/icons/blood_weightless.dmi | Bin 0 -> 1033 bytes 9 files changed, 483 insertions(+), 6 deletions(-) create mode 100644 voidcrew/modules/hyperviolence/blood/_blood_defines.dm create mode 100644 voidcrew/modules/hyperviolence/blood/blood_pools.dm create mode 100644 voidcrew/modules/hyperviolence/blood/blood_spray.dm create mode 100644 voidcrew/modules/hyperviolence/blood/blood_weightless.dm create mode 100644 voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi create mode 100644 voidcrew/modules/hyperviolence/blood/icons/blood_spray.dmi create mode 100644 voidcrew/modules/hyperviolence/blood/icons/blood_weightless.dmi diff --git a/tgstation.dme b/tgstation.dme index 2f7df14adc8..5f1c1efb543 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7230,6 +7230,10 @@ #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" #include "voidcrew\modules\hyperviolence\_core.dm" +#include "voidcrew\modules\hyperviolence\blood\_blood_defines.dm" +#include "voidcrew\modules\hyperviolence\blood\blood_pools.dm" +#include "voidcrew\modules\hyperviolence\blood\blood_spray.dm" +#include "voidcrew\modules\hyperviolence\blood\blood_weightless.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" #include "voidcrew\modules\intents\hud_intent.dm" diff --git a/voidcrew/modules/hyperviolence/_core.dm b/voidcrew/modules/hyperviolence/_core.dm index 0fd61071f56..1a1f8e9e6b7 100644 --- a/voidcrew/modules/hyperviolence/_core.dm +++ b/voidcrew/modules/hyperviolence/_core.dm @@ -9,13 +9,11 @@ var/shown = severity >= HV_LINE_HUGE ? "[text]" : "[text]" visible_message(span_danger(shown), vision_distance = COMBAT_MESSAGE_RANGE) -/// Sprays blood from src toward spray_dir. The blood part replaces this with directional spray -/// decals that paint walls and drench bystanders; the stub uses tg's flying splatter. +/// Sprays blood from src toward spray_dir: streaks and a splash on the floor, a smear on the first +/// wall in the way, and anyone standing in the line is drenched. The body lives in the blood part +/// (blood/blood_spray.dm). /mob/living/proc/hv_blood_spray(spray_dir, strength = 3) - return - -/mob/living/carbon/hv_blood_spray(spray_dir, strength = 3) - spray_blood(spray_dir || pick(GLOB.alldirs), strength) + return hv_blood_throw(spray_dir, strength) /// Bursts src's head: it comes off in pieces rather than dropping whole. burst_dir is the way the /// shot or blow travelled. The brain-and-guts part replaces this to fling the brain out intact. diff --git a/voidcrew/modules/hyperviolence/blood/_blood_defines.dm b/voidcrew/modules/hyperviolence/blood/_blood_defines.dm new file mode 100644 index 00000000000..784b388d1da --- /dev/null +++ b/voidcrew/modules/hyperviolence/blood/_blood_defines.dm @@ -0,0 +1,29 @@ +// Hyperviolence blood part: defines private to this part. Defines shared between the +// hyperviolence parts live in voidcrew/_DEFINES/hyperviolence.dm. + +/// Smallest brute hit, after armour, that can throw blood across the floor. +#define HV_BLOOD_MIN_HIT 12 +/// Brute damage per tile a sprayed hit reaches. +#define HV_BLOOD_DAMAGE_PER_TILE 12 +/// Furthest a single spray line can reach. +#define HV_BLOOD_MAX_RANGE 5 +/// A mob throws one spray per window, so a burst of shots into one body paints the same few tiles once. +#define HV_BLOOD_SPRAY_WINDOW (0.4 SECONDS) +/// A limb that comes off within this long of a hit is blown off in the direction of that hit. +#define HV_BLOOD_HIT_MEMORY (0.5 SECONDS) +/// Sprays at or above this many tiles also throw a pair of flanking lines either side of the main one. +#define HV_BLOOD_FAN_STRENGTH 3 +/// Tiles a spray reaches when a limb comes off. +#define HV_BLOOD_DISMEMBER_STRENGTH 3 + +/// Each unit of blood a standing bleeder loses adds this much to the pool under them. +#define HV_BLOOD_POOL_PER_UNIT 1 + +/// How often, at most, floating blood is checked against gravity when nothing announced a change. +#define HV_BLOOD_FLOAT_SWEEP (15 SECONDS) +/// Floating blood states in blood_weightless.dmi. +#define HV_BLOOD_FLOAT_VARIANTS 12 + +#define HV_BLOOD_SPRAY_ICON 'voidcrew/modules/hyperviolence/blood/icons/blood_spray.dmi' +#define HV_BLOOD_POOL_ICON 'voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi' +#define HV_BLOOD_FLOAT_ICON 'voidcrew/modules/hyperviolence/blood/icons/blood_weightless.dmi' diff --git a/voidcrew/modules/hyperviolence/blood/blood_pools.dm b/voidcrew/modules/hyperviolence/blood/blood_pools.dm new file mode 100644 index 00000000000..aef28f37a4a --- /dev/null +++ b/voidcrew/modules/hyperviolence/blood/blood_pools.dm @@ -0,0 +1,62 @@ +// Hyperviolence blood part: blood pools that grow as a bleeder stands in their own blood. +// +// Art: icons/blood_pool.dmi is original, drawn for this part in the style of tg's blood decals, +// greyscale so the decal takes the bleeder's blood colour. +// +// A pool is the ordinary blood decal tg already leaves under a bleeding mob: one per tile, picked +// up by cleaning, merging with new blood, drying out like any other. It only gets bigger. The +// extra blood a standing bleeder loses is added to it, and its picture steps up as it fills. + +/// How much blood the plain splatter on a tile holds before it grows into each pool size. +GLOBAL_LIST_INIT(hv_blood_pool_thresholds, list(90, 140, 200, 260)) + +/obj/effect/decal/cleanable/blood + /// How far this splatter has grown into a pool, 0 being the stock splatter. + var/hv_pool_tier = 0 + +/obj/effect/decal/cleanable/blood/adjust_bloodiness(by_amount, ignore_timer = FALSE) + . = ..() + if(. && by_amount > 0) + hv_update_pool_tier() + +/// Steps this splatter up to the next pool size once it holds enough blood. Never steps back down. +/obj/effect/decal/cleanable/blood/proc/hv_update_pool_tier() + // Only the plain splatter grows; trails, gibs, footprints, sprays and the rest keep their own look. + if(type != /obj/effect/decal/cleanable/blood || hv_floating || dried || QDELETED(src)) + return + var/new_tier = 0 + for(var/tier in 1 to length(GLOB.hv_blood_pool_thresholds)) + if(bloodiness >= GLOB.hv_blood_pool_thresholds[tier]) + new_tier = tier + if(new_tier <= hv_pool_tier) + return + hv_pool_tier = new_tier + icon = HV_BLOOD_POOL_ICON + icon_state = "pool[new_tier]_[rand(1, 3)]" + update_appearance() + +/mob/living + /// Which tile this mob was on the last time it bled, packed into one number. + var/hv_blood_pool_key = 0 + +/mob/living/bleed(amt) + . = ..() + if(amt > 0 && isturf(loc)) + hv_blood_feed_pool(amt) + +/// Blood lost while standing still goes into the splatter on the tile, which grows into a pool. +/mob/living/proc/hv_blood_feed_pool(amt) + var/turf/here = loc + var/key = (here.z * 1000 + here.y) * 1000 + here.x + var/stationary = (key == hv_blood_pool_key) + hv_blood_pool_key = key + // Moving leaves tg's drips and trails; only a bleeder who stays put builds a pool. + if(!stationary || HAS_TRAIT(src, TRAIT_GODMODE) || can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + if(isgroundlessturf(here) || !here.has_gravity()) + return + for(var/obj/effect/decal/cleanable/blood/pool in here) + if(pool.type != /obj/effect/decal/cleanable/blood || pool.dried || pool.hv_floating || QDELETED(pool)) + continue + pool.adjust_bloodiness(amt * HV_BLOOD_POOL_PER_UNIT) + return diff --git a/voidcrew/modules/hyperviolence/blood/blood_spray.dm b/voidcrew/modules/hyperviolence/blood/blood_spray.dm new file mode 100644 index 00000000000..651f75b352c --- /dev/null +++ b/voidcrew/modules/hyperviolence/blood/blood_spray.dm @@ -0,0 +1,215 @@ +// Hyperviolence blood part: directional blood spray. +// +// Art: icons/blood_spray.dmi holds the squirt_greyscale, splatter_greyscale and +// splatterbloodalt_greyscale states copied from Azure Peak (Azure-Peak/Azure-Peak at commit +// 023a346, icons/effects/blood.dmi), licensed CC BY-SA 3.0. +// Code: the spray line, wall splatter and drench are adapted from the same repository's +// code/modules/mob/living/blood.dm (blood_spray, blood_squirt, drench, add_splatter_wall), +// licensed AGPL-3.0. tg's flying hitsplatter is left alone; a hit that already threw one this +// tick does not throw a second spray. + +/mob/living + /// World time before which this mob will not throw another spray, unless it is a stronger one. + var/hv_blood_spray_until = 0 + /// How far the last spray reached, so a stronger one inside the window can still go. + var/hv_blood_spray_strength = 0 + /// The tick tg's own wound spray last fired from this mob, so one hit does not spray twice. + var/hv_blood_tg_spray_tick = -1 + /// Direction of the last brute hit this mob took, and when. + var/hv_blood_hit_dir = 0 + var/hv_blood_hit_time = -100000 + +/mob/living/Initialize(mapload) + . = ..() + RegisterSignal(src, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(hv_blood_on_damaged)) + if(iscarbon(src)) + RegisterSignal(src, COMSIG_CARBON_REMOVE_LIMB, TYPE_PROC_REF(/mob/living/carbon, hv_blood_on_limb_removed)) + +/// A fan of blood flung across a floor tile, facing the way it flew. +/obj/effect/decal/cleanable/blood/hv_spray + name = "blood splatter" + icon = HV_BLOOD_SPRAY_ICON + icon_state = "splatter_greyscale" + random_icon_states = list("splatter_greyscale", "splatterbloodalt_greyscale") + // One per tile and direction: the spray helper refreshes an existing one instead of stacking. + mergeable_decal = FALSE + // Thin enough that walking through it does not leave footprints. + bloodiness = 15 + drying_time = 3 MINUTES + beauty = -30 + base_name = null + base_suffix = "splatter" + +/// Turns the decal to the way the blood flew. +/obj/effect/decal/cleanable/blood/hv_spray/proc/hv_aim(spray_dir) + setDir(spray_dir) + +/// A thin streak of blood along the floor, the part of the line before the final splash. +/obj/effect/decal/cleanable/blood/hv_spray/streak + name = "blood streak" + icon_state = "squirt_greyscale" + random_icon_states = null + base_suffix = "streak" + +/// Blood on the face of a wall or window. Sits on the floor tile in front of it, drawn onto the obstruction. +/obj/effect/decal/cleanable/blood/hv_spray/wall + plane = GAME_PLANE + layer = ABOVE_WINDOW_LAYER + alpha = 200 + +/obj/effect/decal/cleanable/blood/hv_spray/wall/hv_aim(spray_dir) + . = ..() + pixel_x = 0 + pixel_y = 0 + if(spray_dir & NORTH) + pixel_y = 32 + else if(spray_dir & SOUTH) + pixel_y = -32 + if(spray_dir & EAST) + pixel_x = 32 + else if(spray_dir & WEST) + pixel_x = -32 + +/// tg's wound spray: note the tick, so the heavy-hit spray does not add a second one to the same hit. +/mob/living/carbon/spray_blood(splatter_direction, splatter_strength = 3) + hv_blood_tg_spray_tick = world.time + return ..() + +/// Whether a spray stops at this tile: walls, windows and closed doors. +/proc/hv_blood_spray_blocked(turf/next) + if(isclosedturf(next)) + return TRUE + for(var/obj/blocker in next) + if(!blocker.density) + continue + if(istype(blocker, /obj/structure/window) || istype(blocker, /obj/machinery/door) || istype(blocker, /obj/structure/falsewall)) + return TRUE + return FALSE + +/// Lays one spray decal on a floor tile, or turns the one already there. Returns it. +/proc/hv_blood_lay_spray(turf/target, decal_type, spray_dir, list/blood_dna, list/viruses) + for(var/obj/effect/decal/cleanable/blood/hv_spray/existing in target) + if(existing.type != decal_type || existing.dried || QDELETED(existing)) + continue + existing.add_blood_DNA(blood_dna, viruses) + existing.hv_aim(spray_dir) + return existing + var/obj/effect/decal/cleanable/blood/hv_spray/fresh = new decal_type(target, viruses, blood_dna) + if(QDELETED(fresh)) + return null + fresh.hv_aim(spray_dir) + return fresh + +/** + * Throws blood from src towards spray_dir: streaks along the floor, a splash at the end, a smear + * on the first wall in the way, and anyone standing in the line is drenched. The hook the other + * hyperviolence parts call for a spray; hit and dismember triggers are at the bottom of this file. + * + * spray_dir is the way the blow or shot travelled, away from the attacker. strength is how many + * tiles the line reaches; big sprays also throw two flanking lines. + * Returns TRUE if anything was sprayed. + */ +/mob/living/proc/hv_blood_throw(spray_dir, strength = 3) + if(QDELETED(src) || !isturf(loc)) + return FALSE + if(can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return FALSE + strength = clamp(round(strength), 1, HV_BLOOD_MAX_RANGE) + if(world.time < hv_blood_spray_until && strength <= hv_blood_spray_strength) + return FALSE + hv_blood_spray_until = world.time + HV_BLOOD_SPRAY_WINDOW + hv_blood_spray_strength = strength + if(!spray_dir) + spray_dir = pick(GLOB.alldirs) + + var/list/blood_dna = get_blood_dna_list() + var/list/viruses = get_static_viruses() + var/list/drenched = list() + hv_blood_spray_line(spray_dir, strength, blood_dna, viruses, drenched) + if(strength >= HV_BLOOD_FAN_STRENGTH) + hv_blood_spray_line(turn(spray_dir, 45), strength - 1, blood_dna, viruses, drenched) + hv_blood_spray_line(turn(spray_dir, -45), strength - 1, blood_dna, viruses, drenched) + for(var/mob/living/splashed as anything in drenched) + splashed.hv_blood_drench(blood_dna, viruses) + return TRUE + +/// One line of spray out from src. Mobs it reaches are added to drenched. Returns TRUE if it hit a wall. +/mob/living/proc/hv_blood_spray_line(spray_dir, tiles, list/blood_dna, list/viruses, list/drenched) + var/turf/current = get_turf(src) + for(var/step in 1 to tiles) + var/turf/next = get_step(current, spray_dir) + if(!next) + return FALSE + if(hv_blood_spray_blocked(next)) + hv_blood_lay_wall_spray(current, spray_dir, blood_dna, viruses) + return TRUE + current = next + var/mob/living/in_the_way = locate() in current + if(in_the_way) + if(!(in_the_way in drenched)) + drenched += in_the_way + return FALSE + if(isgroundlessturf(current)) + continue + if(!current.has_gravity()) + // With nothing to pull it down the blood hangs in the air; the floating blood part takes over. + new /obj/effect/decal/cleanable/blood(current, viruses, blood_dna) + continue + hv_blood_lay_spray(current, step < tiles ? /obj/effect/decal/cleanable/blood/hv_spray/streak : /obj/effect/decal/cleanable/blood/hv_spray, spray_dir, blood_dna, viruses) + return FALSE + +/// Blood on whatever blocks the spray, drawn from the floor tile in front of it. +/mob/living/proc/hv_blood_lay_wall_spray(turf/floor, spray_dir, list/blood_dna, list/viruses) + if(isclosedturf(floor) || isgroundlessturf(floor)) + return + for(var/obj/effect/decal/cleanable/blood/hv_spray/wall/existing in floor) + if(existing.dir != spray_dir || existing.dried || QDELETED(existing)) + continue + existing.add_blood_DNA(blood_dna, viruses) + return existing + var/obj/effect/decal/cleanable/blood/hv_spray/wall/fresh = new(floor, viruses, blood_dna) + if(QDELETED(fresh)) + return null + fresh.hv_aim(spray_dir) + return fresh + +/// Blood across this mob and what it wears, through the usual forensics path. +/mob/living/proc/hv_blood_drench(list/blood_dna, list/viruses) + if(QDELETED(src) || !length(blood_dna)) + return + add_blood_DNA(blood_dna, viruses) + to_chat(src, span_warning("Blood spatters across you.")) + +/// Big brute hits throw blood the way they travelled. +/mob/living/proc/hv_blood_on_damaged(datum/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + SIGNAL_HANDLER + if(damagetype != BRUTE || !attack_direction) + return + // Remembered for any limb this hit takes off a moment later. + hv_blood_hit_dir = attack_direction + hv_blood_hit_time = world.time + if(damage_dealt < HV_BLOOD_MIN_HIT) + return + if(hv_blood_tg_spray_tick == world.time || world.time < hv_blood_spray_until) + return + if(blood_volume <= BLOOD_VOLUME_BAD || !isturf(loc)) + return + if(isbodypart(def_zone)) + var/obj/item/bodypart/hit_part = def_zone + if(!hit_part.can_bleed()) + return + // A hard hit always sprays; a glancing one sometimes does. A body already down is hit less wetly. + var/chance = clamp((damage_dealt - 8) * 4, 0, 100) + if(stat == DEAD) + chance *= 0.5 + if(!prob(chance)) + return + hv_blood_throw(attack_direction, round(damage_dealt / HV_BLOOD_DAMAGE_PER_TILE)) + +/// A limb torn off sprays the way the blow that took it travelled. +/mob/living/carbon/proc/hv_blood_on_limb_removed(datum/source, obj/item/bodypart/removed_limb, special, dismembered) + SIGNAL_HANDLER + if(special || !dismembered || !removed_limb.can_bleed()) + return + var/spray_dir = (world.time - hv_blood_hit_time <= HV_BLOOD_HIT_MEMORY) ? hv_blood_hit_dir : pick(GLOB.alldirs) + hv_blood_throw(spray_dir, HV_BLOOD_DISMEMBER_STRENGTH) diff --git a/voidcrew/modules/hyperviolence/blood/blood_weightless.dm b/voidcrew/modules/hyperviolence/blood/blood_weightless.dm new file mode 100644 index 00000000000..e53c467e736 --- /dev/null +++ b/voidcrew/modules/hyperviolence/blood/blood_weightless.dm @@ -0,0 +1,169 @@ +// Hyperviolence blood part: blood in zero gravity. +// +// Art: icons/blood_weightless.dmi is the mfloor1-7 and mgibbl1-5 globules from Paradise +// (ParadiseSS13/Paradise at commit d2a2bb03d588, icons/effects/blood_weightless.dmi), licensed +// CC BY-SA 3.0, recoloured to greyscale so they take the blood's own colour. +// Idea: Paradise's weightless_icon / check_gravity / splat() in +// code/game/objects/effects/decals/Cleanable/humans.dm (AGPL-3.0). +// +// Blood that lands where nothing pulls it down hangs in the air as bobbing beads, does not dry, +// and falls into an ordinary splatter once gravity comes back. Gravity changes are announced by +// the mobs standing in them (refresh_gravity), which is how tg and Voidcrew's gravity failure +// already tell everyone aboard. A slow sweep covers changes nobody announces, such as a gravity +// field around a turf. Blood already on the floor when gravity goes stays where it is. + +/// Blood that is hanging in the air right now. +GLOBAL_LIST_EMPTY(hv_floating_blood) +/// Timer for the sweep that catches gravity changes nothing announced. +GLOBAL_VAR(hv_blood_sweep_timer) + +/obj/effect/decal/cleanable/blood + /// Hanging in the air with no gravity to pull it down. + var/hv_floating = FALSE + +/// Whether blood here would hang in the air rather than lie on the floor. +/obj/effect/decal/cleanable/blood/proc/hv_blood_would_float() + // Only the plain splatters float; trails, gibs, footprints and flying splatters keep their own handling. + if(type != /obj/effect/decal/cleanable/blood && type != /obj/effect/decal/cleanable/blood/splatter) + return FALSE + if(dried || !isturf(loc)) + return FALSE + return has_gravity() <= 0 + +/obj/effect/decal/cleanable/blood/Initialize(mapload, list/datum/disease/diseases, list/blood_or_dna = get_default_blood_type()) + . = ..() + if(. == INITIALIZE_HINT_QDEL || mapload) + return + // A tick's grace: whatever made this decal may still be choosing its picture. + if(hv_blood_would_float()) + addtimer(CALLBACK(src, PROC_REF(hv_blood_float_begin)), 1) + +/obj/effect/decal/cleanable/blood/Destroy() + GLOB.hv_floating_blood -= src + return ..() + +/// Floating blood only runs together with floating blood, and lying blood with lying blood. +/obj/effect/decal/cleanable/blood/replace_decal(obj/effect/decal/cleanable/blood/merger) + if(istype(merger) && hv_blood_would_float() != merger.hv_floating) + return FALSE + return ..() + +/// Lifts this blood into the air. +/obj/effect/decal/cleanable/blood/proc/hv_blood_float_begin() + if(hv_floating || QDELETED(src) || !hv_blood_would_float()) + return + hv_floating = TRUE + // Weightless blood never dries. + can_dry = FALSE + STOP_PROCESSING(SSblood_drying, src) + GLOB.hv_floating_blood += src + hv_blood_arm_sweep() + icon = HV_BLOOD_FLOAT_ICON + icon_state = "float[rand(1, HV_BLOOD_FLOAT_VARIANTS)]" + layer = ABOVE_MOB_LAYER + SET_PLANE_IMPLICIT(src, GAME_PLANE) + base_name = "globules of" + // Drops the slow fade to dried that was set up when this was a splatter on the floor. + update_atom_colour() + update_appearance() + hv_blood_float_bob() + +/// Lets this blood fall: it joins the splatter already on the tile, or becomes one. +/obj/effect/decal/cleanable/blood/proc/hv_blood_land() + if(!hv_floating || QDELETED(src)) + return + hv_floating = FALSE + GLOB.hv_floating_blood -= src + animate(src) + pixel_x = initial(pixel_x) + pixel_y = initial(pixel_y) + transform = null + for(var/obj/effect/decal/cleanable/blood/lying in loc) + if(lying == src || lying.type != type || lying.hv_floating || lying.dried || QDELETED(lying)) + continue + handle_merge_decal(lying) + qdel(src) + return + icon = initial(icon) + icon_state = base_icon_state + layer = initial(layer) + SET_PLANE_IMPLICIT(src, initial(plane)) + base_name = initial(base_name) + can_dry = initial(can_dry) + if(!dried) + START_PROCESSING(SSblood_drying, src) + // Starts the fade to dried again, from the full drying time. + update_atom_colour() + update_appearance() + hv_pool_tier = 0 + hv_update_pool_tier() + +/// A slow rise and fall, a little different for every bead so they do not move together. +/obj/effect/decal/cleanable/blood/proc/hv_blood_float_bob() + var/lift = rand(2, 5) + var/drift = rand(-3, 3) + var/tilt = rand(6, 14) * pick(-1, 1) + var/period = rand(14, 26) + animate(src, pixel_y = lift, pixel_x = drift, transform = matrix().Turn(tilt), time = period, loop = -1, easing = SINE_EASING) + animate(pixel_y = 0, pixel_x = -drift, transform = matrix().Turn(-tilt), time = period, easing = SINE_EASING) + +// tg stops every animation on a decal whenever its colour is recalculated; put the bobbing back. +/obj/effect/decal/cleanable/blood/update_blood_color() + . = ..() + if(hv_floating) + hv_blood_float_bob() + +/obj/effect/decal/cleanable/blood/update_desc(updates) + . = ..() + if(hv_floating) + desc = "Beads of blood hang in the air." + +/obj/effect/decal/cleanable/blood/process(seconds_per_tick) + if(hv_floating) + return PROCESS_KILL + return ..() + +// Nothing to step in, and nothing to crawl into. +/obj/effect/decal/cleanable/blood/on_entered(datum/source, atom/movable/AM) + SIGNAL_HANDLER + if(hv_floating) + return + return ..() + +/obj/effect/decal/cleanable/blood/can_bloodcrawl_in() + if(hv_floating) + return FALSE + return ..() + +/// Without gravity a drip has nothing to fall onto: the blood hangs in the air as beads instead. +/datum/blood_type/make_blood_splatter(mob/living/bleeding, turf/blood_turf, drip = FALSE) + if(drip && isturf(blood_turf) && !blood_turf.has_gravity()) + drip = FALSE + return ..() + +/// Lets every piece of floating blood that now has gravity under it fall. +/proc/hv_blood_recheck_floating() + for(var/obj/effect/decal/cleanable/blood/floater as anything in GLOB.hv_floating_blood.Copy()) + if(QDELETED(floater)) + GLOB.hv_floating_blood -= floater + continue + if(floater.has_gravity() > 0) + floater.hv_blood_land() + +/// Starts the slow sweep if there is floating blood and no sweep already waiting. +/proc/hv_blood_arm_sweep() + if(GLOB.hv_blood_sweep_timer || !length(GLOB.hv_floating_blood)) + return + GLOB.hv_blood_sweep_timer = addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(hv_blood_gravity_sweep)), HV_BLOOD_FLOAT_SWEEP, TIMER_STOPPABLE) + +/proc/hv_blood_gravity_sweep() + GLOB.hv_blood_sweep_timer = null + hv_blood_recheck_floating() + hv_blood_arm_sweep() + +/// Anyone whose gravity comes back tells the floating blood to look again, once however many of them there are. +/mob/living/refresh_gravity() + var/old_gravity = gravity_state + . = ..() + if(gravity_state > 0 && !(old_gravity > 0) && length(GLOB.hv_floating_blood)) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(hv_blood_recheck_floating)), 1, TIMER_UNIQUE) diff --git a/voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi b/voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi new file mode 100644 index 0000000000000000000000000000000000000000..1306debeaf182c9f0d258cd44c37bd7b0d238c68 GIT binary patch literal 3386 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 Qc>n+a07*qoM6N<$g44JwLjV8( literal 0 HcmV?d00001 diff --git a/voidcrew/modules/hyperviolence/blood/icons/blood_spray.dmi b/voidcrew/modules/hyperviolence/blood/icons/blood_spray.dmi new file mode 100644 index 0000000000000000000000000000000000000000..39f4fa6b592c9c86abc567b3c01182364be9d5ad GIT binary patch literal 5118 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 DTSx2+ literal 0 HcmV?d00001 From dcae21ab6d754f80e853e9e821604188d6c87115 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:24:32 -0800 Subject: [PATCH 010/472] Combine hit lines into one message and give organic NPCs blood Wound, dismember and severed-limb lines no longer print as separate chat lines. The attack line is held until the damage is done, then sent with the bold lines queued by hv_add_hit_line() appended, e.g. "X slashes Y in the neck! An artery is torn! The arm is severed!". Each reader keeps their own wording (the victim's "Your arm snaps!") and tg's message ranges: a line that carries further than the attack line also goes, once, to the viewers beyond it. Lines with no attack line behind them are sent on the next tick, merged per mob. Melee, projectile, thrown, unarmed and animal attacks hold their line; a death, a knockdown effect or the mob being deleted sends it first. Organic basic and simple mobs now start with blood (machines, bones, plants, slimes and bosses do not), so shots and blows splatter them and the floor. Blood is only ever read on basic mobs, nothing bleeds them out. They also show light, medium and heavy blood as health falls to 75, 50 and 25 percent, tinted to their blood colour and cut to their own shape, and lose it when healed. Art credit: voidcrew/modules/hyperviolence/hitlines/icons/npc_blood.dmi is adapted from Aurorastation/Aurora.3 icons/mob/npc/blood_overlay.dmi at commit 82a514d97b07 (CC BY-SA 3.0), recoloured white. The wound-text-on- the-attack-line idea is from Septic Shock; the code is new. --- code/_onclick/item_attack.dm | 8 +- code/datums/wounds/_wounds.dm | 12 +- code/datums/wounds/loss.dm | 7 +- .../mob/living/carbon/carbon_defense.dm | 9 +- .../mob/living/carbon/human/_species.dm | 8 +- code/modules/mob/living/living_defense.dm | 26 +- .../surgery/bodyparts/dismemberment.dm | 5 +- tgstation.dme | 4 +- voidcrew/modules/hyperviolence/_core.dm | 13 +- .../hyperviolence/hitlines/hit_lines.dm | 240 ++++++++++++++++++ .../hitlines/icons/npc_blood.dmi | Bin 0 -> 1873 bytes .../hyperviolence/hitlines/npc_blood.dm | 158 ++++++++++++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../voidcrew_hyperviolence_hitlines.dm | 220 ++++++++++++++++ 14 files changed, 666 insertions(+), 45 deletions(-) create mode 100644 voidcrew/modules/hyperviolence/hitlines/hit_lines.dm create mode 100644 voidcrew/modules/hyperviolence/hitlines/icons/npc_blood.dmi create mode 100644 voidcrew/modules/hyperviolence/hitlines/npc_blood.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_hyperviolence_hitlines.dm diff --git a/code/_onclick/item_attack.dm b/code/_onclick/item_attack.dm index a81068c6572..6536eb1328e 100644 --- a/code/_onclick/item_attack.dm +++ b/code/_onclick/item_attack.dm @@ -498,11 +498,13 @@ attack_message_victim = "[user] [message_verb_continuous] you[message_hit_area] with [weapon]!" if(user == src) attack_message_victim = "You [message_verb_simple] yourself[message_hit_area] with [weapon]." - visible_message(span_danger("[attack_message_spectator]"),\ - span_userdanger("[attack_message_victim]"), null, COMBAT_MESSAGE_RANGE, user) + // VOIDCREW EDIT CHANGE START: hold the attack line so the wound lines of this hit join it (voidcrew/modules/hyperviolence/hitlines) + // original: visible_message(span_danger(spectator line), span_userdanger(victim line), null, COMBAT_MESSAGE_RANGE, user) + // original: to_chat(user, span_danger(attacker line)) + hv_hold_attack_message(attack_message_spectator, attack_message_victim, attack_message_attacker, user) if(is_blind()) to_chat(src, span_danger("Someone hits you[message_hit_area]!")) - to_chat(user, span_danger("[attack_message_attacker]")) + // VOIDCREW EDIT CHANGE END return 1 /// Overridable proc so subtypes can have unique targetted strike zone messages, return a string. diff --git a/code/datums/wounds/_wounds.dm b/code/datums/wounds/_wounds.dm index 2985a817184..92cddaf9c94 100644 --- a/code/datums/wounds/_wounds.dm +++ b/code/datums/wounds/_wounds.dm @@ -217,14 +217,10 @@ return if(!silent && !demoted) - var/msg = span_danger("[victim]'s [limb.plaintext_zone] [occur_text]!") - var/vis_dist = COMBAT_MESSAGE_RANGE - - if(severity > WOUND_SEVERITY_SEVERE) - msg = "[msg]" - vis_dist = DEFAULT_MESSAGE_RANGE - - victim.visible_message(msg, span_userdanger("Your [limb.plaintext_zone] [occur_text]!"), vision_distance = vis_dist) + // VOIDCREW EDIT CHANGE START: the wound line joins the message of the hit that caused it (voidcrew/modules/hyperviolence/hitlines) + // original: victim.visible_message(msg, span_userdanger("Your [limb.plaintext_zone] [occur_text]!"), vision_distance = vis_dist) with msg bold and vis_dist DEFAULT_MESSAGE_RANGE above a severe wound, COMBAT_MESSAGE_RANGE otherwise + victim.hv_add_hit_line("[victim]'s [limb.plaintext_zone] [occur_text]!", HV_LINE_BOLD, "Your [limb.plaintext_zone] [occur_text]!", severity > WOUND_SEVERITY_SEVERE ? DEFAULT_MESSAGE_RANGE : COMBAT_MESSAGE_RANGE) + // VOIDCREW EDIT CHANGE END if(sound_effect) playsound(L.owner, sound_effect, sound_volume + (20 * severity), TRUE, falloff_exponent = SOUND_FALLOFF_EXPONENT + 2, ignore_walls = FALSE, falloff_distance = 0) diff --git a/code/datums/wounds/loss.dm b/code/datums/wounds/loss.dm index 4fb5cad387f..8946543abec 100644 --- a/code/datums/wounds/loss.dm +++ b/code/datums/wounds/loss.dm @@ -40,9 +40,10 @@ else occur_text = dismembered_part.get_dismember_message(wounding_type, outright) - var/msg = span_bolddanger("[victim]'s [dismembered_part.plaintext_zone] [occur_text]") - - victim.visible_message(msg, span_userdanger("Your [dismembered_part.plaintext_zone] [self_msg ? self_msg : occur_text]")) + // VOIDCREW EDIT CHANGE START: the severed-limb line joins the message of the hit that caused it (voidcrew/modules/hyperviolence/hitlines) + // original: victim.visible_message(span_bolddanger("[victim]'s [zone] [occur_text]"), span_userdanger("Your [zone] [self_msg ? self_msg : occur_text]")) at the default message range + victim.hv_add_hit_line("[victim]'s [dismembered_part.plaintext_zone] [occur_text]", HV_LINE_HUGE, "Your [dismembered_part.plaintext_zone] [self_msg ? self_msg : occur_text]", DEFAULT_MESSAGE_RANGE) + // VOIDCREW EDIT CHANGE END loss_wounding_type = wounding_type diff --git a/code/modules/mob/living/carbon/carbon_defense.dm b/code/modules/mob/living/carbon/carbon_defense.dm index 1a3629883b2..41d330e60f6 100644 --- a/code/modules/mob/living/carbon/carbon_defense.dm +++ b/code/modules/mob/living/carbon/carbon_defense.dm @@ -135,10 +135,11 @@ attack_message_victim = "[user] [message_verb_continuous] you[message_hit_area] with [weapon][extra_wound_details]!" if(user == src) attack_message_victim = "You [message_verb_simple] yourself[message_hit_area] with [weapon][extra_wound_details]!" - visible_message(span_danger("[attack_message_spectator]"),\ - span_userdanger("[attack_message_victim]"), null, COMBAT_MESSAGE_RANGE, user) - if(user != src) - to_chat(user, span_danger("[attack_message_attacker]")) + // VOIDCREW EDIT CHANGE START: hold the attack line so the wound lines of this hit join it (voidcrew/modules/hyperviolence/hitlines) + // original: visible_message(span_danger(spectator line), span_userdanger(victim line), null, COMBAT_MESSAGE_RANGE, user) + // original: if(user != src) to_chat(user, span_danger(attacker line)) + hv_hold_attack_message(attack_message_spectator, attack_message_victim, (user != src) ? attack_message_attacker : null, user) + // VOIDCREW EDIT CHANGE END return TRUE diff --git a/code/modules/mob/living/carbon/human/_species.dm b/code/modules/mob/living/carbon/human/_species.dm index eccb8abe3de..873fc0a92f9 100644 --- a/code/modules/mob/living/carbon/human/_species.dm +++ b/code/modules/mob/living/carbon/human/_species.dm @@ -966,9 +966,11 @@ GLOBAL_LIST_EMPTY(features_by_species) atk_verb = attacking_bodypart.grappled_attack_verb atk_verb_continuous = attacking_bodypart.grappled_attack_verb_continuous - target.visible_message(span_danger("[user] [atk_verb_continuous] [target]!"), \ - span_userdanger("[user] [atk_verb_continuous] you!"), span_hear("You hear a sickening sound of flesh hitting flesh!"), COMBAT_MESSAGE_RANGE, user) - to_chat(user, span_danger("You [atk_verb] [target]!")) + // VOIDCREW EDIT CHANGE START: hold the attack line so the wound lines of this blow join it (voidcrew/modules/hyperviolence/hitlines) + // original: target.visible_message(span_danger("[user] [atk_verb_continuous] [target]!"), span_userdanger("[user] [atk_verb_continuous] you!"), span_hear("You hear a sickening sound of flesh hitting flesh!"), COMBAT_MESSAGE_RANGE, user) + // original: to_chat(user, span_danger("You [atk_verb] [target]!")) + target.hv_hold_attack_message("[user] [atk_verb_continuous] [target]!", "[user] [atk_verb_continuous] you!", "You [atk_verb] [target]!", user, "You hear a sickening sound of flesh hitting flesh!") + // VOIDCREW EDIT CHANGE END target.lastattacker = user.real_name target.lastattackerckey = user.ckey diff --git a/code/modules/mob/living/living_defense.dm b/code/modules/mob/living/living_defense.dm index 854ddf99a84..a4429fa1c58 100644 --- a/code/modules/mob/living/living_defense.dm +++ b/code/modules/mob/living/living_defense.dm @@ -110,8 +110,10 @@ if (SUPPRESSED_QUIET) to_chat(src, span_userdanger("You're shot by \a [proj][organ_hit_text]!")) if (SUPPRESSED_NONE) - visible_message(span_danger("[src] is hit by \a [proj][organ_hit_text]!"), \ - span_userdanger("You're hit by \a [proj][organ_hit_text]!"), null, COMBAT_MESSAGE_RANGE) + // VOIDCREW EDIT CHANGE START: hold the hit line so the wound lines of this shot join it (voidcrew/modules/hyperviolence/hitlines) + // original: visible_message(span_danger("[src] is hit by \a [proj][organ_hit_text]!"), span_userdanger("You're hit by \a [proj][organ_hit_text]!"), null, COMBAT_MESSAGE_RANGE) + hv_hold_attack_message("[src] is hit by \a [proj][organ_hit_text]!", "You're hit by \a [proj][organ_hit_text]!") + // VOIDCREW EDIT CHANGE END if(is_blind()) to_chat(src, span_userdanger("You feel something hit you[organ_hit_text]!")) @@ -238,8 +240,10 @@ log_hit_combat(throwingdatum?.get_thrower(), thrown_item) return ..() - visible_message(span_danger("[src] is hit by [thrown_item]!"), - span_userdanger("You're hit by [thrown_item]!")) + // VOIDCREW EDIT CHANGE START: hold the hit line so the wound lines of this throw join it (voidcrew/modules/hyperviolence/hitlines) + // original: visible_message(span_danger("[src] is hit by [thrown_item]!"), span_userdanger("You're hit by [thrown_item]!")) + hv_hold_attack_message("[src] is hit by [thrown_item]!", "You're hit by [thrown_item]!", vision_range = DEFAULT_MESSAGE_RANGE) + // VOIDCREW EDIT CHANGE END if(!thrown_item.throwforce) log_hit_combat(throwingdatum?.get_thrower(), thrown_item) return @@ -416,13 +420,11 @@ playsound(src, user.attack_sound, 50, TRUE, TRUE) user.do_attack_animation(src) - visible_message( - span_danger("[user] [user.attack_verb_continuous] [src]!"), - span_userdanger("[user] [user.attack_verb_continuous] you!"), - null, - COMBAT_MESSAGE_RANGE, - user, - ) + // VOIDCREW EDIT CHANGE START: hold the attack line so the wound lines of this blow join it (voidcrew/modules/hyperviolence/hitlines) + // original: visible_message(span_danger("[user] [user.attack_verb_continuous] [src]!"), span_userdanger("[user] [user.attack_verb_continuous] you!"), null, COMBAT_MESSAGE_RANGE, user) + // original: to_chat(user, span_danger("You [user.attack_verb_simple] [src]!")) (sent after the armor check, not on a dismemberment) + hv_hold_attack_message("[user] [user.attack_verb_continuous] [src]!", "[user] [user.attack_verb_continuous] you!", "You [user.attack_verb_simple] [src]!", user) + // VOIDCREW EDIT CHANGE END var/dam_zone = dismembering_strike(user, pick(BODY_ZONE_CHEST, BODY_ZONE_PRECISE_L_HAND, BODY_ZONE_PRECISE_R_HAND, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) if(!dam_zone) //Dismemberment successful @@ -430,7 +432,7 @@ var/armor_block = run_armor_check(user.zone_selected, MELEE, armour_penetration = user.armour_penetration) - to_chat(user, span_danger("You [user.attack_verb_simple] [src]!")) + // VOIDCREW EDIT REMOVAL: the attacker's "You [verb] [src]!" line is held with the attack line above (voidcrew/modules/hyperviolence/hitlines) var/damage_done = apply_damage( damage = damage, damagetype = user.melee_damage_type, diff --git a/code/modules/surgery/bodyparts/dismemberment.dm b/code/modules/surgery/bodyparts/dismemberment.dm index 0e25e1b981f..17012f9db74 100644 --- a/code/modules/surgery/bodyparts/dismemberment.dm +++ b/code/modules/surgery/bodyparts/dismemberment.dm @@ -15,7 +15,10 @@ var/obj/item/bodypart/affecting = limb_owner.get_bodypart(BODY_ZONE_CHEST) affecting.receive_damage(clamp(brute_dam/2 * affecting.body_damage_coeff, 15, 50), clamp(burn_dam/2 * affecting.body_damage_coeff, 0, 50), wound_bonus=CANT_WOUND) //Damage the chest based on limb's existing damage if(!silent) - limb_owner.visible_message(span_danger("[limb_owner]'s [name] is violently dismembered!")) + // VOIDCREW EDIT CHANGE START: the line joins the message of the hit that caused it (voidcrew/modules/hyperviolence/hitlines) + // original: limb_owner.visible_message(span_danger("[limb_owner]'s [name] is violently dismembered!")) + limb_owner.hv_add_hit_line("[limb_owner]'s [name] is violently dismembered!", HV_LINE_HUGE, range = DEFAULT_MESSAGE_RANGE) + // VOIDCREW EDIT CHANGE END INVOKE_ASYNC(limb_owner, TYPE_PROC_REF(/mob, emote), "scream") playsound(get_turf(limb_owner), 'sound/effects/dismember.ogg', 80, TRUE) limb_owner.add_mood_event("dismembered_[body_zone]", /datum/mood_event/dismembered, src) diff --git a/tgstation.dme b/tgstation.dme index 2f7df14adc8..7921541a824 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -33,6 +33,7 @@ #include "code\__DEFINES\_protect.dm" #include "code\__DEFINES\_tick.dm" #include "voidcrew\_DEFINES\core_integration.dm" // VOIDCREW EDIT: shared hooks must precede upstream macro use. +#include "voidcrew\_DEFINES\hyperviolence.dm" // VOIDCREW EDIT: shared hooks must precede upstream macro use (hit lines in code/datums/wounds). #include "voidcrew\_DEFINES\modular_hooks.dm" // VOIDCREW EDIT: shared hooks must precede upstream macro use. #include "code\__DEFINES\access.dm" #include "code\__DEFINES\achievements.dm" @@ -6694,7 +6695,6 @@ #include "voidcrew\_DEFINES\dynamic_events.dm" #include "voidcrew\_DEFINES\flags.dm" #include "voidcrew\_DEFINES\hud.dm" -#include "voidcrew\_DEFINES\hyperviolence.dm" #include "voidcrew\_DEFINES\job_categories.dm" #include "voidcrew\_DEFINES\keybindings.dm" #include "voidcrew\_DEFINES\lich.dm" @@ -7230,6 +7230,8 @@ #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" #include "voidcrew\modules\hyperviolence\_core.dm" +#include "voidcrew\modules\hyperviolence\hitlines\hit_lines.dm" +#include "voidcrew\modules\hyperviolence\hitlines\npc_blood.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" #include "voidcrew\modules\intents\hud_intent.dm" diff --git a/voidcrew/modules/hyperviolence/_core.dm b/voidcrew/modules/hyperviolence/_core.dm index 0fd61071f56..a51ed313e52 100644 --- a/voidcrew/modules/hyperviolence/_core.dm +++ b/voidcrew/modules/hyperviolence/_core.dm @@ -1,13 +1,6 @@ -// Hyperviolence core: the shared hook every gore part uses to add a line to a hit's message. -// The combined-hit-message part replaces the body of hv_add_hit_line with batching; until -// then each line is shown on its own so every part works standalone. - -/// Adds a line ("An artery is torn!") to the combat message of the hit being resolved on src. -/mob/living/proc/hv_add_hit_line(text, severity = HV_LINE_BOLD) - if(!text) - return - var/shown = severity >= HV_LINE_HUGE ? "[text]" : "[text]" - visible_message(span_danger(shown), vision_distance = COMBAT_MESSAGE_RANGE) +// Hyperviolence core: the shared hooks every gore part uses. +// hv_add_hit_line() (a line added to the combat message of the hit being resolved on a mob) lives +// in hitlines/hit_lines.dm, which batches the lines into the attack message. /// Sprays blood from src toward spray_dir. The blood part replaces this with directional spray /// decals that paint walls and drench bystanders; the stub uses tg's flying splatter. diff --git a/voidcrew/modules/hyperviolence/hitlines/hit_lines.dm b/voidcrew/modules/hyperviolence/hitlines/hit_lines.dm new file mode 100644 index 00000000000..ff24b8576ab --- /dev/null +++ b/voidcrew/modules/hyperviolence/hitlines/hit_lines.dm @@ -0,0 +1,240 @@ +// Hyperviolence hit lines: one hit, one chat line. +// +// tg prints the attack ("X slashes Y in the arm!") and then every wound or dismember message as +// its own line. This module holds the attack line for a moment, collects the bold lines queued by +// hv_add_hit_line() while the hit resolves, and sends them as one message: +// "X slashes Y in the neck! **An artery is torn!** ***The arm is severed!***" +// A line queued with no attack line waiting (a wound from an explosion, a fall) goes out as its +// own message on the next tick, merged with anything else queued on the same mob that tick. +// +// The idea comes from Septic Shock's COMSIG_CARBON_ADD_TO_WOUND_MESSAGE (wound text appended to the +// attack message). This is a fresh implementation, not a port. +// +// The held attack line is sent by the outermost wrapper of each attack path (attacked_by, +// bullet_act, hitby, attack_animal and the unarmed harm), so there is no hook to add in the +// upstream procs besides swapping their output for hv_hold_attack_message(). + +/// Longest queue of lines on one hit (twice that for the worst lines). Anything more is dropped so one blast cannot flood chat. +#define HV_MAX_HIT_LINES 6 + +// Indexes into one queued line, see hv_add_hit_line() +#define HV_LINE_TEXT 1 +#define HV_LINE_SELF 2 +#define HV_LINE_SEVERITY 3 +#define HV_LINE_RANGE 4 + +/// The hit being resolved on one mob: the attack line held back and the lines queued behind it. +/datum/hv_hit + /// The attack line as onlookers read it. Null when no attack line is held (lines only). + var/others_text + /// The attack line as the victim reads it. + var/victim_text + /// The attack line as the attacker reads it. Null when the attacker is not told separately. + var/attacker_text + /// What onlookers who cannot see the hit are told. + var/blind_text + /// Who dealt the hit. Cleared when the hit is sent. + var/mob/attacker + /// How far the attack line carries. + var/vision_range = COMBAT_MESSAGE_RANGE + /// Queued lines, each list(text, self_text, severity, range) + var/list/lines = list() + + // What compose() works out + /// How far the first message carries + var/near_range + /// What onlookers within near_range read + var/others_message + /// What the victim reads + var/victim_message + /// What the attacker reads, when told separately + var/attacker_message + /// Lines that carry beyond near_range, for the viewers further out. Null if none do. + var/far_message + /// How far far_message carries + var/far_range + +/// Works out what each reader gets: the held attack line, if any, with the queued lines after it. +/// With no attack line held, the lines are the message and the nearest one sets how far it carries. +/// Returns FALSE when there is nothing to say. +/datum/hv_hit/proc/compose() + near_range = vision_range + if(!others_text) + near_range = INFINITY + for(var/list/line as anything in lines) + near_range = min(near_range, line[HV_LINE_RANGE]) + if(near_range == INFINITY) + return FALSE + + var/others_lines = "" + var/victim_lines = "" + var/far_lines = "" + far_range = near_range + for(var/list/line as anything in lines) + var/severity = line[HV_LINE_SEVERITY] + var/others_line = " [hv_format_hit_line(line[HV_LINE_TEXT], severity)]" + others_lines += others_line + victim_lines += " [hv_format_hit_line(line[HV_LINE_SELF], severity)]" + if(line[HV_LINE_RANGE] > near_range) + far_lines += others_line + far_range = max(far_range, line[HV_LINE_RANGE]) + + if(others_text) + others_message = span_danger("[others_text][others_lines]") + victim_message = span_userdanger("[victim_text][victim_lines]") + if(attacker_text) + attacker_message = span_danger("[attacker_text][others_lines]") + else + others_message = span_danger(copytext(others_lines, 2)) + victim_message = span_userdanger(copytext(victim_lines, 2)) + if(far_lines) + far_message = span_danger(copytext(far_lines, 2)) + return TRUE + +/mob/living + /// The hit being resolved on this mob (voidcrew/modules/hyperviolence/hitlines). Null between hits. + var/datum/hv_hit/hv_hit + +/// Gets the hit being resolved on this mob, starting one if none is. A hit nobody sends is sent on the next tick. +/mob/living/proc/hv_get_hit() + if(!hv_hit) + hv_hit = new + addtimer(CALLBACK(src, PROC_REF(hv_send_stale_hit), hv_hit), 0) + return hv_hit + +/// Sends a hit that nothing sent in the tick it began. A hit sent already (or replaced) is left alone. +/mob/living/proc/hv_send_stale_hit(datum/hv_hit/stale) + if(hv_hit == stale) + hv_flush_hit() + +/** + * Adds a line to the combat message of the hit being resolved on src. + * + * Arguments: + * * text - what bystanders and the attacker read, "The bone in Y's arm snaps!" + * * severity - HV_LINE_BOLD, or HV_LINE_HUGE for a severed limb or a burst head + * * self_text - what src reads in place of text, "Your arm snaps!". Null reads text + * * range - how far the line carries. A line that carries further than the attack line is also + * sent on its own to the extra viewers, so tg's message ranges hold + */ +/mob/living/proc/hv_add_hit_line(text, severity = HV_LINE_BOLD, self_text, range = COMBAT_MESSAGE_RANGE) + if(!text || QDELETED(src)) + return + var/datum/hv_hit/hit = hv_get_hit() + if(length(hit.lines) >= (severity >= HV_LINE_HUGE ? HV_MAX_HIT_LINES * 2 : HV_MAX_HIT_LINES)) + return + hit.lines += list(list(text, self_text || text, severity, range)) + +/** + * Holds an attack line so the wound lines queued behind it join it. The attack path's wrapper sends it + * with hv_flush_hit() once the damage is done. Texts are plain; the danger styling is added when sent. + * + * Arguments: + * * others_text - the line for onlookers + * * victim_text - the line src reads + * * attacker_text - the line the attacker reads. Null if the attacker gets none + * * attacker - who dealt the hit. Skipped when the onlookers are told + * * blind_text - what onlookers who cannot see are told + * * vision_range - how far the line carries + */ +/mob/living/proc/hv_hold_attack_message(others_text, victim_text, attacker_text, mob/attacker, blind_text, vision_range = COMBAT_MESSAGE_RANGE) + if(hv_hit) + // A new hit begins: whatever was still waiting is a different event + hv_flush_hit() + var/datum/hv_hit/hit + if(QDELETED(src)) + // Nothing will send a hit held on a deleted mob, so the line goes out as it is + hit = new + else + hit = hv_get_hit() + hit.others_text = others_text + hit.victim_text = victim_text + hit.attacker_text = attacker_text + hit.blind_text = blind_text + hit.attacker = attacker + hit.vision_range = vision_range + if(QDELETED(src)) + hv_send_hit(hit) + +/// One line as chat markup: bold, or bold italic for the worst of it. It sits inside the danger span of the message. +/proc/hv_format_hit_line(text, severity) + if(severity >= HV_LINE_HUGE) + return "[text]" + return "[text]" + +/// Sends the held attack line and the lines queued behind it, then ends the hit. Safe to call with no hit. +/mob/living/proc/hv_flush_hit() + var/datum/hv_hit/hit = hv_hit + if(!hit) + return + hv_hit = null + hv_send_hit(hit) + +/// Says a hit to everyone who should read it. +/mob/living/proc/hv_send_hit(datum/hv_hit/hit) + var/mob/attacker = hit.attacker + hit.attacker = null + if(QDELETED(attacker)) + attacker = null + if(!hit.compose()) + return + + visible_message( + hit.others_message, + hit.victim_message, + hit.blind_text ? span_hear(hit.blind_text) : null, + hit.near_range, + attacker, + ) + if(attacker && hit.attacker_message) + to_chat(attacker, hit.attacker_message) + + // A line that carries further than the message above also reaches the viewers beyond it, once. + if(hit.far_message) + var/list/already_told = get_hearers_in_view(hit.near_range, src) + already_told += src + if(attacker && hit.attacker_message) + already_told += attacker + visible_message(hit.far_message, vision_distance = hit.far_range, ignored_mobs = already_told) + +/// A mob about to be deleted still says what was done to it. +/mob/living/Destroy() + hv_flush_hit() + return ..() + +/// What follows a blow (a knockdown, a spray) reads after the hit, and so does a death. +/mob/living/attack_effects(damage_done, hit_zone, armor_block, obj/item/attacking_item, mob/living/attacker) + hv_flush_hit() + return ..() + +/mob/living/death(gibbed) + hv_flush_hit() + return ..() + +// Each attack path ends in a wrapper that sends the held line. A voidcrew definition of a proc runs +// outside the upstream one, so these run after the whole hit is resolved. + +/mob/living/attacked_by(obj/item/attacking_item, mob/living/user, list/modifiers, list/attack_modifiers) + . = ..() + hv_flush_hit() + +/mob/living/bullet_act(obj/projectile/proj, def_zone, piercing_hit = FALSE, blocked = 0) + . = ..() + hv_flush_hit() + +/mob/living/hitby(atom/movable/AM, skipcatch, hitpush = TRUE, blocked = FALSE, datum/thrownthing/throwingdatum) + . = ..() + hv_flush_hit() + +/mob/living/attack_animal(mob/living/simple_animal/user, list/modifiers) + . = ..() + hv_flush_hit() + +/datum/species/harm(mob/living/carbon/human/user, mob/living/carbon/human/target, datum/martial_art/attacker_style) + . = ..() + target?.hv_flush_hit() + +#undef HV_LINE_RANGE +#undef HV_LINE_SEVERITY +#undef HV_LINE_SELF +#undef HV_LINE_TEXT diff --git a/voidcrew/modules/hyperviolence/hitlines/icons/npc_blood.dmi b/voidcrew/modules/hyperviolence/hitlines/icons/npc_blood.dmi new file mode 100644 index 0000000000000000000000000000000000000000..00bec0513d00a5e3029beb41acb6c2c2d015581d GIT binary patch literal 1873 zcmV-X2d?;uP)V=-0C=2JR&a84_w-Y6@%7{?OD!tS%+FJ>RWQ*r;NmRLOex6#a*U0*I5Sc+ z(=$pSoZ^zil2jm5DF-B`#KoDCSyT+t#KoCbl$e_eVjCh<<))@&mgW*vmXVrRR*6fM zvVyCh3)qbS$aF9Ul8f1E000J*Nklkp`Eyak%p$FpZHbOv~c%ID))(zocKO zH-L;UEXS;Qk)47`$ld8}gGio1*&-$l<*}QxRHVf&@friy(Bx_e$%Oy=pTu(YHq^X# zIVBLJ>lx_@abpeBex)u=q$=m0gO*7g`TVtQ7BlE!tFZ3RF^CLm?^m`$oBpJgn1Yno_CHo{PaY^)4WW+hN+7M>5~tDF1D!1Psu zrE4by;hlIv*tRLm*tIYPwR6!0DO;6hRW41dvWrtQpPA{_pMza9G{jHB(N8<=w4;Jg zL$p4cFJDfIosjEI8`fS}iu~ z3!%Lz`MXA;B2>LqwPqU1h@=e%+n34@V4V=j@2L{)%ES_=K1wWp1e{!($Cm<0(Z(V2 z-78dLr2{U!vuLPnUZ#fhz%{7+{h7gs8mL#lt1)xD39ZMWk(tH289i^*-$hyM8I)_9 z7v=0)Nx$D-1m*sD9Cf45H5+2&a+3XMaPb)#u%{}sAZa}@kJ(&>-1+P)l|iXO?#i^N zb!r8p!@kG~vy4T0Tv(3eORJ92lTWR7#5f|{GSu#UMiwQ;9kuhxF?p4BBT=gow5->A zX<`*omi>(Bgc|Q<6$)hxp?Bvj6wI;M5qSVy&r)t!1vgYfRMCQ|^UIaId9jBe!n)R)L^Q^q z6Z}C|2y?QBy;>+XD-=CbvD#-<3mGrJQzaQwAsLm)Gw4ZBxr_tsl8*IEM^{ElyQiTm z05Wz;w`y5HN>^rOLb57A(xgTfm*5Er32UBp0n*w43O8>toTSE>BqA7hk zUl%8Yxyp@?zNa1d3i&Gui;e`KoqWU&q@Mqg88Unf4IV`nW>hRpi82zV46 z6nnr^;+#onfNNMe6KVmZpx>MGe{YnJmK{Vq9_gfNgrFTTrL8O=OHGwcW%rTA4^ROY zpNZi|@&i1-XT?WQH5pvvc*zfdxWdc|YLMaqJa!s_AK*zJC*3TR`^Z^@S`SDK@Qff} zlAf{NSIA`Y186VkWFdn!13GY=#VJWma&c?i+PCLInFT+z$RzFp zf1cNr8s^|3DeWq)AYOg9R{~{L0Hs>VtgOJ!SMmc$ZVp@Jw^tK};s>yhSI?efAhIyU%Nsym1|=y>NxNP?g00Itu+m;|&3tR8hViu1PMftq8SIiaVLSN|00000 LNkvXXu0mjfRk3oZ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/hyperviolence/hitlines/npc_blood.dm b/voidcrew/modules/hyperviolence/hitlines/npc_blood.dm new file mode 100644 index 00000000000..0915602e2de --- /dev/null +++ b/voidcrew/modules/hyperviolence/hitlines/npc_blood.dm @@ -0,0 +1,158 @@ +// Hyperviolence: NPCs that bleed. +// +// Basic and simple mobs start with blood_volume 0, so a shot or a blow on them leaves nothing on the +// floor and nothing on the attacker. Organic NPCs are given blood on spawn, and an overlay that +// gets bloodier as their health falls. +// +// Blood cannot hurt them: only wounds, dismemberment and a few items on carbon mobs ever call +// bleed(), and handle_blood() (oxygen loss from thin blood) is human-only, so a basic mob's blood +// is only ever read, to colour decals and splatter. +// +// The overlay art, icons/npc_blood.dmi, is adapted from Aurora.3 icons/mob/npc/blood_overlay.dmi +// (Aurorastation/Aurora.3 commit 82a514d97b07, CC BY-SA 3.0): the three states are recoloured white +// so the mob's own blood colour fills them. The overlay idea follows Aurora's simple_animal +// handle_blood(); this code is new. + +/// Overlay stages, by how much of its health the mob has left +#define HV_NPC_BLOOD_NONE 0 +#define HV_NPC_BLOOD_LIGHT 1 +#define HV_NPC_BLOOD_MEDIUM 2 +#define HV_NPC_BLOOD_HEAVY 3 + +/// At or below this share of its health a mob shows light blood +#define HV_NPC_BLOOD_LIGHT_AT 0.75 +/// At or below this share of its health a mob shows medium blood +#define HV_NPC_BLOOD_MEDIUM_AT 0.5 +/// At or below this share of its health a mob shows heavy blood +#define HV_NPC_BLOOD_HEAVY_AT 0.25 + +/// Source of the keep-together flag the inset blood needs +#define HV_NPC_BLOOD_SOURCE "hv_npc_blood" + +/mob/living + /// Which blood overlay stage this NPC shows right now (HV_NPC_BLOOD_*). Only moved by /datum/element/hv_npc_blood. + var/hv_blood_stage = HV_NPC_BLOOD_NONE + +/// Gives an organic basic or simple mob blood and the overlay that shows its wounds. +/mob/living/proc/hv_setup_npc_blood() + if(QDELETED(src) || HAS_TRAIT(src, TRAIT_NOBLOOD)) + return + if(!blood_volume) + if(!hv_npc_default_bleeder()) + return + blood_volume = BLOOD_VOLUME_NORMAL + else if(!hv_npc_shows_blood()) + return + AddElement(/datum/element/hv_npc_blood) + +/// True for an organic body with blood to show: no machine, spirit, plant, slime, rock or undead thing. +/mob/living/proc/hv_npc_shows_blood() + if(!(mob_biotypes & MOB_ORGANIC)) + return FALSE + return !(mob_biotypes & (MOB_ROBOTIC|MOB_UNDEAD|MOB_SPIRIT|MOB_MINERAL|MOB_PLANT|MOB_SLIME)) + +/// True for a mob that should bleed without having been given blood by hand. Bosses and other special +/// things keep what they have, and a mob only counts as a creature if it says what kind it is +/// or is one of the few plain organic animals and people listed here. +/mob/living/proc/hv_npc_default_bleeder() + if(!hv_npc_shows_blood() || (mob_biotypes & MOB_SPECIAL)) + return FALSE + if(mob_biotypes & (MOB_HUMANOID|MOB_BEAST|MOB_BUG|MOB_REPTILE|MOB_AQUATIC|MOB_CRUSTACEAN)) + return TRUE + var/static/list/plain_bleeders = typecacheof(list( + /mob/living/basic/parrot, + /mob/living/basic/regal_rat, + /mob/living/basic/snail, + /mob/living/basic/stoat, + /mob/living/simple_animal/hostile/asteroid/hermit, + )) + return is_type_in_typecache(src, plain_bleeders) + +/mob/living/basic/Initialize(mapload) + . = ..() + hv_setup_npc_blood() + +/mob/living/simple_animal/Initialize(mapload) + . = ..() + hv_setup_npc_blood() + +/// Overlays a mob with blood as its health falls: light, medium, then heavy. Gone when it is healed. +/datum/element/hv_npc_blood + +/datum/element/hv_npc_blood/Attach(datum/target) + . = ..() + if(!isliving(target)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_LIVING_HEALTH_UPDATE, PROC_REF(on_health_update)) + RegisterSignal(target, COMSIG_ATOM_UPDATE_OVERLAYS, PROC_REF(on_update_overlays)) + on_health_update(target) + +/datum/element/hv_npc_blood/Detach(mob/living/host) + UnregisterSignal(host, list(COMSIG_LIVING_HEALTH_UPDATE, COMSIG_ATOM_UPDATE_OVERLAYS)) + if(host.hv_blood_stage != HV_NPC_BLOOD_NONE) + host.hv_blood_stage = HV_NPC_BLOOD_NONE + REMOVE_KEEP_TOGETHER(host, HV_NPC_BLOOD_SOURCE) + if(!QDELETED(host)) + host.update_appearance(UPDATE_OVERLAYS) + return ..() + +/// How bloody a mob with this much health left should look +/datum/element/hv_npc_blood/proc/stage_for(mob/living/source) + if(source.maxHealth <= 0) + return HV_NPC_BLOOD_NONE + var/health_share = source.health / source.maxHealth + if(health_share <= HV_NPC_BLOOD_HEAVY_AT) + return HV_NPC_BLOOD_HEAVY + if(health_share <= HV_NPC_BLOOD_MEDIUM_AT) + return HV_NPC_BLOOD_MEDIUM + if(health_share <= HV_NPC_BLOOD_LIGHT_AT) + return HV_NPC_BLOOD_LIGHT + return HV_NPC_BLOOD_NONE + +/datum/element/hv_npc_blood/proc/on_health_update(mob/living/host) + SIGNAL_HANDLER + + var/stage = stage_for(host) + if(stage == host.hv_blood_stage) + return + // The blood is cut to the mob's own shape, which only works when the mob is drawn as one group + if(host.hv_blood_stage == HV_NPC_BLOOD_NONE) + ADD_KEEP_TOGETHER(host, HV_NPC_BLOOD_SOURCE) + else if(stage == HV_NPC_BLOOD_NONE) + REMOVE_KEEP_TOGETHER(host, HV_NPC_BLOOD_SOURCE) + host.hv_blood_stage = stage + host.update_appearance(UPDATE_OVERLAYS) + +/datum/element/hv_npc_blood/proc/on_update_overlays(mob/living/source, list/overlays) + SIGNAL_HANDLER + + if(source.hv_blood_stage == HV_NPC_BLOOD_NONE) + return + var/blood_color = source.get_bloodtype()?.get_color() + if(isnull(blood_color)) + return + var/state + switch(source.hv_blood_stage) + if(HV_NPC_BLOOD_LIGHT) + state = "light" + if(HV_NPC_BLOOD_MEDIUM) + state = "medium" + else + state = "heavy" + var/mutable_appearance/blood = mutable_appearance('voidcrew/modules/hyperviolence/hitlines/icons/npc_blood.dmi', state, appearance_flags = RESET_COLOR) + blood.blend_mode = BLEND_INSET_OVERLAY + blood.color = blood_color + // The art is one tile: stretch it over a sprite that is bigger than that + var/width = source.get_cached_width() + var/height = source.get_cached_height() + if(width > ICON_SIZE_X || height > ICON_SIZE_Y) + var/matrix/stretch = matrix() + stretch.Scale(max(width, ICON_SIZE_X) / ICON_SIZE_X, max(height, ICON_SIZE_Y) / ICON_SIZE_Y) + stretch.Translate((max(width, ICON_SIZE_X) - ICON_SIZE_X) / 2, (max(height, ICON_SIZE_Y) - ICON_SIZE_Y) / 2) + blood.transform = stretch + overlays += blood + +#undef HV_NPC_BLOOD_SOURCE +#undef HV_NPC_BLOOD_HEAVY_AT +#undef HV_NPC_BLOOD_MEDIUM_AT +#undef HV_NPC_BLOOD_LIGHT_AT diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..bc16b5e014d 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -90,6 +90,7 @@ #include "voidcrew_helpers.dm" #include "voidcrew_hull_containment.dm" #include "voidcrew_hull_survey.dm" +#include "voidcrew_hyperviolence_hitlines.dm" #include "voidcrew_launch_access.dm" #include "voidcrew_launch_cargo.dm" #include "voidcrew_launch_fabrication.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_hitlines.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_hitlines.dm new file mode 100644 index 00000000000..7c541951751 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_hitlines.dm @@ -0,0 +1,220 @@ +/** + * Hyperviolence hit lines and bleeding NPCs: tests for voidcrew/modules/hyperviolence/hitlines/. + * + * Tests are included before the module files, so the module's own defines are written out here with + * a comment naming them: HV_NPC_BLOOD_NONE 0, HV_NPC_BLOOD_LIGHT 1, HV_NPC_BLOOD_MEDIUM 2 and + * HV_NPC_BLOOD_HEAVY 3; the queued-line indexes are 1 text, 2 self text, 3 severity and 4 range. + * No test has a client, so a message is checked as the text compose() builds, not as a chat line. + */ + +/// One hit: the attack line, then the bold lines after it, each reader getting their own copy. +/datum/unit_test/hv_hitlines_batch/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + victim.hv_hold_attack_message("Alice slashes Bob in the arm!", "Alice slashes you in the arm!", "You slash Bob in the arm!", attacker) + victim.hv_add_hit_line("The bone in Bob's arm snaps!", HV_LINE_BOLD, "The bone in your arm snaps!") + victim.hv_add_hit_line("Bob's arm is severed!", HV_LINE_HUGE) + var/datum/hv_hit/hit = victim.hv_hit + TEST_ASSERT_NOTNULL(hit, "Holding an attack line started no hit") + TEST_ASSERT(hit.compose(), "A hit with an attack line and two lines had nothing to say") + TEST_ASSERT_EQUAL(hit.near_range, COMBAT_MESSAGE_RANGE, "The attack line did not keep its own range") + + var/attack_at = findtext(hit.others_message, "Alice slashes Bob in the arm!") + var/snap_at = findtext(hit.others_message, "The bone in Bob's arm snaps!") + var/sever_at = findtext(hit.others_message, "Bob's arm is severed!") + TEST_ASSERT(attack_at && snap_at && sever_at, "Onlookers did not read the attack and both lines: [hit.others_message]") + TEST_ASSERT(attack_at < snap_at && snap_at < sever_at, "The lines did not follow the attack in the order they were queued: [hit.others_message]") + + TEST_ASSERT(findtext(hit.victim_message, "Alice slashes you in the arm!"), "The victim lost their own attack line: [hit.victim_message]") + TEST_ASSERT(findtext(hit.victim_message, "The bone in your arm snaps!"), "The victim lost their own wound line: [hit.victim_message]") + TEST_ASSERT(!findtext(hit.victim_message, "The bone in Bob's arm"), "The victim read the bystander line: [hit.victim_message]") + TEST_ASSERT(findtext(hit.victim_message, "Bob's arm is severed!"), "A line with no self text did not fall back to its text: [hit.victim_message]") + TEST_ASSERT(findtext(hit.attacker_message, "You slash Bob in the arm!") && findtext(hit.attacker_message, "Bob's arm is severed!"), "The attacker did not read their line with the wounds: [hit.attacker_message]") + TEST_ASSERT_NULL(hit.far_message, "Lines at the attack's range were sent a second time") + + victim.hv_flush_hit() + TEST_ASSERT_NULL(victim.hv_hit, "Sending the hit did not end it") + +/// A line that carries further than the attack line still reaches the viewers beyond it, and only them. +/datum/unit_test/hv_hitlines_ranges/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + victim.hv_hold_attack_message("Alice hits Bob!", "Alice hits you!") + victim.hv_add_hit_line("Bob's arm snaps!") + victim.hv_add_hit_line("Bob's head caves in!", HV_LINE_BOLD, null, DEFAULT_MESSAGE_RANGE) + var/datum/hv_hit/hit = victim.hv_hit + TEST_ASSERT(hit.compose(), "The hit had nothing to say") + TEST_ASSERT_EQUAL(hit.near_range, COMBAT_MESSAGE_RANGE, "The attack line's range changed") + TEST_ASSERT_EQUAL(hit.far_range, DEFAULT_MESSAGE_RANGE, "The wide line's range was lost") + TEST_ASSERT(findtext(hit.others_message, "arm snaps") && findtext(hit.others_message, "head caves in"), "Close viewers did not read every line: [hit.others_message]") + TEST_ASSERT(findtext(hit.far_message, "head caves in") && !findtext(hit.far_message, "arm snaps") && !findtext(hit.far_message, "Alice hits Bob"), "The far viewers read more than the wide line: [hit.far_message]") + victim.hv_flush_hit() + + // With no attack line held, the nearest line sets the range and the wide one is the extra + victim.hv_add_hit_line("Bob's leg snaps!") + victim.hv_add_hit_line("Bob's head caves in!", HV_LINE_BOLD, null, DEFAULT_MESSAGE_RANGE) + hit = victim.hv_hit + TEST_ASSERT(hit.compose(), "Standalone lines had nothing to say") + TEST_ASSERT_EQUAL(hit.near_range, COMBAT_MESSAGE_RANGE, "Standalone lines did not take the nearest line's range") + TEST_ASSERT(!findtext(hit.others_message, "Alice"), "Standalone lines carried an attack line") + TEST_ASSERT(findtext(hit.far_message, "head caves in"), "The wide standalone line was not sent beyond the close ones") + victim.hv_flush_hit() + + // A wide line alone is one message at its own range + victim.hv_add_hit_line("Bob's head caves in!", HV_LINE_BOLD, null, DEFAULT_MESSAGE_RANGE) + hit = victim.hv_hit + TEST_ASSERT(hit.compose(), "A lone wide line had nothing to say") + TEST_ASSERT_EQUAL(hit.near_range, DEFAULT_MESSAGE_RANGE, "A lone wide line lost its range") + TEST_ASSERT_NULL(hit.far_message, "A lone wide line was sent twice") + victim.hv_flush_hit() + +/// Lines queued with no attack line behind them go out on the next tick on their own. +/datum/unit_test/hv_hitlines_standalone/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + victim.hv_add_hit_line("Bob's arm snaps!") + victim.hv_add_hit_line("Bob's leg snaps!") + TEST_ASSERT_NOTNULL(victim.hv_hit, "A line queued with no hit started none") + TEST_ASSERT_EQUAL(length(victim.hv_hit.lines), 2, "Two lines on one tick were not merged") + TEST_ASSERT_NULL(victim.hv_hit.others_text, "Standalone lines held an attack line") + sleep(1 SECONDS) + TEST_ASSERT_NULL(victim.hv_hit, "Nothing sent lines that were never joined to an attack") + +/// A new attack line sends what was waiting instead of mixing the two hits, and one hit's queue is capped. +/datum/unit_test/hv_hitlines_queue/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + victim.hv_hold_attack_message("first hit!", "first hit!") + victim.hv_add_hit_line("first wound!") + victim.hv_hold_attack_message("second hit!", "second hit!") + TEST_ASSERT_EQUAL(victim.hv_hit.others_text, "second hit!", "The second hit did not replace the first") + TEST_ASSERT_EQUAL(length(victim.hv_hit.lines), 0, "The first hit's wound followed the second hit") + victim.hv_flush_hit() + + for(var/i in 1 to 20) + victim.hv_add_hit_line("wound [i]!") + TEST_ASSERT_EQUAL(length(victim.hv_hit.lines), 6, "One hit queued more than six plain lines") // HV_MAX_HIT_LINES + victim.hv_add_hit_line("a limb comes off!", HV_LINE_HUGE) + TEST_ASSERT_EQUAL(length(victim.hv_hit.lines), 7, "A full queue turned away the worst line") + victim.hv_flush_hit() + victim.hv_flush_hit() // nothing waiting is fine + TEST_ASSERT_NULL(victim.hv_hit, "Sending an empty hit started one") + +/// A mob deleted mid-hit still says what was done to it. +/datum/unit_test/hv_hitlines_destroy/Run() + var/mob/living/carbon/human/doomed = new /mob/living/carbon/human/consistent(run_loc_floor_bottom_left) + doomed.hv_hold_attack_message("Alice hits Bob!", "Alice hits you!") + doomed.hv_add_hit_line("Bob is torn apart!", HV_LINE_HUGE) + TEST_ASSERT_NOTNULL(doomed.hv_hit, "The doomed mob had no hit to send") + qdel(doomed) + TEST_ASSERT_NULL(doomed.hv_hit, "Deleting a mob left its hit unsent") + +/// The wound and dismember messages of tg queue as lines instead of speaking for themselves. +/datum/unit_test/hv_hitlines_wounds/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_L_ARM) + var/datum/wound/slash/flesh/moderate/cut = new + cut.apply_wound(arm) + TEST_ASSERT_NOTNULL(victim.hv_hit, "A wound spoke for itself instead of queueing a line") + TEST_ASSERT_EQUAL(length(victim.hv_hit.lines), 1, "A wound queued [length(victim.hv_hit.lines)] lines") + var/list/wound_line = victim.hv_hit.lines[1] + TEST_ASSERT(findtext(wound_line[1], "[victim]'s [arm.plaintext_zone]"), "The wound line does not name the limb: [wound_line[1]]") // text + TEST_ASSERT(findtext(wound_line[2], "Your [arm.plaintext_zone]"), "The victim lost their own wound line: [wound_line[2]]") // self text + TEST_ASSERT_EQUAL(wound_line[3], HV_LINE_BOLD, "A moderate wound was not a plain bold line") // severity + TEST_ASSERT_EQUAL(wound_line[4], COMBAT_MESSAGE_RANGE, "A moderate wound carried further than tg's close range") // range + victim.hv_flush_hit() + + var/obj/item/bodypart/other_arm = victim.get_bodypart(BODY_ZONE_R_ARM) + var/datum/wound/loss/severed = new + severed.apply_dismember(other_arm, WOUND_SLASH) + TEST_ASSERT_NOTNULL(victim.hv_hit, "A severed limb spoke for itself instead of queueing a line") + var/list/sever_line = victim.hv_hit.lines[length(victim.hv_hit.lines)] + TEST_ASSERT_EQUAL(sever_line[3], HV_LINE_HUGE, "A severed limb was not the biggest line") // severity + TEST_ASSERT_EQUAL(sever_line[4], DEFAULT_MESSAGE_RANGE, "A severed limb lost tg's wide range") // range + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_R_ARM), "The arm was not severed") + victim.hv_flush_hit() + +/// A melee hit holds its attack line while the damage lands, then sends it. +/datum/unit_test/hv_hitlines_melee/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/item/knife/blade = allocate(/obj/item/knife) + held_while_hitting = null + RegisterSignal(blade, COMSIG_ITEM_ATTACK_ZONE, PROC_REF(check_held)) + victim.attacked_by(blade, attacker) + TEST_ASSERT(held_while_hitting, "The attack line was not held back while the blow landed") + TEST_ASSERT_NULL(victim.hv_hit, "The attack line was never sent") + +/// Whether the victim of the knife was holding an attack line at the moment the damage was dealt +/datum/unit_test/hv_hitlines_melee + var/held_while_hitting + +/datum/unit_test/hv_hitlines_melee/proc/check_held(datum/source, mob/living/target, mob/living/user, zone) + SIGNAL_HANDLER + held_while_hitting = !isnull(target.hv_hit?.others_text) + +/// Organic NPCs are given blood; machines, bones and plants are not. +/datum/unit_test/hv_npc_blood_grants/Run() + var/turf/spot = run_loc_floor_bottom_left + var/mob/living/basic/trooper/pirate = allocate(/mob/living/basic/trooper, spot) + TEST_ASSERT_EQUAL(pirate.blood_volume, BLOOD_VOLUME_NORMAL, "A human-shaped trooper has no blood") + TEST_ASSERT_NOTNULL(pirate.get_bloodtype(), "The trooper's blood has no type") + var/mob/living/basic/cockroach/roach = allocate(/mob/living/basic/cockroach, spot) + TEST_ASSERT(roach.blood_volume > 0, "An organic animal has no blood") + var/mob/living/basic/skeleton/bones = allocate(/mob/living/basic/skeleton, spot) + TEST_ASSERT_EQUAL(bones.blood_volume, 0, "A skeleton bleeds") + var/mob/living/basic/hivebot/machine = allocate(/mob/living/basic/hivebot, spot) + TEST_ASSERT_EQUAL(machine.blood_volume, 0, "A robot bleeds") + var/mob/living/basic/mushroom/plant = allocate(/mob/living/basic/mushroom, spot) + TEST_ASSERT_EQUAL(plant.blood_volume, 0, "A plant bleeds") + + // Blood leaves marks where it is spilled, and nothing else does + pirate.add_splatter_floor(spot) + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood) in spot, "A bleeding trooper left nothing on the floor") + for(var/obj/effect/decal/cleanable/blood/stain in spot) + qdel(stain) + machine.add_splatter_floor(spot) + TEST_ASSERT_NULL(locate(/obj/effect/decal/cleanable/blood) in spot, "A robot left blood on the floor") + +/// Blood is for show: a wounded NPC keeps its blood and loses exactly the health it was dealt. +/datum/unit_test/hv_npc_blood_no_bleed_out/Run() + var/mob/living/basic/trooper/pirate = allocate(/mob/living/basic/trooper, run_loc_floor_bottom_left) + var/health_before = pirate.health + pirate.adjustBruteLoss(health_before - 10) + TEST_ASSERT_EQUAL(pirate.health, 10, "A wounded trooper lost health besides the damage it took") + TEST_ASSERT_EQUAL(pirate.blood_volume, BLOOD_VOLUME_NORMAL, "A wounded trooper lost blood") + TEST_ASSERT_EQUAL(pirate.get_bleed_rate(), 0, "A trooper has a bleeding rate") + +/// The blood overlay is light, medium, then heavy as health falls, and goes when the NPC is healed. +/datum/unit_test/hv_npc_blood_overlay/Run() + var/mob/living/basic/cow/cow = allocate(/mob/living/basic/cow, run_loc_floor_bottom_left) + var/max_health = cow.maxHealth + TEST_ASSERT_EQUAL(cow.hv_blood_stage, 0, "A healthy cow showed blood") // HV_NPC_BLOOD_NONE + TEST_ASSERT(!hv_blood_overlay(cow), "A healthy cow had a blood overlay") + + cow.adjustBruteLoss(max_health * 3 / 10) + TEST_ASSERT_EQUAL(cow.hv_blood_stage, 1, "A cow at 70% health did not show light blood") // HV_NPC_BLOOD_LIGHT + TEST_ASSERT(hv_blood_overlay(cow), "A cow at 70% health had no blood overlay") + TEST_ASSERT(cow.appearance_flags & KEEP_TOGETHER, "The inset blood needs the cow drawn as one group") + + cow.adjustBruteLoss(max_health * 2 / 10) + TEST_ASSERT_EQUAL(cow.hv_blood_stage, 2, "A cow at 50% health did not show medium blood") // HV_NPC_BLOOD_MEDIUM + TEST_ASSERT_EQUAL(hv_blood_overlay_count(cow), 1, "Medium blood stacked on the light blood") + + cow.adjustBruteLoss(max_health * 3 / 10) + TEST_ASSERT_EQUAL(cow.hv_blood_stage, 3, "A cow at 20% health did not show heavy blood") // HV_NPC_BLOOD_HEAVY + TEST_ASSERT_EQUAL(hv_blood_overlay_count(cow), 1, "Heavy blood stacked on the lighter blood") + + cow.adjustBruteLoss(-max_health) + TEST_ASSERT_EQUAL(cow.hv_blood_stage, 0, "A healed cow kept its blood") // HV_NPC_BLOOD_NONE + TEST_ASSERT(!hv_blood_overlay(cow), "A healed cow kept its blood overlay") + TEST_ASSERT(!(cow.appearance_flags & KEEP_TOGETHER), "A healed cow was still drawn as one group") + +/// How many blood overlays a mob wears +/datum/unit_test/proc/hv_blood_overlay_count(mob/living/target) + var/count = 0 + for(var/mutable_appearance/overlay as anything in target.overlays) + if(overlay.icon == 'voidcrew/modules/hyperviolence/hitlines/icons/npc_blood.dmi') + count++ + return count + +/// Whether a mob wears a blood overlay +/datum/unit_test/proc/hv_blood_overlay(mob/living/target) + return hv_blood_overlay_count(target) > 0 From 8f01cfdf130467b7050642af5ad5516aead6b181 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:28:30 -0800 Subject: [PATCH 011/472] Add aimed critical wounds for hyperviolence Six aimed crits built on tg's wound system: torn-out eye, severed tongue, slit throat, torn aorta, caved-in skull and shattered jaw. Each needs a hit aimed at the right spot with the right damage type and enough force, and is stopped by covering gear. Humanoid basic mobs take them as status effects. Design follows Roguetown's try_crit (BLACKSTONE) and Vanderlin's per-wound crit data; no code, art or sound copied. --- tgstation.dme | 4 + voidcrew/_DEFINES/hyperviolence.dm | 14 + .../modules/hyperviolence/crits/crit_basic.dm | 88 +++++ .../modules/hyperviolence/crits/crit_roll.dm | 349 ++++++++++++++++++ .../modules/hyperviolence/crits/crit_types.dm | 280 ++++++++++++++ .../hyperviolence/crits/crit_wounds.dm | 344 +++++++++++++++++ .../modules/unit_tests/voidcrew_hv_crits.dm | 206 +++++++++++ 7 files changed, 1285 insertions(+) create mode 100644 voidcrew/modules/hyperviolence/crits/crit_basic.dm create mode 100644 voidcrew/modules/hyperviolence/crits/crit_roll.dm create mode 100644 voidcrew/modules/hyperviolence/crits/crit_types.dm create mode 100644 voidcrew/modules/hyperviolence/crits/crit_wounds.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_hv_crits.dm diff --git a/tgstation.dme b/tgstation.dme index 7921541a824..d86dceddaa4 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7230,6 +7230,10 @@ #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" #include "voidcrew\modules\hyperviolence\_core.dm" +#include "voidcrew\modules\hyperviolence\crits\crit_basic.dm" +#include "voidcrew\modules\hyperviolence\crits\crit_roll.dm" +#include "voidcrew\modules\hyperviolence\crits\crit_types.dm" +#include "voidcrew\modules\hyperviolence\crits\crit_wounds.dm" #include "voidcrew\modules\hyperviolence\hitlines\hit_lines.dm" #include "voidcrew\modules\hyperviolence\hitlines\npc_blood.dm" #include "voidcrew\modules\intents\_defines.dm" diff --git a/voidcrew/_DEFINES/hyperviolence.dm b/voidcrew/_DEFINES/hyperviolence.dm index f0e0c2135e3..164e5e74dcf 100644 --- a/voidcrew/_DEFINES/hyperviolence.dm +++ b/voidcrew/_DEFINES/hyperviolence.dm @@ -4,3 +4,17 @@ #define HV_LINE_BOLD 1 /// A big bold-italic line, for severed limbs, burst heads and kills. #define HV_LINE_HUGE 2 + +// Aimed critical wounds (voidcrew/modules/hyperviolence/crits/). Each crit wound sits in its own +// series so it can stack with tg's ordinary bleeds on the same limb. The jaw and skull crits use +// tg's blunt bone series instead, since they are the worst head fractures. +/// A torn-out eye. +#define WOUND_SERIES_HV_EYE "wound_series_hv_eye" +/// A severed tongue. +#define WOUND_SERIES_HV_TONGUE "wound_series_hv_tongue" +/// A slit throat. +#define WOUND_SERIES_HV_CAROTID "wound_series_hv_carotid" +/// A torn aorta. +#define WOUND_SERIES_HV_AORTA "wound_series_hv_aorta" +/// Trait source for effects an aimed crit leaves on a mob. +#define HV_CRIT_TRAIT "hv_crit" diff --git a/voidcrew/modules/hyperviolence/crits/crit_basic.dm b/voidcrew/modules/hyperviolence/crits/crit_basic.dm new file mode 100644 index 00000000000..5aea3251612 --- /dev/null +++ b/voidcrew/modules/hyperviolence/crits/crit_basic.dm @@ -0,0 +1,88 @@ +// Hyperviolence aimed crits on humanoid basic mobs: NPC pirates, troopers, bounty criminals and +// the like. They have no limbs, organs or worn armour, so a crit is a status effect instead of a +// wound: a blow sized to the mob's max health (so health scaling at the spawn site still works), +// a short stun for some, and a bleed for the throat and aorta. The effect stays on the mob so the +// same crit cannot land on it twice. + +/datum/status_effect/hv_crit_basic + id = "hv_crit_basic" + alert_type = null + duration = STATUS_EFFECT_PERMANENT + tick_interval = STATUS_EFFECT_NO_TICK + status_type = STATUS_EFFECT_UNIQUE + remove_on_fullheal = TRUE + /// Brute damage dealt when it lands, as a share of max health. + var/blow_share = 0.1 + /// Stun when it lands. + var/stun_time = 0 + /// Which way the blow went, for blood. + var/attack_direction + +/datum/status_effect/hv_crit_basic/on_creation(mob/living/new_owner, attack_direction) + src.attack_direction = attack_direction + return ..() + +/datum/status_effect/hv_crit_basic/on_apply() + if(blow_share) + owner.adjustBruteLoss(owner.maxHealth * blow_share) + if(stun_time && owner.stat != DEAD) + owner.Stun(stun_time) + owner.hv_blood_spray(attack_direction, 2) + return TRUE + +/datum/status_effect/hv_crit_basic/eye + id = "hv_crit_basic_eye" + blow_share = 0.15 + stun_time = 1.5 SECONDS + +/datum/status_effect/hv_crit_basic/tongue + id = "hv_crit_basic_tongue" + blow_share = 0.1 + +/datum/status_effect/hv_crit_basic/tongue/on_apply() + . = ..() + ADD_TRAIT(owner, TRAIT_MUTE, HV_CRIT_TRAIT) + +/datum/status_effect/hv_crit_basic/tongue/on_remove() + REMOVE_TRAIT(owner, TRAIT_MUTE, HV_CRIT_TRAIT) + return ..() + +/datum/status_effect/hv_crit_basic/jaw + id = "hv_crit_basic_jaw" + blow_share = 0.15 + stun_time = 1 SECONDS + +/datum/status_effect/hv_crit_basic/skull + id = "hv_crit_basic_skull" + blow_share = 0.3 + stun_time = 3 SECONDS + +/// The throat and aorta: a share of max health bled away each second until the budget runs out. +/datum/status_effect/hv_crit_basic/bleed + tick_interval = 2 SECONDS + blow_share = 0.05 + /// Share of max health lost per second while bleeding. + var/bleed_share = 0.02 + /// Seconds of bleeding left. + var/bleed_left = 30 + +/datum/status_effect/hv_crit_basic/bleed/tick(seconds_between_ticks) + if(owner.stat == DEAD || bleed_left <= 0) + return + var/bled = min(seconds_between_ticks, bleed_left) + bleed_left -= bled + owner.adjustBruteLoss(owner.maxHealth * bleed_share * bled) + if(prob(50)) + owner.hv_blood_spray(pick(GLOB.alldirs), 2) + if(bleed_left <= 0) + // Stays on as a marker so the crit cannot land again, but stops ticking. + tick_interval = STATUS_EFFECT_NO_TICK + STOP_PROCESSING(SSfastprocess, src) + +/datum/status_effect/hv_crit_basic/bleed/throat + id = "hv_crit_basic_throat" + bleed_share = 0.025 + +/datum/status_effect/hv_crit_basic/bleed/aorta + id = "hv_crit_basic_aorta" + bleed_share = 0.02 diff --git a/voidcrew/modules/hyperviolence/crits/crit_roll.dm b/voidcrew/modules/hyperviolence/crits/crit_roll.dm new file mode 100644 index 00000000000..7364ddedbb2 --- /dev/null +++ b/voidcrew/modules/hyperviolence/crits/crit_roll.dm @@ -0,0 +1,349 @@ +// Hyperviolence: aimed critical wounds. +// +// A big enough hit, aimed at the right spot with the right kind of weapon, can do one specific, +// lasting injury: tear out an eye, cut off the tongue, slit the throat, tear the aorta, cave in +// the skull or shatter the jaw. The design follows Roguetown's try_crit (BLACKSTONE, +// code/modules/surgery/bodyparts/bodypart_wounds.dm) and Vanderlin's per-wound crit data +// (code/datums/wounds/_wound.dm). Only the ideas were taken; no code, art or sound was copied. +// +// How a crit is rolled. These numbers are balance notes for developers and never go in player +// text. +// * Only aimed hits count. The attacker must be a carbon (something with a target doll) holding +// the weapon or having fired the shot, must not be the victim, and the hit has to land on the +// limb they aimed at. NPC basic mobs never land crits, so NPC fights keep tg's normal wounds. +// * The hit must be brute damage with a wound roll (wound_bonus not CANT_WOUND) and a weapon or +// projectile behind it. Only the aorta can come from a projectile. +// * 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 +// 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, +// standing, unarmoured target hit by a 25+ damage weapon tops out at about 85, so the face +// crits (eye, tongue at 80, jaw at 75) land on roughly one such hit in ten to twenty, and the +// throat (90), aorta (90, chest armour scales it to 81 max) and skull (95) need the victim +// worn down or helpless first. A helpless target held by the attacker gets the throat about +// one big slash in four. A 10-force kitchen knife tops out near 30 and never crits by roll. +// * Gear that covers the spot blocks a crit outright (see each crit's blocked_on_carbon()). +// * A crit never stacks on itself: the wound can only be on a limb once. + +/// Most a battered limb adds to the roll, scaled by how much of its max damage it has taken. +#define HV_CRIT_BATTERED_BONUS 30 +/// Added to the roll when the victim is helpless. +#define HV_CRIT_HELPLESS_BONUS 25 +/// Added on top when the attacker holds the victim in an aggressive grab or tighter. +#define HV_CRIT_HELD_BONUS 15 + +/// Every aimed crit, one singleton per kind. See crit_types.dm. +GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) + +/proc/hv_crit_init_list() + var/list/crits = list() + for(var/crit_type in subtypesof(/datum/hv_crit)) + crits += new crit_type + return crits + +/// One kind of aimed critical wound: where it must be aimed, what can cause it, how hard it is to +/// land, what stops it and what it does. +/datum/hv_crit + /// Plain name, for logs. + var/name = "aimed crit" + /// The limb the hit has to land on. + var/limb_zone = BODY_ZONE_HEAD + /// What the attacker must have selected on their target doll. + var/list/aim_zones = list() + /// Wounding types (WOUND_SLASH, WOUND_PIERCE, WOUND_BLUNT) that can cause it. + var/list/wounding_types = list() + /// Least damage the hit must deal after armour. + var/min_damage = 15 + /// The roll must reach this. See the notes at the top of this file. + var/threshold = 90 + /// Can a projectile cause it? + var/allow_projectiles = FALSE + /// Can it happen to a corpse? + var/allow_dead = FALSE + /// The wound a carbon gets. + var/datum/wound/wound_type + /// The status effect a humanoid basic mob gets instead. See crit_basic.dm. + var/datum/status_effect/basic_effect + /// Size of the hit line: HV_LINE_BOLD or HV_LINE_HUGE. + var/line_severity = HV_LINE_BOLD + /// Played on the victim when it lands. Must be a sound already in the repo. + var/hit_sound + /// How long the victim's screen shakes, in deciseconds. 0 for none. + var/shake = 0 + +/// TRUE if a hit on hit_zone, aimed at aimed_zone, of this wounding type could cause this crit. +/datum/hv_crit/proc/matches(hit_zone, aimed_zone, wounding_type, from_projectile) + if(hit_zone != limb_zone || !(aimed_zone in aim_zones) || !(wounding_type in wounding_types)) + return FALSE + return !from_projectile || allow_projectiles + +/// TRUE if something the victim wears stops this crit outright. +/datum/hv_crit/proc/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + return FALSE + +/// TRUE if this crit's wound can go on this limb right now. +/datum/hv_crit/proc/can_apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, wounding_type) + var/datum/wound_pregen_data/pregen = GLOB.all_wound_pregen_data[wound_type] + return !!pregen?.can_be_applied_to(limb, list(wounding_type)) + +/// TRUE if this crit happens on this hit without a roll (a ruined eye, say). Rolled each hit. +/datum/hv_crit/proc/happens_anyway(mob/living/carbon/victim, obj/item/bodypart/limb) + return FALSE + +/// Applies the crit to a carbon. Returns TRUE if it landed. +/datum/hv_crit/proc/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + var/datum/wound/crit_wound = hv_crit_apply_wound(limb, wound_type, attack_direction, source) + if(!crit_wound) + return FALSE + announce(victim, attack_direction) + return TRUE + +/// The line added to the hit message. +/datum/hv_crit/proc/hit_line(mob/living/victim) + return "[victim] is badly hurt!" + +/// Hit line, sound and shake. +/datum/hv_crit/proc/announce(mob/living/victim, attack_direction, line) + victim.hv_add_hit_line(line || hit_line(victim), line_severity) + if(hit_sound) + playsound(victim, hit_sound, 80, TRUE, falloff_exponent = SOUND_FALLOFF_EXPONENT + 2, ignore_walls = FALSE, falloff_distance = 0) + if(shake) + shake_camera(victim, shake, 1) + +/// TRUE if a humanoid basic mob can take this crit right now. +/datum/hv_crit/proc/can_apply_basic(mob/living/basic/victim) + return basic_effect && !victim.has_status_effect(basic_effect) + +/// Applies the crit to a humanoid basic mob. Returns TRUE if it landed. +/datum/hv_crit/proc/apply_basic(mob/living/basic/victim, damage, attack_direction, atom/source, mob/living/attacker) + if(!victim.apply_status_effect(basic_effect, attack_direction)) + return FALSE + announce(victim, attack_direction) + return TRUE + +// --------------------------------------------------------------------------------------------- +// Helpers + +/// Applies wound_type to limb the way tg's own roll does: it replaces a lesser wound of the same +/// series, and does nothing if one as bad is already there. Silent, since the crit adds its own +/// hit line. Returns the wound, or null. +/proc/hv_crit_apply_wound(obj/item/bodypart/limb, datum/wound/wound_type, attack_direction, atom/source, right_side) + var/datum/wound_pregen_data/pregen = GLOB.all_wound_pregen_data[wound_type] + if(!pregen) + return null + var/datum/wound/replaced + for(var/datum/wound/existing as anything in limb.wounds) + if(existing.get_pregen_data().wound_series != pregen.wound_series) + continue + if(existing.severity >= initial(wound_type.severity)) + return null + replaced = existing + var/datum/wound/new_wound = new wound_type + if(replaced) + // tg's replace_wound(), but silent: tg would print its own wound message on a promotion. + replaced.already_scarred = TRUE + replaced.remove_wound(replaced = TRUE) + var/applied = new_wound.apply_wound(limb, silent = TRUE, old_wound = replaced, attack_direction = attack_direction, wound_source = source, replacing = TRUE) + qdel(replaced) + return applied ? new_wound : null + if(!new_wound.apply_wound(limb, silent = TRUE, attack_direction = attack_direction, wound_source = source)) + return null + return new_wound + +/// The carbon who aimed this hit: whoever holds the weapon, or fired the projectile. +/proc/hv_crit_get_aimer(atom/source) + var/atom/aimer + if(isprojectile(source)) + var/obj/projectile/shot = source + aimer = shot.firer + else if(isitem(source)) + aimer = source.loc + return iscarbon(aimer) ? aimer : null + +/// tg's wounding type for a brute hit. Eye stabs and a few other callers pass no sharpness to +/// apply_damage, so fall back to the weapon's own. +/proc/hv_crit_wounding_type(sharpness, atom/source) + if(!sharpness && isitem(source)) + var/obj/item/weapon = source + sharpness = weapon.get_sharpness() + if(sharpness & SHARP_EDGED) + return WOUND_SLASH + if(sharpness & SHARP_POINTY) + return WOUND_PIERCE + return WOUND_BLUNT + +/// The armour flag the hit is checked against. +/proc/hv_crit_attack_flag(atom/source) + if(isprojectile(source)) + var/obj/projectile/shot = source + return shot.armor_flag + return MELEE + +/// The hit's armour penetration. +/proc/hv_crit_armour_pen(atom/source) + if(isprojectile(source)) + var/obj/projectile/shot = source + return shot.armour_penetration + if(isitem(source)) + var/obj/item/weapon = source + return weapon.armour_penetration + return 0 + +/// Best armour rating of armour_flag among the clothes covering limb, less armour_penetration. +/proc/hv_crit_cover_rating(mob/living/carbon/victim, obj/item/bodypart/limb, armour_flag, armour_penetration = 0) + if(!ishuman(victim)) + return 0 + var/mob/living/carbon/human/human_victim = victim + var/best = 0 + for(var/obj/item/clothing/worn as anything in human_victim.get_clothing_on_part(limb)) + best = max(best, worn.get_armor_rating(armour_flag)) + return best - armour_penetration + +/// Roll bonus for a victim who cannot defend themselves. +/mob/living/proc/hv_crit_helpless_bonus(mob/living/attacker) + . = 0 + if(incapacitated || body_position == LYING_DOWN || stat >= SOFT_CRIT) + . += HV_CRIT_HELPLESS_BONUS + 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 +/// 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) + var/easily_wounded = HAS_TRAIT(owner, TRAIT_EASILY_WOUNDED) + var/hardly_wounded = HAS_TRAIT(owner, TRAIT_HARDLY_WOUNDED) + if(easily_wounded && !hardly_wounded) + damage *= 1.5 + else + damage = min(damage, WOUND_MAX_CONSIDERED_DAMAGE) + 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) + return check_woundings_mods(wounding_type, base_roll, damage, bonus, exposed_wound_bonus, FALSE) + +// --------------------------------------------------------------------------------------------- +// Entry point. Every damaging brute hit with a weapon or projectile behind it passes through +// here once, after tg has applied the damage and rolled its own wound for it. + +/mob/living/apply_damage( + damage = 0, + damagetype = BRUTE, + def_zone = null, + blocked = 0, + forced = FALSE, + spread_damage = FALSE, + wound_bonus = 0, + exposed_wound_bonus = 0, + sharpness = NONE, + attack_direction = null, + attacking_item, + wound_clothing = TRUE, +) + . = ..() + if(. <= 0 || damagetype != BRUTE || forced || wound_bonus == CANT_WOUND || isnull(attacking_item) || QDELETED(src)) + return . + hv_crit_check_hit(., def_zone, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) + return . + +/// Decides whether this hit can be an aimed crit and rolls it. Only carbons and humanoid basic +/// mobs can take crits. +/mob/living/proc/hv_crit_check_hit(damage, def_zone, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, atom/source) + return + +/mob/living/carbon/hv_crit_check_hit(damage, def_zone, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, atom/source) + if(!isbodypart(def_zone)) + return + var/obj/item/bodypart/limb = def_zone + if(limb.owner != src) + return + var/mob/living/carbon/attacker = hv_crit_get_aimer(source) + if(isnull(attacker) || attacker == src) + return + var/wounding_type = hv_crit_wounding_type(sharpness, source) + hv_try_aimed_crit(limb, attacker.zone_selected, wounding_type, damage, wound_bonus, exposed_wound_bonus, attack_direction || get_dir(attacker, src), source, attacker) + +/** + * Rolls every crit that fits this hit and applies the first one that lands. Returns that crit's + * datum, or null. + * + * * limb - the limb that was hit + * * aimed_zone - what the attacker had selected + * * forced_roll - for tests: use this number instead of rolling + */ +/mob/living/carbon/proc/hv_try_aimed_crit(obj/item/bodypart/limb, aimed_zone, wounding_type, damage, wound_bonus, exposed_wound_bonus, attack_direction, atom/source, mob/living/attacker, forced_roll) + if(check_zone(aimed_zone) != limb.body_zone) + return null + var/from_projectile = isprojectile(source) + var/roll + for(var/datum/hv_crit/crit as anything in GLOB.hv_crits) + if(!crit.matches(limb.body_zone, aimed_zone, wounding_type, from_projectile)) + continue + if(stat == DEAD && !crit.allow_dead) + continue + if(crit.blocked_on_carbon(src, limb, source)) + continue + if(!crit.can_apply_carbon(src, limb, wounding_type)) + continue + if(!crit.happens_anyway(src, limb)) + 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 + if(roll < crit.threshold) + continue + if(!crit.apply_carbon(src, limb, damage, attack_direction, source, attacker)) + continue + log_combat(attacker, src, "landed an aimed crit on", source, "([crit.name], roll [isnull(roll) ? "none" : roll])") + return crit + return null + +/mob/living/basic/hv_crit_check_hit(damage, def_zone, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, atom/source) + if(!(mob_biotypes & MOB_HUMANOID) || !(mob_biotypes & MOB_ORGANIC) || stat == DEAD) + return + var/mob/living/carbon/attacker = hv_crit_get_aimer(source) + if(isnull(attacker) || attacker == src) + return + var/aimed_zone = attacker.zone_selected + var/hit_zone = check_zone(aimed_zone) + if(isprojectile(source)) + // Shots know where they landed; it has to be where they were aimed. + if(check_zone(def_zone) != hit_zone) + return + else if(hit_zone != BODY_ZONE_CHEST && !prob(80)) + // Simple mobs have no limbs for a blow to stray onto, so give them the same 80% a carbon gets. + return + var/wounding_type = hv_crit_wounding_type(sharpness, source) + hv_try_basic_crit(hit_zone, aimed_zone, wounding_type, damage, wound_bonus, attack_direction || get_dir(attacker, src), source, attacker) + +/// The basic mob version of hv_try_aimed_crit(). No limbs and no worn armour, so the roll is the +/// same tg roll and bonuses with health standing in for limb damage. +/mob/living/basic/proc/hv_try_basic_crit(hit_zone, aimed_zone, wounding_type, damage, wound_bonus, attack_direction, atom/source, mob/living/attacker, forced_roll) + var/from_projectile = isprojectile(source) + var/roll + for(var/datum/hv_crit/crit as anything in GLOB.hv_crits) + if(!crit.matches(hit_zone, aimed_zone, wounding_type, from_projectile)) + continue + if(!crit.can_apply_basic(src) || damage < crit.min_damage) + continue + if(isnull(roll)) + if(isnull(forced_roll)) + var/base_roll = rand(1, round(min(damage, WOUND_MAX_CONSIDERED_DAMAGE) ** WOUND_DAMAGE_EXPONENT)) + var/battered = round(HV_CRIT_BATTERED_BONUS * (1 - health / max(maxHealth, 1))) + roll = (base_roll + wound_bonus + battered + hv_crit_helpless_bonus(attacker)) * (hit_zone == BODY_ZONE_CHEST ? 0.9 : 0.95) + else + roll = forced_roll + if(roll < crit.threshold) + continue + if(!crit.apply_basic(src, damage, attack_direction, source, attacker)) + continue + log_combat(attacker, src, "landed an aimed crit on", source, "([crit.name], roll [roll])") + return crit + return null + +#undef HV_CRIT_BATTERED_BONUS +#undef HV_CRIT_HELPLESS_BONUS +#undef HV_CRIT_HELD_BONUS diff --git a/voidcrew/modules/hyperviolence/crits/crit_types.dm b/voidcrew/modules/hyperviolence/crits/crit_types.dm new file mode 100644 index 00000000000..4d9ca9d3fa0 --- /dev/null +++ b/voidcrew/modules/hyperviolence/crits/crit_types.dm @@ -0,0 +1,280 @@ +// Hyperviolence aimed crits: the six kinds. Thresholds and minimum damage are explained at the top +// of crit_roll.dm. Hit lines are short and say what happened, nothing about why. + +/// Chance per sharp hit that an eye already ruined past tg's high damage threshold is torn out, +/// with no roll and no minimum damage. This is how repeated eye stabs with a knife end. +#define HV_EYE_RUINED_CHANCE 25 +/// The skull bursts instead of caving in when the victim is already in crit and the hit is this big. +#define HV_SKULL_BURST_DAMAGE 20 +/// Melee armour on the head (after armour penetration) that stops the skull caving in. +#define HV_SKULL_HELMET_ARMOUR 30 +/// Melee armour on an armoured collar (after armour penetration) that stops a throat slash. +#define HV_THROAT_COLLAR_ARMOUR 10 +/// Chest armour against the hit (after armour penetration) that stops a torn aorta. +#define HV_AORTA_CHEST_ARMOUR 40 + +// --------------------------------------------------------------------------------------------- +// Eye torn out: a slash or stab aimed at the eyes. Any eye cover (glasses, visor, sealed helmet) +// stops it. Knives and screwdrivers stab eyes through tg's eyestab element for small damage, so +// they never pass the roll; they get the eye through HV_EYE_RUINED_CHANCE once it is ruined. + +/datum/hv_crit/eye + name = "torn-out eye" + limb_zone = BODY_ZONE_HEAD + aim_zones = list(BODY_ZONE_PRECISE_EYES) + wounding_types = list(WOUND_SLASH, WOUND_PIERCE) + min_damage = 12 + threshold = 80 + wound_type = /datum/wound/pierce/bleed/severe/eye/hv_gouged + basic_effect = /datum/status_effect/hv_crit_basic/eye + hit_sound = 'sound/effects/wounds/pierce2.ogg' + shake = 4 + +/datum/hv_crit/eye/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + return !!victim.is_eyes_covered() + +/datum/hv_crit/eye/can_apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, wounding_type) + var/obj/item/organ/eyes/eyes = victim.get_organ_slot(ORGAN_SLOT_EYES) + if(!eyes || eyes.bodypart_owner != limb || (eyes.organ_flags & ORGAN_UNREMOVABLE)) + return FALSE + if(limb.get_wound_type(/datum/wound/pierce/bleed/severe/eye/hv_gouged)) + return TRUE // the socket wound is already there; this one takes the other eye + return ..() + +/datum/hv_crit/eye/happens_anyway(mob/living/carbon/victim, obj/item/bodypart/limb) + var/obj/item/organ/eyes/eyes = victim.get_organ_slot(ORGAN_SLOT_EYES) + return eyes && eyes.damage >= eyes.high_threshold && prob(HV_EYE_RUINED_CHANCE) + +/datum/hv_crit/eye/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + var/obj/item/organ/eyes/eyes = victim.get_organ_slot(ORGAN_SLOT_EYES) + if(!eyes) + return FALSE + var/list/open_sides = list() + if(!(eyes.scarring & RIGHT_EYE_SCAR)) + open_sides += RIGHT_EYE_SCAR + if(!(eyes.scarring & LEFT_EYE_SCAR)) + open_sides += LEFT_EYE_SCAR + var/side = length(open_sides) ? pick(open_sides) : RIGHT_EYE_SCAR + var/last_eye = length(open_sides) <= 1 + + var/datum/wound/pierce/bleed/severe/eye/hv_gouged/socket = limb.get_wound_type(/datum/wound/pierce/bleed/severe/eye/hv_gouged) + if(socket) + if(last_eye) + socket.lose_other_eye() + else // a fresh pair of eyes went in since the last one + socket.set_blood_flow(max(socket.blood_flow, socket.initial_flow)) + else + socket = new + if(!socket.apply_wound(limb, silent = TRUE, attack_direction = attack_direction, wound_source = source, right_side = (side == RIGHT_EYE_SCAR))) + return FALSE + if(last_eye) + socket.lose_other_eye() + + // tg's eye scarring does the seeing: one scarred eye is nearsighted, two are blind. + if(length(open_sides)) + eyes.apply_scar(side) + victim.adjust_eye_blur(20 SECONDS) + victim.add_mood_event("eye_stab", /datum/mood_event/eye_stab) + + var/line + if(last_eye) + // Nothing left to see with: the whole organ comes out. + eyes.Remove(victim) + var/turf/drop_turf = victim.drop_location() + eyes.forceMove(drop_turf) + var/turf/landing = get_step(drop_turf, attack_direction) + if(landing && !landing.is_blocked_turf()) + eyes.throw_at(landing, 1, 1) + line = length(open_sides) ? "[victim]'s [side == RIGHT_EYE_SCAR ? "right" : "left"] eye is torn out!" : "[victim]'s eyes are torn out!" + else + line = "[victim]'s [side == RIGHT_EYE_SCAR ? "right" : "left"] eye is torn out!" + victim.hv_blood_spray(attack_direction, 2) + announce(victim, attack_direction, line) + return TRUE + +/datum/hv_crit/eye/hit_line(mob/living/victim) + return "[victim]'s [pick("left", "right")] eye is torn out!" + +// --------------------------------------------------------------------------------------------- +// Tongue cut off: a slash aimed at the mouth. A mask or helmet over the mouth stops it. + +/datum/hv_crit/tongue + name = "severed tongue" + limb_zone = BODY_ZONE_HEAD + aim_zones = list(BODY_ZONE_PRECISE_MOUTH) + wounding_types = list(WOUND_SLASH) + min_damage = 12 + threshold = 80 + wound_type = /datum/wound/slash/flesh/moderate/hv_tongue + basic_effect = /datum/status_effect/hv_crit_basic/tongue + hit_sound = 'sound/effects/wounds/blood2.ogg' + +/datum/hv_crit/tongue/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + return !!victim.is_mouth_covered() + +/datum/hv_crit/tongue/can_apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, wounding_type) + var/obj/item/organ/tongue/tongue = victim.get_organ_slot(ORGAN_SLOT_TONGUE) + if(!tongue || tongue.bodypart_owner != limb || (tongue.organ_flags & ORGAN_UNREMOVABLE)) + return FALSE + return ..() + +/datum/hv_crit/tongue/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + var/obj/item/organ/tongue/tongue = victim.get_organ_slot(ORGAN_SLOT_TONGUE) + if(!tongue || !hv_crit_apply_wound(limb, wound_type, attack_direction, source)) + return FALSE + tongue.Remove(victim) + var/turf/drop_turf = victim.drop_location() + tongue.forceMove(drop_turf) + var/turf/landing = get_step(drop_turf, attack_direction) + if(landing && !landing.is_blocked_turf()) + tongue.throw_at(landing, 1, 1) + victim.hv_blood_spray(attack_direction, 1) + announce(victim, attack_direction) + return TRUE + +/datum/hv_crit/tongue/hit_line(mob/living/victim) + return "[victim]'s tongue is cut off!" + +// --------------------------------------------------------------------------------------------- +// Throat slit: a slash aimed at the head. A sealed helmet or an armoured collar stops it. + +/datum/hv_crit/throat + name = "slit throat" + limb_zone = BODY_ZONE_HEAD + aim_zones = list(BODY_ZONE_HEAD) + wounding_types = list(WOUND_SLASH) + min_damage = 18 + threshold = 90 + wound_type = /datum/wound/slash/flesh/critical/hv_carotid + basic_effect = /datum/status_effect/hv_crit_basic/bleed/throat + line_severity = HV_LINE_HUGE + hit_sound = 'sound/effects/wounds/blood3.ogg' + shake = 5 + +/datum/hv_crit/throat/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + if(victim.is_mouth_covered(ITEM_SLOT_HEAD)) + return TRUE + if(!ishuman(victim)) + return FALSE + var/mob/living/carbon/human/human_victim = victim + var/obj/item/clothing/collar = human_victim.wear_neck + if(!istype(collar)) + return FALSE + return collar.get_armor_rating(MELEE) - hv_crit_armour_pen(source) >= HV_THROAT_COLLAR_ARMOUR + +/datum/hv_crit/throat/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, 3) + announce(victim, attack_direction) + return TRUE + +/datum/hv_crit/throat/hit_line(mob/living/victim) + return "[victim]'s throat is slit open!" + +// --------------------------------------------------------------------------------------------- +// Aorta torn: a huge stab, slash or shot aimed at the chest. Heavy chest armour against that kind +// of hit stops it; ordinary vests only make it rarer through their wound armour. + +/datum/hv_crit/aorta + name = "torn aorta" + limb_zone = BODY_ZONE_CHEST + aim_zones = list(BODY_ZONE_CHEST) + wounding_types = list(WOUND_SLASH, WOUND_PIERCE) + min_damage = 25 + threshold = 90 + allow_projectiles = TRUE + wound_type = /datum/wound/pierce/bleed/critical/hv_aorta + basic_effect = /datum/status_effect/hv_crit_basic/bleed/aorta + hit_sound = 'sound/effects/wounds/pierce3.ogg' + shake = 4 + +/datum/hv_crit/aorta/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + return hv_crit_cover_rating(victim, limb, hv_crit_attack_flag(source), hv_crit_armour_pen(source)) >= HV_AORTA_CHEST_ARMOUR + +/datum/hv_crit/aorta/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/aorta/hit_line(mob/living/victim) + return "[victim]'s aorta is torn open!" + +// --------------------------------------------------------------------------------------------- +// Skull caved in: a heavy blunt blow aimed at the head. A real helmet, or anything that blocks +// head injuries (hard hats, riot helmets), stops it. If the victim is already in crit the head +// bursts instead (hv_burst_head(), owned by the brain-and-guts part). + +/datum/hv_crit/skull + name = "caved-in skull" + limb_zone = BODY_ZONE_HEAD + aim_zones = list(BODY_ZONE_HEAD) + wounding_types = list(WOUND_BLUNT) + min_damage = 20 + threshold = 95 + allow_dead = TRUE + wound_type = /datum/wound/blunt/bone/critical/hv_skull + basic_effect = /datum/status_effect/hv_crit_basic/skull + line_severity = HV_LINE_HUGE + hit_sound = 'sound/effects/wounds/crack2.ogg' + shake = 8 + +/datum/hv_crit/skull/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + if(HAS_TRAIT(victim, TRAIT_HEAD_INJURY_BLOCKED)) + return TRUE + return hv_crit_cover_rating(victim, limb, MELEE, hv_crit_armour_pen(source)) >= HV_SKULL_HELMET_ARMOUR + +/datum/hv_crit/skull/can_apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, wounding_type) + if(victim.stat >= SOFT_CRIT && limb.can_dismember()) + return TRUE // the head can still burst even if the skull is already broken + if(victim.stat == DEAD) + return FALSE + return ..() + +/datum/hv_crit/skull/apply_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, damage, attack_direction, atom/source, mob/living/attacker) + if(victim.stat >= SOFT_CRIT && damage >= HV_SKULL_BURST_DAMAGE && victim.hv_burst_head(attack_direction)) + playsound(get_turf(victim), hit_sound, 80, TRUE, falloff_exponent = SOUND_FALLOFF_EXPONENT + 2, ignore_walls = FALSE, falloff_distance = 0) + return TRUE + if(victim.stat == DEAD || !hv_crit_apply_wound(limb, wound_type, attack_direction, source)) + return FALSE + announce(victim, attack_direction) + return TRUE + +/datum/hv_crit/skull/hit_line(mob/living/victim) + return "[victim]'s skull caves in!" + +// --------------------------------------------------------------------------------------------- +// Jaw shattered: a blunt blow aimed at the mouth. A helmet that covers the mouth stops it. + +/datum/hv_crit/jaw + name = "shattered jaw" + limb_zone = BODY_ZONE_HEAD + aim_zones = list(BODY_ZONE_PRECISE_MOUTH) + wounding_types = list(WOUND_BLUNT) + min_damage = 12 + threshold = 75 + wound_type = /datum/wound/blunt/bone/severe/hv_jaw + basic_effect = /datum/status_effect/hv_crit_basic/jaw + hit_sound = 'sound/effects/wounds/crack1.ogg' + shake = 3 + +/datum/hv_crit/jaw/blocked_on_carbon(mob/living/carbon/victim, obj/item/bodypart/limb, atom/source) + return !!victim.is_mouth_covered(ITEM_SLOT_HEAD) + +/datum/hv_crit/jaw/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.add_splatter_floor(get_turf(victim), small_drip = TRUE) + announce(victim, attack_direction) + return TRUE + +/datum/hv_crit/jaw/hit_line(mob/living/victim) + return "[victim]'s jaw shatters!" + +#undef HV_EYE_RUINED_CHANCE +#undef HV_SKULL_BURST_DAMAGE +#undef HV_SKULL_HELMET_ARMOUR +#undef HV_THROAT_COLLAR_ARMOUR +#undef HV_AORTA_CHEST_ARMOUR diff --git a/voidcrew/modules/hyperviolence/crits/crit_wounds.dm b/voidcrew/modules/hyperviolence/crits/crit_wounds.dm new file mode 100644 index 00000000000..05c7314f13f --- /dev/null +++ b/voidcrew/modules/hyperviolence/crits/crit_wounds.dm @@ -0,0 +1,344 @@ +// Hyperviolence aimed crits: the wounds each crit leaves behind. +// +// These are ordinary tg wound subtypes, so health analyzers, examine text, treatment items, +// surgery and scars all handle them like any other wound. None of them can come from tg's random +// wound roll (can_be_randomly_generated = FALSE): only the aimed crit roll in crit_roll.dm applies +// them, and it applies them silently and adds its own hit line instead of tg's wound message. + +// --------------------------------------------------------------------------------------------- +// Torn-out eye. tg keeps both eyes in one organ, so the first crit takes one eye (it scars that +// side, which leaves the victim nearsighted, the same as tg's own eye scarring) and the second +// takes the other and tears the whole organ out onto the floor. + +/datum/wound/pierce/bleed/severe/eye/hv_gouged + name = "Torn-Out Eye" + undiagnosed_name = "Torn-Out Eye" + desc = "Patient's eye has been torn from its socket. The socket is bleeding freely." + treat_text = "Suture or cauterize the socket. Only a replacement pair of eyes will restore sight." + treat_text_short = "Suture or cauterize the socket." + simple_desc = "Patient's eye has been torn out." + simple_treat_text = "Suture or cauterize the socket. Sight only returns with new eyes." + homemade_treat_text = "Press gauze over the socket." + occur_text = "loses an eye in a spray of blood" + internal_bleeding_chance = 20 + /// Set once the second eye has gone too. + var/both_gone = FALSE + +/datum/wound/pierce/bleed/severe/eye/hv_gouged/apply_wound(obj/item/bodypart/limb, silent = FALSE, datum/wound/old_wound = null, smited = FALSE, attack_direction = null, wound_source = "Unknown", replacing = FALSE, right_side = FALSE) + . = ..() + if(.) + update_socket_desc() + +/// The second eye has been torn out as well. +/datum/wound/pierce/bleed/severe/eye/hv_gouged/proc/lose_other_eye() + both_gone = TRUE + set_blood_flow(max(blood_flow, initial_flow)) + update_socket_desc() + +/datum/wound/pierce/bleed/severe/eye/hv_gouged/proc/update_socket_desc() + if(both_gone) + examine_desc = "has two empty eye sockets, weeping blood" + else + examine_desc = "has an empty [right_side ? "right" : "left"] eye socket, weeping blood" + +/datum/wound_pregen_data/flesh_pierce/open_puncture/eye/hv_gouged + wound_path_to_generate = /datum/wound/pierce/bleed/severe/eye/hv_gouged + required_wounding_types = list(WOUND_PIERCE, WOUND_SLASH) + wound_series = WOUND_SERIES_HV_EYE + can_be_randomly_generated = FALSE + +// --------------------------------------------------------------------------------------------- +// Severed tongue. The crit drops the tongue organ; tg's tongueless speech does the rest. The +// wound is the bleeding stump, which clots on its own like a light cut. + +/datum/wound/slash/flesh/moderate/hv_tongue + name = "Severed Tongue" + undiagnosed_name = "Severed Tongue" + desc = "Patient's tongue has been cut off. The stump is bleeding into the mouth." + treat_text = "Suture or cauterize the stump. A new tongue can be implanted." + treat_text_short = "Suture or cauterize the stump." + simple_desc = "Patient's tongue has been cut off." + simple_treat_text = "The bleeding stops on its own. A new tongue has to be implanted." + homemade_treat_text = "Keep the head still and let it clot." + examine_desc = "is bleeding from the mouth" + occur_text = "loses its tongue" + initial_flow = 2 + minimum_flow = 0.5 + clot_rate = 0.04 + +/datum/wound_pregen_data/flesh_slash/hv_tongue + abstract = FALSE + wound_path_to_generate = /datum/wound/slash/flesh/moderate/hv_tongue + wound_series = WOUND_SERIES_HV_TONGUE + viable_zones = list(BODY_ZONE_HEAD) + can_be_randomly_generated = FALSE + threshold_minimum = 20 + +// --------------------------------------------------------------------------------------------- +// Torn carotid (slit throat). Bleeds about as hard as tg's worst cut and never clots, spurts on a +// pulse, and gargles speech while it is open. A hemostat clamps it (a big cut to the bleeding), +// then sutures or cautery close it as with any cut. Untreated it kills in roughly two minutes. + +/// Blood flow at or above which the throat spurts and the victim gargles. +#define HV_CAROTID_OPEN_FLOW 1.5 + +/datum/wound/slash/flesh/critical/hv_carotid + name = "Torn Carotid Artery" + undiagnosed_name = "Slit Throat" + desc = "Patient's throat is cut open through the carotid artery. Blood loss will kill within minutes." + treat_text = "Clamp the artery with a hemostat, then suture or cauterize the wound. Transfuse blood." + treat_text_short = "Clamp with a hemostat, then suture or cauterize." + simple_desc = "Patient's throat is cut open and pumping blood." + simple_treat_text = "Clamp the artery with a hemostat, then suture or cauterize it." + homemade_treat_text = "Press hard on the wound and lie down." + examine_desc = "has its throat slit open, pumping out blood" + occur_text = "is slit open at the throat" + sound_effect = 'sound/effects/wounds/blood3.ogg' + initial_flow = 4.25 + minimum_flow = 0.5 + clot_rate = 0 + demotes_to = null + treatable_tools = list(TOOL_CAUTERY, TOOL_HEMOSTAT) + /// Set once someone has clamped the artery with a hemostat. + var/clamped = FALSE + /// world.time of the next spurt. + var/next_spurt = 0 + +/datum/wound/slash/flesh/critical/hv_carotid/set_victim(new_victim) + if(victim) + UnregisterSignal(victim, COMSIG_LIVING_TREAT_MESSAGE) + if(new_victim) + RegisterSignal(new_victim, COMSIG_LIVING_TREAT_MESSAGE, PROC_REF(gargle_speech)) + return ..() + +/datum/wound/slash/flesh/critical/hv_carotid/handle_process(seconds_per_tick, times_fired) + . = ..() + if(QDELETED(src) || !victim || !limb || victim.stat == DEAD || HAS_TRAIT(victim, TRAIT_STASIS)) + return + if(clamped || blood_flow < HV_CAROTID_OPEN_FLOW || world.time < next_spurt || !limb.can_bleed()) + return + next_spurt = world.time + rand(4 SECONDS, 7 SECONDS) + victim.hv_blood_spray(pick(GLOB.alldirs), 2) + +/datum/wound/slash/flesh/critical/hv_carotid/treat(obj/item/I, mob/user) + if(I.tool_behaviour == TOOL_HEMOSTAT) + return clamp_artery(I, user) + return ..() + +/// Someone clamps the torn artery shut with a hemostat. +/datum/wound/slash/flesh/critical/hv_carotid/proc/clamp_artery(obj/item/tool, mob/user) + if(clamped) + to_chat(user, span_warning("The artery is already clamped.")) + return TRUE + var/whose = (user == victim) ? "your" : "[victim]'s" + user.visible_message(span_danger("[user] reaches into [victim]'s throat with [tool]..."), span_warning("You reach into [whose] throat with [tool]...")) + if(!do_after(user, base_treat_time * (user == victim ? 2 : 1.5), target = victim, extra_checks = CALLBACK(src, PROC_REF(still_exists)))) + return TRUE + clamped = TRUE + user.visible_message(span_green("[user] clamps the artery in [victim]'s throat."), span_green("You clamp the artery in [whose] throat.")) + adjust_blood_flow(-2.5) + return TRUE + +/datum/wound/slash/flesh/critical/hv_carotid/modify_desc_before_span(desc, mob/user) + . = ..() + if(clamped) + . += ", held shut by a clamp" + +/datum/wound/slash/flesh/critical/hv_carotid/proc/gargle_speech(datum/source, list/message_args) + SIGNAL_HANDLER + if(clamped || blood_flow < HV_CAROTID_OPEN_FLOW) + return + message_args[TREAT_MESSAGE_ARG] = hv_crit_gargle(message_args[TREAT_MESSAGE_ARG]) + +#undef HV_CAROTID_OPEN_FLOW + +/datum/wound_pregen_data/flesh_slash/hv_carotid + abstract = FALSE + wound_path_to_generate = /datum/wound/slash/flesh/critical/hv_carotid + wound_series = WOUND_SERIES_HV_CAROTID + required_limb_biostate = (BIO_FLESH|BIO_BLOODED) + ignore_cannot_bleed = FALSE + viable_zones = list(BODY_ZONE_HEAD) + can_be_randomly_generated = FALSE + threshold_minimum = 80 + +// --------------------------------------------------------------------------------------------- +// Torn aorta. A deep chest bleed that never clots, shrugs off gauze, cannot be cauterized from +// outside and makes the victim cough blood. tg's "Repair puncture" surgery fixes it; blood and +// clotting drugs buy time. + +/datum/wound/pierce/bleed/critical/hv_aorta + name = "Torn Aorta" + undiagnosed_name = "Chest Bleed" + desc = "Patient's aorta is torn. The chest cavity is filling with blood." + treat_text = "Repair the vessel surgically at once. Blood transfusions and clotting agents buy time." + treat_text_short = "Surgical repair required." + simple_desc = "A great artery in the chest is torn." + simple_treat_text = "Needs surgery at once. Blood and clotting drugs buy time." + homemade_treat_text = "Keep them lying still." + examine_desc = "has a deep wound that pumps blood with every heartbeat" + occur_text = "is torn open to the heart" + initial_flow = 3.5 + gauzed_clot_rate = 0.1 + internal_bleeding_chance = 100 + internal_bleeding_coefficient = 2 + treatable_by = list() + treatable_tools = list() + wound_flags = (ACCEPTS_GAUZE | MANGLES_EXTERIOR) + /// world.time of the next mouthful of blood. + var/next_cough = 0 + +/datum/wound/pierce/bleed/critical/hv_aorta/wound_injury(datum/wound/old_wound = null, attack_direction = null) + next_cough = world.time + rand(8 SECONDS, 15 SECONDS) + return ..() + +/datum/wound/pierce/bleed/critical/hv_aorta/check_grab_treatments(obj/item/I, mob/user) + return FALSE // a hot knife on the skin does nothing for a torn aorta + +/datum/wound/pierce/bleed/critical/hv_aorta/handle_process(seconds_per_tick, times_fired) + . = ..() + if(QDELETED(src) || !victim || !limb || victim.stat == DEAD || HAS_TRAIT(victim, TRAIT_STASIS)) + return + if(world.time < next_cough || !limb.can_bleed()) + return + next_cough = world.time + rand(15 SECONDS, 25 SECONDS) + victim.visible_message( + span_danger("[victim] coughs up a mouthful of blood!"), + span_userdanger("You cough up a mouthful of blood!"), + vision_distance = COMBAT_MESSAGE_RANGE, + ) + victim.bleed(4) + victim.add_splatter_floor(get_step(victim, victim.dir) || get_turf(victim)) + +/datum/wound_pregen_data/flesh_pierce/hv_aorta + abstract = FALSE + wound_path_to_generate = /datum/wound/pierce/bleed/critical/hv_aorta + required_wounding_types = list(WOUND_PIERCE, WOUND_SLASH) + wound_series = WOUND_SERIES_HV_AORTA + required_limb_biostate = (BIO_FLESH|BIO_BLOODED) + ignore_cannot_bleed = FALSE + viable_zones = list(BODY_ZONE_CHEST) + can_be_randomly_generated = FALSE + threshold_minimum = 100 + +// --------------------------------------------------------------------------------------------- +// Caved-in skull. tg's compound fracture with heavy brain damage and a knockdown on top. It sits +// in tg's blunt bone series as a critical, so it replaces a lesser head fracture and gives tg's +// severe brain trauma cycle until the bone is fixed (compound fracture surgery or gel and tape). + +/// Brain damage dealt when the skull caves in (brains die at 200). +#define HV_SKULL_BRAIN_DAMAGE 35 + +/datum/wound/blunt/bone/critical/hv_skull + name = "Depressed Skull Fracture" + undiagnosed_name = "Caved-In Skull" + desc = "Patient's skull has been driven in against the brain." + treat_text = "Repair surgically and treat the brain damage. Bone gel and surgical tape will hold it in an emergency." + treat_text_short = "Repair surgically, or apply bone gel and surgical tape." + simple_desc = "Patient's skull is caved in." + simple_treat_text = "Surgery, or bone gel and surgical tape. Treat the brain as well." + homemade_treat_text = "Keep them lying down." + occur_text = "caves in" + +/datum/wound/blunt/bone/critical/hv_skull/apply_wound(obj/item/bodypart/L, silent = FALSE, datum/wound/old_wound = null, smited = FALSE, attack_direction = null, wound_source = "Unknown", replacing = FALSE) + . = ..() + examine_desc = "is caved in, the skull driven into a deep dent" + +/datum/wound/blunt/bone/critical/hv_skull/wound_injury(datum/wound/old_wound = null, attack_direction = null) + . = ..() + if(!victim) + return + victim.adjustOrganLoss(ORGAN_SLOT_BRAIN, HV_SKULL_BRAIN_DAMAGE) + if(victim.stat == DEAD) + return + victim.Knockdown(3 SECONDS) + victim.set_confusion_if_lower(15 SECONDS) + victim.adjust_eye_blur(10 SECONDS) + +#undef HV_SKULL_BRAIN_DAMAGE + +/datum/wound_pregen_data/bone/hv_skull + abstract = FALSE + wound_path_to_generate = /datum/wound/blunt/bone/critical/hv_skull + viable_zones = list(BODY_ZONE_HEAD) + can_be_randomly_generated = FALSE + threshold_minimum = 115 + +// --------------------------------------------------------------------------------------------- +// Shattered jaw. tg's hairline fracture on the head without its brain traumas, plus mumbled +// speech while it lasts. Heals like any hairline fracture (surgery, or bone gel and tape). +// tg has no teeth item, so no teeth fly. + +/datum/wound/blunt/bone/severe/hv_jaw + name = "Shattered Jaw" + undiagnosed_name = "Broken Jaw" + desc = "Patient's jaw is broken in several places." + treat_text = "Repair surgically, or set it with bone gel and surgical tape." + treat_text_short = "Repair surgically, or apply bone gel and surgical tape." + simple_desc = "Patient's jaw is broken." + simple_treat_text = "Surgery, or bone gel and surgical tape." + homemade_treat_text = "Keep them off solid food." + examine_desc = "hangs open on a shattered jaw" + occur_text = "has its jaw shattered" + sound_effect = 'sound/effects/wounds/crack1.ogg' + brain_trauma_group = null + +/datum/wound/blunt/bone/severe/hv_jaw/set_victim(new_victim) + if(victim) + UnregisterSignal(victim, COMSIG_LIVING_TREAT_MESSAGE) + if(new_victim) + RegisterSignal(new_victim, COMSIG_LIVING_TREAT_MESSAGE, PROC_REF(mumble_speech)) + return ..() + +/datum/wound/blunt/bone/severe/hv_jaw/get_scanner_description(mob/user) + . = ..() + // tg's bone wounds report brain traumas for any head fracture; a broken jaw gives none. + . = replacetext(., "Cranial Trauma Detected: Patient will suffer random bouts of mild brain traumas until bone is repaired.", "") + +/datum/wound/blunt/bone/severe/hv_jaw/proc/mumble_speech(datum/source, list/message_args) + SIGNAL_HANDLER + message_args[TREAT_MESSAGE_ARG] = hv_crit_mumble(message_args[TREAT_MESSAGE_ARG]) + +/datum/wound_pregen_data/bone/hv_jaw + abstract = FALSE + wound_path_to_generate = /datum/wound/blunt/bone/severe/hv_jaw + viable_zones = list(BODY_ZONE_HEAD) + can_be_randomly_generated = FALSE + threshold_minimum = 60 + +// --------------------------------------------------------------------------------------------- +// Speech mangling for the throat and jaw. Mirrors tg's speech status effects: decode, walk the +// characters (multi-byte safe), re-sanitize. + +/// Speech through a cut throat: words drown in blood. +/proc/hv_crit_gargle(message) + var/static/list/vowels = list("a", "e", "i", "o", "u") + var/phrase = html_decode(message) + if(!length(phrase)) + return message + var/output = "" + var/char = "" + for(var/i = 1, i <= length(phrase), i += length(char)) + char = phrase[i] + if((LOWER_TEXT(char) in vowels) && prob(40)) + output += pick("gh", "rgl", "hl") + else + output += char + return sanitize(output) + +/// Speech through a broken jaw: the mouth barely moves. +/proc/hv_crit_mumble(message) + var/static/list/hard_sounds = list("t", "d", "s", "z", "k", "g", "c", "j", "x", "q") + var/phrase = html_decode(message) + if(!length(phrase)) + return message + var/output = "" + var/char = "" + for(var/i = 1, i <= length(phrase), i += length(char)) + char = phrase[i] + var/lower = LOWER_TEXT(char) + if((lower in hard_sounds) && prob(60)) + output += (char == lower) ? pick("m", "h", "f") : pick("M", "H", "F") + else + output += char + return sanitize(output) diff --git a/voidcrew/modules/unit_tests/voidcrew_hv_crits.dm b/voidcrew/modules/unit_tests/voidcrew_hv_crits.dm new file mode 100644 index 00000000000..63116ea5ace --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_hv_crits.dm @@ -0,0 +1,206 @@ +// Hyperviolence aimed crits (voidcrew/modules/hyperviolence/crits/). Voidcrew defines are not +// visible from here, so nothing below uses them. + +/// Every crit has a wound tg knows about, that tg's own wound roll can never pick, on the right limb. +/datum/unit_test/voidcrew_hv_crit_wounds_registered/Run() + TEST_ASSERT(length(GLOB.hv_crits) >= 6, "Expected six aimed crits, found [length(GLOB.hv_crits)]") + for(var/datum/hv_crit/crit as anything in GLOB.hv_crits) + TEST_ASSERT(ispath(crit.wound_type, /datum/wound), "[crit.type] has no wound type") + var/datum/wound_pregen_data/pregen = GLOB.all_wound_pregen_data[crit.wound_type] + TEST_ASSERT_NOTNULL(pregen, "[crit.wound_type] has no pregen data") + TEST_ASSERT(!pregen.can_be_randomly_generated, "[crit.wound_type] can come from tg's random wound roll") + TEST_ASSERT(crit.limb_zone in pregen.viable_zones, "[crit.wound_type] cannot go on [crit.limb_zone]") + TEST_ASSERT(ispath(crit.basic_effect, /datum/status_effect), "[crit.type] has nothing for basic mobs") + for(var/zone in crit.aim_zones) + TEST_ASSERT_EQUAL(check_zone(zone), crit.limb_zone, "[crit.type] is aimed at [zone], which is not on [crit.limb_zone]") + +/// The first eye crit takes one eye; the second tears the organ out and blinds. +/datum/unit_test/voidcrew_hv_crit_eye/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/item/spear/spear = allocate(/obj/item/spear) + attacker.put_in_active_hand(spear) + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + var/obj/item/organ/eyes/eyes = victim.get_organ_slot(ORGAN_SLOT_EYES) + TEST_ASSERT_NOTNULL(eyes, "Test human has no eyes") + + var/datum/hv_crit/crit = victim.hv_try_aimed_crit(head, BODY_ZONE_PRECISE_EYES, WOUND_PIERCE, 20, 0, 0, NORTH, spear, attacker, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/eye), "A forced eye crit did not land (got [crit])") + TEST_ASSERT_NOTNULL(head.get_wound_type(/datum/wound/pierce/bleed/severe/eye/hv_gouged), "No torn-out eye wound") + TEST_ASSERT_EQUAL(victim.get_organ_slot(ORGAN_SLOT_EYES), eyes, "The first eye crit took the whole organ") + TEST_ASSERT(eyes.scarring == RIGHT_EYE_SCAR || eyes.scarring == LEFT_EYE_SCAR, "The first eye crit should scar exactly one eye (scarring [eyes.scarring])") + TEST_ASSERT(!victim.is_blind(), "One eye gone should not blind") + + crit = victim.hv_try_aimed_crit(head, BODY_ZONE_PRECISE_EYES, WOUND_SLASH, 20, 0, 0, NORTH, spear, attacker, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/eye), "The second eye crit did not land") + TEST_ASSERT_NULL(victim.get_organ_slot(ORGAN_SLOT_EYES), "The second eye crit left the eyes in") + TEST_ASSERT(isturf(eyes.loc), "The torn-out eyes are not on the floor") + TEST_ASSERT(victim.is_blind(), "No eyes left, but not blind") + +/// Eye cover blocks the eye crit, and a hit aimed elsewhere never causes it. +/datum/unit_test/voidcrew_hv_crit_eye_blocked/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/item/spear/spear = allocate(/obj/item/spear) + attacker.put_in_active_hand(spear) + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + + TEST_ASSERT_NULL(victim.hv_try_aimed_crit(head, BODY_ZONE_CHEST, WOUND_PIERCE, 20, 0, 0, NORTH, spear, attacker, 500), "A chest aim caused a head crit") + TEST_ASSERT_NULL(victim.hv_try_aimed_crit(head, BODY_ZONE_PRECISE_EYES, WOUND_PIERCE, 5, 0, 0, NORTH, spear, attacker, 500), "A hit below the minimum damage caused an eye crit") + + var/obj/item/clothing/glasses/sunglasses/shades = allocate(/obj/item/clothing/glasses/sunglasses) + victim.equip_to_slot_if_possible(shades, ITEM_SLOT_EYES) + TEST_ASSERT(victim.is_eyes_covered(), "Sunglasses did not cover the eyes") + TEST_ASSERT_NULL(victim.hv_try_aimed_crit(head, BODY_ZONE_PRECISE_EYES, WOUND_PIERCE, 20, 0, 0, NORTH, spear, attacker, 500), "Sunglasses did not stop the eye crit") + TEST_ASSERT_NULL(head.get_wound_type(/datum/wound/pierce/bleed/severe/eye/hv_gouged), "Blocked eye crit still left a wound") + +/// A real attack through apply_damage, aimed at the eyes with a spear, lands the crit; the same +/// attack from a basic mob's shot or with the doll on the chest does not. +/datum/unit_test/voidcrew_hv_crit_through_apply_damage/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/item/spear/spear = allocate(/obj/item/spear) + attacker.put_in_active_hand(spear) + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + + // 15 damage rolls 1-44; +90 wound bonus keeps the roll above the eye threshold (80) and below + // tg's outright dismember roll (150), so this is deterministic. + attacker.zone_selected = BODY_ZONE_CHEST + victim.apply_damage(15, BRUTE, head, wound_bonus = 90, sharpness = SHARP_POINTY, attacking_item = spear) + TEST_ASSERT_NULL(head.get_wound_type(/datum/wound/pierce/bleed/severe/eye/hv_gouged), "A chest aim tore an eye out") + + attacker.zone_selected = BODY_ZONE_PRECISE_EYES + victim.apply_damage(15, BRUTE, head, wound_bonus = 90, sharpness = SHARP_POINTY, attacking_item = spear) + TEST_ASSERT_NOTNULL(head.get_wound_type(/datum/wound/pierce/bleed/severe/eye/hv_gouged), "An aimed spear hit through apply_damage did not tear an eye out") + + // Thrown or loose weapons have no one aiming them. + var/mob/living/carbon/human/victim_two = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/head_two = victim_two.get_bodypart(BODY_ZONE_HEAD) + var/obj/item/spear/loose_spear = allocate(/obj/item/spear) + victim_two.apply_damage(15, BRUTE, head_two, wound_bonus = 90, sharpness = SHARP_POINTY, attacking_item = loose_spear) + TEST_ASSERT_NULL(head_two.get_wound_type(/datum/wound/pierce/bleed/severe/eye/hv_gouged), "A weapon nobody held tore an eye out") + +/// Tongue crit: the tongue comes out and lands on the floor. +/datum/unit_test/voidcrew_hv_crit_tongue/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/item/knife/combat/knife = allocate(/obj/item/knife/combat) + attacker.put_in_active_hand(knife) + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + var/obj/item/organ/tongue/tongue = victim.get_organ_slot(ORGAN_SLOT_TONGUE) + TEST_ASSERT_NOTNULL(tongue, "Test human has no tongue") + + var/obj/item/clothing/mask/gas/mask = allocate(/obj/item/clothing/mask/gas) + victim.equip_to_slot_if_possible(mask, ITEM_SLOT_MASK) + TEST_ASSERT_NULL(victim.hv_try_aimed_crit(head, BODY_ZONE_PRECISE_MOUTH, WOUND_SLASH, 20, 0, 0, NORTH, knife, attacker, 500), "A gas mask did not stop the tongue crit") + qdel(mask) + + var/datum/hv_crit/crit = victim.hv_try_aimed_crit(head, BODY_ZONE_PRECISE_MOUTH, WOUND_SLASH, 20, 0, 0, NORTH, knife, attacker, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/tongue), "A forced tongue crit did not land (got [crit])") + TEST_ASSERT_NULL(victim.get_organ_slot(ORGAN_SLOT_TONGUE), "The tongue is still in") + TEST_ASSERT(isturf(tongue.loc), "The tongue is not on the floor") + TEST_ASSERT_NOTNULL(head.get_wound_type(/datum/wound/slash/flesh/moderate/hv_tongue), "No severed tongue wound") + +/// Throat crit: a heavy bleed that gargles speech, and a sealed helmet stops it. +/datum/unit_test/voidcrew_hv_crit_throat/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/item/knife/combat/knife = allocate(/obj/item/knife/combat) + attacker.put_in_active_hand(knife) + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + + var/datum/hv_crit/crit = victim.hv_try_aimed_crit(head, BODY_ZONE_HEAD, WOUND_SLASH, 25, 0, 0, NORTH, knife, attacker, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/throat), "A forced throat crit did not land (got [crit])") + var/datum/wound/slash/flesh/critical/hv_carotid/throat = head.get_wound_type(/datum/wound/slash/flesh/critical/hv_carotid) + TEST_ASSERT_NOTNULL(throat, "No slit throat wound") + TEST_ASSERT(throat.blood_flow >= 4, "The slit throat bleeds only [throat.blood_flow]") + TEST_ASSERT(!throat.clot_rate, "The slit throat clots on its own") + + var/vowels = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + TEST_ASSERT_NOTEQUAL(hv_crit_gargle(vowels), vowels, "Gargled speech came out unchanged") + TEST_ASSERT_NULL(victim.hv_try_aimed_crit(head, BODY_ZONE_HEAD, WOUND_SLASH, 25, 0, 0, NORTH, knife, attacker, 500), "The throat crit landed twice") + + var/mob/living/carbon/human/helmeted = allocate(/mob/living/carbon/human/consistent) + var/obj/item/clothing/head/helmet/space/helmet = allocate(/obj/item/clothing/head/helmet/space) + helmeted.equip_to_slot_if_possible(helmet, ITEM_SLOT_HEAD) + TEST_ASSERT(helmeted.is_mouth_covered(ITEM_SLOT_HEAD), "A space helmet does not cover the mouth") + TEST_ASSERT_NULL(helmeted.hv_try_aimed_crit(helmeted.get_bodypart(BODY_ZONE_HEAD), BODY_ZONE_HEAD, WOUND_SLASH, 25, 0, 0, NORTH, knife, attacker, 500), "A sealed helmet did not stop the throat crit") + +/// Aorta crit from a projectile fired by a carbon, and heavy chest armour stops it. +/datum/unit_test/voidcrew_hv_crit_aorta/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/projectile/bullet/shot = allocate(/obj/projectile/bullet) + shot.firer = attacker + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + + TEST_ASSERT_NULL(victim.hv_try_aimed_crit(chest, BODY_ZONE_CHEST, WOUND_PIERCE, 20, 0, 0, NORTH, shot, attacker, 500), "A shot below the aorta's minimum damage tore it") + var/datum/hv_crit/crit = victim.hv_try_aimed_crit(chest, BODY_ZONE_CHEST, WOUND_PIERCE, 30, 0, 0, NORTH, shot, attacker, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/aorta), "A forced aorta crit did not land (got [crit])") + var/datum/wound/pierce/bleed/critical/hv_aorta/aorta = chest.get_wound_type(/datum/wound/pierce/bleed/critical/hv_aorta) + TEST_ASSERT_NOTNULL(aorta, "No torn aorta wound") + TEST_ASSERT(aorta.blood_flow > 0, "The torn aorta does not bleed") + + var/mob/living/carbon/human/armoured = allocate(/mob/living/carbon/human/consistent) + var/obj/item/clothing/suit/armor/bulletproof/vest = allocate(/obj/item/clothing/suit/armor/bulletproof) + armoured.equip_to_slot_if_possible(vest, ITEM_SLOT_OCLOTHING) + TEST_ASSERT_NULL(armoured.hv_try_aimed_crit(armoured.get_bodypart(BODY_ZONE_CHEST), BODY_ZONE_CHEST, WOUND_PIERCE, 30, 0, 0, NORTH, shot, attacker, 500), "A bulletproof vest did not stop the aorta crit") + +/// Skull crit caves in and hurts the brain; a helmet stops it; on a victim in crit the head bursts. +/datum/unit_test/voidcrew_hv_crit_skull/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/item/storage/toolbox/toolbox = allocate(/obj/item/storage/toolbox) + attacker.put_in_active_hand(toolbox) + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + + var/mob/living/carbon/human/helmeted = allocate(/mob/living/carbon/human/consistent) + var/obj/item/clothing/head/helmet/helmet = allocate(/obj/item/clothing/head/helmet) + helmeted.equip_to_slot_if_possible(helmet, ITEM_SLOT_HEAD) + TEST_ASSERT_NULL(helmeted.hv_try_aimed_crit(helmeted.get_bodypart(BODY_ZONE_HEAD), BODY_ZONE_HEAD, WOUND_BLUNT, 25, 0, 0, NORTH, toolbox, attacker, 500), "A helmet did not stop the skull crit") + + var/brain_before = victim.get_organ_loss(ORGAN_SLOT_BRAIN) + var/datum/hv_crit/crit = victim.hv_try_aimed_crit(head, BODY_ZONE_HEAD, WOUND_BLUNT, 25, 0, 0, NORTH, toolbox, attacker, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/skull), "A forced skull crit did not land (got [crit])") + TEST_ASSERT_NOTNULL(head.get_wound_type(/datum/wound/blunt/bone/critical/hv_skull), "No caved-in skull wound") + TEST_ASSERT(victim.get_organ_loss(ORGAN_SLOT_BRAIN) > brain_before, "The skull caved in without hurting the brain") + + var/mob/living/carbon/human/dying = allocate(/mob/living/carbon/human/consistent) + dying.set_stat(SOFT_CRIT) + crit = dying.hv_try_aimed_crit(dying.get_bodypart(BODY_ZONE_HEAD), BODY_ZONE_HEAD, WOUND_BLUNT, 25, 0, 0, NORTH, toolbox, attacker, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/skull), "A skull crit on a victim in crit did not land") + TEST_ASSERT_NULL(dying.get_bodypart(BODY_ZONE_HEAD), "A skull crit on a victim in crit did not burst the head") + +/// Jaw crit: a broken jaw that mumbles speech and gives no brain traumas. +/datum/unit_test/voidcrew_hv_crit_jaw/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/item/storage/toolbox/toolbox = allocate(/obj/item/storage/toolbox) + attacker.put_in_active_hand(toolbox) + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + + var/datum/hv_crit/crit = victim.hv_try_aimed_crit(head, BODY_ZONE_PRECISE_MOUTH, WOUND_BLUNT, 20, 0, 0, NORTH, toolbox, attacker, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/jaw), "A forced jaw crit did not land (got [crit])") + var/datum/wound/blunt/bone/severe/hv_jaw/jaw = head.get_wound_type(/datum/wound/blunt/bone/severe/hv_jaw) + TEST_ASSERT_NOTNULL(jaw, "No shattered jaw wound") + TEST_ASSERT_NULL(jaw.active_trauma, "A broken jaw gave a brain trauma") + var/sounds = "tttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt" + TEST_ASSERT_NOTEQUAL(hv_crit_mumble(sounds), sounds, "Mumbled speech came out unchanged") + +/// Humanoid basic mobs take crits as status effects, once each. +/datum/unit_test/voidcrew_hv_crit_basic_mob/Run() + var/mob/living/basic/trooper/pirate/melee/pirate = allocate(/mob/living/basic/trooper/pirate/melee) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/item/knife/combat/knife = allocate(/obj/item/knife/combat) + attacker.put_in_active_hand(knife) + var/health_before = pirate.health + + var/datum/hv_crit/crit = pirate.hv_try_basic_crit(BODY_ZONE_HEAD, BODY_ZONE_HEAD, WOUND_SLASH, 25, 0, NORTH, knife, attacker, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/throat), "A forced throat crit on a pirate did not land (got [crit])") + TEST_ASSERT_NOTNULL(pirate.has_status_effect(/datum/status_effect/hv_crit_basic/bleed/throat), "The pirate is not bleeding from the throat") + TEST_ASSERT(pirate.health < health_before, "The throat crit did not hurt the pirate") + TEST_ASSERT_NULL(pirate.hv_try_basic_crit(BODY_ZONE_HEAD, BODY_ZONE_HEAD, WOUND_SLASH, 25, 0, NORTH, knife, attacker, 500), "The throat crit landed twice on the pirate") + + crit = pirate.hv_try_basic_crit(BODY_ZONE_HEAD, BODY_ZONE_PRECISE_MOUTH, WOUND_SLASH, 25, 0, NORTH, knife, attacker, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/tongue), "A forced tongue crit on a pirate did not land (got [crit])") + TEST_ASSERT(HAS_TRAIT(pirate, TRAIT_MUTE), "A pirate without a tongue can still talk") From d3b2af2da0bf0bc32c528dc8e4c11368f12ba794 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:33:04 -0800 Subject: [PATCH 012/472] Add hyperviolence blood unit tests; keep sprays out of bloodcrawl; rate-limit drench message Spray art from Azure Peak (Azure-Peak/Azure-Peak @ 023a346, CC BY-SA 3.0); floating globules recoloured from Paradise (ParadiseSS13/Paradise @ d2a2bb03d588, CC BY-SA 3.0). --- .../hyperviolence/blood/blood_spray.dm | 12 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../voidcrew_hyperviolence_blood.dm | 236 ++++++++++++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 voidcrew/modules/unit_tests/voidcrew_hyperviolence_blood.dm diff --git a/voidcrew/modules/hyperviolence/blood/blood_spray.dm b/voidcrew/modules/hyperviolence/blood/blood_spray.dm index 651f75b352c..c0aa18f4d7d 100644 --- a/voidcrew/modules/hyperviolence/blood/blood_spray.dm +++ b/voidcrew/modules/hyperviolence/blood/blood_spray.dm @@ -18,6 +18,8 @@ /// Direction of the last brute hit this mob took, and when. var/hv_blood_hit_dir = 0 var/hv_blood_hit_time = -100000 + /// World time before which this mob is not told again that blood spattered across it. + var/hv_blood_drench_until = 0 /mob/living/Initialize(mapload) . = ..() @@ -44,6 +46,10 @@ /obj/effect/decal/cleanable/blood/hv_spray/proc/hv_aim(spray_dir) setDir(spray_dir) +// Too thin to dive into. +/obj/effect/decal/cleanable/blood/hv_spray/can_bloodcrawl_in() + return FALSE + /// A thin streak of blood along the floor, the part of the line before the final splash. /obj/effect/decal/cleanable/blood/hv_spray/streak name = "blood streak" @@ -89,7 +95,8 @@ /// Lays one spray decal on a floor tile, or turns the one already there. Returns it. /proc/hv_blood_lay_spray(turf/target, decal_type, spray_dir, list/blood_dna, list/viruses) for(var/obj/effect/decal/cleanable/blood/hv_spray/existing in target) - if(existing.type != decal_type || existing.dried || QDELETED(existing)) + // A tile holds one floor spray, whatever shape it came in; wall smears are kept apart. + if(istype(existing, /obj/effect/decal/cleanable/blood/hv_spray/wall) || existing.dried || QDELETED(existing)) continue existing.add_blood_DNA(blood_dna, viruses) existing.hv_aim(spray_dir) @@ -178,6 +185,9 @@ if(QDELETED(src) || !length(blood_dna)) return add_blood_DNA(blood_dna, viruses) + if(world.time < hv_blood_drench_until) + return + hv_blood_drench_until = world.time + 3 SECONDS to_chat(src, span_warning("Blood spatters across you.")) /// Big brute hits throw blood the way they travelled. diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..71e691ce996 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -90,6 +90,7 @@ #include "voidcrew_helpers.dm" #include "voidcrew_hull_containment.dm" #include "voidcrew_hull_survey.dm" +#include "voidcrew_hyperviolence_blood.dm" #include "voidcrew_launch_access.dm" #include "voidcrew_launch_cargo.dm" #include "voidcrew_launch_fabrication.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_blood.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_blood.dm new file mode 100644 index 00000000000..76707cafcce --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_blood.dm @@ -0,0 +1,236 @@ +// Hyperviolence blood part: directional spray, growing pools, zero-G floating blood. +// Test files compile before voidcrew\_DEFINES and the module's own defines, so the icon paths and +// the count of floating states are spelled out here. +// The test room is five floor tiles square with walls along its west and south edges; the +// bottom-left floor tile has a wall to its west and to its south. + +/// The art the blood part draws from has every state the code asks for. +/datum/unit_test/hv_blood_icons/Run() + var/list/spray_states = icon_states('voidcrew/modules/hyperviolence/blood/icons/blood_spray.dmi') + for(var/state in list("squirt_greyscale", "splatter_greyscale", "splatterbloodalt_greyscale")) + TEST_ASSERT(state in spray_states, "blood_spray.dmi has no [state] state") + var/list/pool_states = icon_states('voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi') + for(var/tier in 1 to length(GLOB.hv_blood_pool_thresholds)) + for(var/variant in 1 to 3) + TEST_ASSERT("pool[tier]_[variant]" in pool_states, "blood_pool.dmi has no pool[tier]_[variant] state") + var/list/float_states = icon_states('voidcrew/modules/hyperviolence/blood/icons/blood_weightless.dmi') + for(var/variant in 1 to 12) + TEST_ASSERT("float[variant]" in float_states, "blood_weightless.dmi has no float[variant] state") + +/// One spray line: a streak then a splash along the floor, blood DNA on both, one throw per window, +/// and a second throw over the same tiles turns the decals instead of stacking new ones. +/datum/unit_test/hv_blood_spray_line/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/turf/streak_turf = get_step(run_loc_floor_bottom_left, EAST) + var/turf/splash_turf = get_step(streak_turf, EAST) + + TEST_ASSERT(victim.hv_blood_spray(EAST, 2), "A bleeder could not spray") + var/obj/effect/decal/cleanable/blood/hv_spray/streak/streak = locate() in streak_turf + var/obj/effect/decal/cleanable/blood/hv_spray/splash + for(var/obj/effect/decal/cleanable/blood/hv_spray/candidate in splash_turf) + if(candidate.type == /obj/effect/decal/cleanable/blood/hv_spray) + splash = candidate + TEST_ASSERT_NOTNULL(streak, "The spray left no streak on the first tile") + TEST_ASSERT_NOTNULL(splash, "The spray left no splash on the last tile") + TEST_ASSERT_EQUAL(streak.dir, EAST, "The streak does not face the way the blood flew") + TEST_ASSERT_EQUAL(splash.dir, EAST, "The splash does not face the way the blood flew") + var/list/victim_dna = victim.get_blood_dna_list() + for(var/dna_key in victim_dna) + TEST_ASSERT(dna_key in GET_ATOM_BLOOD_DNA(streak), "The streak carries no DNA from the bleeder") + TEST_ASSERT(dna_key in GET_ATOM_BLOOD_DNA(splash), "The splash carries no DNA from the bleeder") + TEST_ASSERT(!isnull(streak.color), "The streak was not tinted") + TEST_ASSERT(!streak.can_bloodcrawl_in(), "A thin streak of blood can be crawled into") + + TEST_ASSERT(!victim.hv_blood_spray(EAST, 2), "A mob sprayed twice inside one window") + TEST_ASSERT(victim.hv_blood_spray(EAST, 4), "A stronger spray inside the window was refused") + victim.hv_blood_spray_until = 0 + TEST_ASSERT(victim.hv_blood_spray(EAST, 2), "A spray after the window was refused") + var/floor_sprays = 0 + for(var/obj/effect/decal/cleanable/blood/hv_spray/found in streak_turf) + if(!istype(found, /obj/effect/decal/cleanable/blood/hv_spray/wall)) + floor_sprays++ + TEST_ASSERT_EQUAL(floor_sprays, 1, "Repeat sprays stacked decals on one tile") + floor_sprays = 0 + for(var/obj/effect/decal/cleanable/blood/hv_spray/found in splash_turf) + if(!istype(found, /obj/effect/decal/cleanable/blood/hv_spray/wall)) + floor_sprays++ + TEST_ASSERT_EQUAL(floor_sprays, 1, "Repeat sprays of different strength stacked decals on one tile") + +/// A wall in the way takes the splash, drawn from the tile in front of it. +/datum/unit_test/hv_blood_spray_wall/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/turf/start = run_loc_floor_bottom_left + TEST_ASSERT(victim.hv_blood_spray(WEST, 2), "A bleeder with a wall at its back could not spray") + var/obj/effect/decal/cleanable/blood/hv_spray/wall/smear = locate() in start + TEST_ASSERT_NOTNULL(smear, "Nothing was left on the wall") + TEST_ASSERT_EQUAL(smear.pixel_x, -32, "The smear is not drawn onto the wall to the west") + TEST_ASSERT_EQUAL(smear.pixel_y, 0, "The smear is offset on the wrong axis") + TEST_ASSERT_EQUAL(smear.dir, WEST, "The smear does not face the wall") + TEST_ASSERT_NULL(locate(/obj/effect/decal/cleanable/blood/hv_spray/streak) in start, "A floor streak was laid where the wall is") + +/// Anyone standing in the line is drenched and stops it. +/datum/unit_test/hv_blood_spray_drench/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/turf/streak_turf = get_step(run_loc_floor_bottom_left, EAST) + var/turf/bystander_turf = get_step(streak_turf, EAST) + var/turf/beyond_turf = get_step(bystander_turf, EAST) + var/mob/living/carbon/human/bystander = allocate(/mob/living/carbon/human/consistent, bystander_turf) + TEST_ASSERT_EQUAL(GET_ATOM_BLOOD_DNA_LENGTH(bystander), 0, "The bystander started out bloody") + victim.hv_blood_spray(EAST, 4) + TEST_ASSERT(GET_ATOM_BLOOD_DNA_LENGTH(bystander) > 0, "The bystander in the line was not drenched") + TEST_ASSERT_NULL(locate(/obj/effect/decal/cleanable/blood/hv_spray) in beyond_turf, "The spray went through the bystander") + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/hv_spray/streak) in streak_turf, "The spray left no streak before the bystander") + +/// Big brute hits spray, small ones do not, and a hit tg already sprayed for is not sprayed twice. +/datum/unit_test/hv_blood_heavy_hit/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/turf/east = get_step(run_loc_floor_bottom_left, EAST) + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + + // The damage signal is wired up on every mob, and remembers which way a brute hit travelled. + victim.apply_damage(5, BRUTE, BODY_ZONE_CHEST, attack_direction = EAST) + TEST_ASSERT_EQUAL(victim.hv_blood_hit_dir, EAST, "The damage signal did not reach the blood part") + TEST_ASSERT_NULL(locate(/obj/effect/decal/cleanable/blood/hv_spray) in east, "A light hit sprayed blood") + + // The rest calls the handler directly, so wounds from a real hit cannot get in the way. + victim.hv_blood_tg_spray_tick = world.time + victim.hv_blood_on_damaged(victim, 40, BRUTE, chest, 0, 0, 0, NONE, EAST, null, TRUE) + TEST_ASSERT_NULL(locate(/obj/effect/decal/cleanable/blood/hv_spray) in east, "A hit sprayed on top of tg's own spray") + + victim.hv_blood_tg_spray_tick = -1 + victim.hv_blood_on_damaged(victim, 14, BURN, chest, 0, 0, 0, NONE, EAST, null, TRUE) + TEST_ASSERT_NULL(locate(/obj/effect/decal/cleanable/blood/hv_spray) in east, "Burn damage sprayed blood") + victim.hv_blood_on_damaged(victim, 40, BRUTE, chest, 0, 0, 0, NONE, null, null, TRUE) + TEST_ASSERT_NULL(locate(/obj/effect/decal/cleanable/blood/hv_spray) in east, "A hit with no direction sprayed blood") + + victim.hv_blood_on_damaged(victim, 40, BRUTE, chest, 0, 0, 0, NONE, EAST, null, TRUE) + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/hv_spray) in east, "A heavy hit did not spray") + +/// A limb torn off throws blood the way the blow that took it travelled. +/datum/unit_test/hv_blood_dismember_spray/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/turf/east = get_step(run_loc_floor_bottom_left, EAST) + victim.hv_blood_hit_dir = EAST + victim.hv_blood_hit_time = world.time + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_L_ARM) + TEST_ASSERT_NOTNULL(arm, "The test human has no left arm") + TEST_ASSERT(arm.dismember(), "The arm would not come off") + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/hv_spray) in east, "A torn-off limb threw no blood") + +/// Splatters step up to bigger pools as they fill, one decal per tile, and never step back down. +/datum/unit_test/hv_blood_pool_growth/Run() + var/turf/spot = run_loc_floor_bottom_left + var/obj/effect/decal/cleanable/blood/pool = allocate(/obj/effect/decal/cleanable/blood, spot) + TEST_ASSERT_EQUAL(pool.hv_pool_tier, 0, "A fresh splatter was already a pool") + var/expected_tier = 0 + for(var/step in list(45, 50, 60, 60)) + pool.adjust_bloodiness(step) + expected_tier++ + TEST_ASSERT_EQUAL(pool.hv_pool_tier, expected_tier, "Bloodiness [pool.bloodiness] did not make a tier [expected_tier] pool") + TEST_ASSERT_EQUAL(pool.icon, 'voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi', "A tier [expected_tier] pool does not use the pool art") + TEST_ASSERT(findtext(pool.icon_state, "pool[expected_tier]_") == 1, "A tier [expected_tier] pool shows [pool.icon_state]") + pool.adjust_bloodiness(-150) + TEST_ASSERT_EQUAL(pool.hv_pool_tier, 4, "A pool shrank when it lost blood") + var/pools = 0 + for(var/obj/effect/decal/cleanable/blood/found in spot) + pools++ + TEST_ASSERT_EQUAL(pools, 1, "The pool is not the only blood on its tile") + +/// A bleeder who stays put adds to the splatter under them; one who moves does not. +/datum/unit_test/hv_blood_pool_feed/Run() + var/turf/spot = run_loc_floor_bottom_left + var/obj/effect/decal/cleanable/blood/pool = allocate(/obj/effect/decal/cleanable/blood, spot) + var/mob/living/carbon/human/bleeder = allocate(/mob/living/carbon/human/consistent, spot) + var/before = pool.bloodiness + bleeder.bleed(10) + TEST_ASSERT_EQUAL(pool.bloodiness, before, "A bleeder's first bleed on a tile fed the pool") + bleeder.bleed(10) + TEST_ASSERT_EQUAL(pool.bloodiness, before + 10, "A standing bleeder did not feed the pool") + bleeder.forceMove(get_step(spot, EAST)) + bleeder.bleed(10) + TEST_ASSERT_EQUAL(pool.bloodiness, before + 10, "A bleeder who had moved fed the old pool") + +/// Blood where nothing pulls it down hangs in the air, does not dry, and falls when gravity returns. +/datum/unit_test/hv_blood_weightless + var/turf/weightless_turf + +/datum/unit_test/hv_blood_weightless/Destroy() + if(weightless_turf) + weightless_turf.force_no_gravity = FALSE + return ..() + +/datum/unit_test/hv_blood_weightless/Run() + weightless_turf = run_loc_floor_bottom_left + weightless_turf.force_no_gravity = TRUE + var/obj/effect/decal/cleanable/blood/bead = allocate(/obj/effect/decal/cleanable/blood, weightless_turf) + sleep(0.5 SECONDS) + TEST_ASSERT(bead.hv_floating, "Blood in no gravity did not float") + TEST_ASSERT(bead in GLOB.hv_floating_blood, "Floating blood is not tracked") + TEST_ASSERT(!bead.can_dry, "Floating blood can dry") + TEST_ASSERT(!(bead.datum_flags & DF_ISPROCESSING), "Floating blood is still drying out") + TEST_ASSERT_EQUAL(bead.icon, 'voidcrew/modules/hyperviolence/blood/icons/blood_weightless.dmi', "Floating blood does not use the floating art") + TEST_ASSERT(!bead.can_bloodcrawl_in(), "Floating blood can be crawled into") + + // Nothing has changed, so nothing falls. + hv_blood_recheck_floating() + TEST_ASSERT(bead.hv_floating, "Blood fell with no gravity to pull it down") + + weightless_turf.force_no_gravity = FALSE + hv_blood_recheck_floating() + TEST_ASSERT(!bead.hv_floating, "Blood did not fall when gravity came back") + TEST_ASSERT(!(bead in GLOB.hv_floating_blood), "Fallen blood is still tracked as floating") + TEST_ASSERT(bead.can_dry, "Fallen blood cannot dry") + TEST_ASSERT(bead.datum_flags & DF_ISPROCESSING, "Fallen blood is not drying out") + TEST_ASSERT_EQUAL(bead.icon, initial(bead.icon), "Fallen blood still shows the floating art") + TEST_ASSERT_EQUAL(bead.pixel_y, 0, "Fallen blood is still raised") + +/// Floating blood does not run into blood already lying there, and joins it when it falls. +/datum/unit_test/hv_blood_weightless_merge + var/turf/weightless_turf + +/datum/unit_test/hv_blood_weightless_merge/Destroy() + if(weightless_turf) + weightless_turf.force_no_gravity = FALSE + return ..() + +/datum/unit_test/hv_blood_weightless_merge/Run() + weightless_turf = run_loc_floor_bottom_left + var/obj/effect/decal/cleanable/blood/lying = allocate(/obj/effect/decal/cleanable/blood, weightless_turf) + var/before = lying.bloodiness + weightless_turf.force_no_gravity = TRUE + var/obj/effect/decal/cleanable/blood/bead = allocate(/obj/effect/decal/cleanable/blood, weightless_turf) + TEST_ASSERT(!QDELETED(bead), "Floating blood merged into blood lying on the floor") + sleep(0.5 SECONDS) + TEST_ASSERT(bead.hv_floating, "New blood in no gravity did not float over old blood") + TEST_ASSERT(!lying.hv_floating, "Blood already on the floor lifted off") + + weightless_turf.force_no_gravity = FALSE + hv_blood_recheck_floating() + TEST_ASSERT(QDELETED(bead), "Fallen blood did not join the blood already there") + TEST_ASSERT_EQUAL(lying.bloodiness, before + BLOOD_AMOUNT_PER_DECAL, "The blood already there did not take on the fallen blood") + +/// In no gravity a bleed leaves floating beads rather than a drip, and a mob getting gravity back lets them fall. +/datum/unit_test/hv_blood_weightless_bleed + var/turf/weightless_turf + +/datum/unit_test/hv_blood_weightless_bleed/Destroy() + if(weightless_turf) + weightless_turf.force_no_gravity = FALSE + return ..() + +/datum/unit_test/hv_blood_weightless_bleed/Run() + weightless_turf = run_loc_floor_bottom_left + weightless_turf.force_no_gravity = TRUE + var/mob/living/carbon/human/bleeder = allocate(/mob/living/carbon/human/consistent, weightless_turf) + var/datum/blood_type/blood_type = bleeder.get_bloodtype() + var/obj/effect/decal/cleanable/blood/left_behind = blood_type.make_blood_splatter(bleeder, weightless_turf, TRUE) + TEST_ASSERT_NOTNULL(left_behind, "A bleed in no gravity left nothing behind") + TEST_ASSERT(!istype(left_behind, /obj/effect/decal/cleanable/blood/drip), "A bleed in no gravity left a drip with nothing to fall onto") + sleep(0.5 SECONDS) + TEST_ASSERT(left_behind.hv_floating, "Blood from a bleed in no gravity did not float") + + // Gravity returns; the mob standing in it is what announces that. + weightless_turf.force_no_gravity = FALSE + bleeder.refresh_gravity() + sleep(0.5 SECONDS) + TEST_ASSERT(!left_behind.hv_floating, "Floating blood did not fall when a mob's gravity came back") From 3d3d3719319662195ffa503815a944ef06f31503 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:34:28 -0800 Subject: [PATCH 013/472] Add hyperviolence brain burst, gut spill and gut tether Brain burst: hv_burst_head now bursts the head into gibs and a splatter and throws the brain, undamaged and still holding the mind, one to three tiles along the blow, never over a hole or lava. Eyes, ears and tongue scatter. A heavy crushing or piercing blow on a head that is already half destroyed on a dead or hard-crit victim bursts it instead of knocking it off whole, and so can a heavy explosion on a downed victim. Gut spill: a big slash to the chest, or a heavy explosion, can replace the chest cut with a Disembowelment wound (a critical slash). The intestines drop on the floor as a new item; at the worst level the stomach and liver slip out too. It bleeds until the guts are pushed back in, which leaves the ordinary severe cut. Gut tether: while the guts lie on the floor a strand joins them to the victim. Walking too far drags the guts along and hurts, and a sharp item on the guts cuts the strand once, which drops the victim. Ideas from Septic Shock's spilling wounds and Vanderlin's gut rope (Monkestation/Vanderlin 1fc0bf8). The code is written for tg's wound and beam datums, and the intestines and strand sprites are drawn for this module; no third-party art or sound is used. --- tgstation.dme | 5 + voidcrew/_DEFINES/hyperviolence.dm | 38 +++ voidcrew/modules/hyperviolence/_core.dm | 16 +- .../hyperviolence/braingut/blast_gore.dm | 18 ++ .../hyperviolence/braingut/brain_burst.dm | 140 +++++++++++ .../hyperviolence/braingut/gut_spill.dm | 226 ++++++++++++++++++ .../hyperviolence/braingut/gut_tether.dm | 152 ++++++++++++ .../braingut/icons/gore_guts.dmi | Bin 0 -> 877 bytes .../hyperviolence/braingut/intestines.dm | 25 ++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../voidcrew_hyperviolence_braingut.dm | 225 +++++++++++++++++ 11 files changed, 833 insertions(+), 13 deletions(-) create mode 100644 voidcrew/modules/hyperviolence/braingut/blast_gore.dm create mode 100644 voidcrew/modules/hyperviolence/braingut/brain_burst.dm create mode 100644 voidcrew/modules/hyperviolence/braingut/gut_spill.dm create mode 100644 voidcrew/modules/hyperviolence/braingut/gut_tether.dm create mode 100644 voidcrew/modules/hyperviolence/braingut/icons/gore_guts.dmi create mode 100644 voidcrew/modules/hyperviolence/braingut/intestines.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm diff --git a/tgstation.dme b/tgstation.dme index 2f7df14adc8..7dda8c4c2a1 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7230,6 +7230,11 @@ #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" #include "voidcrew\modules\hyperviolence\_core.dm" +#include "voidcrew\modules\hyperviolence\braingut\blast_gore.dm" +#include "voidcrew\modules\hyperviolence\braingut\brain_burst.dm" +#include "voidcrew\modules\hyperviolence\braingut\gut_spill.dm" +#include "voidcrew\modules\hyperviolence\braingut\gut_tether.dm" +#include "voidcrew\modules\hyperviolence\braingut\intestines.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" #include "voidcrew\modules\intents\hud_intent.dm" diff --git a/voidcrew/_DEFINES/hyperviolence.dm b/voidcrew/_DEFINES/hyperviolence.dm index f0e0c2135e3..8ef71ca719c 100644 --- a/voidcrew/_DEFINES/hyperviolence.dm +++ b/voidcrew/_DEFINES/hyperviolence.dm @@ -4,3 +4,41 @@ #define HV_LINE_BOLD 1 /// A big bold-italic line, for severed limbs, burst heads and kills. #define HV_LINE_HUGE 2 + +// Brain burst, gut spill and gut tether (voidcrew/modules/hyperviolence/braingut/). + +/// A blow must deal at least this much (after armour) before it can burst a downed victim's head instead of knocking it off whole. +#define HV_HEAD_BURST_MIN_DAMAGE 30 +/// The head must already be this far gone (fraction of its damage limit) before a blow can burst it. +#define HV_HEAD_BURST_MIN_HEAD_DAMAGE 0.5 +/// A burst head throws its brain at least this many tiles along the blow. +#define HV_BRAIN_FLING_MIN 1 +/// A burst head throws its brain at most this many tiles along the blow. +#define HV_BRAIN_FLING_MAX 3 +/// How many gib decals a burst head leaves, besides the core. +#define HV_HEAD_BURST_GIBS 4 +/// Percent chance that a heavy explosion bursts the head of a victim who is dead or in hard crit. +#define HV_HEAD_BURST_EXPLOSION_CHANCE 10 + +/// A slash must deal at least this much (after armour) to the chest to be able to spill guts. +#define HV_GUTSPILL_MIN_DAMAGE 25 +/// A victim who is still up needs the chest this far gone (fraction of its damage limit) before a slash can spill guts. +#define HV_GUTSPILL_MIN_CHEST_DAMAGE 0.25 +/// Percent chance that a qualifying slash spills guts. +#define HV_GUTSPILL_CHANCE 12 +/// Percent chance that a qualifying slash spills guts on a victim who is dead or in hard crit. +#define HV_GUTSPILL_CHANCE_DOWNED 35 +/// Percent chance that a heavy explosion spills guts. +#define HV_GUTSPILL_EXPLOSION_CHANCE 15 +/// Percent chance that a heavy explosion spills guts on a victim who is dead or in hard crit. +#define HV_GUTSPILL_EXPLOSION_CHANCE_DOWNED 25 +/// The chest this far gone (fraction of its damage limit) when guts spill also lets the stomach and liver slip out. +#define HV_GUTSPILL_ORGAN_CHEST_DAMAGE 0.5 +/// How far the guts can lie from their owner before the owner is dragged against them. +#define HV_GUT_TETHER_RANGE 3 +/// How far apart the guts and their owner can get before the tether tears. +#define HV_GUT_TETHER_SNAP_RANGE 7 +/// Minimum time between the pain of two tugs on the tether. +#define HV_GUT_YANK_COOLDOWN (2 SECONDS) +/// Brute damage to the chest from one tug on the tether. +#define HV_GUT_YANK_DAMAGE 4 diff --git a/voidcrew/modules/hyperviolence/_core.dm b/voidcrew/modules/hyperviolence/_core.dm index 0fd61071f56..c2c99d1d9d3 100644 --- a/voidcrew/modules/hyperviolence/_core.dm +++ b/voidcrew/modules/hyperviolence/_core.dm @@ -17,18 +17,8 @@ /mob/living/carbon/hv_blood_spray(spray_dir, strength = 3) spray_blood(spray_dir || pick(GLOB.alldirs), strength) -/// Bursts src's head: it comes off in pieces rather than dropping whole. burst_dir is the way the -/// shot or blow travelled. The brain-and-guts part replaces this to fling the brain out intact. +/// Bursts src's head: it comes apart in gibs and the brain is thrown out intact. burst_dir is the way +/// the shot or blow travelled. The body is in braingut/brain_burst.dm. /// Returns TRUE if the head was burst. /mob/living/carbon/proc/hv_burst_head(burst_dir) - var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) - if(!head || !head.can_dismember()) - return FALSE - hv_blood_spray(burst_dir, 4) - if(!head.dismember(BRUTE, silent = TRUE)) - return FALSE - head.drop_organs(null, TRUE) - qdel(head) - new /obj/effect/gibspawner/generic(get_turf(src), src) - hv_add_hit_line("[src]'s head bursts apart!", HV_LINE_HUGE) - return TRUE + return hv_burst_head_apart(burst_dir) diff --git a/voidcrew/modules/hyperviolence/braingut/blast_gore.dm b/voidcrew/modules/hyperviolence/braingut/blast_gore.dm new file mode 100644 index 00000000000..110eec3af00 --- /dev/null +++ b/voidcrew/modules/hyperviolence/braingut/blast_gore.dm @@ -0,0 +1,18 @@ +// Blast gore (hyperviolence, braingut part): a heavy explosion can burst the head of a victim who is +// already down, and can blow open the belly of any victim. Both roll against the victim's bomb armour. + +/mob/living/carbon/human/ex_act(severity, target, origin) + . = ..() + if(!. || severity != EXPLODE_HEAVY || QDELETED(src)) + return + var/downed = stat >= HARD_CRIT + if(downed && prob(HV_HEAD_BURST_EXPLOSION_CHANCE) && !prob(getarmor(get_bodypart(BODY_ZONE_HEAD), BOMB))) + hv_burst_head() + var/obj/item/bodypart/chest/chest = get_bodypart(BODY_ZONE_CHEST) + if(!chest || !chest.hv_chest_can_spill()) + return + if(!prob(downed ? HV_GUTSPILL_EXPLOSION_CHANCE_DOWNED : HV_GUTSPILL_EXPLOSION_CHANCE)) + return + if(prob(getarmor(chest, BOMB))) + return + chest.hv_spill_guts(null, "explosion") diff --git a/voidcrew/modules/hyperviolence/braingut/brain_burst.dm b/voidcrew/modules/hyperviolence/braingut/brain_burst.dm new file mode 100644 index 00000000000..e8bf2ea86fc --- /dev/null +++ b/voidcrew/modules/hyperviolence/braingut/brain_burst.dm @@ -0,0 +1,140 @@ +// Brain burst (hyperviolence, braingut part): a head that bursts rather than coming off whole. It +// comes apart in gibs and the brain is thrown out intact, still holding its owner's mind, so the +// victim can be revived later like any other loose brain. +// The idea is Septic Shock's brain spill (modular_septic wounds/organic/spilling.dm). Nothing of its +// code, art or sound is used: the burst is built from tg's gibs, blood splatter and throwing. + +/obj/item/bodypart/head + /// Set while a blow that is allowed to burst this head instead of knocking it off is being resolved. + var/hv_burst_pending = FALSE + /// Direction of the blow being resolved, from the attacker toward this head. + var/hv_blow_dir + +/// Bursts the head: gibs, a spray of blood, a burst of splatter, and the brain thrown intact along burst_dir. +/// announce is off when the caller has already said what happened. Called by hv_burst_head() in _core.dm. +/// Returns TRUE if the head burst. +/mob/living/carbon/proc/hv_burst_head_apart(burst_dir, announce = TRUE) + if(HAS_TRAIT(src, TRAIT_GODMODE)) + return FALSE + var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) + if(!head || !head.can_dismember()) + return FALSE + if(!burst_dir) + burst_dir = pick(GLOB.alldirs) + var/turf/here = get_turf(src) + var/organic = IS_ORGANIC_LIMB(head) + var/bleeds = head.can_bleed() + var/list/blood_dna = bleeds ? get_blood_dna_list() : null + var/blood_color = bleeds ? get_bloodtype()?.get_color() : null + // everything inside the head that can come out: brain, eyes, ears, tongue, and whatever was implanted + var/list/obj/item/organ/loose = list() + for(var/obj/item/organ/organ in head) + if(!(organ.organ_flags & ORGAN_UNREMOVABLE)) + loose += organ + + hv_blood_spray(burst_dir, 4) + if(!head.dismember(BRUTE, silent = TRUE)) + return FALSE + // no violent_removal: that would damage the brain, and it has to stay good for revival + head.drop_organs() + qdel(head) + + if(isturf(here)) + if(!organic) + new /obj/effect/gibspawner/robot(here, src) + else if(bleeds) + hv_spawn_head_gibs(here, burst_dir, blood_dna) + hv_head_burst_splatter(here, burst_dir, blood_color) + playsound(here, 'sound/effects/wounds/splatter.ogg', 80, TRUE) + + for(var/obj/item/organ/organ as anything in loose) + if(QDELETED(organ) || !isturf(organ.loc)) + continue + if(istype(organ, /obj/item/organ/brain)) + var/datum/callback/landed = blood_dna ? CALLBACK(organ, TYPE_PROC_REF(/obj/item/organ, hv_landed), blood_dna) : null + hv_fling_organ(organ, burst_dir, HV_BRAIN_FLING_MIN, HV_BRAIN_FLING_MAX, landed) + else + hv_fling_organ(organ, turn(burst_dir, pick(-45, 0, 45)), 1, 2) + + if(announce) + hv_add_hit_line(pick("[src]'s head bursts apart!", "[src]'s skull bursts open!"), HV_LINE_HUGE, range = DEFAULT_MESSAGE_RANGE) + return TRUE + +/// Throws a loose organ min_dist to max_dist tiles along fling_dir. It lands as an ordinary item. +/// It never goes over a hole or lava: a brain lost in either is a player lost for good, so the throw stops short. +/proc/hv_fling_organ(obj/item/organ/organ, fling_dir, min_dist, max_dist, datum/callback/on_landed) + var/turf/start = get_turf(organ) + if(!start || isgroundlessturf(start)) + return + var/distance = 0 + var/turf/ahead = start + for(var/tile in 1 to rand(min_dist, max_dist)) + ahead = get_step(ahead, fling_dir) + if(!ahead || isgroundlessturf(ahead) || islava(ahead)) + break + distance = tile + if(!distance) + return + organ.throw_at(get_ranged_target_turf(start, fling_dir, distance), distance, 2, callback = on_landed) + +/// A thrown organ has come down: it hits the floor with a wet sound and leaves a smear of the victim's blood. +/obj/item/organ/proc/hv_landed(list/blood_dna) + if(QDELETED(src) || !isturf(loc)) + return + playsound(src, 'sound/misc/moist_impact.ogg', 50, TRUE) + new /obj/effect/decal/cleanable/blood/splatter(loc, null, blood_dna) + +/// Chunks of head thrown across the burst's cone, and a core, using tg's own gib decals so they merge and clean up like any others. +/proc/hv_spawn_head_gibs(turf/where, burst_dir, list/blood_dna) +#ifndef UNIT_TESTS + var/list/cone = list(burst_dir, turn(burst_dir, 45), turn(burst_dir, -45)) +#endif + for(var/i in 1 to HV_HEAD_BURST_GIBS) +// These might streak off into space and cause annoying flaky failures with mapping nearstation tests +#ifndef UNIT_TESTS + var/obj/effect/decal/cleanable/blood/gibs/gib = new(where, null, blood_dna) + gib.streak(cone) +#else + new /obj/effect/decal/cleanable/blood/gibs(where, null, blood_dna) +#endif + new /obj/effect/decal/cleanable/blood/gibs/core(where, null, blood_dna) + +/// The short burst itself: blood and a pale smear of brain thrown out of the head along the blow. +/proc/hv_head_burst_splatter(turf/where, burst_dir, blood_color) + for(var/angle in list(0, 45, -45)) + new /obj/effect/temp_visual/dir_setting/bloodsplatter(where, turn(burst_dir, angle), blood_color) + new /obj/effect/temp_visual/dir_setting/bloodsplatter(where, burst_dir, "#c9a0ac") + +/// Whether a blow that is about to knock this head off should burst it instead. Rare on purpose: the +/// victim must already be dead or in hard crit, the head already half destroyed, and the blow a heavy +/// crushing or piercing one. Cuts and burns never burst a head. +/obj/item/bodypart/head/proc/hv_blow_bursts(wounding_type, wounding_dmg) + if(!owner || wounding_type == WOUND_SLASH || wounding_type == WOUND_BURN) + return FALSE + if(wounding_dmg < HV_HEAD_BURST_MIN_DAMAGE || owner.stat < HARD_CRIT) + return FALSE + return get_damage() >= max_damage * HV_HEAD_BURST_MIN_HEAD_DAMAGE + +/obj/item/bodypart/head/receive_damage(brute = 0, burn = 0, blocked = 0, updating_health = TRUE, forced = FALSE, required_bodytype = null, wound_bonus = 0, exposed_wound_bonus = 0, sharpness = NONE, attack_direction = null, damage_source, wound_clothing = TRUE) + hv_blow_dir = attack_direction + . = ..() + hv_blow_dir = null + +/obj/item/bodypart/head/try_dismember(wounding_type, wounding_dmg, wound_bonus, exposed_wound_bonus) + hv_burst_pending = hv_blow_bursts(wounding_type, wounding_dmg) + . = ..() + hv_burst_pending = FALSE + +/// The loss wound announces what happened to the head, so a burst gets its own wording there. +/obj/item/bodypart/head/get_dismember_message(wounding_type, outright) + if(hv_burst_pending) + return "bursts apart!" + return ..() + +/obj/item/bodypart/head/dismember(dam_type = BRUTE, silent = TRUE, wounding_type) + if(hv_burst_pending && owner) + hv_burst_pending = FALSE + // the wound that got us here has already said the head burst, so no second line + if(owner.hv_burst_head_apart(hv_blow_dir, FALSE)) + return TRUE + return ..() diff --git a/voidcrew/modules/hyperviolence/braingut/gut_spill.dm b/voidcrew/modules/hyperviolence/braingut/gut_spill.dm new file mode 100644 index 00000000000..094982427fd --- /dev/null +++ b/voidcrew/modules/hyperviolence/braingut/gut_spill.dm @@ -0,0 +1,226 @@ +// Gut spill (hyperviolence, braingut part): a critical chest wound for big slashes and heavy +// explosions. The victim's intestines spill onto the floor and stay joined to them by a tether +// (gut_tether.dm); at the worst level the stomach and liver slip out too. It is a critical slash +// wound, so gauze, sutures and cautery all work on the bleeding, but the cut will not close while +// the guts are out. Pushing the guts back in turns it into an ordinary severe cut. +// The design idea is Septic Shock's gut spill and Vanderlin's disembowel (AGPL code, nothing copied). + +/// Slows a victim holding their guts in. +/datum/movespeed_modifier/hv_gut_spill + multiplicative_slowdown = 0.7 + +/datum/wound_pregen_data/flesh_slash/disembowel + abstract = FALSE + wound_path_to_generate = /datum/wound/slash/flesh/critical/disembowel + // only ever applied on purpose (the chest slash hook below, blast_gore.dm), never by the wound roll + can_be_randomly_generated = FALSE + viable_zones = list(BODY_ZONE_CHEST) + threshold_minimum = WOUND_DISMEMBER_OUTRIGHT_THRESH + +/// A spill replaces the cut it comes from, so an existing cut in the same series must not stop it. +/datum/wound_pregen_data/flesh_slash/disembowel/can_be_applied_to(obj/item/bodypart/limb, list/suggested_wounding_types = required_wounding_types, datum/wound/old_wound, random_roll = FALSE, duplicates_allowed = src.duplicates_allowed, care_about_existing_wounds = TRUE) + return ..(limb, suggested_wounding_types, old_wound, random_roll, duplicates_allowed, FALSE) + +/datum/wound/slash/flesh/critical/disembowel + name = "Disembowelment" + desc = "Abdominal wall torn open. Intestines are outside the body." + treat_text = "Return the intestines. Dress the wound." + treat_text_short = "Return the intestines." + simple_treat_text = "Return the intestines. Dress the wound." + homemade_treat_text = "Return the intestines. Dress the wound." + examine_desc = "is torn open, guts hanging out" + occur_text = "is torn open, guts spilling out" + initial_flow = 4 + // the cut cannot close while the guts are out, so this is also the floor the bleeding stops at + minimum_flow = 2.5 + clot_rate = 0 + treatable_by = list(/obj/item/stack/medical/suture, /obj/item/hv_intestines) + scar_keyword = "slashcritical" + + /// The spilled guts, while they exist. + var/obj/item/hv_intestines/guts + /// Whether the guts are still outside the body. Cleared when they are pushed back in or destroyed. + var/guts_out = FALSE + /// The strand between the victim and the guts. + var/datum/hv_gut_tether/tether + +/// Spills quietly: the spill reports itself in the hit message, so the generic "chest is torn open" line would only repeat it. +/datum/wound/slash/flesh/critical/disembowel/apply_wound(obj/item/bodypart/L, silent = FALSE, datum/wound/old_wound = null, smited = FALSE, attack_direction = null, wound_source = "Unknown", replacing = FALSE) + return ..(L, TRUE, old_wound, smited, attack_direction, wound_source, replacing) + +/datum/wound/slash/flesh/critical/disembowel/wound_injury(datum/wound/slash/flesh/old_wound = null, attack_direction = null) + . = ..() + spill_out(attack_direction, !isnull(old_wound)) + +/datum/wound/slash/flesh/critical/disembowel/Destroy() + release_guts() + return ..() + +/datum/wound/slash/flesh/critical/disembowel/remove_wound(ignore_limb, replaced = FALSE) + release_guts() + return ..() + +/datum/wound/slash/flesh/critical/disembowel/get_self_check_description(self_aware) + if(guts_out) + return span_bolddanger("Your insides are hanging out of it!") + return ..() + +/// The cut cannot close while the guts are out. +/datum/wound/slash/flesh/critical/disembowel/adjust_blood_flow(adjust_by, minimum = 0) + if(guts_out) + minimum = max(minimum, minimum_flow) + return ..(adjust_by, minimum) + +/// Open guts never clot, so a cut with them out is never reported as healing. +/datum/wound/slash/flesh/critical/disembowel/get_bleed_rate_of_change() + return ..() || BLOOD_FLOW_STEADY + +/// Throws the guts, and at the worst level the stomach and liver, out of the victim and onto the floor. +/// need_spray is set when the generic cut did not already spray blood (it only does when the wound is new). +/datum/wound/slash/flesh/critical/disembowel/proc/spill_out(attack_direction, need_spray) + var/turf/victim_turf = get_turf(victim) + if(!victim_turf) + return + var/atom/drop_spot = victim.drop_location() || victim_turf + if(isturf(victim.loc) && attack_direction) + var/turf/ahead = get_step(victim, attack_direction) + if(ahead && !ahead.is_blocked_turf(exclude_mobs = TRUE)) + drop_spot = ahead + guts = new(drop_spot) + guts.add_mob_blood(victim) + guts_out = TRUE + RegisterSignal(guts, COMSIG_QDELETING, PROC_REF(on_guts_deleted)) + victim.add_movespeed_modifier(/datum/movespeed_modifier/hv_gut_spill) + + var/list/dna = victim.get_blood_dna_list() + if(victim.can_bleed() && isturf(drop_spot)) + new /obj/effect/decal/cleanable/blood/gibs(drop_spot, null, dna) + if(need_spray) + victim.hv_blood_spray(attack_direction, 3) + playsound(victim, 'sound/effects/wounds/splatter.ogg', 80, TRUE) + INVOKE_ASYNC(victim, TYPE_PROC_REF(/mob, emote), "scream") + victim.hv_add_hit_line("[victim]'s guts spill out!", HV_LINE_HUGE, "Your guts spill out!", DEFAULT_MESSAGE_RANGE) + + // at the worst level more than the guts come out + if(limb.get_damage() >= limb.max_damage * HV_GUTSPILL_ORGAN_CHEST_DAMAGE) + var/list/slip_chances = list(ORGAN_SLOT_STOMACH = 70, ORGAN_SLOT_LIVER = 35) + for(var/slot in slip_chances) + var/obj/item/organ/organ = victim.get_organ_slot(slot) + if(!organ || (organ.organ_flags & ORGAN_UNREMOVABLE) || !prob(slip_chances[slot])) + continue + organ.Remove(victim) + organ.forceMove(drop_spot) + victim.hv_add_hit_line("[victim]'s [organ.name] slips out!", HV_LINE_BOLD, "Your [organ.name] slips out!") + + tether = new(victim, guts) + RegisterSignal(tether, COMSIG_QDELETING, PROC_REF(on_tether_deleted)) + +/datum/wound/slash/flesh/critical/disembowel/proc/on_tether_deleted(datum/source) + SIGNAL_HANDLER + UnregisterSignal(source, COMSIG_QDELETING) + tether = null + +/// The guts were destroyed, so there is nothing left to push back in. +/datum/wound/slash/flesh/critical/disembowel/proc/on_guts_deleted(datum/source) + SIGNAL_HANDLER + UnregisterSignal(source, COMSIG_QDELETING) + guts = null + end_spill() + +/// Lets go of the guts: no tether, no slowdown. Leaves the guts item where it lies. +/datum/wound/slash/flesh/critical/disembowel/proc/release_guts() + if(guts) + UnregisterSignal(guts, COMSIG_QDELETING) + guts = null + end_spill() + +/// Nothing is outside the body any more. +/datum/wound/slash/flesh/critical/disembowel/proc/end_spill() + guts_out = FALSE + if(tether) + UnregisterSignal(tether, COMSIG_QDELETING) + QDEL_NULL(tether) + victim?.remove_movespeed_modifier(/datum/movespeed_modifier/hv_gut_spill) + +/datum/wound/slash/flesh/critical/disembowel/treat(obj/item/I, mob/user) + if(istype(I, /obj/item/hv_intestines)) + return push_guts_back(I, user) + return ..() + +/// Someone pushes the guts back in. Whichever guts they hold go in, and the wound becomes the plain severe cut it was. +/datum/wound/slash/flesh/critical/disembowel/proc/push_guts_back(obj/item/hv_intestines/returned, mob/user) + if(!guts_out) + return FALSE + var/mob/living/carbon/patient = victim + var/self_treat = (user == patient) + user.visible_message( + span_notice("[user] starts pushing [self_treat ? user.p_their() : "[patient]'s"] guts back in."), + span_notice("You start pushing [self_treat ? "your" : "[patient]'s"] guts back in..."), + ) + if(!do_after(user, (self_treat ? 6 SECONDS : 4 SECONDS), target = patient, extra_checks = CALLBACK(src, PROC_REF(can_push_back), returned))) + return TRUE + user.visible_message( + span_notice("[user] pushes [self_treat ? user.p_their() : "[patient]'s"] guts back in."), + span_notice("You push [self_treat ? "your" : "[patient]'s"] guts back in."), + ) + finish_push_back(returned) + return TRUE + +/datum/wound/slash/flesh/critical/disembowel/proc/can_push_back(obj/item/hv_intestines/returned) + return guts_out && still_exists() && !QDELETED(returned) + +/// The guts go back in for good: the held guts are used up and the wound drops to the cut it came from. +/datum/wound/slash/flesh/critical/disembowel/proc/finish_push_back(obj/item/hv_intestines/returned) + end_spill() + qdel(returned) + if(!QDELETED(src) && limb && demotes_to) + replace_wound(new demotes_to) + +/// Whether a blow to this chest is big enough, and the chest and victim fit, for it to spill guts. +/// The deterministic part of the roll, split out so it can be tested. +/obj/item/bodypart/chest/proc/hv_can_spill_guts(blow_damage, sharpness, wound_bonus, forced) + if(!owner || forced || wound_bonus == CANT_WOUND) + return FALSE + if(!(sharpness & SHARP_EDGED) || blow_damage < HV_GUTSPILL_MIN_DAMAGE) + return FALSE + return hv_chest_can_spill() + +/// Whether this chest could spill guts at all, whatever the blow: right body, nothing spilled yet. +/obj/item/bodypart/chest/proc/hv_chest_can_spill() + if(!owner || !ishuman(owner)) + return FALSE + if(HAS_TRAIT(owner, TRAIT_NODISMEMBER) || HAS_TRAIT(owner, TRAIT_NEVER_WOUNDED) || HAS_TRAIT(owner, TRAIT_GODMODE)) + return FALSE + var/datum/wound_pregen_data/pregen = GLOB.all_wound_pregen_data[/datum/wound/slash/flesh/critical/disembowel] + return pregen.can_be_applied_to(src) + +/// Whether a victim is down for good measure: dead or in hard crit. Cuts and blasts spill guts far more readily on them. +/obj/item/bodypart/chest/proc/hv_owner_downed() + return owner.stat >= HARD_CRIT + +/// Puts a disembowelment on this chest, replacing the cut it comes from. Returns the wound, or null if it could not be applied. +/obj/item/bodypart/chest/proc/hv_spill_guts(attack_direction, damage_source) + if(!hv_chest_can_spill()) + return null + var/datum/wound/slash/flesh/critical/disembowel/spill = new + var/datum/wound/slash/flesh/existing = get_wound_type(/datum/wound/slash/flesh) + if(existing) + existing.replace_wound(spill, attack_direction = attack_direction) + else + spill.apply_wound(src, attack_direction = attack_direction, wound_source = damage_source) + if(QDELETED(spill) || spill.limb != src) + return null + return spill + +/obj/item/bodypart/chest/receive_damage(brute = 0, burn = 0, blocked = 0, updating_health = TRUE, forced = FALSE, required_bodytype = null, wound_bonus = 0, exposed_wound_bonus = 0, sharpness = NONE, attack_direction = null, damage_source, wound_clothing = TRUE) + . = ..() + if(!(sharpness & SHARP_EDGED) || !owner) + return + if(!hv_can_spill_guts(brute * (100 - blocked) / 100, sharpness, wound_bonus, forced)) + return + var/downed = hv_owner_downed() + // a victim who is still up must already be badly hurt: no spill from the first blow + if(!downed && get_damage() < max_damage * HV_GUTSPILL_MIN_CHEST_DAMAGE) + return + if(prob(downed ? HV_GUTSPILL_CHANCE_DOWNED : HV_GUTSPILL_CHANCE)) + hv_spill_guts(attack_direction, damage_source) diff --git a/voidcrew/modules/hyperviolence/braingut/gut_tether.dm b/voidcrew/modules/hyperviolence/braingut/gut_tether.dm new file mode 100644 index 00000000000..a2014e08c0e --- /dev/null +++ b/voidcrew/modules/hyperviolence/braingut/gut_tether.dm @@ -0,0 +1,152 @@ +// Gut tether (hyperviolence, braingut part): while spilled guts lie on the floor near their owner, a +// strand of gut joins them. Walking too far drags the guts along and hurts. A sharp item on the guts +// cuts the strand. Idea from Vanderlin's gut rope (Monkestation/Vanderlin 1fc0bf8, AGPL code); this is +// a rewrite on tg's /datum/beam, with a strand sprite drawn for this module. + +/// One piece of the strand. Lies on the floor, so mobs walk over it rather than under it. +/obj/effect/ebeam/hv_gut + name = "intestines" + layer = BELOW_MOB_LAYER + emissive = FALSE + +/// The strand of gut between a disemboweled victim and their spilled guts. Owned by the disembowel wound. +/// Destroys itself when the victim or the guts are deleted. +/datum/hv_gut_tether + /// Whose guts these are. + var/mob/living/carbon/victim + /// The spilled guts. + var/obj/item/hv_intestines/guts + /// The drawn strand. Purely visual; the tether still holds when it is missing. + var/datum/beam/strand + /// Set once the strand is cut or torn. A cut tether never reconnects. + var/cut = FALSE + /// World time the next tug is allowed to hurt. + var/next_yank = 0 + +/datum/hv_gut_tether/New(mob/living/carbon/victim, obj/item/hv_intestines/guts) + src.victim = victim + src.guts = guts + RegisterSignal(victim, COMSIG_MOVABLE_MOVED, PROC_REF(on_victim_moved)) + RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_end_deleted)) + RegisterSignal(guts, COMSIG_MOVABLE_MOVED, PROC_REF(on_guts_moved)) + RegisterSignal(guts, COMSIG_QDELETING, PROC_REF(on_end_deleted)) + RegisterSignal(guts, COMSIG_ATOM_ITEM_INTERACTION, PROC_REF(on_guts_item_interaction)) + refresh() + +/datum/hv_gut_tether/Destroy() + drop_strand() + if(victim) + UnregisterSignal(victim, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) + if(guts) + UnregisterSignal(guts, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING, COMSIG_ATOM_ITEM_INTERACTION)) + victim = null + guts = null + return ..() + +/// Whether the guts are lying on the floor on the victim's level, so the strand is taut between them. +/datum/hv_gut_tether/proc/connected() + return !cut && isturf(victim.loc) && isturf(guts.loc) && victim.z == guts.z + +/// Brings the drawn strand in line with the state of the tether. +/datum/hv_gut_tether/proc/refresh() + if(!connected() || get_dist(victim, guts) > HV_GUT_TETHER_SNAP_RANGE) + drop_strand() + return + if(strand) + return + strand = victim.Beam(guts, "gut_beam", 'voidcrew/modules/hyperviolence/braingut/icons/gore_guts.dmi', INFINITY, HV_GUT_TETHER_SNAP_RANGE + 2, /obj/effect/ebeam/hv_gut, emissive = FALSE) + RegisterSignal(strand, COMSIG_QDELETING, PROC_REF(on_strand_deleted)) + +/// Removes the drawn strand, if there is one. +/datum/hv_gut_tether/proc/drop_strand() + if(!strand) + return + UnregisterSignal(strand, COMSIG_QDELETING) + QDEL_NULL(strand) + +/// The beam removes itself when either end is deleted or they drift too far apart. +/datum/hv_gut_tether/proc/on_strand_deleted(datum/beam/source) + SIGNAL_HANDLER + UnregisterSignal(source, COMSIG_QDELETING) + strand = null + +/// The victim or the guts are being deleted: nothing left to tether. +/datum/hv_gut_tether/proc/on_end_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/datum/hv_gut_tether/proc/on_guts_moved(atom/movable/source, atom/old_loc, dir, forced) + SIGNAL_HANDLER + if(cut) + return + if(connected() && get_dist(victim, guts) > HV_GUT_TETHER_SNAP_RANGE) + INVOKE_ASYNC(src, PROC_REF(tear), FALSE) + return + refresh() + +/datum/hv_gut_tether/proc/on_victim_moved(atom/movable/source, atom/old_loc, dir, forced) + SIGNAL_HANDLER + if(cut) + return + if(!connected()) + // guts left behind on another level are gone for good; guts in someone's hands or a container only wait + if(isturf(victim.loc) && isturf(guts.loc)) + cut = TRUE + drop_strand() + return + var/distance = get_dist(victim, guts) + if(distance > HV_GUT_TETHER_SNAP_RANGE) + INVOKE_ASYNC(src, PROC_REF(tear), TRUE) + return + if(distance > HV_GUT_TETHER_RANGE) + // the guts follow where the victim was, so they trail a few steps behind + var/atom/behind = isturf(old_loc) ? old_loc : victim.loc + guts.forceMove(behind) + INVOKE_ASYNC(src, PROC_REF(yank), behind) + refresh() + +/// A tug on the strand: it hurts, at most once per cooldown, and a standing victim is pulled off their feet. +/datum/hv_gut_tether/proc/yank(turf/behind) + if(QDELETED(src) || cut || world.time < next_yank) + return + next_yank = world.time + HV_GUT_YANK_COOLDOWN + victim.add_splatter_floor(behind, small_drip = TRUE) + if(victim.stat == DEAD) + return + victim.apply_damage(HV_GUT_YANK_DAMAGE, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, wound_clothing = FALSE) + victim.visible_message(span_danger("[victim] drags [victim.p_their()] guts across the floor!"), span_userdanger("Your guts drag across the floor!")) + victim.emote("scream") + if(victim.body_position == STANDING_UP) + victim.Knockdown(1 SECONDS) + +/// The strand parts for good, from a cut or because it was stretched too far. The victim feels it. +/datum/hv_gut_tether/proc/tear(stretched) + if(QDELETED(src) || cut) + return + cut = TRUE + drop_strand() + if(victim.stat == DEAD) + return + if(stretched) + victim.visible_message(span_danger("[victim]'s guts tear loose!"), span_userdanger("Your guts tear loose!")) + victim.apply_damage(HV_GUT_YANK_DAMAGE * 2, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, wound_clothing = FALSE) + victim.emote("scream") + victim.Knockdown(3 SECONDS) + +/// Anyone can cut the strand with something sharp. +/datum/hv_gut_tether/proc/on_guts_item_interaction(obj/item/source, mob/living/user, obj/item/tool, list/modifiers) + SIGNAL_HANDLER + if(!connected() || !tool.get_sharpness()) + return NONE + INVOKE_ASYNC(src, PROC_REF(cut_with), user, tool) + return ITEM_INTERACT_SUCCESS + +/datum/hv_gut_tether/proc/can_cut() + return !QDELETED(src) && connected() + +/datum/hv_gut_tether/proc/cut_with(mob/living/user, obj/item/tool) + user.visible_message(span_danger("[user] cuts at [victim]'s guts with [tool]!"), span_danger("You cut at [victim == user ? "your" : "[victim]'s"] guts with [tool].")) + playsound(guts, 'sound/items/weapons/slice.ogg', 60, TRUE) + if(!do_after(user, 1 SECONDS, target = guts, extra_checks = CALLBACK(src, PROC_REF(can_cut)))) + return + tear(FALSE) diff --git a/voidcrew/modules/hyperviolence/braingut/icons/gore_guts.dmi b/voidcrew/modules/hyperviolence/braingut/icons/gore_guts.dmi new file mode 100644 index 0000000000000000000000000000000000000000..6a6aeae066543b2811c87615a186b53680300ff0 GIT binary patch literal 877 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH!3HE3&8=$zQgcgOBT8ISi<65o3raHc^B9yB zoLt>K{S;h$J-NzKi;96_3bqO+dInsa<(VlZ89ZjE_|l4j zfvMZm#WAE}&eO=fUWWrj?0Cz?*xA_w)ddB)=N@r?vCN`Gqp4}Ov;2a^{0|9N@e1r4e^ZZ%->`OD>i(cqbRye=0F6Ly(UM2oSr`jd#YbWTv&+C-(?Qy%j ztZ|mf^ELYVBKzk5{<*{fXpG9q&oa7#O-on>_O1+D{PFks@SNUhHyKKu_tk`UPn_-+ z5ygC^^?7{MLc4eCa*y=6UTKV6I7#fm-{WqRFBB{|IpImH=7ZINQ?fVx|F!HiH^ZlO z90%n}k}lQ-Pm5tYb=zW>IES@y?CN8>O$w8zT)1_COSv~8Su*2Oki$yr+S-5SyczuL zsuh3t-M*Tv)~Rgw!7Q6Oh~@bVXX8W1CNXuHE(x4BRoPp9LCj>CV@9G5*SLP(u#{Gv zWt=SGwnkJ>C?sn4Nht<>qsVCS3ch*i7g=In25(vJX!_2(Am!lXCCNW^7d2dFJ5X06 z=W~nU$Hlh~8@gDY&YI$NzDa%W8P%Y+?cN53f4}~0z1-27-*PJ4)R)8M?VR*WM(>xt z_*fUizy9=z58rAX&R57MG)-EwhV7e_GC$*)Uv>+os2K1_Zu9(X7W4a=YaExt@<(f! zW~^YWDLxmk;K?B?yYQa+YR9;x`rd#2*D_si+9TqmCjGQ#@z3izoUtb!O--`lzA(Ss z(W|^^nWEFY)5kcTS6=(uDzzY2N@4fHi7huzZ#!`_S$@H#IIB~8rcX5VQ>o?r?-`Zo z_C;Z`Qr<;n!%8O8lWLwBG1|K`9!1_`);TM$94R>A_zZV>feT&E6VKOo3tr58+f$mb iY`X5jJ@pInf3VheZEIQlZRR{+{$cP0rqgv!2~7ZAzjDF= literal 0 HcmV?d00001 diff --git a/voidcrew/modules/hyperviolence/braingut/intestines.dm b/voidcrew/modules/hyperviolence/braingut/intestines.dm new file mode 100644 index 00000000000..8675411296a --- /dev/null +++ b/voidcrew/modules/hyperviolence/braingut/intestines.dm @@ -0,0 +1,25 @@ +// Gut spill (hyperviolence, braingut part): the gore item that lands on the floor when a victim is +// disemboweled. Sprite drawn for this module (icons/gore_guts.dmi); the idea of guts on a tether +// comes from Vanderlin (Monkestation/Vanderlin 1fc0bf8, AGPL code, CC BY-SA 3.0 art) but neither +// its code nor its art is copied here. + +/// A coil of someone's intestines, spilled by a disemboweling wound. Plain item: it is not an organ, +/// so nothing counts it as a body part and surgery never holds it. +/obj/item/hv_intestines + name = "intestines" + desc = "Someone's intestines. There is a lot more of it than there seems to be room for." + icon = 'voidcrew/modules/hyperviolence/braingut/icons/gore_guts.dmi' + icon_state = "intestines" + w_class = WEIGHT_CLASS_BULKY + item_flags = NOBLUDGEON + force = 0 + throwforce = 0 + throw_range = 3 + drop_sound = 'sound/misc/moist_impact.ogg' + pickup_sound = 'sound/misc/moist_impact.ogg' + sound_vary = TRUE + +/obj/item/hv_intestines/Initialize(mapload) + . = ..() + pixel_x = base_pixel_x + rand(-6, 6) + pixel_y = base_pixel_y + rand(-5, 5) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..efe3d9e9cb0 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -90,6 +90,7 @@ #include "voidcrew_helpers.dm" #include "voidcrew_hull_containment.dm" #include "voidcrew_hull_survey.dm" +#include "voidcrew_hyperviolence_braingut.dm" #include "voidcrew_launch_access.dm" #include "voidcrew_launch_cargo.dm" #include "voidcrew_launch_fabrication.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm new file mode 100644 index 00000000000..fcd68239f44 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm @@ -0,0 +1,225 @@ +// Hyperviolence braingut part: brain burst, gut spill and the gut tether. +// Unit tests are included before voidcrew/_DEFINES, so the HV_ tunables are not visible here: +// the tests probe the thresholds through the procs instead of copying their values. + +/// The smallest blow damage (1 to 200) for which check returns TRUE, or null if none does. +/datum/unit_test/proc/hv_smallest_blow(datum/callback/check) + for(var/blow in 1 to 200) + if(check.Invoke(blow)) + return blow + +/// A head that bursts must throw its brain out whole, still holding the mind, and destroy the head. +/datum/unit_test/voidcrew_hv_head_burst + +/datum/unit_test/voidcrew_hv_head_burst/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + victim.mind_initialize() + var/datum/mind/victim_mind = victim.mind + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + var/obj/item/organ/brain/brain = victim.get_organ_slot(ORGAN_SLOT_BRAIN) + var/obj/item/organ/eyes/eyes = victim.get_organ_slot(ORGAN_SLOT_EYES) + TEST_ASSERT_NOTNULL(head, "Test fixture: the victim has no head") + TEST_ASSERT_NOTNULL(brain, "Test fixture: the victim has no brain") + var/turf/origin = get_turf(victim) + + TEST_ASSERT(victim.hv_burst_head(NORTH), "A head that could be removed did not burst") + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_HEAD), "The victim kept a head after it burst") + TEST_ASSERT(QDELETED(head), "The burst head was left lying around") + TEST_ASSERT(!QDELETED(brain), "The brain was destroyed by the burst") + TEST_ASSERT_NOTNULL(brain.throwing, "The brain was not thrown") + TEST_ASSERT(isturf(brain.loc), "The brain was left inside something instead of on the floor") + TEST_ASSERT(!victim.hv_burst_head(NORTH), "A headless victim burst a head that is not there") + + sleep(2 SECONDS) + TEST_ASSERT(!QDELETED(brain), "The brain was destroyed in flight") + TEST_ASSERT(isturf(brain.loc), "The brain did not land on the floor") + var/fling_distance = brain.y - origin.y + TEST_ASSERT(fling_distance >= 1 && fling_distance <= 3, "The brain landed [fling_distance] tiles away, outside one to three tiles") + TEST_ASSERT_EQUAL(brain.x, origin.x, "The brain left the line of the blow") + TEST_ASSERT_EQUAL(brain.damage, 0, "The burst damaged the brain, so the victim could not be revived") + TEST_ASSERT(!(brain.organ_flags & ORGAN_FAILING), "The burst left the brain failing") + TEST_ASSERT_EQUAL(brain.brainmob?.mind, victim_mind, "The mind did not stay with the thrown brain") + if(eyes) + TEST_ASSERT(!QDELETED(eyes) && !istype(eyes.loc, /obj/item/bodypart), "The eyes stayed in a head that no longer exists") + +/// Only a heavy crushing or piercing blow on a downed victim's wrecked head may burst it, not a plain beheading. +/datum/unit_test/voidcrew_hv_head_burst_gating + +/datum/unit_test/voidcrew_hv_head_burst_gating/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + head.set_brute_dam(head.max_damage) + var/datum/callback/blunt = CALLBACK(head, TYPE_PROC_REF(/obj/item/bodypart/head, hv_blow_bursts), WOUND_BLUNT) + var/datum/callback/pierce = CALLBACK(head, TYPE_PROC_REF(/obj/item/bodypart/head, hv_blow_bursts), WOUND_PIERCE) + var/datum/callback/slash = CALLBACK(head, TYPE_PROC_REF(/obj/item/bodypart/head, hv_blow_bursts), WOUND_SLASH) + var/datum/callback/burn = CALLBACK(head, TYPE_PROC_REF(/obj/item/bodypart/head, hv_blow_bursts), WOUND_BURN) + + TEST_ASSERT(!blunt.Invoke(200), "A conscious victim's head burst") + victim.set_stat(HARD_CRIT) + var/min_blunt = hv_smallest_blow(blunt) + TEST_ASSERT_NOTNULL(min_blunt, "No crushing blow bursts a wrecked head on a victim in hard crit") + TEST_ASSERT(min_blunt >= 10, "A crushing blow of only [min_blunt] bursts a head") + TEST_ASSERT_EQUAL(hv_smallest_blow(pierce), min_blunt, "Crushing and piercing blows need different strength") + TEST_ASSERT_NULL(hv_smallest_blow(slash), "A cut bursts the head instead of taking it off") + TEST_ASSERT_NULL(hv_smallest_blow(burn), "A burn bursts the head") + + victim.set_stat(DEAD) + TEST_ASSERT_EQUAL(hv_smallest_blow(blunt), min_blunt, "A corpse needs a different blow than a victim in hard crit") + + // a head that is not wrecked never bursts, however hard the blow + head.set_brute_dam(0) + TEST_ASSERT_NULL(hv_smallest_blow(blunt), "A head with no damage burst") + +/// A blow that is allowed to burst the head sends the knock-off through the burst, with the brain flung out of it. +/datum/unit_test/voidcrew_hv_head_burst_blow + +/datum/unit_test/voidcrew_hv_head_burst_blow/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + var/obj/item/organ/brain/brain = victim.get_organ_slot(ORGAN_SLOT_BRAIN) + head.hv_burst_pending = TRUE + head.hv_blow_dir = NORTH + TEST_ASSERT(head.dismember(BRUTE, TRUE), "The head was not removed") + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_HEAD), "The victim kept a head") + TEST_ASSERT(QDELETED(head), "A burst blow left the head lying around instead of destroying it") + TEST_ASSERT(!QDELETED(brain), "The brain was destroyed") + TEST_ASSERT(isturf(brain.loc), "The brain stayed inside something") + +/// A head knocked off by an ordinary blow still comes off whole, with the brain inside it. +/datum/unit_test/voidcrew_hv_head_plain_decap + +/datum/unit_test/voidcrew_hv_head_plain_decap/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + var/obj/item/organ/brain/brain = victim.get_organ_slot(ORGAN_SLOT_BRAIN) + TEST_ASSERT(head.dismember(BRUTE, TRUE), "The head was not removed") + TEST_ASSERT(!QDELETED(head), "An ordinary decapitation destroyed the head") + TEST_ASSERT_EQUAL(brain.loc, head, "An ordinary decapitation moved the brain out of the head") + +/// Which blows to a chest can spill guts. +/datum/unit_test/voidcrew_hv_gut_spill_gating + +/datum/unit_test/voidcrew_hv_gut_spill_gating/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/chest/chest = victim.get_bodypart(BODY_ZONE_CHEST) + var/min_slash + for(var/blow in 1 to 200) + if(chest.hv_can_spill_guts(blow, SHARP_EDGED, 0, FALSE)) + min_slash = blow + break + TEST_ASSERT_NOTNULL(min_slash, "No slash to a human chest can spill guts") + TEST_ASSERT(min_slash >= 10, "A slash of only [min_slash] can spill guts") + TEST_ASSERT(!chest.hv_can_spill_guts(min_slash - 1, SHARP_EDGED, 0, FALSE), "A slash just under the limit can spill guts") + TEST_ASSERT(!chest.hv_can_spill_guts(200, NONE, 0, FALSE), "A blunt blow can spill guts") + TEST_ASSERT(!chest.hv_can_spill_guts(200, SHARP_POINTY, 0, FALSE), "A stab can spill guts") + TEST_ASSERT(!chest.hv_can_spill_guts(200, SHARP_EDGED, CANT_WOUND, FALSE), "A blow that cannot wound can spill guts") + TEST_ASSERT(!chest.hv_can_spill_guts(200, SHARP_EDGED, 0, TRUE), "Forced damage can spill guts") + TEST_ASSERT(!chest.hv_owner_downed(), "A conscious victim counted as downed") + victim.set_stat(HARD_CRIT) + TEST_ASSERT(chest.hv_owner_downed(), "A victim in hard crit did not count as downed") + ADD_TRAIT(victim, TRAIT_NODISMEMBER, TRAIT_SOURCE_UNIT_TESTS) + TEST_ASSERT(!chest.hv_can_spill_guts(200, SHARP_EDGED, 0, FALSE), "A victim who cannot be dismembered can spill guts") + REMOVE_TRAIT(victim, TRAIT_NODISMEMBER, TRAIT_SOURCE_UNIT_TESTS) + TEST_ASSERT_NOTNULL(chest.hv_spill_guts(NORTH, "test"), "The spill was refused") + TEST_ASSERT(!chest.hv_can_spill_guts(200, SHARP_EDGED, 0, FALSE), "A chest that has already spilled can spill again") + +/// The spill replaces the cut it comes from, drops guts on the floor, and keeps bleeding until they go back in. +/datum/unit_test/voidcrew_hv_gut_spill + +/datum/unit_test/voidcrew_hv_gut_spill/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/chest/chest = victim.get_bodypart(BODY_ZONE_CHEST) + var/datum/wound/slash/flesh/severe/cut = new + cut.apply_wound(chest) + TEST_ASSERT_EQUAL(chest.get_wound_type(/datum/wound/slash/flesh), cut, "Test fixture: the cut was not applied") + + var/datum/wound/slash/flesh/critical/disembowel/spill = chest.hv_spill_guts(NORTH, "test") + TEST_ASSERT_NOTNULL(spill, "The chest did not spill") + TEST_ASSERT(QDELETED(cut), "The spill did not replace the cut it came from") + TEST_ASSERT_EQUAL(chest.get_wound_type(/datum/wound/slash/flesh), spill, "The spill is not the chest's cut") + TEST_ASSERT(spill.guts_out, "The wound does not count the guts as out") + var/obj/item/hv_intestines/guts = spill.guts + TEST_ASSERT_NOTNULL(guts, "No guts were dropped") + TEST_ASSERT(isturf(guts.loc), "The guts were not dropped on the floor") + TEST_ASSERT(spill.blood_flow >= spill.minimum_flow, "The spill is not bleeding") + TEST_ASSERT(victim.has_movespeed_modifier(/datum/movespeed_modifier/hv_gut_spill), "A victim holding their guts in was not slowed") + + // the cut will not close while the guts are out, however much it is dressed + spill.adjust_blood_flow(-100) + TEST_ASSERT(!QDELETED(spill), "The spill closed with the guts still out") + TEST_ASSERT_EQUAL(spill.blood_flow, spill.minimum_flow, "Treating the cut took the bleeding below its floor") + + // pushing the guts back in leaves the plain cut it was, and uses the guts up + spill.finish_push_back(guts) + TEST_ASSERT(QDELETED(spill), "The spill outlived the guts going back in") + TEST_ASSERT(QDELETED(guts), "The guts were not used up") + TEST_ASSERT_NOTNULL(chest.get_wound_type(/datum/wound/slash/flesh/severe), "Pushing the guts back in did not leave the severe cut") + TEST_ASSERT(!victim.has_movespeed_modifier(/datum/movespeed_modifier/hv_gut_spill), "The victim stayed slowed after the guts went back in") + +/// The strand is drawn, drags the guts along when the victim walks off, and cutting it hurts once. +/datum/unit_test/voidcrew_hv_gut_tether + +/datum/unit_test/voidcrew_hv_gut_tether/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/chest/chest = victim.get_bodypart(BODY_ZONE_CHEST) + var/datum/wound/slash/flesh/critical/disembowel/spill = chest.hv_spill_guts(NORTH, "test") + var/datum/hv_gut_tether/tether = spill.tether + var/obj/item/hv_intestines/guts = spill.guts + TEST_ASSERT_NOTNULL(tether, "The spill made no tether") + TEST_ASSERT_NOTNULL(tether.strand, "The tether has no strand") + + // walking away tugs the guts along behind the victim + var/turf/start = get_turf(victim) + var/damage_before = chest.get_damage() + victim.forceMove(run_loc_floor_top_right) + TEST_ASSERT_EQUAL(get_turf(guts), start, "The guts did not follow the victim") + TEST_ASSERT(!tether.cut, "A tug cut the tether") + TEST_ASSERT(chest.get_damage() > damage_before, "A tug on the tether did not hurt") + + // cutting it hurts and drops the victim, and it never reconnects + victim.SetKnockdown(0) + tether.tear(FALSE) + TEST_ASSERT(tether.cut, "The tether was not cut") + TEST_ASSERT_NULL(tether.strand, "The strand outlived the cut") + TEST_ASSERT(victim.IsKnockdown(), "Cutting the tether did not drop the victim") + tether.refresh() + TEST_ASSERT_NULL(tether.strand, "A cut tether reconnected") + TEST_ASSERT(spill.guts_out, "Cutting the tether put the guts back") + +/// Nothing dangles when the guts or the wound go away, and a torn tether stays torn. +/datum/unit_test/voidcrew_hv_gut_tether_cleanup + +/datum/unit_test/voidcrew_hv_gut_tether_cleanup/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/chest/chest = victim.get_bodypart(BODY_ZONE_CHEST) + + // guts destroyed: the wound stays, with nothing out and nothing tethered + var/datum/wound/slash/flesh/critical/disembowel/spill = chest.hv_spill_guts(NORTH, "test") + var/obj/item/hv_intestines/guts = spill.guts + var/datum/hv_gut_tether/tether = spill.tether + qdel(guts) + TEST_ASSERT_NULL(spill.guts, "The wound kept a reference to destroyed guts") + TEST_ASSERT(!spill.guts_out, "The wound still counts destroyed guts as out") + TEST_ASSERT_NULL(spill.tether, "The wound kept a tether to destroyed guts") + TEST_ASSERT(QDELETED(tether), "The tether outlived the guts") + TEST_ASSERT(!QDELETED(spill), "Losing the guts removed the wound") + TEST_ASSERT(!victim.has_movespeed_modifier(/datum/movespeed_modifier/hv_gut_spill), "The victim stayed slowed with no guts out") + qdel(spill) + + // wound removed: the strand goes, the guts stay where they lie + spill = chest.hv_spill_guts(NORTH, "test") + guts = spill.guts + tether = spill.tether + var/datum/beam/strand = tether.strand + qdel(spill) + TEST_ASSERT(QDELETED(tether), "The tether outlived the wound") + TEST_ASSERT(isnull(strand) || QDELETED(strand), "The strand outlived the wound") + TEST_ASSERT(!QDELETED(guts), "Removing the wound destroyed the guts") + TEST_ASSERT(!victim.has_movespeed_modifier(/datum/movespeed_modifier/hv_gut_spill), "The victim stayed slowed after the wound went") + + // a tear cuts the tether for good + spill = chest.hv_spill_guts(NORTH, "test") + tether = spill.tether + tether.tear(TRUE) + TEST_ASSERT(tether.cut, "A tear did not cut the tether") + TEST_ASSERT_NULL(tether.strand, "The strand outlived the tear") From 089e809a92fc88c03427aae14e0d9ae3697f8179 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:35:33 -0800 Subject: [PATCH 014/472] Give aimed crit lines a victim wording and tg's wound ranges Uses the new hv_add_hit_line self_text and range arguments: critical crits (throat, aorta, skull) carry DEFAULT_MESSAGE_RANGE like tg's critical wounds, the rest the close combat range. NPC crits now leave floor blood since NPCs have blood. Crit tests renamed to voidcrew_hyperviolence_crits and included. --- .../modules/hyperviolence/crits/crit_basic.dm | 3 ++ .../modules/hyperviolence/crits/crit_roll.dm | 13 +++++-- .../modules/hyperviolence/crits/crit_types.dm | 35 +++++++++++++++---- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + ...its.dm => voidcrew_hyperviolence_crits.dm} | 0 5 files changed, 43 insertions(+), 9 deletions(-) rename voidcrew/modules/unit_tests/{voidcrew_hv_crits.dm => voidcrew_hyperviolence_crits.dm} (100%) diff --git a/voidcrew/modules/hyperviolence/crits/crit_basic.dm b/voidcrew/modules/hyperviolence/crits/crit_basic.dm index 5aea3251612..082e4d68d27 100644 --- a/voidcrew/modules/hyperviolence/crits/crit_basic.dm +++ b/voidcrew/modules/hyperviolence/crits/crit_basic.dm @@ -28,6 +28,7 @@ if(stun_time && owner.stat != DEAD) owner.Stun(stun_time) owner.hv_blood_spray(attack_direction, 2) + owner.add_splatter_floor(get_turf(owner)) // NPCs have blood since the hit-lines part (npc_blood.dm) return TRUE /datum/status_effect/hv_crit_basic/eye @@ -74,6 +75,8 @@ owner.adjustBruteLoss(owner.maxHealth * bleed_share * bled) if(prob(50)) owner.hv_blood_spray(pick(GLOB.alldirs), 2) + else + owner.add_splatter_floor(get_turf(owner), small_drip = TRUE) if(bleed_left <= 0) // Stays on as a marker so the crit cannot land again, but stops ticking. tick_interval = STATUS_EFFECT_NO_TICK diff --git a/voidcrew/modules/hyperviolence/crits/crit_roll.dm b/voidcrew/modules/hyperviolence/crits/crit_roll.dm index 7364ddedbb2..298d253ef36 100644 --- a/voidcrew/modules/hyperviolence/crits/crit_roll.dm +++ b/voidcrew/modules/hyperviolence/crits/crit_roll.dm @@ -69,6 +69,9 @@ GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) var/datum/status_effect/basic_effect /// Size of the hit line: HV_LINE_BOLD or HV_LINE_HUGE. var/line_severity = HV_LINE_BOLD + /// How far the hit line carries. As tg does for wounds: critical ones carry the full + /// DEFAULT_MESSAGE_RANGE, lesser ones only the close combat range. + var/line_range = COMBAT_MESSAGE_RANGE /// Played on the victim when it lands. Must be a sound already in the repo. var/hit_sound /// How long the victim's screen shakes, in deciseconds. 0 for none. @@ -105,9 +108,13 @@ GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) /datum/hv_crit/proc/hit_line(mob/living/victim) return "[victim] is badly hurt!" -/// Hit line, sound and shake. -/datum/hv_crit/proc/announce(mob/living/victim, attack_direction, line) - victim.hv_add_hit_line(line || hit_line(victim), line_severity) +/// What the victim reads in place of hit_line(). +/datum/hv_crit/proc/self_line(mob/living/victim) + return "You are badly hurt!" + +/// Hit line, sound and shake. line and own_line override hit_line() and self_line(). +/datum/hv_crit/proc/announce(mob/living/victim, attack_direction, line, own_line) + victim.hv_add_hit_line(line || hit_line(victim), line_severity, own_line || self_line(victim), line_range) if(hit_sound) playsound(victim, hit_sound, 80, TRUE, falloff_exponent = SOUND_FALLOFF_EXPONENT + 2, ignore_walls = FALSE, falloff_distance = 0) if(shake) diff --git a/voidcrew/modules/hyperviolence/crits/crit_types.dm b/voidcrew/modules/hyperviolence/crits/crit_types.dm index 4d9ca9d3fa0..16658f94cf3 100644 --- a/voidcrew/modules/hyperviolence/crits/crit_types.dm +++ b/voidcrew/modules/hyperviolence/crits/crit_types.dm @@ -76,7 +76,9 @@ victim.adjust_eye_blur(20 SECONDS) victim.add_mood_event("eye_stab", /datum/mood_event/eye_stab) - var/line + var/side_name = side == RIGHT_EYE_SCAR ? "right" : "left" + var/line = "[victim]'s [side_name] eye is torn out!" + var/own_line = "Your [side_name] eye is torn out!" if(last_eye) // Nothing left to see with: the whole organ comes out. eyes.Remove(victim) @@ -85,15 +87,18 @@ var/turf/landing = get_step(drop_turf, attack_direction) if(landing && !landing.is_blocked_turf()) eyes.throw_at(landing, 1, 1) - line = length(open_sides) ? "[victim]'s [side == RIGHT_EYE_SCAR ? "right" : "left"] eye is torn out!" : "[victim]'s eyes are torn out!" - else - line = "[victim]'s [side == RIGHT_EYE_SCAR ? "right" : "left"] eye is torn out!" + if(!length(open_sides)) + line = "[victim]'s eyes are torn out!" + own_line = "Your eyes are torn out!" victim.hv_blood_spray(attack_direction, 2) - announce(victim, attack_direction, line) + announce(victim, attack_direction, line, own_line) return TRUE /datum/hv_crit/eye/hit_line(mob/living/victim) - return "[victim]'s [pick("left", "right")] eye is torn out!" + return "[victim]'s eye is torn out!" + +/datum/hv_crit/eye/self_line(mob/living/victim) + return "Your eye is torn out!" // --------------------------------------------------------------------------------------------- // Tongue cut off: a slash aimed at the mouth. A mask or helmet over the mouth stops it. @@ -135,6 +140,9 @@ /datum/hv_crit/tongue/hit_line(mob/living/victim) return "[victim]'s tongue is cut off!" +/datum/hv_crit/tongue/self_line(mob/living/victim) + return "Your tongue is cut off!" + // --------------------------------------------------------------------------------------------- // Throat slit: a slash aimed at the head. A sealed helmet or an armoured collar stops it. @@ -148,6 +156,7 @@ wound_type = /datum/wound/slash/flesh/critical/hv_carotid basic_effect = /datum/status_effect/hv_crit_basic/bleed/throat line_severity = HV_LINE_HUGE + line_range = DEFAULT_MESSAGE_RANGE hit_sound = 'sound/effects/wounds/blood3.ogg' shake = 5 @@ -172,6 +181,9 @@ /datum/hv_crit/throat/hit_line(mob/living/victim) return "[victim]'s throat is slit open!" +/datum/hv_crit/throat/self_line(mob/living/victim) + return "Your throat is slit open!" + // --------------------------------------------------------------------------------------------- // Aorta torn: a huge stab, slash or shot aimed at the chest. Heavy chest armour against that kind // of hit stops it; ordinary vests only make it rarer through their wound armour. @@ -186,6 +198,7 @@ allow_projectiles = TRUE wound_type = /datum/wound/pierce/bleed/critical/hv_aorta basic_effect = /datum/status_effect/hv_crit_basic/bleed/aorta + line_range = DEFAULT_MESSAGE_RANGE hit_sound = 'sound/effects/wounds/pierce3.ogg' shake = 4 @@ -202,6 +215,9 @@ /datum/hv_crit/aorta/hit_line(mob/living/victim) return "[victim]'s aorta is torn open!" +/datum/hv_crit/aorta/self_line(mob/living/victim) + return "Something tears deep in your chest!" + // --------------------------------------------------------------------------------------------- // Skull caved in: a heavy blunt blow aimed at the head. A real helmet, or anything that blocks // head injuries (hard hats, riot helmets), stops it. If the victim is already in crit the head @@ -218,6 +234,7 @@ wound_type = /datum/wound/blunt/bone/critical/hv_skull basic_effect = /datum/status_effect/hv_crit_basic/skull line_severity = HV_LINE_HUGE + line_range = DEFAULT_MESSAGE_RANGE hit_sound = 'sound/effects/wounds/crack2.ogg' shake = 8 @@ -245,6 +262,9 @@ /datum/hv_crit/skull/hit_line(mob/living/victim) return "[victim]'s skull caves in!" +/datum/hv_crit/skull/self_line(mob/living/victim) + return "Your skull caves in!" + // --------------------------------------------------------------------------------------------- // Jaw shattered: a blunt blow aimed at the mouth. A helmet that covers the mouth stops it. @@ -273,6 +293,9 @@ /datum/hv_crit/jaw/hit_line(mob/living/victim) return "[victim]'s jaw shatters!" +/datum/hv_crit/jaw/self_line(mob/living/victim) + return "Your jaw shatters!" + #undef HV_EYE_RUINED_CHANCE #undef HV_SKULL_BURST_DAMAGE #undef HV_SKULL_HELMET_ARMOUR diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index bc16b5e014d..bb41528edc8 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -90,6 +90,7 @@ #include "voidcrew_helpers.dm" #include "voidcrew_hull_containment.dm" #include "voidcrew_hull_survey.dm" +#include "voidcrew_hyperviolence_crits.dm" #include "voidcrew_hyperviolence_hitlines.dm" #include "voidcrew_launch_access.dm" #include "voidcrew_launch_cargo.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_hv_crits.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_crits.dm similarity index 100% rename from voidcrew/modules/unit_tests/voidcrew_hv_crits.dm rename to voidcrew/modules/unit_tests/voidcrew_hyperviolence_crits.dm From c200315ede63942c934a2b3ee28780cc454403c1 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:35:51 -0800 Subject: [PATCH 015/472] Add stump and skull-wound overlays and gun executions Stumps: a missing head, arm or leg on a human shows a stump. A limb torn off in the last five minutes leaves a wet stump drawn over the jumpsuit; after that, or after a clean surgical removal, it reads as healed over and sits under clothing. The neck stump always shows above the collar. Blood is tinted to the mob's blood colour. Putting a limb back clears it. Skull wounds: one brute hit of 20 or more to a flesh head opens a skull wound, drawn light, medium or heavy by the head's brute damage, over hair and under masks and hats. It shrinks as the head heals and closes under 20 brute. A severed head carries its wound. Executions: a point-blank click with the mouth selected (tg's slow "points the gun at their head") on a humanoid who is down, out cold, cuffed, stunned or in an aggressive grab becomes a 1.5 second wind-up both must hold still for, then the gun's own shot into the head. A heavy round bursts the head through hv_burst_head; a lighter one leaves a heavy skull wound and finishes them with oxygen damage so defibrillation still works. The brain is never destroyed. Pacifists, firing pins, empty guns, less-lethal rounds and targets over 200 max health are respected. Witnesses get a short mood hit. Works on bounty criminals and other humanoid NPCs. Stump and headshot art is from CM-SS13 (cmss13-devs/cmss13 @ e9b299d266c2be727403751c3474e085803b5934, icons/mob/humans/dam_human.dmi) by the CM-SS13 team, CC BY-SA 3.0. The overlay and execution code is adapted from CM-SS13's update_headshot_overlay() and handle_battlefield_execution() (AGPL-3.0), rewritten for tg. --- tgstation.dme | 2 + voidcrew/_DEFINES/hyperviolence.dm | 38 ++++ .../hyperviolence/overlays_exec/executions.dm | 179 +++++++++++++++ .../overlays_exec/gore_overlays.dm | 204 ++++++++++++++++++ .../overlays_exec/icons/gore_overlays.dmi | Bin 0 -> 2770 bytes voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../voidcrew_hyperviolence_overlays_exec.dm | 123 +++++++++++ 7 files changed, 547 insertions(+) create mode 100644 voidcrew/modules/hyperviolence/overlays_exec/executions.dm create mode 100644 voidcrew/modules/hyperviolence/overlays_exec/gore_overlays.dm create mode 100644 voidcrew/modules/hyperviolence/overlays_exec/icons/gore_overlays.dmi create mode 100644 voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm diff --git a/tgstation.dme b/tgstation.dme index 2f7df14adc8..7c029dfeb16 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7230,6 +7230,8 @@ #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" #include "voidcrew\modules\hyperviolence\_core.dm" +#include "voidcrew\modules\hyperviolence\overlays_exec\executions.dm" +#include "voidcrew\modules\hyperviolence\overlays_exec\gore_overlays.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" #include "voidcrew\modules\intents\hud_intent.dm" diff --git a/voidcrew/_DEFINES/hyperviolence.dm b/voidcrew/_DEFINES/hyperviolence.dm index f0e0c2135e3..ce72c4e86a1 100644 --- a/voidcrew/_DEFINES/hyperviolence.dm +++ b/voidcrew/_DEFINES/hyperviolence.dm @@ -4,3 +4,41 @@ #define HV_LINE_BOLD 1 /// A big bold-italic line, for severed limbs, burst heads and kills. #define HV_LINE_HUGE 2 + +// ---- Stump and skull-wound overlays, gun executions (overlays_exec/) ---- + +/// Icon file for stump and skull-wound overlays (art from CM-SS13, CC BY-SA 3.0). +#define HV_GORE_ICON 'voidcrew/modules/hyperviolence/overlays_exec/icons/gore_overlays.dmi' +/// Mob overlay height of a healed stump: under the jumpsuit (UNIFORM_LAYER 28), over the body. +/// Fractional so it never collides with a real overlays_standing index. +#define HV_STUMP_LAYER 28.5 +/// Mob overlay height of a fresh stump and the neck stump: over the jumpsuit, under ID, belt and suit. +#define HV_STUMP_FRESH_LAYER 27.5 +/// Mob overlay height of a skull wound: over hair (HAIR_LAYER 13), under masks and hats. +#define HV_SKULL_WOUND_LAYER 12.5 +/// How long a torn-off limb leaves a wet, bleeding stump before it reads as healed over. +#define HV_STUMP_FRESH_TIME (5 MINUTES) +/// Alpha of the blood layer on a healed-over stump. +#define HV_STUMP_OLD_ALPHA 140 + +/// Brute damage one hit must deal to a head to open a skull wound. +#define HV_SKULL_OPEN_HIT 20 +/// Head brute damage for the light, medium and heavy skull wound. Below the light value it closes. +#define HV_SKULL_LIGHT_BRUTE 20 +#define HV_SKULL_MEDIUM_BRUTE 45 +#define HV_SKULL_HEAVY_BRUTE 80 + +/// Wind-up before an execution shot. +#define HV_EXECUTION_WINDUP (1.5 SECONDS) +/// Damage multiplier of a shot fired with the barrel against the head, for deciding the outcome. +#define HV_EXECUTION_POINT_BLANK_MULT 2 +/// Extra multiplier for multi-pellet shells: at the muzzle every pellet lands together. +#define HV_EXECUTION_PELLET_MULT 1.5 +/// Outcome damage (after armour) at which an execution bursts the head instead of holing it. +#define HV_EXECUTION_BURST_DAMAGE 80 +/// A chambered round must deal at least this much to count as an execution round. +#define HV_EXECUTION_MIN_DAMAGE 10 +/// Targets with more max health than this take the shot but are not finished off (bosses). +#define HV_EXECUTION_MAX_HEALTH 200 +/// do_after interaction key for an execution wind-up. +#define DOAFTER_SOURCE_HV_EXECUTION "hv_execution" diff --git a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm new file mode 100644 index 00000000000..66651ce7b6d --- /dev/null +++ b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm @@ -0,0 +1,179 @@ +// Hyperviolence: gun executions. +// +// After CM-SS13's handle_battlefield_execution() (cmss13-devs/cmss13 @ e9b299d266c2be727403751c3474e085803b5934, +// code/modules/projectiles/gun.dm, AGPL-3.0, by the CM-SS13 team), rewritten for tg's guns. +// +// The trigger: tg already turns a point-blank click with the mouth selected into "points the gun at +// their head" (handle_suicide: a 12 second wait, on anyone). When the target is humanoid and can't +// fight back (down, out cold, cuffed, stunned or held in an aggressive grab) that click becomes an +// execution instead: a short wind-up that both have to hold still for, then the gun's own shot into +// the head. A round heavy enough bursts the head (hv_burst_head, which keeps the brain); a lighter +// one leaves a heavy skull wound and finishes them. The brain is never destroyed, so revival works +// as usual. Head-selected point-blank shots, holding someone up (right click) and gun bashes are +// unchanged, and anyone the wind-up does not apply to still gets tg's slow version. + +/obj/item/gun/handle_suicide(mob/living/carbon/human/user, mob/living/carbon/human/target, params, bypass_timer) + if(bypass_timer || !hv_execution_possible(user, target)) + return ..() + hv_start_execution(user, target, params) + +/// Whether a mouth-aimed point-blank click by `user` on `target` should be an execution. +/obj/item/gun/proc/hv_execution_possible(mob/living/user, mob/living/target) + if(!isliving(user) || !isliving(target) || user == target) + return FALSE + if(!target.hv_can_be_executed() || !user.Adjacent(target)) + return FALSE + return can_shoot() && hv_execution_round_ok() + +/// Whether the chambered round can kill: a harmful brute or burn round, not a toy or a beanbag. +/obj/item/gun/proc/hv_execution_round_ok() + var/obj/projectile/bullet = chambered?.loaded_projectile + if(!bullet || !chambered.harmful) + return FALSE + if(bullet.damage_type != BRUTE && bullet.damage_type != BURN) + return FALSE + if(bullet.stamina > bullet.damage) // less-lethal: beanbags, rubbershot + return FALSE + return bullet.damage * projectile_damage_multiplier * max(1, chambered.pellets) >= HV_EXECUTION_MIN_DAMAGE + +/// Presses the barrel to the target's head and, if both hold still, fires. +/obj/item/gun/proc/hv_start_execution(mob/living/user, mob/living/target, params) + if(fire_cd || firing_burst) + return + if(HAS_TRAIT(user, TRAIT_PACIFISM)) + to_chat(user, span_warning("[src] is lethally chambered! You don't want to risk harming anyone...")) + return + user.visible_message( + span_danger("[user] presses the barrel of [src] to [target]'s head."), + span_danger("You press the barrel of [src] to [target]'s head."), + ignored_mobs = target, + ) + to_chat(target, span_userdanger("[user] presses the barrel of [src] to your head!")) + // Like tg's version: no other shot from this gun while the barrel is held there. + fire_cd = TRUE + var/held = do_after(user, HV_EXECUTION_WINDUP, target, extra_checks = CALLBACK(src, PROC_REF(hv_execution_holds), user, target), interaction_key = DOAFTER_SOURCE_HV_EXECUTION) + fire_cd = FALSE + if(!held || QDELETED(src) || QDELETED(user) || QDELETED(target)) + return + hv_fire_execution(user, target, params) + +/// do_after check: the shooter still holds the gun to the mouth of someone who still can't fight back. +/obj/item/gun/proc/hv_execution_holds(mob/living/user, mob/living/target) + return user.is_holding(src) && user.zone_selected == BODY_ZONE_PRECISE_MOUTH && target.hv_can_be_executed() + +/// Fires the execution shot. Returns TRUE if the gun went off. +/obj/item/gun/proc/hv_fire_execution(mob/living/user, mob/living/target, params) + if(!user.is_holding(src) || !user.Adjacent(target) || !can_trigger_gun(user)) + return FALSE + if(!can_shoot()) + shoot_with_empty_chamber(user) + return FALSE + if(!hv_execution_round_ok()) // the round changed under the wind-up: just a point-blank shot + return process_fire(target, user, TRUE, params, BODY_ZONE_HEAD) + var/blow = hv_execution_blow(target) + var/shot_dir = get_dir(user, target) + if(!process_fire(target, user, TRUE, params, BODY_ZONE_HEAD)) + return FALSE + log_combat(user, target, "executed", src) + if(burst_size > 1) // the burst lands over the next few ticks; settle the outcome once it has + addtimer(CALLBACK(src, PROC_REF(hv_finish_execution), WEAKREF(user), WEAKREF(target), blow, shot_dir), burst_delay * (burst_size - 1) + 1) + else + hv_finish_execution(WEAKREF(user), WEAKREF(target), blow, shot_dir) + return TRUE + +/// How hard the chambered round hits a head with the barrel against it, after that head's armour. +/obj/item/gun/proc/hv_execution_blow(mob/living/target) + var/obj/projectile/bullet = chambered?.loaded_projectile + if(!bullet) + return 0 + var/pellets = max(1, chambered.pellets) + var/blow = bullet.damage * projectile_damage_multiplier * pellets * max(1, burst_size) * HV_EXECUTION_POINT_BLANK_MULT + if(pellets > 1) + blow *= HV_EXECUTION_PELLET_MULT + var/armor = target.run_armor_check(BODY_ZONE_HEAD, bullet.armor_flag, armour_penetration = bullet.armour_penetration, silent = TRUE, weak_against_armour = bullet.weak_against_armour) + return blow * max(0, 100 - armor) / 100 + +/// Settles an execution once the shot has landed. +/obj/item/gun/proc/hv_finish_execution(datum/weakref/user_ref, datum/weakref/target_ref, blow, shot_dir) + var/mob/living/target = target_ref?.resolve() + if(QDELETED(target)) + return + target.hv_take_execution(blow, shot_dir) + hv_execution_witnesses(user_ref?.resolve(), target) + +/// Everyone who saw it close up feels it, bar the shooter, the victim and those who enjoy that sort of thing. +/proc/hv_execution_witnesses(mob/living/shooter, mob/living/victim) + for(var/mob/living/carbon/witness in viewers(7, victim)) + if(witness == shooter || witness == victim || witness.stat != CONSCIOUS || witness.is_blind()) + continue + if(HAS_MIND_TRAIT(witness, TRAIT_MORBID) || HAS_MIND_TRAIT(witness, TRAIT_EVIL)) + continue + witness.add_mood_event("hv_saw_execution", /datum/mood_event/hv_saw_execution) + +/datum/mood_event/hv_saw_execution + description = "I watched someone get executed." + mood_change = -4 + timeout = 5 MINUTES + +// ---- Who can be executed ---- + +/// Whether this mob is humanoid and can't fight back, so a gun to the mouth is an execution. +/mob/living/proc/hv_can_be_executed() + if(!ishuman(src) && !(mob_biotypes & MOB_HUMANOID)) + return FALSE + return hv_is_helpless() + +/// Whether this mob is down, out cold, cuffed, stunned or held in an aggressive grab. +/mob/living/proc/hv_is_helpless() + if(stat >= UNCONSCIOUS || body_position == LYING_DOWN) + return TRUE + if(HAS_TRAIT(src, TRAIT_RESTRAINED) || HAS_TRAIT(src, TRAIT_INCAPACITATED) || HAS_TRAIT(src, TRAIT_FLOORED)) + return TRUE + return pulledby?.grab_state >= GRAB_AGGRESSIVE + +// Bounty criminals keep their own cuffs, downed and stunned states (voidcrew/modules/bounties/). +/mob/living/basic/bounty_criminal/hv_is_helpless() + return ..() || is_restrained() || is_downed() || body_is_stunned() + +// ---- What the shot does ---- + +/// Takes an execution shot that hit with `blow` force, travelling `shot_dir`. Past the shot itself, a +/// target too tough to finish (see hv_execution_killable()) takes nothing extra. +/mob/living/proc/hv_take_execution(blow, shot_dir) + if(stat != DEAD && !hv_execution_killable()) + return + hv_blood_spray(shot_dir, blow >= HV_EXECUTION_BURST_DAMAGE ? 4 : 3) + hv_add_hit_line("The shot blows out the back of [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) + hv_execution_kill() + +/mob/living/carbon/hv_take_execution(blow, shot_dir) + if(stat != DEAD && !hv_execution_killable()) + return + if(blow >= HV_EXECUTION_BURST_DAMAGE && hv_burst_head(shot_dir)) + hv_execution_kill() + return + var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) + if(isnull(head)) // the shot itself took it + hv_execution_kill() + return + if(!HAS_TRAIT(src, TRAIT_GODMODE) && head.brute_dam < HV_SKULL_HEAVY_BRUTE) + apply_damage(HV_SKULL_HEAVY_BRUTE - head.brute_dam, BRUTE, head, forced = TRUE, wound_bonus = CANT_WOUND) + if(!QDELETED(head) && head.owner == src && head.hv_open_skull()) + hv_blood_spray(shot_dir, 3) + hv_add_hit_line("The shot blows out the back of [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) + hv_execution_kill() + +/// Whether an execution may finish this mob off: alive, mortal, and not a boss-sized health pool. +/mob/living/proc/hv_execution_killable() + return stat != DEAD && !HAS_TRAIT(src, TRAIT_GODMODE) && maxHealth <= HV_EXECUTION_MAX_HEALTH + +/// Finishes off an execution victim if hv_execution_killable() allows it. +/mob/living/proc/hv_execution_kill() + if(hv_execution_killable()) + adjustBruteLoss(health + 1) + +/mob/living/carbon/hv_execution_kill() + if(!hv_execution_killable()) + return + // The rest goes in as oxygen damage, not more trauma, so the body stays inside defibrillator limits. + adjustOxyLoss(health - HEALTH_THRESHOLD_DEAD + 1, forced = TRUE) diff --git a/voidcrew/modules/hyperviolence/overlays_exec/gore_overlays.dm b/voidcrew/modules/hyperviolence/overlays_exec/gore_overlays.dm new file mode 100644 index 00000000000..d8b73c345f8 --- /dev/null +++ b/voidcrew/modules/hyperviolence/overlays_exec/gore_overlays.dm @@ -0,0 +1,204 @@ +// Hyperviolence: stump and skull-wound overlays on human bodies. +// +// Art: icons/gore_overlays.dmi holds the stump and headshot states of CM-SS13's +// icons/mob/humans/dam_human.dmi (cmss13-devs/cmss13 @ e9b299d266c2be727403751c3474e085803b5934), +// made by the CM-SS13 team and licensed CC BY-SA 3.0. Each state comes as a bone layer and a +// greyscale blood layer that is tinted to the mob's blood colour, after CM's +// update_headshot_overlay() (AGPL-3.0, rewritten here for tg's body code). CM's body sprites share +// tg's base, so the art lines up with tg humans without pixel changes. +// +// How it hooks in: tg redraws a carbon's damage overlays (update_damage_overlays) whenever a limb +// comes off or goes back on and whenever a limb's damage state changes, so that is where these are +// rebuilt. They are drawn outside overlays_standing, because a new overlay index would renumber +// every tg layer, on fractional layers between the existing ones (see voidcrew/_DEFINES/hyperviolence.dm). +// - A missing head, arm or leg shows a stump. A limb torn off in the last few minutes leaves a wet +// stump that bleeds through clothing; after that, or after a clean surgical removal, it reads as +// healed over and clothing covers it. The neck stump always shows above the collar. +// - A heavy brute hit to the head opens a skull wound: light, medium or heavy by the head's brute +// damage. It shrinks as the head heals and closes when the damage is mostly gone. + +/mob/living/carbon + /// Hyperviolence: the stump and skull-wound images currently drawn on us. + var/list/hv_gore_overlays + /// Hyperviolence: what hv_gore_overlays was built from, so an unchanged update skips the rebuild. + var/hv_gore_key + /// Hyperviolence: body zone -> world.time that limb was torn off, for wet stumps. Lazy. + var/list/hv_stump_torn_at + +/mob/living/carbon/update_damage_overlays() + . = ..() + hv_update_gore_overlays() + +/mob/living/carbon/regenerate_icons() + // regenerate_icons ends up in update_damage_overlays; forget the key so the gore is redrawn too. + hv_gore_key = null + return ..() + +/// Whether this bodypart is meat on a human-shaped body, the only kind the gore art fits. +/obj/item/bodypart/proc/hv_shows_gore() + if(!(biological_state & BIO_FLESH) || (bodytype & BODYTYPE_ALIEN)) + return FALSE + return (bodyshape & BODYSHAPE_HUMANOID) && !(bodyshape & BODYSHAPE_MONKEY) + +/// Whether a stump torn off at `zone` is still wet. +/mob/living/carbon/proc/hv_stump_is_fresh(zone) + var/torn_at = LAZYACCESS(hv_stump_torn_at, zone) + return !isnull(torn_at) && world.time < torn_at + HV_STUMP_FRESH_TIME + +/// Notes that the limb at `zone` was just torn off, so its stump starts out wet. +/mob/living/carbon/proc/hv_note_torn_off(zone) + LAZYSET(hv_stump_torn_at, zone, world.time) + // Redraw once it has dried. Overriding keeps one timer per mob; every earlier stump is older. + addtimer(CALLBACK(src, PROC_REF(hv_update_gore_overlays)), HV_STUMP_FRESH_TIME + 1, TIMER_UNIQUE|TIMER_OVERRIDE) + +/// Rebuilds the stump and skull-wound overlays if anything they show has changed. +/mob/living/carbon/proc/hv_update_gore_overlays() + var/static/list/stump_zones = list(BODY_ZONE_HEAD, BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + var/list/stumps // zone -> TRUE if the stump is wet + var/skull = 0 + var/obj/item/bodypart/chest = get_bodypart(BODY_ZONE_CHEST) + if(ishuman(src) && chest?.hv_shows_gore()) + for(var/zone in stump_zones) + var/obj/item/bodypart/part = get_bodypart(zone) + if(isnull(part)) + LAZYSET(stumps, zone, hv_stump_is_fresh(zone)) + continue + LAZYREMOVE(hv_stump_torn_at, zone) + if(zone == BODY_ZONE_HEAD && part.hv_shows_gore()) + var/obj/item/bodypart/head/head = part + skull = head.hv_skull_wound + + var/new_key + var/blood_color + if(stumps || skull) + if(can_bleed()) + blood_color = get_bloodtype()?.get_damage_color(src) || BLOOD_COLOR_RED + var/height = HUMAN_HEIGHT_MEDIUM + if(ishuman(src)) + var/mob/living/carbon/human/as_human = src + height = as_human.mob_height + new_key = "[skull]" + for(var/zone in stumps) + new_key += "|[zone][stumps[zone] ? "+" : "-"]" + new_key += "|[islist(blood_color) ? jointext(blood_color, ",") : blood_color]|[height]" + + if(new_key == hv_gore_key && (new_key || !hv_gore_overlays)) + return + hv_gore_key = new_key + if(hv_gore_overlays) + cut_overlay(hv_gore_overlays) + hv_gore_overlays = null + if(isnull(new_key)) + return + hv_gore_overlays = hv_build_gore_images(stumps, skull, blood_color) + add_overlay(hv_gore_overlays) + +/// Builds the overlay images for `stumps` (zone -> wet) and a skull wound of level `skull`. +/mob/living/carbon/proc/hv_build_gore_images(list/stumps, skull, blood_color) + var/list/images = list() + for(var/zone in stumps) + var/wet = stumps[zone] + // A wet stump bleeds through the jumpsuit; a healed one sits under it. The neck always shows. + var/stump_layer = (wet || zone == BODY_ZONE_HEAD) ? HV_STUMP_FRESH_LAYER : HV_STUMP_LAYER + images += hv_gore_image_pair("stump_[zone]", stump_layer, blood_color, wet ? 255 : HV_STUMP_OLD_ALPHA) + if(skull) + images += hv_gore_image_pair(hv_skull_wound_state(skull), HV_SKULL_WOUND_LAYER, blood_color) + if(ishuman(src)) + var/mob/living/carbon/human/as_human = src + if(as_human.mob_height != HUMAN_HEIGHT_MEDIUM) + for(var/image/gore_image as anything in images) + as_human.apply_height_filters(gore_image) + return images + +/// The skull-wound icon state prefix for a wound level of 1 to 3. +/proc/hv_skull_wound_state(level) + var/static/list/states = list("light_headshot", "medium_headshot", "heavy_headshot") + return states[clamp(level, 1, length(states))] + +/// The blood and bone images of one gore state. No blood colour (a body that can't bleed) draws bone only. +/proc/hv_gore_image_pair(state, gore_layer, blood_color, blood_alpha = 255, gore_dir) + var/list/pair = list() + if(blood_color) + var/image/blood = image(HV_GORE_ICON, "[state]_blood", -gore_layer) + blood.appearance_flags = RESET_COLOR + blood.color = blood_color + blood.alpha = blood_alpha + pair += blood + pair += image(HV_GORE_ICON, "[state]_bone", -gore_layer) + if(gore_dir) + for(var/image/gore_image as anything in pair) + gore_image.dir = gore_dir + return pair + +/// Icon states currently drawn by the gore overlays, for tests and debugging. +/mob/living/carbon/proc/hv_gore_states() + var/list/states = list() + for(var/image/gore_image as anything in hv_gore_overlays) + states += gore_image.icon_state + return states + +// ---- Torn-off limbs ---- + +/obj/item/bodypart/drop_limb(special, dismembered, move_to_floor = TRUE) + // Before the parent: it redraws the owner, and the stump should already read as wet. + if(dismembered && !special && iscarbon(owner)) + var/mob/living/carbon/torn_from = owner + torn_from.hv_note_torn_off(body_zone) + return ..() + +// ---- Skull wounds ---- + +/obj/item/bodypart/head + /// Hyperviolence: opened by one heavy brute hit; while open the head shows a skull wound. + var/hv_skull_open = FALSE + /// Hyperviolence: the skull wound shown now, 0 (none) to 3 (heavy). + var/hv_skull_wound = 0 + +/obj/item/bodypart/head/receive_damage(brute = 0, burn = 0, blocked = 0, updating_health = TRUE, forced = FALSE, required_bodytype = null, wound_bonus = 0, exposed_wound_bonus = 0, sharpness = NONE, attack_direction = null, damage_source, wound_clothing = TRUE) + var/brute_before = brute_dam + . = ..() + if(hv_skull_open || brute_dam - brute_before < HV_SKULL_OPEN_HIT || !hv_shows_gore()) + return + hv_skull_open = TRUE + // TRUE tells the caller to redraw damage overlays, which redraws the skull wound with them. + if(hv_update_skull_wound()) + return TRUE + +/obj/item/bodypart/head/update_bodypart_damage_state() + . = ..() + if(hv_update_skull_wound()) + return TRUE + +/// Opens a skull wound if this head can show one, and redraws its owner. +/obj/item/bodypart/head/proc/hv_open_skull() + if(!hv_shows_gore()) + return FALSE + hv_skull_open = TRUE + if(hv_update_skull_wound()) + owner?.update_damage_overlays() + return TRUE + +/// Sets hv_skull_wound from the head's brute damage. Returns TRUE if it changed. +/obj/item/bodypart/head/proc/hv_update_skull_wound() + var/new_level = 0 + if(hv_skull_open) + if(brute_dam >= HV_SKULL_HEAVY_BRUTE) + new_level = 3 + else if(brute_dam >= HV_SKULL_MEDIUM_BRUTE) + new_level = 2 + else if(brute_dam >= HV_SKULL_LIGHT_BRUTE) + new_level = 1 + else + hv_skull_open = FALSE // healed shut + if(new_level == hv_skull_wound) + return FALSE + hv_skull_wound = new_level + return TRUE + +/obj/item/bodypart/head/get_limb_icon(dropped, mob/living/carbon/update_on) + . = ..() + // On a body the mob draws the wound (hv_update_gore_overlays); a severed head carries its own. + if(!dropped || !hv_skull_wound || !islist(.)) + return + var/blood_color = get_color_from_blood_list(blood_dna_info) + . += hv_gore_image_pair(hv_skull_wound_state(hv_skull_wound), HV_SKULL_WOUND_LAYER, blood_color, gore_dir = SOUTH) diff --git a/voidcrew/modules/hyperviolence/overlays_exec/icons/gore_overlays.dmi b/voidcrew/modules/hyperviolence/overlays_exec/icons/gore_overlays.dmi new file mode 100644 index 0000000000000000000000000000000000000000..1cac5520b25f548e0fbea2b89eed016a3810ae41 GIT binary patch literal 2770 zcmb`JdsNbC8pnTL64Od;+R(gBcCk~FwHleAV$;PeGp96^L@Fdf==0YUM*Bk*ByXK?XnVz2;^RB*GM zXYzdO63{2SYDEN-f<|m{zHZyM=^y)tkf#wo%&Rfd?H&iOkKFRosErREh;=U%?+wVw zGb6WM_Op3GWQ<5o5S+uRg>jM3YSy#!a|13u`r+in0&0~%G8AQ8H|Trpmm}0_>y2hd z*mJhVw&5+X2d&v@ha8H$oG%1C_W^B=rMT@BT-<-$^Zh-8+ySS&YtA<#Hh`v!G13R` z5F3A9Rr<4IAo8-t>-QayPBkNxe`A?Zx$~-h0I;~(XZOy4q=LzQc(hHTU6TxD5!l#c z3D*z%Zh6Ss)jvfW3RPBy6|$lqakQs<3+>1mF!b$HgCiR+Y^R;I0k2=(^Ykrb^$Sac z)2wo;e{II01WBjlt%Ml5#r#I$)`5Y{ND=eKI^xiHJXGHqGs6ao;rc+mA#iaqVE7$i zydE(31-{P(s;Y+W+zFmmj{z*=!uf>h)b4pI|MX0Mwqu)ImFaI?4c`I4)yjo(krRru zLyKp}Xr{_3!Rd33-8jwjT9HG?+j2UyAXJ{ ze$mv_*%%UumEi(5dFDf~n2nVyAc-5It=xzvH9L4&Ggsehs~-9I z*E0O84q;HENP)4(-)=&I2-qklZuH7dmi~Q8?5n0@js&ddoU%O@112ioKuVU-pDufQd#2`32 zc__wSs9{3A*@=O<&ux9h%}?FLJ=w3rHLTDW$=EBrNt!vO-Gy;Ofz_weJI?1Y0u$1I zui12{HzT>zNf~%dI>-h2gVhh+g`Lo7cJpCZ&7CH*oP?CIcS+E~1gN>?GC1fft?_N- z`Dd5?DHLNR*`BB-9*x1+R#sYFyL%JpI5_vZsVK8Dw01l49+b$lxbWW16PibBFIZuX z=~q{xxl~kLN-W)%pw3&P&n;3x?e9?gVLNg$n3ybPZXiA?6kNUhEL~O}0{#&0Y{gEI zMLhuTwB?R;up+pp|`^|?0*vW@CjI!U|Y*Z(v&Y<5i6(6$Axg+4S6SdwC*G}l<+(mTsa zws@-IG;9l3IK`X{x$;aVyTr+<&sa`|3<`?&{5Na>rDxk=P}HvM;aC< z4BLL_>f`yC*9-(=E##KEU+%lQKk^17uku%35W--W>ICy4d|3u}x+f^yA(5|ltKLk%@O_{cv&dwO3VcljiLc&1)wtyTXQ=Zk?~ c;ub)f->NbUahvbzmQ=uJkKgW!ACF)B19WQle*gdg literal 0 HcmV?d00001 diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..1bdc78b3d61 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -90,6 +90,7 @@ #include "voidcrew_helpers.dm" #include "voidcrew_hull_containment.dm" #include "voidcrew_hull_survey.dm" +#include "voidcrew_hyperviolence_overlays_exec.dm" #include "voidcrew_launch_access.dm" #include "voidcrew_launch_cargo.dm" #include "voidcrew_launch_fabrication.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm new file mode 100644 index 00000000000..5d281e0fa6a --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm @@ -0,0 +1,123 @@ +// Hyperviolence (voidcrew/modules/hyperviolence/overlays_exec/): stump and skull-wound overlays, +// gun executions. Voidcrew defines are not visible here; literals carry the define name in a comment. + +/// Every icon state the gore overlays can ask for exists in the sheet. +/datum/unit_test/hv_gore_icon_states + +/datum/unit_test/hv_gore_icon_states/Run() + var/gore_icon = 'voidcrew/modules/hyperviolence/overlays_exec/icons/gore_overlays.dmi' // HV_GORE_ICON + var/list/prefixes = list() + for(var/zone in list(BODY_ZONE_HEAD, BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + prefixes += "stump_[zone]" + for(var/level in 1 to 3) + prefixes += hv_skull_wound_state(level) + for(var/prefix in prefixes) + for(var/part in list("blood", "bone")) + TEST_ASSERT(icon_exists(gore_icon, "[prefix]_[part]"), "Gore sheet is missing [prefix]_[part]") + +/// A torn-off limb leaves a wet stump, a clean removal a healed one, and a limb put back clears it. +/datum/unit_test/hv_stump_overlays + +/datum/unit_test/hv_stump_overlays/Run() + var/mob/living/carbon/human/consistent/dummy = allocate(/mob/living/carbon/human/consistent) + + var/obj/item/bodypart/arm = dummy.get_bodypart(BODY_ZONE_L_ARM) + TEST_ASSERT(arm.dismember(), "The left arm would not come off") + TEST_ASSERT(dummy.hv_stump_is_fresh(BODY_ZONE_L_ARM), "A torn-off arm did not leave a wet stump") + var/list/states = dummy.hv_gore_states() + TEST_ASSERT("stump_l_arm_blood" in states, "A torn-off arm drew no bloody stump: [english_list(states)]") + TEST_ASSERT("stump_l_arm_bone" in states, "A torn-off arm drew no bone in its stump") + + var/obj/item/bodypart/arm/left/new_arm = allocate(/obj/item/bodypart/arm/left) + TEST_ASSERT(new_arm.try_attach_limb(dummy), "Could not attach a new left arm") + TEST_ASSERT(!("stump_l_arm_blood" in dummy.hv_gore_states()), "The stump stayed after a new arm went on") + TEST_ASSERT(isnull(LAZYACCESS(dummy.hv_stump_torn_at, BODY_ZONE_L_ARM)), "The torn-off time stayed after a new arm went on") + + // A clean removal (surgery, not dismemberment) shows a healed stump straight away. + var/obj/item/bodypart/leg = dummy.get_bodypart(BODY_ZONE_R_LEG) + leg.drop_limb() + TEST_ASSERT(!dummy.hv_stump_is_fresh(BODY_ZONE_R_LEG), "A clean removal counted as a torn-off limb") + var/image/leg_blood + for(var/image/gore_image as anything in dummy.hv_gore_overlays) + if(gore_image.icon_state == "stump_r_leg_blood") + leg_blood = gore_image + TEST_ASSERT_NOTNULL(leg_blood, "A removed leg drew no stump") + TEST_ASSERT_EQUAL(leg_blood.alpha, 140, "A healed stump was drawn wet") // HV_STUMP_OLD_ALPHA + + var/obj/item/bodypart/head = dummy.get_bodypart(BODY_ZONE_HEAD) + TEST_ASSERT(head.dismember(), "The head would not come off") + TEST_ASSERT("stump_head_blood" in dummy.hv_gore_states(), "Decapitation drew no neck stump") + +/// One heavy hit opens a skull wound, more damage widens it, healing closes it; small hits never open one. +/datum/unit_test/hv_skull_wound + +/datum/unit_test/hv_skull_wound/Run() + var/mob/living/carbon/human/consistent/dummy = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/head/head = dummy.get_bodypart(BODY_ZONE_HEAD) + + for(var/i in 1 to 3) + dummy.apply_damage(10, BRUTE, BODY_ZONE_HEAD, wound_bonus = CANT_WOUND) + TEST_ASSERT_EQUAL(head.hv_skull_wound, 0, "Small hits opened a skull wound") + + dummy.apply_damage(25, BRUTE, BODY_ZONE_HEAD, wound_bonus = CANT_WOUND) // 55 brute: medium (HV_SKULL_MEDIUM_BRUTE 45) + TEST_ASSERT_EQUAL(head.hv_skull_wound, 2, "A heavy hit on a hurt head did not open a medium skull wound") + TEST_ASSERT("medium_headshot_blood" in dummy.hv_gore_states(), "The medium skull wound was not drawn") + + dummy.apply_damage(30, BRUTE, BODY_ZONE_HEAD, wound_bonus = CANT_WOUND) // 85 brute: heavy (HV_SKULL_HEAVY_BRUTE 80) + TEST_ASSERT_EQUAL(head.hv_skull_wound, 3, "More damage did not widen the skull wound") + TEST_ASSERT("heavy_headshot_blood" in dummy.hv_gore_states(), "The heavy skull wound was not drawn") + + dummy.heal_overall_damage(brute = 70) // 15 brute: under HV_SKULL_LIGHT_BRUTE 20 + TEST_ASSERT_EQUAL(head.hv_skull_wound, 0, "Healing did not close the skull wound") + TEST_ASSERT(!head.hv_skull_open, "A healed skull stayed open") + for(var/state in dummy.hv_gore_states()) + TEST_ASSERT(!findtext(state, "headshot"), "A healed head still draws [state]") + +/// Only a downed, cuffed or held humanoid can be executed, and a light round leaves the head on but kills. +/datum/unit_test/hv_gun_execution + +/datum/unit_test/hv_gun_execution/Run() + var/mob/living/carbon/human/consistent/gunner = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) + ADD_TRAIT(victim, TRAIT_PIERCEIMMUNE, INNATE_TRAIT) // no stray shrapnel + var/obj/item/gun/ballistic/automatic/pistol/gun = allocate(/obj/item/gun/ballistic/automatic/pistol) + gun.can_misfire = FALSE + gunner.put_in_active_hand(gun, forced = TRUE) + gunner.zone_selected = BODY_ZONE_PRECISE_MOUTH + + TEST_ASSERT(!gun.hv_execution_possible(gunner, victim), "A standing, free target could be executed") + TEST_ASSERT(!gun.hv_execution_possible(gunner, gunner), "The shooter could execute themselves") + victim.Knockdown(30 SECONDS) + TEST_ASSERT(gun.hv_execution_possible(gunner, victim), "A downed target could not be executed") + + // The real click path: a point-blank, mouth-aimed shot, the wind-up, then the shot. + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + gun.fire_gun(victim, gunner, TRUE, null) + TEST_ASSERT_EQUAL(victim.stat, DEAD, "The executed target survived") + TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_HEAD), head, "A 9mm execution took the head off") + TEST_ASSERT_EQUAL(head.hv_skull_wound, 3, "A 9mm execution left no heavy skull wound") + TEST_ASSERT_NOTNULL(victim.get_organ_slot(ORGAN_SLOT_BRAIN), "A 9mm execution destroyed the brain") + TEST_ASSERT(victim.getBruteLoss() < 180, "An execution left too much trauma to defibrillate") // MAX_REVIVE_BRUTE_DAMAGE is 180 + +/// A heavy enough round bursts the head, and the brain survives it. +/datum/unit_test/hv_gun_execution_burst + +/datum/unit_test/hv_gun_execution_burst/Run() + var/mob/living/carbon/human/consistent/gunner = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) + ADD_TRAIT(victim, TRAIT_PIERCEIMMUNE, INNATE_TRAIT) + var/obj/item/gun/ballistic/automatic/pistol/gun = allocate(/obj/item/gun/ballistic/automatic/pistol) + gun.can_misfire = FALSE + gunner.put_in_active_hand(gun, forced = TRUE) + gunner.zone_selected = BODY_ZONE_PRECISE_MOUTH + gun.chambered.loaded_projectile.damage = 60 // doubled at the muzzle: over HV_EXECUTION_BURST_DAMAGE 80 + ADD_TRAIT(victim, TRAIT_RESTRAINED, TRAIT_SOURCE_UNIT_TESTS) + TEST_ASSERT(gun.hv_execution_possible(gunner, victim), "A restrained target could not be executed") + + TEST_ASSERT(gun.hv_fire_execution(gunner, victim, null), "The execution shot did not fire") + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_HEAD), "A heavy round did not burst the head") + TEST_ASSERT_EQUAL(victim.stat, DEAD, "The executed target survived") + var/found_brain = FALSE + for(var/obj/item/organ/brain/brain in range(7, victim)) + found_brain = TRUE + TEST_ASSERT(found_brain, "Bursting the head destroyed the brain") From 3644023e1acbc6ab89e36008ce70fcc674f80b1a Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:37:12 -0800 Subject: [PATCH 016/472] Add hyperviolence boss finishers A boss gets hold of someone who is down and kills them in stages: a telegraphed wind-up (the victim is marked and can still be pulled clear), a pin, a bite or blow, then the gore. Damage on the boss breaks its hold and it reels; while it works it is rooted and takes 1.5x damage. Both mobs carry self-expiring status effects and the finisher stops itself after its longest length, so nothing holds a victim forever. A torn-off head is dropped where the body lies with the brain inside. Users: the hoarfrost matriarch tears off an arm, the Black Knight cleaves through the hips, a derelict nest's alien empress bites the head off and the Robust One beats the head in with hv_burst_head. The AI only tries on its own target or someone right next to it, once a minute. Stage/safety/interrupt design adapted from Dead Space 13's execution framework (DS-13-Dev-Team/DS13 @ 1751066f68b8, AGPL-3.0, code/modules/mob/living/abilities/execution/). No DS13 art or sound used; telegraph art and sounds are tg's own. --- tgstation.dme | 5 + voidcrew/_DEFINES/hyperviolence.dm | 22 + .../hyperviolence/finishers/finisher.dm | 385 ++++++++++++++++++ .../hyperviolence/finishers/finisher_ai.dm | 237 +++++++++++ .../finishers/finisher_effects.dm | 118 ++++++ .../finishers/finisher_stages.dm | 296 ++++++++++++++ .../hyperviolence/finishers/finisher_users.dm | 255 ++++++++++++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_hv_finishers.dm | 252 ++++++++++++ 9 files changed, 1571 insertions(+) create mode 100644 voidcrew/modules/hyperviolence/finishers/finisher.dm create mode 100644 voidcrew/modules/hyperviolence/finishers/finisher_ai.dm create mode 100644 voidcrew/modules/hyperviolence/finishers/finisher_effects.dm create mode 100644 voidcrew/modules/hyperviolence/finishers/finisher_stages.dm create mode 100644 voidcrew/modules/hyperviolence/finishers/finisher_users.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_hv_finishers.dm diff --git a/tgstation.dme b/tgstation.dme index 2f7df14adc8..32720fc3cc6 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7230,6 +7230,11 @@ #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" #include "voidcrew\modules\hyperviolence\_core.dm" +#include "voidcrew\modules\hyperviolence\finishers\finisher.dm" +#include "voidcrew\modules\hyperviolence\finishers\finisher_ai.dm" +#include "voidcrew\modules\hyperviolence\finishers\finisher_effects.dm" +#include "voidcrew\modules\hyperviolence\finishers\finisher_stages.dm" +#include "voidcrew\modules\hyperviolence\finishers\finisher_users.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" #include "voidcrew\modules\intents\hud_intent.dm" diff --git a/voidcrew/_DEFINES/hyperviolence.dm b/voidcrew/_DEFINES/hyperviolence.dm index f0e0c2135e3..7c7ce18c0ed 100644 --- a/voidcrew/_DEFINES/hyperviolence.dm +++ b/voidcrew/_DEFINES/hyperviolence.dm @@ -4,3 +4,25 @@ #define HV_LINE_BOLD 1 /// A big bold-italic line, for severed limbs, burst heads and kills. #define HV_LINE_HUGE 2 + +// Finishers (voidcrew/modules/hyperviolence/finishers/): a boss kills someone who is down, in stages. + +/// Blackboard key: the mob's finisher action (/datum/action/cooldown/mob_cooldown/hv_finisher) +#define BB_HV_FINISHER_ACTION "BB_hv_finisher_action" +/// Blackboard key: the downed victim the mob is closing on to finish +#define BB_HV_FINISHER_VICTIM "BB_hv_finisher_victim" +/// Offset source for a finisher's poses +#define HV_FINISHER_OFFSET "hv_finisher" + +/// Why a finisher ended. The killing blow landed and every stage ran. +#define HV_FINISHER_DONE 1 +/// The victim was pulled clear, or the attacker was moved off them. +#define HV_FINISHER_LOST_GRIP 2 +/// The attacker took enough damage to let go. +#define HV_FINISHER_DAMAGED 3 +/// The attacker died, was deleted or was knocked out. +#define HV_FINISHER_ATTACKER_DOWN 4 +/// The victim died or was deleted before the killing blow. +#define HV_FINISHER_VICTIM_GONE 5 +/// It ran past its longest possible length. A safety net, never the plan. +#define HV_FINISHER_TIMED_OUT 6 diff --git a/voidcrew/modules/hyperviolence/finishers/finisher.dm b/voidcrew/modules/hyperviolence/finishers/finisher.dm new file mode 100644 index 00000000000..39ffd02112b --- /dev/null +++ b/voidcrew/modules/hyperviolence/finishers/finisher.dm @@ -0,0 +1,385 @@ +/** + * # Hyperviolence finishers + * + * A boss or elite gets hold of someone who is down and kills them in stages: a wind-up everyone + * can see, the pin, a beat or two, then the gore. Each stage is a /datum/hv_finisher_stage with a + * duration, a line and an effect (finisher_stages.dm). + * + * The rules that keep it fair: + * - It only starts on someone alive and down (crit, knocked over or stunned), and never on an + * ally or on someone another finisher already has (hv_finisher_valid_victim()). + * - The wind-up holds nobody: pull the victim clear and it never starts. + * - Every tick a safety check can call it off: the attacker died, was stunned or was knocked + * away, the victim was dragged off, died first or was deleted. + * - Damage on the attacker breaks its hold ([var/interrupt_damage]), and it reels for a moment. + * While it works it is rooted and takes [var/vulnerability] times the damage, so the victim's + * crew can save them. + * - Both mobs carry a status effect that expires on its own (finisher_effects.dm), and the + * finisher stops itself after its longest possible length, so nothing holds a victim forever. + * - The kill never destroys the brain: a torn-off head lands at the victim's feet with the brain + * inside, and hv_burst_head() drops it intact. + * + * Design adapted from Dead Space 13's execution framework (DS-13-Dev-Team/DS13 @ 1751066f68b8, + * AGPL-3.0: code/modules/mob/living/abilities/execution/execution.dm and execution_stage.dm): + * stage datums run in order, a safety check, a damage interrupt and extra incoming damage while + * busy. Rewritten for tg's basic mobs; no DS13 art or sound is used. + * + * Who finishes whom, and walking up to them: finisher_ai.dm. The bosses that use it: + * finisher_users.dm. + */ + +/// How often a running finisher checks itself +#define HV_FINISHER_TICK (0.2 SECONDS) +/// Slack on top of the stage durations before the timeout safety net ends it +#define HV_FINISHER_SLACK (5 SECONDS) + +/datum/hv_finisher + /// For logs only + var/name = "finisher" + /// The mob doing it + var/mob/living/user + /// The person being finished + var/mob/living/carbon/victim + /// Stage types in order. Each instance gets its own stage datums in New(). + var/list/stages = list() + /// Index of the stage running now, 0 before the first + var/stage_index = 0 + /// The stage running now + var/datum/hv_finisher_stage/current_stage + /// world.time the current stage began + var/stage_started_at = 0 + /// world.time it began + var/started_at = 0 + /// TRUE from start() until stop() + var/running = FALSE + /// TRUE once the killing blow has landed. After that nothing calls it off. + var/completed = FALSE + /// TRUE while the victim is held down + var/gripped = FALSE + /// TRUE once the victim has been held down at all + var/ever_gripped = FALSE + /// Health the attacker can lose before it lets go + var/interrupt_damage = 40 + /// Multiplier on damage the attacker takes while it works + var/vulnerability = 1.5 + /// How long the attacker reels after being made to let go + var/stagger_time = 2 SECONDS + /// The attacker's health when it began + var/start_health = 0 + /// Body zones the kill goes for, in order of preference. pick_zone() takes the first the victim can lose. + var/list/zone_preference = list(BODY_ZONE_HEAD) + /// The zone picked at the start, or null if the victim has none of them to lose + var/target_zone + /// telegraph.dmi state marking the victim during the wind-up + var/telegraph_state = "skull" + /// The wind-up's mark on the victim + var/obj/effect/temp_visual/telegraphing/hv_finisher_mark/mark + /// Timer id of the next tick + var/tick_timer + /// How it ended (HV_FINISHER_*), once it has + var/result + /// Called once when it ends: (datum/hv_finisher/finisher, result) + var/datum/callback/on_end + +/datum/hv_finisher/New(mob/living/user, mob/living/carbon/victim, datum/callback/on_end) + . = ..() + src.user = user + src.victim = victim + src.on_end = on_end + var/list/stage_types = stages + stages = list() + for(var/stage_type in stage_types) + stages += new stage_type(src) + +/datum/hv_finisher/Destroy() + if(running) + stop(HV_FINISHER_TIMED_OUT) + on_end = null + current_stage = null + QDEL_LIST(stages) + QDEL_NULL(mark) + user = null + victim = null + return ..() + +/// Sum of every stage's duration +/datum/hv_finisher/proc/total_duration() + . = 0 + for(var/datum/hv_finisher_stage/stage as anything in stages) + . += stage.duration + +/// The longest it can possibly run before the timeout safety net ends it +/datum/hv_finisher/proc/longest_duration() + return total_duration() + HV_FINISHER_SLACK + +/// Whether it can begin right now: a valid victim, right next to a conscious attacker +/datum/hv_finisher/proc/can_start() + if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED)) + return FALSE + if(!hv_finisher_valid_victim(user, victim)) + return FALSE + return get_dist(user, victim) <= 1 && user.Adjacent(victim) + +/** + * Begins: marks both mobs, enters the first stage and starts ticking. + * Returns FALSE, having changed nothing, if it cannot begin. + */ +/datum/hv_finisher/proc/start() + if(running || completed || !length(stages) || !can_start()) + return FALSE + running = TRUE + started_at = world.time + start_health = user.health + pick_zone() + var/longest = longest_duration() + user.apply_status_effect(/datum/status_effect/hv_finishing, longest, vulnerability) + victim.apply_status_effect(/datum/status_effect/hv_finisher_hold, longest) + RegisterSignals(user, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING), PROC_REF(on_user_down)) + RegisterSignal(user, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_user_damaged)) + RegisterSignal(victim, COMSIG_LIVING_DEATH, PROC_REF(on_victim_died)) + RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_victim_deleted)) + RegisterSignal(user, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(victim, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + user.face_atom(victim) + log_combat(user, victim, "began a finisher on", addition = "([name])") + advance() + if(running) + schedule_tick() + return TRUE + +/// Picks the body zone the kill goes for from [var/zone_preference] +/datum/hv_finisher/proc/pick_zone() + target_zone = null + for(var/zone in zone_preference) + var/obj/item/bodypart/part = victim.get_bodypart(zone) + if(part?.can_dismember()) + target_zone = zone + return zone + +/datum/hv_finisher/proc/schedule_tick() + tick_timer = addtimer(CALLBACK(src, PROC_REF(tick)), HV_FINISHER_TICK, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// The safety check, the damage check, the stage's beat and the move to the next stage +/datum/hv_finisher/proc/tick() + tick_timer = null + if(!running) + return + var/reason = safety_check() + if(!reason && !completed && took_too_much_damage()) + reason = HV_FINISHER_DAMAGED + if(reason) + end_early(reason) + return + if(world.time - started_at > longest_duration()) + stop(HV_FINISHER_TIMED_OUT) + return + var/elapsed = world.time - stage_started_at + if(!current_stage.effect_done && elapsed >= current_stage.effect_delay) + fire_effect() + if(!running) + return + if(elapsed >= current_stage.duration) + advance() + if(!running) + return + schedule_tick() + +/** + * Leaves the current stage (firing its beat first if it never fired) and enters the next. + * Past the last stage, it is done. + */ +/datum/hv_finisher/proc/advance() + if(!running) + return + if(current_stage) + if(!current_stage.effect_done) + fire_effect() + if(!running) + return + current_stage.exit() + stage_index++ + if(stage_index > length(stages)) + current_stage = null + completed = TRUE + stop(HV_FINISHER_DONE) + return + current_stage = stages[stage_index] + stage_started_at = world.time + if(current_stage.pins) + take_grip() + else + release_grip() + current_stage.enter() + if(!running) + return + if(current_stage.effect_delay <= 0) + fire_effect() + +/// Fires the current stage's beat. A killing blow marks it complete before it lands, so the death it causes doesn't call it off. +/datum/hv_finisher/proc/fire_effect() + current_stage.effect_done = TRUE + if(current_stage.killing_blow && !completed) + completed = TRUE + log_combat(user, victim, "finished", addition = "([name])") + current_stage.effect() + +/// Holds the victim down from here on +/datum/hv_finisher/proc/take_grip() + if(gripped) + return + gripped = TRUE + ever_gripped = TRUE + QDEL_NULL(mark) + var/datum/status_effect/hv_finisher_hold/hold = victim.has_status_effect(/datum/status_effect/hv_finisher_hold) + hold?.set_gripped(TRUE) + +/// Lets the victim go, for any stages after the kill +/datum/hv_finisher/proc/release_grip() + if(!gripped) + return + gripped = FALSE + var/datum/status_effect/hv_finisher_hold/hold = victim.has_status_effect(/datum/status_effect/hv_finisher_hold) + hold?.set_gripped(FALSE) + +/// Returns the HV_FINISHER_* reason it can't go on, or 0 if all is well +/datum/hv_finisher/proc/safety_check() + if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED)) + return HV_FINISHER_ATTACKER_DOWN + if(QDELETED(victim)) + return HV_FINISHER_VICTIM_GONE + if(!completed && victim.stat == DEAD) + return HV_FINISHER_VICTIM_GONE + if(!isturf(user.loc) || !isturf(victim.loc) || user.z != victim.z || get_dist(user, victim) > 1) + return HV_FINISHER_LOST_GRIP + // An admin heal, or anything else that took the hold off, frees them + if(!victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return HV_FINISHER_LOST_GRIP + return 0 + +/// Whether the attacker has lost [var/interrupt_damage] health since it began +/datum/hv_finisher/proc/took_too_much_damage() + return (start_health - user.health) >= interrupt_damage + +/// Ends it before it is done. After the killing blow there is nothing left to save, so it simply stops. +/datum/hv_finisher/proc/end_early(reason) + if(!running) + return + if(completed) + stop(HV_FINISHER_DONE) + return + interrupt(reason) + +/// Calls it off before the killing blow, with what the room sees +/datum/hv_finisher/proc/interrupt(reason) + if(!running || completed) + return + for(var/datum/hv_finisher_stage/stage as anything in stages) + if(stage.entered) + stage.on_interrupt() + switch(reason) + if(HV_FINISHER_DAMAGED) + user.visible_message(span_danger("[user] staggers back and lets go of [victim]!")) + // Before stop(), so the AI stays held through the hand-over + user.apply_status_effect(/datum/status_effect/hv_finisher_reeling, stagger_time) + if(HV_FINISHER_LOST_GRIP) + if(gripped && !QDELETED(user) && !QDELETED(victim)) + user.visible_message(span_danger("[user] loses [user.p_their()] grip on [victim].")) + stop(reason) + +/// Ends it for any reason: frees both mobs, puts their poses back and reports the result +/datum/hv_finisher/proc/stop(reason) + if(!running) + return + running = FALSE + result = reason + if(tick_timer) + deltimer(tick_timer) + tick_timer = null + QDEL_NULL(mark) + if(user) + UnregisterSignal(user, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_MOVABLE_MOVED)) + if(!QDELETED(user)) + user.remove_status_effect(/datum/status_effect/hv_finishing) + user.remove_offsets(HV_FINISHER_OFFSET) + if(victim) + UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOVABLE_MOVED)) + if(!QDELETED(victim)) + victim.remove_status_effect(/datum/status_effect/hv_finisher_hold) + victim.remove_offsets(HV_FINISHER_OFFSET) + gripped = FALSE + for(var/datum/hv_finisher_stage/stage as anything in stages) + if(stage.entered) + stage.on_stop() + if(on_end) + var/datum/callback/callback = on_end + on_end = null + callback.Invoke(src, reason) + +/// Leans the attacker `lunge` pixels toward the victim and lifts it `rise` pixels. Each call replaces the last pose. +/datum/hv_finisher/proc/pose_user(lunge = 0, rise = 0) + if(QDELETED(user) || QDELETED(victim)) + return + var/toward = get_dir(user, victim) + var/dx = (toward & EAST) ? 1 : ((toward & WEST) ? -1 : 0) + var/dy = (toward & NORTH) ? 1 : ((toward & SOUTH) ? -1 : 0) + user.add_offsets(HV_FINISHER_OFFSET, null, dx * lunge, dy * lunge + rise) + +/// Puts the wind-up's mark on the victim, following them until it is taken off +/datum/hv_finisher/proc/place_mark(duration) + QDEL_NULL(mark) + var/turf/spot = get_turf(victim) + if(spot) + mark = new(spot, victim, telegraph_state, duration) + +// ===== Signal handlers ===== + +/datum/hv_finisher/proc/on_user_down(datum/source) + SIGNAL_HANDLER + end_early(HV_FINISHER_ATTACKER_DOWN) + +/datum/hv_finisher/proc/on_user_damaged(datum/source) + SIGNAL_HANDLER + if(running && !completed && took_too_much_damage()) + end_early(HV_FINISHER_DAMAGED) + +/datum/hv_finisher/proc/on_victim_died(datum/source) + SIGNAL_HANDLER + if(!completed) + end_early(HV_FINISHER_VICTIM_GONE) + +/datum/hv_finisher/proc/on_victim_deleted(datum/source) + SIGNAL_HANDLER + end_early(HV_FINISHER_VICTIM_GONE) + +/// Either of them moved: dragged off, thrown or pushed apart +/datum/hv_finisher/proc/on_moved(datum/source) + SIGNAL_HANDLER + var/reason = safety_check() + if(reason) + end_early(reason) + +/** + * Whether `user` may start a finisher on `victim`: a carbon who is alive, down and on the floor + * where `user` stands, not an ally, not strapped to anything and not already being finished. + */ +/proc/hv_finisher_valid_victim(mob/living/user, mob/living/carbon/victim) + if(!iscarbon(victim) || !isliving(user) || QDELETED(victim) || QDELETED(user) || victim == user) + return FALSE + if(victim.stat == DEAD || HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + if(!hv_finisher_is_down(victim)) + return FALSE + if(victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return FALSE + if(victim.buckled || !isturf(victim.loc) || !isturf(user.loc) || victim.z != user.z) + return FALSE + if(user.faction_check_atom(victim)) + return FALSE + return TRUE + +/// Whether `victim` is down: in crit, on the floor, or stunned +/proc/hv_finisher_is_down(mob/living/victim) + return victim.stat >= SOFT_CRIT || victim.body_position == LYING_DOWN || HAS_TRAIT(victim, TRAIT_INCAPACITATED) + +#undef HV_FINISHER_TICK +#undef HV_FINISHER_SLACK diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_ai.dm b/voidcrew/modules/hyperviolence/finishers/finisher_ai.dm new file mode 100644 index 00000000000..917ed94e83e --- /dev/null +++ b/voidcrew/modules/hyperviolence/finishers/finisher_ai.dm @@ -0,0 +1,237 @@ +/** + * # Finishers: who does them, and the AI that picks a victim + * + * A mob gets a finisher with AddComponent(/datum/component/hv_finisher_user, finisher_type). The + * component grants the mob an ability with no button (only its AI uses it), puts the ability on + * its AI's blackboard and slots /datum/ai_planning_subtree/hv_finisher into + * its planning, right after the subtrees that pick a target. Some Voidcrew code swaps a mob's AI + * controller after it spawns (boarding pods hand bosses a patrolling controller); the component + * follows the mob onto the new controller. + * + * The subtree only acts when the ability is off cooldown and someone valid (finisher.dm, + * hv_finisher_valid_victim()) is down: the mob's own target within + * [var/datum/action/cooldown/mob_cooldown/hv_finisher/seek_range] tiles, or anyone right next to + * it. It walks over and starts the finisher, and gives up on a victim it can't reach in + * [var/datum/action/cooldown/mob_cooldown/hv_finisher/seek_timeout]. + */ + +/datum/component/hv_finisher_user + /// The finisher ability it granted + var/datum/action/cooldown/mob_cooldown/hv_finisher/finisher_action + /// The AI controller it set up, watched so it can follow the mob onto a replacement + var/datum/ai_controller/watched_controller + +/datum/component/hv_finisher_user/Initialize(finisher_type, cooldown) + if(!isliving(parent) || !ispath(finisher_type, /datum/hv_finisher)) + return COMPONENT_INCOMPATIBLE + finisher_action = new(parent) + finisher_action.finisher_type = finisher_type + if(isnum(cooldown)) + finisher_action.cooldown_time = cooldown + finisher_action.Grant(parent) + adopt_controller() + +/datum/component/hv_finisher_user/Destroy(force) + release_controller() + QDEL_NULL(finisher_action) + return ..() + +/// Sets up the mob's current AI controller: the ability on its blackboard and the subtree in its plan +/datum/component/hv_finisher_user/proc/adopt_controller() + var/atom/movable/pawn = parent + var/datum/ai_controller/controller = pawn?.ai_controller + if(QDELETED(controller) || controller == watched_controller || QDELETED(finisher_action)) + return + release_controller() + watched_controller = controller + controller.set_blackboard_key(BB_HV_FINISHER_ACTION, finisher_action) + hv_finisher_add_subtree(controller) + RegisterSignal(controller, COMSIG_AI_CONTROLLER_UNPOSSESSED_PAWN, PROC_REF(on_controller_lost)) + +/datum/component/hv_finisher_user/proc/release_controller() + if(watched_controller) + UnregisterSignal(watched_controller, COMSIG_AI_CONTROLLER_UNPOSSESSED_PAWN) + watched_controller = null + +/// The mob's controller let go of it, usually because a replacement is being built right now +/datum/component/hv_finisher_user/proc/on_controller_lost(datum/ai_controller/source) + SIGNAL_HANDLER + release_controller() + addtimer(CALLBACK(src, PROC_REF(adopt_controller)), 1) + +/** + * Puts the finisher subtree into `controller`'s plan, after the leading subtrees that escape, + * pick or hold a target, and before any attack or ability. Does nothing if it is already there. + * Returns TRUE if it added it. + */ +/proc/hv_finisher_add_subtree(datum/ai_controller/controller) + var/static/list/goes_first = typecacheof(list( + /datum/ai_planning_subtree/call_reinforcements, + /datum/ai_planning_subtree/escape_captivity, + /datum/ai_planning_subtree/pet_planning, + /datum/ai_planning_subtree/simple_find_target, + /datum/ai_planning_subtree/target_retaliate, + /datum/ai_planning_subtree/aggressive_find_target, + )) + var/list/new_plan = list() + var/insert_at = 1 + for(var/datum/ai_planning_subtree/subtree in controller.planning_subtrees) + if(istype(subtree, /datum/ai_planning_subtree/hv_finisher)) + return FALSE + new_plan += subtree.type + if(insert_at == length(new_plan) && is_type_in_typecache(subtree, goes_first)) + insert_at++ + new_plan.Insert(insert_at, /datum/ai_planning_subtree/hv_finisher) + controller.replace_planning_subtrees(new_plan) + return TRUE + +// ===== The ability ===== + +/// Starts the mob's finisher on someone who is down next to it +/datum/action/cooldown/mob_cooldown/hv_finisher + name = "Finish Them" + desc = "Finish off someone who is down." + button_icon = 'icons/mob/telegraphing/telegraph.dmi' + button_icon_state = "skull" + cooldown_time = 60 SECONDS + melee_cooldown_time = 0 + // Its own clock only: a shared cooldown would put the mob's other abilities on a minute's wait + shared_cooldown = NONE + click_to_activate = TRUE + // The AI's move only. A player who takes the mob over (a sentience potion, an admin) gets no + // button, so it can never be turned on other crews. + owner_has_control = FALSE + /// The /datum/hv_finisher it runs + var/finisher_type = /datum/hv_finisher + /// The one running now + var/datum/hv_finisher/active + /// Cooldown when the victim was pulled clear before it got hold + var/missed_cooldown = 15 SECONDS + /// Cooldown when the AI tried from somewhere it couldn't reach them + var/blocked_cooldown = 5 SECONDS + /// How far the AI walks to finish its own target + var/seek_range = 3 + /// How long the AI tries to reach one victim before giving up on them for [var/missed_cooldown] + var/seek_timeout = 6 SECONDS + /// The victim the AI is walking to, and since when + var/datum/weakref/seek_target + var/seek_started_at = 0 + +/datum/action/cooldown/mob_cooldown/hv_finisher/Destroy() + if(active) + var/datum/hv_finisher/finisher = active + active = null + finisher.on_end = null + finisher.stop(HV_FINISHER_ATTACKER_DOWN) + qdel(finisher) + seek_target = null + return ..() + +/datum/action/cooldown/mob_cooldown/hv_finisher/IsAvailable(feedback = FALSE) + return ..() && isnull(active) + +/datum/action/cooldown/mob_cooldown/hv_finisher/Activate(atom/target) + var/mob/living/living_owner = owner + if(!isliving(living_owner) || !hv_finisher_valid_victim(living_owner, target)) + return FALSE + if(get_dist(living_owner, target) > 1 || !living_owner.Adjacent(target)) + // Something is between them. The AI tries again shortly rather than standing there. + StartCooldown(blocked_cooldown) + return FALSE + var/datum/hv_finisher/finisher = new finisher_type(living_owner, target, CALLBACK(src, PROC_REF(finisher_ended))) + active = finisher + // Unavailable for the whole move; finisher_ended() sets the real cooldown when it ends + StartCooldown(cooldown_time + finisher.longest_duration()) + seek_target = null + if(!finisher.start()) + active = null + qdel(finisher) + StartCooldown(blocked_cooldown) + return FALSE + return TRUE + +/// The finisher ended. A miss before the grip costs a short wait, anything else the full cooldown from now. +/datum/action/cooldown/mob_cooldown/hv_finisher/proc/finisher_ended(datum/hv_finisher/finisher, result) + if(active == finisher) + active = null + if(result == HV_FINISHER_LOST_GRIP && !finisher.ever_gripped) + StartCooldown(missed_cooldown) + else + StartCooldown() + qdel(finisher) + +/** + * The victim for the AI, or null: whoever it was already after, else its current target if they + * are down within [var/seek_range], else anyone down right next to it. It never walks to a + * stranger: someone lying behind glass a few tiles off would otherwise stall it. Gives up on a + * victim it has chased for [var/seek_timeout]. + */ +/datum/action/cooldown/mob_cooldown/hv_finisher/proc/find_victim(datum/ai_controller/controller) + var/mob/living/pawn = owner + if(!isliving(pawn)) + return null + var/mob/living/carbon/victim + var/mob/living/carbon/chased = controller.blackboard[BB_HV_FINISHER_VICTIM] + if(reachable_victim(chased)) + victim = chased + if(isnull(victim)) + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(reachable_victim(target)) + victim = target + var/turf/here = get_turf(pawn) + if(isnull(victim) && here) + for(var/mob/living/carbon/candidate in orange(1, here)) + if(pawn.Adjacent(candidate) && hv_finisher_valid_victim(pawn, candidate)) + victim = candidate + break + if(isnull(victim)) + seek_target = null + return null + if(seek_target?.resolve() != victim) + seek_target = WEAKREF(victim) + seek_started_at = world.time + else if(world.time - seek_started_at > seek_timeout) + seek_target = null + StartCooldown(missed_cooldown) + return null + return victim + +/// Whether `candidate` is a valid victim within [var/seek_range] +/datum/action/cooldown/mob_cooldown/hv_finisher/proc/reachable_victim(atom/candidate) + if(!iscarbon(candidate) || get_dist(owner, candidate) > seek_range) + return FALSE + return hv_finisher_valid_victim(owner, candidate) + +// ===== The AI ===== + +/// Walks to someone who is down and finishes them, when the finisher is ready +/datum/ai_planning_subtree/hv_finisher + +/datum/ai_planning_subtree/hv_finisher/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/datum/action/cooldown/mob_cooldown/hv_finisher/finisher = controller.blackboard[BB_HV_FINISHER_ACTION] + if(QDELETED(finisher)) + return + var/mob/living/pawn = controller.pawn + if(!isliving(pawn) || pawn.stat != CONSCIOUS) + return + if(finisher.active) + return SUBTREE_RETURN_FINISH_PLANNING + // Planted for another move, or simply not ready + if(HAS_TRAIT(pawn, TRAIT_IMMOBILIZED) || !finisher.IsAvailable()) + return + var/mob/living/carbon/victim = finisher.find_victim(controller) + if(isnull(victim)) + controller.clear_blackboard_key(BB_HV_FINISHER_VICTIM) + return + controller.set_blackboard_key(BB_HV_FINISHER_VICTIM, victim) + controller.queue_behavior(/datum/ai_behavior/targeted_mob_ability/min_range/hv_finisher, BB_HV_FINISHER_ACTION, BB_HV_FINISHER_VICTIM) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Walks next to the victim, then uses the finisher on them +/datum/ai_behavior/targeted_mob_ability/min_range/hv_finisher + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + required_distance = 1 + +/datum/ai_behavior/targeted_mob_ability/min_range/hv_finisher/finish_action(datum/ai_controller/controller, succeeded, ability_key, target_key) + . = ..() + controller.clear_blackboard_key(target_key) diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_effects.dm b/voidcrew/modules/hyperviolence/finishers/finisher_effects.dm new file mode 100644 index 00000000000..9d2e59f91d9 --- /dev/null +++ b/voidcrew/modules/hyperviolence/finishers/finisher_effects.dm @@ -0,0 +1,118 @@ +/** + * # Finisher status effects and telegraph + * + * The finisher (finisher.dm) puts one status effect on each mob and takes it off when it ends. + * Each effect also runs out on its own after the finisher's longest possible length, so a broken + * or forgotten finisher can never leave a victim held or a boss frozen. + */ + +/// On the mob doing a finisher: rooted, its AI held, and it takes extra damage +/datum/status_effect/hv_finishing + id = "hv_finishing" + alert_type = null + duration = 15 SECONDS + tick_interval = STATUS_EFFECT_NO_TICK + status_type = STATUS_EFFECT_UNIQUE + /// Multiplier on damage it takes + var/vulnerability = 1.5 + +/datum/status_effect/hv_finishing/on_creation(mob/living/new_owner, duration_override, vulnerability) + if(isnum(duration_override)) + duration = duration_override + if(isnum(vulnerability)) + src.vulnerability = vulnerability + return ..() + +/datum/status_effect/hv_finishing/on_apply() + owner.add_traits(list(TRAIT_AI_PAUSED, TRAIT_IMMOBILIZED), TRAIT_STATUS_EFFECT(id)) + RegisterSignal(owner, COMSIG_MOB_APPLY_DAMAGE_MODIFIERS, PROC_REF(on_damage_modifiers)) + return TRUE + +/datum/status_effect/hv_finishing/on_remove() + owner.remove_traits(list(TRAIT_AI_PAUSED, TRAIT_IMMOBILIZED), TRAIT_STATUS_EFFECT(id)) + UnregisterSignal(owner, COMSIG_MOB_APPLY_DAMAGE_MODIFIERS) + +/datum/status_effect/hv_finishing/proc/on_damage_modifiers(mob/living/source, list/damage_mods, damage, damagetype, def_zone, sharpness, attack_direction, attacking_item) + SIGNAL_HANDLER + damage_mods += vulnerability + +/// On the victim of a finisher: marked so nothing else starts one on them, and held down once gripped +/datum/status_effect/hv_finisher_hold + id = "hv_finisher_hold" + alert_type = null + duration = 15 SECONDS + tick_interval = STATUS_EFFECT_NO_TICK + status_type = STATUS_EFFECT_UNIQUE + /// TRUE while held down + var/gripped = FALSE + +/datum/status_effect/hv_finisher_hold/on_creation(mob/living/new_owner, duration_override) + if(isnum(duration_override)) + duration = duration_override + return ..() + +/datum/status_effect/hv_finisher_hold/on_remove() + set_gripped(FALSE) + +/// Holds the victim down, or lets them go. Their hands stay free: a downed victim can still shoot back. +/datum/status_effect/hv_finisher_hold/proc/set_gripped(grip) + if(gripped == grip) + return + gripped = grip + if(gripped) + owner.add_traits(list(TRAIT_IMMOBILIZED, TRAIT_FLOORED), TRAIT_STATUS_EFFECT(id)) + else + owner.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_FLOORED), TRAIT_STATUS_EFFECT(id)) + +/// On a mob made to let go of a finisher by damage: a moment where it does nothing +/datum/status_effect/hv_finisher_reeling + id = "hv_finisher_reeling" + alert_type = null + duration = 2 SECONDS + tick_interval = STATUS_EFFECT_NO_TICK + status_type = STATUS_EFFECT_REFRESH + +/datum/status_effect/hv_finisher_reeling/on_creation(mob/living/new_owner, duration_override) + if(isnum(duration_override)) + duration = duration_override + return ..() + +/datum/status_effect/hv_finisher_reeling/on_apply() + owner.add_traits(list(TRAIT_AI_PAUSED, TRAIT_IMMOBILIZED), TRAIT_STATUS_EFFECT(id)) + return TRUE + +/datum/status_effect/hv_finisher_reeling/on_remove() + owner.remove_traits(list(TRAIT_AI_PAUSED, TRAIT_IMMOBILIZED), TRAIT_STATUS_EFFECT(id)) + +/// The wind-up's mark over the victim: jaws or a skull in a red box, following them if they are dragged +/obj/effect/temp_visual/telegraphing/hv_finisher_mark + icon = 'icons/mob/telegraphing/telegraph.dmi' + icon_state = "skull" + // Over the victim and the boss leaning across them, and lit, so it reads in a dark derelict + layer = ABOVE_MOB_LAYER + light_color = COLOR_RED + randomdir = FALSE + duration = 1.5 SECONDS + +/obj/effect/temp_visual/telegraphing/hv_finisher_mark/Initialize(mapload, atom/movable/followed, state, new_duration) + if(isnum(new_duration)) + duration = new_duration + if(state) + icon_state = state + . = ..() + if(isnull(followed)) + return + RegisterSignal(followed, COMSIG_MOVABLE_MOVED, PROC_REF(on_followed_moved)) + RegisterSignal(followed, COMSIG_QDELETING, PROC_REF(on_followed_deleted)) + +/obj/effect/temp_visual/telegraphing/hv_finisher_mark/proc/on_followed_moved(atom/movable/source) + SIGNAL_HANDLER + var/turf/spot = get_turf(source) + if(isnull(spot)) + qdel(src) + return + forceMove(spot) + +/obj/effect/temp_visual/telegraphing/hv_finisher_mark/proc/on_followed_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm b/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm new file mode 100644 index 00000000000..485d29c0e19 --- /dev/null +++ b/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm @@ -0,0 +1,296 @@ +/** + * # Finisher stages + * + * One step of a finisher (finisher.dm). A stage runs for [var/duration]; its beat, effect(), fires + * [var/effect_delay] in, or the moment it begins when that is 0. enter() poses the attacker and + * shows the stage's opening line; the beat shows the gory line through hv_add_hit_line(). + * + * The kinds here carry the mechanics. Each boss subtypes them in finisher_users.dm for its own + * words, sounds and timing: + * - windup: rears up and marks the victim. Holds nobody, so the victim can still be pulled clear. + * - hold: the pin. From here the victim is held down. + * - maul: a bite or a blow, with blood. Deals no damage, so the victim lives to the end. + * - kill/rip_limb, kill/decapitate, kill/burst_head, kill/bisect: the killing blow. + * - recover: the attacker straightens up after the kill. Lets the victim go. + */ + +/datum/hv_finisher_stage + /// The finisher it belongs to + var/datum/hv_finisher/host + /// How long the stage lasts + var/duration = 1 SECONDS + /// How far into the stage its beat fires + var/effect_delay = 0 + /// TRUE once the beat has fired + var/effect_done = FALSE + /// TRUE once the stage has begun + var/entered = FALSE + /// The victim is held down during this stage + var/pins = TRUE + /// This stage's beat is the kill. Once it lands nothing calls the finisher off. + var/killing_blow = FALSE + /// Pixels the attacker leans toward the victim during this stage + var/lunge = 0 + /// Pixels the attacker rises during this stage + var/rise = 0 + /// Sound when the stage begins, from the attacker + var/enter_sound + var/enter_volume = 60 + /// Sound of the beat, from the victim + var/effect_sound + var/effect_volume = 70 + /// HV_LINE_BOLD or HV_LINE_HUGE for the beat's line. 0 shows it as a plain combat line. + var/effect_severity = HV_LINE_BOLD + /// How far the beat's line carries + var/effect_range = COMBAT_MESSAGE_RANGE + /// How far the opening line carries. The crew should see a boss rear up over one of their own. + var/enter_range = DEFAULT_MESSAGE_RANGE + +/datum/hv_finisher_stage/New(datum/hv_finisher/host) + . = ..() + src.host = host + +/datum/hv_finisher_stage/Destroy() + host = null + return ..() + +/// Begins the stage: pose, sound and its opening line +/datum/hv_finisher_stage/proc/enter() + entered = TRUE + host.user.face_atom(host.victim) + host.pose_user(lunge, rise) + if(enter_sound) + playsound(host.user, enter_sound, enter_volume, TRUE) + var/text = enter_message() + if(text) + host.victim.visible_message(span_danger(text), span_userdanger(enter_self_message() || text), vision_distance = enter_range) + +/// The stage's beat +/datum/hv_finisher_stage/proc/effect() + if(effect_sound) + playsound(host.victim, effect_sound, effect_volume, TRUE) + show_effect_line() + +/// Shows [proc/effect_message] at [var/effect_severity] +/datum/hv_finisher_stage/proc/show_effect_line() + var/text = effect_message() + if(!text) + return + if(effect_severity) + // Positional: (text, severity, self_text, range) on the shared hook + host.victim.hv_add_hit_line(text, effect_severity, effect_self_message(), effect_range) + return + var/self_text = effect_self_message() + host.victim.visible_message(span_danger(text), self_text ? span_userdanger(self_text) : null, vision_distance = effect_range) + +/// What the room sees as the stage begins, or null for nothing +/datum/hv_finisher_stage/proc/enter_message() + return null + +/// What the victim sees as the stage begins, or null to see the room's line +/datum/hv_finisher_stage/proc/enter_self_message() + return null + +/// The line of the beat, or null for none +/datum/hv_finisher_stage/proc/effect_message() + return null + +/// What the victim sees of the beat, or null to see the room's line +/datum/hv_finisher_stage/proc/effect_self_message() + return null + +/// Called as the finisher moves on to the next stage +/datum/hv_finisher_stage/proc/exit() + return + +/// Called on every stage that began when the finisher is called off before the kill +/datum/hv_finisher_stage/proc/on_interrupt() + return + +/// Called on every stage that began when the finisher ends, however it ends +/datum/hv_finisher_stage/proc/on_stop() + return + +/// The name of the limb the kill goes for ("left arm"), or "body" if there is none +/datum/hv_finisher_stage/proc/target_limb_name() + var/obj/item/bodypart/part = host.target_zone ? host.victim.get_bodypart(host.target_zone) : null + return part ? part.plaintext_zone : "body" + +/// Which way blood flies: away from the attacker +/datum/hv_finisher_stage/proc/away_dir() + return get_dir(host.user, host.victim) || host.user.dir + +// ===== Wind-up ===== + +/// Rears up over the victim and marks them. Holds nobody yet. +/datum/hv_finisher_stage/windup + pins = FALSE + duration = 1.5 SECONDS + rise = 6 + /// Something the attacker says as it winds up, or null + var/say_line + +/datum/hv_finisher_stage/windup/enter() + . = ..() + host.place_mark(duration) + if(say_line) + INVOKE_ASYNC(host.user, TYPE_PROC_REF(/atom/movable, say), say_line) + +// ===== Hold ===== + +/// The pin. A beat with no damage. +/datum/hv_finisher_stage/hold + duration = 1.5 SECONDS + lunge = 6 + effect_severity = 0 + +// ===== Maul ===== + +/// A bite or a blow: blood and a shake, but no damage, so the victim lives until the kill +/datum/hv_finisher_stage/maul + duration = 2 SECONDS + effect_delay = 0.5 SECONDS + lunge = 8 + /// How far the blood flies + var/spray_strength = 2 + +/datum/hv_finisher_stage/maul/effect() + . = ..() + host.victim.hv_blood_spray(away_dir(), spray_strength) + host.victim.Shake(2, 1, 0.6 SECONDS) + +// ===== The kill ===== + +/// The killing blow. Subtypes do the gore in gore(); if there is nothing to take, fallback() hurts them instead. +/datum/hv_finisher_stage/kill + killing_blow = TRUE + duration = 1.5 SECONDS + effect_delay = 0.5 SECONDS + lunge = 4 + effect_severity = HV_LINE_HUGE + // Carries as far as tg's own dismemberment line + effect_range = DEFAULT_MESSAGE_RANGE + /// How far the blood flies + var/spray_strength = 5 + /// Brute dealt to the chest when the gore can't happen + var/fallback_damage = 40 + +/datum/hv_finisher_stage/kill/effect() + if(effect_sound) + playsound(host.victim, effect_sound, effect_volume, TRUE) + var/spray_dir = away_dir() + if(!can_gore()) + fallback(spray_dir) + return + // The line names the part, so it goes out while the part is still attached + show_effect_line() + gore(spray_dir) + +/// Whether the victim still has what gore() takes +/datum/hv_finisher_stage/kill/proc/can_gore() + return FALSE + +/// The gore itself +/datum/hv_finisher_stage/kill/proc/gore(spray_dir) + return + +/// When there is nothing to take: a heavy wound instead +/datum/hv_finisher_stage/kill/proc/fallback(spray_dir) + var/text = fallback_message() + if(text) + host.victim.hv_add_hit_line(text, HV_LINE_BOLD, null, effect_range) + host.victim.apply_damage(fallback_damage, BRUTE, BODY_ZONE_CHEST, wound_bonus = 10, sharpness = SHARP_EDGED, attack_direction = spray_dir) + host.victim.hv_blood_spray(spray_dir, 3) + +/// The line for fallback() +/datum/hv_finisher_stage/kill/proc/fallback_message() + return "[host.user] tears into [host.victim]!" + +/// Tears off the limb at the finisher's target zone, flinging it +/datum/hv_finisher_stage/kill/rip_limb + +/datum/hv_finisher_stage/kill/rip_limb/can_gore() + if(!host.target_zone || host.target_zone == BODY_ZONE_HEAD || host.target_zone == BODY_ZONE_CHEST) + return FALSE + var/obj/item/bodypart/part = host.victim.get_bodypart(host.target_zone) + return part?.can_dismember() + +/datum/hv_finisher_stage/kill/rip_limb/gore(spray_dir) + var/obj/item/bodypart/part = host.victim.get_bodypart(host.target_zone) + host.victim.hv_blood_spray(spray_dir, spray_strength) + part?.dismember(BRUTE, TRUE, WOUND_SLASH) + +/// Takes the head off and leaves it at the victim's feet, brain and all +/datum/hv_finisher_stage/kill/decapitate + +/datum/hv_finisher_stage/kill/decapitate/can_gore() + var/obj/item/bodypart/head/head = host.victim.get_bodypart(BODY_ZONE_HEAD) + return head?.can_dismember() + +/datum/hv_finisher_stage/kill/decapitate/gore(spray_dir) + host.victim.hv_blood_spray(spray_dir, spray_strength) + hv_finisher_take_head(host.victim, WOUND_SLASH) + +/// Bursts the head through the shared hook, which keeps the brain intact +/datum/hv_finisher_stage/kill/burst_head + // hv_burst_head() adds its own big line; this stage's line is the blow that does it + effect_severity = HV_LINE_BOLD + +/datum/hv_finisher_stage/kill/burst_head/can_gore() + var/obj/item/bodypart/head/head = host.victim.get_bodypart(BODY_ZONE_HEAD) + return head?.can_dismember() + +/datum/hv_finisher_stage/kill/burst_head/gore(spray_dir) + if(!host.victim.hv_burst_head(spray_dir)) + fallback(spray_dir) + +/// Cuts through at the hips: both legs come off +/datum/hv_finisher_stage/kill/bisect + +/datum/hv_finisher_stage/kill/bisect/can_gore() + for(var/zone in list(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + var/obj/item/bodypart/leg = host.victim.get_bodypart(zone) + if(leg?.can_dismember()) + return TRUE + return FALSE + +/datum/hv_finisher_stage/kill/bisect/gore(spray_dir) + var/turf/spot = get_turf(host.victim) + host.victim.hv_blood_spray(spray_dir, spray_strength) + host.victim.hv_blood_spray(REVERSE_DIR(spray_dir), 3) + for(var/zone in list(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + var/obj/item/bodypart/leg = host.victim.get_bodypart(zone) + if(leg?.can_dismember()) + leg.dismember(BRUTE, TRUE, WOUND_SLASH) + if(spot && !QDELETED(host.victim) && host.victim.can_bleed(BLOOD_COVER_TURFS) == BLEED_SPLATTER) + new /obj/effect/decal/cleanable/blood/gibs(spot, host.victim.get_static_viruses(), host.victim.get_blood_dna_list()) + +// ===== Recover ===== + +/// The attacker straightens up after the kill and lets go +/datum/hv_finisher_stage/recover + pins = FALSE + duration = 1 SECONDS + +/** + * Takes `victim`'s head off without flinging it. dismember() throws the part a few tiles, and a + * head carries the brain: in zero gravity it would drift away for good. So this does what + * dismember() does for a head and drops it where the body lies. Returns the head, or null. + */ +/proc/hv_finisher_take_head(mob/living/carbon/victim, wounding_type = WOUND_SLASH) + var/obj/item/bodypart/head/head = victim?.get_bodypart(BODY_ZONE_HEAD) + if(!head?.can_dismember() || HAS_TRAIT(victim, TRAIT_GODMODE)) + return null + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + chest?.receive_damage(15, wound_bonus = CANT_WOUND) + playsound(get_turf(victim), 'sound/effects/dismember.ogg', 80, TRUE) + LAZYSET(victim.body_zone_dismembered_by, BODY_ZONE_HEAD, wounding_type) + var/bleeds = head.can_bleed() + if(bleeds) + victim.bleed(rand(20, 40)) + var/turf/spot = get_turf(victim) + head.drop_limb(FALSE, TRUE) + victim.update_equipment_speed_mods() + if(bleeds && isturf(spot)) + victim.add_splatter_floor(spot) + return head diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_users.dm b/voidcrew/modules/hyperviolence/finishers/finisher_users.dm new file mode 100644 index 00000000000..e4e912bfd6f --- /dev/null +++ b/voidcrew/modules/hyperviolence/finishers/finisher_users.dm @@ -0,0 +1,255 @@ +/** + * # Finishers: the bosses that have one + * + * - The hoarfrost matriarch (Big Game Hunt, frozen worlds) pins, bites and tears off an arm. + * - The Black Knight (medieval pirate boss) pins with a boot and cleaves through the hips. + * - The alien empress of a derelict xeno nest pins and bites the head off. + * - The Robust One (grey tide pirate boss) kneels on the chest and beats the head in. + * + * Each comes to about six seconds from the wind-up to the kill, with a minute between tries. + * + * Each boss gets its finisher from a second Initialize() on its type, here, so the files that own + * those bosses stay untouched. DM chains duplicate overrides: both bodies run, and both call ..(). + */ + +// ===== The hoarfrost matriarch ===== + +/mob/living/basic/hoarfrost_matriarch/Initialize(mapload) + . = ..() + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/hoarfrost) + +/datum/hv_finisher/hoarfrost + name = "hoarfrost maul" + // She has 800 health; the crew need a real volley to make her let go + interrupt_damage = 60 + telegraph_state = "bite" + zone_preference = list(BODY_ZONE_R_ARM, BODY_ZONE_L_ARM, BODY_ZONE_R_LEG, BODY_ZONE_L_LEG) + stages = list( + /datum/hv_finisher_stage/windup/hoarfrost, + /datum/hv_finisher_stage/hold/hoarfrost, + /datum/hv_finisher_stage/maul/hoarfrost, + /datum/hv_finisher_stage/kill/rip_limb/hoarfrost, + /datum/hv_finisher_stage/recover, + ) + +/// Either arm, else either leg +/datum/hv_finisher/hoarfrost/pick_zone() + var/list/arms = shuffle(list(BODY_ZONE_R_ARM, BODY_ZONE_L_ARM)) + var/list/legs = shuffle(list(BODY_ZONE_R_LEG, BODY_ZONE_L_LEG)) + zone_preference = arms + legs + return ..() + +/datum/hv_finisher_stage/windup/hoarfrost + enter_sound = 'sound/effects/hallucinations/growl1.ogg' + +/datum/hv_finisher_stage/windup/hoarfrost/enter_message() + return "[host.user] lowers [host.user.p_their()] head over [host.victim] and growls." + +/datum/hv_finisher_stage/hold/hoarfrost + enter_sound = 'sound/effects/gravhit.ogg' + +/datum/hv_finisher_stage/hold/hoarfrost/enter_message() + return "[host.user] slams a paw down on [host.victim] and pins [host.victim.p_them()] to the ground!" + +/datum/hv_finisher_stage/hold/hoarfrost/enter_self_message() + return "A huge paw slams you into the ground!" + +/datum/hv_finisher_stage/maul/hoarfrost + duration = 2.5 SECONDS + lunge = 10 + effect_sound = 'sound/items/weapons/bite.ogg' + +/datum/hv_finisher_stage/maul/hoarfrost/effect_message() + return "[host.user] sinks [host.user.p_their()] teeth into [host.victim]'s [target_limb_name()]!" + +/datum/hv_finisher_stage/maul/hoarfrost/effect_self_message() + return "[host.user] sinks [host.user.p_their()] teeth into your [target_limb_name()]!" + +/datum/hv_finisher_stage/kill/rip_limb/hoarfrost + lunge = -2 + rise = 4 + effect_sound = 'sound/effects/wounds/crackandbleed.ogg' + +/datum/hv_finisher_stage/kill/rip_limb/hoarfrost/effect_message() + return "[host.user] wrenches [host.user.p_their()] head back and tears [host.victim]'s [target_limb_name()] off!" + +/datum/hv_finisher_stage/kill/rip_limb/hoarfrost/effect_self_message() + return "[host.user] tears your [target_limb_name()] off!" + +// ===== The Black Knight ===== + +/mob/living/basic/trooper/pirate/faction/boss/medieval/Initialize(mapload) + . = ..() + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/black_knight) + +/datum/hv_finisher/black_knight + name = "black knight cleave" + telegraph_state = "skull" + stages = list( + /datum/hv_finisher_stage/windup/black_knight, + /datum/hv_finisher_stage/hold/black_knight, + /datum/hv_finisher_stage/hold/black_knight_raise, + /datum/hv_finisher_stage/kill/bisect/black_knight, + /datum/hv_finisher_stage/recover, + ) + +/datum/hv_finisher_stage/windup/black_knight + rise = 4 + enter_sound = 'sound/items/unsheath.ogg' + say_line = "None shall pass." + +/datum/hv_finisher_stage/windup/black_knight/enter_message() + return "[host.user] raises [host.user.p_their()] claymore high over [host.victim]." + +/datum/hv_finisher_stage/hold/black_knight + enter_sound = 'sound/effects/hit_kick.ogg' + +/datum/hv_finisher_stage/hold/black_knight/enter_message() + return "[host.user] plants an armoured boot on [host.victim]'s chest." + +/datum/hv_finisher_stage/hold/black_knight/enter_self_message() + return "An armoured boot pins you to the floor!" + +/datum/hv_finisher_stage/hold/black_knight_raise + duration = 2 SECONDS + lunge = 4 + rise = 8 + +/datum/hv_finisher_stage/hold/black_knight_raise/enter_message() + return "[host.user] turns the blade point-down." + +/datum/hv_finisher_stage/kill/bisect/black_knight + effect_delay = 0.4 SECONDS + lunge = 8 + rise = -2 + effect_sound = 'sound/items/weapons/bladeslice.ogg' + +/datum/hv_finisher_stage/kill/bisect/black_knight/effect_message() + return "[host.user] brings the claymore down and cleaves [host.victim] apart at the hips!" + +/datum/hv_finisher_stage/kill/bisect/black_knight/effect_self_message() + return "The claymore comes down across your hips!" + +/datum/hv_finisher_stage/kill/bisect/black_knight/fallback_message() + return "[host.user] drives the claymore down into [host.victim]!" + +// ===== The alien empress of a derelict nest ===== + +/mob/living/basic/alien/queen/large/derelict/Initialize(mapload) + . = ..() + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/alien_empress) + +/datum/hv_finisher/alien_empress + name = "empress bite" + telegraph_state = "bite" + stages = list( + /datum/hv_finisher_stage/windup/alien_empress, + /datum/hv_finisher_stage/hold/alien_empress, + /datum/hv_finisher_stage/hold/alien_empress_jaws, + /datum/hv_finisher_stage/kill/decapitate/alien_empress, + /datum/hv_finisher_stage/recover, + ) + +/datum/hv_finisher_stage/windup/alien_empress + rise = 8 + enter_sound = 'sound/mobs/non-humanoids/hiss/hiss5.ogg' + +/datum/hv_finisher_stage/windup/alien_empress/enter_message() + return "[host.user] rears up over [host.victim] with a long hiss." + +/datum/hv_finisher_stage/hold/alien_empress + enter_sound = 'sound/items/weapons/slash.ogg' + +/datum/hv_finisher_stage/hold/alien_empress/enter_message() + return "[host.user] pins [host.victim] down with [host.user.p_their()] claws." + +/datum/hv_finisher_stage/hold/alien_empress/enter_self_message() + return "Claws pin you to the floor!" + +/datum/hv_finisher_stage/hold/alien_empress_jaws + duration = 2 SECONDS + lunge = 10 + // Dips her head down to the victim's + rise = -4 + enter_sound = 'sound/mobs/non-humanoids/hiss/lowHiss1.ogg' + +/datum/hv_finisher_stage/hold/alien_empress_jaws/enter_message() + return "[host.user]'s jaws open over [host.victim]'s head." + +/datum/hv_finisher_stage/hold/alien_empress_jaws/enter_self_message() + return "[host.user]'s jaws open over your face." + +/datum/hv_finisher_stage/kill/decapitate/alien_empress + effect_delay = 0.4 SECONDS + rise = 4 + effect_sound = 'sound/mobs/non-humanoids/alien/alien_eat.ogg' + +/datum/hv_finisher_stage/kill/decapitate/alien_empress/effect_message() + return "[host.user] bites through [host.victim]'s neck and tears [host.victim.p_their()] head off!" + +/datum/hv_finisher_stage/kill/decapitate/alien_empress/fallback_message() + return "[host.user] bites deep into [host.victim]!" + +// ===== The Robust One ===== + +/mob/living/basic/trooper/pirate/faction/boss/grey/Initialize(mapload) + . = ..() + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/robust_one) + +/datum/hv_finisher/robust_one + name = "toolbox curb stomp" + telegraph_state = "skull" + stages = list( + /datum/hv_finisher_stage/windup/robust_one, + /datum/hv_finisher_stage/hold/robust_one, + /datum/hv_finisher_stage/maul/robust_one, + /datum/hv_finisher_stage/maul/robust_one/again, + /datum/hv_finisher_stage/kill/burst_head/robust_one, + /datum/hv_finisher_stage/recover, + ) + +/datum/hv_finisher_stage/windup/robust_one + rise = 4 + +/datum/hv_finisher_stage/windup/robust_one/enter_message() + return "[host.user] raises [host.user.p_their()] toolbox over [host.victim]." + +/datum/hv_finisher_stage/hold/robust_one + duration = 1 SECONDS + enter_sound = 'sound/effects/hit_kick.ogg' + +/datum/hv_finisher_stage/hold/robust_one/enter_message() + return "[host.user] kneels on [host.victim]'s chest." + +/datum/hv_finisher_stage/hold/robust_one/enter_self_message() + return "A knee crushes the air out of you!" + +/datum/hv_finisher_stage/maul/robust_one + duration = 1.5 SECONDS + effect_delay = 0.3 SECONDS + effect_sound = 'sound/items/weapons/smash.ogg' + +/datum/hv_finisher_stage/maul/robust_one/effect_message() + return "[host.user] brings the toolbox down on [host.victim]'s head!" + +/datum/hv_finisher_stage/maul/robust_one/effect_self_message() + return "[host.user] brings the toolbox down on your head!" + +/datum/hv_finisher_stage/maul/robust_one/again + effect_sound = 'sound/effects/wounds/crack1.ogg' + +/datum/hv_finisher_stage/maul/robust_one/again/effect_message() + return "[host.user] brings it down again!" + +/datum/hv_finisher_stage/maul/robust_one/again/effect_self_message() + return null + +/datum/hv_finisher_stage/kill/burst_head/robust_one + effect_delay = 0.3 SECONDS + effect_sound = 'sound/items/weapons/smash.ogg' + +/datum/hv_finisher_stage/kill/burst_head/robust_one/effect_message() + return "[host.user] brings the toolbox down one last time." + +/datum/hv_finisher_stage/kill/burst_head/robust_one/fallback_message() + return "[host.user] caves in [host.victim]'s skull!" diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..1438938ec8f 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -90,6 +90,7 @@ #include "voidcrew_helpers.dm" #include "voidcrew_hull_containment.dm" #include "voidcrew_hull_survey.dm" +#include "voidcrew_hv_finishers.dm" #include "voidcrew_launch_access.dm" #include "voidcrew_launch_cargo.dm" #include "voidcrew_launch_fabrication.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_hv_finishers.dm b/voidcrew/modules/unit_tests/voidcrew_hv_finishers.dm new file mode 100644 index 00000000000..163f9dfc553 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_hv_finishers.dm @@ -0,0 +1,252 @@ +/** + * Hyperviolence finishers (voidcrew/modules/hyperviolence/finishers/). + * + * Fork defines are included after the tests, so these use the literal values, each with a comment + * naming the define. The finishers are driven by calling advance() and tick() directly; nothing + * here waits on a timer or an AI walk. + */ + +/// Shared helpers; not a test itself +/datum/unit_test/voidcrew_hv_finisher + abstract_type = /datum/unit_test/voidcrew_hv_finisher + +/// The floor `dx`, `dy` tiles from the test room's bottom-left corner +/datum/unit_test/voidcrew_hv_finisher/proc/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 human knocked flat at `where` +/datum/unit_test/voidcrew_hv_finisher/proc/downed_human(turf/where) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, where) + victim.Paralyze(100 SECONDS) + return victim + +/// Runs `finisher` to its end through advance(), as its timer would. Gives up after a sane number of steps. +/datum/unit_test/voidcrew_hv_finisher/proc/run_to_end(datum/hv_finisher/finisher) + for(var/i in 1 to 20) + if(!finisher.running) + return + finisher.advance() + +/// Every boss that should have a finisher has one, wired into its AI once, after its targeting +/datum/unit_test/voidcrew_hv_finisher/users + +/datum/unit_test/voidcrew_hv_finisher/users/Run() + var/list/users = list( + /mob/living/basic/hoarfrost_matriarch, + /mob/living/basic/trooper/pirate/faction/boss/medieval, + /mob/living/basic/alien/queen/large/derelict, + /mob/living/basic/trooper/pirate/faction/boss/grey, + ) + for(var/user_type in users) + var/mob/living/basic/boss = allocate(user_type, spot(1, 1)) + var/datum/component/hv_finisher_user/finishing = boss.GetComponent(/datum/component/hv_finisher_user) + TEST_ASSERT_NOTNULL(finishing, "[user_type] has no finisher") + TEST_ASSERT_NOTNULL(boss.ai_controller, "[user_type] has no AI") + var/count = 0 + for(var/datum/ai_planning_subtree/subtree in boss.ai_controller.planning_subtrees) + if(istype(subtree, /datum/ai_planning_subtree/hv_finisher)) + count++ + TEST_ASSERT_EQUAL(count, 1, "[user_type] should plan its finisher exactly once") + // BB_HV_FINISHER_ACTION + TEST_ASSERT_EQUAL(boss.ai_controller.blackboard["BB_hv_finisher_action"], finishing.finisher_action, "[user_type]'s AI can't see its finisher") + TEST_ASSERT_EQUAL(finishing.finisher_action.owner, boss, "[user_type] wasn't granted its finisher") + TEST_ASSERT(!(finishing.finisher_action in boss.actions), "[user_type]'s finisher has a button a player could press") + qdel(boss) + +/// The subtree goes after the subtrees that pick a target and before the attacks, once +/datum/unit_test/voidcrew_hv_finisher/subtree_order + +/datum/unit_test/voidcrew_hv_finisher/subtree_order/Run() + var/mob/living/basic/hoarfrost_matriarch/matriarch = allocate(/mob/living/basic/hoarfrost_matriarch, spot(1, 1)) + var/list/plan = matriarch.ai_controller.planning_subtrees + var/finisher_at = 0 + var/find_target_at = 0 + var/rotation_at = 0 + for(var/i in 1 to length(plan)) + var/datum/ai_planning_subtree/subtree = plan[i] + if(istype(subtree, /datum/ai_planning_subtree/hv_finisher)) + finisher_at = i + else if(istype(subtree, /datum/ai_planning_subtree/simple_find_target)) + find_target_at = i + else if(istype(subtree, /datum/ai_planning_subtree/hoarfrost_rotation)) + rotation_at = i + TEST_ASSERT(finisher_at > find_target_at, "the finisher is planned before the matriarch picks a target") + TEST_ASSERT(finisher_at < rotation_at, "the finisher is planned after the matriarch's abilities") + TEST_ASSERT(!hv_finisher_add_subtree(matriarch.ai_controller), "the finisher subtree was added twice") + +/// A boarding pod swaps a boss's AI controller after it spawns; the finisher follows it +/datum/unit_test/voidcrew_hv_finisher/controller_swap + +/datum/unit_test/voidcrew_hv_finisher/controller_swap/Run() + var/mob/living/basic/trooper/pirate/faction/boss/medieval/knight = allocate(/mob/living/basic/trooper/pirate/faction/boss/medieval, spot(1, 1)) + var/datum/component/hv_finisher_user/finishing = knight.GetComponent(/datum/component/hv_finisher_user) + var/datum/ai_controller/old_controller = knight.ai_controller + TEST_ASSERT_EQUAL(finishing.watched_controller, old_controller, "the finisher isn't watching the knight's AI") + // What /obj/structure/closet/supplypod/boarding/boss/setup_boarder_patrol() does + qdel(old_controller) + knight.ai_controller = new /datum/ai_controller/basic_controller/trooper/patrolling/boss(knight) + TEST_ASSERT_NULL(finishing.watched_controller, "the finisher still watches a deleted AI") + // The component does this on a one-tick timer + finishing.adopt_controller() + TEST_ASSERT_EQUAL(finishing.watched_controller, knight.ai_controller, "the finisher didn't follow the knight onto its new AI") + TEST_ASSERT(locate(/datum/ai_planning_subtree/hv_finisher) in knight.ai_controller.planning_subtrees, "the new AI doesn't plan the finisher") + +/// Who can and can't be finished +/datum/unit_test/voidcrew_hv_finisher/victims + +/datum/unit_test/voidcrew_hv_finisher/victims/Run() + var/mob/living/basic/hoarfrost_matriarch/matriarch = allocate(/mob/living/basic/hoarfrost_matriarch, spot(1, 1)) + var/mob/living/carbon/human/consistent/standing = allocate(/mob/living/carbon/human/consistent, spot(2, 1)) + TEST_ASSERT(!hv_finisher_valid_victim(matriarch, standing), "someone on their feet can be finished") + var/mob/living/carbon/human/consistent/down = downed_human(spot(2, 2)) + TEST_ASSERT(hv_finisher_valid_victim(matriarch, down), "someone knocked down can't be finished") + down.faction |= matriarch.faction + TEST_ASSERT(!hv_finisher_valid_victim(matriarch, down), "an ally can be finished") + down.faction -= matriarch.faction + var/mob/living/carbon/human/consistent/dead = downed_human(spot(1, 2)) + dead.death() + TEST_ASSERT(!hv_finisher_valid_victim(matriarch, dead), "a corpse can be finished") + var/datum/hv_finisher/finisher = allocate(/datum/hv_finisher/hoarfrost, matriarch, down) + TEST_ASSERT(finisher.start(), "the finisher didn't start on someone down next to it") + TEST_ASSERT(!hv_finisher_valid_victim(matriarch, down), "someone already being finished can be finished again") + finisher.stop(1) // HV_FINISHER_DONE + +/// The matriarch's finisher, start to end: the hold, the arm, and both mobs freed after +/datum/unit_test/voidcrew_hv_finisher/full_run + +/datum/unit_test/voidcrew_hv_finisher/full_run/Run() + var/mob/living/basic/hoarfrost_matriarch/matriarch = allocate(/mob/living/basic/hoarfrost_matriarch, spot(1, 1)) + var/mob/living/carbon/human/consistent/victim = downed_human(spot(2, 1)) + var/datum/hv_finisher/finisher = allocate(/datum/hv_finisher/hoarfrost, matriarch, victim) + TEST_ASSERT(finisher.start(), "the finisher didn't start") + TEST_ASSERT(!finisher.gripped, "the wind-up already holds the victim") + TEST_ASSERT_NOTNULL(finisher.mark, "the wind-up didn't mark the victim") + TEST_ASSERT(HAS_TRAIT(matriarch, TRAIT_AI_PAUSED), "the matriarch's AI runs during the finisher") + TEST_ASSERT(matriarch.has_status_effect(/datum/status_effect/hv_finishing), "the matriarch isn't marked as finishing") + var/zone = finisher.target_zone + TEST_ASSERT(zone in list(BODY_ZONE_R_ARM, BODY_ZONE_L_ARM), "the matriarch went for [zone], not an arm") + finisher.advance() + TEST_ASSERT(finisher.gripped, "the pin doesn't hold the victim") + TEST_ASSERT(HAS_TRAIT_FROM(victim, TRAIT_FLOORED, TRAIT_STATUS_EFFECT("hv_finisher_hold")), "the victim isn't held down") + TEST_ASSERT_NOTNULL(victim.get_bodypart(zone), "the arm came off before the kill") + run_to_end(finisher) + TEST_ASSERT(!finisher.running, "the finisher never ended") + TEST_ASSERT_EQUAL(finisher.result, 1, "the finisher ended early") // HV_FINISHER_DONE + TEST_ASSERT(finisher.completed, "the killing blow never landed") + TEST_ASSERT_NULL(victim.get_bodypart(zone), "the arm is still on") + TEST_ASSERT(!victim.has_status_effect(/datum/status_effect/hv_finisher_hold), "the victim is still held") + TEST_ASSERT(!HAS_TRAIT_FROM(victim, TRAIT_FLOORED, TRAIT_STATUS_EFFECT("hv_finisher_hold")), "the hold's traits outlived it") + TEST_ASSERT(!matriarch.has_status_effect(/datum/status_effect/hv_finishing), "the matriarch is still finishing") + TEST_ASSERT(!HAS_TRAIT(matriarch, TRAIT_AI_PAUSED), "the matriarch's AI is still paused") + // HV_FINISHER_OFFSET + TEST_ASSERT(!LAZYACCESS(matriarch.offsets?[PIXEL_Y_OFFSET], "hv_finisher"), "the matriarch kept her pose") + +/// A torn-off head lands where the body lies, with the brain still in it +/datum/unit_test/voidcrew_hv_finisher/head_keeps_brain + +/datum/unit_test/voidcrew_hv_finisher/head_keeps_brain/Run() + var/mob/living/basic/alien/queen/large/derelict/empress = allocate(/mob/living/basic/alien/queen/large/derelict, spot(1, 1)) + var/mob/living/carbon/human/consistent/victim = downed_human(spot(2, 1)) + var/obj/item/organ/brain/brain = victim.get_organ_slot(ORGAN_SLOT_BRAIN) + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + var/datum/hv_finisher/finisher = allocate(/datum/hv_finisher/alien_empress, empress, victim) + TEST_ASSERT(finisher.start(), "the finisher didn't start") + run_to_end(finisher) + TEST_ASSERT_EQUAL(finisher.result, 1, "the finisher ended early") // HV_FINISHER_DONE + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_HEAD), "the head is still on") + TEST_ASSERT(!QDELETED(head), "the head was destroyed") + TEST_ASSERT_EQUAL(get_turf(head), spot(2, 1), "the head didn't land where the body lies") + TEST_ASSERT(!QDELETED(brain), "the brain was destroyed") + TEST_ASSERT_EQUAL(brain.loc, head, "the brain isn't in the head") + +/// A burst head still leaves the brain +/datum/unit_test/voidcrew_hv_finisher/burst_keeps_brain + +/datum/unit_test/voidcrew_hv_finisher/burst_keeps_brain/Run() + var/mob/living/basic/trooper/pirate/faction/boss/grey/robust = allocate(/mob/living/basic/trooper/pirate/faction/boss/grey, spot(1, 1)) + var/mob/living/carbon/human/consistent/victim = downed_human(spot(2, 1)) + var/obj/item/organ/brain/brain = victim.get_organ_slot(ORGAN_SLOT_BRAIN) + var/datum/hv_finisher/finisher = allocate(/datum/hv_finisher/robust_one, robust, victim) + TEST_ASSERT(finisher.start(), "the finisher didn't start") + run_to_end(finisher) + TEST_ASSERT_EQUAL(finisher.result, 1, "the finisher ended early") // HV_FINISHER_DONE + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_HEAD), "the head is still on") + TEST_ASSERT(!QDELETED(brain), "the brain was destroyed") + +/// The Black Knight takes both legs +/datum/unit_test/voidcrew_hv_finisher/bisect + +/datum/unit_test/voidcrew_hv_finisher/bisect/Run() + var/mob/living/basic/trooper/pirate/faction/boss/medieval/knight = allocate(/mob/living/basic/trooper/pirate/faction/boss/medieval, spot(1, 1)) + var/mob/living/carbon/human/consistent/victim = downed_human(spot(2, 1)) + var/datum/hv_finisher/finisher = allocate(/datum/hv_finisher/black_knight, knight, victim) + TEST_ASSERT(finisher.start(), "the finisher didn't start") + run_to_end(finisher) + TEST_ASSERT_EQUAL(finisher.result, 1, "the finisher ended early") // HV_FINISHER_DONE + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_L_LEG), "the left leg is still on") + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_R_LEG), "the right leg is still on") + TEST_ASSERT_NOTNULL(victim.get_bodypart(BODY_ZONE_HEAD), "the knight took the head too") + +/// Hurting the attacker makes it let go, before the kill; it takes extra damage meanwhile +/datum/unit_test/voidcrew_hv_finisher/damage_interrupt + +/datum/unit_test/voidcrew_hv_finisher/damage_interrupt/Run() + var/mob/living/basic/hoarfrost_matriarch/matriarch = allocate(/mob/living/basic/hoarfrost_matriarch, spot(1, 1)) + var/mob/living/carbon/human/consistent/victim = downed_human(spot(2, 1)) + var/datum/hv_finisher/hoarfrost/finisher = allocate(/datum/hv_finisher/hoarfrost, matriarch, victim) + TEST_ASSERT(finisher.start(), "the finisher didn't start") + finisher.advance() + var/zone = finisher.target_zone + var/health_before = matriarch.health + matriarch.apply_damage(30, BRUTE) + TEST_ASSERT_EQUAL(health_before - matriarch.health, 30 * finisher.vulnerability, "the matriarch didn't take extra damage while finishing") + TEST_ASSERT(finisher.running, "the finisher broke before it took enough damage") + matriarch.apply_damage(30, BRUTE) + TEST_ASSERT(!finisher.running, "the finisher kept going through [finisher.interrupt_damage] damage") + TEST_ASSERT_EQUAL(finisher.result, 3, "the finisher ended for the wrong reason") // HV_FINISHER_DAMAGED + TEST_ASSERT(!finisher.completed, "the kill landed anyway") + TEST_ASSERT_NOTNULL(victim.get_bodypart(zone), "the victim lost the arm anyway") + TEST_ASSERT(!victim.has_status_effect(/datum/status_effect/hv_finisher_hold), "the victim is still held") + TEST_ASSERT(matriarch.has_status_effect(/datum/status_effect/hv_finisher_reeling), "the matriarch didn't reel") + health_before = matriarch.health + matriarch.apply_damage(10, BRUTE) + TEST_ASSERT_EQUAL(health_before - matriarch.health, 10, "the matriarch still takes extra damage after letting go") + +/// Pulling the victim clear frees them; in the wind-up it costs only a short cooldown +/datum/unit_test/voidcrew_hv_finisher/pulled_clear + +/datum/unit_test/voidcrew_hv_finisher/pulled_clear/Run() + var/mob/living/basic/trooper/pirate/faction/boss/medieval/knight = allocate(/mob/living/basic/trooper/pirate/faction/boss/medieval, spot(1, 1)) + var/mob/living/carbon/human/consistent/victim = downed_human(spot(2, 1)) + var/datum/component/hv_finisher_user/finishing = knight.GetComponent(/datum/component/hv_finisher_user) + var/datum/action/cooldown/mob_cooldown/hv_finisher/action = finishing.finisher_action + TEST_ASSERT(action.Trigger(target = victim), "the knight couldn't start his finisher") + var/datum/hv_finisher/finisher = action.active + TEST_ASSERT_NOTNULL(finisher, "the ability started no finisher") + TEST_ASSERT(!action.IsAvailable(), "the ability is ready again mid-finisher") + victim.forceMove(spot(4, 1)) + TEST_ASSERT(!finisher.running, "the finisher went on with the victim out of reach") + TEST_ASSERT_EQUAL(finisher.result, 2, "the finisher ended for the wrong reason") // HV_FINISHER_LOST_GRIP + TEST_ASSERT_NULL(action.active, "the ability still thinks it's finishing") + TEST_ASSERT_EQUAL(action.next_use_time - world.time, action.missed_cooldown, "a miss in the wind-up didn't cost the short cooldown") + TEST_ASSERT(!victim.has_status_effect(/datum/status_effect/hv_finisher_hold), "the victim is still held") + TEST_ASSERT(!knight.has_status_effect(/datum/status_effect/hv_finisher_reeling), "the knight reels from a miss") + +/// However it breaks, the hold runs out: the finisher stops itself past its longest length +/datum/unit_test/voidcrew_hv_finisher/times_out + +/datum/unit_test/voidcrew_hv_finisher/times_out/Run() + var/mob/living/basic/hoarfrost_matriarch/matriarch = allocate(/mob/living/basic/hoarfrost_matriarch, spot(1, 1)) + var/mob/living/carbon/human/consistent/victim = downed_human(spot(2, 1)) + var/datum/hv_finisher/finisher = allocate(/datum/hv_finisher/hoarfrost, matriarch, victim) + TEST_ASSERT(finisher.start(), "the finisher didn't start") + finisher.advance() + var/datum/status_effect/hv_finisher_hold/hold = victim.has_status_effect(/datum/status_effect/hv_finisher_hold) + TEST_ASSERT_NOTNULL(hold, "the victim isn't held") + TEST_ASSERT(hold.duration <= world.time + finisher.longest_duration(), "the hold outlasts the finisher's longest length") + finisher.started_at = world.time - finisher.longest_duration() - 1 + finisher.tick() + TEST_ASSERT(!finisher.running, "the finisher ran past its longest length") + TEST_ASSERT_EQUAL(finisher.result, 6, "the finisher ended for the wrong reason") // HV_FINISHER_TIMED_OUT + TEST_ASSERT(!victim.has_status_effect(/datum/status_effect/hv_finisher_hold), "the victim is still held") + TEST_ASSERT(!HAS_TRAIT(matriarch, TRAIT_AI_PAUSED), "the matriarch's AI is still paused") From 96bdbfcb7c5052291933bd642d5c84d8376a10c3 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:38:02 -0800 Subject: [PATCH 017/472] Assert a burst head leaves its brain short of dead --- voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm index fcd68239f44..23283153b45 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm @@ -36,7 +36,8 @@ var/fling_distance = brain.y - origin.y TEST_ASSERT(fling_distance >= 1 && fling_distance <= 3, "The brain landed [fling_distance] tiles away, outside one to three tiles") TEST_ASSERT_EQUAL(brain.x, origin.x, "The brain left the line of the blow") - TEST_ASSERT_EQUAL(brain.damage, 0, "The burst damaged the brain, so the victim could not be revived") + TEST_ASSERT(brain.damage < brain.maxHealth, "The burst killed the brain outright, so the victim could not be revived") + TEST_ASSERT_EQUAL(brain.damage, 0, "The burst damaged a brain it had no reason to touch") TEST_ASSERT(!(brain.organ_flags & ORGAN_FAILING), "The burst left the brain failing") TEST_ASSERT_EQUAL(brain.brainmob?.mind, victim_mind, "The mind did not stay with the thrown brain") if(eyes) From 73e66060640d16b4ce588b2172b2c2361976dfbd Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:43:37 -0800 Subject: [PATCH 018/472] Cap aimed crit damage on boss-sized NPCs Crit damage on humanoid basic mobs is a share of at most 200 max health, and mobs over 300 max health are not stunned or silenced by a crit, so an aimed slash cannot take most of a boss's health. --- .../modules/hyperviolence/crits/crit_basic.dm | 28 ++++++++++++++++--- .../voidcrew_hyperviolence_crits.dm | 15 ++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/voidcrew/modules/hyperviolence/crits/crit_basic.dm b/voidcrew/modules/hyperviolence/crits/crit_basic.dm index 082e4d68d27..83719807ed1 100644 --- a/voidcrew/modules/hyperviolence/crits/crit_basic.dm +++ b/voidcrew/modules/hyperviolence/crits/crit_basic.dm @@ -3,6 +3,15 @@ // wound: a blow sized to the mob's max health (so health scaling at the spawn site still works), // a short stun for some, and a bleed for the throat and aorta. The effect stays on the mob so the // same crit cannot land on it twice. +// +// Bosses are humanoid basic mobs too. The damage is worked out from at most +// HV_BASIC_CRIT_HEALTH_CAP of their health, so a crit hurts a 2,000 health boss about as much as a +// tough pirate, and boss-sized mobs are never stunned or silenced by one. + +/// Crit damage on a basic mob is a share of its max health, but never of more than this. +#define HV_BASIC_CRIT_HEALTH_CAP 200 +/// Mobs with more max health than this are boss-sized: no stun, no silencing. +#define HV_BASIC_CRIT_BOSS_HEALTH 300 /datum/status_effect/hv_crit_basic id = "hv_crit_basic" @@ -24,13 +33,20 @@ /datum/status_effect/hv_crit_basic/on_apply() if(blow_share) - owner.adjustBruteLoss(owner.maxHealth * blow_share) - if(stun_time && owner.stat != DEAD) + owner.adjustBruteLoss(crit_health() * blow_share) + if(stun_time && owner.stat != DEAD && !boss_sized()) owner.Stun(stun_time) owner.hv_blood_spray(attack_direction, 2) owner.add_splatter_floor(get_turf(owner)) // NPCs have blood since the hit-lines part (npc_blood.dm) return TRUE +/// The health the crit's damage is worked out from. +/datum/status_effect/hv_crit_basic/proc/crit_health() + return min(owner.maxHealth, HV_BASIC_CRIT_HEALTH_CAP) + +/datum/status_effect/hv_crit_basic/proc/boss_sized() + return owner.maxHealth > HV_BASIC_CRIT_BOSS_HEALTH + /datum/status_effect/hv_crit_basic/eye id = "hv_crit_basic_eye" blow_share = 0.15 @@ -42,7 +58,8 @@ /datum/status_effect/hv_crit_basic/tongue/on_apply() . = ..() - ADD_TRAIT(owner, TRAIT_MUTE, HV_CRIT_TRAIT) + if(!boss_sized()) + ADD_TRAIT(owner, TRAIT_MUTE, HV_CRIT_TRAIT) /datum/status_effect/hv_crit_basic/tongue/on_remove() REMOVE_TRAIT(owner, TRAIT_MUTE, HV_CRIT_TRAIT) @@ -72,7 +89,7 @@ return var/bled = min(seconds_between_ticks, bleed_left) bleed_left -= bled - owner.adjustBruteLoss(owner.maxHealth * bleed_share * bled) + owner.adjustBruteLoss(crit_health() * bleed_share * bled) if(prob(50)) owner.hv_blood_spray(pick(GLOB.alldirs), 2) else @@ -89,3 +106,6 @@ /datum/status_effect/hv_crit_basic/bleed/aorta id = "hv_crit_basic_aorta" bleed_share = 0.02 + +#undef HV_BASIC_CRIT_HEALTH_CAP +#undef HV_BASIC_CRIT_BOSS_HEALTH diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_crits.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_crits.dm index 63116ea5ace..e3be128225a 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_crits.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_crits.dm @@ -204,3 +204,18 @@ crit = pirate.hv_try_basic_crit(BODY_ZONE_HEAD, BODY_ZONE_PRECISE_MOUTH, WOUND_SLASH, 25, 0, NORTH, knife, attacker, 500) TEST_ASSERT(istype(crit, /datum/hv_crit/tongue), "A forced tongue crit on a pirate did not land (got [crit])") TEST_ASSERT(HAS_TRAIT(pirate, TRAIT_MUTE), "A pirate without a tongue can still talk") + +/// A boss-sized humanoid takes capped crit damage and is not stunned by it. +/datum/unit_test/voidcrew_hv_crit_basic_boss_capped/Run() + var/mob/living/basic/trooper/pirate/melee/boss = allocate(/mob/living/basic/trooper/pirate/melee) + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/item/storage/toolbox/toolbox = allocate(/obj/item/storage/toolbox) + attacker.put_in_active_hand(toolbox) + boss.maxHealth = 2000 + boss.health = 2000 + + var/datum/hv_crit/crit = boss.hv_try_basic_crit(BODY_ZONE_HEAD, BODY_ZONE_HEAD, WOUND_BLUNT, 25, 0, NORTH, toolbox, attacker, 500) + TEST_ASSERT(istype(crit, /datum/hv_crit/skull), "A forced skull crit on a boss did not land (got [crit])") + // 0.3 of the 200 health cap (HV_BASIC_CRIT_HEALTH_CAP), not of 2000 + TEST_ASSERT(boss.health >= 2000 - 61, "A skull crit took [2000 - boss.health] health from a boss") + TEST_ASSERT(!boss.IsStun(), "A skull crit stunned a boss") From 87f028bfb17a6a65339bc7dd8e7f7339eaa6efd8 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:45:52 -0800 Subject: [PATCH 019/472] Even out face crit thresholds and correct the crit balance notes --- voidcrew/modules/hyperviolence/crits/crit_roll.dm | 14 ++++++++------ voidcrew/modules/hyperviolence/crits/crit_types.dm | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/voidcrew/modules/hyperviolence/crits/crit_roll.dm b/voidcrew/modules/hyperviolence/crits/crit_roll.dm index 298d253ef36..19e067d7da3 100644 --- a/voidcrew/modules/hyperviolence/crits/crit_roll.dm +++ b/voidcrew/modules/hyperviolence/crits/crit_roll.dm @@ -20,11 +20,13 @@ // 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, -// standing, unarmoured target hit by a 25+ damage weapon tops out at about 85, so the face -// crits (eye, tongue at 80, jaw at 75) land on roughly one such hit in ten to twenty, and the -// throat (90), aorta (90, chest armour scales it to 81 max) and skull (95) need the victim -// worn down or helpless first. A helpless target held by the attacker gets the throat about -// one big slash in four. A 10-force kitchen knife tops out near 30 and never crits by roll. +// standing, unarmoured target hit by a 25+ damage weapon tops out at about 90, so the face +// crits (eye, tongue and jaw at 80) land on about one such hit in eight; the throat (90) on +// about one in a hundred; the aorta (90, but the chest's resistance caps a fresh roll at 85) +// and the skull (95) not at all until the victim is worn down or helpless. A helpless victim +// held in the attacker's grab loses the throat to about one combat knife slash in three, one +// in two for a 25+ weapon. A 10-force kitchen knife tops out near 30, and even on a held +// victim under 70, so it never crits by roll. // * Gear that covers the spot blocks a crit outright (see each crit's blocked_on_carbon()). // * A crit never stacks on itself: the wound can only be on a limb once. @@ -137,7 +139,7 @@ GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) /// Applies wound_type to limb the way tg's own roll does: it replaces a lesser wound of the same /// series, and does nothing if one as bad is already there. Silent, since the crit adds its own /// hit line. Returns the wound, or null. -/proc/hv_crit_apply_wound(obj/item/bodypart/limb, datum/wound/wound_type, attack_direction, atom/source, right_side) +/proc/hv_crit_apply_wound(obj/item/bodypart/limb, datum/wound/wound_type, attack_direction, atom/source) var/datum/wound_pregen_data/pregen = GLOB.all_wound_pregen_data[wound_type] if(!pregen) return null diff --git a/voidcrew/modules/hyperviolence/crits/crit_types.dm b/voidcrew/modules/hyperviolence/crits/crit_types.dm index 16658f94cf3..98af4e6cbec 100644 --- a/voidcrew/modules/hyperviolence/crits/crit_types.dm +++ b/voidcrew/modules/hyperviolence/crits/crit_types.dm @@ -274,7 +274,7 @@ aim_zones = list(BODY_ZONE_PRECISE_MOUTH) wounding_types = list(WOUND_BLUNT) min_damage = 12 - threshold = 75 + threshold = 80 wound_type = /datum/wound/blunt/bone/severe/hv_jaw basic_effect = /datum/status_effect/hv_crit_basic/jaw hit_sound = 'sound/effects/wounds/crack1.ogg' From 034dc82a0e98d91e80cede4e0ac8a270fa7a4692 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:49:43 -0800 Subject: [PATCH 020/472] Prospect stakes are filed on real planet ground The claim used to target a bare overmap tile, so the beacon went up on the crew's own deck and the claim jumpers dropped through their floor. The contract now picks a terrain planet in contested or lawless space (keeping the old 45/55 yellow/red roll) and names it in the offer and the Wideband filing. The kit only deploys on open surface or cave ground of that planet, clear of the landing berths and any parked hull. The defend clock runs only while a crew member who is awake, at the keyboard and out on the ground stands within 7 tiles of the beacon. Waves are counted in guarded time, so stepping away never calls extra waves. A beacon lost to the site being torn down after it was left alone voids the claim as abandoned. Pay is unchanged. --- .../missions/missions/prospect_stake.dm | 181 +++++++++++++----- .../modules/missions/objectives/site_ops.dm | 94 ++++++--- 2 files changed, 202 insertions(+), 73 deletions(-) diff --git a/voidcrew/modules/missions/missions/prospect_stake.dm b/voidcrew/modules/missions/missions/prospect_stake.dm index adcc1ff938b..bb847a7d425 100644 --- a/voidcrew/modules/missions/missions/prospect_stake.dm +++ b/voidcrew/modules/missions/missions/prospect_stake.dm @@ -1,16 +1,17 @@ /** * # Prospect Stake * - * "Plant our claim beacon at these red-zone coordinates and keep it alive - * for eight minutes. The filing goes out on Wideband, so expect... interest." + * "Plant our claim beacon on this rock and keep it standing until the claim + * matures. The filing goes out on Wideband, so expect... interest." * * The announced PvP flashpoint as a mission: a claim kit is dispensed at - * accept; deploying it (ship holding at the marked coordinates) files the - * claim galaxy-wide - the contested-cache broadcast pattern - and starts a - * defend clock that only runs on station. Boarding waves answer the filing; - * red space means anyone else can too. The beacon survives, it prints a - * bonded deed for the pad. Objectives: [fly there] -> [deploy & defend] -> - * [carry the deed home]. + * accept, and the crew lands on a contested or lawless planet to raise it on + * open ground, clear of the landing field and any parked hull. Deploying it + * files the claim galaxy-wide - the contested-cache broadcast pattern - and + * starts a defend clock that only runs while a crew member stands guard at + * the beacon. Waves answer the filing on the ground around it; red space + * means anyone else can too. The beacon survives, it prints a bonded deed for + * the pad. Objectives: [deploy & defend] -> [carry the deed home]. */ /datum/mission/prospect_stake name = "Prospect Stake" @@ -29,6 +30,7 @@ var/wave_theme /// The defense objective (kit dispensing at start) var/datum/mission_objective/claim_defense/defense + /datum/mission/prospect_stake/Destroy() defense = null return ..() @@ -36,26 +38,40 @@ /datum/mission/prospect_stake/get_archetype() return "claim" +/** + * Picks the planet to stake. An offer pinned to a band (the reserved top-band + * slots, the unarmed green bias) only tries that band, and a green pin finds + * nothing. Otherwise the band is rolled, falling back to the other band when + * the rolled one has no world to offer. + */ /datum/mission/prospect_stake/setup_target() - var/datum/mission_target/coords/coords = new(src) - // Yellow stakes are the on-ramp; red is the real thing - coords.zone_weights = list( - "[ZONE_YELLOW]" = 45, - "[ZONE_RED]" = 55, - ) - if(!coords.resolve()) - qdel(coords) - return FALSE - target = coords - return TRUE + var/list/bands = list() + if(!isnull(preferred_zone)) + bands += preferred_zone + else + // Yellow stakes are the on-ramp; red is the real thing + var/list/weights = list( + "[ZONE_YELLOW]" = 45, + "[ZONE_RED]" = 55, + ) + while(length(weights)) + var/band_key = pick_weight(weights) + weights -= band_key + bands += text2num(band_key) + for(var/band in bands) + var/datum/mission_target/planet/claim_site/site = new(src) + site.preferred_zone = band + if(site.resolve()) + target = site + return TRUE + qdel(site) + return FALSE /datum/mission/prospect_stake/generate_details() objective_name = "notarized claim deed" wave_theme = pick_weight(MISSION_WAVE_THEMES) + /datum/mission/prospect_stake/build_objectives() - var/datum/mission_objective/goto_coords/approach = new - approach.arrival_message = "On station over the claim." - add_objective(approach) defense = new defense.wave_theme = wave_theme add_objective(defense) @@ -65,18 +81,89 @@ defense?.dispense_kit() /datum/mission/prospect_stake/update_text() - name = "Prospect Stake: ([target.target_x], [target.target_y])" - desc = "File our mineral-rights claim at ([target.target_x], [target.target_y]) in the [target_zone_name]." + var/datum/mission_target/planet/site = target + var/site_name = istype(site) ? (site.planet?.name || "the planet") : "the planet" + name = "Prospect Stake: [site_name]" + desc = "File our mineral-rights claim on [site_name] at ([target.target_x], [target.target_y]) in the [target_zone_name]." /datum/mission/prospect_stake/waypoint_label() return "Prospect stake" +// ========================================================================= +// CLAIM SITE: a real world to stand on +// ========================================================================= + +/** + * # Claim Site + * + * The planet target narrowed to ground a claim can stand on: terrain worlds + * only (GLOB.overmap_planets also holds empty-space dock placeholders, crash + * sites and weak signals, none of which have any), and never neutral space. + * Picks a biome first and hands it to the planet target's wanted_planet + * filter, so every other rule (exclusivity, occupancy, field contacts, + * relocation tracking) is the planet target's own. + */ +/datum/mission_target/planet/claim_site + /// Biomes a field contact may be minted as when no terrain world is on offer + var/static/list/claim_biomes = list( + /datum/overmap/planet/lava, + /datum/overmap/planet/ice, + /datum/overmap/planet/jungle, + /datum/overmap/planet/beach, + /datum/overmap/planet/wasteland, + ) + +/// Claims are filed in contested and lawless space only +/datum/mission_target/planet/claim_site/zone_is_eligible(band) + return band != ZONE_GREEN && ..() + +/datum/mission_target/planet/claim_site/resolve() + var/list/biomes = list() + for(var/obj/structure/overmap/planet/candidate as anything in GLOB.overmap_planets) + if(QDELETED(candidate) || candidate == planet || candidate.mission_exclusive || !candidate.is_terrain_planet()) + continue + if(!istype(get_turf(candidate), /turf/open/overmap) || !zone_is_eligible(SSovermap_zones?.get_zone_type(get_turf(candidate)))) + continue + biomes |= candidate.planet + if(!length(biomes)) + // Never mint a contact in a band no claim can use (a green-pinned reserved slot + // would leave a stray planet on the chart for an offer that then fails) + if(!mission?.create_field_contact || !zone_is_eligible(preferred_zone)) + unhook() + return FALSE + // Nothing standing in the band: the planet target mints a contact of this biome + biomes = claim_biomes + wanted_planet = pick(biomes) + return ..() + +/** + * Why a claim beacon can't stand on this turf, as a balloon line, or null if + * it can. Open, solid ground on this world's own surface or caves - never a + * deck, a ruin, space, lava, water or a chasm - and clear of the landing + * berths and any parked hull, so no ship comes down on the claim and no wave + * lands on somebody's floor. + */ +/datum/mission_target/planet/claim_site/proc/claim_ground_refusal(turf/spot) + if(!spot || !isopenturf(spot) || isgroundlessturf(spot) || !contains_turf(spot)) + return "no claim ground here!" + if(!istype(get_area(spot), /area/overmap_encounter/planetoid) || spot.is_blocked_turf(exclude_mobs = TRUE)) + return "no claim ground here!" + var/datum/space_level/level = length(planet.mapzone?.z_levels) ? planet.mapzone.z_levels[1] : null + var/strip_top = planet.get_dock_strip_top_y(level) + if(!isnull(strip_top) && spot.y <= strip_top) + return "too close to the landing field!" + // One tile past where a wave can scatter, so no arrival lands on a deck + for(var/turf/nearby as anything in RANGE_TURFS(ZONE_MOBS_WAVE_SCATTER + 1, spot)) + if(istype(nearby.loc, /area/shuttle)) + return "too close to a ship!" + return null + /** * # Claim Beacon Kit * - * The packed beacon handed over at accept. Use in hand while the ship holds - * at the stake coordinates; the deployment channel raises the beacon on the - * spot you're standing. + * The packed beacon handed over at accept. Use in hand on open ground at the + * claim site; the deployment channel raises the beacon on the spot you're + * standing. */ /obj/item/claim_beacon_kit name = "claim beacon kit" @@ -93,29 +180,40 @@ if(.) return var/datum/mission_objective/claim_defense/objective = objective_ref?.resolve() - if(!objective?.mission || objective.mission.failed || objective.mission.completed) + if(!objective?.mission || objective.mission.failed || objective.mission.completed || !objective.active) balloon_alert(user, "contract expired!") return TRUE - if(!objective.active || !objective.ship_on_station()) - balloon_alert(user, "ship not holding at the stake coordinates!") + var/refusal = claim_refusal(objective, get_turf(user)) + if(refusal) + balloon_alert(user, refusal) return TRUE balloon_alert(user, "deploying beacon...") playsound(src, 'sound/items/tools/ratchet.ogg', 60, TRUE) if(!do_after(user, 8 SECONDS, target = user)) balloon_alert(user, "deployment interrupted!") return TRUE - if(!objective.ship_on_station()) - balloon_alert(user, "drifted off station!") + if(!objective.mission || objective.mission.failed || objective.mission.completed || !objective.active) + return TRUE + refusal = claim_refusal(objective, get_turf(user)) + if(refusal) + balloon_alert(user, refusal) return TRUE objective.deploy(user, get_turf(user)) return TRUE +/// Why the beacon can't go up here, or null if it can +/obj/item/claim_beacon_kit/proc/claim_refusal(datum/mission_objective/claim_defense/objective, turf/spot) + var/datum/mission_target/planet/claim_site/site = objective.mission?.target + if(!istype(site)) + return "no claim ground here!" + return site.claim_ground_refusal(spot) + /** * # Claim Beacon * * The deployed stake: destructible on purpose - defending it is the mission. - * Ticks the defend clock while the ship holds station, calls its own waves, - * and files every beat on Wideband like the contested cache does. + * Ticks the defend clock while a crew member stands guard, calls its own + * waves, and files every beat on Wideband like the contested cache does. */ /obj/structure/mission_claim_beacon name = "prospecting claim beacon" @@ -125,15 +223,13 @@ anchored = TRUE density = TRUE max_integrity = 400 + // Driven into the ground: wind, force pushes and gravity never shift it off the claim + move_resist = INFINITY /// Weakref to the claim_defense objective driving this beacon var/datum/weakref/objective_ref /// Internal wideband transmitter (the contested-cache broadcast pattern) var/obj/item/radio/headset/radio - /// world.time gate for the next defender wave - var/next_wave_at = 0 - /// Throttle for off-station warnings - COOLDOWN_DECLARE(offstation_warn_cooldown) /obj/structure/mission_claim_beacon/Initialize(mapload) . = ..() @@ -142,7 +238,6 @@ radio.canhear_range = 0 radio.set_listening(FALSE) radio.recalculateChannels() - next_wave_at = world.time + 1 MINUTES START_PROCESSING(SSobj, src) /obj/structure/mission_claim_beacon/Destroy() @@ -164,14 +259,6 @@ priority_announce(message, "Prospect Filing", sender_override = "Sector Claims Registry") radio?.talk_into(src, message, RADIO_CHANNEL_WIDEBAND) -/obj/structure/mission_claim_beacon/proc/show_offstation_warning() - if(!COOLDOWN_FINISHED(src, offstation_warn_cooldown)) - return - COOLDOWN_START(src, offstation_warn_cooldown, 15 SECONDS) - balloon_alert_to_viewers("off station!") - var/datum/mission_objective/claim_defense/objective = objective_ref?.resolve() - objective?.notify_crew("Claim beacon reports the ship off station.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') - /// The claim matured: stop being a mission object, stay as scenery /obj/structure/mission_claim_beacon/proc/retire() STOP_PROCESSING(SSobj, src) diff --git a/voidcrew/modules/missions/objectives/site_ops.dm b/voidcrew/modules/missions/objectives/site_ops.dm index a77f06b6a49..38f748d4e2e 100644 --- a/voidcrew/modules/missions/objectives/site_ops.dm +++ b/voidcrew/modules/missions/objectives/site_ops.dm @@ -470,20 +470,28 @@ /** * The prospect-stake centerpiece: a claim beacon kit is dispensed at accept; - * deploying it (only with the ship holding at the target coordinates) files - * the claim on Wideband - everyone hears - and starts a defend clock that - * only runs while the ship stays on station. The beacon survives, it prints - * the deed; the beacon dies, the contract dies with it. + * deploying it on the site's open ground (the kit decides where) files the + * claim on Wideband - everyone hears - and starts a defend clock that only + * runs while a crew member stands guard at the beacon. Waves come on that + * clock, not the wall clock, so stepping away never calls extra company. The + * beacon survives, it prints the deed; the beacon dies, the contract dies + * with it. */ /datum/mission_objective/claim_defense /// How long the beacon must survive after deployment var/defend_duration = 8 MINUTES /// Seconds of defense remaining (set on deploy) var/defend_remaining = 0 - /// How far (overmap tiles) the ship may drift from the stake coordinates - var/hold_range = 2 - /// Interval between defender waves while the clock runs + /// How close (tiles) a crew member must stand to the beacon for the clock to run + var/guard_range = 7 + /// A guard idle at the keyboard this long stops counting + var/guard_afk_time = 3 MINUTES + /// Guarded time before the first defender wave + var/first_wave_delay = 1 MINUTES + /// Guarded time between defender waves var/wave_interval = 2 MINUTES + /// Seconds of guarded time left until the next wave + var/wave_countdown = 0 /// zone_mobs theme answering the claim (set by the mission) var/wave_theme /// The kit handed over at accept @@ -492,6 +500,10 @@ var/obj/structure/mission_claim_beacon/beacon /// Whether the deed has printed (beacon survived) var/deed_printed = FALSE + /// Whether the last tick found nobody standing guard + var/unguarded = FALSE + /// Throttle for the unattended-beacon notice + COOLDOWN_DECLARE(unguarded_notice_cooldown) /datum/mission_objective/claim_defense/deactivate() if(beacon) @@ -520,15 +532,28 @@ notify_crew("Claim beacon kit delivered.") return TRUE -/// Whether the ship is close enough to the stake coordinates to count -/datum/mission_objective/claim_defense/proc/ship_on_station() +/** + * Whether a crew member is standing guard: alive, awake, at the keyboard, out + * on the site's ground rather than inside a hull, and within guard_range of + * the beacon. Nobody there, no clock. + */ +/datum/mission_objective/claim_defense/proc/is_guarded() var/obj/structure/overmap/ship/ship = get_servant() - var/datum/mission_target/target = mission?.target - if(!ship || !target) + var/datum/team/crew_team = ship?.ship_team + var/turf/beacon_turf = get_turf(beacon) + if(!crew_team || !beacon_turf) return FALSE - var/rel_x = ship.x - var/rel_y = ship.y - OVERMAP_SOUTH_SIDE_COORD + 1 - return sqrt((rel_x - target.target_x) ** 2 + (rel_y - target.target_y) ** 2) <= hold_range + for(var/datum/mind/member as anything in crew_team.members) + var/mob/living/guard = member?.current + if(!istype(guard) || guard.stat >= UNCONSCIOUS || !guard.client || guard.client.is_afk(guard_afk_time)) + continue + var/turf/guard_turf = get_turf(guard) + if(!guard_turf || guard_turf.z != beacon_turf.z || get_dist(guard_turf, beacon_turf) > guard_range) + continue + if(istype(get_area(guard_turf), /area/shuttle) || !mission.target?.contains_turf(guard_turf)) + continue + return TRUE + return FALSE /// The kit was used in hand and the channel finished: raise the beacon /datum/mission_objective/claim_defense/proc/deploy(mob/living/user, turf/where) @@ -537,26 +562,38 @@ beacon = new(where) beacon.objective_ref = WEAKREF(src) QDEL_NULL(kit) + unguarded = FALSE + wave_countdown = first_wave_delay / 10 var/datum/mission_target/target = mission.target - beacon.file_claim("Prospect filing: mineral-rights claim staked at grid [target.target_x], [target.target_y]. Claim matures in [round(defend_duration / (1 MINUTES))] minutes. All counterclaims settled on-site.") + var/datum/mission_target/planet/site = target + var/site_name = istype(site) ? site.planet?.name : null + beacon.file_claim("Prospect filing: mineral-rights claim staked[site_name ? " on [site_name]" : ""] at grid [target.target_x], [target.target_y]. All counterclaims settled on-site.") notify_crew("Claim filed on Wideband.", type = SHIP_NOTIFY_WARNING) -/// One second of defending, ticked by the beacon's process +/// One tick of defending, from the beacon's process /datum/mission_objective/claim_defense/proc/tick_defense(seconds) if(deed_printed || !active || !beacon) return - if(!ship_on_station()) - beacon.show_offstation_warning() + if(!is_guarded()) + if(!unguarded && COOLDOWN_FINISHED(src, unguarded_notice_cooldown)) + COOLDOWN_START(src, unguarded_notice_cooldown, 1 MINUTES) + notify_crew("Claim beacon unattended.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') + unguarded = TRUE return + unguarded = FALSE defend_remaining -= seconds if(defend_remaining <= 0) print_deed() return - // Periodic company - if(wave_theme && beacon.next_wave_at <= world.time) - beacon.next_wave_at = world.time + wave_interval - new wave_theme(get_turf(beacon), list(1, 2), TRUE) - notify_crew("Claim jumpers inbound on the beacon!", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') + // Periodic company, counted in guarded time + if(!wave_theme) + return + wave_countdown -= seconds + if(wave_countdown > 0) + return + wave_countdown += wave_interval / 10 + new wave_theme(get_turf(beacon), list(1, 2), TRUE) + notify_crew("Claim jumpers inbound on the beacon!", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') /// The clock ran out with the beacon alive: print the deed /datum/mission_objective/claim_defense/proc/print_deed() @@ -578,14 +615,19 @@ beacon = null if(deed_printed || !mission || mission.failed || mission.completed) return + // Left alone long enough for the site to empty out and be torn down + var/datum/mission_target/planet/site = mission.target + if(istype(site) && site.planet?.unloading) + mission.fail("Claim abandoned - the filing is void.") + return mission.fail("Claim beacon destroyed - the filing is void.") /datum/mission_objective/claim_defense/get_progress_string() if(deed_printed) return "Deed printed" if(beacon) - if(!ship_on_station()) - return "OFF STATION - claim clock paused" + if(unguarded) + return "Beacon unattended" return "Defend the beacon ([round(defend_remaining / 60)]:[add_leading(num2text(round(defend_remaining) % 60), 2, "0")])" var/datum/mission_target/target = mission?.target - return target ? "Deploy the beacon at ([target.target_x], [target.target_y])" : "Deploy the beacon" + return target ? "Stake the claim at ([target.target_x], [target.target_y])" : "Stake the claim" From e7badf872d781e35eef0a287d3bd6b9db78f8d93 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:53:36 -0800 Subject: [PATCH 021/472] Big Game Hunt beast walks in on the staked lure; pad effects for kit deliveries and turn-ins; wrench for hot extraction --- voidcrew/modules/drug_smuggling/mission.dm | 3 + voidcrew/modules/missions/_missions.dm | 4 + voidcrew/modules/missions/mission_pad.dm | 15 + .../missions/missions/big_game_hunt.dm | 383 +++++++++++++++++- .../modules/missions/missions/live_capture.dm | 1 + .../missions/missions/recovery_variants.dm | 20 +- .../modules/missions/objectives/site_ops.dm | 1 + .../modules/unit_tests/voidcrew_missions.dm | 27 +- 8 files changed, 425 insertions(+), 29 deletions(-) diff --git a/voidcrew/modules/drug_smuggling/mission.dm b/voidcrew/modules/drug_smuggling/mission.dm index 7255eb52cae..7b1116a0086 100644 --- a/voidcrew/modules/drug_smuggling/mission.dm +++ b/voidcrew/modules/drug_smuggling/mission.dm @@ -292,6 +292,7 @@ fail("No mission pad to deliver the formula chip to.") return formula = new(chip_turf) + mission_pad_delivery_effect(chip_turf) formula.recipe = recipe servant?.ship_notify("Formula chip for '[recipe.street_name]' delivered to your mission pad. Examine it for the shopping list.", "DRUG RUN", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) @@ -433,6 +434,8 @@ fail("Nowhere to deliver the finished batch, contract void.") return var/obj/item/mission_recovery/drug_product/product = new(product_turf) + if(!preferred_turf) + mission_pad_delivery_effect(product_turf) // the pad fallback: it arrives like any other delivery product.configure(recipe, purity_tier) bind_item(product) register_quest_atom(product) diff --git a/voidcrew/modules/missions/_missions.dm b/voidcrew/modules/missions/_missions.dm index a1108c51fd5..a8afff5c9f5 100644 --- a/voidcrew/modules/missions/_missions.dm +++ b/voidcrew/modules/missions/_missions.dm @@ -952,6 +952,8 @@ GLOBAL_VAR_INIT(mission_work_serial, 0) var/result = objective.accept_item(turned_in_item, reward_anchor) if(result == MISSION_ITEM_REFUSED) return FALSE + // The pad took something: every hand-over gets the effect, counted ones included + flash_reward_anchor(reward_anchor) if(result == MISSION_ITEM_PROGRESS) return TRUE // partial hand-over; mission continues if(!all_objectives_satisfied()) @@ -1222,6 +1224,8 @@ GLOBAL_VAR_INIT(mission_work_serial, 0) if(completed && !failed && guarded_prize_family && !guarded_prize_rolled) guarded_prize_rolled = TRUE guarded_prize = servant.award_guarded_prize(guarded_prize_family, quote_zone, reward_anchor, type) + if(guarded_prize) + flash_reward_anchor(reward_anchor) /** * Turn-in visual: pads get their teleport effect, other anchors stay quiet diff --git a/voidcrew/modules/missions/mission_pad.dm b/voidcrew/modules/missions/mission_pad.dm index 3c59618a55b..9d705f86760 100644 --- a/voidcrew/modules/missions/mission_pad.dm +++ b/voidcrew/modules/missions/mission_pad.dm @@ -23,6 +23,8 @@ var/datum/pirate_negotiation/tribute_negotiation /// The ship this pad is installed on var/obj/structure/overmap/ship/linked_ship + /// world.time of the last teleport effect, so several things moving through in one tick make one flash + var/last_teleport_effect = -1 /obj/machinery/mission_pad/Initialize(mapload) . = ..() @@ -160,6 +162,11 @@ * Called when items are teleported in (rewards) or out (turn-ins). */ /obj/machinery/mission_pad/proc/do_teleport_effect() + // A turn-in and its payout land in the same tick: one flash, one sound + if(last_teleport_effect == world.time) + return + last_teleport_effect = world.time + // Visual effect flick("lpad-idle", src) @@ -171,6 +178,14 @@ // Sound effect playsound(src, 'sound/effects/magic/teleport_diss.ogg', 50, TRUE) +/** + * Plays the pad effect for something a mission has just put on a pad's tile + * (kits, chips, finished batches). Does nothing where no pad stands there. + */ +/proc/mission_pad_delivery_effect(turf/where) + var/obj/machinery/mission_pad/pad = locate() in where + pad?.do_teleport_effect() + /** * Handle items entering the pad's turf (via signal from connect_loc element). * If we're linked to a tribute negotiation, check if the item is valid tribute. diff --git a/voidcrew/modules/missions/missions/big_game_hunt.dm b/voidcrew/modules/missions/missions/big_game_hunt.dm index ce3d5d7a760..4e0682cc6dd 100644 --- a/voidcrew/modules/missions/missions/big_game_hunt.dm +++ b/voidcrew/modules/missions/missions/big_game_hunt.dm @@ -16,10 +16,32 @@ * the trophy after that. Losing any of them voids the contract (FAIL policy, * with exactly one planet of each type per round, a filtered retarget has * nowhere to go, and set_lure's one kit can't re-arm a fresh site anyway). + * + * The staked lure stays behind as an anchored rig that thumps and stinks of + * chum. It is scenery, not a quest atom. The beast does not appear beside it: + * a roar rolls in from one side, then after a short wait the beast and its + * entourage come into being out of every player's sight, a good way off, and + * walk in on the stake (AI planning subtree at the bottom of this file). The + * rig is trampled when the beast reaches it, or goes quiet when it dies. */ /// How long staking the hunting lure takes #define LURE_STAKE_TIME (8 SECONDS) +/// Wait between the lure going in and its quarry setting out (the walk in comes on top) +#define LURE_ARRIVAL_MIN (9 SECONDS) +#define LURE_ARRIVAL_MAX (16 SECONDS) +/// How far from the stake the quarry starts out, in tiles. An AI drops a movement +/// goal more than 14 tiles away, so the far edge stays under that. +#define LURE_APPROACH_MIN 10 +#define LURE_APPROACH_MAX 13 +/// Nearest a player may stand to a starting tile. A screen reaches 7 tiles each way. +#define LURE_UNSEEN_RANGE 9 +/// How long an animal keeps heading for the lure before it goes back to its own business +#define LURE_CHARGE_TIME (45 SECONDS) +/// The rumble before a beast climbs out of the ground (matches zone_mobs_arrival.dm) +#define LURE_EMERGE_TIME (2.5 SECONDS) +/// Blackboard key: world.time after which a lured animal stops heading for the lure +#define BB_LURE_CHARGE_EXPIRES "BB_lure_charge_expires" /datum/mission/big_game_hunt name = "Big Game Hunt" @@ -208,8 +230,8 @@ * A lure kit is dispensed at the servant's mission pad on accept (the * prospect-stake kit pattern) and tracked as the quest atom, burn the lure, * void the hunt. Staking it in open ground on the target planet records the - * stake position for the kill step and completes; the beast's spawn devours - * the kit. Assumes the FAIL loss policy: the one kit can't re-arm after a + * stake position for the kill step, stands the rig on that spot and + * completes. Assumes the FAIL loss policy: the one kit can't re-arm after a * retarget reset. */ /datum/mission_objective/set_lure @@ -235,6 +257,7 @@ mission.fail("No mission pad to dispense the hunting lure to.") return FALSE kit = new(kit_turf) + mission_pad_delivery_effect(kit_turf) kit.objective_ref = WEAKREF(src) // Losing the lure voids the hunt (and points the GPS chain at it meanwhile) mission.register_quest_atom(kit) @@ -245,13 +268,13 @@ /datum/mission_objective/set_lure/proc/on_planted(mob/living/user, turf/where) if(completed || !active || !where || !mission || mission.failed || mission.completed) return - if(hunt_objective) - hunt_objective.lure_turf = where + // The rig goes into the ground here and the kill step takes it from there + hunt_objective?.plant_lure(where) // The staked lure stops being the tracked objective; the beast that - // answers takes over the slot as soon as complete() advances the chain + // answers takes over the slot once it has been called in mission.forget_quest_atom(kit) QDEL_NULL(kit) - notify_crew("Lure staked. Something big is moving toward it - stand ready.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') + notify_crew("Lure staked.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') complete() /datum/mission_objective/set_lure/get_progress_string() @@ -266,36 +289,231 @@ /** * kill_named pointed at the lure instead of a random stretch of surface: the - * beast (a trophy-grade specimen of the planet's apex fauna) breaks cover - * next to the stake the moment the lure objective completes, with its - * entourage of lesser fauna. Death drops the mission-bound trophy. + * beast (a trophy-grade specimen of the planet's apex fauna) answers the + * stake with its entourage of lesser fauna. It is called in after a roar and a + * short wait, starts well out of sight and heads for the stake; a site too + * cramped to hide an approach gets the beast climbing out of the ground next + * to it instead. Death drops the mission-bound trophy. */ /datum/mission_objective/field/kill_named/big_game proof_type = /obj/item/mission_recovery/trophy - /// Where the lure was staked; the beast breaks cover next to it + /// Where the lure was staked; the beast comes for it var/turf/lure_turf + /// The rig standing on lure_turf (weak: it goes with the site) + var/datum/weakref/lure_stake_ref + /// Pending arrival timer: the wait before the quarry sets out, or the rumble before it digs out + var/arrival_timer + /// Whether the beast has been called but is not out yet + var/arrival_pending = FALSE + /// Where the first roar came from; the quarry comes in from the same side + var/turf/approach_hint /// The trophy the beast drops ("tendril-crowned skull"), set by the mission var/trophy_part = "trophy" /// Trophy-grade specimen: raise maxHealth/health to this on spawn (0 = leave alone). /// A plain var-level scale-up, no new buff system. var/health_override = 0 +/datum/mission_objective/field/kill_named/big_game/Destroy() + cancel_arrival() + // Never leave the rig behind for a contract that no longer exists + var/obj/structure/hunting_lure_stake/stake = lure_stake_ref?.resolve() + stake?.topple() + lure_stake_ref = null + approach_hint = null + lure_turf = null + return ..() + +/datum/mission_objective/field/kill_named/big_game/deactivate() + // Completed, failed or rewound: nothing is on its way and the rig goes quiet + cancel_arrival() + var/obj/structure/hunting_lure_stake/stake = lure_stake_ref?.resolve() + stake?.topple() + lure_stake_ref = null + return ..() + /datum/mission_objective/field/kill_named/big_game/reset() . = ..() lure_turf = null - -/// The beast answers the lure, not a random spawn point + approach_hint = null + +/// Stops a call that is still waiting to be answered +/datum/mission_objective/field/kill_named/big_game/proc/cancel_arrival() + if(arrival_timer) + deltimer(arrival_timer) + arrival_timer = null + arrival_pending = FALSE + +/// The kit went into the ground: remember the spot and stand the rig on it +/datum/mission_objective/field/kill_named/big_game/proc/plant_lure(turf/where) + lure_turf = where + var/obj/structure/hunting_lure_stake/stake = new(where) + lure_stake_ref = WEAKREF(stake) + +/// The beast answers the lure, not a random spawn point, and not at once /datum/mission_objective/field/kill_named/big_game/do_field_spawn() if(spawned) return if(!lure_turf) return ..() spawned = TRUE - spawn_field_objects(get_nearby_open_turf(lure_turf, 4)) + arrival_pending = TRUE + // First sign of it: a roar from the side it will come in on (or from the + // ground itself where there is no hiding place) + var/list/approaches = approach_turfs() + approach_hint = length(approaches) ? pick(approaches) : null + var/turf/roar_from = approach_hint || lure_turf + playsound(roar_from, 'sound/mobs/non-humanoids/space_dragon/space_dragon_roar.ogg', 40, TRUE, extrarange = 20) + for(var/mob/living/hearer in GLOB.player_list) + if(hearer.z == lure_turf.z && get_dist(hearer, lure_turf) <= 14 && hearer.can_hear()) + to_chat(hearer, span_boldwarning("Something huge bellows in the distance.")) + arrival_timer = addtimer(CALLBACK(src, PROC_REF(answer_lure)), rand(LURE_ARRIVAL_MIN, LURE_ARRIVAL_MAX), TIMER_STOPPABLE | TIMER_DELETE_ME) + +/// The wait is over: the beast and its entourage set out for the stake +/datum/mission_objective/field/kill_named/big_game/proc/answer_lure() + arrival_timer = null + if(!arrival_still_wanted()) + return + var/datum/mission_target/site = mission.target + if(!lure_turf || !site?.is_valid() || !site.is_interior_loaded() || !site.contains_turf(lure_turf)) + // The hunting ground went away during the wait; arm again the ordinary way + cancel_arrival() + spawned = FALSE + lure_turf = null + arm() + return + var/list/approaches = approach_turfs() + if(!length(approaches)) + dig_out() + return + arrival_pending = FALSE + // Start on the side the roar came from where there is room, and where a + // straight run at the stake looks open + var/list/same_side = list() + for(var/turf/candidate as anything in approaches) + if(approach_hint && get_dist(candidate, approach_hint) <= 6) + same_side += candidate + var/list/pool = length(same_side) ? same_side : approaches + var/turf/lead = pick(pool) + for(var/_ in 1 to 12) + var/turf/candidate = pick(pool) + if(approach_is_clear(candidate)) + lead = candidate + break + // The entourage trails in beside it, on tiles that are also out of sight + var/list/guard_spots = list() + for(var/_ in guard_types) + var/list/beside = list() + for(var/turf/candidate as anything in approaches) + if(get_dist(candidate, lead) <= 4) + beside += candidate + guard_spots += length(beside) ? pick(beside) : lead + spawn_field_objects(lead, guard_spots) + playsound(lead, 'sound/mobs/non-humanoids/space_dragon/space_dragon_roar.ogg', 50, TRUE, extrarange = 20) + +/// Whether a delayed arrival should still go ahead +/datum/mission_objective/field/kill_named/big_game/proc/arrival_still_wanted() + return active && !completed && mission && !mission.failed && !mission.completed && !mission.named_kill_completed && !target_mob -/datum/mission_objective/field/kill_named/big_game/spawn_field_objects(turf/spawn_turf) +/** + * Open ground a good way from the stake that nobody is close enough to see: + * the quarry's starting tiles. Empty where the site is too small, too crowded + * or too cluttered to hide an approach. + */ +/datum/mission_objective/field/kill_named/big_game/proc/approach_turfs() + . = list() + var/datum/mission_target/site = mission?.target + if(!lure_turf || !site) + return + var/list/watchers = list() + for(var/mob/living/player in GLOB.player_list) + var/turf/watcher_turf = get_turf(player) + if(watcher_turf?.z == lure_turf.z && player.stat != DEAD) + watchers += watcher_turf + for(var/turf/open/candidate in RANGE_TURFS(LURE_APPROACH_MAX, lure_turf)) + if(get_dist(candidate, lure_turf) < LURE_APPROACH_MIN) + continue + if(isspaceturf(candidate) || islava(candidate) || ischasm(candidate)) + continue + // Open planet ground only: no landed ship, no ruin, nothing past the site's edge + if(!istype(get_area(candidate), /area/overmap_encounter/planetoid) || !site.contains_turf(candidate)) + continue + // Not the docking strip: a ship coming in to land would crush whatever stands on it + if((candidate.turf_flags & NO_RUINS) || candidate.is_blocked_turf(exclude_mobs = TRUE)) + continue + var/seen = FALSE + for(var/turf/watcher_turf as anything in watchers) + if(get_dist(watcher_turf, candidate) < LURE_UNSEEN_RANGE) + seen = TRUE + break + if(!seen) + . += candidate + +/// Whether a straight run from the tile to the stake is open ground +/datum/mission_objective/field/kill_named/big_game/proc/approach_is_clear(turf/from) + for(var/turf/along as anything in get_line(from, lure_turf)) + if(!isopenturf(along) || islava(along) || ischasm(along) || along.is_blocked_turf(exclude_mobs = TRUE)) + return FALSE + return TRUE + +/** + * No hiding place for an approach: the ground shakes beside the lure and the + * pack climbs out of it, the same rumble the mission waves use. + */ +/datum/mission_objective/field/kill_named/big_game/proc/dig_out() + var/turf/lead = get_nearby_open_turf(lure_turf, 4) + var/list/guard_spots = list() + for(var/_ in guard_types) + guard_spots += get_nearby_open_turf(lure_turf, 4) + for(var/turf/spot as anything in list(lead) + guard_spots) + new /obj/effect/temp_visual/zone_mobs_emerge(spot) + playsound(lead, 'sound/effects/break_stone.ogg', 60, TRUE) + arrival_timer = addtimer(CALLBACK(src, PROC_REF(emerge_pack), lead, guard_spots), LURE_EMERGE_TIME, TIMER_STOPPABLE | TIMER_DELETE_ME) + +/datum/mission_objective/field/kill_named/big_game/proc/emerge_pack(turf/lead, list/guard_spots) + arrival_timer = null + arrival_pending = FALSE + if(!arrival_still_wanted()) + return + spawn_field_objects(lead, guard_spots, emerge = TRUE) + +/// The ground gives up a body: rubble underneath, then it rises out of the dirt +/datum/mission_objective/field/kill_named/big_game/proc/rise_from_ground(mob/living/body) + if(QDELETED(body)) + return + var/turf/where = get_turf(body) + if(where) + new /obj/effect/decal/cleanable/rubble(where) + playsound(where, 'sound/effects/shovel_dig.ogg', 60, TRUE) + // Parallel and relative so a mob's own idle animation (floating, bobbing) survives + var/final_alpha = body.alpha + body.alpha = 0 + body.pixel_z -= 16 + animate(body, pixel_z = 16, time = 0.6 SECONDS, easing = QUAD_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(body, alpha = final_alpha, time = 0.4 SECONDS, flags = ANIMATION_PARALLEL) + +/** + * Points a lured animal at the stake. It walks there whenever it has nothing + * of its own to hunt, so it goes for the first crew member it sees and + * otherwise comes straight in. + */ +/datum/mission_objective/field/kill_named/big_game/proc/send_to_lure(mob/living/body) + var/datum/ai_controller/brain = body?.ai_controller + if(QDELETED(body) || !istype(brain) || !lure_turf) + return + brain.set_blackboard_key(BB_TRAVEL_DESTINATION, lure_turf) + brain.set_blackboard_key(BB_LURE_CHARGE_EXPIRES, world.time + LURE_CHARGE_TIME) + var/list/subtrees = brain.planning_subtrees ? brain.planning_subtrees.Copy() : list() + subtrees += GLOB.ai_subtrees[/datum/ai_planning_subtree/travel_to_point/lure_charge] + brain.planning_subtrees = subtrees + // An animal this far from anyone would otherwise stand about until somebody wandered close + brain.can_idle = FALSE + brain.reset_ai_status() + +/datum/mission_objective/field/kill_named/big_game/spawn_field_objects(turf/spawn_turf, list/guard_spots, emerge = FALSE) if(mission.named_kill_completed) return ..() + // Anything that did not climb out of the ground has come a long way to get here + var/charging = !emerge && lure_turf var/mob/living/beast = spawn_field_npc(target_mob_type, spawn_turf, finalize = FALSE) beast.name = mission.objective_name beast.desc += " This one is old, scarred, and much too big. The guild wants it as a wall piece." @@ -307,11 +525,20 @@ // The entourage: lesser fauna drawn in by the blood on the wind. Killing // them pays nothing - the contract is the name on the trophy. for(var/guard_type in guard_types) - var/mob/living/guard = spawn_field_npc(guard_type, get_nearby_open_turf(spawn_turf), finalize = FALSE) + var/turf/guard_turf = length(guard_spots) ? pick_n_take(guard_spots) : get_nearby_open_turf(spawn_turf) + var/mob/living/guard = spawn_field_npc(guard_type, guard_turf, finalize = FALSE) guard.desc += " It followed something much bigger here." guard.npc_spawn_context.finalize() - beast.visible_message(span_boldwarning("[beast] comes crashing in and goes straight for the lure!")) - notify_crew("The lure's been taken. [mission.objective_name] is out in the open - good hunting.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') + if(charging) + send_to_lure(guard) + if(emerge) + rise_from_ground(guard) + if(charging) + send_to_lure(beast) + if(emerge) + rise_from_ground(beast) + var/obj/structure/hunting_lure_stake/stake = lure_stake_ref?.resolve() + stake?.watch(beast) /datum/mission_objective/field/kill_named/big_game/get_proof_name() return "[mission.objective_name]'s [trophy_part]" @@ -319,6 +546,8 @@ /datum/mission_objective/field/kill_named/big_game/get_progress_string() if(!spawned) return "Draw [mission?.objective_name || "the beast"] out with the lure" + if(arrival_pending) + return "[mission?.objective_name || "The beast"] is coming for the lure" return "Bring down [mission?.objective_name || "the beast"]" // ========================================================================= @@ -335,8 +564,8 @@ * # Hunting Lure * * The kit handed over at accept. Stake it in hand, in open ground on the - * contracted planet's surface; the staking channel is not subtle, and the - * thing it calls arrives immediately. + * contracted planet's surface. The staking channel is not subtle; what it + * calls takes a little while to get there. */ /obj/item/hunting_lure name = "apex hunting lure" @@ -376,6 +605,87 @@ objective.on_planted(user, get_turf(user)) return TRUE +/** + * # Staked Hunting Lure + * + * What the kit turns into once it is in the ground: an anchored rig that + * thumps and stinks of chum until the beast it called tramples it or dies. + * Scenery only: not a quest atom, not an item, nothing to salvage, and the hunt + * carries on whatever happens to it. + */ +/obj/structure/hunting_lure_stake + name = "hunting lure" + desc = "A guild lure rig staked into the dirt. It stinks of chum and thumps like a heartbeat." + icon = 'voidcrew/modules/missions/icons/recovery.dmi' + icon_state = "recovery_anchored" + anchored = TRUE + density = FALSE + move_resist = INFINITY + resistance_flags = INDESTRUCTIBLE | LAVA_PROOF | FIRE_PROOF | UNACIDABLE | ACID_PROOF + /// Weakref to the beast that was called here + var/datum/weakref/watched_ref + /// Whether it is already coming down + var/toppled = FALSE + /// Process ticks since the last thump + var/thump_ticks = 0 + +/obj/structure/hunting_lure_stake/Initialize(mapload) + . = ..() + START_PROCESSING(SSobj, src) + +/obj/structure/hunting_lure_stake/Destroy() + STOP_PROCESSING(SSobj, src) + unwatch() + return ..() + +/obj/structure/hunting_lure_stake/process(seconds_per_tick) + thump_ticks++ + if(thump_ticks < 2) + return + thump_ticks = 0 + playsound(src, 'sound/effects/singlebeat.ogg', 40, TRUE, extrarange = 6) + animate(src, pixel_y = 2, time = 1) + animate(pixel_y = 0, time = 2) + +/// The beast it called is out: trample the rig when it gets here, drop it if the beast dies first +/obj/structure/hunting_lure_stake/proc/watch(mob/living/beast) + if(toppled || QDELETED(beast)) + return + unwatch() + watched_ref = WEAKREF(beast) + RegisterSignal(beast, COMSIG_MOVABLE_MOVED, PROC_REF(on_beast_moved)) + RegisterSignal(beast, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING), PROC_REF(on_beast_gone)) + +/obj/structure/hunting_lure_stake/proc/unwatch() + var/mob/living/beast = watched_ref?.resolve() + if(beast) + UnregisterSignal(beast, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_DEATH, COMSIG_QDELETING)) + watched_ref = null + +/obj/structure/hunting_lure_stake/proc/on_beast_moved(mob/living/source, atom/old_loc, movement_dir, forced, list/old_locs) + SIGNAL_HANDLER + if(get_dist(source, src) <= 1) + topple(source) + +/obj/structure/hunting_lure_stake/proc/on_beast_gone(datum/source) + SIGNAL_HANDLER + topple() + +/// The rig comes down and goes quiet, trampled by the beast if it got here +/obj/structure/hunting_lure_stake/proc/topple(mob/living/by) + if(toppled) + return + toppled = TRUE + STOP_PROCESSING(SSobj, src) + unwatch() + if(by) + visible_message(span_danger("[by] tramples [src].")) + else + visible_message(span_warning("[src] topples over and goes quiet.")) + playsound(src, 'sound/effects/clang.ogg', 50, TRUE) + animate(src, transform = turn(transform, 80), pixel_y = -4, alpha = 0, time = 1 SECONDS, easing = QUAD_EASING | EASE_IN) + QDEL_IN(src, 1 SECONDS) + /** * # Hunting Trophy * @@ -388,4 +698,39 @@ icon_state = "recovery" w_class = WEIGHT_CLASS_BULKY +// ========================================================================= +// THE CHARGE, lured animals walk in on the stake +// ========================================================================= + +/** + * The last thing a lured animal plans. Everything it can hunt on its own comes + * first, so it goes for the first crew member it sees and otherwise heads for + * the stake. Gives up after LURE_CHARGE_TIME and goes back to its own business. + */ +/datum/ai_planning_subtree/travel_to_point/lure_charge + travel_behaviour = /datum/ai_behavior/travel_towards/lure_charge + +/datum/ai_planning_subtree/travel_to_point/lure_charge/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + if(controller.blackboard[location_key] && world.time > controller.blackboard[BB_LURE_CHARGE_EXPIRES]) + controller.clear_blackboard_key(location_key) + return + return ..() + +/// Close enough to the stake counts as there; the goal is dropped once it is reached +/datum/ai_behavior/travel_towards/lure_charge + required_distance = 2 + +/datum/ai_behavior/travel_towards/lure_charge/finish_action(datum/ai_controller/controller, succeeded, target_key) + . = ..() + if(succeeded) + controller.clear_blackboard_key(target_key) + #undef LURE_STAKE_TIME +#undef LURE_ARRIVAL_MIN +#undef LURE_ARRIVAL_MAX +#undef LURE_APPROACH_MIN +#undef LURE_APPROACH_MAX +#undef LURE_UNSEEN_RANGE +#undef LURE_CHARGE_TIME +#undef LURE_EMERGE_TIME +#undef BB_LURE_CHARGE_EXPIRES diff --git a/voidcrew/modules/missions/missions/live_capture.dm b/voidcrew/modules/missions/missions/live_capture.dm index 17090950e32..494fd5a7763 100644 --- a/voidcrew/modules/missions/missions/live_capture.dm +++ b/voidcrew/modules/missions/missions/live_capture.dm @@ -232,6 +232,7 @@ mission.fail("No mission pad to dispense the capture crate to.") return FALSE kit = new(kit_turf) + mission_pad_delivery_effect(kit_turf) kit.objective_ref = WEAKREF(src) mission.bind_item(kit) // One crate per contract: losing it voids the capture diff --git a/voidcrew/modules/missions/missions/recovery_variants.dm b/voidcrew/modules/missions/missions/recovery_variants.dm index 2a53a981637..aeebcc3881a 100644 --- a/voidcrew/modules/missions/missions/recovery_variants.dm +++ b/voidcrew/modules/missions/missions/recovery_variants.dm @@ -203,7 +203,7 @@ * # Anchored Recovery Cargo * * The extraction objective: a mission_recovery item that starts bolted to - * the deck. Unbolting is a channel that trips the mission's alarm. + * the deck. Unbolting is a wrench channel that trips the mission's alarm. */ /obj/item/mission_recovery/anchored desc = "Contracted salvage, bolted to the deck." @@ -214,26 +214,32 @@ /obj/item/mission_recovery/anchored/examine(mob/user) . = ..() if(anchored) - . += span_warning("It is bolted down.") + . += span_warning("It is bolted to the floor.") +/// Bare hands get nowhere with the bolts /obj/item/mission_recovery/anchored/attack_hand(mob/living/user, list/modifiers) if(!anchored) return ..() + balloon_alert(user, "it's bolted down") + return TRUE + +/obj/item/mission_recovery/anchored/wrench_act(mob/living/user, obj/item/tool) + if(!anchored) + return NONE balloon_alert(user, "wrenching it free...") - playsound(src, 'sound/items/tools/ratchet.ogg', 60, TRUE) - if(!do_after(user, EXTRACTION_UNBOLT_TIME, target = src)) + if(!tool.use_tool(src, user, EXTRACTION_UNBOLT_TIME, volume = 60)) balloon_alert(user, "still bolted!") - return TRUE + return ITEM_INTERACT_BLOCKING if(!anchored) balloon_alert(user, "already free") - return TRUE + return ITEM_INTERACT_BLOCKING anchored = FALSE balloon_alert(user, "wrenched free") playsound(src, 'sound/machines/click.ogg', 60, TRUE) var/datum/mission/recovery/extraction/mission = mission_ref?.resolve() if(istype(mission)) mission.trigger_extraction_alarm(src) - return TRUE + return ITEM_INTERACT_SUCCESS #undef SURVEY_CALIBRATE_TIME #undef EXTRACTION_UNBOLT_TIME diff --git a/voidcrew/modules/missions/objectives/site_ops.dm b/voidcrew/modules/missions/objectives/site_ops.dm index a77f06b6a49..bdbc68b18e4 100644 --- a/voidcrew/modules/missions/objectives/site_ops.dm +++ b/voidcrew/modules/missions/objectives/site_ops.dm @@ -516,6 +516,7 @@ mission.fail("No mission pad to dispense the claim beacon to.") return FALSE kit = new(kit_turf) + mission_pad_delivery_effect(kit_turf) kit.objective_ref = WEAKREF(src) notify_crew("Claim beacon kit delivered.") return TRUE diff --git a/voidcrew/modules/unit_tests/voidcrew_missions.dm b/voidcrew/modules/unit_tests/voidcrew_missions.dm index 03090e20b56..fce019a05a8 100644 --- a/voidcrew/modules/unit_tests/voidcrew_missions.dm +++ b/voidcrew/modules/unit_tests/voidcrew_missions.dm @@ -619,7 +619,7 @@ /datum/unit_test/voidcrew_mission_kill_rewind abstract_type = /datum/unit_test/voidcrew_mission_kill_rewind -/datum/unit_test/voidcrew_mission_kill_rewind/proc/make_hunt(big_game = FALSE) +/datum/unit_test/voidcrew_mission_kill_rewind/proc/make_hunt(big_game = FALSE, wait_for_beast = FALSE) var/datum/mission/kill_rewind_test/mission = allocate(__IMPLIED_TYPE__) mission.servant = allocate(/obj/structure/overmap/ship) mission.servant.ship_account = allocate(/datum/bank_account) @@ -652,8 +652,13 @@ mission.activate_current_objective() if(lure) lure.on_planted(null, target.spawn_turf) - // The single-turf fixture stands in for the whole hunting ground. - hunt.target_mob.forceMove(target.spawn_turf) + if(!wait_for_beast) + // In play the beast comes in after a wait and a walk; the fixture skips both. + var/datum/mission_objective/field/kill_named/big_game/trophy_hunt = hunt + trophy_hunt.cancel_arrival() + trophy_hunt.spawn_field_objects(target.spawn_turf) + // The single-turf fixture stands in for the whole hunting ground. + hunt.target_mob.forceMove(target.spawn_turf) return mission /datum/unit_test/voidcrew_mission_kill_rewind/proc/test_mobs() @@ -746,6 +751,22 @@ TEST_ASSERT(!mission.turn_in(run_loc_floor_bottom_left, null, TRUE), "Repeated forced turn-in must still honor mission completion") TEST_ASSERT_EQUAL(mission.reward_calls, 1, "Repeated turn-in must not distribute rewards twice") +/// The beast is called in with a wait, not conjured beside the staked lure. +/datum/unit_test/voidcrew_mission_kill_rewind/lure_wait/Run() + var/datum/mission/kill_rewind_test/mission = make_hunt(TRUE, wait_for_beast = TRUE) + var/datum/mission_objective/field/kill_named/big_game/hunt = mission.current_objective() + TEST_ASSERT_NULL(hunt.target_mob, "The beast must not appear in the tick the lure goes in") + TEST_ASSERT_EQUAL(length(test_mobs()), 0, "Neither the beast nor its guards may appear in the tick the lure goes in") + TEST_ASSERT(hunt.arrival_pending && hunt.arrival_timer, "The staked lure must call the beast in with a wait") + var/obj/structure/hunting_lure_stake/stake = hunt.lure_stake_ref?.resolve() + TEST_ASSERT_NOTNULL(stake, "The staked lure must stay in the world") + TEST_ASSERT(stake.anchored, "The staked lure must be anchored") + TEST_ASSERT_NULL(mission.quest_atom, "The staked lure is scenery, not the tracked quest atom") + hunt.deactivate() + TEST_ASSERT_NULL(hunt.arrival_timer, "A contract that ends during the wait must cancel the arrival") + TEST_ASSERT(stake.toppled, "A contract that ends must leave the lure down") + TEST_ASSERT_EQUAL(length(test_mobs()), 0, "A cancelled arrival must not spawn anything") + /// Carried proof stays valid while the crew revisits an emptied site. /datum/unit_test/voidcrew_mission_kill_rewind/carried/Run() for(var/big_game in list(FALSE, TRUE)) From c9309737548ed1c933b04820dd7349bd88d67f6d Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:53:52 -0800 Subject: [PATCH 022/472] Exploration contracts sweep the sector on arrival Reaching an exploration site now starts a half-minute sensor sweep instead of finishing the job outright. The ship holds station within two tiles of the site; drifting off pauses the sweep and keeps its progress, and coming back resumes it. About half the time the sweep picks up an ordinary derelict signal five to seven tiles out that the crew has not charted, and pins it on the helm. Only plain seeded signals with no claim, lock, broadcast or rumour on them count, and the find is rolled once per contract. Pay and the board claim are unchanged. --- .../modules/missions/missions/exploration.dm | 279 +++++++++++++++++- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_exploration_sweep.dm | 119 ++++++++ 3 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 voidcrew/modules/unit_tests/voidcrew_exploration_sweep.dm diff --git a/voidcrew/modules/missions/missions/exploration.dm b/voidcrew/modules/missions/missions/exploration.dm index 9f94c4a82da..d719f8f0056 100644 --- a/voidcrew/modules/missions/missions/exploration.dm +++ b/voidcrew/modules/missions/missions/exploration.dm @@ -1,9 +1,15 @@ /** * # Exploration Mission * - * Fly the ship to marked coordinates, then come home for the pay. The - * coordinates come from the zone controller's own turf sets (no private ring - * math), and pay scales through the shared zone table. + * Fly the ship to marked coordinates, hold station there while the sensors sweep + * the sector, then come home for the pay. The coordinates come from the zone + * controller's own turf sets (no private ring math), and pay scales through the + * shared zone table. + * + * The sweep is the arrival beat: half a minute on station, a sonar ping, and + * sometimes a derelict signal nobody aboard had charted yet, pinned on the helm + * so the crew can decide whether to go and look. The signal is always one that + * is already out there; the sweep never makes anything. */ /datum/mission/exploration name = "Exploration Contract" @@ -12,6 +18,12 @@ // Green-band pay; apply_zone_scaling() raises it for deeper zones value_min = 150 value_max = 250 + /// The sweep step, for the waypoint label + var/datum/mission_objective/sector_sweep/sweep + +/datum/mission/exploration/Destroy() + sweep = null + return ..() /datum/mission/exploration/setup_target() var/datum/mission_target/coords/coords = new(src) @@ -28,17 +40,274 @@ return TRUE /datum/mission/exploration/build_objectives() - add_objective(new /datum/mission_objective/goto_coords) + var/datum/mission_objective/goto_coords/approach = new + // The sweep announces the arrival itself + approach.arrival_message = null + add_objective(approach) + sweep = new + add_objective(sweep) /datum/mission/exploration/update_text() name = "Exploration Contract: ([target.target_x], [target.target_y])" desc = "Chart the sector at ([target.target_x], [target.target_y]) in the [target_zone_name]." /datum/mission/exploration/waypoint_label() - return "Survey site" + return sweep?.completed ? "Charted sector" : "Survey site" /datum/mission/exploration/get_ui_data() var/list/data = ..() data["target_x"] = target.target_x data["target_y"] = target.target_y return data + +// ========================================================================= +// SECTOR SWEEP: hold station at the site while the sensors work +// ========================================================================= + +/** + * The ship holds within hold_range of the mission target for sweep_time. Drifting + * off pauses the sweep and keeps what it had; coming back resumes it. The possible + * find (an uncharted ordinary derelict signal near the site) is rolled once, the + * first time the sweep starts, so breaking off and coming back never rerolls it. + */ +/datum/mission_objective/sector_sweep + /// How long the ship has to hold station + var/sweep_time = 30 SECONDS + /// Overmap tiles from the site the ship may sit and keep sweeping + var/hold_range = 2 + /// How far from the site a derelict signal can show up on the sweep + var/find_range = 7 + /// Percent chance the sweep turns up a derelict signal, when one is out there + var/find_chance = 50 + /// Gap between "broken off" lines, so a ship drifting on the edge doesn't spam the crew + var/notice_gap = 10 SECONDS + /// Sweep time banked from earlier runs + var/banked_time = 0 + /// Whether the ship is on station and the sweep is running + var/running = FALSE + /// world.time the current run started + var/run_started = 0 + /// Whether the sweep has started at least once + var/started = FALSE + /// Timer: the sweep finishing + var/finish_timer + /// Timer: the faint return halfway through, when there is a find + var/return_timer + /// Whether the faint return has been called out + var/return_reported = FALSE + /// Whether a "broken off" line went out that still needs its "resumed" + var/break_reported = FALSE + /// Whether the find has been rolled for this sweep + var/find_rolled = FALSE + /// The derelict signal the sweep picked up, if any + var/datum/weakref/find_ref + COOLDOWN_DECLARE(break_notice_cooldown) + +/datum/mission_objective/sector_sweep/Destroy() + find_ref = null + return ..() + +/datum/mission_objective/sector_sweep/activate() + . = ..() + var/obj/structure/overmap/ship/ship = get_servant() + if(!ship) + return + RegisterSignal(ship, COMSIG_MOVABLE_MOVED, PROC_REF(on_ship_moved)) + check_station() + +/datum/mission_objective/sector_sweep/deactivate() + var/obj/structure/overmap/ship/ship = get_servant() + if(ship) + UnregisterSignal(ship, COMSIG_MOVABLE_MOVED) + stop_run() + return ..() + +/datum/mission_objective/sector_sweep/reset() + . = ..() + banked_time = 0 + running = FALSE + run_started = 0 + started = FALSE + return_reported = FALSE + break_reported = FALSE + find_rolled = FALSE + find_ref = null + +/datum/mission_objective/sector_sweep/proc/on_ship_moved(datum/source) + SIGNAL_HANDLER + check_station() + +/// Wrapped overmap distance from relative coordinates to the mission site, or null +/datum/mission_objective/sector_sweep/proc/distance_to_site(list/coords) + var/datum/mission_target/target = mission?.target + if(!coords || !target) + return null + var/dx = overmap_wrapped_delta(coords[1] - target.target_x, OVERMAP_SIZE - 2) + var/dy = overmap_wrapped_delta(coords[2] - target.target_y, OVERMAP_SIZE - 2) + return sqrt(dx * dx + dy * dy) + +/// Whether the servant ship is close enough to the site to sweep it +/datum/mission_objective/sector_sweep/proc/is_on_station() + var/obj/structure/overmap/ship/ship = get_servant() + if(!ship) + return FALSE + var/dist = distance_to_site(ship.get_relative_overmap_coords()) + return !isnull(dist) && dist <= hold_range + +/// Starts or pauses the sweep to match where the ship is +/datum/mission_objective/sector_sweep/proc/check_station() + if(completed || !active) + return + var/on_station = is_on_station() + if(on_station && !running) + start_run() + else if(!on_station && running) + stop_run() + if(COOLDOWN_FINISHED(src, break_notice_cooldown)) + COOLDOWN_START(src, break_notice_cooldown, notice_gap) + break_reported = TRUE + notify_crew("Sweep broken off.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') + +/datum/mission_objective/sector_sweep/proc/start_run() + running = TRUE + run_started = world.time + if(!find_rolled) + find_rolled = TRUE + roll_find() + finish_timer = addtimer(CALLBACK(src, PROC_REF(finish_sweep)), max(sweep_time - banked_time, 1), TIMER_STOPPABLE) + var/halfway = sweep_time / 2 + if(find_ref && !return_reported && banked_time < halfway) + return_timer = addtimer(CALLBACK(src, PROC_REF(report_return)), halfway - banked_time, TIMER_STOPPABLE) + if(!started) + started = TRUE + notify_crew("Survey site reached. Sweeping the sector.", sound = 'sound/machines/sonar-ping.ogg') + else if(break_reported) + break_reported = FALSE + notify_crew("Back on station. Sweep resumed.", sound = 'sound/machines/sonar-ping.ogg') + +/// Banks the current run and stops its timers (safe to call while already stopped) +/datum/mission_objective/sector_sweep/proc/stop_run() + if(running) + banked_time += world.time - run_started + running = FALSE + if(finish_timer) + deltimer(finish_timer) + finish_timer = null + if(return_timer) + deltimer(return_timer) + return_timer = null + +/** + * Picks what the sweep turns up: the nearest ordinary derelict signal just past the + * ship's own sight from the site, that this ship has neither seen nor charted. Only + * plain naturally seeded signals with nobody's claim on them count, so the sweep + * never points at a rumour site, a contract's site, a bounty lair or a broadcast. + */ +/datum/mission_objective/sector_sweep/proc/roll_find() + find_ref = null + if(!prob(find_chance)) + return + var/obj/structure/overmap/ship/ship = get_servant() + var/turf/ship_turf = get_turf(ship) + if(!ship_turf) + return + var/obj/structure/overmap/space_ruin/best + var/best_dist + for(var/obj/structure/overmap/space_ruin/ruin as anything in GLOB.space_ruin_signals) + if(!is_ordinary_signal(ruin)) + continue + var/turf/ruin_turf = get_turf(ruin) + if(ruin_turf?.z != ship_turf.z) + continue + var/ruin_ref = REF(ruin) + if(ship.get_waypoint(ruin_ref)) + continue + var/datum/weakref/seen = ship.discovered_contacts[ruin_ref] + if(seen?.resolve() == ruin) + continue + var/dist = distance_to_site(ruin.get_relative_overmap_coords()) + if(isnull(dist) || dist <= SHIP_VIEW_RANGE || dist > find_range) + continue + if(isnull(best_dist) || dist < best_dist) + best = ruin + best_dist = dist + if(best) + find_ref = WEAKREF(best) + +/// A plain derelict signal from natural seeding, with no claim, lock, broadcast or rumour on it +/datum/mission_objective/sector_sweep/proc/is_ordinary_signal(obj/structure/overmap/space_ruin/ruin) + if(QDELETED(ruin) || ruin.type != /obj/structure/overmap/space_ruin || !ruin.sensor_detectable) + return FALSE + if(ruin.rare || ruin.no_replacement || ruin.mission_locked || ruin.mission_exclusive || ruin.mission_claims) + return FALSE + return !(ruin in GLOB.overmap_fleet_beacons) + +/// The find, if it is still out there +/datum/mission_objective/sector_sweep/proc/get_find() + var/obj/structure/overmap/space_ruin/find = find_ref?.resolve() + if(QDELETED(find)) + find_ref = null + return null + return find + +/// " to the northeast" from the ship to `object`, or "" when there is no bearing +/datum/mission_objective/sector_sweep/proc/bearing_to(obj/structure/overmap/object) + var/static/list/compass_words = list( + "N" = "north", "NE" = "northeast", "E" = "east", "SE" = "southeast", + "S" = "south", "SW" = "southwest", "W" = "west", "NW" = "northwest", + ) + var/obj/structure/overmap/ship/ship = get_servant() + var/list/from = ship?.get_relative_overmap_coords() + var/list/to_coords = object?.get_relative_overmap_coords() + if(!from || !to_coords) + return "" + var/dx = overmap_wrapped_delta(to_coords[1] - from[1], OVERMAP_SIZE - 2) + var/dy = overmap_wrapped_delta(to_coords[2] - from[2], OVERMAP_SIZE - 2) + var/compass = overmap_delta_to_compass(dx, dy) + if(!compass) + return "" + return " to the [compass_words[compass]]" + +/datum/mission_objective/sector_sweep/proc/report_return() + return_timer = null + if(!active || completed) + return + var/obj/structure/overmap/space_ruin/find = get_find() + if(!find) + return + return_reported = TRUE + notify_crew("Faint return[bearing_to(find)].", sound = 'sound/machines/sonar-ping.ogg') + +/datum/mission_objective/sector_sweep/proc/finish_sweep() + finish_timer = null + if(!active || completed) + return + running = FALSE + banked_time = sweep_time + if(return_timer) + deltimer(return_timer) + return_timer = null + var/static/list/quiet_lines = list( + "Sector charted. Quiet out here.", + "Sector charted. Nothing but dust.", + "Sector charted. Just old drive wakes.", + ) + var/line = pick(quiet_lines) + var/obj/structure/overmap/ship/ship = get_servant() + var/obj/structure/overmap/space_ruin/find = get_find() + // A scan the crew ran themselves meanwhile may have charted it already: no news then + if(ship && find && ship.chart_as_waypoint(find)) + line = "Sector charted. Derelict signal logged[bearing_to(find)]." + notify_crew("[line] Pay's waiting on the board.", sound = 'voidcrew/sound/notify2.ogg') + var/datum/mission/owner = mission + complete() + owner?.push_waypoint() + +/datum/mission_objective/sector_sweep/get_progress_string() + if(completed) + return "Sector charted" + if(running) + return "Sweeping the sector" + if(started) + return "Sweep broken off" + return "Sweep the sector" diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..f524093529b 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -82,6 +82,7 @@ #include "voidcrew_dynamic_events.dm" #include "voidcrew_economy_metrics.dm" #include "voidcrew_event_horizon.dm" +#include "voidcrew_exploration_sweep.dm" #include "voidcrew_fleet_waypoints.dm" #include "voidcrew_fresh_cores.dm" #include "voidcrew_gear_behaviour.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_exploration_sweep.dm b/voidcrew/modules/unit_tests/voidcrew_exploration_sweep.dm new file mode 100644 index 00000000000..79fe487f620 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_exploration_sweep.dm @@ -0,0 +1,119 @@ +/// An exploration contract aimed at whatever test-room spot the test caches into its target +/datum/mission/exploration/sweep_test + // Never rolled onto a real board + weight = 0 + +/datum/mission/exploration/sweep_test/setup_target() + target = new /datum/mission_target/exploration_sweep_test(src) + return TRUE + +/// A green destination with no overmap object behind it +/datum/mission_target/exploration_sweep_test + +/datum/mission_target/exploration_sweep_test/get_zone_type() + return 1 // ZONE_GREEN + +/** + * Arriving at an exploration site starts a sweep rather than finishing the job: the + * ship holds station, drifting off pauses it without losing it, two crews on one site + * sweep independently, and the only thing a sweep turns up is an ordinary derelict + * signal that was already out there. + */ +/datum/unit_test/voidcrew_exploration_sweep + +/datum/unit_test/voidcrew_exploration_sweep/proc/make_ship() + var/obj/structure/overmap/ship/ship = allocate(/obj/structure/overmap/ship) + ship.ship_team = allocate(/datum/team/voidcrew) + ship.ship_team.ship = ship + allocated += ship.get_crew_record() + return ship + +/datum/unit_test/voidcrew_exploration_sweep/proc/make_contract(turf/site) + var/datum/mission/exploration/sweep_test/contract = allocate(/datum/mission/exploration/sweep_test) + contract.target?.cache_coords_from(site) + return contract + +/datum/unit_test/voidcrew_exploration_sweep/Run() + var/turf/site = run_loc_floor_top_right + var/turf/away = run_loc_floor_bottom_left + // Two tiles off the site: past the arrival range, still on station for the sweep + var/turf/edge = locate(site.x - 2, site.y, site.z) + TEST_ASSERT_NOTNULL(edge, "The test room must have a tile two west of its top right corner.") + + var/obj/structure/overmap/ship/first_ship = make_ship() + var/obj/structure/overmap/ship/second_ship = make_ship() + first_ship.forceMove(away) + second_ship.forceMove(away) + + var/datum/mission/exploration/sweep_test/first = make_contract(site) + var/datum/mission/exploration/sweep_test/second = make_contract(site) + TEST_ASSERT(!first.generation_failed && !second.generation_failed, "The test exploration contracts must generate.") + var/datum/mission_objective/sector_sweep/first_sweep = first.sweep + var/datum/mission_objective/sector_sweep/second_sweep = second.sweep + TEST_ASSERT_NOTNULL(first_sweep, "Exploration must build a sweep step.") + TEST_ASSERT_EQUAL(length(first.objectives), 2, "Exploration is travel, then the sweep.") + var/datum/mission_objective/goto_coords/first_approach = first.objectives[1] + TEST_ASSERT(istype(first_approach), "Exploration must still open with the travel step.") + TEST_ASSERT_NULL(first_approach.arrival_message, "The sweep announces the arrival, not the travel step.") + TEST_ASSERT_EQUAL(first.objectives[2], first_sweep, "The sweep must be the contract's last step.") + TEST_ASSERT(!first.requires_item, "Exploration is still claimed at the board, with nothing to hand over.") + + // Only the second crew's sweep can pick anything up + first_sweep.find_chance = 0 + second_sweep.find_chance = 100 + var/obj/structure/overmap/space_ruin/rumour_site = allocate(/obj/structure/overmap/space_ruin, away) + rumour_site.rare = TRUE + var/obj/structure/overmap/space_ruin/derelict = allocate(/obj/structure/overmap/space_ruin, away) + + TEST_ASSERT(first.start_mission(first_ship), "The first exploration contract must start.") + TEST_ASSERT(second.start_mission(second_ship), "The second exploration contract must start.") + TEST_ASSERT(!first_sweep.active, "The sweep waits until the ship reaches the site.") + + // Arrival: the travel step is done, the sweep runs, and the contract is not claimable yet + first_ship.forceMove(site) + TEST_ASSERT(first_approach.completed, "Reaching the site must finish the travel step.") + TEST_ASSERT(first_sweep.active && first_sweep.running, "Reaching the site must start the sweep.") + TEST_ASSERT_NOTNULL(first_sweep.finish_timer, "A running sweep must have its finish timer armed.") + TEST_ASSERT(!first.can_complete(), "Arriving alone must not make the contract claimable.") + TEST_ASSERT(!second_sweep.active, "One crew reaching the site must not start another crew's sweep.") + + second_ship.forceMove(site) + TEST_ASSERT(second_sweep.running, "The second crew's sweep must start when its own ship arrives.") + TEST_ASSERT_EQUAL(second_sweep.find_ref?.resolve(), derelict, "The sweep must pick up the plain derelict signal and pass over the rumour site.") + + // Drifting to the edge of the site keeps sweeping + first_ship.forceMove(edge) + TEST_ASSERT(first_sweep.running, "A ship two tiles off the site is still on station.") + + // Leaving pauses this crew's sweep only, and keeps it + first_ship.forceMove(away) + TEST_ASSERT(!first_sweep.running, "Leaving the site must pause the sweep.") + TEST_ASSERT_NULL(first_sweep.finish_timer, "A paused sweep must not finish on its own.") + TEST_ASSERT(first_sweep.active && !first_sweep.completed, "A paused sweep stays the contract's current step.") + TEST_ASSERT_EQUAL(first_sweep.get_progress_string(), "Sweep broken off", "The board must show the sweep as broken off.") + TEST_ASSERT(!first.can_complete(), "A broken-off sweep must not make the contract claimable.") + TEST_ASSERT(second_sweep.running, "Another crew leaving must not pause this crew's sweep.") + + first_ship.forceMove(site) + TEST_ASSERT(first_sweep.running, "Coming back must resume the sweep.") + TEST_ASSERT_NOTNULL(first_sweep.finish_timer, "A resumed sweep must re-arm its finish timer.") + + // The find is rolled once: breaking off and coming back with no luck left keeps it + second_ship.forceMove(away) + second_sweep.find_chance = 0 + second_ship.forceMove(site) + TEST_ASSERT_EQUAL(second_sweep.find_ref?.resolve(), derelict, "Breaking off and coming back must not reroll the find.") + + first_sweep.finish_sweep() + TEST_ASSERT(first_sweep.completed, "A finished sweep must complete its step.") + TEST_ASSERT(first.can_complete(), "A finished sweep must leave the contract ready to claim at the board.") + var/datum/ship_waypoint/site_mark = first_ship.get_waypoint(REF(first)) + TEST_ASSERT_NOTNULL(site_mark, "The contract's site stays on the helm until it is claimed.") + TEST_ASSERT_EQUAL(site_mark.name, "Charted sector", "The site's helm label must change once it is charted.") + TEST_ASSERT_NULL(first_ship.get_waypoint(REF(derelict)), "A sweep with no find must chart nothing extra.") + + second_sweep.finish_sweep() + TEST_ASSERT(second.can_complete(), "The second crew's contract must finish on its own sweep.") + TEST_ASSERT_NOTNULL(second_ship.get_waypoint(REF(derelict)), "The find must be charted on the sweeping crew's helm.") + TEST_ASSERT_NULL(second_ship.get_waypoint(REF(rumour_site)), "A rumour site must never be charted by a sweep.") + TEST_ASSERT_NULL(first_ship.get_waypoint(REF(derelict)), "One crew's find must not chart on another crew's helm.") From 27c39e1fe581c1e9b666807413e65a9c168c2715 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:01:58 -0800 Subject: [PATCH 023/472] Mission objectives spawn out of sight and come with set dressing Field objectives no longer appear in front of a crew already standing on the site. The planet and space ruin spawn pickers, the survey pylon scatter and the drug run's replacement specimen skip turfs any living player can see (their screen plus a small margin, with walls blocking line of sight) and fall back to the spot farthest from everyone when nowhere is hidden. Planet spawns also skip lava and chasms, and ruin spawns prefer tiles nothing dense stands on. Recovery objectives get a small scene around them: a crater, furrow, smoking wreck and debris for field expeditions; a dropped flare and litter for recovery; a smashed crate and flare for extraction; a scorch and rubble under each survey pylon. All of it is effects, so none of it can be picked up or salvaged, and the smoke and flares burn out on a timer. A destroyed drug run ingredient now regrows a few tiles from its cache instead of anywhere on the planet. --- tgstation.dme | 2 + voidcrew/modules/drug_smuggling/mission.dm | 26 +- voidcrew/modules/missions/mission_target.dm | 16 +- .../missions/missions/field_expedition.dm | 4 +- .../missions/missions/recovery_variants.dm | 1 + .../modules/missions/objectives/site_ops.dm | 33 +- voidcrew/modules/missions/site_dressing.dm | 375 ++++++++++++++++++ voidcrew/modules/missions/spawn_cover.dm | 103 +++++ .../code/modules/overmap/space_ruin.dm | 12 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../voidcrew_mission_spawn_cover.dm | 52 +++ 11 files changed, 607 insertions(+), 18 deletions(-) create mode 100644 voidcrew/modules/missions/site_dressing.dm create mode 100644 voidcrew/modules/missions/spawn_cover.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_mission_spawn_cover.dm diff --git a/tgstation.dme b/tgstation.dme index a06d0df2577..fd8d63bacac 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7292,6 +7292,8 @@ #include "voidcrew\modules\missions\mission_pad.dm" #include "voidcrew\modules\missions\mission_target.dm" #include "voidcrew\modules\missions\player_bounty.dm" +#include "voidcrew\modules\missions\site_dressing.dm" +#include "voidcrew\modules\missions\spawn_cover.dm" #include "voidcrew\modules\missions\code\controllers\subsystem\missions.dm" #include "voidcrew\modules\missions\missions\big_game_hunt.dm" #include "voidcrew\modules\missions\missions\delivery.dm" diff --git a/voidcrew/modules/drug_smuggling/mission.dm b/voidcrew/modules/drug_smuggling/mission.dm index 7255eb52cae..55b8cea6c3c 100644 --- a/voidcrew/modules/drug_smuggling/mission.dm +++ b/voidcrew/modules/drug_smuggling/mission.dm @@ -496,6 +496,10 @@ var/respawns_left = 1 /// Guard so notify_when_loaded() is never double-registered var/awaiting_load = FALSE + /// Where the current harvest scene was laid; a replacement grows near it + var/turf/scene_anchor + /// How far from the cache a replacement specimen may grow + var/regrow_radius = 6 /datum/ingredient_site/New(datum/mission/drug_run/mission, list/entry) ..() @@ -508,6 +512,7 @@ release_item(delete_uncollected = TRUE) QDEL_NULL(site_target) mission = null + scene_anchor = null return ..() /** @@ -554,6 +559,7 @@ site_target?.notify_when_loaded() return spawned = TRUE + scene_anchor = scene_turf new /obj/structure/ingredient_cache(scene_turf) var/obj/item/drug_ingredient/ingredient = new ingredient_type(get_nearby_open_turf(scene_turf)) watch_item(ingredient) @@ -608,7 +614,7 @@ site_target.notify_when_loaded() return if(respawns_left > 0) - var/turf/retry_turf = site_target.get_spawn_turf() + var/turf/retry_turf = find_regrowth_turf() if(retry_turf) respawns_left-- var/obj/item/drug_ingredient/replacement = new ingredient_type(retry_turf) @@ -639,6 +645,24 @@ if(mission.active) mission.push_site_waypoint(src) +/** + * Where a replacement specimen grows: clear ground a few tiles from the cache, + * out of the crew's sight when there is any. Falls back to the whole site when + * the camp has no room (or the anchor isn't on this site any more). + */ +/datum/ingredient_site/proc/find_regrowth_turf() + if(scene_anchor && site_target.contains_turf(scene_anchor)) + var/list/nearby = list() + for(var/turf/open/tile in RANGE_TURFS(regrow_radius, scene_anchor)) + if(isgroundlessturf(tile) || tile.is_blocked_turf(exclude_mobs = TRUE)) + continue + if(istype(get_area(tile), /area/shuttle) || !site_target.contains_turf(tile)) + continue + nearby += tile + if(length(nearby)) + return pick_unwatched_turf(nearby) + return site_target.get_spawn_turf() + /// Stops watching the live field ingredient; optionally removes it outright /datum/ingredient_site/proc/release_item(delete_uncollected = FALSE) var/obj/item/drug_ingredient/ingredient = item_ref?.resolve() diff --git a/voidcrew/modules/missions/mission_target.dm b/voidcrew/modules/missions/mission_target.dm index 00f7d84bc2c..7fb44cf6f05 100644 --- a/voidcrew/modules/missions/mission_target.dm +++ b/voidcrew/modules/missions/mission_target.dm @@ -62,7 +62,8 @@ /datum/mission_target/proc/notify_when_loaded() return -/// A random open turf inside the loaded interior for objective spawns +/// A random open turf inside the loaded interior for objective spawns, out of +/// every living player's sight when there is one (see spawn_cover.dm) /datum/mission_target/proc/get_spawn_turf() return null @@ -222,7 +223,7 @@ mission?.on_target_interior_loaded() /datum/mission_target/space_ruin/get_spawn_turf() - return ruin?.get_random_interior_turf() || ruin?.ruin_bottom_left + return ruin?.get_random_interior_turf(unwatched = TRUE) || ruin?.ruin_bottom_left /datum/mission_target/space_ruin/contains_turf(turf/T) if(!ruin || !T) @@ -405,16 +406,23 @@ min_y = max(min_y, above_docks) if(min_x > max_x || min_y > max_y) return null + // A crew already on the surface must not watch the objective appear. Every + // sample that passes is kept as a fallback, farthest from them wins. + var/datum/spawn_cover/cover = new(site_z) for(var/_ in 1 to 40) var/turf/candidate = locate(rand(min_x, max_x), rand(min_y, max_y), site_z) if(!candidate || !isopenturf(candidate) || isspaceturf(candidate)) continue + // Lava burns the objective and a chasm swallows it before anyone arrives + if(islava(candidate) || ischasm(candidate)) + continue if(istype(get_area(candidate), /area/shuttle)) continue if(candidate.is_blocked_turf(exclude_mobs = TRUE)) continue - return candidate - return null + if(cover.is_hidden(candidate)) + return candidate + return cover.fallback /** * "Is the player at the site" - the primitive the pylon scatter and the hunting diff --git a/voidcrew/modules/missions/missions/field_expedition.dm b/voidcrew/modules/missions/missions/field_expedition.dm index 5ca8eaec4e4..ce12215fa5c 100644 --- a/voidcrew/modules/missions/missions/field_expedition.dm +++ b/voidcrew/modules/missions/missions/field_expedition.dm @@ -51,7 +51,9 @@ )) /datum/mission/field_expedition/build_objectives() - add_objective(new /datum/mission_objective/field/plant_quest) + var/datum/mission_objective/field/plant_quest/plant = new + plant.scene_type = /datum/mission_scene/crash + add_objective(plant) add_objective(new /datum/mission_objective/deliver/bound) /// Picking the core up can wake the neighborhood (once per era) diff --git a/voidcrew/modules/missions/missions/recovery_variants.dm b/voidcrew/modules/missions/missions/recovery_variants.dm index 2a53a981637..25374e386b9 100644 --- a/voidcrew/modules/missions/missions/recovery_variants.dm +++ b/voidcrew/modules/missions/missions/recovery_variants.dm @@ -165,6 +165,7 @@ /datum/mission/recovery/extraction/build_objectives() var/datum/mission_objective/field/plant_quest/plant = new plant.quest_item_type = /obj/item/mission_recovery/anchored + plant.scene_type = /datum/mission_scene/cargo_wreck add_objective(plant) add_objective(new /datum/mission_objective/deliver/bound) diff --git a/voidcrew/modules/missions/objectives/site_ops.dm b/voidcrew/modules/missions/objectives/site_ops.dm index a77f06b6a49..17aa4118ec2 100644 --- a/voidcrew/modules/missions/objectives/site_ops.dm +++ b/voidcrew/modules/missions/objectives/site_ops.dm @@ -18,12 +18,15 @@ /datum/mission_objective/field/plant_quest /// The quest item type planted at the site var/quest_item_type = /obj/item/mission_recovery + /// Set dressing laid around the item (a /datum/mission_scene path, null for none) + var/scene_type = /datum/mission_scene/dropped_case /datum/mission_objective/field/plant_quest/spawn_field_objects(turf/spawn_turf) var/obj/item/mission_recovery/quest_item = new quest_item_type(spawn_turf) quest_item.name = mission.objective_name mission.bind_item(quest_item) mission.register_quest_atom(quest_item) + dress_mission_scene(scene_type, spawn_turf, mission.target) complete() /datum/mission_objective/field/plant_quest/get_progress_string() @@ -91,23 +94,30 @@ /datum/mission_objective/field/pylon_chain/proc/pick_spawn_spots(turf/anchor) var/list/spots = list(anchor) var/datum/mission_target/target = mission?.target + // Out of sight first; ground a crew can see is only used if the rest can't hold the set + var/datum/spawn_cover/cover = new(anchor.z) var/list/candidates = list() + var/list/in_view = list() for(var/turf/open/tile in RANGE_TURFS(PYLON_SCATTER_RADIUS, anchor)) if(isspaceturf(tile) || tile.is_blocked_turf(exclude_mobs = TRUE)) continue if(target && !target.contains_turf(tile)) continue - candidates += tile - while(length(spots) < points_total && length(candidates)) - var/turf/candidate = pick_n_take(candidates) - var/too_close = FALSE - for(var/turf/taken as anything in spots) - if(get_dist(candidate, taken) < PYLON_MIN_SPACING) - too_close = TRUE - break - if(too_close) - continue - spots += candidate + if(cover.is_hidden(tile)) + candidates += tile + else + in_view += tile + for(var/list/pool as anything in list(candidates, in_view)) + while(length(spots) < points_total && length(pool)) + var/turf/candidate = pick_n_take(pool) + var/too_close = FALSE + for(var/turf/taken as anything in spots) + if(get_dist(candidate, taken) < PYLON_MIN_SPACING) + too_close = TRUE + break + if(too_close) + continue + spots += candidate // Cramped site: take whatever's open rather than hand back a short set while(length(spots) < points_total) spots += get_nearby_open_turf(anchor, 3) @@ -121,6 +131,7 @@ pylon.name = "survey pylon [length(pylons) + 1] of [points_total]" pylon.objective_ref = WEAKREF(src) pylons += pylon + dress_mission_scene(/datum/mission_scene/pylon_drop, spawn_turf, mission?.target) /// The first pylon still waiting on calibration /datum/mission_objective/field/pylon_chain/proc/next_pylon() diff --git a/voidcrew/modules/missions/site_dressing.dm b/voidcrew/modules/missions/site_dressing.dm new file mode 100644 index 00000000000..75e6363f01a --- /dev/null +++ b/voidcrew/modules/missions/site_dressing.dm @@ -0,0 +1,375 @@ +/** + * # Mission Site Dressing + * + * A recovery item on its own is one sprite on a random tile: nothing on the site + * says anything happened there, and nothing draws the eye to it. A scene lays a + * little set dressing around the objective's anchor turf (a crater and wreckage + * for a crash, a dropped flare and some litter for a lost case) so the spot can be + * found by looking and reads like the job's story. + * + * Everything placed here is an /obj/effect: it can't be picked up, taken apart, + * broken for parts or sold. Footprint is the anchor plus up to two tiles around + * it, only on open, unblocked ground inside the site, never on another objective. + * The only lasting cost is a smoke emitter and a couple of lights, which burn out + * on one timer each; nothing processes. + */ + +/// How long a crash keeps smoking and a dropped flare keeps burning +#define MISSION_SCENE_BURN_TIME (15 MINUTES) + +/// Lays a scene around an objective's anchor turf. Null or a bad path does nothing. +/proc/dress_mission_scene(scene_type, turf/anchor, datum/mission_target/target) + if(!anchor || !ispath(scene_type, /datum/mission_scene)) + return + var/datum/mission_scene/scene = new scene_type + scene.dress(anchor, target) + +/datum/mission_scene + +/// Places the scene. The anchor holds the objective; only flat ground marks go on it. +/datum/mission_scene/proc/dress(turf/anchor, datum/mission_target/target) + return + +/// Whether one piece of dressing may go on this turf +/datum/mission_scene/proc/can_use(turf/tile, datum/mission_target/target) + var/static/list/objective_types = typecacheof(list( + /obj/item/mission_recovery, + /obj/structure/mission_survey_pylon, + /obj/structure/mission_claim_beacon, + /obj/structure/ingredient_cache, + /obj/item/drug_ingredient, + /obj/effect/anomaly, + /obj/effect/decal/mission_scene, + )) + if(!isopenturf(tile) || isspaceturf(tile) || isgroundlessturf(tile)) + return FALSE + if(tile.is_blocked_turf(exclude_mobs = TRUE)) + return FALSE + // A landed ship's floor flies off with whatever is on it + if(istype(get_area(tile), /area/shuttle)) + return FALSE + if(target && !target.contains_turf(tile)) + return FALSE + for(var/atom/movable/thing as anything in tile) + if(objective_types[thing.type]) + return FALSE + return TRUE + +/// Usable turfs within range of the anchor, the anchor itself excluded +/datum/mission_scene/proc/spots_around(turf/anchor, datum/mission_target/target, range = 1) + var/list/spots = list() + for(var/turf/tile as anything in RANGE_TURFS(range, anchor)) + if(tile != anchor && can_use(tile, target)) + spots += tile + return spots + +/** + * Puts one piece on a spot from the list, preferring one no other piece of this + * scene took. Returns the piece, or null when there was nowhere to put it. + */ +/datum/mission_scene/proc/place(piece_type, list/spots, list/taken) + if(!length(spots)) + return null + var/list/free = spots - taken + var/turf/spot = pick(length(free) ? free : spots) + taken += spot + return new piece_type(spot) + +// ========================================================================= +// CRASH: something came down hard (Field Expedition) +// ========================================================================= + +/datum/mission_scene/crash + +/datum/mission_scene/crash/dress(turf/anchor, datum/mission_target/target) + new /obj/effect/decal/mission_scene/scorch(anchor) + new /obj/effect/decal/mission_scene/scorch/core(anchor) + var/list/near = spots_around(anchor, target, 1) + var/list/far = spots_around(anchor, target, 2) - near + var/list/taken = list() + // The furrow it ploughed coming in, and the hull it shed at the end of it + var/skid_dir = pick_skid_dir(anchor, target) + if(skid_dir) + var/obj/effect/decal/mission_scene/skid/skid = new(anchor) + skid.point(skid_dir) + var/turf/beyond = get_step(anchor, REVERSE_DIR(skid_dir)) + if(beyond in near) + taken += beyond + new /obj/effect/decal/mission_scene/wreck(beyond) + else + place(/obj/effect/decal/mission_scene/wreck, near, taken) + else + place(/obj/effect/decal/mission_scene/wreck, near, taken) + place(/obj/effect/decal/mission_scene/wreck/dish, near, taken) + place(/obj/effect/decal/mission_scene/debris, near, taken) + place(/obj/effect/decal/mission_scene/debris/glass, near, taken) + place(/obj/effect/decal/mission_scene/debris, length(far) ? far : near, taken) + for(var/_ in 1 to 3) + place(/obj/effect/decal/mission_scene/debris/rubble, length(far) ? far : near, taken) + +/// A direction with two clear tiles for the furrow, or null +/datum/mission_scene/crash/proc/pick_skid_dir(turf/anchor, datum/mission_target/target) + for(var/direction in shuffle(GLOB.alldirs)) + var/turf/first = get_step(anchor, direction) + var/turf/second = first && get_step(first, direction) + if(second && can_use(first, target) && can_use(second, target)) + return direction + return null + +// ========================================================================= +// DROPPED CASE: somebody lost it here and lit a flare (recovery) +// ========================================================================= + +/datum/mission_scene/dropped_case + +/datum/mission_scene/dropped_case/dress(turf/anchor, datum/mission_target/target) + new /obj/effect/decal/mission_scene/scorch/scuff(anchor) + var/list/near = spots_around(anchor, target, 1) + var/list/taken = list() + place(/obj/effect/decal/mission_scene/flare, near, taken) + place(/obj/effect/decal/mission_scene/debris/glass, near, taken) + place(/obj/effect/decal/mission_scene/debris, near, taken) + +// ========================================================================= +// CARGO WRECK: the crate it came in, broken open (extraction) +// ========================================================================= + +/datum/mission_scene/cargo_wreck + +/datum/mission_scene/cargo_wreck/dress(turf/anchor, datum/mission_target/target) + new /obj/effect/decal/mission_scene/scorch/scuff(anchor) + var/list/near = spots_around(anchor, target, 1) + var/list/taken = list() + place(/obj/effect/decal/mission_scene/crate, near, taken) + place(/obj/effect/decal/mission_scene/flare, near, taken) + place(/obj/effect/decal/mission_scene/debris, near, taken) + place(/obj/effect/decal/mission_scene/debris/glass, near, taken) + +// ========================================================================= +// PYLON DROP: a survey pylon landed from orbit (survey, core sample) +// ========================================================================= + +/datum/mission_scene/pylon_drop + +/datum/mission_scene/pylon_drop/dress(turf/anchor, datum/mission_target/target) + new /obj/effect/decal/mission_scene/scorch/pylon(anchor) + var/list/near = spots_around(anchor, target, 1) + var/list/taken = list() + for(var/_ in 1 to 2) + place(/obj/effect/decal/mission_scene/debris/rubble, near, taken) + +// ========================================================================= +// THE PIECES +// ========================================================================= + +/// Set dressing. An effect, so it gives nothing when attacked, wrenched or blown up. +/obj/effect/decal/mission_scene + name = "wreckage" + desc = "Torn plating and burnt wiring." + icon = 'icons/mob/silicon/robots.dmi' + icon_state = "gib1" + plane = GAME_PLANE + layer = LOW_OBJ_LAYER + /// Icon states picked from at random, if any + var/list/random_states + /// Largest random pixel nudge either way + var/jitter = 6 + +/obj/effect/decal/mission_scene/Initialize(mapload) + . = ..() + if(length(random_states)) + icon_state = pick(random_states) + if(jitter) + pixel_x = base_pixel_x + rand(-jitter, jitter) + pixel_y = base_pixel_y + rand(-jitter, jitter) + +/// Scraps of hull and wiring +/obj/effect/decal/mission_scene/debris + random_states = list("gib1", "gib2", "gib3", "gib4", "gib5", "gib6") + +/obj/effect/decal/mission_scene/debris/glass + name = "broken glass" + desc = "Glittering shards from a shattered panel." + icon = 'icons/obj/debris.dmi' + icon_state = "tiny" + random_states = list("tiny", "small") + +/obj/effect/decal/mission_scene/debris/glass/Initialize(mapload) + . = ..() + setDir(pick(GLOB.cardinals)) + +/obj/effect/decal/mission_scene/debris/rubble + name = "rubble" + desc = "Loose chunks thrown clear by the impact." + icon = 'icons/obj/debris.dmi' + icon_state = "rubble" + random_states = null + +/// The burn at the center. Wide and soft; the core darkens the middle off-center. +/obj/effect/decal/mission_scene/scorch + name = "scorched ground" + desc = "Blackened and still warm." + icon = 'icons/effects/light_overlays/light_96.dmi' + icon_state = "light" + color = "#1e1610" + alpha = 170 + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + blocks_emissive = EMISSIVE_BLOCK_NONE + jitter = 4 + SET_BASE_PIXEL(-32, -32) + /// Horizontal and vertical stretch of the sprite + var/spread_x = 0.95 + var/spread_y = 0.8 + +/obj/effect/decal/mission_scene/scorch/Initialize(mapload) + . = ..() + var/matrix/shape = matrix() + shape.Scale(spread_x, spread_y) + shape.Turn(rand(0, 179)) + transform = shape + +/obj/effect/decal/mission_scene/scorch/core + icon = 'icons/effects/light_overlays/light_64.dmi' + color = "#120c08" + alpha = 150 + jitter = 6 + SET_BASE_PIXEL(-16, -16) + spread_x = 0.6 + spread_y = 0.7 + +/// A light scuff where something heavy hit the deck +/obj/effect/decal/mission_scene/scorch/scuff + name = "scuff marks" + color = "#2a2420" + alpha = 110 + jitter = 0 + spread_x = 0.6 + spread_y = 0.6 + +/obj/effect/decal/mission_scene/scorch/pylon + alpha = 160 + jitter = 0 + spread_x = 0.7 + spread_y = 0.7 + +/// The furrow a crash ploughs: a long soft smudge pointed away from the anchor +/obj/effect/decal/mission_scene/skid + name = "furrow" + desc = "A long gouge in the ground." + icon = 'icons/effects/light_overlays/light_96.dmi' + icon_state = "light" + color = "#1a130d" + alpha = 150 + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + blocks_emissive = EMISSIVE_BLOCK_NONE + jitter = 0 + SET_BASE_PIXEL(-32, -32) + +/// Lays the furrow out from the anchor toward direction +/obj/effect/decal/mission_scene/skid/proc/point(direction) + var/matrix/shape = matrix() + shape.Scale(ISDIAGONALDIR(direction) ? 1.2 : 0.9, 0.38) + shape.Turn(dir2angle(direction) - 90) + transform = shape + var/dx = (direction & EAST) ? 1 : ((direction & WEST) ? -1 : 0) + var/dy = (direction & NORTH) ? 1 : ((direction & SOUTH) ? -1 : 0) + pixel_x = base_pixel_x + dx * 44 + pixel_y = base_pixel_y + dy * 44 + +/// The biggest piece of what came down, smoking until it burns out +/obj/effect/decal/mission_scene/wreck + name = "smoking wreckage" + desc = "A buckled hull section, still smoking." + icon = 'icons/obj/fluff/general.dmi' + icon_state = "broken_canister" + color = "#8a8078" + layer = BELOW_OBJ_LAYER + jitter = 4 + light_range = 2 + light_power = 0.8 + light_color = LIGHT_COLOR_FIRE + /// Whether this piece puts up the smoke column + var/smokes = TRUE + +/obj/effect/decal/mission_scene/wreck/Initialize(mapload) + . = ..() + if(smokes) + particles = new /particles/smoke/mission_plume + addtimer(CALLBACK(src, PROC_REF(burn_out)), MISSION_SCENE_BURN_TIME, TIMER_DELETE_ME) + +/obj/effect/decal/mission_scene/wreck/Destroy(force) + QDEL_NULL(particles) + return ..() + +/obj/effect/decal/mission_scene/wreck/proc/burn_out() + QDEL_NULL(particles) + set_light(0) + name = "wreckage" + desc = "A buckled hull section, scorched black." + +/obj/effect/decal/mission_scene/wreck/dish + name = "bent antenna dish" + desc = "Snapped off its mount on the way down." + icon_state = "sat_dish" + color = "#a09890" + light_range = 0 + smokes = FALSE + +/obj/effect/decal/mission_scene/wreck/dish/Initialize(mapload) + . = ..() + setDir(pick(GLOB.cardinals)) + transform = transform.Turn(pick(-70, 70, 110, -110)) + +/// The crate the cargo came in +/obj/effect/decal/mission_scene/crate + name = "smashed crate" + desc = "Split open and empty." + icon = 'icons/obj/storage/crates.dmi' + icon_state = "crateopen" + color = "#8a8078" + layer = BELOW_OBJ_LAYER + jitter = 3 + +/obj/effect/decal/mission_scene/crate/Initialize(mapload) + . = ..() + transform = transform.Turn(rand(-20, 20)) + +/// Somebody lit it to mark the spot +/obj/effect/decal/mission_scene/flare + name = "flare" + desc = "A road flare, burning low." + icon = 'icons/obj/lighting.dmi' + icon_state = "flare-on" + light_range = 3 + light_power = 1 + light_color = LIGHT_COLOR_FLARE + +/obj/effect/decal/mission_scene/flare/Initialize(mapload) + . = ..() + transform = transform.Turn(rand(0, 359)) + addtimer(CALLBACK(src, PROC_REF(burn_out)), MISSION_SCENE_BURN_TIME, TIMER_DELETE_ME) + +/obj/effect/decal/mission_scene/flare/proc/burn_out() + set_light(0) + icon_state = "flare-empty" + name = "spent flare" + desc = "Burnt down to the stub." + +/// A tall, slow column a crew can spot from across the site. Client-side only. +/particles/smoke/mission_plume + width = 192 + height = 320 + count = 150 + spawning = 2 + lifespan = 4 SECONDS + fade = 2 SECONDS + position = generator(GEN_SPHERE, 0, 4, NORMAL_RAND) + scale = list(1.5, 1.5) + grow = list(0.05, 0.05) + gravity = list(0.08, 0.95) + +#undef MISSION_SCENE_BURN_TIME diff --git a/voidcrew/modules/missions/spawn_cover.dm b/voidcrew/modules/missions/spawn_cover.dm new file mode 100644 index 00000000000..f1b2ccde122 --- /dev/null +++ b/voidcrew/modules/missions/spawn_cover.dm @@ -0,0 +1,103 @@ +/** + * # Mission Spawn Cover + * + * Field objectives are placed on whatever turf the site sampler rolls, and the + * site can already be loaded with a crew standing in it: accept a contract while + * landed and the survivor, the bounty, the anomaly or the crash site would wink + * into existence in front of them. The pickers here sample the same candidates, + * skip any turf a living player could see right now, and when nowhere is hidden + * fall back to the candidate farthest from everyone watching. + * + * "Could see" is the watcher's screen rectangle (their own view size, so a scope + * or zoom counts), widened by a few tiles so whatever scatters around the anchor + * (guards, dressing, the rest of a pylon set) doesn't land on the edge of the + * screen, then a straight line of sight that walls and other opaque turfs block. + * Darkness is ignored: a dark tile in view still counts as seen. + */ + +/// Tiles past the edge of a watcher's screen still treated as in view +#define MISSION_SPAWN_COVER_MARGIN 3 +/// Random samples a hidden pick takes from a candidate list before falling back +#define MISSION_SPAWN_COVER_TRIES 40 + +/** + * Everyone who could be looking at one z-level, taken once per pick so each + * candidate check is a few subtractions and, only for turfs on somebody's screen, + * one line-of-sight walk. + */ +/datum/spawn_cover + /// One entry per watcher: list(eye turf, half view width, half view height), margin included + var/list/watchers = list() + /// The seen candidate farthest from its nearest watcher, for when nothing is hidden + var/turf/fallback + /// Chebyshev distance from the fallback to its nearest watcher + var/fallback_distance = -1 + +/datum/spawn_cover/New(z) + . = ..() + if(!z) + return + for(var/mob/living/player as anything in GLOB.alive_player_list) + var/client/viewer = player.client + if(!viewer) + continue + // The client's eye, not the body: an AI looks through its camera eye + var/turf/eye_turf = get_turf(viewer.eye || player) + if(!eye_turf || eye_turf.z != z) + continue + var/list/view_size = getviewsize(viewer.view) + add_watcher(eye_turf, CEILING((view_size[1] - 1) / 2, 1), CEILING((view_size[2] - 1) / 2, 1)) + +/// Registers one pair of eyes. Half sizes are the screen's, before the margin. +/datum/spawn_cover/proc/add_watcher(turf/eye_turf, half_width, half_height) + watchers += list(list(eye_turf, half_width + MISSION_SPAWN_COVER_MARGIN, half_height + MISSION_SPAWN_COVER_MARGIN)) + +/datum/spawn_cover/proc/has_watchers() + return length(watchers) > 0 + +/** + * TRUE when no watcher could see the candidate. A seen candidate is remembered + * as the fallback if it sits farther from its nearest watcher than the last one. + */ +/datum/spawn_cover/proc/is_hidden(turf/candidate) + if(!length(watchers)) + return TRUE + var/nearest = INFINITY + var/seen = FALSE + for(var/list/watcher as anything in watchers) + var/turf/eye_turf = watcher[1] + var/dx = abs(candidate.x - eye_turf.x) + var/dy = abs(candidate.y - eye_turf.y) + nearest = min(nearest, max(dx, dy)) + if(seen || dx > watcher[2] || dy > watcher[3]) + continue + if(inLineOfSight(eye_turf.x, eye_turf.y, candidate.x, candidate.y, candidate.z)) + seen = TRUE + if(!seen) + return TRUE + if(nearest > fallback_distance) + fallback = candidate + fallback_distance = nearest + return FALSE + +/** + * A random turf from candidates that nobody can see, or the sampled candidate + * farthest from any watcher. Candidates must already be valid spawn turfs on one + * z-level; this only decides between them. With nobody on the level it is a + * plain pick(). + */ +/proc/pick_unwatched_turf(list/candidates, tries = MISSION_SPAWN_COVER_TRIES) + if(!length(candidates)) + return null + var/turf/first = candidates[1] + var/datum/spawn_cover/cover = new(first.z) + if(!cover.has_watchers()) + return pick(candidates) + for(var/_ in 1 to tries) + var/turf/candidate = pick(candidates) + if(cover.is_hidden(candidate)) + return candidate + return cover.fallback + +#undef MISSION_SPAWN_COVER_MARGIN +#undef MISSION_SPAWN_COVER_TRIES diff --git a/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm b/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm index 2a88700fb42..a6a43e9ced1 100644 --- a/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm +++ b/voidcrew/modules/overmap/code/modules/overmap/space_ruin.dm @@ -649,8 +649,12 @@ GLOBAL_LIST_EMPTY(space_ruin_signals) /** * Picks a random non-dense, non-space turf inside the loaded ruin's template footprint. * Used by recovery missions to place objectives. Returns null if the ruin isn't loaded. + * + * unwatched: mission field spawns. Prefer solid ground nothing dense is standing on, and + * ones no living player can see right now (pick_unwatched_turf()), so a crew already + * aboard doesn't watch the objective appear. */ -/obj/structure/overmap/space_ruin/proc/get_random_interior_turf() +/obj/structure/overmap/space_ruin/proc/get_random_interior_turf(unwatched = FALSE) if(!loaded || !ruin_bottom_left || !ruin_template?.width || !ruin_template?.height) return null var/turf/top_right = locate( @@ -679,6 +683,12 @@ GLOBAL_LIST_EMPTY(space_ruin_signals) candidates += interior_turf if(!length(candidates)) return null + if(unwatched) + var/list/clear = list() + for(var/turf/candidate as anything in candidates) + if(!candidate.is_blocked_turf(exclude_mobs = TRUE) && !isgroundlessturf(candidate)) + clear += candidate + return pick_unwatched_turf(length(clear) ? clear : candidates) return pick(candidates) /obj/structure/overmap/space_ruin/proc/remove_docks() diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..12a80b08170 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -108,6 +108,7 @@ #include "voidcrew_mission_console_rewards.dm" #include "voidcrew_mission_gps.dm" #include "voidcrew_mission_metrics.dm" +#include "voidcrew_mission_spawn_cover.dm" #include "voidcrew_missions.dm" #include "voidcrew_nanite_research.dm" #include "voidcrew_npc_boarding_docking.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_mission_spawn_cover.dm b/voidcrew/modules/unit_tests/voidcrew_mission_spawn_cover.dm new file mode 100644 index 00000000000..2480c897343 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_mission_spawn_cover.dm @@ -0,0 +1,52 @@ +/// Mission field spawns stay off turfs a player could be looking at, and walls count as cover +/datum/unit_test/voidcrew_mission_spawn_cover + +/datum/unit_test/voidcrew_mission_spawn_cover/Run() + var/turf/eye_turf = run_loc_floor_bottom_left + var/turf/beside = locate(eye_turf.x + 1, eye_turf.y, eye_turf.z) + var/turf/near = locate(eye_turf.x + 2, eye_turf.y, eye_turf.z) + // Well past any screen, on the same level + var/far_x = (eye_turf.x + 40 <= world.maxx) ? eye_turf.x + 40 : eye_turf.x - 40 + var/turf/far = locate(far_x, eye_turf.y, eye_turf.z) + TEST_ASSERT_NOTNULL(near, "The test room must be at least three tiles wide.") + TEST_ASSERT_NOTNULL(far, "The test level must be wider than one screen.") + + // No z: nobody is registered as watching + var/datum/spawn_cover/cover = new + TEST_ASSERT(cover.is_hidden(near), "With nobody watching, every turf is hidden.") + + cover.add_watcher(eye_turf, 9, 7) + TEST_ASSERT(!cover.is_hidden(near), "A turf two tiles from a watcher, in the open, is in view.") + TEST_ASSERT(!cover.is_hidden(beside), "The turf next to a watcher is in view.") + TEST_ASSERT_EQUAL(cover.fallback, near, "The fallback is the seen turf farthest from the watcher.") + TEST_ASSERT(cover.is_hidden(far), "A turf forty tiles away is off the watcher's screen.") + + // An opaque wall between them hides what's behind it + allocate(/obj/structure/falsewall, beside) + TEST_ASSERT(IS_OPAQUE_TURF(beside), "The false wall must make its turf opaque.") + TEST_ASSERT(cover.is_hidden(near), "A turf behind a wall is hidden from the watcher.") + + // The list picker never hands back anything but one of its candidates + var/list/candidates = list(beside, near) + TEST_ASSERT(pick_unwatched_turf(candidates) in candidates, "The picker must return one of its candidates.") + TEST_ASSERT_NULL(pick_unwatched_turf(list()), "An empty candidate list has nothing to pick.") + +/// Set dressing is effects only, stays off walls, and leaves the objective's tile to the objective +/datum/unit_test/voidcrew_mission_site_dressing + +/datum/unit_test/voidcrew_mission_site_dressing/Run() + var/turf/anchor = locate(run_loc_floor_bottom_left.x + 2, run_loc_floor_bottom_left.y + 2, run_loc_floor_bottom_left.z) + var/obj/item/mission_recovery/objective = allocate(__IMPLIED_TYPE__, anchor) + for(var/scene_type in subtypesof(/datum/mission_scene)) + dress_mission_scene(scene_type, anchor, null) + var/pieces = 0 + for(var/turf/tile as anything in RANGE_TURFS(3, anchor)) + for(var/obj/item/loot in tile) + TEST_ASSERT_EQUAL(loot, objective, "Set dressing must not leave items lying around ([loot.type]).") + for(var/obj/effect/decal/mission_scene/piece in tile) + pieces++ + TEST_ASSERT(!isclosedturf(tile), "[piece.type] was placed on a wall.") + if(tile == anchor) + TEST_ASSERT(istype(piece, /obj/effect/decal/mission_scene/scorch) || istype(piece, /obj/effect/decal/mission_scene/skid), "Only ground marks may share the objective's tile, not [piece.type].") + TEST_ASSERT(pieces > 0, "The scenes must lay something down.") + TEST_ASSERT_EQUAL(objective.loc, anchor, "Dressing must not move the objective.") From 3e80a18525b58e572e8d5e379a534d1b21ed3cc1 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:03:13 -0800 Subject: [PATCH 024/472] Allow out-of-body decay in the head burst brain test --- voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm index 23283153b45..b7b9e32f2e2 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm @@ -37,7 +37,7 @@ TEST_ASSERT(fling_distance >= 1 && fling_distance <= 3, "The brain landed [fling_distance] tiles away, outside one to three tiles") TEST_ASSERT_EQUAL(brain.x, origin.x, "The brain left the line of the blow") TEST_ASSERT(brain.damage < brain.maxHealth, "The burst killed the brain outright, so the victim could not be revived") - TEST_ASSERT_EQUAL(brain.damage, 0, "The burst damaged a brain it had no reason to touch") + TEST_ASSERT(brain.damage < 1, "The burst damaged a brain it had no reason to touch (got [brain.damage]; only out-of-body decay is expected)") TEST_ASSERT(!(brain.organ_flags & ORGAN_FAILING), "The burst left the brain failing") TEST_ASSERT_EQUAL(brain.brainmob?.mind, victim_mind, "The mind did not stay with the thrown brain") if(eyes) From cd71cb20b282c681b5e144aca2ed0fcb8a42adc4 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:06:18 -0800 Subject: [PATCH 025/472] Keep wave arrivals off hazards, resend the hunt lure after a rewind, update the missions wiki --- voidcrew/modules/loot/zone_mobs.dm | 3 +++ voidcrew/modules/missions/missions/big_game_hunt.dm | 10 ++++++++++ wiki/content/missions.md | 8 ++++---- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/voidcrew/modules/loot/zone_mobs.dm b/voidcrew/modules/loot/zone_mobs.dm index fa12198c220..c0bc85595c1 100644 --- a/voidcrew/modules/loot/zone_mobs.dm +++ b/voidcrew/modules/loot/zone_mobs.dm @@ -134,5 +134,8 @@ // A wave never lands a pod or digs up under someone's feet if(tile.is_blocked_turf(exclude_mobs = !wave)) continue + // Nor drops one into lava, water or a pit + if(wave && (islava(tile) || ischasm(tile) || istype(tile, /turf/open/water))) + continue result += tile return result diff --git a/voidcrew/modules/missions/missions/big_game_hunt.dm b/voidcrew/modules/missions/missions/big_game_hunt.dm index 4e0682cc6dd..511c4d39225 100644 --- a/voidcrew/modules/missions/missions/big_game_hunt.dm +++ b/voidcrew/modules/missions/missions/big_game_hunt.dm @@ -239,12 +239,21 @@ var/obj/item/hunting_lure/kit /// The kill step this lure feeds its stake position to var/datum/mission_objective/field/kill_named/big_game/hunt_objective + /// The kit went into the ground; a rewind (the site unloaded) needs a fresh one + var/kit_spent = FALSE /datum/mission_objective/set_lure/Destroy() kit = null // the item survives; it self-reports expiry on use hunt_objective = null return ..() +/// Back on this step after the site unloaded with the beast in it: send a new lure +/datum/mission_objective/set_lure/activate() + . = ..() + if(kit_spent && QDELETED(kit)) + kit_spent = FALSE + dispense_kit() + /// Called by the mission at start: hand the lure over at the ship's pad /datum/mission_objective/set_lure/proc/dispense_kit() var/obj/structure/overmap/ship/ship = get_servant() @@ -274,6 +283,7 @@ // answers takes over the slot once it has been called in mission.forget_quest_atom(kit) QDEL_NULL(kit) + kit_spent = TRUE notify_crew("Lure staked.", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') complete() diff --git a/wiki/content/missions.md b/wiki/content/missions.md index 5265ce0d1dd..65983aa88cd 100644 --- a/wiki/content/missions.md +++ b/wiki/content/missions.md @@ -13,7 +13,7 @@ The mission board links to a nearby mission pad. Open **Available** to browse wo The console posts a set of **five offers every 20 minutes**, including a green-zone exploration job when one is available. Up to three slots are reserved for targeted work in the highest zone your crew's [reputation](zone-reputation.md) allows. Taking or losing a job leaves a vacancy until the next set arrives. **Refresh** becomes available when the next set is due; it does not give an early replacement. Reopening or rebuilding the console keeps the ship's offers and schedule. -For a starting job, look for an **Exploration Contract** in green space. Fly to its marked coordinates, then collect the listed fee at the board. +For a starting job, look for an **Exploration Contract** in green space. Fly to its marked coordinates and hold there while the sensors sweep the sector, then collect the listed fee at the board. Each offer shows its task, pay, and allowed time. Accepting starts its deadline and adds a helm waypoint when there is a destination. Some jobs also leave equipment on the mission pad: collect it before leaving. Tap a handheld GPS on the board to copy active mission beacons, or use **Link Mission Beacons** with a fitted MOD GPS. @@ -86,13 +86,13 @@ These prizes are separate from the offer's ordinary credits, vouchers and goods. | Contract | What it asks | |---|---| | Delivery Contract | Bring the listed goods to the pad: spice pods, sinew, goliath hides, glacial cores, raw telecrystals, a sea crystal, or Syndicate documents. Stored goods qualify. | -| Exploration Contract | Fly the ship to marked coordinates, then collect the fee. | +| Exploration Contract | Fly the ship to marked coordinates, hold position for the sensor sweep, then collect the fee. The sweep sometimes turns up a nearby derelict. | | Survey Contract | Scan the requested celestial objects with the orbital survey console. | | Telemetry Contract | Gather the requested survey scans and return for credits and research notes. | | Recovery Contract | Retrieve a named object from inside a space ruin. | | Kill Contract | Hunt a named pirate captain in a ruin and bring back their identification tag. | | Survey Contract (pylons) | Calibrate the ruin's survey pylons, survive their defenders, and bring back the printed core. | -| Extraction Contract | Unbolt cargo inside a ruin, deal with the defenders, and bring it home. | +| Extraction Contract | Unbolt cargo inside a ruin with a wrench, deal with the defenders, and bring it home. | | Rescue Contract | Escort a survivor out of a wreck and bring them back alive. | | Suppression Sweep | Confirm the requested pirate kills in the required area, then collect at the board. | | Gas Harvest Contract | Deliver a tank containing the requested gas and amount. Gas already aboard qualifies. See [Fuel & Gas](gas-economy.md). | @@ -100,7 +100,7 @@ These prizes are separate from the offer's ordinary credits, vouchers and goods. | Big Game Hunt | Deploy the supplied lure, kill the named beast, and return its trophy. | | Live Capture | Find the marked specimen, weaken it without killing it, and deliver it alive in the supplied capture crate. | | Poacher Bust | Clear a poacher camp and recover its evidence cache. | -| Prospect Stake | Deploy a claim beacon aboard your ship at the marked coordinates, hold station through its defense, and deliver the deed. | +| Prospect Stake | Land on the marked planet, deploy the claim beacon on open ground, stay with it through its defense, and deliver the deed. | | Containment Contract | Find and neutralize the named anomaly, then deliver its core. | | Core Sample Contract | Complete a planet's sampling work and return the sample. | From cd6fbeedb64fb82a78346d3ab44669bc8f332003 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:08:12 -0800 Subject: [PATCH 026/472] Rescue survivors hide, call for help and leave in a transporter beam The rescue survivor is now an ambient NPC with its own small brain. It hides somewhere sheltered in the wreck (a locker, a corner or an alcove away from anything hostile), keeps its head down, radios the contract crew when they are close, calls out and waves when it sees someone, runs from hostiles that come close and warns people of ones further off. Once someone leads it, it stays at their side, ducks and yelps at danger, climbs out of anything it is shut in and waits where it is if its rescuer goes. It never fights. When the contract lets go of a survivor (delivered, timed out, abandoned or re-targeted) it is taken up in the transporter's column of light instead of blinking out, or simply removed if nobody is near enough to see. --- tgstation.dme | 1 + voidcrew/modules/missions/missions/rescue.dm | 87 +- .../missions/missions/rescue_survivor.dm | 1087 +++++++++++++++++ .../modules/missions/objectives/site_ops.dm | 25 +- .../modules/unit_tests/voidcrew_missions.dm | 24 +- 5 files changed, 1136 insertions(+), 88 deletions(-) create mode 100644 voidcrew/modules/missions/missions/rescue_survivor.dm diff --git a/tgstation.dme b/tgstation.dme index a06d0df2577..f7ec524b8a7 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7304,6 +7304,7 @@ #include "voidcrew\modules\missions\missions\recovery.dm" #include "voidcrew\modules\missions\missions\recovery_variants.dm" #include "voidcrew\modules\missions\missions\rescue.dm" +#include "voidcrew\modules\missions\missions\rescue_survivor.dm" #include "voidcrew\modules\missions\missions\research.dm" #include "voidcrew\modules\missions\missions\suppression.dm" #include "voidcrew\modules\missions\missions\survey.dm" diff --git a/voidcrew/modules/missions/missions/rescue.dm b/voidcrew/modules/missions/missions/rescue.dm index 0a3e7b6527a..fbae8504522 100644 --- a/voidcrew/modules/missions/missions/rescue.dm +++ b/voidcrew/modules/missions/missions/rescue.dm @@ -3,11 +3,11 @@ * * "A survivor's beacon is still pinging inside that wreck. Bring them home alive." * - * Recovery variant where the objective breathes: a survivor NPC spawns in - * the target ruin, follows whoever asks (or rides a fireman carry), and the - * mission turns in while they're alive beside the ship's mission pad. - * Survivor death burns a retarget ("another beacon"). The non-gunner's - * recovery mission. + * Recovery variant where the objective breathes: a survivor NPC hides in the + * target ruin, follows whoever asks, and the mission turns in while they're + * alive beside the ship's mission pad. Survivor death burns a retarget + * ("another beacon"). The non-gunner's recovery mission. The survivor + * themselves are in rescue_survivor.dm. */ /datum/mission/rescue name = "Rescue Contract" @@ -50,80 +50,3 @@ /datum/mission/rescue/waypoint_label() return "Rescue: [objective_name]" - -/** - * # Mission Survivor - * - * The living objective: friendly, fragile, and clingy on request. An empty - * hand toggles follow; they never fight back. - * - * They are immune to the environment on purpose. The contract puts them inside - * wrecks, and a wreck is airless and near absolute zero: on tg's basic-mob - * defaults this mob takes 1 brute (no air to breathe) plus 2 burn (body temp - * under 250K, which it reaches in a single tick at -30K/s) every two-second - * Life tick from the moment it spawns. That is 60 HP of survivor in forty - * seconds, and the survivor spawns when the crew's ship docks - so they were - * dead before anyone could walk across the ruin to them, every single time, on - * every airless site. Their suit is what they have been surviving in; only - * violence gets to kill them. - */ -/mob/living/basic/mission_survivor - name = "survivor" - desc = "Somebody who has had a very long week and would love to see a ship interior again. Their emergency softsuit is patched in three places and still holding." - icon = 'icons/mob/simple/simple_human.dmi' - maxHealth = 60 - health = 60 - melee_damage_lower = 0 - melee_damage_upper = 0 - combat_mode = FALSE - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - sentience_type = SENTIENCE_HUMANOID - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - speak_emote = list("croaks") - - /// Who the survivor is currently following, if anyone - var/datum/weakref/following_ref - -/mob/living/basic/mission_survivor/Initialize(mapload) - . = ..() - var/static/list/survivor_names = list("Adler", "Bex", "Cato", "Dova", "Emin", "Farrow", "Ines", "Jun", "Kest", "Loami") - name = "survivor ([pick(survivor_names)])" - real_name = name - apply_dynamic_human_appearance(src, outfit_path = /datum/outfit/job/assistant) - -/mob/living/basic/mission_survivor/examine(mob/user) - . = ..() - var/mob/following = following_ref?.resolve() - if(following) - . += span_notice("They're sticking close to [following].") - else - . += span_notice("They look lost.") - -/mob/living/basic/mission_survivor/attack_hand(mob/living/user, list/modifiers) - . = ..() - if(. || user.combat_mode) - return - var/mob/following = following_ref?.resolve() - if(following == user) - stop_following() - balloon_alert(user, "waiting here") - say("I'll... wait right here.") - else - start_following(user) - balloon_alert(user, "following you") - say("Right behind you. Please don't be wrong about the way out.") - return TRUE - -/mob/living/basic/mission_survivor/proc/start_following(mob/living/leader) - following_ref = WEAKREF(leader) - GLOB.move_manager.move_to(src, leader, 1, 4) - -/mob/living/basic/mission_survivor/proc/stop_following() - following_ref = null - GLOB.move_manager.stop_looping(src) - -/mob/living/basic/mission_survivor/death(gibbed) - stop_following() - return ..() diff --git a/voidcrew/modules/missions/missions/rescue_survivor.dm b/voidcrew/modules/missions/missions/rescue_survivor.dm new file mode 100644 index 00000000000..848dd4e9ba2 --- /dev/null +++ b/voidcrew/modules/missions/missions/rescue_survivor.dm @@ -0,0 +1,1087 @@ +/** + * # Rescue survivor + * + * The person a rescue contract sends a crew to fetch. They are an ambient NPC (the world population + * core in voidcrew/modules/ambient_npcs/), so they get its looks, its walking, its activities and + * its dialogue cooldowns, and they behave like a frightened person: + * + * - placed somewhere sheltered in the wreck (a locker, a corner, an alcove), out of sight of + * whatever is loose in there (mission_survivor_hiding_place()); + * - hiding: crouched and quiet, fidgeting now and then; inside a locker, silent; + * - before anyone has found them, their beacon crackles a distress call to the contract crew's + * radios when they are a few screens away; + * - when they see a person, they call out and wave (or bang on the locker), and come over if + * nothing is about; + * - a hostile close by, or one going for them: they run, away from it, and hide again where they + * stop; one further off: they keep still, and if there are people about they shout a warning; + * - an empty hand makes them follow (or wait); following, they stay at their rescuer's side, + * duck and yelp at danger instead of running, climb out of anything they are shut in, and wait + * where they are if their rescuer goes; + * - they never fight; + * - removed by the contract (delivered, timed out, abandoned, re-targeted), they are taken up from + * orbit in the transporter's column of light (beam_out()), or deleted at once if nobody sees. + * + * Immune to air, cold and heat on purpose (see the old note in the rescue contract's history): + * the contract puts them inside airless wrecks, and only violence should kill them. + */ + +/// How far they see people and threats +#define SURVIVOR_SIGHT 7 +/// How often they look around +#define SURVIVOR_LOOK_INTERVAL (1 SECONDS) +/// A threat this close, or one going for them, sends them running (until they have a rescuer) +#define SURVIVOR_FLEE_DISTANCE 4 +/// How far they run at most, per run +#define SURVIVOR_FLEE_RANGE 6 +/// How long one flight lasts before they settle wherever they got to +#define SURVIVOR_FLEE_TIME (12 SECONDS) +/// A threat that keeps coming can send them running this many more times in one flight +#define SURVIVOR_FLEE_RUNS 3 +/// The contract crew hear the distress call within this many tiles +#define SURVIVOR_DISTRESS_RANGE 30 +/// Between two distress calls +#define SURVIVOR_DISTRESS_GAP_LOW (50 SECONDS) +#define SURVIVOR_DISTRESS_GAP_HIGH (90 SECONDS) +/// Somebody they spotted this close (or nearer) gets walked up to, if nothing else is about +#define SURVIVOR_APPROACH_RANGE 6 +/// Somebody this close counts as right there (they stand up, they plead) +#define SURVIVOR_NEAR_RANGE 3 +/// Inside a locker they hear people this close +#define SURVIVOR_HEARING_RANGE 3 +/// Following: a leader further than this, or gone, or dead, is out of reach +#define SURVIVOR_FOLLOW_RANGE 14 +/// Following: a leader out of reach this long, and they stop and wait where they are +#define SURVIVOR_LOST_TIME (45 SECONDS) +/// Following outranks every reaction: nothing makes them run off from their rescuer +#define SURVIVOR_FOLLOW_PRIORITY 60 +/// Running outranks calling out +#define SURVIVOR_FLEE_PRIORITY 55 +/// Following: how long they stay down when they duck, and how soon they may duck again +#define SURVIVOR_DUCK_TIME (3 SECONDS) +#define SURVIVOR_DUCK_COOLDOWN (8 SECONDS) +/// Hiding place search: how far from the site's spawn point it looks +#define SURVIVOR_SHELTER_RADIUS 20 +/// Hiding place search: nothing hostile within this many tiles of it +#define SURVIVOR_SHELTER_THREAT_GAP 6 +/// Hiding place search: blocked neighbours (of 8) a floor tile needs to count as a corner or alcove +#define SURVIVOR_SHELTER_MIN_COVER 5 +/// The beam: the column comes down, they come apart in it, and they are gone this long after it started +#define SURVIVOR_BEAM_TIME (3 SECONDS) +/// The beam: how long the column takes to come down before they start to come apart +#define SURVIVOR_BEAM_DROP (0.4 SECONDS) +/// The beam is only drawn for someone with a client within this many tiles; otherwise they are deleted at once +#define SURVIVOR_BEAM_WATCH_RANGE 10 +/// Trait source for what the survivor puts on themselves +#define SURVIVOR_TRAIT "mission_survivor" + +// ========================================================================= +// THE SURVIVOR +// ========================================================================= + +/mob/living/basic/ambient_npc/mission_survivor + name = "survivor" + desc = "Somebody who has had a very long week and would love to see a ship interior again. Their emergency suit is patched in three places and still holding." + maxHealth = 60 + health = 60 + // Quick on their feet: they keep up with whoever is getting them out + speed = 1 + // Carried out like anyone else: pulled, bagged, boxed or beamed (the ambient core forbids all of that) + move_resist = MOVE_RESIST_DEFAULT + death_cash_low = 0 + death_cash_high = 0 + speak_emote = list("says") + speech_pace = 1.5 + outfit_choices = list( + /datum/outfit/mission_survivor, + /datum/outfit/mission_survivor/bareheaded, + ) + routine = list(/datum/ambient_activity/survivor_hide = 1) + ai_controller = /datum/ai_controller/basic_controller/ambient_npc/mission_survivor + /// The contract they belong to (a /datum/mission), for the distress call and to know its crew + var/datum/weakref/contract_ref + /// Who they were before this ("salvage tech"), from the contract + var/story + /// Where they keep their head down when they are not doing anything else + var/turf/hide_spot + /// The locker they were put in to hide, while they are still in it + var/datum/weakref/hid_in_ref + /// Someone has made them follow at least once + var/rescued = FALSE + /// They have seen a person: no more distress calls + var/contacted = FALSE + /// They said something on first stepping aboard a ship + var/said_aboard = FALSE + /// Being taken up in the beam: nothing else happens to them + var/beaming_out = FALSE + /// REFs of the people they already called out to + var/list/greeted + /// world.time they next look around, call for help on the radio, or fidget + var/next_look = 0 + var/next_distress = 0 + var/next_fidget = 0 + /// world.time they last saw a threat + var/last_threat_at = 0 + +/mob/living/basic/ambient_npc/mission_survivor/Initialize(mapload) + . = ..() + // The ambient core makes its people fixtures of their place. A survivor has no place: let them + // be put in a locker or a body bag, drag-dropped and teleported, the way the contract always + // allowed. They still refuse polymorph and type changes, which would only lose the contract. + REMOVE_TRAIT(src, TRAIT_NO_CONTAINMENT, AMBIENT_NPC_TRAIT) + UnregisterSignal(src, list(COMSIG_MOUSEDROP_ONTO, COMSIG_MOVABLE_TELEPORTING)) + hide_spot = get_turf(src) + next_fidget = world.time + rand(20 SECONDS, 40 SECONDS) + +/mob/living/basic/ambient_npc/mission_survivor/Destroy() + contract_ref = null + hide_spot = null + hid_in_ref = null + greeted = null + return ..() + +/// Ties them to `contract`: its crew hear their distress call, and they take its story +/mob/living/basic/ambient_npc/mission_survivor/proc/join_contract(datum/mission/contract) + if(!contract) + return + contract_ref = WEAKREF(contract) + story = contract.objective_name + if(story) + var/article = (LOWER_TEXT(copytext(story, 1, 2)) in list("a", "e", "i", "o", "u")) ? "An" : "A" + desc = "[article] [story] who has had a very long week. [p_Their()] emergency suit is patched in three places and still holding." + +/** + * Puts them in their hiding place: into `locker` (shut) if there is one, or down in a crouch where + * they stand. Where they stand is where they come back to. + */ +/mob/living/basic/ambient_npc/mission_survivor/proc/take_shelter(obj/structure/closet/locker) + hide_spot = get_turf(src) + if(QDELETED(locker)) + crouch() + return + if(locker.opened) + forceMove(locker.loc) + locker.close() + else + forceMove(locker) + if(loc == locker) + hid_in_ref = WEAKREF(locker) + +/mob/living/basic/ambient_npc/mission_survivor/examine(mob/user) + . = ..() + if(stat != CONSCIOUS) + return + var/datum/ambient_activity/survivor_follow/follow = activity + var/mob/leader = istype(follow) ? follow.anchor() : null + if(leader) + . += span_notice("[p_Theyre()] sticking close to [leader].") + else if(crouching) + . += span_notice("[p_Theyre()] shaking.") + +// ========================================================================= +// FOLLOWING +// ========================================================================= + +/mob/living/basic/ambient_npc/mission_survivor/talked_to(mob/living/user) + if(beaming_out || !talk_ready(user)) + return + if(!buckled) + face_atom(user) + var/datum/ambient_activity/survivor_follow/follow = activity + var/mob/living/leader = istype(follow) ? follow.anchor() : null + if(leader == user) + stop_following() + speak_context("wait", user, force = TRUE) + return + // Whoever came for them keeps them: a stranger can't walk off with them + if(leader && is_contract_crew(leader) && !is_contract_crew(user)) + speak_context("refuse", user, force = TRUE) + return + if(start_following(user)) + speak_context("follow", user, force = TRUE) + +/// They follow `leader` from now on. Returns the activity, or null. +/mob/living/basic/ambient_npc/mission_survivor/proc/start_following(mob/living/leader) + if(beaming_out || !isliving(leader)) + return null + rescued = TRUE + contacted = TRUE + return start_activity(new /datum/ambient_activity/survivor_follow(src, leader)) + +/// They stop following and wait where they are +/mob/living/basic/ambient_npc/mission_survivor/proc/stop_following() + hide_spot = get_turf(src) + if(is_following()) + end_activity() + +/mob/living/basic/ambient_npc/mission_survivor/proc/is_following() + return istype(activity, /datum/ambient_activity/survivor_follow) + +/// Their rescuer is out of reach: they wait where they are. Called by the follow activity as it ends. +/mob/living/basic/ambient_npc/mission_survivor/proc/lose_leader() + hide_spot = get_turf(src) + +/// Their rescuer went down in front of them +/mob/living/basic/ambient_npc/mission_survivor/proc/leader_fell(mob/living/leader) + hide_spot = get_turf(src) + if(!buckled) + face_atom(leader) + speak_context("leader_down", leader, force = TRUE) + +/// Whether `person` is crew of the ship that took their contract +/mob/living/basic/ambient_npc/mission_survivor/proc/is_contract_crew(mob/living/person) + var/datum/mission/contract = contract_ref?.resolve() + if(!contract?.servant || !isliving(person)) + return FALSE + return ambient_crew_ship(person) == contract.servant + +/// Shut in something while following: they push it open, or bang on it if it won't +/mob/living/basic/ambient_npc/mission_survivor/proc/climb_out() + var/obj/structure/closet/container = loc + if(!istype(container) || !reaction_ready("climb_out", 3 SECONDS)) + return + if(container.open()) + return + if(reaction_ready("trapped", 12 SECONDS)) + bang_on(container) + speak_context("trapped", null, force = TRUE) + +/// Bangs on the inside of `container` +/mob/living/basic/ambient_npc/mission_survivor/proc/bang_on(obj/structure/closet/container) + if(QDELETED(container)) + return + container.Shake(2, 1, 0.5 SECONDS) + playsound(container, 'sound/effects/bang.ogg', 30, TRUE, -2) + +/// The first time they step aboard a crew's ship +/mob/living/basic/ambient_npc/mission_survivor/proc/check_aboard() + if(said_aboard || !istype(get_area(src), /area/shuttle/voidcrew)) + return + said_aboard = TRUE + speak_context("aboard", null, force = TRUE) + +// ========================================================================= +// LOOKING AROUND +// ========================================================================= + +/** + * About once a second while their AI is on: who and what can they see, and what do they do about + * it. Starts reactions (calling out, running) as activities; never sleeps. + */ +/mob/living/basic/ambient_npc/mission_survivor/proc/look_around() + if(beaming_out || !can_act() || world.time < next_look) + return + next_look = world.time + SURVIVOR_LOOK_INTERVAL + var/turf/here = get_turf(src) + if(!here) + return + var/obj/structure/closet/container = istype(loc, /obj/structure/closet) ? loc : null + // Out of the locker they hid in: whoever let them out gets thanked + if(hid_in_ref && !container) + hid_in_ref = null + var/mob/living/opener = nearest_person(here, 2) + if(opener) + mark_greeted(opener) + contacted = TRUE + face_atom(opener) + speak_context("freed", opener, force = TRUE) + var/mob/living/threat = container ? null : nearest_threat(here) + if(threat) + last_threat_at = world.time + if(is_following()) + if(threat && reaction_ready("duck", SURVIVOR_DUCK_COOLDOWN)) + duck(threat) + return + if(threat) + react_threat(threat, here) + return + var/mob/living/person = new_person_in_sight(here, container) + if(person) + contacted = TRUE + start_activity(new /datum/ambient_activity/survivor_call(src, person)) + return + if(!contacted) + distress_call(here) + +/** + * A threat in sight, while nobody is leading them out. Close, or after them: they run. Further off: + * they keep still, and warn anyone there. + */ +/mob/living/basic/ambient_npc/mission_survivor/proc/react_threat(mob/living/threat, turf/here) + var/after_us = threat.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] == src + if(get_dist(here, threat) <= SURVIVOR_FLEE_DISTANCE || after_us) + if(!istype(activity, /datum/ambient_activity/survivor_flee)) + start_activity(new /datum/ambient_activity/survivor_flee(src, threat)) + return + if(istype(activity, /datum/ambient_activity/survivor_flee)) + return + // Walking up to someone with that about? No. + if(istype(activity, /datum/ambient_activity/survivor_call)) + end_activity() + if(isturf(loc) && !crouching) + crouch() + var/mob/living/person = nearest_person(here, SURVIVOR_SIGHT) + if(person) + contacted = TRUE + mark_greeted(person) + if(reaction_ready("warn", 15 SECONDS)) + speak_context("warn", person, force = TRUE) + return + if(!reaction_ready("cower", 30 SECONDS)) + return + if(prob(50)) + speak_context("cower", threat, force = TRUE) + else + manual_emote(pick("freezes.", "holds [p_their()] breath.", "presses [p_themselves()] against the wall.")) + +/// Following, and something is coming: they duck down and yelp, and stay with their rescuer +/mob/living/basic/ambient_npc/mission_survivor/proc/duck(mob/living/threat) + if(!isturf(loc)) + return + speak_context("duck", threat, force = TRUE) + if(crouching || buckled) + return + crouch() + addtimer(CALLBACK(src, PROC_REF(stop_ducking)), SURVIVOR_DUCK_TIME, TIMER_UNIQUE | TIMER_OVERRIDE) + +/mob/living/basic/ambient_npc/mission_survivor/proc/stop_ducking() + if(crouching && is_following()) + stand_up() + +/// Whether `thing` is something they should be afraid of: awake, nobody's player, not a person, and out to hurt someone +/mob/living/basic/ambient_npc/mission_survivor/proc/is_threat(mob/living/thing) + if(thing == src || thing.stat != CONSCIOUS || thing.client || istype(thing, /mob/living/basic/ambient_npc)) + return FALSE + if(faction_check_atom(thing)) + return FALSE + return mission_survivor_dangerous(thing) + +/// The nearest threat they can see from `here`, or null +/mob/living/basic/ambient_npc/mission_survivor/proc/nearest_threat(turf/here) + var/mob/living/nearest + var/nearest_distance = INFINITY + for(var/mob/living/thing in hearers(SURVIVOR_SIGHT, here)) + if(!is_threat(thing)) + continue + var/distance = get_dist(here, thing) + if(distance < nearest_distance) + nearest = thing + nearest_distance = distance + return nearest + +/** + * The nearest awake person (someone played) within `range` of `here` that they can see, or null. + * From inside a locker they can't see out, so it goes by ear: anyone close enough counts. + * `skip_greeted` leaves out people they already called out to. + */ +/mob/living/basic/ambient_npc/mission_survivor/proc/nearest_person(turf/here, range, by_ear = FALSE, skip_greeted = FALSE) + var/mob/living/nearest + var/nearest_distance = INFINITY + for(var/mob/living/person in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, range)) + if(person == src || person.stat != CONSCIOUS) + continue + var/turf/there = get_turf(person) + if(!there || there.z != here.z) + continue + var/distance = get_dist(here, there) + if(distance > range || distance >= nearest_distance) + continue + if(skip_greeted && LAZYFIND(greeted, REF(person))) + continue + if(!by_ear && !can_see(here, there, range)) + continue + nearest = person + nearest_distance = distance + return nearest + +/// Someone they have not called out to yet, in sight (or in earshot from a locker), or null. They count as called out to from now. +/mob/living/basic/ambient_npc/mission_survivor/proc/new_person_in_sight(turf/here, obj/structure/closet/container) + var/mob/living/person = container ? nearest_person(here, SURVIVOR_HEARING_RANGE, by_ear = TRUE, skip_greeted = TRUE) : nearest_person(here, SURVIVOR_SIGHT, skip_greeted = TRUE) + if(person) + mark_greeted(person) + return person + +/mob/living/basic/ambient_npc/mission_survivor/proc/mark_greeted(mob/living/person) + var/key = REF(person) + if(LAZYFIND(greeted, key)) + return + LAZYADD(greeted, key) + if(length(greeted) > 20) + greeted.Cut(1, 2) + +/** + * Nobody has found them yet: their beacon picks up the contract crew's ship and they talk into it. + * Heard on the radios of that crew within SURVIVOR_DISTRESS_RANGE, as a weak signal. + */ +/mob/living/basic/ambient_npc/mission_survivor/proc/distress_call(turf/here) + if(world.time < next_distress) + return + var/datum/mission/contract = contract_ref?.resolve() + var/obj/structure/overmap/ship/ship = contract?.servant + if(!ship) + return + var/list/listeners = list() + for(var/mob/living/person in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, SURVIVOR_DISTRESS_RANGE)) + if(person.stat == DEAD) + continue + var/turf/there = get_turf(person) + if(!there || there.z != here.z || get_dist(here, there) > SURVIVOR_DISTRESS_RANGE) + continue + if(ambient_crew_ship(person) != ship) + continue + listeners += person + if(!length(listeners)) + return + next_distress = world.time + rand(SURVIVOR_DISTRESS_GAP_LOW, SURVIVOR_DISTRESS_GAP_HIGH) + var/list/lines = get_lines("distress") + if(!length(lines)) + return + var/line = fill_line(pick(lines)) + for(var/mob/living/person as anything in listeners) + to_chat(person, span_radio("\[Distress\] [real_name] says, \"[line]\"")) + person.playsound_local(get_turf(person), 'sound/misc/interference.ogg', 20, FALSE) + +/** + * Their hiding activity's tick: up on their feet when someone is right there, down again when + * they are alone; a plea to whoever is close; otherwise a fidget now and then. + */ +/mob/living/basic/ambient_npc/mission_survivor/proc/hide_tick() + var/turf/here = get_turf(src) + if(!here) + return + if(!isturf(loc)) + return // shut in somewhere, keeping quiet + var/mob/living/near = nearest_person(here, SURVIVOR_NEAR_RANGE) + var/scared = world.time - last_threat_at < 5 SECONDS + if(near && !scared) + if(crouching) + stand_up() + if(!buckled) + face_atom(near) + if(reaction_ready("plead", 40 SECONDS)) + speak_context(rescued ? "wait_idle" : "plead", near, force = TRUE) + return + if(!near && !crouching && !buckled) + crouch() + if(world.time < next_fidget) + return + next_fidget = world.time + rand(40 SECONDS, 80 SECONDS) + if(prob(40)) + speak_context(rescued ? "wait_idle" : "hide", null, force = TRUE) + return + manual_emote(pick( + "hugs [p_their()] knees.", + "shivers.", + "taps at the beacon on [p_their()] wrist.", + "listens, very still.", + "rocks back and forth.", + )) + +/** + * Where to run from `threat`: a free tile within SURVIVOR_FLEE_RANGE, further from it than they are, + * on their own side of it (so the way there doesn't pass it), in sight from where they stand (so + * the way there is short), best out of its sight with a wall at their back. Null if nowhere. + */ +/mob/living/basic/ambient_npc/mission_survivor/proc/flee_tile(mob/living/threat, list/avoid) + var/turf/here = get_turf(src) + var/turf/danger = get_turf(threat) + if(!here || !danger || here.z != danger.z) + return null + var/start_gap = get_dist(here, danger) + var/turf/best + var/best_score = -INFINITY + for(var/turf/tile as anything in RANGE_TURFS(SURVIVOR_FLEE_RANGE, here)) + var/gap = get_dist(tile, danger) + if(gap < start_gap + 2 || get_dist(tile, here) >= gap) + continue + if(!standable(tile, avoid) || !can_see(here, tile, SURVIVOR_FLEE_RANGE + 1)) + continue + var/score = gap + rand(0, 3) + if(!can_see(danger, tile, SURVIVOR_SIGHT + 2)) + score += 4 + if(ambient_backed_by_wall_or_table(tile)) + score += 1 + if(score > best_score) + best = tile + best_score = score + return best + +/mob/living/basic/ambient_npc/mission_survivor/react_attacked(atom/attacker) + if(stat != CONSCIOUS || fading || beaming_out || !reaction_ready("attacked", 4 SECONDS)) + return + var/mob/living/hitter = isliving(attacker) ? attacker : null + speak_context(hitter?.client ? "hurt_by_person" : "hurt", attacker, force = TRUE) + if(is_following()) + if(hitter) + duck(hitter) + return + if(hitter) + start_activity(new /datum/ambient_activity/survivor_flee(src, hitter)) + +// ========================================================================= +// LEAVING: THE BEAM +// ========================================================================= + +/** + * Taken up from orbit: a column of light comes down on them, they lift and come apart in it, and + * they are gone. Nothing happens to them meanwhile. Somewhere nobody can see, they just go. + * `farewell` has them say goodbye first (delivered home). `force_visual` draws the beam even + * with nobody about (unit tests). + */ +/mob/living/basic/ambient_npc/mission_survivor/proc/beam_out(farewell = FALSE, force_visual = FALSE) + if(beaming_out || QDELETED(src)) + return + var/turf/spot = get_turf(src) + if(!spot || (!force_visual && !mission_survivor_watched(spot))) + beaming_out = TRUE + qdel(src) + return + var/was_awake = stat == CONSCIOUS && isturf(loc) + if(was_awake) + setDir(SOUTH) // face front, so the dissolve reads + if(farewell) + speak_context("goodbye", null, force = TRUE) + manual_emote("waves.") + else + manual_emote("looks up.") + beaming_out = TRUE + fading = TRUE + end_activity() + stand_up() + add_traits(list(TRAIT_AI_PAUSED, TRAIT_GODMODE, TRAIT_IMMOBILIZED, TRAIT_NO_FLOATING_ANIM), SURVIVOR_TRAIT) + pulledby?.stop_pulling() + stop_pulling() + buckled?.unbuckle_mob(src, force = TRUE) + set_anchored(TRUE) + move_resist = INFINITY + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + playsound(spot, 'sound/effects/magic/teleport_diss.ogg', 40, TRUE) + new /obj/effect/temp_visual/transporter_beam(spot, SURVIVOR_BEAM_TIME + 0.5 SECONDS) + addtimer(CALLBACK(src, PROC_REF(beam_dissolve)), SURVIVOR_BEAM_DROP) + addtimer(CALLBACK(src, PROC_REF(beam_gone)), SURVIVOR_BEAM_TIME) + +/// The column is down: they lift a little and come apart in it, head first +/mob/living/basic/ambient_npc/mission_survivor/proc/beam_dissolve() + if(!isturf(loc)) + return // shut in a locker: the column takes them through it + var/dissolve_time = SURVIVOR_BEAM_TIME - SURVIVOR_BEAM_DROP + new /obj/effect/abstract/particle_holder(src, /particles/transporter_motes, PARTICLE_ATTACH_MOB) + transporter_dematerialise(src, dissolve_time) + animate(src, pixel_z = pixel_z + 8, time = dissolve_time, easing = SINE_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + +/// Gone: the last flash where they stood, and the column lifts back up without them +/mob/living/basic/ambient_npc/mission_survivor/proc/beam_gone() + var/turf/spot = get_turf(src) + if(spot) + new /obj/effect/temp_visual/transporter_flash/departure(spot) + playsound(spot, 'sound/effects/magic/teleport_app.ogg', 50, TRUE) + qdel(src) + +/// Whether anyone with a client is close enough to `spot` to see a beam come down there +/proc/mission_survivor_watched(turf/spot) + for(var/mob/viewer in SSspatial_grid.orthogonal_range_search(spot, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, SURVIVOR_BEAM_WATCH_RANGE)) + if(!viewer.client) + continue + var/turf/there = get_turf(viewer) + if(there?.z == spot.z && get_dist(there, spot) <= SURVIVOR_BEAM_WATCH_RANGE) + return TRUE + return FALSE + +// ========================================================================= +// WHAT THEY SAY +// ========================================================================= + +// Their own lines only: none of the ambient core's chatter fits someone in this state +/mob/living/basic/ambient_npc/mission_survivor/get_lines(context) + var/static/list/lines = list( + // Hiding alone, under their breath + "hide" = list( + "#Come on. Somebody heard it.", + "#Stay quiet. Stay quiet.", + "#Please, please, please.", + "#Beacon's still on. It's still on.", + ), + // A threat about and nobody there + "cower" = list( + "#Don't see me. Don't see me.", + "#Go away. Please go away.", + "#Oh no. Oh no.", + ), + // A threat about, and people there + "warn" = list( + "Look out!", + "There's more of them!", + "Watch it, they're coming!", + ), + // First sight of someone + "call" = list( + "Over here!", + "Hey! Hey, over here!", + "Is someone there? Please!", + "Here! I'm over here!", + "You came. You actually came.", + ), + // First sound of someone, from a locker + "call_locker" = list( + "In here! I'm in here!", + "Is someone out there? Let me out!", + "Don't go! I'm in here!", + ), + // Let out of the locker + "freed" = list( + "Oh, thank god.", + "Thank you. Thank you.", + "I thought you were one of them.", + ), + // Someone right there, not taking them yet + "plead" = list( + "Please, get me out of here.", + "Are you taking me out? Please say yes.", + "Can we go? Please?", + ), + "flee" = list( + "No no no!", + "Get away from me!", + "Help! Help!", + "Somebody help!", + ), + "hurt" = list( + "Agh!", + "Ow! Help!", + "Please! Stop!", + ), + "hurt_by_person" = list( + "Hey! I'm on your side!", + "Ow! What was that for?", + "Watch where you're shooting!", + ), + "follow" = list( + "Right behind you.", + "Okay. Okay. Let's go.", + "Don't leave me behind.", + "Right behind you. Please don't be wrong about the way out.", + ), + "wait" = list( + "I'll... wait right here.", + "Don't be long.", + "Okay. I'll be here.", + ), + "refuse" = list( + "I'm with them.", + "No. I'm going with them.", + ), + // Following, now and then + "follow_idle" = list( + "Is your ship far?", + "I thought nobody was coming.", + "How long was I in there?", + "Thank you. Really.", + "Do you have any water?", + ), + // Following, and something is coming + "duck" = list( + "What was that?", + "Something's moving!", + "Don't let it near me!", + "Get down!", + ), + "lost" = list( + "Hello? Where'd you go?", + "Don't leave me here!", + "Hello?", + ), + "leader_down" = list( + "No. No, no, no.", + "Get up. Please, get up.", + ), + "trapped" = list( + "Let me out!", + "Hey! Open this!", + ), + "aboard" = list( + "Is this your ship? Oh, thank god.", + "A real ship. Lights and everything.", + ), + // Waiting where they were told, or near someone after being rescued + "wait_idle" = list( + "Is it much further?", + "I'll stay right here.", + "Are we leaving soon?", + ), + "goodbye" = list( + "Thank you. I mean it.", + "I owe you. All of you.", + "Thank you. Really.", + ), + // Over the radio, before anyone has found them + "distress" = list( + "Is anyone out there? Please.", + "This is {name}. I'm still here.", + "I can hear a ship. Please don't leave.", + "Hello? Anyone? Please.", + "Still here. Still here. Please.", + ), + ) + var/list/found = lines[context] + return islist(found) ? found : list() + +// ========================================================================= +// THEIR LOOK +// ========================================================================= + +/// What they have been living in: an orange emergency suit over their work clothes +/datum/outfit/mission_survivor + name = "Rescue Survivor" + uniform = /obj/item/clothing/under/color/grey + suit = /obj/item/clothing/suit/space/fragile + head = /obj/item/clothing/head/helmet/space/fragile + shoes = /obj/item/clothing/shoes/workboots + +/datum/outfit/mission_survivor/bareheaded + name = "Rescue Survivor (Helmet Off)" + head = null + +// ========================================================================= +// THEIR BRAIN +// ========================================================================= + +/// The ambient routine runner, with their senses in front of it +/datum/ai_controller/basic_controller/ambient_npc/mission_survivor + planning_subtrees = list( + /datum/ai_planning_subtree/mission_survivor_senses, + /datum/ai_planning_subtree/ambient_routine, + ) + // Awake while a crew is still a few screens off, for the distress call + interesting_dist = 24 + +/// Looks around and reacts; the routine subtree after it does the walking +/datum/ai_planning_subtree/mission_survivor_senses + +/datum/ai_planning_subtree/mission_survivor_senses/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/basic/ambient_npc/mission_survivor/survivor = controller.pawn + if(istype(survivor) && controller.ai_status == AI_STATUS_ON) + survivor.look_around() + +// ========================================================================= +// WHAT THEY DO +// ========================================================================= + +/// Keeping their head down where they are told to stay, or where they last stopped +/datum/ambient_activity/survivor_hide + name = "hiding" + +/datum/ambient_activity/survivor_hide/setup() + var/mob/living/basic/ambient_npc/mission_survivor/survivor = doer + var/turf/hide_at = istype(survivor) ? survivor.hide_spot : null + if(isturf(doer.loc) && hide_at && hide_at != doer.loc && hide_at.z == doer.z && get_dist(doer, hide_at) <= SURVIVOR_FOLLOW_RANGE && doer.standable(hide_at, failed_spots)) + go_to(hide_at) + return TRUE + +/datum/ambient_activity/survivor_hide/act(seconds) + var/mob/living/basic/ambient_npc/mission_survivor/survivor = doer + if(istype(survivor)) + survivor.hide_tick() + return AMBIENT_STEP_CONTINUE + +/datum/ambient_activity/survivor_hide/spot_unreachable() + . = ..() + var/mob/living/basic/ambient_npc/mission_survivor/survivor = doer + if(istype(survivor)) + survivor.hide_spot = get_turf(doer) + +/// Calling out to someone they spotted, waving, and coming over if it is safe; from a locker, banging on it +/datum/ambient_activity/survivor_call + name = "calling out" + priority = AMBIENT_PRIORITY_REACTION + duration_low = 15 SECONDS + duration_high = 20 SECONDS + +/datum/ambient_activity/survivor_call/setup() + var/mob/living/person = anchor() + var/mob/living/basic/ambient_npc/mission_survivor/survivor = doer + if(!isliving(person) || !istype(survivor)) + return FALSE + set_duration() + if(istype(doer.loc, /obj/structure/closet)) + survivor.bang_on(doer.loc) + doer.speak_context("call_locker", person, force = TRUE) + return TRUE + doer.stand_up() + doer.face_atom(person) + doer.manual_emote(pick("waves both arms.", "waves.", "waves weakly.")) + doer.speak_context("call", person, force = TRUE) + var/distance = get_dist(doer, person) + if(distance > 2 && distance <= SURVIVOR_APPROACH_RANGE) + go_to(get_turf(person), 2) + return TRUE + +/datum/ambient_activity/survivor_call/act(seconds) + var/mob/living/person = anchor() + var/turf/there = get_turf(person) + if(!person || person.stat == DEAD || there?.z != doer.z) + return AMBIENT_STEP_DONE + var/obj/structure/closet/container = doer.loc + if(istype(container)) + var/mob/living/basic/ambient_npc/mission_survivor/survivor = doer + // Right outside: they push it open themselves + if(get_dist(container, person) <= 1 && container.open()) + return AMBIENT_STEP_DONE + if(istype(survivor) && survivor.reaction_ready("bang", 6 SECONDS)) + survivor.bang_on(container) + return AMBIENT_STEP_CONTINUE + if(spot && get_dist(doer, person) > 2) + if(LAZYACCESS(failed_spots, there)) + return AMBIENT_STEP_DONE + go_to(there, 2) + return AMBIENT_STEP_MOVE + if(!doer.buckled) + doer.face_atom(person) + return AMBIENT_STEP_DONE + +/datum/ambient_activity/survivor_call/finish() + var/mob/living/basic/ambient_npc/mission_survivor/survivor = doer + if(istype(survivor) && isturf(survivor.loc)) + survivor.hide_spot = survivor.loc + return ..() + +/// Running from something, then down in a crouch wherever they got to +/datum/ambient_activity/survivor_flee + name = "running" + priority = SURVIVOR_FLEE_PRIORITY + /// How many more times a threat that keeps coming can send them on + var/runs_left = SURVIVOR_FLEE_RUNS + +/datum/ambient_activity/survivor_flee/setup() + var/mob/living/threat = anchor() + var/mob/living/basic/ambient_npc/mission_survivor/survivor = doer + if(!isliving(threat) || !istype(survivor)) + return FALSE + ends_at = world.time + SURVIVOR_FLEE_TIME + if(survivor.reaction_ready("flee_line", 6 SECONDS)) + doer.speak_context("flee", threat, force = TRUE) + var/turf/away = isturf(doer.loc) ? survivor.flee_tile(threat, failed_spots) : null + if(away) + doer.stand_up() + go_to(away) + else + // Nowhere to go: down, arms over their head + doer.crouch() + return TRUE + +/datum/ambient_activity/survivor_flee/arrive() + doer.crouch() + +/datum/ambient_activity/survivor_flee/act(seconds) + var/mob/living/threat = anchor() + if(!threat || threat.stat != CONSCIOUS) + return AMBIENT_STEP_DONE + if(runs_left > 0 && get_dist(doer, threat) <= 2 && isturf(doer.loc)) + var/mob/living/basic/ambient_npc/mission_survivor/survivor = doer + var/turf/away = survivor.flee_tile(threat, failed_spots) + if(away) + runs_left-- + doer.stand_up() + go_to(away) + return AMBIENT_STEP_MOVE + return AMBIENT_STEP_CONTINUE + +/datum/ambient_activity/survivor_flee/finish() + var/mob/living/basic/ambient_npc/mission_survivor/survivor = doer + if(istype(survivor) && isturf(survivor.loc)) + survivor.hide_spot = survivor.loc + return ..() + +/// At their rescuer's side as they move, facing them when they stop; out of anything they get shut in +/datum/ambient_activity/survivor_follow + name = "following" + priority = SURVIVOR_FOLLOW_PRIORITY + spot_distance = 1 + /// world.time their rescuer went out of reach, or 0 + var/lost_since = 0 + /// They called out for their rescuer since losing them + var/called_lost = FALSE + +/datum/ambient_activity/survivor_follow/setup() + if(!isliving(anchor())) + return FALSE + next_line = world.time + rand(40 SECONDS, 80 SECONDS) + return TRUE + +/// Whether `leader` can still be followed: alive, on this level, near enough +/datum/ambient_activity/survivor_follow/proc/in_reach(mob/living/leader) + var/turf/there = get_turf(leader) + var/turf/here = get_turf(doer) + return leader && leader.stat != DEAD && there && here && there.z == here.z && get_dist(here, there) <= SURVIVOR_FOLLOW_RANGE + +// Where they go is wherever their rescuer is now +/datum/ambient_activity/survivor_follow/at_spot() + if(!isturf(doer.loc)) + return TRUE // act() gets them out first + var/mob/living/leader = anchor() + if(!in_reach(leader) || get_dist(doer, leader) <= 1) + return TRUE + if(doer.crouching) + doer.stand_up() + var/turf/there = get_turf(leader) + if(spot != there) + go_to(there, 1) + return FALSE + +/datum/ambient_activity/survivor_follow/act(seconds) + var/mob/living/basic/ambient_npc/mission_survivor/survivor = doer + if(!istype(survivor)) + return AMBIENT_STEP_DONE + if(!isturf(doer.loc)) + survivor.climb_out() + return AMBIENT_STEP_CONTINUE + var/mob/living/leader = anchor() + if(leader?.stat == DEAD && get_dist(doer, leader) <= SURVIVOR_SIGHT) + survivor.leader_fell(leader) + return AMBIENT_STEP_DONE + if(!in_reach(leader)) + if(!lost_since) + lost_since = world.time + if(!called_lost && world.time - lost_since > 8 SECONDS) + called_lost = TRUE + doer.speak_context("lost", null, force = TRUE) + if(world.time - lost_since >= SURVIVOR_LOST_TIME) + survivor.lose_leader() + return AMBIENT_STEP_DONE + return AMBIENT_STEP_CONTINUE + lost_since = 0 + called_lost = FALSE + if(get_dist(doer, leader) <= 2 && !doer.buckled) + doer.face_atom(leader) + survivor.check_aboard() + chatter("follow_idle", 60 SECONDS, 120 SECONDS) + return AMBIENT_STEP_CONTINUE + +// A spot they could not reach is just their rescuer's last one: they try again from where they are +/datum/ambient_activity/survivor_follow/spot_unreachable() + spot = null + arrived = FALSE + +// ========================================================================= +// WHERE THEY HIDE +// ========================================================================= + +/** + * Where a survivor hides at `target`, looking around `near` (the site's spawn point): list(turf, + * locker or null). Half the time a locker if there is one (they get in it), otherwise a corner or + * an alcove, never beside open space or on a docked ship, and never within + * SURVIVOR_SHELTER_THREAT_GAP of anything hostile already loose in there. Falls back to `near`. + * + * Bounded (a square of SURVIVOR_SHELTER_RADIUS around `near`) and never sleeps: it runs from the + * site's loaded signal. + */ +/proc/mission_survivor_hiding_place(turf/near, datum/mission_target/target) + . = list(near, null) + if(!near || !target) + return + var/list/dangers = list() + for(var/mob/living/thing as anything in GLOB.mob_living_list) + if(thing.z != near.z || thing.stat == DEAD || thing.client) + continue + if(get_dist(thing, near) > SURVIVOR_SHELTER_RADIUS + SURVIVOR_SHELTER_THREAT_GAP) + continue + if(FACTION_NEUTRAL in thing.faction) + continue + if(mission_survivor_dangerous(thing)) + dangers += get_turf(thing) + var/list/lockers = list() + var/list/nooks = list() + var/best_cover = 0 + for(var/turf/open/tile in RANGE_TURFS(SURVIVOR_SHELTER_RADIUS, near)) + if(isspaceturf(tile) || isgroundlessturf(tile) || islava(tile) || ischasm(tile)) + continue + if(istype(tile.loc, /area/shuttle) || !target.contains_turf(tile)) + continue + if(length(dangers) && ambient_too_close(tile, dangers, SURVIVOR_SHELTER_THREAT_GAP)) + continue + var/obj/structure/closet/locker = locate() in tile + if(locker) + if(mission_survivor_locker_ok(locker)) + lockers += locker + continue + if(tile.is_blocked_turf(exclude_mobs = TRUE)) + continue + var/cover = mission_survivor_cover(tile) + if(cover < SURVIVOR_SHELTER_MIN_COVER) + continue + nooks[tile] = cover + best_cover = max(best_cover, cover) + if(length(lockers) && (prob(50) || !length(nooks))) + var/obj/structure/closet/locker = pick(lockers) + return list(get_turf(locker), locker) + if(!length(nooks)) + return + // The snuggest spots, not always the single snuggest one + var/list/best = list() + for(var/turf/tile as anything in nooks) + if(nooks[tile] >= best_cover - 1) + best += tile + return list(pick(best), null) + +/// Neighbours (of 8) of `tile` that are walls or blocked, or 0 if it opens onto space or has no free way in +/proc/mission_survivor_cover(turf/tile) + var/blocked = 0 + var/way_in = FALSE + for(var/direction in GLOB.alldirs) + var/turf/neighbour = get_step(tile, direction) + if(!neighbour || isspaceturf(neighbour) || isgroundlessturf(neighbour)) + return 0 + if(neighbour.is_blocked_turf(exclude_mobs = TRUE)) + blocked++ + else if(direction in GLOB.cardinals) + way_in = TRUE + return way_in ? blocked : 0 + +/// A locker a survivor can shut themselves in, and that someone can get to and open +/proc/mission_survivor_locker_ok(obj/structure/closet/locker) + if(locker.locked || locker.welded || locker.wall_mounted || locker.horizontal) + return FALSE + if(istype(locker, /obj/structure/closet/body_bag) || (locker.resistance_flags & INDESTRUCTIBLE)) + return FALSE + if(locker.max_mob_size < MOB_SIZE_HUMAN || locker.mob_storage_capacity < 1 || (locate(/mob/living) in locker)) + return FALSE + if(locker.opened && !locker.can_close()) + return FALSE + for(var/direction in GLOB.cardinals) + var/turf/neighbour = get_step(locker, direction) + if(neighbour && !isspaceturf(neighbour) && !neighbour.is_blocked_turf(exclude_mobs = TRUE)) + return TRUE + return FALSE + +/** + * Whether `thing` is out to hurt people: a hostile legacy animal, or a mob whose AI picks targets. + * Faction is the caller's business. + */ +/proc/mission_survivor_dangerous(mob/living/thing) + if(istype(thing, /mob/living/simple_animal/hostile)) + return TRUE + return !!thing.ai_controller?.blackboard[BB_TARGETING_STRATEGY] + +#undef SURVIVOR_SIGHT +#undef SURVIVOR_LOOK_INTERVAL +#undef SURVIVOR_FLEE_DISTANCE +#undef SURVIVOR_FLEE_RANGE +#undef SURVIVOR_FLEE_TIME +#undef SURVIVOR_FLEE_RUNS +#undef SURVIVOR_DISTRESS_RANGE +#undef SURVIVOR_DISTRESS_GAP_LOW +#undef SURVIVOR_DISTRESS_GAP_HIGH +#undef SURVIVOR_APPROACH_RANGE +#undef SURVIVOR_NEAR_RANGE +#undef SURVIVOR_HEARING_RANGE +#undef SURVIVOR_FOLLOW_RANGE +#undef SURVIVOR_LOST_TIME +#undef SURVIVOR_FOLLOW_PRIORITY +#undef SURVIVOR_FLEE_PRIORITY +#undef SURVIVOR_DUCK_TIME +#undef SURVIVOR_DUCK_COOLDOWN +#undef SURVIVOR_SHELTER_RADIUS +#undef SURVIVOR_SHELTER_THREAT_GAP +#undef SURVIVOR_SHELTER_MIN_COVER +#undef SURVIVOR_BEAM_TIME +#undef SURVIVOR_BEAM_DROP +#undef SURVIVOR_BEAM_WATCH_RANGE +#undef SURVIVOR_TRAIT diff --git a/voidcrew/modules/missions/objectives/site_ops.dm b/voidcrew/modules/missions/objectives/site_ops.dm index a77f06b6a49..ea4998176e2 100644 --- a/voidcrew/modules/missions/objectives/site_ops.dm +++ b/voidcrew/modules/missions/objectives/site_ops.dm @@ -299,7 +299,7 @@ */ /datum/mission_objective/field/escort /// The survivor mob type - var/survivor_type = /mob/living/basic/mission_survivor + var/survivor_type = /mob/living/basic/ambient_npc/mission_survivor /// The live survivor var/mob/living/survivor /// Health fraction at turn-in for the "still walking" pay bonus @@ -341,6 +341,9 @@ * * The successful turn-in has already nulled the ref by the time deactivation * runs, so a repatriated survivor never reaches this. + * + * Whoever is watching sees them taken up from orbit in a column of light + * (beam_out()); this is usually aboard the crew's own ship, mid-escort. */ /datum/mission_objective/field/escort/proc/release_survivor() clear_grace() @@ -349,11 +352,20 @@ if(QDELETED(leaving)) return UnregisterSignal(leaving, list(COMSIG_LIVING_DEATH, COMSIG_LIVING_REVIVE)) - leaving.visible_message(span_notice("[leaving]'s beacon chirps, and they are teleported away.")) + var/mob/living/basic/ambient_npc/mission_survivor/person = leaving + if(istype(person)) + person.beam_out() + return qdel(leaving) +/// The survivor goes somewhere sheltered in the site (a locker, a corner), not just any open tile /datum/mission_objective/field/escort/spawn_field_objects(turf/spawn_turf) - survivor = new survivor_type(spawn_turf) + var/list/hiding_place = mission_survivor_hiding_place(spawn_turf, mission?.target) + survivor = new survivor_type(hiding_place[1] || spawn_turf) + var/mob/living/basic/ambient_npc/mission_survivor/person = survivor + if(istype(person)) + person.join_contract(mission) + person.take_shelter(hiding_place[2]) protect_field_mob(survivor) RegisterSignal(survivor, COMSIG_LIVING_DEATH, PROC_REF(on_survivor_death)) RegisterSignal(survivor, COMSIG_LIVING_REVIVE, PROC_REF(on_survivor_revived)) @@ -444,14 +456,17 @@ if(!was_revived && survivor.health >= survivor.maxHealth * unharmed_threshold) mission.value *= unharmed_bonus notify_crew("Survivor recovered unharmed.") - // Repatriation: they step onto the pad and ship out + // Repatriation: a goodbye, and the beam takes them home from beside the pad var/mob/living/leaving = survivor UnregisterSignal(leaving, list(COMSIG_LIVING_DEATH, COMSIG_LIVING_REVIVE)) survivor = null - leaving.visible_message(span_notice("[leaving] waves goodbye and is teleported out.")) if(istype(reward_anchor, /obj/machinery/mission_pad)) var/obj/machinery/mission_pad/pad = reward_anchor pad.do_teleport_effect() + var/mob/living/basic/ambient_npc/mission_survivor/person = leaving + if(istype(person)) + person.beam_out(farewell = TRUE) + return qdel(leaving) /datum/mission_objective/field/escort/get_progress_string() diff --git a/voidcrew/modules/unit_tests/voidcrew_missions.dm b/voidcrew/modules/unit_tests/voidcrew_missions.dm index 03090e20b56..a8a188b0e9f 100644 --- a/voidcrew/modules/unit_tests/voidcrew_missions.dm +++ b/voidcrew/modules/unit_tests/voidcrew_missions.dm @@ -480,7 +480,7 @@ repeated.voucher_count = initial(rescue_type.voucher_count) var/datum/mission_objective/field/escort/repeated_escort = new repeated.add_objective(repeated_escort) - repeated_escort.survivor = allocate(/mob/living/basic/mission_survivor, pad_turf) + repeated_escort.survivor = allocate(/mob/living/basic/ambient_npc/mission_survivor, pad_turf) repeated_escort.on_turn_in_finalized(pad) TEST_ASSERT_EQUAL(repeated.value, 1250, "A healthy survivor must add 25% to credits") TEST_ASSERT_NULL(repeated_escort.survivor, "Finalization must clear the survivor reference") @@ -488,6 +488,28 @@ TEST_ASSERT_EQUAL(repeated.value, 1250, "Repeated finalization must not stack the bonus") TEST_ASSERT_EQUAL(repeated.voucher_count, initial(rescue_type.voucher_count), "Repeated finalization must not change vouchers") +/** + * A survivor the contract lets go of in front of someone is taken up in the transporter's beam: + * held still and untouchable for the length of it, with the column on their tile, rather than + * blinking out. With nobody about they are simply deleted (the delivery test above relies on it). + */ +/datum/unit_test/voidcrew_mission_survivor_beam/Run() + var/turf/spot = run_loc_floor_bottom_left + var/mob/living/basic/ambient_npc/mission_survivor/watched = allocate(/mob/living/basic/ambient_npc/mission_survivor, spot) + watched.beam_out(force_visual = TRUE) + TEST_ASSERT(!QDELETED(watched), "A watched survivor must stay for the beam, not vanish on the spot") + TEST_ASSERT(watched.beaming_out, "A watched survivor must be marked as leaving") + TEST_ASSERT(HAS_TRAIT(watched, TRAIT_GODMODE), "A survivor in the beam must not be hurt") + TEST_ASSERT(HAS_TRAIT(watched, TRAIT_IMMOBILIZED), "A survivor in the beam must not walk out of it") + TEST_ASSERT(watched.anchored, "A survivor in the beam must not be pulled out of it") + TEST_ASSERT(locate(/obj/effect/temp_visual/transporter_beam) in spot, "A watched survivor must get the beam column on their tile") + watched.talked_to(allocate(/mob/living/carbon/human/consistent, get_step(spot, NORTH))) + TEST_ASSERT(!watched.is_following(), "A survivor in the beam must not start following anyone") + + var/mob/living/basic/ambient_npc/mission_survivor/unseen = allocate(/mob/living/basic/ambient_npc/mission_survivor, get_step(spot, EAST)) + unseen.beam_out() + TEST_ASSERT(QDELETED(unseen), "A survivor nobody can see must be deleted at once") + /** * A contract that marks itself exclusive_site must get a wreck to itself. * From 3ccf0a8e63d3c726887fbaec87374e08fc7f0d6f Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:23:26 -0800 Subject: [PATCH 027/472] Give the hoarfrost matriarch a beast growl instead of the hallucination one The wind-up played sound/effects/hallucinations/growl1.ogg, the zombie and faithless growl. It now plays a slowed dog growl at a fixed pitch, so the random pitch cannot squeak it up. Every other finisher and gore sound was checked and fits its mob; a test keeps hallucination, ghost and zombie sounds out of the finisher stages. --- .../hyperviolence/finishers/finisher_stages.dm | 4 +++- .../hyperviolence/finishers/finisher_users.dm | 4 +++- .../modules/unit_tests/voidcrew_hv_finishers.dm | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm b/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm index 485d29c0e19..029278ecf16 100644 --- a/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm +++ b/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm @@ -36,6 +36,8 @@ /// Sound when the stage begins, from the attacker var/enter_sound var/enter_volume = 60 + /// Playback speed of the opening sound. Null lets it vary as playsound() does; a big beast wants a fixed low one. + var/enter_frequency /// Sound of the beat, from the victim var/effect_sound var/effect_volume = 70 @@ -60,7 +62,7 @@ host.user.face_atom(host.victim) host.pose_user(lunge, rise) if(enter_sound) - playsound(host.user, enter_sound, enter_volume, TRUE) + playsound(host.user, enter_sound, enter_volume, TRUE, frequency = enter_frequency) var/text = enter_message() if(text) host.victim.visible_message(span_danger(text), span_userdanger(enter_self_message() || text), vision_distance = enter_range) diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_users.dm b/voidcrew/modules/hyperviolence/finishers/finisher_users.dm index e4e912bfd6f..aeb855eb94a 100644 --- a/voidcrew/modules/hyperviolence/finishers/finisher_users.dm +++ b/voidcrew/modules/hyperviolence/finishers/finisher_users.dm @@ -40,7 +40,9 @@ return ..() /datum/hv_finisher_stage/windup/hoarfrost - enter_sound = 'sound/effects/hallucinations/growl1.ogg' + // A dog's growl, slowed: the hallucination growl sounded like a zombie. Never pitched up, so it stays a big animal. + enter_sound = 'sound/mobs/non-humanoids/dog/growl2.ogg' + enter_frequency = 0.8 /datum/hv_finisher_stage/windup/hoarfrost/enter_message() return "[host.user] lowers [host.user.p_their()] head over [host.victim] and growls." diff --git a/voidcrew/modules/unit_tests/voidcrew_hv_finishers.dm b/voidcrew/modules/unit_tests/voidcrew_hv_finishers.dm index 163f9dfc553..2f3926e274d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hv_finishers.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hv_finishers.dm @@ -250,3 +250,20 @@ TEST_ASSERT_EQUAL(finisher.result, 6, "the finisher ended for the wrong reason") // HV_FINISHER_TIMED_OUT TEST_ASSERT(!victim.has_status_effect(/datum/status_effect/hv_finisher_hold), "the victim is still held") TEST_ASSERT(!HAS_TRAIT(matriarch, TRAIT_AI_PAUSED), "the matriarch's AI is still paused") + +/// No finisher stage plays a hallucination, ghost or zombie sound, which sit wrong on a beast, a knight or a pirate, +/// and the matriarch's growl is a fixed, slowed animal growl rather than one the random pitch can squeak up. +/datum/unit_test/voidcrew_hv_finisher/sounds + +/datum/unit_test/voidcrew_hv_finisher/sounds/Run() + var/list/wrong_for_a_mob = list("hallucinations", "zombie", "ghost", "faithless") + for(var/datum/hv_finisher_stage/stage_type as anything in typesof(/datum/hv_finisher_stage)) + for(var/sound_file in list(initial(stage_type.enter_sound), initial(stage_type.effect_sound))) + if(!sound_file) + continue + for(var/word in wrong_for_a_mob) + TEST_ASSERT(!findtext("[sound_file]", word), "[stage_type] plays [sound_file], which is a [word] sound") + var/datum/hv_finisher_stage/windup/hoarfrost/growl = /datum/hv_finisher_stage/windup/hoarfrost + TEST_ASSERT(findtext("[initial(growl.enter_sound)]", "non-humanoids"), "The matriarch's wind-up is not a beast sound") + var/slowed = initial(growl.enter_frequency) + TEST_ASSERT(slowed > 0 && slowed < 1, "The matriarch's growl is not slowed") From 25e037349b8b1ea8c3a96c71b7fe50d56259f57e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:23:27 -0800 Subject: [PATCH 028/472] Spill the intestines with tg's chest organ spill, every time tg spills the chest organs when the chest is split open (chest dismember, only on someone in hard crit, dead or cursed). The intestines now come out in that same call and nothing else triggers them: the slash chance and the explosion roll are gone. A living victim is disemboweled as before, with the tether and the push-back treatment. A corpse has nothing to drag, so its guts just drop with the other organs. The stomach and liver no longer need their own roll, since tg's spill already takes every chest organ. The guts line now reads after tg's chest line instead of repeating it. A body that cannot be dismembered keeps its guts and its head; tests cover the living, corpse and blocked cases and a head burst on such a body. --- voidcrew/_DEFINES/hyperviolence.dm | 16 +-- .../hyperviolence/blood/blood_spray.dm | 5 + .../hyperviolence/braingut/blast_gore.dm | 15 +- .../hyperviolence/braingut/gut_spill.dm | 121 ++++++++-------- .../voidcrew_hyperviolence_braingut.dm | 135 +++++++++++++++--- 5 files changed, 183 insertions(+), 109 deletions(-) diff --git a/voidcrew/_DEFINES/hyperviolence.dm b/voidcrew/_DEFINES/hyperviolence.dm index a9c53ad033d..22c99a7ebfc 100644 --- a/voidcrew/_DEFINES/hyperviolence.dm +++ b/voidcrew/_DEFINES/hyperviolence.dm @@ -65,7 +65,7 @@ /// It ran past its longest possible length. A safety net, never the plan. #define HV_FINISHER_TIMED_OUT 6 -// Brain burst, gut spill and gut tether (voidcrew/modules/hyperviolence/braingut/). +// Brain burst, gut spill and gut tether (voidcrew/modules/hyperviolence/braingut/). The guts come out with tg's chest organ spill, so they have no odds of their own. /// A blow must deal at least this much (after armour) before it can burst a downed victim's head instead of knocking it off whole. #define HV_HEAD_BURST_MIN_DAMAGE 30 @@ -80,20 +80,6 @@ /// Percent chance that a heavy explosion bursts the head of a victim who is dead or in hard crit. #define HV_HEAD_BURST_EXPLOSION_CHANCE 10 -/// A slash must deal at least this much (after armour) to the chest to be able to spill guts. -#define HV_GUTSPILL_MIN_DAMAGE 25 -/// A victim who is still up needs the chest this far gone (fraction of its damage limit) before a slash can spill guts. -#define HV_GUTSPILL_MIN_CHEST_DAMAGE 0.25 -/// Percent chance that a qualifying slash spills guts. -#define HV_GUTSPILL_CHANCE 12 -/// Percent chance that a qualifying slash spills guts on a victim who is dead or in hard crit. -#define HV_GUTSPILL_CHANCE_DOWNED 35 -/// Percent chance that a heavy explosion spills guts. -#define HV_GUTSPILL_EXPLOSION_CHANCE 15 -/// Percent chance that a heavy explosion spills guts on a victim who is dead or in hard crit. -#define HV_GUTSPILL_EXPLOSION_CHANCE_DOWNED 25 -/// The chest this far gone (fraction of its damage limit) when guts spill also lets the stomach and liver slip out. -#define HV_GUTSPILL_ORGAN_CHEST_DAMAGE 0.5 /// How far the guts can lie from their owner before the owner is dragged against them. #define HV_GUT_TETHER_RANGE 3 /// How far apart the guts and their owner can get before the tether tears. diff --git a/voidcrew/modules/hyperviolence/blood/blood_spray.dm b/voidcrew/modules/hyperviolence/blood/blood_spray.dm index c0aa18f4d7d..5f2da954724 100644 --- a/voidcrew/modules/hyperviolence/blood/blood_spray.dm +++ b/voidcrew/modules/hyperviolence/blood/blood_spray.dm @@ -216,6 +216,11 @@ return hv_blood_throw(attack_direction, round(damage_dealt / HV_BLOOD_DAMAGE_PER_TILE)) +/// The way the last brute hit travelled if it landed a moment ago, else null. +/mob/living/proc/hv_blood_recent_hit_dir() + if(world.time - hv_blood_hit_time <= HV_BLOOD_HIT_MEMORY) + return hv_blood_hit_dir + /// A limb torn off sprays the way the blow that took it travelled. /mob/living/carbon/proc/hv_blood_on_limb_removed(datum/source, obj/item/bodypart/removed_limb, special, dismembered) SIGNAL_HANDLER diff --git a/voidcrew/modules/hyperviolence/braingut/blast_gore.dm b/voidcrew/modules/hyperviolence/braingut/blast_gore.dm index 110eec3af00..9224df5d5d1 100644 --- a/voidcrew/modules/hyperviolence/braingut/blast_gore.dm +++ b/voidcrew/modules/hyperviolence/braingut/blast_gore.dm @@ -1,18 +1,11 @@ // Blast gore (hyperviolence, braingut part): a heavy explosion can burst the head of a victim who is -// already down, and can blow open the belly of any victim. Both roll against the victim's bomb armour. +// already down. It rolls against the victim's bomb armour. A blast that splits a chest open does so +// through tg's own chest spill, which brings the intestines out with the other organs (gut_spill.dm). /mob/living/carbon/human/ex_act(severity, target, origin) . = ..() if(!. || severity != EXPLODE_HEAVY || QDELETED(src)) return - var/downed = stat >= HARD_CRIT - if(downed && prob(HV_HEAD_BURST_EXPLOSION_CHANCE) && !prob(getarmor(get_bodypart(BODY_ZONE_HEAD), BOMB))) - hv_burst_head() - var/obj/item/bodypart/chest/chest = get_bodypart(BODY_ZONE_CHEST) - if(!chest || !chest.hv_chest_can_spill()) + if(stat < HARD_CRIT || !prob(HV_HEAD_BURST_EXPLOSION_CHANCE) || prob(getarmor(get_bodypart(BODY_ZONE_HEAD), BOMB))) return - if(!prob(downed ? HV_GUTSPILL_EXPLOSION_CHANCE_DOWNED : HV_GUTSPILL_EXPLOSION_CHANCE)) - return - if(prob(getarmor(chest, BOMB))) - return - chest.hv_spill_guts(null, "explosion") + hv_burst_head() diff --git a/voidcrew/modules/hyperviolence/braingut/gut_spill.dm b/voidcrew/modules/hyperviolence/braingut/gut_spill.dm index 094982427fd..f6a6e12f266 100644 --- a/voidcrew/modules/hyperviolence/braingut/gut_spill.dm +++ b/voidcrew/modules/hyperviolence/braingut/gut_spill.dm @@ -1,8 +1,10 @@ -// Gut spill (hyperviolence, braingut part): a critical chest wound for big slashes and heavy -// explosions. The victim's intestines spill onto the floor and stay joined to them by a tether -// (gut_tether.dm); at the worst level the stomach and liver slip out too. It is a critical slash -// wound, so gauze, sutures and cautery all work on the bleeding, but the cut will not close while -// the guts are out. Pushing the guts back in turns it into an ordinary severe cut. +// Gut spill (hyperviolence, braingut part): tg already spills a victim's chest organs onto the floor +// when the chest is split open (/obj/item/bodypart/chest/dismember, only ever on someone in hard crit +// or dead, or cursed). The intestines come out in that same spill, every time, and nothing else +// triggers them. On a living victim they stay joined to the body by a tether (gut_tether.dm) through +// a critical slash wound, so gauze, sutures and cautery all work on the bleeding, but the cut will not +// close while the guts are out. Pushing the guts back in turns it into an ordinary severe cut. On a +// corpse there is nothing to drag: the guts just lie with the other organs. // The design idea is Septic Shock's gut spill and Vanderlin's disembowel (AGPL code, nothing copied). /// Slows a victim holding their guts in. @@ -12,7 +14,7 @@ /datum/wound_pregen_data/flesh_slash/disembowel abstract = FALSE wound_path_to_generate = /datum/wound/slash/flesh/critical/disembowel - // only ever applied on purpose (the chest slash hook below, blast_gore.dm), never by the wound roll + // only ever applied on purpose, by the chest spill hook below, never by the wound roll can_be_randomly_generated = FALSE viable_zones = list(BODY_ZONE_CHEST) threshold_minimum = WOUND_DISMEMBER_OUTRIGHT_THRESH @@ -44,13 +46,13 @@ /// The strand between the victim and the guts. var/datum/hv_gut_tether/tether -/// Spills quietly: the spill reports itself in the hit message, so the generic "chest is torn open" line would only repeat it. +/// Spills quietly: tg's chest spill line and the guts line already say it, so the generic "chest is torn open" line would only repeat them. /datum/wound/slash/flesh/critical/disembowel/apply_wound(obj/item/bodypart/L, silent = FALSE, datum/wound/old_wound = null, smited = FALSE, attack_direction = null, wound_source = "Unknown", replacing = FALSE) return ..(L, TRUE, old_wound, smited, attack_direction, wound_source, replacing) /datum/wound/slash/flesh/critical/disembowel/wound_injury(datum/wound/slash/flesh/old_wound = null, attack_direction = null) . = ..() - spill_out(attack_direction, !isnull(old_wound)) + spill_out(attack_direction) /datum/wound/slash/flesh/critical/disembowel/Destroy() release_guts() @@ -75,43 +77,17 @@ /datum/wound/slash/flesh/critical/disembowel/get_bleed_rate_of_change() return ..() || BLOOD_FLOW_STEADY -/// Throws the guts, and at the worst level the stomach and liver, out of the victim and onto the floor. -/// need_spray is set when the generic cut did not already spray blood (it only does when the wound is new). -/datum/wound/slash/flesh/critical/disembowel/proc/spill_out(attack_direction, need_spray) - var/turf/victim_turf = get_turf(victim) - if(!victim_turf) +/// Drops the guts on the floor and ties them to the victim. The other organs are already out: the chest spill +/// that brought this wound took them. +/datum/wound/slash/flesh/critical/disembowel/proc/spill_out(attack_direction) + var/obj/item/bodypart/chest/chest = limb + guts = chest.hv_drop_intestines(attack_direction) + if(!guts) return - var/atom/drop_spot = victim.drop_location() || victim_turf - if(isturf(victim.loc) && attack_direction) - var/turf/ahead = get_step(victim, attack_direction) - if(ahead && !ahead.is_blocked_turf(exclude_mobs = TRUE)) - drop_spot = ahead - guts = new(drop_spot) - guts.add_mob_blood(victim) guts_out = TRUE RegisterSignal(guts, COMSIG_QDELETING, PROC_REF(on_guts_deleted)) victim.add_movespeed_modifier(/datum/movespeed_modifier/hv_gut_spill) - - var/list/dna = victim.get_blood_dna_list() - if(victim.can_bleed() && isturf(drop_spot)) - new /obj/effect/decal/cleanable/blood/gibs(drop_spot, null, dna) - if(need_spray) - victim.hv_blood_spray(attack_direction, 3) - playsound(victim, 'sound/effects/wounds/splatter.ogg', 80, TRUE) INVOKE_ASYNC(victim, TYPE_PROC_REF(/mob, emote), "scream") - victim.hv_add_hit_line("[victim]'s guts spill out!", HV_LINE_HUGE, "Your guts spill out!", DEFAULT_MESSAGE_RANGE) - - // at the worst level more than the guts come out - if(limb.get_damage() >= limb.max_damage * HV_GUTSPILL_ORGAN_CHEST_DAMAGE) - var/list/slip_chances = list(ORGAN_SLOT_STOMACH = 70, ORGAN_SLOT_LIVER = 35) - for(var/slot in slip_chances) - var/obj/item/organ/organ = victim.get_organ_slot(slot) - if(!organ || (organ.organ_flags & ORGAN_UNREMOVABLE) || !prob(slip_chances[slot])) - continue - organ.Remove(victim) - organ.forceMove(drop_spot) - victim.hv_add_hit_line("[victim]'s [organ.name] slips out!", HV_LINE_BOLD, "Your [organ.name] slips out!") - tether = new(victim, guts) RegisterSignal(tether, COMSIG_QDELETING, PROC_REF(on_tether_deleted)) @@ -176,16 +152,7 @@ if(!QDELETED(src) && limb && demotes_to) replace_wound(new demotes_to) -/// Whether a blow to this chest is big enough, and the chest and victim fit, for it to spill guts. -/// The deterministic part of the roll, split out so it can be tested. -/obj/item/bodypart/chest/proc/hv_can_spill_guts(blow_damage, sharpness, wound_bonus, forced) - if(!owner || forced || wound_bonus == CANT_WOUND) - return FALSE - if(!(sharpness & SHARP_EDGED) || blow_damage < HV_GUTSPILL_MIN_DAMAGE) - return FALSE - return hv_chest_can_spill() - -/// Whether this chest could spill guts at all, whatever the blow: right body, nothing spilled yet. +/// Whether this chest could spill intestines at all: a human body that can be wounded. /obj/item/bodypart/chest/proc/hv_chest_can_spill() if(!owner || !ishuman(owner)) return FALSE @@ -194,11 +161,28 @@ var/datum/wound_pregen_data/pregen = GLOB.all_wound_pregen_data[/datum/wound/slash/flesh/critical/disembowel] return pregen.can_be_applied_to(src) -/// Whether a victim is down for good measure: dead or in hard crit. Cuts and blasts spill guts far more readily on them. -/obj/item/bodypart/chest/proc/hv_owner_downed() - return owner.stat >= HARD_CRIT +/// Puts a coil of intestines on the floor where the organs fell, ahead of the victim when the blow's direction +/// is known, with a smear of gibs and a line in the hit message. Returns the guts, or null if there was nowhere to put them. +/obj/item/bodypart/chest/proc/hv_drop_intestines(attack_direction) + var/mob/living/carbon/victim = owner + var/turf/victim_turf = get_turf(victim) + if(!victim_turf) + return null + var/atom/drop_spot = victim.drop_location() || victim_turf + if(isturf(victim.loc) && attack_direction) + var/turf/ahead = get_step(victim, attack_direction) + if(ahead && !ahead.is_blocked_turf(exclude_mobs = TRUE)) + drop_spot = ahead + var/obj/item/hv_intestines/guts = new(drop_spot) + guts.add_mob_blood(victim) + if(victim.can_bleed() && isturf(drop_spot)) + new /obj/effect/decal/cleanable/blood/gibs(drop_spot, null, victim.get_blood_dna_list()) + // Reads after tg's "chest is split open, causing their internal organs to spill out!" + victim.hv_add_hit_line("[victim]'s guts unspool across the floor!", HV_LINE_BOLD, "Your guts unspool across the floor!", DEFAULT_MESSAGE_RANGE) + return guts -/// Puts a disembowelment on this chest, replacing the cut it comes from. Returns the wound, or null if it could not be applied. +/// Puts a disembowelment on this chest, replacing the cut it comes from, and drops the guts it holds on a tether. +/// Returns the wound, or null if it could not be applied. /obj/item/bodypart/chest/proc/hv_spill_guts(attack_direction, damage_source) if(!hv_chest_can_spill()) return null @@ -212,15 +196,26 @@ return null return spill -/obj/item/bodypart/chest/receive_damage(brute = 0, burn = 0, blocked = 0, updating_health = TRUE, forced = FALSE, required_bodytype = null, wound_bonus = 0, exposed_wound_bonus = 0, sharpness = NONE, attack_direction = null, damage_source, wound_clothing = TRUE) - . = ..() - if(!(sharpness & SHARP_EDGED) || !owner) +/// The intestines that come out in the same moment as the other chest organs. A living victim (in hard crit, +/// or cursed) is disemboweled, so the guts stay tied to them; a corpse has nothing to drag, so its guts only drop. +/// Does nothing if the guts are already out. +/obj/item/bodypart/chest/proc/hv_spill_intestines(attack_direction) + if(!hv_chest_can_spill()) return - if(!hv_can_spill_guts(brute * (100 - blocked) / 100, sharpness, wound_bonus, forced)) + var/datum/wound/slash/flesh/critical/disembowel/already = get_wound_type(/datum/wound/slash/flesh/critical/disembowel) + if(already?.guts_out) return - var/downed = hv_owner_downed() - // a victim who is still up must already be badly hurt: no spill from the first blow - if(!downed && get_damage() < max_damage * HV_GUTSPILL_MIN_CHEST_DAMAGE) + if(owner.stat == DEAD) + hv_drop_intestines(attack_direction) + return + hv_spill_guts(attack_direction, "chest spill") + +/// tg splits the chest open by spilling its organs onto the floor; the intestines are part of that spill. +/// The way the last blow travelled, if one just landed, is where the guts are thrown. +/obj/item/bodypart/chest/dismember(dam_type = BRUTE, silent = TRUE, wounding_type) + var/mob/living/carbon/chest_owner = owner + . = ..() + // tg gives back the organs it spilled, or FALSE when it did nothing + if(!islist(.) || !length(.) || !chest_owner || chest_owner != owner) return - if(prob(downed ? HV_GUTSPILL_CHANCE_DOWNED : HV_GUTSPILL_CHANCE)) - hv_spill_guts(attack_direction, damage_source) + hv_spill_intestines(chest_owner.hv_blood_recent_hit_dir()) diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm index b7b9e32f2e2..1d5cfba8794 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm @@ -97,32 +97,127 @@ TEST_ASSERT(!QDELETED(head), "An ordinary decapitation destroyed the head") TEST_ASSERT_EQUAL(brain.loc, head, "An ordinary decapitation moved the brain out of the head") -/// Which blows to a chest can spill guts. -/datum/unit_test/voidcrew_hv_gut_spill_gating +/// The intestines of a victim who is up and fighting (hard crit, or cursed) come out in the same moment as tg's chest organs, +/// stay tied to them by a tether, and a second spill does not drop a second set. +/datum/unit_test/voidcrew_hv_chest_spill_living -/datum/unit_test/voidcrew_hv_gut_spill_gating/Run() +/datum/unit_test/voidcrew_hv_chest_spill_living/Run() var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) var/obj/item/bodypart/chest/chest = victim.get_bodypart(BODY_ZONE_CHEST) - var/min_slash - for(var/blow in 1 to 200) - if(chest.hv_can_spill_guts(blow, SHARP_EDGED, 0, FALSE)) - min_slash = blow - break - TEST_ASSERT_NOTNULL(min_slash, "No slash to a human chest can spill guts") - TEST_ASSERT(min_slash >= 10, "A slash of only [min_slash] can spill guts") - TEST_ASSERT(!chest.hv_can_spill_guts(min_slash - 1, SHARP_EDGED, 0, FALSE), "A slash just under the limit can spill guts") - TEST_ASSERT(!chest.hv_can_spill_guts(200, NONE, 0, FALSE), "A blunt blow can spill guts") - TEST_ASSERT(!chest.hv_can_spill_guts(200, SHARP_POINTY, 0, FALSE), "A stab can spill guts") - TEST_ASSERT(!chest.hv_can_spill_guts(200, SHARP_EDGED, CANT_WOUND, FALSE), "A blow that cannot wound can spill guts") - TEST_ASSERT(!chest.hv_can_spill_guts(200, SHARP_EDGED, 0, TRUE), "Forced damage can spill guts") - TEST_ASSERT(!chest.hv_owner_downed(), "A conscious victim counted as downed") + var/turf/here = get_turf(victim) + TEST_ASSERT_NOTNULL(victim.get_organ_slot(ORGAN_SLOT_HEART), "Test fixture: the victim has no heart") victim.set_stat(HARD_CRIT) - TEST_ASSERT(chest.hv_owner_downed(), "A victim in hard crit did not count as downed") + TEST_ASSERT(chest.can_dismember(), "Test fixture: tg will not spill the chest of a victim in hard crit") + + var/list/spilled = chest.dismember(BRUTE, TRUE) + TEST_ASSERT(islist(spilled) && length(spilled), "tg's chest spill did not spill anything") + TEST_ASSERT_NULL(victim.get_organ_slot(ORGAN_SLOT_HEART), "The chest organs were not spilled") + var/datum/wound/slash/flesh/critical/disembowel/spill = chest.get_wound_type(/datum/wound/slash/flesh/critical/disembowel) + TEST_ASSERT_NOTNULL(spill, "A living victim's chest spill did not disembowel them") + TEST_ASSERT(spill.guts_out, "The wound does not count the guts as out") + var/obj/item/hv_intestines/guts = spill.guts + TEST_ASSERT_NOTNULL(guts, "The chest spill dropped no guts") + TEST_ASSERT(isturf(guts.loc), "The guts were not dropped on the floor") + TEST_ASSERT_NOTNULL(spill.tether, "A living victim's guts are not tied to them") + TEST_ASSERT(victim.has_movespeed_modifier(/datum/movespeed_modifier/hv_gut_spill), "A victim holding their guts in was not slowed") + var/found_line = FALSE + for(var/list/line as anything in victim.hv_hit?.lines) + if(findtext(line[1], "guts")) + found_line = TRUE + TEST_ASSERT(found_line, "The spill did not say the guts came out") + + // the organs are gone and the guts are out: a second spill drops nothing more + chest.dismember(BRUTE, TRUE) + var/guts_on_floor = 0 + for(var/obj/item/hv_intestines/coil in here) + guts_on_floor++ + TEST_ASSERT_EQUAL(guts_on_floor, 1, "A second chest spill dropped more guts") + chest.hv_spill_intestines(NORTH) + guts_on_floor = 0 + for(var/obj/item/hv_intestines/coil in here) + guts_on_floor++ + TEST_ASSERT_EQUAL(guts_on_floor, 1, "Guts already out spilled again") + +/// A corpse has nothing to drag: its guts drop with the other organs, with no wound, tether or slowdown. +/datum/unit_test/voidcrew_hv_chest_spill_corpse + +/datum/unit_test/voidcrew_hv_chest_spill_corpse/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/chest/chest = victim.get_bodypart(BODY_ZONE_CHEST) + var/turf/here = get_turf(victim) + victim.set_stat(DEAD) + + var/list/spilled = chest.dismember(BRUTE, TRUE) + TEST_ASSERT(islist(spilled) && length(spilled), "tg's chest spill did not spill anything") + var/obj/item/hv_intestines/guts = locate() in here + TEST_ASSERT_NOTNULL(guts, "A corpse's chest spill dropped no guts") + TEST_ASSERT_NULL(chest.get_wound_type(/datum/wound/slash/flesh/critical/disembowel), "A corpse was given a disembowelment to carry") + TEST_ASSERT(!victim.has_movespeed_modifier(/datum/movespeed_modifier/hv_gut_spill), "A corpse was slowed by its guts") + var/found_line = FALSE + for(var/list/line as anything in victim.hv_hit?.lines) + if(findtext(line[1], "guts")) + found_line = TRUE + TEST_ASSERT(found_line, "The spill did not say the guts came out") + + chest.dismember(BRUTE, TRUE) + var/guts_on_floor = 0 + for(var/obj/item/hv_intestines/coil in here) + guts_on_floor++ + TEST_ASSERT_EQUAL(guts_on_floor, 1, "A second chest spill dropped more guts on a corpse") + +/// Nothing spills the guts but tg's chest spill: a body that cannot be dismembered (an Atlas frame) keeps them, and +/// a chest with nothing to spill drops none. +/datum/unit_test/voidcrew_hv_chest_spill_blocked + +/datum/unit_test/voidcrew_hv_chest_spill_blocked/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/chest/chest = victim.get_bodypart(BODY_ZONE_CHEST) + var/turf/here = get_turf(victim) + victim.set_stat(HARD_CRIT) + ADD_TRAIT(victim, TRAIT_NODISMEMBER, TRAIT_SOURCE_UNIT_TESTS) + TEST_ASSERT(!chest.dismember(BRUTE, TRUE), "A chest that cannot be dismembered spilled") + TEST_ASSERT_NOTNULL(victim.get_organ_slot(ORGAN_SLOT_HEART), "A chest that cannot be dismembered lost its organs") + TEST_ASSERT_NULL(locate(/obj/item/hv_intestines) in here, "A chest that cannot be dismembered dropped its guts") + chest.hv_spill_intestines(NORTH) + TEST_ASSERT_NULL(locate(/obj/item/hv_intestines) in here, "The guts spilled from a body that cannot be dismembered") + TEST_ASSERT_NULL(chest.hv_spill_guts(NORTH, "test"), "A body that cannot be dismembered was disemboweled") + REMOVE_TRAIT(victim, TRAIT_NODISMEMBER, TRAIT_SOURCE_UNIT_TESTS) + + // the same chest with its organs already gone has nothing to spill + for(var/obj/item/organ/organ in chest) + organ.Remove(victim) + qdel(organ) + chest.dismember(BRUTE, TRUE) + TEST_ASSERT_NULL(locate(/obj/item/hv_intestines) in here, "A chest with nothing in it dropped guts") + +/// A body that cannot be dismembered (an Atlas frame) never has its head burst, by any gore path, and an execution +/// falls back to the plain kill. +/datum/unit_test/voidcrew_hv_head_burst_nodismember + +/datum/unit_test/voidcrew_hv_head_burst_nodismember/Run() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) ADD_TRAIT(victim, TRAIT_NODISMEMBER, TRAIT_SOURCE_UNIT_TESTS) - TEST_ASSERT(!chest.hv_can_spill_guts(200, SHARP_EDGED, 0, FALSE), "A victim who cannot be dismembered can spill guts") + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + var/obj/item/organ/brain/brain = victim.get_organ_slot(ORGAN_SLOT_BRAIN) + victim.set_stat(HARD_CRIT) + + TEST_ASSERT(!victim.hv_burst_head(NORTH), "The head of a body that cannot be dismembered burst") + TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_HEAD), head, "The head came off a body that cannot be dismembered") + TEST_ASSERT_EQUAL(brain.loc, head, "The brain left a head that did not burst") + + // a blow that would burst any other head + head.set_brute_dam(head.max_damage) + head.hv_burst_pending = head.hv_blow_bursts(WOUND_BLUNT, 200) + TEST_ASSERT(!head.dismember(BRUTE, TRUE), "A burst blow took the head off a body that cannot be dismembered") + TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_HEAD), head, "A burst blow took the head off a body that cannot be dismembered") + + // an execution shot that would burst the head holes the skull and kills instead + head.set_brute_dam(0) + victim.set_stat(CONSCIOUS) + victim.hv_take_execution(500, NORTH) + TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_HEAD), head, "An execution burst the head of a body that cannot be dismembered") + TEST_ASSERT_EQUAL(victim.stat, DEAD, "An execution did not finish a body that cannot be dismembered") REMOVE_TRAIT(victim, TRAIT_NODISMEMBER, TRAIT_SOURCE_UNIT_TESTS) - TEST_ASSERT_NOTNULL(chest.hv_spill_guts(NORTH, "test"), "The spill was refused") - TEST_ASSERT(!chest.hv_can_spill_guts(200, SHARP_EDGED, 0, FALSE), "A chest that has already spilled can spill again") /// The spill replaces the cut it comes from, drops guts on the floor, and keeps bleeding until they go back in. /datum/unit_test/voidcrew_hv_gut_spill From 7c379a5b8062fb3f82cb96cfef0743800a911644 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:23:27 -0800 Subject: [PATCH 029/472] Count cyberware plating in the aimed crit armour gates The aorta and skull gates only read worn clothing. They now stack the best covering clothing with the body's own armour the way check_armor does, less the hit's armour penetration, so Slabskin under a vest stops a torn aorta and under any helmet stops a caved skull. Tests cover both, and a skull crit on a body that cannot be dismembered. --- .../modules/hyperviolence/crits/crit_roll.dm | 11 +++- .../voidcrew_hyperviolence_crits.dm | 59 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/voidcrew/modules/hyperviolence/crits/crit_roll.dm b/voidcrew/modules/hyperviolence/crits/crit_roll.dm index 19e067d7da3..b37d5250e12 100644 --- a/voidcrew/modules/hyperviolence/crits/crit_roll.dm +++ b/voidcrew/modules/hyperviolence/crits/crit_roll.dm @@ -27,7 +27,8 @@ // held in the attacker's grab loses the throat to about one combat knife slash in three, one // in two for a 25+ weapon. A 10-force kitchen knife tops out near 30, and even on a held // victim under 70, so it never crits by roll. -// * Gear that covers the spot blocks a crit outright (see each crit's blocked_on_carbon()). +// * Gear that covers the spot blocks a crit outright (see each crit's blocked_on_carbon()). Worn +// armour and the body's own plating (cyberware) count together, as tg's check_armor() stacks them. // * A crit never stacks on itself: the wound can only be on a limb once. /// Most a battered limb adds to the roll, scaled by how much of its max damage it has taken. @@ -201,7 +202,9 @@ GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) return weapon.armour_penetration return 0 -/// Best armour rating of armour_flag among the clothes covering limb, less armour_penetration. +/// How well the victim is armoured against armour_flag on limb, less armour_penetration: the best rating among +/// the clothes covering it, stacked on the human's own physiology armour (cyberware dermal plating lives there) +/// the way tg's check_armor() stacks them, each layer stopping its share of what the one before let through. /proc/hv_crit_cover_rating(mob/living/carbon/victim, obj/item/bodypart/limb, armour_flag, armour_penetration = 0) if(!ishuman(victim)) return 0 @@ -209,7 +212,9 @@ GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) var/best = 0 for(var/obj/item/clothing/worn as anything in human_victim.get_clothing_on_part(limb)) best = max(best, worn.get_armor_rating(armour_flag)) - return best - armour_penetration + var/plating = human_victim.physiology?.armor.get_rating(armour_flag) || 0 + var/combined = 100 - (100 - min(best, 100)) * (100 - min(plating, 100)) * 0.01 + return combined - armour_penetration /// Roll bonus for a victim who cannot defend themselves. /mob/living/proc/hv_crit_helpless_bonus(mob/living/attacker) diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_crits.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_crits.dm index 628d8255aaf..9e71ab8a1a7 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_crits.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_crits.dm @@ -171,6 +171,65 @@ TEST_ASSERT(istype(crit, /datum/hv_crit/skull), "A skull crit on a victim in crit did not land") TEST_ASSERT_NULL(dying.get_bodypart(BODY_ZONE_HEAD), "A skull crit on a victim in crit did not burst the head") +/// Plating under the skin (cyberware dermal armour, which lives in the body's own armour) counts with worn armour the +/// way tg stacks them: a vest and Slabskin stop the aorta crit that neither stops alone, and the lightest helmet and +/// Slabskin stop the skull crit. +/datum/unit_test/voidcrew_hv_crit_plating + +/// A human with Slabskin's armour mixed into their own, as the implant does. +/datum/unit_test/voidcrew_hv_crit_plating/proc/plated_human() + var/mob/living/carbon/human/plated = allocate(/mob/living/carbon/human/consistent) + plated.physiology.armor = plated.physiology.armor.add_other_armor(/datum/armor/cyberware_slabskin) + return plated + +/datum/unit_test/voidcrew_hv_crit_plating/Run() + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + + // the aorta, shot at the chest: heavy chest armour is 40 or better after penetration + var/obj/projectile/bullet/shot = allocate(/obj/projectile/bullet) + shot.firer = attacker + var/mob/living/carbon/human/vest_only = allocate(/mob/living/carbon/human/consistent) + vest_only.equip_to_slot_if_possible(allocate(/obj/item/clothing/suit/armor/vest), ITEM_SLOT_OCLOTHING) + var/mob/living/carbon/human/plate_only = plated_human() + var/mob/living/carbon/human/both = plated_human() + both.equip_to_slot_if_possible(allocate(/obj/item/clothing/suit/armor/vest), ITEM_SLOT_OCLOTHING) + var/chest_rating = hv_crit_cover_rating(vest_only, vest_only.get_bodypart(BODY_ZONE_CHEST), BULLET) + TEST_ASSERT(hv_crit_cover_rating(plate_only, plate_only.get_bodypart(BODY_ZONE_CHEST), BULLET) > 0, "Plating under the skin counted for nothing") + TEST_ASSERT(hv_crit_cover_rating(both, both.get_bodypart(BODY_ZONE_CHEST), BULLET) > chest_rating, "Plating did not add to the vest") + TEST_ASSERT(hv_crit_cover_rating(both, both.get_bodypart(BODY_ZONE_CHEST), BULLET, 20) < hv_crit_cover_rating(both, both.get_bodypart(BODY_ZONE_CHEST), BULLET), "Armour penetration did not cut the rating") + TEST_ASSERT(istype(vest_only.hv_try_aimed_crit(vest_only.get_bodypart(BODY_ZONE_CHEST), BODY_ZONE_CHEST, WOUND_PIERCE, 30, 0, 0, NORTH, shot, attacker, 500), /datum/hv_crit/aorta), "A plain vest stopped the aorta crit") + TEST_ASSERT(istype(plate_only.hv_try_aimed_crit(plate_only.get_bodypart(BODY_ZONE_CHEST), BODY_ZONE_CHEST, WOUND_PIERCE, 30, 0, 0, NORTH, shot, attacker, 500), /datum/hv_crit/aorta), "Plating alone stopped the aorta crit") + TEST_ASSERT_NULL(both.hv_try_aimed_crit(both.get_bodypart(BODY_ZONE_CHEST), BODY_ZONE_CHEST, WOUND_PIERCE, 30, 0, 0, NORTH, shot, attacker, 500), "A vest over Slabskin did not stop the aorta crit") + + // the skull, with a toolbox: a cap worth 10 melee and Slabskin's 25 each fall short of the 30 that stops it, together they pass + var/obj/item/storage/toolbox/toolbox = allocate(/obj/item/storage/toolbox) + attacker.put_in_active_hand(toolbox) + var/obj/item/clothing/head/soft/black/cap = allocate(/obj/item/clothing/head/soft/black) + cap.set_armor_rating(MELEE, 10) + var/obj/item/clothing/head/soft/black/other_cap = allocate(/obj/item/clothing/head/soft/black) + other_cap.set_armor_rating(MELEE, 10) + var/mob/living/carbon/human/cap_only = allocate(/mob/living/carbon/human/consistent) + cap_only.equip_to_slot_if_possible(cap, ITEM_SLOT_HEAD) + var/mob/living/carbon/human/plate_and_cap = plated_human() + plate_and_cap.equip_to_slot_if_possible(other_cap, ITEM_SLOT_HEAD) + var/mob/living/carbon/human/bare_plated = plated_human() + TEST_ASSERT(istype(cap_only.hv_try_aimed_crit(cap_only.get_bodypart(BODY_ZONE_HEAD), BODY_ZONE_HEAD, WOUND_BLUNT, 25, 0, 0, NORTH, toolbox, attacker, 500), /datum/hv_crit/skull), "A thin cap stopped the skull crit") + TEST_ASSERT(istype(bare_plated.hv_try_aimed_crit(bare_plated.get_bodypart(BODY_ZONE_HEAD), BODY_ZONE_HEAD, WOUND_BLUNT, 25, 0, 0, NORTH, toolbox, attacker, 500), /datum/hv_crit/skull), "Plating alone stopped the skull crit") + TEST_ASSERT_NULL(plate_and_cap.hv_try_aimed_crit(plate_and_cap.get_bodypart(BODY_ZONE_HEAD), BODY_ZONE_HEAD, WOUND_BLUNT, 25, 0, 0, NORTH, toolbox, attacker, 500), "A cap over Slabskin did not stop the skull crit") + +/// A skull crit on a victim in crit bursts the head, except on a body that cannot be dismembered (an Atlas frame). +/datum/unit_test/voidcrew_hv_crit_skull_nodismember/Run() + var/mob/living/carbon/human/attacker = allocate(/mob/living/carbon/human/consistent) + var/obj/item/storage/toolbox/toolbox = allocate(/obj/item/storage/toolbox) + attacker.put_in_active_hand(toolbox) + var/mob/living/carbon/human/dying = allocate(/mob/living/carbon/human/consistent) + ADD_TRAIT(dying, TRAIT_NODISMEMBER, TRAIT_SOURCE_UNIT_TESTS) + dying.set_stat(HARD_CRIT) + var/obj/item/bodypart/head = dying.get_bodypart(BODY_ZONE_HEAD) + dying.hv_try_aimed_crit(head, BODY_ZONE_HEAD, WOUND_BLUNT, 40, 0, 0, NORTH, toolbox, attacker, 500) + TEST_ASSERT_EQUAL(dying.get_bodypart(BODY_ZONE_HEAD), head, "A skull crit burst the head of a body that cannot be dismembered") + REMOVE_TRAIT(dying, TRAIT_NODISMEMBER, TRAIT_SOURCE_UNIT_TESTS) + /// Jaw crit: a broken jaw that mumbles speech and gives no brain traumas. /datum/unit_test/voidcrew_hv_crit_jaw/Run() var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) From d679b101d8041203f29fe488876e3687e9c40a8e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:23:27 -0800 Subject: [PATCH 030/472] Draw blood pools as big organic shapes that run over tile edges Pools were 32x32 pictures that filled their tile, so neighbouring ones made dark squares cut off at the tile line. Pool art is now 64x64, drawn centred on the tile with rounded irregular outlines that never touch the canvas edge, all shaded from one tiling texture so overlapping pools join without a seam. A pool beside open space keeps to a size that fits its tile. A pool of the first big size also runs onto one open neighbouring tile and the biggest onto up to three, once per side: the neighbour gets an ordinary mopable decal of its own with the same blood, drawn as the end of the pool running onto it. Nothing runs through walls, windows or closed doors, onto lava, chasms or open space, or out of a dried splatter. --- .../hyperviolence/blood/_blood_defines.dm | 11 ++ .../hyperviolence/blood/blood_pools.dm | 106 +++++++++++++++++- .../hyperviolence/blood/blood_weightless.dm | 2 +- .../hyperviolence/blood/icons/blood_pool.dmi | Bin 3386 -> 12241 bytes .../voidcrew_hyperviolence_blood.dm | 94 +++++++++++++++- 5 files changed, 207 insertions(+), 6 deletions(-) diff --git a/voidcrew/modules/hyperviolence/blood/_blood_defines.dm b/voidcrew/modules/hyperviolence/blood/_blood_defines.dm index 784b388d1da..d2557a891fc 100644 --- a/voidcrew/modules/hyperviolence/blood/_blood_defines.dm +++ b/voidcrew/modules/hyperviolence/blood/_blood_defines.dm @@ -18,6 +18,17 @@ /// Each unit of blood a standing bleeder loses adds this much to the pool under them. #define HV_BLOOD_POOL_PER_UNIT 1 +/// Pool pictures are 64x64 and drawn this many pixels left of and below their tile, which centres them on it. +#define HV_BLOOD_POOL_OFFSET -16 +/// Pool sizes up to this one fit inside their own tile. Bigger ones reach over its edges. +#define HV_BLOOD_POOL_CONTAINED_TIER 2 +/// The pool size from which a pool runs onto the tiles beside it. +#define HV_BLOOD_POOL_LOBE_TIER 3 +/// The biggest pool size. +#define HV_BLOOD_POOL_BIGGEST_TIER 4 +/// How many neighbouring tiles a pool of the first big size has run onto, and a pool of the biggest size. +#define HV_BLOOD_POOL_LOBES_BIG 1 +#define HV_BLOOD_POOL_LOBES_BIGGEST 3 /// How often, at most, floating blood is checked against gravity when nothing announced a change. #define HV_BLOOD_FLOAT_SWEEP (15 SECONDS) diff --git a/voidcrew/modules/hyperviolence/blood/blood_pools.dm b/voidcrew/modules/hyperviolence/blood/blood_pools.dm index aef28f37a4a..51446683949 100644 --- a/voidcrew/modules/hyperviolence/blood/blood_pools.dm +++ b/voidcrew/modules/hyperviolence/blood/blood_pools.dm @@ -1,11 +1,16 @@ // Hyperviolence blood part: blood pools that grow as a bleeder stands in their own blood. // -// Art: icons/blood_pool.dmi is original, drawn for this part in the style of tg's blood decals, -// greyscale so the decal takes the bleeder's blood colour. +// Art: icons/blood_pool.dmi is original, drawn for this part, greyscale so the decal takes the +// bleeder's blood colour. Every picture is 64x64 and drawn centred on its tile (pixel_x and pixel_y +// of -16), so a big pool's rounded edge wanders over the tiles around it instead of stopping dead at +// the tile line. All of them are shaded from one tiling texture, so neighbouring pools join without +// a seam. // // A pool is the ordinary blood decal tg already leaves under a bleeding mob: one per tile, picked // up by cleaning, merging with new blood, drying out like any other. It only gets bigger. The -// extra blood a standing bleeder loses is added to it, and its picture steps up as it fills. +// extra blood a standing bleeder loses is added to it, and its picture steps up as it fills. A big +// pool also runs onto the open tiles beside it, a few times at most: each one gets an ordinary +// decal of its own (blood DNA, mopable) drawn as the end of the pool running onto it. /// How much blood the plain splatter on a tile holds before it grows into each pool size. GLOBAL_LIST_INIT(hv_blood_pool_thresholds, list(90, 140, 200, 260)) @@ -13,6 +18,12 @@ GLOBAL_LIST_INIT(hv_blood_pool_thresholds, list(90, 140, 200, 260)) /obj/effect/decal/cleanable/blood /// How far this splatter has grown into a pool, 0 being the stock splatter. var/hv_pool_tier = 0 + /// The way the blood flowed when this is only the end of a bigger pool running onto the tile, else 0. + var/hv_pool_lobe_dir = 0 + /// Neighbouring tiles this pool has already tried to run onto, as directions. Each is tried once. + var/hv_pool_lobe_tried = 0 + /// How many tiles this pool has run onto. + var/hv_pool_lobes = 0 /obj/effect/decal/cleanable/blood/adjust_bloodiness(by_amount, ignore_timer = FALSE) . = ..() @@ -31,10 +42,97 @@ GLOBAL_LIST_INIT(hv_blood_pool_thresholds, list(90, 140, 200, 260)) if(new_tier <= hv_pool_tier) return hv_pool_tier = new_tier + hv_pool_lobe_dir = 0 + // A pool beside open space keeps to a size that stays on its own tile. + var/art_tier = new_tier + if(new_tier > HV_BLOOD_POOL_CONTAINED_TIER && !hv_pool_can_overflow()) + art_tier = HV_BLOOD_POOL_CONTAINED_TIER + hv_pool_show("pool[art_tier]_[rand(1, 3)]") + if(new_tier >= HV_BLOOD_POOL_LOBE_TIER) + hv_pool_run_onto_neighbours(new_tier) + +/// Puts this decal's picture on a state of the pool art, drawn centred on the tile. +/obj/effect/decal/cleanable/blood/proc/hv_pool_show(state) icon = HV_BLOOD_POOL_ICON - icon_state = "pool[new_tier]_[rand(1, 3)]" + icon_state = state + pixel_x = base_pixel_x + HV_BLOOD_POOL_OFFSET + pixel_y = base_pixel_y + HV_BLOOD_POOL_OFFSET update_appearance() +/// Forgets everything about being a pool, for blood that starts over as a plain splatter. +/obj/effect/decal/cleanable/blood/proc/hv_pool_reset() + hv_pool_tier = 0 + hv_pool_lobe_dir = 0 + hv_pool_lobe_tried = 0 + hv_pool_lobes = 0 + +/// Whether a pool's big picture, which reaches over the edges of its tile, would lie over open space. +/obj/effect/decal/cleanable/blood/proc/hv_pool_can_overflow() + var/turf/here = loc + if(!isturf(here)) + return FALSE + for(var/direction in GLOB.alldirs) + var/turf/around = get_step(here, direction) + if(!around || isgroundlessturf(around)) + return FALSE + return TRUE + +/// Lets a big pool run onto the open tiles beside it: one at the first big size, more at the biggest. +/// Each side is tried once, so a pool does this a few times in its life and no more. +/obj/effect/decal/cleanable/blood/proc/hv_pool_run_onto_neighbours(tier) + var/turf/here = loc + if(!isturf(here)) + return + var/wanted = tier >= HV_BLOOD_POOL_BIGGEST_TIER ? HV_BLOOD_POOL_LOBES_BIGGEST : HV_BLOOD_POOL_LOBES_BIG + for(var/direction in shuffle(GLOB.cardinals)) + if(hv_pool_lobes >= wanted) + return + if(hv_pool_lobe_tried & direction) + continue + hv_pool_lobe_tried |= direction + if(hv_pool_run_onto(get_step(here, direction), direction, tier)) + hv_pool_lobes++ + +/// Whether blood could run from this pool onto the tile beside it: open floor with gravity, no wall, +/// window or closed door in the way. +/obj/effect/decal/cleanable/blood/proc/hv_pool_can_run_onto(turf/target, direction) + var/turf/here = loc + if(!isturf(here) || !target || target.z != here.z) + return FALSE + if(!isopenturf(target) || isgroundlessturf(target) || islava(target) || ischasm(target) || iswaterturf(target) || target.has_gravity() <= 0) + return FALSE + if(hv_blood_spray_blocked(target) || !here.Adjacent(target)) + return FALSE + return TRUE + +/// Runs this pool onto the tile beside it. Returns TRUE if it did. +/obj/effect/decal/cleanable/blood/proc/hv_pool_run_onto(turf/target, direction, tier) + if(!hv_pool_can_run_onto(target, direction)) + return FALSE + var/obj/effect/decal/cleanable/blood/lobe + for(var/obj/effect/decal/cleanable/blood/found in target) + if(found.type != /obj/effect/decal/cleanable/blood || found.dried || found.hv_floating || QDELETED(found)) + continue + // A pool or an earlier lobe is blood enough already; a plain splatter is stretched into this one. + if(found.hv_pool_tier || found.hv_pool_lobe_dir) + return FALSE + lobe = found + break + var/list/blood_dna = GET_ATOM_BLOOD_DNA(src) + if(lobe) + if(length(blood_dna)) + lobe.add_blood_DNA(blood_dna) + else + if(length(blood_dna)) + lobe = new /obj/effect/decal/cleanable/blood(target, null, blood_dna) + else + lobe = new /obj/effect/decal/cleanable/blood(target) + if(QDELETED(lobe)) + return FALSE + lobe.hv_pool_lobe_dir = direction + lobe.hv_pool_show("lobe[tier >= HV_BLOOD_POOL_BIGGEST_TIER ? HV_BLOOD_POOL_BIGGEST_TIER : HV_BLOOD_POOL_LOBE_TIER]_[dir2text(direction)]_[rand(1, 2)]") + return TRUE + /mob/living /// Which tile this mob was on the last time it bled, packed into one number. var/hv_blood_pool_key = 0 diff --git a/voidcrew/modules/hyperviolence/blood/blood_weightless.dm b/voidcrew/modules/hyperviolence/blood/blood_weightless.dm index e53c467e736..0cec368db28 100644 --- a/voidcrew/modules/hyperviolence/blood/blood_weightless.dm +++ b/voidcrew/modules/hyperviolence/blood/blood_weightless.dm @@ -95,7 +95,7 @@ GLOBAL_VAR(hv_blood_sweep_timer) // Starts the fade to dried again, from the full drying time. update_atom_colour() update_appearance() - hv_pool_tier = 0 + hv_pool_reset() hv_update_pool_tier() /// A slow rise and fall, a little different for every bead so they do not move together. diff --git a/voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi b/voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi index 1306debeaf182c9f0d258cd44c37bd7b0d238c68..b278093f81ba5fe7560b947990c0dfc5ae70fc75 100644 GIT binary patch literal 12241 zcmZ{~dpwi>|3B_5(fc)1m4w|UE_rB!qVRUzZH7y0AoFVcFf zYR^e%?(vOJcrdrwQ@$&p)4lt_CD)m4WMS3EdIevWFkx9ioGFS<+ai>trOQF42UK-Ez=CQ}{cA(rmeISG)yL(x9jt4dUAd;vp?zOh`n6H{ z+4YY?qnl@?3yrQ?UtALy;<0AOdG0X}hv(vw|xzVth=-T<|d zXt19NOM3CaI@&`&mGRi|)_P%86s18}l@-Wvnmzs|dDK**OG%=R_ZPTZ_;&;Jfk;3w zSi(#8Uf}@?A>sb@5!cShdEGYv06^&PBLFg^qnYfRk|b*$A#D7mo`l=L(EZ!_6eCUK z^sM063$eM(ZN%yxg};N{h`v|1MeC8;`@o0aW7A8X+1%n;9}tQ{qrr^XheaK&ZFDn9 z!z>o72&Lr60 zQ&*#D2l4e;8XM>6>O20nPdarmVL!h#WE?3HKW8m+ImK2r`u8d#1i+;wg()-~*qxC$ zC_J%r*u3_^nl}=Oto9Op%fnqV6s49_K?xeE_x@$AGKI}W%kxXU)|9yiH#D#Fbm{Eq zNNktn=kADPLUK}u9@kyb24^FF777g+>){w;*p|)y*=>TOh*prd8epZZ|y}dSG zV7)*ETop)3+cW`BUKDrqW2fyqWTqI~BcV|+W569ghk*LQz$*1%BW$Sl>?Qe&{(}Jj zM=w;cG4Fv@WN(Rjzm0YD%Z-(}ON7y?-vbD#M;GytCIY>fgI%KDGfu6vnz9tA`^?+YZ|v=58gJiN8Qew{ z?u~KO;Je<(@BWn^T6%Y4qL}9tGgSBC$lMw%E@E`q?mhT31BXSuazIScfKC!Wz2WSJ zv+Pw4r{tdU>YDuHdtz6TR8g(U1j#meSjDtUe$p!bUjBy@eZBJ534?$jTwEVq#?aBE z!%}mB&xDkrIc-$1IFFeEquLx`Xpv#FB?1@AOAZ9h#>?10(-g$RKM$Me;!-F{N zRDwhaLOY;w5P!iAJ7*2v9$zdc#3MkIKA?0C-0z$}dl;TDzq{xoZ#-%(SRQxa*-&`x_S;f zbLU=o+Q%j`elMT{7%v|U+-dwNic0D(LF9f35u0|f7zDJl(Y-bS{kjHEa^Y#8bMO0; zidKM&gyoMLOc@Jbaaoq~Dzn1qgK+Q3z@lXHX{J^)`)kh{DUH7H()rXgKkG$nbO#*4Zd#M0siw7->_jJJKp9~256ndSko z2{-Ri3&&a=qDr#%Oa5Q|O?t3# zNbz}6QVUuPkRbFuqhWunCEF8)kqMH73WNACcl6xUlt%G#*^GDZ?$hmo&P(^M2qQAR zi^m^bA{+tt!H19QEB4}^&~@j%wYTqr0gBmRLwcFSY9KHCdJhg`q9Hzytl{KOAoFfF zXcfyl9%wWY-G3^Xei-v=>xu}AKt;!vuAq&c;jJIx_)wz9i7l(k6V)#P6D9C1s=X82 zVOn|^5oaGPIazU8gZOEy_&zWdj!yg4V&vZ&J~4E{a%b`;)=t2r7pV?@XfBK|MR*e{ zR`dLz<7I*MCg3M|gxt|rJlIC(&`Un7LIdidshdz&SOJ+c9f1_PIM)D}>Q|j%qhxxG3OqW8+dNzvLXcJ=TulGzk*+F;W(X-s-`sB_c=o_~GcdR)4cp z(A@U=&5@lmlS&H8$;RO-QE4Wo5jxS z>Zh8Eyx_jCPG4ZvPSEV(Mn!;^Pi;&WqWXp;Y^AME{pw)^@VT&&S@WFg9%JAmhE9E| zDUwBE2n`GSmpKfdO_Kxze6%+xm!tj&N z0K-EFdqwneS3x|Y#!3RC+?f6@cEm%dcvYQF2&wn}`N4s5Q@RE51Y7L(k08?~ApB#p zaS4DV!m5PfDY&>+;K#~bK+QklQcDt=m7{0SKUP>21%KYjX?E&xCI z5)h;MlyYhnB2RD6J5AE4K3}dqGd*oFEzHYJ`%^SMK%e7AV|c(%KF4=YdRD(Ht}k?l zC!E_Lf@;r0i=LEeztvB&+|B$b8wDlg@fDFNC~*vle$-%VUJ{Cp2g7uwZ3x^*%2lW9tU_tSp7ykQ-T z$g>jqB9qoeeX@0$b?EkXm6(X?QR4aEY5%r#o{FVymlre^yt~wfDai<7RBBdZ=&OZ? z&X$eY~ES_D5d&{XvzvF1K0;^=LLT|d1mmRbj z_%RPScF29~5<(ppa^%~!{-Ek(WVv;ic5{k8he1!P9Q`Le+z85No^xi!o1)&&MU~F- zu4BC>s@6lK0{97a8;y3ckYF;!?3x7)TO~1G5`Wgr%C**uWlS9w=X5lHFT&HxTdPm+ ze8l$~2C`=8u~bAX()Y2MeT1P{y-W7~f_n4WwZG_;#z^}y>tScxG?Gamk-TXh>mUl+ zI0hqa_(^t=Zb{kT-=|TmwuRL*`AZjn{6(=q0^&(b*V{Z4akt1HL_Ek%P*UYjzlI6? zh2Np{|4uvQ#?@8l_pVQo5CZ^6RaI4+@MI%IU%qDk(Ie|P6dFV*!>Q=}4cqxB=cJys zL!Sc_eq+zyn4O;X>u6VYko*4N^iAzv0)LP*j)cr>;meB`+_yhHZ6|v)eB+-{Au9fBf*h2B=nfU}Z8nS$uC_a|^W#@iHIFJwg5jIk@A}&LJ_4GKdlq6*%k9dtb-vfV6#O=i zkdQP%dcj%@UIHM6?hpijOe1i##RKJA=M~V<{(;tuRD?eYZlZNs|D9~K2 z=Ok6AgL>hL&fpW&)zgS6W(-0NP=VJJVidqB#wwfr)=!K6}p*EpNPqSy5P6IBF0V%wswMfu&dH5UvP~M4Tz3f0#8T zj%p-HvXba;i1=}bp#7W>zm#4w=(h3jn^cE4v=rCUp&FWzo}Qj0oSJTHX-c;wWOt(A zDM;CQQVgS?uvQq$z-LEL8Zzh*I#o$Vz*gq0*-k~z9pYaIJ>Dko0(>CSBpB_1)5n<~ zx2rru$OGX~u_|Oz=J)p1H0jTbvcBbW&?1m#VBtcFJvb%$yur|Ow5<JT{51lp2M41LhujW$7XiPEi_#G_IQ^2aR*hX4mY08LhA`3x8-x)sbe#x< z+i4OJI*C^+I5xXv)C7e-o``$uCKmTOKGe?fwVEMGQTvV#?)T- zNWi2)KbS9-aS`)D~LJdY*rSFDY zn^T`*a_y~iN*|g<;>_zkRu20UOQoNPQIAaF2818j69O+W?Wl`0aBqrTH>lXE%ECa5 z5%CmJ@K`q}TYP`5^h&{jSOpO-*OVdni_YGm44Z@#?r=2dBh$6S#E9VJ4ts)AkzAu0 z%k|zuJx!9ufyRKPHgh)A(EqZn_xHp!Rlr|K2R1V_WsUbo;zZdmBRP+dFiMRlyWyps z@JdZe#H)W;(4H!=UJ{%Vrf{b8)mje3J<*jhGGPa+uGr}4nl}`_6;#?svB|^dyaBJl zjh+Gale8?66V_lmDQZTQ&ZcR-?=~y}jy)6gBZe)dtQlts5?(*Zar97My-hG~^1hFL zgi5M`L!nE464Bqffl^GP!eC8zP%x7BJWy#R?CPjE&?109AhPv@=qD*Po7f?ocl?Z3 zt2;Uol!oM$7eIkoE3mNZg%uT6vDetqbOJt1@slmt6xhbc}ebTN_CZtYwGhNbbm-s9q1{VX#T##-s0g4F7 zrx>*8ba+0#bXUmi^Y^iV+s&KcvIHj!rW4~@#)@ve-dn6U`~tKssw^3JI0~h?b|~pv z{NPI{9eG*Oog;Bs-6vpk$Z4l-z+PH%Ka$@aef1)Ti*O!M3LWYQ^Kdyint=|f(+0o=%5rb0(l6Hai9S6jHAy~KW!<9()tqzY6&cWq99$*@Yl&JIGg)$* zBEBq}!B%Sc*cb6e%3r*v=|D01P()!PEm=4hbS*{e2tJaS$ru0$aAgMsU6xvSs6cE{ zeiTC5RwoihN==Kq)*2yxkD>w4#;&b=x_89GF> z;?_z-roHF9ar6uf$4`H2ZYQ>YUv9wPy!bqf>ctG@2llMk&631XCRbQ??%aEV>`lv; z&mlb-(1ZWU*133WcpF>PyVhh}1T{rDF37qvJlcuMB0W*TXuSdRQfztQj91&Az5zs& z(F;tPC0q;u6-mJAeiuUs{phW%D}|Ykj~$dc)gHlR0!}a1>bw&x=N?^ERxtg0YMTP+ z28Em#`e3~Jd-5a)INoGcd{3F%RZ_gJj zqS7Chj>p|phs*Zk;#%+z2$!OeFn4qYx3eo8-%`Xf5#RjDd6kh&4Q*w%`fWTKM^>dj zpcOh;sN)O@mkkDHbRnWL95Rrl7XLOhjTghL%J;%cGj!aNrRnFcuxxscxzbwpo<|pd z+apiR7*Ms-8gD8TT0^{%w2i0~vho^KW!W;gRHlBZ>1mnri009i?wD_7=C!Jekv=Fb z5!8G6>NJM4HCBNqln*f1jTCc3Q}ee~itLX>KAJ;BamZT(7&FaDV|bIRN0*r;mlm1B@?O0JmUQBb^FB__ zU0gTo^(9s~OE{xhh%*xTc7`Dw(ZtgFHmJssThJ4z4agx>T&_NH(h_5GW!q!_8Yl1! zt2SC4Mux#;A8IS0bpcUY;C$h|8EmrZ_|9DnX9fvWzkVBrclCLF9D?5ZDEvA*##eQX`!GN=u^xAI6#QkLtx|sT`%hioAEa%x#xDG%^7ja7`J}`E9R2)s5Sl>96SvwHIcO zfmtUi!q`Cmcng{QIy53e&Q9{|b%&UmJ6aZ!`zXh!Q7gChSJ=gtBt~cJy(KyoFb~Lx z0QIoK)d0Ie@CwFlOGPkyNn}LVerFWEV+|{~HvC9pyp}yU=yDas)^0A0f`e!NgAx8J zvP$%vELhGH)s8{fcK6P7DZ2A8-FII0+Jut^O)q}>KI*Ci_MxJYTd7O%tM7M}u9$LO zd?f>o-ezbr4IPA;<&;=}s*7sPTk)QG&hba#vNNqoWkx8bo91Bi%8%Bw2a9{DlCrzD z;r9#B`-fO|yR#~zkPqgP<)kWN;@>is=kqN~v4^vlcNS)?Syk|$s_r_8 zD#xDJ?NY~?;HpuLfD|@?#QH2m^f=UT}CUzE_V)gJd}{M z=HGni^BRFByiPpl_S%fMEf`R0C(}|kHy6Vz;#bo~RqSm=#2-Ka@j%wJvmv+vfN%-~ z0lD<^0+)V5WcT7DqnWo2IpVY4$hQLTFrTyv&hGgAW^4vx8XkXVP3OCY0?Uu_N@FE{ zElsaz@3a`A9>xVc!iYy8OzvEnPU4xz+ZAJ_!UA2%vN4E_kl68?{E?`C70;~n*z_yb z>+~D?ogP~Bo0z@Lp_KMbA2A81B{crfTC@b@MiqsoEs3=z?J*C%|CeksSX%u$zv36erFQB_6>M@r?}Z~e>DSy6OU-FdA>^lawG zjQ=fZL11Y)`XArTaa8{#oPEc#6!5@6^Bj6Uh;`9-_Gm!B46xf;j^zNEe(BMK^E8yDe~ z{->wxxH?&bFxC|APfT>@$dbK%`%>H8Tkmv#;QS5oMzuSR|MqjBXKQC@9uR3;&|Dcb zcmdf}cQHboHQf}w4QA4nQXL`pPnx90t}7z$+kvqclm}dvV>m>|HzSBM$dT0zWdBqX zX&YVRmw8AMXq%XjAw~ORNG13(TMpu6+AWT0eJqPw zz7&m+0o4%-Q`}Q5Ka+-AEKft$to8l+Rl6&or_X&zcj*~Cf}&EPxzao-M38LtKK|qw zS1tj%Nykv{wC(mihcLC)&o%IciYD3rJUG;>{9Yg|)^Q6Tib~ae{1B3xUk1WWlS^sI z1r`^2sNT?p)t(C6!CCCUsxipfix6fLeTD~aibkZrK9~t< zFK1B92L~q1)@ltTUrAicL=%j=VF{2k0!zdhC4m=%XLjtmr*zHaY1bEHVagc-LfJy~ z3q*L2!L=E64-mhWwh7g82+LD}Nk;$h0|p!y>d|LA*iP-y>5ju!aLZ3Vn{>ly2YLrb zYUk#TLb46yu*lc)t_rvELhMqSK@^KlJgpy%p;8MUP)&l;Ign#SJ4oAg&}FcoKk;!n zSz3NZiSljmj?h;0s|9AF#W{TtIpGw;ES@5(9vn6eW3B6O&0zw@pV399j50aOs1}xQ z1m~?gi?|xn%2JI1dt`*C(Blgd2c!RY{4?$(HLS%XGpxF|1^)oeGhV3{tRDK_U~I|=TXhnO@L_w?LScZWJKcln`+oGS&G26Ao)Mq(Z0itI8#|muW}aH42;5gNU0KEDQ=v{Wp}p2$t_t0E7nJ z@8KSh3)B0p5|EG1p(Q?Xj>M!%kW@uz4`oro%W|enR$6Jp*kLlDRCv_9OWNcJNQ8ft zKB4pD$omeNmSB|;4UsO3UG>mps@oxF`5PXqTOTl=14g*az2HJG6Xs1fc7 zYp`;?A-NKkDk?3jKodgnCk)|Yis6~ZM|nBgzR zru0t_kxw8J?#1eWbS+A8G#Rq#`Y9~vDk`#QlAI`(%@fxzr*^~aI8eoc`8{So5q`CA zZ#t?X4Vl*nSx$E3R(eJs?k;OBGc%mm0yazPgism-M`y+0`OuDX3TlITb9XPFL$HXu z&3|-n-~P?q_R^ijoP4wWPms_}-U}Zs-V^fxF&r0u4t*hxD_3na1Zx^`c-@}OWJ!99 z?mC@PBQ7~UTOf@@q!G>7{~_<{7Ck@=hVcsquE^9q;smaq6I6= z)+~5*AS%##Vhz2Mw>D<^`ZdEI7QO@a20^g1#N+YvkE8!bvBVqC6Z|L%iVqLlXeG3a zN-8bY`{f+DtCoY}0TiErIZu$#oY0zF*QSne5NBNWe&)vPnt9FR2UMRM5IjZrek9z2 z%$aYEqGYtS`)3vecF8>x9 zKn1wlZuwHl#__1wU?nmR@?ZQ~WzOlMvKk0I$%#U1g1d#(PTA}n2>tTb zW#L|EXy^%s*{-R;*$=mP*n}ADBb*YWzbK;nGZ)F=2*|v9<~2!*V_z)XtDoC43_n2f zA0iQQ5n8v(K_N`F8E_oYh!0jBF@8h66e$@Y1pl?m!IOoYsx{bKM=m@~UflpFeg%6< z6lmDv98Fo#6h8j(BJ-{i3#U~c6h(3p(qYcRZ@mG=1tri!36HSqYs_ zcvm5h^iveB{Imu%xWE-iHjG}~VQzXqJ*$;F(?OI!Lij#7PM3mkn)hFd;1oMDCxLMj zgR(AO7A%RSt1;VL%$1?Ac$N;;QJDXC9R=P95c-A;zWS^*QUU6u;Q!xH0Xn^lZMU{y zP2e?My#xDN2k?-tlt`Ilv)yD5%#!tVWaWF55%i=3`zU9!U!$e4mC(}tt`b7dfL}3f zLsPCg>FtmKr9v4N;KTohh7mS@jTN)mwLO$#eLS?Lkw~sGCX7G+^Nkspf5>DgwYO~F z4#-p)Mj6x#V=t^rO{n6gOX5tfymo>sul=nrM?s}W1M{4hZN@w%}N!5h8S zga!|Oe;!DZG8Y$Hh=cvdY|(4_w<$3NGFvc5?&_Y-6Z`^cOC1V;|3_qUR{VG!P0siZvqm0SbRfaCK3@jQQk2#ohCt@ zO7W{PMXU4Wy8bmxZ0C?M)M14q&_>yl{jGhf$4m$N+-gB(z(A`1462TL$_2aAbbW=5FrKUKFNm(ID!xp)oYNSt3y0pVDiqToDD^4r zhJ0R+O3Tx%DZ=s$?H~@S%Hp;-$3+fUX!P40I~(LEr)WCf7Ba}k@q>#$15a6Q8APi> z&s^a5sEo3C@4jc?;!)`y2xPXug9IUd=!Mq^YKKE^{&hG{%q^)G%-wNb6+lrmP(LXC zg*@F@*KfEMg?kjP>}b#3!@k+yS8{=@w_8^Jt1^O_bU=ygJPUn83jrFav)KJg12{f_ zkCpwsjs+bG$=7Nd_Z6|f?si;vKYd`pcx_7zef5{I?-SUZ>Rnv!ymwU7z!d8j_=dX` zDS{pcIX2gCq=tJ3LPVq)|0D`IGC11ijl7XXtzsOq!5**yA3}hvLXYR&1eLTI`q4g$ zklGB*SwYEbc5d$OeFGMa*3`{+R4ei8z@6zTgLcTkEots)N*)hg(F1hgpkMru$)PJJ z!A-2SE7`)mkTrGwr`2vYcSXb_U$$pfW(vzG3^2PwC>6VJEajs4+|LrUT>Sn~XIoL* ze#R9Mou-%&a)4iIl0UdnsN*B=29>=p$7~kM?dhLxNk>v$Xa{CQiw+~aXGXImzjDvr zcOE*5)$Q4YseEzH4d*D|bsyFJ1sTBa{2aUj_s&edh-<0elvFEP1P_@9<;{D z#oBIRN-;Fel>_{MVjoVJYV#Ug#$!(lUeMoL2D#LF2d5>>K33caPcB!UX=gONm{&oa zm%nx&)JK)rAfTRu_zAm}vn&F{&%NtNZ&|usX_ab=(W-aH=KFsG_fU%=!HP7M78HHT zocr{rEe>Dw6n2Gv+|l_WK8xUDN% z_H5(=Sew&ACu(mdQXE%Ctt1Be>ky^~(Q6{6qLR#vUw$W9FN=yhA6a3m-G;^1l z-PF&qdI&gwia~CH&>4w9li;{%6ZlsvvWo*!A_DA=`75Jsr@>##zn4jdZ%^^b-r3uA z?__Q+Y<0UT_g#1UsYTQ0=BRhc<5Aksmh2MIK5C-0)$%F-JUf;OCa?|WToQLB+edEXcrlIg-@Bosvzv&*w zfB$~ppam5kK0XP_N-ajkmVO)Sg)qZy#!nav6HY}u4pz)$0v<(#pxL$ic@3*}sQ1X} z9~6*5QhgJxKQ!wTX}NzXNYzyyo;PFSTl;L-#{pxZ9i^&6**uZWPsi-OrjveX=>)h5 z^^Pucb+xZJ;x2HKJIxPLDW#jwl;m6>pUx=I!=%wI`wma5K@wSZ65a3vrwNKoo%b_@ zW@%<0AwL-^aMdKt&9y1eKrhDzz&HO?{T7BL!T(1?v?Qj<+kKD>RF1N&&vm)Hoz|CGa_@4bR zvc*{7%U}98+?_V%c1D8Imclun22+ucO`_u$7K#r@{0aZOV!Uq?EsN4JD`)d{41+(W zEtd#FX&aFYbydczKT@wbs<*{=&vTJ0d{9>Iph73J-YjxylJbY+7xs=HvF+Pb{GSoD z7_Qf#{9-2BHCq_ACS$zpAsbcnco*%M0?7Km!Z1`3f(BCz;`KQs$_4#QGr34A9ahy~ zGcw2zy_Gz1YfUMCIvpl=@K0ZBm(ZRe|CT(4NR~JI_|^sd zg$sXz)QopldVczqi`)hU4jzjx7j5*283B26CoQ^)D>Q#WHTpa@7WPL=%srz2A61QY AqyPW_ literal 3386 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 Qc>n+a07*qoM6N<$g44JwLjV8( diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_blood.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_blood.dm index 76707cafcce..e6417e62abc 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_blood.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_blood.dm @@ -13,6 +13,14 @@ for(var/tier in 1 to length(GLOB.hv_blood_pool_thresholds)) for(var/variant in 1 to 3) TEST_ASSERT("pool[tier]_[variant]" in pool_states, "blood_pool.dmi has no pool[tier]_[variant] state") + // the pictures are 64x64, drawn centred on their tile, and the ends of a big pool running onto the next tile come with them + var/icon/pool_icon = icon('voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi') + TEST_ASSERT_EQUAL(pool_icon.Width(), 64, "The pool art is not 64 pixels wide") + TEST_ASSERT_EQUAL(pool_icon.Height(), 64, "The pool art is not 64 pixels tall") + for(var/lobe_tier in list(3, 4)) + for(var/lobe_dir in list("north", "south", "east", "west")) + for(var/variant in 1 to 2) + TEST_ASSERT("lobe[lobe_tier]_[lobe_dir]_[variant]" in pool_states, "blood_pool.dmi has no lobe[lobe_tier]_[lobe_dir]_[variant] state") var/list/float_states = icon_states('voidcrew/modules/hyperviolence/blood/icons/blood_weightless.dmi') for(var/variant in 1 to 12) TEST_ASSERT("float[variant]" in float_states, "blood_weightless.dmi has no float[variant] state") @@ -128,7 +136,11 @@ expected_tier++ TEST_ASSERT_EQUAL(pool.hv_pool_tier, expected_tier, "Bloodiness [pool.bloodiness] did not make a tier [expected_tier] pool") TEST_ASSERT_EQUAL(pool.icon, 'voidcrew/modules/hyperviolence/blood/icons/blood_pool.dmi', "A tier [expected_tier] pool does not use the pool art") - TEST_ASSERT(findtext(pool.icon_state, "pool[expected_tier]_") == 1, "A tier [expected_tier] pool shows [pool.icon_state]") + // next to open space a pool keeps to a size that fits its tile + var/shown_tier = (expected_tier > 2 && !pool.hv_pool_can_overflow()) ? 2 : expected_tier + TEST_ASSERT(findtext(pool.icon_state, "pool[shown_tier]_") == 1, "A tier [expected_tier] pool shows [pool.icon_state]") + TEST_ASSERT_EQUAL(pool.pixel_x, -16, "A tier [expected_tier] pool is not drawn centred on its tile") + TEST_ASSERT_EQUAL(pool.pixel_y, -16, "A tier [expected_tier] pool is not drawn centred on its tile") pool.adjust_bloodiness(-150) TEST_ASSERT_EQUAL(pool.hv_pool_tier, 4, "A pool shrank when it lost blood") var/pools = 0 @@ -136,6 +148,86 @@ pools++ TEST_ASSERT_EQUAL(pools, 1, "The pool is not the only blood on its tile") +/// A big pool runs onto the open tiles beside it: one at the first big size, more at the biggest, never through a wall or +/// a window, one decal per tile, each carrying the blood and each a decal of its own that cleaning takes up. +/datum/unit_test/hv_blood_pool_lobes/Run() + // the bottom-left tile has a wall to its west and south, so only north and east are open + var/turf/spot = run_loc_floor_bottom_left + var/turf/north = get_step(spot, NORTH) + var/turf/east = get_step(spot, EAST) + var/obj/effect/decal/cleanable/blood/pool = allocate(/obj/effect/decal/cleanable/blood, spot) + TEST_ASSERT(pool.hv_pool_can_overflow(), "Test fixture: the test room's corner tile is next to open space") + + pool.adjust_bloodiness(150) + TEST_ASSERT_EQUAL(pool.hv_pool_tier, 3, "Test fixture: 200 blood is not a tier 3 pool") + TEST_ASSERT_EQUAL(pool.hv_pool_lobes, 1, "A pool of the first big size did not run onto exactly one tile") + var/lobe_count = 0 + for(var/direction in list(NORTH, EAST)) + var/turf/beside = get_step(spot, direction) + var/obj/effect/decal/cleanable/blood/lobe = locate() in beside + if(!lobe) + continue + lobe_count++ + TEST_ASSERT_EQUAL(lobe.hv_pool_lobe_dir, direction, "A lobe does not know which way the blood flowed") + TEST_ASSERT(findtext(lobe.icon_state, "lobe3_[dir2text(direction)]_") == 1, "A lobe from a tier 3 pool shows [lobe.icon_state]") + TEST_ASSERT_EQUAL(lobe.pixel_x, -16, "A lobe is not drawn centred on its tile") + TEST_ASSERT(GET_ATOM_BLOOD_DNA_LENGTH(lobe) > 0, "A lobe carries no blood") + TEST_ASSERT(HAS_TRAIT(lobe, TRAIT_MOPABLE), "A lobe cannot be mopped up") + TEST_ASSERT_EQUAL(lobe.hv_pool_lobes, 0, "A lobe ran onto tiles of its own") + var/decals = 0 + for(var/obj/effect/decal/cleanable/blood/found in beside) + decals++ + TEST_ASSERT_EQUAL(decals, 1, "A lobe stacked decals on its tile") + TEST_ASSERT_EQUAL(lobe_count, 1, "The pool ran onto [lobe_count] open tiles, not one") + + // the biggest size runs onto the rest of the open sides, and no more however much more blood comes + pool.adjust_bloodiness(70) + TEST_ASSERT_EQUAL(pool.hv_pool_tier, 4, "Test fixture: 270 blood is not a tier 4 pool") + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood) in north, "The biggest pool did not run north") + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood) in east, "The biggest pool did not run east") + TEST_ASSERT_EQUAL(pool.hv_pool_lobes, 2, "The pool ran onto something besides its two open sides") + pool.adjust_bloodiness(25) + TEST_ASSERT_EQUAL(pool.hv_pool_lobes, 2, "More blood ran the pool onto more tiles") + var/pools_here = 0 + for(var/obj/effect/decal/cleanable/blood/found in spot) + pools_here++ + TEST_ASSERT_EQUAL(pools_here, 1, "The pool left more than one decal on its own tile") + +/// Blood does not run through a window, or out of a splatter that has dried. +/datum/unit_test/hv_blood_pool_lobes_blocked/Run() + var/turf/spot = run_loc_floor_bottom_left + var/turf/north = get_step(spot, NORTH) + allocate(/obj/structure/window/fulltile, north) + var/obj/effect/decal/cleanable/blood/pool = allocate(/obj/effect/decal/cleanable/blood, spot) + pool.adjust_bloodiness(220) + TEST_ASSERT_NULL(locate(/obj/effect/decal/cleanable/blood) in north, "A pool ran through a window") + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood) in get_step(spot, EAST), "A pool did not run onto the open tile beside it") + TEST_ASSERT_EQUAL(pool.hv_pool_lobes, 1, "A pool ran onto a blocked tile") + + var/turf/other = get_step(get_step(spot, EAST), NORTH) + var/obj/effect/decal/cleanable/blood/dry_pool = allocate(/obj/effect/decal/cleanable/blood, other) + dry_pool.dry() + dry_pool.adjust_bloodiness(220) + TEST_ASSERT_EQUAL(dry_pool.hv_pool_tier, 0, "A dried splatter grew into a pool") + TEST_ASSERT_EQUAL(dry_pool.hv_pool_lobes, 0, "A dried splatter ran onto other tiles") + +/// A plain splatter already lying on the tile is stretched into the lobe rather than doubled up; a pool there stays as it is. +/datum/unit_test/hv_blood_pool_lobes_existing/Run() + var/turf/spot = run_loc_floor_bottom_left + var/turf/east = get_step(spot, EAST) + var/turf/north = get_step(spot, NORTH) + var/obj/effect/decal/cleanable/blood/splatter = allocate(/obj/effect/decal/cleanable/blood, east) + var/obj/effect/decal/cleanable/blood/neighbour_pool = allocate(/obj/effect/decal/cleanable/blood, north) + neighbour_pool.adjust_bloodiness(45) + TEST_ASSERT_EQUAL(neighbour_pool.hv_pool_tier, 1, "Test fixture: 95 blood is not a tier 1 pool") + var/obj/effect/decal/cleanable/blood/pool = allocate(/obj/effect/decal/cleanable/blood, spot) + pool.adjust_bloodiness(270) + TEST_ASSERT_EQUAL(splatter.hv_pool_lobe_dir, EAST, "A splatter beside a big pool was not stretched into it") + TEST_ASSERT(!QDELETED(splatter), "The splatter was replaced instead of stretched") + TEST_ASSERT_EQUAL(neighbour_pool.hv_pool_lobe_dir, 0, "A pool beside a bigger pool became a lobe") + TEST_ASSERT_EQUAL(neighbour_pool.hv_pool_tier, 1, "A pool beside a bigger pool changed size") + TEST_ASSERT_EQUAL(pool.hv_pool_lobes, 1, "A pool counted a tile it did not run onto") + /// A bleeder who stays put adds to the splatter under them; one who moves does not. /datum/unit_test/hv_blood_pool_feed/Run() var/turf/spot = run_loc_floor_bottom_left From 9a7e05dff6e8baf003130164b044c85c134f4bbf Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:36:11 -0800 Subject: [PATCH 031/472] Carry NPC fights onto their bodies, add NPC deathgasps and head-aimed executions Human-looking NPCs that leave a human corpse (troopers, pirates, mafia, hermits and the rest) now hand the fight to it: damage by body zone, blood on the clothes over the hurt places, a pool, the killing blow struck again through tg's wound and dismember rolls (a katana kill can take the head, a heavy crushing or piercing one can burst it), the outcome of an execution, and their crit injuries as wounds and missing organs. The NPC gives the player's deathgasp with its corpse's sex and death sound, and the corpse shudders upright before it falls. NPCs that keep their own body (bounty criminals, townsfolk, prisoners, survivors) are left as they are for the systems that rely on them; their body shows the gore instead: a skull wound, a head blown off with gibs, a pool, the deathgasp and a shudder. Reviving one clears it. A point-blank shot with the head selected now executes a helpless target, as the mouth already did, and humanoid NPCs on their last legs count as helpless. --- code/modules/projectiles/gun.dm | 5 + tgstation.dme | 4 + voidcrew/_DEFINES/hyperviolence.dm | 17 ++ .../corpses/icons/npc_body_gore.dmi | Bin 0 -> 233 bytes .../hyperviolence/corpses/npc_body_gore.dm | 180 +++++++++++++ .../hyperviolence/corpses/npc_corpse_gore.dm | 191 ++++++++++++++ .../corpses/npc_corpse_handoff.dm | 130 ++++++++++ .../hyperviolence/corpses/npc_wounds.dm | 137 ++++++++++ .../hyperviolence/overlays_exec/executions.dm | 75 ++++-- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../voidcrew_hyperviolence_corpses.dm | 244 ++++++++++++++++++ 11 files changed, 969 insertions(+), 15 deletions(-) create mode 100644 voidcrew/modules/hyperviolence/corpses/icons/npc_body_gore.dmi create mode 100644 voidcrew/modules/hyperviolence/corpses/npc_body_gore.dm create mode 100644 voidcrew/modules/hyperviolence/corpses/npc_corpse_gore.dm create mode 100644 voidcrew/modules/hyperviolence/corpses/npc_corpse_handoff.dm create mode 100644 voidcrew/modules/hyperviolence/corpses/npc_wounds.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm diff --git a/code/modules/projectiles/gun.dm b/code/modules/projectiles/gun.dm index 5d65e684704..25e62df0950 100644 --- a/code/modules/projectiles/gun.dm +++ b/code/modules/projectiles/gun.dm @@ -376,6 +376,11 @@ handle_suicide(user, target, params) return + // VOIDCREW EDIT ADDITION START: a point-blank head shot at someone helpless is an execution (voidcrew/modules/hyperviolence/overlays_exec/executions.dm) + if(flag && hv_try_head_execution(user, target, params)) + return + // VOIDCREW EDIT ADDITION END + if(!can_shoot()) //Just because you can pull the trigger doesn't mean it can shoot. shoot_with_empty_chamber(user) return diff --git a/tgstation.dme b/tgstation.dme index 273bebd829f..aee2dba253a 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7239,6 +7239,10 @@ #include "voidcrew\modules\hyperviolence\braingut\gut_spill.dm" #include "voidcrew\modules\hyperviolence\braingut\gut_tether.dm" #include "voidcrew\modules\hyperviolence\braingut\intestines.dm" +#include "voidcrew\modules\hyperviolence\corpses\npc_body_gore.dm" +#include "voidcrew\modules\hyperviolence\corpses\npc_corpse_gore.dm" +#include "voidcrew\modules\hyperviolence\corpses\npc_corpse_handoff.dm" +#include "voidcrew\modules\hyperviolence\corpses\npc_wounds.dm" #include "voidcrew\modules\hyperviolence\crits\crit_basic.dm" #include "voidcrew\modules\hyperviolence\crits\crit_roll.dm" #include "voidcrew\modules\hyperviolence\crits\crit_types.dm" diff --git a/voidcrew/_DEFINES/hyperviolence.dm b/voidcrew/_DEFINES/hyperviolence.dm index a9c53ad033d..04e97e37c52 100644 --- a/voidcrew/_DEFINES/hyperviolence.dm +++ b/voidcrew/_DEFINES/hyperviolence.dm @@ -42,6 +42,23 @@ #define HV_EXECUTION_MAX_HEALTH 200 /// do_after interaction key for an execution wind-up. #define DOAFTER_SOURCE_HV_EXECUTION "hv_execution" +/// A humanoid NPC (basic mob) at or below this share of its max health is staggering, and can be executed. +#define HV_EXECUTION_STAGGER_HEALTH 0.2 + +// ---- NPC corpses (corpses/): the fight carried onto the human body an NPC leaves ---- + +/// A killing blow that cuts must deal at least this much to roll for taking the limb off the corpse. +#define HV_CORPSE_EDGED_SEVER_DAMAGE 20 +/// A killing blow that crushes or pierces must deal at least this much to roll for taking the limb off. +#define HV_CORPSE_HEAVY_SEVER_DAMAGE 30 +/// A crushing or piercing killing blow to the head this big always bursts it. +#define HV_CORPSE_HEAD_BURST_DAMAGE 60 +/// A body zone that took this share of the NPC's max health in brute gets the clothes over it bloodied. +#define HV_CORPSE_BLOOD_ZONE_SHARE 0.15 +/// Brute damage (share of max health) at which the corpse's hands are bloody and it lies in a pool. +#define HV_CORPSE_BLOOD_HANDS_SHARE 0.5 +/// How long a new corpse convulses on its feet before it falls. +#define HV_DEATH_THROES_TIME (0.6 SECONDS) // Finishers (voidcrew/modules/hyperviolence/finishers/): a boss kills someone who is down, in stages. diff --git a/voidcrew/modules/hyperviolence/corpses/icons/npc_body_gore.dmi b/voidcrew/modules/hyperviolence/corpses/icons/npc_body_gore.dmi new file mode 100644 index 0000000000000000000000000000000000000000..d70a040b8187d855dc4f459193e4c9ec72ebe1fa GIT binary patch literal 233 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJ!m5yn5|`BC)e_1!5cyiE*d|0q;uX!^CUx2Pwx&3;~?Y9#$F0@9!)wj zDMTSyQ`yu|RekFY3p?|T7SWyE#;TiFNND7pI65= HV_EXECUTION_BURST_DAMAGE) + hv_burst_body_head(wounds.execution_dir, !wounds.execution_said) + else + if(!wounds.execution_said) + hv_add_hit_line("The shot blows out the back of [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) + hv_body_skull = 3 + else if(hv_body_blow_bursts_head(wounds, head_damage)) + hv_burst_body_head(wounds.last_dir) + if(!hv_body_headless && !hv_body_skull && wounds.max_head_hit * scale >= HV_SKULL_OPEN_HIT) + hv_body_skull = hv_skull_level_for(head_damage) + var/beaten = counterlist_sum(wounds.brute_by_zone) / max(maxHealth, 1) + if(beaten >= HV_CORPSE_BLOOD_HANDS_SHARE && can_bleed()) + add_splatter_floor(get_turf(src)) + hv_death_throes() + update_appearance(UPDATE_OVERLAYS) + +/// Whether the killing blow bursts the head, by the same rule a corpse gets (npc_corpse_gore.dm): +/// a huge crushing or piercing blow always does; a heavy one gets tg's dismember odds on a head +/// already half gone. +/mob/living/proc/hv_body_blow_bursts_head(datum/hv_npc_wounds/wounds, head_damage) + if(!wounds.has_killing_blow() || wounds.last_zone != BODY_ZONE_HEAD || wounds.last_type != BRUTE) + return FALSE + if(wounds.last_wound_bonus == CANT_WOUND || hv_crit_wounding_type(wounds.last_sharpness) == WOUND_SLASH) + return FALSE + var/blow = wounds.last_damage + if(blow >= HV_CORPSE_HEAD_BURST_DAMAGE) + return TRUE + if(blow < HV_CORPSE_HEAVY_SEVER_DAMAGE || head_damage < LIMB_MAX_HP_CORE * HV_HEAD_BURST_MIN_HEAD_DAMAGE) + return FALSE + return prob(blow + head_damage / LIMB_MAX_HP_CORE * 50) + +/// The skull wound level (0 to 3) a person's head with this much brute damage shows. +/proc/hv_skull_level_for(head_brute) + if(head_brute >= HV_SKULL_HEAVY_BRUTE) + return 3 + if(head_brute >= HV_SKULL_MEDIUM_BRUTE) + return 2 + if(head_brute >= HV_SKULL_LIGHT_BRUTE) + return 1 + return 0 + +/// Whether this body still has a head that could burst. +/mob/living/proc/hv_body_head_can_burst() + return hv_gory_own_body && !hv_body_headless && hv_looks_human() + +/** + * Blows this body's head off: gibs and blood along `burst_dir`, the head cut out of the sprite and a + * neck stump drawn in. `announce` adds the line. Returns TRUE if the head burst. + */ +/mob/living/proc/hv_burst_body_head(burst_dir, announce = TRUE) + if(!hv_body_head_can_burst()) + return FALSE + if(!burst_dir) + burst_dir = pick(GLOB.alldirs) + hv_body_headless = TRUE + hv_body_skull = 0 + add_filter(HV_BODY_HEADLESS_FILTER, 1, alpha_mask_filter(icon = icon(HV_BODY_GORE_ICON, "headless_mask"))) + var/turf/here = get_turf(src) + if(can_bleed()) + hv_blood_spray(burst_dir, 4) + if(isturf(here)) + hv_spawn_head_gibs(here, burst_dir, get_blood_dna_list()) + hv_head_burst_splatter(here, burst_dir, get_bloodtype()?.get_color()) + if(isturf(here)) + playsound(here, 'sound/effects/wounds/splatter.ogg', 80, TRUE) + if(announce) + hv_add_hit_line(pick("[src]'s head bursts apart!", "[src]'s skull bursts open!"), HV_LINE_HUGE, range = DEFAULT_MESSAGE_RANGE) + update_appearance(UPDATE_OVERLAYS) + return TRUE + +/// The neck stump or skull wound drawn on this body, tinted to its blood. +/mob/living/proc/hv_body_gore_images() + var/blood_color = can_bleed() ? (get_bloodtype()?.get_color() || BLOOD_COLOR_RED) : null + if(hv_body_headless) + return hv_gore_image_pair("stump_head", HV_STUMP_FRESH_LAYER, blood_color) + if(hv_body_skull) + return hv_gore_image_pair(hv_skull_wound_state(hv_body_skull), HV_SKULL_WOUND_LAYER, blood_color) + return list() + +/// Back to an unhurt look, for a body brought back to life. +/mob/living/proc/hv_clear_body_gore() + if(!hv_body_headless && !hv_body_skull) + return + hv_body_headless = FALSE + hv_body_skull = 0 + remove_filter(HV_BODY_HEADLESS_FILTER) + update_appearance(UPDATE_OVERLAYS) + +#undef HV_BODY_GORE_ICON +#undef HV_BODY_HEADLESS_FILTER diff --git a/voidcrew/modules/hyperviolence/corpses/npc_corpse_gore.dm b/voidcrew/modules/hyperviolence/corpses/npc_corpse_gore.dm new file mode 100644 index 00000000000..42116844b1a --- /dev/null +++ b/voidcrew/modules/hyperviolence/corpses/npc_corpse_gore.dm @@ -0,0 +1,191 @@ +// Hyperviolence: putting an NPC's fight onto the human corpse it leaves (see npc_wounds.dm). +// +// In order: +// 1. The damage it took, by body zone, scaled from the NPC's health to the corpse's, goes on the +// matching bodyparts with no wound roll, so the fight itself never rolls a random dismemberment. +// Damage nothing noted (fire, vacuum, bleeding out) is spread over the body, as tg always did. +// 2. The killing blow is struck again on its bodypart at its real force, through tg's own wound roll +// (a chest cut can spill the guts, gut_spill.dm). A heavy enough blow then gets tg's dismember +// roll (try_dismember(): the blow's damage plus how battered the limb is), so a katana kill can +// take the head or a limb off, as it can on a player, and a crushing or piercing one can burst the +// head instead (brain_burst.dm). A huge crushing or piercing blow to the head always bursts it. +// 3. An execution ends the same way it would on a person: the head burst, or a heavy skull wound. +// 4. Crits the NPC took (crit_basic.dm) become the matching wound and missing organ. +// 5. The clothes over the places that were hit get the corpse's blood, and the hands too if it was +// badly hurt, with a pool under it. +// The lines those steps add name the NPC people saw die, not the corpse's own random name, and go out +// as one message before the deathgasp. + +/** + * Puts `dead`'s fight onto this corpse. `dead` is the NPC that just died, still in the world. + * Returns TRUE if any of its tally was used. + */ +/mob/living/carbon/human/proc/hv_take_npc_wounds(mob/living/dead) + var/datum/hv_npc_wounds/wounds = dead.hv_npc_wounds + var/npc_health = max(dead.maxHealth, 1) + var/scale = maxHealth / npc_health + // Basic mobs keep every kind of damage as brute. + var/taken = dead.getBruteLoss() + dead.getFireLoss() + var/tracked = wounds?.total() || 0 + // Healing makes the tally run over what it really lost; fit the tally to what it lost. + var/fit = (tracked > taken && tracked > 0) ? taken / tracked : 1 + var/untracked = max(taken - tracked, 0) + var/killing_blow = wounds?.has_killing_blow() + var/executed = wounds?.was_executed() + + var/real_name_shown = name + name = dead.name + if(wounds) + hv_take_npc_tally(wounds, fit * scale, killing_blow) + if(untracked > 0) + take_overall_damage(brute = untracked * scale, updating_health = FALSE, forced = TRUE) + if(killing_blow) + hv_replay_killing_blow(wounds, executed) + if(executed) + hv_replay_execution(wounds) + hv_take_npc_crits(dead) + if(wounds) + hv_bloody_npc_clothes(wounds, npc_health) + updatehealth() + update_damage_overlays() + // Say it now, so the deathgasp comes after. + hv_flush_hit() + // A lost head changes what the body is called; otherwise it is its own name again. + name = real_name_shown + update_visible_name() + return !!wounds + +/// Step 1: the tally on the bodyparts it landed on, times `multiplier`, without the killing blow. +/mob/living/carbon/human/proc/hv_take_npc_tally(datum/hv_npc_wounds/wounds, multiplier, killing_blow) + var/list/zones = wounds.brute_by_zone | wounds.burn_by_zone + for(var/zone in zones) + var/brute = wounds.brute_by_zone[zone] || 0 + var/burn = wounds.burn_by_zone[zone] || 0 + if(killing_blow && zone == wounds.last_zone) + if(wounds.last_type == BRUTE) + brute = max(brute - wounds.last_damage, 0) + else + burn = max(burn - wounds.last_damage, 0) + brute *= multiplier + burn *= multiplier + if(brute <= 0 && burn <= 0) + continue + var/obj/item/bodypart/part = get_bodypart(zone) || get_bodypart(BODY_ZONE_CHEST) + part?.receive_damage(brute, burn, updating_health = FALSE, forced = TRUE, wound_bonus = CANT_WOUND) + + // A skull is only opened by one heavy hit. The tally went on in one go, so settle it by the heaviest hit it noted. + var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) + if(!head) + return + if(wounds.max_head_hit >= HV_SKULL_OPEN_HIT) + head.hv_open_skull() + else if(head.hv_skull_open) + head.hv_skull_open = FALSE + head.hv_update_skull_wound() + +/// Step 2: the killing blow again, at its real force. Executions settle the head themselves (step 3). +/mob/living/carbon/human/proc/hv_replay_killing_blow(datum/hv_npc_wounds/wounds, executed) + var/obj/item/bodypart/part = get_bodypart(wounds.last_zone) || get_bodypart(BODY_ZONE_CHEST) + if(!part) + return + var/blow = wounds.last_damage + var/brute_blow = wounds.last_type == BRUTE + // Not forced: a forced hit never spills guts. The corpse has no armour to soak it. + part.receive_damage( + brute = brute_blow ? blow : 0, + burn = brute_blow ? 0 : blow, + updating_health = FALSE, + wound_bonus = wounds.last_wound_bonus, + exposed_wound_bonus = wounds.last_exposed_bonus, + sharpness = wounds.last_sharpness, + attack_direction = wounds.last_dir, + ) + if(executed || !brute_blow || wounds.last_wound_bonus == CANT_WOUND) + return + if(QDELETED(part) || part.owner != src) + return // the wound roll already took it + var/wounding_type = hv_crit_wounding_type(wounds.last_sharpness) + if(part.body_zone == BODY_ZONE_HEAD && wounding_type != WOUND_SLASH && blow >= HV_CORPSE_HEAD_BURST_DAMAGE) + hv_burst_head_apart(wounds.last_dir) + return + if(blow < (wounding_type == WOUND_SLASH ? HV_CORPSE_EDGED_SEVER_DAMAGE : HV_CORPSE_HEAVY_SEVER_DAMAGE)) + return + if(part.body_zone == BODY_ZONE_CHEST && part.get_wound_type(/datum/wound/slash/flesh/critical/disembowel)) + return // the cut already spilled the guts + hv_roll_corpse_dismember(part, wounding_type, blow, wounds.last_wound_bonus, wounds.last_exposed_bonus, wounds.last_dir) + +/// tg's dismember roll for one blow on a corpse's limb. A separate proc so tests can force it. +/mob/living/carbon/human/proc/hv_roll_corpse_dismember(obj/item/bodypart/part, wounding_type, blow, wound_bonus, exposed_wound_bonus, blow_dir) + var/obj/item/bodypart/head/head = part + if(istype(head)) + head.hv_blow_dir = blow_dir // so a burst throws the brain the way the blow went + . = part.try_dismember(wounding_type, blow, wound_bonus, exposed_wound_bonus) + if(istype(head) && !QDELETED(head)) + head.hv_blow_dir = null + +/// Step 3: the end of an execution, as hv_take_execution() would have it on a person. +/mob/living/carbon/human/proc/hv_replay_execution(datum/hv_npc_wounds/wounds) + var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) + if(!head) + return + // Said here unless the NPC's own line already did (it didn't if the bullet killed it outright). + var/announce = !wounds.execution_said + if(wounds.execution_blow >= HV_EXECUTION_BURST_DAMAGE && hv_burst_head_apart(wounds.execution_dir, announce)) + return + if(head.brute_dam < HV_SKULL_HEAVY_BRUTE) + head.receive_damage(HV_SKULL_HEAVY_BRUTE - head.brute_dam, updating_health = FALSE, forced = TRUE, wound_bonus = CANT_WOUND) + if(head.hv_open_skull() && announce) + hv_add_hit_line("The shot blows out the back of [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) + +/// Step 4: the NPC's crits (crit_basic.dm) as the wounds and missing organs a person gets from them. +/mob/living/carbon/human/proc/hv_take_npc_crits(mob/living/dead) + var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) + if(head) + if(dead.has_status_effect(/datum/status_effect/hv_crit_basic/eye)) + var/obj/item/organ/eyes/eyes = get_organ_slot(ORGAN_SLOT_EYES) + if(eyes && !(eyes.organ_flags & ORGAN_UNREMOVABLE) && hv_crit_apply_wound(head, /datum/wound/pierce/bleed/severe/eye/hv_gouged)) + eyes.apply_scar(LEFT_EYE_SCAR) // the socket wound names the left eye + if(dead.has_status_effect(/datum/status_effect/hv_crit_basic/tongue)) + var/obj/item/organ/tongue/tongue = get_organ_slot(ORGAN_SLOT_TONGUE) + if(tongue && !(tongue.organ_flags & ORGAN_UNREMOVABLE) && hv_crit_apply_wound(head, /datum/wound/slash/flesh/moderate/hv_tongue)) + tongue.Remove(src) + qdel(tongue) + if(dead.has_status_effect(/datum/status_effect/hv_crit_basic/jaw)) + hv_crit_apply_wound(head, /datum/wound/blunt/bone/severe/hv_jaw) + if(dead.has_status_effect(/datum/status_effect/hv_crit_basic/skull)) + hv_crit_apply_wound(head, /datum/wound/blunt/bone/critical/hv_skull) + head.hv_open_skull() + if(dead.has_status_effect(/datum/status_effect/hv_crit_basic/bleed/throat)) + hv_crit_apply_wound(head, /datum/wound/slash/flesh/critical/hv_carotid) + var/obj/item/bodypart/chest = get_bodypart(BODY_ZONE_CHEST) + if(chest && dead.has_status_effect(/datum/status_effect/hv_crit_basic/bleed/aorta)) + hv_crit_apply_wound(chest, /datum/wound/pierce/bleed/critical/hv_aorta) + +/// Step 5: blood on the clothes over every place that took a beating, and on the hands and floor if it took a lot. +/mob/living/carbon/human/proc/hv_bloody_npc_clothes(datum/hv_npc_wounds/wounds, npc_health) + if(!can_bleed()) + return + var/list/blood_dna = get_blood_dna_list() + if(!length(blood_dna)) + return + var/slots = NONE + var/beaten = 0 + for(var/zone in wounds.brute_by_zone) + var/share = wounds.brute_by_zone[zone] / npc_health + beaten += share + if(share >= HV_CORPSE_BLOOD_ZONE_SHARE) + slots |= hv_zone_clothing_slots(zone) + if(beaten >= HV_CORPSE_BLOOD_HANDS_SHARE) + slots |= ITEM_SLOT_GLOVES + add_splatter_floor(get_turf(src)) + if(slots) + add_blood_DNA_to_items(blood_dna, slots) + +/// The clothing slots over a body zone. +/proc/hv_zone_clothing_slots(zone) + switch(zone) + if(BODY_ZONE_HEAD) + return ITEM_SLOT_HEAD | ITEM_SLOT_MASK | ITEM_SLOT_NECK | ITEM_SLOT_EYES + if(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM) + return ITEM_SLOT_ICLOTHING | ITEM_SLOT_OCLOTHING | ITEM_SLOT_GLOVES + return ITEM_SLOT_ICLOTHING | ITEM_SLOT_OCLOTHING diff --git a/voidcrew/modules/hyperviolence/corpses/npc_corpse_handoff.dm b/voidcrew/modules/hyperviolence/corpses/npc_corpse_handoff.dm new file mode 100644 index 00000000000..73d40f85ae0 --- /dev/null +++ b/voidcrew/modules/hyperviolence/corpses/npc_corpse_handoff.dm @@ -0,0 +1,130 @@ +// Hyperviolence: handing a dying NPC over to the human corpse it leaves. +// +// tg's death_drops element makes the corpse of every trooper and other NPC that drops one: it spawns +// the /obj/effect/mob_spawn/corpse/human, then copies the NPC's total damage onto it, spread evenly. +// For a human-looking NPC of flesh and blood we make the corpse the same way, then hand the fight +// over (npc_corpse_gore.dm) in place of that even spread, give the player's deathgasp in the NPC's +// name, and let the corpse seize up on its feet before it falls. The NPC is still in the world while +// this runs: basic mobs are deleted after their death signal. Wasteland hermits (legacy simple mobs +// whose corpse comes from their loot list) get the same. + +/datum/element/death_drops/Attach(datum/target, list/loot) + . = ..() + if(. == ELEMENT_INCOMPATIBLE) + return + var/mob/living/npc = target + if(npc.hv_leaves_human_corpse || !npc.hv_is_fleshy_humanoid_npc()) + return + for(var/path in loot) + if(hv_is_human_corpse_path(path)) + npc.hv_leaves_human_corpse = TRUE + npc.AddElement(/datum/element/hv_npc_wounds) + return + +/// Whether `path` is a corpse spawner that makes a human. +/proc/hv_is_human_corpse_path(path) + if(!ispath(path, /obj/effect/mob_spawn/corpse/human)) + return FALSE + var/obj/effect/mob_spawn/corpse/human/spawner = path + return ispath(initial(spawner.mob_type), /mob/living/carbon/human) + +/datum/element/death_drops/handle_corpse(typepath, atom/loot_loc, mob/living/dead, gibbed) + if(gibbed || !dead.hv_leaves_human_corpse || !hv_is_human_corpse_path(typepath)) + return ..() + hv_make_npc_corpse(typepath, loot_loc, dead) + +/** + * Makes `dead`'s human corpse from the spawner `typepath` at `where` and hands the fight over. + * `gasp_now` is off for an NPC that has not been marked dead yet: tg's death() gives the gasp then. + * Returns the corpse. + */ +/proc/hv_make_npc_corpse(typepath, atom/where, mob/living/dead, gasp_now = TRUE) + var/obj/effect/mob_spawn/corpse/human/spawner = new typepath(where, TRUE) + var/mob/living/carbon/human/body = spawner.create() + if(!istype(body)) + return null + // As tg's handle_corpse() does for the kinds of damage that never show on a body. + body.setToxLoss(dead.getToxLoss()) + body.setOxyLoss(dead.getOxyLoss()) + body.hv_take_npc_wounds(dead) + body.hv_death_throes(rise = TRUE) + dead.hv_npc_deathgasp(body, gasp_now) + return body + +// ---- Wasteland hermits ---- + +/mob/living/simple_animal/hostile/asteroid/hermit/Initialize(mapload) + . = ..() + for(var/path in loot) + if(hv_is_human_corpse_path(path)) + hv_leaves_human_corpse = TRUE + AddElement(/datum/element/hv_npc_wounds) + break + +/mob/living/simple_animal/hostile/asteroid/hermit/hv_is_fleshy_humanoid_npc() + return TRUE + +/mob/living/simple_animal/hv_damage_share(damagetype) + return damage_coeff?[damagetype] || 0 + +// A simple mob drops its loot before it is marked dead, so its corpse is made here, by hand, with the +// rest of the loot left to the usual drop. +/mob/living/simple_animal/hostile/asteroid/hermit/drop_loot() + if(!hv_leaves_human_corpse || !length(loot)) + return ..() + // The attack line goes out before the corpse's lines and the deathgasp. + hv_flush_hit() + for(var/path in loot.Copy()) + if(!hv_is_human_corpse_path(path)) + continue + loot -= path + hv_make_npc_corpse(path, drop_location(), src, FALSE) + return ..() + +// ---- Deathgasp and death throes ---- + +/** + * The player's deathgasp ("seizes up and falls limp, their eyes dead and lifeless..."), given by an + * NPC that has none of its own, once its corpse exists. It takes the corpse's sex and death sound + * first, so the line and the body agree. An NPC with its own death message already gave it. Without + * `emote_now` it is only made ready, for tg's death() to give. + */ +/mob/living/proc/hv_npc_deathgasp(mob/living/carbon/human/body, emote_now = TRUE) + if(death_message || death_sound || !hv_is_fleshy_humanoid_npc()) + return + if(body) + gender = body.gender + death_sound = body.death_sound + var/datum/emote/living/deathgasp/gasp = /datum/emote/living/deathgasp + death_message = initial(gasp.message) + if(!emote_now) + return + // Forced (the last argument): it is already dead, which no emote allows. Async as tg's death() does + // it; it runs at once, before the NPC is deleted, unless something in it sleeps. + INVOKE_ASYNC(src, TYPE_PROC_REF(/mob, emote), "deathgasp", null, null, FALSE, FALSE, TRUE) + +/** + * A body shudders as it dies. With `rise` (a corpse that has just taken the place of a standing NPC) + * it is first drawn upright over its lying pose, seizing, then falls the way tg lays a body down. + */ +/mob/living/proc/hv_death_throes(rise = FALSE) + // How far it is turned now: tg lays a body down by turning it. + var/angle = round(arctan(transform.a, transform.b)) + if(rise && angle) + var/matrix/fallen = matrix(transform) + var/matrix/upright = matrix(transform) + upright.Turn(-angle) + animate(src, transform = upright, time = 0) + animate(transform = upright, time = HV_DEATH_THROES_TIME) + animate(transform = fallen, time = UPDATE_TRANSFORM_ANIMATION_TIME, easing = EASE_IN|EASE_OUT) + // The shudder: sideways offsets that die down, each step relative to the last, ending where it began. + var/static/list/shudder = list(3, -3, 2, -2, 2, -1, 1, 0) + var/step_time = HV_DEATH_THROES_TIME / length(shudder) + var/offset = 0 + for(var/i in 1 to length(shudder)) + var/shift = shudder[i] - offset + offset = shudder[i] + if(i == 1) + animate(src, pixel_w = shift, time = step_time, flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) + else + animate(pixel_w = shift, time = step_time, flags = ANIMATION_RELATIVE) diff --git a/voidcrew/modules/hyperviolence/corpses/npc_wounds.dm b/voidcrew/modules/hyperviolence/corpses/npc_wounds.dm new file mode 100644 index 00000000000..f58056b5559 --- /dev/null +++ b/voidcrew/modules/hyperviolence/corpses/npc_wounds.dm @@ -0,0 +1,137 @@ +// Hyperviolence: the fight an NPC carries onto its corpse. +// +// Pirates, troopers and the other human-looking NPCs are basic mobs. Most are deleted when they die +// and leave a fresh human corpse in their place, so the blood and damage of the fight used to vanish +// with them. This file keeps a cheap tally on the living NPC: one damage signal, no ticking, nothing +// stored until the first hit. It holds the damage by body zone and the last blow. When the corpse is +// made (npc_corpse_handoff.dm), npc_corpse_gore.dm puts all of it onto the corpse. + +/// What a human-looking NPC went through, kept for the human corpse it will leave. Made on the first hit. +/datum/hv_npc_wounds + /// Body zone -> brute damage taken there + var/list/brute_by_zone = list() + /// Body zone -> burn damage taken there + var/list/burn_by_zone = list() + /// The heaviest single brute hit to the head, for whether the skull was opened + var/max_head_hit = 0 + // The last hit. It is the killing blow if it landed in the tick the NPC died. + /// Body zone it landed on + var/last_zone + /// Damage it dealt, after armour and the NPC's own damage modifiers + var/last_damage = 0 + /// BRUTE or BURN + var/last_type + /// Its sharpness flags + var/last_sharpness = NONE + /// Its wound bonus (CANT_WOUND for hits that can't wound) + var/last_wound_bonus = 0 + /// Its wound bonus against bare skin + var/last_exposed_bonus = 0 + /// The way it travelled, from the attacker toward the NPC + var/last_dir + /// world.time it landed + var/last_time = -1 + /// Force of the execution shot, after armour and the point-blank bonus (executions.dm) + var/execution_blow = 0 + /// The way the execution shot travelled + var/execution_dir + /// world.time of the execution shot, -1 if it was never executed + var/execution_time = -1 + /// Whether the NPC's own hit line already said how the execution ended + var/execution_said = FALSE + +/// Total damage of both kinds in the tally. +/datum/hv_npc_wounds/proc/total() + return counterlist_sum(brute_by_zone) + counterlist_sum(burn_by_zone) + +/// Whether the last hit landed this tick, and so is what killed it. +/datum/hv_npc_wounds/proc/has_killing_blow() + return last_time == world.time && last_damage > 0 && (last_type == BRUTE || last_type == BURN) + +/// Whether it died to an execution: the shot was noted just now (a burst of rounds lands over a few ticks). +/datum/hv_npc_wounds/proc/was_executed() + return execution_time >= 0 && world.time - execution_time <= 1 SECONDS + +/mob/living + /// Hyperviolence: the tally for the human corpse this NPC leaves (corpses/). Null until it is first hurt. + var/datum/hv_npc_wounds/hv_npc_wounds + /// Hyperviolence: set on human-looking NPCs whose death leaves a human corpse for the fight to be carried onto. + var/hv_leaves_human_corpse = FALSE + +/// Whether this mob is a human-looking NPC of flesh and blood, the kind whose corpse can carry the fight. +/mob/living/proc/hv_is_fleshy_humanoid_npc() + return FALSE + +/mob/living/basic/hv_is_fleshy_humanoid_npc() + if(!(mob_biotypes & MOB_HUMANOID) || !(mob_biotypes & MOB_ORGANIC)) + return FALSE + return !(mob_biotypes & (MOB_ROBOTIC|MOB_UNDEAD|MOB_SPIRIT|MOB_MINERAL|MOB_PLANT|MOB_SLIME)) + +/// How much of a hit of this damage type this mob really takes: basic mobs scale it by their damage coefficients. +/mob/living/proc/hv_damage_share(damagetype) + return 1 + +/mob/living/basic/hv_damage_share(damagetype) + return damage_coeff?[damagetype] || 0 + +/// The body zone a hit with this def_zone (a zone, a bodypart or nothing) counts against. +/proc/hv_tally_zone(def_zone) + if(isbodypart(def_zone)) + var/obj/item/bodypart/part = def_zone + return part.body_zone + if(istext(def_zone)) + return check_zone(def_zone) + return BODY_ZONE_CHEST + +/// Keeps the tally of every hit on a human-looking NPC: one that leaves a human corpse +/// (npc_corpse_handoff.dm), or one whose own body shows the gore (npc_body_gore.dm). +/datum/element/hv_npc_wounds + +/datum/element/hv_npc_wounds/Attach(datum/target) + . = ..() + if(!isliving(target)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_MOB_APPLY_DAMAGE, PROC_REF(on_damage)) + +/datum/element/hv_npc_wounds/Detach(mob/living/source) + UnregisterSignal(source, COMSIG_MOB_APPLY_DAMAGE) + return ..() + +// Sent before the damage goes in, so a hit that kills is noted before the corpse is made. +/datum/element/hv_npc_wounds/proc/on_damage(mob/living/source, damage, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + SIGNAL_HANDLER + if(source.stat == DEAD || (damagetype != BRUTE && damagetype != BURN) || HAS_TRAIT(source, TRAIT_GODMODE)) + return + source.hv_note_npc_hit(damage * source.hv_damage_share(damagetype), damagetype, def_zone, wound_bonus, exposed_wound_bonus, sharpness, attack_direction) + +/// Adds one hit to this NPC's tally. +/mob/living/proc/hv_note_npc_hit(damage, damagetype, def_zone, wound_bonus = 0, exposed_wound_bonus = 0, sharpness = NONE, attack_direction) + if(damage <= 0) + return + if(!hv_npc_wounds) + hv_npc_wounds = new + var/datum/hv_npc_wounds/wounds = hv_npc_wounds + var/zone = hv_tally_zone(def_zone) + if(damagetype == BRUTE) + wounds.brute_by_zone[zone] += damage + if(zone == BODY_ZONE_HEAD) + wounds.max_head_hit = max(wounds.max_head_hit, damage) + else + wounds.burn_by_zone[zone] += damage + wounds.last_zone = zone + wounds.last_damage = damage + wounds.last_type = damagetype + wounds.last_sharpness = sharpness + wounds.last_wound_bonus = wound_bonus + wounds.last_exposed_bonus = exposed_wound_bonus + wounds.last_dir = attack_direction + wounds.last_time = world.time + +/// Notes that this NPC is being executed, for the corpse it leaves. `said` when its hit line tells how it ended. +/mob/living/proc/hv_note_npc_execution(blow, shot_dir, said = FALSE) + if(!hv_npc_wounds) + hv_npc_wounds = new + hv_npc_wounds.execution_blow = blow + hv_npc_wounds.execution_dir = shot_dir + hv_npc_wounds.execution_time = world.time + hv_npc_wounds.execution_said = said diff --git a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm index 66651ce7b6d..740b74fd083 100644 --- a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm +++ b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm @@ -3,21 +3,35 @@ // After CM-SS13's handle_battlefield_execution() (cmss13-devs/cmss13 @ e9b299d266c2be727403751c3474e085803b5934, // code/modules/projectiles/gun.dm, AGPL-3.0, by the CM-SS13 team), rewritten for tg's guns. // -// The trigger: tg already turns a point-blank click with the mouth selected into "points the gun at -// their head" (handle_suicide: a 12 second wait, on anyone). When the target is humanoid and can't -// fight back (down, out cold, cuffed, stunned or held in an aggressive grab) that click becomes an -// execution instead: a short wind-up that both have to hold still for, then the gun's own shot into -// the head. A round heavy enough bursts the head (hv_burst_head, which keeps the brain); a lighter -// one leaves a heavy skull wound and finishes them. The brain is never destroyed, so revival works -// as usual. Head-selected point-blank shots, holding someone up (right click) and gun bashes are -// unchanged, and anyone the wind-up does not apply to still gets tg's slow version. +// The trigger: a point-blank click with the head or the mouth selected, on a humanoid who can't fight +// back (down, out cold, dead, cuffed, stunned, held in an aggressive grab, or an NPC staggering on +// its last legs), is an execution: a short wind-up that both have to hold still for, then the gun's +// own shot into the head. A round heavy enough bursts the head (hv_burst_head, which keeps the +// brain); a lighter one leaves a heavy skull wound and finishes them. The brain is never destroyed, +// so revival works as usual. An NPC that leaves a human corpse has the outcome replayed on that +// corpse (corpses/). The mouth click reaches us through tg's handle_suicide(); the head click through +// hv_try_head_execution(), called from a marked line in tg's fire_gun(). Anyone the execution does +// not apply to keeps tg's behaviour: a normal point-blank shot to the head, or tg's slow mouth +// version (which is also how you shoot yourself). Holding someone up (right click) and gun bashes are +// unchanged. /obj/item/gun/handle_suicide(mob/living/carbon/human/user, mob/living/carbon/human/target, params, bypass_timer) if(bypass_timer || !hv_execution_possible(user, target)) return ..() hv_start_execution(user, target, params) -/// Whether a mouth-aimed point-blank click by `user` on `target` should be an execution. +/// Called by tg's fire_gun() for a point-blank shot, after its own checks (safeties, firing pins, the +/// mouth). A head-aimed one at someone who can be executed starts the execution instead of firing. +/// Returns TRUE if it did. +/obj/item/gun/proc/hv_try_head_execution(mob/living/user, atom/target, params) + if(!doafter_self_shoot || !isliving(user) || user.zone_selected != BODY_ZONE_HEAD) + return FALSE + if(!hv_execution_possible(user, target)) + return FALSE + hv_start_execution(user, target, params) + return TRUE + +/// Whether a head- or mouth-aimed point-blank click by `user` on `target` should be an execution. /obj/item/gun/proc/hv_execution_possible(mob/living/user, mob/living/target) if(!isliving(user) || !isliving(target) || user == target) return FALSE @@ -57,9 +71,11 @@ return hv_fire_execution(user, target, params) -/// do_after check: the shooter still holds the gun to the mouth of someone who still can't fight back. +/// do_after check: the shooter still holds the gun to the head of someone who still can't fight back. /obj/item/gun/proc/hv_execution_holds(mob/living/user, mob/living/target) - return user.is_holding(src) && user.zone_selected == BODY_ZONE_PRECISE_MOUTH && target.hv_can_be_executed() + if(!user.is_holding(src) || !target.hv_can_be_executed()) + return FALSE + return user.zone_selected == BODY_ZONE_HEAD || user.zone_selected == BODY_ZONE_PRECISE_MOUTH /// Fires the execution shot. Returns TRUE if the gun went off. /obj/item/gun/proc/hv_fire_execution(mob/living/user, mob/living/target, params) @@ -72,6 +88,9 @@ return process_fire(target, user, TRUE, params, BODY_ZONE_HEAD) var/blow = hv_execution_blow(target) var/shot_dir = get_dir(user, target) + // An NPC the bullet itself kills dies before the shot is settled below, so tell it now. + if((target.hv_leaves_human_corpse || target.hv_gory_own_body) && target.hv_execution_killable()) + target.hv_note_npc_execution(blow, shot_dir) if(!process_fire(target, user, TRUE, params, BODY_ZONE_HEAD)) return FALSE log_combat(user, target, "executed", src) @@ -131,6 +150,12 @@ return TRUE return pulledby?.grab_state >= GRAB_AGGRESSIVE +// An NPC has no crit to fall into: it fights on until it dies. On its last legs it is staggering. +/mob/living/basic/hv_is_helpless() + if(..()) + return TRUE + return maxHealth > 0 && stat != DEAD && health <= maxHealth * HV_EXECUTION_STAGGER_HEALTH + // Bounty criminals keep their own cuffs, downed and stunned states (voidcrew/modules/bounties/). /mob/living/basic/bounty_criminal/hv_is_helpless() return ..() || is_restrained() || is_downed() || body_is_stunned() @@ -138,13 +163,33 @@ // ---- What the shot does ---- /// Takes an execution shot that hit with `blow` force, travelling `shot_dir`. Past the shot itself, a -/// target too tough to finish (see hv_execution_killable()) takes nothing extra. +/// target too tough to finish (see hv_execution_killable()) takes nothing extra. A human-looking NPC +/// notes the shot first, so the corpse it leaves, or its own body as it dies, shows the same outcome +/// (corpses/); one already dead shows it at once. /mob/living/proc/hv_take_execution(blow, shot_dir) if(stat != DEAD && !hv_execution_killable()) return - hv_blood_spray(shot_dir, blow >= HV_EXECUTION_BURST_DAMAGE ? 4 : 3) - hv_add_hit_line("The shot blows out the back of [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) - hv_execution_kill() + var/heavy = blow >= HV_EXECUTION_BURST_DAMAGE + hv_blood_spray(shot_dir, heavy ? 4 : 3) + if(hv_body_headless) + return // nothing left to shoot + // Only an NPC whose corpse or own body can show it has a head to burst. + var/bursts = heavy && (hv_leaves_human_corpse || hv_body_head_can_burst()) + if(stat != DEAD && (hv_leaves_human_corpse || hv_gory_own_body)) + hv_note_npc_execution(blow, shot_dir, TRUE) + if(bursts) + hv_add_hit_line("[src]'s head bursts apart!", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) + else + hv_add_hit_line("The shot blows out the back of [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) + if(stat != DEAD) + hv_execution_kill() + return + // A body already dead: nothing settles it at death any more, so it shows now. + if(bursts) + hv_burst_body_head(shot_dir, FALSE) + else if(hv_gory_own_body && hv_looks_human()) + hv_body_skull = 3 + update_appearance(UPDATE_OVERLAYS) /mob/living/carbon/hv_take_execution(blow, shot_dir) if(stat != DEAD && !hv_execution_killable()) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 23506e51cf6..929b6a52b51 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -93,6 +93,7 @@ #include "voidcrew_hv_finishers.dm" #include "voidcrew_hyperviolence_blood.dm" #include "voidcrew_hyperviolence_braingut.dm" +#include "voidcrew_hyperviolence_corpses.dm" #include "voidcrew_hyperviolence_crits.dm" #include "voidcrew_hyperviolence_hitlines.dm" #include "voidcrew_hyperviolence_overlays_exec.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm new file mode 100644 index 00000000000..e6ef582d78a --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm @@ -0,0 +1,244 @@ +// Hyperviolence (voidcrew/modules/hyperviolence/corpses/): NPC corpses that keep the fight, the +// gore on NPCs that keep their own body, and executions of downed and staggering NPCs. +// Literals carry the name of the voidcrew define they stand for in a comment. + +/// The human corpse a basic mob just left on `spot`, which was not there in `before`. Cleaned up with the test. +/datum/unit_test/proc/hv_new_corpse(turf/spot, list/before) + for(var/mob/living/carbon/human/body in spot) + if(body in before) + continue + allocated += body + return body + return null + +/// Everything of type `kind` within a few tiles of `spot` that was not in `before`, cleaned up with the test. +/datum/unit_test/proc/hv_new_things(turf/spot, kind, list/before) + var/list/found = list() + for(var/atom/movable/thing in range(7, spot)) + if(istype(thing, kind) && !(thing in before)) + found += thing + allocated += thing + return found + +/// A pirate with no mind of its own to wander off with during the test. +/datum/unit_test/proc/hv_still_pirate() + var/mob/living/basic/trooper/pirate/melee/pirate = allocate(/mob/living/basic/trooper/pirate/melee) + QDEL_NULL(pirate.ai_controller) + return pirate + +/// Damage taken by body zone lands on the matching bodyparts of the corpse, with blood on the clothes over them. +/datum/unit_test/hv_npc_corpse_carries_fight + +/datum/unit_test/hv_npc_corpse_carries_fight/Run() + var/turf/spot = run_loc_floor_bottom_left + var/mob/living/basic/trooper/pirate/melee/pirate = hv_still_pirate() + TEST_ASSERT(pirate.hv_leaves_human_corpse, "A pirate trooper was not marked as leaving a human corpse") + var/npc_health = pirate.maxHealth + pirate.apply_damage(npc_health * 0.4, BRUTE, BODY_ZONE_HEAD, wound_bonus = CANT_WOUND) + pirate.apply_damage(npc_health * 0.3, BRUTE, BODY_ZONE_L_ARM, wound_bonus = CANT_WOUND) + var/list/before = range(7, spot) + pirate.apply_damage(npc_health * 0.6, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND) + TEST_ASSERT(QDELETED(pirate), "The pirate did not die") + + var/mob/living/carbon/human/body = hv_new_corpse(spot, before) + TEST_ASSERT_NOTNULL(body, "The pirate left no human corpse") + TEST_ASSERT_EQUAL(body.stat, DEAD, "The pirate's corpse is alive") + var/scale = body.maxHealth / npc_health + var/obj/item/bodypart/head/head = body.get_bodypart(BODY_ZONE_HEAD) + var/obj/item/bodypart/arm = body.get_bodypart(BODY_ZONE_L_ARM) + var/obj/item/bodypart/chest = body.get_bodypart(BODY_ZONE_CHEST) + var/obj/item/bodypart/leg = body.get_bodypart(BODY_ZONE_R_LEG) + TEST_ASSERT(abs(head.brute_dam - npc_health * 0.4 * scale) < 1, "The head took [head.brute_dam] brute, not the pirate's [npc_health * 0.4 * scale]") + TEST_ASSERT(abs(arm.brute_dam - npc_health * 0.3 * scale) < 1, "The left arm took [arm.brute_dam] brute, not the pirate's [npc_health * 0.3 * scale]") + // The killing blow is struck at its real force, not scaled. + TEST_ASSERT(abs(chest.brute_dam - npc_health * 0.6) < 1, "The chest took [chest.brute_dam] brute from a [npc_health * 0.6] killing blow") + TEST_ASSERT_EQUAL(leg.brute_dam, 0, "A leg nobody hit took damage") + // One heavy hit to the head opened the skull; 40 brute is a light skull wound (HV_SKULL_LIGHT_BRUTE 20, HV_SKULL_MEDIUM_BRUTE 45). + TEST_ASSERT_EQUAL(head.hv_skull_wound, 1, "A 40-brute head hit left skull wound level [head.hv_skull_wound]") + + var/bloody_clothes = FALSE + for(var/obj/item/clothing/worn in list(body.w_uniform, body.wear_suit)) + if(GET_ATOM_BLOOD_DNA_LENGTH(worn)) + bloody_clothes = TRUE + TEST_ASSERT(bloody_clothes, "A corpse beaten in the chest and arm has clean clothes") + TEST_ASSERT(GET_ATOM_BLOOD_DNA_LENGTH(body.head), "A corpse beaten about the head has a clean hat") + +/// A heavy edged killing blow to the head takes the head off the corpse, through tg's dismember roll. +/datum/unit_test/hv_npc_corpse_katana_beheads + +/datum/unit_test/hv_npc_corpse_katana_beheads/Run() + var/turf/spot = run_loc_floor_bottom_left + var/mob/living/basic/trooper/pirate/melee/pirate = hv_still_pirate() + var/list/before = range(7, spot) + // try_dismember() rolls the blow's damage plus half the head's damage share as a percent: 150 always takes it. + pirate.apply_damage(150, BRUTE, BODY_ZONE_HEAD, sharpness = SHARP_EDGED, attack_direction = NORTH) + TEST_ASSERT(QDELETED(pirate), "The pirate did not die") + var/mob/living/carbon/human/body = hv_new_corpse(spot, before) + TEST_ASSERT_NOTNULL(body, "The pirate left no human corpse") + TEST_ASSERT_NULL(body.get_bodypart(BODY_ZONE_HEAD), "A katana kill to the head left the head on") + var/list/heads = hv_new_things(spot, /obj/item/bodypart/head, before) + TEST_ASSERT(length(heads), "A cut-off head left nothing on the floor") + TEST_ASSERT("stump_head_blood" in body.hv_gore_states(), "The beheaded corpse has no neck stump") + hv_new_things(spot, /obj/item/organ, before) + + // The roll is a chance, not a rule: a light cut on a fresh corpse can leave the head on. + var/mob/living/carbon/human/consistent/dummy = allocate(/mob/living/carbon/human/consistent) + dummy.death() + var/obj/item/bodypart/head/dummy_head = dummy.get_bodypart(BODY_ZONE_HEAD) + var/took_off = 0 + for(var/i in 1 to 20) + if(QDELETED(dummy_head) || dummy_head.owner != dummy) + break + if(dummy.hv_roll_corpse_dismember(dummy_head, WOUND_SLASH, 1, 0, 0, NORTH)) + took_off++ + TEST_ASSERT_EQUAL(took_off, 0, "A 1-damage cut took a head off (tg's floor is 10)") + +/// A huge crushing killing blow to the head bursts it (HV_CORPSE_HEAD_BURST_DAMAGE 60), and the brain survives. +/datum/unit_test/hv_npc_corpse_head_burst + +/datum/unit_test/hv_npc_corpse_head_burst/Run() + var/turf/spot = run_loc_floor_bottom_left + var/mob/living/basic/trooper/pirate/melee/pirate = hv_still_pirate() + var/list/before = range(7, spot) + pirate.apply_damage(120, BRUTE, BODY_ZONE_HEAD, attack_direction = NORTH) + var/mob/living/carbon/human/body = hv_new_corpse(spot, before) + TEST_ASSERT_NOTNULL(body, "The pirate left no human corpse") + TEST_ASSERT_NULL(body.get_bodypart(BODY_ZONE_HEAD), "A 120-damage crushing kill to the head left the head on") + TEST_ASSERT(!length(hv_new_things(spot, /obj/item/bodypart/head, before)), "A burst head came off whole") + TEST_ASSERT(length(hv_new_things(spot, /obj/item/organ/brain, before)), "Bursting the corpse's head destroyed the brain") + hv_new_things(spot, /obj/item/organ, before) + +/// Crits a pirate took come through on its corpse as the matching wound and missing organ. +/datum/unit_test/hv_npc_corpse_crits + +/datum/unit_test/hv_npc_corpse_crits/Run() + var/turf/spot = run_loc_floor_bottom_left + var/mob/living/basic/trooper/pirate/melee/pirate = hv_still_pirate() + pirate.apply_status_effect(/datum/status_effect/hv_crit_basic/tongue, NORTH) + pirate.apply_status_effect(/datum/status_effect/hv_crit_basic/bleed/throat, NORTH) + var/list/before = range(7, spot) + pirate.apply_damage(200, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND) + var/mob/living/carbon/human/body = hv_new_corpse(spot, before) + TEST_ASSERT_NOTNULL(body, "The pirate left no human corpse") + var/obj/item/bodypart/head/head = body.get_bodypart(BODY_ZONE_HEAD) + TEST_ASSERT_NULL(body.get_organ_slot(ORGAN_SLOT_TONGUE), "A pirate whose tongue was cut out left a corpse with a tongue") + TEST_ASSERT_NOTNULL(head.get_wound_type(/datum/wound/slash/flesh/moderate/hv_tongue), "The corpse has no severed-tongue wound") + TEST_ASSERT_NOTNULL(head.get_wound_type(/datum/wound/slash/flesh/critical/hv_carotid), "A pirate with a slit throat left a corpse with an unbroken throat") + +/// A humanoid NPC is helpless on its last legs (HV_EXECUTION_STAGGER_HEALTH 0.2), not before. +/datum/unit_test/hv_npc_staggering_executable + +/datum/unit_test/hv_npc_staggering_executable/Run() + var/mob/living/basic/trooper/pirate/melee/pirate = hv_still_pirate() + TEST_ASSERT(!pirate.hv_can_be_executed(), "A pirate at full health could be executed") + pirate.adjustBruteLoss(pirate.maxHealth * 0.5) + TEST_ASSERT(!pirate.hv_can_be_executed(), "A pirate at half health could be executed") + pirate.adjustBruteLoss(pirate.maxHealth * 0.35) + TEST_ASSERT(pirate.hv_can_be_executed(), "A pirate at 15% health could not be executed") + +/// A point-blank click with the head selected executes someone down, and is a plain shot at someone standing. +/datum/unit_test/hv_head_aimed_execution + +/datum/unit_test/hv_head_aimed_execution/Run() + var/mob/living/carbon/human/consistent/gunner = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) + ADD_TRAIT(victim, TRAIT_PIERCEIMMUNE, INNATE_TRAIT) // no stray shrapnel + var/obj/item/gun/ballistic/automatic/pistol/gun = allocate(/obj/item/gun/ballistic/automatic/pistol) + gun.can_misfire = FALSE + gunner.put_in_active_hand(gun, forced = TRUE) + gunner.zone_selected = BODY_ZONE_HEAD + + // Standing and free: tg's own point-blank shot, which a 9mm survives. + gun.fire_gun(victim, gunner, TRUE, null) + TEST_ASSERT_NOTEQUAL(victim.stat, DEAD, "A head-aimed shot at a standing target was an execution") + + victim.fully_heal(HEAL_ALL) + victim.Knockdown(30 SECONDS) + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + gun.fire_gun(victim, gunner, TRUE, null) + TEST_ASSERT_EQUAL(victim.stat, DEAD, "A head-aimed point-blank shot at a downed target was not an execution") + TEST_ASSERT_EQUAL(head.hv_skull_wound, 3, "A head-aimed 9mm execution left no heavy skull wound") + +/// Executing a staggering pirate carries the outcome to its corpse: a light round the skull, a heavy one the head. +/datum/unit_test/hv_npc_execution_corpse + +/datum/unit_test/hv_npc_execution_corpse/Run() + var/turf/spot = run_loc_floor_bottom_left + var/mob/living/carbon/human/consistent/gunner = allocate(/mob/living/carbon/human/consistent) + var/obj/item/gun/ballistic/automatic/pistol/gun = allocate(/obj/item/gun/ballistic/automatic/pistol) + gun.can_misfire = FALSE + gunner.put_in_active_hand(gun, forced = TRUE) + gunner.zone_selected = BODY_ZONE_HEAD + + var/mob/living/basic/trooper/pirate/melee/pirate = hv_still_pirate() + pirate.adjustBruteLoss(pirate.maxHealth * 0.85) + TEST_ASSERT(gun.hv_execution_possible(gunner, pirate), "A staggering pirate could not be executed") + gun.chambered.loaded_projectile.damage = 20 // doubled at the muzzle: 40, under HV_EXECUTION_BURST_DAMAGE 80 + var/list/before = range(7, spot) + TEST_ASSERT(gun.hv_fire_execution(gunner, pirate, null), "The execution shot did not fire") + TEST_ASSERT(QDELETED(pirate), "The executed pirate did not die") + var/mob/living/carbon/human/body = hv_new_corpse(spot, before) + TEST_ASSERT_NOTNULL(body, "The executed pirate left no human corpse") + var/obj/item/bodypart/head/head = body.get_bodypart(BODY_ZONE_HEAD) + TEST_ASSERT_NOTNULL(head, "A light execution round took the corpse's head") + TEST_ASSERT_EQUAL(head.hv_skull_wound, 3, "A light execution left the corpse no heavy skull wound") + + var/mob/living/basic/trooper/pirate/melee/second = hv_still_pirate() + second.adjustBruteLoss(second.maxHealth * 0.85) + gun.chambered.loaded_projectile.damage = 60 // doubled: 120 + before = range(7, spot) + TEST_ASSERT(gun.hv_fire_execution(gunner, second, null), "The heavy execution shot did not fire") + body = hv_new_corpse(spot, before) + TEST_ASSERT_NOTNULL(body, "The second executed pirate left no human corpse") + TEST_ASSERT_NULL(body.get_bodypart(BODY_ZONE_HEAD), "A heavy execution round left the corpse's head on") + hv_new_things(spot, /obj/item/organ, before) + +/// An NPC that keeps its own body gives the deathgasp and shows a burst head on it, and coming back clears it. +/datum/unit_test/hv_npc_own_body_gore + +/datum/unit_test/hv_npc_own_body_gore/Run() + var/mob/living/basic/mission_survivor/survivor = allocate(/mob/living/basic/mission_survivor) + QDEL_NULL(survivor.ai_controller) + TEST_ASSERT(survivor.hv_gory_own_body, "A survivor (no DEL_ON_DEATH) did not get the body gore") + survivor.icon = 'icons/mob/human/human.dmi' // its look is put on in the background + var/datum/emote/living/deathgasp/gasp = /datum/emote/living/deathgasp + survivor.apply_damage(survivor.maxHealth * 0.5, BRUTE, BODY_ZONE_HEAD) + survivor.apply_damage(100, BRUTE, BODY_ZONE_HEAD, attack_direction = SOUTH) + TEST_ASSERT_EQUAL(survivor.stat, DEAD, "The survivor did not die") + TEST_ASSERT(!QDELETED(survivor), "The survivor's own body was deleted") + TEST_ASSERT_EQUAL(survivor.death_message, initial(gasp.message), "The survivor did not give the player's deathgasp") + TEST_ASSERT(survivor.hv_body_headless, "A 100-damage crushing kill to the head did not burst it") + TEST_ASSERT_NOTNULL(survivor.get_filter("hv_headless"), "The burst head is still drawn") + + survivor.revive(ADMIN_HEAL_ALL) + TEST_ASSERT(!survivor.hv_body_headless, "Coming back to life left the head off") + TEST_ASSERT_NULL(survivor.get_filter("hv_headless"), "Coming back to life left the head cut out of the sprite") + +/// The NPC takes its corpse's sex before its deathgasp, so the line and the body agree. +/datum/unit_test/hv_npc_deathgasp_matches_corpse + +/datum/unit_test/hv_npc_deathgasp_matches_corpse/Run() + var/mob/living/basic/trooper/pirate/melee/pirate = hv_still_pirate() + var/mob/living/carbon/human/consistent/body = allocate(/mob/living/carbon/human/consistent) + body.gender = pirate.gender == MALE ? FEMALE : MALE + pirate.hv_npc_deathgasp(body, FALSE) + TEST_ASSERT_EQUAL(pirate.gender, body.gender, "The pirate's deathgasp would not match its corpse's sex") + var/datum/emote/living/deathgasp/gasp = /datum/emote/living/deathgasp + TEST_ASSERT_EQUAL(pirate.death_message, initial(gasp.message), "The pirate's death line is not the player's deathgasp") + +/// A wasteland hermit (a legacy simple mob with its corpse in its loot) hands its fight over too. +/datum/unit_test/hv_hermit_corpse + +/datum/unit_test/hv_hermit_corpse/Run() + var/turf/spot = run_loc_floor_bottom_left + var/mob/living/simple_animal/hostile/asteroid/hermit/survivor/hermit = allocate(/mob/living/simple_animal/hostile/asteroid/hermit/survivor) + hermit.toggle_ai(AI_OFF) + TEST_ASSERT(hermit.hv_leaves_human_corpse, "A hermit was not marked as leaving a human corpse") + var/list/before = range(7, spot) + hermit.apply_damage(hermit.maxHealth * 0.4, BRUTE, BODY_ZONE_L_LEG, wound_bonus = CANT_WOUND) + hermit.apply_damage(hermit.maxHealth, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND) + TEST_ASSERT(QDELETED(hermit), "The hermit did not die") + var/mob/living/carbon/human/body = hv_new_corpse(spot, before) + TEST_ASSERT_NOTNULL(body, "The hermit left no human corpse") + var/obj/item/bodypart/leg = body.get_bodypart(BODY_ZONE_L_LEG) + TEST_ASSERT(leg.brute_dam > 10, "The hermit's corpse lost the beating its leg took") From a81964b13c819a3bb2f214821c4a373934ae756c Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:59:56 -0800 Subject: [PATCH 032/472] Clear the gun's fire cooldown between back-to-back shots in the execution tests --- voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm index e6ef582d78a..68c52906af5 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm @@ -154,6 +154,7 @@ victim.fully_heal(HEAL_ALL) victim.Knockdown(30 SECONDS) + gun.fire_cd = FALSE // the standing shot above just fired; skip its cooldown var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) gun.fire_gun(victim, gunner, TRUE, null) TEST_ASSERT_EQUAL(victim.stat, DEAD, "A head-aimed point-blank shot at a downed target was not an execution") @@ -186,6 +187,7 @@ var/mob/living/basic/trooper/pirate/melee/second = hv_still_pirate() second.adjustBruteLoss(second.maxHealth * 0.85) gun.chambered.loaded_projectile.damage = 60 // doubled: 120 + gun.fire_cd = FALSE // the first execution just fired; skip its cooldown before = range(7, spot) TEST_ASSERT(gun.hv_fire_execution(gunner, second, null), "The heavy execution shot did not fire") body = hv_new_corpse(spot, before) From 990499cb275665788fb67853beb2642e4aafad20 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:24:35 -0800 Subject: [PATCH 033/472] Add a human combat NPC prototype and its measurement harness A /mob/living/carbon/human/npc driven by a basic_controller that fires its held gun and swings its held weapon through ai_interact, so wounds, crits and executions apply to it alive. Life() skips while no player is on its z-level and once dead. The harness is compiled only with -DHUMAN_NPC_SPIKE_HARNESS. --- tgstation.dme | 3 + voidcrew/modules/human_npcs/human_npc.dm | 108 ++++ voidcrew/modules/human_npcs/human_npc_ai.dm | 147 ++++++ .../human_npcs/human_npc_spike_harness.dm | 484 ++++++++++++++++++ 4 files changed, 742 insertions(+) create mode 100644 voidcrew/modules/human_npcs/human_npc.dm create mode 100644 voidcrew/modules/human_npcs/human_npc_ai.dm create mode 100644 voidcrew/modules/human_npcs/human_npc_spike_harness.dm diff --git a/tgstation.dme b/tgstation.dme index aee2dba253a..09c0684c3b5 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7229,6 +7229,9 @@ #include "voidcrew\modules\dynamic_events\events\wormholes.dm" #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" +#include "voidcrew\modules\human_npcs\human_npc.dm" +#include "voidcrew\modules\human_npcs\human_npc_ai.dm" +#include "voidcrew\modules\human_npcs\human_npc_spike_harness.dm" #include "voidcrew\modules\hyperviolence\_core.dm" #include "voidcrew\modules\hyperviolence\blood\_blood_defines.dm" #include "voidcrew\modules\hyperviolence\blood\blood_pools.dm" diff --git a/voidcrew/modules/human_npcs/human_npc.dm b/voidcrew/modules/human_npcs/human_npc.dm new file mode 100644 index 00000000000..d3132901e1a --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc.dm @@ -0,0 +1,108 @@ +// Human combat NPCs: a real /mob/living/carbon/human body driven by an ai_controller. +// +// SPIKE PROTOTYPE (branch hv/human-npc-spike). Nothing spawns these in normal play yet. +// +// Because the body is a real human, tg's wounds, dismemberment, soft and hard crit, crawling, +// bleeding out and the hyperviolence executions and crits all apply to it while it is alive, +// with no corpse swap at death. The AI (human_npc_ai.dm) fights through the held item: a gun is +// fired through ClickOn, so the gun's own fire path, projectiles and casings are used. +// +// Life() trimming. A human's Life() runs every SSmobs tick (2 s) for every human in the world, +// with or without a client or a player nearby. NPCs keep everything that matters for a fight +// (blood, wounds, organs, breathing, body temperature) and drop the rest: +// - no mood datum (setup_mood), so nothing processes on SSmood; +// - TRAIT_NOHUNGER, so a long-lived NPC never starves over a 6 hour round; +// - dormancy: Life() does nothing while no player is on the NPC's z-level (the same test tg uses +// to switch the AI off) and always once the NPC is dead. A dormant NPC is frozen in time; a +// dead one never decays or rots. This has to be a guard on Life() itself: COMSIG_LIVING_LIFE's +// cancel flag only stops /mob/living/Life(), and carbon and human Life() run organs, wounds, +// limbs, body temperature, heart and liver around that call whatever it returns. + +/mob/living/carbon/human/npc + name = "Pirate" + faction = list(FACTION_PIRATE) + ai_controller = /datum/ai_controller/basic_controller/human_npc + /// Outfit worn at spawn. + var/datum/outfit/npc_outfit = /datum/outfit/piratecorpse + /// Weapon put in the right hand at spawn, or null for fists. + var/obj/item/npc_weapon + /// Apply the NPC Life() trims (no mood, no hunger, dormancy). Off only for measurement. + var/npc_trim_life = TRUE + /// Cancel Life() while dead. + var/npc_dormant_when_dead = TRUE + +/mob/living/carbon/human/npc/Initialize(mapload) + . = ..() + if(npc_trim_life) + ADD_TRAIT(src, TRAIT_NOHUNGER, INNATE_TRAIT) + if(npc_outfit) + equipOutfit(npc_outfit) + if(npc_weapon) + var/obj/item/weapon = new npc_weapon(src) + if(put_in_r_hand(weapon)) + npc_prepare_weapon(weapon) + else + qdel(weapon) + +/mob/living/carbon/human/npc/setup_mood() + if(npc_trim_life) + return + return ..() + +/// Switches on whatever the NPC holds so it is ready to fight (energy swords and similar). +/mob/living/carbon/human/npc/proc/npc_prepare_weapon(obj/item/weapon) + if(istype(weapon, /obj/item/melee/energy)) + var/obj/item/melee/energy/esword = weapon + if(!HAS_TRAIT(esword, TRAIT_TRANSFORM_ACTIVE)) + esword.attack_self(src) + +/mob/living/carbon/human/npc/Life(seconds_per_tick = SSMOBS_DT, times_fired) + if(npc_trim_life && npc_dormant()) + return FALSE + return ..() + +/// TRUE while this NPC's Life() should not run. +/mob/living/carbon/human/npc/proc/npc_dormant() + if(stat == DEAD) + return npc_dormant_when_dead + return !npc_players_on_level() + +/// Whether any living player shares this NPC's z-level. Same test as get_expected_ai_status(). +/mob/living/carbon/human/npc/proc/npc_players_on_level() + var/turf/our_turf = get_turf(src) + if(isnull(our_turf)) + return FALSE + return length(SSmobs.clients_by_zlevel[our_turf.z]) > 0 + +/mob/living/carbon/human/npc/ranged + name = "Pirate Gunner" + npc_outfit = /datum/outfit/piratecorpse/ranged + npc_weapon = /obj/item/gun/energy/laser + +/mob/living/carbon/human/npc/melee + name = "Pirate Swashbuckler" + npc_outfit = /datum/outfit/piratecorpse/melee + npc_weapon = /obj/item/melee/energy/sword/pirate + +/** + * Refills a gun an NPC carries, standing in for the spare cells and magazines it doesn't model. + * Returns TRUE if the gun can shoot afterwards. + */ +/obj/item/gun/proc/npc_refill() + return can_shoot() + +/obj/item/gun/energy/npc_refill() + if(QDELETED(cell)) + return FALSE + cell.give(cell.maxcharge) + recharge_newshot(TRUE) + update_appearance() + return can_shoot() + +/obj/item/gun/ballistic/npc_refill() + if(magazine) + magazine.top_off() + if(!chambered) + chamber_round() + update_appearance() + return can_shoot() diff --git a/voidcrew/modules/human_npcs/human_npc_ai.dm b/voidcrew/modules/human_npcs/human_npc_ai.dm new file mode 100644 index 00000000000..cc97c966a18 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_ai.dm @@ -0,0 +1,147 @@ +// AI for human combat NPCs (human_npc.dm). +// +// Built on tg's basic_controller: it only needs a /mob/living pawn, and its get_able_to_run() +// stops the AI at any stat above CONSCIOUS (soft crit, unconscious, hard crit, dead) and while +// incapacitated, so a downed NPC stops acting and lies there for executions and capture. +// Target finding is stock (simple_find_target / basic targeting strategy). Attacks go through the +// held item with controller.ai_interact(), the same click path a player uses, the way tg's monkey +// AI fires guns: guns run their real fire path (projectiles, wounds, crits, executions), melee +// weapons their real attack chain. + +/// Blackboard key: world.time at which the current reload finishes. +#define BB_HUMAN_NPC_RELOAD_DONE_AT "human_npc_reload_done_at" + +/datum/ai_controller/basic_controller/human_npc + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic, + BB_TARGET_MINIMUM_STAT = HARD_CRIT, + ) + ai_movement = /datum/ai_movement/basic_avoidance + idle_behavior = /datum/idle_behavior/idle_random_walk + planning_subtrees = list( + /datum/ai_planning_subtree/escape_captivity, + /datum/ai_planning_subtree/simple_find_target, + /datum/ai_planning_subtree/human_npc_combat, + ) + +/// Picks between shooting the held gun, reloading it, and meleeing with whatever is held. +/datum/ai_planning_subtree/human_npc_combat + +/datum/ai_planning_subtree/human_npc_combat/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + if(!controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/gun = locate() in npc.held_items + if(gun) + if(!gun.can_shoot()) + controller.queue_behavior(/datum/ai_behavior/human_npc_reload) + return SUBTREE_RETURN_FINISH_PLANNING + controller.queue_behavior(/datum/ai_behavior/basic_ranged_attack/human_npc, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + return SUBTREE_RETURN_FINISH_PLANNING + controller.queue_behavior(/datum/ai_behavior/basic_melee_attack/human_npc, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Body zones an NPC aims at, weighted. Players pick their own zone; an NPC's shots spread out. +/proc/human_npc_pick_aim_zone() + var/static/list/aim_zones = list( + BODY_ZONE_CHEST = 40, + BODY_ZONE_HEAD = 15, + BODY_ZONE_L_ARM = 10, + BODY_ZONE_R_ARM = 10, + BODY_ZONE_L_LEG = 10, + BODY_ZONE_R_LEG = 10, + BODY_ZONE_PRECISE_GROIN = 5, + ) + return pick_weight(aim_zones) + +/// Makes `item` the NPC's active hand. Returns FALSE if it isn't held. +/proc/human_npc_ready_item(mob/living/carbon/npc, obj/item/item) + if(isnull(item)) + return FALSE + if(npc.get_active_held_item() == item) + return TRUE + var/index = npc.get_held_index_of_item(item) + if(!index) + return FALSE + npc.swap_hand(index, silent = TRUE) + return npc.get_active_held_item() == item + +// Ranged: the stock trooper ranged attack (spacing, line of sight, friendly fire check), but the +// shot is a click with the held gun. One shot per 3 seconds matches a trooper gunner's damage +// (two lasers every 6 seconds). +/datum/ai_behavior/basic_ranged_attack/human_npc + action_cooldown = 3 SECONDS + required_distance = 5 + avoid_friendly_fire = TRUE + +/datum/ai_behavior/basic_ranged_attack/human_npc/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/npc = controller.pawn + var/atom/target = controller.blackboard[target_key] + var/datum/targeting_strategy/targeting_strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) + + if(!targeting_strategy.can_attack(npc, target, chase_range)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + + var/obj/item/gun/gun = locate() in npc.held_items + if(isnull(gun) || !gun.can_shoot()) + // Succeed so the target is kept; the next plan reloads or switches to melee. + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + + var/atom/hiding_target = targeting_strategy.find_hidden_mobs(npc, target) + var/atom/final_target = hiding_target || target + + if(!can_see(npc, final_target, required_distance)) + return AI_BEHAVIOR_INSTANT + + if(avoid_friendly_fire && check_friendly_in_path(npc, target, targeting_strategy)) + adjust_position(npc, target) + return AI_BEHAVIOR_DELAY + + controller.set_blackboard_key(hiding_location_key, hiding_target) + if(!human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + npc.zone_selected = human_npc_pick_aim_zone() + controller.ai_interact(target = final_target, combat_mode = TRUE) + return AI_BEHAVIOR_DELAY + +// Melee: stock basic melee attack, which already clicks the target through ai_interact(). We only +// make sure the weapon is in the active hand and pick where to hit. +/datum/ai_behavior/basic_melee_attack/human_npc + +/datum/ai_behavior/basic_melee_attack/human_npc/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/weapon = npc.get_active_held_item() || npc.get_inactive_held_item() + if(weapon) + human_npc_ready_item(npc, weapon) + npc.zone_selected = human_npc_pick_aim_zone() + return ..() + +/// Stands still for two seconds, then refills the held gun. +/datum/ai_behavior/human_npc_reload + action_cooldown = 0.5 SECONDS + +/datum/ai_behavior/human_npc_reload/setup(datum/ai_controller/controller) + . = ..() + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/gun = locate() in npc.held_items + if(isnull(gun)) + return FALSE + controller.set_blackboard_key(BB_HUMAN_NPC_RELOAD_DONE_AT, world.time + 2 SECONDS) + npc.visible_message(span_notice("[npc] reloads [gun].")) + playsound(npc, 'sound/items/weapons/gun/general/magazine_insert_full.ogg', 40, TRUE) + +/datum/ai_behavior/human_npc_reload/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/gun = locate() in npc.held_items + if(isnull(gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(world.time < controller.blackboard[BB_HUMAN_NPC_RELOAD_DONE_AT]) + return AI_BEHAVIOR_DELAY + gun.npc_refill() + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + +/datum/ai_behavior/human_npc_reload/finish_action(datum/ai_controller/controller, succeeded) + . = ..() + controller.clear_blackboard_key(BB_HUMAN_NPC_RELOAD_DONE_AT) + +#undef BB_HUMAN_NPC_RELOAD_DONE_AT diff --git a/voidcrew/modules/human_npcs/human_npc_spike_harness.dm b/voidcrew/modules/human_npcs/human_npc_spike_harness.dm new file mode 100644 index 00000000000..64115b563bb --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_spike_harness.dm @@ -0,0 +1,484 @@ +// Measurement harness for the human NPC spike. Compiled only with -DHUMAN_NPC_SPIKE_HARNESS; +// nothing here exists in a normal build. +// +// Boots a clientless world, starts the round, builds a walled, lit, breathable test room in a +// turf reservation, then spawns NPCs of one kind (world.params "spike_kind": human, +// human_untrimmed or trooper) in steps of 20, 50 and 100. At each step it measures them dormant +// (no player on their z-level), awake with nothing to fight, and in combat against damage-proof +// human dummies; then their corpses; then a 25 vs 25 fight to the death. Results go to +// data/human_npc_spike__*: a CSV of per-phase numbers, BYOND profiler dumps, and instance +// censuses. The current phase name is written to data/human_npc_spike__phase.txt so an +// outside sampler can tag process memory readings with it. +#ifdef HUMAN_NPC_SPIKE_HARNESS + +/// Mobs the harness spawned and controls, assoc mob -> TRUE. +GLOBAL_LIST_EMPTY(human_npc_spike_mobs) +/// Whether the harness pretends a player is on the test room's z-level. +GLOBAL_VAR_INIT(human_npc_spike_awake, FALSE) + +/// Same NPC with every Life() trim switched off, to measure what the trims save. +/mob/living/carbon/human/npc/ranged/spike_untrimmed + npc_trim_life = FALSE + npc_dormant_when_dead = FALSE + +/mob/living/carbon/human/npc/npc_players_on_level() + if(GLOB.human_npc_spike_mobs[src]) + return GLOB.human_npc_spike_awake + return ..() + +// A clientless world has no clients on any z-level, so every AI would be off. While "awake", the +// harness's own mobs get the status a mob near a player would get. +/datum/ai_controller/get_expected_ai_status() + if(GLOB.human_npc_spike_awake && GLOB.human_npc_spike_mobs[pawn]) + var/mob/living/living_pawn = pawn + if(living_pawn.stat != DEAD && able_to_run && !on_failed_planning_timeout && isturf(living_pawn.loc) && !living_pawn.client) + return AI_STATUS_ON + return ..() + +#define SPIKE_ROOM_W 30 +#define SPIKE_ROOM_H 36 + +SUBSYSTEM_DEF(human_npc_spike) + name = "Human NPC Spike Harness" + flags = SS_NO_FIRE + /// human, human_untrimmed or trooper + var/kind = "human" + var/mob_type + var/out_prefix + var/csv_path + var/phase_path + var/datum/turf_reservation/reservation + /// Bottom-left floor turf of the test room. + var/turf/origin + var/list/turf/room_turfs = list() + var/list/mob/living/npcs = list() + var/list/mob/living/dummies = list() + +/datum/controller/subsystem/human_npc_spike/Initialize() + Master.sleep_offline_after_initializations = FALSE + var/list/params = world.params + kind = params?["spike_kind"] || "human" + switch(kind) + if("trooper") + mob_type = /mob/living/basic/trooper/pirate/ranged + if("human_untrimmed") + mob_type = /mob/living/carbon/human/npc/ranged/spike_untrimmed + else + kind = "human" + mob_type = /mob/living/carbon/human/npc/ranged + out_prefix = "data/human_npc_spike_[kind]" + csv_path = "[out_prefix]_results.csv" + phase_path = "[out_prefix]_phase.txt" + fdel(csv_path) + rustg_file_write("kind,phase,n,metric,value\n", csv_path) + set_phase("init") + SSticker.start_immediately = TRUE + INVOKE_ASYNC(src, PROC_REF(run_spike)) + return SS_INIT_SUCCESS + +/datum/controller/subsystem/human_npc_spike/proc/set_phase(label) + rustg_file_write(label, phase_path) + log_world("human_npc_spike: phase [label]") + +/datum/controller/subsystem/human_npc_spike/proc/record(phase, n, metric, value) + rustg_file_append("[kind],[phase],[n],[metric],[value]\n", csv_path) + +/datum/controller/subsystem/human_npc_spike/proc/run_spike() + UNTIL(SSticker.current_state == GAME_STATE_PLAYING) + SSticker.delay_end = TRUE + SSprofiler.can_fire = FALSE + world.Profile(PROFILE_STOP) + set_phase("settle_roundstart") + sleep(60 SECONDS) + if(!build_room()) + set_phase("failed_room") + return + sleep(10 SECONDS) + census("baseline") + measure("baseline", 0, 30 SECONDS) + profile("baseline", 0, 15 SECONDS) + + for(var/n in list(20, 50, 100)) + spawn_npcs_to(n) + set_awake(FALSE) + settle("spawned", n, 20 SECONDS) + if(n == 100) + census("spawned_100") + measure("dormant", n, 30 SECONDS) + set_awake(TRUE) + settle("awake_settle", n, 10 SECONDS) + measure("awake", n, 30 SECONDS) + profile("awake", n, 15 SECONDS) + spawn_dummies() + settle("combat_settle", n, 10 SECONDS) + measure("combat", n, 45 SECONDS) + profile("combat", n, 15 SECONDS) + clear_dummies() + set_awake(FALSE) + settle("post_combat", n, 10 SECONDS) + clear_debris() + + bulk_memory(400) + + // Corpses: kill every NPC the way a fight would, then measure the bodies left behind. + set_awake(TRUE) + kill_npcs() + settle("corpse_settle", 100, 20 SECONDS) + census("corpses_100") + measure("corpses_awake", 100, 30 SECONDS) + profile("corpses_awake", 100, 15 SECONDS) + set_awake(FALSE) + measure("corpses_dormant", 100, 30 SECONDS) + clear_room() + settle("cleared", 0, 20 SECONDS) + census("cleared") + + team_fight(25) + settle("after_fight", 0, 10 SECONDS) + clear_room() + settle("cleared_fight", 0, 15 SECONDS) + + bench_spawn_and_kill(20) + clear_room() + settle("cleared_bench", 0, 10 SECONDS) + bench_hits(/obj/projectile/bullet/c9mm, 150) + bench_hits(/obj/projectile/beam/laser, 150) + clear_room() + settle("cleared_hits", 0, 15 SECONDS) + + // The same fight again under the profiler, to see where its time goes. + world.Profile(PROFILE_CLEAR) + world.Profile(PROFILE_START) + team_fight(25, "fight_profiled") + var/fight_profile = world.Profile(PROFILE_REFRESH, format = "json") + world.Profile(PROFILE_STOP) + rustg_file_write(fight_profile, "[out_prefix]_profile_fight_25.json") + settle("after_fight_profiled", 0, 10 SECONDS) + set_phase("done") + +/datum/controller/subsystem/human_npc_spike/proc/settle(label, n, duration) + set_phase("[label]_[n]") + sleep(duration) + +/datum/controller/subsystem/human_npc_spike/proc/build_room() + reservation = SSmapping.request_turf_block_reservation(SPIKE_ROOM_W + 8, SPIKE_ROOM_H + 8, requester = "human NPC spike harness") + if(!reservation) + log_world("human_npc_spike: no reservation") + return FALSE + var/turf/bottom_left = reservation.bottom_left_turfs[1] + origin = locate(bottom_left.x + 4, bottom_left.y + 4, bottom_left.z) + var/area/misc/testroom/room_area = GLOB.areas_by_type[/area/misc/testroom] || new + for(var/dx in -1 to SPIKE_ROOM_W) + for(var/dy in -1 to SPIKE_ROOM_H) + var/turf/spot = locate(origin.x + dx, origin.y + dy, origin.z) + var/edge = dx == -1 || dy == -1 || dx == SPIKE_ROOM_W || dy == SPIKE_ROOM_H + if(edge) + spot = spot.ChangeTurf(/turf/closed/indestructible) + else + spot = spot.ChangeTurf(/turf/open/floor/iron) + room_turfs += spot + spot.change_area(spot.loc, room_area) + CHECK_TICK + record("room", 0, "z", origin.z) + return TRUE + +/// Spawn slot `index` (1-100): four columns, 25 rows, front column first. +/datum/controller/subsystem/human_npc_spike/proc/npc_slot(index, x_front = 9, x_step = -1) + var/col = round((index - 1) / 25) + var/row = (index - 1) % 25 + return locate(origin.x + x_front + col * x_step, origin.y + 5 + row, origin.z) + +/datum/controller/subsystem/human_npc_spike/proc/add_npc(turf/where, list/faction) + var/mob/living/npc = new mob_type(where) + if(faction) + npc.faction = faction.Copy() + npcs += npc + GLOB.human_npc_spike_mobs[npc] = TRUE + RegisterSignal(npc, COMSIG_QDELETING, PROC_REF(forget_mob)) + return npc + +/datum/controller/subsystem/human_npc_spike/proc/forget_mob(mob/living/gone) + SIGNAL_HANDLER + npcs -= gone + dummies -= gone + GLOB.human_npc_spike_mobs -= gone + +/datum/controller/subsystem/human_npc_spike/proc/spawn_npcs_to(n) + set_phase("spawning_[n]") + while(length(npcs) < n) + add_npc(npc_slot(length(npcs) + 1)) + CHECK_TICK + record("spawn", n, "npcs", length(npcs)) + if(length(npcs)) + var/mob/living/sample = npcs[1] + record("spawn", n, "atoms_in_one_npc", length(sample.get_all_contents())) + +/datum/controller/subsystem/human_npc_spike/proc/set_awake(awake) + GLOB.human_npc_spike_awake = awake + for(var/mob/living/npc as anything in GLOB.human_npc_spike_mobs) + var/datum/ai_controller/controller = npc.ai_controller + if(!controller) + continue + controller.can_idle = !awake + controller.reset_ai_status() + +/// BYOND reuses freed heap, so a few dozen mobs barely move the process's private bytes. Spawns +/// `count` more dormant NPCs in the free half of the room so the outside sampler sees a real step, +/// counts the world, then deletes them again. +/datum/controller/subsystem/human_npc_spike/proc/bulk_memory(count) + set_phase("bulk_spawning") + set_awake(FALSE) + var/list/bulk = list() + for(var/i in 1 to count) + var/turf/where = locate(origin.x + 12 + (i - 1) % 18, origin.y + round((i - 1) / 18), origin.z) + bulk += add_npc(where) + CHECK_TICK + settle("bulk", length(npcs), 25 SECONDS) + census("bulk_[length(npcs)]") + settle("bulk_after_census", length(npcs), 15 SECONDS) + set_phase("bulk_deleting") + for(var/mob/living/npc as anything in bulk) + qdel(npc) + CHECK_TICK + settle("bulk_deleted", length(npcs), 30 SECONDS) + +/// Damage-proof clientless humans the NPCs see as hostile. Every hit lands and is processed, but +/// deals nothing, so they never fall and the fight never ends. +/datum/controller/subsystem/human_npc_spike/proc/spawn_dummies() + for(var/k in 0 to 4) + var/turf/where = locate(origin.x + 11, origin.y + 6 + k * 6, origin.z) + var/mob/living/carbon/human/dummy_target = new(where) + dummy_target.faction = list("spike_target") + var/datum/physiology/body = dummy_target.physiology + body.brute_mod = 0 + body.burn_mod = 0 + body.tox_mod = 0 + body.oxy_mod = 0 + body.stamina_mod = 0 + body.bleed_mod = 0 + dummies += dummy_target + RegisterSignal(dummy_target, COMSIG_QDELETING, PROC_REF(forget_mob)) + +/datum/controller/subsystem/human_npc_spike/proc/clear_dummies() + for(var/mob/living/dummy_target as anything in dummies.Copy()) + qdel(dummy_target) + dummies.Cut() + +/// Deletes blood, casings, dropped items and scorch marks left on the floor. +/datum/controller/subsystem/human_npc_spike/proc/clear_debris() + var/removed = 0 + for(var/turf/spot as anything in room_turfs) + for(var/atom/movable/thing as anything in spot) + if(istype(thing, /obj/effect/decal) || isitem(thing)) + qdel(thing) + removed++ + CHECK_TICK + record("debris", length(npcs), "removed", removed) + +/// Deletes everything in the room that isn't a client's mob. +/datum/controller/subsystem/human_npc_spike/proc/clear_room() + for(var/turf/spot as anything in room_turfs) + for(var/atom/movable/thing as anything in spot) + if(ismob(thing)) + var/mob/thing_mob = thing + if(thing_mob.client) + continue + qdel(thing) + CHECK_TICK + npcs.Cut() + GLOB.human_npc_spike_mobs.Cut() + +/// Kills every NPC with a big chest hit, as a fight would. Troopers leave their human corpse. +/datum/controller/subsystem/human_npc_spike/proc/kill_npcs() + set_phase("killing") + for(var/mob/living/npc as anything in npcs.Copy()) + if(QDELETED(npc)) + continue + npc.apply_damage(300, BRUTE, BODY_ZONE_CHEST) + if(!QDELETED(npc) && npc.stat != DEAD) + npc.death() + CHECK_TICK + var/corpses = 0 + for(var/turf/spot as anything in room_turfs) + for(var/mob/living/body in spot) + if(body.stat == DEAD) + corpses++ + record("corpses", 100, "corpses_in_room", corpses) + +/// Times making `count` NPCs one by one, then killing each with one big hit (a trooper's kill +/// includes building its human corpse). A synchronous call can't span ticks, so the tick usage +/// delta of each call is its whole cost. +/datum/controller/subsystem/human_npc_spike/proc/bench_spawn_and_kill(count) + set_phase("bench_spawn_kill") + set_awake(FALSE) + var/list/made = list() + var/spawn_usage = 0 + for(var/i in 1 to count) + var/turf/where = npc_slot(i) + var/start = TICK_USAGE_REAL + made += add_npc(where) + spawn_usage += TICK_USAGE_REAL - start + stoplag() + var/kill_usage = 0 + for(var/mob/living/npc as anything in made) + if(QDELETED(npc)) + continue + var/start = TICK_USAGE_REAL + npc.apply_damage(300, BRUTE, BODY_ZONE_CHEST) + if(!QDELETED(npc) && npc.stat != DEAD) + npc.death() + kill_usage += TICK_USAGE_REAL - start + stoplag() + record("bench", count, "ms_per_spawn", TICK_DELTA_TO_MS(spawn_usage) / count) + record("bench", count, "ms_per_kill", TICK_DELTA_TO_MS(kill_usage) / count) + +/// Times `hits` hits of `projectile_type` from a human shooter on NPCs of the kind under test, +/// aimed at random zones. Each hit is processed the way a fired projectile's impact is (armour +/// check, bullet_act, wounds, gore). Damage carries between hits; an NPC that drops to hard crit +/// or dies is replaced, untimed. +/datum/controller/subsystem/human_npc_spike/proc/bench_hits(projectile_type, hits) + set_phase("bench_hits") + set_awake(FALSE) + var/turf/shooter_turf = locate(origin.x + 20, origin.y + 18, origin.z) + var/turf/target_turf = locate(origin.x + 20, origin.y + 20, origin.z) + var/mob/living/carbon/human/shooter = new(shooter_turf) + shooter.faction = list("spike_shooter") + var/mob/living/target + var/usage = 0 + var/replaced = 0 + for(var/i in 1 to hits) + if(QDELETED(target) || target.stat >= HARD_CRIT) + if(!QDELETED(target)) + qdel(target) + target = add_npc(target_turf) + replaced++ + var/obj/projectile/shot = new projectile_type(target_turf) + shot.firer = shooter + shot.fired_from = shooter + shot.starting = shooter_turf + shot.def_zone = human_npc_pick_aim_zone() + var/start = TICK_USAGE_REAL + target.projectile_hit(shot, shot.def_zone) + usage += TICK_USAGE_REAL - start + qdel(shot) + stoplag() + qdel(shooter) + var/projectile_name = replacetext("[projectile_type]", "/obj/projectile/", "") + record("bench", hits, "ms_per_hit:[projectile_name]", TICK_DELTA_TO_MS(usage) / hits) + record("bench", hits, "targets_used:[projectile_name]", replaced) + +/// Two teams of `per_side`, the kind under test on both, fight until one side is down. +/datum/controller/subsystem/human_npc_spike/proc/team_fight(per_side, label = "fight") + var/list/team_a = list() + var/list/team_b = list() + for(var/i in 1 to per_side) + team_a += add_npc(npc_slot(i, x_front = 9, x_step = -1), list("spike_a")) + team_b += add_npc(npc_slot(i, x_front = 13, x_step = 1), list("spike_b")) + settle("[label]_spawned", per_side, 10 SECONDS) + set_awake(TRUE) + var/datum/callback/fight_over = CALLBACK(src, PROC_REF(fight_over), team_a, team_b) + measure(label, per_side, 180 SECONDS, fight_over) + record(label, per_side, "a_standing", count_standing(team_a)) + record(label, per_side, "b_standing", count_standing(team_b)) + var/dead = 0 + for(var/turf/spot as anything in room_turfs) + for(var/mob/living/body in spot) + if(body.stat == DEAD) + dead++ + record(label, per_side, "dead_bodies", dead) + set_awake(FALSE) + +/datum/controller/subsystem/human_npc_spike/proc/count_standing(list/team) + . = 0 + for(var/mob/living/member as anything in team) + if(!QDELETED(member) && member.stat == CONSCIOUS) + .++ + +/datum/controller/subsystem/human_npc_spike/proc/fight_over(list/team_a, list/team_b) + return !count_standing(team_a) || !count_standing(team_b) + +/// Measures `duration` of play: world.cpu per tick, time dilation, and each subsystem's CPU time +/// from the MC's rolling usage, which records the real tick usage of every fire. +/datum/controller/subsystem/human_npc_spike/proc/measure(label, n, duration, datum/callback/stop_when) + set_phase("[label]_[n]") + for(var/datum/controller/subsystem/subsystem as anything in Master.subsystems) + subsystem.rolling_usage = list() + Master.rolling_usage_length = 30 MINUTES + Master.use_rolling_usage = TRUE + var/start_time = world.time + var/start_real = REALTIMEOFDAY + var/end_time = world.time + duration + var/list/cpu_samples = list() + var/map_cpu_sum = 0 + while(world.time < end_time) + sleep(world.tick_lag) + cpu_samples += world.cpu + map_cpu_sum += world.map_cpu + if(stop_when?.Invoke()) + break + Master.use_rolling_usage = FALSE + var/game_seconds = max((world.time - start_time) / 10, 0.1) + var/real_seconds = max((REALTIMEOFDAY - start_real) / 10, 0.1) + var/samples = max(length(cpu_samples), 1) + var/cpu_sum = 0 + for(var/sample in cpu_samples) + cpu_sum += sample + sortTim(cpu_samples, cmp = GLOBAL_PROC_REF(cmp_numeric_asc)) + record(label, n, "game_seconds", game_seconds) + record(label, n, "real_seconds", real_seconds) + record(label, n, "cpu_avg", cpu_sum / samples) + record(label, n, "cpu_p95", length(cpu_samples) ? cpu_samples[max(1, round(length(cpu_samples) * 0.95))] : 0) + record(label, n, "cpu_max", length(cpu_samples) ? cpu_samples[length(cpu_samples)] : 0) + record(label, n, "map_cpu_avg", map_cpu_sum / samples) + record(label, n, "mob_living_list", length(GLOB.mob_living_list)) + record(label, n, "ai_on", length(GLOB.ai_controllers_by_status[AI_STATUS_ON])) + record(label, n, "npcs_alive", count_alive()) + var/total_ms = 0 + for(var/datum/controller/subsystem/subsystem as anything in Master.subsystems) + var/list/usage = subsystem.rolling_usage + var/sum = 0 + for(var/i in 2 to length(usage) step 2) + sum += usage[i] + subsystem.rolling_usage = list() + if(!sum) + continue + var/ms_per_second = TICK_DELTA_TO_MS(sum) / game_seconds + total_ms += ms_per_second + record(label, n, "ss_ms_per_s:[subsystem.name]", ms_per_second) + record(label, n, "ss_total_ms_per_s", total_ms) + +/datum/controller/subsystem/human_npc_spike/proc/count_alive() + . = 0 + for(var/mob/living/npc as anything in npcs) + if(!QDELETED(npc) && npc.stat != DEAD) + .++ + +/// Runs the BYOND profiler for `duration` and saves its JSON. +/datum/controller/subsystem/human_npc_spike/proc/profile(label, n, duration) + set_phase("profile_[label]_[n]") + world.Profile(PROFILE_CLEAR) + world.Profile(PROFILE_START) + sleep(duration) + var/data = world.Profile(PROFILE_REFRESH, format = "json") + world.Profile(PROFILE_STOP) + rustg_file_write(data, "[out_prefix]_profile_[label]_[n].json") + +/// Counts every atom and datum in the world by type and saves the totals. +/datum/controller/subsystem/human_npc_spike/proc/census(label) + set_phase("census_[label]") + var/list/atoms = count_atoms() + var/list/datums = count_datums() + var/atom_total = 0 + for(var/key in atoms) + atom_total += atoms[key] + var/datum_total = 0 + for(var/key in datums) + datum_total += datums[key] + record("census_[label]", 0, "atoms_total", atom_total) + record("census_[label]", 0, "datums_total", datum_total) + rustg_file_write(json_encode(list("atoms" = atoms, "datums" = datums)), "[out_prefix]_census_[label].json") + +#undef SPIKE_ROOM_W +#undef SPIKE_ROOM_H + +#endif From a7eadf98888dd9d296db679b8231cd23109b45d5 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:30:43 -0800 Subject: [PATCH 034/472] Spray blood from an element so it doesn't replace a mob's own damage handlers --- .../hyperviolence/blood/blood_spray.dm | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/voidcrew/modules/hyperviolence/blood/blood_spray.dm b/voidcrew/modules/hyperviolence/blood/blood_spray.dm index 5f2da954724..de2d3f89441 100644 --- a/voidcrew/modules/hyperviolence/blood/blood_spray.dm +++ b/voidcrew/modules/hyperviolence/blood/blood_spray.dm @@ -23,9 +23,32 @@ /mob/living/Initialize(mapload) . = ..() - RegisterSignal(src, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(hv_blood_on_damaged)) - if(iscarbon(src)) - RegisterSignal(src, COMSIG_CARBON_REMOVE_LIMB, TYPE_PROC_REF(/mob/living/carbon, hv_blood_on_limb_removed)) + AddElement(/datum/element/hv_blood_hooks) + +/// Listens for hits and lost limbs on a mob and sprays its blood. An element rather than the mob +/// registering on itself: a mob holds one handler per signal per listener, so a self-registration +/// would replace (or be replaced by) the mob's own handlers for the same signals. +/datum/element/hv_blood_hooks + +/datum/element/hv_blood_hooks/Attach(datum/target) + . = ..() + if(!isliving(target)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) + if(iscarbon(target)) + RegisterSignal(target, COMSIG_CARBON_REMOVE_LIMB, PROC_REF(on_limb_removed)) + +/datum/element/hv_blood_hooks/Detach(datum/source) + UnregisterSignal(source, list(COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_CARBON_REMOVE_LIMB)) + return ..() + +/datum/element/hv_blood_hooks/proc/on_damaged(mob/living/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + SIGNAL_HANDLER + source.hv_blood_on_damaged(source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + +/datum/element/hv_blood_hooks/proc/on_limb_removed(mob/living/carbon/source, obj/item/bodypart/removed_limb, special, dismembered) + SIGNAL_HANDLER + source.hv_blood_on_limb_removed(source, removed_limb, special, dismembered) /// A fan of blood flung across a floor tile, facing the way it flew. /obj/effect/decal/cleanable/blood/hv_spray From c60d1797567e2c4b3c3fcf06308aec8d287a39f3 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:06:39 -0800 Subject: [PATCH 035/472] Make gun executions a real boosted shot, and gate crits and executions by who aims An execution now fires the gun's own round at the head with doubled damage through tg's normal damage. It kills only if that damage kills and bursts the head only if the round dealt 80 or more to it. The forced head top-up, the forced oxygen kill, the 200 max health exemption and the basic-mob 20% health rule are gone; helpless means tg states. hv_can_land_aimed_crits() decides who may land aimed crits and executions. --- voidcrew/_DEFINES/hyperviolence.dm | 11 +- .../modules/hyperviolence/crits/crit_roll.dm | 15 +- .../hyperviolence/overlays_exec/executions.dm | 174 +++++++++--------- .../voidcrew_hyperviolence_braingut.dm | 6 +- .../voidcrew_hyperviolence_corpses.dm | 27 +-- .../voidcrew_hyperviolence_overlays_exec.dm | 20 +- 6 files changed, 135 insertions(+), 118 deletions(-) diff --git a/voidcrew/_DEFINES/hyperviolence.dm b/voidcrew/_DEFINES/hyperviolence.dm index 29032b53f6c..bc5ceddb75e 100644 --- a/voidcrew/_DEFINES/hyperviolence.dm +++ b/voidcrew/_DEFINES/hyperviolence.dm @@ -30,20 +30,15 @@ /// Wind-up before an execution shot. #define HV_EXECUTION_WINDUP (1.5 SECONDS) -/// Damage multiplier of a shot fired with the barrel against the head, for deciding the outcome. +/// Multiplier on the damage of an execution round, fired with the barrel against the head. The round +/// deals it through tg's normal damage; nothing else is added. #define HV_EXECUTION_POINT_BLANK_MULT 2 -/// Extra multiplier for multi-pellet shells: at the muzzle every pellet lands together. -#define HV_EXECUTION_PELLET_MULT 1.5 -/// Outcome damage (after armour) at which an execution bursts the head instead of holing it. +/// Damage an execution round must deal to the head (after armour) to burst it instead of holing it. #define HV_EXECUTION_BURST_DAMAGE 80 /// A chambered round must deal at least this much to count as an execution round. #define HV_EXECUTION_MIN_DAMAGE 10 -/// Targets with more max health than this take the shot but are not finished off (bosses). -#define HV_EXECUTION_MAX_HEALTH 200 /// do_after interaction key for an execution wind-up. #define DOAFTER_SOURCE_HV_EXECUTION "hv_execution" -/// A humanoid NPC (basic mob) at or below this share of its max health is staggering, and can be executed. -#define HV_EXECUTION_STAGGER_HEALTH 0.2 // ---- NPC corpses (corpses/): the fight carried onto the human body an NPC leaves ---- diff --git a/voidcrew/modules/hyperviolence/crits/crit_roll.dm b/voidcrew/modules/hyperviolence/crits/crit_roll.dm index b37d5250e12..ac5cc07daeb 100644 --- a/voidcrew/modules/hyperviolence/crits/crit_roll.dm +++ b/voidcrew/modules/hyperviolence/crits/crit_roll.dm @@ -11,6 +11,8 @@ // * Only aimed hits count. The attacker must be a carbon (something with a target doll) holding // the weapon or having fired the shot, must not be the victim, and the hit has to land on the // limb they aimed at. NPC basic mobs never land crits, so NPC fights keep tg's normal wounds. +// Human NPCs (voidcrew/modules/human_npcs/) are carbons, but only their elites and bosses land +// crits (owner decision D4, hv_can_land_aimed_crits()). Players always can. // * The hit must be brute damage with a wound roll (wound_bonus not CANT_WOUND) and a weapon or // projectile behind it. Only the aorta can come from a projectile. // * The roll is tg's own wound roll: rand(1, min(damage, 25) ^ 1.4), so 1 to 90. On top of the @@ -163,7 +165,8 @@ GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) return null return new_wound -/// The carbon who aimed this hit: whoever holds the weapon, or fired the projectile. +/// The carbon who aimed this hit: whoever holds the weapon, or fired the projectile. Null if +/// nobody aimed it, or if whoever did may not land aimed crits. /proc/hv_crit_get_aimer(atom/source) var/atom/aimer if(isprojectile(source)) @@ -171,7 +174,15 @@ GLOBAL_LIST_INIT(hv_crits, hv_crit_init_list()) aimer = shot.firer else if(isitem(source)) aimer = source.loc - return iscarbon(aimer) ? aimer : null + if(!iscarbon(aimer)) + return null + var/mob/living/carbon/carbon_aimer = aimer + return carbon_aimer.hv_can_land_aimed_crits() ? carbon_aimer : null + +/// Whether this mob's aimed hits can be aimed crits, and its point-blank head shots executions. +/// Everyone can, bar human NPCs that aren't elites or bosses (voidcrew/modules/human_npcs/human_npc.dm). +/mob/living/proc/hv_can_land_aimed_crits() + return TRUE /// tg's wounding type for a brute hit. Eye stabs and a few other callers pass no sharpness to /// apply_damage, so fall back to the weapon's own. diff --git a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm index 740b74fd083..ec82bfb4b24 100644 --- a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm +++ b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm @@ -4,16 +4,26 @@ // code/modules/projectiles/gun.dm, AGPL-3.0, by the CM-SS13 team), rewritten for tg's guns. // // The trigger: a point-blank click with the head or the mouth selected, on a humanoid who can't fight -// back (down, out cold, dead, cuffed, stunned, held in an aggressive grab, or an NPC staggering on -// its last legs), is an execution: a short wind-up that both have to hold still for, then the gun's -// own shot into the head. A round heavy enough bursts the head (hv_burst_head, which keeps the -// brain); a lighter one leaves a heavy skull wound and finishes them. The brain is never destroyed, -// so revival works as usual. An NPC that leaves a human corpse has the outcome replayed on that -// corpse (corpses/). The mouth click reaches us through tg's handle_suicide(); the head click through +// back (in crit, out cold, dead, lying down, cuffed, stunned or in stamcrit, or held in an aggressive +// grab: tg's own states, nothing else), is an execution: a short wind-up that both have to hold still +// for, then the gun's own round fired into the head, its damage multiplied by +// HV_EXECUTION_POINT_BLANK_MULT for the barrel against the skull. Everything after that is tg's: the +// round's damage after the head's armour, wounds, dismemberment, and death if the damage kills. If the +// round dealt HV_EXECUTION_BURST_DAMAGE or more to the head, the head bursts (hv_burst_head, which keeps +// the brain, so revival works as usual); a lighter round leaves the skull wound its real damage earns. +// Nothing is added to finish the target off. +// +// The mouth click reaches us through tg's handle_suicide(); the head click through // hv_try_head_execution(), called from a marked line in tg's fire_gun(). Anyone the execution does // not apply to keeps tg's behaviour: a normal point-blank shot to the head, or tg's slow mouth // version (which is also how you shoot yourself). Holding someone up (right click) and gun bashes are -// unchanged. +// unchanged. Human NPCs execute only if they are elites or bosses (owner decision D4, +// hv_can_land_aimed_crits()). A basic humanoid NPC that leaves a human corpse has the outcome replayed +// on that corpse (corpses/). + +/obj/item/gun + /// While an execution round is being fired: the damage it has dealt to the victim's head + var/hv_execution_head_damage = 0 /obj/item/gun/handle_suicide(mob/living/carbon/human/user, mob/living/carbon/human/target, params, bypass_timer) if(bypass_timer || !hv_execution_possible(user, target)) @@ -35,6 +45,9 @@ /obj/item/gun/proc/hv_execution_possible(mob/living/user, mob/living/target) if(!isliving(user) || !isliving(target) || user == target) return FALSE + // Executions follow the aimed crit rule: an ordinary human NPC just fires a point-blank shot (D4). + if(!user.hv_can_land_aimed_crits()) + return FALSE if(!target.hv_can_be_executed() || !user.Adjacent(target)) return FALSE return can_shoot() && hv_execution_round_ok() @@ -77,7 +90,11 @@ return FALSE return user.zone_selected == BODY_ZONE_HEAD || user.zone_selected == BODY_ZONE_PRECISE_MOUTH -/// Fires the execution shot. Returns TRUE if the gun went off. +/** + * Fires the execution round: the gun's own shot at the head through its real fire path, with the + * point-blank multiplier on its damage. A point-blank round lands inside process_fire(), so what it + * did to the head is known when that returns. Returns TRUE if the gun went off. + */ /obj/item/gun/proc/hv_fire_execution(mob/living/user, mob/living/target, params) if(!user.is_holding(src) || !user.Adjacent(target) || !can_trigger_gun(user)) return FALSE @@ -86,40 +103,54 @@ return FALSE if(!hv_execution_round_ok()) // the round changed under the wind-up: just a point-blank shot return process_fire(target, user, TRUE, params, BODY_ZONE_HEAD) - var/blow = hv_execution_blow(target) var/shot_dir = get_dir(user, target) - // An NPC the bullet itself kills dies before the shot is settled below, so tell it now. - if((target.hv_leaves_human_corpse || target.hv_gory_own_body) && target.hv_execution_killable()) - target.hv_note_npc_execution(blow, shot_dir) - if(!process_fire(target, user, TRUE, params, BODY_ZONE_HEAD)) + var/was_dead = target.stat == DEAD + // A basic NPC that leaves a human corpse is replaced the moment the round kills it, so its corpse + // is told now how hard the round will land. + if(!was_dead && (target.hv_leaves_human_corpse || target.hv_gory_own_body)) + target.hv_note_npc_execution(hv_execution_blow(target), shot_dir) + hv_execution_head_damage = 0 + RegisterSignal(target, COMSIG_MOB_APPLY_DAMAGE, PROC_REF(hv_execution_hit)) + var/normal_multiplier = projectile_damage_multiplier + projectile_damage_multiplier *= HV_EXECUTION_POINT_BLANK_MULT + // Only the first round of a burst is held to the head; the rest are ordinary shots. + var/fired = process_fire(target, user, TRUE, params, BODY_ZONE_HEAD) + projectile_damage_multiplier = normal_multiplier + if(!QDELETED(target)) + UnregisterSignal(target, COMSIG_MOB_APPLY_DAMAGE) + if(!fired) return FALSE - log_combat(user, target, "executed", src) - if(burst_size > 1) // the burst lands over the next few ticks; settle the outcome once it has - addtimer(CALLBACK(src, PROC_REF(hv_finish_execution), WEAKREF(user), WEAKREF(target), blow, shot_dir), burst_delay * (burst_size - 1) + 1) - else - hv_finish_execution(WEAKREF(user), WEAKREF(target), blow, shot_dir) + log_combat(user, target, "executed", src, "([round(hv_execution_head_damage, 0.1)] damage to the head)") + if(!QDELETED(target)) + target.hv_take_execution(hv_execution_head_damage, shot_dir, was_dead) + hv_execution_witnesses(user, target) return TRUE -/// How hard the chambered round hits a head with the barrel against it, after that head's armour. +/// Adds up what this gun's rounds do to the victim's head while an execution round is fired. +/obj/item/gun/proc/hv_execution_hit(mob/living/source, damage, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, atom/attacking_item) + SIGNAL_HANDLER + var/obj/projectile/round = attacking_item + if(!istype(round) || round.fired_from != src || (damagetype != BRUTE && damagetype != BURN)) + return + var/zone + if(isbodypart(def_zone)) + var/obj/item/bodypart/limb = def_zone + zone = limb.body_zone + else + zone = check_zone(def_zone) + if(zone == BODY_ZONE_HEAD) + hv_execution_head_damage += damage + +/// What the boosted round will deal to this target's head after its armour. Only for the corpse a basic +/// NPC leaves, which is built before the round's damage can be read. /obj/item/gun/proc/hv_execution_blow(mob/living/target) var/obj/projectile/bullet = chambered?.loaded_projectile if(!bullet) return 0 - var/pellets = max(1, chambered.pellets) - var/blow = bullet.damage * projectile_damage_multiplier * pellets * max(1, burst_size) * HV_EXECUTION_POINT_BLANK_MULT - if(pellets > 1) - blow *= HV_EXECUTION_PELLET_MULT + var/blow = bullet.damage * projectile_damage_multiplier * max(1, chambered.pellets) * HV_EXECUTION_POINT_BLANK_MULT var/armor = target.run_armor_check(BODY_ZONE_HEAD, bullet.armor_flag, armour_penetration = bullet.armour_penetration, silent = TRUE, weak_against_armour = bullet.weak_against_armour) return blow * max(0, 100 - armor) / 100 -/// Settles an execution once the shot has landed. -/obj/item/gun/proc/hv_finish_execution(datum/weakref/user_ref, datum/weakref/target_ref, blow, shot_dir) - var/mob/living/target = target_ref?.resolve() - if(QDELETED(target)) - return - target.hv_take_execution(blow, shot_dir) - hv_execution_witnesses(user_ref?.resolve(), target) - /// Everyone who saw it close up feels it, bar the shooter, the victim and those who enjoy that sort of thing. /proc/hv_execution_witnesses(mob/living/shooter, mob/living/victim) for(var/mob/living/carbon/witness in viewers(7, victim)) @@ -142,83 +173,48 @@ return FALSE return hv_is_helpless() -/// Whether this mob is down, out cold, cuffed, stunned or held in an aggressive grab. +/// Whether this mob is in crit, out cold, dead, lying down, cuffed, stunned or in stamcrit, or held +/// in an aggressive grab. tg's own states only. /mob/living/proc/hv_is_helpless() - if(stat >= UNCONSCIOUS || body_position == LYING_DOWN) + if(stat >= SOFT_CRIT || body_position == LYING_DOWN) return TRUE if(HAS_TRAIT(src, TRAIT_RESTRAINED) || HAS_TRAIT(src, TRAIT_INCAPACITATED) || HAS_TRAIT(src, TRAIT_FLOORED)) return TRUE return pulledby?.grab_state >= GRAB_AGGRESSIVE -// An NPC has no crit to fall into: it fights on until it dies. On its last legs it is staggering. -/mob/living/basic/hv_is_helpless() - if(..()) - return TRUE - return maxHealth > 0 && stat != DEAD && health <= maxHealth * HV_EXECUTION_STAGGER_HEALTH - -// Bounty criminals keep their own cuffs, downed and stunned states (voidcrew/modules/bounties/). +// Bounty criminals keep their own cuffs, downed and stunned states (voidcrew/modules/bounties/) until +// the human NPC migration moves them onto real bodies. /mob/living/basic/bounty_criminal/hv_is_helpless() return ..() || is_restrained() || is_downed() || body_is_stunned() -// ---- What the shot does ---- +// ---- What the round did ---- -/// Takes an execution shot that hit with `blow` force, travelling `shot_dir`. Past the shot itself, a -/// target too tough to finish (see hv_execution_killable()) takes nothing extra. A human-looking NPC -/// notes the shot first, so the corpse it leaves, or its own body as it dies, shows the same outcome -/// (corpses/); one already dead shows it at once. -/mob/living/proc/hv_take_execution(blow, shot_dir) - if(stat != DEAD && !hv_execution_killable()) +/** + * Shows what an execution round did. `head_damage` is what it dealt to the head; `was_dead` whether + * the target was already dead before it. Adds no damage and kills nothing: the round did that or not. + * A basic NPC the round killed shows it on the corpse it left (noted before the shot); one whose own + * body was already dead shows it here. + */ +/mob/living/proc/hv_take_execution(head_damage, shot_dir, was_dead) + if(!was_dead || stat != DEAD || hv_body_headless) return - var/heavy = blow >= HV_EXECUTION_BURST_DAMAGE - hv_blood_spray(shot_dir, heavy ? 4 : 3) - if(hv_body_headless) - return // nothing left to shoot - // Only an NPC whose corpse or own body can show it has a head to burst. - var/bursts = heavy && (hv_leaves_human_corpse || hv_body_head_can_burst()) - if(stat != DEAD && (hv_leaves_human_corpse || hv_gory_own_body)) - hv_note_npc_execution(blow, shot_dir, TRUE) + var/bursts = head_damage >= HV_EXECUTION_BURST_DAMAGE && hv_body_head_can_burst() + hv_blood_spray(shot_dir, bursts ? 4 : 3) if(bursts) hv_add_hit_line("[src]'s head bursts apart!", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) - else - hv_add_hit_line("The shot blows out the back of [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) - if(stat != DEAD) - hv_execution_kill() - return - // A body already dead: nothing settles it at death any more, so it shows now. - if(bursts) hv_burst_body_head(shot_dir, FALSE) else if(hv_gory_own_body && hv_looks_human()) + hv_add_hit_line("The shot blows out the back of [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) hv_body_skull = 3 update_appearance(UPDATE_OVERLAYS) -/mob/living/carbon/hv_take_execution(blow, shot_dir) - if(stat != DEAD && !hv_execution_killable()) - return - if(blow >= HV_EXECUTION_BURST_DAMAGE && hv_burst_head(shot_dir)) - hv_execution_kill() - return +/mob/living/carbon/hv_take_execution(head_damage, shot_dir, was_dead) var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) - if(isnull(head)) // the shot itself took it - hv_execution_kill() + if(isnull(head) || head_damage <= 0) + return // the round took the head off itself, or never reached it + if(head_damage >= HV_EXECUTION_BURST_DAMAGE && hv_burst_head(shot_dir)) return - if(!HAS_TRAIT(src, TRAIT_GODMODE) && head.brute_dam < HV_SKULL_HEAVY_BRUTE) - apply_damage(HV_SKULL_HEAVY_BRUTE - head.brute_dam, BRUTE, head, forced = TRUE, wound_bonus = CANT_WOUND) - if(!QDELETED(head) && head.owner == src && head.hv_open_skull()) + // The skull wound the round's real damage earns (gore_overlays.dm); the line only for the worst. + if(head.hv_open_skull() && head.hv_skull_wound >= 3) hv_blood_spray(shot_dir, 3) hv_add_hit_line("The shot blows out the back of [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) - hv_execution_kill() - -/// Whether an execution may finish this mob off: alive, mortal, and not a boss-sized health pool. -/mob/living/proc/hv_execution_killable() - return stat != DEAD && !HAS_TRAIT(src, TRAIT_GODMODE) && maxHealth <= HV_EXECUTION_MAX_HEALTH - -/// Finishes off an execution victim if hv_execution_killable() allows it. -/mob/living/proc/hv_execution_kill() - if(hv_execution_killable()) - adjustBruteLoss(health + 1) - -/mob/living/carbon/hv_execution_kill() - if(!hv_execution_killable()) - return - // The rest goes in as oxygen damage, not more trauma, so the body stays inside defibrillator limits. - adjustOxyLoss(health - HEALTH_THRESHOLD_DEAD + 1, forced = TRUE) diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm index 1d5cfba8794..c507da6dec4 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_braingut.dm @@ -211,12 +211,12 @@ TEST_ASSERT(!head.dismember(BRUTE, TRUE), "A burst blow took the head off a body that cannot be dismembered") TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_HEAD), head, "A burst blow took the head off a body that cannot be dismembered") - // an execution shot that would burst the head holes the skull and kills instead + // an execution round big enough to burst any other head leaves this one on, and adds nothing of its own head.set_brute_dam(0) victim.set_stat(CONSCIOUS) - victim.hv_take_execution(500, NORTH) + victim.hv_take_execution(500, NORTH, FALSE) TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_HEAD), head, "An execution burst the head of a body that cannot be dismembered") - TEST_ASSERT_EQUAL(victim.stat, DEAD, "An execution did not finish a body that cannot be dismembered") + TEST_ASSERT_NOTEQUAL(victim.stat, DEAD, "Settling an execution killed the target on top of the round's own damage") REMOVE_TRAIT(victim, TRAIT_NODISMEMBER, TRAIT_SOURCE_UNIT_TESTS) /// The spill replaces the cut it comes from, drops guts on the floor, and keeps bleeding until they go back in. diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm index 68c52906af5..0805ef40c8b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm @@ -1,5 +1,5 @@ // Hyperviolence (voidcrew/modules/hyperviolence/corpses/): NPC corpses that keep the fight, the -// gore on NPCs that keep their own body, and executions of downed and staggering NPCs. +// gore on NPCs that keep their own body, and executions of downed and stunned NPCs. // Literals carry the name of the voidcrew define they stand for in a comment. /// The human corpse a basic mob just left on `spot`, which was not there in `before`. Cleaned up with the test. @@ -125,16 +125,16 @@ TEST_ASSERT_NOTNULL(head.get_wound_type(/datum/wound/slash/flesh/moderate/hv_tongue), "The corpse has no severed-tongue wound") TEST_ASSERT_NOTNULL(head.get_wound_type(/datum/wound/slash/flesh/critical/hv_carotid), "A pirate with a slit throat left a corpse with an unbroken throat") -/// A humanoid NPC is helpless on its last legs (HV_EXECUTION_STAGGER_HEALTH 0.2), not before. -/datum/unit_test/hv_npc_staggering_executable +/// A humanoid NPC is helpless in tg's states (stunned, cuffed, held), never because of its health. +/datum/unit_test/hv_npc_executable_states -/datum/unit_test/hv_npc_staggering_executable/Run() +/datum/unit_test/hv_npc_executable_states/Run() var/mob/living/basic/trooper/pirate/melee/pirate = hv_still_pirate() TEST_ASSERT(!pirate.hv_can_be_executed(), "A pirate at full health could be executed") - pirate.adjustBruteLoss(pirate.maxHealth * 0.5) - TEST_ASSERT(!pirate.hv_can_be_executed(), "A pirate at half health could be executed") - pirate.adjustBruteLoss(pirate.maxHealth * 0.35) - TEST_ASSERT(pirate.hv_can_be_executed(), "A pirate at 15% health could not be executed") + pirate.adjustBruteLoss(pirate.maxHealth * 0.85) + TEST_ASSERT(!pirate.hv_can_be_executed(), "A pirate on its feet at 15% health could be executed") + pirate.Paralyze(30 SECONDS) + TEST_ASSERT(pirate.hv_can_be_executed(), "A paralysed pirate could not be executed") /// A point-blank click with the head selected executes someone down, and is a plain shot at someone standing. /datum/unit_test/hv_head_aimed_execution @@ -155,12 +155,13 @@ victim.fully_heal(HEAL_ALL) victim.Knockdown(30 SECONDS) gun.fire_cd = FALSE // the standing shot above just fired; skip its cooldown + var/round_damage = gun.chambered.loaded_projectile.damage * gun.projectile_damage_multiplier var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) gun.fire_gun(victim, gunner, TRUE, null) - TEST_ASSERT_EQUAL(victim.stat, DEAD, "A head-aimed point-blank shot at a downed target was not an execution") - TEST_ASSERT_EQUAL(head.hv_skull_wound, 3, "A head-aimed 9mm execution left no heavy skull wound") + TEST_ASSERT(head.brute_dam >= round_damage * 1.5, "A head-aimed point-blank shot at a downed target was not an execution ([head.brute_dam] head damage from a [round_damage] round)") + TEST_ASSERT(head.hv_skull_wound >= 2, "A head-aimed 9mm execution left no skull wound for its damage") -/// Executing a staggering pirate carries the outcome to its corpse: a light round the skull, a heavy one the head. +/// Executing a badly hurt, paralysed pirate carries the outcome to its corpse: a light round the skull, a heavy one the head. /datum/unit_test/hv_npc_execution_corpse /datum/unit_test/hv_npc_execution_corpse/Run() @@ -173,7 +174,8 @@ var/mob/living/basic/trooper/pirate/melee/pirate = hv_still_pirate() pirate.adjustBruteLoss(pirate.maxHealth * 0.85) - TEST_ASSERT(gun.hv_execution_possible(gunner, pirate), "A staggering pirate could not be executed") + pirate.Paralyze(30 SECONDS) + TEST_ASSERT(gun.hv_execution_possible(gunner, pirate), "A paralysed pirate could not be executed") gun.chambered.loaded_projectile.damage = 20 // doubled at the muzzle: 40, under HV_EXECUTION_BURST_DAMAGE 80 var/list/before = range(7, spot) TEST_ASSERT(gun.hv_fire_execution(gunner, pirate, null), "The execution shot did not fire") @@ -186,6 +188,7 @@ var/mob/living/basic/trooper/pirate/melee/second = hv_still_pirate() second.adjustBruteLoss(second.maxHealth * 0.85) + second.Paralyze(30 SECONDS) gun.chambered.loaded_projectile.damage = 60 // doubled: 120 gun.fire_cd = FALSE // the first execution just fired; skip its cooldown before = range(7, spot) diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm index 5d281e0fa6a..1fcc18062f7 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm @@ -73,7 +73,8 @@ for(var/state in dummy.hv_gore_states()) TEST_ASSERT(!findtext(state, "headshot"), "A healed head still draws [state]") -/// Only a downed, cuffed or held humanoid can be executed, and a light round leaves the head on but kills. +/// Only a downed, cuffed or held humanoid can be executed. The round is the gun's own, doubled at the +/// muzzle and dealt through tg's damage: it kills someone in crit, and a healthy target survives it. /datum/unit_test/hv_gun_execution /datum/unit_test/hv_gun_execution/Run() @@ -91,13 +92,24 @@ TEST_ASSERT(gun.hv_execution_possible(gunner, victim), "A downed target could not be executed") // The real click path: a point-blank, mouth-aimed shot, the wind-up, then the shot. + var/round_damage = gun.chambered.loaded_projectile.damage * gun.projectile_damage_multiplier var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) gun.fire_gun(victim, gunner, TRUE, null) - TEST_ASSERT_EQUAL(victim.stat, DEAD, "The executed target survived") TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_HEAD), head, "A 9mm execution took the head off") - TEST_ASSERT_EQUAL(head.hv_skull_wound, 3, "A 9mm execution left no heavy skull wound") + TEST_ASSERT(head.brute_dam >= round_damage * 1.5, "The execution round was not boosted at the muzzle ([head.brute_dam] head damage from a [round_damage] round)") + TEST_ASSERT_NOTEQUAL(victim.stat, DEAD, "A 9mm execution killed a healthy target with more than its round's damage") + TEST_ASSERT(head.hv_skull_wound >= 2, "A 9mm execution left no skull wound for its damage") TEST_ASSERT_NOTNULL(victim.get_organ_slot(ORGAN_SLOT_BRAIN), "A 9mm execution destroyed the brain") - TEST_ASSERT(victim.getBruteLoss() < 180, "An execution left too much trauma to defibrillate") // MAX_REVIVE_BRUTE_DAMAGE is 180 + TEST_ASSERT_EQUAL(gun.projectile_damage_multiplier, initial(gun.projectile_damage_multiplier), "The point-blank boost stayed on the gun") + + // Someone in hard crit dies of the same round's real damage. + var/mob/living/carbon/human/consistent/dying = allocate(/mob/living/carbon/human/consistent) + ADD_TRAIT(dying, TRAIT_PIERCEIMMUNE, INNATE_TRAIT) + dying.apply_damage(150, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND) + TEST_ASSERT(dying.stat >= SOFT_CRIT, "Test fixture: 150 brute did not put the target in crit") + gun.fire_cd = FALSE + TEST_ASSERT(gun.hv_fire_execution(gunner, dying, null), "The execution shot did not fire") + TEST_ASSERT_EQUAL(dying.stat, DEAD, "A 9mm execution of someone in hard crit did not kill them") /// A heavy enough round bursts the head, and the brain survives it. /datum/unit_test/hv_gun_execution_burst From 5782d8cfcdfd55b96f1d35709a8da44852ba1ff3 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:06:47 -0800 Subject: [PATCH 036/472] Build the human NPC base from the spike prototype /mob/living/carbon/human/npc is a real human body with an AI. Its Life() is dormant while no player is on its z-level, and a corpse catches up the organ decay it slept through. Spawns are queued through spawn_human_npc() and built one per fire. Its kit is locked to the body and never becomes loot; ship keys and bounty proof drop on death, and consumables can be thrown or used. Its chrome burns out to scrap. Only elites and bosses land aimed crits and executions. The AI shoots, reloads from carried magazines and fights through the player's click path, and works its cuffs. Corpses are cleaned up after ten minutes with nobody near. Hull turrets shoot human NPC boarders, weather reaches awake human NPCs, pirate crews can enlist them, and the ladder weapon strip leaves them alone. --- code/controllers/subsystem/weather.dm | 3 +- code/datums/weather/weather.dm | 3 +- tgstation.dme | 5 + voidcrew/_DEFINES/human_npcs.dm | 50 ++ voidcrew/edits/mobs/ladder_outfits.dm | 5 +- voidcrew/machinery/ship_defense_turret.dm | 8 +- voidcrew/modules/human_npcs/human_npc.dm | 524 +++++++++++++--- .../modules/human_npcs/human_npc_admin.dm | 30 + voidcrew/modules/human_npcs/human_npc_ai.dm | 564 +++++++++++++++--- voidcrew/modules/human_npcs/human_npc_gear.dm | 235 ++++++++ .../modules/human_npcs/human_npc_samples.dm | 87 +++ .../modules/human_npcs/human_npc_spawning.dm | 184 ++++++ .../human_npcs/human_npc_spike_harness.dm | 94 ++- .../modules/npc_ships/code/pirate_crew.dm | 14 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../modules/unit_tests/voidcrew_human_npcs.dm | 415 +++++++++++++ 16 files changed, 2040 insertions(+), 182 deletions(-) create mode 100644 voidcrew/_DEFINES/human_npcs.dm create mode 100644 voidcrew/modules/human_npcs/human_npc_admin.dm create mode 100644 voidcrew/modules/human_npcs/human_npc_gear.dm create mode 100644 voidcrew/modules/human_npcs/human_npc_samples.dm create mode 100644 voidcrew/modules/human_npcs/human_npc_spawning.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_human_npcs.dm diff --git a/code/controllers/subsystem/weather.dm b/code/controllers/subsystem/weather.dm index ffd151171cc..b2c35e62947 100644 --- a/code/controllers/subsystem/weather.dm +++ b/code/controllers/subsystem/weather.dm @@ -38,7 +38,8 @@ SUBSYSTEM_DEF(weather) mobs_by_z_cache_fire = times_fired for(var/mob/living/candidate as anything in GLOB.mob_living_list) // Weather is player-facing; ordinary planetary fauna should never enter its work list. - if(!candidate.mind && !candidate.ever_had_mind) + // Human NPCs (voidcrew/modules/human_npcs/) are people: weathered like players while awake. + if(!candidate.mind && !candidate.ever_had_mind && !human_npc_weathered(candidate)) continue var/candidate_z = candidate.z if(!candidate_z) // contained mobs report z = 0 diff --git a/code/datums/weather/weather.dm b/code/datums/weather/weather.dm index b1820c4d40b..cf6f55fec98 100644 --- a/code/datums/weather/weather.dm +++ b/code/datums/weather/weather.dm @@ -425,7 +425,8 @@ // VOIDCREW EDIT START - PR #123: ship systems and overmap integration. /datum/weather/proc/can_weather_act_mob(mob/living/mob_to_check) // Preserve effects on abandoned player bodies while excluding ordinary fauna. - if(!mob_to_check.mind && !mob_to_check.ever_had_mind) + // Human NPCs (voidcrew/modules/human_npcs/) are weathered like players while awake. + if(!mob_to_check.mind && !mob_to_check.ever_had_mind && !human_npc_weathered(mob_to_check)) return var/turf/mob_turf = get_turf(mob_to_check) diff --git a/tgstation.dme b/tgstation.dme index 09eb6e0a6f6..9942609a685 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6695,6 +6695,7 @@ #include "voidcrew\_DEFINES\dynamic_events.dm" #include "voidcrew\_DEFINES\flags.dm" #include "voidcrew\_DEFINES\hud.dm" +#include "voidcrew\_DEFINES\human_npcs.dm" #include "voidcrew\_DEFINES\job_categories.dm" #include "voidcrew\_DEFINES\keybindings.dm" #include "voidcrew\_DEFINES\lich.dm" @@ -7230,7 +7231,11 @@ #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" #include "voidcrew\modules\human_npcs\human_npc.dm" +#include "voidcrew\modules\human_npcs\human_npc_admin.dm" #include "voidcrew\modules\human_npcs\human_npc_ai.dm" +#include "voidcrew\modules\human_npcs\human_npc_gear.dm" +#include "voidcrew\modules\human_npcs\human_npc_samples.dm" +#include "voidcrew\modules\human_npcs\human_npc_spawning.dm" #include "voidcrew\modules\human_npcs\human_npc_spike_harness.dm" #include "voidcrew\modules\hyperviolence\_core.dm" #include "voidcrew\modules\hyperviolence\blood\_blood_defines.dm" diff --git a/voidcrew/_DEFINES/human_npcs.dm b/voidcrew/_DEFINES/human_npcs.dm new file mode 100644 index 00000000000..05d46e8c88a --- /dev/null +++ b/voidcrew/_DEFINES/human_npcs.dm @@ -0,0 +1,50 @@ +// Human NPCs: real /mob/living/carbon/human bodies driven by an AI (voidcrew/modules/human_npcs/). +// Unit tests are included before voidcrew/_DEFINES, so nothing here is visible to them. + +/// Returns TRUE if `thing` is a human NPC body. +#define ishumannpc(thing) istype(thing, /mob/living/carbon/human/npc) + +// npc_rank: who may land hyperviolence aimed crits and gun executions (owner decision D4). +/// An ordinary NPC. Its hits never become aimed crits or executions. +#define HUMAN_NPC_RANK_REGULAR 0 +/// An elite NPC. Lands aimed crits and executions the way a player does. +#define HUMAN_NPC_RANK_ELITE 1 +/// A boss NPC. Lands aimed crits and executions the way a player does. +#define HUMAN_NPC_RANK_BOSS 2 + +/// On every item locked to a human NPC's body (human_npc_gear.dm). +#define TRAIT_HUMAN_NPC_GEAR "human_npc_gear" +/// On a consumable issued to a human NPC: it may leave the body, but never becomes loot (human_npc_gear.dm). +#define TRAIT_HUMAN_NPC_CONSUMABLE "human_npc_consumable" +/// On locked gear that went somewhere it may not be, and is deleted on the next tick unless it comes back. +#define TRAIT_HUMAN_NPC_GEAR_LOST "human_npc_gear_lost" + +/// Default for how far a human NPC spots hostiles, in tiles (BB_AGGRO_RANGE). +#define HUMAN_NPC_SIGHT_RANGE 9 + +/// Human NPCs the spawn queue makes per fire of SShuman_npcs. One human costs about 11 ms to make. +#define HUMAN_NPC_SPAWNS_PER_FIRE 1 +/// How often SShuman_npcs looks over the corpses it tracks. +#define HUMAN_NPC_CORPSE_SWEEP_INTERVAL (30 SECONDS) +/// Default time a human NPC corpse lies before it may be cleaned up (npc_corpse_lifetime). +#define HUMAN_NPC_CORPSE_LIFETIME (10 MINUTES) +/// A corpse or loose body part with a player this close is never cleaned up. +#define HUMAN_NPC_CORPSE_WATCH_RANGE 10 +/// Corpses deleted per fire at most, so a big sweep doesn't stall a tick. +#define HUMAN_NPC_CORPSE_DELETES_PER_FIRE 2 + +// Blackboard keys (human_npc_ai.dm). +/// Deciseconds between two pulls of the trigger. +#define BB_HUMAN_NPC_SHOT_DELAY "human_npc_shot_delay" +/// world.time the NPC may next pull the trigger. +#define BB_HUMAN_NPC_NEXT_SHOT "human_npc_next_shot" +/// Furthest a gunner shoots from, in tiles. Further than this it closes in. +#define BB_HUMAN_NPC_FIRE_RANGE "human_npc_fire_range" +/// A gunner steps back from a target closer than this, in tiles. 0 never steps back. +#define BB_HUMAN_NPC_KEEP_AWAY "human_npc_keep_away" +/// world.time the NPC may next try to reload or change weapons, after a try that got nowhere. +#define BB_HUMAN_NPC_ARMING_RETRY_AT "human_npc_arming_retry_at" +/// world.time a reload in progress gives up. +#define BB_HUMAN_NPC_RELOAD_UNTIL "human_npc_reload_until" +/// The item the NPC is drawing (human_npc_draw). +#define BB_HUMAN_NPC_DRAW_ITEM "human_npc_draw_item" diff --git a/voidcrew/edits/mobs/ladder_outfits.dm b/voidcrew/edits/mobs/ladder_outfits.dm index c2925461fe3..4e1344f14b2 100644 --- a/voidcrew/edits/mobs/ladder_outfits.dm +++ b/voidcrew/edits/mobs/ladder_outfits.dm @@ -59,10 +59,11 @@ // Corpse and ghost-role outfits are also fixed starting placements. // Appearance dummies are left alone: get_dynamic_human_appearance() dresses one through the // spawner to draw an NPC's look (a gunner holding its gun) and deletes it straight after, so -// nothing on it can ever be picked up. +// nothing on it can ever be picked up. Human NPCs (voidcrew/modules/human_npcs/) are left alone +// too: their kit is locked to the body and never becomes loot, and it's what they fight with. /obj/effect/mob_spawn/equip(mob/living/spawned_mob) . = ..() - if(ishuman(spawned_mob) && !istype(spawned_mob, /mob/living/carbon/human/dummy)) + if(ishuman(spawned_mob) && !istype(spawned_mob, /mob/living/carbon/human/dummy) && !ishumannpc(spawned_mob)) remove_ladder_starting_weapons(spawned_mob, list()) /datum/voucher_set/mining/crusher_kit diff --git a/voidcrew/machinery/ship_defense_turret.dm b/voidcrew/machinery/ship_defense_turret.dm index 13c244dcdc6..b59033508c0 100644 --- a/voidcrew/machinery/ship_defense_turret.dm +++ b/voidcrew/machinery/ship_defense_turret.dm @@ -288,17 +288,19 @@ return FALSE if(creature.client || creature.mind) // Anything a player is driving, or was driving. return FALSE - if(iscarbon(creature) || issilicon(creature)) // People, monkeys, xenos, borgs, pAIs. + // People, monkeys, xenos, borgs, pAIs. Human NPCs (voidcrew/modules/human_npcs/) are bodies no + // player drives, the human boarders that replace troopers, so they are fair game like them. + if(issilicon(creature) || (iscarbon(creature) && !ishumannpc(creature))) return FALSE if(creature.invisibility > SEE_INVISIBLE_LIVING) return FALSE if(in_faction(creature)) // Bots, pets and other turrets. return FALSE // With wildlife targeting off the turret only watches for boarding parties, which are - // all trooper-type humanoids (pirates and their kin). Lets the crew hunt the local + // all trooper-type humanoids or human NPCs (pirates and their kin). Lets the crew hunt the local // fauna themselves without the turret stealing every kill. // Escaped outpost prisoners count as boarders (outpost_prison_riot.dm). - if(!target_wildlife && !istype(creature, /mob/living/basic/trooper) && !is_loose_outpost_prisoner(creature)) + if(!target_wildlife && !istype(creature, /mob/living/basic/trooper) && !ishumannpc(creature) && !is_loose_outpost_prisoner(creature)) return FALSE if(!is_hostile_creature(creature)) // Livestock, pets and passive fauna get left alone. return FALSE diff --git a/voidcrew/modules/human_npcs/human_npc.dm b/voidcrew/modules/human_npcs/human_npc.dm index d3132901e1a..e4e823d8780 100644 --- a/voidcrew/modules/human_npcs/human_npc.dm +++ b/voidcrew/modules/human_npcs/human_npc.dm @@ -1,70 +1,131 @@ -// Human combat NPCs: a real /mob/living/carbon/human body driven by an ai_controller. -// -// SPIKE PROTOTYPE (branch hv/human-npc-spike). Nothing spawns these in normal play yet. -// -// Because the body is a real human, tg's wounds, dismemberment, soft and hard crit, crawling, -// bleeding out and the hyperviolence executions and crits all apply to it while it is alive, -// with no corpse swap at death. The AI (human_npc_ai.dm) fights through the held item: a gun is -// fired through ClickOn, so the gun's own fire path, projectiles and casings are used. -// -// Life() trimming. A human's Life() runs every SSmobs tick (2 s) for every human in the world, -// with or without a client or a player nearby. NPCs keep everything that matters for a fight -// (blood, wounds, organs, breathing, body temperature) and drop the rest: -// - no mood datum (setup_mood), so nothing processes on SSmood; -// - TRAIT_NOHUNGER, so a long-lived NPC never starves over a 6 hour round; -// - dormancy: Life() does nothing while no player is on the NPC's z-level (the same test tg uses -// to switch the AI off) and always once the NPC is dead. A dormant NPC is frozen in time; a -// dead one never decays or rots. This has to be a guard on Life() itself: COMSIG_LIVING_LIFE's -// cancel flag only stops /mob/living/Life(), and carbon and human Life() run organs, wounds, -// limbs, body temperature, heart and liver around that call whatever it returns. - +/** + * # Human NPCs + * + * A human NPC is a real /mob/living/carbon/human driven by an AI (human_npc_ai.dm). Everything a + * player's body does, it does through the same tg code: wounds, bleeding, dismemberment, soft and hard + * crit, suffocation, stamina damage and stamcrit, stuns, cuffs, grabs, pulling, death, revival, + * organ decay, and the hyperviolence gore and executions. There are no stand-in states, health + * pools or heals. Phases B-E of the human NPC migration build pirates, troopers, hermits and bounty + * criminals on this type; the developer notes are in the kit vault (modules/human_npcs.md). + * + * Making one: spawn_human_npc() (human_npc_spawning.dm) queues it, so a wave is built a body or two + * per tick, never all at once. Maps place /obj/effect/spawner/human_npc. A subtype sets its look, + * outfit, name, faction, rank, toughness and death drops with the vars below. + * + * What differs from a player's body, and why. These are the only deviations: + * - Dormancy (performance). Life() does nothing while no player is on the NPC's z-level, alive or + * dead (npc_dormant()), the same test tg uses to switch the AI off. While dormant nothing in Life() + * runs: bleeding, wound and organ processing, metabolism, breathing, body temperature, pressure, + * crit oxygen loss, natural healing, disease. A dormant NPC is frozen in time; a wounded one doesn't + * bleed out until someone arrives. A corpse that wakes catches up the organ decay it missed while + * dormant (npc_catch_up_decay()), so the defib window and organ rot match a player's corpse; its + * body temperature is not caught up. Stamina recovery is a timer, not Life(), so it is not paused. + * This has to be a guard on this type's own Life(): COMSIG_LIVING_LIFE's cancel flag only stops + * /mob/living/Life(), and carbon and human Life() run organs, wounds, limbs and temperature around it. + * - No mood datum and TRAIT_NOHUNGER (performance, and an NPC never eats over a 6 hour round). + * - Gear lock (owner decision D3, human_npc_gear.dm): its kit can't be stripped, dropped or looted. + * - Aimed crits and gun executions only from elites and bosses (owner decision D4, npc_rank). + */ /mob/living/carbon/human/npc - name = "Pirate" - faction = list(FACTION_PIRATE) + name = "stranger" + faction = list(FACTION_HOSTILE) ai_controller = /datum/ai_controller/basic_controller/human_npc - /// Outfit worn at spawn. - var/datum/outfit/npc_outfit = /datum/outfit/piratecorpse - /// Weapon put in the right hand at spawn, or null for fists. - var/obj/item/npc_weapon - /// Apply the NPC Life() trims (no mood, no hunger, dormancy). Off only for measurement. + /// What it wears and holds: an outfit typepath. Weapons go in its r_hand and l_hand, spare + /// magazines in pockets or belt_contents, an air tank with internals_slot set. + var/datum/outfit/npc_outfit + /** + * How it looks. Null: an ordinary random person of the default species. A + * /obj/effect/mob_spawn typepath: that spawner's species, hair and skin, and its outfit if + * npc_outfit is unset (the corpse spawners of today's NPCs). spawn_human_npc(look =) also takes a + * /datum/bounty_look or a /datum/bounty_record. + */ + var/npc_look + /// Its name. Null keeps the random name its look rolled. + var/npc_name + /// HUMAN_NPC_RANK_*. Only elites and bosses land aimed crits and gun executions. + var/npc_rank = HUMAN_NPC_RANK_REGULAR + /** + * Toughness: brute and burn damage taken is multiplied by this, through the body's physiology, + * as species and cyberware do it for players. Never touches stamina or stuns. 1 is a person. + * Change it on a live NPC with set_npc_toughness(). + */ + var/npc_damage_taken = 1 + /// A /datum/armor added to the body's own physiology armour (dermal plating on a player). Null for none. + var/datum/armor/npc_body_armor + /** + * Cyberware organ typepaths installed at spawn (voidcrew/modules/cyberware/). It works as it does + * in a player: a dodge shunt dodges, plating armours, a brownout browns out. It burns out to scrap + * when the NPC dies or the ware is taken out of it (owner ruling), so it is never salvaged working. + */ + var/list/npc_cyberware + /// Opens an air tank it carries if it has a mask or helmet that takes one and its outfit didn't. + var/npc_internals = TRUE + /// Typepaths spawned under it the first time it dies (its designed loot). Never a second time. + var/list/npc_death_loot + /// Credits dropped as a holochip the first time it dies. + var/npc_death_cash = 0 + /// How long its corpse lies before SShuman_npcs may delete it once nobody is near. 0: never; the + /// system that owns the NPC cleans it up. + var/npc_corpse_lifetime = HUMAN_NPC_CORPSE_LIFETIME + /// Apply the dormancy and life trims. Only the measurement harness turns this off. var/npc_trim_life = TRUE - /// Cancel Life() while dead. - var/npc_dormant_when_dead = TRUE -/mob/living/carbon/human/npc/Initialize(mapload) + /// TRUE once its death loot has dropped + var/npc_loot_dropped = FALSE + /// world.time it last died, or 0 + var/npc_died_at = 0 + /// world.time its current dormant spell began, or 0 while awake + var/npc_dormant_since = 0 + /// The damage multiplier set_npc_toughness() last applied + var/npc_damage_taken_applied = 1 + /// Weakrefs to limbs and organs that came off it, cleaned up with its corpse + var/list/datum/weakref/npc_remains + +/mob/living/carbon/human/npc/Initialize(mapload, datum/human_npc_order/order) . = ..() if(npc_trim_life) ADD_TRAIT(src, TRAIT_NOHUNGER, INNATE_TRAIT) - if(npc_outfit) - equipOutfit(npc_outfit) - if(npc_weapon) - var/obj/item/weapon = new npc_weapon(src) - if(put_in_r_hand(weapon)) - npc_prepare_weapon(weapon) - else - qdel(weapon) + npc_dress(order) + for(var/ware_type in npc_cyberware) + npc_install_cyberware(ware_type) + npc_conceal_presence() + npc_lock_all_gear() + for(var/obj/item/held as anything in held_items) + if(held) + human_npc_ready_weapon(src, held) + npc_open_internals() + set_npc_toughness(npc_damage_taken) + if(npc_body_armor) + physiology.armor = physiology.armor.add_other_armor(npc_body_armor) + AddElement(/datum/element/human_npc_body) + +/mob/living/carbon/human/npc/Destroy() + SShuman_npcs?.forget_corpse(src) + npc_remains = null + return ..() /mob/living/carbon/human/npc/setup_mood() if(npc_trim_life) return return ..() -/// Switches on whatever the NPC holds so it is ready to fight (energy swords and similar). -/mob/living/carbon/human/npc/proc/npc_prepare_weapon(obj/item/weapon) - if(istype(weapon, /obj/item/melee/energy)) - var/obj/item/melee/energy/esword = weapon - if(!HAS_TRAIT(esword, TRAIT_TRANSFORM_ACTIVE)) - esword.attack_self(src) +// ===== DORMANCY ===== /mob/living/carbon/human/npc/Life(seconds_per_tick = SSMOBS_DT, times_fired) - if(npc_trim_life && npc_dormant()) - return FALSE + if(npc_trim_life) + if(npc_dormant()) + npc_dormant_since ||= world.time + return FALSE + if(npc_dormant_since) + npc_catch_up_decay((world.time - npc_dormant_since) / (1 SECONDS), times_fired) + npc_dormant_since = 0 +#ifdef HUMAN_NPC_SPIKE_HARNESS + GLOB.human_npc_spike_life_passes++ +#endif return ..() -/// TRUE while this NPC's Life() should not run. +/// TRUE while this NPC's Life() should not run: no player is on its z-level. /mob/living/carbon/human/npc/proc/npc_dormant() - if(stat == DEAD) - return npc_dormant_when_dead return !npc_players_on_level() /// Whether any living player shares this NPC's z-level. Same test as get_expected_ai_status(). @@ -74,35 +135,348 @@ return FALSE return length(SSmobs.clients_by_zlevel[our_turf.z]) > 0 -/mob/living/carbon/human/npc/ranged - name = "Pirate Gunner" - npc_outfit = /datum/outfit/piratecorpse/ranged - npc_weapon = /obj/item/gun/energy/laser +/** + * A corpse that slept for `slept_seconds` decays its organs for that long at once, through tg's own + * handle_organs() (which skips frozen and robotic organs and bodies kept in formaldehyde or + * cryostylane), so its defib window closes when a player's would. A living NPC catches nothing up. + */ +/mob/living/carbon/human/npc/proc/npc_catch_up_decay(slept_seconds, times_fired) + if(stat != DEAD || slept_seconds <= 0 || HAS_TRAIT(src, TRAIT_STASIS)) + return + handle_organs(slept_seconds, times_fired) + +// ===== DRESSING ===== + +/// Puts on its look, outfit, name and faction. `order` may override the type's own. +/mob/living/carbon/human/npc/proc/npc_dress(datum/human_npc_order/order) + var/look = order?.look || npc_look + var/outfit = order?.outfit || npc_outfit + var/datum/bounty_record/record + if(istype(look, /datum/bounty_record)) + record = look + look = bounty_ensure_look(record) + if(ispath(look, /obj/effect/mob_spawn)) + var/spawner_outfit = npc_apply_spawner_look(look) + outfit ||= spawner_outfit + else if(istype(look, /datum/bounty_look)) + npc_apply_bounty_look(look) + else + npc_apply_random_look() + // Plasmamen and other species that need their own kit to live get it first, as from a spawner. + dna.species.give_important_for_life(src) + // Dressed directly, not through /obj/effect/mob_spawn/equip(), whose ladder strip + // (voidcrew/edits/mobs/ladder_outfits.dm) would delete the guns an NPC fights with. + if(outfit) + equipOutfit(outfit) + if(record) + bounty_wear_features(src, record.features) + var/new_name = order?.name || npc_name + if(new_name) + fully_replace_character_name(real_name, new_name) + if(length(order?.faction)) + faction = order.faction.Copy() + +/// An ordinary random person, as tg's corpse spawners make them. +/mob/living/carbon/human/npc/proc/npc_apply_random_look() + randomize_human_normie(src, update_body = FALSE) + update_body(is_creating = TRUE) + +/// The look of a /obj/effect/mob_spawn typepath, applied the way its special() applies it. Returns its outfit. +/mob/living/carbon/human/npc/proc/npc_apply_spawner_look(obj/effect/mob_spawn/spawner) + var/datum/species/species = initial(spawner.mob_species) + if(ispath(species, /datum/species) && dna.species.type != species) + set_species(species) + underwear = "Nude" + undershirt = "Nude" + socks = "Nude" + randomize_human_normie(src, update_body = FALSE) + var/hairstyle = initial(spawner.hairstyle) + if(hairstyle) + set_hairstyle(hairstyle, update = FALSE) + var/facial_hairstyle = initial(spawner.facial_hairstyle) + if(facial_hairstyle) + set_facial_hairstyle(facial_hairstyle, update = FALSE) + var/haircolor = initial(spawner.haircolor) + if(haircolor) + set_haircolor(haircolor, update = FALSE) + var/facial_haircolor = initial(spawner.facial_haircolor) + if(facial_haircolor) + set_facial_haircolor(facial_haircolor, update = FALSE) + var/spawner_skin = initial(spawner.skin_tone) + if(spawner_skin) + skin_tone = spawner_skin + update_body(is_creating = TRUE) + return initial(spawner.outfit) + +/// A bounty record's face (voidcrew/modules/bounties/), applied in the order bounty_dress_dummy() uses. +/mob/living/carbon/human/npc/proc/npc_apply_bounty_look(datum/bounty_look/look) + // Before the species: its visible organs take their shape from dna.features as they go in. + for(var/feature in look.features) + dna.features[feature] = look.features[feature] + gender = look.physique == FEMALE ? FEMALE : MALE + physique = gender + if(ispath(look.species, /datum/species) && dna.species.type != look.species) + set_species(look.species) + if(look.skin_tone) + skin_tone = look.skin_tone + set_hairstyle(look.hairstyle || "Bald", update = FALSE) + if(look.hair_color) + set_haircolor(look.hair_color, update = FALSE) + set_facial_hairstyle(look.facial_hairstyle || "Shaved", update = FALSE) + if(look.facial_hair_color || look.hair_color) + set_facial_haircolor(look.facial_hair_color || look.hair_color, update = FALSE) + if(look.eye_color) + set_eye_color(look.eye_color) + underwear = "Nude" + undershirt = "Nude" + socks = "Nude" + update_body(is_creating = TRUE) + +/// Keeps its PDA off messenger lists and its suit sensors off crew monitors, as corpse spawners do. +/mob/living/carbon/human/npc/proc/npc_conceal_presence() + var/obj/item/modular_computer/pda/messenger = locate() in src + if(messenger) + var/datum/computer_file/program/messenger/message_app = locate() in messenger.stored_files + if(message_app) + message_app.invisible = TRUE + var/obj/item/clothing/under/sensor_clothes = w_uniform + if(istype(sensor_clothes)) + sensor_clothes.sensor_mode = SENSOR_OFF + update_suit_sensors() + +/// Turns on an air tank it wears, if it has something to breathe it through, the way a player does. +/mob/living/carbon/human/npc/proc/npc_open_internals() + if(!npc_internals || internal || !can_breathe_internals()) + return + for(var/obj/item/tank/internals/tank in get_equipped_items(INCLUDE_POCKETS | INCLUDE_HELD)) + if(open_internals(tank)) + return + +/** + * Sets how much brute and burn damage it takes, as a multiple of a person's. Scales the + * physiology mods it set before, so other changes to them (species, implants) are kept. + */ +/mob/living/carbon/human/npc/proc/set_npc_toughness(damage_taken) + if(damage_taken <= 0) + CRASH("[src] ([type]) was given a damage multiplier of [damage_taken]") + var/change = damage_taken / npc_damage_taken_applied + physiology.brute_mod *= change + physiology.burn_mod *= change + npc_damage_taken_applied = damage_taken + npc_damage_taken = damage_taken + +// ===== RULES OF ENGAGEMENT ===== + +// The strip menu never opens on a human NPC (D3). Carrying and dragging are unaffected. +/mob/living/carbon/human/npc/should_strip(mob/user) + return FALSE + +// D4: an NPC's hits become aimed crits and executions only if it is an elite or a boss, or a +// person is driving the body. +/mob/living/carbon/human/npc/hv_can_land_aimed_crits() + return client || npc_rank >= HUMAN_NPC_RANK_ELITE + +/mob/living/carbon/human/npc/Login() + . = ..() + // A player in control of the body (a brain transplant) can't keep its gear (D3). Admins + // driving an NPC for an event keep it. + if(!. || isnull(client) || client.holder) + return + npc_delete_locked_gear() + +// ===== DEATH AND REMAINS ===== + +/mob/living/carbon/human/npc/death(gibbed) + if(stat == DEAD) + return ..() + . = ..() + if(stat != DEAD) + return + npc_died_at = world.time + npc_drop_death_loot() + npc_drop_not_gear() + npc_burn_out_chrome() + if(!gibbed && npc_corpse_lifetime && !QDELING(src)) + SShuman_npcs.track_corpse(src) + +/// Drops its designed loot under it, once in its life however often it dies. +/mob/living/carbon/human/npc/proc/npc_drop_death_loot() + if(npc_loot_dropped) + return + npc_loot_dropped = TRUE + var/atom/drop_spot = drop_location() + if(isnull(drop_spot)) + return + // As death_drops does for basic mobs (voidcrew/edits/mobs/weapon_drops.dm): admin-spawned NPCs drop no guns. + var/no_guns = (flags_1 & ADMIN_SPAWNED_1) || npc_spawn_context?.suppress_gun_loot + for(var/loot_type in npc_roll_death_loot()) + if(no_guns && ispath(loot_type, /obj/item/gun)) + continue + new loot_type(drop_spot) + if(npc_death_cash > 0) + new /obj/item/holochip(drop_spot, npc_death_cash) + +/// The typepaths it drops on its first death. Override to roll them (zone ladders, rare pools). +/mob/living/carbon/human/npc/proc/npc_roll_death_loot() + return npc_death_loot + +/// Back from the dead (defib, strange reagent): no longer a corpse to clean up. +/mob/living/carbon/human/npc/proc/npc_revived() + npc_died_at = 0 + SShuman_npcs.forget_corpse(src) + +/// A limb or organ came off: remember it, so it goes when the corpse does. +/mob/living/carbon/human/npc/proc/npc_lost_part(obj/item/part) + LAZYADD(npc_remains, WEAKREF(part)) + +// ===== CYBERWARE ===== + +/** + * Installs a cyberware organ at spawn, the way the cyberware code's own staging inserts do + * (special = TRUE skips the install-context gate; the brownout monitor still applies). Returns the + * organ, or null if it didn't go in. + */ +/mob/living/carbon/human/npc/proc/npc_install_cyberware(ware_type) + var/obj/item/organ/ware = new ware_type() + if(!ware.GetComponent(/datum/component/cyberware)) + stack_trace("[type] was given [ware_type] as cyberware, which isn't chrome") + qdel(ware) + return null + if(!ware.Insert(src, special = TRUE)) + qdel(ware) + return null + return ware + +/// Owner ruling: an NPC's chrome burns out when it dies. Every piece comes out and turns to scrap. +/mob/living/carbon/human/npc/proc/npc_burn_out_chrome() + var/atom/drop_spot = drop_location() + var/burnt = 0 + for(var/obj/item/organ/ware in organs.Copy()) + if(!ware.GetComponent(/datum/component/cyberware)) + continue + ware.Remove(src, special = TRUE) + if(drop_spot) + ware.forceMove(drop_spot) + human_npc_burn_out_chrome(ware) + burnt++ + if(burnt && drop_spot) + do_sparks(min(burnt, 3), TRUE, src) + visible_message(span_warning("[src]'s chrome sparks and goes dead.")) + +/// Turns a piece of a human NPC's chrome that has left its body into scrap where it lies. +/proc/human_npc_burn_out_chrome(obj/item/organ/ware) + if(QDELETED(ware) || ware.owner) + return + var/turf/floor = get_turf(ware) + if(isnull(floor)) + qdel(ware) + return + var/mob/living/holder = ismob(ware.loc) ? ware.loc : null + var/obj/item/burnt_chrome/scrap = new(floor, ware) + qdel(ware) + holder?.put_in_hands(scrap) + +/// What a human NPC's cyberware leaves once it burns out: scrap, never a working implant. +/obj/item/burnt_chrome + name = "burnt-out chrome" + desc = "Cyberware fused black inside. Only good for scrap." + icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' + icon_state = "scrapper" + w_class = WEIGHT_CLASS_SMALL + color = "#4a4a4a" + custom_materials = list(/datum/material/iron = SMALL_MATERIAL_AMOUNT) + +/obj/item/burnt_chrome/Initialize(mapload, obj/item/organ/ware) + . = ..() + if(isnull(ware)) + return + name = "burnt-out [format_text(ware.name)]" + icon = ware.icon + icon_state = ware.icon_state + +/** + * Listens to a human NPC's body for the base type. An element, not RegisterSignal(src) on the mob, + * so a subtype can register these signals on itself without replacing the base's handlers. + */ +/datum/element/human_npc_body + element_flags = ELEMENT_DETACH_ON_HOST_DESTROY -/mob/living/carbon/human/npc/melee - name = "Pirate Swashbuckler" - npc_outfit = /datum/outfit/piratecorpse/melee - npc_weapon = /obj/item/melee/energy/sword/pirate +/datum/element/human_npc_body/Attach(datum/target) + . = ..() + if(!ishumannpc(target)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_CARBON_REMOVE_LIMB, PROC_REF(on_lost_limb)) + RegisterSignal(target, COMSIG_CARBON_LOSE_ORGAN, PROC_REF(on_lost_organ)) + RegisterSignal(target, COMSIG_LIVING_REVIVE, PROC_REF(on_revived)) + +/datum/element/human_npc_body/Detach(datum/source) + UnregisterSignal(source, list(COMSIG_CARBON_REMOVE_LIMB, COMSIG_CARBON_LOSE_ORGAN, COMSIG_LIVING_REVIVE)) + return ..() + +/datum/element/human_npc_body/proc/on_lost_limb(mob/living/carbon/human/npc/source, obj/item/bodypart/limb, special, dismembered) + SIGNAL_HANDLER + if(!special) + source.npc_lost_part(limb) + +/datum/element/human_npc_body/proc/on_lost_organ(mob/living/carbon/human/npc/source, obj/item/organ/organ, special) + SIGNAL_HANDLER + // Chrome taken out of an NPC, by surgery, a lost limb, gibbing or dusting, burns out like it does at + // death. Next tick, once whatever moved it has put it down. + if(organ.GetComponent(/datum/component/cyberware)) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(human_npc_burn_out_chrome), organ), 0) + return + if(!special) + source.npc_lost_part(organ) + +/datum/element/human_npc_body/proc/on_revived(mob/living/carbon/human/npc/source, full_heal_flags) + SIGNAL_HANDLER + source.npc_revived() /** - * Refills a gun an NPC carries, standing in for the spare cells and magazines it doesn't model. - * Returns TRUE if the gun can shoot afterwards. + * Whether its corpse may be cleaned up now: dead for npc_corpse_lifetime, with no player within + * HUMAN_NPC_CORPSE_WATCH_RANGE, and not being dragged, carried or held. */ -/obj/item/gun/proc/npc_refill() - return can_shoot() +/mob/living/carbon/human/npc/proc/npc_corpse_expired() + if(stat != DEAD || !npc_corpse_lifetime || !npc_died_at) + return FALSE + if(world.time < npc_died_at + npc_corpse_lifetime) + return FALSE + if(pulledby || buckled || ismob(loc)) + return FALSE + return !human_npc_watched(src) + +/// Deletes its corpse, and the limbs and organs it lost that lie unwatched on the floor. +/mob/living/carbon/human/npc/proc/npc_fade() + for(var/datum/weakref/remain_ref as anything in npc_remains) + var/obj/item/remain = remain_ref.resolve() + if(QDELETED(remain) || !isturf(remain.loc) || remain.pulledby) + continue + var/obj/item/bodypart/limb = remain + if(istype(limb) && limb.owner) + continue + var/obj/item/organ/organ = remain + if(istype(organ) && organ.owner) + continue + if(human_npc_watched(remain)) + continue + qdel(remain) + npc_remains = null + qdel(src) + +/// Whether a player is within HUMAN_NPC_CORPSE_WATCH_RANGE of `thing`. +/proc/human_npc_watched(atom/thing) + var/turf/spot = get_turf(thing) + if(isnull(spot) || !length(SSmobs.clients_by_zlevel[spot.z])) + return FALSE + for(var/mob/viewer as anything in SSspatial_grid.orthogonal_range_search(spot, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, HUMAN_NPC_CORPSE_WATCH_RANGE)) + if(get_dist(viewer, spot) <= HUMAN_NPC_CORPSE_WATCH_RANGE) + return TRUE + return FALSE -/obj/item/gun/energy/npc_refill() - if(QDELETED(cell)) +/// Whether tg weather should act on `candidate`, a mob that has never had a mind: a human NPC that +/// isn't dormant. Read by the mind gates in tg's weather code. +/proc/human_npc_weathered(mob/living/candidate) + if(!ishumannpc(candidate)) return FALSE - cell.give(cell.maxcharge) - recharge_newshot(TRUE) - update_appearance() - return can_shoot() - -/obj/item/gun/ballistic/npc_refill() - if(magazine) - magazine.top_off() - if(!chambered) - chamber_round() - update_appearance() - return can_shoot() + var/mob/living/carbon/human/npc/npc = candidate + return !npc.npc_dormant() diff --git a/voidcrew/modules/human_npcs/human_npc_admin.dm b/voidcrew/modules/human_npcs/human_npc_admin.dm new file mode 100644 index 00000000000..88da89ce822 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_admin.dm @@ -0,0 +1,30 @@ +// Admin debug tool: spawn human NPCs around yourself, through the same throttled queue the game uses. + +ADMIN_VERB(spawn_human_npcs, R_SPAWN|R_DEBUG, "Spawn Human NPCs", "Spawn human NPCs of one type around you, built a few per tick.", ADMIN_CATEGORY_DEBUG) + var/turf/center = get_turf(user.mob) + if(isnull(center)) + to_chat(user, span_warning("You need to be standing somewhere."), confidential = TRUE) + return + var/list/choices = list() + for(var/npc_type in subtypesof(/mob/living/carbon/human/npc)) + var/mob/living/carbon/human/npc/npc_path = npc_type + choices["[initial(npc_path.npc_name) || initial(npc_path.name)] ([npc_type])"] = npc_type + var/choice = tgui_input_list(user, "Which human NPC?", "Spawn Human NPCs", sort_list(choices)) + if(isnull(choice) || isnull(choices[choice])) + return + var/count = tgui_input_number(user, "How many?", "Spawn Human NPCs", 1, 50, 1) + if(!count) + return + var/list/spots = list() + for(var/turf/open/spot in RANGE_TURFS(3, center)) + if(!spot.is_blocked_turf(exclude_mobs = TRUE)) + spots += spot + if(!length(spots)) + spots += center + var/npc_type = choices[choice] + var/datum/callback/mark = CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(human_npc_mark_admin_spawned)) + for(var/i in 1 to count) + spawn_human_npc(npc_type, pick(spots), on_spawned = mark) + log_admin("[key_name(user)] spawned [count] [npc_type] at [AREACOORD(center)]") + message_admins("[key_name_admin(user)] spawned [count] [npc_type] at [ADMIN_VERBOSEJMP(center)]") + BLACKBOX_LOG_ADMIN_VERB("Spawn Human NPCs") diff --git a/voidcrew/modules/human_npcs/human_npc_ai.dm b/voidcrew/modules/human_npcs/human_npc_ai.dm index cc97c966a18..490b88dec4a 100644 --- a/voidcrew/modules/human_npcs/human_npc_ai.dm +++ b/voidcrew/modules/human_npcs/human_npc_ai.dm @@ -1,47 +1,314 @@ -// AI for human combat NPCs (human_npc.dm). -// -// Built on tg's basic_controller: it only needs a /mob/living pawn, and its get_able_to_run() -// stops the AI at any stat above CONSCIOUS (soft crit, unconscious, hard crit, dead) and while -// incapacitated, so a downed NPC stops acting and lies there for executions and capture. -// Target finding is stock (simple_find_target / basic targeting strategy). Attacks go through the -// held item with controller.ai_interact(), the same click path a player uses, the way tg's monkey -// AI fires guns: guns run their real fire path (projectiles, wounds, crits, executions), melee -// weapons their real attack chain. - -/// Blackboard key: world.time at which the current reload finishes. -#define BB_HUMAN_NPC_RELOAD_DONE_AT "human_npc_reload_done_at" +/** + * # Human NPC AI + * + * Fights through the body's own hands, the way a player does. Everything goes through + * controller.ai_interact(), which is a player's click (tg's monkey AI and ranged_skirmish use it too): + * - a gun is fired by clicking the target with it, so the gun's real fire path runs: fire delay, + * firing pin, projectile, casing, recoil, wounds and the hyperviolence executions; + * - a melee weapon (or a fist) hits through the real attack chain; + * - a reload is the player's reload: the spare magazine comes out of a pocket, belt or bag into the + * free hand, the gun is clicked with it (tg's insert or tactical reload), the bolt is racked by + * using the gun, and the empty magazine is dropped. Nothing refills from nowhere. When the spares + * run out it draws another weapon it carries, or fights with what it holds. + * + * tg's basic controller already stops the AI at any stat above CONSCIOUS (soft crit and worse) and + * while stunned, knocked out, in stamcrit or in stasis. It also stops while cuffed, which would keep + * tg's escape_captivity subtree from ever working the cuffs; get_able_to_run() below lets a cuffed or + * grabbed NPC struggle the way a player does (resist), and do_after()s pause it so a breakout isn't + * cut short by walking. The AI goes off with the body's dormancy: tg switches it off while no player + * is on the z-level, the same test human_npc.dm uses for Life(). + * + * Phase B (PR #500's pirate crews) can drop these subtrees into its own planning lists: + * human_npc_arm, human_npc_shoot and human_npc_melee need only BB_BASIC_MOB_CURRENT_TARGET and + * BB_TARGETING_STRATEGY; the shooting keys have defaults. The helper procs at the bottom are for + * any behaviour that has to handle a human NPC's weapons. + */ /datum/ai_controller/basic_controller/human_npc blackboard = list( - BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic, + 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, ) ai_movement = /datum/ai_movement/basic_avoidance - idle_behavior = /datum/idle_behavior/idle_random_walk + idle_behavior = /datum/idle_behavior/idle_random_walk/less_walking planning_subtrees = list( /datum/ai_planning_subtree/escape_captivity, /datum/ai_planning_subtree/simple_find_target, - /datum/ai_planning_subtree/human_npc_combat, + /datum/ai_planning_subtree/human_npc_arm, + /datum/ai_planning_subtree/human_npc_shoot, + /datum/ai_planning_subtree/human_npc_melee, ) -/// Picks between shooting the held gun, reloading it, and meleeing with whatever is held. -/datum/ai_planning_subtree/human_npc_combat +/datum/ai_controller/basic_controller/human_npc/TryPossessPawn(atom/new_pawn) + if(!ishuman(new_pawn)) + return AI_CONTROLLER_INCOMPATIBLE + // Relays who hit us, so we turn on someone shooting from where we weren't looking + new_pawn.AddElement(/datum/element/relay_attackers) + RegisterSignal(new_pawn, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_pawn_attacked)) + return ..() -/datum/ai_planning_subtree/human_npc_combat/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - if(!controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) +/datum/ai_controller/basic_controller/human_npc/UnpossessPawn(destroy) + if(pawn) + UnregisterSignal(pawn, COMSIG_ATOM_WAS_ATTACKED) + return ..() + +// The basic controller's own check, except that cuffs don't stop us (see the top of this file). +/datum/ai_controller/basic_controller/human_npc/get_able_to_run() + if(HAS_TRAIT(pawn, TRAIT_AI_PAUSED) || world.time < paused_until) + return AI_UNABLE_TO_RUN + var/mob/living/living_pawn = pawn + if(living_pawn.stat && !(ai_traits & CAN_ACT_WHILE_DEAD)) + return AI_UNABLE_TO_RUN + var/ignore_incap_flags = INCAPABLE_RESTRAINTS + if(ai_traits & CAN_ACT_IN_STASIS) + ignore_incap_flags |= INCAPABLE_STASIS + if(ai_traits & CAN_ACT_WHILE_GRABBED) + ignore_incap_flags |= INCAPABLE_GRAB + if(INCAPACITATED_IGNORING(living_pawn, ignore_incap_flags)) + return AI_UNABLE_TO_RUN + if((ai_traits & PAUSE_DURING_DO_AFTER) && LAZYLEN(living_pawn.do_afters)) + return AI_UNABLE_TO_RUN | AI_PREVENT_CANCEL_ACTIONS + return NONE + +/// Someone hit us: with nothing else to fight, fight them, if we can see them. +/datum/ai_controller/basic_controller/human_npc/proc/on_pawn_attacked(mob/living/source, atom/attacker, attack_flags) + SIGNAL_HANDLER + if(source == attacker || QDELETED(attacker) || blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) return + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(blackboard[BB_TARGETING_STRATEGY]) + if(strategy?.can_attack(source, attacker)) + set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, attacker) + +// ===== TARGETING ===== + +/** + * tg's basic targeting, but a check made without a range (target finding filters its candidates + * that way) uses the NPC's sight range instead of can_see()'s default of 5 tiles. It also never + * picks someone in another packed encounter on the same z-level, who can't be reached across the + * cordon (the containment the old simple-animal hostiles have in ListTargetsLazy()). + */ +/datum/targeting_strategy/basic/human_npc + +/datum/targeting_strategy/basic/human_npc/can_attack(mob/living/living_mob, atom/the_target, vision_range) + if(isnull(vision_range)) + vision_range = living_mob.ai_controller?.blackboard[BB_AGGRO_RANGE] || HUMAN_NPC_SIGHT_RANGE + if(!..(living_mob, the_target, vision_range)) + return FALSE + return !map_region_excludes_turf(map_region_for_turf(get_turf(living_mob)), get_turf(the_target)) + +// ===== ARMING: RELOAD OR DRAW ===== + +/// Keeps a usable weapon in hand: reloads a spent gun from the magazines it carries, or draws another weapon. +/datum/ai_planning_subtree/human_npc_arm + +/datum/ai_planning_subtree/human_npc_arm/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) var/mob/living/carbon/npc = controller.pawn - var/obj/item/gun/gun = locate() in npc.held_items - if(gun) - if(!gun.can_shoot()) - controller.queue_behavior(/datum/ai_behavior/human_npc_reload) - return SUBTREE_RETURN_FINISH_PLANNING - controller.queue_behavior(/datum/ai_behavior/basic_ranged_attack/human_npc, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + if(HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || LAZYLEN(npc.do_afters)) + return + if(world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT]) + return + var/obj/item/gun/gun = human_npc_held_gun(npc) + if(gun?.can_shoot()) + return + if(human_npc_can_reload(npc, gun)) + controller.queue_behavior(/datum/ai_behavior/human_npc_reload) return SUBTREE_RETURN_FINISH_PLANNING + if(human_npc_held_melee_weapon(npc)) + return + var/obj/item/backup = human_npc_backup_weapon(npc) + if(isnull(backup)) + return + controller.set_blackboard_key(BB_HUMAN_NPC_DRAW_ITEM, backup) + controller.queue_behavior(/datum/ai_behavior/human_npc_draw, BB_HUMAN_NPC_DRAW_ITEM) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * A player's reload, one step per action: drop the spent magazine, rack a loaded gun, take a spare + * into the free hand, click the gun with it. Fails, and the NPC fights on with something else, if + * it has no spare or no hand free. + */ +/datum/ai_behavior/human_npc_reload + action_cooldown = 0.8 SECONDS + /// Gives up after this long, so a gun that won't take what it carries can't hold the NPC forever + var/give_up_after = 8 SECONDS + +/datum/ai_behavior/human_npc_reload/setup(datum/ai_controller/controller) + . = ..() + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/ballistic/gun = human_npc_held_gun(npc) + if(!istype(gun)) + return FALSE + controller.set_blackboard_key(BB_HUMAN_NPC_RELOAD_UNTIL, world.time + give_up_after) + npc.visible_message(span_notice("[npc] reloads [gun].")) + +/datum/ai_behavior/human_npc_reload/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/ballistic/gun = human_npc_held_gun(npc) + if(!istype(gun) || world.time >= controller.blackboard[BB_HUMAN_NPC_RELOAD_UNTIL]) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/obj/item/spent = human_npc_spent_ammo_in_hand(npc, gun) + if(spent) + npc.dropItemToGround(spent, force = TRUE) + return AI_BEHAVIOR_DELAY + if(gun.can_shoot()) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(human_npc_gun_needs_rack(gun)) + if(!human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + var/obj/item/ammo = human_npc_find_ammo(npc, gun) + if(isnull(ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!npc.is_holding(ammo)) + if(!human_npc_take_in_hand(npc, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/human_npc_reload/finish_action(datum/ai_controller/controller, succeeded) + . = ..() + // A reload that got nowhere isn't tried again straight away; the NPC fights with what it has. + controller.set_blackboard_key(BB_HUMAN_NPC_ARMING_RETRY_AT, succeeded ? 0 : world.time + 10 SECONDS) + +/// Takes another weapon it carries into hand: a loaded gun, or failing that a melee weapon. +/datum/ai_behavior/human_npc_draw + action_cooldown = 0.6 SECONDS + +/datum/ai_behavior/human_npc_draw/perform(seconds_per_tick, datum/ai_controller/controller, item_key) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/weapon = controller.blackboard[item_key] + if(QDELETED(weapon) || human_npc_holding(weapon) != npc) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!npc.is_holding(weapon)) + if(!length(npc.get_empty_held_indexes())) + // Both hands full: holster the spent gun first, if it has somewhere to go. + var/obj/item/gun/spent = human_npc_held_gun(npc) + if(isnull(spent) || spent.can_shoot() || !spent.equip_to_best_slot(npc)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_take_in_hand(npc, weapon)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + human_npc_ready_weapon(npc, weapon) + human_npc_ready_item(npc, weapon) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + +/datum/ai_behavior/human_npc_draw/finish_action(datum/ai_controller/controller, succeeded, item_key) + . = ..() + controller.clear_blackboard_key(item_key) + if(!succeeded) + controller.set_blackboard_key(BB_HUMAN_NPC_ARMING_RETRY_AT, world.time + 10 SECONDS) + +// ===== SHOOTING ===== + +/** + * With a loaded gun in hand: shoots the target from up to BB_HUMAN_NPC_FIRE_RANGE, stepping back + * from anyone closer than BB_HUMAN_NPC_KEEP_AWAY, and closes in when it can't see them or they are + * too far. + */ +/datum/ai_planning_subtree/human_npc_shoot + +/datum/ai_planning_subtree/human_npc_shoot/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(QDELETED(target)) + return + var/mob/living/carbon/npc = controller.pawn + if(HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || isnull(human_npc_loaded_gun(npc))) + return + var/fire_range = controller.blackboard[BB_HUMAN_NPC_FIRE_RANGE] || 7 + var/distance = get_dist(npc, target) + if(distance <= fire_range && can_see(npc, target, fire_range)) + var/keep_away = controller.blackboard[BB_HUMAN_NPC_KEEP_AWAY] + if(keep_away && distance < keep_away && !LAZYLEN(npc.do_afters)) + controller.queue_behavior(/datum/ai_behavior/step_away, BB_BASIC_MOB_CURRENT_TARGET) + controller.queue_behavior(/datum/ai_behavior/human_npc_shoot, 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 + +/// One pull of the trigger, then a wait of BB_HUMAN_NPC_SHOT_DELAY. Doesn't hold up planning. +/datum/ai_behavior/human_npc_shoot + action_cooldown = 0.5 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/human_npc_shoot/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/npc = controller.pawn + var/atom/target = controller.blackboard[target_key] + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) + if(QDELETED(target) || !strategy?.can_attack(npc, target)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(isnull(gun)) + // Empty now: the next plan reloads or changes weapon. + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(world.time < controller.blackboard[BB_HUMAN_NPC_NEXT_SHOT]) + return AI_BEHAVIOR_DELAY + var/atom/hiding_target = strategy.find_hidden_mobs(npc, target) + controller.set_blackboard_key(hiding_location_key, hiding_target) + var/atom/final_target = hiding_target || target + if(human_npc_ally_in_line(npc, final_target)) + human_npc_sidestep(npc, final_target) + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + npc.zone_selected = human_npc_pick_aim_zone() + controller.ai_interact(target = final_target, combat_mode = TRUE) + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_SHOT, world.time + (controller.blackboard[BB_HUMAN_NPC_SHOT_DELAY] || 3 SECONDS)) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED + +/// Walks at the target until it is within `range` and in sight. +/datum/ai_behavior/human_npc_close_in + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/human_npc_close_in/setup(datum/ai_controller/controller, target_key, range) + . = ..() + var/atom/target = controller.blackboard[target_key] + if(QDELETED(target)) + return FALSE + set_movement_target(controller, target) + +/datum/ai_behavior/human_npc_close_in/perform(seconds_per_tick, datum/ai_controller/controller, target_key, range) + var/atom/target = controller.blackboard[target_key] + if(QDELETED(target)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(get_dist(controller.pawn, target) <= range && can_see(controller.pawn, target, range)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_INSTANT + +// ===== MELEE ===== + +/// With no loaded gun: walks up and hits with the best melee weapon held, or a fist. +/datum/ai_planning_subtree/human_npc_melee + +/datum/ai_planning_subtree/human_npc_melee/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + if(!controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return controller.queue_behavior(/datum/ai_behavior/basic_melee_attack/human_npc, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) return SUBTREE_RETURN_FINISH_PLANNING -/// Body zones an NPC aims at, weighted. Players pick their own zone; an NPC's shots spread out. +// tg's basic melee attack already clicks the target through ai_interact(); this picks what to hit with and where. +/datum/ai_behavior/basic_melee_attack/human_npc + +/datum/ai_behavior/basic_melee_attack/human_npc/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/weapon = human_npc_held_melee_weapon(npc) + if(weapon) + human_npc_ready_item(npc, weapon) + else + human_npc_ready_empty_hand(npc) + npc.zone_selected = human_npc_pick_aim_zone() + return ..() + +// ===== HELPERS ===== + +/// Body zones an NPC aims at, weighted. A player picks their own zone; an NPC's hits spread out. /proc/human_npc_pick_aim_zone() var/static/list/aim_zones = list( BODY_ZONE_CHEST = 40, @@ -54,7 +321,7 @@ ) return pick_weight(aim_zones) -/// Makes `item` the NPC's active hand. Returns FALSE if it isn't held. +/// Makes `item` the active hand. Returns FALSE if `npc` isn't holding it. /proc/human_npc_ready_item(mob/living/carbon/npc, obj/item/item) if(isnull(item)) return FALSE @@ -66,82 +333,197 @@ npc.swap_hand(index, silent = TRUE) return npc.get_active_held_item() == item -// Ranged: the stock trooper ranged attack (spacing, line of sight, friendly fire check), but the -// shot is a click with the held gun. One shot per 3 seconds matches a trooper gunner's damage -// (two lasers every 6 seconds). -/datum/ai_behavior/basic_ranged_attack/human_npc - action_cooldown = 3 SECONDS - required_distance = 5 - avoid_friendly_fire = TRUE +/// Makes an empty hand the active one, for a punch. Returns FALSE if both hands are full. +/proc/human_npc_ready_empty_hand(mob/living/carbon/npc) + if(isnull(npc.get_active_held_item())) + return TRUE + var/list/empty = npc.get_empty_held_indexes() + if(!length(empty)) + return FALSE + npc.swap_hand(empty[1], silent = TRUE) + return isnull(npc.get_active_held_item()) -/datum/ai_behavior/basic_ranged_attack/human_npc/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) - var/mob/living/carbon/npc = controller.pawn - var/atom/target = controller.blackboard[target_key] - var/datum/targeting_strategy/targeting_strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) +/// Switches on or wields a weapon in hand the way its user would: energy blades, batons, two-handed weapons. +/proc/human_npc_ready_weapon(mob/living/carbon/npc, obj/item/weapon) + if(QDELETED(weapon) || !npc.is_holding(weapon)) + return + if(weapon.GetComponent(/datum/component/transforming)) + if(!HAS_TRAIT(weapon, TRAIT_TRANSFORM_ACTIVE)) + weapon.attack_self(npc) + return + if(istype(weapon, /obj/item/melee/baton/security)) + var/obj/item/melee/baton/security/baton = weapon + if(!baton.active) + baton.attack_self(npc) + return + if(weapon.GetComponent(/datum/component/two_handed) && !HAS_TRAIT(weapon, TRAIT_WIELDED) && length(npc.get_empty_held_indexes())) + weapon.attack_self(npc) - if(!targeting_strategy.can_attack(npc, target, chase_range)) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED +/// The gun it holds, preferring one that can fire. +/proc/human_npc_held_gun(mob/living/carbon/npc) + var/obj/item/gun/found + for(var/obj/item/gun/gun in npc.held_items) + if(gun.can_shoot()) + return gun + found ||= gun + return found - var/obj/item/gun/gun = locate() in npc.held_items - if(isnull(gun) || !gun.can_shoot()) - // Succeed so the target is kept; the next plan reloads or switches to melee. - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED +/// A gun it holds that can fire right now, or null. +/proc/human_npc_loaded_gun(mob/living/carbon/npc) + for(var/obj/item/gun/gun in npc.held_items) + if(gun.can_shoot()) + return gun + return null - var/atom/hiding_target = targeting_strategy.find_hidden_mobs(npc, target) - var/atom/final_target = hiding_target || target +/// Whether `item` is any use as a melee weapon. +/proc/human_npc_is_melee_weapon(obj/item/item) + if(isnull(item) || isgun(item) || isbodypart(item) || isorgan(item) || (item.item_flags & ABSTRACT)) + return FALSE + // Energy blades and batons are weak until switched on. + return item.force >= 10 || item.GetComponent(/datum/component/transforming) || istype(item, /obj/item/melee/baton) - if(!can_see(npc, final_target, required_distance)) - return AI_BEHAVIOR_INSTANT +/// The best melee weapon it holds, or null. +/proc/human_npc_held_melee_weapon(mob/living/carbon/npc) + var/obj/item/best + for(var/obj/item/held in npc.held_items) + if(human_npc_is_melee_weapon(held) && (isnull(best) || held.force > best.force)) + best = held + return best - if(avoid_friendly_fire && check_friendly_in_path(npc, target, targeting_strategy)) - adjust_position(npc, target) - return AI_BEHAVIOR_DELAY +/// Something better to fight with that it carries but doesn't hold: a loaded gun first, then the strongest melee weapon. +/proc/human_npc_backup_weapon(mob/living/carbon/npc) + var/obj/item/best_melee + for(var/obj/item/thing as anything in npc.get_all_contents_type(/obj/item)) + if(npc.is_holding(thing) || !human_npc_can_draw(npc, thing)) + continue + if(isgun(thing)) + var/obj/item/gun/spare = thing + if(spare.can_shoot()) + return spare + continue + if(human_npc_is_melee_weapon(thing) && (isnull(best_melee) || thing.force > best_melee.force)) + best_melee = thing + return best_melee - controller.set_blackboard_key(hiding_location_key, hiding_target) - if(!human_npc_ready_item(npc, gun)) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - npc.zone_selected = human_npc_pick_aim_zone() - controller.ai_interact(target = final_target, combat_mode = TRUE) - return AI_BEHAVIOR_DELAY +/// Whether `item` is somewhere a person can take it from in a moment: a worn slot, or a bag or pouch it wears. +/proc/human_npc_can_draw(mob/living/carbon/npc, obj/item/item) + if(isbodypart(item) || isorgan(item) || istype(item, /obj/item/implant)) + return FALSE + if(item.loc == npc) + return !isnull(npc.get_slot_by_item(item)) + var/obj/item/container = item.loc + return istype(container) && !isnull(container.atom_storage) && human_npc_holding(container) == npc -// Melee: stock basic melee attack, which already clicks the target through ai_interact(). We only -// make sure the weapon is in the active hand and pick where to hit. -/datum/ai_behavior/basic_melee_attack/human_npc +/// Takes `item` out of a slot or a bag into a free hand. Returns TRUE if it ends up held. +/proc/human_npc_take_in_hand(mob/living/carbon/npc, obj/item/item) + if(npc.is_holding(item)) + return TRUE + if(!length(npc.get_empty_held_indexes()) || !human_npc_can_draw(npc, item)) + return FALSE + if(item.loc == npc) + if(!npc.temporarilyRemoveItemFromInventory(item)) + return FALSE + else + var/obj/item/container = item.loc + if(!container.atom_storage.attempt_remove(item, npc, silent = TRUE)) + return FALSE + if(npc.put_in_hands(item)) + return TRUE + npc.equip_to_appropriate_slot(item) + return FALSE -/datum/ai_behavior/basic_melee_attack/human_npc/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) - var/mob/living/carbon/npc = controller.pawn - var/obj/item/weapon = npc.get_active_held_item() || npc.get_inactive_held_item() - if(weapon) - human_npc_ready_item(npc, weapon) - npc.zone_selected = human_npc_pick_aim_zone() - return ..() +/// Live rounds in a magazine, box or casing. +/proc/human_npc_live_rounds(obj/item/ammo) + if(isammocasing(ammo)) + var/obj/item/ammo_casing/casing = ammo + return casing.loaded_projectile ? 1 : 0 + if(istype(ammo, /obj/item/ammo_box/magazine)) + var/obj/item/ammo_box/magazine/magazine = ammo + return magazine.ammo_count(FALSE) + var/obj/item/ammo_box/box = ammo + if(!istype(box)) + return 0 + . = 0 + for(var/obj/item/ammo_casing/round as anything in box.stored_ammo) + if(ispath(round) || round.loaded_projectile) + .++ -/// Stands still for two seconds, then refills the held gun. -/datum/ai_behavior/human_npc_reload - action_cooldown = 0.5 SECONDS +/// Whether a ballistic gun has live rounds loaded but none chambered, so using it (rack, pump, drop the bolt) readies it. +/proc/human_npc_gun_needs_rack(obj/item/gun/ballistic/gun) + if(gun.bolt_type == BOLT_TYPE_NO_BOLT || gun.chambered?.loaded_projectile) + return FALSE + return gun.magazine && human_npc_live_rounds(gun.magazine) > 0 -/datum/ai_behavior/human_npc_reload/setup(datum/ai_controller/controller) - . = ..() - var/mob/living/carbon/npc = controller.pawn - var/obj/item/gun/gun = locate() in npc.held_items - if(isnull(gun)) +/// Spare ammunition it carries that `gun` takes: a loaded magazine for a detachable-magazine gun, +/// or a box, clip, speedloader or loose round of its calibre for one with an internal magazine. +/proc/human_npc_find_ammo(mob/living/carbon/npc, obj/item/gun/ballistic/gun) + if(!istype(gun)) + return null + // Whatever is already in hand first, then pockets, belt and bags. + for(var/obj/item/held in npc.held_items) + if(held != gun && human_npc_ammo_fits(gun, held)) + return held + for(var/obj/item/thing as anything in npc.get_all_contents_type(/obj/item)) + if(npc.is_holding(thing) || thing == gun.magazine || thing.loc == gun || !human_npc_can_draw(npc, thing)) + continue + if(human_npc_ammo_fits(gun, thing)) + return thing + return null + +/// Whether `ammo` loads `gun` and has a live round in it. +/proc/human_npc_ammo_fits(obj/item/gun/ballistic/gun, obj/item/ammo) + if(!gun.internal_magazine) + return istype(ammo, gun.accepted_magazine_type) && human_npc_live_rounds(ammo) > 0 + var/caliber = gun.magazine?.caliber + if(isnull(caliber)) return FALSE - controller.set_blackboard_key(BB_HUMAN_NPC_RELOAD_DONE_AT, world.time + 2 SECONDS) - npc.visible_message(span_notice("[npc] reloads [gun].")) - playsound(npc, 'sound/items/weapons/gun/general/magazine_insert_full.ogg', 40, TRUE) + if(istype(ammo, /obj/item/ammo_box)) + var/obj/item/ammo_box/box = ammo + return box.caliber == caliber && human_npc_live_rounds(box) > 0 + if(isammocasing(ammo)) + var/obj/item/ammo_casing/casing = ammo + return casing.caliber == caliber && !isnull(casing.loaded_projectile) + return FALSE -/datum/ai_behavior/human_npc_reload/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/carbon/npc = controller.pawn - var/obj/item/gun/gun = locate() in npc.held_items - if(isnull(gun)) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - if(world.time < controller.blackboard[BB_HUMAN_NPC_RELOAD_DONE_AT]) - return AI_BEHAVIOR_DELAY - gun.npc_refill() - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED +/// Whether it carries anything that makes `gun` fire again. +/proc/human_npc_can_reload(mob/living/carbon/npc, obj/item/gun/ballistic/gun) + if(!istype(gun)) + return FALSE + return human_npc_gun_needs_rack(gun) || !isnull(human_npc_find_ammo(npc, gun)) -/datum/ai_behavior/human_npc_reload/finish_action(datum/ai_controller/controller, succeeded) - . = ..() - controller.clear_blackboard_key(BB_HUMAN_NPC_RELOAD_DONE_AT) +/// A spent magazine, empty box or spent casing in its hands that's no use to `gun`. +/proc/human_npc_spent_ammo_in_hand(mob/living/carbon/npc, obj/item/gun/ballistic/gun) + for(var/obj/item/held in npc.held_items) + if(held == gun || !(istype(held, /obj/item/ammo_box) || isammocasing(held))) + continue + if(!human_npc_live_rounds(held)) + return held + return null -#undef BB_HUMAN_NPC_RELOAD_DONE_AT +/// Whether an ally stands between `shooter` and `target`. Shots pass over anyone lying down. +/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)) + if(line_turf == shooter_turf || line_turf == target_turf) + continue + for(var/mob/living/other in line_turf) + if(other.stat == DEAD || other.body_position == LYING_DOWN) + continue + if(shooter.faction_check_atom(other)) + return TRUE + return FALSE + +/// 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) + var/list/possible_turfs = list() + for(var/direction in GLOB.alldirs) + var/turf/step_turf = get_step(our_turf, direction) + if(isnull(step_turf) || step_turf.is_blocked_turf() || get_dist(step_turf, target) > get_dist(shooter, target)) + continue + possible_turfs += step_turf + if(!length(possible_turfs)) + return + var/turf/picked = pick(possible_turfs) + step(shooter, get_dir(shooter, picked)) diff --git a/voidcrew/modules/human_npcs/human_npc_gear.dm b/voidcrew/modules/human_npcs/human_npc_gear.dm new file mode 100644 index 00000000000..ff85d481fc5 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_gear.dm @@ -0,0 +1,235 @@ +// Human NPC gear lock (owner decision D3): everything a human NPC wears or holds stays on its body +// and never becomes loot. Its designed death drops (human_npc.dm, npc_drop_death_loot()) are the +// only things a player gets from killing one. +// +// How it holds, using tg's own inventory paths rather than special cases per route: +// * A drop that would put the item anywhere but on a human NPC is refused (COMSIG_ITEM_PRE_UNEQUIP): +// disarm shoves, a held item dropped on a knockdown, gib item drops, a stray transferItemToLoc(). +// The NPC's own moves inside its inventory (magazine into its gun, gun into its holster, a tank +// into its suit storage) still work, because they end on the NPC. +// * The strip menu never opens on a human NPC (should_strip() in human_npc.dm). +// * Anything that forces the item out of the body anyway (an arm cut off still holding the gun, a +// head knocked off with the helmet on, species changes, admin undress) lands outside the NPC, and +// the item is deleted on the next tick (COMSIG_MOVABLE_MOVED). The wait lets tg code that moves an +// item twice in one call finish: a tactical reload drops the old magazine on the floor and then +// puts it back in the NPC's hand. +// * Locked items can't be sold (COMSIG_ITEM_PRE_EXPORT) and carry no materials, so an NPC corpse put +// through the cargo shuttle or an emagged recycler yields nothing for its kit. +// * When the body is deleted (corpse cleanup, gibbing, a site unloading) the gear goes with it, +// since /atom/movable/Destroy() deletes contents. +// * A player who ends up in control of an NPC body (a brain transplant) loses its gear at once +// (human_npc.dm, Login()). +// +// Organs and body parts are the body, not gear: they come off and can be harvested the way a +// player's do (cyberware is the exception: it burns out to scrap, human_npc.dm). Ammunition casings +// are not locked either, so spent brass stays on the floor. +// +// The exceptions, explicit and tested: +// * GLOB.human_npc_not_gear: items whose loss would break something outside the NPC (a ship key, a +// contract's recovery item, bounty proof). Never locked; they drop at the NPC's feet when it dies. +// * GLOB.human_npc_consumable_gear: things an NPC uses up (grenades, bottles, syringe darts, medipens, +// pills, thrown spears, stars and bolas). They may leave its body the way a person's would: thrown, +// fired, dropped or knocked out of its hand. They still never become loot: in anyone's hands or +// bags but a human NPC's they are deleted, and loose ones go when the NPC that carried them does. + +/// Items an NPC carries that aren't its gear: never locked, dropped when it dies. Their loss would +/// break something outside the NPC (the cryo pod refuses to delete the first two for the same reason). +GLOBAL_LIST_INIT(human_npc_not_gear, typecacheof(list( + /obj/item/ship_key, + /obj/item/mission_recovery, + /obj/item/bounty_proof, +))) + +/// Items an NPC uses up: they leave its body like a person's would, but never become loot. +GLOBAL_LIST_INIT(human_npc_consumable_gear, typecacheof(list( + /obj/item/grenade, + /obj/item/reagent_containers/cup/glass/bottle, + /obj/item/reagent_containers/syringe, + /obj/item/reagent_containers/hypospray/medipen, + /obj/item/reagent_containers/applicator/pill, + /obj/item/spear, + /obj/item/throwing_star, + /obj/item/restraints/legcuffs/bola, +))) + +/// Trait source for the gear lock's traits, shared by the element and the consumable component. +#define HUMAN_NPC_GEAR_TRAIT_SOURCE "human_npc_gear_lock" + +/// Locks an item to the human NPC body that carries it. Safe to call again on a locked item. +/datum/element/human_npc_gear + element_flags = ELEMENT_DETACH_ON_HOST_DESTROY + +/datum/element/human_npc_gear/Attach(datum/target) + . = ..() + if(!isitem(target)) + return ELEMENT_INCOMPATIBLE + var/obj/item/gear = target + ADD_TRAIT(gear, TRAIT_HUMAN_NPC_GEAR, HUMAN_NPC_GEAR_TRAIT_SOURCE) + RegisterSignal(gear, COMSIG_ITEM_PRE_UNEQUIP, PROC_REF(on_pre_unequip)) + RegisterSignal(gear, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(gear, COMSIG_ITEM_PRE_EXPORT, PROC_REF(on_pre_export)) + human_npc_gear_strip_materials(gear) + +/datum/element/human_npc_gear/Detach(datum/source) + UnregisterSignal(source, list(COMSIG_ITEM_PRE_UNEQUIP, COMSIG_MOVABLE_MOVED, COMSIG_ITEM_PRE_EXPORT)) + REMOVE_TRAIT(source, TRAIT_HUMAN_NPC_GEAR, HUMAN_NPC_GEAR_TRAIT_SOURCE) + return ..() + +/// Refuses any drop or hand-over that would leave the NPC's body. Forced moves are let through and +/// caught by on_moved(). +/datum/element/human_npc_gear/proc/on_pre_unequip(obj/item/source, force, atom/newloc, no_move, invdrop, silent) + SIGNAL_HANDLER + if(force || isnull(newloc) || human_npc_holding(newloc)) + return NONE + return COMPONENT_ITEM_BLOCK_UNEQUIP + +/datum/element/human_npc_gear/proc/on_moved(obj/item/source, atom/old_loc, dir, forced, list/old_locs) + SIGNAL_HANDLER + human_npc_gear_check(source) + +/datum/element/human_npc_gear/proc/on_pre_export(obj/item/source) + SIGNAL_HANDLER + return COMPONENT_STOP_EXPORT + +/** + * Something an NPC uses up (GLOB.human_npc_consumable_gear). It leaves the body like anyone's would: + * thrown, fired from a dart gun, dropped, knocked from a hand. Lying loose or in flight it stays; in + * anyone's hands or bags but a human NPC's it is deleted; loose, it goes when its NPC does. + */ +/datum/component/human_npc_consumable + /// The NPC it was issued to + var/mob/living/carbon/human/npc/owner + +/datum/component/human_npc_consumable/Initialize(mob/living/carbon/human/npc/owner) + if(!isitem(parent) || !ishumannpc(owner)) + return COMPONENT_INCOMPATIBLE + src.owner = owner + RegisterSignal(owner, COMSIG_QDELETING, PROC_REF(on_owner_deleted)) + human_npc_gear_strip_materials(parent) + +/datum/component/human_npc_consumable/RegisterWithParent() + ADD_TRAIT(parent, TRAIT_HUMAN_NPC_CONSUMABLE, HUMAN_NPC_GEAR_TRAIT_SOURCE) + RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(parent, COMSIG_ITEM_PRE_EXPORT, PROC_REF(on_pre_export)) + +/datum/component/human_npc_consumable/UnregisterFromParent() + REMOVE_TRAIT(parent, TRAIT_HUMAN_NPC_CONSUMABLE, HUMAN_NPC_GEAR_TRAIT_SOURCE) + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_ITEM_PRE_EXPORT)) + +/datum/component/human_npc_consumable/Destroy(force) + if(owner) + UnregisterSignal(owner, COMSIG_QDELETING) + owner = null + return ..() + +/datum/component/human_npc_consumable/proc/on_moved(obj/item/source, atom/old_loc, dir, forced, list/old_locs) + SIGNAL_HANDLER + human_npc_gear_check(source) + +/datum/component/human_npc_consumable/proc/on_pre_export(obj/item/source) + SIGNAL_HANDLER + return COMPONENT_STOP_EXPORT + +/// Its NPC is gone: a loose one goes too. One still on the NPC is deleted with it anyway. +/datum/component/human_npc_consumable/proc/on_owner_deleted(datum/source) + SIGNAL_HANDLER + var/obj/item/item = parent + UnregisterSignal(owner, COMSIG_QDELETING) + owner = null + if(!QDELETED(item) && !human_npc_holding(item)) + qdel(item) + +/// The human NPC that `place` is, or is carried by at any depth (a gun in its hand, a magazine in a +/// pouch on its belt, its body in a bag). Null when the chain ends on a turf or in nullspace first. +/proc/human_npc_holding(atom/place) + while(place && !isturf(place)) + if(ishumannpc(place)) + return place + place = place.loc + return null + +/// Whether `item` may be where it is: on a human NPC, or for a consumable, also loose or in flight. +/proc/human_npc_gear_allowed_at(obj/item/item) + if(human_npc_holding(item.loc)) + return TRUE + if(!HAS_TRAIT(item, TRAIT_HUMAN_NPC_CONSUMABLE)) + return FALSE + return isturf(item.loc) || istype(item.loc, /obj/projectile) + +/// After a move: locked gear that ended up somewhere it may not be is deleted on the next tick, unless +/// it is back where it may be by then. +/proc/human_npc_gear_check(obj/item/item) + if(QDELING(item) || human_npc_gear_allowed_at(item) || HAS_TRAIT(item, TRAIT_HUMAN_NPC_GEAR_LOST)) + return + ADD_TRAIT(item, TRAIT_HUMAN_NPC_GEAR_LOST, HUMAN_NPC_GEAR_TRAIT_SOURCE) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(human_npc_gear_settle), item), 0) + +/// A locked item that went somewhere it may not be last tick: deleted, unless it is back by now. +/proc/human_npc_gear_settle(obj/item/gear) + if(QDELETED(gear)) + return + REMOVE_TRAIT(gear, TRAIT_HUMAN_NPC_GEAR_LOST, HUMAN_NPC_GEAR_TRAIT_SOURCE) + if(human_npc_gear_allowed_at(gear)) + return + qdel(gear) + +/// Locked gear keeps no salvage value. Material effects (a plasteel helmet's colour and armour) live in +/// custom_materials, so only plain items lose theirs. +/proc/human_npc_gear_strip_materials(obj/item/gear) + if(length(gear.custom_materials) && !(gear.material_flags & MATERIAL_EFFECTS)) + gear.set_custom_materials(null) + +/// Whether `item` is locked to a human NPC's body. For tests and admin tools. +/proc/human_npc_gear_locked(obj/item/item) + return !QDELETED(item) && HAS_TRAIT(item, TRAIT_HUMAN_NPC_GEAR) + +/// Whether `item` is a consumable issued to a human NPC. For tests and admin tools. +/proc/human_npc_gear_consumable(obj/item/item) + return !QDELETED(item) && HAS_TRAIT(item, TRAIT_HUMAN_NPC_CONSUMABLE) + +/** + * Locks `item` to this NPC's body, or issues it as a consumable, by the lists above. Items that aren't + * gear, body parts, organs, ammunition casings and abstract items are left alone. Use it for anything + * handed to an NPC after it spawned. Returns TRUE if the item is now locked or issued. + */ +/mob/living/carbon/human/npc/proc/npc_lock_item(obj/item/item) + if(QDELETED(item) || isbodypart(item) || isorgan(item) || isammocasing(item) || (item.item_flags & ABSTRACT)) + return FALSE + if(is_type_in_typecache(item, GLOB.human_npc_not_gear)) + return FALSE + if(HAS_TRAIT(item, TRAIT_HUMAN_NPC_GEAR) || HAS_TRAIT(item, TRAIT_HUMAN_NPC_CONSUMABLE)) + return TRUE + if(is_type_in_typecache(item, GLOB.human_npc_consumable_gear)) + item.AddComponent(/datum/component/human_npc_consumable, src) + else + item.AddElement(/datum/element/human_npc_gear) + return TRUE + +/// Locks everything the NPC carries: worn, held, in pockets and bags, implants and skillchips. +/mob/living/carbon/human/npc/proc/npc_lock_all_gear() + for(var/obj/item/item as anything in get_all_contents_type(/obj/item)) + npc_lock_item(item) + +/// Deletes every locked or issued item the NPC carries. Used when a player takes over the body. +/mob/living/carbon/human/npc/proc/npc_delete_locked_gear() + for(var/obj/item/item as anything in get_all_contents_type(/obj/item)) + if(QDELETED(item)) + continue + if(HAS_TRAIT(item, TRAIT_HUMAN_NPC_GEAR) || HAS_TRAIT(item, TRAIT_HUMAN_NPC_CONSUMABLE)) + qdel(item) + +/// Drops what it carries that isn't gear (GLOB.human_npc_not_gear) at its feet, as anyone's would be +/// found on them. Called when it dies, since nobody can search an NPC's pockets. +/mob/living/carbon/human/npc/proc/npc_drop_not_gear() + var/atom/drop_spot = drop_location() + if(isnull(drop_spot)) + return + for(var/obj/item/item as anything in get_all_contents_type(/obj/item)) + if(QDELETED(item) || !is_type_in_typecache(item, GLOB.human_npc_not_gear)) + continue + if(item.loc == src) + dropItemToGround(item, force = TRUE) + else + item.forceMove(drop_spot) + +#undef HUMAN_NPC_GEAR_TRAIT_SOURCE diff --git a/voidcrew/modules/human_npcs/human_npc_samples.dm b/voidcrew/modules/human_npcs/human_npc_samples.dm new file mode 100644 index 00000000000..922a77c6bd2 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_samples.dm @@ -0,0 +1,87 @@ +// Sample human NPCs: the reference kit for the base (admin spawns, tests, the measurement harness). +// Phases B-E make their own types (pirate factions, troopers, hermits, criminals) and replace these. +// The ranged samples carry their spare ammunition, and the spacer its own air: no NPC refills from +// nothing or breathes vacuum. + +/datum/outfit/human_npc + name = "Human NPC" + +/datum/outfit/human_npc/pirate_gunner + name = "Human NPC: pirate gunner" + uniform = /obj/item/clothing/under/costume/pirate + suit = /obj/item/clothing/suit/costume/pirate/armored + head = /obj/item/clothing/head/costume/pirate/armored + glasses = /obj/item/clothing/glasses/eyepatch + gloves = /obj/item/clothing/gloves/color/black + shoes = /obj/item/clothing/shoes/jackboots + belt = /obj/item/storage/belt/military + belt_contents = list( + /obj/item/ammo_box/magazine/m9mm = 2, + /obj/item/knife/combat/survival = 1, + ) + r_hand = /obj/item/gun/ballistic/automatic/pistol + +/datum/outfit/human_npc/pirate_laser + name = "Human NPC: pirate laser gunner" + uniform = /obj/item/clothing/under/costume/pirate + suit = /obj/item/clothing/suit/costume/pirate/armored + head = /obj/item/clothing/head/costume/pirate/armored + glasses = /obj/item/clothing/glasses/eyepatch + shoes = /obj/item/clothing/shoes/jackboots + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/knife/combat/survival = 1) + r_hand = /obj/item/gun/energy/laser + +/datum/outfit/human_npc/pirate_swashbuckler + name = "Human NPC: pirate swashbuckler" + uniform = /obj/item/clothing/under/costume/pirate + head = /obj/item/clothing/head/costume/pirate/bandana/armored + glasses = /obj/item/clothing/glasses/eyepatch + shoes = /obj/item/clothing/shoes/jackboots + r_hand = /obj/item/melee/energy/sword/pirate + +/// For hulls and ruins open to space: a sealed suit and its own air. +/datum/outfit/human_npc/pirate_spacer + name = "Human NPC: pirate spacer" + uniform = /obj/item/clothing/under/costume/pirate + suit = /obj/item/clothing/suit/space/pirate + head = /obj/item/clothing/head/helmet/space/pirate + gloves = /obj/item/clothing/gloves/color/black + shoes = /obj/item/clothing/shoes/jackboots + suit_store = /obj/item/tank/internals/oxygen + internals_slot = ITEM_SLOT_SUITSTORE + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/knife/combat/survival = 1) + r_hand = /obj/item/gun/energy/laser + +/mob/living/carbon/human/npc/sample + faction = list(FACTION_PIRATE) + npc_name = "Pirate" + +/mob/living/carbon/human/npc/sample/gunner + npc_name = "Pirate Gunner" + npc_outfit = /datum/outfit/human_npc/pirate_gunner + npc_death_cash = 100 + +/mob/living/carbon/human/npc/sample/laser + npc_name = "Pirate Gunner" + npc_outfit = /datum/outfit/human_npc/pirate_laser + npc_death_cash = 100 + +/mob/living/carbon/human/npc/sample/swashbuckler + npc_name = "Pirate Swashbuckler" + npc_outfit = /datum/outfit/human_npc/pirate_swashbuckler + npc_death_cash = 100 + +/mob/living/carbon/human/npc/sample/spacer + npc_name = "Pirate Spacer" + npc_outfit = /datum/outfit/human_npc/pirate_spacer + npc_death_cash = 100 + +/// An elite: lands aimed crits and executions, and takes a quarter less brute and burn. +/mob/living/carbon/human/npc/sample/elite + npc_name = "Pirate Bosun" + npc_outfit = /datum/outfit/human_npc/pirate_gunner + npc_rank = HUMAN_NPC_RANK_ELITE + npc_damage_taken = 0.75 + npc_death_cash = 300 diff --git a/voidcrew/modules/human_npcs/human_npc_spawning.dm b/voidcrew/modules/human_npcs/human_npc_spawning.dm new file mode 100644 index 00000000000..aef592a9cb8 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_spawning.dm @@ -0,0 +1,184 @@ +/** + * # Making human NPCs, and clearing their bodies away + * + * A human costs about 11 ms to build (spike measurement), so a boarding wave of eight built in one + * tick stalls the server for two. Every human NPC goes through spawn_human_npc(), which queues an + * order; SShuman_npcs builds HUMAN_NPC_SPAWNS_PER_FIRE of them per fire. + * + * The same subsystem cleans up corpses so they don't pile up over a 6 hour round: a body dead for + * its npc_corpse_lifetime is deleted the first time a sweep finds no player near it, with the limbs + * and organs it lost that lie unwatched on the floor. Bodies being dragged, carried or watched stay. + * An NPC whose own system cleans it up sets npc_corpse_lifetime = 0. + */ + +/** + * Queues a human NPC to be built at `where`. Returns the order; qdel() it to cancel. + * + * * npc_type - a /mob/living/carbon/human/npc typepath + * * where - where it appears (its turf is used) + * * on_spawned - called with the new mob straight after it is built, the same tick + * * look - overrides the type's npc_look: a /obj/effect/mob_spawn typepath, a /datum/bounty_look or a + * /datum/bounty_record + * * outfit - overrides the type's npc_outfit + * * name - overrides the type's npc_name + * * faction - overrides the type's faction list + * * context - a /datum/npc_spawn_context (voidcrew/modules/npc_scaling/); the body is built through + * spawn_npc_in_zone() and the context finalised after on_spawned + * * immediate - build it now instead of queueing it (admin tools, tests) + */ +/proc/spawn_human_npc(npc_type, atom/where, datum/callback/on_spawned, look, outfit, name, list/faction, datum/npc_spawn_context/context, immediate = FALSE) + var/datum/human_npc_order/order = new(npc_type, where, on_spawned, look, outfit, name, faction, context) + if(immediate) + order.fulfil() + else + SShuman_npcs.queue += order + return order + +/// One queued human NPC. Holds what spawn_human_npc() was given until the body is built. +/datum/human_npc_order + var/npc_type + var/turf/where + var/datum/callback/on_spawned + var/look + var/datum/outfit/outfit + var/name + var/list/faction + var/datum/npc_spawn_context/context + /// Weakref to the mob once it is built + var/datum/weakref/spawned_ref + /// Set once fulfil() has run, whether or not it made a mob + var/done = FALSE + +/datum/human_npc_order/New(npc_type, atom/where, datum/callback/on_spawned, look, outfit, name, list/faction, datum/npc_spawn_context/context) + if(!ispath(npc_type, /mob/living/carbon/human/npc)) + CRASH("spawn_human_npc() was given [npc_type], which is not a human NPC type") + src.npc_type = npc_type + src.where = get_turf(where) + src.on_spawned = on_spawned + src.look = look + src.outfit = outfit + src.name = name + src.faction = faction?.Copy() + src.context = context + +/datum/human_npc_order/Destroy(force) + SShuman_npcs.queue -= src + release() + return ..() + +/// Drops what the order held, so a fulfilled order keeps nothing alive. +/datum/human_npc_order/proc/release() + where = null + on_spawned = null + look = null + outfit = null + faction = null + context = null + +/// Builds the NPC now. Returns it, or null if it couldn't be built. +/datum/human_npc_order/proc/fulfil() + if(done) + return spawned_ref?.resolve() + done = TRUE + SShuman_npcs.queue -= src + var/mob/living/carbon/human/npc/npc + if(!isnull(where)) + if(context) + npc = spawn_npc_in_zone(npc_type, where, context, finalize = FALSE, constructor_args = list(src)) + else + npc = new npc_type(where, src) + if(QDELETED(npc)) + release() + return null + spawned_ref = WEAKREF(npc) + SShuman_npcs.spawned_total++ + on_spawned?.Invoke(npc) + context?.finalize() + release() + return npc + +/// Marks a human NPC as admin-spawned (spawn_human_npcs verb). +/proc/human_npc_mark_admin_spawned(mob/living/carbon/human/npc/npc) + npc.flags_1 |= ADMIN_SPAWNED_1 + +SUBSYSTEM_DEF(human_npcs) + name = "Human NPCs" + flags = SS_NO_INIT + wait = 1 + runlevels = RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT + /// Orders waiting to be built, oldest first + var/list/datum/human_npc_order/queue = list() + /// Dead human NPCs whose corpses may be cleaned up + var/list/mob/living/carbon/human/npc/corpses = list() + /// The corpses this sweep still has to look at + var/list/mob/living/carbon/human/npc/corpse_run = list() + /// world.time of the next corpse sweep + var/next_sweep = 0 + /// Human NPCs built this round + var/spawned_total = 0 + /// Corpses cleaned up this round + var/faded_total = 0 + +/datum/controller/subsystem/human_npcs/stat_entry(msg) + msg = "Q:[length(queue)]|Made:[spawned_total]|Bodies:[length(corpses)]|Faded:[faded_total]" + return ..() + +/datum/controller/subsystem/human_npcs/fire(resumed = FALSE) + if(length(queue)) + process_spawns(HUMAN_NPC_SPAWNS_PER_FIRE) + if(MC_TICK_CHECK) + return + if(!length(corpse_run)) + if(world.time < next_sweep || !length(corpses)) + return + next_sweep = world.time + HUMAN_NPC_CORPSE_SWEEP_INTERVAL + corpse_run = corpses.Copy() + var/deleted = 0 + while(length(corpse_run)) + var/mob/living/carbon/human/npc/body = corpse_run[length(corpse_run)] + corpse_run.len-- + if(QDELETED(body)) + corpses -= body + continue + if(body.npc_corpse_expired()) + body.npc_fade() + faded_total++ + deleted++ + if(deleted >= HUMAN_NPC_CORPSE_DELETES_PER_FIRE || MC_TICK_CHECK) + return + +/// Builds up to `budget` queued NPCs. Returns how many it built. +/datum/controller/subsystem/human_npcs/proc/process_spawns(budget = HUMAN_NPC_SPAWNS_PER_FIRE) + . = 0 + while(length(queue) && . < budget) + var/datum/human_npc_order/order = queue[1] + queue.Cut(1, 2) + if(QDELETED(order) || order.done) + continue + if(order.fulfil()) + .++ + +/datum/controller/subsystem/human_npcs/proc/track_corpse(mob/living/carbon/human/npc/body) + corpses |= body + +/datum/controller/subsystem/human_npcs/proc/forget_corpse(mob/living/carbon/human/npc/body) + corpses -= body + corpse_run -= body + +/** + * Map placement for a human NPC: queues `npc_type` here when the map loads, so a ruin full of them + * builds over several ticks. + */ +/obj/effect/spawner/human_npc + name = "human NPC spawner" + icon = 'icons/effects/mapping_helpers.dmi' + icon_state = "mobspawner" + /// The /mob/living/carbon/human/npc typepath to spawn + var/npc_type + +/obj/effect/spawner/human_npc/Initialize(mapload) + . = ..() + if(!ispath(npc_type, /mob/living/carbon/human/npc)) + stack_trace("[type] at [AREACOORD(src)] has no human NPC type to spawn") + return + spawn_human_npc(npc_type, get_turf(src), context = get_npc_spawn_context()) diff --git a/voidcrew/modules/human_npcs/human_npc_spike_harness.dm b/voidcrew/modules/human_npcs/human_npc_spike_harness.dm index 64115b563bb..894b95afc05 100644 --- a/voidcrew/modules/human_npcs/human_npc_spike_harness.dm +++ b/voidcrew/modules/human_npcs/human_npc_spike_harness.dm @@ -15,11 +15,12 @@ GLOBAL_LIST_EMPTY(human_npc_spike_mobs) /// Whether the harness pretends a player is on the test room's z-level. GLOBAL_VAR_INIT(human_npc_spike_awake, FALSE) +/// Human NPC Life() calls that got past the dormancy guard (counted in human_npc.dm under this define). +GLOBAL_VAR_INIT(human_npc_spike_life_passes, 0) /// Same NPC with every Life() trim switched off, to measure what the trims save. -/mob/living/carbon/human/npc/ranged/spike_untrimmed +/mob/living/carbon/human/npc/sample/laser/spike_untrimmed npc_trim_life = FALSE - npc_dormant_when_dead = FALSE /mob/living/carbon/human/npc/npc_players_on_level() if(GLOB.human_npc_spike_mobs[src]) @@ -62,10 +63,12 @@ SUBSYSTEM_DEF(human_npc_spike) if("trooper") mob_type = /mob/living/basic/trooper/pirate/ranged if("human_untrimmed") - mob_type = /mob/living/carbon/human/npc/ranged/spike_untrimmed + mob_type = /mob/living/carbon/human/npc/sample/laser/spike_untrimmed else kind = "human" - mob_type = /mob/living/carbon/human/npc/ranged + mob_type = /mob/living/carbon/human/npc/sample/laser + if(params?["spike_mode"] == "dormancy") + kind = "[kind]_dormancy" out_prefix = "data/human_npc_spike_[kind]" csv_path = "[out_prefix]_results.csv" phase_path = "[out_prefix]_phase.txt" @@ -73,7 +76,10 @@ SUBSYSTEM_DEF(human_npc_spike) rustg_file_write("kind,phase,n,metric,value\n", csv_path) set_phase("init") SSticker.start_immediately = TRUE - INVOKE_ASYNC(src, PROC_REF(run_spike)) + if(params?["spike_mode"] == "dormancy") + INVOKE_ASYNC(src, PROC_REF(run_dormancy_proof)) + else + INVOKE_ASYNC(src, PROC_REF(run_spike)) return SS_INIT_SUCCESS /datum/controller/subsystem/human_npc_spike/proc/set_phase(label) @@ -156,6 +162,84 @@ SUBSYSTEM_DEF(human_npc_spike) settle("after_fight_profiled", 0, 10 SECONDS) set_phase("done") +/** + * spike_mode=dormancy: a short run for the production base. Queues 100 NPCs through + * spawn_human_npc() and records how many were built per tick, then measures their Life() and AI + * cost dormant, awake, and as corpses without and with a player on the z-level, counting the Life() + * calls that got past the dormancy guard in each phase, and checks that a waking corpse catches up + * the organ decay it slept through. + */ +/datum/controller/subsystem/human_npc_spike/proc/run_dormancy_proof() + UNTIL(SSticker.current_state == GAME_STATE_PLAYING) + SSticker.delay_end = TRUE + SSprofiler.can_fire = FALSE + world.Profile(PROFILE_STOP) + set_phase("settle_roundstart") + sleep(45 SECONDS) + if(!build_room()) + set_phase("failed_room") + return + sleep(10 SECONDS) + census("baseline") + measure("baseline", 0, 20 SECONDS) + queue_npcs(100) + set_awake(FALSE) + settle("spawned", 100, 15 SECONDS) + life_phase("dormant", 100, 30 SECONDS) + set_awake(TRUE) + settle("awake_settle", 100, 5 SECONDS) + life_phase("awake", 100, 30 SECONDS) + kill_npcs() + set_awake(FALSE) + settle("corpse_settle", 100, 10 SECONDS) + life_phase("corpses_dormant", 100, 30 SECONDS) + var/mob/living/carbon/human/sample_corpse + for(var/mob/living/carbon/human/npc/body as anything in npcs) + if(!QDELETED(body) && body.stat == DEAD) + sample_corpse = body + break + var/obj/item/organ/brain/brain = sample_corpse?.get_organ_slot(ORGAN_SLOT_BRAIN) + record("decay", 100, "brain_damage_asleep", brain?.damage) + set_awake(TRUE) + life_phase("corpses_awake", 100, 30 SECONDS) + record("decay", 100, "brain_damage_after_waking", brain?.damage) + set_phase("done") + +/// Queues `count` NPCs through the production spawn queue and records how fast it builds them. +/datum/controller/subsystem/human_npc_spike/proc/queue_npcs(count) + set_phase("queueing_[count]") + var/start_built = SShuman_npcs.spawned_total + var/start_time = world.time + var/first_slot = length(npcs) + for(var/i in 1 to count) + spawn_human_npc(mob_type, npc_slot(first_slot + i), on_spawned = CALLBACK(src, PROC_REF(adopt_npc))) + var/most_in_one_tick = 0 + var/last_built = start_built + var/ticks = 0 + while(SShuman_npcs.spawned_total - start_built < count && ticks < 4000) + sleep(world.tick_lag) + ticks++ + most_in_one_tick = max(most_in_one_tick, SShuman_npcs.spawned_total - last_built) + last_built = SShuman_npcs.spawned_total + record("spawn_queue", count, "built", SShuman_npcs.spawned_total - start_built) + record("spawn_queue", count, "ticks", ticks) + record("spawn_queue", count, "seconds", (world.time - start_time) / 10) + record("spawn_queue", count, "most_built_in_one_tick", most_in_one_tick) + +/datum/controller/subsystem/human_npc_spike/proc/adopt_npc(mob/living/npc) + npcs += npc + GLOB.human_npc_spike_mobs[npc] = TRUE + RegisterSignal(npc, COMSIG_QDELETING, PROC_REF(forget_mob)) + +/// measure(), plus how many Life() calls got past the dormancy guard. +/datum/controller/subsystem/human_npc_spike/proc/life_phase(label, n, duration) + GLOB.human_npc_spike_life_passes = 0 + var/start = world.time + measure(label, n, duration) + var/seconds = max((world.time - start) / 10, 1) + record(label, n, "life_passes", GLOB.human_npc_spike_life_passes) + record(label, n, "life_passes_per_npc_per_s", GLOB.human_npc_spike_life_passes / max(n, 1) / seconds) + /datum/controller/subsystem/human_npc_spike/proc/settle(label, n, duration) set_phase("[label]_[n]") sleep(duration) diff --git a/voidcrew/modules/npc_ships/code/pirate_crew.dm b/voidcrew/modules/npc_ships/code/pirate_crew.dm index 127b17cbc4b..fb5cb472298 100644 --- a/voidcrew/modules/npc_ships/code/pirate_crew.dm +++ b/voidcrew/modules/npc_ships/code/pirate_crew.dm @@ -38,9 +38,14 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( "Fine. I'll do it myself.", )) +/// Whether `recruit` is a body a pirate crew can take on: a basic mob, or a human NPC +/// (voidcrew/modules/human_npcs/). Human members still need their own ranged and breach behaviours. +/proc/pirate_crew_can_enlist(mob/living/recruit) + return isbasicmob(recruit) || ishumannpc(recruit) + /// Enlists `recruit` in the crew defending `hull`, making the crew if it has none yet /proc/pirate_crew_enlist_defender(mob/living/recruit, obj/structure/overmap/ship/hull, role = PIRATE_ROLE_HUNTER) - if(QDELETED(hull) || !isbasicmob(recruit) || QDELETED(recruit)) + if(QDELETED(hull) || !pirate_crew_can_enlist(recruit) || QDELETED(recruit)) return FALSE var/datum/pirate_crew/crew = pirate_crew_of(hull, FALSE) if(!crew) @@ -49,7 +54,7 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( /// Enlists `recruit` in the raid on `target`, making the raid if there is none yet /proc/pirate_crew_enlist_raider(mob/living/recruit, obj/structure/overmap/ship/target) - if(QDELETED(target) || !isbasicmob(recruit) || QDELETED(recruit)) + if(QDELETED(target) || !pirate_crew_can_enlist(recruit) || QDELETED(recruit)) return FALSE var/datum/pirate_crew/crew = pirate_crew_of(target, TRUE) if(!crew) @@ -646,7 +651,8 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( */ /datum/pirate_crew/proc/check_members() var/list/stranded = list() - for(var/mob/living/basic/member in members) + // Every member, human NPCs included: a loop typed to basic mobs skipped them silently. + for(var/mob/living/member in members) if(QDELETED(member) || member.stat == DEAD || GET_CLIENT(member)) continue var/datum/ai_controller/controller = member.ai_controller @@ -660,7 +666,7 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( var/obj/structure/overmap/ship/other = pirate_crew_ship_at(where) if(other && other != home_ship) stranded[member] = other - for(var/mob/living/basic/member as anything in stranded) + for(var/mob/living/member as anything in stranded) var/obj/structure/overmap/ship/other = stranded[member] remove_member(member) pirate_crew_enlist_raider(member, other) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 56bb9c3ca77..3fe0553a970 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -91,6 +91,7 @@ #include "voidcrew_helpers.dm" #include "voidcrew_hull_containment.dm" #include "voidcrew_hull_survey.dm" +#include "voidcrew_human_npcs.dm" #include "voidcrew_hv_finishers.dm" #include "voidcrew_hyperviolence_blood.dm" #include "voidcrew_hyperviolence_braingut.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm b/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm new file mode 100644 index 00000000000..ed1a0201045 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm @@ -0,0 +1,415 @@ +// Human NPC base (voidcrew/modules/human_npcs/). Unit tests are included before voidcrew/_DEFINES, so +// these use the sample types and the module's procs, never its HUMAN_NPC_* defines. + +/// Waits up to a second for `thing` to be deleted. Locked gear that leaves its NPC goes on the next tick. +/datum/unit_test/proc/human_npc_wait_deleted(datum/thing) + for(var/i in 1 to 10) + if(QDELETED(thing)) + return TRUE + sleep(1) + return QDELETED(thing) + +/// Life() does nothing while no player is on the z-level and runs once one is; a corpse is dormant +/// the same way, and when it wakes it catches up the organ decay it slept through. +/datum/unit_test/voidcrew_human_npc_dormancy + +/datum/unit_test/voidcrew_human_npc_dormancy/Run() + var/mob/living/carbon/human/npc/sample/swashbuckler/npc = allocate(/mob/living/carbon/human/npc/sample/swashbuckler) + var/turf/here = get_turf(npc) + TEST_ASSERT(!length(SSmobs.clients_by_zlevel[here.z]), "Test fixture: a client is on the test z-level") + TEST_ASSERT(npc.npc_dormant(), "An NPC with no player on its z-level is not dormant") + + npc.bodytemperature = 250 + npc.Life(SSMOBS_DT, 1) + TEST_ASSERT_EQUAL(npc.bodytemperature, 250, "A dormant NPC's Life() ran its body temperature") + TEST_ASSERT(npc.npc_dormant_since, "A dormant NPC did not note when it fell asleep") + + // A player arrives. The NPC stands in for them in the z-level's client list for the length of + // one Life() call; nothing else runs in between. + SSmobs.clients_by_zlevel[here.z] += npc + var/awake = !npc.npc_dormant() + npc.Life(SSMOBS_DT, 2) + SSmobs.clients_by_zlevel[here.z] -= npc + TEST_ASSERT(awake, "An NPC with a player on its z-level is dormant") + TEST_ASSERT(npc.bodytemperature != 250, "An awake NPC's Life() did not run") + TEST_ASSERT(!npc.npc_dormant_since, "An NPC that woke still thinks it is asleep") + + // Dead: dormant without a player, and a corpse that slept an hour has the organs of one dead an hour. + npc.death() + npc.bodytemperature = BODYTEMP_NORMAL + var/obj/item/organ/heart/heart = npc.get_organ_slot(ORGAN_SLOT_HEART) + TEST_ASSERT_NOTNULL(heart, "Test fixture: the NPC has no heart") + npc.Life(SSMOBS_DT, 3) + TEST_ASSERT(!(heart.organ_flags & ORGAN_FAILING), "A fresh corpse's heart has already failed") + TEST_ASSERT(npc.npc_dormant_since, "A dormant corpse did not note when it fell asleep") + npc.npc_dormant_since = world.time - 1 HOURS + SSmobs.clients_by_zlevel[here.z] += npc + npc.Life(SSMOBS_DT, 4) + SSmobs.clients_by_zlevel[here.z] -= npc + TEST_ASSERT(heart.organ_flags & ORGAN_FAILING, "A corpse that slept for an hour woke with a working heart") + TEST_ASSERT(npc.can_defib() != DEFIB_POSSIBLE, "A corpse dead for an hour could still be defibrillated") + +/// No mood and no hunger, and nothing done to stamina or stuns: a baton works on an NPC as on a player. +/datum/unit_test/voidcrew_human_npc_body_trims + +/datum/unit_test/voidcrew_human_npc_body_trims/Run() + var/mob/living/carbon/human/npc/sample/gunner/npc = allocate(/mob/living/carbon/human/npc/sample/gunner) + TEST_ASSERT_NULL(npc.mob_mood, "A human NPC has a mood datum") + TEST_ASSERT(HAS_TRAIT(npc, TRAIT_NOHUNGER), "A human NPC gets hungry") + TEST_ASSERT_EQUAL(npc.physiology.stamina_mod, 1, "A human NPC's stamina damage is scaled") + TEST_ASSERT_EQUAL(npc.physiology.stun_mod, 1, "A human NPC's stuns are scaled") + TEST_ASSERT_EQUAL(npc.physiology.brute_mod, 1, "A regular human NPC takes scaled brute damage") + + var/mob/living/carbon/human/npc/sample/elite/elite = allocate(/mob/living/carbon/human/npc/sample/elite) + TEST_ASSERT_EQUAL(elite.physiology.brute_mod, 0.75, "The elite's toughness did not reach its brute mod") + TEST_ASSERT_EQUAL(elite.physiology.burn_mod, 0.75, "The elite's toughness did not reach its burn mod") + TEST_ASSERT_EQUAL(elite.physiology.stamina_mod, 1, "Toughness changed stamina damage") + elite.set_npc_toughness(0.5) + TEST_ASSERT_EQUAL(elite.physiology.brute_mod, 0.5, "set_npc_toughness() did not replace the old multiplier") + + npc.adjustStaminaLoss(200) + TEST_ASSERT(npc.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "200 stamina damage did not stamcrit a human NPC") + TEST_ASSERT(npc.ai_controller.get_able_to_run() & AI_UNABLE_TO_RUN, "A stamcrit human NPC's AI can still act") + +/// The strip menu never opens, the NPC never lets go of its kit, and kit forced off it is deleted, +/// unless it is straight back on the NPC (a tactical reload drops the old magazine and catches it). +/datum/unit_test/voidcrew_human_npc_gear_lock + +/datum/unit_test/voidcrew_human_npc_gear_lock/Run() + var/mob/living/carbon/human/npc/sample/gunner/npc = allocate(/mob/living/carbon/human/npc/sample/gunner) + var/mob/living/carbon/human/consistent/player = allocate(/mob/living/carbon/human/consistent) + var/obj/item/gun/gun = locate() in npc.held_items + TEST_ASSERT_NOTNULL(gun, "The sample gunner holds no gun") + var/obj/item/ammo_box/magazine/spare = locate() in npc.belt + TEST_ASSERT_NOTNULL(spare, "The sample gunner carries no spare magazine") + for(var/obj/item/kit as anything in list(gun, spare, npc.w_uniform, npc.wear_suit, npc.head, npc.shoes, npc.belt)) + TEST_ASSERT(human_npc_gear_locked(kit), "[kit] is not locked to the NPC") + TEST_ASSERT(!human_npc_gear_locked(npc.get_bodypart(BODY_ZONE_CHEST)), "A body part was locked as gear") + + TEST_ASSERT(!npc.should_strip(player), "The strip menu would open on a human NPC") + TEST_ASSERT(!npc.dropItemToGround(gun), "A human NPC let go of its gun") + TEST_ASSERT(npc.is_holding(gun), "The gun left the NPC's hand") + TEST_ASSERT(!npc.transferItemToLoc(gun, player), "A human NPC handed its gun over") + TEST_ASSERT(!npc.dropItemToGround(npc.wear_suit), "A human NPC took off its armour") + TEST_ASSERT(SEND_SIGNAL(gun, COMSIG_ITEM_PRE_EXPORT) & COMPONENT_STOP_EXPORT, "A human NPC's gun can be sold") + + // Moves that end on the NPC still work. + TEST_ASSERT(npc.temporarilyRemoveItemFromInventory(gun), "The NPC could not move its own gun") + TEST_ASSERT(npc.put_in_hands(gun), "The NPC could not take its gun back") + + // Forced out and straight back: kept. + spare.forceMove(get_turf(npc)) + TEST_ASSERT(npc.put_in_hands(spare), "The NPC could not catch its magazine") + sleep(2) + TEST_ASSERT(!QDELETED(spare), "A magazine that left the NPC and came straight back was deleted") + + // Forced out for good: deleted. + npc.dropItemToGround(gun, force = TRUE) + TEST_ASSERT(isturf(gun.loc), "A forced drop did not put the gun on the floor") + TEST_ASSERT(human_npc_wait_deleted(gun), "A human NPC's gun forced out of its hand was left lying around") + +/// Cutting off the gun arm or the head, death and gibbing never leave the NPC's kit for players; the +/// limbs and gore stay. Its designed death cash drops once, however often it dies. +/datum/unit_test/voidcrew_human_npc_gear_body + +/datum/unit_test/voidcrew_human_npc_gear_body/Run() + var/mob/living/carbon/human/npc/sample/gunner/npc = allocate(/mob/living/carbon/human/npc/sample/gunner) + var/obj/item/gun/gun = locate() in npc.held_items + var/obj/item/bodypart/arm = npc.hand_bodyparts[npc.get_held_index_of_item(gun)] + TEST_ASSERT_NOTNULL(arm, "Test fixture: the gun is in no hand") + TEST_ASSERT(arm.dismember(), "The gun arm would not come off") + TEST_ASSERT(isturf(arm.loc), "The severed arm is not on the floor") + TEST_ASSERT(human_npc_wait_deleted(gun), "A severed arm handed over the NPC's gun") + + var/obj/item/hat = npc.head + var/obj/item/bodypart/head/head = npc.get_bodypart(BODY_ZONE_HEAD) + TEST_ASSERT(head.dismember(), "The NPC's head would not come off") + TEST_ASSERT(human_npc_wait_deleted(hat), "The NPC's hat was left behind when its head came off") + + // Death keeps everything on the body. + // On its own tile: the first NPC died when its head came off, and dropped its own cash. + var/turf/floor = locate(run_loc_floor_bottom_left.x + 2, run_loc_floor_bottom_left.y + 2, run_loc_floor_bottom_left.z) + var/mob/living/carbon/human/npc/sample/gunner/victim = allocate(/mob/living/carbon/human/npc/sample/gunner, floor) + var/list/kit = victim.get_equipped_items(INCLUDE_POCKETS | INCLUDE_HELD) + victim.death() + for(var/obj/item/worn as anything in kit) + TEST_ASSERT(!QDELETED(worn) && human_npc_holding(worn) == victim, "[worn] came off the NPC when it died") + var/obj/item/holochip/cash = locate() in floor + TEST_ASSERT_NOTNULL(cash, "The NPC's death cash did not drop") + qdel(cash) + victim.revive(HEAL_ALL) + victim.death() + TEST_ASSERT_NULL(locate(/obj/item/holochip) in floor, "The NPC's death cash dropped a second time") + + // Gibbing deletes the kit with the body. + var/mob/living/carbon/human/npc/sample/gunner/gibbed = allocate(/mob/living/carbon/human/npc/sample/gunner) + var/list/gibbed_kit = list() + for(var/obj/item/item as anything in gibbed.get_all_contents_type(/obj/item)) + if(human_npc_gear_locked(item)) + gibbed_kit += item + TEST_ASSERT(length(gibbed_kit), "Test fixture: the gibbed NPC carried nothing locked") + gibbed.gib(DROP_ALL_REMAINS) + for(var/obj/item/item as anything in gibbed_kit) + TEST_ASSERT(human_npc_wait_deleted(item), "[item] survived its NPC being gibbed") + +/// D4: only elite and boss human NPCs land aimed crits and gun executions. Players always can. +/datum/unit_test/voidcrew_human_npc_crit_policy + +/datum/unit_test/voidcrew_human_npc_crit_policy/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/npc/sample/gunner/regular = allocate(/mob/living/carbon/human/npc/sample/gunner) + var/mob/living/carbon/human/npc/sample/elite/elite = allocate(/mob/living/carbon/human/npc/sample/elite) + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + var/obj/item/spear/spear = allocate(/obj/item/spear) + + TEST_ASSERT(regular.put_in_hands(spear), "Test fixture: the regular NPC can't hold a spear") + TEST_ASSERT_NULL(hv_crit_get_aimer(spear), "A regular human NPC counts as aiming its hits") + regular.zone_selected = BODY_ZONE_PRECISE_EYES + // As in the crit tests: +90 keeps the roll over the eye threshold and under a dismember. + victim.apply_damage(15, BRUTE, head, wound_bonus = 90, sharpness = SHARP_POINTY, attacking_item = spear) + TEST_ASSERT_NULL(head.get_wound_type(/datum/wound/pierce/bleed/severe/eye/hv_gouged), "A regular human NPC tore an eye out") + + TEST_ASSERT(regular.dropItemToGround(spear), "Test fixture: the NPC kept a spear it was handed") + TEST_ASSERT(elite.put_in_hands(spear), "Test fixture: the elite can't hold a spear") + TEST_ASSERT_EQUAL(hv_crit_get_aimer(spear), elite, "An elite human NPC doesn't count as aiming its hits") + elite.zone_selected = BODY_ZONE_PRECISE_EYES + victim.apply_damage(15, BRUTE, head, wound_bonus = 90, sharpness = SHARP_POINTY, attacking_item = spear) + TEST_ASSERT_NOTNULL(head.get_wound_type(/datum/wound/pierce/bleed/severe/eye/hv_gouged), "An elite human NPC could not land an eye crit") + + var/obj/item/gun/regular_gun = locate() in regular.held_items + var/obj/item/gun/elite_gun = locate() in elite.held_items + victim.Knockdown(30 SECONDS) + TEST_ASSERT(!regular_gun.hv_execution_possible(regular, victim), "A regular human NPC can execute a downed player") + TEST_ASSERT(elite_gun.hv_execution_possible(elite, victim), "An elite human NPC can't execute a downed player") + +/// spawn_human_npc() queues bodies; the subsystem builds them a budget at a time, and a cancelled +/// order builds nothing. +/datum/unit_test/voidcrew_human_npc_spawn_queue + +/datum/unit_test/voidcrew_human_npc_spawn_queue/Run() + TEST_ASSERT(!length(SSmobs.clients_by_zlevel[run_loc_floor_bottom_left.z]), "Test fixture: a client is on the test z-level") + TEST_ASSERT(!length(SShuman_npcs.queue), "Test fixture: the human NPC queue is not empty") + var/list/datum/human_npc_order/orders = list() + for(var/i in 1 to 3) + orders += spawn_human_npc(/mob/living/carbon/human/npc/sample/swashbuckler, run_loc_floor_bottom_left) + for(var/datum/human_npc_order/order as anything in orders) + TEST_ASSERT_NULL(order.spawned_ref, "spawn_human_npc() built a body at once") + TEST_ASSERT_EQUAL(SShuman_npcs.process_spawns(1), 1, "A budget of one built [length(orders) - length(SShuman_npcs.queue)] bodies") + TEST_ASSERT_EQUAL(length(SShuman_npcs.queue), 2, "The queue did not keep the rest") + TEST_ASSERT_EQUAL(SShuman_npcs.process_spawns(10), 2, "The queue did not build the rest") + for(var/datum/human_npc_order/order as anything in orders) + var/mob/living/carbon/human/npc/built = order.spawned_ref?.resolve() + TEST_ASSERT(istype(built, /mob/living/carbon/human/npc/sample/swashbuckler), "An order built [built || "nothing"]") + TEST_ASSERT_EQUAL(get_turf(built), run_loc_floor_bottom_left, "A queued NPC appeared in the wrong place") + allocated += built + + var/datum/human_npc_order/cancelled = spawn_human_npc(/mob/living/carbon/human/npc/sample/swashbuckler, run_loc_floor_bottom_left) + qdel(cancelled) + TEST_ASSERT_EQUAL(SShuman_npcs.process_spawns(5), 0, "A cancelled order was built") + + // An order's overrides reach the body, and a spawner path dresses it. + var/datum/human_npc_order/dressed = spawn_human_npc(/mob/living/carbon/human/npc/sample/gunner, run_loc_floor_bottom_left, name = "Test Name", faction = list("human_npc_test"), immediate = TRUE) + var/mob/living/carbon/human/npc/dressed_npc = dressed.spawned_ref?.resolve() + TEST_ASSERT_NOTNULL(dressed_npc, "An immediate order built nothing") + allocated += dressed_npc + TEST_ASSERT_EQUAL(dressed_npc.real_name, "Test Name", "The order's name did not stick") + TEST_ASSERT(("human_npc_test" in dressed_npc.faction) && !(FACTION_PIRATE in dressed_npc.faction), "The order's faction did not replace the type's") + + var/datum/human_npc_order/skeleton = spawn_human_npc(/mob/living/carbon/human/npc/sample/swashbuckler, run_loc_floor_bottom_left, look = /obj/effect/mob_spawn/corpse/human/old_pirate_captain, immediate = TRUE) + var/mob/living/carbon/human/npc/skeleton_npc = skeleton.spawned_ref?.resolve() + allocated += skeleton_npc + TEST_ASSERT(istype(skeleton_npc?.dna.species, /datum/species/skeleton), "A spawner look did not give the spawner's species") + +/// Human NPCs take wounds, bleed, lose limbs and have their heads burst as a player does. +/datum/unit_test/voidcrew_human_npc_gore + +/datum/unit_test/voidcrew_human_npc_gore/Run() + var/mob/living/carbon/human/npc/sample/swashbuckler/npc = allocate(/mob/living/carbon/human/npc/sample/swashbuckler) + var/obj/item/bodypart/arm = npc.get_bodypart(BODY_ZONE_L_ARM) + npc.apply_damage(25, BRUTE, arm, wound_bonus = 60, sharpness = SHARP_EDGED) + TEST_ASSERT(length(arm.wounds), "A heavy cut left no wound on a human NPC") + var/blood_before = npc.blood_volume + npc.bleed(20) + TEST_ASSERT(npc.blood_volume < blood_before, "A human NPC did not lose blood") + TEST_ASSERT(arm.dismember(), "A human NPC's arm would not come off") + TEST_ASSERT_NULL(npc.get_bodypart(BODY_ZONE_L_ARM), "The arm is still on") + + npc.death() + var/obj/item/organ/brain/brain = npc.get_organ_slot(ORGAN_SLOT_BRAIN) + TEST_ASSERT(npc.hv_burst_head(NORTH), "A dead human NPC's head would not burst") + TEST_ASSERT_NULL(npc.get_bodypart(BODY_ZONE_HEAD), "The burst head is still on") + TEST_ASSERT(!QDELETED(brain) && isturf(brain.loc), "The burst head did not throw its brain out") + +/// The AI keeps working the cuffs when cuffed (tg's escape_captivity) and reloads its gun from the +/// magazines it carries through the gun's own reload path, then fires the real gun. +/datum/unit_test/voidcrew_human_npc_ai_hands + +/datum/unit_test/voidcrew_human_npc_ai_hands/Run() + var/mob/living/carbon/human/npc/sample/swashbuckler/cuffed = allocate(/mob/living/carbon/human/npc/sample/swashbuckler) + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(cuffed.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the NPC could not be cuffed") + TEST_ASSERT(HAS_TRAIT(cuffed, TRAIT_RESTRAINED), "A cuffed NPC isn't restrained") + TEST_ASSERT(!(cuffed.ai_controller.get_able_to_run() & AI_UNABLE_TO_RUN), "A cuffed NPC's AI stopped, so it can't work at the cuffs") + + var/mob/living/carbon/human/npc/sample/gunner/npc = allocate(/mob/living/carbon/human/npc/sample/gunner) + var/obj/item/gun/ballistic/automatic/pistol/gun = locate() in npc.held_items + TEST_ASSERT_NOTNULL(gun, "The sample gunner holds no pistol") + gun.can_misfire = FALSE + var/obj/item/ammo_box/magazine/first_mag = gun.magazine + // Spend it, as firing it dry would. + var/obj/item/ammo_casing/round = first_mag.get_round() + while(round) + qdel(round) + round = first_mag.get_round() + QDEL_NULL(gun.chambered) + TEST_ASSERT(!gun.can_shoot(), "Test fixture: the pistol can still fire") + var/obj/item/ammo_box/magazine/spare = human_npc_find_ammo(npc, gun) + TEST_ASSERT_NOTNULL(spare, "The gunner can't find its spare magazines") + + var/datum/ai_controller/controller = npc.ai_controller + var/datum/ai_behavior/human_npc_reload/reload = GET_AI_BEHAVIOR(/datum/ai_behavior/human_npc_reload) + TEST_ASSERT(reload.setup(controller), "The reload would not start") + var/finished = FALSE + for(var/step in 1 to 10) + // The waits between steps that the controller's cooldowns would give + npc.next_click = 0 + npc.next_move = 0 + var/result = reload.perform(1, controller) + TEST_ASSERT(!(result & AI_BEHAVIOR_FAILED), "The reload failed at step [step]") + if(result & AI_BEHAVIOR_SUCCEEDED) + finished = TRUE + break + TEST_ASSERT(finished, "The reload never finished") + TEST_ASSERT(gun.can_shoot(), "The reload left the pistol unable to fire") + TEST_ASSERT_EQUAL(gun.magazine, spare, "The pistol doesn't hold the spare magazine") + TEST_ASSERT(!npc.is_holding(first_mag), "The NPC kept hold of the empty magazine") + TEST_ASSERT(human_npc_wait_deleted(first_mag), "The dropped empty magazine was left for players") + + // One click fires the real gun. + var/mob/living/carbon/human/consistent/target = allocate(/mob/living/carbon/human/consistent, locate(run_loc_floor_bottom_left.x + 2, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z)) + var/rounds_before = spare.ammo_count(FALSE) + npc.next_click = 0 + npc.next_move = 0 + human_npc_ready_item(npc, gun) + controller.ai_interact(target = target, combat_mode = TRUE) + TEST_ASSERT(spare.ammo_count(FALSE) < rounds_before || !gun.chambered?.loaded_projectile, "A click with the pistol did not fire it") + +/// Corpses are cleaned up after their lifetime when nobody is near, with the limbs they lost, but +/// never while dragged. +/datum/unit_test/voidcrew_human_npc_corpse_cleanup + +/datum/unit_test/voidcrew_human_npc_corpse_cleanup/Run() + var/mob/living/carbon/human/npc/sample/swashbuckler/npc = allocate(/mob/living/carbon/human/npc/sample/swashbuckler) + TEST_ASSERT(!npc.npc_corpse_expired(), "A living NPC can be cleaned up") + var/obj/item/bodypart/leg = npc.get_bodypart(BODY_ZONE_L_LEG) + TEST_ASSERT(leg.dismember(), "Test fixture: the leg would not come off") + npc.death() + TEST_ASSERT(npc in SShuman_npcs.corpses, "A dead NPC's corpse is not tracked") + TEST_ASSERT(!npc.npc_corpse_expired(), "A fresh corpse can be cleaned up") + npc.npc_died_at = world.time - npc.npc_corpse_lifetime - 1 + TEST_ASSERT(npc.npc_corpse_expired(), "An old corpse nobody is near can't be cleaned up") + + var/mob/living/carbon/human/consistent/dragger = allocate(/mob/living/carbon/human/consistent) + dragger.start_pulling(npc) + TEST_ASSERT(!npc.npc_corpse_expired(), "A corpse being dragged can be cleaned up") + dragger.stop_pulling() + + npc.revive(HEAL_ALL) + TEST_ASSERT(!(npc in SShuman_npcs.corpses), "A revived NPC is still tracked as a corpse") + npc.death() + npc.npc_died_at = world.time - npc.npc_corpse_lifetime - 1 + npc.npc_fade() + TEST_ASSERT(QDELETED(npc), "npc_fade() left the corpse") + TEST_ASSERT(QDELETED(leg), "npc_fade() left the leg the NPC lost") + +/// Systems built for basic-mob NPCs treat human NPCs as the NPCs they are: hull turrets shoot them, +/// weather reaches them while they are awake, and pirate crews can take them on. +/datum/unit_test/voidcrew_human_npc_world_gates + +/datum/unit_test/voidcrew_human_npc_world_gates/Run() + var/obj/machinery/porta_turret/ship_defense/turret = allocate(/obj/machinery/porta_turret/ship_defense) + var/mob/living/carbon/human/npc/sample/gunner/npc = allocate(/mob/living/carbon/human/npc/sample/gunner) + var/mob/living/carbon/human/consistent/person = allocate(/mob/living/carbon/human/consistent) + TEST_ASSERT(turret.valid_target(npc), "A hull turret won't shoot a human NPC boarder") + TEST_ASSERT(!turret.valid_target(person), "A hull turret would shoot a person") + var/mob/living/carbon/human/npc/sample/gunner/possessed = allocate(/mob/living/carbon/human/npc/sample/gunner) + possessed.mind_initialize() + TEST_ASSERT(!turret.valid_target(possessed), "A hull turret would shoot a human NPC body with a mind in it") + + TEST_ASSERT(!human_npc_weathered(npc), "Weather reaches a dormant human NPC") + var/turf/here = get_turf(npc) + SSmobs.clients_by_zlevel[here.z] += npc + var/weathered = human_npc_weathered(npc) + SSmobs.clients_by_zlevel[here.z] -= npc + TEST_ASSERT(weathered, "Weather doesn't reach an awake human NPC") + TEST_ASSERT(!human_npc_weathered(person), "human_npc_weathered() took in a person") + + TEST_ASSERT(pirate_crew_can_enlist(npc), "A pirate crew can't take on a human NPC") + +/// D3's exceptions: a ship key isn't gear and drops when the NPC dies; a grenade is a consumable the NPC +/// can drop and throw, that no player can keep, and that goes when its NPC does. +/datum/unit_test/voidcrew_human_npc_gear_exceptions + +/datum/unit_test/voidcrew_human_npc_gear_exceptions/Run() + var/mob/living/carbon/human/npc/sample/gunner/npc = allocate(/mob/living/carbon/human/npc/sample/gunner) + var/mob/living/carbon/human/consistent/player = 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)) + var/obj/item/ship_key/key = allocate(/obj/item/ship_key) + TEST_ASSERT(npc.equip_to_slot_if_possible(key, ITEM_SLOT_LPOCKET), "Test fixture: the key would not go in the NPC's pocket") + TEST_ASSERT(!npc.npc_lock_item(key), "A ship key was locked to an NPC as gear") + TEST_ASSERT(!human_npc_gear_locked(key) && !human_npc_gear_consumable(key), "A ship key was locked to an NPC") + + var/obj/item/grenade/smokebomb/grenade = allocate(/obj/item/grenade/smokebomb) + TEST_ASSERT(npc.put_in_hands(grenade), "Test fixture: the NPC could not hold a grenade") + TEST_ASSERT(npc.npc_lock_item(grenade), "A grenade handed to an NPC was not issued") + TEST_ASSERT(human_npc_gear_consumable(grenade) && !human_npc_gear_locked(grenade), "A grenade was locked like gear instead of issued as a consumable") + TEST_ASSERT(HAS_TRAIT(npc, TRAIT_CAN_THROW_ITEMS), "Test fixture: the NPC can't throw") + TEST_ASSERT(human_npc_ready_item(npc, grenade), "Test fixture: the grenade is not in the NPC's hand") + npc.throw_item(locate(run_loc_floor_bottom_left.x + 3, run_loc_floor_bottom_left.y + 3, run_loc_floor_bottom_left.z)) + TEST_ASSERT(!npc.is_holding(grenade), "The NPC could not throw its grenade") + sleep(1 SECONDS) + TEST_ASSERT(!QDELETED(grenade) && isturf(grenade.loc), "A thrown grenade was deleted where it landed") + player.put_in_hands(grenade) + TEST_ASSERT(human_npc_wait_deleted(grenade), "A player kept an NPC's grenade") + + // Dropped like anyone's, and gone with its NPC. + var/obj/item/grenade/smokebomb/dropped = allocate(/obj/item/grenade/smokebomb) + npc.put_in_hands(dropped) + npc.npc_lock_item(dropped) + TEST_ASSERT(npc.dropItemToGround(dropped), "An NPC could not let go of a grenade") + sleep(2) + TEST_ASSERT(!QDELETED(dropped), "A grenade an NPC dropped was deleted on the floor") + + var/obj/item/gun/gun = locate() in npc.held_items + npc.death() + TEST_ASSERT(isturf(key.loc), "The ship key did not drop when the NPC died") + TEST_ASSERT(human_npc_holding(gun) == npc, "The NPC's gun came off when it died") + qdel(npc) + TEST_ASSERT(QDELETED(dropped), "A loose grenade outlived the NPC that carried it") + +/// Owner ruling: an NPC's chrome works like a player's in life (a dodge shunt dodges) and burns out to +/// scrap when it dies or the ware is taken out of it. +/datum/unit_test/voidcrew_human_npc_chrome + +/datum/unit_test/voidcrew_human_npc_chrome/Run() + var/mob/living/carbon/human/npc/sample/swashbuckler/npc = allocate(/mob/living/carbon/human/npc/sample/swashbuckler) + var/obj/item/organ/cyberimp/cyberware/slipwire/shunt = npc.npc_install_cyberware(/obj/item/organ/cyberimp/cyberware/slipwire) + TEST_ASSERT_NOTNULL(shunt, "A dodge shunt would not install in an NPC") + TEST_ASSERT_EQUAL(shunt.owner, npc, "The dodge shunt is not in the NPC") + TEST_ASSERT_NOTNULL(npc.GetComponent(/datum/component/cyberware_dodge), "An NPC's dodge shunt registered no dodge") + TEST_ASSERT(shunt.get_dodge_chance() > 0, "An NPC's working dodge shunt gives no dodge chance") + + var/turf/floor = get_turf(npc) + npc.death() + TEST_ASSERT(QDELETED(shunt), "An NPC's chrome came through its death working") + TEST_ASSERT_NOTNULL(locate(/obj/item/burnt_chrome) in floor, "An NPC's chrome left no scrap") + TEST_ASSERT_NULL(npc.GetComponent(/datum/component/cyberware_dodge), "A dead NPC still dodges") + + // Taken out of a living NPC: scrap too. + var/turf/elsewhere = locate(run_loc_floor_bottom_left.x + 2, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z) + var/mob/living/carbon/human/npc/sample/swashbuckler/patient = allocate(/mob/living/carbon/human/npc/sample/swashbuckler, elsewhere) + var/obj/item/organ/ware = patient.npc_install_cyberware(/obj/item/organ/cyberimp/cyberware/slipwire) + TEST_ASSERT_NOTNULL(ware, "Test fixture: the second shunt would not install") + ware.Remove(patient) + 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") From 260ad6975e5375b480af6e58b74c4503696d194e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:09:43 -0800 Subject: [PATCH 037/472] Only boss NPCs perform gun executions --- voidcrew/modules/human_npcs/human_npc.dm | 4 ++++ .../modules/hyperviolence/overlays_exec/executions.dm | 9 +++++++-- voidcrew/modules/unit_tests/voidcrew_human_npcs.dm | 6 ++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/voidcrew/modules/human_npcs/human_npc.dm b/voidcrew/modules/human_npcs/human_npc.dm index e4e823d8780..375fc3c1bb3 100644 --- a/voidcrew/modules/human_npcs/human_npc.dm +++ b/voidcrew/modules/human_npcs/human_npc.dm @@ -276,6 +276,10 @@ /mob/living/carbon/human/npc/hv_can_land_aimed_crits() return client || npc_rank >= HUMAN_NPC_RANK_ELITE +// Gun executions are rarer still: bosses only, as a deliberate move (owner ruling OC-9). +/mob/living/carbon/human/npc/hv_can_execute() + return client || npc_rank >= HUMAN_NPC_RANK_BOSS + /mob/living/carbon/human/npc/Login() . = ..() // A player in control of the body (a brain transplant) can't keep its gear (D3). Admins diff --git a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm index ec82bfb4b24..0c196a8a813 100644 --- a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm +++ b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm @@ -45,8 +45,8 @@ /obj/item/gun/proc/hv_execution_possible(mob/living/user, mob/living/target) if(!isliving(user) || !isliving(target) || user == target) return FALSE - // Executions follow the aimed crit rule: an ordinary human NPC just fires a point-blank shot (D4). - if(!user.hv_can_land_aimed_crits()) + // Only players and boss NPCs execute; anyone else just fires a point-blank shot (owner ruling OC-9). + if(!user.hv_can_execute()) return FALSE if(!target.hv_can_be_executed() || !user.Adjacent(target)) return FALSE @@ -218,3 +218,8 @@ if(head.hv_open_skull() && head.hv_skull_wound >= 3) hv_blood_spray(shot_dir, 3) hv_add_hit_line("The shot blows out the back of [src]'s skull.", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) + +/// Whether this mob turns a point-blank head shot on someone helpless into an execution. Players can; +/// human NPCs only at boss rank (human_npcs/human_npc.dm). +/mob/living/proc/hv_can_execute() + return hv_can_land_aimed_crits() diff --git a/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm b/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm index ed1a0201045..34c480a273b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm +++ b/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm @@ -152,7 +152,7 @@ for(var/obj/item/item as anything in gibbed_kit) TEST_ASSERT(human_npc_wait_deleted(item), "[item] survived its NPC being gibbed") -/// D4: only elite and boss human NPCs land aimed crits and gun executions. Players always can. +/// D4: only elite and boss human NPCs land aimed crits; only bosses execute (OC-9). Players always can. /datum/unit_test/voidcrew_human_npc_crit_policy /datum/unit_test/voidcrew_human_npc_crit_policy/Run() @@ -180,7 +180,9 @@ var/obj/item/gun/elite_gun = locate() in elite.held_items victim.Knockdown(30 SECONDS) TEST_ASSERT(!regular_gun.hv_execution_possible(regular, victim), "A regular human NPC can execute a downed player") - TEST_ASSERT(elite_gun.hv_execution_possible(elite, victim), "An elite human NPC can't execute a downed player") + TEST_ASSERT(!elite_gun.hv_execution_possible(elite, victim), "An elite human NPC can execute a downed player; only bosses may") + elite.npc_rank = 2 // HUMAN_NPC_RANK_BOSS; tests compile before voidcrew defines + TEST_ASSERT(elite_gun.hv_execution_possible(elite, victim), "A boss human NPC can't execute a downed player") /// spawn_human_npc() queues bodies; the subsystem builds them a budget at a time, and a cancelled /// order builds nothing. From adab4f41de5e0e991e178dd967d01530e9ffdec0 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:50:54 -0800 Subject: [PATCH 038/472] Add the gore animation pass to hyperviolence Hit flinches, head snaps, hit-stops, limb-loss lurches and limps; directional death falls with slides, different deaths, twitches; tumbling limbs, heads and brains, arterial spurts; blood that paints in, drips, spreads and floats; guts that unspool and slither; the execution press and slump; boss finisher rears, lifts, shakes and kill beats; surrender trembling, crit crawling, pleading and shaky self-injection. All named animate() sequences that end where they began. --- tgstation.dme | 10 + .../modules/hyperviolence/visuals/_defines.dm | 148 +++++++ .../hyperviolence/visuals/_motion_core.dm | 377 ++++++++++++++++++ .../hyperviolence/visuals/blood_motion.dm | 176 ++++++++ .../hyperviolence/visuals/death_falls.dm | 266 ++++++++++++ .../hyperviolence/visuals/execution_motion.dm | 86 ++++ .../hyperviolence/visuals/finisher_motion.dm | 140 +++++++ .../hyperviolence/visuals/flying_gore.dm | 365 +++++++++++++++++ .../hyperviolence/visuals/gut_motion.dm | 67 ++++ .../hyperviolence/visuals/hit_reactions.dm | 191 +++++++++ .../hyperviolence/visuals/npc_motion.dm | 114 ++++++ 11 files changed, 1940 insertions(+) create mode 100644 voidcrew/modules/hyperviolence/visuals/_defines.dm create mode 100644 voidcrew/modules/hyperviolence/visuals/_motion_core.dm create mode 100644 voidcrew/modules/hyperviolence/visuals/blood_motion.dm create mode 100644 voidcrew/modules/hyperviolence/visuals/death_falls.dm create mode 100644 voidcrew/modules/hyperviolence/visuals/execution_motion.dm create mode 100644 voidcrew/modules/hyperviolence/visuals/finisher_motion.dm create mode 100644 voidcrew/modules/hyperviolence/visuals/flying_gore.dm create mode 100644 voidcrew/modules/hyperviolence/visuals/gut_motion.dm create mode 100644 voidcrew/modules/hyperviolence/visuals/hit_reactions.dm create mode 100644 voidcrew/modules/hyperviolence/visuals/npc_motion.dm diff --git a/tgstation.dme b/tgstation.dme index 9942609a685..54911ffe0fc 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7264,6 +7264,16 @@ #include "voidcrew\modules\hyperviolence\hitlines\npc_blood.dm" #include "voidcrew\modules\hyperviolence\overlays_exec\executions.dm" #include "voidcrew\modules\hyperviolence\overlays_exec\gore_overlays.dm" +#include "voidcrew\modules\hyperviolence\visuals\_defines.dm" +#include "voidcrew\modules\hyperviolence\visuals\_motion_core.dm" +#include "voidcrew\modules\hyperviolence\visuals\blood_motion.dm" +#include "voidcrew\modules\hyperviolence\visuals\death_falls.dm" +#include "voidcrew\modules\hyperviolence\visuals\execution_motion.dm" +#include "voidcrew\modules\hyperviolence\visuals\finisher_motion.dm" +#include "voidcrew\modules\hyperviolence\visuals\flying_gore.dm" +#include "voidcrew\modules\hyperviolence\visuals\gut_motion.dm" +#include "voidcrew\modules\hyperviolence\visuals\hit_reactions.dm" +#include "voidcrew\modules\hyperviolence\visuals\npc_motion.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" #include "voidcrew\modules\intents\hud_intent.dm" diff --git a/voidcrew/modules/hyperviolence/visuals/_defines.dm b/voidcrew/modules/hyperviolence/visuals/_defines.dm new file mode 100644 index 00000000000..c2f8cd724bc --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/_defines.dm @@ -0,0 +1,148 @@ +// Hyperviolence visuals: defines private to this part. Defines shared between the hyperviolence +// parts live in voidcrew/_DEFINES/hyperviolence.dm. Every number here is a first guess, untuned. +// +// This folder is named so it sorts after the other hyperviolence parts in tgstation.dme: its +// overrides of their procs must come after the procs are declared, or DM silently drops them. +// For the same reason this file and _motion_core.dm sort first inside the folder. + +// ---- Named animation sequences ---- +// Every animation this part plays on a mob is a named animate() sequence. A named sequence runs +// alongside every other animation on the atom (tg's lying turn, its offsets, the floating bob), +// adds only its own change on top, and replaces only an earlier sequence of the same name. Each one +// ends exactly where it began, so the server-side transform and pixel offsets never move. + +#define HV_VIS_TAG_FLINCH "hv_flinch" +#define HV_VIS_TAG_HITSTOP "hv_hitstop" +#define HV_VIS_TAG_LURCH "hv_lurch" +#define HV_VIS_TAG_LIMP "hv_limp" +#define HV_VIS_TAG_FALL "hv_fall" +#define HV_VIS_TAG_SLIDE "hv_slide" +#define HV_VIS_TAG_POSE "hv_pose" +#define HV_VIS_TAG_TWITCH "hv_twitch" +#define HV_VIS_TAG_ARC "hv_arc" +#define HV_VIS_TAG_SPIN "hv_spin" +#define HV_VIS_TAG_PAINT "hv_paint" +#define HV_VIS_TAG_DRIFT "hv_drift" +#define HV_VIS_TAG_TREMBLE "hv_tremble" +#define HV_VIS_TAG_LEAN "hv_lean" +#define HV_VIS_TAG_CRAWL "hv_crawl" +#define HV_VIS_TAG_PRESS "hv_press" +#define HV_VIS_TAG_LIFT "hv_lift" +#define HV_VIS_TAG_SHAKE "hv_shake" +#define HV_VIS_TAG_REAR "hv_rear" + +/// What ends a held animation (/datum/component/hv_motion_hold). +#define HV_VIS_END_MOVE (1<<0) +#define HV_VIS_END_STAND (1<<1) +#define HV_VIS_END_REVIVE (1<<2) +#define HV_VIS_END_DEATH (1<<3) +#define HV_VIS_END_LIE (1<<4) + +/// Where the feet of a standing 32x32 body are, from the middle of its icon. Leans pivot here. +#define HV_VIS_FEET_Y -16 + +/// The x and y of a direction, each -1, 0 or 1. +#define HV_VIS_DX(dir) (((dir) & EAST) ? 1 : (((dir) & WEST) ? -1 : 0)) +#define HV_VIS_DY(dir) (((dir) & NORTH) ? 1 : (((dir) & SOUTH) ? -1 : 0)) + +// ---- Getting hit ---- + +/// Least damage in one tick that makes a body flinch. +#define HV_VIS_FLINCH_MIN 4 +/// Damage per pixel a flinch pushes the body along the blow, and the most it ever pushes. +#define HV_VIS_FLINCH_DAMAGE_PER_PIXEL 6 +#define HV_VIS_FLINCH_MAX_PUSH 8 +/// Damage per degree a flinch tilts a standing body, and the most it ever tilts. +#define HV_VIS_FLINCH_DAMAGE_PER_DEGREE 3 +#define HV_VIS_FLINCH_MAX_TILT 12 +/// Damage in one tick (a shotgun's pellets together) that turns a flinch into a stagger. +#define HV_VIS_STAGGER_DAMAGE 40 +/// Least damage to the head that whips it back. +#define HV_VIS_HEADSHOT_MIN 10 +/// Tilt of a head snap, at least and at most. +#define HV_VIS_HEADSHOT_MIN_TILT 10 +#define HV_VIS_HEADSHOT_MAX_TILT 22 +/// How long a hit-stop holds both bodies still: three frames at 20 frames a second. +#define HV_VIS_HITSTOP_TIME (0.15 SECONDS) +/// The colour filter that flashes a body pale on a big hit. +#define HV_VIS_FLASH_FILTER "hv_vis_flash" +/// How far a lost limb throws the body toward the missing side, in pixels and degrees. +#define HV_VIS_LURCH_PUSH 4 +#define HV_VIS_LURCH_TILT 14 +/// The dip and lean of each step on one leg. +#define HV_VIS_LIMP_DIP 2 +#define HV_VIS_LIMP_TILT 6 + +// ---- Dying ---- + +/// Damage in the killing tick that slides a body a whole tile. +#define HV_VIS_FULL_SLIDE_DAMAGE 40 +/// How long a whole-tile slide takes. +#define HV_VIS_SLIDE_TIME (0.4 SECONDS) +/// Most a body slides within its own tile, in pixels. +#define HV_VIS_MAX_SLIDE 10 +/// How long a corpse keeps a pose (a slide, a curl) before it eases back. Moving, standing up or +/// being revived ends it at once. +#define HV_VIS_POSE_HOLD (20 MINUTES) +/// The beat an executed body holds before it slumps. +#define HV_VIS_SLUMP_BEAT (0.3 SECONDS) +/// How a body died, which decides how it falls (hv_vis_death_kind()). +/// Nothing that just hit it: poison, suffocation, a slow death in crit. +#define HV_VIS_DEATH_QUIET 0 +/// A blow from a direction killed it. +#define HV_VIS_DEATH_HIT 1 +/// A blow to the head killed it, or the head came off. +#define HV_VIS_DEATH_HEADSHOT 2 +/// Fire or a burning blow killed it. +#define HV_VIS_DEATH_BURNED 3 +/// It ran out of blood. +#define HV_VIS_DEATH_BLED_OUT 4 +/// An execution round killed it. +#define HV_VIS_DEATH_EXECUTED 5 + +// ---- Flying gore ---- + +/// Styles of tumble (/datum/component/hv_tumble). +#define HV_VIS_TUMBLE_LIMB 1 +#define HV_VIS_TUMBLE_HEAD 2 +#define HV_VIS_TUMBLE_BRAIN 3 +#define HV_VIS_TUMBLE_ORGAN 4 +#define HV_VIS_TUMBLE_SLIDE 5 +/// Throw speed (tiles a decisecond) of a tumbling part, slow enough to see the arc. +#define HV_VIS_TUMBLE_SPEED 1 +/// Most blood streaks one tumbling part leaves along its way. +#define HV_VIS_TUMBLE_MAX_STREAKS 4 +/// Blood flow of a single wound from which it spurts with the heartbeat. +#define HV_VIS_ARTERIAL_FLOW 3 +/// Heartbeats: resting, fast (blood running low) and racing (nearly bled out). +#define HV_VIS_BEAT_RESTING (0.8 SECONDS) +#define HV_VIS_BEAT_FAST (0.55 SECONDS) +#define HV_VIS_BEAT_RACING (0.45 SECONDS) +/// How often at most a body spurts, about one Life tick. +#define HV_VIS_SPURT_COOLDOWN (2 SECONDS) + +// ---- Blood ---- + +/// How long a spray takes to reach each further tile. +#define HV_VIS_SPRAY_STEP (0.06 SECONDS) +/// How long each tile of spray takes to paint in. +#define HV_VIS_SPRAY_PAINT (0.15 SECONDS) +/// How long a smear on a wall takes to run down. +#define HV_VIS_DRIP_TIME (6 SECONDS) +/// How long a pool takes to spread to its new size, and a lobe to run onto the next tile. +#define HV_VIS_POOL_CREEP (1.8 SECONDS) +#define HV_VIS_LOBE_CREEP (1.5 SECONDS) +/// How far floating blood drifts from the wound, in pixels, at least and at most. +#define HV_VIS_DRIFT_MIN 5 +#define HV_VIS_DRIFT_MAX 10 + +// ---- Executions and finishers ---- + +/// How far the shooter leans in over the victim while the barrel is pressed to the head. +#define HV_VIS_PRESS_LEAN 4 +/// The still moment at the end of the press, after the trembling stops. +#define HV_VIS_PRESS_PAUSE (0.3 SECONDS) +/// Screen shake of a finisher's killing blow, for everyone who sees it: length and strength in tiles. +#define HV_VIS_KILL_SHAKE_TIME 4 +#define HV_VIS_KILL_SHAKE_STRENGTH 0.12 +#define HV_VIS_KILL_SHAKE_RANGE 7 diff --git a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm new file mode 100644 index 00000000000..7e98cec1ce4 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm @@ -0,0 +1,377 @@ +// Hyperviolence visuals: the animation pass over the gore. Bodies flinch, snap, lurch and limp when +// hit, fall away from the killing blow and twitch after, severed parts and brains tumble through +// the air, blood paints in, drips, spreads and floats, guts unspool and slither, and executions and +// boss finishers get their beats. +// +// Rules every animation here keeps: +// - Code animation only, on the sprites the body already has: animate(), transforms, pixel offsets, +// one short colour filter. Nothing runs on a timer loop over mobs; everything is started by an +// event that already happens (a hit, a death, a lost limb, a throw, a move, a stage of a finisher). +// - Each animation is a named sequence (_visuals_defines.dm) that ends exactly where it began. The +// server-side transform and pixel offsets are never left changed, so tg's lying turn, offsets, +// height filters and mob scaling stay right, and anything that later rebuilds the transform from +// the current one (update_transform) builds on the true value. +// - Poses a corpse keeps (a slide, a curl) are held by the animation itself and ended by +// /datum/component/hv_motion_hold when the body moves, stands up or is revived. +// - Purely visual: nothing here stuns, roots or delays anyone. A hit-stop freezes the pictures only. +// - Offsets stay small (under a third of a tile) so nobody is drawn over someone else's tile. +// - Screen shake only for viewers near a boss finisher's kill, small, and never for players who set +// the screen shake preference (/datum/preference/toggle/screen_shake_darken). + +/mob/living + /// The tick of the hit being resolved, noted before its damage lands so a fall or death it + /// causes can go its way. + var/hv_vis_hit_time = -1 + /// The way that hit travelled, from the attacker toward this mob. + var/hv_vis_hit_dir = 0 + /// The body zone it landed on. + var/hv_vis_hit_zone + /// Its damage type. + var/hv_vis_hit_type + /// All the damage this mob took in hits this tick, so a shotgun's pellets flinch as one blow. + var/hv_vis_hit_damage = 0 + /// Weak reference to whoever landed it, for the hit-stop. + var/datum/weakref/hv_vis_hit_by + /// How many hits are being resolved on this mob right now, and the tick that count is for. + var/hv_vis_resolving = 0 + var/hv_vis_resolving_time = -1 + /// The tick a sever, burst or crit asked for a hit-stop. + var/hv_vis_big_hit_time = -1 + /// The tick the last hit-stop played, so one blow plays it once. + var/hv_vis_hitstop_time = -1 + /// The tick this mob went from standing to lying. + var/hv_vis_fell_time = -1 + /// The tick this mob died. + var/hv_vis_died_time = -1 + /// The tick an execution round was fired into this mob. + var/hv_vis_execution_time = -1 + +/mob/living/Initialize(mapload) + . = ..() + AddElement(/datum/element/hv_motion_hooks) + +/// Starts the animations on a mob from what happens to it. An element, not the mob registering on +/// itself: a mob holds one handler per signal per listener, so self-registrations collide with the +/// mob's own handlers (see /datum/element/hv_blood_hooks). +/datum/element/hv_motion_hooks + +/datum/element/hv_motion_hooks/Attach(datum/target) + . = ..() + if(!isliving(target)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_MOB_APPLY_DAMAGE, PROC_REF(on_hit)) + RegisterSignal(target, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(after_hit)) + RegisterSignal(target, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + RegisterSignal(target, COMSIG_MOB_STATCHANGE, PROC_REF(on_stat_change)) + RegisterSignal(target, COMSIG_MOB_EMOTED("surrender"), PROC_REF(on_surrender)) + if(iscarbon(target)) + RegisterSignal(target, COMSIG_CARBON_REMOVE_LIMB, PROC_REF(on_limb_lost)) + +/datum/element/hv_motion_hooks/Detach(datum/source) + UnregisterSignal(source, list( + COMSIG_MOB_APPLY_DAMAGE, + COMSIG_MOB_AFTER_APPLY_DAMAGE, + COMSIG_LIVING_DEATH, + COMSIG_MOB_STATCHANGE, + COMSIG_MOB_EMOTED("surrender"), + COMSIG_CARBON_REMOVE_LIMB, + )) + return ..() + +/datum/element/hv_motion_hooks/proc/on_hit(mob/living/source, damage, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) + SIGNAL_HANDLER + source.hv_vis_note_hit(damage, damagetype, def_zone, attack_direction, attacking_item) + +/datum/element/hv_motion_hooks/proc/after_hit(mob/living/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) + SIGNAL_HANDLER + source.hv_vis_after_hit(damagetype, attack_direction) + +/datum/element/hv_motion_hooks/proc/on_death(mob/living/source, gibbed) + SIGNAL_HANDLER + source.hv_vis_on_death(gibbed) + +/datum/element/hv_motion_hooks/proc/on_stat_change(mob/living/source, new_stat, old_stat) + SIGNAL_HANDLER + source.hv_vis_on_stat_change(new_stat, old_stat) + +/datum/element/hv_motion_hooks/proc/on_surrender(mob/living/source) + SIGNAL_HANDLER + source.hv_vis_surrender() + +/datum/element/hv_motion_hooks/proc/on_limb_lost(mob/living/carbon/source, obj/item/bodypart/lost_limb, special, dismembered) + SIGNAL_HANDLER + source.hv_vis_on_limb_lost(lost_limb, special, dismembered) + +// ---- What hit us ---- + +/// Notes a hit before its damage lands: which way it went, where, how hard, and who landed it. +/mob/living/proc/hv_vis_note_hit(damage, damagetype, def_zone, attack_direction, atom/attacking_item) + if(hv_vis_resolving_time != world.time) + hv_vis_resolving_time = world.time + hv_vis_resolving = 0 + hv_vis_resolving++ + if(damagetype != BRUTE && damagetype != BURN && damagetype != STAMINA) + return + if(hv_vis_hit_time != world.time) + hv_vis_hit_time = world.time + hv_vis_hit_damage = 0 + hv_vis_hit_dir = 0 + hv_vis_hit_by = null + // A baton or disabler hit makes a body jerk, but less than a bullet. + hv_vis_hit_damage += damagetype == STAMINA ? damage * 0.5 : damage + if(attack_direction) + hv_vis_hit_dir = attack_direction + hv_vis_hit_type = damagetype + hv_vis_hit_zone = hv_vis_zone_of(def_zone) + var/mob/living/attacker = hv_vis_attacker_of(attacking_item) + if(attacker && attacker != src) + hv_vis_hit_by = WEAKREF(attacker) + +/// After a hit has landed: the flinch, held for a hit-stop if the hit took a limb, burst a head or +/// landed a crit. A hit that killed is shown by the death instead. +/mob/living/proc/hv_vis_after_hit(damagetype, attack_direction) + if(hv_vis_resolving_time == world.time && hv_vis_resolving > 0) + hv_vis_resolving-- + if(damagetype != BRUTE && damagetype != BURN && damagetype != STAMINA) + return + if(hv_vis_hit_time != world.time || hv_vis_died_time == world.time) + return + var/hit_dir = hv_vis_hit_dir || attack_direction + if(!hit_dir || !isturf(loc)) + return + var/big = hv_vis_big_hit_time == world.time + var/headshot = hv_vis_hit_zone == BODY_ZONE_HEAD && damagetype != STAMINA && hv_vis_hit_damage >= HV_VIS_HEADSHOT_MIN + hv_vis_flinch(hv_vis_hit_damage, hit_dir, headshot, big ? HV_VIS_HITSTOP_TIME : 0) + if(big) + hv_vis_hitstop_extras(hit_dir) + +/// Whether a hit on this mob is being resolved right now, between its two damage signals. +/mob/living/proc/hv_vis_inside_hit() + return hv_vis_resolving_time == world.time && hv_vis_resolving > 0 + +/// A limb came off, a head burst or a crit landed: this blow gets a hit-stop. Inside a hit, the flinch +/// at its end holds; anything else (an execution's burst, a finisher's kill) plays it now. +/mob/living/proc/hv_vis_big_hit(hit_dir, mob/living/attacker) + hv_vis_big_hit_time = world.time + if(attacker && attacker != src) + hv_vis_hit_by = WEAKREF(attacker) + if(hv_vis_inside_hit() || !isturf(loc)) + return + if(!hit_dir) + hit_dir = hv_vis_hit_dir || dir + if(hv_vis_died_time != world.time) + var/damage = hv_vis_hit_time == world.time ? hv_vis_hit_damage : 0 + hv_vis_flinch(max(damage, HV_VIS_STAGGER_DAMAGE), hit_dir, FALSE, HV_VIS_HITSTOP_TIME) + hv_vis_hitstop_extras(hit_dir) + +/// The zone a hit landed on, whether given as a bodypart or a zone name. +/proc/hv_vis_zone_of(def_zone) + if(isbodypart(def_zone)) + var/obj/item/bodypart/limb = def_zone + return limb.body_zone + if(istext(def_zone)) + return check_zone(def_zone) + return null + +/// Who is behind a hit: the firer of a projectile, the holder of a weapon, or a mob itself. +/proc/hv_vis_attacker_of(atom/attacking_item) + if(isprojectile(attacking_item)) + var/obj/projectile/shot = attacking_item + return isliving(shot.firer) ? shot.firer : null + if(isitem(attacking_item)) + var/obj/item/weapon = attacking_item + return isliving(weapon.loc) ? weapon.loc : null + if(isliving(attacking_item)) + return attacking_item + return null + +// ---- Playing animations ---- + +/** + * Plays a named sequence of steps on this atom. Each step is list(pixel_w, pixel_z, transform, time, + * easing), absolute values; the last step must be where the atom stands now, so the sequence ends + * where it began. A named sequence only adds its own change to whatever else is animating. + */ +/atom/proc/hv_vis_play(tag, list/steps, loop = 1) + if(!length(steps)) + return + for(var/i in 1 to length(steps)) + var/list/step = steps[i] + if(i == 1) + animate(src, pixel_w = step[1], pixel_z = step[2], transform = step[3], time = step[4], easing = step[5], loop = loop, tag = tag) + else + animate(pixel_w = step[1], pixel_z = step[2], transform = step[3], time = step[4], easing = step[5]) + +/** + * Plays steps that take over this atom's whole animation: each step is list(pixel_y, transform, time, + * easing). Used only where tg's own lying turn has to wait (a body held up for a beat before it + * falls). The last step must be the atom's real pixel_y and transform. Never on a floating mob: it + * would stop the floating bob. + */ +/atom/proc/hv_vis_play_over(list/steps) + if(!length(steps)) + return + for(var/i in 1 to length(steps)) + var/list/step = steps[i] + if(i == 1) + animate(src, pixel_y = step[1], transform = step[2], time = step[3], easing = step[4]) + else + animate(pixel_y = step[1], transform = step[2], time = step[3], easing = step[4]) + +/// Eases a named sequence back to where the atom really is over `time`, from wherever it has got to. +/atom/proc/hv_vis_release(tag, time = 2) + animate(src, pixel_w = pixel_w, pixel_z = pixel_z, transform = transform, time = time, easing = SINE_EASING, tag = tag) + +/// Stops named sequences at once. +/atom/proc/hv_vis_stop(list/tags) + for(var/tag in tags) + animate(src, tag = tag) + +/// A copy of `base` turned `angle` degrees clockwise about a point `pivot_y` pixels above (or below) +/// the middle of the icon, as drawn. +/proc/hv_vis_turned(matrix/base, angle, pivot_y = 0) + var/matrix/turned = matrix(base) + if(!angle) + return turned + turned.Translate(0, -pivot_y) + turned.Turn(angle) + turned.Translate(0, pivot_y) + return turned + +/// `base` with a change made in the body's own frame first: scaled, then moved, then turned, before +/// `base` lays it down. On a lying body this shortens the body along its own length. +/proc/hv_vis_local(matrix/base, scale_x = 1, scale_y = 1, angle = 0, move_x = 0, move_y = 0) + var/matrix/local = matrix() + local.Scale(scale_x, scale_y) + if(move_x || move_y) + local.Translate(move_x, move_y) + if(angle) + local.Turn(angle) + return local * matrix(base) + +/// Where a lean of this mob pivots, from the middle of its icon: the feet of a standing body. +/mob/living/proc/hv_vis_pivot() + if(!iscarbon(src) || body_position == LYING_DOWN) + return 0 + return HV_VIS_FEET_Y * current_size + +/// Whether this mob is so big that blows barely move it. +/mob/living/proc/hv_vis_is_huge() + return mob_size >= MOB_SIZE_LARGE || maxHealth >= 400 + +// ---- Hit-stop ---- + +/// The hit-stop on top of the held flinch, once per blow: the body flashes pale and whoever landed it +/// holds their pose for the same frames. +/mob/living/proc/hv_vis_hitstop_extras(hit_dir, mob/living/attacker) + if(hv_vis_hitstop_time == world.time) + return + hv_vis_hitstop_time = world.time + hv_vis_flash() + if(!attacker) + attacker = hv_vis_hit_by?.resolve() + if(!attacker || attacker == src || QDELETED(attacker) || attacker.z != z || get_dist(attacker, src) > 9) + return + attacker.hv_vis_hold_blow(src) + +/// A pale flash over the body for the frames of a hit-stop. A colour filter, gone again half a second later. +/mob/living/proc/hv_vis_flash() + if(!get_filter(HV_VIS_FLASH_FILTER)) + add_filter(HV_VIS_FLASH_FILTER, 10, color_matrix_filter(list(1,0,0, 0,1,0, 0,0,1, 0,0,0))) + var/flash = get_filter(HV_VIS_FLASH_FILTER) + if(!flash) + return + animate(flash, color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = 0.5, easing = LINEAR_EASING) + animate(color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = HV_VIS_HITSTOP_TIME, easing = LINEAR_EASING) + animate(color = list(1,0,0, 0,1,0, 0,0,1, 0,0,0), time = 1, easing = LINEAR_EASING) + addtimer(CALLBACK(src, TYPE_PROC_REF(/datum, remove_filter), HV_VIS_FLASH_FILTER), 0.5 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_DELETE_ME) + +/// The attacker's side of a hit-stop: the blow sticks for a few frames. A melee attacker stays leaned +/// into the victim, a shooter stays rocked back. +/mob/living/proc/hv_vis_hold_blow(atom/victim) + if(!isturf(loc)) + return + var/toward = get_dir(src, victim) + var/lean = get_dist(src, victim) <= 1 ? 3 : -2 + var/dx = HV_VIS_DX(toward) * lean + var/dy = HV_VIS_DY(toward) * lean + var/matrix/base = matrix(transform) + hv_vis_play(HV_VIS_TAG_HITSTOP, list( + list(pixel_w + dx, pixel_z + dy, base, 0.5, QUAD_EASING|EASE_OUT), + list(pixel_w + dx, pixel_z + dy, base, HV_VIS_HITSTOP_TIME, LINEAR_EASING), + list(pixel_w, pixel_z, base, 2, SINE_EASING), + )) + +/// Shakes the screens of players near `center` who can see it, unless they asked for less shake. +/proc/hv_vis_shake_viewers(atom/center, range, duration, strength) + for(var/mob/viewer in viewers(range, center)) + if(!viewer.client || isobserver(viewer)) + continue + if(viewer.client.prefs?.read_preference(/datum/preference/toggle/screen_shake_darken)) + continue + shake_camera(viewer, duration, strength) + +// ---- Held poses ---- + +/** + * Ends held animations on a mob when something happens to it: it is moved, stands up, lies down, + * is revived or dies. Removes itself when the hold runs out on its own. One per hold; a mob can + * have several. + */ +/datum/component/hv_motion_hold + dupe_mode = COMPONENT_DUPE_ALLOWED + /// The named sequences it stops. + var/list/tags + /// HV_VIS_END_* flags: what ends the hold. + var/end_flags = NONE + +/datum/component/hv_motion_hold/Initialize(list/tags, end_flags, duration) + if(!isliving(parent)) + return COMPONENT_INCOMPATIBLE + src.tags = tags + src.end_flags = end_flags + if(duration > 0) + addtimer(CALLBACK(src, PROC_REF(expire)), duration, TIMER_DELETE_ME) + +/datum/component/hv_motion_hold/RegisterWithParent() + if(end_flags & HV_VIS_END_MOVE) + RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_end)) + if(end_flags & (HV_VIS_END_STAND|HV_VIS_END_LIE)) + RegisterSignal(parent, COMSIG_LIVING_SET_BODY_POSITION, PROC_REF(on_body_position)) + if(end_flags & HV_VIS_END_REVIVE) + RegisterSignal(parent, COMSIG_LIVING_REVIVE, PROC_REF(on_end)) + if(end_flags & HV_VIS_END_DEATH) + RegisterSignal(parent, COMSIG_LIVING_DEATH, PROC_REF(on_end)) + +/datum/component/hv_motion_hold/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BODY_POSITION, COMSIG_LIVING_REVIVE, COMSIG_LIVING_DEATH)) + +/datum/component/hv_motion_hold/Destroy(force) + tags = null + return ..() + +/datum/component/hv_motion_hold/proc/on_end(datum/source) + SIGNAL_HANDLER + release() + +/datum/component/hv_motion_hold/proc/on_body_position(datum/source, new_position, old_position) + SIGNAL_HANDLER + if(new_position == STANDING_UP && (end_flags & HV_VIS_END_STAND)) + release() + else if(new_position == LYING_DOWN && (end_flags & HV_VIS_END_LIE)) + release() + +/// Stops the held sequences and goes. +/datum/component/hv_motion_hold/proc/release() + var/atom/holder = parent + if(!QDELETED(holder)) + holder.hv_vis_stop(tags) + qdel(src) + +/// The hold ran its course: the sequences already ended themselves. +/datum/component/hv_motion_hold/proc/expire() + qdel(src) + +/// Holds this mob's named sequences until one of `end_flags` happens or `duration` passes. +/mob/living/proc/hv_vis_hold(list/tags, end_flags, duration) + AddComponent(/datum/component/hv_motion_hold, tags, end_flags, duration) diff --git a/voidcrew/modules/hyperviolence/visuals/blood_motion.dm b/voidcrew/modules/hyperviolence/visuals/blood_motion.dm new file mode 100644 index 00000000000..1f80bf4abbb --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/blood_motion.dm @@ -0,0 +1,176 @@ +// Hyperviolence visuals: blood that moves. +// 11. A spray paints in along the floor: each tile's streak or splash grows out from the side the +// blood came from, a moment after the tile before it. +// 12. A smear on a wall lands short and runs down the wall to its full length over a few seconds. +// 13. A pool that grows to its next size spreads out to it over a couple of seconds, and the end of a +// big pool running onto the next tile flows out from the pool's side. +// 14. In zero gravity, blood leaving a wound drifts away from it turning over, and some of it splits +// off into a smaller bead that floats off and fades. +// tg restarts a blood decal's animations whenever its blood or colour changes (update_blood_color()), +// so any of these is cut short by more blood landing on the same decal: it just shows at once. + +/// The turf a spray is being thrown from, while hv_blood_throw() runs, so its decals know their order. +GLOBAL_VAR(hv_vis_spray_origin) + +/mob/living/hv_blood_throw(spray_dir, strength = 3) + var/turf/previous_origin = GLOB.hv_vis_spray_origin + GLOB.hv_vis_spray_origin = get_turf(src) + . = ..() + GLOB.hv_vis_spray_origin = previous_origin + +/obj/effect/decal/cleanable/blood/hv_spray + /// Set on a decal just made, until hv_aim() turns it the way the blood flew and paints it in. + var/hv_vis_fresh = FALSE + /// How many tiles past the first the spray had come when it made this, for the order tiles paint in. + var/hv_vis_spray_step = 0 + +/obj/effect/decal/cleanable/blood/hv_spray/Initialize(mapload, list/datum/disease/diseases, list/blood_or_dna = get_default_blood_type()) + . = ..() + if(mapload || . == INITIALIZE_HINT_QDEL) + return + hv_vis_fresh = TRUE + var/turf/origin = GLOB.hv_vis_spray_origin + if(origin && origin.z == z) + hv_vis_spray_step = max(get_dist(origin, src) - 1, 0) + +/obj/effect/decal/cleanable/blood/hv_spray/hv_aim(spray_dir) + . = ..() + if(!hv_vis_fresh || istype(src, /obj/effect/decal/cleanable/blood/hv_spray/wall)) + return + hv_vis_fresh = FALSE + hv_vis_paint_in(spray_dir) + +/// Grows the decal out along the floor from the side the blood came from, after the tiles before it. +/obj/effect/decal/cleanable/blood/hv_spray/proc/hv_vis_paint_in(spray_dir) + var/dx = HV_VIS_DX(spray_dir) + var/dy = HV_VIS_DY(spray_dir) + var/full_alpha = alpha + var/matrix/base = matrix(transform) + var/matrix/start + if(dx || dy) + // Squeezed against the edge the blood came in over. + start = hv_vis_local(base, dx ? 0.3 : 1, dy ? 0.3 : 1, 0, -dx * 16 * 0.7, -dy * 16 * 0.7) + else + start = hv_vis_local(base, 0.3, 0.3) + animate(src, alpha = 0, transform = start, time = 0, tag = HV_VIS_TAG_PAINT) + animate(alpha = 0, transform = start, time = hv_vis_spray_step * HV_VIS_SPRAY_STEP) + animate(alpha = full_alpha, transform = base, time = HV_VIS_SPRAY_PAINT, easing = QUAD_EASING|EASE_OUT) + +/obj/effect/decal/cleanable/blood/hv_spray/wall/hv_aim(spray_dir) + . = ..() + if(!hv_vis_fresh) + return + hv_vis_fresh = FALSE + hv_vis_drip() + +/// The smear lands short, hanging from its top edge, and runs down the wall to its full length. +/obj/effect/decal/cleanable/blood/hv_spray/wall/proc/hv_vis_drip() + var/full_alpha = alpha + var/matrix/base = matrix(transform) + // Shortened from the top edge, which stays where it is. + var/matrix/splash = hv_vis_local(base, 1, 0.5, 0, 0, 16 * 0.5) + var/matrix/running = hv_vis_local(base, 1, 0.72, 0, 0, 16 * 0.28) + animate(src, alpha = 0, transform = splash, time = 0, tag = HV_VIS_TAG_PAINT) + animate(alpha = 0, transform = splash, time = (hv_vis_spray_step + 1) * HV_VIS_SPRAY_STEP) + animate(alpha = full_alpha, transform = running, time = 1.5, easing = QUAD_EASING|EASE_OUT) + animate(alpha = full_alpha, transform = base, time = HV_VIS_DRIP_TIME, easing = SINE_EASING|EASE_IN) + +// ---- Pools ---- + +/// How big each pool size is drawn, by its longest reach from the middle, for spreading from one to the next. +GLOBAL_LIST_INIT(hv_vis_pool_reach, list(10, 15, 26, 29)) + +/obj/effect/decal/cleanable/blood/hv_pool_show(state) + var/old_tier = icon == HV_BLOOD_POOL_ICON ? hv_vis_pool_state_tier(icon_state) : 0 + . = ..() + if(QDELETED(src) || dried) + return + if(findtext(state, "lobe") == 1) + hv_vis_lobe_creep() + return + var/new_tier = hv_vis_pool_state_tier(state) + if(!new_tier || new_tier <= old_tier) + return + // A plain splatter reaches about as far as the smallest pool's middle. + var/old_reach = old_tier ? GLOB.hv_vis_pool_reach[old_tier] : 7 + hv_vis_pool_creep(old_reach / GLOB.hv_vis_pool_reach[new_tier]) + +/// The pool size a pool_art state shows, or 0. +/proc/hv_vis_pool_state_tier(state) + if(!istext(state) || findtext(state, "pool") != 1) + return 0 + return clamp(text2num(copytext(state, 5, 6)) || 0, 0, length(GLOB.hv_vis_pool_reach)) + +/// Spreads the pool out from `start_scale` of its size. +/obj/effect/decal/cleanable/blood/proc/hv_vis_pool_creep(start_scale) + var/matrix/base = matrix(transform) + animate(src, transform = hv_vis_local(base, start_scale, start_scale), time = 0, tag = HV_VIS_TAG_PAINT) + animate(transform = base, time = HV_VIS_POOL_CREEP, easing = SINE_EASING|EASE_OUT) + +/// The end of a pool running onto this tile flows out from the pool's side. +/obj/effect/decal/cleanable/blood/proc/hv_vis_lobe_creep() + if(!hv_pool_lobe_dir) + return + // The pool's middle, from this tile's. + var/from_x = -HV_VIS_DX(hv_pool_lobe_dir) * 32 + var/from_y = -HV_VIS_DY(hv_pool_lobe_dir) * 32 + var/scale = 0.3 + var/matrix/base = matrix(transform) + var/matrix/start = hv_vis_local(base, scale, scale, 0, from_x * (1 - scale), from_y * (1 - scale)) + var/full_alpha = alpha + animate(src, transform = start, alpha = 0, time = 0, tag = HV_VIS_TAG_PAINT) + animate(transform = start, alpha = 0, time = HV_VIS_POOL_CREEP * 0.5) + animate(transform = base, alpha = full_alpha, time = HV_VIS_LOBE_CREEP, easing = SINE_EASING|EASE_OUT) + +// ---- Zero gravity ---- + +/obj/effect/decal/cleanable/blood/hv_blood_float_begin() + var/was_floating = hv_floating + . = ..() + if(!was_floating && hv_floating && !QDELETED(src)) + hv_vis_drift() + +/// Drifts off the wound in some direction, turning over once, and sometimes sheds a smaller bead. +/// The drift stays: floating blood hangs wherever it drifted to, until it lands. +/obj/effect/decal/cleanable/blood/proc/hv_vis_drift() + var/angle = rand(0, 359) + var/distance = rand(HV_VIS_DRIFT_MIN, HV_VIS_DRIFT_MAX) + var/end_w = round(distance * sin(angle)) + var/end_z = round(distance * cos(angle)) + animate(src, pixel_w = end_w, pixel_z = end_z, time = 2 SECONDS, easing = SINE_EASING|EASE_OUT, tag = HV_VIS_TAG_DRIFT) + hv_vis_play(HV_VIS_TAG_SPIN, hv_vis_spin_steps(src, 1, 2 SECONDS, pick(-1, 1))) + if(prob(50)) + new /obj/effect/temp_visual/hv_blood_bead(loc, icon_state, color, end_w, end_z) + +/obj/effect/decal/cleanable/blood/hv_blood_land() + if(hv_floating) + hv_vis_stop(list(HV_VIS_TAG_DRIFT, HV_VIS_TAG_SPIN)) + pixel_w = 0 + pixel_z = 0 + return ..() + +/// A small bead of blood split off a floating globule, drifting the other way as it fades. away_w and +/// away_z are the way the globule drifts. +/obj/effect/temp_visual/hv_blood_bead + name = "blood" + icon = HV_BLOOD_FLOAT_ICON + icon_state = "float1" + plane = GAME_PLANE + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 2 SECONDS + +/obj/effect/temp_visual/hv_blood_bead/Initialize(mapload, state, bead_color, away_w = 0, away_z = 0) + . = ..() + if(state) + icon_state = state + if(bead_color && !islist(bead_color)) + color = bead_color + var/matrix/small = matrix() + small.Scale(0.5) + transform = small + var/matrix/gone = matrix() + gone.Scale(0.3) + gone.Turn(pick(-1, 1) * 160) + // Off the other way from the globule it split from. + animate(src, pixel_w = -round(away_w * 0.8) + rand(-2, 2), pixel_z = -round(away_z * 0.8) + rand(-2, 2), transform = gone, alpha = 0, time = duration, easing = SINE_EASING|EASE_OUT) diff --git a/voidcrew/modules/hyperviolence/visuals/death_falls.dm b/voidcrew/modules/hyperviolence/visuals/death_falls.dm new file mode 100644 index 00000000000..affb7ac45c6 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/death_falls.dm @@ -0,0 +1,266 @@ +// Hyperviolence visuals: dying. +// 5. A body falls away from the blow that killed it: tg's turn onto its side is pointed that way, and +// it slides along the blow within its tile. A blow of HV_VIS_FULL_SLIDE_DAMAGE or more (a +// shotgun at close range) slides it a whole tile, a real step onto open floor, never into space, +// lava, a chasm, a wall or a standing person. +// 6. Deaths read differently: a head shot drops straight down and lies limp, a body that bled out +// kneels and sways before it collapses, a burned body draws up and curls. +// 7. Most bodies twitch a few times in the seconds after death. +// Every pose is held by the animation itself and let go by /datum/component/hv_motion_hold the moment +// the body is moved, stands up or is revived. + +/// Falls point away from the hit that dropped the body, when one just landed from the side. +/mob/living/carbon/set_lying_angle(new_lying) + var/falling = !lying_angle && new_lying + if(falling && hv_vis_hit_time == world.time && (new_lying == LYING_ANGLE_EAST || new_lying == LYING_ANGLE_WEST)) + var/dx = HV_VIS_DX(hv_vis_hit_dir) + if(dx) + new_lying = dx > 0 ? LYING_ANGLE_EAST : LYING_ANGLE_WEST + . = ..() + if(falling && lying_angle) + hv_vis_fell_time = world.time + hv_vis_after_fall() + +/// A body that just went down. One collapsing into crit from blood loss kneels and sways first. +/// Deaths are shown by hv_vis_on_death(), a moment later in the same tick. +/mob/living/carbon/proc/hv_vis_after_fall() + if(stat == CONSCIOUS || stat == DEAD || hv_vis_hit_time == world.time) + return + if(blood_volume >= BLOOD_VOLUME_BAD || !can_bleed() || HAS_TRAIT(src, TRAIT_MOVE_FLOATING) || !isturf(loc)) + return + hv_vis_play_over(hv_vis_kneel_collapse_steps()) + +/mob/living/proc/hv_vis_on_death(gibbed) + hv_vis_died_time = world.time + +/mob/living/carbon/hv_vis_on_death(gibbed) + . = ..() + hv_vis_stop(list(HV_VIS_TAG_TREMBLE, HV_VIS_TAG_LEAN, HV_VIS_TAG_CRAWL, HV_VIS_TAG_LIMP)) + if(gibbed || QDELETED(src) || !isturf(loc)) + return + var/fell = hv_vis_fell_time == world.time + var/hit_dir = hv_vis_hit_time == world.time ? hv_vis_hit_dir : 0 + var/big = hv_vis_big_hit_time == world.time + if(big) + hv_vis_hitstop_extras(hit_dir) + switch(hv_vis_death_kind()) + if(HV_VIS_DEATH_EXECUTED) + hv_vis_death_slump(hit_dir, fell) + if(HV_VIS_DEATH_HEADSHOT) + hv_vis_death_drop(hit_dir, fell, big) + if(HV_VIS_DEATH_BURNED) + hv_vis_death_curl(fell) + if(HV_VIS_DEATH_BLED_OUT) + hv_vis_death_bleed_out(fell) + if(HV_VIS_DEATH_HIT) + hv_vis_death_hit(hit_dir, fell, big) + else + hv_vis_twitches(2, fell ? 5 : 3) + +/// How this body died, from what happened to it this tick (HV_VIS_DEATH_*). +/mob/living/carbon/proc/hv_vis_death_kind() + var/recent = hv_vis_hit_time == world.time + if(hv_vis_execution_time == world.time) + return HV_VIS_DEATH_EXECUTED + if(recent && hv_vis_hit_type != STAMINA && (hv_vis_hit_zone == BODY_ZONE_HEAD || !get_bodypart(BODY_ZONE_HEAD))) + return HV_VIS_DEATH_HEADSHOT + if(hv_vis_big_hit_time == world.time && !get_bodypart(BODY_ZONE_HEAD)) + return HV_VIS_DEATH_HEADSHOT + if((recent && hv_vis_hit_type == BURN) || on_fire || HAS_TRAIT(src, TRAIT_HUSK)) + return HV_VIS_DEATH_BURNED + if(recent && hv_vis_hit_dir) + return HV_VIS_DEATH_HIT + if(blood_volume < BLOOD_VOLUME_BAD && can_bleed()) + return HV_VIS_DEATH_BLED_OUT + return HV_VIS_DEATH_QUIET + +// ---- Upright for a moment, then down ---- + +/// The body's transform and pixel_y as it stood a moment ago, before tg turned it onto its side. +/// Returns list(pixel_y, transform). +/mob/living/carbon/proc/hv_vis_upright() + var/matrix/upright = matrix(transform) + var/angle = round(arctan(upright.a, upright.b)) + if(angle) + upright.Turn(-angle) + return list(pixel_y - has_offset(LYING_DOWN_TRAIT, PIXEL_Y_OFFSET), upright) + +/// Holds the body upright for `hold`, then lets it fall over `fall_time`. Steps for hv_vis_play_over(). +/mob/living/carbon/proc/hv_vis_hold_then_fall_steps(hold, fall_time, fall_easing = QUAD_EASING|EASE_IN) + var/list/upright = hv_vis_upright() + return list( + list(upright[1], upright[2], 0, LINEAR_EASING), + list(upright[1], upright[2], hold, LINEAR_EASING), + list(pixel_y, matrix(transform), fall_time, fall_easing), + ) + +/// Sinks to the knees, sways twice and collapses: a body out of blood. Steps for hv_vis_play_over(). +/mob/living/carbon/proc/hv_vis_kneel_collapse_steps() + var/list/upright = hv_vis_upright() + var/matrix/standing = upright[2] + // Shorter from the feet up: down on the knees. + var/matrix/kneeling = hv_vis_local(standing, 1, 0.86, 0, 0, HV_VIS_FEET_Y * 0.14) + return list( + list(upright[1], standing, 0, LINEAR_EASING), + list(upright[1], kneeling, 3, SINE_EASING), + list(upright[1], hv_vis_turned(kneeling, 5, HV_VIS_FEET_Y), 4, SINE_EASING), + list(upright[1], hv_vis_turned(kneeling, -4, HV_VIS_FEET_Y), 5, SINE_EASING), + list(upright[1], hv_vis_turned(kneeling, 2, HV_VIS_FEET_Y), 3, SINE_EASING), + list(pixel_y, matrix(transform), 3, QUAD_EASING|EASE_IN), + ) + +/// Whether the body may be held up over tg's fall: not while floating, where it would stop the bob. +/mob/living/carbon/proc/hv_vis_can_hold_fall() + return !HAS_TRAIT(src, TRAIT_MOVE_FLOATING) + +// ---- The kinds of death ---- + +/// Killed by a blow from the side: slides along it, a whole tile for a heavy blow, then twitches. +/mob/living/carbon/proc/hv_vis_death_hit(hit_dir, fell, big) + if(big && fell && hv_vis_can_hold_fall()) + hv_vis_play_over(hv_vis_hold_then_fall_steps(HV_VIS_HITSTOP_TIME, 2)) + if(hv_vis_hit_damage >= HV_VIS_FULL_SLIDE_DAMAGE && hv_vis_can_slide_tile(hit_dir)) + // Next tick: the blow that killed it may still be resolving (a shotgun's other pellets). + addtimer(CALLBACK(src, PROC_REF(hv_vis_slide_tile), hit_dir), 1, TIMER_DELETE_ME) + return + hv_vis_slide_in_tile(hit_dir, hv_vis_hit_damage, big ? HV_VIS_HITSTOP_TIME : 0) + hv_vis_twitches(rand(3, 5), big ? 6 : 4) + +/// The steps of a slide along `slide_dir` inside the tile, held as the corpse's pose. +/mob/living/carbon/proc/hv_vis_slide_steps(slide_dir, damage, delay = 0) + var/dx = HV_VIS_DX(slide_dir) + var/dy = HV_VIS_DY(slide_dir) + var/distance = clamp(round(damage / 4), 2, HV_VIS_MAX_SLIDE) + if(dx && dy) + distance = max(2, round(distance * 0.7)) + var/matrix/base = matrix(transform) + return list( + list(pixel_w, pixel_z, base, delay, LINEAR_EASING), + list(pixel_w + dx * distance, pixel_z + dy * distance, base, 3, CUBIC_EASING|EASE_OUT), + list(pixel_w + dx * distance, pixel_z + dy * distance, base, HV_VIS_POSE_HOLD, LINEAR_EASING), + list(pixel_w, pixel_z, base, 10, SINE_EASING), + ) + +/mob/living/carbon/proc/hv_vis_slide_in_tile(slide_dir, damage, delay = 0) + if(!slide_dir) + return + hv_vis_play(HV_VIS_TAG_SLIDE, hv_vis_slide_steps(slide_dir, damage, delay)) + hv_vis_hold(list(HV_VIS_TAG_SLIDE), HV_VIS_END_MOVE|HV_VIS_END_STAND|HV_VIS_END_REVIVE, HV_VIS_POSE_HOLD + 3 SECONDS) + +/// Whether a corpse can be slid one tile along `slide_dir`: onto open floor with gravity, nothing +/// solid or standing there, not into space, lava or a chasm, and nobody holding or carrying it. +/mob/living/carbon/proc/hv_vis_can_slide_tile(slide_dir) + if(!slide_dir || buckled || pulledby || throwing || anchored || !isturf(loc)) + return FALSE + if(HAS_TRAIT(src, TRAIT_MOVE_FLOATING) || has_status_effect(/datum/status_effect/hv_finisher_hold)) + return FALSE + var/turf/here = loc + if(here.has_gravity() <= 0) + return FALSE + var/turf/next = get_step(here, slide_dir) + if(!next || next.z != here.z || !isopenturf(next) || isgroundlessturf(next) || islava(next) || ischasm(next)) + return FALSE + if(next.has_gravity() <= 0 || next.is_blocked_turf(FALSE, src) || !here.Adjacent(next)) + return FALSE + return TRUE + +/// Slides the corpse a whole tile along the killing blow, a real move with a slow glide. +/mob/living/carbon/proc/hv_vis_slide_tile(slide_dir) + if(QDELETED(src) || stat != DEAD) + return + if(hv_vis_can_slide_tile(slide_dir)) + Move(get_step(loc, slide_dir), slide_dir, DELAY_TO_GLIDE_SIZE(HV_VIS_SLIDE_TIME)) + hv_vis_twitches(rand(3, 5), HV_VIS_SLIDE_TIME + 2) + +/// Killed by a head shot, or the head came off: straight down, no fall, lying limp. A burst gets its +/// hit-stop first. +/mob/living/carbon/proc/hv_vis_death_drop(hit_dir, fell, big) + if(fell && hv_vis_can_hold_fall()) + if(big) + hv_vis_play_over(hv_vis_hold_then_fall_steps(HV_VIS_HITSTOP_TIME, 0.5)) + else + hv_vis_play_over(list(list(pixel_y, matrix(transform), 0, LINEAR_EASING))) + var/dx = HV_VIS_DX(hit_dir) + var/dy = HV_VIS_DY(hit_dir) + var/matrix/base = matrix(transform) + // Lands with a small bounce and stays where it fell. + hv_vis_play(HV_VIS_TAG_FALL, list( + list(pixel_w, pixel_z, base, big ? HV_VIS_HITSTOP_TIME + 0.5 : 0.5, LINEAR_EASING), + list(pixel_w + dx * 2, pixel_z + dy * 2 + 2, base, 0.5, QUAD_EASING|EASE_OUT), + list(pixel_w, pixel_z, base, 1.5, BOUNCE_EASING), + )) + +/// Burned to death: the body draws up and curls, and keeps that shape. +/mob/living/carbon/proc/hv_vis_death_curl(fell) + hv_vis_play(HV_VIS_TAG_POSE, hv_vis_curl_steps(fell ? 3 : 0)) + hv_vis_hold(list(HV_VIS_TAG_POSE), HV_VIS_END_MOVE|HV_VIS_END_STAND|HV_VIS_END_REVIVE, HV_VIS_POSE_HOLD + 3 SECONDS) + hv_vis_twitches(2, 10) + +/// The steps of a curl: shorter along the body's own length, drawn in a little, held. +/mob/living/carbon/proc/hv_vis_curl_steps(delay = 0) + var/matrix/base = matrix(transform) + var/matrix/curled = hv_vis_local(base, 0.92, 0.8, -8) + return list( + list(pixel_w, pixel_z, base, delay, LINEAR_EASING), + list(pixel_w, pixel_z, curled, 8, SINE_EASING|EASE_OUT), + list(pixel_w, pixel_z, curled, HV_VIS_POSE_HOLD, LINEAR_EASING), + list(pixel_w, pixel_z, base, 10, SINE_EASING), + ) + +/// Bled out: on its feet it kneels and sways before it goes down; already down it sags one last time. +/mob/living/carbon/proc/hv_vis_death_bleed_out(fell) + if(fell && hv_vis_can_hold_fall()) + hv_vis_play_over(hv_vis_kneel_collapse_steps()) + hv_vis_twitches(1, 18) + return + var/matrix/base = matrix(transform) + hv_vis_play(HV_VIS_TAG_FALL, list( + list(pixel_w, pixel_z - 1, hv_vis_local(base, 1, 0.96), 8, SINE_EASING), + list(pixel_w, pixel_z, base, 12, SINE_EASING), + )) + hv_vis_twitches(1, 12) + +/// Executed: the body holds where it was for a beat, then slumps forward along the shot. +/mob/living/carbon/proc/hv_vis_death_slump(shot_dir, fell) + var/beat = HV_VIS_HITSTOP_TIME + HV_VIS_SLUMP_BEAT + if(fell && hv_vis_can_hold_fall()) + hv_vis_play_over(hv_vis_hold_then_fall_steps(beat, 3)) + beat += 3 + if(!shot_dir) + return + var/dx = HV_VIS_DX(shot_dir) + var/dy = HV_VIS_DY(shot_dir) + var/matrix/base = matrix(transform) + var/matrix/slumped = hv_vis_local(base, 1, 0.94) + hv_vis_play(HV_VIS_TAG_SLIDE, list( + list(pixel_w, pixel_z, base, beat, LINEAR_EASING), + list(pixel_w + dx * 4, pixel_z + dy * 4, slumped, 3, QUAD_EASING|EASE_IN), + list(pixel_w + dx * 4, pixel_z + dy * 4, slumped, HV_VIS_POSE_HOLD, LINEAR_EASING), + list(pixel_w, pixel_z, base, 10, SINE_EASING), + )) + hv_vis_hold(list(HV_VIS_TAG_SLIDE), HV_VIS_END_MOVE|HV_VIS_END_STAND|HV_VIS_END_REVIVE, HV_VIS_POSE_HOLD + 3 SECONDS) + +// ---- Twitches ---- + +/// `count` twitches after `start_delay`, each smaller than the last, a second or so apart. +/mob/living/carbon/proc/hv_vis_twitch_steps(count, start_delay) + var/matrix/base = matrix(transform) + var/list/steps = list(list(pixel_w, pixel_z, base, start_delay, LINEAR_EASING)) + for(var/i in 1 to count) + var/strength = 1 - (i - 1) / (count + 1) + var/jerk = pick(-1, 1) * max(1, round(2 * strength)) + var/matrix/twitched = hv_vis_turned(base, pick(-1, 1) * 3 * strength) + steps += list(list(pixel_w + jerk, pixel_z, twitched, 0.5, LINEAR_EASING)) + steps += list(list(pixel_w, pixel_z, base, 1, QUAD_EASING|EASE_OUT)) + steps += list(list(pixel_w, pixel_z, base, rand(5, 12), LINEAR_EASING)) + return steps + +/mob/living/carbon/proc/hv_vis_twitches(count, start_delay = 4) + if(count <= 0 || !isturf(loc)) + return + var/list/steps = hv_vis_twitch_steps(count, start_delay) + var/total = 0 + for(var/list/step as anything in steps) + total += step[4] + hv_vis_play(HV_VIS_TAG_TWITCH, steps) + hv_vis_hold(list(HV_VIS_TAG_TWITCH), HV_VIS_END_MOVE|HV_VIS_END_STAND|HV_VIS_END_REVIVE, total + 1) diff --git a/voidcrew/modules/hyperviolence/visuals/execution_motion.dm b/voidcrew/modules/hyperviolence/visuals/execution_motion.dm new file mode 100644 index 00000000000..369c8dabfe8 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/execution_motion.dm @@ -0,0 +1,86 @@ +// Hyperviolence visuals: gun executions (overlays_exec/executions.dm). +// 17. The press: the shooter leans in over the victim, the victim trembles, and both go still for a +// short, deliberate moment before the shot. The shot: a big muzzle flash, a hit-stop on both, and +// a burst head throws its gibs in arcs (flying_gore.dm). The body holds for a beat, then slumps +// forward along the shot (death_falls.dm). +// The press is only drawn over the wind-up the execution already has; nothing here changes its timing. + +/obj/item/gun/hv_start_execution(mob/living/user, mob/living/target, params) + // The same checks that stop the wind-up before it starts. + var/pressing = !fire_cd && !firing_burst && !HAS_TRAIT(user, TRAIT_PACIFISM) && isturf(user.loc) && isturf(target.loc) + if(pressing) + user.hv_vis_press_in(target, HV_EXECUTION_WINDUP) + target.hv_vis_tremble(HV_EXECUTION_WINDUP - HV_VIS_PRESS_PAUSE, 1) + . = ..() + if(!pressing) + return + // Shot or called off, the shooter straightens up. + if(!QDELETED(user)) + user.hv_vis_release(HV_VIS_TAG_PRESS, 1.5) + if(!QDELETED(target) && target.stat != DEAD) + target.hv_vis_release(HV_VIS_TAG_TREMBLE, 1) + +/obj/item/gun/hv_fire_execution(mob/living/user, mob/living/target, params) + if(!QDELETED(target)) + target.hv_vis_execution_time = world.time + . = ..() + if(!. || QDELETED(user)) + return + hv_vis_muzzle_flash(user, target) + if(!QDELETED(target)) + target.hv_vis_big_hit(get_dir(user, target), user) + +/// Leans in toward `target` and stays there for `hold`, as if pressing something to their head. +/mob/living/proc/hv_vis_press_in(atom/target, hold) + var/toward = get_dir(src, target) + var/dx = HV_VIS_DX(toward) * HV_VIS_PRESS_LEAN + var/dy = HV_VIS_DY(toward) * HV_VIS_PRESS_LEAN + var/matrix/base = matrix(transform) + var/matrix/hunched = hv_vis_turned(base, HV_VIS_DX(toward) * 6, hv_vis_pivot()) + hv_vis_play(HV_VIS_TAG_PRESS, list( + list(pixel_w + dx, pixel_z + dy, hunched, 4, SINE_EASING), + list(pixel_w + dx, pixel_z + dy, hunched, hold + 1 SECONDS, LINEAR_EASING), + list(pixel_w, pixel_z, base, 3, SINE_EASING), + )) + +/// Shakes with fear for `duration`, small quick jerks side to side. `strength` in pixels. +/mob/living/proc/hv_vis_tremble(duration, strength = 1) + if(duration <= 0 || !isturf(loc)) + return + var/matrix/base = matrix(transform) + var/loops = max(1, round(duration / 2)) + var/base_w = pixel_w + var/base_z = pixel_z + animate(src, pixel_w = base_w + strength, pixel_z = base_z, transform = base, time = 0.5, loop = loops, tag = HV_VIS_TAG_TREMBLE) + animate(pixel_w = base_w - strength, pixel_z = base_z, transform = base, time = 1) + animate(pixel_w = base_w, pixel_z = base_z, transform = base, time = 0.5) + +/// A muzzle flash much bigger than a normal shot's, with a flash of light. +/proc/hv_vis_muzzle_flash(mob/living/shooter, atom/target) + var/turf/spot = get_turf(shooter) + if(!spot) + return + new /obj/effect/temp_visual/hv_muzzle_flash(spot, get_dir(shooter, target) || shooter.dir) + spot.flash_lighting_fx(3, 2, LIGHT_COLOR_ORANGE, 2) + +/obj/effect/temp_visual/hv_muzzle_flash + name = "muzzle flash" + icon = 'icons/effects/effects.dmi' + icon_state = "firing_effect" + plane = GAME_PLANE + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 4 + +/obj/effect/temp_visual/hv_muzzle_flash/Initialize(mapload, shot_dir) + . = ..() + setDir(shot_dir || SOUTH) + // At the end of the barrel, between the two of them. + pixel_w = HV_VIS_DX(dir) * 14 + pixel_z = HV_VIS_DY(dir) * 14 + var/matrix/big = matrix() + big.Scale(2.2) + transform = big + var/matrix/bigger = matrix() + bigger.Scale(2.8) + animate(src, transform = bigger, alpha = 0, time = duration, easing = QUAD_EASING|EASE_OUT) diff --git a/voidcrew/modules/hyperviolence/visuals/finisher_motion.dm b/voidcrew/modules/hyperviolence/visuals/finisher_motion.dm new file mode 100644 index 00000000000..23c13e29bb0 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/finisher_motion.dm @@ -0,0 +1,140 @@ +// Hyperviolence visuals: boss finishers (finishers/). +// 18. The wind-up: the boss rears up, bigger and leaning back, then comes down as the pin lands. +// Stages with a lift raise the victim off the floor (in the matriarch's and the empress's jaws). +// The matriarch shakes her victim like a dog with a rat. When the Black Knight cuts someone in +// half the halves slide apart. The killing blow gets a hit-stop on both, and the screens of +// players who saw it shake a little. +// Everything is let go when the finisher stops, however it stops. + +/datum/hv_finisher_stage + /// Pixels the victim is lifted off the floor during this stage. 0 sets them back down. + var/hv_vis_lift = 0 + +/datum/hv_finisher_stage/maul/hoarfrost + hv_vis_lift = 8 + +/datum/hv_finisher_stage/kill/rip_limb/hoarfrost + hv_vis_lift = 8 + +/datum/hv_finisher_stage/hold/alien_empress_jaws + hv_vis_lift = 6 + +/datum/hv_finisher_stage/kill/decapitate/alien_empress + hv_vis_lift = 6 + +/datum/hv_finisher_stage/enter() + . = ..() + if(!host?.running || QDELETED(host.victim)) + return + if(hv_vis_lift) + host.victim.hv_vis_lift(hv_vis_lift, duration + 1 SECONDS) + else + host.victim.hv_vis_release(HV_VIS_TAG_LIFT, 2) + +/datum/hv_finisher_stage/windup/enter() + . = ..() + if(host?.running && !QDELETED(host.user) && !QDELETED(host.victim)) + host.user.hv_vis_rear(host.victim, duration) + +/datum/hv_finisher_stage/maul/hoarfrost/effect() + . = ..() + if(!host?.running || QDELETED(host.victim) || QDELETED(host.user)) + return + host.victim.hv_vis_dog_shake(get_dir(host.user, host.victim)) + host.user.hv_vis_head_shake() + +/// The killing blow: a hit-stop on both, and the screens of those who saw it shake. +/datum/hv_finisher_stage/kill/effect() + . = ..() + if(!host || QDELETED(host.victim) || QDELETED(host.user)) + return + var/kill_dir = get_dir(host.user, host.victim) || host.user.dir + host.victim.hv_vis_big_hit(kill_dir, host.user) + hv_vis_shake_viewers(host.victim, HV_VIS_KILL_SHAKE_RANGE, HV_VIS_KILL_SHAKE_TIME, HV_VIS_KILL_SHAKE_STRENGTH) + +/// Cut in half: the legs slide off one way across the floor and the body the other. +/datum/hv_finisher_stage/kill/bisect/gore(spray_dir) + var/mob/living/carbon/victim = host.victim + // The head end of a body lying on its side points east or west. + var/head_dir = spray_dir + if(victim.lying_angle == LYING_ANGLE_EAST) + head_dir = EAST + else if(victim.lying_angle == LYING_ANGLE_WEST) + head_dir = WEST + for(var/zone in list(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + var/obj/item/bodypart/leg = victim.get_bodypart(zone) + if(leg?.can_dismember()) + leg.hv_vis_tumble = HV_VIS_TUMBLE_SLIDE + leg.hv_vis_fling_dir = REVERSE_DIR(head_dir) + . = ..() + if(!QDELETED(victim) && isturf(victim.loc)) + victim.hv_vis_slide_in_tile(head_dir, 40) + +/datum/hv_finisher/stop(reason) + if(running) + if(user && !QDELETED(user)) + user.hv_vis_release(HV_VIS_TAG_REAR, 2) + user.hv_vis_release(HV_VIS_TAG_SHAKE, 1) + if(victim && !QDELETED(victim)) + victim.hv_vis_release(HV_VIS_TAG_LIFT, 2) + return ..() + +/// Where this mob's feet are, from the middle of its icon, so it grows up from the floor. +/mob/living/proc/hv_vis_feet_y() + var/list/size = get_icon_dimensions(icon) + var/height = size?["height"] || ICON_SIZE_Y + return -height / 2 * current_size + +/// Rears up over `victim` for `duration`: grows a little from the feet up and leans back away from +/// them, then comes down at the end, as the pin lands. +/mob/living/proc/hv_vis_rear(atom/victim, duration) + var/away = HV_VIS_DX(get_dir(victim, src)) + var/feet = hv_vis_feet_y() + var/matrix/base = matrix(transform) + // Bigger from the feet up, then leaned back about the feet. + var/matrix/reared = matrix(base) + reared.Translate(0, -feet) + reared.Scale(1.12) + reared.Turn((away || pick(-1, 1)) * 7) + reared.Translate(0, feet) + var/rise_time = duration * 0.6 + hv_vis_play(HV_VIS_TAG_REAR, list( + list(pixel_w + away * 2, pixel_z + 2, reared, rise_time, SINE_EASING|EASE_OUT), + list(pixel_w + away * 2, pixel_z + 2, reared, max(duration - rise_time - 1.5, 0), LINEAR_EASING), + list(pixel_w, pixel_z, base, 1.5, QUAD_EASING|EASE_IN), + )) + +/// Lifts this mob `height` pixels off the floor for up to `hold`. hv_vis_release() sets it back down. +/mob/living/proc/hv_vis_lift(height, hold) + var/matrix/base = matrix(transform) + hv_vis_play(HV_VIS_TAG_LIFT, list( + list(pixel_w, pixel_z + height, base, 2, QUAD_EASING|EASE_OUT), + list(pixel_w, pixel_z + height, base, hold, LINEAR_EASING), + list(pixel_w, pixel_z, base, 2, QUAD_EASING|EASE_IN), + )) + +/// Shaken hard from side to side, as a dog shakes a rat, across the line from the beast holding it. +/mob/living/proc/hv_vis_dog_shake(beast_dir) + // Across the line between them: a beast to the east or west shakes it up and down, and the other way round. + var/across_w = HV_VIS_DX(beast_dir) ? 0 : 1 + var/across_z = across_w ? 0 : 1 + var/matrix/base = matrix(transform) + var/list/swings = list(18, -16, 14, -12, 9, -6, 3) + var/list/steps = list() + for(var/i in 1 to length(swings)) + var/swing = swings[i] + var/shift = round(swing / 3) + steps += list(list(pixel_w + across_w * shift, pixel_z + across_z * shift, hv_vis_turned(base, swing), 1, SINE_EASING)) + steps += list(list(pixel_w, pixel_z, base, 1.5, SINE_EASING)) + hv_vis_play(HV_VIS_TAG_SHAKE, steps) + +/// The beast's side of the shake: its head goes side to side with the victim. +/mob/living/proc/hv_vis_head_shake() + var/matrix/base = matrix(transform) + var/feet = hv_vis_feet_y() + var/list/swings = list(6, -6, 5, -4, 3, -2) + var/list/steps = list() + for(var/swing in swings) + steps += list(list(pixel_w, pixel_z, hv_vis_turned(base, swing, feet), 1, SINE_EASING)) + steps += list(list(pixel_w, pixel_z, base, 1.5, SINE_EASING)) + hv_vis_play(HV_VIS_TAG_SHAKE, steps) diff --git a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm new file mode 100644 index 00000000000..8f630513652 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm @@ -0,0 +1,365 @@ +// Hyperviolence visuals: flying gore. +// 8. A severed limb or head flies the way the blow went (tg throws it in a random direction), slowly +// enough to see: up in an arc, spinning, leaving a blood streak on each tile it crosses; it bounces +// once where it lands, and a head rolls on. +// 9. A burst head's brain arcs out spinning and lands with a wet bounce. Its gibs arc as they fly. +// 10. A wound bleeding hard enough spurts with the heartbeat: a jet from that side of the body, two or +// three beats between Life ticks, quicker and weaker as the blood runs out. Nothing while the heart +// is stopped. +// In zero gravity nothing arcs or bounces: a part just turns slowly as it drifts, and leaves no streak. + +/// Set while a burst head throws its gibs, so they arc. +GLOBAL_VAR_INIT(hv_vis_arcing_gibs, FALSE) + +/obj/item + /// Hyperviolence visuals: set just before a body part or organ is thrown off a body, so the throw + /// tumbles it (HV_VIS_TUMBLE_*). Used up by the throw. + var/hv_vis_tumble + /// The way to fling it, in place of the throw's own target. Used up by the throw. + var/hv_vis_fling_dir + /// The blood it streaks along its way. Used up by the throw. + var/list/hv_vis_tumble_dna + +/obj/item/throw_at(atom/target, range, speed, mob/thrower, spin = 1, diagonals_first = 0, datum/callback/callback, force, gentle = FALSE, quickstart = TRUE, throw_type_path = /datum/thrownthing) + if(!hv_vis_tumble) + return ..() + var/style = hv_vis_tumble + var/list/blood_dna = hv_vis_tumble_dna + var/fling_dir = hv_vis_fling_dir + hv_vis_tumble = null + hv_vis_tumble_dna = null + hv_vis_fling_dir = null + if(fling_dir) + target = hv_vis_fling_target(target, fling_dir, style == HV_VIS_TUMBLE_SLIDE ? 1 : 0) + speed = style == HV_VIS_TUMBLE_SLIDE ? 0.5 : min(speed, HV_VIS_TUMBLE_SPEED) + // Our own spin replaces tg's single quick turn. + . = ..(target, range, speed, thrower, FALSE, diagonals_first, callback, force, gentle, quickstart, throw_type_path) + if(throwing && !QDELETED(src)) + AddComponent(/datum/component/hv_tumble, style, blood_dna, fling_dir || get_dir(src, target)) + +/// Where to fling this along `fling_dir`, as far as `original` was (or `distance` tiles): the farthest +/// open tile on the way. Stops before space, lava or a chasm, and at the first wall, as tg's own fling +/// does. Falls back to `original` if there is nowhere to go. +/obj/item/proc/hv_vis_fling_target(atom/original, fling_dir, distance = 0) + var/turf/start = get_turf(src) + if(!start) + return original + if(!distance) + distance = max(get_dist(start, original), 2) + var/turf/landing + var/turf/ahead = start + for(var/i in 1 to distance) + ahead = get_step(ahead, fling_dir) + if(!ahead || isgroundlessturf(ahead) || islava(ahead) || ischasm(ahead)) + break + landing = ahead + if(ahead.density) + break + return landing || original + +/** + * A part flying off a body: the arc, the spin, the blood along the way, the landing. On the thrown + * item for the length of the throw only; gone once it lands, or after a few seconds whatever happens. + */ +/datum/component/hv_tumble + dupe_mode = COMPONENT_DUPE_HIGHLANDER + /// HV_VIS_TUMBLE_* + var/style + /// Blood it streaks along its way, or null. + var/list/blood_dna + /// The way it was flung. + var/fling_dir + /// Streaks left so far. + var/streaks = 0 + /// Whether it was thrown where gravity pulls it down. + var/grounded = TRUE + +/datum/component/hv_tumble/Initialize(style, list/blood_dna, fling_dir) + if(!isitem(parent)) + return COMPONENT_INCOMPATIBLE + var/obj/item/part = parent + src.style = style + src.blood_dna = blood_dna + src.fling_dir = fling_dir + grounded = part.has_gravity() > 0 + addtimer(CALLBACK(src, PROC_REF(give_up)), 5 SECONDS, TIMER_DELETE_ME) + start_flight() + +/datum/component/hv_tumble/RegisterWithParent() + RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(parent, COMSIG_MOVABLE_THROW_LANDED, PROC_REF(on_landed)) + +/datum/component/hv_tumble/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_MOVABLE_THROW_LANDED)) + +/datum/component/hv_tumble/Destroy(force) + blood_dna = null + return ..() + +/// How long the flight should take, from the throw under way. +/datum/component/hv_tumble/proc/flight_time() + var/obj/item/part = parent + var/datum/thrownthing/flight = part.throwing + if(!flight) + return 2 + var/distance = max(get_dist(part, flight.target_turf), 1) + return max(distance / max(flight.speed, 0.5), 1.5) + +/// How high it rises on the way. +/datum/component/hv_tumble/proc/arc_height() + switch(style) + if(HV_VIS_TUMBLE_HEAD) + return 12 + if(HV_VIS_TUMBLE_BRAIN) + return 14 + if(HV_VIS_TUMBLE_LIMB) + return 10 + if(HV_VIS_TUMBLE_ORGAN) + return 7 + return 0 + +/datum/component/hv_tumble/proc/start_flight() + var/obj/item/part = parent + var/matrix/base = matrix(part.transform) + var/time = flight_time() + if(style == HV_VIS_TUMBLE_SLIDE) + // The legs of a body cut in half: a slide across the floor with a slight turn. + var/turn = pick(-1, 1) * 20 + part.hv_vis_play(HV_VIS_TAG_SPIN, list( + list(part.pixel_w, part.pixel_z, hv_vis_turned(base, turn), time, CUBIC_EASING|EASE_OUT), + list(part.pixel_w, part.pixel_z, base, 4, SINE_EASING), + )) + return + if(!grounded) + part.hv_vis_play(HV_VIS_TAG_SPIN, hv_vis_spin_steps(part, 2, 30, pick(-1, 1))) + return + var/height = arc_height() + part.hv_vis_play(HV_VIS_TAG_ARC, list( + list(part.pixel_w, part.pixel_z + height, base, time / 2, QUAD_EASING|EASE_OUT), + list(part.pixel_w, part.pixel_z, base, time / 2, QUAD_EASING|EASE_IN), + )) + var/turns = style == HV_VIS_TUMBLE_ORGAN ? 1 : 2 + part.hv_vis_play(HV_VIS_TAG_SPIN, hv_vis_spin_steps(part, turns, time + 1, HV_VIS_DX(fling_dir) || pick(-1, 1))) + +/// Blood on each tile it crosses, until it lands. +/datum/component/hv_tumble/proc/on_moved(obj/item/source, atom/old_loc, movement_dir, forced, list/old_locs) + SIGNAL_HANDLER + if(!grounded || !length(blood_dna) || streaks >= HV_VIS_TUMBLE_MAX_STREAKS || !source.throwing) + return + var/turf/crossed = old_loc + if(!isturf(crossed) || isgroundlessturf(crossed) || crossed.has_gravity() <= 0) + return + streaks++ + hv_blood_lay_spray(crossed, /obj/effect/decal/cleanable/blood/hv_spray/streak, movement_dir || fling_dir, blood_dna) + +/datum/component/hv_tumble/proc/on_landed(obj/item/source, datum/thrownthing/flight) + SIGNAL_HANDLER + if(grounded && isturf(source.loc)) + switch(style) + if(HV_VIS_TUMBLE_HEAD) + source.hv_vis_play(HV_VIS_TAG_ARC, hv_vis_bounce_steps(source, 4)) + hv_vis_roll(source, fling_dir) + if(HV_VIS_TUMBLE_BRAIN) + source.hv_vis_play(HV_VIS_TAG_ARC, hv_vis_wet_bounce_steps(source)) + if(HV_VIS_TUMBLE_LIMB) + source.hv_vis_play(HV_VIS_TAG_ARC, hv_vis_bounce_steps(source, 4)) + if(HV_VIS_TUMBLE_ORGAN) + source.hv_vis_play(HV_VIS_TAG_ARC, hv_vis_bounce_steps(source, 2)) + qdel(src) + +/datum/component/hv_tumble/proc/give_up() + qdel(src) + +/// `turns` whole turns over `time`, clockwise for `spin_sign` 1, ending where the part started. +/proc/hv_vis_spin_steps(atom/movable/part, turns, time, spin_sign = 1) + var/matrix/base = matrix(part.transform) + var/list/steps = list() + var/segments = max(3, turns * 3) + for(var/i in 1 to segments) + var/matrix/turned = i == segments ? matrix(base) : hv_vis_turned(base, spin_sign * 120 * i) + steps += list(list(part.pixel_w, part.pixel_z, turned, time / segments, LINEAR_EASING)) + return steps + +/// One bounce off the floor, `height` pixels. +/proc/hv_vis_bounce_steps(atom/movable/part, height) + var/matrix/base = matrix(part.transform) + return list( + list(part.pixel_w, part.pixel_z + height, base, 1, QUAD_EASING|EASE_OUT), + list(part.pixel_w, part.pixel_z, base, 1.5, BOUNCE_EASING), + ) + +/// A brain hitting the floor: it squashes flat, springs up, squashes again and wobbles still. +/proc/hv_vis_wet_bounce_steps(atom/movable/part) + var/matrix/base = matrix(part.transform) + // Squashed from the bottom edge, so it spreads on the floor rather than sinking into it. + var/matrix/flat = hv_vis_local(base, 1.3, 0.7, 0, 0, -16 * 0.3) + var/matrix/tall = hv_vis_local(base, 0.85, 1.15, 0, 0, 16 * 0.15) + var/matrix/settle = hv_vis_local(base, 1.12, 0.88, 0, 0, -16 * 0.12) + return list( + list(part.pixel_w, part.pixel_z, flat, 0.5, QUAD_EASING|EASE_OUT), + list(part.pixel_w, part.pixel_z + 4, tall, 1.5, QUAD_EASING|EASE_OUT), + list(part.pixel_w, part.pixel_z, settle, 1.5, QUAD_EASING|EASE_IN), + list(part.pixel_w, part.pixel_z, base, 2, ELASTIC_EASING), + ) + +/// A head rolling on after it lands: one turn, a few pixels further along. It keeps its new spot on +/// the tile, as any dropped item does. +/proc/hv_vis_roll(obj/item/part, roll_dir) + var/dx = HV_VIS_DX(roll_dir) + var/dy = HV_VIS_DY(roll_dir) + var/spin = dx || pick(-1, 1) + var/end_x = clamp(part.pixel_x + dx * 6, -12, 12) + var/end_y = clamp(part.pixel_y + dy * 6, -12, 12) + var/matrix/base = matrix(part.transform) + var/start_x = part.pixel_x + var/start_y = part.pixel_y + animate(part, pixel_x = start_x, pixel_y = start_y, transform = base, time = 2.5, tag = HV_VIS_TAG_SPIN) + animate(pixel_x = start_x + (end_x - start_x) / 3, pixel_y = start_y + (end_y - start_y) / 3, transform = hv_vis_turned(base, spin * 120), time = 1.5, easing = LINEAR_EASING) + animate(pixel_x = start_x + (end_x - start_x) * 2 / 3, pixel_y = start_y + (end_y - start_y) * 2 / 3, transform = hv_vis_turned(base, spin * 240), time = 2, easing = LINEAR_EASING) + animate(pixel_x = end_x, pixel_y = end_y, transform = base, time = 3, easing = QUAD_EASING|EASE_OUT) + +// ---- Limbs and heads coming off ---- + +/// A limb or head taken off whole flies the way the blow went, tumbling. Burned-off parts and the +/// chest (which spills, braingut/gut_spill.dm) are left to tg. +/obj/item/bodypart/dismember(dam_type = BRUTE, silent = TRUE, wounding_type) + var/mob/living/carbon/limb_owner = owner + var/marked = FALSE + if(limb_owner && dam_type != BURN && body_zone != BODY_ZONE_CHEST) + marked = TRUE + if(!hv_vis_tumble) + hv_vis_tumble = body_zone == BODY_ZONE_HEAD ? HV_VIS_TUMBLE_HEAD : HV_VIS_TUMBLE_LIMB + if(!hv_vis_fling_dir) + hv_vis_fling_dir = limb_owner.hv_blood_recent_hit_dir() + if(!hv_vis_tumble_dna && can_bleed()) + hv_vis_tumble_dna = limb_owner.get_blood_dna_list() + . = ..() + // Not thrown after all (it burned away, or it was never let go): forget the fling. + if(marked && !QDELETED(src) && !throwing) + hv_vis_tumble = null + hv_vis_fling_dir = null + hv_vis_tumble_dna = null + +// ---- Burst heads ---- + +/// The brain and the other loose organs of a burst head tumble out, the gibs arc, and the blow gets a hit-stop. +/mob/living/carbon/hv_burst_head_apart(burst_dir, announce = TRUE) + var/obj/item/bodypart/head/head = get_bodypart(BODY_ZONE_HEAD) + var/list/obj/item/organ/marked = list() + if(head?.can_dismember() && !HAS_TRAIT(src, TRAIT_GODMODE)) + var/list/blood_dna = can_bleed() ? get_blood_dna_list() : null + for(var/obj/item/organ/organ in head) + organ.hv_vis_tumble = istype(organ, /obj/item/organ/brain) ? HV_VIS_TUMBLE_BRAIN : HV_VIS_TUMBLE_ORGAN + organ.hv_vis_tumble_dna = blood_dna + marked += organ + // Before the burst, so the death it causes sees the blow was a big one. + hv_vis_big_hit(burst_dir) + GLOB.hv_vis_arcing_gibs = TRUE + . = ..() + GLOB.hv_vis_arcing_gibs = FALSE + for(var/obj/item/organ/organ as anything in marked) + if(!QDELETED(organ) && !organ.throwing) + organ.hv_vis_tumble = null + organ.hv_vis_tumble_dna = null + +/// Gibs thrown by a burst head rise and fall as they fly, turning over. +/obj/effect/decal/cleanable/blood/gibs/streak(list/directions, mapload = FALSE) + var/arcing = GLOB.hv_vis_arcing_gibs && !mapload + . = ..() + if(arcing && !QDELETED(src) && isturf(loc) && has_gravity() > 0) + var/matrix/base = matrix(transform) + var/height = rand(8, 14) + var/turn = pick(-1, 1) * rand(90, 200) + hv_vis_play(HV_VIS_TAG_ARC, list( + list(pixel_w, pixel_z + height, hv_vis_turned(base, turn / 2), 2, QUAD_EASING|EASE_OUT), + list(pixel_w, pixel_z, hv_vis_turned(base, turn), 2.5, QUAD_EASING|EASE_IN), + list(pixel_w, pixel_z + 2, hv_vis_turned(base, turn), 0.7, QUAD_EASING|EASE_OUT), + list(pixel_w, pixel_z, base, 1.5, BOUNCE_EASING), + )) + +// ---- Arterial spurts ---- + +/mob/living/carbon + /// World time before which this body does not spurt again. + var/hv_vis_next_spurt = 0 + +/mob/living/carbon/human/bleed(amt) + . = ..() + hv_vis_arterial_spurt() + +/// The wound bleeding hard enough to spurt, the worst if several, or null. +/mob/living/carbon/proc/hv_vis_arterial_wound() + var/datum/wound/worst + for(var/datum/wound/wound as anything in all_wounds) + if(wound.blood_flow < HV_VIS_ARTERIAL_FLOW || !wound.limb) + continue + if(!worst || wound.blood_flow > worst.blood_flow) + worst = wound + return worst + +/// A spurt from the worst wound, beating with the heart, at most once per Life tick. +/mob/living/carbon/proc/hv_vis_arterial_spurt() + if(world.time < hv_vis_next_spurt || stat == DEAD || !isturf(loc)) + return + var/datum/wound/artery = hv_vis_arterial_wound() + if(!artery || can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + var/obj/item/organ/heart/heart = get_organ_slot(ORGAN_SLOT_HEART) + if(!heart?.is_beating()) + return + hv_vis_next_spurt = world.time + HV_VIS_SPURT_COOLDOWN + var/beat = HV_VIS_BEAT_RESTING + if(blood_volume < BLOOD_VOLUME_BAD) + beat = HV_VIS_BEAT_RACING + else if(blood_volume < BLOOD_VOLUME_OKAY) + beat = HV_VIS_BEAT_FAST + var/strength = clamp(blood_volume / BLOOD_VOLUME_NORMAL, 0.25, 1) + var/pulses = max(1, round(HV_VIS_SPURT_COOLDOWN / beat)) + new /obj/effect/temp_visual/hv_arterial_spurt(loc, hv_vis_spurt_dir(artery.limb.body_zone), get_bloodtype()?.get_color(), strength, beat, pulses) + +/// Which way a wound on `zone` spurts: out to the side for limbs, forward for the head and chest. +/mob/living/carbon/proc/hv_vis_spurt_dir(zone) + switch(zone) + if(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM) + var/side = hv_vis_zone_side(zone) + return side > 0 ? EAST : (side < 0 ? WEST : dir) + if(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + var/side = hv_vis_zone_side(zone) + return side > 0 ? SOUTHEAST : (side < 0 ? SOUTHWEST : turn(dir, 180)) + return dir + +/// A jet of blood from a body, pulsing `pulses` times one `beat` apart. Thinner and shorter for a +/// body low on blood. +/obj/effect/temp_visual/hv_arterial_spurt + name = "blood" + icon = HV_BLOOD_SPRAY_ICON + icon_state = "squirt_greyscale" + plane = GAME_PLANE + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 2 SECONDS + +/obj/effect/temp_visual/hv_arterial_spurt/Initialize(mapload, spurt_dir, blood_color, strength = 1, beat = HV_VIS_BEAT_RESTING, pulses = 2) + duration = beat * pulses + 1 + . = ..() + setDir(spurt_dir || SOUTH) + if(blood_color) + color = blood_color + var/dx = HV_VIS_DX(dir) + var/dy = HV_VIS_DY(dir) + // The art is a line through the whole tile; shrunk and moved out so it starts at the body. + var/reach = 0.25 + 0.35 * strength + var/matrix/start = matrix() + start.Scale(0.1) + start.Translate(dx * 2, dy * 2) + var/matrix/out = matrix() + out.Scale(reach) + out.Translate(dx * 16 * reach, dy * 16 * reach) + var/matrix/spent = matrix() + spent.Scale(reach * 1.1) + spent.Translate(dx * 18 * reach, dy * 18 * reach) + alpha = 0 + animate(src, alpha = 235, transform = start, time = 0, loop = pulses) + animate(alpha = 235, transform = out, time = beat * 0.35, easing = CUBIC_EASING|EASE_OUT) + animate(alpha = 0, transform = spent, time = beat * 0.4, easing = LINEAR_EASING) + animate(alpha = 0, transform = spent, time = beat * 0.25, easing = LINEAR_EASING) diff --git a/voidcrew/modules/hyperviolence/visuals/gut_motion.dm b/voidcrew/modules/hyperviolence/visuals/gut_motion.dm new file mode 100644 index 00000000000..257042e25e0 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/gut_motion.dm @@ -0,0 +1,67 @@ +// Hyperviolence visuals: guts. +// 15. Spilled intestines come out of the body: they start small at the body, spill along the floor to +// where they lie and flop down, and the strand to the body (braingut/gut_tether.dm) only appears +// once they are out. +// 16. Guts dragged along the floor, by someone pulling them or by the strand, slither as they go. + +/// How long the guts take to come out of the body. +#define HV_VIS_UNSPOOL_TIME (0.7 SECONDS) + +/obj/item/hv_intestines + /// World time the guts are all the way out of the body; the strand waits for it. + var/hv_vis_unspooled_at = 0 + +/obj/item/bodypart/chest/hv_drop_intestines(attack_direction) + var/mob/living/carbon/victim = owner + . = ..() + var/obj/item/hv_intestines/guts = . + if(istype(guts) && victim && isturf(guts.loc)) + guts.hv_vis_unspool_from(victim) + +/// Comes out of `body`: from small at the body to full size where the guts lie, and a flop. +/obj/item/hv_intestines/proc/hv_vis_unspool_from(atom/body) + var/turf/from = get_turf(body) + var/turf/here = get_turf(src) + if(!from || !here || from.z != here.z) + return + hv_vis_unspooled_at = world.time + HV_VIS_UNSPOOL_TIME + var/off_w = (from.x - here.x) * ICON_SIZE_X - pixel_x + var/off_z = (from.y - here.y) * ICON_SIZE_Y - pixel_y + var/matrix/base = matrix(transform) + var/twist = pick(-1, 1) + hv_vis_play(HV_VIS_TAG_ARC, list( + list(pixel_w + off_w, pixel_z + off_z, hv_vis_local(base, 0.25, 0.25, twist * -60), 0, LINEAR_EASING), + list(pixel_w + round(off_w * 0.45), pixel_z + round(off_z * 0.45) - 2, hv_vis_local(base, 0.65, 0.5, twist * 35), HV_VIS_UNSPOOL_TIME * 0.5, SINE_EASING), + list(pixel_w, pixel_z, hv_vis_local(base, 1.12, 0.84, twist * -8), HV_VIS_UNSPOOL_TIME * 0.3, QUAD_EASING|EASE_OUT), + list(pixel_w, pixel_z, base, HV_VIS_UNSPOOL_TIME * 0.2 + 2, ELASTIC_EASING), + )) + +/// The strand to the body waits until the guts are out. +/datum/hv_gut_tether/refresh() + if(!strand && guts && world.time < guts.hv_vis_unspooled_at) + addtimer(CALLBACK(src, PROC_REF(refresh)), guts.hv_vis_unspooled_at - world.time, TIMER_UNIQUE|TIMER_DELETE_ME) + return + return ..() + +/obj/item/hv_intestines/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) + . = ..() + if(!isturf(loc) || !isturf(old_loc) || throwing) + return + hv_vis_slither(movement_dir || get_dir(old_loc, loc)) + +/// One slither along the floor: a wiggle from side to side across the way it is going. +/obj/item/hv_intestines/proc/hv_vis_slither(going) + // Across the way it moves: east-west movement wiggles north-south, and the other way round. + var/side_w = HV_VIS_DY(going) ? 2 : 0 + var/side_z = HV_VIS_DX(going) ? 2 : 0 + if(!side_w && !side_z) + side_w = 2 + var/matrix/base = matrix(transform) + hv_vis_play(HV_VIS_TAG_SHAKE, list( + list(pixel_w + side_w, pixel_z + side_z, hv_vis_local(base, 1.1, 0.9, 12), 1, SINE_EASING), + list(pixel_w - side_w, pixel_z - side_z, hv_vis_local(base, 0.92, 1.06, -12), 1.2, SINE_EASING), + list(pixel_w + round(side_w / 2), pixel_z + round(side_z / 2), hv_vis_local(base, 1.04, 0.97, 6), 1, SINE_EASING), + list(pixel_w, pixel_z, base, 1, SINE_EASING), + )) + +#undef HV_VIS_UNSPOOL_TIME diff --git a/voidcrew/modules/hyperviolence/visuals/hit_reactions.dm b/voidcrew/modules/hyperviolence/visuals/hit_reactions.dm new file mode 100644 index 00000000000..b5e585b3c7e --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/hit_reactions.dm @@ -0,0 +1,191 @@ +// Hyperviolence visuals: getting hit. +// 1. A flinch along the blow, scaled by its damage: a pistol round twitches a body, a shotgun's +// pellets together stagger it back past where it stood. +// 2. A head hit whips the body back and tilts it hard. +// 3. A sever, a burst head or a crit holds the flinch at its peak for a few frames (hit-stop), with a +// pale flash, and whoever landed it holds their pose too (_motion_core.dm). +// 4. Losing a limb lurches the body toward the missing side; with a leg gone, every step dips and +// leans that way. +// The idea of a flinch on apply_damage() is Shiptest's shake_animation() (code/__HELPERS/matrices.dm, +// AGPL-3.0); this is a rewrite with the direction of the blow and named sequences. + +/** + * The steps of a flinch: out along the blow and tilted away from it, held for `hold` if this is a + * hit-stop, then back. A big enough blow (a stagger) reels back past the start before it settles. + * A lying body only jolts. Returns the steps for hv_vis_play(), ending where the body is now. + */ +/mob/living/proc/hv_vis_flinch_steps(damage, attack_dir, headshot = FALSE, hold = 0) + var/dx = HV_VIS_DX(attack_dir) + var/dy = HV_VIS_DY(attack_dir) + var/push = clamp(round(damage / HV_VIS_FLINCH_DAMAGE_PER_PIXEL), 1, HV_VIS_FLINCH_MAX_PUSH) + var/tilt = clamp(damage / HV_VIS_FLINCH_DAMAGE_PER_DEGREE, 2, HV_VIS_FLINCH_MAX_TILT) + var/stagger = damage >= HV_VIS_STAGGER_DAMAGE + if(headshot) + push = min(push + 2, HV_VIS_FLINCH_MAX_PUSH) + tilt = clamp(tilt * 1.8, HV_VIS_HEADSHOT_MIN_TILT, HV_VIS_HEADSHOT_MAX_TILT) + if(body_position == LYING_DOWN || stat == DEAD) + push = max(1, round(push / 2)) + tilt = 0 + stagger = FALSE + if(hv_vis_is_huge()) + push = max(1, round(push / 2)) + tilt /= 2 + // A diagonal push covers as much ground as a straight one. + if(dx && dy) + push = max(1, round(push * 0.7)) + // Tilted away from the blow; a blow from straight above or below tips it a little either way. + var/tilt_sign = dx + if(!dx) + tilt_sign = pick(-1, 1) + tilt *= 0.5 + var/base_w = pixel_w + var/base_z = pixel_z + var/matrix/base = matrix(transform) + var/pivot = hv_vis_pivot() + var/matrix/peak = hv_vis_turned(base, tilt * tilt_sign, pivot) + var/list/steps = list() + var/out_time = stagger ? 1 : (headshot ? 0.4 : 0.6) + steps += list(list(base_w + dx * push, base_z + dy * push, peak, out_time, QUAD_EASING|EASE_OUT)) + if(hold > 0) + steps += list(list(base_w + dx * push, base_z + dy * push, peak, hold, LINEAR_EASING)) + if(stagger) + var/matrix/rebound = hv_vis_turned(base, -tilt * tilt_sign * 0.35, pivot) + steps += list(list(base_w - round(dx * push * 0.35), base_z - round(dy * push * 0.35), rebound, 2.5, SINE_EASING)) + steps += list(list(base_w, base_z, base, 2.5, SINE_EASING)) + else + steps += list(list(base_w, base_z, base, headshot ? 4 : 3, ELASTIC_EASING)) + return steps + +/// Flinches from a blow of `damage` travelling `attack_dir`. See hv_vis_flinch_steps(). +/mob/living/proc/hv_vis_flinch(damage, attack_dir, headshot = FALSE, hold = 0) + if(damage < HV_VIS_FLINCH_MIN || !attack_dir || !isturf(loc)) + return + hv_vis_play(HV_VIS_TAG_FLINCH, hv_vis_flinch_steps(damage, attack_dir, headshot, hold)) + +// ---- Crits ask for a hit-stop ---- + +/mob/living/carbon/hv_try_aimed_crit(obj/item/bodypart/limb, aimed_zone, wounding_type, damage, wound_bonus, exposed_wound_bonus, attack_direction, atom/source, mob/living/attacker, forced_roll) + . = ..() + if(.) + hv_vis_big_hit(attack_direction, attacker) + +/mob/living/basic/hv_try_basic_crit(hit_zone, aimed_zone, wounding_type, damage, wound_bonus, attack_direction, atom/source, mob/living/attacker, forced_roll) + . = ..() + if(.) + hv_vis_big_hit(attack_direction, attacker) + +// ---- Lost limbs ---- + +/mob/living/proc/hv_vis_on_limb_lost(obj/item/bodypart/lost_limb, special, dismembered) + return + +/mob/living/carbon/hv_vis_on_limb_lost(obj/item/bodypart/lost_limb, special, dismembered) + if(special || QDELETED(src)) + return + var/zone = lost_limb.body_zone + if(dismembered) + hv_vis_big_hit(hv_blood_recent_hit_dir()) + if(stat == DEAD || zone == BODY_ZONE_CHEST) + return + if(zone != BODY_ZONE_HEAD) + hv_vis_lurch(zone) + if((zone == BODY_ZONE_L_LEG || zone == BODY_ZONE_R_LEG) && default_num_legs >= 2) + AddComponent(/datum/component/hv_limp) + +/// Which way, on screen, the side of this body that `zone` is on lies: -1 left, 1 right, 0 neither +/// (seen side-on). Facing south, a body's left arm is drawn on the right of the screen. +/mob/living/carbon/proc/hv_vis_zone_side(zone) + var/left = (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_L_LEG) + switch(dir) + if(SOUTH) + return left ? 1 : -1 + if(NORTH) + return left ? -1 : 1 + return 0 + +/// The steps of a lurch toward the side a limb was lost from. A body seen side-on pitches forward. +/mob/living/carbon/proc/hv_vis_lurch_steps(zone) + var/side = hv_vis_zone_side(zone) + var/dx = side + var/dy = 0 + if(!side) + dx = HV_VIS_DX(dir) + dy = HV_VIS_DY(dir) + var/tilt = HV_VIS_LURCH_TILT * (side || dx || pick(-1, 1)) + var/standing = body_position == STANDING_UP + if(!standing) + tilt = 0 + var/matrix/base = matrix(transform) + var/matrix/lurched = hv_vis_turned(base, tilt, hv_vis_pivot()) + var/matrix/caught = hv_vis_turned(base, -tilt * 0.3, hv_vis_pivot()) + return list( + list(pixel_w + dx * HV_VIS_LURCH_PUSH, pixel_z + dy * HV_VIS_LURCH_PUSH, lurched, 1, QUAD_EASING|EASE_OUT), + list(pixel_w + dx * HV_VIS_LURCH_PUSH, pixel_z + dy * HV_VIS_LURCH_PUSH, lurched, 1.5, LINEAR_EASING), + list(pixel_w - dx, pixel_z - dy, caught, 2.5, SINE_EASING), + list(pixel_w, pixel_z, base, 3, SINE_EASING), + ) + +/mob/living/carbon/proc/hv_vis_lurch(zone) + if(!isturf(loc)) + return + hv_vis_play(HV_VIS_TAG_LURCH, hv_vis_lurch_steps(zone)) + +/** + * A body standing on one leg: every step dips and leans toward the missing one. Listens to the + * body's own moves only while a leg is gone, and goes when both legs are back or the body dies. + */ +/datum/component/hv_limp + dupe_mode = COMPONENT_DUPE_UNIQUE + +/datum/component/hv_limp/Initialize() + if(!iscarbon(parent)) + return COMPONENT_INCOMPATIBLE + +/datum/component/hv_limp/RegisterWithParent() + RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(parent, COMSIG_CARBON_POST_ATTACH_LIMB, PROC_REF(on_limb_attached)) + RegisterSignal(parent, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + +/datum/component/hv_limp/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_CARBON_POST_ATTACH_LIMB, COMSIG_LIVING_DEATH)) + +/// The leg that is missing, or null if both are there. +/datum/component/hv_limp/proc/missing_leg() + var/mob/living/carbon/body = parent + var/has_left = !!body.get_bodypart(BODY_ZONE_L_LEG) + var/has_right = !!body.get_bodypart(BODY_ZONE_R_LEG) + if(has_left == has_right) + return null // both there, or both gone and crawling + return has_left ? BODY_ZONE_R_LEG : BODY_ZONE_L_LEG + +/datum/component/hv_limp/proc/on_moved(mob/living/carbon/source, atom/old_loc, movement_dir, forced, list/old_locs) + SIGNAL_HANDLER + if(forced || !isturf(source.loc) || source.body_position != STANDING_UP || source.buckled || source.stat != CONSCIOUS) + return + var/zone = missing_leg() + if(!zone) + if(source.get_bodypart(BODY_ZONE_L_LEG) && source.get_bodypart(BODY_ZONE_R_LEG)) + qdel(src) + return + source.hv_vis_play(HV_VIS_TAG_LIMP, source.hv_vis_limp_steps(zone)) + +/datum/component/hv_limp/proc/on_limb_attached(datum/source) + SIGNAL_HANDLER + if(!missing_leg()) + var/mob/living/carbon/body = parent + if(body.get_bodypart(BODY_ZONE_L_LEG) && body.get_bodypart(BODY_ZONE_R_LEG)) + qdel(src) + +/datum/component/hv_limp/proc/on_death(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// One limping step: down and over toward the missing leg, and back up. +/mob/living/carbon/proc/hv_vis_limp_steps(zone) + var/side = hv_vis_zone_side(zone) + var/matrix/base = matrix(transform) + var/matrix/leaned = hv_vis_turned(base, HV_VIS_LIMP_TILT * (side || pick(-1, 1)), HV_VIS_FEET_Y * current_size) + return list( + list(pixel_w + side, pixel_z - HV_VIS_LIMP_DIP, leaned, 1.5, SINE_EASING), + list(pixel_w, pixel_z, base, 1.5, SINE_EASING), + ) diff --git a/voidcrew/modules/hyperviolence/visuals/npc_motion.dm b/voidcrew/modules/hyperviolence/visuals/npc_motion.dm new file mode 100644 index 00000000000..cb02624ec17 --- /dev/null +++ b/voidcrew/modules/hyperviolence/visuals/npc_motion.dm @@ -0,0 +1,114 @@ +// Hyperviolence visuals: people who are losing. These read the same on players and human NPCs: an +// NPC that surrenders, crawls, pleads or injects itself does so through the same tg actions. +// 19. Surrender (tg's *surrender emote): down on the floor with hands on head, trembling until they +// get up. +// 20. In soft crit, a body crawling along hauls itself forward a heave at a time, with the blood +// smear tg already leaves behind a badly hurt body that moves while down. Anything it says +// comes with a pleading lean up toward whoever last hurt it. Injecting yourself with a medipen +// or hypospray while badly hurt is shaky. + +/// How long the surrender tremble runs: tg's surrender paralyses for 20 seconds. +#define HV_VIS_SURRENDER_TIME (20 SECONDS) + +/mob/living/proc/hv_vis_surrender() + if(stat == DEAD) + return + hv_vis_tremble(HV_VIS_SURRENDER_TIME, 1) + hv_vis_hold(list(HV_VIS_TAG_TREMBLE), HV_VIS_END_STAND|HV_VIS_END_DEATH|HV_VIS_END_MOVE, HV_VIS_SURRENDER_TIME + 1 SECONDS) + +/mob/living/proc/hv_vis_on_stat_change(new_stat, old_stat) + return + +/mob/living/carbon/hv_vis_on_stat_change(new_stat, old_stat) + if(new_stat == SOFT_CRIT && old_stat != SOFT_CRIT) + AddComponent(/datum/component/hv_crawl) + +/** + * A body in soft crit. Each move it makes on its own while down is a heave forward; whatever it says + * comes with a lean up toward whoever last hurt it. Goes when the body leaves soft crit. + */ +/datum/component/hv_crawl + dupe_mode = COMPONENT_DUPE_UNIQUE + +/datum/component/hv_crawl/Initialize() + if(!iscarbon(parent)) + return COMPONENT_INCOMPATIBLE + +/datum/component/hv_crawl/RegisterWithParent() + RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(parent, COMSIG_MOB_SAY, PROC_REF(on_say)) + RegisterSignal(parent, COMSIG_MOB_STATCHANGE, PROC_REF(on_stat_change)) + +/datum/component/hv_crawl/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_MOB_SAY, COMSIG_MOB_STATCHANGE)) + +/datum/component/hv_crawl/proc/on_stat_change(mob/living/source, new_stat, old_stat) + SIGNAL_HANDLER + if(new_stat != SOFT_CRIT) + source.hv_vis_stop(list(HV_VIS_TAG_CRAWL, HV_VIS_TAG_LEAN)) + qdel(src) + +/datum/component/hv_crawl/proc/on_moved(mob/living/carbon/source, atom/old_loc, movement_dir, forced, list/old_locs) + SIGNAL_HANDLER + if(forced || source.pulledby || source.buckled || source.body_position != LYING_DOWN || !isturf(source.loc) || !isturf(old_loc)) + return + source.hv_vis_play(HV_VIS_TAG_CRAWL, source.hv_vis_crawl_steps(movement_dir || get_dir(old_loc, source.loc))) + +/datum/component/hv_crawl/proc/on_say(mob/living/carbon/source, list/speech_args) + SIGNAL_HANDLER + if(source.stat != SOFT_CRIT || !isturf(source.loc)) + return + source.hv_vis_plead(source.hv_vis_hit_by?.resolve()) + +/// One heave of a crawl: the body drags behind the move, then hauls itself up to where it is going. +/mob/living/carbon/proc/hv_vis_crawl_steps(going) + var/dx = HV_VIS_DX(going) + var/dy = HV_VIS_DY(going) + var/matrix/base = matrix(transform) + // Stretched along the way it pulls itself, in the screen's frame since the body lies sideways. + var/matrix/reaching = matrix(base) + reaching.Scale(dx ? 1.06 : 1, dy ? 1.06 : 1) + return list( + list(pixel_w - dx * 5, pixel_z - dy * 5, base, 0, LINEAR_EASING), + list(pixel_w - dx * 3, pixel_z - dy * 3, reaching, 3, SINE_EASING), + list(pixel_w, pixel_z, base, 3, QUAD_EASING|EASE_OUT), + ) + +/// Leans up toward `toward` (or the way it faces) for a moment: a body on the floor begging. +/mob/living/proc/hv_vis_plead(atom/toward) + var/plead_dir = dir + if(toward && get_dist(src, toward) <= 7 && toward.z == z) + plead_dir = get_dir(src, toward) || dir + var/dx = HV_VIS_DX(plead_dir) + var/dy = HV_VIS_DY(plead_dir) + var/matrix/base = matrix(transform) + hv_vis_play(HV_VIS_TAG_LEAN, list( + list(pixel_w + dx * 3, pixel_z + dy * 3 + 2, base, 4, SINE_EASING), + list(pixel_w + dx * 3, pixel_z + dy * 3 + 2, base, 8, LINEAR_EASING), + list(pixel_w, pixel_z, base, 5, SINE_EASING), + )) + +// ---- Shaky self-injection ---- + +/obj/item/reagent_containers/hypospray/inject(mob/living/affected_mob, mob/user) + . = ..() + if(!. || user != affected_mob || !isliving(user)) + return + var/mob/living/patient = user + if(patient.stat >= SOFT_CRIT || patient.health < patient.maxHealth * 0.5) + patient.hv_vis_shaky_inject() + +/// Hands shaking as they jab themselves, then a sag of relief. +/mob/living/proc/hv_vis_shaky_inject() + if(!isturf(loc)) + return + var/matrix/base = matrix(transform) + var/list/steps = list() + for(var/i in 1 to 6) + var/side = i % 2 ? 1 : -1 + steps += list(list(pixel_w + side, pixel_z, hv_vis_turned(base, side * 2, hv_vis_pivot()), 0.6, LINEAR_EASING)) + steps += list(list(pixel_w, pixel_z - 1, hv_vis_local(base, 1, 0.97), 4, SINE_EASING)) + steps += list(list(pixel_w, pixel_z, base, 5, SINE_EASING)) + hv_vis_play(HV_VIS_TAG_SHAKE, steps) + +#undef HV_VIS_SURRENDER_TIME From 910ecc54848afe5841e2b6a7e602a738040d77b0 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:59:30 -0800 Subject: [PATCH 039/472] Test the gore animations and keep the gut tether holding at once The strand still exists from the first moment; only its picture fades in once the guts are out. Tumbling parts start on the next throw tick so the first tile they leave gets its streak. --- .../hyperviolence/visuals/flying_gore.dm | 5 +- .../hyperviolence/visuals/gut_motion.dm | 24 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../voidcrew_hyperviolence_visuals.dm | 337 ++++++++++++++++++ 4 files changed, 359 insertions(+), 8 deletions(-) create mode 100644 voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm diff --git a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm index 8f630513652..6acf4ef7e40 100644 --- a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm +++ b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm @@ -32,8 +32,9 @@ GLOBAL_VAR_INIT(hv_vis_arcing_gibs, FALSE) if(fling_dir) target = hv_vis_fling_target(target, fling_dir, style == HV_VIS_TUMBLE_SLIDE ? 1 : 0) speed = style == HV_VIS_TUMBLE_SLIDE ? 0.5 : min(speed, HV_VIS_TUMBLE_SPEED) - // Our own spin replaces tg's single quick turn. - . = ..(target, range, speed, thrower, FALSE, diagonals_first, callback, force, gentle, quickstart, throw_type_path) + // Our own spin replaces tg's single quick turn. No quick start: the tumble has to be listening + // before the first step, to streak the tile the part leaves. + . = ..(target, range, speed, thrower, FALSE, diagonals_first, callback, force, gentle, FALSE, throw_type_path) if(throwing && !QDELETED(src)) AddComponent(/datum/component/hv_tumble, style, blood_dna, fling_dir || get_dir(src, target)) diff --git a/voidcrew/modules/hyperviolence/visuals/gut_motion.dm b/voidcrew/modules/hyperviolence/visuals/gut_motion.dm index 257042e25e0..b7961abe5f9 100644 --- a/voidcrew/modules/hyperviolence/visuals/gut_motion.dm +++ b/voidcrew/modules/hyperviolence/visuals/gut_motion.dm @@ -1,7 +1,7 @@ // Hyperviolence visuals: guts. // 15. Spilled intestines come out of the body: they start small at the body, spill along the floor to -// where they lie and flop down, and the strand to the body (braingut/gut_tether.dm) only appears -// once they are out. +// where they lie and flop down, and the strand to the body (braingut/gut_tether.dm) only shows +// once they are out. The tether itself holds from the first moment, as before. // 16. Guts dragged along the floor, by someone pulling them or by the strand, slither as they go. /// How long the guts take to come out of the body. @@ -36,12 +36,24 @@ list(pixel_w, pixel_z, base, HV_VIS_UNSPOOL_TIME * 0.2 + 2, ELASTIC_EASING), )) -/// The strand to the body waits until the guts are out. +/// The strand to the body only shows once the guts are out. It exists (and holds) from the start; +/// only its picture waits. /datum/hv_gut_tether/refresh() - if(!strand && guts && world.time < guts.hv_vis_unspooled_at) - addtimer(CALLBACK(src, PROC_REF(refresh)), guts.hv_vis_unspooled_at - world.time, TIMER_UNIQUE|TIMER_DELETE_ME) + var/had_strand = !!strand + . = ..() + if(had_strand || !strand || !guts || world.time >= guts.hv_vis_unspooled_at) return - return ..() + var/wait = guts.hv_vis_unspooled_at - world.time + // Every piece of the strand shows the shared visuals, except the cropped end piece. + var/list/pictures = list() + if(strand.visuals) + pictures += strand.visuals + pictures += strand.elements + for(var/atom/movable/picture as anything in pictures) + var/full_alpha = picture.alpha + animate(picture, alpha = 0, time = 0) + animate(alpha = 0, time = wait) + animate(alpha = full_alpha, time = 2, easing = SINE_EASING) /obj/item/hv_intestines/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) . = ..() diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 3fe0553a970..998e4f89986 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -99,6 +99,7 @@ #include "voidcrew_hyperviolence_crits.dm" #include "voidcrew_hyperviolence_hitlines.dm" #include "voidcrew_hyperviolence_overlays_exec.dm" +#include "voidcrew_hyperviolence_visuals.dm" #include "voidcrew_launch_access.dm" #include "voidcrew_launch_cargo.dm" #include "voidcrew_launch_fabrication.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm new file mode 100644 index 00000000000..6e42717680a --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm @@ -0,0 +1,337 @@ +/** + * Hyperviolence visuals (voidcrew/modules/hyperviolence/visuals/). + * + * A headless world draws nothing, so these check what the server keeps: every animation leaves the + * transform and pixel offsets where they were, held poses let go when the body moves, stands up or + * is revived, and nothing is left behind (components, fling marks). Test files compile before the + * module's defines, so tags and tunables are written out, each with the define's name. + */ + +/// Shared helpers; not a test itself +/datum/unit_test/voidcrew_hv_visuals + abstract_type = /datum/unit_test/voidcrew_hv_visuals + +/// The floor `dx`, `dy` tiles from the test room's bottom-left corner +/datum/unit_test/voidcrew_hv_visuals/proc/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) + +/// Whether two transforms are the same (null counts as the identity) +/datum/unit_test/voidcrew_hv_visuals/proc/same_matrix(matrix/first, matrix/second) + first = first ? matrix(first) : matrix() + second = second ? matrix(second) : matrix() + return abs(first.a - second.a) < 0.001 && abs(first.b - second.b) < 0.001 && abs(first.c - second.c) < 0.001 \ + && abs(first.d - second.d) < 0.001 && abs(first.e - second.e) < 0.001 && abs(first.f - second.f) < 0.001 + +/// Where `thing` is drawn at rest: its pixel offsets and transform +/datum/unit_test/voidcrew_hv_visuals/proc/rest_of(atom/movable/thing) + return list(thing.pixel_x, thing.pixel_y, thing.pixel_w, thing.pixel_z, matrix(thing.transform)) + +/// Fails unless `thing` is drawn exactly as `rest` says +/datum/unit_test/voidcrew_hv_visuals/proc/check_rest(atom/movable/thing, list/rest, what) + if(thing.pixel_x != rest[1] || thing.pixel_y != rest[2] || thing.pixel_w != rest[3] || thing.pixel_z != rest[4]) + TEST_FAIL("[what] left [thing]'s pixel offsets changed: x [thing.pixel_x] y [thing.pixel_y] w [thing.pixel_w] z [thing.pixel_z], expected [rest[1]] [rest[2]] [rest[3]] [rest[4]]") + if(!same_matrix(thing.transform, rest[5])) + TEST_FAIL("[what] left [thing]'s transform changed") + +/// Fails unless the last of `steps` (hv_vis_play() steps) is where `thing` is drawn now +/datum/unit_test/voidcrew_hv_visuals/proc/check_ends_at_rest(atom/movable/thing, list/steps, what) + if(!length(steps)) + TEST_FAIL("[what] has no steps") + return + var/list/last = steps[length(steps)] + if(last[1] != thing.pixel_w || last[2] != thing.pixel_z || !same_matrix(last[3], thing.transform)) + TEST_FAIL("[what] does not end where the body stands") + +/// Fails unless the last of `steps` (hv_vis_play_over() steps) is where `thing` is drawn now +/datum/unit_test/voidcrew_hv_visuals/proc/check_over_ends_at_rest(atom/movable/thing, list/steps, what) + var/list/last = steps[length(steps)] + if(last[1] != thing.pixel_y || !same_matrix(last[2], thing.transform)) + TEST_FAIL("[what] does not end where the body lies") + +/// How many held poses `body` has +/datum/unit_test/voidcrew_hv_visuals/proc/holds(mob/living/body) + return length(body.GetComponents(/datum/component/hv_motion_hold)) + +/// Hits of every size leave the body where it stood, standing or lying, and a tick's pellets add up to one blow +/datum/unit_test/voidcrew_hv_visuals/flinch + +/datum/unit_test/voidcrew_hv_visuals/flinch/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(2, 2)) + var/list/standing = rest_of(victim) + victim.apply_damage(25, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, attack_direction = EAST) + check_rest(victim, standing, "A pistol-sized hit") + victim.apply_damage(20, BRUTE, BODY_ZONE_HEAD, wound_bonus = CANT_WOUND, attack_direction = WEST) + check_rest(victim, standing, "A hit to the head") + TEST_ASSERT_EQUAL(victim.hv_vis_hit_zone, BODY_ZONE_HEAD, "The head hit was not noted on the head") + victim.fully_heal(HEAL_ALL) + standing = rest_of(victim) + var/before = victim.hv_vis_hit_time == world.time ? victim.hv_vis_hit_damage : 0 + for(var/pellet in 1 to 8) + victim.apply_damage(7, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, attack_direction = NORTH) + check_rest(victim, standing, "A shotgun's pellets") + TEST_ASSERT(victim.hv_vis_hit_damage - before >= 56, "A tick's pellets were not added up into one blow (got [victim.hv_vis_hit_damage - before])") + TEST_ASSERT_EQUAL(victim.hv_vis_resolving, 0, "A hit was left counted as still being resolved") + + victim.fully_heal(HEAL_ALL) + victim.Paralyze(10 SECONDS) + TEST_ASSERT_EQUAL(victim.body_position, LYING_DOWN, "Test fixture: the victim did not lie down") + var/list/lying = rest_of(victim) + victim.apply_damage(30, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, attack_direction = EAST) + check_rest(victim, lying, "A hit on a body lying down") + victim.apply_damage(30, STAMINA, BODY_ZONE_CHEST, attack_direction = SOUTH) + check_rest(victim, lying, "A stamina hit") + +/// Every animation's steps end where the body is, standing and lying +/datum/unit_test/voidcrew_hv_visuals/steps_end_at_rest + +/datum/unit_test/voidcrew_hv_visuals/steps_end_at_rest/Run() + var/mob/living/carbon/human/consistent/body = allocate(/mob/living/carbon/human/consistent, spot(2, 2)) + for(var/pass in 1 to 2) + var/state = pass == 1 ? "standing" : "lying" + for(var/hit_dir in GLOB.alldirs) + check_ends_at_rest(body, body.hv_vis_flinch_steps(25, hit_dir), "A flinch ([state], [dir2text(hit_dir)])") + check_ends_at_rest(body, body.hv_vis_flinch_steps(60, hit_dir, TRUE, 1.5), "A held stagger to the head ([state], [dir2text(hit_dir)])") + check_ends_at_rest(body, body.hv_vis_slide_steps(hit_dir, 30, 1.5), "A death slide ([state], [dir2text(hit_dir)])") + check_ends_at_rest(body, body.hv_vis_crawl_steps(hit_dir), "A crawl heave ([state], [dir2text(hit_dir)])") + for(var/zone in list(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + check_ends_at_rest(body, body.hv_vis_lurch_steps(zone), "A lurch ([state], [zone])") + check_ends_at_rest(body, body.hv_vis_limp_steps(zone), "A limping step ([state], [zone])") + check_ends_at_rest(body, body.hv_vis_curl_steps(3), "A burned curl ([state])") + check_ends_at_rest(body, body.hv_vis_twitch_steps(4, 4), "Twitches ([state])") + body.Paralyze(10 SECONDS) + + // The fall over-rides end where tg laid the body down. + body.death() + TEST_ASSERT_EQUAL(body.body_position, LYING_DOWN, "Test fixture: the body is not lying down") + check_over_ends_at_rest(body, body.hv_vis_kneel_collapse_steps(), "The bleed-out kneel") + check_over_ends_at_rest(body, body.hv_vis_hold_then_fall_steps(3, 2), "The held fall") + var/list/upright = body.hv_vis_upright() + TEST_ASSERT(same_matrix(upright[2], matrix()), "The pose a body stood in before it fell is not upright") + +/// A body killed by a blow from the side falls away from it, lies exactly as tg lays it, slides and +/// twitches as held poses, and is drawn as it should be once revived +/datum/unit_test/voidcrew_hv_visuals/death_falls_away + +/datum/unit_test/voidcrew_hv_visuals/death_falls_away/Run() + for(var/blow_dir in list(EAST, WEST)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(2, 2)) + victim.adjustOxyLoss(185, updating_health = FALSE) + TEST_ASSERT_EQUAL(victim.body_position, STANDING_UP, "Test fixture: the victim fell before the killing blow") + victim.apply_damage(25, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, attack_direction = blow_dir) + TEST_ASSERT_EQUAL(victim.stat, DEAD, "Test fixture: the killing blow did not kill") + // LYING_ANGLE_EAST 90 (head to the east), LYING_ANGLE_WEST 270 + TEST_ASSERT_EQUAL(victim.lying_angle, blow_dir == EAST ? 90 : 270, "A body killed by a blow going [dir2text(blow_dir)] did not fall that way") + var/matrix/laid = matrix() + laid.Turn(victim.lying_angle) + TEST_ASSERT(same_matrix(victim.transform, laid), "The death animations changed how the body lies") + TEST_ASSERT_EQUAL(victim.pixel_w, 0, "The death animations moved the body sideways") + TEST_ASSERT_EQUAL(victim.pixel_z, 0, "The death animations moved the body up or down") + TEST_ASSERT(holds(victim) >= 1, "The death slide and twitches are not held") + + victim.revive(ADMIN_HEAL_ALL) + TEST_ASSERT_EQUAL(victim.body_position, STANDING_UP, "Test fixture: the revived body did not stand") + TEST_ASSERT_EQUAL(holds(victim), 0, "A held death pose outlived the revival") + TEST_ASSERT(same_matrix(victim.transform, matrix()), "A revived body stands skewed") + TEST_ASSERT_EQUAL(victim.pixel_y, 0, "A revived body stands too low") + TEST_ASSERT_EQUAL(victim.pixel_w, 0, "A revived body stands off to the side") + +/// The kind of death decides the fall +/datum/unit_test/voidcrew_hv_visuals/death_kinds + +/datum/unit_test/voidcrew_hv_visuals/death_kinds/Run() + // HV_VIS_DEATH_QUIET 0, _HIT 1, _HEADSHOT 2, _BURNED 3, _BLED_OUT 4, _EXECUTED 5 + var/mob/living/carbon/human/consistent/shot = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + shot.adjustOxyLoss(185, updating_health = FALSE) + shot.apply_damage(25, BRUTE, BODY_ZONE_HEAD, wound_bonus = CANT_WOUND, attack_direction = NORTH) + TEST_ASSERT_EQUAL(shot.hv_vis_death_kind(), 2, "A head shot did not read as a head shot") + var/matrix/laid = matrix() + laid.Turn(shot.lying_angle) + TEST_ASSERT(same_matrix(shot.transform, laid), "The head shot drop changed how the body lies") + + var/mob/living/carbon/human/consistent/burned = allocate(/mob/living/carbon/human/consistent, spot(3, 1)) + burned.adjustOxyLoss(185, updating_health = FALSE) + burned.apply_damage(25, BURN, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, attack_direction = WEST) + TEST_ASSERT_EQUAL(burned.hv_vis_death_kind(), 3, "A burning blow did not read as burned") + TEST_ASSERT(holds(burned) >= 1, "The burned curl is not held") + laid = matrix() + laid.Turn(burned.lying_angle) + TEST_ASSERT(same_matrix(burned.transform, laid), "The curl changed how the body lies") + burned.forceMove(spot(3, 2)) + TEST_ASSERT_EQUAL(holds(burned), 0, "Moving a curled body did not let the curl go") + + var/mob/living/carbon/human/consistent/drained = allocate(/mob/living/carbon/human/consistent, spot(1, 3)) + drained.blood_volume = 50 + drained.death() + TEST_ASSERT_EQUAL(drained.hv_vis_death_kind(), 4, "A body out of blood did not read as bled out") + laid = matrix() + laid.Turn(drained.lying_angle) + TEST_ASSERT(same_matrix(drained.transform, laid), "The bleed-out kneel left the body standing") + + var/mob/living/carbon/human/consistent/quiet = allocate(/mob/living/carbon/human/consistent, spot(3, 3)) + quiet.death() + TEST_ASSERT_EQUAL(quiet.hv_vis_death_kind(), 0, "A death with nothing behind it read as something") + quiet.hv_vis_execution_time = world.time + TEST_ASSERT_EQUAL(quiet.hv_vis_death_kind(), 5, "An execution did not read as one") + +/// A heavy killing blow slides the corpse a whole tile, never into a wall or a standing person +/datum/unit_test/voidcrew_hv_visuals/full_slide + +/datum/unit_test/voidcrew_hv_visuals/full_slide/Run() + // The bottom-left floor has walls to its west and south. + var/mob/living/carbon/human/consistent/corpse = allocate(/mob/living/carbon/human/consistent, spot(0, 0)) + corpse.death() + TEST_ASSERT(!corpse.hv_vis_can_slide_tile(WEST), "A corpse could slide into a wall") + TEST_ASSERT(!corpse.hv_vis_can_slide_tile(SOUTH), "A corpse could slide into a wall") + TEST_ASSERT(corpse.hv_vis_can_slide_tile(EAST), "A corpse could not slide onto open floor") + var/mob/living/carbon/human/consistent/bystander = allocate(/mob/living/carbon/human/consistent, spot(1, 0)) + TEST_ASSERT(!corpse.hv_vis_can_slide_tile(EAST), "A corpse could slide into someone standing there") + qdel(bystander) + corpse.hv_vis_slide_tile(EAST) + TEST_ASSERT_EQUAL(get_turf(corpse), spot(1, 0), "The corpse did not slide a tile") + + // Through a real killing blow, next tick + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 2)) + victim.adjustOxyLoss(185, updating_health = FALSE) + victim.apply_damage(50, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, attack_direction = EAST) + TEST_ASSERT_EQUAL(victim.stat, DEAD, "Test fixture: the killing blow did not kill") + sleep(3) + TEST_ASSERT_EQUAL(get_turf(victim), spot(2, 2), "A heavy killing blow did not slide the body a tile along it") + + // A living body is never slid + var/mob/living/carbon/human/consistent/alive = allocate(/mob/living/carbon/human/consistent, spot(3, 3)) + alive.hv_vis_slide_tile(EAST) + TEST_ASSERT_EQUAL(get_turf(alive), spot(3, 3), "A living body was slid") + +/// A severed arm flies the way the blow went, tumbling, streaks blood, and lands with nothing left on it +/datum/unit_test/voidcrew_hv_visuals/tumble + +/datum/unit_test/voidcrew_hv_visuals/tumble/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + var/list/standing = rest_of(victim) + victim.hv_blood_hit_dir = EAST + victim.hv_blood_hit_time = world.time + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_L_ARM) + var/turf/start = get_turf(victim) + TEST_ASSERT(arm.dismember(BRUTE, TRUE, WOUND_SLASH), "Test fixture: the arm did not come off") + TEST_ASSERT_NULL(arm.hv_vis_tumble, "The throw did not use up the arm's fling") + var/datum/component/hv_tumble/tumble = arm.GetComponent(/datum/component/hv_tumble) + TEST_ASSERT_NOTNULL(tumble, "A severed arm did not tumble") + TEST_ASSERT_EQUAL(tumble.fling_dir, EAST, "The arm was not flung the way the blow went") + check_rest(victim, standing, "Losing an arm") + TEST_ASSERT_NOTNULL(victim.get_filter("hv_vis_flash"), "Losing an arm got no hit-stop flash") + sleep(1 SECONDS) + TEST_ASSERT(isturf(arm.loc), "The arm did not land") + TEST_ASSERT(arm.x > start.x && arm.y == start.y, "The arm did not fly the way the blow went") + TEST_ASSERT_NULL(arm.GetComponent(/datum/component/hv_tumble), "The tumble outlived the landing") + TEST_ASSERT_EQUAL(arm.pixel_w, 0, "The landed arm is drawn off to the side") + TEST_ASSERT_EQUAL(arm.pixel_z, 0, "The landed arm is drawn in the air") + TEST_ASSERT(same_matrix(arm.transform, matrix()), "The landed arm is left turned") + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/hv_spray/streak) in start, "The arm left no blood along its way") + +/// A burst head's brain tumbles out and lands clean, and no organ keeps a fling mark +/datum/unit_test/voidcrew_hv_visuals/brain_tumble + +/datum/unit_test/voidcrew_hv_visuals/brain_tumble/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(2, 0)) + var/obj/item/organ/brain/brain = victim.get_organ_slot(ORGAN_SLOT_BRAIN) + var/list/obj/item/organ/head_organs = list() + for(var/obj/item/organ/organ in victim.get_bodypart(BODY_ZONE_HEAD)) + head_organs += organ + TEST_ASSERT(victim.hv_burst_head(NORTH), "Test fixture: the head did not burst") + TEST_ASSERT_NOTNULL(brain.GetComponent(/datum/component/hv_tumble), "The brain did not tumble out") + for(var/obj/item/organ/organ as anything in head_organs) + if(!QDELETED(organ)) + TEST_ASSERT_NULL(organ.hv_vis_tumble, "[organ] kept its fling mark after the burst") + TEST_ASSERT(!GLOB.hv_vis_arcing_gibs, "The burst left gibs arcing") + sleep(2 SECONDS) + TEST_ASSERT_NULL(brain.GetComponent(/datum/component/hv_tumble), "The brain's tumble outlived its landing") + TEST_ASSERT_EQUAL(brain.pixel_w, 0, "The landed brain is drawn off to the side") + TEST_ASSERT_EQUAL(brain.pixel_z, 0, "The landed brain is drawn in the air") + TEST_ASSERT(same_matrix(brain.transform, matrix()), "The landed brain is left squashed or turned") + +/// One leg gone: a limp while it is gone, none once it is back +/datum/unit_test/voidcrew_hv_visuals/limp + +/datum/unit_test/voidcrew_hv_visuals/limp/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + var/obj/item/bodypart/leg = victim.get_bodypart(BODY_ZONE_L_LEG) + leg.drop_limb(FALSE, TRUE) + TEST_ASSERT_NOTNULL(victim.GetComponent(/datum/component/hv_limp), "Losing a leg left no limp") + var/list/rest = rest_of(victim) + victim.Move(spot(2, 1), EAST) + check_rest(victim, rest, "A limping step") + leg.try_attach_limb(victim) + TEST_ASSERT_NULL(victim.GetComponent(/datum/component/hv_limp), "The limp outlived the leg coming back") + +/// Soft crit brings the crawl; leaving it takes it away +/datum/unit_test/voidcrew_hv_visuals/crawl + +/datum/unit_test/voidcrew_hv_visuals/crawl/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + victim.apply_damage(110, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND) + TEST_ASSERT_EQUAL(victim.stat, SOFT_CRIT, "Test fixture: the victim is not in soft crit") + TEST_ASSERT_NOTNULL(victim.GetComponent(/datum/component/hv_crawl), "Soft crit did not bring the crawl") + var/list/rest = rest_of(victim) + victim.Move(spot(2, 1), EAST) + victim.hv_vis_plead(null) + check_rest(victim, rest, "A heave and a plea") + victim.fully_heal(HEAL_ALL) + TEST_ASSERT_NULL(victim.GetComponent(/datum/component/hv_crawl), "The crawl outlived soft crit") + +/// A surrender trembles until they stand back up +/datum/unit_test/voidcrew_hv_visuals/surrender + +/datum/unit_test/voidcrew_hv_visuals/surrender/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + victim.emote("surrender") + TEST_ASSERT_EQUAL(victim.body_position, LYING_DOWN, "Test fixture: the surrender did not put them on the floor") + TEST_ASSERT_EQUAL(holds(victim), 1, "The surrender tremble is not held") + victim.SetParalyzed(0) + TEST_ASSERT_EQUAL(victim.body_position, STANDING_UP, "Test fixture: they did not stand back up") + TEST_ASSERT_EQUAL(holds(victim), 0, "The tremble outlived getting back up") + +/// Spilled guts unspool over time but hold their strand at once, and slither without moving off their spot +/datum/unit_test/voidcrew_hv_visuals/guts + +/datum/unit_test/voidcrew_hv_visuals/guts/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + var/obj/item/bodypart/chest/chest = victim.get_bodypart(BODY_ZONE_CHEST) + var/datum/wound/slash/flesh/critical/disembowel/spill = chest.hv_spill_guts(EAST, "test") + TEST_ASSERT_NOTNULL(spill, "Test fixture: the guts did not spill") + var/obj/item/hv_intestines/guts = spill.guts + TEST_ASSERT(guts.hv_vis_unspooled_at > world.time, "The guts came out all at once") + TEST_ASSERT_NOTNULL(spill.tether.strand, "The strand waited for the guts instead of holding at once") + var/list/rest = rest_of(guts) + guts.forceMove(spot(2, 2)) + check_rest(guts, rest, "A slither") + +/// A finisher leaves both of them drawn as they were, however its poses went +/datum/unit_test/voidcrew_hv_visuals/finisher_release + +/datum/unit_test/voidcrew_hv_visuals/finisher_release/Run() + var/mob/living/basic/hoarfrost_matriarch/matriarch = allocate(/mob/living/basic/hoarfrost_matriarch, spot(1, 1)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(2, 1)) + victim.Paralyze(100 SECONDS) + var/list/boss_rest = rest_of(matriarch) + var/datum/hv_finisher/hoarfrost/finisher = new(matriarch, victim) + TEST_ASSERT(finisher.start(), "Test fixture: the finisher did not start") + for(var/i in 1 to 20) + if(!finisher.running) + break + finisher.advance() + TEST_ASSERT(!finisher.running, "Test fixture: the finisher did not finish") + check_rest(matriarch, boss_rest, "A finisher") + TEST_ASSERT_EQUAL(victim.pixel_w, 0, "A finisher left the victim off to the side") + TEST_ASSERT_EQUAL(victim.pixel_z, 0, "A finisher left the victim lifted") + qdel(finisher) + +/// The hit-stop flash goes on, and comes off on a timer that dies with the body +/datum/unit_test/voidcrew_hv_visuals/flash + +/datum/unit_test/voidcrew_hv_visuals/flash/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + victim.hv_vis_flash() + TEST_ASSERT_NOTNULL(victim.get_filter("hv_vis_flash"), "The hit-stop did not flash") + TEST_ASSERT(length(victim._active_timers), "Nothing will take the flash off") + sleep(1 SECONDS) + TEST_ASSERT_NULL(victim.get_filter("hv_vis_flash"), "The flash stayed on") From 179bc4ca9b217ac2be50600b7e684bf2d3c2f748 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:04:50 -0800 Subject: [PATCH 040/472] Add the shared human NPC pieces the pirate, ruin and criminal phases build on - Barks on real tg changes: entering and leaving crit, stamcrit ending, cuffs on and off, working the cuffs, breaking a grab, giving up and death, with per-family voices, a cooldown, and speech that gags and mutes stop. Death also plays the hyperviolence death throes. - Surrender as an AI choice: per-family odds, weapons holstered on the body, the family's line and tg's *surrender, then a fresh decision when the paralysis ends uncuffed. - human_npc_out_of_fight(): dead, in crit or restrained. Hull turrets now skip anyone out of the fight. - Per-family target stat, and a soft-crit branch: the AI keeps running in soft crit to plead, use a medipen, crawl away, and for elites and bosses fire wild from the floor. - Walking out: a leaving NPC walks to its exit for real and is deleted only on the exit tile or out of every player's sight, never while down, cuffed, dragged or carried. - Outfit tables by zone and rank, and site layers from the spawn turf's air: sealed suits and tanks in vacuum, a mask and tank in bad air, cold-weather wear in the cold. Pirate faction outfits with real weapons and spare ammunition, and per-faction tables and space suits. - Gear lock: papers are never gear, medical stacks are consumables, and an NPC can opt out of the lock entirely (prisoners). - The zone health procs refuse carbons. --- tgstation.dme | 6 + voidcrew/_DEFINES/human_npcs.dm | 74 ++++ voidcrew/machinery/ship_defense_turret.dm | 4 +- voidcrew/modules/human_npcs/human_npc.dm | 128 +++++- voidcrew/modules/human_npcs/human_npc_ai.dm | 43 +- .../modules/human_npcs/human_npc_barks.dm | 203 +++++++++ voidcrew/modules/human_npcs/human_npc_gear.dm | 24 +- .../modules/human_npcs/human_npc_outfits.dm | 231 ++++++++++ .../human_npcs/human_npc_outfits_pirates.dm | 384 ++++++++++++++++ .../modules/human_npcs/human_npc_samples.dm | 75 ++++ .../modules/human_npcs/human_npc_soft_crit.dm | 164 +++++++ .../modules/human_npcs/human_npc_spawning.dm | 86 +++- .../modules/human_npcs/human_npc_states.dm | 108 +++++ .../modules/human_npcs/human_npc_surrender.dm | 161 +++++++ voidcrew/modules/npc_scaling/zone_profiles.dm | 8 + voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_human_npcs_shared.dm | 416 ++++++++++++++++++ 17 files changed, 2072 insertions(+), 44 deletions(-) create mode 100644 voidcrew/modules/human_npcs/human_npc_barks.dm create mode 100644 voidcrew/modules/human_npcs/human_npc_outfits.dm create mode 100644 voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm create mode 100644 voidcrew/modules/human_npcs/human_npc_soft_crit.dm create mode 100644 voidcrew/modules/human_npcs/human_npc_states.dm create mode 100644 voidcrew/modules/human_npcs/human_npc_surrender.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_human_npcs_shared.dm diff --git a/tgstation.dme b/tgstation.dme index 9942609a685..e3dcc2950d3 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7233,10 +7233,16 @@ #include "voidcrew\modules\human_npcs\human_npc.dm" #include "voidcrew\modules\human_npcs\human_npc_admin.dm" #include "voidcrew\modules\human_npcs\human_npc_ai.dm" +#include "voidcrew\modules\human_npcs\human_npc_barks.dm" #include "voidcrew\modules\human_npcs\human_npc_gear.dm" +#include "voidcrew\modules\human_npcs\human_npc_outfits.dm" +#include "voidcrew\modules\human_npcs\human_npc_outfits_pirates.dm" #include "voidcrew\modules\human_npcs\human_npc_samples.dm" +#include "voidcrew\modules\human_npcs\human_npc_soft_crit.dm" #include "voidcrew\modules\human_npcs\human_npc_spawning.dm" #include "voidcrew\modules\human_npcs\human_npc_spike_harness.dm" +#include "voidcrew\modules\human_npcs\human_npc_states.dm" +#include "voidcrew\modules\human_npcs\human_npc_surrender.dm" #include "voidcrew\modules\hyperviolence\_core.dm" #include "voidcrew\modules\hyperviolence\blood\_blood_defines.dm" #include "voidcrew\modules\hyperviolence\blood\blood_pools.dm" diff --git a/voidcrew/_DEFINES/human_npcs.dm b/voidcrew/_DEFINES/human_npcs.dm index 05d46e8c88a..56871ad9b3b 100644 --- a/voidcrew/_DEFINES/human_npcs.dm +++ b/voidcrew/_DEFINES/human_npcs.dm @@ -48,3 +48,77 @@ #define BB_HUMAN_NPC_RELOAD_UNTIL "human_npc_reload_until" /// The item the NPC is drawing (human_npc_draw). #define BB_HUMAN_NPC_DRAW_ITEM "human_npc_draw_item" + +// ===== Phase A2: shared pieces for pirates, ruin NPCs, hermits and criminals ===== + +// npc_target_stat: the worst state a target can be in and still get attacked (BB_TARGET_MINIMUM_STAT). +/// Pirates, troopers, hermits, raiders, NT customs: keep shooting someone in crit, as tg troopers do. +#define HUMAN_NPC_FINISHES_DOWNED HARD_CRIT +/// Criminals, companions, bosses, prisoners, guards: stop once a target is in crit. +#define HUMAN_NPC_SPARES_DOWNED CONSCIOUS + +// Bark kinds (human_npc_barks.dm). Families supply the lines through a /datum/human_npc_voice. +/// Entered crit. Whispered in soft crit; hard crit gets the emote only. +#define HUMAN_NPC_BARK_DOWNED "downed" +/// Left crit, or got up from stamcrit. +#define HUMAN_NPC_BARK_RECOVER "recover" +/// Now and then while it crawls in soft crit. Falls back to the downed lines. +#define HUMAN_NPC_BARK_PLEAD "plead" +/// Handcuffs went on. +#define HUMAN_NPC_BARK_CUFFED "cuffed" +/// Started working at its cuffs (tg's cuff_resist). +#define HUMAN_NPC_BARK_SLIPPING "slipping_cuffs" +/// Its cuffs came off, by its own work or someone else's. +#define HUMAN_NPC_BARK_UNCUFFED "uncuffed" +/// Broke out of a grab or off someone's shoulders by resisting. +#define HUMAN_NPC_BARK_PULLED_FREE "pulled_free" +/// Gave up (human_npc_surrender.dm). Always said. +#define HUMAN_NPC_BARK_SURRENDER "surrender" +/// Died. Emote only: the dead don't talk. +#define HUMAN_NPC_BARK_DEATH "death" +/// Default seconds between two barks from one NPC. +#define HUMAN_NPC_BARK_COOLDOWN (4 SECONDS) +/// How often an NPC crawling in soft crit pleads, at most. +#define HUMAN_NPC_PLEAD_INTERVAL (12 SECONDS) + +// Sites (human_npc_outfits.dm): what the air where an NPC is dressed asks of its kit. +/// Too little pressure to live in: a sealed suit and helmet and its own air. +#define HUMAN_NPC_SITE_VACUUM (1<<0) +/// Pressure is fine but the air isn't: a mask and a tank. +#define HUMAN_NPC_SITE_BAD_AIR (1<<1) +/// Cold enough to freeze an unprotected person: cold-weather wear. +#define HUMAN_NPC_SITE_COLD (1<<2) +/// Air colder than this, in kelvin, is a cold site. +#define HUMAN_NPC_SITE_COLD_TEMP (T0C - 10) +/// Oxygen partial pressure below this, in kPa, is not breathable (tg's safe minimum). +#define HUMAN_NPC_SITE_MIN_OXYGEN 16 +/// A queued NPC dressed by an outfit table waits this long for its spawn zone to resolve, then takes green. +#define HUMAN_NPC_ZONE_WAIT (60 SECONDS) + +// Leaving (human_npc_states.dm). +/// How often SShuman_npcs checks whether an NPC walking out may be deleted. +#define HUMAN_NPC_LEAVER_SWEEP_INTERVAL (1 SECONDS) + +// Signals sent on a human NPC. +/// It gave up: tg's *surrender is running. () +#define COMSIG_HUMAN_NPC_SURRENDERED "human_npc_surrendered" +/// Its surrender's paralysis ended and nobody had cuffed it. () +#define COMSIG_HUMAN_NPC_SURRENDER_ENDED "human_npc_surrender_ended" +/// It entered crit. () +#define COMSIG_HUMAN_NPC_DOWNED "human_npc_downed" +/// It left crit, or got up from stamcrit. () +#define COMSIG_HUMAN_NPC_RECOVERED "human_npc_recovered" +/// It walked out and is about to be deleted (human_npc_states.dm). () +#define COMSIG_HUMAN_NPC_LEFT "human_npc_left" + +// More blackboard keys. +/// A mob it is running from (surrender flight, soft-crit crawl). +#define BB_HUMAN_NPC_FLEE_FROM "human_npc_flee_from" +/// world.time it stops fleeing after deciding not to give up again. +#define BB_HUMAN_NPC_FLEE_UNTIL "human_npc_flee_until" +/// The item it is using on itself (human_npc_treat_self). +#define BB_HUMAN_NPC_TREAT_ITEM "human_npc_treat_item" +/// world.time it may next treat itself. +#define BB_HUMAN_NPC_NEXT_TREAT "human_npc_next_treat" +/// The turf it is walking out to (npc_walk_out()). +#define BB_HUMAN_NPC_EXIT "human_npc_exit" diff --git a/voidcrew/machinery/ship_defense_turret.dm b/voidcrew/machinery/ship_defense_turret.dm index b59033508c0..a0223d00be2 100644 --- a/voidcrew/machinery/ship_defense_turret.dm +++ b/voidcrew/machinery/ship_defense_turret.dm @@ -284,7 +284,9 @@ * which is the whole job. */ /obj/machinery/porta_turret/ship_defense/proc/valid_target(mob/living/creature) - if(creature.stat == DEAD) + // Dead, in crit or cuffed: out of the fight (human_npc_out_of_fight(), owner ruling OC-7), so a + // boarder the crew has taken down or captured is left alone. A basic mob is out only once dead. + if(human_npc_out_of_fight(creature)) return FALSE if(creature.client || creature.mind) // Anything a player is driving, or was driving. return FALSE diff --git a/voidcrew/modules/human_npcs/human_npc.dm b/voidcrew/modules/human_npcs/human_npc.dm index 375fc3c1bb3..b827b2222ed 100644 --- a/voidcrew/modules/human_npcs/human_npc.dm +++ b/voidcrew/modules/human_npcs/human_npc.dm @@ -20,6 +20,8 @@ * bleed out until someone arrives. A corpse that wakes catches up the organ decay it missed while * dormant (npc_catch_up_decay()), so the defib window and organ rot match a player's corpse; its * body temperature is not caught up. Stamina recovery is a timer, not Life(), so it is not paused. + * A space suit's thermal regulator is switched off while it is dormant and back on when it wakes + * (npc_set_suit_heating()), so a cell doesn't run flat keeping a frozen-in-time body warm. * This has to be a guard on this type's own Life(): COMSIG_LIVING_LIFE's cancel flag only stops * /mob/living/Life(), and carbon and human Life() run organs, wounds, limbs and temperature around it. * - No mood datum and TRAIT_NOHUNGER (performance, and an NPC never eats over a 6 hour round). @@ -70,6 +72,54 @@ /// Apply the dormancy and life trims. Only the measurement harness turns this off. var/npc_trim_life = TRUE + // ===== Per-family behaviour (phase A2). A family sets these on its own subtype. ===== + /** + * Picks its outfit by zone and rank when npc_outfit is unset: a /datum/human_npc_outfit_table typepath + * (human_npc_outfits.dm). spawn_human_npc(outfit =) also takes a table. + */ + var/datum/human_npc_outfit_table/npc_outfit_table + /// HUMAN_NPC_SITE_* flags it always dresses for, on top of the air where it spawns: a boarder that + /// crosses vacuum sets HUMAN_NPC_SITE_VACUUM so it carries a sealed suit even when it spawns indoors. + var/npc_always_site = NONE + /// What it says when things happen to it: a /datum/human_npc_voice typepath (human_npc_barks.dm), or + /// null for nothing. Override npc_bark() to voice it another way. + var/datum/human_npc_voice/npc_voice + /// When it gives up: a /datum/human_npc_surrender typepath (human_npc_surrender.dm), or null if it never does. + var/datum/human_npc_surrender/npc_surrender + /** + * The worst state a target can be in and still get attacked, written into its AI's + * BB_TARGET_MINIMUM_STAT at spawn: HUMAN_NPC_FINISHES_DOWNED (pirates, troopers, hermits) or + * HUMAN_NPC_SPARES_DOWNED (criminals, bosses, prisoners, guards). + */ + var/npc_target_stat = HUMAN_NPC_FINISHES_DOWNED + /// D3: its kit is locked to its body. Prisoners turn this off (owner ruling OC-11): their jumpsuits, + /// contraband and shivs are ordinary items that shakedowns and confiscation work on. + var/npc_gear_locked = TRUE + + /// The HUMAN_NPC_SITE_* flags it was dressed for + var/npc_site = NONE + /// The bark kind it last gave, for tests and admins + var/npc_last_bark + COOLDOWN_DECLARE(npc_bark_cooldown) + /// world.time it may next plead while crawling in soft crit + var/npc_next_plead = 0 + /// TRUE while the paralysis of its own *surrender runs + var/npc_surrendering = FALSE + /// Set when a surrender's paralysis ended with nobody cuffing it: it decides again at once + var/npc_surrender_redecide = FALSE + /// world.time it may next roll to surrender + var/npc_next_surrender_roll = 0 + /// How many times it has rolled to give up, and how many times it did + var/npc_surrender_rolls = 0 + var/npc_surrender_count = 0 + /// Weakref to whoever held it when it last resisted, and the world.time it did + var/datum/weakref/npc_resisted_hold + var/npc_resisted_at = 0 + /// The turf it is walking out to (npc_walk_out()), or null + var/turf/npc_exit + /// Called with it just before it is deleted on walking out + var/datum/callback/npc_on_left + /// TRUE once its death loot has dropped var/npc_loot_dropped = FALSE /// world.time it last died, or 0 @@ -89,19 +139,28 @@ for(var/ware_type in npc_cyberware) npc_install_cyberware(ware_type) npc_conceal_presence() - npc_lock_all_gear() + if(npc_gear_locked) + npc_lock_all_gear() for(var/obj/item/held as anything in held_items) if(held) human_npc_ready_weapon(src, held) npc_open_internals() + npc_set_suit_heating(TRUE) set_npc_toughness(npc_damage_taken) if(npc_body_armor) physiology.armor = physiology.armor.add_other_armor(npc_body_armor) + if(istype(ai_controller)) + ai_controller.set_blackboard_key(BB_TARGET_MINIMUM_STAT, npc_target_stat) AddElement(/datum/element/human_npc_body) + AddElement(/datum/element/human_npc_reactions) /mob/living/carbon/human/npc/Destroy() SShuman_npcs?.forget_corpse(src) + SShuman_npcs?.leavers -= src npc_remains = null + npc_resisted_hold = null + npc_exit = null + npc_on_left = null return ..() /mob/living/carbon/human/npc/setup_mood() @@ -114,11 +173,14 @@ /mob/living/carbon/human/npc/Life(seconds_per_tick = SSMOBS_DT, times_fired) if(npc_trim_life) if(npc_dormant()) - npc_dormant_since ||= world.time + if(!npc_dormant_since) + npc_dormant_since = world.time + npc_set_suit_heating(FALSE) return FALSE if(npc_dormant_since) npc_catch_up_decay((world.time - npc_dormant_since) / (1 SECONDS), times_fired) npc_dormant_since = 0 + npc_set_suit_heating(TRUE) #ifdef HUMAN_NPC_SPIKE_HARNESS GLOB.human_npc_spike_life_passes++ #endif @@ -147,26 +209,46 @@ // ===== DRESSING ===== -/// Puts on its look, outfit, name and faction. `order` may override the type's own. +/** + * Puts on its look, outfit, name and faction. `order` may override the type's own. + * + * The outfit is, first found: the order's outfit, npc_outfit, a pick from npc_outfit_table, or the + * outfit of a spawner look. An outfit table (as the order's outfit or npc_outfit_table) picks by the + * zone it spawns in and its rank. Then the site layers go on (human_npc_outfits.dm): where the air + * can't keep it alive it gets a sealed suit and a tank, where it is cold it gets cold-weather wear, so + * no NPC suffocates or freezes on the spot it was made. Species that don't need them (skeletons in + * vacuum) are left as they are. + */ /mob/living/carbon/human/npc/proc/npc_dress(datum/human_npc_order/order) var/look = order?.look || npc_look var/outfit = order?.outfit || npc_outfit + var/datum/human_npc_outfit_table/table + if(ispath(outfit, /datum/human_npc_outfit_table)) + table = outfit + outfit = null + else if(isnull(outfit) && ispath(npc_outfit_table, /datum/human_npc_outfit_table)) + table = npc_outfit_table var/datum/bounty_record/record if(istype(look, /datum/bounty_record)) record = look look = bounty_ensure_look(record) + var/spawner_outfit if(ispath(look, /obj/effect/mob_spawn)) - var/spawner_outfit = npc_apply_spawner_look(look) - outfit ||= spawner_outfit + spawner_outfit = npc_apply_spawner_look(look) else if(istype(look, /datum/bounty_look)) npc_apply_bounty_look(look) else npc_apply_random_look() // Plasmamen and other species that need their own kit to live get it first, as from a spawner. dna.species.give_important_for_life(src) + if(table) + table = human_npc_singleton(table) + outfit = table.pick_outfit(src, order ? order.resolve_zone() : npc_spawn_zone()) + outfit ||= spawner_outfit // Dressed directly, not through /obj/effect/mob_spawn/equip(), whose ladder strip // (voidcrew/edits/mobs/ladder_outfits.dm) would delete the guns an NPC fights with. if(outfit) + outfit = npc_fit_outfit_to_site(outfit, table) equipOutfit(outfit) if(record) bounty_wear_features(src, record.features) @@ -267,8 +349,11 @@ // ===== RULES OF ENGAGEMENT ===== -// The strip menu never opens on a human NPC (D3). Carrying and dragging are unaffected. +// The strip menu never opens on a human NPC (D3). Carrying and dragging are unaffected. An NPC whose +// gear isn't locked (prisoners, OC-11) is searched and stripped like anyone. /mob/living/carbon/human/npc/should_strip(mob/user) + if(!npc_gear_locked) + return ..() return FALSE // D4: an NPC's hits become aimed crits and executions only if it is an elite or a boss, or a @@ -484,3 +569,34 @@ return FALSE var/mob/living/carbon/human/npc/npc = candidate return !npc.npc_dormant() + +/// Shared instances of the per-family rule datums (voices, surrender rules, outfit tables, site layers), +/// one per type, made on first use. +GLOBAL_LIST_EMPTY(human_npc_singletons) + +/// The shared instance of `datum_type`. +/proc/human_npc_singleton(datum_type) + var/datum/instance = GLOB.human_npc_singletons[datum_type] + if(isnull(instance)) + instance = new datum_type() + GLOB.human_npc_singletons[datum_type] = instance + return instance + +/** + * The zone it was made in, for outfit tables: its spawn context's zone, else the zone of where it + * stands (a ship's overmap tile when aboard one), else green, the weakest. + */ +/mob/living/carbon/human/npc/proc/npc_spawn_zone() + return npc_spawn_context?.zone_type || get_zone_type_for_player_turf(get_turf(src)) || ZONE_GREEN + +/** + * Switches its space suit's thermal regulator on or off, as a player does with the suit's button. On only + * where it was dressed for vacuum or cold, and never for a corpse. Off while dormant (see the top of this + * file). + */ +/mob/living/carbon/human/npc/proc/npc_set_suit_heating(on) + if(on && (stat == DEAD || !(npc_site & (HUMAN_NPC_SITE_VACUUM | HUMAN_NPC_SITE_COLD)))) + return + var/obj/item/clothing/suit/space/suit = wear_suit + if(istype(suit) && suit.thermal_on != !!on) + suit.toggle_spacesuit(src) diff --git a/voidcrew/modules/human_npcs/human_npc_ai.dm b/voidcrew/modules/human_npcs/human_npc_ai.dm index 490b88dec4a..c8c133dedd3 100644 --- a/voidcrew/modules/human_npcs/human_npc_ai.dm +++ b/voidcrew/modules/human_npcs/human_npc_ai.dm @@ -11,12 +11,17 @@ * using the gun, and the empty magazine is dropped. Nothing refills from nowhere. When the spares * run out it draws another weapon it carries, or fights with what it holds. * - * tg's basic controller already stops the AI at any stat above CONSCIOUS (soft crit and worse) and - * while stunned, knocked out, in stamcrit or in stasis. It also stops while cuffed, which would keep - * tg's escape_captivity subtree from ever working the cuffs; get_able_to_run() below lets a cuffed or - * grabbed NPC struggle the way a player does (resist), and do_after()s pause it so a breakout isn't - * cut short by walking. The AI goes off with the body's dormancy: tg switches it off while no player - * is on the z-level, the same test human_npc.dm uses for Life(). + * tg's basic controller already stops the AI at any stat above CONSCIOUS and while stunned, knocked + * out, in stamcrit or in stasis. It also stops while cuffed, which would keep tg's escape_captivity + * subtree from ever working the cuffs; get_able_to_run() below lets a cuffed or grabbed NPC struggle the + * way a player does (resist), and do_after()s pause it so a breakout isn't cut short by walking. It also + * lets the AI run in soft crit, where only the soft-crit subtree plans (human_npc_soft_crit.dm). + * + * The AI goes off with the body's dormancy: tg switches it off while no player is on the z-level, the + * same test human_npc.dm uses for Life(). + * + * Planning order: soft crit (crawl, plead, medipen), escape captivity, walk out (human_npc_states.dm), + * surrender (human_npc_surrender.dm), then find a target, arm, shoot, melee. * * Phase B (PR #500's pirate crews) can drop these subtrees into its own planning lists: * human_npc_arm, human_npc_shoot and human_npc_melee need only BB_BASIC_MOB_CURRENT_TARGET and @@ -36,7 +41,10 @@ ai_movement = /datum/ai_movement/basic_avoidance idle_behavior = /datum/idle_behavior/idle_random_walk/less_walking 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/simple_find_target, /datum/ai_planning_subtree/human_npc_arm, /datum/ai_planning_subtree/human_npc_shoot, @@ -56,23 +64,14 @@ UnregisterSignal(pawn, COMSIG_ATOM_WAS_ATTACKED) return ..() -// The basic controller's own check, except that cuffs don't stop us (see the top of this file). +// The basic controller's own check, except that cuffs and soft crit don't stop us (see the top of this +// file, and human_npc_soft_crit.dm). /datum/ai_controller/basic_controller/human_npc/get_able_to_run() - if(HAS_TRAIT(pawn, TRAIT_AI_PAUSED) || world.time < paused_until) - return AI_UNABLE_TO_RUN - var/mob/living/living_pawn = pawn - if(living_pawn.stat && !(ai_traits & CAN_ACT_WHILE_DEAD)) - return AI_UNABLE_TO_RUN - var/ignore_incap_flags = INCAPABLE_RESTRAINTS - if(ai_traits & CAN_ACT_IN_STASIS) - ignore_incap_flags |= INCAPABLE_STASIS - if(ai_traits & CAN_ACT_WHILE_GRABBED) - ignore_incap_flags |= INCAPABLE_GRAB - if(INCAPACITATED_IGNORING(living_pawn, ignore_incap_flags)) - return AI_UNABLE_TO_RUN - if((ai_traits & PAUSE_DURING_DO_AFTER) && LAZYLEN(living_pawn.do_afters)) - return AI_UNABLE_TO_RUN | AI_PREVENT_CANCEL_ACTIONS - return NONE + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/human_npc/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) /// Someone hit us: with nothing else to fight, fight them, if we can see them. /datum/ai_controller/basic_controller/human_npc/proc/on_pawn_attacked(mob/living/source, atom/attacker, attack_flags) diff --git a/voidcrew/modules/human_npcs/human_npc_barks.dm b/voidcrew/modules/human_npcs/human_npc_barks.dm new file mode 100644 index 00000000000..4aa4ea541e8 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_barks.dm @@ -0,0 +1,203 @@ +/** + * # Human NPC barks (N3) + * + * What a human NPC says when something real happens to its body. Every hook hangs on a tg state change, + * never on a stand-in state: entering crit (TRAIT_CRITICAL_CONDITION), leaving crit or getting up from + * stamcrit, handcuffs going on and coming off (TRAIT_RESTRAINED), starting to work at its cuffs (tg's + * cuff_resist(), COMSIG_MOB_REMOVING_CUFFS), breaking out of a grab or off someone's shoulders by + * resisting, giving up (human_npc_surrender.dm) and dying. + * + * A family supplies its lines with a /datum/human_npc_voice subtype on the NPC's npc_voice. A voice has + * spoken `lines` and third-person `emotes` ("collapses.") per kind (HUMAN_NPC_BARK_*, or any kind of the + * family's own: npc_bark("alert") works the same). Speech goes through say() without `forced`, so a gag or + * a mute works on an NPC as on a player, and tg whispers it in soft crit. In hard crit and after death it + * can't speak, so only the emote shows. One NPC barks at most once per voice `cooldown`; surrender and + * death always come through. A family that already has its own voice system (bounty criminals' + * bounty_say()) overrides npc_bark() instead. + * + * Death also plays the hyperviolence death throes (HV-21, hv_death_throes(), a shudder as the body + * falls). The deathgasp itself is tg's: carbons always give it. + */ + +/// Says or shows its line for `kind` (HUMAN_NPC_BARK_*). `force` skips the cooldown and the voice's chance. +/// Returns TRUE if it said or did anything. Override to voice a family another way. +/mob/living/carbon/human/npc/proc/npc_bark(kind, force = FALSE) + if(QDELETED(src) || !ispath(npc_voice, /datum/human_npc_voice)) + return FALSE + var/datum/human_npc_voice/voice = human_npc_singleton(npc_voice) + return voice.bark(src, kind, force) + +/** + * A family's lines. Lists are kind => list of strings. Emotes are third person without the name + * ("collapses.", "groans and tries to get up."). Keep them short and in character (kit rules, "Writing + * for players"): no mechanics, no hints. + */ +/datum/human_npc_voice + /// Spoken lines by kind + var/list/lines = list() + /// Emotes by kind, shown before the line, and alone when it can't speak + var/list/emotes = list() + /// Percent chance it barks at all, by kind. A kind not listed always barks (cooldown allowing). + var/list/chances = list() + /// Seconds between two barks from one NPC + var/cooldown = HUMAN_NPC_BARK_COOLDOWN + +/// The spoken lines for `kind`. Pleading falls back to the downed lines. +/datum/human_npc_voice/proc/lines_for(kind) + . = lines[kind] + if(!length(.) && kind == HUMAN_NPC_BARK_PLEAD) + . = lines[HUMAN_NPC_BARK_DOWNED] + +/// The emotes for `kind`. +/datum/human_npc_voice/proc/emotes_for(kind) + return emotes[kind] + +/datum/human_npc_voice/proc/bark(mob/living/carbon/human/npc/speaker, kind, force = FALSE) + if(!force && !COOLDOWN_FINISHED(speaker, npc_bark_cooldown)) + return FALSE + if(!force && (kind in chances) && !prob(chances[kind])) + return FALSE + var/list/acts = emotes_for(kind) + var/list/spoken = lines_for(kind) + var/can_talk = length(spoken) && human_npc_can_talk(speaker) + if(!length(acts) && !can_talk) + return FALSE + if(length(acts)) + speaker.visible_message(span_emote("[speaker] [pick(acts)]")) + if(can_talk) + INVOKE_ASYNC(speaker, TYPE_PROC_REF(/atom/movable, say), pick(spoken)) + speaker.npc_last_bark = kind + COOLDOWN_START(speaker, npc_bark_cooldown, cooldown) + return TRUE + +/// Whether `speaker` can speak out loud or whisper now. Never in hard crit: tg turns that into last words. +/proc/human_npc_can_talk(mob/living/speaker) + if(speaker.stat != CONSCIOUS && speaker.stat != SOFT_CRIT) + return FALSE + return speaker.can_speak() + +/** + * Relays the tg state changes the barks hang on to the NPC's own procs, so a family can override what + * happens (npc_on_downed() and so on) without touching the signals. An element, so a subtype can listen + * to the same signals on itself. + */ +/datum/element/human_npc_reactions + element_flags = ELEMENT_DETACH_ON_HOST_DESTROY + +/datum/element/human_npc_reactions/Attach(datum/target) + . = ..() + if(!ishumannpc(target)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), PROC_REF(on_crit_started)) + RegisterSignal(target, SIGNAL_REMOVETRAIT(TRAIT_CRITICAL_CONDITION), PROC_REF(on_crit_ended)) + RegisterSignal(target, COMSIG_LIVING_STATUS_REMOVED, PROC_REF(on_status_removed)) + RegisterSignal(target, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), PROC_REF(on_restrained)) + RegisterSignal(target, SIGNAL_REMOVETRAIT(TRAIT_RESTRAINED), PROC_REF(on_unrestrained)) + RegisterSignal(target, COMSIG_MOB_REMOVING_CUFFS, PROC_REF(on_working_cuffs)) + RegisterSignal(target, COMSIG_LIVING_RESIST, PROC_REF(on_resist)) + RegisterSignal(target, COMSIG_ATOM_NO_LONGER_PULLED, PROC_REF(on_let_go)) + RegisterSignal(target, COMSIG_LIVING_SET_BUCKLED, PROC_REF(on_buckled)) + RegisterSignal(target, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + +/datum/element/human_npc_reactions/Detach(datum/source) + UnregisterSignal(source, list( + SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_REMOVETRAIT(TRAIT_CRITICAL_CONDITION), + COMSIG_LIVING_STATUS_REMOVED, + SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), + SIGNAL_REMOVETRAIT(TRAIT_RESTRAINED), + COMSIG_MOB_REMOVING_CUFFS, + COMSIG_LIVING_RESIST, + COMSIG_ATOM_NO_LONGER_PULLED, + COMSIG_LIVING_SET_BUCKLED, + COMSIG_LIVING_DEATH, + )) + return ..() + +/datum/element/human_npc_reactions/proc/on_crit_started(mob/living/carbon/human/npc/source) + SIGNAL_HANDLER + if(source.stat != DEAD) + source.npc_on_downed() + +/datum/element/human_npc_reactions/proc/on_crit_ended(mob/living/carbon/human/npc/source) + SIGNAL_HANDLER + // tg drops the trait on death too; only a living body that came out of crit has recovered. + if(source.stat != DEAD && !QDELING(source)) + source.npc_on_recovered() + +/datum/element/human_npc_reactions/proc/on_status_removed(mob/living/carbon/human/npc/source, datum/status_effect/effect) + SIGNAL_HANDLER + if(source.stat == DEAD || QDELING(source)) + return + if(istype(effect, /datum/status_effect/incapacitating/stamcrit)) + if(source.stat == CONSCIOUS) + source.npc_on_recovered() + return + if(istype(effect, /datum/status_effect/incapacitating/paralyzed) && source.npc_surrendering) + source.npc_surrender_ended() + +/datum/element/human_npc_reactions/proc/on_restrained(mob/living/carbon/human/npc/source) + SIGNAL_HANDLER + if(source.handcuffed && source.stat != DEAD) + source.npc_bark(HUMAN_NPC_BARK_CUFFED) + +/datum/element/human_npc_reactions/proc/on_unrestrained(mob/living/carbon/human/npc/source) + SIGNAL_HANDLER + if(source.stat != DEAD && !QDELING(source)) + source.npc_bark(HUMAN_NPC_BARK_UNCUFFED) + +/datum/element/human_npc_reactions/proc/on_working_cuffs(mob/living/carbon/human/npc/source, obj/item/cuffs) + SIGNAL_HANDLER + source.npc_bark(HUMAN_NPC_BARK_SLIPPING) + +/// Notes who held it as it resisted. tg's resist_grab() lets go in the same call when it works. +/datum/element/human_npc_reactions/proc/on_resist(mob/living/carbon/human/npc/source) + SIGNAL_HANDLER + var/atom/movable/holder = source.pulledby || (ismob(source.buckled) ? source.buckled : null) + if(isnull(holder) || HAS_TRAIT(source, TRAIT_RESTRAINED)) + return + source.npc_resisted_hold = WEAKREF(holder) + source.npc_resisted_at = world.time + +/datum/element/human_npc_reactions/proc/on_let_go(mob/living/carbon/human/npc/source, atom/movable/puller) + SIGNAL_HANDLER + source.npc_check_pulled_free(puller) + +/datum/element/human_npc_reactions/proc/on_buckled(mob/living/carbon/human/npc/source, atom/movable/new_buckled) + SIGNAL_HANDLER + // Squirming off a carrier's shoulders can take a moment; a grab breaks in the same call. + if(isnull(new_buckled)) + source.npc_check_pulled_free(within = 6 SECONDS) + +/datum/element/human_npc_reactions/proc/on_death(mob/living/carbon/human/npc/source, gibbed) + SIGNAL_HANDLER + source.npc_surrendering = FALSE + if(gibbed) + return + source.hv_death_throes() + source.npc_bark(HUMAN_NPC_BARK_DEATH, force = TRUE) + +// ===== What it does when those things happen. Families override these. ===== + +/// It entered crit. +/mob/living/carbon/human/npc/proc/npc_on_downed() + npc_surrendering = FALSE + npc_bark(HUMAN_NPC_BARK_DOWNED, force = TRUE) + npc_next_plead = world.time + HUMAN_NPC_PLEAD_INTERVAL + SEND_SIGNAL(src, COMSIG_HUMAN_NPC_DOWNED) + +/// It left crit, or got up from stamcrit. +/mob/living/carbon/human/npc/proc/npc_on_recovered() + npc_bark(HUMAN_NPC_BARK_RECOVER) + SEND_SIGNAL(src, COMSIG_HUMAN_NPC_RECOVERED) + +/// It stopped being held. If it resisted its way out (this tick, or `within` that long ago), it pulled free. +/mob/living/carbon/human/npc/proc/npc_check_pulled_free(atom/movable/holder, within = 0) + if(!npc_resisted_at || world.time > npc_resisted_at + within) + return + var/atom/movable/resisted = npc_resisted_hold?.resolve() + npc_resisted_hold = null + npc_resisted_at = 0 + if(holder && resisted != holder) + return + npc_bark(HUMAN_NPC_BARK_PULLED_FREE) diff --git a/voidcrew/modules/human_npcs/human_npc_gear.dm b/voidcrew/modules/human_npcs/human_npc_gear.dm index ff85d481fc5..0ae34c58312 100644 --- a/voidcrew/modules/human_npcs/human_npc_gear.dm +++ b/voidcrew/modules/human_npcs/human_npc_gear.dm @@ -26,11 +26,16 @@ // // The exceptions, explicit and tested: // * GLOB.human_npc_not_gear: items whose loss would break something outside the NPC (a ship key, a -// contract's recovery item, bounty proof). Never locked; they drop at the NPC's feet when it dies. -// * GLOB.human_npc_consumable_gear: things an NPC uses up (grenades, bottles, syringe darts, medipens, -// pills, thrown spears, stars and bolas). They may leave its body the way a person's would: thrown, -// fired, dropped or knocked out of its hand. They still never become loot: in anyone's hands or -// bags but a human NPC's they are deleted, and loose ones go when the NPC that carried them does. +// contract's recovery item, bounty proof, a paper someone has to read). Never locked; they drop at +// the NPC's feet when it dies. +// * GLOB.human_npc_consumable_gear: things an NPC uses up (grenades, barrier grenades, bottles, syringe +// darts, medipens, pills, bandages and other medical stacks, thrown spears, stars and bolas). They +// may leave its body the way a person's would: thrown, fired, dropped or knocked out of its hand. +// They still never become loot: in anyone's hands or bags but a human NPC's they are deleted, and +// loose ones go when the NPC that carried them does. +// * NPCs with npc_gear_locked = FALSE lock nothing at all (owner ruling OC-11: prisoners' jumpsuits, +// contraband and shivs are ordinary items, so shakedowns, confiscation and uniform swaps work). Their +// guards keep the lock. /// Items an NPC carries that aren't its gear: never locked, dropped when it dies. Their loss would /// break something outside the NPC (the cryo pod refuses to delete the first two for the same reason). @@ -38,6 +43,7 @@ GLOBAL_LIST_INIT(human_npc_not_gear, typecacheof(list( /obj/item/ship_key, /obj/item/mission_recovery, /obj/item/bounty_proof, + /obj/item/paper, ))) /// Items an NPC uses up: they leave its body like a person's would, but never become loot. @@ -47,6 +53,7 @@ GLOBAL_LIST_INIT(human_npc_consumable_gear, typecacheof(list( /obj/item/reagent_containers/syringe, /obj/item/reagent_containers/hypospray/medipen, /obj/item/reagent_containers/applicator/pill, + /obj/item/stack/medical, /obj/item/spear, /obj/item/throwing_star, /obj/item/restraints/legcuffs/bola, @@ -189,11 +196,12 @@ GLOBAL_LIST_INIT(human_npc_consumable_gear, typecacheof(list( /** * Locks `item` to this NPC's body, or issues it as a consumable, by the lists above. Items that aren't - * gear, body parts, organs, ammunition casings and abstract items are left alone. Use it for anything - * handed to an NPC after it spawned. Returns TRUE if the item is now locked or issued. + * gear, body parts, organs, ammunition casings and abstract items are left alone, and so is everything + * on an NPC whose gear isn't locked (npc_gear_locked). Use it for anything handed to an NPC after it + * spawned. Returns TRUE if the item is now locked or issued. */ /mob/living/carbon/human/npc/proc/npc_lock_item(obj/item/item) - if(QDELETED(item) || isbodypart(item) || isorgan(item) || isammocasing(item) || (item.item_flags & ABSTRACT)) + if(!npc_gear_locked || QDELETED(item) || isbodypart(item) || isorgan(item) || isammocasing(item) || (item.item_flags & ABSTRACT)) return FALSE if(is_type_in_typecache(item, GLOB.human_npc_not_gear)) return FALSE diff --git a/voidcrew/modules/human_npcs/human_npc_outfits.dm b/voidcrew/modules/human_npcs/human_npc_outfits.dm new file mode 100644 index 00000000000..405c1437449 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_outfits.dm @@ -0,0 +1,231 @@ +/** + * # Human NPC outfits by zone, rank and site (N7) + * + * Toughness for human NPCs comes from what they wear (defaults 1.5), so harder zones get better kit, + * not more health: rags in green, a vest in yellow, a vest and helmet in red, better guns as you go. + * + * An outfit table (/datum/human_npc_outfit_table) picks a /datum/outfit for one NPC from weighted lists + * per zone and rank. Use one by setting npc_outfit_table on the NPC type, or by passing the table + * typepath as spawn_human_npc(outfit =). The zone is the spawn context's (spawn_npc_in_zone()), else + * the zone of the spawn turf (a ship's overmap tile when aboard one), else green. A queued order whose + * zone isn't known yet waits for it in the queue, up to HUMAN_NPC_ZONE_WAIT (human_npc_spawning.dm). + * + * Whatever the outfit, the site layers then make sure the NPC can live where it is made (decision D5 + * and defaults 1.12: real suits and tanks, never immunity traits). human_npc_site_at() reads the air on + * the spawn turf: + * - HUMAN_NPC_SITE_VACUUM (too little pressure): if the outfit's suit and helmet aren't both sealed, + * the table's vacuum_layer replaces them with a sealed suit and helmet; it also gets a tank. + * - HUMAN_NPC_SITE_BAD_AIR (pressure fine, air not): a breath mask if nothing it wears takes internals, + * and a tank. + * - HUMAN_NPC_SITE_COLD: the table's cold_layer fills the slots the outfit leaves empty with cold-weather + * wear and swaps the shoes for winter boots. An outfit meant for ice planets should bring its own. + * A table's always_site, and the NPC's npc_always_site, add sites regardless of the air (boarders that + * cross vacuum). Species that need none of it (skeletons) are left alone. The NPC then opens its tank + * and switches on its suit's thermal regulator, as a player would. + */ + +/** + * Weighted outfits for one family. Lists are outfit typepath => weight. A zone left null uses the zone + * below it; elites and bosses fall back to the zone's regulars. Override outfits_for() to pick by + * anything else the NPC carries (a bounty's tier, a faction's role). + */ +/datum/human_npc_outfit_table + /// Regulars in the green zone + var/list/green + /// Regulars in the yellow zone. Null: green's. + var/list/yellow + /// Regulars in the red zone. Null: yellow's. + var/list/red + /// Elites in any zone. Null: the zone's regulars. + var/list/elite + /// Bosses in any zone. Null: elite's. + var/list/boss + /// HUMAN_NPC_SITE_* flags every NPC it dresses gets, whatever the air where it spawns + var/always_site = NONE + /// What goes over an outfit that isn't sealed, in vacuum (a /datum/human_npc_layer typepath) + var/datum/human_npc_layer/vacuum_layer = /datum/human_npc_layer/eva + /// What goes over an outfit in the cold + var/datum/human_npc_layer/cold_layer = /datum/human_npc_layer/cold + +/// The weighted outfits `npc` may get in `zone` (ZONE_*). +/datum/human_npc_outfit_table/proc/outfits_for(mob/living/carbon/human/npc/npc, zone) + var/rank = npc?.npc_rank || HUMAN_NPC_RANK_REGULAR + if(rank >= HUMAN_NPC_RANK_BOSS && length(boss)) + return boss + if(rank >= HUMAN_NPC_RANK_ELITE && length(elite)) + return elite + if(zone == ZONE_RED && length(red)) + return red + if(zone >= ZONE_YELLOW && length(yellow)) + return yellow + return green + +/// One outfit typepath for `npc` in `zone`, or null if the table has nothing for it. +/datum/human_npc_outfit_table/proc/pick_outfit(mob/living/carbon/human/npc/npc, zone) + var/list/choices = outfits_for(npc, zone) + if(!length(choices)) + return null + return pick_weight(choices) + +/** + * What `spot` asks of a person standing there: HUMAN_NPC_SITE_* flags read from its air. Space and + * anything under tg's hazard pressure is vacuum; too little oxygen or a lungful of plasma or carbon + * dioxide is bad air; air below HUMAN_NPC_SITE_COLD_TEMP is cold. + */ +/proc/human_npc_site_at(turf/spot) + if(isnull(spot)) + return NONE + var/datum/gas_mixture/air = spot.return_air() + if(isnull(air)) + return HUMAN_NPC_SITE_VACUUM | HUMAN_NPC_SITE_COLD + . = NONE + var/pressure = air.return_pressure() + if(pressure < HAZARD_LOW_PRESSURE) + . |= HUMAN_NPC_SITE_VACUUM + else + var/oxygen = human_npc_partial_pressure(air, /datum/gas/oxygen, pressure) + var/plasma = human_npc_partial_pressure(air, /datum/gas/plasma, pressure) + var/carbon_dioxide = human_npc_partial_pressure(air, /datum/gas/carbon_dioxide, pressure) + if(oxygen < HUMAN_NPC_SITE_MIN_OXYGEN || plasma > 0.5 || carbon_dioxide > 10) + . |= HUMAN_NPC_SITE_BAD_AIR + if(air.temperature < HUMAN_NPC_SITE_COLD_TEMP) + . |= HUMAN_NPC_SITE_COLD + +/// The partial pressure of `gas_type` in `air`, in kPa, given the mix's total `pressure`. +/proc/human_npc_partial_pressure(datum/gas_mixture/air, gas_type, pressure) + var/list/entry = air.gases[gas_type] + var/total = air.total_moles() + if(!entry || !total) + return 0 + return pressure * entry[MOLES] / total + +/** + * Its outfit, with whatever the site needs layered on (see the top of this file). Returns `outfit` + * unchanged when nothing is needed, else an outfit instance (an instance passed in is changed in place). + */ +/mob/living/carbon/human/npc/proc/npc_fit_outfit_to_site(outfit, datum/human_npc_outfit_table/table) + npc_site = human_npc_site_at(get_turf(src)) | npc_always_site | table?.always_site + var/needs_seal = (npc_site & HUMAN_NPC_SITE_VACUUM) && !HAS_TRAIT(src, TRAIT_RESISTLOWPRESSURE) + var/needs_air = (npc_site & (HUMAN_NPC_SITE_VACUUM | HUMAN_NPC_SITE_BAD_AIR)) && !HAS_TRAIT(src, TRAIT_NOBREATH) + var/needs_warmth = (npc_site & HUMAN_NPC_SITE_COLD) && !HAS_TRAIT(src, TRAIT_RESISTCOLD) + if(!needs_seal && !needs_air && !needs_warmth) + return outfit + var/datum/outfit/dressed = ispath(outfit) ? new outfit() : outfit + if(!istype(dressed)) + return outfit + if(needs_seal && !human_npc_outfit_sealed(dressed)) + var/datum/human_npc_layer/seal = human_npc_singleton(table?.vacuum_layer || /datum/human_npc_layer/eva) + seal.apply(dressed) + if(needs_air && !human_npc_outfit_has_air(dressed)) + if(!human_npc_outfit_can_breathe(dressed)) + dressed.mask = /obj/item/clothing/mask/breath + human_npc_outfit_add_tank(dressed) + // A sealed suit with its heating on keeps out the cold of space; warm clothes go on where it isn't sealed. + if(needs_warmth && !(needs_seal && human_npc_outfit_sealed(dressed))) + var/datum/human_npc_layer/warmth = human_npc_singleton(table?.cold_layer || /datum/human_npc_layer/cold) + warmth.apply(dressed) + return dressed + +/// Whether the outfit's suit and helmet both hold pressure. +/proc/human_npc_outfit_sealed(datum/outfit/outfit) + var/obj/item/clothing/suit = outfit.suit + var/obj/item/clothing/head = outfit.head + if(!ispath(suit, /obj/item/clothing) || !ispath(head, /obj/item/clothing)) + return FALSE + return (initial(suit.clothing_flags) & STOPSPRESSUREDAMAGE) && (initial(head.clothing_flags) & STOPSPRESSUREDAMAGE) + +/// Whether it wears something it can breathe a tank through: a mask or a helmet that takes internals. +/proc/human_npc_outfit_can_breathe(datum/outfit/outfit) + var/obj/item/clothing/mask = outfit.mask + var/obj/item/clothing/head = outfit.head + if(ispath(mask, /obj/item/clothing) && (initial(mask.clothing_flags) & MASKINTERNALS)) + return TRUE + return ispath(head, /obj/item/clothing) && (initial(head.clothing_flags) & HEADINTERNALS) + +/// Whether it carries a tank where it can open it and can breathe through something. +/proc/human_npc_outfit_has_air(datum/outfit/outfit) + if(!human_npc_outfit_can_breathe(outfit)) + return FALSE + for(var/carried in list(outfit.suit_store, outfit.back, outfit.belt, outfit.l_pocket, outfit.r_pocket)) + if(ispath(carried, /obj/item/tank/internals)) + return TRUE + return FALSE + +/// Gives the outfit an air tank and opens it: in a space suit's storage, else a pocket, else the back +/// or the belt. Returns TRUE if it found a place. +/proc/human_npc_outfit_add_tank(datum/outfit/outfit) + if(isnull(outfit.suit_store) && ispath(outfit.suit, /obj/item/clothing/suit/space)) + outfit.suit_store = /obj/item/tank/internals/oxygen + outfit.internals_slot = ITEM_SLOT_SUITSTORE + return TRUE + if(isnull(outfit.l_pocket)) + outfit.l_pocket = /obj/item/tank/internals/emergency_oxygen/double + outfit.internals_slot = ITEM_SLOT_LPOCKET + return TRUE + if(isnull(outfit.r_pocket)) + outfit.r_pocket = /obj/item/tank/internals/emergency_oxygen/double + outfit.internals_slot = ITEM_SLOT_RPOCKET + return TRUE + if(isnull(outfit.back)) + outfit.back = /obj/item/tank/internals/oxygen + outfit.internals_slot = ITEM_SLOT_BACK + return TRUE + if(isnull(outfit.belt)) + outfit.belt = /obj/item/tank/internals/emergency_oxygen/double + outfit.internals_slot = ITEM_SLOT_BELT + return TRUE + stack_trace("[outfit.type] has nowhere to carry an air tank") + return FALSE + +/** + * Clothing layered over an outfit for a site. Slots it sets are filled where the outfit leaves them + * empty, or always for the slots in `replace_slots` (ITEM_SLOT_* flags). Anything in suit storage that + * a replaced suit can't hold moves to the backpack, if there is one. + */ +/datum/human_npc_layer + var/suit + var/head + var/mask + var/gloves + var/shoes + var/neck + /// ITEM_SLOT_* flags of slots it takes over even when the outfit fills them + var/replace_slots = NONE + +/datum/human_npc_layer/proc/apply(datum/outfit/outfit) + if(suit && (isnull(outfit.suit) || (replace_slots & ITEM_SLOT_OCLOTHING))) + if(outfit.suit_store && outfit.suit != suit) + if(outfit.back) + LAZYADDASSOC(outfit.backpack_contents, outfit.suit_store, 1) + outfit.suit_store = null + outfit.suit = suit + if(head && (isnull(outfit.head) || (replace_slots & ITEM_SLOT_HEAD))) + outfit.head = head + if(mask && (isnull(outfit.mask) || (replace_slots & ITEM_SLOT_MASK))) + outfit.mask = mask + if(gloves && (isnull(outfit.gloves) || (replace_slots & ITEM_SLOT_GLOVES))) + outfit.gloves = gloves + if(shoes && (isnull(outfit.shoes) || (replace_slots & ITEM_SLOT_FEET))) + outfit.shoes = shoes + if(neck && (isnull(outfit.neck) || (replace_slots & ITEM_SLOT_NECK))) + outfit.neck = neck + +/// A plain sealed EVA suit and helmet, the fallback for any family in vacuum. +/datum/human_npc_layer/eva + suit = /obj/item/clothing/suit/space/eva + head = /obj/item/clothing/head/helmet/space/eva + replace_slots = ITEM_SLOT_OCLOTHING | ITEM_SLOT_HEAD + +/// Pirates' own space suit: lighter, armoured, and it holds a gun. +/datum/human_npc_layer/eva/pirate + suit = /obj/item/clothing/suit/space/pirate + head = /obj/item/clothing/head/helmet/space/pirate + +/// Cold-weather wear for whatever an outfit leaves bare. +/datum/human_npc_layer/cold + suit = /obj/item/clothing/suit/hooded/wintercoat + head = /obj/item/clothing/head/costume/ushanka + gloves = /obj/item/clothing/gloves/color/black + shoes = /obj/item/clothing/shoes/winterboots + neck = /obj/item/clothing/neck/scarf + replace_slots = ITEM_SLOT_FEET diff --git a/voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm b/voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm new file mode 100644 index 00000000000..c2bec158d1e --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm @@ -0,0 +1,384 @@ +/** + * # Pirate faction outfits for human NPCs (N7, for phase B) + * + * The faction looks are today's faction pirate corpse outfits (voidcrew/modules/npc_ships/code/ + * faction_pirates/faction_pirate_corpses.dm), so a human pirate looks like the pirate players already + * know. These subtypes add what the basic mob only drew: the real weapon in hand, and the real spare + * magazines, shells or backup knife it fights on with (human_npc_ai.dm reloads from them the way a player + * does). Energy guns can't be reloaded by hand, so energy gunners carry a knife. + * + * Guns per faction follow each faction's crew_gun_weights (faction_pirate_mobs.dm); melee weapons, + * captains and bosses hold what the basic mobs hold today. Bosses are a first pass for phase B. + * + * Tables (one per faction): /datum/human_npc_outfit_table/pirate/ dresses a mixed crew (melee + * and gunners, the faction's captain for elites and its boss for bosses); + * /datum/human_npc_outfit_table/pirate//gunners only gunners. A fixed melee crewman can use the + * melee outfit directly. In vacuum each faction gets its own space suit (vacuum_layer). + */ + +/datum/human_npc_outfit_table/pirate + vacuum_layer = /datum/human_npc_layer/eva/pirate + /// Melee crew outfits: outfit typepath => weight + var/list/melee + /// Gunner outfits: outfit typepath => weight, as the faction's crew guns + var/list/gunners + /// Dress only gunners + var/gunners_only = FALSE + +/datum/human_npc_outfit_table/pirate/New() + . = ..() + if(!isnull(green)) + return + green = gunners?.Copy() || list() + if(gunners_only) + return + for(var/outfit_type in melee) + green[outfit_type] = melee[outfit_type] + +// ===== SILVERSCALE ===== + +/datum/human_npc_layer/eva/silverscale + suit = /obj/item/clothing/suit/space/pirate/silverscale + head = /obj/item/clothing/head/helmet/space/pirate/tophat + +/datum/human_npc_outfit_table/pirate/silverscale + vacuum_layer = /datum/human_npc_layer/eva/silverscale + melee = list(/datum/outfit/piratecorpse/faction/silverscale/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/silverscale/ranged/energy_gun = 3, + /datum/outfit/piratecorpse/faction/silverscale/ranged/laser_rifle = 2, + /datum/outfit/piratecorpse/faction/silverscale/ranged/pistol = 3, + /datum/outfit/piratecorpse/faction/silverscale/ranged/shotgun = 1, + ) + elite = list(/datum/outfit/piratecorpse/faction/silverscale/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/silverscale/highlord/armed = 1) + +/datum/human_npc_outfit_table/pirate/silverscale/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/silverscale/melee/armed + name = "Human NPC: Silverscale Duelist" + r_hand = /obj/item/melee/energy/sword/pirate + +/datum/outfit/piratecorpse/faction/silverscale/ranged/energy_gun + name = "Human NPC: Silverscale Marksman (energy gun)" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/silverscale/ranged/laser_rifle + name = "Human NPC: Silverscale Marksman (laser rifle)" + r_hand = /obj/item/gun/energy/laser + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/silverscale/ranged/pistol + name = "Human NPC: Silverscale Marksman (pistol)" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/piratecorpse/faction/silverscale/ranged/shotgun + name = "Human NPC: Silverscale Marksman (shotgun)" + r_hand = /obj/item/gun/ballistic/shotgun/lethal + belt = /obj/item/storage/belt/bandolier + belt_contents = list(/obj/item/ammo_casing/shotgun/buckshot = 8) + +/datum/outfit/piratecorpse/faction/silverscale/captain/armed + name = "Human NPC: Silverscale Noble" + r_hand = /obj/item/melee/sabre + +/datum/outfit/piratecorpse/faction/silverscale/highlord/armed + name = "Human NPC: Silverscale Highlord" + r_hand = /obj/item/melee/energy/sword/pirate + +// ===== SKELETON ===== + +/datum/human_npc_layer/eva/pirate/bandana + head = /obj/item/clothing/head/helmet/space/pirate/bandana + +// Skeletons don't breathe and shrug off vacuum and cold (their species), so they rarely need the suit. +/datum/human_npc_outfit_table/pirate/skeleton + vacuum_layer = /datum/human_npc_layer/eva/pirate/bandana + melee = list(/datum/outfit/piratecorpse/faction/skeleton/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/skeleton/ranged/laser_rifle = 1, + /datum/outfit/piratecorpse/faction/skeleton/ranged/pistol = 2, + /datum/outfit/piratecorpse/faction/skeleton/ranged/smg = 2, + /datum/outfit/piratecorpse/faction/skeleton/ranged/shotgun = 3, + ) + elite = list(/datum/outfit/piratecorpse/faction/skeleton/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/skeleton/davyjones/armed = 1) + +/datum/human_npc_outfit_table/pirate/skeleton/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/skeleton/melee/armed + name = "Human NPC: Skeleton Swashbuckler" + r_hand = /obj/item/melee/energy/sword/pirate + +/datum/outfit/piratecorpse/faction/skeleton/ranged/laser_rifle + name = "Human NPC: Skeleton Gunner (laser rifle)" + r_hand = /obj/item/gun/energy/laser + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/skeleton/ranged/pistol + name = "Human NPC: Skeleton Gunner (pistol)" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/piratecorpse/faction/skeleton/ranged/smg + name = "Human NPC: Skeleton Gunner (SMG)" + r_hand = /obj/item/gun/ballistic/automatic/mini_uzi + l_pocket = /obj/item/ammo_box/magazine/uzim9mm + r_pocket = /obj/item/ammo_box/magazine/uzim9mm + +/datum/outfit/piratecorpse/faction/skeleton/ranged/shotgun + name = "Human NPC: Skeleton Gunner (shotgun)" + r_hand = /obj/item/gun/ballistic/shotgun/lethal + belt = /obj/item/storage/belt/bandolier + belt_contents = list(/obj/item/ammo_casing/shotgun/buckshot = 8) + +/datum/outfit/piratecorpse/faction/skeleton/captain/armed + name = "Human NPC: Skeleton Captain" + r_hand = /obj/item/claymore/cutlass + +/datum/outfit/piratecorpse/faction/skeleton/davyjones/armed + name = "Human NPC: Davy Jones" + r_hand = /obj/item/claymore/cutlass + +// ===== GREY TIDE ===== + +// Assistants in the station's grey EVA suits: slow, but sealed. +/datum/human_npc_outfit_table/pirate/grey + vacuum_layer = /datum/human_npc_layer/eva + melee = list(/datum/outfit/piratecorpse/faction/grey/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/grey/ranged/energy_gun = 3, + /datum/outfit/piratecorpse/faction/grey/ranged/laser_rifle = 3, + /datum/outfit/piratecorpse/faction/grey/ranged/smg = 1, + ) + elite = list(/datum/outfit/piratecorpse/faction/grey/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/grey/robust/armed = 1) + +/datum/human_npc_outfit_table/pirate/grey/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/grey/melee/armed + name = "Human NPC: Grey Tider" + r_hand = /obj/item/storage/toolbox/mechanical + +/datum/outfit/piratecorpse/faction/grey/ranged/energy_gun + name = "Human NPC: Grey Tider Gunner (energy gun)" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/grey/ranged/laser_rifle + name = "Human NPC: Grey Tider Gunner (laser rifle)" + r_hand = /obj/item/gun/energy/laser + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/grey/ranged/smg + name = "Human NPC: Grey Tider Gunner (SMG)" + r_hand = /obj/item/gun/ballistic/automatic/mini_uzi + l_pocket = /obj/item/ammo_box/magazine/uzim9mm + r_pocket = /obj/item/ammo_box/magazine/uzim9mm + +/datum/outfit/piratecorpse/faction/grey/captain/armed + name = "Human NPC: Tidemaster" + r_hand = /obj/item/storage/toolbox/syndicate + +/datum/outfit/piratecorpse/faction/grey/robust/armed + name = "Human NPC: The Robust One" + r_hand = /obj/item/storage/toolbox/mechanical/old/clean + +// ===== LUSTROUS ===== + +/datum/human_npc_outfit_table/pirate/lustrous + melee = list(/datum/outfit/piratecorpse/faction/lustrous/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/lustrous/ranged/energy_gun = 3, + /datum/outfit/piratecorpse/faction/lustrous/ranged/laser_rifle = 3, + /datum/outfit/piratecorpse/faction/lustrous/ranged/pistol = 1, + ) + elite = list(/datum/outfit/piratecorpse/faction/lustrous/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/lustrous/radiant/armed = 1) + +/datum/human_npc_outfit_table/pirate/lustrous/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/lustrous/melee/armed + name = "Human NPC: Lustrous Scintillant" + r_hand = /obj/item/switchblade + +/datum/outfit/piratecorpse/faction/lustrous/ranged/energy_gun + name = "Human NPC: Lustrous Coruscant (energy gun)" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/lustrous/ranged/laser_rifle + name = "Human NPC: Lustrous Coruscant (laser rifle)" + r_hand = /obj/item/gun/energy/laser + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/lustrous/ranged/pistol + name = "Human NPC: Lustrous Coruscant (pistol)" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/piratecorpse/faction/lustrous/captain/armed + name = "Human NPC: Lustrous Radiant" + r_hand = /obj/item/melee/energy/sword/pirate + +/datum/outfit/piratecorpse/faction/lustrous/radiant/armed + name = "Human NPC: The Radiant One" + r_hand = /obj/item/melee/energy/sword/pirate + +// ===== INTERDYNE ===== + +/datum/human_npc_layer/eva/interdyne + suit = /obj/item/clothing/suit/space/syndicate/black/med + head = /obj/item/clothing/head/helmet/space/syndicate/black/med + +/datum/human_npc_outfit_table/pirate/interdyne + vacuum_layer = /datum/human_npc_layer/eva/interdyne + melee = list(/datum/outfit/piratecorpse/faction/interdyne/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/interdyne/ranged/energy_gun = 2, + /datum/outfit/piratecorpse/faction/interdyne/ranged/pistol = 2, + /datum/outfit/piratecorpse/faction/interdyne/ranged/smg = 3, + /datum/outfit/piratecorpse/faction/interdyne/ranged/shotgun = 1, + ) + elite = list(/datum/outfit/piratecorpse/faction/interdyne/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/interdyne/prime/armed = 1) + +/datum/human_npc_outfit_table/pirate/interdyne/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/interdyne/melee/armed + name = "Human NPC: Interdyne Enforcer" + r_hand = /obj/item/scalpel + +/datum/outfit/piratecorpse/faction/interdyne/ranged/energy_gun + name = "Human NPC: Interdyne Pharmacist (energy gun)" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/interdyne/ranged/pistol + name = "Human NPC: Interdyne Pharmacist (pistol)" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/piratecorpse/faction/interdyne/ranged/smg + name = "Human NPC: Interdyne Pharmacist (SMG)" + r_hand = /obj/item/gun/ballistic/automatic/mini_uzi + l_pocket = /obj/item/ammo_box/magazine/uzim9mm + r_pocket = /obj/item/ammo_box/magazine/uzim9mm + +/datum/outfit/piratecorpse/faction/interdyne/ranged/shotgun + name = "Human NPC: Interdyne Pharmacist (shotgun)" + r_hand = /obj/item/gun/ballistic/shotgun/lethal + belt = /obj/item/storage/belt/bandolier + belt_contents = list(/obj/item/ammo_casing/shotgun/buckshot = 8) + +/datum/outfit/piratecorpse/faction/interdyne/captain/armed + name = "Human NPC: Interdyne Director" + r_hand = /obj/item/melee/energy/sword/pirate + +// Today's Director Prime shoots lasers from its body; the human one holds the gun, with its combat +// hypospray in a pocket for phase B's self-treatment. +/datum/outfit/piratecorpse/faction/interdyne/prime/armed + name = "Human NPC: Interdyne Director Prime" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + r_pocket = /obj/item/reagent_containers/hypospray/combat + +// ===== IRS ===== + +/datum/human_npc_layer/eva/irs + suit = /obj/item/clothing/suit/space/syndicate/black + head = /obj/item/clothing/head/helmet/space/syndicate/black + +/datum/human_npc_outfit_table/pirate/irs + vacuum_layer = /datum/human_npc_layer/eva/irs + melee = list(/datum/outfit/piratecorpse/faction/irs/melee/armed = 4) + gunners = list( + /datum/outfit/piratecorpse/faction/irs/ranged/energy_gun = 1, + /datum/outfit/piratecorpse/faction/irs/ranged/pistol = 3, + /datum/outfit/piratecorpse/faction/irs/ranged/smg = 2, + /datum/outfit/piratecorpse/faction/irs/ranged/shotgun = 2, + ) + elite = list(/datum/outfit/piratecorpse/faction/irs/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/irs/chief/armed = 1) + +/datum/human_npc_outfit_table/pirate/irs/gunners + gunners_only = TRUE + +// A real telescopic baton: it knocks people down, where the basic mob's only did brute. +/datum/outfit/piratecorpse/faction/irs/melee/armed + name = "Human NPC: IRS Enforcer" + r_hand = /obj/item/melee/baton/telescopic + +/datum/outfit/piratecorpse/faction/irs/ranged/energy_gun + name = "Human NPC: IRS Agent (energy gun)" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/irs/ranged/pistol + name = "Human NPC: IRS Agent (pistol)" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/piratecorpse/faction/irs/ranged/smg + name = "Human NPC: IRS Agent (SMG)" + r_hand = /obj/item/gun/ballistic/automatic/mini_uzi + l_pocket = /obj/item/ammo_box/magazine/uzim9mm + r_pocket = /obj/item/ammo_box/magazine/uzim9mm + +/datum/outfit/piratecorpse/faction/irs/ranged/shotgun + name = "Human NPC: IRS Agent (shotgun)" + r_hand = /obj/item/gun/ballistic/shotgun/lethal + belt = /obj/item/storage/belt/bandolier + belt_contents = list(/obj/item/ammo_casing/shotgun/buckshot = 8) + +/datum/outfit/piratecorpse/faction/irs/captain/armed + name = "Human NPC: IRS Head Auditor" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/irs/chief/armed + name = "Human NPC: IRS Chief Auditor" + r_hand = /obj/item/gun/energy/e_gun/lethal + l_pocket = /obj/item/knife/combat/survival + +// ===== MEDIEVAL ===== + +// Footsoldiers and the warlord hate guns (TRAIT_NOGUNS, set by phase B on their types); only the +// crossbowmen shoot, and their crossbows recharge by themselves. +/datum/human_npc_outfit_table/pirate/medieval + melee = list(/datum/outfit/piratecorpse/faction/medieval/melee/armed = 2) + gunners = list(/datum/outfit/piratecorpse/faction/medieval/ranged/crossbow = 1) + elite = list(/datum/outfit/piratecorpse/faction/medieval/captain/armed = 1) + boss = list(/datum/outfit/piratecorpse/faction/medieval/blackknight/armed = 1) + +/datum/human_npc_outfit_table/pirate/medieval/gunners + gunners_only = TRUE + +/datum/outfit/piratecorpse/faction/medieval/melee/armed + name = "Human NPC: Medieval Footsoldier" + r_hand = /obj/item/claymore/shortsword + +/datum/outfit/piratecorpse/faction/medieval/ranged/crossbow + name = "Human NPC: Medieval Crossbowman" + r_hand = /obj/item/gun/energy/recharge/ebow/large + l_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/medieval/captain/armed + name = "Human NPC: Medieval Warlord" + r_hand = /obj/item/fireaxe/boardingaxe + +/datum/outfit/piratecorpse/faction/medieval/blackknight/armed + name = "Human NPC: The Black Knight" + r_hand = /obj/item/claymore diff --git a/voidcrew/modules/human_npcs/human_npc_samples.dm b/voidcrew/modules/human_npcs/human_npc_samples.dm index 922a77c6bd2..216b76347d8 100644 --- a/voidcrew/modules/human_npcs/human_npc_samples.dm +++ b/voidcrew/modules/human_npcs/human_npc_samples.dm @@ -85,3 +85,78 @@ npc_rank = HUMAN_NPC_RANK_ELITE npc_damage_taken = 0.75 npc_death_cash = 300 + +// ===== Phase A2 samples: a voice, surrender rules and a zone table, for tests and admin spawns ===== + +/datum/human_npc_voice/sample + lines = list( + HUMAN_NPC_BARK_DOWNED = list("I'm hit! I'm hit!", "Not like this..."), + HUMAN_NPC_BARK_RECOVER = list("Still breathing.", "That all you've got?"), + HUMAN_NPC_BARK_PLEAD = list("Please... don't.", "I give, I give..."), + HUMAN_NPC_BARK_CUFFED = list("These are too tight."), + HUMAN_NPC_BARK_SLIPPING = list("Almost got it..."), + HUMAN_NPC_BARK_UNCUFFED = list("Hands free."), + HUMAN_NPC_BARK_PULLED_FREE = list("Hands off!"), + HUMAN_NPC_BARK_SURRENDER = list("Alright! I give up!", "Don't shoot!"), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + +/datum/human_npc_surrender/sample + +/mob/living/carbon/human/npc/sample + npc_voice = /datum/human_npc_voice/sample + +/// Gives up when it is losing, and stops shooting anyone already down: the shape of a criminal. +/mob/living/carbon/human/npc/sample/thug + npc_name = "Thug" + npc_outfit = /datum/outfit/human_npc/thug + npc_surrender = /datum/human_npc_surrender/sample + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + npc_death_cash = 100 + +/// Dressed by zone: rags in green, a vest in yellow, a vest, helmet and SMG in red. +/mob/living/carbon/human/npc/sample/raider + npc_name = "Raider" + npc_outfit_table = /datum/human_npc_outfit_table/sample_raiders + npc_death_cash = 100 + +/datum/human_npc_outfit_table/sample_raiders + green = list(/datum/outfit/human_npc/raider = 1) + yellow = list(/datum/outfit/human_npc/raider/vest = 1) + red = list(/datum/outfit/human_npc/raider/armoured = 1) + elite = list(/datum/outfit/human_npc/pirate_gunner = 1) + vacuum_layer = /datum/human_npc_layer/eva/pirate + +/datum/outfit/human_npc/raider + name = "Human NPC: raider" + uniform = /obj/item/clothing/under/costume/pirate + head = /obj/item/clothing/head/costume/pirate/bandana/armored + shoes = /obj/item/clothing/shoes/jackboots + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/human_npc/raider/vest + name = "Human NPC: raider in a vest" + suit = /obj/item/clothing/suit/armor/vest + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/human_npc/raider/armoured + name = "Human NPC: armoured raider" + suit = /obj/item/clothing/suit/armor/vest + head = /obj/item/clothing/head/helmet + r_hand = /obj/item/gun/ballistic/automatic/mini_uzi + l_pocket = /obj/item/ammo_box/magazine/uzim9mm + r_pocket = /obj/item/ammo_box/magazine/uzim9mm + +/// A pistol and somewhere to holster it, so it has somewhere to put the gun when it gives up. +/datum/outfit/human_npc/thug + name = "Human NPC: thug" + uniform = /obj/item/clothing/under/costume/pirate + shoes = /obj/item/clothing/shoes/jackboots + gloves = /obj/item/clothing/gloves/color/black + belt = /obj/item/storage/belt/holster + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/reagent_containers/hypospray/medipen diff --git a/voidcrew/modules/human_npcs/human_npc_soft_crit.dm b/voidcrew/modules/human_npcs/human_npc_soft_crit.dm new file mode 100644 index 00000000000..7fee891aba9 --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_soft_crit.dm @@ -0,0 +1,164 @@ +/** + * # Human NPCs in soft crit (N5) + * + * A person in soft crit is on the floor, can crawl, and (Voidcrew's soft-crit item use, PR #284, + * voidcrew/edits/modules/mob/mob.dm) can use what is on them at a quarter of the speed. An NPC does the + * same, and nothing else: it crawls away from whoever is nearest, pleads now and then, and injects a + * medipen it carries. Elites and bosses also fire wild from the floor, at the ground around their + * attacker, the way a player shooting from crit does. Everything goes through tg: the crawl is a crawl, + * the medipen is the player's "use in hand" (execute_mode()), the shot is a click with the gun. + * Brute packs and other medical stacks need a do_after, which tg refuses to anyone incapacitated, so a + * person in soft crit can't use them, and neither can an NPC. + * + * tg's basic controller turns the AI off at any stat above CONSCIOUS. human_npc_ai_able_to_run() lets a + * human NPC's AI run in soft crit; this subtree must then come first in its planning list, so nothing + * else plans while it is down. Hard crit, unconscious and dead still stop the AI. + * + * Who a family keeps attacking is npc_target_stat on the NPC (HUMAN_NPC_FINISHES_DOWNED or + * HUMAN_NPC_SPARES_DOWNED), written into BB_TARGET_MINIMUM_STAT at spawn. + */ + +/** + * The run check for any controller whose pawn is a human NPC: the basic controller's, except that cuffs + * don't stop it (so escape_captivity can work at them, phase A) and soft crit doesn't either (so the + * soft-crit subtree runs). A controller of another type (PR #500's pirate crew) calls this from its own + * get_able_to_run(). + */ +/proc/human_npc_ai_able_to_run(datum/ai_controller/controller) + var/mob/living/living_pawn = controller.pawn + if(HAS_TRAIT(living_pawn, TRAIT_AI_PAUSED) || world.time < controller.paused_until) + return AI_UNABLE_TO_RUN + if(living_pawn.stat && living_pawn.stat != SOFT_CRIT && !(controller.ai_traits & CAN_ACT_WHILE_DEAD)) + return AI_UNABLE_TO_RUN + var/ignore_incap_flags = INCAPABLE_RESTRAINTS + if(controller.ai_traits & CAN_ACT_IN_STASIS) + ignore_incap_flags |= INCAPABLE_STASIS + if(controller.ai_traits & CAN_ACT_WHILE_GRABBED) + ignore_incap_flags |= INCAPABLE_GRAB + // The same test the click code uses in soft crit: crit's own incapacitation doesn't count, a stun does. + if(living_pawn.incapacitated_except_softcrit(ignore_incap_flags)) + return AI_UNABLE_TO_RUN + if((controller.ai_traits & PAUSE_DURING_DO_AFTER) && LAZYLEN(living_pawn.do_afters)) + return AI_UNABLE_TO_RUN | AI_PREVENT_CANCEL_ACTIONS + return NONE + +/// For a human NPC controller's on_stat_changed(): falling into soft crit drops whatever it was doing +/// on its feet (a reload, a shot, a chase), so only the soft-crit subtree's behaviours run. +/proc/human_npc_ai_stat_changed(datum/ai_controller/controller, new_stat) + if(new_stat == SOFT_CRIT) + controller.CancelActions() + +/// In soft crit: treat itself, fire wild (elites and bosses), crawl away, plead. Must come first. +/datum/ai_planning_subtree/human_npc_soft_crit + +/datum/ai_planning_subtree/human_npc_soft_crit/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/npc = controller.pawn + if(npc.stat != SOFT_CRIT) + return + var/mob/living/carbon/human/npc/human_npc = ishumannpc(npc) ? npc : null + if(human_npc && world.time >= human_npc.npc_next_plead) + human_npc.npc_next_plead = world.time + HUMAN_NPC_PLEAD_INTERVAL + human_npc.npc_bark(HUMAN_NPC_BARK_PLEAD) + if(world.time >= controller.blackboard[BB_HUMAN_NPC_NEXT_TREAT]) + var/obj/item/treatment = human_npc_soft_crit_treatment(npc) + if(treatment) + controller.set_blackboard_key(BB_HUMAN_NPC_TREAT_ITEM, treatment) + controller.queue_behavior(/datum/ai_behavior/human_npc_treat_self, BB_HUMAN_NPC_TREAT_ITEM) + return SUBTREE_RETURN_FINISH_PLANNING + var/mob/living/threat = human_npc_nearest_threat(controller) + if(isnull(threat)) + return SUBTREE_RETURN_FINISH_PLANNING + controller.set_blackboard_key(BB_HUMAN_NPC_FLEE_FROM, threat) + if(human_npc?.npc_rank >= HUMAN_NPC_RANK_ELITE && !isnull(human_npc_loaded_gun(npc))) + controller.queue_behavior(/datum/ai_behavior/human_npc_fire_wild, BB_HUMAN_NPC_FLEE_FROM) + controller.queue_behavior(/datum/ai_behavior/run_away_from_target, BB_HUMAN_NPC_FLEE_FROM) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Something it carries that it can use on itself in soft crit: a medipen with something in it. +/proc/human_npc_soft_crit_treatment(mob/living/carbon/npc) + for(var/obj/item/reagent_containers/hypospray/medipen/pen in npc.get_all_contents_type(/obj/item/reagent_containers/hypospray/medipen)) + if(pen.used_up || !pen.reagents?.total_volume) + continue + if(npc.is_holding(pen) || human_npc_can_draw(npc, pen)) + return pen + return null + +/// The closest mob it can see that it would fight, whatever state that mob is in. Its target first. +/proc/human_npc_nearest_threat(datum/ai_controller/controller) + var/mob/living/npc = controller.pawn + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(isliving(target) && !QDELETED(target) && target.z == npc.z && can_see(npc, target, HUMAN_NPC_SIGHT_RANGE)) + return target + var/mob/living/closest + var/closest_distance = INFINITY + for(var/mob/living/other in oview(HUMAN_NPC_SIGHT_RANGE, npc)) + if(other.stat == DEAD || npc.faction_check_atom(other)) + continue + var/distance = get_dist(npc, other) + if(distance < closest_distance) + closest = other + closest_distance = distance + return closest + +/** + * Uses a medipen on itself the way a player in soft crit does: takes it in hand (holstering its gun if + * both hands are full), then "use in hand", then lets the empty pen fall. + */ +/datum/ai_behavior/human_npc_treat_self + action_cooldown = 1 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/human_npc_treat_self/perform(seconds_per_tick, datum/ai_controller/controller, item_key) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/reagent_containers/hypospray/medipen/pen = controller.blackboard[item_key] + if(!istype(pen) || QDELETED(pen) || human_npc_holding(pen) != npc) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(pen.used_up) + if(npc.is_holding(pen)) + npc.dropItemToGround(pen) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(!npc.is_holding(pen)) + if(!length(npc.get_empty_held_indexes())) + var/obj/item/gun/gun = human_npc_held_gun(npc) + if(isnull(gun) || !gun.equip_to_best_slot(npc)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_take_in_hand(npc, pen)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, pen)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(npc.next_move > world.time) + return AI_BEHAVIOR_DELAY + npc.execute_mode() + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/human_npc_treat_self/finish_action(datum/ai_controller/controller, succeeded, item_key) + . = ..() + controller.clear_blackboard_key(item_key) + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_TREAT, world.time + (succeeded ? 30 SECONDS : 10 SECONDS)) + +/** + * Fires its gun from the floor at the ground around `target`, not at the person: it can barely aim. + * Half the rate of a standing shooter. Only for elites and bosses (the soft-crit subtree decides). + */ +/datum/ai_behavior/human_npc_fire_wild + action_cooldown = 0.5 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/human_npc_fire_wild/perform(seconds_per_tick, datum/ai_controller/controller, target_key) + var/mob/living/carbon/npc = controller.pawn + var/atom/target = controller.blackboard[target_key] + if(npc.stat != SOFT_CRIT || QDELETED(target) || !can_see(npc, target, controller.blackboard[BB_HUMAN_NPC_FIRE_RANGE] || 7)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(isnull(gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(world.time < controller.blackboard[BB_HUMAN_NPC_NEXT_SHOT] || npc.next_move > world.time) + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/turf/aim = pick(RANGE_TURFS(1, get_turf(target))) + controller.ai_interact(target = aim, combat_mode = TRUE) + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_SHOT, world.time + 2 * (controller.blackboard[BB_HUMAN_NPC_SHOT_DELAY] || 3 SECONDS)) + return AI_BEHAVIOR_DELAY diff --git a/voidcrew/modules/human_npcs/human_npc_spawning.dm b/voidcrew/modules/human_npcs/human_npc_spawning.dm index aef592a9cb8..6be222b9dda 100644 --- a/voidcrew/modules/human_npcs/human_npc_spawning.dm +++ b/voidcrew/modules/human_npcs/human_npc_spawning.dm @@ -8,7 +8,12 @@ * The same subsystem cleans up corpses so they don't pile up over a 6 hour round: a body dead for * its npc_corpse_lifetime is deleted the first time a sweep finds no player near it, with the limbs * and organs it lost that lie unwatched on the floor. Bodies being dragged, carried or watched stay. - * An NPC whose own system cleans it up sets npc_corpse_lifetime = 0. + * An NPC whose own system cleans it up sets npc_corpse_lifetime = 0. It also deletes living NPCs that + * are walking out (npc_walk_out(), human_npc_states.dm) once they may go. + * + * An order dressed by an outfit table (human_npc_outfits.dm) needs its zone. While its spawn context + * is still resolving the zone (a planet level registering late), the order waits in the queue, up to + * HUMAN_NPC_ZONE_WAIT, and is then dressed for green. */ /** @@ -19,7 +24,8 @@ * * on_spawned - called with the new mob straight after it is built, the same tick * * look - overrides the type's npc_look: a /obj/effect/mob_spawn typepath, a /datum/bounty_look or a * /datum/bounty_record - * * outfit - overrides the type's npc_outfit + * * outfit - overrides the type's npc_outfit: an outfit typepath, or a /datum/human_npc_outfit_table + * typepath to pick one by zone and rank * * name - overrides the type's npc_name * * faction - overrides the type's faction list * * context - a /datum/npc_spawn_context (voidcrew/modules/npc_scaling/); the body is built through @@ -44,6 +50,12 @@ var/name var/list/faction var/datum/npc_spawn_context/context + /// The ZONE_* it is dressed for, once known (resolve_zone()) + var/zone + /// world.time it was queued + var/queued_at = 0 + /// world.time it may next look up a zone it is waiting for + var/next_zone_check = 0 /// Weakref to the mob once it is built var/datum/weakref/spawned_ref /// Set once fulfil() has run, whether or not it made a mob @@ -60,6 +72,7 @@ src.name = name src.faction = faction?.Copy() src.context = context + queued_at = world.time /datum/human_npc_order/Destroy(force) SShuman_npcs.queue -= src @@ -75,6 +88,38 @@ faction = null context = null +/// Whether its body is dressed from an outfit table, and so needs to know its zone first. +/datum/human_npc_order/proc/needs_zone() + if(ispath(outfit, /datum/human_npc_outfit_table)) + return TRUE + if(outfit) + return FALSE + var/mob/living/carbon/human/npc/npc_path = npc_type + return !initial(npc_path.npc_outfit) && ispath(initial(npc_path.npc_outfit_table), /datum/human_npc_outfit_table) + +/** + * The zone to dress it for, or null while it is still unknown and worth waiting for: its context's + * zone, else the zone of its spawn turf (a ship's overmap tile when aboard one). `now` settles for + * green instead of waiting, as does an order that has waited HUMAN_NPC_ZONE_WAIT. + */ +/datum/human_npc_order/proc/resolve_zone(now = TRUE) + if(!isnull(zone)) + return zone + zone = context?.zone_type || get_zone_type_for_player_turf(where) + if(isnull(zone) && (now || isnull(context) || world.time >= queued_at + HUMAN_NPC_ZONE_WAIT)) + zone = ZONE_GREEN + return zone + +/// Whether it can be built now: it doesn't need a zone, or its zone is known. A waiting order looks +/// its zone up at most once a second. +/datum/human_npc_order/proc/ready() + if(!isnull(zone) || !needs_zone()) + return TRUE + if(world.time < next_zone_check) + return FALSE + next_zone_check = world.time + 1 SECONDS + return !isnull(resolve_zone(now = FALSE)) + /// Builds the NPC now. Returns it, or null if it couldn't be built. /datum/human_npc_order/proc/fulfil() if(done) @@ -118,9 +163,15 @@ SUBSYSTEM_DEF(human_npcs) var/spawned_total = 0 /// Corpses cleaned up this round var/faded_total = 0 + /// Living NPCs walking out (npc_walk_out()) + var/list/mob/living/carbon/human/npc/leavers = list() + /// world.time of the next check of the leavers + var/next_leaver_sweep = 0 + /// NPCs that walked out this round + var/left_total = 0 /datum/controller/subsystem/human_npcs/stat_entry(msg) - msg = "Q:[length(queue)]|Made:[spawned_total]|Bodies:[length(corpses)]|Faded:[faded_total]" + msg = "Q:[length(queue)]|Made:[spawned_total]|Bodies:[length(corpses)]|Faded:[faded_total]|Leaving:[length(leavers)]|Left:[left_total]" return ..() /datum/controller/subsystem/human_npcs/fire(resumed = FALSE) @@ -128,6 +179,11 @@ SUBSYSTEM_DEF(human_npcs) process_spawns(HUMAN_NPC_SPAWNS_PER_FIRE) if(MC_TICK_CHECK) return + if(length(leavers) && world.time >= next_leaver_sweep) + next_leaver_sweep = world.time + HUMAN_NPC_LEAVER_SWEEP_INTERVAL + sweep_leavers() + if(MC_TICK_CHECK) + return if(!length(corpse_run)) if(world.time < next_sweep || !length(corpses)) return @@ -147,17 +203,33 @@ SUBSYSTEM_DEF(human_npcs) if(deleted >= HUMAN_NPC_CORPSE_DELETES_PER_FIRE || MC_TICK_CHECK) return -/// Builds up to `budget` queued NPCs. Returns how many it built. +/// Builds up to `budget` queued NPCs, oldest first, passing over orders still waiting for their zone. +/// Returns how many it built. /datum/controller/subsystem/human_npcs/proc/process_spawns(budget = HUMAN_NPC_SPAWNS_PER_FIRE) . = 0 - while(length(queue) && . < budget) - var/datum/human_npc_order/order = queue[1] - queue.Cut(1, 2) + var/index = 1 + while(index <= length(queue) && . < budget) + var/datum/human_npc_order/order = queue[index] if(QDELETED(order) || order.done) + queue.Cut(index, index + 1) + continue + if(!order.ready()) + index++ continue + queue.Cut(index, index + 1) if(order.fulfil()) .++ +/// Deletes the NPCs walking out that may go now (npc_may_vanish()). +/datum/controller/subsystem/human_npcs/proc/sweep_leavers() + for(var/mob/living/carbon/human/npc/leaver as anything in leavers.Copy()) + if(QDELETED(leaver) || leaver.stat == DEAD) + leavers -= leaver + continue + if(leaver.npc_may_vanish()) + leaver.npc_vanish() + left_total++ + /datum/controller/subsystem/human_npcs/proc/track_corpse(mob/living/carbon/human/npc/body) corpses |= body diff --git a/voidcrew/modules/human_npcs/human_npc_states.dm b/voidcrew/modules/human_npcs/human_npc_states.dm new file mode 100644 index 00000000000..279cc850d6e --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_states.dm @@ -0,0 +1,108 @@ +/** + * # Shared state rules for human NPCs + * + * N4, out of the fight: one test for "this body isn't fighting any more", read from tg state, used by + * boarding waves and the pirate pool slot (owner ruling OC-7), hull turrets, bounty pay, and AI morale. + * + * N8, walking out: a living NPC that leaves (a goon running off, a decoy heading for the lift, a crewman + * abandoning a fight) walks to its exit for real. Shots land, wounds slow it, cuffs and grabs stop it. + * It is deleted only when it stands on its exit tile, or when no player is near enough to see it + * (human_npc_watched()), and never while it is down, cuffed, dragged, carried or buckled. There is no + * godmode beam. SShuman_npcs checks the NPCs that are leaving once a second. + */ + +/** + * Whether `body` is out of the fight: dead, in crit (tg's TRAIT_CRITICAL_CONDITION, soft or hard, + * asleep or not), or restrained (handcuffs, a straitjacket). Stamcrit and stuns pass, so they don't + * count. Works on any mob; a basic mob is out only once dead. + */ +/proc/human_npc_out_of_fight(mob/living/body) + if(QDELETED(body) || body.stat == DEAD) + return TRUE + return HAS_TRAIT(body, TRAIT_CRITICAL_CONDITION) || HAS_TRAIT(body, TRAIT_RESTRAINED) + +// ===== Walking out ===== + +/** + * Sends it walking to `exit` (an atom; its turf is used). It is deleted on that tile, or wherever nobody + * can see it (npc_may_vanish()). `on_left`, if given, is called with it just before it goes, after + * COMSIG_HUMAN_NPC_LEFT. Call npc_stop_leaving() to call it back. + */ +/mob/living/carbon/human/npc/proc/npc_walk_out(atom/exit, datum/callback/on_left) + var/turf/exit_turf = get_turf(exit) + if(isnull(exit_turf) || stat == DEAD) + return FALSE + npc_exit = exit_turf + npc_on_left = on_left + SShuman_npcs.leavers |= src + if(istype(ai_controller)) + ai_controller.set_blackboard_key(BB_HUMAN_NPC_EXIT, exit_turf) + // The way out may be round corners: walk it with a pathfinder, as a person would. + human_npc_set_ai_movement(ai_controller, /datum/ai_movement/jps) + return TRUE + +/// Calls off its walk out. +/mob/living/carbon/human/npc/proc/npc_stop_leaving() + npc_exit = null + npc_on_left = null + SShuman_npcs.leavers -= src + if(istype(ai_controller)) + ai_controller.clear_blackboard_key(BB_HUMAN_NPC_EXIT) + human_npc_set_ai_movement(ai_controller, initial(ai_controller.ai_movement)) + +/** + * Whether a living NPC that is leaving may be deleted now: up and free (not in crit, not cuffed, not + * stunned, not dragged, carried or buckled, standing on a turf), and either on its exit tile or out of + * sight of every player. + */ +/mob/living/carbon/human/npc/proc/npc_may_vanish() + if(isnull(npc_exit) || stat != CONSCIOUS || human_npc_out_of_fight(src)) + return FALSE + if(INCAPACITATED_IGNORING(src, NONE) || pulledby || buckled || !isturf(loc)) + return FALSE + return get_turf(src) == npc_exit || !human_npc_watched(src) + +/// Switches a controller's movement type, stopping any walk the old one was doing. +/proc/human_npc_set_ai_movement(datum/ai_controller/controller, datum/ai_movement/movement_type) + if(!ispath(movement_type, /datum/ai_movement)) + return + if(controller.ai_movement?.moving_controllers[controller]) + controller.ai_movement.stop_moving_towards(controller) + controller.change_ai_movement_type(movement_type) + +/// It has left: what it carries that isn't gear drops where it stood, and it is deleted with its kit. +/mob/living/carbon/human/npc/proc/npc_vanish() + SShuman_npcs.leavers -= src + SEND_SIGNAL(src, COMSIG_HUMAN_NPC_LEFT) + npc_on_left?.Invoke(src) + npc_on_left = null + npc_drop_not_gear() + qdel(src) + +/// While it has somewhere to walk out to, it walks there and does nothing else. +/datum/ai_planning_subtree/human_npc_walk_out + +/datum/ai_planning_subtree/human_npc_walk_out/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/npc = controller.pawn + if(npc.stat != CONSCIOUS || !controller.blackboard_key_exists(BB_HUMAN_NPC_EXIT)) + return + controller.queue_behavior(/datum/ai_behavior/human_npc_walk_out, BB_HUMAN_NPC_EXIT) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Walks to the exit. SShuman_npcs deletes it when it may go (npc_may_vanish()). +/datum/ai_behavior/human_npc_walk_out + required_distance = 0 + action_cooldown = 1 SECONDS + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/human_npc_walk_out/setup(datum/ai_controller/controller, exit_key) + . = ..() + var/turf/exit = controller.blackboard[exit_key] + if(isnull(exit)) + return FALSE + set_movement_target(controller, exit) + +/datum/ai_behavior/human_npc_walk_out/perform(seconds_per_tick, datum/ai_controller/controller, exit_key) + if(!controller.blackboard_key_exists(exit_key)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY diff --git a/voidcrew/modules/human_npcs/human_npc_surrender.dm b/voidcrew/modules/human_npcs/human_npc_surrender.dm new file mode 100644 index 00000000000..93c8f3b7f9b --- /dev/null +++ b/voidcrew/modules/human_npcs/human_npc_surrender.dm @@ -0,0 +1,161 @@ +/** + * # Human NPC surrender (N2) + * + * Giving up is an AI choice followed by the same action a player takes (defaults 1.3, decision D3): + * 1. The NPC weighs its odds (a /datum/human_npc_surrender on its npc_surrender: how hurt it is, how many + * hostiles are on it, how many friends are still standing). + * 2. On a yes it stows the weapons in its hands in its own belt, bag, holster or suit storage. Its gear + * is locked (D3), so it never drops them; one with nowhere to put a gun keeps holding it, hands blocked. + * 3. It says its surrender line and runs tg's *surrender emote, a real 20 second Paralyze. That is the + * cuff window: tg cuffs (4 s) work on it like on anyone. + * 4. When the Paralyze ends and nobody cuffed it, it decides again at once: give up again, or (if its + * rules say so) run from the nearest hostile for a while, or fight on. + * There is no surrender state, trait or mark: what players see and act on is tg's Paralyze and cuffs. + * + * A family picks the numbers with a subtype, and overrides hurt_enough(), should_consider() or odds() + * for its own rules (the kingpin only once his crew is down; convicts the moment they are sighted, so + * hurt_enough() is always TRUE for them). Code that wants an NPC to give up at once calls + * npc_surrender() itself. + */ + +/// A family's surrender rules. One shared instance per type (human_npc_singleton()). +/datum/human_npc_surrender + /// Percent chance on the first roll of a fight + var/chance = 60 + /// Percent chance on later rolls + var/retry_chance = 25 + /// Percent taken off the chance for each friend still standing near it + var/ally_penalty = 20 + /// It considers giving up only at or below this share of its health + var/health_share = 0.4 + /// ...and only with at least this many hostiles near it + var/hostiles_needed = 2 + /// How far it looks for hostiles and friends, in tiles + var/range = 7 + /// Time between two rolls + var/retry_delay = 5 SECONDS + /// When a surrender's paralysis ends uncuffed and it decides not to give up again: run (TRUE), or fight on + var/flees = TRUE + /// How long it runs for + var/flee_time = 20 SECONDS + +/// Whether `npc` is in a bad enough way to think about giving up. Cheap: checked before it looks around. +/datum/human_npc_surrender/proc/hurt_enough(mob/living/carbon/human/npc/npc) + return npc.health <= npc.maxHealth * health_share + +/// Whether, having looked around, it thinks about giving up. +/datum/human_npc_surrender/proc/should_consider(mob/living/carbon/human/npc/npc, list/hostiles, list/allies) + return length(hostiles) >= hostiles_needed + +/// Percent chance it gives up on this roll. `first` is TRUE for its first roll of the fight. +/datum/human_npc_surrender/proc/odds(mob/living/carbon/human/npc/npc, list/hostiles, list/allies, first) + if(!length(hostiles)) + return 0 + return clamp((first ? chance : retry_chance) - ally_penalty * length(allies), 0, 100) + +/// The living hostiles near `npc` that are still in the fight and that it can see. +/datum/human_npc_surrender/proc/hostiles_near(mob/living/carbon/human/npc/npc) + . = list() + for(var/mob/living/other in oview(range, npc)) + if(other.faction_check_atom(npc) || human_npc_out_of_fight(other)) + continue + if(other.client || other.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] == npc || npc.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] == other) + . += other + +/// Its friends near it that are still standing. +/datum/human_npc_surrender/proc/allies_near(mob/living/carbon/human/npc/npc) + . = list() + for(var/mob/living/other in oview(range, npc)) + if(other.client || !npc.faction_check_atom(other) || human_npc_out_of_fight(other)) + continue + if(other.body_position == LYING_DOWN) + continue + . += other + +/// The rules it surrenders by, or null if it never does. +/mob/living/carbon/human/npc/proc/npc_surrender_rules() + if(!ispath(npc_surrender, /datum/human_npc_surrender)) + return null + return human_npc_singleton(npc_surrender) + +/** + * Gives up now, the way a player does: weapons stowed, its surrender line, tg's *surrender (a 20 second + * Paralyze). Returns TRUE if it is lying there with its hands on its head. + */ +/mob/living/carbon/human/npc/proc/npc_surrender() + if(stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_RESTRAINED) || npc_surrendering) + return FALSE + npc_stow_weapons() + npc_bark(HUMAN_NPC_BARK_SURRENDER, force = TRUE) + npc_surrendering = TRUE + emote("surrender", intentional = TRUE) + if(!IsParalyzed()) + npc_surrendering = FALSE + return FALSE + npc_surrender_count++ + SEND_SIGNAL(src, COMSIG_HUMAN_NPC_SURRENDERED) + return TRUE + +/// Puts the weapons in its hands away on its body. D3: never on the floor. +/mob/living/carbon/human/npc/proc/npc_stow_weapons() + for(var/obj/item/held in held_items) + if(isgun(held) || human_npc_is_melee_weapon(held)) + held.equip_to_best_slot(src) + +/// Its surrender's paralysis is over. Cuffed: taken, nothing to decide. Otherwise it decides again. +/mob/living/carbon/human/npc/proc/npc_surrender_ended() + if(IsParalyzed()) + return + npc_surrendering = FALSE + if(stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_RESTRAINED)) + return + npc_surrender_redecide = TRUE + SEND_SIGNAL(src, COMSIG_HUMAN_NPC_SURRENDER_ENDED) + +/** + * Rolls to give up when its rules say it is losing, and decides again straight after a surrender + * nobody took. Put it after escape_captivity and before targeting in any controller whose pawn is a + * human NPC. + */ +/datum/ai_planning_subtree/human_npc_surrender + +/datum/ai_planning_subtree/human_npc_surrender/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/npc = controller.pawn + if(!ishumannpc(npc) || npc.stat != CONSCIOUS || HAS_TRAIT(npc, TRAIT_RESTRAINED)) + return + var/datum/human_npc_surrender/rules = npc.npc_surrender_rules() + if(isnull(rules)) + return + // Running after walking away from a surrender. + if(world.time < controller.blackboard[BB_HUMAN_NPC_FLEE_UNTIL] && controller.blackboard_key_exists(BB_HUMAN_NPC_FLEE_FROM)) + controller.queue_behavior(/datum/ai_behavior/run_away_from_target, BB_HUMAN_NPC_FLEE_FROM) + return SUBTREE_RETURN_FINISH_PLANNING + var/redecide = npc.npc_surrender_redecide + if(!redecide && (world.time < npc.npc_next_surrender_roll || !rules.hurt_enough(npc))) + return + // Looking around costs, so it looks at most once per retry_delay. + npc.npc_next_surrender_roll = world.time + rules.retry_delay + npc.npc_surrender_redecide = FALSE + var/list/hostiles = rules.hostiles_near(npc) + var/list/allies = rules.allies_near(npc) + if(!redecide && !rules.should_consider(npc, hostiles, allies)) + return + var/first = !npc.npc_surrender_rolls + npc.npc_surrender_rolls++ + if(prob(rules.odds(npc, hostiles, allies, first))) + controller.queue_behavior(/datum/ai_behavior/human_npc_surrender) + return SUBTREE_RETURN_FINISH_PLANNING + if(redecide && rules.flees && length(hostiles)) + controller.set_blackboard_key(BB_HUMAN_NPC_FLEE_FROM, get_closest_atom(/mob/living, hostiles, npc)) + controller.set_blackboard_key(BB_HUMAN_NPC_FLEE_UNTIL, world.time + rules.flee_time) + controller.queue_behavior(/datum/ai_behavior/run_away_from_target, BB_HUMAN_NPC_FLEE_FROM) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Gives up (npc_surrender()). +/datum/ai_behavior/human_npc_surrender + +/datum/ai_behavior/human_npc_surrender/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/human/npc/npc = controller.pawn + if(!ishumannpc(npc) || !npc.npc_surrender()) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED diff --git a/voidcrew/modules/npc_scaling/zone_profiles.dm b/voidcrew/modules/npc_scaling/zone_profiles.dm index 59385ef4d83..93faa694435 100644 --- a/voidcrew/modules/npc_scaling/zone_profiles.dm +++ b/voidcrew/modules/npc_scaling/zone_profiles.dm @@ -186,13 +186,21 @@ RegisterSignal(src, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(npc_zone_projectile)) /// Use for raw max-health changes (posse size, phases). Retains the selected factor. +/// Never on a carbon: a person's crit line stays where tg puts it (human NPCs, defaults PI-1). /mob/living/proc/set_npc_zone_health(raw_max_health) + if(iscarbon(src)) + stack_trace("set_npc_zone_health() was called on [src] ([type]); carbons keep tg's health. Toughen human NPCs with armour and npc_damage_taken.") + return npc_zone_original_max_health = raw_max_health resize_npc_zone_health(raw_max_health * npc_zone_health_factor) /// Basic and legacy simple animals both store ALL physical damage in bruteloss. /// Scale the damage store too, so updatehealth/healing/revival cannot undo the resize. +/// Never on a carbon (see set_npc_zone_health()). /mob/living/proc/resize_npc_zone_health(new_max_health) + if(iscarbon(src)) + stack_trace("resize_npc_zone_health() was called on [src] ([type]); carbons keep tg's health. Toughen human NPCs with armour and npc_damage_taken.") + return if(maxHealth <= 0 || new_max_health <= 0) return var/ratio = new_max_health / maxHealth diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 3fe0553a970..ce5aa07f5cb 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -92,6 +92,7 @@ #include "voidcrew_hull_containment.dm" #include "voidcrew_hull_survey.dm" #include "voidcrew_human_npcs.dm" +#include "voidcrew_human_npcs_shared.dm" #include "voidcrew_hv_finishers.dm" #include "voidcrew_hyperviolence_blood.dm" #include "voidcrew_hyperviolence_braingut.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_human_npcs_shared.dm b/voidcrew/modules/unit_tests/voidcrew_human_npcs_shared.dm new file mode 100644 index 00000000000..8f302dde046 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_human_npcs_shared.dm @@ -0,0 +1,416 @@ +// Human NPC shared pieces, phase A2 (voidcrew/modules/human_npcs/). Unit tests compile before +// voidcrew/_DEFINES, so values from there are written out: zones ZONE_GREEN 1, ZONE_YELLOW 2, +// ZONE_RED 3; sites HUMAN_NPC_SITE_VACUUM 1, _BAD_AIR 2, _COLD 4; blackboard keys and bark kinds by +// their strings. + +/// A voice with a death line, for the death hook. +/datum/human_npc_voice/unit_test + emotes = list("death" = list("goes still.")) + +/// Dressed for vacuum wherever it spawns. +/mob/living/carbon/human/npc/sample/raider/unit_test_spacer + npc_always_site = 1 + +/// Dressed for the cold wherever it spawns. +/mob/living/carbon/human/npc/sample/raider/unit_test_cold + npc_always_site = 4 + +/// A skeleton dressed for vacuum: its species needs nothing. +/mob/living/carbon/human/npc/sample/raider/unit_test_skeleton + npc_always_site = 1 + npc_look = /obj/effect/mob_spawn/corpse/human/old_pirate_captain + +/// A prisoner: nothing it carries is locked (owner ruling OC-11). +/mob/living/carbon/human/npc/sample/gunner/unit_test_prisoner + npc_gear_locked = FALSE + +/// N4: dead, in crit or cuffed is out of the fight; stamcrit isn't; a basic mob is out only once dead. +/datum/unit_test/voidcrew_human_npc_out_of_fight + +/datum/unit_test/voidcrew_human_npc_out_of_fight/Run() + var/mob/living/carbon/human/npc/sample/gunner/npc = allocate(/mob/living/carbon/human/npc/sample/gunner) + TEST_ASSERT(!human_npc_out_of_fight(npc), "A healthy human NPC counts as out of the fight") + npc.adjustStaminaLoss(200) + TEST_ASSERT(npc.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "Test fixture: 200 stamina damage did not stamcrit the NPC") + TEST_ASSERT(!human_npc_out_of_fight(npc), "A stamcrit NPC counts as out of the fight") + npc.setStaminaLoss(0) + + npc.adjustOxyLoss(110) + TEST_ASSERT_EQUAL(npc.stat, SOFT_CRIT, "Test fixture: 110 oxygen damage did not put the NPC in soft crit") + TEST_ASSERT(human_npc_out_of_fight(npc), "An NPC in soft crit is still in the fight") + npc.setOxyLoss(0) + TEST_ASSERT(!human_npc_out_of_fight(npc), "An NPC healed out of crit is still out of the fight") + + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(npc.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the NPC could not be cuffed") + TEST_ASSERT(human_npc_out_of_fight(npc), "A cuffed NPC is still in the fight") + npc.uncuff() + + npc.death() + TEST_ASSERT(human_npc_out_of_fight(npc), "A dead NPC is still in the fight") + var/mob/living/basic/mouse/mouse = allocate(/mob/living/basic/mouse) + TEST_ASSERT(!human_npc_out_of_fight(mouse), "A living basic mob counts as out of the fight") + +/// Builds a human NPC on the test floor dressed for `zone`, whatever zone the test level has. +/datum/unit_test/proc/human_npc_build_in_zone(npc_type, zone) + var/datum/human_npc_order/order = new /datum/human_npc_order(npc_type, run_loc_floor_bottom_left) + order.zone = zone + var/mob/living/carbon/human/npc/npc = order.fulfil() + qdel(order) + if(npc) + allocated += npc + return npc + +/// N5: pirate-family NPCs keep shooting someone in crit; criminal-family NPCs stop. +/datum/unit_test/voidcrew_human_npc_target_stat + +/datum/unit_test/voidcrew_human_npc_target_stat/Run() + var/mob/living/carbon/human/npc/sample/gunner/finisher = allocate(/mob/living/carbon/human/npc/sample/gunner) + var/mob/living/carbon/human/npc/sample/thug/spare = allocate(/mob/living/carbon/human/npc/sample/thug, locate(run_loc_floor_bottom_left.x, run_loc_floor_bottom_left.y + 1, run_loc_floor_bottom_left.z)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, locate(run_loc_floor_bottom_left.x + 2, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z)) + TEST_ASSERT_EQUAL(finisher.ai_controller.blackboard[BB_TARGET_MINIMUM_STAT], HARD_CRIT, "A pirate-family NPC's AI didn't get its target stat") + TEST_ASSERT_EQUAL(spare.ai_controller.blackboard[BB_TARGET_MINIMUM_STAT], CONSCIOUS, "A criminal-family NPC's AI didn't get its target stat") + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(/datum/targeting_strategy/basic/human_npc) + TEST_ASSERT(strategy.can_attack(finisher, victim), "Test fixture: the pirate-family NPC won't fight a healthy person") + TEST_ASSERT(strategy.can_attack(spare, victim), "Test fixture: the criminal-family NPC won't fight a healthy person") + victim.adjustOxyLoss(110) + TEST_ASSERT_EQUAL(victim.stat, SOFT_CRIT, "Test fixture: the victim is not in soft crit") + TEST_ASSERT(strategy.can_attack(finisher, victim), "A pirate-family NPC stopped attacking someone in soft crit") + TEST_ASSERT(!strategy.can_attack(spare, victim), "A criminal-family NPC keeps attacking someone in crit") + +/// N3: barks fire on real tg changes (crit, stamcrit, cuffs, working the cuffs, breaking a grab, death), +/// are rate-limited, and a mute NPC only shows its emotes. +/datum/unit_test/voidcrew_human_npc_barks + +/datum/unit_test/voidcrew_human_npc_barks/Run() + var/mob/living/carbon/human/npc/sample/gunner/npc = allocate(/mob/living/carbon/human/npc/sample/gunner) + npc.adjustOxyLoss(110) + TEST_ASSERT_EQUAL(npc.npc_last_bark, "downed", "Entering crit gave no downed line") + npc.npc_bark_cooldown = 0 + npc.setOxyLoss(0) + TEST_ASSERT_EQUAL(npc.npc_last_bark, "recover", "Leaving crit gave no recover line") + + npc.npc_last_bark = null + npc.npc_bark_cooldown = 0 + npc.adjustStaminaLoss(200) + npc.setStaminaLoss(0) + TEST_ASSERT(!npc.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "Test fixture: stamcrit did not end") + TEST_ASSERT_EQUAL(npc.npc_last_bark, "recover", "Getting up from stamcrit gave no recover line") + + npc.npc_bark_cooldown = 0 + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(npc.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the NPC could not be cuffed") + TEST_ASSERT_EQUAL(npc.npc_last_bark, "cuffed", "Being cuffed gave no cuffed line") + npc.npc_bark_cooldown = 0 + SEND_SIGNAL(npc, COMSIG_MOB_REMOVING_CUFFS, cuffs) + TEST_ASSERT_EQUAL(npc.npc_last_bark, "slipping_cuffs", "Working at its cuffs gave no line") + npc.npc_bark_cooldown = 0 + npc.uncuff() + TEST_ASSERT_EQUAL(npc.npc_last_bark, "uncuffed", "Its cuffs coming off gave no line") + + // A passive grab breaks on the first resist, in the same call. + var/mob/living/carbon/human/consistent/grabber = 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)) + grabber.start_pulling(npc) + TEST_ASSERT_EQUAL(npc.pulledby, grabber, "Test fixture: the grab didn't take") + npc.npc_bark_cooldown = 0 + npc.next_move = 0 + npc.execute_resist() + TEST_ASSERT_NULL(npc.pulledby, "Test fixture: resisting did not break the grab") + TEST_ASSERT_EQUAL(npc.npc_last_bark, "pulled_free", "Pulling free of a grab gave no line") + + // Rate limit: straight after a line, the next waits; surrender and death don't. + TEST_ASSERT(!npc.npc_bark("cuffed"), "A bark ignored the cooldown") + TEST_ASSERT(npc.npc_bark("cuffed", force = TRUE), "A forced bark was held back by the cooldown") + + // Mute: a kind with only spoken lines gives nothing, one with an emote still shows it. + npc.npc_bark_cooldown = 0 + ADD_TRAIT(npc, TRAIT_MUTE, TRAIT_SOURCE_UNIT_TESTS) + TEST_ASSERT(!npc.npc_bark("cuffed"), "A mute NPC spoke") + TEST_ASSERT(npc.npc_bark("downed"), "A mute NPC didn't show its emote") + REMOVE_TRAIT(npc, TRAIT_MUTE, TRAIT_SOURCE_UNIT_TESTS) + + npc.npc_voice = /datum/human_npc_voice/unit_test + npc.death() + TEST_ASSERT_EQUAL(npc.npc_last_bark, "death", "Dying gave no death line") + +/// N2: giving up stows the weapon (D3: never dropped), says the line and runs tg's *surrender; when the +/// paralysis ends uncuffed it decides again; cuffed, it doesn't. +/datum/unit_test/voidcrew_human_npc_surrender + +/datum/unit_test/voidcrew_human_npc_surrender/Run() + var/mob/living/carbon/human/npc/sample/thug/npc = allocate(/mob/living/carbon/human/npc/sample/thug) + var/obj/item/gun/gun = locate() in npc.held_items + TEST_ASSERT_NOTNULL(gun, "Test fixture: the thug holds no gun") + var/datum/human_npc_surrender/rules = npc.npc_surrender_rules() + TEST_ASSERT_NOTNULL(rules, "The thug has no surrender rules") + + TEST_ASSERT(!rules.hurt_enough(npc), "A healthy NPC thinks about giving up") + npc.adjustOxyLoss(70) + TEST_ASSERT(rules.hurt_enough(npc), "An NPC at 30 health doesn't think about giving up") + TEST_ASSERT(!rules.should_consider(npc, list(1), list()), "One hostile is enough to make it think about giving up") + TEST_ASSERT(rules.should_consider(npc, list(1, 2), list()), "Two hostiles aren't enough to make it think about giving up") + TEST_ASSERT_EQUAL(rules.odds(npc, list(1, 2), list(), TRUE), 60, "The first roll's odds are wrong") + TEST_ASSERT_EQUAL(rules.odds(npc, list(1, 2), list(1), TRUE), 40, "A standing friend doesn't lower the odds") + TEST_ASSERT_EQUAL(rules.odds(npc, list(1, 2), list(), FALSE), 25, "A later roll's odds are wrong") + TEST_ASSERT_EQUAL(rules.odds(npc, list(), list(), TRUE), 0, "It would give up with nobody there") + + TEST_ASSERT(npc.npc_surrender(), "The NPC would not give up") + TEST_ASSERT(npc.IsParalyzed(), "*surrender did not paralyse the NPC") + TEST_ASSERT_EQUAL(npc.npc_last_bark, "surrender", "Giving up gave no surrender line") + TEST_ASSERT(!npc.is_holding(gun), "The NPC kept its gun in hand when it gave up") + TEST_ASSERT_EQUAL(human_npc_holding(gun), npc, "The NPC's gun left its body when it gave up") + TEST_ASSERT(!npc.npc_surrender(), "The NPC gave up twice at once") + + npc.SetParalyzed(0) + TEST_ASSERT(!npc.npc_surrendering, "The NPC still counts as surrendering after the paralysis") + TEST_ASSERT(npc.npc_surrender_redecide, "Uncuffed after the paralysis, the NPC did not decide again") + + // Given up and cuffed: taken, nothing to decide. + npc.npc_surrender_redecide = FALSE + npc.emotes_used = null // the emote spam limit, not a game rule + TEST_ASSERT(npc.npc_surrender(), "The NPC would not give up a second time") + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(npc.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the NPC could not be cuffed") + npc.SetParalyzed(0) + TEST_ASSERT(!npc.npc_surrender_redecide, "A cuffed NPC decided again after giving up") + TEST_ASSERT(!npc.npc_surrender(), "A cuffed NPC gave up again") + +/// N5: in soft crit the AI keeps running and does only soft-crit things: a medipen, firing wild (elites), +/// crawling away. Hard crit stops it. +/datum/unit_test/voidcrew_human_npc_soft_crit + +/datum/unit_test/voidcrew_human_npc_soft_crit/Run() + var/mob/living/carbon/human/npc/sample/thug/npc = allocate(/mob/living/carbon/human/npc/sample/thug) + var/datum/ai_controller/controller = npc.ai_controller + npc.adjustOxyLoss(110) + TEST_ASSERT_EQUAL(npc.stat, SOFT_CRIT, "Test fixture: the NPC is not in soft crit") + TEST_ASSERT(!(controller.get_able_to_run() & AI_UNABLE_TO_RUN), "A human NPC's AI can't run in soft crit") + var/obj/item/reagent_containers/hypospray/medipen/pen = human_npc_soft_crit_treatment(npc) + TEST_ASSERT_NOTNULL(pen, "The NPC doesn't find the medipen in its pocket") + TEST_ASSERT(human_npc_gear_consumable(pen), "The medipen wasn't issued as a consumable") + + var/datum/ai_planning_subtree/human_npc_soft_crit/soft = new + TEST_ASSERT_EQUAL(soft.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "The soft-crit subtree let other subtrees plan") + var/datum/ai_behavior/human_npc_treat_self/treat = GET_AI_BEHAVIOR(/datum/ai_behavior/human_npc_treat_self) + TEST_ASSERT(controller.current_behaviors[treat], "In soft crit with a medipen, the NPC didn't plan to use it") + + var/finished = FALSE + for(var/step in 1 to 10) + npc.next_move = 0 + npc.next_click = 0 + var/result = treat.perform(1, controller, "human_npc_treat_item") + TEST_ASSERT(!(result & AI_BEHAVIOR_FAILED), "Using the medipen failed at step [step]") + if(result & AI_BEHAVIOR_SUCCEEDED) + finished = TRUE + break + TEST_ASSERT(finished, "The NPC never finished using its medipen") + TEST_ASSERT(npc.reagents.has_reagent(/datum/reagent/medicine/epinephrine), "The medipen's epinephrine isn't in the NPC") + TEST_ASSERT(pen.used_up, "The medipen isn't spent") + + npc.adjustOxyLoss(40) + TEST_ASSERT_EQUAL(npc.stat, HARD_CRIT, "Test fixture: the NPC is not in hard crit") + TEST_ASSERT(controller.get_able_to_run() & AI_UNABLE_TO_RUN, "A human NPC's AI runs in hard crit") + + // An elite fires from the floor at the ground around its attacker; a regular only crawls. + var/mob/living/carbon/human/consistent/target = allocate(/mob/living/carbon/human/consistent, locate(run_loc_floor_bottom_left.x + 4, run_loc_floor_bottom_left.y + 2, run_loc_floor_bottom_left.z)) + var/mob/living/carbon/human/npc/sample/elite/elite = allocate(/mob/living/carbon/human/npc/sample/elite, locate(run_loc_floor_bottom_left.x, run_loc_floor_bottom_left.y + 2, run_loc_floor_bottom_left.z)) + var/mob/living/carbon/human/npc/sample/gunner/regular = allocate(/mob/living/carbon/human/npc/sample/gunner, locate(run_loc_floor_bottom_left.x, run_loc_floor_bottom_left.y + 3, run_loc_floor_bottom_left.z)) + var/datum/ai_behavior/human_npc_fire_wild/wild = GET_AI_BEHAVIOR(/datum/ai_behavior/human_npc_fire_wild) + for(var/mob/living/carbon/human/npc/downed as anything in list(elite, regular)) + downed.adjustOxyLoss(110) + TEST_ASSERT_EQUAL(downed.stat, SOFT_CRIT, "Test fixture: [downed] is not in soft crit") + downed.ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target) + soft.SelectBehaviors(downed.ai_controller, 1) + TEST_ASSERT(elite.ai_controller.current_behaviors[wild], "An elite in soft crit doesn't fire from the floor") + TEST_ASSERT(!regular.ai_controller.current_behaviors[wild], "A regular NPC in soft crit fires from the floor") + + var/obj/item/gun/ballistic/elite_gun = locate() in elite.held_items + TEST_ASSERT_NOTNULL(elite_gun, "Test fixture: the elite holds no ballistic gun") + elite_gun.can_misfire = FALSE + var/rounds_before = elite_gun.magazine.ammo_count(FALSE) + elite.next_move = 0 + elite.next_click = 0 + wild.perform(1, elite.ai_controller, "human_npc_flee_from") + TEST_ASSERT(elite_gun.magazine.ammo_count(FALSE) < rounds_before || !elite_gun.chambered?.loaded_projectile, "The elite in soft crit did not fire") + +/// N7: outfit tables pick by zone and rank; every pirate table dresses every rank in every zone, with a +/// real weapon and the ammunition it takes. +/datum/unit_test/voidcrew_human_npc_outfit_tables + +/datum/unit_test/voidcrew_human_npc_outfit_tables/Run() + var/datum/human_npc_outfit_table/raiders = human_npc_singleton(/datum/human_npc_outfit_table/sample_raiders) + TEST_ASSERT_EQUAL(raiders.pick_outfit(null, 1), /datum/outfit/human_npc/raider, "Green raiders aren't in rags") + TEST_ASSERT_EQUAL(raiders.pick_outfit(null, 2), /datum/outfit/human_npc/raider/vest, "Yellow raiders have no vest") + TEST_ASSERT_EQUAL(raiders.pick_outfit(null, 3), /datum/outfit/human_npc/raider/armoured, "Red raiders aren't armoured") + var/mob/living/carbon/human/npc/sample/elite/elite = allocate(/mob/living/carbon/human/npc/sample/elite) + TEST_ASSERT_EQUAL(raiders.pick_outfit(elite, 1), /datum/outfit/human_npc/pirate_gunner, "An elite didn't get the elite outfit") + + // An order that knows its zone dresses for it. + var/mob/living/carbon/human/npc/red_raider = human_npc_build_in_zone(/mob/living/carbon/human/npc/sample/raider, 3) + TEST_ASSERT(istype(red_raider?.wear_suit, /obj/item/clothing/suit/armor/vest), "A red-zone raider wears no vest") + TEST_ASSERT(istype(red_raider?.head, /obj/item/clothing/head/helmet), "A red-zone raider wears no helmet") + TEST_ASSERT_NOTNULL(locate(/obj/item/gun/ballistic/automatic/mini_uzi) in red_raider?.held_items, "A red-zone raider holds no SMG") + + // A spawn context's zone reaches the outfit, and the zone multipliers never touch a human. + var/datum/npc_spawn_context/context = new /datum/npc_spawn_context(run_loc_floor_bottom_left, 2) + var/mob/living/carbon/human/npc/yellow_raider = spawn_npc_in_zone(/mob/living/carbon/human/npc/sample/raider, run_loc_floor_bottom_left, context) + allocated += yellow_raider + TEST_ASSERT(istype(yellow_raider?.wear_suit, /obj/item/clothing/suit/armor/vest), "A raider made in a yellow-zone context wears no vest") + TEST_ASSERT(!istype(yellow_raider?.head, /obj/item/clothing/head/helmet), "A yellow-zone raider got the red-zone helmet") + TEST_ASSERT_EQUAL(yellow_raider?.maxHealth, 100, "A human NPC's max health was changed by its zone") + TEST_ASSERT(!yellow_raider?.npc_zone_applied, "A zone profile was applied to a human NPC") + + // An order whose context is still resolving its zone waits, then settles for green. + var/turf_zone = get_zone_type_for_player_turf(run_loc_floor_bottom_left) + var/datum/npc_spawn_context/unresolved = new /datum/npc_spawn_context(run_loc_floor_bottom_left) + var/datum/human_npc_order/waiting = new /datum/human_npc_order(/mob/living/carbon/human/npc/sample/raider, run_loc_floor_bottom_left, null, null, null, null, null, unresolved) + if(isnull(turf_zone)) + TEST_ASSERT(!waiting.ready(), "An order waiting on its zone was ready to build") + waiting.queued_at = world.time - 61 SECONDS + waiting.next_zone_check = 0 + TEST_ASSERT(waiting.ready(), "An order that waited a minute for its zone still isn't ready") + TEST_ASSERT_EQUAL(waiting.zone, 1, "An order that gave up waiting wasn't dressed for green") + else + TEST_ASSERT(waiting.ready(), "An order on a turf with a zone wasn't ready") + qdel(waiting) + qdel(unresolved) + + // Every pirate table, every outfit in it worn once. + var/list/outfits_seen = list() + for(var/table_type in subtypesof(/datum/human_npc_outfit_table/pirate)) + var/datum/human_npc_outfit_table/pirate/table = human_npc_singleton(table_type) + for(var/zone in 1 to 3) + TEST_ASSERT(length(table.outfits_for(null, zone)), "[table_type] dresses nobody in zone [zone]") + TEST_ASSERT(length(table.elite), "[table_type] has no captain") + TEST_ASSERT(length(table.boss), "[table_type] has no boss") + if(table.gunners_only) + for(var/outfit_type in table.green) + TEST_ASSERT(!(outfit_type in table.melee), "[table_type] hands out a melee outfit") + for(var/list/choices in list(table.green, table.elite, table.boss)) + for(var/outfit_type in choices) + outfits_seen |= outfit_type + for(var/outfit_type in outfits_seen) + TEST_ASSERT(ispath(outfit_type, /datum/outfit), "A pirate table lists [outfit_type], which is not an outfit") + var/datum/human_npc_order/dressed = spawn_human_npc(/mob/living/carbon/human/npc/sample, run_loc_floor_bottom_left, outfit = outfit_type, immediate = TRUE) + var/mob/living/carbon/human/npc/pirate = dressed.spawned_ref?.resolve() + TEST_ASSERT_NOTNULL(pirate, "[outfit_type] built nobody") + var/obj/item/gun/gun = human_npc_held_gun(pirate) + TEST_ASSERT(gun || human_npc_held_melee_weapon(pirate), "[outfit_type] holds no weapon") + if(istype(gun, /obj/item/gun/ballistic)) + TEST_ASSERT_NOTNULL(human_npc_find_ammo(pirate, gun), "[outfit_type] carries no spare ammunition for its [gun]") + qdel(pirate) + +/// N7: where the air can't keep a person alive the NPC gets a sealed suit and an open tank; in the cold +/// it gets cold-weather wear; a species that needs neither is left alone. +/datum/unit_test/voidcrew_human_npc_site_layers + +/datum/unit_test/voidcrew_human_npc_site_layers/Run() + var/turf/open/floor = run_loc_floor_bottom_left + TEST_ASSERT_EQUAL(human_npc_site_at(floor), 0, "The test room's air counts as hostile") + var/datum/gas_mixture/air = floor.return_air() + var/old_temperature = air.temperature + air.temperature = 200 + var/cold_site = human_npc_site_at(floor) + air.temperature = old_temperature + TEST_ASSERT(cold_site & 4, "Air at 200 K doesn't count as cold") + + var/datum/outfit/human_npc/pirate_spacer/sealed_outfit = new() + var/datum/outfit/human_npc/raider/rags_outfit = new() + TEST_ASSERT(human_npc_outfit_sealed(sealed_outfit), "The pirate spacer's suit doesn't count as sealed") + TEST_ASSERT(!human_npc_outfit_sealed(rags_outfit), "Rags count as sealed") + TEST_ASSERT(human_npc_outfit_has_air(sealed_outfit), "The pirate spacer doesn't count as carrying air") + + var/mob/living/carbon/human/npc/sample/raider/unit_test_spacer/spacer = human_npc_build_in_zone(/mob/living/carbon/human/npc/sample/raider/unit_test_spacer, 1) + TEST_ASSERT(istype(spacer.wear_suit, /obj/item/clothing/suit/space/pirate), "An NPC dressed for vacuum has no space suit") + TEST_ASSERT(istype(spacer.head, /obj/item/clothing/head/helmet/space), "An NPC dressed for vacuum has no space helmet") + TEST_ASSERT(istype(spacer.s_store, /obj/item/tank/internals), "An NPC dressed for vacuum carries no tank") + TEST_ASSERT_NOTNULL(spacer.internal, "An NPC dressed for vacuum didn't open its tank") + var/obj/item/clothing/suit/space/suit = spacer.wear_suit + TEST_ASSERT(suit?.thermal_on, "An NPC dressed for vacuum didn't switch on its suit's heating") + // Dormant, the regulator is off so the cell lasts; awake, it goes back on. + var/turf/here = get_turf(spacer) + spacer.Life(SSMOBS_DT, 1) + TEST_ASSERT(!suit?.thermal_on, "A dormant NPC kept its suit's heating running") + SSmobs.clients_by_zlevel[here.z] += spacer + spacer.Life(SSMOBS_DT, 2) + SSmobs.clients_by_zlevel[here.z] -= spacer + TEST_ASSERT(suit?.thermal_on, "An NPC that woke didn't switch its suit's heating back on") + TEST_ASSERT(human_npc_gear_locked(spacer.s_store), "The NPC's tank isn't locked to it") + TEST_ASSERT_NOTNULL(human_npc_held_gun(spacer), "The suit layer took the NPC's gun") + + var/mob/living/carbon/human/npc/sample/raider/unit_test_cold/cold = human_npc_build_in_zone(/mob/living/carbon/human/npc/sample/raider/unit_test_cold, 1) + TEST_ASSERT(istype(cold.wear_suit, /obj/item/clothing/suit/hooded/wintercoat), "An NPC dressed for the cold has no coat") + TEST_ASSERT(istype(cold.shoes, /obj/item/clothing/shoes/winterboots), "An NPC dressed for the cold has no winter boots") + TEST_ASSERT_NOTNULL(cold.gloves, "An NPC dressed for the cold has bare hands") + + var/mob/living/carbon/human/npc/sample/raider/unit_test_skeleton/skeleton = human_npc_build_in_zone(/mob/living/carbon/human/npc/sample/raider/unit_test_skeleton, 1) + TEST_ASSERT(istype(skeleton.dna.species, /datum/species/skeleton), "Test fixture: the skeleton isn't a skeleton") + TEST_ASSERT(!istype(skeleton.wear_suit, /obj/item/clothing/suit/space), "A skeleton was put in a space suit it doesn't need") + +/// N8: a living NPC walking out is deleted on its exit or out of sight, never while down, cuffed or +/// dragged; what isn't gear drops where it stood. +/datum/unit_test/voidcrew_human_npc_walk_out + var/left_called = FALSE + +/datum/unit_test/voidcrew_human_npc_walk_out/proc/on_left(mob/living/carbon/human/npc/leaver) + left_called = TRUE + +/datum/unit_test/voidcrew_human_npc_walk_out/Run() + var/turf/exit = locate(run_loc_floor_bottom_left.x + 3, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z) + var/mob/living/carbon/human/npc/sample/gunner/npc = allocate(/mob/living/carbon/human/npc/sample/gunner) + var/obj/item/ship_key/key = allocate(/obj/item/ship_key) + TEST_ASSERT(npc.equip_to_slot_if_possible(key, ITEM_SLOT_LPOCKET), "Test fixture: the key would not go in the NPC's pocket") + TEST_ASSERT(!npc.npc_may_vanish(), "An NPC that isn't leaving may vanish") + TEST_ASSERT(npc.npc_walk_out(exit, CALLBACK(src, PROC_REF(on_left))), "The NPC would not walk out") + TEST_ASSERT(npc in SShuman_npcs.leavers, "A leaving NPC isn't tracked") + TEST_ASSERT_EQUAL(npc.ai_controller.blackboard["human_npc_exit"], exit, "The NPC's AI doesn't know its exit") + + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(npc.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the NPC could not be cuffed") + TEST_ASSERT(!npc.npc_may_vanish(), "A cuffed NPC may vanish") + npc.uncuff() + var/mob/living/carbon/human/consistent/dragger = 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)) + dragger.start_pulling(npc) + TEST_ASSERT(!npc.npc_may_vanish(), "An NPC being dragged may vanish") + dragger.stop_pulling() + npc.adjustOxyLoss(110) + TEST_ASSERT(!npc.npc_may_vanish(), "An NPC in crit may vanish") + npc.setOxyLoss(0) + + // Free and with no player on the test level to see it: it may go. + TEST_ASSERT(npc.npc_may_vanish(), "A free NPC nobody can see may not vanish") + npc.npc_stop_leaving() + TEST_ASSERT(!npc.npc_may_vanish(), "An NPC called back may still vanish") + TEST_ASSERT(!(npc in SShuman_npcs.leavers), "An NPC called back is still tracked as leaving") + + npc.npc_walk_out(exit, CALLBACK(src, PROC_REF(on_left))) + SShuman_npcs.sweep_leavers() + TEST_ASSERT(QDELETED(npc), "The sweep left a free, unwatched NPC that was walking out") + TEST_ASSERT(left_called, "Walking out didn't call back") + TEST_ASSERT(!QDELETED(key) && isturf(key.loc), "The ship key went with the NPC that walked out") + +/// N1 and OC-11: papers aren't gear and drop when the NPC dies; medical stacks are consumables; an NPC +/// whose gear isn't locked (a prisoner) carries ordinary items players can take. +/datum/unit_test/voidcrew_human_npc_gear_rules + +/datum/unit_test/voidcrew_human_npc_gear_rules/Run() + var/mob/living/carbon/human/npc/sample/gunner/npc = allocate(/mob/living/carbon/human/npc/sample/gunner) + var/obj/item/paper/note = allocate(/obj/item/paper) + TEST_ASSERT(npc.equip_to_slot_if_possible(note, ITEM_SLOT_LPOCKET), "Test fixture: the paper would not go in the NPC's pocket") + TEST_ASSERT(!npc.npc_lock_item(note), "A paper was locked to an NPC as gear") + var/obj/item/stack/medical/bruise_pack/pack = allocate(/obj/item/stack/medical/bruise_pack) + TEST_ASSERT(npc.put_in_hands(pack), "Test fixture: the NPC could not hold a bruise pack") + TEST_ASSERT(npc.npc_lock_item(pack) && human_npc_gear_consumable(pack), "A bruise pack wasn't issued as a consumable") + npc.death() + TEST_ASSERT(!QDELETED(note) && isturf(note.loc), "A paper didn't drop when its NPC died") + + var/mob/living/carbon/human/npc/sample/gunner/unit_test_prisoner/prisoner = allocate(/mob/living/carbon/human/npc/sample/gunner/unit_test_prisoner, locate(run_loc_floor_bottom_left.x + 1, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z)) + var/mob/living/carbon/human/consistent/guard = allocate(/mob/living/carbon/human/consistent) + var/obj/item/gun/gun = locate() in prisoner.held_items + TEST_ASSERT_NOTNULL(gun, "Test fixture: the prisoner holds nothing") + TEST_ASSERT(!human_npc_gear_locked(gun), "An unlocked NPC's gun is locked") + TEST_ASSERT(!human_npc_gear_locked(prisoner.w_uniform), "An unlocked NPC's jumpsuit is locked") + TEST_ASSERT(prisoner.should_strip(guard), "An unlocked NPC can't be searched") + TEST_ASSERT(prisoner.dropItemToGround(gun), "An unlocked NPC can't let go of its gun") + sleep(2) + TEST_ASSERT(!QDELETED(gun) && isturf(gun.loc), "An unlocked NPC's dropped gun was deleted") From e6abce3b12ad23f436348dc8430932f2542bc223 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:12:25 -0800 Subject: [PATCH 041/472] Thicker arterial jets, bisect legs clear the knight, tox setup in tests Spurts shorten along the jet only and thicken across it, so they read as jets. Cut-off legs slide sideways when the attacker stands at the feet. Test victims are pre-damaged with toxin, since oxygen damage knocks a carbon out and floors it before the killing blow. --- .../modules/hyperviolence/visuals/finisher_motion.dm | 7 ++++++- voidcrew/modules/hyperviolence/visuals/flying_gore.dm | 10 ++++++---- .../unit_tests/voidcrew_hyperviolence_visuals.dm | 8 ++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/voidcrew/modules/hyperviolence/visuals/finisher_motion.dm b/voidcrew/modules/hyperviolence/visuals/finisher_motion.dm index 23c13e29bb0..457eae5b599 100644 --- a/voidcrew/modules/hyperviolence/visuals/finisher_motion.dm +++ b/voidcrew/modules/hyperviolence/visuals/finisher_motion.dm @@ -61,11 +61,16 @@ head_dir = EAST else if(victim.lying_angle == LYING_ANGLE_WEST) head_dir = WEST + // The legs slide off the feet end, or off to the side if the knight stands there. + var/legs_dir = REVERSE_DIR(head_dir) + var/turf/legs_to = get_step(victim, legs_dir) + if(!legs_to || legs_to == get_turf(host.user) || legs_to.is_blocked_turf(FALSE)) + legs_dir = pick(turn(legs_dir, 90), turn(legs_dir, -90)) for(var/zone in list(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) var/obj/item/bodypart/leg = victim.get_bodypart(zone) if(leg?.can_dismember()) leg.hv_vis_tumble = HV_VIS_TUMBLE_SLIDE - leg.hv_vis_fling_dir = REVERSE_DIR(head_dir) + leg.hv_vis_fling_dir = legs_dir . = ..() if(!QDELETED(victim) && isturf(victim.loc)) victim.hv_vis_slide_in_tile(head_dir, 40) diff --git a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm index 6acf4ef7e40..5bd8c98ead2 100644 --- a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm +++ b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm @@ -348,16 +348,18 @@ GLOBAL_VAR_INIT(hv_vis_arcing_gibs, FALSE) color = blood_color var/dx = HV_VIS_DX(dir) var/dy = HV_VIS_DY(dir) - // The art is a line through the whole tile; shrunk and moved out so it starts at the body. + // The art is a thin line through the whole tile: shortened along the jet (and moved out so it starts + // at the body), and drawn a little thicker across it. A diagonal jet is shortened both ways. var/reach = 0.25 + 0.35 * strength + var/thick = 1 + 0.5 * strength var/matrix/start = matrix() - start.Scale(0.1) + start.Scale(dx ? 0.1 : thick, dy ? 0.1 : thick) start.Translate(dx * 2, dy * 2) var/matrix/out = matrix() - out.Scale(reach) + out.Scale(dx ? reach : thick, dy ? reach : thick) out.Translate(dx * 16 * reach, dy * 16 * reach) var/matrix/spent = matrix() - spent.Scale(reach * 1.1) + spent.Scale(dx ? reach * 1.1 : thick * 1.2, dy ? reach * 1.1 : thick * 1.2) spent.Translate(dx * 18 * reach, dy * 18 * reach) alpha = 0 animate(src, alpha = 235, transform = start, time = 0, loop = pulses) diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm index 6e42717680a..0c14c867729 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm @@ -115,7 +115,7 @@ /datum/unit_test/voidcrew_hv_visuals/death_falls_away/Run() for(var/blow_dir in list(EAST, WEST)) var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(2, 2)) - victim.adjustOxyLoss(185, updating_health = FALSE) + victim.adjustToxLoss(185, updating_health = FALSE, forced = TRUE) TEST_ASSERT_EQUAL(victim.body_position, STANDING_UP, "Test fixture: the victim fell before the killing blow") victim.apply_damage(25, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, attack_direction = blow_dir) TEST_ASSERT_EQUAL(victim.stat, DEAD, "Test fixture: the killing blow did not kill") @@ -141,7 +141,7 @@ /datum/unit_test/voidcrew_hv_visuals/death_kinds/Run() // HV_VIS_DEATH_QUIET 0, _HIT 1, _HEADSHOT 2, _BURNED 3, _BLED_OUT 4, _EXECUTED 5 var/mob/living/carbon/human/consistent/shot = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) - shot.adjustOxyLoss(185, updating_health = FALSE) + shot.adjustToxLoss(185, updating_health = FALSE, forced = TRUE) shot.apply_damage(25, BRUTE, BODY_ZONE_HEAD, wound_bonus = CANT_WOUND, attack_direction = NORTH) TEST_ASSERT_EQUAL(shot.hv_vis_death_kind(), 2, "A head shot did not read as a head shot") var/matrix/laid = matrix() @@ -149,7 +149,7 @@ TEST_ASSERT(same_matrix(shot.transform, laid), "The head shot drop changed how the body lies") var/mob/living/carbon/human/consistent/burned = allocate(/mob/living/carbon/human/consistent, spot(3, 1)) - burned.adjustOxyLoss(185, updating_health = FALSE) + burned.adjustToxLoss(185, updating_health = FALSE, forced = TRUE) burned.apply_damage(25, BURN, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, attack_direction = WEST) TEST_ASSERT_EQUAL(burned.hv_vis_death_kind(), 3, "A burning blow did not read as burned") TEST_ASSERT(holds(burned) >= 1, "The burned curl is not held") @@ -191,7 +191,7 @@ // Through a real killing blow, next tick var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 2)) - victim.adjustOxyLoss(185, updating_health = FALSE) + victim.adjustToxLoss(185, updating_health = FALSE, forced = TRUE) victim.apply_damage(50, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND, attack_direction = EAST) TEST_ASSERT_EQUAL(victim.stat, DEAD, "Test fixture: the killing blow did not kill") sleep(3) From 9d92cec4cfdf48fa53cb90192b0f9b816a86da8f Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:22:33 -0800 Subject: [PATCH 042/472] Drop the arterial spurt and gut unspool animations --- .../modules/hyperviolence/visuals/_defines.dm | 8 -- .../hyperviolence/visuals/_motion_core.dm | 2 +- .../hyperviolence/visuals/flying_gore.dm | 92 ------------------- .../hyperviolence/visuals/gut_motion.dm | 55 ----------- .../voidcrew_hyperviolence_visuals.dm | 5 +- 5 files changed, 3 insertions(+), 159 deletions(-) diff --git a/voidcrew/modules/hyperviolence/visuals/_defines.dm b/voidcrew/modules/hyperviolence/visuals/_defines.dm index c2f8cd724bc..7cf30673575 100644 --- a/voidcrew/modules/hyperviolence/visuals/_defines.dm +++ b/voidcrew/modules/hyperviolence/visuals/_defines.dm @@ -112,14 +112,6 @@ #define HV_VIS_TUMBLE_SPEED 1 /// Most blood streaks one tumbling part leaves along its way. #define HV_VIS_TUMBLE_MAX_STREAKS 4 -/// Blood flow of a single wound from which it spurts with the heartbeat. -#define HV_VIS_ARTERIAL_FLOW 3 -/// Heartbeats: resting, fast (blood running low) and racing (nearly bled out). -#define HV_VIS_BEAT_RESTING (0.8 SECONDS) -#define HV_VIS_BEAT_FAST (0.55 SECONDS) -#define HV_VIS_BEAT_RACING (0.45 SECONDS) -/// How often at most a body spurts, about one Life tick. -#define HV_VIS_SPURT_COOLDOWN (2 SECONDS) // ---- Blood ---- diff --git a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm index 7e98cec1ce4..516b5978216 100644 --- a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm +++ b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm @@ -1,6 +1,6 @@ // Hyperviolence visuals: the animation pass over the gore. Bodies flinch, snap, lurch and limp when // hit, fall away from the killing blow and twitch after, severed parts and brains tumble through -// the air, blood paints in, drips, spreads and floats, guts unspool and slither, and executions and +// the air, blood paints in, drips, spreads and floats, guts slither, and executions and // boss finishers get their beats. // // Rules every animation here keeps: diff --git a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm index 5bd8c98ead2..5b34dc19ef3 100644 --- a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm +++ b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm @@ -3,9 +3,6 @@ // enough to see: up in an arc, spinning, leaving a blood streak on each tile it crosses; it bounces // once where it lands, and a head rolls on. // 9. A burst head's brain arcs out spinning and lands with a wet bounce. Its gibs arc as they fly. -// 10. A wound bleeding hard enough spurts with the heartbeat: a jet from that side of the body, two or -// three beats between Life ticks, quicker and weaker as the blood runs out. Nothing while the heart -// is stopped. // In zero gravity nothing arcs or bounces: a part just turns slowly as it drifts, and leaves no streak. /// Set while a burst head throws its gibs, so they arc. @@ -277,92 +274,3 @@ GLOBAL_VAR_INIT(hv_vis_arcing_gibs, FALSE) list(pixel_w, pixel_z + 2, hv_vis_turned(base, turn), 0.7, QUAD_EASING|EASE_OUT), list(pixel_w, pixel_z, base, 1.5, BOUNCE_EASING), )) - -// ---- Arterial spurts ---- - -/mob/living/carbon - /// World time before which this body does not spurt again. - var/hv_vis_next_spurt = 0 - -/mob/living/carbon/human/bleed(amt) - . = ..() - hv_vis_arterial_spurt() - -/// The wound bleeding hard enough to spurt, the worst if several, or null. -/mob/living/carbon/proc/hv_vis_arterial_wound() - var/datum/wound/worst - for(var/datum/wound/wound as anything in all_wounds) - if(wound.blood_flow < HV_VIS_ARTERIAL_FLOW || !wound.limb) - continue - if(!worst || wound.blood_flow > worst.blood_flow) - worst = wound - return worst - -/// A spurt from the worst wound, beating with the heart, at most once per Life tick. -/mob/living/carbon/proc/hv_vis_arterial_spurt() - if(world.time < hv_vis_next_spurt || stat == DEAD || !isturf(loc)) - return - var/datum/wound/artery = hv_vis_arterial_wound() - if(!artery || can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) - return - var/obj/item/organ/heart/heart = get_organ_slot(ORGAN_SLOT_HEART) - if(!heart?.is_beating()) - return - hv_vis_next_spurt = world.time + HV_VIS_SPURT_COOLDOWN - var/beat = HV_VIS_BEAT_RESTING - if(blood_volume < BLOOD_VOLUME_BAD) - beat = HV_VIS_BEAT_RACING - else if(blood_volume < BLOOD_VOLUME_OKAY) - beat = HV_VIS_BEAT_FAST - var/strength = clamp(blood_volume / BLOOD_VOLUME_NORMAL, 0.25, 1) - var/pulses = max(1, round(HV_VIS_SPURT_COOLDOWN / beat)) - new /obj/effect/temp_visual/hv_arterial_spurt(loc, hv_vis_spurt_dir(artery.limb.body_zone), get_bloodtype()?.get_color(), strength, beat, pulses) - -/// Which way a wound on `zone` spurts: out to the side for limbs, forward for the head and chest. -/mob/living/carbon/proc/hv_vis_spurt_dir(zone) - switch(zone) - if(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM) - var/side = hv_vis_zone_side(zone) - return side > 0 ? EAST : (side < 0 ? WEST : dir) - if(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) - var/side = hv_vis_zone_side(zone) - return side > 0 ? SOUTHEAST : (side < 0 ? SOUTHWEST : turn(dir, 180)) - return dir - -/// A jet of blood from a body, pulsing `pulses` times one `beat` apart. Thinner and shorter for a -/// body low on blood. -/obj/effect/temp_visual/hv_arterial_spurt - name = "blood" - icon = HV_BLOOD_SPRAY_ICON - icon_state = "squirt_greyscale" - plane = GAME_PLANE - layer = ABOVE_MOB_LAYER - randomdir = FALSE - duration = 2 SECONDS - -/obj/effect/temp_visual/hv_arterial_spurt/Initialize(mapload, spurt_dir, blood_color, strength = 1, beat = HV_VIS_BEAT_RESTING, pulses = 2) - duration = beat * pulses + 1 - . = ..() - setDir(spurt_dir || SOUTH) - if(blood_color) - color = blood_color - var/dx = HV_VIS_DX(dir) - var/dy = HV_VIS_DY(dir) - // The art is a thin line through the whole tile: shortened along the jet (and moved out so it starts - // at the body), and drawn a little thicker across it. A diagonal jet is shortened both ways. - var/reach = 0.25 + 0.35 * strength - var/thick = 1 + 0.5 * strength - var/matrix/start = matrix() - start.Scale(dx ? 0.1 : thick, dy ? 0.1 : thick) - start.Translate(dx * 2, dy * 2) - var/matrix/out = matrix() - out.Scale(dx ? reach : thick, dy ? reach : thick) - out.Translate(dx * 16 * reach, dy * 16 * reach) - var/matrix/spent = matrix() - spent.Scale(dx ? reach * 1.1 : thick * 1.2, dy ? reach * 1.1 : thick * 1.2) - spent.Translate(dx * 18 * reach, dy * 18 * reach) - alpha = 0 - animate(src, alpha = 235, transform = start, time = 0, loop = pulses) - animate(alpha = 235, transform = out, time = beat * 0.35, easing = CUBIC_EASING|EASE_OUT) - animate(alpha = 0, transform = spent, time = beat * 0.4, easing = LINEAR_EASING) - animate(alpha = 0, transform = spent, time = beat * 0.25, easing = LINEAR_EASING) diff --git a/voidcrew/modules/hyperviolence/visuals/gut_motion.dm b/voidcrew/modules/hyperviolence/visuals/gut_motion.dm index b7961abe5f9..1aac864718a 100644 --- a/voidcrew/modules/hyperviolence/visuals/gut_motion.dm +++ b/voidcrew/modules/hyperviolence/visuals/gut_motion.dm @@ -1,60 +1,6 @@ // Hyperviolence visuals: guts. -// 15. Spilled intestines come out of the body: they start small at the body, spill along the floor to -// where they lie and flop down, and the strand to the body (braingut/gut_tether.dm) only shows -// once they are out. The tether itself holds from the first moment, as before. // 16. Guts dragged along the floor, by someone pulling them or by the strand, slither as they go. -/// How long the guts take to come out of the body. -#define HV_VIS_UNSPOOL_TIME (0.7 SECONDS) - -/obj/item/hv_intestines - /// World time the guts are all the way out of the body; the strand waits for it. - var/hv_vis_unspooled_at = 0 - -/obj/item/bodypart/chest/hv_drop_intestines(attack_direction) - var/mob/living/carbon/victim = owner - . = ..() - var/obj/item/hv_intestines/guts = . - if(istype(guts) && victim && isturf(guts.loc)) - guts.hv_vis_unspool_from(victim) - -/// Comes out of `body`: from small at the body to full size where the guts lie, and a flop. -/obj/item/hv_intestines/proc/hv_vis_unspool_from(atom/body) - var/turf/from = get_turf(body) - var/turf/here = get_turf(src) - if(!from || !here || from.z != here.z) - return - hv_vis_unspooled_at = world.time + HV_VIS_UNSPOOL_TIME - var/off_w = (from.x - here.x) * ICON_SIZE_X - pixel_x - var/off_z = (from.y - here.y) * ICON_SIZE_Y - pixel_y - var/matrix/base = matrix(transform) - var/twist = pick(-1, 1) - hv_vis_play(HV_VIS_TAG_ARC, list( - list(pixel_w + off_w, pixel_z + off_z, hv_vis_local(base, 0.25, 0.25, twist * -60), 0, LINEAR_EASING), - list(pixel_w + round(off_w * 0.45), pixel_z + round(off_z * 0.45) - 2, hv_vis_local(base, 0.65, 0.5, twist * 35), HV_VIS_UNSPOOL_TIME * 0.5, SINE_EASING), - list(pixel_w, pixel_z, hv_vis_local(base, 1.12, 0.84, twist * -8), HV_VIS_UNSPOOL_TIME * 0.3, QUAD_EASING|EASE_OUT), - list(pixel_w, pixel_z, base, HV_VIS_UNSPOOL_TIME * 0.2 + 2, ELASTIC_EASING), - )) - -/// The strand to the body only shows once the guts are out. It exists (and holds) from the start; -/// only its picture waits. -/datum/hv_gut_tether/refresh() - var/had_strand = !!strand - . = ..() - if(had_strand || !strand || !guts || world.time >= guts.hv_vis_unspooled_at) - return - var/wait = guts.hv_vis_unspooled_at - world.time - // Every piece of the strand shows the shared visuals, except the cropped end piece. - var/list/pictures = list() - if(strand.visuals) - pictures += strand.visuals - pictures += strand.elements - for(var/atom/movable/picture as anything in pictures) - var/full_alpha = picture.alpha - animate(picture, alpha = 0, time = 0) - animate(alpha = 0, time = wait) - animate(alpha = full_alpha, time = 2, easing = SINE_EASING) - /obj/item/hv_intestines/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) . = ..() if(!isturf(loc) || !isturf(old_loc) || throwing) @@ -76,4 +22,3 @@ list(pixel_w, pixel_z, base, 1, SINE_EASING), )) -#undef HV_VIS_UNSPOOL_TIME diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm index 0c14c867729..3c562fcd801 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_visuals.dm @@ -290,7 +290,7 @@ TEST_ASSERT_EQUAL(victim.body_position, STANDING_UP, "Test fixture: they did not stand back up") TEST_ASSERT_EQUAL(holds(victim), 0, "The tremble outlived getting back up") -/// Spilled guts unspool over time but hold their strand at once, and slither without moving off their spot +/// Spilled guts hold their strand at once, and slither without moving off their spot /datum/unit_test/voidcrew_hv_visuals/guts /datum/unit_test/voidcrew_hv_visuals/guts/Run() @@ -299,8 +299,7 @@ var/datum/wound/slash/flesh/critical/disembowel/spill = chest.hv_spill_guts(EAST, "test") TEST_ASSERT_NOTNULL(spill, "Test fixture: the guts did not spill") var/obj/item/hv_intestines/guts = spill.guts - TEST_ASSERT(guts.hv_vis_unspooled_at > world.time, "The guts came out all at once") - TEST_ASSERT_NOTNULL(spill.tether.strand, "The strand waited for the guts instead of holding at once") + TEST_ASSERT_NOTNULL(spill.tether.strand, "The guts spilled without a strand to the body") var/list/rest = rest_of(guts) guts.forceMove(spot(2, 2)) check_rest(guts, rest, "A slither") From bdb0157a77aa3989c02e1f77378cfd6c66f5f980 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:58:26 -0800 Subject: [PATCH 043/472] Make ruin, planet and mission troopers, pirates and hermits human NPCs Phase C of the human NPC migration. The people in ruins, on planets and at mission sites are real human bodies now, so wounds, crit, medicine, cuffs and revival work on them as on players. - Ruin families (voidcrew/modules/human_npcs/ruins/): tg's space pirates, Syndicate operatives, commandos, stormtroopers and salvage pilots, Nanotrasen security and Russian mobsters; the beach pirates (the boat pirates now wade, in the costume their old sprite showed); faction pirate crews, captains and leaders for the pirate zone markers and kill contracts; and the wasteland hermits. Each keeps its trooper's name, faction and look, holds its weapon and carries spare ammunition. - Zone outfit tables: green wears what the trooper wore, yellow adds armour, red a helmet or better gun and a medipen. The trooper weapon ladder lives in the tables. Operatives on the ice get the Syndicate's armoured explorer suit; space crews wear their own sealed suits and tanks. - Designed drops stay as they were: the Russians' knife and revolver, the faction pirates' fixed pieces, rare rolls, zone ladder weapons and cash, once per life. - On planets the ruin families join the local fauna's alliance, as the mobs they replace did, and say a line when they spot someone. - Maps: every trooper and hermit placed in Voidcrew ruins, the caravan ambush's derelict shuttles (now Voidcrew copies) and the Hightide's Timothy is a human NPC spawner, with mapped names and camp factions kept. Biome, planet, barrel and marker spawn lists queue human types a body per tick. - Missions: kill contracts queue a human target and tie its cash to the contract once; suppression counts human pirates, each body once; poacher squads are human hermits dressed for the cold. - The hermit simple mobs, the basic beach pirates and the hermit corpse-swap hooks are gone. - Cave barrels bite the searching hand through its glove; the lich codices no longer force their toxin past immunity, and the mirage stings the arm that struck it. --- .../RandomRuins/BeachRuins/fishing_hut.dmm | 4 +- .../RandomRuins/BeachRuins/push_it.dmm | 28 +- .../icemoon_underground_syndielab.dmm | 4 +- .../RandomRuins/JungleRuins/jungle_pirate.dmm | 12 +- .../JungleRuins/jungle_syndicate.dmm | 4 +- .../RandomRuins/SpaceRuins/caravanambush.dmm | 22 +- .../SpaceRuins/derelict_construction.dmm | 4 +- .../RandomRuins/SpaceRuins/forgottenship.dmm | 8 +- .../RandomRuins/SpaceRuins/garbagetruck2.dmm | 2 +- .../RandomRuins/SpaceRuins/garbagetruck3.dmm | 2 +- .../SpaceRuins/rare_pirate_cove.dmm | 12 +- .../RandomRuins/SpaceRuins/the_faceoff.dmm | 2 +- .../RandomRuins/SpaceRuins/waystation.dmm | 10 +- .../wasteland_fortress_of_solitide.dmm | 4 +- .../WastelandRuins/wasteland_pod.dmm | 2 +- .../WastelandRuins/wasteland_soviet.dmm | 6 +- .../wasteland_surface_camp_adobe.dmm | 38 +- .../wasteland_surface_camp_drugstore.dmm | 4 +- .../wasteland_surface_camp_saloon.dmm | 26 +- .../wasteland_surface_chokepoint.dmm | 20 +- .../wasteland_surface_crash_bar.dmm | 25 +- .../wasteland_surface_crash_cargo.dmm | 10 +- .../wasteland_surface_onlyaspoonful.dmm | 10 +- .../wasteland_surface_starfurycrash.dmm | 6 +- _maps/voidcrew/ships/ship_hightide.dmm | 6 +- .../voidcrew/shuttles/ruin_caravan_victim.dmm | 1225 +++++++++++++++++ .../voidcrew/shuttles/ruin_pirate_cutter.dmm | 1177 ++++++++++++++++ .../shuttles/ruin_syndicate_dropship.dmm | 620 +++++++++ .../shuttles/ruin_syndicate_fighter_shiv.dmm | 156 +++ .../unit_tests/simple_animal_freeze.dm | 6 - tgstation.dme | 7 +- voidcrew/controllers/subsystem/planet_mobs.dm | 2 +- voidcrew/datums/mapgen/PlanetGenerator.dm | 2 +- voidcrew/datums/mapgen/biomes/_biome.dm | 2 +- voidcrew/datums/mapgen/biomes/beach_biomes.dm | 8 +- .../datums/mapgen/biomes/wasteland_biomes.dm | 8 +- .../modules/human_npcs/human_npc_spawning.dm | 6 +- .../modules/human_npcs/ruins/ruin_npc_base.dm | 147 ++ .../ruins/ruin_npc_faction_pirates.dm | 327 +++++ .../human_npcs/ruins/ruin_npc_hermits.dm | 89 ++ .../human_npcs/ruins/ruin_npc_spawners.dm | 111 ++ .../human_npcs/ruins/ruin_npc_troopers.dm | 567 ++++++++ .../corpses/npc_corpse_handoff.dm | 31 +- .../hyperviolence/hitlines/npc_blood.dm | 1 - voidcrew/modules/lich/lich_spells.dm | 7 +- voidcrew/modules/loot/themes/plunder.dm | 44 +- voidcrew/modules/loot/themes/syndicate.dm | 22 +- voidcrew/modules/loot/zone_mobs.dm | 2 +- .../modules/missions/missions/poacher_bust.dm | 104 +- .../modules/missions/missions/recovery.dm | 43 +- .../modules/missions/objectives/combat.dm | 63 +- .../living/simple_animal/hostile/pirate.dm | 23 - .../hostile/wasteland/hermits.dm | 92 -- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_human_npcs_ruins.dm | 271 ++++ .../voidcrew_hyperviolence_corpses.dm | 17 - voidcrew/modules/unit_tests/voidcrew_loot.dm | 7 +- .../voidcrew_mission_console_rewards.dm | 13 +- .../unit_tests/voidcrew_npc_zone_profiles.dm | 1 - .../unit_tests/voidcrew_planetary_factions.dm | 7 +- .../unit_tests/voidcrew_simple_mob_ai.dm | 6 +- voidcrew/objects/structures/spawner.dm | 7 +- 62 files changed, 5050 insertions(+), 443 deletions(-) create mode 100644 _maps/voidcrew/shuttles/ruin_caravan_victim.dmm create mode 100644 _maps/voidcrew/shuttles/ruin_pirate_cutter.dmm create mode 100644 _maps/voidcrew/shuttles/ruin_syndicate_dropship.dmm create mode 100644 _maps/voidcrew/shuttles/ruin_syndicate_fighter_shiv.dmm create mode 100644 voidcrew/modules/human_npcs/ruins/ruin_npc_base.dm create mode 100644 voidcrew/modules/human_npcs/ruins/ruin_npc_faction_pirates.dm create mode 100644 voidcrew/modules/human_npcs/ruins/ruin_npc_hermits.dm create mode 100644 voidcrew/modules/human_npcs/ruins/ruin_npc_spawners.dm create mode 100644 voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm delete mode 100644 voidcrew/modules/mob/living/simple_animal/hostile/pirate.dm delete mode 100644 voidcrew/modules/mob/living/simple_animal/hostile/wasteland/hermits.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_human_npcs_ruins.dm diff --git a/_maps/voidcrew/RandomRuins/BeachRuins/fishing_hut.dmm b/_maps/voidcrew/RandomRuins/BeachRuins/fishing_hut.dmm index 8f5cad05fc2..1905f78ca0a 100644 --- a/_maps/voidcrew/RandomRuins/BeachRuins/fishing_hut.dmm +++ b/_maps/voidcrew/RandomRuins/BeachRuins/fishing_hut.dmm @@ -372,7 +372,7 @@ /turf/template_noop, /area/template_noop) "qU" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /obj/structure/chair/stool/directional/west, /turf/open/floor/bamboo/tatami, /area/ruin/powered{ @@ -853,7 +853,7 @@ /turf/open/misc/asteroid/sand/beach, /area/overmap_encounter/planetoid/beach) "Mn" = ( -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /obj/structure/chair/stool/directional/east, /turf/open/floor/bamboo/tatami, /area/ruin/powered{ diff --git a/_maps/voidcrew/RandomRuins/BeachRuins/push_it.dmm b/_maps/voidcrew/RandomRuins/BeachRuins/push_it.dmm index 611652c7469..74926bbff22 100644 --- a/_maps/voidcrew/RandomRuins/BeachRuins/push_it.dmm +++ b/_maps/voidcrew/RandomRuins/BeachRuins/push_it.dmm @@ -157,7 +157,7 @@ /area/overmap_encounter/planetoid/beach) "dG" = ( /obj/structure/chair, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 8 }, @@ -244,7 +244,7 @@ /obj/structure/chair{ dir = 4 }, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /turf/open/floor/iron/dark, /area/ruin/powered) "ia" = ( @@ -338,7 +338,7 @@ /turf/template_noop, /area/template_noop) "mf" = ( -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/carpet/neon/simple/violet/nodots, /area/ruin/powered) "ms" = ( @@ -365,7 +365,7 @@ /turf/open/floor/carpet/neon/simple/violet, /area/ruin/powered) "mK" = ( -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 1 }, @@ -532,7 +532,7 @@ /area/ruin/powered) "sv" = ( /obj/structure/chair/stool/directional/north, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/carpet/neon/simple/violet/nodots, /area/ruin/powered) "sP" = ( @@ -602,7 +602,7 @@ /area/ruin/powered) "ve" = ( /obj/structure/toilet, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /obj/machinery/light/small/directional/north, /turf/open/floor/iron/dark/smooth_large, /area/ruin/powered) @@ -738,7 +738,7 @@ /obj/structure/chair/sofa/corp/right{ dir = 1 }, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /turf/open/floor/carpet/neon/simple/violet, /area/ruin/powered) "Cz" = ( @@ -817,7 +817,7 @@ /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 1 }, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /turf/open/floor/iron/dark/smooth_large, /area/ruin/powered) "Fw" = ( @@ -1065,7 +1065,7 @@ /turf/open/floor/iron/dark, /area/ruin/powered) "Pl" = ( -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /turf/open/floor/light/colour_cycle/dancefloor_a, /area/ruin/powered) "Pq" = ( @@ -1085,7 +1085,7 @@ /area/template_noop) "Qw" = ( /obj/structure/chair/sofa/corp/corner, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/carpet/neon/simple/violet, /area/ruin/powered) "QN" = ( @@ -1169,7 +1169,7 @@ "SV" = ( /obj/structure/mirror/directional/north, /obj/structure/sink/directional/south, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /obj/machinery/light/blacklight/directional/west, /turf/open/floor/iron/dark/smooth_large, /area/ruin/powered) @@ -1218,7 +1218,7 @@ /turf/open/floor/carpet/neon/simple/violet, /area/ruin/powered) "Ux" = ( -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/light/colour_cycle/dancefloor_a, /area/ruin/powered) "UU" = ( @@ -1257,7 +1257,7 @@ /obj/structure/chair/sofa/corp/corner{ dir = 8 }, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/iron/dark/smooth_large, /area/ruin/powered) "VX" = ( @@ -1292,7 +1292,7 @@ /obj/structure/chair{ dir = 1 }, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 8 }, diff --git a/_maps/voidcrew/RandomRuins/IceRuins/icemoon_underground_syndielab.dmm b/_maps/voidcrew/RandomRuins/IceRuins/icemoon_underground_syndielab.dmm index 384d19ad3bf..4bb94e40177 100644 --- a/_maps/voidcrew/RandomRuins/IceRuins/icemoon_underground_syndielab.dmm +++ b/_maps/voidcrew/RandomRuins/IceRuins/icemoon_underground_syndielab.dmm @@ -18,13 +18,13 @@ /turf/open/floor/mineral/plastitanium/red, /area/ruin/syndielab) "ae" = ( -/mob/living/basic/trooper/syndicate/ranged, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner, /obj/structure/cable/layer1, /turf/open/floor/iron/dark/textured, /area/ruin/syndielab) "af" = ( /obj/structure/chair/sofa/corp/right, -/mob/living/basic/trooper/syndicate/ranged, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner, /obj/machinery/light/warm/directional/north, /obj/structure/cable/layer1, /turf/open/floor/iron/dark/textured, diff --git a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_pirate.dmm b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_pirate.dmm index c702a41efa4..3dc2f3b3ac2 100644 --- a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_pirate.dmm +++ b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_pirate.dmm @@ -167,7 +167,7 @@ /obj/structure/railing{ dir = 4 }, -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/wood, /area/ruin/unpowered) "rc" = ( @@ -242,11 +242,11 @@ /turf/open/floor/wood, /area/ruin/unpowered) "DH" = ( -/mob/living/basic/trooper/pirate/ranged/space, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/open/floor/carpet/red, /area/ruin/unpowered) "DS" = ( -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/wood, /area/ruin/unpowered) "DV" = ( @@ -296,7 +296,7 @@ /turf/open/floor/wood, /area/ruin/unpowered) "LT" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /obj/structure/chair/wood, /obj/effect/decal/cleanable/dirt, /turf/open/floor/wood, @@ -366,7 +366,7 @@ /turf/open/misc/dirt/jungle, /area/ruin/unpowered) "UX" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /turf/open/floor/wood, /area/ruin/unpowered) "Vf" = ( @@ -396,7 +396,7 @@ /turf/open/floor/carpet/red, /area/ruin/unpowered) "XA" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /obj/effect/decal/cleanable/dirt, /turf/open/floor/wood, /area/ruin/unpowered) diff --git a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_syndicate.dmm b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_syndicate.dmm index 22c2ce8c137..1d7fb523f84 100644 --- a/_maps/voidcrew/RandomRuins/JungleRuins/jungle_syndicate.dmm +++ b/_maps/voidcrew/RandomRuins/JungleRuins/jungle_syndicate.dmm @@ -129,7 +129,7 @@ /area/ruin/powered) "G" = ( /obj/structure/cable, -/mob/living/basic/trooper/syndicate, +/obj/effect/spawner/human_npc/ruin/syndicate, /turf/open/floor/plating, /area/ruin/powered) "H" = ( @@ -148,7 +148,7 @@ /area/ruin/unpowered) "K" = ( /obj/structure/chair/plastic, -/mob/living/basic/trooper/syndicate/ranged, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner, /turf/open/floor/plating, /area/ruin/powered) "M" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/caravanambush.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/caravanambush.dmm index 0189b72e62b..9c3bd502369 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/caravanambush.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/caravanambush.dmm @@ -29,9 +29,7 @@ /area/template_noop) "ah" = ( /obj/structure/lattice/catwalk, -/mob/living/basic/trooper/pirate/ranged/space{ - environment_smash = 0 - }, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/template_noop, /area/template_noop) "ai" = ( @@ -60,7 +58,7 @@ /area/shuttle/ruin/caravan/freighter2) "al" = ( /obj/effect/decal/cleanable/dirt, -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/iron/airless, /area/shuttle/ruin/caravan/freighter3) "am" = ( @@ -74,7 +72,7 @@ dir = 4 }, /obj/effect/decal/cleanable/dirt, -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/iron/airless, /area/shuttle/ruin/caravan/freighter3) "ao" = ( @@ -85,7 +83,7 @@ /obj/effect/turf_decal/box/white/corners{ dir = 4 }, -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/iron/dark/airless, /area/shuttle/ruin/caravan/freighter3) "aD" = ( @@ -623,9 +621,7 @@ dir = 4 }, /obj/effect/decal/cleanable/dirt, -/mob/living/basic/trooper/pirate/ranged/space{ - environment_smash = 0 - }, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/open/floor/iron/airless, /area/shuttle/ruin/caravan/freighter2) "wC" = ( @@ -755,9 +751,7 @@ /area/ruin/space/has_grav) "Fr" = ( /obj/effect/decal/cleanable/dirt, -/mob/living/basic/trooper/pirate/ranged/space{ - environment_smash = 0 - }, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/open/floor/plating/airless, /area/shuttle/ruin/caravan/freighter2) "FG" = ( @@ -956,9 +950,7 @@ "Nm" = ( /obj/machinery/atmospherics/components/unary/vent_pump/on, /obj/effect/decal/cleanable/dirt, -/mob/living/basic/trooper/pirate/ranged/space{ - environment_smash = 0 - }, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/open/floor/iron/airless, /area/shuttle/ruin/caravan/freighter2) "NJ" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/derelict_construction.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/derelict_construction.dmm index cfaca1c8c3b..9dc7a53c439 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/derelict_construction.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/derelict_construction.dmm @@ -36,7 +36,7 @@ /area/ruin/space/has_grav/derelictconstruction) "fa" = ( /obj/structure/lattice, -/mob/living/basic/trooper/pirate/ranged/space, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/template_noop, /area/space/nearstation) "gi" = ( @@ -438,7 +438,7 @@ /turf/open/floor/plating, /area/ruin/space/has_grav/derelictconstruction) "Za" = ( -/mob/living/basic/trooper/pirate/melee/space, +/obj/effect/spawner/human_npc/ruin/pirate/space, /obj/structure/lattice/catwalk, /turf/template_noop, /area/ruin/space/has_grav/derelictconstruction) diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/forgottenship.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/forgottenship.dmm index 9e743c22eb0..fd8752ec4a3 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/forgottenship.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/forgottenship.dmm @@ -168,7 +168,7 @@ /turf/open/floor/mineral/plastitanium, /area/ruin/space/has_grav/syndicate_forgotten_ship) "aG" = ( -/mob/living/basic/trooper/nanotrasen/ranged, +/obj/effect/spawner/human_npc/ruin/nanotrasen, /turf/open/floor/mineral/plastitanium/red, /area/ruin/space/has_grav/syndicate_forgotten_ship) "aH" = ( @@ -190,7 +190,7 @@ dir = 10 }, /obj/item/wrench, -/mob/living/basic/trooper/nanotrasen/ranged, +/obj/effect/spawner/human_npc/ruin/nanotrasen, /turf/open/floor/mineral/plastitanium/red, /area/ruin/space/has_grav/syndicate_forgotten_ship) "aK" = ( @@ -214,7 +214,7 @@ /area/ruin/space/has_grav/syndicate_forgotten_ship) "aN" = ( /obj/machinery/light/directional/south, -/mob/living/basic/trooper/nanotrasen/ranged, +/obj/effect/spawner/human_npc/ruin/nanotrasen, /turf/open/floor/iron/dark, /area/ruin/space/has_grav/syndicate_forgotten_ship) "aO" = ( @@ -299,7 +299,7 @@ /obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ dir = 4 }, -/mob/living/basic/trooper/nanotrasen/ranged, +/obj/effect/spawner/human_npc/ruin/nanotrasen, /turf/open/floor/mineral/plastitanium, /area/ruin/space/has_grav/syndicate_forgotten_ship) "bd" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck2.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck2.dmm index fd128120432..d16dfe16136 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck2.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck2.dmm @@ -262,7 +262,7 @@ /turf/open/floor/plating, /area/ruin/space/has_grav/garbagetruck/medicalwaste) "wh" = ( -/mob/living/basic/trooper/syndicate/ranged, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner, /turf/open/floor/plating, /area/ruin/space/has_grav/garbagetruck/medicalwaste) "wk" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck3.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck3.dmm index 6575a90d588..a9db1cac58f 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck3.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/garbagetruck3.dmm @@ -708,7 +708,7 @@ "JR" = ( /obj/effect/decal/cleanable/ash, /obj/effect/decal/cleanable/blood/splatter/oil, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/plating/dumpsterair, /area/ruin/space/has_grav/garbagetruck/squat) "JW" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/rare_pirate_cove.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/rare_pirate_cove.dmm index 070986d9901..81402127f2c 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/rare_pirate_cove.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/rare_pirate_cove.dmm @@ -68,7 +68,7 @@ /turf/open/floor/plating, /area/ruin/space/has_grav/powered) "ao" = ( -/mob/living/basic/trooper/pirate/melee/space, +/obj/effect/spawner/human_npc/ruin/pirate/space, /turf/open/misc/asteroid, /area/ruin/space/has_grav/powered) "ap" = ( @@ -176,7 +176,7 @@ /turf/open/misc/asteroid, /area/ruin/space/has_grav/powered) "aL" = ( -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/misc/asteroid, /area/ruin/space/has_grav/powered) "aM" = ( @@ -406,7 +406,7 @@ /turf/open/floor/wood, /area/ruin/space/has_grav/powered) "pn" = ( -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/wood, /area/ruin/space/has_grav/powered) "rS" = ( @@ -523,7 +523,7 @@ /turf/open/floor/wood, /area/ruin/space/has_grav/powered) "Ej" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /turf/open/floor/wood, /area/ruin/space/has_grav/powered) "Fd" = ( @@ -541,7 +541,7 @@ /turf/open/floor/wood, /area/ruin/space/has_grav/powered) "Hx" = ( -/mob/living/basic/trooper/pirate/ranged/space, +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner, /turf/open/floor/plating, /area/ruin/space/has_grav/powered) "JH" = ( @@ -640,7 +640,7 @@ /turf/open/floor/plating, /area/ruin/space/has_grav/powered) "Uv" = ( -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/plating, /area/ruin/space/has_grav/powered) "UQ" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/the_faceoff.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/the_faceoff.dmm index f2e9240f637..a19a80247cc 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/the_faceoff.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/the_faceoff.dmm @@ -189,7 +189,7 @@ pixel_x = -1; pixel_y = 5 }, -/mob/living/basic/trooper/syndicate/melee/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando, /turf/open/floor/iron/dark/airless, /area/ruin/space) "eU" = ( diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/waystation.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/waystation.dmm index 6faae6d9085..ca04e714348 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/waystation.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/waystation.dmm @@ -18,7 +18,7 @@ /turf/open/floor/iron, /area/ruin/space/has_grav/waystation/cargobay) "ad" = ( -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /obj/item/ammo_casing/shotgun/buckshot/spent{ pixel_x = 3; pixel_y = 10 @@ -35,14 +35,14 @@ /area/ruin/space/has_grav/waystation/dorms) "ae" = ( /obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden/layer4, -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /obj/structure/cable, /turf/open/floor/iron, /area/ruin/space/has_grav/waystation/cargooffice) "af" = ( /obj/effect/turf_decal/loading_area/red, /obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden/layer4, -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /obj/structure/cable, /turf/open/floor/iron, /area/ruin/space/has_grav/waystation/cargobay) @@ -98,11 +98,11 @@ /turf/open/floor/iron/dark, /area/ruin/space/has_grav/waystation/securestorage) "ah" = ( -/mob/living/basic/trooper/syndicate/melee/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando, /turf/open/floor/iron/dark, /area/ruin/space/has_grav/waystation/securestorage) "ai" = ( -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/iron, /area/ruin/space/has_grav/waystation/cargobay) "aj" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm index 7c2663ddb2e..2d34b772c5c 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm @@ -2017,9 +2017,7 @@ /obj/structure/chair/wood{ dir = 8 }, -/mob/living/basic/trooper/russian{ - dir = 8 - }, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/wood, /area/ruin/powered) "tI" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_pod.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_pod.dmm index 986dda90c83..9cc06996557 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_pod.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_pod.dmm @@ -81,7 +81,7 @@ /area/overmap_encounter/planetoid/wasteland) "K" = ( /obj/effect/decal/cleanable/dirt, -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, +/obj/effect/spawner/human_npc/ruin/hermit/hunter, /turf/open/floor/pod/dark, /area/overmap_encounter/planetoid/wasteland) "M" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_soviet.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_soviet.dmm index 0d6dae8b6c3..fc1decc8833 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_soviet.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_soviet.dmm @@ -101,7 +101,7 @@ "sr" = ( /obj/effect/decal/cleanable/wrapping, /obj/structure/cable, -/mob/living/basic/trooper/russian, +/obj/effect/spawner/human_npc/ruin/russian, /turf/open/floor/plating/rust, /area/ruin) "sV" = ( @@ -191,7 +191,7 @@ /area/ruin) "Fg" = ( /obj/structure/chair/comfy/beige, -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /obj/structure/closet/crate/zone_loot/armory, /turf/open/floor/wood, /area/ruin) @@ -265,7 +265,7 @@ /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) "VS" = ( -/mob/living/basic/trooper/russian/ranged, +/obj/effect/spawner/human_npc/ruin/russian/gunner, /turf/open/floor/plating, /area/ruin) "WB" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm index 60616fa2f46..c7660e306a6 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm @@ -22,8 +22,8 @@ /turf/open/floor/plating/rust, /area/ruin/unpowered) "af" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ - faction = list("adobe") +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ + npc_faction = list("adobe") }, /turf/open/floor/plating/rust, /area/ruin/unpowered) @@ -50,9 +50,8 @@ /turf/open/floor/plating, /area/ruin/unpowered) "al" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ - dir = 4; - faction = list("adobe") +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ + npc_faction = list("adobe") }, /turf/open/floor/plating/rust, /area/ruin/unpowered) @@ -104,17 +103,15 @@ /turf/open/floor/plating/rust, /area/ruin/unpowered) "aw" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ - dir = 4; - faction = list("adobe") +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ + npc_faction = list("adobe") }, /obj/effect/decal/cleanable/dirt, /turf/open/floor/plating, /area/ruin/unpowered) "ax" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - dir = 4; - faction = list("adobe") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("adobe") }, /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) @@ -144,23 +141,21 @@ /area/ruin/unpowered) "aC" = ( /obj/structure/chair/plastic, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - faction = list("adobe") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("adobe") }, /obj/effect/decal/cleanable/dirt, /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) "aD" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger{ - dir = 4; - faction = list("adobe") +/obj/effect/spawner/human_npc/ruin/hermit/gunslinger{ + npc_faction = list("adobe") }, /turf/open/floor/plating/rust, /area/ruin/unpowered) "aE" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - dir = 1; - faction = list("adobe") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("adobe") }, /obj/effect/decal/cleanable/dirt, /obj/structure/closet/crate/zone_loot/wardrobe, @@ -215,9 +210,8 @@ /obj/structure/chair/plastic{ dir = 4 }, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - dir = 4; - faction = list("adobe") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("adobe") }, /obj/effect/decal/cleanable/dirt, /turf/open/misc/wasteland/lit, diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm index d5efef03e0c..0cddd821e27 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm @@ -54,9 +54,7 @@ /turf/open/floor/wood, /area/ruin/unpowered) "n" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - dir = 4 - }, +/obj/effect/spawner/human_npc/ruin/hermit, /turf/open/floor/wood{ broken = 1 }, diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm index 74e10504e99..af4b6686887 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm @@ -121,9 +121,9 @@ "hL" = ( /obj/effect/decal/cleanable/dirt, /obj/effect/decal/cleanable/blood/old, -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ - faction = list("saloon"); - name = "Whitesands Bartender" +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ + npc_faction = list("saloon"); + npc_name = "Whitesands Bartender" }, /turf/open/floor/wood{ broken = 1 @@ -191,9 +191,8 @@ /area/ruin/unpowered) "mr" = ( /obj/structure/chair/stool/bar, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - dir = 8; - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/wood{ broken = 1 @@ -205,9 +204,8 @@ /area/ruin/unpowered) "nE" = ( /obj/structure/chair/stool/bar, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - dir = 1; - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/wood, /area/ruin/unpowered) @@ -477,9 +475,8 @@ "Ov" = ( /obj/structure/chair/stool/bar, /obj/effect/decal/cleanable/dirt, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - dir = 8; - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/wood, /area/ruin/unpowered) @@ -559,9 +556,8 @@ /area/ruin/unpowered) "Xb" = ( /obj/item/chair/stool/bar, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - dir = 1; - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/wood, /area/ruin/unpowered) diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm index 349d0f5e9f0..442ce3b4cbe 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm @@ -31,10 +31,8 @@ "g" = ( /obj/structure/bed, /obj/item/bedsheet/hos, -/mob/living/basic/trooper/nanotrasen/ranged{ - atmos_requirements = list("min_oxy" = 5, "max_oxy" = 0, "min_tox" = 0, "max_tox" = 0, "min_co2" = 0, "max_co2" = 999, "min_n2" = 0, "max_n2" = 0); - damage_coeff = list("brute" = 1, "fire" = 1, "toxin" = 1, "clone" = 1, "stamina" = 0, "oxygen" = 0); - name = "\improper NanoTrasen Stranded Security Officer" +/obj/effect/spawner/human_npc/ruin/nanotrasen{ + npc_name = "NanoTrasen Stranded Security Officer" }, /obj/effect/decal/cleanable/dirt, /turf/open/floor/plating{ @@ -42,11 +40,8 @@ }, /area/ruin/unpowered) "h" = ( -/mob/living/basic/trooper/nanotrasen/ranged{ - atmos_requirements = list("min_oxy" = 5, "max_oxy" = 0, "min_tox" = 0, "max_tox" = 0, "min_co2" = 0, "max_co2" = 999, "min_n2" = 0, "max_n2" = 0); - damage_coeff = list("brute" = 1, "fire" = 1, "toxin" = 1, "clone" = 1, "stamina" = 0, "oxygen" = 0); - dir = 4; - name = "\improper NanoTrasen Stranded Security Officer" +/obj/effect/spawner/human_npc/ruin/nanotrasen{ + npc_name = "NanoTrasen Stranded Security Officer" }, /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) @@ -75,11 +70,8 @@ /turf/open/misc/dust/lit, /area/overmap_encounter/planetoid/wasteland) "v" = ( -/mob/living/basic/trooper/nanotrasen/ranged{ - atmos_requirements = list("min_oxy" = 5, "max_oxy" = 0, "min_tox" = 0, "max_tox" = 0, "min_co2" = 0, "max_co2" = 999, "min_n2" = 0, "max_n2" = 0); - damage_coeff = list("brute" = 1, "fire" = 1, "toxin" = 1, "clone" = 1, "stamina" = 0, "oxygen" = 0); - dir = 8; - name = "\improper NanoTrasen Stranded Security Officer" +/obj/effect/spawner/human_npc/ruin/nanotrasen{ + npc_name = "NanoTrasen Stranded Security Officer" }, /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_bar.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_bar.dmm index 28d2c66731e..f4832b7e84d 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_bar.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_bar.dmm @@ -1,8 +1,7 @@ //MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE "aa" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ - desc = "One of the few survivors on the planet with working weaponry. Doesn't seem as friendly as the bartenders you're used to."; - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ + npc_faction = list("saloon") }, /turf/open/floor/wood{ broken = 1 @@ -51,8 +50,8 @@ /obj/machinery/atmospherics/pipe/smart/simple/scrubbers/hidden/layer4{ dir = 10 }, -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter{ - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit/hunter{ + npc_faction = list("saloon") }, /turf/open/floor/plating{ burnt = 1 @@ -220,8 +219,8 @@ /turf/open/floor/plating, /area/ruin/unpowered) "hm" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/plating, /area/ruin/unpowered) @@ -430,8 +429,8 @@ }, /area/ruin/unpowered) "vR" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/iron/dark, /area/ruin/unpowered) @@ -443,8 +442,8 @@ /area/ruin/unpowered) "wL" = ( /obj/effect/decal/cleanable/dirt, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/plating, /area/ruin/unpowered) @@ -1039,8 +1038,8 @@ /turf/open/floor/wood, /area/ruin/unpowered) "ZU" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/survivor{ - faction = list("saloon") +/obj/effect/spawner/human_npc/ruin/hermit{ + npc_faction = list("saloon") }, /turf/open/floor/plating{ broken = 1 diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_cargo.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_cargo.dmm index 473b45b1284..eef0eeed63d 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_cargo.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_crash_cargo.dmm @@ -111,7 +111,7 @@ /turf/open/floor/plating, /area/ruin/unpowered) "kO" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /turf/open/floor/plating{ burnt = 1 }, @@ -424,7 +424,7 @@ /turf/open/floor/iron/dark, /area/ruin/unpowered) "Gs" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /turf/open/floor/plating{ broken = 1 }, @@ -461,7 +461,7 @@ dir = 8 }, /obj/structure/cable, -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/plating, /area/ruin/unpowered) "Iv" = ( @@ -483,7 +483,7 @@ /turf/open/floor/plating, /area/ruin/unpowered) "Ku" = ( -/mob/living/basic/trooper/pirate/melee, +/obj/effect/spawner/human_npc/ruin/pirate, /turf/open/floor/mineral/titanium{ broken = 1 }, @@ -639,7 +639,7 @@ /area/ruin/unpowered) "RQ" = ( /obj/effect/decal/cleanable/dirt/dust, -/mob/living/basic/trooper/pirate/ranged, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, /turf/open/floor/iron/dark, /area/ruin/unpowered) "RV" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_onlyaspoonful.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_onlyaspoonful.dmm index 729906e4db5..b3f7d1cbf67 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_onlyaspoonful.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_onlyaspoonful.dmm @@ -26,7 +26,7 @@ pixel_y = 9 }, /obj/effect/decal/cleanable/blood/tracks, -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, +/obj/effect/spawner/human_npc/ruin/hermit/hunter, /turf/open/floor/plastic, /area/ruin/powered) "ae" = ( @@ -92,7 +92,7 @@ /area/ruin/powered) "ew" = ( /obj/effect/decal/cleanable/dirt/dust, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor, +/obj/effect/spawner/human_npc/ruin/hermit, /turf/open/floor/plastic, /area/ruin/powered) "eH" = ( @@ -184,7 +184,7 @@ /turf/open/floor/plastic, /area/ruin/powered) "hw" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, +/obj/effect/spawner/human_npc/ruin/hermit/hunter, /turf/open/floor/plastic, /area/ruin/powered) "hA" = ( @@ -470,7 +470,7 @@ /turf/open/floor/material, /area/ruin/powered) "EC" = ( -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, +/obj/effect/spawner/human_npc/ruin/hermit/hunter, /turf/open/misc/wasteland/lit, /area/overmap_encounter/planetoid/wasteland) "Fi" = ( @@ -596,7 +596,7 @@ /area/ruin/powered) "NU" = ( /obj/effect/decal/cleanable/dirt/dust, -/mob/living/simple_animal/hostile/asteroid/hermit/survivor, +/obj/effect/spawner/human_npc/ruin/hermit, /turf/open/floor/material, /area/ruin/powered) "Oo" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_starfurycrash.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_starfurycrash.dmm index 6e0af1a52f6..fddbbd2cf2f 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_starfurycrash.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_starfurycrash.dmm @@ -27,7 +27,7 @@ /turf/open/floor/plating, /area/ruin/unpowered) "ag" = ( -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/iron/dark, /area/ruin/unpowered) "ah" = ( @@ -42,7 +42,7 @@ }, /area/ruin/unpowered) "ai" = ( -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/misc/dust/lit, /area/overmap_encounter/planetoid/wasteland) "aj" = ( @@ -191,7 +191,7 @@ /turf/closed/wall/r_wall/plastitanium/syndicate/nodiagonal, /area/ruin/unpowered) "CW" = ( -/mob/living/basic/trooper/syndicate/ranged/space, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner, /turf/open/floor/plating, /area/ruin/unpowered) "Dz" = ( diff --git a/_maps/voidcrew/ships/ship_hightide.dmm b/_maps/voidcrew/ships/ship_hightide.dmm index 4fe02ee2122..724818b48d5 100644 --- a/_maps/voidcrew/ships/ship_hightide.dmm +++ b/_maps/voidcrew/ships/ship_hightide.dmm @@ -976,11 +976,7 @@ /turf/open/floor/plating, /area/shuttle/voidcrew/hightide/maintenance/cargo) "RI" = ( -/mob/living/basic/trooper/pirate/ranged{ - desc = "Timmy is a classic pirate cosplayer. He was locked in this room by the previous owner. He's survived by eating the rats, this is why there are ZERO rats in the universe."; - health = 25; - name = "Timothy" - }, +/obj/effect/spawner/human_npc/ruin/pirate/cosplayer, /obj/effect/decal/cleanable/dirt/dust, /obj/effect/decal/cleanable/greenglow, /turf/open/floor/plating, diff --git a/_maps/voidcrew/shuttles/ruin_caravan_victim.dmm b/_maps/voidcrew/shuttles/ruin_caravan_victim.dmm new file mode 100644 index 00000000000..46e644d1e04 --- /dev/null +++ b/_maps/voidcrew/shuttles/ruin_caravan_victim.dmm @@ -0,0 +1,1225 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"aj" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 8 + }, +/obj/structure/closet/crate, +/obj/item/stack/sheet/plasteel/twenty, +/obj/item/stack/sheet/plasteel/twenty, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"co" = ( +/turf/closed/wall/mineral/titanium/nodiagonal, +/area/shuttle/ruin/caravan/freighter1) +"cu" = ( +/obj/effect/spawner/human_npc/ruin/syndicate/commando/smg, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"cC" = ( +/obj/machinery/firealarm/directional/north, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 1 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"cJ" = ( +/obj/structure/table, +/obj/item/storage/toolbox/mechanical, +/obj/item/multitool, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"dv" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/right{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"ex" = ( +/obj/structure/lattice, +/obj/item/stack/sheet/mineral/titanium, +/turf/template_noop, +/area/shuttle/ruin/caravan/freighter1) +"eN" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 4 + }, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"fJ" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 10 + }, +/obj/effect/decal/cleanable/blood, +/obj/effect/turf_decal/tile/yellow/half/contrasted{ + dir = 1 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"fR" = ( +/obj/effect/turf_decal/bot, +/obj/machinery/atmospherics/components/tank/air{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"hM" = ( +/obj/structure/rack, +/obj/item/tank/internals/oxygen, +/obj/item/radio, +/obj/item/clothing/mask/gas, +/obj/effect/turf_decal/tile/blue{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"hS" = ( +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"hZ" = ( +/obj/machinery/door/firedoor, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/airlock/command{ + name = "Bridge" + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"iB" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/mapping_helpers/burnt_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"jz" = ( +/obj/structure/closet/crate, +/obj/item/stack/sheet/mineral/silver{ + amount = 25 + }, +/obj/item/stack/sheet/mineral/silver{ + amount = 25 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"jQ" = ( +/obj/structure/table, +/obj/machinery/cell_charger, +/obj/machinery/firealarm/directional/north, +/obj/item/stack/cable_coil{ + pixel_x = 12; + pixel_y = 4 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"kg" = ( +/obj/machinery/door/poddoor{ + id = "caravantrade1_cargo"; + name = "Cargo Blast Door" + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"ll" = ( +/obj/machinery/power/port_gen/pacman{ + anchored = 1 + }, +/obj/item/wrench, +/obj/machinery/power/terminal, +/obj/effect/turf_decal/stripes/line{ + dir = 5 + }, +/obj/effect/decal/cleanable/dirt, +/obj/structure/cable, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"lD" = ( +/obj/structure/closet/crate/preopen, +/obj/item/stack/sheet/iron/fifty, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"lK" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/yellow{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"mb" = ( +/obj/machinery/light/small/directional/south, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/yellow/half/contrasted{ + dir = 1 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"mx" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"nj" = ( +/obj/machinery/airalarm/directional/west, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/blood, +/obj/machinery/atmospherics/components/unary/vent_pump/on, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"nI" = ( +/turf/template_noop, +/area/shuttle/ruin/caravan/freighter1) +"nO" = ( +/obj/machinery/airalarm/directional/east, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"oJ" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 1 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"pj" = ( +/obj/machinery/suit_storage_unit/standard_unit, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"px" = ( +/obj/machinery/light/directional/north, +/obj/structure/table, +/obj/item/stack/package_wrap, +/obj/item/crowbar, +/obj/item/flashlight{ + pixel_x = 1; + pixel_y = 5 + }, +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"rt" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/turf_decal/tile/blue/half/contrasted{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"rz" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 4 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"rC" = ( +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"su" = ( +/obj/structure/table, +/obj/machinery/microwave{ + pixel_y = 5 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"sJ" = ( +/obj/effect/decal/cleanable/dirt, +/obj/structure/table/reinforced, +/obj/item/paper_bin{ + pixel_x = 6; + pixel_y = 6 + }, +/obj/item/pen{ + pixel_x = 6; + pixel_y = 6 + }, +/obj/item/folder/yellow{ + pixel_x = -6 + }, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"tp" = ( +/obj/machinery/door/airlock{ + id_tag = "caravantrade1_cabin1"; + name = "Cabin 1" + }, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"tH" = ( +/obj/structure/chair/stool/directional/west, +/obj/effect/turf_decal/tile/yellow/half/contrasted{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"tT" = ( +/obj/machinery/door/poddoor{ + id = "caravantrade1_cargo"; + name = "Cargo Blast Door" + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"uE" = ( +/obj/structure/table, +/obj/item/storage/box/donkpockets{ + pixel_x = 6; + pixel_y = 6 + }, +/obj/item/plate{ + pixel_x = -5; + pixel_y = -3 + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"uZ" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 1 + }, +/obj/structure/closet/crate, +/obj/item/stack/sheet/mineral/uranium{ + amount = 10 + }, +/obj/item/stack/sheet/mineral/uranium{ + amount = 10 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"vG" = ( +/obj/structure/table/reinforced, +/obj/machinery/button/door{ + id = "caravantrade1_bridge"; + name = "Ship Blast Door Control" + }, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted{ + dir = 4 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"wC" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/crate, +/obj/item/stack/sheet/mineral/gold{ + amount = 25 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"yv" = ( +/obj/effect/turf_decal/bot_white, +/obj/structure/closet/emcloset, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"zV" = ( +/obj/structure/lattice, +/obj/structure/broken_flooring/singular/directional/south, +/turf/template_noop, +/area/shuttle/ruin/caravan/freighter1) +"Bh" = ( +/obj/machinery/power/shuttle_engine/heater{ + dir = 8 + }, +/obj/structure/window/reinforced/spawner/directional/east, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Bp" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 10 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"BC" = ( +/obj/structure/lattice, +/turf/template_noop, +/area/shuttle/ruin/caravan/freighter1) +"BY" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 8 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"CA" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 8 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Da" = ( +/obj/machinery/door/poddoor{ + id = "caravantrade1_cargo"; + name = "Cargo Blast Door" + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Dg" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/computer/camera_advanced/shuttle_docker/caravan/trade1{ + dir = 8 + }, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"DV" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/computer/shuttle/caravan/trade1{ + dir = 8 + }, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"Eh" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 1 + }, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"EO" = ( +/obj/effect/spawner/structure/window/reinforced/shuttle, +/obj/machinery/door/poddoor{ + id = "caravantrade1_bridge" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/freighter1) +"Fb" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/firealarm/directional/east, +/obj/effect/decal/cleanable/blood, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 8 + }, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/smg, +/obj/effect/turf_decal/tile/yellow{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"Fu" = ( +/obj/effect/decal/cleanable/blood, +/obj/effect/spawner/human_npc/ruin/syndicate/stormtrooper/sword, +/obj/effect/turf_decal/tile/blue{ + dir = 4 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"FQ" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 8 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Gq" = ( +/obj/structure/girder, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"He" = ( +/obj/machinery/door/airlock{ + id_tag = "caravantrade1_cabin2"; + name = "Cabin 2" + }, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Hz" = ( +/obj/machinery/door/firedoor, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/structure/door_assembly/door_assembly_min{ + anchored = 1; + density = 0; + name = "broken airlock" + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"HF" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 8 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"HY" = ( +/obj/machinery/light/small/directional/west, +/obj/machinery/firealarm/directional/west, +/obj/effect/decal/cleanable/dirt, +/obj/structure/rack, +/obj/item/storage/toolbox/emergency, +/obj/item/wrench, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"Id" = ( +/obj/structure/rack, +/obj/item/storage/belt/utility, +/obj/structure/extinguisher_cabinet/directional/north, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Ix" = ( +/obj/machinery/light/small/directional/south, +/obj/structure/bed{ + dir = 4 + }, +/obj/item/bedsheet{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/airalarm/directional/east, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 1 + }, +/obj/machinery/button/door/directional/west{ + id = "caravantrade1_cabin2"; + name = "Cabin Bolt Control"; + normaldoorcontrol = 1; + specialfunctions = 4 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"IA" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on, +/obj/machinery/button/door/directional/west{ + id = "caravantrade1_bolt"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + pixel_y = 8; + specialfunctions = 4 + }, +/obj/effect/turf_decal/tile/blue, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"IY" = ( +/obj/machinery/door/airlock{ + name = "Restroom" + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/showroomfloor{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"Jv" = ( +/turf/template_noop, +/area/template_noop) +"JH" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"JU" = ( +/obj/item/stack/sheet/iron/fifty, +/obj/effect/mapping_helpers/burnt_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"JW" = ( +/turf/closed/wall/mineral/titanium, +/area/shuttle/ruin/caravan/freighter1) +"KD" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/spawner/human_npc/ruin/syndicate/commando/smg, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Ld" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Lo" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 1 + }, +/obj/structure/closet/crate, +/obj/item/stack/sheet/rglass{ + amount = 20 + }, +/obj/item/stack/sheet/rglass{ + amount = 20 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"LI" = ( +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 1 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"LL" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"LO" = ( +/obj/effect/turf_decal/box/white/corners{ + dir = 8 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Mc" = ( +/obj/machinery/light/small/directional/west, +/obj/structure/sign/warning/vacuum/directional/east, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/freighter1) +"Mr" = ( +/obj/machinery/light/small/directional/north, +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"ME" = ( +/obj/effect/turf_decal/box/white/corners, +/obj/structure/closet/crate, +/obj/item/stack/sheet/glass/fifty, +/obj/item/stack/sheet/glass/fifty, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Ni" = ( +/obj/machinery/light/small/directional/south, +/obj/structure/bed, +/obj/item/bedsheet, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/airalarm/directional/east, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 1 + }, +/obj/machinery/button/door/directional/west{ + id = "caravantrade1_cabin1"; + name = "Cabin Bolt Control"; + normaldoorcontrol = 1; + specialfunctions = 4 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"NQ" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 1 + }, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravantrade1_bolt" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/freighter1) +"Op" = ( +/obj/machinery/door/firedoor, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/airlock/engineering{ + name = "Engine Room" + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"OW" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/mob_spawn/corpse/human/cargo_tech, +/obj/effect/decal/cleanable/blood, +/obj/effect/turf_decal/tile/blue, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"Pf" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/power/apc/auto_name/directional/east, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/structure/cable, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Qi" = ( +/obj/machinery/airalarm/directional/west, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/mob_spawn/corpse/human/cargo_tech, +/obj/effect/decal/cleanable/blood, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Ql" = ( +/obj/machinery/light/small/directional/south, +/obj/effect/turf_decal/box/white/corners, +/obj/structure/closet/crate, +/obj/item/stack/sheet/mineral/diamond{ + amount = 5 + }, +/obj/machinery/button/door/directional/south{ + id = "caravantrade1_cargo"; + name = "Cargo Blast Door Control" + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Qu" = ( +/obj/machinery/door/airlock{ + name = "Crew Quarters" + }, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"QH" = ( +/obj/effect/turf_decal/bot, +/obj/machinery/space_heater, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 4 + }, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Rv" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Tb" = ( +/obj/machinery/power/smes, +/obj/effect/turf_decal/stripes/line{ + dir = 4 + }, +/obj/structure/cable, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"To" = ( +/obj/machinery/door/airlock{ + name = "Crew Cabins" + }, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"Tq" = ( +/obj/structure/closet/crate, +/obj/item/stack/sheet/mineral/titanium{ + amount = 20 + }, +/obj/item/stack/sheet/mineral/titanium{ + amount = 20 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Tu" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravantrade1_bolt" + }, +/obj/docking_port/mobile{ + callTime = 250; + dir = 2; + shuttle_id = "caravantrade1"; + movement_force = list("KNOCKDOWN" = 0, "THROW" = 0); + name = "Small Freighter"; + port_direction = 8; + preferred_direction = 4 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/freighter1) +"Tx" = ( +/obj/machinery/light/small/directional/west, +/obj/effect/decal/cleanable/dirt, +/obj/structure/rack, +/obj/item/storage/medkit/regular, +/obj/effect/turf_decal/tile/blue/anticorner/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"TY" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 1 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Uz" = ( +/obj/structure/toilet{ + dir = 4 + }, +/obj/structure/sink/directional/south, +/obj/machinery/light/small/directional/south, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/showroomfloor{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"UK" = ( +/obj/structure/closet/secure_closet/freezer{ + locked = 0; + name = "fridge" + }, +/obj/item/reagent_containers/cup/glass/bottle/beer{ + pixel_x = -3; + pixel_y = 3 + }, +/obj/item/reagent_containers/cup/glass/bottle/beer, +/obj/item/reagent_containers/cup/glass/bottle/beer{ + pixel_x = 3; + pixel_y = -3 + }, +/obj/item/reagent_containers/cup/glass/waterbottle{ + pixel_x = -3; + pixel_y = 3 + }, +/obj/item/reagent_containers/cup/glass/waterbottle, +/obj/item/reagent_containers/cup/glass/waterbottle{ + pixel_x = 3; + pixel_y = -3 + }, +/obj/item/food/pizzaslice/margherita{ + pixel_x = -3; + pixel_y = 3 + }, +/obj/item/food/pizzaslice/margherita, +/obj/item/food/chocolatebar, +/obj/effect/turf_decal/tile/yellow/anticorner/contrasted{ + dir = 4 + }, +/turf/open/floor/iron{ + initial_gas_mix = "TEMP=2.7" + }, +/area/shuttle/ruin/caravan/freighter1) +"VA" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 9 + }, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"Xq" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/machinery/meter, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"XE" = ( +/obj/machinery/light/small/directional/south, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"XM" = ( +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"XN" = ( +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Yt" = ( +/obj/item/stack/sheet/mineral/titanium, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/mapping_helpers/broken_floor, +/turf/open/floor/iron/airless, +/area/shuttle/ruin/caravan/freighter1) +"YG" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"Zq" = ( +/obj/structure/closet/crate, +/obj/item/stack/sheet/iron/twenty, +/obj/item/stack/sheet/glass{ + amount = 10 + }, +/obj/item/stack/rods/ten, +/obj/item/storage/box/lights/bulbs, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/stripes/line{ + dir = 6 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) +"ZA" = ( +/obj/effect/turf_decal/bot_white, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 1 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark/airless, +/area/shuttle/ruin/caravan/freighter1) +"ZG" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/left{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/freighter1) + +(1,1,1) = {" +Jv +ZG +LL +LL +LL +dv +JW +Jv +Jv +Jv +Jv +"} +(2,1,1) = {" +JW +Bh +Bh +Bh +Bh +Bh +co +ZG +LL +dv +Jv +"} +(3,1,1) = {" +co +QH +ll +Tb +Zq +fR +co +Bh +Bh +Bh +JW +"} +(4,1,1) = {" +co +oJ +YG +Pf +nO +Xq +co +uZ +jz +HF +co +"} +(5,1,1) = {" +co +Op +co +co +co +Op +co +wC +rC +Ql +co +"} +(6,1,1) = {" +co +cC +He +Ix +co +Bp +BY +TY +cu +XM +kg +"} +(7,1,1) = {" +EO +XE +co +co +co +Id +iB +Lo +hS +LO +Da +"} +(8,1,1) = {" +co +LI +tp +Ni +JW +px +JH +rz +hS +ME +tT +"} +(9,1,1) = {" +co +To +co +co +co +jQ +KD +JU +Tq +Rv +kg +"} +(10,1,1) = {" +co +lK +co +Uz +co +cJ +mx +Eh +lD +aj +tT +"} +(11,1,1) = {" +EO +mb +co +IY +co +co +iB +eN +Rv +BC +Ld +"} +(12,1,1) = {" +EO +fJ +Qi +FQ +CA +Hz +Yt +ZA +zV +nI +ex +"} +(13,1,1) = {" +EO +UK +tH +Fb +JH +co +yv +XN +BC +nI +nI +"} +(14,1,1) = {" +co +su +uE +co +Qu +co +co +Gq +nI +nI +Jv +"} +(15,1,1) = {" +co +co +co +co +Mr +pj +co +Jv +Jv +Jv +Jv +"} +(16,1,1) = {" +Tu +Mc +NQ +IA +rt +hM +EO +Jv +Jv +Jv +Jv +"} +(17,1,1) = {" +JW +co +co +co +hZ +co +co +Jv +Jv +Jv +Jv +"} +(18,1,1) = {" +Jv +co +Tx +nj +VA +HY +co +Jv +Jv +Jv +Jv +"} +(19,1,1) = {" +Jv +EO +sJ +Fu +OW +vG +EO +Jv +Jv +Jv +Jv +"} +(20,1,1) = {" +Jv +EO +EO +Dg +DV +EO +EO +Jv +Jv +Jv +Jv +"} +(21,1,1) = {" +Jv +Jv +EO +EO +EO +EO +Jv +Jv +Jv +Jv +Jv +"} diff --git a/_maps/voidcrew/shuttles/ruin_pirate_cutter.dmm b/_maps/voidcrew/shuttles/ruin_pirate_cutter.dmm new file mode 100644 index 00000000000..1007292411e --- /dev/null +++ b/_maps/voidcrew/shuttles/ruin_pirate_cutter.dmm @@ -0,0 +1,1177 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"cf" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 1 + }, +/turf/open/floor/iron/white/side{ + dir = 1 + }, +/area/shuttle/ruin/caravan/pirate) +"co" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"dS" = ( +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"es" = ( +/obj/machinery/power/port_gen/pacman{ + anchored = 1 + }, +/obj/item/wrench, +/obj/effect/turf_decal/stripes/line{ + dir = 6 + }, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"fb" = ( +/obj/structure/table, +/obj/item/reagent_containers/cup/glass/bottle/rum{ + pixel_x = 3; + pixel_y = 6 + }, +/obj/item/reagent_containers/cup/glass/bottle/rum, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"ge" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"gS" = ( +/obj/structure/closet{ + name = "pirate outfits" + }, +/obj/item/clothing/head/collectable/pirate, +/obj/item/clothing/suit/costume/pirate, +/obj/item/clothing/under/costume/pirate, +/obj/item/clothing/shoes/jackboots, +/obj/item/clothing/head/costume/pirate/bandana, +/turf/open/floor/iron/dark/side{ + dir = 5 + }, +/area/shuttle/ruin/caravan/pirate) +"hi" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/button/door/directional/south{ + id = "caravanpirate_bolt_starboard"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + specialfunctions = 4 + }, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"iM" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"iN" = ( +/obj/effect/spawner/human_npc/ruin/pirate, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"jc" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 9 + }, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"jk" = ( +/obj/structure/table/reinforced, +/obj/machinery/recharger, +/obj/item/melee/baton, +/obj/effect/turf_decal/tile/red, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"jn" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/left{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/pirate) +"jx" = ( +/obj/machinery/suit_storage_unit/open, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"ka" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/firedoor, +/obj/machinery/door/airlock/security{ + name = "Armory" + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"kU" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 1 + }, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"lD" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"lN" = ( +/obj/structure/chair/office{ + dir = 4 + }, +/obj/machinery/turretid{ + icon_state = "control_kill"; + lethal = 1; + locked = 0; + pixel_y = -30; + req_access = null + }, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"me" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravanpirate_bolt_starboard" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"ms" = ( +/obj/structure/table, +/obj/item/storage/fancy/donut_box{ + pixel_y = 18 + }, +/obj/item/storage/box/donkpockets{ + pixel_x = -3; + pixel_y = 7 + }, +/obj/item/storage/box/donkpockets{ + pixel_x = -6 + }, +/obj/item/reagent_containers/cup/glass/bottle/rum{ + pixel_x = 8; + pixel_y = 3 + }, +/obj/structure/sign/poster/contraband/red_rum/directional/east, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"nN" = ( +/obj/structure/table, +/obj/item/retractor, +/obj/item/hemostat, +/turf/open/floor/iron/white, +/area/shuttle/ruin/caravan/pirate) +"nT" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"pe" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 8 + }, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"pg" = ( +/obj/structure/closet{ + name = "pirate outfits" + }, +/obj/item/clothing/head/collectable/pirate, +/obj/item/clothing/suit/costume/pirate, +/obj/item/clothing/under/costume/pirate, +/obj/item/clothing/shoes/jackboots, +/obj/item/clothing/head/costume/pirate/bandana, +/obj/effect/turf_decal/tile/red{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"ph" = ( +/obj/machinery/door/poddoor{ + id = "caravanpirate_bridge" + }, +/obj/effect/spawner/structure/window/reinforced/plasma/plastitanium, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"pR" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"qB" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 1 + }, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravanpirate_bolt_starboard" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"qU" = ( +/obj/structure/table, +/obj/machinery/light/small/directional/west, +/obj/item/stack/spacecash/c200, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"se" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"sR" = ( +/obj/structure/table, +/obj/machinery/microwave{ + pixel_y = 5 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"sW" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/button/door/directional/north{ + id = "caravanpirate_bolt_port"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + specialfunctions = 4 + }, +/turf/open/floor/iron/white/side{ + dir = 1 + }, +/area/shuttle/ruin/caravan/pirate) +"ti" = ( +/obj/structure/table, +/obj/machinery/cell_charger, +/obj/item/stack/cable_coil, +/obj/item/stock_parts/power_store/cell/high, +/obj/effect/turf_decal/bot, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"ua" = ( +/obj/structure/table, +/obj/item/storage/box/lethalshot, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"ug" = ( +/obj/machinery/porta_turret/syndicate/pod{ + dir = 10; + faction = list("pirate") + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/pirate) +"uV" = ( +/obj/structure/reagent_dispensers/watertank, +/obj/item/reagent_containers/cup/bucket, +/obj/item/mop, +/obj/effect/turf_decal/stripes/line{ + dir = 5 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"vT" = ( +/obj/structure/table, +/obj/item/circular_saw, +/obj/item/scalpel{ + pixel_y = 12 + }, +/obj/item/cautery{ + pixel_x = 4 + }, +/obj/machinery/light/small/directional/west, +/turf/open/floor/iron/white/side{ + dir = 8 + }, +/area/shuttle/ruin/caravan/pirate) +"wg" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"wh" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"wE" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/firedoor, +/obj/machinery/door/airlock/medical/glass{ + name = "Medbay" + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"wP" = ( +/obj/structure/closet/crate/freezer/surplus_limbs, +/obj/effect/turf_decal/bot, +/turf/open/floor/iron/white, +/area/shuttle/ruin/caravan/pirate) +"wR" = ( +/obj/machinery/porta_turret/syndicate/pod{ + dir = 5; + faction = list("pirate") + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/pirate) +"wV" = ( +/obj/machinery/light/small/directional/north, +/obj/structure/bed, +/obj/item/bedsheet/pirate, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"xb" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 4 + }, +/obj/machinery/space_heater, +/obj/effect/turf_decal/stripes/line{ + dir = 6 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"xq" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden, +/obj/machinery/airalarm/directional/east, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/structure/cable, +/obj/machinery/power/apc/auto_name/directional/west, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"ya" = ( +/obj/structure/table/reinforced, +/obj/machinery/button/door{ + id = "caravanpirate_bridge"; + name = "Bridge Blast Door Control"; + pixel_x = -16 + }, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"yR" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Aj" = ( +/obj/machinery/computer/shuttle/caravan/pirate{ + dir = 8 + }, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"An" = ( +/obj/item/stack/sheet/mineral/gold{ + amount = 25 + }, +/obj/item/stack/sheet/mineral/bananium{ + amount = 5 + }, +/obj/item/stack/sheet/mineral/silver{ + amount = 25 + }, +/obj/item/stack/sheet/mineral/uranium{ + amount = 10 + }, +/obj/item/stack/sheet/mineral/diamond{ + amount = 5 + }, +/obj/structure/closet/crate, +/obj/item/coin/silver, +/obj/item/coin/silver, +/obj/item/coin/silver, +/obj/item/coin/gold, +/obj/item/coin/gold, +/obj/effect/turf_decal/tile/blue, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"AS" = ( +/obj/machinery/porta_turret/syndicate/pod{ + dir = 9; + faction = list("pirate") + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/pirate) +"AW" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 6 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"BX" = ( +/obj/structure/closet/crate/secure/loot, +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/all_access, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"CI" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 9 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Di" = ( +/obj/structure/table, +/obj/item/storage/medkit/brute{ + pixel_x = 3; + pixel_y = 3 + }, +/obj/item/storage/medkit/fire, +/obj/machinery/firealarm/directional/south, +/turf/open/floor/iron/white, +/area/shuttle/ruin/caravan/pirate) +"DQ" = ( +/obj/structure/reagent_dispensers/fueltank, +/obj/effect/turf_decal/stripes/line{ + dir = 4 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Eh" = ( +/obj/machinery/atmospherics/components/unary/vent_pump/on, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Fn" = ( +/obj/machinery/light/small/directional/west, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 8 + }, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"FB" = ( +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"FI" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/firedoor, +/obj/machinery/door/airlock/engineering{ + name = "Engineering" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"FX" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/right{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/pirate) +"Gj" = ( +/obj/structure/tank_dispenser/oxygen, +/obj/machinery/firealarm/directional/north, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"GL" = ( +/obj/structure/bed{ + dir = 4 + }, +/obj/item/bedsheet/pirate{ + dir = 4 + }, +/obj/machinery/airalarm/directional/west, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/effect/turf_decal/tile/red{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Ha" = ( +/obj/machinery/light/small/directional/south, +/obj/structure/bed, +/obj/item/bedsheet/pirate, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Hb" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 5 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"Hl" = ( +/obj/machinery/porta_turret/syndicate/pod{ + dir = 6; + faction = list("pirate") + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/pirate) +"Hu" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/machinery/door/firedoor, +/obj/machinery/door/airlock/command{ + name = "Bridge" + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Ir" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 8 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Iu" = ( +/obj/structure/table, +/obj/machinery/door/window/right/directional/south{ + name = "Weapon Storage" + }, +/obj/item/gun/energy/laser, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Jv" = ( +/turf/template_noop, +/area/template_noop) +"JJ" = ( +/obj/structure/table/optable, +/obj/effect/turf_decal/bot, +/turf/open/floor/iron/white, +/area/shuttle/ruin/caravan/pirate) +"JP" = ( +/obj/machinery/computer/camera_advanced/shuttle_docker/caravan/pirate, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Kc" = ( +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/pirate) +"KL" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/pirate) +"Ll" = ( +/obj/structure/rack, +/obj/item/storage/toolbox/emergency, +/obj/item/weldingtool, +/obj/effect/turf_decal/tile/red{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Mk" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Ms" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravanpirate_bolt_port" + }, +/obj/docking_port/mobile{ + callTime = 150; + dir = 2; + shuttle_id = "caravanpirate"; + movement_force = list("KNOCKDOWN"=0,"THROW"=0); + name = "Pirate Cutter"; + port_direction = 8; + preferred_direction = 4 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Ns" = ( +/obj/machinery/light/small/directional/east, +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 10 + }, +/turf/open/floor/iron/white/side{ + dir = 1 + }, +/area/shuttle/ruin/caravan/pirate) +"Oo" = ( +/obj/machinery/light/small/directional/west, +/obj/structure/sign/warning/vacuum/directional/west, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Or" = ( +/obj/structure/chair/office{ + dir = 8 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"OO" = ( +/obj/machinery/sleeper{ + dir = 1 + }, +/obj/effect/turf_decal/delivery, +/obj/machinery/airalarm/directional/south, +/obj/effect/mapping_helpers/airalarm/all_access, +/turf/open/floor/iron/white, +/area/shuttle/ruin/caravan/pirate) +"Po" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/dark/side{ + dir = 4 + }, +/area/shuttle/ruin/caravan/pirate) +"PC" = ( +/obj/machinery/power/terminal{ + dir = 1 + }, +/obj/structure/closet/crate, +/obj/item/stack/sheet/iron/twenty, +/obj/item/stack/sheet/glass{ + amount = 10 + }, +/obj/item/storage/toolbox/mechanical, +/obj/item/flashlight{ + pixel_x = 3; + pixel_y = 3 + }, +/obj/item/stack/sheet/mineral/plasma{ + amount = 20 + }, +/obj/effect/turf_decal/stripes/line{ + dir = 4 + }, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Qb" = ( +/obj/structure/sign/departments/medbay/alt, +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/pirate) +"Rb" = ( +/obj/structure/table, +/obj/structure/window/reinforced/spawner/directional/west, +/obj/machinery/door/window/left/directional/south{ + name = "Weapon Storage" + }, +/obj/item/grenade/smokebomb{ + pixel_x = -4 + }, +/obj/item/grenade/smokebomb{ + pixel_x = 2 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Ry" = ( +/obj/structure/rack, +/obj/item/storage/bag/money/vault, +/mob/living/basic/parrot{ + faction = list("pirate"); + name = "Pegwing" + }, +/obj/effect/turf_decal/tile/blue{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"SU" = ( +/obj/machinery/power/shuttle_engine/heater{ + dir = 8 + }, +/obj/structure/window/reinforced/spawner/directional/east, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/pirate) +"UH" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/obj/effect/turf_decal/tile/blue/half/contrasted{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Vh" = ( +/obj/structure/sign/departments/engineering, +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/pirate) +"Vs" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/white/side{ + dir = 1 + }, +/area/shuttle/ruin/caravan/pirate) +"Vz" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 9 + }, +/obj/machinery/firealarm/directional/south, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"VF" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 4 + }, +/turf/open/floor/iron/white/side{ + dir = 9 + }, +/area/shuttle/ruin/caravan/pirate) +"We" = ( +/obj/machinery/power/smes/super/full, +/obj/effect/turf_decal/stripes/line{ + dir = 5 + }, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Wi" = ( +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 1 + }, +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/external/ruin{ + id_tag = "caravanpirate_bolt_port" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Wo" = ( +/obj/machinery/light/small/directional/west, +/obj/structure/closet/crate/secure/loot, +/obj/machinery/atmospherics/components/unary/vent_pump/on{ + dir = 4 + }, +/obj/structure/extinguisher_cabinet{ + pixel_x = -27 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"WV" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/spawner/human_npc/ruin/pirate/gunner, +/turf/open/floor/iron, +/area/shuttle/ruin/caravan/pirate) +"Xw" = ( +/obj/structure/table, +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/all_access, +/obj/item/ammo_box/a40mm, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"XF" = ( +/obj/structure/table, +/obj/item/coin/gold, +/obj/item/coin/silver, +/obj/item/coin/silver, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Yc" = ( +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/pirate) +"Ye" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 10 + }, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/pirate) +"Yi" = ( +/obj/effect/spawner/structure/window/reinforced/plasma/plastitanium, +/obj/machinery/door/poddoor{ + id = "caravanpirate_bridge" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Yy" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 8 + }, +/obj/machinery/meter, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Zp" = ( +/obj/machinery/atmospherics/components/tank/air, +/obj/effect/turf_decal/bot, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) +"Zq" = ( +/obj/structure/bed{ + dir = 4 + }, +/obj/item/bedsheet/pirate{ + dir = 4 + }, +/obj/machinery/firealarm/directional/west, +/turf/open/floor/iron/dark/side{ + dir = 6 + }, +/area/shuttle/ruin/caravan/pirate) +"ZU" = ( +/obj/machinery/atmospherics/pipe/smart/manifold4w/supply/hidden{ + dir = 5 + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/pirate) + +(1,1,1) = {" +Jv +Jv +Jv +jn +KL +FX +Jv +jn +KL +FX +Jv +Jv +Jv +"} +(2,1,1) = {" +Jv +Jv +Yc +SU +SU +SU +Kc +SU +SU +SU +Yc +Jv +Jv +"} +(3,1,1) = {" +Jv +AS +Kc +uV +DQ +xb +Kc +We +PC +es +Kc +ug +Jv +"} +(4,1,1) = {" +Jv +Kc +Zp +Yy +wg +pR +xq +iM +se +ZU +ti +Kc +Jv +"} +(5,1,1) = {" +Jv +Kc +Kc +FI +Vh +Kc +Kc +Kc +Vh +FI +Kc +Kc +Jv +"} +(6,1,1) = {" +Jv +Kc +jx +VF +vT +nN +Kc +ua +Fn +pe +jx +Kc +Jv +"} +(7,1,1) = {" +Yc +Kc +Kc +Vs +FB +JJ +Kc +Xw +WV +co +Kc +Kc +Yc +"} +(8,1,1) = {" +Ms +Oo +Wi +cf +kU +Di +Kc +Gj +ge +lD +me +Oo +qB +"} +(9,1,1) = {" +Yc +Kc +Kc +sW +FB +OO +Kc +Rb +iN +hi +Kc +Kc +Yc +"} +(10,1,1) = {" +Jv +Yi +jx +Ns +Hb +wP +Kc +Iu +AW +jc +jx +ph +Jv +"} +(11,1,1) = {" +Jv +Yc +Kc +Qb +wE +Kc +Kc +Kc +ka +Kc +Kc +Yc +Jv +"} +(12,1,1) = {" +Jv +Jv +Kc +gS +Po +Zq +Wo +GL +Mk +pg +Kc +Jv +Jv +"} +(13,1,1) = {" +Jv +Jv +Kc +wV +Ye +nT +wh +Ir +CI +Ha +Kc +Jv +Jv +"} +(14,1,1) = {" +Jv +Jv +wR +Kc +sR +ms +An +UH +Ry +Kc +Hl +Jv +Jv +"} +(15,1,1) = {" +Jv +Jv +Jv +Yc +Kc +Kc +Kc +Hu +Kc +Yc +Jv +Jv +Jv +"} +(16,1,1) = {" +Jv +Jv +Jv +Jv +Kc +fb +qU +yR +Kc +Jv +Jv +Jv +Jv +"} +(17,1,1) = {" +Jv +Jv +Jv +Jv +Kc +XF +Or +yR +Kc +Jv +Jv +Jv +Jv +"} +(18,1,1) = {" +Jv +Jv +Jv +Jv +Kc +BX +Eh +Vz +Kc +Jv +Jv +Jv +Jv +"} +(19,1,1) = {" +Jv +Jv +Jv +Jv +Kc +Ll +dS +jk +Kc +Jv +Jv +Jv +Jv +"} +(20,1,1) = {" +Jv +Jv +Jv +Jv +ph +JP +lN +ya +Yi +Jv +Jv +Jv +Jv +"} +(21,1,1) = {" +Jv +Jv +Jv +Jv +Yi +Yi +Aj +Yi +Yi +Jv +Jv +Jv +Jv +"} +(22,1,1) = {" +Jv +Jv +Jv +Jv +Jv +Yi +Yi +Yi +Jv +Jv +Jv +Jv +Jv +"} diff --git a/_maps/voidcrew/shuttles/ruin_syndicate_dropship.dmm b/_maps/voidcrew/shuttles/ruin_syndicate_dropship.dmm new file mode 100644 index 00000000000..31ca89403cd --- /dev/null +++ b/_maps/voidcrew/shuttles/ruin_syndicate_dropship.dmm @@ -0,0 +1,620 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"bh" = ( +/obj/machinery/door/airlock/hatch{ + id_tag = "caravansyndicate3_bolt_port"; + name = "External Airlock"; + normalspeed = 0 + }, +/obj/docking_port/mobile{ + dir = 2; + shuttle_id = "caravansyndicate3"; + name = "Syndicate Drop Ship"; + port_direction = 8; + preferred_direction = 4 + }, +/obj/effect/mapping_helpers/airlock/cyclelink_helper, +/obj/effect/mapping_helpers/airlock/access/all/syndicate/general, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"bt" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 8 + }, +/obj/structure/sign/warning/vacuum/directional/south, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/under/syndicate/combat, +/obj/item/storage/belt/military, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"bX" = ( +/obj/machinery/porta_turret/syndicate/energy{ + dir = 1 + }, +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/syndicate3) +"co" = ( +/obj/machinery/power/shuttle_engine/heater{ + dir = 8 + }, +/obj/structure/window/reinforced/spawner/directional/east, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/syndicate3) +"dt" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 6 + }, +/obj/machinery/power/port_gen/pacman{ + anchored = 1 + }, +/obj/item/wrench, +/obj/effect/decal/cleanable/dirt, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"dy" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 8 + }, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/structure/sign/warning/vacuum/directional/north, +/obj/effect/decal/cleanable/dirt, +/obj/item/clothing/shoes/sneakers/black, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"eV" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/left{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/syndicate3) +"fU" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/syndicate3) +"gv" = ( +/obj/structure/table/reinforced, +/obj/machinery/recharger, +/obj/machinery/light/small/directional/west, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"gz" = ( +/obj/structure/table/reinforced, +/obj/item/storage/toolbox/emergency, +/obj/item/wrench, +/obj/machinery/light/small/directional/west, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"id" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 10 + }, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/under/syndicate/combat, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"im" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/computer/camera_advanced/shuttle_docker/caravan/syndicate3{ + dir = 8 + }, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"iN" = ( +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"kc" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"kk" = ( +/obj/effect/spawner/structure/window/reinforced/plasma/plastitanium, +/obj/machinery/door/poddoor{ + id = "caravansyndicate3_bridge" + }, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"kR" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 8 + }, +/obj/machinery/light/small/directional/east, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/under/syndicate, +/obj/item/clothing/glasses/night, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"mq" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 10 + }, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/under/syndicate/combat, +/obj/item/clothing/shoes/jackboots, +/obj/item/storage/belt/military, +/obj/item/crowbar/red, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"nv" = ( +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"oW" = ( +/obj/machinery/light/small/directional/west, +/obj/machinery/button/door/directional/west{ + id = "caravansyndicate3_bolt_port"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + req_access = list("syndicate"); + specialfunctions = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"pY" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 1 + }, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/light/small/directional/south, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) +"rQ" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 9 + }, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/under/syndicate, +/obj/item/clothing/shoes/sneakers/black, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"sk" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 4 + }, +/obj/effect/turf_decal/tile/red, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"ts" = ( +/obj/machinery/airalarm/directional/west, +/obj/effect/mapping_helpers/airalarm/syndicate_access, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"uv" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 4 + }, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner/pilot, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"uI" = ( +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/syndicate3) +"wl" = ( +/obj/machinery/airalarm/directional/west, +/obj/effect/mapping_helpers/airalarm/syndicate_access, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"wS" = ( +/obj/machinery/door/airlock/hatch{ + id_tag = "caravansyndicate3_bolt_starboard"; + name = "External Airlock"; + normalspeed = 0 + }, +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 1 + }, +/obj/effect/mapping_helpers/airlock/access/all/syndicate/general, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"zB" = ( +/obj/structure/chair/comfy/shuttle, +/obj/machinery/light/small/directional/north, +/obj/machinery/power/apc/auto_name/directional/west, +/obj/effect/mapping_helpers/apc/syndicate_access, +/obj/effect/decal/cleanable/dirt, +/obj/structure/cable, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) +"zP" = ( +/obj/machinery/computer/crew{ + dir = 8 + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"DF" = ( +/obj/machinery/computer/shuttle/caravan/syndicate3{ + dir = 8 + }, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 8 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"GU" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"HI" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 4 + }, +/obj/structure/closet/crate, +/obj/machinery/power/terminal{ + dir = 1 + }, +/obj/item/stack/sheet/iron/twenty, +/obj/item/stack/sheet/glass{ + amount = 10 + }, +/obj/item/stack/sheet/mineral/plastitanium{ + amount = 20 + }, +/obj/item/storage/box/lights/bulbs, +/obj/item/storage/toolbox/mechanical, +/obj/item/stack/sheet/mineral/plasma{ + amount = 20 + }, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"Ib" = ( +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Ie" = ( +/obj/structure/table/reinforced, +/obj/machinery/button/door{ + id = "caravansyndicate3_bridge"; + name = "Bridge Blast Door Control"; + pixel_x = -16; + pixel_y = 5; + req_access = list("syndicate") + }, +/obj/machinery/button/door{ + id = "caravansyndicate3_bolt_bridge"; + name = "Bridge Bolt Control"; + normaldoorcontrol = 1; + pixel_x = -16; + pixel_y = -5; + req_access = list("syndicate"); + specialfunctions = 4 + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/red/anticorner/contrasted, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Jv" = ( +/turf/template_noop, +/area/template_noop) +"JK" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 8 + }, +/obj/machinery/light/small/directional/east, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/storage/box/syndie_kit/chameleon, +/obj/item/crowbar/red, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"JT" = ( +/obj/machinery/light/small/directional/west, +/obj/machinery/button/door/directional/west{ + id = "caravansyndicate3_bolt_starboard"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + req_access = list("syndicate"); + specialfunctions = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"KN" = ( +/obj/machinery/firealarm/directional/west, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"KW" = ( +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"LF" = ( +/obj/structure/chair/comfy/shuttle, +/obj/machinery/airalarm/directional/north, +/obj/effect/mapping_helpers/airalarm/syndicate_access, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Mc" = ( +/obj/effect/turf_decal/tile/red/half/contrasted, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Nn" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 1 + }, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Oe" = ( +/obj/machinery/power/smes/super/full, +/obj/effect/turf_decal/stripes/line{ + dir = 5 + }, +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/obj/structure/cable, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate3) +"Pp" = ( +/obj/effect/turf_decal/stripes/line{ + dir = 9 + }, +/obj/effect/decal/cleanable/dirt, +/obj/structure/closet/syndicate{ + anchored = 1 + }, +/obj/item/clothing/shoes/jackboots, +/obj/item/crowbar/red, +/turf/open/floor/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate3) +"Ry" = ( +/obj/machinery/door/airlock/hatch{ + name = "Ready Room" + }, +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 4 + }, +/obj/effect/mapping_helpers/airlock/access/all/syndicate/general, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"RT" = ( +/obj/effect/decal/cleanable/dirt, +/obj/structure/cable, +/obj/effect/turf_decal/tile/red/half/contrasted{ + dir = 1 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"SU" = ( +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/hatch{ + id_tag = "caravansyndicate3_bolt_bridge"; + name = "Bridge" + }, +/obj/effect/mapping_helpers/airlock/cyclelink_helper{ + dir = 8 + }, +/obj/effect/mapping_helpers/airlock/access/all/syndicate/general, +/obj/effect/turf_decal/tile/neutral/fourcorners, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Te" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst/right{ + dir = 8 + }, +/turf/open/floor/plating/airless, +/area/shuttle/ruin/caravan/syndicate3) +"TT" = ( +/obj/machinery/turretid{ + ailock = 1; + desc = "A specially designed set of turret controls. Looks to be covered in protective casing to prevent AI interfacing."; + icon_state = "control_kill"; + lethal = 1; + name = "Shuttle turret control"; + pixel_y = 34; + req_access = list("syndicate") + }, +/obj/structure/chair/comfy/shuttle{ + dir = 4 + }, +/obj/effect/turf_decal/tile/red{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"UI" = ( +/obj/machinery/porta_turret/syndicate/energy, +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/syndicate3) +"UV" = ( +/obj/structure/table/reinforced, +/obj/item/storage/medkit/regular, +/obj/item/assembly/flash/handheld, +/obj/effect/decal/cleanable/dirt, +/obj/effect/turf_decal/tile/red/anticorner/contrasted{ + dir = 4 + }, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Vh" = ( +/obj/structure/chair/comfy/shuttle, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) +"Vp" = ( +/obj/machinery/firealarm/directional/west, +/turf/open/floor/iron/dark, +/area/shuttle/ruin/caravan/syndicate3) +"ZF" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 1 + }, +/obj/machinery/firealarm/directional/south, +/obj/effect/decal/cleanable/dirt, +/turf/open/floor/pod/dark, +/area/shuttle/ruin/caravan/syndicate3) + +(1,1,1) = {" +Jv +eV +fU +fU +fU +Te +Jv +"} +(2,1,1) = {" +KW +co +co +co +co +co +KW +"} +(3,1,1) = {" +uI +uI +Oe +HI +dt +uI +uI +"} +(4,1,1) = {" +uI +zB +RT +nv +Mc +pY +uI +"} +(5,1,1) = {" +uI +LF +Ib +iN +Mc +ZF +uI +"} +(6,1,1) = {" +uI +Vh +Ib +iN +kc +Nn +uI +"} +(7,1,1) = {" +uI +kR +mq +iN +rQ +JK +uI +"} +(8,1,1) = {" +bX +uI +uI +Ry +uI +uI +UI +"} +(9,1,1) = {" +bh +oW +wl +iN +Vp +JT +wS +"} +(10,1,1) = {" +uI +dy +id +GU +Pp +bt +uI +"} +(11,1,1) = {" +uI +uI +uI +SU +uI +uI +uI +"} +(12,1,1) = {" +uI +gv +ts +iN +KN +gz +uI +"} +(13,1,1) = {" +kk +Ie +TT +uv +sk +UV +kk +"} +(14,1,1) = {" +kk +kk +im +DF +zP +kk +kk +"} +(15,1,1) = {" +Jv +kk +kk +kk +kk +kk +Jv +"} diff --git a/_maps/voidcrew/shuttles/ruin_syndicate_fighter_shiv.dmm b/_maps/voidcrew/shuttles/ruin_syndicate_fighter_shiv.dmm new file mode 100644 index 00000000000..9ca923f4561 --- /dev/null +++ b/_maps/voidcrew/shuttles/ruin_syndicate_fighter_shiv.dmm @@ -0,0 +1,156 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"cZ" = ( +/obj/machinery/porta_turret/syndicate/energy{ + dir = 4 + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate1) +"dc" = ( +/obj/structure/chair/comfy/shuttle{ + dir = 4 + }, +/obj/machinery/turretid{ + ailock = 1; + desc = "A specially designed set of turret controls. Looks to be covered in protective casing to prevent AI interfacing."; + icon_state = "control_kill"; + lethal = 1; + name = "Shuttle turret control"; + pixel_x = 32; + pixel_y = -28; + req_access = list("syndicate") + }, +/obj/structure/cable, +/obj/effect/spawner/human_npc/ruin/syndicate/gunner/pilot, +/turf/open/floor/mineral/plastitanium/red, +/area/shuttle/ruin/caravan/syndicate1) +"ie" = ( +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate1) +"qe" = ( +/obj/effect/decal/cleanable/dirt, +/obj/effect/decal/cleanable/dirt, +/obj/machinery/computer/shuttle/caravan/syndicate1{ + dir = 8 + }, +/turf/open/floor/mineral/plastitanium/red, +/area/shuttle/ruin/caravan/syndicate1) +"qu" = ( +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/syndicate1) +"tO" = ( +/obj/effect/decal/cleanable/dirt, +/obj/machinery/computer/camera_advanced/shuttle_docker/caravan/syndicate1, +/obj/machinery/button/door/directional/west{ + id = "caravansyndicate1_bolt"; + name = "External Bolt Control"; + normaldoorcontrol = 1; + req_access = list("syndicate"); + specialfunctions = 4 + }, +/turf/open/floor/mineral/plastitanium/red, +/area/shuttle/ruin/caravan/syndicate1) +"Jv" = ( +/turf/template_noop, +/area/template_noop) +"UZ" = ( +/obj/machinery/power/shuttle_engine/propulsion/burst{ + dir = 8 + }, +/turf/closed/wall/mineral/plastitanium, +/area/shuttle/ruin/caravan/syndicate1) +"Ve" = ( +/obj/machinery/camera/xray/directional/west{ + c_tag = "External View"; + network = list("caravansyndicate1"); + pixel_x = 32 + }, +/turf/closed/wall/mineral/plastitanium/nodiagonal, +/area/shuttle/ruin/caravan/syndicate1) +"Yi" = ( +/obj/effect/mapping_helpers/airlock/locked, +/obj/machinery/door/airlock/hatch{ + id_tag = "caravansyndicate1_bolt"; + name = "External Airlock"; + normalspeed = 0 + }, +/obj/effect/decal/cleanable/dirt, +/obj/docking_port/mobile{ + callTime = 50; + dir = 4; + shuttle_id = "caravansyndicate1"; + ignitionTime = 25; + name = "Syndicate Fighter"; + port_direction = 2; + preferred_direction = 4 + }, +/obj/structure/cable, +/obj/effect/mapping_helpers/airlock/access/all/syndicate/general, +/turf/open/floor/plating, +/area/shuttle/ruin/caravan/syndicate1) +"YY" = ( +/obj/machinery/power/apc/auto_name/directional/west, +/obj/effect/mapping_helpers/apc/syndicate_access, +/obj/effect/mapping_helpers/apc/cell_10k, +/obj/machinery/computer/security{ + dir = 1; + network = list("caravansyndicate1") + }, +/obj/structure/cable, +/turf/open/floor/mineral/plastitanium/red, +/area/shuttle/ruin/caravan/syndicate1) + +(1,1,1) = {" +Jv +Jv +UZ +qu +Yi +qu +UZ +Jv +Jv +"} +(2,1,1) = {" +Jv +UZ +qu +tO +dc +YY +qu +UZ +Jv +"} +(3,1,1) = {" +ie +qu +qu +qu +qe +qu +qu +qu +ie +"} +(4,1,1) = {" +qu +ie +Jv +cZ +Ve +cZ +Jv +ie +qu +"} +(5,1,1) = {" +cZ +Jv +Jv +Jv +Jv +Jv +Jv +Jv +cZ +"} diff --git a/code/modules/unit_tests/simple_animal_freeze.dm b/code/modules/unit_tests/simple_animal_freeze.dm index daefc29f871..457b208f62e 100644 --- a/code/modules/unit_tests/simple_animal_freeze.dm +++ b/code/modules/unit_tests/simple_animal_freeze.dm @@ -33,12 +33,6 @@ // mob set, grandfathered here so the freeze still catches genuinely new // simple animals. They are queued for the basic mob refactor like the rest. // VOIDCREW EDIT START: unit_tests retains the existing mining fauna during basic-mob migration. - /mob/living/simple_animal/hostile/asteroid/hermit, - /mob/living/simple_animal/hostile/asteroid/hermit/ranged, - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger, - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, - /mob/living/simple_animal/hostile/asteroid/hermit/survivor, - /mob/living/simple_animal/hostile/asteroid/hermit/survivor/random, /mob/living/simple_animal/hostile/asteroid/old_demon, // VOIDCREW EDIT END /mob/living/simple_animal/hostile/asteroid/polarbear, diff --git a/tgstation.dme b/tgstation.dme index e3dcc2950d3..7dbf7bd8344 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7243,6 +7243,11 @@ #include "voidcrew\modules\human_npcs\human_npc_spike_harness.dm" #include "voidcrew\modules\human_npcs\human_npc_states.dm" #include "voidcrew\modules\human_npcs\human_npc_surrender.dm" +#include "voidcrew\modules\human_npcs\ruins\ruin_npc_base.dm" +#include "voidcrew\modules\human_npcs\ruins\ruin_npc_faction_pirates.dm" +#include "voidcrew\modules\human_npcs\ruins\ruin_npc_hermits.dm" +#include "voidcrew\modules\human_npcs\ruins\ruin_npc_spawners.dm" +#include "voidcrew\modules\human_npcs\ruins\ruin_npc_troopers.dm" #include "voidcrew\modules\hyperviolence\_core.dm" #include "voidcrew\modules\hyperviolence\blood\_blood_defines.dm" #include "voidcrew\modules\hyperviolence\blood\blood_pools.dm" @@ -7375,7 +7380,6 @@ #include "voidcrew\modules\mob\living\simple_animal\hostile\hivebot.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\killertomato.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\mushroom.dm" -#include "voidcrew\modules\mob\living\simple_animal\hostile\pirate.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\wumborian_fugu.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\jungle\mega_arachnid.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\mining_mobs\basilisk.dm" @@ -7384,7 +7388,6 @@ #include "voidcrew\modules\mob\living\simple_animal\hostile\mining_mobs\ice_demon.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\mining_mobs\polarbear.dm" #include "voidcrew\modules\mob\living\simple_animal\hostile\mining_mobs\wolf.dm" -#include "voidcrew\modules\mob\living\simple_animal\hostile\wasteland\hermits.dm" #include "voidcrew\modules\mob\living\simple_animal\slime\slime.dm" #include "voidcrew\modules\mob_spawn\corpses\wasteland_corpses.dm" #include "voidcrew\modules\mob_spawn\ghost_roles\skeleton_roles.dm" diff --git a/voidcrew/controllers/subsystem/planet_mobs.dm b/voidcrew/controllers/subsystem/planet_mobs.dm index ba6718aa84a..f772ec5db19 100644 --- a/voidcrew/controllers/subsystem/planet_mobs.dm +++ b/voidcrew/controllers/subsystem/planet_mobs.dm @@ -351,7 +351,7 @@ SUBSYSTEM_DEF(planet_mobs) continue var/datum/npc_spawn_context/context = new(tracker, tracker.zone_band, source_turf = candidate) - spawn_npc_in_zone(mob_type, candidate, context) + spawn_npc_in_zone_or_queue(mob_type, candidate, context) spawned++ total_managed_mobs++ diff --git a/voidcrew/datums/mapgen/PlanetGenerator.dm b/voidcrew/datums/mapgen/PlanetGenerator.dm index 4bfb47df656..9b5c02e7fa1 100644 --- a/voidcrew/datums/mapgen/PlanetGenerator.dm +++ b/voidcrew/datums/mapgen/PlanetGenerator.dm @@ -552,7 +552,7 @@ if(can_spawn) var/datum/npc_spawn_context/context = new(src, zone_band, "auto", source_turf = target_turf) - spawn_npc_in_zone(picked_mob, target_turf, context) + spawn_npc_in_zone_or_queue(picked_mob, target_turf, context) spawned_something = TRUE // The expensive half of a planet build - every iteration runs several range() // scans - and the one that most needs to stop hogging the tick. See diff --git a/voidcrew/datums/mapgen/biomes/_biome.dm b/voidcrew/datums/mapgen/biomes/_biome.dm index 6ef081c4196..80c1637c63d 100644 --- a/voidcrew/datums/mapgen/biomes/_biome.dm +++ b/voidcrew/datums/mapgen/biomes/_biome.dm @@ -126,7 +126,7 @@ if(can_spawn) var/datum/npc_spawn_context/context = new(src, source_turf = new_turf) - spawn_npc_in_zone(picked_mob, new_turf, context) + spawn_npc_in_zone_or_queue(picked_mob, new_turf, context) new_turf.flags_1 |= NO_LAVA_GEN diff --git a/voidcrew/datums/mapgen/biomes/beach_biomes.dm b/voidcrew/datums/mapgen/biomes/beach_biomes.dm index 6b7d8ffa634..ce3af2a53ca 100644 --- a/voidcrew/datums/mapgen/biomes/beach_biomes.dm +++ b/voidcrew/datums/mapgen/biomes/beach_biomes.dm @@ -94,7 +94,7 @@ dangerous_mob_spawn_list = list( /mob/living/basic/carp/beach = 3, /mob/living/basic/carp/mega/beach = 1, - /mob/living/basic/trooper/pirate/melee/beach/boat = 1, + /mob/living/carbon/human/npc/ruin/trooper/pirate/beach = 1, ) flora_spawn_list = list( /obj/structure/flora/rock/beach = 1, @@ -111,12 +111,12 @@ /mob/living/basic/carp/beach = 6, /mob/living/basic/carp/beach/small = 5, /mob/living/basic/carp/mega/beach = 2, - /mob/living/basic/trooper/pirate/melee/beach/boat = 3, - /mob/living/basic/trooper/pirate/ranged/beach/boat = 1 + /mob/living/carbon/human/npc/ruin/trooper/pirate/beach = 3, + /mob/living/carbon/human/npc/ruin/trooper/pirate/beach/gunner = 1 ) dangerous_mob_spawn_list = list( /mob/living/basic/carp/mega/beach = 2, - /mob/living/basic/trooper/pirate/ranged/beach/boat = 1, + /mob/living/carbon/human/npc/ruin/trooper/pirate/beach/gunner = 1, ) feature_spawn_chance = 0.1 feature_spawn_list = list( diff --git a/voidcrew/datums/mapgen/biomes/wasteland_biomes.dm b/voidcrew/datums/mapgen/biomes/wasteland_biomes.dm index 2a0736c2fba..c2ff36d4115 100644 --- a/voidcrew/datums/mapgen/biomes/wasteland_biomes.dm +++ b/voidcrew/datums/mapgen/biomes/wasteland_biomes.dm @@ -24,14 +24,14 @@ feature_spawn_chance = 3 mob_spawn_chance = 7 mob_spawn_list = list( - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter = 10, + /mob/living/carbon/human/npc/ruin/hermit/hunter = 10, /mob/living/basic/spider/giant/wasteland = 1, /mob/living/basic/spider/giant/tarantula/wasteland = 1, /mob/living/basic/mining/legion/wasteland = 3 ) // Meaner tier for dangerous-zone planets (see dangerous_mob_spawn_list) dangerous_mob_spawn_list = list( - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger = 5, + /mob/living/carbon/human/npc/ruin/hermit/gunslinger = 5, /mob/living/basic/mining/legion/wasteland = 3, /mob/living/basic/spider/giant/tarantula/wasteland = 2, /mob/living/basic/spider/giant/wasteland = 2, @@ -92,13 +92,13 @@ flora_spawn_list = list(/obj/structure/flora/rock/wasteland = 30, /obj/effect/decal/cleanable/greenglow = 30, /obj/structure/elite_tumor = 1) mob_spawn_chance = 20 mob_spawn_list = list( - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter = 17, + /mob/living/carbon/human/npc/ruin/hermit/hunter = 17, /mob/living/basic/hivebot/range = 5, /mob/living/basic/spider/giant/wasteland = 1, /mob/living/basic/spider/giant/tarantula/wasteland = 1 ) dangerous_mob_spawn_list = list( - /mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger = 7, + /mob/living/carbon/human/npc/ruin/hermit/gunslinger = 7, /mob/living/basic/hivebot/rapid/wasteland = 3, /mob/living/basic/spider/giant/tarantula/wasteland = 1, ) diff --git a/voidcrew/modules/human_npcs/human_npc_spawning.dm b/voidcrew/modules/human_npcs/human_npc_spawning.dm index 6be222b9dda..406d8e8f986 100644 --- a/voidcrew/modules/human_npcs/human_npc_spawning.dm +++ b/voidcrew/modules/human_npcs/human_npc_spawning.dm @@ -247,10 +247,14 @@ SUBSYSTEM_DEF(human_npcs) icon_state = "mobspawner" /// The /mob/living/carbon/human/npc typepath to spawn var/npc_type + /// A name for this one NPC (a mapped character), or null for its type's + var/npc_name + /// Factions for this one NPC (a mapped camp's own side), or null for its type's + var/list/npc_faction /obj/effect/spawner/human_npc/Initialize(mapload) . = ..() if(!ispath(npc_type, /mob/living/carbon/human/npc)) stack_trace("[type] at [AREACOORD(src)] has no human NPC type to spawn") return - spawn_human_npc(npc_type, get_turf(src), context = get_npc_spawn_context()) + spawn_human_npc(npc_type, get_turf(src), name = npc_name, faction = npc_faction, context = get_npc_spawn_context()) diff --git a/voidcrew/modules/human_npcs/ruins/ruin_npc_base.dm b/voidcrew/modules/human_npcs/ruins/ruin_npc_base.dm new file mode 100644 index 00000000000..de97cc85111 --- /dev/null +++ b/voidcrew/modules/human_npcs/ruins/ruin_npc_base.dm @@ -0,0 +1,147 @@ +/** + * # Ruin, planet and mission human NPCs (phase C of the human NPC migration) + * + * The people found in ruins, on planets and at mission sites: tg's troopers (space pirates, Syndicate + * operatives, Nanotrasen security, Russian mobsters), the beach and boat pirates, faction pirate crews + * squatting in ruins or named in kill contracts, and the wasteland hermits and poacher squads. They were + * basic and simple mobs that swapped to a corpse on death; now they are real + * /mob/living/carbon/human/npc bodies (voidcrew/modules/human_npcs/), so wounds, crit, bleeding, + * medicine, cuffs and revival work on them as on players. Developer notes: kit vault modules/human_npcs.md. + * + * What the families share (this root): + * - They belong where they spawn. On a planet they join its fauna's alliance (map_footprint.dm), as the + * mobs they replace did, so hermits and beach pirates don't fight the local wildlife. Anywhere else they + * keep their own factions. + * - A "spot" line when they pick someone new to fight (RUIN_NPC_BARK_SPOT). + * - Zone outfit tables (/datum/human_npc_outfit_table/ruin): more armour, better guns and a medipen in + * the yellow and red zones, never more health, and outfits of their own on cold sites. + * - spawn_npc_in_zone_or_queue(): planet, biome and marker spawners queue human types through + * spawn_human_npc() instead of building a dozen bodies in one tick. + * - A kill contract's cash entitlement (npc_payout_mission): a named target pays its cash once per + * contract, as the faction pirates did. + */ + +/// Bark kind: it picked someone new to fight. +#define RUIN_NPC_BARK_SPOT "spot" + +/mob/living/carbon/human/npc/ruin + npc_target_stat = HUMAN_NPC_FINISHES_DOWNED + /// Weakref to the /datum/mission whose named target this is; its death cash pays only if that + /// contract can still claim it (/datum/mission/proc/claim_boss_cash()). + var/datum/weakref/npc_payout_mission + /// Weakref to the last mob it called out, so one fight gives one "spot" line + var/datum/weakref/npc_spotted + /// The ZONE_* its spawn order dressed it for, or null if it was made without one + var/npc_dressed_zone + +/mob/living/carbon/human/npc/ruin/Initialize(mapload, datum/human_npc_order/order) + . = ..() + npc_dressed_zone = order?.resolve_zone() + // tg's Initialize gave it the planet's alliance before it was dressed; a mapped or ordered faction + // list has replaced its factions since, so it rejoins here. + inherit_planetary_faction(src) + AddElement(/datum/element/human_npc_spot_bark) + +/mob/living/carbon/human/npc/ruin/Destroy() + npc_payout_mission = null + npc_spotted = null + return ..() + +/** + * The zone it belongs to, for zone-laddered loot: its spawn context's, which may have resolved after it + * was made (a ruin loading before its planet registered), else the zone it was dressed for, else where + * it stands. + */ +/mob/living/carbon/human/npc/ruin/proc/npc_home_zone() + return npc_spawn_context?.zone_type || npc_dressed_zone || npc_spawn_zone() + +// Natives of the planet they spawn on, like the fauna they share it with. +/mob/living/carbon/human/npc/ruin/should_inherit_planetary_faction() + return TRUE + +// A named contract target's cash goes to the contract once (the faction pirates' rule): if the +// contract is gone or has already paid, the body carries none. +/mob/living/carbon/human/npc/ruin/npc_drop_death_loot() + if(!npc_loot_dropped && npc_death_cash > 0 && npc_payout_mission) + var/datum/mission/mission = npc_payout_mission.resolve() + if(!mission?.claim_boss_cash()) + npc_death_cash = 0 + return ..() + +/** + * Says the family's spot line when the NPC picks someone new to fight. An element, so the NPC's own + * types can listen to the same blackboard signal. + */ +/datum/element/human_npc_spot_bark + element_flags = ELEMENT_DETACH_ON_HOST_DESTROY + +/datum/element/human_npc_spot_bark/Attach(datum/target) + . = ..() + if(!istype(target, /mob/living/carbon/human/npc/ruin)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), PROC_REF(on_target_set)) + +/datum/element/human_npc_spot_bark/Detach(datum/source) + UnregisterSignal(source, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET)) + return ..() + +/datum/element/human_npc_spot_bark/proc/on_target_set(mob/living/carbon/human/npc/ruin/source) + SIGNAL_HANDLER + var/mob/living/target = source.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!isliving(target) || source.stat != CONSCIOUS) + return + if(source.npc_spotted?.resolve() == target) + return + source.npc_spotted = WEAKREF(target) + source.npc_bark(RUIN_NPC_BARK_SPOT) + +/** + * Outfit tables for the ruin families. Green regulars wear what they always did; yellow adds armour; red + * adds a helmet or better gun and a medipen (used in soft crit, human_npc_soft_crit.dm). Families that + * are found on cold sites set a cold_layer that swaps their suit for a cold-weather one (the base cold + * layer only fills empty slots, which leaves an armour vest's wearer to freeze). + */ +/datum/human_npc_outfit_table/ruin + +/// Cold-weather wear that also replaces the suit: for outfits whose suit is armour with no warmth in it. +/datum/human_npc_layer/cold/coat + replace_slots = ITEM_SLOT_FEET | ITEM_SLOT_OCLOTHING + +/** + * spawn_npc_in_zone() for spawners that roll many mobs at once (planet population, biome terrain, zone + * markers): a human NPC type is queued through spawn_human_npc() with the same context, so it is built + * over the next ticks instead of now. Returns the mob, or the human NPC order. + */ +/proc/spawn_npc_in_zone_or_queue(spawn_type, atom/location, datum/npc_spawn_context/context) + if(ispath(spawn_type, /mob/living/carbon/human/npc)) + return spawn_human_npc(spawn_type, location, context = context) + return spawn_npc_in_zone(spawn_type, location, context) + +/// Map spawners for the ruin families (ruin_npc_spawners.dm). Each subtype names the NPC it places; +/// npc_name and npc_faction set one mapped NPC's own name and side. +/obj/effect/spawner/human_npc/ruin + name = "ruin NPC spawner" + +// The family's own type places nothing (it is never mapped; tests create every type once). +/obj/effect/spawner/human_npc/ruin/Initialize(mapload) + if(isnull(npc_type)) + flags_1 |= INITIALIZED_1 + return INITIALIZE_HINT_QDEL + return ..() + +// ===== Derelict shuttles inside space ruins ===== +// The caravan ambush ruin docks four upstream derelict shuttles (code/datums/shuttles/ruin.dm) that +// carried trooper mobs. Following the space ruins' copy-and-redirect scheme (voidcrew/datums/ruins/space.dm), +// voidcrew copies of those maps with human NPC spawners live in _maps/voidcrew/shuttles/, and these +// prefixes point the upstream templates at them. The upstream maps stay as merge reference. +/datum/map_template/shuttle/ruin/caravan_victim + prefix = "_maps/voidcrew/shuttles/" + +/datum/map_template/shuttle/ruin/pirate_cutter + prefix = "_maps/voidcrew/shuttles/" + +/datum/map_template/shuttle/ruin/syndicate_dropship + prefix = "_maps/voidcrew/shuttles/" + +/datum/map_template/shuttle/ruin/syndicate_fighter_shiv + prefix = "_maps/voidcrew/shuttles/" diff --git a/voidcrew/modules/human_npcs/ruins/ruin_npc_faction_pirates.dm b/voidcrew/modules/human_npcs/ruins/ruin_npc_faction_pirates.dm new file mode 100644 index 00000000000..4fee90dc758 --- /dev/null +++ b/voidcrew/modules/human_npcs/ruins/ruin_npc_faction_pirates.dm @@ -0,0 +1,327 @@ +/** + * # Faction pirates in ruins and kill contracts + * + * The faction pirate crews (Silverscale, the skeletons, the grey tide, the Lustrous, Interdyne) that the + * pirate zone markers (voidcrew/modules/loot/themes/plunder.dm) scatter through ruins, and that kill + * contracts (voidcrew/modules/missions/missions/recovery.dm) name as targets and guards. They wear the + * faction looks and weapons of the pirate outfit tables (human_npc_outfits_pirates.dm) on the faction's + * own species. NPC-ship crews and boarders are phase B's (voidcrew/modules/npc_ships/); these are the + * same people met on the ground, without a ship's crew AI. + * + * Ranks: crew are regulars, captains elites (they take a fifth less brute and burn, and land aimed hits), + * the faction leaders bosses (two fifths less). Designed drops are the basic mobs' (faction_pirate_mobs.dm, + * faction_pirate_bosses.dm), rolled the same way: a captain's or boss's fixed pieces, its rare piece at 25% + * rolled when it is made, the zone's ladder weapon for the rank and file, a captain's zone blade, one + * odds-and-ends item, and the cash. A kill contract's target pays its cash once (npc_payout_mission). + */ + +/datum/human_npc_voice/ruin_faction_pirate + lines = list( + RUIN_NPC_BARK_SPOT = list("There!", "Over here!", "Found one!", "Contact!"), + HUMAN_NPC_BARK_DOWNED = list("I'm hit!", "Not like this..."), + HUMAN_NPC_BARK_RECOVER = list("Still breathing."), + HUMAN_NPC_BARK_CUFFED = list("The captain will hear of this."), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 60) + +/datum/human_npc_voice/ruin_faction_pirate/skeleton + lines = list( + RUIN_NPC_BARK_SPOT = list("There ye are!", "I see ye!", "Over here, lads!"), + HUMAN_NPC_BARK_DOWNED = list("Back to the deep..."), + HUMAN_NPC_BARK_RECOVER = list("The dead don't stay down."), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("clatters to the floor."), + ) + +/datum/human_npc_voice/ruin_faction_pirate/grey + lines = list( + RUIN_NPC_BARK_SPOT = list("Get 'em!", "That one, there!", "Over here!"), + HUMAN_NPC_BARK_DOWNED = list("I want my toolbox..."), + HUMAN_NPC_BARK_RECOVER = list("Robust!"), + HUMAN_NPC_BARK_CUFFED = list("I didn't take nothing!"), + ) + +/mob/living/carbon/human/npc/ruin/faction_pirate + name = "pirate" + faction = list(FACTION_PIRATE) + npc_voice = /datum/human_npc_voice/ruin_faction_pirate + npc_death_cash = 25 + /// Death loot it always carries (the basic mob's loot_pool) + var/list/npc_loot_always + /// Each piece rolled once at 25% when it is made (rare_loot_pool) + var/list/npc_loot_rare + /// What the rare roll gave it + var/list/npc_loot_rolled + /// Rolls the zone's ladder weapon on death (rank and file; captains and bosses don't) + var/npc_loot_ladder = TRUE + /// Rolls an odds-and-ends item on death (random_loot_2; bosses don't) + var/npc_loot_extra = TRUE + /// The faction's outfit table (human_npc_outfits_pirates.dm), whose space suit it wears in vacuum + var/datum/human_npc_outfit_table/npc_site_table = /datum/human_npc_outfit_table/pirate + +/mob/living/carbon/human/npc/ruin/faction_pirate/Initialize(mapload, datum/human_npc_order/order) + . = ..() + npc_loot_rolled = list() + for(var/item_type in npc_loot_rare) + if(npc_ladder_red_drop()) + npc_loot_rolled += item_type + +// Dressed for vacuum in the faction's own space suit, whatever outfit it wears. +/mob/living/carbon/human/npc/ruin/faction_pirate/npc_fit_outfit_to_site(outfit, datum/human_npc_outfit_table/table) + return ..(outfit, table || human_npc_singleton(npc_site_table)) + +/mob/living/carbon/human/npc/ruin/faction_pirate/npc_roll_death_loot() + var/zone = npc_home_zone() + . = list() + if(length(npc_loot_always)) + . += npc_loot_always + if(length(npc_loot_rolled)) + . += npc_loot_rolled + if(npc_loot_ladder) + . += ruin_pirate_ladder_weapon(zone) + var/zone_blade = npc_zone_blade(zone) + if(zone_blade) + . += zone_blade + if(npc_loot_extra) + var/list/extras = npc_extra_loot_pool() + if(length(extras)) + . += pick(extras) + +/// The odds-and-ends pool one item is picked from on death. +/mob/living/carbon/human/npc/ruin/faction_pirate/proc/npc_extra_loot_pool() + return list( + /obj/item/storage/medkit/regular, + /obj/item/storage/toolbox/mechanical, + /obj/item/flashlight/seclite, + /obj/item/reagent_containers/hypospray/medipen, + /obj/item/stack/telecrystal, + ) + +/// A blade some captains carry by zone, or null. +/mob/living/carbon/human/npc/ruin/faction_pirate/proc/npc_zone_blade(zone) + return null + +/// The ladder weapon a faction pirate drops for its zone (the basic mob's roll_ladder_weapon()). +/proc/ruin_pirate_ladder_weapon(zone, roll = rand(1, 100)) + switch(zone) + if(ZONE_YELLOW) + return roll <= 5 ? /obj/item/gun/energy/laser : pick(npc_ladder_yellow_melee()) + if(ZONE_RED) + return roll <= 10 ? /obj/item/gun/ballistic/automatic/c20r/unrestricted : pick(npc_ladder_yellow_melee()) + if(roll <= 50) + return /obj/item/knife/combat/survival + return roll <= 75 ? /obj/item/spear : /obj/item/melee/baseball_bat + +/// Captains: elites with a fixed purse and no ladder weapon. +/mob/living/carbon/human/npc/ruin/faction_pirate/captain + npc_rank = HUMAN_NPC_RANK_ELITE + npc_damage_taken = 0.8 + npc_death_cash = 1000 + npc_loot_ladder = FALSE + +/// The captains of Silverscale, the Lustrous and Interdyne carry a better blade in harder zones. +/mob/living/carbon/human/npc/ruin/faction_pirate/captain/proc/zone_blade_for(zone) + if(zone == ZONE_YELLOW) + return /obj/item/claymore/shortsword + if(zone == ZONE_RED) + return pick(npc_ladder_yellow_melee()) + return null + +/// Faction leaders: bosses with their faction's prize and the big purse. +/mob/living/carbon/human/npc/ruin/faction_pirate/boss + npc_rank = HUMAN_NPC_RANK_BOSS + npc_damage_taken = 0.6 + npc_death_cash = 3000 + npc_loot_ladder = FALSE + npc_loot_extra = FALSE + +// ===== SILVERSCALE ===== + +/mob/living/carbon/human/npc/ruin/faction_pirate/silverscale + npc_name = "Silverscale Duelist" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/silverscale/melee + npc_site_table = /datum/human_npc_outfit_table/pirate/silverscale + npc_outfit = /datum/outfit/piratecorpse/faction/silverscale/melee/armed + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ruin/faction_pirate/silverscale/npc_extra_loot_pool() + return list(/obj/item/storage/medkit/regular, /obj/item/storage/toolbox/mechanical, random_fish_type()) + +/mob/living/carbon/human/npc/ruin/faction_pirate/silverscale/gunner + npc_name = "Silverscale Marksman" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/silverscale/ranged + npc_site_table = /datum/human_npc_outfit_table/pirate/silverscale + npc_outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/pirate/silverscale/gunners + npc_death_cash = 25 + +/mob/living/carbon/human/npc/ruin/faction_pirate/captain/silverscale + npc_name = "Silverscale Noble" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/silverscale/captain + npc_site_table = /datum/human_npc_outfit_table/pirate/silverscale + npc_outfit = /datum/outfit/piratecorpse/faction/silverscale/captain/armed + +/mob/living/carbon/human/npc/ruin/faction_pirate/captain/silverscale/npc_zone_blade(zone) + return zone_blade_for(zone) + +/mob/living/carbon/human/npc/ruin/faction_pirate/boss/silverscale + npc_name = "Silverscale Highlord" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/silverscale/highlord + npc_site_table = /datum/human_npc_outfit_table/pirate/silverscale + npc_outfit = /datum/outfit/piratecorpse/faction/silverscale/highlord/armed + npc_loot_always = list(/obj/item/clothing/suit/hooded/cloak/drake, /obj/item/clothing/head/hooded/cloakhood/drake) + +// ===== SKELETONS ===== + +/mob/living/carbon/human/npc/ruin/faction_pirate/skeleton + npc_name = "Skeleton Swashbuckler" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/skeleton/melee + npc_site_table = /datum/human_npc_outfit_table/pirate/skeleton + npc_outfit = /datum/outfit/piratecorpse/faction/skeleton/melee/armed + npc_voice = /datum/human_npc_voice/ruin_faction_pirate/skeleton + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ruin/faction_pirate/skeleton/gunner + npc_name = "Skeleton Gunner" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/skeleton/ranged + npc_site_table = /datum/human_npc_outfit_table/pirate/skeleton + npc_outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/pirate/skeleton/gunners + npc_death_cash = 25 + +/mob/living/carbon/human/npc/ruin/faction_pirate/captain/skeleton + npc_name = "Skeleton Captain" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/skeleton/captain + npc_site_table = /datum/human_npc_outfit_table/pirate/skeleton + npc_outfit = /datum/outfit/piratecorpse/faction/skeleton/captain/armed + npc_voice = /datum/human_npc_voice/ruin_faction_pirate/skeleton + npc_loot_always = list(/obj/item/gun/energy/laser) + +/mob/living/carbon/human/npc/ruin/faction_pirate/boss/skeleton + npc_name = "Davy Jones" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/skeleton/davyjones + npc_site_table = /datum/human_npc_outfit_table/pirate/skeleton + npc_outfit = /datum/outfit/piratecorpse/faction/skeleton/davyjones/armed + npc_voice = /datum/human_npc_voice/ruin_faction_pirate/skeleton + npc_loot_always = list(/obj/item/gun/ballistic/shotgun/hook) + +// ===== GREY TIDE ===== + +/mob/living/carbon/human/npc/ruin/faction_pirate/grey + npc_name = "Grey Tider" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/grey/melee + npc_site_table = /datum/human_npc_outfit_table/pirate/grey + npc_outfit = /datum/outfit/piratecorpse/faction/grey/melee/armed + npc_voice = /datum/human_npc_voice/ruin_faction_pirate/grey + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ruin/faction_pirate/grey/gunner + npc_name = "Grey Tider Gunner" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/grey/ranged + npc_site_table = /datum/human_npc_outfit_table/pirate/grey + npc_outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/pirate/grey/gunners + npc_death_cash = 25 + +/mob/living/carbon/human/npc/ruin/faction_pirate/captain/grey + npc_name = "Tidemaster" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/grey/captain + npc_site_table = /datum/human_npc_outfit_table/pirate/grey + npc_outfit = /datum/outfit/piratecorpse/faction/grey/captain/armed + npc_voice = /datum/human_npc_voice/ruin_faction_pirate/grey + npc_loot_always = list(/obj/item/storage/toolbox/syndicate) + +/mob/living/carbon/human/npc/ruin/faction_pirate/boss/grey + npc_name = "The Robust One" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/grey/robust + npc_site_table = /datum/human_npc_outfit_table/pirate/grey + npc_outfit = /datum/outfit/piratecorpse/faction/grey/robust/armed + npc_voice = /datum/human_npc_voice/ruin_faction_pirate/grey + npc_loot_always = list(/obj/item/stack/telecrystal/five) + npc_loot_rare = list(/obj/item/storage/toolbox/mechanical/old/clean) + +// ===== LUSTROUS ===== + +/mob/living/carbon/human/npc/ruin/faction_pirate/lustrous + npc_name = "Lustrous Scintillant" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/lustrous/melee + npc_site_table = /datum/human_npc_outfit_table/pirate/lustrous + npc_outfit = /datum/outfit/piratecorpse/faction/lustrous/melee/armed + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ruin/faction_pirate/lustrous/gunner + npc_name = "Lustrous Coruscant" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/lustrous/ranged + npc_site_table = /datum/human_npc_outfit_table/pirate/lustrous + npc_outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/pirate/lustrous/gunners + npc_death_cash = 25 + +/mob/living/carbon/human/npc/ruin/faction_pirate/captain/lustrous + npc_name = "Lustrous Radiant" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/lustrous/captain + npc_site_table = /datum/human_npc_outfit_table/pirate/lustrous + npc_outfit = /datum/outfit/piratecorpse/faction/lustrous/captain/armed + +/mob/living/carbon/human/npc/ruin/faction_pirate/captain/lustrous/npc_zone_blade(zone) + return zone_blade_for(zone) + +/mob/living/carbon/human/npc/ruin/faction_pirate/boss/lustrous + npc_name = "The Radiant One" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/lustrous/radiant + npc_site_table = /datum/human_npc_outfit_table/pirate/lustrous + npc_outfit = /datum/outfit/piratecorpse/faction/lustrous/radiant/armed + npc_loot_always = list(/obj/item/grenade/gas_crystal/nitrous_oxide_crystal) + npc_loot_rare = list(/obj/item/cain_and_abel) + +// ===== INTERDYNE ===== + +/mob/living/carbon/human/npc/ruin/faction_pirate/interdyne + npc_name = "Interdyne Enforcer" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/interdyne/melee + npc_site_table = /datum/human_npc_outfit_table/pirate/interdyne + npc_outfit = /datum/outfit/piratecorpse/faction/interdyne/melee/armed + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ruin/faction_pirate/interdyne/gunner + npc_name = "Interdyne Pharmacist" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/interdyne/ranged + npc_site_table = /datum/human_npc_outfit_table/pirate/interdyne + npc_outfit = null + npc_outfit_table = /datum/human_npc_outfit_table/pirate/interdyne/gunners + npc_death_cash = 25 + +/mob/living/carbon/human/npc/ruin/faction_pirate/captain/interdyne + npc_name = "Interdyne Director" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/interdyne/captain + npc_site_table = /datum/human_npc_outfit_table/pirate/interdyne + npc_outfit = /datum/outfit/piratecorpse/faction/interdyne/captain/armed + +/mob/living/carbon/human/npc/ruin/faction_pirate/captain/interdyne/npc_zone_blade(zone) + return zone_blade_for(zone) + +/mob/living/carbon/human/npc/ruin/faction_pirate/boss/interdyne + npc_name = "Director Prime" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/interdyne/prime + npc_site_table = /datum/human_npc_outfit_table/pirate/interdyne + npc_outfit = /datum/outfit/piratecorpse/faction/interdyne/prime/armed + npc_loot_always = list(/obj/item/reagent_containers/hypospray/combat, /obj/item/gun/syringe/syndicate, /obj/item/grenade/gluon) + +// ===== THE BLACK KNIGHT ===== + +/// Hates guns, as the basic mob did (tg's traits: he can't fire one, and throws them hard). +/mob/living/carbon/human/npc/ruin/faction_pirate/boss/medieval + npc_name = "The Black Knight" + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/medieval/blackknight + npc_site_table = /datum/human_npc_outfit_table/pirate/medieval + npc_outfit = /datum/outfit/piratecorpse/faction/medieval/blackknight/armed + npc_loot_always = list(/obj/item/claymore/shortsword, /obj/item/shield/kite) + +/mob/living/carbon/human/npc/ruin/faction_pirate/boss/medieval/Initialize(mapload, datum/human_npc_order/order) + . = ..() + ADD_TRAIT(src, TRAIT_NOGUNS, INNATE_TRAIT) + ADD_TRAIT(src, TRAIT_TOSS_GUN_HARD, INNATE_TRAIT) diff --git a/voidcrew/modules/human_npcs/ruins/ruin_npc_hermits.dm b/voidcrew/modules/human_npcs/ruins/ruin_npc_hermits.dm new file mode 100644 index 00000000000..fcd6c135127 --- /dev/null +++ b/voidcrew/modules/human_npcs/ruins/ruin_npc_hermits.dm @@ -0,0 +1,89 @@ +/** + * # Wasteland hermits and poacher squads (AM-9, AM-3) + * + * The wild-eyed people of the sandworlds, in their tattered survival gear (the wasteland corpse outfits, + * voidcrew/modules/mob_spawn/corpses/wasteland_corpses.dm): wanderers who fight with their fists or a + * knife, hunters with a .38 revolver and speedloaders, and soldiers with a WT-550 and spare magazines. + * They spawn from the wasteland biomes, wasteland ruins and the poacher bust contract, which renames them. + * + * Their survival suits keep the cold off their bodies; on a cold site the cold layer adds a hat, scarf and + * winter boots (a poacher camp on an ice planet), and a bad-air site a mask and tank. Nothing makes them + * immune to the weather: a storm that hurts a player hurts them. + */ + +/datum/human_npc_voice/ruin_hermit + lines = list( + RUIN_NPC_BARK_SPOT = list( + "The sun... The sun...", + "You'll do...", + "All mine...", + "Gg..free..", + "Drink.. need drink...", + "I can smell... you..", + "G-g...", + "Taste..the salt.", + ), + HUMAN_NPC_BARK_DOWNED = list("G-g...", "The sun..."), + HUMAN_NPC_BARK_PLEAD = list("Drink... need drink..."), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 50) + +/datum/outfit/hermit/survivor/knife + name = "Wasteland Survivor (knife)" + r_hand = /obj/item/knife/combat/survival + +/datum/outfit/hermit/survivor/knife/red + name = "Wasteland Survivor (knife, red zone)" + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// A .38 revolver and two speedloaders. +/datum/outfit/hermit/survivor/hunter/armed + name = "Wasteland Hunter (armed)" + r_hand = /obj/item/gun/ballistic/revolver/c38 + backpack_contents = list(/obj/item/ammo_box/c38 = 2) + +/datum/outfit/hermit/survivor/hunter/armed/red + name = "Wasteland Hunter (armed, red zone)" + backpack_contents = list(/obj/item/ammo_box/c38 = 2, /obj/item/reagent_containers/hypospray/medipen = 1) + +/// A WT-550 and two spare magazines. +/datum/outfit/hermit/survivor/gunslinger/armed + name = "Wasteland Gunslinger (armed)" + r_hand = /obj/item/gun/ballistic/automatic/wt550 + backpack_contents = list(/obj/item/ammo_box/magazine/wt550m9 = 2) + +/datum/outfit/hermit/survivor/gunslinger/armed/red + name = "Wasteland Gunslinger (armed, red zone)" + backpack_contents = list(/obj/item/ammo_box/magazine/wt550m9 = 2, /obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/human_npc_outfit_table/ruin/hermit + green = list(/datum/outfit/hermit/survivor = 1) + yellow = list(/datum/outfit/hermit/survivor/knife = 1) + red = list(/datum/outfit/hermit/survivor/knife/red = 1) + +/datum/human_npc_outfit_table/ruin/hermit/hunter + green = list(/datum/outfit/hermit/survivor/hunter/armed = 1) + yellow = list(/datum/outfit/hermit/survivor/hunter/armed = 1) + red = list(/datum/outfit/hermit/survivor/hunter/armed/red = 1) + +/datum/human_npc_outfit_table/ruin/hermit/gunslinger + green = list(/datum/outfit/hermit/survivor/gunslinger/armed = 1) + yellow = list(/datum/outfit/hermit/survivor/gunslinger/armed = 1) + red = list(/datum/outfit/hermit/survivor/gunslinger/armed/red = 1) + +/mob/living/carbon/human/npc/ruin/hermit + npc_name = "Hermit Wanderer" + faction = list("hermit", FACTION_WASTELAND) + npc_voice = /datum/human_npc_voice/ruin_hermit + npc_outfit_table = /datum/human_npc_outfit_table/ruin/hermit + +/mob/living/carbon/human/npc/ruin/hermit/hunter + npc_name = "Hermit Hunter" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/hermit/hunter + +/mob/living/carbon/human/npc/ruin/hermit/gunslinger + npc_name = "Hermit Soldier" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/hermit/gunslinger diff --git a/voidcrew/modules/human_npcs/ruins/ruin_npc_spawners.dm b/voidcrew/modules/human_npcs/ruins/ruin_npc_spawners.dm new file mode 100644 index 00000000000..1185c09b221 --- /dev/null +++ b/voidcrew/modules/human_npcs/ruins/ruin_npc_spawners.dm @@ -0,0 +1,111 @@ +// Map spawners for the ruin, planet and mission human NPCs. Each one queues its NPC when the map loads +// (spawn_human_npc(), a body per tick), dressed for the zone the ruin landed in. Set npc_name or +// npc_faction on a placed spawner for one NPC's own name or side (a camp's private faction). + +// ===== Pirates ===== + +/obj/effect/spawner/human_npc/ruin/pirate + name = "pirate swashbuckler spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate + +/obj/effect/spawner/human_npc/ruin/pirate/gunner + name = "pirate gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/gunner + +/obj/effect/spawner/human_npc/ruin/pirate/space + name = "space pirate swashbuckler spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/space + +/obj/effect/spawner/human_npc/ruin/pirate/space/gunner + name = "space pirate gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/space/gunner + +/obj/effect/spawner/human_npc/ruin/pirate/beach + name = "beach pirate swashbuckler spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/beach + +/obj/effect/spawner/human_npc/ruin/pirate/beach/gunner + name = "beach pirate gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/beach/gunner + +/obj/effect/spawner/human_npc/ruin/pirate/cosplayer + name = "pirate cosplayer spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/pirate/cosplayer + +// ===== Syndicate ===== + +/obj/effect/spawner/human_npc/ruin/syndicate + name = "Syndicate operative spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate + +/obj/effect/spawner/human_npc/ruin/syndicate/sword + name = "Syndicate sword guard spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/sword + +/obj/effect/spawner/human_npc/ruin/syndicate/gunner + name = "Syndicate gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner + +/obj/effect/spawner/human_npc/ruin/syndicate/gunner/smg + name = "Syndicate SMG gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/smg + +/obj/effect/spawner/human_npc/ruin/syndicate/gunner/shotgun + name = "Syndicate shotgunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/shotgun + +/obj/effect/spawner/human_npc/ruin/syndicate/gunner/pilot + name = "Syndicate salvage pilot spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/pilot + +/obj/effect/spawner/human_npc/ruin/syndicate/commando + name = "Syndicate commando spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/commando + +/obj/effect/spawner/human_npc/ruin/syndicate/commando/gunner + name = "Syndicate commando gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/commando/gunner + +/obj/effect/spawner/human_npc/ruin/syndicate/commando/smg + name = "Syndicate commando SMG gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/commando/smg + +/obj/effect/spawner/human_npc/ruin/syndicate/stormtrooper + name = "Syndicate stormtrooper spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper + +/obj/effect/spawner/human_npc/ruin/syndicate/stormtrooper/shotgun + name = "Syndicate stormtrooper shotgunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper/shotgun + +/obj/effect/spawner/human_npc/ruin/syndicate/stormtrooper/sword + name = "Syndicate stormtrooper sword guard spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper/sword + +// ===== Nanotrasen and Russians ===== + +/obj/effect/spawner/human_npc/ruin/nanotrasen + name = "Nanotrasen security officer spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/nanotrasen + +/obj/effect/spawner/human_npc/ruin/russian + name = "Russian mobster spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/russian + +/obj/effect/spawner/human_npc/ruin/russian/gunner + name = "Russian gunner spawner" + npc_type = /mob/living/carbon/human/npc/ruin/trooper/russian/gunner + +// ===== Hermits ===== + +/obj/effect/spawner/human_npc/ruin/hermit + name = "hermit wanderer spawner" + npc_type = /mob/living/carbon/human/npc/ruin/hermit + +/obj/effect/spawner/human_npc/ruin/hermit/hunter + name = "hermit hunter spawner" + npc_type = /mob/living/carbon/human/npc/ruin/hermit/hunter + +/obj/effect/spawner/human_npc/ruin/hermit/gunslinger + name = "hermit soldier spawner" + npc_type = /mob/living/carbon/human/npc/ruin/hermit/gunslinger diff --git a/voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm b/voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm new file mode 100644 index 00000000000..09ac92898cc --- /dev/null +++ b/voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm @@ -0,0 +1,567 @@ +/** + * # Troopers as human NPCs: pirates, Syndicate operatives, Nanotrasen security, Russian mobsters + * + * The human versions of tg's /mob/living/basic/trooper families placed in Voidcrew ruins, ruin shuttles, + * beaches and zone markers. Each keeps its trooper's name, faction and look (today's corpse outfits), + * holds the weapon the trooper only drew, and carries real spare ammunition. + * + * Zones (/datum/human_npc_outfit_table/ruin): green wears what the trooper wore; yellow adds armour; red + * adds a helmet and a medipen. The trooper weapon ladder (voidcrew/edits/mobs/weapon_drops.dm) lives in + * the tables: Syndicate SMGs and shotguns, and the Nanotrasen guns, are pistols outside the red zone, and + * the Syndicate sword guard has only a knife in the green. + * + * Designed drops stay as the troopers had them: only the Russians carried any (a kitchen knife, a Nagant + * revolver). Their clothes and guns are gear (decision D3) and never loot. The pirates' coin-snatching + * hits are gone: an NPC hits like a person. + */ + +// ===== VOICES ===== + +/datum/human_npc_voice/ruin_pirate + lines = list( + RUIN_NPC_BARK_SPOT = list("Yarr! Fresh meat!", "Ahoy, landlubber!", "Cut 'em down!", "Avast!"), + HUMAN_NPC_BARK_DOWNED = list("Arr... me guts...", "Not like this..."), + HUMAN_NPC_BARK_RECOVER = list("Still breathin'!", "That all ye got?"), + HUMAN_NPC_BARK_PLEAD = list("Mercy, matey..."), + HUMAN_NPC_BARK_CUFFED = list("Ye'll hang for this."), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 60) + +/datum/human_npc_voice/ruin_syndicate + lines = list( + RUIN_NPC_BARK_SPOT = list("Contact!", "Hostile sighted.", "Engaging.", "Death to Nanotrasen!"), + HUMAN_NPC_BARK_DOWNED = list("Man down...", "I'm hit..."), + HUMAN_NPC_BARK_RECOVER = list("Back in it.", "Still in the fight."), + HUMAN_NPC_BARK_CUFFED = list("I'm not telling you anything."), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 50) + +/datum/human_npc_voice/ruin_nanotrasen + lines = list( + RUIN_NPC_BARK_SPOT = list("Halt! Private security!", "Hostile in sight.", "Stop right there!"), + HUMAN_NPC_BARK_DOWNED = list("Officer down...", "Need a medic..."), + HUMAN_NPC_BARK_RECOVER = list("Still standing."), + HUMAN_NPC_BARK_CUFFED = list("You'll answer to Central for this."), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 50) + +/datum/human_npc_voice/ruin_russian + lines = list( + RUIN_NPC_BARK_SPOT = list("Hey! You!", "Who let you in?", "For the Motherland!"), + HUMAN_NPC_BARK_DOWNED = list("Ugh... not good...", "Someone... help..."), + HUMAN_NPC_BARK_RECOVER = list("Is that all?"), + ) + emotes = list( + HUMAN_NPC_BARK_DOWNED = list("collapses."), + ) + chances = list(RUIN_NPC_BARK_SPOT = 50) + +// ===== FAMILY ROOT ===== + +/mob/living/carbon/human/npc/ruin/trooper + name = "trooper" + +// ===== SPACE PIRATES (tg's pirate troopers) ===== + +/datum/outfit/ruin_npc + name = "Ruin NPC" + +/// The swashbuckler: a pirate's rags, an eyepatch and an energy cutlass. +/datum/outfit/ruin_npc/pirate + name = "Ruin NPC: pirate swashbuckler" + uniform = /obj/item/clothing/under/costume/pirate + shoes = /obj/item/clothing/shoes/jackboots + glasses = /obj/item/clothing/glasses/eyepatch + head = /obj/item/clothing/head/costume/pirate/bandana/armored + r_hand = /obj/item/melee/energy/sword/pirate + +/datum/outfit/ruin_npc/pirate/coat + name = "Ruin NPC: pirate swashbuckler in a coat" + suit = /obj/item/clothing/suit/costume/pirate/armored + +/datum/outfit/ruin_npc/pirate/coat/red + name = "Ruin NPC: pirate swashbuckler, red zone" + gloves = /obj/item/clothing/gloves/color/black + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// The gunner: a laser gun and the armoured captain's hat. +/datum/outfit/ruin_npc/pirate/gunner + name = "Ruin NPC: pirate gunner" + head = /obj/item/clothing/head/costume/pirate/armored + r_hand = /obj/item/gun/energy/laser + +/datum/outfit/ruin_npc/pirate/gunner/coat + name = "Ruin NPC: pirate gunner in a coat" + suit = /obj/item/clothing/suit/costume/pirate/armored + +/datum/outfit/ruin_npc/pirate/gunner/coat/red + name = "Ruin NPC: pirate gunner, red zone" + gloves = /obj/item/clothing/gloves/color/black + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// Space pirates fight in breached hulls: the pirate space suit and its own air. +/datum/outfit/ruin_npc/pirate/space + name = "Ruin NPC: space pirate swashbuckler" + suit = /obj/item/clothing/suit/space/pirate + head = /obj/item/clothing/head/helmet/space/pirate/bandana + gloves = /obj/item/clothing/gloves/color/black + suit_store = /obj/item/tank/internals/oxygen + internals_slot = ITEM_SLOT_SUITSTORE + +/datum/outfit/ruin_npc/pirate/space/red + name = "Ruin NPC: space pirate swashbuckler, red zone" + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/datum/outfit/ruin_npc/pirate/space/gunner + name = "Ruin NPC: space pirate gunner" + head = /obj/item/clothing/head/helmet/space/pirate + r_hand = /obj/item/gun/energy/e_gun/lethal + +/datum/outfit/ruin_npc/pirate/space/gunner/red + name = "Ruin NPC: space pirate gunner, red zone" + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// Timothy, the pirate cosplayer locked in the Hightide's kitchen: the costume, not the armour. +/datum/outfit/ruin_npc/pirate/cosplayer + name = "Ruin NPC: pirate cosplayer" + head = /obj/item/clothing/head/costume/pirate + suit = /obj/item/clothing/suit/costume/pirate + shoes = /obj/item/clothing/shoes/sneakers/black + r_hand = /obj/item/gun/energy/laser + +/datum/human_npc_outfit_table/ruin/pirate + vacuum_layer = /datum/human_npc_layer/eva/pirate + green = list(/datum/outfit/ruin_npc/pirate = 1) + yellow = list(/datum/outfit/ruin_npc/pirate/coat = 1) + red = list(/datum/outfit/ruin_npc/pirate/coat/red = 1) + +/datum/human_npc_outfit_table/ruin/pirate/gunner + green = list(/datum/outfit/ruin_npc/pirate/gunner = 1) + yellow = list(/datum/outfit/ruin_npc/pirate/gunner/coat = 1) + red = list(/datum/outfit/ruin_npc/pirate/gunner/coat/red = 1) + +/datum/human_npc_outfit_table/ruin/pirate/space + green = list(/datum/outfit/ruin_npc/pirate/space = 1) + yellow = list(/datum/outfit/ruin_npc/pirate/space = 1) + red = list(/datum/outfit/ruin_npc/pirate/space/red = 1) + +/datum/human_npc_outfit_table/ruin/pirate/space/gunner + green = list(/datum/outfit/ruin_npc/pirate/space/gunner = 1) + yellow = list(/datum/outfit/ruin_npc/pirate/space/gunner = 1) + red = list(/datum/outfit/ruin_npc/pirate/space/gunner/red = 1) + +/mob/living/carbon/human/npc/ruin/trooper/pirate + npc_name = "Pirate Swashbuckler" + faction = list(FACTION_PIRATE) + npc_voice = /datum/human_npc_voice/ruin_pirate + npc_outfit_table = /datum/human_npc_outfit_table/ruin/pirate + +/mob/living/carbon/human/npc/ruin/trooper/pirate/gunner + npc_name = "Pirate Gunner" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/pirate/gunner + +/mob/living/carbon/human/npc/ruin/trooper/pirate/space + npc_name = "Space Pirate Swashbuckler" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/pirate/space + +/mob/living/carbon/human/npc/ruin/trooper/pirate/space/gunner + npc_name = "Space Pirate Gunner" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/pirate/space/gunner + +/// The beach's own pirates, sided with the shore's wildlife: the barrel spawners' crews, and the boat +/// pirates of the deep water, who now wade (the old boat sprite was this same costume in a boat). +/mob/living/carbon/human/npc/ruin/trooper/pirate/beach + faction = list(FACTION_BEACH) + +/mob/living/carbon/human/npc/ruin/trooper/pirate/beach/gunner + npc_name = "Pirate Gunner" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/pirate/gunner + +/// Timothy. He survived on the rats, which shows: he goes down twice as fast as anyone else. +/mob/living/carbon/human/npc/ruin/trooper/pirate/cosplayer + npc_name = "Timothy" + npc_outfit_table = null + npc_outfit = /datum/outfit/ruin_npc/pirate/cosplayer + npc_damage_taken = 2 + +// ===== SYNDICATE OPERATIVES ===== + +/// A Syndicate operative with a knife. +/datum/outfit/ruin_npc/syndicate + name = "Ruin NPC: Syndicate operative" + uniform = /obj/item/clothing/under/syndicate + shoes = /obj/item/clothing/shoes/combat + gloves = /obj/item/clothing/gloves/combat + r_hand = /obj/item/knife/combat/survival + +/datum/outfit/ruin_npc/syndicate/vest + name = "Ruin NPC: Syndicate operative in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/datum/outfit/ruin_npc/syndicate/vest/helmet + name = "Ruin NPC: Syndicate operative, red zone" + head = /obj/item/clothing/head/helmet/swat + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// The sword guard: an energy sword and an energy shield. +/datum/outfit/ruin_npc/syndicate/vest/sword + name = "Ruin NPC: Syndicate sword guard" + r_hand = /obj/item/melee/energy/sword/saber/red + l_hand = /obj/item/shield/energy + +/datum/outfit/ruin_npc/syndicate/vest/sword/helmet + name = "Ruin NPC: Syndicate sword guard, red zone" + head = /obj/item/clothing/head/helmet/swat + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// A Makarov and two spare magazines. +/datum/outfit/ruin_npc/syndicate/pistol + name = "Ruin NPC: Syndicate gunner" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/ruin_npc/syndicate/pistol/vest + name = "Ruin NPC: Syndicate gunner in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet + name = "Ruin NPC: Syndicate gunner, red zone" + head = /obj/item/clothing/head/helmet/swat + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/// The C-20r, in the red zone only. +/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet/smg + name = "Ruin NPC: Syndicate SMG gunner" + r_hand = /obj/item/gun/ballistic/automatic/c20r/unrestricted + l_pocket = /obj/item/ammo_box/magazine/smgm45 + r_pocket = /obj/item/ammo_box/magazine/smgm45 + +/// The Bulldog, in the red zone only. +/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet/shotgun + name = "Ruin NPC: Syndicate shotgunner" + r_hand = /obj/item/gun/ballistic/shotgun/bulldog/unrestricted + l_pocket = /obj/item/ammo_box/magazine/m12g + r_pocket = /obj/item/ammo_box/magazine/m12g + +/// The salvage pilots of the caravan ambush: flight scarf and goggles over the armour. +/datum/outfit/ruin_npc/syndicate/pistol/pilot + name = "Ruin NPC: Syndicate salvage pilot" + suit = /obj/item/clothing/suit/armor/vest/alt + neck = /obj/item/clothing/neck/large_scarf/syndie + glasses = /obj/item/clothing/glasses/cold + +/datum/outfit/ruin_npc/syndicate/pistol/pilot/smg + name = "Ruin NPC: Syndicate salvage pilot, red zone" + head = /obj/item/clothing/head/helmet/swat + r_hand = /obj/item/gun/ballistic/automatic/c20r/unrestricted + l_pocket = /obj/item/ammo_box/magazine/smgm45 + r_pocket = /obj/item/ammo_box/magazine/smgm45 + +/// Commandos: the black Syndicate space suit and its own air. +/datum/outfit/ruin_npc/syndicate/commando + name = "Ruin NPC: Syndicate commando" + suit = /obj/item/clothing/suit/space/syndicate/black + head = /obj/item/clothing/head/helmet/space/syndicate/black + suit_store = /obj/item/tank/internals/oxygen + internals_slot = ITEM_SLOT_SUITSTORE + +/datum/outfit/ruin_npc/syndicate/commando/red + name = "Ruin NPC: Syndicate commando, red zone" + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/datum/outfit/ruin_npc/syndicate/commando/pistol + name = "Ruin NPC: Syndicate commando gunner" + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/ruin_npc/syndicate/commando/pistol/red + name = "Ruin NPC: Syndicate commando gunner, red zone" + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/outfit/ruin_npc/syndicate/commando/pistol/red/smg + name = "Ruin NPC: Syndicate commando SMG gunner" + r_hand = /obj/item/gun/ballistic/automatic/c20r/unrestricted + l_pocket = /obj/item/ammo_box/magazine/smgm45 + r_pocket = /obj/item/ammo_box/magazine/smgm45 + +/datum/outfit/ruin_npc/syndicate/commando/pistol/red/shotgun + name = "Ruin NPC: Syndicate commando shotgunner" + r_hand = /obj/item/gun/ballistic/shotgun/bulldog/unrestricted + l_pocket = /obj/item/ammo_box/magazine/m12g + r_pocket = /obj/item/ammo_box/magazine/m12g + +/datum/outfit/ruin_npc/syndicate/commando/sword + name = "Ruin NPC: Syndicate commando sword guard" + r_hand = /obj/item/melee/energy/sword/saber/red + l_hand = /obj/item/shield/energy + +/// Stormtroopers: the blood-red Syndicate space suit. +/datum/outfit/ruin_npc/syndicate/commando/stormtrooper + name = "Ruin NPC: Syndicate stormtrooper" + suit = /obj/item/clothing/suit/space/syndicate/black/red + head = /obj/item/clothing/head/helmet/space/syndicate/black/red + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/smg + name = "Ruin NPC: Syndicate stormtrooper SMG gunner" + r_hand = /obj/item/gun/ballistic/automatic/c20r/unrestricted + l_pocket = /obj/item/ammo_box/magazine/smgm45 + r_pocket = /obj/item/ammo_box/magazine/smgm45 + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/shotgun + name = "Ruin NPC: Syndicate stormtrooper shotgunner" + r_hand = /obj/item/gun/ballistic/shotgun/bulldog/unrestricted + l_pocket = /obj/item/ammo_box/magazine/m12g + r_pocket = /obj/item/ammo_box/magazine/m12g + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/sword + name = "Ruin NPC: Syndicate stormtrooper sword guard" + r_hand = /obj/item/melee/energy/sword/saber/red + l_hand = /obj/item/shield/energy + l_pocket = /obj/item/reagent_containers/hypospray/medipen + r_pocket = null + +/// Operatives on the ice wear the Syndicate's armoured explorer suit over their kit. +/datum/human_npc_layer/cold/coat/syndicate + suit = /obj/item/clothing/suit/hooded/explorer/syndicate + +/datum/human_npc_outfit_table/ruin/syndicate + vacuum_layer = /datum/human_npc_layer/eva/syndicate + cold_layer = /datum/human_npc_layer/cold/coat/syndicate + green = list(/datum/outfit/ruin_npc/syndicate = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/vest = 1) + red = list(/datum/outfit/ruin_npc/syndicate/vest/helmet = 1) + +/// The sword guard has only a knife in the green zone (the trooper weapon ladder). +/datum/human_npc_outfit_table/ruin/syndicate/sword + green = list(/datum/outfit/ruin_npc/syndicate = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/vest/sword = 1) + red = list(/datum/outfit/ruin_npc/syndicate/vest/sword/helmet = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/pistol + green = list(/datum/outfit/ruin_npc/syndicate/pistol = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/pistol/vest = 1) + red = list(/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet = 1) + +/// SMGs and shotguns are pistols outside the red zone (the trooper weapon ladder). +/datum/human_npc_outfit_table/ruin/syndicate/pistol/smg + red = list(/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet/smg = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/pistol/shotgun + red = list(/datum/outfit/ruin_npc/syndicate/pistol/vest/helmet/shotgun = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/pilot + green = list(/datum/outfit/ruin_npc/syndicate/pistol/pilot = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/pistol/pilot = 1) + red = list(/datum/outfit/ruin_npc/syndicate/pistol/pilot/smg = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/commando + green = list(/datum/outfit/ruin_npc/syndicate/commando = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/red = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/commando/pistol + green = list(/datum/outfit/ruin_npc/syndicate/commando/pistol = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando/pistol = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/pistol/red = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/commando/smg + green = list(/datum/outfit/ruin_npc/syndicate/commando/pistol = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando/pistol = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/pistol/red/smg = 1) + +/// Stormtroopers are elites but follow the zone ladder like the regulars, so their tables pick by zone +/// alone: SMG stormtroopers carry pistols in the green zone, shotgunners outside the red. +/datum/human_npc_outfit_table/ruin/syndicate/stormtrooper + +/datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/outfits_for(mob/living/carbon/human/npc/npc, zone) + if(zone == ZONE_RED && length(red)) + return red + if(zone >= ZONE_YELLOW && length(yellow)) + return yellow + return green + +/datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/smg + green = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/smg = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/smg = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/shotgun + green = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/shotgun = 1) + +/datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/sword + green = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/sword = 1) + yellow = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/sword = 1) + red = list(/datum/outfit/ruin_npc/syndicate/commando/stormtrooper/sword = 1) + +/// Space Syndicate kit: the black suit. +/datum/human_npc_layer/eva/syndicate + suit = /obj/item/clothing/suit/space/syndicate/black + head = /obj/item/clothing/head/helmet/space/syndicate/black + +/mob/living/carbon/human/npc/ruin/trooper/syndicate + npc_name = "Syndicate Operative" + faction = list(ROLE_SYNDICATE) + npc_voice = /datum/human_npc_voice/ruin_syndicate + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/sword + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/sword + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pistol + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/smg + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pistol/smg + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/shotgun + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pistol/shotgun + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/pilot + npc_name = "Syndicate Salvage Pilot" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/pilot + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/commando + npc_name = "Syndicate Commando" + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/commando + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/commando/gunner + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/commando/pistol + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/commando/smg + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/commando/smg + +/// Stormtroopers: elites in the blood-red suit. They take a fifth less brute and burn, and land aimed hits. +/mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper + npc_name = "Syndicate Stormtrooper" + npc_rank = HUMAN_NPC_RANK_ELITE + npc_damage_taken = 0.8 + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/smg + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper/shotgun + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/shotgun + +/mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper/sword + npc_outfit_table = /datum/human_npc_outfit_table/ruin/syndicate/stormtrooper/sword + +// ===== NANOTRASEN PRIVATE SECURITY ===== + +/// An M1911 and two spare magazines, in security blues. +/datum/outfit/ruin_npc/nanotrasen + name = "Ruin NPC: Nanotrasen security officer" + uniform = /obj/item/clothing/under/rank/security/officer + shoes = /obj/item/clothing/shoes/combat + gloves = /obj/item/clothing/gloves/combat + back = /obj/item/storage/backpack/security + r_hand = /obj/item/gun/ballistic/automatic/pistol/m1911 + l_pocket = /obj/item/ammo_box/magazine/m45 + r_pocket = /obj/item/ammo_box/magazine/m45 + +/datum/outfit/ruin_npc/nanotrasen/vest + name = "Ruin NPC: Nanotrasen security officer in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/datum/outfit/ruin_npc/nanotrasen/vest/helmet + name = "Ruin NPC: Nanotrasen security officer, red zone" + head = /obj/item/clothing/head/helmet/swat/nanotrasen + backpack_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/human_npc_outfit_table/ruin/nanotrasen + green = list(/datum/outfit/ruin_npc/nanotrasen = 1) + yellow = list(/datum/outfit/ruin_npc/nanotrasen/vest = 1) + red = list(/datum/outfit/ruin_npc/nanotrasen/vest/helmet = 1) + +/mob/living/carbon/human/npc/ruin/trooper/nanotrasen + npc_name = "Nanotrasen Private Security Officer" + faction = list(ROLE_DEATHSQUAD) + npc_voice = /datum/human_npc_voice/ruin_nanotrasen + npc_outfit_table = /datum/human_npc_outfit_table/ruin/nanotrasen + +// ===== RUSSIAN MOBSTERS ===== + +/// A soviet uniform, a bear pelt and a kitchen knife. +/datum/outfit/ruin_npc/russian + name = "Ruin NPC: Russian mobster" + uniform = /obj/item/clothing/under/costume/soviet + shoes = /obj/item/clothing/shoes/jackboots + head = /obj/item/clothing/head/costume/bearpelt + gloves = /obj/item/clothing/gloves/color/black + r_hand = /obj/item/knife/kitchen + +/datum/outfit/ruin_npc/russian/vest + name = "Ruin NPC: Russian mobster in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/// The red zone's Russians are troopers: camo, a bulletproof vest and a helmet. +/datum/outfit/ruin_npc/russian/trooper + name = "Ruin NPC: Russian trooper" + uniform = /obj/item/clothing/under/syndicate/camo + suit = /obj/item/clothing/suit/armor/bulletproof + head = /obj/item/clothing/head/helmet/alt + shoes = /obj/item/clothing/shoes/combat + gloves = /obj/item/clothing/gloves/combat + l_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// A Makarov and two spare magazines, under an ushanka. +/datum/outfit/ruin_npc/russian/gunner + name = "Ruin NPC: Russian gunner" + head = /obj/item/clothing/head/costume/ushanka + r_hand = /obj/item/gun/ballistic/automatic/pistol + l_pocket = /obj/item/ammo_box/magazine/m9mm + r_pocket = /obj/item/ammo_box/magazine/m9mm + +/datum/outfit/ruin_npc/russian/gunner/vest + name = "Ruin NPC: Russian gunner in a vest" + suit = /obj/item/clothing/suit/armor/vest + +/datum/outfit/ruin_npc/russian/gunner/trooper + name = "Ruin NPC: Russian trooper gunner" + uniform = /obj/item/clothing/under/syndicate/camo + suit = /obj/item/clothing/suit/armor/bulletproof + head = /obj/item/clothing/head/helmet/alt + shoes = /obj/item/clothing/shoes/combat + gloves = /obj/item/clothing/gloves/combat + belt = /obj/item/storage/belt/military + belt_contents = list(/obj/item/reagent_containers/hypospray/medipen = 1) + +/datum/human_npc_outfit_table/ruin/russian + green = list(/datum/outfit/ruin_npc/russian = 1) + yellow = list(/datum/outfit/ruin_npc/russian/vest = 1) + red = list(/datum/outfit/ruin_npc/russian/trooper = 1) + +/datum/human_npc_outfit_table/ruin/russian/gunner + green = list(/datum/outfit/ruin_npc/russian/gunner = 1) + yellow = list(/datum/outfit/ruin_npc/russian/gunner/vest = 1) + red = list(/datum/outfit/ruin_npc/russian/gunner/trooper = 1) + +/mob/living/carbon/human/npc/ruin/trooper/russian + npc_name = "Russian Mobster" + faction = list(FACTION_RUSSIAN) + npc_voice = /datum/human_npc_voice/ruin_russian + npc_outfit_table = /datum/human_npc_outfit_table/ruin/russian + npc_death_loot = list(/obj/item/knife/kitchen) + +/mob/living/carbon/human/npc/ruin/trooper/russian/gunner + npc_outfit_table = /datum/human_npc_outfit_table/ruin/russian/gunner + npc_death_loot = list(/obj/item/gun/ballistic/revolver/nagant) diff --git a/voidcrew/modules/hyperviolence/corpses/npc_corpse_handoff.dm b/voidcrew/modules/hyperviolence/corpses/npc_corpse_handoff.dm index 73d40f85ae0..31edcc849e7 100644 --- a/voidcrew/modules/hyperviolence/corpses/npc_corpse_handoff.dm +++ b/voidcrew/modules/hyperviolence/corpses/npc_corpse_handoff.dm @@ -5,8 +5,8 @@ // For a human-looking NPC of flesh and blood we make the corpse the same way, then hand the fight // over (npc_corpse_gore.dm) in place of that even spread, give the player's deathgasp in the NPC's // name, and let the corpse seize up on its feet before it falls. The NPC is still in the world while -// this runs: basic mobs are deleted after their death signal. Wasteland hermits (legacy simple mobs -// whose corpse comes from their loot list) get the same. +// this runs: basic mobs are deleted after their death signal. (Wasteland hermits had hooks here too; +// they are human NPCs now, voidcrew/modules/human_npcs/ruins/, and die in their own bodies.) /datum/element/death_drops/Attach(datum/target, list/loot) . = ..() @@ -51,36 +51,11 @@ dead.hv_npc_deathgasp(body, gasp_now) return body -// ---- Wasteland hermits ---- - -/mob/living/simple_animal/hostile/asteroid/hermit/Initialize(mapload) - . = ..() - for(var/path in loot) - if(hv_is_human_corpse_path(path)) - hv_leaves_human_corpse = TRUE - AddElement(/datum/element/hv_npc_wounds) - break - -/mob/living/simple_animal/hostile/asteroid/hermit/hv_is_fleshy_humanoid_npc() - return TRUE +// ---- Simple mobs ---- /mob/living/simple_animal/hv_damage_share(damagetype) return damage_coeff?[damagetype] || 0 -// A simple mob drops its loot before it is marked dead, so its corpse is made here, by hand, with the -// rest of the loot left to the usual drop. -/mob/living/simple_animal/hostile/asteroid/hermit/drop_loot() - if(!hv_leaves_human_corpse || !length(loot)) - return ..() - // The attack line goes out before the corpse's lines and the deathgasp. - hv_flush_hit() - for(var/path in loot.Copy()) - if(!hv_is_human_corpse_path(path)) - continue - loot -= path - hv_make_npc_corpse(path, drop_location(), src, FALSE) - return ..() - // ---- Deathgasp and death throes ---- /** diff --git a/voidcrew/modules/hyperviolence/hitlines/npc_blood.dm b/voidcrew/modules/hyperviolence/hitlines/npc_blood.dm index 0915602e2de..73ab469b7da 100644 --- a/voidcrew/modules/hyperviolence/hitlines/npc_blood.dm +++ b/voidcrew/modules/hyperviolence/hitlines/npc_blood.dm @@ -64,7 +64,6 @@ /mob/living/basic/regal_rat, /mob/living/basic/snail, /mob/living/basic/stoat, - /mob/living/simple_animal/hostile/asteroid/hermit, )) return is_type_in_typecache(src, plain_bleeders) diff --git a/voidcrew/modules/lich/lich_spells.dm b/voidcrew/modules/lich/lich_spells.dm index 096c68d95f2..0a4d2d0761b 100644 --- a/voidcrew/modules/lich/lich_spells.dm +++ b/voidcrew/modules/lich/lich_spells.dm @@ -332,7 +332,7 @@ mend_the_dead(victim) return - victim.adjustToxLoss(VERDIGRIS_BOLT_ROT, forced = TRUE) + victim.adjustToxLoss(VERDIGRIS_BOLT_ROT) victim.visible_message( span_danger("The green soaks into [victim] and starts to rot [victim.p_them()]."), span_userdanger("Something green gets under your skin and starts rotting you."), @@ -636,7 +636,8 @@ return stung = TRUE - attacker.adjustBruteLoss(npc_zone_damage(VERDIGRIS_MIRAGE_STING, src)) + // Through the arm that struck it, as the message says (a body without arms takes it anywhere). + attacker.apply_damage(npc_zone_damage(VERDIGRIS_MIRAGE_STING, src), BRUTE, attacker.get_active_hand() || null) to_chat(attacker, span_userdanger("The mirage bursts, spraying green light through your arm!")) new /obj/effect/temp_visual/small_smoke/halfsecond(get_turf(src)) QDEL_IN(src, 0) @@ -701,7 +702,7 @@ /obj/item/book/granter/action/spell/verdigris_bolt/recoil(mob/living/user) . = ..() user.visible_message(span_warning("[src] coughs out a wash of green, and the air around [user] starts to smell like wet soil.")) - user.adjustToxLoss(15, forced = TRUE) + user.adjustToxLoss(15) /obj/item/book/granter/action/spell/grave_mirage name = "codex of the fourth corpse" diff --git a/voidcrew/modules/loot/themes/plunder.dm b/voidcrew/modules/loot/themes/plunder.dm index ea2a1067b3d..b6c9e4014af 100644 --- a/voidcrew/modules/loot/themes/plunder.dm +++ b/voidcrew/modules/loot/themes/plunder.dm @@ -95,40 +95,40 @@ /obj/effect/zone_mobs/pirate name = "zone mob spawner (pirate)" mobs_green = list( - /mob/living/basic/trooper/pirate/faction/grey/melee = 10, - /mob/living/basic/trooper/pirate/faction/skeleton/melee = 8, - /mob/living/basic/trooper/pirate/faction/silverscale/melee = 6, + /mob/living/carbon/human/npc/ruin/faction_pirate/grey = 10, + /mob/living/carbon/human/npc/ruin/faction_pirate/skeleton = 8, + /mob/living/carbon/human/npc/ruin/faction_pirate/silverscale = 6, ) mobs_yellow = list( - /mob/living/basic/trooper/pirate/faction/grey/ranged = 8, - /mob/living/basic/trooper/pirate/faction/skeleton/ranged = 8, - /mob/living/basic/trooper/pirate/faction/silverscale/melee = 6, - /mob/living/basic/trooper/pirate/faction/lustrous/ranged = 5, + /mob/living/carbon/human/npc/ruin/faction_pirate/grey/gunner = 8, + /mob/living/carbon/human/npc/ruin/faction_pirate/skeleton/gunner = 8, + /mob/living/carbon/human/npc/ruin/faction_pirate/silverscale = 6, + /mob/living/carbon/human/npc/ruin/faction_pirate/lustrous/gunner = 5, ) mobs_red = list( - /mob/living/basic/trooper/pirate/faction/silverscale/ranged = 8, - /mob/living/basic/trooper/pirate/faction/lustrous/ranged = 8, - /mob/living/basic/trooper/pirate/faction/interdyne/ranged = 6, - /mob/living/basic/trooper/pirate/faction/skeleton/captain = 4, - /mob/living/basic/trooper/pirate/faction/grey/captain = 4, + /mob/living/carbon/human/npc/ruin/faction_pirate/silverscale/gunner = 8, + /mob/living/carbon/human/npc/ruin/faction_pirate/lustrous/gunner = 8, + /mob/living/carbon/human/npc/ruin/faction_pirate/interdyne/gunner = 6, + /mob/living/carbon/human/npc/ruin/faction_pirate/captain/skeleton = 4, + /mob/living/carbon/human/npc/ruin/faction_pirate/captain/grey = 4, ) /// The pirate setpiece: a captain holding the vault, a proper boss in the red. /obj/effect/zone_mobs/pirate/boss name = "zone mob spawner (pirate boss)" mobs_green = list( - /mob/living/basic/trooper/pirate/faction/grey/captain = 1, - /mob/living/basic/trooper/pirate/faction/skeleton/captain = 1, + /mob/living/carbon/human/npc/ruin/faction_pirate/captain/grey = 1, + /mob/living/carbon/human/npc/ruin/faction_pirate/captain/skeleton = 1, ) mobs_yellow = list( - /mob/living/basic/trooper/pirate/faction/silverscale/captain = 1, - /mob/living/basic/trooper/pirate/faction/lustrous/captain = 1, - /mob/living/basic/trooper/pirate/faction/interdyne/captain = 1, + /mob/living/carbon/human/npc/ruin/faction_pirate/captain/silverscale = 1, + /mob/living/carbon/human/npc/ruin/faction_pirate/captain/lustrous = 1, + /mob/living/carbon/human/npc/ruin/faction_pirate/captain/interdyne = 1, ) mobs_red = list( - /mob/living/basic/trooper/pirate/faction/boss/silverscale = 1, - /mob/living/basic/trooper/pirate/faction/boss/skeleton = 1, - /mob/living/basic/trooper/pirate/faction/boss/grey = 1, - /mob/living/basic/trooper/pirate/faction/boss/lustrous = 1, - /mob/living/basic/trooper/pirate/faction/boss/interdyne = 1, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss/silverscale = 1, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss/skeleton = 1, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss/grey = 1, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss/lustrous = 1, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss/interdyne = 1, ) diff --git a/voidcrew/modules/loot/themes/syndicate.dm b/voidcrew/modules/loot/themes/syndicate.dm index 1f63090058e..eebf4f72ad0 100644 --- a/voidcrew/modules/loot/themes/syndicate.dm +++ b/voidcrew/modules/loot/themes/syndicate.dm @@ -96,28 +96,28 @@ /obj/effect/zone_mobs/syndicate name = "zone mob spawner (syndicate)" mobs_green = list( - /mob/living/basic/trooper/syndicate/melee = 14, + /mob/living/carbon/human/npc/ruin/trooper/syndicate = 14, ) mobs_yellow = list( - /mob/living/basic/trooper/syndicate/melee/sword = 8, - /mob/living/basic/trooper/syndicate/ranged = 12, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/sword = 8, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner = 12, ) mobs_red = list( - /mob/living/basic/trooper/syndicate/ranged/smg = 8, - /mob/living/basic/trooper/syndicate/ranged/shotgun = 6, - /mob/living/basic/trooper/syndicate/melee/sword = 5, - /mob/living/basic/trooper/syndicate/ranged/smg/space/stormtrooper = 2, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/smg = 8, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/shotgun = 6, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/sword = 5, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper = 2, ) /obj/effect/zone_mobs/syndicate/boss name = "zone mob spawner (syndicate boss)" mobs_green = list( - /mob/living/basic/trooper/syndicate/melee = 1, + /mob/living/carbon/human/npc/ruin/trooper/syndicate = 1, ) mobs_yellow = list( - /mob/living/basic/trooper/syndicate/ranged = 2, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner = 2, ) mobs_red = list( - /mob/living/basic/trooper/syndicate/ranged/smg/space/stormtrooper = 1, - /mob/living/basic/trooper/syndicate/ranged/shotgun/space/stormtrooper = 1, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper = 1, + /mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper/shotgun = 1, ) diff --git a/voidcrew/modules/loot/zone_mobs.dm b/voidcrew/modules/loot/zone_mobs.dm index 284115aa4f7..d84add286aa 100644 --- a/voidcrew/modules/loot/zone_mobs.dm +++ b/voidcrew/modules/loot/zone_mobs.dm @@ -107,7 +107,7 @@ continue var/turf/spot = length(open_turfs) ? pick_n_take(open_turfs) : get_turf(src) var/datum/npc_spawn_context/context = new(src, zone_type, npc_role) - spawn_npc_in_zone(mob_path, spot, context) + spawn_npc_in_zone_or_queue(mob_path, spot, context) /** * Open, unblocked turfs around the marker the pack can scatter onto. diff --git a/voidcrew/modules/missions/missions/poacher_bust.dm b/voidcrew/modules/missions/missions/poacher_bust.dm index 07cf53dc8f8..e664c33b809 100644 --- a/voidcrew/modules/missions/missions/poacher_bust.dm +++ b/voidcrew/modules/missions/missions/poacher_bust.dm @@ -12,9 +12,11 @@ * filter on the planet target), and the protected species, terrain and * evidence all come from a per-planet table. * - * The squad is the fork's hermit family (simple_animal humanoids): scruffy - * wild-country gunmen with working c38/c46x30mm casing fire, each renamed to - * a generated poacher on spawn. Every death is attributed suppression-style + * The squad is the wasteland hermits as human NPCs + * (voidcrew/modules/human_npcs/ruins/ruin_npc_hermits.dm): scruffy wild-country + * gunmen with real .38 revolvers and WT-550s and their spare ammunition, each + * renamed to a generated poacher on spawn. They are queued and built a body a + * tick; on an ice planet they are dressed for the cold. Every death is attributed suppression-style * (last-attacker ckey or a living crew member in confirm range); unattributed * deaths still count - dead is dead, and the telemetry logs it - so the wipe * can never stall on a weather or wildlife kill. @@ -194,26 +196,25 @@ wipe = new // The squad scales with the zone: green gets rifles and a knife, deeper - // zones add the trailboss's SMG and more guns. Exact subtypes only, the - // /survivor/random variant self-replaces on Initialize and would escape - // tracking. + // zones add the trailboss's SMG and more guns. Exact types only: a type + // that replaces itself on Initialize would escape tracking. var/list/squad_types = list() switch(difficulty) if(MISSION_DIFFICULTY_MEDIUM) - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/survivor + squad_types += /mob/living/carbon/human/npc/ruin/hermit/gunslinger + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit if(MISSION_DIFFICULTY_HARD) - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/survivor + squad_types += /mob/living/carbon/human/npc/ruin/hermit/gunslinger + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit else - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - squad_types += /mob/living/simple_animal/hostile/asteroid/hermit/survivor + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit/hunter + squad_types += /mob/living/carbon/human/npc/ruin/hermit wipe.squad_types = squad_types wipe.squad_names = squad_names wipe.evidence_names = bust_row["evidence"] @@ -264,6 +265,8 @@ var/list/squad_names /// Live squad members still being tracked var/list/mob/living/squad = list() + /// Queued orders for human NPC poachers not built yet (voidcrew/modules/human_npcs/) + var/list/datum/human_npc_order/pending_poachers = list() /// Total squad size at spawn / members down so far var/squad_size = 0 var/squad_down = 0 @@ -301,29 +304,39 @@ squad_size = 0 cache = null -/// Stops tracking every remaining squad member (mission over / reset) +/// Stops tracking every remaining squad member (mission over / reset), and cancels the ones still queued /datum/mission_objective/field/wipe_camp/proc/release_squad() for(var/mob/living/poacher as anything in squad) UnregisterSignal(poacher, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING)) squad.Cut() + for(var/datum/human_npc_order/order as anything in pending_poachers) + qdel(order) + pending_poachers.Cut() /datum/mission_objective/field/wipe_camp/spawn_field_objects(turf/camp_turf) - // The squad, scattered around the campsite + // The squad, scattered around the campsite. Human poachers are queued and join + // the squad as they are built; the head count includes them from the start. for(var/i in 1 to length(squad_types)) var/poacher_type = squad_types[i] - var/mob/living/simple_animal/poacher = spawn_field_npc(poacher_type, get_nearby_open_turf(camp_turf, 3), finalize = FALSE) - if(i <= length(squad_names)) - poacher.name = squad_names[i] + var/poacher_name = i <= length(squad_names) ? squad_names[i] : null + var/turf/poacher_spot = get_nearby_open_turf(camp_turf, 3) + if(ispath(poacher_type, /mob/living/carbon/human/npc)) + // Their outfits keep an ice-planet camp from freezing (human_npc_outfits.dm site layers). + var/datum/npc_spawn_context/context = new(mission?.target || src, mission?.target?.get_zone_type(), "auto", source_turf = poacher_spot) + pending_poachers += spawn_human_npc(poacher_type, poacher_spot, CALLBACK(src, PROC_REF(on_human_poacher_built)), name = poacher_name, context = context) + continue + var/mob/living/simple_animal/poacher = spawn_field_npc(poacher_type, poacher_spot, finalize = FALSE) + if(poacher_name) + poacher.name = poacher_name poacher.desc += " The kit is professional: snare wire, skinning knives, and the brand of [mission.objective_name] burned into the stock." // Outfitted expedition: cold-weather gear keeps an ice-planet camp // from freezing to death before the crew arrives poacher.minbodytemp = 0 poacher.npc_spawn_context.finalize() - protect_field_mob(poacher) - RegisterSignal(poacher, COMSIG_LIVING_DEATH, PROC_REF(on_poacher_death)) - RegisterSignal(poacher, COMSIG_QDELETING, PROC_REF(on_poacher_removed)) - squad += poacher - squad_size = length(squad) + enlist_poacher(poacher) + squad_size = length(squad) + length(pending_poachers) + if(length(pending_poachers)) + addtimer(CALLBACK(src, PROC_REF(check_pending_poachers)), HUMAN_NPC_ZONE_WAIT + 10 SECONDS) // Camp dressing: fire, crude cover, bedrolls. The dense prelit bonfire // can't be pathed into, so the squad won't cook itself. @@ -366,6 +379,41 @@ notify_crew("[mission.objective_name] camp sighted - [squad_size] armed poachers holding a sealed trophy cache. The case beacon marks the cache ([mission.gps_tag]).", type = SHIP_NOTIFY_WARNING, sound = 'voidcrew/sound/notify2.ogg') +/// Tracks a squad member: spared by the planet's fauna sweep, counted when it dies. +/datum/mission_objective/field/wipe_camp/proc/enlist_poacher(mob/living/poacher) + protect_field_mob(poacher) + RegisterSignal(poacher, COMSIG_LIVING_DEATH, PROC_REF(on_poacher_death)) + RegisterSignal(poacher, COMSIG_QDELETING, PROC_REF(on_poacher_removed)) + squad += poacher + +/// A queued human poacher was built: it joins the squad. +/datum/mission_objective/field/wipe_camp/proc/on_human_poacher_built(mob/living/carbon/human/npc/poacher) + for(var/datum/human_npc_order/order as anything in pending_poachers) + if(order.spawned_ref?.resolve() == poacher) + pending_poachers -= order + break + if(!active || !mission || mission.failed || mission.completed) + return + enlist_poacher(poacher) + +/** + * A queued poacher that was never built (its spot became unusable) leaves the head count, so the wipe + * can still finish. Orders still waiting are looked at again later. + */ +/datum/mission_objective/field/wipe_camp/proc/check_pending_poachers() + if(!active || !mission || mission.failed || mission.completed) + return + for(var/datum/human_npc_order/order as anything in pending_poachers.Copy()) + if(QDELETED(order) || (order.done && !order.spawned_ref?.resolve())) + pending_poachers -= order + squad_size-- + if(length(pending_poachers)) + addtimer(CALLBACK(src, PROC_REF(check_pending_poachers)), 10 SECONDS) + return + if(squad_size > 0 && squad_down >= squad_size) + unlock_cache() + complete() + /** * A named poacher died: always counts toward the wipe (attribution only * flavors the line - dead is dead and the telemetry logs it, so a weather or diff --git a/voidcrew/modules/missions/missions/recovery.dm b/voidcrew/modules/missions/missions/recovery.dm index a018e34a098..34c95f06f21 100644 --- a/voidcrew/modules/missions/missions/recovery.dm +++ b/voidcrew/modules/missions/missions/recovery.dm @@ -70,39 +70,40 @@ mission_limit = 2 gps_tag_prefix = "HUNT" - /// Mob type pools per difficulty (space-capable faction pirates). + /// Mob type pools per difficulty: faction pirates as human NPCs + /// (voidcrew/modules/human_npcs/ruins/), in space suits where the site is airless. /// Hard pool is the real faction bosses. Red-zone bounties are the /// named monsters of the sector, not just another captain. var/static/list/easy_targets = list( - /mob/living/basic/trooper/pirate/faction/silverscale/melee, - /mob/living/basic/trooper/pirate/faction/skeleton/melee, - /mob/living/basic/trooper/pirate/faction/grey/melee, + /mob/living/carbon/human/npc/ruin/faction_pirate/silverscale, + /mob/living/carbon/human/npc/ruin/faction_pirate/skeleton, + /mob/living/carbon/human/npc/ruin/faction_pirate/grey, ) var/static/list/medium_targets = list( - /mob/living/basic/trooper/pirate/faction/silverscale/ranged, - /mob/living/basic/trooper/pirate/faction/skeleton/ranged, - /mob/living/basic/trooper/pirate/faction/lustrous/ranged, - /mob/living/basic/trooper/pirate/faction/grey/captain, + /mob/living/carbon/human/npc/ruin/faction_pirate/silverscale/gunner, + /mob/living/carbon/human/npc/ruin/faction_pirate/skeleton/gunner, + /mob/living/carbon/human/npc/ruin/faction_pirate/lustrous/gunner, + /mob/living/carbon/human/npc/ruin/faction_pirate/captain/grey, ) var/static/list/hard_targets = list( - /mob/living/basic/trooper/pirate/faction/boss/silverscale, - /mob/living/basic/trooper/pirate/faction/boss/skeleton, - /mob/living/basic/trooper/pirate/faction/boss/grey, - /mob/living/basic/trooper/pirate/faction/boss/lustrous, - /mob/living/basic/trooper/pirate/faction/boss/interdyne, - /mob/living/basic/trooper/pirate/faction/boss/medieval, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss/silverscale, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss/skeleton, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss/grey, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss/lustrous, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss/interdyne, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss/medieval, ) /// Entourage pools: bounty targets above easy keep hired muscle around var/static/list/medium_guards = list( - /mob/living/basic/trooper/pirate/faction/grey/melee, - /mob/living/basic/trooper/pirate/faction/skeleton/melee, - /mob/living/basic/trooper/pirate/faction/silverscale/melee, + /mob/living/carbon/human/npc/ruin/faction_pirate/grey, + /mob/living/carbon/human/npc/ruin/faction_pirate/skeleton, + /mob/living/carbon/human/npc/ruin/faction_pirate/silverscale, ) var/static/list/hard_guards = list( - /mob/living/basic/trooper/pirate/faction/silverscale/ranged, - /mob/living/basic/trooper/pirate/faction/skeleton/ranged, - /mob/living/basic/trooper/pirate/faction/lustrous/ranged, - /mob/living/basic/trooper/pirate/faction/interdyne/ranged, + /mob/living/carbon/human/npc/ruin/faction_pirate/silverscale/gunner, + /mob/living/carbon/human/npc/ruin/faction_pirate/skeleton/gunner, + /mob/living/carbon/human/npc/ruin/faction_pirate/lustrous/gunner, + /mob/living/carbon/human/npc/ruin/faction_pirate/interdyne/gunner, ) /datum/mission/recovery/kill/generate_details() diff --git a/voidcrew/modules/missions/objectives/combat.dm b/voidcrew/modules/missions/objectives/combat.dm index a9f08365fff..466cac9fccd 100644 --- a/voidcrew/modules/missions/objectives/combat.dm +++ b/voidcrew/modules/missions/objectives/combat.dm @@ -158,8 +158,12 @@ var/proof_type = /obj/item/mission_recovery/proof /// The live target, while they're alive var/mob/living/target_mob + /// The queued order building a human NPC target (voidcrew/modules/human_npcs/), until it is built + var/datum/human_npc_order/quarry_order /datum/mission_objective/field/kill_named/deactivate() + // A target still waiting in the spawn queue is never built. + QDEL_NULL(quarry_order) if(target_mob) UnregisterSignal(target_mob, COMSIG_LIVING_DEATH) target_mob = null @@ -174,6 +178,9 @@ create_proof(spawn_turf) complete() return + if(ispath(target_mob_type, /mob/living/carbon/human/npc)) + spawn_human_quarry(spawn_turf) + return var/mob/living/target = spawn_field_npc(target_mob_type, spawn_turf, finalize = FALSE) target.name = mission.objective_name target.desc += " They look like they're worth something dead." @@ -186,6 +193,31 @@ guard.desc += " They're on somebody's payroll." guard.npc_spawn_context.finalize() +/** + * A human NPC target (voidcrew/modules/human_npcs/) and its guards are queued, a body a tick, not built + * on the spot. The target carries the contract's name and is tracked once it exists; the guards are + * untracked muscle, as above. + */ +/datum/mission_objective/field/kill_named/proc/spawn_human_quarry(turf/spawn_turf) + var/datum/npc_spawn_context/context = new(mission?.target || src, mission?.target?.get_zone_type(), "auto", source_turf = spawn_turf) + quarry_order = spawn_human_npc(target_mob_type, spawn_turf, CALLBACK(src, PROC_REF(on_quarry_built)), name = mission.objective_name, context = context) + for(var/guard_type in guard_types) + var/turf/guard_spot = get_nearby_open_turf(spawn_turf) + if(ispath(guard_type, /mob/living/carbon/human/npc)) + var/datum/npc_spawn_context/guard_context = new(mission?.target || src, mission?.target?.get_zone_type(), "auto", source_turf = guard_spot) + spawn_human_npc(guard_type, guard_spot, context = guard_context) + continue + var/mob/living/guard = spawn_field_npc(guard_type, guard_spot, finalize = FALSE) + guard.desc += " They're on somebody's payroll." + guard.npc_spawn_context.finalize() + +/// The queued target exists now: track it, unless the contract moved on while it waited. +/datum/mission_objective/field/kill_named/proc/on_quarry_built(mob/living/quarry) + quarry_order = null + if(!active || !mission || mission.failed || mission.completed || mission.named_kill_completed) + return + track_target(quarry) + /// Keep death tracking and the cash entitlement attached to the same mission. /datum/mission_objective/field/kill_named/proc/track_target(mob/living/target) target_mob = target @@ -193,6 +225,9 @@ if(istype(target, /mob/living/basic/trooper/pirate/faction)) var/mob/living/basic/trooper/pirate/faction/pirate = target pirate.mission_payout_ref = WEAKREF(mission) + else if(istype(target, /mob/living/carbon/human/npc/ruin)) + var/mob/living/carbon/human/npc/ruin/quarry = target + quarry.npc_payout_mission = WEAKREF(mission) RegisterSignal(target, COMSIG_LIVING_DEATH, PROC_REF(on_target_death)) mission.register_quest_atom(target) @@ -248,10 +283,13 @@ var/kill_count = 0 /// How close (tiles) a living crew member must be to confirm a kill var/confirm_range = 9 + /// Weakrefs to every body already counted: one revived and killed again counts once + var/list/datum/weakref/counted_kills = list() /datum/mission_objective/kill_count/reset() . = ..() kill_count = 0 + counted_kills = list() /datum/mission_objective/kill_count/activate() . = ..() @@ -263,16 +301,17 @@ /datum/mission_objective/kill_count/proc/on_any_death(datum/source, mob/living/victim, gibbed) SIGNAL_HANDLER - if(completed || !istype(victim, mob_base_type)) + if(completed || !counts_victim(victim)) return - var/turf/where = get_turf(victim) - if(!where) + var/datum/weakref/victim_ref = WEAKREF(victim) + if(victim_ref in counted_kills) return - var/zone_type = SSovermap_zones?.get_zone_type_anywhere(where) - if(isnull(zone_type) || zone_type < minimum_zone) + var/turf/where = get_turf(victim) + if(!where || !kill_zone_counts(where)) return if(!kill_attributable_to_crew(victim, where)) return + counted_kills += victim_ref kill_count++ if(kill_count >= required_kills) notify_crew("Suppression sweep complete.") @@ -280,6 +319,20 @@ return notify_crew("Kill confirmed: [kill_count]/[required_kills].", sound = 'voidcrew/sound/notify2.ogg') +/// Whether a kill at `where` is deep enough to count (design guard: no green farming). +/datum/mission_objective/kill_count/proc/kill_zone_counts(turf/where) + var/zone_type = SSovermap_zones?.get_zone_type_anywhere(where) + return !isnull(zone_type) && zone_type >= minimum_zone + +/** + * Whether `victim` is a mob this sweep counts: the basic faction pirates, and human NPCs + * (voidcrew/modules/human_npcs/) that sail under the pirate flag. + */ +/datum/mission_objective/kill_count/proc/counts_victim(mob/living/victim) + if(istype(victim, mob_base_type)) + return TRUE + return ishumannpc(victim) && (FACTION_PIRATE in victim.faction) + /** * Whether this kill belongs to the servant crew: the last attacker's ckey is * a crew member's, or a living crew member is close enough to have done it diff --git a/voidcrew/modules/mob/living/simple_animal/hostile/pirate.dm b/voidcrew/modules/mob/living/simple_animal/hostile/pirate.dm deleted file mode 100644 index c44867a13e0..00000000000 --- a/voidcrew/modules/mob/living/simple_animal/hostile/pirate.dm +++ /dev/null @@ -1,23 +0,0 @@ -/mob/living/basic/trooper/pirate/melee/beach - faction = list(FACTION_BEACH) - -/mob/living/basic/trooper/pirate/ranged/beach - faction = list(FACTION_BEACH) - -/mob/living/basic/trooper/pirate/melee/beach/boat - icon = 'voidcrew/icons/mob/boat_pirates.dmi' - icon_state = "piratemelee" - icon_living = "piratemelee" - use_dynamic_appearance = FALSE - faction = list(FACTION_BEACH) - unsuitable_atmos_damage = 0 - minimum_survivable_temperature = 0 - -/mob/living/basic/trooper/pirate/ranged/beach/boat - icon = 'voidcrew/icons/mob/boat_pirates.dmi' - icon_state = "pirateranged" - icon_living = "pirateranged" - use_dynamic_appearance = FALSE - faction = list(FACTION_BEACH) - unsuitable_atmos_damage = 0 - minimum_survivable_temperature = 0 diff --git a/voidcrew/modules/mob/living/simple_animal/hostile/wasteland/hermits.dm b/voidcrew/modules/mob/living/simple_animal/hostile/wasteland/hermits.dm deleted file mode 100644 index e59848796f3..00000000000 --- a/voidcrew/modules/mob/living/simple_animal/hostile/wasteland/hermits.dm +++ /dev/null @@ -1,92 +0,0 @@ -/mob/living/simple_animal/hostile/asteroid/hermit - name = "Wasteland Inhabitant" - desc = "If you can read this, yell at a coder!" - icon = 'voidcrew/icons/mob/simple_human.dmi' - icon_state = "survivor_base" - icon_living = "survivor_base" - icon_dead = null - icon_gib = null - mob_biotypes = MOB_ORGANIC - minbodytemp = 180 - unsuitable_atmos_damage = 15 - atmos_requirements = list("min_oxy" = 1, "max_oxy" = 0, "min_tox" = 0, "max_tox" = 0, "min_co2" = 0, "max_co2" = 999, "min_n2" = 0, "max_n2" = 0) - speak_chance = 20 - turns_per_move = 5 - response_help_continuous = "pushes" - response_help_simple = "push" - speed = 0 - maxHealth = 100 - health = 100 - harm_intent_damage = 5 - melee_damage_lower = 10 - melee_damage_upper = 10 - attack_verb_continuous = "punches" - attack_verb_simple = "punch" - attack_sound = 'sound/items/weapons/punch1.ogg' - combat_mode = TRUE - unsuitable_atmos_damage = 15 - speak_emote = list( - "The sun... The sun...", - "You'll do...", - "All mine...", - "Gg..free..", - "Drink.. need drink...", - "I can smell... you..", - "G-g...", - "Taste..the salt.", - ) - loot = list( - /obj/effect/mob_spawn/corpse/human/hermit/survivor - ) - del_on_death = 1 - faction = list("hermit", "wasteland") - -/mob/living/simple_animal/hostile/asteroid/hermit/death(gibbed) - move_force = MOVE_FORCE_DEFAULT - move_resist = MOVE_RESIST_DEFAULT - pull_force = PULL_FORCE_DEFAULT - return ..() - -/mob/living/simple_animal/hostile/asteroid/hermit/survivor - name = "Hermit Wanderer" - desc =" A wild-eyed figure, wearing tattered mining equipment and boasting a malformed body, twisted by the heavy metals and high background radiation of the sandworlds." - -/mob/living/simple_animal/hostile/asteroid/hermit/survivor/random/Initialize() - . = ..() - if(prob(10)) - spawn_npc_in_zone(/mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger, loc, get_npc_spawn_context(), FALSE) - return INITIALIZE_HINT_QDEL - if(prob(35)) - spawn_npc_in_zone(/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter, loc, get_npc_spawn_context(), FALSE) - return INITIALIZE_HINT_QDEL - -/mob/living/simple_animal/hostile/asteroid/hermit/ranged - icon_state = "survivor_hunter" - icon_living = "survivor_hunter" - casingtype = /obj/item/ammo_casing/c38 - projectilesound = 'sound/items/weapons/gun/rifle/shot.ogg' - ranged = 1 - rapid_fire_delay = 6 - retreat_distance = 5 - minimum_distance = 5 - -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/hunter - name = "Hermit Hunter" - desc ="A wild-eyed figure. Watch out- he has a gun, and he remembers just enough of his old life to use it!" - loot = list( - /obj/effect/mob_spawn/corpse/human/hermit/survivor/hunter - ) - -/mob/living/simple_animal/hostile/asteroid/hermit/ranged/gunslinger - name = "Hermit Soldier" - desc = "The miner's rebellion, though mostly underground, recieved a few good weapon shipments from an off-sector source. You should probably start running." - icon_state = "survivor_gunslinger" - icon_living = "survivor_gunslinger" - casingtype = /obj/item/ammo_casing/c46x30mm - projectilesound = 'sound/items/weapons/gun/smg/shot.ogg' - speed = 1 - rapid = 4 - rapid_fire_delay = 3 - loot = list( - /obj/effect/mob_spawn/corpse/human/hermit/survivor/gunslinger - ) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index ce5aa07f5cb..0112006e262 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -92,6 +92,7 @@ #include "voidcrew_hull_containment.dm" #include "voidcrew_hull_survey.dm" #include "voidcrew_human_npcs.dm" +#include "voidcrew_human_npcs_ruins.dm" #include "voidcrew_human_npcs_shared.dm" #include "voidcrew_hv_finishers.dm" #include "voidcrew_hyperviolence_blood.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_human_npcs_ruins.dm b/voidcrew/modules/unit_tests/voidcrew_human_npcs_ruins.dm new file mode 100644 index 00000000000..1edaabc93d1 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_human_npcs_ruins.dm @@ -0,0 +1,271 @@ +// Ruin, planet and mission human NPCs, phase C (voidcrew/modules/human_npcs/ruins/). Unit tests compile +// before voidcrew/_DEFINES, so values from there are written out: zones 1 green, 2 yellow, 3 red; ranks +// 0 regular, 1 elite, 2 boss; the spot bark kind is "spot". + +/// The ruin families' own abstract types, which no spawner or list places. +/datum/unit_test/proc/ruin_npc_abstract_types() + return list( + /mob/living/carbon/human/npc/ruin, + /mob/living/carbon/human/npc/ruin/trooper, + /mob/living/carbon/human/npc/ruin/faction_pirate, + /mob/living/carbon/human/npc/ruin/faction_pirate/captain, + /mob/living/carbon/human/npc/ruin/faction_pirate/boss, + ) + +/** + * Every ruin NPC type dresses in every zone, fights with something it holds (a green-zone wanderer with + * its fists), carries spare ammunition for a magazine gun, wears locked gear, and keeps its side. + */ +/datum/unit_test/voidcrew_ruin_npc_families + +/datum/unit_test/voidcrew_ruin_npc_families/Run() + var/list/abstract = ruin_npc_abstract_types() + for(var/npc_type in subtypesof(/mob/living/carbon/human/npc/ruin) - abstract) + var/mob/living/carbon/human/npc/ruin/prototype = npc_type + var/list/sides = initial(prototype.faction) + for(var/zone in 1 to 3) + var/mob/living/carbon/human/npc/ruin/npc = human_npc_build_in_zone(npc_type, zone) + TEST_ASSERT_NOTNULL(npc, "[npc_type] wasn't built in zone [zone]") + if(isnull(npc)) + continue + TEST_ASSERT_NOTNULL(npc.w_uniform, "[npc_type] has no clothes in zone [zone]") + TEST_ASSERT(human_npc_gear_locked(npc.w_uniform), "[npc_type]'s clothes aren't locked to it in zone [zone]") + var/obj/item/gun/gun = human_npc_held_gun(npc) + var/unarmed_ok = npc_type == /mob/living/carbon/human/npc/ruin/hermit && zone == 1 + if(!unarmed_ok) + TEST_ASSERT(gun || human_npc_held_melee_weapon(npc), "[npc_type] holds no weapon in zone [zone]") + if(istype(gun, /obj/item/gun/ballistic)) + TEST_ASSERT_NOTNULL(human_npc_find_ammo(npc, gun), "[npc_type] carries no spare ammunition for its [gun] in zone [zone]") + for(var/side in sides) + TEST_ASSERT(side in npc.faction, "[npc_type] lost its [side] faction") + TEST_ASSERT(npc.should_inherit_planetary_faction(), "[npc_type] doesn't join its planet's natives") + TEST_ASSERT_EQUAL(npc.maxHealth, 100, "[npc_type]'s max health isn't a person's") + qdel(npc) + +/// The trooper weapon ladder and the zone armour live in the outfit tables. +/datum/unit_test/voidcrew_ruin_npc_zone_ladder + +/datum/unit_test/voidcrew_ruin_npc_zone_ladder/Run() + // Syndicate SMG gunners carry a pistol outside the red zone, and the C-20r in it. + var/mob/living/carbon/human/npc/smg_green = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/smg, 1) + TEST_ASSERT_NOTNULL(locate(/obj/item/gun/ballistic/automatic/pistol) in smg_green?.held_items, "A green-zone Syndicate SMG gunner doesn't hold the ladder pistol") + TEST_ASSERT(!istype(smg_green?.wear_suit, /obj/item/clothing/suit/armor), "A green-zone Syndicate gunner wears armour") + var/mob/living/carbon/human/npc/smg_yellow = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/smg, 2) + TEST_ASSERT_NOTNULL(locate(/obj/item/gun/ballistic/automatic/pistol) in smg_yellow?.held_items, "A yellow-zone Syndicate SMG gunner doesn't hold the ladder pistol") + TEST_ASSERT(istype(smg_yellow?.wear_suit, /obj/item/clothing/suit/armor/vest), "A yellow-zone Syndicate gunner wears no vest") + var/mob/living/carbon/human/npc/smg_red = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/smg, 3) + TEST_ASSERT_NOTNULL(locate(/obj/item/gun/ballistic/automatic/c20r) in smg_red?.held_items, "A red-zone Syndicate SMG gunner holds no C-20r") + TEST_ASSERT(istype(smg_red?.head, /obj/item/clothing/head/helmet), "A red-zone Syndicate gunner wears no helmet") + TEST_ASSERT_NOTNULL(locate(/obj/item/reagent_containers/hypospray/medipen) in smg_red?.get_all_contents(), "A red-zone Syndicate gunner carries no medipen") + // The sword guard only has a knife in the green zone. + var/mob/living/carbon/human/npc/sword_green = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/trooper/syndicate/sword, 1) + TEST_ASSERT_NULL(locate(/obj/item/melee/energy/sword) in sword_green?.held_items, "A green-zone Syndicate sword guard holds an energy sword") + var/mob/living/carbon/human/npc/sword_yellow = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/trooper/syndicate/sword, 2) + TEST_ASSERT_NOTNULL(locate(/obj/item/melee/energy/sword) in sword_yellow?.held_items, "A yellow-zone Syndicate sword guard holds no energy sword") + // Stormtroopers are elites and still follow the ladder. + var/mob/living/carbon/human/npc/storm_green = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper, 1) + TEST_ASSERT_EQUAL(storm_green?.npc_rank, 1, "A stormtrooper isn't an elite") + TEST_ASSERT_NOTNULL(locate(/obj/item/gun/ballistic/automatic/pistol) in storm_green?.held_items, "A green-zone stormtrooper doesn't hold the ladder pistol") + var/mob/living/carbon/human/npc/storm_yellow = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper, 2) + TEST_ASSERT_NOTNULL(locate(/obj/item/gun/ballistic/automatic/c20r) in storm_yellow?.held_items, "A yellow-zone stormtrooper holds no C-20r") + // Space kit is sealed and breathes from its own tank. + var/mob/living/carbon/human/npc/commando = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/trooper/syndicate/commando/gunner, 1) + TEST_ASSERT(istype(commando?.wear_suit, /obj/item/clothing/suit/space/syndicate), "A Syndicate commando wears no space suit") + TEST_ASSERT_NOTNULL(commando?.internal, "A Syndicate commando isn't breathing from its tank") + +/// Faction pirates drop what the basic mobs dropped: fixed pieces, a captain's zone blade, the ladder weapon, cash. +/datum/unit_test/voidcrew_ruin_npc_faction_pirate_loot + +/datum/unit_test/voidcrew_ruin_npc_faction_pirate_loot/Run() + var/mob/living/carbon/human/npc/ruin/faction_pirate/captain/silverscale/noble = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/faction_pirate/captain/silverscale, 2) + TEST_ASSERT_EQUAL(noble?.npc_rank, 1, "A faction captain isn't an elite") + TEST_ASSERT(/obj/item/claymore/shortsword in noble?.npc_roll_death_loot(), "A yellow-zone Silverscale noble doesn't carry the zone's shortsword") + var/mob/living/carbon/human/npc/ruin/faction_pirate/boss/skeleton/davy = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/faction_pirate/boss/skeleton, 3) + TEST_ASSERT_EQUAL(davy?.npc_rank, 2, "A faction leader isn't a boss") + var/list/davy_loot = davy?.npc_roll_death_loot() + TEST_ASSERT(/obj/item/gun/ballistic/shotgun/hook in davy_loot, "Davy Jones lost his hook shotgun") + TEST_ASSERT_EQUAL(length(davy_loot), 1, "Davy Jones rolls loot a boss doesn't") + var/mob/living/carbon/human/npc/ruin/faction_pirate/grey/tider = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/faction_pirate/grey, 1) + TEST_ASSERT_EQUAL(length(tider?.npc_roll_death_loot()), 2, "A grey tider doesn't roll a ladder weapon and one odds-and-ends item") + // The designed drops and the cash land once, however often it dies. + var/turf/drop_turf = tider.drop_location() + var/list/before = drop_turf.contents.Copy() + tider.death() + var/obj/item/holochip/chip = locate() in (drop_turf.contents - before) + TEST_ASSERT_NOTNULL(chip, "A grey tider dropped no cash") + TEST_ASSERT_EQUAL(chip?.credits, 300, "A grey tider's cash changed") + var/list/after_first = drop_turf.contents.Copy() + tider.revive(ADMIN_HEAL_ALL) + tider.death() + TEST_ASSERT_NULL(locate(/obj/item/holochip) in (drop_turf.contents - after_first), "A revived grey tider paid its cash twice") + for(var/atom/movable/dropped in drop_turf.contents - before) + if(dropped != tider) + qdel(dropped) + +/// Mapped NPCs: a placed spawner queues its NPC with the map's name and side; the family's own spawner places nothing. +/obj/effect/spawner/human_npc/ruin/hermit/unit_test + npc_name = "Test Hermit" + npc_faction = list("saloon") + +/datum/unit_test/voidcrew_ruin_npc_spawners + +/datum/unit_test/voidcrew_ruin_npc_spawners/Run() + var/queued_before = length(SShuman_npcs.queue) + new /obj/effect/spawner/human_npc/ruin(run_loc_floor_bottom_left) + TEST_ASSERT_EQUAL(length(SShuman_npcs.queue), queued_before, "The ruin family's own spawner queued an NPC") + new /obj/effect/spawner/human_npc/ruin/hermit/unit_test(run_loc_floor_bottom_left) + TEST_ASSERT_EQUAL(length(SShuman_npcs.queue), queued_before + 1, "A placed hermit spawner queued nothing") + var/datum/human_npc_order/order = SShuman_npcs.queue[length(SShuman_npcs.queue)] + order.zone = 1 + var/mob/living/carbon/human/npc/ruin/hermit/hermit = order.fulfil() + qdel(order) + TEST_ASSERT_NOTNULL(hermit, "The placed hermit wasn't built") + if(isnull(hermit)) + return + allocated += hermit + TEST_ASSERT_EQUAL(hermit.real_name, "Test Hermit", "The map's name didn't reach the hermit") + TEST_ASSERT("saloon" in hermit.faction, "The map's faction didn't reach the hermit") + TEST_ASSERT(!("hermit" in hermit.faction), "The map's faction didn't replace the hermit's own") + // Lists that roll many mobs queue human types instead of building them. + var/datum/npc_spawn_context/context = new /datum/npc_spawn_context(run_loc_floor_bottom_left, 1) + var/datum/human_npc_order/queued = spawn_npc_in_zone_or_queue(/mob/living/carbon/human/npc/ruin/hermit, run_loc_floor_bottom_left, context) + TEST_ASSERT(istype(queued, /datum/human_npc_order), "A human NPC from a spawn list was built on the spot") + qdel(queued) + var/mob/living/basic/mouse/mouse = spawn_npc_in_zone_or_queue(/mob/living/basic/mouse, run_loc_floor_bottom_left, new /datum/npc_spawn_context(run_loc_floor_bottom_left, 1)) + allocated += mouse + TEST_ASSERT(istype(mouse, /mob/living/basic/mouse), "A basic mob from a spawn list wasn't built at once") + +/// Suppression counts human pirates, each body once, and never another side's NPCs. +/datum/mission_objective/kill_count/unit_test + +/datum/mission_objective/kill_count/unit_test/kill_zone_counts(turf/where) + return TRUE + +/datum/mission_objective/kill_count/unit_test/kill_attributable_to_crew(mob/living/victim, turf/where) + return TRUE + +/datum/unit_test/voidcrew_ruin_npc_suppression_counts + +/datum/unit_test/voidcrew_ruin_npc_suppression_counts/Run() + var/datum/mission_objective/kill_count/unit_test/counter = allocate(/datum/mission_objective/kill_count/unit_test) + counter.required_kills = 10 + var/mob/living/carbon/human/npc/pirate = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/trooper/pirate, 2) + counter.on_any_death(null, pirate, FALSE) + TEST_ASSERT_EQUAL(counter.kill_count, 1, "A dead human pirate wasn't counted") + counter.on_any_death(null, pirate, FALSE) + TEST_ASSERT_EQUAL(counter.kill_count, 1, "A human pirate revived and killed again counted twice") + var/mob/living/carbon/human/npc/captain = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/faction_pirate/captain/grey, 2) + counter.on_any_death(null, captain, FALSE) + TEST_ASSERT_EQUAL(counter.kill_count, 2, "A dead faction pirate captain wasn't counted") + var/mob/living/carbon/human/npc/boater = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/trooper/pirate/beach, 2) + var/mob/living/carbon/human/npc/operative = human_npc_build_in_zone(/mob/living/carbon/human/npc/ruin/trooper/syndicate, 2) + counter.on_any_death(null, boater, FALSE) + counter.on_any_death(null, operative, FALSE) + TEST_ASSERT_EQUAL(counter.kill_count, 2, "Beach pirates or Syndicate operatives counted as pirates") + +/// Kill contracts queue a human target, track it once it is built, and pay its cash once per contract. +/datum/unit_test/voidcrew_ruin_npc_kill_contract + +/datum/unit_test/voidcrew_ruin_npc_kill_contract/Run() + var/turf/location = run_loc_floor_bottom_left + var/datum/mission/mission = allocate(/datum/mission/npc_zone_test) + mission.objective_name = "Test Captain" + var/datum/mission_target/npc_zone_test/site = allocate(/datum/mission_target/npc_zone_test, mission) + site.band = 2 + mission.target = site + var/datum/mission_objective/field/kill_named/hunt = allocate(/datum/mission_objective/field/kill_named) + hunt.mission = mission + hunt.active = TRUE + hunt.target_mob_type = /mob/living/carbon/human/npc/ruin/faction_pirate/captain/grey + hunt.spawn_field_objects(location) + TEST_ASSERT_NULL(hunt.target_mob, "A human target was built on the spot instead of queued") + TEST_ASSERT_NOTNULL(hunt.quarry_order, "A human target wasn't queued") + var/datum/human_npc_order/order = hunt.quarry_order + var/mob/living/carbon/human/npc/ruin/quarry = order?.fulfil() + TEST_ASSERT_NOTNULL(quarry, "The queued target wasn't built") + if(isnull(quarry)) + return + allocated += quarry + TEST_ASSERT_EQUAL(hunt.target_mob, quarry, "The built target isn't tracked") + TEST_ASSERT_EQUAL(quarry.real_name, "Test Captain", "The target doesn't carry the contract's name") + TEST_ASSERT_EQUAL(quarry.npc_payout_mission?.resolve(), mission, "The target isn't tied to its contract's cash") + // A contract that has already paid its cash leaves the body without any. + mission.boss_cash_issued = TRUE + var/list/before = location.contents.Copy() + mission.forget_quest_atom(quarry) + hunt.deactivate() + quarry.death() + TEST_ASSERT_NULL(locate(/obj/item/holochip) in (location.contents - before), "A target paid cash its contract had already paid") + for(var/atom/movable/dropped in location.contents - before) + if(dropped != quarry) + qdel(dropped) + // A cancelled contract never builds a target still in the queue. + hunt.active = TRUE + hunt.spawn_field_objects(location) + var/datum/human_npc_order/cancelled = hunt.quarry_order + hunt.deactivate() + TEST_ASSERT(QDELETED(cancelled), "A deactivated contract left its target in the spawn queue") + +/// Poacher squads of human hermits are queued, counted from the start and named when built. +/datum/unit_test/voidcrew_ruin_npc_poacher_squad + +/datum/unit_test/voidcrew_ruin_npc_poacher_squad/Run() + var/turf/location = run_loc_floor_bottom_left + var/datum/mission/mission = allocate(/datum/mission/npc_zone_test) + mission.objective_name = "the Test Outfit" + var/datum/mission_target/npc_zone_test/site = allocate(/datum/mission_target/npc_zone_test, mission) + mission.target = site + var/datum/mission_objective/field/wipe_camp/camp = allocate(/datum/mission_objective/field/wipe_camp) + camp.mission = mission + camp.active = TRUE + camp.squad_types = list(/mob/living/carbon/human/npc/ruin/hermit/hunter, /mob/living/carbon/human/npc/ruin/hermit) + camp.squad_names = list("'Snares' Okafor", "'Tallow' Vance") + camp.spawn_field_objects(location) + TEST_ASSERT_EQUAL(camp.squad_size, 2, "The squad's head count didn't include the queued poachers") + TEST_ASSERT_EQUAL(length(camp.pending_poachers), 2, "The human poachers weren't queued") + for(var/datum/human_npc_order/order as anything in camp.pending_poachers.Copy()) + order.fulfil() + TEST_ASSERT_EQUAL(length(camp.squad), 2, "The built poachers didn't join the squad") + TEST_ASSERT_EQUAL(length(camp.pending_poachers), 0, "Built poachers are still pending") + var/list/names = list() + for(var/mob/living/carbon/human/npc/poacher in camp.squad) + names += poacher.real_name + allocated += poacher + TEST_ASSERT("'Snares' Okafor" in names, "The trailboss lost the contract's name") + mission.forget_quest_atom(mission.quest_atom) + camp.deactivate() + for(var/obj/thing in range(3, location)) + if(!isturf(thing) && !istype(thing, /obj/effect/landmark)) + qdel(thing) + +/// No Voidcrew map places a trooper or hermit basic mob any more, and the derelict shuttles inside ruins load +/// their Voidcrew copies (the bounty lair's mafia is another phase's). +/datum/unit_test/voidcrew_ruin_npc_maps + +/datum/unit_test/voidcrew_ruin_npc_maps/Run() + var/list/maps = list() + ruin_npc_collect_maps("_maps/voidcrew/", maps) + TEST_ASSERT(length(maps) > 100, "The Voidcrew map scan found only [length(maps)] maps") + for(var/map_path in maps) + if(findtext(map_path, "bounty_lair_mafia_club.dmm")) + continue + var/text = maps[map_path] + TEST_ASSERT(!findtext(text, "/mob/living/basic/trooper"), "[map_path] still places a trooper basic mob") + TEST_ASSERT(!findtext(text, "/mob/living/simple_animal/hostile/asteroid/hermit"), "[map_path] still places a hermit simple mob") + for(var/template_type in list( + /datum/map_template/shuttle/ruin/caravan_victim, + /datum/map_template/shuttle/ruin/pirate_cutter, + /datum/map_template/shuttle/ruin/syndicate_dropship, + /datum/map_template/shuttle/ruin/syndicate_fighter_shiv, + )) + var/datum/map_template/shuttle/template = new template_type() + TEST_ASSERT(findtext(template.mappath, "_maps/voidcrew/shuttles/") == 1, "[template_type] loads [template.mappath], not its Voidcrew copy") + TEST_ASSERT(fexists(template.mappath), "[template_type]'s map [template.mappath] is missing") + qdel(template) + +/datum/unit_test/voidcrew_ruin_npc_maps/proc/ruin_npc_collect_maps(root, list/out) + for(var/entry in flist(root)) + if(copytext(entry, -1) == "/") + ruin_npc_collect_maps("[root][entry]", out) + else if(copytext(entry, -4) == ".dmm") + out["[root][entry]"] = file2text("[root][entry]") diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm index 0805ef40c8b..3af25dd4dea 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm @@ -230,20 +230,3 @@ TEST_ASSERT_EQUAL(pirate.gender, body.gender, "The pirate's deathgasp would not match its corpse's sex") var/datum/emote/living/deathgasp/gasp = /datum/emote/living/deathgasp TEST_ASSERT_EQUAL(pirate.death_message, initial(gasp.message), "The pirate's death line is not the player's deathgasp") - -/// A wasteland hermit (a legacy simple mob with its corpse in its loot) hands its fight over too. -/datum/unit_test/hv_hermit_corpse - -/datum/unit_test/hv_hermit_corpse/Run() - var/turf/spot = run_loc_floor_bottom_left - var/mob/living/simple_animal/hostile/asteroid/hermit/survivor/hermit = allocate(/mob/living/simple_animal/hostile/asteroid/hermit/survivor) - hermit.toggle_ai(AI_OFF) - TEST_ASSERT(hermit.hv_leaves_human_corpse, "A hermit was not marked as leaving a human corpse") - var/list/before = range(7, spot) - hermit.apply_damage(hermit.maxHealth * 0.4, BRUTE, BODY_ZONE_L_LEG, wound_bonus = CANT_WOUND) - hermit.apply_damage(hermit.maxHealth, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND) - TEST_ASSERT(QDELETED(hermit), "The hermit did not die") - var/mob/living/carbon/human/body = hv_new_corpse(spot, before) - TEST_ASSERT_NOTNULL(body, "The hermit left no human corpse") - var/obj/item/bodypart/leg = body.get_bodypart(BODY_ZONE_L_LEG) - TEST_ASSERT(leg.brute_dam > 10, "The hermit's corpse lost the beating its leg took") diff --git a/voidcrew/modules/unit_tests/voidcrew_loot.dm b/voidcrew/modules/unit_tests/voidcrew_loot.dm index 068de6eac78..7b50fde4674 100644 --- a/voidcrew/modules/unit_tests/voidcrew_loot.dm +++ b/voidcrew/modules/unit_tests/voidcrew_loot.dm @@ -504,11 +504,12 @@ TEST_ASSERT_EQUAL(spawned, 1, "Contraband did not pay exactly one green melee weapon") for(var/marker_path in list(/obj/effect/zone_mobs/syndicate, /obj/effect/zone_mobs/syndicate/boss)) var/obj/effect/zone_mobs/marker = new marker_path() + // Human NPC operatives (voidcrew/modules/human_npcs/ruins/); their tables also hold SMGs and shotguns back to the red zone. TEST_ASSERT_EQUAL(length(marker.mobs_green), 1, "Green syndicate pool still includes sword guards") - TEST_ASSERT(marker.mobs_green[/mob/living/basic/trooper/syndicate/melee], "Green syndicate guards lost their knives") + TEST_ASSERT(marker.mobs_green[/mob/living/carbon/human/npc/ruin/trooper/syndicate], "Green syndicate guards lost their knives") for(var/entry in marker.mobs_yellow) - TEST_ASSERT(!ispath(entry, /mob/living/basic/trooper/syndicate/ranged/smg) && !ispath(entry, /mob/living/basic/trooper/syndicate/ranged/shotgun), "Yellow syndicate guard has an advanced ranged weapon") - TEST_ASSERT(marker.mobs_red[/mob/living/basic/trooper/syndicate/ranged/smg] || marker.mobs_red[/mob/living/basic/trooper/syndicate/ranged/smg/space/stormtrooper], "Red syndicate pool lost its SMG guards") + TEST_ASSERT(!ispath(entry, /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/smg) && !ispath(entry, /mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/shotgun), "Yellow syndicate guard has an advanced ranged weapon") + TEST_ASSERT(marker.mobs_red[/mob/living/carbon/human/npc/ruin/trooper/syndicate/gunner/smg] || marker.mobs_red[/mob/living/carbon/human/npc/ruin/trooper/syndicate/stormtrooper], "Red syndicate pool lost its SMG guards") qdel(marker) /** diff --git a/voidcrew/modules/unit_tests/voidcrew_mission_console_rewards.dm b/voidcrew/modules/unit_tests/voidcrew_mission_console_rewards.dm index f0586b41d09..40e7b652fb3 100644 --- a/voidcrew/modules/unit_tests/voidcrew_mission_console_rewards.dm +++ b/voidcrew/modules/unit_tests/voidcrew_mission_console_rewards.dm @@ -424,16 +424,17 @@ /datum/unit_test/voidcrew_m2_rewards/chips/Run() var/datum/mission/recovery/kill/kill_type = allocate(/datum/mission/recovery/kill/m2_rewards_test) TEST_ASSERT_EQUAL(kill_type.guarded_prize_family, "kill", "Kill fixture lost its production prize family") + // The targets are human NPC faction pirates (voidcrew/modules/human_npcs/ruins/); their chip is npc_death_cash. var/easy_cash = 0 - for(var/mob/living/basic/trooper/pirate/faction/pirate_type as anything in kill_type.easy_targets) - easy_cash += initial(pirate_type.plunder_credits) + for(var/mob/living/carbon/human/npc/ruin/faction_pirate/pirate_type as anything in kill_type.easy_targets) + easy_cash += initial(pirate_type.npc_death_cash) TEST_ASSERT_EQUAL(easy_cash / length(kill_type.easy_targets), 300, "Wrong green kill-chip expectation") var/medium_cash = 0 - for(var/mob/living/basic/trooper/pirate/faction/pirate_type as anything in kill_type.medium_targets) - medium_cash += initial(pirate_type.plunder_credits) + for(var/mob/living/carbon/human/npc/ruin/faction_pirate/pirate_type as anything in kill_type.medium_targets) + medium_cash += initial(pirate_type.npc_death_cash) TEST_ASSERT_EQUAL(medium_cash / length(kill_type.medium_targets) + 300, 568.75, "Wrong yellow kill-chip expectation") - for(var/mob/living/basic/trooper/pirate/faction/pirate_type as anything in kill_type.hard_targets) - TEST_ASSERT_EQUAL(initial(pirate_type.plunder_credits) + 2 * 25, 3050, "Wrong red kill-chip expectation") + for(var/mob/living/carbon/human/npc/ruin/faction_pirate/pirate_type as anything in kill_type.hard_targets) + TEST_ASSERT_EQUAL(initial(pirate_type.npc_death_cash) + 2 * 25, 3050, "Wrong red kill-chip expectation") var/mob/living/basic/trooper/pirate/faction/medieval/captain/captain_type = /mob/living/basic/trooper/pirate/faction/medieval/captain TEST_ASSERT_EQUAL(initial(captain_type.plunder_credits), 1000, "Medieval captain kept its melee chip value") diff --git a/voidcrew/modules/unit_tests/voidcrew_npc_zone_profiles.dm b/voidcrew/modules/unit_tests/voidcrew_npc_zone_profiles.dm index 05727a42eb3..7f9ef39987d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_npc_zone_profiles.dm +++ b/voidcrew/modules/unit_tests/voidcrew_npc_zone_profiles.dm @@ -780,7 +780,6 @@ /mob/living/basic/mining/wolf/random = /mob/living/basic/mining/wolf/alpha, /mob/living/basic/mining/wolf/wasteland/random = /mob/living/basic/mining/wolf/alpha/wasteland, /mob/living/simple_animal/hostile/asteroid/polarbear/random = /mob/living/simple_animal/hostile/asteroid/polarbear/warrior, - /mob/living/simple_animal/hostile/asteroid/hermit/survivor/random = /mob/living/simple_animal/hostile/asteroid/hermit/ranged, ) for(var/spawn_type in replacement_types) var/found_replacement = FALSE diff --git a/voidcrew/modules/unit_tests/voidcrew_planetary_factions.dm b/voidcrew/modules/unit_tests/voidcrew_planetary_factions.dm index a0a240408a5..e44eb64220b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_planetary_factions.dm +++ b/voidcrew/modules/unit_tests/voidcrew_planetary_factions.dm @@ -288,9 +288,10 @@ distant_planet.enable_planetary_faction() // This represents a mob already initialized from a preloaded ruin before the planet's - // footprint was enabled. The mapped override replaces its normal type factions. - var/mob/living/simple_animal/hostile/asteroid/hermit/survivor/mapped_hermit = allocate( - /mob/living/simple_animal/hostile/asteroid/hermit/survivor, + // footprint was enabled. The mapped override replaces its normal type factions. Hermits are + // human NPCs (voidcrew/modules/human_npcs/ruins/), natives of their planet like the fauna. + var/mob/living/carbon/human/npc/ruin/hermit/mapped_hermit = allocate( + /mob/living/carbon/human/npc/ruin/hermit, run_loc_floor_bottom_left, ) mapped_hermit.faction = list("saloon", "[REF(mapped_hermit)]") diff --git a/voidcrew/modules/unit_tests/voidcrew_simple_mob_ai.dm b/voidcrew/modules/unit_tests/voidcrew_simple_mob_ai.dm index 52d776aa83c..a017e0fc6a9 100644 --- a/voidcrew/modules/unit_tests/voidcrew_simple_mob_ai.dm +++ b/voidcrew/modules/unit_tests/voidcrew_simple_mob_ai.dm @@ -5,13 +5,13 @@ * itself out of that list on its first tick. Upstream deleted the subsystem that walked * the idle list (tg #82469), which silently made AI_IDLE permanent - every mapped hermit * in the wasteland ruins loaded, idled, and then ignored players for the rest of the - * round. SSidlenpcpool + consider_wakeup() (voidcrew/controllers/subsystem/idle_npc_wakeup.dm) + * round. (Hermits are human NPCs now; the polar bear stands in as a mapped hostile.) SSidlenpcpool + consider_wakeup() (voidcrew/controllers/subsystem/idle_npc_wakeup.dm) * restore the wakeup; this guards it against being dropped in an upstream merge. */ /datum/unit_test/voidcrew_simple_mob_wakeup /datum/unit_test/voidcrew_simple_mob_wakeup/Run() - var/mob/living/simple_animal/hostile/asteroid/hermit/survivor/hermit = allocate(/mob/living/simple_animal/hostile/asteroid/hermit/survivor) + var/mob/living/simple_animal/hostile/asteroid/polarbear/hermit = allocate(/mob/living/simple_animal/hostile/asteroid/polarbear) var/turf/hermit_turf = get_turf(hermit) TEST_ASSERT(hermit_turf, "the test hermit was allocated without a turf") @@ -24,7 +24,7 @@ TEST_ASSERT_EQUAL(hermit.AIStatus, AI_IDLE, "an idle hostile woke up with nobody on its z-level") // Now put someone it wants to kill in front of it. Both mobs are allocated onto - // run_loc_floor_bottom_left, so this is well inside the hermit's vision_range of 2. + // run_loc_floor_bottom_left, so this is well inside the bear's vision_range of 2. var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) SSmobs.clients_by_zlevel[hermit_turf.z] += victim hermit.consider_wakeup() diff --git a/voidcrew/objects/structures/spawner.dm b/voidcrew/objects/structures/spawner.dm index 603eb2311f4..52519a306d4 100644 --- a/voidcrew/objects/structures/spawner.dm +++ b/voidcrew/objects/structures/spawner.dm @@ -68,7 +68,10 @@ to_chat(user, "You start searching the [name] for anything useful...") if(do_after(user, 40, target = src)) if(prob(bite_chance)) - user.adjustBruteLoss(15) + // The hand that reached in takes the bite, through whatever glove covers it. + var/obj/item/clothing/gloves/worn_gloves = user.gloves + var/blocked = istype(worn_gloves) ? min(worn_gloves.get_armor_rating(MELEE), 100) : 0 + user.apply_damage(15, BRUTE, user.get_active_hand(), blocked, sharpness = SHARP_POINTY) playsound(user.loc, 'sound/items/weapons/bite.ogg', 50, TRUE, -1) to_chat(user, "OW! Something bit you!") else @@ -94,7 +97,7 @@ desc = "A musty, mold-covered barrel." icon = 'icons/obj/structures.dmi' icon_state = "barrel" - mob_types = list(/mob/living/basic/trooper/pirate/melee/beach, /mob/living/basic/trooper/pirate/ranged/beach) + mob_types = list(/mob/living/carbon/human/npc/ruin/trooper/pirate/beach, /mob/living/carbon/human/npc/ruin/trooper/pirate/beach/gunner) max_mobs = 2 max_integrity = 250 bite_chance = 0 From e76d433fe34c68470d1b8bf3b3c3f95108affc67 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:03:23 -0800 Subject: [PATCH 044/472] Crew NPC pirate ships and their boarders with human NPCs Faction pirate crews, pod boarders and boarding bosses are real human NPCs dressed from their faction tables, running PR #500's crew AI on the human base: real guns in volleys with real reloads, doors pried, shot or hit, soft crit and cuffs handled, captains and bosses on real stimulants, Davy Jones on cursed grog, the Black Knight a hulk. Out of the fight (dead, down or cuffed) ends a wave, a crew and a boss. Spaced crew are no longer killed. The pirate_gun component is gone. The Black Knight and Robust One finish with a real grab and real blows. --- tgstation.dme | 2 + .../human_npcs/human_npc_outfits_pirates.dm | 34 +- .../hyperviolence/finishers/finisher.dm | 102 ++- .../hyperviolence/finishers/finisher_ai.dm | 2 + .../finishers/finisher_effects.dm | 31 +- .../finishers/finisher_stages.dm | 59 +- .../hyperviolence/finishers/finisher_users.dm | 3 + .../modules/missions/objectives/combat.dm | 14 +- voidcrew/modules/npc_ships/code/_defines.dm | 35 + .../modules/npc_ships/code/admin_verbs.dm | 72 +- .../code/behaviors/ship_combat_behaviors.dm | 18 +- .../modules/npc_ships/code/boarding_pod.dm | 89 ++- .../faction_pirates/faction_pirate_corpses.dm | 4 +- .../faction_pirates/faction_pirate_humans.dm | 671 ++++++++++++++++++ .../faction_pirates/faction_pirate_mobs.dm | 64 +- .../faction_pirates/faction_pirate_ships.dm | 71 +- voidcrew/modules/npc_ships/code/npc_ship.dm | 148 ++-- .../npc_ships/code/npc_ship_controller.dm | 152 ++-- .../modules/npc_ships/code/pirate_crew.dm | 115 ++- .../modules/npc_ships/code/pirate_crew_ai.dm | 64 +- .../npc_ships/code/pirate_crew_human.dm | 474 +++++++++++++ .../modules/npc_ships/code/pirate_guns.dm | 419 +++-------- .../npc_ships/code/spawner_subsystem.dm | 10 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_pirate_crew_ai.dm | 140 ++-- .../unit_tests/voidcrew_pirate_crew_human.dm | 413 +++++++++++ 26 files changed, 2456 insertions(+), 751 deletions(-) create mode 100644 voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm create mode 100644 voidcrew/modules/npc_ships/code/pirate_crew_human.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_pirate_crew_human.dm diff --git a/tgstation.dme b/tgstation.dme index e3dcc2950d3..0d5805564e2 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7433,6 +7433,7 @@ #include "voidcrew\modules\npc_ships\code\overmap_pathfinding.dm" #include "voidcrew\modules\npc_ships\code\pirate_crew.dm" #include "voidcrew\modules\npc_ships\code\pirate_crew_ai.dm" +#include "voidcrew\modules\npc_ships\code\pirate_crew_human.dm" #include "voidcrew\modules\npc_ships\code\pirate_guns.dm" #include "voidcrew\modules\npc_ships\code\ship_key.dm" #include "voidcrew\modules\npc_ships\code\spawner_subsystem.dm" @@ -7444,6 +7445,7 @@ #include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_bosses.dm" #include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_corpses.dm" #include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_equipment.dm" +#include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_humans.dm" #include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_mobs.dm" #include "voidcrew\modules\npc_ships\code\faction_pirates\faction_pirate_ships.dm" #include "voidcrew\modules\npc_ships\code\faction_pirates\nt_patrol.dm" diff --git a/voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm b/voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm index c2bec158d1e..199f63e5af5 100644 --- a/voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm +++ b/voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm @@ -7,13 +7,16 @@ * magazines, shells or backup knife it fights on with (human_npc_ai.dm reloads from them the way a player * does). Energy guns can't be reloaded by hand, so energy gunners carry a knife. * - * Guns per faction follow each faction's crew_gun_weights (faction_pirate_mobs.dm); melee weapons, - * captains and bosses hold what the basic mobs hold today. Bosses are a first pass for phase B. + * Guns per faction follow PR #500's crew gun weights; melee weapons, captains and bosses hold what the + * basic mobs hold today, the bosses' with their own attack verbs (faction_pirate_humans.dm). The melee + * crew carry a crowbar for unpowered doors. The ship crews that wear these are in + * voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm. * * Tables (one per faction): /datum/human_npc_outfit_table/pirate/ dresses a mixed crew (melee * and gunners, the faction's captain for elites and its boss for bosses); - * /datum/human_npc_outfit_table/pirate//gunners only gunners. A fixed melee crewman can use the - * melee outfit directly. In vacuum each faction gets its own space suit (vacuum_layer). + * /datum/human_npc_outfit_table/pirate//gunners only gunners. The ship crews add a boarding + * table per faction (sealed for vacuum) and pick by their crew_role (faction_pirate_humans.dm). In vacuum + * each faction gets its own space suit (vacuum_layer). */ /datum/human_npc_outfit_table/pirate @@ -59,6 +62,7 @@ /datum/outfit/piratecorpse/faction/silverscale/melee/armed name = "Human NPC: Silverscale Duelist" r_hand = /obj/item/melee/energy/sword/pirate + l_pocket = /obj/item/crowbar /datum/outfit/piratecorpse/faction/silverscale/ranged/energy_gun name = "Human NPC: Silverscale Marksman (energy gun)" @@ -88,7 +92,7 @@ /datum/outfit/piratecorpse/faction/silverscale/highlord/armed name = "Human NPC: Silverscale Highlord" - r_hand = /obj/item/melee/energy/sword/pirate + r_hand = /obj/item/melee/energy/sword/pirate/boss // ===== SKELETON ===== @@ -114,6 +118,7 @@ /datum/outfit/piratecorpse/faction/skeleton/melee/armed name = "Human NPC: Skeleton Swashbuckler" r_hand = /obj/item/melee/energy/sword/pirate + l_pocket = /obj/item/crowbar /datum/outfit/piratecorpse/faction/skeleton/ranged/laser_rifle name = "Human NPC: Skeleton Gunner (laser rifle)" @@ -142,9 +147,12 @@ name = "Human NPC: Skeleton Captain" r_hand = /obj/item/claymore/cutlass +// His flask of cursed grog (voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm) where +// the corpse kept a doubloon. /datum/outfit/piratecorpse/faction/skeleton/davyjones/armed name = "Human NPC: Davy Jones" - r_hand = /obj/item/claymore/cutlass + r_hand = /obj/item/claymore/cutlass/davy_jones + l_pocket = /obj/item/reagent_containers/cup/glass/flask/davy_jones // ===== GREY TIDE ===== @@ -166,6 +174,7 @@ /datum/outfit/piratecorpse/faction/grey/melee/armed name = "Human NPC: Grey Tider" r_hand = /obj/item/storage/toolbox/mechanical + l_pocket = /obj/item/crowbar /datum/outfit/piratecorpse/faction/grey/ranged/energy_gun name = "Human NPC: Grey Tider Gunner (energy gun)" @@ -189,7 +198,7 @@ /datum/outfit/piratecorpse/faction/grey/robust/armed name = "Human NPC: The Robust One" - r_hand = /obj/item/storage/toolbox/mechanical/old/clean + r_hand = /obj/item/storage/toolbox/mechanical/old/clean/robust_one // ===== LUSTROUS ===== @@ -209,6 +218,7 @@ /datum/outfit/piratecorpse/faction/lustrous/melee/armed name = "Human NPC: Lustrous Scintillant" r_hand = /obj/item/switchblade + l_pocket = /obj/item/crowbar /datum/outfit/piratecorpse/faction/lustrous/ranged/energy_gun name = "Human NPC: Lustrous Coruscant (energy gun)" @@ -232,7 +242,7 @@ /datum/outfit/piratecorpse/faction/lustrous/radiant/armed name = "Human NPC: The Radiant One" - r_hand = /obj/item/melee/energy/sword/pirate + r_hand = /obj/item/melee/energy/sword/pirate/boss/radiant // ===== INTERDYNE ===== @@ -258,6 +268,7 @@ /datum/outfit/piratecorpse/faction/interdyne/melee/armed name = "Human NPC: Interdyne Enforcer" r_hand = /obj/item/scalpel + l_pocket = /obj/item/crowbar /datum/outfit/piratecorpse/faction/interdyne/ranged/energy_gun name = "Human NPC: Interdyne Pharmacist (energy gun)" @@ -319,6 +330,7 @@ /datum/outfit/piratecorpse/faction/irs/melee/armed name = "Human NPC: IRS Enforcer" r_hand = /obj/item/melee/baton/telescopic + l_pocket = /obj/item/crowbar /datum/outfit/piratecorpse/faction/irs/ranged/energy_gun name = "Human NPC: IRS Agent (energy gun)" @@ -352,6 +364,7 @@ name = "Human NPC: IRS Chief Auditor" r_hand = /obj/item/gun/energy/e_gun/lethal l_pocket = /obj/item/knife/combat/survival + r_pocket = /obj/item/reagent_containers/hypospray/medipen/stimulants // ===== MEDIEVAL ===== @@ -369,6 +382,7 @@ /datum/outfit/piratecorpse/faction/medieval/melee/armed name = "Human NPC: Medieval Footsoldier" r_hand = /obj/item/claymore/shortsword + l_pocket = /obj/item/crowbar /datum/outfit/piratecorpse/faction/medieval/ranged/crossbow name = "Human NPC: Medieval Crossbowman" @@ -377,8 +391,8 @@ /datum/outfit/piratecorpse/faction/medieval/captain/armed name = "Human NPC: Medieval Warlord" - r_hand = /obj/item/fireaxe/boardingaxe + r_hand = /obj/item/fireaxe/boardingaxe/warlord /datum/outfit/piratecorpse/faction/medieval/blackknight/armed name = "Human NPC: The Black Knight" - r_hand = /obj/item/claymore + r_hand = /obj/item/claymore/black_knight diff --git a/voidcrew/modules/hyperviolence/finishers/finisher.dm b/voidcrew/modules/hyperviolence/finishers/finisher.dm index 39ffd02112b..871fa2dc825 100644 --- a/voidcrew/modules/hyperviolence/finishers/finisher.dm +++ b/voidcrew/modules/hyperviolence/finishers/finisher.dm @@ -14,6 +14,11 @@ * - Damage on the attacker breaks its hold ([var/interrupt_damage]), and it reels for a moment. * While it works it is rooted and takes [var/vulnerability] times the damage, so the victim's * crew can save them. + * - A person doing it (a human NPC boss, [var/real_hits]) does it with their body, as a player would + * (human NPC migration, defaults HV-4 and HV-5): the pin is a real aggressive grab, which the victim + * can resist out of; every beat is a real blow with the weapon in hand (tg damage, armour, wounds and + * dismemberment decide what it does); the killing blow is the same with a higher wound bonus. It isn't + * rooted, takes no extra damage, and doesn't reel: a hit that makes it let go is just that. * - Both mobs carry a status effect that expires on its own (finisher_effects.dm), and the * finisher stops itself after its longest possible length, so nothing holds a victim forever. * - The kill never destroys the brain: a torn-off head lands at the victim's feet with the brain @@ -80,12 +85,17 @@ var/result /// Called once when it ends: (datum/hv_finisher/finisher, result) var/datum/callback/on_end + /// Set when the attacker is a carbon: real grab, real blows (see the top of this file) + var/real_hits = FALSE + /// Wound bonus added to the killing blow when it is a real one + var/kill_wound_bonus = 30 /datum/hv_finisher/New(mob/living/user, mob/living/carbon/victim, datum/callback/on_end) . = ..() src.user = user src.victim = victim src.on_end = on_end + real_hits = iscarbon(user) var/list/stage_types = stages stages = list() for(var/stage_type in stage_types) @@ -116,6 +126,8 @@ /datum/hv_finisher/proc/can_start() if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED)) return FALSE + if(real_hits && (HAS_TRAIT(user, TRAIT_HANDS_BLOCKED) || isnull(hv_finisher_weapon(user)))) + return FALSE if(!hv_finisher_valid_victim(user, victim)) return FALSE return get_dist(user, victim) <= 1 && user.Adjacent(victim) @@ -132,8 +144,8 @@ start_health = user.health pick_zone() var/longest = longest_duration() - user.apply_status_effect(/datum/status_effect/hv_finishing, longest, vulnerability) - victim.apply_status_effect(/datum/status_effect/hv_finisher_hold, longest) + user.apply_status_effect(/datum/status_effect/hv_finishing, longest, real_hits ? 1 : vulnerability, !real_hits) + victim.apply_status_effect(/datum/status_effect/hv_finisher_hold, longest, real_hits) RegisterSignals(user, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING), PROC_REF(on_user_down)) RegisterSignal(user, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_user_damaged)) RegisterSignal(victim, COMSIG_LIVING_DEATH, PROC_REF(on_victim_died)) @@ -206,7 +218,9 @@ current_stage = stages[stage_index] stage_started_at = world.time if(current_stage.pins) - take_grip() + if(!take_grip()) + end_early(HV_FINISHER_LOST_GRIP) + return else release_grip() current_stage.enter() @@ -223,21 +237,25 @@ log_combat(user, victim, "finished", addition = "([name])") current_stage.effect() -/// Holds the victim down from here on +/// Holds the victim down from here on. A person grabs them for real. Returns FALSE if it couldn't. /datum/hv_finisher/proc/take_grip() if(gripped) - return + return TRUE + if(real_hits && !hv_finisher_grab(user, victim)) + return FALSE gripped = TRUE ever_gripped = TRUE QDEL_NULL(mark) var/datum/status_effect/hv_finisher_hold/hold = victim.has_status_effect(/datum/status_effect/hv_finisher_hold) hold?.set_gripped(TRUE) + return TRUE /// Lets the victim go, for any stages after the kill /datum/hv_finisher/proc/release_grip() if(!gripped) return gripped = FALSE + hv_finisher_let_go(user, victim) var/datum/status_effect/hv_finisher_hold/hold = victim.has_status_effect(/datum/status_effect/hv_finisher_hold) hold?.set_gripped(FALSE) @@ -254,6 +272,12 @@ // An admin heal, or anything else that took the hold off, frees them if(!victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) return HV_FINISHER_LOST_GRIP + if(real_hits && !completed) + // Cuffed, or the weapon gone: it can't go on. The victim broke the grab: it lost its grip. + if(HAS_TRAIT(user, TRAIT_HANDS_BLOCKED) || isnull(hv_finisher_weapon(user))) + return HV_FINISHER_ATTACKER_DOWN + if(gripped && (user.pulling != victim || user.grab_state < GRAB_AGGRESSIVE)) + return HV_FINISHER_LOST_GRIP return 0 /// Whether the attacker has lost [var/interrupt_damage] health since it began @@ -279,8 +303,10 @@ switch(reason) if(HV_FINISHER_DAMAGED) user.visible_message(span_danger("[user] staggers back and lets go of [victim]!")) - // Before stop(), so the AI stays held through the hand-over - user.apply_status_effect(/datum/status_effect/hv_finisher_reeling, stagger_time) + // Before stop(), so the AI stays held through the hand-over. A person doesn't reel: the + // hit that made it let go stuns it or doesn't, by tg's rules (HV-5). + if(!real_hits) + user.apply_status_effect(/datum/status_effect/hv_finisher_reeling, stagger_time) if(HV_FINISHER_LOST_GRIP) if(gripped && !QDELETED(user) && !QDELETED(victim)) user.visible_message(span_danger("[user] loses [user.p_their()] grip on [victim].")) @@ -296,6 +322,8 @@ deltimer(tick_timer) tick_timer = null QDEL_NULL(mark) + if(gripped && real_hits) + hv_finisher_let_go(user, victim) if(user) UnregisterSignal(user, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_MOVABLE_MOVED)) if(!QDELETED(user)) @@ -324,6 +352,37 @@ var/dy = (toward & NORTH) ? 1 : ((toward & SOUTH) ? -1 : 0) user.add_offsets(HV_FINISHER_OFFSET, null, dx * lunge, dy * lunge + rise) +/** + * A real blow from the attacker at `zone` with the melee weapon in its hand, through the attack chain a + * player's click runs (a person attacker only). `extra_wound_bonus` is added to the weapon's for this + * one blow, the way an execution boosts its one round. Returns TRUE if it swung. + */ +/datum/hv_finisher/proc/real_blow(zone, extra_wound_bonus = 0) + if(QDELETED(user) || QDELETED(victim) || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED)) + return FALSE + var/obj/item/weapon = hv_finisher_weapon(user) + if(isnull(weapon)) + return FALSE + var/mob/living/carbon/attacker = user + if(attacker.get_active_held_item() != weapon) + var/index = attacker.get_held_index_of_item(weapon) + if(!index) + return FALSE + attacker.swap_hand(index, silent = TRUE) + user.zone_selected = zone + user.face_atom(victim) + var/old_combat_mode = user.combat_mode + user.set_combat_mode(TRUE) + var/old_wound_bonus = weapon.wound_bonus + weapon.wound_bonus += extra_wound_bonus + weapon.melee_attack_chain(user, victim) + if(!QDELETED(weapon)) + weapon.wound_bonus = old_wound_bonus + if(!QDELETED(user)) + user.set_combat_mode(old_combat_mode) + user.changeNext_move(CLICK_CD_MELEE) + return TRUE + /// Puts the wind-up's mark on the victim, following them until it is taken off /datum/hv_finisher/proc/place_mark(duration) QDEL_NULL(mark) @@ -377,6 +436,35 @@ return FALSE return TRUE +/// The melee weapon a person attacker swings in a finisher: the heaviest one in its hands, or null +/proc/hv_finisher_weapon(mob/living/user) + var/obj/item/best + for(var/obj/item/held in user.held_items) + if(isgun(held) || (held.item_flags & ABSTRACT) || held.force <= 0) + continue + if(isnull(best) || held.force > best.force) + best = held + return best + +/** + * A person attacker's pin: a real aggressive grab on `victim`, the way a player gets one (a pull, then + * the grip tightened). Fails if the victim blocks it or can't be grabbed hard (a hulk). Returns TRUE if + * it holds them. + */ +/proc/hv_finisher_grab(mob/living/user, mob/living/carbon/victim) + if(user.pulling != victim) + victim.grabbedby(user, supress_message = TRUE) + if(user.pulling != victim) + return FALSE + if(user.grab_state < GRAB_AGGRESSIVE) + victim.grabbedby(user) + return user.pulling == victim && user.grab_state >= GRAB_AGGRESSIVE + +/// A person attacker lets go of the victim it grabbed. +/proc/hv_finisher_let_go(mob/living/user, mob/living/victim) + if(!QDELETED(user) && user.pulling == victim) + user.stop_pulling() + /// Whether `victim` is down: in crit, on the floor, or stunned /proc/hv_finisher_is_down(mob/living/victim) return victim.stat >= SOFT_CRIT || victim.body_position == LYING_DOWN || HAS_TRAIT(victim, TRAIT_INCAPACITATED) diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_ai.dm b/voidcrew/modules/hyperviolence/finishers/finisher_ai.dm index 917ed94e83e..a02648f4ab3 100644 --- a/voidcrew/modules/hyperviolence/finishers/finisher_ai.dm +++ b/voidcrew/modules/hyperviolence/finishers/finisher_ai.dm @@ -67,6 +67,8 @@ /proc/hv_finisher_add_subtree(datum/ai_controller/controller) var/static/list/goes_first = typecacheof(list( /datum/ai_planning_subtree/call_reinforcements, + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/pirate_crew_find_target, /datum/ai_planning_subtree/escape_captivity, /datum/ai_planning_subtree/pet_planning, /datum/ai_planning_subtree/simple_find_target, diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_effects.dm b/voidcrew/modules/hyperviolence/finishers/finisher_effects.dm index 9d2e59f91d9..9e3d4ef2f0b 100644 --- a/voidcrew/modules/hyperviolence/finishers/finisher_effects.dm +++ b/voidcrew/modules/hyperviolence/finishers/finisher_effects.dm @@ -6,26 +6,35 @@ * or forgotten finisher can never leave a victim held or a boss frozen. */ -/// On the mob doing a finisher: rooted, its AI held, and it takes extra damage +/** + * On the mob doing a finisher: its AI held while it works. A beast is also rooted and takes extra + * damage; a person (a human NPC boss) is neither: it simply stands there busy, as a player would (HV-5). + */ /datum/status_effect/hv_finishing id = "hv_finishing" alert_type = null duration = 15 SECONDS tick_interval = STATUS_EFFECT_NO_TICK status_type = STATUS_EFFECT_UNIQUE - /// Multiplier on damage it takes + /// Multiplier on damage it takes. 1 adds none. var/vulnerability = 1.5 + /// Rooted where it stands + var/rooted = TRUE -/datum/status_effect/hv_finishing/on_creation(mob/living/new_owner, duration_override, vulnerability) +/datum/status_effect/hv_finishing/on_creation(mob/living/new_owner, duration_override, vulnerability, rooted = TRUE) if(isnum(duration_override)) duration = duration_override if(isnum(vulnerability)) src.vulnerability = vulnerability + src.rooted = rooted return ..() /datum/status_effect/hv_finishing/on_apply() - owner.add_traits(list(TRAIT_AI_PAUSED, TRAIT_IMMOBILIZED), TRAIT_STATUS_EFFECT(id)) - RegisterSignal(owner, COMSIG_MOB_APPLY_DAMAGE_MODIFIERS, PROC_REF(on_damage_modifiers)) + ADD_TRAIT(owner, TRAIT_AI_PAUSED, TRAIT_STATUS_EFFECT(id)) + if(rooted) + ADD_TRAIT(owner, TRAIT_IMMOBILIZED, TRAIT_STATUS_EFFECT(id)) + if(vulnerability != 1) + RegisterSignal(owner, COMSIG_MOB_APPLY_DAMAGE_MODIFIERS, PROC_REF(on_damage_modifiers)) return TRUE /datum/status_effect/hv_finishing/on_remove() @@ -36,7 +45,10 @@ SIGNAL_HANDLER damage_mods += vulnerability -/// On the victim of a finisher: marked so nothing else starts one on them, and held down once gripped +/** + * On the victim of a finisher: marked so nothing else starts one on them, and held down once gripped. + * When a person does the finisher the hold is their real grab, so this only marks the victim. + */ /datum/status_effect/hv_finisher_hold id = "hv_finisher_hold" alert_type = null @@ -45,10 +57,13 @@ status_type = STATUS_EFFECT_UNIQUE /// TRUE while held down var/gripped = FALSE + /// The attacker holds them with a real grab: no traits of its own + var/real_grip = FALSE -/datum/status_effect/hv_finisher_hold/on_creation(mob/living/new_owner, duration_override) +/datum/status_effect/hv_finisher_hold/on_creation(mob/living/new_owner, duration_override, real_grip = FALSE) if(isnum(duration_override)) duration = duration_override + src.real_grip = real_grip return ..() /datum/status_effect/hv_finisher_hold/on_remove() @@ -59,6 +74,8 @@ if(gripped == grip) return gripped = grip + if(real_grip) + return if(gripped) owner.add_traits(list(TRAIT_IMMOBILIZED, TRAIT_FLOORED), TRAIT_STATUS_EFFECT(id)) else diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm b/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm index 029278ecf16..efdb7b2f0e8 100644 --- a/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm +++ b/voidcrew/modules/hyperviolence/finishers/finisher_stages.dm @@ -9,8 +9,10 @@ * words, sounds and timing: * - windup: rears up and marks the victim. Holds nobody, so the victim can still be pulled clear. * - hold: the pin. From here the victim is held down. - * - maul: a bite or a blow, with blood. Deals no damage, so the victim lives to the end. - * - kill/rip_limb, kill/decapitate, kill/burst_head, kill/bisect: the killing blow. + * - maul: a bite or a blow, with blood. Deals no damage, so the victim lives to the end. When a person + * does it (host.real_hits) it is a real blow with the weapon in hand instead, at [var/blow_zone]. + * - kill/rip_limb, kill/decapitate, kill/burst_head, kill/bisect: the killing blow. A person's is a real + * blow with a higher wound bonus (real_kill()); tg's wounds and dismemberment decide what it takes. * - recover: the attacker straightens up after the kill. Lets the victim go. */ @@ -148,16 +150,22 @@ // ===== Maul ===== -/// A bite or a blow: blood and a shake, but no damage, so the victim lives until the kill +/// A bite or a blow: blood and a shake, but no damage, so the victim lives until the kill. A person's is a +/// real blow with its weapon at blow_zone. /datum/hv_finisher_stage/maul duration = 2 SECONDS effect_delay = 0.5 SECONDS lunge = 8 /// How far the blood flies var/spray_strength = 2 + /// Where a person's blow lands + var/blow_zone = BODY_ZONE_HEAD /datum/hv_finisher_stage/maul/effect() . = ..() + if(host.real_hits) + host.real_blow(blow_zone) + return host.victim.hv_blood_spray(away_dir(), spray_strength) host.victim.Shake(2, 1, 0.6 SECONDS) @@ -181,6 +189,9 @@ if(effect_sound) playsound(host.victim, effect_sound, effect_volume, TRUE) var/spray_dir = away_dir() + if(host.real_hits) + real_kill(spray_dir) + return if(!can_gore()) fallback(spray_dir) return @@ -188,6 +199,37 @@ show_effect_line() gore(spray_dir) +/// Where a person's killing blow lands +/datum/hv_finisher_stage/kill/proc/real_zones() + return list(host.target_zone || BODY_ZONE_CHEST) + +/** + * A person's killing blow: a real blow at each of real_zones() with the finisher's extra wound bonus. What + * it takes off, if anything, is tg's to decide. The line said is what happened: the gore line if the part + * came off, else the fallback. + */ +/datum/hv_finisher_stage/kill/proc/real_kill(spray_dir) + var/list/before = real_parts() + for(var/zone in real_zones()) + host.real_blow(zone, host.kill_wound_bonus) + if(QDELETED(host.victim)) + return + var/list/after = real_parts() + if(length(after) < length(before)) + show_effect_line() + return + var/text = fallback_message() + if(text) + host.victim.hv_add_hit_line(text, HV_LINE_BOLD, null, effect_range) + +/// The parts a person's killing blow goes for that the victim still has +/datum/hv_finisher_stage/kill/proc/real_parts() + . = list() + for(var/zone in real_zones()) + var/obj/item/bodypart/part = host.victim.get_bodypart(zone) + if(part) + . += part + /// Whether the victim still has what gore() takes /datum/hv_finisher_stage/kill/proc/can_gore() return FALSE @@ -246,6 +288,14 @@ if(!host.victim.hv_burst_head(spray_dir)) fallback(spray_dir) +/datum/hv_finisher_stage/kill/burst_head/real_zones() + return list(BODY_ZONE_HEAD) + +// The blow is the line here, whatever it does: a head that bursts says so itself (brain_burst.dm). +/datum/hv_finisher_stage/kill/burst_head/real_kill(spray_dir) + show_effect_line() + host.real_blow(BODY_ZONE_HEAD, host.kill_wound_bonus) + /// Cuts through at the hips: both legs come off /datum/hv_finisher_stage/kill/bisect @@ -256,6 +306,9 @@ return TRUE return FALSE +/datum/hv_finisher_stage/kill/bisect/real_zones() + return list(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + /datum/hv_finisher_stage/kill/bisect/gore(spray_dir) var/turf/spot = get_turf(host.victim) host.victim.hv_blood_spray(spray_dir, spray_strength) diff --git a/voidcrew/modules/hyperviolence/finishers/finisher_users.dm b/voidcrew/modules/hyperviolence/finishers/finisher_users.dm index aeb855eb94a..3c6501123d2 100644 --- a/voidcrew/modules/hyperviolence/finishers/finisher_users.dm +++ b/voidcrew/modules/hyperviolence/finishers/finisher_users.dm @@ -10,6 +10,9 @@ * * Each boss gets its finisher from a second Initialize() on its type, here, so the files that own * those bosses stay untouched. DM chains duplicate overrides: both bodies run, and both call ..(). + * The NPC ship bosses are human NPCs now (voidcrew/modules/npc_ships/code/faction_pirates/ + * faction_pirate_humans.dm, which adds the component to the human Black Knight and Robust One): theirs + * is a real grab and real blows (finisher.dm). The basic-mob bosses below are the ruin and planet ones. */ // ===== The hoarfrost matriarch ===== diff --git a/voidcrew/modules/missions/objectives/combat.dm b/voidcrew/modules/missions/objectives/combat.dm index a9f08365fff..e9893cc1b83 100644 --- a/voidcrew/modules/missions/objectives/combat.dm +++ b/voidcrew/modules/missions/objectives/combat.dm @@ -239,8 +239,11 @@ * turn-in item - the mission auto-completes like a survey. */ /datum/mission_objective/kill_count - /// Base typepath of mobs that count - var/mob_base_type = /mob/living/basic/trooper/pirate/faction + /// Base typepaths of mobs that count: the basic faction pirates of ruins and planets, and the human + /// pirate crews of NPC ships (voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm) + var/list/mob_base_types = list(/mob/living/basic/trooper/pirate/faction, /mob/living/carbon/human/npc/ship_crew/pirate) + /// Weakrefs to the bodies already counted: a pirate revived and killed again counts once + var/list/datum/weakref/counted_kills = list() /// Minimum zone the kill must happen in (design guard: no green farming) var/minimum_zone = ZONE_YELLOW /// Kills required / confirmed @@ -252,6 +255,7 @@ /datum/mission_objective/kill_count/reset() . = ..() kill_count = 0 + counted_kills.Cut() /datum/mission_objective/kill_count/activate() . = ..() @@ -263,7 +267,10 @@ /datum/mission_objective/kill_count/proc/on_any_death(datum/source, mob/living/victim, gibbed) SIGNAL_HANDLER - if(completed || !istype(victim, mob_base_type)) + if(completed || !is_type_in_list(victim, mob_base_types)) + return + var/datum/weakref/victim_ref = WEAKREF(victim) + if(victim_ref in counted_kills) return var/turf/where = get_turf(victim) if(!where) @@ -273,6 +280,7 @@ return if(!kill_attributable_to_crew(victim, where)) return + counted_kills += victim_ref kill_count++ if(kill_count >= required_kills) notify_crew("Suppression sweep complete.") diff --git a/voidcrew/modules/npc_ships/code/_defines.dm b/voidcrew/modules/npc_ships/code/_defines.dm index 42a56952e19..9e5eafe0b60 100644 --- a/voidcrew/modules/npc_ships/code/_defines.dm +++ b/voidcrew/modules/npc_ships/code/_defines.dm @@ -390,3 +390,38 @@ GLOBAL_LIST_EMPTY(door_to_rooms) // ship_ref -> list(door_ref -> list(room_id_ #define PIRATE_CREW_CROSS_BARK_CREW (30 SECONDS) /// How often a pirate with no target looks around for one #define PIRATE_CREW_SCAN_COOLDOWN (1 SECONDS) + +// ========== HUMAN SHIP CREWS (faction_pirate_humans.dm, pirate_crew_human.dm) ========== +// NPC ship crews and their boarders are real human NPCs (voidcrew/modules/human_npcs/). Unit tests +// can't see these; they use the literals. + +/// A crewman who fights hand to hand (ship_crew/var/crew_role); its faction table dresses it from the melee outfits +#define SHIP_CREW_ROLE_MELEE "melee" +/// A crewman who fights with a gun; its faction table dresses it from the gunner outfits +#define SHIP_CREW_ROLE_GUNNER "gunner" + +/// Pulls of the trigger left in the volley a human gunner is firing +#define BB_PIRATE_VOLLEY_LEFT "pirate_volley_left" +/// world.time a gunner's spin-up or aim ends; a hit before then spoils it +#define BB_PIRATE_WINDUP_UNTIL "pirate_windup_until" +/// world.time the door being broken last lost integrity, so a breach that gets somewhere isn't given up on +#define BB_PIRATE_BREACH_PROGRESS_AT "pirate_breach_progress_at" +/// The door's integrity when it was last looked at +#define BB_PIRATE_BREACH_INTEGRITY "pirate_breach_integrity" +/// The item an elite or boss is using on itself (its stimulant, a flask) +#define BB_PIRATE_SELF_ITEM "pirate_self_item" +/// world.time a boss may next drink from its flask +#define BB_PIRATE_NEXT_DRINK "pirate_next_drink" +/// world.time a boss gunner may next put its gun to a downed hunter's head +#define BB_PIRATE_NEXT_EXECUTION "pirate_next_execution" + +/// A crew member adrift in space, out of sight of every player, this long is cleaned up (pirate_crew.dm) +#define PIRATE_CREW_ADRIFT_TIME (2 MINUTES) +/// A boss gunner waits this long between executions +#define PIRATE_CREW_EXECUTION_COOLDOWN (60 SECONDS) +/// A boss with a flask drinks from it no more often than this, and only once it is this hurt +#define PIRATE_CREW_DRINK_COOLDOWN (20 SECONDS) +#define PIRATE_CREW_DRINK_HEALTH_SHARE 0.7 +/// Elites' and bosses' toughness by rank (npc_damage_taken). Starting values for the balance pass. +#define SHIP_CREW_ELITE_DAMAGE_TAKEN 0.8 +#define SHIP_CREW_BOSS_DAMAGE_TAKEN 0.6 diff --git a/voidcrew/modules/npc_ships/code/admin_verbs.dm b/voidcrew/modules/npc_ships/code/admin_verbs.dm index dd3bd2d8a1b..83dc8ef8ad9 100644 --- a/voidcrew/modules/npc_ships/code/admin_verbs.dm +++ b/voidcrew/modules/npc_ships/code/admin_verbs.dm @@ -2,49 +2,49 @@ * Admin verbs for NPC pirate mob spawning */ -/// Mapping of faction names to their mob types +/// Mapping of faction names to their mob types: the human NPC ship crews (faction_pirate_humans.dm) GLOBAL_LIST_INIT(npc_pirate_factions, list( "Silverscale" = list( - "Melee (Duelist)" = /mob/living/basic/trooper/pirate/faction/silverscale/melee, - "Ranged (Marksman)" = /mob/living/basic/trooper/pirate/faction/silverscale/ranged, - "Captain (Noble)" = /mob/living/basic/trooper/pirate/faction/silverscale/captain, - "Boss (Highlord)" = /mob/living/basic/trooper/pirate/faction/boss/silverscale, + "Melee (Duelist)" = /mob/living/carbon/human/npc/ship_crew/pirate/silverscale/melee, + "Ranged (Marksman)" = /mob/living/carbon/human/npc/ship_crew/pirate/silverscale/ranged, + "Captain (Noble)" = /mob/living/carbon/human/npc/ship_crew/pirate/silverscale/captain, + "Boss (Highlord)" = /mob/living/carbon/human/npc/ship_crew/pirate/silverscale/boss, ), "Skeleton" = list( - "Melee (Swashbuckler)" = /mob/living/basic/trooper/pirate/faction/skeleton/melee, - "Ranged (Gunner)" = /mob/living/basic/trooper/pirate/faction/skeleton/ranged, - "Captain" = /mob/living/basic/trooper/pirate/faction/skeleton/captain, - "Boss (Davy Jones)" = /mob/living/basic/trooper/pirate/faction/boss/skeleton, + "Melee (Swashbuckler)" = /mob/living/carbon/human/npc/ship_crew/pirate/skeleton/melee, + "Ranged (Gunner)" = /mob/living/carbon/human/npc/ship_crew/pirate/skeleton/ranged, + "Captain" = /mob/living/carbon/human/npc/ship_crew/pirate/skeleton/captain, + "Boss (Davy Jones)" = /mob/living/carbon/human/npc/ship_crew/pirate/skeleton/boss, ), "Grey Tide" = list( - "Melee (Grey Tider)" = /mob/living/basic/trooper/pirate/faction/grey/melee, - "Ranged (Gunner)" = /mob/living/basic/trooper/pirate/faction/grey/ranged, - "Captain (Tidemaster)" = /mob/living/basic/trooper/pirate/faction/grey/captain, - "Boss (The Robust One)" = /mob/living/basic/trooper/pirate/faction/boss/grey, + "Melee (Grey Tider)" = /mob/living/carbon/human/npc/ship_crew/pirate/grey/melee, + "Ranged (Gunner)" = /mob/living/carbon/human/npc/ship_crew/pirate/grey/ranged, + "Captain (Tidemaster)" = /mob/living/carbon/human/npc/ship_crew/pirate/grey/captain, + "Boss (The Robust One)" = /mob/living/carbon/human/npc/ship_crew/pirate/grey/boss, ), "Lustrous" = list( - "Melee (Scintillant)" = /mob/living/basic/trooper/pirate/faction/lustrous/melee, - "Ranged (Coruscant)" = /mob/living/basic/trooper/pirate/faction/lustrous/ranged, - "Captain (Radiant)" = /mob/living/basic/trooper/pirate/faction/lustrous/captain, - "Boss (The Radiant One)" = /mob/living/basic/trooper/pirate/faction/boss/lustrous, + "Melee (Scintillant)" = /mob/living/carbon/human/npc/ship_crew/pirate/lustrous/melee, + "Ranged (Coruscant)" = /mob/living/carbon/human/npc/ship_crew/pirate/lustrous/ranged, + "Captain (Radiant)" = /mob/living/carbon/human/npc/ship_crew/pirate/lustrous/captain, + "Boss (The Radiant One)" = /mob/living/carbon/human/npc/ship_crew/pirate/lustrous/boss, ), "Interdyne" = list( - "Melee (Enforcer)" = /mob/living/basic/trooper/pirate/faction/interdyne/melee, - "Ranged (Pharmacist)" = /mob/living/basic/trooper/pirate/faction/interdyne/ranged, - "Captain (Director)" = /mob/living/basic/trooper/pirate/faction/interdyne/captain, - "Boss (Director Prime)" = /mob/living/basic/trooper/pirate/faction/boss/interdyne, + "Melee (Enforcer)" = /mob/living/carbon/human/npc/ship_crew/pirate/interdyne/melee, + "Ranged (Pharmacist)" = /mob/living/carbon/human/npc/ship_crew/pirate/interdyne/ranged, + "Captain (Director)" = /mob/living/carbon/human/npc/ship_crew/pirate/interdyne/captain, + "Boss (Director Prime)" = /mob/living/carbon/human/npc/ship_crew/pirate/interdyne/boss, ), "IRS" = list( - "Melee (Enforcer)" = /mob/living/basic/trooper/pirate/faction/irs/melee, - "Ranged (Agent)" = /mob/living/basic/trooper/pirate/faction/irs/ranged, - "Captain (Head Auditor)" = /mob/living/basic/trooper/pirate/faction/irs/captain, - "Boss (Chief Auditor)" = /mob/living/basic/trooper/pirate/faction/boss/irs, + "Melee (Enforcer)" = /mob/living/carbon/human/npc/ship_crew/pirate/irs/melee, + "Ranged (Agent)" = /mob/living/carbon/human/npc/ship_crew/pirate/irs/ranged, + "Captain (Head Auditor)" = /mob/living/carbon/human/npc/ship_crew/pirate/irs/captain, + "Boss (Chief Auditor)" = /mob/living/carbon/human/npc/ship_crew/pirate/irs/boss, ), "Medieval" = list( - "Melee (Footsoldier)" = /mob/living/basic/trooper/pirate/faction/medieval/melee, - "Ranged (Crossbowman)" = /mob/living/basic/trooper/pirate/faction/medieval/ranged, - "Captain (Warlord)" = /mob/living/basic/trooper/pirate/faction/medieval/captain, - "Boss (The Black Knight)" = /mob/living/basic/trooper/pirate/faction/boss/medieval, + "Melee (Footsoldier)" = /mob/living/carbon/human/npc/ship_crew/pirate/medieval/melee, + "Ranged (Crossbowman)" = /mob/living/carbon/human/npc/ship_crew/pirate/medieval/ranged, + "Captain (Warlord)" = /mob/living/carbon/human/npc/ship_crew/pirate/medieval/captain, + "Boss (The Black Knight)" = /mob/living/carbon/human/npc/ship_crew/pirate/medieval/boss, ), )) @@ -90,9 +90,16 @@ ADMIN_VERB(spawn_npc_pirate, R_SPAWN|R_DEBUG, "Spawn NPC Pirate", "Spawn an NPC // Step 3: Ask whether it boards the ship we're standing on var/setup_raid = tgui_alert(user, "Send it after the crew of the ship you're standing on, as a boarder?", "Boarder AI", list("Yes", "No")) - // Spawn the pirate + // Spawn the pirate: a human NPC is built at once, dressed as a boarder if it is boarding var/datum/npc_spawn_context/context = new(spawn_turf, null, "ordinary") - var/mob/living/basic/trooper/pirate/spawned_pirate = spawn_npc_in_zone(mob_path, spawn_turf, context, FALSE) + var/mob/living/spawned_pirate + if(ispath(mob_path, /mob/living/carbon/human/npc)) + var/datum/human_npc_order/order = spawn_human_npc(mob_path, spawn_turf, outfit = (setup_raid == "Yes" ? boarding_outfit_for(mob_path) : null), context = context, immediate = TRUE) + spawned_pirate = order?.spawned_ref?.resolve() + if(spawned_pirate) + spawned_pirate.flags_1 |= ADMIN_SPAWNED_1 + else + spawned_pirate = spawn_npc_in_zone(mob_path, spawn_turf, context, FALSE) if(!spawned_pirate) to_chat(user, span_warning("Failed to spawn pirate.")) return @@ -118,7 +125,8 @@ ADMIN_VERB(spawn_npc_pirate, R_SPAWN|R_DEBUG, "Spawn NPC Pirate", "Spawn an NPC else to_chat(user, span_notice("Spawned [spawned_pirate.name] ([selected_faction] - [selected_type]) with its own AI.")) - context.finalize() + if(!ispath(mob_path, /mob/living/carbon/human/npc)) + context.finalize() message_admins("[key_name_admin(user)] spawned NPC pirate: [spawned_pirate.name] ([selected_faction] - [selected_type]) at [AREACOORD(spawn_turf)]") log_admin("[key_name(user)] spawned NPC pirate: [spawned_pirate.name] ([selected_faction] - [selected_type]) at [AREACOORD(spawn_turf)]") BLACKBOX_LOG_ADMIN_VERB("Spawn NPC Pirate") diff --git a/voidcrew/modules/npc_ships/code/behaviors/ship_combat_behaviors.dm b/voidcrew/modules/npc_ships/code/behaviors/ship_combat_behaviors.dm index 0b5c1916e1c..fcdb81f058b 100644 --- a/voidcrew/modules/npc_ships/code/behaviors/ship_combat_behaviors.dm +++ b/voidcrew/modules/npc_ships/code/behaviors/ship_combat_behaviors.dm @@ -704,18 +704,20 @@ return AI_BEHAVIOR_DELAY /** - * Count hostile mobs (pirate troopers) currently on a target ship. - * Used to enforce the max boarder cap during ship combat. + * Count the boarders on a target ship still in the fight: the raid's members aboard it (pirate_crew.dm), + * human or basic, whatever their faction. Used to enforce the max boarder cap during ship combat. */ /datum/ai_behavior/npc_ship/fire_boarding_pods/proc/count_hostile_mobs_on_ship(obj/structure/overmap/ship/target) - if(!target?.shuttle?.shuttle_areas) + var/list/shuttle_areas = target?.shuttle?.shuttle_areas + var/datum/pirate_crew/raid = pirate_crew_of(target, TRUE) + if(!length(shuttle_areas) || !raid) return 0 - var/count = 0 - for(var/area/shuttle_area as anything in target.shuttle.shuttle_areas) - for(var/mob/living/basic/trooper/pirate/boarder in shuttle_area) - if(boarder.stat != DEAD) - count++ + for(var/mob/living/boarder as anything in raid.members) + if(human_npc_out_of_fight(boarder)) + continue + if(shuttle_areas[get_area(boarder)]) + count++ return count // ========== USE INTERDICTOR ========== diff --git a/voidcrew/modules/npc_ships/code/boarding_pod.dm b/voidcrew/modules/npc_ships/code/boarding_pod.dm index e54b2177e0e..3aa8ad23f02 100644 --- a/voidcrew/modules/npc_ships/code/boarding_pod.dm +++ b/voidcrew/modules/npc_ships/code/boarding_pod.dm @@ -3,15 +3,24 @@ * * Uses the existing supplypod/landing zone system for the dramatic drop-from-above effect. * Pod lands, opens to reveal the boarder, then vanishes. + * + * Human NPC boarders (the NPC ship crews, faction_pirate_humans.dm) are built through the spawn queue + * (spawn_human_npc(), a body at a time) aboard the pirate's own hull, dressed from their faction's + * boarding table (a sealed suit and air), and only then put in their pod and launched + * (boarding_pod_launch()). A basic-mob boarder is built straight into its pod as before. Either way the + * caller hears about the boarder through `on_boarder`, called with it once it is in its pod. */ /// Tracks spawn index for distributing boarders along patrol path GLOBAL_VAR_INIT(boarding_spawn_index, 0) GLOBAL_VAR_INIT(boarding_spawn_total, 1) -/// Global proc to create a boarding pod drop at a target location -/// Returns the boarder mob so the caller can register signals (e.g. death tracking) -/proc/create_boarding_pod(turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship, mob_type, datum/npc_spawn_context/encounter, budget_reserved = FALSE) +/** + * Launches a boarding pod at `target_turf` carrying a `mob_type`. Returns the boarder (a basic mob, built + * now), the /datum/human_npc_order of a human boarder still in the spawn queue, or null if nothing was + * sent. `on_boarder` is called with the boarder once it is in its pod. + */ +/proc/create_boarding_pod(turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship, mob_type, datum/npc_spawn_context/encounter, budget_reserved = FALSE, datum/callback/on_boarder) if(!target_turf || !mob_type) return null // Combat volleys call this on a timer; the target may have docked since firing. @@ -28,6 +37,12 @@ GLOBAL_VAR_INIT(boarding_spawn_total, 1) return null var/datum/npc_spawn_context/context = new(target_ship, controller.boarding_context.zone_type, "ordinary", source_turf = controller.boarding_context.source_turf) + if(ispath(mob_type, /mob/living/carbon/human/npc)) + var/datum/callback/launch = CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(boarding_pod_launch), /obj/structure/closet/supplypod/boarding, target_turf, target_ship, source_ship, on_boarder) + var/datum/human_npc_order/order = spawn_human_npc(mob_type, boarding_pod_build_turf(source_ship, target_turf), launch, outfit = boarding_outfit_for(mob_type), context = context) + controller.track_boarding_order(order) + return order + // Create the boarding pod with the mob inside var/obj/structure/closet/supplypod/boarding/pod = new() @@ -37,17 +52,52 @@ GLOBAL_VAR_INIT(boarding_spawn_total, 1) qdel(pod) return null - // Store references for the pod + boarding_pod_send(pod, boarder, target_turf, target_ship, source_ship) + on_boarder?.Invoke(boarder) + return boarder + +/// Where a human boarder is built before it goes in its pod: aboard the pirate's own hull, where it +/// climbs into the pod, else (a hull with no dock) the landing spot itself. It is in its pod the same tick. +/proc/boarding_pod_build_turf(obj/structure/overmap/ship/npc/source_ship, turf/target_turf) + return get_turf(source_ship?.shuttle) || target_turf + +/// The boarding outfit table for a human ship crewman (its faction's table sealed for vacuum), or null +/// for anything else. +/proc/boarding_outfit_for(mob_type) + if(!ispath(mob_type, /mob/living/carbon/human/npc/ship_crew)) + return null + var/mob/living/carbon/human/npc/ship_crew/crew_type = mob_type + return initial(crew_type.boarding_outfit_table) + +/** + * A human boarder is built (the spawn queue's callback): if the raid it was sent on still stands, it goes + * in its pod and the pod launches; if the target docked or the raid ended meanwhile, the delivery is + * cancelled and the boarder deleted before anyone saw it. A boss learns which hull it boarded for. + */ +/proc/boarding_pod_launch(pod_type, turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship, datum/callback/on_boarder, mob/living/boarder) + if(QDELETED(boarder)) + return + var/datum/ai_controller/npc_ship/controller = source_ship?.ai_controller + if(QDELETED(target_ship) || QDELETED(source_ship) || target_ship.state != OVERMAP_SHIP_FLYING || source_ship.state != OVERMAP_SHIP_FLYING || !istype(controller) || controller.get_target() != target_ship) + qdel(boarder) + return + var/obj/structure/closet/supplypod/boarding/pod = new pod_type() + boarder.forceMove(pod) + var/mob/living/carbon/human/npc/ship_crew/crewman = boarder + if(istype(crewman) && ispath(pod_type, /obj/structure/closet/supplypod/boarding/boss)) + crewman.parent_ship = source_ship + boarding_pod_send(pod, boarder, target_turf, target_ship, source_ship) + on_boarder?.Invoke(boarder) + +/// Sends `pod`, with `boarder` already inside, falling onto `target_turf`. +/proc/boarding_pod_send(obj/structure/closet/supplypod/boarding/pod, mob/living/boarder, turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship) pod.target_ship = target_ship pod.source_ship = source_ship pod.boarder_ref = WEAKREF(boarder) pod.spawn_index = GLOB.boarding_spawn_index++ - // Create the landing zone - this handles the whole falling animation new /obj/effect/pod_landingzone/boarding(target_turf, pod, target_ship, source_ship) - return boarder - /** * Boarding Pod - A supplypod variant for delivering hostile boarders * @@ -162,9 +212,12 @@ GLOBAL_VAR_INIT(boarding_spawn_total, 1) // ========== BOSS BOARDING POD ========== -/// Global proc to create a boss boarding pod drop at a target location -/// Returns the boss mob so the caller can register signals and track it -/proc/create_boss_boarding_pod(turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship, mob_type) +/** + * Launches the boss's heavy boarding pod at `target_turf`. Returns the boss (a basic mob), the order of a + * human boss still in the spawn queue, or null. `on_boss` is called with the boss once it is in its pod. + * A human boss keeps its own look (it is dressed from its faction's ordinary table, with a mask and air). + */ +/proc/create_boss_boarding_pod(turf/target_turf, obj/structure/overmap/ship/target_ship, obj/structure/overmap/ship/npc/source_ship, mob_type, datum/callback/on_boss) if(!target_turf || !mob_type) return null if(QDELETED(target_ship) || QDELETED(source_ship) || target_ship.state != OVERMAP_SHIP_FLYING || source_ship.state != OVERMAP_SHIP_FLYING) @@ -179,6 +232,12 @@ GLOBAL_VAR_INIT(boarding_spawn_total, 1) controller.boarding_boss_sent = TRUE var/datum/npc_spawn_context/context = new(target_ship, controller.boarding_context.zone_type, "boss", source_turf = controller.boarding_context.source_turf) + if(ispath(mob_type, /mob/living/carbon/human/npc)) + var/datum/callback/launch = CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(boarding_pod_launch), /obj/structure/closet/supplypod/boarding/boss, target_turf, target_ship, source_ship, on_boss) + var/datum/human_npc_order/order = spawn_human_npc(mob_type, boarding_pod_build_turf(source_ship, target_turf), launch, context = context) + controller.track_boarding_order(order) + return order + // Create the boss boarding pod var/obj/structure/closet/supplypod/boarding/boss/pod = new() @@ -192,14 +251,8 @@ GLOBAL_VAR_INIT(boarding_spawn_total, 1) boss.parent_ship = source_ship context.finalize() - // Store references for the pod - pod.target_ship = target_ship - pod.source_ship = source_ship - pod.boarder_ref = WEAKREF(boss) - - // Create the landing zone - this handles the whole falling animation - new /obj/effect/pod_landingzone/boarding(target_turf, pod, target_ship, source_ship) - + boarding_pod_send(pod, boss, target_turf, target_ship, source_ship) + on_boss?.Invoke(boss) return boss /** diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_corpses.dm b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_corpses.dm index 441ce006666..8cb174b72ea 100644 --- a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_corpses.dm +++ b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_corpses.dm @@ -168,7 +168,9 @@ outfit = /datum/outfit/piratecorpse/faction/lustrous /obj/effect/mob_spawn/corpse/human/pirate/faction/lustrous/special(mob/living/carbon/human/spawned_human) - // Prevent the ethereal heart's crystal revival mechanic from reviving NPC pirate corpses + // Only for bodies that start dead: the corpse a basic lustrous pirate leaves, and appearance dummies. A + // clientless prop revived by its crystal would be nobody. Living lustrous pirates are human NPCs that + // take only this spawner's look, never this proc, and crystallise as players do (defaults PI-6). ADD_TRAIT(spawned_human, TRAIT_CANNOT_CRYSTALIZE, TRAIT_GENERIC) return ..() diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm new file mode 100644 index 00000000000..461a2e44f69 --- /dev/null +++ b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm @@ -0,0 +1,671 @@ +/** + * # Human NPC ship crews + * + * The crews of NPC pirate hulls and the boarders they send are real human NPCs + * (voidcrew/modules/human_npcs/): real bodies with real crit, stamina, wounds, cuffs, bleeding and + * revival, holding the real weapons they fight with (human NPC migration, phase B). Their AI is PR #500's + * pirate crew on the human base (pirate_crew_human.dm). The basic-mob faction pirates in + * faction_pirate_mobs.dm stay for ruins, planets and missions until phase C moves those. + * + * Every faction keeps its PR #500 look, lines and guns: + * - the look (species, hair, skin) comes from today's faction corpse spawner (npc_look), the clothes and + * weapons from the faction's outfit table (human_npc_outfits_pirates.dm), picked by crew_role and rank; + * - a crewman holds the gun or blade its outfit gives it, with its spare magazines, shells or a knife; + * - boarders cross space, so they are dressed from the faction's boarding table, which adds a sealed suit + * and air (bosses only take a mask and a tank: they keep their look and suffer a vented room as a player + * would); + * - toughness comes from what they wear plus npc_damage_taken by rank (captains 0.8, bosses 0.6), never + * from a health pool; + * - captains and bosses carry a real stimulant and inject it when their fight starts (the old stun + * immunity), Davy Jones drinks from a flask of cursed grog (his old regeneration), the Black Knight has + * tg's hulk and gigantism (his size and his shrugging off batons), the Radiant One is a real ethereal; + * - medieval footsoldiers, the warlord and the Black Knight refuse guns (TRAIT_NOGUNS), as before; + * - the designed loot rolls (the zone ladder, the red-zone 25% weapon, boss uniques, the plunder of 300, + * 1000 or 3000 credits) drop once at death. What they wear and hold is locked to the body (D3) and + * never loot. The captain's ship key is not gear: it drops when he dies. + * + * Their ID card carries their name, so a crewman whose helmet hides his face is still "Silverscale + * Duelist" and not "Unknown". It is locked gear like the rest and opens nothing. + */ + +/// What a pirate ship crewman says when things happen to its body (human_npc_barks.dm) +/datum/human_npc_voice/pirate + lines = list( + HUMAN_NPC_BARK_DOWNED = list("I'm hit!", "Agh! Man down!", "Not like this..."), + HUMAN_NPC_BARK_PLEAD = list("Don't... don't.", "Quarter... give quarter..."), + HUMAN_NPC_BARK_RECOVER = list("Still breathing.", "Back on my feet."), + HUMAN_NPC_BARK_CUFFED = list("Get these off me!", "You'll hang for this."), + HUMAN_NPC_BARK_SLIPPING = list("Almost..."), + HUMAN_NPC_BARK_PULLED_FREE = list("Hands off!"), + ) + emotes = list( + "stimulant" = list("jabs a needle into their neck."), + "drink" = list("takes a long pull from a battered flask."), + ) + +/mob/living/carbon/human/npc/ship_crew + name = "spacer" + faction = list(FACTION_PIRATE) + ai_controller = /datum/ai_controller/basic_controller/pirate_crew/human + npc_voice = /datum/human_npc_voice/pirate + npc_target_stat = HUMAN_NPC_FINISHES_DOWNED + /// SHIP_CREW_ROLE_MELEE or SHIP_CREW_ROLE_GUNNER: which of its faction's outfits a regular wears + var/crew_role + /// Its faction's table with a sealed suit added, for boarders (an outfit table typepath) + var/datum/human_npc_outfit_table/boarding_outfit_table + /// One line about it on examine, in character + var/crew_desc + /// What this faction shouts when its ship's crew goes on alert (pirate_crew.dm). Null: the generic lines. + var/list/crew_alert_lines + /// What it shouts when it spots someone. Null: the generic lines. + var/list/crew_spot_lines + + // ===== Designed loot, as the basic faction pirates roll it (faction_pirate_mobs.dm) ===== + /// Always dropped + var/list/loot_pool + /// Each rolled once at spawn against npc_ladder_red_drop() + var/list/rare_loot_pool + /// What the rare roll gave + var/list/rolled_rare_loot + /// With any of these set, it drops one zone-laddered weapon (pirate_roll_ladder_weapon()) + var/list/random_loot = list( + /obj/item/knife/combat/survival, + /obj/item/spear, + /obj/item/melee/baseball_bat, + ) + /// One of these is always dropped + var/list/random_loot_2 = list( + /obj/item/storage/medkit/regular, + /obj/item/storage/toolbox/mechanical, + /obj/item/flashlight/seclite, + /obj/item/reagent_containers/hypospray/medipen, + /obj/item/stack/telecrystal, + ) + /// A captain whose zone ladder drops a melee weapon (Silverscale, Lustrous and Interdyne captains) + var/captain_ladder_melee = FALSE + /// A named mission target pays its cash once, however often it dies + var/datum/weakref/mission_payout_ref + + // ===== Elites and bosses ===== + /// The medipen it injects when its fight starts, or null. Elites and bosses get one by rank. + var/obj/item/reagent_containers/hypospray/medipen/stimulant_type + /// The NPC hull this is the boarding boss of; killing or taking it disables the hull + var/obj/structure/overmap/ship/npc/pirate/parent_ship + +/mob/living/carbon/human/npc/ship_crew/Initialize(mapload, datum/human_npc_order/order) + switch(npc_rank) + if(HUMAN_NPC_RANK_ELITE) + // A type that sets its own toughness keeps it + if(npc_damage_taken == 1) + npc_damage_taken = SHIP_CREW_ELITE_DAMAGE_TAKEN + stimulant_type ||= /obj/item/reagent_containers/hypospray/medipen/stimpack/traitor + if(HUMAN_NPC_RANK_BOSS) + if(npc_damage_taken == 1) + npc_damage_taken = SHIP_CREW_BOSS_DAMAGE_TAKEN + . = ..() + rolled_rare_loot = list() + for(var/item_type in rare_loot_pool) + if(npc_ladder_red_drop()) + rolled_rare_loot += item_type + crew_issue_id() + if(stimulant_type) + var/obj/item/stimulant = new stimulant_type(src) + if(!crew_give(stimulant)) + qdel(stimulant) + +/mob/living/carbon/human/npc/ship_crew/Destroy() + parent_ship = null + mission_payout_ref = null + return ..() + +/mob/living/carbon/human/npc/ship_crew/examine(mob/user) + . = ..() + if(crew_desc && stat != DEAD) + . += span_notice(crew_desc) + +/// An ID card with its name on it, so a helmet that hides its face doesn't make it "Unknown". +/mob/living/carbon/human/npc/ship_crew/proc/crew_issue_id() + if(wear_id) + return + var/obj/item/card/id/advanced/black/card = new(src) + card.registered_name = real_name + card.assignment = real_name + card.update_label() + if(!equip_to_slot_if_possible(card, ITEM_SLOT_ID, disable_warning = TRUE)) + qdel(card) + return + npc_lock_item(card) + +/** + * Puts `thing` somewhere on its body it can take it from: a free pocket, else its backpack or belt. + * Locks or issues it by the gear rules (a ship key isn't gear, a medipen is a consumable). Returns TRUE + * if it is on the body; FALSE leaves it where it was, for the caller to deal with. + */ +/mob/living/carbon/human/npc/ship_crew/proc/crew_give(obj/item/thing) + if(QDELETED(thing)) + return FALSE + if(equip_to_slot_if_possible(thing, ITEM_SLOT_LPOCKET, disable_warning = TRUE) || equip_to_slot_if_possible(thing, ITEM_SLOT_RPOCKET, disable_warning = TRUE)) + npc_lock_item(thing) + return TRUE + for(var/obj/item/container in list(back, belt)) + if(container.atom_storage?.attempt_insert(thing, src, override = TRUE, messages = FALSE)) + npc_lock_item(thing) + return TRUE + return FALSE + +// ===== Death and its designed drops ===== + +/mob/living/carbon/human/npc/ship_crew/npc_roll_death_loot() + var/zone = npc_spawn_zone() + . = list() + if(length(loot_pool)) + . += loot_pool + if(length(rolled_rare_loot)) + . += rolled_rare_loot + if(length(random_loot)) + . += pirate_roll_ladder_weapon(zone) + if(captain_ladder_melee) + if(zone == ZONE_YELLOW) + . += /obj/item/claymore/shortsword + else if(zone == ZONE_RED) + . += pick(npc_ladder_yellow_melee()) + if(length(random_loot_2)) + . += pick(random_loot_2) + +/mob/living/carbon/human/npc/ship_crew/npc_drop_death_loot() + // A named mission target pays its cash once for the mission, however many bodies it takes + if(!npc_loot_dropped && npc_death_cash > 0 && mission_payout_ref) + var/datum/mission/mission = mission_payout_ref.resolve() + if(!mission?.claim_boss_cash()) + npc_death_cash = 0 + return ..() + +/mob/living/carbon/human/npc/ship_crew/death(gibbed) + var/was_alive = stat != DEAD + . = ..() + if(was_alive && stat == DEAD) + crew_report_boss_down() + +/// The hull this boarded for hears its boss is down (PI-22: the boss's fall disables the hull). +/mob/living/carbon/human/npc/ship_crew/proc/crew_report_boss_down() + var/datum/ai_controller/npc_ship/controller = parent_ship?.ai_controller + if(istype(controller)) + SEND_SIGNAL(controller, COMSIG_BOARDING_BOSS_KILLED, src) + +// ===== The factions ===== + +// ----- Silverscale (aristocratic lizards) ----- + +/mob/living/carbon/human/npc/ship_crew/pirate + name = "pirate" + +/mob/living/carbon/human/npc/ship_crew/pirate/silverscale + npc_name = "Silverscale Pirate" + crew_desc = "An aristocratic lizard pirate demanding tribute from lesser beings." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/silverscale + npc_outfit_table = /datum/human_npc_outfit_table/pirate/silverscale + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/silverscale/boarding + +/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/Initialize(mapload, datum/human_npc_order/order) + // The rank and file may be carrying a fish (as the basic duelists and marksmen do) + if(crew_role) + random_loot_2 = list(/obj/item/storage/medkit/regular, /obj/item/storage/toolbox/mechanical, random_fish_type()) + return ..() + +/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/melee + npc_name = "Silverscale Duelist" + crew_desc = "A noble lizard trained in the art of the blade." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/ranged + npc_name = "Silverscale Marksman" + crew_desc = "A noble lizard with impeccable aim." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/captain + npc_name = "Silverscale Noble" + crew_desc = "A high-born lizard of impeccable breeding and ruthless ambition." + npc_rank = HUMAN_NPC_RANK_ELITE + random_loot = null + captain_ladder_melee = TRUE + npc_death_cash = 1000 + +/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/boss + npc_name = "Silverscale Highlord" + crew_desc = "The supreme ruler of the Silverscale noble house. A master duelist who has slain countless challengers." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/clothing/suit/hooded/cloak/drake, /obj/item/clothing/head/hooded/cloakhood/drake) + npc_death_cash = 3000 + +// ----- Skeleton (the Flying Dutchman) ----- + +/mob/living/carbon/human/npc/ship_crew/pirate/skeleton + npc_name = "Skeleton Pirate" + crew_desc = "Yarr! An undead scourge of the space lanes." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/skeleton + npc_outfit_table = /datum/human_npc_outfit_table/pirate/skeleton + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/skeleton/boarding + crew_alert_lines = list( + "All hands! Boarders!", + "Fresh crew coming aboard!", + "They'll fit right in. Eventually.", + ) + crew_spot_lines = list( + "There ye are!", + "I see ye!", + "Over here, lads!", + ) + +/mob/living/carbon/human/npc/ship_crew/pirate/skeleton/melee + npc_name = "Skeleton Swashbuckler" + crew_desc = "A skeletal swordsman that refuses to stay dead." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ship_crew/pirate/skeleton/ranged + npc_name = "Skeleton Gunner" + crew_desc = "A skeletal marksman with hollow eyes." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/ship_crew/pirate/skeleton/captain + npc_name = "Skeleton Captain" + crew_desc = "The terrifying undead commander of the Flying Dutchman." + npc_rank = HUMAN_NPC_RANK_ELITE + loot_pool = list(/obj/item/gun/energy/laser) + random_loot = null + npc_death_cash = 1000 + +/// Davy Jones drinks cursed grog from the flask in his pocket when he is hurt (his old regeneration, +/// defaults PI-5). +/mob/living/carbon/human/npc/ship_crew/pirate/skeleton/boss + npc_name = "Davy Jones" + crew_desc = "Captain of the Flying Dutchman. His cursed compass always points to the greatest treasure... or the nearest victim." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/gun/ballistic/shotgun/hook) + npc_death_cash = 3000 + +// ----- Grey Tide (rogue assistants) ----- + +/mob/living/carbon/human/npc/ship_crew/pirate/grey + npc_name = "Grey Tider" + crew_desc = "A former assistant turned space pirate. Robust." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/grey + npc_outfit_table = /datum/human_npc_outfit_table/pirate/grey + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/grey/boarding + crew_alert_lines = list( + "Someone's on the ship!", + "Who let them in?", + "They're after our stuff!", + ) + crew_spot_lines = list( + "Get 'em!", + "That one, there!", + "Over here!", + ) + +/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee + crew_desc = "Armed with a toolbox and bad intentions." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ship_crew/pirate/grey/ranged + npc_name = "Grey Tider Gunner" + crew_desc = "An assistant with a gun. Uh oh." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/ship_crew/pirate/grey/captain + npc_name = "Tidemaster" + crew_desc = "If he's on your shift, the Captain does not have his spare." + npc_rank = HUMAN_NPC_RANK_ELITE + loot_pool = list(/obj/item/storage/toolbox/syndicate) + random_loot = null + npc_death_cash = 1000 + +/mob/living/carbon/human/npc/ship_crew/pirate/grey/boss + npc_name = "The Robust One" + crew_desc = "The ultimate assistant. The greyest tider to ever live. Their toolbox has seen things." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/stack/telecrystal/five) + rare_loot_pool = list(/obj/item/storage/toolbox/mechanical/old/clean) + npc_death_cash = 3000 + +/mob/living/carbon/human/npc/ship_crew/pirate/grey/boss/Initialize(mapload, datum/human_npc_order/order) + . = ..() + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/robust_one) + +// ----- Lustrous (mutated ethereals) ----- + +// Real ethereals: they glow by their species, and a dead one's heart crystallises and revives it as a +// player's does, unless the crystal is broken (defaults PI-6). +/mob/living/carbon/human/npc/ship_crew/pirate/lustrous + npc_name = "Lustrous Pirate" + crew_desc = "A mutated ethereal obsessed with bluespace crystals." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/lustrous + npc_outfit_table = /datum/human_npc_outfit_table/pirate/lustrous + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/lustrous/boarding + +/mob/living/carbon/human/npc/ship_crew/pirate/lustrous/melee + npc_name = "Lustrous Scintillant" + crew_desc = "A crystalline being with a sharp blade." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ship_crew/pirate/lustrous/ranged + npc_name = "Lustrous Coruscant" + crew_desc = "A crystalline being channeling energy through strange weapons." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/ship_crew/pirate/lustrous/captain + npc_name = "Lustrous Radiant" + crew_desc = "A blindingly bright ethereal, the leader of their crystalline kind." + npc_rank = HUMAN_NPC_RANK_ELITE + random_loot = null + captain_ladder_melee = TRUE + npc_death_cash = 1000 + +/mob/living/carbon/human/npc/ship_crew/pirate/lustrous/boss + npc_name = "The Radiant One" + crew_desc = "A blindingly luminous ethereal. The crystalline matrix of their body phases in and out of realspace." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/grenade/gas_crystal/nitrous_oxide_crystal) + rare_loot_pool = list(/obj/item/cain_and_abel) + npc_death_cash = 3000 + +// ----- Interdyne (ex-pharmacists) ----- + +/mob/living/carbon/human/npc/ship_crew/pirate/interdyne + npc_name = "Interdyne Pirate" + crew_desc = "A former pharmaceutical employee turned bioterrorist." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/interdyne + npc_outfit_table = /datum/human_npc_outfit_table/pirate/interdyne + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/interdyne/boarding + +/mob/living/carbon/human/npc/ship_crew/pirate/interdyne/melee + npc_name = "Interdyne Enforcer" + crew_desc = "Enforces pharmaceutical compliance with surgical precision." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ship_crew/pirate/interdyne/ranged + npc_name = "Interdyne Pharmacist" + crew_desc = "Dispenses death instead of medicine." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/ship_crew/pirate/interdyne/captain + npc_name = "Interdyne Director" + crew_desc = "The head of this rogue pharmaceutical operation." + npc_rank = HUMAN_NPC_RANK_ELITE + random_loot = null + captain_ladder_melee = TRUE + npc_death_cash = 1000 + +/mob/living/carbon/human/npc/ship_crew/pirate/interdyne/boss + npc_name = "Director Prime" + crew_desc = "The head of all Interdyne black site operations. Their hypospray contains experimental compounds." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/reagent_containers/hypospray/combat, /obj/item/gun/syringe/syndicate, /obj/item/grenade/gluon) + npc_death_cash = 3000 + +// ----- IRS (tax collectors) ----- + +/mob/living/carbon/human/npc/ship_crew/pirate/irs + npc_name = "IRS Agent" + crew_desc = "The only thing certain in life is death and taxes. They're here for both." + faction = list(FACTION_PIRATE, FACTION_IRS) + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/irs + npc_outfit_table = /datum/human_npc_outfit_table/pirate/irs + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/irs/boarding + crew_alert_lines = list( + "Unscheduled visitors. Begin the audit.", + "Everyone aboard is now under review.", + "Intruders on deck. Find them.", + ) + crew_spot_lines = list( + "There's the taxpayer!", + "Stop right there.", + "You're overdue.", + ) + +/mob/living/carbon/human/npc/ship_crew/pirate/irs/melee + npc_name = "IRS Enforcer" + crew_desc = "Collects taxes the hard way." + crew_role = SHIP_CREW_ROLE_MELEE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ship_crew/pirate/irs/ranged + crew_desc = "Armed and ready to collect." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/mob/living/carbon/human/npc/ship_crew/pirate/irs/captain + npc_name = "IRS Head Auditor" + crew_desc = "The head of tax enforcement. Nobody escapes their audits." + npc_rank = HUMAN_NPC_RANK_ELITE + loot_pool = list(/obj/item/gun/energy/laser) + random_loot = null + npc_death_cash = 1000 + +/mob/living/carbon/human/npc/ship_crew/pirate/irs/boss + npc_name = "Chief Auditor" + crew_desc = "The supreme authority of the Space IRS. No one escapes their audits. NO ONE." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + // Carried in the outfit: the chief has no bag to put one in + stimulant_type = null + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/gun/energy/laser, /obj/item/storage/bag/money/dutchmen) + npc_death_cash = 3000 + +// ----- Medieval (space warmongers) ----- + +/mob/living/carbon/human/npc/ship_crew/pirate/medieval + npc_name = "Medieval Pirate" + crew_desc = "A space-faring knight who somehow operates a starship." + npc_look = /obj/effect/mob_spawn/corpse/human/pirate/faction/medieval + npc_outfit_table = /datum/human_npc_outfit_table/pirate/medieval + boarding_outfit_table = /datum/human_npc_outfit_table/pirate/medieval/boarding + crew_alert_lines = list( + "To arms! Intruders!", + "Who dares board us?", + "Defend the ship!", + ) + crew_spot_lines = list( + "Have at thee!", + "There, the coward!", + "Halt!", + ) + /// Hates guns and throws them hard (footsoldiers, the warlord and the Black Knight; not the crossbowmen) + var/refuses_guns = FALSE + +/mob/living/carbon/human/npc/ship_crew/pirate/medieval/Initialize(mapload, datum/human_npc_order/order) + . = ..() + if(refuses_guns) + add_traits(list(TRAIT_NOGUNS, TRAIT_TOSS_GUN_HARD), INNATE_TRAIT) + +/mob/living/carbon/human/npc/ship_crew/pirate/medieval/melee + npc_name = "Medieval Footsoldier" + crew_desc = "A knight who refuses to use guns. Honorable." + crew_role = SHIP_CREW_ROLE_MELEE + refuses_guns = TRUE + npc_death_cash = 300 + +/mob/living/carbon/human/npc/ship_crew/pirate/medieval/ranged + npc_name = "Medieval Crossbowman" + crew_desc = "At least crossbows are period-appropriate. Sort of." + crew_role = SHIP_CREW_ROLE_GUNNER + npc_death_cash = 25 + +/// The warlord: plate armour and a stimulant where the basic mob had stun immunity (defaults PI-4). +/mob/living/carbon/human/npc/ship_crew/pirate/medieval/captain + npc_name = "Medieval Warlord" + crew_desc = "A hulking brute of a knight. Massive, and very angry." + npc_rank = HUMAN_NPC_RANK_ELITE + refuses_guns = TRUE + stimulant_type = /obj/item/reagent_containers/hypospray/medipen/stimulants + loot_pool = list(/obj/item/fireaxe) + random_loot = null + npc_death_cash = 1000 + +/// The Black Knight: tg's hulk (stun and shove immune until he drops into crit) and gigantism (his size). +/mob/living/carbon/human/npc/ship_crew/pirate/medieval/boss + npc_name = "The Black Knight" + crew_desc = "A massive armored warrior. 'Tis but a scratch!' No really, they refuse to die." + npc_rank = HUMAN_NPC_RANK_BOSS + npc_damage_taken = 0.5 + npc_always_site = HUMAN_NPC_SITE_BAD_AIR + refuses_guns = TRUE + random_loot = null + random_loot_2 = null + loot_pool = list(/obj/item/claymore/shortsword, /obj/item/shield/kite) + npc_death_cash = 3000 + +/mob/living/carbon/human/npc/ship_crew/pirate/medieval/boss/Initialize(mapload, datum/human_npc_order/order) + . = ..() + dna.add_mutation(/datum/mutation/hulk, MUTATION_SOURCE_MUTATOR) + dna.add_mutation(/datum/mutation/gigantism, MUTATION_SOURCE_MUTATOR) + AddComponent(/datum/component/hv_finisher_user, /datum/hv_finisher/black_knight) + +// ===== Boss kit ===== + +/// Davy Jones's flask. Locked to him like the rest of his kit (D3). +/obj/item/reagent_containers/cup/glass/flask/davy_jones + name = "battered flask" + desc = "A dented pewter flask. Whatever is in it smells of the deep." + list_reagents = list(/datum/reagent/medicine/omnizine/cursed_grog = 40) + +/** + * Cursed grog: knits flesh while it is in the blood, with a cold blue glow. Never synthesised, never + * overdosed. Like any reagent it can be purged, and the flask runs dry. + */ +/datum/reagent/medicine/omnizine/cursed_grog + name = "Cursed Grog" + description = "Sea water and something older. It knits flesh back together while it lasts." + color = "#2a4a7a" + taste_description = "brine and old rum" + healing = 3 + overdose_threshold = 0 + chemical_flags = NONE + +/datum/reagent/medicine/omnizine/cursed_grog/on_mob_metabolize(mob/living/affected_mob) + . = ..() + affected_mob.add_filter("cursed_grog", 2, outline_filter(1, "#4488FF")) + +/datum/reagent/medicine/omnizine/cursed_grog/on_mob_end_metabolize(mob/living/affected_mob) + . = ..() + affected_mob.remove_filter("cursed_grog") + +/// The Robust One's toolbox: the old blue toolbox, with the telecrystals inside that make it hit harder. +/obj/item/storage/toolbox/mechanical/old/clean/robust_one + attack_verb_continuous = list("ROBUSTS") + attack_verb_simple = list("ROBUST") + +/obj/item/storage/toolbox/mechanical/old/clean/robust_one/PopulateContents() + . = ..() + new /obj/item/stack/telecrystal(src, 6) + +/// Davy Jones's cutlass. +/obj/item/claymore/cutlass/davy_jones + attack_verb_continuous = list("curses", "slashes", "cuts") + attack_verb_simple = list("curse", "slash", "cut") + +/// The Black Knight's claymore. +/obj/item/claymore/black_knight + attack_verb_continuous = list("smites", "cleaves", "hews") + attack_verb_simple = list("smite", "cleave", "hew") + +/// The warlord's boarding axe. +/obj/item/fireaxe/boardingaxe/warlord + attack_verb_continuous = list("crushes", "cleaves", "hacks") + attack_verb_simple = list("crush", "cleave", "hack") + +/// An energy cutlass with a boss's own attack verbs. +/obj/item/melee/energy/sword/pirate/boss + /// Verbs while lit + var/list/boss_verbs_continuous = list("eviscerates") + var/list/boss_verbs_simple = list("eviscerate") + +/obj/item/melee/energy/sword/pirate/boss/make_transformable() + AddComponent( \ + /datum/component/transforming, \ + force_on = active_force, \ + throwforce_on = active_throwforce, \ + throw_speed_on = 4, \ + sharpness_on = active_sharpness, \ + hitsound_on = active_hitsound, \ + w_class_on = active_w_class, \ + attack_verb_continuous_on = boss_verbs_continuous, \ + attack_verb_simple_on = boss_verbs_simple, \ + ) + RegisterSignal(src, COMSIG_TRANSFORMING_ON_TRANSFORM, PROC_REF(on_transform)) + +/// The Radiant One's cutlass. +/obj/item/melee/energy/sword/pirate/boss/radiant + boss_verbs_continuous = list("phases through", "slashes") + boss_verbs_simple = list("phase through", "slash") + +// ===== Dressing by role, and for boarding ===== + +// A crewman of the rank and file wears its role's outfits from its faction's table +// (voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm): a duelist the faction's blades, a marksman +// its guns in PR #500's gun weights. Captains and bosses wear their own. +/datum/human_npc_outfit_table/pirate/outfits_for(mob/living/carbon/human/npc/npc, zone) + var/mob/living/carbon/human/npc/ship_crew/crewman = npc + if(istype(crewman) && crewman.npc_rank == HUMAN_NPC_RANK_REGULAR) + switch(crewman.crew_role) + if(SHIP_CREW_ROLE_MELEE) + if(length(melee) && !gunners_only) + return melee + if(SHIP_CREW_ROLE_GUNNER) + if(length(gunners)) + return gunners + return ..() + +// Boarders cross space and may land in a vented room, so they wear a sealed suit and carry air +// (always_site, human_npc_outfits.dm). Skeletons need neither and are left as they are. +/datum/human_npc_outfit_table/pirate/silverscale/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/skeleton/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/grey/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/lustrous/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/interdyne/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/irs/boarding + always_site = HUMAN_NPC_SITE_VACUUM + +/datum/human_npc_outfit_table/pirate/medieval/boarding + always_site = HUMAN_NPC_SITE_VACUUM diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_mobs.dm b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_mobs.dm index 4622793770b..081ecec1352 100644 --- a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_mobs.dm +++ b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_mobs.dm @@ -1,4 +1,5 @@ -// Faction-specific pirate NPC mobs for Voidcrew NPC ships +// Faction-specific pirate NPC basic mobs. NPC ship crews and their boarders are human NPCs now +// (faction_pirate_humans.dm); these stay for ruins, planets and missions until those move (phase C). // Each faction has melee, ranged, and captain variants // All are space-capable (no atmos damage, spacewalk trait) @@ -25,10 +26,6 @@ var/list/crew_alert_lines /// What it shouts when it spots someone. Null uses the generic lines. var/list/crew_spot_lines - /// Weights of the guns a gunner of this faction rolls when it joins a crew (pirate_guns.dm). Null: the default mix. - var/list/crew_gun_weights - /// Keeps its own ranged weapon instead of rolling a gun - var/crew_keeps_gun = FALSE /// Random combat loot - one item is picked at random and added to death drops. var/list/random_loot = list(\ /obj/item/knife/combat/survival,\ @@ -83,6 +80,10 @@ AddElement(/datum/element/death_drops, string_list(death_loot)) /mob/living/basic/trooper/pirate/faction/proc/roll_ladder_weapon(zone, roll = rand(1, 100)) + return pirate_roll_ladder_weapon(zone, roll) + +/// The weapon a faction pirate drops by the zone it fought in, basic or human (faction_pirate_humans.dm). +/proc/pirate_roll_ladder_weapon(zone, roll = rand(1, 100)) switch(zone) if(ZONE_YELLOW) return roll <= 5 ? /obj/item/gun/energy/laser : pick(npc_ladder_yellow_melee()) @@ -636,56 +637,3 @@ ADD_TRAIT(src, TRAIT_PUSHIMMUNE, INNATE_TRAIT) ADD_TRAIT(src, TRAIT_NOGUNS, INNATE_TRAIT) ADD_TRAIT(src, TRAIT_TOSS_GUN_HARD, INNATE_TRAIT) - -// ==================== CREW GUNS ==================== -// What each faction's gunners roll when they join a crew (pirate_guns.dm) - -/mob/living/basic/trooper/pirate/faction/silverscale - crew_gun_weights = list( - /datum/pirate_gun_loadout/energy_gun = 3, - /datum/pirate_gun_loadout/laser_rifle = 2, - /datum/pirate_gun_loadout/pistol = 3, - /datum/pirate_gun_loadout/shotgun = 1, - ) - -/mob/living/basic/trooper/pirate/faction/skeleton - crew_gun_weights = list( - /datum/pirate_gun_loadout/laser_rifle = 1, - /datum/pirate_gun_loadout/pistol = 2, - /datum/pirate_gun_loadout/smg = 2, - /datum/pirate_gun_loadout/shotgun = 3, - ) - -/mob/living/basic/trooper/pirate/faction/grey - crew_gun_weights = list( - /datum/pirate_gun_loadout/energy_gun = 3, - /datum/pirate_gun_loadout/laser_rifle = 3, - /datum/pirate_gun_loadout/smg = 1, - ) - -/mob/living/basic/trooper/pirate/faction/lustrous - crew_gun_weights = list( - /datum/pirate_gun_loadout/energy_gun = 3, - /datum/pirate_gun_loadout/laser_rifle = 3, - /datum/pirate_gun_loadout/pistol = 1, - ) - -/mob/living/basic/trooper/pirate/faction/interdyne - crew_gun_weights = list( - /datum/pirate_gun_loadout/energy_gun = 2, - /datum/pirate_gun_loadout/pistol = 2, - /datum/pirate_gun_loadout/smg = 3, - /datum/pirate_gun_loadout/shotgun = 1, - ) - -/mob/living/basic/trooper/pirate/faction/irs - crew_gun_weights = list( - /datum/pirate_gun_loadout/energy_gun = 1, - /datum/pirate_gun_loadout/pistol = 3, - /datum/pirate_gun_loadout/smg = 2, - /datum/pirate_gun_loadout/shotgun = 2, - ) - -// Crossbows suit them -/mob/living/basic/trooper/pirate/faction/medieval - crew_keeps_gun = TRUE diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_ships.dm b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_ships.dm index 8d743ce5b2f..6c0103b077b 100644 --- a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_ships.dm +++ b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_ships.dm @@ -1,5 +1,6 @@ // Faction-specific NPC pirate ship types -// Each faction has unique colors, crew types, and behaviors +// Each faction has unique colors, crew types, and behaviors. Crews, boarders and bosses are human NPCs +// (faction_pirate_humans.dm). // ==================== FACTION COLORS ==================== @@ -29,11 +30,11 @@ crew_min = 2 crew_max = 4 - captain_type = /mob/living/basic/trooper/pirate/faction/silverscale/captain + captain_type = /mob/living/carbon/human/npc/ship_crew/pirate/silverscale/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/silverscale/melee, - /mob/living/basic/trooper/pirate/faction/silverscale/melee, - /mob/living/basic/trooper/pirate/faction/silverscale/ranged, + /mob/living/carbon/human/npc/ship_crew/pirate/silverscale/melee, + /mob/living/carbon/human/npc/ship_crew/pirate/silverscale/melee, + /mob/living/carbon/human/npc/ship_crew/pirate/silverscale/ranged, ) // Boarding pods - aristocrats send their duelists @@ -52,7 +53,7 @@ pirate_faction = "silverscale" // Phased combat - aristocratic duelists - boss_type = /mob/living/basic/trooper/pirate/faction/boss/silverscale + boss_type = /mob/living/carbon/human/npc/ship_crew/pirate/silverscale/boss broke_lines = list( "Destitute. How disappointing. My retainers will see what else your hold contains.", "You have nothing to offer but cargo, then. And perhaps your lives.", @@ -90,11 +91,11 @@ crew_min = 3 crew_max = 6 - captain_type = /mob/living/basic/trooper/pirate/faction/skeleton/captain + captain_type = /mob/living/carbon/human/npc/ship_crew/pirate/skeleton/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/skeleton/melee, - /mob/living/basic/trooper/pirate/faction/skeleton/melee, - /mob/living/basic/trooper/pirate/faction/skeleton/ranged, + /mob/living/carbon/human/npc/ship_crew/pirate/skeleton/melee, + /mob/living/carbon/human/npc/ship_crew/pirate/skeleton/melee, + /mob/living/carbon/human/npc/ship_crew/pirate/skeleton/ranged, ) // Boarding pods - undead are relentless boarders @@ -112,7 +113,7 @@ pirate_faction = "skeleton" // Phased combat - undead hordes - boss_type = /mob/living/basic/trooper/pirate/faction/boss/skeleton + boss_type = /mob/living/carbon/human/npc/ship_crew/pirate/skeleton/boss broke_lines = list( "No coin on this wreck. No matter - we take flesh as readily as gold.", "Empty holds, empty pockets. We will settle for your crew.", @@ -150,11 +151,11 @@ crew_min = 3 crew_max = 5 - captain_type = /mob/living/basic/trooper/pirate/faction/grey/captain + captain_type = /mob/living/carbon/human/npc/ship_crew/pirate/grey/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/grey/melee, - /mob/living/basic/trooper/pirate/faction/grey/melee, - /mob/living/basic/trooper/pirate/faction/grey/ranged, + /mob/living/carbon/human/npc/ship_crew/pirate/grey/melee, + /mob/living/carbon/human/npc/ship_crew/pirate/grey/melee, + /mob/living/carbon/human/npc/ship_crew/pirate/grey/ranged, ) // Boarding pods - grey tiders love swarming @@ -172,7 +173,7 @@ hold_credits_max = 1600 // Phased combat - greytide swarm - boss_type = /mob/living/basic/trooper/pirate/faction/boss/grey + boss_type = /mob/living/carbon/human/npc/ship_crew/pirate/grey/boss broke_lines = list( "lmao ur broke? w/e. boys go grab whatever isnt bolted down", "no creds? fine. WE TAKE THE TOOLBOXES", @@ -206,10 +207,10 @@ crew_min = 2 crew_max = 4 - captain_type = /mob/living/basic/trooper/pirate/faction/lustrous/captain + captain_type = /mob/living/carbon/human/npc/ship_crew/pirate/lustrous/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/lustrous/melee, - /mob/living/basic/trooper/pirate/faction/lustrous/ranged, + /mob/living/carbon/human/npc/ship_crew/pirate/lustrous/melee, + /mob/living/carbon/human/npc/ship_crew/pirate/lustrous/ranged, ) // Boarding pods - ethereals prefer ranged combat, fewer boarders @@ -227,7 +228,7 @@ hold_credits_max = 3000 // Phased combat - crystalline beings - boss_type = /mob/living/basic/trooper/pirate/faction/boss/lustrous + boss_type = /mob/living/carbon/human/npc/ship_crew/pirate/lustrous/boss broke_lines = list( "You carry no wealth. We will take what matter you do carry.", "Nothing of value in your accounts. We will harvest from your hold instead.", @@ -266,11 +267,11 @@ crew_min = 3 crew_max = 5 - captain_type = /mob/living/basic/trooper/pirate/faction/interdyne/captain + captain_type = /mob/living/carbon/human/npc/ship_crew/pirate/interdyne/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/interdyne/melee, - /mob/living/basic/trooper/pirate/faction/interdyne/ranged, - /mob/living/basic/trooper/pirate/faction/interdyne/ranged, + /mob/living/carbon/human/npc/ship_crew/pirate/interdyne/melee, + /mob/living/carbon/human/npc/ship_crew/pirate/interdyne/ranged, + /mob/living/carbon/human/npc/ship_crew/pirate/interdyne/ranged, ) // Boarding pods - interdyne sends surgical strike teams @@ -288,7 +289,7 @@ hold_credits_max = 3800 // Phased combat - surgical strike teams - boss_type = /mob/living/basic/trooper/pirate/faction/boss/interdyne + boss_type = /mob/living/carbon/human/npc/ship_crew/pirate/interdyne/boss broke_lines = list( "Accounts empty. Switching to physical asset recovery. Deploying collection team.", "No liquid funds. Your cargo and your crew will serve as compensation.", @@ -331,11 +332,11 @@ crew_min = 3 crew_max = 5 - captain_type = /mob/living/basic/trooper/pirate/faction/irs/captain + captain_type = /mob/living/carbon/human/npc/ship_crew/pirate/irs/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/irs/melee, - /mob/living/basic/trooper/pirate/faction/irs/ranged, - /mob/living/basic/trooper/pirate/faction/irs/ranged, + /mob/living/carbon/human/npc/ship_crew/pirate/irs/melee, + /mob/living/carbon/human/npc/ship_crew/pirate/irs/ranged, + /mob/living/carbon/human/npc/ship_crew/pirate/irs/ranged, ) // Boarding pods - IRS sends auditors to seize assets @@ -354,7 +355,7 @@ hold_credits_max = 6000 // Phased combat - tax enforcement - boss_type = /mob/living/basic/trooper/pirate/faction/boss/irs + boss_type = /mob/living/carbon/human/npc/ship_crew/pirate/irs/boss broke_lines = list( "Insufficient funds for settlement. Proceeding directly to asset seizure.", "You cannot pay. Then we collect in kind. Agents are boarding now.", @@ -396,11 +397,11 @@ crew_min = 3 crew_max = 6 - captain_type = /mob/living/basic/trooper/pirate/faction/medieval/captain + captain_type = /mob/living/carbon/human/npc/ship_crew/pirate/medieval/captain crew_types = list( - /mob/living/basic/trooper/pirate/faction/medieval/melee, - /mob/living/basic/trooper/pirate/faction/medieval/melee, - /mob/living/basic/trooper/pirate/faction/medieval/ranged, + /mob/living/carbon/human/npc/ship_crew/pirate/medieval/melee, + /mob/living/carbon/human/npc/ship_crew/pirate/medieval/melee, + /mob/living/carbon/human/npc/ship_crew/pirate/medieval/ranged, ) // Boarding pods - medieval warriors LOVE boarding actions @@ -418,7 +419,7 @@ hold_credits_max = 3200 // Phased combat - knights and men-at-arms - boss_type = /mob/living/basic/trooper/pirate/faction/boss/medieval + boss_type = /mob/living/carbon/human/npc/ship_crew/pirate/medieval/boss broke_lines = list( "No coin in thy coffers? Then we shall take thy goods by force of arms!", "A pauper's ship! Very well - the spoils shall be whatever thou carriest!", diff --git a/voidcrew/modules/npc_ships/code/npc_ship.dm b/voidcrew/modules/npc_ships/code/npc_ship.dm index b639e0a06a5..752be23f6e3 100644 --- a/voidcrew/modules/npc_ships/code/npc_ship.dm +++ b/voidcrew/modules/npc_ships/code/npc_ship.dm @@ -147,6 +147,11 @@ /// treat an empty roster as a crew wipe unless this is set - otherwise a hull whose /// crew spawn silently failed would resolve at birth and churn the pool in a loop. var/crew_ever_spawned = FALSE + /// Human NPC crew still waiting in the spawn queue (spawn_human_npc()); the hull's roster isn't + /// complete, and can't be wiped, until they are built + var/list/datum/human_npc_order/crew_orders = list() + /// Set once the crew wipe has been handled, so a second path to it does nothing + var/crew_wipe_handled = FALSE // ========== MASS CACHING (Performance optimization) ========== // Instead of iterating all turfs every second, we cache mass and only @@ -239,8 +244,9 @@ if(abandonment_timer) deltimer(abandonment_timer) abandonment_timer = null - // Clear crew tracking + // Clear crew tracking; crew still in the spawn queue are never built tracked_crew.Cut() + QDEL_LIST(crew_orders) return ..() /** @@ -375,16 +381,13 @@ if(captain_type && length(valid_turfs)) var/turf/captain_loc = pick_n_take(valid_turfs) var/datum/npc_spawn_context/context = new(src, pool_zone_type, "boss") - var/mob/living/basic/captain = spawn_npc_in_zone(captain_type, captain_loc, context, FALSE) - // Give captain the ship key - stored in contents, drops on death - var/obj/item/ship_key/key = new(null, src) - key.forceMove(captain) - context.finalize() - // Track captain and register death signal - tracked_crew += captain - RegisterSignal(captain, COMSIG_LIVING_DEATH, PROC_REF(on_crew_death)) - // The captain holds the helm with one guard (pirate_crew.dm) - pirate_crew_enlist_defender(captain, src, PIRATE_ROLE_CAPTAIN) + if(ispath(captain_type, /mob/living/carbon/human/npc)) + // Human NPCs are built a body at a time (spawn_human_npc()); the captain joins when built + crew_orders += spawn_human_npc(captain_type, captain_loc, CALLBACK(src, PROC_REF(on_captain_built)), context = context) + else + var/mob/living/basic/captain = spawn_npc_in_zone(captain_type, captain_loc, context, FALSE) + context.finalize() + on_captain_built(captain) crew_count-- // Captain counts toward crew count // Spawn rest of crew from configured types @@ -392,15 +395,52 @@ var/turf/spawn_loc = pick_n_take(valid_turfs) var/mob_type = pick(crew_types) var/datum/npc_spawn_context/context = new(src, pool_zone_type, "ordinary") - var/mob/living/crewmember = spawn_npc_in_zone(mob_type, spawn_loc, context) - // Track crew and register death signal - tracked_crew += crewmember - RegisterSignal(crewmember, COMSIG_LIVING_DEATH, PROC_REF(on_crew_death)) - pirate_crew_enlist_defender(crewmember, src) + if(ispath(mob_type, /mob/living/carbon/human/npc)) + crew_orders += spawn_human_npc(mob_type, spawn_loc, CALLBACK(src, PROC_REF(on_crewman_built)), context = context) + else + on_crewman_built(spawn_npc_in_zone(mob_type, spawn_loc, context)) + +/// The captain is built: it carries the ship key and holds the helm with one guard (pirate_crew.dm). +/obj/structure/overmap/ship/npc/proc/on_captain_built(mob/living/captain) + if(QDELETED(captain)) + return + // The key is not gear: a human captain keeps it on him, and it drops when he dies (on_crew_death()) + var/obj/item/ship_key/key = new(null, src) + var/mob/living/carbon/human/npc/ship_crew/human_captain = captain + if(!istype(human_captain) || !human_captain.crew_give(key)) + key.forceMove(captain) + track_crewman(captain, PIRATE_ROLE_CAPTAIN) +/// A crewman is built: tracked for the crew wipe and enlisted in the hull's crew. +/obj/structure/overmap/ship/npc/proc/on_crewman_built(mob/living/crewmember) + if(QDELETED(crewmember)) + return + track_crewman(crewmember, PIRATE_ROLE_HUNTER) + +/obj/structure/overmap/ship/npc/proc/track_crewman(mob/living/crewmember, role) + prune_crew_orders() + if(QDELETED(src)) + qdel(crewmember) + return + tracked_crew += crewmember + RegisterSignal(crewmember, COMSIG_LIVING_DEATH, PROC_REF(on_crew_death)) + // Out of the fight counts as gone for the crew wipe too (owner ruling OC-7): down or cuffed + if(ishumannpc(crewmember)) + RegisterSignals(crewmember, list(COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED)), PROC_REF(on_crew_out_of_fight)) + pirate_crew_enlist_defender(crewmember, src, role) // A roster exists; the pool may now treat this roster emptying as a crew wipe - if(length(tracked_crew)) - crew_ever_spawned = TRUE + crew_ever_spawned = TRUE + +/// Drops crew orders that have been built or cancelled. +/obj/structure/overmap/ship/npc/proc/prune_crew_orders() + for(var/datum/human_npc_order/order as anything in crew_orders.Copy()) + if(QDELETED(order) || order.done) + crew_orders -= order + +/// Whether crew are still waiting to be built. +/obj/structure/overmap/ship/npc/proc/crew_pending() + prune_crew_orders() + return length(crew_orders) > 0 /** * Signal handler for when any crew member dies. @@ -408,27 +448,45 @@ */ /obj/structure/overmap/ship/npc/proc/on_crew_death(mob/living/victim, gibbed) SIGNAL_HANDLER - UnregisterSignal(victim, COMSIG_LIVING_DEATH) + UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) - // Drop ship key if the victim has one (captain) + // Drop ship key if the victim has one (captain). A human captain's drops from his pocket with + // whatever else he carries that isn't gear (human_npc_gear.dm); this catches one held loose. var/turf/drop_loc = get_turf(victim) if(drop_loc) for(var/obj/item/ship_key/key in victim.contents) - key.forceMove(drop_loc) + if(!victim.get_slot_by_item(key)) + key.forceMove(drop_loc) // Remove from tracked crew list tracked_crew -= victim log_shuttle("NPC_SHIP: [name] crew member [victim] died ([length(tracked_crew)] tracked crew remain)") + check_crew_wipe() + +/// A human crewman went into crit or was cuffed: if nobody aboard is left fighting, the crew is wiped. +/obj/structure/overmap/ship/npc/proc/on_crew_out_of_fight(mob/living/source) + SIGNAL_HANDLER + check_crew_wipe() - // Check if all crew are dead - if(!length(tracked_crew)) - npc_metric_crew_wiped(src) - // The pool slot frees the instant the crew is wiped. The abandonment timer below - // is a separate concern - it holds the hull claimable for a while - and must not - // delay the replacement spawn. - if(!player_controlled) - notify_spawner_resolved("crew wiped") - start_abandonment_timer() +/** + * The crew is wiped once every tracked crewman is dead, or out of the fight (in crit or cuffed, owner + * ruling OC-7: a crew the players took down or captured is beaten, and a body frozen in crit on a hull + * nobody is near must not hold the pool slot). Crew still waiting to be built keep it from wiping. + */ +/obj/structure/overmap/ship/npc/proc/check_crew_wipe() + if(crew_wipe_handled || crew_pending()) + return + for(var/mob/living/crewmember as anything in tracked_crew) + if(!human_npc_out_of_fight(crewmember)) + return + crew_wipe_handled = TRUE + npc_metric_crew_wiped(src) + // The pool slot frees the instant the crew is wiped. The abandonment timer below + // is a separate concern - it holds the hull claimable for a while - and must not + // delay the replacement spawn. + if(!player_controlled) + notify_spawner_resolved("crew wiped") + start_abandonment_timer() /** * Starts a timer to abandon the ship after all crew die. @@ -522,21 +580,20 @@ return despawn_derelict() /** - * How many of this hull's own tracked crew are alive and actually aboard. + * How many of this hull's own tracked crew are still fighting and actually aboard. * * Derived from live state rather than roster bookkeeping on purpose: a missed death * signal, a hard-deleted mob, or a pirate spaced or dragged off the hull must all read - * as "not aboard", or the pool slot this crew holds never frees. The roster is 2-6 - * entries, so this is cheap enough for the reconcile's slow tick. + * as "not aboard", or the pool slot this crew holds never frees. A crewman in crit or + * cuffed is out of the fight and doesn't count either (owner ruling OC-7). The roster is + * 2-6 entries, so this is cheap enough for the reconcile's slow tick. */ /obj/structure/overmap/ship/npc/proc/count_live_crew_aboard() if(!shuttle?.shuttle_areas) return 0 var/count = 0 for(var/mob/living/crew as anything in tracked_crew) - if(QDELETED(crew)) - continue - if(crew.stat == DEAD) + if(human_npc_out_of_fight(crew)) continue var/area/crew_area = get_area(crew) if(!crew_area || !shuttle.shuttle_areas[crew_area]) @@ -544,25 +601,6 @@ count++ return count -/** - * Kills tracked crew that ended up floating in open space (hull breach blowout). - * The same cleanup boarding waves already get via check_boarders_in_space(); without it - * a spaced pirate drifts alive forever. Run from the pool reconcile. - */ -/obj/structure/overmap/ship/npc/proc/sweep_spaced_crew() - var/list/spaced = list() - for(var/mob/living/crew as anything in tracked_crew) - if(QDELETED(crew) || crew.stat == DEAD) - continue - var/turf/crew_turf = get_turf(crew) - if(!crew_turf) - continue - if(isspaceturf(crew_turf) || istype(get_area(crew_turf), /area/space)) - spaced += crew - // death() fires on_crew_death, which edits tracked_crew - never kill mid-iteration - for(var/mob/living/lost as anything in spaced) - lost.death() - /** * Signal handler for ship integrity changes. * Updates boarding state when ship is damaged enough. diff --git a/voidcrew/modules/npc_ships/code/npc_ship_controller.dm b/voidcrew/modules/npc_ships/code/npc_ship_controller.dm index bb5a1744aab..f26d546ccf8 100644 --- a/voidcrew/modules/npc_ships/code/npc_ship_controller.dm +++ b/voidcrew/modules/npc_ships/code/npc_ship_controller.dm @@ -32,6 +32,10 @@ var/boarding_committed = 0 var/boarding_pending = 0 var/boarding_boss_sent = FALSE + /// Human boarders (and a human boss) of this encounter still waiting in the spawn queue + var/list/datum/human_npc_order/boarding_orders = list() + /// Of those, the ones the scripted wave sent: the wave isn't over while any of them is still coming + var/list/datum/human_npc_order/wave_orders = list() /datum/ai_controller/npc_ship/New(atom/new_pawn) // Initialize combat blackboard @@ -101,6 +105,7 @@ /// Override to avoid ai_movement access in parent Destroy /datum/ai_controller/npc_ship/Destroy(force) cleanup_boarding_signals() + cancel_boarding_orders() UnpossessPawn(FALSE) if(ai_status) GLOB.ai_controllers_by_status[ai_status] -= src @@ -439,6 +444,8 @@ // half-elapsed wipe timer over onto whoever we point at next. if(old_target != target) cleanup_boarding_signals() + // Boarders not yet built for the old target are never sent + cancel_boarding_orders() boarding_context = null boarding_budget = 0 boarding_committed = 0 @@ -835,27 +842,26 @@ GLOB.boarding_spawn_index = 0 GLOB.boarding_spawn_total = boarder_count - // Spawn the boarders via drop pods + // Spawn the boarders via drop pods. Human boarders are built through the spawn queue first, so they + // join the wave (track_wave_boarder()) as each one goes in its pod. var/list/wave_boarders = list() + blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] = wave_boarders + wave_orders.Cut() + var/launched = 0 for(var/i in 1 to boarder_count) if(!length(valid_turfs)) break var/turf/spawn_loc = pick(valid_turfs) var/mob_type = pick(mob_types) - // Create boarding pod with the mob inside - returns the boarder for tracking - var/mob/living/boarder = create_boarding_pod(spawn_loc, target, ship, mob_type) - - if(boarder) - // Track this boarder and register death signal - wave_boarders += boarder - RegisterSignal(boarder, COMSIG_LIVING_DEATH, PROC_REF(on_boarder_death)) + var/sent = create_boarding_pod(spawn_loc, target, ship, mob_type, on_boarder = CALLBACK(src, PROC_REF(track_wave_boarder), wave_boarders)) + if(istype(sent, /datum/human_npc_order)) + wave_orders += sent + if(sent) + launched++ // Note: Patrol setup is handled by the boarding pod's open_pod() proc - log_shuttle("PATROL: Wave [wave_number] launched [length(wave_boarders)] boarders via drop pods") - - // Store the wave boarders for tracking - blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] = wave_boarders + log_shuttle("PATROL: Wave [wave_number] launched [launched] boarders via drop pods") // Track wave start time and target position (for time limit and movement detection) set_blackboard_key(BB_NPC_BOARDING_WAVE_START_TIME, world.time) @@ -865,10 +871,43 @@ // Notify target ship target.ship_notify("Hostiles have boarded!", "SECURITY", SHIP_NOTIFY_DANGER, 'voidcrew/sound/warn3.ogg', 25) - npc_metric_boarding_wave(src, wave_number, length(wave_boarders)) + npc_metric_boarding_wave(src, wave_number, launched) return TRUE +/// A wave boarder is in its pod: it counts toward `wave` (the list it was sent with) until it is out of +/// the fight. Dead, in crit or cuffed is out (owner ruling OC-7). +/datum/ai_controller/npc_ship/proc/track_wave_boarder(list/wave, mob/living/boarder) + prune_boarding_orders() + if(QDELETED(boarder) || wave != blackboard[BB_NPC_BOARDING_WAVE_BOARDERS]) + return + wave += boarder + RegisterSignal(boarder, COMSIG_LIVING_DEATH, PROC_REF(on_boarder_death)) + if(ishumannpc(boarder)) + RegisterSignals(boarder, list(COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED)), PROC_REF(on_boarder_out_of_fight)) + +/// Remembers a human boarder still waiting to be built, so the encounter can call it off. +/datum/ai_controller/npc_ship/proc/track_boarding_order(datum/human_npc_order/order) + if(!QDELETED(order) && !order.done) + boarding_orders += order + +/// Drops orders that have been built or cancelled. +/datum/ai_controller/npc_ship/proc/prune_boarding_orders() + for(var/datum/human_npc_order/order as anything in boarding_orders.Copy()) + if(QDELETED(order) || order.done) + boarding_orders -= order + for(var/datum/human_npc_order/order as anything in wave_orders.Copy()) + if(QDELETED(order) || order.done) + wave_orders -= order + +/// Calls off every boarder of this encounter that hasn't been built yet. +/datum/ai_controller/npc_ship/proc/cancel_boarding_orders() + for(var/datum/human_npc_order/order as anything in boarding_orders) + if(!QDELETED(order)) + qdel(order) + boarding_orders.Cut() + wave_orders.Cut() + /** * Find valid turfs on the target ship for spawning boarders. */ @@ -904,7 +943,7 @@ */ /datum/ai_controller/npc_ship/proc/on_boarder_death(mob/living/victim, gibbed) SIGNAL_HANDLER - UnregisterSignal(victim, COMSIG_LIVING_DEATH) + UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) var/list/wave_boarders = blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] if(wave_boarders) @@ -913,8 +952,14 @@ // Check if all boarders are dead INVOKE_ASYNC(src, PROC_REF(check_wave_complete)) +/// A human boarder went into crit or was cuffed: out of the fight, so the wave may be over. +/datum/ai_controller/npc_ship/proc/on_boarder_out_of_fight(mob/living/source) + SIGNAL_HANDLER + INVOKE_ASYNC(src, PROC_REF(check_wave_complete)) + /** - * Check if the current wave is complete (all boarders dead). + * Check if the current wave is complete: every boarder is out of the fight (dead, in crit or cuffed, + * owner ruling OC-7) and none is still waiting to be built. */ /datum/ai_controller/npc_ship/proc/check_wave_complete() // Guard against multiple calls - only process if we're in BOARDING state @@ -922,12 +967,16 @@ if(combat_state != NPC_COMBAT_BOARDING || !validate_boarding_target()) return + prune_boarding_orders() + if(length(wave_orders)) + return // Boarders still on their way + var/list/wave_boarders = blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] - // Count living boarders + // Count boarders still fighting var/living_count = 0 for(var/mob/living/boarder as anything in wave_boarders) - if(!QDELETED(boarder) && boarder.stat != DEAD) + if(!human_npc_out_of_fight(boarder)) living_count++ if(living_count > 0) @@ -981,7 +1030,7 @@ // Notify about cooldown var/next_wave = completed_wave + 1 - target.ship_notify("Wave [completed_wave] repelled! Next wave in 30 seconds...", "COMBAT", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) + target.ship_notify("Boarders repelled!", "COMBAT", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 50) // Schedule next wave boarding_timers += addtimer(CALLBACK(src, PROC_REF(end_wave_cooldown), next_wave), NPC_BOARDING_WAVE_COOLDOWN, TIMER_STOPPABLE) @@ -1071,24 +1120,32 @@ if(!boss_type) return - // Create boss boarding pod - returns the boss for tracking - // Pod handles parent_ship, AI controller swap, and patrol assignment - var/mob/living/basic/trooper/pirate/faction/boss/boss = create_boss_boarding_pod(spawn_loc, target, ship, boss_type) - if(boss) - set_blackboard_key(BB_NPC_BOARDING_BOSS, boss) - RegisterSignal(boss, COMSIG_LIVING_DEATH, PROC_REF(on_boss_death)) - npc_metric_boss_deployed(src, boss) + // Create boss boarding pod. The pod gives the boss its parent_ship; track_boss() follows it from + // the moment it is in its pod (a human boss is built through the spawn queue first). + create_boss_boarding_pod(spawn_loc, target, ship, boss_type, CALLBACK(src, PROC_REF(track_boss))) // Announce boss arrival target.ship_notify("WARNING: Enemy Commander incoming via drop pod!", "SECURITY", SHIP_NOTIFY_DANGER, 'voidcrew/sound/warn3.ogg', 25) +/// The boss is in its pod: watched until it is out of the fight. +/datum/ai_controller/npc_ship/proc/track_boss(mob/living/boss) + prune_boarding_orders() + if(QDELETED(boss)) + return + set_blackboard_key(BB_NPC_BOARDING_BOSS, boss) + RegisterSignal(boss, COMSIG_LIVING_DEATH, PROC_REF(on_boss_death)) + // A human boss in crit or cuffed is beaten as surely as a dead one (owner ruling OC-7) + if(ishumannpc(boss)) + RegisterSignals(boss, list(COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED)), PROC_REF(on_boss_death)) + npc_metric_boss_deployed(src, boss) + /** - * Signal handler for when the boss is killed. + * Signal handler for when the boss is killed, or a human boss is downed or cuffed. * This disables the pirate ship and allows players to board it. */ /datum/ai_controller/npc_ship/proc/on_boss_death(mob/living/victim, gibbed) SIGNAL_HANDLER - UnregisterSignal(victim, COMSIG_LIVING_DEATH) + UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) INVOKE_ASYNC(src, PROC_REF(handle_boss_killed)) @@ -1333,6 +1390,8 @@ boarding_timers.Cut() boarding_committed -= boarding_pending boarding_pending = 0 + // Boarders still being built land as part of the raid, outside any wave + wave_orders.Cut() // Unregister from player crew deaths var/list/tracked_crew = blackboard[BB_NPC_BOARDING_PLAYER_CREW] @@ -1345,11 +1404,11 @@ var/list/wave_boarders = blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] for(var/mob/living/boarder as anything in wave_boarders) if(!QDELETED(boarder)) - UnregisterSignal(boarder, COMSIG_LIVING_DEATH) + UnregisterSignal(boarder, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) var/mob/living/boss = blackboard[BB_NPC_BOARDING_BOSS] if(!QDELETED(boss)) - UnregisterSignal(boss, COMSIG_LIVING_DEATH) + UnregisterSignal(boss, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) // Clear boarding blackboard keys clear_blackboard_key(BB_NPC_BOARDING_WAVE) @@ -1397,13 +1456,14 @@ return npc_metric_wave_ended(src, current_wave, "timed_out") - // Clean up current wave boarders + // Clean up current wave boarders; any still on their way land as part of the raid, outside the wave var/list/wave_boarders = blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] if(wave_boarders) for(var/mob/living/boarder as anything in wave_boarders) if(!QDELETED(boarder)) - UnregisterSignal(boarder, COMSIG_LIVING_DEATH) + UnregisterSignal(boarder, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) clear_blackboard_key(BB_NPC_BOARDING_WAVE_BOARDERS) + wave_orders.Cut() if(is_red_zone_raid()) start_boss_cooldown() @@ -1411,8 +1471,9 @@ end_yellow_raid() /** - * Check if any boarders have fallen into space and clean them up. - * This prevents boarders from being "stuck" floating in space after being spaced. + * Boarders that have fallen into space are off the ship and out of this wave. Nothing kills them: space + * does that to an unsuited body, and a suited one drifts until its crew gives it up (pirate_crew.dm, + * check_adrift()). A wave whose last boarders were spaced is over. */ /datum/ai_controller/npc_ship/proc/check_boarders_in_space() var/list/wave_boarders = blackboard[BB_NPC_BOARDING_WAVE_BOARDERS] @@ -1421,21 +1482,17 @@ var/list/spaced_boarders = list() for(var/mob/living/boarder as anything in wave_boarders) - if(QDELETED(boarder)) + if(QDELETED(boarder) || boarder.stat == DEAD) continue - var/turf/boarder_turf = get_turf(boarder) - if(!boarder_turf) - continue - // Check if boarder is in space (not on a ship) - if(isspaceturf(boarder_turf) || istype(get_area(boarder_turf), /area/space)) + if(pirate_crew_member_adrift(boarder)) spaced_boarders += boarder - // Kill spaced boarders (they suffocate in space anyway) + if(!length(spaced_boarders)) + return for(var/mob/living/spaced as anything in spaced_boarders) - if(!QDELETED(spaced) && spaced.stat != DEAD) - spaced.death() - wave_boarders -= spaced - UnregisterSignal(spaced, COMSIG_LIVING_DEATH) + wave_boarders -= spaced + UnregisterSignal(spaced, list(COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_DOWNED, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED))) + check_wave_complete() /// Snapshot the target band and connected crew exactly once, including combat-only raids. /datum/ai_controller/npc_ship/proc/capture_boarding_encounter(obj/structure/overmap/ship/target) @@ -1451,6 +1508,7 @@ return reserved /// Timer callbacks must still belong to this encounter after docking or target changes. +/// Returns the boarder, or the order of a human one still being built. /datum/ai_controller/npc_ship/proc/deploy_combat_boarder(turf/spot, obj/structure/overmap/ship/target, mob_type, datum/npc_spawn_context/encounter) if(get_target() != target || boarding_context != encounter) return @@ -1464,10 +1522,6 @@ var/list/spots = get_target_spawn_turfs(target) if(!istype(ship) || !ship.boss_type || !length(spots)) return FALSE - var/mob/living/boss = create_boss_boarding_pod(pick(spots), target, ship, ship.boss_type) - if(!boss) + if(!create_boss_boarding_pod(pick(spots), target, ship, ship.boss_type, CALLBACK(src, PROC_REF(track_boss)))) return FALSE - set_blackboard_key(BB_NPC_BOARDING_BOSS, boss) - RegisterSignal(boss, COMSIG_LIVING_DEATH, PROC_REF(on_boss_death)) - npc_metric_boss_deployed(src, boss) return TRUE diff --git a/voidcrew/modules/npc_ships/code/pirate_crew.dm b/voidcrew/modules/npc_ships/code/pirate_crew.dm index fb5cb472298..3b22b4afad8 100644 --- a/voidcrew/modules/npc_ships/code/pirate_crew.dm +++ b/voidcrew/modules/npc_ships/code/pirate_crew.dm @@ -8,8 +8,15 @@ * * Once alerted, a crew knows where every hostile on its battlefield is, refreshed every few * seconds, and sends its hunters after them. Players can dodge but can't hide. The captain - * and one guard hold the helm until most of the crew is dead. The per-mob behaviour lives in - * pirate_crew_ai.dm. + * and one guard hold the helm until most of the crew is out of the fight. The per-mob behaviour + * lives in pirate_crew_ai.dm, and for the human NPC crews every NPC ship now has, in + * pirate_crew_human.dm. + * + * A member counts as out of the fight once it is dead, in crit or cuffed (human_npc_out_of_fight(), + * owner ruling OC-7): it no longer hunts or holds the helm. A member adrift in space with no player + * near enough to see it is cleaned up after PIRATE_CREW_ADRIFT_TIME (check_adrift()): it can't get + * back aboard, and a body drifting alive forever would hold its crew open. That is bookkeeping, not a + * death: one drifting where a player can see it stays. */ /// Defending crews, by REF() of the NPC hull they crew @@ -69,8 +76,10 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( var/datum/pirate_crew/crew = registry[REF(ship)] return QDELETED(crew) ? null : crew -/// The controller a pirate gets when it joins a crew: ranged if it was a ranged trooper -/proc/pirate_crew_controller_type(mob/living/basic/recruit) +/// The controller a pirate gets when it joins a crew: the human one for a human NPC, else ranged if it was a ranged trooper +/proc/pirate_crew_controller_type(mob/living/recruit) + if(ishumannpc(recruit)) + return /datum/ai_controller/basic_controller/pirate_crew/human var/datum/ai_controller/current = recruit?.ai_controller if(istype(current, /datum/ai_controller/basic_controller/trooper/ranged) || istype(current, /datum/ai_controller/basic_controller/pirate_crew/ranged)) return /datum/ai_controller/basic_controller/pirate_crew/ranged @@ -78,15 +87,23 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( /// What `speaker` says for a PIRATE_BARK_* moment. Faction pirates may carry their own lines. /proc/pirate_crew_bark_lines(mob/living/speaker, kind) + var/list/alert_lines + var/list/spot_lines var/mob/living/basic/trooper/pirate/faction/faction_pirate = speaker + var/mob/living/carbon/human/npc/ship_crew/crewman = speaker if(istype(faction_pirate)) - switch(kind) - if(PIRATE_BARK_ALERT) - if(length(faction_pirate.crew_alert_lines)) - return faction_pirate.crew_alert_lines - if(PIRATE_BARK_SPOT) - if(length(faction_pirate.crew_spot_lines)) - return faction_pirate.crew_spot_lines + alert_lines = faction_pirate.crew_alert_lines + spot_lines = faction_pirate.crew_spot_lines + else if(istype(crewman)) + alert_lines = crewman.crew_alert_lines + spot_lines = crewman.crew_spot_lines + switch(kind) + if(PIRATE_BARK_ALERT) + if(length(alert_lines)) + return alert_lines + if(PIRATE_BARK_SPOT) + if(length(spot_lines)) + return spot_lines switch(kind) if(PIRATE_BARK_ALERT) return GLOB.pirate_crew_alert_lines @@ -153,6 +170,8 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( var/battlefield_dirty = TRUE /// Whether the raiding crew has had its arrival line var/arrival_barked = FALSE + /// Members adrift in space, mob -> world.time they were first found adrift + var/list/adrift_since = list() COOLDOWN_DECLARE(intel_cooldown) COOLDOWN_DECLARE(battlefield_cooldown) COOLDOWN_DECLARE(spot_bark_cooldown) @@ -196,6 +215,7 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( controller.reset_ai_status() members.Cut() home_turfs.Cut() + adrift_since.Cut() for(var/mob/living/hostile in known_hostiles) UnregisterSignal(hostile, COMSIG_QDELETING) known_hostiles.Cut() @@ -212,7 +232,7 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( * role. A defending crew's first non-captain becomes the guard; a melee recruit takes the * job off a ranged guard while the crew is still calm. */ -/datum/pirate_crew/proc/enlist(mob/living/basic/recruit, role = PIRATE_ROLE_HUNTER) +/datum/pirate_crew/proc/enlist(mob/living/recruit, role = PIRATE_ROLE_HUNTER) if(QDELETED(recruit) || recruit.stat == DEAD) return FALSE if(raiding || isnull(role)) @@ -225,7 +245,6 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( home_turfs -= recruit return FALSE if(!already_member) - pirate_crew_arm(recruit) RegisterSignal(recruit, COMSIG_LIVING_DEATH, PROC_REF(on_member_lost)) RegisterSignal(recruit, COMSIG_QDELETING, PROC_REF(on_member_lost)) if(!raiding && role != PIRATE_ROLE_CAPTAIN) @@ -241,10 +260,15 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( return TRUE /// Puts `member` on a pirate crew controller wired to this crew. Returns the controller. -/datum/pirate_crew/proc/attach_controller(mob/living/basic/member, role) +/datum/pirate_crew/proc/attach_controller(mob/living/member, role) var/datum/ai_controller/basic_controller/pirate_crew/controller = swap_basic_ai_controller(member, pirate_crew_controller_type(member)) if(!istype(controller)) return null + // A human NPC's family decides who it keeps attacking (pirates: until hard crit); a fresh controller + // starts on its type's default + if(ishumannpc(member)) + var/mob/living/carbon/human/npc/human_member = member + controller.set_blackboard_key(BB_TARGET_MINIMUM_STAT, human_member.npc_target_stat) // A member handed over from another crew starts this crew's bookkeeping afresh if(controller.blackboard[BB_PIRATE_CREW] != src) controller.last_battlefield_turf = null @@ -282,8 +306,10 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( set_role(guard, PIRATE_ROLE_HUNTER) set_role(recruit, PIRATE_ROLE_GUARD) -/// Whether `member` fights at range +/// Whether `member` fights at range: a human carrying a gun it may use, or a basic mob on the ranged controller /proc/pirate_crew_is_ranged(mob/living/member) + if(ishuman(member)) + return pirate_crew_human_gunner(member) return istype(member?.ai_controller, /datum/ai_controller/basic_controller/pirate_crew/ranged) /datum/pirate_crew/proc/on_member_lost(mob/living/source) @@ -296,6 +322,7 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( UnregisterSignal(member, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING)) members -= member home_turfs -= member + adrift_since -= member if(!length(members)) qdel(src) @@ -489,7 +516,8 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( /datum/pirate_crew/proc/assign_hunts() var/list/hunters = list() for(var/mob/living/member in members) - if(QDELETED(member) || member.stat == DEAD || !member.ai_controller) + // Down or cuffed: not hunting anyone + if(QDELETED(member) || human_npc_out_of_fight(member) || !member.ai_controller) continue if(holds_post(member)) member.ai_controller.clear_blackboard_key(BB_PIRATE_HUNT_TARGET) @@ -573,13 +601,13 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( controller.can_idle = TRUE controller.reset_ai_status() -/// The captain and the guard join the hunt once the rest of the crew is mostly dead +/// The captain and the guard join the hunt once the rest of the crew is mostly out of the fight /datum/pirate_crew/proc/check_promotion() if(promoted || raiding) return var/living = 0 for(var/mob/living/member in members) - if(members[member] != PIRATE_ROLE_CAPTAIN && member.stat != DEAD) + if(members[member] != PIRATE_ROLE_CAPTAIN && !human_npc_out_of_fight(member)) living++ if(living > min(starting_crew / 3, 1)) return @@ -589,13 +617,14 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( // ===== BARKS ===== +/// A crew shout from `speaker`. Ordinary speech (defaults PI-18): a gagged or muted pirate says nothing. /datum/pirate_crew/proc/bark(mob/living/speaker, kind) - if(QDELETED(speaker) || speaker.stat != CONSCIOUS) + if(QDELETED(speaker) || speaker.stat != CONSCIOUS || !speaker.can_speak()) return FALSE var/list/lines = pirate_crew_bark_lines(speaker, kind) if(!length(lines)) return FALSE - INVOKE_ASYNC(speaker, TYPE_PROC_REF(/atom/movable, say), message = pick(lines), forced = "pirate crew") + INVOKE_ASYNC(speaker, TYPE_PROC_REF(/atom/movable, say), message = pick(lines)) return TRUE /// `member` saw `target`: tell the crew, and maybe shout about it @@ -630,6 +659,9 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( if(battlefield_dirty || COOLDOWN_FINISHED(src, battlefield_cooldown)) refresh_battlefield() check_members() + if(QDELETED(src)) + return PROCESS_KILL + check_adrift() if(QDELETED(src)) return PROCESS_KILL if(!alerted) @@ -673,6 +705,40 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( if(QDELETED(src)) return +/** + * Cleans up members adrift in space that no player can see, once they have drifted for + * PIRATE_CREW_ADRIFT_TIME. Space kills a pirate without a suit on its own; a suited one would drift + * alive forever, off its battlefield, holding its crew and its hull's pool slot open. A human NPC goes + * the way one that walks out does (npc_vanish(): what it carries that isn't gear, a ship key, drops where + * it was). See the top of this file. + */ +/datum/pirate_crew/proc/check_adrift() + for(var/mob/living/member as anything in members.Copy()) + if(QDELETED(member) || member.stat == DEAD || GET_CLIENT(member) || !pirate_crew_member_adrift(member)) + adrift_since -= member + continue + if(isnull(adrift_since[member])) + adrift_since[member] = world.time + continue + if(world.time < adrift_since[member] + PIRATE_CREW_ADRIFT_TIME || human_npc_watched(member)) + continue + log_shuttle("PIRATE_CREW: [member] was lost adrift at [AREACOORD(member)]") + remove_member(member) + if(ishumannpc(member)) + var/mob/living/carbon/human/npc/lost = member + lost.npc_vanish() + else + qdel(member) + if(QDELETED(src)) + return + +/// Whether `member` is floating in space, off any ship's deck +/proc/pirate_crew_member_adrift(mob/living/member) + var/turf/where = get_turf(member) + if(isnull(where) || !isturf(member.loc)) + return FALSE + return isspaceturf(where) || istype(get_area(where), /area/space) + // ===== HOME SHIP SIGNALS ===== /datum/pirate_crew/proc/on_home_ship_deleted(datum/source) @@ -716,9 +782,12 @@ GLOBAL_LIST_INIT(pirate_crew_captain_lines, list( behavior_names += replacetext("[behavior.type]", "/datum/ai_behavior/", "") text += ": [controller.ai_status][controller.can_idle ? " (may idle)" : ""][controller.able_to_plan ? "" : " (planning blocked)"][controller.on_failed_planning_timeout ? " (planning timeout)" : ""]" text += ", doing [length(behavior_names) ? jointext(behavior_names, " + ") : "nothing"]" - var/datum/component/pirate_gun/gun = pirate_gun_of(member) - if(gun) - text += ", [gun.status_text()]" + if(ishuman(member)) + var/obj/item/gun/gun = human_npc_held_gun(member) + if(gun) + text += ", holding [gun][gun.can_shoot() ? "" : " (empty)"]" + if(human_npc_out_of_fight(member)) + text += " (out of the fight)" var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] text += ", target [target ? "[target]" : "none"]" var/mob/hunt = controller.blackboard[BB_PIRATE_HUNT_TARGET] diff --git a/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm b/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm index 252035ceb11..117c605f675 100644 --- a/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm +++ b/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm @@ -4,6 +4,10 @@ * Fights whoever it can see, breaks through doors that won't open for it, holds the helm if it * is the captain or the guard, and otherwise walks to wherever the crew last knew a hostile to * be. Doors it may open are walked through, never hit. Allies in a corridor swap places. + * + * NPC ship crews and boarders are human NPCs and run the human controller (pirate_crew_human.dm), + * which fights and breaks doors with the body's own hands. The basic-mob controllers below are what + * a basic pirate gets if it is enlisted (an admin spawn, a basic boarding type). */ // ===== CONTROLLERS ===== @@ -67,8 +71,6 @@ * or away from the helm, since nothing would get to check. */ /datum/ai_planning_subtree/basic_ranged_attack_subtree/trooper/pirate_crew - // Stock gunfire, or a rolled pirate gun (pirate_guns.dm) - operational_datums = list(/datum/component/ranged_attacks, /datum/component/pirate_gun) ranged_attack_behavior = /datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew /datum/ai_planning_subtree/basic_ranged_attack_subtree/trooper/pirate_crew/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) @@ -78,20 +80,12 @@ var/behavior = ranged_attack_behavior if(istype(pirate_controller) && pirate_controller.target_hold_ground) behavior = /datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew/hold_ground - else - var/datum/component/pirate_gun/gun = pirate_gun_of(controller.pawn) - if(gun?.loadout.close_range) - behavior = /datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew/close controller.queue_behavior(behavior, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) return SUBTREE_RETURN_FINISH_PLANNING /datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION -/// A shotgunner walks in close before it fires -/datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew/close - required_distance = 3 - /// Returns fire at someone it may not go after (off the battlefield, or past the helm's leash) without taking a step towards them /datum/ai_behavior/basic_ranged_attack/trooper/pirate_crew/hold_ground required_distance = PIRATE_CREW_SIGHT_RANGE @@ -129,8 +123,7 @@ if(QDELETED(target)) return FALSE if(istype(src, /datum/ai_controller/basic_controller/pirate_crew/ranged)) - var/datum/component/pirate_gun/gun = pirate_gun_of(pawn) - return can_see(pawn, target, gun?.loadout.close_range || 5) + return can_see(pawn, target, 5) return get_dist(pawn, target) <= 1 // ----- Door bookkeeping ----- @@ -155,12 +148,16 @@ set_blackboard_key(BB_PIRATE_BREACH_STAND, stand || get_turf(pawn)) set_blackboard_key(BB_PIRATE_BREACH_TURF, get_turf(target)) set_blackboard_key(BB_PIRATE_BREACH_STARTED, world.time) + clear_blackboard_key(BB_PIRATE_BREACH_INTEGRITY) + clear_blackboard_key(BB_PIRATE_BREACH_PROGRESS_AT) /datum/ai_controller/basic_controller/pirate_crew/proc/clear_breach() clear_blackboard_key(BB_PIRATE_BREACH_TARGET) clear_blackboard_key(BB_PIRATE_BREACH_STAND) clear_blackboard_key(BB_PIRATE_BREACH_TURF) clear_blackboard_key(BB_PIRATE_BREACH_STARTED) + clear_blackboard_key(BB_PIRATE_BREACH_INTEGRITY) + clear_blackboard_key(BB_PIRATE_BREACH_PROGRESS_AT) // ----- Pawn signals ----- @@ -427,8 +424,15 @@ if(isliving(requester) && istype(requester.ai_controller, /datum/ai_controller/basic_controller/pirate_crew)) return TRUE -/// Whether breaking `door` would open the ship to space: an external airlock or blast door with nothing beyond it +/** + * Whether breaking `door` would open the ship to space: an external airlock or blast door with nothing + * beyond it, or a firelock holding back a room that has lost its air. + */ /proc/pirate_door_vents_space(obj/machinery/door/door, turf/from) + if(istype(door, /obj/machinery/door/firedoor)) + var/turf/firelock_turf = get_turf(door) + var/turf/beyond = (from && firelock_turf) ? get_turf_through_door(from, firelock_turf) : null + return beyond && !is_safe_turf(beyond, TRUE, FALSE) if(!istype(door, /obj/machinery/door/airlock/external) && !istype(door, /obj/machinery/door/poddoor)) return FALSE var/turf/door_turf = get_turf(door) @@ -627,7 +631,7 @@ * battlefield or only just off it on solid ground, and within PIRATE_CREW_POST_LEASH of the helm * for the captain and the guard. Past that a gunner standing on the battlefield still shoots back * at a player from where it stands (PIRATE_TARGET_HOLD); anything else is let go (PIRATE_TARGET_OUT). - * Nobody follows a target into space, where the spaced-crew sweeps would kill them. + * Nobody follows a target into space: a crewman adrift is lost to the crew (pirate_crew.dm). */ /datum/ai_planning_subtree/pirate_crew_find_target/proc/target_reach(datum/ai_controller/basic_controller/pirate_crew/controller, datum/pirate_crew/crew, atom/target) if(!crew) @@ -637,7 +641,7 @@ return PIRATE_TARGET_OUT var/mob/living/pawn = controller.pawn var/fallback = PIRATE_TARGET_OUT - if(istype(controller, /datum/ai_controller/basic_controller/pirate_crew/ranged) && crew.turf_on_battlefield(get_turf(pawn)) && pirate_crew_player_target(target)) + if(pirate_crew_is_ranged(pawn) && crew.turf_on_battlefield(get_turf(pawn)) && pirate_crew_player_target(target)) fallback = PIRATE_TARGET_HOLD if(crew.holds_post(pawn)) var/turf/post = crew.post_for(pawn) @@ -739,8 +743,14 @@ if(!pirate_door_breachable(blocker, stand)) controller.skip_door(blocker) return FALSE + // Given up once a stretch goes by with the door taking no damage at all: a human shooting a door + // open takes longer than a basic mob smashing it, but gets somewhere. var/started = controller.blackboard[BB_PIRATE_BREACH_STARTED] || world.time - if(world.time > started + PIRATE_CREW_BREACH_GIVE_UP) + if(blocker.atom_integrity < (controller.blackboard[BB_PIRATE_BREACH_INTEGRITY] || INFINITY)) + controller.set_blackboard_key(BB_PIRATE_BREACH_INTEGRITY, blocker.atom_integrity) + controller.set_blackboard_key(BB_PIRATE_BREACH_PROGRESS_AT, world.time) + var/progress_at = max(started, controller.blackboard[BB_PIRATE_BREACH_PROGRESS_AT]) + if(world.time > progress_at + PIRATE_CREW_BREACH_GIVE_UP) log_shuttle("PIRATE_CREW: [pawn] gave up breaking [blocker] at [AREACOORD(blocker)]") controller.skip_door(blocker) return FALSE @@ -910,12 +920,28 @@ /datum/ai_behavior/pirate_crew_breach/perform(seconds_per_tick, datum/ai_controller/controller, target_key, stand_key) var/obj/blocker = controller.blackboard[target_key] - var/mob/living/basic/pawn = controller.pawn + var/mob/living/pawn = controller.pawn if(QDELETED(blocker) || !blocker.density) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED if(istype(blocker, /obj/machinery/door) && pirate_can_open_door(pawn, blocker)) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED - if(!isbasicmob(pawn) || !pawn.Adjacent(blocker)) + if(ishuman(pawn)) + // A person's hands: pry it, shoot it from a step back, or hit it (pirate_crew_human.dm) + var/turf/stand = controller.blackboard[stand_key] || get_turf(pawn) + var/turf/spot = pirate_crew_human_breach_spot(pawn, blocker, stand) + if(controller.current_movement_target != spot) + set_movement_target(controller, spot) + if(get_turf(pawn) != spot && !(spot == stand && pawn.Adjacent(blocker))) + return AI_BEHAVIOR_DELAY + if(!pirate_crew_human_breach(controller, pawn, blocker)) + // Nothing it carries can hurt this door: leave it to someone who can + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + pirate_controller.skip_door(blocker) + pirate_controller.clear_breach() + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + var/mob/living/basic/basic_pawn = pawn + if(!isbasicmob(basic_pawn) || !basic_pawn.Adjacent(blocker)) return AI_BEHAVIOR_DELAY - pawn.melee_attack(blocker) + basic_pawn.melee_attack(blocker) return AI_BEHAVIOR_DELAY diff --git a/voidcrew/modules/npc_ships/code/pirate_crew_human.dm b/voidcrew/modules/npc_ships/code/pirate_crew_human.dm new file mode 100644 index 00000000000..0656da17770 --- /dev/null +++ b/voidcrew/modules/npc_ships/code/pirate_crew_human.dm @@ -0,0 +1,474 @@ +/** + * Pirate crew AI on human NPC bodies (human NPC migration, phase B). + * + * The crew AI itself is PR #500's (pirate_crew.dm, pirate_crew_ai.dm): the crew's intel, the captain and + * guard at the helm, the hunt, door breaking that never vents a ship, swapping places in corridors. This + * file gives it a human body's hands, through the human NPC base (voidcrew/modules/human_npcs/): + * - it runs in soft crit and while cuffed (human_npc_ai_able_to_run()), with the soft-crit branch first, + * so a downed pirate crawls, pleads and, if it is an elite or a boss, fires wild, and a cuffed one + * works at its cuffs; + * - gunners fire the real gun in their hands in volleys (pirate_guns.dm), reload from the magazines and + * shells they carry the way a player does, and fight with a knife or their fists when they run dry; + * - doors that won't open are pried with a crowbar or a fireaxe it carries (an unpowered airlock, a + * firelock), else shot, else hit with a blade heavy enough to dent them; + * - captains and bosses inject the stimulant they carry when their fight starts; a boss with a flask + * drinks from it when hurt; a boss gunner puts its gun to the head of a hunter who is down (the + * hyperviolence execution, with its wind-up the crew can interrupt), once a minute at most. + * Everything goes through ai_interact(), a player's click. + */ + +/datum/ai_controller/basic_controller/pirate_crew/human + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/human_npc, + BB_TARGET_MINIMUM_STAT = HARD_CRIT, + BB_AGGRO_RANGE = PIRATE_CREW_SIGHT_RANGE, + BB_HUMAN_NPC_SHOT_DELAY = 3 SECONDS, + BB_HUMAN_NPC_FIRE_RANGE = 7, + BB_HUMAN_NPC_KEEP_AWAY = 2, + ) + // Holds still through its own do_afters: a cuff breakout, a gun to someone's head + ai_traits = PAUSE_DURING_DO_AFTER + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/escape_captivity, + /datum/ai_planning_subtree/pirate_crew_find_target, + /datum/ai_planning_subtree/pirate_crew_human_self_care, + /datum/ai_planning_subtree/pirate_crew_doors, + /datum/ai_planning_subtree/human_npc_arm/pirate_crew, + /datum/ai_planning_subtree/pirate_crew_human_shoot, + /datum/ai_planning_subtree/human_npc_melee/pirate_crew, + /datum/ai_planning_subtree/pirate_crew_hold_post, + /datum/ai_planning_subtree/pirate_crew_hunt, + /datum/ai_planning_subtree/pirate_crew_leash, + ) + +/datum/ai_controller/basic_controller/pirate_crew/human/TryPossessPawn(atom/new_pawn) + if(!ishuman(new_pawn)) + return AI_CONTROLLER_INCOMPATIBLE + return ..() + +// The human NPC base's run check: cuffs and soft crit don't stop it (human_npc_soft_crit.dm). +/datum/ai_controller/basic_controller/pirate_crew/human/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/pirate_crew/human/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) + +// A hit during an SMG's spin-up or a shotgun's aim spoils the volley. +/datum/ai_controller/basic_controller/pirate_crew/human/on_pawn_damaged(mob/living/source, damage_dealt) + . = ..() + if(damage_dealt <= 0 || blackboard[BB_PIRATE_WINDUP_UNTIL] <= world.time) + return + clear_blackboard_key(BB_PIRATE_WINDUP_UNTIL) + set_blackboard_key(BB_PIRATE_VOLLEY_LEFT, 0) + set_blackboard_key(BB_HUMAN_NPC_NEXT_SHOT, world.time + 1 SECONDS) + +/datum/ai_controller/basic_controller/pirate_crew/human/can_hit_now(atom/target) + if(QDELETED(target)) + return FALSE + var/mob/living/carbon/npc = pawn + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(gun) + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + var/reach = handling.close_range || blackboard[BB_HUMAN_NPC_FIRE_RANGE] || 7 + return get_dist(npc, target) <= reach && can_see(npc, target, reach) + return npc.Adjacent(target) + +/// Whether a human crewman fights at range: it holds or carries a gun it can fire, and may use guns. +/proc/pirate_crew_human_gunner(mob/living/carbon/human/npc) + if(HAS_TRAIT(npc, TRAIT_NOGUNS)) + return FALSE + if(locate(/obj/item/gun) in npc.held_items) + return TRUE + return length(npc.get_all_contents_type(/obj/item/gun)) > 0 + +// ===== ARMING: the base's reload and draw, with the crew's reload shouts ===== + +/datum/ai_planning_subtree/human_npc_arm/pirate_crew + +/datum/ai_planning_subtree/human_npc_arm/pirate_crew/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/gun = human_npc_held_gun(npc) + var/was_dry = gun && !gun.can_shoot() + // A pump racked between shells is no reload worth shouting about + var/just_racking = istype(gun, /obj/item/gun/ballistic) && human_npc_gun_needs_rack(gun) + . = ..() + if(. != SUBTREE_RETURN_FINISH_PLANNING || !was_dry || just_racking || !prob(PIRATE_CREW_RELOAD_BARK_CHANCE)) + return + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + var/list/lines = human_npc_can_reload(npc, gun) ? handling.reload_lines : handling.dry_lines + if(length(lines) && human_npc_can_talk(npc)) + INVOKE_ASYNC(npc, TYPE_PROC_REF(/atom/movable, say), pick(lines)) + +// ===== SHOOTING ===== + +/** + * With a loaded gun in hand: fires its volleys at the target from up to the gun's range (a shotgunner + * walks in to three tiles first), stepping back from anyone too close, and closing in when it can't see + * them. A gunner told to hold its ground (target_reach()) shoots back from where it stands and never + * takes a step after them. A boss gunner whose target is down puts the gun to their head instead. + */ +/datum/ai_planning_subtree/pirate_crew_human_shoot + +/datum/ai_planning_subtree/pirate_crew_human_shoot/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(QDELETED(target)) + return + var/mob/living/carbon/human/npc/npc = controller.pawn + if(HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || LAZYLEN(npc.do_afters)) + return + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(isnull(gun)) + return + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + var/hold = istype(pirate_controller) && pirate_controller.target_hold_ground + if(!hold && pirate_crew_human_wants_execution(controller, npc, target)) + if(npc.Adjacent(target)) + controller.queue_behavior(/datum/ai_behavior/pirate_crew_human_execute, BB_BASIC_MOB_CURRENT_TARGET) + else + controller.queue_behavior(/datum/ai_behavior/human_npc_close_in, BB_BASIC_MOB_CURRENT_TARGET, 1) + return SUBTREE_RETURN_FINISH_PLANNING + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + var/fire_range = hold ? PIRATE_CREW_SIGHT_RANGE : (handling.close_range || controller.blackboard[BB_HUMAN_NPC_FIRE_RANGE] || 7) + var/distance = get_dist(npc, target) + if(distance <= fire_range && can_see(npc, target, fire_range)) + var/keep_away = controller.blackboard[BB_HUMAN_NPC_KEEP_AWAY] + if(!hold && !handling.close_range && keep_away && distance < keep_away) + controller.queue_behavior(/datum/ai_behavior/step_away, BB_BASIC_MOB_CURRENT_TARGET) + controller.queue_behavior(/datum/ai_behavior/pirate_crew_human_shoot, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + return SUBTREE_RETURN_FINISH_PLANNING + if(hold) + return + controller.queue_behavior(/datum/ai_behavior/human_npc_close_in, BB_BASIC_MOB_CURRENT_TARGET, fire_range) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * One pull of the trigger per step of a volley (pirate_guns.dm). A volley that needs a spin-up or a + * moment's aim waits for it first; a hit in that moment spoils it (the controller's on_pawn_damaged()). + * Doesn't hold up planning. + */ +/datum/ai_behavior/pirate_crew_human_shoot + action_cooldown = 0.1 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/pirate_crew_human_shoot/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/npc = controller.pawn + var/atom/target = controller.blackboard[target_key] + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) + if(QDELETED(target) || !strategy?.can_attack(npc, target)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(isnull(gun)) + // Empty now: the next plan reloads or changes weapon + controller.set_blackboard_key(BB_PIRATE_VOLLEY_LEFT, 0) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + var/windup_until = controller.blackboard[BB_PIRATE_WINDUP_UNTIL] + if(windup_until) + if(world.time < windup_until) + return AI_BEHAVIOR_DELAY + controller.clear_blackboard_key(BB_PIRATE_WINDUP_UNTIL) + controller.set_blackboard_key(BB_PIRATE_VOLLEY_LEFT, handling.shots) + if(world.time < controller.blackboard[BB_HUMAN_NPC_NEXT_SHOT]) + return AI_BEHAVIOR_DELAY + if(controller.blackboard[BB_PIRATE_VOLLEY_LEFT] <= 0) + if(handling.windup) + npc.face_atom(target) + if(handling.windup_sound) + playsound(npc, handling.windup_sound, 50, TRUE) + controller.set_blackboard_key(BB_PIRATE_WINDUP_UNTIL, world.time + handling.windup) + return AI_BEHAVIOR_DELAY + controller.set_blackboard_key(BB_PIRATE_VOLLEY_LEFT, handling.shots) + var/atom/hiding_target = strategy.find_hidden_mobs(npc, target) + controller.set_blackboard_key(hiding_location_key, hiding_target) + var/atom/final_target = hiding_target || target + if(human_npc_ally_in_line(npc, final_target)) + human_npc_sidestep(npc, final_target) + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + npc.zone_selected = pirate_crew_human_aim_zone(npc, final_target) + controller.ai_interact(target = final_target, combat_mode = TRUE) + var/volley_left = controller.blackboard[BB_PIRATE_VOLLEY_LEFT] - 1 + controller.set_blackboard_key(BB_PIRATE_VOLLEY_LEFT, volley_left) + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_SHOT, world.time + (volley_left > 0 ? handling.shot_gap : handling.volley_gap)) + return AI_BEHAVIOR_DELAY + +/// Where an ordinary shot is aimed. Never the head of someone helpless at point blank: that would be an +/// execution, which only a boss starts, and only on purpose (pirate_crew_human_execute). +/proc/pirate_crew_human_aim_zone(mob/living/shooter, atom/target) + var/zone = human_npc_pick_aim_zone() + if(zone == BODY_ZONE_HEAD && isliving(target) && shooter.Adjacent(target)) + var/mob/living/victim = target + if(victim.hv_can_be_executed()) + return BODY_ZONE_CHEST + return zone + +/// Whether a boss gunner should walk up to `target` and put its gun to their head: it is a boss (OC-9), +/// its execution isn't cooling down, and the target is someone it may execute. +/proc/pirate_crew_human_wants_execution(datum/ai_controller/controller, mob/living/carbon/human/npc/npc, atom/target) + if(!ishumannpc(npc) || npc.npc_rank < HUMAN_NPC_RANK_BOSS || !isliving(target)) + return FALSE + if(world.time < controller.blackboard[BB_PIRATE_NEXT_EXECUTION]) + return FALSE + var/mob/living/victim = target + if(victim.stat == DEAD || !victim.hv_can_be_executed()) + return FALSE + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + return gun?.hv_execution_possible(npc, victim) || (gun && !npc.Adjacent(victim)) + +/** + * A boss puts the barrel of its gun to the head of someone down beside it: the hyperviolence execution + * (executions.dm), a click aimed at the head. Its 1.5 second wind-up is a do_after, so it runs apart + * from the AI's tick, and the crew can break it by moving either of them. + */ +/datum/ai_behavior/pirate_crew_human_execute + action_cooldown = 1 SECONDS + +/datum/ai_behavior/pirate_crew_human_execute/perform(seconds_per_tick, datum/ai_controller/controller, target_key) + var/mob/living/carbon/npc = controller.pawn + var/mob/living/victim = controller.blackboard[target_key] + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(!isliving(victim) || isnull(gun) || !gun.hv_execution_possible(npc, victim) || !human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.set_blackboard_key(BB_PIRATE_NEXT_EXECUTION, world.time + PIRATE_CREW_EXECUTION_COOLDOWN) + npc.zone_selected = BODY_ZONE_HEAD + INVOKE_ASYNC(controller, TYPE_PROC_REF(/datum/ai_controller, ai_interact), victim, TRUE) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + +/// tg melee with what it holds, except against someone it was told to hold its ground against: a gunner +/// whose gun ran dry doesn't run off the battlefield or away from the helm after them. +/datum/ai_planning_subtree/human_npc_melee/pirate_crew + +/datum/ai_planning_subtree/human_npc_melee/pirate_crew/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + if(istype(pirate_controller) && pirate_controller.target_hold_ground) + return + return ..() + +// ===== STIMULANTS AND THE FLASK ===== + +/** + * Captains and bosses inject the stimulant they carry once their fight has started (they have someone + * to fight); a boss carrying a flask drinks from it when it is hurt. Real items used the way a player + * uses them: the medipen in hand, then used; the flask clicked on itself. + */ +/datum/ai_planning_subtree/pirate_crew_human_self_care + +/datum/ai_planning_subtree/pirate_crew_human_self_care/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/npc = controller.pawn + if(!ishumannpc(npc) || npc.npc_rank < HUMAN_NPC_RANK_ELITE || npc.stat != CONSCIOUS) + return + if(HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || LAZYLEN(npc.do_afters)) + return + if(!controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/obj/item/to_use = pirate_crew_human_stimulant(npc) + if(isnull(to_use) && npc.health < npc.maxHealth * PIRATE_CREW_DRINK_HEALTH_SHARE && world.time >= controller.blackboard[BB_PIRATE_NEXT_DRINK]) + to_use = pirate_crew_human_flask(npc) + if(isnull(to_use)) + return + controller.set_blackboard_key(BB_PIRATE_SELF_ITEM, to_use) + controller.queue_behavior(/datum/ai_behavior/pirate_crew_human_use_on_self, BB_PIRATE_SELF_ITEM) + return SUBTREE_RETURN_FINISH_PLANNING + +/// A stimulant medipen it carries and hasn't used, unless one is already in its blood. +/proc/pirate_crew_human_stimulant(mob/living/carbon/npc) + if(npc.reagents?.has_reagent(/datum/reagent/medicine/stimulants)) + return null + for(var/obj/item/reagent_containers/hypospray/medipen/pen in npc.get_all_contents_type(/obj/item/reagent_containers/hypospray/medipen)) + if(pen.used_up || !pen.reagents?.has_reagent(/datum/reagent/medicine/stimulants)) + continue + if(npc.is_holding(pen) || human_npc_can_draw(npc, pen)) + return pen + return null + +/// A flask with something left in it that it can take out. +/proc/pirate_crew_human_flask(mob/living/carbon/npc) + for(var/obj/item/reagent_containers/cup/glass/flask/flask in npc.get_all_contents_type(/obj/item/reagent_containers/cup/glass/flask)) + if(!flask.reagents?.total_volume) + continue + if(npc.is_holding(flask) || human_npc_can_draw(npc, flask)) + return flask + return null + +/** + * Takes the item in a free hand (letting go of a two-handed grip, or holstering a spent gun, if both are + * full), uses it on itself, puts a flask back and takes up its weapon again. + */ +/datum/ai_behavior/pirate_crew_human_use_on_self + action_cooldown = 0.8 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/pirate_crew_human_use_on_self/perform(seconds_per_tick, datum/ai_controller/controller, item_key) + var/mob/living/carbon/human/npc/npc = controller.pawn + var/obj/item/thing = controller.blackboard[item_key] + if(QDELETED(thing) || human_npc_holding(thing) != npc || npc.stat != CONSCIOUS) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!npc.is_holding(thing)) + if(!length(npc.get_empty_held_indexes()) && !pirate_crew_human_free_a_hand(npc)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!human_npc_take_in_hand(npc, thing)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, thing) || npc.next_move > world.time) + return AI_BEHAVIOR_DELAY + if(istype(thing, /obj/item/reagent_containers/hypospray/medipen)) + npc.npc_bark("stimulant", force = TRUE) + npc.execute_mode() + var/obj/item/reagent_containers/hypospray/medipen/pen = thing + if(pen.used_up && npc.is_holding(pen)) + npc.dropItemToGround(pen) + else + npc.npc_bark("drink", force = TRUE) + controller.ai_interact(target = npc, combat_mode = FALSE) + controller.set_blackboard_key(BB_PIRATE_NEXT_DRINK, world.time + PIRATE_CREW_DRINK_COOLDOWN) + if(npc.is_holding(thing)) + thing.equip_to_best_slot(npc) + for(var/obj/item/held in npc.held_items) + human_npc_ready_weapon(npc, held) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + +/datum/ai_behavior/pirate_crew_human_use_on_self/finish_action(datum/ai_controller/controller, succeeded, item_key) + . = ..() + controller.clear_blackboard_key(item_key) + if(!succeeded) + // Couldn't get at it; it won't keep trying every tick + controller.set_blackboard_key(BB_PIRATE_NEXT_DRINK, world.time + PIRATE_CREW_DRINK_COOLDOWN) + +/// Frees a hand: lets go of a two-handed grip, or holsters a gun that can't fire. Returns TRUE if a hand is free. +/proc/pirate_crew_human_free_a_hand(mob/living/carbon/npc) + for(var/obj/item/held in npc.held_items) + if(held.GetComponent(/datum/component/two_handed) && HAS_TRAIT(held, TRAIT_WIELDED)) + held.attack_self(npc) + break + if(length(npc.get_empty_held_indexes())) + return TRUE + var/obj/item/gun/spent = human_npc_held_gun(npc) + if(spent && !spent.can_shoot()) + spent.equip_to_best_slot(npc) + return length(npc.get_empty_held_indexes()) > 0 + +// ===== DOORS ===== + +/** + * A human crewman breaking through `blocker` (a door or the frame a broken door left), the ways a person + * can: prying an unpowered airlock or a firelock with a crowbar or fireaxe it carries, shooting the door + * from a step back, or hitting it with something heavy enough to dent it. Returns FALSE when it has no + * way to hurt this door, so the crew's breach gives up on it and another member can try. + */ +/proc/pirate_crew_human_breach(datum/ai_controller/controller, mob/living/carbon/human/npc, obj/blocker) + if(LAZYLEN(npc.do_afters) || HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || npc.next_move > world.time) + return TRUE + // A pry bar on a door that will give: an airlock with no power that isn't bolted or welded, or a firelock + var/obj/item/pry_tool = pirate_crew_human_pry_tool(npc) + if(pry_tool && pirate_crew_human_can_pry(blocker)) + if(npc.Adjacent(blocker) && pirate_crew_human_ready_tool(npc, pry_tool)) + // A crowbar's right click forces a firelock fully open; its left click only holds it + var/list/modifiers = (istype(blocker, /obj/machinery/door/firedoor) && pry_tool.tool_behaviour == TOOL_CROWBAR) ? list(RIGHT_CLICK = TRUE) : null + controller.ai_interact(target = blocker, combat_mode = FALSE, modifiers = modifiers) + return TRUE + // A gun, from a step back so the shot isn't a bash + var/obj/item/gun/gun = human_npc_loaded_gun(npc) + if(gun) + if(npc.Adjacent(blocker)) + step_away(npc, blocker) + return TRUE + if(get_dist(npc, blocker) <= 3 && can_see(npc, blocker, 3) && human_npc_ready_item(npc, gun)) + npc.face_atom(blocker) + controller.ai_interact(target = blocker, combat_mode = TRUE) + return TRUE + return TRUE + // A blade or a club that dents it + var/obj/item/weapon = pirate_crew_human_door_weapon(npc, blocker) + if(weapon) + if(npc.Adjacent(blocker) && human_npc_ready_item(npc, weapon)) + controller.ai_interact(target = blocker, combat_mode = TRUE) + return TRUE + return FALSE + +/** + * Where a crewman works on `blocker` from: the floor beside it (`stand`) to pry or hit it, or for a gunner + * with nothing to pry it, the floor a step further back, so the shot isn't a bash with the gun. + */ +/proc/pirate_crew_human_breach_spot(mob/living/carbon/human/npc, obj/blocker, turf/stand) + if(!isturf(stand)) + stand = get_turf(npc) + if(isnull(human_npc_loaded_gun(npc))) + return stand + if(pirate_crew_human_can_pry(blocker) && pirate_crew_human_pry_tool(npc)) + return stand + var/turf/back = get_step(stand, get_dir(blocker, stand)) + if(back && pirate_turf_standable(back) && can_see(back, blocker, 3)) + return back + return stand + +/// Whether a crowbar or a fireaxe in a person's hands would force `blocker`. +/proc/pirate_crew_human_can_pry(obj/blocker) + if(istype(blocker, /obj/machinery/door/firedoor)) + var/obj/machinery/door/firedoor/firelock = blocker + return firelock.density && !firelock.welded && !firelock.operating + if(!istype(blocker, /obj/machinery/door/airlock)) + return FALSE + var/obj/machinery/door/airlock/airlock = blocker + return airlock.density && !airlock.locked && !airlock.welded && !airlock.seal && !airlock.operating && !airlock.hasPower() + +/// A crowbar or a fireaxe it holds or can draw. +/proc/pirate_crew_human_pry_tool(mob/living/carbon/npc) + for(var/obj/item/thing as anything in npc.get_all_contents_type(/obj/item)) + if(!(thing.tool_behaviour == TOOL_CROWBAR || istype(thing, /obj/item/fireaxe))) + continue + if(npc.is_holding(thing) || human_npc_can_draw(npc, thing)) + return thing + return null + +/// Gets `tool` into the active hand and ready (a fireaxe wielded), putting away what's in the way. +/// Returns TRUE once it can be used. +/proc/pirate_crew_human_ready_tool(mob/living/carbon/npc, obj/item/tool) + if(!npc.is_holding(tool)) + if(!length(npc.get_empty_held_indexes()) && !pirate_crew_human_free_a_hand(npc)) + // Both hands still full: put the weaker of what it holds away + var/obj/item/stowed = npc.get_inactive_held_item() || npc.get_active_held_item() + if(isnull(stowed) || !stowed.equip_to_best_slot(npc)) + return FALSE + if(!human_npc_take_in_hand(npc, tool)) + return FALSE + if(istype(tool, /obj/item/fireaxe)) + human_npc_ready_weapon(npc, tool) + if(!HAS_TRAIT(tool, TRAIT_WIELDED)) + return FALSE + return human_npc_ready_item(npc, tool) + +/// A melee weapon it holds or can draw that hits `blocker` hard enough to hurt it (its damage +/// deflection), or null. +/proc/pirate_crew_human_door_weapon(mob/living/carbon/npc, obj/blocker) + var/obj/item/best + var/best_force = 0 + for(var/obj/item/thing as anything in npc.get_all_contents_type(/obj/item)) + if(!human_npc_is_melee_weapon(thing)) + continue + if(!npc.is_holding(thing) && !human_npc_can_draw(npc, thing)) + continue + var/blow = pirate_crew_human_door_blow(thing) + if(blow > best_force) + best = thing + best_force = blow + if(isnull(best) || best_force < blocker.damage_deflection) + return null + if(!npc.is_holding(best)) + if(!length(npc.get_empty_held_indexes()) || !human_npc_take_in_hand(npc, best)) + return null + human_npc_ready_weapon(npc, best) + return best + +/// What `weapon` hits a door for, as it would be swung: lit, wielded, and with its demolition modifier. +/proc/pirate_crew_human_door_blow(obj/item/weapon) + var/force = weapon.force + var/datum/component/transforming/transforming = weapon.GetComponent(/datum/component/transforming) + if(transforming && !HAS_TRAIT(weapon, TRAIT_TRANSFORM_ACTIVE)) + force = max(force, transforming.force_on) + var/datum/component/two_handed/two_handed = weapon.GetComponent(/datum/component/two_handed) + if(two_handed && !HAS_TRAIT(weapon, TRAIT_WIELDED)) + force = max(force, two_handed.force_wielded) + return force * weapon.demolition_mod diff --git a/voidcrew/modules/npc_ships/code/pirate_guns.dm b/voidcrew/modules/npc_ships/code/pirate_guns.dm index 9c98550f815..80ba300a4c7 100644 --- a/voidcrew/modules/npc_ships/code/pirate_guns.dm +++ b/voidcrew/modules/npc_ships/code/pirate_guns.dm @@ -1,359 +1,104 @@ /** - * Pirate crew guns. + * Pirate crew guns: how a human pirate handles the real gun in its hands. * - * A ranged pirate rolls a gun when it joins a crew (pirate_crew_arm()), weighted by its faction: an - * energy gun, a laser rifle, a pistol, an SMG or a shotgun. The gun shows in its hand and fires the - * real gun's rounds: a ballistic gun's are read off the item by bounty_real_weapon() (bounties - * module), with its magazine, pellets, spread and damage multiplier; an energy gun fires its - * casing's beam. Nothing is spawned for the fight and nothing drops. - * - * Each loadout has a magazine and a reload, and the SMG and shotgun wind up before they fire (a - * hit during the wind-up spoils it). The shotgun walks in close before it fires. Every loadout - * deals about what the old two-laser volley every 6 seconds did, so a crew hits as hard as before. + * PR #500 rolled a drawn gun for each basic-mob gunner and fired its rounds from the body. Human + * pirates (faction_pirate_humans.dm) hold the real thing, chosen by their faction's outfit table + * (voidcrew/modules/human_npcs/human_npc_outfits_pirates.dm, weighted as PR #500's crew_gun_weights), + * with real spare magazines or shells, and fire it through the real fire path. What is left of PR #500's + * loadouts is how each kind of gun is used: how many pulls of the trigger make a volley and how long the + * pirate waits between volleys (tuned, as before, to about the old two-laser volley every six seconds), + * the SMG's spin-up and the shotgun's moment of aim that a hit spoils, the shotgunner walking in close, + * and what it shouts when it reloads. The pump, the magazine change and their sounds are the gun's own. */ -/// One of each loadout, by type -GLOBAL_LIST_INIT(pirate_gun_loadouts, init_pirate_gun_loadouts()) +/// One of each handling, most specific gun type first +GLOBAL_LIST_INIT(pirate_gun_handlings, init_pirate_gun_handlings()) -/proc/init_pirate_gun_loadouts() +/proc/init_pirate_gun_handlings() . = list() - for(var/loadout_type in subtypesof(/datum/pirate_gun_loadout)) - .[loadout_type] = new loadout_type - -/// What a gunner rolls when its faction says nothing: an even mix of energy and ballistic -GLOBAL_LIST_INIT(pirate_gun_default_weights, list( - /datum/pirate_gun_loadout/energy_gun = 2, - /datum/pirate_gun_loadout/laser_rifle = 1, - /datum/pirate_gun_loadout/pistol = 2, - /datum/pirate_gun_loadout/smg = 2, - /datum/pirate_gun_loadout/shotgun = 2, -)) + // Most specific first: pistols are automatic guns too, and every gun type is a /obj/item/gun. + for(var/handling_type in list( + /datum/pirate_gun_handling/shotgun, + /datum/pirate_gun_handling/pistol, + /datum/pirate_gun_handling/smg, + /datum/pirate_gun_handling/ballistic, + /datum/pirate_gun_handling/crossbow, + /datum/pirate_gun_handling/energy, + /datum/pirate_gun_handling, + )) + . += new handling_type + +/// How a pirate uses `gun`: the first handling whose gun type it is. +/proc/pirate_gun_handling_for(obj/item/gun/gun) + for(var/datum/pirate_gun_handling/handling as anything in GLOB.pirate_gun_handlings) + if(istype(gun, handling.gun_type)) + return handling + return GLOB.pirate_gun_handlings[length(GLOB.pirate_gun_handlings)] /** - * A pirate gun: the item in the hand, its rounds, and how it is fired. Read-only, shared by every - * pirate carrying it; the ammunition and timers live on the pirate's /datum/component/pirate_gun. + * How a pirate fires one kind of gun. Read-only and shared; the timers live on the pirate's AI + * blackboard (BB_PIRATE_VOLLEY_LEFT, BB_PIRATE_WINDUP_UNTIL, BB_HUMAN_NPC_NEXT_SHOT). */ -/datum/pirate_gun_loadout - /// The gun in the hand - var/gun_type - /// For a ballistic gun, what it is loaded with (null: the magazine's own rounds) - var/ammo_type - /// For an energy gun, the casing it fires (its beam and its sound) - var/obj/item/ammo_casing/energy/energy_casing - /// For an energy gun, shots in a cell - var/energy_shots = 0 - /// Shots in one burst, and the time between them - var/burst = 1 - var/burst_delay = 0.2 SECONDS - /// From the start of one burst (or wind-up) to the next - var/cooldown = 6 SECONDS - var/reload_time = 2.5 SECONDS - /// The spin-up or pump before a burst, if any; a hit during it spoils the burst +/datum/pirate_gun_handling + /// The guns it covers + var/gun_type = /obj/item/gun + /// Pulls of the trigger in one volley, and the time between them + var/shots = 1 + var/shot_gap = 0.3 SECONDS + /// From the last pull of a volley to the first of the next + var/volley_gap = 3 SECONDS + /// The spin-up or aim before a volley, if any: a hit during it spoils the volley var/windup = 0 var/windup_sound - var/fire_sound - var/reload_sound = 'sound/items/weapons/gun/general/magazine_insert_full.ogg' /// Tiles: it walks in to this before it fires (0: the usual gunner range) var/close_range = 0 - /// What its carrier may shout on reloading + /// What it may shout as it changes magazines or loads shells var/list/reload_lines = list("Reloading!", "Changing mags!", "Cover me, I'm reloading!") - /// The real gun's numbers, for a ballistic gun (/datum/bounty_real_weapon) - var/datum/bounty_real_weapon/weapon - -/datum/pirate_gun_loadout/New() - . = ..() - if(ispath(gun_type, /obj/item/gun/ballistic)) - weapon = bounty_real_weapon(gun_type, ammo_type) - if(!fire_sound) - fire_sound = weapon.fire_sound - else if(energy_casing && !fire_sound) - fire_sound = initial(energy_casing.fire_sound) - -/// Rounds in a full magazine, or shots in a full cell -/datum/pirate_gun_loadout/proc/magazine_size() - if(weapon) - return max(1, weapon.magazine) - return max(1, energy_shots) - -/// Projectiles one round fires -/datum/pirate_gun_loadout/proc/pellets() - if(weapon) - return max(1, weapon.pellets) - return max(1, initial(energy_casing.pellets)) - -/// A new projectile of one round (one pellet of a shell) at `start`, carrying the real round's numbers -/datum/pirate_gun_loadout/proc/make_projectile(turf/start) - if(weapon) - if(!weapon.round_type) - return null - var/obj/projectile/bullet = new weapon.round_type(start) - weapon.load(bullet) - return bullet - var/projectile_type = initial(energy_casing.projectile_type) - return projectile_type ? new projectile_type(start) : null - -/// Degrees one projectile strays -/datum/pirate_gun_loadout/proc/shot_spread() - if(weapon) - return weapon.shot_spread() - var/variance = initial(energy_casing.variance) - return variance ? round((rand() - 0.5) * variance) : 0 - -/// Energy gun (lethal): two beams every six seconds, twelve shots to a cell -/datum/pirate_gun_loadout/energy_gun - gun_type = /obj/item/gun/energy/e_gun/lethal - energy_casing = /obj/item/ammo_casing/energy/laser - energy_shots = 12 - burst = 2 - cooldown = 6 SECONDS - reload_time = 3 SECONDS - reload_sound = 'sound/items/weapons/gun/general/magazine_insert_empty.ogg' - reload_lines = list("Swapping cells!", "Cell's dry!", "Cover me!") - -/// Laser rifle: as the energy gun, with a bigger cell -/datum/pirate_gun_loadout/laser_rifle - gun_type = /obj/item/gun/energy/laser - energy_casing = /obj/item/ammo_casing/energy/lasergun - energy_shots = 16 - burst = 2 - cooldown = 6 SECONDS - reload_time = 3 SECONDS - reload_sound = 'sound/items/weapons/gun/general/magazine_insert_empty.ogg' - reload_lines = list("Swapping cells!", "Cell's dry!", "Cover me!") - -/// Makarov: a quick pair of 9mm every seven seconds, a reload every six pairs -/datum/pirate_gun_loadout/pistol + /// What it may shout when the gun runs dry and can't be reloaded + var/list/dry_lines + +/// Energy guns: two beams every six seconds. A cell can't be changed by hand, so a dry one is put away. +/datum/pirate_gun_handling/energy + gun_type = /obj/item/gun/energy + shots = 2 + shot_gap = 0.2 SECONDS + volley_gap = 6 SECONDS + reload_lines = null + dry_lines = list("Cell's dry!", "Cover me!") + +/// The medieval crossbow: one bolt every six seconds. It draws itself again. +/datum/pirate_gun_handling/crossbow + gun_type = /obj/item/gun/energy/recharge + shots = 1 + volley_gap = 6 SECONDS + reload_lines = null + +/// Any other bullet gun: one shot every three seconds. +/datum/pirate_gun_handling/ballistic + gun_type = /obj/item/gun/ballistic + +/// Pistols: a quick pair every seven seconds. +/datum/pirate_gun_handling/pistol gun_type = /obj/item/gun/ballistic/automatic/pistol - burst = 2 - burst_delay = 0.3 SECONDS - cooldown = 7 SECONDS - reload_time = 2 SECONDS - reload_sound = 'sound/items/weapons/gun/pistol/mag_insert.ogg' - -/// Uzi: a five-round string after a short wind-up, every seven seconds -/datum/pirate_gun_loadout/smg - gun_type = /obj/item/gun/ballistic/automatic/mini_uzi - burst = 5 - burst_delay = 0.1 SECONDS - cooldown = 7 SECONDS - reload_time = 2.5 SECONDS + shots = 2 + shot_gap = 0.3 SECONDS + volley_gap = 7 SECONDS + +/// SMGs: spins up, then three bursts, every seven seconds. +/datum/pirate_gun_handling/smg + gun_type = /obj/item/gun/ballistic/automatic + shots = 3 + shot_gap = 0.3 SECONDS + volley_gap = 7 SECONDS windup = 0.4 SECONDS windup_sound = 'sound/items/weapons/gun/smg/smgrack.ogg' -/// Pump shotgun with buckshot: walks in close, pumps, and fires a shell every four seconds -/datum/pirate_gun_loadout/shotgun +/// Pump shotguns: walks in close, aims a moment and fires a shell every four seconds. The pump after +/// each shot is the gun's own (the AI racks it as a player does). +/datum/pirate_gun_handling/shotgun gun_type = /obj/item/gun/ballistic/shotgun - ammo_type = /obj/item/ammo_casing/shotgun/buckshot - burst = 1 - cooldown = 4 SECONDS - reload_time = 3 SECONDS + shots = 1 + volley_gap = 4 SECONDS windup = 0.4 SECONDS - windup_sound = 'sound/items/weapons/gun/shotgun/rack.ogg' - reload_sound = 'sound/items/weapons/gun/shotgun/insert_shell.ogg' close_range = 3 reload_lines = list("Reloading!", "Loading shells!", "Cover me!") - -// ===== ARMING ===== - -/// The loadout weights for `gunner`'s faction: type => weight, or null to keep its own gun -/proc/pirate_gun_weights_for(mob/living/basic/gunner) - var/mob/living/basic/trooper/pirate/faction/faction_pirate = gunner - if(istype(faction_pirate)) - if(faction_pirate.crew_keeps_gun) - return null - return faction_pirate.crew_gun_weights || GLOB.pirate_gun_default_weights - if(istype(gunner, /mob/living/basic/trooper/pirate)) - return GLOB.pirate_gun_default_weights - return null - -/** - * Hands `gunner` a rolled pirate gun in place of its stock ranged attack: the gun shows in its hand - * and fires real rounds. Only a gunner whose faction has loadouts, and only once. Returns the loadout. - */ -/proc/pirate_crew_arm(mob/living/basic/gunner, loadout_type) - if(QDELETED(gunner) || gunner.GetComponent(/datum/component/pirate_gun)) - return null - if(!gunner.GetComponent(/datum/component/ranged_attacks)) - return null - if(!loadout_type) - var/list/weights = pirate_gun_weights_for(gunner) - if(!length(weights)) - return null - loadout_type = pick_weight(weights) - var/datum/pirate_gun_loadout/loadout = GLOB.pirate_gun_loadouts[loadout_type] - if(!loadout) - return null - qdel(gunner.GetComponent(/datum/component/ranged_attacks)) - gunner.AddComponent(/datum/component/pirate_gun, loadout) - var/mob/living/basic/trooper/trooper = gunner - if(istype(trooper)) - trooper.r_hand = loadout.gun_type - if(trooper.use_dynamic_appearance) - apply_dynamic_human_appearance(trooper, mob_spawn_path = trooper.mob_spawner, r_hand = trooper.r_hand, l_hand = trooper.l_hand) - return loadout - -/// The pirate gun `gunner` carries, if any -/proc/pirate_gun_of(mob/living/gunner) - return gunner?.GetComponent(/datum/component/pirate_gun) - -// ===== THE GUN IN HAND ===== - -/** - * A pirate's gun in use: its ammunition and timers. The AI's ranged attack (RangedAttack()) asks it - * to fire; it fires a burst when it's ready, winds up first if it has to, and reloads when empty. - * Its timers check `serial`, so a spoiled or cancelled burst does nothing. - */ -/datum/component/pirate_gun - var/datum/pirate_gun_loadout/loadout - /// Rounds left in the magazine - var/ammo = 0 - /// world.time it may start the next burst or wind-up - var/next_fire_at = 0 - /// world.time the reload is done - var/reloading_until = 0 - /// Winding up now - var/winding = FALSE - /// Goes up with every burst, wind-up and fumble, so stale timers do nothing - var/serial = 0 - /// What it's aimed at - var/datum/weakref/aim_ref - -/datum/component/pirate_gun/Initialize(datum/pirate_gun_loadout/loadout) - if(!isbasicmob(parent) || !istype(loadout)) - return COMPONENT_INCOMPATIBLE - src.loadout = loadout - ammo = loadout.magazine_size() - -/datum/component/pirate_gun/Destroy(force) - serial++ - loadout = null - aim_ref = null - return ..() - -/datum/component/pirate_gun/RegisterWithParent() - . = ..() - ADD_TRAIT(parent, TRAIT_SUBTREE_REQUIRED_OPERATIONAL_DATUM, type) - RegisterSignal(parent, COMSIG_MOB_ATTACK_RANGED, PROC_REF(on_ranged_attack)) - RegisterSignal(parent, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) - -/datum/component/pirate_gun/UnregisterFromParent() - . = ..() - UnregisterSignal(parent, list(COMSIG_MOB_ATTACK_RANGED, COMSIG_MOB_AFTER_APPLY_DAMAGE)) - REMOVE_TRAIT(parent, TRAIT_SUBTREE_REQUIRED_OPERATIONAL_DATUM, type) - -/datum/component/pirate_gun/proc/on_ranged_attack(mob/living/basic/source, atom/target, modifiers) - SIGNAL_HANDLER - try_fire(target) - -/// A hit during the wind-up spoils it: nothing fires, and the next try waits a moment -/datum/component/pirate_gun/proc/on_damaged(mob/living/source, damage_dealt) - SIGNAL_HANDLER - if(!winding || damage_dealt <= 0) - return - serial++ - winding = FALSE - next_fire_at = max(next_fire_at, world.time + 1 SECONDS) - -/datum/component/pirate_gun/proc/ready() - return !winding && world.time >= next_fire_at - -/datum/component/pirate_gun/proc/is_reloading() - return world.time < reloading_until - -/datum/component/pirate_gun/proc/can_fire(mob/living/owner) - return owner && owner.stat == CONSCIOUS && isturf(owner.loc) && !HAS_TRAIT(owner, TRAIT_INCAPACITATED) && !HAS_TRAIT(owner, TRAIT_HANDS_BLOCKED) - -/// Starts a burst at `target`, after the wind-up if it has one. TRUE if it started. -/datum/component/pirate_gun/proc/try_fire(atom/target) - var/mob/living/owner = parent - if(!can_fire(owner) || !ready() || QDELETED(target)) - return FALSE - if(loadout.close_range && get_dist(owner, target) > loadout.close_range) - return FALSE - aim_ref = WEAKREF(target) - serial++ - owner.face_atom(target) - if(loadout.windup) - winding = TRUE - next_fire_at = world.time + loadout.windup + loadout.cooldown - if(loadout.windup_sound) - playsound(owner, loadout.windup_sound, 50, TRUE) - addtimer(CALLBACK(src, PROC_REF(finish_windup), serial), loadout.windup, TIMER_DELETE_ME) - return TRUE - fire_burst() - return TRUE - -/datum/component/pirate_gun/proc/finish_windup(fire_serial) - if(fire_serial != serial || !winding) - return - winding = FALSE - if(can_fire(parent)) - fire_burst() - -/// A burst: as many shots as the magazine has left, then a reload if it's spent -/datum/component/pirate_gun/proc/fire_burst() - var/shots = clamp(loadout.burst, 1, ammo) - ammo -= shots - next_fire_at = max(next_fire_at, world.time + loadout.cooldown) - var/fire_serial = serial - shoot_once(fire_serial) - for(var/i in 2 to shots) - addtimer(CALLBACK(src, PROC_REF(shoot_once), fire_serial), (i - 1) * loadout.burst_delay, TIMER_DELETE_ME) - if(ammo <= 0) - start_reload((shots - 1) * loadout.burst_delay) - -/// One round of the burst under `fire_serial`: every pellet of it -/datum/component/pirate_gun/proc/shoot_once(fire_serial) - if(fire_serial != serial) - return null - var/mob/living/owner = parent - var/atom/target = aim_ref?.resolve() - if(!can_fire(owner) || QDELETED(target)) - return null - if(isliving(target)) - var/mob/living/living_target = target - if(living_target.stat == DEAD) - return null - owner.face_atom(target) - var/turf/start = get_turf(owner) - if(!start || start == get_turf(target)) - return null - var/obj/projectile/first - for(var/pellet in 1 to loadout.pellets()) - var/obj/projectile/shot = loadout.make_projectile(start) - if(!shot) - break - shot.firer = owner - shot.fired_from = owner - shot.ignored_factions = owner.faction?.Copy() - shot.aim_projectile(target, owner, null, loadout.shot_spread()) - if(QDELETED(shot)) - continue - shot.fire() - if(!first && !QDELETED(shot)) - first = shot - if(loadout.fire_sound) - playsound(owner, loadout.fire_sound, 60, TRUE) - SEND_SIGNAL(owner, COMSIG_BASICMOB_POST_ATTACK_RANGED, target, null) - return first - -/// A fresh magazine, ready `delay` + the reload time from now -/datum/component/pirate_gun/proc/start_reload(delay = 0) - ammo = loadout.magazine_size() - reloading_until = world.time + delay + loadout.reload_time - next_fire_at = max(next_fire_at, reloading_until) - addtimer(CALLBACK(src, PROC_REF(reload_effects)), delay, TIMER_DELETE_ME) - -/datum/component/pirate_gun/proc/reload_effects() - var/mob/living/owner = parent - if(QDELETED(owner) || owner.stat != CONSCIOUS) - return - if(loadout.reload_sound) - playsound(owner, loadout.reload_sound, 40, TRUE) - if(length(loadout.reload_lines) && prob(PIRATE_CREW_RELOAD_BARK_CHANCE)) - INVOKE_ASYNC(owner, TYPE_PROC_REF(/atom/movable, say), message = pick(loadout.reload_lines), forced = "pirate crew") - -/// For the admin status readout -/datum/component/pirate_gun/proc/status_text() - var/obj/item/gun_path = loadout.gun_type - return "[initial(gun_path.name)] [ammo]/[loadout.magazine_size()][is_reloading() ? " reloading" : ""][winding ? " winding up" : ""]" diff --git a/voidcrew/modules/npc_ships/code/spawner_subsystem.dm b/voidcrew/modules/npc_ships/code/spawner_subsystem.dm index 2391f08dc8b..f74882420fa 100644 --- a/voidcrew/modules/npc_ships/code/spawner_subsystem.dm +++ b/voidcrew/modules/npc_ships/code/spawner_subsystem.dm @@ -258,12 +258,18 @@ SUBSYSTEM_DEF(npc_ships) if(ship.spawner_resolved || ship.abandoned) continue if(!ship.crew_ever_spawned) + // Human crews are built a body at a time (spawn_human_npc()); still queued is not malformed + if(ship.crew_pending()) + continue if(!(REF(ship) in logged_malformed)) logged_malformed += REF(ship) log_world("SSnpc_ships: [ship.name] has no crew roster (spawn_crew failed?) - it holds its pool slot and will never auto-resolve") continue - // Put spaced survivors out of their misery before the census reads them - ship.sweep_spaced_crew() + // A crewman spaced off the hull no longer counts as aboard; the space kills him or it doesn't + // (no death is forced, defaults PI-3), and one adrift with nobody to see him is cleaned up by + // his crew (pirate_crew.dm). Crew still queued to be built keep the hull's slot. + if(ship.crew_pending()) + continue if(ship.count_live_crew_aboard() <= 0) ship.notify_spawner_resolved("crew wiped (reconcile)") continue diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index ce5aa07f5cb..dad9a92cb8a 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -192,6 +192,7 @@ #include "voidcrew_overmap_management_bans.dm" #include "voidcrew_pandora_links.dm" #include "voidcrew_pirate_crew_ai.dm" +#include "voidcrew_pirate_crew_human.dm" #include "voidcrew_planet_anomalies.dm" #include "voidcrew_planet_cleanup.dm" #include "voidcrew_planet_definitions.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_pirate_crew_ai.dm b/voidcrew/modules/unit_tests/voidcrew_pirate_crew_ai.dm index 0a7fdb59221..f0e7c017387 100644 --- a/voidcrew/modules/unit_tests/voidcrew_pirate_crew_ai.dm +++ b/voidcrew/modules/unit_tests/voidcrew_pirate_crew_ai.dm @@ -1,7 +1,9 @@ /** - * Pirate crew AI (voidcrew/modules/npc_ships/code/pirate_crew.dm and pirate_crew_ai.dm): which - * doors a pirate walks through, which it may break, the search for the door in the way, and how - * a hull's crew and a raid are put together. + * Pirate crew AI (voidcrew/modules/npc_ships/code/pirate_crew.dm, pirate_crew_ai.dm and + * pirate_crew_human.dm): which doors a pirate walks through, which it may break, the search for the + * door in the way, and how a hull's crew and a raid are put together. NPC ship crews are human NPCs + * (faction_pirate_humans.dm), so most of these use them; voidcrew_pirate_crew_human.dm tests what they + * do with their hands. * * Fork defines are included after the tests, so roles and blackboard keys appear as their * literal strings ("captain" is PIRATE_ROLE_CAPTAIN, "pirate_crew" is BB_PIRATE_CREW). Every @@ -30,13 +32,21 @@ changed_turfs |= where return where.ChangeTurf(new_type) +/// Builds a human NPC now at `where`, dressed in `outfit` if given (an outfit or an outfit table) +/datum/unit_test/voidcrew_pirate_crew/proc/build_npc(npc_type, turf/where, outfit) + var/datum/human_npc_order/order = spawn_human_npc(npc_type, where || run_loc_floor_bottom_left, outfit = outfit, immediate = TRUE) + var/mob/living/carbon/human/npc/npc = order?.spawned_ref?.resolve() + if(npc) + allocated += npc + return npc + // ===== WHICH DOORS OPEN ===== /// An unlocked airlock opens for a pirate; a bolted, welded, unpowered or access-locked one does not /datum/unit_test/voidcrew_pirate_crew/door_rules /datum/unit_test/voidcrew_pirate_crew/door_rules/Run() - var/mob/living/basic/trooper/pirate/melee/pirate = allocate(/mob/living/basic/trooper/pirate/melee, spot(0, 0)) + var/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee/pirate = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee, spot(0, 0)) var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, spot(2, 2)) TEST_ASSERT(door.density, "The test airlock spawned open") TEST_ASSERT(pirate_can_open_door(pirate, door), "A pirate can't open an unlocked, powered airlock") @@ -55,8 +65,9 @@ door.set_machine_stat(old_stat) TEST_ASSERT(pirate_can_open_door(pirate, door), "Restoring power didn't make the airlock openable again") + // The pirate's ID card carries its name and no access door.req_access = list(ACCESS_ENGINEERING) - TEST_ASSERT(!pirate_can_open_door(pirate, door), "A pirate with no ID can open an access-locked airlock") + TEST_ASSERT(!pirate_can_open_door(pirate, door), "A pirate's ID card opens an access-locked airlock") door.req_access = null /// Breaking an external airlock with space beyond it is off limits; an inner door or an indestructible one depends @@ -74,13 +85,19 @@ inner.open() TEST_ASSERT(!pirate_door_breachable(inner, spot(0, 2)), "A pirate may break an airlock that is already open") + // A firelock holding back a room that lost its air is never forced either + var/obj/machinery/door/firedoor/firelock = allocate(/obj/machinery/door/firedoor, spot(4, 3)) + TEST_ASSERT(pirate_door_vents_space(firelock, spot(4, 2)), "A pirate may force a firelock with vacuum behind it") + var/obj/machinery/door/firedoor/inner_firelock = allocate(/obj/machinery/door/firedoor, spot(1, 3)) + TEST_ASSERT(!pirate_door_vents_space(inner_firelock, spot(1, 2)), "A firelock with air on both sides counts as venting") + // ===== THE DOOR IN THE WAY ===== /// Two rooms joined only by a bolted airlock: the search finds that airlock and the floor to break it from /datum/unit_test/voidcrew_pirate_crew/breach_search /datum/unit_test/voidcrew_pirate_crew/breach_search/Run() - var/mob/living/basic/trooper/pirate/melee/pirate = allocate(/mob/living/basic/trooper/pirate/melee, spot(0, 2)) + var/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee/pirate = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee, spot(0, 2)) for(var/dy in list(0, 1, 3, 4)) change_turf(spot(2, dy), /turf/closed/wall) var/obj/machinery/door/airlock/instant/door = allocate(/obj/machinery/door/airlock/instant, spot(2, 2)) @@ -113,15 +130,18 @@ // ===== CREWS ===== -/// A hull's crew has one captain and one guard, a melee guard when there is one, and alerting it wakes everyone +/// A hull's crew has one captain and one guard, a melee guard when there is one, and alerting it wakes +/// everyone; the captain and guard join the hunt once the rest are out of the fight (dead, down or cuffed) /datum/unit_test/voidcrew_pirate_crew/defending_roles /datum/unit_test/voidcrew_pirate_crew/defending_roles/Run() var/obj/structure/overmap/ship/npc/pirate/hull = allocate(/obj/structure/overmap/ship/npc/pirate) - var/mob/living/basic/trooper/pirate/ranged/captain = allocate(/mob/living/basic/trooper/pirate/ranged, spot(0, 0)) - var/mob/living/basic/trooper/pirate/ranged/gunner = allocate(/mob/living/basic/trooper/pirate/ranged, spot(1, 0)) - var/mob/living/basic/trooper/pirate/melee/brawler = allocate(/mob/living/basic/trooper/pirate/melee, spot(2, 0)) - var/mob/living/basic/trooper/pirate/melee/deckhand = allocate(/mob/living/basic/trooper/pirate/melee, spot(3, 0)) + var/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/captain/captain = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/captain, spot(0, 0)) + var/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/ranged/gunner = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/ranged, spot(1, 0)) + var/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/melee/brawler = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/melee, spot(2, 0)) + var/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/melee/deckhand = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/melee, spot(3, 0)) + TEST_ASSERT(pirate_crew_is_ranged(gunner), "A marksman holding a gun doesn't count as a gunner") + TEST_ASSERT(!pirate_crew_is_ranged(brawler), "A duelist counts as a gunner") TEST_ASSERT(pirate_crew_enlist_defender(captain, hull, "captain"), "The captain could not join the hull's crew") // The gunner joins first and takes the guard post; the first melee recruit takes it off him @@ -142,6 +162,8 @@ if("guard") guards++ TEST_ASSERT_EQUAL(member.ai_controller?.blackboard["pirate_crew"], crew, "[member] doesn't know its crew") + TEST_ASSERT_EQUAL(member.ai_controller?.type, /datum/ai_controller/basic_controller/pirate_crew/human, "[member] isn't on the human pirate crew controller") + TEST_ASSERT_EQUAL(member.ai_controller?.blackboard[BB_TARGET_MINIMUM_STAT], HARD_CRIT, "[member] stops shooting people in crit") TEST_ASSERT_EQUAL(captains, 1, "The crew has [captains] captains") TEST_ASSERT_EQUAL(guards, 1, "The crew has [guards] guards") TEST_ASSERT_EQUAL(crew.members[captain], "captain", "The pirate enlisted as captain isn't the captain") @@ -159,32 +181,45 @@ for(var/mob/living/member in crew.members) TEST_ASSERT(!member.ai_controller.can_idle, "[member] can still idle on alert") + // The guard down and the deckhand cuffed: only the gunner is still fighting, so the captain joins in + brawler.adjustOxyLoss(110) + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(deckhand.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the deckhand could not be cuffed") + crew.check_promotion() + TEST_ASSERT(crew.promoted, "The captain didn't join the hunt with only one of his crew still fighting") + TEST_ASSERT(!crew.holds_post(captain), "The captain still holds the post once his crew is beaten") + qdel(crew) TEST_ASSERT_NULL(pirate_crew_of(hull), "A deleted crew is still registered to its hull") TEST_ASSERT_NULL(captain.ai_controller.blackboard["pirate_crew"], "The captain still points at a deleted crew") -/// A boarder joins the raid on the ship it landed on, and a trooper gets the melee controller, a ranged trooper the ranged one +/// A boarder joins the raid on the ship it landed on; a human pirate gets the human controller, and a +/// basic trooper still gets the melee controller, a ranged trooper the ranged one /datum/unit_test/voidcrew_pirate_crew/raid_controllers /datum/unit_test/voidcrew_pirate_crew/raid_controllers/Run() var/obj/structure/overmap/ship/target = allocate(/obj/structure/overmap/ship) var/mob/living/basic/trooper/trooper = allocate(/mob/living/basic/trooper, spot(0, 0)) - var/mob/living/basic/trooper/pirate/ranged/gunner = allocate(/mob/living/basic/trooper/pirate/ranged, spot(1, 0)) + var/mob/living/basic/trooper/pirate/ranged/basic_gunner = allocate(/mob/living/basic/trooper/pirate/ranged, spot(1, 0)) + var/mob/living/carbon/human/npc/ship_crew/pirate/irs/ranged/boarder = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/irs/ranged, spot(2, 0)) TEST_ASSERT_EQUAL(pirate_crew_controller_type(trooper), /datum/ai_controller/basic_controller/pirate_crew, "A melee trooper is down for the wrong controller") - TEST_ASSERT_EQUAL(pirate_crew_controller_type(gunner), /datum/ai_controller/basic_controller/pirate_crew/ranged, "A ranged trooper is down for the wrong controller") + TEST_ASSERT_EQUAL(pirate_crew_controller_type(basic_gunner), /datum/ai_controller/basic_controller/pirate_crew/ranged, "A ranged trooper is down for the wrong controller") + TEST_ASSERT_EQUAL(pirate_crew_controller_type(boarder), /datum/ai_controller/basic_controller/pirate_crew/human, "A human pirate is down for the wrong controller") TEST_ASSERT(pirate_crew_enlist_raider(trooper, target), "A trooper could not join the raid") - TEST_ASSERT(pirate_crew_enlist_raider(gunner, target), "A gunner could not join the raid") + TEST_ASSERT(pirate_crew_enlist_raider(basic_gunner, target), "A basic gunner could not join the raid") + TEST_ASSERT(pirate_crew_enlist_raider(boarder, target), "A human boarder could not join the raid") TEST_ASSERT_EQUAL(trooper.ai_controller?.type, /datum/ai_controller/basic_controller/pirate_crew, "The trooper didn't get the pirate crew controller") - TEST_ASSERT_EQUAL(gunner.ai_controller?.type, /datum/ai_controller/basic_controller/pirate_crew/ranged, "The gunner didn't get the ranged pirate crew controller") + TEST_ASSERT_EQUAL(basic_gunner.ai_controller?.type, /datum/ai_controller/basic_controller/pirate_crew/ranged, "The basic gunner didn't get the ranged pirate crew controller") + TEST_ASSERT_EQUAL(boarder.ai_controller?.type, /datum/ai_controller/basic_controller/pirate_crew/human, "The human boarder lost its human controller") var/datum/pirate_crew/raid = pirate_crew_of(target, TRUE) TEST_ASSERT_NOTNULL(raid, "Enlisting made no raid on the ship") TEST_ASSERT_NULL(pirate_crew_of(target), "A raid registered itself as the ship's own crew") TEST_ASSERT(raid.raiding && raid.alerted, "A raid isn't on the attack from the start") - TEST_ASSERT_EQUAL(raid.members[trooper], "hunter", "A boarder isn't a hunter") - TEST_ASSERT(!raid.holds_post(trooper), "A boarder holds a post") - TEST_ASSERT(!trooper.ai_controller.can_idle && !gunner.ai_controller.can_idle, "Boarders can idle") + TEST_ASSERT_EQUAL(raid.members[boarder], "hunter", "A boarder isn't a hunter") + TEST_ASSERT(!raid.holds_post(boarder), "A boarder holds a post") + TEST_ASSERT(!trooper.ai_controller.can_idle && !boarder.ai_controller.can_idle, "Boarders can idle") qdel(raid) // ===== LOOKS ===== @@ -214,10 +249,10 @@ /datum/unit_test/voidcrew_pirate_crew/mineral_doors /datum/unit_test/voidcrew_pirate_crew/mineral_doors/Run() - var/mob/living/basic/trooper/pirate/melee/pirate = allocate(/mob/living/basic/trooper/pirate/melee, spot(0, 2)) + var/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee/pirate = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee, spot(0, 2)) var/mob/living/basic/trooper/pirate/melee/bystander = allocate(/mob/living/basic/trooper/pirate/melee, spot(4, 2)) var/obj/structure/overmap/ship/target = allocate(/obj/structure/overmap/ship) - TEST_ASSERT(pirate_crew_enlist_raider(pirate, target), "A trooper could not join the raid") + TEST_ASSERT(pirate_crew_enlist_raider(pirate, target), "A pirate could not join the raid") for(var/dy in list(0, 1, 3, 4)) change_turf(spot(2, dy), /turf/closed/wall) var/obj/structure/mineral_door/iron/slab = allocate(/obj/structure/mineral_door/iron, spot(2, 2)) @@ -237,66 +272,3 @@ TEST_ASSERT(!pirate_can_open_mineral_door(slab), "A pirate tries to open a loose iron door") TEST_ASSERT_NULL(pirate_crew_breach_search(pirate, spot(0, 2), spot(4, 2), areas), "The search found a way through a loose iron door") qdel(pirate_crew_of(target, TRUE)) - -// ===== GUNS ===== - -/// Every pirate gun fires a real round from a real magazine; a gunner joining a crew trades its stock shot for one, and a crossbowman keeps his -/datum/unit_test/voidcrew_pirate_crew/guns - -/datum/unit_test/voidcrew_pirate_crew/guns/Run() - for(var/loadout_type in subtypesof(/datum/pirate_gun_loadout)) - var/datum/pirate_gun_loadout/loadout = GLOB.pirate_gun_loadouts[loadout_type] - TEST_ASSERT_NOTNULL(loadout, "[loadout_type] has no shared copy") - TEST_ASSERT(loadout.magazine_size() > 1, "[loadout_type] holds [loadout.magazine_size()] rounds") - TEST_ASSERT(loadout.fire_sound, "[loadout_type] makes no sound") - var/obj/projectile/shot = loadout.make_projectile(spot(4, 4)) - TEST_ASSERT_NOTNULL(shot, "[loadout_type] fires nothing") - TEST_ASSERT(shot.damage > 0, "[loadout_type]'s round does no damage") - qdel(shot) - var/datum/pirate_gun_loadout/shotgun = GLOB.pirate_gun_loadouts[/datum/pirate_gun_loadout/shotgun] - TEST_ASSERT(shotgun.pellets() > 1, "The shotgun fires one pellet") - var/datum/pirate_gun_loadout/smg = GLOB.pirate_gun_loadouts[/datum/pirate_gun_loadout/smg] - TEST_ASSERT_EQUAL(smg.magazine_size(), 32, "The SMG's magazine isn't the Uzi's") - - var/obj/structure/overmap/ship/target = allocate(/obj/structure/overmap/ship) - var/mob/living/basic/trooper/pirate/faction/irs/ranged/gunner = allocate(/mob/living/basic/trooper/pirate/faction/irs/ranged, spot(0, 0)) - var/mob/living/basic/trooper/pirate/faction/medieval/ranged/archer = allocate(/mob/living/basic/trooper/pirate/faction/medieval/ranged, spot(0, 1)) - TEST_ASSERT(pirate_crew_enlist_raider(gunner, target), "A gunner could not join the raid") - TEST_ASSERT(pirate_crew_enlist_raider(archer, target), "A crossbowman could not join the raid") - var/datum/component/pirate_gun/gun = pirate_gun_of(gunner) - TEST_ASSERT_NOTNULL(gun, "A gunner joined a crew without rolling a gun") - TEST_ASSERT_NULL(gunner.GetComponent(/datum/component/ranged_attacks), "A gunner kept its stock shot as well as its gun") - TEST_ASSERT_EQUAL(gunner.r_hand, gun.loadout.gun_type, "A gunner holds a different gun from the one it fires") - TEST_ASSERT_NULL(pirate_gun_of(archer), "A crossbowman traded his crossbow for a gun") - TEST_ASSERT_NOTNULL(archer.GetComponent(/datum/component/ranged_attacks), "A crossbowman lost his crossbow") - - // A pistol fires its pair, and reloads when the magazine runs dry - var/mob/living/basic/trooper/pirate/ranged/pistoleer = allocate(/mob/living/basic/trooper/pirate/ranged, spot(0, 2)) - TEST_ASSERT_EQUAL(pirate_crew_arm(pistoleer, /datum/pirate_gun_loadout/pistol), GLOB.pirate_gun_loadouts[/datum/pirate_gun_loadout/pistol], "A gunner couldn't be handed a pistol") - var/datum/component/pirate_gun/pistol = pirate_gun_of(pistoleer) - var/mob/living/carbon/human/consistent/mark = allocate(/mob/living/carbon/human/consistent, spot(4, 2)) - var/full = pistol.ammo - TEST_ASSERT(pistol.try_fire(mark), "A loaded pistol wouldn't fire") - TEST_ASSERT_EQUAL(pistol.ammo, full - 2, "A pistol pair didn't use two rounds") - TEST_ASSERT(!pistol.try_fire(mark), "A pistol fired again before its cooldown") - pistol.next_fire_at = 0 - pistol.ammo = 1 - TEST_ASSERT(pistol.try_fire(mark), "A pistol with one round left wouldn't fire") - TEST_ASSERT(pistol.is_reloading(), "An empty pistol isn't reloading") - TEST_ASSERT_EQUAL(pistol.ammo, full, "A reload didn't fill the magazine") - - // A shotgunner holds fire until it's close, and a hit during the pump spoils the shot - var/mob/living/basic/trooper/pirate/ranged/shotgunner = allocate(/mob/living/basic/trooper/pirate/ranged, spot(0, 3)) - pirate_crew_arm(shotgunner, /datum/pirate_gun_loadout/shotgun) - var/datum/component/pirate_gun/pump = pirate_gun_of(shotgunner) - var/mob/living/carbon/human/consistent/far_mark = allocate(/mob/living/carbon/human/consistent, spot(4, 3)) - TEST_ASSERT(!pump.try_fire(far_mark), "A shotgun fired from four tiles out") - far_mark.forceMove(spot(2, 3)) - TEST_ASSERT(pump.try_fire(far_mark), "A shotgun wouldn't fire from two tiles") - TEST_ASSERT(pump.winding, "A shotgun fired without pumping") - var/shells = pump.ammo - shotgunner.apply_damage(5, BRUTE) - TEST_ASSERT(!pump.winding, "A hit during the pump didn't spoil it") - pump.finish_windup(pump.serial - 1) - TEST_ASSERT_EQUAL(pump.ammo, shells, "A spoiled pump still fired") - qdel(pirate_crew_of(target, TRUE)) diff --git a/voidcrew/modules/unit_tests/voidcrew_pirate_crew_human.dm b/voidcrew/modules/unit_tests/voidcrew_pirate_crew_human.dm new file mode 100644 index 00000000000..57bde3d6c45 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_pirate_crew_human.dm @@ -0,0 +1,413 @@ +/** + * Human NPC ship crews (voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm and + * pirate_crew_human.dm, human NPC migration phase B): how they are dressed, armed and ranked, how they + * fire their real guns, how a hull's crew and a boarding wave count as beaten (dead, down or cuffed, + * owner ruling OC-7), what happens to one spaced (no forced death, PI-3), how boarders come by pod, and + * the bosses' real kit and finishers. + * + * Fork defines are included after the tests, so their values are written out: ranks + * HUMAN_NPC_RANK_ELITE 1 and _BOSS 2; blackboard keys by their strings; combat states "boarding", + * "boarding_cooldown"; HV_FINISHER_DAMAGED 3. Nothing waits on a timer or an AI walk. + */ + +/// The boarding test controller from voidcrew_npc_boarding_docking.dm, with the turfs its pods may use +/datum/unit_test/voidcrew_pirate_crew/human_crew + abstract_type = /datum/unit_test/voidcrew_pirate_crew/human_crew + +// ===== DRESS, RANK AND KIT ===== + +/// Every faction's crew is dressed for its role, ranked and armed; boarders wear a sealed suit and air, +/// crew aboard their own hull don't; medieval footsoldiers refuse guns and their crossbowmen don't. +/datum/unit_test/voidcrew_pirate_crew/human_crew/dress + +/datum/unit_test/voidcrew_pirate_crew/human_crew/dress/Run() + for(var/faction in list("silverscale", "skeleton", "grey", "lustrous", "interdyne", "irs", "medieval")) + var/melee_type = text2path("/mob/living/carbon/human/npc/ship_crew/pirate/[faction]/melee") + var/ranged_type = text2path("/mob/living/carbon/human/npc/ship_crew/pirate/[faction]/ranged") + var/captain_type = text2path("/mob/living/carbon/human/npc/ship_crew/pirate/[faction]/captain") + var/mob/living/carbon/human/npc/ship_crew/melee = build_npc(melee_type, spot(0, 0)) + var/mob/living/carbon/human/npc/ship_crew/ranged = build_npc(ranged_type, spot(1, 0)) + var/mob/living/carbon/human/npc/ship_crew/captain = build_npc(captain_type, spot(2, 0)) + TEST_ASSERT(human_npc_held_melee_weapon(melee) && !human_npc_held_gun(melee), "[melee_type] isn't holding a blade or a club") + TEST_ASSERT_NOTNULL(human_npc_held_gun(ranged), "[ranged_type] isn't holding a gun") + var/obj/item/gun/ballistic/bullet_gun = human_npc_held_gun(ranged) + if(istype(bullet_gun)) + TEST_ASSERT_NOTNULL(human_npc_find_ammo(ranged, bullet_gun), "[ranged_type] carries no spare ammunition") + TEST_ASSERT_EQUAL(captain.npc_rank, 1, "[captain_type] isn't an elite") + TEST_ASSERT_EQUAL(captain.npc_damage_taken, 0.8, "[captain_type] isn't as tough as a captain should be") + TEST_ASSERT_EQUAL(melee.npc_rank, 0, "[melee_type] isn't rank and file") + TEST_ASSERT_EQUAL(melee.ai_controller?.type, /datum/ai_controller/basic_controller/pirate_crew/human, "[melee_type] isn't on the human pirate crew controller") + TEST_ASSERT(melee.npc_death_cash > 0 && ranged.npc_death_cash > 0 && captain.npc_death_cash > melee.npc_death_cash, "[faction] crew lost their plunder") + // Their ID card names them, so a helmet doesn't make them "Unknown" + var/obj/item/card/id/card = melee.wear_id + TEST_ASSERT(istype(card) && card.registered_name == melee.real_name, "[melee_type] carries no ID card with its name") + TEST_ASSERT(human_npc_gear_locked(card), "[melee_type]'s ID card isn't locked to it") + // Aboard its own hull in good air: no space suit + TEST_ASSERT(!istype(melee.wear_suit, /obj/item/clothing/suit/space), "[melee_type] wears a space suit in good air") + // A boarder: sealed and on its own air, unless it is a skeleton + var/mob/living/carbon/human/npc/ship_crew/boarder = build_npc(melee_type, spot(3, 0), boarding_outfit_for(melee_type)) + if(faction == "skeleton") + TEST_ASSERT(!istype(boarder.wear_suit, /obj/item/clothing/suit/space), "A skeleton boarder was given a space suit it doesn't need") + else + TEST_ASSERT(boarder.wear_suit && (boarder.wear_suit.clothing_flags & STOPSPRESSUREDAMAGE), "A [faction] boarder has no sealed suit") + TEST_ASSERT(boarder.head && (boarder.head.clothing_flags & STOPSPRESSUREDAMAGE), "A [faction] boarder has no sealed helmet") + TEST_ASSERT_NOTNULL(boarder.internal, "A [faction] boarder isn't breathing from a tank") + if(faction == "medieval") + TEST_ASSERT(HAS_TRAIT(melee, TRAIT_NOGUNS), "A medieval footsoldier will use guns") + TEST_ASSERT(HAS_TRAIT(captain, TRAIT_NOGUNS), "The medieval warlord will use guns") + TEST_ASSERT(!HAS_TRAIT(ranged, TRAIT_NOGUNS), "A medieval crossbowman can't use his crossbow") + for(var/mob/living/carbon/human/npc/built as anything in list(melee, ranged, captain, boarder)) + qdel(built) + +/// The captain's key isn't gear: it is on him, not locked, and drops at his feet when he dies. Crew are +/// tracked by the hull as they are built. +/datum/unit_test/voidcrew_pirate_crew/human_crew/captain_key + +/datum/unit_test/voidcrew_pirate_crew/human_crew/captain_key/Run() + var/obj/structure/overmap/ship/npc/pirate/hull = allocate(/obj/structure/overmap/ship/npc/pirate) + var/mob/living/carbon/human/npc/ship_crew/pirate/grey/captain/captain = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/grey/captain, spot(1, 1)) + hull.on_captain_built(captain) + var/obj/item/ship_key/key = locate() in captain.get_all_contents() + TEST_ASSERT_NOTNULL(key, "The captain doesn't carry the ship key") + TEST_ASSERT(!human_npc_gear_locked(key) && !human_npc_gear_consumable(key), "The ship key was locked to the captain like his gear") + TEST_ASSERT(captain in hull.tracked_crew, "The hull doesn't track its captain") + TEST_ASSERT(hull.crew_ever_spawned, "The hull doesn't know it has a crew") + TEST_ASSERT_EQUAL(pirate_crew_of(hull)?.members[captain], "captain", "The captain isn't his crew's captain") + captain.death() + TEST_ASSERT_EQUAL(key.loc, get_turf(captain), "The ship key didn't drop at the dead captain's feet") + qdel(pirate_crew_of(hull)) + +// ===== OUT OF THE FIGHT (OC-7) ===== + +/// A hull's crew is wiped once every crewman is dead, down or cuffed; crew still being built hold it open. +/datum/unit_test/voidcrew_pirate_crew/human_crew/crew_wipe + +/datum/unit_test/voidcrew_pirate_crew/human_crew/crew_wipe/Run() + var/obj/structure/overmap/ship/npc/pirate/hull = allocate(/obj/structure/overmap/ship/npc/pirate) + var/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee/first = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee, spot(0, 1)) + var/mob/living/carbon/human/npc/ship_crew/pirate/grey/ranged/second = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/grey/ranged, spot(1, 1)) + hull.on_crewman_built(first) + hull.on_crewman_built(second) + // One more still waiting in the queue + var/datum/human_npc_order/pending = new /datum/human_npc_order(/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee, spot(2, 1)) + hull.crew_orders += pending + + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(first.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the crewman could not be cuffed") + TEST_ASSERT(!hull.crew_wipe_handled, "Cuffing one of two crewmen wiped the crew") + second.adjustOxyLoss(110) + TEST_ASSERT_EQUAL(second.stat, SOFT_CRIT, "Test fixture: the second crewman isn't in soft crit") + TEST_ASSERT(!hull.crew_wipe_handled, "The crew was wiped with another crewman still being built") + qdel(pending) + hull.check_crew_wipe() + TEST_ASSERT(hull.crew_wipe_handled, "A crew all down or cuffed wasn't wiped") + TEST_ASSERT_EQUAL(hull.count_live_crew_aboard(), 0, "Crew down or cuffed still count as aboard and fighting") + qdel(pirate_crew_of(hull)) + +/// A wave is over once every boarder is dead, down or cuffed, and none is still on its way. +/datum/unit_test/voidcrew_pirate_crew/human_crew/wave_out_of_fight + +/datum/unit_test/voidcrew_pirate_crew/human_crew/wave_out_of_fight/Run() + var/obj/structure/overmap/ship/npc/pirate/pirate = allocate(/obj/structure/overmap/ship/npc/pirate) + var/obj/structure/overmap/ship/target = allocate(/obj/structure/overmap/ship) + var/datum/ai_controller/npc_ship/boarding_docking_test/profile/controller = allocate(/datum/ai_controller/npc_ship/boarding_docking_test/profile, pirate) + controller.test_zone = allocate(/datum/overmap_zone, 3) + target.state = "flying" + pirate.state = "flying" + controller.set_target(target) + controller.set_combat_state("boarding") + var/list/wave = list() + controller.blackboard["npc_boarding_wave_boarders"] = wave + var/mob/living/carbon/human/npc/ship_crew/pirate/irs/melee/boarder = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/irs/melee, spot(1, 1)) + controller.track_wave_boarder(wave, boarder) + TEST_ASSERT(boarder in wave, "The boarder didn't join its wave") + + var/datum/human_npc_order/coming = new /datum/human_npc_order(/mob/living/carbon/human/npc/ship_crew/pirate/irs/melee, spot(2, 1)) + controller.wave_orders += coming + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(boarder.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the boarder could not be cuffed") + TEST_ASSERT_EQUAL(controller.get_combat_state(), "boarding", "The wave ended with a boarder still on its way") + qdel(coming) + controller.check_wave_complete() + TEST_ASSERT_EQUAL(controller.get_combat_state(), "boarding_cooldown", "A wave whose last boarder was cuffed didn't end") + controller.clear_target() + +/// Spaced boarders are dropped from their wave but not killed; one adrift where no player can see it is +/// cleaned up by its crew after a while. +/datum/unit_test/voidcrew_pirate_crew/human_crew/spaced + +/datum/unit_test/voidcrew_pirate_crew/human_crew/spaced/Run() + change_turf(spot(4, 4), /turf/open/space) + var/obj/structure/overmap/ship/npc/pirate/pirate = allocate(/obj/structure/overmap/ship/npc/pirate) + var/obj/structure/overmap/ship/target = allocate(/obj/structure/overmap/ship) + var/datum/ai_controller/npc_ship/boarding_docking_test/profile/controller = allocate(/datum/ai_controller/npc_ship/boarding_docking_test/profile, pirate) + controller.test_zone = allocate(/datum/overmap_zone, 3) + target.state = "flying" + pirate.state = "flying" + controller.set_target(target) + controller.set_combat_state("boarding") + var/list/wave = list() + controller.blackboard["npc_boarding_wave_boarders"] = wave + var/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee/boarder = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee, spot(4, 4), /datum/human_npc_outfit_table/pirate/grey/boarding) + TEST_ASSERT(pirate_crew_enlist_raider(boarder, target), "The boarder could not join the raid") + controller.track_wave_boarder(wave, boarder) + TEST_ASSERT(pirate_crew_member_adrift(boarder), "A boarder floating in space isn't adrift") + controller.check_boarders_in_space() + TEST_ASSERT(!(boarder in wave), "A spaced boarder still counts toward its wave") + TEST_ASSERT(boarder.stat != DEAD, "A spaced boarder was killed") + + var/datum/pirate_crew/raid = pirate_crew_of(target, TRUE) + raid.check_adrift() + TEST_ASSERT(!QDELETED(boarder), "A boarder was cleaned up the moment it went adrift") + raid.adrift_since[boarder] = world.time - 3 MINUTES + raid.check_adrift() + TEST_ASSERT(QDELETED(boarder), "A boarder adrift for minutes with nobody to see it wasn't cleaned up") + controller.clear_target() + +/// The pod cap counts the raid's boarders aboard still fighting, human or not. +/datum/unit_test/voidcrew_pirate_crew/human_crew/pod_cap + +/datum/unit_test/voidcrew_pirate_crew/human_crew/pod_cap/Run() + var/obj/structure/overmap/ship/target = allocate(/obj/structure/overmap/ship) + var/obj/docking_port/mobile/voidcrew/port = allocate(/obj/docking_port/mobile/voidcrew, run_loc_floor_bottom_left) + port.shuttle_areas = list((get_area(spot(1, 1))) = TRUE) + target.shuttle = port + var/mob/living/carbon/human/npc/ship_crew/pirate/irs/melee/boarder = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/irs/melee, spot(1, 1)) + TEST_ASSERT(pirate_crew_enlist_raider(boarder, target), "The boarder could not join the raid") + var/datum/ai_behavior/npc_ship/fire_boarding_pods/pods = GET_AI_BEHAVIOR(/datum/ai_behavior/npc_ship/fire_boarding_pods) + var/counted = pods.count_hostile_mobs_on_ship(target) + boarder.adjustOxyLoss(110) + var/counted_down = pods.count_hostile_mobs_on_ship(target) + qdel(pirate_crew_of(target, TRUE)) + target.shuttle = null + port.shuttle_areas = list() + qdel(port, force = TRUE) + TEST_ASSERT_EQUAL(counted, 1, "The pod cap didn't count a human boarder aboard") + TEST_ASSERT_EQUAL(counted_down, 0, "The pod cap still counts a boarder in crit") + +// ===== BOARDING PODS ===== + +/// A human boarder is queued, built, put in its pod and sent, and its caller hears about it; a raid called +/// off before it is built never sends it. +/datum/unit_test/voidcrew_pirate_crew/human_crew/pods + +/datum/unit_test/voidcrew_pirate_crew/human_crew/pods/Run() + var/obj/structure/overmap/ship/npc/pirate/pirate = allocate(/obj/structure/overmap/ship/npc/pirate) + var/obj/structure/overmap/ship/target = allocate(/obj/structure/overmap/ship) + var/datum/ai_controller/npc_ship/boarding_docking_test/profile/controller = allocate(/datum/ai_controller/npc_ship/boarding_docking_test/profile, pirate) + controller.test_zone = allocate(/datum/overmap_zone, 3) + target.state = "flying" + pirate.state = "flying" + controller.set_target(target) + var/list/heard = list() + controller.blackboard["npc_boarding_wave_boarders"] = heard + var/sent = create_boarding_pod(spot(2, 2), target, pirate, /mob/living/carbon/human/npc/ship_crew/pirate/lustrous/melee, on_boarder = CALLBACK(controller, TYPE_PROC_REF(/datum/ai_controller/npc_ship, track_wave_boarder), heard)) + TEST_ASSERT(istype(sent, /datum/human_npc_order), "A human boarder wasn't queued") + var/datum/human_npc_order/order = sent + TEST_ASSERT(order in controller.boarding_orders, "The encounter isn't tracking its queued boarder") + var/mob/living/carbon/human/npc/ship_crew/boarder = order.fulfil() + TEST_ASSERT_NOTNULL(boarder, "The queued boarder wasn't built") + TEST_ASSERT(istype(boarder.loc, /obj/structure/closet/supplypod/boarding), "The boarder isn't in its pod") + TEST_ASSERT(boarder in heard, "The caller didn't hear about its boarder") + TEST_ASSERT(boarder.wear_suit && (boarder.wear_suit.clothing_flags & STOPSPRESSUREDAMAGE), "A boarder came without a sealed suit") + + var/datum/human_npc_order/second = create_boarding_pod(spot(3, 2), target, pirate, /mob/living/carbon/human/npc/ship_crew/pirate/lustrous/melee) + TEST_ASSERT(istype(second), "The second boarder wasn't queued") + controller.cancel_boarding_orders() + TEST_ASSERT(QDELETED(second), "Calling the raid off left a boarder in the spawn queue") + + target.state = "idle" + for(var/obj/effect/pod_landingzone/boarding/landing in spot(2, 2)) + landing.cancel_if_target_docked() + controller.clear_target() + +// ===== GUNS ===== + +/// Each kind of gun is handled its own way; a gunner fires its real gun in volleys, and a hit during a +/// shotgun's aim spoils the shot. +/datum/unit_test/voidcrew_pirate_crew/human_crew/guns + +/datum/unit_test/voidcrew_pirate_crew/human_crew/guns/Run() + var/list/expected = list( + /obj/item/gun/ballistic/automatic/pistol = /datum/pirate_gun_handling/pistol, + /obj/item/gun/ballistic/automatic/mini_uzi = /datum/pirate_gun_handling/smg, + /obj/item/gun/ballistic/shotgun/lethal = /datum/pirate_gun_handling/shotgun, + /obj/item/gun/energy/e_gun/lethal = /datum/pirate_gun_handling/energy, + /obj/item/gun/energy/laser = /datum/pirate_gun_handling/energy, + /obj/item/gun/energy/recharge/ebow/large = /datum/pirate_gun_handling/crossbow, + ) + for(var/gun_type in expected) + var/obj/item/gun/gun = allocate(gun_type, spot(4, 4)) + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + TEST_ASSERT_EQUAL(handling.type, expected[gun_type], "[gun_type] is handled as [handling.type]") + TEST_ASSERT(handling.shots >= 1 && handling.volley_gap > 0, "[gun_type] never fires") + var/datum/pirate_gun_handling/shotgun_handling = pirate_gun_handling_for(allocate(/obj/item/gun/ballistic/shotgun/lethal, spot(4, 4))) + TEST_ASSERT_EQUAL(shotgun_handling.close_range, 3, "A shotgunner doesn't walk in close") + + // A pistol volley: two pulls, a short gap between them, then a long one + var/mob/living/carbon/human/npc/ship_crew/gunner = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/irs/ranged, spot(0, 0), /datum/outfit/piratecorpse/faction/irs/ranged/pistol) + var/mob/living/carbon/human/consistent/mark = allocate(/mob/living/carbon/human/consistent, spot(4, 0)) + var/obj/item/gun/ballistic/automatic/pistol/pistol = locate() in gunner.held_items + TEST_ASSERT_NOTNULL(pistol, "Test fixture: the gunner holds no pistol") + pistol.can_misfire = FALSE + var/datum/ai_controller/controller = gunner.ai_controller + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, mark) + var/datum/ai_behavior/pirate_crew_human_shoot/shoot = GET_AI_BEHAVIOR(/datum/ai_behavior/pirate_crew_human_shoot) + var/rounds = pistol.get_ammo() + gunner.next_move = 0 + gunner.next_click = 0 + shoot.perform(1, controller, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + TEST_ASSERT(pistol.get_ammo() < rounds, "The gunner's first pull didn't fire the pistol") + TEST_ASSERT_EQUAL(controller.blackboard["pirate_volley_left"], 1, "A pistol volley isn't two shots") + TEST_ASSERT(controller.blackboard["human_npc_next_shot"] <= world.time + 0.3 SECONDS, "The second shot of a pistol volley waits too long") + rounds = pistol.get_ammo() + shoot.perform(1, controller, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + TEST_ASSERT_EQUAL(pistol.get_ammo(), rounds, "The gunner fired again before the gap between its shots") + + // A shotgun aims a moment first; a hit in that moment spoils it + var/mob/living/carbon/human/npc/ship_crew/shotgunner = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/irs/ranged, spot(0, 2), /datum/outfit/piratecorpse/faction/irs/ranged/shotgun) + var/datum/ai_controller/pump_controller = shotgunner.ai_controller + pump_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, mark) + shotgunner.next_move = 0 + shotgunner.next_click = 0 + shoot.perform(1, pump_controller, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + TEST_ASSERT(pump_controller.blackboard["pirate_windup_until"] > world.time, "A shotgun fired without aiming first") + shotgunner.apply_damage(5, BRUTE, BODY_ZONE_CHEST) + TEST_ASSERT(!pump_controller.blackboard["pirate_windup_until"], "A hit while aiming didn't spoil the shot") + +/// Only a boss puts its gun to the head of someone down beside it; anyone else never aims there at point blank. +/datum/unit_test/voidcrew_pirate_crew/human_crew/executions + +/datum/unit_test/voidcrew_pirate_crew/human_crew/executions/Run() + var/mob/living/carbon/human/npc/ship_crew/chief = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/irs/boss, spot(1, 1)) + var/mob/living/carbon/human/npc/ship_crew/agent = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/irs/ranged, spot(1, 3), /datum/outfit/piratecorpse/faction/irs/ranged/energy_gun) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(2, 2)) + victim.adjustOxyLoss(110) + TEST_ASSERT_EQUAL(victim.stat, SOFT_CRIT, "Test fixture: the victim isn't in soft crit") + TEST_ASSERT_EQUAL(chief.npc_rank, 2, "The Chief Auditor isn't a boss") + TEST_ASSERT(pirate_crew_human_wants_execution(chief.ai_controller, chief, victim), "A boss gunner doesn't go for a downed hunter's head") + TEST_ASSERT(!pirate_crew_human_wants_execution(agent.ai_controller, agent, victim), "An ordinary gunner tries to execute") + for(var/i in 1 to 30) + TEST_ASSERT(pirate_crew_human_aim_zone(agent, victim) != BODY_ZONE_HEAD, "An ordinary gunner aimed at the head of someone helpless beside it") + +// ===== CAPTAINS AND BOSSES ===== + +/// Captains and bosses carry a stimulant and inject it once they have someone to fight; Davy Jones drinks +/// from his flask when hurt. +/datum/unit_test/voidcrew_pirate_crew/human_crew/self_care + +/datum/unit_test/voidcrew_pirate_crew/human_crew/self_care/Run() + var/mob/living/carbon/human/npc/ship_crew/noble = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/silverscale/captain, spot(1, 1)) + var/mob/living/carbon/human/consistent/foe = allocate(/mob/living/carbon/human/consistent, spot(4, 4)) + TEST_ASSERT_NOTNULL(pirate_crew_human_stimulant(noble), "A captain carries no stimulant") + var/datum/ai_controller/controller = noble.ai_controller + var/datum/ai_planning_subtree/pirate_crew_human_self_care/care = new + TEST_ASSERT_NULL(care.SelectBehaviors(controller, 1), "A captain with nobody to fight injected its stimulant") + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, foe) + TEST_ASSERT_EQUAL(care.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "A captain in a fight didn't reach for its stimulant") + var/datum/ai_behavior/pirate_crew_human_use_on_self/use = GET_AI_BEHAVIOR(/datum/ai_behavior/pirate_crew_human_use_on_self) + var/finished = FALSE + for(var/step in 1 to 6) + noble.next_move = 0 + noble.next_click = 0 + var/result = use.perform(1, controller, "pirate_self_item") + TEST_ASSERT(!(result & AI_BEHAVIOR_FAILED), "Using the stimulant failed at step [step]") + if(result & AI_BEHAVIOR_SUCCEEDED) + finished = TRUE + break + TEST_ASSERT(finished, "The captain never used its stimulant") + TEST_ASSERT(noble.reagents.has_reagent(/datum/reagent/medicine/stimulants), "The stimulant isn't in the captain's blood") + TEST_ASSERT(human_npc_held_melee_weapon(noble), "The captain didn't take up its sabre again") + + var/mob/living/carbon/human/npc/ship_crew/davy = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/skeleton/boss, spot(1, 3)) + var/obj/item/reagent_containers/cup/glass/flask/flask = pirate_crew_human_flask(davy) + TEST_ASSERT_NOTNULL(flask, "Davy Jones has no flask") + TEST_ASSERT(flask.reagents.has_reagent(/datum/reagent/medicine/omnizine/cursed_grog), "Davy Jones's flask has no grog in it") + TEST_ASSERT(human_npc_gear_locked(flask), "Davy Jones's flask isn't locked to him") + var/full = flask.reagents.total_volume + // Already stimmed, and hurt: he drinks + davy.reagents.add_reagent(/datum/reagent/medicine/stimulants, 5) + davy.adjustBruteLoss(80) + var/datum/ai_controller/davy_controller = davy.ai_controller + davy_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, foe) + TEST_ASSERT_EQUAL(care.SelectBehaviors(davy_controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "A hurt Davy Jones doesn't reach for his flask") + TEST_ASSERT_EQUAL(davy_controller.blackboard["pirate_self_item"], flask, "Davy Jones reached for something other than his flask") + for(var/step in 1 to 6) + davy.next_move = 0 + davy.next_click = 0 + if(use.perform(1, davy_controller, "pirate_self_item") & AI_BEHAVIOR_SUCCEEDED) + break + TEST_ASSERT(flask.reagents.total_volume < full, "Davy Jones didn't drink from his flask") + +/// The bosses' real kit: rank and toughness, the Black Knight's hulk and size and his refusal of guns, the +/// finishers of the Black Knight and the Robust One, and a stimulant for the rest. +/datum/unit_test/voidcrew_pirate_crew/human_crew/bosses + +/datum/unit_test/voidcrew_pirate_crew/human_crew/bosses/Run() + for(var/faction in list("silverscale", "skeleton", "grey", "lustrous", "interdyne", "irs", "medieval")) + var/boss_type = text2path("/mob/living/carbon/human/npc/ship_crew/pirate/[faction]/boss") + var/mob/living/carbon/human/npc/ship_crew/boss = build_npc(boss_type, spot(2, 2)) + TEST_ASSERT_NOTNULL(boss, "[boss_type] wasn't built") + TEST_ASSERT_EQUAL(boss.npc_rank, 2, "[boss_type] isn't a boss") + TEST_ASSERT(boss.npc_damage_taken < 0.8, "[boss_type] is no tougher than a captain") + TEST_ASSERT(human_npc_held_melee_weapon(boss) || human_npc_held_gun(boss), "[boss_type] holds no weapon") + TEST_ASSERT(boss.maxHealth == 100, "[boss_type] was given a health pool") + TEST_ASSERT(!HAS_TRAIT_FROM(boss, TRAIT_STUNIMMUNE, INNATE_TRAIT), "[boss_type] is innately stun immune") + TEST_ASSERT(boss.npc_death_cash >= 3000, "[boss_type] lost its plunder") + if(faction == "medieval") + TEST_ASSERT(boss.dna.check_mutation(/datum/mutation/hulk), "The Black Knight isn't a hulk") + TEST_ASSERT(boss.dna.check_mutation(/datum/mutation/gigantism), "The Black Knight isn't huge") + TEST_ASSERT(HAS_TRAIT(boss, TRAIT_NOGUNS), "The Black Knight will use guns") + else + TEST_ASSERT_NOTNULL(pirate_crew_human_stimulant(boss), "[boss_type] carries no stimulant") + if(faction == "medieval" || faction == "grey") + TEST_ASSERT_NOTNULL(boss.GetComponent(/datum/component/hv_finisher_user), "[boss_type] has no finisher") + qdel(boss) + +/// HV-4 and HV-5: a human boss's finisher pins with a real aggressive grab and strikes with the real weapon +/// in its hand; it isn't rooted, takes no extra damage, and doesn't reel when made to let go. +/datum/unit_test/voidcrew_pirate_crew/human_crew/finisher + +/datum/unit_test/voidcrew_pirate_crew/human_crew/finisher/Run() + var/mob/living/carbon/human/npc/ship_crew/robust = build_npc(/mob/living/carbon/human/npc/ship_crew/pirate/grey/boss, spot(1, 1)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(2, 1)) + victim.adjustOxyLoss(110) + TEST_ASSERT_EQUAL(victim.stat, SOFT_CRIT, "Test fixture: the victim isn't down") + var/datum/hv_finisher/robust_one/finisher = new(robust, victim) + TEST_ASSERT(finisher.real_hits, "A human boss's finisher isn't done with real hits") + TEST_ASSERT(finisher.start(), "The Robust One's finisher wouldn't start on someone down beside it") + var/datum/status_effect/hv_finishing/busy = robust.has_status_effect(/datum/status_effect/hv_finishing) + TEST_ASSERT_NOTNULL(busy, "The Robust One isn't busy with its finisher") + TEST_ASSERT_EQUAL(busy.vulnerability, 1, "A human boss takes extra damage while it finishes someone") + TEST_ASSERT(!HAS_TRAIT(robust, TRAIT_IMMOBILIZED), "A human boss is rooted while it finishes someone") + + TEST_ASSERT(finisher.take_grip(), "The Robust One couldn't get hold of the victim") + TEST_ASSERT_EQUAL(robust.pulling, victim, "The pin isn't a real grab") + TEST_ASSERT(robust.grab_state >= GRAB_AGGRESSIVE, "The pin isn't an aggressive grab") + TEST_ASSERT(!HAS_TRAIT(victim, TRAIT_IMMOBILIZED), "The victim is held by a trait as well as the grab") + + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + var/head_before = head.get_damage() + TEST_ASSERT(finisher.real_blow(BODY_ZONE_HEAD), "The Robust One didn't swing its toolbox") + TEST_ASSERT(head.get_damage() > head_before, "The toolbox blow didn't hurt the victim's head") + + // A heavy hit makes it let go: the grab breaks, and it doesn't reel + robust.apply_damage(100, BRUTE, BODY_ZONE_CHEST) + TEST_ASSERT(!finisher.running, "A heavy hit didn't make the Robust One let go") + TEST_ASSERT_EQUAL(finisher.result, 3, "The finisher didn't end because the boss was hurt") + TEST_ASSERT(robust.pulling != victim, "The Robust One kept hold of the victim after letting go") + TEST_ASSERT(!robust.has_status_effect(/datum/status_effect/hv_finisher_reeling), "A human boss reeled after letting go") + qdel(finisher) + +/// AM-15: suppression sweeps count the human pirate crews. +/datum/unit_test/voidcrew_pirate_crew/human_crew/suppression_counts + +/datum/unit_test/voidcrew_pirate_crew/human_crew/suppression_counts/Run() + var/datum/mission_objective/kill_count/counter = new + TEST_ASSERT(ispath(/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee, /mob/living/carbon/human/npc/ship_crew/pirate), "Test fixture: the grey tider isn't a pirate crewman") + var/counts = FALSE + for(var/base_type in counter.mob_base_types) + if(ispath(/mob/living/carbon/human/npc/ship_crew/pirate/grey/melee, base_type)) + counts = TRUE + TEST_ASSERT(counts, "A suppression sweep doesn't count the human pirate crews") + qdel(counter) From 3899f6286fd115c2c5b9c3a0dbb48c306598bd4e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:04:33 -0800 Subject: [PATCH 045/472] Keep the ruin NPC tests to what the camp spawned and to ruin, shuttle and hull maps --- voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm | 5 +++-- voidcrew/modules/unit_tests/voidcrew_human_npcs_ruins.dm | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm b/voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm index 09ac92898cc..e7f905bf9f0 100644 --- a/voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm +++ b/voidcrew/modules/human_npcs/ruins/ruin_npc_troopers.dm @@ -7,8 +7,9 @@ * * Zones (/datum/human_npc_outfit_table/ruin): green wears what the trooper wore; yellow adds armour; red * adds a helmet and a medipen. The trooper weapon ladder (voidcrew/edits/mobs/weapon_drops.dm) lives in - * the tables: Syndicate SMGs and shotguns, and the Nanotrasen guns, are pistols outside the red zone, and - * the Syndicate sword guard has only a knife in the green. + * the tables: Syndicate SMGs and shotguns are pistols outside the red zone (stormtroopers' SMGs only in + * the green), and the Syndicate sword guard has only a knife in the green. Nanotrasen officers only ever + * carried the M1911 in Voidcrew maps. * * Designed drops stay as the troopers had them: only the Russians carried any (a kitchen knife, a Nagant * revolver). Their clothes and guns are gear (decision D3) and never loot. The pirates' coin-snatching diff --git a/voidcrew/modules/unit_tests/voidcrew_human_npcs_ruins.dm b/voidcrew/modules/unit_tests/voidcrew_human_npcs_ruins.dm index 1edaabc93d1..91a9a4d8ff9 100644 --- a/voidcrew/modules/unit_tests/voidcrew_human_npcs_ruins.dm +++ b/voidcrew/modules/unit_tests/voidcrew_human_npcs_ruins.dm @@ -234,17 +234,19 @@ TEST_ASSERT("'Snares' Okafor" in names, "The trailboss lost the contract's name") mission.forget_quest_atom(mission.quest_atom) camp.deactivate() + // The camp's dressing and its cache (with the evidence inside). for(var/obj/thing in range(3, location)) - if(!isturf(thing) && !istype(thing, /obj/effect/landmark)) + if(istype(thing, /obj/structure/bonfire) || istype(thing, /obj/structure/barricade) || istype(thing, /obj/structure/bed) || istype(thing, /obj/item/bedsheet) || istype(thing, /obj/structure/closet)) qdel(thing) -/// No Voidcrew map places a trooper or hermit basic mob any more, and the derelict shuttles inside ruins load +/// No Voidcrew ruin, shuttle or hull map places a trooper or hermit basic mob any more, and the derelict shuttles inside ruins load /// their Voidcrew copies (the bounty lair's mafia is another phase's). /datum/unit_test/voidcrew_ruin_npc_maps /datum/unit_test/voidcrew_ruin_npc_maps/Run() var/list/maps = list() - ruin_npc_collect_maps("_maps/voidcrew/", maps) + for(var/root in list("_maps/voidcrew/RandomRuins/", "_maps/voidcrew/shuttles/", "_maps/voidcrew/ships/")) + ruin_npc_collect_maps(root, maps) TEST_ASSERT(length(maps) > 100, "The Voidcrew map scan found only [length(maps)] maps") for(var/map_path in maps) if(findtext(map_path, "bounty_lair_mafia_club.dmm")) From 6fcbba39a6264f21cc99eec18475c3d614548a8a Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:06:49 -0800 Subject: [PATCH 046/472] Burst heads on executions from ordinary sidearms and count a limbless body's whole round as the head --- voidcrew/_DEFINES/hyperviolence.dm | 2 +- .../hyperviolence/overlays_exec/executions.dm | 4 ++++ .../unit_tests/voidcrew_hyperviolence_corpses.dm | 5 +++-- .../voidcrew_hyperviolence_overlays_exec.dm | 13 +++++++------ 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/voidcrew/_DEFINES/hyperviolence.dm b/voidcrew/_DEFINES/hyperviolence.dm index bc5ceddb75e..4feca805af1 100644 --- a/voidcrew/_DEFINES/hyperviolence.dm +++ b/voidcrew/_DEFINES/hyperviolence.dm @@ -34,7 +34,7 @@ /// deals it through tg's normal damage; nothing else is added. #define HV_EXECUTION_POINT_BLANK_MULT 2 /// Damage an execution round must deal to the head (after armour) to burst it instead of holing it. -#define HV_EXECUTION_BURST_DAMAGE 80 +#define HV_EXECUTION_BURST_DAMAGE 50 /// A chambered round must deal at least this much to count as an execution round. #define HV_EXECUTION_MIN_DAMAGE 10 /// do_after interaction key for an execution wind-up. diff --git a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm index 0c196a8a813..ab574724e78 100644 --- a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm +++ b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm @@ -132,6 +132,10 @@ var/obj/projectile/round = attacking_item if(!istype(round) || round.fired_from != src || (damagetype != BRUTE && damagetype != BURN)) return + // A body without limbs (a basic mob) takes the whole round in the head it was pressed to. + if(!iscarbon(source)) + hv_execution_head_damage += damage + return var/zone if(isbodypart(def_zone)) var/obj/item/bodypart/limb = def_zone diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm index 0805ef40c8b..0fe8e247cd9 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm @@ -155,11 +155,12 @@ victim.fully_heal(HEAL_ALL) victim.Knockdown(30 SECONDS) gun.fire_cd = FALSE // the standing shot above just fired; skip its cooldown + gun.chambered.loaded_projectile.damage = 20 // doubled at the muzzle: 40, under the burst line var/round_damage = gun.chambered.loaded_projectile.damage * gun.projectile_damage_multiplier var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) gun.fire_gun(victim, gunner, TRUE, null) TEST_ASSERT(head.brute_dam >= round_damage * 1.5, "A head-aimed point-blank shot at a downed target was not an execution ([head.brute_dam] head damage from a [round_damage] round)") - TEST_ASSERT(head.hv_skull_wound >= 2, "A head-aimed 9mm execution left no skull wound for its damage") + TEST_ASSERT(head.hv_skull_wound >= 2, "A head-aimed light execution left no skull wound for its damage") /// Executing a badly hurt, paralysed pirate carries the outcome to its corpse: a light round the skull, a heavy one the head. /datum/unit_test/hv_npc_execution_corpse @@ -176,7 +177,7 @@ pirate.adjustBruteLoss(pirate.maxHealth * 0.85) pirate.Paralyze(30 SECONDS) TEST_ASSERT(gun.hv_execution_possible(gunner, pirate), "A paralysed pirate could not be executed") - gun.chambered.loaded_projectile.damage = 20 // doubled at the muzzle: 40, under HV_EXECUTION_BURST_DAMAGE 80 + gun.chambered.loaded_projectile.damage = 20 // doubled at the muzzle: 40, under HV_EXECUTION_BURST_DAMAGE 50 var/list/before = range(7, spot) TEST_ASSERT(gun.hv_fire_execution(gunner, pirate, null), "The execution shot did not fire") TEST_ASSERT(QDELETED(pirate), "The executed pirate did not die") diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm index 1fcc18062f7..0866bf7bca8 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm @@ -92,14 +92,15 @@ TEST_ASSERT(gun.hv_execution_possible(gunner, victim), "A downed target could not be executed") // The real click path: a point-blank, mouth-aimed shot, the wind-up, then the shot. + gun.chambered.loaded_projectile.damage = 20 // doubled at the muzzle: 40, under the burst line var/round_damage = gun.chambered.loaded_projectile.damage * gun.projectile_damage_multiplier var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) gun.fire_gun(victim, gunner, TRUE, null) - TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_HEAD), head, "A 9mm execution took the head off") + TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_HEAD), head, "A light execution took the head off") TEST_ASSERT(head.brute_dam >= round_damage * 1.5, "The execution round was not boosted at the muzzle ([head.brute_dam] head damage from a [round_damage] round)") - TEST_ASSERT_NOTEQUAL(victim.stat, DEAD, "A 9mm execution killed a healthy target with more than its round's damage") - TEST_ASSERT(head.hv_skull_wound >= 2, "A 9mm execution left no skull wound for its damage") - TEST_ASSERT_NOTNULL(victim.get_organ_slot(ORGAN_SLOT_BRAIN), "A 9mm execution destroyed the brain") + TEST_ASSERT_NOTEQUAL(victim.stat, DEAD, "A light execution killed a healthy target with more than its round's damage") + TEST_ASSERT(head.hv_skull_wound >= 2, "A light execution left no skull wound for its damage") + TEST_ASSERT_NOTNULL(victim.get_organ_slot(ORGAN_SLOT_BRAIN), "A light execution destroyed the brain") TEST_ASSERT_EQUAL(gun.projectile_damage_multiplier, initial(gun.projectile_damage_multiplier), "The point-blank boost stayed on the gun") // Someone in hard crit dies of the same round's real damage. @@ -109,7 +110,7 @@ TEST_ASSERT(dying.stat >= SOFT_CRIT, "Test fixture: 150 brute did not put the target in crit") gun.fire_cd = FALSE TEST_ASSERT(gun.hv_fire_execution(gunner, dying, null), "The execution shot did not fire") - TEST_ASSERT_EQUAL(dying.stat, DEAD, "A 9mm execution of someone in hard crit did not kill them") + TEST_ASSERT_EQUAL(dying.stat, DEAD, "A light execution of someone in hard crit did not kill them") /// A heavy enough round bursts the head, and the brain survives it. /datum/unit_test/hv_gun_execution_burst @@ -122,7 +123,7 @@ gun.can_misfire = FALSE gunner.put_in_active_hand(gun, forced = TRUE) gunner.zone_selected = BODY_ZONE_PRECISE_MOUTH - gun.chambered.loaded_projectile.damage = 60 // doubled at the muzzle: over HV_EXECUTION_BURST_DAMAGE 80 + gun.chambered.loaded_projectile.damage = 60 // doubled at the muzzle: over HV_EXECUTION_BURST_DAMAGE 50 ADD_TRAIT(victim, TRAIT_RESTRAINED, TRAIT_SOURCE_UNIT_TESTS) TEST_ASSERT(gun.hv_execution_possible(gunner, victim), "A restrained target could not be executed") From 8861c7d638bf7194bbd8d9260c7233845451cbf9 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:06:49 -0800 Subject: [PATCH 047/472] Burst heads on executions from ordinary sidearms and count a limbless body's whole round as the head --- voidcrew/_DEFINES/hyperviolence.dm | 2 +- .../hyperviolence/overlays_exec/executions.dm | 4 ++++ .../unit_tests/voidcrew_hyperviolence_corpses.dm | 5 +++-- .../voidcrew_hyperviolence_overlays_exec.dm | 13 +++++++------ 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/voidcrew/_DEFINES/hyperviolence.dm b/voidcrew/_DEFINES/hyperviolence.dm index bc5ceddb75e..4feca805af1 100644 --- a/voidcrew/_DEFINES/hyperviolence.dm +++ b/voidcrew/_DEFINES/hyperviolence.dm @@ -34,7 +34,7 @@ /// deals it through tg's normal damage; nothing else is added. #define HV_EXECUTION_POINT_BLANK_MULT 2 /// Damage an execution round must deal to the head (after armour) to burst it instead of holing it. -#define HV_EXECUTION_BURST_DAMAGE 80 +#define HV_EXECUTION_BURST_DAMAGE 50 /// A chambered round must deal at least this much to count as an execution round. #define HV_EXECUTION_MIN_DAMAGE 10 /// do_after interaction key for an execution wind-up. diff --git a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm index 0c196a8a813..ab574724e78 100644 --- a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm +++ b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm @@ -132,6 +132,10 @@ var/obj/projectile/round = attacking_item if(!istype(round) || round.fired_from != src || (damagetype != BRUTE && damagetype != BURN)) return + // A body without limbs (a basic mob) takes the whole round in the head it was pressed to. + if(!iscarbon(source)) + hv_execution_head_damage += damage + return var/zone if(isbodypart(def_zone)) var/obj/item/bodypart/limb = def_zone diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm index 0805ef40c8b..0fe8e247cd9 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_corpses.dm @@ -155,11 +155,12 @@ victim.fully_heal(HEAL_ALL) victim.Knockdown(30 SECONDS) gun.fire_cd = FALSE // the standing shot above just fired; skip its cooldown + gun.chambered.loaded_projectile.damage = 20 // doubled at the muzzle: 40, under the burst line var/round_damage = gun.chambered.loaded_projectile.damage * gun.projectile_damage_multiplier var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) gun.fire_gun(victim, gunner, TRUE, null) TEST_ASSERT(head.brute_dam >= round_damage * 1.5, "A head-aimed point-blank shot at a downed target was not an execution ([head.brute_dam] head damage from a [round_damage] round)") - TEST_ASSERT(head.hv_skull_wound >= 2, "A head-aimed 9mm execution left no skull wound for its damage") + TEST_ASSERT(head.hv_skull_wound >= 2, "A head-aimed light execution left no skull wound for its damage") /// Executing a badly hurt, paralysed pirate carries the outcome to its corpse: a light round the skull, a heavy one the head. /datum/unit_test/hv_npc_execution_corpse @@ -176,7 +177,7 @@ pirate.adjustBruteLoss(pirate.maxHealth * 0.85) pirate.Paralyze(30 SECONDS) TEST_ASSERT(gun.hv_execution_possible(gunner, pirate), "A paralysed pirate could not be executed") - gun.chambered.loaded_projectile.damage = 20 // doubled at the muzzle: 40, under HV_EXECUTION_BURST_DAMAGE 80 + gun.chambered.loaded_projectile.damage = 20 // doubled at the muzzle: 40, under HV_EXECUTION_BURST_DAMAGE 50 var/list/before = range(7, spot) TEST_ASSERT(gun.hv_fire_execution(gunner, pirate, null), "The execution shot did not fire") TEST_ASSERT(QDELETED(pirate), "The executed pirate did not die") diff --git a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm index 1fcc18062f7..0866bf7bca8 100644 --- a/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm +++ b/voidcrew/modules/unit_tests/voidcrew_hyperviolence_overlays_exec.dm @@ -92,14 +92,15 @@ TEST_ASSERT(gun.hv_execution_possible(gunner, victim), "A downed target could not be executed") // The real click path: a point-blank, mouth-aimed shot, the wind-up, then the shot. + gun.chambered.loaded_projectile.damage = 20 // doubled at the muzzle: 40, under the burst line var/round_damage = gun.chambered.loaded_projectile.damage * gun.projectile_damage_multiplier var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) gun.fire_gun(victim, gunner, TRUE, null) - TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_HEAD), head, "A 9mm execution took the head off") + TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_HEAD), head, "A light execution took the head off") TEST_ASSERT(head.brute_dam >= round_damage * 1.5, "The execution round was not boosted at the muzzle ([head.brute_dam] head damage from a [round_damage] round)") - TEST_ASSERT_NOTEQUAL(victim.stat, DEAD, "A 9mm execution killed a healthy target with more than its round's damage") - TEST_ASSERT(head.hv_skull_wound >= 2, "A 9mm execution left no skull wound for its damage") - TEST_ASSERT_NOTNULL(victim.get_organ_slot(ORGAN_SLOT_BRAIN), "A 9mm execution destroyed the brain") + TEST_ASSERT_NOTEQUAL(victim.stat, DEAD, "A light execution killed a healthy target with more than its round's damage") + TEST_ASSERT(head.hv_skull_wound >= 2, "A light execution left no skull wound for its damage") + TEST_ASSERT_NOTNULL(victim.get_organ_slot(ORGAN_SLOT_BRAIN), "A light execution destroyed the brain") TEST_ASSERT_EQUAL(gun.projectile_damage_multiplier, initial(gun.projectile_damage_multiplier), "The point-blank boost stayed on the gun") // Someone in hard crit dies of the same round's real damage. @@ -109,7 +110,7 @@ TEST_ASSERT(dying.stat >= SOFT_CRIT, "Test fixture: 150 brute did not put the target in crit") gun.fire_cd = FALSE TEST_ASSERT(gun.hv_fire_execution(gunner, dying, null), "The execution shot did not fire") - TEST_ASSERT_EQUAL(dying.stat, DEAD, "A 9mm execution of someone in hard crit did not kill them") + TEST_ASSERT_EQUAL(dying.stat, DEAD, "A light execution of someone in hard crit did not kill them") /// A heavy enough round bursts the head, and the brain survives it. /datum/unit_test/hv_gun_execution_burst @@ -122,7 +123,7 @@ gun.can_misfire = FALSE gunner.put_in_active_hand(gun, forced = TRUE) gunner.zone_selected = BODY_ZONE_PRECISE_MOUTH - gun.chambered.loaded_projectile.damage = 60 // doubled at the muzzle: over HV_EXECUTION_BURST_DAMAGE 80 + gun.chambered.loaded_projectile.damage = 60 // doubled at the muzzle: over HV_EXECUTION_BURST_DAMAGE 50 ADD_TRAIT(victim, TRAIT_RESTRAINED, TRAIT_SOURCE_UNIT_TESTS) TEST_ASSERT(gun.hv_execution_possible(gunner, victim), "A restrained target could not be executed") From 0d6236d7a9631eb51568454c31df32b820f8ec06 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:10:07 -0800 Subject: [PATCH 048/472] Keep cursed grog to the undead and stop gunners dancing at doors Davy Jones's grog heals only the undead, so blood drawn from him heals nobody. A gunner that can't step back from a door hits it or leaves it to someone else instead of stepping away and back. --- .../code/faction_pirates/faction_pirate_humans.dm | 9 +++++++-- voidcrew/modules/npc_ships/code/pirate_crew_human.dm | 10 +++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm index 461a2e44f69..4d5ef231cbf 100644 --- a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm +++ b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm @@ -561,18 +561,23 @@ list_reagents = list(/datum/reagent/medicine/omnizine/cursed_grog = 40) /** - * Cursed grog: knits flesh while it is in the blood, with a cold blue glow. Never synthesised, never + * Cursed grog: knits the flesh of the undead while it is in their blood, with a cold blue glow. To the + * living it is brine and bad rum, so blood drawn from Davy Jones heals nobody. Never synthesised, never * overdosed. Like any reagent it can be purged, and the flask runs dry. */ /datum/reagent/medicine/omnizine/cursed_grog name = "Cursed Grog" - description = "Sea water and something older. It knits flesh back together while it lasts." + description = "Sea water and something older. It keeps the dead from staying dead." color = "#2a4a7a" taste_description = "brine and old rum" healing = 3 overdose_threshold = 0 chemical_flags = NONE +/datum/reagent/medicine/omnizine/cursed_grog/on_mob_life(mob/living/carbon/affected_mob, seconds_per_tick, times_fired) + healing = (affected_mob.mob_biotypes & MOB_UNDEAD) ? initial(healing) : 0 + return ..() + /datum/reagent/medicine/omnizine/cursed_grog/on_mob_metabolize(mob/living/affected_mob) . = ..() affected_mob.add_filter("cursed_grog", 2, outline_filter(1, "#4488FF")) diff --git a/voidcrew/modules/npc_ships/code/pirate_crew_human.dm b/voidcrew/modules/npc_ships/code/pirate_crew_human.dm index 0656da17770..6d2011314e0 100644 --- a/voidcrew/modules/npc_ships/code/pirate_crew_human.dm +++ b/voidcrew/modules/npc_ships/code/pirate_crew_human.dm @@ -369,16 +369,12 @@ var/list/modifiers = (istype(blocker, /obj/machinery/door/firedoor) && pry_tool.tool_behaviour == TOOL_CROWBAR) ? list(RIGHT_CLICK = TRUE) : null controller.ai_interact(target = blocker, combat_mode = FALSE, modifiers = modifiers) return TRUE - // A gun, from a step back so the shot isn't a bash + // A gun, from a step back so the shot isn't a bash (pirate_crew_human_breach_spot() puts it there) var/obj/item/gun/gun = human_npc_loaded_gun(npc) - if(gun) - if(npc.Adjacent(blocker)) - step_away(npc, blocker) - return TRUE - if(get_dist(npc, blocker) <= 3 && can_see(npc, blocker, 3) && human_npc_ready_item(npc, gun)) + if(gun && !npc.Adjacent(blocker) && get_dist(npc, blocker) <= 3 && can_see(npc, blocker, 3)) + if(human_npc_ready_item(npc, gun)) npc.face_atom(blocker) controller.ai_interact(target = blocker, combat_mode = TRUE) - return TRUE return TRUE // A blade or a club that dents it var/obj/item/weapon = pirate_crew_human_door_weapon(npc, blocker) From e3c14c19dd45735844976bb61d6f9ed10d11229d Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:12:38 -0800 Subject: [PATCH 049/472] Only animate a blood pool's picture when it changes, so lobes don't vanish and regrow --- voidcrew/modules/hyperviolence/visuals/blood_motion.dm | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/hyperviolence/visuals/blood_motion.dm b/voidcrew/modules/hyperviolence/visuals/blood_motion.dm index 1f80bf4abbb..647a1c0a763 100644 --- a/voidcrew/modules/hyperviolence/visuals/blood_motion.dm +++ b/voidcrew/modules/hyperviolence/visuals/blood_motion.dm @@ -82,10 +82,16 @@ GLOBAL_LIST_INIT(hv_vis_pool_reach, list(10, 15, 26, 29)) /obj/effect/decal/cleanable/blood/hv_pool_show(state) var/old_tier = icon == HV_BLOOD_POOL_ICON ? hv_vis_pool_state_tier(icon_state) : 0 + // A pool is shown again every time more blood lands on it; only a picture that changed animates. + var/was_shown = icon == HV_BLOOD_POOL_ICON && icon_state == state + var/was_lobe = icon == HV_BLOOD_POOL_ICON && findtext(icon_state, "lobe") == 1 . = ..() - if(QDELETED(src) || dried) + if(QDELETED(src) || dried || was_shown) return if(findtext(state, "lobe") == 1) + // Only a lobe that is new on this tile flows out; a bigger lobe replacing a smaller one just shows. + if(was_lobe || old_tier) + return hv_vis_lobe_creep() return var/new_tier = hv_vis_pool_state_tier(state) From de0dcbb7d1648e129d311d7910bef79463e19e7b Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:35:16 -0800 Subject: [PATCH 050/472] Move bounty criminals, companions, the kingpin's crew and the mafia club onto human bodies Every bounty person is now a real human NPC: tg crit, stamina, stuns, cuffs, wounds, death and revival, with locked gear and real weapons. - Pay is the criminal's condition on the pad when Turn In is pressed: cuffed or stunned 100%, in crit 60%, dead 25%. There is no worst-state ratchet, and a bounty still pays once. A revived criminal pays by its condition. - The downed state, mercy clip, timed get-up, rally, stamina pools, health by tier, the 40% floor, fake cuffs, pull refusals, the meek dodge and the no-revive rule are gone. Toughness is real armour by tier plus npc_damage_taken. - Cuffs are tg's. A free criminal steps away from a reach for its wrists and runs or fights; a cuffed one works at its cuffs. Criminals, the kingpin and companions give up through tg's *surrender. - Criminals carry and draw real weapons with real spare ammunition, reload the player's way, and hold a shot when someone they shouldn't hit is in the line. Bars, camps and hiding use real chairs, glasses, closets and a held potted plant. - Meek criminals and the Ghost dodge with real Slipwire and Cascade chrome; meek criminals jab a stimpack when they bolt, bosses an ephedrine stimpack at the start of a fight. - Boss fire sets victims alight with tg fire stacks, blasts call ex_act on each person in the ring, and the Ghost cuts with a real knife. None of them skip someone who is down; only target choice spares the downed. Each extra hunter brings one more of the boss's crew instead of more health. - The kingpin and his goons fire real guns, the goons walk out when he falls, and the mafia club's goons, lieutenants and don are human; the don's mech stays a machine. - Hurt NPCs patch themselves up with what they carry when nobody is attacking them. - Fugitives and decoys at trader outposts no longer let shots pass through or refuse pulls, and leave by walking to the lift. A talked-down convict keeps its body and follows the crew. - The pad beam has no godmode and leaves the crew's cuffs behind. --- .../SpaceRuins/bounty_lair_mafia_club.dmm | 4 +- tgstation.dme | 3 + voidcrew/_DEFINES/bounties.dm | 6 +- voidcrew/_DEFINES/bounty_ai.dm | 130 +- voidcrew/_DEFINES/bounty_bosses.dm | 172 +- voidcrew/_DEFINES/bounty_criminals.dm | 151 +- voidcrew/_DEFINES/bounty_kingpin.dm | 64 +- voidcrew/_DEFINES/bounty_lairs.dm | 77 +- voidcrew/_DEFINES/bounty_outpost.dm | 8 +- voidcrew/_HELPERS/hostile_creatures.dm | 7 +- voidcrew/modules/ambient_npcs/convict.dm | 605 +++++-- .../modules/bounties/bounty_activities.dm | 218 ++- voidcrew/modules/bounties/bounty_admin.dm | 136 +- voidcrew/modules/bounties/bounty_ai.dm | 588 +++--- voidcrew/modules/bounties/bounty_ai_human.dm | 210 +++ voidcrew/modules/bounties/bounty_ai_meek.dm | 495 +++--- voidcrew/modules/bounties/bounty_ai_normal.dm | 710 +------- voidcrew/modules/bounties/bounty_board.dm | 2 +- voidcrew/modules/bounties/bounty_boss.dm | 928 +++++----- voidcrew/modules/bounties/bounty_boss_kits.dm | 742 ++++---- .../modules/bounties/bounty_companions.dm | 394 ++-- voidcrew/modules/bounties/bounty_criminal.dm | 1041 +++-------- voidcrew/modules/bounties/bounty_identity.dm | 8 +- voidcrew/modules/bounties/bounty_kingpin.dm | 1386 +++++++++------ voidcrew/modules/bounties/bounty_lair.dm | 152 +- .../modules/bounties/bounty_lair_mafia.dm | 1579 +++++++++-------- .../modules/bounties/bounty_lair_types.dm | 24 +- voidcrew/modules/bounties/bounty_outfits.dm | 124 ++ voidcrew/modules/bounties/bounty_outpost.dm | 656 ++++--- voidcrew/modules/bounties/bounty_placement.dm | 43 +- voidcrew/modules/bounties/bounty_posting.dm | 27 +- .../modules/bounties/bounty_restraints.dm | 339 ++-- voidcrew/modules/bounties/bounty_styles.dm | 656 +++++++ voidcrew/modules/bounties/bounty_turn_in.dm | 81 +- voidcrew/modules/bounties/bounty_types.dm | 51 +- .../bounties/strings/bounty_lair_mafia.json | 15 + .../hyperviolence/overlays_exec/executions.dm | 5 - voidcrew/modules/missions/mission_pad.dm | 2 +- voidcrew/modules/npc_scaling/zone_profiles.dm | 4 +- .../unit_tests/voidcrew_ambient_strays.dm | 84 +- .../unit_tests/voidcrew_bounty_admin.dm | 138 +- .../modules/unit_tests/voidcrew_bounty_ai.dm | 598 ++++--- .../unit_tests/voidcrew_bounty_board.dm | 137 +- .../unit_tests/voidcrew_bounty_bosses.dm | 734 +++++--- .../unit_tests/voidcrew_bounty_criminal.dm | 969 ++++------ .../unit_tests/voidcrew_bounty_identity.dm | 8 +- .../unit_tests/voidcrew_bounty_kingpin.dm | 698 +++++--- .../unit_tests/voidcrew_bounty_lair_mafia.dm | 383 ++-- .../unit_tests/voidcrew_bounty_lair_maps.dm | 13 +- .../unit_tests/voidcrew_bounty_lairs.dm | 118 +- .../unit_tests/voidcrew_bounty_outpost.dm | 593 +++++-- .../unit_tests/voidcrew_bounty_seams.dm | 2 +- .../unit_tests/voidcrew_npc_zone_profiles.dm | 111 +- .../unit_tests/voidcrew_outpost_npc_looks.dm | 1 - .../voidcrew_outpost_prison_bounty.dm | 6 +- .../unit_tests/voidcrew_zone_identity.dm | 18 +- 56 files changed, 8884 insertions(+), 7570 deletions(-) create mode 100644 voidcrew/modules/bounties/bounty_ai_human.dm create mode 100644 voidcrew/modules/bounties/bounty_outfits.dm create mode 100644 voidcrew/modules/bounties/bounty_styles.dm diff --git a/_maps/voidcrew/RandomRuins/SpaceRuins/bounty_lair_mafia_club.dmm b/_maps/voidcrew/RandomRuins/SpaceRuins/bounty_lair_mafia_club.dmm index ffeabbd76bb..67b2d679367 100644 --- a/_maps/voidcrew/RandomRuins/SpaceRuins/bounty_lair_mafia_club.dmm +++ b/_maps/voidcrew/RandomRuins/SpaceRuins/bounty_lair_mafia_club.dmm @@ -1749,7 +1749,7 @@ /area/ruin/space/has_grav/powered/mafia_club/back_office) "FW" = ( /obj/effect/landmark/bounty_lair_gatekeeper{ - gatekeeper_type = /mob/living/basic/trooper/russian/mafia/lieutenant + gatekeeper_type = /mob/living/carbon/human/npc/bounty_mafia/lieutenant }, /turf/open/floor/carpet/red, /area/ruin/space/has_grav/powered/mafia_club/back_office) @@ -2653,7 +2653,7 @@ /area/ruin/space/has_grav/powered/mafia_club/staff) "ZX" = ( /obj/effect/landmark/bounty_lair_gatekeeper{ - gatekeeper_type = /mob/living/basic/trooper/russian/mafia/lieutenant + gatekeeper_type = /mob/living/carbon/human/npc/bounty_mafia/lieutenant }, /turf/open/floor/iron/dark/textured, /area/ruin/space/has_grav/powered/mafia_club/back_office) diff --git a/tgstation.dme b/tgstation.dme index e3dcc2950d3..6203bd6200e 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7093,6 +7093,7 @@ #include "voidcrew\modules\bounties\bounty_activities.dm" #include "voidcrew\modules\bounties\bounty_admin.dm" #include "voidcrew\modules\bounties\bounty_ai.dm" +#include "voidcrew\modules\bounties\bounty_ai_human.dm" #include "voidcrew\modules\bounties\bounty_ai_meek.dm" #include "voidcrew\modules\bounties\bounty_ai_normal.dm" #include "voidcrew\modules\bounties\bounty_board.dm" @@ -7107,10 +7108,12 @@ #include "voidcrew\modules\bounties\bounty_lair_maps.dm" #include "voidcrew\modules\bounties\bounty_lair_types.dm" #include "voidcrew\modules\bounties\bounty_looks.dm" +#include "voidcrew\modules\bounties\bounty_outfits.dm" #include "voidcrew\modules\bounties\bounty_outpost.dm" #include "voidcrew\modules\bounties\bounty_placement.dm" #include "voidcrew\modules\bounties\bounty_posting.dm" #include "voidcrew\modules\bounties\bounty_restraints.dm" +#include "voidcrew\modules\bounties\bounty_styles.dm" #include "voidcrew\modules\bounties\bounty_turn_in.dm" #include "voidcrew\modules\bounties\bounty_types.dm" #include "voidcrew\modules\bounties\bounty_weapons.dm" diff --git a/voidcrew/_DEFINES/bounties.dm b/voidcrew/_DEFINES/bounties.dm index e4413b2c683..bf105eff360 100644 --- a/voidcrew/_DEFINES/bounties.dm +++ b/voidcrew/_DEFINES/bounties.dm @@ -70,10 +70,10 @@ // What capture_state() (P2) returns, listed in the order it checks them. The pad pays by these (P5). #define BOUNTY_STATE_DEAD "dead" -/// At or below the downed line, cuffed or not +/// In crit (tg's TRAIT_CRITICAL_CONDITION, soft or hard), cuffed or not #define BOUNTY_STATE_DOWNED "downed" #define BOUNTY_STATE_RESTRAINED "restrained" -/// Stamina crit, knocked down, paralysed, or surrendered (TRAIT_BOUNTY_SURRENDERED) +/// Stamina crit, knocked down, stunned, paralysed (tg's *surrender too), unconscious or asleep #define BOUNTY_STATE_STUNNED "stunned" /// Standing and free: the pad refuses it #define BOUNTY_STATE_FREE "free" @@ -117,8 +117,6 @@ /// Trait source for everything the bounty packages put on a mob #define BOUNTY_TRAIT "bounty" -/// A normal criminal has given up (P3): it can't attack, and capture_state() (P2) reads it as BOUNTY_STATE_STUNNED -#define TRAIT_BOUNTY_SURRENDERED "bounty_surrendered" // ===== SIGNALS ===== diff --git a/voidcrew/_DEFINES/bounty_ai.dm b/voidcrew/_DEFINES/bounty_ai.dm index 9aa30f159d7..42fcfcbb112 100644 --- a/voidcrew/_DEFINES/bounty_ai.dm +++ b/voidcrew/_DEFINES/bounty_ai.dm @@ -1,24 +1,9 @@ // # Bounty hunting: criminal AI defines // // Owner: P3 AI (voidcrew/modules/bounties/bounty_ai.dm, bounty_ai_meek.dm, bounty_ai_normal.dm, -// bounty_activities.dm, bounty_companions.dm). Only P3 edits this file. The shared enums are in -// bounties.dm. The numbers come from balance/combat.md section 6 and spec section 12.3 (C4, C6, -// C7); the rest are the spec's section 3 values. - -// ===== TRAITS ===== - -// P2 declares these two in bounty_criminals.dm with the same values. The guarded copies here let this -// package build on its own; once both are merged, these can go. -#ifndef TRAIT_BOUNTY_SPRINTING -/// A meek criminal running flat out, not winded, cornered or aiming. P2's projectile dodge reads it. -#define TRAIT_BOUNTY_SPRINTING "bounty_sprinting" -#endif -#ifndef TRAIT_BOUNTY_HELD -/// Restrained or downed (P2): the AI plans nothing -#define TRAIT_BOUNTY_HELD "bounty_held" -#endif -/// Trait source for what the criminal AI puts on a mob (TRAIT_BOUNTY_SPRINTING) -#define BOUNTY_AI_TRAIT "bounty_ai" +// bounty_styles.dm, bounty_activities.dm, bounty_companions.dm). Only P3 edits this file. The shared +// enums are in bounties.dm. Criminals and companions are real human bodies (voidcrew/modules/human_npcs/): +// nothing here sets health, stamina or speed. A pace is only a walk or a run (ai_set_pace()). // ===== WHAT THEY ARE DOING ===== @@ -37,7 +22,7 @@ /// In a locker or a crate #define BOUNTY_HIDE_CONTAINER "container" -/// Passing as a potted plant +/// Holding up a potted plant (tg's own disguise: the plant's tactical component) #define BOUNTY_HIDE_PLANT "plant" /// Standing still on a dark tile #define BOUNTY_HIDE_DARK "dark" @@ -52,16 +37,18 @@ #define BB_BOUNTY_LEADER "bb_bounty_leader" /// Their fighting style's key (BOUNTY_STYLE_*) #define BB_BOUNTY_STYLE "bb_bounty_style" -/// The real item they fight with (a typepath: bounty_real_weapon()), or null for bare fists -#define BB_BOUNTY_WEAPON "bb_bounty_weapon" -/// Rounds left in the magazine -#define BB_BOUNTY_AMMO "bb_bounty_ammo" -/// world.time a reload ends -#define BB_BOUNTY_RELOAD_UNTIL "bb_bounty_reload_until" /// world.time the current wind-up ends #define BB_BOUNTY_WINDUP_UNTIL "bb_bounty_windup_until" -/// world.time of the last shot +/// world.time the current wind-up began +#define BB_BOUNTY_WINDUP_FROM "bb_bounty_windup_from" +/// Their stamina loss when the wind-up began: more by the end of it and they fumble +#define BB_BOUNTY_WINDUP_STAMINA "bb_bounty_windup_stamina" +/// world.time they last took a hit +#define BB_BOUNTY_HIT_AT "bb_bounty_hit_at" +/// world.time of the last shot or throw #define BB_BOUNTY_LAST_SHOT "bb_bounty_last_shot" +/// world.time they may next pull the trigger or throw +#define BB_BOUNTY_NEXT_SHOT "bb_bounty_next_shot" /// world.time a ranged fighter may next step back from someone too close #define BB_BOUNTY_BACKSTEP_AT "bb_bounty_backstep_at" @@ -76,8 +63,11 @@ // ===== SHARED ===== -/// Speed while going about their business, and while blended in (the fugitive and every decoy move alike) +/// The pace while going about their business, and while blended in (the fugitive and every decoy walk +/// alike). Anything quicker than this is a run (ai_set_pace()). #define BOUNTY_CALM_SPEED 2.5 +/// The pace of anyone running: running, as a person runs. There is no faster sprint. +#define BOUNTY_RUN_SPEED 1.5 /// How often they look around for hunters #define BOUNTY_NOTICE_INTERVAL (0.5 SECONDS) /// Held items at least this strong count as a weapon drawn @@ -96,29 +86,23 @@ #define BOUNTY_ACTIVITY_RANGE 8 /// How far exploring looks for things to look at #define BOUNTY_EXPLORE_RANGE 10 -/// Glasses a criminal leaves on tables at the bar before a finished glass just goes +/// Glasses a criminal leaves about at the bar (on a table or the floor) before a finished glass just goes #define BOUNTY_BAR_GLASSES_MAX 2 +/// Drunk this much (tg's drunkenness), they nurse their drink instead of drinking it +#define BOUNTY_BAR_SOBER_LIMIT 30 /// Spots an activity remembers as out of reach #define BOUNTY_FAILED_SPOTS_MAX 8 /// How long a trader outpost's public floor is reused between its patrons before it is worked out again #define BOUNTY_OUTPOST_FLOOR_CACHE (1 MINUTES) +/// A wind-up left this long past its end is stale: the next shot telegraphs again +#define BOUNTY_WINDUP_STALE (1 SECONDS) +/// After a fumble, this long before they try again +#define BOUNTY_FUMBLE_PAUSE (1 SECONDS) // ===== MEEK (combat.md 6.1, spec C6) ===== -/// Speed while sprinting: the tg trooper's, faster than a running person (1.5) -#define BOUNTY_MEEK_SPRINT_SPEED 1.1 -/// A lizard sprints a little faster (spec section 2) -#define BOUNTY_MEEK_LIZARD_SPRINT_BONUS 0.1 -/// Speed while winded: clearly slower than a runner -#define BOUNTY_MEEK_WINDED_SPEED 2.2 -/// How long they sprint before they are winded -#define BOUNTY_MEEK_SPRINT_TIME (8 SECONDS) -/// Resting still, the sprint comes back in this long -#define BOUNTY_MEEK_REST_TIME (8 SECONDS) -/// Sprint back per second spent hidden (full in about 5 s) -#define BOUNTY_MEEK_RECOVER_HIDDEN 1.5 -/// Sprint back per second spent unseen but not hidden -#define BOUNTY_MEEK_RECOVER_WALKING 0.3 +/// Stamina loss at which tg's own slowdown starts (human updatehealth(): 40): they are winded +#define BOUNTY_MEEK_WINDED_STAMINA 40 /// Someone armed, cuffs or a warrant in hand this close is noticed #define BOUNTY_MEEK_NOTICE_RANGE 6 /// Someone running straight at them this close is noticed @@ -149,7 +133,7 @@ #define BOUNTY_MEEK_CORNERED_TIME (0.8 SECONDS) /// The aim before the shots: the telegraph #define BOUNTY_MEEK_PISTOL_WINDUP (0.5 SECONDS) -/// Shots in the holdout's volley, a magazine at most +/// Trigger pulls in the holdout's volley, a magazine at most #define BOUNTY_MEEK_PISTOL_SHOTS 8 /// Between them #define BOUNTY_MEEK_PISTOL_GAP (0.2 SECONDS) @@ -169,9 +153,6 @@ // ===== NORMAL (combat.md 6.2, spec C7) ===== -/// Speed in a fight, by style -#define BOUNTY_NORMAL_SPEED_MELEE 1.25 -#define BOUNTY_NORMAL_SPEED_RANGED 1.4 /// Noticed at this range (someone armed, cuffs or a warrant in hand) #define BOUNTY_NORMAL_NOTICE_RANGE 6 /// Someone with a weapon drawn this close, while they are aware of them, starts the fight @@ -180,22 +161,21 @@ #define BOUNTY_NORMAL_AWARE_FORGET (15 SECONDS) /// Fighting, but nobody on the grudge list seen for this long: the fight is over #define BOUNTY_NORMAL_CALM_AFTER (30 SECONDS) -/// Share of max health at or below which they may give up +/// Surrender (defaults CR-10, /datum/human_npc_surrender/bounty): at or below this share of health, #define BOUNTY_SURRENDER_BELOW 0.4 -/// Percent chance on the hit that crosses the line +/// a percent chance on the first roll of a fight, #define BOUNTY_SURRENDER_CHANCE 60 -/// Less for each companion still standing +/// less this for each companion still standing, #define BOUNTY_SURRENDER_PER_COMPANION 20 -/// Percent chance on later hits +/// then this on later rolls, #define BOUNTY_SURRENDER_RETRY 25 -/// At most this often +/// at most this often, #define BOUNTY_SURRENDER_RETRY_GAP (5 SECONDS) -/// Hunters needed around them +/// with this many hunters around them, #define BOUNTY_SURRENDER_HUNTERS 2 -/// Within this range, in view +/// within this range, in view #define BOUNTY_SURRENDER_HUNTER_RANGE 7 -/// Below this share of health with no hunter near, they break off and retreat. The spec's 25% is the -/// downed line, so a standing criminal is never below it; the surrender line is used instead. +/// Below this share of health with no hunter near, they break off and retreat #define BOUNTY_NORMAL_RETREAT_BELOW 0.4 /// How far they try to get away when retreating #define BOUNTY_NORMAL_RETREAT_DISTANCE 10 @@ -204,58 +184,46 @@ #define BOUNTY_COMPANIONS_MIN 0 #define BOUNTY_COMPANIONS_MAX 2 -/// Health before the style's multiplier -#define BOUNTY_COMPANION_HEALTH 110 /// Below this share of health a companion may run (a BOUNTY_COMPANION_FLEE_CHANCE roll, once) #define BOUNTY_COMPANION_FLEE_BELOW 0.3 #define BOUNTY_COMPANION_FLEE_CHANCE 50 +/// Up from a stamina crit with hunters around: the chance a companion gives up rather than runs +#define BOUNTY_COMPANION_GIVE_UP_CHANCE 50 /// How far a companion keeps from its criminal while nothing is happening #define BOUNTY_COMPANION_FOLLOW 2 /// How often a companion calls the rest of the gang in (the trooper's 30 s) #define BOUNTY_REINFORCE_COOLDOWN (30 SECONDS) -// ===== STYLES (combat.md 6.2) ===== -// Every blow and shot is the real weapon's (bounty_weapons.dm): the item in their hand is each -// style's held_look, and a Wanted criminal carries its heavy_look where it has one. +// ===== STYLES (combat.md 6.2, bounty_styles.dm) ===== +// Every style is a real item the fighter carries and draws: blows and shots are the item's own, through +// the fighter's hands. These only set the timing: how often they pull the trigger or throw, the +// telegraph, and the distance they keep. -#define BOUNTY_BRAWLER_HEALTH 1.2 -#define BOUNTY_BRAWLER_STAMINA 0.8 -#define BOUNTY_BRAWLER_INTERVAL (1 SECONDS) -/// Chance a punch shoves the target back a tile and staggers them for a second +/// Chance a brawler's blow is a real shove instead of a punch #define BOUNTY_BRAWLER_SHOVE_CHANCE 20 -#define BOUNTY_BRAWLER_STAGGER (1 SECONDS) -#define BOUNTY_KNIFE_HEALTH 1 -#define BOUNTY_KNIFE_INTERVAL (0.8 SECONDS) - -#define BOUNTY_PISTOL_HEALTH 0.9 -#define BOUNTY_PISTOL_INTERVAL (0.25 SECONDS) +/// Between trigger pulls (each pull of the Stechkin is its own burst) +#define BOUNTY_PISTOL_INTERVAL (0.8 SECONDS) /// Keeps about this far away #define BOUNTY_PISTOL_RANGE 5 #define BOUNTY_PISTOL_MIN_RANGE 3 -/// The reload: the window to close in -#define BOUNTY_PISTOL_RELOAD (2 SECONDS) /// Raising the gun before the first shot of a string #define BOUNTY_PISTOL_WINDUP (0.2 SECONDS) /// Shots further apart than this start a new string #define BOUNTY_PISTOL_STRING_GAP (3 SECONDS) -#define BOUNTY_SHOTGUN_HEALTH 1 #define BOUNTY_SHOTGUN_INTERVAL (1.2 SECONDS) #define BOUNTY_SHOTGUN_RANGE 2 /// The pump before each blast #define BOUNTY_SHOTGUN_WINDUP (0.3 SECONDS) -/// Loading the tube again: the window to close in -#define BOUNTY_SHOTGUN_RELOAD (3 SECONDS) - -#define BOUNTY_CLUB_HEALTH 1.1 -#define BOUNTY_CLUB_INTERVAL (1.1 SECONDS) +/// Spare shells on the bandolier +#define BOUNTY_SHOTGUN_SHELLS 8 -#define BOUNTY_BOTTLE_HEALTH 0.9 #define BOUNTY_BOTTLE_INTERVAL (1.2 SECONDS) #define BOUNTY_BOTTLE_RANGE 4 #define BOUNTY_BOTTLE_MIN_RANGE 2 -/// Tiles a bottle flies -#define BOUNTY_BOTTLE_REACH 6 /// The throwing arc before each throw #define BOUNTY_BOTTLE_WINDUP (0.3 SECONDS) +/// Bottles in the bag, by tier: Petty, and above +#define BOUNTY_BOTTLES_PETTY 4 +#define BOUNTY_BOTTLES_WANTED 6 diff --git a/voidcrew/_DEFINES/bounty_bosses.dm b/voidcrew/_DEFINES/bounty_bosses.dm index 67ac1ea79c1..b647004b3fd 100644 --- a/voidcrew/_DEFINES/bounty_bosses.dm +++ b/voidcrew/_DEFINES/bounty_bosses.dm @@ -1,30 +1,32 @@ // # Bounty hunting: mini-boss defines // // Owner: P4 bosses (voidcrew/modules/bounties/bounty_boss.dm, bounty_boss_kits.dm). Only P4 edits -// this file. The shared enums are in bounties.dm. The numbers are the combat balance review's +// this file. The shared enums are in bounties.dm. The ability numbers are the combat balance review's // (balance/combat.md section 6.3, folded into spec.md 12.3 C8). A number marked (P4) is not in the -// review and was picked by P4. +// review and was picked by P4; one marked (phase D) came with the move to real human bodies and is a +// starting value for the balance pass (defaults 1.16). +// +// A boss is a real human (voidcrew/modules/human_npcs/): its health, stamina and stuns are tg's. There +// are no health tables, stamina pools or damage coefficients here, and there must never be. // ===== COMMON RULES ===== -/// Percent of max health at or below which a boss is tired: stamina works fully, it slows, its cooldowns stretch, and it can be stunned -#define BOUNTY_BOSS_TIRED_BELOW 40 -/// Stamina damage coefficient while fresh (the Heavy's is BOUNTY_HEAVY_STAMINA_FRESH). 0 after review H3: combat.md's 0.2 only slowed a fresh boss under steady disabler fire and pre-loaded its pool, so capture took one hit instead of five. -#define BOUNTY_BOSS_STAMINA_FRESH 0 -/// Stamina damage coefficient once tired -#define BOUNTY_BOSS_STAMINA_TIRED 1 -/// Stamina damage it takes to stamina-crit a tired boss (the Heavy's is BOUNTY_HEAVY_STAMINA), counted from zero when it tires. max_stamina stays P2's 100 because of the stamcrit removal quirk; the pool is folded into the coefficient instead. -#define BOUNTY_BOSS_STAMINA 150 +/// Brute and burn a boss takes, as a multiple of a person's, through its physiology (npc_damage_taken, defaults 1.5). Its armour does the rest. (phase D) +#define BOUNTY_BOSS_DAMAGE_TAKEN 0.6 +/// Its combat stimulant: one at the start of a fight, a second for its second wind (defaults 1.6, CR-4). Ephedrine: baton resistance, speed and quicker recovery from stuns, never immunity, so a crew can still stun and cuff a worn-out boss. (phase D) +#define BOUNTY_BOSS_STIMULANT /obj/item/reagent_containers/hypospray/medipen/stimpack +/// How many of BOUNTY_BOSS_STIMULANT it carries. (phase D) +#define BOUNTY_BOSS_STIMULANTS 2 +/// Worn out: stamina loss over this share of its max stamina... (phase D, CB-2) +#define BOUNTY_BOSS_WORN_OUT_STAMINA 0.5 +/// ...or health under this share of its max health. (phase D, CB-2) +#define BOUNTY_BOSS_WORN_OUT_HEALTH 0.4 +/// Worn out, it falls back from its target this long before its second wind. (phase D) +#define BOUNTY_BOSS_FALLBACK_TIME (8 SECONDS) /// Below this pressure (kPa) on either side, a wall, window or door is not interior (P4) #define BOUNTY_BOSS_INTERIOR_MIN_PRESSURE 20 /// More than this pressure difference (kPa) between its sides, and a wall, window or door is not interior (P4) #define BOUNTY_BOSS_INTERIOR_MAX_PRESSURE_GAP 50 -/// A dodge or miss message is shown at most this often (P4) -#define BOUNTY_BOSS_MESSAGE_COOLDOWN (1 SECONDS) -/// How much slower a tired boss moves -#define BOUNTY_BOSS_TIRED_SLOWDOWN 0.3 -/// Ability cooldowns are this much longer while tired (P4: the spec says "longer" and gives no number) -#define BOUNTY_BOSS_TIRED_COOLDOWN_MULT 1.5 /// Pause after one ability before the next may start, so telegraphs never overlap #define BOUNTY_BOSS_ABILITY_GAP_MIN (1 SECONDS) #define BOUNTY_BOSS_ABILITY_GAP_MAX (2 SECONDS) @@ -45,6 +47,19 @@ /// A hunter who hit the boss this recently is its first choice of target (P4) #define BOUNTY_BOSS_REVENGE_TIME (10 SECONDS) +// ===== THE POSSE (OC-8): ONE MORE COMPANION PER EXTRA HUNTER ===== + +/// Most companions a posse brings in for one boss (four or more hunters). (phase D) +#define BOUNTY_BOSS_REINFORCEMENTS_MAX 3 +/// Time between two companions coming in, and between tries to find a spot out of view. (phase D) +#define BOUNTY_BOSS_REINFORCE_DELAY (6 SECONDS) +/// A companion comes in at least this far from the boss... (phase D) +#define BOUNTY_BOSS_REINFORCE_MIN_DIST 5 +/// ...and no further than this. (phase D) +#define BOUNTY_BOSS_REINFORCE_MAX_DIST 14 +/// A spot any player within this many tiles can see is in view. (phase D) +#define BOUNTY_BOSS_REINFORCE_SIGHT 13 + // ===== SUMMONED THINGS: CAPS AND LIFETIMES (P4) ===== /// Fire pools (flamer floors and molotovs) burning at once, server-wide @@ -57,22 +72,11 @@ #define BOUNTY_BOSS_BARRICADES_PER_BOSS 2 /// How long a Heavy barricade stands before it folds away #define BOUNTY_BOSS_BARRICADE_LIFE (90 SECONDS) +/// Fire stacks (tg's) a boss's burning floor adds every second to anyone standing in it. (phase D, CB-8) +#define BOUNTY_BOSS_FIRE_TICK_STACKS 1 // ===== JUGGERNAUT ===== -/// Health for 1, 2, 3 and 4+ engaged hunters -#define BOUNTY_JUGGERNAUT_HEALTH_1 300 -#define BOUNTY_JUGGERNAUT_HEALTH_2 950 -#define BOUNTY_JUGGERNAUT_HEALTH_3 1600 -#define BOUNTY_JUGGERNAUT_HEALTH_4 2300 -#define BOUNTY_JUGGERNAUT_BRUTE_MOD 0.7 -#define BOUNTY_JUGGERNAUT_BURN_MOD 0.8 -#define BOUNTY_JUGGERNAUT_SPEED 1.9 -/// Its fists -#define BOUNTY_JUGGERNAUT_MELEE_MIN 20 -#define BOUNTY_JUGGERNAUT_MELEE_MAX 26 -#define BOUNTY_JUGGERNAUT_MELEE_AP 15 -#define BOUNTY_JUGGERNAUT_MELEE_COOLDOWN (1.1 SECONDS) /// Charge: a roar, a stamp and a cracked-floor line #define BOUNTY_JUGGERNAUT_CHARGE_WINDUP (0.8 SECONDS) #define BOUNTY_JUGGERNAUT_CHARGE_COOLDOWN (8 SECONDS) @@ -82,11 +86,15 @@ #define BOUNTY_JUGGERNAUT_CHARGE_STEP 1 #define BOUNTY_JUGGERNAUT_CHARGE_DAMAGE 25 #define BOUNTY_JUGGERNAUT_CHARGE_KNOCKDOWN (2 SECONDS) +/// Armour penetration of the charge +#define BOUNTY_JUGGERNAUT_CHARGE_AP 15 /// Percent chance that someone else in the way is caught too #define BOUNTY_JUGGERNAUT_CHARGE_BYSTANDER 25 -/// Charged into something it can't break: it stands reeling this long, taking more damage -#define BOUNTY_JUGGERNAUT_WALL_STAGGER (2 SECONDS) -#define BOUNTY_JUGGERNAUT_WALL_VULNERABLE 1.25 +/// Charged into something it can't break: brute to its head through its helmet, stamina, a moment's paralysis and a knockdown, as tg's tackle splat does (CB-6). (phase D) +#define BOUNTY_JUGGERNAUT_WALL_BRUTE 15 +#define BOUNTY_JUGGERNAUT_WALL_STAMINA 30 +#define BOUNTY_JUGGERNAUT_WALL_PARALYZE (1 SECONDS) +#define BOUNTY_JUGGERNAUT_WALL_KNOCKDOWN (3 SECONDS) /// Slam: both fists up, a red ring #define BOUNTY_JUGGERNAUT_SLAM_WINDUP (0.7 SECONDS) #define BOUNTY_JUGGERNAUT_SLAM_COOLDOWN (7 SECONDS) @@ -95,19 +103,8 @@ // ===== PYROMANIAC ===== -#define BOUNTY_PYROMANIAC_HEALTH_1 580 -#define BOUNTY_PYROMANIAC_HEALTH_2 1300 -#define BOUNTY_PYROMANIAC_HEALTH_3 1950 -#define BOUNTY_PYROMANIAC_HEALTH_4 2550 -/// Fireproof, not laser-proof: fire and heat can't hurt it, and lasers (BURN) hit it at this -#define BOUNTY_PYROMANIAC_BRUTE_MOD 0.9 -#define BOUNTY_PYROMANIAC_BURN_MOD 0.9 -#define BOUNTY_PYROMANIAC_SPEED 1.8 -/// Its torch: burn damage -#define BOUNTY_PYROMANIAC_MELEE_MIN 10 -#define BOUNTY_PYROMANIAC_MELEE_MAX 14 -#define BOUNTY_PYROMANIAC_MELEE_AP 15 -#define BOUNTY_PYROMANIAC_MELEE_COOLDOWN (1 SECONDS) +/// Its torch: lit, and it never goes out (a real tg torch, held from the start: it doesn't holster) +#define BOUNTY_PYROMANIAC_TORCH /obj/item/flashlight/flare/torch/everburning /// Flamer: a pilot light hiss, an orange cone #define BOUNTY_PYROMANIAC_FLAMER_WINDUP (0.5 SECONDS) #define BOUNTY_PYROMANIAC_FLAMER_COOLDOWN (5 SECONDS) @@ -115,6 +112,8 @@ /// Full width of the cone, in degrees #define BOUNTY_PYROMANIAC_FLAMER_ARC 90 #define BOUNTY_PYROMANIAC_FLAMER_DAMAGE 18 +/// Fire stacks (tg's) the jet puts on anyone in the cone. (phase D, CB-8) +#define BOUNTY_PYROMANIAC_FLAMER_STACKS 4 /// How long the floor the cone covered keeps burning #define BOUNTY_PYROMANIAC_FLAMER_FLOOR (4 SECONDS) /// Molotov: a lit rag, the throw, a ring where it lands @@ -122,23 +121,14 @@ #define BOUNTY_PYROMANIAC_MOLOTOV_COOLDOWN (7 SECONDS) #define BOUNTY_PYROMANIAC_MOLOTOV_RANGE 7 #define BOUNTY_PYROMANIAC_MOLOTOV_DAMAGE 10 +/// Fire stacks (tg's) the bursting bottle puts on anyone it splashes. (phase D, CB-8) +#define BOUNTY_PYROMANIAC_MOLOTOV_STACKS 3 #define BOUNTY_PYROMANIAC_MOLOTOV_POOL (8 SECONDS) -/// Set alight by a boss's fire: burn damage every second for this long, renewed while standing in the flames -#define BOUNTY_BOSS_BURNING_TIME (4 SECONDS) -#define BOUNTY_BOSS_BURNING_DAMAGE 3 // ===== DEMOLITIONIST ===== -#define BOUNTY_DEMOLITIONIST_HEALTH_1 870 -#define BOUNTY_DEMOLITIONIST_HEALTH_2 1750 -#define BOUNTY_DEMOLITIONIST_HEALTH_3 2300 -#define BOUNTY_DEMOLITIONIST_HEALTH_4 2850 -#define BOUNTY_DEMOLITIONIST_BRUTE_MOD 0.85 -#define BOUNTY_DEMOLITIONIST_BURN_MOD 0.85 -#define BOUNTY_DEMOLITIONIST_SPEED 1.7 -/// Its crowbar: the look in its hand and every number of its blows (bounty_real_weapon()) -#define BOUNTY_DEMOLITIONIST_CROWBAR /obj/item/crowbar/red -#define BOUNTY_DEMOLITIONIST_MELEE_COOLDOWN (1 SECONDS) +/// Its crowbar: carried in its bag until the fight starts +#define BOUNTY_DEMOLITIONIST_CROWBAR /obj/item/crowbar/large /// It keeps this far from its target unless someone is right on it #define BOUNTY_DEMOLITIONIST_KEEP_MIN 4 #define BOUNTY_DEMOLITIONIST_KEEP_MAX 6 @@ -148,9 +138,8 @@ #define BOUNTY_DEMOLITIONIST_GRENADE_COOLDOWN (4 SECONDS) #define BOUNTY_DEMOLITIONIST_GRENADE_RANGE 8 #define BOUNTY_DEMOLITIONIST_GRENADE_RADIUS 2 -/// Brute to anyone caught, against bomb armour +/// Damage to a mech's hull it catches, against bomb armour. People in the ring take tg's light explosion (CB-10). #define BOUNTY_DEMOLITIONIST_GRENADE_DAMAGE 35 -#define BOUNTY_DEMOLITIONIST_GRENADE_KNOCKDOWN (1 SECONDS) /// Damage to structures within BOUNTY_DEMOLITIONIST_GRENADE_OBJECT_RADIUS #define BOUNTY_DEMOLITIONIST_GRENADE_OBJECT_DAMAGE 60 #define BOUNTY_DEMOLITIONIST_GRENADE_OBJECT_RADIUS 1 @@ -160,79 +149,54 @@ #define BOUNTY_DEMOLITIONIST_BREACH_WINDUP (0.6 SECONDS) #define BOUNTY_DEMOLITIONIST_BREACH_FUSE (5 SECONDS) #define BOUNTY_DEMOLITIONIST_BREACH_COOLDOWN (18 SECONDS) +/// Damage to a mech's hull beside the charge. People beside it take tg's light explosion (CB-10). #define BOUNTY_DEMOLITIONIST_BREACH_DAMAGE 25 /// It breaches toward a target it can't see within this range #define BOUNTY_DEMOLITIONIST_BREACH_RANGE 8 // ===== GHOST ===== -#define BOUNTY_GHOST_HEALTH_1 250 -#define BOUNTY_GHOST_HEALTH_2 850 -#define BOUNTY_GHOST_HEALTH_3 1700 -#define BOUNTY_GHOST_HEALTH_4 2450 -#define BOUNTY_GHOST_BRUTE_MOD 1 -#define BOUNTY_GHOST_BURN_MOD 1 -#define BOUNTY_GHOST_SPEED 1.2 -/// Its blade: the look in its hand and every number of its blows (bounty_real_weapon()) +/// Its blade: a real combat knife, carried in a pocket until the fight starts, and every cut it lands is the knife's (CB-9) #define BOUNTY_GHOST_KNIFE /obj/item/knife/combat -#define BOUNTY_GHOST_MELEE_COOLDOWN (0.7 SECONDS) -/// Each cut bleeds this much brute a second for BOUNTY_GHOST_BLEED_TIME, up to BOUNTY_GHOST_BLEED_CAP a second -#define BOUNTY_GHOST_BLEED_PER_CUT 1 -#define BOUNTY_GHOST_BLEED_CAP 2 -#define BOUNTY_GHOST_BLEED_TIME (12 SECONDS) -/// Dash: a blur line to the target +/// Dash: a blur line to the target, the Cascade lattice firing as it goes (OC-6 ruling) #define BOUNTY_GHOST_DASH_WINDUP (0.3 SECONDS) #define BOUNTY_GHOST_DASH_COOLDOWN (5 SECONDS) #define BOUNTY_GHOST_DASH_RANGE 5 -/// The dash's cut, its biggest hit (P4: combat.md B4 asks for the dash to be the biggest hit), and its armour penetration -#define BOUNTY_GHOST_DASH_DAMAGE 20 -#define BOUNTY_GHOST_DASH_AP 10 -/// Cloak: a shimmer, then a faint distortion +/// Cloak: a shimmer, then a faint distortion. Only how it looks: nothing misses it for it (OC-6 ruling, CB-12) #define BOUNTY_GHOST_CLOAK_WINDUP (0.3 SECONDS) #define BOUNTY_GHOST_CLOAK_TIME (6 SECONDS) #define BOUNTY_GHOST_CLOAK_COOLDOWN (12 SECONDS) #define BOUNTY_GHOST_CLOAK_ALPHA 50 -/// Percent of hits that miss it while it is cloaked -#define BOUNTY_GHOST_CLOAK_MISS 50 -/// Percent of projectiles it dodges while moving -#define BOUNTY_GHOST_DODGE 30 -/// It counts as moving for this long after a step -#define BOUNTY_GHOST_MOVING_WINDOW (1 SECONDS) /// Hit and run: after this many cuts it breaks off for a while #define BOUNTY_GHOST_CUTS_BEFORE_BREAKOFF 2 #define BOUNTY_GHOST_BREAKOFF_TIME (4 SECONDS) // ===== HEAVY ===== -#define BOUNTY_HEAVY_HEALTH_1 340 -#define BOUNTY_HEAVY_HEALTH_2 970 -#define BOUNTY_HEAVY_HEALTH_3 1400 -#define BOUNTY_HEAVY_HEALTH_4 1850 -#define BOUNTY_HEAVY_BRUTE_MOD 0.55 -#define BOUNTY_HEAVY_BURN_MOD 0.65 -#define BOUNTY_HEAVY_SPEED 2.2 -/// 0 after review H3, as BOUNTY_BOSS_STAMINA_FRESH (combat.md had 0.15) -#define BOUNTY_HEAVY_STAMINA_FRESH 0 -#define BOUNTY_HEAVY_STAMINA 180 -/// Its belt-fed gun: the look in its hand, the rounds of its burst and the blow of its butt (bounty_real_weapon()) -#define BOUNTY_HEAVY_GUN /obj/item/gun/ballistic/automatic/l6_saw -#define BOUNTY_HEAVY_MELEE_COOLDOWN (1.1 SECONDS) +/// Its belt-fed gun: a real L6 held from the start (it has no sling, so it never holsters), with a real spare box (CB-13) +#define BOUNTY_HEAVY_GUN /obj/item/gun/ballistic/automatic/l6_saw/unrestricted +/// Its spare box magazine +#define BOUNTY_HEAVY_SPARE_AMMO /obj/item/ammo_box/magazine/m7mm /// Suppressive burst: a spin-up whirr, a laser sight and a red cone #define BOUNTY_HEAVY_BURST_WINDUP (0.7 SECONDS) #define BOUNTY_HEAVY_BURST_COOLDOWN (4 SECONDS) #define BOUNTY_HEAVY_BURST_RANGE 8 #define BOUNTY_HEAVY_BURST_SHOTS 10 -/// How long the burst takes to fire -#define BOUNTY_HEAVY_BURST_TIME (1.5 SECONDS) +/// How long the burst takes to fire. Each pull is a click, at least 2 deciseconds apart (a mob clicks once a decisecond at most). (phase D) +#define BOUNTY_HEAVY_BURST_TIME (2 SECONDS) /// Full width of the cone the rounds go into, in degrees #define BOUNTY_HEAVY_BURST_ARC 30 /// Extra width, in degrees, of the cone marked on the floor, so edge tiles a round can cross are marked too (P4) #define BOUNTY_HEAVY_BURST_MARK_MARGIN 10 -/// The rounds' damage to windows, grilles, tables and barricades it may break, as a share of their damage (none to anything else) -#define BOUNTY_HEAVY_BURST_DEMOLITION 0.25 -/// Barricade: tg's security barrier, dropped in front of it +/// A hit of at least this much damage while it aims makes it fumble the burst. (phase D, CB-13) +#define BOUNTY_HEAVY_FUMBLE_DAMAGE 8 +/// Barricade: a real barrier grenade, thrown in front of it (CB-26) #define BOUNTY_HEAVY_BARRICADE_WINDUP (0.6 SECONDS) #define BOUNTY_HEAVY_BARRICADE_COOLDOWN (20 SECONDS) +/// Barrier grenades it carries. (phase D) +#define BOUNTY_HEAVY_BARRIERS 2 +/// Its barrier grenades' fuse, set short. (phase D) +#define BOUNTY_HEAVY_BARRIER_FUSE (1 SECONDS) /// Percent of projectiles that pass its barricade (combat.md's number; tg's barrier lets 20 through) #define BOUNTY_HEAVY_BARRICADE_PASS 50 @@ -246,3 +210,7 @@ #define BB_BOUNTY_BOSS_PREY "BB_bounty_boss_prey" /// world.time it looks for prey again #define BB_BOUNTY_BOSS_HUNT_AT "BB_bounty_boss_hunt_at" +/// Who it falls back from while worn out +#define BB_BOUNTY_BOSS_FALL_BACK_FROM "BB_bounty_boss_fall_back_from" +/// world.time a Heavy's belt change in progress gives up +#define BB_BOUNTY_BOSS_RELOAD_UNTIL "BB_bounty_boss_reload_until" diff --git a/voidcrew/_DEFINES/bounty_criminals.dm b/voidcrew/_DEFINES/bounty_criminals.dm index e3e81d76cb4..696d2484b10 100644 --- a/voidcrew/_DEFINES/bounty_criminals.dm +++ b/voidcrew/_DEFINES/bounty_criminals.dm @@ -1,137 +1,22 @@ // # Bounty hunting: criminal body defines // -// Owner: P2 body (voidcrew/modules/bounties/bounty_criminal.dm, bounty_restraints.dm). Only P2 edits -// this file. The shared enums are in bounties.dm. The numbers are balance/combat.md section 6, as -// consolidated in spec.md section 12.3 (C1-C9). +// Owner: P2 body (voidcrew/modules/bounties/bounty_criminal.dm, bounty_restraints.dm, bounty_outfits.dm, +// bounty_ai_human.dm). Only P2 edits this file. The shared enums are in bounties.dm. +// +// A criminal is a real human NPC (human NPC migration, phase D): its health, stamina, crit, cuffs and +// breakout times are tg's, so there are no health pools, stamina pools, downed lines, recovery timers +// or slip times here, and there must never be. // // Unit tests compile before voidcrew/_DEFINES and cannot see these: a test uses the literal value, // with a comment naming the define. // ===== TRAITS ===== -/** - * On a criminal while it is restrained or downed: its AI must plan nothing. P3's controller can - * check it in its first subtree. P2 also adds TRAIT_AI_PAUSED under BOUNTY_BODY_HELD_TRAIT at the - * same time, which stops the controller outright. - */ -#define TRAIT_BOUNTY_HELD "bounty_held" -/// A meek criminal sprinting, and neither winded nor cornered: P3 adds and removes it (source BOUNTY_TRAIT). P2 dodges projectiles while it is on (BOUNTY_MEEK_DODGE). -#define TRAIT_BOUNTY_SPRINTING "bounty_sprinting" -/** - * A script is walking the criminal somewhere on purpose (a walk-out, a cutscene): the leash lets its - * steps through. Godmode counts the same, which covers P6's walk out to the hangar lift. - */ +/// A script is walking the criminal somewhere on purpose (a cutscene): the leash lets its steps through. Walking out (npc_walk_out()) counts the same. #define TRAIT_BOUNTY_SCRIPTED_MOVE "bounty_scripted_move" -/// Trait source for what the body always has (no teleports, no mob swaps) +/// Trait source for what the body always has (no teleports, no mob swaps). A convict a crew talks down loses them. #define BOUNTY_BODY_TRAIT "bounty_body" -/// Trait source for being downed: floored, incapacitated, immobilized -#define BOUNTY_BODY_DOWNED_TRAIT "bounty_body_downed" -/// Trait source for the restraints holding a criminal still -#define BOUNTY_BODY_CUFFS_TRAIT "bounty_body_cuffs" -/// Trait source for TRAIT_BOUNTY_HELD and TRAIT_AI_PAUSED while restrained or downed -#define BOUNTY_BODY_HELD_TRAIT "bounty_body_held" - -// ===== BODY ===== - -/// sentience_type of every criminal: no sentience potion, no ghost role -#define BOUNTY_CRIMINAL_NO_SENTIENCE 0 - -/// Health of a meek criminal, every tier -#define BOUNTY_MEEK_HEALTH 120 -/// Health of a normal criminal by tier, before the style's multiplier (C7) -#define BOUNTY_NORMAL_HEALTH_PETTY 140 -#define BOUNTY_NORMAL_HEALTH_WANTED 180 -#define BOUNTY_NORMAL_HEALTH_MOST 220 -/// P0's name for the normal criminal's health: the Wanted value -#define BOUNTY_NORMAL_HEALTH BOUNTY_NORMAL_HEALTH_WANTED - -/// Health multiplier by fighting style (combat.md 6.2) -#define BOUNTY_STYLE_HEALTH_BRAWLER 1.2 -#define BOUNTY_STYLE_HEALTH_KNIFE 1 -#define BOUNTY_STYLE_HEALTH_PISTOL 0.9 -#define BOUNTY_STYLE_HEALTH_SHOTGUN 1 -#define BOUNTY_STYLE_HEALTH_CLUB 1.1 -#define BOUNTY_STYLE_HEALTH_BOTTLE 0.9 -/// A brawler takes stamina damage x this -#define BOUNTY_STYLE_STAMINA_BRAWLER 0.8 - -/// Fire and heat (L3): a criminal on fire, or hotter than this, burns like a suited person, BOUNTY_CRIMINAL_BURN_DAMAGE a second. The mercy line and the automated floor still hold it. Mini-bosses keep P4's own fire rules. -#define BOUNTY_CRIMINAL_MAX_TEMP SPACE_SUIT_MAX_TEMP_PROTECT -#define BOUNTY_CRIMINAL_BURN_DAMAGE 3 - -/// An explosion counts as a player's when the explosive was last touched by one, or someone with a mind stands within this many tiles -#define BOUNTY_EXPLOSION_WITNESS_RANGE 7 - -/// Pace of a meek criminal: its sprint (P3 slows it while winded) -#define BOUNTY_BODY_MEEK_SPEED 1.1 -/// Pace of a normal criminal with a melee style, and with a ranged one (pistol, bottle) -#define BOUNTY_BODY_NORMAL_SPEED_MELEE 1.25 -#define BOUNTY_BODY_NORMAL_SPEED_RANGED 1.4 - -// ===== STAMINA ===== -// max_stamina stays at 100 on every criminal: basic mobs leave stamina crit by comparing raw stamina -// with a percentage (stamcrit.dm), which only agree at 100. A pool of any other size is the stamina -// damage coefficient: 100 / pool. - -#define BOUNTY_CRIMINAL_MAX_STAMINA 100 -/// Stamina crit at this much stamina: a hair under 100, so a pool that divides evenly (three 30-point disabler shots on a 90 pool) still crits through float rounding -#define BOUNTY_CRIMINAL_STAMCRIT_AT 99.9 -/// A meek criminal's stamina pool: three disabler shots or two batons (C5) -#define BOUNTY_MEEK_STAMINA 90 -/// A meek criminal's slowdown with no stamina left (tg's is 3): one hit must not end the chase (C5) -#define BOUNTY_MEEK_STAMINA_SLOWDOWN 0.5 -/// How long stamina crit lasts after the last stamina hit: meek (C5), and everyone else (tg's default, as simulated) -#define BOUNTY_MEEK_STAMCRIT_TIME (12 SECONDS) -#define BOUNTY_NORMAL_STAMCRIT_TIME (10 SECONDS) -/// P0's name for the stamina crit time -#define BOUNTY_STAMCRIT_TIME BOUNTY_NORMAL_STAMCRIT_TIME -/// P0's names for the stamina coefficients. The pool sets the real one (100 / pool); these stay 1. -#define BOUNTY_MEEK_STAMINA_COEFF 1 -#define BOUNTY_NORMAL_STAMINA_COEFF 1 - -/// Percent of projectiles a sprinting meek criminal ducks (C5) -#define BOUNTY_MEEK_DODGE 50 -/// No ducking within this long of being shot -#define BOUNTY_MEEK_DODGE_AFTER_HIT (1 SECONDS) - -// ===== DOWNED AND RECOVERY (C1, C3) ===== - -/// Percent of max health at or below which a criminal is downed -#define BOUNTY_DOWNED_BELOW 25 -/// P0's name for the downed line, as a fraction -#define BOUNTY_DOWNED_FRACTION (BOUNTY_DOWNED_BELOW / 100) -/// Percent of max health a downed criminal gets up with (more if it was healed while down) -#define BOUNTY_RECOVER_TO 35 -/// How long a downed, uncuffed criminal stays down, by archetype -#define BOUNTY_RECOVER_TIME_MEEK (60 SECONDS) -#define BOUNTY_RECOVER_TIME_NORMAL (75 SECONDS) -#define BOUNTY_RECOVER_TIME_BOSS (45 SECONDS) -/// The last stretch of the recovery, when it groans and tries to rise -#define BOUNTY_BODY_STIRRING_TELL (10 SECONDS) -/// A mini-boss that gets up shrugs off stamina for this long (C1); P4 shows it -#define BOUNTY_BODY_RALLY_TIME (20 SECONDS) - -// ===== AUTOMATED DAMAGE (AR-D3) ===== - -/// On its site, damage with nobody's mind behind it (turrets, traps, fire, fauna) stops at this percent of max health -#define BOUNTY_AUTOMATED_FLOOR 40 - -// ===== RESTRAINTS (C2) ===== - -/// Putting restraints on a downed, stunned or surrendered criminal -#define BOUNTY_CUFF_TIME (2 SECONDS) -/// Taking them off -#define BOUNTY_UNCUFF_TIME (2 SECONDS) -/// Cable restraints and zipties slip after this long; handcuffs never do -#define BOUNTY_CABLE_SLIP (3 MINUTES) -#define BOUNTY_ZIPTIE_SLIP (5 MINUTES) -/// Joke cuffs and zipties -#define BOUNTY_FAKE_CUFF_SLIP (5 SECONDS) -/// A mini-boss gets out of cable and zipties in this share of the time (combat.md 6.4) -#define BOUNTY_BOSS_SLIP_MULT 0.5 -/// The visible struggle starts this long before they slip -#define BOUNTY_SLIP_WARNING (20 SECONDS) // ===== LEASH AND TARGETS (AR-D1, AR-D2) ===== @@ -141,3 +26,23 @@ #define BOUNTY_BODY_FALLBACK_LEASH 40 /// Most mobs a criminal remembers as fair game; the oldest go first #define BOUNTY_GRUDGE_MAX 12 + +// ===== CUFFS, TOUGHNESS, SELF-TREATMENT ===== + +/// A criminal answers a reach for its wrists this long after tg's cuffing starts: a person's reaction, and inside tg's 4 second do_after, so stepping away breaks it +#define BOUNTY_CUFF_REACTION_DELAY (0.5 SECONDS) +/// Sent on a criminal when someone starts putting cuffs on it, before its own reaction: (mob/living/user). Return COMPONENT_BOUNTY_CUFF_HANDLED to take the reaction over. +#define COMSIG_BOUNTY_CUFF_REACHED "bounty_cuff_reached" + #define COMPONENT_BOUNTY_CUFF_HANDLED (1<<0) +/// A cuffed criminal that started working at its cuffs this recently and then lost them got itself out +#define BOUNTY_SELF_FREED_WINDOW (2 MINUTES) +/// Brute and burn a criminal takes, as a multiple of a person's (physiology, npc_damage_taken), by tier. Armour does the rest. +#define BOUNTY_TOUGHNESS_PETTY 1 +#define BOUNTY_TOUGHNESS_WANTED 0.9 +#define BOUNTY_TOUGHNESS_MOST_WANTED 0.8 +/// A bounty NPC treats itself when its health is at or below this share of its max, with nothing it can see attacking it +#define BOUNTY_TREAT_SELF_BELOW 0.7 +/// Between two goes at treating itself +#define BOUNTY_TREAT_SELF_COOLDOWN (20 SECONDS) +/// Blackboard key: the medical stack it is using on itself has been clicked on it once this go (bounty_ai_human.dm) +#define BB_BOUNTY_TREAT_APPLIED "bounty_treat_applied" diff --git a/voidcrew/_DEFINES/bounty_kingpin.dm b/voidcrew/_DEFINES/bounty_kingpin.dm index 5ebae7d5de0..07912939809 100644 --- a/voidcrew/_DEFINES/bounty_kingpin.dm +++ b/voidcrew/_DEFINES/bounty_kingpin.dm @@ -23,16 +23,11 @@ // ===== HIS BODY ===== -#define BOUNTY_KINGPIN_HEALTH 200 -/// Stamina damage that stamcrits him: 5 disabler hits in 10 seconds, or 3 baton hits -#define BOUNTY_KINGPIN_STAMINA 150 -/// Downed to up, uncuffed (the 10 s stirring tell is P2's, inside this) -#define BOUNTY_KINGPIN_RECOVERY (60 SECONDS) -/// After he gets up: stamina does nothing to him and he won't surrender (combat.md 5.6) -#define BOUNTY_KINGPIN_RALLY (20 SECONDS) -/// Below this percent of his health, with his crew down, he may surrender +/// Brute and burn he takes, as a multiple of a person's (the human NPC base's npc_damage_taken, boss rank). His armoured coat does the rest. +#define BOUNTY_KINGPIN_DAMAGE_TAKEN 0.6 +/// At or below this percent of his health, with his crew down and a hunter in sight, he may give up #define BOUNTY_KINGPIN_SURRENDER_BELOW 40 -/// Percent chance on the hit that crosses the line, and on each later hit +/// Percent chance on his first roll, and on each later one #define BOUNTY_KINGPIN_SURRENDER_FIRST 60 #define BOUNTY_KINGPIN_SURRENDER_LATER 25 /// At most one surrender roll per this @@ -40,13 +35,14 @@ // ===== HIS REVOLVER ===== -/// The revolver in his hand: its look, its rounds and its cylinder (bounty_real_weapon()) +/// The revolver he carries in his holster: a real .357, drawn when the guns come out #define BOUNTY_KINGPIN_REVOLVER /obj/item/gun/ballistic/revolver +/// His spare rounds: real .357 speedloaders, two in the holster and one in a pocket +#define BOUNTY_KINGPIN_SPEEDLOADER /obj/item/ammo_box/a357 /// The aim line before each shot; he never fires without it #define BOUNTY_KINGPIN_REVOLVER_AIM (0.5 SECONDS) /// From one aim to the next #define BOUNTY_KINGPIN_REVOLVER_COOLDOWN (1.2 SECONDS) -#define BOUNTY_KINGPIN_REVOLVER_RELOAD (3 SECONDS) // ===== THE SHOOTOUT ===== @@ -75,53 +71,47 @@ /// A fixed crew of six (kingpin.md 3.4), one per map post #define BOUNTY_KINGPIN_GOONS 6 -#define BOUNTY_GOON_HEALTH 150 -/// A goon's stamina pool, the way P2 sets a criminal's: max_stamina 100, with a coefficient of 100 / BOUNTY_GOON_HEALTH, so 5 disabler hits stamcrit him. tg's basic mobs compare raw points with this percentage when the crit ends, so it must be 100. -#define BOUNTY_GOON_MAX_STAMINA 100 -/// Percent of it that stamcrits him. Just under 100, for float rounding. -#define BOUNTY_GOON_STAMCRIT_AT 99.9 #define BOUNTY_GOON_WEAPON_PISTOL "pistol" #define BOUNTY_GOON_WEAPON_SMG "smg" #define BOUNTY_GOON_WEAPON_SHOTGUN "shotgun" -/// The guns in the goons' hands: their looks, their rounds and their magazines (bounty_real_weapon()) +/// The real guns the goons carry, and their real spares #define BOUNTY_GOON_PISTOL_GUN /obj/item/gun/ballistic/automatic/pistol/aps +#define BOUNTY_GOON_PISTOL_MAG /obj/item/ammo_box/magazine/m9mm_aps #define BOUNTY_GOON_SMG_GUN /obj/item/gun/ballistic/automatic/mini_uzi -#define BOUNTY_GOON_SHOTGUN_GUN /obj/item/gun/ballistic/shotgun/automatic/combat -/// What the shotguns are loaded with +#define BOUNTY_GOON_SMG_MAG /obj/item/ammo_box/magazine/uzim9mm +/// The combat shotgun, its tube loaded with buckshot (bounty_kingpin.dm) +#define BOUNTY_GOON_SHOTGUN_GUN /obj/item/gun/ballistic/shotgun/automatic/combat/bounty_kingpin +/// What the shotguns are loaded with, and the loose shells a shotgun goon carries in his bandolier #define BOUNTY_GOON_SHOTGUN_AMMO /obj/item/ammo_casing/shotgun/buckshot +#define BOUNTY_GOON_SHOTGUN_SHELLS 8 -#define BOUNTY_GOON_PISTOL_COOLDOWN (0.3 SECONDS) -#define BOUNTY_GOON_PISTOL_RELOAD (2 SECONDS) +/// Between two trigger pulls of a goon's pistol (each pull is the Stechkin's own three-round burst) +#define BOUNTY_GOON_PISTOL_COOLDOWN (1 SECONDS) -/// Shots in a burst, and the gap between them -#define BOUNTY_GOON_SMG_BURST 5 -#define BOUNTY_GOON_SMG_BURST_GAP (0.1 SECONDS) +/// Trigger pulls in a burst (each pull is the Uzi's own two-round burst), and the gap between them +#define BOUNTY_GOON_SMG_BURST 2 +#define BOUNTY_GOON_SMG_BURST_GAP (0.3 SECONDS) /// The spin-up: the gun comes up level, with a sound #define BOUNTY_GOON_SMG_WINDUP (0.3 SECONDS) #define BOUNTY_GOON_SMG_COOLDOWN (1 SECONDS) -#define BOUNTY_GOON_SMG_RELOAD (2.5 SECONDS) /// Tiles: he walks in to this before he fires #define BOUNTY_GOON_SHOTGUN_RANGE 2 /// The pump before each blast #define BOUNTY_GOON_SHOTGUN_WINDUP (0.3 SECONDS) #define BOUNTY_GOON_SHOTGUN_COOLDOWN (1.2 SECONDS) -#define BOUNTY_GOON_SHOTGUN_RELOAD (3 SECONDS) -/// Tiles from the sofa a goon ever walks +/// Tiles from the sofa a goon ever walks while his crew holds the lounge #define BOUNTY_GOON_LEASH 7 -/// Percent of the goons left who flee when he falls (downed, cuffed, surrendered or dead) +/// Percent of the goons left who run when he falls (in crit, cuffed, given up or dead) #define BOUNTY_GOON_FLEE_ON_BOSS 50 -/// A fleeing goon is gone after this -#define BOUNTY_GOON_FLEE_TIME (6 SECONDS) -/// A goon's body fades after this (the outpost never unloads) -#define BOUNTY_GOON_FADE (60 SECONDS) -/// How long a goon's body takes to fade, and a leaving goon to walk out -#define BOUNTY_GOON_FADE_OUT (2 SECONDS) -#define BOUNTY_GOON_LEAVE_TIME (5 SECONDS) -/// Between a goon's steps in a fight +/// The quickest a goon takes a step in a fight; his own body's slowdown (wounds, armour) can make it slower #define BOUNTY_GOON_STEP (0.35 SECONDS) +/// A reload by hand gives up after this: a tube is fed a shell at a time +#define BOUNTY_KINGPIN_RELOAD_GIVE_UP (15 SECONDS) +/// Blackboard key on him and his goons: their guns are out (the crew is drawing or fighting), so a spent gun is reloaded +#define BB_BOUNTY_KINGPIN_ARMED "bounty_kingpin_armed" // ===== TALKING ===== @@ -158,7 +148,7 @@ #define BOUNTY_KINGPIN_PAY_MAX 3500 /// Vouchers on a full-share turn-in; red adds BOUNTY_RED_VOUCHER_BONUS #define BOUNTY_KINGPIN_VOUCHERS 1 -/// Wanted dead or alive: restrained, stunned or downed is alive +/// Wanted dead or alive: alive pays in full however he is brought in (cuffed, stunned or in crit); dead pays 80% #define BOUNTY_KINGPIN_PAY_ALIVE 100 #define BOUNTY_KINGPIN_PAY_DEAD 80 /// He goes on the board no earlier than 45 minutes into the round, and only with 3+ active ships diff --git a/voidcrew/_DEFINES/bounty_lairs.dm b/voidcrew/_DEFINES/bounty_lairs.dm index 1044e6fdab2..c4776e75f22 100644 --- a/voidcrew/_DEFINES/bounty_lairs.dm +++ b/voidcrew/_DEFINES/bounty_lairs.dm @@ -58,9 +58,12 @@ /// A second pass that keeps the goons in their rooms, once every zone_mobs marker has had time to spawn (its resolver retries for up to a minute) #define BOUNTY_LAIR_GOON_LEASH_DELAY (65 SECONDS) -// ===== P12 ===== +/// ===== P12 ===== // The mafia club's people (bounty_lair_mafia.dm): balance/lairs.md sections 3-5 and 10, spec section 15. // Unit tests can't see these; they use the literal values with the define named in a comment. +// The goons, the lieutenants and the don are real human NPCs (voidcrew/modules/human_npcs/): their health is a +// person's, their toughness is what they wear and npc_damage_taken, and they fire the real guns in their hands. +// Only the don's mech is a mob of its own. /// The faction every club mob shares: goons, lieutenants, the mech and the don #define BOUNTY_MAFIA_FACTION "bounty_mafia" @@ -71,8 +74,10 @@ #define BOUNTY_MAFIA_BARK_COOLDOWN (12 SECONDS) /// Chance a second, per mob, that an idle goon says something while players are about #define BOUNTY_MAFIA_IDLE_BARK_CHANCE 1 +/// After someone starts cuffing a club member who can still move, how long before it steps away (tg's cuffing takes 4 seconds) +#define BOUNTY_MAFIA_CUFF_REACTION (0.6 SECONDS) -/// Sent on the mech as the don climbs out, before the mech's death: (mob/living/basic/bounty_lair_boss/mafia_don/don) +/// Sent on the mech as the don climbs out, before the mech's death: (mob/living/carbon/human/npc/bounty_mafia/don/don) #define COMSIG_BOUNTY_MAFIA_DON_EJECTED "bounty_mafia_don_ejected" /// Sent on the don when his trophy drops: (obj/item/bounty_proof/trophy/mafia_don/trophy) #define COMSIG_BOUNTY_MAFIA_TROPHY_DROPPED "bounty_mafia_trophy_dropped" @@ -81,30 +86,25 @@ #define BB_BOUNTY_MAFIA_MOVE_TO "BB_bounty_mafia_move_to" // ----- mobsters ----- -// Every blow and round is the real item's in their hand (bounty_real_weapon()): its look, damage and magazine. +// A volley is one aim (if the gun has one), then VOLLEY pulls of the trigger VOLLEY_GAP apart, then COOLDOWN before +// the next. The gun's own burst and fire rate apply to every pull: a Stechkin pull is its three-round burst. #define BOUNTY_MOBSTER_KNIFE /obj/item/knife/kitchen -#define BOUNTY_MOBSTER_KNIFE_HEALTH 120 -#define BOUNTY_MOBSTER_KNIFE_COOLDOWN (0.8 SECONDS) -#define BOUNTY_MOBSTER_KNIFE_SPEED 1.2 #define BOUNTY_MOBSTER_PISTOL_GUN /obj/item/gun/ballistic/automatic/pistol/aps -#define BOUNTY_MOBSTER_PISTOL_HEALTH 120 -/// A pistol fires this many shots each time it fires, this far apart; a goon only decides to shoot every half second -#define BOUNTY_MOBSTER_PISTOL_BURST 2 -#define BOUNTY_MOBSTER_PISTOL_BURST_GAP (0.25 SECONDS) -#define BOUNTY_MOBSTER_PISTOL_COOLDOWN (0.5 SECONDS) -#define BOUNTY_MOBSTER_PISTOL_RELOAD (2 SECONDS) -#define BOUNTY_MOBSTER_PISTOL_SPEED 1.4 +#define BOUNTY_MOBSTER_PISTOL_MAGAZINE /obj/item/ammo_box/magazine/m9mm_aps +#define BOUNTY_MOBSTER_PISTOL_VOLLEY 1 +#define BOUNTY_MOBSTER_PISTOL_VOLLEY_GAP (0.3 SECONDS) +#define BOUNTY_MOBSTER_PISTOL_COOLDOWN (0.8 SECONDS) #define BOUNTY_MOBSTER_SMG_GUN /obj/item/gun/ballistic/automatic/mini_uzi -#define BOUNTY_MOBSTER_SMG_HEALTH 150 -#define BOUNTY_MOBSTER_SMG_BURST 5 -#define BOUNTY_MOBSTER_SMG_BURST_GAP (0.1 SECONDS) +#define BOUNTY_MOBSTER_SMG_MAGAZINE /obj/item/ammo_box/magazine/uzim9mm +/// The SMG spins up before each volley ("aiming!"), and a hit then spoils it #define BOUNTY_MOBSTER_SMG_WINDUP (0.3 SECONDS) +/// Each pull is the Uzi's two-round burst +#define BOUNTY_MOBSTER_SMG_VOLLEY 3 +#define BOUNTY_MOBSTER_SMG_VOLLEY_GAP (0.3 SECONDS) #define BOUNTY_MOBSTER_SMG_COOLDOWN (1 SECONDS) -#define BOUNTY_MOBSTER_SMG_RELOAD (2.5 SECONDS) -#define BOUNTY_MOBSTER_SMG_SPEED 1.4 /// The shout before a room's first shot #define BOUNTY_MOBSTER_ALERT (0.5 SECONDS) @@ -125,25 +125,20 @@ // ----- lieutenants ----- +/// Both lieutenants take this much of any brute or burn, through their bodies (elite rank), on top of their armour +#define BOUNTY_LIEUTENANT_DAMAGE_TAKEN 0.8 + #define BOUNTY_LIEUTENANT_TOMMY_GUN /obj/item/gun/ballistic/automatic/tommygun -#define BOUNTY_LIEUTENANT_TOMMY_HEALTH 350 -#define BOUNTY_LIEUTENANT_TOMMY_BURST 6 -#define BOUNTY_LIEUTENANT_TOMMY_BURST_GAP (0.1 SECONDS) +#define BOUNTY_LIEUTENANT_TOMMY_MAGAZINE /obj/item/ammo_box/magazine/tommygunm45 #define BOUNTY_LIEUTENANT_TOMMY_WINDUP (0.3 SECONDS) +/// Each pull is one round of the Thompson +#define BOUNTY_LIEUTENANT_TOMMY_VOLLEY 6 +#define BOUNTY_LIEUTENANT_TOMMY_VOLLEY_GAP (0.1 SECONDS) #define BOUNTY_LIEUTENANT_TOMMY_COOLDOWN (1 SECONDS) -#define BOUNTY_LIEUTENANT_TOMMY_RELOAD (3 SECONDS) -#define BOUNTY_LIEUTENANT_TOMMY_SPEED 1.4 -/// Brute fights with his fists: a human's punch -#define BOUNTY_LIEUTENANT_BRUTE_HEALTH 400 -#define BOUNTY_LIEUTENANT_BRUTE_COOLDOWN (1 SECONDS) -/// Percent chance a blow knocks the hunter down +/// Brute fights with his fists: a person's punch. A landed punch knocks the hunter down this often (percent), for this long. #define BOUNTY_LIEUTENANT_BRUTE_KNOCKDOWN_CHANCE 25 #define BOUNTY_LIEUTENANT_BRUTE_KNOCKDOWN (1 SECONDS) -#define BOUNTY_LIEUTENANT_BRUTE_SPEED 1.2 - -/// Both lieutenants take this much of any brute or burn damage -#define BOUNTY_LIEUTENANT_DAMAGE_MOD 0.8 /// The lieutenants' names for the role var #define BOUNTY_LIEUTENANT_TOMMY "tommy" @@ -171,10 +166,11 @@ #define BOUNTY_MECH_STOMP_THROW 3 #define BOUNTY_MECH_STOMP_THROW_SPEED 2 -/// The lock-on: a marker under each hunter it can see, then one rocket per marker +/// The lock-on: a marker under each hunter it can see, then one rocket per marker. Each burst is tg's light blast +/// (ex_act(EXPLODE_LIGHT)) on everyone standing on its marker, and nothing else: no explosion(), no breach. #define BOUNTY_MECH_ROCKET_LOCK (0.8 SECONDS) +/// A rocket's damage to what it hits in flight in the arena (a table, a window, a door), before BOUNTY_MECH_ROCKET_DEMOLITION #define BOUNTY_MECH_ROCKET_DAMAGE 70 -#define BOUNTY_MECH_ROCKET_KNOCKDOWN (1 SECONDS) #define BOUNTY_MECH_ROCKET_COOLDOWN (9 SECONDS) /// Damage a rocket does to a table, window or door it hits in the arena, as a multiple of its damage #define BOUNTY_MECH_ROCKET_DEMOLITION 8 @@ -210,12 +206,15 @@ /// His name when his posting's record gives none: the name P10's card shows for Club Volga #define BOUNTY_DON_NAME "Arkady Sokolov" -/// His gold pistol: its look, its rounds and its magazine (bounty_real_weapon()); he fires strings of BOUNTY_MOBSTER_PISTOL_BURST as a goon does +/// The don takes this much of any brute or burn, through his body (boss rank) +#define BOUNTY_DON_DAMAGE_TAKEN 0.6 +/// His gold pistol, and the magazines he carries for it #define BOUNTY_DON_GUN /obj/item/gun/ballistic/automatic/pistol/deagle/gold -#define BOUNTY_DON_HEALTH 250 -#define BOUNTY_DON_COOLDOWN (0.5 SECONDS) -#define BOUNTY_DON_RELOAD (2 SECONDS) -/// He raises the pistol this long before each string of shots +#define BOUNTY_DON_MAGAZINE /obj/item/ammo_box/magazine/m50 +/// He raises the pistol this long before the first volley of each magazine #define BOUNTY_DON_RAISE (0.2 SECONDS) -#define BOUNTY_DON_SPEED 1.4 +#define BOUNTY_DON_VOLLEY 2 +#define BOUNTY_DON_VOLLEY_GAP (0.25 SECONDS) +#define BOUNTY_DON_COOLDOWN (0.5 SECONDS) +/// Climbing out of the wreck, he takes this long to get his bearings before he acts #define BOUNTY_DON_EJECT_STAGGER (2 SECONDS) diff --git a/voidcrew/_DEFINES/bounty_outpost.dm b/voidcrew/_DEFINES/bounty_outpost.dm index 97017714e57..faa545816d5 100644 --- a/voidcrew/_DEFINES/bounty_outpost.dm +++ b/voidcrew/_DEFINES/bounty_outpost.dm @@ -17,12 +17,8 @@ #define BOUNTY_DECOY_FLEE_DISTANCE 5 /// How long a decoy keeps backing off #define BOUNTY_DECOY_FLEE_TIME (4 SECONDS) -/// Move delay of someone walking out to the hangar: a walk, not a run -#define BOUNTY_OUTPOST_EXIT_DELAY 4 -/// Anyone still walking out after this fades where they are -#define BOUNTY_OUTPOST_EXIT_TIMEOUT (45 SECONDS) -/// How long the fade out takes -#define BOUNTY_OUTPOST_FADE_TIME (1 SECONDS) +// Walking out to the hangar lift is the human NPC base's npc_walk_out() (human_npc_states.dm): a walk +// at the body's own pace, with no timeout and no fade. They leave on the lift or out of sight. /// Trait source for everything P6 puts on a mob #define BOUNTY_OUTPOST_TRAIT "bounty_outpost" diff --git a/voidcrew/_HELPERS/hostile_creatures.dm b/voidcrew/_HELPERS/hostile_creatures.dm index 22dc386a234..30e616a1de2 100644 --- a/voidcrew/_HELPERS/hostile_creatures.dm +++ b/voidcrew/_HELPERS/hostile_creatures.dm @@ -73,9 +73,10 @@ GLOBAL_LIST_INIT(turret_retaliating_subtrees, typecacheof(list( // headslug, whose stock AI already reads as hostile. Their custom AI does not. if(istype(creature, /mob/living/basic/outpost_experiment)) return is_outpost_experiment_turret_target(creature) - // Bounty criminals, decoys and companions (voidcrew/modules/bounties/bounty_criminal.dm): trader-outpost - // turrets leave them to the hunters, and no turret shoots a criminal already down, stunned or cuffed. - if(istype(creature, /mob/living/basic/bounty_criminal) || istype(creature, /mob/living/basic/bounty_companion)) + // 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, + // 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))) return FALSE // Bees from an outpost's own apiary never sting anyone (outpost_apiary.dm), but their diff --git a/voidcrew/modules/ambient_npcs/convict.dm b/voidcrew/modules/ambient_npcs/convict.dm index 79bf0667f63..f7e8296213c 100644 --- a/voidcrew/modules/ambient_npcs/convict.dm +++ b/voidcrew/modules/ambient_npcs/convict.dm @@ -7,24 +7,35 @@ * inside the existing two-offer limit, paid by the existing capture rules; recruiting them * withdraws it unpaid. * - * The convict (spec 5.4) is the bounty module's meek criminal in prison orange, with its own AI: - * camping at a small fire on a planet, or looking round a space ruin; sprinting and hiding from - * anyone who comes at them armed; a holdout when cornered. This file only calls the bounty - * module's procs; it edits nothing there. - * - Sighted: the first time someone of a ship crew comes into view, they put their hands up - * ("Don't shoot!") and a private petty bounty on them is posted to that crew's ship, if the board - * would offer that ship another private bounty now (under BOUNTY_PRIVATE_MAX, and not in the gap - * after its last one ended). Only ever one bounty per convict. The crew cuff them and use - * their pad, and the bounty pays by the usual rules. + * The convict (spec 5.4) is the bounty module's meek criminal, a real human body + * (voidcrew/modules/human_npcs/), in prison orange, with the meek AI: camping at a small fire on a + * planet, or looking round a space ruin; running and hiding from anyone who comes at them armed; a + * holdout pistol when cornered. This file only calls the bounty module's procs; it edits nothing there. + * - Sighted: the first time someone of a ship crew comes into view, they give up the way a person + * does (npc_surrender(): the weapon put away, "Don't shoot!", tg's *surrender, a real 20 second + * Paralyze), and a private petty bounty on them is posted to that crew's ship, if the board would + * offer that ship another private bounty now (under BOUNTY_PRIVATE_MAX, and not in the gap after its + * last one ended). Only ever one bounty per convict. The crew cuff them and use their pad, and the + * bounty pays on their condition there by the usual rules. * - Talked down: an empty hand from a crew member while they are calm, and they ask to come along. - * "Come with us" turns them into a stray following that crew (recruit.dm), wearing the same face, - * and withdraws their bounty unpaid. A convict who is fleeing, hiding, down or cuffed won't talk. + * "Come with us" and the SAME body goes with that crew (defaults 1.9, AM-6): its bounty is withdrawn + * unpaid, it is taken off the board for good (no posting, no leash, no teleport block, no sweep + * protection, off the convict count), and its AI and faction change: a small follower AI of its own + * (below) walks after whoever asked, keeps out of fights, and stays aboard once it has stepped onto + * their ship. Wounds, blood and everything else about the body carry on. A convict who is fleeing, + * hiding, down, stunned or cuffed won't talk. * - Left alone: when a bounty on a free convict ends unclaimed, they stay where they are, off the * board for good. * Where they turn up: a planet site (/datum/ambient_site_kind/convict, yellow and red space) or, * rolled once per space ruin per round by SSambient_strays (stranded_lifeboat.dm), a loaded ruin * a crew has docked at. At most one per AMBIENT_CONVICT_SHIPS_PER active crews, and never more than * AMBIENT_CONVICTS_MAX alive. + * + * A talked-down convict is still a /bounty_criminal type: a mob can't change type and stay the same + * body. What reads the posting (the pad, the board, proof of death, pay) leaves it alone, since it has + * none. What reads the type alone still counts it as a bounty body: other criminals never pick it as a + * target, and the core's transporter, bag-fold and contract-pad checks still refuse it while it is alive + * (see the PHASE D CORE REQUEST below). Turrets leave it be: its follower AI has no target finding. */ /// How far a convict sees a crew member coming @@ -37,8 +48,33 @@ #define CONVICT_TALK_COOLDOWN (4 SECONDS) /// The reason a convict's bounty closes when a crew takes them in #define CONVICT_CLOSE_RECRUITED "recruited" - -/// Weakrefs to every escaped convict's body (weakrefs: the bounty body's own Destroy() is not overridden here) +/// Beyond this many tiles (or off the level, or dead) a follower's leader is out of reach +#define CONVICT_FOLLOW_RANGE 12 +/// A leader out of reach this long is lost: the follower waits where it is +#define CONVICT_LOST_TIME (60 SECONDS) +/// Further than this behind its leader, a follower runs to catch up +#define CONVICT_RUN_DISTANCE 4 +/// How long a follower keeps clear of someone who hit it +#define CONVICT_SHY_TIME (10 SECONDS) +/// How close a fight has to be for a follower to back off from it +#define CONVICT_FIGHT_RANGE 5 +/// Between a follower's idle lines aboard, while somebody is near +#define CONVICT_IDLE_LINE_LOW (90 SECONDS) +#define CONVICT_IDLE_LINE_HIGH (180 SECONDS) +/// Contexts a talked-down convict says from the strays file's deckhand section first +#define CONVICT_DECKHAND_CONTEXTS list("talk", "idle", "follow", "wait") + +/// Blackboard: who a talked-down convict follows (null: it stays where it is) +#define BB_CONVICT_LEADER "convict_leader" +/// Blackboard: world.time its leader went out of reach, or null +#define BB_CONVICT_LOST_SINCE "convict_lost_since" +/// Blackboard: whoever last hit it, and the world.time it stops keeping clear of them +#define BB_CONVICT_HIT_BY "convict_hit_by" +#define BB_CONVICT_HIT_UNTIL "convict_hit_until" +/// Blackboard: what it is backing away from now (run_away_from_target clears it) +#define BB_CONVICT_AVOID "convict_avoid" + +/// Weakrefs to every escaped convict's body still on the board (weakrefs: the bounty body's own Destroy() is not overridden here) GLOBAL_LIST_EMPTY(ambient_convicts) // ========================================================================= @@ -62,7 +98,7 @@ GLOBAL_LIST_EMPTY(ambient_convicts) /proc/ambient_live_convicts() . = 0 for(var/datum/weakref/ref as anything in GLOB.ambient_convicts.Copy()) - var/mob/living/basic/bounty_criminal/meek/convict/convict = ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = ref?.resolve() if(QDELETED(convict)) GLOB.ambient_convicts -= ref continue @@ -78,7 +114,8 @@ GLOBAL_LIST_EMPTY(ambient_convicts) /** * A new convict at `where`, on site `site` (the overmap planet or ruin, or null), for a * `placement_kind` (BOUNTY_PLACEMENT_PLANET or _RUIN) site: `record`'s person, or someone new. They - * camp on a planet and look round a ruin. Returns the convict, or null. + * camp on a planet and look round a ruin. One body, wanted back at once, so it is built now rather + * than queued, in its record's face and name. Returns the convict, or null. */ /proc/ambient_spawn_convict(turf/where, obj/structure/overmap/site, placement_kind = BOUNTY_PLACEMENT_PLANET, datum/bounty_record/record) where = get_turf(where) @@ -87,7 +124,11 @@ GLOBAL_LIST_EMPTY(ambient_convicts) record ||= generate_bounty_record(BOUNTY_TIER_PETTY, BOUNTY_ARCHETYPE_MEEK, placement_kind) if(!record) return null - var/mob/living/basic/bounty_criminal/meek/convict/convict = new(where) + var/datum/human_npc_order/order = spawn_human_npc(/mob/living/carbon/human/npc/bounty_criminal/meek/convict, where, look = record, name = record.name, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = order?.spawned_ref?.resolve() + qdel(order) + if(QDELETED(convict)) + return null convict.body_setup(record, null) if(site) convict.body_set_site(site) @@ -167,7 +208,7 @@ GLOBAL_LIST_EMPTY(ambient_convicts) if(site.data["taken"] || site.state == AMBIENT_SITE_SPENT) return FALSE var/datum/weakref/convict_ref = site.data["convict"] - var/mob/living/basic/bounty_criminal/meek/convict/convict = convict_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = convict_ref?.resolve() if(!QDELETED(convict)) return FALSE if(!has_room()) @@ -190,7 +231,24 @@ GLOBAL_LIST_EMPTY(ambient_convicts) // THE CONVICT // ========================================================================= -/mob/living/basic/bounty_criminal/meek/convict +/** + * Prison orange and the escapee's shiv, with the meek criminal's kit (bounty_styles.dm's holdout): the + * little Makarov in a pocket, its spare magazine and the stimpack they bolt on in a fanny pack. They run + * and hide, and only shoot when cornered. Their own name stays on them: no prisoner number. + */ +/datum/outfit/prisoner/bounty_convict + name = "Escaped convict" + l_pocket = /obj/item/gun/ballistic/automatic/pistol + belt = /obj/item/storage/belt/fannypack + belt_contents = list( + /obj/item/ammo_box/magazine/m9mm = 1, + /obj/item/reagent_containers/hypospray/medipen/stimpack = 1, + ) + +/datum/outfit/prisoner/bounty_convict/post_equip(mob/living/carbon/human/prisoner, visuals_only = FALSE) + return + +/mob/living/carbon/human/npc/bounty_criminal/meek/convict name = "escaped convict" desc = "Someone in prison orange, a long way from any prison." /// Weakref to the planet site (/datum/ambient_place/site) that keeps them, if any @@ -205,16 +263,25 @@ GLOBAL_LIST_EMPTY(ambient_convicts) var/convict_next_line = 0 /// REF of a player -> world.time they may talk to them again var/list/convict_talk_cooldowns - -/mob/living/basic/bounty_criminal/meek/convict/Initialize(mapload) + /// Talked down: off the board, going with a crew (the same body, a follower AI) + var/convict_recruited = FALSE + /// Weakref to the ship whose crew took them in + var/datum/weakref/convict_ship_ref + /// They have stepped aboard that ship, and stay aboard from now on + var/convict_aboard = FALSE + /// world.time of their next idle line aboard + var/convict_next_idle_line = 0 + +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/Initialize(mapload, datum/human_npc_order/order) . = ..() GLOB.ambient_convicts += WEAKREF(src) -// Prison orange, wherever they are -/mob/living/basic/bounty_criminal/meek/convict/bounty_outfit() - return /datum/outfit/prisoner +// Prison orange, wherever they are (the human NPC base adds a sealed suit and air, a mask or warm +// clothes on top where the spot needs them) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/bounty_build_outfit(zone) + return /datum/outfit/prisoner/bounty_convict -/mob/living/basic/bounty_criminal/meek/convict/death(gibbed) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/death(gibbed) var/was_alive = stat != DEAD . = ..() if(!was_alive) @@ -224,7 +291,7 @@ GLOBAL_LIST_EMPTY(ambient_convicts) site.npc_died(src) /// Their planet site knows they are gone for good (a bounty took them over, or a crew took them in) -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_mark_site_taken() +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_mark_site_taken() var/datum/ambient_place/site/site = convict_site_ref?.resolve() if(!site || QDELETED(site)) return @@ -233,38 +300,63 @@ GLOBAL_LIST_EMPTY(ambient_convicts) // ----- their own lines ----- +/** + * Their lines for `context` from the strays file: the convict section, and once a crew has taken them + * in the deckhand section too (first for its everyday contexts, CONVICT_DECKHAND_CONTEXTS). + */ +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_lines(context) + var/list/sections = list("convict") + if(convict_recruited) + if(context in CONVICT_DECKHAND_CONTEXTS) + sections.Insert(1, "deckhand") + else + sections += "deckhand" + for(var/section in sections) + var/list/lines = ambient_dialogue_lines(AMBIENT_STRINGS_STRAYS, section, context) + if(length(lines)) + return lines + return list() + /// Says a line for `context` from the strays file, now and then; `force` skips the wait. TRUE if they spoke. -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_say(context, force = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_say(context, force = FALSE) if(stat != CONSCIOUS || (!force && world.time < convict_next_line)) return FALSE - var/list/lines = ambient_dialogue_lines(AMBIENT_STRINGS_STRAYS, "convict", context) + var/list/lines = convict_lines(context) if(!length(lines)) return FALSE convict_next_line = world.time + CONVICT_LINE_COOLDOWN INVOKE_ASYNC(src, TYPE_PROC_REF(/atom/movable, say), pick(lines)) return TRUE -// Their own words where the strays file has them (at the camp, cornered, cuffed), the bounty module's otherwise -/mob/living/basic/bounty_criminal/meek/convict/bounty_say(context, list/values, force = FALSE) - if(!length(ambient_dialogue_lines(AMBIENT_STRINGS_STRAYS, "convict", context))) - return ..() +// Their own words where the strays file has them (seen and giving up, at the camp, cornered, cuffed), +// the bounty module's otherwise +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/bounty_say(context, list/values, force = FALSE) + // The human NPC base's surrender bark: theirs is "Don't shoot! I'm not armed." + if(context == HUMAN_NPC_BARK_SURRENDER) + context = "sighted" + if(!length(convict_lines(context))) + return ..(context, values, force) return convict_say(context, force) // ----- being seen ----- -// Each look round for hunters is also a look for crews they have not seen yet -/mob/living/basic/bounty_criminal/meek/convict/ai_look_around() - convict_look_for_crews() +// Each look round for hunters is also a look for crews they have not seen yet. Giving up to one, they +// notice nobody else this look: they are lying there with their hands on their head. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/ai_look_around() + if(convict_recruited) + return null + if(convict_look_for_crews() || npc_surrendering) + return null return ..() -/// The first time someone of a ship crew comes into view: hands up, and maybe their bounty -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_look_for_crews() - if(world.time < convict_next_look || hidden || !ai_can_act()) - return +/// The first time someone of a ship crew comes into view: they give up, and maybe their bounty. TRUE if they saw a new crew. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_look_for_crews() + if(convict_recruited || world.time < convict_next_look || hidden || !ai_can_act()) + return FALSE convict_next_look = world.time + CONVICT_LOOK_INTERVAL var/turf/here = get_turf(src) if(!here) - return + return FALSE for(var/mob/living/person in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, CONVICT_SIGHT_RANGE)) if(!bounty_ai_is_hunter(person)) continue @@ -276,16 +368,21 @@ GLOBAL_LIST_EMPTY(ambient_convicts) continue LAZYADD(convict_seen_by, key) INVOKE_ASYNC(src, PROC_REF(convict_sighted), person, ship) - return + return TRUE + return FALSE -/// `person` of `ship` saw them -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_sighted(mob/living/person, obj/structure/overmap/ship/ship) - if(QDELETED(src) || stat != CONSCIOUS) +/** + * `person` of `ship` saw them. They give up the way a person does (npc_surrender(): whatever weapon is + * in their hands goes in a pocket, "Don't shoot!", tg's *surrender, a real 20 second Paralyze, the cuff + * window), and that crew may get a private bounty on them. If nobody cuffs them before they get up, the + * meek AI carries on: calm, they can be talked to; at someone armed, they run. + */ +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_sighted(mob/living/person, obj/structure/overmap/ship/ship) + if(QDELETED(src) || stat != CONSCIOUS || convict_recruited) return if(!QDELETED(person)) face_atom(person) - manual_emote("puts [p_their()] hands up.") - convict_say("sighted", force = TRUE) + npc_surrender() convict_post_bounty(ship) /** @@ -293,8 +390,8 @@ GLOBAL_LIST_EMPTY(ambient_convicts) * private offer. board_post() rolls a record of its own, so the posting takes theirs instead, and * the card shows who the crew actually saw. Returns the posting, or null. */ -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_post_bounty(obj/structure/overmap/ship/ship) - if(convict_posted || QDELETED(ship) || !record || posting() || stat == DEAD) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_post_bounty(obj/structure/overmap/ship/ship) + if(convict_posted || convict_recruited || QDELETED(ship) || !record || posting() || stat == DEAD) return null // Inside the board's own rules for private offers: at most BOUNTY_PRIVATE_MAX open, and none during the gap after one ends if(SScriminal_bounties.board_private_count(ship) >= BOUNTY_PRIVATE_MAX || world.time < SScriminal_bounties.board_private_next[WEAKREF(ship)]) @@ -329,15 +426,15 @@ GLOBAL_LIST_EMPTY(ambient_convicts) /** * Their bounty closed. Claimed, the pad has them. Anything else while they are free on their own - * ground (not dead, down, cuffed or aboard a ship): they stay where they are, off the board. + * ground (not dead, in crit, cuffed or aboard a ship): they stay where they are, off the board. */ -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_on_posting_closed(datum/source, reason, obj/structure/overmap/ship/winner) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_on_posting_closed(datum/source, reason, obj/structure/overmap/ship/winner) SIGNAL_HANDLER UnregisterSignal(source, COMSIG_BOUNTY_POSTING_CLOSED) var/datum/criminal_bounty/posting = source if(reason == BOUNTY_CLOSE_CLAIMED || QDELETED(src) || posting.criminal() != src) return - if(stat == DEAD || downed || is_restrained() || get_ship_from_atom(src)) + if(stat == DEAD || is_downed() || is_restrained() || get_ship_from_atom(src)) return posting.board_detach_criminal() posting_ref = null @@ -345,20 +442,28 @@ GLOBAL_LIST_EMPTY(ambient_convicts) // ----- being talked down ----- -/// Whether they would talk: awake, calm, on their feet, not hidden or cuffed -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_can_talk() - if(stat != CONSCIOUS || hidden || downed || is_restrained() || HAS_TRAIT(src, TRAIT_BOUNTY_REMOVED)) +/// Whether they would talk: awake, calm, on their feet, not hidden, stunned or cuffed, and not taken in already +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_can_talk() + if(convict_recruited || stat != CONSCIOUS || hidden || is_downed() || is_restrained() || HAS_TRAIT(src, TRAIT_BOUNTY_REMOVED)) return FALSE return ai_can_act() && ai_mode == BOUNTY_AI_CALM -/mob/living/basic/bounty_criminal/meek/convict/attack_hand(mob/living/carbon/human/user, list/modifiers) - if(istype(user) && !user.combat_mode && !LAZYACCESS(modifiers, RIGHT_CLICK) && convict_can_talk()) - INVOKE_ASYNC(src, PROC_REF(convict_talk), user) - return TRUE +// An empty hand: the plea while they are wild, follow or stay once a crew has them. Anything else (they +// are down, asleep or on fire) is tg's own help: CPR, shaking them awake, patting them out. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/attack_hand(mob/living/carbon/human/user, list/modifiers) + if(istype(user) && !user.combat_mode && !LAZYACCESS(modifiers, RIGHT_CLICK)) + if(convict_recruited) + var/obj/structure/overmap/ship/ship = convict_ship() + if(ship && convict_follower_can_talk() && ambient_crew_ship(user) == ship) + INVOKE_ASYNC(src, PROC_REF(convict_crew_menu), user) + return TRUE + else if(convict_can_talk()) + INVOKE_ASYNC(src, PROC_REF(convict_talk), user) + return TRUE return ..() /// A word with `user`, and to a crew member with room aboard, the plea. Sleeps. -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_talk(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_talk(mob/living/user) var/key = REF(user) if(LAZYACCESS(convict_talk_cooldowns, key) > world.time) return @@ -384,68 +489,370 @@ GLOBAL_LIST_EMPTY(ambient_convicts) convict_say("refused", force = TRUE) /** - * They go with `leader` to `ship`: a stray with the same name and face takes their place, following - * `leader` (recruit.dm), and their bounty is withdrawn unpaid. Returns the stray, or null. + * They go with `leader` to `ship`, in the same body (defaults 1.9): their bounty is withdrawn unpaid, + * they come off the board for good, their side changes to the people's (FACTION_NEUTRAL, as a player's) + * and their AI to the follower's, set on `leader`. The ship counts them as one of the strays it took this + * round (recruit.dm's limits). Returns them, or null if they can't go now. */ -/mob/living/basic/bounty_criminal/meek/convict/proc/convict_join(mob/living/leader, obj/structure/overmap/ship/ship) - var/turf/here = get_turf(src) - if(!here || QDELETED(ship) || QDELETED(leader)) +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_join(mob/living/leader, obj/structure/overmap/ship/ship) + if(convict_recruited || stat == DEAD || !isturf(loc) || QDELETED(ship) || QDELETED(leader)) return null - var/mob/living/basic/ambient_npc/stray/convict/stray = new(here, list("name" = real_name, "gender" = gender), record) - stray.setDir(dir) - // Their face, carried over at once; build_look() puts the same one back on from the record - stray.icon = icon - stray.icon_state = icon_state - stray.appearance_flags |= KEEP_TOGETHER - stray.copy_overlays(src, cut_old = TRUE) + // Off its posting first, so closing it takes nobody away var/datum/criminal_bounty/posting = posting() if(posting) UnregisterSignal(posting, COMSIG_BOUNTY_POSTING_CLOSED) posting.board_detach_criminal() + posting_ref = null convict_mark_site_taken() + convict_site_ref = null ai_put_out_camp() - // Gone on purpose: no proof of death, nothing to turn in - body_mark_removed() - ADD_TRAIT(src, TRAIT_BOUNTY_REMOVED, BOUNTY_PAD_TRAIT) + ai_end_activity() + // Withdrawn unpaid: the crew who saw them are told, as for any offer that ends posting?.close(CONVICT_CLOSE_RECRUITED) - ambient_offer_recruit(stray) - stray.accept_offer(leader, ship) + convict_leave_board() + convict_recruited = TRUE + convict_ship_ref = WEAKREF(ship) + var/datum/ambient_place/ship/deck = ambient_ship_place(ship) + if(deck) + deck.taken++ + faction = list(FACTION_NEUTRAL) + new /datum/ai_controller/basic_controller/convict_follower(src) + convict_follow(leader) + convict_say("accepted", force = TRUE) log_game("AMBIENT: the escaped convict [real_name] went with the [ship.name], talked down by [key_name(leader)]") - qdel(src) - return stray + return src + +/** + * Takes the bounty body's rules off them, for a convict who is no criminal any more: the sweep + * protection, the teleport and mob-swap block, the body's signal hooks (teleport, polymorph and + * type-change refusals, grudges), any container's teleport block, the leash and the grudge list. Off the + * convict count. Its corpse is an ordinary human NPC's from now on: the corpse sweep may clear it. + */ +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_leave_board() + REMOVE_TRAIT(src, TRAIT_MISSION_FIELD_MOB, BOUNTY_TRAIT) + remove_traits(list(TRAIT_NO_TELEPORT, TRAIT_NOMOBSWAP), BOUNTY_BODY_TRAIT) + qdel(GetComponent(/datum/component/bounty_body)) + body_set_holder(null) + body_set_site(null) + grudge.Cut() + ai_set_mode(BOUNTY_AI_CALM) + GLOB.ambient_convicts -= WEAKREF(src) + npc_corpse_lifetime = HUMAN_NPC_CORPSE_LIFETIME + +// Taken in, nothing they sit in is held back from a teleport any more +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/body_set_holder(atom/movable/holder) + return ..(convict_recruited ? null : holder) + +// Taken in, they let their own crew cuff them +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/body_on_cuff_attempt(mob/living/user) + if(convict_recruited) + return + return ..() // ========================================================================= -// THE CONVICT WHO JOINED A CREW +// THE CONVICT WHO WENT WITH A CREW // ========================================================================= -/// A convict who went with a crew: the same face, a stray now (recruit.dm) -/mob/living/basic/ambient_npc/stray/convict - name = "escaped convict" - desc = "Someone in prison orange who has decided not to go back." - outfit = /datum/outfit/prisoner - dialogue_section = "convict" - random_name = FALSE - /// Who they are (/datum/bounty_record): their face comes from it - var/datum/bounty_record/bounty_record - -/mob/living/basic/ambient_npc/stray/convict/Initialize(mapload, list/identity, datum/bounty_record/from_record) - bounty_record = from_record - return ..(mapload, identity) - -/mob/living/basic/ambient_npc/stray/convict/Destroy() - bounty_record = null - return ..() +/// The ship whose crew took them in, if it is still there +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_ship() + var/obj/structure/overmap/ship/ship = convict_ship_ref?.resolve() + return QDELETED(ship) ? null : ship + +/// That ship's deckhand place (recruit.dm), if any +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_deck() + var/obj/structure/overmap/ship/ship = convict_ship() + return ship ? ambient_ship_place(ship, create = FALSE) : null + +/// Whether `spot` is aboard the ship that took them in +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_on_ship(turf/spot) + var/datum/ambient_place/ship/deck = convict_deck() + return !!deck?.aboard(get_turf(spot)) + +/// Who they follow, or null while they stay where they are +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_leader() + var/mob/living/leader = ai_controller?.blackboard[BB_CONVICT_LEADER] + return QDELETED(leader) ? null : leader + +/// They follow `leader` from now on. TRUE if they will. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_follow(mob/living/leader) + if(!isliving(leader) || QDELETED(leader) || !istype(ai_controller, /datum/ai_controller/basic_controller/convict_follower)) + return FALSE + ai_controller.set_blackboard_key(BB_CONVICT_LEADER, leader) + ai_controller.clear_blackboard_key(BB_CONVICT_LOST_SINCE) + return TRUE + +/// They stay where they are +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_stay() + ai_controller?.clear_blackboard_key(BB_CONVICT_LEADER) + ai_controller?.clear_blackboard_key(BB_CONVICT_LOST_SINCE) + +/// Their leader has been out of reach too long: they wait where they are for someone of the crew +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_lose_leader() + convict_stay() + convict_say("lost", force = TRUE) -/mob/living/basic/ambient_npc/stray/convict/build_look() - if(QDELETED(src)) +/// Whether `leader` can still be followed: alive, on this level, near enough +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_leader_in_reach(mob/living/leader) + var/turf/there = get_turf(leader) + return !QDELETED(leader) && leader.stat != DEAD && there && isturf(loc) && there.z == z && get_dist(src, there) <= CONVICT_FOLLOW_RANGE + +/// Whether they would answer a crew member: awake and on their feet +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_follower_can_talk() + return stat == CONSCIOUS && !body_is_stunned() + +/// Follow or stay, for `user` of their ship. Sleeps. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_crew_menu(mob/living/user) + var/key = REF(user) + if(LAZYACCESS(convict_talk_cooldowns, key) > world.time) return - if(bounty_record) - apply_bounty_look(src, bounty_record, /datum/outfit/prisoner) + LAZYSET(convict_talk_cooldowns, key, world.time + CONVICT_TALK_COOLDOWN) + if(!buckled) + face_atom(user) + convict_say("talk", force = TRUE) + var/list/choices = list() + if(convict_leader() == user) + choices["Stay here"] = image(icon = 'icons/hud/radial.dmi', icon_state = "guard") + else + choices["Follow me"] = image(icon = 'icons/hud/radial.dmi', icon_state = "move") + var/choice = show_radial_menu(user, src, choices, require_near = TRUE, tooltips = TRUE, autopick_single_option = FALSE) + if(QDELETED(src) || QDELETED(user) || !convict_follower_can_talk() || ambient_crew_ship(user) != convict_ship()) + return + switch(choice) + if("Follow me") + if(convict_follow(user)) + convict_say("follow", force = TRUE) + if("Stay here") + convict_stay() + convict_say("wait", force = TRUE) + +/// The first time they stand aboard their crew's ship: the line, and they live aboard from now on. TRUE if they just stepped aboard. +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_board_if_aboard() + if(!convict_recruited || convict_aboard || !convict_on_ship(get_turf(src))) + return FALSE + convict_aboard = TRUE + convict_next_idle_line = world.time + rand(CONVICT_IDLE_LINE_LOW, CONVICT_IDLE_LINE_HIGH) + convict_say("aboard", force = TRUE) + var/obj/structure/overmap/ship/ship = convict_ship() + log_game("AMBIENT: the escaped convict [real_name] came aboard the [ship?.name || "ship"]") + return TRUE + +/// Aboard, now and then a word while somebody is near +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_idle_chatter() + if(!convict_aboard || world.time < convict_next_idle_line) + return + convict_next_idle_line = world.time + rand(CONVICT_IDLE_LINE_LOW, CONVICT_IDLE_LINE_HIGH) + var/turf/here = get_turf(src) + if(here && ambient_any_player_near(here, 5)) + convict_say("idle") + +/// Hit by someone of the crew they follow: they stop following and say so +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_hit_by(mob/living/attacker) + if(QDELETED(src) || stat != CONSCIOUS || !isliving(attacker)) return + var/obj/structure/overmap/ship/ship = convict_ship() + if(!ship || ambient_crew_ship(attacker) != ship || !convict_leader()) + return + convict_stay() + convict_say("attacked", force = TRUE) + +/// Where they may take a step of their own: never into lava, a chasm or the like; aboard, never off the ship; never out into no air without a sealed suit, unless they are out in it already +/mob/living/carbon/human/npc/bounty_criminal/meek/convict/proc/convict_may_step(turf/next) + next = get_turf(next) + if(!next) + return TRUE + if(!next.can_cross_safely(src)) + return FALSE + var/turf/here = get_turf(src) + if(convict_aboard && convict_on_ship(here) && !convict_on_ship(next)) + return FALSE + if(!is_atmos_sealed() && convict_airless(next) && !convict_airless(here)) + return FALSE + return TRUE + +/// Whether `spot` has too little air to stand in unsuited (read only: never materialises a planet turf's air) +/proc/convict_airless(turf/spot) + if(!isopenturf(spot)) + return FALSE + var/datum/gas_mixture/air = spot.return_air_readonly() + return isnull(air) || air.return_pressure() < HAZARD_LOW_PRESSURE + +// ========================================================================= +// THE FOLLOWER'S AI +// ========================================================================= + +/** + * A talked-down convict's AI: a human pawn (human_npc_ai_able_to_run(), so it stops while stunned, + * knocked down or out, and in soft crit only crawls, pleads and uses a medipen). It never picks a fight: + * no target finding at all, so turrets read it as harmless. It backs off from whoever hit it and from + * anything fighting it or its leader, and otherwise walks after its leader (running to catch up), by a + * pathfinder that keeps out of hazards and airless ground and, once it is aboard, on the ship. + */ +/datum/ai_controller/basic_controller/convict_follower + ai_movement = /datum/ai_movement/jps/convict_follower + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/convict_keep_clear, + /datum/ai_planning_subtree/convict_follow, + ) + +/datum/ai_controller/basic_controller/convict_follower/TryPossessPawn(atom/new_pawn) + if(!istype(new_pawn, /mob/living/carbon/human/npc/bounty_criminal/meek/convict)) + return AI_CONTROLLER_INCOMPATIBLE + new_pawn.AddElement(/datum/element/relay_attackers) + RegisterSignal(new_pawn, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_pawn_attacked)) + return ..() + +/datum/ai_controller/basic_controller/convict_follower/UnpossessPawn(destroy) + if(pawn) + UnregisterSignal(pawn, COMSIG_ATOM_WAS_ATTACKED) return ..() +/datum/ai_controller/basic_controller/convict_follower/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/convict_follower/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) + +/// Someone hit it: it keeps clear of them for a while, and if it was its own crew, it stops following +/datum/ai_controller/basic_controller/convict_follower/proc/on_pawn_attacked(mob/living/source, atom/attacker, attack_flags) + SIGNAL_HANDLER + if(source == attacker || !isliving(attacker) || !(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK | ATTACKER_SHOVING))) + return + set_blackboard_key(BB_CONVICT_HIT_BY, attacker) + set_blackboard_key(BB_CONVICT_HIT_UNTIL, world.time + CONVICT_SHY_TIME) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal/meek/convict, convict_hit_by), attacker) + +/// Backs off from a fight near it: whoever hit it lately, or anything set on it or its leader +/datum/ai_planning_subtree/convict_keep_clear + +/datum/ai_planning_subtree/convict_keep_clear/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = controller.pawn + if(!istype(convict) || convict.stat != CONSCIOUS) + return + var/mob/living/threat = convict_nearest_fight(controller) + if(isnull(threat)) + return + controller.set_blackboard_key(BB_CONVICT_AVOID, threat) + controller.queue_behavior(/datum/ai_behavior/run_away_from_target, BB_CONVICT_AVOID) + return SUBTREE_RETURN_FINISH_PLANNING + +/// The fight `controller`'s pawn should keep clear of, or null +/proc/convict_nearest_fight(datum/ai_controller/controller) + var/mob/living/pawn = controller.pawn + var/mob/living/hitter = controller.blackboard[BB_CONVICT_HIT_BY] + if(isliving(hitter) && !QDELETED(hitter) && hitter.stat == CONSCIOUS && world.time < controller.blackboard[BB_CONVICT_HIT_UNTIL] && can_see(pawn, hitter, CONVICT_FIGHT_RANGE)) + return hitter + var/mob/living/leader = controller.blackboard[BB_CONVICT_LEADER] + var/mob/living/closest + var/closest_distance = INFINITY + for(var/mob/living/other in oview(CONVICT_FIGHT_RANGE, pawn)) + var/datum/ai_controller/their_ai = other.ai_controller + if(other.stat != CONSCIOUS || other.client || !istype(their_ai)) + continue + var/atom/their_target = their_ai.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(isnull(their_target) || (their_target != pawn && their_target != leader)) + continue + var/distance = get_dist(pawn, other) + if(distance < closest_distance) + closest = other + closest_distance = distance + return closest + +/// Walks after its leader, says its line on stepping aboard, and loses a leader out of reach too long +/datum/ai_planning_subtree/convict_follow + +/datum/ai_planning_subtree/convict_follow/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = controller.pawn + if(!istype(convict) || convict.stat != CONSCIOUS) + return + convict.convict_board_if_aboard() + var/mob/living/leader = controller.blackboard[BB_CONVICT_LEADER] + if(isnull(leader)) + convict.convict_idle_chatter() + return + if(!convict.convict_leader_in_reach(leader)) + var/lost_since = controller.blackboard[BB_CONVICT_LOST_SINCE] + if(!lost_since) + controller.set_blackboard_key(BB_CONVICT_LOST_SINCE, world.time) + else if(world.time - lost_since >= CONVICT_LOST_TIME) + convict.convict_lose_leader() + return + controller.clear_blackboard_key(BB_CONVICT_LOST_SINCE) + var/distance = get_dist(convict, leader) + var/run = distance > CONVICT_RUN_DISTANCE + if((convict.move_intent == MOVE_INTENT_RUN) != run) + convict.toggle_move_intent() + if(distance <= 2) + if(!convict.buckled) + convict.face_atom(leader) + convict.convict_idle_chatter() + return + // Aboard, it stays aboard: it waits for a leader who has gone off the ship + if(convict.convict_aboard && !convict.convict_on_ship(get_turf(leader))) + return + controller.queue_behavior(/datum/ai_behavior/convict_follow, BB_CONVICT_LEADER) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Walks to its leader's side, following them as they move +/datum/ai_behavior/convict_follow + required_distance = 1 + 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/convict_follow/setup(datum/ai_controller/controller, leader_key) + . = ..() + var/atom/leader = controller.blackboard[leader_key] + if(QDELETED(leader)) + return FALSE + set_movement_target(controller, leader) + +/datum/ai_behavior/convict_follow/perform(seconds_per_tick, datum/ai_controller/controller, leader_key) + var/mob/living/leader = controller.blackboard[leader_key] + if(QDELETED(leader) || leader.stat == DEAD) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(get_dist(controller.pawn, leader) <= 1) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_DELAY + +/// Smart pathing that keeps a follower out of hazards and airless ground, and aboard once it lives there +/datum/ai_movement/jps/convict_follower + +/datum/ai_movement/jps/convict_follower/allowed_to_move(datum/move_loop/has_target/jps/source) + . = ..() + if(!.) + return + var/list/path = source.movement_path + if(!length(path)) + return + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = source.moving + if(istype(convict) && !convict.convict_may_step(path[1])) + return FALSE + +/* + * PHASE D CORE REQUEST: bounty_blocks_transport(), bounty_holds_living_criminal() and + * bounty_blocks_bag_fold() (bounty_turn_in.dm) and bounty_is_gang_body() (bounty_criminal.dm) go by type + * alone, so a talked-down convict (still a /bounty_criminal type, posting_ref null, convict_recruited TRUE) + * can't ride a transporter beam, be sent on a contract pad or be folded into a bluespace body bag while it + * lives, and other criminals never take it for a target. A shared "is this body wanted on anything" check + * (a posting, or not a recruited convict) in those procs would fix it; this file can't reach them. + */ + #undef CONVICT_SIGHT_RANGE #undef CONVICT_LOOK_INTERVAL #undef CONVICT_LINE_COOLDOWN #undef CONVICT_TALK_COOLDOWN #undef CONVICT_CLOSE_RECRUITED +#undef CONVICT_FOLLOW_RANGE +#undef CONVICT_LOST_TIME +#undef CONVICT_RUN_DISTANCE +#undef CONVICT_SHY_TIME +#undef CONVICT_FIGHT_RANGE +#undef CONVICT_IDLE_LINE_LOW +#undef CONVICT_IDLE_LINE_HIGH +#undef CONVICT_DECKHAND_CONTEXTS +#undef BB_CONVICT_LEADER +#undef BB_CONVICT_LOST_SINCE +#undef BB_CONVICT_HIT_BY +#undef BB_CONVICT_HIT_UNTIL +#undef BB_CONVICT_AVOID diff --git a/voidcrew/modules/bounties/bounty_activities.dm b/voidcrew/modules/bounties/bounty_activities.dm index 894fd6f1026..cda2ba521bc 100644 --- a/voidcrew/modules/bounties/bounty_activities.dm +++ b/voidcrew/modules/bounties/bounty_activities.dm @@ -4,13 +4,14 @@ * Owner: P3 AI. Do not edit this file outside P3. * * What it owns: the idle activities (spec section 3), each with real objects: - * - bar: a stool or chair beside a table or counter, a drink in hand, sipped now and then; + * - bar: a stool or chair beside a table or counter (sat on: a real buckle), a real glass in hand, + * drunk from now and then (a real gulp: the drink goes into them, and they stop once they are tipsy); * - chat: turns at talking with a companion (or whoever is at hand); * - trade: at a trader's counter or a vending machine, haggling out loud; * - explore: three to five things on the site to walk up to and look over; * - loot: lockers and crates (zone loot first) opened, gone through and shut again; * - camp: a small camp made once (a fire, or a fixed lamp where a fire won't burn; a mattress; a - * crate), sat at, with errands; + * crate), sat at on the ground (tg's resting), with errands; * - blend: the routine a trader-outpost fugitive and every one of its decoys share (AR-C6/C7): the * same steps, weights, speed and lines, so nothing in how they act gives the fugitive away. * An activity scans for what it uses once, when it starts (or a blend step starts), never per tick. @@ -18,14 +19,14 @@ * they could not walk to is not picked again while the activity lasts, and at a trader outpost the * blend routine only picks spots on the public floor (L5). * It runs while they are calm; a flight or a fight puts it away, and it is set up again after. - * The decoy's AI (/mob/living/basic/bounty_criminal/decoy: blend, and getting out of the way when + * The decoy's AI (/mob/living/carbon/human/npc/bounty_criminal/decoy: blend, and getting out of the way when * hit) is here too; what a decoy does when accused is P6's. * * The shared vars are declared in bounty_types.dm and nowhere else; a var only P3 uses has an * `ai_` prefix. */ -/mob/living/basic/bounty_criminal +/mob/living/carbon/human/npc/bounty_criminal /// What they are doing now (/datum/bounty_activity), if anything var/datum/bounty_activity/ai_activity /// The kind start_activity() set (BOUNTY_ACTIVITY_*), kept so they go back to it once calm @@ -34,17 +35,17 @@ var/datum/weakref/ai_activity_anchor /// world.time before which an activity that could not be set up is not tried again var/ai_activity_retry_at = 0 - /// The drink in their hand, held in their contents + /// The glass in their hand at the bar var/obj/item/reagent_containers/cup/glass/drinkingglass/ai_drink /// Weakref to the seat they sat down on, so only that one is ever left var/datum/weakref/ai_seat_ref - /// Sitting on the floor + /// Sitting on the floor: lying down by their own choice (tg's resting), which they get up from themselves var/ai_crouching = FALSE /// Weakrefs to the camp they made: the fire or lamp, the mattress, the crate var/list/ai_camp /// The mattress and crate were made; only the fire is ever made again var/ai_camp_made = FALSE - /// Glasses left on tables at the bar; past BOUNTY_BAR_GLASSES_MAX the glass just goes (M4) + /// Glasses left on tables at the bar; past BOUNTY_BAR_GLASSES_MAX a finished glass just goes (M4) var/ai_glasses_left = 0 /// Spots they could not walk to (turf = TRUE), not picked again until the activity changes (L5) var/list/ai_failed_spots @@ -57,7 +58,7 @@ * null to find one nearby. BOUNTY_ACTIVITY_BLEND also makes them a blended patron. Returns the * activity, or null if nothing could be set up (they stand around and try again later). */ -/mob/living/basic/bounty_criminal/proc/start_activity(kind, atom/anchor) +/mob/living/carbon/human/npc/bounty_criminal/proc/start_activity(kind, atom/anchor) ai_suspend_activity() ai_activity_kind = kind ai_activity_anchor = anchor ? WEAKREF(anchor) : null @@ -73,7 +74,7 @@ return ai_resume_activity() /// Their activity, set up again if it was put away. Falls back to standing around when it can't be set up. -/mob/living/basic/bounty_criminal/proc/ai_resume_activity() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_resume_activity() if(ai_activity) return ai_activity if(!ai_activity_kind || world.time < ai_activity_retry_at) @@ -92,7 +93,7 @@ return activity /// The activity datum for `kind` -/mob/living/basic/bounty_criminal/proc/ai_activity_type(kind) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_activity_type(kind) switch(kind) if(BOUNTY_ACTIVITY_BAR) return /datum/bounty_activity/bar @@ -111,13 +112,13 @@ return /datum/bounty_activity/loiter /// A meek criminal never loots: it looks around instead -/mob/living/basic/bounty_criminal/meek/ai_activity_type(kind) +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_activity_type(kind) if(kind == BOUNTY_ACTIVITY_LOOT) return /datum/bounty_activity/explore return ..() /// Puts their activity away (drink down, up from the seat), keeping its kind to go back to -/mob/living/basic/bounty_criminal/proc/ai_suspend_activity() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_suspend_activity() if(!ai_activity) return var/datum/bounty_activity/old = ai_activity @@ -126,13 +127,13 @@ qdel(old) /// Stops their activity for good -/mob/living/basic/bounty_criminal/proc/ai_end_activity() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_end_activity() ai_activity_kind = null ai_activity_anchor = null ai_suspend_activity() /// One step of their activity, where they stand: arriving the first time, then its business. Returns BOUNTY_STEP_*. -/mob/living/basic/bounty_criminal/proc/ai_activity_step(seconds) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_activity_step(seconds) if(!ai_activity) return BOUNTY_STEP_DONE if(!ai_activity.arrived) @@ -142,8 +143,8 @@ // ===== BLENDING IN ===== -/// A patron now: moves like every other, can't be pulled, and shots aimed at someone else pass through them -/mob/living/basic/bounty_criminal/proc/ai_start_blend() +/// A patron now: walks like every other patron, and does what every other patron does (the blend routine) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_start_blend() blended = TRUE ai_set_pace(BOUNTY_CALM_SPEED) @@ -152,7 +153,7 @@ * (the component's stop_blending(): pulls, containers and the turrets' faction back to normal), so * `blended` and the act never disagree (C1). */ -/mob/living/basic/bounty_criminal/proc/ai_end_blend() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_end_blend() var/datum/component/bounty_outpost_blend/act = GetComponent(/datum/component/bounty_outpost_blend) act?.stop_blending() blended = FALSE @@ -161,8 +162,8 @@ // ===== SEATS, DRINKS, SPOTS ===== -/// Sits on `seat` from its tile -/mob/living/basic/bounty_criminal/proc/ai_sit_on(obj/structure/chair/seat) +/// Sits on `seat` from its tile: buckled to it, as anyone sitting down is +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_sit_on(obj/structure/chair/seat) if(QDELETED(seat)) return FALSE if(buckled == seat) @@ -175,68 +176,98 @@ ai_seat_ref = WEAKREF(seat) return TRUE -/// Sits down on the floor -/mob/living/basic/bounty_criminal/proc/ai_crouch() - if(ai_crouching || buckled) +/// Sits down on the floor: tg's resting, as a player lies down +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_crouch() + if(ai_crouching || buckled || resting) return ai_crouching = TRUE - add_offsets(BOUNTY_AI_TRAIT, y_add = -4) + set_resting(TRUE, silent = TRUE) -/// Up from their seat or the floor. Only a seat they sat on themselves: never out of a bed or a chair someone put them in. -/mob/living/basic/bounty_criminal/proc/ai_stand_up() +/// Up from their seat or the floor. Only a seat they sat on themselves, and only the floor they lay down on themselves: never out of a bed or a chair someone put them in. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_stand_up() if(ai_crouching) ai_crouching = FALSE - remove_offsets(BOUNTY_AI_TRAIT) + if(resting) + set_resting(FALSE, silent = TRUE) var/obj/structure/chair/seat = ai_seat_ref?.resolve() ai_seat_ref = null if(seat && buckled == seat) seat.unbuckle_mob(src, force = TRUE) -/// A drink in hand, poured for the occasion -/mob/living/basic/bounty_criminal/proc/ai_take_drink() - if(!QDELETED(ai_drink)) +/// A glass of something in hand, poured for them at the bar: a real drinking glass in a free hand. Null with no hand free. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_take_drink() + if(!QDELETED(ai_drink) && is_holding(ai_drink)) return ai_drink - var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = new(src) - glass.reagents.add_reagent(pick(/datum/reagent/consumable/ethanol/beer, /datum/reagent/consumable/ethanol/whiskey, /datum/reagent/consumable/ethanol/rum), 25) - ai_drink = glass - ai_hand = bounty_ai_update_held(src, ai_hand, glass) - return glass + ai_drink = bounty_ai_pour_drink(src) + return ai_drink -/// A sip; an empty glass gets filled again after a while -/mob/living/basic/bounty_criminal/proc/ai_sip() - if(QDELETED(ai_drink)) - return FALSE - if(!ai_drink.reagents.total_volume) - ai_drink.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, 25) - visible_message(span_notice("[src] gets another drink."), vision_distance = 5) - else - ai_drink.reagents.remove_all(5) - visible_message(span_notice("[src] takes a sip from [p_their()] glass."), vision_distance = 5) - playsound(src, 'sound/items/drink.ogg', 20, TRUE, -4) - ai_hand = bounty_ai_update_held(src, ai_hand, ai_drink) - return TRUE +/// A gulp from their glass, a real one (the drink goes into them); an empty glass gets filled again after a while. Tipsy, they only nurse it. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_sip() + return bounty_ai_sip(src, ai_drink) /** * Sets the drink down on `table` if it is right there (on their side of any glass), up to * BOUNTY_BAR_GLASSES_MAX glasses (M4); otherwise, or with no table, it goes. */ -/mob/living/basic/bounty_criminal/proc/ai_put_drink_down(obj/structure/table/table) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_put_drink_down(obj/structure/table/table) var/obj/item/glass = ai_drink ai_drink = null - ai_hand = bounty_ai_update_held(src, ai_hand, null) if(QDELETED(glass)) return - if(stat == CONSCIOUS && ai_glasses_left < BOUNTY_BAR_GLASSES_MAX && !QDELETED(table) && isturf(table.loc) && isturf(loc) && Adjacent(table)) + if(stat == CONSCIOUS && ai_glasses_left < BOUNTY_BAR_GLASSES_MAX && bounty_ai_set_glass_down(src, glass, table)) ai_glasses_left++ - glass.forceMove(table.loc) - glass.pixel_x = rand(-6, 6) - glass.pixel_y = rand(0, 6) - visible_message(span_notice("[src] puts [glass] down on [table]."), vision_distance = 5) return qdel(glass) +/// The drinks a bar patron is poured. The first glass is any of these; another is a beer. +GLOBAL_LIST_INIT(bounty_bar_drinks, list( + /datum/reagent/consumable/ethanol/beer, + /datum/reagent/consumable/ethanol/whiskey, + /datum/reagent/consumable/ethanol/rum, +)) + +/// A fresh glass of something poured into a free hand of `drinker` (a criminal or a companion). Returns it, or null with no hand free. +/proc/bounty_ai_pour_drink(mob/living/carbon/human/drinker) + if(QDELETED(drinker) || !length(drinker.get_empty_held_indexes())) + return null + var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = new(drinker.drop_location()) + glass.reagents.add_reagent(pick(GLOB.bounty_bar_drinks), 25) + if(!drinker.put_in_hands(glass)) + qdel(glass) + return null + return glass + +/** + * A gulp from `glass`: the glass's own drink proc on `drinker` themselves, as a player drinking does, so + * the drink goes into them (and its alcohol is theirs to deal with). Tipsy (BOUNTY_BAR_SOBER_LIMIT), + * they only nurse it. An empty glass gets another beer. + */ +/proc/bounty_ai_sip(mob/living/carbon/human/drinker, obj/item/reagent_containers/cup/glass/glass) + if(QDELETED(glass) || !drinker.is_holding(glass) || drinker.stat != CONSCIOUS) + return FALSE + if(!glass.reagents.total_volume) + glass.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, 25) + drinker.visible_message(span_notice("[drinker] gets another drink."), vision_distance = 5) + return TRUE + if(drinker.get_drunk_amount() >= BOUNTY_BAR_SOBER_LIMIT) + return FALSE + drinker.visible_message(span_notice("[drinker] takes a sip from [drinker.p_their()] glass."), vision_distance = 5) + glass.attack(drinker, drinker) + return TRUE + +/// Sets `glass` down on `table` from right beside it (on their side of any window), as anyone puts a glass down. Returns TRUE if it is on the table. +/proc/bounty_ai_set_glass_down(mob/living/carbon/human/drinker, obj/item/glass, obj/structure/table/table) + if(QDELETED(table) || !isturf(table.loc) || !isturf(drinker.loc) || !drinker.Adjacent(table) || !drinker.is_holding(glass)) + return FALSE + if(!drinker.transferItemToLoc(glass, table.loc)) + return FALSE + glass.pixel_x = rand(-6, 6) + glass.pixel_y = rand(0, 6) + drinker.visible_message(span_notice("[drinker] puts [glass] down on [table]."), vision_distance = 5) + return TRUE + /// A floor tile they can stand on: not space, lava or a chasm, free, on their site -/mob/living/basic/bounty_criminal/proc/ai_standable(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_standable(turf/tile) if(!isopenturf(tile) || isspaceturf(tile) || isgroundlessturf(tile) || islava(tile) || ischasm(tile)) return FALSE if(tile != loc && tile.is_blocked_turf(source_atom = src)) @@ -244,13 +275,13 @@ return leash_ok(tile) /// Whether an activity may send them to `tile`: not a spot they failed to reach, and on the floor it is kept to (L5) -/mob/living/basic/bounty_criminal/proc/ai_spot_allowed(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_spot_allowed(turf/tile) if(LAZYACCESS(ai_failed_spots, tile)) return FALSE return !ai_allowed_floor || ai_allowed_floor[tile] /// Remembers `tile` as somewhere they could not walk to, for as long as the activity lasts (a few at most) -/mob/living/basic/bounty_criminal/proc/ai_spot_failed(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_spot_failed(turf/tile) if(!isturf(tile)) return LAZYSET(ai_failed_spots, tile, TRUE) @@ -262,7 +293,7 @@ * Right beside it (distance 1), nothing may stand between the tile and the thing (Adjacent(): no * wall, glass or rail), since they will use it from there. */ -/mob/living/basic/bounty_criminal/proc/ai_free_tile_beside(atom/thing, distance = 1) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_free_tile_beside(atom/thing, distance = 1) var/turf/center = get_turf(thing) if(!center) return null @@ -280,7 +311,7 @@ return best /// A random free tile within `radius` of `center`, in the same area, or null -/mob/living/basic/bounty_criminal/proc/ai_random_tile_near(turf/center, radius) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_random_tile_near(turf/center, radius) if(!center) return null var/area/here = get_area(src) @@ -291,7 +322,7 @@ return null /// A table beside `thing`, or null -/mob/living/basic/bounty_criminal/proc/ai_table_beside(atom/thing) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_table_beside(atom/thing) var/turf/center = get_turf(thing) if(!center) return null @@ -305,7 +336,7 @@ * Whether they could sit on `seat`: loose on the floor, nobody on it, on their site. Stools (bar * stools too) have can_buckle off for people; they are sat on with a forced buckle all the same. */ -/mob/living/basic/bounty_criminal/proc/ai_seat_usable(obj/structure/chair/seat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_seat_usable(obj/structure/chair/seat) if(QDELETED(seat) || !isturf(seat.loc) || istype(seat, /obj/structure/chair/e_chair)) return FALSE if(seat.has_buckled_mobs() && !(src in seat.buckled_mobs)) @@ -316,7 +347,7 @@ return leash_ok(seat.loc) && (seat.loc == loc || ai_spot_allowed(seat.loc)) /// The nearest seat within `range` of `near` they could sit on, beside a table if `needs_table` -/mob/living/basic/bounty_criminal/proc/ai_find_seat(atom/near, range = BOUNTY_ACTIVITY_RANGE, needs_table = TRUE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_seat(atom/near, range = BOUNTY_ACTIVITY_RANGE, needs_table = TRUE) var/turf/center = get_turf(near) || get_turf(src) if(!center) return null @@ -332,7 +363,7 @@ return best /// The nearest table within `range` of `near` with room to stand at, or null -/mob/living/basic/bounty_criminal/proc/ai_find_table(atom/near, range = BOUNTY_ACTIVITY_RANGE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_table(atom/near, range = BOUNTY_ACTIVITY_RANGE) var/turf/center = get_turf(near) || get_turf(src) if(!center) return null @@ -348,7 +379,7 @@ return best /// The nearest trader or vending machine within `range`: somewhere to look at goods and haggle -/mob/living/basic/bounty_criminal/proc/ai_find_stall(range = BOUNTY_ACTIVITY_RANGE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_stall(range = BOUNTY_ACTIVITY_RANGE) var/turf/center = get_turf(src) if(!center) return null @@ -370,22 +401,22 @@ * Someone to talk to within `range`: their own companions first; otherwise someone at hand who is * awake and not played (a patron, an outpost regular, another blended patron, one of their own faction). */ -/mob/living/basic/bounty_criminal/proc/ai_find_chat_partner(range = BOUNTY_ACTIVITY_RANGE, patrons_only = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_chat_partner(range = BOUNTY_ACTIVITY_RANGE, patrons_only = FALSE) var/turf/here = get_turf(src) if(!here) return null if(!patrons_only) - for(var/mob/living/basic/bounty_companion/buddy as anything in ai_companions()) + for(var/mob/living/carbon/human/npc/bounty_companion/buddy as anything in ai_companions()) if(buddy.stat == CONSCIOUS && get_dist(src, buddy) <= range) return buddy var/list/options = list() - for(var/mob/living/basic/other in range(range, here)) - if(other == src || other.stat != CONSCIOUS || other.client) + for(var/mob/living/other in range(range, here)) + if(other == src || other.stat != CONSCIOUS || other.client || other.mind) continue if(istype(other, /mob/living/basic/ambient_npc)) options += other - else if(istype(other, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/patron = other + else if(istype(other, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/patron = other if(patron.blended || !patrons_only) options += other else if(!patrons_only && (other.mob_biotypes & MOB_HUMANOID) && faction_check_atom(other)) @@ -412,7 +443,7 @@ return is_type_in_typecache(thing, interesting) && isturf(thing.loc) && !thing.invisibility /// Three to five things to look at around `anchor`, spread out, or spots on open ground when there is little; as weakrefs -/mob/living/basic/bounty_criminal/proc/ai_find_points_of_interest(atom/anchor) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_points_of_interest(atom/anchor) var/turf/center = get_turf(anchor) || get_turf(src) . = list() if(!center) @@ -444,7 +475,7 @@ . += WEAKREF(point) /// Whether they could go through `box`: loose, not locked or welded, not outpost property, not a body bag, on their site -/mob/living/basic/bounty_criminal/proc/ai_can_rummage(obj/structure/closet/box) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_can_rummage(obj/structure/closet/box) if(QDELETED(box) || !isturf(box.loc) || box.locked || box.welded) return FALSE if(istype(box, /obj/structure/closet/body_bag) || box.wall_mounted || (box.resistance_flags & INDESTRUCTIBLE)) @@ -452,7 +483,7 @@ return leash_ok(box.loc) /// Up to four containers to go through around `anchor`, zone loot caches first; as weakrefs -/mob/living/basic/bounty_criminal/proc/ai_find_containers(atom/anchor) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_find_containers(atom/anchor) var/turf/center = get_turf(anchor) || get_turf(src) . = list() if(!center) @@ -475,7 +506,7 @@ break /// Shuts `box` after going through it, from right beside it, unless someone is standing in it -/mob/living/basic/bounty_criminal/proc/ai_close_container(obj/structure/closet/box) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_close_container(obj/structure/closet/box) if(QDELETED(box) || !box.opened || !isturf(loc) || !Adjacent(box)) return if(locate(/mob/living) in box.loc) @@ -489,7 +520,7 @@ * again, and one that is gone is replaced. The fire is solid, so no walk, flight or fight takes * anyone through the flames (L4). Returns the fire or lamp, or null. */ -/mob/living/basic/bounty_criminal/proc/ai_make_camp(turf/center) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_make_camp(turf/center) var/atom/fire = ai_camp_fire() if(fire) var/obj/structure/bonfire/bonfire = fire @@ -528,7 +559,7 @@ return fire /// Their camp's fire or lamp, if it is still there where they put it (one carried off is gone, L4) -/mob/living/basic/bounty_criminal/proc/ai_camp_fire() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_camp_fire() var/atom/movable/fire = ai_camp_piece(/obj/structure/bonfire) || ai_camp_piece(/obj/structure/bounty_camp_lamp) return (fire && isturf(fire.loc)) ? fire : null @@ -550,13 +581,13 @@ light_color = "#ffcc66" /// They are gone: the camp stays as it is, but the fire burns down -/mob/living/basic/bounty_criminal/proc/ai_put_out_camp() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_put_out_camp() var/obj/structure/bonfire/bonfire = ai_camp_piece(/obj/structure/bonfire) if(bonfire?.burning) bonfire.extinguish() /// A piece of their camp of `piece_type`, if it is still there -/mob/living/basic/bounty_criminal/proc/ai_camp_piece(piece_type) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_camp_piece(piece_type) for(var/datum/weakref/ref as anything in ai_camp) var/atom/piece = ref?.resolve() if(!QDELETED(piece) && istype(piece, piece_type)) @@ -564,7 +595,7 @@ return null /// The patron they are talking to answers them, a moment after they spoke -/mob/living/basic/bounty_criminal/proc/ai_get_reply(datum/weakref/partner_ref) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_get_reply(datum/weakref/partner_ref) var/mob/living/partner = partner_ref?.resolve() if(partner && stat == CONSCIOUS && get_dist(src, partner) <= 2) bounty_ai_reply(partner, src) @@ -573,10 +604,10 @@ /proc/bounty_ai_reply(mob/living/partner, mob/living/speaker) if(QDELETED(partner) || partner.stat != CONSCIOUS) return - if(istype(partner, /mob/living/basic/bounty_companion)) + if(istype(partner, /mob/living/carbon/human/npc/bounty_companion)) partner.face_atom(speaker) INVOKE_ASYNC(partner, TYPE_PROC_REF(/mob/living, bounty_say), "companion_reply") - else if(istype(partner, /mob/living/basic/bounty_criminal)) + else if(istype(partner, /mob/living/carbon/human/npc/bounty_criminal)) partner.face_atom(speaker) INVOKE_ASYNC(partner, TYPE_PROC_REF(/mob/living, bounty_say), "idle_chat") @@ -586,7 +617,7 @@ /datum/ai_planning_subtree/bounty_activity /datum/ai_planning_subtree/bounty_activity/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!istype(criminal) || criminal.ai_mode != BOUNTY_AI_CALM || criminal.hidden || !criminal.ai_can_act()) return var/datum/bounty_activity/activity = criminal.ai_activity || criminal.ai_resume_activity() @@ -606,11 +637,11 @@ behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION /datum/ai_behavior/bounty_activity/setup(datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn return istype(criminal) && !!criminal.ai_activity /datum/ai_behavior/bounty_activity/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn var/datum/bounty_activity/activity = criminal.ai_activity if(!activity || criminal.ai_mode != BOUNTY_AI_CALM) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED @@ -635,7 +666,7 @@ /datum/bounty_activity /// Short text for the admin panel var/name = "standing around" - var/mob/living/basic/bounty_criminal/doer + var/mob/living/carbon/human/npc/bounty_criminal/doer var/datum/weakref/anchor_ref /// Where they are headed, or null where they are var/turf/spot @@ -646,7 +677,7 @@ /// world.time of the next line var/next_line = 0 -/datum/bounty_activity/New(mob/living/basic/bounty_criminal/new_doer, atom/anchor) +/datum/bounty_activity/New(mob/living/carbon/human/npc/bounty_criminal/new_doer, atom/anchor) . = ..() doer = new_doer anchor_ref = anchor ? WEAKREF(anchor) : null @@ -1276,7 +1307,7 @@ if(prob(step_kind == "chat" ? 70 : 30)) doer.ai_bark("idle_chat") if(step_kind == "chat" && isliving(target)) - addtimer(CALLBACK(doer, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_get_reply), WEAKREF(target)), rand(3, 6) SECONDS, TIMER_DELETE_ME) + addtimer(CALLBACK(doer, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_get_reply), WEAKREF(target)), rand(3, 6) SECONDS, TIMER_DELETE_ME) return BOUNTY_STEP_CONTINUE /// Ends the step: the glass goes with them (never left on the bar, M4), up from the seat @@ -1298,7 +1329,7 @@ * as turf = TRUE), shared for BOUNTY_OUTPOST_FLOOR_CACHE between everyone there; null anywhere else, * or when the outpost has none (a broken load). */ -/proc/bounty_ai_outpost_floor(mob/living/basic/bounty_criminal/patron) +/proc/bounty_ai_outpost_floor(mob/living/carbon/human/npc/bounty_criminal/patron) var/datum/component/bounty_outpost_blend/act = patron?.GetComponent(/datum/component/bounty_outpost_blend) var/obj/structure/overmap/trader_outpost/outpost = act?.outpost_ref?.resolve() if(!istype(outpost)) @@ -1336,22 +1367,25 @@ * this controller is only the default for a decoy made some other way. Every criminal tree carries * bounty_flee_and_calm, so a decoy that runs on any of them runs and then calms down (M3). */ -/mob/living/basic/bounty_criminal/decoy +/mob/living/carbon/human/npc/bounty_criminal/decoy ai_controller = /datum/ai_controller/basic_controller/bounty/criminal/decoy /datum/ai_controller/basic_controller/bounty/criminal/decoy planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, /datum/ai_planning_subtree/bounty_gate, /datum/ai_planning_subtree/bounty_flee_and_calm, /datum/ai_planning_subtree/bounty_activity, ) /// Always a patron -/mob/living/basic/bounty_criminal/decoy/ai_expose() +/mob/living/carbon/human/npc/bounty_criminal/decoy/ai_expose() return /// Made to react (P6, or a decoy no longer blending in): they back away, then go back to their evening -/mob/living/basic/bounty_criminal/decoy/ai_react(mob/living/threat, bark) +/mob/living/carbon/human/npc/bounty_criminal/decoy/ai_react(mob/living/threat, bark) ai_flee_from(threat) /** @@ -1363,7 +1397,7 @@ /datum/ai_planning_subtree/bounty_flee_and_calm /datum/ai_planning_subtree/bounty_flee_and_calm/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!istype(criminal) || criminal.ai_mode != BOUNTY_AI_FLEEING) return var/seen = criminal.ai_sees_threat(BOUNTY_FLEE_DISTANCE) diff --git a/voidcrew/modules/bounties/bounty_admin.dm b/voidcrew/modules/bounties/bounty_admin.dm index 721a17dfc7e..501163d2ad0 100644 --- a/voidcrew/modules/bounties/bounty_admin.dm +++ b/voidcrew/modules/bounties/bounty_admin.dm @@ -12,8 +12,7 @@ * * It changes other packages' state only through their procs: * - P1: generate_bounty_record(), make_decoy_record(); - * - P2: spawn_bounty_criminal(), body_apply_cuffs(), body_remove_cuffs(), body_downed_line(), - * body_stand_up(), capture_state(); + * - P2: spawn_bounty_criminal(), capture_state(); * - P3: start_activity(); * - P5: post_criminal_bounty(), close(), relist(), hunt() (forced), print_warrant(), * SScriminal_bounties.board_fast_forward() and @@ -21,8 +20,10 @@ * - P6: the decoy's outpost_become() and the outpost's display name, description and outfit; * - P7: bounty_pool_add(), bounty_pool_remove(), bounty_pool_clear(), the prison's * bounty_admit_record(). - * The one direct write is a criminal's worst_state back to free (force_free()): no package has a - * proc that makes a worst state better, and the admin's "Free" exists to try a full-pay turn-in again. + * A criminal is a real human body (voidcrew/modules/human_npcs/), so the forced capture states are + * tg's own, put on through tg's procs (CR-21): real brute on the chest to just past its crit line, a + * real Paralyze, real handcuffs in the cuff slot, death(), and for "Free" tg's revive(HEAL_ALL) with the + * cuffs taken off and every stun ended. No stand-in states; the pad reads them as it reads anyone's. * * Everything goes through /datum/bounty_admin_panel. The menu procs (menu_*) ask the questions; the * action procs (admin_*) take plain arguments, check the admin again, act and log, so a unit test can @@ -43,6 +44,8 @@ #define BOUNTY_ADMIN_TICK_SECONDS 10 /// How long a forced stun lasts #define BOUNTY_ADMIN_STUN_TIME (30 SECONDS) +/// Most hits "force downed" lands to get a tough body past its crit line (its toughness cuts each one) +#define BOUNTY_ADMIN_DOWN_HITS 20 /// Most questions one opening of the panel asks before it closes itself: a guard, never reached by hand #define BOUNTY_ADMIN_MAX_STEPS 200 /// Who the pool's test records say brought them in @@ -205,7 +208,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a var/share_choice = ask_list(user, "The decoy shares how much of their look?", "Spawn a decoy", shares) if(!share_choice || !authorized(user)) return - var/mob/living/basic/bounty_criminal/wanted = wanted_ref.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/wanted = wanted_ref.resolve() if(QDELETED(wanted)) refuse(user, "That criminal is gone.") return @@ -353,12 +356,12 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a "Stunned" = BOUNTY_STATE_STUNNED, "Restrained (cuffs on)" = BOUNTY_STATE_RESTRAINED, "Dead" = BOUNTY_STATE_DEAD, - "Free (up, healed, uncuffed, worst state cleared)" = BOUNTY_STATE_FREE, + "Free (alive, healed, uncuffed)" = BOUNTY_STATE_FREE, ) var/state_choice = ask_list(user, "Which state?", "Force a capture state", states) if(!state_choice || !authorized(user)) return - var/mob/living/basic/bounty_criminal/criminal = criminal_ref.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal_ref.resolve() if(QDELETED(criminal)) refuse(user, "That criminal is gone.") return @@ -454,7 +457,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a /// The live criminal the admin picks (never a decoy), or null /datum/bounty_admin_panel/proc/pick_criminal(mob/user, message) var/list/choices = list() - for(var/mob/living/basic/bounty_criminal/criminal as anything in live_criminals()) + for(var/mob/living/carbon/human/npc/bounty_criminal/criminal as anything in live_criminals()) choices[unique_label(choices, criminal_label(criminal))] = WEAKREF(criminal) if(!length(choices)) return refuse(user, "No bounty criminals are about. Spawn one first.") @@ -462,7 +465,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a if(!choice || !authorized(user)) return null var/datum/weakref/criminal_ref = choices[choice] - var/mob/living/basic/bounty_criminal/criminal = criminal_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal_ref?.resolve() if(QDELETED(criminal)) return refuse(user, "That criminal is gone.") return criminal @@ -500,10 +503,10 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a /// Every live bounty criminal in the world, decoys only if asked for /datum/bounty_admin_panel/proc/live_criminals(include_decoys = FALSE) var/list/found = list() - for(var/mob/living/basic/bounty_criminal/criminal in GLOB.mob_living_list) + for(var/mob/living/carbon/human/npc/bounty_criminal/criminal in GLOB.mob_living_list) if(QDELETED(criminal)) continue - if(!include_decoys && istype(criminal, /mob/living/basic/bounty_criminal/decoy)) + if(!include_decoys && istype(criminal, /mob/living/carbon/human/npc/bounty_criminal/decoy)) continue found += criminal return found @@ -682,7 +685,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a // ===== DESCRIPTIONS ===== -/datum/bounty_admin_panel/proc/criminal_name(mob/living/basic/bounty_criminal/criminal) +/datum/bounty_admin_panel/proc/criminal_name(mob/living/carbon/human/npc/bounty_criminal/criminal) if(!criminal) return "nobody" return criminal.record?.name || criminal.real_name || criminal.name @@ -694,7 +697,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a var/variant = record.kit || record.style return "[bounty_tier_name(record.tier)] [record.archetype || "criminal"][variant ? " ([variant])" : ""]" -/datum/bounty_admin_panel/proc/criminal_label(mob/living/basic/bounty_criminal/criminal) +/datum/bounty_admin_panel/proc/criminal_label(mob/living/carbon/human/npc/bounty_criminal/criminal) var/area/place = get_area(criminal) return "[criminal_name(criminal)]: [record_kind(criminal.record)], [criminal.capture_state()], in [place?.name || "nowhere"]" @@ -739,7 +742,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a var/datum/bounty_record/record = posting.record var/obj/structure/overmap/site = posting.site() var/obj/structure/overmap/ship/private_ship = posting.offered_to() - var/mob/living/basic/bounty_criminal/criminal = posting.criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = posting.criminal() var/location = criminal ? "[ADMIN_VERBOSEJMP(criminal)], [criminal.capture_state()]" : absent_reason(posting) return list( "tier" = bounty_tier_name(record?.tier), @@ -829,7 +832,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a record.kit = null if(variant) record.style = variant - var/mob/living/basic/bounty_criminal/criminal = spawn_bounty_criminal(record, where, null) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = spawn_bounty_criminal(record, where, null) if(QDELETED(criminal)) return refuse(user, "The criminal did not spawn.") if(activity) @@ -845,11 +848,11 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a * the alert and it walks out when the posting ends; anywhere else it stands alone, with the black * market's look. A decoy is never linked to a posting through posting_ref (P6). Returns the decoy. */ -/datum/bounty_admin_panel/proc/admin_spawn_decoy(mob/user, mob/living/basic/bounty_criminal/wanted, shared = 1) +/datum/bounty_admin_panel/proc/admin_spawn_decoy(mob/user, mob/living/carbon/human/npc/bounty_criminal/wanted, shared = 1) error = null if(!authorized(user)) return null - if(QDELETED(wanted) || istype(wanted, /mob/living/basic/bounty_criminal/decoy)) + if(QDELETED(wanted) || istype(wanted, /mob/living/carbon/human/npc/bounty_criminal/decoy)) return refuse(user, "Pick a wanted criminal, not a decoy.") if(!wanted.record) return refuse(user, "[criminal_name(wanted)] has no record to copy.") @@ -868,8 +871,12 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a var/datum/criminal_bounty/posting = wanted.posting() if(posting && (posting.placement_kind != BOUNTY_PLACEMENT_TRADER_OUTPOST || posting.outpost_site?.resolve() != outpost)) posting = null - var/datum/npc_spawn_context/context = wanted.bounty_profile_context() - var/mob/living/basic/bounty_criminal/decoy/decoy = spawn_npc_in_zone(/mob/living/basic/bounty_criminal/decoy, decoy_spot(center), context, FALSE) + // One body, wanted back at once: built now rather than queued, in the decoy record's face + var/datum/human_npc_order/order = spawn_human_npc(/mob/living/carbon/human/npc/bounty_criminal/decoy, decoy_spot(center), look = decoy_record, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = order?.spawned_ref?.resolve() + qdel(order) + if(QDELETED(decoy)) + return refuse(user, "The decoy did not spawn.") decoy.outpost_become(wanted, decoy_record, posting, outpost, bounty_outpost_display_name(outpost), bounty_outpost_display_desc(outpost), bounty_outpost_outfit(outpost)) if(posting) posting.decoys += WEAKREF(decoy) @@ -1012,7 +1019,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a return "not spawned yet" /// Moves the admin to `criminal`. Returns TRUE if they moved. -/datum/bounty_admin_panel/proc/admin_jump(mob/user, mob/living/basic/bounty_criminal/criminal, datum/criminal_bounty/posting) +/datum/bounty_admin_panel/proc/admin_jump(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal, datum/criminal_bounty/posting) error = null if(!authorized(user)) return FALSE @@ -1024,7 +1031,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a return TRUE /// Brings `criminal` to the admin's tile, out of any locker or crate. Returns TRUE if they came. -/datum/bounty_admin_panel/proc/admin_bring(mob/user, mob/living/basic/bounty_criminal/criminal, datum/criminal_bounty/posting) +/datum/bounty_admin_panel/proc/admin_bring(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal, datum/criminal_bounty/posting) error = null if(!authorized(user)) return FALSE @@ -1043,11 +1050,12 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a // ===== CAPTURE STATES ===== /** - * Puts `criminal` in capture state `state` (BOUNTY_STATE_*) through P2's own procs: damage to its - * downed line, a stun, real handcuffs put on by nobody (so nobody lands on its grudge list), death, - * or taking all of that off again. Returns TRUE if capture_state() now reads `state`. + * Puts `criminal` in capture state `state` (BOUNTY_STATE_*) the way tg does it to anyone (CR-21): real + * brute to just past its crit line, a real Paralyze, real handcuffs put on by nobody (so nobody lands on + * its grudge list), death(), or for free a revive with all of that taken off again. Returns TRUE if it + * was done; what capture_state() reads then is reported to the admin. */ -/datum/bounty_admin_panel/proc/admin_force_state(mob/user, mob/living/basic/bounty_criminal/criminal, state) +/datum/bounty_admin_panel/proc/admin_force_state(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal, state) error = null if(!authorized(user)) return FALSE @@ -1070,7 +1078,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a if(!done) return FALSE var/now = criminal.capture_state() - log_action(user, "force [criminal_name(criminal)] to [state] (capture state now [now], worst [criminal.worst_state])") + log_action(user, "force [criminal_name(criminal)] to [state] (capture state now [now])") if(now != state) // Stunned or cuffed while down still reads as downed, as the pad would pay it tell(user, span_notice("[criminal_name(criminal)] is [state], but reads as [now]: [now] comes first.")) @@ -1078,21 +1086,32 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a tell(user, span_notice("[criminal_name(criminal)] is now [now].")) return TRUE -/// Damage straight to P2's downed line, past the mercy rule and armour; P2 downs it from there -/datum/bounty_admin_panel/proc/force_downed(mob/user, mob/living/basic/bounty_criminal/criminal) +/** + * Real brute on its chest until it is just in crit: health a point under its crit line (tg's + * crit_threshold, HEALTH_THRESHOLD_CRIT; the hard-crit line for a body that has no soft crit). No + * armour roll and no wound. Its toughness (physiology) takes its share of each hit, so the hit is + * repeated on what is left until it is down. + */ +/datum/bounty_admin_panel/proc/force_downed(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal) if(criminal.stat == DEAD) return refuse(user, "[criminal_name(criminal)] is dead.") if(criminal.is_downed()) return refuse(user, "[criminal_name(criminal)] is already down.") - var/excess = criminal.health - criminal.body_downed_line() - if(excess > 0) - criminal.adjust_health(excess, TRUE, TRUE) + var/line = (HAS_TRAIT(criminal, TRAIT_NOSOFTCRIT) ? criminal.hardcrit_threshold : criminal.crit_threshold) - 1 + for(var/hit in 1 to BOUNTY_ADMIN_DOWN_HITS) + if(criminal.is_downed() || criminal.stat == DEAD) + break + var/excess = criminal.health - line + if(excess <= 0) + break + if(!criminal.apply_damage(excess, BRUTE, BODY_ZONE_CHEST, wound_bonus = CANT_WOUND)) + break if(!criminal.is_downed()) - return refuse(user, "[criminal_name(criminal)] did not go down ([criminal.health] health, line [criminal.body_downed_line()]).") + return refuse(user, "[criminal_name(criminal)] did not go down ([criminal.health] health).") return TRUE -/// A long paralysis, past stun immunity -/datum/bounty_admin_panel/proc/force_stunned(mob/user, mob/living/basic/bounty_criminal/criminal) +/// A long tg paralysis, past stun immunity +/datum/bounty_admin_panel/proc/force_stunned(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal) if(criminal.stat == DEAD) return refuse(user, "[criminal_name(criminal)] is dead.") criminal.Paralyze(BOUNTY_ADMIN_STUN_TIME, TRUE) @@ -1100,20 +1119,27 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a return refuse(user, "[criminal_name(criminal)] shrugged the stun off.") return TRUE -/// A new pair of real handcuffs, put on at once through P2's body_apply_cuffs() by nobody: held still, its AI paused, and no one on its grudge list -/datum/bounty_admin_panel/proc/force_restrained(mob/user, mob/living/basic/bounty_criminal/criminal) +/** + * A new pair of real handcuffs in its cuff slot, put on the way tg's apply_cuffs() does it, by nobody: + * no cuffing do_after for it to answer, and no one on its grudge list. It can work at them as at + * anyone's. + */ +/datum/bounty_admin_panel/proc/force_restrained(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal) if(criminal.stat == DEAD) - return refuse(user, "[criminal_name(criminal)] is dead: the cuffs would only fall off.") + return refuse(user, "[criminal_name(criminal)] is dead.") if(criminal.is_restrained()) - return refuse(user, "[criminal_name(criminal)] is already cuffed.") + return refuse(user, "[criminal_name(criminal)] is already restrained.") + if(!criminal.canBeHandcuffed()) + return refuse(user, "[criminal_name(criminal)] has no wrists to cuff.") var/obj/item/restraints/handcuffs/cuffs = new(criminal.drop_location()) - if(!criminal.body_apply_cuffs(cuffs, null)) + criminal.equip_to_slot(cuffs, ITEM_SLOT_HANDCUFFED) + if(criminal.handcuffed != cuffs) if(!QDELETED(cuffs) && cuffs.loc != criminal) qdel(cuffs) return refuse(user, "The cuffs did not go on [criminal_name(criminal)].") return TRUE -/datum/bounty_admin_panel/proc/force_dead(mob/user, mob/living/basic/bounty_criminal/criminal) +/datum/bounty_admin_panel/proc/force_dead(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal) if(criminal.stat == DEAD) return refuse(user, "[criminal_name(criminal)] is already dead.") criminal.death() @@ -1122,27 +1148,20 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a return TRUE /** - * Takes every capture state off: the cuffs come off through P2's body_remove_cuffs() (they drop at - * its feet), the surrender ends, stuns end, all damage heals, a downed one gets back up through P2's - * body_stand_up() (a mini-boss rallies, as it would), and the worst state goes back to free, so a - * full-pay turn-in can be tried again. The dead stay dead: a criminal can't be revived. + * Takes every capture state off, through tg (CR-21): revive(HEAL_ALL) heals every hurt (damage, wounds, + * organs, blood, stamina, stuns) and brings a dead one back if its body can live; the cuffs come off + * through tg's uncuff() (they drop at its feet), and any stun left ends, a surrender with it. So a + * full-pay turn-in can be tried again, or a dead criminal brought back for another go. */ -/datum/bounty_admin_panel/proc/force_free(mob/user, mob/living/basic/bounty_criminal/criminal) +/datum/bounty_admin_panel/proc/force_free(mob/user, mob/living/carbon/human/npc/bounty_criminal/criminal) + criminal.revive(HEAL_ALL) if(criminal.stat == DEAD) - return refuse(user, "[criminal_name(criminal)] is dead, and the dead stay dead.") - criminal.body_remove_cuffs(null) - REMOVE_TRAIT(criminal, TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT) - criminal.SetParalyzed(0, TRUE) - criminal.SetKnockdown(0, TRUE) - criminal.SetStun(0, TRUE) - criminal.SetImmobilized(0, TRUE) + return refuse(user, "[criminal_name(criminal)] could not be brought back.") + if(criminal.handcuffed || criminal.legcuffed) + criminal.uncuff() + criminal.SetAllImmobility(0) + criminal.SetSleeping(0) criminal.remove_status_effect(/datum/status_effect/incapacitating/stamcrit) - criminal.fully_heal(HEAL_DAMAGE) - if(criminal.is_downed()) - criminal.body_cancel_recovery() - criminal.body_stand_up() - // No proc makes a worst state better: this is the panel's one direct write (see the file's header) - criminal.worst_state = BOUNTY_STATE_FREE if(criminal.capture_state() != BOUNTY_STATE_FREE) return refuse(user, "[criminal_name(criminal)] still reads as [criminal.capture_state()].") return TRUE @@ -1335,6 +1354,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a #undef BOUNTY_ADMIN_MAX_TICKS #undef BOUNTY_ADMIN_TICK_SECONDS #undef BOUNTY_ADMIN_STUN_TIME +#undef BOUNTY_ADMIN_DOWN_HITS #undef BOUNTY_ADMIN_MAX_STEPS #undef BOUNTY_ADMIN_TEST_CAPTOR #undef BOUNTY_ADMIN_RELIST_REASON diff --git a/voidcrew/modules/bounties/bounty_ai.dm b/voidcrew/modules/bounties/bounty_ai.dm index 7efeb9db2b1..41ee248a3b8 100644 --- a/voidcrew/modules/bounties/bounty_ai.dm +++ b/voidcrew/modules/bounties/bounty_ai.dm @@ -1,22 +1,30 @@ /** * # Bounty hunting: criminal AI, shared parts * - * Owner: P3 AI. Owns this file, bounty_ai_meek.dm, bounty_ai_normal.dm, bounty_activities.dm, - * bounty_companions.dm, voidcrew/_DEFINES/bounty_ai.dm and the tests in + * Owner: P3 AI. Owns this file, bounty_ai_meek.dm, bounty_ai_normal.dm, bounty_styles.dm, + * bounty_activities.dm, bounty_companions.dm, voidcrew/_DEFINES/bounty_ai.dm and the tests in * voidcrew/modules/unit_tests/voidcrew_bounty_ai.dm. Do not edit this file outside P3. * - * What it owns: the criminal AI's own system (decision 11: never the prison AI). This file holds - * what meek, normal, decoys and companions share: - * - the controller base, which hooks the mob's signals from the controller (a separate datum, so - * nothing here collides with the body's own registrations in bounty_criminal.dm); - * - who they may fight (/datum/targeting_strategy/basic/bounty: the body's may_attack(), never - * their own gang, never a downed hunter), and movement that stays on their site (leash_ok()); - * - the planning gate: nothing is planned while dead, downed, restrained or surrendered, and a - * hidden criminal only keeps still; - * - noticing hunters (a spatial grid lookup of player mobs, once a second, never view()); - * - being attacked, confronted, downed, getting up, cuffed and uncuffed; - * - what they hold in their hand and over their head (drawn in vis_contents, so a look rebuilt - * over their overlays never wipes it), and their pace. + * Criminals and their companions are real human bodies (voidcrew/modules/human_npcs/, phase D of the + * human NPC migration). Their crit, stamina, stuns, cuffs, wounds and death are tg's; this AI only + * chooses what the body does, and the body does it the way a player would: it walks or runs, draws + * a weapon it carries, clicks with it (controller.ai_interact()), throws, shoves, sits on a chair, + * lies down, climbs into a locker, injects a pen it carries. + * + * This file holds what meek, normal, decoys and companions share: + * - the controller base (/datum/ai_controller/basic_controller/bounty), which runs on a human pawn + * (contract 2.4: the human NPC run check, soft crit, the core's bounty_escape_captivity) and hooks + * the pawn's attack signals from the controller (a separate datum, so nothing here collides with the + * body's own registrations in bounty_criminal.dm); + * - who they may fight (/datum/targeting_strategy/basic/bounty: the body's may_attack(), never their + * own gang, never someone who is down), and movement that stays on their site + * (/datum/ai_movement/jps/bounty: the body's own leash rule, body_move_allowed()). There is no body + * Move() override: the leash is the AI's, so anyone can still drag, carry or shove them anywhere; + * - the planning gate: nothing is planned while they are dead, in crit, restrained, stunned or giving + * up, and a hidden criminal only keeps still; + * - noticing hunters (a spatial grid lookup of player mobs, twice a second, never view()); + * - being attacked, confronted, downed, getting up, cuffed and uncuffed, giving up; + * - their pace: a walk or a run (ai_set_pace()), as a person moves. * P4 overrides on_confronted() for bosses in its own files, on the /boss subtype. * * The shared vars are declared in bounty_types.dm and nowhere else; a var only P3 uses has an @@ -25,7 +33,7 @@ // ===== STATE ON THE CRIMINAL ===== -/mob/living/basic/bounty_criminal +/mob/living/carbon/human/npc/bounty_criminal ai_controller = /datum/ai_controller/basic_controller/bounty/criminal /// BOUNTY_AI_CALM, _AWARE, _FLEEING, _FIGHTING or _RETREATING var/ai_mode = BOUNTY_AI_CALM @@ -37,12 +45,8 @@ var/ai_next_notice = 0 /// How far off each person was at the last look around, by REF(), to spot someone running at them var/list/ai_seen_distances - /// The speed they move at now; see ai_set_pace() + /// The pace they asked for last (ai_set_pace()): calm or slower is a walk, anything quicker a run var/ai_pace - /// What they hold in their hand, drawn in their vis_contents - var/obj/effect/abstract/bounty_held/ai_hand - /// What shows over their head (hands up), drawn in their vis_contents - var/obj/effect/abstract/bounty_held/ai_over_head /// A walk that keeps failing: how many times, so they give up on it var/ai_travel_failures = 0 @@ -50,10 +54,10 @@ /// The criminal a mob fights for: the criminal itself, or a companion's criminal. Null for anyone else. /proc/bounty_ai_side_of(atom/movable/thing) - if(istype(thing, /mob/living/basic/bounty_criminal)) + if(istype(thing, /mob/living/carbon/human/npc/bounty_criminal)) return thing - if(istype(thing, /mob/living/basic/bounty_companion)) - var/mob/living/basic/bounty_companion/companion = thing + if(istype(thing, /mob/living/carbon/human/npc/bounty_companion)) + var/mob/living/carbon/human/npc/bounty_companion/companion = thing return companion.ai_leader() return null @@ -61,7 +65,7 @@ /proc/bounty_ai_same_gang(atom/movable/first, atom/movable/second) if(first == second) return TRUE - var/mob/living/basic/bounty_criminal/side = bounty_ai_side_of(first) + var/mob/living/carbon/human/npc/bounty_criminal/side = bounty_ai_side_of(first) return !isnull(side) && side == bounty_ai_side_of(second) /// Whether `person` counts as a hunter: someone played, awake, and not a criminal or one of their friends @@ -70,7 +74,7 @@ return FALSE if(!person.mind && !person.client) return FALSE - if(istype(person, /mob/living/basic/bounty_criminal) || istype(person, /mob/living/basic/bounty_companion)) + if(istype(person, /mob/living/carbon/human/npc/bounty_criminal) || istype(person, /mob/living/carbon/human/npc/bounty_companion)) return FALSE return TRUE @@ -83,10 +87,10 @@ /proc/bounty_ai_may_hit(atom/movable/fighter, atom/target) if(QDELETED(target) || bounty_ai_same_gang(fighter, target)) return FALSE - if(istype(fighter, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = fighter + if(istype(fighter, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = fighter return criminal.may_attack(target) - var/mob/living/basic/bounty_criminal/leader = bounty_ai_side_of(fighter) + var/mob/living/carbon/human/npc/bounty_criminal/leader = bounty_ai_side_of(fighter) return !leader || leader.may_attack_scope(target) /// Tiles between `first` and `second` by the turfs they stand on, so a pilot counts as where their mech is (H4); INFINITY across z-levels @@ -150,65 +154,10 @@ continue .++ -// ===== HELD THINGS ===== - -/** - * Something drawn in a mob's hand or over its head. It lives in the mob's vis_contents rather than - * its overlays: the look builder replaces the overlays wholesale, and would wipe it. - */ -/obj/effect/abstract/bounty_held - name = "" - mouse_opacity = MOUSE_OPACITY_TRANSPARENT - appearance_flags = KEEP_APART | RESET_COLOR | RESET_TRANSFORM | PIXEL_SCALE - vis_flags = VIS_INHERIT_PLANE - layer = ABOVE_MOB_LAYER - /// Over the head rather than in the hand - var/over_head = FALSE - /// Drawn with the item's own in-hand sprite, which turns with the holder, rather than its item icon - var/inhand = FALSE - -/** - * Draws `look` in `owner`'s hand (or over their head), reusing `holder`: `look` is an atom, whose - * appearance is copied, or an atom typepath, whose icon is used. An item with an in-hand sprite is - * drawn with that instead, the way a player holds it, so it points the way they face. A null `look` - * takes it away. Returns the holder to keep, or null. - */ -/proc/bounty_ai_update_held(mob/living/owner, obj/effect/abstract/bounty_held/holder, look, over_head = FALSE) - if(isnull(look) || QDELETED(owner)) - if(holder) - owner?.vis_contents -= holder - qdel(holder) - return null - if(!holder) - holder = new(null) - owner.vis_contents += holder - var/list/inhand_look = over_head ? null : npc_inhand_look(look) - if(inhand_look) - holder.appearance = mutable_appearance(inhand_look[1], inhand_look[2]) - else if(ispath(look, /atom)) - var/atom/look_type = look - holder.appearance = mutable_appearance(initial(look_type.icon), initial(look_type.icon_state)) - else if(isatom(look)) - var/atom/look_atom = look - holder.appearance = look_atom.appearance - holder.name = "" - holder.over_head = over_head - holder.inhand = !!inhand_look - holder.mouse_opacity = MOUSE_OPACITY_TRANSPARENT - holder.appearance_flags = KEEP_APART | RESET_COLOR | RESET_TRANSFORM | PIXEL_SCALE - holder.vis_flags = VIS_INHERIT_PLANE - holder.layer = ABOVE_MOB_LAYER - holder.alpha = 255 - holder.dir = SOUTH - holder.pixel_x = 0 - holder.pixel_y = 0 - bounty_ai_place_held(owner, holder) - return holder - /** * The right-hand sprite of `look` (an item or item typepath) as list(file, state), or null when it - * has none: what a player holding it shows. Drawn on an NPC's body it turns with them, as a player's - * does. Used by bounty NPCs and the prison's prisoners and guards. + * has none: what a player holding it shows. The prison's prisoners and guards draw it on their bodies + * (outpost_prison_prisoner.dm); bounty NPCs now hold the real item. */ /proc/npc_inhand_look(look) var/hand_file @@ -225,45 +174,54 @@ return null return list(hand_file, state) -/// Puts a held thing in the hand on the side they face (`facing`, or the way they face now), or over their head -/proc/bounty_ai_place_held(mob/living/owner, obj/effect/abstract/bounty_held/holder, facing) - if(!holder || !owner) - return - // An in-hand sprite is drawn for a whole body: full size, no offset, facing their way - if(holder.inhand) - holder.transform = matrix() - holder.pixel_w = 0 - holder.pixel_z = 0 - holder.dir = facing || owner.dir - return - holder.transform = matrix().Scale(0.6) - if(holder.over_head) - holder.pixel_w = 0 - holder.pixel_z = 22 +/** + * Sets the pace of `body` (a criminal or a companion): calm or slower walks, anything quicker runs, + * through tg's own move intent. A person can't go faster than a run. + */ +/proc/bounty_ai_set_pace(mob/living/carbon/human/body, new_speed) + if(QDELETED(body)) return - var/facing_dir = facing || owner.dir - // The same hand whichever way they face: to the right facing south or east, the left facing north or west - holder.pixel_w = ((facing_dir & WEST) || facing_dir == NORTH) ? -7 : 7 - holder.pixel_z = -4 - -/// What a fighter holds, by whichever of the two types it is -/proc/bounty_ai_hold(mob/living/basic/fighter, look) - if(istype(fighter, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = fighter - criminal.ai_hand = bounty_ai_update_held(criminal, criminal.ai_hand, look) - else if(istype(fighter, /mob/living/basic/bounty_companion)) - var/mob/living/basic/bounty_companion/companion = fighter - companion.ai_hand = bounty_ai_update_held(companion, companion.ai_hand, look) + var/want_run = !isnull(new_speed) && new_speed < BOUNTY_CALM_SPEED + if(want_run != (body.move_intent == MOVE_INTENT_RUN)) + body.toggle_move_intent() + +/** + * A step `mover` (a criminal or a companion) takes by hand, outside the AI's pathing: never off its + * side's leash (the criminal's own body_move_allowed(); a companion its criminal's leash_ok()), into + * lava or a chasm. Returns TRUE if it moved. Every direct step in the bounty AI goes through here. + */ +/proc/bounty_ai_step(mob/living/mover, turf/destination) + if(QDELETED(mover) || !isturf(mover.loc) || !isturf(destination) || get_dist(mover, destination) != 1) + return FALSE + if(islava(destination) || ischasm(destination) || destination.is_blocked_turf(source_atom = mover)) + return FALSE + if(!bounty_ai_step_allowed(mover, destination)) + return FALSE + var/turf/was = mover.loc + mover.Move(destination, get_dir(mover, destination)) + return mover.loc != was + +/// Whether `mover`'s own step onto `destination` keeps to its leash (see bounty_ai_step()) +/proc/bounty_ai_step_allowed(mob/living/mover, turf/destination) + if(istype(mover, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = mover + return criminal.body_move_allowed(destination) + var/mob/living/carbon/human/npc/bounty_criminal/leader = bounty_ai_side_of(mover) + if(!leader || leader.leash_ok(destination)) + return TRUE + // Already off its criminal's ground (dragged, carried, woken elsewhere): it may walk about, but never onto danger + return !leader.leash_ok(get_turf(mover)) && destination.can_cross_safely(mover) // ===== THE CRIMINAL'S SIDE OF IT ===== /** - * `user` confronted them: showed the warrant, or accused them. A meek criminal bolts, a normal one - * fights; a boss starts its fight (P4 overrides this on /boss); P6 decides what a decoy does. This - * and P6's exposure (which ends its act, then calls this) are the only ways out of blending in: - * anything else that happens to a blended patron shows nothing (C1). + * `user` confronted them: showed the warrant, accused them, reached for their wrists (the core's cuff + * reaction calls this). A meek criminal bolts, a normal one fights; a boss starts its fight (P4 + * overrides this on /boss); P6 decides what a decoy does. This and P6's exposure (which ends its act, + * then calls this) are the only ways out of blending in: anything else that happens to a blended + * patron shows nothing (C1). */ -/mob/living/basic/bounty_criminal/proc/on_confronted(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/proc/on_confronted(mob/user) if(stat != CONSCIOUS || !isliving(user) || QDELETED(user)) return ai_add_grudge(user) @@ -278,58 +236,58 @@ * What they do about `threat`: a meek criminal runs, a normal one fights, a decoy gets out of the * way. `bark` is the dialogue context to use instead of the usual one, or null. */ -/mob/living/basic/bounty_criminal/proc/ai_react(mob/living/threat, bark) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_react(mob/living/threat, bark) return /// Something about `hunter` made them look twice (armed, cuffs, a warrant, running at them) -/mob/living/basic/bounty_criminal/proc/ai_notice(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_notice(mob/living/hunter) return /// How far they notice hunters -/mob/living/basic/bounty_criminal/proc/ai_notice_range() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_notice_range() return BOUNTY_NORMAL_NOTICE_RANGE /// Their fighting style's key, BOUNTY_STYLE_*, or null for a mob that never fights -/mob/living/basic/bounty_criminal/proc/ai_wanted_style() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_wanted_style() return null /// Whether they carry their style's heavier weapon: every tier above Petty. A 9mm is a 9mm; the tier only picks the gun. -/mob/living/basic/bounty_criminal/proc/ai_heavy_weapon() - return record?.tier != BOUNTY_TIER_PETTY +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_heavy_weapon() + return !isnull(record?.tier) && record.tier != BOUNTY_TIER_PETTY -/// Puts their style on them, once the record says which it is. Cheap when nothing changed. -/mob/living/basic/bounty_criminal/proc/ai_update_style() +/// Puts their style on their AI, once the record says which it is. Cheap when nothing changed. The weapon itself is what they carry (bounty_build_outfit()). +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_update_style() var/key = ai_wanted_style() if(!key || QDELETED(ai_controller) || ai_controller.blackboard[BB_BOUNTY_STYLE] == key) return - bounty_ai_apply_style(src, key, ai_heavy_weapon()) + bounty_ai_apply_style(src, key) /** - * Whether they can do anything at all: alive, on their feet, free and not giving up. Knocked down, - * stunned or in stamina crit (P2's body_is_stunned()) is the cuff window: no blows, no shots, no - * climbing into hiding (H2). + * Whether they can do anything at all: alive, out of crit, on their feet, free and not giving up. + * Knocked down, stunned, in stamina crit or lying there with their hands on their head (tg's + * *surrender) is the cuff window: no blows, no shots, no climbing into hiding (H2). All read from tg. */ -/mob/living/basic/bounty_criminal/proc/ai_can_act() - if(stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_BOUNTY_HELD) || HAS_TRAIT(src, TRAIT_FLOORED) || is_downed() || is_restrained()) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_can_act() + if(stat != CONSCIOUS || npc_surrendering || HAS_TRAIT(src, TRAIT_FLOORED) || is_downed() || is_restrained()) return FALSE return !body_is_stunned() /// Says a line for `context`, never from inside a signal handler's stack. `force` skips the cooldown, for a moment that must be heard. -/mob/living/basic/bounty_criminal/proc/ai_bark(context, force = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_bark(context, force = FALSE) INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), context, null, force) /// Puts `who` on the list of people they may fight, by P2's rules (the cap, never another criminal or a companion) -/mob/living/basic/bounty_criminal/proc/ai_add_grudge(mob/living/who) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_add_grudge(mob/living/who) if(!isliving(who) || bounty_ai_same_gang(src, who)) return body_add_grudge(who) /// Whether `who` is on their grudge list -/mob/living/basic/bounty_criminal/proc/ai_has_grudge(mob/living/who) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_has_grudge(mob/living/who) return body_has_grudge(who) /// The nearest person on their grudge list they can see (a pilot through their mech), or null -/mob/living/basic/bounty_criminal/proc/ai_nearest_grudge(range = BOUNTY_FIGHT_VISION) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_nearest_grudge(range = BOUNTY_FIGHT_VISION) var/mob/living/best var/best_distance = INFINITY for(var/datum/weakref/ref as anything in grudge) @@ -345,16 +303,16 @@ return best /// Who they are wary of, running from or fighting, if they still exist -/mob/living/basic/bounty_criminal/proc/ai_threat() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_threat() var/mob/living/threat = ai_threat_ref?.resolve() return QDELETED(threat) ? null : threat -/mob/living/basic/bounty_criminal/proc/ai_set_threat(mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_set_threat(mob/living/threat) ai_threat_ref = threat ? WEAKREF(threat) : null ai_threat_seen_at = world.time /// Whether they can see their threat now (a pilot through their mech); seeing it restarts the clock on losing it -/mob/living/basic/bounty_criminal/proc/ai_sees_threat(range = BOUNTY_FIGHT_VISION) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_sees_threat(range = BOUNTY_FIGHT_VISION) var/mob/living/threat = ai_threat() var/turf/threat_turf = get_turf(threat) if(!threat || threat_turf?.z != z || !bounty_ai_can_see(src, threat, range)) @@ -362,21 +320,20 @@ ai_threat_seen_at = world.time return TRUE -/mob/living/basic/bounty_criminal/proc/ai_set_mode(new_mode) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_set_mode(new_mode) ai_mode = new_mode -/// Moves at `new_speed` from now on. Compared with the speed they have, not a cache: the body sets speed too (L3). -/mob/living/basic/bounty_criminal/proc/ai_set_pace(new_speed) +/// Walks (calm, BOUNTY_CALM_SPEED or slower) or runs (anything quicker) from now on: tg's move intent, nothing faster. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_set_pace(new_speed) ai_pace = new_speed - if(speed != new_speed) - set_varspeed(new_speed) + bounty_ai_set_pace(src, new_speed) /** * Takes them out of blending in: P6's act ends with it, and they never go back to the routine (a * P6 exposure has already cleared `blended` by the time on_confronted() runs). Not for decoys, who * are always patrons (bounty_activities.dm). */ -/mob/living/basic/bounty_criminal/proc/ai_expose() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_expose() ai_end_blend() /** @@ -385,7 +342,7 @@ * pathfinder's own step check), and never leaves their site. Built once per flight, never per tick * (H1). get_path_to() can't be used here: it sleeps, and planning must not. */ -/mob/living/basic/bounty_criminal/proc/ai_reachable_turfs(radius) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_reachable_turfs(radius) . = list() var/turf/start = get_turf(src) if(!start) @@ -407,10 +364,10 @@ queue += next /// Gets out of the way of `threat` without looking for somewhere to hide (a decoy that stopped blending in) -/mob/living/basic/bounty_criminal/proc/ai_flee_from(mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_flee_from(mob/living/threat) if(!isliving(threat) || !ai_flee_event(threat, null, seek_hiding = FALSE, quiet = TRUE)) return FALSE - ai_set_pace(BOUNTY_NORMAL_SPEED_MELEE) + ai_set_pace(BOUNTY_RUN_SPEED) return TRUE /** @@ -420,7 +377,7 @@ * blended in (a fugitive at a trader outpost only reacts to a warrant or a blow), fleeing or * fighting. Returns the nearest one found, or null. */ -/mob/living/basic/bounty_criminal/proc/ai_look_around() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_look_around() if(blended || ai_mode == BOUNTY_AI_FLEEING || ai_mode == BOUNTY_AI_FIGHTING) return null if(world.time < ai_next_notice) @@ -459,7 +416,7 @@ * - Friendly fire from their own side of the site with nobody playing it (the pirates they are * aboard with, the site's fauna) is let go (M1). */ -/mob/living/basic/bounty_criminal/proc/ai_attacked_by(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_attacked_by(mob/living/attacker) if(stat == DEAD || blended || !isliving(attacker) || bounty_ai_same_gang(src, attacker)) return if(bounty_ai_friendly_fire(src, attacker)) @@ -476,88 +433,95 @@ ai_react(attacker, null) /// Attacked while already running or fighting -/mob/living/basic/bounty_criminal/proc/ai_attacked_again(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_attacked_again(mob/living/attacker) return /// Someone touched them without attacking: a hider is found by it -/mob/living/basic/bounty_criminal/proc/ai_touched_by(mob/living/toucher) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_touched_by(mob/living/toucher) if(hidden && ai_hide_mode != BOUNTY_HIDE_CONTAINER) ai_reveal(toucher, "touched") /// Someone walked into them: a hider is found by it -/mob/living/basic/bounty_criminal/proc/ai_bumped_by(mob/living/bumper) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_bumped_by(mob/living/bumper) if(hidden && ai_hide_mode != BOUNTY_HIDE_CONTAINER && bounty_ai_is_hunter(bumper)) ai_reveal(bumper, "bumped") -/// They are about to take damage. Inside a signal handler: must not sleep. -/mob/living/basic/bounty_criminal/proc/ai_took_damage(damage, damagetype) +/// They took a hit of `damage` `damagetype`. Inside a signal handler: must not sleep. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_took_damage(damage, damagetype) return -/// Down at the downed line (P2's COMSIG_BOUNTY_CRIMINAL_DOWNED): they drop everything, a shot or a wind-up included. The body says the line (body_go_down()). -/mob/living/basic/bounty_criminal/proc/ai_on_downed() +/// Into crit (the core's COMSIG_BOUNTY_CRIMINAL_DOWNED): whatever they were doing stops, a shot or a wind-up included. The body says the line. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_downed() ai_let_go() if(!QDELETED(ai_controller)) ai_controller.CancelActions() ai_companions_lose_heart() /** - * Back up after the recovery time (P2's COMSIG_BOUNTY_CRIMINAL_RECOVERED): they run or fight again. + * Out of crit or back up from stamina crit (COMSIG_BOUNTY_CRIMINAL_RECOVERED): they run or fight again. * Blended in, they only go back to the blend routine, like any patron (M2). */ -/mob/living/basic/bounty_criminal/proc/ai_on_recovered() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_recovered() if(blended || !ai_can_act()) return ai_react(ai_threat() || ai_nearest_grudge(), "recover") -/// Cuffed (P2's COMSIG_BOUNTY_CRIMINAL_RESTRAINED). The body says the line (body_apply_cuffs()). -/mob/living/basic/bounty_criminal/proc/ai_on_restrained(mob/living/user) +/// Cuffed (COMSIG_BOUNTY_CRIMINAL_RESTRAINED). The body says the line. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_restrained(mob/living/user) ai_let_go() if(!QDELETED(ai_controller)) ai_controller.CancelActions() ai_add_grudge(user) ai_companions_lose_heart() -/// Cuffs off (P2's COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED): if they slipped them, they make a break for it -/mob/living/basic/bounty_criminal/proc/ai_on_unrestrained(mob/living/user) +/// Cuffs off (COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED): if they got out of them themselves, they make a break for it +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_unrestrained(mob/living/user) if(user) return - REMOVE_TRAIT(src, TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT) - ai_over_head = bounty_ai_update_held(src, ai_over_head, null) if(!blended && ai_can_act()) ai_react(ai_nearest_grudge(), null) +/// They gave up (tg's *surrender, run by the human NPC base): hands on their head, the fight dropped, their friends lose heart +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_surrendered() + visible_message(span_notice("[src] raises [p_their()] hands and gives up.")) + ai_stop_aiming() + ai_suspend_activity() + if(!QDELETED(ai_controller)) + ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) + ai_controller.clear_blackboard_key(BB_BOUNTY_DESTINATION) + ai_controller.ai_movement?.stop_moving_towards(ai_controller) + ai_companions_lose_heart() + /// Dead: they let go of everything, and a disguise comes off, so the body looks like them -/mob/living/basic/bounty_criminal/proc/ai_on_death() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_death() ai_let_go() - ai_over_head = bounty_ai_update_held(src, ai_over_head, null) /** * Drops everything they are doing: out of hiding (looks restored), no aim, the activity put away - * (drink down, standing up), nothing in their hand, not sprinting. Safe to call at any time, + * (drink down, up from a seat or the floor). What they hold stays in their hands: their gear is locked + * to them (D3), and a person who is down or cuffed can't holster anything. Safe to call at any time, * including while they are being deleted. */ -/mob/living/basic/bounty_criminal/proc/ai_let_go() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_let_go() if(hidden) ai_unhide() ai_stop_aiming() ai_suspend_activity() // Off the floor from a retreat's rest too, not only an activity's (L9) ai_stand_up() - ai_hand = bounty_ai_update_held(src, ai_hand, null) - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) if(!QDELETED(ai_controller)) ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) ai_controller.clear_blackboard_key(BB_BASIC_MOB_FLEE_TARGET) ai_controller.clear_blackboard_key(BB_BOUNTY_HIDE_SPOT) ai_controller.clear_blackboard_key(BB_BOUNTY_DESTINATION) -/// Nothing is after them any more: back to what they were doing, at an easy pace -/mob/living/basic/bounty_criminal/proc/ai_calm_down() +/// Nothing is after them any more: weapon away, back to what they were doing, at a walk +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_calm_down() ai_set_mode(BOUNTY_AI_CALM) ai_set_threat(null) ai_stop_aiming() - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) - ai_hand = bounty_ai_update_held(src, ai_hand, null) + if(ai_can_act() && !HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) + bounty_ai_holster(src) ai_set_pace(BOUNTY_CALM_SPEED) if(!QDELETED(ai_controller)) ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) @@ -566,7 +530,7 @@ ai_controller.clear_blackboard_key(BB_BOUNTY_DESTINATION) /// A walk the AI set up failed; after a few, they give up on it -/mob/living/basic/bounty_criminal/proc/ai_travel_failed() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_travel_failed() if(++ai_travel_failures < BOUNTY_TRAVEL_GIVE_UP) return ai_travel_failures = 0 @@ -579,39 +543,44 @@ ai_activity?.spot_unreachable() /// Their companions, as mobs that still exist -/mob/living/basic/bounty_criminal/proc/ai_companions() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_companions() . = list() for(var/datum/weakref/ref as anything in companions) - var/mob/living/basic/bounty_companion/buddy = ref?.resolve() + var/mob/living/carbon/human/npc/bounty_companion/buddy = ref?.resolve() if(!QDELETED(buddy)) . += buddy /// Tells every companion to go for `target`: the trooper reinforcement pattern, without a view() scan -/mob/living/basic/bounty_criminal/proc/ai_rally_companions(mob/living/target) - for(var/mob/living/basic/bounty_companion/buddy as anything in ai_companions()) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_rally_companions(mob/living/target) + for(var/mob/living/carbon/human/npc/bounty_companion/buddy as anything in ai_companions()) buddy.ai_join_fight(target) /// Their criminal is down, cuffed or gave up: each companion may run for it -/mob/living/basic/bounty_criminal/proc/ai_companions_lose_heart() - for(var/mob/living/basic/bounty_companion/buddy as anything in ai_companions()) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_companions_lose_heart() + for(var/mob/living/carbon/human/npc/bounty_companion/buddy as anything in ai_companions()) buddy.ai_lose_heart() /// Whether they may fire at `target` now. A meek criminal only fires its cornered volley. -/mob/living/basic/bounty_criminal/proc/ai_may_shoot(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_may_shoot(atom/target) return TRUE // ===== TARGETING AND MOVEMENT ===== /** * Who a criminal or a companion may fight: tg's rules (in sight, not in godmode), plus - * bounty_ai_may_hit() (the body's rules on who is fair game, never their own gang), and never a - * downed hunter (BB_TARGET_MINIMUM_STAT is CONSCIOUS, spec C4). A pilot is fought through their - * mech (H4): tg's own mech branch tests the pilot with no sight range and never passes, so a mech - * is judged here, as P4 does for bosses. + * bounty_ai_may_hit() (the body's rules on who is fair game, never their own gang), and never someone + * who is down (BB_TARGET_MINIMUM_STAT is CONSCIOUS: the human NPC base's HUMAN_NPC_SPARES_DOWNED, spec + * C4). A check made without a range uses the sight range (BB_AGGRO_RANGE), as the human NPC base's + * targeting does, and never picks someone in another packed encounter. This is the only gate on whom + * a blow or a shot is aimed at: the body then clicks the target like a player. A pilot is fought + * through their mech (H4): tg's own mech branch tests the pilot with no sight range and never + * passes, so a mech is judged here, as P4 does for bosses. */ /datum/targeting_strategy/basic/bounty /datum/targeting_strategy/basic/bounty/can_attack(mob/living/living_mob, atom/the_target, vision_range) + if(isnull(vision_range)) + vision_range = living_mob.ai_controller?.blackboard[BB_AGGRO_RANGE] || BOUNTY_FIGHT_VISION if(ismecha(the_target)) return mech_attackable(living_mob, the_target, vision_range) . = ..() @@ -620,6 +589,8 @@ var/mob/living/person = the_target if(isliving(person) && ismecha(person.loc)) return FALSE + if(map_region_excludes_turf(map_region_for_turf(get_turf(living_mob)), get_turf(the_target))) + return FALSE return bounty_ai_may_hit(living_mob, the_target) /// A mech in sight with someone awake aboard who is fair game @@ -634,9 +605,11 @@ return FALSE /** - * Smart pathing that stays on their site (AR-E1): a step off the leash (P2's leash_ok(): the site's - * bounds, never a ship, hangar or player outpost) is refused. Paths are bounded by - * AI_MAX_PATH_LENGTH like any jps mover. + * Smart pathing that stays on their site (AR-E1, CR-19): a step the body's leash refuses is never + * taken (body_move_allowed(): onto the site always, off it never, and an off-site body only back + * toward it and never into a hazard or another ship). This is the leash; the body has no Move() + * override, so drags, carries and shoves are tg's. Paths are bounded by AI_MAX_PATH_LENGTH like any + * jps mover. */ /datum/ai_movement/jps/bounty @@ -647,8 +620,8 @@ var/list/path = source.movement_path if(!length(path)) return - var/mob/living/basic/bounty_criminal/leash_holder = bounty_ai_side_of(source.moving) - if(leash_holder && !leash_holder.leash_ok(path[1])) + var/mob/living/mover = source.moving + if(isliving(mover) && !bounty_ai_step_allowed(mover, path[1])) return FALSE /// Now and then a step in some direction, while nothing else is planned: never off the leash, never while hiding, cuffed or down @@ -658,39 +631,36 @@ /datum/idle_behavior/bounty_wander/perform_idle_behavior(seconds_per_tick, datum/ai_controller/controller) . = ..() - var/mob/living/basic/wanderer = controller.pawn - if(!istype(wanderer) || !isturf(wanderer.loc) || wanderer.buckled || wanderer.pulledby || LAZYLEN(wanderer.do_afters)) + var/mob/living/carbon/human/wanderer = controller.pawn + if(!istype(wanderer) || wanderer.stat != CONSCIOUS || !isturf(wanderer.loc) || wanderer.buckled || wanderer.pulledby || wanderer.resting || LAZYLEN(wanderer.do_afters)) return if(!(wanderer.mobility_flags & MOBILITY_MOVE) || !SPT_PROB(walk_chance, seconds_per_tick)) return - if(istype(wanderer, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = wanderer + if(istype(wanderer, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = wanderer if(criminal.hidden || !criminal.ai_can_act() || criminal.ai_activity) return - else if(istype(wanderer, /mob/living/basic/bounty_companion)) - var/mob/living/basic/bounty_companion/companion = wanderer - if(companion.ai_gave_up) + else if(istype(wanderer, /mob/living/carbon/human/npc/bounty_companion)) + var/mob/living/carbon/human/npc/bounty_companion/companion = wanderer + if(!companion.ai_can_fight()) return - var/mob/living/basic/bounty_criminal/side = bounty_ai_side_of(wanderer) - var/move_dir = pick(GLOB.alldirs) - var/turf/destination = get_step(wanderer, move_dir) + var/turf/destination = get_step(wanderer, pick(GLOB.alldirs)) if(!destination?.can_cross_safely(wanderer)) return - if(side && !side.leash_ok(destination)) - return - wanderer.Move(destination, move_dir) + bounty_ai_step(wanderer, destination) // ===== PLANNING ===== /** - * First in every criminal's tree. Nothing is planned while they are dead, downed, restrained or - * surrendered; a hidden criminal only keeps still (and checks whether it has been found); one - * shut in something they can't get out of waits. + * First of a criminal's own subtrees, after the human NPC base's soft crit and the core's + * bounty_escape_captivity. Nothing is planned while they can't act (in crit, cuffed, stunned, giving + * up); a hidden criminal only keeps still (and checks whether it has been found); one shut in + * something they can't get out of waits. */ /datum/ai_planning_subtree/bounty_gate /datum/ai_planning_subtree/bounty_gate/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!istype(criminal)) return if(!criminal.ai_can_act()) @@ -706,7 +676,7 @@ /datum/ai_planning_subtree/bounty_notice /datum/ai_planning_subtree/bounty_notice/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn var/mob/living/hunter = criminal.ai_look_around() if(hunter) criminal.ai_notice(hunter) @@ -716,11 +686,11 @@ action_cooldown = 1 SECONDS /datum/ai_behavior/bounty_stay_hidden/setup(datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn return istype(criminal) && criminal.hidden /datum/ai_behavior/bounty_stay_hidden/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!criminal.hidden) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED criminal.ai_hidden_tick() @@ -732,9 +702,9 @@ behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION /datum/ai_behavior/bounty_watch/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn var/mob/living/threat = istype(criminal) ? criminal.ai_threat() : null - if(threat && bounty_ai_dist(criminal, threat) <= BOUNTY_FIGHT_VISION) + if(threat && bounty_ai_dist(criminal, threat) <= BOUNTY_FIGHT_VISION && !criminal.buckled) criminal.face_atom(bounty_ai_target_for(threat)) return AI_BEHAVIOR_DELAY @@ -760,7 +730,7 @@ var/atom/target = controller.blackboard[target_key] var/stuck = !succeeded && controller.consecutive_pathing_attempts > 0 && target && controller.current_movement_target == target . = ..() - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!istype(criminal)) return if(succeeded) @@ -775,74 +745,69 @@ // ===== CONTROLLERS ===== /** - * What every bounty fighter's controller shares, criminals and companions alike: who they may - * fight, smart pathing on the leash, and the signal hooks for styles and for being attacked. The - * hooks are registered by the controller, never by the mob on itself, so they never collide with - * the body's own registrations (a second RegisterSignal from the same datum replaces the first). - * - * Attacks are read from the attack signals themselves (items, hands, mobs, shots that landed, - * thrown things) rather than relay_attackers, whose projectile hook fires even for a shot that - * passes through a blended patron. + * What every bounty fighter's controller shares, criminals and companions alike, on a human pawn + * (contract 2.4): the human NPC base's run check (cuffs and soft crit don't switch the AI off, so it can + * work at its cuffs and crawl), who they may fight, smart pathing on the leash, and the signal hooks for + * being attacked. The hooks are registered by the controller, never by the mob on itself, so they + * never collide with the body's own registrations (a second RegisterSignal from the same datum + * replaces the first). * * Who they fight comes from their grudge list and retaliation only, filtered by the body's * may_attack() and their own gang, so faction is ignored in targeting: a criminal shares * FACTION_NEUTRAL with players until the placement sets otherwise, and would never hit back. + * + * Every planning list starts with the human NPC base's soft-crit subtree, then the core's + * bounty_escape_captivity (it works at its cuffs and breaks grabs, but never climbs out of a locker it + * hid in or off a chair it sat on). */ /datum/ai_controller/basic_controller/bounty blackboard = list( BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/bounty, - BB_TARGET_MINIMUM_STAT = CONSCIOUS, + BB_TARGET_MINIMUM_STAT = HUMAN_NPC_SPARES_DOWNED, + BB_AGGRO_RANGE = BOUNTY_FIGHT_VISION, BB_BASIC_MOB_FLEE_DISTANCE = BOUNTY_FLEE_DISTANCE, BB_ALWAYS_IGNORE_FACTION = TRUE, ) ai_movement = /datum/ai_movement/jps/bounty idle_behavior = /datum/idle_behavior/bounty_wander - /// Whether the mob carries a ranged_attacks component, for a ranged style or the holdout. A tree with a ranged subtree needs it at init. - var/armed = TRUE /datum/ai_controller/basic_controller/bounty/TryPossessPawn(atom/new_pawn) - if(!isbasicmob(new_pawn)) + if(!ishumannpc(new_pawn)) return AI_CONTROLLER_INCOMPATIBLE - var/mob/living/basic/fighter = new_pawn - if(armed) - fighter.AddComponent(/datum/component/ranged_attacks, projectile_type = /obj/projectile/bullet/bounty) - RegisterSignal(fighter, COMSIG_HOSTILE_PRE_ATTACKINGTARGET, PROC_REF(on_pre_melee)) - RegisterSignal(fighter, COMSIG_HOSTILE_POST_ATTACKINGTARGET, PROC_REF(on_post_melee)) - RegisterSignal(fighter, COMSIG_BASICMOB_PRE_ATTACK_RANGED, PROC_REF(on_pre_ranged)) - RegisterSignal(fighter, COMSIG_BASICMOB_POST_ATTACK_RANGED, PROC_REF(on_post_ranged)) - RegisterSignal(fighter, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(on_fire)) - RegisterSignal(fighter, COMSIG_ATOM_DIR_CHANGE, PROC_REF(on_turned)) + var/mob/living/carbon/human/npc/fighter = new_pawn RegisterSignal(fighter, COMSIG_ATOM_AFTER_ATTACKEDBY, PROC_REF(on_attacked_by_item)) RegisterSignal(fighter, COMSIG_ATOM_ATTACK_HAND, PROC_REF(on_touched)) - // A basic mob's blow reaches attack_animal() too, so this one signal covers both kinds of mob - RegisterSignal(fighter, COMSIG_ATOM_ATTACK_ANIMAL, PROC_REF(on_attacked_by_mob)) + RegisterSignals(fighter, list(COMSIG_ATOM_ATTACK_ANIMAL, COMSIG_ATOM_ATTACK_BASIC_MOB), PROC_REF(on_attacked_by_mob)) RegisterSignal(fighter, COMSIG_ATOM_ATTACK_MECH, PROC_REF(on_attacked_by_mech)) RegisterSignal(fighter, COMSIG_ATOM_BULLET_ACT, PROC_REF(on_shot)) RegisterSignal(fighter, COMSIG_ATOM_HITBY, PROC_REF(on_hit_by_thrown)) - RegisterSignal(fighter, COMSIG_MOB_APPLY_DAMAGE, PROC_REF(on_damage)) + RegisterSignal(fighter, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damage)) RegisterSignal(fighter, SIGNAL_ADDTRAIT(TRAIT_FLOORED), PROC_REF(on_floored)) return ..() /datum/ai_controller/basic_controller/bounty/UnpossessPawn(destroy) if(pawn) UnregisterSignal(pawn, list( - COMSIG_HOSTILE_PRE_ATTACKINGTARGET, - COMSIG_HOSTILE_POST_ATTACKINGTARGET, - COMSIG_BASICMOB_PRE_ATTACK_RANGED, - COMSIG_BASICMOB_POST_ATTACK_RANGED, - COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, - COMSIG_ATOM_DIR_CHANGE, COMSIG_ATOM_AFTER_ATTACKEDBY, COMSIG_ATOM_ATTACK_HAND, COMSIG_ATOM_ATTACK_ANIMAL, + COMSIG_ATOM_ATTACK_BASIC_MOB, COMSIG_ATOM_ATTACK_MECH, COMSIG_ATOM_BULLET_ACT, COMSIG_ATOM_HITBY, - COMSIG_MOB_APPLY_DAMAGE, + COMSIG_MOB_AFTER_APPLY_DAMAGE, SIGNAL_ADDTRAIT(TRAIT_FLOORED), )) return ..() +// The basic controller's own check, except that cuffs and soft crit don't stop us (human NPC base, contract 2.4) +/datum/ai_controller/basic_controller/bounty/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/bounty/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) + /// Whether the pawn may land a blow or a shot on `target` (a companion fights on when its criminal is down, H3) /datum/ai_controller/basic_controller/bounty/proc/may_strike(atom/target) return bounty_ai_may_hit(pawn, target) @@ -859,50 +824,10 @@ /datum/ai_controller/basic_controller/bounty/proc/pawn_touched(mob/living/toucher) return -/// The pawn is about to take `damage` of `damagetype`. Called inside the signal handler: must not sleep. +/// The pawn just took `damage` of `damagetype` (its health already shows it). Inside the signal handler: must not sleep. /datum/ai_controller/basic_controller/bounty/proc/pawn_damaged(damage, damagetype) return -/// Moves what the pawn holds to the hand on the side it is turning to (`facing`) -/datum/ai_controller/basic_controller/bounty/proc/place_held(facing) - return - -/datum/ai_controller/basic_controller/bounty/proc/on_pre_melee(mob/living/basic/source, atom/target, proximity, list/modifiers) - SIGNAL_HANDLER - if(!may_strike(target)) - return COMPONENT_HOSTILE_NO_ATTACK - // Each swing picks one of the weapon's own verbs, as a player's does - bounty_ai_real_weapon(source)?.pick_verb(source) - return NONE - -/datum/ai_controller/basic_controller/bounty/proc/on_post_melee(mob/living/basic/source, atom/target, result) - SIGNAL_HANDLER - if(!result || !isliving(target)) - return - var/datum/bounty_style/style = bounty_ai_style(source) - if(style) - INVOKE_ASYNC(style, TYPE_PROC_REF(/datum/bounty_style, on_melee_hit), source, target) - -/datum/ai_controller/basic_controller/bounty/proc/on_pre_ranged(mob/living/basic/source, atom/target, list/modifiers) - SIGNAL_HANDLER - return may_shoot(target) ? NONE : COMPONENT_CANCEL_RANGED_ATTACK - -/datum/ai_controller/basic_controller/bounty/proc/on_post_ranged(mob/living/basic/source, atom/target, list/modifiers) - SIGNAL_HANDLER - var/datum/bounty_style/style = bounty_ai_style(source) - style?.after_shot(source) - -/datum/ai_controller/basic_controller/bounty/proc/on_fire(mob/living/basic/source, obj/projectile/shot, atom/fired_from, atom/original) - SIGNAL_HANDLER - var/datum/bounty_style/style = bounty_ai_style(source) - style?.prepare_projectile(source, shot) - -/// Sent before the turn lands, so the new direction is passed on rather than read off the mob -/datum/ai_controller/basic_controller/bounty/proc/on_turned(atom/source, old_dir, new_dir) - SIGNAL_HANDLER - if(old_dir != new_dir) - place_held(new_dir) - /datum/ai_controller/basic_controller/bounty/proc/on_attacked_by_item(atom/source, obj/item/weapon, mob/living/attacker, list/modifiers) SIGNAL_HANDLER if(weapon?.force && isliving(attacker)) @@ -919,12 +844,8 @@ /datum/ai_controller/basic_controller/bounty/proc/on_attacked_by_mob(atom/source, mob/living/attacker) SIGNAL_HANDLER - if(!isliving(attacker)) + if(!isliving(attacker) || attacker.melee_damage_upper <= 0) return - if(isbasicmob(attacker)) - var/mob/living/basic/basic_attacker = attacker - if(basic_attacker.melee_damage_upper <= 0) - return INVOKE_ASYNC(src, PROC_REF(pawn_attacked), attacker) /// A mech's blow is its pilot's (H4) @@ -934,7 +855,7 @@ if(attacker) INVOKE_ASYNC(src, PROC_REF(pawn_attacked), attacker) -/// Only a shot that actually hit: one that passed through a blended patron never gets here. A mech's gunfire is its pilot's (H4). +/// A shot that hit them. A mech's gunfire is its pilot's (H4). /datum/ai_controller/basic_controller/bounty/proc/on_shot(atom/source, obj/projectile/shot, def_zone, piercing_hit, blocked) SIGNAL_HANDLER if(!shot.is_hostile_projectile()) @@ -961,8 +882,12 @@ if(isliving(thrower)) INVOKE_ASYNC(src, PROC_REF(pawn_attacked), thrower) +/// Any damage landed: the hit is noted (a wind-up it lands in is fumbled, bounty_styles.dm) /datum/ai_controller/basic_controller/bounty/proc/on_damage(atom/source, damage, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) SIGNAL_HANDLER + if(damage <= 0) + return + set_blackboard_key(BB_BOUNTY_HIT_AT, world.time) pawn_damaged(damage, damagetype) /** @@ -971,23 +896,25 @@ * bounty_activities.dm). can_idle stays TRUE, as for every basic mob. */ /datum/ai_controller/basic_controller/bounty/criminal - armed = FALSE planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, /datum/ai_planning_subtree/bounty_gate, /datum/ai_planning_subtree/bounty_activity, ) /datum/ai_controller/basic_controller/bounty/criminal/TryPossessPawn(atom/new_pawn) - if(!istype(new_pawn, /mob/living/basic/bounty_criminal)) + if(!istype(new_pawn, /mob/living/carbon/human/npc/bounty_criminal)) return AI_CONTROLLER_INCOMPATIBLE - var/mob/living/basic/bounty_criminal/criminal = new_pawn - RegisterSignal(criminal, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_prehit)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = new_pawn RegisterSignal(criminal, COMSIG_ATOM_CAN_BE_PULLED, PROC_REF(on_pull_check)) RegisterSignal(criminal, COMSIG_ATOM_BUMPED, PROC_REF(on_bumped)) RegisterSignal(criminal, COMSIG_BOUNTY_CRIMINAL_DOWNED, PROC_REF(on_downed)) RegisterSignal(criminal, COMSIG_BOUNTY_CRIMINAL_RECOVERED, PROC_REF(on_recovered)) RegisterSignal(criminal, COMSIG_BOUNTY_CRIMINAL_RESTRAINED, PROC_REF(on_restrained)) RegisterSignal(criminal, COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED, PROC_REF(on_unrestrained)) + RegisterSignal(criminal, COMSIG_HUMAN_NPC_SURRENDERED, PROC_REF(on_surrendered)) criminal.ai_set_pace(BOUNTY_CALM_SPEED) // Spec section 3: they don't fight the hostiles native to their site. The placement adds the // site's own factions (pirates) on top. @@ -998,100 +925,85 @@ return ..() /datum/ai_controller/basic_controller/bounty/criminal/UnpossessPawn(destroy) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(istype(criminal)) UnregisterSignal(criminal, list( - COMSIG_PROJECTILE_PREHIT, COMSIG_ATOM_CAN_BE_PULLED, COMSIG_ATOM_BUMPED, COMSIG_BOUNTY_CRIMINAL_DOWNED, COMSIG_BOUNTY_CRIMINAL_RECOVERED, COMSIG_BOUNTY_CRIMINAL_RESTRAINED, COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED, + COMSIG_HUMAN_NPC_SURRENDERED, )) - // Anything they hold goes, and a disguise comes off: this runs before the mob's contents are deleted. + // A disguise comes off, a hiding place is left: this runs before the mob's contents are deleted. criminal.ai_let_go() - criminal.ai_over_head = bounty_ai_update_held(criminal, criminal.ai_over_head, null) // Their camp stays as it is, but the fire burns down criminal.ai_put_out_camp() return ..() /datum/ai_controller/basic_controller/bounty/criminal/on_stat_changed(mob/living/source, new_stat) . = ..() - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(istype(criminal) && criminal.stat == DEAD) criminal.ai_on_death() /// No blow from someone giving up, cuffed, down, hidden or passing as a patron /datum/ai_controller/basic_controller/bounty/criminal/may_strike(atom/target) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(!istype(criminal) || !criminal.ai_can_act() || criminal.hidden || criminal.blended) return FALSE return ..() /datum/ai_controller/basic_controller/bounty/criminal/may_shoot(atom/target) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(!istype(criminal) || !criminal.ai_may_shoot(target)) return FALSE return ..() /datum/ai_controller/basic_controller/bounty/criminal/pawn_attacked(mob/living/attacker) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(istype(criminal)) criminal.ai_attacked_by(attacker) /datum/ai_controller/basic_controller/bounty/criminal/pawn_touched(mob/living/toucher) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(istype(criminal)) criminal.ai_touched_by(toucher) /datum/ai_controller/basic_controller/bounty/criminal/pawn_damaged(damage, damagetype) - var/mob/living/basic/bounty_criminal/criminal = pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = pawn if(istype(criminal)) criminal.ai_took_damage(damage, damagetype) -/datum/ai_controller/basic_controller/bounty/criminal/place_held(facing) - var/mob/living/basic/bounty_criminal/criminal = pawn - if(istype(criminal)) - bounty_ai_place_held(criminal, criminal.ai_hand, facing) - -/// A shot passes through a blended patron (the fugitive or a decoy) it was not aimed at (AR-C7) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_prehit(mob/living/basic/bounty_criminal/source, obj/projectile/shot) +/// Pulling at a hider finds them (CR-8: the pull itself is tg's and goes ahead) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_pull_check(mob/living/carbon/human/npc/bounty_criminal/source, mob/living/user) SIGNAL_HANDLER - if(source.blended && shot.original != source) - return PROJECTILE_INTERRUPT_HIT_PHASE + if(source.hidden && isliving(user)) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_reveal), user, "pulled") return NONE -/** - * Pulling at a hider finds them (P2 sends the signal before its own refusal, L1). Who may pull a - * blended patron is P2's rule (body_can_be_dragged(): a downed one can be dragged, AR-C7) and P6's - * act, never decided here. - */ -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_pull_check(mob/living/basic/bounty_criminal/source, mob/living/user) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_bumped(mob/living/carbon/human/npc/bounty_criminal/source, atom/movable/bumper) SIGNAL_HANDLER - if(source.hidden) - if(isliving(user)) - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_reveal), user, "pulled") - return COMSIG_ATOM_CANT_PULL - return NONE + if(source.hidden && isliving(bumper)) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_bumped_by), bumper) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_bumped(mob/living/basic/bounty_criminal/source, atom/movable/bumper) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_downed(mob/living/carbon/human/npc/bounty_criminal/source) SIGNAL_HANDLER - if(source.hidden && isliving(bumper)) - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_bumped_by), bumper) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_on_downed)) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_downed(mob/living/basic/bounty_criminal/source) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_recovered(mob/living/carbon/human/npc/bounty_criminal/source) SIGNAL_HANDLER - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_on_downed)) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_on_recovered)) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_recovered(mob/living/basic/bounty_criminal/source) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_restrained(mob/living/carbon/human/npc/bounty_criminal/source, mob/living/user) SIGNAL_HANDLER - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_on_recovered)) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_on_restrained), user) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_restrained(mob/living/basic/bounty_criminal/source, mob/living/user) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_unrestrained(mob/living/carbon/human/npc/bounty_criminal/source, mob/living/user) SIGNAL_HANDLER - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_on_restrained), user) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_on_unrestrained), user) -/datum/ai_controller/basic_controller/bounty/criminal/proc/on_unrestrained(mob/living/basic/bounty_criminal/source, mob/living/user) +/datum/ai_controller/basic_controller/bounty/criminal/proc/on_surrendered(mob/living/carbon/human/npc/bounty_criminal/source) SIGNAL_HANDLER - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, ai_on_unrestrained), user) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_criminal, ai_on_surrendered)) diff --git a/voidcrew/modules/bounties/bounty_ai_human.dm b/voidcrew/modules/bounties/bounty_ai_human.dm new file mode 100644 index 00000000000..ba71cdb90f2 --- /dev/null +++ b/voidcrew/modules/bounties/bounty_ai_human.dm @@ -0,0 +1,210 @@ +/** + * # Bounty hunting: AI pieces for human bodies + * + * Owner: P2 body. Shared by every bounty AI controller (criminals, companions, decoys, mini-bosses, + * the kingpin's crew, the mafia club), whose pawns are real humans (voidcrew/modules/human_npcs/). + * + * - Breaking free (bounty_escape_captivity): tg's escape_captivity, without the cases that would undo + * what a criminal chose to do: it never resists out of a chair it sat down on or a locker it hid in. + * It works at its cuffs (tg's breakout times), breaks grabs by anyone it may fight, and squirms off a + * carrier's shoulders, the way a player does. + * - Self-treatment (N6, bounty_treat_self): hurt, with nothing it can see attacking it, it uses what it + * carries on itself the way a player does: a medipen through the human NPC base's + * human_npc_treat_self ("use in hand"), a bruise pack, ointment, suture or mesh clicked on itself on its + * worst limb (tg's own self-treatment delay). No heal comes from anywhere else. + * - Injectors (bounty_ai_use_injector()): a boss's stimulant at the start of a fight, a meek criminal's + * stimpack as it bolts: a real pen, used on itself. + */ + +// ===== BREAKING FREE ===== + +/datum/ai_planning_subtree/bounty_escape_captivity + /// Blackboard key of the targeting strategy that says whose grab it fights + var/targeting_strategy_key = BB_TARGETING_STRATEGY + +/datum/ai_planning_subtree/bounty_escape_captivity/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/pawn = controller.pawn + if(!iscarbon(pawn) || pawn.stat != CONSCIOUS) + return + if(HAS_TRAIT(pawn, TRAIT_RESTRAINED)) + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + var/mob/living/carbon/human/npc/bounty_criminal/criminal = istype(pawn, /mob/living/carbon/human/npc/bounty_criminal) ? pawn : null + // Shut in something it didn't choose to hide in: it gets out the way a person does. + if(!isturf(pawn.loc) && !ismob(pawn.loc) && !criminal?.hidden) + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) + var/mob/living/puller = pawn.pulledby + if(puller && puller.grab_state > GRAB_PASSIVE && bounty_ai_fights_holder(pawn, puller, strategy)) + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + // Over someone's shoulders, or on a buckle it didn't sit down on, it gets off. + if(ismob(pawn.buckled)) + if(bounty_ai_fights_holder(pawn, pawn.buckled, strategy)) + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + else if(pawn.buckled?.buckle_requires_restraints) + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + +/// Whether `pawn` fights its way out of `holder`'s hold: someone it may fight, or any player while it is wanted +/proc/bounty_ai_fights_holder(mob/living/pawn, mob/living/holder, datum/targeting_strategy/strategy) + if(!isliving(holder) || holder == pawn || pawn.faction_check_atom(holder)) + return FALSE + if(strategy?.can_attack(pawn, holder)) + return TRUE + return !isnull(holder.mind) || !isnull(holder.client) + +// ===== SELF-TREATMENT (N6) ===== + +/** + * Hurt and unthreatened, it treats itself with what it carries. Put it after the fight subtrees: it + * only plans when nothing before it did, and never with a living hostile in sight that has hurt it. + */ +/datum/ai_planning_subtree/bounty_treat_self + +/datum/ai_planning_subtree/bounty_treat_self/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/pawn = controller.pawn + if(!iscarbon(pawn) || pawn.stat != CONSCIOUS || HAS_TRAIT(pawn, TRAIT_HANDS_BLOCKED) || LAZYLEN(pawn.do_afters)) + return + if(world.time < controller.blackboard[BB_HUMAN_NPC_NEXT_TREAT]) + return + if(pawn.health > pawn.maxHealth * BOUNTY_TREAT_SELF_BELOW) + return + if(controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + if(bounty_ai_queue_treat_self(controller)) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * Queues the best treatment `controller`'s pawn carries for itself: a medipen with something in it, else + * a medical stack that suits its worst hurt. Returns TRUE if it queued one. For a retreat that wants to + * patch up where it sits. + */ +/proc/bounty_ai_queue_treat_self(datum/ai_controller/controller) + var/mob/living/carbon/pawn = controller.pawn + if(!iscarbon(pawn) || world.time < controller.blackboard[BB_HUMAN_NPC_NEXT_TREAT]) + return FALSE + var/obj/item/reagent_containers/hypospray/medipen/pen = bounty_ai_find_medipen(pawn) + if(pen) + controller.set_blackboard_key(BB_HUMAN_NPC_TREAT_ITEM, pen) + controller.queue_behavior(/datum/ai_behavior/human_npc_treat_self, BB_HUMAN_NPC_TREAT_ITEM) + return TRUE + var/obj/item/stack/medical/stack = bounty_ai_find_medical_stack(pawn) + if(stack) + controller.set_blackboard_key(BB_HUMAN_NPC_TREAT_ITEM, stack) + controller.queue_behavior(/datum/ai_behavior/bounty_apply_medical, BB_HUMAN_NPC_TREAT_ITEM) + return TRUE + // Nothing to use: it doesn't look again for a while. + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_TREAT, world.time + BOUNTY_TREAT_SELF_COOLDOWN) + return FALSE + +/// A medipen it carries with something healing in it (not a stimulant: those are for a fight), that it can reach +/proc/bounty_ai_find_medipen(mob/living/carbon/pawn) + for(var/obj/item/reagent_containers/hypospray/medipen/pen in pawn.get_all_contents_type(/obj/item/reagent_containers/hypospray/medipen)) + if(pen.used_up || !pen.reagents?.total_volume || bounty_ai_is_stimulant(pen)) + continue + if(pawn.is_holding(pen) || human_npc_can_draw(pawn, pen)) + return pen + return null + +/// Whether `pen` is a stimulant (a fight drug), not a treatment +/proc/bounty_ai_is_stimulant(obj/item/reagent_containers/hypospray/medipen/pen) + return istype(pen, /obj/item/reagent_containers/hypospray/medipen/stimpack) || istype(pen, /obj/item/reagent_containers/hypospray/medipen/stimulants) || istype(pen, /obj/item/reagent_containers/hypospray/medipen/methamphetamine) + +/// A medical stack it carries and can reach that treats the kind of hurt it has most of +/proc/bounty_ai_find_medical_stack(mob/living/carbon/pawn) + var/burnt = pawn.getFireLoss() > pawn.getBruteLoss() + var/obj/item/stack/medical/fallback + for(var/obj/item/stack/medical/stack in pawn.get_all_contents_type(/obj/item/stack/medical)) + if(stack.amount <= 0 || !(pawn.is_holding(stack) || human_npc_can_draw(pawn, stack))) + continue + if((burnt && stack.heal_burn > 0) || (!burnt && stack.heal_brute > 0)) + return stack + fallback ||= stack + return fallback + +/// The limb of `pawn` with the most brute and burn on it, or the chest +/proc/bounty_ai_worst_limb(mob/living/carbon/pawn) + var/obj/item/bodypart/worst + var/worst_damage = 0 + for(var/obj/item/bodypart/limb as anything in pawn.bodyparts) + var/damage = limb.brute_dam + limb.burn_dam + if(damage > worst_damage) + worst = limb + worst_damage = damage + return worst?.body_zone || BODY_ZONE_CHEST + +/** + * Applies a medical stack to itself the way a player does: takes it in hand (holstering its gun if both + * hands are full), aims at its worst limb and clicks itself with it. tg's own self-treatment do_after + * runs; the AI waits through it. + */ +/datum/ai_behavior/bounty_apply_medical + action_cooldown = 1 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/bounty_apply_medical/perform(seconds_per_tick, datum/ai_controller/controller, item_key) + var/mob/living/carbon/pawn = controller.pawn + var/obj/item/stack/medical/stack = controller.blackboard[item_key] + if(!istype(stack) || QDELETED(stack) || human_npc_holding(stack) != pawn || pawn.stat != CONSCIOUS) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(LAZYLEN(pawn.do_afters)) + return AI_BEHAVIOR_DELAY + if(pawn.health > pawn.maxHealth * BOUNTY_TREAT_SELF_BELOW && !pawn.is_holding(stack)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(!pawn.is_holding(stack)) + if(!length(pawn.get_empty_held_indexes())) + var/obj/item/gun/gun = human_npc_held_gun(pawn) + var/obj/item/stowed = gun || human_npc_held_melee_weapon(pawn) + if(isnull(stowed) || !stowed.equip_to_best_slot(pawn)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_take_in_hand(pawn, stack)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + // One application per go; it puts the rest away after. + if(controller.blackboard[BB_BOUNTY_TREAT_APPLIED]) + if(!stack.equip_to_best_slot(pawn)) + pawn.dropItemToGround(stack) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(!human_npc_ready_item(pawn, stack) || pawn.next_move > world.time) + return AI_BEHAVIOR_DELAY + pawn.zone_selected = bounty_ai_worst_limb(pawn) + controller.set_blackboard_key(BB_BOUNTY_TREAT_APPLIED, TRUE) + controller.ai_interact(target = pawn, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/bounty_apply_medical/finish_action(datum/ai_controller/controller, succeeded, item_key) + . = ..() + controller.clear_blackboard_key(item_key) + controller.clear_blackboard_key(BB_BOUNTY_TREAT_APPLIED) + controller.set_blackboard_key(BB_HUMAN_NPC_NEXT_TREAT, world.time + BOUNTY_TREAT_SELF_COOLDOWN) + +// ===== INJECTORS ===== + +/** + * Has `controller`'s pawn inject `pen` (a medipen or autoinjector it carries) into itself, with a + * visible emote first: a boss's combat stimulant, a meek criminal's stimpack as it bolts. A real pen and + * a real dose; what it does is the reagent's. Returns TRUE if the injection is queued. + */ +/proc/bounty_ai_use_injector(datum/ai_controller/controller, obj/item/reagent_containers/hypospray/medipen/pen) + var/mob/living/carbon/pawn = controller?.pawn + if(!iscarbon(pawn) || !istype(pen) || QDELETED(pen) || pen.used_up || !pen.reagents?.total_volume) + return FALSE + if(human_npc_holding(pen) != pawn || !(pawn.is_holding(pen) || human_npc_can_draw(pawn, pen))) + return FALSE + pawn.visible_message(span_warning("[pawn] pulls out [pen].")) + controller.set_blackboard_key(BB_HUMAN_NPC_TREAT_ITEM, pen) + controller.queue_behavior(/datum/ai_behavior/human_npc_treat_self, BB_HUMAN_NPC_TREAT_ITEM) + return TRUE + +/// The first unused pen of `pen_type` that `pawn` carries and can reach, or null +/proc/bounty_ai_find_injector(mob/living/carbon/pawn, pen_type = /obj/item/reagent_containers/hypospray/medipen) + for(var/obj/item/reagent_containers/hypospray/medipen/pen in pawn.get_all_contents_type(pen_type)) + if(pen.used_up || !pen.reagents?.total_volume) + continue + if(pawn.is_holding(pen) || human_npc_can_draw(pawn, pen)) + return pen + return null diff --git a/voidcrew/modules/bounties/bounty_ai_meek.dm b/voidcrew/modules/bounties/bounty_ai_meek.dm index b27ae351772..798ace46139 100644 --- a/voidcrew/modules/bounties/bounty_ai_meek.dm +++ b/voidcrew/modules/bounties/bounty_ai_meek.dm @@ -3,26 +3,32 @@ * * Owner: P3 AI. Do not edit this file outside P3. * - * What it owns: the meek archetype (spec section 3, C6): - * - sprinting faster than a runner, getting winded, and getting the sprint back by resting or - * hiding (TRAIT_BOUNTY_SPRINTING while running flat out, for the body's dodge); + * What it owns: the meek archetype (spec section 3, C6, defaults CR-14): + * - running at a person's run, nothing faster. When they bolt they jab the stimpack they carry (a real + * pen, ephedrine and coffee) if they still have it; worn out (tg's own stamina slowdown) they say so. + * Their dodge is a real Slipwire implant (npc_cyberware), the shared dodge arbiter's, which an EMP + * stops; it burns out when they die; * - noticing hunters, and choosing the best hiding place ONCE per flight within - * BOUNTY_MEEK_HIDE_RADIUS, on their site: a locker or crate (in, and shut), a potted plant (they - * take it and pass as it), a dark tile (they stand still, faded); - * - being found (the container opened; bumped, touched, pulled, hit or examined up close; a light - * coming near), which shoves the finder back a tile and sends them running again; + * BOUNTY_MEEK_HIDE_RADIUS, on their site: a locker or crate (in, and shut: a real move into it), a + * potted plant (picked up and held in both hands: tg's own disguise, the plant's tactical + * component, which makes whoever holds it look and read like the plant), a dark tile (they stand + * still, faded); + * - being found (the container opened; bumped, touched, pulled, hit or examined up close; the plant + * knocked out of their hands; a light coming near), which shoves the finder (a real shove) and sends + * them running again; * - a restless tell while hidden a long time, so a search can end; - * - cornered: a shout, a short aim with the holdout (the telegraph), a quick volley, then - * running again. A stamina hit during the aim makes them fumble it; each shot checks they can - * still shoot. - * It never loots and never attacks first. + * - cornered: a shout, the holdout out of their pocket and aimed (the telegraph), then a quick volley + * of real trigger pulls, then the gun goes back in the pocket and they run again. A hit during the + * aim makes them fumble it; each pull checks they can still shoot. + * It never loots and never attacks first. It wears plain clothes for its zone, its tier's armour, the + * holdout and its spare magazine, and the stimpack. * * The shared vars (`hidden` and the rest) are declared in bounty_types.dm and nowhere else; a var * only P3 uses has an `ai_` prefix. The hiding procs sit on the base type so the shared hooks * (attacked, touched, pulled, dead, deleted) can undo them for anyone. */ -/mob/living/basic/bounty_criminal +/mob/living/carbon/human/npc/bounty_criminal /// How they are hidden while `hidden`: BOUNTY_HIDE_CONTAINER, _PLANT or _DARK var/ai_hide_mode /// Weakref to what they hide in or as: the closet, the plant or the turf @@ -37,14 +43,12 @@ var/ai_next_restless = 0 /// world.time they next check whether it is quiet enough to come out var/ai_next_quiet_check = 0 - /// Their own look, kept while they pass as a plant or stand faded in the dark - var/list/ai_disguise_saved - /// Deciseconds of sprint left - var/ai_sprint_left = BOUNTY_MEEK_SPRINT_TIME - /// Out of breath: slow until the sprint is all back + /// Their alpha before they stood faded in the dark + var/ai_saved_alpha + /// Out of breath (tg's stamina slowdown), and said so var/ai_winded = FALSE - /// world.time the sprint was last worked out - var/ai_sprint_checked_at = 0 + /// A fresh flight: they jab their stimpack, if they still have one + var/ai_bolting = FALSE /// world.time this cornering began, or 0 var/ai_cornered_since = 0 /// world.time running away last failed, or 0 @@ -53,29 +57,33 @@ var/ai_holdout_ready_at = 0 /// Aiming the holdout: world.time the shots go off, or 0 var/ai_aim_until = 0 + /// world.time the aim began + var/ai_aim_from = 0 /// Their stamina loss when the aim began: any more at the shot and they fumble it var/ai_aim_stamina = 0 /// Weakref to who they aim at var/datum/weakref/ai_aim_ref - /// Shots of the volley still to fire, and when the next goes + /// Trigger pulls of the volley still to come, and when the next goes var/ai_volley_left = 0 var/ai_next_volley_shot = 0 /// Weakref to who the volley is at var/datum/weakref/ai_volley_ref - /// Set only while a volley shot is being fired, so no other shot gets out - var/ai_firing = FALSE #ifdef UNIT_TESTS /// Unit tests only: turfs that read as dark. The test room is fullbright, so nothing is dark there otherwise. GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) #endif -/mob/living/basic/bounty_criminal/meek +/mob/living/carbon/human/npc/bounty_criminal/meek ai_controller = /datum/ai_controller/basic_controller/bounty/criminal/meek + // OC-6 ruling: their dodge is real chrome, the shared arbiter's (voidcrew/modules/cyberware/ware_pro_combat.dm) + npc_cyberware = list(/obj/item/organ/cyberimp/cyberware/slipwire) /datum/ai_controller/basic_controller/bounty/criminal/meek - armed = TRUE planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, /datum/ai_planning_subtree/bounty_gate, /datum/ai_planning_subtree/bounty_meek_volley, /datum/ai_planning_subtree/bounty_notice, @@ -85,27 +93,31 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) /datum/ai_planning_subtree/bounty_activity, ) -/mob/living/basic/bounty_criminal/meek/ai_notice_range() +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_notice_range() return BOUNTY_MEEK_NOTICE_RANGE -/mob/living/basic/bounty_criminal/meek/ai_wanted_style() +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_wanted_style() return BOUNTY_STYLE_HOLDOUT /// The holdout is the same little gun whatever the tier -/mob/living/basic/bounty_criminal/meek/ai_heavy_weapon() +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_heavy_weapon() return FALSE -/mob/living/basic/bounty_criminal/meek/ai_sprints() +/// Plain clothes for their zone, their tier's armour, the holdout and its spare, and the stimpack they bolt on +/mob/living/carbon/human/npc/bounty_criminal/meek/bounty_build_outfit(zone) + return bounty_fighter_outfit(BOUNTY_ARCHETYPE_MEEK, zone, record?.tier, BOUNTY_STYLE_HOLDOUT, FALSE, /obj/item/reagent_containers/hypospray/medipen/stimpack) + +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_sprints() return TRUE -/mob/living/basic/bounty_criminal/meek/ai_notice(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_notice(mob/living/hunter) ai_flee_event(hunter, "noticed") -/mob/living/basic/bounty_criminal/meek/ai_react(mob/living/threat, bark) +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_react(mob/living/threat, bark) ai_flee_event(threat, bark) /// Hit while running: they run from whoever is closer -/mob/living/basic/bounty_criminal/meek/ai_attacked_again(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_attacked_again(mob/living/attacker) var/mob/living/threat = ai_threat() if(!threat || bounty_ai_dist(src, attacker) < bounty_ai_dist(src, threat)) ai_set_threat(attacker) @@ -113,16 +125,16 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) ai_bark("hurt") /// Anything that hurts a hider finds them -/mob/living/basic/bounty_criminal/meek/ai_took_damage(damage, damagetype) +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_took_damage(damage, damagetype) if(hidden && damage > 0) INVOKE_ASYNC(src, PROC_REF(ai_reveal), null, "hurt") /// Only the cornered volley gets a shot out: they never attack first -/mob/living/basic/bounty_criminal/meek/ai_may_shoot(atom/target) - return ai_firing +/mob/living/carbon/human/npc/bounty_criminal/meek/ai_may_shoot(atom/target) + return ai_aim_until || ai_volley_left -/// Passing as a plant, they look and read like the plant; up close, looking at them gives them away -/mob/living/basic/bounty_criminal/meek/examine(mob/user) +/// Behind the plant, they read as the plant; up close, looking at them gives them away +/mob/living/carbon/human/npc/bounty_criminal/meek/examine(mob/user) if(!hidden || ai_hide_mode == BOUNTY_HIDE_CONTAINER) return ..() if(user && get_dist(user, src) <= 1 && bounty_ai_is_hunter(user)) @@ -135,8 +147,8 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) // ===== RUNNING ===== -/// Whether they sprint and tire (only the meek) -/mob/living/basic/bounty_criminal/proc/ai_sprints() +/// Whether they run and hide (only the meek) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_sprints() return FALSE /** @@ -144,7 +156,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * the best hiding place in reach, once for this whole flight. `bark` is the line to shout, or * null for the usual one on a fresh flight (none if `quiet`). */ -/mob/living/basic/bounty_criminal/proc/ai_flee_event(mob/living/threat, bark, seek_hiding = TRUE, quiet = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_flee_event(mob/living/threat, bark, seek_hiding = TRUE, quiet = FALSE) if(!ai_can_act() || hidden) return FALSE var/fresh = ai_mode != BOUNTY_AI_FLEEING @@ -173,70 +185,43 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) else if(fresh && !quiet) ai_bark("flee") if(ai_sprints()) - ai_update_sprint() + ai_set_pace(BOUNTY_RUN_SPEED) + if(fresh && seek_hiding) + ai_bolting = TRUE return TRUE -/// Their sprint speed: a lizard's is a little quicker -/mob/living/basic/bounty_criminal/proc/ai_sprint_speed() - if(ispath(record?.species, /datum/species/lizard)) - return BOUNTY_MEEK_SPRINT_SPEED - BOUNTY_MEEK_LIZARD_SPRINT_BONUS - return BOUNTY_MEEK_SPRINT_SPEED - -/** - * Works out their sprint since the last time: running in sight of the hunter uses it up, and then - * they are winded (slow) until it is all back. It comes back fastest hidden, slowly while out of - * sight but still on the run, and at a rest in BOUNTY_MEEK_REST_TIME. Sets their pace and - * TRAIT_BOUNTY_SPRINTING while running flat out (never winded, cornered or shooting). - */ -/mob/living/basic/bounty_criminal/proc/ai_update_sprint() - var/now = world.time - var/elapsed = ai_sprint_checked_at ? min(now - ai_sprint_checked_at, 5 SECONDS) : 0 - ai_sprint_checked_at = now - var/running = ai_mode == BOUNTY_AI_FLEEING && !hidden && !ai_aim_until && !ai_volley_left && !ai_cornered_since && (now - ai_threat_seen_at) <= 1 SECONDS - if(running) - if(!ai_winded) - ai_sprint_left -= elapsed - else if(hidden) - ai_sprint_left += elapsed * BOUNTY_MEEK_RECOVER_HIDDEN - else if(ai_mode == BOUNTY_AI_FLEEING) - ai_sprint_left += elapsed * BOUNTY_MEEK_RECOVER_WALKING - else - ai_sprint_left += elapsed * (BOUNTY_MEEK_SPRINT_TIME / BOUNTY_MEEK_REST_TIME) - ai_sprint_left = clamp(ai_sprint_left, 0, BOUNTY_MEEK_SPRINT_TIME) - if(!ai_winded && ai_sprint_left <= 0) - ai_winded = TRUE +/// Out of breath: tg's own stamina slowdown has them. They say so once, and again only after getting their breath back. +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_check_winded() + var/winded = getStaminaLoss() >= BOUNTY_MEEK_WINDED_STAMINA + if(winded == ai_winded) + return + ai_winded = winded + if(winded && stat == CONSCIOUS) visible_message(span_notice("[src] is out of breath.")) ai_bark("winded") - else if(ai_winded && ai_sprint_left >= BOUNTY_MEEK_SPRINT_TIME) - ai_winded = FALSE - if(running && !ai_winded) - ADD_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) - else - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) - if(ai_mode == BOUNTY_AI_FLEEING) - ai_set_pace(ai_winded ? BOUNTY_MEEK_WINDED_SPEED : ai_sprint_speed()) /** - * The run: to the hiding place chosen for this flight if there is one and it is still good, else - * away from the hunter while they can see them, else still and catching their breath. Cornered, - * the holdout comes out. Out of sight long enough, they calm down. + * The run: their stimpack first on a fresh flight, then to the hiding place chosen for this flight if + * there is one and it is still good, else away from the hunter while they can see them, else still and + * catching their breath. Cornered, the holdout comes out. Out of sight long enough, they calm down. */ /datum/ai_planning_subtree/bounty_meek_flee /datum/ai_planning_subtree/bounty_meek_flee/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn - // A decoy given this tree (P6 gives decoys their fugitive's controller) never sprints, hides or - // draws: bounty_flee_and_calm, next in the tree, runs it (L6). + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn + // A decoy given this tree (P6 gives decoys their fugitive's controller) never hides or draws: + // bounty_flee_and_calm, next in the tree, runs it (L6). if(!criminal.ai_sprints()) return var/seen = criminal.ai_mode == BOUNTY_AI_FLEEING && criminal.ai_sees_threat(BOUNTY_MEEK_HIDE_RADIUS) - criminal.ai_update_sprint() + criminal.ai_check_winded() if(criminal.ai_mode != BOUNTY_AI_FLEEING) return var/mob/living/threat = criminal.ai_threat() if(!threat || world.time - criminal.ai_threat_seen_at > BOUNTY_MEEK_CALM_AFTER) criminal.ai_calm_down() return + criminal.ai_plan_stimpack(controller) if(seen && criminal.ai_check_cornered(threat)) controller.queue_behavior(/datum/ai_behavior/bounty_holdout_volley) return SUBTREE_RETURN_FINISH_PLANNING @@ -263,6 +248,21 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) controller.queue_behavior(/datum/ai_behavior/bounty_watch) return SUBTREE_RETURN_FINISH_PLANNING +/** + * Bolting: the stimpack they carry, jabbed in as they run (the core's bounty_ai_use_injector(), the human + * NPC base's "use in hand"), once per pen. Kept in the plan until the pen is spent. + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_plan_stimpack(datum/ai_controller/controller) + var/obj/item/reagent_containers/hypospray/medipen/pen = controller.blackboard[BB_HUMAN_NPC_TREAT_ITEM] + if(istype(pen, /obj/item/reagent_containers/hypospray/medipen/stimpack) && !pen.used_up) + controller.queue_behavior(/datum/ai_behavior/human_npc_treat_self, BB_HUMAN_NPC_TREAT_ITEM) + return TRUE + if(!ai_bolting) + return FALSE + ai_bolting = FALSE + pen = bounty_ai_find_injector(src, /obj/item/reagent_containers/hypospray/medipen/stimpack) + return pen && bounty_ai_use_injector(controller, pen) + /** * tg's run away, staying on the site: a leg never goes off the leash, into lava or into a chasm * (D-A9). When no leg works, the criminal is cornered (ai_run_failed_at). @@ -273,21 +273,21 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) /datum/ai_behavior/run_away_from_target/bounty/setup(datum/ai_controller/controller, target_key, hiding_location_key) . = ..() if(!.) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(istype(criminal)) criminal.ai_run_failed_at = world.time /datum/ai_behavior/run_away_from_target/bounty/perform(seconds_per_tick, datum/ai_controller/controller, target_key, hiding_location_key) . = ..() if(. & AI_BEHAVIOR_FAILED) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(istype(criminal)) criminal.ai_run_failed_at = world.time /datum/ai_behavior/run_away_from_target/bounty/get_furthest_turf(atom/source, angle, atom/target) var/turf/return_turf var/list/airlocks = SSmachines.get_machines_by_type_and_subtypes(/obj/machinery/door/airlock) - var/mob/living/basic/bounty_criminal/leash_holder = bounty_ai_side_of(source) + var/mob/living/carbon/human/npc/bounty_criminal/leash_holder = bounty_ai_side_of(source) for(var/i in 1 to run_distance) var/turf/test_destination = get_ranged_target_turf_direct(source, target, range = i, offset = angle) if(test_destination.is_blocked_turf(source_atom = source, ignore_atoms = airlocks)) @@ -308,7 +308,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) // ===== CORNERED ===== /// How many ways out lead away from `threat`: free, on the leash, and further from them -/mob/living/basic/bounty_criminal/proc/ai_ways_out(mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_ways_out(mob/living/threat) . = 0 var/turf/here = get_turf(src) if(!here || !threat) @@ -323,10 +323,9 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) /** * Whether they are cornered by `threat`, and have been for BOUNTY_MEEK_CORNERED_TIME: the hunter * close, with running away failing or fewer than two ways out. Once it has lasted, and the holdout - * is ready, the aim starts. Returns TRUE when it did. Being held no longer counts: a free criminal - * pulls away from any hand (P2), so a grab only happens while it is stunned and can't act (H2). + * is ready, the aim starts. Returns TRUE when it did. */ -/mob/living/basic/bounty_criminal/proc/ai_check_cornered(mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_check_cornered(mob/living/threat) if(!ai_sprints() || world.time < ai_holdout_ready_at || !threat || !ai_can_act()) ai_cornered_since = 0 return FALSE @@ -347,73 +346,100 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return FALSE return ai_start_aim(threat) -/// The aim: the holdout in hand, a mark where it points, a shout. The shots start BOUNTY_MEEK_PISTOL_WINDUP later. Never from the floor (H2). -/mob/living/basic/bounty_criminal/proc/ai_start_aim(mob/living/target) - if(!ai_sprints() || ai_aim_until || ai_volley_left || !ai_can_act() || !isturf(loc) || !isliving(target)) +/// The holdout they carry (in hand, a pocket or the fanny pack), or null +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_holdout() + var/datum/bounty_style/style = GLOB.bounty_styles[BOUNTY_STYLE_HOLDOUT] + for(var/obj/item/gun/ballistic/gun as anything in get_all_contents_type(style.weapon_type)) + if(is_holding(gun) || human_npc_can_draw(src, gun)) + return gun + return null + +/** + * The aim: the holdout out of their pocket and into their hand, a mark where it points, a shout. The + * shots start BOUNTY_MEEK_PISTOL_WINDUP later. Never from the floor (H2), never with an empty gun. + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_start_aim(mob/living/target) + if(!ai_sprints() || ai_aim_until || ai_volley_left || !ai_can_act() || !isturf(loc) || !isliving(target) || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) return FALSE ai_update_style() + var/obj/item/gun/ballistic/gun = ai_holdout() + if(!gun || !(gun.can_shoot() || human_npc_gun_needs_rack(gun))) + return FALSE + if(!is_holding(gun)) + if(!length(get_empty_held_indexes())) + bounty_ai_free_other_hand(src, null) + if(!human_npc_take_in_hand(src, gun)) + return FALSE + if(human_npc_gun_needs_rack(gun)) + human_npc_ready_item(src, gun) + gun.attack_self(src) ai_aim_ref = WEAKREF(target) ai_aim_until = world.time + BOUNTY_MEEK_PISTOL_WINDUP - ai_aim_stamina = staminaloss + ai_aim_from = world.time + ai_aim_stamina = getStaminaLoss() ai_cornered_since = 0 - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) face_atom(bounty_ai_target_for(target)) - ai_hand = bounty_ai_update_held(src, ai_hand, bounty_ai_held_look(src) || /obj/item/gun/ballistic/automatic/pistol) new /obj/effect/temp_visual/telegraphing/bounty_aim(get_turf(target)) - visible_message(span_danger("[src] pulls out a small pistol and aims it at [target]!")) + visible_message(span_danger("[src] pulls out [gun] and aims it at [target]!")) to_chat(target, span_userdanger("[src] is aiming a pistol at you!")) playsound(src, 'sound/items/weapons/gun/pistol/rack_small.ogg', 50, TRUE) - // Forced: the draw is a one-second warning, and a meek criminal has just said "cornered" or "hurt" + // Forced: the draw is a half-second warning, and a meek criminal has just said "cornered" or "hurt" ai_bark("draw_gun", TRUE) return TRUE /** - * The aim is up: the volley starts, BOUNTY_MEEK_PISTOL_SHOTS shots (never more than the holdout's - * magazine) BOUNTY_MEEK_PISTOL_GAP apart, the first one now. A stamina hit or a stun during the aim - * makes them fumble it instead. Returns TRUE if the first shot went. + * The aim is up: the volley starts, BOUNTY_MEEK_PISTOL_SHOTS trigger pulls (never more than the gun + * holds) BOUNTY_MEEK_PISTOL_GAP apart, the first one now. A hit, more stamina loss or a stun during the + * aim makes them fumble it instead. Returns TRUE if the first shot went. */ -/mob/living/basic/bounty_criminal/proc/ai_fire_holdout() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_fire_holdout() var/mob/living/target = ai_aim_ref?.resolve() - var/stunned = !ai_can_act() || staminaloss > ai_aim_stamina + 1 || HAS_TRAIT(src, TRAIT_INCAPACITATED) + var/hit_at = ai_controller?.blackboard[BB_BOUNTY_HIT_AT] + var/spoiled = !ai_can_act() || getStaminaLoss() > ai_aim_stamina + 1 || HAS_TRAIT(src, TRAIT_INCAPACITATED) || (hit_at && hit_at >= ai_aim_from) ai_aim_until = 0 ai_aim_ref = null ai_holdout_ready_at = world.time + BOUNTY_MEEK_PISTOL_COOLDOWN ai_run_failed_at = 0 - if(stunned) + if(spoiled) ai_fumble() return FALSE if(!ai_volley_target_ok(target)) ai_put_gun_away() return FALSE - var/datum/bounty_real_weapon/holdout = bounty_ai_real_weapon(src) - ai_volley_left = holdout?.magazine ? min(BOUNTY_MEEK_PISTOL_SHOTS, holdout.magazine) : BOUNTY_MEEK_PISTOL_SHOTS + var/obj/item/gun/ballistic/gun = ai_holdout() + var/rounds = istype(gun) ? gun.get_ammo(TRUE) : 0 + ai_volley_left = clamp(rounds, 0, BOUNTY_MEEK_PISTOL_SHOTS) ai_volley_ref = WEAKREF(target) return ai_volley_shot() /// Whether the volley may go on at `target`: awake (downed hunters are left alone, C4), close and in sight -/mob/living/basic/bounty_criminal/proc/ai_volley_target_ok(mob/living/target) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_volley_target_ok(mob/living/target) return !QDELETED(target) && target.stat == CONSCIOUS && bounty_ai_can_see(src, target, BOUNTY_MEEK_PISTOL_RANGE) /** - * One shot of the volley, if they can still shoot (on their feet, not hidden) and the target is - * still there. Returns TRUE if a shot left the gun; a shot the gun refused ends the volley. + * One trigger pull of the volley, the player's way (a click with the gun in hand), if they can still + * shoot (on their feet, not hidden, the gun in hand and loaded) and the target is still there and + * nobody they may not hit stands in the way. Returns TRUE if a round left the gun; a pull that didn't + * fire ends the volley. */ -/mob/living/basic/bounty_criminal/proc/ai_volley_shot() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_volley_shot() var/mob/living/target = ai_volley_ref?.resolve() - if(!ai_volley_left || !ai_can_act() || hidden || !isturf(loc) || !ai_volley_target_ok(target)) + if(!ai_volley_left || !ai_can_act() || hidden || !isturf(loc) || !ai_volley_target_ok(target) || QDELETED(ai_controller)) ai_end_volley() return FALSE - var/datum/component/ranged_attacks/gun = GetComponent(/datum/component/ranged_attacks) - if(!gun) + var/obj/item/gun/ballistic/gun = ai_holdout() + if(!istype(gun) || !is_holding(gun) || !gun.can_shoot() || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) ai_end_volley() return FALSE - face_atom(bounty_ai_target_for(target)) - var/cooldown_before = gun.fire_cooldown - ai_firing = TRUE - RangedAttack(bounty_ai_target_for(target)) - ai_firing = FALSE - // The gun starts its cooldown only when it fires: a refused shot leaves it as it was - if(gun.fire_cooldown == cooldown_before || gun.fire_cooldown <= world.time) + var/atom/aim_at = bounty_ai_target_for(target) + face_atom(aim_at) + if(bounty_ai_line_blocked(src, aim_at) || !human_npc_ready_item(src, gun)) + ai_end_volley() + return FALSE + var/rounds_before = gun.get_ammo(TRUE) + zone_selected = human_npc_pick_aim_zone() + ai_controller.ai_interact(target = aim_at, combat_mode = TRUE) + if(QDELETED(gun) || gun.get_ammo(TRUE) >= rounds_before) ai_end_volley() return FALSE ai_volley_left-- @@ -422,38 +448,41 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) ai_end_volley() return TRUE -/// The volley is over (or cut short): the gun goes away a moment later -/mob/living/basic/bounty_criminal/proc/ai_end_volley() +/// The volley is over (or cut short): the gun goes back in the pocket a moment later +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_end_volley() ai_volley_left = 0 ai_volley_ref = null addtimer(CALLBACK(src, PROC_REF(ai_put_gun_away)), 0.5 SECONDS, TIMER_DELETE_ME) -/// A stamina hit or a stun during the aim: the volley never happens and they run -/mob/living/basic/bounty_criminal/proc/ai_fumble() +/// A hit, more stamina loss or a stun during the aim: the volley never happens and they run +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_fumble() ai_aim_until = 0 ai_aim_ref = null ai_volley_left = 0 ai_volley_ref = null ai_holdout_ready_at = max(ai_holdout_ready_at, world.time + BOUNTY_MEEK_PISTOL_COOLDOWN) - ai_hand = bounty_ai_update_held(src, ai_hand, null) - if(stat != DEAD) - visible_message(span_notice("[src] fumbles the pistol!")) + if(stat == DEAD) + return + visible_message(span_notice("[src] fumbles the pistol!")) + ai_put_gun_away() -/// Puts the holdout away after the volley -/mob/living/basic/bounty_criminal/proc/ai_put_gun_away() - if(ai_aim_until || ai_volley_left || ai_mode == BOUNTY_AI_FIGHTING) +/// Puts the holdout back in a pocket after the volley, when they can +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_put_gun_away() + if(ai_aim_until || ai_volley_left || ai_mode == BOUNTY_AI_FIGHTING || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) return - ai_hand = bounty_ai_update_held(src, ai_hand, null) + var/obj/item/gun/gun = ai_holdout() + if(gun && is_holding(gun)) + gun.equip_to_best_slot(src) /// Drops the aim and the volley without a word: they calmed down, hid, were cuffed, died -/mob/living/basic/bounty_criminal/proc/ai_stop_aiming() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_stop_aiming() if(!ai_aim_until && !ai_volley_left) return ai_aim_until = 0 ai_aim_ref = null ai_volley_left = 0 ai_volley_ref = null - ai_hand = bounty_ai_update_held(src, ai_hand, null) + ai_put_gun_away() /// The mark where a cornered criminal aims, for the length of the aim /obj/effect/temp_visual/telegraphing/bounty_aim @@ -461,25 +490,24 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) /// The volley: first in a meek criminal's tree while an aim or a volley is on /datum/ai_planning_subtree/bounty_meek_volley - operational_datums = list(/datum/component/ranged_attacks) /datum/ai_planning_subtree/bounty_meek_volley/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!criminal.ai_aim_until && !criminal.ai_volley_left) return controller.queue_behavior(/datum/ai_behavior/bounty_holdout_volley) return SUBTREE_RETURN_FINISH_PLANNING -/// Holds the aim, then fires the volley shot by shot. Cancelled (a stun stops the AI), the aim is fumbled or the volley cut short. +/// Holds the aim, then fires the volley pull by pull. Cancelled (a stun stops the AI), the aim is fumbled or the volley cut short. /datum/ai_behavior/bounty_holdout_volley - action_cooldown = 0.2 SECONDS + action_cooldown = 0.1 SECONDS /datum/ai_behavior/bounty_holdout_volley/setup(datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn return istype(criminal) && (criminal.ai_aim_until || criminal.ai_volley_left) /datum/ai_behavior/bounty_holdout_volley/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(criminal.ai_aim_until) if(world.time < criminal.ai_aim_until) var/mob/living/target = criminal.ai_aim_ref?.resolve() @@ -499,7 +527,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) . = ..() if(succeeded) return - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(!istype(criminal)) return if(criminal.ai_aim_until) @@ -517,7 +545,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * them and out of the hunter's sight is better. Never where they were just found, never off their * site. Null if nothing will do. */ -/mob/living/basic/bounty_criminal/proc/ai_pick_hide_spot(mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_pick_hide_spot(mob/living/threat) var/turf/center = get_turf(src) if(!center) return null @@ -553,7 +581,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) best = thing best_score = score best_stand = stand - var/dark_bonus = BOUNTY_HIDE_SCORE_DARK + (ispath(record?.species, /datum/species/moth) ? BOUNTY_HIDE_SCORE_MOTH_DARK : 0) + var/dark_bonus = BOUNTY_HIDE_SCORE_DARK + (ismoth(src) ? BOUNTY_HIDE_SCORE_MOTH_DARK : 0) for(var/turf/tile as anything in ai_dark_candidates(reachable)) if(tile == last) continue @@ -569,7 +597,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return best /// How good `spot` is to hide from `threat`, or null if it is no good at all -/mob/living/basic/bounty_criminal/proc/ai_score_hide_spot(atom/spot, mob/living/threat) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_score_hide_spot(atom/spot, mob/living/threat) var/turf/spot_turf = get_turf(spot) if(!spot_turf || spot_turf.z != z) return null @@ -587,7 +615,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) . += BOUNTY_HIDE_SCORE_UNSEEN /// Up to `samples` dark tiles they could stand in, sampled once from `reachable` (turf = TRUE, the floor they can walk to) -/mob/living/basic/bounty_criminal/proc/ai_dark_candidates(list/reachable, samples = BOUNTY_MEEK_DARK_SAMPLES) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_dark_candidates(list/reachable, samples = BOUNTY_MEEK_DARK_SAMPLES) . = list() var/list/pool = list() for(var/turf/tile as anything in reachable) @@ -602,7 +630,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * Whether `tile` is dark enough to hide in. A turf with no lighting object (planet ground, lit by * its area's ambient light) reads its area's base light instead, which tg's lumcount skips (L2). */ -/mob/living/basic/bounty_criminal/proc/ai_tile_is_dark(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_tile_is_dark(turf/tile) if(!isturf(tile)) return FALSE #ifdef UNIT_TESTS @@ -614,7 +642,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return (tile_area.base_lighting_alpha / 255) + tile.dynamic_lumcount < BOUNTY_MEEK_DARK_LUMCOUNT return tile.get_lumcount() < BOUNTY_MEEK_DARK_LUMCOUNT -/mob/living/basic/bounty_criminal/proc/ai_hide_spot_usable(atom/spot) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_spot_usable(atom/spot) if(QDELETED(spot)) return FALSE if(istype(spot, /obj/structure/closet)) @@ -629,7 +657,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * A locker or crate they fit in: on the floor, unlocked, not welded, not full, not outpost property, * not a body bag, and nobody standing in it (shutting it would take them along). */ -/mob/living/basic/bounty_criminal/proc/ai_container_usable(obj/structure/closet/container) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_container_usable(obj/structure/closet/container) if(QDELETED(container) || !isturf(container.loc) || container.locked || container.welded) return FALSE if(istype(container, /obj/structure/closet/body_bag) || container.wall_mounted || (container.resistance_flags & INDESTRUCTIBLE)) @@ -647,7 +675,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return leash_ok(get_turf(container)) /// A potted plant on the floor, loose, with nobody standing on it -/mob/living/basic/bounty_criminal/proc/ai_plant_usable(obj/item/kirbyplants/plant) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_plant_usable(obj/item/kirbyplants/plant) if(QDELETED(plant) || !isturf(plant.loc) || plant.anchored) return FALSE var/turf/plant_turf = plant.loc @@ -659,15 +687,15 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return leash_ok(plant_turf) /// A dark floor tile they can stand on: not space, lava or a chasm, free, on their site -/mob/living/basic/bounty_criminal/proc/ai_dark_usable(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_dark_usable(turf/tile) if(!isopenturf(tile) || isspaceturf(tile) || isgroundlessturf(tile) || islava(tile) || ischasm(tile)) return FALSE if(tile != loc && tile.is_blocked_turf(source_atom = src)) return FALSE return leash_ok(tile) && ai_tile_is_dark(tile) -/// Whether they can hide in or as `spot` from where they stand: on the dark tile, or right beside the closet or plant with nothing between (H1) -/mob/living/basic/bounty_criminal/proc/ai_at_hide_spot(atom/spot) +/// Whether they can hide in or as `spot` from where they stand: on the dark tile or the plant's, or right beside the closet or plant with nothing between (H1) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_at_hide_spot(atom/spot) if(!isturf(loc)) return FALSE if(isturf(spot)) @@ -677,7 +705,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return Adjacent(spot) /// Where they walk to to hide at `spot`: beside a closet on their side of it (the tile picked with it), onto a plant or a dark tile. Null if there is nowhere. -/mob/living/basic/bounty_criminal/proc/ai_hide_approach(atom/spot) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_approach(atom/spot) if(istype(spot, /obj/structure/closet)) if(ai_hide_stand && (ai_hide_stand == loc || ai_standable(ai_hide_stand)) && ai_hide_stand.Adjacent(spot)) return ai_hide_stand @@ -688,7 +716,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * The tile to stand on to climb into `container`: beside it, free, with no wall, window or rail * between (so Adjacent() holds from it), and in `reachable` when that is given. Nearest to them. */ -/mob/living/basic/bounty_criminal/proc/ai_container_approach(obj/structure/closet/container, list/reachable) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_container_approach(obj/structure/closet/container, list/reachable) var/turf/container_turf = get_turf(container) if(!container_turf) return null @@ -710,7 +738,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) action_cooldown = 0.5 SECONDS /datum/ai_behavior/bounty_hide/perform(seconds_per_tick, datum/ai_controller/controller, spot_key) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn var/atom/spot = controller.blackboard[spot_key] if(QDELETED(spot) || !criminal.ai_hide_in(spot)) controller.clear_blackboard_key(spot_key) @@ -718,17 +746,15 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED /** - * Hides in or as `spot`, from beside it: a closet (in, and shut), a potted plant (theirs now, and - * so is its look) or a dark turf (still and faded). Returns TRUE if they are hidden. + * Hides in or as `spot`, from beside it: a closet (in, and shut), a potted plant (picked up and held + * up in front of them) or a dark turf (still and faded). Returns TRUE if they are hidden. */ -/mob/living/basic/bounty_criminal/proc/ai_hide_in(atom/spot) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_in(atom/spot) // Never from the floor: a knockdown is a cuff window (H2) if(hidden || !ai_can_act() || QDELETED(spot) || !isturf(loc)) return FALSE ai_suspend_activity() ai_stop_aiming() - ai_hand = bounty_ai_update_held(src, ai_hand, null) - ai_over_head = bounty_ai_update_held(src, ai_over_head, null) var/done = FALSE if(istype(spot, /obj/structure/closet)) done = ai_hide_in_container(spot) @@ -743,7 +769,6 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) ai_next_restless = world.time + BOUNTY_MEEK_RESTLESS_AFTER ai_next_quiet_check = world.time + BOUNTY_MEEK_COME_OUT_AFTER ai_cornered_since = 0 - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) if(!QDELETED(ai_controller)) ai_controller.clear_blackboard_key(BB_BOUNTY_HIDE_SPOT) ai_controller.clear_blackboard_key(BB_BOUNTY_DESTINATION) @@ -752,11 +777,12 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return TRUE /** - * In a locker or a crate: opens it if shut, gets in, and shuts it from inside. A crate won't take - * a standing person the ordinary way, so it is straight insertion for both, then close(). Only - * from right beside it, with no wall or window between (H1). + * In a locker or a crate: opens it if shut, gets in (a real move into it), and pulls it shut from + * inside. A crate won't take a standing person the ordinary way, so it is straight in for both, then + * close(). Only from right beside it, with no wall or window between (H1). Getting out is the + * container opening, as anyone gets out. */ -/mob/living/basic/bounty_criminal/proc/ai_hide_in_container(obj/structure/closet/container) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_in_container(obj/structure/closet/container) if(!ai_container_usable(container) || !Adjacent(container)) return FALSE ai_stand_up() @@ -777,10 +803,12 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return TRUE /** - * As a potted plant: they step onto its tile (a real step, from right beside it: H1), take it, keep - * their own look aside, and wear the plant's: name, description, icon, overlays and layer. + * Behind a potted plant: they step onto its tile (a real step, from right beside it: H1), put away what + * is in their hands, and pick it up in both hands. The plant's own tactical component (tg's disguise: + * whoever holds it up looks like the plant to everyone and goes by its name) does the rest; nothing of + * their own look is touched. The plant knocked out of their hands, or put down, gives them away. */ -/mob/living/basic/bounty_criminal/proc/ai_hide_as_plant(obj/item/kirbyplants/plant) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_as_plant(obj/item/kirbyplants/plant) if(!ai_plant_usable(plant)) return FALSE var/turf/plant_turf = plant.loc @@ -788,42 +816,28 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) if(!Adjacent(plant)) return FALSE ai_stand_up() - Move(plant_turf, get_dir(src, plant_turf)) - if(loc != plant_turf) + if(!bounty_ai_step(src, plant_turf) || loc != plant_turf) return FALSE ai_stand_up() - ai_disguise_saved = list( - "plant_turf" = plant_turf, - "name" = name, - "desc" = desc, - "icon" = icon, - "icon_state" = icon_state, - "overlays" = overlays.Copy(), - "underlays" = underlays.Copy(), - "layer" = layer, - "alpha" = alpha, - ) - plant.forceMove(src) - if(plant.loc != src) - ai_disguise_saved = null - return FALSE - name = plant.name - desc = plant.desc - icon = plant.icon - icon_state = plant.icon_state - overlays = plant.overlays.Copy() - underlays = null - layer = plant.layer + bounty_ai_holster(src) + if(length(get_empty_held_indexes()) < 2) + bounty_ai_free_other_hand(src, null) + if(length(get_empty_held_indexes()) < 2 || !put_in_hands(plant)) + return FALSE + if(!is_holding(plant)) + return FALSE + RegisterSignal(plant, COMSIG_ITEM_DROPPED, PROC_REF(ai_on_plant_dropped)) + RegisterSignal(plant, COMSIG_QDELETING, PROC_REF(ai_on_plant_dropped)) ai_hide_mode = BOUNTY_HIDE_PLANT ai_hide_ref = WEAKREF(plant) return TRUE /// In the dark: on the tile, still, faded -/mob/living/basic/bounty_criminal/proc/ai_hide_in_dark(turf/tile) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hide_in_dark(turf/tile) if(loc != tile || !ai_dark_usable(tile)) return FALSE ai_stand_up() - ai_disguise_saved = list("alpha" = alpha) + ai_saved_alpha = alpha alpha = BOUNTY_MEEK_DARK_ALPHA setDir(pick(GLOB.cardinals)) ai_hide_mode = BOUNTY_HIDE_DARK @@ -831,11 +845,10 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) return TRUE /** - * Stops hiding: out of the closet if it opens, the plant put back where it stood and their own look - * back, the fade gone. Welded or locked in, they stay in (and hidden, so opening it later finds - * them) and it returns FALSE. + * Stops hiding: out of the closet if it opens, the plant put down at their feet, the fade gone. Welded + * or locked in, they stay in (and hidden, so opening it later finds them) and it returns FALSE. */ -/mob/living/basic/bounty_criminal/proc/ai_unhide() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_unhide() var/atom/spot = ai_hide_ref?.resolve() switch(ai_hide_mode) if(BOUNTY_HIDE_CONTAINER) @@ -850,54 +863,40 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) RegisterSignal(container, COMSIG_QDELETING, PROC_REF(ai_on_container_gone)) return FALSE if(BOUNTY_HIDE_PLANT) - // Back where it stood (L8): the outpost's decor stays where the outpost put it - var/turf/plant_turf = ai_disguise_saved?["plant_turf"] - ai_restore_look() var/obj/item/kirbyplants/plant = spot - if(istype(plant) && plant.loc == src) - var/atom/put_down = isturf(plant_turf) ? plant_turf : drop_location() - if(put_down) - plant.forceMove(put_down) - else - qdel(plant) + if(istype(plant)) + UnregisterSignal(plant, list(COMSIG_ITEM_DROPPED, COMSIG_QDELETING)) + if(is_holding(plant)) + dropItemToGround(plant) if(BOUNTY_HIDE_DARK) - ai_restore_look() + if(!isnull(ai_saved_alpha)) + alpha = ai_saved_alpha + ai_saved_alpha = null hidden = FALSE ai_hide_mode = null ai_hide_ref = null - ai_sprint_checked_at = world.time return TRUE -/// Their own look back after passing as a plant or standing faded -/mob/living/basic/bounty_criminal/proc/ai_restore_look() - var/list/saved = ai_disguise_saved - ai_disguise_saved = null - if(!saved) - return - if("icon" in saved) - name = saved["name"] - desc = saved["desc"] - icon = saved["icon"] - icon_state = saved["icon_state"] - overlays = saved["overlays"] - underlays = saved["underlays"] - layer = saved["layer"] - alpha = saved["alpha"] - -/mob/living/basic/bounty_criminal/proc/ai_on_container_opened(obj/structure/closet/source, mob/living/user, force) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_container_opened(obj/structure/closet/source, mob/living/user, force) SIGNAL_HANDLER INVOKE_ASYNC(src, PROC_REF(ai_reveal), isliving(user) ? user : null, "opened") -/mob/living/basic/bounty_criminal/proc/ai_on_container_gone(obj/structure/closet/source) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_container_gone(obj/structure/closet/source) SIGNAL_HANDLER INVOKE_ASYNC(src, PROC_REF(ai_reveal), null, "opened") +/// The plant left their hands (knocked away, shoved out of them, broken): they are found +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_on_plant_dropped(obj/item/kirbyplants/source) + SIGNAL_HANDLER + UnregisterSignal(source, list(COMSIG_ITEM_DROPPED, COMSIG_QDELETING)) + INVOKE_ASYNC(src, PROC_REF(ai_reveal), null, "dropped") + /** - * Found: out of hiding, a word, the finder shoved back a tile if they are right there (M4), and - * off again, to a new hiding place. `finder` may be null (a light came, the closet broke). Welded - * or locked in, they can't get out and stay hidden. Returns TRUE if they came out. + * Found: out of hiding, a word, the finder shoved if they are right there (M4, a real shove), and off + * again, to a new hiding place. `finder` may be null (a light came, the closet broke). Welded or locked + * in, they can't get out and stay hidden. Returns TRUE if they came out. */ -/mob/living/basic/bounty_criminal/proc/ai_reveal(mob/living/finder, reason) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_reveal(mob/living/finder, reason) if(!hidden) return FALSE var/mode = ai_hide_mode @@ -925,30 +924,22 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) ai_flee_event(isliving(finder) ? finder : ai_threat(), "found") return TRUE -/// Shoves `finder` back a tile, without a stun, to get a head start; never onto lava, into a chasm or out into space (M5) -/mob/living/basic/bounty_criminal/proc/ai_shove_away(mob/living/finder) - bounty_ai_shove_back(src, finder) - visible_message(span_warning("[src] shoves [finder] back and bolts!")) - playsound(finder, 'sound/items/weapons/shove.ogg', 50, TRUE) - -/// Pushes `victim` a tile away from `shover`, if the tile behind them is safe to stand on (M5). Returns TRUE if they moved. -/proc/bounty_ai_shove_back(atom/shover, mob/living/victim) - var/shove_dir = get_dir(shover, victim) - var/turf/behind = shove_dir && get_step(victim, shove_dir) - if(!behind || behind.is_blocked_turf(source_atom = victim)) +/// Shoves `finder`, a real shove with an empty hand (tg's own, which may knock them into a wall), to get a head start +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_shove_away(mob/living/finder) + if(QDELETED(ai_controller) || !ai_can_act() || !human_npc_ready_empty_hand(src)) return FALSE - if(isspaceturf(behind) || isopenspaceturf(behind) || isgroundlessturf(behind) || islava(behind) || ischasm(behind) || !behind.can_cross_safely(victim)) - return FALSE - return victim.Move(behind, shove_dir) + face_atom(finder) + return ai_controller.ai_interact(target = finder, combat_mode = TRUE, modifiers = list(RIGHT_CLICK = TRUE)) /** * About once a second while hidden: whether they have been found out (let out of the closet, a - * light on them), the restless tell, and coming out when it has been quiet for long enough. + * light on them, the plant gone from their hands), the restless tell, and coming out when it has been + * quiet for long enough. */ -/mob/living/basic/bounty_criminal/proc/ai_hidden_tick() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_hidden_tick() if(!hidden) return - ai_update_sprint() + ai_check_winded() var/atom/spot = ai_hide_ref?.resolve() switch(ai_hide_mode) if(BOUNTY_HIDE_CONTAINER) @@ -956,6 +947,10 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) if(!istype(container) || loc != container || container.opened) ai_reveal(null, "opened") return + if(BOUNTY_HIDE_PLANT) + if(!spot || !is_holding(spot)) + ai_reveal(null, "dropped") + return if(BOUNTY_HIDE_DARK) if(!ai_tile_is_dark(get_turf(src))) ai_reveal(null, "light") @@ -972,7 +967,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) * The restless tell (M2): after a while hidden, a noise and a shake of the hiding place every * BOUNTY_MEEK_RESTLESS_GAP, heard within BOUNTY_MEEK_RESTLESS_RANGE, and now and then a mutter. */ -/mob/living/basic/bounty_criminal/proc/ai_restless_tell() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_restless_tell() var/atom/shaken = src var/message switch(ai_hide_mode) @@ -990,7 +985,7 @@ GLOBAL_LIST_EMPTY(bounty_ai_test_dark_turfs) ai_bark("hide") /// Quiet for long enough: they come out and carry on with what they were doing -/mob/living/basic/bounty_criminal/proc/ai_come_out() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_come_out() var/mode = ai_hide_mode var/atom/spot = ai_hide_ref?.resolve() if(!ai_unhide() || !isturf(loc)) diff --git a/voidcrew/modules/bounties/bounty_ai_normal.dm b/voidcrew/modules/bounties/bounty_ai_normal.dm index 70aa025207e..6630df3e905 100644 --- a/voidcrew/modules/bounties/bounty_ai_normal.dm +++ b/voidcrew/modules/bounties/bounty_ai_normal.dm @@ -7,49 +7,55 @@ * - noticing someone armed (aware: up from what they were doing, watching them), and fighting when * attacked, confronted, or when someone with a weapon drawn comes within * BOUNTY_NORMAL_THREAT_RANGE while they are aware; - * - fighting by style, with its companions called in; - * - surrendering (TRAIT_BOUNTY_SURRENDERED, source BOUNTY_TRAIT, which P2's capture_state() reads): - * rolled on the hit that crosses BOUNTY_SURRENDER_BELOW and on later hits, with two hunters near; - * - breaking off and retreating when badly hurt with nobody near. They never regenerate. - * It also owns the fighting styles every bounty fighter uses (criminals, companions, the meek's - * holdout): /datum/bounty_style and their projectiles. Every blow and shot is the real weapon's - * (bounty_weapons.dm). + * - fighting by style with the real weapon they carry (bounty_styles.dm), their companions called in; + * - giving up (CR-10): an AI choice followed by tg's *surrender, through the human NPC base + * (/datum/human_npc_surrender/bounty below: hurt, with hunters around them, less likely for each + * companion still standing). tg's 20 second paralysis is the cuff window; nobody cuffs them in it and + * they decide again: give up again, run, or fight on; + * - breaking off and retreating when badly hurt with nobody near: they sit down there and patch + * themselves up with what they carry (N6), and nothing else heals them; + * - what they wear: plain clothes for their zone, their tier's armour, their style's weapon and spares, + * and a bruise pack. * * The shared vars are declared in bounty_types.dm and nowhere else; a var only P3 uses has an * `ai_` prefix. */ -/mob/living/basic/bounty_criminal - /// Whether the roll on the hit that crosses the surrender line has been made - var/ai_surrender_rolled = FALSE - /// world.time the next surrender roll on a later hit may be made - var/ai_next_surrender_roll = 0 - -/mob/living/basic/bounty_criminal/normal +/mob/living/carbon/human/npc/bounty_criminal/normal ai_controller = /datum/ai_controller/basic_controller/bounty/criminal/normal + npc_surrender = /datum/human_npc_surrender/bounty /datum/ai_controller/basic_controller/bounty/criminal/normal - armed = TRUE planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, + /datum/ai_planning_subtree/human_npc_surrender, /datum/ai_planning_subtree/bounty_gate, /datum/ai_planning_subtree/bounty_notice, /datum/ai_planning_subtree/bounty_fight_target, - /datum/ai_planning_subtree/basic_ranged_attack_subtree/bounty, - /datum/ai_planning_subtree/basic_melee_attack_subtree/bounty, + /datum/ai_planning_subtree/bounty_arm, + /datum/ai_planning_subtree/bounty_ranged, + /datum/ai_planning_subtree/bounty_melee, /datum/ai_planning_subtree/bounty_retreat, + /datum/ai_planning_subtree/bounty_treat_self, /datum/ai_planning_subtree/bounty_aware, // For a decoy on this tree (P6 gives decoys their fugitive's controller): run, then calm down (M3) /datum/ai_planning_subtree/bounty_flee_and_calm, /datum/ai_planning_subtree/bounty_activity, ) -/mob/living/basic/bounty_criminal/normal/ai_wanted_style() +/mob/living/carbon/human/npc/bounty_criminal/normal/ai_wanted_style() var/key = record?.style if(key && key != BOUNTY_STYLE_HOLDOUT && GLOB.bounty_styles[key]) return key return BOUNTY_STYLE_BRAWLER -/mob/living/basic/bounty_criminal/normal/ai_notice(mob/living/hunter) +/// Plain clothes for their zone, their tier's armour, their style's weapon and spares, and a bruise pack +/mob/living/carbon/human/npc/bounty_criminal/normal/bounty_build_outfit(zone) + return bounty_fighter_outfit(BOUNTY_ARCHETYPE_NORMAL, zone, record?.tier, ai_wanted_style(), ai_heavy_weapon(), /obj/item/stack/medical/bruise_pack) + +/mob/living/carbon/human/npc/bounty_criminal/normal/ai_notice(mob/living/hunter) var/mob/living/watched = ai_threat() if(ai_mode == BOUNTY_AI_CALM || ai_mode == BOUNTY_AI_RETREATING) ai_become_aware(hunter) @@ -58,7 +64,7 @@ if(ai_mode == BOUNTY_AI_AWARE && bounty_ai_dist(src, hunter) <= BOUNTY_NORMAL_THREAT_RANGE && bounty_ai_is_armed(hunter, weapons_only = TRUE)) ai_start_fight(hunter) -/mob/living/basic/bounty_criminal/normal/ai_react(mob/living/threat, bark) +/mob/living/carbon/human/npc/bounty_criminal/normal/ai_react(mob/living/threat, bark) if(!ai_can_act()) return threat = threat || ai_nearest_grudge() @@ -69,7 +75,7 @@ ai_start_fight(threat, bark) /// Hit in a fight by someone new: they turn on whoever is closer (a pilot through their mech, H4) -/mob/living/basic/bounty_criminal/normal/ai_attacked_again(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/normal/ai_attacked_again(mob/living/attacker) if(ai_mode == BOUNTY_AI_FIGHTING) ai_rally_companions(attacker) var/mob/living/threat = ai_threat() @@ -81,13 +87,10 @@ return ai_start_fight(attacker) -/mob/living/basic/bounty_criminal/normal/ai_took_damage(damage, damagetype) - ai_check_surrender(damage, damagetype) - // ===== AWARE ===== /// Someone armed caught their eye: they stop what they are doing and keep an eye on them -/mob/living/basic/bounty_criminal/proc/ai_become_aware(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_become_aware(mob/living/hunter) if(!ai_can_act()) return ai_suspend_activity() @@ -101,7 +104,7 @@ /datum/ai_planning_subtree/bounty_aware /datum/ai_planning_subtree/bounty_aware/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(criminal.ai_mode != BOUNTY_AI_AWARE) return criminal.ai_sees_threat(BOUNTY_FIGHT_VISION) @@ -113,8 +116,12 @@ // ===== FIGHTING ===== -/// Starts (or keeps) fighting `target`: up, weapon out, companions called in, a line for it. A pilot is fought through their mech (H4). -/mob/living/basic/bounty_criminal/proc/ai_start_fight(mob/living/target, bark) +/** + * Starts (or keeps) fighting `target`: up, at a run, companions called in, a line for it. The weapon + * comes out of its holster, pocket or bag through the arm subtree (bounty_styles.dm). A pilot is fought + * through their mech (H4). + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_start_fight(mob/living/target, bark) if(!ai_can_act() || !isliving(target) || QDELETED(ai_controller)) return FALSE ai_update_style() @@ -124,9 +131,7 @@ ai_set_mode(BOUNTY_AI_FIGHTING) ai_set_threat(target) ai_add_grudge(target) - var/datum/bounty_style/style = bounty_ai_style(src) - ai_set_pace(style?.speed || BOUNTY_NORMAL_SPEED_MELEE) - ai_hand = bounty_ai_update_held(src, ai_hand, bounty_ai_held_look(src)) + ai_set_pace(BOUNTY_RUN_SPEED) if(fresh) ai_bark(bark || "fight_start") ai_rally_companions(target) @@ -137,7 +142,7 @@ * The nearest valid target on their grudge list, keeping the current one while it is still good. * Someone in a mech is fought through the mech (H4): tg's melee can't reach a pilot inside. */ -/mob/living/basic/bounty_criminal/proc/ai_pick_fight_target() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_pick_fight_target() if(QDELETED(ai_controller)) return null var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(ai_controller.blackboard[BB_TARGETING_STRATEGY]) @@ -160,7 +165,7 @@ return best /// The fight is over: nobody on their grudge list seen for a while -/mob/living/basic/bounty_criminal/proc/ai_fight_over() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_fight_over() if(ai_should_retreat()) ai_start_retreat(ai_threat()) return @@ -173,7 +178,7 @@ /datum/ai_planning_subtree/bounty_fight_target /datum/ai_planning_subtree/bounty_fight_target/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(criminal.ai_mode != BOUNTY_AI_FIGHTING) return var/atom/target = criminal.ai_pick_fight_target() @@ -190,201 +195,78 @@ controller.queue_behavior(/datum/ai_behavior/bounty_watch) return SUBTREE_RETURN_FINISH_PLANNING -/// Whether a pawn is in a fight it can take part in: a criminal fighting, or a companion that has not broken -/proc/bounty_ai_fighting(mob/living/basic/fighter) - if(istype(fighter, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = fighter - return criminal.ai_mode == BOUNTY_AI_FIGHTING && criminal.ai_can_act() - if(istype(fighter, /mob/living/basic/bounty_companion)) - var/mob/living/basic/bounty_companion/companion = fighter - return companion.ai_can_fight() - return FALSE - -/// tg's melee subtree, for melee styles only, and only in a fight -/datum/ai_planning_subtree/basic_melee_attack_subtree/bounty - -/datum/ai_planning_subtree/basic_melee_attack_subtree/bounty/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/datum/bounty_style/style = GLOB.bounty_styles[controller.blackboard[BB_BOUNTY_STYLE]] - if(!style || style.ranged || !bounty_ai_fighting(controller.pawn)) - return - return ..() - -/// tg's ranged subtree, for ranged styles only, and only in a fight: each style keeps its own distance -/datum/ai_planning_subtree/basic_ranged_attack_subtree/bounty - ranged_attack_behavior = /datum/ai_behavior/basic_ranged_attack/bounty - -/datum/ai_planning_subtree/basic_ranged_attack_subtree/bounty/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/datum/bounty_style/style = GLOB.bounty_styles[controller.blackboard[BB_BOUNTY_STYLE]] - if(!style?.ranged || !bounty_ai_fighting(controller.pawn)) - return - if(!controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) - return - controller.queue_behavior(style.ranged_behavior, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) - return SUBTREE_RETURN_FINISH_PLANNING +// ===== SURRENDER (CR-10) ===== /** - * tg's ranged attack, with the style doing the shooting: its wind-up telegraph, its magazine and - * reload, through the mob's ranged_attacks component. The pistol keeps BOUNTY_PISTOL_RANGE off and - * backs off a step when someone gets inside its style's min_range. Planning carries on meanwhile, - * so a new target, a retreat or a call for help is picked up mid-fight. + * When a normal criminal or a companion gives up (defaults CR-10, 1.3): at or below 40% of its health, + * with two hunters within seven tiles that it can see; 60% on its first roll of a fight, less 20 for + * each companion still standing with it, then 25% every 5 seconds. Through the human NPC base's + * human_npc_surrender subtree, which stows the weapon, says the line and runs tg's *surrender. */ -/datum/ai_behavior/basic_ranged_attack/bounty - // Often enough that the pistol fires at its own pace, and its wind-up is never stretched; each tick walks the line of fire (L10) - action_cooldown = 0.1 SECONDS - behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION - required_distance = BOUNTY_PISTOL_RANGE - chase_range = BOUNTY_FIGHT_VISION - avoid_friendly_fire = TRUE - -/datum/ai_behavior/basic_ranged_attack/bounty/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) - var/mob/living/basic/fighter = controller.pawn - var/atom/target = controller.blackboard[target_key] - var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) - if(!strategy.can_attack(fighter, target, chase_range)) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - var/datum/bounty_style/style = bounty_ai_style(fighter) - if(!style?.ranged) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - if(!can_see(fighter, target, required_distance)) - return AI_BEHAVIOR_INSTANT - if(style.min_range && get_dist(fighter, target) < style.min_range && !controller.blackboard[BB_BOUNTY_WINDUP_UNTIL]) - back_off(controller, fighter, target) - if(avoid_friendly_fire && check_friendly_in_path(fighter, target, strategy)) - adjust_position(fighter, target) - return AI_BEHAVIOR_DELAY - return style.ranged_tick(fighter, target) - -/// tg's sidestep out of a friend's line of fire, never off the leash, into lava or into a chasm (L11) -/datum/ai_behavior/basic_ranged_attack/bounty/adjust_position(mob/living/living_pawn, atom/target) - var/turf/our_turf = get_turf(living_pawn) - if(!our_turf) +/datum/human_npc_surrender/bounty + chance = BOUNTY_SURRENDER_CHANCE + retry_chance = BOUNTY_SURRENDER_RETRY + ally_penalty = BOUNTY_SURRENDER_PER_COMPANION + health_share = BOUNTY_SURRENDER_BELOW + hostiles_needed = BOUNTY_SURRENDER_HUNTERS + range = BOUNTY_SURRENDER_HUNTER_RANGE + retry_delay = BOUNTY_SURRENDER_RETRY_GAP + +/// The hunters near it that can see it: a spatial grid lookup of player mobs, as the criminal AI looks around +/datum/human_npc_surrender/bounty/hostiles_near(mob/living/carbon/human/npc/npc) + . = list() + var/turf/here = get_turf(npc) + if(!here) return - var/mob/living/basic/bounty_criminal/leash_holder = bounty_ai_side_of(living_pawn) - var/list/possible_turfs = list() - for(var/direction in GLOB.alldirs) - var/turf/target_turf = get_step(our_turf, direction) - if(isnull(target_turf) || target_turf.is_blocked_turf() || get_dist(target_turf, target) > get_dist(living_pawn, target)) - continue - if(islava(target_turf) || ischasm(target_turf) || !target_turf.can_cross_safely(living_pawn)) + for(var/mob/living/person in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, range)) + if(!bounty_ai_is_hunter(person) || get_dist(here, person) > range || !can_see(person, here, range)) continue - if(leash_holder && !leash_holder.leash_ok(target_turf)) - continue - possible_turfs += target_turf - if(!length(possible_turfs)) - return - var/turf/picked_turf = get_closest_atom(/turf, possible_turfs, target) - living_pawn.Move(picked_turf, get_dir(living_pawn, picked_turf)) + . += person -/// A wind-up cut short never carries over: the next shot telegraphs again -/datum/ai_behavior/basic_ranged_attack/bounty/finish_action(datum/ai_controller/controller, succeeded, target_key, targeting_strategy_key, hiding_location_key) - . = ..() - controller.clear_blackboard_key(BB_BOUNTY_WINDUP_UNTIL) - -/// A step back from `target`, now and then, never off the leash -/datum/ai_behavior/basic_ranged_attack/bounty/proc/back_off(datum/ai_controller/controller, mob/living/basic/fighter, atom/target) - if(world.time < controller.blackboard[BB_BOUNTY_BACKSTEP_AT] || !isturf(fighter.loc)) - return - controller.set_blackboard_key(BB_BOUNTY_BACKSTEP_AT, world.time + BOUNTY_BACKSTEP_GAP) - var/away_dir = get_dir(target, fighter) - var/turf/away = get_step(fighter, away_dir) - if(!away_dir || !away || away.is_blocked_turf(source_atom = fighter) || islava(away) || ischasm(away)) - return - var/mob/living/basic/bounty_criminal/leash_holder = bounty_ai_side_of(fighter) - if(leash_holder && !leash_holder.leash_ok(away)) +/// The companions still standing with it (a companion counts its criminal's other companions) +/datum/human_npc_surrender/bounty/allies_near(mob/living/carbon/human/npc/npc) + . = list() + var/mob/living/carbon/human/npc/bounty_criminal/side = bounty_ai_side_of(npc) + if(!side) return - fighter.Move(away, away_dir) - fighter.face_atom(target) - -/// The shotgun closes in to BOUNTY_SHOTGUN_RANGE -/datum/ai_behavior/basic_ranged_attack/bounty/close - required_distance = BOUNTY_SHOTGUN_RANGE - -/// The bottle thrower stays BOUNTY_BOTTLE_RANGE off -/datum/ai_behavior/basic_ranged_attack/bounty/throw - required_distance = BOUNTY_BOTTLE_RANGE - -// ===== SURRENDER ===== - -/** - * Rolls to give up as a hit is about to land (combat.md N2): BOUNTY_SURRENDER_CHANCE on the hit that - * crosses BOUNTY_SURRENDER_BELOW, less BOUNTY_SURRENDER_PER_COMPANION for each companion still up, - * then BOUNTY_SURRENDER_RETRY on later hits at most every BOUNTY_SURRENDER_RETRY_GAP. Only with - * BOUNTY_SURRENDER_HUNTERS hunters near. Rolled before the damage lands, so the body's mercy rule - * can see TRAIT_BOUNTY_SURRENDERED on that same hit. Must not sleep: it runs in a signal handler. - */ -/mob/living/basic/bounty_criminal/proc/ai_check_surrender(damage, damagetype) - if(damage <= 0 || blended || !(damagetype in list(BRUTE, BURN)) || !ai_can_act()) + for(var/mob/living/carbon/human/npc/bounty_companion/buddy as anything in side.ai_companions()) + if(buddy != npc && buddy.ai_can_fight() && buddy.body_position == STANDING_UP) + . += buddy + +/// Not while blended in at a trader outpost (nothing gives the fugitive away but P6's rules, C1) +/datum/human_npc_surrender/bounty/should_consider(mob/living/carbon/human/npc/npc, list/hostiles, list/allies) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = npc + if(istype(criminal) && criminal.blended) return FALSE - var/line = maxHealth * BOUNTY_SURRENDER_BELOW - var/coeff = damage_coeff?[damagetype] - var/after = health - damage * (isnull(coeff) ? 1 : coeff) - var/chance - if(health > line && after <= line && !ai_surrender_rolled) - ai_surrender_rolled = TRUE - chance = ai_surrender_chance() - else if(health <= line && world.time >= ai_next_surrender_roll) - chance = BOUNTY_SURRENDER_RETRY - else - return FALSE - ai_next_surrender_roll = world.time + BOUNTY_SURRENDER_RETRY_GAP - if(bounty_ai_hunters_near(src, BOUNTY_SURRENDER_HUNTER_RANGE) < BOUNTY_SURRENDER_HUNTERS || !prob(chance)) - return FALSE - ai_surrender() - return TRUE + return ..() -/// The chance on the crossing hit: less for each companion still standing with them -/mob/living/basic/bounty_criminal/proc/ai_surrender_chance() - var/standing = 0 - for(var/mob/living/basic/bounty_companion/buddy as anything in ai_companions()) - if(buddy.ai_can_fight()) - standing++ - return max(0, BOUNTY_SURRENDER_CHANCE - standing * BOUNTY_SURRENDER_PER_COMPANION) +/// The chance on the first roll of a fight: less for each companion still standing with them +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_surrender_chance() + var/datum/human_npc_surrender/rules = npc_surrender_rules() || human_npc_singleton(/datum/human_npc_surrender/bounty) + return rules.odds(src, list(src), rules.allies_near(src), TRUE) /** - * They give up: hands up (an overhead mark), no more attacks, cuffable in 2 s, and a full share at - * the pad until cuffed (P2's capture_state() reads the trait as STUNNED). Hitting them afterwards - * does not undo it. Their companions may lose heart. + * Gives up now: the human NPC base's npc_surrender() (the weapon stowed, the line, tg's *surrender). + * Returns TRUE if they are lying there with their hands on their head. Its AI side + * (ai_on_surrendered()) runs from the base's COMSIG_HUMAN_NPC_SURRENDERED. */ -/mob/living/basic/bounty_criminal/proc/ai_surrender() - if(HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED) || stat == DEAD) - return - ADD_TRAIT(src, TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT) - body_note_state() - ai_stop_aiming() - ai_suspend_activity() - ai_set_mode(BOUNTY_AI_CALM) - ai_hand = bounty_ai_update_held(src, ai_hand, null) - ai_over_head = bounty_ai_update_held(src, ai_over_head, /obj/effect/abstract/bounty_surrender_mark, over_head = TRUE) - REMOVE_TRAIT(src, TRAIT_BOUNTY_SPRINTING, BOUNTY_AI_TRAIT) - if(!QDELETED(ai_controller)) - ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) - ai_controller.clear_blackboard_key(BB_BOUNTY_DESTINATION) - ai_controller.ai_movement?.stop_moving_towards(ai_controller) - ai_controller.CancelActions() - visible_message(span_notice("[src] raises [p_their()] hands and gives up.")) - // Forced: it usually comes right after a "hurt" line, inside that line's cooldown (BUG-1) - ai_bark("surrender", TRUE) - ai_companions_lose_heart() - -/// Only its look is used: the hands-up mark over a surrendered criminal's head -/obj/effect/abstract/bounty_surrender_mark - icon = 'icons/hud/screen_alert.dmi' - icon_state = "surrender" +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_surrender() + return npc_surrender() // ===== RETREAT ===== /// Badly hurt with no hunter near: they break off rather than wait to be found -/mob/living/basic/bounty_criminal/proc/ai_should_retreat() +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_should_retreat() if(!ai_can_act() || health > maxHealth * BOUNTY_NORMAL_RETREAT_BELOW) return FALSE return !bounty_ai_hunters_near(src, BOUNTY_SURRENDER_HUNTER_RANGE) /** * Gets away from `from` (or where they stand) to somewhere about BOUNTY_NORMAL_RETREAT_DISTANCE - * off on their site, picked from a handful of random tiles once, and sits it out there. They do not - * heal; a hunter coming close starts the fight again. + * off on their site, picked from a handful of random tiles once, and sits it out there, patching + * themselves up with what they carry. A hunter coming close starts the fight again. */ -/mob/living/basic/bounty_criminal/proc/ai_start_retreat(mob/living/from) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_start_retreat(mob/living/from) if(!ai_can_act() || QDELETED(ai_controller)) return FALSE var/turf/here = get_turf(src) @@ -401,8 +283,8 @@ best_distance = distance ai_suspend_activity() ai_set_mode(BOUNTY_AI_RETREATING) - ai_hand = bounty_ai_update_held(src, ai_hand, null) - ai_set_pace(BOUNTY_NORMAL_SPEED_RANGED) + bounty_ai_holster(src) + ai_set_pace(BOUNTY_RUN_SPEED) ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) if(best) ai_controller.set_blackboard_key(BB_BOUNTY_DESTINATION, best) @@ -411,11 +293,11 @@ ai_bark("hurt") return TRUE -/// Walks to where they retreat to, then sits down there, winded and hurt +/// Walks to where they retreat to, then sits down on the floor there and treats themselves (N6) /datum/ai_planning_subtree/bounty_retreat /datum/ai_planning_subtree/bounty_retreat/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/criminal = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/criminal = controller.pawn if(criminal.ai_mode != BOUNTY_AI_RETREATING) return var/turf/destination = controller.blackboard[BB_BOUNTY_DESTINATION] @@ -427,411 +309,7 @@ criminal.ai_set_pace(BOUNTY_CALM_SPEED) criminal.ai_crouch() criminal.visible_message(span_notice("[criminal] sits down heavily, catching [criminal.p_their()] breath.")) + if(criminal.health < criminal.maxHealth && bounty_ai_queue_treat_self(controller)) + return SUBTREE_RETURN_FINISH_PLANNING controller.queue_behavior(/datum/ai_behavior/bounty_watch) return SUBTREE_RETURN_FINISH_PLANNING - -// ===== STYLES ===== - -/// Every fighting style by key (BOUNTY_STYLE_*), one datum each -GLOBAL_LIST_INIT(bounty_styles, init_bounty_styles()) - -/proc/init_bounty_styles() - var/list/styles = list() - for(var/datum/bounty_style/style_type as anything in subtypesof(/datum/bounty_style)) - var/datum/bounty_style/style = new style_type - if(style.key) - styles[style.key] = style - return styles - -/// A fighter's style: from its controller's blackboard -/proc/bounty_ai_style(mob/living/basic/fighter) - var/datum/ai_controller/controller = fighter?.ai_controller - if(QDELETED(controller)) - return null - return GLOB.bounty_styles[controller.blackboard[BB_BOUNTY_STYLE]] - -/// The item a fighter fights with and shows in their hand (a typepath), or null for bare fists -/proc/bounty_ai_held_look(mob/living/basic/fighter) - var/datum/ai_controller/controller = fighter?.ai_controller - if(QDELETED(controller)) - return null - return controller.blackboard[BB_BOUNTY_WEAPON] - -/// The real weapon a fighter fights with: the item in their hand, loaded as their style loads it. Null with no style. -/proc/bounty_ai_real_weapon(mob/living/basic/fighter) - var/datum/bounty_style/style = bounty_ai_style(fighter) - if(!style) - return null - return bounty_real_weapon(bounty_ai_held_look(fighter), style.ammo_type) - -// A style's health multiplier is P2's bounty_style_health_mult() (bounty_criminal.dm), from the combat.md defines. - -/// Stamina damage multiplier of a style (the brawler shrugs off some): P2 folds it into damage_coeff[STAMINA] -/proc/bounty_style_stamina_mult(style_key) - var/datum/bounty_style/style = GLOB.bounty_styles[style_key] - return style ? style.stamina_mult : 1 - -/** - * Puts style `key` on `fighter`: its weapon (the style's heavy_look if `heavy` and it has one), a - * full magazine, and the blow and the gun that weapon gives. - */ -/proc/bounty_ai_apply_style(mob/living/basic/fighter, key, heavy = FALSE) - var/datum/bounty_style/style = GLOB.bounty_styles[key] - var/datum/ai_controller/controller = fighter?.ai_controller - if(!style || QDELETED(controller)) - return FALSE - var/weapon_type = (heavy && style.heavy_look) || style.held_look - var/datum/bounty_real_weapon/weapon = bounty_real_weapon(weapon_type, style.ammo_type) - controller.set_blackboard_key(BB_BOUNTY_STYLE, key) - if(weapon_type) - controller.set_blackboard_key(BB_BOUNTY_WEAPON, weapon_type) - else - controller.clear_blackboard_key(BB_BOUNTY_WEAPON) - controller.set_blackboard_key(BB_BOUNTY_AMMO, weapon.magazine) - controller.clear_blackboard_key(BB_BOUNTY_RELOAD_UNTIL) - controller.clear_blackboard_key(BB_BOUNTY_WINDUP_UNTIL) - style.apply(fighter, weapon) - return TRUE - -/** - * How someone fights (combat.md 6.2). Criminals, companions and the meek's holdout share these. - * Every blow and shot is the real weapon's (bounty_weapons.dm): the item the style puts in their - * hand gives its damage, wounds, armour penetration, rounds and magazine, so a 9mm is a 9mm - * whoever fires it. The style only sets the timing: how fast they swing or fire, the telegraph, the - * reload and the distance they keep. Melee styles hit through the mob's own melee attack; ranged - * ones shoot through its ranged_attacks component. Nothing is spawned for the fight, and nothing - * drops. - */ -/datum/bounty_style - /// BOUNTY_STYLE_* - var/key - /// Fights at range - var/ranged = FALSE - /// Health multiplier (P2 applies it to a criminal; companions apply it themselves) - var/health_mult = 1 - /// Stamina damage multiplier (P2 applies it to a criminal) - var/stamina_mult = 1 - /// Between attacks - var/interval = 1.4 SECONDS - /// Speed in a fight - var/speed = BOUNTY_NORMAL_SPEED_MELEE - /// The real item they fight with, shown in their hand (a typepath; null for bare fists) - var/held_look - /// The heavier real item a Wanted criminal carries instead, if the style has one - var/heavy_look - /// What a gun is loaded with instead of what it comes with (a shotgun's buckshot) - var/ammo_type - /// Ranged: the NPCs' own projectile a round flies as, and the sound of a throw (a gun sounds like itself) - var/projectile_type - var/projectile_sound - /// The ranged behavior that keeps this style's distance - var/ranged_behavior = /datum/ai_behavior/basic_ranged_attack/bounty - /// Anyone closer than this and they step back (0: never) - var/min_range = 0 - /// The telegraph before a shot or throw, and what it says and sounds like - var/windup = 0 - var/windup_message - var/windup_sound - /// How long a reload takes once the magazine is spent, and what it sounds like - var/reload_time = 0 - var/reload_sound - -/// Sets `weapon`'s blow and gun on `fighter`, at this style's pace -/datum/bounty_style/proc/apply(mob/living/basic/fighter, datum/bounty_real_weapon/weapon) - fighter.melee_attack_cooldown = interval - weapon.apply_melee(fighter) - var/datum/component/ranged_attacks/gun = fighter.GetComponent(/datum/component/ranged_attacks) - if(!gun || !projectile_type) - return - gun.casing_type = null - gun.projectile_type = projectile_type - gun.projectile_sound = weapon.fire_sound || projectile_sound - gun.cooldown_time = interval - gun.burst_shots = null - gun.burst_intervals = 0 - -/// Anything extra a melee hit does -/datum/bounty_style/proc/on_melee_hit(mob/living/basic/fighter, mob/living/target) - return - -/// Whether this shot needs the telegraph first -/datum/bounty_style/proc/needs_windup(datum/ai_controller/controller) - return windup > 0 - -/// The telegraph: what they say, show and sound like before the shot -/datum/bounty_style/proc/telegraph(mob/living/basic/fighter, atom/target) - fighter.face_atom(target) - var/look = bounty_ai_held_look(fighter) - if(look) - bounty_ai_hold(fighter, look) - if(windup_message) - fighter.visible_message(span_warning(replacetext(windup_message, "%USER", "[fighter]"))) - if(windup_sound) - playsound(fighter, windup_sound, 50, TRUE) - -/** - * One tick of a ranged fight: reloading, winding up, or firing through the ranged_attacks - * component when it is ready. Returns the AI behavior flags. - */ -/datum/bounty_style/proc/ranged_tick(mob/living/basic/fighter, atom/target) - var/datum/ai_controller/controller = fighter.ai_controller - var/datum/bounty_real_weapon/weapon = bounty_ai_real_weapon(fighter) - var/magazine = weapon?.magazine - var/now = world.time - var/reload_until = controller.blackboard[BB_BOUNTY_RELOAD_UNTIL] - if(reload_until) - if(now < reload_until) - return AI_BEHAVIOR_DELAY - controller.clear_blackboard_key(BB_BOUNTY_RELOAD_UNTIL) - controller.set_blackboard_key(BB_BOUNTY_AMMO, magazine) - if(magazine && controller.blackboard[BB_BOUNTY_AMMO] <= 0) - controller.set_blackboard_key(BB_BOUNTY_RELOAD_UNTIL, now + reload_time) - fighter.visible_message(span_notice("[fighter] reloads.")) - if(reload_sound) - playsound(fighter, reload_sound, 50, TRUE) - return AI_BEHAVIOR_DELAY - var/datum/component/ranged_attacks/gun = fighter.GetComponent(/datum/component/ranged_attacks) - if(!gun) - return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED - var/windup_until = controller.blackboard[BB_BOUNTY_WINDUP_UNTIL] - if(windup_until && now > windup_until + 1 SECONDS) - // A wind-up left over from an interrupted fight: it has to be done again - controller.clear_blackboard_key(BB_BOUNTY_WINDUP_UNTIL) - windup_until = null - if(!windup_until) - if(gun.fire_cooldown > now + windup) - return AI_BEHAVIOR_DELAY - if(needs_windup(controller)) - controller.set_blackboard_key(BB_BOUNTY_WINDUP_UNTIL, now + windup) - telegraph(fighter, target) - return AI_BEHAVIOR_DELAY - else if(now < windup_until) - fighter.face_atom(target) - return AI_BEHAVIOR_DELAY - controller.clear_blackboard_key(BB_BOUNTY_WINDUP_UNTIL) - fighter.RangedAttack(target) - return AI_BEHAVIOR_DELAY - -/// After each shot: the magazine and the clock for the next string -/datum/bounty_style/proc/after_shot(mob/living/basic/fighter) - var/datum/ai_controller/controller = fighter.ai_controller - if(QDELETED(controller)) - return - controller.set_blackboard_key(BB_BOUNTY_LAST_SHOT, world.time) - var/datum/bounty_real_weapon/weapon = bounty_ai_real_weapon(fighter) - if(weapon?.magazine) - controller.set_blackboard_key(BB_BOUNTY_AMMO, max(0, controller.blackboard[BB_BOUNTY_AMMO] - 1)) - -/** - * Loads a shot as it leaves the gun: the real round's numbers and its spread, and the rest of a - * shell's pellets fired alongside it (one shell, one shot from the magazine). A thrown bottle - * takes the bottle's throw. Called inside the projectile's fire(): must not sleep. - */ -/datum/bounty_style/proc/prepare_projectile(mob/living/basic/fighter, obj/projectile/shot) - var/datum/bounty_real_weapon/weapon = bounty_ai_real_weapon(fighter) - if(!weapon) - return - if(istype(shot, /obj/projectile/bounty_bottle)) - weapon.load_thrown(shot) - return - var/obj/projectile/bullet/bounty/bullet = shot - if(!istype(bullet) || bullet.round_loaded) - return - bullet.round_loaded = TRUE - if(!weapon.load(bullet)) - return - bullet.set_angle(bullet.angle + weapon.shot_spread()) - for(var/pellet in 2 to weapon.pellets) - INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_ai_fire_pellet), fighter, weapon, bullet.type, bullet.original) - -/// Another pellet of the shell `fighter` just fired at `target`, with its own spread -/proc/bounty_ai_fire_pellet(mob/living/basic/fighter, datum/bounty_real_weapon/weapon, shot_type, atom/target) - var/turf/start = get_turf(fighter) - if(!start || QDELETED(fighter) || QDELETED(target)) - return null - var/obj/projectile/bullet/bounty/pellet = new shot_type(start) - pellet.round_loaded = TRUE - weapon.load(pellet) - pellet.firer = fighter - pellet.fired_from = fighter - pellet.aim_projectile(target, fighter, null, weapon.shot_spread()) - if(QDELETED(pellet)) - return null - pellet.fire() - return pellet - -/// Bare fists: a human's punch -/datum/bounty_style/brawler - key = BOUNTY_STYLE_BRAWLER - health_mult = BOUNTY_BRAWLER_HEALTH - stamina_mult = BOUNTY_BRAWLER_STAMINA - interval = BOUNTY_BRAWLER_INTERVAL - -/// Now and then a punch shoves them back a tile (never onto lava, into a chasm or out into space, M5) and staggers them; no stun -/datum/bounty_style/brawler/on_melee_hit(mob/living/basic/fighter, mob/living/target) - if(target.stat != CONSCIOUS || !prob(BOUNTY_BRAWLER_SHOVE_CHANCE)) - return - bounty_ai_shove_back(fighter, target) - target.apply_status_effect(/datum/status_effect/staggered, BOUNTY_BRAWLER_STAGGER) - fighter.visible_message(span_warning("[fighter] shoves [target] back!")) - playsound(target, 'sound/items/weapons/shove.ogg', 50, TRUE) - -/// The blade's own cuts bleed, as a real one's do -/datum/bounty_style/knife - key = BOUNTY_STYLE_KNIFE - health_mult = BOUNTY_KNIFE_HEALTH - interval = BOUNTY_KNIFE_INTERVAL - held_look = /obj/item/knife/kitchen - heavy_look = /obj/item/knife/combat - -/datum/bounty_style/club - key = BOUNTY_STYLE_CLUB - health_mult = BOUNTY_CLUB_HEALTH - interval = BOUNTY_CLUB_INTERVAL - held_look = /obj/item/melee/baseball_bat - heavy_look = /obj/item/melee/baseball_bat/ablative - -/// A bat knocks them back a tile or two, gently, as tg's bat does; never onto lava, into a chasm or out into space -/datum/bounty_style/club/on_melee_hit(mob/living/basic/fighter, mob/living/target) - if(QDELETED(target) || target.stat == DEAD) - return - bounty_throw_back(fighter, target, rand(1, 2), prob(60) ? 1 : 4, gentle = TRUE) - -/datum/bounty_style/pistol - key = BOUNTY_STYLE_PISTOL - ranged = TRUE - health_mult = BOUNTY_PISTOL_HEALTH - interval = BOUNTY_PISTOL_INTERVAL - speed = BOUNTY_NORMAL_SPEED_RANGED - held_look = /obj/item/gun/ballistic/automatic/pistol/aps - projectile_type = /obj/projectile/bullet/bounty - windup = BOUNTY_PISTOL_WINDUP - windup_message = "%USER raises a pistol." - windup_sound = 'sound/items/weapons/gun/pistol/rack_small.ogg' - min_range = BOUNTY_PISTOL_MIN_RANGE - reload_time = BOUNTY_PISTOL_RELOAD - reload_sound = 'sound/items/weapons/gun/pistol/mag_insert.ogg' - -/// Only the first shot of a string needs the gun raised -/datum/bounty_style/pistol/needs_windup(datum/ai_controller/controller) - var/last_shot = controller.blackboard[BB_BOUNTY_LAST_SHOT] - return !last_shot || world.time - last_shot > BOUNTY_PISTOL_STRING_GAP - -/// A reload starts a new string -/datum/bounty_style/pistol/ranged_tick(mob/living/basic/fighter, atom/target) - var/datum/ai_controller/controller = fighter.ai_controller - if(controller.blackboard[BB_BOUNTY_RELOAD_UNTIL] && world.time >= controller.blackboard[BB_BOUNTY_RELOAD_UNTIL]) - controller.clear_blackboard_key(BB_BOUNTY_LAST_SHOT) - return ..() - -/// Buckshot: every pellet of a shell, one shell a shot -/datum/bounty_style/shotgun - key = BOUNTY_STYLE_SHOTGUN - ranged = TRUE - health_mult = BOUNTY_SHOTGUN_HEALTH - interval = BOUNTY_SHOTGUN_INTERVAL - speed = BOUNTY_NORMAL_SPEED_RANGED - held_look = /obj/item/gun/ballistic/shotgun - heavy_look = /obj/item/gun/ballistic/shotgun/automatic/combat - ammo_type = /obj/item/ammo_casing/shotgun/buckshot - projectile_type = /obj/projectile/bullet/bounty - ranged_behavior = /datum/ai_behavior/basic_ranged_attack/bounty/close - windup = BOUNTY_SHOTGUN_WINDUP - windup_message = "%USER pumps the shotgun." - windup_sound = 'sound/items/weapons/gun/shotgun/rack.ogg' - reload_time = BOUNTY_SHOTGUN_RELOAD - reload_sound = 'sound/items/weapons/gun/shotgun/insert_shell.ogg' - -/// A beer bottle, thrown: it hits with the bottle's throwforce, and breaks -/datum/bounty_style/bottle - key = BOUNTY_STYLE_BOTTLE - ranged = TRUE - health_mult = BOUNTY_BOTTLE_HEALTH - interval = BOUNTY_BOTTLE_INTERVAL - speed = BOUNTY_NORMAL_SPEED_RANGED - held_look = /obj/item/reagent_containers/cup/glass/bottle/beer - projectile_type = /obj/projectile/bounty_bottle - projectile_sound = 'sound/items/weapons/punchmiss.ogg' - ranged_behavior = /datum/ai_behavior/basic_ranged_attack/bounty/throw - min_range = BOUNTY_BOTTLE_MIN_RANGE - windup = BOUNTY_BOTTLE_WINDUP - windup_message = "%USER winds up to throw a bottle." - -/** - * The meek's little gun: a quick volley when cornered, once per cornering. The volley is fired - * one shot at a time by the meek AI (bounty_ai_meek.dm), each shot checking they can still shoot, - * so the gun here only has to be ready again between shots. - */ -/datum/bounty_style/holdout - key = BOUNTY_STYLE_HOLDOUT - ranged = TRUE - interval = BOUNTY_MEEK_PISTOL_GAP * 0.8 - speed = BOUNTY_MEEK_SPRINT_SPEED - held_look = /obj/item/gun/ballistic/automatic/pistol - projectile_type = /obj/projectile/bullet/bounty/holdout - -// ===== WHAT THEY FIRE ===== - -/** - * A criminal's bullet or pellet: the real round's numbers are put on it as it is fired - * (/datum/bounty_style/proc/prepare_projectile()), embedding and all. It never hits the shooter's - * own gang, and off their site or at a trader outpost it passes anyone the body's rules leave alone - * (AR-C8, AR-D1; bounty_ai_may_hit(), so a companion's shots don't depend on its criminal being up, - * H3): only a direct target is hit regardless. - */ -/obj/projectile/bullet/bounty - name = "bullet" - /// The round's numbers are on it already: a pellet fired alongside a shell's first - var/round_loaded = FALSE - -/obj/projectile/bullet/bounty/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) - . = ..() - if(!. || direct_target || !isliving(target) || !firer) - return - if(!bounty_ai_may_hit(firer, target)) - return FALSE - -/// The meek's holdout fires these -/obj/projectile/bullet/bounty/holdout - name = "small bullet" - -/** - * A thrown bottle: it flies slower than a bullet, hits with the bottle's throw, and smashes where it - * lands, leaving broken glass on the floor (a decal, never a weapon). - */ -/obj/projectile/bounty_bottle - name = "bottle" - icon = 'icons/obj/drinks/bottles.dmi' - icon_state = "beer" - damage_type = BRUTE - armor_flag = MELEE - speed = 0.6 - range = BOUNTY_BOTTLE_REACH - hitsound = 'sound/effects/glass/glassbr1.ogg' - hitsound_wall = 'sound/effects/glass/glassbr2.ogg' - /// Smashed already, so it leaves glass once - var/smashed = FALSE - -/obj/projectile/bounty_bottle/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) - . = ..() - if(!. || direct_target || !isliving(target) || !firer) - return - if(!bounty_ai_may_hit(firer, target)) - return FALSE - -/obj/projectile/bounty_bottle/on_hit(atom/target, blocked = 0, pierce_hit) - . = ..() - smash(get_turf(target)) - -/obj/projectile/bounty_bottle/on_range() - smash(get_turf(src)) - return ..() - -/// Broken glass where it landed -/obj/projectile/bounty_bottle/proc/smash(turf/where) - if(smashed || !where) - return - smashed = TRUE - if(!isgroundlessturf(where) && !isspaceturf(where)) - new /obj/effect/decal/cleanable/glass(where) - playsound(where, 'sound/effects/glass/glassbr3.ogg', 50, TRUE) diff --git a/voidcrew/modules/bounties/bounty_board.dm b/voidcrew/modules/bounties/bounty_board.dm index 7f17d045fe0..28617021795 100644 --- a/voidcrew/modules/bounties/bounty_board.dm +++ b/voidcrew/modules/bounties/bounty_board.dm @@ -186,7 +186,7 @@ SUBSYSTEM_DEF(criminal_bounties) /datum/controller/subsystem/criminal_bounties/proc/board_live_criminals() var/count = 0 for(var/datum/criminal_bounty/posting as anything in GLOB.criminal_bounties) - var/mob/living/basic/bounty_criminal/criminal = posting.criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = posting.criminal() if(criminal && criminal.stat != DEAD) count++ return count diff --git a/voidcrew/modules/bounties/bounty_boss.dm b/voidcrew/modules/bounties/bounty_boss.dm index 94d389fabdf..8c4b1680f80 100644 --- a/voidcrew/modules/bounties/bounty_boss.dm +++ b/voidcrew/modules/bounties/bounty_boss.dm @@ -5,32 +5,42 @@ * the tests in voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm. Do not edit this file * outside P4. * - * What it owns: /mob/living/basic/bounty_criminal/boss (its body, built-in values and AI tree), the - * bounty ability base with its wind-up, telegraph and stale-serial guard (a port of the prison - * horror's pattern, not a reuse of its type), health that grows with the posse, tiredness below - * BOUNTY_BOSS_TIRED_BELOW percent, the limits on what it may break, and on_confronted() for bosses - * (spec section 5, 12.3 C8). + * What it owns: /mob/living/carbon/human/npc/bounty_criminal/boss (its body's built-in values, its gear + * and its AI), the bounty ability base with its wind-up, telegraph and stale-serial guard (a port of + * the prison horror's pattern), the posse and its reinforcements, the limits on what it may break, its + * scripted fire and blasts, and on_confronted() for bosses (spec section 5, 12.3 C8). * - * The shared vars are declared in bounty_types.dm and nowhere else; a var only P4 uses goes here - * with a `boss_` prefix, or on the /boss subtypes. + * A boss is a real human (the human NPC base, voidcrew/modules/human_npcs/; owner decision D5). Its + * crit, stamina, stamcrit, stuns, cuffs, wounds, bleeding and death are tg's, exactly as a player's: + * - Toughness is real armour from its kit (locked to its body, D3) and npc_damage_taken + * (BOUNTY_BOSS_DAMAGE_TAKEN), fixed whatever the posse (defaults 1.5, OC-8). Never health. + * - Resistance to stuns is its armour and a real stimulant pen it carries and injects, with a visible + * emote, when its fight starts (defaults 1.6). A second pen is its second wind. + * - "Worn out" is its AI reading real state (stamina loss over half its pool, or health under 40%): it + * says so, falls back, and then takes its second pen if it has one (CB-2, CR-4). + * - It fights with what it holds (real knives, a real torch, a real gun, its fists) through its hands, + * the way a player does (controller.ai_interact()). + * - It never surrenders. Bosses execute nobody this phase (the core's hv_can_execute(), OC-9). * * How a fight goes: * - A boss waits (its activity, if P3 gives it one) until it spots a hunter, is attacked or is * confronted. Then it turns hostile: every player within BOUNTY_BOSS_POSSE_RANGE who can see it - * joins its posse, and its health is set from the kit's table for that many hunters. Anyone who - * hurts it later joins too, raising its health again (never lowering it). - * - Its AI: nothing while busy or disabled; a target; an ability, never the same one twice in a - * row; the kit's way of closing in or keeping away; its melee attack; and with nobody in sight, - * the hunt for the nearest hunter still on its site. It calms down after BOUNTY_BOSS_CALM_TIME - * with nobody around. + * joins its posse. Each hunter in the posse beyond the first brings one more companion in from out of + * their view, one at a time (OC-8, CB-1), up to BOUNTY_BOSS_REINFORCEMENTS_MAX. + * - Its AI: soft crit and breaking free first (the human NPC base's and the core's); nothing while + * busy or disabled; worn out, it falls back; its stimulant; a target; its kit's gear; an ability, + * never the same one twice in a row; the kit's way of closing in or keeping away; its melee; patching + * itself up with nobody about; and with nobody in sight, the hunt for the nearest hunter still on its + * site. It calms down after BOUNTY_BOSS_CALM_TIME with nobody around. * - Every ability winds up first, where everyone can see and hear it (telegraph()), and does its - * harm only when the wind-up is over (effect()). Downed, restrained, stunned or dead in between, - * and nothing happens. - * - Below BOUNTY_BOSS_TIRED_BELOW percent health it tires: slower, longer cooldowns, and stamina - * weapons work fully, so it can be stunned and cuffed. That is the capture window. + * harm only when the wind-up is over (effect()). Downed, restrained, stunned, knocked down or dead + * in between (tg's states, CB-24), and nothing happens. + * - Who it chooses to go after is never someone already down (npc_target_stat, its targeting). What + * its fire and blasts catch is whoever stands there, down or not (CB-11, defaults 1.11). * - What it breaks is scripted, never a real explosion or atmos fire: tables, chairs, windows and * grilles, and at most BOUNTY_BOSS_WALL_BUDGET walls or doors a fight, only indoors on its own - * site, never anything with space, the outdoors or another place on its far side. + * site, never anything with space, the outdoors or another place on its far side. People its fire + * reaches burn with tg's fire stacks; people its blasts reach take tg's light explosion. */ /// Trait source for standing still while busy with an ability @@ -47,41 +57,47 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /proc/bounty_boss_is_hunter(mob/living/candidate) if(!isliving(candidate) || QDELETED(candidate) || candidate.stat == DEAD) return FALSE - if(istype(candidate, /mob/living/basic/bounty_criminal) || istype(candidate, /mob/living/basic/bounty_companion)) + if(bounty_is_gang_body(candidate)) return FALSE return !isnull(candidate.mind) || !isnull(candidate.client) +/** + * Whether a boss's fire or blast catches `thing`, whatever state it is in (CB-11): a person who isn't + * on the boss's side (`side`, its factions), or a mech with one in it. Never a criminal or a companion, + * never anyone in godmode. Where it reaches is the caller's business (boss_reach_ok()). + */ +/proc/bounty_boss_effect_victim(atom/thing, list/side) + if(QDELETED(thing)) + return FALSE + if(ismecha(thing)) + var/obj/vehicle/sealed/mecha/mech = thing + for(var/mob/living/pilot in mech.occupants) + if(bounty_boss_effect_person(pilot, side)) + return TRUE + return FALSE + var/mob/living/person = thing + // A pilot is reached through the mech's hull. + return isliving(person) && !ismecha(person.loc) && bounty_boss_effect_person(person, side) + +/// Whether `person` is someone a boss's fire and blasts catch: anyone not on its side, down, dead or standing +/proc/bounty_boss_effect_person(mob/living/person, list/side) + if(!isliving(person) || QDELETED(person) || HAS_TRAIT(person, TRAIT_GODMODE) || bounty_is_gang_body(person)) + return FALSE + return !length(side) || !faction_check(side, person.faction) + // ===== THE BOSS ===== -/mob/living/basic/bounty_criminal/boss +/mob/living/carbon/human/npc/bounty_criminal/boss name = "wanted criminal" desc = "Someone with a big price on their head, and the gear to keep it." - maxHealth = BOUNTY_JUGGERNAUT_HEALTH_1 - health = BOUNTY_JUGGERNAUT_HEALTH_1 - // Fresh, it shrugs off stuns and knockdowns; boss_update_tired() adds them back once it tires. - status_flags = CANPUSH - move_resist = MOVE_FORCE_STRONG - mob_size = MOB_SIZE_HUMAN - combat_mode = TRUE - // The placement adds the site's own factions (spec section 3), so the site's hostiles leave it be. - faction = list(FACTION_HOSTILE) - environment_smash = ENVIRONMENT_SMASH_NONE - // max_stamina and the stamina crit threshold are P2's (100 and 99.9). Stamina never slows a boss: - // the tired slowdown is the only one the spec asks for, so steady disabler fire can't pin it (H3). - max_stamina_slowdown = 0 - damage_coeff = list(BRUTE = 1, BURN = 1, TOX = 1, STAMINA = BOUNTY_BOSS_STAMINA_FRESH * 100 / BOUNTY_BOSS_STAMINA, OXY = 1) - melee_damage_lower = 10 - melee_damage_upper = 14 - melee_attack_cooldown = 1.5 SECONDS - attack_verb_continuous = "hits" - attack_verb_simple = "hit" - attack_sound = 'sound/items/weapons/punch4.ogg' - attack_vis_effect = ATTACK_EFFECT_PUNCH ai_controller = /datum/ai_controller/basic_controller/bounty_boss + // Aimed crits like a player's (D4); executions stay off for every bounty NPC (the core's hv_can_execute()). + npc_rank = HUMAN_NPC_RANK_BOSS + // Fixed toughness, whatever its tier or posse (defaults 1.5, OC-8). Its armour does the rest. + npc_damage_taken = BOUNTY_BOSS_DAMAGE_TAKEN + body_tier_toughness = FALSE /// BOUNTY_KIT_*: which kit it is, and the prefix of its dialogue contexts var/boss_kit - /// The real item in its hand it strikes with (bounty_real_weapon()): its blows are that item's. Null keeps the kit's own blow. - var/boss_weapon /// The ability types it is given var/list/boss_ability_types = list() /// Its abilities (/datum/action/cooldown/mob_cooldown/bounty_boss), granted at Initialize @@ -92,26 +108,23 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) var/boss_strict_rotation = BOUNTY_BOSS_STRICT_ROTATION /// world.time the next ability may start var/boss_next_ability_at = 0 - /// What keeps it still with its AI quiet ("windup", "charge", "dash", "burst", "stagger"), or null + /// What keeps it still with its AI quiet ("windup", "charge", "dash", "burst"), or null var/boss_busy /// Goes up each time it becomes busy, so a stale timer knows to do nothing var/boss_busy_serial = 0 var/boss_busy_timer /// The ability winding up now, whose telegraph goes if it is interrupted var/datum/weakref/boss_winding_ref - /// Stamina damage it takes to crit it once tired, and its coefficient while fresh - var/boss_stamina_pool = BOUNTY_BOSS_STAMINA - var/boss_stamina_fresh = BOUNTY_BOSS_STAMINA_FRESH - /// At or below BOUNTY_BOSS_TIRED_BELOW percent health - var/boss_tired = FALSE - /// Its speed while fresh, and its brute and burn coefficients (a stagger's weakness puts them back) - var/boss_base_speed - var/boss_brute_coeff = 1 - var/boss_burn_coeff = 1 /// Turned hostile: it spotted a hunter, or someone attacked or confronted it var/boss_hostile = FALSE - /// REF() of each hunter in its posse -> TRUE; its health follows how many + /// REF() of each hunter in its posse -> TRUE var/list/boss_engaged = list() + /// Companions its posse has brought in (OC-8), and how many more are owed and not yet called + var/boss_reinforcements_sent = 0 + var/boss_reinforcements_owed = 0 + var/boss_reinforce_timer + /// Weakrefs to the companions that came in for it + var/list/boss_reinforcement_refs /// Walls and doors it may still break this fight var/boss_walls_left = BOUNTY_BOSS_WALL_BUDGET /// Whoever hurt it last (a person, or the mech they are in), and when @@ -119,54 +132,56 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) var/boss_last_attacked_at = 0 /// world.time it calms down if it still finds no hunter on its site var/boss_calm_at = 0 - -/mob/living/basic/bounty_criminal/boss/Initialize(mapload) + /// Worn out (boss_is_worn_out(), read by its AI): said so, and falls back until boss_fallback_until + var/boss_worn_out = FALSE + var/boss_fallback_until = 0 + /// Stimulant pens it has used: the first when its fight starts, the second for its second wind + var/boss_stimulants_used = 0 + /// Its fall back is over and it is still worn out: its next plan takes the second pen + var/boss_second_wind_due = FALSE + +/mob/living/carbon/human/npc/bounty_criminal/boss/Initialize(mapload, datum/human_npc_order/order) . = ..() - if(boss_weapon) - bounty_real_weapon(boss_weapon).apply_melee(src) - damage_coeff = damage_coeff.Copy() - boss_brute_coeff = damage_coeff[BRUTE] - boss_burn_coeff = damage_coeff[BURN] - boss_base_speed = speed for(var/ability_type in boss_ability_types) var/datum/action/cooldown/mob_cooldown/bounty_boss/ability = new ability_type(src) ability.Grant(src) boss_abilities += ability - AddElement(/datum/element/relay_attackers) AddComponent(/datum/component/bounty_boss_signals) - boss_update_tired(silent = TRUE, force = TRUE) - // Dressed by the spawner when it has a record; an admin spawn with none gets the kit's look. - addtimer(CALLBACK(src, PROC_REF(boss_dress_fallback)), 1, TIMER_DELETE_ME) -/mob/living/basic/bounty_criminal/boss/Destroy() +/mob/living/carbon/human/npc/bounty_criminal/boss/Destroy() boss_interrupt() QDEL_LIST(boss_abilities) deltimer(boss_busy_timer) boss_busy_timer = null + deltimer(boss_reinforce_timer) + boss_reinforce_timer = null + boss_reinforcement_refs = null boss_winding_ref = null boss_last_attacker = null return ..() -/mob/living/basic/bounty_criminal/boss/death(gibbed) +/mob/living/carbon/human/npc/bounty_criminal/boss/death(gibbed) boss_interrupt() - . = ..() - boss_update_hold() + deltimer(boss_reinforce_timer) + boss_reinforce_timer = null + boss_reinforcements_owed = 0 + return ..() -/mob/living/basic/bounty_criminal/boss/examine(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/boss/examine(mob/user) . = ..() - if(stat == DEAD) + if(stat == DEAD || hidden) return var/gear = boss_gear_text() if(gear) . += span_warning(gear) - if(boss_tired) + if(boss_worn_out) . += span_notice("[p_They()] look[p_s()] worn out.") /** * `user` confronted them: showed the warrant, or accused them. A boss doesn't run or argue. It * starts its fight, with `user` first on its list. */ -/mob/living/basic/bounty_criminal/boss/on_confronted(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/boss/on_confronted(mob/user) if(stat != CONSCIOUS || !isliving(user)) return body_add_grudge(user) @@ -175,135 +190,94 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) if(boss_valid_target(target)) ai_controller?.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target) -/** - * Its blows land on a mech's hull in full (H2): tg's mech defence scales a mob's blow by how much it - * smashes, and a boss smashes nothing, so every blow would do nothing. - */ -/mob/living/basic/bounty_criminal/boss/melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - // Each swing picks one of the weapon's own verbs, as a player's does - if(boss_weapon) - bounty_real_weapon(boss_weapon).pick_verb(src) - if(!ismecha(target)) - return ..() - if(!early_melee_attack(target, modifiers, ignore_cooldown)) - return FALSE - do_attack_animation(target, attack_vis_effect) - if(attack_sound) - playsound(target, attack_sound, 50, TRUE) - target.visible_message(span_danger("[src] [attack_verb_continuous] [target]!")) - boss_hit(target, rand(melee_damage_lower, melee_damage_upper), melee_damage_type, MELEE, 0, armour_penetration, TRUE) - SEND_SIGNAL(src, COMSIG_HOSTILE_POST_ATTACKINGTARGET, target, TRUE) - if(!ignore_cooldown) - changeNext_move(melee_attack_cooldown) - return TRUE - // ----- its kit ----- -/// Health for 1, 2, 3 and 4+ engaged hunters. Each kit has its own. -/mob/living/basic/bounty_criminal/boss/proc/boss_health_table() - return list(initial(maxHealth)) - -/// The outfit its look is built in: each kit has one, so the five read differently at a glance. P2's look uses it. -/mob/living/basic/bounty_criminal/boss/boss_kit_outfit() - return /datum/outfit/bounty_boss +// What each kit wears and carries (bounty_boss_kits.dm): real armour, its weapon carried or held, spare +// ammunition, its stimulants and something to patch itself up with. The base adds vacuum and cold layers. +/mob/living/carbon/human/npc/bounty_criminal/boss/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss)) -/// With no record to dress it from (an admin spawn), the kit's look on a stock face -/mob/living/basic/bounty_criminal/boss/proc/boss_dress_fallback() - if(QDELETED(src) || record) - return - apply_dynamic_human_appearance(src, boss_kit_outfit(), /datum/species/human) +/** + * Packs what every boss carries into `outfit`, an instance: BOUNTY_BOSS_STIMULANTS combat stimulants + * and a bruise pack, in a bag on its back (one is added if it wears none). Returns the outfit. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_supplies(datum/outfit/outfit) + if(!istype(outfit)) + return outfit + outfit.back ||= /obj/item/storage/backpack + LAZYINITLIST(outfit.backpack_contents) + outfit.backpack_contents[BOUNTY_BOSS_STIMULANT] = (outfit.backpack_contents[BOUNTY_BOSS_STIMULANT] || 0) + BOUNTY_BOSS_STIMULANTS + outfit.backpack_contents[/obj/item/stack/medical/bruise_pack] = (outfit.backpack_contents[/obj/item/stack/medical/bruise_pack] || 0) + 1 + return outfit /// A line on examine about its gear, if the kit has one -/mob/living/basic/bounty_criminal/boss/proc/boss_gear_text() +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gear_text() return null /** * Says a line for "_", or for "_" if there is none for it. Async, so a * signal handler can call it. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_bark(suffix, fallback) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_bark(suffix, fallback) if(!boss_kit || stat != CONSCIOUS) return INVOKE_ASYNC(src, PROC_REF(boss_bark_now), suffix, fallback) -/mob/living/basic/bounty_criminal/boss/proc/boss_bark_now(suffix, fallback) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_bark_now(suffix, fallback) if(QDELETED(src) || stat != CONSCIOUS) return FALSE if(bounty_say("[boss_kit]_[suffix]")) return TRUE return fallback ? bounty_say("[boss_kit]_[fallback]") : FALSE -// ----- state ----- +// ----- state, all tg's ----- -/// Downed: P2's state, read from the shared var too so a test can set it -/mob/living/basic/bounty_criminal/boss/proc/boss_is_downed() - return downed || is_downed() - -/// Restrained: P2's state, read from the shared var too so a test can set it -/mob/living/basic/bounty_criminal/boss/proc/boss_is_restrained() - return !isnull(restraints) || is_restrained() - -/// Whether it can act at all: alive and awake, not downed, restrained, given up, stunned or knocked down -/mob/living/basic/bounty_criminal/boss/proc/boss_can_act() +/// Whether it can act at all: alive and awake, not in crit, restrained, stunned, knocked down or in stamina crit +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_can_act() if(QDELETED(src) || stat != CONSCIOUS || !isturf(loc)) return FALSE - if(boss_is_downed() || boss_is_restrained() || HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED)) + if(is_downed() || is_restrained() || npc_surrendering) return FALSE return !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_FLOORED) -/// Whether it may start an ability now: able, not busy, and past the gap since the last one -/mob/living/basic/bounty_criminal/boss/proc/boss_can_use_ability() - return boss_can_act() && !boss_busy && world.time >= boss_next_ability_at - -/// Its cooldowns stretch once it tires -/mob/living/basic/bounty_criminal/boss/proc/boss_cooldown_mult() - return boss_tired ? BOUNTY_BOSS_TIRED_COOLDOWN_MULT : 1 - -/// Pulled by anyone once it is dead, downed or cuffed; hard to shift otherwise -/mob/living/basic/bounty_criminal/boss/proc/boss_update_hold() - move_resist = (stat == DEAD || boss_is_downed() || boss_is_restrained()) ? MOVE_RESIST_DEFAULT : initial(move_resist) +/// Whether it may start an ability now: able, not busy, not falling back, and past the gap since the last one +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_can_use_ability() + return boss_can_act() && !boss_busy && world.time >= boss_next_ability_at && world.time >= boss_fallback_until /** - * Tired at or below BOUNTY_BOSS_TIRED_BELOW percent health: stamina weapons work fully, stuns and - * knockdowns land, it moves slower and its cooldowns stretch. Healed back above the line, it is - * fresh again. Returns TRUE if that changed. P2's rally calls it with `force` when the rally ends. - * - * On tiring, its stamina loss starts again from zero, so the capture takes its whole pool - * (BOUNTY_BOSS_STAMINA: five disabler hits) whatever was thrown at it while it was fresh (H3). + * Worn out, read from its body (CB-2): stamina loss over BOUNTY_BOSS_WORN_OUT_STAMINA of its pool, or + * health under BOUNTY_BOSS_WORN_OUT_HEALTH of its max. tg's own slowdown from both does the rest. */ -/mob/living/basic/bounty_criminal/boss/boss_update_tired(silent = FALSE, force = FALSE) - var/now_tired = stat != DEAD && maxHealth > 0 && health <= maxHealth * BOUNTY_BOSS_TIRED_BELOW / 100 - if(now_tired == boss_tired && !force) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_is_worn_out() + if(stat == DEAD) return FALSE - var/was_tired = boss_tired - boss_tired = now_tired - damage_coeff[STAMINA] = boss_stamina_coefficient() - if(boss_tired && !was_tired && staminaloss > 0) - adjustStaminaLoss(-staminaloss, forced = TRUE) - status_flags &= ~(CANSTUN | CANKNOCKDOWN) - if(boss_tired) - status_flags |= CANSTUN | CANKNOCKDOWN - set_varspeed(boss_base_speed + (boss_tired ? BOUNTY_BOSS_TIRED_SLOWDOWN : 0)) - if(boss_tired && !silent) - visible_message(span_warning("[src] is breathing hard and slowing down. [p_They()] look[p_s()] worn out.")) - // The stun window is open: the balloon says so where the fight is, not only in chat (P4 polish) - body_alert_hunters("worn out") - boss_bark("exhausted") - return TRUE + return getStaminaLoss() > max_stamina * BOUNTY_BOSS_WORN_OUT_STAMINA || health < maxHealth * BOUNTY_BOSS_WORN_OUT_HEALTH -/// Stamina damage coefficient now: the fresh or tired one, folded against its pool, as max_stamina stays 100 -/mob/living/basic/bounty_criminal/boss/proc/boss_stamina_coefficient() - var/coefficient = boss_tired ? BOUNTY_BOSS_STAMINA_TIRED : boss_stamina_fresh - return coefficient * max_stamina / max(boss_stamina_pool, 1) +/// It has just worn out: it says so where the fight is, drops what it was winding up and falls back for a while +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_wear_out() + boss_worn_out = TRUE + boss_second_wind_due = FALSE + boss_fallback_until = world.time + BOUNTY_BOSS_FALLBACK_TIME + boss_interrupt() + visible_message(span_warning("[src] is breathing hard and slowing down. [p_They()] look[p_s()] worn out.")) + body_alert_hunters("worn out") + boss_bark("exhausted") + +/// Whether its next plan should take a stimulant: the first when its fight starts, the second for its second wind +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_wants_stimulant() + if(!boss_hostile || boss_stimulants_used >= BOUNTY_BOSS_STIMULANTS) + return FALSE + return !boss_stimulants_used || boss_second_wind_due // ----- busy ----- /** - * Busy with `state`, its AI quiet. `immobile` keeps it standing still (a wind-up, a stagger); a - * charge or dash moves it itself. With a `duration` it ends on its own. Returns the serial a timer - * checks against, so a stale one does nothing. + * Busy with `state`, its AI quiet. `immobile` keeps it standing still (a wind-up, a burst); a charge or + * dash moves it itself. A wind-up is a telegraph: tg's stuns, knockdowns, stamcrit, crit and cuffs + * interrupt it (the bounty_boss_signals component, and boss_can_act() when it ends; CB-24). With a + * `duration` it ends on its own. Returns the serial a timer checks against, so a stale one does nothing. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_set_busy(state, duration, immobile = TRUE) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_set_busy(state, duration, immobile = TRUE) boss_busy = state boss_busy_serial++ deltimer(boss_busy_timer) @@ -318,7 +292,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return boss_busy_serial /// Free again, unless `serial` is stale -/mob/living/basic/bounty_criminal/boss/proc/boss_clear_busy(serial) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_clear_busy(serial) if(serial && serial != boss_busy_serial) return FALSE boss_busy = null @@ -328,11 +302,11 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return TRUE /// Whether it is still busy with `state` under `serial`: what a charge or burst checks before each step -/mob/living/basic/bounty_criminal/boss/proc/boss_still(state, serial) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_still(state, serial) return !QDELETED(src) && boss_busy == state && boss_busy_serial == serial && boss_can_act() /// Knocked out of whatever it was doing: a wind-up's telegraph goes, and nothing lands -/mob/living/basic/bounty_criminal/boss/proc/boss_interrupt() +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_interrupt() var/datum/action/cooldown/mob_cooldown/bounty_boss/winding = boss_winding_ref?.resolve() boss_winding_ref = null winding?.cancel_windup() @@ -341,23 +315,14 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) boss_on_interrupted() /// A kit's own clean-up when it is interrupted, downed or killed -/mob/living/basic/bounty_criminal/boss/proc/boss_on_interrupted() +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_on_interrupted() return -/// Reeling for `duration` (charged into a wall): still, quiet, and taking `vulnerable` times the damage. Not a stun, so it can't be cuffed for it. -/mob/living/basic/bounty_criminal/boss/proc/boss_stagger(duration, vulnerable = 1) - if(stat != CONSCIOUS) - return - boss_set_busy("stagger", duration) - Shake(2, 1, duration) - if(vulnerable != 1) - damage_coeff[BRUTE] = boss_brute_coeff * vulnerable - damage_coeff[BURN] = boss_burn_coeff * vulnerable - addtimer(CALLBACK(src, PROC_REF(boss_end_vulnerable)), duration, TIMER_UNIQUE | TIMER_OVERRIDE | TIMER_DELETE_ME) - -/mob/living/basic/bounty_criminal/boss/proc/boss_end_vulnerable() - damage_coeff[BRUTE] = boss_brute_coeff - damage_coeff[BURN] = boss_burn_coeff +/// The ability winding up now, if any +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_winding() + if(boss_busy != "windup") + return null + return boss_winding_ref?.resolve() // ----- abilities ----- @@ -366,7 +331,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * while another is ready. With BOUNTY_BOSS_STRICT_ROTATION (boss_strict_rotation) the last one is * never picked again straight away, even when it is the only one ready. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_choose_ability(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_choose_ability(atom/target) if(QDELETED(target) || !boss_can_use_ability()) return null var/list/options = list() @@ -383,7 +348,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return boss_strict_rotation ? null : last_one /// Starts `ability`'s wind-up. Returns the serial its timer checks, or 0 if it can't start now. -/mob/living/basic/bounty_criminal/boss/proc/boss_begin_ability(datum/action/cooldown/mob_cooldown/bounty_boss/ability) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_begin_ability(datum/action/cooldown/mob_cooldown/bounty_boss/ability) if(!boss_can_use_ability()) return 0 boss_last_ability = ability.type @@ -392,7 +357,11 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return boss_set_busy("windup", ability.windup + 1 SECONDS) /// The kit's way of fighting after abilities: keep range, break off. Returns TRUE if it queued something. -/mob/living/basic/bounty_criminal/boss/proc/boss_engage_plan(datum/ai_controller/controller, atom/target) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_engage_plan(datum/ai_controller/controller, atom/target) + return FALSE + +/// The kit's own gear to see to before it fights on (a Heavy's belt change). Returns TRUE if it queued something. +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gear_plan(datum/ai_controller/controller) return FALSE // ----- the posse and turning hostile ----- @@ -402,7 +371,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * count (boss_counts_for_posse()). The first time, everyone who counts within * BOUNTY_BOSS_POSSE_RANGE and can see it joins too, mech pilots included, and it says its intro line. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_turn_hostile(mob/living/instigator) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_turn_hostile(mob/living/instigator) if(stat == DEAD) return FALSE boss_calm_at = world.time + BOUNTY_BOSS_CALM_TIME @@ -418,12 +387,11 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) for(var/obj/vehicle/sealed/mecha/mech in view(BOUNTY_BOSS_POSSE_RANGE, src)) for(var/mob/living/pilot in mech.occupants) boss_engage(pilot, announce = FALSE) - boss_apply_posse_health(announce = FALSE) boss_bark("intro") return TRUE -/// With no hunter left on its site, it stops hunting and waits again. Its posse and health stay. -/mob/living/basic/bounty_criminal/boss/proc/boss_calm_down() +/// With no hunter left on its site, it stops hunting and waits again. Its posse stays. +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_calm_down() boss_hostile = FALSE ai_controller?.clear_blackboard_key(BB_BOUNTY_BOSS_PREY) @@ -432,7 +400,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * confronted or cuffed it), or one whose ship is hunting its bounty. Being seen or targeted alone * doesn't count. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_counts_for_posse(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_counts_for_posse(mob/living/hunter) if(!bounty_boss_is_hunter(hunter)) return FALSE if(body_has_grudge(hunter)) @@ -445,55 +413,109 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return TRUE return FALSE -/// Adds `hunter` to its posse if they count, raising its health for the bigger posse. Returns TRUE if they are new. -/mob/living/basic/bounty_criminal/boss/proc/boss_engage(mob/living/hunter, announce = TRUE) +/** + * Adds `hunter` to its posse if they count. Each hunter beyond the first owes it one more companion + * (OC-8); with `announce`, it sizes the bigger posse up out loud. Returns TRUE if they are new. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_engage(mob/living/hunter, announce = TRUE) if(stat == DEAD || !boss_counts_for_posse(hunter)) return FALSE var/key = REF(hunter) if(boss_engaged[key]) return FALSE boss_engaged[key] = TRUE - boss_apply_posse_health(announce) - return TRUE - -/** - * Max health for the posse it has: the kit's table entry for that many hunters (never more than the - * 4+ entry), raised and never lowered (M4). - * - Fresh, current health rises by the same step (spec: "raised, current health too"). - * - Tired, it keeps the same share of its health, so a newcomer never heals it out of its capture - * window. - * - Downed or cuffed, the raise waits; it is applied when it gets up (on_freed), keeping its share. - * Returns TRUE if max health rose. - */ -/mob/living/basic/bounty_criminal/boss/proc/boss_apply_posse_health(announce = TRUE) - if(stat == DEAD || boss_is_downed() || boss_is_restrained()) - return FALSE - var/list/table = boss_health_table() var/count = length(boss_engaged) - if(!length(table) || !count) - return FALSE - var/wanted = table[clamp(count, 1, length(table))] - var/raw_health = wanted - wanted *= npc_zone_health_factor - if(wanted <= maxHealth) - return FALSE - npc_zone_original_max_health = raw_health - var/share = maxHealth > 0 ? health / maxHealth : 1 - setMaxHealth(wanted) - if(boss_tired) - // Basic mobs keep all their damage as bruteloss. Not rounded, so it never lands over the tired line. - bruteloss = wanted * (1 - share) - updatehealth() - boss_update_tired(silent = TRUE) + boss_owe_reinforcements(count) if(announce && count > 1) visible_message(span_warning("[src] looks over the [count] of you and squares up.")) boss_bark("posse", "intro") return TRUE +/// A posse of `count` hunters owes one companion for each beyond the first, up to BOUNTY_BOSS_REINFORCEMENTS_MAX, called one at a time +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_owe_reinforcements(count) + var/wanted = clamp(count - 1, 0, BOUNTY_BOSS_REINFORCEMENTS_MAX) + var/more = wanted - boss_reinforcements_sent - boss_reinforcements_owed + if(more <= 0) + return + boss_reinforcements_owed += more + boss_schedule_reinforcement() + +/// The next companion comes after BOUNTY_BOSS_REINFORCE_DELAY, never two at once +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_schedule_reinforcement() + if(boss_reinforce_timer || boss_reinforcements_owed <= 0 || stat == DEAD) + return + boss_reinforce_timer = addtimer(CALLBACK(src, PROC_REF(boss_reinforce_tick)), BOUNTY_BOSS_REINFORCE_DELAY, TIMER_STOPPABLE | TIMER_DELETE_ME) + +/// Calls the next companion owed, from somewhere no player can see; tries again later if there is nowhere yet +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_reinforce_tick() + boss_reinforce_timer = null + if(QDELETED(src) || stat == DEAD || !boss_hostile || boss_reinforcements_owed <= 0) + return + var/turf/spot = boss_reinforcement_spot() + if(spot) + boss_send_reinforcement(spot) + boss_schedule_reinforcement() + +/** + * Calls one companion owed in at `where` (the criminal-AI package's bounty_spawn_companion(): a queued, + * throttled human NPC joined to it). Returns what that returns. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_send_reinforcement(turf/where) + if(boss_reinforcements_owed <= 0 || !isturf(where)) + return null + boss_reinforcements_owed-- + boss_reinforcements_sent++ + return bounty_spawn_companion(src, where, CALLBACK(src, PROC_REF(boss_reinforcement_arrived))) + +/// A companion it called is here: it goes after whoever the boss is fighting +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_reinforcement_arrived(mob/living/carbon/human/npc/companion) + if(QDELETED(src) || !ismob(companion) || QDELETED(companion)) + return + LAZYADD(boss_reinforcement_refs, WEAKREF(companion)) + var/atom/target = ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!QDELETED(target)) + companion.ai_controller?.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target) + +/** + * A spot for a companion to come in at: open ground on its site, BOUNTY_BOSS_REINFORCE_MIN_DIST to + * _MAX_DIST tiles off, where no player within BOUNTY_BOSS_REINFORCE_SIGHT can see. Null if there is none. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_reinforcement_spot() + var/turf/here = get_turf(src) + if(!here) + return null + var/list/watchers = list() + if(here.z <= length(SSmobs.clients_by_zlevel)) + for(var/mob/living/person as anything in SSmobs.clients_by_zlevel[here.z]) + if(person.stat != DEAD && get_dist(person, here) <= BOUNTY_BOSS_REINFORCE_MAX_DIST + BOUNTY_BOSS_REINFORCE_SIGHT) + watchers += person + var/list/candidates = list() + for(var/turf/open/spot in RANGE_TURFS(BOUNTY_BOSS_REINFORCE_MAX_DIST, here)) + if(get_dist(spot, here) < BOUNTY_BOSS_REINFORCE_MIN_DIST) + continue + if(isspaceturf(spot) || isgroundlessturf(spot) || islava(spot) || ischasm(spot) || spot.is_blocked_turf()) + continue + if(!leash_ok(spot) || !boss_reach_ok(spot)) + continue + candidates += spot + shuffle_inplace(candidates) + var/tries = 0 + for(var/turf/spot as anything in candidates) + if(++tries > 40) + break + var/seen = FALSE + for(var/mob/living/watcher as anything in watchers) + if(get_dist(watcher, spot) <= BOUNTY_BOSS_REINFORCE_SIGHT && can_see(watcher, spot, BOUNTY_BOSS_REINFORCE_SIGHT)) + seen = TRUE + break + if(!seen) + return spot + return null + // ----- targets ----- /// The people behind `thing`: the person, or everyone in the mech -/mob/living/basic/bounty_criminal/boss/proc/boss_people_in(atom/thing) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_people_in(atom/thing) . = list() if(isliving(thing)) . += thing @@ -503,60 +525,65 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) . += pilot /// What to go after for `who`: the mech they sit in, or them -/mob/living/basic/bounty_criminal/boss/proc/boss_target_for(atom/who) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_target_for(atom/who) var/mob/living/person = who if(isliving(person) && ismecha(person.loc)) return person.loc return who -/// Whether it goes after `target`: a hunter, or a mech with a hunter in it, that it may hurt -/mob/living/basic/bounty_criminal/boss/proc/boss_valid_target(atom/target) - return boss_can_hurt(target) - /** - * Whether it and its abilities may hurt `victim` (C4, H2, M2, L1): - * - an awake hunter not on its side, or a mech with one in it (a pilot is hurt through the mech); - * - standing on its site, and never in a ship, a hangar or an outpost; - * - someone it may attack from where it is (P2's may_attack()). - * Never a criminal, one of their friends, fauna or anyone else on the site. + * Whether it chooses to go after `target` (C4, H2, M2, L1): an awake hunter not on its side, or a mech + * with one in it (a pilot is hurt through the mech); standing on its site, never in a ship, a hangar or + * an outpost; someone it may attack from where it is (P2's may_attack()). This is its choice of target + * only: what its fire and blasts catch is boss_effect_hits() (CB-11). */ -/mob/living/basic/bounty_criminal/boss/proc/boss_can_hurt(atom/victim) - if(QDELETED(victim) || victim == src) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_valid_target(atom/target) + if(QDELETED(target) || target == src) return FALSE - if(!boss_reach_ok(get_turf(victim))) + if(!boss_reach_ok(get_turf(target))) return FALSE - if(ismecha(victim)) - var/obj/vehicle/sealed/mecha/mech = victim + if(ismecha(target)) + var/obj/vehicle/sealed/mecha/mech = target return boss_hunter_aboard(mech) && may_attack(mech) - var/mob/living/person = victim + var/mob/living/person = target if(!isliving(person) || ismecha(person.loc) || !boss_hunter_awake(person)) return FALSE return may_attack(person) -/// An awake hunter not on its side -/mob/living/basic/bounty_criminal/boss/proc/boss_hunter_awake(mob/living/person) +/// An awake hunter not on its side: who it picks to fight. Never used for what its fire and blasts catch. +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_hunter_awake(mob/living/person) return bounty_boss_is_hunter(person) && person.stat == CONSCIOUS && !HAS_TRAIT(person, TRAIT_GODMODE) && !faction_check_atom(person) /// Whether an awake hunter not on its side sits in `mech` -/mob/living/basic/bounty_criminal/boss/proc/boss_hunter_aboard(obj/vehicle/sealed/mecha/mech) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_hunter_aboard(obj/vehicle/sealed/mecha/mech) for(var/mob/living/pilot in mech.occupants) if(boss_hunter_awake(pilot)) return TRUE return FALSE -/// Everyone on `turfs` its abilities may hurt: hunters, and mechs with hunters in them -/mob/living/basic/bounty_criminal/boss/proc/boss_victims_on(list/turfs) +/** + * Whether its fire, blasts, charge or slam catch `victim` where it stands: anywhere its abilities may + * reach (boss_reach_ok()), anyone not on its side, in whatever state (CB-11: down, cuffed or standing). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_effect_hits(atom/victim) + if(QDELETED(victim) || victim == src) + return FALSE + return boss_reach_ok(get_turf(victim)) && bounty_boss_effect_victim(victim, faction) + +/// Everyone on `turfs` its abilities catch: people not on its side, in any state, and mechs with one in them +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_victims_on(list/turfs) . = list() for(var/turf/spot as anything in turfs) for(var/atom/movable/thing as anything in spot) - if((isliving(thing) || ismecha(thing)) && boss_can_hurt(thing)) + if((isliving(thing) || ismecha(thing)) && boss_effect_hits(thing)) . += thing /** - * One hit from an ability on `victim`: `damage` of `damage_type` against `armour_flag` armour, and - * a knockdown. A mech takes it on its hull (H2). + * One scripted hit from an ability (a charge or a slam) on `victim`: `damage` of `damage_type` against + * `armour_flag` armour on the chest, and a knockdown, both through tg (CB-25). A mech takes it on its + * hull (H2). `already_scaled` is for the zone-damage helper, which leaves human bosses at a person's. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_hit(atom/victim, damage, damage_type = BRUTE, armour_flag = MELEE, knockdown = 0, armour_penetration = 0, already_scaled = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_hit(atom/victim, damage, damage_type = BRUTE, armour_flag = MELEE, knockdown = 0, armour_penetration = 0, already_scaled = FALSE) damage = npc_zone_damage(damage, src, already_scaled) if(ismecha(victim)) var/obj/vehicle/sealed/mecha/mech = victim @@ -572,7 +599,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return TRUE /// Whoever hurt it within BOUNTY_BOSS_REVENGE_TIME (their mech, if they were in one), if it may still go after them -/mob/living/basic/bounty_criminal/boss/proc/boss_revenge_target() +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_revenge_target() if(world.time > boss_last_attacked_at + BOUNTY_BOSS_REVENGE_TIME) return null var/atom/attacker = boss_target_for(boss_last_attacker?.resolve()) @@ -582,7 +609,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * `attacker` went for it: a person, or a mech and everyone in it (H2). The hunters behind it go on * its grudge list and into its posse, it turns hostile, and it remembers whom to turn on. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_note_attacker(atom/attacker) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_note_attacker(atom/attacker) if(stat == DEAD || QDELETED(attacker) || attacker == src) return FALSE var/list/hunters = list() @@ -599,7 +626,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return TRUE /// The nearest hunter (or their mech) still on its site within BOUNTY_BOSS_HUNT_RANGE, for the hunt. Only players with a client count, from SSmobs' list for its level. -/mob/living/basic/bounty_criminal/boss/proc/boss_nearest_hunter() +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_nearest_hunter() var/turf/here = get_turf(src) if(!here || here.z > length(SSmobs.clients_by_zlevel)) return null @@ -621,7 +648,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) // ----- where it may reach, and what it may break ----- /// Whether an ability's effect may reach `spot` (M2): on its site, and never in a ship (other than the one it is wanted aboard), a hangar or an outpost -/mob/living/basic/bounty_criminal/boss/proc/boss_reach_ok(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_reach_ok(turf/spot) spot = get_turf(spot) return !isnull(spot) && body_on_site(spot) && !body_forbidden_turf(spot) @@ -630,24 +657,24 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * P2's environment_damage_allowed(): its own site, never a trader outpost, and never within * BOUNTY_ENV_SAFE_RANGE tiles of a player ship, a hangar, a player outpost or a planet's dock strip. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_environment_allowed(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_environment_allowed(turf/spot) spot = get_turf(spot) if(!spot || spot.z != z) return FALSE return environment_damage_allowed(spot) /// Whether a thrown or fired ability may be aimed at `spot` (M2): somewhere it could walk itself, and allowed for environment damage -/mob/living/basic/bounty_criminal/boss/proc/boss_landing_ok(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_landing_ok(turf/spot) spot = get_turf(spot) return !isnull(spot) && leash_ok(spot) && boss_environment_allowed(spot) /// The air pressure on `spot`, in kPa -/mob/living/basic/bounty_criminal/boss/proc/boss_turf_pressure(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_turf_pressure(turf/spot) var/datum/gas_mixture/air = spot?.return_air() return air ? air.return_pressure() : 0 /// Whether `spot` holds an airlock with space, another ship or somewhere off its site on one side: an outer airlock -/mob/living/basic/bounty_criminal/boss/proc/boss_outer_airlock_turf(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_outer_airlock_turf(turf/spot) if(!(locate(/obj/machinery/door/airlock) in spot)) return FALSE for(var/direction in GLOB.cardinals) @@ -661,7 +688,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * another ship, a hangar or an outpost, with air in it. A ship's tile counts only if nothing around * it is space, off its site, another ship, or an outer airlock. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_indoor_turf(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_indoor_turf(turf/spot) if(!spot || isspaceturf(spot) || isgroundlessturf(spot) || islava(spot) || ischasm(spot)) return FALSE var/area/spot_area = get_area(spot) @@ -682,7 +709,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * than BOUNTY_BOSS_INTERIOR_MAX_PRESSURE_GAP between their pressures (L2). Never a hull, never an * outside wall, never a window onto space, the outdoors, a vacuum or another ship. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_interior_spot(turf/spot) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_interior_spot(turf/spot) if(!spot) return FALSE var/list/open_sides = list() @@ -712,15 +739,16 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /** * Whether its abilities may damage `thing`: allowed there (boss_environment_allowed(), and P2's - * may_attack(), which refuses structures off its site), indoors on both sides, and a kind it breaks - * (tables, chairs, windows, grilles, barricades; plain walls and doors only while its - * BOUNTY_BOSS_WALL_BUDGET lasts). Never anything indestructible or outpost property. + * may_attack_scope(), which refuses structures off its site), indoors on both sides, and a kind it + * breaks (tables, chairs, windows, grilles, barricades; plain walls and doors only while its + * BOUNTY_BOSS_WALL_BUDGET lasts). Never anything indestructible or outpost property. It is about the + * place and the thing, not about the boss's own state: a grenade already thrown still lands. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_may_break(atom/thing) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_may_break(atom/thing) if(QDELETED(thing)) return FALSE var/turf/spot = get_turf(thing) - if(!boss_environment_allowed(spot) || !may_attack(thing) || !boss_interior_spot(spot)) + if(!boss_environment_allowed(spot) || !may_attack_scope(thing) || !boss_interior_spot(spot)) return FALSE if(isturf(thing)) if(!iswallturf(thing) || istype(thing, /turf/closed/wall/r_wall) || isindestructiblewall(thing)) @@ -741,7 +769,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) * with its frame, so the way through really opens; either spends the fight's wall budget, as does a * door that damage alone destroys. Returns TRUE if it did damage. */ -/mob/living/basic/bounty_criminal/boss/proc/boss_damage_structure(atom/thing, damage, list/tally, damage_flag = MELEE) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_damage_structure(atom/thing, damage, list/tally, damage_flag = MELEE) if(length(tally) && tally[1] <= 0) return FALSE if(!boss_may_break(thing)) @@ -772,156 +800,112 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) boss_walls_left-- return TRUE -/// With its boss gone, whom its leftover fire or blast may still hurt: awake hunters, and mechs with one in them -/proc/bounty_boss_orphan_victim(atom/thing) - if(ismecha(thing)) - var/obj/vehicle/sealed/mecha/mech = thing - for(var/mob/living/pilot in mech.occupants) - if(bounty_boss_is_hunter(pilot) && pilot.stat == CONSCIOUS) - return TRUE - return FALSE - var/mob/living/person = thing - return isliving(person) && !ismecha(person.loc) && person.stat == CONSCIOUS && bounty_boss_is_hunter(person) - // ===== SIGNALS ===== /** * The boss's signal hooks, on a component of their own so they never replace a handler P2 or P3 - * registers on the same mob for the same signal. Each one hands over to a boss proc. + * registers on the same mob for the same signal. Each one hands over to a boss proc. Being disabled is + * read from tg's own state changes: crit, cuffs, stamcrit, stuns and knockdowns (CB-24). */ /datum/component/bounty_boss_signals /datum/component/bounty_boss_signals/Initialize() - if(!istype(parent, /mob/living/basic/bounty_criminal/boss)) + if(!istype(parent, /mob/living/carbon/human/npc/bounty_criminal/boss)) return COMPONENT_INCOMPATIBLE /datum/component/bounty_boss_signals/RegisterWithParent() - RegisterSignal(parent, COMSIG_LIVING_HEALTH_UPDATE, PROC_REF(on_health)) RegisterSignal(parent, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) RegisterSignal(parent, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), PROC_REF(on_target)) RegisterSignals(parent, list( - COMSIG_BOUNTY_CRIMINAL_DOWNED, - COMSIG_BOUNTY_CRIMINAL_RESTRAINED, - COMSIG_LIVING_ENTER_STAMCRIT, + SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), SIGNAL_ADDTRAIT(TRAIT_FLOORED), - SIGNAL_ADDTRAIT(TRAIT_BOUNTY_SURRENDERED), + COMSIG_LIVING_ENTER_STAMCRIT, ), PROC_REF(on_disabled)) - RegisterSignals(parent, list(COMSIG_BOUNTY_CRIMINAL_RECOVERED, COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED), PROC_REF(on_freed)) RegisterSignal(parent, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_prehit)) - RegisterSignal(parent, COMSIG_LIVING_CHECK_BLOCK, PROC_REF(on_check_block)) RegisterSignal(parent, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) RegisterSignal(parent, COMSIG_ATOM_ATTACKBY, PROC_REF(on_attackby)) RegisterSignal(parent, COMSIG_MOB_FLASHED, PROC_REF(on_flashed)) - RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(parent, COMSIG_MOB_ITEM_ATTACK, PROC_REF(on_item_attack)) /datum/component/bounty_boss_signals/UnregisterFromParent() UnregisterSignal(parent, list( - COMSIG_LIVING_HEALTH_UPDATE, COMSIG_ATOM_WAS_ATTACKED, COMSIG_AI_BLACKBOARD_KEY_SET(BB_BASIC_MOB_CURRENT_TARGET), - COMSIG_BOUNTY_CRIMINAL_DOWNED, - COMSIG_BOUNTY_CRIMINAL_RESTRAINED, - COMSIG_LIVING_ENTER_STAMCRIT, + SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), SIGNAL_ADDTRAIT(TRAIT_FLOORED), - SIGNAL_ADDTRAIT(TRAIT_BOUNTY_SURRENDERED), - COMSIG_BOUNTY_CRIMINAL_RECOVERED, - COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED, + COMSIG_LIVING_ENTER_STAMCRIT, COMSIG_PROJECTILE_PREHIT, - COMSIG_LIVING_CHECK_BLOCK, COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_ATOM_ATTACKBY, COMSIG_MOB_FLASHED, - COMSIG_MOVABLE_MOVED, + COMSIG_MOB_ITEM_ATTACK, )) -/datum/component/bounty_boss_signals/proc/on_health(mob/living/basic/bounty_criminal/boss/source) - SIGNAL_HANDLER - source.boss_update_tired() - // A person or a mech (relay_attackers reports mech melee with the mech as the attacker). -/datum/component/bounty_boss_signals/proc/on_attacked(mob/living/basic/bounty_criminal/boss/source, atom/attacker, attack_flags) +/datum/component/bounty_boss_signals/proc/on_attacked(mob/living/carbon/human/npc/bounty_criminal/boss/source, atom/attacker, attack_flags) SIGNAL_HANDLER if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK))) return source.boss_note_attacker(attacker) -/datum/component/bounty_boss_signals/proc/on_target(mob/living/basic/bounty_criminal/boss/source) +/datum/component/bounty_boss_signals/proc/on_target(mob/living/carbon/human/npc/bounty_criminal/boss/source) SIGNAL_HANDLER var/atom/target = source.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] for(var/mob/living/person as anything in source.boss_people_in(target)) if(bounty_boss_is_hunter(person)) source.boss_turn_hostile(person) -/datum/component/bounty_boss_signals/proc/on_disabled(mob/living/basic/bounty_criminal/boss/source) +/datum/component/bounty_boss_signals/proc/on_disabled(mob/living/carbon/human/npc/bounty_criminal/boss/source) SIGNAL_HANDLER source.boss_interrupt() - source.boss_update_hold() - -/datum/component/bounty_boss_signals/proc/on_freed(mob/living/basic/bounty_criminal/boss/source) - SIGNAL_HANDLER - source.boss_update_hold() - // Not forced: P2's rally may have just set the stamina coefficient for itself. - source.boss_update_tired(silent = TRUE) - // Hunters who joined while it was down or cuffed count now, keeping its share of health (M4). - source.boss_apply_posse_health(announce = FALSE) -/datum/component/bounty_boss_signals/proc/on_prehit(mob/living/basic/bounty_criminal/boss/source, obj/projectile/shot) +/datum/component/bounty_boss_signals/proc/on_prehit(mob/living/carbon/human/npc/bounty_criminal/boss/source, obj/projectile/shot) SIGNAL_HANDLER // relay_attackers drops shots whose firer isn't a mob, so a mech's gunfire is noted here (H2). if(ismecha(shot.firer) && shot.is_hostile_projectile()) source.boss_note_attacker(shot.firer) - return source.boss_projectile_prehit(shot) - -/datum/component/bounty_boss_signals/proc/on_check_block(mob/living/basic/bounty_criminal/boss/source, atom/hit_by, damage, attack_text, attack_type, armour_penetration, damage_type) - SIGNAL_HANDLER - return source.boss_check_block(hit_by, attack_text, attack_type) + return NONE -/datum/component/bounty_boss_signals/proc/on_damaged(mob/living/basic/bounty_criminal/boss/source, damage, damagetype) +/datum/component/bounty_boss_signals/proc/on_damaged(mob/living/carbon/human/npc/bounty_criminal/boss/source, damage, damagetype) SIGNAL_HANDLER if(damage > 0) source.boss_took_damage(damage, damagetype) -/datum/component/bounty_boss_signals/proc/on_attackby(mob/living/basic/bounty_criminal/boss/source, obj/item/attacking_item, mob/living/user) +/datum/component/bounty_boss_signals/proc/on_attackby(mob/living/carbon/human/npc/bounty_criminal/boss/source, obj/item/attacking_item, mob/living/user) SIGNAL_HANDLER var/obj/item/assembly/flash/flash = attacking_item if(istype(flash) && !flash.burnt_out && isliving(user)) source.boss_flashed(user) return NONE -/datum/component/bounty_boss_signals/proc/on_flashed(mob/living/basic/bounty_criminal/boss/source) +/datum/component/bounty_boss_signals/proc/on_flashed(mob/living/carbon/human/npc/bounty_criminal/boss/source) SIGNAL_HANDLER source.boss_flashed() -/datum/component/bounty_boss_signals/proc/on_moved(mob/living/basic/bounty_criminal/boss/source) +/datum/component/bounty_boss_signals/proc/on_item_attack(mob/living/carbon/human/npc/bounty_criminal/boss/source, mob/living/target, mob/living/user) SIGNAL_HANDLER - source.boss_moved() - -// Kit hooks for those signals. None of them may sleep. - -/// A projectile is about to hit it: return PROJECTILE_INTERRUPT_HIT_PHASE to dodge it -/mob/living/basic/bounty_criminal/boss/proc/boss_projectile_prehit(obj/projectile/shot) - SHOULD_NOT_SLEEP(TRUE) + if(user == source && isliving(target) && target != source) + source.boss_landed_blow(target, source.get_active_held_item()) return NONE -/// A blow or a thrown thing is about to land: return SUCCESSFUL_BLOCK to make it miss -/mob/living/basic/bounty_criminal/boss/proc/boss_check_block(atom/hit_by, attack_text, attack_type) - SHOULD_NOT_SLEEP(TRUE) - return NONE +// Kit hooks for those signals. None of them may sleep. /// Damage landed on it -/mob/living/basic/bounty_criminal/boss/proc/boss_took_damage(damage, damagetype) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_took_damage(damage, damagetype) SHOULD_NOT_SLEEP(TRUE) return /// Flashed, by a handheld flash (`user`) or a flash going off near it -/mob/living/basic/bounty_criminal/boss/proc/boss_flashed(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_flashed(mob/living/user) SHOULD_NOT_SLEEP(TRUE) return -/// It took a step -/mob/living/basic/bounty_criminal/boss/proc/boss_moved() +/// It swung `weapon` at `victim` with its own hand +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_landed_blow(mob/living/victim, obj/item/weapon) SHOULD_NOT_SLEEP(TRUE) return @@ -967,7 +951,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) . = ..() if(!.) return FALSE - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner return istype(boss) && boss.boss_can_use_ability() /// Whether it is worth using on `target` now: in range, in sight, and so on @@ -980,17 +964,17 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /// Whether a thrown or fired ability may be aimed at where `target` stands (M2) /datum/action/cooldown/mob_cooldown/bounty_boss/proc/landing_ok(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner return istype(boss) && boss.boss_landing_ok(get_turf(target)) /datum/action/cooldown/mob_cooldown/bounty_boss/Activate(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || QDELETED(target)) return FALSE var/serial = boss.boss_begin_ability(src) if(!serial) return FALSE - StartCooldown(cooldown_time * boss.boss_cooldown_mult()) + StartCooldown(cooldown_time) pending_serial = serial pending_target = WEAKREF(target) // Marks left lingering from the last use look after themselves. @@ -1012,7 +996,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) pending_serial = 0 pending_target = null clear_telegraphs() - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || boss.boss_busy != "windup" || boss.boss_busy_serial != serial || !boss.boss_can_act()) return FALSE boss.boss_winding_ref = null @@ -1066,7 +1050,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /// Damages `thing` as part of this use, within its cap and the fight's wall budget /datum/action/cooldown/mob_cooldown/bounty_boss/proc/break_thing(atom/thing, damage, damage_flag = MELEE) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss)) return FALSE if(!structure_tally) @@ -1147,10 +1131,13 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /** * A patch of a boss's fire: flames on `turfs` for `lifetime`, only where its boss's abilities may - * reach (never off its site or in a ship, hangar or outpost, M2). A hunter it may hurt who stands in - * them is set alight (/datum/status_effect/bounty_boss_burning) every second; a mech with a hunter - * in it takes the burn on its hull. Effect-only fire: no gas, no hotspots, nothing flammable set - * burning, no fire alarms. Capped at BOUNTY_BOSS_FIRE_POOL_CAP server-wide. + * reach (never off its site or in a ship, hangar or outpost, M2). The patch itself is scripted: it + * makes no gas and no hotspot and lights nothing, so it never spreads or sets off fire alarms (site + * safety). Whoever it catches burns for real (CB-8): `landing_damage` burn against fire armour where it + * first lands, then tg's own fire stacks and ignite_mob() (`new_landing_stacks` on landing, + * BOUNTY_BOSS_FIRE_TICK_STACKS every second they stand in it), whatever state they are in. From then on + * it is tg's fire on them: firesuits, stop-drop-and-roll, extinguishers, showers and wet clothes all + * work. A mech with someone in it takes the burn on its hull. Capped at BOUNTY_BOSS_FIRE_POOL_CAP. */ /obj/effect/bounty_boss_fire_pool name = "fire" @@ -1162,16 +1149,23 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) light_color = LIGHT_COLOR_FIRE /// The tiles burning var/list/turfs - /// The boss whose fire it is, for who it may hurt + /// The boss whose fire it is, for who it catches var/datum/weakref/boss_ref + /// Its boss's factions when it was lit: its fire never catches its own side, even with the boss gone + var/list/side_factions + /// Its boss's zone damage factor when it was lit (1 for a human boss) var/damage_factor = 1 + /// Fire stacks it puts on whoever it catches where it first lands + var/landing_stacks = BOUNTY_BOSS_FIRE_TICK_STACKS -/obj/effect/bounty_boss_fire_pool/Initialize(mapload, list/fire_turfs, lifetime, mob/living/basic/bounty_criminal/boss/boss, landing_damage = 0) +/obj/effect/bounty_boss_fire_pool/Initialize(mapload, list/fire_turfs, lifetime, mob/living/carbon/human/npc/bounty_criminal/boss/boss, landing_damage = 0, new_landing_stacks) . = ..() if(length(GLOB.bounty_boss_fire_pools) >= BOUNTY_BOSS_FIRE_POOL_CAP) return INITIALIZE_HINT_QDEL GLOB.bounty_boss_fire_pools += src lifetime = lifetime || 4 SECONDS + if(!isnull(new_landing_stacks)) + landing_stacks = new_landing_stacks turfs = list() for(var/turf/spot as anything in fire_turfs) if(isturf(spot) && !isclosedturf(spot) && (!boss || boss.boss_reach_ok(spot))) @@ -1179,6 +1173,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) if(!length(turfs)) return INITIALIZE_HINT_QDEL boss_ref = boss ? WEAKREF(boss) : null + side_factions = boss ? boss.faction.Copy() : null damage_factor = boss ? boss.get_npc_zone_damage_factor() : 1 // Invisible, but the light comes from here: sit it where the fire is. var/turf/middle = turfs[round((length(turfs) + 1) / 2)] @@ -1186,7 +1181,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) forceMove(middle) for(var/turf/spot as anything in turfs) new /obj/effect/temp_visual/bounty_boss_flames(spot, lifetime) - scorch(landing_damage) + scorch(landing_damage, landing_stacks) addtimer(CALLBACK(src, PROC_REF(scorch)), 1 SECONDS, TIMER_LOOP | TIMER_DELETE_ME) addtimer(CALLBACK(src, PROC_REF(burn_out)), lifetime, TIMER_DELETE_ME) @@ -1194,111 +1189,96 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) GLOB.bounty_boss_fire_pools -= src turfs = null boss_ref = null + side_factions = null return ..() -/// Whether it may burn `victim`: its boss's rules, or with the boss gone, awake hunters and their mechs +/// Whether it catches `victim`: its boss's rules, or with the boss gone, anyone not on the boss's side. Never by the victim's state (CB-11). /obj/effect/bounty_boss_fire_pool/proc/may_burn(atom/victim) - var/mob/living/basic/bounty_criminal/boss/boss = boss_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = boss_ref?.resolve() if(!QDELETED(boss)) - return boss.boss_can_hurt(victim) - return bounty_boss_orphan_victim(victim) + return boss.boss_effect_hits(victim) + return bounty_boss_effect_victim(victim, side_factions) -/// Sets alight everyone it may burn in the flames, with `damage` burn first if given; mechs take the burn on the hull -/obj/effect/bounty_boss_fire_pool/proc/scorch(damage = 0) +/** + * Catches everyone in the flames: `damage` burn against fire armour if given (the jet or the bottle + * landing), then `stacks` of tg's fire stacks and ignite_mob(). Mechs take the burn on the hull. + */ +/obj/effect/bounty_boss_fire_pool/proc/scorch(damage = 0, stacks = BOUNTY_BOSS_FIRE_TICK_STACKS) for(var/turf/spot as anything in turfs) for(var/atom/movable/thing as anything in spot) if(!(isliving(thing) || ismecha(thing)) || !may_burn(thing)) continue if(ismecha(thing)) var/obj/vehicle/sealed/mecha/mech = thing - mech.take_damage(npc_zone_damage(max(damage, BOUNTY_BOSS_BURNING_DAMAGE), damage_factor), BURN, FIRE, FALSE) + mech.take_damage(npc_zone_damage(max(damage, 3), damage_factor), BURN, FIRE, FALSE) continue var/mob/living/victim = thing if(damage > 0) victim.apply_damage(npc_zone_damage(damage, damage_factor), BURN, blocked = victim.run_armor_check(null, FIRE, silent = TRUE), spread_damage = TRUE) to_chat(victim, span_userdanger("You're caught in the flames!")) - victim.apply_status_effect(/datum/status_effect/bounty_boss_burning, damage_factor) + if(stacks > 0) + victim.adjust_fire_stacks(stacks) + victim.ignite_mob() /obj/effect/bounty_boss_fire_pool/proc/burn_out() qdel(src) -/** - * Set alight by a boss's fire: BOUNTY_BOSS_BURNING_DAMAGE burn a second, against fire armour, for - * BOUNTY_BOSS_BURNING_TIME, renewed while standing in the flames. It goes out once they are down - * (C4, M3). Scripted: it never lights the air or anything else. - */ -/datum/status_effect/bounty_boss_burning - id = "bounty_boss_burning" - duration = BOUNTY_BOSS_BURNING_TIME - tick_interval = 1 SECONDS - status_type = STATUS_EFFECT_REFRESH - alert_type = null - /// The flames on them - var/mutable_appearance/flames - var/damage_factor = 1 - -/datum/status_effect/bounty_boss_burning/on_creation(mob/living/new_owner, factor = 1) - damage_factor = factor - return ..() - -/datum/status_effect/bounty_boss_burning/refresh(mob/living/new_owner, factor = 1) - damage_factor = factor - return ..() - -/datum/status_effect/bounty_boss_burning/on_apply() - if(owner.stat != CONSCIOUS || HAS_TRAIT(owner, TRAIT_NOFIRE)) - return FALSE - flames = mutable_appearance('icons/mob/effects/onfire.dmi', ishuman(owner) ? "human_small_fire" : "generic_fire", -HIGHEST_LAYER, appearance_flags = RESET_COLOR | KEEP_APART) - owner.add_overlay(flames) - return TRUE - -/datum/status_effect/bounty_boss_burning/tick(seconds_between_ticks) - if(owner.stat != CONSCIOUS) - qdel(src) - return - owner.apply_damage(npc_zone_damage(BOUNTY_BOSS_BURNING_DAMAGE, damage_factor), BURN, blocked = owner.run_armor_check(null, FIRE, silent = TRUE), spread_damage = TRUE) - -/datum/status_effect/bounty_boss_burning/on_remove() - if(flames) - owner.cut_overlay(flames) - flames = null - // ===== THE MIND ===== /** - * Its planning: nothing while it is busy or can't act; its target; an ability; the kit's way of - * closing in or keeping away; its melee attack; and with nobody in sight, the hunt for the nearest - * hunter still on its site. - * - * TODO(P3 integration): if start_activity() needs a planning subtree to run, add it to the end of - * this list, so a boss waiting for hunters does its activity. + * Its planning, on a real human body (contract 2.4): soft crit and breaking free first; nothing while + * it is busy or can't act; worn out, it falls back; its stimulant; its target; its kit's gear; drawing + * its weapon once its fight has started; an ability; the kit's way of closing in or keeping away; its + * melee with what it holds; patching itself up with nobody about; and with nobody in sight, the hunt + * for the nearest hunter still on its site. Bosses never surrender. */ /datum/ai_controller/basic_controller/bounty_boss blackboard = list( - BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/bounty_boss, - BB_TARGET_MINIMUM_STAT = CONSCIOUS, + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/human_npc/bounty_boss, + BB_TARGET_MINIMUM_STAT = HUMAN_NPC_SPARES_DOWNED, BB_AGGRO_RANGE = BOUNTY_BOSS_POSSE_RANGE, ) - ai_movement = /datum/ai_movement/jps + ai_movement = /datum/ai_movement/jps/bounty idle_behavior = null planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, /datum/ai_planning_subtree/bounty_boss_busy, + /datum/ai_planning_subtree/bounty_boss_worn_out, + /datum/ai_planning_subtree/bounty_boss_stimulant, /datum/ai_planning_subtree/bounty_boss_target, /datum/ai_planning_subtree/simple_find_target, + /datum/ai_planning_subtree/bounty_boss_gear, + /datum/ai_planning_subtree/human_npc_arm/bounty_boss, /datum/ai_planning_subtree/bounty_boss_abilities, /datum/ai_planning_subtree/bounty_boss_engage, - /datum/ai_planning_subtree/basic_melee_attack_subtree, + /datum/ai_planning_subtree/human_npc_melee, + /datum/ai_planning_subtree/bounty_treat_self, /datum/ai_planning_subtree/bounty_boss_hunt, ) +/datum/ai_controller/basic_controller/bounty_boss/TryPossessPawn(atom/new_pawn) + if(!ishumannpc(new_pawn)) + return AI_CONTROLLER_INCOMPATIBLE + return ..() + +// Cuffs and soft crit don't stop it (the human NPC base's run check), so it can work at its cuffs and crawl. +/datum/ai_controller/basic_controller/bounty_boss/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/bounty_boss/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) + /** - * Hunters it may hurt, and mechs with one in them (H2); never anyone already down (C4). tg's own mech - * branch would test the pilot, whom it only hurts through the mech, so a mech is judged here. + * Hunters it may go after, and mechs with one in them (H2); never anyone already down (C4, + * npc_target_stat). tg's own mech branch would test the pilot, whom it only hurts through the mech, so + * a mech is judged here. */ -/datum/targeting_strategy/basic/bounty_boss +/datum/targeting_strategy/basic/human_npc/bounty_boss -/datum/targeting_strategy/basic/bounty_boss/can_attack(mob/living/living_mob, atom/the_target, vision_range) - var/mob/living/basic/bounty_criminal/boss/boss = living_mob +/datum/targeting_strategy/basic/human_npc/bounty_boss/can_attack(mob/living/living_mob, atom/the_target, vision_range) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = living_mob if(!istype(boss) || !boss.boss_valid_target(the_target)) return FALSE if(ismecha(the_target)) @@ -1306,19 +1286,81 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return isturf(boss.loc) && the_target.z == boss.z && get_dist(boss, the_target) <= range && can_see(boss, the_target, range) return ..() -/// Nothing at all while it winds up, charges or reels, or while it can't act +/// Nothing at all while it winds up, charges or bursts, or while it can't act (tg's states) /datum/ai_planning_subtree/bounty_boss_busy /datum/ai_planning_subtree/bounty_boss_busy/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn if(!istype(boss) || boss.boss_busy || !boss.boss_can_act()) return SUBTREE_RETURN_FINISH_PLANNING +/** + * Worn out (boss_is_worn_out(), its body's real state): the first time, it says so and shows it to + * the hunters (boss_wear_out()); then it falls back from whoever it was fighting for + * BOUNTY_BOSS_FALLBACK_TIME, and after that its second wind is due (bounty_boss_stimulant). Back above + * the line (healed, or its stamina back), it is fresh again. + */ +/datum/ai_planning_subtree/bounty_boss_worn_out + +/datum/ai_planning_subtree/bounty_boss_worn_out/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + if(!istype(boss) || !boss.boss_hostile) + return + if(!boss.boss_is_worn_out()) + boss.boss_worn_out = FALSE + boss.boss_fallback_until = 0 + return + if(!boss.boss_worn_out) + boss.boss_wear_out() + if(world.time >= boss.boss_fallback_until) + if(boss.boss_fallback_until && boss.boss_stimulants_used < BOUNTY_BOSS_STIMULANTS) + boss.boss_second_wind_due = TRUE + boss.boss_fallback_until = 0 + return + var/atom/threat = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] || boss.boss_revenge_target() + if(QDELETED(threat)) + return + controller.set_blackboard_key(BB_BOUNTY_BOSS_FALL_BACK_FROM, threat) + controller.queue_behavior(/datum/ai_behavior/run_away_from_target, BB_BOUNTY_BOSS_FALL_BACK_FROM) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * Its combat stimulant (defaults 1.6, CR-4): the first pen as its fight starts, the second for its + * second wind once a fall back is over, each a real pen it carries, used on itself the player's way + * through the core's bounty_ai_use_injector() (a visible emote first). The second comes with its + * "recover" line ("Round two."). While a pen is going in, it keeps at it. + */ +/datum/ai_planning_subtree/bounty_boss_stimulant + +/datum/ai_planning_subtree/bounty_boss_stimulant/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + if(!istype(boss) || !boss.boss_hostile) + return + var/obj/item/reagent_containers/hypospray/medipen/using = controller.blackboard[BB_HUMAN_NPC_TREAT_ITEM] + if(istype(using) && !QDELETED(using) && !using.used_up && bounty_ai_is_stimulant(using)) + controller.queue_behavior(/datum/ai_behavior/human_npc_treat_self, BB_HUMAN_NPC_TREAT_ITEM) + return SUBTREE_RETURN_FINISH_PLANNING + if(!boss.boss_wants_stimulant()) + return + var/obj/item/reagent_containers/hypospray/medipen/pen = bounty_ai_find_injector(boss, BOUNTY_BOSS_STIMULANT) + if(isnull(pen)) + // Nothing left to take: it fights on as it is. + boss.boss_stimulants_used = BOUNTY_BOSS_STIMULANTS + boss.boss_second_wind_due = FALSE + return + if(!bounty_ai_use_injector(controller, pen)) + return + boss.boss_stimulants_used++ + if(boss.boss_second_wind_due) + boss.boss_second_wind_due = FALSE + INVOKE_ASYNC(boss, TYPE_PROC_REF(/mob/living, bounty_say), "recover", null, TRUE) + return SUBTREE_RETURN_FINISH_PLANNING + /// Drops a target it may no longer go after, and turns on whoever hurt it last when it has none /datum/ai_planning_subtree/bounty_boss_target /datum/ai_planning_subtree/bounty_boss_target/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[BB_TARGETING_STRATEGY]) var/atom/current = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] if(current && !strategy?.can_attack(boss, current, BOUNTY_BOSS_POSSE_RANGE)) @@ -1330,11 +1372,30 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) if(avenge && strategy?.can_attack(boss, avenge, BOUNTY_BOSS_POSSE_RANGE)) controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, avenge) +/// The kit's own gear, once its fight is on: a Heavy changing the belt in its gun +/datum/ai_planning_subtree/bounty_boss_gear + +/datum/ai_planning_subtree/bounty_boss_gear/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + if(!istype(boss) || !boss.boss_hostile) + return + if(boss.boss_gear_plan(controller)) + return SUBTREE_RETURN_FINISH_PLANNING + +/// The human NPC base's arming (reload or draw what it carries), only once its fight has started: until then its weapon stays where it carries it +/datum/ai_planning_subtree/human_npc_arm/bounty_boss + +/datum/ai_planning_subtree/human_npc_arm/bounty_boss/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + if(!istype(boss) || !boss.boss_hostile) + return + return ..() + /// One ability, if one is ready, worth it, and not the one it used last /datum/ai_planning_subtree/bounty_boss_abilities /datum/ai_planning_subtree/bounty_boss_abilities/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] if(QDELETED(target)) return @@ -1349,7 +1410,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /datum/ai_behavior/bounty_boss_ability /datum/ai_behavior/bounty_boss_ability/perform(seconds_per_tick, datum/ai_controller/controller, ability_key, target_key) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn var/datum/action/cooldown/mob_cooldown/bounty_boss/ability = controller.blackboard[ability_key] var/atom/target = controller.blackboard[target_key] if(QDELETED(ability) || QDELETED(target)) @@ -1366,13 +1427,34 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /datum/ai_planning_subtree/bounty_boss_engage /datum/ai_planning_subtree/bounty_boss_engage/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] if(QDELETED(target)) return if(boss.boss_engage_plan(controller, target)) return SUBTREE_RETURN_FINISH_PLANNING +/** + * One real step away from `target_key`'s target, onto ground its leash allows (P2's + * body_step_away_from()): tg's step_away walks on a movement that knows nothing of the leash. + */ +/datum/ai_behavior/bounty_boss_back_off + action_cooldown = 0.3 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +// No quicker than its body walks: armour, wounds and stamina slow it as they slow anyone. +/datum/ai_behavior/bounty_boss_back_off/get_cooldown(datum/ai_controller/cooldown_for) + var/mob/living/pawn = cooldown_for?.pawn + return max(action_cooldown, pawn?.cached_multiplicative_slowdown || 0) + +/datum/ai_behavior/bounty_boss_back_off/perform(seconds_per_tick, datum/ai_controller/controller, target_key) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + var/atom/target = controller.blackboard[target_key] + if(!istype(boss) || QDELETED(target) || !boss.boss_can_act()) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + boss.face_atom(target) + return boss.body_step_away_from(target) ? (AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED) : (AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED) + /** * Nobody in sight while hostile: it goes after the nearest hunter still on its site, looking again * every BOUNTY_BOSS_HUNT_INTERVAL. With nobody on the site for BOUNTY_BOSS_CALM_TIME it calms down. @@ -1380,7 +1462,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /datum/ai_planning_subtree/bounty_boss_hunt /datum/ai_planning_subtree/bounty_boss_hunt/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_criminal/boss/boss = controller.pawn + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn if(!boss.boss_hostile || controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) return var/atom/prey = controller.blackboard[BB_BOUNTY_BOSS_PREY] @@ -1401,7 +1483,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) // ===== LOOKS ===== -/// A mini-boss's look before its kit's: plain dark clothes +/// A mini-boss's clothes before its kit's: plain dark clothes /datum/outfit/bounty_boss name = "Bounty mini-boss" uniform = /obj/item/clothing/under/color/black diff --git a/voidcrew/modules/bounties/bounty_boss_kits.dm b/voidcrew/modules/bounties/bounty_boss_kits.dm index 22bfcd86bb2..259fa3f5a1b 100644 --- a/voidcrew/modules/bounties/bounty_boss_kits.dm +++ b/voidcrew/modules/bounties/bounty_boss_kits.dm @@ -4,22 +4,28 @@ * Owner: P4 bosses. Do not edit this file outside P4. * * What it owns: the Juggernaut, Pyromaniac, Demolitionist, Ghost and Heavy - * (/mob/living/basic/bounty_criminal/boss/, BOUNTY_KIT_*): their health, armour, weapon and - * two abilities each (spec section 5, 12.3 C8). No kit drops real loot: the guns, grenades and - * flamers are scripted, and the gear exists only in the look. + * (/mob/living/carbon/human/npc/bounty_criminal/boss/, BOUNTY_KIT_*): what each wears and carries, + * and its two abilities (spec section 5, 12.3 C8). Every kit is a real human in real gear, locked to its + * body (D3): its armour protects by tg's rules and its weapon is a real item it fights with. Its + * stimulants and bruise pack are packed by boss_supplies(). Nothing it carries is ever loot. * - * - Juggernaut: riot plates. Charges in a line, smashing tables, chairs and plain windows and - * tearing through an interior wall; bounces off anything else and reels. Slams the floor around - * it when someone is close. - * - Pyromaniac: a fire suit and a flamer. Sweeps a cone of fire, and throws molotovs that leave a - * burning patch. Fire can't hurt it; lasers can. - * - Demolitionist: a bandolier of grenades. Keeps its distance and lobs grenades with a visible - * fuse, and blows a hole through an interior wall or door to get at someone behind cover. - * - Ghost: a dark cloak and a knife. Dashes in along a line, cuts that bleed, cloaks into a faint - * shimmer that a flash or a hit breaks, dodges shots while moving, and backs off after a couple of + * - Juggernaut: riot armour and a body grown huge (tg's gigantism gene). Charges in a line, smashing + * tables, chairs and plain windows and tearing through an interior wall; running into anything else + * hurts it and knocks it down, as running into a wall does anyone (CB-6). Slams the floor around it + * when someone is close. Fights with its fists. + * - Pyromaniac: a real firefighter suit and helmet (tg's thermal protection, CB-7) and a lit torch. + * Sweeps a cone of fire and throws molotovs that leave a burning patch; whoever it catches burns with + * tg's fire stacks (CB-8). + * - Demolitionist: a hard hat, a hi-vis vest, a bandolier and a crowbar in its bag. Keeps its distance + * and lobs grenades with a visible fuse, and blows a hole through an interior wall or door to get at + * someone behind cover. Whoever a blast catches takes tg's light explosion (CB-10). + * - Ghost: a dark hood, a real combat knife and real chrome (Slipwire, Cascade; the OC-6 ruling). + * Dashes in along a line with its Cascade lattice firing and stabs at the end of it (CB-9); fades + * into a shimmer that is only a look, broken by a hit or a flash (CB-12); backs off after a couple of * cuts. - * - Heavy: heavy armour and a belt-fed gun. A laser sight and a spin-up, then a burst into a cone; - * drops a folding barrier for cover. Slow. + * - Heavy: SWAT armour and a real L6 with a spare box. A laser sight and a spin-up, then a burst of + * real rounds into a cone (a hit while it aims spoils the shot); throws real barrier grenades for + * cover (CB-26); changes its own belts. Slow. * * The shared vars are declared in bounty_types.dm and nowhere else; a var only P4 uses goes here * with a `boss_` prefix, or on the /boss subtypes. @@ -27,39 +33,24 @@ // ===== JUGGERNAUT ===== -/mob/living/basic/bounty_criminal/boss/juggernaut +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut name = "armoured criminal" desc = "A huge criminal in riot plates, built like a door." - maxHealth = BOUNTY_JUGGERNAUT_HEALTH_1 - health = BOUNTY_JUGGERNAUT_HEALTH_1 - mob_size = MOB_SIZE_LARGE - speed = BOUNTY_JUGGERNAUT_SPEED - damage_coeff = list(BRUTE = BOUNTY_JUGGERNAUT_BRUTE_MOD, BURN = BOUNTY_JUGGERNAUT_BURN_MOD, TOX = 1, STAMINA = BOUNTY_BOSS_STAMINA_FRESH * 100 / BOUNTY_BOSS_STAMINA, OXY = 1) - melee_damage_lower = BOUNTY_JUGGERNAUT_MELEE_MIN - melee_damage_upper = BOUNTY_JUGGERNAUT_MELEE_MAX - armour_penetration = BOUNTY_JUGGERNAUT_MELEE_AP - melee_attack_cooldown = BOUNTY_JUGGERNAUT_MELEE_COOLDOWN - attack_verb_continuous = "pounds" - attack_verb_simple = "pound" - attack_sound = 'sound/effects/hit_punch.ogg' - attack_vis_effect = ATTACK_EFFECT_SMASH boss_kit = BOUNTY_KIT_JUGGERNAUT boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/charge, /datum/action/cooldown/mob_cooldown/bounty_boss/slam, ) -/mob/living/basic/bounty_criminal/boss/juggernaut/Initialize(mapload) +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/Initialize(mapload, datum/human_npc_order/order) . = ..() - update_transform(1.15) + // Its size is a real gene (CB-21, PI-21): mutadone shrinks it back like anyone else. + dna?.add_mutation(/datum/mutation/gigantism, MUTATION_SOURCE_ACTIVATED) -/mob/living/basic/bounty_criminal/boss/juggernaut/boss_health_table() - return list(BOUNTY_JUGGERNAUT_HEALTH_1, BOUNTY_JUGGERNAUT_HEALTH_2, BOUNTY_JUGGERNAUT_HEALTH_3, BOUNTY_JUGGERNAUT_HEALTH_4) +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/juggernaut)) -/mob/living/basic/bounty_criminal/boss/juggernaut/boss_kit_outfit() - return /datum/outfit/bounty_boss/juggernaut - -/mob/living/basic/bounty_criminal/boss/juggernaut/boss_gear_text() +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss_gear_text() return "Riot plates cover [p_them()] from neck to boots." /datum/outfit/bounty_boss/juggernaut @@ -71,11 +62,11 @@ shoes = /obj/item/clothing/shoes/jackboots /** - * Charge: a roar, a stamp and a line on the floor, then a run down that line. The first hunter in + * Charge: a roar, a stamp and a line on the floor, then a run down that line. The first person in * the way is slammed and knocked down (anyone else in the way only BOUNTY_JUGGERNAUT_CHARGE_BYSTANDER * percent of the time). Tables, chairs, plain windows and grilles on the line are smashed. With * nobody caught, it tears through an interior wall at the end of the line if its budget allows; - * anything else stops it dead, reeling and open to damage. + * anything else stops it dead, hurt and on the floor (bounce()). */ /datum/action/cooldown/mob_cooldown/bounty_boss/charge name = "Charge" @@ -131,7 +122,7 @@ mark_turfs(path, "#c8a060") /datum/action/cooldown/mob_cooldown/bounty_boss/charge/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || !length(path)) path = null return @@ -145,7 +136,7 @@ /// One tile of the charge /datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/charge_step(serial) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || !boss.boss_still("charge", serial)) end_charge(serial) return @@ -158,9 +149,9 @@ if(!boss.leash_ok(next) || isspaceturf(next) || isgroundlessturf(next) || islava(next) || ischasm(next)) end_charge(serial) return - // Someone in the way: a hunter, or a mech with one in it, is slammed rather than bounced off (H2). + // Someone in the way: a person, or a mech with one in it, is slammed rather than bounced off (H2). for(var/atom/movable/thing as anything in next) - if(!thing.density || !(isliving(thing) || ismecha(thing)) || !boss.boss_can_hurt(thing)) + if(!thing.density || !(isliving(thing) || ismecha(thing)) || !boss.boss_effect_hits(thing)) continue if(thing == charge_target?.resolve() || prob(BOUNTY_JUGGERNAUT_CHARGE_BYSTANDER)) slam_into(thing) @@ -217,8 +208,8 @@ playsound(spot, is_window ? 'sound/effects/glass/glassbr1.ogg' : 'sound/effects/woodhit.ogg', 70, TRUE) return QDELETED(thing) || !thing.density -/// Steps onto `next`; a plain directional window on its own tile facing that way is smashed first. Returns TRUE if it moved. -/datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/try_step(mob/living/basic/bounty_criminal/boss/boss, turf/next) +/// Steps onto `next` (its leash checked by charge_step()); a plain directional window on its own tile facing that way is smashed first. Returns TRUE if it moved. +/datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/try_step(mob/living/carbon/human/npc/bounty_criminal/boss/boss, turf/next) var/direction = get_dir(boss, next) if(boss.Move(next, direction)) return TRUE @@ -233,34 +224,43 @@ return FALSE return boss.Move(next, direction) -/// Slams into `victim`, a hunter or a mech: brute through armour, and a hunter is knocked down +/// Slams into `victim`, a person or a mech: brute through armour, and a person is knocked down (CB-25) /datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/slam_into(atom/victim) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner victim.visible_message(span_danger("[boss] slams into [victim]!"), span_userdanger("[boss] slams into you!")) playsound(victim, 'sound/effects/meteorimpact.ogg', 70, TRUE) - boss.boss_hit(victim, BOUNTY_JUGGERNAUT_CHARGE_DAMAGE, BRUTE, MELEE, BOUNTY_JUGGERNAUT_CHARGE_KNOCKDOWN, BOUNTY_JUGGERNAUT_MELEE_AP) + boss.boss_hit(victim, BOUNTY_JUGGERNAUT_CHARGE_DAMAGE, BRUTE, MELEE, BOUNTY_JUGGERNAUT_CHARGE_KNOCKDOWN, BOUNTY_JUGGERNAUT_CHARGE_AP) -/// Ran into something it can't break: stopped dead, reeling +/** + * Ran into something it can't break: stopped dead, and it hurts, the way running into a wall at full + * tilt hurts anyone (tg's tackle splat, CB-6): brute to its head through its helmet, a blow to its + * stamina, a moment's paralysis and a knockdown, all tg's. No damage multiplier: on the floor it is + * simply easier to hit, and to cuff. + */ /datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/bounce(atom/obstacle, serial) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner end_charge(serial) if(!istype(boss) || boss.stat != CONSCIOUS) return boss.visible_message(span_danger("[boss] slams into [obstacle] and reels!")) playsound(boss, 'sound/effects/bang.ogg', 80, TRUE) - boss.boss_stagger(BOUNTY_JUGGERNAUT_WALL_STAGGER, BOUNTY_JUGGERNAUT_WALL_VULNERABLE) + boss.Shake(2, 1, BOUNTY_JUGGERNAUT_WALL_KNOCKDOWN) + boss.apply_damage(BOUNTY_JUGGERNAUT_WALL_BRUTE, BRUTE, BODY_ZONE_HEAD, boss.run_armor_check(BODY_ZONE_HEAD, MELEE, silent = TRUE)) + boss.apply_damage(BOUNTY_JUGGERNAUT_WALL_STAMINA, STAMINA) + boss.Paralyze(BOUNTY_JUGGERNAUT_WALL_PARALYZE) + boss.Knockdown(BOUNTY_JUGGERNAUT_WALL_KNOCKDOWN) /datum/action/cooldown/mob_cooldown/bounty_boss/charge/proc/end_charge(serial) path = null charge_target = null - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(istype(boss) && boss.boss_busy == "charge" && boss.boss_busy_serial == serial) boss.boss_clear_busy(serial) /** - * Slam: both fists up and a red ring on the floor around it, then down. Every hunter it may hurt in - * the ring takes brute through armour and is knocked down; a mech takes it on its hull. Only when - * someone is next to it. + * Slam: both fists up and a red ring on the floor around it, then down. Everyone it catches in the + * ring, standing or not, takes brute through armour and is knocked down; a mech takes it on its hull. + * Only when someone is next to it. */ /datum/action/cooldown/mob_cooldown/bounty_boss/slam name = "Ground Slam" @@ -282,7 +282,7 @@ animate(transform = rest, time = 0.2 SECONDS) /datum/action/cooldown/mob_cooldown/bounty_boss/slam/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner boss.visible_message(span_danger("[boss] brings both fists down on the floor!")) playsound(boss, 'sound/effects/meteorimpact.ogg', 80, TRUE) for(var/atom/victim as anything in boss.boss_victims_on(RANGE_TURFS(1, boss))) @@ -292,43 +292,23 @@ // ===== PYROMANIAC ===== -/mob/living/basic/bounty_criminal/boss/pyromaniac +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac name = "scorched criminal" - desc = "A criminal in a blackened fire suit, with a fuel tank on their back and a lit torch in hand." - maxHealth = BOUNTY_PYROMANIAC_HEALTH_1 - health = BOUNTY_PYROMANIAC_HEALTH_1 - speed = BOUNTY_PYROMANIAC_SPEED - damage_coeff = list(BRUTE = BOUNTY_PYROMANIAC_BRUTE_MOD, BURN = BOUNTY_PYROMANIAC_BURN_MOD, TOX = 1, STAMINA = BOUNTY_BOSS_STAMINA_FRESH * 100 / BOUNTY_BOSS_STAMINA, OXY = 1) - melee_damage_lower = BOUNTY_PYROMANIAC_MELEE_MIN - melee_damage_upper = BOUNTY_PYROMANIAC_MELEE_MAX - armour_penetration = BOUNTY_PYROMANIAC_MELEE_AP - melee_damage_type = BURN - melee_attack_cooldown = BOUNTY_PYROMANIAC_MELEE_COOLDOWN - attack_verb_continuous = "burns" - attack_verb_simple = "burn" - attack_sound = 'sound/items/weapons/sear.ogg' - attack_vis_effect = ATTACK_EFFECT_SMASH - // Fireproof, not laser-proof: no heat damage and no fire stacks, but BURN still lands. - unsuitable_heat_damage = 0 + desc = "A criminal in a blackened fire suit, with a lit torch in hand." boss_kit = BOUNTY_KIT_PYROMANIAC boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/flamer, /datum/action/cooldown/mob_cooldown/bounty_boss/molotov, ) -/mob/living/basic/bounty_criminal/boss/pyromaniac/Initialize(mapload) - . = ..() - add_traits(list(TRAIT_NOFIRE, TRAIT_RESISTHEAT), BOUNTY_TRAIT) - -/mob/living/basic/bounty_criminal/boss/pyromaniac/boss_health_table() - return list(BOUNTY_PYROMANIAC_HEALTH_1, BOUNTY_PYROMANIAC_HEALTH_2, BOUNTY_PYROMANIAC_HEALTH_3, BOUNTY_PYROMANIAC_HEALTH_4) - -/mob/living/basic/bounty_criminal/boss/pyromaniac/boss_kit_outfit() - return /datum/outfit/bounty_boss/pyromaniac +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/pyromaniac)) -/mob/living/basic/bounty_criminal/boss/pyromaniac/boss_gear_text() +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_gear_text() return "[p_Their()] fire suit is scorched black." +// The firefighter suit and helmet are what keep its own fire off it (tg's thermal protection, CB-7). The torch +// doesn't holster, so it is in hand from the start. /datum/outfit/bounty_boss/pyromaniac name = "Bounty mini-boss: Pyromaniac" uniform = /obj/item/clothing/under/color/black @@ -336,12 +316,12 @@ head = /obj/item/clothing/head/utility/hardhat/red gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/workboots - r_hand = /obj/item/flamethrower + r_hand = BOUNTY_PYROMANIAC_TORCH /** * Flamer: the pilot light hisses and an orange cone shows on the floor, then a jet of fire sweeps - * it. Everyone it may hurt in the cone takes BOUNTY_PYROMANIAC_FLAMER_DAMAGE burn and is set - * alight; the floor burns on for BOUNTY_PYROMANIAC_FLAMER_FLOOR. + * it. Everyone it catches in the cone takes BOUNTY_PYROMANIAC_FLAMER_DAMAGE burn and catches fire + * (tg's fire stacks); the floor burns on for BOUNTY_PYROMANIAC_FLAMER_FLOOR. */ /datum/action/cooldown/mob_cooldown/bounty_boss/flamer name = "Flamer" @@ -359,7 +339,7 @@ return aimable(target) && get_dist(owner, target) <= BOUNTY_PYROMANIAC_FLAMER_RANGE && can_see(owner, target, BOUNTY_PYROMANIAC_FLAMER_RANGE) && landing_ok(target) /datum/action/cooldown/mob_cooldown/bounty_boss/flamer/telegraph(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner cone = list() // Only where its fire may reach: never off its site or into a ship (M2). for(var/turf/spot as anything in cone_turfs(target, BOUNTY_PYROMANIAC_FLAMER_RANGE, BOUNTY_PYROMANIAC_FLAMER_ARC)) @@ -376,12 +356,12 @@ return owner.visible_message(span_danger("[owner] sweeps a jet of fire across the floor!")) playsound(owner, 'sound/items/weapons/fwoosh.ogg', 80, TRUE) - new /obj/effect/bounty_boss_fire_pool(get_turf(owner), fire_turfs, BOUNTY_PYROMANIAC_FLAMER_FLOOR, owner, BOUNTY_PYROMANIAC_FLAMER_DAMAGE) + new /obj/effect/bounty_boss_fire_pool(get_turf(owner), fire_turfs, BOUNTY_PYROMANIAC_FLAMER_FLOOR, owner, BOUNTY_PYROMANIAC_FLAMER_DAMAGE, BOUNTY_PYROMANIAC_FLAMER_STACKS) /** * Molotov: it lights the rag on a bottle and a ring shows where it will land, then it throws. The * bottle breaks into a 3x3 patch of fire (not through walls) for BOUNTY_PYROMANIAC_MOLOTOV_POOL: - * BOUNTY_PYROMANIAC_MOLOTOV_DAMAGE burn to anyone it may hurt there, and set alight. + * BOUNTY_PYROMANIAC_MOLOTOV_DAMAGE burn to anyone it catches there, and they catch fire. */ /datum/action/cooldown/mob_cooldown/bounty_boss/molotov name = "Molotov" @@ -411,7 +391,7 @@ /// The 3x3 around `center` that a bottle bursting there reaches: open floor, not through walls, and only where its fire may reach (M2) /datum/action/cooldown/mob_cooldown/bounty_boss/molotov/proc/splash_turfs(turf/center) . = list() - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!center || !istype(boss)) return for(var/turf/spot as anything in RANGE_TURFS(1, center)) @@ -433,7 +413,7 @@ if(QDELETED(owner)) return playsound(spot, 'sound/effects/glass/glassbr3.ogg', 70, TRUE) - new /obj/effect/bounty_boss_fire_pool(spot, splash_turfs(spot), BOUNTY_PYROMANIAC_MOLOTOV_POOL, owner, BOUNTY_PYROMANIAC_MOLOTOV_DAMAGE) + new /obj/effect/bounty_boss_fire_pool(spot, splash_turfs(spot), BOUNTY_PYROMANIAC_MOLOTOV_POOL, owner, BOUNTY_PYROMANIAC_MOLOTOV_DAMAGE, BOUNTY_PYROMANIAC_MOLOTOV_STACKS) /// Something thrown, flying from where it starts to `destination` /obj/effect/temp_visual/bounty_boss_thrown @@ -454,29 +434,19 @@ // ===== DEMOLITIONIST ===== -/mob/living/basic/bounty_criminal/boss/demolitionist +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist name = "criminal in a hard hat" - desc = "A criminal in a hard hat and a hi-vis vest, with a bandolier of grenades across their chest." - maxHealth = BOUNTY_DEMOLITIONIST_HEALTH_1 - health = BOUNTY_DEMOLITIONIST_HEALTH_1 - speed = BOUNTY_DEMOLITIONIST_SPEED - damage_coeff = list(BRUTE = BOUNTY_DEMOLITIONIST_BRUTE_MOD, BURN = BOUNTY_DEMOLITIONIST_BURN_MOD, TOX = 1, STAMINA = BOUNTY_BOSS_STAMINA_FRESH * 100 / BOUNTY_BOSS_STAMINA, OXY = 1) - melee_attack_cooldown = BOUNTY_DEMOLITIONIST_MELEE_COOLDOWN - attack_vis_effect = ATTACK_EFFECT_SMASH + desc = "A criminal in a hard hat and a hi-vis vest, with a bandolier across their chest." boss_kit = BOUNTY_KIT_DEMOLITIONIST - boss_weapon = BOUNTY_DEMOLITIONIST_CROWBAR boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/grenade, /datum/action/cooldown/mob_cooldown/bounty_boss/breach, ) -/mob/living/basic/bounty_criminal/boss/demolitionist/boss_health_table() - return list(BOUNTY_DEMOLITIONIST_HEALTH_1, BOUNTY_DEMOLITIONIST_HEALTH_2, BOUNTY_DEMOLITIONIST_HEALTH_3, BOUNTY_DEMOLITIONIST_HEALTH_4) +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/demolitionist)) -/mob/living/basic/bounty_criminal/boss/demolitionist/boss_kit_outfit() - return /datum/outfit/bounty_boss/demolitionist - -/mob/living/basic/bounty_criminal/boss/demolitionist/boss_gear_text() +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/boss_gear_text() return "A bandolier of grenades is slung across [p_their()] chest." /** @@ -485,14 +455,14 @@ * ready for this target (out of its sight with no breach possible, or the strict rotation ruling * out the grenade), rather than standing idle. */ -/mob/living/basic/bounty_criminal/boss/demolitionist/boss_engage_plan(datum/ai_controller/controller, atom/target) +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/boss_engage_plan(datum/ai_controller/controller, atom/target) if(!isliving(target) && !ismecha(target)) return FALSE var/distance = get_dist(src, target) if(distance <= 1 || !boss_has_next_throw(target)) return FALSE if(distance < BOUNTY_DEMOLITIONIST_KEEP_MIN) - controller.queue_behavior(/datum/ai_behavior/step_away, BB_BASIC_MOB_CURRENT_TARGET) + controller.queue_behavior(/datum/ai_behavior/bounty_boss_back_off, BB_BASIC_MOB_CURRENT_TARGET) return TRUE if(distance > BOUNTY_DEMOLITIONIST_KEEP_MAX) controller.queue_behavior(/datum/ai_behavior/pursue_to_range, BB_BASIC_MOB_CURRENT_TARGET, BOUNTY_DEMOLITIONIST_KEEP_MAX) @@ -504,7 +474,7 @@ * Whether the rotation leaves it an ability it could use on `target` from its band. Cooldowns are * ignored on purpose, so it holds its range between grenades instead of stepping in and out. */ -/mob/living/basic/bounty_criminal/boss/demolitionist/proc/boss_has_next_throw(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/proc/boss_has_next_throw(atom/target) for(var/datum/action/cooldown/mob_cooldown/bounty_boss/ability as anything in boss_abilities) if(QDELETED(ability) || (boss_strict_rotation && ability.type == boss_last_ability)) continue @@ -517,6 +487,7 @@ return TRUE return FALSE +// Its crowbar rides in its bag until the fight starts; welding goggles keep a flash out of its eyes (tg's flash protection). /datum/outfit/bounty_boss/demolitionist name = "Bounty mini-boss: Demolitionist" uniform = /obj/item/clothing/under/misc/overalls @@ -526,7 +497,8 @@ glasses = /obj/item/clothing/glasses/welding gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/workboots - r_hand = BOUNTY_DEMOLITIONIST_CROWBAR + back = /obj/item/storage/backpack/industrial + backpack_contents = list(BOUNTY_DEMOLITIONIST_CROWBAR = 1) /** * Grenade: it pulls the pin and a mark shows where it is aiming, then it throws. The grenade lands @@ -567,7 +539,7 @@ throw_grenade(spot) if(!prob(BOUNTY_DEMOLITIONIST_SECOND_GRENADE)) return - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner var/list/near = list() for(var/turf/other as anything in RANGE_TURFS(2, spot)) if(other != spot && !isclosedturf(other) && boss.boss_reach_ok(other) && bounty_boss_clear_line(get_turf(owner), other)) @@ -577,7 +549,7 @@ /// Throws one grenade at `spot`: it flies, lands and starts its fuse /datum/action/cooldown/mob_cooldown/bounty_boss/grenade/proc/throw_grenade(turf/spot) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner // The second grenade comes half a second later: not if it was downed, cuffed or stunned meanwhile. if(!istype(boss) || !boss.boss_can_act() || !spot) return @@ -610,7 +582,7 @@ return ..() /datum/action/cooldown/mob_cooldown/bounty_boss/breach/worth_using(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!aimable(target) || !istype(boss) || boss.boss_walls_left <= 0) return FALSE if(get_dist(boss, target) > BOUNTY_DEMOLITIONIST_BREACH_RANGE) @@ -622,7 +594,7 @@ /// The interior wall or door beside it that leads toward `target`, if it may break one /datum/action/cooldown/mob_cooldown/bounty_boss/breach/proc/breach_spot(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner var/toward = get_dir(boss, target) for(var/direction in list(toward, turn(toward, 45), turn(toward, -45))) var/turf/spot = get_step(boss, direction) @@ -645,7 +617,7 @@ mark_turfs(list(get_turf(spot)), COLOR_RED) /datum/action/cooldown/mob_cooldown/bounty_boss/breach/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner var/atom/spot = breach_ref?.resolve() breach_ref = null if(!istype(boss) || QDELETED(spot) || !boss.boss_may_break(spot)) @@ -654,11 +626,13 @@ boss.visible_message(span_danger("The charge on [spot] starts beeping!")) /** - * A boss's grenade or breaching charge: a fuse with a blinking red ring and beeping, then a - * scripted blast. No explosion(), no fire: brute against bomb armour and a knockdown to everyone - * the boss may hurt within `radius` (not through walls), and damage to structures within - * `object_radius` through the boss's rules (boss_damage_structure()). Capped at - * BOUNTY_BOSS_EXPLOSIVE_CAP server-wide. With its boss gone, it still goes off but breaks nothing. + * A boss's grenade or breaching charge: a fuse with a blinking red ring and beeping, then a blast. + * The object is scripted so nothing breaches a hull: no explosion() and no fire, and structures within + * `object_radius` take damage only through the boss's rules (boss_damage_structure()). Whoever the blast + * catches within `radius` (not through walls), standing or down, takes tg's own light explosion + * (ex_act(EXPLODE_LIGHT), CB-10): bomb armour, knockdown, ears, wounds and limbs are all tg's. A mech + * with someone in it takes `damage` on its hull. Capped at BOUNTY_BOSS_EXPLOSIVE_CAP server-wide. With + * its boss gone, it still goes off but breaks nothing. */ /obj/effect/bounty_boss_explosive name = "grenade" @@ -669,12 +643,17 @@ layer = ABOVE_OBJ_LAYER /// The boss who threw it var/datum/weakref/boss_ref + /// Its boss's factions when it was thrown: its blast never catches its own side, even with the boss gone + var/list/side_factions var/fuse = BOUNTY_DEMOLITIONIST_GRENADE_FUSE - /// Who it catches, and how hard + /// Who it catches var/radius = BOUNTY_DEMOLITIONIST_GRENADE_RADIUS + /// What it does to a mech's hull var/damage = BOUNTY_DEMOLITIONIST_GRENADE_DAMAGE + /// Its boss's zone damage factor when it was thrown (1 for a human boss) var/damage_factor = 1 - var/knockdown = BOUNTY_DEMOLITIONIST_GRENADE_KNOCKDOWN + /// Unused by the field kit (people get tg's own knockdown from the explosion); the prison's rigged charge sets it + var/knockdown = 0 /// What it breaks, and how hard var/object_radius = BOUNTY_DEMOLITIONIST_GRENADE_OBJECT_RADIUS var/object_damage = BOUNTY_DEMOLITIONIST_GRENADE_OBJECT_DAMAGE @@ -683,12 +662,13 @@ /// Its ring var/obj/effect/temp_visual/bounty_boss_mark/ring -/obj/effect/bounty_boss_explosive/Initialize(mapload, mob/living/basic/bounty_criminal/boss/boss) +/obj/effect/bounty_boss_explosive/Initialize(mapload, mob/living/carbon/human/npc/bounty_criminal/boss/boss) . = ..() if(length(GLOB.bounty_boss_explosives) >= BOUNTY_BOSS_EXPLOSIVE_CAP) return INITIALIZE_HINT_QDEL GLOB.bounty_boss_explosives += src boss_ref = boss ? WEAKREF(boss) : null + side_factions = boss ? boss.faction.Copy() : null damage_factor = boss ? boss.get_npc_zone_damage_factor() : 1 structure_tally = list(BOUNTY_BOSS_ABILITY_STRUCTURE_CAP) ring = new(get_turf(src), fuse, COLOR_RED, "target_circle") @@ -701,6 +681,7 @@ /obj/effect/bounty_boss_explosive/Destroy() GLOB.bounty_boss_explosives -= src boss_ref = null + side_factions = null structure_tally = null if(!QDELETED(ring)) qdel(ring) @@ -710,18 +691,18 @@ /obj/effect/bounty_boss_explosive/proc/beep() playsound(src, 'sound/machines/beep/beep.ogg', 30, TRUE) -/// Whether the blast may hurt `victim`: its boss's rules, or with the boss gone, awake hunters and their mechs -/obj/effect/bounty_boss_explosive/proc/may_hit(atom/victim, mob/living/basic/bounty_criminal/boss/boss) +/// Whether the blast catches `victim`: its boss's rules, or with the boss gone, anyone not on the boss's side. Never by the victim's state (CB-11). +/obj/effect/bounty_boss_explosive/proc/may_hit(atom/victim, mob/living/carbon/human/npc/bounty_criminal/boss/boss) if(!QDELETED(boss)) - return boss.boss_can_hurt(victim) - return bounty_boss_orphan_victim(victim) + return boss.boss_effect_hits(victim) + return bounty_boss_effect_victim(victim, side_factions) /obj/effect/bounty_boss_explosive/proc/detonate() var/turf/here = get_turf(src) if(!here) qdel(src) return - var/mob/living/basic/bounty_criminal/boss/boss = boss_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = boss_ref?.resolve() if(QDELETED(boss)) boss = null blast_structures(boss, here) @@ -730,19 +711,21 @@ for(var/atom/movable/victim in range(radius, here)) if(!(isliving(victim) || ismecha(victim)) || !may_hit(victim, boss) || !bounty_boss_clear_line(here, get_turf(victim))) continue - if(boss) - boss.boss_hit(victim, npc_zone_damage(damage, damage_factor), BRUTE, BOMB, knockdown, already_scaled = TRUE) - else if(ismecha(victim)) - victim.take_damage(npc_zone_damage(damage, damage_factor), BRUTE, BOMB, FALSE) - else - var/mob/living/person = victim - person.apply_damage(npc_zone_damage(damage, damage_factor), BRUTE, BODY_ZONE_CHEST, person.run_armor_check(BODY_ZONE_CHEST, BOMB, silent = TRUE)) - if(knockdown) - person.Knockdown(knockdown) + blast(victim) qdel(src) +/// Catches `victim` in the blast: a mech on its hull, a person by tg's light explosion +/obj/effect/bounty_boss_explosive/proc/blast(atom/movable/victim) + if(ismecha(victim)) + var/obj/vehicle/sealed/mecha/mech = victim + mech.take_damage(npc_zone_damage(damage, damage_factor), BRUTE, BOMB, FALSE, get_dir(mech, src)) + return + var/mob/living/person = victim + EX_ACT(person, EXPLODE_LIGHT) + shake_camera(person, 3, 2) + /// Structures in reach take the blast, through the boss's rules -/obj/effect/bounty_boss_explosive/proc/blast_structures(mob/living/basic/bounty_criminal/boss/boss, turf/here) +/obj/effect/bounty_boss_explosive/proc/blast_structures(mob/living/carbon/human/npc/bounty_criminal/boss/boss, turf/here) if(!boss || object_damage <= 0) return for(var/obj/thing in range(object_radius, here)) @@ -758,13 +741,12 @@ fuse = BOUNTY_DEMOLITIONIST_BREACH_FUSE radius = 1 damage = BOUNTY_DEMOLITIONIST_BREACH_DAMAGE - knockdown = 0 object_radius = 0 object_damage = 0 /// The wall or door it is on var/datum/weakref/breach_ref -/obj/effect/bounty_boss_explosive/breach/Initialize(mapload, mob/living/basic/bounty_criminal/boss/boss, atom/breach_target) +/obj/effect/bounty_boss_explosive/breach/Initialize(mapload, mob/living/carbon/human/npc/bounty_criminal/boss/boss, atom/breach_target) . = ..() breach_ref = breach_target ? WEAKREF(breach_target) : null @@ -775,7 +757,7 @@ /obj/effect/bounty_boss_explosive/breach/beep() playsound(src, 'sound/machines/beep/beep.ogg', 60, TRUE) -/obj/effect/bounty_boss_explosive/breach/blast_structures(mob/living/basic/bounty_criminal/boss/boss, turf/here) +/obj/effect/bounty_boss_explosive/breach/blast_structures(mob/living/carbon/human/npc/bounty_criminal/boss/boss, turf/here) var/atom/target = breach_ref?.resolve() if(!boss || QDELETED(target)) return @@ -783,46 +765,39 @@ // ===== GHOST ===== -/mob/living/basic/bounty_criminal/boss/ghost +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost name = "hooded criminal" - desc = "A lean criminal in a dark hooded cloak, holding a long knife low." - maxHealth = BOUNTY_GHOST_HEALTH_1 - health = BOUNTY_GHOST_HEALTH_1 - speed = BOUNTY_GHOST_SPEED - damage_coeff = list(BRUTE = BOUNTY_GHOST_BRUTE_MOD, BURN = BOUNTY_GHOST_BURN_MOD, TOX = 1, STAMINA = BOUNTY_BOSS_STAMINA_FRESH * 100 / BOUNTY_BOSS_STAMINA, OXY = 1) - melee_attack_cooldown = BOUNTY_GHOST_MELEE_COOLDOWN - attack_vis_effect = ATTACK_EFFECT_SLASH + desc = "A lean criminal in a dark hooded cloak, a knife at the ready." boss_kit = BOUNTY_KIT_GHOST - boss_weapon = BOUNTY_GHOST_KNIFE + // Its quickness is real chrome (the OC-6 ruling): a Slipwire shunt and a Cascade lattice. An EMP or a + // brownout takes it away as it would from a player, and it burns out when the Ghost dies. + npc_cyberware = list( + /obj/item/organ/cyberimp/cyberware/slipwire, + /obj/item/organ/cyberimp/cyberware/cascade, + ) boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/dash, /datum/action/cooldown/mob_cooldown/bounty_boss/cloak, ) - /// Cloaked: faint, and hard to hit, until a flash, a hit or the time runs out + /// Shimmering: only a look (alpha and blur), until a flash, a hit or the time runs out var/boss_cloaked = FALSE var/boss_cloak_timer - /// world.time of its last step, for dodging while moving - var/boss_last_moved = 0 /// Cuts since it last broke off, and when it may close in again var/boss_cuts = 0 var/boss_breakoff_until = 0 - /// Its dodge and miss messages show at most every BOUNTY_BOSS_MESSAGE_COOLDOWN (L5) - COOLDOWN_DECLARE(boss_message_cooldown) -/mob/living/basic/bounty_criminal/boss/ghost/Destroy() +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/Destroy() deltimer(boss_cloak_timer) boss_cloak_timer = null return ..() -/mob/living/basic/bounty_criminal/boss/ghost/boss_health_table() - return list(BOUNTY_GHOST_HEALTH_1, BOUNTY_GHOST_HEALTH_2, BOUNTY_GHOST_HEALTH_3, BOUNTY_GHOST_HEALTH_4) - -/mob/living/basic/bounty_criminal/boss/ghost/boss_kit_outfit() - return /datum/outfit/bounty_boss/ghost +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/ghost)) -/mob/living/basic/bounty_criminal/boss/ghost/boss_gear_text() +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_gear_text() return "[p_They()] move[p_s()] quick and light, and the knife is never still." +// The hood goes up as the outfit is put on (tg's hooded suits do that). The knife rides in a pocket until the fight starts. /datum/outfit/bounty_boss/ghost name = "Bounty mini-boss: Ghost" uniform = /obj/item/clothing/under/color/black @@ -830,81 +805,66 @@ mask = /obj/item/clothing/mask/bandana/black gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/sneakers/black - r_hand = BOUNTY_GHOST_KNIFE + l_pocket = BOUNTY_GHOST_KNIFE -/// Its cuts bleed, and after a couple of them it breaks off for a while -/mob/living/basic/bounty_criminal/boss/ghost/melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - . = ..() - if(!. || !isliving(target)) - return - boss_cut(target) +/// The knife it holds, if it has one in hand +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/proc/boss_knife() + for(var/obj/item/knife/blade in held_items) + return blade + return null -/// A cut on `victim`: it bleeds, and the hit-and-run count goes up -/mob/living/basic/bounty_criminal/boss/ghost/proc/boss_cut(mob/living/victim) - if(QDELETED(victim) || victim.stat == DEAD) +/** + * Stabs `victim` with the knife it holds, as a player would: the knife in its active hand, a body + * zone picked, and a click through controller.ai_interact(). Damage, armour, wounds and bleeding are + * the knife's and tg's (CB-9). Returns TRUE if it swung. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/proc/boss_knife_attack(atom/victim) + var/obj/item/knife/blade = boss_knife() + if(isnull(blade) || isnull(ai_controller) || next_move > world.time || !human_npc_ready_item(src, blade)) + return FALSE + victim.visible_message(span_danger("[src] darts in and slashes [victim]!"), span_userdanger("[src] darts in and slashes you!")) + playsound(victim, 'sound/items/weapons/bladeslice.ogg', 70, TRUE) + zone_selected = human_npc_pick_aim_zone() + return ai_controller.ai_interact(target = victim, combat_mode = TRUE) + +// Every knife blow it lands counts toward its hit and run: after a couple, it breaks off for a while. +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_landed_blow(mob/living/victim, obj/item/weapon) + if(!istype(weapon, /obj/item/knife)) return - victim.apply_status_effect(/datum/status_effect/bounty_boss_bleed, get_npc_zone_damage_factor()) boss_cuts++ if(boss_cuts >= BOUNTY_GHOST_CUTS_BEFORE_BREAKOFF) boss_cuts = 0 boss_breakoff_until = world.time + BOUNTY_GHOST_BREAKOFF_TIME /// Breaking off: it backs away from its target for a while -/mob/living/basic/bounty_criminal/boss/ghost/boss_engage_plan(datum/ai_controller/controller, atom/target) +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_engage_plan(datum/ai_controller/controller, atom/target) if(world.time >= boss_breakoff_until || !(isliving(target) || ismecha(target)) || get_dist(src, target) > 4) return FALSE - controller.queue_behavior(/datum/ai_behavior/step_away, BB_BASIC_MOB_CURRENT_TARGET) + controller.queue_behavior(/datum/ai_behavior/bounty_boss_back_off, BB_BASIC_MOB_CURRENT_TARGET) return TRUE -/mob/living/basic/bounty_criminal/boss/ghost/boss_moved() - boss_last_moved = world.time - -/// Whether it is on the move, for dodging -/mob/living/basic/bounty_criminal/boss/ghost/proc/boss_moving() - return world.time <= boss_last_moved + BOUNTY_GHOST_MOVING_WINDOW && boss_can_act() - -/// It dodges some shots while moving, and cloaked, half of them miss -/mob/living/basic/bounty_criminal/boss/ghost/boss_projectile_prehit(obj/projectile/shot) - if(shot.firer == src || stat != CONSCIOUS) - return NONE - var/hit_chance = 100 - if(boss_moving()) - hit_chance *= (100 - BOUNTY_GHOST_DODGE) / 100 - if(boss_cloaked) - hit_chance *= (100 - BOUNTY_GHOST_CLOAK_MISS) / 100 - if(prob(hit_chance)) - return NONE - boss_miss_message(span_warning("[src] slips aside from [shot]!")) - return PROJECTILE_INTERRUPT_HIT_PHASE - -/// A dodge or miss message, at most every BOUNTY_BOSS_MESSAGE_COOLDOWN so automatic fire doesn't flood chat (L5) -/mob/living/basic/bounty_criminal/boss/ghost/proc/boss_miss_message(message) - if(!COOLDOWN_FINISHED(src, boss_message_cooldown)) - return - COOLDOWN_START(src, boss_message_cooldown, BOUNTY_BOSS_MESSAGE_COOLDOWN) - visible_message(message) - -/// Cloaked, half the blows find nothing -/mob/living/basic/bounty_criminal/boss/ghost/boss_check_block(atom/hit_by, attack_text, attack_type) - if(!boss_cloaked || stat != CONSCIOUS || !prob(BOUNTY_GHOST_CLOAK_MISS)) - return NONE - boss_miss_message(span_warning("[attack_text] swings through the shimmer where [src] was!")) - return SUCCESSFUL_BLOCK +/// Fires its Cascade lattice (the chrome's own action), if it is ready. Returns TRUE if it fired. +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/proc/boss_surge() + for(var/obj/item/organ/cyberimp/cyberware/cascade/lattice in organs) + for(var/datum/action/cooldown/cyberware/cascade_surge/surge in lattice.actions) + if(surge.IsAvailable()) + return surge.Trigger() + return FALSE -/mob/living/basic/bounty_criminal/boss/ghost/boss_took_damage(damage, damagetype) +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_took_damage(damage, damagetype) if(boss_cloaked) boss_uncloak("A hit breaks the shimmer around [src].") -/mob/living/basic/bounty_criminal/boss/ghost/boss_flashed(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_flashed(mob/living/user) if(boss_cloaked) boss_uncloak("The flash burns away the shimmer around [src]!") -/mob/living/basic/bounty_criminal/boss/ghost/boss_on_interrupted() +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss_on_interrupted() if(boss_cloaked) boss_uncloak() -/// Fades to a faint shimmer for BOUNTY_GHOST_CLOAK_TIME -/mob/living/basic/bounty_criminal/boss/ghost/proc/boss_cloak() +/// Fades to a faint shimmer for BOUNTY_GHOST_CLOAK_TIME: a look only, nothing misses it for it (CB-12) +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/proc/boss_cloak() if(stat != CONSCIOUS) return FALSE boss_cloaked = TRUE @@ -914,8 +874,8 @@ boss_cloak_timer = addtimer(CALLBACK(src, PROC_REF(boss_uncloak), "The shimmer around [src] fades."), BOUNTY_GHOST_CLOAK_TIME, TIMER_STOPPABLE | TIMER_DELETE_ME) return TRUE -/// Plain to see again, with `message` if something broke the cloak -/mob/living/basic/bounty_criminal/boss/ghost/proc/boss_uncloak(message) +/// Plain to see again, with `message` if something broke the shimmer +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/proc/boss_uncloak(message) if(!boss_cloaked) return FALSE boss_cloaked = FALSE @@ -928,8 +888,8 @@ return TRUE /** - * Dash: it crouches and a blurred line shows on the floor to its target, then it darts down it, - * leaving after-images, and cuts at the end: BOUNTY_GHOST_DASH_DAMAGE and a bleed. + * Dash: it crouches and a blurred line shows on the floor to its target, then it darts down it with + * its Cascade lattice firing, leaving after-images, and stabs at the end with the knife it holds. */ /datum/action/cooldown/mob_cooldown/bounty_boss/dash name = "Dash" @@ -949,7 +909,8 @@ return ..() /datum/action/cooldown/mob_cooldown/bounty_boss/dash/worth_using(atom/target) - if(!aimable(target)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss = owner + if(!aimable(target) || !istype(boss) || isnull(boss.boss_knife())) return FALSE var/distance = get_dist(owner, target) return distance >= 2 && distance <= BOUNTY_GHOST_DASH_RANGE + 1 && bounty_boss_clear_line(get_turf(owner), get_turf(target)) @@ -970,11 +931,13 @@ mark_turfs(path, "#9a9ab8") /datum/action/cooldown/mob_cooldown/bounty_boss/dash/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss = owner if(!istype(boss)) return dash_target = aimable(target) ? WEAKREF(target) : null step_index = 0 + // The lattice fires as it goes (the OC-6 ruling): real chrome, with its own crash after. + boss.boss_surge() if(!length(path)) cut_at_end(target) return @@ -983,7 +946,7 @@ dash_step(serial) /datum/action/cooldown/mob_cooldown/bounty_boss/dash/proc/dash_step(serial) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || !boss.boss_still("dash", serial)) end_dash(serial, FALSE) return @@ -1003,7 +966,7 @@ addtimer(CALLBACK(src, PROC_REF(dash_step), serial), 1, TIMER_DELETE_ME) /datum/action/cooldown/mob_cooldown/bounty_boss/dash/proc/end_dash(serial, cut) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner path = null var/atom/victim = dash_target?.resolve() dash_target = null @@ -1012,30 +975,25 @@ if(cut) cut_at_end(victim) -/// The cut at the end of the dash, if its target (a hunter, or a mech) is within reach +/** + * The cut at the end of the dash: a real knife attack on whoever it dashed at, if they are within + * reach, standing or not (CB-11). Returns TRUE if it swung. + */ /datum/action/cooldown/mob_cooldown/bounty_boss/dash/proc/cut_at_end(atom/victim) - var/mob/living/basic/bounty_criminal/boss/ghost/boss = owner - if(!istype(boss) || !boss.boss_can_act() || !aimable(victim) || !boss.Adjacent(victim) || !boss.boss_can_hurt(victim)) - return - boss.do_attack_animation(victim, ATTACK_EFFECT_SLASH) - playsound(victim, 'sound/items/weapons/bladeslice.ogg', 70, TRUE) - victim.visible_message(span_danger("[boss] darts in and slashes [victim]!"), span_userdanger("[boss] darts in and slashes you!")) - if(ismecha(victim)) - boss.boss_hit(victim, BOUNTY_GHOST_DASH_DAMAGE, BRUTE, MELEE, 0, BOUNTY_GHOST_DASH_AP) - return - var/mob/living/person = victim - person.apply_damage(npc_zone_damage(BOUNTY_GHOST_DASH_DAMAGE, boss), BRUTE, BODY_ZONE_CHEST, person.run_armor_check(BODY_ZONE_CHEST, MELEE, armour_penetration = BOUNTY_GHOST_DASH_AP, silent = TRUE), sharpness = SHARP_EDGED) - boss.boss_cut(person) + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss = owner + if(!istype(boss) || !boss.boss_can_act() || !aimable(victim) || !boss.Adjacent(victim) || !boss.boss_effect_hits(victim)) + return FALSE + return boss.boss_knife_attack(victim) -/// Cloak: the air around it shimmers, then it fades to a faint distortion that half of all hits miss +/// Cloak: the air around it shimmers, then it fades to a faint distortion. A look only (CB-12). /datum/action/cooldown/mob_cooldown/bounty_boss/cloak name = "Cloak" - desc = "Fade into a shimmer that is hard to hit." + desc = "Fade into a shimmer." cooldown_time = BOUNTY_GHOST_CLOAK_COOLDOWN windup = BOUNTY_GHOST_CLOAK_WINDUP /datum/action/cooldown/mob_cooldown/bounty_boss/cloak/worth_using(atom/target) - var/mob/living/basic/bounty_criminal/boss/ghost/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss = owner return aimable(target) && istype(boss) && !boss.boss_cloaked /datum/action/cooldown/mob_cooldown/bounty_boss/cloak/telegraph(atom/target) @@ -1045,75 +1003,29 @@ animate(alpha = 255, time = windup / 2) /datum/action/cooldown/mob_cooldown/bounty_boss/cloak/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/ghost/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boss = owner if(istype(boss) && boss.boss_cloak()) boss.visible_message(span_warning("[boss] fades into a faint shimmer!")) -/** - * The Ghost's cuts: BOUNTY_GHOST_BLEED_PER_CUT brute a second for each cut, up to - * BOUNTY_GHOST_BLEED_CAP, for BOUNTY_GHOST_BLEED_TIME after the last one, with the blood to show it. - * It stops once they are down (C4, M3). - */ -/datum/status_effect/bounty_boss_bleed - id = "bounty_boss_bleed" - duration = BOUNTY_GHOST_BLEED_TIME - tick_interval = 1 SECONDS - status_type = STATUS_EFFECT_REFRESH - alert_type = null - /// Cuts bleeding - var/cuts = 1 - var/damage_factor = 1 - -/datum/status_effect/bounty_boss_bleed/on_creation(mob/living/new_owner, factor = 1) - damage_factor = factor - return ..() - -/datum/status_effect/bounty_boss_bleed/on_apply() - return owner.stat == CONSCIOUS - -/datum/status_effect/bounty_boss_bleed/refresh(mob/living/new_owner, factor = 1) - damage_factor = factor - . = ..() - cuts++ - -/datum/status_effect/bounty_boss_bleed/tick(seconds_between_ticks) - if(owner.stat != CONSCIOUS) - qdel(src) - return - var/rate = min(cuts * BOUNTY_GHOST_BLEED_PER_CUT, BOUNTY_GHOST_BLEED_CAP) - owner.apply_damage(npc_zone_damage(rate, damage_factor), BRUTE, spread_damage = TRUE) - owner.bleed(rate) - // ===== HEAVY ===== -/mob/living/basic/bounty_criminal/boss/heavy +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy name = "heavily armed criminal" desc = "A big criminal in heavy armour, carrying a belt-fed gun." - maxHealth = BOUNTY_HEAVY_HEALTH_1 - health = BOUNTY_HEAVY_HEALTH_1 - mob_size = MOB_SIZE_LARGE - speed = BOUNTY_HEAVY_SPEED - damage_coeff = list(BRUTE = BOUNTY_HEAVY_BRUTE_MOD, BURN = BOUNTY_HEAVY_BURN_MOD, TOX = 1, STAMINA = BOUNTY_HEAVY_STAMINA_FRESH * 100 / BOUNTY_HEAVY_STAMINA, OXY = 1) - melee_attack_cooldown = BOUNTY_HEAVY_MELEE_COOLDOWN - attack_vis_effect = ATTACK_EFFECT_SMASH boss_kit = BOUNTY_KIT_HEAVY - boss_weapon = BOUNTY_HEAVY_GUN - boss_stamina_pool = BOUNTY_HEAVY_STAMINA - boss_stamina_fresh = BOUNTY_HEAVY_STAMINA_FRESH boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/burst, /datum/action/cooldown/mob_cooldown/bounty_boss/barricade, ) -/mob/living/basic/bounty_criminal/boss/heavy/boss_health_table() - return list(BOUNTY_HEAVY_HEALTH_1, BOUNTY_HEAVY_HEALTH_2, BOUNTY_HEAVY_HEALTH_3, BOUNTY_HEAVY_HEALTH_4) +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/heavy)) -/mob/living/basic/bounty_criminal/boss/heavy/boss_kit_outfit() - return /datum/outfit/bounty_boss/heavy - -/mob/living/basic/bounty_criminal/boss/heavy/boss_gear_text() +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_gear_text() return "[p_They()] [p_are()] in heavy armour." +// The L6 has no sling and won't holster, so it is in hand from the start, its other hand free to fire it. +// A spare box rides in the bag; a barrier grenade in each pocket. /datum/outfit/bounty_boss/heavy name = "Bounty mini-boss: Heavy" uniform = /obj/item/clothing/under/syndicate/combat @@ -1122,14 +1034,118 @@ gloves = /obj/item/clothing/gloves/combat shoes = /obj/item/clothing/shoes/jackboots r_hand = BOUNTY_HEAVY_GUN + l_pocket = /obj/item/grenade/barrier/bounty_boss + r_pocket = /obj/item/grenade/barrier/bounty_boss + back = /obj/item/storage/backpack + backpack_contents = list(BOUNTY_HEAVY_SPARE_AMMO = 1) + +/// The belt-fed gun it holds, if it holds one +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/proc/boss_gun() + for(var/obj/item/gun/ballistic/automatic/l6_saw/gun in held_items) + return gun + return null + +/// A barrier grenade it carries, not yet thrown +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/proc/boss_barrier() + for(var/obj/item/grenade/barrier/bounty_boss/barrier in get_all_contents_type(/obj/item/grenade/barrier/bounty_boss)) + if(!barrier.active && (is_holding(barrier) || human_npc_can_draw(src, barrier))) + return barrier + return null + +// Its gun's belt runs out: it changes it the way a player does (bounty_heavy_reload), if it has a spare. +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_gear_plan(datum/ai_controller/controller) + if(world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT] || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) + return FALSE + var/obj/item/gun/ballistic/automatic/l6_saw/gun = boss_gun() + if(isnull(gun) || (gun.can_shoot() && !gun.cover_open)) + return FALSE + if(!gun.cover_open && isnull(human_npc_find_ammo(src, gun))) + return FALSE + controller.queue_behavior(/datum/ai_behavior/bounty_heavy_reload) + return TRUE + +// A hit while it aims spoils the burst (CB-13). +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_took_damage(damage, damagetype) + var/datum/action/cooldown/mob_cooldown/bounty_boss/burst/aiming = boss_winding() + if(!istype(aiming) || damage < BOUNTY_HEAVY_FUMBLE_DAMAGE) + return + visible_message(span_warning("[src] fumbles the shot.")) + boss_interrupt() + +/** + * Changes the belt in its L6 the way a player does, one step per action: open the feed cover, pull + * the empty box with its free hand and let it fall, take the spare from its bag, click the gun with it, + * close the cover. tg's own sounds and procs throughout. Gives up after a while and fights on. + */ +/datum/ai_behavior/bounty_heavy_reload + action_cooldown = 0.8 SECONDS + +/datum/ai_behavior/bounty_heavy_reload/setup(datum/ai_controller/controller) + . = ..() + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = controller.pawn + var/obj/item/gun/ballistic/automatic/l6_saw/gun = istype(heavy) ? heavy.boss_gun() : null + if(isnull(gun)) + return FALSE + controller.set_blackboard_key(BB_BOUNTY_BOSS_RELOAD_UNTIL, world.time + 12 SECONDS) + if(!gun.cover_open) + heavy.visible_message(span_notice("[heavy] flips open the feed cover on [gun].")) + +/datum/ai_behavior/bounty_heavy_reload/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = controller.pawn + var/obj/item/gun/ballistic/automatic/l6_saw/gun = istype(heavy) ? heavy.boss_gun() : null + if(isnull(gun) || world.time >= controller.blackboard[BB_BOUNTY_BOSS_RELOAD_UNTIL] || HAS_TRAIT(heavy, TRAIT_HANDS_BLOCKED)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(heavy.next_move > world.time) + return AI_BEHAVIOR_DELAY + var/obj/item/spent = human_npc_spent_ammo_in_hand(heavy, gun) + if(spent) + heavy.dropItemToGround(spent, force = TRUE) + return AI_BEHAVIOR_DELAY + if(gun.magazine && gun.can_shoot()) + if(gun.cover_open) + gun.click_alt(heavy) + return AI_BEHAVIOR_DELAY + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(!gun.cover_open) + gun.click_alt(heavy) + return AI_BEHAVIOR_DELAY + if(gun.magazine) + // The empty box comes out into its free hand. + if(!human_npc_ready_empty_hand(heavy)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + var/obj/item/ammo = human_npc_find_ammo(heavy, gun) + if(isnull(ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!heavy.is_holding(ammo)) + if(!human_npc_take_in_hand(heavy, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(heavy, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/bounty_heavy_reload/finish_action(datum/ai_controller/controller, succeeded) + . = ..() + controller.clear_blackboard_key(BB_BOUNTY_BOSS_RELOAD_UNTIL) + if(succeeded) + return + // A change that got nowhere: the cover goes back down and it fights on with what it has for a while. + controller.set_blackboard_key(BB_HUMAN_NPC_ARMING_RETRY_AT, world.time + 10 SECONDS) + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = controller.pawn + var/obj/item/gun/ballistic/automatic/l6_saw/gun = istype(heavy) ? heavy.boss_gun() : null + if(gun?.cover_open) + gun.click_alt(heavy) /** * Suppressive burst: the gun spins up with a whirr, a red laser sight settles on the target's tile - * and a red cone shows on the floor, staying there until the last round (L3). Then - * BOUNTY_HEAVY_BURST_SHOTS rounds go into that cone over BOUNTY_HEAVY_BURST_TIME, where it aimed, not - * where the target went, and no further than the cone is marked. The rounds damage only windows, - * grilles, tables and barricades the boss may break (interior, on its site), at - * BOUNTY_HEAVY_BURST_DEMOLITION of their damage (M5). + * and a red cone shows on the floor, staying there until the last round (L3). Then it pulls the + * trigger of the real gun it holds BOUNTY_HEAVY_BURST_SHOTS times over BOUNTY_HEAVY_BURST_TIME, at the + * far edge of the cone where it aimed, not where the target went (CB-13). Real rounds from its real + * belt: when the belt runs dry the burst stops and it changes belts. It holds its fire for a round + * with one of its own side in the way. A hit while it aims spoils the burst (boss_took_damage()). */ /datum/action/cooldown/mob_cooldown/bounty_boss/burst name = "Suppressive Burst" @@ -1148,7 +1164,12 @@ return ..() /datum/action/cooldown/mob_cooldown/bounty_boss/burst/worth_using(atom/target) - if(!aimable(target)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss = owner + if(!aimable(target) || !istype(boss)) + return FALSE + var/obj/item/gun/gun = boss.boss_gun() + // Its gun loaded, its other hand free to fire it. + if(isnull(gun) || !gun.can_shoot() || boss.get_inactive_held_item()) return FALSE var/distance = get_dist(owner, target) return distance >= 2 && distance <= BOUNTY_HEAVY_BURST_RANGE && can_see(owner, target, BOUNTY_HEAVY_BURST_RANGE) && landing_ok(target) @@ -1166,7 +1187,7 @@ mark_turfs(cone_turfs(target, BOUNTY_HEAVY_BURST_RANGE, BOUNTY_HEAVY_BURST_ARC + BOUNTY_HEAVY_BURST_MARK_MARGIN), "#ff2020", linger = BOUNTY_HEAVY_BURST_TIME) /datum/action/cooldown/mob_cooldown/bounty_boss/burst/effect(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss) || !aim_turf) return shots_left = BOUNTY_HEAVY_BURST_SHOTS @@ -1174,65 +1195,54 @@ var/serial = boss.boss_set_busy("burst", effect_time + 1 SECONDS) fire_round(serial) -/// One round of the burst, and the next after it +/// Where one round goes: a tile at the cone's far edge, somewhere within its arc +/datum/action/cooldown/mob_cooldown/bounty_boss/burst/proc/round_spot() + var/turf/origin = get_turf(owner) + if(!origin) + return null + var/angle = aim_angle + rand(-BOUNTY_HEAVY_BURST_ARC / 2, BOUNTY_HEAVY_BURST_ARC / 2) + return locate( + clamp(origin.x + round(BOUNTY_HEAVY_BURST_RANGE * sin(angle)), 1, world.maxx), + clamp(origin.y + round(BOUNTY_HEAVY_BURST_RANGE * cos(angle)), 1, world.maxy), + origin.z, + ) + +/// One pull of the trigger, and the next after it /datum/action/cooldown/mob_cooldown/bounty_boss/burst/proc/fire_round(serial) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss = owner if(!istype(boss) || !boss.boss_still("burst", serial) || shots_left <= 0 || !aim_turf) end_burst(serial) return + var/obj/item/gun/gun = boss.boss_gun() + if(isnull(gun) || !gun.can_shoot()) + // Out of belt: it changes it on its next plan. + end_burst(serial) + return shots_left-- - var/turf/origin = get_turf(boss) - var/obj/projectile/bullet/bounty_boss_heavy/bullet = new(origin) - // Its gun's own rounds, no further than the marked cone - bounty_real_weapon(BOUNTY_HEAVY_GUN).load(bullet) - bullet.range = BOUNTY_HEAVY_BURST_RANGE - bullet.maximum_range = BOUNTY_HEAVY_BURST_RANGE - bullet.firer = boss - bullet.fired_from = boss - bullet.ignored_factions = boss.faction.Copy() - bullet.aim_projectile(aim_turf, boss) - if(!QDELETED(bullet)) - bullet.fire(aim_angle + rand(-BOUNTY_HEAVY_BURST_ARC / 2, BOUNTY_HEAVY_BURST_ARC / 2)) - playsound(boss, 'sound/items/weapons/gun/l6/shot.ogg', 60, TRUE) + var/turf/spot = round_spot() + if(spot && !human_npc_ally_in_line(boss, spot) && !boss.get_inactive_held_item() && human_npc_ready_item(boss, gun)) + boss.ai_controller?.ai_interact(target = spot, combat_mode = TRUE) if(shots_left <= 0) end_burst(serial) return - addtimer(CALLBACK(src, PROC_REF(fire_round), serial), max(1, round(BOUNTY_HEAVY_BURST_TIME / BOUNTY_HEAVY_BURST_SHOTS)), TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(fire_round), serial), max(2, round(BOUNTY_HEAVY_BURST_TIME / BOUNTY_HEAVY_BURST_SHOTS)), TIMER_DELETE_ME) /datum/action/cooldown/mob_cooldown/bounty_boss/burst/proc/end_burst(serial) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner shots_left = 0 aim_turf = null if(istype(boss) && boss.boss_busy == "burst" && boss.boss_busy_serial == serial) boss.boss_clear_busy(serial) /** - * The Heavy's rounds: its belt-fed gun's own (BOUNTY_HEAVY_GUN, loaded as they are fired), no - * further than the marked cone, and hard on structures only where the boss may break them - */ -/obj/projectile/bullet/bounty_boss_heavy - name = "heavy round" - range = BOUNTY_HEAVY_BURST_RANGE - demolition_mod = 0 - -// Judged at what it hits, not where it was fired from (M5): never a window onto space, never off the site. -// A mech is a target, not a structure: it takes the round in full. -/obj/projectile/bullet/bounty_boss_heavy/get_demolition_modifier(obj/target) - if(ismecha(target)) - return 1 - var/mob/living/basic/bounty_criminal/boss/boss = firer - if(!istype(boss) || QDELETED(boss) || !istype(target) || istype(target, /obj/machinery/door)) - return 0 - return boss.boss_may_break(target) ? BOUNTY_HEAVY_BURST_DEMOLITION : 0 - -/** - * Barricade: it unslings a folding barrier and drops it on the tile in front of it, toward its - * target: tg's security barrier (/obj/structure/barricade/security/bounty_boss). It keeps - * BOUNTY_BOSS_BARRICADES_PER_BOSS standing; each folds away after BOUNTY_BOSS_BARRICADE_LIFE. + * Barricade: it unslings a folded barrier, arms a real barrier grenade it carries and tosses it onto + * the tile in front of it, toward its target (CB-26). The grenade opens into the Heavy's folding barrier + * (/obj/structure/barricade/security/bounty_boss). It keeps BOUNTY_BOSS_BARRICADES_PER_BOSS standing; + * each folds away after BOUNTY_BOSS_BARRICADE_LIFE. Two grenades, two barriers: then it has none left. */ /datum/action/cooldown/mob_cooldown/bounty_boss/barricade name = "Deploy Barricade" - desc = "Drop a folding barrier in front of you." + desc = "Toss a barrier grenade in front of you." cooldown_time = BOUNTY_HEAVY_BARRICADE_COOLDOWN windup = BOUNTY_HEAVY_BARRICADE_WINDUP /// Where it will stand, from the telegraph @@ -1246,14 +1256,17 @@ return ..() /datum/action/cooldown/mob_cooldown/bounty_boss/barricade/worth_using(atom/target) - if(!aimable(target) || length(GLOB.bounty_boss_barricades) >= BOUNTY_BOSS_BARRICADE_CAP) + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss = owner + if(!aimable(target) || !istype(boss) || length(GLOB.bounty_boss_barricades) >= BOUNTY_BOSS_BARRICADE_CAP) + return FALSE + if(isnull(boss.boss_barrier()) || !length(boss.get_empty_held_indexes())) return FALSE var/distance = get_dist(owner, target) return distance >= 3 && distance <= 9 && !isnull(find_spot(target)) /// The open tile in front of it toward `target`, on its site, with no barrier on it /datum/action/cooldown/mob_cooldown/bounty_boss/barricade/proc/find_spot(atom/target) - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner if(!istype(boss)) return null var/turf/spot = get_step(boss, get_dir(boss, target)) @@ -1273,16 +1286,26 @@ /datum/action/cooldown/mob_cooldown/bounty_boss/barricade/effect(atom/target) var/turf/spot = barricade_spot barricade_spot = null - var/mob/living/basic/bounty_criminal/boss/boss = owner + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss = owner if(!istype(boss) || !spot || spot.is_blocked_turf() || (locate(/obj/structure/barricade) in spot) || !boss.boss_environment_allowed(spot)) return if(length(GLOB.bounty_boss_barricades) >= BOUNTY_BOSS_BARRICADE_CAP) return - var/obj/structure/barricade/security/bounty_boss/barrier = new(spot) + var/obj/item/grenade/barrier/bounty_boss/grenade = boss.boss_barrier() + if(isnull(grenade) || !human_npc_take_in_hand(boss, grenade) || !human_npc_ready_item(boss, grenade)) + return + grenade.ability_ref = WEAKREF(src) + // The player's way: pull the pin in hand, then throw it. + grenade.attack_self(boss) + boss.throw_mode_on(THROW_MODE_TOGGLE) + if(!boss.throw_item(spot)) + boss.throw_mode_off(THROW_MODE_TOGGLE) + boss.dropItemToGround(grenade) + +/// One of its barriers went up: it keeps count of it +/datum/action/cooldown/mob_cooldown/bounty_boss/barricade/proc/note_barrier(obj/structure/barricade/security/bounty_boss/barrier) if(QDELETED(barrier)) return - boss.visible_message(span_warning("[boss] slams a barrier down in front of [boss.p_them()].")) - playsound(spot, 'sound/effects/bang.ogg', 50, TRUE) barriers += WEAKREF(barrier) prune_barriers() @@ -1297,6 +1320,39 @@ var/obj/structure/barricade/security/bounty_boss/barrier = oldest.resolve() barrier?.fold_away() +/** + * The Heavy's barrier grenades: tg's barrier grenade on a short fuse, that opens into its folding + * barrier instead of a standing one. Issued as a consumable (it may leave the Heavy's body, thrown, but + * in anyone else's hands it is gone). + */ +/obj/item/grenade/barrier/bounty_boss + det_time = BOUNTY_HEAVY_BARRIER_FUSE + /// The Heavy's barricade ability that threw it, to count the barrier it makes + var/datum/weakref/ability_ref + +/obj/item/grenade/barrier/bounty_boss/Destroy() + ability_ref = null + return ..() + +// tg's barrier grenade, but what it makes is the folding barrier (and only ever the one). +/obj/item/grenade/barrier/bounty_boss/detonate(mob/living/lanced_by) + if(dud_flags) + active = FALSE + update_appearance() + return FALSE + dud_flags |= GRENADE_USED + SEND_SIGNAL(src, COMSIG_GRENADE_DETONATE, lanced_by) + var/turf/spot = get_turf(src) + if(spot && !(locate(/obj/structure/barricade) in spot)) + var/obj/structure/barricade/security/bounty_boss/barrier = new(spot) + if(!QDELETED(barrier)) + spot.visible_message(span_warning("[src] snaps open into a barrier.")) + playsound(spot, 'sound/effects/bang.ogg', 50, TRUE) + var/datum/action/cooldown/mob_cooldown/bounty_boss/barricade/ability = ability_ref?.resolve() + ability?.note_barrier(barrier) + qdel(src) + return TRUE + /// The Heavy's folding barrier: tg's security barrier that folds itself away after BOUNTY_BOSS_BARRICADE_LIFE. Drops nothing. /obj/structure/barricade/security/bounty_boss name = "folding barrier" diff --git a/voidcrew/modules/bounties/bounty_companions.dm b/voidcrew/modules/bounties/bounty_companions.dm index 909d993cbd6..3637824a3cd 100644 --- a/voidcrew/modules/bounties/bounty_companions.dm +++ b/voidcrew/modules/bounties/bounty_companions.dm @@ -3,66 +3,49 @@ * * Owner: P3 AI. Do not edit this file outside P3. * - * What it owns: /mob/living/basic/bounty_companion (its body, its built-in values and its AI) and - * spawn_companions(). Companions (0-2, spec C7) keep a criminal company: they sit at the bar with - * them, take turns in their talk, sit round their camp, and fight beside them with a style of their - * own (BOUNTY_COMPANION_HEALTH x the style's health; its weapon hits as the real one does). The - * AI is the trooper's peaceful pattern: retaliation, then calling the rest of the gang in, then the - * attack, then going to whoever called. A badly hurt companion may run; when their criminal goes - * down, is cuffed or gives up, each may run; a stamina-crit companion gives up for good. + * What it owns: /mob/living/carbon/human/npc/bounty_companion (its built-in values, its outfit and its + * AI), bounty_spawn_companion() and spawn_companions(). Companions (0-2, spec C7) keep a criminal + * company: they sit at the bar with them (a real chair and a real glass), take turns in their talk, sit + * round their camp, and fight beside them with a style of their own: a real weapon they carry and draw + * (bounty_styles.dm). They are real human bodies (CR-11): a person's health and stamina, toughness 1, + * no multipliers. The AI is the trooper's peaceful pattern: retaliation, then calling the rest of the + * gang in, then the attack, then going to whoever called. Badly hurt, a companion may run; when their + * criminal goes down, is cuffed or gives up, each may run; up from a real stamina crit with hunters + * around, it gives up (tg's *surrender) or runs. It gives up the way its criminal does + * (/datum/human_npc_surrender/bounty). * - * They are not bounties, pay nothing, drop nothing and carry no real weapon (what is in their hand - * is only a picture, though its blows and shots are the real item's). P5 deletes them when the posting closes; deleting one at any time - * is safe. + * They are not bounties, pay nothing and drop nothing (their kit is locked to them, D3). P5 deletes + * them when the posting closes; deleting one at any time is safe. * * The shared vars are declared in bounty_types.dm and nowhere else (leader_ref); the rest here * have an `ai_` prefix. */ -/mob/living/basic/bounty_companion +/mob/living/carbon/human/npc/bounty_companion name = "associate" - desc = "Someone keeping bad company." - icon = 'icons/mob/simple/simple_human.dmi' - unique_name = FALSE - maxHealth = BOUNTY_COMPANION_HEALTH - health = BOUNTY_COMPANION_HEALTH - speed = BOUNTY_CALM_SPEED - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - sentience_type = SENTIENCE_HUMANOID - status_flags = CANPUSH | CANSTUN | CANKNOCKDOWN - basic_mob_flags = NONE - // Stamina crit compares raw loss with a percentage when it ends; a pool of 100 keeps the two the same. - max_stamina = 100 - max_stamina_slowdown = 1 - // Dressed for the site, as their criminal is: no dying of the air or the cold on the way. - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - blood_volume = BLOOD_VOLUME_NORMAL - mobility_flags = MOBILITY_FLAGS_REST_CAPABLE_DEFAULT - rotate_on_lying = TRUE - death_message = "seizes up and falls limp, their eyes dead and lifeless..." - response_help_continuous = "pats" - response_help_simple = "pat" faction = list(FACTION_HOSTILE) ai_controller = /datum/ai_controller/basic_controller/bounty/companion - /// What they hold, drawn in their vis_contents - var/obj/effect/abstract/bounty_held/ai_hand - /// Hands up, over their head, once they give up - var/obj/effect/abstract/bounty_held/ai_over_head - /// Put their hands up (stamina crit): out of it for good - var/ai_gave_up = FALSE + npc_outfit_table = /datum/human_npc_outfit_table/bounty + npc_surrender = /datum/human_npc_surrender/bounty + // They stop once their target is down, as their criminal does (spec C4) + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + // A person's toughness: no multipliers (CR-11) + npc_damage_taken = 1 + /// Their fighting style (BOUNTY_STYLE_*), picked when they are dressed + var/ai_style /// Ran off: out of it for good var/ai_fled = FALSE /// The one roll to run when badly hurt has been made var/ai_flee_rolled = FALSE - /// In the fight now (weapon out, fighting pace) + /// In the fight now (weapon out, at a run) var/ai_in_fight = FALSE - /// Their drink at the bar, in their contents + /// Went into a real stamina crit, and hasn't decided yet what to do about it + var/ai_stamcritted = FALSE + /// Their glass at the bar var/obj/item/reagent_containers/cup/glass/drinkingglass/ai_drink /// Weakref to the seat they sat on var/datum/weakref/ai_seat_ref - /// Sitting on the floor + /// Sitting on the floor (tg's resting, of their own choosing) var/ai_crouching = FALSE /// Where they mean to be while nothing is happening, and when that is next thought about var/turf/ai_idle_spot @@ -70,112 +53,107 @@ /// world.time of their next word in the conversation var/ai_next_reply = 0 -/mob/living/basic/bounty_companion/Destroy() +/mob/living/carbon/human/npc/bounty_companion/Destroy() ai_let_go() - ai_over_head = bounty_ai_update_held(src, ai_over_head, null) ai_idle_spot = null leader_ref = null return ..() -/mob/living/basic/bounty_companion/death(gibbed) +/mob/living/carbon/human/npc/bounty_companion/death(gibbed) ai_let_go() - ai_over_head = bounty_ai_update_held(src, ai_over_head, null) return ..() -/// Their criminal, if still around -/mob/living/basic/bounty_companion/proc/ai_leader() - var/mob/living/basic/bounty_criminal/leader = leader_ref?.resolve() - return QDELETED(leader) ? null : leader - -/// Whether they can still fight: awake, on their feet (a knockdown is a cuff window, H2), not run off, not given up -/mob/living/basic/bounty_companion/proc/ai_can_fight() - return stat == CONSCIOUS && !ai_gave_up && !ai_fled && !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_FLOORED) - /** - * Joins `leader`: their faction, a style of their own, a look for the site, and a name. Called by - * spawn_companions(). + * What they wear: plain clothes for the zone they are made in, their style's weapon (never a Wanted + * criminal's heavier one) and its spares, and a bruise pack. The style is picked here, as they are + * dressed, so the weapon they carry is the one they fight with. */ -/mob/living/basic/bounty_companion/proc/ai_join(mob/living/basic/bounty_criminal/leader) - leader_ref = WEAKREF(leader) - faction = leader.faction.Copy() - gender = pick(MALE, FEMALE) - name = generate_random_name_species_based(gender, TRUE, /datum/species/human) - real_name = name - ai_controller?.set_blackboard_key(BB_BOUNTY_LEADER, leader) - ai_take_style(pick_weight(list( +/mob/living/carbon/human/npc/bounty_companion/bounty_build_outfit(zone) + ai_style ||= pick_weight(list( BOUNTY_STYLE_BRAWLER = 3, BOUNTY_STYLE_KNIFE = 2, BOUNTY_STYLE_CLUB = 2, BOUNTY_STYLE_PISTOL = 1, BOUNTY_STYLE_BOTTLE = 1, BOUNTY_STYLE_SHOTGUN = 1, - ))) - INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(set_outpost_npc_look), src, bounty_companion_outfit(leader), gender, random_outpost_npc_look_number()) + )) + return bounty_fighter_outfit(BOUNTY_ARCHETYPE_NORMAL, zone, BOUNTY_TIER_PETTY, ai_style, FALSE, /obj/item/stack/medical/bruise_pack) + +/// Their criminal, if still around +/mob/living/carbon/human/npc/bounty_companion/proc/ai_leader() + var/mob/living/carbon/human/npc/bounty_criminal/leader = leader_ref?.resolve() + return QDELETED(leader) ? null : leader + +/** + * Whether they can still fight: awake, out of crit, on their feet (a knockdown is a cuff window, H2), + * free, not giving up and not run off. All read from tg. + */ +/mob/living/carbon/human/npc/bounty_companion/proc/ai_can_fight() + if(stat != CONSCIOUS || ai_fled || npc_surrendering || HAS_TRAIT(src, TRAIT_CRITICAL_CONDITION) || HAS_TRAIT(src, TRAIT_RESTRAINED)) + return FALSE + return !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_FLOORED) -/// Takes style `key`: health BOUNTY_COMPANION_HEALTH x the style's, and the style's own weapon (never a Wanted criminal's heavier one) -/mob/living/basic/bounty_companion/proc/ai_take_style(key) - var/datum/bounty_style/style = GLOB.bounty_styles[key] - if(!style) +/** + * Joins `leader`: their faction, the style they were dressed for, and a place on the criminal's and + * the posting's list of companions (whoever spawned them). Called by bounty_spawn_companion(). + */ +/mob/living/carbon/human/npc/bounty_companion/proc/ai_join(mob/living/carbon/human/npc/bounty_criminal/leader) + if(QDELETED(leader)) return FALSE - maxHealth = round(BOUNTY_COMPANION_HEALTH * style.health_mult) - health = maxHealth - damage_coeff = damage_coeff.Copy() - damage_coeff[STAMINA] = style.stamina_mult - return bounty_ai_apply_style(src, key) - -/// What a companion of `leader` wears, by where the criminal is -/proc/bounty_companion_outfit(mob/living/basic/bounty_criminal/leader) - var/datum/criminal_bounty/posting = leader?.posting() - switch(posting?.placement_kind) - if(BOUNTY_PLACEMENT_NPC_SHIP) - return /datum/outfit/outpost_off_duty_pirate - if(BOUNTY_PLACEMENT_TRADER_OUTPOST) - return /datum/outfit/job/assistant/greytide - return pick(/datum/outfit/hermit/survivor, /datum/outfit/hermit/survivor/hunter) + leader_ref = WEAKREF(leader) + faction = leader.faction.Copy() + ai_controller?.set_blackboard_key(BB_BOUNTY_LEADER, leader) + bounty_ai_apply_style(src, ai_style || BOUNTY_STYLE_BRAWLER) + var/datum/weakref/our_ref = WEAKREF(src) + leader.companions |= our_ref + var/datum/criminal_bounty/posting = leader.posting() + if(posting) + posting.companions |= our_ref + bounty_ai_set_pace(src, BOUNTY_CALM_SPEED) + return TRUE /// Called in by their criminal: `target` is theirs to fight too -/mob/living/basic/bounty_companion/proc/ai_join_fight(mob/living/target) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_join_fight(mob/living/target) if(!ai_can_fight() || !isliving(target) || QDELETED(ai_controller) || bounty_ai_same_gang(src, target)) return ai_mark_enemy(target) - var/mob/living/basic/bounty_criminal/leader = ai_leader() + var/mob/living/carbon/human/npc/bounty_criminal/leader = ai_leader() if(leader && get_dist(src, leader) > 4) ai_controller.set_blackboard_key(BB_BASIC_MOB_REINFORCEMENT_TARGET, leader) ai_enter_fight() -/// Weapon out, up from their seat, fighting pace -/mob/living/basic/bounty_companion/proc/ai_enter_fight() +/// Up from their seat, drink down, at a run. The weapon comes out through the arm subtree (bounty_styles.dm). +/mob/living/carbon/human/npc/bounty_companion/proc/ai_enter_fight() if(ai_in_fight) return ai_in_fight = TRUE ai_put_drink_away() ai_stand_up() - var/datum/bounty_style/style = bounty_ai_style(src) - set_varspeed(style?.speed || BOUNTY_NORMAL_SPEED_MELEE) - ai_hand = bounty_ai_update_held(src, ai_hand, bounty_ai_held_look(src)) + bounty_ai_set_pace(src, BOUNTY_RUN_SPEED) -/// The fight is over for them: weapon away, easy pace -/mob/living/basic/bounty_companion/proc/ai_leave_fight() +/// The fight is over for them: weapon away, at a walk +/mob/living/carbon/human/npc/bounty_companion/proc/ai_leave_fight() if(!ai_in_fight) return ai_in_fight = FALSE - set_varspeed(BOUNTY_CALM_SPEED) - ai_hand = bounty_ai_update_held(src, ai_hand, null) + bounty_ai_set_pace(src, BOUNTY_CALM_SPEED) + bounty_ai_holster(src) /// Their criminal is down, cuffed or gave up: half the time they run for it -/mob/living/basic/bounty_companion/proc/ai_lose_heart() +/mob/living/carbon/human/npc/bounty_companion/proc/ai_lose_heart() if(!ai_can_fight() || !prob(50)) return var/mob/living/threat = ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] ai_run_off(isliving(threat) ? threat : null) /// Runs off, out of the fight for good -/mob/living/basic/bounty_companion/proc/ai_run_off(mob/living/threat) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_run_off(mob/living/threat) if(ai_fled || stat != CONSCIOUS) return ai_fled = TRUE ai_let_go() - set_varspeed(BOUNTY_NORMAL_SPEED_MELEE) + bounty_ai_holster(src) + bounty_ai_set_pace(src, BOUNTY_RUN_SPEED) visible_message(span_notice("[src] backs off and runs for it.")) if(QDELETED(ai_controller)) return @@ -186,25 +164,36 @@ ai_controller.set_blackboard_key(BB_BASIC_MOB_FLEE_TARGET, bounty_ai_target_for(threat)) ai_controller.CancelActions() -/// Stamina crit: hands up, done with it -/mob/living/basic/bounty_companion/proc/ai_give_up() - if(ai_gave_up || stat == DEAD) - return - ai_gave_up = TRUE - ai_let_go() - ai_over_head = bounty_ai_update_held(src, ai_over_head, /obj/effect/abstract/bounty_surrender_mark, over_head = TRUE) +/// They gave up (tg's *surrender, through the human NPC base): the fight dropped +/mob/living/carbon/human/npc/bounty_companion/proc/ai_on_surrendered() visible_message(span_notice("[src] puts [p_their()] hands up.")) + ai_let_go() if(QDELETED(ai_controller)) return ai_controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) ai_controller.clear_blackboard_key(BB_BASIC_MOB_RETALIATE_LIST) - ai_controller.CancelActions() + +/** + * Up from a real stamina crit (CR-11): with hunters around, they give up (tg's *surrender) or run; + * with nobody about, they get back to it. + */ +/mob/living/carbon/human/npc/bounty_companion/proc/ai_after_stamcrit() + ai_stamcritted = FALSE + if(!ai_can_fight()) + return + var/datum/human_npc_surrender/rules = npc_surrender_rules() + var/list/hostiles = rules?.hostiles_near(src) + if(!length(hostiles)) + return + if(prob(BOUNTY_COMPANION_GIVE_UP_CHANCE) && npc_surrender()) + return + ai_run_off(get_closest_atom(/mob/living, hostiles, src)) /** * `enemy` is someone to fight: onto their retaliation list, and the mech they sit in with them (H4: * the targeting takes the mech while they are in it, and them once they climb out). */ -/mob/living/basic/bounty_companion/proc/ai_mark_enemy(mob/living/enemy) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_mark_enemy(mob/living/enemy) if(QDELETED(ai_controller) || !isliving(enemy)) return ai_controller.insert_blackboard_key_lazylist(BB_BASIC_MOB_RETALIATE_LIST, enemy) @@ -218,8 +207,8 @@ * blow from their own side of the site with nobody playing it is let go (M1). Knocked down, they * still remember who did it, and fight once up. */ -/mob/living/basic/bounty_companion/proc/ai_attacked_by(mob/living/attacker) - if(stat == DEAD || ai_gave_up || ai_fled || !isliving(attacker) || bounty_ai_same_gang(src, attacker)) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_attacked_by(mob/living/attacker) + if(stat == DEAD || ai_fled || !isliving(attacker) || bounty_ai_same_gang(src, attacker)) return if(bounty_ai_friendly_fire(src, attacker)) return @@ -228,24 +217,24 @@ ai_enter_fight() ai_leader()?.ai_ally_attacked(attacker) -/// About to take damage: badly hurt, they may run (one roll) -/mob/living/basic/bounty_companion/proc/ai_took_damage(damage, damagetype) +/// They took a hit: badly hurt (a person's health, read after the hit), they may run (one roll) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_took_damage(damage, damagetype) if(ai_flee_rolled || damage <= 0 || !ai_can_fight() || !(damagetype in list(BRUTE, BURN))) return - if(health - damage > maxHealth * BOUNTY_COMPANION_FLEE_BELOW) + if(health > maxHealth * BOUNTY_COMPANION_FLEE_BELOW) return ai_flee_rolled = TRUE if(prob(BOUNTY_COMPANION_FLEE_CHANCE)) var/mob/living/threat = ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] INVOKE_ASYNC(src, PROC_REF(ai_run_off), isliving(threat) ? threat : null) -/// Everything down: drink, seat, weapon -/mob/living/basic/bounty_companion/proc/ai_let_go() +/// Everything down: drink, seat or the floor. What they hold stays held (D3). +/mob/living/carbon/human/npc/bounty_companion/proc/ai_let_go() ai_put_drink_away() ai_stand_up() - ai_hand = bounty_ai_update_held(src, ai_hand, null) -/mob/living/basic/bounty_companion/proc/ai_sit_on(obj/structure/chair/seat) +/// Sits on `seat` from its tile: buckled to it, as anyone sitting down is +/mob/living/carbon/human/npc/bounty_companion/proc/ai_sit_on(obj/structure/chair/seat) if(QDELETED(seat) || loc != seat.loc || (seat.has_buckled_mobs() && buckled != seat)) return FALSE if(buckled == seat) @@ -256,40 +245,49 @@ ai_seat_ref = WEAKREF(seat) return TRUE -/mob/living/basic/bounty_companion/proc/ai_crouch() - if(ai_crouching || buckled) +/// Sits down on the ground: tg's resting +/mob/living/carbon/human/npc/bounty_companion/proc/ai_crouch() + if(ai_crouching || buckled || resting) return ai_crouching = TRUE - add_offsets(BOUNTY_AI_TRAIT, y_add = -4) + set_resting(TRUE, silent = TRUE) -/// Up from a seat they sat on themselves, or the floor -/mob/living/basic/bounty_companion/proc/ai_stand_up() +/// Up from a seat they sat on themselves, or the floor they lay down on themselves +/mob/living/carbon/human/npc/bounty_companion/proc/ai_stand_up() if(ai_crouching) ai_crouching = FALSE - remove_offsets(BOUNTY_AI_TRAIT) + if(resting) + set_resting(FALSE, silent = TRUE) var/obj/structure/chair/seat = ai_seat_ref?.resolve() ai_seat_ref = null if(seat && buckled == seat) seat.unbuckle_mob(src, force = TRUE) -/mob/living/basic/bounty_companion/proc/ai_take_drink() - if(!QDELETED(ai_drink)) +/// A glass of something poured into a free hand +/mob/living/carbon/human/npc/bounty_companion/proc/ai_take_drink() + if(!QDELETED(ai_drink) && is_holding(ai_drink)) + return ai_drink + ai_drink = bounty_ai_pour_drink(src) + return ai_drink + +/// The glass goes: on the table beside them if there is one, else it is gone (M4, L7) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_put_drink_away() + if(QDELETED(ai_drink)) + ai_drink = null return - ai_drink = new(src) - ai_drink.reagents.add_reagent(/datum/reagent/consumable/ethanol/beer, 25) - ai_hand = bounty_ai_update_held(src, ai_hand, ai_drink) - -/mob/living/basic/bounty_companion/proc/ai_put_drink_away() - if(ai_drink) - QDEL_NULL(ai_drink) - ai_hand = bounty_ai_update_held(src, ai_hand, null) + var/obj/item/glass = ai_drink + ai_drink = null + var/obj/structure/table/table = locate() in range(1, src) + if(stat == CONSCIOUS && bounty_ai_set_glass_down(src, glass, table)) + return + qdel(glass) /** * Where they mean to be while nothing is happening, thought about every few seconds rather than * every plan: beside their criminal when they talk, on a seat near them at the bar, beside the fire * at the camp, otherwise within BOUNTY_COMPANION_FOLLOW of them. Null means right where they are. */ -/mob/living/basic/bounty_companion/proc/ai_pick_idle_spot(mob/living/basic/bounty_criminal/leader) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_pick_idle_spot(mob/living/carbon/human/npc/bounty_criminal/leader) var/datum/bounty_activity/activity = leader.ai_activity if(istype(activity, /datum/bounty_activity/chat)) var/datum/bounty_activity/chat/talk = activity @@ -312,9 +310,9 @@ return ai_free_tile_near(leader, BOUNTY_COMPANION_FOLLOW) /// The nearest free tile within `distance` of `thing` (not its own), on their criminal's site -/mob/living/basic/bounty_companion/proc/ai_free_tile_near(atom/thing, distance) +/mob/living/carbon/human/npc/bounty_companion/proc/ai_free_tile_near(atom/thing, distance) var/turf/center = get_turf(thing) - var/mob/living/basic/bounty_criminal/leader = ai_leader() + var/mob/living/carbon/human/npc/bounty_criminal/leader = ai_leader() if(!center) return null var/turf/best @@ -334,18 +332,22 @@ /** * Hanging out with their criminal, about once a second: sat down where there is a seat (with a - * drink at the bar), sat by the fire at the camp, facing them, and now and then a word back. + * drink at the bar, which they really drink now and then), sat by the fire at the camp, facing them, + * and now and then a word back. */ -/mob/living/basic/bounty_companion/proc/ai_hang_out() - var/mob/living/basic/bounty_criminal/leader = ai_leader() +/mob/living/carbon/human/npc/bounty_companion/proc/ai_hang_out() + var/mob/living/carbon/human/npc/bounty_criminal/leader = ai_leader() if(!leader) return var/obj/structure/chair/seat = locate() in loc if(seat && istype(leader.buckled, /obj/structure/chair) && ai_sit_on(seat)) - ai_take_drink() + if(QDELETED(ai_drink)) + ai_take_drink() + else if(prob(5)) + bounty_ai_sip(src, ai_drink) else if(istype(leader.ai_activity, /datum/bounty_activity/camp)) ai_crouch() - if(!buckled && get_dist(src, leader) <= 3) + if(!buckled && !resting && get_dist(src, leader) <= 3) face_atom(leader) if(world.time < ai_next_reply || leader.ai_mode != BOUNTY_AI_CALM || get_dist(src, leader) > 3) return @@ -353,14 +355,39 @@ if(prob(50) && !istype(leader.ai_activity, /datum/bounty_activity/chat)) INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "companion_reply") +// ===== SPAWNING ===== + +/** + * Queues one companion for `leader` at `where` (the human NPC spawn queue: never a room of humans in + * one tick), who joins them as it is made: their faction, their side, their posting's list. Then + * `on_spawned` is called with it. If the criminal is gone by then, the companion is not kept. + * `immediate` builds it now (tests, admin tools). Returns the spawn order (qdel() cancels it). + */ +/proc/bounty_spawn_companion(mob/living/carbon/human/npc/bounty_criminal/leader, turf/where, datum/callback/on_spawned, immediate = FALSE) + where = get_turf(where) + if(QDELETED(leader) || !where) + return null + return spawn_human_npc(/mob/living/carbon/human/npc/bounty_companion, where, CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_companion_spawned), WEAKREF(leader), on_spawned), immediate = immediate) + +/// A queued companion is made: it joins its criminal, or goes if its criminal is gone +/proc/bounty_companion_spawned(datum/weakref/leader_ref, datum/callback/on_spawned, mob/living/carbon/human/npc/bounty_companion/companion) + var/mob/living/carbon/human/npc/bounty_criminal/leader = leader_ref?.resolve() + if(QDELETED(companion)) + return + if(QDELETED(leader) || !companion.ai_join(leader)) + qdel(companion) + return + on_spawned?.Invoke(companion) + // ===== THE CRIMINAL'S SIDE ===== /** * Spawns `count` companions (clamped to BOUNTY_COMPANIONS_MIN-BOUNTY_COMPANIONS_MAX) on free tiles - * beside them, remembered in their own `companions`. Returns the companions spawned: the caller - * (P5) puts them on the posting, and keeps to the global cap on live companions. + * beside them, through the human NPC spawn queue (bounty_spawn_companion()). Each joins them, and their + * posting, as it is made. Returns the companions already made: only with `immediate` (tests, admin + * tools), otherwise an empty list. */ -/mob/living/basic/bounty_criminal/proc/spawn_companions(count) +/mob/living/carbon/human/npc/bounty_criminal/proc/spawn_companions(count, immediate = FALSE) . = list() count = clamp(count, BOUNTY_COMPANIONS_MIN, BOUNTY_COMPANIONS_MAX) var/turf/home = get_turf(src) @@ -377,16 +404,16 @@ for(var/i in 1 to count) if(!length(spots)) break - var/mob/living/basic/bounty_companion/buddy = new(pick_n_take(spots)) - buddy.ai_join(src) - companions |= WEAKREF(buddy) - . += buddy + var/datum/human_npc_order/order = bounty_spawn_companion(src, pick_n_take(spots), null, immediate) + var/mob/living/carbon/human/npc/bounty_companion/buddy = order?.spawned_ref?.resolve() + if(!QDELETED(buddy)) + . += buddy /** * One of their companions was attacked by `attacker`: it goes on their grudge list, and the gang * turns on them. Never while blended in (C1), and never over friendly fire (M1). */ -/mob/living/basic/bounty_criminal/proc/ai_ally_attacked(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/proc/ai_ally_attacked(mob/living/attacker) if(blended || !isliving(attacker) || bounty_ai_same_gang(src, attacker) || bounty_ai_friendly_fire(src, attacker)) return ai_add_grudge(attacker) @@ -397,18 +424,25 @@ // ===== AI ===== /** - * A companion's controller: the trooper's peaceful pattern (retaliate, call the others in, - * attack by style, go to whoever called), then hanging out with their criminal. + * A companion's controller: the trooper's peaceful pattern (retaliate, call the others in, attack by + * style with the real weapon, go to whoever called), patching themselves up when it is quiet (N6), then + * hanging out with their criminal. It gives up as its criminal does (human_npc_surrender). */ /datum/ai_controller/basic_controller/bounty/companion planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_walk_out, + /datum/ai_planning_subtree/human_npc_surrender, /datum/ai_planning_subtree/bounty_companion_gate, /datum/ai_planning_subtree/flee_target/bounty, /datum/ai_planning_subtree/target_retaliate/bounty, /datum/ai_planning_subtree/call_reinforcements/bounty, - /datum/ai_planning_subtree/basic_ranged_attack_subtree/bounty, - /datum/ai_planning_subtree/basic_melee_attack_subtree/bounty, + /datum/ai_planning_subtree/bounty_arm, + /datum/ai_planning_subtree/bounty_ranged, + /datum/ai_planning_subtree/bounty_melee, /datum/ai_planning_subtree/travel_to_point/and_clear_target/reinforce/bounty, + /datum/ai_planning_subtree/bounty_treat_self, /datum/ai_planning_subtree/bounty_companion_idle, ) @@ -418,53 +452,61 @@ * takes a pilot's mech on with them (H4). */ /datum/ai_controller/basic_controller/bounty/companion/TryPossessPawn(atom/new_pawn) - if(!istype(new_pawn, /mob/living/basic/bounty_companion)) + if(!istype(new_pawn, /mob/living/carbon/human/npc/bounty_companion)) return AI_CONTROLLER_INCOMPATIBLE - var/mob/living/basic/bounty_companion/companion = new_pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = new_pawn RegisterSignal(companion, COMSIG_LIVING_ENTER_STAMCRIT, PROC_REF(on_stamcrit)) + RegisterSignal(companion, COMSIG_HUMAN_NPC_RECOVERED, PROC_REF(on_recovered)) + RegisterSignal(companion, COMSIG_HUMAN_NPC_SURRENDERED, PROC_REF(on_surrendered)) return ..() /datum/ai_controller/basic_controller/bounty/companion/UnpossessPawn(destroy) if(pawn) - UnregisterSignal(pawn, COMSIG_LIVING_ENTER_STAMCRIT) + UnregisterSignal(pawn, list(COMSIG_LIVING_ENTER_STAMCRIT, COMSIG_HUMAN_NPC_RECOVERED, COMSIG_HUMAN_NPC_SURRENDERED)) return ..() /datum/ai_controller/basic_controller/bounty/companion/may_shoot(atom/target) - var/mob/living/basic/bounty_companion/companion = pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = pawn return istype(companion) && companion.ai_can_fight() && ..() /datum/ai_controller/basic_controller/bounty/companion/may_strike(atom/target) - var/mob/living/basic/bounty_companion/companion = pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = pawn return istype(companion) && companion.ai_can_fight() && ..() /datum/ai_controller/basic_controller/bounty/companion/pawn_attacked(mob/living/attacker) - var/mob/living/basic/bounty_companion/companion = pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = pawn if(istype(companion)) companion.ai_attacked_by(attacker) /datum/ai_controller/basic_controller/bounty/companion/pawn_damaged(damage, damagetype) - var/mob/living/basic/bounty_companion/companion = pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = pawn if(istype(companion)) companion.ai_took_damage(damage, damagetype) -/datum/ai_controller/basic_controller/bounty/companion/place_held(facing) - var/mob/living/basic/bounty_companion/companion = pawn - if(istype(companion)) - bounty_ai_place_held(companion, companion.ai_hand, facing) +/datum/ai_controller/basic_controller/bounty/companion/proc/on_stamcrit(mob/living/carbon/human/npc/bounty_companion/source) + SIGNAL_HANDLER + source.ai_stamcritted = TRUE + +/// Back up: from a real stamina crit, they decide whether to give up or run +/datum/ai_controller/basic_controller/bounty/companion/proc/on_recovered(mob/living/carbon/human/npc/bounty_companion/source) + SIGNAL_HANDLER + if(source.ai_stamcritted && source.stat == CONSCIOUS) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_companion, ai_after_stamcrit)) -/datum/ai_controller/basic_controller/bounty/companion/proc/on_stamcrit(mob/living/basic/bounty_companion/source) +/datum/ai_controller/basic_controller/bounty/companion/proc/on_surrendered(mob/living/carbon/human/npc/bounty_companion/source) SIGNAL_HANDLER - INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/basic/bounty_companion, ai_give_up)) + INVOKE_ASYNC(source, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_companion, ai_on_surrendered)) /** - * First in a companion's tree: nothing while given up or down; one that ran off only runs, and - * once clear of whoever it ran from, stays out of it. + * A companion's own subtrees start here, after soft crit, breaking free and giving up: nothing while + * it can't act (in crit, cuffed, stunned, giving up); one that ran off only runs, and once clear of + * whoever it ran from, stays out of it. */ /datum/ai_planning_subtree/bounty_companion_gate /datum/ai_planning_subtree/bounty_companion_gate/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_companion/companion = controller.pawn - if(!istype(companion) || companion.stat != CONSCIOUS || companion.ai_gave_up) + var/mob/living/carbon/human/npc/bounty_companion/companion = controller.pawn + if(!istype(companion) || companion.stat != CONSCIOUS || companion.npc_surrendering || HAS_TRAIT(companion, TRAIT_RESTRAINED)) return SUBTREE_RETURN_FINISH_PLANNING // Knocked down or stunned: nothing at all until they are up (H2) if(HAS_TRAIT(companion, TRAIT_FLOORED) || HAS_TRAIT(companion, TRAIT_INCAPACITATED)) @@ -497,7 +539,7 @@ /datum/ai_behavior/call_reinforcements/bounty/perform(seconds_per_tick, datum/ai_controller/controller) var/mob/living/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] - var/mob/living/basic/bounty_criminal/leader = bounty_ai_side_of(controller.pawn) + var/mob/living/carbon/human/npc/bounty_criminal/leader = bounty_ai_side_of(controller.pawn) if(leader && isliving(target)) leader.ai_ally_attacked(target) controller.set_blackboard_key(BB_BASIC_MOB_REINFORCEMENTS_COOLDOWN, world.time + BOUNTY_REINFORCE_COOLDOWN) @@ -514,10 +556,10 @@ /datum/ai_planning_subtree/bounty_companion_idle /datum/ai_planning_subtree/bounty_companion_idle/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_companion/companion = controller.pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = controller.pawn if(!companion.ai_can_fight()) return - var/mob/living/basic/bounty_criminal/leader = companion.ai_leader() + var/mob/living/carbon/human/npc/bounty_criminal/leader = companion.ai_leader() if(companion.ai_in_fight && !controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) && leader?.ai_mode != BOUNTY_AI_FIGHTING) companion.ai_leave_fight() if(!leader) @@ -541,7 +583,7 @@ behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION /datum/ai_behavior/bounty_companion_hang_out/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/bounty_companion/companion = controller.pawn + var/mob/living/carbon/human/npc/bounty_companion/companion = controller.pawn if(!istype(companion) || !companion.ai_can_fight()) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED companion.ai_hang_out() diff --git a/voidcrew/modules/bounties/bounty_criminal.dm b/voidcrew/modules/bounties/bounty_criminal.dm index e8f95e0702f..9db63f2a5eb 100644 --- a/voidcrew/modules/bounties/bounty_criminal.dm +++ b/voidcrew/modules/bounties/bounty_criminal.dm @@ -5,97 +5,48 @@ * bounty exemption line in voidcrew/_HELPERS/hostile_creatures.dm and the tests in * voidcrew/modules/unit_tests/voidcrew_bounty_criminal.dm. Do not edit this file outside P2. * - * What it owns: the criminal mob's body (health, stamina, status flags and the built-in values of - * /mob/living/basic/bounty_criminal, /meek and /normal), the downed state and its mercy rule and - * recovery, capture_state(), refusing teleports and polymorph, the planet-sweep protection, - * spawn_bounty_criminal(), the leash and who it may attack (spec sections 4 and 12). + * A criminal is a real human (/mob/living/carbon/human/npc, voidcrew/modules/human_npcs/), so its body + * is a player's: tg's soft and hard crit, stamina and stamcrit, stuns, wounds, bleeding, cuffs, grabs, + * fireman carries, fire, cold, suffocation, death and revival (owner decision D5, no stand-ins). What + * this file adds on top is the bounty's own rules: + * - Who it is (record, posting) and its look: its record's face (the human NPC base applies it), in the + * outfit its archetype and tier give it (bounty_outfits.dm). Toughness is the armour it wears and + * npc_damage_taken by tier (body_toughness()), never health. + * - capture_state(), read from tg at the moment it is asked: dead; in crit (soft or hard, even + * cuffed); restrained (tg's TRAIT_RESTRAINED: handcuffs, cable, zipties, a straitjacket); stunned + * (stamcrit, knockdown, stun, paralysis including tg's *surrender, unconscious, asleep); free. The pad + * pays on the state at the press (owner ruling 2026-11-08: no worst-state ratchet). + * - What it says: npc_bark() speaks through bounty_say() and strings/bounty_criminals.json, so the human + * NPC base's reactions (crit, getting up, cuffs, working the cuffs, breaking a grab, giving up) use the + * criminal lines. + * - Its site and leash (AR-D2, H1, M3): the site's rectangle, or the pirate ship it is wanted aboard, + * less every ship, hangar, player outpost and dock strip inside it. Its AI's movement keeps to it + * (/datum/ai_movement/jps/bounty); anyone can drag, carry or shove it anywhere. Off its site, and + * anywhere at a trader outpost, it fights only those on its grudge list and never structures. + * - No teleports (the pad deletes it), and a container it is in can't be teleported either; no fultons, + * no polymorph, no type change (M2). Turrets read its tg state (bounty_turret_ignores()). + * - Its body is the proof (AR-A5): a corpse is turned in for the dead share; a body destroyed with its + * posting open (gibbed, dusted, lost in a chasm or lava) leaves an evidence tag through P5's + * bounty_drop_proof(), unless it was removed on purpose (TRAIT_BOUNTY_REMOVED, or body_mark_removed()). + * A live body deleted never does. It can be revived like anyone; it pays by its condition when it + * reaches the pad. + * Cuffs and the reaction to a reach for its wrists are in bounty_restraints.dm. * - * The body, in short: - * - Health: meek BOUNTY_MEEK_HEALTH; normal by tier times its style; mini-bosses per kit (P4). - * - Stamina: max_stamina is always 100 (tg's basic mobs leave stamina crit by comparing raw points - * with a percentage), so a pool of another size is the stamina damage coefficient, 100 / pool. - * - Downed at BOUNTY_DOWNED_BELOW percent: floored, harmless and draggable. A hit on one still on its - * feet stops just under the line (the mercy rule), so killing it is always a choice; a hit on one - * that is down can kill. It gets up after its recovery time, groaning for the last - * BOUNTY_BODY_STIRRING_TELL, at BOUNTY_RECOVER_TO percent. Cuffed, it stays down; healing does not - * stand it up; a stamina hit starts the clock again (C1). A mini-boss that gets up rallies for - * BOUNTY_BODY_RALLY_TIME: stamina does nothing to it. - * - On its site, damage with nobody's mind behind it (turrets, traps, fire, fauna) stops at - * BOUNTY_AUTOMATED_FLOOR percent. /datum/component/bounty_body keeps the ledger (AR-D3): a hit is a - * player's when someone with a mind made it, fired it (a mech's or vehicle's shot counts its pilot), - * last touched the explosive, or stands near an explosion. - * - Fire and heat burn it like a suited person (L3), within the mercy line and the floor. - * - worst_state only ever gets worse (spec 12.1); P5 pays on it, and carries it to a respawned copy - * with body_restore_worst_state(). - * - On the floor and draggable (down, stunned, cuffed or dead), it can be fireman carried like a - * person. It squirms off the carrier's shoulders once it can't be dragged any more. - * - Dead, the body stays and can't be revived (AR-A5). A dead body destroyed with its posting open - * (gibbed, dusted, lost in a chasm) asks P5's bounty_drop_proof() for proof of death, unless it was - * removed on purpose (P5's TRAIT_BOUNTY_REMOVED, or body_mark_removed()). A live one deleted never - * does. P5 decides where the proof goes. - * - Its site: the site's rectangle (or the pirate ship it is wanted aboard), less every ship, hangar, - * player outpost and planet dock strip inside it (H1). Leashed to it (AR-D2): its own feet never - * take it off, nor onto a trader outpost's lift alcove, lava, a chasm or space. Once off (woken - * aboard a ship, dragged away), it may walk about where it is and back, but never on into another - * ship or hangar or into a player outpost (M3). Anyone can still drag it anywhere, and a scripted - * walk (TRAIT_BOUNTY_SCRIPTED_MOVE, or godmode) goes where it is sent. Off its site, and anywhere at - * a trader outpost, it fights only those on its grudge list and never structures (AR-D1, AR-C8). - * - No teleports (the pad deletes it), and a container it is in can't be teleported either; no - * fultons, no polymorph, no type change, no sentience (M2). Turrets at trader outposts leave it - * alone, and no turret shoots one that is down, stunned or restrained (bounty_turret_ignores()). - * Restraints are in bounty_restraints.dm. - * - * The shared vars are declared in bounty_types.dm and nowhere else; a var only P2 uses goes here - * with a `body_` prefix. + * What differs from a player's body is only the human NPC base's documented list (dormancy off-z, no + * mood or hunger, locked gear). The shared vars are declared in bounty_types.dm and nowhere else; a var + * only P2 uses goes here with a `body_` prefix. */ // ===== BUILT-IN VALUES ===== -/mob/living/basic/bounty_criminal +/mob/living/carbon/human/npc/bounty_criminal name = "criminal" - desc = "Someone with a price on their head." - icon = 'icons/mob/simple/simple_human.dmi' - unique_name = FALSE - maxHealth = BOUNTY_NORMAL_HEALTH_WANTED - health = BOUNTY_NORMAL_HEALTH_WANTED - speed = BOUNTY_BODY_NORMAL_SPEED_MELEE - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - mob_size = MOB_SIZE_HUMAN - sentience_type = BOUNTY_CRIMINAL_NO_SENTIENCE - density = TRUE - // Too heavy to shove about while free and on its feet; see body_update_drag(). - move_resist = MOVE_FORCE_VERY_STRONG - // No DEL_ON_DEATH: the body is the proof (AR-A5). - basic_mob_flags = NONE - // Batons knock them down, as they do people. - status_flags = CANPUSH | CANSTUN | CANKNOCKDOWN - max_stamina = BOUNTY_CRIMINAL_MAX_STAMINA - stamina_crit_threshold = BOUNTY_CRIMINAL_STAMCRIT_AT - stamina_regen_time = BOUNTY_NORMAL_STAMCRIT_TIME - // Downed, stunned or dead, they lie on the floor like a person. - mobility_flags = MOBILITY_FLAGS_REST_CAPABLE_DEFAULT - rotate_on_lying = TRUE - blood_volume = BLOOD_VOLUME_NORMAL - // Dressed for out there: vacuum and cold don't hurt them (D-A9). Weapons, lava, chasms and fire do; - // fire and heat through Life(), not tg's element, so the mercy line holds. - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - // tg's human deathgasp - death_message = "seizes up and falls limp, their eyes dead and lifeless..." - response_help_continuous = "taps" - response_help_simple = "tap" - /// Runs while it is down: the stirring tell, then getting up - var/body_recovery_timer - /// Down, and groaning as it tries to get up - var/body_stirring = FALSE - /// Its recovery came due while it was cuffed: it gets up once the cuffs are off - var/body_recovery_due = FALSE - /// world.time until which a mini-boss that just got up shrugs off stamina, and the timer that ends it - var/body_rally_until = 0 - var/body_rally_timer - /// world.time a projectile last got through to it, for the meek's dodge - var/body_last_shot_at = -1 + faction = list(FACTION_HOSTILE) + // Criminals stop once their target is down (spec C4, defaults 1.13). Their area effects still hit whoever stands there. + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + // The body is the proof: its posting decides when it goes (bounty_posting.dm), never the corpse sweep. + npc_corpse_lifetime = 0 + npc_outfit_table = /datum/human_npc_outfit_table/bounty /// Removed on purpose (beamed out by the pad, cleaned up by its posting, its site unloading): being deleted is not a death, so it leaves no proof var/body_removed = FALSE /// Its proof of death is down already @@ -104,59 +55,39 @@ var/datum/weakref/body_site_ref /// The top row of its planet's dock strip, or null var/body_dock_top_y - /// Someone is putting restraints on it or taking them off (bounty_restraints.dm) - var/body_cuff_work = FALSE - /// The timers for its restraints slipping: the visible struggle, then the slip (bounty_restraints.dm) - var/body_slip_warn_timer - var/body_slip_timer /// Weakref to the container holding it (a locker, a crate, a body bag), kept from being teleported while it is inside var/datum/weakref/body_holder_ref /// The last balloon floated over it for hunters (body_alert_hunters()): "worn out" var/body_last_alert + /// Its toughness comes from its record's tier (body_toughness()). A subtype with a fixed toughness of its own (a mini-boss, the kingpin) turns this off. + var/body_tier_toughness = TRUE -/mob/living/basic/bounty_criminal/meek - maxHealth = BOUNTY_MEEK_HEALTH - health = BOUNTY_MEEK_HEALTH - speed = BOUNTY_BODY_MEEK_SPEED - max_stamina_slowdown = BOUNTY_MEEK_STAMINA_SLOWDOWN - stamina_regen_time = BOUNTY_MEEK_STAMCRIT_TIME - -/mob/living/basic/bounty_criminal/Initialize(mapload) +/mob/living/carbon/human/npc/bounty_criminal/Initialize(mapload, datum/human_npc_order/order) + // Who it is must be known before the base dresses it: the outfit table reads the record (bounty_outfits.dm). + if(istype(order?.look, /datum/bounty_record)) + record = order.look . = ..() - // Its own copy: the stamina pool here, and a mini-boss's armour (P4), change it per mob. - damage_coeff = damage_coeff.Copy() - body_update_stamina_coeff() - // TRAIT_CAN_MOUNT_HUMANS: someone can carry it over their shoulders (body_can_be_carried()) - add_traits(list(TRAIT_NO_TELEPORT, TRAIT_NOMOBSWAP, TRAIT_CAN_MOUNT_HUMANS), BOUNTY_BODY_TRAIT) + add_traits(list(TRAIT_NO_TELEPORT, TRAIT_NOMOBSWAP), BOUNTY_BODY_TRAIT) AddComponent(/datum/component/bounty_body) - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) + body_hook_cuffs() + body_toughness() -/mob/living/basic/bounty_criminal/Destroy() +/mob/living/carbon/human/npc/bounty_criminal/Destroy() if(body_leaves_proof()) body_drop_proof() - deltimer(body_recovery_timer) - body_recovery_timer = null - body_remove_cuffs() body_set_holder(null) grudge?.Cut() body_site_ref = null return ..() -// Fire and heat burn it like a suited person (L3). Not forced, so the mercy line and the automated floor hold. Mini-bosses keep P4's own fire rules. -/mob/living/basic/bounty_criminal/Life(seconds_per_tick = SSMOBS_DT, times_fired) - . = ..() - if(stat == DEAD || istype(src, /mob/living/basic/bounty_criminal/boss)) - return - if(on_fire || bodytemperature > BOUNTY_CRIMINAL_MAX_TEMP) - adjustFireLoss(BOUNTY_CRIMINAL_BURN_DAMAGE * seconds_per_tick) - // ===== SPAWNING ===== /** * Spawns the criminal for `record` at `where`, wanted on `posting` (or on nothing, for an admin - * spawn): the subtype for its archetype and kit, with the body for its tier and style, leashed to the - * posting's site, protected from the planet sweep and dressed in its look. Returns the mob. Decoys - * are made by P6 directly. + * spawn): a real human of the subtype for its archetype and kit, built now (one body, so it doesn't + * wait in the human NPC queue), in its record's face and name, dressed for its archetype, tier and + * site, leashed to the posting's site and protected from the planet sweep. Returns the mob. Decoys are + * made by P6 directly. */ /proc/spawn_bounty_criminal(datum/bounty_record/record, turf/where, datum/criminal_bounty/posting) where = get_turf(where) @@ -165,9 +96,12 @@ var/criminal_type = bounty_criminal_type(record, posting) var/atom/site = posting?.site_ref?.resolve() var/datum/npc_spawn_context/context = new(site || where, null, "ordinary", source_turf = site ? get_turf(site) : where) - var/mob/living/basic/bounty_criminal/criminal = spawn_npc_in_zone(criminal_type, where, context, FALSE) + var/datum/human_npc_order/order = spawn_human_npc(criminal_type, where, look = record, name = record?.name, context = context, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = order?.spawned_ref?.resolve() + qdel(order) + if(QDELETED(criminal)) + return null criminal.body_setup(record, posting) - context.finalize() return criminal /// The criminal type for `record` wanted on `posting`: meek, normal, or the mini-boss of its kit. A mini-boss never goes to a trader outpost; a normal criminal stands in (D-A8). @@ -177,138 +111,75 @@ archetype = BOUNTY_ARCHETYPE_NORMAL switch(archetype) if(BOUNTY_ARCHETYPE_MEEK) - return /mob/living/basic/bounty_criminal/meek + return /mob/living/carbon/human/npc/bounty_criminal/meek if(BOUNTY_ARCHETYPE_BOSS) switch(record.kit) if(BOUNTY_KIT_JUGGERNAUT) - return /mob/living/basic/bounty_criminal/boss/juggernaut + return /mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut if(BOUNTY_KIT_PYROMANIAC) - return /mob/living/basic/bounty_criminal/boss/pyromaniac + return /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac if(BOUNTY_KIT_DEMOLITIONIST) - return /mob/living/basic/bounty_criminal/boss/demolitionist + return /mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist if(BOUNTY_KIT_GHOST) - return /mob/living/basic/bounty_criminal/boss/ghost + return /mob/living/carbon/human/npc/bounty_criminal/boss/ghost if(BOUNTY_KIT_HEAVY) - return /mob/living/basic/bounty_criminal/boss/heavy + return /mob/living/carbon/human/npc/bounty_criminal/boss/heavy return pick( - /mob/living/basic/bounty_criminal/boss/juggernaut, - /mob/living/basic/bounty_criminal/boss/pyromaniac, - /mob/living/basic/bounty_criminal/boss/demolitionist, - /mob/living/basic/bounty_criminal/boss/ghost, - /mob/living/basic/bounty_criminal/boss/heavy, + /mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, + /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac, + /mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist, + /mob/living/carbon/human/npc/bounty_criminal/boss/ghost, + /mob/living/carbon/human/npc/bounty_criminal/boss/heavy, ) - return /mob/living/basic/bounty_criminal/normal + return /mob/living/carbon/human/npc/bounty_criminal/normal /** - * Makes it the person in `new_record`, wanted on `new_posting` (either may be null): name, the body - * for its tier and style, the leash to the posting's site, the planet-sweep protection, and its look, - * put on in the background. spawn_bounty_criminal() calls it once, right after making the mob. + * Makes it the person in `new_record`, wanted on `new_posting` (either may be null): name, toughness + * for its tier, the leash to the posting's site and the planet-sweep protection. spawn_bounty_criminal() + * calls it once, right after making the mob; its face and clothes are already on (the human NPC base + * dresses it from the record as it is made). */ -/mob/living/basic/bounty_criminal/proc/body_setup(datum/bounty_record/new_record, datum/criminal_bounty/new_posting) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_setup(datum/bounty_record/new_record, datum/criminal_bounty/new_posting) record = new_record posting_ref = new_posting ? WEAKREF(new_posting) : null - if(record?.name) - name = record.name - real_name = record.name - if(record?.gender == MALE || record?.gender == FEMALE) - gender = record.gender - var/tier_health = body_tier_health() - if(tier_health) - maxHealth = tier_health - updatehealth() - if(istype(src, /mob/living/basic/bounty_criminal/normal)) - var/ranged = record?.style == BOUNTY_STYLE_PISTOL || record?.style == BOUNTY_STYLE_BOTTLE - set_varspeed(ranged ? BOUNTY_BODY_NORMAL_SPEED_RANGED : BOUNTY_BODY_NORMAL_SPEED_MELEE) - body_update_stamina_coeff() + if(record?.name && real_name != record.name) + fully_replace_character_name(real_name, record.name) + body_toughness() body_set_site(new_posting?.site_ref?.resolve()) if(new_posting) // The planet's fauna sweep leaves it be while it is wanted (missions/objectives/combat.dm). P5 takes it off when the posting ends. ADD_TRAIT(src, TRAIT_MISSION_FIELD_MOB, BOUNTY_TRAIT) - INVOKE_ASYNC(src, PROC_REF(body_dress)) -/// A normal criminal's health: its tier's, times its style's. Null for anyone else, who keeps their built-in health. -/mob/living/basic/bounty_criminal/proc/body_tier_health() - if(!istype(src, /mob/living/basic/bounty_criminal/normal)) - return null - var/base = BOUNTY_NORMAL_HEALTH_WANTED +/** + * How much brute and burn it takes, as a multiple of a person's, through its physiology (the human + * NPC base's npc_damage_taken): by its record's tier, meek criminals at a person's. A subtype with a + * fixed value of its own sets body_tier_toughness = FALSE. Its armour does the rest. + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/body_toughness() + if(!body_tier_toughness) + return + var/wanted = BOUNTY_TOUGHNESS_PETTY switch(record?.tier) - if(BOUNTY_TIER_PETTY) - base = BOUNTY_NORMAL_HEALTH_PETTY + if(BOUNTY_TIER_WANTED) + wanted = BOUNTY_TOUGHNESS_WANTED if(BOUNTY_TIER_MOST_WANTED) - base = BOUNTY_NORMAL_HEALTH_MOST - return round(base * bounty_style_health_mult(record?.style), 1) - -/// How much tougher or frailer than its tier a fighting style makes a normal criminal -/proc/bounty_style_health_mult(style) - switch(style) - if(BOUNTY_STYLE_BRAWLER) - return BOUNTY_STYLE_HEALTH_BRAWLER - if(BOUNTY_STYLE_KNIFE) - return BOUNTY_STYLE_HEALTH_KNIFE - if(BOUNTY_STYLE_PISTOL) - return BOUNTY_STYLE_HEALTH_PISTOL - if(BOUNTY_STYLE_SHOTGUN) - return BOUNTY_STYLE_HEALTH_SHOTGUN - if(BOUNTY_STYLE_CLUB) - return BOUNTY_STYLE_HEALTH_CLUB - if(BOUNTY_STYLE_BOTTLE) - return BOUNTY_STYLE_HEALTH_BOTTLE - return 1 - -/// How much stamina damage stamina-crits it: a meek criminal's pool, or anyone else's health -/mob/living/basic/bounty_criminal/proc/body_stamina_pool() - if(istype(src, /mob/living/basic/bounty_criminal/meek)) - return BOUNTY_MEEK_STAMINA - return maxHealth - -/// Sets its stamina damage coefficient from its pool (max_stamina / pool), times its style's. A mini-boss's is P4's. -/mob/living/basic/bounty_criminal/proc/body_update_stamina_coeff() - if(istype(src, /mob/living/basic/bounty_criminal/boss)) - return - var/pool = body_stamina_pool() - if(pool <= 0) - return - var/style_mult = record?.style == BOUNTY_STYLE_BRAWLER ? BOUNTY_STYLE_STAMINA_BRAWLER : 1 - damage_coeff[STAMINA] = style_mult * max_stamina / pool + wanted = BOUNTY_TOUGHNESS_MOST_WANTED + if(istype(src, /mob/living/carbon/human/npc/bounty_criminal/meek)) + wanted = BOUNTY_TOUGHNESS_PETTY + if(wanted != npc_damage_taken) + set_npc_toughness(wanted) /** - * Gives it the same body as `other`: health, pace and stamina. For a trader-outpost decoy (P6), which - * must be as tough and as quick as the fugitive it stands in for (AR-C6). + * Gives it the same toughness and pace as `other`: a trader-outpost decoy (P6) must be as hard to put + * down and as quick as the fugitive it stands in for (AR-C6). Its armour comes from its own clothes. */ -/mob/living/basic/bounty_criminal/proc/body_match(mob/living/basic/bounty_criminal/other) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_match(mob/living/carbon/human/npc/bounty_criminal/other) if(QDELETED(other) || other == src) return - // Copy raw stats before finalizing the inherited profile, never its scaled pool. - var/datum/npc_spawn_context/context = npc_spawn_context - if(!context || context.source_id != other.npc_spawn_context?.source_id) - context = other.bounty_profile_context() - if(npc_zone_applied) - UnregisterSignal(src, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE) - melee_damage_lower = npc_zone_original_melee_lower - melee_damage_upper = npc_zone_original_melee_upper - npc_zone_applied = FALSE - npc_zone_health_factor = 1 - npc_zone_damage_factor = 1 - npc_spawn_context = context - maxHealth = other.npc_zone_applied ? other.npc_zone_original_max_health : other.maxHealth - var/health_share = other.maxHealth > 0 ? other.health / other.maxHealth : 1 - setBruteLoss(maxHealth * (1 - health_share)) - updatehealth() - context.bodies |= WEAKREF(src) - context.finalize() - set_varspeed(other.speed) - damage_coeff[STAMINA] = other.damage_coeff[STAMINA] - stamina_regen_time = other.stamina_regen_time - if(max_stamina_slowdown != other.max_stamina_slowdown) - // The same arguments make_stamina_slowable() added it with, or the element is not found. - RemoveElement(/datum/element/basic_stamina_slowdown, minium_stamina_threshold = max_stamina / 3, maximum_stamina = max_stamina, maximum_slowdown = max_stamina_slowdown) - max_stamina_slowdown = other.max_stamina_slowdown - make_stamina_slowable() - -/// Decoys use the source's selected role before construction, including named boss bodies. -/mob/living/basic/bounty_criminal/proc/bounty_profile_context() - var/role = npc_zone_role || (npc_zone_boss_type(type) ? "boss" : npc_spawn_context?.role || "ordinary") - return new /datum/npc_spawn_context(src, null, role) + if(npc_damage_taken != other.npc_damage_taken) + set_npc_toughness(other.npc_damage_taken) + if(move_intent != other.move_intent) + toggle_move_intent() // ===== THE SITE ===== @@ -317,7 +188,7 @@ * site's rectangle, or for a pirate ship the hull itself. A site with no rectangle to read gets a box * around where it stands. With no site at all (an admin spawn) it has no leash. */ -/mob/living/basic/bounty_criminal/proc/body_set_site(atom/site) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_set_site(atom/site) body_site_ref = site ? WEAKREF(site) : null site_bounds = bounty_site_bounds(site) body_dock_top_y = bounty_site_dock_top(site) @@ -356,17 +227,17 @@ return planet.get_dock_strip_top_y(level) /// The overmap object of its site: the one it was spawned for, else its posting's -/mob/living/basic/bounty_criminal/proc/body_site() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_site() var/obj/structure/overmap/site = body_site_ref?.resolve() || posting()?.site_ref?.resolve() return (istype(site) && !QDELETED(site)) ? site : null /// The pirate ship that is its site, if it is wanted aboard one -/mob/living/basic/bounty_criminal/proc/body_site_ship() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_site_ship() var/obj/structure/overmap/ship/site_ship = body_site() return istype(site_ship) ? site_ship : null /// Whether it has a site to be leashed to -/mob/living/basic/bounty_criminal/proc/body_has_site() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_has_site() return !!site_bounds || !!body_site_ship() /** @@ -374,7 +245,7 @@ * on), and not part of a ship, a hangar, a player outpost or a planet's dock strip that happens to lie * inside that rectangle (H1). With no site, anywhere but those is. */ -/mob/living/basic/bounty_criminal/proc/body_on_site(turf/place) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_site(turf/place) place = get_turf(place) if(!place || body_forbidden_turf(place)) return FALSE @@ -386,14 +257,14 @@ return TRUE /// Whether `place` is never its ground: a ship (other than the pirate ship it is wanted aboard), a hangar, a player outpost, or its planet's dock strip -/mob/living/basic/bounty_criminal/proc/body_forbidden_turf(turf/place) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_forbidden_turf(turf/place) return !!body_hold(place) || body_on_dock_strip(place) /** * The ship, hangar or player outpost `place` is part of, or null: the pirate ship it is wanted aboard * is its site, not a hold. A ship is known by its docking port, so every room aboard is one hold. */ -/mob/living/basic/bounty_criminal/proc/body_hold(turf/place) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_hold(turf/place) var/area/place_area = get_area(place) if(istype(place_area, /area/shuttle)) var/obj/structure/overmap/ship/site_ship = body_site_ship() @@ -406,22 +277,22 @@ return null /// Whether `place` is on its planet's dock strip, where ships land (L1) -/mob/living/basic/bounty_criminal/proc/body_on_dock_strip(turf/place) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_dock_strip(turf/place) if(isnull(body_dock_top_y) || !place) return FALSE return (!site_bounds || place.z == site_bounds[5]) && place.y <= body_dock_top_y /// Whether it is at a trader outpost, wanted there or standing in one: there it fights only its grudge list (AR-C8) -/mob/living/basic/bounty_criminal/proc/body_at_trader_outpost() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_at_trader_outpost() return posting()?.placement_kind == BOUNTY_PLACEMENT_TRADER_OUTPOST || istype(get_area(src), /area/voidcrew/trader_outpost) /** * Whether it may go onto `destination` by itself (AR-D2, AR-E1, L1): on its site (so never a ship, a * hangar, a player outpost or a dock strip), never a lift alcove that would carry it off, and never - * lava, a chasm or open space. P3 checks hiding spots and flight paths with it; its own steps are held - * to it too (Move()). + * lava, a chasm or open space. P3 checks hiding spots and flight paths with it, and its AI's movement + * holds its own steps to it (body_move_allowed()). */ -/mob/living/basic/bounty_criminal/proc/leash_ok(turf/destination) +/mob/living/carbon/human/npc/bounty_criminal/proc/leash_ok(turf/destination) destination = get_turf(destination) if(!destination || !body_on_site(destination)) return FALSE @@ -430,23 +301,19 @@ return FALSE return destination.can_cross_safely(src) -// Its own steps stay on the leash (M3). Dragged, carried along, thrown or walked by a script, it goes where it is taken. -/mob/living/basic/bounty_criminal/Move(atom/newloc, direct, glide_size_override) - if(!body_move_allowed(newloc)) - return FALSE - return ..() - -/// Whether a script is walking it on purpose: TRAIT_BOUNTY_SCRIPTED_MOVE, or godmode (P6's walk out to the lift) -/mob/living/basic/bounty_criminal/proc/body_scripted() - return HAS_TRAIT(src, TRAIT_BOUNTY_SCRIPTED_MOVE) || HAS_TRAIT(src, TRAIT_GODMODE) +/// Whether a script is walking it on purpose (TRAIT_BOUNTY_SCRIPTED_MOVE), or it is walking out (the human NPC base's npc_walk_out()) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_scripted() + return HAS_TRAIT(src, TRAIT_BOUNTY_SCRIPTED_MOVE) || !isnull(npc_exit) /** - * Whether a step of its own onto `newloc` is allowed. Onto its leash, always. Off it from on it, - * never. Already off it (woken aboard a ship, dragged off its site, carried somewhere), it may walk - * about and back toward its site, but never into a hazard and never on into a ship, hangar or player - * outpost other than the one it is already in (M3). + * Whether a step of its own onto `newloc` is allowed: the leash its AI's movement keeps to + * (/datum/ai_movement/jps/bounty, and any step the AI takes by hand). Onto its leash, always. Off it + * from on it, never. Already off it (woken aboard a ship, dragged off its site, carried somewhere), it + * may walk about and back toward its site, but never into a hazard and never on into a ship, hangar or + * player outpost other than the one it is already in (M3). Its body is never held back: anyone can drag, + * carry, throw or shove it anywhere. */ -/mob/living/basic/bounty_criminal/proc/body_move_allowed(atom/newloc) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_move_allowed(atom/newloc) if(!isturf(newloc) || !isturf(loc) || pulledby || buckled || throwing || stat == DEAD || body_scripted()) return TRUE var/turf/destination = newloc @@ -463,7 +330,7 @@ * BOUNTY_ENV_SAFE_RANGE tiles of a ship (other than the pirate ship it is aboard), a hangar, a player * outpost or a planet's dock strip. */ -/mob/living/basic/bounty_criminal/proc/environment_damage_allowed(turf/target_turf) +/mob/living/carbon/human/npc/bounty_criminal/proc/environment_damage_allowed(turf/target_turf) target_turf = get_turf(target_turf) if(!target_turf || !body_on_site(target_turf) || body_at_trader_outpost()) return FALSE @@ -482,15 +349,16 @@ // ===== WHO IT FIGHTS ===== /** - * Whether it may attack `target` (AR-D1, AR-C8, C4). Never while down, restrained or surrendered, - * never someone who is down, and never its own side. Off its site, and anywhere at a trader outpost, - * only those on its grudge list, and no structures. On its site it may break structures that are on - * its site and not part of a ship, a hangar or an outpost. Its melee and ranged attacks are held to it. + * Whether it may attack `target` (AR-D1, AR-C8, C4). Never while it can't fight (dead, in crit, + * restrained, lying there with its hands on its head after giving up), and never someone who is down, + * or its own side. Off its site, and anywhere at a trader outpost, only those on its grudge list, and no + * structures. On its site it may break structures that are on its site and not part of a ship, a hangar + * or an outpost. Its AI's targeting is held to it. */ -/mob/living/basic/bounty_criminal/proc/may_attack(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/proc/may_attack(atom/target) if(QDELETED(target) || target == src) return FALSE - if(stat != CONSCIOUS || downed || is_restrained() || HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED)) + if(stat != CONSCIOUS || is_downed() || is_restrained() || npc_surrendering) return FALSE return may_attack_scope(target) @@ -500,7 +368,7 @@ * grudge list off its site and at a trader outpost, structures only on its own ground. Its * companions go by this alone, so they fight on when it is down, cuffed, dead or has given up. */ -/mob/living/basic/bounty_criminal/proc/may_attack_scope(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/proc/may_attack_scope(atom/target) if(QDELETED(target) || target == src) return FALSE // A ship, hangar, outpost or dock strip inside the site's rectangle is not its site (H1) @@ -520,15 +388,19 @@ var/turf/target_turf = get_turf(target) return body_on_site(target_turf) && !body_forbidden_turf(target_turf) for(var/mob/living/person as anything in people) - if(person.stat != CONSCIOUS || istype(person, /mob/living/basic/bounty_criminal) || istype(person, /mob/living/basic/bounty_companion)) + if(person.stat != CONSCIOUS || bounty_is_gang_body(person)) continue if(!limited || body_has_grudge(person)) return TRUE return FALSE +/// Whether `thing` is a bounty criminal or a companion: never on anyone's grudge list, never counted as a player +/proc/bounty_is_gang_body(atom/thing) + return istype(thing, /mob/living/carbon/human/npc/bounty_criminal) || istype(thing, /mob/living/carbon/human/npc/bounty_companion) + /// Remembers `who` as someone it may fight anywhere: they attacked it, confronted it or cuffed it. P3 calls it when someone confronts it. -/mob/living/basic/bounty_criminal/proc/body_add_grudge(mob/living/who) - if(!isliving(who) || who == src || istype(who, /mob/living/basic/bounty_criminal) || istype(who, /mob/living/basic/bounty_companion)) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_add_grudge(mob/living/who) + if(!isliving(who) || who == src || bounty_is_gang_body(who)) return var/datum/weakref/who_ref = WEAKREF(who) if(who_ref in grudge) @@ -538,26 +410,25 @@ grudge.Cut(1, 2) /// Whether `who` is on its grudge list -/mob/living/basic/bounty_criminal/proc/body_has_grudge(mob/living/who) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_has_grudge(mob/living/who) return !isnull(who?.weak_reference) && (who.weak_reference in grudge) -// Its own fists follow may_attack(). -/mob/living/basic/bounty_criminal/early_melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - if(!may_attack(target)) - return FALSE - return ..() +// Bounty NPCs never land a gun execution this phase: a boss's deliberate, telegraphed execution (OC-9) +// isn't built yet, and an accidental point-blank head shot must not stand in for it. Aimed crits still +// follow npc_rank (D4). +/mob/living/carbon/human/npc/bounty_criminal/hv_can_execute() + return !!client // ===== CAPTURE STATE ===== /** - * What the pad pays on (BOUNTY_STATE_*), checked in order (AR-A3): dead; downed (even if cuffed or - * healed); restrained with its own restraints; stunned (stamina crit, knocked down, stunned, - * paralysed, unconscious, or surrendered); free. + * What the pad pays on (BOUNTY_STATE_*), read from tg in this order (AR-A3): dead; in crit, soft or hard, + * even if cuffed; restrained; stunned; free. The pad reads it once, at the press. */ -/mob/living/basic/bounty_criminal/proc/capture_state() +/mob/living/carbon/human/npc/bounty_criminal/proc/capture_state() if(stat == DEAD) return BOUNTY_STATE_DEAD - if(downed) + if(is_downed()) return BOUNTY_STATE_DOWNED if(is_restrained()) return BOUNTY_STATE_RESTRAINED @@ -565,17 +436,20 @@ return BOUNTY_STATE_STUNNED return BOUNTY_STATE_FREE -/// Whether it is down and alive: floored and harmless until it recovers -/mob/living/basic/bounty_criminal/proc/is_downed() - return downed && stat != DEAD +/// Whether it is alive and in crit (tg's TRAIT_CRITICAL_CONDITION: soft or hard crit) +/mob/living/carbon/human/npc/bounty_criminal/proc/is_downed() + return stat != DEAD && HAS_TRAIT(src, TRAIT_CRITICAL_CONDITION) -/// Whether its own restraints are on it -/mob/living/basic/bounty_criminal/proc/is_restrained() - return !QDELETED(restraints) && restraints.loc == src +/// Whether it is restrained: tg's handcuffs, cable or zipties, or a straitjacket +/mob/living/carbon/human/npc/bounty_criminal/proc/is_restrained() + return HAS_TRAIT(src, TRAIT_RESTRAINED) -/// Whether it is stunned: stamina crit, knocked down, stunned, paralysed, out cold, or surrendered (TRAIT_BOUNTY_SURRENDERED) -/mob/living/basic/bounty_criminal/proc/body_is_stunned() - if(HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED) || has_status_effect(/datum/status_effect/incapacitating/stamcrit)) +/** + * Whether it is stunned: stamina crit, knocked down, stunned, paralysed (tg's *surrender is a 20 second + * paralysis), unconscious or asleep (a sedative dart counts). + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/body_is_stunned() + if(stat == UNCONSCIOUS || has_status_effect(/datum/status_effect/incapacitating/stamcrit)) return TRUE return IsKnockdown() || IsStun() || IsParalyzed() || IsUnconscious() || IsSleeping() @@ -590,192 +464,49 @@ return 3 return 4 -/// Records its capture state in worst_state if it is as bad or worse: once downed or dead, it never gets better (spec 12.1) -/mob/living/basic/bounty_criminal/proc/body_note_state() - var/state = capture_state() - if(state != BOUNTY_STATE_FREE) - posting()?.board_note_work_zone(src) - if(bounty_state_rank(state) <= bounty_state_rank(worst_state)) - worst_state = state - /** - * Carries a worst state over from an earlier copy of this criminal (M4): P5 respawns the same record - * when its site loads again, and pay must not forget that it was downed or killed before. Only ever - * makes worst_state worse. Returns the worst state now. + * Something about its state may have changed (stat, crit, cuffs, a stun): the first catch or death + * binds the zone the bounty's reputation counts in. Nothing else is remembered; the pad reads the + * body when it is pressed. */ -/mob/living/basic/bounty_criminal/proc/body_restore_worst_state(state) - if(state && bounty_state_rank(state) < bounty_state_rank(worst_state)) - worst_state = state - return worst_state - -/// Something about its state changed: worst_state, and whether it can be dragged or carried -/mob/living/basic/bounty_criminal/proc/body_state_changed() - body_note_state() - body_update_drag() - body_update_carry() - -/mob/living/basic/bounty_criminal/set_stat(new_stat) - . = ..() - body_state_changed() - -/mob/living/basic/bounty_criminal/on_floored_trait_gain(datum/source) - . = ..() - body_state_changed() - -/mob/living/basic/bounty_criminal/on_floored_trait_loss(datum/source) - . = ..() - body_state_changed() - -/mob/living/basic/bounty_criminal/on_incapacitated_trait_gain(datum/source) - . = ..() - body_state_changed() - -/mob/living/basic/bounty_criminal/on_incapacitated_trait_loss(datum/source) - . = ..() - body_state_changed() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_state_changed() + if(capture_state() != BOUNTY_STATE_FREE) + posting()?.board_note_work_zone(src) -// ===== DRAGGING ===== +// ===== WHAT IT SAYS ===== -/** - * Whether it can be dragged: dead, downed, restrained or stunned. Blending in at a trader outpost it - * shakes off any hand until it is down or cuffed (AR-C7), the same for the fugitive and its decoys. - * Free and on its feet it pulls away. - */ -/mob/living/basic/bounty_criminal/proc/body_can_be_dragged() - if(stat == DEAD || downed || is_restrained()) - return TRUE - if(blended) +// The human NPC base's reactions (human_npc_barks.dm) speak the criminal's own lines (strings/bounty_criminals.json). +/mob/living/carbon/human/npc/bounty_criminal/npc_bark(kind, force = FALSE) + if(QDELETED(src) || !istext(kind)) return FALSE - return body_is_stunned() - -/// Too heavy to shove while free; ordinary once it can be dragged. Anyone dragging it loses their grip when it can't be. -/mob/living/basic/bounty_criminal/proc/body_update_drag() - var/draggable = body_can_be_dragged() - move_resist = draggable ? MOVE_RESIST_DEFAULT : MOVE_FORCE_VERY_STRONG - if(draggable || !pulledby) - return - visible_message(span_warning("[src] pulls free of [pulledby].")) - pulledby.stop_pulling() - -/mob/living/basic/bounty_criminal/can_be_pulled(user, force) - if(!body_can_be_dragged()) - // P3 REVIEW ROUND (L1): the pull attempt is still announced before it is refused, so P3 hears - // it (pulling at a hider finds it) and P6's blend hooks see it. A hider shows no balloon: it - // is being found instead. - SEND_SIGNAL(src, COMSIG_ATOM_CAN_BE_PULLED, user) - if(ismob(user) && !hidden) - balloon_alert(user, "[p_they()] pull[p_s()] away!") - return FALSE - body_update_drag() - return ..() - -// ===== CARRYING ===== - -/// Whether someone can lift it over their shoulders (a fireman carry): on the floor, and in a state to be dragged -/mob/living/basic/bounty_criminal/proc/body_can_be_carried() - return body_position == LYING_DOWN && body_can_be_dragged() - -/// Carried, and no longer in a state to be dragged (it came round, or got up): it gets off whoever is carrying it -/mob/living/basic/bounty_criminal/proc/body_update_carry() - if(!ishuman(buckled) || body_can_be_dragged()) - return - var/mob/living/carrier = buckled - carrier.unbuckle_mob(src, force = TRUE) - visible_message(span_warning("[src] squirms off [carrier]'s shoulders.")) - -/** - * Turns `body` flat when it lies down out of a chair or off someone's shoulders (set_lying_down()). - * tg turns only carbons there (/mob/living/carbon/on_lying_down()); any other mob gets its angle from - * on_floored_start(), which is skipped while a chair holds it up. Without this, a criminal or a goon - * unbuckled while down or dead lies down but still looks like it is standing. - */ -/proc/bounty_body_lie_flat(mob/living/body, new_lying_angle) - if(!body.rotate_on_lying || body.body_position != LYING_DOWN || body.lying_angle) - return - if(body.buckled && body.buckled.buckle_lying == 0) - return - body.set_lying_angle(new_lying_angle || pick(LYING_ANGLE_EAST, LYING_ANGLE_WEST)) + // Crawling in soft crit it pleads with its downed lines. + if(kind == HUMAN_NPC_BARK_PLEAD) + kind = HUMAN_NPC_BARK_DOWNED + // The capture's moments are always heard, past the line cooldown (BUG-1): the cuffs go on moments after a fall. + if(kind == HUMAN_NPC_BARK_CUFFED) + force = TRUE + npc_last_bark = kind + INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), kind, null, force) + return TRUE -/mob/living/basic/bounty_criminal/set_lying_down(new_lying_angle) +/mob/living/carbon/human/npc/bounty_criminal/npc_on_downed() + visible_message(span_danger("[src] collapses.")) . = ..() - bounty_body_lie_flat(src, new_lying_angle) - -// A criminal lying on the floor can be carried like a person, as long as it can be dragged -/mob/living/carbon/human/can_be_firemanned(mob/living/carbon/target) - if(istype(target, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = target - return criminal.body_can_be_carried() - return ..() - -// ===== DOWNED, MERCY AND RECOVERY ===== - -/// Its health at the downed line -/mob/living/basic/bounty_criminal/proc/body_downed_line() - return maxHealth * BOUNTY_DOWNED_BELOW / 100 - -// Every kind of damage a basic mob takes comes through here: the mercy rule and the automated floor hold it back. -/mob/living/basic/bounty_criminal/adjust_health(amount, updating_health = TRUE, forced = FALSE) - if(amount > 0 && !forced && stat != DEAD) - amount = body_allowed_damage(amount) - return ..(amount, updating_health, forced) - -/** - * How much of `amount` damage gets through. On its feet (stamina crit counts), a hit stops at 1 under - * the downed line: the rest is dropped. On its site, damage with no mind behind it stops at - * BOUNTY_AUTOMATED_FLOOR percent. Down, its own side's blows miss it entirely. Anything else gets through. - */ -/mob/living/basic/bounty_criminal/proc/body_allowed_damage(amount) - var/datum/component/bounty_body/ledger = GetComponent(/datum/component/bounty_body) - var/player_hit = ledger?.player_hit_now() - if(downed && !player_hit && ledger?.own_side_hit_now()) - return 0 - var/floor = downed ? 0 : body_downed_line() - 1 - if(!player_hit && body_has_site() && body_on_site(get_turf(src))) - floor = max(floor, maxHealth * BOUNTY_AUTOMATED_FLOOR / 100) - var/health_left = maxHealth - bruteloss - if(health_left - amount >= floor) - return amount - return max(0, health_left - floor) - -// An explosion names no attacker; the ledger works out whose it was before the blast lands (M1). -/mob/living/basic/bounty_criminal/ex_act(severity, target, origin) - var/datum/component/bounty_body/ledger = GetComponent(/datum/component/bounty_body) - ledger?.note_explosion(origin) - return ..() + body_state_changed() + SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_DOWNED) -/mob/living/basic/bounty_criminal/updatehealth() +/mob/living/carbon/human/npc/bounty_criminal/npc_on_recovered() + if(stat == CONSCIOUS && !is_restrained()) + visible_message(span_warning("[src] gets back up.")) . = ..() - body_check_downed() - -/// Downs it once its health reaches the downed line -/mob/living/basic/bounty_criminal/proc/body_check_downed() - if(downed || stat == DEAD || QDELETED(src) || health > body_downed_line()) - return - body_go_down() - -/// Down: floored, harmless, draggable, its AI paused, and its recovery clock running. The line is forced: it is the moment the fight ends (BUG-1). -/mob/living/basic/bounty_criminal/proc/body_go_down() - downed = TRUE - body_recovery_due = FALSE - body_rally_until = 0 - deltimer(body_rally_timer) - body_rally_timer = null - add_traits(list(TRAIT_FLOORED, TRAIT_INCAPACITATED, TRAIT_IMMOBILIZED), BOUNTY_BODY_DOWNED_TRAIT) - body_update_held() - ai_controller?.CancelActions() - if(pulling) - stop_pulling() - body_start_recovery() body_state_changed() - visible_message(span_danger("[src] collapses.")) - SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_DOWNED) - INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "downed", null, TRUE) + SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_RECOVERED) /** * Floats `text` over it for every hunter who can see it (bounty_ai_is_hunter()), and nobody else. * Kept in body_last_alert. Returns how many hunters saw it. */ -/mob/living/basic/bounty_criminal/proc/body_alert_hunters(text) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_alert_hunters(text) . = 0 body_last_alert = text for(var/mob/living/viewer in get_hearers_in_view(DEFAULT_MESSAGE_RANGE, src, RECURSIVE_CONTENTS_CLIENT_MOBS)) @@ -784,130 +515,19 @@ balloon_alert(viewer, text) .++ -/// Restrained or down, its AI plans nothing: TRAIT_BOUNTY_HELD for P3's controller, and TRAIT_AI_PAUSED -/mob/living/basic/bounty_criminal/proc/body_update_held() - if(downed || is_restrained()) - add_traits(list(TRAIT_BOUNTY_HELD, TRAIT_AI_PAUSED), BOUNTY_BODY_HELD_TRAIT) - else - remove_traits(list(TRAIT_BOUNTY_HELD, TRAIT_AI_PAUSED), BOUNTY_BODY_HELD_TRAIT) - -/// How long it stays down: meek, normal or mini-boss -/mob/living/basic/bounty_criminal/proc/body_recovery_time() - if(istype(src, /mob/living/basic/bounty_criminal/boss)) - return BOUNTY_RECOVER_TIME_BOSS - if(istype(src, /mob/living/basic/bounty_criminal/meek)) - return BOUNTY_RECOVER_TIME_MEEK - return BOUNTY_RECOVER_TIME_NORMAL - -/// (Re)starts its recovery clock from the top: on going down, and on every stamina hit while down, cuffed or not (L4) -/mob/living/basic/bounty_criminal/proc/body_start_recovery() - deltimer(body_recovery_timer) - body_stirring = FALSE - body_recovery_due = FALSE - body_recovery_timer = addtimer(CALLBACK(src, PROC_REF(body_stir)), max(0, body_recovery_time() - BOUNTY_BODY_STIRRING_TELL), TIMER_STOPPABLE | TIMER_DELETE_ME) - -/// Stops its recovery clock -/mob/living/basic/bounty_criminal/proc/body_cancel_recovery() - deltimer(body_recovery_timer) - body_recovery_timer = null - body_stirring = FALSE - -/// The tell before it gets up: it groans and tries to rise, and is up BOUNTY_BODY_STIRRING_TELL later. Cuffed, it waits for the cuffs to come off. -/mob/living/basic/bounty_criminal/proc/body_stir() - body_recovery_timer = null - if(!downed || stat == DEAD || QDELETED(src)) - return - if(is_restrained()) - body_recovery_due = TRUE - return - body_stirring = TRUE - visible_message(span_warning("[src] groans and tries to get up.")) - Shake(1, 0, 0.5 SECONDS) - body_recovery_timer = addtimer(CALLBACK(src, PROC_REF(body_stand_up)), BOUNTY_BODY_STIRRING_TELL, TIMER_STOPPABLE | TIMER_DELETE_ME) - -/** - * Up again at BOUNTY_RECOVER_TO percent of its health, or more if it was healed while down. A - * mini-boss rallies. Cuffed, it stays down until the cuffs come off (C1). Returns TRUE if it got up. - */ -/mob/living/basic/bounty_criminal/proc/body_stand_up() - body_recovery_timer = null - body_stirring = FALSE - if(!downed || stat == DEAD || QDELETED(src)) - return FALSE - if(is_restrained()) - body_recovery_due = TRUE - return FALSE - var/stand_health = maxHealth * BOUNTY_RECOVER_TO / 100 - var/health_now = maxHealth - bruteloss - if(health_now < stand_health) - adjust_health(health_now - stand_health, forced = TRUE) - downed = FALSE - body_recovery_due = FALSE - remove_traits(list(TRAIT_FLOORED, TRAIT_INCAPACITATED, TRAIT_IMMOBILIZED), BOUNTY_BODY_DOWNED_TRAIT) - body_update_held() - var/rally = body_rally_time() - if(rally > 0) - body_rally_until = world.time + rally - deltimer(body_rally_timer) - body_rally_timer = addtimer(CALLBACK(src, PROC_REF(body_end_rally)), rally, TIMER_STOPPABLE | TIMER_DELETE_ME) - body_state_changed() - visible_message(span_warning("[src] gets back up.")) - SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_RECOVERED) - INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "recover") - return TRUE - -/// How long it rallies after getting up: a mini-boss only (C1). P4 may override it per kit. -/mob/living/basic/bounty_criminal/proc/body_rally_time() - return istype(src, /mob/living/basic/bounty_criminal/boss) ? BOUNTY_BODY_RALLY_TIME : 0 - -/// Whether it is rallying after getting up: stamina does nothing, and it won't surrender (P3) -/mob/living/basic/bounty_criminal/proc/body_is_rallying() - return world.time < body_rally_until +// ===== DEATH AND PROOF ===== -/// The rally is over: stamina works on it again, as far as its tiredness lets it (P4's boss_update_tired()) -/mob/living/basic/bounty_criminal/proc/body_end_rally() - body_rally_timer = null - body_rally_until = 0 - if(QDELETED(src) || stat == DEAD) - return - boss_update_tired(force = TRUE) - -/** - * A mini-boss re-reads how tired it is: its stamina coefficient and stun flags (P4). Nothing for - * anyone else. P4 overrides it on /boss; the override must not repeat the `proc/` keyword. - */ -/mob/living/basic/bounty_criminal/proc/boss_update_tired(silent = FALSE, force = FALSE) - return - -// A stamina hit while it is down starts its recovery over; rallying, it shrugs stamina off. -/mob/living/basic/bounty_criminal/adjustStaminaLoss(amount, updating_stamina = TRUE, forced = FALSE, required_biotype) - if(amount > 0 && !forced && stat != DEAD) - if(body_is_rallying()) - return 0 - if(downed) - body_start_recovery() - return ..() - -// ===== DEATH ===== - -// The body stays as proof; the cuffs come off it. -/mob/living/basic/bounty_criminal/death(gibbed) - body_cancel_recovery() +// The body stays as proof. Fallen into a chasm, it is in the chasm's depths for good: P5 puts the proof on +// safe ground beside the chasm (get_turf() here is the chasm itself). +/mob/living/carbon/human/npc/bounty_criminal/death(gibbed) . = ..() - if(QDELETED(src)) + if(QDELETED(src) || stat != DEAD) return - body_remove_cuffs() body_state_changed() - // Fallen into a chasm: the body is in the chasm's depths for good. P5 puts the proof on safe ground - // beside the chasm (get_turf() here is the chasm itself). if(istype(loc, /obj/effect/abstract/chasm_storage) && body_leaves_proof()) body_drop_proof() -// Nothing brings it back: dead is the dead share (AR-A5). -/mob/living/basic/bounty_criminal/can_be_revived() - return FALSE - -/mob/living/basic/bounty_criminal/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) +/mob/living/carbon/human/npc/bounty_criminal/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) . = ..() body_set_holder(isturf(loc) ? null : loc) // A body dragged into a chasm is gone for good too; P5 puts the proof beside it. @@ -917,9 +537,11 @@ /** * Keeps whatever holds it (`holder`, the outermost thing it is inside, or null on a turf) from being * teleported while it is inside (M2): a ship's transporter can't beam up a locker, crate or body bag - * with a criminal in it, any more than the criminal itself. Moves the block when it changes hands. + * with a criminal in it, any more than the criminal itself, and a person carrying a folded body bag + * with one inside can't teleport with it. Moves the block when it changes hands. (Carried over the + * shoulders it isn't inside anyone: it stands on the carrier's tile.) */ -/mob/living/basic/bounty_criminal/proc/body_set_holder(atom/movable/holder) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_set_holder(atom/movable/holder) while(ismovable(holder?.loc)) holder = holder.loc var/atom/movable/old_holder = body_holder_ref?.resolve() @@ -938,7 +560,7 @@ * no proof. P5 marks removals with TRAIT_BOUNTY_REMOVED instead (the pad, closing, relisting, a site * unloading); this is for anything else that takes a criminal away on purpose. */ -/mob/living/basic/bounty_criminal/proc/body_mark_removed() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_mark_removed() body_removed = TRUE /** @@ -946,171 +568,53 @@ * first; a live one deleted is never a death, M5), not removed on purpose, and its posting still open. * P5's bounty_drop_proof() makes the final call (a site unloading, a relist) and places the proof. */ -/mob/living/basic/bounty_criminal/proc/body_leaves_proof() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_leaves_proof() if(stat != DEAD || body_removed || body_proof_dropped || HAS_TRAIT(src, TRAIT_BOUNTY_REMOVED)) return FALSE var/datum/criminal_bounty/wanted_on = posting() return wanted_on?.status == BOUNTY_POSTING_OPEN /// Leaves proof of death through P5's bounty_drop_proof(), once -/mob/living/basic/bounty_criminal/proc/body_drop_proof() +/mob/living/carbon/human/npc/bounty_criminal/proc/body_drop_proof() body_proof_dropped = TRUE bounty_drop_proof(src) -// ===== LOOKS ===== - -/// Puts its look on: its record's face in the outfit for its archetype and zone. Sleeps. -/mob/living/basic/bounty_criminal/proc/body_dress() - var/outfit = bounty_outfit() - if(record) - apply_bounty_look(src, record, outfit) - else - set_outpost_npc_look(src, outfit, gender, 1) - if(QDELETED(src)) - return - update_appearance(UPDATE_OVERLAYS) - -/// The outfit it wears: a mini-boss its kit's (boss_kit_outfit(), P4), anyone else plain clothes for their archetype and the zone of their site -/mob/living/basic/bounty_criminal/proc/bounty_outfit() - if(istype(src, /mob/living/basic/bounty_criminal/boss)) - return boss_kit_outfit() || boss_outfit() - var/meek = istype(src, /mob/living/basic/bounty_criminal/meek) - switch(body_zone()) - if(ZONE_RED) - return meek ? /datum/outfit/bounty_criminal/drifter : /datum/outfit/bounty_criminal/tough - if(ZONE_YELLOW) - return meek ? /datum/outfit/bounty_criminal/spacer : /datum/outfit/bounty_criminal/hauler - return meek ? /datum/outfit/bounty_criminal : /datum/outfit/bounty_criminal/local - -/// A mini-boss's kit outfit, or null. P4 overrides it on /boss; the override must not repeat the `proc/` keyword. -/mob/living/basic/bounty_criminal/proc/boss_kit_outfit() - return null - -/// What a mini-boss wears when its kit has no outfit: plain rough clothes -/mob/living/basic/bounty_criminal/proc/boss_outfit() - return /datum/outfit/bounty_criminal/tough - -/// ZONE_GREEN, ZONE_YELLOW or ZONE_RED for where its site is (or where it stands), green if unknown -/mob/living/basic/bounty_criminal/proc/body_zone() - var/atom/site = body_site_ref?.resolve() - var/zone = site ? SSovermap_zones?.get_zone_type(get_turf(site)) : SSovermap_zones?.get_zone_type_anywhere(get_turf(src)) - return zone || ZONE_GREEN - -/mob/living/basic/bounty_criminal/update_overlays() - . = ..() - if(is_restrained() && !hidden) - // tg's own cuff overlay, as people wear it - . += mutable_appearance('icons/mob/simple/mob.dmi', "handcuff1") - -/// Plain clothes for a meek criminal in green space -/datum/outfit/bounty_criminal - name = "Bounty criminal: civilian" - uniform = /obj/item/clothing/under/pants/jeans - suit = /obj/item/clothing/suit/toggle/jacket/sweater - shoes = /obj/item/clothing/shoes/sneakers/brown - -/// A meek criminal in yellow space -/datum/outfit/bounty_criminal/spacer - name = "Bounty criminal: spacer" - uniform = /obj/item/clothing/under/color/grey - suit = /obj/item/clothing/suit/jacket/bomber - shoes = /obj/item/clothing/shoes/workboots - head = /obj/item/clothing/head/beanie - -/// A meek criminal in red space -/datum/outfit/bounty_criminal/drifter - name = "Bounty criminal: drifter" - uniform = /obj/item/clothing/under/color/black - suit = /obj/item/clothing/suit/jacket/leather - shoes = /obj/item/clothing/shoes/jackboots - head = /obj/item/clothing/head/soft/grey - -/// A normal criminal in green space -/datum/outfit/bounty_criminal/local - name = "Bounty criminal: local" - uniform = /obj/item/clothing/under/costume/buttondown/slacks - suit = /obj/item/clothing/suit/jacket/letterman - shoes = /obj/item/clothing/shoes/sneakers/black - -/// A normal criminal in yellow space -/datum/outfit/bounty_criminal/hauler - name = "Bounty criminal: hauler" - uniform = /obj/item/clothing/under/pants/jeans - suit = /obj/item/clothing/suit/jacket/miljacket - gloves = /obj/item/clothing/gloves/fingerless - shoes = /obj/item/clothing/shoes/workboots - -/// A normal criminal in red space, and the fallback for a mini-boss -/datum/outfit/bounty_criminal/tough - name = "Bounty criminal: tough" - uniform = /obj/item/clothing/under/color/black - suit = /obj/item/clothing/suit/jacket/leather/biker - gloves = /obj/item/clothing/gloves/fingerless - shoes = /obj/item/clothing/shoes/jackboots - // ===== EXAMINE ===== /** - * How hurt it looks, its distinguishing features, and whether it is restrained, down or has given up. - * Blending in, the fugitive and its decoys show one layout: how hurt, then the features. Hidden (a - * disguise, a locker), it adds nothing. + * Its distinguishing features, and whether it has given up. How hurt it is, whether it is restrained + * and whether it is down are tg's own human examine. Hidden (a disguise, a locker), it adds nothing. */ -/mob/living/basic/bounty_criminal/examine(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/examine(mob/user) . = ..() if(hidden) return - if(stat != DEAD) - . += body_health_line() for(var/line in record?.feature_lines()) . += span_notice(line) - if(blended || stat == DEAD) + if(blended || stat != CONSCIOUS) return - if(is_restrained()) - . += span_warning("[p_They()] [p_are()] restrained with [restraints].") - if(downed) - if(body_stirring) - . += span_warning("[p_They()] [p_are()] trying to get up.") - else - . += span_notice("[p_They()] [p_are()] down.") - else if(HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED)) + if(npc_surrendering && IsParalyzed()) . += span_notice("[p_They()] [p_have()] given up.") -/// One line on how hurt it looks -/mob/living/basic/bounty_criminal/proc/body_health_line() - var/fraction = maxHealth > 0 ? health / maxHealth : 0 - if(fraction >= 0.9) - return span_notice("[p_They()] look[p_s()] unhurt.") - if(fraction >= 0.6) - return span_warning("[p_They()] [p_have()] a few cuts and bruises.") - if(fraction > BOUNTY_DOWNED_BELOW / 100) - return span_warning("[p_They()] [p_are()] badly hurt.") - return span_danger("[p_They()] [p_are()] barely conscious.") - -// ===== THE LEDGER AND THE BODY'S SIGNALS ===== +// ===== THE BODY'S SIGNALS ===== /** - * Who has been hurting a criminal, and the rest of the body's signal hooks, kept on a component so the - * AI packages can register the same signals on the mob without clashing. The ledger (in the style of - * experiment_damage_ledger) tells a hit with someone's mind behind it from one without, so turrets, - * traps, fire and fauna can't finish off a criminal on its site (AR-D3), and puts attackers on its - * grudge list. Damage and the attack report arrive in the same tick: a projectile's pre-hit and an item - * attack's attackby come before the damage, so the tick's attacker is known when the damage lands. - * It also refuses teleports, polymorph and type changes, lets a sprinting meek criminal duck shots, - * holds ranged attacks to may_attack(), and stops a free criminal being dragged onto things. + * The body's own signal hooks, kept on a component so the AI packages can register the same signals on + * the mob without clashing: + * - who has been attacking it (tg's relay_attackers, an item's attackby, a projectile's firer, a mech's + * or a vehicle's pilot) goes on its grudge list; + * - teleports, polymorph and type changes are refused; + * - any change of tg state that capture_state() reads tells the body (body_state_changed()). */ /datum/component/bounty_body dupe_mode = COMPONENT_DUPE_UNIQUE - /// world.time someone with a mind behind them last attacked it - var/player_hit_at = -1 - /// world.time another criminal or a companion last attacked it - var/own_side_hit_at = -1 /datum/component/bounty_body/Initialize() - if(!istype(parent, /mob/living/basic/bounty_criminal)) + if(!istype(parent, /mob/living/carbon/human/npc/bounty_criminal)) return COMPONENT_INCOMPATIBLE /datum/component/bounty_body/RegisterWithParent() - var/mob/living/basic/bounty_criminal/criminal = parent + var/mob/living/carbon/human/npc/bounty_criminal/criminal = parent criminal.AddElement(/datum/element/relay_attackers) RegisterSignal(criminal, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) RegisterSignal(criminal, COMSIG_ATOM_ATTACKBY, PROC_REF(on_attackby)) @@ -1118,9 +622,15 @@ RegisterSignal(criminal, COMSIG_MOVABLE_TELEPORTING, PROC_REF(refuse_teleport)) RegisterSignal(criminal, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) RegisterSignal(criminal, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) - RegisterSignal(criminal, COMSIG_BASICMOB_PRE_ATTACK_RANGED, PROC_REF(on_pre_ranged_attack)) - RegisterSignal(criminal, COMSIG_MOUSEDROP_ONTO, PROC_REF(on_mousedrop)) - RegisterSignals(criminal, list(SIGNAL_ADDTRAIT(TRAIT_BOUNTY_SURRENDERED), SIGNAL_REMOVETRAIT(TRAIT_BOUNTY_SURRENDERED)), PROC_REF(on_state_signal)) + RegisterSignal(criminal, COMSIG_MOB_STATCHANGE, PROC_REF(on_state_signal)) + RegisterSignals(criminal, list( + SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_REMOVETRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_ADDTRAIT(TRAIT_FLOORED), + SIGNAL_REMOVETRAIT(TRAIT_FLOORED), + SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), + SIGNAL_REMOVETRAIT(TRAIT_INCAPACITATED), + ), PROC_REF(on_state_signal)) // A stun's traits land before the stun is on its list, so the trait handlers alone would miss it. RegisterSignals(criminal, list(COMSIG_LIVING_STATUS_APPLIED, COMSIG_LIVING_STATUS_REMOVED), PROC_REF(on_state_signal)) @@ -1132,29 +642,20 @@ COMSIG_MOVABLE_TELEPORTING, COMSIG_LIVING_PRE_WABBAJACKED, COMSIG_PRE_MOB_CHANGED_TYPE, - COMSIG_BASICMOB_PRE_ATTACK_RANGED, - COMSIG_MOUSEDROP_ONTO, - SIGNAL_ADDTRAIT(TRAIT_BOUNTY_SURRENDERED), - SIGNAL_REMOVETRAIT(TRAIT_BOUNTY_SURRENDERED), + COMSIG_MOB_STATCHANGE, + SIGNAL_ADDTRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_REMOVETRAIT(TRAIT_CRITICAL_CONDITION), + SIGNAL_ADDTRAIT(TRAIT_FLOORED), + SIGNAL_REMOVETRAIT(TRAIT_FLOORED), + SIGNAL_ADDTRAIT(TRAIT_INCAPACITATED), + SIGNAL_REMOVETRAIT(TRAIT_INCAPACITATED), COMSIG_LIVING_STATUS_APPLIED, COMSIG_LIVING_STATUS_REMOVED, )) -/// Whether someone with a mind behind them attacked it this tick -/datum/component/bounty_body/proc/player_hit_now() - return player_hit_at == world.time - -/// Whether its own side (another criminal, a companion) attacked it this tick -/datum/component/bounty_body/proc/own_side_hit_now() - return own_side_hit_at == world.time - -/// `attacker` went for it: noted for the ledger, and they go on its grudge list +/// `attacker` went for it: they go on its grudge list (a vehicle's pilots for a vehicle) /datum/component/bounty_body/proc/note_attacker(atom/attacker) - var/mob/living/basic/bounty_criminal/criminal = parent - if(bounty_player_behind(attacker)) - player_hit_at = world.time - if(istype(attacker, /mob/living/basic/bounty_criminal) || istype(attacker, /mob/living/basic/bounty_companion)) - own_side_hit_at = world.time + var/mob/living/carbon/human/npc/bounty_criminal/criminal = parent if(istype(attacker, /obj/vehicle/sealed)) var/obj/vehicle/sealed/vehicle = attacker for(var/mob/living/pilot in vehicle.occupants) @@ -1162,60 +663,35 @@ else if(isliving(attacker)) criminal.body_add_grudge(attacker) -/** - * An explosion is about to hit it (M1). ex_act() carries no attacker, so the blast counts as a - * player's when the explosive was last handled by one (`origin`'s last fingerprints: a grenade - * someone threw or set), or when someone with a mind stands within BOUNTY_EXPLOSION_WITNESS_RANGE - * tiles. Anything else is automated, like fire. - */ -/datum/component/bounty_body/proc/note_explosion(atom/origin) - var/mob/living/basic/bounty_criminal/criminal = parent - if(isatom(origin) && origin.fingerprintslast) - player_hit_at = world.time - return - var/turf/here = get_turf(criminal) - if(!here) - return - for(var/mob/living/person in range(BOUNTY_EXPLOSION_WITNESS_RANGE, here)) - if(bounty_player_behind(person)) - player_hit_at = world.time - return - /datum/component/bounty_body/proc/on_attacked(datum/source, atom/attacker, attack_flags) SIGNAL_HANDLER if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK | ATTACKER_SHOVING))) return note_attacker(attacker) -// Before an item's damage lands (relay_attackers only reports it after), so the ledger knows whose it is. /datum/component/bounty_body/proc/on_attackby(datum/source, obj/item/attacking_item, mob/living/user, list/modifiers, list/attack_modifiers) SIGNAL_HANDLER if(attacking_item?.force > 0 || user?.combat_mode) note_attacker(user) -/// Whose shot it is, even a mech's or a vehicle's (relay_attackers only reports mobs, M1); and a sprinting meek criminal ducks some shots (C5), though one that gets through spoils the next second's dodging +/// Whose shot it is, even a mech's or a vehicle's (relay_attackers only reports mobs, M1) /datum/component/bounty_body/proc/on_projectile_prehit(datum/source, obj/projectile/shot) SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent if(shot?.firer && shot.is_hostile_projectile()) note_attacker(shot.firer) - if(prob(criminal.body_dodge_chance())) - criminal.visible_message(span_warning("[criminal] ducks out of the way of [shot]!")) - return PROJECTILE_INTERRUPT_HIT_PHASE - criminal.body_last_shot_at = world.time return NONE /// The pad deletes a criminal; nothing teleports one (D-A10) /datum/component/bounty_body/proc/refuse_teleport(datum/source, atom/destination, channel) SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent + var/mob/living/carbon/human/npc/bounty_criminal/criminal = parent if(isturf(criminal.loc)) criminal.visible_message(span_notice("[criminal] flickers for a moment, but stays where [criminal.p_they()] [criminal.p_are()].")) return TRUE /datum/component/bounty_body/proc/refuse_polymorph(datum/source, what_to_randomize) SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent + var/mob/living/carbon/human/npc/bounty_criminal/criminal = parent criminal.visible_message(span_notice("[criminal] shimmers for a moment, then looks the same as before.")) return STOP_WABBAJACK @@ -1223,51 +699,30 @@ SIGNAL_HANDLER return COMPONENT_BLOCK_MOB_CHANGE -/datum/component/bounty_body/proc/on_pre_ranged_attack(datum/source, atom/target, modifiers) - SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent - if(!criminal.may_attack(target)) - return COMPONENT_CANCEL_RANGED_ATTACK - -/datum/component/bounty_body/proc/on_mousedrop(datum/source, atom/over, mob/user) - SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent - if(!criminal.body_can_be_dragged()) - return COMPONENT_CANCEL_MOUSEDROP_ONTO - -/// It surrendered or stopped, or a stun, knockdown or stamina crit came or went: its capture state may have changed +/// Its stat, crit, a stun, a knockdown or stamina crit came or went: its capture state may have changed /datum/component/bounty_body/proc/on_state_signal(datum/source) SIGNAL_HANDLER - var/mob/living/basic/bounty_criminal/criminal = parent - criminal.body_state_changed() - -/// The chance, in percent, that it ducks a shot now: a meek criminal sprinting freely (TRAIT_BOUNTY_SPRINTING, from P3), not shot in the last second -/mob/living/basic/bounty_criminal/proc/body_dodge_chance() - if(!istype(src, /mob/living/basic/bounty_criminal/meek) || !HAS_TRAIT(src, TRAIT_BOUNTY_SPRINTING)) - return 0 - if(stat != CONSCIOUS || downed || is_restrained() || !isturf(loc) || pulledby || buckled) - return 0 - if(HAS_TRAIT(src, TRAIT_INCAPACITATED) || HAS_TRAIT(src, TRAIT_IMMOBILIZED) || HAS_TRAIT(src, TRAIT_FLOORED)) - return 0 - if(world.time - body_last_shot_at < BOUNTY_MEEK_DODGE_AFTER_HIT) - return 0 - return BOUNTY_MEEK_DODGE + var/mob/living/carbon/human/npc/bounty_criminal/criminal = parent + if(!QDELING(criminal)) + criminal.body_state_changed() + +// ===== TURRETS AND PLAYERS ===== /** - * Whether a turret leaves `creature` alone for being part of a bounty (L2; hostile_creatures.dm). + * Whether a turret leaves `creature` alone for being part of a bounty (L2, AM-11; hostile_creatures.dm). * `at_trader_outpost`: it stands in a trader outpost's concourse. There, criminals, decoys and - * companions are left to the hunters (spec 4). Anywhere else a criminal is left alone only once it is - * down, stunned, restrained or dead, so a crew's own turrets don't finish off their catch; a free one, - * and any companion, is judged like any other creature. + * companions are left to the hunters (spec 4), unless an exposed fugitive hurt a bystander (P6's + * outpost_turret_target()). Anywhere else a criminal is left alone once it is in crit, stunned, + * restrained or dead (tg's states), so a crew's own turrets don't finish off their catch; a free one, and + * any companion, is judged like any other creature. */ /proc/bounty_turret_ignores(mob/living/creature, at_trader_outpost) - var/mob/living/basic/bounty_criminal/criminal = creature + var/mob/living/carbon/human/npc/bounty_criminal/criminal = creature if(istype(criminal)) - // At a trader outpost, turrets leave it alone unless an exposed fugitive hurt someone outside its grudge list (AR-C8, P6's outpost_turret_target()). if(at_trader_outpost) return !criminal.outpost_turret_target() return criminal.capture_state() != BOUNTY_STATE_FREE - return at_trader_outpost && istype(creature, /mob/living/basic/bounty_companion) + return at_trader_outpost && istype(creature, /mob/living/carbon/human/npc/bounty_companion) /// Whether someone with a mind is behind `attacker`: a person, a borg, or the pilot of a mech or other vehicle. Criminals and their companions never count. /proc/bounty_player_behind(atom/attacker) @@ -1278,6 +733,6 @@ return TRUE return FALSE var/mob/living/person = attacker - if(!istype(person) || istype(person, /mob/living/basic/bounty_criminal) || istype(person, /mob/living/basic/bounty_companion)) + if(!istype(person) || bounty_is_gang_body(person)) return FALSE return !isnull(person.mind) || !isnull(person.ckey) diff --git a/voidcrew/modules/bounties/bounty_identity.dm b/voidcrew/modules/bounties/bounty_identity.dm index 43e82826b77..66cce89380d 100644 --- a/voidcrew/modules/bounties/bounty_identity.dm +++ b/voidcrew/modules/bounties/bounty_identity.dm @@ -710,22 +710,22 @@ GLOBAL_LIST_INIT(bounty_species_weights, list( /mob/living/proc/identity_line_values() return null -/mob/living/basic/bounty_criminal/identity_voice() +/mob/living/carbon/human/npc/bounty_criminal/identity_voice() // Blended in at a trader outpost, the fugitive talks exactly like its decoys (AR-C6). if(blended) return null return record?.archetype -/mob/living/basic/bounty_criminal/identity_line_values() +/mob/living/carbon/human/npc/bounty_criminal/identity_line_values() // A Most Wanted crime is no line to toss off ("All this over the Corvina Station massacre?"). if(!record?.crime || record.tier == BOUNTY_TIER_MOST_WANTED) return null return list("{crime}" = record.crime) /// A decoy is an innocent patron: always the shared, neutral lines, whether or not it is blended in -/mob/living/basic/bounty_criminal/decoy/identity_voice() +/mob/living/carbon/human/npc/bounty_criminal/decoy/identity_voice() return null /// A decoy has no crime to own up to -/mob/living/basic/bounty_criminal/decoy/identity_line_values() +/mob/living/carbon/human/npc/bounty_criminal/decoy/identity_line_values() return null diff --git a/voidcrew/modules/bounties/bounty_kingpin.dm b/voidcrew/modules/bounties/bounty_kingpin.dm index 29ab4fe6b61..d0ff11992c1 100644 --- a/voidcrew/modules/bounties/bounty_kingpin.dm +++ b/voidcrew/modules/bounties/bounty_kingpin.dm @@ -21,32 +21,37 @@ * ckey), and anyone aboard for themselves, can never hunt him or turn him in, and his goons treat * them as guests. One run at a time (the job's mission_limit, counting the one he holds). He won't * hire drifters or crews who fought his people. - * - His posting (/datum/criminal_bounty/kingpin): P5's posting with his own shares (alive 100%, - * downed counts as alive, dead 80%), his own card lines and a clock that pauses during a shootout. + * - His posting (/datum/criminal_bounty/kingpin): P5's posting with his own shares (alive 100% + * however he is brought in, dead 80%), his own card lines and a clock that pauses during a shootout. * It goes up only on the clock (no earlier than 45 minutes in with 3+ active ships, the next 60-90 * minutes after the last resolves) or from the admin panel; a shootout never puts him on the * board. It adopts him wherever he is, alive (never a second body), so a crew that grabbed him * early can turn him in once he is posted; with his crew at ease every empty goon post gets a new - * goon. Killed before any posting, he is gone with nothing to claim, and his body fades. Closed - * unclaimed (run out, withdrawn), it leaves him in the lounge, alive, uncuffed and in business, - * when he is alive within his goons' leash of the sofa; otherwise he is removed as any criminal is, - * and gone. + * goon. Killed before any posting, he is gone with nothing to claim: his body is an ordinary corpse + * that the human NPC corpse sweep takes in time. Closed unclaimed (run out, withdrawn), it leaves + * him in the lounge, alive, uncuffed and in business, when he is alive within his goons' leash of + * the sofa; otherwise he is removed as any criminal is, and gone. * - Talking: a radial on him. "We're here for you" and "Walk away" only show once that crew's * ship has taken his open posting from the board; the first gets his confront line and starts the - * shootout. Any attack on him or a goon starts it too. A warrant shown to him, or a reach for his - * wrists, only gets the confront line. + * shootout. Any attack on him or a goon starts it too. A warrant shown to him only gets the + * confront line; a reach for his wrists while he can still move gets the line and a shove. * - The shootout (/datum/bounty_kingpin_crew): a 0.6 s telegraph where nobody fires ("the goons - * reach for their guns"), then first shots spread over half a second, at most two goons on one - * hunter in cover. He never leaves the sofa and fires a revolver after a visible 0.5 s aim. Goons - * stay within 7 tiles of the sofa, never shoot anyone who is down, and half of those left flee once - * he falls. The barkeep shouts, the turret ignores both sides, and hunters - * fighting his crew take no outpost property strikes (one marked hook in outpost.dm). His crew - * lives as long as he does. - * - His body is P2's (/mob/living/basic/bounty_criminal), with or without a posting: downed at 25%, - * up again after 60 s unless cuffed, and P2's capture rules and P5's pad apply. He surrenders only - * once his crew is down. - * - Goons (/mob/living/basic/bounty_kingpin_goon) are not bounties: 150 health, no loot, no real - * guns, and their bodies fade after 60 s. + * reach for their guns", and they really draw them), then first shots spread over half a second, + * at most two goons on one hunter in cover. Every shot is a real trigger pull of the real gun in a + * hand, through that body's AI controller (ai_interact(), a player's click), and every reload is + * the player's reload from the spares they carry. Nobody in his crew fires through someone they + * aren't fighting: a bystander, a guest or one of their own in the line holds the shot. He never + * leaves the sofa and fires his revolver after a visible 0.5 s aim that a hit spoils. Goons stay + * within 7 tiles of the sofa, never shoot anyone who is down, and half of those left walk out once + * he falls. The barkeep shouts, the turret ignores both sides, and hunters fighting his crew take + * no outpost property strikes (one marked hook in outpost.dm). His crew lives as long as he does. + * - His body is P2's (/mob/living/carbon/human/npc/bounty_criminal), a real human with or without a + * posting: tg's crit, stamina, cuffs and death, P2's capture rules and P5's pad. A boss (aimed crits), + * tougher by physiology (BOUNTY_KINGPIN_DAMAGE_TAKEN) and an armoured coat. He gives up (tg's + * *surrender) only once his crew is down and he is badly hurt. + * - Goons (/mob/living/carbon/human/npc/bounty_kingpin_goon) are not bounties: real humans in armour + * vests with real pistols, SMGs and shotguns and real spare magazines and shells. Their gear is + * locked to them (D3), so nothing they carry is loot; their bodies go with the human NPC corpse sweep. * * A var this file adds to a shared type carries a `kingpin_` prefix. */ @@ -182,65 +187,92 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // LOOKS // ========================================================================= -/// The kingpin: a charcoal suit, a red tie, a fedora and a cigar +/** + * The kingpin: a charcoal suit under an armoured trenchcoat, a red tie, a fedora and a cigar. His .357 + * and two speedloaders ride in the shoulder holster under the coat, a third speedloader and an + * epinephrine pen in his pockets. Locked to him (D3): none of it is loot. + */ /datum/outfit/bounty_kingpin name = "Bounty kingpin" uniform = /obj/item/clothing/under/suit/charcoal - suit = /obj/item/clothing/suit/toggle/lawyer/black + suit = /obj/item/clothing/suit/armor/hos/trenchcoat neck = /obj/item/clothing/neck/tie/red/tied gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/laceup head = /obj/item/clothing/head/fedora mask = /obj/item/cigarette/cigar/havana - -/// The kingpin in a shootout: the same, with his revolver out (worn look only; he carries no real gun) -/datum/outfit/bounty_kingpin/armed - name = "Bounty kingpin: armed" - r_hand = BOUNTY_KINGPIN_REVOLVER - -/// A goon at ease: black suit, black tie, sunglasses + belt = /obj/item/storage/belt/holster/detective + belt_contents = list( + BOUNTY_KINGPIN_REVOLVER = 1, + BOUNTY_KINGPIN_SPEEDLOADER = 2, + ) + l_pocket = BOUNTY_KINGPIN_SPEEDLOADER + r_pocket = /obj/item/reagent_containers/hypospray/medipen + +/// A goon: black suit and tie under an armour vest, sunglasses. The subtypes carry the gun and its spares. /datum/outfit/bounty_kingpin_goon name = "Kingpin goon" uniform = /obj/item/clothing/under/suit/black - suit = /obj/item/clothing/suit/toggle/lawyer/black + suit = /obj/item/clothing/suit/armor/vest neck = /obj/item/clothing/neck/tie/black/tied glasses = /obj/item/clothing/glasses/sunglasses gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/laceup -/// Goons with their guns out (worn looks only; goons carry no real guns and drop none) +/// A Stechkin in the holster under the vest, with two spare magazines beside it /datum/outfit/bounty_kingpin_goon/pistol name = "Kingpin goon: pistol" - r_hand = BOUNTY_GOON_PISTOL_GUN + belt = /obj/item/storage/belt/holster/detective + belt_contents = list( + BOUNTY_GOON_PISTOL_GUN = 1, + BOUNTY_GOON_PISTOL_MAG = 2, + ) +/// An Uzi on the vest, a spare magazine in each pocket /datum/outfit/bounty_kingpin_goon/smg name = "Kingpin goon: SMG" - r_hand = BOUNTY_GOON_SMG_GUN + suit_store = BOUNTY_GOON_SMG_GUN + l_pocket = BOUNTY_GOON_SMG_MAG + r_pocket = BOUNTY_GOON_SMG_MAG +/// A combat shotgun on the back, a bandolier of buckshot /datum/outfit/bounty_kingpin_goon/shotgun name = "Kingpin goon: shotgun" - r_hand = BOUNTY_GOON_SHOTGUN_GUN + back = BOUNTY_GOON_SHOTGUN_GUN + belt = /obj/item/storage/belt/bandolier + belt_contents = list(BOUNTY_GOON_SHOTGUN_AMMO = BOUNTY_GOON_SHOTGUN_SHELLS) + +/// The goons' combat shotgun: tg's, with its tube loaded with buckshot instead of rubber +/obj/item/gun/ballistic/shotgun/automatic/combat/bounty_kingpin + spawn_magazine_type = /obj/item/ammo_box/magazine/internal/shot/com/bounty_kingpin + +/obj/item/ammo_box/magazine/internal/shot/com/bounty_kingpin + ammo_type = BOUNTY_GOON_SHOTGUN_AMMO // ========================================================================= // THE KINGPIN // ========================================================================= /** - * The kingpin: P2's criminal body with his own numbers. He sits on the sofa and never leaves it, - * talks across the table, and in a shootout fires a revolver after a visible aim. His crew - * (kingpin_crew) runs the goons, the room and his business. He may be on a posting or on none. + * The kingpin: P2's criminal body, a real human. He sits on the sofa and never leaves it, talks across + * the table, and in a shootout draws his real revolver and fires it after a visible aim. His crew + * (kingpin_crew) runs the goons, the room, his shots and his business; his own AI controller only + * does what his body does by itself (soft crit, breaking free, giving up, reloading, his medipen). He + * may be on a posting or on none. */ -/mob/living/basic/bounty_criminal/kingpin +/mob/living/carbon/human/npc/bounty_criminal/kingpin name = "kingpin" desc = "Sharp suit, cold eyes. He runs this lounge, and his people are never far away." - maxHealth = BOUNTY_KINGPIN_HEALTH - health = BOUNTY_KINGPIN_HEALTH - // His crew drives him; no criminal AI (P3) is ever put on him. - ai_controller = null + ai_controller = /datum/ai_controller/basic_controller/human_npc/bounty_kingpin + npc_rank = HUMAN_NPC_RANK_BOSS + npc_damage_taken = BOUNTY_KINGPIN_DAMAGE_TAKEN + // A boss's toughness is his own, not his record's tier (bounty_criminal.dm body_toughness()) + body_tier_toughness = FALSE + npc_surrender = /datum/human_npc_surrender/bounty_kingpin + // Killed before any posting he is an ordinary corpse; on an open posting the sweep leaves him for the pad (npc_corpse_expired()) + npc_corpse_lifetime = HUMAN_NPC_CORPSE_LIFETIME /// His goons, the shootout, the room and the job he holds (/datum/bounty_kingpin_crew). He deletes it. var/datum/bounty_kingpin_crew/kingpin_crew - /// Rounds left in his revolver (a full cylinder at Initialize) - var/kingpin_rounds = 0 /// world.time he may start his next aim var/kingpin_next_aim_at = 0 /// The timer that fires the aimed shot, while he aims @@ -251,65 +283,51 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/datum/weakref/kingpin_aim_target /// Weakref of anyone who hit him -> how many times: he shoots whoever hurt him most var/list/kingpin_hits = list() - /// world.time of his next surrender roll - var/kingpin_next_surrender_roll = 0 - /// The timer fading his body out, when no posting holds it (kingpin_start_fade()) - var/kingpin_fade_timer /// Between two of his lines COOLDOWN_DECLARE(kingpin_say_cooldown) -/mob/living/basic/bounty_criminal/kingpin/Initialize(mapload) - . = ..() - kingpin_rounds = bounty_kingpin_revolver().magazine - -/mob/living/basic/bounty_criminal/kingpin/Destroy() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/Destroy() kingpin_cancel_aim() - deltimer(kingpin_fade_timer) - kingpin_fade_timer = null QDEL_NULL(kingpin_crew) kingpin_hits.Cut() - // Deleted for any reason (the pad, a posting's cleanup, an admin): he is gone for the round + // Deleted for any reason (the pad, a posting's cleanup, the corpse sweep, an admin): he is gone for the round SSbounty_kingpin.kingpin_removed(src) return ..() -// Dead is gone for the round. On the board, his body is there to bring in; killed off it, there is nothing to claim and it fades. -/mob/living/basic/bounty_criminal/kingpin/death(gibbed) +// Dead is gone for the round. On the board, his body is there to bring in; killed off it, there is nothing to claim. +/mob/living/carbon/human/npc/bounty_criminal/kingpin/death(gibbed) . = ..() SSbounty_kingpin.kingpin_died(src) if(QDELETED(src)) return + kingpin_cancel_aim() // A chair holds a body upright: he slumps off his sofa if(istype(buckled, /obj/structure/chair)) buckled.unbuckle_mob(src, force = TRUE) - if(!kingpin_posting()) - kingpin_start_fade() - -// His numbers on P2's body -/mob/living/basic/bounty_criminal/kingpin/body_recovery_time() - return BOUNTY_KINGPIN_RECOVERY - -/mob/living/basic/bounty_criminal/kingpin/body_rally_time() - return BOUNTY_KINGPIN_RALLY -/mob/living/basic/bounty_criminal/kingpin/body_stamina_pool() - return BOUNTY_KINGPIN_STAMINA +// The corpse sweep never takes the body an open posting pays for; with no posting he is an ordinary corpse +/mob/living/carbon/human/npc/bounty_criminal/kingpin/npc_corpse_expired() + if(kingpin_posting()?.is_open()) + return FALSE + return ..() -/mob/living/basic/bounty_criminal/kingpin/bounty_outfit() - return kingpin_crew?.crew_armed ? /datum/outfit/bounty_kingpin/armed : /datum/outfit/bounty_kingpin +// He dresses as himself, whatever the zone: the outfit table asks (bounty_outfits.dm) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/bounty_build_outfit(zone) + return /datum/outfit/bounty_kingpin // He is always at the sofa; nothing else sets what he is doing. -/mob/living/basic/bounty_criminal/kingpin/start_activity(kind, atom/anchor) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/start_activity(kind, atom/anchor) return -// Shown a warrant, or someone reached for his wrists: his confront line, and nothing more (P6, P2) -/mob/living/basic/bounty_criminal/kingpin/on_confronted(mob/user) +// Shown a warrant: his confront line, and nothing more (P6, P2) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/on_confronted(mob/user) INVOKE_ASYNC(src, PROC_REF(kingpin_confronted), user) -/mob/living/basic/bounty_criminal/kingpin/identity_voice() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/identity_voice() return BOUNTY_ARCHETYPE_KINGPIN /// His lines come from his own dialogue file first; anything it has no lines for goes to P1's -/mob/living/basic/bounty_criminal/kingpin/bounty_say(context, list/values, force = FALSE) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/bounty_say(context, list/values, force = FALSE) if(QDELETED(src) || stat == DEAD || !istext(context)) return FALSE if(!length(bounty_kingpin_lines("kingpin", context))) @@ -326,28 +344,46 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) say(line, forced = "bounty kingpin") return TRUE -/mob/living/basic/bounty_criminal/kingpin/examine(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/examine(mob/user) . = ..() if(kingpin_can_talk()) . += span_notice("[p_They()] look[p_s()] ready to talk business.") -// Every hit that gets through: with his crew down, he may give up -/mob/living/basic/bounty_criminal/kingpin/adjust_health(amount, updating_health = TRUE, forced = FALSE) - var/health_before = health - . = ..() - if(amount > 0 && !forced) - kingpin_consider_surrender(health_before) - // Hurt while his crew is at ease, by anything at all: they find out who - kingpin_crew?.member_hurt(src) +/** + * Someone reaches for his wrists. A man on his sofa doesn't step away: while he can still move he + * shoves them off a moment later (a real shove, which breaks their cuffing) and says his confront line. + * Paralysed after giving up, stamcritted, knocked down or in crit, he can't, and the cuffs go on. + */ +/mob/living/carbon/human/npc/bounty_criminal/kingpin/body_on_cuff_attempt(mob/living/user) + if(!kingpin_able()) + return + body_add_grudge(user) + addtimer(CALLBACK(src, PROC_REF(kingpin_answer_cuffs), WEAKREF(user)), BOUNTY_CUFF_REACTION_DELAY, TIMER_DELETE_ME) + +/// A moment after the reach: his line, and a shove for whoever is at his wrists, if he still can +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_answer_cuffs(datum/weakref/user_ref) + var/mob/living/user = user_ref?.resolve() + if(QDELETED(src) || QDELETED(user) || !kingpin_able() || !Adjacent(user)) + return + INVOKE_ASYNC(src, PROC_REF(kingpin_confronted), user) + bounty_kingpin_shove(src, user) + +// He gives up the way a person does (the human NPC base): his aim drops, his hands go up, then tg's *surrender +/mob/living/carbon/human/npc/bounty_criminal/kingpin/npc_surrender() + if(stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_RESTRAINED) || npc_surrendering) + return FALSE + kingpin_cancel_aim() + visible_message(span_notice("[src] puts [p_their()] hands up."), vision_distance = BOUNTY_KINGPIN_SIGHT) + return ..() // An open hand in reach of him starts the talk -/mob/living/basic/bounty_criminal/kingpin/attack_hand(mob/living/carbon/human/user, list/modifiers) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/attack_hand(mob/living/carbon/human/user, list/modifiers) if(istype(user) && !user.combat_mode && !LAZYACCESS(modifiers, RIGHT_CLICK) && !is_restrained() && kingpin_can_talk()) INVOKE_ASYNC(src, PROC_REF(kingpin_open_talk), user) return TRUE return ..() -/mob/living/basic/bounty_criminal/kingpin/ranged_attack_hand(mob/living/user, list/modifiers) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/ranged_attack_hand(mob/living/user, list/modifiers) if(!kingpin_in_talk_range(user)) return FALSE if(!kingpin_can_talk()) @@ -357,72 +393,54 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return TRUE /// His posting, if he is on one (/datum/criminal_bounty/kingpin): none while he just sits in his lounge -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_posting() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_posting() RETURN_TYPE(/datum/criminal_bounty/kingpin) var/datum/criminal_bounty/kingpin/posting = posting() return istype(posting) ? posting : null /// Whether he is in his lounge: on a turf (not in a locker or a bag) within his goons' leash of his seat -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_on_leash() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_on_leash() var/turf/seat = kingpin_crew?.crew_seat var/turf/here = get_turf(src) if(!seat || !here || !isturf(loc)) return FALSE return here.z == seat.z && get_dist(here, seat) <= BOUNTY_GOON_LEASH -/// A posting let go of him in his lounge: his people see to him. The cuffs come off (onto the floor) and he stops giving up; a downed one gets up in his own time. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_back_to_business() - if(is_restrained()) - body_remove_cuffs() - REMOVE_TRAIT(src, TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT) - -/// His body, with no posting to bring it in for, fades after BOUNTY_GOON_FADE as a goon's does (once) -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_start_fade() - if(kingpin_fade_timer || stat != DEAD || QDELETED(src)) - return - kingpin_fade_timer = addtimer(CALLBACK(src, PROC_REF(kingpin_fade_out)), BOUNTY_GOON_FADE, TIMER_STOPPABLE | TIMER_DELETE_ME) - -/// The body fades away, unless a posting holds it for the pad. Taken away, not destroyed: no proof of death. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_fade_out() - kingpin_fade_timer = null - if(QDELETED(src) || kingpin_posting()) - return - ADD_TRAIT(src, TRAIT_BOUNTY_REMOVED, BOUNTY_KINGPIN_TRAIT) - pulledby?.stop_pulling() - animate(src, alpha = 0, time = BOUNTY_GOON_FADE_OUT) - QDEL_IN(src, BOUNTY_GOON_FADE_OUT) +/// A posting let go of him in his lounge: his people see to him. The cuffs come off (onto the floor); anything else he is in, crit included, he gets over in his own time. +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_back_to_business() + if(handcuffed || legcuffed) + uncuff() /** * Takes him back to his sofa when he's been moved off it and is free again: a step at a time within - * his crew's leash, a walk from further off, and put straight back from another level or too far to - * walk, when nobody is watching him (L5). + * his crew's leash, a real walk from further off, and put straight back from another level or too + * far to walk only when no player is near enough to see it (human_npc_watched(), CB-19, L5). */ -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_go_home() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_go_home() var/turf/seat = kingpin_crew?.crew_seat var/turf/here = get_turf(src) if(!seat || !here || here == seat || buckled || !kingpin_able() || !isturf(loc) || pulledby) return FALSE if(here.z == seat.z && get_dist(here, seat) <= BOUNTY_GOON_LEASH) var/turf/next = get_step_to(src, seat) - if(next) + if(next && body_move_allowed(next)) Move(next, get_dir(src, next)) return TRUE if(here.z == seat.z && get_dist(here, seat) <= BOUNTY_KINGPIN_WALK_HOME_MAX) if(world.time >= kingpin_crew.crew_next_home_walk) kingpin_crew.crew_next_home_walk = world.time + BOUNTY_KINGPIN_WALK_HOME_GAP - GLOB.move_manager.jps_move(src, seat, delay = 3, timeout = BOUNTY_KINGPIN_WALK_HOME_GAP, repath_delay = 2 SECONDS, max_path_length = BOUNTY_KINGPIN_WALK_HOME_MAX * 2, simulated_only = TRUE, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) + GLOB.move_manager.jps_move(src, seat, delay = max(3, cached_multiplicative_slowdown), timeout = BOUNTY_KINGPIN_WALK_HOME_GAP, repath_delay = 2 SECONDS, max_path_length = BOUNTY_KINGPIN_WALK_HOME_MAX * 2, simulated_only = TRUE, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) return TRUE - for(var/mob/living/watcher in viewers(world.view, src)) - if(watcher.client && watcher != src) - return FALSE + if(human_npc_watched(src)) + return FALSE forceMove(seat) return kingpin_sit() /// Sits him on the sofa at his seat, facing the way it faces. TRUE if he is sitting there now. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_sit() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_sit() if(buckled) return istype(buckled, /obj/structure/chair) - if(stat != CONSCIOUS || downed || is_restrained() || body_is_stunned()) + if(stat != CONSCIOUS || is_downed() || is_restrained() || body_is_stunned()) return FALSE var/turf/seat = kingpin_crew?.crew_seat || get_turf(src) if(get_turf(src) != seat) @@ -438,30 +456,30 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // ===== TALKING ===== /// Whether he'll talk now: awake, free, on his feet, and no shootout on -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_can_talk() - if(stat != CONSCIOUS || downed || is_restrained() || body_is_stunned()) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_can_talk() + if(stat != CONSCIOUS || is_downed() || is_restrained() || body_is_stunned() || npc_surrendering) return FALSE return kingpin_crew?.crew_state == BOUNTY_KINGPIN_CALM /// Whether `user` is close enough to talk to him: across the table, in sight, awake -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_in_talk_range(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_in_talk_range(mob/living/user) if(!isliving(user) || QDELETED(user) || user.stat != CONSCIOUS || user.z != z) return FALSE return get_dist(src, user) <= BOUNTY_KINGPIN_TALK_RANGE && can_see(user, src, BOUNTY_KINGPIN_TALK_RANGE) /// The radial's validity check -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_talk_check(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_talk_check(mob/living/user) return kingpin_can_talk() && kingpin_in_talk_range(user) /// Whether `user`'s crew has a live reason to confront him: his posting is up and open, and their ship took it from the board -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_confrontable(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_confrontable(mob/living/user) var/datum/criminal_bounty/kingpin/posting = kingpin_posting() if(!posting?.is_open()) return FALSE return posting.is_hunting(get_crew_ship(user)) /// The talk radial for `user`. "We're here for you" and "Walk away" only while their crew is hunting him; "I'll take it" only while he has offered their crew his job. Sleeps. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_open_talk(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_open_talk(mob/living/user) if(!kingpin_talk_check(user)) return var/list/options = list() @@ -487,7 +505,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * starts, his job is offered or taken, or nothing happens. Returns TRUE if he listened. Making a job * can sleep. */ -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_talk(mob/living/user, option) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_talk(mob/living/user, option) if(!kingpin_can_talk() || QDELETED(user)) return FALSE bounty_kingpin_hunter_says(user, option) @@ -508,7 +526,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return TRUE /// Shown a warrant or reached for: his confront line, and no fight -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_confronted(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_confronted(mob/living/user) if(!kingpin_can_talk() || QDELETED(user)) return bounty_say("confront", force = TRUE) @@ -516,7 +534,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // ===== HIS BUSINESS ===== /// The name he goes by on his jobs: his alias, else his name ("The Accountant") -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_job_name() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_job_name() return capitalize(record?.alias || record?.name || name) /** @@ -525,7 +543,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * fought his people, someone is already running his job, or he has none to give. May make his job, * which can sleep. */ -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_work_refusal(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_work_refusal(mob/living/user) var/datum/bounty_kingpin_crew/crew = kingpin_crew if(!crew) return "no_work" @@ -546,7 +564,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return null /// "Got any work?": he offers `user`'s crew the job he holds, for BOUNTY_KINGPIN_OFFER_TIME, or says why not. Returns TRUE if he offered it. Sleeps. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_offer_work(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_offer_work(mob/living/user) var/refusal = kingpin_work_refusal(user) var/datum/bounty_kingpin_crew/crew = kingpin_crew if(QDELETED(src) || !crew) @@ -566,7 +584,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * (SSbounty_kingpin.kingpin_bind()): none of them can hunt him or turn him in. He makes another job * if the round's limit allows. Returns the job, or null. Sleeps. */ -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_take_job(mob/living/user) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_take_job(mob/living/user) var/datum/bounty_kingpin_crew/crew = kingpin_crew var/obj/structure/overmap/ship/ship = get_crew_ship(user) var/datum/mission/job = crew?.job() @@ -605,29 +623,32 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // ===== HIS REVOLVER ===== -/// His revolver: the one in his hand, whose rounds and cylinder are his (bounty_real_weapon()) -/proc/bounty_kingpin_revolver() - return bounty_real_weapon(BOUNTY_KINGPIN_REVOLVER) - -/// Whether he can shoot now: awake, not down, not cuffed, not stunned, not given up -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_able() - if(stat != CONSCIOUS || downed || is_restrained() || body_is_stunned()) +/// Whether he can shoot now: awake, not in crit, not cuffed, not stunned, not lying there having given up, hands free +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_able() + if(stat != CONSCIOUS || is_downed() || is_restrained() || body_is_stunned() || npc_surrendering) return FALSE - return !HAS_TRAIT(src, TRAIT_INCAPACITATED) + return !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_HANDS_BLOCKED) -/// He thumbs the hammer back and aims at `target`: a visible line for BOUNTY_KINGPIN_REVOLVER_AIM, then the shot -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_start_aim(mob/living/target) +/// He thumbs the hammer back and aims at `target`: a visible line for BOUNTY_KINGPIN_REVOLVER_AIM, then the shot. Needs a loaded gun in his hand. +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_start_aim(mob/living/target) kingpin_cancel_aim() + if(!human_npc_loaded_gun(src)) + return FALSE kingpin_next_aim_at = world.time + BOUNTY_KINGPIN_REVOLVER_COOLDOWN kingpin_aim_target = WEAKREF(target) var/atom/aim_at = kingpin_crew?.shot_target(target) || target + face_atom(aim_at) kingpin_aim_beam = Beam(aim_at, icon_state = "infrared", time = BOUNTY_KINGPIN_REVOLVER_AIM, beam_color = COLOR_RED) visible_message(span_danger("[src] thumbs the hammer back and aims at [target]!"), vision_distance = BOUNTY_KINGPIN_SIGHT) playsound(src, 'sound/items/weapons/gun/general/ballistic_click.ogg', 60, TRUE) kingpin_aim_timer = addtimer(CALLBACK(src, PROC_REF(kingpin_fire)), BOUNTY_KINGPIN_REVOLVER_AIM, TIMER_STOPPABLE | TIMER_DELETE_ME) + return TRUE -/// The aim is done: he fires if he still can and still sees them. A stun in the meantime spoils it. -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_fire() +/** + * The aim is done: he pulls the trigger of the revolver in his hand, if he still can, still sees them + * and nobody he isn't fighting stands in the line. A stun in the meantime spoils it. + */ +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_fire() kingpin_aim_timer = null var/mob/living/target = kingpin_aim_target?.resolve() kingpin_aim_target = null @@ -641,32 +662,45 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return FALSE if(!crew.valid_target(target) || !bounty_kingpin_clear_shot(src, target)) return FALSE - kingpin_rounds-- - bounty_kingpin_shoot(src, crew.shot_target(target), bounty_kingpin_revolver(), crew) + var/atom/aim_at = crew.shot_target(target) + if(crew.line_blocked(src, aim_at)) + return FALSE + var/obj/item/gun/gun = human_npc_loaded_gun(src) + if(!gun) + return FALSE + return crew.pull_trigger(src, gun, aim_at) + +/// A hit while he aims: the shot is spoiled +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_fumble() + if(!kingpin_aim_timer) + return FALSE + kingpin_cancel_aim() + if(stat == CONSCIOUS) + visible_message(span_notice("[src] fumbles the shot."), vision_distance = COMBAT_MESSAGE_RANGE) return TRUE /// Drops his aim, if he has one -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_cancel_aim() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_cancel_aim() if(kingpin_aim_timer) deltimer(kingpin_aim_timer) kingpin_aim_timer = null kingpin_aim_target = null QDEL_NULL(kingpin_aim_beam) -/// He reloads: BOUNTY_KINGPIN_REVOLVER_RELOAD with nothing fired, a window for the hunters -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_reload() - kingpin_rounds = bounty_kingpin_revolver().magazine - kingpin_next_aim_at = world.time + BOUNTY_KINGPIN_REVOLVER_RELOAD +/// His reload starts (his AI's reload, the player's way: a speedloader into the cylinder): his line, and the room hears him ask for cover +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_reload_tell() + kingpin_cancel_aim() visible_message(span_notice("[src] breaks open [p_their()] revolver and reloads."), vision_distance = COMBAT_MESSAGE_RANGE) - playsound(src, 'sound/items/weapons/gun/revolver/load_bullet.ogg', 50, TRUE) INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "reload") -/// He picks who to shoot among `visible`: whoever hit him most, else the nearest -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_choose_target(list/visible) +/// He picks who to shoot among `visible`: whoever hit him most, else the nearest, among those he has a clear line to +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_choose_target(list/visible) var/mob/living/best var/best_hits = -1 var/best_dist = INFINITY for(var/mob/living/candidate as anything in visible) + if(kingpin_crew?.line_blocked(src, kingpin_crew.shot_target(candidate))) + continue var/hits = kingpin_hits[candidate.weak_reference] || 0 var/dist = get_dist(src, candidate) if(hits > best_hits || (hits == best_hits && dist < best_dist)) @@ -676,48 +710,51 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return best /// Someone hit him: they count toward who he shoots first -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_note_hit(mob/living/attacker) +/mob/living/carbon/human/npc/bounty_criminal/kingpin/proc/kingpin_note_hit(mob/living/attacker) if(!isliving(attacker)) return var/datum/weakref/ref = WEAKREF(attacker) kingpin_hits[ref] = (kingpin_hits[ref] || 0) + 1 -// ===== GIVING UP ===== +// ===== GIVING UP (CB-14) ===== /** - * After a hit that got through: with his crew down and his health at or below - * BOUNTY_KINGPIN_SURRENDER_BELOW percent, he may give up. BOUNTY_KINGPIN_SURRENDER_FIRST on the hit - * that crosses the line, BOUNTY_KINGPIN_SURRENDER_LATER on later hits, one roll per - * BOUNTY_KINGPIN_SURRENDER_GAP. Never while rallying. Doesn't sleep. + * His surrender rules for the human NPC base's surrender subtree: only once his crew is down (no goon + * still in the fight) and he is at or below BOUNTY_KINGPIN_SURRENDER_BELOW percent of his health, with + * a hunter in sight. BOUNTY_KINGPIN_SURRENDER_FIRST on his first roll, BOUNTY_KINGPIN_SURRENDER_LATER + * after, one roll per BOUNTY_KINGPIN_SURRENDER_GAP. He holsters the revolver, says his line, puts his + * hands up and runs tg's *surrender; then tg's cuffs. He never runs from the sofa. */ -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_consider_surrender(health_before) - if(stat != CONSCIOUS || downed || is_restrained() || HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED) || body_is_rallying()) - return FALSE - if(!kingpin_crew?.crew_down()) - return FALSE - var/line = maxHealth * BOUNTY_KINGPIN_SURRENDER_BELOW / 100 - if(health > line) - return FALSE - var/chance = BOUNTY_KINGPIN_SURRENDER_FIRST - if(health_before <= line) - if(world.time < kingpin_next_surrender_roll) - return FALSE - chance = BOUNTY_KINGPIN_SURRENDER_LATER - kingpin_next_surrender_roll = world.time + BOUNTY_KINGPIN_SURRENDER_GAP - if(!prob(chance)) +/datum/human_npc_surrender/bounty_kingpin + chance = BOUNTY_KINGPIN_SURRENDER_FIRST + retry_chance = BOUNTY_KINGPIN_SURRENDER_LATER + ally_penalty = 0 + health_share = BOUNTY_KINGPIN_SURRENDER_BELOW / 100 + hostiles_needed = 1 + range = BOUNTY_KINGPIN_SIGHT + retry_delay = BOUNTY_KINGPIN_SURRENDER_GAP + flees = FALSE + +/datum/human_npc_surrender/bounty_kingpin/hurt_enough(mob/living/carbon/human/npc/npc) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = npc + if(!istype(kingpin) || !kingpin.kingpin_crew?.crew_down()) return FALSE - kingpin_surrender() - return TRUE + return ..() -/// He gives up: P2 reads it as stunned, so he can be cuffed, and he fires no more -/mob/living/basic/bounty_criminal/kingpin/proc/kingpin_surrender() - if(HAS_TRAIT(src, TRAIT_BOUNTY_SURRENDERED)) +// Who he gives up to: the hunters his crew is fighting that he can see +/datum/human_npc_surrender/bounty_kingpin/hostiles_near(mob/living/carbon/human/npc/npc) + . = list() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = npc + var/datum/bounty_kingpin_crew/crew = istype(kingpin) ? kingpin.kingpin_crew : null + if(!crew) return - kingpin_cancel_aim() - ADD_TRAIT(src, TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT) - body_note_state() - visible_message(span_notice("[src] puts [p_their()] hands up."), vision_distance = BOUNTY_KINGPIN_SIGHT) - INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "surrender", null, TRUE) + for(var/mob/living/hunter as anything in crew.live_hunters()) + if(can_see(npc, hunter, range)) + . += hunter + +// His crew is down by the time he thinks about it: nobody's left to count +/datum/human_npc_surrender/bounty_kingpin/allies_near(mob/living/carbon/human/npc/npc) + return list() // ========================================================================= // THE GOONS @@ -726,42 +763,23 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * # Kingpin goon * - * One of the kingpin's crew: not a bounty, pays nothing, carries nothing. Before a shootout they - * idle at their posts (smoke, drink, murmur, watch the room); in one, their crew - * (/datum/bounty_kingpin_crew) moves them and fires their simulated guns. Turrets leave them alone - * (FACTION_TURRET). Their bodies fade after BOUNTY_GOON_FADE. + * One of the kingpin's crew: not a bounty, pays nothing. A real human in a suit and an armour vest, + * with a real gun and its spares carried on the body (the subtype picks which: three pistols, two + * shotguns and an SMG in the lounge). Their gear is locked to them (D3), so none of it is loot. Before + * a shootout they idle at their posts (smoke, drink, murmur, watch the room); in one, their crew + * (/datum/bounty_kingpin_crew) picks their targets, moves them, and pulls the triggers of the guns + * in their hands through their AI controllers; their own AI reloads, breaks free, crawls in crit and + * walks out. Turrets leave them alone (FACTION_TURRET). Their bodies go with the human NPC corpse sweep. */ -/mob/living/basic/bounty_kingpin_goon +/mob/living/carbon/human/npc/bounty_kingpin_goon name = "goon" - desc = "One of the kingpin's people. Good suit, bad attitude." - icon = 'icons/mob/simple/simple_human.dmi' - unique_name = FALSE - maxHealth = BOUNTY_GOON_HEALTH - health = BOUNTY_GOON_HEALTH - speed = 1 - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - mob_size = MOB_SIZE_HUMAN - sentience_type = SENTIENCE_BOSS + npc_name = "goon" faction = list(FACTION_BOUNTY_KINGPIN, FACTION_TURRET) - density = TRUE - move_resist = MOVE_FORCE_VERY_STRONG - // The body stays until it fades; nothing drops - basic_mob_flags = NONE - status_flags = CANPUSH | CANSTUN | CANKNOCKDOWN - // P2's pattern: a pool of 100 and a coefficient, or the stamcrit ends the moment it starts (M1) - max_stamina = BOUNTY_GOON_MAX_STAMINA - stamina_crit_threshold = BOUNTY_GOON_STAMCRIT_AT - mobility_flags = MOBILITY_FLAGS_REST_CAPABLE_DEFAULT - rotate_on_lying = TRUE - blood_volume = BLOOD_VOLUME_NORMAL - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - death_message = "seizes up and falls limp, their eyes dead and lifeless..." - response_help_continuous = "taps" - response_help_simple = "tap" - ai_controller = null - /// BOUNTY_GOON_WEAPON_* + ai_controller = /datum/ai_controller/basic_controller/human_npc/bounty_kingpin_goon + npc_outfit_table = /datum/human_npc_outfit_table/bounty + // Like the criminals, his people never shoot someone who is down (defaults 1.13) + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + /// BOUNTY_GOON_WEAPON_*: what they carry var/goon_weapon = BOUNTY_GOON_WEAPON_PISTOL /// Where they stand, or sit, when there is no fight var/turf/goon_post @@ -771,9 +789,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/datum/weakref/goon_crew_ref /// Weakref to who they are shooting at var/datum/weakref/goon_target_ref - /// Rounds (or shells) left before a reload - var/goon_rounds = 0 - /// world.time they may fire next + /// world.time they may pull the trigger next var/goon_next_fire_at = 0 /// world.time a fumble ends var/goon_fumbled_until = 0 @@ -781,70 +797,56 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/goon_windup_timer /// world.time of their next step var/goon_next_move_at = 0 - /// Running away: he fell and they don't want to die for him + /// Running away: he fell and they don't want to die for him (they walk out, npc_walk_out()) var/goon_fleeing = FALSE - /// Walking out: their crew is gone - var/goon_leaving = FALSE - /// Their gun is out (their worn look) - var/goon_armed = FALSE - /// Which of the look pool's faces they have - var/goon_look_number = 1 - /// The timer fading their body out - var/goon_fade_timer /// Weakref to the chair at their post, if they sit var/datum/weakref/goon_chair_ref -/mob/living/basic/bounty_kingpin_goon/Initialize(mapload) +/mob/living/carbon/human/npc/bounty_kingpin_goon/pistol + +/mob/living/carbon/human/npc/bounty_kingpin_goon/smg + goon_weapon = BOUNTY_GOON_WEAPON_SMG + +/mob/living/carbon/human/npc/bounty_kingpin_goon/shotgun + goon_weapon = BOUNTY_GOON_WEAPON_SHOTGUN + +/// The goon type that carries `weapon` (BOUNTY_GOON_WEAPON_*) +/proc/bounty_kingpin_goon_type(weapon) + switch(weapon) + if(BOUNTY_GOON_WEAPON_SMG) + return /mob/living/carbon/human/npc/bounty_kingpin_goon/smg + if(BOUNTY_GOON_WEAPON_SHOTGUN) + return /mob/living/carbon/human/npc/bounty_kingpin_goon/shotgun + return /mob/living/carbon/human/npc/bounty_kingpin_goon/pistol + +/mob/living/carbon/human/npc/bounty_kingpin_goon/Initialize(mapload, datum/human_npc_order/order) . = ..() - // Their own copy: 5 disabler hits (30 each) fill the pool of BOUNTY_GOON_MAX_STAMINA - damage_coeff = damage_coeff.Copy() - damage_coeff[STAMINA] = BOUNTY_GOON_MAX_STAMINA / BOUNTY_GOON_HEALTH - gender = prob(75) ? MALE : FEMALE - goon_look_number = random_outpost_npc_look_number() add_traits(list(TRAIT_NO_TELEPORT, TRAIT_NOMOBSWAP), BOUNTY_KINGPIN_TRAIT) - AddElement(/datum/element/relay_attackers) - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) - INVOKE_ASYNC(src, PROC_REF(goon_dress)) -/mob/living/basic/bounty_kingpin_goon/Destroy() +/mob/living/carbon/human/npc/bounty_kingpin_goon/Destroy() goon_cancel_windup() - deltimer(goon_fade_timer) - goon_fade_timer = null goon_post = null goon_crew_ref = null goon_target_ref = null goon_chair_ref = null return ..() +// The outfit table asks what they wear: the suit and vest, and the gun of their post with its spares +/mob/living/carbon/human/npc/bounty_kingpin_goon/bounty_build_outfit(zone) + switch(goon_weapon) + if(BOUNTY_GOON_WEAPON_SMG) + return /datum/outfit/bounty_kingpin_goon/smg + if(BOUNTY_GOON_WEAPON_SHOTGUN) + return /datum/outfit/bounty_kingpin_goon/shotgun + return /datum/outfit/bounty_kingpin_goon/pistol + /// Their crew, if it still exists -/mob/living/basic/bounty_kingpin_goon/proc/goon_crew() +/mob/living/carbon/human/npc/bounty_kingpin_goon/proc/goon_crew() var/datum/bounty_kingpin_crew/crew = goon_crew_ref?.resolve() return QDELETED(crew) ? null : crew -/// Puts their look on: at ease, or with their gun out. Sleeps. -/mob/living/basic/bounty_kingpin_goon/proc/goon_dress() - var/outfit = /datum/outfit/bounty_kingpin_goon - if(goon_armed) - switch(goon_weapon) - if(BOUNTY_GOON_WEAPON_SMG) - outfit = /datum/outfit/bounty_kingpin_goon/smg - if(BOUNTY_GOON_WEAPON_SHOTGUN) - outfit = /datum/outfit/bounty_kingpin_goon/shotgun - else - outfit = /datum/outfit/bounty_kingpin_goon/pistol - set_outpost_npc_look(src, outfit, gender, goon_look_number) - if(!QDELETED(src)) - update_appearance(UPDATE_OVERLAYS) - -/// Their gun comes out, or goes away -/mob/living/basic/bounty_kingpin_goon/proc/goon_arm(armed) - if(goon_armed == armed) - return - goon_armed = armed - INVOKE_ASYNC(src, PROC_REF(goon_dress)) - /// Says a line from the goons' dialogue, out loud or under their breath -/mob/living/basic/bounty_kingpin_goon/proc/goon_say(context, whisper = FALSE) +/mob/living/carbon/human/npc/bounty_kingpin_goon/proc/goon_say(context, whisper = FALSE) if(stat != CONSCIOUS) return FALSE var/line = bounty_kingpin_line("goon", context) @@ -856,110 +858,230 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) say(line, forced = "bounty kingpin goon") return TRUE -/// Whether they can fight now: awake, on their feet, not stunned, not leaving -/mob/living/basic/bounty_kingpin_goon/proc/goon_able() - if(stat != CONSCIOUS || goon_fleeing || goon_leaving) +/// Whether they can fight now: awake, on their feet, hands free, not cuffed, not running, not leaving +/mob/living/carbon/human/npc/bounty_kingpin_goon/proc/goon_able() + if(stat != CONSCIOUS || goon_fleeing || !isnull(npc_exit)) + return FALSE + if(HAS_TRAIT(src, TRAIT_RESTRAINED) || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) return FALSE return !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_FLOORED) /// Drops a burst or a blast that was winding up -/mob/living/basic/bounty_kingpin_goon/proc/goon_cancel_windup() +/mob/living/carbon/human/npc/bounty_kingpin_goon/proc/goon_cancel_windup() if(goon_windup_timer) deltimer(goon_windup_timer) goon_windup_timer = null -/// A goon's gun by BOUNTY_GOON_WEAPON_*: the one in his hand, loaded as it is (bounty_real_weapon()) -/proc/bounty_kingpin_goon_gun(weapon) - switch(weapon) - if(BOUNTY_GOON_WEAPON_SMG) - return bounty_real_weapon(BOUNTY_GOON_SMG_GUN) - if(BOUNTY_GOON_WEAPON_SHOTGUN) - return bounty_real_weapon(BOUNTY_GOON_SHOTGUN_GUN, BOUNTY_GOON_SHOTGUN_AMMO) - return bounty_real_weapon(BOUNTY_GOON_PISTOL_GUN) - -/// A full magazine (or tube) for their gun -/proc/bounty_kingpin_goon_mag(weapon) - var/datum/bounty_real_weapon/gun = bounty_kingpin_goon_gun(weapon) - return gun.magazine - -/// How long their gun takes to reload -/proc/bounty_kingpin_goon_reload(weapon) - switch(weapon) - if(BOUNTY_GOON_WEAPON_SMG) - return BOUNTY_GOON_SMG_RELOAD - if(BOUNTY_GOON_WEAPON_SHOTGUN) - return BOUNTY_GOON_SHOTGUN_RELOAD - return BOUNTY_GOON_PISTOL_RELOAD +/mob/living/carbon/human/npc/bounty_kingpin_goon/examine(mob/user) + . = ..() + if(stat == DEAD || human_npc_held_gun(src)) + return + . += span_notice("[p_They()] keep[p_s()] a hand near [p_their()] jacket.") -/mob/living/basic/bounty_kingpin_goon/examine(mob/user) +// Killed in a chair, they slump out of it +/mob/living/carbon/human/npc/bounty_kingpin_goon/death(gibbed) + goon_cancel_windup() . = ..() - if(stat == DEAD) + if(QDELETED(src) || stat != DEAD) return - if(goon_armed) - . += span_warning("[p_They()] [p_have()] a gun out.") - else - . += span_notice("[p_They()] keep[p_s()] a hand near [p_their()] jacket.") + if(istype(buckled, /obj/structure/chair)) + buckled.unbuckle_mob(src, force = TRUE) -// Hurt while their crew is at ease, by anything at all: the crew finds out who (H1) -/mob/living/basic/bounty_kingpin_goon/adjust_health(amount, updating_health = TRUE, forced = FALSE) +/** + * They broke someone's hold on them by resisting (the human NPC base's reactions): the grab answer + * (CB-22). A word, the grabber told, and a real shove if they're still in reach and have a hand free. + */ +/mob/living/carbon/human/npc/bounty_kingpin_goon/npc_check_pulled_free(atom/movable/holder, within = 0) + var/mob/living/grabber = npc_resisted_hold?.resolve() + var/freed = npc_resisted_at && world.time <= npc_resisted_at + within && (!holder || grabber == holder) . = ..() - if(amount > 0 && !forced) - goon_crew()?.member_hurt(src) + if(!freed || !isliving(grabber) || QDELETED(grabber) || stat != CONSCIOUS) + return + INVOKE_ASYNC(src, PROC_REF(goon_shove_off), grabber) + +/// "Hands off.", and a shove for `grabber`. Sleeps. +/mob/living/carbon/human/npc/bounty_kingpin_goon/proc/goon_shove_off(mob/living/grabber) + if(QDELETED(src) || QDELETED(grabber) || stat != CONSCIOUS) + return + balloon_alert(grabber, "[p_they()] shove[p_s()] you off!") + goon_say("shoved") + bounty_kingpin_shove(src, grabber) -// Alive, they don't get dragged off anywhere; a body can be. -/mob/living/basic/bounty_kingpin_goon/can_be_pulled(user, force) - if(stat != DEAD) - if(ismob(user)) - balloon_alert(user, "[p_they()] shove[p_s()] you off!") +/** + * `shover` gives `target` a real shove: an empty hand, a right click on them, tg's shove (a step back, + * or a stagger against something solid). Nothing if they're out of reach or both hands are full. + * Returns TRUE if it was tried. + */ +/proc/bounty_kingpin_shove(mob/living/carbon/human/shover, mob/living/target) + if(QDELETED(shover) || QDELETED(target) || !shover.Adjacent(target) || !shover.ai_controller) return FALSE - return ..() + if(HAS_TRAIT(shover, TRAIT_HANDS_BLOCKED) || HAS_TRAIT(shover, TRAIT_INCAPACITATED)) + return FALSE + if(!human_npc_ready_empty_hand(shover)) + return FALSE + INVOKE_ASYNC(shover.ai_controller, TYPE_PROC_REF(/datum/ai_controller, ai_interact), target, TRUE, list(RIGHT_CLICK = TRUE)) + return TRUE -// Out of a chair while down or dead, they lie flat as the criminals do -/mob/living/basic/bounty_kingpin_goon/set_lying_down(new_lying_angle) - . = ..() - bounty_body_lie_flat(src, new_lying_angle) +// ===== THEIR HANDS AND THEIR GUNS ===== -// A goon's body stays for BOUNTY_GOON_FADE, then fades. Nothing drops. -/mob/living/basic/bounty_kingpin_goon/death(gibbed) - goon_cancel_windup() - . = ..() - if(QDELETED(src)) - return - // A chair holds a body upright: they slump out of it - if(istype(buckled, /obj/structure/chair)) - buckled.unbuckle_mob(src, force = TRUE) - // A body can be dragged about - move_resist = MOVE_RESIST_DEFAULT - deltimer(goon_fade_timer) - goon_fade_timer = addtimer(CALLBACK(src, PROC_REF(goon_fade_out)), BOUNTY_GOON_FADE, TIMER_STOPPABLE | TIMER_DELETE_ME) - -/// Their body fades away -/mob/living/basic/bounty_kingpin_goon/proc/goon_fade_out() - goon_fade_timer = null - if(QDELETED(src)) +/// A gun `npc` carries on its body and can draw in a moment (holster, belt, suit storage, back, a pocket): a loaded one first +/proc/bounty_kingpin_carried_gun(mob/living/carbon/npc) + var/obj/item/gun/found + for(var/obj/item/gun/gun in npc.get_all_contents_type(/obj/item/gun)) + if(npc.is_holding(gun) || !human_npc_can_draw(npc, gun)) + continue + if(gun.can_shoot()) + return gun + found ||= gun + return found + +/// `npc` draws its gun into a free hand, as a person does, and readies it. Returns the gun in its hand, or null. +/proc/bounty_kingpin_draw_gun(mob/living/carbon/npc) + var/obj/item/gun/gun = human_npc_held_gun(npc) + if(gun) + return gun + if(HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || HAS_TRAIT(npc, TRAIT_INCAPACITATED)) + return null + gun = bounty_kingpin_carried_gun(npc) + if(!gun || !human_npc_take_in_hand(npc, gun)) + return null + human_npc_ready_weapon(npc, gun) + human_npc_ready_item(npc, gun) + return gun + +/// `npc` puts the guns in its hands away on its body (D3: never on the floor) +/proc/bounty_kingpin_holster_guns(mob/living/carbon/npc) + for(var/obj/item/gun/gun in npc.held_items) + gun.equip_to_best_slot(npc) + +/// Tells `member`'s AI whether its crew's guns are out, so a spent one gets reloaded (and a holstered one drawn again) +/proc/bounty_kingpin_set_armed(mob/living/member, armed) + var/datum/ai_controller/controller = member?.ai_controller + if(!controller) return - pulledby?.stop_pulling() - animate(src, alpha = 0, time = BOUNTY_GOON_FADE_OUT) - addtimer(CALLBACK(src, PROC_REF(goon_gone)), BOUNTY_GOON_FADE_OUT, TIMER_DELETE_ME) + if(armed) + controller.set_blackboard_key(BB_BOUNTY_KINGPIN_ARMED, TRUE) + else + controller.clear_blackboard_key(BB_BOUNTY_KINGPIN_ARMED) + controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET) -/mob/living/basic/bounty_kingpin_goon/proc/goon_gone() - qdel(src) +// ========================================================================= +// THEIR AI +// ========================================================================= /** - * They leave: their crew is gone (the posting closed), or they ran. They walk toward `exit` and fade - * out after `time`. Nobody can hurt them on the way out. A body is left to fade on its own. + * The kingpin's own AI: what his body does by itself. His crew aims and fires for him. First the human + * NPC base's soft crit and breaking free (never out of his sofa: P2's bounty_escape_captivity leaves + * a chair he sat in alone), then giving up (his surrender rules), keeping his revolver loaded while the + * guns are out, and his medipen when he is hurt and nobody is on him. He doesn't wander. */ -/mob/living/basic/bounty_kingpin_goon/proc/goon_leave(turf/exit, time = BOUNTY_GOON_LEAVE_TIME) - if(goon_leaving || stat == DEAD || QDELETED(src)) +/datum/ai_controller/basic_controller/human_npc/bounty_kingpin + idle_behavior = null + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/human_npc_surrender, + /datum/ai_planning_subtree/bounty_kingpin_crew_arms, + /datum/ai_planning_subtree/bounty_treat_self, + ) + +/** + * A goon's own AI: soft crit, breaking free of cuffs, carriers and hard grabs (P2's), the grab answer + * for anyone else holding them (CB-22), walking out when they leave, and keeping their gun loaded while + * the guns are out. Their crew does the rest. They don't wander. + */ +/datum/ai_controller/basic_controller/human_npc/bounty_kingpin_goon + idle_behavior = null + planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity, + /datum/ai_planning_subtree/bounty_kingpin_goon_grabbed, + /datum/ai_planning_subtree/human_npc_walk_out, + /datum/ai_planning_subtree/bounty_kingpin_crew_arms, + ) + +/// Held by anyone outside the crew, even a gentle pull: they resist, and break free with a word and a shove (npc_check_pulled_free()) +/datum/ai_planning_subtree/bounty_kingpin_goon_grabbed + +/datum/ai_planning_subtree/bounty_kingpin_goon_grabbed/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = controller.pawn + if(!istype(goon) || goon.stat != CONSCIOUS || HAS_TRAIT(goon, TRAIT_RESTRAINED)) return - goon_leaving = TRUE - goon_cancel_windup() - goon_target_ref = null - ADD_TRAIT(src, TRAIT_GODMODE, BOUNTY_KINGPIN_TRAIT) - buckled?.unbuckle_mob(src, force = TRUE) - if(exit && isturf(loc)) - GLOB.move_manager.jps_move(src, exit, delay = BOUNTY_GOON_STEP, timeout = time, repath_delay = 2 SECONDS, max_path_length = 60, simulated_only = TRUE, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) - addtimer(CALLBACK(src, PROC_REF(goon_fade_out)), time, TIMER_DELETE_ME) + var/mob/living/grabber = goon.pulledby + if(!isliving(grabber) || goon.goon_crew()?.is_member(grabber)) + return + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * While the crew's guns are out (BB_BOUNTY_KINGPIN_ARMED): a spent gun in hand is reloaded the player's + * way from the spares carried, and an empty hand draws the gun again (after giving up, say). Nothing + * while at ease: the guns stay holstered. + */ +/datum/ai_planning_subtree/bounty_kingpin_crew_arms + +/datum/ai_planning_subtree/bounty_kingpin_crew_arms/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/pawn = controller.pawn + if(pawn.stat != CONSCIOUS || HAS_TRAIT(pawn, TRAIT_HANDS_BLOCKED) || LAZYLEN(pawn.do_afters)) + return + if(!controller.blackboard[BB_BOUNTY_KINGPIN_ARMED] || world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT]) + return + if(ishumannpc(pawn)) + var/mob/living/carbon/human/npc/npc = pawn + if(npc.npc_surrendering) + return + var/obj/item/gun/gun = human_npc_held_gun(pawn) + if(gun?.can_shoot()) + return + if(gun) + if(!human_npc_can_reload(pawn, gun)) + return + controller.queue_behavior(/datum/ai_behavior/human_npc_reload/bounty_kingpin_crew) + return SUBTREE_RETURN_FINISH_PLANNING + var/obj/item/gun/carried = bounty_kingpin_carried_gun(pawn) + if(!carried) + return + controller.set_blackboard_key(BB_HUMAN_NPC_DRAW_ITEM, carried) + controller.queue_behavior(/datum/ai_behavior/human_npc_draw, BB_HUMAN_NPC_DRAW_ITEM) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * The human NPC base's reload (a spare from a pocket, belt or bag into the free hand, the gun clicked + * with it, racked, the empty dropped), with the crew's own line as it starts. A tube or cylinder is + * fed what it takes before it is racked, as a person feeds shells one by one. + */ +/datum/ai_behavior/human_npc_reload/bounty_kingpin_crew + give_up_after = BOUNTY_KINGPIN_RELOAD_GIVE_UP + +// Its own start instead of the base's: the same checks and give-up time, with the kingpin's or the goon's line +/datum/ai_behavior/human_npc_reload/bounty_kingpin_crew/setup(datum/ai_controller/controller) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/ballistic/gun = human_npc_held_gun(npc) + if(!istype(gun)) + return FALSE + controller.set_blackboard_key(BB_HUMAN_NPC_RELOAD_UNTIL, world.time + give_up_after) + if(istype(npc, /mob/living/carbon/human/npc/bounty_criminal/kingpin)) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = npc + kingpin.kingpin_reload_tell() + else + npc.visible_message(span_notice("[npc] reloads."), vision_distance = COMBAT_MESSAGE_RANGE) + return TRUE + +/datum/ai_behavior/human_npc_reload/bounty_kingpin_crew/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/ballistic/gun = human_npc_held_gun(npc) + // Shell by shell into a tube with room left, while there's time to rack it after + if(istype(gun) && gun.internal_magazine && gun.magazine && world.time < controller.blackboard[BB_HUMAN_NPC_RELOAD_UNTIL] - 3 SECONDS) + if(!human_npc_spent_ammo_in_hand(npc, gun) && human_npc_live_rounds(gun.magazine) < gun.magazine.max_ammo) + var/obj/item/ammo = human_npc_find_ammo(npc, gun) + if(ammo) + if(!npc.is_holding(ammo)) + if(human_npc_take_in_hand(npc, ammo)) + return AI_BEHAVIOR_DELAY + else if(human_npc_ready_item(npc, ammo)) + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + return ..() // ========================================================================= // HIS CREW: THE ROOM AND THE SHOOTOUT @@ -972,7 +1094,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * shootout, and the job he holds for a crew. Owned by the kingpin and deleted with * him: it lives as long as he does, posting or none. Holds its mobs by weakref. Processes on * SSfastprocess: at ease it keeps the room lively and watches for known hunters; in a shootout it - * moves the goons and fires their guns and his. + * picks targets, moves the goons and pulls the triggers of the real guns in their hands, and his. */ /datum/bounty_kingpin_crew /// Weakref to the kingpin @@ -981,6 +1103,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/turf/crew_seat /// The goon posts he sat down with, list(list(turf, BOUNTY_GOON_WEAPON_*), ...): an empty one is filled again when he goes on the board var/list/crew_posts = list() + /// Goons on their way to a post (human NPCs are built a body at a time): post turf -> /datum/human_npc_order + var/list/crew_pending = list() + /// Build new goons at once instead of queueing them (the unit tests) + var/crew_immediate = FALSE /// The job he holds for a crew (a BOUNTY_KINGPIN_JOB mission, not yet taken), or null var/datum/mission/crew_job /// Weakref of a ship he offered crew_job to -> world.time the offer lapses @@ -991,7 +1117,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/crew_facing = NORTH /// Where bystanders and leaving goons go: the lounge's refuge mark, out of the room and away from the lift var/turf/crew_refuge - /// Weakrefs to the goons (/mob/living/basic/bounty_kingpin_goon) + /// Weakrefs to the goons (/mob/living/carbon/human/npc/bounty_kingpin_goon) var/list/crew_goons = list() /// Weakrefs to everyone the crew is fighting: whoever confronted him, attacked any of them, or came with them var/list/crew_hunters = list() @@ -1032,20 +1158,20 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/crew_next_calm_at = 0 var/crew_next_home_walk = 0 var/crew_next_table_warning = 0 - /// Shots the crew has fired (the unit tests read it) + /// Trigger pulls the crew has made (the unit tests read it) var/crew_shots = 0 -/datum/bounty_kingpin_crew/New(mob/living/basic/bounty_criminal/kingpin/kingpin, turf/seat) +/datum/bounty_kingpin_crew/New(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin, turf/seat, immediate = FALSE) . = ..() kingpin_ref = WEAKREF(kingpin) crew_seat = seat || get_turf(kingpin) + crew_immediate = immediate var/obj/structure/chair/sofa = locate(/obj/structure/chair) in crew_seat if(sofa) crew_facing = sofa.dir GLOB.bounty_kingpin_crews += src - RegisterSignal(kingpin, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_member_attacked)) - RegisterSignal(kingpin, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_member_shot)) - RegisterSignals(kingpin, list(COMSIG_BOUNTY_CRIMINAL_DOWNED, COMSIG_LIVING_DEATH, SIGNAL_ADDTRAIT(TRAIT_BOUNTY_SURRENDERED)), PROC_REF(on_boss_fell)) + watch_member(kingpin) + RegisterSignals(kingpin, list(COMSIG_BOUNTY_CRIMINAL_DOWNED, COMSIG_LIVING_DEATH, COMSIG_HUMAN_NPC_SURRENDERED), PROC_REF(on_boss_fell)) RegisterSignal(kingpin, COMSIG_BOUNTY_CRIMINAL_RESTRAINED, PROC_REF(on_boss_restrained)) crew_next_idle_at = world.time + rand(BOUNTY_GOON_IDLE_MIN, BOUNTY_GOON_IDLE_MAX) crew_next_boss_idle_at = world.time + rand(BOUNTY_KINGPIN_IDLE_MIN, BOUNTY_KINGPIN_IDLE_MAX) @@ -1054,14 +1180,22 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /datum/bounty_kingpin_crew/Destroy() STOP_PROCESSING(SSfastprocess, src) GLOB.bounty_kingpin_crews -= src + // Goons still on their way never arrive + for(var/post in crew_pending) + qdel(crew_pending[post]) + crew_pending.Cut() // The job he held goes with him drop_job() restore_room(TRUE) - // Whoever is still standing leaves; bodies fade on their own - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons()) + // Whoever is still alive walks out, for real, as soon as they can; bodies are the corpse sweep's + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons()) goon.goon_crew_ref = null - if(goon.stat != DEAD) - goon.goon_leave(exit_turf(goon)) + goon.goon_cancel_windup() + if(goon.stat == DEAD) + continue + bounty_kingpin_set_armed(goon, FALSE) + bounty_kingpin_holster_guns(goon) + goon_walk_out(goon) crew_goons.Cut() crew_hunters.Cut() crew_hostile_ships.Cut() @@ -1080,29 +1214,43 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) crew_barkeep_ref = null return ..() +/// Listens to `member` (him or a goon) for attacks, shots from things that aren't people, and any damage at all +/datum/bounty_kingpin_crew/proc/watch_member(mob/living/member) + RegisterSignal(member, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_member_attacked)) + RegisterSignal(member, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_member_shot)) + RegisterSignal(member, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_member_damaged)) + RegisterSignals(member, list(COMSIG_LIVING_ADJUST_BRUTE_DAMAGE, COMSIG_LIVING_ADJUST_BURN_DAMAGE), PROC_REF(on_member_adjusted)) + // ===== WHO IS WHO ===== /// The kingpin, if he still exists /datum/bounty_kingpin_crew/proc/kingpin() - RETURN_TYPE(/mob/living/basic/bounty_criminal/kingpin) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() + RETURN_TYPE(/mob/living/carbon/human/npc/bounty_criminal/kingpin) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() return QDELETED(kingpin) ? null : kingpin /// The goons that still exist, dead or alive /datum/bounty_kingpin_crew/proc/goons() . = list() for(var/datum/weakref/ref as anything in crew_goons.Copy()) - var/mob/living/basic/bounty_kingpin_goon/goon = ref?.resolve() + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = ref?.resolve() if(QDELETED(goon)) crew_goons -= ref continue . += goon -/// The goons still in the fight: alive, not running, not leaving +/// The goons still in the fight: not dead, in crit or cuffed (human_npc_out_of_fight()), not running, not leaving /datum/bounty_kingpin_crew/proc/standing_goons() . = list() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons()) - if(goon.stat != DEAD && !goon.goon_fleeing && !goon.goon_leaving) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons()) + if(!goon.goon_fleeing && isnull(goon.npc_exit) && !human_npc_out_of_fight(goon)) + . += goon + +/// The goons still at their posts, whatever state they are in: alive, not running, not leaving +/datum/bounty_kingpin_crew/proc/present_goons() + . = list() + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons()) + if(goon.stat != DEAD && !goon.goon_fleeing && isnull(goon.npc_exit)) . += goon /// His crew is down: no goon still in the fight @@ -1115,7 +1263,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return FALSE if(thing == kingpin()) return TRUE - var/mob/living/basic/bounty_kingpin_goon/goon = thing + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = thing return istype(goon) && goon.goon_crew_ref?.resolve() == src /// Whether the crew is fighting `who` @@ -1135,7 +1283,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /datum/bounty_kingpin_crew/proc/add_hunter(mob/living/who) if(!isliving(who) || QDELETED(who) || is_member(who)) return FALSE - if(istype(who, /mob/living/basic/bounty_criminal) || istype(who, /mob/living/basic/bounty_companion)) + if(istype(who, /mob/living/carbon/human/npc/bounty_criminal) || istype(who, /mob/living/carbon/human/npc/bounty_companion)) return FALSE var/datum/weakref/ref = WEAKREF(who) if(ref in crew_hunters) @@ -1233,18 +1381,44 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // ===== THE GOONS ===== -/// A goon with `weapon` at `post`, sitting if there is a chair there, facing the talk spot -/datum/bounty_kingpin_crew/proc/spawn_goon(turf/post, weapon = BOUNTY_GOON_WEAPON_PISTOL) +/** + * A goon with `weapon` (BOUNTY_GOON_WEAPON_*) for `post`, sitting if there is a chair there, facing + * the talk spot. A real human, so it goes through the human NPC spawn queue: `immediate` builds it now + * and returns it, else it is queued (one post at a time) and joins the crew when it is built, and this + * returns null. + */ +/datum/bounty_kingpin_crew/proc/spawn_goon(turf/post, weapon = BOUNTY_GOON_WEAPON_PISTOL, immediate = TRUE) if(!post) return null - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() var/datum/npc_spawn_context/context = new(kingpin || post, null, "ordinary", source_turf = post) - var/mob/living/basic/bounty_kingpin_goon/goon = spawn_npc_in_zone(/mob/living/basic/bounty_kingpin_goon, post, context, FALSE) + var/goon_type = bounty_kingpin_goon_type(weapon) + if(!immediate) + if(crew_pending[post]) + return null + crew_pending[post] = spawn_human_npc(goon_type, post, CALLBACK(src, PROC_REF(goon_arrived), post, weapon), context = context) + return null + var/datum/human_npc_order/order = spawn_human_npc(goon_type, post, context = context, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = order?.spawned_ref?.resolve() + qdel(order) + if(QDELETED(goon)) + return null + goon_joined(goon, post, weapon) + return goon + +/// A queued goon for `post` has been built: he takes his post +/datum/bounty_kingpin_crew/proc/goon_arrived(turf/post, weapon, mob/living/carbon/human/npc/bounty_kingpin_goon/goon) + crew_pending -= post + if(QDELETED(goon)) + return + goon_joined(goon, post, weapon) + +/// `goon` takes `post` with `weapon`: in its chair, or facing the talk spot, one of the crew. A fight on already: he draws. +/datum/bounty_kingpin_crew/proc/goon_joined(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, turf/post, weapon) goon.goon_weapon = weapon goon.goon_post = post - goon.goon_rounds = bounty_kingpin_goon_mag(weapon) if(crew_seat && get_dist(post, crew_seat) <= 3) - goon.name = "bodyguard" + goon.fully_replace_character_name(goon.real_name, "bodyguard") var/obj/structure/chair/chair = locate(/obj/structure/chair) in post if(chair && !chair.has_buckled_mobs() && chair.buckle_mob(goon, force = TRUE)) goon.goon_chair_ref = WEAKREF(chair) @@ -1258,15 +1432,14 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) goon.goon_post_dir = facing || SOUTH goon.setDir(goon.goon_post_dir) add_goon(goon) - context.finalize() - return goon + if(is_fighting()) + goon_draw(goon) /// Makes `goon` one of the crew -/datum/bounty_kingpin_crew/proc/add_goon(mob/living/basic/bounty_kingpin_goon/goon) +/datum/bounty_kingpin_crew/proc/add_goon(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) goon.goon_crew_ref = WEAKREF(src) crew_goons |= WEAKREF(goon) - RegisterSignal(goon, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_member_attacked)) - RegisterSignal(goon, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_member_shot)) + watch_member(goon) /// Where hunters stand to talk to him: two tiles out from the sofa, across the table /datum/bounty_kingpin_crew/proc/talk_spot() @@ -1275,12 +1448,23 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/turf/front = get_step(crew_seat, crew_facing) return front ? (get_step(front, crew_facing) || front) : crew_seat +/// `goon` walks out of the lounge for real (the human NPC base's npc_walk_out()): gone at the exit, or once nobody can see him +/datum/bounty_kingpin_crew/proc/goon_walk_out(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, turf/exit) + exit ||= exit_turf(goon) + if(!exit || goon.stat == DEAD) + return FALSE + goon.goon_cancel_windup() + goon.goon_target_ref = null + if(istype(goon.buckled, /obj/structure/chair)) + goon.buckled.unbuckle_mob(goon) + return goon.npc_walk_out(exit) + // ===== THE ROOM ===== /** * Finds the room around the sofa: the coffee table in front of it and the tables at the goons' posts * (breakable while he is here, and put back if missing), the barkeep who can see the - * sofa, and the refuge. + * sofa, and the refuge. Reads the posts, not the goons: they may still be on their way. */ /datum/bounty_kingpin_crew/proc/find_room() if(!crew_seat) @@ -1293,9 +1477,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(spot) coffee_spots += spot var/list/table_spots = coffee_spots.Copy() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons()) + for(var/list/post as anything in crew_posts) + var/turf/post_turf = post[1] for(var/direction in GLOB.cardinals) - var/turf/spot = get_step(goon.goon_post, direction) + var/turf/spot = get_step(post_turf, direction) if(spot && !(spot in table_spots)) table_spots += spot for(var/turf/spot as anything in table_spots) @@ -1377,11 +1562,14 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) else make_table_breakable(table) -/// Somewhere out of the lounge for `mover` to walk to: the refuge mark, never the hangar lift (M5) +/// Somewhere out of the lounge for `mover` to walk to: the refuge mark, never the hangar lift (M5). With no refuge (a test room): straight on out from the sofa past them. /datum/bounty_kingpin_crew/proc/exit_turf(atom/mover) if(crew_refuge) return crew_refuge - return null + var/turf/here = get_turf(mover) + if(!crew_seat || !here || here == crew_seat || here.z != crew_seat.z) + return null + return get_ranged_target_turf_direct(crew_seat, here, BOUNTY_KINGPIN_SIGHT + BOUNTY_GOON_LEASH) // ===== HIS BUSINESS ===== @@ -1426,7 +1614,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) qdel(job) crew_job_retry_at = world.time + BOUNTY_KINGPIN_JOB_RETRY return null - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin) job.author = kingpin.kingpin_job_name() job.update_text() @@ -1494,20 +1682,21 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * He goes on the board with his crew at ease: every post he sat down with whose goon is dead or gone - * (fled, walked out) gets a new goon. Nothing mid-fight. Returns the goons it placed. + * (fled, walked out) gets a new goon, through the human NPC spawn queue (at once in the tests). + * Nothing mid-fight. Returns the goons it placed at once. */ /datum/bounty_kingpin_crew/proc/restaff() . = list() if(crew_state != BOUNTY_KINGPIN_CALM) return var/list/manned = list() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in present_goons()) manned |= goon.goon_post for(var/list/post as anything in crew_posts) var/turf/spot = post[1] - if(!spot || (spot in manned)) + if(!spot || (spot in manned) || crew_pending[spot]) continue - var/mob/living/basic/bounty_kingpin_goon/goon = spawn_goon(spot, post[2]) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = spawn_goon(spot, post[2], crew_immediate) if(goon) manned |= spot . += goon @@ -1521,9 +1710,9 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(!length(people)) return if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK))) - if((attack_flags & ATTACKER_SHOVING) && istype(source, /mob/living/basic/bounty_kingpin_goon)) - var/mob/living/basic/bounty_kingpin_goon/goon = source - INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_say), "shoved") + if((attack_flags & ATTACKER_SHOVING) && istype(source, /mob/living/carbon/human/npc/bounty_kingpin_goon)) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = source + INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_kingpin_goon, goon_say), "shoved") return member_attacked(source, people) @@ -1540,6 +1729,18 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(length(people)) member_attacked(source, people) +/// Him or a goon took brute or burn through tg's apply_damage (a hit to a limb, a bullet, a blade) +/datum/bounty_kingpin_crew/proc/on_member_damaged(mob/living/source, damage, damagetype, def_zone, blocked) + SIGNAL_HANDLER + if(damage > 0 && (damagetype == BRUTE || damagetype == BURN)) + member_hurt(source) + +/// Him or a goon took brute or burn straight to the body (fire, a blast, a chemical) +/datum/bounty_kingpin_crew/proc/on_member_adjusted(mob/living/source, damagetype, amount, forced) + SIGNAL_HANDLER + if(amount > 0 && !forced) + member_hurt(source) + /// A hunter fired: at his crew, it's part of the fight (their stray shots take no outpost strike) /datum/bounty_kingpin_crew/proc/on_hunter_fired(mob/living/source, obj/projectile/shot, atom/fired_from, atom/original) SIGNAL_HANDLER @@ -1556,11 +1757,11 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * `member` (him or a goon) was attacked by `people`: they are hunters now, a goon drawing or - * winding up fumbles, and if all was calm the shootout starts. Someone the crew won't fight (another - * criminal) sets nothing off (L6). Doesn't sleep. + * winding up fumbles, his aim is spoiled, and if all was calm the shootout starts. Someone the crew + * won't fight (another criminal) sets nothing off (L6). Doesn't sleep. */ /datum/bounty_kingpin_crew/proc/member_attacked(atom/member, list/people) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() var/mob/living/first for(var/mob/living/person as anything in people) if(QDELETED(person) || is_member(person)) @@ -1573,8 +1774,11 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) kingpin.kingpin_note_hit(person) if(!first) return - if(istype(member, /mob/living/basic/bounty_kingpin_goon)) + if(istype(member, /mob/living/carbon/human/npc/bounty_kingpin_goon)) goon_hit(member) + else if(member == kingpin) + // A hit while he aims: "[src] fumbles the shot." + kingpin.kingpin_fumble() if(crew_state == BOUNTY_KINGPIN_CALM) start_shootout(first, FALSE) @@ -1596,7 +1800,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) for(var/mob/living/person in range(BOUNTY_GOON_LEASH, member)) if(person.stat != CONSCIOUS || !(person.mind || person.client) || is_member(person)) continue - if(istype(person, /mob/living/basic/bounty_criminal) || istype(person, /mob/living/basic/bounty_companion)) + if(istype(person, /mob/living/carbon/human/npc/bounty_criminal) || istype(person, /mob/living/carbon/human/npc/bounty_companion)) continue if(SSbounty_kingpin.kingpin_mind_bound(person.mind) && !is_hunter(person)) continue @@ -1607,7 +1811,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(suspect) member_attacked(member, list(suspect)) -/// He fell (downed, gave up, died): his aim drops, and half the goons left run (once a fight) +/// He fell (in crit, gave up, died): his aim drops, and half the goons left run (once a fight) /datum/bounty_kingpin_crew/proc/on_boss_fell(datum/source) SIGNAL_HANDLER kingpin()?.kingpin_cancel_aim() @@ -1630,7 +1834,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/list/standing = sortTim(standing_goons(), GLOBAL_PROC_REF(cmp_bounty_kingpin_goon_health)) var/fleeing = floor(length(standing) * BOUNTY_GOON_FLEE_ON_BOSS / 100) for(var/i in 1 to fleeing) - var/mob/living/basic/bounty_kingpin_goon/goon = standing[i] + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = standing[i] goon_flee(goon) . += goon @@ -1638,27 +1842,32 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /proc/cmp_bounty_kingpin_goon_health(mob/living/first, mob/living/second) return first.health - second.health -/// `goon` runs for it: away from the nearest hunter, and gone after BOUNTY_GOON_FLEE_TIME -/datum/bounty_kingpin_crew/proc/goon_flee(mob/living/basic/bounty_kingpin_goon/goon) +/** + * `goon` runs for it (CB-16): his gun goes away, his line, and he walks out for real (npc_walk_out()), + * to the refuge, else away from the nearest hunter. Shots still land on the way; he is gone only on + * the exit tile or once nobody can see him. + */ +/datum/bounty_kingpin_crew/proc/goon_flee(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) if(goon.goon_fleeing || goon.stat == DEAD) return goon.goon_fleeing = TRUE goon.goon_cancel_windup() goon.goon_target_ref = null - INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_say), "flee") - var/mob/living/nearest - for(var/mob/living/hunter as anything in live_hunters()) - if(!nearest || get_dist(goon, hunter) < get_dist(goon, nearest)) - nearest = hunter - if(nearest && isturf(goon.loc)) - GLOB.move_manager.move_away(goon, nearest, BOUNTY_KINGPIN_SIGHT + BOUNTY_GOON_LEASH, BOUNTY_GOON_STEP, BOUNTY_GOON_FLEE_TIME, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) - else - goon.goon_leave(exit_turf(goon), BOUNTY_GOON_FLEE_TIME) - return - addtimer(CALLBACK(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_leave), null, 1), BOUNTY_GOON_FLEE_TIME, TIMER_DELETE_ME) + bounty_kingpin_set_armed(goon, FALSE) + bounty_kingpin_holster_guns(goon) + INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_kingpin_goon, goon_say), "flee") + var/turf/exit = crew_refuge + if(!exit) + var/mob/living/nearest + for(var/mob/living/hunter as anything in live_hunters()) + if(!nearest || get_dist(goon, hunter) < get_dist(goon, nearest)) + nearest = hunter + if(nearest && get_turf(nearest) != get_turf(goon)) + exit = get_ranged_target_turf_direct(goon, nearest, BOUNTY_KINGPIN_SIGHT + BOUNTY_GOON_LEASH, 180) + goon_walk_out(goon, exit) /// A goon was hit: drawing, he fumbles his first shot; winding up, he loses the burst or the blast -/datum/bounty_kingpin_crew/proc/goon_hit(mob/living/basic/bounty_kingpin_goon/goon) +/datum/bounty_kingpin_crew/proc/goon_hit(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) if(goon.stat != CONSCIOUS) return if(crew_state == BOUNTY_KINGPIN_DRAWING) @@ -1675,7 +1884,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /datum/bounty_kingpin_crew/proc/can_fight() if(kingpin()?.kingpin_able()) return TRUE - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) if(goon.stat == CONSCIOUS) return TRUE return FALSE @@ -1683,10 +1892,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * The shootout starts, set off by `instigator` ("We're here for you" when `confronted`, else an * attack): the telegraph. For BOUNTY_KINGPIN_TELEGRAPH nobody in the crew fires: the goons reach - * for their guns, the table goons flip their tables, he kicks the coffee table over and the - * barkeep shouts. The instigator and their shipmates nearby are hunters now. It - * puts nobody on the board. Nothing starts when the crew won't fight the instigator (L6) or nobody - * can shoot. Doesn't sleep. Returns TRUE if it started. + * for their guns (and draw them), the table goons flip their tables, he draws his and kicks the + * coffee table over and the barkeep shouts. The instigator and their shipmates nearby are hunters + * now. It puts nobody on the board. Nothing starts when the crew won't fight the instigator (L6) or + * nobody can shoot. Doesn't sleep. Returns TRUE if it started. */ /datum/bounty_kingpin_crew/proc/start_shootout(mob/living/instigator, confronted = FALSE) add_hunter(instigator) @@ -1698,38 +1907,41 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) crew_last_contact_at = world.time crew_lead = null crew_boss_fell = FALSE - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin?.stat == CONSCIOUS) kingpin.kingpin_cancel_aim() INVOKE_ASYNC(kingpin, TYPE_PROC_REF(/mob/living, bounty_say), confronted ? "confront" : "attacked", null, TRUE) crew_seat?.visible_message(span_userdanger("The goons reach for their guns!"), vision_distance = BOUNTY_KINGPIN_SIGHT) crew_armed = TRUE if(kingpin) - INVOKE_ASYNC(kingpin, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, body_dress)) kingpin.kingpin_next_aim_at = crew_draw_ends_at - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + bounty_kingpin_set_armed(kingpin, TRUE) + if(kingpin.kingpin_able()) + bounty_kingpin_draw_gun(kingpin) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) goon_draw(goon) kick_table() clear_room() log_game("BOUNTY: [key_name(instigator)] started a shootout with [kingpin || "the kingpin's crew"] at [AREACOORD(crew_seat)] ([confronted ? "a confrontation" : "an attack"])") return TRUE -/// `goon` reaches for his gun: the telegraph box, the sound, out of his chair, his table over -/datum/bounty_kingpin_crew/proc/goon_draw(mob/living/basic/bounty_kingpin_goon/goon) - goon.goon_arm(TRUE) +/// `goon` reaches for his gun: the telegraph box, the sound, out of his chair, the real gun into his hand, his table over +/datum/bounty_kingpin_crew/proc/goon_draw(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) goon.goon_cancel_windup() goon.goon_target_ref = null - goon.goon_rounds = bounty_kingpin_goon_mag(goon.goon_weapon) goon.goon_next_fire_at = crew_draw_ends_at + bounty_kingpin_set_armed(goon, TRUE) var/turf/spot = get_turf(goon) if(spot) new /obj/effect/temp_visual/telegraphing/bounty_kingpin(spot) playsound(goon, 'sound/items/weapons/gun/pistol/rack.ogg', 50, TRUE) if(goon.buckled) goon.buckled.unbuckle_mob(goon, force = TRUE) + if(goon.goon_able()) + bounty_kingpin_draw_gun(goon) goon_flip_table(goon) if(prob(40)) - INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_say), "draw") + INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_kingpin_goon, goon_say), "draw") /// Flips `table` toward `direction`: a table someone else knocked over the wrong way is set up first (H2) /datum/bounty_kingpin_crew/proc/flip_our_way(obj/structure/table/table, direction) @@ -1745,7 +1957,9 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return TRUE /// A goon at a table kicks it over for cover, away from himself. Never the coffee table: that's his. -/datum/bounty_kingpin_crew/proc/goon_flip_table(mob/living/basic/bounty_kingpin_goon/goon) +/datum/bounty_kingpin_crew/proc/goon_flip_table(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) + if(!goon.goon_able()) + return null for(var/direction in GLOB.cardinals) var/turf/spot = get_step(goon, direction) var/obj/structure/table/table = locate(/obj/structure/table) in spot @@ -1759,7 +1973,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /// He kicks the coffee table over, toward the hunters: his shots go over it, theirs hit it /datum/bounty_kingpin_crew/proc/kick_table() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(!kingpin?.kingpin_able()) return FALSE var/kicked = FALSE @@ -1795,7 +2009,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/list/goons = shuffle(standing_goons()) var/count = length(goons) for(var/i in 1 to count) - var/mob/living/basic/bounty_kingpin_goon/goon = goons[i] + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = goons[i] var/offset = count > 1 ? round(BOUNTY_KINGPIN_FIRST_SHOT_SPREAD * (i - 1) / (count - 1), 1) : 0 goon.goon_next_fire_at = max(crew_draw_ends_at + offset, goon.goon_fumbled_until) goon.goon_target_ref = null @@ -1806,26 +2020,28 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) crew_lead = null crew_armed = FALSE crew_engaged.Cut() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin) kingpin.kingpin_cancel_aim() - INVOKE_ASYNC(kingpin, TYPE_PROC_REF(/mob/living/basic/bounty_criminal, body_dress)) + bounty_kingpin_set_armed(kingpin, FALSE) + bounty_kingpin_holster_guns(kingpin) if(kingpin.kingpin_able()) INVOKE_ASYNC(kingpin, TYPE_PROC_REF(/mob/living, bounty_say), "stand_down", null, TRUE) - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in present_goons()) goon.goon_cancel_windup() goon.goon_target_ref = null - goon.goon_arm(FALSE) + bounty_kingpin_set_armed(goon, FALSE) + bounty_kingpin_holster_guns(goon) restore_room() log_game("BOUNTY: [kingpin || "the kingpin's crew"] stood down at [AREACOORD(crew_seat)]") /** * With him gone (padded or deleted) or carried off his leash, there is nobody left to guard: the - * goons still standing walk out. Cuffed or dead on his sofa, they stay with him (M2). Returns TRUE if - * they left. + * goons still standing walk out, for real. Cuffed, in crit or dead on his sofa, they stay with him + * (M2). Returns TRUE if they left. */ /datum/bounty_kingpin_crew/proc/dismiss_if_boss_gone() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin) var/turf/here = get_turf(kingpin) var/on_leash = here && crew_seat && here.z == crew_seat.z && get_dist(here, crew_seat) <= BOUNTY_GOON_LEASH @@ -1834,25 +2050,24 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/list/leaving = standing_goons() if(!length(leaving)) return FALSE - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in leaving) - goon.goon_leave(exit_turf(goon)) - return TRUE + . = FALSE + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in leaving) + bounty_kingpin_set_armed(goon, FALSE) + bounty_kingpin_holster_guns(goon) + if(goon_walk_out(goon)) + . = TRUE // ===== THE TICK ===== /datum/bounty_kingpin_crew/process(seconds_per_tick) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(!kingpin) return PROCESS_KILL - if(!kingpin.kingpin_posting()) - // With no posting to answer for him (L10): nowhere at all (nullspace) is gone for good - if(!get_turf(kingpin)) - log_game("BOUNTY: [kingpin] was cleared away from nowhere") - qdel(kingpin) - return PROCESS_KILL - // A body nobody can bring in (killed off the board, or its proof went up at a chasm's edge) fades - if(kingpin.stat == DEAD) - kingpin.kingpin_start_fade() + // With no posting to answer for him (L10): nowhere at all (nullspace) is gone for good + if(!kingpin.kingpin_posting() && !get_turf(kingpin)) + log_game("BOUNTY: [kingpin] was cleared away from nowhere") + qdel(kingpin) + return PROCESS_KILL // The pad is taking him, or a posting is clearing him away if(HAS_TRAIT(kingpin, TRAIT_BOUNTY_REMOVED)) return @@ -1871,10 +2086,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /datum/bounty_kingpin_crew/proc/fight_tick() var/list/hunters = live_hunters() var/contact = FALSE - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) if(goon_tick(goon, hunters)) contact = TRUE - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin && boss_tick(kingpin, hunters)) contact = TRUE if(contact) @@ -1896,7 +2111,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) crew_lead_at = world.time /// One goon's turn: pick a target, close in or shoot, or go looking. Returns TRUE if he sees a hunter. -/datum/bounty_kingpin_crew/proc/goon_tick(mob/living/basic/bounty_kingpin_goon/goon, list/hunters) +/datum/bounty_kingpin_crew/proc/goon_tick(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, list/hunters) if(goon.stat != CONSCIOUS) goon.goon_cancel_windup() return FALSE @@ -1921,10 +2136,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return length(visible) > 0 /// Whether `goon` is one of the BOUNTY_KINGPIN_MAX_ON_ONE goons nearest the lead, the ones who go looking -/datum/bounty_kingpin_crew/proc/goon_is_seeker(mob/living/basic/bounty_kingpin_goon/goon) +/datum/bounty_kingpin_crew/proc/goon_is_seeker(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) var/my_dist = get_dist(goon, crew_lead) var/closer = 0 - for(var/mob/living/basic/bounty_kingpin_goon/other as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/other as anything in standing_goons()) if(other == goon || !other.goon_able()) continue if(get_dist(other, crew_lead) < my_dist) @@ -1936,7 +2151,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * opening volley, and whenever a hunter is in cover, at most BOUNTY_KINGPIN_MAX_ON_ONE goons draw * on one hunter. */ -/datum/bounty_kingpin_crew/proc/goon_choose_target(mob/living/basic/bounty_kingpin_goon/goon, list/visible) +/datum/bounty_kingpin_crew/proc/goon_choose_target(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, list/visible) if(!length(visible)) return null var/opening = world.time < crew_draw_ends_at + BOUNTY_KINGPIN_FIRST_SHOT_SPREAD @@ -1955,22 +2170,25 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return best /// Whether enough other goons are drawing on `hunter` already that `goon` should pick someone else -/datum/bounty_kingpin_crew/proc/target_full(mob/living/hunter, mob/living/basic/bounty_kingpin_goon/goon, opening) +/datum/bounty_kingpin_crew/proc/target_full(mob/living/hunter, mob/living/carbon/human/npc/bounty_kingpin_goon/goon, opening) if(!opening && !in_cover(hunter)) return FALSE return goons_on(hunter, goon) >= BOUNTY_KINGPIN_MAX_ON_ONE /// How many goons other than `except` are drawing on `hunter` -/datum/bounty_kingpin_crew/proc/goons_on(mob/living/hunter, mob/living/basic/bounty_kingpin_goon/except) +/datum/bounty_kingpin_crew/proc/goons_on(mob/living/hunter, mob/living/carbon/human/npc/bounty_kingpin_goon/except) var/count = 0 - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) if(goon != except && goon.goon_target_ref?.resolve() == hunter) count++ return count -/// One step for `goon` toward `destination`, never past his leash from the sofa. Returns TRUE if he moved. -/datum/bounty_kingpin_crew/proc/goon_step_toward(mob/living/basic/bounty_kingpin_goon/goon, turf/destination) - if(!destination || !crew_seat || world.time < goon.goon_next_move_at || goon.buckled || !isturf(goon.loc)) +/** + * One real step for `goon` toward `destination`, never past his leash from the sofa, and never faster + * than his body walks (wounds, armour and stamina slow him as they slow anyone). Returns TRUE if he moved. + */ +/datum/bounty_kingpin_crew/proc/goon_step_toward(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, turf/destination) + if(!destination || !crew_seat || world.time < goon.goon_next_move_at || goon.buckled || !isturf(goon.loc) || goon.pulledby) return FALSE if(get_turf(goon) == destination) return FALSE @@ -1979,24 +2197,42 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return FALSE if(next.z != crew_seat.z || get_dist(next, crew_seat) > BOUNTY_GOON_LEASH) return FALSE - goon.goon_next_move_at = world.time + BOUNTY_GOON_STEP + goon.goon_next_move_at = world.time + max(BOUNTY_GOON_STEP, goon.cached_multiplicative_slowdown) return goon.Move(next, get_dir(goon, next)) +/// A sidestep for a clear line on `target`: an open tile next to `goon`, no further from them, from which nobody he isn't fighting is in the way +/datum/bounty_kingpin_crew/proc/goon_sidestep(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, atom/target) + var/turf/here = get_turf(goon) + var/turf/there = get_turf(target) + if(!here || !there) + return FALSE + var/list/spots = list() + for(var/direction in GLOB.alldirs) + var/turf/spot = get_step(here, direction) + if(!spot || spot == there || spot.is_blocked_turf(exclude_mobs = FALSE) || get_dist(spot, there) > get_dist(here, there)) + continue + if(line_blocked(spot, target)) + continue + spots += spot + if(!length(spots)) + return FALSE + return goon_step_toward(goon, pick(spots)) + /** - * `goon` tries to shoot `target`. Nobody fires while the crew is drawing. A pistol fires at once; an - * SMG spins up (BOUNTY_GOON_SMG_WINDUP) and a shotgun pumps (BOUNTY_GOON_SHOTGUN_WINDUP) first, and - * a hit in the meantime spoils it. An empty gun reloads. Returns TRUE if a shot or a wind-up started. + * `goon` tries to shoot `target`. Nobody fires while the crew is drawing, and only with a loaded gun + * in hand: a spent one is his AI's to reload. A pistol fires at once; an SMG spins up + * (BOUNTY_GOON_SMG_WINDUP) and a shotgun pumps (BOUNTY_GOON_SHOTGUN_WINDUP) first, and a hit in the + * meantime spoils it. Returns TRUE if a shot or a wind-up started. */ -/datum/bounty_kingpin_crew/proc/goon_try_attack(mob/living/basic/bounty_kingpin_goon/goon, mob/living/target) +/datum/bounty_kingpin_crew/proc/goon_try_attack(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, mob/living/target) if(crew_state != BOUNTY_KINGPIN_FIGHTING || !goon.goon_able() || !valid_target(target)) return FALSE if(goon.goon_windup_timer || world.time < goon.goon_next_fire_at || world.time < goon.goon_fumbled_until) return FALSE - if(goon.goon_rounds <= 0) - goon.goon_rounds = bounty_kingpin_goon_mag(goon.goon_weapon) - goon.goon_next_fire_at = world.time + bounty_kingpin_goon_reload(goon.goon_weapon) - goon.visible_message(span_notice("[goon] reloads."), vision_distance = COMBAT_MESSAGE_RANGE) - playsound(goon, 'sound/items/weapons/gun/general/magazine_insert_full.ogg', 40, TRUE) + if(!human_npc_loaded_gun(goon)) + return FALSE + if(line_blocked(goon, shot_target(target))) + goon_sidestep(goon, target) return FALSE switch(goon.goon_weapon) if(BOUNTY_GOON_WEAPON_SMG) @@ -2016,7 +2252,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /// The wind-up is done: the SMG's burst or the shotgun's blast, if he still can and still sees them /datum/bounty_kingpin_crew/proc/goon_finish_windup(datum/weakref/goon_ref, datum/weakref/target_ref) - var/mob/living/basic/bounty_kingpin_goon/goon = goon_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = goon_ref?.resolve() if(QDELETED(goon)) return goon.goon_windup_timer = null @@ -2027,27 +2263,70 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(get_dist(goon, target) <= BOUNTY_GOON_SHOTGUN_RANGE) goon_fire(goon, target) return - goon_fire(goon, target) - for(var/shot in 2 to BOUNTY_GOON_SMG_BURST) - addtimer(CALLBACK(src, PROC_REF(goon_burst_shot), goon_ref, target_ref), (shot - 1) * BOUNTY_GOON_SMG_BURST_GAP, TIMER_DELETE_ME) + if(!goon_fire(goon, target)) + return + for(var/pull in 2 to BOUNTY_GOON_SMG_BURST) + addtimer(CALLBACK(src, PROC_REF(goon_burst_shot), goon_ref, target_ref), (pull - 1) * BOUNTY_GOON_SMG_BURST_GAP, TIMER_DELETE_ME) -/// The rest of an SMG burst, one shot at a time +/// The rest of an SMG burst, a trigger pull at a time /datum/bounty_kingpin_crew/proc/goon_burst_shot(datum/weakref/goon_ref, datum/weakref/target_ref) - var/mob/living/basic/bounty_kingpin_goon/goon = goon_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = goon_ref?.resolve() var/mob/living/target = target_ref?.resolve() if(QDELETED(goon) || crew_state != BOUNTY_KINGPIN_FIGHTING || !goon.goon_able() || !valid_target(target) || !bounty_kingpin_clear_shot(goon, target)) return goon_fire(goon, target) -/// One round from `goon`'s gun at `target` (at the mech they drive, if they drive one), if one is left: every pellet of a shell -/datum/bounty_kingpin_crew/proc/goon_fire(mob/living/basic/bounty_kingpin_goon/goon, mob/living/target) - if(goon.goon_rounds <= 0) - return null - goon.goon_rounds-- - return bounty_kingpin_shoot(goon, shot_target(target), bounty_kingpin_goon_gun(goon.goon_weapon), src) +/** + * One pull of the trigger of the loaded gun in `goon`'s hand at `target` (at the mech they drive, if + * they drive one), unless someone he isn't fighting is in the line: then he steps aside instead. + * Returns TRUE if he pulled it. + */ +/datum/bounty_kingpin_crew/proc/goon_fire(mob/living/carbon/human/npc/bounty_kingpin_goon/goon, mob/living/target) + var/obj/item/gun/gun = human_npc_loaded_gun(goon) + if(!gun || QDELETED(target)) + return FALSE + var/atom/aim_at = shot_target(target) + if(line_blocked(goon, aim_at)) + goon_sidestep(goon, target) + return FALSE + return pull_trigger(goon, gun, aim_at) + +/** + * `shooter` pulls the trigger of `gun` (in their hand) at `aim_at`, the way a player does: the gun made + * the active hand, a body zone picked, and a click through the body's AI controller (ai_interact()), so + * the gun's own fire path runs (fire delay, burst, recoil, spread, casing, sound, wounds). No projectile + * is ever made here. Returns TRUE if the click was made. + */ +/datum/bounty_kingpin_crew/proc/pull_trigger(mob/living/carbon/human/shooter, obj/item/gun/gun, atom/aim_at) + var/datum/ai_controller/controller = shooter?.ai_controller + if(!controller || QDELETED(aim_at) || !human_npc_ready_item(shooter, gun)) + return FALSE + shooter.zone_selected = human_npc_pick_aim_zone() + INVOKE_ASYNC(controller, TYPE_PROC_REF(/datum/ai_controller, ai_interact), aim_at, TRUE) + crew_shots++ + return TRUE + +/** + * Whether someone the crew isn't fighting stands between `from` and `target`: a bystander, a guest, + * another criminal, or one of the crew itself. A shot from them would hit that person, so it isn't + * taken (CB-11, CB-13: no round passes through anyone). Shots go over anyone lying down. + */ +/datum/bounty_kingpin_crew/proc/line_blocked(atom/from, atom/target) + var/turf/start = get_turf(from) + var/turf/end = get_turf(target) + if(!start || !end || start == end) + return FALSE + for(var/turf/line_turf as anything in get_line(start, end)) + if(line_turf == start || line_turf == end) + continue + for(var/mob/living/other in line_turf) + if(other.stat == DEAD || other.body_position == LYING_DOWN || is_hunter(other)) + continue + return TRUE + return FALSE -/// The kingpin's turn: aim at whoever hurt him most, or reload. Never while drawing. Returns TRUE if he sees a hunter. -/datum/bounty_kingpin_crew/proc/boss_tick(mob/living/basic/bounty_criminal/kingpin/kingpin, list/hunters) +/// The kingpin's turn: aim at whoever hurt him most. Never while drawing; a spent revolver is his AI's to reload. Returns TRUE if he sees a hunter. +/datum/bounty_kingpin_crew/proc/boss_tick(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin, list/hunters) if(kingpin.stat != CONSCIOUS) kingpin.kingpin_cancel_aim() return FALSE @@ -2057,8 +2336,8 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return length(visible) > 0 if(crew_state != BOUNTY_KINGPIN_FIGHTING || kingpin.kingpin_aim_timer || world.time < kingpin.kingpin_next_aim_at) return length(visible) > 0 - if(kingpin.kingpin_rounds <= 0) - kingpin.kingpin_reload() + if(!human_npc_loaded_gun(kingpin)) + // Holstered (he gave up and thought better of it): his AI draws it again while the guns are out return length(visible) > 0 var/mob/living/target = kingpin.kingpin_choose_target(visible) if(target) @@ -2078,10 +2357,10 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) return if(watch_for_trouble()) return - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin && !kingpin.buckled && kingpin.kingpin_able() && !kingpin.kingpin_sit()) kingpin.kingpin_go_home() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) goon_calm_tick(goon) right_the_tables() prune_job_offers() @@ -2099,7 +2378,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * started. */ /datum/bounty_kingpin_crew/proc/watch_for_trouble() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() var/mob/living/puller = kingpin?.pulledby if(isliving(puller) && !is_member(puller)) member_attacked(kingpin, list(puller)) @@ -2132,8 +2411,8 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) var/obj/structure/table/table = ref?.resolve() if(QDELETED(table) || !table.is_flipped) continue - var/mob/living/basic/bounty_kingpin_goon/nearest - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/nearest + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons) if(goon.goon_able() && (!nearest || get_dist(goon, table) < get_dist(nearest, table))) nearest = goon if(!nearest) @@ -2141,13 +2420,13 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) table.unflip_table() nearest.visible_message(span_notice("[nearest] sets [table] back on its legs."), vision_distance = COMBAT_MESSAGE_RANGE) righted = TRUE - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(righted && world.time >= crew_next_table_warning && kingpin?.kingpin_can_talk()) crew_next_table_warning = world.time + BOUNTY_KINGPIN_TABLE_WARN_GAP INVOKE_ASYNC(kingpin, TYPE_PROC_REF(/mob/living, bounty_say), "furniture", null, TRUE) /// A goon at ease: back to his post and into his chair, and watching whoever is closest -/datum/bounty_kingpin_crew/proc/goon_calm_tick(mob/living/basic/bounty_kingpin_goon/goon) +/datum/bounty_kingpin_crew/proc/goon_calm_tick(mob/living/carbon/human/npc/bounty_kingpin_goon/goon) if(!goon.goon_able()) return if(goon.goon_post && get_turf(goon) != goon.goon_post) @@ -2173,7 +2452,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /// Someone came up to the table: a goon warns him, he says hello. Each visitor once per BOUNTY_KINGPIN_GREET_GAP. /datum/bounty_kingpin_crew/proc/greet_visitors() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(!kingpin?.kingpin_can_talk() || !crew_seat) return for(var/mob/living/carbon/human/visitor in range(3, crew_seat)) @@ -2186,8 +2465,8 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) crew_greeted[ref] = world.time var/list/goons = standing_goons() if(length(goons)) - var/mob/living/basic/bounty_kingpin_goon/goon = pick(goons) - INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_say), "company") + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = pick(goons) + INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_kingpin_goon, goon_say), "company") var/context = "greet" if(is_hunter(visitor)) context = "greet_hostile" @@ -2199,14 +2478,14 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /// One goon does something: a quiet word, a smoke, a drink, the cards /datum/bounty_kingpin_crew/proc/goon_idle() var/list/at_ease = list() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in standing_goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in standing_goons()) if(goon.goon_able()) at_ease += goon if(!length(at_ease)) return - var/mob/living/basic/bounty_kingpin_goon/goon = pick(at_ease) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = pick(at_ease) if(prob(40)) - INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/basic/bounty_kingpin_goon, goon_say), "idle", TRUE) + INVOKE_ASYNC(goon, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_kingpin_goon, goon_say), "idle", TRUE) return var/context = "actions" var/turf/spot = get_turf(goon) @@ -2225,7 +2504,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /// He says something to nobody in particular, or puffs his cigar /datum/bounty_kingpin_crew/proc/boss_idle() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(!kingpin?.kingpin_can_talk()) return if(prob(50)) @@ -2247,58 +2526,11 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) duration = BOUNTY_KINGPIN_TELEGRAPH /** - * # A shot from the kingpin's crew - * - * The real round of the gun that fires it (bounty_kingpin_shoot()). It passes through anyone the - * crew isn't fighting (bystanders, the crew itself, those who work for him): only a hunter it - * reaches can be hit. - */ -/obj/projectile/bullet/bounty_kingpin - name = "bullet" - /// Weakref to the crew whose shot it is (/datum/bounty_kingpin_crew) - var/datum/weakref/kingpin_crew_ref - -/obj/projectile/bullet/bounty_kingpin/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) - if(isliving(target) && !direct_target) - var/datum/bounty_kingpin_crew/crew = kingpin_crew_ref?.resolve() - if(!crew?.is_hunter(target)) - return FALSE - return ..() - -/** - * `shooter` fires one round of `weapon` (a /datum/bounty_real_weapon) at `target` for `crew`: every - * pellet of a shell, each with the real round's numbers and spread. Returns the first projectile. - */ -/proc/bounty_kingpin_shoot(mob/living/shooter, atom/target, datum/bounty_real_weapon/weapon, datum/bounty_kingpin_crew/crew) - var/turf/start = get_turf(shooter) - if(!start || !target || !weapon) - return null - var/obj/projectile/bullet/bounty_kingpin/first - for(var/pellet in 1 to weapon.pellets) - var/obj/projectile/bullet/bounty_kingpin/bullet = new(start) - weapon.load(bullet) - bullet.kingpin_crew_ref = crew ? WEAKREF(crew) : null - bullet.starting = start - bullet.firer = shooter - bullet.fired_from = shooter - bullet.yo = target.y - start.y - bullet.xo = target.x - start.x - bullet.original = target - bullet.aim_projectile(target, shooter, null, weapon.shot_spread()) - if(QDELETED(bullet)) - continue - first ||= bullet - INVOKE_ASYNC(bullet, TYPE_PROC_REF(/obj/projectile, fire)) - if(weapon.fire_sound) - playsound(shooter, weapon.fire_sound, 60, TRUE) - if(crew) - crew.crew_shots++ - return first - -/** - * Whether a shot from `from` would reach `target`: in range, on the same level, and nothing in the - * way that stops a bullet (walls, windows, closed doors, machines; tables only when flipped toward - * the shot). People in the way don't count: the crew's shots pass through anyone but a hunter. + * Whether `from` has a line of sight and fire on `target` for the crew's choice of who to shoot: in + * range, on the same level, and nothing in the way that stops a bullet (walls, windows, closed doors, + * machines; tables only when flipped toward the shot, as tg's flipped tables let shots from behind + * through). People in the way are the trigger's business (line_blocked()): a goon doesn't fire + * through anyone he isn't fighting. */ /proc/bounty_kingpin_clear_shot(atom/from, atom/target, range = BOUNTY_KINGPIN_SIGHT) var/turf/start = get_turf(from) @@ -2340,7 +2572,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * # The kingpin's posting * - * P5's posting with his rules: "wanted dead or alive" (alive 100% however he got there, dead 80%), + * P5's posting with his rules: "wanted dead or alive" (alive 100% however he is brought in, dead 80%), * a clock that stops during a shootout, no relisting (he is only ever on his sofa), and no place * under the board's live-criminal cap. It never spawns him: it adopts the seated kingpin wherever he * is (bounty_kingpin_post()). Nobody who works for him (SSbounty_kingpin's bindings) can hunt him or @@ -2351,11 +2583,11 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /// Every crew has been told he is wanted: once, when it went up (L7) var/kingpin_announced = FALSE -// Wanted dead or alive: restrained, stunned or downed is alive and pays in full; dead pays 80% -/datum/criminal_bounty/kingpin/board_share_for(state, worst_state) +// Wanted dead or alive (CB-28), on his condition at the pad: alive pays in full, cuffed, stunned or in crit alike; dead pays 80%. Free is refused. +/datum/criminal_bounty/kingpin/board_share_for(state) if(!bounty_state_share(state)) return 0 - if(state == BOUNTY_STATE_DEAD || worst_state == BOUNTY_STATE_DEAD) + if(state == BOUNTY_STATE_DEAD) return BOUNTY_KINGPIN_PAY_DEAD return BOUNTY_KINGPIN_PAY_ALIVE @@ -2369,7 +2601,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) // The clock stops while his crew is fighting; a hunt doesn't stop it /datum/criminal_bounty/kingpin/board_clock_held(now = world.time) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = criminal() if(!kingpin) return !board_proof() && !board_relisting return istype(kingpin) && !!kingpin.kingpin_crew?.is_fighting() @@ -2427,7 +2659,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) if(reason == BOUNTY_CLOSE_CLAIMED) SSbounty_kingpin.kingpin_set_gone("claimed") else - var/mob/living/basic/bounty_criminal/kingpin/kingpin = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = criminal() if(kingpin_stays(kingpin)) kingpin_let_go(kingpin) . = ..() @@ -2435,13 +2667,13 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) SSbounty_kingpin.kingpin_resolved() /// Whether `kingpin` stays when it closes unclaimed: alive, with his crew, in his lounge (within his goons' leash of the sofa) -/datum/criminal_bounty/kingpin/proc/kingpin_stays(mob/living/basic/bounty_criminal/kingpin/kingpin) +/datum/criminal_bounty/kingpin/proc/kingpin_stays(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) if(!istype(kingpin) || QDELETED(kingpin) || kingpin.stat == DEAD || !kingpin.kingpin_crew || HAS_TRAIT(kingpin, TRAIT_BOUNTY_REMOVED)) return FALSE return kingpin.kingpin_on_leash() /// Lets go of `kingpin`, who stays in his lounge: no longer its criminal, on no posting, and back to business -/datum/criminal_bounty/kingpin/proc/kingpin_let_go(mob/living/basic/bounty_criminal/kingpin/kingpin) +/datum/criminal_bounty/kingpin/proc/kingpin_let_go(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) board_detach_criminal() kingpin.posting_ref = null REMOVE_TRAIT(kingpin, TRAIT_MISSION_FIELD_MOB, BOUNTY_TRAIT) @@ -2468,24 +2700,30 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) /** * The kingpin in `record`'s face at `seat` (on its sofa), on no posting, with a goon at each of * `posts` (list(list(turf, BOUNTY_GOON_WEAPON_*), ...)), and his crew knowing the room. Leashed to - * the trader outpost the seat is in, if it is in one. Returns him. + * the trader outpost the seat is in, if it is in one. He is built at once (one body); his goons come + * through the human NPC spawn queue a body at a time, unless `immediate_goons` (the unit tests). + * Returns him. */ -/proc/bounty_kingpin_setup(turf/seat, list/posts, datum/bounty_record/record) +/proc/bounty_kingpin_setup(turf/seat, list/posts, datum/bounty_record/record, immediate_goons = FALSE) if(!seat) return null var/datum/npc_spawn_context/context = new(seat, null, "boss") - var/mob/living/basic/bounty_criminal/kingpin/kingpin = spawn_npc_in_zone(/mob/living/basic/bounty_criminal/kingpin, seat, context, FALSE) - kingpin.kingpin_crew = new /datum/bounty_kingpin_crew(kingpin, seat) + var/datum/human_npc_order/order = spawn_human_npc(/mob/living/carbon/human/npc/bounty_criminal/kingpin, seat, look = record, name = record?.name, context = context, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = order?.spawned_ref?.resolve() + qdel(order) + if(QDELETED(kingpin)) + return null + kingpin.kingpin_crew = new /datum/bounty_kingpin_crew(kingpin, seat, immediate_goons) kingpin.body_setup(record, null) var/obj/structure/overmap/trader_outpost/outpost = get_trader_outpost_for_turf(seat) if(outpost) kingpin.body_set_site(outpost) kingpin.kingpin_sit() - context.finalize() for(var/list/post as anything in posts) kingpin.kingpin_crew.crew_posts += list(post.Copy()) - kingpin.kingpin_crew.spawn_goon(post[1], post[2]) kingpin.kingpin_crew.find_room() + for(var/list/post as anything in posts) + kingpin.kingpin_crew.spawn_goon(post[1], post[2], immediate_goons) return kingpin /// A new record for a kingpin: Most Wanted, his own archetype, a boss's alias and crime, a base value from his band @@ -2542,7 +2780,7 @@ GLOBAL_LIST_EMPTY(bounty_kingpin_marks) * SSbounty_kingpin.kingpin_post() is the way in (the clock and the admin panel). Doesn't sleep. * Returns the posting, or null. */ -/proc/bounty_kingpin_post(mob/living/basic/bounty_criminal/kingpin/kingpin) +/proc/bounty_kingpin_post(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) if(!istype(kingpin) || QDELETED(kingpin) || HAS_TRAIT(kingpin, TRAIT_BOUNTY_REMOVED) || kingpin.kingpin_posting() || bounty_kingpin_open_posting()) return null var/obj/structure/overmap/trader_outpost/outpost = kingpin.kingpin_crew?.crew_outpost() @@ -2605,7 +2843,7 @@ SUBSYSTEM_DEF(bounty_kingpin) /// Whether it seats and posts him on its own. Off in tests, which seat and post their own. var/kingpin_auto_post = TRUE #endif - /// Weakref to the round's kingpin (/mob/living/basic/bounty_criminal/kingpin), once seated + /// Weakref to the round's kingpin (/mob/living/carbon/human/npc/bounty_criminal/kingpin), once seated var/datum/weakref/kingpin_ref /// He died, was deleted or was claimed at a pad: nobody seats or posts him again this round var/kingpin_gone = FALSE @@ -2631,12 +2869,12 @@ SUBSYSTEM_DEF(bounty_kingpin) /// This round's kingpin, if he is seated and his mob still exists (dead or alive) /datum/controller/subsystem/bounty_kingpin/proc/kingpin() - RETURN_TYPE(/mob/living/basic/bounty_criminal/kingpin) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() + RETURN_TYPE(/mob/living/carbon/human/npc/bounty_criminal/kingpin) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_ref?.resolve() return QDELETED(kingpin) ? null : kingpin /// Makes `kingpin` this round's kingpin -/datum/controller/subsystem/bounty_kingpin/proc/kingpin_track(mob/living/basic/bounty_criminal/kingpin/kingpin) +/datum/controller/subsystem/bounty_kingpin/proc/kingpin_track(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) kingpin_ref = (istype(kingpin) && !QDELETED(kingpin)) ? WEAKREF(kingpin) : null /// Seats him at the black market once it has loaded, unless he sits there already or is gone for the round. Returns him, or null. @@ -2656,7 +2894,7 @@ SUBSYSTEM_DEF(bounty_kingpin) /datum/controller/subsystem/bounty_kingpin/proc/kingpin_seat(obj/structure/overmap/trader_outpost/outpost) if(kingpin_gone) return null - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(kingpin) return kingpin for(var/datum/bounty_kingpin_crew/crew as anything in GLOB.bounty_kingpin_crews) @@ -2711,7 +2949,7 @@ SUBSYSTEM_DEF(bounty_kingpin) /datum/controller/subsystem/bounty_kingpin/proc/kingpin_post() if(kingpin_gone) return null - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin() if(!kingpin || kingpin.stat == DEAD) return null var/datum/criminal_bounty/kingpin/posting = bounty_kingpin_post(kingpin) @@ -2720,11 +2958,11 @@ SUBSYSTEM_DEF(bounty_kingpin) return posting /// `kingpin` died: he is gone for the round. A posting already up holds his body for the dead share; none goes up for it. -/datum/controller/subsystem/bounty_kingpin/proc/kingpin_died(mob/living/basic/bounty_criminal/kingpin/kingpin) +/datum/controller/subsystem/bounty_kingpin/proc/kingpin_died(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) kingpin_set_gone(kingpin?.kingpin_posting() ? "died on the board" : "died off the board, nothing to claim") /// `kingpin`'s mob is being deleted: he is gone for the round -/datum/controller/subsystem/bounty_kingpin/proc/kingpin_removed(mob/living/basic/bounty_criminal/kingpin/kingpin) +/datum/controller/subsystem/bounty_kingpin/proc/kingpin_removed(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) if(kingpin_ref && kingpin_ref == kingpin?.weak_reference) kingpin_ref = null kingpin_set_gone("deleted") @@ -2942,5 +3180,5 @@ SUBSYSTEM_DEF(bounty_kingpin) return "post the kingpin bounty on [posting.record?.name || "nobody"] at [posting.board_site_name || "the black market"]" /// His lounge faction stays neutral; his explicit boss encounter is still profiled. -/mob/living/basic/bounty_criminal/kingpin/is_npc_zone_exempt() +/mob/living/carbon/human/npc/bounty_criminal/kingpin/is_npc_zone_exempt() return npc_zone_exempt || npc_spawn_context?.exempt || client || mind diff --git a/voidcrew/modules/bounties/bounty_lair.dm b/voidcrew/modules/bounties/bounty_lair.dm index d8173490d96..0cb5f3338b1 100644 --- a/voidcrew/modules/bounties/bounty_lair.dm +++ b/voidcrew/modules/bounties/bounty_lair.dm @@ -22,9 +22,10 @@ * rooms a crew clears stay cleared: the zone_mobs markers delete themselves after spawning and are never stamped * again. On its first load it indexes its own footprint once: it spawns the boss and the gatekeepers on their * landmarks, finds its gate poddoors (id BOUNTY_LAIR_GATE_ID), and keeps the boss, the gatekeepers and the goons to - * the rooms they started in. When the last gatekeeper dies, the gate opens. When the last boss dies (the mech, then - * the don who climbs out of it), the trophy drops where he fell, unless the boss's own death dropped it through - * bounty_lair_drop_trophy(). + * the rooms they started in. Gatekeepers that are human NPCs are queued (spawn_human_npc(), a body or two a tick); + * until each order has built its body, it counts as a gatekeeper standing, so the gate never opens on an empty back + * office. When the last gatekeeper dies, the gate opens. When the last boss dies (the mech, then the don who climbs + * out of it), the trophy drops where he fell, unless the boss's own death dropped it through bounty_lair_drop_trophy(). * * ## The lair clock (SSbounty_lairs) * @@ -88,8 +89,8 @@ close(BOUNTY_CLOSE_LAIR_GONE) return FALSE -/// Dead, and only dead: the trophy pays in full, anything else pays nothing -/datum/criminal_bounty/kill_only/board_share_for(state, worst_state) +/// Dead, and only dead: the trophy pays in full, anything else pays nothing (a boss brought in cuffed pays nothing: his trophy pays) +/datum/criminal_bounty/kill_only/board_share_for(state) return state == BOUNTY_STATE_DEAD ? 100 : 0 /// The pad takes its trophy only: the one it dropped, or any trophy bound to it, lying on the pad's own tile @@ -274,12 +275,33 @@ return FALSE return TRUE +/// Whether `candidate` is a lair boss: a /mob/living/basic/bounty_lair_boss (the don's mech), or the don on foot (a person) +/proc/bounty_is_lair_boss(mob/living/candidate) + return istype(candidate, /mob/living/basic/bounty_lair_boss) || istype(candidate, /mob/living/carbon/human/npc/bounty_mafia/don) + +/// The weakref to the posting a lair boss is wanted on, or null (not a lair boss, or an admin spawn) +/proc/bounty_lair_boss_posting_ref(mob/living/boss) + if(istype(boss, /mob/living/basic/bounty_lair_boss)) + var/mob/living/basic/bounty_lair_boss/machine = boss + return machine.posting_ref + if(istype(boss, /mob/living/carbon/human/npc/bounty_mafia/don)) + var/mob/living/carbon/human/npc/bounty_mafia/don/person = boss + return person.posting_ref + return null + +/// Makes `boss` wanted on the posting `posting_ref` points at. Does nothing to a mob that isn't a lair boss. +/proc/bounty_lair_set_boss_posting(mob/living/boss, datum/weakref/posting_ref) + if(istype(boss, /mob/living/basic/bounty_lair_boss)) + var/mob/living/basic/bounty_lair_boss/machine = boss + machine.posting_ref = posting_ref + else if(istype(boss, /mob/living/carbon/human/npc/bounty_mafia/don)) + var/mob/living/carbon/human/npc/bounty_mafia/don/person = boss + person.posting_ref = posting_ref + /// The open kill-only bounty a lair boss is wanted on (its posting_ref), or null /proc/bounty_kill_posting_of(mob/living/boss) - if(!istype(boss, /mob/living/basic/bounty_lair_boss)) - return null - var/mob/living/basic/bounty_lair_boss/lair_boss = boss - var/datum/criminal_bounty/kill_only/posting = lair_boss.posting_ref?.resolve() + var/datum/weakref/posting_ref = bounty_lair_boss_posting_ref(boss) + var/datum/criminal_bounty/kill_only/posting = posting_ref?.resolve() if(!istype(posting) || !posting.is_open()) return null return posting @@ -329,6 +351,11 @@ var/list/lair_gate_doors = list() /// Weakrefs to its living gatekeepers; the gate opens when the last one is gone var/list/lair_gatekeepers = list() + /// Gatekeepers still in the human NPC spawn queue: a key per order -> the order (/datum/human_npc_order). Each counts + /// as a gatekeeper standing until it has built its body (or failed to). + var/list/lair_pending_gatekeepers = list() + /// Makes the keys of lair_pending_gatekeepers + var/lair_spawn_serial = 0 /// Weakrefs to the bosses it is waiting on (the mech, then the don who climbs out of it) var/list/lair_bosses = list() /// The gate is open: it stays open, and the gatekeepers never come back @@ -357,6 +384,7 @@ /obj/structure/overmap/space_ruin/bounty_lair/Destroy() lair_unhook_mobs() + lair_cancel_pending_gatekeepers() lair_clear_timers() if(lair_release_timer) deltimer(lair_release_timer) @@ -550,9 +578,15 @@ log_mapping("BOUNTY LAIR: [name] has a boss landmark with no mob type at [AREACOORD(home)]") continue var/datum/npc_spawn_context/context = new(src, null, "boss") - var/mob/living/boss = spawn_npc_in_zone(boss_type, home, context, FALSE) + var/mob/living/boss + if(ispath(boss_type, /mob/living/carbon/human/npc)) + // One body, wanted before the fight can start: built now + var/datum/human_npc_order/order = spawn_human_npc(boss_type, home, context = context, immediate = TRUE) + boss = order?.spawned_ref?.resolve() + else + boss = spawn_npc_in_zone(boss_type, home, context, FALSE) + context.finalize() lair_adopt_boss(boss) - context.finalize() for(var/obj/effect/landmark/bounty_lair_gatekeeper/mark as anything in keeper_marks) if(QDELETED(mark)) @@ -566,17 +600,24 @@ log_mapping("BOUNTY LAIR: [name] has a gatekeeper landmark with no mob type at [AREACOORD(home)]") continue var/datum/npc_spawn_context/context = new(src, null, "boss") + if(ispath(keeper_type, /mob/living/carbon/human/npc)) + // People are queued, never a room of them in one tick: it holds the gate from now, and is watched once built + lair_spawn_serial++ + var/key = "[lair_spawn_serial]" + var/datum/human_npc_order/order = spawn_human_npc(keeper_type, home, CALLBACK(src, PROC_REF(lair_on_gatekeeper_built), key), context = context) + lair_pending_gatekeepers[key] = order + continue var/mob/living/keeper = spawn_npc_in_zone(keeper_type, home, context, FALSE) lair_add_gatekeeper(keeper) context.finalize() lair_leash_goons() - lair_goon_timer = addtimer(CALLBACK(src, PROC_REF(lair_leash_goons)), BOUNTY_LAIR_GOON_LEASH_DELAY, TIMER_STOPPABLE) + lair_goon_timer = addtimer(CALLBACK(src, PROC_REF(lair_settle)), BOUNTY_LAIR_GOON_LEASH_DELAY, TIMER_STOPPABLE) // Nobody left to hold the gate (none mapped, or it was opened before a reload): it stands open - if(lair_gate_open || !length(lair_gatekeepers)) + if(lair_gate_open || (!length(lair_gatekeepers) && !lair_pending_gatekeeper_count())) lair_open_gate(silent = TRUE) lair_link_done = TRUE - log_game("BOUNTY: [name] linked its interior: [length(lair_bosses)] boss(es), [length(lair_gatekeepers)] gatekeeper(s), [length(lair_gate_doors)] gate door(s)") + log_game("BOUNTY: [name] linked its interior: [length(lair_bosses)] boss(es), [length(lair_gatekeepers)] gatekeeper(s) and [lair_pending_gatekeeper_count()] on their way, [length(lair_gate_doors)] gate door(s)") /** * The interior is being given back while the bounty is open (nothing does this while it is mission-locked, so this is @@ -585,6 +626,7 @@ /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_on_unloading(datum/source) SIGNAL_HANDLER lair_unhook_mobs() + lair_cancel_pending_gatekeepers() lair_clear_timers() lair_gate_doors = list() lair_last_death_spot = null @@ -599,9 +641,7 @@ /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_adopt_boss(mob/living/boss) if(QDELETED(boss)) return FALSE - if(istype(boss, /mob/living/basic/bounty_lair_boss)) - var/mob/living/basic/bounty_lair_boss/lair_boss = boss - lair_boss.posting_ref = posting_ref + bounty_lair_set_boss_posting(boss, posting_ref) lair_bosses |= WEAKREF(boss) lair_watch(boss, PROC_REF(lair_on_boss_gone)) lair_leash(boss) @@ -637,7 +677,7 @@ if(!posting || lair_boss_killed) return var/standing = FALSE - for(var/mob/living/basic/bounty_lair_boss/boss as anything in lair_bosses_inside()) + for(var/mob/living/boss as anything in lair_bosses_inside()) if(boss.stat == DEAD) continue if(!boss.weak_reference || !(boss.weak_reference in lair_bosses)) @@ -673,13 +713,13 @@ log_game("BOUNTY: [name]'s boss is dead but there was no safe floor for its trophy; closing its bounty") posting.close(BOUNTY_CLOSE_LAIR_GONE) -/// Every lair boss standing in its footprint +/// Every lair boss standing in its footprint (bounty_is_lair_boss()) /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_bosses_inside() . = list() if(!footprint) return - for(var/mob/living/basic/bounty_lair_boss/boss in GLOB.mob_living_list) - if(!QDELETED(boss) && footprint.contains_turf(get_turf(boss))) + for(var/mob/living/boss as anything in GLOB.mob_living_list) + if(!QDELETED(boss) && bounty_is_lair_boss(boss) && footprint.contains_turf(get_turf(boss))) . += boss /// A trophy already bound to `posting` near where the boss fell (a boss's death that made its own) @@ -699,8 +739,8 @@ if(QDELETED(keeper)) return FALSE lair_gatekeepers |= WEAKREF(keeper) - // Troopers delete themselves on death and leave a corpse spawner: either signal counts, whichever comes first. - // An admin deleting one counts as gone too: a gate that never opens is worse. + // A person dies in place (COMSIG_LIVING_DEATH); a mob deleted on death, or by an admin, counts as gone too, whichever + // signal comes first: a gate that never opens is worse. lair_watch(keeper, PROC_REF(lair_on_gatekeeper_gone)) lair_leash(keeper) return TRUE @@ -713,7 +753,7 @@ lair_gatekeepers -= source.weak_reference lair_check_gate() -/// Opens the gate if no gatekeeper is left standing. Returns TRUE if it opened now. +/// Opens the gate if no gatekeeper is left standing, and none is still on its way. Returns TRUE if it opened now. /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_check_gate() if(lair_gate_open) return FALSE @@ -723,9 +763,48 @@ lair_gatekeepers -= ref continue return FALSE + if(lair_pending_gatekeeper_count()) + return FALSE lair_open_gate() return TRUE +/** + * How many queued gatekeepers are still to be built. An order that was cancelled, or built nothing, is dropped here; + * one that built its body has already moved to lair_gatekeepers (lair_on_gatekeeper_built()). + */ +/obj/structure/overmap/space_ruin/bounty_lair/proc/lair_pending_gatekeeper_count() + . = 0 + for(var/key in lair_pending_gatekeepers.Copy()) + var/datum/human_npc_order/order = lair_pending_gatekeepers[key] + if(QDELETED(order) || order.done) + lair_pending_gatekeepers -= key + continue + .++ + +/// A queued gatekeeper (`key`) was built: it holds the gate from now, kept to its room, unless the gate opened meanwhile +/obj/structure/overmap/space_ruin/bounty_lair/proc/lair_on_gatekeeper_built(key, mob/living/keeper) + if(QDELETED(src)) + return + var/known = !isnull(lair_pending_gatekeepers[key]) + lair_pending_gatekeepers -= key + if(QDELETED(keeper)) + lair_check_gate() + return + lair_leash(keeper) + if(!known || lair_gate_open || !lair_posting()) + return + lair_add_gatekeeper(keeper) + +/// Calls off every gatekeeper still in the spawn queue (the interior is going, or the lair is) +/obj/structure/overmap/space_ruin/bounty_lair/proc/lair_cancel_pending_gatekeepers() + var/list/orders = list() + for(var/key in lair_pending_gatekeepers) + orders += lair_pending_gatekeepers[key] + lair_pending_gatekeepers = list() + for(var/datum/human_npc_order/order as anything in orders) + if(!QDELETED(order) && !order.done) + qdel(order) + /// Opens its gate poddoors, the ones in its own footprint only, and tells everyone inside unless `silent` /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_open_gate(silent = FALSE) lair_gate_open = TRUE @@ -757,9 +836,15 @@ lair_mob.AddElement(/datum/element/bounty_lair_room_leash, home.type) return TRUE +/// Once every marker has spawned (the zone resolver retries for up to a minute, then the people are queued): the late goons +/// are kept to their rooms, and a gatekeeper order that never built a body stops holding the gate +/obj/structure/overmap/space_ruin/bounty_lair/proc/lair_settle() + lair_goon_timer = null + lair_leash_goons() + lair_check_gate() + /// Keeps every living goon of its kind in the room it stands in. Runs at the link, and again once every marker has spawned. /obj/structure/overmap/space_ruin/bounty_lair/proc/lair_leash_goons() - lair_goon_timer = null var/goon_type = lair_kind?.goon_type if(!goon_type || !footprint || !loaded) return 0 @@ -774,13 +859,16 @@ /** * Keeps a lair mob in the room (area) it started in: it won't step out of it on its own, so it fights from the * doorway and a fight never drags the next room in (balance/lairs.md 9.2). Moves it doesn't make itself are never - * stopped (pulled, thrown, or dead), and once it is out of its room it walks freely, so it can never be stuck. + * stopped (pulled, carried or wheeled on what it is buckled to, thrown, shoved, or dead), and once it is out of its + * room it walks freely, so it can never be stuck. */ /datum/element/bounty_lair_room_leash element_flags = ELEMENT_BESPOKE argument_hash_start_idx = 2 /// The area type it is kept to. A type, not the area: a lair's areas are per-load instances, and no ref is held. var/home_area_type + /// REF() of each mob shoved -> the world.time of the shove: the push of a shove is someone else's move + var/list/shoved_at = list() /datum/element/bounty_lair_room_leash/Attach(datum/target, home_area_type) . = ..() @@ -788,14 +876,20 @@ return ELEMENT_INCOMPATIBLE src.home_area_type = home_area_type RegisterSignal(target, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(on_pre_move), override = TRUE) + RegisterSignal(target, COMSIG_LIVING_DISARM_HIT, PROC_REF(on_shoved), override = TRUE) /datum/element/bounty_lair_room_leash/Detach(datum/source) - UnregisterSignal(source, COMSIG_MOVABLE_PRE_MOVE) + UnregisterSignal(source, list(COMSIG_MOVABLE_PRE_MOVE, COMSIG_LIVING_DISARM_HIT)) + shoved_at -= REF(source) return ..() +/datum/element/bounty_lair_room_leash/proc/on_shoved(mob/living/source, mob/living/attacker, zone, obj/item/weapon) + SIGNAL_HANDLER + shoved_at[REF(source)] = world.time + /datum/element/bounty_lair_room_leash/proc/on_pre_move(mob/living/source, atom/new_location) SIGNAL_HANDLER - if(source.stat == DEAD || source.pulledby || source.throwing) + if(source.stat == DEAD || source.pulledby || source.buckled || source.throwing || shoved_at[REF(source)] == world.time) return NONE var/area/here = get_area(source) if(here?.type != home_area_type) @@ -916,7 +1010,7 @@ trophy_type = /obj/item/bounty_proof/trophy/mafia_don mugshot_icon = 'icons/mob/rideables/mecha.dmi' mugshot_state = "mauler" - goon_type = /mob/living/basic/trooper/russian/mafia + goon_type = /mob/living/carbon/human/npc/bounty_mafia gate_message = "The garage door grinds open." /** diff --git a/voidcrew/modules/bounties/bounty_lair_mafia.dm b/voidcrew/modules/bounties/bounty_lair_mafia.dm index 796e4eb45f9..822e2d3d63c 100644 --- a/voidcrew/modules/bounties/bounty_lair_mafia.dm +++ b/voidcrew/modules/bounties/bounty_lair_mafia.dm @@ -8,42 +8,54 @@ * declared in bounty_lair_types.dm; this file sets their values and behaviour. Its numbers are in the P12 * section of voidcrew/_DEFINES/bounty_lairs.dm, its lines in strings/bounty_lair_mafia.json. * + * The goons, the lieutenants and the don are real human NPCs (voidcrew/modules/human_npcs/, phase D of the human NPC + * migration): a person's body, crit, wounds, cuffs and death, dressed in their Club Volga clothes, holding real guns + * with real spare magazines, reloading them by hand. Their kit is locked to them (owner decision D3), so a corpse + * leaves no gun. Only the don's mech is a mob of its own: it is a machine (owner call OC-5). + * * How the club fights: - * - **Goons** (/mob/living/basic/trooper/russian/mafia: knife, /pistol, /smg) keep to the room (area) they - * spawned in: they never walk out of it, and come to its door to get a line. A room is alerted by what its - * own goons see or what hits them: the one who spots a hunter shouts, the room's first shots follow a + * - **Goons** (/mob/living/carbon/human/npc/bounty_mafia: a kitchen knife, /pistol a Stechkin, /smg an Uzi) keep to + * the room (area) they spawned in: they never walk out of it, and come to its door to get a line. A room is alerted + * by what its own goons see or what hits them: the one who spots a hunter shouts, the room's first shots follow a * second later and spread over another second, and gunmen kick a wood table over for cover. At most * BOUNTY_MOBSTER_MAX_ON_ONE goons of a room go after one hunter standing in a doorway, however many are shot at. - * Grabbed, a goon breaks free and fights its grabber; dragged or thrown out of its room it still fights, and - * only walks back once there's nobody to fight. Nobody in the club targets a hunter who is down. Polymorph and - * type changes are refused. They leave a corpse in club clothes and never a gun. - * - **Lieutenants** (/lieutenant, /lieutenant/tommy, /lieutenant/brute) are tougher goons that hold the - * arena gate. The base type takes whichever role its room doesn't have yet, so two plain gatekeeper - * landmarks give one of each. They die like any trooper: COMSIG_LIVING_DEATH, then deletion. - * - **The mech** (/mob/living/basic/bounty_lair_boss/mafia_mech) is a mob that looks like a Mauler, not a tg - * mecha and not /mob/living/basic/boss. Its health follows the hunters engaged (P4's posse rule), counting - * exosuit pilots and harmless shots such as ion bolts. Anyone who hurts it is fair game at any range: it closes - * in as far as its arena lets it and answers from there, so it can't be sniped from past its sight. Every - * ability winds up where everyone can see it: the rocket volley puts a marker under each hunter it sees, - * then fires one non-breaching rocket per marker; the LMG shows a red cone out to its target that stays until - * the burst ends. An EMP stalls it (the ability it cut off is ready again as the stall ends) rather than - * killing it. With nobody left to fight for BOUNTY_MECH_RESET_AFTER it resets. It never leaves its arena. - * - **The don** (/mob/living/basic/bounty_lair_boss/mafia_don) climbs out when the mech breaks, next to a - * burnt-out wreck he uses for cover, holding the mech's grudges. He can't be cuffed and fights to the death; - * his death drops the gold signet ring (/obj/item/bounty_proof/trophy/mafia_don) with the posting on it, which - * P10 pays on. + * Grabbed, a goon resists the tg way and fights its grabber; dragged, carried, shoved or thrown out of its room it + * still fights, and only walks back once there's nobody to fight. Someone starting to cuff a goon who can still + * move sees it step away. Nobody in the club picks a hunter who is down as a target (only the choice: a round in + * flight hits whoever is in the way). Polymorph and type changes are refused. + * - **Lieutenants** (/lieutenant, /lieutenant/tommy, /lieutenant/brute) are elite goons in real armour that hold the + * arena gate. The base type takes whichever role its room doesn't have yet, so two plain gatekeeper landmarks give + * one of each. They die in place like anyone: P10 opens the gate on COMSIG_LIVING_DEATH. Tommy fires a Thompson; + * Brute punches, and a landed punch now and then knocks the hunter down (a tg Knockdown). + * - **The mech** (/mob/living/basic/bounty_lair_boss/mafia_mech) is a mob that looks like a Mauler, not a tg mecha + * and not /mob/living/basic/boss. Its health follows the hunters engaged (P4's posse rule), counting exosuit pilots + * and harmless shots such as ion bolts. Anyone who hurts it is fair game at any range: it closes in as far as its + * arena lets it and answers from there, so it can't be sniped from past its sight. Every ability winds up where + * everyone can see it: the rocket volley puts a marker under each hunter it sees, then fires one rocket per marker, + * whose burst is tg's light blast on everyone standing there, whatever their state, and never explosion(); the LMG + * shows a red cone out to its target that stays until the burst ends, and its rounds hit whoever is in the way. An + * EMP stalls it (the ability it cut off is ready again as the stall ends) rather than killing it. With nobody left + * to fight for BOUNTY_MECH_RESET_AFTER it repairs itself. It never leaves its arena. + * - **The don** (/mob/living/carbon/human/npc/bounty_mafia/don) climbs out when the mech breaks, next to a burnt-out + * wreck he uses for cover while he reloads, holding the mech's grudges. He never gives up: someone starting to cuff + * him while he can still move hears about it and sees him step away; cuffed, he works at the cuffs. A cuffed don + * still pays nothing: the lair is kill-only, and only his ring pays. His death drops the gold signet ring + * (/obj/item/bounty_proof/trophy/mafia_don) with the posting on it, once, which P10 pays on. + * + * For P10: a gatekeeper's death is COMSIG_LIVING_DEATH (watch COMSIG_QDELETING too, for an admin delete). The mech + * hands its posting_ref to the don and sends COMSIG_BOUNTY_MAFIA_DON_EJECTED with him before its own death; the don's + * death drops the ring through P10's bounty_lair_drop_trophy() and sends COMSIG_BOUNTY_MAFIA_TROPHY_DROPPED with it. + * Everyone keeps to their room by bounty_mafia_leash_allows(), the same rule as P10's + * /datum/element/bounty_lair_room_leash, which a lair also puts on its mobs. * - * For P10: a gatekeeper's death is COMSIG_LIVING_DEATH (the lieutenant is deleted right after, so watch - * COMSIG_QDELETING too for an admin delete). The mech hands its posting_ref to the don and sends - * COMSIG_BOUNTY_MAFIA_DON_EJECTED with him before its own death; the don's death drops the ring through P10's - * bounty_lair_drop_trophy() and sends COMSIG_BOUNTY_MAFIA_TROPHY_DROPPED with it. Everyone keeps to their room by - * bounty_mafia_leash_allows(), the same rule as P10's /datum/element/bounty_lair_room_leash. + * The club is built a body at a time (spawn_human_npc(), queued): its markers queue their goons, the lair queues the + * lieutenants, and only the don, one body the fight needs at once, is built on the spot. * * Nothing here scans turfs on a tick: targets come from SSmobs' list of players on the level, and every * telegraph and timer is tracked and cleaned up. */ -/// Trait source for a club mob standing still while it winds up, fires or reels +/// Trait source for the mech standing still while it winds up, fires or reels #define BOUNTY_MAFIA_BUSY_TRAIT "bounty_mafia_busy" // ===== SHARED ===== @@ -59,7 +71,10 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return FALSE return !isnull(candidate.mind) || !isnull(candidate.client) -/// Whether a club mob may shoot or hit `target` now: a hunter on their feet, never anyone already down (spec 12.3 C4) +/** + * Whether a club mob may pick `target` to go after: a hunter on their feet, never anyone already down (spec 12.3 C4). + * Only the choice of target: what is fired hits whoever it hits (defaults 1.11, CB-11). + */ /proc/bounty_mafia_may_hurt(mob/living/target) if(!bounty_mafia_is_hunter(target) || target.stat != CONSCIOUS) return FALSE @@ -89,9 +104,15 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) var/list/lines = GLOB.string_cache?[BOUNTY_MAFIA_STRINGS_FILE]?[context] return length(lines) ? pick(lines) : null -/// `speaker` says a line for `context`, off the caller's stack (safe from signal handlers). TRUE if there was one. +/** + * `speaker` says a line for `context`, off the caller's stack (safe from signal handlers). TRUE if there was one. + * A person speaks on their feet or whispers in soft crit (tg's say() does the whisper), never deeper down. + */ /proc/bounty_mafia_say(mob/living/speaker, context) - if(QDELETED(speaker) || speaker.stat != CONSCIOUS) + if(QDELETED(speaker)) + return FALSE + var/can_talk = ishuman(speaker) ? human_npc_can_talk(speaker) : (speaker.stat == CONSCIOUS) + if(!can_talk) return FALSE var/line = bounty_mafia_line(context) if(!line) @@ -99,46 +120,15 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) INVOKE_ASYNC(speaker, TYPE_PROC_REF(/atom/movable, say), line) return TRUE -/** - * Fires one round of `weapon` (a /datum/bounty_real_weapon) from `shooter` at `target` (a mob or a turf) as - * `projectile_type`: every pellet of a shell, each with the real round's numbers and spread, plus `spread` degrees - * more (full width) if given, and no farther than `max_range` tiles if given. The club's own faction is never hit by - * it. Returns the first projectile still flying, or null. - */ -/proc/bounty_mafia_shoot(mob/living/shooter, projectile_type, atom/target, datum/bounty_real_weapon/weapon, spread = 0, max_range) - var/turf/start = get_turf(shooter) - var/turf/aim = get_turf(target) - if(!start || !aim || start == aim || !weapon) - return null - var/obj/projectile/first - for(var/pellet in 1 to weapon.pellets) - var/obj/projectile/bullet = new projectile_type(start) - weapon.load(bullet) - if(max_range) - bullet.range = max_range - bullet.maximum_range = max_range - bullet.firer = shooter - bullet.fired_from = shooter - bullet.ignored_factions = shooter.faction?.Copy() - bullet.spread = spread - bullet.aim_projectile(target, shooter, null, weapon.shot_spread()) - if(QDELETED(bullet)) - continue - bullet.fire() - if(!first && !QDELETED(bullet)) - first = bullet - if(weapon.fire_sound) - playsound(shooter, weapon.fire_sound, 60, TRUE) - return first - /** * Whether `mover`, kept to the room (area) `home`, may step onto `new_loc` on its own: anywhere in its room, never * out of it, so it fights from its doorway and a fight never drags the next room in (lairs.md 9.2). The same rule as * P10's /datum/element/bounty_lair_room_leash, which a lair also puts on its mobs at load, so the two always agree: - * moves it doesn't make itself (pulled, thrown, dead) are never stopped, and once out of its room it walks freely. + * moves it doesn't make itself (pulled, carried or wheeled on what it is buckled to, thrown, `shoved`, dead) are never + * stopped, and once out of its room it walks freely. */ -/proc/bounty_mafia_leash_allows(mob/living/mover, area/home, atom/new_loc) - if(!home || !new_loc || mover.stat == DEAD || mover.pulledby || mover.throwing) +/proc/bounty_mafia_leash_allows(mob/living/mover, area/home, atom/new_loc, shoved = FALSE) + if(!home || !new_loc || mover.stat == DEAD || mover.pulledby || mover.buckled || mover.throwing || shoved) return TRUE if(get_area(mover) != home) return TRUE @@ -172,14 +162,14 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) if(!at) return null var/static/list/crews = list( - "foyer" = list(/mob/living/basic/trooper/russian/mafia = 2, /mob/living/basic/trooper/russian/mafia/pistol = 1), - "cloakroom" = list(/mob/living/basic/trooper/russian/mafia/pistol = 1, /mob/living/basic/trooper/russian/mafia = 1, /mob/living/basic/trooper/russian/mafia/smg = 1), - "casino" = list(/mob/living/basic/trooper/russian/mafia/pistol = 2, /mob/living/basic/trooper/russian/mafia = 2, /mob/living/basic/trooper/russian/mafia/smg = 1), - "bar" = list(/mob/living/basic/trooper/russian/mafia/pistol = 2, /mob/living/basic/trooper/russian/mafia/smg = 1, /mob/living/basic/trooper/russian/mafia = 1), - "kitchen" = list(/mob/living/basic/trooper/russian/mafia = 2, /mob/living/basic/trooper/russian/mafia/pistol = 1), - "vip" = list(/mob/living/basic/trooper/russian/mafia/smg = 1, /mob/living/basic/trooper/russian/mafia/pistol = 2, /mob/living/basic/trooper/russian/mafia = 1), - "counting" = list(/mob/living/basic/trooper/russian/mafia/pistol = 1, /mob/living/basic/trooper/russian/mafia = 1, /mob/living/basic/trooper/russian/mafia/smg = 1), - "back_office" = list(/mob/living/basic/trooper/russian/mafia/pistol = 1), + "foyer" = list(/mob/living/carbon/human/npc/bounty_mafia = 2, /mob/living/carbon/human/npc/bounty_mafia/pistol = 1), + "cloakroom" = list(/mob/living/carbon/human/npc/bounty_mafia/pistol = 1, /mob/living/carbon/human/npc/bounty_mafia = 1, /mob/living/carbon/human/npc/bounty_mafia/smg = 1), + "casino" = list(/mob/living/carbon/human/npc/bounty_mafia/pistol = 2, /mob/living/carbon/human/npc/bounty_mafia = 2, /mob/living/carbon/human/npc/bounty_mafia/smg = 1), + "bar" = list(/mob/living/carbon/human/npc/bounty_mafia/pistol = 2, /mob/living/carbon/human/npc/bounty_mafia/smg = 1, /mob/living/carbon/human/npc/bounty_mafia = 1), + "kitchen" = list(/mob/living/carbon/human/npc/bounty_mafia = 2, /mob/living/carbon/human/npc/bounty_mafia/pistol = 1), + "vip" = list(/mob/living/carbon/human/npc/bounty_mafia/smg = 1, /mob/living/carbon/human/npc/bounty_mafia/pistol = 2, /mob/living/carbon/human/npc/bounty_mafia = 1), + "counting" = list(/mob/living/carbon/human/npc/bounty_mafia/pistol = 1, /mob/living/carbon/human/npc/bounty_mafia = 1, /mob/living/carbon/human/npc/bounty_mafia/smg = 1), + "back_office" = list(/mob/living/carbon/human/npc/bounty_mafia/pistol = 1), ) return crews[copytext(type_text, at + length(marker))] @@ -213,7 +203,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) var/room_key /// Its area var/datum/weakref/area_ref - /// Weakrefs to its goons (/mob/living/basic/trooper/russian/mafia) + /// Weakrefs to its goons (/mob/living/carbon/human/npc/bounty_mafia) var/list/members = list() /// Someone has seen or been hit by a hunter: the shout has gone up var/alerted = FALSE @@ -271,26 +261,26 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) break return room_exits -/datum/bounty_mafia_room/proc/join(mob/living/basic/trooper/russian/mafia/goon) +/datum/bounty_mafia_room/proc/join(mob/living/carbon/human/npc/bounty_mafia/goon) members |= WEAKREF(goon) -/datum/bounty_mafia_room/proc/leave(mob/living/basic/trooper/russian/mafia/goon) +/datum/bounty_mafia_room/proc/leave(mob/living/carbon/human/npc/bounty_mafia/goon) members -= WEAKREF(goon) if(!length(members)) qdel(src) -/// Its goons still standing +/// Its goons still alive /datum/bounty_mafia_room/proc/goons() . = list() for(var/datum/weakref/ref as anything in members) - var/mob/living/basic/trooper/russian/mafia/goon = ref.resolve() + var/mob/living/carbon/human/npc/bounty_mafia/goon = ref.resolve() if(!QDELETED(goon) && goon.stat != DEAD) . += goon /// How many of its goons, besides `except`, are going after `hunter` /datum/bounty_mafia_room/proc/attackers_on(mob/living/hunter, mob/living/except) . = 0 - for(var/mob/living/basic/trooper/russian/mafia/goon as anything in goons()) + for(var/mob/living/carbon/human/npc/bounty_mafia/goon as anything in goons()) if(goon != except && goon.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET] == hunter) .++ @@ -304,221 +294,28 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) * BOUNTY_MOBSTER_ALERT, the others spread over the next BOUNTY_MOBSTER_FIRST_SHOT_SPREAD. Returns TRUE if this * alerted it. */ -/datum/bounty_mafia_room/proc/raise_alarm(mob/living/basic/trooper/russian/mafia/spotter, mob/living/hunter) +/datum/bounty_mafia_room/proc/raise_alarm(mob/living/carbon/human/npc/bounty_mafia/spotter, mob/living/hunter) if(hunter) hunter_ref = WEAKREF(hunter) if(alerted) return FALSE alerted = TRUE - for(var/mob/living/basic/trooper/russian/mafia/goon as anything in goons()) + for(var/mob/living/carbon/human/npc/bounty_mafia/goon as anything in goons()) goon.mafia_on_alarm(spotter, goon == spotter ? 0 : rand(0, BOUNTY_MOBSTER_FIRST_SHOT_SPREAD), hunter) return TRUE -// ===== GUNS ===== - -/** - * A club mob's gun, simulated: no item, nothing to drop, but every round is the real gun's - * (`gun_type`, bounty_real_weapon()): its damage, wounds, armour penetration, spread and magazine. - * try_fire() spins it up first if it has a wind-up, then fires a burst of real projectiles, and - * reloads when the magazine is spent. A hit during the wind-up can spoil it (fumble()). Its timers - * check `serial`, so a cancelled shot does nothing. - */ -/datum/bounty_mafia_gun - /// What it's called in messages - var/gun_name = "pistol" - /// The real gun: the one in its owner's hand - var/gun_type = BOUNTY_MOBSTER_PISTOL_GUN - var/projectile_type = /obj/projectile/bullet/bounty_mafia - /// Shots in one burst, and the time between them. A pistol fires a quick string of shots each time. - var/burst = BOUNTY_MOBSTER_PISTOL_BURST - var/burst_delay = BOUNTY_MOBSTER_PISTOL_BURST_GAP - /// From the start of one burst (or wind-up) to the next - var/cooldown = BOUNTY_MOBSTER_PISTOL_COOLDOWN - var/reload_time = BOUNTY_MOBSTER_PISTOL_RELOAD - /// The spin-up before a burst, if any - var/windup = 0 - /// The wind-up comes once per magazine (the don raises his pistol before each string), not before every burst - var/windup_once_per_magazine = FALSE - var/windup_sound - var/reload_sound = 'sound/items/weapons/gun/pistol/mag_insert.ogg' - /// Dialogue context its owner may say on reloading, and the chance they do - var/reload_context = "goon_reload" - var/reload_bark_chance = 35 - /// A hit during the wind-up spoils it - var/fumbles = FALSE - /// The real gun's numbers (/datum/bounty_real_weapon) - var/datum/bounty_real_weapon/weapon - /// Rounds in a full magazine: the real gun's - var/magazine = 0 - /// Rounds left in the magazine - var/ammo - /// world.time it may start the next burst or wind-up - var/next_fire_at = 0 - /// world.time the reload is done - var/reloading_until = 0 - /// Spinning up now - var/winding = FALSE - /// Raised this magazine (windup_once_per_magazine) - var/raised = FALSE - /// Goes up with every burst or wind-up, and on a fumble, so stale timers do nothing - var/serial = 0 - /// Its owner (/mob/living) - var/datum/weakref/owner_ref - /// What it's aimed at - var/datum/weakref/aim_ref - -/datum/bounty_mafia_gun/New(mob/living/owner) - . = ..() - owner_ref = WEAKREF(owner) - weapon = bounty_real_weapon(gun_type) - magazine = max(1, weapon.magazine) - ammo = magazine - -/datum/bounty_mafia_gun/Destroy() - serial++ - owner_ref = null - aim_ref = null - weapon = null - return ..() - -/datum/bounty_mafia_gun/proc/owner() - var/mob/living/owner = owner_ref?.resolve() - return QDELETED(owner) ? null : owner - -/// Ready to start a burst or a wind-up -/datum/bounty_mafia_gun/proc/ready() - return !winding && world.time >= next_fire_at - -/datum/bounty_mafia_gun/proc/is_reloading() - return world.time < reloading_until - -/datum/bounty_mafia_gun/proc/owner_can_fire(mob/living/owner) - return owner && owner.stat == CONSCIOUS && isturf(owner.loc) && !HAS_TRAIT(owner, TRAIT_INCAPACITATED) - -/// Starts a burst at `target`, after the wind-up if it has one. Returns TRUE if it started. -/datum/bounty_mafia_gun/proc/try_fire(atom/target) - var/mob/living/owner = owner() - if(!owner_can_fire(owner) || !ready() || QDELETED(target)) - return FALSE - aim_ref = WEAKREF(target) - serial++ - owner.face_atom(target) - if(windup && !(windup_once_per_magazine && raised)) - winding = TRUE - next_fire_at = world.time + windup + cooldown - if(windup_sound) - playsound(owner, windup_sound, 50, TRUE) - owner.balloon_alert_to_viewers("aiming!") - addtimer(CALLBACK(src, PROC_REF(finish_windup), serial), windup, TIMER_DELETE_ME) - return TRUE - fire_burst() - return TRUE - -/// The wind-up under `fire_serial` is over: the burst goes, unless it was spoiled. Returns TRUE if it fired. -/datum/bounty_mafia_gun/proc/finish_windup(fire_serial) - if(fire_serial != serial || !winding) - return FALSE - winding = FALSE - raised = TRUE - if(!owner_can_fire(owner())) - return FALSE - fire_burst() - return TRUE - -/// A burst: as many of its shots as the magazine has rounds left, then a reload if it's spent -/datum/bounty_mafia_gun/proc/fire_burst() - var/shots = clamp(burst, 1, ammo) - ammo -= shots - next_fire_at = max(next_fire_at, world.time + cooldown) - var/fire_serial = serial - shoot_once(fire_serial) - for(var/i in 2 to shots) - addtimer(CALLBACK(src, PROC_REF(shoot_once), fire_serial), (i - 1) * burst_delay, TIMER_DELETE_ME) - if(ammo <= 0) - start_reload((shots - 1) * burst_delay) - -/// One shot of the burst under `fire_serial`. Never at someone who went down meanwhile. -/datum/bounty_mafia_gun/proc/shoot_once(fire_serial) - if(fire_serial != serial) - return null - var/mob/living/owner = owner() - var/atom/target = aim_ref?.resolve() - if(!owner_can_fire(owner) || QDELETED(target)) - return null - if(isliving(target) && !bounty_mafia_may_hurt(target)) - return null - owner.face_atom(target) - return bounty_mafia_shoot(owner, projectile_type, target, weapon) - -/// A fresh magazine, ready `delay` + reload_time from now -/datum/bounty_mafia_gun/proc/start_reload(delay = 0) - ammo = magazine - raised = FALSE - reloading_until = world.time + delay + reload_time - next_fire_at = max(next_fire_at, reloading_until) - var/mob/living/owner = owner() - if(!owner) - return - if(reload_sound) - playsound(owner, reload_sound, 40, TRUE) - if(reload_context && prob(reload_bark_chance)) - bounty_mafia_say(owner, reload_context) - -/// The wind-up is spoiled: nothing fires, and the next try waits `delay` -/datum/bounty_mafia_gun/proc/fumble(delay) - serial++ - winding = FALSE - next_fire_at = max(next_fire_at, world.time + delay) - -/datum/bounty_mafia_gun/smg - gun_name = "SMG" - gun_type = BOUNTY_MOBSTER_SMG_GUN - burst = BOUNTY_MOBSTER_SMG_BURST - burst_delay = BOUNTY_MOBSTER_SMG_BURST_GAP - cooldown = BOUNTY_MOBSTER_SMG_COOLDOWN - reload_time = BOUNTY_MOBSTER_SMG_RELOAD - windup = BOUNTY_MOBSTER_SMG_WINDUP - windup_sound = 'sound/items/weapons/gun/smg/smgrack.ogg' - reload_sound = 'sound/items/weapons/gun/general/magazine_insert_full.ogg' - fumbles = TRUE - -/datum/bounty_mafia_gun/tommy - gun_name = "Tommy gun" - gun_type = BOUNTY_LIEUTENANT_TOMMY_GUN - burst = BOUNTY_LIEUTENANT_TOMMY_BURST - burst_delay = BOUNTY_LIEUTENANT_TOMMY_BURST_GAP - cooldown = BOUNTY_LIEUTENANT_TOMMY_COOLDOWN - reload_time = BOUNTY_LIEUTENANT_TOMMY_RELOAD - windup = BOUNTY_LIEUTENANT_TOMMY_WINDUP - windup_sound = 'sound/items/weapons/gun/smg/smgrack.ogg' - reload_sound = 'sound/items/weapons/gun/general/magazine_insert_full.ogg' - -/datum/bounty_mafia_gun/don - gun_name = "gold pistol" - gun_type = BOUNTY_DON_GUN - cooldown = BOUNTY_DON_COOLDOWN - reload_time = BOUNTY_DON_RELOAD - windup = BOUNTY_DON_RAISE - windup_once_per_magazine = TRUE - windup_sound = 'sound/items/weapons/gun/pistol/rack_small.ogg' - reload_context = "don_reload" - reload_bark_chance = 50 - -// ===== PROJECTILES ===== - -/// A club bullet: the real round of the gun that fires it (bounty_mafia_shoot()) -/obj/projectile/bullet/bounty_mafia - name = "bullet" +// ===== THE MECH'S ROUNDS ===== +// The club's people fire the real guns in their hands; only the machine fires rounds of its own. /// The mech's machine gun: its real exosuit gun's rounds (BOUNTY_MECH_LMG_GUN), which barely scratch furniture -/obj/projectile/bullet/bounty_mafia/lmg +/obj/projectile/bullet/bounty_mafia_lmg name = "machine gun round" demolition_mod = BOUNTY_MECH_LMG_DEMOLITION /** * The mech's rocket: the cyberware buster's pattern, never tg's rocket and never explosion(). It flies through - * people to the marker it was fired at, and there does BOUNTY_MECH_ROCKET_DAMAGE brute and a knockdown to each - * hunter still standing on it, once a volley each. A player's exosuit in its way takes + * people to the marker it was fired at, and there it bursts: tg's light blast (ex_act(EXPLODE_LIGHT)) on everyone + * standing on it, whatever their state, once a volley each. A player's exosuit in its way takes * BOUNTY_MECH_ROCKET_EXOSUIT_DAMAGE, never the anti-structure multiplier. It bursts harmlessly on a wall, a pillar * or anything else in its way; it breaks tables, windows and doors only inside the club's garage. */ @@ -535,7 +332,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) projectile_phasing = PASSMOB /// The marker it flies to var/turf/rocket_target - /// REF() of each hunter this volley has hit -> TRUE, shared by the volley's rockets + /// REF() of each mob this volley has hit -> TRUE, shared by the volley's rockets var/list/rocket_volley /// The arena (/area), the only place it breaks things var/datum/weakref/rocket_area_ref @@ -590,7 +387,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return FALSE return get_area(spot) == arena -/// The bang, and nothing else: the fake explosion and its sound +/// The look and sound of the burst, and nothing else /obj/projectile/bullet/bounty_mafia_rocket/proc/rocket_burst(turf/spot) if(rocket_done || !spot) return FALSE @@ -599,53 +396,46 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) playsound(spot, 'sound/effects/explosion/explosion1.ogg', 60, TRUE) return TRUE -/// It reached its marker on `spot`: the bang, and the hit on each hunter standing there not yet hit this volley. Returns who it hit. +/** + * It reached its marker on `spot`: the burst, and tg's light blast on each mob standing there that this volley hasn't + * caught yet, whatever its state (CB-10: the blast is ex_act's, which spreads the damage, wounds, deafens and knocks + * down by the victim's bomb armour; no explosion(), so nothing is breached). Returns who it caught. + */ /obj/projectile/bullet/bounty_mafia_rocket/proc/rocket_detonate(turf/spot) . = list() if(rocket_done || !spot) return rocket_burst(spot) for(var/mob/living/victim in spot) - if(!bounty_mafia_may_hurt(victim)) + if(victim == firer) continue var/key = REF(victim) if(rocket_volley?[key]) continue if(rocket_volley) rocket_volley[key] = TRUE - var/blocked = victim.run_armor_check(BODY_ZONE_CHEST, BULLET, silent = TRUE) - victim.apply_damage(npc_zone_damage(damage, npc_zone_damage_factor, npc_zone_damage_scaled), BRUTE, BODY_ZONE_CHEST, blocked) - victim.Knockdown(BOUNTY_MECH_ROCKET_KNOCKDOWN) + victim.ex_act(EXPLODE_LIGHT, null, src) to_chat(victim, span_userdanger("A rocket bursts right at your feet!")) . += victim if(!QDELETED(src)) qdel(src) -// ===== GOONS ===== +// ===== THE CLUB'S PEOPLE ===== /** - * A club mobster. The base is the knife man; /pistol and /smg carry guns. Every blow and round is the real - * item's in its hand (`r_hand`, bounty_real_weapon()), at the club's own pace (lairs.md 3.1). + * A club mobster, a person. The base is the knife man; /pistol and /smg carry guns, /lieutenant holds the gate and + * /don runs the family. Each holds its real weapon from the start (they are on the clock in a mob club), with its + * spare magazines in its pockets, and fights with it through its hands (the AI's clicks). */ -/mob/living/basic/trooper/russian/mafia - name = "mobster" - desc = "A heavy in a tracksuit, working for the family that runs this club. He has a knife out." - maxHealth = BOUNTY_MOBSTER_KNIFE_HEALTH - health = BOUNTY_MOBSTER_KNIFE_HEALTH - speed = BOUNTY_MOBSTER_KNIFE_SPEED - melee_attack_cooldown = BOUNTY_MOBSTER_KNIFE_COOLDOWN +/mob/living/carbon/human/npc/bounty_mafia + npc_name = "mobster" + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia + npc_outfit = /datum/outfit/bounty_mafia_goon/armed + npc_target_stat = HUMAN_NPC_SPARES_DOWNED faction = list(FACTION_RUSSIAN, BOUNTY_MAFIA_FACTION) - sentience_type = SENTIENCE_BOSS - // They talk to the hunters, so they speak Common - initial_language_holder = /datum/language_holder/atom_basic - mob_spawner = /obj/effect/mob_spawn/corpse/human/bounty_mafia - corpse = /obj/effect/mob_spawn/corpse/human/bounty_mafia - r_hand = BOUNTY_MOBSTER_KNIFE - loot = null ai_controller = /datum/ai_controller/basic_controller/bounty_mafia - /// The gun it fights with (a /datum/bounty_mafia_gun type), or null for a knife - var/mafia_gun_type - var/datum/bounty_mafia_gun/mafia_gun + /// A line on examine: who they are, never how to fight them + var/mafia_examine_line = "He works for the family that runs this club." /// Its room: the area it spawned in, which it never walks out of var/datum/weakref/mafia_home_ref /// Where it spawned, to walk back to if it's ever pushed out of its room @@ -658,46 +448,95 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) var/mafia_next_bark_at = 0 /// Prefix of its dialogue contexts; lines it has none of come from "goon" var/mafia_voice = "goon" + /// It chats about the club while nobody is fighting (the don doesn't) + var/mafia_idle_chatter = TRUE + /// Percent chance it says something when it's hurt + var/mafia_hurt_bark_chance = 20 /// world.time it first found itself out of its room with nobody to fight, or 0 var/mafia_away_since = 0 /// It has kicked a table over for cover this fight var/mafia_took_cover = FALSE - -/mob/living/basic/trooper/russian/mafia/Initialize(mapload) + /// world.time someone last shoved it: that tick's push is someone else's move, which its room never holds back + var/mafia_shoved_at = -1 + + // ----- how it fires its gun (a volley: the aim, then the pulls, then a pause) ----- + /// The aim before a volley ("aiming!"), or 0 for none + var/mafia_windup = 0 + /// The aim comes once per magazine (the don raises his pistol before the first string), not before every volley + var/mafia_windup_once = FALSE + var/mafia_windup_sound + /// A hit during the aim spoils it + var/mafia_fumbles = FALSE + /// Pulls of the trigger in a volley, and the time between them + var/mafia_volley = BOUNTY_MOBSTER_PISTOL_VOLLEY + var/mafia_volley_gap = BOUNTY_MOBSTER_PISTOL_VOLLEY_GAP + /// From the end of one volley to the start of the next + var/mafia_cooldown = BOUNTY_MOBSTER_PISTOL_COOLDOWN + /// What it says as it reloads ("[voice]_[context]"), how often, and the sound of its hand going for a magazine + var/mafia_reload_context = "reload" + var/mafia_reload_chance = 35 + var/mafia_reload_sound = 'sound/items/weapons/gun/pistol/mag_insert.ogg' + /// world.time the aim under way is done, or 0 + var/mafia_aim_until = 0 + /// A hit spoiled the aim under way + var/mafia_aim_spoiled = FALSE + /// Raised this magazine (mafia_windup_once) + var/mafia_raised = FALSE + /// Pulls left in the volley under way + var/mafia_volley_left = 0 + /// world.time of its next pull in the volley, and of its next volley + var/mafia_next_pull_at = 0 + var/mafia_next_volley_at = 0 + +/mob/living/carbon/human/npc/bounty_mafia/Initialize(mapload, datum/human_npc_order/order) . = ..() - // Its blows are the real item's in its hand (bare fists without one) - bounty_real_weapon(r_hand).apply_melee(src) - if(mafia_gun_type) - mafia_gun = new mafia_gun_type(src) - mafia_home_turf = get_turf(src) - var/area/home = get_area(src) - if(home) - mafia_home_ref = WEAKREF(home) - mafia_room_key = REF(home) - var/datum/bounty_mafia_room/room = bounty_mafia_room_for(home) - room.join(src) + mafia_settle_home() AddElement(/datum/element/relay_attackers) - RegisterSignal(src, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(mafia_on_attacked)) - RegisterSignal(src, COMSIG_ATOM_BULLET_ACT, PROC_REF(mafia_on_bullet_act)) - RegisterSignal(src, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(mafia_on_pre_move)) - RegisterSignal(src, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(mafia_on_damaged)) - // A polymorph or a type change deletes it without a death: a free kill, and for a lieutenant a free gate - RegisterSignal(src, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(mafia_refuse_polymorph)) - RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(mafia_refuse_type_change)) + AddElement(/datum/element/bounty_mafia_body) -/mob/living/basic/trooper/russian/mafia/Destroy() +/mob/living/carbon/human/npc/bounty_mafia/Destroy() var/datum/bounty_mafia_room/room = mafia_room() room?.leave(src) - QDEL_NULL(mafia_gun) mafia_home_ref = null mafia_home_turf = null - // The shout's "!" (do_alert_animation) forgets itself on a timer that dies with us: let it go now, or it hard-deletes - update_on_z = null + // The shout's "!" (do_alert_animation) forgets itself on a timer that dies with us: let it go now + for(var/atom/movable/flick_visual/visual in update_on_z) + LAZYREMOVE(update_on_z, visual) return ..() -/mob/living/basic/trooper/russian/mafia/Life(seconds_per_tick = SSMOBS_DT, times_fired) +/// Takes the room it stands in as its home: its leash, its shout and its doorway cap +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_settle_home() + mafia_home_turf = get_turf(src) + var/area/home = get_area(src) + if(!home) + return + mafia_home_ref = WEAKREF(home) + mafia_room_key = REF(home) + bounty_mafia_room_for(home).join(src) + +// Club Volga's men: the face and hair of their look, always a man's build +/mob/living/carbon/human/npc/bounty_mafia/npc_apply_spawner_look(obj/effect/mob_spawn/spawner) + . = ..() + if(gender == MALE) + return + gender = MALE + physique = MALE + update_body(is_creating = TRUE) + +/mob/living/carbon/human/npc/bounty_mafia/examine(mob/user) + . = ..() + if(mafia_examine_line && stat != DEAD) + . += span_notice(mafia_examine_line) + +// OC-9: no deliberate executions are built this phase, so the club never executes anyone by accident +/mob/living/carbon/human/npc/bounty_mafia/hv_can_execute() + return !isnull(client) + +/mob/living/carbon/human/npc/bounty_mafia/Life(seconds_per_tick = SSMOBS_DT, times_fired) . = ..() - if(stat != CONSCIOUS || world.time < mafia_next_bark_at || !SPT_PROB(BOUNTY_MAFIA_IDLE_BARK_CHANCE, seconds_per_tick)) + if(!mafia_idle_chatter || stat != CONSCIOUS || npc_dormant()) + return + if(world.time < mafia_next_bark_at || !SPT_PROB(BOUNTY_MAFIA_IDLE_BARK_CHANCE, seconds_per_tick)) return var/datum/bounty_mafia_room/room = mafia_room() if(room?.alerted || !mafia_player_nearby()) @@ -705,7 +544,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) mafia_bark("idle") /// Whether a player is close enough to hear it chat -/mob/living/basic/trooper/russian/mafia/proc/mafia_player_nearby() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_player_nearby() var/turf/here = get_turf(src) if(!here || here.z > length(SSmobs.clients_by_zlevel)) return FALSE @@ -715,28 +554,112 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return FALSE /// Its room (/datum/bounty_mafia_room), or null -/mob/living/basic/trooper/russian/mafia/proc/mafia_room() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_room() return mafia_room_key ? GLOB.bounty_mafia_rooms[mafia_room_key] : null /// Says a line for "_" (or the plain goon one) unless it spoke recently or `force`. TRUE if it spoke. -/mob/living/basic/trooper/russian/mafia/proc/mafia_bark(context, force = FALSE) - if(stat != CONSCIOUS || (!force && world.time < mafia_next_bark_at)) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_bark(context, force = FALSE) + if(!human_npc_can_talk(src) || (!force && world.time < mafia_next_bark_at)) return FALSE if(!bounty_mafia_say(src, "[mafia_voice]_[context]") && (mafia_voice == "goon" || !bounty_mafia_say(src, "goon_[context]"))) return FALSE mafia_next_bark_at = world.time + BOUNTY_MAFIA_BARK_COOLDOWN return TRUE -/mob/living/basic/trooper/russian/mafia/proc/mafia_refuse_polymorph(datum/source, what_to_randomize) - SIGNAL_HANDLER - return STOP_WABBAJACK +/** + * The base reactions (human_npc_barks.dm) speak the club's lines: going down is a hurt line, breaking a grab is + * "Hands off." Everything else they react to (cuffs on and off, working at them, death) is left to tg's messages. + */ +/mob/living/carbon/human/npc/bounty_mafia/npc_bark(kind, force = FALSE) + switch(kind) + if(HUMAN_NPC_BARK_DOWNED) + return mafia_bark("hurt", force = TRUE) + if(HUMAN_NPC_BARK_PULLED_FREE) + return mafia_bark("shoved", force = TRUE) + return FALSE -/mob/living/basic/trooper/russian/mafia/proc/mafia_refuse_type_change(datum/source) - SIGNAL_HANDLER - return COMPONENT_BLOCK_MOB_CHANGE +// It broke a hunter's grab by resisting: the hunter feels it shove them off +/mob/living/carbon/human/npc/bounty_mafia/npc_check_pulled_free(atom/movable/holder, within = 0) + var/broke_free = npc_resisted_at && world.time <= npc_resisted_at + within + var/mob/living/resisted = npc_resisted_hold?.resolve() + . = ..() + if(!broke_free || !isliving(resisted) || (holder && resisted != holder)) + return + balloon_alert(resisted, "[p_they()] shove[p_s()] you off!") + +// ----- what happens to it (/datum/element/bounty_mafia_body relays these) ----- + +/** + * `attacker` (a player, or an exosuit's pilots) went for it: its room is alerted, and it turns on them if it has no + * one else, and only while the doorway cap has room (so shooting a goon held back never pulls it in). + */ +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_answer_attack(atom/attacker) + if(stat == DEAD || !attacker) + return + for(var/mob/living/hunter as anything in bounty_mafia_attackers(attacker)) + mafia_raise_alarm(hunter) + if(ai_controller && !ai_controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) && mafia_target_ok(hunter) && mafia_room_has_space(hunter)) + ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) + +/** + * A hunter has hold of it (a grab, a carry, the cuffs coming out): that's an attack. The room is alerted and the + * hunter is its target, doorway cap or not. Its AI resists the hold the tg way (escape_captivity). TRUE if it answered. + */ +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_answer_grab(mob/living/grabber) + if(stat == DEAD || !bounty_mafia_is_hunter(grabber) || faction_check_atom(grabber)) + return FALSE + mafia_raise_alarm(grabber) + if(ai_controller && ai_controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] != grabber && mafia_target_ok(grabber)) + ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, grabber) + return TRUE + +/// Whether it can still answer cuffs on its own feet: up, awake, free, and nobody holding it +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_can_answer_cuffs() + if(stat != CONSCIOUS || !isturf(loc) || buckled || pulledby || body_position != STANDING_UP) + return FALSE + return !HAS_TRAIT(src, TRAIT_INCAPACITATED) && !HAS_TRAIT(src, TRAIT_FLOORED) && !HAS_TRAIT(src, TRAIT_IMMOBILIZED) && !HAS_TRAIT(src, TRAIT_RESTRAINED) + +/** + * tg's attempt_to_cuff() has begun on it (COMSIG_CARBON_CUFF_ATTEMPTED, defaults 1.4, CB-15). The cuffer is its target, + * and if it can still move it says so and, a moment into the cuffer's four seconds, steps away: a real step, which + * breaks their do_after. Down, paralysed or held, it can't, and the cuffs go on. Never COMSIG_CARBON_CUFF_PREVENT. + */ +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_on_cuff_attempt(mob/living/user) + if(stat == DEAD || !isliving(user)) + return + mafia_answer_grab(user) + if(!mafia_can_answer_cuffs()) + return + mafia_bark("cuffed", force = TRUE) + addtimer(CALLBACK(src, PROC_REF(mafia_dodge_cuffs), WEAKREF(user)), BOUNTY_MAFIA_CUFF_REACTION, TIMER_DELETE_ME) + +/// One step away from `user_ref`'s cuffer, if it still can. TRUE if it stepped. +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_dodge_cuffs(datum/weakref/user_ref) + var/mob/living/user = user_ref?.resolve() + if(QDELETED(user) || !mafia_can_answer_cuffs() || !Adjacent(user)) + return FALSE + var/turf/here = get_turf(src) + var/away = get_dir(user, src) || pick(GLOB.cardinals) + for(var/direction in list(away, turn(away, 45), turn(away, -45), turn(away, 90), turn(away, -90))) + var/turf/spot = get_step(here, direction) + if(!spot || spot.is_blocked_turf()) + continue + // A real step: its room's leash and everything else on a move apply + if(Move(spot, direction) && get_turf(src) == spot) + return TRUE + return FALSE + +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_on_damaged(damage, damagetype) + if(damage <= 0 || stat != CONSCIOUS) + return + if(mafia_fumbles && mafia_aim_until && !mafia_aim_spoiled) + mafia_aim_spoiled = TRUE + visible_message(span_notice("[src] flinches and loses [p_their()] aim.")) + if(prob(mafia_hurt_bark_chance)) + mafia_bark("hurt") /// Its room was alerted by `spotter` over `hunter`; it may attack `delay` after the shout, and a gunman takes cover -/mob/living/basic/trooper/russian/mafia/proc/mafia_on_alarm(mob/living/basic/trooper/russian/mafia/spotter, delay = 0, mob/living/hunter) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_on_alarm(mob/living/carbon/human/npc/bounty_mafia/spotter, delay = 0, mob/living/hunter) mafia_ready_at = world.time + BOUNTY_MOBSTER_ALERT + delay if(spotter == src) do_alert_animation() @@ -748,8 +671,8 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) * A gunman kicks over a wood table beside it, on its side toward `hunter`, once a fight (lairs.md 3.1). The table's * top faces the hunter, so their shots hit it and the gunman's pass over it. Returns the table, or null. */ -/mob/living/basic/trooper/russian/mafia/proc/mafia_take_cover(mob/living/hunter) - if(mafia_took_cover || !mafia_gun || stat != CONSCIOUS || QDELETED(hunter)) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_take_cover(mob/living/hunter) + if(mafia_took_cover || !human_npc_held_gun(src) || !mafia_can_act() || QDELETED(hunter)) return null var/turf/here = get_turf(src) var/toward = here ? get_dir(here, hunter) : NONE @@ -777,7 +700,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return cover /// Raises its room's alarm over `hunter`, or just its own with no room. TRUE if this raised it. -/mob/living/basic/trooper/russian/mafia/proc/mafia_raise_alarm(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_raise_alarm(mob/living/hunter) var/datum/bounty_mafia_room/room = mafia_room() if(room?.raise_alarm(src, hunter)) return TRUE @@ -787,34 +710,28 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) mafia_on_alarm(room ? null : src, room ? rand(0, BOUNTY_MOBSTER_FIRST_SHOT_SPREAD) : 0) return TRUE -/// Whether it can do anything at all -/mob/living/basic/trooper/russian/mafia/proc/mafia_can_act() +/// Whether it can do anything at all on its feet +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_can_act() return !QDELETED(src) && stat == CONSCIOUS && isturf(loc) && !HAS_TRAIT(src, TRAIT_INCAPACITATED) // ----- its room ----- /// Whether it stands in its own room -/mob/living/basic/trooper/russian/mafia/proc/mafia_at_home() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_at_home() var/area/home = mafia_home_ref?.resolve() return !home || get_area(src) == home /// Whether it may step onto `spot` on its own: anywhere in its room, never out of it (bounty_mafia_leash_allows()) -/mob/living/basic/trooper/russian/mafia/proc/mafia_leash_allows(turf/spot) - return bounty_mafia_leash_allows(src, mafia_home_ref?.resolve(), spot) - -/mob/living/basic/trooper/russian/mafia/proc/mafia_on_pre_move(datum/source, atom/new_loc) - SIGNAL_HANDLER - if(!mafia_leash_allows(get_turf(new_loc))) - return COMPONENT_MOVABLE_BLOCK_PRE_MOVE - return NONE +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_leash_allows(turf/spot) + return bounty_mafia_leash_allows(src, mafia_home_ref?.resolve(), spot, mafia_shoved_at == world.time) /// Whether its next step toward `target` stays in its room -/mob/living/basic/trooper/russian/mafia/proc/mafia_step_ok(atom/target) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_step_ok(atom/target) var/turf/next = get_step_towards(src, target) return next && mafia_leash_allows(next) /// Its room's door tile nearest `target`: where it goes to get a line on someone outside, when a straight step won't do -/mob/living/basic/trooper/russian/mafia/proc/mafia_exit_toward(atom/target) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_exit_toward(atom/target) var/datum/bounty_mafia_room/room = mafia_room() if(!room || !target) return null @@ -831,7 +748,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) * Out of its room with nobody to fight: it walks back. If it hasn't made it in BOUNTY_MOBSTER_ROOM_GIVE_UP (another * leash keeps it where it is), the room it stands in becomes its room. Returns the turf it walks to, or null. */ -/mob/living/basic/trooper/russian/mafia/proc/mafia_head_home() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_head_home() if(mafia_at_home()) mafia_away_since = 0 return null @@ -843,7 +760,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return mafia_home_turf /// `new_home` is its room from now on: its leash, its shout and its doorway cap -/mob/living/basic/trooper/russian/mafia/proc/mafia_adopt_room(area/new_home) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_adopt_room(area/new_home) if(!new_home) return var/datum/bounty_mafia_room/old_room = mafia_room() @@ -857,14 +774,14 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) // ----- targets ----- /// Whether it may go after `target`: a hunter on their feet, on its level and near -/mob/living/basic/trooper/russian/mafia/proc/mafia_target_ok(mob/living/target) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_target_ok(mob/living/target) if(!bounty_mafia_may_hurt(target) || faction_check_atom(target)) return FALSE var/turf/spot = get_turf(target) return spot && spot.z == z && get_dist(src, spot) <= BOUNTY_MOBSTER_CHASE /// Whether `hunter` is in a doorway as its room sees it: outside the room, or on a door tile -/mob/living/basic/trooper/russian/mafia/proc/mafia_in_doorway(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_in_doorway(mob/living/hunter) var/turf/spot = get_turf(hunter) if(!spot) return FALSE @@ -874,7 +791,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return !isnull(locate(/obj/machinery/door) in spot) /// Whether it may take `hunter` on: fewer than BOUNTY_MOBSTER_MAX_ON_ONE of its room already on a hunter in a doorway -/mob/living/basic/trooper/russian/mafia/proc/mafia_room_has_space(mob/living/hunter) +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_room_has_space(mob/living/hunter) if(!mafia_in_doorway(hunter)) return TRUE var/datum/bounty_mafia_room/room = mafia_room() @@ -885,7 +802,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) * room's alarm), else the one its alerted room knows about, to get a line on from inside the room. Only * players on its level with a client are looked at, from SSmobs' list. */ -/mob/living/basic/trooper/russian/mafia/proc/mafia_find_target() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_find_target() var/datum/ai_controller/controller = ai_controller if(!controller) return null @@ -921,130 +838,113 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, best) return best -/** - * A hunter is pulling or grabbing it: that's an attack. The room is alerted and the grabber is its target, doorway cap - * or not. Returns TRUE while it is held, so it resists before anything else. - */ -/mob/living/basic/trooper/russian/mafia/proc/mafia_answer_grab() - var/mob/living/grabber = pulledby - if(!bounty_mafia_is_hunter(grabber) || faction_check_atom(grabber)) - return FALSE - mafia_raise_alarm(grabber) - if(ai_controller && ai_controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] != grabber && mafia_target_ok(grabber)) - ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, grabber) - return TRUE - /// Whether it's past the shout and may attack -/mob/living/basic/trooper/russian/mafia/proc/mafia_is_ready() +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_is_ready() var/datum/bounty_mafia_room/room = mafia_room() if(room && !room.alerted) return FALSE return mafia_ready_at && world.time >= mafia_ready_at -/// One attack on `target`: a burst if it has a gun and a line, a stab if it's next to them. TRUE if it attacked. -/mob/living/basic/trooper/russian/mafia/proc/mafia_attack(mob/living/target) - if(!mafia_can_act() || !mafia_is_ready() || !mafia_target_ok(target)) +// ----- fighting, through its hands ----- + +/** + * Queues its next move against `target` on `controller`: a reload from its pockets when its gun is dry, a volley + * when it has a loaded gun and a line, its knife or fists when it's next to them. TRUE if it queued one (or is + * between volleys, standing its ground); FALSE if it has to move to get at them. + */ +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_plan_attack(datum/ai_controller/controller, mob/living/target) + if(HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) return FALSE - face_atom(target) - if(mafia_gun) - if(!can_see(src, target, BOUNTY_MOBSTER_SIGHT)) + var/obj/item/gun/gun = human_npc_held_gun(src) + if(gun && !gun.can_shoot() && !LAZYLEN(do_afters) && world.time >= controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT] && human_npc_can_reload(src, gun)) + controller.queue_behavior(/datum/ai_behavior/human_npc_reload/bounty_mafia) + return TRUE + if(human_npc_loaded_gun(src)) + if(!can_see(src, target, mafia_sight())) return FALSE - return mafia_gun.try_fire(target) - if(!Adjacent(target) || world.time < next_move) + controller.queue_behavior(/datum/ai_behavior/bounty_mafia_fire, BB_BASIC_MOB_CURRENT_TARGET) + return TRUE + if(!Adjacent(target)) return FALSE - return melee_attack(target) - -// ----- what happens to it ----- - -/mob/living/basic/trooper/russian/mafia/proc/mafia_on_attacked(datum/source, atom/attacker, attack_flags) - SIGNAL_HANDLER - if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK))) - return - mafia_answer_attack(attacker) - -// Every shot that hits it, the ones relay_attackers skips too: an exosuit's guns fire as the suit -/mob/living/basic/trooper/russian/mafia/proc/mafia_on_bullet_act(datum/source, obj/projectile/shot, def_zone, piercing_hit, blocked) - SIGNAL_HANDLER - mafia_answer_attack(shot?.firer) + controller.queue_behavior(mafia_melee_behavior(), BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + return TRUE -/** - * `attacker` (a player, or an exosuit's pilots) went for it: its room is alerted, and it turns on them if it has no - * one else, and only while the doorway cap has room (so shooting a goon held back never pulls it in). - */ -/mob/living/basic/trooper/russian/mafia/proc/mafia_answer_attack(atom/attacker) - if(stat == DEAD || !attacker) - return - for(var/mob/living/hunter as anything in bounty_mafia_attackers(attacker)) - mafia_raise_alarm(hunter) - if(ai_controller && !ai_controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) && mafia_target_ok(hunter) && mafia_room_has_space(hunter)) - ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) +/// How far it sees and shoots +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_sight() + return BOUNTY_MOBSTER_SIGHT -/mob/living/basic/trooper/russian/mafia/proc/mafia_on_damaged(datum/source, damage, damagetype) - SIGNAL_HANDLER - if(damage <= 0 || stat != CONSCIOUS) - return - if(mafia_gun?.fumbles && mafia_gun.winding) - mafia_gun.fumble(BOUNTY_MOBSTER_FUMBLE) - visible_message(span_notice("[src] flinches and loses [p_their()] aim.")) - if(prob(20)) - mafia_bark("hurt") +/// The behaviour it hits with up close: tg's melee, with the best thing it holds or its fist +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_melee_behavior() + return /datum/ai_behavior/basic_melee_attack/human_npc -/// A gunman with a pistol -/mob/living/basic/trooper/russian/mafia/pistol - name = "mobster gunman" - desc = "A heavy in a balaclava and a leather jacket, working for the family that runs this club. He has a pistol." - maxHealth = BOUNTY_MOBSTER_PISTOL_HEALTH - health = BOUNTY_MOBSTER_PISTOL_HEALTH - speed = BOUNTY_MOBSTER_PISTOL_SPEED - mob_spawner = /obj/effect/mob_spawn/corpse/human/bounty_mafia/pistol - corpse = /obj/effect/mob_spawn/corpse/human/bounty_mafia/pistol - r_hand = BOUNTY_MOBSTER_PISTOL_GUN - attack_vis_effect = ATTACK_EFFECT_PUNCH - mafia_gun_type = /datum/bounty_mafia_gun - -/// An enforcer with an SMG that spins up before each burst -/mob/living/basic/trooper/russian/mafia/smg - name = "mobster enforcer" - desc = "A heavy in a balaclava and a leather jacket, working for the family that runs this club. He has an SMG." - maxHealth = BOUNTY_MOBSTER_SMG_HEALTH - health = BOUNTY_MOBSTER_SMG_HEALTH - speed = BOUNTY_MOBSTER_SMG_SPEED - mob_spawner = /obj/effect/mob_spawn/corpse/human/bounty_mafia/smg - corpse = /obj/effect/mob_spawn/corpse/human/bounty_mafia/smg - r_hand = BOUNTY_MOBSTER_SMG_GUN - attack_vis_effect = ATTACK_EFFECT_PUNCH - mafia_gun_type = /datum/bounty_mafia_gun/smg +/// It starts aiming at `target`: the "aiming!" telegraph, its gun's sound, and nothing fires until mafia_aim_until +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_start_aim(atom/target) + mafia_aim_until = world.time + mafia_windup + mafia_aim_spoiled = FALSE + face_atom(target) + if(mafia_windup_sound) + playsound(src, mafia_windup_sound, 50, TRUE) + balloon_alert_to_viewers("aiming!") + +/// It reaches for a fresh magazine (its AI's reload, human_npc_reload): the sound, maybe a line, and the next magazine needs raising again +/mob/living/carbon/human/npc/bounty_mafia/proc/mafia_on_reload() + mafia_raised = FALSE + if(mafia_reload_sound) + playsound(src, mafia_reload_sound, 40, TRUE) + if(mafia_reload_context && prob(mafia_reload_chance)) + mafia_bark(mafia_reload_context) + +/// A gunman with a Stechkin +/mob/living/carbon/human/npc/bounty_mafia/pistol + npc_name = "mobster gunman" + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia/pistol + npc_outfit = /datum/outfit/bounty_mafia_goon/pistol/armed + +/// An enforcer with an Uzi that spins up before each volley +/mob/living/carbon/human/npc/bounty_mafia/smg + npc_name = "mobster enforcer" + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia/smg + npc_outfit = /datum/outfit/bounty_mafia_goon/smg/armed + mafia_windup = BOUNTY_MOBSTER_SMG_WINDUP + mafia_windup_sound = 'sound/items/weapons/gun/smg/smgrack.ogg' + mafia_fumbles = TRUE + mafia_volley = BOUNTY_MOBSTER_SMG_VOLLEY + mafia_volley_gap = BOUNTY_MOBSTER_SMG_VOLLEY_GAP + mafia_cooldown = BOUNTY_MOBSTER_SMG_COOLDOWN + mafia_reload_sound = 'sound/items/weapons/gun/general/magazine_insert_full.ogg' // ===== LIEUTENANTS ===== /** - * The two who hold the arena gate: "Tommy" with a Tommy gun and "Brute" with his fists. Both take - * BOUNTY_LIEUTENANT_DAMAGE_MOD of any brute or burn. The base type takes the role its room doesn't have yet, - * so the first is Tommy and the second Brute; /tommy and /brute fix it. P10 opens the gate on their deaths - * (COMSIG_LIVING_DEATH; they are deleted right after, as troopers are). + * The two who hold the arena gate: "Tommy" with a Thompson and "Brute" with his fists. Elites (aimed crits, fire + * from the floor), in real armour, and they take BOUNTY_LIEUTENANT_DAMAGE_TAKEN of any brute or burn through their + * bodies. The base type takes the role its room doesn't have yet, so the first is Tommy and the second Brute; + * /tommy and /brute fix it. P10 opens the gate on their deaths (COMSIG_LIVING_DEATH). */ -/mob/living/basic/trooper/russian/mafia/lieutenant - name = "lieutenant" - desc = "One of the don's lieutenants." - maxHealth = BOUNTY_LIEUTENANT_TOMMY_HEALTH - health = BOUNTY_LIEUTENANT_TOMMY_HEALTH +/mob/living/carbon/human/npc/bounty_mafia/lieutenant + npc_name = "lieutenant" + npc_rank = HUMAN_NPC_RANK_ELITE + npc_damage_taken = BOUNTY_LIEUTENANT_DAMAGE_TAKEN + mafia_examine_line = "One of the don's lieutenants." /// BOUNTY_LIEUTENANT_TOMMY or BOUNTY_LIEUTENANT_BRUTE; null picks one at Initialize var/mafia_lieutenant_role + /// Percent chance a punch of Brute's that lands knocks them down + var/mafia_punch_knockdown_chance = BOUNTY_LIEUTENANT_BRUTE_KNOCKDOWN_CHANCE -/mob/living/basic/trooper/russian/mafia/lieutenant/Initialize(mapload) +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/Initialize(mapload, datum/human_npc_order/order) if(!mafia_lieutenant_role) mafia_lieutenant_role = mafia_pick_lieutenant_role() mafia_apply_lieutenant_role() return ..() /// The role its room doesn't have yet: Tommy first, then Brute -/mob/living/basic/trooper/russian/mafia/lieutenant/proc/mafia_pick_lieutenant_role() +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/proc/mafia_pick_lieutenant_role() var/area/home = get_area(loc) var/datum/bounty_mafia_room/room = home ? GLOB.bounty_mafia_rooms[REF(home)] : null var/has_tommy = FALSE var/has_brute = FALSE if(room) - for(var/mob/living/basic/trooper/russian/mafia/lieutenant/other in room.goons()) + for(var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/other in room.goons()) if(other.mafia_lieutenant_role == BOUNTY_LIEUTENANT_TOMMY) has_tommy = TRUE else if(other.mafia_lieutenant_role == BOUNTY_LIEUTENANT_BRUTE) @@ -1057,94 +957,187 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return pick(BOUNTY_LIEUTENANT_TOMMY, BOUNTY_LIEUTENANT_BRUTE) return BOUNTY_LIEUTENANT_TOMMY -/// Its role's body, gun and look. Before the parent Initialize, which dresses it and makes its AI. -/mob/living/basic/trooper/russian/mafia/lieutenant/proc/mafia_apply_lieutenant_role() - damage_coeff = list(BRUTE = BOUNTY_LIEUTENANT_DAMAGE_MOD, BURN = BOUNTY_LIEUTENANT_DAMAGE_MOD, TOX = 1, STAMINA = 1, OXY = 1) +/// Its role's look, clothes, gun and voice. Before the parent Initialize, which dresses it and makes its AI. +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/proc/mafia_apply_lieutenant_role() if(mafia_lieutenant_role == BOUNTY_LIEUTENANT_BRUTE) - name = "Brute" - desc = "A mountain of a man in a tracksuit and a bear-pelt hat, knuckles taped for work. The family's muscle." - maxHealth = BOUNTY_LIEUTENANT_BRUTE_HEALTH - health = BOUNTY_LIEUTENANT_BRUTE_HEALTH - speed = BOUNTY_LIEUTENANT_BRUTE_SPEED - melee_attack_cooldown = BOUNTY_LIEUTENANT_BRUTE_COOLDOWN - attack_vis_effect = ATTACK_EFFECT_PUNCH - mafia_gun_type = null - mob_spawner = /obj/effect/mob_spawn/corpse/human/bounty_mafia/brute - corpse = /obj/effect/mob_spawn/corpse/human/bounty_mafia/brute - r_hand = null + npc_name = "Brute" + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia/brute + npc_outfit = /datum/outfit/bounty_mafia_brute/armed + mafia_examine_line = "One of the don's lieutenants: the family's muscle, knuckles taped for work." mafia_voice = "brute" return mafia_lieutenant_role = BOUNTY_LIEUTENANT_TOMMY - name = "Tommy" - desc = "A lieutenant in a long coat and a fedora, with a drum-fed Tommy gun. He runs the back office." - maxHealth = BOUNTY_LIEUTENANT_TOMMY_HEALTH - health = BOUNTY_LIEUTENANT_TOMMY_HEALTH - speed = BOUNTY_LIEUTENANT_TOMMY_SPEED - attack_vis_effect = ATTACK_EFFECT_PUNCH - mafia_gun_type = /datum/bounty_mafia_gun/tommy - mob_spawner = /obj/effect/mob_spawn/corpse/human/bounty_mafia/tommy - corpse = /obj/effect/mob_spawn/corpse/human/bounty_mafia/tommy - r_hand = BOUNTY_LIEUTENANT_TOMMY_GUN + npc_name = "Tommy" + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia/tommy + npc_outfit = /datum/outfit/bounty_mafia_tommy/armed + mafia_examine_line = "One of the don's lieutenants. He runs the back office." mafia_voice = "tommy" + mafia_windup = BOUNTY_LIEUTENANT_TOMMY_WINDUP + mafia_windup_sound = 'sound/items/weapons/gun/smg/smgrack.ogg' + mafia_volley = BOUNTY_LIEUTENANT_TOMMY_VOLLEY + mafia_volley_gap = BOUNTY_LIEUTENANT_TOMMY_VOLLEY_GAP + mafia_cooldown = BOUNTY_LIEUTENANT_TOMMY_COOLDOWN + mafia_reload_sound = 'sound/items/weapons/gun/general/magazine_insert_full.ogg' + +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/mafia_melee_behavior() + if(mafia_lieutenant_role == BOUNTY_LIEUTENANT_BRUTE) + return /datum/ai_behavior/basic_melee_attack/human_npc/bounty_mafia_brute + return ..() -// Brute's blows knock people down now and then -/mob/living/basic/trooper/russian/mafia/lieutenant/melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - . = ..() - if(!. || mafia_lieutenant_role != BOUNTY_LIEUTENANT_BRUTE || !isliving(target)) +/** + * Brute's punch landed on `victim` (COMSIG_HUMAN_GOT_PUNCHED, which his melee listens to on its target for the length + * of the blow): now and then it knocks them off their feet. A designed weapon effect, through tg's Knockdown. + */ +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/proc/mafia_on_punch_landed(mob/living/carbon/human/victim, mob/living/attacker, damage, attack_type, obj/item/bodypart/affecting, final_armor_block, kicking, limb_sharpness) + SIGNAL_HANDLER + if(attacker != src || mafia_lieutenant_role != BOUNTY_LIEUTENANT_BRUTE || QDELETED(victim) || victim.stat == DEAD) return - if(!prob(BOUNTY_LIEUTENANT_BRUTE_KNOCKDOWN_CHANCE)) + if(!prob(mafia_punch_knockdown_chance)) return - var/mob/living/victim = target victim.Knockdown(BOUNTY_LIEUTENANT_BRUTE_KNOCKDOWN) victim.visible_message(span_danger("[src]'s punch knocks [victim] off [victim.p_their()] feet!"), span_userdanger("[src]'s punch knocks you off your feet!")) -/mob/living/basic/trooper/russian/mafia/lieutenant/tommy - name = "Tommy" +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/tommy + npc_name = "Tommy" mafia_lieutenant_role = BOUNTY_LIEUTENANT_TOMMY -/mob/living/basic/trooper/russian/mafia/lieutenant/brute - name = "Brute" - maxHealth = BOUNTY_LIEUTENANT_BRUTE_HEALTH - health = BOUNTY_LIEUTENANT_BRUTE_HEALTH +/mob/living/carbon/human/npc/bounty_mafia/lieutenant/brute + npc_name = "Brute" mafia_lieutenant_role = BOUNTY_LIEUTENANT_BRUTE +/** + * Listens to a club member's body for it: hits, shots, steps, damage, grabs, cuffs, shoves, and the polymorph and + * type changes it refuses. An element, so nothing else registering the same signals on the mob replaces these. + */ +/datum/element/bounty_mafia_body + element_flags = ELEMENT_DETACH_ON_HOST_DESTROY + +/datum/element/bounty_mafia_body/Attach(datum/target) + . = ..() + if(!istype(target, /mob/living/carbon/human/npc/bounty_mafia)) + return ELEMENT_INCOMPATIBLE + RegisterSignal(target, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) + RegisterSignal(target, COMSIG_ATOM_BULLET_ACT, PROC_REF(on_bullet_act)) + RegisterSignal(target, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(on_pre_move)) + RegisterSignal(target, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) + RegisterSignal(target, COMSIG_LIVING_GET_PULLED, PROC_REF(on_grabbed)) + RegisterSignal(target, COMSIG_CARBON_CUFF_ATTEMPTED, PROC_REF(on_cuff_attempt)) + RegisterSignal(target, COMSIG_LIVING_DISARM_HIT, PROC_REF(on_shoved)) + // A polymorph or a type change deletes it without a death: a free kill, and for a lieutenant a free gate + RegisterSignal(target, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) + RegisterSignal(target, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) + +/datum/element/bounty_mafia_body/Detach(datum/source) + UnregisterSignal(source, list( + COMSIG_ATOM_WAS_ATTACKED, + COMSIG_ATOM_BULLET_ACT, + COMSIG_MOVABLE_PRE_MOVE, + COMSIG_MOB_AFTER_APPLY_DAMAGE, + COMSIG_LIVING_GET_PULLED, + COMSIG_CARBON_CUFF_ATTEMPTED, + COMSIG_LIVING_DISARM_HIT, + COMSIG_LIVING_PRE_WABBAJACKED, + COMSIG_PRE_MOB_CHANGED_TYPE, + )) + return ..() + +/datum/element/bounty_mafia_body/proc/on_attacked(mob/living/carbon/human/npc/bounty_mafia/source, atom/attacker, attack_flags) + SIGNAL_HANDLER + if(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK)) + source.mafia_answer_attack(attacker) + +// Every shot that hits it, the ones relay_attackers skips too: an exosuit's guns fire as the suit +/datum/element/bounty_mafia_body/proc/on_bullet_act(mob/living/carbon/human/npc/bounty_mafia/source, obj/projectile/shot, def_zone, piercing_hit, blocked) + SIGNAL_HANDLER + source.mafia_answer_attack(shot?.firer) + +/datum/element/bounty_mafia_body/proc/on_pre_move(mob/living/carbon/human/npc/bounty_mafia/source, atom/new_loc) + SIGNAL_HANDLER + if(!source.mafia_leash_allows(get_turf(new_loc))) + return COMPONENT_MOVABLE_BLOCK_PRE_MOVE + return NONE + +/datum/element/bounty_mafia_body/proc/on_damaged(mob/living/carbon/human/npc/bounty_mafia/source, damage, damagetype) + SIGNAL_HANDLER + source.mafia_on_damaged(damage, damagetype) + +/datum/element/bounty_mafia_body/proc/on_grabbed(mob/living/carbon/human/npc/bounty_mafia/source, mob/living/puller) + SIGNAL_HANDLER + source.mafia_answer_grab(puller) + +/datum/element/bounty_mafia_body/proc/on_cuff_attempt(mob/living/carbon/human/npc/bounty_mafia/source, mob/living/user) + SIGNAL_HANDLER + source.mafia_on_cuff_attempt(user) + return NONE + +/datum/element/bounty_mafia_body/proc/on_shoved(mob/living/carbon/human/npc/bounty_mafia/source, mob/living/attacker, zone, obj/item/weapon) + SIGNAL_HANDLER + source.mafia_shoved_at = world.time + +/datum/element/bounty_mafia_body/proc/refuse_polymorph(datum/source, what_to_randomize) + SIGNAL_HANDLER + return STOP_WABBAJACK + +/datum/element/bounty_mafia_body/proc/refuse_type_change(datum/source) + SIGNAL_HANDLER + return COMPONENT_BLOCK_MOB_CHANGE + // ===== THE ROOM SPAWNER ===== /** * The club's goons. On P11's map every room has one of these; its room (the marker's area) fixes the crew - * (bounty_mafia_room_crew()). Each marker rolls 1-2 from its room's weighted roster. Goons - * only spawn on tiles of the marker's own room. + * (bounty_mafia_room_crew()). Each marker rolls 1-2 from its room's weighted roster, anywhere else 1-2 from its zone + * table. Goons only spawn on tiles of the marker's own room. They are people, so they are queued + * (spawn_human_npc()): a club of two dozen is built a body at a time, never in one tick. */ /obj/effect/zone_mobs/mafia name = "zone mob spawner (mafia)" scatter_radius = 3 mobs_green = list( - /mob/living/basic/trooper/russian/mafia = 4, - /mob/living/basic/trooper/russian/mafia/pistol = 3, - /mob/living/basic/trooper/russian/mafia/smg = 1, + /mob/living/carbon/human/npc/bounty_mafia = 4, + /mob/living/carbon/human/npc/bounty_mafia/pistol = 3, + /mob/living/carbon/human/npc/bounty_mafia/smg = 1, ) mobs_yellow = list( - /mob/living/basic/trooper/russian/mafia = 3, - /mob/living/basic/trooper/russian/mafia/pistol = 4, - /mob/living/basic/trooper/russian/mafia/smg = 2, + /mob/living/carbon/human/npc/bounty_mafia = 3, + /mob/living/carbon/human/npc/bounty_mafia/pistol = 4, + /mob/living/carbon/human/npc/bounty_mafia/smg = 2, ) mobs_red = list( - /mob/living/basic/trooper/russian/mafia = 2, - /mob/living/basic/trooper/russian/mafia/pistol = 4, - /mob/living/basic/trooper/russian/mafia/smg = 3, + /mob/living/carbon/human/npc/bounty_mafia = 2, + /mob/living/carbon/human/npc/bounty_mafia/pistol = 4, + /mob/living/carbon/human/npc/bounty_mafia/smg = 3, ) +/// Queues its goons. Returns their orders (/datum/human_npc_order). /obj/effect/zone_mobs/mafia/do_spawn(zone_type) + . = list() var/area/room = get_area(src) var/list/crew = room ? bounty_mafia_room_crew(room.type) : null - if(!crew) - return ..() + var/amount + if(crew) + amount = rand(1, 2) + else + var/list/count_range = count_green + crew = mobs_green + switch(zone_type) + if(ZONE_YELLOW) + crew = mobs_yellow || crew + count_range = count_yellow + if(ZONE_RED) + crew = mobs_red || mobs_yellow || crew + count_range = count_red + amount = rand(count_range[1], count_range[2]) + if(!length(crew) || amount < 1) + return var/list/open_turfs = get_scatter_turfs() - for(var/i in 1 to rand(1, 2)) + for(var/i in 1 to amount) var/mob_path = pick_weight(crew) + if(!ispath(mob_path, /mob/living/carbon/human/npc)) + continue var/turf/spot = length(open_turfs) ? pick_n_take(open_turfs) : get_turf(src) var/datum/npc_spawn_context/context = new(src, zone_type, "ordinary") - spawn_npc_in_zone(mob_path, spot, context) + . += spawn_human_npc(mob_path, spot, context = context) /obj/effect/zone_mobs/mafia/get_scatter_turfs() var/area/room = get_area(src) @@ -1153,9 +1146,10 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) if(get_area(spot) == room) . += spot -// ===== LOOKS AND CORPSES ===== -// The goons wear what the corpse leaves behind: club clothes, and never a gun (the gun in the living mob's -// hand is only a picture). +// ===== CLOTHES AND LOOKS ===== +// The club's clothes, as their corpses used to wear them; the /armed outfits are what the living wear: the same +// clothes, their real gun or knife in hand and spare magazines in their pockets, and the lieutenants' real armour. +// All of it is locked to them (D3): none of it is loot. /// A Ratnik tracksuit and an ushanka: the doormen and knife men /datum/outfit/bounty_mafia_goon @@ -1165,12 +1159,22 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/sneakers/black +/datum/outfit/bounty_mafia_goon/armed + name = "Club Volga mobster (armed)" + r_hand = BOUNTY_MOBSTER_KNIFE + /// The gunmen hide their faces under the ushanka and keep a leather jacket over the tracksuit /datum/outfit/bounty_mafia_goon/pistol name = "Club Volga gunman" suit = /obj/item/clothing/suit/jacket/leather mask = /obj/item/clothing/mask/russian_balaclava +/datum/outfit/bounty_mafia_goon/pistol/armed + name = "Club Volga gunman (armed)" + r_hand = BOUNTY_MOBSTER_PISTOL_GUN + l_pocket = BOUNTY_MOBSTER_PISTOL_MAGAZINE + r_pocket = BOUNTY_MOBSTER_PISTOL_MAGAZINE + /// The enforcers: a military tracksuit, a black balaclava, heavy boots /datum/outfit/bounty_mafia_goon/smg name = "Club Volga enforcer" @@ -1180,6 +1184,12 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) suit = /obj/item/clothing/suit/jacket/leather shoes = /obj/item/clothing/shoes/russian +/datum/outfit/bounty_mafia_goon/smg/armed + name = "Club Volga enforcer (armed)" + r_hand = BOUNTY_MOBSTER_SMG_GUN + l_pocket = BOUNTY_MOBSTER_SMG_MAGAZINE + r_pocket = BOUNTY_MOBSTER_SMG_MAGAZINE + /// Tommy dresses like the old films: a black suit, a long coat, a fedora /datum/outfit/bounty_mafia_tommy name = "Club Volga lieutenant (Tommy)" @@ -1189,6 +1199,14 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/laceup +/// His long coat is a lined noir trenchcoat (real armour), the Thompson in his hands and two drums in his pockets +/datum/outfit/bounty_mafia_tommy/armed + name = "Club Volga lieutenant (Tommy, armed)" + suit = /obj/item/clothing/suit/toggle/jacket/det_trench/noir + r_hand = BOUNTY_LIEUTENANT_TOMMY_GUN + l_pocket = BOUNTY_LIEUTENANT_TOMMY_MAGAZINE + r_pocket = BOUNTY_LIEUTENANT_TOMMY_MAGAZINE + /// Brute: a tracksuit, a bear-pelt hat and taped knuckles /datum/outfit/bounty_mafia_brute name = "Club Volga lieutenant (Brute)" @@ -1197,6 +1215,11 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/russian +/// And a bulletproof vest over the tracksuit (real armour) +/datum/outfit/bounty_mafia_brute/armed + name = "Club Volga lieutenant (Brute, armed)" + suit = /obj/item/clothing/suit/armor/vest/russian + /// The don: a black suit, a fur coat, a gold chain and dark glasses /datum/outfit/bounty_mafia_don name = "Club Volga don" @@ -1207,6 +1230,13 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/laceup +/datum/outfit/bounty_mafia_don/armed + name = "Club Volga don (armed)" + r_hand = BOUNTY_DON_GUN + l_pocket = BOUNTY_DON_MAGAZINE + r_pocket = BOUNTY_DON_MAGAZINE + +// The club's looks: hair and faces for the people (npc_look), and the plain clothes outfits above /obj/effect/mob_spawn/corpse/human/bounty_mafia name = "Club Volga mobster" outfit = /datum/outfit/bounty_mafia_goon @@ -1295,7 +1325,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) /// What its volley fires: never tg's rocket (explosion()), always this var/mech_rocket_type = /obj/projectile/bullet/bounty_mafia_rocket /// What its machine gun fires - var/mech_lmg_type = /obj/projectile/bullet/bounty_mafia/lmg + var/mech_lmg_type = /obj/projectile/bullet/bounty_mafia_lmg /// What keeps it still with its AI quiet ("rockets", "lmg_windup", "lmg_fire", "stall"), or null var/mech_busy /// Goes up each time it becomes busy, so a stale timer does nothing @@ -1399,7 +1429,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return COMPONENT_MOVABLE_BLOCK_PRE_MOVE return NONE -/// Dragged or thrown out of its arena somehow: it's put back where it started +/// Dragged or thrown out of its arena somehow: the machine is put back where it started (OC-5) /mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_check_home() var/area/arena = mech_home_ref?.resolve() if(!arena || !mech_home_turf || get_area(src) == arena) @@ -1575,7 +1605,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) /** * After BOUNTY_MECH_RESET_AFTER with nobody in its arena, in sight or shooting at it, a fight it was in resets: * full health, the one-hunter table and an empty posse, so a second crew never finishes a wounded mech (lairs.md - * 9.5). Its Life() calls this. TRUE if it reset. + * 9.5). A machine with repair arms (OC-5, CB-18). Its Life() calls this. TRUE if it reset. */ /mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_check_reset() if(stat == DEAD) @@ -1695,9 +1725,10 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return FALSE /** - * The rocket volley's lock-on: a red marker under each of `targets` (by default each hunter it can see), a - * rising tone, and BOUNTY_MECH_ROCKET_LOCK later one rocket per marker (mech_fire_rockets()). Nothing is hurt - * before then. Returns the serial the launch checks, or 0 if it didn't start. + * The rocket volley's lock-on: a red marker under each of `targets` (by default each hunter it can see; it never + * picks one who is down), a rising tone, and BOUNTY_MECH_ROCKET_LOCK later one rocket per marker + * (mech_fire_rockets()). Nothing is hurt before then, and the burst then catches whoever stands on the marker. + * Returns the serial the launch checks, or 0 if it didn't start. */ /mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_start_rockets(list/targets) if(!mech_can_start_ability()) @@ -1754,7 +1785,6 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) rocket.rocket_area_ref = mech_home_ref rocket.firer = src rocket.fired_from = src - rocket.ignored_factions = faction.Copy() rocket.range = get_dist(start, spot) + 3 rocket.maximum_range = rocket.range rocket.aim_projectile(spot, src) @@ -1805,7 +1835,38 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) /mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_lmg_round(serial) if(!mech_still("lmg_fire", serial) || !mech_lmg_aim) return null - return bounty_mafia_shoot(src, mech_lmg_type, mech_lmg_aim, bounty_real_weapon(BOUNTY_MECH_LMG_GUN), BOUNTY_MECH_LMG_ARC, mech_lmg_range + 1) + return mech_shoot_round(mech_lmg_aim, bounty_real_weapon(BOUNTY_MECH_LMG_GUN), BOUNTY_MECH_LMG_ARC, mech_lmg_range + 1) + +/** + * The machine fires one round of `weapon` (a /datum/bounty_real_weapon, its exosuit gun) at `target` (a turf): + * every pellet with the real round's numbers and spread, `spread` degrees more (full width), no farther than + * `max_range` tiles. A real round from a machine: it hits whoever is in its way, the club's own people included. + * Returns the first projectile still flying, or null. + */ +/mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_shoot_round(atom/target, datum/bounty_real_weapon/weapon, spread = 0, max_range) + var/turf/start = get_turf(src) + var/turf/aim = get_turf(target) + if(!start || !aim || start == aim || !weapon) + return null + var/obj/projectile/first + for(var/pellet in 1 to weapon.pellets) + var/obj/projectile/bullet = new mech_lmg_type(start) + weapon.load(bullet) + if(max_range) + bullet.range = max_range + bullet.maximum_range = max_range + bullet.firer = src + bullet.fired_from = src + bullet.spread = spread + bullet.aim_projectile(target, src, null, weapon.shot_spread()) + if(QDELETED(bullet)) + continue + bullet.fire() + if(!first && !QDELETED(bullet)) + first = bullet + if(weapon.fire_sound) + playsound(src, weapon.fire_sound, 60, TRUE) + return first /** * A stomp on someone right next to it, which throws them (their exosuit and all) BOUNTY_MECH_STOMP_THROW tiles @@ -1862,7 +1923,8 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) /** * The mech breaks: a burnt-out wreck where it stood (decoration and cover, nothing to salvage) and the don - * climbing out beside it, on the side away from the hunters, with the mech's posting. Returns the don. + * climbing out beside it, on the side away from the hunters, with the mech's posting. He is one body the fight + * needs at once, so he is built now (spawn_human_npc(immediate = TRUE)). Returns the don. */ /mob/living/basic/bounty_lair_boss/mafia_mech/proc/mech_eject() if(mech_ejected) @@ -1877,22 +1939,21 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) var/obj/structure/bounty_mafia_wreck/wreck = new(spot) wreck.setDir(dir) var/turf/exit = mech_exit_turf(spot) + var/datum/criminal_bounty/posting = posting_ref?.resolve() var/datum/npc_spawn_context/context = new(src, null, "boss") - var/mob/living/basic/bounty_lair_boss/mafia_don/don = spawn_npc_in_zone(/mob/living/basic/bounty_lair_boss/mafia_don, exit || spot, context, FALSE) + var/datum/human_npc_order/order = spawn_human_npc(/mob/living/carbon/human/npc/bounty_mafia/don, exit || spot, name = posting?.record?.name, context = context, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_mafia/don/don = order?.spawned_ref?.resolve() + if(QDELETED(don)) + return null don.posting_ref = posting_ref don.don_wreck_ref = WEAKREF(wreck) // Whoever broke his machine, he knows don.don_grudge = mech_grudge.Copy() if(mech_home_ref) - don.don_home_ref = mech_home_ref - don.don_home_turf = mech_home_turf - var/datum/criminal_bounty/posting = posting_ref?.resolve() - if(posting?.record?.name) - don.name = posting.record.name - don.real_name = posting.record.name + don.mafia_home_ref = mech_home_ref + don.mafia_home_turf = mech_home_turf SEND_SIGNAL(src, COMSIG_BOUNTY_MAFIA_DON_EJECTED, don) don.don_on_eject() - context.finalize() return don /// The open tile beside `wreck_spot`, in its arena, farthest from the hunters @@ -1933,155 +1994,77 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) // ===== THE DON ===== /** - * The don on foot (phase two), out of his wreck. A pistol, the wreck for cover while he reloads, and no - * surrender: cuffs are refused and he fights until he dies. His death drops the gold signet ring with his - * posting on it and leaves a corpse in his clothes. + * The don on foot (phase two), out of his wreck: a person, the boss of the club. His gold Desert Eagle and two + * spare magazines, the wreck for cover while he reloads, and no surrender: he fights until he dies, and resists + * cuffs the tg way. Dragged out of his garage he can be taken anywhere; on his feet with nobody to fight he walks + * back. His death drops the gold signet ring with his posting on it, once, and leaves his body in his clothes. */ -/mob/living/basic/bounty_lair_boss/mafia_don - name = BOUNTY_DON_NAME - desc = "The don himself, head of the family that runs this club: a fur coat, a gold chain, and a gold pistol in his hand. He looks furious." - icon = 'icons/mob/simple/simple_human.dmi' - gender = MALE - // Nobody drags the don out of his garage - move_resist = MOVE_FORCE_STRONG - mob_biotypes = MOB_ORGANIC|MOB_HUMANOID - maxHealth = BOUNTY_DON_HEALTH - health = BOUNTY_DON_HEALTH - speed = BOUNTY_DON_SPEED - melee_attack_cooldown = 1.2 SECONDS - attack_vis_effect = ATTACK_EFFECT_PUNCH - combat_mode = TRUE - faction = list(FACTION_RUSSIAN, BOUNTY_MAFIA_FACTION) - sentience_type = SENTIENCE_BOSS - basic_mob_flags = DEL_ON_DEATH - initial_language_holder = /datum/language_holder/atom_basic - unsuitable_atmos_damage = 7.5 - unsuitable_cold_damage = 7.5 - unsuitable_heat_damage = 7.5 - ai_controller = /datum/ai_controller/basic_controller/bounty_mafia_don - /// His gold pistol (/datum/bounty_mafia_gun/don) - var/datum/bounty_mafia_gun/don/don_gun - /// His arena, which he never leaves, and where he's put back if dragged out - var/datum/weakref/don_home_ref - var/turf/don_home_turf +/mob/living/carbon/human/npc/bounty_mafia/don + npc_name = BOUNTY_DON_NAME + npc_look = /obj/effect/mob_spawn/corpse/human/bounty_mafia/don + npc_outfit = /datum/outfit/bounty_mafia_don/armed + npc_rank = HUMAN_NPC_RANK_BOSS + npc_damage_taken = BOUNTY_DON_DAMAGE_TAKEN + ai_controller = /datum/ai_controller/basic_controller/bounty_mafia/don + mafia_examine_line = "The don himself, head of the family that runs this club. He looks furious." + mafia_voice = "don" + mafia_idle_chatter = FALSE + mafia_hurt_bark_chance = 15 + mafia_windup = BOUNTY_DON_RAISE + mafia_windup_once = TRUE + mafia_windup_sound = 'sound/items/weapons/gun/pistol/rack_small.ogg' + mafia_volley = BOUNTY_DON_VOLLEY + mafia_volley_gap = BOUNTY_DON_VOLLEY_GAP + mafia_cooldown = BOUNTY_DON_COOLDOWN + mafia_reload_chance = 50 /// The wreck he hides behind (/obj/structure/bounty_mafia_wreck) var/datum/weakref/don_wreck_ref - /// What keeps him still with his AI quiet ("stagger"), or null - var/don_busy - var/don_busy_serial = 0 - var/don_busy_timer - /// The ring has dropped - var/don_trophy_dropped = FALSE - /// world.time he may say another line - var/don_next_bark_at = 0 /// Weakrefs to the hunters who have hurt him or his mech: fair game at any range, seen or not var/list/don_grudge = list() + /// world.time he has his bearings after climbing out of the wreck + var/don_dazed_until = 0 + /// The ring has dropped + var/don_trophy_dropped = FALSE -/mob/living/basic/bounty_lair_boss/mafia_don/Initialize(mapload) - . = ..() - don_gun = new(src) - // Up close he pistol-whips with it, as hard as the real thing - bounty_real_weapon(BOUNTY_DON_GUN).apply_melee(src) - apply_dynamic_human_appearance(src, mob_spawn_path = /obj/effect/mob_spawn/corpse/human/bounty_mafia/don, r_hand = BOUNTY_DON_GUN) - AddElement(/datum/element/death_drops, string_list(list(/obj/effect/mob_spawn/corpse/human/bounty_mafia/don))) - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) - AddElement(/datum/element/relay_attackers) - RegisterSignal(src, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(don_on_attacked)) - RegisterSignal(src, COMSIG_ATOM_BULLET_ACT, PROC_REF(don_on_bullet_act)) - RegisterSignal(src, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(don_on_pre_move)) - RegisterSignal(src, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(don_on_damaged)) - RegisterSignal(src, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(don_refuse_polymorph)) - RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(don_refuse_type_change)) - ban_from_containment() - don_home_turf = get_turf(src) - var/area/home = get_area(src) - if(home) - don_home_ref = WEAKREF(home) - -/mob/living/basic/bounty_lair_boss/mafia_don/Destroy() - QDEL_NULL(don_gun) - deltimer(don_busy_timer) - don_busy_timer = null - don_home_ref = null - don_home_turf = null +/mob/living/carbon/human/npc/bounty_mafia/don/Destroy() don_wreck_ref = null don_grudge = null return ..() -/mob/living/basic/bounty_lair_boss/mafia_don/Life(seconds_per_tick = SSMOBS_DT, times_fired) - . = ..() - if(stat == DEAD) - return - var/area/arena = don_home_ref?.resolve() - if(arena && don_home_turf && get_area(src) != arena) - forceMove(don_home_turf) - -/mob/living/basic/bounty_lair_boss/mafia_don/death(gibbed) - if(stat != DEAD) - don_clear_busy() - don_drop_trophy() - return ..() +// His garage is his arena: no room, no shout, no doorway cap +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_settle_home() + mafia_home_turf = get_turf(src) + var/area/home = get_area(src) + if(home) + mafia_home_ref = WEAKREF(home) -// He won't be taken alive (kill-only, spec 14.1) -/mob/living/basic/bounty_lair_boss/mafia_don/item_interaction(mob/living/user, obj/item/tool, list/modifiers) - if(istype(tool, /obj/item/restraints)) - if(stat != DEAD) - balloon_alert(user, "he won't be taken alive!") - return ITEM_INTERACT_BLOCKING - return ..() +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_is_ready() + return TRUE -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_refuse_polymorph(datum/source, what_to_randomize) - SIGNAL_HANDLER - return STOP_WABBAJACK +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_raise_alarm(mob/living/hunter) + return FALSE -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_refuse_type_change(datum/source) - SIGNAL_HANDLER - return COMPONENT_BLOCK_MOB_CHANGE +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_sight() + return BOUNTY_MECH_SIGHT -/// Says a line for "don_" unless he spoke recently or `force` -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_bark(context, force = FALSE) - if(stat != CONSCIOUS || (!force && world.time < don_next_bark_at)) - return FALSE - if(!bounty_mafia_say(src, "don_[context]")) - return FALSE - don_next_bark_at = world.time + BOUNTY_MAFIA_BARK_COOLDOWN - return TRUE +/mob/living/carbon/human/npc/bounty_mafia/don/death(gibbed) + var/was_alive = stat != DEAD + . = ..() + if(was_alive && stat == DEAD) + don_drop_trophy() -/// Climbing out of the wreck: dazed for BOUNTY_DON_EJECT_STAGGER, and a word about his machine -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_on_eject() - don_set_busy("stagger", BOUNTY_DON_EJECT_STAGGER) +/// Climbing out of the wreck: he takes BOUNTY_DON_EJECT_STAGGER to get his bearings, and has a word about his machine +/mob/living/carbon/human/npc/bounty_mafia/don/proc/don_on_eject() + don_dazed_until = world.time + BOUNTY_DON_EJECT_STAGGER Shake(2, 1, BOUNTY_DON_EJECT_STAGGER) visible_message(span_danger("The canopy blows off the wreck and [src] climbs out, coughing!")) - don_bark("eject", force = TRUE) - -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_set_busy(state, duration) - don_busy = state - don_busy_serial++ - deltimer(don_busy_timer) - don_busy_timer = null - ADD_TRAIT(src, TRAIT_IMMOBILIZED, BOUNTY_MAFIA_BUSY_TRAIT) - ai_controller?.CancelActions() - if(duration) - don_busy_timer = addtimer(CALLBACK(src, PROC_REF(don_clear_busy), don_busy_serial), duration, TIMER_STOPPABLE | TIMER_DELETE_ME) - return don_busy_serial - -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_clear_busy(serial) - if(serial && serial != don_busy_serial) - return FALSE - don_busy = null - deltimer(don_busy_timer) - don_busy_timer = null - REMOVE_TRAIT(src, TRAIT_IMMOBILIZED, BOUNTY_MAFIA_BUSY_TRAIT) - return TRUE - -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_can_act() - return !QDELETED(src) && stat == CONSCIOUS && isturf(loc) && !HAS_TRAIT(src, TRAIT_INCAPACITATED) + mafia_bark("eject", force = TRUE) /** - * The trophy: the gold signet ring, where he fell, with his posting and its record on it. Once only. - * Returns the ring, or null. + * The trophy: the gold signet ring, where he fell, with his posting and its record on it. Once only, however often + * he dies. A designed death drop: it is not his gear. Returns the ring, or null. */ -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_drop_trophy() +/mob/living/carbon/human/npc/bounty_mafia/don/proc/don_drop_trophy() if(don_trophy_dropped) return null var/turf/spot = get_turf(src) @@ -2102,39 +2085,23 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) SEND_SIGNAL(src, COMSIG_BOUNTY_MAFIA_TROPHY_DROPPED, ring) return ring -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_on_pre_move(datum/source, atom/new_loc) - SIGNAL_HANDLER - if(!bounty_mafia_leash_allows(src, don_home_ref?.resolve(), new_loc)) - return COMPONENT_MOVABLE_BLOCK_PRE_MOVE - return NONE - -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_on_attacked(datum/source, atom/attacker, attack_flags) - SIGNAL_HANDLER - if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK))) - return - don_answer_attack(attacker) - -// Every shot that hits him, the ones relay_attackers skips too (an exosuit's guns fire as the suit) -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_on_bullet_act(datum/source, obj/projectile/shot, def_zone, piercing_hit, blocked) - SIGNAL_HANDLER - don_answer_attack(shot?.firer) - -/// `attacker` (a player, or an exosuit's pilots) went for him, from any range: a grudge, and his target if he has none -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_answer_attack(atom/attacker) +// He holds a grudge against whoever hurts him, from any range, and takes them on if he has nobody else +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_answer_attack(atom/attacker) if(stat == DEAD || !attacker) return for(var/mob/living/hunter as anything in bounty_mafia_attackers(attacker)) don_grudge |= WEAKREF(hunter) - if(ai_controller && !ai_controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) && don_target_ok(hunter)) + if(ai_controller && !ai_controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) && mafia_target_ok(hunter)) ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_on_damaged(datum/source, damage, damagetype) - SIGNAL_HANDLER - if(damage > 0 && stat == CONSCIOUS && prob(15)) - don_bark("hurt") +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_answer_grab(mob/living/grabber) + if(stat == DEAD || !bounty_mafia_is_hunter(grabber) || faction_check_atom(grabber)) + return FALSE + don_grudge |= WEAKREF(grabber) + return ..() /// Whether he may go after `target`: a hunter on their feet in his arena, in his sight, or who has hurt him or his mech (any range up to BOUNTY_MECH_GRUDGE_RANGE) -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_target_ok(mob/living/target) +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_target_ok(mob/living/target) if(!bounty_mafia_may_hurt(target) || faction_check_atom(target)) return FALSE var/turf/spot = get_turf(target) @@ -2145,16 +2112,16 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return TRUE if(distance > BOUNTY_MECH_SIGHT) return FALSE - var/area/arena = don_home_ref?.resolve() + var/area/arena = mafia_home_ref?.resolve() return (arena && get_area(spot) == arena) || can_see(src, target, BOUNTY_MECH_SIGHT) -/// His target: the one he has while they're fair game, else the nearest he can see, else the nearest in his arena -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_find_target() +/// His target: the one he has while they're fair game, else the nearest he can see, else the nearest in his arena or holding his grudge +/mob/living/carbon/human/npc/bounty_mafia/don/mafia_find_target() var/datum/ai_controller/controller = ai_controller if(!controller) return null var/mob/living/current = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] - if(don_target_ok(current)) + if(mafia_target_ok(current)) return current current = null var/turf/here = get_turf(src) @@ -2167,7 +2134,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) if(grudged) candidates |= grudged for(var/mob/living/person in candidates) - if(!don_target_ok(person)) + if(!mafia_target_ok(person)) continue var/seen = can_see(src, person, BOUNTY_MECH_SIGHT) var/distance = get_dist(src, person) @@ -2182,7 +2149,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return current /// The tile behind his wreck from `threat`, if it's open and in his arena -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_cover_turf(atom/threat) +/mob/living/carbon/human/npc/bounty_mafia/don/proc/don_cover_turf(atom/threat) var/obj/structure/bounty_mafia_wreck/wreck = don_wreck_ref?.resolve() if(QDELETED(wreck) || !threat) return null @@ -2190,7 +2157,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) var/away = get_dir(threat, wreck_spot) if(!wreck_spot || !away) return null - var/area/arena = don_home_ref?.resolve() + var/area/arena = mafia_home_ref?.resolve() for(var/direction in list(away, turn(away, 45), turn(away, -45))) var/turf/spot = get_step(wreck_spot, direction) if(!spot || isclosedturf(spot) || spot.is_blocked_turf(exclude_mobs = TRUE)) @@ -2200,16 +2167,11 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) return spot return null -/// One attack on `target`: a shot if he has a line, the pistol butt if he's next to them. TRUE if he attacked. -/mob/living/basic/bounty_lair_boss/mafia_don/proc/don_attack(mob/living/target) - if(!don_can_act() || don_busy || !don_target_ok(target)) - return FALSE - face_atom(target) - if(don_gun.ready() && can_see(src, target, BOUNTY_MECH_SIGHT) && don_gun.try_fire(target)) - return TRUE - if(Adjacent(target) && world.time >= next_move && !don_gun.winding) - return melee_attack(target) - return FALSE +/// Out of his garage on his own feet with nobody to fight: he walks back to where he climbed out. Returns that turf, or null. +/mob/living/carbon/human/npc/bounty_mafia/don/proc/don_head_home() + if(mafia_at_home() || !mafia_home_turf) + return null + return mafia_home_turf // ===== THE TROPHY ===== @@ -2245,30 +2207,164 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) . = ..() controller.clear_blackboard_key(target_key) +/** + * A club gunman's volley at its target, through its hands: the aim first if its gun has one (mafia_start_aim(), + * "aiming!"), then mafia_volley pulls of the trigger (controller.ai_interact(), the gun's own fire path, burst and + * fire rate) mafia_volley_gap apart, then mafia_cooldown before the next. A hit during the aim spoils it for a gun + * that fumbles. It checks its line of fire before each pull and steps aside rather than shoot through a friend. + */ +/datum/ai_behavior/bounty_mafia_fire + action_cooldown = 0.1 SECONDS + behavior_flags = AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + +/datum/ai_behavior/bounty_mafia_fire/setup(datum/ai_controller/controller, target_key) + . = ..() + var/mob/living/carbon/human/npc/bounty_mafia/shooter = controller.pawn + var/atom/target = controller.blackboard[target_key] + if(!istype(shooter) || QDELETED(target) || isnull(human_npc_loaded_gun(shooter))) + return FALSE + if(world.time < shooter.mafia_next_volley_at) + return FALSE + shooter.mafia_aim_until = 0 + shooter.mafia_aim_spoiled = FALSE + shooter.mafia_volley_left = 0 + shooter.mafia_next_pull_at = 0 + if(shooter.mafia_windup && !(shooter.mafia_windup_once && shooter.mafia_raised)) + shooter.mafia_start_aim(target) + else + shooter.mafia_volley_left = shooter.mafia_volley + return TRUE + +/datum/ai_behavior/bounty_mafia_fire/perform(seconds_per_tick, datum/ai_controller/controller, target_key) + var/mob/living/carbon/human/npc/bounty_mafia/shooter = controller.pawn + var/atom/target = controller.blackboard[target_key] + if(QDELETED(target) || !shooter.mafia_can_act() || HAS_TRAIT(shooter, TRAIT_HANDS_BLOCKED)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + shooter.face_atom(target) + if(shooter.mafia_aim_until) + if(shooter.mafia_aim_spoiled) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(world.time < shooter.mafia_aim_until) + return AI_BEHAVIOR_DELAY + shooter.mafia_aim_until = 0 + shooter.mafia_raised = TRUE + shooter.mafia_volley_left = shooter.mafia_volley + var/obj/item/gun/gun = human_npc_loaded_gun(shooter) + if(isnull(gun) || shooter.mafia_volley_left <= 0) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(world.time < shooter.mafia_next_pull_at) + return AI_BEHAVIOR_DELAY + if(human_npc_ally_in_line(shooter, target)) + human_npc_sidestep(shooter, target) + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(shooter, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + shooter.zone_selected = human_npc_pick_aim_zone() + controller.ai_interact(target = target, combat_mode = TRUE) + shooter.mafia_volley_left-- + shooter.mafia_next_pull_at = world.time + shooter.mafia_volley_gap + if(shooter.mafia_volley_left <= 0) + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/bounty_mafia_fire/finish_action(datum/ai_controller/controller, succeeded, target_key) + . = ..() + var/mob/living/carbon/human/npc/bounty_mafia/shooter = controller.pawn + if(!istype(shooter)) + return + var/pause = shooter.mafia_aim_spoiled ? BOUNTY_MOBSTER_FUMBLE : shooter.mafia_cooldown + shooter.mafia_next_volley_at = max(shooter.mafia_next_volley_at, world.time + pause) + shooter.mafia_aim_until = 0 + shooter.mafia_aim_spoiled = FALSE + shooter.mafia_volley_left = 0 + +/// The player's reload (human_npc_reload), with the club's sound and lines as the hand goes for the magazine +/datum/ai_behavior/human_npc_reload/bounty_mafia + +/datum/ai_behavior/human_npc_reload/bounty_mafia/setup(datum/ai_controller/controller) + . = ..() + if(!.) + return + var/mob/living/carbon/human/npc/bounty_mafia/member = controller.pawn + if(istype(member)) + member.mafia_on_reload() + +/// Brute's fists: tg's melee, listening for his punch landing on the target for the length of the blow +/datum/ai_behavior/basic_melee_attack/human_npc/bounty_mafia_brute + +/datum/ai_behavior/basic_melee_attack/human_npc/bounty_mafia_brute/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/brute = controller.pawn + var/mob/living/carbon/human/victim = controller.blackboard[target_key] + if(!istype(brute) || !ishuman(victim) || QDELETED(victim)) + return ..() + brute.RegisterSignal(victim, COMSIG_HUMAN_GOT_PUNCHED, TYPE_PROC_REF(/mob/living/carbon/human/npc/bounty_mafia/lieutenant, mafia_on_punch_landed), override = TRUE) + . = ..() + brute.UnregisterSignal(victim, COMSIG_HUMAN_GOT_PUNCHED) + +/** + * The bounty AI's breaking free (bounty_escape_captivity: cuffs, a locker, a hard grab, a carry), and any hold a + * hunter has on a club member, a passive grab included: grabbed, a goon breaks free. It resists the tg way (resist()). + */ +/datum/ai_planning_subtree/bounty_escape_captivity/bounty_mafia + +/datum/ai_planning_subtree/bounty_escape_captivity/bounty_mafia/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + . = ..() + if(. == SUBTREE_RETURN_FINISH_PLANNING) + return + var/mob/living/carbon/human/npc/bounty_mafia/member = controller.pawn + if(!istype(member) || member.stat != CONSCIOUS) + return + var/mob/living/holder = member.pulledby || (ismob(member.buckled) ? member.buckled : null) + if(!holder || !member.mafia_answer_grab(holder)) + return + controller.queue_behavior(/datum/ai_behavior/resist) + return SUBTREE_RETURN_FINISH_PLANNING + // ----- goons ----- +/** + * The club's people on human bodies: soft crit first (crawl, plead, a boss fires wild from the floor), then breaking + * free of whatever holds them, then the family's own mind. + */ /datum/ai_controller/basic_controller/bounty_mafia - blackboard = list() + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/human_npc, + BB_TARGET_MINIMUM_STAT = HUMAN_NPC_SPARES_DOWNED, + BB_AGGRO_RANGE = BOUNTY_MOBSTER_SIGHT, + BB_HUMAN_NPC_SHOT_DELAY = BOUNTY_MOBSTER_PISTOL_COOLDOWN, + BB_HUMAN_NPC_FIRE_RANGE = BOUNTY_MOBSTER_SIGHT, + ) ai_movement = /datum/ai_movement/basic_avoidance idle_behavior = null planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity/bounty_mafia, /datum/ai_planning_subtree/bounty_mafia_goon, ) +/datum/ai_controller/basic_controller/bounty_mafia/TryPossessPawn(atom/new_pawn) + if(!ishumannpc(new_pawn)) + return AI_CONTROLLER_INCOMPATIBLE + return ..() + +// Cuffs and soft crit don't stop a person's AI (human_npc_soft_crit.dm) +/datum/ai_controller/basic_controller/bounty_mafia/get_able_to_run() + return human_npc_ai_able_to_run(src) + +/datum/ai_controller/basic_controller/bounty_mafia/on_stat_changed(mob/living/source, new_stat) + . = ..() + human_npc_ai_stat_changed(src, new_stat) + /** - * A goon's whole mind: back into its room if it's out; its target; nothing until its room's shout is over; - * then shoot with a line (or stab when next to them), else close in as far as its room lets it. + * A goon's whole mind: its target; nothing until its room's shout is over; then reload, shoot with a line (or stab + * or punch when next to them), else close in as far as its room lets it; with nobody to fight, back into its room. */ /datum/ai_planning_subtree/bounty_mafia_goon /datum/ai_planning_subtree/bounty_mafia_goon/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/trooper/russian/mafia/goon = controller.pawn + var/mob/living/carbon/human/npc/bounty_mafia/goon = controller.pawn if(!istype(goon) || goon.stat != CONSCIOUS) return SUBTREE_RETURN_FINISH_PLANNING - // Grabbed by a hunter: that's a fight. He's the target, and it breaks free first. - if(goon.mafia_answer_grab()) - controller.queue_behavior(/datum/ai_behavior/resist) - return SUBTREE_RETURN_FINISH_PLANNING if(!goon.mafia_can_act()) return SUBTREE_RETURN_FINISH_PLANNING var/at_home = goon.mafia_at_home() @@ -2278,10 +2374,8 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) goon.face_atom(target) return SUBTREE_RETURN_FINISH_PLANNING if(target) - var/has_line = goon.mafia_gun ? can_see(goon, target, BOUNTY_MOBSTER_SIGHT) : goon.Adjacent(target) - if(has_line) + if(goon.mafia_plan_attack(controller, target)) goon.mafia_away_since = 0 - controller.queue_behavior(/datum/ai_behavior/bounty_mafia_goon_attack, BB_BASIC_MOB_CURRENT_TARGET) return SUBTREE_RETURN_FINISH_PLANNING if(at_home) if(goon.mafia_step_ok(target)) @@ -2293,22 +2387,13 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) controller.set_blackboard_key(BB_BOUNTY_MAFIA_MOVE_TO, exit) controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach/onto, BB_BOUNTY_MAFIA_MOVE_TO) return SUBTREE_RETURN_FINISH_PLANNING + // Nobody it can get at from here: out of its room, it walks back var/turf/home = goon.mafia_head_home() if(home) controller.set_blackboard_key(BB_BOUNTY_MAFIA_MOVE_TO, home) controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach/onto, BB_BOUNTY_MAFIA_MOVE_TO) return SUBTREE_RETURN_FINISH_PLANNING -/datum/ai_behavior/bounty_mafia_goon_attack - -/datum/ai_behavior/bounty_mafia_goon_attack/perform(seconds_per_tick, datum/ai_controller/controller, target_key) - var/mob/living/basic/trooper/russian/mafia/goon = controller.pawn - var/mob/living/target = controller.blackboard[target_key] - if(!istype(goon) || QDELETED(target)) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - goon.mafia_attack(target) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED - // ----- the mech ----- /datum/ai_controller/basic_controller/bounty_mafia_mech @@ -2358,44 +2443,50 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) // ----- the don ----- -/datum/ai_controller/basic_controller/bounty_mafia_don - blackboard = list() - ai_movement = /datum/ai_movement/basic_avoidance - idle_behavior = null +/datum/ai_controller/basic_controller/bounty_mafia/don + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/human_npc, + BB_TARGET_MINIMUM_STAT = HUMAN_NPC_SPARES_DOWNED, + BB_AGGRO_RANGE = BOUNTY_MECH_SIGHT, + BB_HUMAN_NPC_SHOT_DELAY = BOUNTY_DON_COOLDOWN, + BB_HUMAN_NPC_FIRE_RANGE = BOUNTY_MECH_SIGHT, + ) planning_subtrees = list( + /datum/ai_planning_subtree/human_npc_soft_crit, + /datum/ai_planning_subtree/bounty_escape_captivity/bounty_mafia, /datum/ai_planning_subtree/bounty_mafia_don, ) -/// The don's mind: nothing while he reels; his target; behind the wreck while he reloads; shoot with a line; else closer +/** + * The don's mind: nothing while he gets his bearings; his target; behind the wreck to reload; shoot with a line, or + * his fists up close; else closer. With nobody to fight, back into his garage if he was taken out of it. + */ /datum/ai_planning_subtree/bounty_mafia_don /datum/ai_planning_subtree/bounty_mafia_don/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/bounty_lair_boss/mafia_don/don = controller.pawn - if(!istype(don) || don.don_busy || !don.don_can_act()) + var/mob/living/carbon/human/npc/bounty_mafia/don/don = controller.pawn + if(!istype(don) || don.stat != CONSCIOUS) + return SUBTREE_RETURN_FINISH_PLANNING + if(world.time < don.don_dazed_until || !don.mafia_can_act()) return SUBTREE_RETURN_FINISH_PLANNING - var/mob/living/target = don.don_find_target() + var/mob/living/target = don.mafia_find_target() if(!target) + var/turf/home = don.don_head_home() + if(home) + controller.set_blackboard_key(BB_BOUNTY_MAFIA_MOVE_TO, home) + controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach/onto, BB_BOUNTY_MAFIA_MOVE_TO) return SUBTREE_RETURN_FINISH_PLANNING - if(don.don_gun.is_reloading()) + var/obj/item/gun/gun = human_npc_held_gun(don) + if(gun && !gun.can_shoot() && !LAZYLEN(don.do_afters) && world.time >= controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT] && human_npc_can_reload(don, gun)) + // Behind the wreck first, then the fresh magazine var/turf/cover = don.don_cover_turf(target) if(cover && get_turf(don) != cover) controller.set_blackboard_key(BB_BOUNTY_MAFIA_MOVE_TO, cover) controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach/onto, BB_BOUNTY_MAFIA_MOVE_TO) return SUBTREE_RETURN_FINISH_PLANNING - if(don.Adjacent(target) || can_see(don, target, BOUNTY_MECH_SIGHT)) - controller.queue_behavior(/datum/ai_behavior/bounty_mafia_don_attack, BB_BASIC_MOB_CURRENT_TARGET) - else - controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach, BB_BASIC_MOB_CURRENT_TARGET) + if(don.mafia_plan_attack(controller, target)) + return SUBTREE_RETURN_FINISH_PLANNING + controller.queue_behavior(/datum/ai_behavior/bounty_mafia_approach, BB_BASIC_MOB_CURRENT_TARGET) return SUBTREE_RETURN_FINISH_PLANNING -/datum/ai_behavior/bounty_mafia_don_attack - -/datum/ai_behavior/bounty_mafia_don_attack/perform(seconds_per_tick, datum/ai_controller/controller, target_key) - var/mob/living/basic/bounty_lair_boss/mafia_don/don = controller.pawn - var/mob/living/target = controller.blackboard[target_key] - if(!istype(don) || QDELETED(target)) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - don.don_attack(target) - return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED - #undef BOUNTY_MAFIA_BUSY_TRAIT diff --git a/voidcrew/modules/bounties/bounty_lair_types.dm b/voidcrew/modules/bounties/bounty_lair_types.dm index 3abadc197f3..f4406c2c0b5 100644 --- a/voidcrew/modules/bounties/bounty_lair_types.dm +++ b/voidcrew/modules/bounties/bounty_lair_types.dm @@ -19,30 +19,32 @@ /// Where a lair's boss appears when the interior loads. Placed on the map (P11); P10 spawns boss_type there. /obj/effect/landmark/bounty_lair_boss - /// The boss to spawn here (a /mob/living/basic/bounty_lair_boss path), set on the map + /// The boss to spawn here, set on the map: a /mob/living/basic/bounty_lair_boss path, or a human NPC boss (built at once, it is one body) var/boss_type /// A lair's gatekeeper spot. P10 spawns gatekeeper_type here at load; when every gatekeeper in the lair is dead, P10 opens the lair's gate poddoors (mapped with id "bounty_lair_gate"). /obj/effect/landmark/bounty_lair_gatekeeper - /// The mob to spawn here as a gatekeeper, set on the map (the mafia lieutenant for the club) + /// The mob to spawn here as a gatekeeper, set on the map (the mafia lieutenant for the club). A human NPC is queued (spawn_human_npc()). var/gatekeeper_type -/// The base of lair bosses: never under /mob/living/basic/boss (megafauna are banned from ruins and outposts). P12 sets the mafia ones. +/// The base of lair bosses that aren't people: never under /mob/living/basic/boss (megafauna are banned from ruins and outposts). P12 sets the mafia one. /mob/living/basic/bounty_lair_boss /// Weakref to the posting (/datum/criminal_bounty), or null for an admin spawn var/datum/weakref/posting_ref -/// The don in his mech (phase one). P12. +/// The don in his mech (phase one): a machine, so it stays a mob of its own (owner call OC-5). P12. /mob/living/basic/bounty_lair_boss/mafia_mech -/// The don on foot after the mech breaks (phase two). His death drops the trophy. P12. -/mob/living/basic/bounty_lair_boss/mafia_don -/// Mafia goons for the club. P12 sets them (lootless, club outfits); P11 places their markers. -/mob/living/basic/trooper/russian/mafia -/mob/living/basic/trooper/russian/mafia/pistol -/mob/living/basic/trooper/russian/mafia/smg +/// The club's people: real human NPCs. The base is the knife man; P12 sets them (club outfits, real guns); P11 places their markers. +/mob/living/carbon/human/npc/bounty_mafia +/mob/living/carbon/human/npc/bounty_mafia/pistol +/mob/living/carbon/human/npc/bounty_mafia/smg /// Tougher; their deaths open the arena (P10's gate rule). P12. -/mob/living/basic/trooper/russian/mafia/lieutenant +/mob/living/carbon/human/npc/bounty_mafia/lieutenant +/// The don on foot after the mech breaks (phase two), a person. His death drops the trophy. P12. +/mob/living/carbon/human/npc/bounty_mafia/don + /// Weakref to the posting (/datum/criminal_bounty) he is wanted on, or null for an admin spawn + var/datum/weakref/posting_ref /// Room spawner for the club's goons. P12 sets its tables; P11 places it on the map (the loot test requires every zone_mobs subtype to appear in a ruin map). /obj/effect/zone_mobs/mafia diff --git a/voidcrew/modules/bounties/bounty_outfits.dm b/voidcrew/modules/bounties/bounty_outfits.dm new file mode 100644 index 00000000000..cb8ff1c6da0 --- /dev/null +++ b/voidcrew/modules/bounties/bounty_outfits.dm @@ -0,0 +1,124 @@ +/** + * # Bounty hunting: what criminals and their people wear + * + * Owner: P2 body (the table, the base clothes and the tier armour). The families dress themselves by + * overriding bounty_build_outfit(): P3 meek and normal criminals and companions, P4 the mini-boss kits, + * P6 decoys, P9 the kingpin and his goons, P12 the mafia club. + * + * Toughness comes from what they wear (defaults 1.5, CR-12): petty criminals in plain clothes, Wanted in + * an armour vest, Most Wanted in a vest and a helmet, mini-bosses in their kit's armour. It is real + * armour on real slots, locked to the body (D3), so it protects by tg's armour rules and never drops. + * Their weapons are carried, not held, until the fight starts, with real spare ammunition; their + * self-treatment items are real (N6). The human NPC base adds a sealed suit and air in vacuum, a mask in + * bad air and warm clothes in the cold, on top of whatever is picked here (human_npc_outfits.dm). + */ + +/** + * The outfit table of every bounty human: it asks the NPC being dressed (bounty_build_outfit()), so + * the choice can read its record, its tier, its style and its kit. Set as npc_outfit_table. + */ +/datum/human_npc_outfit_table/bounty + vacuum_layer = /datum/human_npc_layer/eva + +/datum/human_npc_outfit_table/bounty/pick_outfit(mob/living/carbon/human/npc/npc, zone) + if(!ishumannpc(npc)) + return null + return npc.bounty_build_outfit(zone || ZONE_GREEN) + +/** + * What a bounty human wears in `zone` (ZONE_*): a /datum/outfit typepath, or an instance made for it + * (bounty_outfit_instance()). Null leaves it in whatever its look brings. Every bounty family overrides it. + */ +/mob/living/carbon/human/npc/proc/bounty_build_outfit(zone) + return null + +/// A fresh outfit of `base_type`, to add weapons, armour and kit to before it is worn +/proc/bounty_outfit_instance(base_type) + if(!ispath(base_type, /datum/outfit)) + return null + return new base_type() + +/** + * Puts the armour of `tier` (BOUNTY_TIER_*) on `outfit`, an instance: Wanted an armour vest, Most Wanted + * a vest and a helmet, Petty none. Anything the old suit carried in its storage moves to the backpack. + */ +/proc/bounty_add_tier_armour(datum/outfit/outfit, tier) + if(!istype(outfit) || tier == BOUNTY_TIER_PETTY || isnull(tier)) + return outfit + if(outfit.suit_store) + if(outfit.back) + LAZYADDASSOC(outfit.backpack_contents, outfit.suit_store, 1) + outfit.suit_store = null + outfit.suit = /obj/item/clothing/suit/armor/vest + if(tier == BOUNTY_TIER_MOST_WANTED) + outfit.head = /obj/item/clothing/head/helmet + return outfit + +/// The plain clothes a criminal of `archetype` (BOUNTY_ARCHETYPE_*) wears in `zone` +/proc/bounty_plain_clothes(archetype, zone) + var/meek = archetype == BOUNTY_ARCHETYPE_MEEK + switch(zone) + if(ZONE_RED) + return meek ? /datum/outfit/bounty_criminal/drifter : /datum/outfit/bounty_criminal/tough + if(ZONE_YELLOW) + return meek ? /datum/outfit/bounty_criminal/spacer : /datum/outfit/bounty_criminal/hauler + return meek ? /datum/outfit/bounty_criminal : /datum/outfit/bounty_criminal/local + +// A criminal with no family of its own to dress it: plain clothes for its archetype and zone, and its tier's armour. +/mob/living/carbon/human/npc/bounty_criminal/bounty_build_outfit(zone) + var/datum/outfit/outfit = bounty_outfit_instance(bounty_plain_clothes(record?.archetype, zone)) + return bounty_add_tier_armour(outfit, record?.tier) + +/// ZONE_GREEN, ZONE_YELLOW or ZONE_RED for where its site is (or where it stands), green if unknown +/mob/living/carbon/human/npc/bounty_criminal/proc/body_zone() + var/atom/site = body_site_ref?.resolve() + var/zone = site ? SSovermap_zones?.get_zone_type(get_turf(site)) : SSovermap_zones?.get_zone_type_anywhere(get_turf(src)) + return zone || ZONE_GREEN + +// ===== PLAIN CLOTHES ===== + +/// Plain clothes for a meek criminal in green space +/datum/outfit/bounty_criminal + name = "Bounty criminal: civilian" + uniform = /obj/item/clothing/under/pants/jeans + suit = /obj/item/clothing/suit/toggle/jacket/sweater + shoes = /obj/item/clothing/shoes/sneakers/brown + +/// A meek criminal in yellow space +/datum/outfit/bounty_criminal/spacer + name = "Bounty criminal: spacer" + uniform = /obj/item/clothing/under/color/grey + suit = /obj/item/clothing/suit/jacket/bomber + shoes = /obj/item/clothing/shoes/workboots + head = /obj/item/clothing/head/beanie + +/// A meek criminal in red space +/datum/outfit/bounty_criminal/drifter + name = "Bounty criminal: drifter" + uniform = /obj/item/clothing/under/color/black + suit = /obj/item/clothing/suit/jacket/leather + shoes = /obj/item/clothing/shoes/jackboots + head = /obj/item/clothing/head/soft/grey + +/// A normal criminal in green space +/datum/outfit/bounty_criminal/local + name = "Bounty criminal: local" + uniform = /obj/item/clothing/under/costume/buttondown/slacks + suit = /obj/item/clothing/suit/jacket/letterman + shoes = /obj/item/clothing/shoes/sneakers/black + +/// A normal criminal in yellow space. Overalls, not bare jeans: a Wanted criminal's vest goes over a shirt. +/datum/outfit/bounty_criminal/hauler + name = "Bounty criminal: hauler" + uniform = /obj/item/clothing/under/misc/overalls + suit = /obj/item/clothing/suit/jacket/miljacket + gloves = /obj/item/clothing/gloves/fingerless + shoes = /obj/item/clothing/shoes/workboots + +/// A normal criminal in red space, and the fallback for a mini-boss +/datum/outfit/bounty_criminal/tough + name = "Bounty criminal: tough" + uniform = /obj/item/clothing/under/color/black + suit = /obj/item/clothing/suit/jacket/leather/biker + gloves = /obj/item/clothing/gloves/fingerless + shoes = /obj/item/clothing/shoes/jackboots diff --git a/voidcrew/modules/bounties/bounty_outpost.dm b/voidcrew/modules/bounties/bounty_outpost.dm index 8d18b730997..f557e66e163 100644 --- a/voidcrew/modules/bounties/bounty_outpost.dm +++ b/voidcrew/modules/bounties/bounty_outpost.dm @@ -7,23 +7,28 @@ * tgui/packages/tgui/interfaces/WantedBoard.tsx and the tests in * voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm. Do not edit this file outside P6. * - * What it owns: identifying the fugitive (decision 16, spec section 7, abuse review section C): + * What it owns: identifying the fugitive (decision 16, spec section 7, abuse review section C). + * The fugitive and its decoys are real humans (/mob/living/carbon/human/npc, voidcrew/modules/human_npcs/): * - Blending in. The fugitive and BOUNTY_DECOY_COUNT decoys wear the zone's patron clothes and go * by the same generic name ("patron", "dockhand", "spacer"); the alias is never shown. While - * they blend in nobody can pull them, turrets leave them alone and stray shots pass through them - * (/datum/component/bounty_outpost_blend). - * - Decoys (/mob/living/basic/bounty_criminal/decoy): look-alikes with the same body and routine - * as the fugitive. Showing one the warrant, or hitting one on purpose, raises the posting's - * alert; a hit is also an outpost strike for the hitter. + * they blend in the turrets leave them alone and a hit on one is judged by who it was meant for + * (/datum/component/bounty_outpost_blend). They are people like any other: they can be grabbed, + * shot, knocked down and cuffed the way anyone can, and what they do about it is their AI's. + * - Decoys (/mob/living/carbon/human/npc/bounty_criminal/decoy): look-alikes with the fugitive's + * face (a record that shares one or two of its features), the zone's clothes, and its toughness + * and pace (body_match()). Showing one the warrant, hitting one on purpose or reaching for its + * wrists raises the posting's alert; a hit is also an outpost strike for the hitter. * - Traders' clues: any trader, the barkeep included, gives a ship hunting a fugitive there one * true clue, a different one per trader. * - The warrant (/obj/item/paper/bounty_warrant): shown to the fugitive it exposes them (P3's * on_confronted()); shown to anyone else they protest. A ship can show one every * BOUNTY_WARRANT_COOLDOWN. - * - The alert: at BOUNTY_OUTPOST_ALERT_SLIP the fugitive slips away and the bounty relists (P5's - * relist()). - * - The walk out: when the posting closes or the fugitive slips away, the decoys and an uncaught - * fugitive walk to the hangar lift and fade out (/datum/component/bounty_outpost_exit). + * - The alert: at BOUNTY_OUTPOST_ALERT_SLIP the decoys leave and the fugitive walks out to the + * hangar lift; the bounty relists at another trader outpost once it has gone (P5's relist()). A + * fugitive caught on the way is still the posting's. + * - The walk out: when the posting closes, the decoys and an uncaught fugitive walk to the hangar + * lift through the human NPC base's npc_walk_out(). They can be shot, downed and cuffed on the way + * and are deleted only on the lift or out of every player's sight (bounty_outpost_walk_out()). * - The wanted board (/obj/structure/bounty_wanted_board): a locked board on each concourse * listing the public bounties. * @@ -127,24 +132,28 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( var/list/outpost_clues_told = list() /// How many of the fugitive's features each decoy was made to share, in spawn order var/list/outpost_decoy_shares = list() + /// The decoys' human NPC orders (/datum/human_npc_order), kept so the ones not built yet can be cancelled + var/list/outpost_decoy_orders = list() /** * Sets a trader-outpost bounty up to blend in, once P5 has spawned the criminal at the outpost * (site_ref and criminal_ref set): * - the record gets its old look, so the listing's mugshot shows how they used to look (AR-C1); * - the fugitive wears the zone's clothes and the zone's generic name, and blends in; - * - BOUNTY_DECOY_COUNT decoys spawn out of sight, each sharing one or two of its features; + * - BOUNTY_DECOY_COUNT decoys spawn out of sight, each sharing one or two of its features. They are + * human NPCs, so they wait their turn in the spawn queue and join `decoys` as they are built; with + * `immediate` they are built now (tests, and callers that need them at once); * - the alert and the traders' clues start over. * * Refuses (FALSE) a mini-boss (AR-C9), an outpost that already has BOUNTY_MAX_FUGITIVES_PER_OUTPOST, * and a posting whose site is not a loaded trader outpost. Returns TRUE when set up. */ -/datum/criminal_bounty/proc/setup_outpost_blend() +/datum/criminal_bounty/proc/setup_outpost_blend(immediate = FALSE) var/obj/structure/overmap/trader_outpost/outpost = site_ref?.resolve() - var/mob/living/basic/bounty_criminal/fugitive = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = criminal() if(!istype(outpost) || !outpost.template_bottom_left || !outpost.outpost_template || !fugitive || !record) return FALSE - if(istype(fugitive, /mob/living/basic/bounty_criminal/boss) || record.archetype == BOUNTY_ARCHETYPE_BOSS) + if(istype(fugitive, /mob/living/carbon/human/npc/bounty_criminal/boss) || record.archetype == BOUNTY_ARCHETYPE_BOSS) log_game("BOUNTY: a mini-boss ([record.name]) was refused at [outpost]. Mini-bosses never go to trader outposts.") return FALSE if(!bounty_outpost_has_room(outpost, src)) @@ -189,6 +198,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( if(!fugitive.site_bounds) fugitive.site_bounds = bounty_outpost_bounds(outpost) + // Dressed in the zone's clothes by the component, which also gives it the generic name fugitive.AddComponent(/datum/component/bounty_outpost_blend, src, FALSE, display_name, display_desc, outfit) // Everyone blending in has the same even chance of heading for a bar stool, the fugitive included var/list/free_stools = bounty_outpost_free_stools(public_floor) @@ -196,7 +206,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( var/feature_count = length(record.features) for(var/turf/spot as anything in spots) - if(length(decoys) >= BOUNTY_DECOY_COUNT) + if(length(outpost_decoy_orders) >= BOUNTY_DECOY_COUNT) break // One or two of its features each, and never all of them (make_decoy_record() holds to that too) var/shared = rand(1, 2) @@ -207,16 +217,19 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( // If a line ever names a crime, it names the same one for everybody if(decoy_record) decoy_record.crime = record.crime - var/datum/npc_spawn_context/context = fugitive.bounty_profile_context() - var/mob/living/basic/bounty_criminal/decoy/decoy = spawn_npc_in_zone(/mob/living/basic/bounty_criminal/decoy, spot, context, FALSE) var/atom/anchor = (length(free_stools) && prob(50)) ? pick_n_take(free_stools) : null - decoy.outpost_become(fugitive, decoy_record, src, outpost, display_name, display_desc, outfit, anchor) - decoys += WEAKREF(decoy) + var/datum/human_npc_order/order = bounty_outpost_spawn_decoy(fugitive, decoy_record, spot, src, outpost, display_name, display_desc, outfit, anchor, CALLBACK(src, PROC_REF(outpost_decoy_built)), immediate) + outpost_decoy_orders += order RegisterSignal(src, COMSIG_BOUNTY_POSTING_CLOSED, PROC_REF(outpost_on_closed), override = TRUE) - log_game("BOUNTY: [record.name] is blending in at [outpost] as a [display_name], with [length(decoys)] decoys.") + log_game("BOUNTY: [record.name] is blending in at [outpost] as a [display_name], with [length(outpost_decoy_orders)] decoys on the way.") return TRUE +/// One of this posting's decoys has been built and made a look-alike: it is the posting's from now on +/datum/criminal_bounty/proc/outpost_decoy_built(mob/living/carbon/human/npc/bounty_criminal/decoy/decoy) + if(!QDELETED(decoy)) + decoys += WEAKREF(decoy) + /** * Raises the fugitive's alert for `reason` (BOUNTY_ALERT_*): a wrong warrant, or a hit on a decoy, * whoever did it. At BOUNTY_OUTPOST_ALERT_SLIP a fugitive still blending in slips away: it walks @@ -233,32 +246,58 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( return TRUE /** - * The fugitive has noticed too much fuss: while it is still blending in and free, it and its - * decoys leave, and the bounty relists. An exposed fugitive is already running or fighting and - * stays. Returns TRUE if it slipped away. + * The fugitive has noticed too much fuss: while it is still blending in and free, the decoys leave + * and it walks out to the hangar lift for real (bounty_outpost_walk_out()). The bounty relists at + * another trader outpost once it has gone (outpost_fugitive_left()); until then it is still this + * posting's, so a fugitive downed or cuffed on the way is as good a catch as any. An exposed + * fugitive is already running or fighting and stays. Returns TRUE if it set off. */ /datum/criminal_bounty/proc/outpost_slip_away() - var/mob/living/basic/bounty_criminal/fugitive = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = criminal() if(!fugitive || !fugitive.blended || fugitive.capture_state() != BOUNTY_STATE_FREE) return FALSE + var/datum/component/bounty_outpost_blend/blend = fugitive.GetComponent(/datum/component/bounty_outpost_blend) + if(blend?.leaving) + return FALSE var/obj/structure/overmap/trader_outpost/outpost = outpost_site?.resolve() || site_ref?.resolve() var/where = outpost ? format_text(outpost.name) : "the outpost" - log_game("BOUNTY: [record?.name] slipped away from [where].") - // Detached first (outpost_release_site()), so relist() never deletes them mid-walk. P5 shows the - // reason as "WANTED: ." - outpost_release_site() - relist("slipped away from [where]") - return TRUE + log_game("BOUNTY: [record?.name] is slipping away from [where].") + outpost_dismiss_decoys() + // P5 shows the reason as "WANTED: ." + return bounty_outpost_walk_out(fugitive, outpost, CALLBACK(src, PROC_REF(outpost_fugitive_left), "slipped away from [where]")) -/// The decoys at the current site leave and fade out, and the posting forgets them -/datum/criminal_bounty/proc/outpost_dismiss_decoys(list/floor) - var/obj/structure/overmap/trader_outpost/outpost = outpost_site?.resolve() - if(length(decoys) && outpost && isnull(floor)) - floor = bounty_outpost_public_floor(outpost) +/** + * The slipping fugitive reached the lift (or went where no player could see): the bounty relists. + * Its body goes with it, so the posting lets go of it first and its going is not read as a loss. + * Nothing happens if it stopped being the posting's meanwhile (the posting closed or relisted). + */ +/datum/criminal_bounty/proc/outpost_fugitive_left(reason, mob/living/carbon/human/npc/bounty_criminal/leaver) + if(!is_open() || board_relisting || QDELETED(leaver) || criminal() != leaver) + return + board_detach_criminal() + leaver.posting_ref = null + log_game("BOUNTY: [record?.name] slipped away.") + relist(reason) + +/** + * The decoys not built yet are cancelled and the built ones set walking out, and the posting forgets + * them all. The walk itself starts asynchronously: closing a posting must not wait on anyone's AI. + */ +/datum/criminal_bounty/proc/outpost_dismiss_decoys() + var/list/leaving = outpost_forget_decoys() + if(length(leaving)) + INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_outpost_walk_out_all), leaving, outpost_site?.resolve()) + +/// Cancels the decoys still waiting to be built and lets go of the built ones. Returns the built ones, living or dead. +/datum/criminal_bounty/proc/outpost_forget_decoys() + for(var/datum/human_npc_order/order as anything in outpost_decoy_orders) + qdel(order) + outpost_decoy_orders.Cut() + . = list() for(var/datum/weakref/decoy_ref as anything in decoys) - var/mob/living/basic/bounty_criminal/decoy = decoy_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoy_ref?.resolve() if(!QDELETED(decoy)) - bounty_outpost_walk_out(decoy, outpost, floor) + . += decoy decoys.Cut() /** @@ -270,15 +309,15 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( */ /datum/criminal_bounty/proc/outpost_release_site() var/obj/structure/overmap/trader_outpost/outpost = outpost_site?.resolve() - var/list/floor = outpost ? bounty_outpost_public_floor(outpost) : null - outpost_dismiss_decoys(floor) - var/mob/living/basic/bounty_criminal/fugitive = criminal() - if(!fugitive || !outpost || get_trader_outpost_for_turf(get_turf(fugitive)) != outpost) - return - // P5's own detach, so its deletion watch comes off too and the fade isn't read as a loss - board_detach_criminal() - fugitive.posting_ref = null - bounty_outpost_walk_out(fugitive, outpost, floor) + var/list/leaving = outpost_forget_decoys() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = criminal() + if(fugitive && outpost && get_trader_outpost_for_turf(get_turf(fugitive)) == outpost) + // P5's own detach, so its deletion watch comes off too and the walk isn't read as a loss + board_detach_criminal() + fugitive.posting_ref = null + leaving += fugitive + if(length(leaving)) + INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_outpost_walk_out_all), leaving, outpost) /// The posting closed (P5's close()): the decoys and an uncaught fugitive walk out /datum/criminal_bounty/proc/outpost_on_closed(datum/source, reason, obj/structure/overmap/ship/winner) @@ -444,7 +483,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( * `user` showed this posting's warrant to its fugitive: it stops blending in, remembers who, and * reacts as its kind does (P3's on_confronted(): a meek one bolts, a normal one fights). */ -/datum/criminal_bounty/proc/outpost_confront(mob/living/basic/bounty_criminal/fugitive, mob/living/user) +/datum/criminal_bounty/proc/outpost_confront(mob/living/carbon/human/npc/bounty_criminal/fugitive, mob/living/user) fugitive.body_add_grudge(user) var/datum/component/bounty_outpost_blend/blend = fugitive.GetComponent(/datum/component/bounty_outpost_blend) blend?.stop_blending() @@ -460,7 +499,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( * The room notices an exposed fugitive: the nearest trader who can see them, within * BOUNTY_OUTPOST_REACT_RANGE, calls out. Returns the trader, or null. */ -/datum/criminal_bounty/proc/outpost_trader_reacts(mob/living/basic/bounty_criminal/fugitive) +/datum/criminal_bounty/proc/outpost_trader_reacts(mob/living/carbon/human/npc/bounty_criminal/fugitive) var/mob/living/basic/outpost_trader/nearest var/nearest_distance = INFINITY for(var/mob/living/basic/outpost_trader/trader in view(BOUNTY_OUTPOST_REACT_RANGE, fugitive)) @@ -478,7 +517,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( * `user` showed this posting's warrant to the wrong person, `target`: they protest and show their * papers. At this posting's trader outpost, it raises the alert. */ -/datum/criminal_bounty/proc/outpost_wrong_accusation(mob/living/basic/bounty_criminal/target, mob/living/user) +/datum/criminal_bounty/proc/outpost_wrong_accusation(mob/living/carbon/human/npc/bounty_criminal/target, mob/living/user) target.outpost_protest(user) to_chat(user, span_notice("Their papers are in order. Not who you're after.")) var/obj/structure/overmap/trader_outpost/outpost = outpost_site?.resolve() @@ -492,7 +531,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( * Someone showed them a warrant that isn't theirs: they protest, and show a docking pass that * checks out. Nothing else happens to them. */ -/mob/living/basic/bounty_criminal/proc/outpost_protest(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/proc/outpost_protest(mob/user) if(stat != CONSCIOUS) return // Forced: a protest always comes out, whatever was said a moment ago (P1's third argument) @@ -505,99 +544,84 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( // DECOYS // ========================================================================= -// Built-in values, P6's. Everything that could tell a decoy from its fugitive is copied from the -// fugitive when the decoy is made (outpost_match_body()), whatever the fugitive's kind. -/mob/living/basic/bounty_criminal/decoy +// Built-in values, P6's. A decoy is a person like the fugitive: the same species, build and face but +// for the features it was made not to share, the zone's clothes, and the fugitive's toughness and pace +// (body_match()). It carries no weapon and never fights. +/mob/living/carbon/human/npc/bounty_criminal/decoy name = "patron" desc = "Another customer passing through." - maxHealth = BOUNTY_MEEK_HEALTH - health = BOUNTY_MEEK_HEALTH + /// Its fixed toughness is its fugitive's (body_match()), never its own record's tier + body_tier_toughness = FALSE + /// Nobody's posting cleans a dead decoy up: the corpse sweep does + npc_corpse_lifetime = HUMAN_NPC_CORPSE_LIFETIME /// Barks when hit are rate-limited on their own, beside bounty_say()'s cooldown COOLDOWN_DECLARE(outpost_bark_cooldown) - /// How long its fugitive stays down (P2's body_recovery_time()), so they get up together; null before it is made one - var/outpost_recovery_time - /// Its fugitive's stamina pool (P2's body_stamina_pool()); null before it is made one - var/outpost_stamina_pool /** - * Makes this decoy a look-alike of `fugitive` wanted on `posting` at `outpost`: `decoy_record`'s - * face in the zone's `outfit`, the fugitive's body and routine, the zone's generic name, doing - * what the fugitive does (at `anchor`, a bar stool, or nothing). The decoy is NOT linked to the - * posting through posting_ref: it is not the criminal, and nothing the pad, the proof of death or - * the board does may treat it as one. The admin panel's decoys should come through here too. + * A decoy made any other way than bounty_outpost_spawn_decoy() still dresses as a customer of the zone + * it was made in, never as a criminal: no gun, no armour. */ -/mob/living/basic/bounty_criminal/decoy/proc/outpost_become(mob/living/basic/bounty_criminal/fugitive, datum/bounty_record/decoy_record, datum/criminal_bounty/posting, obj/structure/overmap/trader_outpost/outpost, display_name, display_desc, outfit, atom/anchor) - record = decoy_record - if(decoy_record?.gender) - gender = decoy_record.gender - site_bounds = bounty_outpost_bounds(outpost) - outpost_match_body(fugitive) - // The same routine as the fugitive's (AR-C6): the same AI controller type, so start_activity() - // sets the same blend-in routine on both. - var/datum/ai_controller/fugitive_controller = fugitive.ai_controller - if(istype(fugitive_controller)) - var/datum/ai_controller/current = ai_controller - if(!istype(current) || current.type != fugitive_controller.type) - if(istype(current)) - QDEL_NULL(ai_controller) - var/controller_type = fugitive_controller.type - new controller_type(src) - AddComponent(/datum/component/bounty_outpost_blend, posting, TRUE, display_name, display_desc, outfit) - start_activity(BOUNTY_ACTIVITY_BLEND, anchor) +/mob/living/carbon/human/npc/bounty_criminal/decoy/bounty_build_outfit(zone) + switch(zone) + if(ZONE_YELLOW) + return /datum/outfit/bounty_outpost_dockhand + if(ZONE_RED) + return /datum/outfit/bounty_outpost_spacer + return /datum/outfit/bounty_outpost_patron /** - * Copies everything about `fugitive`'s body that a player could compare (AR-C6): P2's body_match() - * (health, pace, stamina and how it slows), how long it stays down, its size, how it breathes and - * talks, and how it reacts to a touch. + * Builds a decoy for `fugitive` at `spot`: a human NPC with `decoy_record`'s face in the zone's + * `outfit` and generic name, in its fugitive's faction, in the spawn queue (SShuman_npcs) unless + * `immediate`. Once built it is made a look-alike (outpost_become()) and `on_built` is called with it. + * Returns the order; qdel() it to cancel a decoy not built yet. A decoy built for a posting that has + * closed, moved on or lost its fugitive in the meantime is deleted unseen. */ -/mob/living/basic/bounty_criminal/decoy/proc/outpost_match_body(mob/living/basic/bounty_criminal/fugitive) - outpost_recovery_time = fugitive.body_recovery_time() - outpost_stamina_pool = fugitive.body_stamina_pool() +/proc/bounty_outpost_spawn_decoy(mob/living/carbon/human/npc/bounty_criminal/fugitive, datum/bounty_record/decoy_record, turf/spot, datum/criminal_bounty/posting, obj/structure/overmap/trader_outpost/outpost, display_name, display_desc, outfit, atom/anchor, datum/callback/on_built, immediate = FALSE) + var/datum/npc_spawn_context/context = new(outpost || spot, null, "ordinary", source_turf = get_turf(outpost || spot)) + var/datum/callback/finish = CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(bounty_outpost_finish_decoy), WEAKREF(fugitive), decoy_record, posting ? WEAKREF(posting) : null, outpost ? WEAKREF(outpost) : null, display_name, display_desc, outfit, anchor ? WEAKREF(anchor) : null, on_built) + return spawn_human_npc(/mob/living/carbon/human/npc/bounty_criminal/decoy, spot, finish, look = decoy_record, outfit = outfit, name = display_name, faction = fugitive?.faction, context = context, immediate = immediate) + +/// A decoy has just been built (bounty_outpost_spawn_decoy()): checks it is still wanted, then makes it a look-alike +/proc/bounty_outpost_finish_decoy(datum/weakref/fugitive_ref, datum/bounty_record/decoy_record, datum/weakref/posting_ref, datum/weakref/outpost_ref, display_name, display_desc, outfit, datum/weakref/anchor_ref, datum/callback/on_built, mob/living/carbon/human/npc/bounty_criminal/decoy/decoy) + if(QDELETED(decoy)) + return + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = fugitive_ref?.resolve() + var/datum/criminal_bounty/posting = posting_ref?.resolve() + var/obj/structure/overmap/trader_outpost/outpost = outpost_ref?.resolve() + if(QDELETED(fugitive) || (posting_ref && (QDELETED(posting) || !posting.is_open() || posting.outpost_site?.resolve() != outpost))) + qdel(decoy) + return + decoy.outpost_become(fugitive, decoy_record, posting, outpost, display_name, display_desc, outfit, anchor_ref?.resolve()) + on_built?.Invoke(decoy) + +/** + * Makes this decoy, already built in the zone's clothes with its record's face, a look-alike of + * `fugitive` wanted on `posting` at `outpost`: the fugitive's toughness and pace (body_match()), the + * zone's generic name and description, blending in like the fugitive and doing what it does (at + * `anchor`, a bar stool, or nothing). The decoy is NOT linked to the posting through posting_ref: it + * is not the criminal, and nothing the pad, the proof of death or the board does may treat it as + * one. `outfit` is the zone's clothes: a decoy built another way has its own put right. + */ +/mob/living/carbon/human/npc/bounty_criminal/decoy/proc/outpost_become(mob/living/carbon/human/npc/bounty_criminal/fugitive, datum/bounty_record/decoy_record, datum/criminal_bounty/posting, obj/structure/overmap/trader_outpost/outpost, display_name, display_desc, outfit, atom/anchor) + record = decoy_record + body_set_site(outpost) + // The concourse itself, whatever the site gave it + var/list/bounds = bounty_outpost_bounds(outpost) + if(bounds) + site_bounds = bounds body_match(fugitive) - damage_coeff = fugitive.damage_coeff.Copy() - max_stamina = fugitive.max_stamina - stamina_crit_threshold = fugitive.stamina_crit_threshold - stamina_recovery = fugitive.stamina_recovery - status_flags = fugitive.status_flags - mob_size = fugitive.mob_size - faction = LAZYCOPY(fugitive.faction) - habitable_atmos = fugitive.habitable_atmos?.Copy() - unsuitable_atmos_damage = fugitive.unsuitable_atmos_damage - minimum_survivable_temperature = fugitive.minimum_survivable_temperature - maximum_survivable_temperature = fugitive.maximum_survivable_temperature - unsuitable_cold_damage = fugitive.unsuitable_cold_damage - unsuitable_heat_damage = fugitive.unsuitable_heat_damage - speak_emote = fugitive.speak_emote?.Copy() - verb_say = fugitive.verb_say - verb_ask = fugitive.verb_ask - verb_exclaim = fugitive.verb_exclaim - verb_yell = fugitive.verb_yell - verb_whisper = fugitive.verb_whisper - bubble_icon = fugitive.bubble_icon - response_help_continuous = fugitive.response_help_continuous - response_help_simple = fugitive.response_help_simple - response_disarm_continuous = fugitive.response_disarm_continuous - response_disarm_simple = fugitive.response_disarm_simple - response_harm_continuous = fugitive.response_harm_continuous - response_harm_simple = fugitive.response_harm_simple - attacked_sound = fugitive.attacked_sound - -// Down as long as its fugitive would be (M2): a meek fugitive can't give itself away by getting up first. -/mob/living/basic/bounty_criminal/decoy/body_recovery_time() - return isnull(outpost_recovery_time) ? ..() : outpost_recovery_time - -/mob/living/basic/bounty_criminal/decoy/body_stamina_pool() - return isnull(outpost_stamina_pool) ? ..() : outpost_stamina_pool + AddComponent(/datum/component/bounty_outpost_blend, posting, TRUE, display_name, display_desc, outfit) + start_activity(BOUNTY_ACTIVITY_BLEND, anchor) /// A decoy never fights anyone (P2's gate on attacks) -/mob/living/basic/bounty_criminal/decoy/may_attack(atom/target) +/mob/living/carbon/human/npc/bounty_criminal/decoy/may_attack(atom/target) return FALSE /** - * Confronting a decoy, whoever does it (a wrong warrant, cuffs reached for, any other package): a - * wrong accusation, with its cost. It protests, and at its outpost the posting's alert rises (C1). + * Confronting a decoy, whoever does it (a wrong warrant, any other package): a wrong accusation, + * with its cost. It protests, and at its outpost the posting's alert rises (C1). */ -/mob/living/basic/bounty_criminal/decoy/on_confronted(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/decoy/on_confronted(mob/user) var/datum/component/bounty_outpost_blend/blend = GetComponent(/datum/component/bounty_outpost_blend) var/datum/criminal_bounty/posting = blend?.posting_ref?.resolve() if(blend?.leaving || QDELETED(posting) || !isliving(user) || !user.mind) @@ -605,15 +629,16 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( return posting.outpost_wrong_accusation(src, user) -/// Someone hit it on purpose: a bark, then it backs off from them for a few seconds -/mob/living/basic/bounty_criminal/decoy/proc/outpost_react_to_hit(mob/living/attacker) +/// Someone hit it on purpose: a bark, then it backs off from them for a few seconds (not while it is walking out) +/mob/living/carbon/human/npc/bounty_criminal/decoy/proc/outpost_react_to_hit(mob/living/attacker) if(stat != CONSCIOUS || is_downed() || is_restrained()) return if(COOLDOWN_FINISHED(src, outpost_bark_cooldown)) COOLDOWN_START(src, outpost_bark_cooldown, 8 SECONDS) if(!bounty_say("hurt")) say(pick(GLOB.bounty_decoy_hurt_lines), forced = "bounty decoy") - if(QDELETED(attacker) || !isturf(loc) || HAS_TRAIT(src, TRAIT_IMMOBILIZED)) + var/datum/component/bounty_outpost_blend/blend = GetComponent(/datum/component/bounty_outpost_blend) + if(blend?.leaving || QDELETED(attacker) || !isturf(loc) || HAS_TRAIT(src, TRAIT_IMMOBILIZED)) return GLOB.move_manager.move_away(src, attacker, BOUNTY_DECOY_FLEE_DISTANCE, max(cached_multiplicative_slowdown, 1), BOUNTY_DECOY_FLEE_TIME, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) @@ -626,15 +651,19 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( * * On the fugitive and on each decoy, so the two behave the same way (AR-C6, C7, C8): * - the zone's generic name and description (set once, and kept); - * - nobody can pull, drag, shove or stuff them anywhere; + * - the fugitive wears the zone's clothes (redress()); a decoy is built in them; * - the outpost's turrets leave them alone; - * - a shot not aimed at them passes through them; + * - they never step onto the hangar lift by themselves; * - a deliberate hit by a person counts: on a decoy it raises the alert and is an outpost strike, - * on the fugitive it ends the act. + * on the fugitive it ends the act. So does a reach for their wrists with cuffs. + * + * Nothing else is special about them: a hand on them, a shot, a shove or a thrown bottle goes through + * tg like it would for anyone, and they answer it the way their AI does. A shot hits whoever it hits; + * only one aimed at them counts as a deliberate hit. * * The fugitive stops blending in when exposed (the warrant, or a hit); a decoy never does. The - * component stays on an exposed fugitive for the rest of its time at the outpost: its own shots - * there pass through anyone not on its grudge list (AR-C8). + * component stays on an exposed fugitive for the rest of its time at the outpost: if it hurts a + * bystander there, the turrets are let loose on it (AR-C8). */ /datum/component/bounty_outpost_blend /// The posting they are part of, as a weakref (/datum/criminal_bounty) @@ -645,7 +674,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( var/decoy = FALSE /// Still blending in var/blending = TRUE - /// Walking out to the hangar (/datum/component/bounty_outpost_exit): hits and warrants no longer count + /// Walking out to the hangar (bounty_outpost_walk_out()): hits and warrants no longer count var/leaving = FALSE /// The zone's clothes they wear while blending in var/outfit @@ -657,21 +686,21 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( COOLDOWN_DECLARE(hit_grace) /datum/component/bounty_outpost_blend/Initialize(datum/criminal_bounty/posting, decoy = FALSE, display_name, display_desc, outfit) - if(!istype(parent, /mob/living/basic/bounty_criminal)) + if(!istype(parent, /mob/living/carbon/human/npc/bounty_criminal)) return COMPONENT_INCOMPATIBLE posting_ref = posting ? WEAKREF(posting) : null var/atom/outpost = posting?.outpost_site?.resolve() || posting?.site_ref?.resolve() outpost_ref = outpost ? WEAKREF(outpost) : null src.decoy = decoy src.outfit = outfit - var/mob/living/basic/bounty_criminal/owner = parent + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent owner.blended = TRUE if(display_name) owner.name = display_name owner.real_name = display_name + owner.dna?.real_name = display_name if(display_desc) owner.desc = display_desc - owner.move_resist = MOVE_FORCE_VERY_STRONG ADD_TRAIT(owner, TRAIT_NO_CONTAINMENT, BOUNTY_OUTPOST_TRAIT) ADD_TRAIT(owner, TRAIT_NO_STORAGE_INSERT, BOUNTY_OUTPOST_TRAIT) // Belt and braces beside P2's turret rule for criminals: turrets skip faction-mates before @@ -681,23 +710,18 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( new_faction += FACTION_TURRET owner.faction = new_faction added_turret_faction = TRUE + // A decoy was built in the zone's clothes already, which changes nothing; the fugitive was dressed as the criminal it is if(outfit) redress() - // P2 dresses a new criminal in its own clothes in the background; whichever look lands - // last would stick, so the zone's is put back on after it (from the cache: cheap). - addtimer(CALLBACK(src, PROC_REF(redress)), 3 SECONDS, TIMER_DELETE_ME) - addtimer(CALLBACK(src, PROC_REF(redress)), 10 SECONDS, TIMER_DELETE_ME) /datum/component/bounty_outpost_blend/Destroy(force) stop_blending() return ..() /datum/component/bounty_outpost_blend/RegisterWithParent() - RegisterSignal(parent, COMSIG_ATOM_CAN_BE_PULLED, PROC_REF(on_pull_attempt)) - RegisterSignal(parent, COMSIG_MOUSEDROP_ONTO, PROC_REF(on_dragged)) RegisterSignal(parent, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(on_pre_move)) RegisterSignal(parent, COMSIG_PROJECTILE_PREHIT, PROC_REF(on_projectile_prehit)) - RegisterSignal(parent, COMSIG_ATOM_ITEM_INTERACTION, PROC_REF(on_item_interaction)) + RegisterSignal(parent, COMSIG_BOUNTY_CUFF_REACHED, PROC_REF(on_cuff_reached)) RegisterSignal(parent, COMSIG_ATOM_AFTER_ATTACKEDBY, PROC_REF(on_item_attack)) RegisterSignal(parent, COMSIG_ATOM_ATTACK_HAND, PROC_REF(on_hand_attack)) RegisterSignal(parent, COMSIG_ATOM_ATTACK_HAND_SECONDARY, PROC_REF(on_shoved)) @@ -707,15 +731,13 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( RegisterSignal(parent, COMSIG_ATOM_PREHITBY, PROC_REF(on_thrown_hit)) RegisterSignals(parent, list(COMSIG_ATOM_ATTACK_BASIC_MOB, COMSIG_ATOM_ATTACK_ANIMAL), PROC_REF(on_creature_attack)) RegisterSignal(parent, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(on_firing)) - RegisterSignal(parent, COMSIG_HOSTILE_POST_ATTACKINGTARGET, PROC_REF(on_melee_attack)) + RegisterSignals(parent, list(COMSIG_MOB_ITEM_ATTACK, COMSIG_LIVING_UNARMED_ATTACK), PROC_REF(on_own_melee_attack)) /datum/component/bounty_outpost_blend/UnregisterFromParent() UnregisterSignal(parent, list( - COMSIG_ATOM_CAN_BE_PULLED, - COMSIG_MOUSEDROP_ONTO, COMSIG_MOVABLE_PRE_MOVE, COMSIG_PROJECTILE_PREHIT, - COMSIG_ATOM_ITEM_INTERACTION, + COMSIG_BOUNTY_CUFF_REACHED, COMSIG_ATOM_AFTER_ATTACKEDBY, COMSIG_ATOM_ATTACK_HAND, COMSIG_ATOM_ATTACK_HAND_SECONDARY, @@ -726,27 +748,62 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( COMSIG_ATOM_ATTACK_BASIC_MOB, COMSIG_ATOM_ATTACK_ANIMAL, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, - COMSIG_HOSTILE_POST_ATTACKINGTARGET, + COMSIG_MOB_ITEM_ATTACK, + COMSIG_LIVING_UNARMED_ATTACK, )) -/// Puts the zone's clothes back on, while still blending in and not hiding (a meek one's disguise stays) +/** + * Changes the fugitive into the zone's clothes while it blends in. It was dressed as the criminal it + * is; decoys are built in the zone's, so the two must end up looking the same. See + * outpost_wear_patron_clothes(). + */ /datum/component/bounty_outpost_blend/proc/redress() - var/mob/living/basic/bounty_criminal/owner = parent - if(!blending || leaving || !outfit || !owner.record || owner.hidden) + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent + if(!blending || leaving || !outfit || QDELETED(owner)) return - INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(apply_bounty_look), owner, owner.record, outfit) + owner.outpost_wear_patron_clothes(outfit) -/// Ends the act: pullable (by P2's rules) and containable again, and no longer in the turrets' faction. The name stays. +/** + * Changes into `outfit_type`'s clothes: the uniform, the coat, the gloves and the shoes it names, and + * nothing on the head or the face (the zone's patrons wear no hats: hair is a clue). What it wore + * there is taken away and deleted. Everything else it carries stays where it is, pockets and belt + * included. The new clothes are locked to the body like the rest of its kit (D3). + */ +/mob/living/carbon/human/npc/bounty_criminal/proc/outpost_wear_patron_clothes(outfit_type) + var/datum/outfit/patron = new outfit_type() + var/list/swaps = list( + list(w_uniform, patron.uniform, ITEM_SLOT_ICLOTHING), + list(wear_suit, patron.suit, ITEM_SLOT_OCLOTHING), + list(gloves, patron.gloves, ITEM_SLOT_GLOVES), + list(shoes, patron.shoes, ITEM_SLOT_FEET), + list(head, patron.head, ITEM_SLOT_HEAD), + list(wear_mask, patron.mask, ITEM_SLOT_MASK), + ) + qdel(patron) + for(var/list/swap as anything in swaps) + var/obj/item/worn = swap[1] + var/new_type = swap[2] + if(worn && ispath(new_type) && worn.type == new_type) + continue + if(worn) + // idrop off: the pockets, belt and suit storage are meant to stay when the clothes over them are swapped + temporarilyRemoveItemFromInventory(worn, force = TRUE, idrop = FALSE) + qdel(worn) + if(!ispath(new_type)) + continue + var/obj/item/clothes = new new_type(src) + equip_to_slot_or_del(clothes, swap[3], initial = TRUE) + npc_lock_item(clothes) + +/// Ends the act: containable again, and no longer in the turrets' faction. The name stays. /datum/component/bounty_outpost_blend/proc/stop_blending() if(!blending) return blending = FALSE - var/mob/living/basic/bounty_criminal/owner = parent + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent if(QDELETED(owner)) return owner.blended = FALSE - if(!leaving) - owner.body_update_drag() REMOVE_TRAIT(owner, TRAIT_NO_CONTAINMENT, BOUNTY_OUTPOST_TRAIT) REMOVE_TRAIT(owner, TRAIT_NO_STORAGE_INSERT, BOUNTY_OUTPOST_TRAIT) if(added_turret_faction) @@ -755,29 +812,6 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( owner.faction = new_faction added_turret_faction = FALSE -/** - * Nobody pulls someone blending in, or walking out. By P2's rule, though, anyone down, cuffed or dead - * can be dragged: a fugitive (or a decoy, the same, AR-C6) downed or cuffed while still blending in, - * by a blast say, is dragged like any other catch. - */ -/datum/component/bounty_outpost_blend/proc/on_pull_attempt(datum/source, mob/living/puller) - SIGNAL_HANDLER - if(leaving) - return COMSIG_ATOM_CANT_PULL - if(!blending) - return NONE - var/mob/living/basic/bounty_criminal/owner = parent - if(owner.stat == DEAD || owner.downed || owner.is_restrained()) - return NONE - return COMSIG_ATOM_CANT_PULL - -/// Drag-drops (onto a bed, into a crate or disposals) never check move_resist, so they are refused here -/datum/component/bounty_outpost_blend/proc/on_dragged(datum/source, atom/over, mob/user) - SIGNAL_HANDLER - if(blending || leaving) - return COMPONENT_CANCEL_MOUSEDROP_ONTO - return NONE - /** * They never step onto the hangar lift by themselves: the lift carries everyone standing on it, and * a patron riding it down would leave the outpost. Dragged or thrown, they go where they are taken @@ -794,37 +828,39 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( return NONE /** - * A shot not aimed at someone blending in passes through them (AR-C7): nobody can use a decoy as a - * shield, or shoot "past" one to make a rival hit it. A shot aimed at them is a deliberate hit. + * A shot hits whoever it hits. One aimed at someone blending in is a deliberate hit by whoever fired + * it; a stray one that lands on them is not (nobody is framed with it). */ /datum/component/bounty_outpost_blend/proc/on_projectile_prehit(datum/source, obj/projectile/projectile) SIGNAL_HANDLER - if(leaving) - return PROJECTILE_INTERRUPT_HIT_PHASE - if(!blending) - return NONE - if(projectile.original != parent) - return PROJECTILE_INTERRUPT_HIT_PHASE - if(projectile.is_hostile_projectile() && isliving(projectile.firer)) + if(projectile.original == parent && projectile.is_hostile_projectile() && isliving(projectile.firer)) deliberate_hit(projectile.firer) return NONE /** - * Restraints reached for (C1): exactly the cost of a punch, on anyone blending in. A decoy's wrists - * are an outpost strike and the alert; the fugitive's end its act. Standing and free, they pull - * away, so P2's own refusal (and the confrontation it starts) never runs as a free test. Down or - * stunned, the cuffs go on as P2 puts them on anyone. + * Someone is putting restraints on them (COMSIG_BOUNTY_CUFF_REACHED, from the criminal body before its + * own reaction), exactly the cost of a punch (C1): + * - A decoy's wrists are an outpost strike and the alert, and it backs away (which breaks the cuffer's + * try). Handled here, so it isn't also taken for a wrong warrant. + * - The fugitive's end its act. The body's own reaction then steps it away and runs or fights, as for + * anyone who is reached for while free. + * Down, stunned or held, they can't do anything about it: the cuffs go on. Nobody without a mind + * counts. Walking out, the fugitive just takes it like any other free criminal; a decoy's wrists are + * still the hitter's strike (handle_hit()). */ -/datum/component/bounty_outpost_blend/proc/on_item_interaction(datum/source, mob/living/user, obj/item/tool, list/modifiers) +/datum/component/bounty_outpost_blend/proc/on_cuff_reached(datum/source, mob/living/user) SIGNAL_HANDLER - if(!blending || leaving || !istype(tool, /obj/item/restraints/handcuffs)) + if(!isliving(user) || !user.mind) return NONE - var/mob/living/basic/bounty_criminal/owner = parent - deliberate_hit(user) - if(owner.capture_state() != BOUNTY_STATE_FREE) + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent + if(decoy) + deliberate_hit(user) + return owner.body_can_answer_cuffs() ? COMPONENT_BOUNTY_CUFF_HANDLED : NONE + if(!blending || leaving) return NONE - owner.balloon_alert(user, "[owner.p_they()] pull[owner.p_s()] away!") - return ITEM_INTERACT_BLOCKING + owner.body_add_grudge(user) + stop_blending() + return NONE /datum/component/bounty_outpost_blend/proc/on_item_attack(datum/source, obj/item/weapon, mob/living/attacker, list/modifiers, list/attack_modifiers) SIGNAL_HANDLER @@ -873,22 +909,24 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( /// Someone hit them on purpose. Only a person counts: stray shots, stray throws, explosions and NPCs never do. /datum/component/bounty_outpost_blend/proc/deliberate_hit(mob/living/attacker) - if(leaving || !istype(attacker) || !attacker.mind || attacker == parent) + if(!istype(attacker) || !attacker.mind || attacker == parent) return INVOKE_ASYNC(src, PROC_REF(handle_hit), attacker) /** * A person hit them on purpose. - * - The fugitive: the act is over (it remembers who, and reacts as its kind does). + * - The fugitive: the act is over (it remembers who, and reacts as its kind does). Walking out, it + * just takes the blow. * - A decoy (AR-C5): one outpost strike for the hitter, as for hitting a trader, and the bounty's - * alert rises once per scuffle, whoever swung. The decoy barks and backs off. + * alert rises once per scuffle, whoever swung. The decoy barks and backs off. Walking out, the + * alert has gone up already, so only the strike and the bark are left. */ /datum/component/bounty_outpost_blend/proc/handle_hit(mob/living/attacker) - var/mob/living/basic/bounty_criminal/owner = parent - if(QDELETED(owner) || QDELETED(attacker) || leaving) + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent + if(QDELETED(owner) || QDELETED(attacker)) return if(!decoy) - if(!blending) + if(!blending || leaving) return owner.body_add_grudge(attacker) stop_blending() @@ -896,33 +934,33 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( return var/obj/structure/overmap/trader_outpost/outpost = get_trader_outpost_for_turf(get_turf(owner)) outpost?.register_aggression(attacker) - if(COOLDOWN_FINISHED(src, hit_grace)) + if(!leaving && COOLDOWN_FINISHED(src, hit_grace)) COOLDOWN_START(src, hit_grace, BOUNTY_OUTPOST_HIT_GRACE) var/datum/criminal_bounty/posting = posting_ref?.resolve() if(posting && !QDELETED(posting)) log_game("BOUNTY: [key_name(attacker)] hit a decoy of [posting.record?.name] at [AREACOORD(owner)].") posting.raise_alert(BOUNTY_ALERT_DECOY_HIT) // The alert may have sent everyone out already - var/mob/living/basic/bounty_criminal/decoy/decoy_mob = owner - if(!leaving && istype(decoy_mob)) + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy_mob = owner + if(istype(decoy_mob)) decoy_mob.outpost_react_to_hit(attacker) -/// Its own fists landed on `target` -/datum/component/bounty_outpost_blend/proc/on_melee_attack(datum/source, atom/target, result) +/// Its own fists, or what it swung, landed on `target` +/datum/component/bounty_outpost_blend/proc/on_own_melee_attack(datum/source, atom/target) SIGNAL_HANDLER note_victim(target) /** * The fugitive hurt `victim`. At a trader outpost, anyone not on its grudge list is a bystander, and * hurting one ends the turrets' leave to let the hunters handle it (AR-C8). P2's may_attack() and - * the grudge-only shots below keep that from happening; a thrown attack that gets round them should - * call this too. + * its AI's line-of-fire check keep that from happening on purpose; a bullet that hits one anyway + * lands here too (on_firing()). */ /datum/component/bounty_outpost_blend/proc/note_victim(atom/victim) if(decoy || !isliving(victim) || victim == parent) return - var/mob/living/basic/bounty_criminal/owner = parent - if(owner.body_has_grudge(victim) || istype(victim, /mob/living/basic/bounty_criminal) || istype(victim, /mob/living/basic/bounty_companion)) + var/mob/living/carbon/human/npc/bounty_criminal/owner = parent + if(owner.body_has_grudge(victim) || istype(victim, /mob/living/carbon/human/npc/bounty_criminal) || istype(victim, /mob/living/carbon/human/npc/bounty_companion)) return if(!is_trader_outpost_protected(owner)) return @@ -933,48 +971,60 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( /** * Whether a trader outpost's turrets may fire on them (AR-C8): only a fugitive, exposed, that has * hurt someone who never crossed it. Blending in, or fighting only those who came for it, never. - * P2's line in is_hostile_creature() asks this for bounty criminals. + * Nor one the hunters already have: dead, in crit, cuffed or stunned (tg's own states). P2's line in + * is_hostile_creature() asks this for bounty criminals. */ -/mob/living/basic/bounty_criminal/proc/outpost_turret_target() +/mob/living/carbon/human/npc/bounty_criminal/proc/outpost_turret_target() + if(capture_state() != BOUNTY_STATE_FREE) + return FALSE var/datum/component/bounty_outpost_blend/blend = GetComponent(/datum/component/bounty_outpost_blend) return !!blend && !blend.blending && !blend.leaving && blend.hurt_bystander -/// The fugitive fired: at a trader outpost its shot passes through anyone not on its grudge list (AR-C8) +/// The fugitive fired: a shot of its that hits someone at a trader outpost is judged by who it hit (note_victim()) /datum/component/bounty_outpost_blend/proc/on_firing(datum/source, obj/projectile/projectile, atom/fired_from, atom/original) SIGNAL_HANDLER if(decoy || !is_trader_outpost_protected(parent)) return - RegisterSignal(projectile, COMSIG_PROJECTILE_SELF_PREHIT, PROC_REF(on_own_projectile_prehit)) + RegisterSignal(projectile, COMSIG_PROJECTILE_SELF_ON_HIT, PROC_REF(on_own_projectile_hit)) -/datum/component/bounty_outpost_blend/proc/on_own_projectile_prehit(obj/projectile/source, atom/target) +/datum/component/bounty_outpost_blend/proc/on_own_projectile_hit(obj/projectile/source, atom/movable/firer, atom/target, angle, hit_limb, blocked, pierce_hit) SIGNAL_HANDLER - if(!isliving(target) || target == parent) - return NONE - var/mob/living/basic/bounty_criminal/owner = parent - if(owner.body_has_grudge(target)) - return NONE - return PROJECTILE_INTERRUPT_HIT_PHASE + note_victim(target) // ========================================================================= // WALKING OUT // ========================================================================= +/// Sends each of `walkers` (human NPCs) out to `outpost`'s hangar lift: bounty_outpost_walk_out() +/proc/bounty_outpost_walk_out_all(list/walkers, obj/structure/overmap/trader_outpost/outpost) + for(var/mob/living/carbon/human/npc/walker as anything in walkers) + bounty_outpost_walk_out(walker, outpost) + /** - * `walker` leaves `outpost`: walks to the nearest hangar lift and fades out, or fades where they - * are if they can't walk there. `floor` is the outpost's public floor if the caller has it (one - * flood for everyone leaving at once); the lift is only walked to from a tile on it, so an - * unreachable lift is never pathed to. + * `walker` is done at `outpost`: it walks to the nearest hangar lift the way a person would + * (npc_walk_out(), human_npc_states.dm). It can be shot, downed, cuffed or grabbed on the way and then + * stays where it is; it is deleted only when it stands on the lift, or when no player can see it and + * it is on its feet and free. `on_left` is called with it just before that. A lift behind a counter it + * can't get round just means it goes when nobody is looking. Someone who is dead stays for the + * corpse sweep; someone sitting at a table or hiding gets up first. Returns TRUE if it set off. */ -/proc/bounty_outpost_walk_out(mob/living/walker, obj/structure/overmap/trader_outpost/outpost, list/floor) - if(QDELETED(walker) || walker.GetComponent(/datum/component/bounty_outpost_exit)) - return - var/turf/exit_turf = bounty_outpost_exit_turf(walker, outpost) - if(exit_turf) - if(isnull(floor)) - floor = bounty_outpost_public_floor(outpost) - if(!isturf(walker.loc) || !(walker.loc in floor) || !(exit_turf in floor)) - exit_turf = null - walker.AddComponent(/datum/component/bounty_outpost_exit, exit_turf) +/proc/bounty_outpost_walk_out(mob/living/carbon/human/npc/walker, obj/structure/overmap/trader_outpost/outpost, datum/callback/on_left) + if(QDELETED(walker) || walker.stat == DEAD || !isnull(walker.npc_exit)) + return FALSE + var/datum/component/bounty_outpost_blend/blend = walker.GetComponent(/datum/component/bounty_outpost_blend) + blend?.leaving = TRUE + var/mob/living/carbon/human/npc/bounty_criminal/criminal = walker + if(istype(criminal)) + // Out of the closet or from behind the plant, and up from its seat, its drink put down + if(criminal.hidden) + criminal.ai_unhide() + criminal.ai_end_activity() + criminal.ai_stand_up() + else if(walker.buckled && !ismob(walker.buckled) && !human_npc_out_of_fight(walker)) + walker.buckled.unbuckle_mob(walker, force = TRUE) + // The lift if there is one on its level; else where it stands, which it leaves once nobody can see it + var/turf/exit_turf = bounty_outpost_exit_turf(walker, outpost) || get_turf(walker) + return walker.npc_walk_out(exit_turf, on_left) /// The hangar lift alcove tile nearest `from`, on its level, or null /proc/bounty_outpost_exit_turf(atom/from, obj/structure/overmap/trader_outpost/outpost) @@ -992,114 +1042,6 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( nearest_distance = distance return nearest -/** - * # Walking out - * - * Someone P6 put at an outpost is done there: their AI stops, nobody can hurt or pull them, and - * they walk to the hangar lift and fade out. Anyone who can't walk (down, cuffed, hidden, dead), - * can't reach the lift, is taken off their feet on the way, or is still walking after - * BOUNTY_OUTPOST_EXIT_TIMEOUT fades where they are. The walk's move loop never outlives them. - */ -/datum/component/bounty_outpost_exit - /// The hangar lift tile they are walking to, or null - var/turf/destination - /// The walk to the lift, while it runs - var/datum/move_loop/walk_loop - /// Fading out now - var/fading = FALSE - -/datum/component/bounty_outpost_exit/Initialize(turf/destination) - if(!isliving(parent)) - return COMPONENT_INCOMPATIBLE - src.destination = destination - var/mob/living/walker = parent - var/datum/component/bounty_outpost_blend/blend = walker.GetComponent(/datum/component/bounty_outpost_blend) - if(blend) - blend.leaving = TRUE - ADD_TRAIT(walker, TRAIT_AI_PAUSED, BOUNTY_OUTPOST_TRAIT) - ADD_TRAIT(walker, TRAIT_GODMODE, BOUNTY_OUTPOST_TRAIT) - walker.move_resist = MOVE_FORCE_VERY_STRONG - walker.pulledby?.stop_pulling() - walker.stop_pulling() - addtimer(CALLBACK(src, PROC_REF(fade_out)), BOUNTY_OUTPOST_EXIT_TIMEOUT, TIMER_DELETE_ME) - if(!destination || !can_walk() || destination.z != walker.z) - fade_out() - return - RegisterSignal(walker, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) - walk_loop = GLOB.move_manager.jps_move(walker, destination, delay = BOUNTY_OUTPOST_EXIT_DELAY, timeout = BOUNTY_OUTPOST_EXIT_TIMEOUT, repath_delay = 2 SECONDS, max_path_length = 150, simulated_only = TRUE, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) - if(!walk_loop) - fade_out() - return - RegisterSignal(walk_loop, COMSIG_MOVELOOP_PREPROCESS_CHECK, PROC_REF(before_step)) - RegisterSignals(walk_loop, list(COMSIG_MOVELOOP_STOP, COMSIG_QDELETING), PROC_REF(on_walk_ended)) - -/datum/component/bounty_outpost_exit/Destroy(force) - end_walk() - destination = null - return ..() - -/// Whether they can walk out on their own feet -/datum/component/bounty_outpost_exit/proc/can_walk() - var/mob/living/walker = parent - if(QDELETED(walker) || walker.stat != CONSCIOUS || !isturf(walker.loc) || walker.buckled) - return FALSE - if(HAS_TRAIT(walker, TRAIT_IMMOBILIZED) || HAS_TRAIT(walker, TRAIT_FLOORED) || HAS_TRAIT(walker, TRAIT_RESTRAINED)) - return FALSE - if(istype(walker, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = walker - if(criminal.hidden || criminal.is_downed() || criminal.is_restrained()) - return FALSE - return TRUE - -/// Before each step: someone off their feet or off the floor (stuffed somewhere, carried off) stops walking and fades, so the pathfinder is never asked to start from nowhere -/datum/component/bounty_outpost_exit/proc/before_step(datum/move_loop/source) - SIGNAL_HANDLER - if(can_walk()) - return NONE - addtimer(CALLBACK(src, PROC_REF(fade_out)), 0, TIMER_DELETE_ME) - return MOVELOOP_SKIP_STEP - -/// The walk stopped (timed out, or ended): whoever hasn't reached the lift fades where they are -/datum/component/bounty_outpost_exit/proc/on_walk_ended(datum/move_loop/source) - SIGNAL_HANDLER - UnregisterSignal(source, list(COMSIG_MOVELOOP_PREPROCESS_CHECK, COMSIG_MOVELOOP_STOP, COMSIG_QDELETING)) - if(walk_loop == source) - walk_loop = null - if(!fading) - addtimer(CALLBACK(src, PROC_REF(fade_out)), 0, TIMER_DELETE_ME) - -/// Stops the walk, if it is still going -/datum/component/bounty_outpost_exit/proc/end_walk() - if(!walk_loop) - return - var/datum/move_loop/ending = walk_loop - walk_loop = null - UnregisterSignal(ending, list(COMSIG_MOVELOOP_PREPROCESS_CHECK, COMSIG_MOVELOOP_STOP, COMSIG_QDELETING)) - if(!QDELETED(ending)) - qdel(ending) - -/datum/component/bounty_outpost_exit/proc/on_moved(datum/source, atom/old_loc, dir, forced, list/old_locs) - SIGNAL_HANDLER - // Faded after the step, never inside the move loop's own step - if(destination && get_dist(parent, destination) <= 1) - addtimer(CALLBACK(src, PROC_REF(fade_out)), 0, TIMER_DELETE_ME) - -/datum/component/bounty_outpost_exit/proc/fade_out() - if(fading) - return - fading = TRUE - end_walk() - var/mob/living/walker = parent - if(QDELETED(walker)) - return - UnregisterSignal(walker, COMSIG_MOVABLE_MOVED) - GLOB.move_manager.stop_looping(walker) - animate(walker, alpha = 0, time = BOUNTY_OUTPOST_FADE_TIME) - addtimer(CALLBACK(src, PROC_REF(vanish)), BOUNTY_OUTPOST_FADE_TIME, TIMER_DELETE_ME) - -/datum/component/bounty_outpost_exit/proc/vanish() - qdel(parent) - // ========================================================================= // ASKING TRADERS // ========================================================================= @@ -1132,7 +1074,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( if(!posting) return FALSE var/words - var/mob/living/basic/bounty_criminal/fugitive = posting.criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() if(!fugitive || get_trader_outpost_for_turf(get_turf(fugitive)) != trader.outpost) words = "They're not here any more." // slipped away, or someone already dragged them off else @@ -1204,21 +1146,21 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( var/obj/structure/overmap/trader_outpost/outpost = outpost_site?.resolve() switch(kind[1]) if(BOUNTY_CLUE_SEEN) - var/mob/living/basic/bounty_criminal/fugitive = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = criminal() return fugitive ? bounty_outpost_where(fugitive, outpost) : null if(BOUNTY_CLUE_RULED_OUT) var/index = kind[2] if(index < 1 || index > length(decoys)) return null var/datum/weakref/decoy_ref = decoys[index] - var/mob/living/basic/bounty_criminal/decoy = decoy_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/decoy = decoy_ref?.resolve() if(QDELETED(decoy) || decoy.stat == DEAD) return null var/where = bounty_outpost_where(decoy, outpost) if(!where) return null // Only if the place picks out one person: never a hint that could fit the fugitive - for(var/mob/living/basic/bounty_criminal/other as anything in outpost_candidates()) + for(var/mob/living/carbon/human/npc/bounty_criminal/other as anything in outpost_candidates()) if(other != decoy && bounty_outpost_where(other, outpost) == where) return null return where @@ -1251,11 +1193,11 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( /// The fugitive and its decoys, whoever of them still exists /datum/criminal_bounty/proc/outpost_candidates() . = list() - var/mob/living/basic/bounty_criminal/fugitive = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = criminal() if(fugitive) . += fugitive for(var/datum/weakref/decoy_ref as anything in decoys) - var/mob/living/basic/bounty_criminal/decoy = decoy_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/decoy = decoy_ref?.resolve() if(!QDELETED(decoy)) . += decoy @@ -1420,7 +1362,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( if(QDELETED(posting) || posting.status != BOUNTY_POSTING_OPEN) to_chat(user, span_warning("This warrant is out of date.")) return - if(!istype(target, /mob/living/basic/bounty_criminal)) + if(!istype(target, /mob/living/carbon/human/npc/bounty_criminal)) user.visible_message(span_notice("[user] holds up a warrant for [target] to see."), span_notice("You hold up the warrant for [target] to see."), vision_distance = COMBAT_MESSAGE_RANGE) if(target.client) to_chat(target, span_notice("[user] shows you a bounty warrant. It isn't for you.")) @@ -1434,7 +1376,7 @@ GLOBAL_LIST_INIT(bounty_outpost_trader_react_lines, list( return posting.outpost_start_warrant_cooldown(user) user.visible_message(span_notice("[user] holds up a warrant for [target] to see."), span_notice("You hold up the warrant for [target] to see."), vision_distance = COMBAT_MESSAGE_RANGE) - var/mob/living/basic/bounty_criminal/criminal = target + var/mob/living/carbon/human/npc/bounty_criminal/criminal = target if(criminal == posting.criminal()) posting.outpost_confront(criminal, user) else diff --git a/voidcrew/modules/bounties/bounty_placement.dm b/voidcrew/modules/bounties/bounty_placement.dm index f150143c2ce..2ee253fc2cf 100644 --- a/voidcrew/modules/bounties/bounty_placement.dm +++ b/voidcrew/modules/bounties/bounty_placement.dm @@ -277,7 +277,7 @@ SIGNAL_HANDLER if(!is_open() || board_relisting) return - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() // A criminal carried off the site (aboard a ship) is not the site's to take if(criminal && !board_site_contains(source, get_turf(criminal))) return @@ -285,11 +285,11 @@ /** * The site's interior went away with the criminal in it (spec 6: unload): the criminal, its - * companions and the marker go, the posting stays open, and the same record comes back at full - * health on the next load. Cuffs don't survive, but the worst state it reached does (12.1): it - * never pays more than it did before, and a dead one comes back as proof of death. + * companions and the marker go, the posting stays open, and the same record comes back unhurt on the + * next load, out of any cuffs. Pay is by its condition on the pad, so a living one owes nothing to how + * it was before; one that died comes back as proof of death. */ -/datum/criminal_bounty/proc/board_site_took_criminal(mob/living/basic/bounty_criminal/criminal) +/datum/criminal_bounty/proc/board_site_took_criminal(mob/living/carbon/human/npc/bounty_criminal/criminal) board_note_worst(criminal) board_detach_criminal() var/list/extras = list() @@ -307,13 +307,10 @@ board_wait_for_load(site()) log_game("BOUNTY: the [board_log_name()] waits for its site to load again") -/// Remembers the worst state `criminal` reached, for whatever body of it comes next (12.1) -/datum/criminal_bounty/proc/board_note_worst(mob/living/basic/bounty_criminal/criminal) - if(!criminal) - return - var/state = criminal.stat == DEAD ? BOUNTY_STATE_DEAD : criminal.worst_state - if(bounty_state_rank(state) < bounty_state_rank(board_worst_state)) - board_worst_state = state +/// Remembers whether `criminal` was dead when its site took it: the next body of it is then its proof of death +/datum/criminal_bounty/proc/board_note_worst(mob/living/carbon/human/npc/bounty_criminal/criminal) + if(criminal?.stat == DEAD) + board_worst_state = BOUNTY_STATE_DEAD /// The site is gone (a ruin respawning elsewhere, a pirate ship destroyed or despawned) /datum/criminal_bounty/proc/board_on_site_deleted(datum/source) @@ -409,7 +406,7 @@ return null if(board_worst_state == BOUNTY_STATE_DEAD) return board_place_proof(spot) - var/mob/living/basic/bounty_criminal/criminal = spawn_bounty_criminal(record, spot, src) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = spawn_bounty_criminal(record, spot, src) board_spawning = FALSE if(QDELETED(criminal)) return null @@ -441,7 +438,7 @@ * the blend at a trader outpost: P6's setup_outpost_blend()). Also used by tests and the admin panel * to adopt a criminal spawned by hand. */ -/datum/criminal_bounty/proc/board_adopt_criminal(mob/living/basic/bounty_criminal/criminal, obj/structure/overmap/where, activity, atom/anchor) +/datum/criminal_bounty/proc/board_adopt_criminal(mob/living/carbon/human/npc/bounty_criminal/criminal, obj/structure/overmap/where, activity, atom/anchor) if(QDELETED(criminal)) return FALSE board_detach_criminal() @@ -451,8 +448,6 @@ criminal.record = record RegisterSignal(criminal, COMSIG_QDELETING, PROC_REF(board_on_criminal_deleted)) board_spawn_failures = 0 - // Whatever an earlier body of theirs went through still counts (12.1). Only ever made worse. - criminal.body_restore_worst_state(board_worst_state) if(where) criminal.site_bounds = board_site_bounds(where) board_add_site_factions(criminal, where) @@ -480,7 +475,7 @@ /// Stops tracking the criminal without touching it /datum/criminal_bounty/proc/board_detach_criminal() - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() if(criminal) UnregisterSignal(criminal, COMSIG_QDELETING) criminal_ref = null @@ -497,7 +492,7 @@ return BOUNTY_ACTIVITY_EXPLORE /// Companions for a normal criminal: 0 to BOUNTY_MAX_COMPANIONS in every zone (12.3 C9: the zone scales pay, never the fight), within the server's cap -/datum/criminal_bounty/proc/board_spawn_companions(mob/living/basic/bounty_criminal/criminal) +/datum/criminal_bounty/proc/board_spawn_companions(mob/living/carbon/human/npc/bounty_criminal/criminal) var/count = rand(0, BOUNTY_MAX_COMPANIONS) // The crew aboard a pirate ship are company enough if(placement_kind == BOUNTY_PLACEMENT_NPC_SHIP) @@ -505,17 +500,15 @@ count = min(count, BOUNTY_MAX_LIVE_COMPANIONS - SScriminal_bounties.board_live_companions()) if(count <= 0) return list() - var/list/spawned = criminal.spawn_companions(count) - for(var/mob/living/companion in spawned) - companions |= WEAKREF(companion) - return spawned + // Built through the human NPC queue: each companion joins the posting's list as it joins its criminal (bounty_companions.dm). + criminal.spawn_companions(count) /** * Makes the criminal a friend of the site's own, so it doesn't fight the hostiles native to where * it hides (spec 3): the crew's factions aboard a pirate ship, the planet's and ruins' hostiles. * The neutral faction every player has is never added. */ -/datum/criminal_bounty/proc/board_add_site_factions(mob/living/basic/bounty_criminal/criminal, obj/structure/overmap/where) +/datum/criminal_bounty/proc/board_add_site_factions(mob/living/carbon/human/npc/bounty_criminal/criminal, obj/structure/overmap/where) var/list/added = list(FACTION_HOSTILE) if(istype(where, /obj/structure/overmap/ship/npc)) var/obj/structure/overmap/ship/npc/npc_ship = where @@ -562,7 +555,7 @@ * let go and the bounty lists at a new site. Returns TRUE if it acted. */ /datum/criminal_bounty/proc/board_check_unreachable() - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() if(!criminal) return FALSE var/in_chasm = istype(criminal.loc, /obj/effect/abstract/chasm_storage) @@ -850,7 +843,7 @@ if(placement_kind == BOUNTY_PLACEMENT_TRADER_OUTPOST) board_drop_marker() return FALSE - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() if(!criminal) return FALSE if(!force && world.time < board_next_sighting) diff --git a/voidcrew/modules/bounties/bounty_posting.dm b/voidcrew/modules/bounties/bounty_posting.dm index 3cbc898812e..3fe62e1ed42 100644 --- a/voidcrew/modules/bounties/bounty_posting.dm +++ b/voidcrew/modules/bounties/bounty_posting.dm @@ -45,7 +45,7 @@ var/datum/weakref/board_proof_ref /// Weakrefs to GPS units (/datum/component/gps/item) its sighting beacon was uploaded to -> weakref of the ship that linked them var/list/board_gps_units = list() - /// The worst capture state (BOUNTY_STATE_*) any body of its criminal reached. A criminal that comes back when its site loads again starts from here: once downed, never full pay; once dead, only proof (12.1). + /// BOUNTY_STATE_DEAD once a body of its criminal was dead when its site unloaded: the next load places its proof of death, never a living body again. Otherwise BOUNTY_STATE_FREE. var/board_worst_state = BOUNTY_STATE_FREE /// The sighting beacon's tag on those units var/board_gps_tag @@ -348,7 +348,7 @@ * so this never touches one being beamed out. */ /datum/criminal_bounty/proc/board_remove_mobs(reason, relisting = FALSE) - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() board_detach_criminal() var/list/extras = list() for(var/datum/weakref/ref as anything in companions + decoys) @@ -382,6 +382,12 @@ var/turf/spot = get_turf(leaving) if(spot && leaving.stat != DEAD) new /obj/effect/temp_visual/transporter_flash/departure(spot) + // Its locked kit goes with it (D3); whatever on it isn't its own (papers, a key, a crew's cuffs) stays behind. + if(ishumannpc(leaving) && spot) + var/mob/living/carbon/human/npc/body = leaving + body.npc_drop_not_gear() + for(var/obj/item/restraints/worn in list(body.handcuffed, body.legcuffed)) + body.transferItemToLoc(worn, spot, force = TRUE) qdel(leaving) // ===== RELISTING ===== @@ -483,19 +489,12 @@ return 0 /** - * Percent paid for a criminal in `state` at the pad that has at some point been as bad as - * `worst_state` (12.1: once downed, at most the downed share; once dead, the dead share). 0 for a - * free criminal, which the pad refuses. + * Percent paid for a criminal in `state` at the pad: its condition there, nothing it went through on the + * way (owner ruling 2026-11-08). 0 for a free criminal, which the pad refuses. The kingpin and kill-only + * postings override it. */ -/datum/criminal_bounty/proc/board_share_for(state, worst_state) - var/share = bounty_state_share(state) - if(!share) - return 0 - if(worst_state && worst_state != BOUNTY_STATE_FREE) - var/worst_share = bounty_state_share(worst_state) - if(worst_share) - share = min(share, worst_share) - return share +/datum/criminal_bounty/proc/board_share_for(state) + return bounty_state_share(state) /** * What a turn-in at `share` percent pays: list(credits, vouchers). Vouchers come only with the diff --git a/voidcrew/modules/bounties/bounty_restraints.dm b/voidcrew/modules/bounties/bounty_restraints.dm index 736afda066f..a98fab30a54 100644 --- a/voidcrew/modules/bounties/bounty_restraints.dm +++ b/voidcrew/modules/bounties/bounty_restraints.dm @@ -1,268 +1,127 @@ /** - * # Bounty hunting: restraints on a criminal + * # Bounty hunting: cuffs on a criminal * * Owner: P2 body. Do not edit this file outside P2. * - * What it owns: cuffing bounty criminals, a port of the prisoner's cuff pattern - * (outpost_prison_capture.dm) as procs on the criminal (spec sections 4 and 12.3, C1 and C2): - * - Any tg handcuffs, cable restraints or zipties, used on it by anyone with hands. Only on a criminal - * that is downed, stunned or has surrendered, over BOUNTY_CUFF_TIME. On one standing free the cuffs - * are refused, and the try alerts it (P3's on_confronted()). - * - Restrained, it is held still, throws no punches and fires no shots (may_attack()), plans nothing - * (TRAIT_BOUNTY_HELD and TRAIT_AI_PAUSED), and can be dragged. A downed one stays down until the - * cuffs come off. - * - Anyone takes them off with an empty hand over BOUNTY_UNCUFF_TIME: handcuffs to their hand, cable - * and zipties cut away. - * - Cable restraints slip after BOUNTY_CABLE_SLIP and zipties after BOUNTY_ZIPTIE_SLIP (a mini-boss in - * half the time, and it snaps them), with a visible struggle for the last BOUNTY_SLIP_WARNING. - * Handcuffs never slip. - * - The cuffs show as tg's handcuff overlay, stay in its contents, and drop when it dies or is deleted. - * Cuffing a criminal gives no claim on it: whoever gets it onto their own pad is paid (decision 18). + * A criminal is cuffed the way a person is: tg's attempt_to_cuff() (4 seconds, anyone with hands, any + * tg handcuffs, cable restraints or zipties), worn in tg's handcuff slot, and taken off the way anyone's + * are. A cuffed criminal can walk, as players can; it works at its cuffs through tg's resist (its AI's + * escape captivity): a minute for handcuffs, 45 s for zipties, 30 s for cable, and dragging it breaks the + * try (defaults 1.4). Its AI plans nothing else while cuffed. * - * The shared vars (`restraints` and the rest) are declared in bounty_types.dm and nowhere else; a - * var only P2 uses goes here, or in bounty_criminal.dm, with a `body_` prefix. + * What this file adds: + * - The reaction to a reach for its wrists (defaults 1.4, CR-7): while it can still move on its own it + * steps away a moment after the cuffing starts, which breaks the cuffer's do_after, and then runs or + * fights (on_confronted()). Paralysed after giving up, stamcritted, knocked down or in crit, it can't, + * and the cuffs go on. A subtype changes it in body_on_cuff_attempt(); the trader-outpost blend takes + * it over through COMSIG_BOUNTY_CUFF_REACHED. + * - COMSIG_BOUNTY_CRIMINAL_RESTRAINED and _UNRESTRAINED for the AI packages, from tg's TRAIT_RESTRAINED. + * - Fultons never go on (M2, owner question 10): the pad is the only way off its site bar the carry. + * Cuffing gives no claim on it: whoever gets it onto their own pad is paid (decision 18). */ -// ===== CUFFING ===== - -// Restraints go on from the item interaction, ahead of anything else that item might do to it. A -// fulton never goes on: the pad is the only way off its site bar the carry (M2, owner question 10). -/mob/living/basic/bounty_criminal/item_interaction(mob/living/user, obj/item/tool, list/modifiers) - if(istype(tool, /obj/item/restraints/handcuffs)) - return body_on_cuffs_used(user, tool) +/mob/living/carbon/human/npc/bounty_criminal + /// Weakref to whoever last reached for its wrists, and when + var/datum/weakref/body_cuffer_ref + var/body_cuffed_at = 0 + /// world.time it last started working at its cuffs (tg's cuff_resist()) + var/body_working_cuffs_at = 0 + +/// Hooks tg's cuffing on it. Called once from Initialize(). +/mob/living/carbon/human/npc/bounty_criminal/proc/body_hook_cuffs() + RegisterSignal(src, COMSIG_CARBON_CUFF_ATTEMPTED, PROC_REF(body_on_cuff_signal)) + RegisterSignal(src, COMSIG_MOB_REMOVING_CUFFS, PROC_REF(body_on_working_cuffs)) + RegisterSignal(src, SIGNAL_ADDTRAIT(TRAIT_RESTRAINED), PROC_REF(body_on_restrained)) + RegisterSignal(src, SIGNAL_REMOVETRAIT(TRAIT_RESTRAINED), PROC_REF(body_on_unrestrained)) + +// A fulton never goes on a criminal (M2). +/mob/living/carbon/human/npc/bounty_criminal/item_interaction(mob/living/user, obj/item/tool, list/modifiers) if(istype(tool, /obj/item/extraction_pack)) balloon_alert(user, "won't hold [p_them()]!") return ITEM_INTERACT_BLOCKING return ..() -/// Why `user` can't put `cuffs` on it now, as a balloon alert, or null if they can -/mob/living/basic/bounty_criminal/proc/body_cuff_refusal(mob/living/user, obj/item/restraints/handcuffs/cuffs) - if(stat == DEAD) - return "can't cuff [p_them()]" - if(is_restrained()) - return "already cuffed" - if(istype(cuffs, /obj/item/restraints/handcuffs/cable/zipties/used) || istype(cuffs, /obj/item/restraints/handcuffs/cable/zipties/fake/used)) - return "used up" - if(user && !iscyborg(user) && user.usable_hands <= 0) - return "no hands free" - // Standing and free, it fights or runs; it has to be put down, stunned or talked into giving up first (C2). - if(capture_state() == BOUNTY_STATE_FREE) - return "[p_they()] pull[p_s()] away!" - return null +// ===== A REACH FOR ITS WRISTS ===== -/// Restraints used on it: refused with a balloon alert (and a free one is alerted), or put on in the background -/mob/living/basic/bounty_criminal/proc/body_on_cuffs_used(mob/living/user, obj/item/restraints/handcuffs/cuffs) - var/refusal = body_cuff_work ? "already busy" : body_cuff_refusal(user, cuffs) - if(refusal) - balloon_alert(user, refusal) - // Reaching for its wrists is a confrontation: a meek one bolts, a normal one fights (P3). Blending - // in, P6's blend component answers for it, the same for the fugitive and its decoys. - if(!body_cuff_work && !blended && capture_state() == BOUNTY_STATE_FREE) - body_add_grudge(user) - INVOKE_ASYNC(src, PROC_REF(on_confronted), user) - return ITEM_INTERACT_BLOCKING - INVOKE_ASYNC(src, PROC_REF(body_cuff_by), user, cuffs) - return ITEM_INTERACT_SUCCESS +/// tg's attempt_to_cuff() is about to start on it. Never refuses the cuffs: the body answers the way a person would. +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_cuff_signal(datum/source, mob/living/user) + SIGNAL_HANDLER + if(!isliving(user) || user == src || stat == DEAD) + return NONE + body_cuffer_ref = WEAKREF(user) + body_cuffed_at = world.time + if(SEND_SIGNAL(src, COMSIG_BOUNTY_CUFF_REACHED, user) & COMPONENT_BOUNTY_CUFF_HANDLED) + return NONE + body_on_cuff_attempt(user) + return NONE /** - * `user` puts `cuffs` on it over BOUNTY_CUFF_TIME, with tg's cuff sounds. Fails if it gets up or - * breaks free meanwhile, or moves away. Returns TRUE if it is restrained. Sleeps. + * `user` started cuffing it. While it can still move on its own it puts them on its grudge list and, + * a moment into their cuffing, steps away from them (breaking the try) and runs or fights. Override + * for a body that answers differently (a man on his sofa, a don who won't be taken alive). Inside the + * cuff signal's stack: must not sleep. */ -/mob/living/basic/bounty_criminal/proc/body_cuff_by(mob/living/user, obj/item/restraints/handcuffs/cuffs) - if(body_cuff_work || body_cuff_refusal(user, cuffs)) - return FALSE - body_cuff_work = TRUE - user.visible_message(span_danger("[user] is trying to put [cuffs] on [src]!"), span_danger("You try to put [cuffs] on [src].")) - playsound(src, cuffs.cuffsound, 30, TRUE, -2) - var/done = do_after(user, BOUNTY_CUFF_TIME, target = src, timed_action_flags = IGNORE_SLOWDOWNS) - body_cuff_work = FALSE - if(QDELETED(src)) - return FALSE - // A borg's cuffs are part of its module: it puts on a new pair of the same kind, as tg's cuffs do for borgs. - var/dispense = iscyborg(user) - if(!done || QDELETED(cuffs) || (cuffs.loc != user && !dispense) || body_cuff_refusal(user, cuffs)) - balloon_alert(user, "failed to cuff!") +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_cuff_attempt(mob/living/user) + if(!body_can_answer_cuffs()) + return + body_add_grudge(user) + addtimer(CALLBACK(src, PROC_REF(body_answer_cuffs), WEAKREF(user)), BOUNTY_CUFF_REACTION_DELAY, TIMER_DELETE_ME) + +/// Whether it can do anything about a reach for its wrists: awake, on its feet and free to move, not already cuffed, not held or hidden +/mob/living/carbon/human/npc/bounty_criminal/proc/body_can_answer_cuffs() + if(stat != CONSCIOUS || is_restrained() || buckled || hidden || !isturf(loc)) return FALSE - var/success_sound = cuffs.cuffsuccesssound - if(!body_apply_cuffs(cuffs, user, dispense)) - balloon_alert(user, "failed to cuff!") + if(HAS_TRAIT(src, TRAIT_INCAPACITATED) || HAS_TRAIT(src, TRAIT_IMMOBILIZED) || HAS_TRAIT(src, TRAIT_FLOORED)) return FALSE - playsound(src, success_sound, 30, TRUE, -2) - user.visible_message(span_notice("[user] restrains [src] with [restraints]."), span_notice("You restrain [src] with [restraints].")) - log_combat(user, src, "restrained") - return TRUE + return !(pulledby && pulledby.grab_state >= GRAB_AGGRESSIVE) -/// What `cuffs` turn into once worn: zipties their used pair, fake zipties their fake used pair (tg gives both the real one), anything else stays itself (null) -/proc/bounty_worn_cuffs_type(obj/item/restraints/handcuffs/cuffs) - if(istype(cuffs, /obj/item/restraints/handcuffs/cable/zipties/fake) && !istype(cuffs, /obj/item/restraints/handcuffs/cable/zipties/fake/used)) - return /obj/item/restraints/handcuffs/cable/zipties/fake/used - return cuffs?.trashtype +/// A moment after the reach: a step away from `user_ref`'s owner, then its fight or flight (on_confronted()). +/mob/living/carbon/human/npc/bounty_criminal/proc/body_answer_cuffs(datum/weakref/user_ref) + var/mob/living/user = user_ref?.resolve() + if(QDELETED(src) || QDELETED(user) || !body_can_answer_cuffs()) + return + body_step_away_from(user) + on_confronted(user) -/** - * Puts `cuffs` on it at once; zipties become their used pair, as on people. `user`, if any, gives - * them up from their hands, unless `dispense` (a borg), which puts on a new pair of the same kind - * instead. Returns TRUE if it is restrained. - */ -/mob/living/basic/bounty_criminal/proc/body_apply_cuffs(obj/item/restraints/handcuffs/cuffs, mob/living/user, dispense = FALSE) - if(is_restrained() || QDELETED(cuffs) || stat == DEAD) - return FALSE - var/obj/item/restraints/handcuffs/worn = cuffs - var/used_path = bounty_worn_cuffs_type(cuffs) - if(dispense) - var/dispensed_path = used_path || cuffs.type - worn = new dispensed_path(src) - else - if(user && !user.temporarilyRemoveItemFromInventory(cuffs)) - return FALSE - if(used_path) - worn = new used_path(src) - qdel(cuffs) - else - cuffs.forceMove(src) - if(QDELETED(worn) || worn.loc != src) +/// One real step directly or diagonally away from `threat`, onto ground its leash allows. Returns TRUE if it moved. +/mob/living/carbon/human/npc/bounty_criminal/proc/body_step_away_from(atom/threat) + var/turf/here = get_turf(src) + if(!here || !threat) return FALSE - restraints = worn - ADD_TRAIT(src, TRAIT_IMMOBILIZED, BOUNTY_BODY_CUFFS_TRAIT) - if(pulling) - stop_pulling() - ai_controller?.CancelActions() - body_update_held() + var/away = get_dir(threat, src) || pick(GLOB.alldirs) + for(var/direction in list(away, turn(away, 45), turn(away, -45), turn(away, 90), turn(away, -90))) + var/turf/step_turf = get_step(here, direction) + if(!step_turf || step_turf.is_blocked_turf(exclude_mobs = FALSE) || !body_move_allowed(step_turf)) + continue + if(Move(step_turf, direction)) + return TRUE + return FALSE + +// ===== CUFFS ON AND OFF ===== + +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_working_cuffs(datum/source, obj/item/cuffs) + SIGNAL_HANDLER + body_working_cuffs_at = world.time + +/// Cuffed: the AI packages hear who did it (whoever last reached for its wrists, if it was lately) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_restrained(datum/source) + SIGNAL_HANDLER + if(stat == DEAD || QDELING(src)) + return + var/mob/living/user = body_cuffer_ref?.resolve() + if(world.time > body_cuffed_at + 30 SECONDS) + user = null if(user) body_add_grudge(user) - body_start_slip() - update_appearance(UPDATE_OVERLAYS) body_state_changed() SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_RESTRAINED, user) - // Forced: the cuffs go on 2 s after a downing or a stun, well inside the cooldown of what they said then (BUG-1). - if(stat == CONSCIOUS) - INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "cuffed", null, TRUE) - return TRUE - -// ===== UNCUFFING ===== - -// An empty, open hand on a restrained criminal starts taking the restraints off. -/mob/living/basic/bounty_criminal/attack_hand(mob/living/carbon/human/user, list/modifiers) - if(is_restrained() && stat != DEAD && !user.combat_mode && !LAZYACCESS(modifiers, RIGHT_CLICK)) - INVOKE_ASYNC(src, PROC_REF(body_uncuff_by), user) - return TRUE - return ..() - -/// `user` takes its restraints off over BOUNTY_UNCUFF_TIME. Returns TRUE if they came off. Sleeps. -/mob/living/basic/bounty_criminal/proc/body_uncuff_by(mob/living/user) - if(!is_restrained() || body_cuff_work) - return FALSE - body_cuff_work = TRUE - user.visible_message(span_notice("[user] starts taking [restraints] off [src]."), span_notice("You start taking [restraints] off [src].")) - var/done = do_after(user, BOUNTY_UNCUFF_TIME, target = src) - body_cuff_work = FALSE - if(!done || QDELETED(src) || !is_restrained()) - return FALSE - var/cut = istype(restraints, /obj/item/restraints/handcuffs/cable) - user.visible_message(span_notice("[user] [cut ? "cuts" : "takes"] [restraints] off [src]."), span_notice("You [cut ? "cut" : "take"] [restraints] off [src].")) - body_remove_cuffs(user) - return TRUE - -/** - * Takes its restraints off. With `user`, handcuffs go to their hands (or the floor) and cable and - * zipties are cut away; without, the restraints drop where it is. Returns the restraints still about, - * or null. - */ -/mob/living/basic/bounty_criminal/proc/body_remove_cuffs(mob/living/user) - var/obj/item/restraints/handcuffs/worn = restraints - if(!worn) - return null - // Cleared first, so Exited() leaves the rest to this proc. - restraints = null - var/atom/drop_spot = drop_location() - if(QDELETED(worn)) - worn = null - else if((user && istype(worn, /obj/item/restraints/handcuffs/cable)) || !drop_spot) - qdel(worn) - worn = null - else - worn.forceMove(drop_spot) - if(user) - user.put_in_hands(worn) - body_uncuffed(user) - return worn -/// Its restraints are off, however they came off: it can move and act again, and a downed one whose recovery came due starts getting up -/mob/living/basic/bounty_criminal/proc/body_uncuffed(mob/living/user) - REMOVE_TRAIT(src, TRAIT_IMMOBILIZED, BOUNTY_BODY_CUFFS_TRAIT) - body_stop_slip() - if(QDELETED(src)) +/// Out of its cuffs: the AI packages hear whether it got itself out (null) or someone took them off (src: who isn't known) +/mob/living/carbon/human/npc/bounty_criminal/proc/body_on_unrestrained(datum/source) + SIGNAL_HANDLER + if(stat == DEAD || QDELING(src)) return - body_update_held() - update_appearance(UPDATE_OVERLAYS) + var/self_freed = body_working_cuffs_at && world.time <= body_working_cuffs_at + BOUNTY_SELF_FREED_WINDOW + body_working_cuffs_at = 0 body_state_changed() - SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED, user) - if(downed && body_recovery_due && stat != DEAD) - body_recovery_due = FALSE - deltimer(body_recovery_timer) - body_stir() - -/mob/living/basic/bounty_criminal/Exited(atom/movable/gone, direction) - . = ..() - if(gone == restraints) - // Taken or destroyed some other way than body_remove_cuffs() - restraints = null - body_uncuffed(null) - -// ===== SLIPPING ===== - -/// How long its restraints hold before it slips them: cable, zipties or joke cuffs; a mini-boss in half the time; 0 for handcuffs, which never slip -/mob/living/basic/bounty_criminal/proc/body_slip_time() - if(!is_restrained()) - return 0 - var/time - if(istype(restraints, /obj/item/restraints/handcuffs/fake) || istype(restraints, /obj/item/restraints/handcuffs/cable/zipties/fake)) - time = BOUNTY_FAKE_CUFF_SLIP - else if(istype(restraints, /obj/item/restraints/handcuffs/cable/zipties)) - time = BOUNTY_ZIPTIE_SLIP - else if(istype(restraints, /obj/item/restraints/handcuffs/cable)) - time = BOUNTY_CABLE_SLIP - else - return 0 - if(istype(src, /mob/living/basic/bounty_criminal/boss)) - time *= BOUNTY_BOSS_SLIP_MULT - return time - -/// Starts the clock on its restraints slipping, if they ever do -/mob/living/basic/bounty_criminal/proc/body_start_slip() - body_stop_slip() - var/time = body_slip_time() - if(time <= 0) - return - body_slip_warn_timer = addtimer(CALLBACK(src, PROC_REF(body_slip_warning)), max(0, time - BOUNTY_SLIP_WARNING), TIMER_STOPPABLE | TIMER_DELETE_ME) - body_slip_timer = addtimer(CALLBACK(src, PROC_REF(body_slip_cuffs)), time, TIMER_STOPPABLE | TIMER_DELETE_ME) - -/// Stops the slipping clock -/mob/living/basic/bounty_criminal/proc/body_stop_slip() - deltimer(body_slip_warn_timer) - deltimer(body_slip_timer) - body_slip_warn_timer = null - body_slip_timer = null - -/// The visible struggle before it slips its restraints -/mob/living/basic/bounty_criminal/proc/body_slip_warning() - body_slip_warn_timer = null - if(!is_restrained() || stat == DEAD) - return - if(istype(src, /mob/living/basic/bounty_criminal/boss)) - visible_message(span_warning("[src] strains against [restraints].")) - else - visible_message(span_warning("[src] is working at [restraints].")) - INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "slipping_cuffs") - -/// Out of its restraints: a mini-boss snaps them, anyone else slips them and they drop. Returns TRUE if they came off. -/mob/living/basic/bounty_criminal/proc/body_slip_cuffs() - body_slip_timer = null - if(!is_restrained() || stat == DEAD) - return FALSE - var/snapped = istype(src, /mob/living/basic/bounty_criminal/boss) - var/message = snapped ? "[src] snaps [restraints]!" : "[src] slips out of [restraints]!" - visible_message(span_danger(message)) - var/obj/item/restraints/handcuffs/worn = body_remove_cuffs() - if(snapped && worn) - qdel(worn) - return TRUE + SEND_SIGNAL(src, COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED, self_freed ? null : src) diff --git a/voidcrew/modules/bounties/bounty_styles.dm b/voidcrew/modules/bounties/bounty_styles.dm new file mode 100644 index 00000000000..d590d9812d0 --- /dev/null +++ b/voidcrew/modules/bounties/bounty_styles.dm @@ -0,0 +1,656 @@ +/** + * # Bounty hunting: fighting styles, with real weapons + * + * Owner: P3 AI. Do not edit this file outside P3. + * + * How a criminal or a companion fights (combat.md 6.2, defaults CR-13). Every style is a real item the + * fighter carries, puts in its hand when the fight starts, and uses the way a player does, through + * controller.ai_interact() (a click): + * - brawler: fists, and now and then a real shove (a right click); + * - knife: a knife from a pocket (Wanted and above: a combat knife). Its cuts and bleeding are the + * knife's own; + * - club: a baseball bat in hand (Wanted and above: the metal bat). It knocks people back as tg's bat does; + * - pistol: a Stechkin from a shoulder holster, a spare magazine in a pocket, reloaded the player's way; + * - shotgun: a pump shotgun on the back with buckshot (Wanted and above: a semi-automatic combat + * shotgun), shells on a bandolier, loaded into the tube shell by shell; + * - bottle: beer bottles from a satchel, thrown one at a time with a real throw (tg's bottle smashes on + * whoever it hits); out of bottles, they fight with their fists; + * - holdout: a meek criminal's little Makarov, a pocket gun, and a spare magazine in a fanny pack (the + * cornered volley in bounty_ai_meek.dm). + * Nothing is spawned for the fight, no projectile comes from the body, and nothing passes through + * anyone: before a shot or a throw the fighter checks its line and steps aside for anyone it may not + * hit (bounty_ai_line_blocked()). The style sets only the timing: how often they pull the trigger or + * throw, the telegraph before it (a hit during it fumbles the attack), the reload, and the distance they + * keep. Gear is locked to the body (D3): it never drops and is never loot; bottles are consumables, so a + * thrown one leaves the hand and breaks. + */ + +/// Every fighting style by key (BOUNTY_STYLE_*), one datum each +GLOBAL_LIST_INIT(bounty_styles, init_bounty_styles()) + +/proc/init_bounty_styles() + var/list/styles = list() + for(var/datum/bounty_style/style_type as anything in subtypesof(/datum/bounty_style)) + var/datum/bounty_style/style = new style_type + if(style.key) + styles[style.key] = style + return styles + +/// A fighter's style: from its controller's blackboard +/proc/bounty_ai_style(mob/living/fighter) + var/datum/ai_controller/controller = fighter?.ai_controller + if(QDELETED(controller)) + return null + return GLOB.bounty_styles[controller.blackboard[BB_BOUNTY_STYLE]] + +/// Puts style `key` on `fighter`'s AI. Its weapon is whatever it was dressed with (bounty_build_outfit()). +/proc/bounty_ai_apply_style(mob/living/fighter, key) + var/datum/bounty_style/style = GLOB.bounty_styles[key] + var/datum/ai_controller/controller = fighter?.ai_controller + if(!style || QDELETED(controller)) + return FALSE + controller.set_blackboard_key(BB_BOUNTY_STYLE, key) + bounty_ai_clear_windup(controller) + return TRUE + +/// Whether a pawn is in a fight it can take part in: a criminal fighting, or a companion that has not broken +/proc/bounty_ai_fighting(mob/living/fighter) + if(istype(fighter, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = fighter + return criminal.ai_mode == BOUNTY_AI_FIGHTING && criminal.ai_can_act() + if(istype(fighter, /mob/living/carbon/human/npc/bounty_companion)) + var/mob/living/carbon/human/npc/bounty_companion/companion = fighter + return companion.ai_can_fight() + return FALSE + +/// Puts the weapons in `body`'s hands away on its body (the human NPC base's npc_stow_weapons()): a holster, a pocket, a back. What has nowhere to go stays in hand. +/proc/bounty_ai_holster(mob/living/carbon/human/npc/body) + if(!ishumannpc(body) || HAS_TRAIT(body, TRAIT_HANDS_BLOCKED)) + return + body.npc_stow_weapons() + +/// The first `item_type` that `body` carries and can take in hand in a moment (held, worn, in a bag or pouch it wears), or null +/proc/bounty_ai_carried(mob/living/carbon/body, item_type) + if(!ispath(item_type)) + return null + for(var/obj/item/thing as anything in body.get_all_contents_type(item_type)) + if(body.is_holding(thing) || human_npc_can_draw(body, thing)) + return thing + return null + +/// Whether `gun` fires on the next pull, or would after the pump its style's telegraph works (`pumps`) +/proc/bounty_ai_gun_ready(obj/item/gun/gun, pumps = FALSE) + if(!istype(gun)) + return FALSE + if(gun.can_shoot()) + return TRUE + var/obj/item/gun/ballistic/ballistic = gun + return pumps && istype(ballistic) && human_npc_gun_needs_rack(ballistic) + +/// Empties the hand that isn't holding `keep`, for a gun that needs both: puts what's there away, and lets go of a spent shell or a spent magazine +/proc/bounty_ai_free_other_hand(mob/living/carbon/body, obj/item/keep) + for(var/obj/item/held in body.held_items) + if(held == keep) + continue + if((isammocasing(held) || istype(held, /obj/item/ammo_box)) && !human_npc_live_rounds(held)) + body.dropItemToGround(held) + continue + if(!held.equip_to_best_slot(body)) + body.dropItemToGround(held) + return length(body.get_empty_held_indexes()) > 0 + +/** + * Whether someone `shooter` may not hit stands in its line of fire to `target`: one of its own gang, its + * own side of the site (the crew it is aboard with), or a bystander it has no quarrel with. Shots and + * throws pass over anyone lying down. This is the whole of friendly fire: nothing it fires passes + * through anyone. + */ +/proc/bounty_ai_line_blocked(mob/living/shooter, atom/target) + var/turf/start = get_turf(shooter) + var/turf/end = get_turf(target) + if(!start || !end) + return FALSE + for(var/turf/line_turf as anything in get_line(start, end)) + if(line_turf == start || line_turf == end) + continue + for(var/mob/living/other in line_turf) + if(other == shooter || other.stat == DEAD || other.body_position == LYING_DOWN) + continue + if(shooter.faction_check_atom(other) || !bounty_ai_may_hit(shooter, other)) + return TRUE + return FALSE + +/// One step sideways for a clear line to `target`, no further from them, on the leash +/proc/bounty_ai_sidestep(mob/living/fighter, atom/target) + var/turf/here = get_turf(fighter) + if(!here || !isturf(fighter.loc)) + return FALSE + var/list/options = list() + for(var/direction in GLOB.alldirs) + var/turf/next = get_step(here, direction) + if(!next || get_dist(next, target) > get_dist(here, target) || !next.can_cross_safely(fighter)) + continue + options += next + shuffle_inplace(options) + for(var/turf/next as anything in options) + if(bounty_ai_step(fighter, next)) + return TRUE + return FALSE + +/// A step straight back from `target`, now and then, on the leash +/proc/bounty_ai_back_off(datum/ai_controller/controller, mob/living/fighter, atom/target) + if(world.time < controller.blackboard[BB_BOUNTY_BACKSTEP_AT] || !isturf(fighter.loc)) + return FALSE + controller.set_blackboard_key(BB_BOUNTY_BACKSTEP_AT, world.time + BOUNTY_BACKSTEP_GAP) + var/away_dir = get_dir(target, fighter) + var/turf/away = away_dir && get_step(fighter, away_dir) + if(!away || !bounty_ai_step(fighter, away)) + return FALSE + fighter.face_atom(target) + return TRUE + +/// Forgets a wind-up in progress +/proc/bounty_ai_clear_windup(datum/ai_controller/controller) + controller.clear_blackboard_key(BB_BOUNTY_WINDUP_UNTIL) + controller.clear_blackboard_key(BB_BOUNTY_WINDUP_FROM) + controller.clear_blackboard_key(BB_BOUNTY_WINDUP_STAMINA) + +// ===== THE STYLES ===== + +/** + * How someone fights. Criminals, companions and the meek's holdout share these. The style puts a real + * weapon (and its spares) on the outfit they are dressed in, and sets how they use it; what the weapon + * does is the weapon's. + */ +/datum/bounty_style + /// BOUNTY_STYLE_* + var/key + /// Fights at range: a gun, or something thrown + var/ranged = FALSE + /// What they carry, by tier: Petty, and Wanted or above (null for bare fists) + var/weapon + var/heavy_weapon + /// What counts as their style's weapon when they look for it on themselves + var/weapon_type + /// The behaviour that keeps this style's distance while it shoots or throws + var/ranged_behavior = /datum/ai_behavior/bounty_shoot + /// Anyone closer than this and they step back (0: never) + var/min_range = 0 + /// Between trigger pulls or throws + var/interval = 1 SECONDS + /// The telegraph before a shot or a throw, and what it says and sounds like + var/windup = 0 + var/windup_message + var/windup_sound + /// The telegraph works a pump: a spent shell in the chamber doesn't need a reload + var/pumps = FALSE + /// Chance a blow is a shove instead + var/shove_chance = 0 + +/** + * Puts this style's weapon and spares on `outfit` (an instance), the heavier weapon if `heavy`. The + * weapon is carried (a holster, a pocket, the back, a bag) unless it has nowhere to go on a body. + */ +/datum/bounty_style/proc/equip(datum/outfit/outfit, heavy = FALSE) + return + +/// The weapon this style carries at `heavy` +/datum/bounty_style/proc/weapon_for(heavy) + return (heavy && heavy_weapon) || weapon + +/// The ranged weapon in `fighter`'s hand that is ready to use, or null: a loaded (or pumpable) gun, a bottle +/datum/bounty_style/proc/ready_in_hand(mob/living/carbon/fighter) + if(!ranged) + return null + for(var/obj/item/gun/gun in fighter.held_items) + if(bounty_ai_gun_ready(gun, pumps)) + return gun + return null + +/// A ranged weapon of this style `fighter` carries that is worth drawing (one that fires, or that it can load), or null +/datum/bounty_style/proc/carried_ranged(mob/living/carbon/fighter) + if(!ranged || !weapon_type) + return null + for(var/obj/item/gun/gun in fighter.get_all_contents_type(weapon_type)) + if(fighter.is_holding(gun) || !human_npc_can_draw(fighter, gun)) + continue + if(bounty_ai_gun_ready(gun, pumps) || human_npc_can_reload(fighter, gun)) + return gun + return null + +/// Whether this shot needs the telegraph first +/datum/bounty_style/proc/needs_windup(datum/ai_controller/controller) + return windup > 0 + +/// The telegraph: what they say, show and sound like before the shot +/datum/bounty_style/proc/telegraph(mob/living/carbon/fighter, atom/target, obj/item/weapon) + fighter.face_atom(target) + if(windup_message) + fighter.visible_message(span_warning(replacetext(windup_message, "%USER", "[fighter]"))) + if(windup_sound) + playsound(fighter, windup_sound, 50, TRUE) + +/** + * One step of a telegraphed attack. Returns TRUE when the attack goes now. Otherwise it is winding + * up (the telegraph is shown at the start), or the wind-up just ended in a fumble: any hit taken during + * it (damage, or more stamina loss) spoils the attack, and they try again a moment later. + */ +/datum/bounty_style/proc/windup_step(mob/living/carbon/fighter, datum/ai_controller/controller, atom/target, obj/item/weapon) + var/now = world.time + var/until = controller.blackboard[BB_BOUNTY_WINDUP_UNTIL] + if(until && now > until + BOUNTY_WINDUP_STALE) + // Left over from an interrupted fight: it has to be done again + bounty_ai_clear_windup(controller) + until = null + if(!until) + if(!needs_windup(controller)) + return TRUE + controller.set_blackboard_key(BB_BOUNTY_WINDUP_UNTIL, now + windup) + controller.set_blackboard_key(BB_BOUNTY_WINDUP_FROM, now) + controller.set_blackboard_key(BB_BOUNTY_WINDUP_STAMINA, fighter.getStaminaLoss()) + telegraph(fighter, target, weapon) + return FALSE + if(now < until) + fighter.face_atom(target) + return FALSE + var/hit_at = controller.blackboard[BB_BOUNTY_HIT_AT] + var/fumbled = (hit_at && hit_at >= controller.blackboard[BB_BOUNTY_WINDUP_FROM]) || fighter.getStaminaLoss() > controller.blackboard[BB_BOUNTY_WINDUP_STAMINA] + 1 + bounty_ai_clear_windup(controller) + if(fumbled) + fighter.visible_message(span_notice("[fighter] fumbles [weapon]!")) + controller.set_blackboard_key(BB_BOUNTY_NEXT_SHOT, now + BOUNTY_FUMBLE_PAUSE) + return FALSE + return TRUE + +/** + * The attack itself, with `weapon` in hand: a trigger pull at `target`, the player's way. Returns TRUE + * if it went. + */ +/datum/bounty_style/proc/attack(mob/living/carbon/fighter, datum/ai_controller/controller, atom/target, obj/item/weapon) + var/obj/item/gun/gun = weapon + if(!istype(gun)) + return FALSE + if(gun.weapon_weight == WEAPON_HEAVY) + bounty_ai_free_other_hand(fighter, gun) + if(!human_npc_ready_item(fighter, gun)) + return FALSE + fighter.zone_selected = human_npc_pick_aim_zone() + return controller.ai_interact(target = target, combat_mode = TRUE) + +/// Bare fists: a person's punch, and now and then a shove +/datum/bounty_style/brawler + key = BOUNTY_STYLE_BRAWLER + shove_chance = BOUNTY_BRAWLER_SHOVE_CHANCE + +/// A knife in a pocket. Its cuts bleed, as a real one's do. +/datum/bounty_style/knife + key = BOUNTY_STYLE_KNIFE + weapon = /obj/item/knife + heavy_weapon = /obj/item/knife/combat + weapon_type = /obj/item/knife + +/datum/bounty_style/knife/equip(datum/outfit/outfit, heavy = FALSE) + bounty_outfit_carry(outfit, weapon_for(heavy)) + +/// A baseball bat. A bat has no slot to go in on a body, so it is in hand from the start. +/datum/bounty_style/club + key = BOUNTY_STYLE_CLUB + weapon = /obj/item/melee/baseball_bat + heavy_weapon = /obj/item/melee/baseball_bat/ablative + weapon_type = /obj/item/melee/baseball_bat + +/datum/bounty_style/club/equip(datum/outfit/outfit, heavy = FALSE) + outfit.r_hand = weapon_for(heavy) + +/// A Stechkin in a shoulder holster, a spare magazine in a pocket +/datum/bounty_style/pistol + key = BOUNTY_STYLE_PISTOL + ranged = TRUE + weapon = /obj/item/gun/ballistic/automatic/pistol/aps + weapon_type = /obj/item/gun/ballistic/automatic/pistol/aps + interval = BOUNTY_PISTOL_INTERVAL + min_range = BOUNTY_PISTOL_MIN_RANGE + windup = BOUNTY_PISTOL_WINDUP + windup_message = "%USER raises a pistol." + windup_sound = 'sound/items/weapons/gun/pistol/rack_small.ogg' + +/datum/bounty_style/pistol/equip(datum/outfit/outfit, heavy = FALSE) + var/obj/item/gun/ballistic/gun_type = weapon_for(heavy) + outfit.belt = /obj/item/storage/belt/holster + outfit.belt_contents = null + LAZYADDASSOC(outfit.belt_contents, gun_type, 1) + bounty_outfit_carry(outfit, initial(gun_type.accepted_magazine_type)) + +/// Only the first shot of a string needs the gun raised +/datum/bounty_style/pistol/needs_windup(datum/ai_controller/controller) + var/last_shot = controller.blackboard[BB_BOUNTY_LAST_SHOT] + return !last_shot || world.time - last_shot > BOUNTY_PISTOL_STRING_GAP + +/// Buckshot: a pump shotgun on the back, shells on a bandolier. The pump is the telegraph before each blast. +/datum/bounty_style/shotgun + key = BOUNTY_STYLE_SHOTGUN + ranged = TRUE + weapon = /obj/item/gun/ballistic/shotgun/lethal + heavy_weapon = /obj/item/gun/ballistic/shotgun/automatic/combat/bounty + weapon_type = /obj/item/gun/ballistic/shotgun + ranged_behavior = /datum/ai_behavior/bounty_shoot/close + interval = BOUNTY_SHOTGUN_INTERVAL + windup = BOUNTY_SHOTGUN_WINDUP + windup_message = "%USER pumps the shotgun." + windup_sound = 'sound/items/weapons/gun/shotgun/rack.ogg' + pumps = TRUE + +/datum/bounty_style/shotgun/equip(datum/outfit/outfit, heavy = FALSE) + outfit.back = weapon_for(heavy) + outfit.belt = /obj/item/storage/belt/bandolier + outfit.belt_contents = null + LAZYADDASSOC(outfit.belt_contents, /obj/item/ammo_casing/shotgun/buckshot, BOUNTY_SHOTGUN_SHELLS) + +/// The pump is real: a spent shell in the chamber is worked out by hand, which makes the rack's own sound +/datum/bounty_style/shotgun/telegraph(mob/living/carbon/fighter, atom/target, obj/item/weapon) + fighter.face_atom(target) + if(windup_message) + fighter.visible_message(span_warning(replacetext(windup_message, "%USER", "[fighter]"))) + var/obj/item/gun/ballistic/gun = weapon + if(istype(gun) && human_npc_gun_needs_rack(gun) && human_npc_ready_item(fighter, gun)) + gun.attack_self(fighter) + else if(windup_sound) + playsound(fighter, windup_sound, 50, TRUE) + +/// Beer bottles from a satchel, thrown one at a time +/datum/bounty_style/bottle + key = BOUNTY_STYLE_BOTTLE + ranged = TRUE + weapon = /obj/item/reagent_containers/cup/glass/bottle/beer + weapon_type = /obj/item/reagent_containers/cup/glass/bottle + ranged_behavior = /datum/ai_behavior/bounty_shoot/throw + interval = BOUNTY_BOTTLE_INTERVAL + min_range = BOUNTY_BOTTLE_MIN_RANGE + windup = BOUNTY_BOTTLE_WINDUP + windup_message = "%USER winds up to throw a bottle." + +/datum/bounty_style/bottle/equip(datum/outfit/outfit, heavy = FALSE) + if(!outfit.back) + outfit.back = /obj/item/storage/backpack/satchel + LAZYADDASSOC(outfit.backpack_contents, weapon, heavy ? BOUNTY_BOTTLES_WANTED : BOUNTY_BOTTLES_PETTY) + +/datum/bounty_style/bottle/ready_in_hand(mob/living/carbon/fighter) + for(var/obj/item/reagent_containers/cup/glass/bottle/bottle in fighter.held_items) + if(bottle.isGlass) + return bottle + return null + +/datum/bounty_style/bottle/carried_ranged(mob/living/carbon/fighter) + for(var/obj/item/reagent_containers/cup/glass/bottle/bottle in fighter.get_all_contents_type(weapon_type)) + if(bottle.isGlass && !fighter.is_holding(bottle) && human_npc_can_draw(fighter, bottle)) + return bottle + return null + +/// A real throw: the bottle leaves the hand and flies, as a player's does. tg's bottle breaks on whoever it hits. +/datum/bounty_style/bottle/attack(mob/living/carbon/fighter, datum/ai_controller/controller, atom/target, obj/item/weapon) + if(!human_npc_ready_item(fighter, weapon)) + return FALSE + fighter.zone_selected = human_npc_pick_aim_zone() + fighter.throw_mode_on(THROW_MODE_TOGGLE) + controller.ai_interact(target = target, combat_mode = TRUE) + // A click that didn't go through leaves throw mode on: the next click must not be a throw + if(fighter.throw_mode) + fighter.throw_mode_off(THROW_MODE_TOGGLE) + return FALSE + return !fighter.is_holding(weapon) + +/** + * The meek's little gun: a Makarov in a pocket and a spare magazine in a fanny pack. The volley is fired + * one trigger pull at a time by the meek AI (bounty_ai_meek.dm). + */ +/datum/bounty_style/holdout + key = BOUNTY_STYLE_HOLDOUT + ranged = TRUE + weapon = /obj/item/gun/ballistic/automatic/pistol + weapon_type = /obj/item/gun/ballistic/automatic/pistol + interval = BOUNTY_MEEK_PISTOL_GAP + +/datum/bounty_style/holdout/equip(datum/outfit/outfit, heavy = FALSE) + var/obj/item/gun/ballistic/gun_type = weapon + bounty_outfit_carry(outfit, gun_type) + outfit.belt = /obj/item/storage/belt/fannypack + outfit.belt_contents = null + LAZYADDASSOC(outfit.belt_contents, initial(gun_type.accepted_magazine_type), 1) + +/** + * Puts `item_type` somewhere `outfit` carries it: a free pocket, else the belt's pouch, else the bag. + * Returns TRUE if it found a place. + */ +/proc/bounty_outfit_carry(datum/outfit/outfit, item_type) + if(!istype(outfit) || !ispath(item_type)) + return FALSE + if(!outfit.r_pocket) + outfit.r_pocket = item_type + return TRUE + if(!outfit.l_pocket) + outfit.l_pocket = item_type + return TRUE + if(outfit.belt && ispath(outfit.belt, /obj/item/storage)) + LAZYADDASSOC(outfit.belt_contents, item_type, 1) + return TRUE + if(outfit.back && ispath(outfit.back, /obj/item/storage)) + LAZYADDASSOC(outfit.backpack_contents, item_type, 1) + return TRUE + return FALSE + +/** + * The outfit a bounty fighter wears: plain clothes for `archetype` in `zone`, its tier's armour, its + * style's weapon and spares, and `treatment` (a bruise pack, a stimpack) in a pocket. + */ +/proc/bounty_fighter_outfit(archetype, zone, tier, style_key, heavy = FALSE, treatment) + var/datum/outfit/outfit = bounty_outfit_instance(bounty_plain_clothes(archetype, zone)) + if(!outfit) + return null + bounty_add_tier_armour(outfit, tier) + var/datum/bounty_style/style = GLOB.bounty_styles[style_key] + style?.equip(outfit, heavy) + if(treatment) + bounty_outfit_carry(outfit, treatment) + return outfit + +/// A Wanted criminal's combat shotgun, loaded with buckshot (tg's comes with rubber shot) +/obj/item/gun/ballistic/shotgun/automatic/combat/bounty + accepted_magazine_type = /obj/item/ammo_box/magazine/internal/shot/com/bounty + +/obj/item/ammo_box/magazine/internal/shot/com/bounty + ammo_type = /obj/item/ammo_casing/shotgun/buckshot + +// ===== ARMING ===== + +/** + * In a fight with a target: keeps their style's weapon in hand. Draws it when the fight starts (a + * holster, a pocket, the back, a bag), reloads a gun the player's way when it runs dry (the human NPC + * base's reload; a shotgun shell by shell), or draws something else, or nothing and uses fists. + */ +/datum/ai_planning_subtree/bounty_arm + +/datum/ai_planning_subtree/bounty_arm/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/fighter = controller.pawn + if(!ishumannpc(fighter) || !bounty_ai_fighting(fighter) || !controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + if(HAS_TRAIT(fighter, TRAIT_HANDS_BLOCKED) || LAZYLEN(fighter.do_afters) || world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT]) + return + var/datum/bounty_style/style = bounty_ai_style(fighter) + if(!style) + return + var/obj/item/to_draw + if(style.ranged) + if(style.ready_in_hand(fighter)) + return + var/obj/item/gun/ballistic/gun = human_npc_held_gun(fighter) + if(istype(gun) && human_npc_can_reload(fighter, gun)) + controller.queue_behavior(gun.internal_magazine ? /datum/ai_behavior/human_npc_reload/bounty_tube : /datum/ai_behavior/human_npc_reload) + return SUBTREE_RETURN_FINISH_PLANNING + to_draw = style.carried_ranged(fighter) + else if(style.weapon_type) + if(human_npc_held_melee_weapon(fighter)) + return + to_draw = bounty_ai_carried(fighter, style.weapon_type) + if(fighter.is_holding(to_draw)) + return + else + return + if(!to_draw && !human_npc_held_melee_weapon(fighter)) + to_draw = human_npc_backup_weapon(fighter) + if(!to_draw) + return + controller.set_blackboard_key(BB_HUMAN_NPC_DRAW_ITEM, to_draw) + controller.queue_behavior(/datum/ai_behavior/human_npc_draw, BB_HUMAN_NPC_DRAW_ITEM) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * The human NPC base's reload, for a gun loaded through its tube (a shotgun): every shell it can fit, + * one at a time, then the pump. + */ +/datum/ai_behavior/human_npc_reload/bounty_tube + give_up_after = 15 SECONDS + +/datum/ai_behavior/human_npc_reload/bounty_tube/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/ballistic/gun = human_npc_held_gun(npc) + if(!istype(gun) || !gun.internal_magazine || world.time >= controller.blackboard[BB_HUMAN_NPC_RELOAD_UNTIL]) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/obj/item/spent = human_npc_spent_ammo_in_hand(npc, gun) + if(spent) + npc.dropItemToGround(spent, force = TRUE) + return AI_BEHAVIOR_DELAY + var/obj/item/ammo_box/magazine/tube = gun.magazine + if(tube && tube.ammo_count(FALSE) < tube.max_ammo) + var/obj/item/ammo = human_npc_find_ammo(npc, gun) + if(ammo) + if(!npc.is_holding(ammo)) + if(!human_npc_take_in_hand(npc, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, ammo)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + if(human_npc_gun_needs_rack(gun)) + if(!human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + if(gun.can_shoot()) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + +// ===== SHOOTING AND THROWING ===== + +/// In a fight with a ranged style and its weapon ready in hand: shoots or throws, at the style's distance +/datum/ai_planning_subtree/bounty_ranged + +/datum/ai_planning_subtree/bounty_ranged/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/fighter = controller.pawn + if(!bounty_ai_fighting(fighter) || !controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/datum/bounty_style/style = bounty_ai_style(fighter) + if(!style?.ranged || HAS_TRAIT(fighter, TRAIT_HANDS_BLOCKED) || !style.ready_in_hand(fighter)) + return + controller.queue_behavior(style.ranged_behavior, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * Shoots (or throws) at the target from the style's distance: closes in while it can't see them, steps + * back from anyone inside the style's min_range, steps aside for anyone it may not hit in its line, + * telegraphs, then pulls the trigger (or throws) once per the style's interval. Planning carries on + * meanwhile, so a new target, a retreat or a call for help is picked up mid-fight. + */ +/datum/ai_behavior/bounty_shoot + // Often enough that a style fires at its own pace and its wind-up is never stretched + action_cooldown = 0.1 SECONDS + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_MOVE_AND_PERFORM | AI_BEHAVIOR_CAN_PLAN_DURING_EXECUTION + required_distance = BOUNTY_PISTOL_RANGE + +/datum/ai_behavior/bounty_shoot/setup(datum/ai_controller/controller, target_key, targeting_strategy_key) + . = ..() + var/atom/target = controller.blackboard[target_key] + if(QDELETED(target)) + return FALSE + set_movement_target(controller, target) + +/datum/ai_behavior/bounty_shoot/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key) + var/mob/living/carbon/fighter = controller.pawn + var/atom/target = controller.blackboard[target_key] + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) + if(QDELETED(target) || !strategy?.can_attack(fighter, target, BOUNTY_FIGHT_VISION)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/datum/ai_controller/basic_controller/bounty/brain = controller + if(istype(brain) && !brain.may_shoot(target)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + var/datum/bounty_style/style = bounty_ai_style(fighter) + var/obj/item/weapon = style?.ready_in_hand(fighter) + if(!weapon || HAS_TRAIT(fighter, TRAIT_HANDS_BLOCKED)) + // Dry or empty-handed: the arm subtree reloads or draws, or they fight with their fists + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + if(!can_see(fighter, target, required_distance)) + return AI_BEHAVIOR_INSTANT + var/winding = !!controller.blackboard[BB_BOUNTY_WINDUP_UNTIL] + if(style.min_range && get_dist(fighter, target) < style.min_range && !winding) + bounty_ai_back_off(controller, fighter, target) + if(bounty_ai_line_blocked(fighter, target)) + bounty_ai_sidestep(fighter, target) + return AI_BEHAVIOR_DELAY + if(!winding && world.time < controller.blackboard[BB_BOUNTY_NEXT_SHOT]) + return AI_BEHAVIOR_DELAY + if(!style.windup_step(fighter, controller, target, weapon)) + return AI_BEHAVIOR_DELAY + if(style.attack(fighter, controller, target, weapon)) + controller.set_blackboard_key(BB_BOUNTY_LAST_SHOT, world.time) + controller.set_blackboard_key(BB_BOUNTY_NEXT_SHOT, world.time + style.interval) + return AI_BEHAVIOR_DELAY + +/// A wind-up cut short never carries over: the next shot telegraphs again +/datum/ai_behavior/bounty_shoot/finish_action(datum/ai_controller/controller, succeeded, target_key, targeting_strategy_key) + . = ..() + bounty_ai_clear_windup(controller) + +/// The shotgun closes in to BOUNTY_SHOTGUN_RANGE +/datum/ai_behavior/bounty_shoot/close + required_distance = BOUNTY_SHOTGUN_RANGE + +/// The bottle thrower stays BOUNTY_BOTTLE_RANGE off +/datum/ai_behavior/bounty_shoot/throw + required_distance = BOUNTY_BOTTLE_RANGE + +// ===== MELEE ===== + +/** + * In a fight with a target and no ranged weapon ready: walks up and hits with the best melee weapon in + * hand, or a fist (the human NPC base's melee). A brawler now and then shoves instead. + */ +/datum/ai_planning_subtree/bounty_melee + +/datum/ai_planning_subtree/bounty_melee/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/fighter = controller.pawn + if(!bounty_ai_fighting(fighter) || !controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/datum/bounty_style/style = bounty_ai_style(fighter) + if(style?.ranged && style.ready_in_hand(fighter)) + return + controller.queue_behavior(/datum/ai_behavior/basic_melee_attack/human_npc/bounty, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY, BB_BASIC_MOB_CURRENT_TARGET_HIDING_LOCATION) + return SUBTREE_RETURN_FINISH_PLANNING + +/// The human NPC base's melee (tg's, through a click), with the brawler's shove: a real right click with an empty hand +/datum/ai_behavior/basic_melee_attack/human_npc/bounty + +/datum/ai_behavior/basic_melee_attack/human_npc/bounty/perform(seconds_per_tick, datum/ai_controller/controller, target_key, targeting_strategy_key, hiding_location_key) + var/mob/living/carbon/fighter = controller.pawn + var/mob/living/target = controller.blackboard[target_key] + var/datum/bounty_style/style = bounty_ai_style(fighter) + if(!style?.shove_chance || !isliving(target) || !fighter.CanReach(target) || world.time < fighter.next_move) + return ..() + var/attack_at = controller.blackboard[BB_BASIC_MOB_MELEE_COOLDOWN_TIMER] + if(isnull(attack_at) || attack_at > world.time || !prob(style.shove_chance)) + return ..() + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(controller.blackboard[targeting_strategy_key]) + if(!strategy?.can_attack(fighter, target) || !human_npc_ready_empty_hand(fighter)) + return ..() + fighter.zone_selected = BODY_ZONE_CHEST + controller.ai_interact(target = target, combat_mode = TRUE, modifiers = list(RIGHT_CLICK = TRUE)) + return AI_BEHAVIOR_DELAY diff --git a/voidcrew/modules/bounties/bounty_turn_in.dm b/voidcrew/modules/bounties/bounty_turn_in.dm index 463539bf9a0..422892a428f 100644 --- a/voidcrew/modules/bounties/bounty_turn_in.dm +++ b/voidcrew/modules/bounties/bounty_turn_in.dm @@ -36,7 +36,7 @@ // ===== TURNING IN ===== /// Bind field work before the criminal or its proof can be carried to another band. -/datum/criminal_bounty/proc/board_note_work_zone(mob/living/basic/bounty_criminal/target) +/datum/criminal_bounty/proc/board_note_work_zone(mob/living/carbon/human/npc/bounty_criminal/target) if(!is_open() || !record || record.board_work_zone || target != criminal() || record.status != BOUNTY_RECORD_WANTED) return var/band = get_zone_type_for_player_turf(get_turf(target)) @@ -112,7 +112,7 @@ /datum/criminal_bounty/proc/board_target_on_pad(obj/machinery/mission_pad/pad) if(!pad || !isturf(pad.loc)) return null - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() if(criminal && criminal.loc == pad.loc) return criminal var/obj/item/bounty_proof/proof = board_proof() @@ -129,13 +129,13 @@ /datum/criminal_bounty/proc/board_target_refusal(obj/machinery/mission_pad/pad, list/found) var/atom/movable/target = board_target_on_pad(pad) if(!target) - var/mob/living/basic/bounty_criminal/criminal = criminal() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal() if(criminal && get_turf(criminal) == get_turf(pad)) return "they're inside [criminal.loc]" return "nothing on the pad for this bounty" var/state = BOUNTY_STATE_DEAD - if(istype(target, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = target + if(istype(target, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = target if(criminal.client || criminal.mind) return "the pad won't take them" state = criminal.capture_state() @@ -147,9 +147,11 @@ /** * Pays `ship` for `target` (this bounty's criminal or its proof) in `state` at `pad`, and ends the - * bounty for everyone. No yield from start to end. The share is the worse of `state` and the worst - * state the criminal ever reached (12.1); a live catch gets its hurt, its captor and its preferred - * prison and goes to the prisoner pool. Returns list(credits, vouchers), or why not. + * bounty for everyone. No yield from start to end, and it pays once: the posting is closed before + * anything else can press. The share is the criminal's condition on the pad, read at the press (owner + * ruling 2026-11-08: dead 25%, in crit 60%, cuffed or stunned 100%; what it went through on the way + * doesn't count). A live catch gets its hurt, its captor and its preferred prison and goes to the + * prisoner pool. Returns list(credits, vouchers), or why not. */ /datum/criminal_bounty/proc/board_claim(obj/structure/overmap/ship/ship, obj/machinery/mission_pad/pad, atom/movable/target, state, mob/user) // Everything after this line runs once: a second press, or a second ship, finds it closed @@ -157,13 +159,13 @@ return "that bounty is closed" if(QDELETED(target) || QDELETED(ship)) return "nothing on the pad for this bounty" - var/mob/living/basic/bounty_criminal/criminal = istype(target, /mob/living/basic/bounty_criminal) ? target : null + var/mob/living/carbon/human/npc/bounty_criminal/criminal = istype(target, /mob/living/carbon/human/npc/bounty_criminal) ? target : null var/obj/item/bounty_proof/proof = istype(target, /obj/item/bounty_proof) ? target : null if(!criminal && !proof) return "nothing on the pad for this bounty" if(proof) state = BOUNTY_STATE_DEAD - var/share = board_share_for(state, criminal ? criminal.worst_state : BOUNTY_STATE_DEAD) + var/share = board_share_for(state) if(!share) return "they're standing free" var/list/pay = board_payout(share) @@ -266,8 +268,8 @@ var/state = null if(target) state = "proof" - if(istype(target, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = target + if(istype(target, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = target state = criminal.capture_state() var/refusal if(private_to && offered_to() != ship) @@ -316,8 +318,8 @@ /// What the pad says about `arrived` on it, or null if it says nothing (not a bounty target, or not this ship's to turn in) /obj/machinery/mission_pad/proc/bounty_pad_line(atom/movable/arrived) var/datum/criminal_bounty/posting - if(istype(arrived, /mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = arrived + if(istype(arrived, /mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = arrived posting = criminal.posting() else if(istype(arrived, /obj/item/bounty_proof)) var/obj/item/bounty_proof/proof = arrived @@ -343,18 +345,19 @@ // ===== THE BEAM ===== /** - * The pad takes them: held still, harmless and untouchable, and fading out in the prisoner's - * beam (outpost_prison_prisoner.dm beam_out()), then deleted. Never a teleport (AR-A1). Nothing - * they leave behind counts as proof of death. + * The pad takes them: held still and harmless, fading out in the prisoner's beam + * (outpost_prison_prisoner.dm beam_out()), then deleted. Never a teleport (AR-A1). The pay was settled + * before the beam starts, so nothing that happens to them during it changes it; there is no godmode + * (defaults 1.8, CR-18). Their locked kit goes with them (D3); anything on them that isn't kit (papers, + * a key, the crew's own cuffs) is left on the pad. Nothing they leave behind counts as proof of death. */ -/mob/living/basic/bounty_criminal/proc/board_beam_out() +/mob/living/carbon/human/npc/bounty_criminal/proc/board_beam_out() // Their last words as the pad takes them (P1's beamed_out lines), said before the beam holds them still if(stat == CONSCIOUS) INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "beamed_out", null, TRUE) ADD_TRAIT(src, TRAIT_BOUNTY_REMOVED, BOUNTY_PAD_TRAIT) ADD_TRAIT(src, TRAIT_IMMOBILIZED, BOUNTY_PAD_TRAIT) ADD_TRAIT(src, TRAIT_INCAPACITATED, BOUNTY_PAD_TRAIT) - ADD_TRAIT(src, TRAIT_GODMODE, BOUNTY_PAD_TRAIT) REMOVE_TRAIT(src, TRAIT_MISSION_FIELD_MOB, BOUNTY_TRAIT) ai_controller?.set_ai_status(AI_STATUS_OFF) pulledby?.stop_pulling() @@ -368,8 +371,13 @@ transporter_dematerialise(src, BOUNTY_BEAM_TIME) addtimer(CALLBACK(src, PROC_REF(board_finish_beam_out)), BOUNTY_BEAM_TIME, TIMER_DELETE_ME) -/mob/living/basic/bounty_criminal/proc/board_finish_beam_out() +/mob/living/carbon/human/npc/bounty_criminal/proc/board_finish_beam_out() var/turf/spot = get_turf(src) + npc_drop_not_gear() + // The crew's restraints stay with the crew: they are theirs, not the criminal's. + if(spot) + for(var/obj/item/restraints/worn in list(handcuffed, legcuffed)) + transferItemToLoc(worn, spot, force = TRUE) if(spot) new /obj/effect/temp_visual/transporter_flash/departure(spot) transporter_sparks(spot) @@ -445,13 +453,13 @@ * the criminal away (TRAIT_BOUNTY_REMOVED), when its bounty is closed or relisting, or when the * criminal went with its site's interior (it comes back at the next load). One proof per bounty. */ -/proc/bounty_drop_proof(mob/living/basic/bounty_criminal/criminal) +/proc/bounty_drop_proof(mob/living/carbon/human/npc/bounty_criminal/criminal) if(!criminal || HAS_TRAIT(criminal, TRAIT_BOUNTY_REMOVED)) return null var/datum/criminal_bounty/posting = criminal.posting_ref?.resolve() if(!posting || !posting.is_open() || posting.board_relisting) return null - var/mob/living/basic/bounty_criminal/current = posting.criminal() + var/mob/living/carbon/human/npc/bounty_criminal/current = posting.criminal() if(current && current != criminal) return null var/turf/spot = get_turf(criminal) @@ -526,13 +534,15 @@ /** * Called from the bluespace body bag's attempt_fold(): TRUE stops `folder` folding `bag`, because - * a living bounty criminal is inside (AR-E7). Tells `folder` why. + * a bounty criminal inside is alive and struggling (AR-E7, PL-20). One in crit or dead is a body, and + * folds like anyone's; the bag still never rides a transporter beam with a live one in it + * (bounty_blocks_transport()). Tells `folder` why. */ /proc/bounty_blocks_bag_fold(obj/structure/closet/body_bag/bag, mob/living/folder) if(!bag) return FALSE - for(var/mob/living/basic/bounty_criminal/criminal in bag.get_all_contents()) - if(criminal.stat == DEAD) + for(var/mob/living/carbon/human/npc/bounty_criminal/criminal in bag.get_all_contents()) + if(!bounty_body_bound(criminal) || criminal.is_downed()) continue if(folder) to_chat(folder, span_warning("[bag] won't fold with someone alive and struggling inside.")) @@ -555,12 +565,22 @@ if(QDELETED(thing)) return FALSE for(var/mob/living/passenger in thing.get_all_contents()) - if(passenger == thing || passenger.stat == DEAD) - continue - if(istype(passenger, /mob/living/basic/bounty_criminal) || istype(passenger, /mob/living/basic/bounty_companion)) + if(passenger != thing && bounty_body_bound(passenger)) return TRUE return FALSE +/** + * Whether `body` is a living bounty person the pad is the only way out for: a criminal still held to its + * site (its body's TRAIT_NO_TELEPORT; a convict a crew talked down loses it and is free to go), or a + * companion. Dead bodies are proof, and travel like any body. + */ +/proc/bounty_body_bound(mob/living/body) + if(!isliving(body) || QDELETED(body) || body.stat == DEAD) + return FALSE + if(istype(body, /mob/living/carbon/human/npc/bounty_criminal)) + return HAS_TRAIT_FROM(body, TRAIT_NO_TELEPORT, BOUNTY_BODY_TRAIT) + return istype(body, /mob/living/carbon/human/npc/bounty_companion) + /** * Called from the transporter pad's gather_payload(): TRUE keeps `thing` off the beam. A criminal * refuses teleports itself (P2); this stops a locker, a crate or a bag with a living one inside @@ -570,7 +590,6 @@ /proc/bounty_blocks_transport(atom/movable/thing) if(QDELETED(thing)) return FALSE - if(istype(thing, /mob/living/basic/bounty_criminal) || istype(thing, /mob/living/basic/bounty_companion)) - var/mob/living/rider = thing - return rider.stat != DEAD + if(ismob(thing)) + return bounty_body_bound(thing) return bounty_holds_living_criminal(thing) diff --git a/voidcrew/modules/bounties/bounty_types.dm b/voidcrew/modules/bounties/bounty_types.dm index 6af790269ea..12943ae57fd 100644 --- a/voidcrew/modules/bounties/bounty_types.dm +++ b/voidcrew/modules/bounties/bounty_types.dm @@ -140,7 +140,7 @@ GLOBAL_LIST_EMPTY(bounty_prisoner_pool) var/placement_kind /// Weakref to where the criminal is: the overmap object of the planet, ruin, pirate ship or trader outpost var/datum/weakref/site_ref - /// Weakref to the criminal's mob while its site is loaded (/mob/living/basic/bounty_criminal) + /// Weakref to the criminal's mob while its site is loaded (/mob/living/carbon/human/npc/bounty_criminal) var/datum/weakref/criminal_ref /// Weakref to the ship a private offer is for; null for a public bounty var/datum/weakref/private_to @@ -152,9 +152,9 @@ GLOBAL_LIST_EMPTY(bounty_prisoner_pool) var/value = 0 /// world.time it comes off the board var/expires_at = 0 - /// Weakrefs to its decoys at a trader outpost (/mob/living/basic/bounty_criminal/decoy) + /// Weakrefs to its decoys at a trader outpost (/mob/living/carbon/human/npc/bounty_criminal/decoy) var/list/decoys = list() - /// Weakrefs to the criminal's companions (/mob/living/basic/bounty_companion) + /// Weakrefs to the criminal's companions (/mob/living/carbon/human/npc/bounty_companion) var/list/companions = list() /// The sighting marker hunters' GPS units point to var/obj/effect/bounty_sighting/marker @@ -167,29 +167,26 @@ GLOBAL_LIST_EMPTY(bounty_prisoner_pool) /// The criminal's mob, if its site is loaded and it still exists /datum/criminal_bounty/proc/criminal() - var/mob/living/basic/bounty_criminal/criminal = criminal_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = criminal_ref?.resolve() return QDELETED(criminal) ? null : criminal // ===== THE CRIMINALS ===== /** - * A wanted criminal, while its site is loaded. Its own mob family: not an outpost prisoner (a - * caught criminal becomes a new prisoner, rebuilt from the record) and never under - * /mob/living/basic/boss (megafauna are banned from outposts). No AI controller until P3 gives it - * one. Built-in values are set by P2 in bounty_criminal.dm. + * A wanted criminal, while its site is loaded: a real human body (/mob/living/carbon/human/npc, the + * human NPC base) driven by the bounty AI. Its crit, stamina, cuffs, wounds, death and revival are + * tg's, as for a player; only its AI and the bounty rules on top are its own. Not an outpost + * prisoner: a caught criminal becomes a new prisoner, rebuilt from the record. Built-in values are + * set by P2 in bounty_criminal.dm. */ -/mob/living/basic/bounty_criminal +/mob/living/carbon/human/npc/bounty_criminal /// Who they are (/datum/bounty_record) var/datum/bounty_record/record /// Weakref to the posting they are wanted on (/datum/criminal_bounty); null for an admin spawn with none var/datum/weakref/posting_ref - /// At or below the downed line: floored, harmless and draggable until they recover (P2) - var/downed = FALSE /// Hidden in a locker, a crate, as a potted plant or in the dark (P3) var/hidden = FALSE - /// The restraints on them, held in their contents (P2), or null - var/obj/item/restraints/handcuffs/restraints - /// Weakrefs to their companions (/mob/living/basic/bounty_companion) + /// Weakrefs to their companions (/mob/living/carbon/human/npc/bounty_companion) var/list/companions = list() /// Blending in at a trader outpost: the fugitive and its decoys idle, talk and move the same way (P6, P3) var/blended = FALSE @@ -197,42 +194,40 @@ GLOBAL_LIST_EMPTY(bounty_prisoner_pool) var/list/grudge = list() /// Their leash, list(min_x, min_y, max_x, max_y, z), or null for none (P2's leash_ok()) var/list/site_bounds - /// The worst capture state they have reached (BOUNTY_STATE_*): once downed or dead, the pay share never rises again. P2 writes it, P5 pays on it. - var/worst_state = BOUNTY_STATE_FREE /// The posting they are wanted on, if it still exists -/mob/living/basic/bounty_criminal/proc/posting() +/mob/living/carbon/human/npc/bounty_criminal/proc/posting() var/datum/criminal_bounty/posting = posting_ref?.resolve() return QDELETED(posting) ? null : posting /// A meek criminal: runs, hides, blends in. P2 sets its body, P3 its AI. -/mob/living/basic/bounty_criminal/meek +/mob/living/carbon/human/npc/bounty_criminal/meek /// A normal criminal: found doing something, fights back. P2 sets its body, P3 its AI. -/mob/living/basic/bounty_criminal/normal +/mob/living/carbon/human/npc/bounty_criminal/normal /// A mini-boss: the base of the five kits. P4 sets its body and AI in bounty_boss.dm. -/mob/living/basic/bounty_criminal/boss +/mob/living/carbon/human/npc/bounty_criminal/boss -/mob/living/basic/bounty_criminal/boss/juggernaut -/mob/living/basic/bounty_criminal/boss/pyromaniac -/mob/living/basic/bounty_criminal/boss/demolitionist -/mob/living/basic/bounty_criminal/boss/ghost -/mob/living/basic/bounty_criminal/boss/heavy +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist +/mob/living/carbon/human/npc/bounty_criminal/boss/ghost +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy /** * A patron at a trader outpost who shares some of the fugitive's features. Same body, same * blended-in mode and same examine layout as the fugitive, so nothing about it gives the fugitive * away; only what a warrant does to it differs. P6 owns its behaviour (bounty_outpost.dm). */ -/mob/living/basic/bounty_criminal/decoy +/mob/living/carbon/human/npc/bounty_criminal/decoy /** * Someone with a normal criminal: fights beside them, is not a bounty and pays nothing. P3 owns it * (bounty_companions.dm) and sets its built-in values. */ -/mob/living/basic/bounty_companion - /// Weakref to the criminal they are with (/mob/living/basic/bounty_criminal) +/mob/living/carbon/human/npc/bounty_companion + /// Weakref to the criminal they are with (/mob/living/carbon/human/npc/bounty_criminal) var/datum/weakref/leader_ref // ===== OBJECTS ===== diff --git a/voidcrew/modules/bounties/strings/bounty_lair_mafia.json b/voidcrew/modules/bounties/strings/bounty_lair_mafia.json index 1c4af5a59e4..d0966d79c2a 100644 --- a/voidcrew/modules/bounties/strings/bounty_lair_mafia.json +++ b/voidcrew/modules/bounties/strings/bounty_lair_mafia.json @@ -33,6 +33,16 @@ "I'm bleeding here!", "Somebody shoot them!" ], + "goon_shoved": [ + "Hands off.", + "Back up, pal.", + "Don't touch me." + ], + "goon_cuffed": [ + "Get off me!", + "Not a chance.", + "You're not putting those on me." + ], "tommy_alert": [ "Nobody goes through this door. Boss's orders.", "You picked the wrong club, friend.", @@ -83,5 +93,10 @@ "You'll pay for that.", "My family will find you.", "Not like this. Not in my own club." + ], + "don_cuffed": [ + "Nobody takes me alive.", + "You'll have to kill me first.", + "Cuffs? On me?" ] } diff --git a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm index 0c196a8a813..5a9f40be306 100644 --- a/voidcrew/modules/hyperviolence/overlays_exec/executions.dm +++ b/voidcrew/modules/hyperviolence/overlays_exec/executions.dm @@ -182,11 +182,6 @@ return TRUE return pulledby?.grab_state >= GRAB_AGGRESSIVE -// Bounty criminals keep their own cuffs, downed and stunned states (voidcrew/modules/bounties/) until -// the human NPC migration moves them onto real bodies. -/mob/living/basic/bounty_criminal/hv_is_helpless() - return ..() || is_restrained() || is_downed() || body_is_stunned() - // ---- What the round did ---- /** diff --git a/voidcrew/modules/missions/mission_pad.dm b/voidcrew/modules/missions/mission_pad.dm index 3c59618a55b..f52ef602e19 100644 --- a/voidcrew/modules/missions/mission_pad.dm +++ b/voidcrew/modules/missions/mission_pad.dm @@ -181,7 +181,7 @@ if(arrived.loc != loc) return // A wanted criminal or its proof of death: the pad says what Turn In would pay (voidcrew/modules/bounties/bounty_turn_in.dm) - if(istype(arrived, /mob/living/basic/bounty_criminal) || istype(arrived, /obj/item/bounty_proof)) + if(istype(arrived, /mob/living/carbon/human/npc/bounty_criminal) || istype(arrived, /obj/item/bounty_proof)) INVOKE_ASYNC(src, PROC_REF(bounty_pad_announce), arrived) if(!isitem(arrived)) return diff --git a/voidcrew/modules/npc_scaling/zone_profiles.dm b/voidcrew/modules/npc_scaling/zone_profiles.dm index 93faa694435..1397933e795 100644 --- a/voidcrew/modules/npc_scaling/zone_profiles.dm +++ b/voidcrew/modules/npc_scaling/zone_profiles.dm @@ -101,8 +101,8 @@ /mob/living/simple_animal/hostile/asteroid/elite, /mob/living/basic/boss, /mob/living/basic/trooper/pirate/faction/boss, - /mob/living/basic/bounty_criminal/boss, - /mob/living/basic/bounty_criminal/kingpin, + /mob/living/carbon/human/npc/bounty_criminal/boss, + /mob/living/carbon/human/npc/bounty_criminal/kingpin, /mob/living/basic/bounty_lair_boss, /mob/living/basic/lich, /mob/living/basic/hoarfrost_matriarch, diff --git a/voidcrew/modules/unit_tests/voidcrew_ambient_strays.dm b/voidcrew/modules/unit_tests/voidcrew_ambient_strays.dm index 48dbd3d1c30..9511e78dc9d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_ambient_strays.dm +++ b/voidcrew/modules/unit_tests/voidcrew_ambient_strays.dm @@ -271,7 +271,13 @@ // THE ESCAPED CONVICT // ========================================================================= -/// The cap follows the crews; a convict is a meek petty criminal in orange; seen, they get one private offer on their own record, within the two-offer limit; talked down, the offer is withdrawn unpaid and a stray with their name follows the crew; an offer that ends leaves a free convict where they are +/** + * The cap follows the crews; a convict is a meek petty criminal in prison orange with the meek's holdout + * kit; seen, they give up the tg way (weapon away, *surrender's real Paralyze) and get one private offer on + * their own record, within the two-offer limit; talked down, the SAME body goes with the crew (follower AI, + * the people's side, off the board for good) and the offer is withdrawn unpaid; aboard they stay aboard; an + * offer that ends leaves a free convict where they are + */ /datum/unit_test/voidcrew_ambient_strays_convict /datum/unit_test/voidcrew_ambient_strays_convict/Run() @@ -285,23 +291,43 @@ var/list/room = ambient_test_room_bounds() var/turf/corner = run_loc_floor_bottom_left var/obj/structure/overmap/space_ruin/ruin = allocate(/obj/structure/overmap/space_ruin) - var/mob/living/basic/bounty_criminal/meek/convict/convict = ambient_spawn_convict(corner, ruin, "ruin") // BOUNTY_PLACEMENT_RUIN + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = ambient_spawn_convict(corner, ruin, "ruin") // BOUNTY_PLACEMENT_RUIN TEST_ASSERT_NOTNULL(convict, "No convict was made") allocated += convict convict.ai_controller?.set_ai_status(AI_STATUS_OFF) TEST_ASSERT(WEAKREF(convict) in GLOB.ambient_convicts, "A convict is not on the list") TEST_ASSERT_EQUAL(convict.record?.tier, 1, "A convict is not a petty criminal") // BOUNTY_TIER_PETTY TEST_ASSERT_EQUAL(convict.record?.archetype, "meek", "A convict is not meek") // BOUNTY_ARCHETYPE_MEEK - TEST_ASSERT_EQUAL(convict.bounty_outfit(), /datum/outfit/prisoner, "A convict is not in prison orange") + + // Prison orange, under their own name, with the meek's holdout carried, not held + TEST_ASSERT(ispath(convict.bounty_build_outfit(1), /datum/outfit/prisoner), "A convict is not dressed as an escapee") // ZONE_GREEN + TEST_ASSERT(istype(convict.w_uniform, /obj/item/clothing/under/rank/prisoner), "A convict is not in prison orange") + TEST_ASSERT(istype(convict.shoes, /obj/item/clothing/shoes/sneakers/orange), "A convict is not in orange shoes") + TEST_ASSERT_EQUAL(convict.real_name, convict.record?.name, "A convict goes by a prisoner number, not their own name") + var/obj/item/gun/ballistic/automatic/pistol/holdout = locate() in convict.get_all_contents() + TEST_ASSERT_NOTNULL(holdout, "A convict carries no holdout") + TEST_ASSERT(!convict.is_holding(holdout), "A convict walks about with the holdout in hand") + TEST_ASSERT_NOTNULL(locate(/obj/item/ammo_box/magazine/m9mm) in convict.get_all_contents(), "A convict carries no spare magazine") + TEST_ASSERT_NULL(convict.posting(), "A convict nobody has seen is wanted") TEST_ASSERT(convict.convict_can_talk(), "A calm convict won't talk") convict.hidden = TRUE TEST_ASSERT(!convict.convict_can_talk(), "A hidden convict talks") convict.hidden = FALSE - // Seen: one private petty offer, to that crew, on the convict's own record + // Seen: they give up the way a person does, and one private petty offer goes to that crew, on the convict's own record var/obj/structure/overmap/ship/ship = strays_test_ship(room) - var/datum/criminal_bounty/posting = convict.convict_post_bounty(ship) + var/mob/living/carbon/human/consistent/spotter = allocate(__IMPLIED_TYPE__, run_loc_floor_top_right) + TEST_ASSERT(human_npc_take_in_hand(convict, holdout), "Test fixture: the convict could not draw the holdout") + convict.convict_sighted(spotter, ship) + TEST_ASSERT(convict.IsParalyzed(), "A sighted convict did not give up with tg's *surrender") + TEST_ASSERT(convict.npc_surrendering, "A sighted convict is not lying there with their hands on their head") + TEST_ASSERT_EQUAL(convict.npc_last_bark, "surrender", "A sighted convict gave no line as they gave up") // HUMAN_NPC_BARK_SURRENDER + TEST_ASSERT_EQUAL(convict.capture_state(), "stunned", "A convict who gave up reads as [convict.capture_state()]") // BOUNTY_STATE_STUNNED + TEST_ASSERT(!convict.is_holding(holdout), "A convict who gave up kept the holdout in hand") + TEST_ASSERT_EQUAL(human_npc_holding(holdout), convict, "A convict's holdout left their body when they gave up") + TEST_ASSERT(!convict.convict_can_talk(), "A convict lying there with their hands on their head talks") + var/datum/criminal_bounty/posting = convict.posting() TEST_ASSERT_NOTNULL(posting, "Seeing a convict posted no bounty") TEST_ASSERT_EQUAL(posting?.record, convict.record, "The bounty shows someone other than the convict") TEST_ASSERT_EQUAL(posting?.offered_to(), ship, "The bounty was not offered to the crew who saw the convict") @@ -315,25 +341,51 @@ var/obj/structure/overmap/ship/busy = strays_test_ship(room) SScriminal_bounties.board_post(1, null, ruin, busy, TRUE) // BOUNTY_TIER_PETTY SScriminal_bounties.board_post(1, null, ruin, busy, TRUE) - var/mob/living/basic/bounty_criminal/meek/convict/other = ambient_spawn_convict(run_loc_floor_top_right, ruin, "ruin") + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/other = ambient_spawn_convict(run_loc_floor_top_right, ruin, "ruin") allocated += other other.ai_controller?.set_ai_status(AI_STATUS_OFF) TEST_ASSERT_NULL(other.convict_post_bounty(busy), "A convict's bounty went past the two private offers") // BOUNTY_PRIVATE_MAX - // Talked down: the bounty is withdrawn unpaid, and a stray with their name follows the crew + // Talked down once they are up again: the same body goes with the crew, and the bounty is withdrawn unpaid + convict.SetParalyzed(0) var/mob/living/carbon/human/consistent/leader = allocate(__IMPLIED_TYPE__, locate(corner.x + 1, corner.y, corner.z)) var/convict_name = convict.real_name var/datum/bounty_record/convict_record = convict.record - var/mob/living/basic/ambient_npc/stray/convict/stray = convict.convict_join(leader, ship) - TEST_ASSERT_NOTNULL(stray, "A talked-down convict did not join the crew") - allocated += stray - TEST_ASSERT(QDELETED(convict), "The convict's body stayed after they joined a crew") + var/datum/ambient_place/ship/deck = GLOB.ambient_ship_places[REF(ship)] + var/taken_before = deck?.taken + TEST_ASSERT_EQUAL(convict.convict_join(leader, ship), convict, "A talked-down convict did not go with the crew in their own body") + convict.ai_controller?.set_ai_status(AI_STATUS_OFF) + TEST_ASSERT(!QDELETED(convict), "The convict's body was replaced when they joined a crew") + TEST_ASSERT(convict.convict_recruited, "The talked-down convict is not with the crew") TEST_ASSERT(!(posting in GLOB.criminal_bounties), "Recruiting the convict left their bounty open") + TEST_ASSERT_NULL(convict.posting(), "A recruited convict is still wanted on a posting") TEST_ASSERT_EQUAL(convict_record.status, "closed", "The recruited convict's record is still wanted") // BOUNTY_RECORD_CLOSED - TEST_ASSERT_EQUAL(stray?.real_name, convict_name, "The convict came back as someone else") - TEST_ASSERT_EQUAL(stray?.bounty_record, convict_record, "The recruited convict lost their face") - TEST_ASSERT(stray?.is_following(), "The recruited convict is not following the crew") - TEST_ASSERT_NULL(locate(/obj/item/bounty_proof) in get_turf(stray), "Recruiting the convict left proof of death") + TEST_ASSERT_EQUAL(convict.real_name, convict_name, "The recruited convict is someone else now") + TEST_ASSERT_EQUAL(convict.record, convict_record, "The recruited convict lost who they are") + TEST_ASSERT(istype(convict.ai_controller, /datum/ai_controller/basic_controller/convict_follower), "The recruited convict kept the criminal's AI") + TEST_ASSERT_EQUAL(convict.convict_leader(), leader, "The recruited convict is not following the crew member who asked") + TEST_ASSERT((FACTION_NEUTRAL in convict.faction) && !(FACTION_HOSTILE in convict.faction), "The recruited convict is still on the hostiles' side") + TEST_ASSERT(!(WEAKREF(convict) in GLOB.ambient_convicts), "A recruited convict still counts as a convict on the loose") + TEST_ASSERT(!HAS_TRAIT(convict, TRAIT_NO_TELEPORT), "A recruited convict still can't be teleported") + TEST_ASSERT_NULL(convict.GetComponent(/datum/component/bounty_body), "A recruited convict still has the bounty body's hooks") + TEST_ASSERT(!HAS_TRAIT(convict, "mission_field_mob"), "A recruited convict is still kept from the sweep") // TRAIT_MISSION_FIELD_MOB + TEST_ASSERT(!is_hostile_creature(convict), "A hull turret would take the recruited convict for a threat") + TEST_ASSERT_EQUAL(deck?.taken, taken_before + 1, "The ship did not count the convict it took in") + TEST_ASSERT_NULL(locate(/obj/item/bounty_proof) in get_turf(convict), "Recruiting the convict left proof of death") + TEST_ASSERT(!convict.convict_can_talk(), "A recruited convict pleads to be taken in again") + + // Aboard (the test room counts as the ship): they step aboard once, and from then on stay aboard + TEST_ASSERT(convict.convict_board_if_aboard(), "The recruited convict did not step aboard") + TEST_ASSERT(convict.convict_aboard, "The recruited convict does not live aboard") + TEST_ASSERT(!convict.convict_board_if_aboard(), "The recruited convict stepped aboard twice") + TEST_ASSERT(convict.convict_may_step(locate(corner.x + 1, corner.y + 1, corner.z)), "Aboard, the recruited convict won't walk about the ship") + TEST_ASSERT(!convict.convict_may_step(locate(corner.x - 1, corner.y, corner.z)), "Aboard, the recruited convict would walk off the ship") + + // Told to stay, they stay; asked again, they follow + convict.convict_stay() + TEST_ASSERT_NULL(convict.convict_leader(), "Told to stay, the recruited convict still follows someone") + TEST_ASSERT(convict.convict_follow(leader), "The recruited convict would not follow again") + TEST_ASSERT_EQUAL(convict.convict_leader(), leader, "Asked again, the recruited convict follows someone else") // The crew's last offer just ended: none during the board's gap after it TEST_ASSERT_NULL(other.convict_post_bounty(ship), "A convict was offered during the gap after the crew's last private offer ended") // BOUNTY_PRIVATE_GAP @@ -374,7 +426,7 @@ record.sites += site TEST_ASSERT(kind.realize(site), "A hideout brought no convict out") var/datum/weakref/convict_ref = site.data["convict"] - var/mob/living/basic/bounty_criminal/meek/convict/convict = convict_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/meek/convict/convict = convict_ref?.resolve() TEST_ASSERT(istype(convict), "A hideout's convict is not a convict") allocated += convict convict.ai_controller?.set_ai_status(AI_STATUS_OFF) diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_admin.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_admin.dm index 7188777d9db..3160c891c63 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_admin.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_admin.dm @@ -6,8 +6,9 @@ * * The panel runs without an admin client: /datum/bounty_admin_panel/unit_test answers its prompts * from a list and notes what it logged and said. The tests call the action procs straight, and the - * menu once each through its prompts. They run against the real packages: capture states are read - * back through P2's capture_state(), the pool through P7's procs, the clock through P5's. + * menu once each through its prompts. They run against the real packages: criminals are real human + * bodies, so a forced state is checked on tg's own state (crit, Paralyze, the cuff slot, death) and read + * back through P2's capture_state(); the pool through P7's procs, the clock through P5's. * * Every test that touches a global list (the postings, the pool) sets it aside first and puts it back * after, from a Run() that calls a separate checks proc, so a failed check can't skip the restore. @@ -81,7 +82,7 @@ /// A criminal spawned by the panel, deleted with the test /datum/unit_test/voidcrew_bounty_admin/proc/spawn_criminal(datum/bounty_admin_panel/unit_test/panel, mob/operator, tier, archetype, variant, turf/spot) - var/mob/living/basic/bounty_criminal/criminal = panel.admin_spawn_criminal(operator, tier, archetype, variant, null, spot) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = panel.admin_spawn_criminal(operator, tier, archetype, variant, null, spot) if(criminal) allocated += criminal return criminal @@ -107,19 +108,19 @@ // Every archetype and kit: list(archetype, kit or style, the subtype P2 picks) var/list/cases = list( - list("meek", null, /mob/living/basic/bounty_criminal/meek), // BOUNTY_ARCHETYPE_MEEK - list("normal", "knife", /mob/living/basic/bounty_criminal/normal), // BOUNTY_ARCHETYPE_NORMAL, BOUNTY_STYLE_KNIFE - list("boss", "juggernaut", /mob/living/basic/bounty_criminal/boss/juggernaut), // BOUNTY_ARCHETYPE_BOSS, BOUNTY_KIT_* - list("boss", "pyromaniac", /mob/living/basic/bounty_criminal/boss/pyromaniac), - list("boss", "demolitionist", /mob/living/basic/bounty_criminal/boss/demolitionist), - list("boss", "ghost", /mob/living/basic/bounty_criminal/boss/ghost), - list("boss", "heavy", /mob/living/basic/bounty_criminal/boss/heavy), + list("meek", null, /mob/living/carbon/human/npc/bounty_criminal/meek), // BOUNTY_ARCHETYPE_MEEK + list("normal", "knife", /mob/living/carbon/human/npc/bounty_criminal/normal), // BOUNTY_ARCHETYPE_NORMAL, BOUNTY_STYLE_KNIFE + list("boss", "juggernaut", /mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut), // BOUNTY_ARCHETYPE_BOSS, BOUNTY_KIT_* + list("boss", "pyromaniac", /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac), + list("boss", "demolitionist", /mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist), + list("boss", "ghost", /mob/living/carbon/human/npc/bounty_criminal/boss/ghost), + list("boss", "heavy", /mob/living/carbon/human/npc/bounty_criminal/boss/heavy), ) for(var/list/spawn_case as anything in cases) var/archetype = spawn_case[1] var/variant = spawn_case[2] var/expected = spawn_case[3] - var/mob/living/basic/bounty_criminal/criminal = spawn_criminal(panel, operator, 2, archetype, variant, spot) // BOUNTY_TIER_WANTED + var/mob/living/carbon/human/npc/bounty_criminal/criminal = spawn_criminal(panel, operator, 2, archetype, variant, spot) // BOUNTY_TIER_WANTED TEST_ASSERT_NOTNULL(criminal, "No [archetype] [variant] criminal spawned: [panel.error]") TEST_ASSERT_EQUAL(criminal.type, expected, "The [archetype] [variant] criminal is a [criminal.type]") TEST_ASSERT_EQUAL(get_turf(criminal), spot, "The [archetype] [variant] criminal spawned away from the spot") @@ -138,7 +139,7 @@ TEST_ASSERT_EQUAL(length(panel.operations), length(cases), "The panel logged [length(panel.operations)] spawns for [length(cases)]") // A random kit is still a kit. - var/mob/living/basic/bounty_criminal/random_boss = spawn_criminal(panel, operator, 3, "boss", null, spot) // BOUNTY_TIER_MOST_WANTED + var/mob/living/carbon/human/npc/bounty_criminal/random_boss = spawn_criminal(panel, operator, 3, "boss", null, spot) // BOUNTY_TIER_MOST_WANTED TEST_ASSERT((random_boss?.record?.kit in list("juggernaut", "pyromaniac", "demolitionist", "ghost", "heavy")), "A boss with a random kit has the kit [random_boss?.record?.kit]") // Bad requests are refused, with a reason and no log line. @@ -155,8 +156,8 @@ panel.answers = list("Spawn a criminal at my feet", "Normal", "Petty", "Shotgun", "Camping") panel.run_menu(operator) TEST_ASSERT_EQUAL(length(panel.missed), 0, "The spawn menu did not offer: [jointext(panel.missed, "; ")]") - var/mob/living/basic/bounty_criminal/from_menu - for(var/mob/living/basic/bounty_criminal/found in get_turf(operator)) + var/mob/living/carbon/human/npc/bounty_criminal/from_menu + for(var/mob/living/carbon/human/npc/bounty_criminal/found in get_turf(operator)) from_menu = found TEST_ASSERT_NOTNULL(from_menu, "The spawn menu put nobody at the admin's feet") allocated += from_menu @@ -164,16 +165,16 @@ TEST_ASSERT_EQUAL(from_menu.record?.tier, 1, "The spawn menu's criminal is not petty") // BOUNTY_TIER_PETTY // A decoy, through P6's builder: its own record, beside the criminal, blending in, on no posting. - var/mob/living/basic/bounty_criminal/wanted = spawn_criminal(panel, operator, 1, "meek", null, spot) - var/mob/living/basic/bounty_criminal/decoy/decoy = panel.admin_spawn_decoy(operator, wanted, 2) + var/mob/living/carbon/human/npc/bounty_criminal/wanted = spawn_criminal(panel, operator, 1, "meek", null, spot) + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = panel.admin_spawn_decoy(operator, wanted, 2) TEST_ASSERT_NOTNULL(decoy, "No decoy spawned: [panel.error]") allocated += decoy - TEST_ASSERT(istype(decoy, /mob/living/basic/bounty_criminal/decoy), "The decoy is a [decoy.type]") + TEST_ASSERT(istype(decoy, /mob/living/carbon/human/npc/bounty_criminal/decoy), "The decoy is a [decoy.type]") TEST_ASSERT(decoy.record && decoy.record != wanted.record, "The decoy has no record of its own") TEST_ASSERT(get_dist(decoy, wanted) <= 1, "The decoy spawned [get_dist(decoy, wanted)] tiles from its criminal") TEST_ASSERT_NULL(decoy.posting_ref, "The decoy is linked to a posting through posting_ref") TEST_ASSERT(decoy.blended, "The decoy is not blending in: it skipped P6's builder") - TEST_ASSERT_EQUAL(decoy.maxHealth, wanted.maxHealth, "The decoy's health does not match its criminal's") + TEST_ASSERT_EQUAL(decoy.npc_damage_taken, wanted.npc_damage_taken, "The decoy's toughness does not match its criminal's") TEST_ASSERT_NULL(panel.admin_spawn_decoy(operator, decoy, 1), "A decoy of a decoy was spawned") TEST_ASSERT(!(decoy in panel.live_criminals()), "Decoys are offered as wanted criminals") TEST_ASSERT(decoy in panel.live_criminals(include_decoys = TRUE), "Decoys are missing when asked for") @@ -181,21 +182,26 @@ // ===== CAPTURE STATES ===== /** - * Each forced state reads back through P2's capture_state(), each on a fresh criminal; restraints go on - * through P2's own proc with nobody on the grudge list; and "Free" takes off everything P2 puts on, - * including cuffs a player put on a downed criminal. + * Each forced state is tg's own, read back through P2's capture_state(), each on a fresh criminal: + * downed is real brute on the chest to just past the crit line (soft crit, not hard, and no wound), a + * tough Most Wanted body included; stunned is a real Paralyze; restrained is real handcuffs in tg's cuff + * slot with nobody on the grudge list; dead is death(). "Free" takes all of it off through tg + * (revive(HEAL_ALL), uncuff(), the stuns ended): a real *surrender, a hunter's cuffs on a body in crit, + * and death itself. */ /datum/unit_test/voidcrew_bounty_admin/force_state -/datum/unit_test/voidcrew_bounty_admin/force_state/proc/fresh(datum/bounty_admin_panel/unit_test/panel, mob/operator) - return spawn_criminal(panel, operator, 1, "normal", "brawler", run_loc_floor_bottom_left) // BOUNTY_TIER_PETTY +/datum/unit_test/voidcrew_bounty_admin/force_state/proc/fresh(datum/bounty_admin_panel/unit_test/panel, mob/operator, tier = 1) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = spawn_criminal(panel, operator, tier, "normal", "brawler", run_loc_floor_bottom_left) // BOUNTY_TIER_PETTY + criminal?.ai_controller?.set_ai_status(AI_STATUS_OFF) + return criminal /datum/unit_test/voidcrew_bounty_admin/force_state/Run() var/mob/living/carbon/human/operator = make_operator(run_loc_floor_top_right) var/datum/bounty_admin_panel/unit_test/panel = allocate(__IMPLIED_TYPE__, operator) // Only an admin, and only known states; refusals log nothing. - var/mob/living/basic/bounty_criminal/criminal = fresh(panel, operator) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = fresh(panel, operator) TEST_ASSERT_NOTNULL(criminal, "No criminal spawned: [panel.error]") var/logged = length(panel.operations) panel.allow_actions = FALSE @@ -205,77 +211,85 @@ TEST_ASSERT(!panel.admin_force_state(operator, criminal, "fine") && panel.error, "An unknown state was accepted") TEST_ASSERT_EQUAL(length(panel.operations), logged, "A refused forced state was logged") - // Downed: down, alive, at or under P2's line. + // Downed: tg's crit, just past the line, from real brute on the chest. criminal = fresh(panel, operator) TEST_ASSERT(panel.admin_force_state(operator, criminal, "downed"), "Forcing downed was refused: [panel.error]") // BOUNTY_STATE_DOWNED TEST_ASSERT_EQUAL(criminal.capture_state(), "downed", "A forced downed criminal reads as [criminal.capture_state()]") - TEST_ASSERT(criminal.is_downed(), "A forced downed criminal is not down") - TEST_ASSERT(criminal.health <= criminal.body_downed_line(), "Forcing downed left them at [criminal.health] health, over the line") - TEST_ASSERT_EQUAL(criminal.worst_state, "downed", "A forced downed criminal's worst state is [criminal.worst_state]") + TEST_ASSERT(HAS_TRAIT(criminal, TRAIT_CRITICAL_CONDITION), "A forced downed criminal is not in tg's crit") + TEST_ASSERT_EQUAL(criminal.stat, SOFT_CRIT, "Forcing downed left them at stat [criminal.stat], not just into soft crit") + TEST_ASSERT(criminal.health <= HEALTH_THRESHOLD_CRIT && criminal.health > HEALTH_THRESHOLD_FULLCRIT, "Forcing downed left them at [criminal.health] health, not just past the crit line") + var/obj/item/bodypart/chest = criminal.get_bodypart(BODY_ZONE_CHEST) + TEST_ASSERT(chest?.brute_dam > 0, "Forcing downed put no brute on their chest") + TEST_ASSERT_EQUAL(LAZYLEN(criminal.all_wounds), 0, "Forcing downed wounded them") TEST_ASSERT(!panel.admin_force_state(operator, criminal, "downed"), "A downed criminal was downed again") + // A tougher body (Most Wanted takes less of each hit) still goes down, no further than a person would. + var/mob/living/carbon/human/npc/bounty_criminal/tough = fresh(panel, operator, 3) // BOUNTY_TIER_MOST_WANTED + TEST_ASSERT(tough.npc_damage_taken < 1, "Test fixture: a Most Wanted criminal takes a person's damage") + TEST_ASSERT(panel.admin_force_state(operator, tough, "downed"), "Forcing a Most Wanted criminal downed was refused: [panel.error]") + TEST_ASSERT_EQUAL(tough.stat, SOFT_CRIT, "A forced downed Most Wanted criminal is at stat [tough.stat]") - // Stunned: paralysed. + // Stunned: a real paralysis. criminal = fresh(panel, operator) TEST_ASSERT(panel.admin_force_state(operator, criminal, "stunned"), "Forcing a stun was refused: [panel.error]") // BOUNTY_STATE_STUNNED TEST_ASSERT_EQUAL(criminal.capture_state(), "stunned", "A forced stunned criminal reads as [criminal.capture_state()]") TEST_ASSERT(criminal.IsParalyzed(), "A forced stunned criminal is not paralysed") - // Restrained: P2's cuffs and everything that comes with them, and nobody on the grudge list. + // Restrained: real handcuffs in tg's cuff slot, put on by nobody. criminal = fresh(panel, operator) TEST_ASSERT(panel.admin_force_state(operator, criminal, "restrained"), "Forcing restraints was refused: [panel.error]") // BOUNTY_STATE_RESTRAINED TEST_ASSERT_EQUAL(criminal.capture_state(), "restrained", "A forced restrained criminal reads as [criminal.capture_state()]") - TEST_ASSERT(criminal.is_restrained(), "A forced restrained criminal has no restraints on") - TEST_ASSERT(istype(criminal.restraints, /obj/item/restraints/handcuffs), "Forcing restraints put no handcuffs on them") - TEST_ASSERT(HAS_TRAIT_FROM(criminal, TRAIT_IMMOBILIZED, "bounty_body_cuffs"), "The forced cuffs don't hold them still") // BOUNTY_BODY_CUFFS_TRAIT - TEST_ASSERT(HAS_TRAIT_FROM(criminal, TRAIT_AI_PAUSED, "bounty_body_held"), "The forced cuffs don't pause their AI") // BOUNTY_BODY_HELD_TRAIT + TEST_ASSERT(istype(criminal.handcuffed, /obj/item/restraints/handcuffs), "Forcing restraints put no handcuffs in their cuff slot") + TEST_ASSERT(HAS_TRAIT(criminal, TRAIT_RESTRAINED), "The forced cuffs don't restrain them") TEST_ASSERT_EQUAL(length(criminal.grudge), 0, "Forcing restraints put someone on their grudge list") TEST_ASSERT(!panel.admin_force_state(operator, criminal, "restrained") && panel.error, "Cuffing a cuffed criminal was accepted") - // Free takes them off through P2: the cuffs drop at their feet and the hold is gone. - var/obj/item/restraints/handcuffs/forced_cuffs = criminal.restraints + // Free takes them off the tg way: the cuffs drop at their feet. + var/obj/item/restraints/handcuffs/forced_cuffs = criminal.handcuffed TEST_ASSERT(panel.admin_force_state(operator, criminal, "free"), "Freeing a cuffed criminal was refused: [panel.error]") // BOUNTY_STATE_FREE TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A freed criminal reads as [criminal.capture_state()]") - TEST_ASSERT(!HAS_TRAIT_FROM(criminal, TRAIT_IMMOBILIZED, "bounty_body_cuffs"), "Freeing left them held still by the cuffs") - TEST_ASSERT(!HAS_TRAIT_FROM(criminal, TRAIT_AI_PAUSED, "bounty_body_held"), "Freeing left their AI paused") + TEST_ASSERT_NULL(criminal.handcuffed, "Freeing left the cuffs on") + TEST_ASSERT(!HAS_TRAIT(criminal, TRAIT_RESTRAINED), "Freeing left them restrained") TEST_ASSERT_EQUAL(forced_cuffs.loc, get_turf(criminal), "Freeing did not drop the cuffs at their feet") allocated += forced_cuffs - // Surrendered reads as stunned, and free ends it. + // A real *surrender reads as stunned, and free ends it. criminal = fresh(panel, operator) - ADD_TRAIT(criminal, "bounty_surrendered", "bounty") // TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT - TEST_ASSERT_EQUAL(criminal.capture_state(), "stunned", "A surrendered criminal reads as [criminal.capture_state()]") - TEST_ASSERT(panel.admin_force_state(operator, criminal, "free"), "Freeing a surrendered criminal was refused: [panel.error]") - TEST_ASSERT(!HAS_TRAIT(criminal, "bounty_surrendered"), "Freeing left the surrender on") - TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A freed surrendered criminal reads as [criminal.capture_state()]") - - // A player cuffs a downed criminal through P2; the admin frees it: up, healed, uncuffed, nothing of P2's left on it. + TEST_ASSERT(criminal.npc_surrender(), "Test fixture: the criminal would not give up") + TEST_ASSERT_EQUAL(criminal.capture_state(), "stunned", "A criminal who gave up reads as [criminal.capture_state()]") + TEST_ASSERT(panel.admin_force_state(operator, criminal, "free"), "Freeing a criminal who gave up was refused: [panel.error]") + TEST_ASSERT(!criminal.IsParalyzed(), "Freeing left them paralysed") + TEST_ASSERT(!criminal.npc_surrendering, "Freeing left the surrender on") + TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A freed criminal who gave up reads as [criminal.capture_state()]") + + // A hunter cuffs a criminal in crit (tg's apply_cuffs, without the do_after); the admin frees it: up, healed, uncuffed. criminal = fresh(panel, operator) TEST_ASSERT(panel.admin_force_state(operator, criminal, "downed"), "Forcing downed was refused: [panel.error]") var/mob/living/carbon/human/hunter = make_operator(run_loc_floor_bottom_left) var/obj/item/restraints/handcuffs/player_cuffs = allocate(/obj/item/restraints/handcuffs, run_loc_floor_bottom_left) hunter.put_in_hands(player_cuffs) - TEST_ASSERT(criminal.body_apply_cuffs(player_cuffs, hunter), "The hunter's cuffs did not go on the downed criminal") - TEST_ASSERT_EQUAL(criminal.capture_state(), "downed", "A cuffed downed criminal reads as [criminal.capture_state()]") - TEST_ASSERT(panel.admin_force_state(operator, criminal, "free"), "Freeing a cuffed downed criminal was refused: [panel.error]") + player_cuffs.apply_cuffs(criminal, hunter) + TEST_ASSERT_EQUAL(criminal.handcuffed, player_cuffs, "The hunter's cuffs did not go on the criminal in crit") + TEST_ASSERT_EQUAL(criminal.capture_state(), "downed", "A cuffed criminal in crit reads as [criminal.capture_state()]") + TEST_ASSERT(panel.admin_force_state(operator, criminal, "free"), "Freeing a cuffed criminal in crit was refused: [panel.error]") TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "The freed criminal reads as [criminal.capture_state()]") - TEST_ASSERT(!criminal.is_downed(), "Freeing left them down") + TEST_ASSERT_EQUAL(criminal.stat, CONSCIOUS, "Freeing left them at stat [criminal.stat]") + TEST_ASSERT(!HAS_TRAIT(criminal, TRAIT_CRITICAL_CONDITION), "Freeing left them in crit") TEST_ASSERT(!criminal.is_restrained(), "Freeing left the hunter's cuffs on") + TEST_ASSERT_EQUAL(player_cuffs.loc, get_turf(criminal), "Freeing did not drop the hunter's cuffs at their feet") TEST_ASSERT_EQUAL(criminal.health, criminal.maxHealth, "Freeing left them hurt") - TEST_ASSERT_EQUAL(criminal.worst_state, "free", "Freeing left the worst state at [criminal.worst_state]") - for(var/source in list("bounty_body_cuffs", "bounty_body_downed")) // BOUNTY_BODY_CUFFS_TRAIT, BOUNTY_BODY_DOWNED_TRAIT - TEST_ASSERT(!HAS_TRAIT_FROM(criminal, TRAIT_IMMOBILIZED, source), "Freeing left them held still ([source])") - TEST_ASSERT(!HAS_TRAIT_FROM(criminal, TRAIT_FLOORED, "bounty_body_downed"), "Freeing left them on the floor") - TEST_ASSERT(!HAS_TRAIT_FROM(criminal, TRAIT_AI_PAUSED, "bounty_body_held"), "Freeing left their AI paused") - TEST_ASSERT(!HAS_TRAIT_FROM(criminal, "bounty_held", "bounty_body_held"), "Freeing left them held") // TRAIT_BOUNTY_HELD - - // Dead: dead, and the pad would read it so. The dead stay dead and take no other state. + chest = criminal.get_bodypart(BODY_ZONE_CHEST) + TEST_ASSERT_EQUAL(chest?.brute_dam, 0, "Freeing left brute on their chest") + + // Dead: dead, and the pad would read it so. No other state goes on a corpse; free brings them back. criminal = fresh(panel, operator) TEST_ASSERT(panel.admin_force_state(operator, criminal, "dead"), "Forcing death was refused: [panel.error]") TEST_ASSERT_EQUAL(criminal.stat, DEAD, "Forcing death left them alive") TEST_ASSERT_EQUAL(criminal.capture_state(), "dead", "A dead criminal reads as [criminal.capture_state()]") - TEST_ASSERT(!panel.admin_force_state(operator, criminal, "free"), "A dead criminal was freed") - TEST_ASSERT_EQUAL(criminal.stat, DEAD, "Freeing a dead criminal revived them") TEST_ASSERT(!panel.admin_force_state(operator, criminal, "downed"), "A dead criminal was downed") TEST_ASSERT(!panel.admin_force_state(operator, criminal, "restrained"), "A dead criminal was cuffed") + TEST_ASSERT(!panel.admin_force_state(operator, criminal, "stunned"), "A dead criminal was stunned") + TEST_ASSERT(panel.admin_force_state(operator, criminal, "free"), "Freeing a dead criminal was refused: [panel.error]") + TEST_ASSERT(criminal.stat != DEAD, "Freeing a dead criminal did not revive them") + TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A revived criminal reads as [criminal.capture_state()]") // Through the menu: pick the criminal, then the state. criminal = fresh(panel, operator) @@ -382,7 +396,7 @@ posting.value = 1234 posting.expires_at = world.time + 20 MINUTES GLOB.criminal_bounties += posting - var/mob/living/basic/bounty_criminal/criminal = spawn_bounty_criminal(record, run_loc_floor_bottom_left, posting) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = spawn_bounty_criminal(record, run_loc_floor_bottom_left, posting) if(criminal) allocated += criminal posting.criminal_ref = WEAKREF(criminal) @@ -392,7 +406,7 @@ GLOB.criminal_bounties.Cut() GLOB.criminal_bounties += postings_before -/datum/unit_test/voidcrew_bounty_admin/postings/proc/run_checks(datum/bounty_admin_panel/unit_test/panel, mob/operator, datum/criminal_bounty/posting, mob/living/basic/bounty_criminal/criminal) +/datum/unit_test/voidcrew_bounty_admin/postings/proc/run_checks(datum/bounty_admin_panel/unit_test/panel, mob/operator, datum/criminal_bounty/posting, mob/living/carbon/human/npc/bounty_criminal/criminal) TEST_ASSERT_NOTNULL(criminal, "No criminal spawned for the posting") // The row: every field, a jump link to the criminal, the time left. diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_ai.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_ai.dm index a19b953039a..bec9c2e6c57 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_ai.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_ai.dm @@ -1,5 +1,8 @@ /** - * Bounty hunting: P3 AI's tests, for voidcrew/modules/bounties/bounty_ai*.dm, bounty_activities.dm and bounty_companions.dm. + * Bounty hunting: P3 AI's tests, for voidcrew/modules/bounties/bounty_ai*.dm, bounty_styles.dm, + * bounty_activities.dm and bounty_companions.dm. Criminals and companions are real human bodies (phase D + * of the human NPC migration): these tests check that they draw, fire, throw and shove with the real + * items they carry, hide by real moves, sit and drink for real, and give up through tg's *surrender. * The blend test uses P6's blend component and the companion tests P2's may_attack_scope(), as * production does. * @@ -15,11 +18,36 @@ /datum/unit_test/voidcrew_bounty_ai/proc/tile(dx, dy) return locate(run_loc_floor_bottom_left.x + dx, run_loc_floor_bottom_left.y + dy, run_loc_floor_bottom_left.z) -/// Hiding in a locker and in a crate; opening either finds them, and they bolt +/** + * A criminal of `criminal_type` at `where`, made the way production makes one (the human NPC spawn + * order, with its record as its look), so it is dressed by its record: `style` (BOUNTY_STYLE_*) and + * `tier` (BOUNTY_TIER_*, 1 Petty). + */ +/datum/unit_test/voidcrew_bounty_ai/proc/make_criminal(criminal_type, turf/where, style, tier = 1) + var/archetype = ispath(criminal_type, /mob/living/carbon/human/npc/bounty_criminal/meek) ? "meek" : "normal" // BOUNTY_ARCHETYPE_* + var/datum/bounty_record/record = generate_bounty_record(tier, archetype, "planet", /datum/species/human) // BOUNTY_PLACEMENT_PLANET + record.tier = tier + if(style) + record.style = style + var/datum/human_npc_order/order = spawn_human_npc(criminal_type, where, look = record, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = order?.spawned_ref?.resolve() + qdel(order) + TEST_ASSERT_NOTNULL(criminal, "Test fixture: no [criminal_type] was made") + allocated += criminal + criminal.ai_update_style() + return criminal + +/// The first `item_type` `body` carries anywhere on it, held or not +/datum/unit_test/voidcrew_bounty_ai/proc/carried(mob/living/carbon/body, item_type) + for(var/obj/item/thing as anything in body.get_all_contents_type(item_type)) + return thing + return null + +/// Hiding in a locker and in a crate (a real move in, and out when it opens); opening either finds them, and they bolt /datum/unit_test/voidcrew_bounty_ai/containers /datum/unit_test/voidcrew_bounty_ai/containers/Run() - var/mob/living/basic/bounty_criminal/meek/criminal = allocate(/mob/living/basic/bounty_criminal/meek, tile(1, 1)) + var/mob/living/carbon/human/npc/bounty_criminal/meek/criminal = allocate(/mob/living/carbon/human/npc/bounty_criminal/meek, tile(1, 1)) var/obj/structure/closet/locker = allocate(/obj/structure/closet, tile(2, 1)) TEST_ASSERT(criminal.ai_hide_in(locker), "The meek criminal would not hide in the locker beside them") TEST_ASSERT(criminal.hidden, "Hiding in a locker did not set hidden") @@ -31,9 +59,11 @@ TEST_ASSERT(!criminal.hidden, "Opening the locker did not find them") TEST_ASSERT(isturf(criminal.loc), "Found, they are still in the locker") TEST_ASSERT_EQUAL(criminal.ai_mode, "fleeing", "Found, they did not bolt") // BOUNTY_AI_FLEEING + TEST_ASSERT_EQUAL(criminal.move_intent, MOVE_INTENT_RUN, "Found, they bolted at a walk") TEST_ASSERT_EQUAL(criminal.ai_last_hide_ref?.resolve(), locker, "They would hide in the same locker again") criminal.ai_calm_down() + TEST_ASSERT_EQUAL(criminal.move_intent, MOVE_INTENT_WALK, "Calm again, they still run") criminal.forceMove(tile(1, 1)) var/obj/structure/closet/crate/crate = allocate(/obj/structure/closet/crate, tile(1, 2)) TEST_ASSERT(criminal.ai_hide_in(crate), "The meek criminal would not hide in the crate beside them") @@ -49,43 +79,41 @@ locker.welded = TRUE TEST_ASSERT(!criminal.ai_container_usable(locker), "A welded locker counts as a hiding place") -/// Passing as a potted plant, and their own look back after +/// Behind a potted plant: tg's own disguise (the plant held up in both hands), and their own name back when it is put down /datum/unit_test/voidcrew_bounty_ai/plant /datum/unit_test/voidcrew_bounty_ai/plant/Run() - var/mob/living/basic/bounty_criminal/meek/criminal = allocate(/mob/living/basic/bounty_criminal/meek, tile(1, 1)) + var/mob/living/carbon/human/npc/bounty_criminal/meek/criminal = allocate(/mob/living/carbon/human/npc/bounty_criminal/meek, tile(1, 1)) var/obj/item/kirbyplants/plant = allocate(/obj/item/kirbyplants, tile(2, 1)) - var/old_name = criminal.name - var/old_desc = criminal.desc - var/old_icon = criminal.icon - var/old_state = criminal.icon_state - var/old_layer = criminal.layer + var/old_name = criminal.get_visible_name() - TEST_ASSERT(criminal.ai_hide_in(plant), "The meek criminal would not pass as the plant beside them") + TEST_ASSERT(criminal.ai_hide_in(plant), "The meek criminal would not hide behind the plant beside them") TEST_ASSERT_EQUAL(criminal.loc, tile(2, 1), "They did not step onto the plant's tile") - TEST_ASSERT_EQUAL(plant.loc, criminal, "They did not take the plant") - TEST_ASSERT_EQUAL(criminal.name, plant.name, "They do not go by the plant's name") - TEST_ASSERT_EQUAL(criminal.desc, plant.desc, "They do not read as the plant") - TEST_ASSERT_EQUAL(criminal.icon, plant.icon, "They do not look like the plant") - TEST_ASSERT_EQUAL(criminal.icon_state, plant.icon_state, "They do not look like the plant") + TEST_ASSERT(criminal.is_holding(plant), "They are not holding the plant up") + TEST_ASSERT_EQUAL(criminal.get_visible_name(), plant.name, "Behind the plant, they don't go by the plant's name") + TEST_ASSERT_EQUAL(criminal.ai_hide_mode, "plant", "The wrong hiding mode") // BOUNTY_HIDE_PLANT // Someone touching the plant finds them var/mob/living/carbon/human/consistent/finder = allocate(/mob/living/carbon/human/consistent, tile(3, 1)) criminal.ai_touched_by(finder) TEST_ASSERT(!criminal.hidden, "Touching the plant did not find them") - TEST_ASSERT_EQUAL(criminal.name, old_name, "Their name did not come back") - TEST_ASSERT_EQUAL(criminal.desc, old_desc, "Their description did not come back") - TEST_ASSERT_EQUAL(criminal.icon, old_icon, "Their look did not come back") - TEST_ASSERT_EQUAL(criminal.icon_state, old_state, "Their look did not come back") - TEST_ASSERT_EQUAL(criminal.layer, old_layer, "Their layer did not come back") - TEST_ASSERT_EQUAL(plant.loc, tile(2, 1), "The plant was not put back") + TEST_ASSERT(!criminal.is_holding(plant), "Found, they still hold the plant") + TEST_ASSERT(isturf(plant.loc), "Found, the plant was not put down") + TEST_ASSERT_EQUAL(criminal.get_visible_name(), old_name, "Their own name did not come back") TEST_ASSERT(criminal.ai_has_grudge(finder), "The finder is not on their grudge list") + // Knocked out of their hands, the plant gives them away + criminal.ai_calm_down() + criminal.forceMove(get_turf(plant)) + TEST_ASSERT(criminal.ai_hide_in(plant), "They would not hide behind the plant a second time") + criminal.dropItemToGround(plant) + TEST_ASSERT(!criminal.hidden, "The plant left their hands, but they still count as hidden") + /// Picking a dark tile, standing in it faded, and a light finding them /datum/unit_test/voidcrew_bounty_ai/dark /datum/unit_test/voidcrew_bounty_ai/dark/Run() - var/mob/living/basic/bounty_criminal/meek/criminal = allocate(/mob/living/basic/bounty_criminal/meek, tile(2, 2)) + var/mob/living/carbon/human/npc/bounty_criminal/meek/criminal = allocate(/mob/living/carbon/human/npc/bounty_criminal/meek, tile(2, 2)) var/turf/dark = tile(3, 3) // The test room is fullbright: mark one tile dark GLOB.bounty_ai_test_dark_turfs += dark @@ -109,19 +137,30 @@ /datum/unit_test/voidcrew_bounty_ai/hide_choice /datum/unit_test/voidcrew_bounty_ai/hide_choice/Run() - var/mob/living/basic/bounty_criminal/meek/criminal = allocate(/mob/living/basic/bounty_criminal/meek, tile(2, 2)) + var/mob/living/carbon/human/npc/bounty_criminal/meek/criminal = allocate(/mob/living/carbon/human/npc/bounty_criminal/meek, tile(2, 2)) var/mob/living/carbon/human/consistent/hunter = allocate(/mob/living/carbon/human/consistent, tile(0, 2)) var/obj/structure/closet/near_hunter = allocate(/obj/structure/closet, tile(1, 3)) var/obj/structure/closet/far_side = allocate(/obj/structure/closet, tile(4, 2)) TEST_ASSERT_EQUAL(criminal.ai_pick_hide_spot(hunter), far_side, "They picked the locker by the hunter over the one away from them") TEST_ASSERT(near_hunter, "The test's locker was not made") -/// Cornered: the aim (hand, mark, message), and a stamina hit during it makes them fumble +/** + * Meek kit and cornering: they carry a real Makarov, a spare magazine and a stimpack, and a Slipwire + * implant; cornered, they shout, take the holdout out of their pocket and aim (the telegraph); a hit + * during the aim makes them fumble it; a clean aim fires real rounds, one trigger pull at a time. + */ /datum/unit_test/voidcrew_bounty_ai/cornered /datum/unit_test/voidcrew_bounty_ai/cornered/Run() - var/mob/living/basic/bounty_criminal/meek/criminal = allocate(/mob/living/basic/bounty_criminal/meek, tile(0, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/meek/criminal = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/meek, tile(0, 0)) var/mob/living/carbon/human/consistent/hunter = allocate(/mob/living/carbon/human/consistent, tile(1, 1)) + var/obj/item/gun/ballistic/automatic/pistol/holdout = criminal.ai_holdout() + TEST_ASSERT_NOTNULL(holdout, "The meek criminal carries no holdout pistol") + TEST_ASSERT(!criminal.is_holding(holdout), "The holdout is out before anything happened") + TEST_ASSERT(human_npc_gear_locked(holdout), "The holdout isn't locked to the body") + TEST_ASSERT_NOTNULL(carried(criminal, /obj/item/ammo_box/magazine/m9mm), "The meek criminal carries no spare magazine") + TEST_ASSERT_NOTNULL(carried(criminal, /obj/item/reagent_containers/hypospray/medipen/stimpack), "The meek criminal carries no stimpack") + TEST_ASSERT_NOTNULL(criminal.get_organ_by_type(/obj/item/organ/cyberimp/cyberware/slipwire), "The meek criminal has no Slipwire implant to dodge with") TEST_ASSERT(criminal.ai_ways_out(hunter) < 2, "In the corner with the hunter on them, they have [criminal.ai_ways_out(hunter)] ways out") // The first moment cornered: a warning, and no gun yet @@ -131,44 +170,57 @@ TEST_ASSERT(voice.last_line in lines["cornered"]["any"], "Cornered, they warned nobody ([voice.last_line || "nothing said"])") // The draw is said even inside the cooldown of that warning (BUG-2: draw_gun was never said) voice.last_line = null - criminal.ai_update_style() TEST_ASSERT(criminal.ai_start_aim(hunter), "Cornered, they did not aim") TEST_ASSERT(voice.last_line in lines["draw_gun"]["any"], "Drawing the holdout, they said [voice.last_line || "nothing"]") TEST_ASSERT(criminal.ai_aim_until > world.time, "The aim has no wind-up") - TEST_ASSERT_NOTNULL(criminal.ai_hand, "No gun in their hand while aiming") + TEST_ASSERT(criminal.is_holding(holdout), "The holdout is not in their hand while aiming") TEST_ASSERT(locate(/obj/effect/temp_visual/telegraphing/bounty_aim) in get_turf(hunter), "No aim mark on the hunter") - TEST_ASSERT(!HAS_TRAIT(criminal, "bounty_sprinting"), "Aiming, they still count as sprinting") // TRAIT_BOUNTY_SPRINTING + // A stamina hit during the aim: fumbled, and the gun goes back in the pocket criminal.adjustStaminaLoss(20) criminal.ai_aim_until = world.time TEST_ASSERT(!criminal.ai_fire_holdout(), "A stamina hit during the aim did not make them fumble") TEST_ASSERT(!criminal.ai_aim_until, "Still aiming after the fumble") - TEST_ASSERT_NULL(criminal.ai_hand, "The gun is still in their hand after the fumble") + TEST_ASSERT(!criminal.is_holding(holdout), "The gun is still in their hand after the fumble") TEST_ASSERT(criminal.ai_holdout_ready_at > world.time, "No cooldown after the fumble") - // Ready again, a clean aim fires + // Ready again, a clean aim fires: a real round leaves the real gun criminal.setStaminaLoss(0) criminal.ai_holdout_ready_at = 0 + criminal.next_click = 0 TEST_ASSERT(criminal.ai_start_aim(hunter), "They would not aim a second time") criminal.ai_aim_until = world.time + var/rounds = holdout.get_ammo(TRUE) + TEST_ASSERT(rounds >= 2, "Test fixture: the holdout holds [rounds] rounds") TEST_ASSERT(criminal.ai_fire_holdout(), "A clean aim did not fire") - TEST_ASSERT_EQUAL(criminal.ai_volley_left, 7, "After the first shot, seven of the volley should be left") // BOUNTY_MEEK_PISTOL_SHOTS - 1 - var/datum/component/ranged_attacks/gun = criminal.GetComponent(/datum/component/ranged_attacks) - TEST_ASSERT_NOTNULL(gun, "The meek criminal has no ranged_attacks component") - TEST_ASSERT_EQUAL(gun.projectile_type, /obj/projectile/bullet/bounty/holdout, "The holdout fires the wrong thing") - TEST_ASSERT(gun.cooldown_time < 5, "The holdout's gun is not ready between the volley's shots") // BOUNTY_MEEK_PISTOL_GAP - - // Cuffed or down mid-volley, the rest of it never comes - ADD_TRAIT(criminal, "bounty_held", "test") // TRAIT_BOUNTY_HELD - TEST_ASSERT(!criminal.ai_volley_shot(), "A held criminal fired the rest of the volley") + TEST_ASSERT_EQUAL(holdout.get_ammo(TRUE), rounds - 1, "The first pull of the volley did not fire a round from the gun") + TEST_ASSERT_EQUAL(criminal.ai_volley_left, min(8, rounds) - 1, "After the first pull, the rest of the volley should be left") // BOUNTY_MEEK_PISTOL_SHOTS + + // Cuffed mid-volley, the rest of it never comes + board_test_cuff(criminal) + TEST_ASSERT(criminal.is_restrained(), "Test fixture: the cuffs did not go on") + TEST_ASSERT(!criminal.ai_volley_shot(), "A cuffed criminal fired the rest of the volley") TEST_ASSERT(!criminal.ai_volley_left, "The volley was not cut short") - REMOVE_TRAIT(criminal, "bounty_held", "test") -/// A normal criminal notices someone armed, fights when they close in, and surrenders +/// A meek criminal winded by real stamina loss says so, once +/datum/unit_test/voidcrew_bounty_ai/winded + +/datum/unit_test/voidcrew_bounty_ai/winded/Run() + var/mob/living/carbon/human/npc/bounty_criminal/meek/criminal = allocate(/mob/living/carbon/human/npc/bounty_criminal/meek, tile(2, 2)) + criminal.ai_check_winded() + TEST_ASSERT(!criminal.ai_winded, "Fresh, they count as winded") + criminal.adjustStaminaLoss(50) + criminal.ai_check_winded() + TEST_ASSERT(criminal.ai_winded, "Worn out by real stamina loss, they are not winded") + criminal.setStaminaLoss(0) + criminal.ai_check_winded() + TEST_ASSERT(!criminal.ai_winded, "Rested, they are still winded") + +/// A normal criminal notices someone armed, fights when they close in, and gives up through tg's *surrender /datum/unit_test/voidcrew_bounty_ai/normal /datum/unit_test/voidcrew_bounty_ai/normal/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(/mob/living/basic/bounty_criminal/normal, tile(1, 2)) + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(1, 2), "brawler") // BOUNTY_STYLE_BRAWLER var/mob/living/carbon/human/consistent/hunter = allocate(/mob/living/carbon/human/consistent, tile(3, 2)) var/obj/item/knife/kitchen/knife = allocate(/obj/item/knife/kitchen) hunter.put_in_hands(knife) @@ -181,162 +233,205 @@ criminal.ai_notice(hunter) TEST_ASSERT_EQUAL(criminal.ai_mode, "fighting", "Someone armed two tiles off did not start the fight") // BOUNTY_AI_FIGHTING TEST_ASSERT(criminal.ai_has_grudge(hunter), "The armed hunter is not on their grudge list") - TEST_ASSERT_EQUAL(criminal.ai_controller.blackboard["bb_bounty_style"], "brawler", "With no record, they did not fight as a brawler") // BB_BOUNTY_STYLE, BOUNTY_STYLE_BRAWLER + TEST_ASSERT_EQUAL(criminal.ai_controller.blackboard["bb_bounty_style"], "brawler", "They did not fight as a brawler") // BB_BOUNTY_STYLE, BOUNTY_STYLE_BRAWLER TEST_ASSERT_EQUAL(criminal.ai_controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET], hunter, "They are not targeting the hunter") TEST_ASSERT_EQUAL(criminal.ai_pick_fight_target(), hunter, "In the fight, they do not pick the hunter from their grudge list") + TEST_ASSERT_EQUAL(criminal.move_intent, MOVE_INTENT_RUN, "In a fight, they walk") // One companion standing: less chance to give up - var/list/buddies = criminal.spawn_companions(1) + var/list/buddies = criminal.spawn_companions(1, TRUE) + TEST_ASSERT_EQUAL(length(buddies), 1, "Test fixture: no companion was made") TEST_ASSERT_EQUAL(criminal.ai_surrender_chance(), 40, "One standing companion should take the chance from 60 to 40") // BOUNTY_SURRENDER_CHANCE - BOUNTY_SURRENDER_PER_COMPANION QDEL_LIST(buddies) TEST_ASSERT_EQUAL(criminal.ai_surrender_chance(), 60, "With no companion the chance should be 60") // BOUNTY_SURRENDER_CHANCE - // Giving up is said even right after another line, inside the cooldown (BUG-1) + // Giving up: tg's *surrender (a real paralysis, the cuff window), said even right after another line (BUG-1) var/datum/component/bounty_identity_voice/voice = criminal.LoadComponent(/datum/component/bounty_identity_voice) COOLDOWN_START(voice, line_cooldown, 8 SECONDS) // BOUNTY_SAY_COOLDOWN voice.last_line = null - criminal.ai_surrender() + TEST_ASSERT(criminal.ai_surrender(), "The criminal would not give up") TEST_ASSERT(voice.last_line in bounty_identity_strings("lines")["surrender"]["any"], "Giving up, they said [voice.last_line || "nothing"]") - TEST_ASSERT(HAS_TRAIT(criminal, "bounty_surrendered"), "Surrendering did not set the trait") // TRAIT_BOUNTY_SURRENDERED + TEST_ASSERT(criminal.npc_surrendering && criminal.IsParalyzed(), "Giving up is not tg's *surrender") + TEST_ASSERT_EQUAL(criminal.capture_state(), "stunned", "A criminal lying there with its hands on its head is not stunned") // BOUNTY_STATE_STUNNED TEST_ASSERT(!criminal.ai_can_act(), "A surrendered criminal can still act") - TEST_ASSERT_NOTNULL(criminal.ai_over_head, "No hands-up mark over a surrendered criminal") TEST_ASSERT_NULL(criminal.ai_controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET], "A surrendered criminal kept a target") var/datum/ai_controller/basic_controller/bounty/brain = criminal.ai_controller TEST_ASSERT(!brain.may_shoot(hunter), "A surrendered criminal may still shoot") TEST_ASSERT(!brain.may_strike(hunter), "A surrendered criminal may still hit") - // Hitting them afterwards does not undo it - criminal.ai_attacked_by(hunter) - TEST_ASSERT(HAS_TRAIT(criminal, "bounty_surrendered"), "A hit undid the surrender") // TRAIT_BOUNTY_SURRENDERED + TEST_ASSERT(locate(/datum/ai_planning_subtree/human_npc_surrender) in brain.planning_subtrees, "The normal criminal's tree never gives up") + var/datum/human_npc_surrender/rules = criminal.npc_surrender_rules() + TEST_ASSERT(istype(rules, /datum/human_npc_surrender/bounty), "The normal criminal gives up by the wrong rules") -/// Styles: each carries a real weapon; the pistol reloads through the Stechkin's magazine, the club is a bat, fists are a punch +/** + * Every style is a real item they carry, not hold, until the fight: the pistol in a holster with a spare + * magazine, the shotgun on the back with shells on a bandolier, the knife in a pocket, bottles in a bag, + * the bat in hand (it has nowhere else to go), fists for a brawler. A bruise pack for treating themselves. + * The tier picks the weapon (a combat knife, a combat shotgun) and the armour; it never scales them. + */ /datum/unit_test/voidcrew_bounty_ai/styles /datum/unit_test/voidcrew_bounty_ai/styles/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(/mob/living/basic/bounty_criminal/normal, tile(1, 1)) - TEST_ASSERT(bounty_ai_apply_style(criminal, "pistol"), "The pistol style would not apply") // BOUNTY_STYLE_PISTOL - var/datum/component/ranged_attacks/gun = criminal.GetComponent(/datum/component/ranged_attacks) - TEST_ASSERT_EQUAL(gun.projectile_type, /obj/projectile/bullet/bounty, "The pistol fires the wrong thing") - TEST_ASSERT_EQUAL(criminal.ai_controller.blackboard["bb_bounty_weapon"], /obj/item/gun/ballistic/automatic/pistol/aps, "The pistol style isn't the Stechkin") // BB_BOUNTY_WEAPON - var/obj/item/ammo_box/magazine/m9mm_aps/magazine = /obj/item/ammo_box/magazine/m9mm_aps - var/rounds = initial(magazine.max_ammo) - TEST_ASSERT_EQUAL(criminal.ai_controller.blackboard["bb_bounty_ammo"], rounds, "The pistol's magazine isn't a full Stechkin magazine") // BB_BOUNTY_AMMO - var/datum/bounty_style/pistol = bounty_ai_style(criminal) - for(var/shot in 1 to rounds) - pistol.after_shot(criminal) - TEST_ASSERT_EQUAL(criminal.ai_controller.blackboard["bb_bounty_ammo"], 0, "A magazine's worth of shots did not empty the magazine") // BB_BOUNTY_AMMO - TEST_ASSERT(bounty_style_health_mult("brawler") > 1, "The brawler is not sturdier") // BOUNTY_STYLE_BRAWLER - - // The club is a baseball bat; a Wanted criminal's is the metal one. The tier picks the bat, never scales it. - var/obj/item/melee/baseball_bat/bat = /obj/item/melee/baseball_bat - var/obj/item/melee/baseball_bat/ablative/metal_bat = /obj/item/melee/baseball_bat/ablative - TEST_ASSERT(bounty_ai_apply_style(criminal, "club"), "The club style would not apply") // BOUNTY_STYLE_CLUB - TEST_ASSERT_EQUAL(criminal.melee_damage_upper, initial(bat.force), "The club doesn't hit like a baseball bat") - TEST_ASSERT_EQUAL(criminal.wound_bonus, initial(bat.wound_bonus), "The club doesn't wound like a baseball bat") - TEST_ASSERT(bounty_ai_apply_style(criminal, "club", TRUE), "The heavy club style would not apply") - TEST_ASSERT_EQUAL(criminal.melee_damage_upper, initial(metal_bat.force), "A Wanted criminal's club doesn't hit like a metal bat") - - // Bare fists are a human's punch - var/obj/item/bodypart/arm/right/arm = /obj/item/bodypart/arm/right - TEST_ASSERT(bounty_ai_apply_style(criminal, "brawler"), "The brawler style would not apply") // BOUNTY_STYLE_BRAWLER - TEST_ASSERT_EQUAL(criminal.melee_damage_lower, initial(arm.unarmed_damage_low), "A brawler's weakest punch isn't a human's") - TEST_ASSERT_EQUAL(criminal.melee_damage_upper, initial(arm.unarmed_damage_high), "A brawler's hardest punch isn't a human's") - TEST_ASSERT_NULL(criminal.ai_controller.blackboard["bb_bounty_weapon"], "A brawler has something in their hand") // BB_BOUNTY_WEAPON - -/// Their weapons are the real thing: a pistol's shot is a 9mm round, a shotgun fires a whole shell of buckshot, a knife cuts like a knife + var/mob/living/carbon/human/npc/bounty_criminal/normal/pistol = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(0, 0), "pistol") // BOUNTY_STYLE_PISTOL + var/obj/item/gun/ballistic/automatic/pistol/aps/stechkin = carried(pistol, /obj/item/gun/ballistic/automatic/pistol/aps) + TEST_ASSERT_NOTNULL(stechkin, "The pistol criminal carries no Stechkin") + TEST_ASSERT(!pistol.is_holding(stechkin), "The Stechkin is in hand before the fight") + TEST_ASSERT(istype(stechkin.loc, /obj/item/storage/belt/holster), "The Stechkin isn't in a holster") + TEST_ASSERT_NOTNULL(carried(pistol, /obj/item/ammo_box/magazine/m9mm_aps), "The pistol criminal carries no spare magazine") + TEST_ASSERT_NOTNULL(carried(pistol, /obj/item/stack/medical/bruise_pack), "The criminal carries nothing to treat themselves with") + var/datum/bounty_style/pistol_style = bounty_ai_style(pistol) + TEST_ASSERT_EQUAL(pistol_style?.carried_ranged(pistol), stechkin, "The pistol style would not draw the holstered Stechkin") + TEST_ASSERT(human_npc_take_in_hand(pistol, stechkin), "The Stechkin can't be drawn") + TEST_ASSERT_EQUAL(pistol_style.ready_in_hand(pistol), stechkin, "The drawn Stechkin isn't ready to fire") + + var/mob/living/carbon/human/npc/bounty_criminal/normal/shotgunner = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(1, 0), "shotgun") // BOUNTY_STYLE_SHOTGUN + TEST_ASSERT(istype(shotgunner.back, /obj/item/gun/ballistic/shotgun/lethal), "A petty shotgun criminal has no pump shotgun on the back") + TEST_ASSERT(istype(shotgunner.belt, /obj/item/storage/belt/bandolier), "The shotgun criminal has no bandolier") + var/shells = length(shotgunner.belt.get_all_contents_type(/obj/item/ammo_casing/shotgun/buckshot)) + TEST_ASSERT_EQUAL(shells, 8, "The bandolier holds [shells] buckshot shells") // BOUNTY_SHOTGUN_SHELLS + + var/mob/living/carbon/human/npc/bounty_criminal/normal/heavy = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(2, 0), "shotgun", 2) // BOUNTY_STYLE_SHOTGUN, BOUNTY_TIER_WANTED + var/obj/item/gun/ballistic/shotgun/automatic/combat/combat = heavy.back + TEST_ASSERT(istype(combat), "A Wanted shotgun criminal has no combat shotgun") + TEST_ASSERT(istype(combat.chambered, /obj/item/ammo_casing/shotgun/buckshot), "The Wanted criminal's combat shotgun isn't loaded with buckshot") + TEST_ASSERT(istype(heavy.wear_suit, /obj/item/clothing/suit/armor/vest), "A Wanted criminal wears no armour vest") + + var/mob/living/carbon/human/npc/bounty_criminal/normal/knifer = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(3, 0), "knife") // BOUNTY_STYLE_KNIFE + var/obj/item/knife/blade = carried(knifer, /obj/item/knife) + TEST_ASSERT_NOTNULL(blade, "The knife criminal carries no knife") + TEST_ASSERT(!knifer.is_holding(blade), "The knife is out before the fight") + var/mob/living/carbon/human/npc/bounty_criminal/normal/wanted_knifer = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(4, 0), "knife", 2) // BOUNTY_STYLE_KNIFE, BOUNTY_TIER_WANTED + TEST_ASSERT_NOTNULL(carried(wanted_knifer, /obj/item/knife/combat), "A Wanted knife criminal carries no combat knife") + + var/mob/living/carbon/human/npc/bounty_criminal/normal/clubber = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(0, 1), "club") // BOUNTY_STYLE_CLUB + var/obj/item/melee/baseball_bat/bat = carried(clubber, /obj/item/melee/baseball_bat) + TEST_ASSERT(bat && clubber.is_holding(bat), "The club criminal holds no baseball bat") + + var/mob/living/carbon/human/npc/bounty_criminal/normal/thrower = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(1, 1), "bottle") // BOUNTY_STYLE_BOTTLE + var/list/bottles = thrower.get_all_contents_type(/obj/item/reagent_containers/cup/glass/bottle/beer) + TEST_ASSERT_EQUAL(length(bottles), 4, "A petty bottle thrower carries [length(bottles)] bottles") // BOUNTY_BOTTLES_PETTY + var/obj/item/reagent_containers/cup/glass/bottle/beer/bottle = bottles[1] + TEST_ASSERT(human_npc_gear_consumable(bottle), "The bottles aren't consumables that may leave the body") + + var/mob/living/carbon/human/npc/bounty_criminal/normal/brawler = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(2, 1), "brawler") // BOUNTY_STYLE_BRAWLER + TEST_ASSERT_NULL(carried(brawler, /obj/item/gun), "A brawler carries a gun") + TEST_ASSERT_NULL(carried(brawler, /obj/item/knife), "A brawler carries a knife") + TEST_ASSERT_EQUAL(brawler.npc_damage_taken, 1, "A petty criminal takes other than a person's damage") // BOUNTY_TOUGHNESS_PETTY + +/** + * Real weapons used for real: the shotgun's pump is its telegraph, a hit during it fumbles the blast, a + * clean blast fires the chambered shell; a bottle leaves the hand in a real throw; a knife cuts; a + * brawler's shove is a real shove; nobody fires through their own gang. + */ /datum/unit_test/voidcrew_bounty_ai/real_weapons - /// How many projectiles the criminal fired (counted, never kept: they fly off and are deleted) - var/shots_fired = 0 - /// The last hit the hunter took: damage, sharpness, wound bonus, exposed wound bonus + /// The last hit the hunter took: damage, sharpness var/list/last_hit + /// How many times the hunter was shoved + var/shoves = 0 -/datum/unit_test/voidcrew_bounty_ai/real_weapons/proc/on_fired(datum/source, obj/projectile/shot, atom/fired_from, atom/original) +/datum/unit_test/voidcrew_bounty_ai/real_weapons/proc/on_hit(datum/source, damage, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) SIGNAL_HANDLER - shots_fired++ + last_hit = list(damage, sharpness) -/datum/unit_test/voidcrew_bounty_ai/real_weapons/proc/on_hit(datum/source, damage, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) +/datum/unit_test/voidcrew_bounty_ai/real_weapons/proc/on_shoved(datum/source, mob/living/shover, zone, obj/item/weapon) SIGNAL_HANDLER - last_hit = list(damage, sharpness, wound_bonus, exposed_wound_bonus) + shoves++ /datum/unit_test/voidcrew_bounty_ai/real_weapons/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(/mob/living/basic/bounty_criminal/normal, tile(1, 2)) - var/mob/living/carbon/human/consistent/hunter = allocate(/mob/living/carbon/human/consistent, tile(2, 2)) + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(1, 2), "shotgun") // BOUNTY_STYLE_SHOTGUN + var/mob/living/carbon/human/consistent/hunter = allocate(/mob/living/carbon/human/consistent, tile(3, 2)) criminal.ai_add_grudge(hunter) - RegisterSignal(criminal, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(on_fired)) - RegisterSignal(hunter, COMSIG_MOB_APPLY_DAMAGE, PROC_REF(on_hit)) + var/datum/ai_controller/controller = criminal.ai_controller + var/datum/bounty_style/style = bounty_ai_style(criminal) + var/obj/item/gun/ballistic/shotgun/gun = criminal.back + TEST_ASSERT(istype(gun), "Test fixture: no shotgun on their back") + TEST_ASSERT(human_npc_take_in_hand(criminal, gun), "The shotgun can't be drawn off their back") + TEST_ASSERT_EQUAL(style.ready_in_hand(criminal), gun, "The drawn shotgun isn't ready") + TEST_ASSERT_NOTNULL(gun.chambered?.loaded_projectile, "Test fixture: the shotgun has no shell chambered") + + // The pump is the telegraph: no blast on the first step + TEST_ASSERT(!style.windup_step(criminal, controller, hunter, gun), "The shotgun fired with no pump first") + TEST_ASSERT(controller.blackboard["bb_bounty_windup_until"], "No wind-up began") // BB_BOUNTY_WINDUP_UNTIL + // A hit during it: fumbled + controller.set_blackboard_key("bb_bounty_hit_at", world.time) // BB_BOUNTY_HIT_AT + controller.set_blackboard_key("bb_bounty_windup_until", world.time) // BB_BOUNTY_WINDUP_UNTIL + TEST_ASSERT(!style.windup_step(criminal, controller, hunter, gun), "A hit during the pump did not spoil the blast") + TEST_ASSERT(controller.blackboard["bb_bounty_next_shot"] > world.time, "A fumble did not hold the next blast back") // BB_BOUNTY_NEXT_SHOT + // Clean: the chambered shell is fired, the player's way + controller.clear_blackboard_key("bb_bounty_hit_at") // BB_BOUNTY_HIT_AT + TEST_ASSERT(!style.windup_step(criminal, controller, hunter, gun), "The shotgun fired with no pump first") + controller.set_blackboard_key("bb_bounty_windup_until", world.time) // BB_BOUNTY_WINDUP_UNTIL + TEST_ASSERT(style.windup_step(criminal, controller, hunter, gun), "A clean pump did not let the blast go") + criminal.next_click = 0 + TEST_ASSERT(style.attack(criminal, controller, hunter, gun), "The blast didn't go") + TEST_ASSERT_NULL(gun.chambered?.loaded_projectile, "The blast did not fire the chambered shell") + TEST_ASSERT(bounty_ai_gun_ready(gun, TRUE), "A pump shotgun with shells in its tube isn't ready for the next pump") + + // Nobody fires through their own gang + var/list/buddies = criminal.spawn_companions(1, TRUE) + var/mob/living/carbon/human/npc/bounty_companion/buddy = buddies[1] + buddy.forceMove(tile(2, 2)) + TEST_ASSERT(bounty_ai_line_blocked(criminal, hunter), "A companion in the line of fire doesn't block the shot") + buddy.forceMove(tile(0, 4)) + TEST_ASSERT(!bounty_ai_line_blocked(criminal, hunter), "A clear line of fire counts as blocked") + QDEL_LIST(buddies) - // The pistol: the Stechkin's 9mm round, wounds and embedding and all - var/obj/projectile/bullet/c9mm/nine = /obj/projectile/bullet/c9mm - TEST_ASSERT(bounty_ai_apply_style(criminal, "pistol"), "The pistol style would not apply") // BOUNTY_STYLE_PISTOL - var/obj/projectile/bullet/bounty/bullet = fire_at(criminal, hunter) - TEST_ASSERT_EQUAL(shots_fired, 1, "A pistol shot fired [shots_fired] projectiles") - TEST_ASSERT_EQUAL(bullet.damage, initial(nine.damage), "A pistol shot doesn't do a 9mm round's damage") - TEST_ASSERT_EQUAL(bullet.wound_bonus, initial(nine.wound_bonus), "A pistol shot doesn't wound like a 9mm round") - TEST_ASSERT_EQUAL(bullet.armour_penetration, initial(nine.armour_penetration), "A pistol shot doesn't pierce armour like a 9mm round") - TEST_ASSERT_EQUAL(bullet.sharpness, initial(nine.sharpness), "A pistol shot isn't as sharp as a 9mm round") - TEST_ASSERT_EQUAL(bullet.embed_type, initial(nine.embed_type), "A pistol shot doesn't embed like a 9mm round") - TEST_ASSERT_NOTNULL(bullet.get_embed(), "A pistol shot can't embed") - TEST_ASSERT_NOTNULL(bullet.shrapnel_type, "A pistol shot leaves nothing to embed") - - // The shotgun: every pellet of a buckshot shell, one shell from the tube - var/obj/item/ammo_casing/shotgun/buckshot/shell = /obj/item/ammo_casing/shotgun/buckshot - var/obj/projectile/bullet/pellet/shotgun_buckshot/pellet = /obj/projectile/bullet/pellet/shotgun_buckshot - TEST_ASSERT(bounty_ai_apply_style(criminal, "shotgun"), "The shotgun style would not apply") // BOUNTY_STYLE_SHOTGUN - shots_fired = 0 - var/obj/projectile/bullet/bounty/first_pellet = fire_at(criminal, hunter) - TEST_ASSERT_EQUAL(shots_fired, initial(shell.pellets), "A shotgun blast wasn't a whole shell of buckshot") - TEST_ASSERT_EQUAL(first_pellet.damage, initial(pellet.damage), "A pellet doesn't do buckshot's damage") - TEST_ASSERT_EQUAL(first_pellet.sharpness, initial(pellet.sharpness), "A pellet isn't as sharp as buckshot") - var/datum/bounty_style/shotgun = bounty_ai_style(criminal) - shotgun.after_shot(criminal) - var/obj/item/ammo_box/magazine/internal/shot/tube = /obj/item/ammo_box/magazine/internal/shot - TEST_ASSERT_EQUAL(criminal.ai_controller.blackboard["bb_bounty_ammo"], initial(tube.max_ammo) - 1, "A blast took more than one shell") // BB_BOUNTY_AMMO - // A Wanted criminal's combat shotgun hits harder, as the real one does - var/obj/item/gun/ballistic/shotgun/automatic/combat/combat = /obj/item/gun/ballistic/shotgun/automatic/combat - var/datum/bounty_real_weapon/heavy = bounty_real_weapon(/obj/item/gun/ballistic/shotgun/automatic/combat, /obj/item/ammo_casing/shotgun/buckshot) - TEST_ASSERT_EQUAL(heavy.round_damage, initial(pellet.damage) * initial(combat.projectile_damage_multiplier), "The combat shotgun's pellets don't take its damage multiplier") - - // The knife: a kitchen knife's force, edge and wounds, through the mob's own blow - var/obj/item/knife/kitchen/knife = /obj/item/knife/kitchen - TEST_ASSERT(bounty_ai_apply_style(criminal, "knife"), "The knife style would not apply") // BOUNTY_STYLE_KNIFE + // A bottle, thrown for real: it leaves the hand + var/mob/living/carbon/human/npc/bounty_criminal/normal/thrower = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(1, 4), "bottle") // BOUNTY_STYLE_BOTTLE + var/datum/bounty_style/bottle_style = bounty_ai_style(thrower) + var/obj/item/reagent_containers/cup/glass/bottle/bottle = bottle_style.carried_ranged(thrower) + TEST_ASSERT_NOTNULL(bottle, "The bottle thrower has no bottle to draw") + TEST_ASSERT(human_npc_take_in_hand(thrower, bottle), "A bottle can't be drawn from the bag") + TEST_ASSERT(bottle_style.attack(thrower, thrower.ai_controller, hunter, bottle), "The bottle wasn't thrown") + TEST_ASSERT(!thrower.is_holding(bottle), "The thrown bottle is still in their hand") + TEST_ASSERT(!thrower.throw_mode, "Throw mode was left on after the throw") + + // A knife cuts, through the body's own blow + RegisterSignal(hunter, COMSIG_MOB_APPLY_DAMAGE, PROC_REF(on_hit)) + var/mob/living/carbon/human/npc/bounty_criminal/normal/knifer = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(4, 2), "knife") // BOUNTY_STYLE_KNIFE + knifer.ai_add_grudge(hunter) + var/obj/item/knife/blade = carried(knifer, /obj/item/knife) + TEST_ASSERT(human_npc_take_in_hand(knifer, blade) && human_npc_ready_item(knifer, blade), "The knife can't be drawn") last_hit = null - TEST_ASSERT(criminal.melee_attack(hunter, ignore_cooldown = TRUE), "The knife didn't land") - TEST_ASSERT_NOTNULL(last_hit, "The knife hit did no damage") - TEST_ASSERT_EQUAL(last_hit[1], initial(knife.force), "A knife hit doesn't do the kitchen knife's force") - TEST_ASSERT_EQUAL(last_hit[2], SHARP_EDGED, "A knife hit isn't edged") - TEST_ASSERT_EQUAL(last_hit[3], initial(knife.wound_bonus), "A knife hit doesn't wound like the kitchen knife") - TEST_ASSERT_EQUAL(last_hit[4], initial(knife.exposed_wound_bonus), "A knife hit doesn't wound bare skin like the kitchen knife") - -/// Loads a shot from `criminal` at `target` as it leaves the gun, as the ranged_attacks component's shot does, and returns it -/datum/unit_test/voidcrew_bounty_ai/real_weapons/proc/fire_at(mob/living/basic/bounty_criminal/criminal, mob/living/target) - var/datum/bounty_style/style = bounty_ai_style(criminal) - var/obj/projectile/bullet/bounty/shot = allocate(style.projectile_type, get_turf(criminal)) - shot.firer = criminal - shot.fired_from = criminal - shot.original = target - SEND_SIGNAL(criminal, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, shot, criminal, target) - return shot - -/// Every activity kind starts with its anchor in place + knifer.next_click = 0 + knifer.ai_controller.ai_interact(target = hunter, combat_mode = TRUE) + TEST_ASSERT_NOTNULL(last_hit, "The knife did no damage") + TEST_ASSERT_EQUAL(last_hit[2], SHARP_EDGED, "A knife's cut isn't edged") + + // A brawler's shove is a right click with an empty hand + RegisterSignal(hunter, COMSIG_LIVING_DISARM_HIT, PROC_REF(on_shoved)) + var/mob/living/carbon/human/npc/bounty_criminal/normal/brawler = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(4, 3), "brawler") // BOUNTY_STYLE_BRAWLER + brawler.forceMove(get_step(hunter, NORTH)) + TEST_ASSERT(human_npc_ready_empty_hand(brawler), "Test fixture: the brawler has no free hand") + brawler.next_click = 0 + brawler.next_move = 0 + brawler.ai_controller.ai_interact(target = hunter, combat_mode = TRUE, modifiers = list("right" = TRUE)) // RIGHT_CLICK + TEST_ASSERT_EQUAL(shoves, 1, "The brawler's shove didn't shove") + +/// Every activity kind starts with its anchor in place; the bar is a real seat and a real glass, really drunk from /datum/unit_test/voidcrew_bounty_ai/activities /datum/unit_test/voidcrew_bounty_ai/activities/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(/mob/living/basic/bounty_criminal/normal, tile(1, 1)) + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(1, 1), "brawler") // BOUNTY_STYLE_BRAWLER var/obj/structure/chair/stool/stool = allocate(/obj/structure/chair/stool, tile(2, 2)) var/obj/structure/table/table = allocate(/obj/structure/table, tile(3, 2)) - // bar: the stool, sitting, a drink; put away, the drink goes on the table + // bar: the stool, sitting (a buckle), a glass in hand, a real sip; put away, the glass goes on the table var/datum/bounty_activity/activity = criminal.start_activity("bar", stool) // BOUNTY_ACTIVITY_BAR TEST_ASSERT(istype(activity, /datum/bounty_activity/bar), "The bar activity did not start") TEST_ASSERT_EQUAL(activity.spot, get_turf(stool), "The bar activity is not headed for the stool") criminal.forceMove(get_turf(stool)) criminal.ai_activity_step(1) TEST_ASSERT_EQUAL(criminal.buckled, stool, "At the bar, they did not sit on the stool") - TEST_ASSERT_NOTNULL(criminal.ai_drink, "At the bar, they have no drink") - TEST_ASSERT_NOTNULL(criminal.ai_hand, "At the bar, the drink is not in their hand") - var/obj/item/glass = criminal.ai_drink + var/obj/item/reagent_containers/cup/glass/drinkingglass/glass = criminal.ai_drink + TEST_ASSERT_NOTNULL(glass, "At the bar, they have no drink") + TEST_ASSERT(criminal.is_holding(glass), "At the bar, the glass is not in their hand") + var/before = glass.reagents.total_volume + TEST_ASSERT(criminal.ai_sip(), "They would not drink") + TEST_ASSERT(glass.reagents.total_volume < before, "A sip left the glass as full as it was") + TEST_ASSERT(criminal.reagents.total_volume > 0, "A sip put nothing in them") criminal.ai_suspend_activity() TEST_ASSERT_NULL(criminal.buckled, "Done at the bar, they are still sitting") - TEST_ASSERT_EQUAL(glass.loc, get_turf(table), "Done at the bar, the drink was not put down on the table") + TEST_ASSERT_EQUAL(glass.loc, get_turf(table), "Done at the bar, the glass was not put down on the table") TEST_ASSERT_EQUAL(criminal.ai_activity_kind, "bar", "Putting the activity away forgot what it was") // BOUNTY_ACTIVITY_BAR qdel(glass) criminal.forceMove(tile(1, 1)) @@ -362,7 +457,7 @@ TEST_ASSERT(length(exploring.points) >= 1, "Exploring, they found nothing to look at") // chat: with a companion - var/list/buddies = criminal.spawn_companions(1) + var/list/buddies = criminal.spawn_companions(1, TRUE) activity = criminal.start_activity("chat", buddies[1]) // BOUNTY_ACTIVITY_CHAT TEST_ASSERT(istype(activity, /datum/bounty_activity/chat), "The chat activity did not start") var/datum/bounty_activity/chat/talking = activity @@ -370,7 +465,7 @@ criminal.ai_end_activity() QDEL_LIST(buddies) - // camp: a fire (or a fixed lamp), a mattress and a crate around the spot + // camp: a fire (or a fixed lamp), a mattress and a crate around the spot; sat at on the ground (tg's resting) activity = criminal.start_activity("camp", tile(2, 3)) // BOUNTY_ACTIVITY_CAMP TEST_ASSERT(istype(activity, /datum/bounty_activity/camp), "The camp activity did not start") var/atom/movable/fire = criminal.ai_camp_fire() @@ -378,42 +473,74 @@ TEST_ASSERT(fire.density || fire.anchored, "The camp's fire can be walked through, or its lamp carried off") var/list/camp = criminal.ai_camp.Copy() TEST_ASSERT_EQUAL(criminal.ai_make_camp(tile(2, 3)), fire, "A second camp was made") + criminal.ai_crouch() + TEST_ASSERT(criminal.resting, "Sitting down at the camp isn't tg's resting") + criminal.ai_stand_up() + TEST_ASSERT(!criminal.resting, "Getting up from the camp left them resting") criminal.ai_end_activity() for(var/datum/weakref/ref as anything in camp) qdel(ref.resolve()) - // blend: a patron now + // blend: a patron now, at a walk activity = criminal.start_activity("blend", stool) // BOUNTY_ACTIVITY_BLEND TEST_ASSERT(istype(activity, /datum/bounty_activity/blend), "The blend activity did not start") TEST_ASSERT(criminal.blended, "Blending in did not make them a patron") + TEST_ASSERT_EQUAL(criminal.move_intent, MOVE_INTENT_WALK, "Blended in, they don't walk") criminal.start_activity(null) TEST_ASSERT(!criminal.blended, "Stopping the blend left them a patron") -/// Companions spawn beside their criminal, fight in their style, and delete cleanly in any order +/** + * Companions are made through the human NPC spawn queue (or at once for a test), join their criminal, + * fight in their style with the real weapon they carry, give up through tg's *surrender, and delete + * cleanly in any order. + */ /datum/unit_test/voidcrew_bounty_ai/companions /datum/unit_test/voidcrew_bounty_ai/companions/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(/mob/living/basic/bounty_criminal/normal, tile(2, 2)) - var/list/buddies = criminal.spawn_companions(5) + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(2, 2), "brawler") // BOUNTY_STYLE_BRAWLER + // Queued, a companion is not there yet + var/datum/human_npc_order/order = bounty_spawn_companion(criminal, tile(1, 1)) + TEST_ASSERT_NOTNULL(order, "No companion was queued") + TEST_ASSERT_NULL(order.spawned_ref?.resolve(), "A queued companion was made at once") + qdel(order) + + var/list/buddies = criminal.spawn_companions(5, TRUE) TEST_ASSERT_EQUAL(length(buddies), 2, "Asked for five, [length(buddies)] companions came; the most is two") // BOUNTY_COMPANIONS_MAX TEST_ASSERT_EQUAL(length(criminal.ai_companions()), 2, "The criminal does not know its companions") - for(var/mob/living/basic/bounty_companion/buddy as anything in buddies) + for(var/mob/living/carbon/human/npc/bounty_companion/buddy as anything in buddies) TEST_ASSERT_EQUAL(buddy.ai_leader(), criminal, "A companion does not know its criminal") TEST_ASSERT(get_dist(buddy, criminal) <= 2, "A companion spawned too far off") TEST_ASSERT_NOTNULL(buddy.ai_controller.blackboard["bb_bounty_style"], "A companion has no style") // BB_BOUNTY_STYLE + TEST_ASSERT_EQUAL(buddy.ai_controller.blackboard["bb_bounty_style"], buddy.ai_style, "A companion fights in another style than it was dressed for") // BB_BOUNTY_STYLE TEST_ASSERT(bounty_ai_same_gang(buddy, criminal), "A companion is not on its criminal's side") TEST_ASSERT(!length(buddy.faction - criminal.faction) && !length(criminal.faction - buddy.faction), "A companion does not share its criminal's faction") + TEST_ASSERT_EQUAL(buddy.npc_damage_taken, 1, "A companion takes other than a person's damage") + var/datum/bounty_style/style = GLOB.bounty_styles[buddy.ai_style] + if(style.weapon_type) + TEST_ASSERT_NOTNULL(bounty_ai_carried(buddy, style.weapon_type), "A [buddy.ai_style] companion doesn't carry its weapon") // Called into a fight var/mob/living/carbon/human/consistent/hunter = allocate(/mob/living/carbon/human/consistent, tile(4, 4)) criminal.ai_rally_companions(hunter) - var/mob/living/basic/bounty_companion/first = buddies[1] + var/mob/living/carbon/human/npc/bounty_companion/first = buddies[1] TEST_ASSERT(hunter in first.ai_controller.blackboard[BB_BASIC_MOB_RETALIATE_LIST], "Rallied, a companion did not take the hunter on") + TEST_ASSERT_EQUAL(first.move_intent, MOVE_INTENT_RUN, "In the fight, a companion walks") + + // A companion gives up the way its criminal does: tg's *surrender + var/mob/living/carbon/human/npc/bounty_companion/second = buddies[2] + TEST_ASSERT(istype(second.npc_surrender_rules(), /datum/human_npc_surrender/bounty), "A companion gives up by the wrong rules") + TEST_ASSERT(second.npc_surrender(), "A companion would not give up") + TEST_ASSERT(second.IsParalyzed() && !second.ai_can_fight(), "A companion that gave up can still fight") + + // Up from a real stamina crit with nobody about, it carries on + first.adjustStaminaLoss(200) + TEST_ASSERT(first.ai_stamcritted, "A real stamina crit went unnoticed") + first.setStaminaLoss(0) + TEST_ASSERT(!first.ai_stamcritted, "Up from stamina crit, the companion never decided what to do") // Deleting one, then the criminal, then the last qdel(first) TEST_ASSERT_EQUAL(length(criminal.ai_companions()), 1, "A deleted companion is still counted") - var/mob/living/basic/bounty_companion/second = buddies[2] qdel(criminal) TEST_ASSERT_NULL(second.ai_leader(), "A companion still has a deleted criminal") second.ai_join_fight(hunter) @@ -421,15 +548,14 @@ /** * The blend routine, with the fugitive and a decoy made as P6 makes them: the decoy runs the - * fugitive's own controller, and both carry P6's act. They share a routine and a pace, can't be - * pulled, and let shots aimed at someone else through. Nothing but P6's rules exposes the fugitive - * (C1), and once exposed, `blended` and P6's act agree. + * fugitive's own controller, and both carry P6's act. They share a routine and a pace. Confronted, the + * fugitive stops blending in, and `blended` and P6's act agree. */ /datum/unit_test/voidcrew_bounty_ai/blend /datum/unit_test/voidcrew_bounty_ai/blend/Run() - var/mob/living/basic/bounty_criminal/meek/fugitive = allocate(/mob/living/basic/bounty_criminal/meek, tile(1, 1)) - var/mob/living/basic/bounty_criminal/decoy/decoy = allocate(/mob/living/basic/bounty_criminal/decoy, tile(3, 1)) + var/mob/living/carbon/human/npc/bounty_criminal/meek/fugitive = allocate(/mob/living/carbon/human/npc/bounty_criminal/meek, tile(1, 1)) + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = allocate(/mob/living/carbon/human/npc/bounty_criminal/decoy, tile(3, 1)) var/mob/living/carbon/human/consistent/hunter = allocate(/mob/living/carbon/human/consistent, tile(2, 3)) hunter.mind_initialize() QDEL_NULL(decoy.ai_controller) @@ -438,57 +564,29 @@ TEST_ASSERT_EQUAL(decoy.ai_controller?.type, controller_type, "The decoy does not run its fugitive's controller") fugitive.AddComponent(/datum/component/bounty_outpost_blend, null, FALSE) decoy.AddComponent(/datum/component/bounty_outpost_blend, null, TRUE) - var/datum/component/bounty_outpost_blend/act = fugitive.GetComponent(/datum/component/bounty_outpost_blend) fugitive.start_activity("blend") // BOUNTY_ACTIVITY_BLEND decoy.start_activity("blend") // BOUNTY_ACTIVITY_BLEND TEST_ASSERT(fugitive.blended && decoy.blended, "Blending in did not make both patrons") TEST_ASSERT_EQUAL(fugitive.ai_activity?.type, decoy.ai_activity?.type, "The fugitive and the decoy do not share one routine") - TEST_ASSERT_EQUAL(fugitive.speed, decoy.speed, "The fugitive and the decoy move at different speeds") - TEST_ASSERT_EQUAL(fugitive.speed, 2.5, "Blended in, they do not move at the calm pace") // BOUNTY_CALM_SPEED + TEST_ASSERT_EQUAL(fugitive.move_intent, decoy.move_intent, "The fugitive and the decoy move at different paces") + TEST_ASSERT_EQUAL(fugitive.move_intent, MOVE_INTENT_WALK, "Blended in, they do not walk") - TEST_ASSERT(!fugitive.can_be_pulled(hunter, MOVE_FORCE_EXTREMELY_STRONG), "A blended fugitive can be pulled") - TEST_ASSERT(!decoy.can_be_pulled(hunter, MOVE_FORCE_EXTREMELY_STRONG), "A decoy can be pulled") - - var/obj/projectile/bullet/shot = allocate(/obj/projectile/bullet, tile(2, 2)) - shot.original = hunter - TEST_ASSERT(SEND_SIGNAL(decoy, COMSIG_PROJECTILE_PREHIT, shot) & PROJECTILE_INTERRUPT_HIT_PHASE, "A shot aimed at someone else hit the decoy") - TEST_ASSERT(SEND_SIGNAL(fugitive, COMSIG_PROJECTILE_PREHIT, shot) & PROJECTILE_INTERRUPT_HIT_PHASE, "A shot aimed at someone else hit the blended fugitive") - QDEL_NULL(shot) - - // Nothing P6 does not count gives the fugitive away: a stray throw, a creature, getting up, cuffs slipping + // Nothing P6 does not count gives the fugitive away: a stray blow, a creature, getting up, cuffs slipping var/datum/ai_controller/basic_controller/bounty/fugitive_brain = fugitive.ai_controller var/datum/ai_controller/basic_controller/bounty/decoy_brain = decoy.ai_controller var/mob/living/carbon/human/consistent/thrower = allocate(/mob/living/carbon/human/consistent, tile(4, 4)) fugitive_brain.pawn_attacked(thrower) - var/mob/living/basic/carp/creature = allocate(/mob/living/basic/carp, tile(0, 2)) - SEND_SIGNAL(fugitive, COMSIG_ATOM_ATTACK_ANIMAL, creature) fugitive.ai_add_grudge(hunter) fugitive.ai_on_recovered() fugitive.ai_on_unrestrained(null) - TEST_ASSERT(fugitive.blended && act.blending, "Something P6 does not count as a hit exposed the fugitive") + TEST_ASSERT(fugitive.blended, "Something P6 does not count as a hit exposed the fugitive") TEST_ASSERT_EQUAL(fugitive.ai_mode, "calm", "The blended fugitive reacted on its own") // BOUNTY_AI_CALM TEST_ASSERT(istype(fugitive.ai_activity, /datum/bounty_activity/blend), "The blended fugitive stopped blending in") decoy_brain.pawn_attacked(thrower) TEST_ASSERT_EQUAL(decoy.ai_mode, "calm", "A blended decoy reacted to a blow on its own") // BOUNTY_AI_CALM - // Walking out, not even a blow from a person gives it away - act.leaving = TRUE - hunter.set_combat_mode(TRUE) - SEND_SIGNAL(fugitive, COMSIG_ATOM_ATTACK_HAND, hunter, null) - fugitive_brain.pawn_attacked(hunter) - TEST_ASSERT(fugitive.blended, "A blow while walking out exposed the fugitive") - TEST_ASSERT_EQUAL(fugitive.ai_mode, "calm", "Walking out, the fugitive reacted to a blow") // BOUNTY_AI_CALM - act.leaving = FALSE - - // A hit P6 counts exposes it, and its act and `blended` end together - act.handle_hit(hunter) - TEST_ASSERT(!fugitive.blended, "A hit P6 counts did not expose the fugitive") - TEST_ASSERT(!act.blending, "Exposed, the fugitive is still blending in by P6's act") - TEST_ASSERT_EQUAL(fugitive.ai_mode, "fleeing", "The exposed meek fugitive did not bolt") // BOUNTY_AI_FLEEING - TEST_ASSERT(fugitive.ai_activity_kind != "blend", "The exposed fugitive would go back to blending in") // BOUNTY_ACTIVITY_BLEND - // Confronted directly (any package's on_confronted()), P6's act ends too - var/mob/living/basic/bounty_criminal/normal/other = allocate(/mob/living/basic/bounty_criminal/normal, tile(0, 4)) + var/mob/living/carbon/human/npc/bounty_criminal/normal/other = allocate(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(0, 4)) other.AddComponent(/datum/component/bounty_outpost_blend, null, FALSE) other.start_activity("blend") // BOUNTY_ACTIVITY_BLEND var/datum/component/bounty_outpost_blend/other_act = other.GetComponent(/datum/component/bounty_outpost_blend) @@ -509,27 +607,39 @@ TEST_ASSERT_EQUAL(decoy.ai_mode, "calm", "Out of sight long enough, the decoy did not calm down") // BOUNTY_AI_CALM TEST_ASSERT(istype(decoy.ai_resume_activity(), /datum/bounty_activity/blend), "Calm again, the decoy did not go back to blending in") -/// A normal fugitive's decoy runs the normal tree, which can run it off and calm it down (M3) -/datum/unit_test/voidcrew_bounty_ai/normal_decoy - -/datum/unit_test/voidcrew_bounty_ai/normal_decoy/Run() - var/mob/living/basic/bounty_criminal/normal/fugitive = allocate(/mob/living/basic/bounty_criminal/normal, tile(1, 1)) - var/mob/living/basic/bounty_criminal/decoy/decoy = allocate(/mob/living/basic/bounty_criminal/decoy, tile(3, 1)) - QDEL_NULL(decoy.ai_controller) - var/controller_type = fugitive.ai_controller.type - new controller_type(decoy) - var/datum/ai_controller/basic_controller/bounty/brain = decoy.ai_controller - TEST_ASSERT(locate(/datum/ai_planning_subtree/bounty_flee_and_calm) in brain.planning_subtrees, "The normal tree cannot run a fleeing decoy") +/// Every bounty tree runs on a human: soft crit first, then breaking free and walking out, before anything of its own (contract 2.4) +/datum/unit_test/voidcrew_bounty_ai/trees + +/datum/unit_test/voidcrew_bounty_ai/trees/Run() + var/list/pawn_types = list( + /mob/living/carbon/human/npc/bounty_criminal = /datum/ai_controller/basic_controller/bounty/criminal, + /mob/living/carbon/human/npc/bounty_criminal/meek = /datum/ai_controller/basic_controller/bounty/criminal/meek, + /mob/living/carbon/human/npc/bounty_criminal/normal = /datum/ai_controller/basic_controller/bounty/criminal/normal, + /mob/living/carbon/human/npc/bounty_criminal/decoy = /datum/ai_controller/basic_controller/bounty/criminal/decoy, + /mob/living/carbon/human/npc/bounty_companion = /datum/ai_controller/basic_controller/bounty/companion, + ) + var/spot = 0 + for(var/pawn_type in pawn_types) + var/mob/living/carbon/human/npc/pawn = allocate(pawn_type, tile(spot++, 2)) + var/datum/ai_controller/brain = pawn.ai_controller + TEST_ASSERT(istype(brain, pawn_types[pawn_type]), "[pawn_type]'s AI didn't take its human body") + TEST_ASSERT_EQUAL(brain.blackboard[BB_TARGET_MINIMUM_STAT], CONSCIOUS, "[pawn_type] fights people who are down") + TEST_ASSERT(length(brain.planning_subtrees) >= 3, "[pawn_type] has no planning list") + var/datum/first = brain.planning_subtrees[1] + var/datum/second = brain.planning_subtrees[2] + var/datum/third = brain.planning_subtrees[3] + TEST_ASSERT_EQUAL(first.type, /datum/ai_planning_subtree/human_npc_soft_crit, "[pawn_type] doesn't plan soft crit first") + TEST_ASSERT_EQUAL(second.type, /datum/ai_planning_subtree/bounty_escape_captivity, "[pawn_type] doesn't break free second") + TEST_ASSERT_EQUAL(third.type, /datum/ai_planning_subtree/human_npc_walk_out, "[pawn_type] doesn't walk out third") /// Knocked down is the cuff window (H2): no blows, no shots, no aim, no climbing into hiding /datum/unit_test/voidcrew_bounty_ai/knockdown /datum/unit_test/voidcrew_bounty_ai/knockdown/Run() - var/mob/living/basic/bounty_criminal/meek/meek = allocate(/mob/living/basic/bounty_criminal/meek, tile(0, 0)) - var/mob/living/basic/bounty_criminal/normal/normal = allocate(/mob/living/basic/bounty_criminal/normal, tile(3, 3)) + var/mob/living/carbon/human/npc/bounty_criminal/meek/meek = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/meek, tile(0, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/normal/normal = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(3, 3), "brawler") // BOUNTY_STYLE_BRAWLER var/mob/living/carbon/human/consistent/hunter = allocate(/mob/living/carbon/human/consistent, tile(1, 1)) var/obj/structure/closet/locker = allocate(/obj/structure/closet, tile(1, 0)) - meek.ai_update_style() normal.ai_add_grudge(hunter) var/datum/ai_controller/basic_controller/bounty/meek_brain = meek.ai_controller var/datum/ai_controller/basic_controller/bounty/normal_brain = normal.ai_controller @@ -552,8 +662,8 @@ TEST_ASSERT(meek_brain, "The meek criminal has no AI") // A companion on the floor can't fight either - var/list/buddies = normal.spawn_companions(1) - var/mob/living/basic/bounty_companion/buddy = buddies[1] + var/list/buddies = normal.spawn_companions(1, TRUE) + var/mob/living/carbon/human/npc/bounty_companion/buddy = buddies[1] var/datum/ai_controller/basic_controller/bounty/buddy_brain = buddy.ai_controller buddy.Knockdown(5 SECONDS) TEST_ASSERT(!buddy.ai_can_fight(), "A knocked-down companion can still fight") @@ -567,25 +677,26 @@ TEST_ASSERT(buddy.ai_can_fight(), "Up again, the companion still can't fight") QDEL_LIST(buddies) -/// A companion fights on when its criminal is down, cuffed, surrendered or gone (H3) +/// A companion fights on when its criminal is down, cuffed, gave up or is gone (H3) /datum/unit_test/voidcrew_bounty_ai/companion_fights_on /datum/unit_test/voidcrew_bounty_ai/companion_fights_on/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(/mob/living/basic/bounty_criminal/normal, tile(2, 2)) + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(2, 2), "brawler") // BOUNTY_STYLE_BRAWLER var/mob/living/carbon/human/consistent/hunter = allocate(/mob/living/carbon/human/consistent, tile(4, 4)) - var/list/buddies = criminal.spawn_companions(1) - var/mob/living/basic/bounty_companion/buddy = buddies[1] + var/list/buddies = criminal.spawn_companions(1, TRUE) + var/mob/living/carbon/human/npc/bounty_companion/buddy = buddies[1] var/datum/ai_controller/basic_controller/bounty/buddy_brain = buddy.ai_controller var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(buddy_brain.blackboard[BB_TARGETING_STRATEGY]) criminal.ai_add_grudge(hunter) - ADD_TRAIT(criminal, "bounty_surrendered", "test") // TRAIT_BOUNTY_SURRENDERED - TEST_ASSERT(!criminal.may_attack(hunter), "A surrendered criminal may still attack") - TEST_ASSERT(criminal.may_attack_scope(hunter), "Surrendering took the hunter out of the criminal's scope") - TEST_ASSERT(buddy_brain.may_strike(hunter), "With its criminal surrendered, the companion may not hit the hunter") - TEST_ASSERT(strategy.can_attack(buddy, hunter, 9), "With its criminal surrendered, the companion will not target the hunter") // BOUNTY_FIGHT_VISION - TEST_ASSERT(bounty_ai_may_hit(buddy, hunter), "With its criminal surrendered, the companion's shots pass the hunter") - REMOVE_TRAIT(criminal, "bounty_surrendered", "test") // TRAIT_BOUNTY_SURRENDERED + // Its criminal lying there giving up (the human NPC base's state; the roll to run is left out of this test) + criminal.npc_surrendering = TRUE + TEST_ASSERT(!criminal.may_attack(hunter), "A surrendering criminal may still attack") + TEST_ASSERT(criminal.may_attack_scope(hunter), "Giving up took the hunter out of the criminal's scope") + TEST_ASSERT(buddy_brain.may_strike(hunter), "With its criminal giving up, the companion may not hit the hunter") + TEST_ASSERT(strategy.can_attack(buddy, hunter, 9), "With its criminal giving up, the companion will not target the hunter") // BOUNTY_FIGHT_VISION + TEST_ASSERT(bounty_ai_may_hit(buddy, hunter), "With its criminal giving up, the companion may not hit the hunter") + criminal.npc_surrendering = FALSE // Hit by the hunter, it takes them on itself: no tg element needed buddy_brain.pawn_attacked(hunter) @@ -600,9 +711,9 @@ /datum/unit_test/voidcrew_bounty_ai/friendly_fire /datum/unit_test/voidcrew_bounty_ai/friendly_fire/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(/mob/living/basic/bounty_criminal/normal, tile(2, 2)) - var/list/buddies = criminal.spawn_companions(1) - var/mob/living/basic/bounty_companion/buddy = buddies[1] + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(2, 2), "brawler") // BOUNTY_STYLE_BRAWLER + var/list/buddies = criminal.spawn_companions(1, TRUE) + var/mob/living/carbon/human/npc/bounty_companion/buddy = buddies[1] var/mob/living/basic/carp/crewmate = allocate(/mob/living/basic/carp, tile(4, 4)) crewmate.faction = criminal.faction.Copy() var/datum/ai_controller/basic_controller/bounty/brain = criminal.ai_controller @@ -613,13 +724,18 @@ TEST_ASSERT_EQUAL(criminal.ai_mode, "calm", "A stray blow from its own side started a fight") // BOUNTY_AI_CALM var/list/retaliate = buddy_brain.blackboard[BB_BASIC_MOB_RETALIATE_LIST] TEST_ASSERT(!LAZYLEN(retaliate) || !(crewmate in retaliate), "A stray blow from its own side turned the companion on a crewmate") + // Their own side in the line of fire stops a shot + crewmate.forceMove(tile(3, 2)) + var/mob/living/carbon/human/consistent/hunter = allocate(/mob/living/carbon/human/consistent, tile(4, 2)) + criminal.ai_add_grudge(hunter) + TEST_ASSERT(bounty_ai_line_blocked(criminal, hunter), "A crewmate in the line of fire doesn't block the shot") QDEL_LIST(buddies) /// Someone in a mech is seen, fought and run from like anyone else (H4) /datum/unit_test/voidcrew_bounty_ai/mech_hunter /datum/unit_test/voidcrew_bounty_ai/mech_hunter/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(/mob/living/basic/bounty_criminal/normal, tile(1, 1)) + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = make_criminal(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(1, 1), "brawler") // BOUNTY_STYLE_BRAWLER var/mob/living/carbon/human/consistent/pilot = allocate(/mob/living/carbon/human/consistent, tile(3, 3)) var/obj/vehicle/sealed/mecha/ripley/mech = allocate(/obj/vehicle/sealed/mecha/ripley, tile(3, 3)) mech.mob_enter(pilot, silent = TRUE) @@ -639,7 +755,7 @@ TEST_ASSERT_EQUAL(criminal.ai_pick_fight_target(), mech, "In the fight, the criminal does not pick the mech") // The mech's gunfire is the pilot's: a meek one runs from it - var/mob/living/basic/bounty_criminal/meek/meek = allocate(/mob/living/basic/bounty_criminal/meek, tile(0, 4)) + var/mob/living/carbon/human/npc/bounty_criminal/meek/meek = allocate(/mob/living/carbon/human/npc/bounty_criminal/meek, tile(0, 4)) var/obj/projectile/bullet/shot = allocate(/obj/projectile/bullet, tile(0, 3)) shot.firer = mech SEND_SIGNAL(meek, COMSIG_ATOM_BULLET_ACT, shot, BODY_ZONE_CHEST, FALSE, 0) @@ -656,7 +772,7 @@ var/obj/item/kirbyplants/plant = allocate(/obj/item/kirbyplants, tile(4, 4)) allocate(/obj/structure/window/fulltile, tile(3, 4)) allocate(/obj/structure/window/fulltile, tile(4, 3)) - var/mob/living/basic/bounty_criminal/meek/criminal = allocate(/mob/living/basic/bounty_criminal/meek, tile(3, 3)) + var/mob/living/carbon/human/npc/bounty_criminal/meek/criminal = allocate(/mob/living/carbon/human/npc/bounty_criminal/meek, tile(3, 3)) TEST_ASSERT(!criminal.Adjacent(plant), "The test's panes do not wall the plant off") TEST_ASSERT(!criminal.ai_at_hide_spot(plant), "A walled-off plant counts as right beside them") TEST_ASSERT(!criminal.ai_hide_in(plant), "They got onto the plant through the glass") @@ -679,3 +795,19 @@ TEST_ASSERT_EQUAL(criminal.ai_pick_hide_spot(null), locker, "They did not pick the locker they can walk round to") var/turf/stand = criminal.ai_hide_approach(locker) TEST_ASSERT(stand && stand != tile(0, 0) && stand.Adjacent(locker), "The locker is to be climbed into through the window") + +/// The leash (CR-19): the AI's own steps never leave their site; there is no Move() override on the body +/datum/unit_test/voidcrew_bounty_ai/leash + +/datum/unit_test/voidcrew_bounty_ai/leash/Run() + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = allocate(/mob/living/carbon/human/npc/bounty_criminal/normal, tile(1, 1)) + var/turf/home = tile(1, 1) + criminal.site_bounds = list(home.x - 1, home.y - 1, home.x + 1, home.y + 1, home.z) + TEST_ASSERT(bounty_ai_step(criminal, tile(2, 1)), "A step inside their site was refused") + TEST_ASSERT_EQUAL(criminal.loc, tile(2, 1), "The step inside their site didn't move them") + TEST_ASSERT(!bounty_ai_step(criminal, tile(3, 1)), "A step off their site was taken") + TEST_ASSERT_EQUAL(criminal.loc, tile(2, 1), "A refused step moved them anyway") + // Their body is anyone's to move: only their own steps are held to the leash + criminal.Move(tile(3, 1), EAST) + TEST_ASSERT_EQUAL(criminal.loc, tile(3, 1), "Their body can't be moved off their site (a Move() override)") + criminal.site_bounds = null diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_board.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_board.dm index e1487fe9dbe..09ba6189956 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_board.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_board.dm @@ -48,13 +48,19 @@ /// The posting's criminal, spawned at `spot` and adopted by hand, its AI off so it stays where a test puts it /datum/unit_test/proc/board_test_criminal(datum/criminal_bounty/posting, turf/spot) - var/mob/living/basic/bounty_criminal/criminal = spawn_bounty_criminal(posting.record, spot, posting) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = spawn_bounty_criminal(posting.record, spot, posting) if(!criminal) return null posting.board_adopt_criminal(criminal, null, "explore", null) // BOUNTY_ACTIVITY_EXPLORE criminal.ai_controller?.set_ai_status(AI_STATUS_OFF) return criminal +/// Puts real tg handcuffs on `target`, as a person would (tg's apply_cuffs() without the do_after). Returns the cuffs it wears. +/datum/unit_test/proc/board_test_cuff(mob/living/carbon/target) + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + cuffs.apply_cuffs(target, allocate(/mob/living/carbon/human/consistent), dispense = TRUE) + return target.handcuffed + /// A bare ship record with an account holding `balance` /datum/unit_test/proc/board_test_ship(balance = 0) var/obj/structure/overmap/ship/ship = allocate(/obj/structure/overmap/ship) @@ -348,7 +354,7 @@ var/turf/aside = locate(pad_turf.x + 2, pad_turf.y, pad_turf.z) var/obj/machinery/mission_pad/pad = allocate(/obj/machinery/mission_pad, pad_turf) var/datum/criminal_bounty/posting = board_test_posting() - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, aside) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, aside) TEST_ASSERT_NOTNULL(criminal, "No criminal was spawned") var/list/found = list() @@ -366,7 +372,7 @@ // Another bounty's criminal is the wrong mob var/datum/criminal_bounty/other_posting = board_test_posting() - var/mob/living/basic/bounty_criminal/wrong = board_test_criminal(other_posting, aside) + var/mob/living/carbon/human/npc/bounty_criminal/wrong = board_test_criminal(other_posting, aside) wrong.forceMove(pad_turf) TEST_ASSERT(istext(posting.board_target_refusal(pad, found)), "The pad took another bounty's criminal for this one") TEST_ASSERT(!QDELETED(wrong), "The wrong criminal was deleted") @@ -405,38 +411,36 @@ // ===== PAY ===== -/// Pay by the worst state reached: 100 / 100 / 60 / 25%, vouchers only on a full share (12.1) +/// Pay by the criminal's condition on the pad: 100 / 100 / 60 / 25%, vouchers only on a full share (owner ruling 2026-11-08) /datum/unit_test/voidcrew_bounty_board/pay /datum/unit_test/voidcrew_bounty_board/pay/Run() var/turf/pad_turf = run_loc_floor_bottom_left var/obj/machinery/mission_pad/pad = allocate(/obj/machinery/mission_pad, pad_turf) var/obj/structure/overmap/ship/ship = board_test_ship() - // state, worst state, credits, vouchers: a value of 1000 and 1 voucher (VOUCHER_CREDIT_VALUE 1200) + // state at the pad, credits, vouchers: a value of 1000 and 1 voucher (VOUCHER_CREDIT_VALUE 1200) var/list/cases = list( - list("restrained", "free", 1000, 1), - list("stunned", "stunned", 1000, 1), - list("downed", "downed", 1320, 0), - list("dead", "dead", 550, 0), - list("restrained", "downed", 1320, 0), - list("stunned", "dead", 550, 0), + list("restrained", 1000, 1), + list("stunned", 1000, 1), + list("downed", 1320, 0), + list("dead", 550, 0), ) for(var/list/pay_case as anything in cases) var/datum/criminal_bounty/posting = board_test_posting() var/datum/bounty_record/record = posting.record - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, pad_turf) - criminal.worst_state = pay_case[2] + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, pad_turf) var/balance_before = ship.ship_account.account_balance var/vouchers_before = board_test_vouchers(pad_turf) var/list/paid = posting.board_claim(ship, pad, criminal, pay_case[1]) - TEST_ASSERT(islist(paid), "A [pay_case[1]] criminal (worst [pay_case[2]]) was refused: [paid]") - TEST_ASSERT_EQUAL(paid[1], pay_case[3], "A [pay_case[1]] criminal (worst [pay_case[2]]) paid the wrong credits") - TEST_ASSERT_EQUAL(paid[2], pay_case[4], "A [pay_case[1]] criminal (worst [pay_case[2]]) paid the wrong vouchers") - TEST_ASSERT_EQUAL(ship.ship_account.account_balance - balance_before, pay_case[3], "The ship's account didn't get the credits") - TEST_ASSERT_EQUAL(board_test_vouchers(pad_turf) - vouchers_before, pay_case[4], "The pad didn't get the vouchers") + TEST_ASSERT(islist(paid), "A [pay_case[1]] criminal was refused: [paid]") + TEST_ASSERT_EQUAL(paid[1], pay_case[2], "A [pay_case[1]] criminal paid the wrong credits") + TEST_ASSERT_EQUAL(paid[2], pay_case[3], "A [pay_case[1]] criminal paid the wrong vouchers") + TEST_ASSERT_EQUAL(ship.ship_account.account_balance - balance_before, pay_case[2], "The ship's account didn't get the credits") + TEST_ASSERT_EQUAL(board_test_vouchers(pad_turf) - vouchers_before, pay_case[3], "The pad didn't get the vouchers") TEST_ASSERT(QDELETED(posting), "A claimed bounty stayed up") TEST_ASSERT(HAS_TRAIT(criminal, "bounty_removed"), "The claimed criminal isn't marked taken") // TRAIT_BOUNTY_REMOVED - TEST_ASSERT(HAS_TRAIT(criminal, TRAIT_GODMODE), "The criminal isn't held still for the beam") + TEST_ASSERT(HAS_TRAIT(criminal, TRAIT_IMMOBILIZED), "The criminal isn't held still for the beam") + TEST_ASSERT(!HAS_TRAIT(criminal, TRAIT_GODMODE), "The beam put the criminal in godmode") TEST_ASSERT_NULL(bounty_drop_proof(criminal), "A criminal the pad took left proof of death") TEST_ASSERT_EQUAL(record.captor_name, ship.name, "The record doesn't name its captor") if(pay_case[1] == "dead") @@ -448,7 +452,7 @@ // A second press finds it closed: one payment, one record (AR-A7) var/datum/criminal_bounty/posting = board_test_posting() - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, pad_turf) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, pad_turf) var/balance_before = ship.ship_account.account_balance TEST_ASSERT(islist(posting.board_claim(ship, pad, criminal, "restrained")), "The first press was refused") TEST_ASSERT(istext(posting.board_claim(ship, pad, criminal, "restrained")), "A second press paid again") @@ -462,7 +466,7 @@ // A free criminal pays nothing var/datum/criminal_bounty/free_posting = board_test_posting() - var/mob/living/basic/bounty_criminal/free_criminal = board_test_criminal(free_posting, pad_turf) + var/mob/living/carbon/human/npc/bounty_criminal/free_criminal = board_test_criminal(free_posting, pad_turf) TEST_ASSERT(istext(free_posting.board_claim(ship, pad, free_criminal, "free")), "A free criminal was paid for") TEST_ASSERT(free_posting.is_open(), "Refusing a free criminal closed the bounty") @@ -500,7 +504,7 @@ var/obj/machinery/mission_pad/pad = allocate(/obj/machinery/mission_pad, pad_turf) var/datum/criminal_bounty/posting = board_test_posting() var/datum/bounty_record/record = posting.record - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, pad_turf) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, pad_turf) criminal.adjustBruteLoss(round(criminal.maxHealth * 0.1)) var/hurt = clamp(1 - criminal.health / criminal.maxHealth, 0, 1) TEST_ASSERT(islist(posting.board_claim(ship, pad, criminal, "restrained")), "The live catch was refused") @@ -517,7 +521,7 @@ ship.board_test_heard.Cut() var/datum/criminal_bounty/refused_posting = board_test_posting() var/datum/bounty_record/refused_record = refused_posting.record - var/mob/living/basic/bounty_criminal/refused_criminal = board_test_criminal(refused_posting, pad_turf) + var/mob/living/carbon/human/npc/bounty_criminal/refused_criminal = board_test_criminal(refused_posting, pad_turf) TEST_ASSERT(islist(refused_posting.board_claim(ship, pad, refused_criminal, "restrained")), "The second live catch was refused") TEST_ASSERT(!ship.board_test_heard("headed for your prison at"), "The notice promised a prison that takes no bounty transfers") TEST_ASSERT(!ship.board_test_heard("transfer pool"), "The notice explains the prisoner transfers") @@ -542,7 +546,7 @@ var/obj/machinery/mission_pad/pad = allocate(/obj/machinery/mission_pad, pad_turf) var/obj/structure/overmap/ship/ship = board_test_ship() var/datum/criminal_bounty/posting = board_test_posting() - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, aside) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, aside) // Taken away on purpose (the pad, the board): no proof, even with the bounty still open (L9) ADD_TRAIT(criminal, "bounty_removed", "bounty_pad") // TRAIT_BOUNTY_REMOVED, BOUNTY_PAD_TRAIT @@ -591,7 +595,7 @@ TEST_ASSERT_EQUAL(posting.hunt(ship), TRUE, "The ship could not hunt the bounty") TEST_ASSERT_NOTNULL(ship.get_waypoint("wanted_[REF(posting)]"), "Hunting didn't chart the site on the helm") var/turf/aside = locate(run_loc_floor_bottom_left.x + 2, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z) - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, aside) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, aside) var/expires_before = posting.expires_at // Deleted, the body leaves proof (P2 asks for it); gone too, nothing is left qdel(criminal) @@ -636,11 +640,11 @@ var/turf/spot = locate(run_loc_floor_bottom_left.x + 2, run_loc_floor_bottom_left.y + 2, run_loc_floor_bottom_left.z) var/datum/criminal_bounty/posting = board_test_posting() TEST_ASSERT_EQUAL(posting.hunt(ship), TRUE, "The ship could not hunt the bounty") - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, spot) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, spot) ADD_TRAIT(criminal, "mission_field_mob", "bounty") // TRAIT_MISSION_FIELD_MOB, BOUNTY_TRAIT - var/mob/living/basic/bounty_companion/companion = new(spot) + var/mob/living/carbon/human/npc/bounty_companion/companion = new(spot) posting.companions |= WEAKREF(companion) - var/mob/living/basic/bounty_criminal/decoy/decoy = new(spot) + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = new(spot) posting.decoys |= WEAKREF(decoy) TEST_ASSERT(posting.board_move_sighting(TRUE), "The sighting marker wasn't placed") var/obj/effect/bounty_sighting/marker = posting.marker @@ -697,7 +701,7 @@ /datum/unit_test/voidcrew_bounty_board/direct_delete/Run() var/datum/criminal_bounty/posting = board_test_posting() - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, run_loc_floor_bottom_left) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, run_loc_floor_bottom_left) qdel(posting) TEST_ASSERT(QDELETED(posting), "The posting wasn't deleted") TEST_ASSERT_EQUAL(posting.status, "closed", "Deleting an open posting didn't close it") // BOUNTY_POSTING_CLOSED @@ -721,7 +725,7 @@ bag.close() TEST_ASSERT(bag.attempt_fold(folder), "An empty bag won't fold") var/datum/criminal_bounty/posting = board_test_posting() - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, spot) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, spot) criminal.forceMove(bag) TEST_ASSERT(!bag.attempt_fold(folder), "A bag folded with a living criminal inside") TEST_ASSERT(bounty_blocks_bag_fold(bag, null), "The guard doesn't see a living criminal in the bag") @@ -737,11 +741,11 @@ var/obj/item/mmi/posibrain/brain = allocate(/obj/item/mmi/posibrain, spot) TEST_ASSERT_NULL(bounty_offer_refusal(list(brain)), "An offer refused a positronic brain") var/datum/criminal_bounty/other_posting = board_test_posting() - var/mob/living/basic/bounty_criminal/bagged = board_test_criminal(other_posting, spot) + var/mob/living/carbon/human/npc/bounty_criminal/bagged = board_test_criminal(other_posting, spot) bagged.forceMove(folded) TEST_ASSERT(istext(bounty_offer_refusal(list(brain, folded))), "An offer sent a living criminal inside an item") bagged.forceMove(spot) - var/mob/living/basic/bounty_companion/companion = allocate(/mob/living/basic/bounty_companion, spot) + var/mob/living/carbon/human/npc/bounty_companion/companion = allocate(/mob/living/carbon/human/npc/bounty_companion, spot) companion.forceMove(folded) TEST_ASSERT(istext(bounty_offer_refusal(list(folded))), "An offer sent a living companion inside an item") companion.forceMove(spot) @@ -834,7 +838,7 @@ pad.set_anchored(TRUE) var/datum/criminal_bounty/posting = board_test_posting() - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, deck) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, deck) criminal.death() criminal.forceMove(deck) var/balance_before = ship.ship_account.account_balance @@ -847,9 +851,8 @@ // A live one, cuffed with its own restraints: full pay and the voucher, and the record goes on to the prisons var/datum/criminal_bounty/live_posting = board_test_posting() var/datum/bounty_record/live_record = live_posting.record - var/mob/living/basic/bounty_criminal/cuffed = board_test_criminal(live_posting, deck) - var/obj/item/restraints/handcuffs/cuffs = new(deck) - TEST_ASSERT(cuffed.body_apply_cuffs(cuffs, null), "The criminal could not be cuffed") + var/mob/living/carbon/human/npc/bounty_criminal/cuffed = board_test_criminal(live_posting, deck) + TEST_ASSERT_NOTNULL(board_test_cuff(cuffed), "The criminal could not be cuffed") cuffed.forceMove(deck) TEST_ASSERT_EQUAL(cuffed.capture_state(), "restrained", "A cuffed criminal doesn't read restrained") // BOUNTY_STATE_RESTRAINED balance_before = ship.ship_account.account_balance @@ -865,26 +868,27 @@ // ===== SITES UNLOADING ===== -/// The worst state a criminal reached outlives its site unloading: it never pays more on the next body, and a dead one comes back as proof (12.1, M2) +/// A criminal's site unloading: a living one comes back on the next load and pays by its condition then; a dead one comes back as proof (M2) /datum/unit_test/voidcrew_bounty_board/worst_state /datum/unit_test/voidcrew_bounty_board/worst_state/Run() var/turf/spot = locate(run_loc_floor_bottom_left.x + 2, run_loc_floor_bottom_left.y + 2, run_loc_floor_bottom_left.z) var/datum/criminal_bounty/posting = board_test_posting() - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, spot) - criminal.worst_state = "downed" // BOUNTY_STATE_DOWNED + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, spot) + criminal.adjustBruteLoss(110) + TEST_ASSERT(criminal.is_downed(), "The criminal didn't go into crit") posting.board_site_took_criminal(criminal) TEST_ASSERT(QDELETED(criminal), "The unloading site didn't take the criminal") TEST_ASSERT(posting.is_open() && !posting.board_relisting, "The bounty didn't stay up for the next load") - TEST_ASSERT_EQUAL(posting.board_worst_state, "downed", "The bounty forgot its criminal was downed") + TEST_ASSERT_EQUAL(posting.board_worst_state, "free", "A criminal alive when its site unloaded counts as dead") // BOUNTY_STATE_FREE TEST_ASSERT_NULL(posting.board_proof(), "A criminal taken by its site left proof of death") - // The next load: the same record, still paying no more than the downed share - var/mob/living/basic/bounty_criminal/again = posting.board_place_at(spot, "explore", null, null) // BOUNTY_ACTIVITY_EXPLORE + // The next load: the same record, paid by its condition on the pad (no ratchet) + var/mob/living/carbon/human/npc/bounty_criminal/again = posting.board_place_at(spot, "explore", null, null) // BOUNTY_ACTIVITY_EXPLORE TEST_ASSERT(istype(again), "The criminal didn't come back") TEST_ASSERT_EQUAL(again.record, posting.record, "Someone else came back") - TEST_ASSERT_EQUAL(again.worst_state, "downed", "The criminal came back with a clean slate") - TEST_ASSERT_EQUAL(posting.board_share_for("restrained", again.worst_state), 60, "A criminal downed before the unload pays in full") // BOUNTY_SHARE_DOWNED + TEST_ASSERT(!again.is_downed(), "The criminal came back still in crit") + TEST_ASSERT_EQUAL(posting.board_share_for("restrained"), 100, "A cuffed criminal doesn't pay the full share") // BOUNTY_SHARE_RESTRAINED // Dead when its site unloaded: it comes back as its proof of death, never alive again.death() @@ -922,7 +926,7 @@ var/datum/criminal_bounty/posting = board_test_posting() var/turf/brink = locate(pit.x - 1, pit.y, pit.z) - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, brink) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, brink) criminal.forceMove(pit) sleep(3 SECONDS) TEST_ASSERT(QDELETED(criminal) || istype(criminal.loc, /obj/effect/abstract/chasm_storage), "The criminal didn't fall into the chasm") @@ -952,7 +956,7 @@ /datum/unit_test/voidcrew_bounty_board/unreachable/Run() var/datum/criminal_bounty/posting = board_test_posting() - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, run_loc_floor_bottom_left) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, run_loc_floor_bottom_left) TEST_ASSERT(!posting.board_check_unreachable(), "A criminal standing on the floor counts as unreachable") criminal.moveToNullspace() TEST_ASSERT(posting.board_check_unreachable(), "A criminal in nullspace still holds the bounty") @@ -1161,7 +1165,7 @@ var/obj/machinery/mission_pad/pad = allocate(/obj/machinery/mission_pad, pad_turf) var/datum/criminal_bounty/posting = board_test_posting(3) TEST_ASSERT_EQUAL(posting.hunt(hunter), TRUE, "The hunter could not hunt the bounty") - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, pad_turf) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, pad_turf) var/wanted_name = posting.record.name listener.board_test_heard.Cut() hunter.board_test_heard.Cut() @@ -1183,7 +1187,7 @@ var/obj/structure/overmap/ship/ship = board_test_ship() var/datum/criminal_bounty/posting = board_test_posting() var/wanted_name = posting.record.name - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, pad_turf) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, pad_turf) TEST_ASSERT(islist(posting.board_claim(ship, pad, criminal, "restrained")), "The clean catch was refused") var/obj/item/paper/bounty_captured/poster = locate() in pad_turf TEST_ASSERT_NOTNULL(poster, "A clean Wanted catch left no poster") @@ -1197,10 +1201,9 @@ qdel(poster) var/datum/criminal_bounty/downed_posting = board_test_posting() - var/mob/living/basic/bounty_criminal/downed_criminal = board_test_criminal(downed_posting, pad_turf) - downed_criminal.worst_state = "downed" // BOUNTY_STATE_DOWNED - TEST_ASSERT(islist(downed_posting.board_claim(ship, pad, downed_criminal, "restrained")), "The downed catch was refused") - TEST_ASSERT_NULL(locate(/obj/item/paper/bounty_captured) in pad_turf, "A catch downed earlier left a poster") + var/mob/living/carbon/human/npc/bounty_criminal/downed_criminal = board_test_criminal(downed_posting, pad_turf) + TEST_ASSERT(islist(downed_posting.board_claim(ship, pad, downed_criminal, "downed")), "The downed catch was refused") + TEST_ASSERT_NULL(locate(/obj/item/paper/bounty_captured) in pad_turf, "A catch in crit left a poster") for(var/datum/bounty_record/pooled as anything in GLOB.bounty_prisoner_pool.Copy()) bounty_pool_remove(pooled) @@ -1214,7 +1217,7 @@ var/obj/machinery/mission_pad/pad = allocate(/obj/machinery/mission_pad, pad_turf) var/datum/criminal_bounty/posting = board_test_posting() var/wanted_name = posting.record.name - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, aside) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, aside) // Nothing there: the card says so var/list/preview = posting.board_pad_preview(ship, pad) @@ -1230,11 +1233,9 @@ var/line = pad.bounty_pad_line(criminal) TEST_ASSERT_EQUAL(line, "On the pad: [wanted_name], standing free.", "The pad's line for a free criminal") - // Cuffed, but downed earlier: the pad names the state and why Turn In won't work (the bare pad isn't aboard), never the pay - var/obj/item/restraints/handcuffs/cuffs = new(pad_turf) - TEST_ASSERT(criminal.body_apply_cuffs(cuffs, null), "The criminal could not be cuffed") + // Cuffed: the pad names the state and why Turn In won't work (the bare pad isn't aboard), never the pay + TEST_ASSERT_NOTNULL(board_test_cuff(criminal), "The criminal could not be cuffed") criminal.forceMove(pad_turf) - criminal.worst_state = "downed" // BOUNTY_STATE_DOWNED preview = posting.board_pad_preview(ship, pad) TEST_ASSERT_EQUAL(preview[3], "the pad must be aboard your ship", "The card doesn't say the pad isn't aboard") line = pad.bounty_pad_line(criminal) @@ -1243,9 +1244,17 @@ TEST_ASSERT(!findtext(line, "1320") && !findtextEx(line, " cr") && !findtextEx(line, "pays"), "The pad says what the catch pays: [line]") var/list/entry = posting.board_ui_entry(ship, pad) TEST_ASSERT_EQUAL(entry["turn_in_refusal"], "the pad must be aboard your ship", "The card entry doesn't carry the refusal") - // The pay itself is unchanged: downed earlier, the downed share (60% of 1000 + 1200) - var/list/downed_pay = posting.board_payout(posting.board_share_for(criminal.capture_state(), criminal.worst_state)) - TEST_ASSERT_EQUAL(downed_pay[1], 1320, "A catch downed earlier doesn't pay the downed share") + // The pay is the condition on the pad: cuffed, the full share; in crit, the downed share (60% of 1000 + 1200) + var/list/cuffed_pay = posting.board_payout(posting.board_share_for(criminal.capture_state())) + TEST_ASSERT_EQUAL(cuffed_pay[1], 1000, "A cuffed catch doesn't pay the full share") + TEST_ASSERT_EQUAL(cuffed_pay[2], 1, "A cuffed catch doesn't pay its voucher") + criminal.adjustBruteLoss(110) + TEST_ASSERT_EQUAL(criminal.capture_state(), "downed", "A cuffed criminal in crit doesn't read downed") + var/list/downed_pay = posting.board_payout(posting.board_share_for(criminal.capture_state())) + TEST_ASSERT_EQUAL(downed_pay[1], 1320, "A catch in crit doesn't pay the downed share") + // Patched up out of crit and still cuffed: the full share again (owner ruling 2026-11-08) + criminal.adjustBruteLoss(-110) + TEST_ASSERT_EQUAL(posting.board_share_for(criminal.capture_state()), 100, "A criminal patched up after a fall doesn't pay in full") // Once per few seconds, however often they're dragged on and off (dragging them on above already spoke) COOLDOWN_RESET(pad, bounty_announce_cooldown) @@ -1254,9 +1263,9 @@ // Another crew's offer, or a decoy: nothing var/datum/criminal_bounty/offer = board_test_posting(1, board_test_ship()) - var/mob/living/basic/bounty_criminal/offered = board_test_criminal(offer, pad_turf) + var/mob/living/carbon/human/npc/bounty_criminal/offered = board_test_criminal(offer, pad_turf) TEST_ASSERT_NULL(pad.bounty_pad_line(offered), "The pad spoke about another crew's offer") - var/mob/living/basic/bounty_criminal/decoy/decoy = allocate(/mob/living/basic/bounty_criminal/decoy, pad_turf) + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = allocate(/mob/living/carbon/human/npc/bounty_criminal/decoy, pad_turf) TEST_ASSERT_NULL(pad.bounty_pad_line(decoy), "The pad spoke about a decoy") // Its proof of death: the pad names it, and it pays the dead share (25% of 1000 + 1200) @@ -1268,7 +1277,7 @@ TEST_ASSERT(findtext(line, "Evidence tag for [wanted_name]"), "The pad's line for the proof: [line]") preview = posting.board_pad_preview(ship, pad) TEST_ASSERT_EQUAL(preview[2], "proof", "The card doesn't see the proof on the pad") - var/list/dead_pay = posting.board_payout(posting.board_share_for("dead", "dead")) // BOUNTY_STATE_DEAD + var/list/dead_pay = posting.board_payout(posting.board_share_for("dead")) // BOUNTY_STATE_DEAD TEST_ASSERT_EQUAL(dead_pay[1], 550, "The proof doesn't pay the dead share") // The body the proof stands for is no longer the bounty's, so closing it won't take the body away qdel(criminal) @@ -1281,7 +1290,7 @@ var/turf/spot = locate(run_loc_floor_bottom_left.x + 2, run_loc_floor_bottom_left.y + 2, run_loc_floor_bottom_left.z) var/datum/criminal_bounty/posting = board_test_posting() TEST_ASSERT_EQUAL(posting.hunt(ship), TRUE, "The ship could not hunt the bounty") - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, spot) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, spot) TEST_ASSERT_NOTNULL(criminal, "No criminal to track") var/mob/living/carbon/human/consistent/holder = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) var/obj/item/gps/gps = allocate(/obj/item/gps, run_loc_floor_bottom_left) diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm index d8c7284bb9a..47b86330a9b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm @@ -4,6 +4,11 @@ * Owner: P4 bosses. Do not edit this file outside that package. Fork defines are included after the * tests, so a test uses the literal value with a comment naming the define. Every test calls the * boss's procs directly with the mob already in place; none waits on a timer or an AI walk. + * + * The bosses are real humans (phase D of the human NPC migration): these tests check that they wear + * and carry real gear, that their fire, blasts and knife reach people through tg's own paths whatever + * state those people are in, that their wind-ups stop for tg's states, and that their AI reads real + * state (worn out, the stimulant, the posse's reinforcements). */ /// Shared helpers; not a test itself @@ -49,6 +54,11 @@ hunter.mind_initialize() return hunter +/// Puts `person` into crit with real damage (soft crit: health 100 - 120 = -20) +/datum/unit_test/voidcrew_bounty_boss/proc/put_in_crit(mob/living/person) + person.adjustBruteLoss(120) + return person.stat != CONSCIOUS && person.stat != DEAD + /// A Ripley at `where` with `pilot` in it /datum/unit_test/voidcrew_bounty_boss/proc/mech_with(turf/where, mob/living/pilot) var/obj/vehicle/sealed/mecha/ripley/mech = allocate(/obj/vehicle/sealed/mecha/ripley, where) @@ -57,20 +67,25 @@ return mech /// `hunter` hits `boss`, as far as its posse is concerned: onto its grudge list, then into the posse -/datum/unit_test/voidcrew_bounty_boss/proc/posse_member(mob/living/basic/bounty_criminal/boss/boss, mob/living/hunter) +/datum/unit_test/voidcrew_bounty_boss/proc/posse_member(mob/living/carbon/human/npc/bounty_criminal/boss/boss, mob/living/hunter) boss.body_add_grudge(hunter) return boss.boss_engage(hunter) /// The ability of `ability_type` that `boss` has -/datum/unit_test/voidcrew_bounty_boss/proc/ability_of(mob/living/basic/bounty_criminal/boss/boss, ability_type) +/datum/unit_test/voidcrew_bounty_boss/proc/ability_of(mob/living/carbon/human/npc/bounty_criminal/boss/boss, ability_type) return locate(ability_type) in boss.boss_abilities /// Ready for another ability straight away: no gap, no cooldowns -/datum/unit_test/voidcrew_bounty_boss/proc/ready(mob/living/basic/bounty_criminal/boss/boss) +/datum/unit_test/voidcrew_bounty_boss/proc/ready(mob/living/carbon/human/npc/bounty_criminal/boss/boss) boss.boss_next_ability_at = 0 for(var/datum/action/cooldown/ability as anything in boss.boss_abilities) ability.next_use_time = 0 +/// Ready to click again: no click or attack cooldown left +/datum/unit_test/voidcrew_bounty_boss/proc/ready_hands(mob/living/carbon/human/npc/bounty_criminal/boss/boss) + boss.next_move = 0 + boss.next_click = 0 + /// Telegraph marks on the tiles around `center` /datum/unit_test/voidcrew_bounty_boss/proc/marks_near(atom/center, radius = 1) . = 0 @@ -85,50 +100,97 @@ return TRUE return FALSE -// ===== EACH KIT SPAWNS AND GOES CLEANLY ===== +/// How many of `item_type` `holder` carries anywhere on it +/datum/unit_test/voidcrew_bounty_boss/proc/carried(mob/living/holder, item_type) + return length(holder.get_all_contents_type(item_type)) + +// ===== EACH KIT IS A REAL HUMAN IN REAL GEAR, AND GOES CLEANLY ===== /datum/unit_test/voidcrew_bounty_boss/kits /datum/unit_test/voidcrew_bounty_boss/kits/Run() var/list/kits = list( - /mob/living/basic/bounty_criminal/boss/juggernaut = "juggernaut", // BOUNTY_KIT_JUGGERNAUT - /mob/living/basic/bounty_criminal/boss/pyromaniac = "pyromaniac", // BOUNTY_KIT_PYROMANIAC - /mob/living/basic/bounty_criminal/boss/demolitionist = "demolitionist", // BOUNTY_KIT_DEMOLITIONIST - /mob/living/basic/bounty_criminal/boss/ghost = "ghost", // BOUNTY_KIT_GHOST - /mob/living/basic/bounty_criminal/boss/heavy = "heavy", // BOUNTY_KIT_HEAVY + /mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut = "juggernaut", // BOUNTY_KIT_JUGGERNAUT + /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac = "pyromaniac", // BOUNTY_KIT_PYROMANIAC + /mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist = "demolitionist", // BOUNTY_KIT_DEMOLITIONIST + /mob/living/carbon/human/npc/bounty_criminal/boss/ghost = "ghost", // BOUNTY_KIT_GHOST + /mob/living/carbon/human/npc/bounty_criminal/boss/heavy = "heavy", // BOUNTY_KIT_HEAVY ) for(var/kit_type in kits) - TEST_ASSERT(!ispath(kit_type, /mob/living/basic/boss), "[kit_type] is a megafauna boss, which outposts ban") - var/mob/living/basic/bounty_criminal/boss/boss = allocate(kit_type) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = allocate(kit_type) + TEST_ASSERT(ishuman(boss), "[kit_type] isn't a real human") TEST_ASSERT_EQUAL(boss.boss_kit, kits[kit_type], "[kit_type] has the wrong kit") TEST_ASSERT_EQUAL(length(boss.boss_abilities), 2, "[kit_type] doesn't have its two abilities") TEST_ASSERT(istype(boss.ai_controller, /datum/ai_controller/basic_controller/bounty_boss), "[kit_type] doesn't have the boss AI") - var/list/table = boss.boss_health_table() - TEST_ASSERT_EQUAL(length(table), 4, "[kit_type] has no health for 1, 2, 3 and 4+ hunters") - TEST_ASSERT_EQUAL(boss.maxHealth, table[1], "[kit_type] doesn't start at its one-hunter health") - for(var/i in 2 to 4) - TEST_ASSERT(table[i] > table[i - 1], "[kit_type]'s health doesn't grow with the posse") - TEST_ASSERT(!(boss.status_flags & CANSTUN), "A fresh [kit_type] can be stunned") - TEST_ASSERT(boss.mob_size <= MOB_SIZE_LARGE, "[kit_type] is bigger than large") - TEST_ASSERT(ispath(boss.boss_kit_outfit(), /datum/outfit/bounty_boss), "[kit_type] has no boss outfit") - TEST_ASSERT(boss.boss_kit_outfit() != /datum/outfit/bounty_boss, "[kit_type] wears the plain boss outfit") + TEST_ASSERT_EQUAL(boss.npc_rank, 2, "[kit_type] isn't ranked a boss") // HUMAN_NPC_RANK_BOSS + // Toughness is armour and physiology, never health (defaults 1.5). + TEST_ASSERT_EQUAL(boss.maxHealth, 100, "[kit_type] has a health pool of [boss.maxHealth]") // MAX_LIVING_HEALTH + TEST_ASSERT(abs(boss.physiology.brute_mod - 0.6) < 0.01, "[kit_type] takes [boss.physiology.brute_mod] brute, not 0.6") // BOUNTY_BOSS_DAMAGE_TAKEN + TEST_ASSERT(abs(boss.physiology.burn_mod - 0.6) < 0.01, "[kit_type] takes [boss.physiology.burn_mod] burn, not 0.6") + TEST_ASSERT(boss.status_flags & CANSTUN, "[kit_type] can't be stunned") + TEST_ASSERT(boss.status_flags & CANKNOCKDOWN, "[kit_type] can't be knocked down") + // Its stimulants and a bruise pack, carried for real. + TEST_ASSERT_EQUAL(carried(boss, /obj/item/reagent_containers/hypospray/medipen/stimpack), 2, "[kit_type] doesn't carry its two stimulants") // BOUNTY_BOSS_STIMULANTS + TEST_ASSERT(carried(boss, /obj/item/stack/medical/bruise_pack) >= 1, "[kit_type] carries nothing to patch itself up with") + TEST_ASSERT(boss.boss_gear_text(), "[kit_type] has no gear line on examine") var/list/abilities = boss.boss_abilities.Copy() qdel(boss) TEST_ASSERT(QDELETED(boss), "[kit_type] didn't delete") for(var/datum/ability as anything in abilities) TEST_ASSERT(QDELETED(ability), "[kit_type]'s [ability.type] outlived it") - // Fireproof, not laser-proof. - var/mob/living/basic/bounty_criminal/boss/pyromaniac/pyro = allocate(/mob/living/basic/bounty_criminal/boss/pyromaniac) - TEST_ASSERT(HAS_TRAIT(pyro, TRAIT_NOFIRE), "The Pyromaniac can be set alight") - TEST_ASSERT(pyro.damage_coeff[BURN] >= 0.9, "The Pyromaniac shrugs off lasers") // BOUNTY_PYROMANIAC_BURN_MOD - -// ===== WIND-UP BEFORE DAMAGE; RESTRAINED OR DOWNED, NOTHING ===== + // The Juggernaut: riot armour, and a real gene for its size (CB-5, CB-21). + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/jugg = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut) + TEST_ASSERT(istype(jugg.wear_suit, /obj/item/clothing/suit/armor/riot), "The Juggernaut isn't wearing riot armour") + TEST_ASSERT(istype(jugg.head, /obj/item/clothing/head/helmet/toggleable/riot), "The Juggernaut isn't wearing a riot helmet") + TEST_ASSERT(HAS_TRAIT(jugg, TRAIT_GIANT), "The Juggernaut isn't a giant") + TEST_ASSERT_NOTNULL(jugg.dna.get_mutation(/datum/mutation/gigantism), "The Juggernaut's size isn't the gigantism gene") + + // The Pyromaniac: its fire suit and helmet keep fire off it, not a trait (CB-7). Its torch is a real one, lit. + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/pyro = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac) + TEST_ASSERT(!HAS_TRAIT(pyro, TRAIT_NOFIRE), "The Pyromaniac is fireproof by a trait") + TEST_ASSERT(!HAS_TRAIT(pyro, TRAIT_RESISTHEAT), "The Pyromaniac is heat-proof by a trait") + TEST_ASSERT(istype(pyro.wear_suit, /obj/item/clothing/suit/utility/fire/firefighter), "The Pyromaniac isn't wearing a firefighter suit") + TEST_ASSERT(istype(pyro.head, /obj/item/clothing/head/utility/hardhat/red), "The Pyromaniac isn't wearing a firefighter helmet") + TEST_ASSERT(pyro.get_thermal_protection() >= FIRE_SUIT_MAX_TEMP_PROTECT, "The Pyromaniac's gear doesn't keep the fire off it") + var/obj/item/flashlight/flare/torch/torch = locate() in pyro.held_items + TEST_ASSERT_NOTNULL(torch, "The Pyromaniac holds no torch") + TEST_ASSERT(torch.light_on && torch.damtype == BURN, "The Pyromaniac's torch isn't lit") + + // The Demolitionist: a hard hat, and its crowbar in its bag until the fight starts. + var/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/demo = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist) + TEST_ASSERT(istype(demo.head, /obj/item/clothing/head/utility/hardhat), "The Demolitionist isn't wearing a hard hat") + TEST_ASSERT(istype(demo.belt, /obj/item/storage/belt/bandolier), "The Demolitionist has no bandolier") + var/obj/item/crowbar/large/crowbar = locate() in demo.get_all_contents_type(/obj/item/crowbar/large) + TEST_ASSERT_NOTNULL(crowbar, "The Demolitionist carries no crowbar") + TEST_ASSERT(!demo.is_holding(crowbar), "The Demolitionist has its crowbar out before any fight") + + // The Ghost: its hood up, its knife in a pocket, and real chrome for its quickness (OC-6 ruling). + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/ghost = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/ghost) + TEST_ASSERT(istype(ghost.head, /obj/item/clothing/head/hooded/chaplain_hood), "The Ghost's hood isn't up") + var/obj/item/knife/combat/knife = locate() in ghost.get_all_contents_type(/obj/item/knife/combat) + TEST_ASSERT_NOTNULL(knife, "The Ghost carries no combat knife") + TEST_ASSERT(!ghost.is_holding(knife), "The Ghost has its knife out before any fight") + TEST_ASSERT_NOTNULL(locate(/obj/item/organ/cyberimp/cyberware/slipwire) in ghost.organs, "The Ghost has no Slipwire") + TEST_ASSERT_NOTNULL(locate(/obj/item/organ/cyberimp/cyberware/cascade) in ghost.organs, "The Ghost has no Cascade lattice") + + // The Heavy: SWAT armour, a real L6 with a belt in it, a spare box and two barrier grenades (CB-13, CB-26). + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/heavy) + TEST_ASSERT(istype(heavy.wear_suit, /obj/item/clothing/suit/armor/swat), "The Heavy isn't wearing SWAT armour") + TEST_ASSERT(istype(heavy.head, /obj/item/clothing/head/helmet/swat), "The Heavy isn't wearing a SWAT helmet") + var/obj/item/gun/ballistic/automatic/l6_saw/gun = heavy.boss_gun() + TEST_ASSERT_NOTNULL(gun, "The Heavy holds no belt-fed gun") + TEST_ASSERT(gun.can_shoot(), "The Heavy's gun isn't loaded") + TEST_ASSERT(!heavy.get_inactive_held_item(), "The Heavy's other hand isn't free to fire") + TEST_ASSERT_EQUAL(carried(heavy, /obj/item/ammo_box/magazine/m7mm), 2, "The Heavy has no spare box beside the one in its gun") + TEST_ASSERT_EQUAL(carried(heavy, /obj/item/grenade/barrier/bounty_boss), 2, "The Heavy doesn't carry its two barrier grenades") // BOUNTY_HEAVY_BARRIERS + +// ===== WIND-UP BEFORE DAMAGE; TG'S STATES STOP IT (CB-24) ===== /datum/unit_test/voidcrew_bounty_boss/windup /datum/unit_test/voidcrew_bounty_boss/windup/Run() - var/mob/living/basic/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 0)) var/mob/living/carbon/human/consistent/victim = hunter_at(spot(1, 0)) var/datum/action/cooldown/mob_cooldown/bounty_boss/slam/slam = ability_of(boss, /datum/action/cooldown/mob_cooldown/bounty_boss/slam) TEST_ASSERT_NOTNULL(slam, "The Juggernaut has no slam") @@ -147,106 +209,54 @@ var/mob/living/carbon/human/consistent/second = hunter_at(spot(0, 1)) - // Restrained: it can't start one. + // Restrained (tg's TRAIT_RESTRAINED): it can't start one. ready(boss) - boss.restraints = new /obj/item/restraints/handcuffs(boss) + ADD_TRAIT(boss, TRAIT_RESTRAINED, "bounty_boss_test") TEST_ASSERT(!boss.boss_can_act(), "A cuffed boss can act") TEST_ASSERT(!slam.IsAvailable(), "A cuffed boss's ability is available") TEST_ASSERT(!slam.Trigger(target = second), "A cuffed boss started an ability") TEST_ASSERT_NULL(boss.boss_busy, "A cuffed boss began a wind-up") - qdel(boss.restraints) - boss.restraints = null + REMOVE_TRAIT(boss, TRAIT_RESTRAINED, "bounty_boss_test") - // Downed: it can't start one. - boss.downed = TRUE - TEST_ASSERT(!slam.IsAvailable(), "A downed boss's ability is available") - TEST_ASSERT(!slam.Trigger(target = second), "A downed boss started an ability") - boss.downed = FALSE + // Knocked down (tg's Knockdown): it can't start one either. + boss.Knockdown(5 SECONDS) + TEST_ASSERT(!slam.IsAvailable(), "A knocked-down boss's ability is available") + boss.SetKnockdown(0) // Cuffed during the wind-up: the telegraph goes and nothing lands. ready(boss) TEST_ASSERT(slam.Trigger(target = second), "The slam didn't start again") var/serial = slam.pending_serial - SEND_SIGNAL(boss, "bounty_criminal_restrained", null) // COMSIG_BOUNTY_CRIMINAL_RESTRAINED + ADD_TRAIT(boss, TRAIT_RESTRAINED, "bounty_boss_test") TEST_ASSERT(!length(slam.telegraphs), "The telegraph stayed after the boss was cuffed") TEST_ASSERT_NULL(boss.boss_busy, "The boss kept winding up after it was cuffed") TEST_ASSERT(!slam.finish_windup(serial), "A cancelled wind-up still landed") TEST_ASSERT_EQUAL(second.getBruteLoss(), 0, "A cancelled slam hurt someone") + REMOVE_TRAIT(boss, TRAIT_RESTRAINED, "bounty_boss_test") - // Downed during the wind-up, with no signal: the check when it ends still stops it. + // Stunned during the wind-up (tg's Stun): the same. ready(boss) TEST_ASSERT(slam.Trigger(target = second), "The slam didn't start a third time") - boss.downed = TRUE - TEST_ASSERT(!slam.finish_windup(slam.pending_serial), "A downed boss's wind-up landed") - TEST_ASSERT_EQUAL(second.getBruteLoss(), 0, "A downed boss's slam hurt someone") - boss.downed = FALSE - -// ===== TIRED BELOW 40%; STAMINA ONLY THEN (H3) ===== - -/datum/unit_test/voidcrew_bounty_boss/tired - -/datum/unit_test/voidcrew_bounty_boss/tired/Run() - var/mob/living/basic/bounty_criminal/boss/heavy/heavy = allocate(/mob/living/basic/bounty_criminal/boss/heavy, spot(0, 0)) - TEST_ASSERT(!heavy.boss_tired, "A fresh boss is tired") - TEST_ASSERT(!(heavy.status_flags & (CANSTUN | CANKNOCKDOWN)), "A fresh boss can be stunned or knocked down") - TEST_ASSERT_EQUAL(heavy.damage_coeff[STAMINA], 0, "A fresh boss takes stamina damage") // BOUNTY_HEAVY_STAMINA_FRESH - TEST_ASSERT_NULL(heavy.body_last_alert, "A fresh boss showed [heavy.body_last_alert]") - var/fresh_speed = heavy.speed - - // Down to 38%, under BOUNTY_BOSS_TIRED_BELOW (40) - heavy.adjustBruteLoss(heavy.maxHealth * 0.62, forced = TRUE) - TEST_ASSERT(heavy.boss_tired, "A boss at [heavy.health]/[heavy.maxHealth] isn't tired") - // The stun window opening shows over it, not only in chat (P4 polish) - TEST_ASSERT_EQUAL(heavy.body_last_alert, "worn out", "Tiring showed [heavy.body_last_alert || "nothing"] over the boss") - TEST_ASSERT(heavy.status_flags & CANSTUN, "A tired boss can't be stunned") - TEST_ASSERT(heavy.status_flags & CANKNOCKDOWN, "A tired boss can't be knocked down") - // BOUNTY_BOSS_STAMINA_TIRED 1 against BOUNTY_HEAVY_STAMINA 180, as max_stamina is 100 - TEST_ASSERT(abs(heavy.damage_coeff[STAMINA] - 100 / 180) < 0.001, "A tired Heavy's stamina coefficient is [heavy.damage_coeff[STAMINA]]") - TEST_ASSERT(abs(heavy.speed - (fresh_speed + 0.3)) < 0.001, "A tired boss isn't slower") // BOUNTY_BOSS_TIRED_SLOWDOWN - TEST_ASSERT_EQUAL(heavy.boss_cooldown_mult(), 1.5, "A tired boss's cooldowns don't stretch") // BOUNTY_BOSS_TIRED_COOLDOWN_MULT - heavy.adjustStaminaLoss(200) - TEST_ASSERT(heavy.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "Stamina weapons don't put a tired boss down") - TEST_ASSERT(!heavy.boss_can_act(), "A stamina-crit boss can still act") - - // Healed back over the line, it is fresh again. - var/mob/living/basic/bounty_criminal/boss/juggernaut/healed = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(4, 0)) - healed.adjustBruteLoss(healed.maxHealth * 0.7, forced = TRUE) - TEST_ASSERT(healed.boss_tired, "The boss didn't tire") - healed.adjustBruteLoss(-healed.maxHealth, forced = TRUE) - TEST_ASSERT(!healed.boss_tired, "A healed boss is still tired") - TEST_ASSERT(!(healed.status_flags & CANSTUN), "A healed boss can still be stunned") - -/// Steady disabler fire on a fresh boss neither slows it nor builds up; once tired, it takes the whole pool (review H3) -/datum/unit_test/voidcrew_bounty_boss/stamina + serial = slam.pending_serial + boss.Stun(5 SECONDS) + TEST_ASSERT_NULL(boss.boss_busy, "The boss kept winding up after it was stunned") + TEST_ASSERT(!slam.finish_windup(serial), "A stunned boss's wind-up landed") + boss.SetStun(0) -/datum/unit_test/voidcrew_bounty_boss/stamina/Run() - var/mob/living/basic/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 0)) - var/fresh_speed = boss.speed - for(var/i in 1 to 25) - boss.adjustStaminaLoss(30) // a disabler shot - TEST_ASSERT_EQUAL(boss.staminaloss, 0, "Disabler fire built up on a fresh boss") - TEST_ASSERT_EQUAL(boss.speed, fresh_speed, "Disabler fire slowed a fresh boss") - TEST_ASSERT(!boss.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "Disabler fire put a fresh boss down") - - // Whatever it had built up is wiped when it tires. - boss.adjustStaminaLoss(80, forced = TRUE) - boss.adjustBruteLoss(boss.maxHealth * 0.65, forced = TRUE) - TEST_ASSERT(boss.boss_tired, "The boss didn't tire") - TEST_ASSERT_EQUAL(boss.staminaloss, 0, "The boss tired with stamina already built up") - - // Tired: four disabler shots don't do it (BOUNTY_BOSS_STAMINA 150), the fifth does. - for(var/i in 1 to 4) - boss.adjustStaminaLoss(30) - TEST_ASSERT(!boss.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "Four disabler shots put a tired boss down") - boss.adjustStaminaLoss(30) - TEST_ASSERT(boss.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "Five disabler shots didn't put a tired boss down") + // Into crit during the wind-up, by real damage: the same. + ready(boss) + TEST_ASSERT(slam.Trigger(target = second), "The slam didn't start a fourth time") + serial = slam.pending_serial + TEST_ASSERT(put_in_crit(boss), "Test fixture: the boss isn't in crit") + TEST_ASSERT(!slam.finish_windup(serial), "A downed boss's wind-up landed") + TEST_ASSERT_EQUAL(second.getBruteLoss(), 0, "A downed boss's slam hurt someone") // ===== WHAT IT MAY BREAK, AND HOW (H1, M1, L2) ===== /datum/unit_test/voidcrew_bounty_boss/structures /datum/unit_test/voidcrew_bounty_boss/structures/Run() - var/mob/living/basic/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 0)) var/turf/middle = spot(2, 2) change_turf(middle, /turf/closed/wall) TEST_ASSERT(boss.boss_may_break(middle), "The boss may not break a plain interior wall") @@ -326,13 +336,14 @@ /datum/unit_test/voidcrew_bounty_boss/near_ships /datum/unit_test/voidcrew_bounty_boss/near_ships/Run() - var/mob/living/basic/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 0)) var/turf/middle = spot(2, 2) change_turf(middle, /turf/closed/wall) var/mob/living/carbon/human/consistent/hunter = hunter_at(spot(4, 4)) TEST_ASSERT(boss.boss_may_break(middle), "The boss may not break an interior wall before any ship is near") TEST_ASSERT(boss.boss_landing_ok(get_turf(hunter)), "The boss may not aim at a hunter before any ship is near") - TEST_ASSERT(boss.boss_can_hurt(hunter), "The boss may not hurt a hunter before any ship is near") + TEST_ASSERT(boss.boss_valid_target(hunter), "The boss won't go after a hunter before any ship is near") + TEST_ASSERT(boss.boss_effect_hits(hunter), "The boss's fire and blasts don't reach a hunter before any ship is near") // A ship's tile in the room, within BOUNTY_ENV_SAFE_RANGE (7) of everything here. var/turf/ship_floor = make_ship_tile(spot(4, 0)) @@ -343,7 +354,8 @@ // Standing inside the ship, a hunter is out of its reach altogether. hunter.forceMove(ship_floor) - TEST_ASSERT(!boss.boss_can_hurt(hunter), "The boss may hurt a hunter inside their ship") + TEST_ASSERT(!boss.boss_valid_target(hunter), "The boss goes after a hunter inside their ship") + TEST_ASSERT(!boss.boss_effect_hits(hunter), "The boss's fire and blasts reach inside a ship") TEST_ASSERT(!boss.boss_reach_ok(ship_floor), "The boss's fire and blasts may reach inside a ship") // ===== NEVER THE SAME ABILITY TWICE IN A ROW WHILE ANOTHER IS READY ===== @@ -352,7 +364,7 @@ /datum/unit_test/voidcrew_bounty_boss/rotation/Run() // Two abilities both worth using: they alternate. - var/mob/living/basic/bounty_criminal/boss/pyromaniac/pyro = allocate(/mob/living/basic/bounty_criminal/boss/pyromaniac, spot(0, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/pyro = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac, spot(0, 0)) var/mob/living/carbon/human/consistent/near = hunter_at(spot(2, 0)) var/last_type for(var/i in 1 to 20) @@ -385,7 +397,7 @@ TEST_ASSERT(other && other.type == molotov.type, "The flamer repeated while the molotov was off cooldown") // Only one ability worth using: it repeats; strictly, nothing (the hoarfrost rotation). - var/mob/living/basic/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 3)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 3)) var/mob/living/carbon/human/consistent/far = hunter_at(spot(3, 3)) var/datum/action/cooldown/mob_cooldown/bounty_boss/first = boss.boss_choose_ability(far) TEST_ASSERT(istype(first, /datum/action/cooldown/mob_cooldown/bounty_boss/charge), "The Juggernaut didn't choose to charge someone three tiles off") @@ -394,73 +406,49 @@ boss.boss_strict_rotation = TRUE TEST_ASSERT_NULL(boss.boss_choose_ability(far), "The strict rotation chose the same ability twice in a row") -// ===== SUMMONED THINGS CLEAN UP; CAPS HOLD ===== - -/datum/unit_test/voidcrew_bounty_boss/cleanup - -/datum/unit_test/voidcrew_bounty_boss/cleanup/Run() - // Telegraphs go with a cancelled wind-up. - var/mob/living/basic/bounty_criminal/boss/juggernaut/jugg = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(2, 2)) - var/mob/living/carbon/human/consistent/beside = hunter_at(spot(3, 2)) - var/datum/action/cooldown/mob_cooldown/bounty_boss/slam/slam = ability_of(jugg, /datum/action/cooldown/mob_cooldown/bounty_boss/slam) - TEST_ASSERT(slam.Trigger(target = beside), "The slam didn't start") - TEST_ASSERT(marks_near(jugg), "The slam left no marks") - jugg.boss_interrupt() - TEST_ASSERT_EQUAL(marks_near(jugg), 0, "The slam's marks outlived the interrupted wind-up") - qdel(jugg) - qdel(beside) - - // Barricades: one from the ability, folding away on its own; a global cap. - TEST_ASSERT_EQUAL(length(GLOB.bounty_boss_barricades), 0, "Barricades were standing before the test") - var/mob/living/basic/bounty_criminal/boss/heavy/heavy = allocate(/mob/living/basic/bounty_criminal/boss/heavy, spot(0, 0)) - var/mob/living/carbon/human/consistent/target = hunter_at(spot(4, 0)) - var/datum/action/cooldown/mob_cooldown/bounty_boss/barricade/barricade = ability_of(heavy, /datum/action/cooldown/mob_cooldown/bounty_boss/barricade) - TEST_ASSERT(barricade.worth_using(target), "The Heavy won't put up a barricade") - TEST_ASSERT(barricade.Trigger(target = target), "The barricade didn't start") - TEST_ASSERT(barricade.finish_windup(barricade.pending_serial), "The barricade didn't go up") - var/obj/structure/barricade/security/bounty_boss/barrier = locate() in spot(1, 0) - TEST_ASSERT_NOTNULL(barrier, "No barricade in front of the Heavy") - TEST_ASSERT(barrier in GLOB.bounty_boss_barricades, "The barricade isn't counted") - barrier.fold_away() - TEST_ASSERT(QDELETED(barrier), "The barricade didn't fold away") - TEST_ASSERT_EQUAL(length(GLOB.bounty_boss_barricades), 0, "A folded barricade is still counted") - - var/list/made = list() - for(var/i in 1 to 9) - made += new /obj/structure/barricade/security/bounty_boss(spot(i % 5, 4)) - TEST_ASSERT_EQUAL(length(GLOB.bounty_boss_barricades), 8, "The barricade cap didn't hold") // BOUNTY_BOSS_BARRICADE_CAP - for(var/obj/structure/barricade/security/bounty_boss/extra as anything in made) - if(!QDELETED(extra)) - qdel(extra) - TEST_ASSERT_EQUAL(length(GLOB.bounty_boss_barricades), 0, "Deleted barricades are still counted") +// ===== FIRE IS TG'S FIRE, ON ANYONE IT CATCHES (CB-8, CB-11) ===== - // Barricades a boss keeps: a new one folds up its oldest. - var/list/mine = list() - for(var/i in 1 to 3) - var/obj/structure/barricade/security/bounty_boss/one = new(spot(i, 3)) - mine += one - barricade.barriers += WEAKREF(one) - barricade.prune_barriers() - TEST_ASSERT(QDELETED(mine[1]), "The Heavy's oldest barricade stayed past its limit") // BOUNTY_BOSS_BARRICADES_PER_BOSS - TEST_ASSERT_EQUAL(length(barricade.barriers), 2, "The Heavy keeps the wrong number of barricades") - for(var/obj/structure/barricade/security/bounty_boss/kept as anything in mine) - if(!QDELETED(kept)) - qdel(kept) +/datum/unit_test/voidcrew_bounty_boss/fire - // Fire: it burns who it may, never the boss, and goes out. - var/mob/living/basic/bounty_criminal/boss/pyromaniac/pyro = allocate(/mob/living/basic/bounty_criminal/boss/pyromaniac, spot(0, 2)) +/datum/unit_test/voidcrew_bounty_boss/fire/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/pyro = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac, spot(0, 2)) var/mob/living/carbon/human/consistent/walker = hunter_at(spot(2, 2)) - var/obj/effect/bounty_boss_fire_pool/pool = new(spot(2, 2), list(spot(0, 2), spot(2, 2)), 4 SECONDS, pyro, 10) + var/mob/living/carbon/human/consistent/downed = hunter_at(spot(3, 2)) + TEST_ASSERT(put_in_crit(downed), "Test fixture: the second hunter isn't in crit") + + var/obj/effect/bounty_boss_fire_pool/pool = new(spot(2, 2), list(spot(0, 2), spot(2, 2), spot(3, 2)), 4 SECONDS, pyro, 10, 4) TEST_ASSERT(!QDELETED(pool), "The fire went out at once") TEST_ASSERT(pool in GLOB.bounty_boss_fire_pools, "The fire isn't counted") - TEST_ASSERT(walker.getFireLoss() > 0, "The fire didn't burn someone standing in it") - TEST_ASSERT(walker.has_status_effect(/datum/status_effect/bounty_boss_burning), "The fire didn't set someone alight") + TEST_ASSERT(walker.getFireLoss() > 0, "The fire's landing didn't burn someone standing in it") + TEST_ASSERT(walker.fire_stacks > 0, "The fire put no fire stacks on someone standing in it") + TEST_ASSERT(walker.on_fire, "The fire didn't set someone standing in it alight") + TEST_ASSERT(downed.fire_stacks > 0, "The fire put no fire stacks on someone in crit") + TEST_ASSERT(downed.on_fire, "The fire didn't set someone in crit alight") TEST_ASSERT_EQUAL(pyro.getFireLoss(), 0, "The Pyromaniac's own fire burned it") + TEST_ASSERT(!pyro.on_fire, "The Pyromaniac's own fire set it alight") + + // Every second in the flames adds to the fire, standing or not. + var/stacks_before = downed.fire_stacks + pool.scorch() + TEST_ASSERT(downed.fire_stacks > stacks_before, "Lying in the flames added no fire stacks") + + // Once lit, it is tg's fire: putting it out is tg's too. + walker.extinguish_mob() + TEST_ASSERT(!walker.on_fire, "A boss's fire couldn't be put out the tg way") + downed.extinguish_mob() + pool.burn_out() TEST_ASSERT(QDELETED(pool), "The fire didn't go out") TEST_ASSERT(!(pool in GLOB.bounty_boss_fire_pools), "A fire that went out is still counted") - walker.remove_status_effect(/datum/status_effect/bounty_boss_burning) - TEST_ASSERT(!walker.has_status_effect(/datum/status_effect/bounty_boss_burning), "The burning didn't end") + + // With its boss gone, it still burns anyone not on the boss's side. + var/obj/effect/bounty_boss_fire_pool/orphan = new(spot(2, 2), list(spot(2, 2)), 4 SECONDS, pyro) + qdel(pyro) + walker.set_fire_stacks(0) + orphan.scorch() + TEST_ASSERT(walker.on_fire, "A fire whose boss is gone didn't burn anyone") + walker.extinguish_mob() + qdel(orphan) // The fire cap. var/list/pools = list() @@ -471,23 +459,47 @@ if(!QDELETED(extra_pool)) qdel(extra_pool) TEST_ASSERT_EQUAL(length(GLOB.bounty_boss_fire_pools), 0, "Deleted fire pools are still counted") + walker.extinguish_mob() + downed.extinguish_mob() + +// ===== BLASTS ARE TG'S LIGHT EXPLOSION, ON ANYONE THEY CATCH (CB-10, CB-11) ===== + +/datum/unit_test/voidcrew_bounty_boss/blasts + /// Severity of the last explosion that reached the watched victim + var/blast_severity = 0 + +/datum/unit_test/voidcrew_bounty_boss/blasts/proc/on_ex_act(datum/source, severity, target, origin) + SIGNAL_HANDLER + blast_severity = severity - // A grenade: nothing until its fuse runs out, never its thrower, then gone. - var/mob/living/basic/bounty_criminal/boss/demolitionist/demo = allocate(/mob/living/basic/bounty_criminal/boss/demolitionist, spot(2, 4)) +/datum/unit_test/voidcrew_bounty_boss/blasts/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/demo = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist, spot(2, 4)) var/mob/living/carbon/human/consistent/caught = hunter_at(spot(4, 4)) + TEST_ASSERT(put_in_crit(caught), "Test fixture: the hunter isn't in crit") + RegisterSignal(caught, COMSIG_ATOM_EX_ACT, PROC_REF(on_ex_act)) var/obj/effect/bounty_boss_explosive/grenade = new(spot(3, 4), demo) TEST_ASSERT(grenade in GLOB.bounty_boss_explosives, "The grenade isn't counted") - TEST_ASSERT_EQUAL(caught.getBruteLoss(), 0, "The grenade hurt someone before its fuse ran out") + var/before = caught.getBruteLoss() + TEST_ASSERT_EQUAL(blast_severity, 0, "The grenade went off before its fuse ran out") grenade.detonate() - TEST_ASSERT(caught.getBruteLoss() > 0, "The grenade didn't hurt someone next to it") + TEST_ASSERT_EQUAL(blast_severity, EXPLODE_LIGHT, "The grenade's blast didn't reach someone in crit as tg's light explosion") + TEST_ASSERT(caught.getBruteLoss() > before, "The grenade's blast didn't hurt someone in crit next to it") TEST_ASSERT_EQUAL(demo.getBruteLoss(), 0, "The grenade hurt the Demolitionist who threw it") TEST_ASSERT(QDELETED(grenade), "The grenade is still there after going off") TEST_ASSERT_EQUAL(length(GLOB.bounty_boss_explosives), 0, "A spent grenade is still counted") + UnregisterSignal(caught, COMSIG_ATOM_EX_ACT) + + // A blast never goes through a wall. + var/mob/living/carbon/human/consistent/behind = hunter_at(spot(0, 0)) + change_turf(spot(1, 0), /turf/closed/wall/r_wall) + var/obj/effect/bounty_boss_explosive/walled = new(spot(2, 0), demo) + walled.detonate() + TEST_ASSERT_EQUAL(behind.getBruteLoss(), 0, "A blast reached through a wall") // The explosive cap. var/list/bombs = list() for(var/i in 1 to 17) - bombs += new /obj/effect/bounty_boss_explosive(spot(i % 5, 0)) + bombs += new /obj/effect/bounty_boss_explosive(spot(i % 5, 1)) TEST_ASSERT_EQUAL(length(GLOB.bounty_boss_explosives), 16, "The explosive cap didn't hold") // BOUNTY_BOSS_EXPLOSIVE_CAP for(var/obj/effect/bounty_boss_explosive/extra_bomb as anything in bombs) if(!QDELETED(extra_bomb)) @@ -503,108 +515,193 @@ TEST_ASSERT(!blocked(spot(2, 3)), "The breached airlock left something in the way") TEST_ASSERT_EQUAL(demo.boss_walls_left, 1, "Breaching the airlock didn't spend the budget") -// ===== DOWNED HUNTERS ARE LEFT ALONE (C4, M3) ===== +// ===== ITS CHOICE OF TARGET SPARES THE DOWNED; ITS EFFECTS DON'T (CB-11) ===== /datum/unit_test/voidcrew_bounty_boss/downed_hunters /datum/unit_test/voidcrew_bounty_boss/downed_hunters/Run() - var/mob/living/basic/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 0)) var/mob/living/carbon/human/consistent/victim = hunter_at(spot(1, 0)) - victim.apply_status_effect(/datum/status_effect/bounty_boss_burning) - victim.apply_status_effect(/datum/status_effect/bounty_boss_bleed) - TEST_ASSERT(victim.has_status_effect(/datum/status_effect/bounty_boss_burning), "The hunter didn't catch fire") - TEST_ASSERT(victim.has_status_effect(/datum/status_effect/bounty_boss_bleed), "The hunter didn't bleed") - victim.adjustBruteLoss(120) - TEST_ASSERT(victim.stat != CONSCIOUS && victim.stat != DEAD, "The hunter isn't down") - TEST_ASSERT(!boss.boss_can_hurt(victim), "The boss may hurt a downed hunter") - TEST_ASSERT(!(victim in boss.boss_victims_on(list(get_turf(victim)))), "Area abilities catch a downed hunter") - var/datum/status_effect/burning = victim.has_status_effect(/datum/status_effect/bounty_boss_burning) - burning?.tick(1) - var/datum/status_effect/bleeding = victim.has_status_effect(/datum/status_effect/bounty_boss_bleed) - bleeding?.tick(1) - TEST_ASSERT(!victim.has_status_effect(/datum/status_effect/bounty_boss_burning), "A downed hunter keeps burning") - TEST_ASSERT(!victim.has_status_effect(/datum/status_effect/bounty_boss_bleed), "A downed hunter keeps bleeding from the cuts") - TEST_ASSERT(!victim.apply_status_effect(/datum/status_effect/bounty_boss_burning), "A downed hunter can be set alight") - -// ===== HEALTH GROWS WITH THE POSSE (M4) ===== + TEST_ASSERT(boss.boss_valid_target(victim), "The boss won't go after a hunter on their feet") + TEST_ASSERT(put_in_crit(victim), "Test fixture: the hunter isn't down") + TEST_ASSERT(!boss.boss_valid_target(victim), "The boss chooses to go after a downed hunter") + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(/datum/targeting_strategy/basic/human_npc/bounty_boss) + TEST_ASSERT(!strategy.can_attack(boss, victim, 9), "The boss's targeting picks a downed hunter") + // What its abilities catch is whoever is there. + TEST_ASSERT(boss.boss_effect_hits(victim), "The boss's fire and blasts skip a downed hunter") + TEST_ASSERT(victim in boss.boss_victims_on(list(get_turf(victim))), "Area abilities skip a downed hunter") + var/datum/action/cooldown/mob_cooldown/bounty_boss/slam/slam = ability_of(boss, /datum/action/cooldown/mob_cooldown/bounty_boss/slam) + var/mob/living/carbon/human/consistent/standing = hunter_at(spot(0, 1)) + var/before = victim.getBruteLoss() + TEST_ASSERT(slam.Trigger(target = standing), "The slam didn't start") + TEST_ASSERT(slam.finish_windup(slam.pending_serial), "The slam didn't land") + TEST_ASSERT(victim.getBruteLoss() > before, "The slam passed over a downed hunter beside the boss") + // Never its own side. + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/friend = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(1, 1)) + TEST_ASSERT(!boss.boss_effect_hits(friend), "The boss's fire and blasts catch another criminal") + +// ===== THE POSSE: ONE MORE COMPANION PER EXTRA HUNTER (OC-8, CB-1) ===== /datum/unit_test/voidcrew_bounty_boss/posse /datum/unit_test/voidcrew_bounty_boss/posse/Run() - var/mob/living/basic/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 0)) var/mob/living/carbon/human/consistent/first = hunter_at(spot(4, 4)) var/mob/living/carbon/human/consistent/second = hunter_at(spot(3, 4)) var/mob/living/carbon/human/consistent/third = hunter_at(spot(2, 4)) var/mob/living/carbon/human/consistent/fourth = hunter_at(spot(1, 4)) var/mob/living/carbon/human/consistent/fifth = hunter_at(spot(0, 4)) - // BOUNTY_JUGGERNAUT_HEALTH_1 to _4: 300, 950, 1600, 2300 - TEST_ASSERT_EQUAL(boss.maxHealth, 300, "The Juggernaut doesn't start at its one-hunter health") + boss.boss_hostile = TRUE // Only hunters on its grudge list (or hunting its bounty) count. TEST_ASSERT(!boss.boss_engage(first), "A hunter who never touched it joined the posse") TEST_ASSERT(posse_member(boss, first), "The first hunter didn't join the posse") - TEST_ASSERT_EQUAL(boss.maxHealth, 300, "One hunter raised the health") + TEST_ASSERT_EQUAL(boss.boss_reinforcements_owed, 0, "One hunter brought in a companion") TEST_ASSERT(!boss.boss_engage(first), "The same hunter joined twice") - // It sizes up the bigger posse in its kit's own words (BUG-2: "_posse" had no lines) + TEST_ASSERT_EQUAL(boss.maxHealth, 100, "The posse changed the boss's health") + + // The second hunter owes one companion, and it sizes them up in its kit's own words (BUG-2). var/datum/component/bounty_identity_voice/voice = boss.LoadComponent(/datum/component/bounty_identity_voice) voice.line_cooldown = 0 voice.last_line = null TEST_ASSERT(posse_member(boss, second), "The second hunter didn't join") - TEST_ASSERT_EQUAL(boss.maxHealth, 950, "Two hunters didn't raise the health") + TEST_ASSERT_EQUAL(boss.boss_reinforcements_owed, 1, "Two hunters didn't call in a companion") + TEST_ASSERT_NOTNULL(boss.boss_reinforce_timer, "The companion owed wasn't scheduled") TEST_ASSERT(voice.last_line in bounty_identity_strings("lines")["juggernaut_posse"]["any"], "Sizing up two hunters, the Juggernaut said [voice.last_line || "nothing"]") - TEST_ASSERT_EQUAL(boss.health, 950, "A fresh boss's raise didn't lift current health") // Someone with no mind isn't a hunter. var/mob/living/carbon/human/consistent/bystander = allocate(/mob/living/carbon/human/consistent, spot(4, 3)) boss.body_add_grudge(bystander) TEST_ASSERT(!boss.boss_engage(bystander), "A mindless bystander joined the posse") + TEST_ASSERT_EQUAL(boss.boss_reinforcements_owed, 1, "A mindless bystander called in a companion") - // Tired, a raise keeps its share of health: it is never healed out of its capture window. - boss.adjustBruteLoss(boss.maxHealth * 0.7, forced = TRUE) - TEST_ASSERT(boss.boss_tired, "The boss didn't tire") - var/share = boss.health / boss.maxHealth + // One per hunter beyond the first, up to BOUNTY_BOSS_REINFORCEMENTS_MAX (3). TEST_ASSERT(posse_member(boss, third), "The third hunter didn't join") - TEST_ASSERT_EQUAL(boss.maxHealth, 1600, "Three hunters didn't raise max health") - TEST_ASSERT(abs(boss.health / boss.maxHealth - share) < 0.01, "A tired boss's raise healed it from [share] to [boss.health / boss.maxHealth]") - TEST_ASSERT(boss.boss_tired, "A raise healed the boss out of being tired") - - // Downed, a newcomer waits; they count when it gets up, capped at the 4+ value. - boss.downed = TRUE - TEST_ASSERT(posse_member(boss, fourth), "The fourth hunter wasn't noted while it was down") - TEST_ASSERT(posse_member(boss, fifth), "The fifth hunter wasn't noted while it was down") - TEST_ASSERT_EQUAL(boss.maxHealth, 1600, "A downed boss's health rose") - boss.downed = FALSE - share = boss.health / boss.maxHealth - SEND_SIGNAL(boss, "bounty_criminal_recovered") // COMSIG_BOUNTY_CRIMINAL_RECOVERED - TEST_ASSERT_EQUAL(boss.maxHealth, 2300, "Hunters who joined while it was down didn't count when it got up") - TEST_ASSERT(abs(boss.health / boss.maxHealth - share) < 0.01, "Getting up with a bigger posse healed it") - - // Never lowered. + TEST_ASSERT_EQUAL(boss.boss_reinforcements_owed, 2, "Three hunters didn't call in two companions") + TEST_ASSERT(posse_member(boss, fourth), "The fourth hunter didn't join") + TEST_ASSERT(posse_member(boss, fifth), "The fifth hunter didn't join") + TEST_ASSERT_EQUAL(boss.boss_reinforcements_owed, 3, "Five hunters called in more than three companions") + + // Each one called is a real human NPC queued through the spawn queue, joined to the boss. + var/datum/human_npc_order/order = boss.boss_send_reinforcement(spot(4, 0)) + TEST_ASSERT(istype(order, /datum/human_npc_order), "Calling a companion in queued no human NPC") + TEST_ASSERT_EQUAL(boss.boss_reinforcements_sent, 1, "The companion called in wasn't counted") + TEST_ASSERT_EQUAL(boss.boss_reinforcements_owed, 2, "The companion called in is still owed") + // Cancelled here, so no body turns up after the test. + qdel(order) + + // Companions already sent count: a posse that shrinks and grows again owes nothing new. + boss.boss_reinforcements_owed = 0 + boss.boss_reinforcements_sent = 3 boss.boss_engaged.Cut() - boss.boss_engage(first) - TEST_ASSERT_EQUAL(boss.maxHealth, 2300, "A smaller posse lowered the health") + posse_member(boss, first) + posse_member(boss, second) + TEST_ASSERT_EQUAL(boss.boss_reinforcements_owed, 0, "Companions already sent were called in again") + + // They come in a way off, on open ground (who can see the spot is read from players with clients, and tests have none). + var/turf/arrival = boss.boss_reinforcement_spot() + if(arrival) + TEST_ASSERT(get_dist(arrival, boss) >= 5, "A companion would come in [get_dist(arrival, boss)] tiles from the boss") // BOUNTY_BOSS_REINFORCE_MIN_DIST + TEST_ASSERT(!arrival.is_blocked_turf(), "A companion would come in on a blocked tile") + + // Dead, it calls nobody. + boss.boss_reinforcements_owed = 1 + boss.boss_schedule_reinforcement() + boss.death() + TEST_ASSERT_NULL(boss.boss_reinforce_timer, "A dead boss still calls in companions") + TEST_ASSERT_EQUAL(boss.boss_reinforcements_owed, 0, "A dead boss still owes companions") // Confronted: it turns on whoever confronted it. - var/mob/living/basic/bounty_criminal/boss/juggernaut/other = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 2)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/other = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 2)) other.on_confronted(first) TEST_ASSERT(other.boss_hostile, "A confronted boss didn't turn hostile") TEST_ASSERT(other.body_has_grudge(first), "A confronted boss has no grudge against who confronted it") TEST_ASSERT_EQUAL(other.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET], first, "A confronted boss isn't going after who confronted it") +// ===== WORN OUT AND THE STIMULANT: REAL STATE, REAL PENS (CB-2, CB-4, CR-4) ===== + +/datum/unit_test/voidcrew_bounty_boss/stamina + +/datum/unit_test/voidcrew_bounty_boss/stamina/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 0)) + var/mob/living/carbon/human/consistent/hunter = hunter_at(spot(3, 0)) + var/datum/ai_controller/controller = boss.ai_controller + boss.body_add_grudge(hunter) + boss.boss_turn_hostile(hunter) + TEST_ASSERT(boss.boss_hostile, "Test fixture: the boss didn't turn hostile") + + // Stamina works on it from the first hit (CB-2): no fresh immunity. + boss.apply_damage(30, STAMINA) + TEST_ASSERT(boss.getStaminaLoss() >= 30, "A fresh boss shrugged off stamina damage") + + // The fight starting, it injects a real stimulant it carries. + var/datum/ai_planning_subtree/bounty_boss_stimulant/stimulant = new + TEST_ASSERT_EQUAL(stimulant.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "The boss didn't reach for its stimulant as its fight started") + TEST_ASSERT_EQUAL(boss.boss_stimulants_used, 1, "The boss didn't count its first stimulant") + var/datum/ai_behavior/human_npc_treat_self/treat = GET_AI_BEHAVIOR(/datum/ai_behavior/human_npc_treat_self) + TEST_ASSERT(controller.current_behaviors[treat], "The boss didn't plan to use its stimulant") + var/finished = FALSE + for(var/step in 1 to 10) + ready_hands(boss) + var/result = treat.perform(1, controller, "human_npc_treat_item") // BB_HUMAN_NPC_TREAT_ITEM + TEST_ASSERT(!(result & AI_BEHAVIOR_FAILED), "Using the stimulant failed at step [step]") + if(result & AI_BEHAVIOR_SUCCEEDED) + finished = TRUE + break + TEST_ASSERT(finished, "The boss never finished using its stimulant") + TEST_ASSERT(boss.reagents.has_reagent(/datum/reagent/medicine/ephedrine), "The stimulant isn't in the boss's blood") + TEST_ASSERT_NOTNULL(bounty_ai_find_injector(boss, /obj/item/reagent_containers/hypospray/medipen/stimpack), "The boss has no second stimulant left for its second wind") + treat.finish_action(controller, TRUE, "human_npc_treat_item") + TEST_ASSERT(!boss.boss_wants_stimulant(), "The boss wants a second stimulant before its second wind") + + // Worn out is its real stamina (over half its pool): it says so, shows the hunters, and falls back. + boss.setStaminaLoss(0) + boss.reagents.clear_reagents() + boss.adjustStaminaLoss(70) + TEST_ASSERT(boss.boss_is_worn_out(), "A boss at [boss.getStaminaLoss()] stamina loss isn't worn out") + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) + var/datum/ai_planning_subtree/bounty_boss_worn_out/worn = new + TEST_ASSERT_EQUAL(worn.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "A worn-out boss didn't fall back") + TEST_ASSERT(boss.boss_worn_out, "The boss didn't wear out") + TEST_ASSERT_EQUAL(boss.body_last_alert, "worn out", "Wearing out showed [boss.body_last_alert || "nothing"] over the boss") + TEST_ASSERT(boss.boss_fallback_until > world.time, "A worn-out boss has no fall back") + TEST_ASSERT(!boss.boss_can_use_ability(), "A boss falling back can still start an ability") + + // The fall back over and still worn out: its second wind is due, the second pen with its "recover" line. + boss.boss_fallback_until = world.time - 1 + worn.SelectBehaviors(controller, 1) + TEST_ASSERT(boss.boss_second_wind_due, "The boss's second wind isn't due after its fall back") + TEST_ASSERT(boss.boss_wants_stimulant(), "The boss doesn't want its second stimulant") + controller.clear_blackboard_key("human_npc_treat_item") + TEST_ASSERT_EQUAL(stimulant.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "The boss didn't take its second wind") + TEST_ASSERT_EQUAL(boss.boss_stimulants_used, 2, "The boss didn't count its second stimulant") + TEST_ASSERT(!boss.boss_second_wind_due, "The second wind is still due after it was taken") + + // Its stamina back, it is no longer worn out. + boss.setStaminaLoss(0) + worn.SelectBehaviors(controller, 1) + TEST_ASSERT(!boss.boss_worn_out, "A boss with its stamina back is still worn out") + + // Real health counts too: under 40% of a person's. + boss.adjustBruteLoss(65) + TEST_ASSERT(boss.boss_is_worn_out(), "A boss at [boss.health] health isn't worn out") + // ===== MECHS (H2) ===== /datum/unit_test/voidcrew_bounty_boss/mechs /datum/unit_test/voidcrew_bounty_boss/mechs/Run() - var/mob/living/basic/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 0)) var/mob/living/carbon/human/consistent/pilot = hunter_at(spot(1, 0)) var/obj/vehicle/sealed/mecha/ripley/mech = mech_with(spot(1, 0), pilot) // A mech with a hunter in it is a target; the pilot is reached through it. TEST_ASSERT(boss.boss_valid_target(mech), "A hunter's mech isn't a target") TEST_ASSERT(!boss.boss_valid_target(pilot), "The boss goes for a pilot through the mech's hull") + TEST_ASSERT(!boss.boss_effect_hits(pilot), "The boss's fire and blasts reach a pilot through the hull") + TEST_ASSERT(boss.boss_effect_hits(mech), "The boss's fire and blasts skip a hunter's mech") TEST_ASSERT_EQUAL(boss.boss_target_for(pilot), mech, "The boss doesn't go for the pilot's mech") - var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(/datum/targeting_strategy/basic/bounty_boss) + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(/datum/targeting_strategy/basic/human_npc/bounty_boss) TEST_ASSERT(strategy.can_attack(boss, mech, 9), "The boss's targeting ignores a hunter's mech") // The mech's blows count as the pilot's: the pilot joins the posse, and the boss turns on the mech. @@ -614,20 +711,15 @@ TEST_ASSERT_EQUAL(boss.boss_revenge_target(), mech, "The boss doesn't turn on the mech that hit it") // Mech gunfire counts too. - var/mob/living/basic/bounty_criminal/boss/juggernaut/shot_at = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 3)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/shot_at = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 3)) var/obj/projectile/bullet/gunfire = new(get_turf(mech)) gunfire.firer = mech SEND_SIGNAL(shot_at, COMSIG_PROJECTILE_PREHIT, gunfire) qdel(gunfire) TEST_ASSERT(shot_at.boss_engaged[REF(pilot)], "Mech gunfire didn't put the pilot in the posse") - // Its fists land on the hull. - var/integrity = mech.get_integrity() - TEST_ASSERT(boss.melee_attack(mech, ignore_cooldown = TRUE), "The boss didn't swing at a mech") - TEST_ASSERT(mech.get_integrity() < integrity, "The boss's fists did nothing to a mech") - // The slam hits a mech next to it. - integrity = mech.get_integrity() + var/integrity = mech.get_integrity() var/datum/action/cooldown/mob_cooldown/bounty_boss/slam/slam = ability_of(boss, /datum/action/cooldown/mob_cooldown/bounty_boss/slam) TEST_ASSERT(slam.worth_using(mech), "The slam isn't worth using on a mech beside it") TEST_ASSERT(slam.Trigger(target = mech), "The slam didn't start on a mech") @@ -635,7 +727,7 @@ TEST_ASSERT(mech.get_integrity() < integrity, "The slam didn't hurt the mech") // The charge slams into a mech instead of bouncing off it. - var/mob/living/basic/bounty_criminal/boss/juggernaut/charger = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 2)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/charger = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 2)) mech.forceMove(spot(3, 2)) integrity = mech.get_integrity() var/datum/action/cooldown/mob_cooldown/bounty_boss/charge/charge = ability_of(charger, /datum/action/cooldown/mob_cooldown/bounty_boss/charge) @@ -648,15 +740,15 @@ break charge.charge_step(serial) TEST_ASSERT(mech.get_integrity() < integrity, "The charge didn't hurt the mech") - TEST_ASSERT(charger.boss_busy != "stagger", "The charge bounced off a mech") + TEST_ASSERT(!charger.IsKnockdown(), "The charge bounced off a mech") -// ===== THE JUGGERNAUT'S CHARGE (M1) ===== +// ===== THE JUGGERNAUT'S CHARGE (M1, CB-6) ===== /datum/unit_test/voidcrew_bounty_boss/charge /datum/unit_test/voidcrew_bounty_boss/charge/Run() // Through a table. - var/mob/living/basic/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 0)) var/obj/structure/table/table = allocate(/obj/structure/table, spot(1, 0)) var/mob/living/carbon/human/consistent/victim = hunter_at(spot(3, 0)) var/datum/action/cooldown/mob_cooldown/bounty_boss/charge/charge = ability_of(boss, /datum/action/cooldown/mob_cooldown/bounty_boss/charge) @@ -673,11 +765,12 @@ charge.charge_step(serial) TEST_ASSERT(QDELETED(table), "The charge didn't smash the table in its way") TEST_ASSERT(victim.getBruteLoss() > 0, "The charge didn't hit its target") + TEST_ASSERT(victim.IsKnockdown(), "The charge didn't knock its target down") TEST_ASSERT_NULL(boss.boss_busy, "The boss is still charging") TEST_ASSERT_EQUAL(get_dist(boss, victim), 1, "The charge didn't end at its target") // Through a plain window and its grille, without bouncing. - var/mob/living/basic/bounty_criminal/boss/juggernaut/second = allocate(/mob/living/basic/bounty_criminal/boss/juggernaut, spot(0, 3)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/second = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 3)) var/obj/structure/grille/grille = allocate(/obj/structure/grille, spot(1, 3)) var/obj/structure/window/fulltile/pane = allocate(/obj/structure/window/fulltile, spot(1, 3)) var/mob/living/carbon/human/consistent/behind = hunter_at(spot(3, 3)) @@ -691,45 +784,166 @@ window_charge.charge_step(serial) TEST_ASSERT(QDELETED(pane), "The charge didn't smash the plain window") TEST_ASSERT(QDELETED(grille), "The charge didn't smash the grille under the window") - TEST_ASSERT(second.boss_busy != "stagger", "The charge bounced off a plain window") + TEST_ASSERT(!second.IsKnockdown(), "The charge bounced off a plain window") TEST_ASSERT(behind.getBruteLoss() > 0, "The charge through a window didn't reach its target") -// ===== THE HEAVY'S ROUNDS (M5) ===== + // Into a reinforced wall: it hurts, and it puts the Juggernaut down where it can be cuffed (CB-6). + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/wall_runner = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 1)) + var/mob/living/carbon/human/consistent/beyond = hunter_at(spot(4, 1)) + change_turf(spot(2, 1), /turf/closed/wall/r_wall) + var/datum/action/cooldown/mob_cooldown/bounty_boss/charge/wall_charge = ability_of(wall_runner, /datum/action/cooldown/mob_cooldown/bounty_boss/charge) + TEST_ASSERT(wall_charge.Trigger(target = beyond), "The charge at the wall didn't start") + TEST_ASSERT(wall_charge.finish_windup(wall_charge.pending_serial), "The charge at the wall didn't go") + serial = wall_runner.boss_busy_serial + for(var/i in 1 to 8) + if(wall_runner.boss_busy != "charge") + break + wall_charge.charge_step(serial) + TEST_ASSERT(isclosedturf(spot(2, 1)), "The charge broke a reinforced wall") + TEST_ASSERT(wall_runner.getBruteLoss() > 0, "Running into the wall didn't hurt the Juggernaut") + TEST_ASSERT(wall_runner.IsKnockdown(), "Running into the wall didn't knock the Juggernaut down") + TEST_ASSERT(!wall_runner.body_can_answer_cuffs(), "A Juggernaut dazed by a wall could still fight off the cuffs") + TEST_ASSERT(!wall_runner.boss_can_act(), "A Juggernaut dazed by a wall can still act") + TEST_ASSERT(abs(wall_runner.physiology.brute_mod - 0.6) < 0.01, "Running into the wall changed how much damage it takes") + +// ===== THE HEAVY'S GUN: REAL ROUNDS, REAL BELTS, A FUMBLE (CB-13) ===== + +/datum/unit_test/voidcrew_bounty_boss/heavy + +/datum/unit_test/voidcrew_bounty_boss/heavy/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/heavy, spot(0, 0)) + var/mob/living/carbon/human/consistent/target = hunter_at(spot(4, 0)) + var/obj/item/gun/ballistic/automatic/l6_saw/gun = heavy.boss_gun() + TEST_ASSERT_NOTNULL(gun, "The Heavy holds no gun") + var/datum/action/cooldown/mob_cooldown/bounty_boss/burst/burst = ability_of(heavy, /datum/action/cooldown/mob_cooldown/bounty_boss/burst) + TEST_ASSERT(burst.worth_using(target), "The Heavy won't open up on someone four tiles off") + + // A hit while it aims spoils the burst. + TEST_ASSERT(burst.Trigger(target = target), "The burst didn't start") + var/serial = burst.pending_serial + heavy.apply_damage(20, BRUTE) + TEST_ASSERT_NULL(heavy.boss_busy, "A hit didn't spoil the Heavy's aim") + TEST_ASSERT(!burst.finish_windup(serial), "A spoiled burst still fired") + + // Aimed in peace, it pulls the real gun's trigger: a real round leaves its belt. + ready(heavy) + ready_hands(heavy) + var/rounds = gun.get_ammo() + TEST_ASSERT(burst.Trigger(target = target), "The burst didn't start again") + TEST_ASSERT(burst.finish_windup(burst.pending_serial), "The burst didn't fire when its wind-up ended") + TEST_ASSERT(gun.get_ammo() < rounds, "The burst fired no round from the Heavy's gun") + burst.end_burst(heavy.boss_busy_serial) + + // Its belt run dry, it changes it from its bag the way a player does. + QDEL_NULL(gun.chambered) + gun.magazine.stored_ammo.Cut() + gun.update_appearance() + TEST_ASSERT(!gun.can_shoot(), "Test fixture: the Heavy's gun still fires") + var/datum/ai_controller/controller = heavy.ai_controller + heavy.boss_hostile = TRUE + TEST_ASSERT(heavy.boss_gear_plan(controller), "The Heavy didn't plan to change its belt") + var/datum/ai_behavior/bounty_heavy_reload/reload = GET_AI_BEHAVIOR(/datum/ai_behavior/bounty_heavy_reload) + reload.setup(controller) + var/finished = FALSE + for(var/step in 1 to 12) + ready_hands(heavy) + var/result = reload.perform(1, controller) + TEST_ASSERT(!(result & AI_BEHAVIOR_FAILED), "Changing the belt failed at step [step]") + if(result & AI_BEHAVIOR_SUCCEEDED) + finished = TRUE + break + TEST_ASSERT(finished, "The Heavy never finished changing its belt") + TEST_ASSERT(gun.can_shoot(), "The Heavy's gun doesn't fire after a belt change") + TEST_ASSERT(!gun.cover_open, "The Heavy left its gun's cover open") + TEST_ASSERT(gun.magazine?.ammo_count() > 0, "The new belt is empty") + TEST_ASSERT_EQUAL(carried(heavy, /obj/item/ammo_box/magazine/m7mm) - (gun.magazine ? 1 : 0), 0, "The spare box wasn't the one that went in") + + // Barricade: a real barrier grenade, armed and thrown in front of it (CB-26). + var/datum/action/cooldown/mob_cooldown/bounty_boss/barricade/barricade = ability_of(heavy, /datum/action/cooldown/mob_cooldown/bounty_boss/barricade) + ready(heavy) + ready_hands(heavy) + TEST_ASSERT_EQUAL(length(GLOB.bounty_boss_barricades), 0, "Barricades were standing before the test") + TEST_ASSERT(barricade.worth_using(target), "The Heavy won't put up a barricade") + TEST_ASSERT(barricade.Trigger(target = target), "The barricade didn't start") + TEST_ASSERT(barricade.finish_windup(barricade.pending_serial), "The barricade wasn't thrown") + TEST_ASSERT_EQUAL(carried(heavy, /obj/item/grenade/barrier/bounty_boss), 1, "The Heavy didn't throw one of its barrier grenades") + var/obj/item/grenade/barrier/bounty_boss/thrown + for(var/obj/item/grenade/barrier/bounty_boss/loose in range(2, heavy)) + if(!heavy.is_holding(loose)) + thrown = loose + TEST_ASSERT_NOTNULL(thrown, "The thrown barrier grenade is nowhere near the Heavy") + TEST_ASSERT(thrown.active, "The Heavy threw its barrier grenade without pulling the pin") + thrown.forceMove(spot(1, 0)) + thrown.detonate() + var/obj/structure/barricade/security/bounty_boss/barrier = locate() in spot(1, 0) + TEST_ASSERT_NOTNULL(barrier, "The barrier grenade made no barrier") + TEST_ASSERT(barrier in GLOB.bounty_boss_barricades, "The barricade isn't counted") + TEST_ASSERT_EQUAL(length(barricade.barriers), 1, "The Heavy doesn't count the barrier it threw") + barrier.fold_away() + TEST_ASSERT(QDELETED(barrier), "The barricade didn't fold away") + TEST_ASSERT_EQUAL(length(GLOB.bounty_boss_barricades), 0, "A folded barricade is still counted") -/datum/unit_test/voidcrew_bounty_boss/heavy_rounds + // The barricade cap. + var/list/made = list() + for(var/i in 1 to 9) + made += new /obj/structure/barricade/security/bounty_boss(spot(i % 5, 4)) + TEST_ASSERT_EQUAL(length(GLOB.bounty_boss_barricades), 8, "The barricade cap didn't hold") // BOUNTY_BOSS_BARRICADE_CAP + for(var/obj/structure/barricade/security/bounty_boss/extra as anything in made) + if(!QDELETED(extra)) + qdel(extra) + TEST_ASSERT_EQUAL(length(GLOB.bounty_boss_barricades), 0, "Deleted barricades are still counted") -/datum/unit_test/voidcrew_bounty_boss/heavy_rounds/Run() - var/mob/living/basic/bounty_criminal/boss/heavy/heavy = allocate(/mob/living/basic/bounty_criminal/boss/heavy, spot(0, 0)) - var/obj/projectile/bullet/bounty_boss_heavy/heavy_round = new(spot(0, 0)) - heavy_round.firer = heavy - var/obj/structure/window/fulltile/inside = allocate(/obj/structure/window/fulltile, spot(2, 2)) - TEST_ASSERT_EQUAL(heavy_round.get_demolition_modifier(inside), 0.25, "The Heavy's rounds don't chip an interior window") // BOUNTY_HEAVY_BURST_DEMOLITION - var/obj/structure/window/fulltile/outside = allocate(/obj/structure/window/fulltile, spot(4, 2)) - change_turf(spot(4, 3), /turf/open/space/basic) - TEST_ASSERT_EQUAL(heavy_round.get_demolition_modifier(outside), 0, "The Heavy's rounds damage a window onto space") - var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, spot(2, 4)) - TEST_ASSERT_EQUAL(heavy_round.get_demolition_modifier(door), 0, "The Heavy's rounds break doors") - var/mob/living/carbon/human/consistent/pilot = hunter_at(spot(1, 1)) - var/obj/vehicle/sealed/mecha/ripley/mech = mech_with(spot(1, 1), pilot) - TEST_ASSERT_EQUAL(heavy_round.get_demolition_modifier(mech), 1, "The Heavy's rounds go easy on mechs") - qdel(heavy_round) + // Barricades a boss keeps: a new one folds up its oldest. + var/list/mine = list() + for(var/i in 1 to 3) + var/obj/structure/barricade/security/bounty_boss/one = new(spot(i, 3)) + mine += one + barricade.barriers += WEAKREF(one) + barricade.prune_barriers() + TEST_ASSERT(QDELETED(mine[1]), "The Heavy's oldest barricade stayed past its limit") // BOUNTY_BOSS_BARRICADES_PER_BOSS + TEST_ASSERT_EQUAL(length(barricade.barriers), 2, "The Heavy keeps the wrong number of barricades") + for(var/obj/structure/barricade/security/bounty_boss/kept as anything in mine) + if(!QDELETED(kept)) + qdel(kept) -// ===== THE GHOST ===== +// ===== THE GHOST: A REAL KNIFE, REAL CHROME, A SHIMMER THAT IS ONLY A LOOK (CB-9, CB-12) ===== /datum/unit_test/voidcrew_bounty_boss/ghost /datum/unit_test/voidcrew_bounty_boss/ghost/Run() - var/mob/living/basic/bounty_criminal/boss/ghost/ghost = allocate(/mob/living/basic/bounty_criminal/boss/ghost, spot(0, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/ghost = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/ghost, spot(0, 0)) TEST_ASSERT(ghost.boss_cloak(), "The Ghost didn't cloak") + TEST_ASSERT(ghost.alpha < 255 || ghost.get_filter("bounty_ghost_shimmer"), "The Ghost's shimmer doesn't show") ghost.apply_damage(5, BRUTE) - TEST_ASSERT(!ghost.boss_cloaked, "A hit didn't break the Ghost's cloak") + TEST_ASSERT(!ghost.boss_cloaked, "A hit didn't break the Ghost's shimmer") TEST_ASSERT(ghost.boss_cloak(), "The Ghost didn't cloak again") SEND_SIGNAL(ghost, COMSIG_MOB_FLASHED) - TEST_ASSERT(!ghost.boss_cloaked, "A flash didn't break the Ghost's cloak") + TEST_ASSERT(!ghost.boss_cloaked, "A flash didn't break the Ghost's shimmer") - // Its cuts bleed, and a couple of them make it break off. + // Its dash ends in a real knife attack, the knife's own damage, wounds and bleeding (CB-9). var/mob/living/carbon/human/consistent/victim = hunter_at(spot(1, 0)) - ghost.boss_cut(victim) - TEST_ASSERT(victim.has_status_effect(/datum/status_effect/bounty_boss_bleed), "The Ghost's cut didn't bleed") - ghost.boss_cut(victim) - TEST_ASSERT(ghost.boss_breakoff_until > world.time, "The Ghost didn't break off after two cuts") // BOUNTY_GHOST_CUTS_BEFORE_BREAKOFF + var/datum/action/cooldown/mob_cooldown/bounty_boss/dash/dash = ability_of(ghost, /datum/action/cooldown/mob_cooldown/bounty_boss/dash) + TEST_ASSERT(!dash.cut_at_end(victim), "The Ghost cut with a knife it hadn't drawn") + var/obj/item/knife/combat/knife = locate() in ghost.get_all_contents_type(/obj/item/knife/combat) + TEST_ASSERT(human_npc_take_in_hand(ghost, knife), "The Ghost couldn't draw its knife") + ready_hands(ghost) + TEST_ASSERT(dash.cut_at_end(victim), "The Ghost's dash didn't end in a cut") + TEST_ASSERT(victim.getBruteLoss() > 0, "The Ghost's knife didn't hurt the hunter") + TEST_ASSERT_EQUAL(ghost.boss_cuts, 1, "The Ghost's cut wasn't a blow from its knife") + + // A couple of cuts and it breaks off (BOUNTY_GHOST_CUTS_BEFORE_BREAKOFF). + ready_hands(ghost) + TEST_ASSERT(dash.cut_at_end(victim), "The Ghost's second cut didn't land") + TEST_ASSERT(ghost.boss_breakoff_until > world.time, "The Ghost didn't break off after two cuts") + + // The cut at the end of a dash lands on whoever it dashed at, down or not (CB-11). + TEST_ASSERT(put_in_crit(victim), "Test fixture: the hunter isn't down") + var/before = victim.getBruteLoss() + ready_hands(ghost) + TEST_ASSERT(dash.cut_at_end(victim), "The Ghost's cut skipped a downed hunter") + TEST_ASSERT(victim.getBruteLoss() > before, "The Ghost's knife didn't hurt a downed hunter") + + // The dash fires its Cascade lattice: real chrome, with its own window and crash. + TEST_ASSERT(ghost.boss_surge(), "The Ghost's Cascade lattice didn't fire") + TEST_ASSERT(ghost.has_status_effect(/datum/status_effect/cyberware_cascade_window), "The Cascade window didn't open") + TEST_ASSERT(!ghost.boss_surge(), "The Cascade lattice fired twice in one cycle") diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_criminal.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_criminal.dm index 06fbd1ad671..5abc60516d6 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_criminal.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_criminal.dm @@ -1,9 +1,12 @@ /** - * Bounty hunting: P2 body's tests, for voidcrew/modules/bounties/bounty_criminal.dm and bounty_restraints.dm. + * Bounty hunting: P2 body's tests, for voidcrew/modules/bounties/bounty_criminal.dm, bounty_restraints.dm, + * bounty_outfits.dm and bounty_ai_human.dm. * - * Owner: P2 body. Do not edit this file outside that package. Fork defines are included after the - * tests, so a test uses the literal value with a comment naming the define. Every test drives the - * body's procs directly with the mob in place; none relies on the AI walking anywhere. + * A criminal is a real human NPC (voidcrew/modules/human_npcs/): its crit, stamina, cuffs and death are + * tg's. These tests drive tg's own procs on the body (apply_damage/adjustBruteLoss, Knockdown, Paralyze, + * adjustStaminaLoss, tg handcuffs, death, revive) and check that the bounty rules read them. Fork + * defines are included after the tests, so a test uses the literal value with a comment naming the + * define. None relies on the AI walking anywhere (the AI is off with no player on the level). */ /// Holds the helpers the body tests share @@ -11,6 +14,8 @@ abstract_type = /datum/unit_test/voidcrew_bounty_criminal /// How many times each bounty signal was sent in the test var/list/signal_counts + /// The user each restraint signal carried, last first + var/list/restraint_users /// Turfs moved into a test area, and the area each came from var/list/original_areas /// Areas made for the test @@ -54,451 +59,254 @@ SIGNAL_HANDLER LAZYINITLIST(signal_counts) signal_counts["restrained"] += 1 + LAZYINITLIST(restraint_users) + restraint_users.Insert(1, list(list(user))) /datum/unit_test/voidcrew_bounty_criminal/proc/on_unrestrained(datum/source, mob/user) SIGNAL_HANDLER LAZYINITLIST(signal_counts) signal_counts["unrestrained"] += 1 + LAZYINITLIST(restraint_users) + restraint_users.Insert(1, list(list(user))) /// How many times `signal_name` was counted /datum/unit_test/voidcrew_bounty_criminal/proc/signal_count(signal_name) return LAZYACCESS(signal_counts, signal_name) || 0 +/// The user the last restraint signal carried +/datum/unit_test/voidcrew_bounty_criminal/proc/last_restraint_user() + if(!length(restraint_users)) + return null + var/list/entry = restraint_users[1] + return entry[1] + +/// Listens to every bounty body signal on `criminal` +/datum/unit_test/voidcrew_bounty_criminal/proc/listen_to(mob/living/carbon/human/npc/bounty_criminal/criminal) + RegisterSignal(criminal, "bounty_criminal_downed", PROC_REF(on_downed)) // COMSIG_BOUNTY_CRIMINAL_DOWNED + RegisterSignal(criminal, "bounty_criminal_recovered", PROC_REF(on_recovered)) // COMSIG_BOUNTY_CRIMINAL_RECOVERED + RegisterSignal(criminal, "bounty_criminal_restrained", PROC_REF(on_restrained)) // COMSIG_BOUNTY_CRIMINAL_RESTRAINED + RegisterSignal(criminal, "bounty_criminal_unrestrained", PROC_REF(on_unrestrained)) // COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED + +/// Puts real tg handcuffs on `criminal`, as `user` would (tg's apply_cuffs(), without the do_after) +/datum/unit_test/voidcrew_bounty_criminal/proc/cuff(mob/living/carbon/criminal, mob/living/user) + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + cuffs.apply_cuffs(criminal, user || allocate(/mob/living/carbon/human/consistent), dispense = TRUE) + return criminal.handcuffed + /// Everything examining `criminal` shows `user`, as one text -/datum/unit_test/voidcrew_bounty_criminal/proc/examine_text(mob/living/basic/bounty_criminal/criminal, mob/user) +/datum/unit_test/voidcrew_bounty_criminal/proc/examine_text(mob/living/carbon/human/npc/bounty_criminal/criminal, mob/user) return jointext(criminal.examine(user), "\n") -// ===== THE MERCY RULE ===== - -/datum/unit_test/voidcrew_bounty_criminal_mercy - parent_type = /datum/unit_test/voidcrew_bounty_criminal +// ===== THE BODY IS A PERSON'S ===== -/datum/unit_test/voidcrew_bounty_criminal_mercy/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) - // BOUNTY_NORMAL_HEALTH_WANTED 180; BOUNTY_DOWNED_BELOW 25: the line is 45 - TEST_ASSERT_EQUAL(criminal.maxHealth, 180, "A normal criminal has the wrong health") - TEST_ASSERT(!criminal.is_downed(), "A fresh criminal is downed") - criminal.adjustBruteLoss(500) - TEST_ASSERT_EQUAL(criminal.stat, CONSCIOUS, "One big hit killed a criminal on its feet") - TEST_ASSERT_EQUAL(criminal.health, 44, "A big hit on a criminal on its feet did not stop 1 under the downed line") - TEST_ASSERT(criminal.is_downed(), "A criminal at the downed line is not downed") - TEST_ASSERT_EQUAL(criminal.capture_state(), "downed", "A downed criminal's capture state is wrong") // BOUNTY_STATE_DOWNED - // Down, the next hit can kill: that is a choice. - criminal.adjustBruteLoss(500) - TEST_ASSERT_EQUAL(criminal.stat, DEAD, "A hit on a downed criminal could not kill it") - TEST_ASSERT_EQUAL(criminal.capture_state(), "dead", "A dead criminal's capture state is wrong") // BOUNTY_STATE_DEAD - TEST_ASSERT(!QDELETED(criminal), "A dead criminal's body was deleted") - - // Stamina crit counts as standing for the mercy rule. - var/mob/living/basic/bounty_criminal/meek/runner = allocate(__IMPLIED_TYPE__) - runner.adjustStaminaLoss(200) - TEST_ASSERT(runner.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "A meek criminal did not stamina crit") - runner.adjustBruteLoss(500) - // BOUNTY_MEEK_HEALTH 120: the line is 30 - TEST_ASSERT_EQUAL(runner.stat, CONSCIOUS, "A big hit killed a criminal in stamina crit") - TEST_ASSERT_EQUAL(runner.health, 29, "A big hit on a criminal in stamina crit did not stop 1 under the downed line") - - // Small hits add up to the same line and no further. - var/mob/living/basic/bounty_criminal/normal/chipped = allocate(__IMPLIED_TYPE__) - for(var/i in 1 to 30) - chipped.adjustBruteLoss(7) - if(chipped.is_downed()) - break - TEST_ASSERT(chipped.is_downed(), "Small hits never downed a criminal") - TEST_ASSERT(chipped.health > 0 && chipped.health <= 45, "Small hits left a downed criminal at [chipped.health]") - - // Fire and heat burn it like a suited person (BOUNTY_CRIMINAL_MAX_TEMP 1500, BOUNTY_CRIMINAL_BURN_DAMAGE 3 a second), but not past the mercy line. - var/mob/living/basic/bounty_criminal/normal/burning = allocate(__IMPLIED_TYPE__) - burning.bodytemperature = 3000 - burning.Life(2) - TEST_ASSERT(burning.health < 180, "Heat did not hurt a criminal") - burning.adjustBruteLoss(burning.health - 46) - TEST_ASSERT_EQUAL(burning.health, 46, "The burning criminal was not brought near the line") - burning.bodytemperature = 3000 - burning.Life(2) - TEST_ASSERT_EQUAL(burning.stat, CONSCIOUS, "Heat killed a criminal on its feet") - TEST_ASSERT_EQUAL(burning.health, 44, "Heat took a criminal on its feet past the downed line") - -// ===== DOWNED AND GETTING UP ===== - -/datum/unit_test/voidcrew_bounty_criminal_recovery +/// No stand-in body: a person's health and stamina, tg crit and death, revival works, no fire or vacuum immunity. +/datum/unit_test/voidcrew_bounty_criminal_body parent_type = /datum/unit_test/voidcrew_bounty_criminal -/datum/unit_test/voidcrew_bounty_criminal_recovery/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) - RegisterSignal(criminal, "bounty_criminal_downed", TYPE_PROC_REF(/datum/unit_test/voidcrew_bounty_criminal, on_downed)) // COMSIG_BOUNTY_CRIMINAL_DOWNED - RegisterSignal(criminal, "bounty_criminal_recovered", TYPE_PROC_REF(/datum/unit_test/voidcrew_bounty_criminal, on_recovered)) // COMSIG_BOUNTY_CRIMINAL_RECOVERED - var/mob/living/carbon/human/consistent/hunter = allocate(__IMPLIED_TYPE__, get_step(run_loc_floor_bottom_left, EAST)) - - criminal.adjustBruteLoss(500) - TEST_ASSERT_EQUAL(signal_count("downed"), 1, "Going down did not send the downed signal once") - TEST_ASSERT(HAS_TRAIT(criminal, TRAIT_FLOORED), "A downed criminal is not on the floor") - TEST_ASSERT(HAS_TRAIT(criminal, TRAIT_INCAPACITATED), "A downed criminal is not incapacitated") - TEST_ASSERT(HAS_TRAIT(criminal, "bounty_held"), "A downed criminal's AI may still plan") // TRAIT_BOUNTY_HELD - TEST_ASSERT(HAS_TRAIT(criminal, TRAIT_AI_PAUSED), "A downed criminal's AI is not paused") - TEST_ASSERT(!criminal.may_attack(hunter), "A downed criminal may still attack") - TEST_ASSERT_NOTNULL(criminal.body_recovery_timer, "A downed criminal has no recovery clock") - - // Healing raises its health, not its state. - criminal.adjustBruteLoss(-500) - TEST_ASSERT_EQUAL(criminal.health, 180, "A downed criminal could not be healed") - TEST_ASSERT(criminal.is_downed(), "Healing stood a downed criminal up") - - // A stamina hit starts the clock over. - var/first_clock = criminal.body_recovery_timer - criminal.adjustStaminaLoss(10) - TEST_ASSERT(criminal.body_recovery_timer && criminal.body_recovery_timer != first_clock, "A stamina hit did not restart a downed criminal's recovery") - - // Cuffed, it stays down when its time comes, and gets up once the cuffs are off. - TEST_ASSERT(criminal.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs)), "A downed criminal could not be cuffed") - criminal.body_stir() - TEST_ASSERT(criminal.is_downed(), "A cuffed, downed criminal started getting up") - TEST_ASSERT(!criminal.body_stirring, "A cuffed, downed criminal is stirring") - TEST_ASSERT(!criminal.body_stand_up(), "A cuffed, downed criminal got up") - TEST_ASSERT(criminal.body_recovery_due, "A cuffed criminal's recovery did not wait for the cuffs") - TEST_ASSERT_EQUAL(criminal.capture_state(), "downed", "A cuffed, downed criminal is not downed") // BOUNTY_STATE_DOWNED - // A baton on it now starts the clock over: uncuffed, it doesn't jump up at once (L4). - criminal.adjustStaminaLoss(10) - TEST_ASSERT(!criminal.body_recovery_due, "A stamina hit left a cuffed criminal's recovery due") - var/restarted_clock = criminal.body_recovery_timer - criminal.body_remove_cuffs() - TEST_ASSERT(!criminal.body_stirring, "An uncuffed criminal stirred at once after a stamina hit restarted its clock") - TEST_ASSERT_EQUAL(criminal.body_recovery_timer, restarted_clock, "Uncuffing threw away a restarted recovery clock") - // Its time comes again, cuffed: it waits, then stirs once the cuffs are off. - TEST_ASSERT(criminal.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs)), "The downed criminal could not be cuffed again") - criminal.body_stir() - TEST_ASSERT(criminal.body_recovery_due, "A cuffed criminal's recovery did not wait for the cuffs the second time") - criminal.body_remove_cuffs() - TEST_ASSERT(criminal.body_stirring, "A downed criminal whose time had come did not stir once uncuffed") - TEST_ASSERT(criminal.body_stand_up(), "A stirring criminal could not get up") - TEST_ASSERT(!criminal.is_downed(), "A criminal that got up is still downed") - TEST_ASSERT_EQUAL(signal_count("recovered"), 1, "Getting up did not send the recovered signal once") - TEST_ASSERT_EQUAL(criminal.health, 180, "A criminal healed while down did not get up with its health") - TEST_ASSERT(!HAS_TRAIT(criminal, TRAIT_FLOORED), "A criminal that got up is still on the floor") - TEST_ASSERT(!HAS_TRAIT(criminal, "bounty_held"), "A criminal that got up is still held") // TRAIT_BOUNTY_HELD - TEST_ASSERT(!HAS_TRAIT(criminal, TRAIT_AI_PAUSED), "A criminal that got up still has its AI paused") - TEST_ASSERT(criminal.may_attack(hunter), "A criminal that got up may not fight") - - // Left alone, it gets up at the recovery line. - var/mob/living/basic/bounty_criminal/normal/other = allocate(__IMPLIED_TYPE__) - other.adjustBruteLoss(500) - other.body_stir() - TEST_ASSERT(other.body_stirring, "A downed criminal did not stir when its time came") - TEST_ASSERT(other.body_stand_up(), "A downed criminal did not get up") - // BOUNTY_RECOVER_TO 35: 63 of 180 - TEST_ASSERT_EQUAL(other.health, 63, "A criminal got up with the wrong health") - - // Only mini-bosses rally: stamina does nothing to one that just got up, until the rally ends. - TEST_ASSERT(!other.body_is_rallying(), "A normal criminal rallies") - var/mob/living/basic/bounty_criminal/boss/juggernaut/brute = allocate(__IMPLIED_TYPE__) - brute.adjustBruteLoss(brute.maxHealth * 2) - TEST_ASSERT(brute.is_downed(), "A mini-boss was not downed") - TEST_ASSERT(brute.body_stand_up(), "A downed mini-boss did not get up") - TEST_ASSERT(brute.body_is_rallying(), "A mini-boss that got up is not rallying") - TEST_ASSERT_NOTNULL(brute.body_rally_timer, "A rallying mini-boss has no end to its rally") - var/stamina_before = brute.getStaminaLoss() - brute.adjustStaminaLoss(50) - TEST_ASSERT_EQUAL(brute.getStaminaLoss(), stamina_before, "A rallying mini-boss took stamina damage") - brute.body_end_rally() - TEST_ASSERT(!brute.body_is_rallying(), "A mini-boss's rally did not end") - - // BOUNTY_RECOVER_TIME_MEEK, _NORMAL, _BOSS (60 SECONDS, 75 SECONDS, 45 SECONDS) - var/mob/living/basic/bounty_criminal/meek/runner = allocate(__IMPLIED_TYPE__) - TEST_ASSERT_EQUAL(runner.body_recovery_time(), 600, "A meek criminal's recovery time is wrong") - TEST_ASSERT_EQUAL(other.body_recovery_time(), 750, "A normal criminal's recovery time is wrong") - TEST_ASSERT_EQUAL(brute.body_recovery_time(), 450, "A mini-boss's recovery time is wrong") - -// ===== WORST STATE AND THE STATE ORDER ===== +/datum/unit_test/voidcrew_bounty_criminal_body/Run() + for(var/criminal_type in list(/mob/living/carbon/human/npc/bounty_criminal/meek, /mob/living/carbon/human/npc/bounty_criminal/normal, /mob/living/carbon/human/npc/bounty_criminal/decoy)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = allocate(criminal_type) + TEST_ASSERT(ishuman(criminal), "[criminal_type] is not a human") + TEST_ASSERT_EQUAL(criminal.maxHealth, 100, "[criminal_type] has a stand-in health pool") + TEST_ASSERT_EQUAL(criminal.max_stamina, 120, "[criminal_type] has a stand-in stamina pool") + TEST_ASSERT(!HAS_TRAIT(criminal, TRAIT_NOFIRE) && !HAS_TRAIT(criminal, TRAIT_RESISTCOLD) && !HAS_TRAIT(criminal, TRAIT_RESISTLOWPRESSURE), "[criminal_type] is immune to fire, cold or vacuum") + TEST_ASSERT(!HAS_TRAIT(criminal, TRAIT_GODMODE), "[criminal_type] starts in godmode") + TEST_ASSERT_EQUAL(criminal.ai_controller?.blackboard[BB_TARGET_MINIMUM_STAT], CONSCIOUS, "[criminal_type] would attack someone in crit") // HUMAN_NPC_SPARES_DOWNED + + // tg crit: a hit that takes it past 0 health puts it in soft crit, nothing holds it up. + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) + criminal.adjustBruteLoss(90) + TEST_ASSERT_EQUAL(criminal.stat, CONSCIOUS, "90 brute put a criminal down") + criminal.adjustBruteLoss(20) + TEST_ASSERT(HAS_TRAIT(criminal, TRAIT_CRITICAL_CONDITION), "A criminal at -10 health is not in crit") + TEST_ASSERT_EQUAL(criminal.stat, SOFT_CRIT, "A criminal at -10 health is not in soft crit") + TEST_ASSERT(criminal.health < 0, "A hit on a criminal was clipped to keep it standing") + + // Healed out of crit, it stands the tg way; nothing else heals it. + criminal.adjustBruteLoss(-30) + TEST_ASSERT_EQUAL(criminal.stat, CONSCIOUS, "A criminal healed out of crit is still down") + TEST_ASSERT_EQUAL(criminal.health, 20, "Something besides the heal changed the criminal's health") + + // It dies, and can be brought back like anyone. + criminal.death() + TEST_ASSERT_EQUAL(criminal.stat, DEAD, "The criminal did not die") + TEST_ASSERT(criminal.can_be_revived(), "A dead criminal can't be revived") + criminal.revive(HEAL_ALL) + TEST_ASSERT_EQUAL(criminal.stat, CONSCIOUS, "A revived criminal is still dead") + + // Stamina is a person's: 120 points, tg's stamcrit. + var/mob/living/carbon/human/npc/bounty_criminal/meek/runner = allocate(__IMPLIED_TYPE__) + runner.adjustStaminaLoss(100) + TEST_ASSERT(!runner.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "100 stamina damage stamcritted a meek criminal") + runner.adjustStaminaLoss(30) + TEST_ASSERT(runner.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "130 stamina damage did not stamcrit a meek criminal") + + // Anyone can grab a free one; it isn't too heavy to move. + var/mob/living/carbon/human/npc/bounty_criminal/normal/free = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/consistent/grabber = allocate(__IMPLIED_TYPE__, get_step(run_loc_floor_bottom_left, EAST)) + grabber.start_pulling(free) + TEST_ASSERT_EQUAL(grabber.pulling, free, "A free criminal could not be grabbed") + grabber.stop_pulling() + + // Its kit is locked to it and not loot (D3), its toughness is its tier's (npc_damage_taken), never its health. + var/datum/bounty_record/wanted = new + wanted.archetype = "normal" // BOUNTY_ARCHETYPE_NORMAL + wanted.tier = 2 // BOUNTY_TIER_WANTED + var/mob/living/carbon/human/npc/bounty_criminal/normal/tough = spawn_bounty_criminal(wanted, run_loc_floor_bottom_left, null) + TEST_ASSERT_EQUAL(tough.maxHealth, 100, "A Wanted criminal has more health than a person") + TEST_ASSERT_EQUAL(tough.npc_damage_taken, 0.9, "A Wanted criminal takes the wrong damage") // BOUNTY_TOUGHNESS_WANTED + TEST_ASSERT(istype(tough.wear_suit, /obj/item/clothing/suit/armor/vest), "A Wanted criminal wears no armour vest") + TEST_ASSERT(human_npc_gear_locked(tough.wear_suit), "A Wanted criminal's vest is not locked to it") + qdel(tough) + +// ===== CAPTURE STATE ===== /datum/unit_test/voidcrew_bounty_criminal_states parent_type = /datum/unit_test/voidcrew_bounty_criminal /datum/unit_test/voidcrew_bounty_criminal_states/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/consistent/hunter = allocate(__IMPLIED_TYPE__, get_step(run_loc_floor_bottom_left, NORTH)) + listen_to(criminal) TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A fresh criminal is not free") // BOUNTY_STATE_FREE - TEST_ASSERT_EQUAL(criminal.worst_state, "free", "A fresh criminal's worst state is not free") - // Knocked down is stunned; the worst state holds after it stands. + // Knocked down, stunned, paralysed (*surrender is one), stamcritted or asleep is stunned; up again it is free. criminal.Knockdown(5 SECONDS) TEST_ASSERT_EQUAL(criminal.capture_state(), "stunned", "A knocked-down criminal is not stunned") // BOUNTY_STATE_STUNNED - TEST_ASSERT_EQUAL(criminal.worst_state, "stunned", "Being stunned did not reach the worst state") criminal.SetKnockdown(0) TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A criminal back on its feet is not free") - TEST_ASSERT_EQUAL(criminal.worst_state, "stunned", "The worst state got better") - - // Stamina crit and surrender are stunned too. - criminal.adjustStaminaLoss(300) + criminal.Paralyze(5 SECONDS) + TEST_ASSERT_EQUAL(criminal.capture_state(), "stunned", "A paralysed criminal is not stunned") + criminal.SetParalyzed(0) + criminal.adjustStaminaLoss(200) TEST_ASSERT_EQUAL(criminal.capture_state(), "stunned", "A criminal in stamina crit is not stunned") criminal.setStaminaLoss(0) + criminal.remove_status_effect(/datum/status_effect/incapacitating/stamcrit) TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A criminal out of stamina crit is not free") - ADD_TRAIT(criminal, "bounty_surrendered", "bounty") // TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT - TEST_ASSERT_EQUAL(criminal.capture_state(), "stunned", "A surrendered criminal is not stunned") - TEST_ASSERT(!criminal.may_attack(allocate(/mob/living/carbon/human/consistent)), "A surrendered criminal may still attack") - - // Restrained comes before stunned. - TEST_ASSERT(criminal.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs)), "A surrendered criminal could not be cuffed") - TEST_ASSERT_EQUAL(criminal.capture_state(), "restrained", "A cuffed, surrendered criminal is not restrained") // BOUNTY_STATE_RESTRAINED - REMOVE_TRAIT(criminal, "bounty_surrendered", "bounty") - TEST_ASSERT_EQUAL(criminal.capture_state(), "restrained", "A cuffed criminal is not restrained") - - // Downed comes before restrained, and once downed the worst state never goes back up. - criminal.adjustBruteLoss(500) - TEST_ASSERT_EQUAL(criminal.capture_state(), "downed", "A cuffed, downed criminal is not downed") - TEST_ASSERT_EQUAL(criminal.worst_state, "downed", "Being downed did not reach the worst state") - criminal.body_remove_cuffs() - criminal.body_stand_up() - TEST_ASSERT(!criminal.is_downed(), "The criminal did not get up") - TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A criminal that got up is not free") - TEST_ASSERT_EQUAL(criminal.worst_state, "downed", "The worst state got better after getting up") - criminal.Knockdown(5 SECONDS) - TEST_ASSERT(criminal.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs)), "The criminal could not be cuffed again") - TEST_ASSERT_EQUAL(criminal.worst_state, "downed", "Cuffing after a down improved the worst state") - - // Dead comes before everything, and the cuffs drop. + criminal.Sleeping(5 SECONDS) + TEST_ASSERT_EQUAL(criminal.capture_state(), "stunned", "A sleeping criminal is not stunned") + criminal.SetSleeping(0) + TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A criminal woken up is not free") + + // Cuffed with real tg handcuffs is restrained; the restraint signal names the cuffer it last reached for. + criminal.body_cuffer_ref = WEAKREF(hunter) + criminal.body_cuffed_at = world.time + TEST_ASSERT_NOTNULL(cuff(criminal, hunter), "Real handcuffs did not go on a criminal") + TEST_ASSERT_EQUAL(criminal.capture_state(), "restrained", "A cuffed criminal is not restrained") // BOUNTY_STATE_RESTRAINED + TEST_ASSERT_EQUAL(signal_count("restrained"), 1, "Cuffing sent [signal_count("restrained")] restraint signals") + TEST_ASSERT_EQUAL(last_restraint_user(), hunter, "The restraint signal didn't name the cuffer") + TEST_ASSERT(!criminal.may_attack(hunter), "A cuffed criminal may attack") + + // In crit comes before cuffed, and getting out of crit is the tg way: healing. + criminal.adjustBruteLoss(110) + TEST_ASSERT_EQUAL(criminal.capture_state(), "downed", "A cuffed criminal in crit is not downed") // BOUNTY_STATE_DOWNED + TEST_ASSERT_EQUAL(signal_count("downed"), 1, "Going into crit sent [signal_count("downed")] downed signals") + criminal.adjustBruteLoss(-50) + TEST_ASSERT_EQUAL(criminal.capture_state(), "restrained", "A cuffed criminal healed out of crit is not restrained") + TEST_ASSERT_EQUAL(signal_count("recovered"), 1, "Leaving crit sent [signal_count("recovered")] recovered signals") + + // Uncuffed by someone else: the signal carries a non-null user (it didn't free itself). + criminal.uncuff() + TEST_ASSERT_EQUAL(signal_count("unrestrained"), 1, "Uncuffing sent [signal_count("unrestrained")] signals") + TEST_ASSERT_NOTNULL(last_restraint_user(), "Taken out of its cuffs by someone, it counted as freeing itself") + TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "An uncuffed criminal is not free") + // Worked out of them itself (tg's cuff_resist() started): the signal carries null. + cuff(criminal, hunter) + SEND_SIGNAL(criminal, COMSIG_MOB_REMOVING_CUFFS, criminal.handcuffed) + criminal.uncuff() + TEST_ASSERT_NULL(last_restraint_user(), "A criminal that worked its cuffs off didn't count as freeing itself") + + // Dead comes before everything. criminal.death() - TEST_ASSERT_EQUAL(criminal.capture_state(), "dead", "A dead criminal is not dead") - TEST_ASSERT_EQUAL(criminal.worst_state, "dead", "Dying did not reach the worst state") - TEST_ASSERT(!criminal.is_restrained(), "A dead criminal kept its cuffs") - TEST_ASSERT_NOTNULL(locate(/obj/item/restraints/handcuffs) in criminal.loc, "A dead criminal's cuffs did not drop") + TEST_ASSERT_EQUAL(criminal.capture_state(), "dead", "A dead criminal is not dead") // BOUNTY_STATE_DEAD TEST_ASSERT(!criminal.is_downed(), "A dead criminal counts as downed") - // A copy respawned after its site reloaded keeps the old worst state, and it never gets better (M4). - var/mob/living/basic/bounty_criminal/normal/respawned = allocate(__IMPLIED_TYPE__) - TEST_ASSERT_EQUAL(respawned.body_restore_worst_state("downed"), "downed", "A respawned criminal did not take its old worst state") - TEST_ASSERT_EQUAL(respawned.body_restore_worst_state("stunned"), "downed", "Restoring a better state improved the worst state") - TEST_ASSERT_EQUAL(respawned.body_restore_worst_state("free"), "downed", "Restoring free improved the worst state") - TEST_ASSERT_EQUAL(respawned.body_restore_worst_state(null), "downed", "Restoring nothing changed the worst state") - respawned.Knockdown(5 SECONDS) - TEST_ASSERT_EQUAL(respawned.worst_state, "downed", "Stunning a respawned criminal improved its worst state") - TEST_ASSERT_EQUAL(respawned.body_restore_worst_state("dead"), "dead", "Restoring dead did not make the worst state dead") + // Revived and brought in alive, it is judged by its condition then, not by having died. + criminal.revive(HEAL_ALL) + TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A revived criminal is not free") + cuff(criminal, hunter) + TEST_ASSERT_EQUAL(criminal.capture_state(), "restrained", "A revived, cuffed criminal is not restrained") -// ===== RESTRAINTS ===== +// ===== CUFFS ===== +/// A reach for a free criminal's wrists: it steps away and runs or fights. Paralysed, stamcritted or in crit, it can't. /datum/unit_test/voidcrew_bounty_criminal_cuffs parent_type = /datum/unit_test/voidcrew_bounty_criminal /datum/unit_test/voidcrew_bounty_criminal_cuffs/Run() - var/mob/living/carbon/human/consistent/hunter = allocate(__IMPLIED_TYPE__, get_step(run_loc_floor_bottom_left, EAST)) - for(var/cuff_type in list(/obj/item/restraints/handcuffs, /obj/item/restraints/handcuffs/cable, /obj/item/restraints/handcuffs/cable/zipties)) - signal_counts = null - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) - RegisterSignal(criminal, "bounty_criminal_restrained", TYPE_PROC_REF(/datum/unit_test/voidcrew_bounty_criminal, on_restrained)) // COMSIG_BOUNTY_CRIMINAL_RESTRAINED - RegisterSignal(criminal, "bounty_criminal_unrestrained", TYPE_PROC_REF(/datum/unit_test/voidcrew_bounty_criminal, on_unrestrained)) // COMSIG_BOUNTY_CRIMINAL_UNRESTRAINED - var/obj/item/restraints/handcuffs/cuffs = allocate(cuff_type) - - // On its feet and free: refused, and it holds a grudge against whoever tried. - TEST_ASSERT_EQUAL(criminal.body_cuff_refusal(hunter, cuffs), "[criminal.p_they()] pull[criminal.p_s()] away!", "[cuff_type] could go on a criminal standing free") - TEST_ASSERT_EQUAL(criminal.item_interaction(hunter, cuffs), ITEM_INTERACT_BLOCKING, "Using [cuff_type] on a criminal standing free was not refused") - TEST_ASSERT(criminal.body_has_grudge(hunter), "Reaching for a free criminal's wrists did not put the hunter on its grudge list") - TEST_ASSERT(!criminal.is_restrained(), "A refused cuffing restrained the criminal") - - // Knocked down: they go on, and hold it. - criminal.Knockdown(5 SECONDS) - TEST_ASSERT_NULL(criminal.body_cuff_refusal(hunter, cuffs), "[cuff_type] could not go on a knocked-down criminal") - TEST_ASSERT(criminal.body_apply_cuffs(cuffs), "[cuff_type] did not go on") - TEST_ASSERT(criminal.is_restrained(), "[cuff_type] went on but the criminal is not restrained") - TEST_ASSERT_EQUAL(criminal.restraints.loc, criminal, "[cuff_type] are not in the criminal's contents") - if(ispath(cuff_type, /obj/item/restraints/handcuffs/cable/zipties)) - TEST_ASSERT(istype(criminal.restraints, /obj/item/restraints/handcuffs/cable/zipties/used), "Zipties went on as [criminal.restraints.type], not a used pair") - TEST_ASSERT_EQUAL(signal_count("restrained"), 1, "Cuffing with [cuff_type] did not send the restrained signal once") - TEST_ASSERT(HAS_TRAIT(criminal, TRAIT_IMMOBILIZED), "A restrained criminal can move") - TEST_ASSERT(HAS_TRAIT(criminal, "bounty_held"), "A restrained criminal's AI may still plan") // TRAIT_BOUNTY_HELD - TEST_ASSERT(HAS_TRAIT(criminal, TRAIT_AI_PAUSED), "A restrained criminal's AI is not paused") - criminal.SetKnockdown(0) - TEST_ASSERT_EQUAL(criminal.capture_state(), "restrained", "A criminal in [cuff_type] is not restrained") // BOUNTY_STATE_RESTRAINED - TEST_ASSERT(!criminal.may_attack(hunter), "A restrained criminal may still attack") - TEST_ASSERT(!criminal.early_melee_attack(hunter), "A restrained criminal threw a punch") - TEST_ASSERT_EQUAL(criminal.body_cuff_refusal(hunter, allocate(/obj/item/restraints/handcuffs)), "already cuffed", "A restrained criminal could be cuffed twice") - TEST_ASSERT(findtext(examine_text(criminal, hunter), "restrained with"), "Examining a restrained criminal does not say so") - - // Off again: they drop, and it is free. - var/obj/item/restraints/handcuffs/dropped = criminal.body_remove_cuffs() - TEST_ASSERT(!criminal.is_restrained(), "Taking [cuff_type] off left the criminal restrained") - TEST_ASSERT_EQUAL(dropped?.loc, criminal.loc, "[cuff_type] did not drop where the criminal is") - TEST_ASSERT(!HAS_TRAIT(criminal, TRAIT_IMMOBILIZED), "A criminal out of [cuff_type] still can't move") - TEST_ASSERT(!HAS_TRAIT(criminal, "bounty_held"), "A criminal out of [cuff_type] is still held") - TEST_ASSERT_EQUAL(signal_count("unrestrained"), 1, "Taking [cuff_type] off did not send the unrestrained signal once") - TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A criminal out of [cuff_type] is not free") - - // Cuffs taken out of it some other way still free it. - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) - criminal.Knockdown(5 SECONDS) - TEST_ASSERT(criminal.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs)), "The criminal could not be cuffed") - criminal.restraints.forceMove(run_loc_floor_bottom_left) - TEST_ASSERT_NULL(criminal.restraints, "Cuffs moved out of a criminal are still its restraints") - TEST_ASSERT(!HAS_TRAIT(criminal, TRAIT_IMMOBILIZED), "A criminal whose cuffs were taken still can't move") - - // Used zipties are no use; a dead criminal takes no cuffs. - TEST_ASSERT_EQUAL(criminal.body_cuff_refusal(hunter, allocate(/obj/item/restraints/handcuffs/cable/zipties/used)), "used up", "Used zipties could go on") - criminal.death() - TEST_ASSERT_EQUAL(criminal.body_cuff_refusal(hunter, allocate(/obj/item/restraints/handcuffs)), "can't cuff [criminal.p_them()]", "A dead criminal could be cuffed") - - // A surrendered one can be cuffed on its feet. - var/mob/living/basic/bounty_criminal/normal/quitter = allocate(__IMPLIED_TYPE__) - ADD_TRAIT(quitter, "bounty_surrendered", "bounty") // TRAIT_BOUNTY_SURRENDERED, BOUNTY_TRAIT - TEST_ASSERT_NULL(quitter.body_cuff_refusal(hunter, allocate(/obj/item/restraints/handcuffs)), "A surrendered criminal could not be cuffed") - - // Joke zipties stay joke zipties once worn, and slip like them (L5; BOUNTY_FAKE_CUFF_SLIP 5 SECONDS). - TEST_ASSERT(quitter.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs/cable/zipties/fake)), "Fake zipties did not go on") - TEST_ASSERT(istype(quitter.restraints, /obj/item/restraints/handcuffs/cable/zipties/fake/used), "Fake zipties went on as [quitter.restraints?.type]") - TEST_ASSERT_EQUAL(quitter.body_slip_time(), 50, "Fake zipties hold as long as real ones") - TEST_ASSERT_EQUAL(quitter.body_cuff_refusal(hunter, allocate(/obj/item/restraints/handcuffs/cable/zipties/fake/used)), "already cuffed", "Used fake zipties were not refused") - quitter.body_remove_cuffs() - TEST_ASSERT_EQUAL(quitter.body_cuff_refusal(hunter, allocate(/obj/item/restraints/handcuffs/cable/zipties/fake/used)), "used up", "Used fake zipties could go on") - - // A fulton never goes on a criminal (M2). - TEST_ASSERT_EQUAL(quitter.item_interaction(hunter, allocate(/obj/item/extraction_pack)), ITEM_INTERACT_BLOCKING, "A fulton could go on a criminal") - -// ===== SLIPPING ===== - -/datum/unit_test/voidcrew_bounty_criminal_slipping - parent_type = /datum/unit_test/voidcrew_bounty_criminal - -/datum/unit_test/voidcrew_bounty_criminal_slipping/Run() - // Cable restraints slip (BOUNTY_CABLE_SLIP 3 MINUTES), with a struggle first. - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) - criminal.Knockdown(5 SECONDS) - TEST_ASSERT(criminal.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs/cable)), "Cable restraints did not go on") - TEST_ASSERT_EQUAL(criminal.body_slip_time(), 1800, "Cable restraints slip at the wrong time") - TEST_ASSERT_NOTNULL(criminal.body_slip_timer, "Cable restraints have no slip clock") - TEST_ASSERT_NOTNULL(criminal.body_slip_warn_timer, "Cable restraints have no struggle before they slip") - criminal.body_slip_warning() - TEST_ASSERT(criminal.is_restrained(), "The struggle took the cable restraints off") - TEST_ASSERT(criminal.body_slip_cuffs(), "The criminal did not slip its cable restraints") - TEST_ASSERT(!criminal.is_restrained(), "A criminal that slipped its restraints is still restrained") - TEST_ASSERT_NOTNULL(locate(/obj/item/restraints/handcuffs/cable) in criminal.loc, "Slipped cable restraints did not drop") - TEST_ASSERT_NULL(criminal.body_slip_timer, "A slip clock is left after slipping") - - // Zipties hold longer (BOUNTY_ZIPTIE_SLIP 5 MINUTES); handcuffs never slip. - var/mob/living/basic/bounty_criminal/normal/tied = allocate(__IMPLIED_TYPE__) - tied.Knockdown(5 SECONDS) - TEST_ASSERT(tied.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs/cable/zipties)), "Zipties did not go on") - TEST_ASSERT_EQUAL(tied.body_slip_time(), 3000, "Zipties slip at the wrong time") - var/mob/living/basic/bounty_criminal/normal/cuffed = allocate(__IMPLIED_TYPE__) - cuffed.Knockdown(5 SECONDS) - TEST_ASSERT(cuffed.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs)), "Handcuffs did not go on") - TEST_ASSERT_EQUAL(cuffed.body_slip_time(), 0, "Handcuffs slip") - TEST_ASSERT_NULL(cuffed.body_slip_timer, "Handcuffs have a slip clock") - - // A mini-boss snaps cable in half the time (BOUNTY_BOSS_SLIP_MULT 0.5), and the cable is gone. - var/mob/living/basic/bounty_criminal/boss/juggernaut/brute = allocate(__IMPLIED_TYPE__) - TEST_ASSERT(brute.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs/cable)), "Cable restraints did not go on a mini-boss") - TEST_ASSERT_EQUAL(brute.body_slip_time(), 900, "A mini-boss slips cable at the wrong time") - var/obj/item/restraints/handcuffs/cable/snapped = brute.restraints - TEST_ASSERT(brute.body_slip_cuffs(), "A mini-boss did not snap its cable restraints") - TEST_ASSERT(!brute.is_restrained(), "A mini-boss that snapped its restraints is still restrained") - TEST_ASSERT(QDELETED(snapped), "Snapped cable restraints dropped whole") - -// ===== DRAGGING ===== - -/datum/unit_test/voidcrew_bounty_criminal_drag - parent_type = /datum/unit_test/voidcrew_bounty_criminal - -/datum/unit_test/voidcrew_bounty_criminal_drag/Run() - var/mob/living/carbon/human/consistent/hunter = allocate(__IMPLIED_TYPE__, get_step(run_loc_floor_bottom_left, EAST)) - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) - - // Free and on its feet, it pulls away. - hunter.start_pulling(criminal) - TEST_ASSERT_NULL(hunter.pulling, "A free criminal on its feet could be pulled") - - // In stamina crit it can be dragged, and it shakes the hand off once it is up. - criminal.adjustStaminaLoss(300) - hunter.start_pulling(criminal) - TEST_ASSERT_EQUAL(hunter.pulling, criminal, "A criminal in stamina crit could not be pulled") + var/turf/here = run_loc_floor_bottom_left + var/turf/cuffer_spot = get_step(here, WEST) || get_step(here, SOUTH) || get_step(here, NORTH) + if(isnull(cuffer_spot) || cuffer_spot.is_blocked_turf()) + cuffer_spot = get_step(here, NORTH) + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__, get_step(here, NORTHEAST)) + var/turf/start = get_turf(criminal) + var/mob/living/carbon/human/consistent/cuffer = allocate(__IMPLIED_TYPE__, here) + + // Free and on its feet: it can answer, puts the cuffer on its grudge list and steps away. + TEST_ASSERT(criminal.body_can_answer_cuffs(), "A free criminal can't answer a reach for its wrists") + criminal.body_on_cuff_attempt(cuffer) + TEST_ASSERT(criminal.body_has_grudge(cuffer), "Reaching for a criminal's wrists didn't make it hold a grudge") + criminal.body_answer_cuffs(WEAKREF(cuffer)) + TEST_ASSERT(get_turf(criminal) != start, "A free criminal didn't step away from the cuffs") + TEST_ASSERT(get_dist(criminal, cuffer) >= get_dist(start, cuffer), "A free criminal stepped toward the cuffs") + + // Paralysed (as by *surrender), stamcritted or in crit, it can't do anything about them. + criminal.Paralyze(20 SECONDS) + TEST_ASSERT(!criminal.body_can_answer_cuffs(), "A paralysed criminal can still answer a reach for its wrists") + criminal.SetParalyzed(0) + criminal.adjustStaminaLoss(200) + TEST_ASSERT(!criminal.body_can_answer_cuffs(), "A stamcritted criminal can still answer a reach for its wrists") criminal.setStaminaLoss(0) - TEST_ASSERT_NULL(hunter.pulling, "A criminal back on its feet is still being pulled") - - // Restrained, it can be dragged on its feet. - criminal.Knockdown(5 SECONDS) - TEST_ASSERT(criminal.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs)), "The criminal could not be cuffed") - criminal.SetKnockdown(0) - hunter.start_pulling(criminal) - TEST_ASSERT_EQUAL(hunter.pulling, criminal, "A restrained criminal could not be pulled") - hunter.stop_pulling() - criminal.body_remove_cuffs() - - // Blending in, a stunned one still shakes off any hand; a downed one does not (AR-C7). - criminal.blended = TRUE - criminal.Knockdown(5 SECONDS) - hunter.start_pulling(criminal) - TEST_ASSERT_NULL(hunter.pulling, "A stunned criminal blending in could be pulled") - criminal.SetKnockdown(0) - criminal.adjustBruteLoss(500) - TEST_ASSERT(criminal.is_downed(), "The criminal was not downed") - hunter.start_pulling(criminal) - TEST_ASSERT_EQUAL(hunter.pulling, criminal, "A downed criminal blending in could not be pulled") - hunter.stop_pulling() - - // Dead, it can be dragged. - criminal.blended = FALSE - criminal.death() - hunter.start_pulling(criminal) - TEST_ASSERT_EQUAL(hunter.pulling, criminal, "A dead criminal could not be pulled") - hunter.stop_pulling() - -// ===== CARRYING ===== - -/// On the floor and draggable, it goes over a hunter's shoulders and comes along; it squirms off once it gets up or comes round -/datum/unit_test/voidcrew_bounty_criminal_carry - parent_type = /datum/unit_test/voidcrew_bounty_criminal - -/datum/unit_test/voidcrew_bounty_criminal_carry/Run() - var/turf/start = get_step(run_loc_floor_bottom_left, EAST) - var/mob/living/carbon/human/consistent/hunter = allocate(__IMPLIED_TYPE__, start) - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__, start) - - // On its feet, nobody lifts it. - TEST_ASSERT(!hunter.can_be_firemanned(criminal), "A criminal on its feet could be carried") - - // Down, it goes over the shoulders and comes along. - criminal.adjustBruteLoss(500) - TEST_ASSERT(criminal.is_downed(), "The criminal was not downed") - TEST_ASSERT(hunter.can_be_firemanned(criminal), "A downed criminal could not be carried") - TEST_ASSERT(hunter.buckle_mob(criminal, TRUE, TRUE, CARRIER_NEEDS_ARM), "A downed criminal could not be lifted") - TEST_ASSERT_EQUAL(criminal.buckled, hunter, "The downed criminal is not on the hunter's shoulders") - var/turf/ahead = get_step(start, NORTH) - hunter.Move(ahead, NORTH) - TEST_ASSERT_EQUAL(get_turf(hunter), ahead, "The hunter could not walk with the criminal on their shoulders") - TEST_ASSERT_EQUAL(get_turf(criminal), ahead, "The carried criminal did not come along") - TEST_ASSERT(criminal.is_downed(), "Being carried stood the criminal up") - - // Getting up, it squirms off. - TEST_ASSERT(criminal.body_stand_up(), "The criminal could not get up") - TEST_ASSERT_NULL(criminal.buckled, "A criminal that got up stayed on the hunter's shoulders") - TEST_ASSERT(!hunter.has_buckled_mobs(), "The hunter still carries a criminal that got up") - - // Knocked down, it can be carried until it comes round. - criminal.Knockdown(10 SECONDS) - TEST_ASSERT(hunter.can_be_firemanned(criminal), "A knocked down criminal could not be carried") - TEST_ASSERT(hunter.buckle_mob(criminal, TRUE, TRUE, CARRIER_NEEDS_ARM), "A knocked down criminal could not be lifted") - criminal.SetKnockdown(0) - TEST_ASSERT_NULL(criminal.buckled, "A criminal that came round stayed on the hunter's shoulders") - - // Dead, it can be carried. - criminal.death() - TEST_ASSERT(hunter.can_be_firemanned(criminal), "A dead criminal could not be carried") + criminal.remove_status_effect(/datum/status_effect/incapacitating/stamcrit) + criminal.adjustBruteLoss(110) + TEST_ASSERT(!criminal.body_can_answer_cuffs(), "A criminal in crit can still answer a reach for its wrists") + criminal.adjustBruteLoss(-110) + + // The cuff signal never refuses tg's cuffs. + TEST_ASSERT(!(SEND_SIGNAL(criminal, COMSIG_CARBON_CUFF_ATTEMPTED, cuffer) & COMSIG_CARBON_CUFF_PREVENT), "A criminal refused tg's cuffs") + TEST_ASSERT_EQUAL(criminal.body_cuffer_ref?.resolve(), cuffer, "The cuff signal didn't note the cuffer") + + // A fulton never goes on one. + var/obj/item/extraction_pack/fulton = allocate(__IMPLIED_TYPE__) + TEST_ASSERT_EQUAL(criminal.item_interaction(cuffer, fulton), ITEM_INTERACT_BLOCKING, "A fulton could go on a criminal") + + // A cuffed criminal's AI works at its cuffs, the player's way (tg's resist). + var/datum/ai_planning_subtree/bounty_escape_captivity/escape = new + cuff(criminal, cuffer) + escape.SelectBehaviors(criminal.ai_controller, 1) + TEST_ASSERT(locate(/datum/ai_behavior/resist) in criminal.ai_controller.planned_behaviors, "A cuffed criminal doesn't work at its cuffs") + criminal.ai_controller.CancelActions() + // ...but a criminal sitting down or hiding in a locker by choice doesn't fight its way out of the chair or the locker. + criminal.uncuff() + var/obj/structure/chair/seat = allocate(__IMPLIED_TYPE__, get_turf(criminal)) + seat.buckle_mob(criminal, force = TRUE) + escape.SelectBehaviors(criminal.ai_controller, 1) + TEST_ASSERT(!(locate(/datum/ai_behavior/resist) in criminal.ai_controller.planned_behaviors), "A criminal fights its way out of a chair it sat on") + seat.unbuckle_mob(criminal, force = TRUE) + var/obj/structure/closet/locker = allocate(__IMPLIED_TYPE__, get_turf(criminal)) + criminal.forceMove(locker) + criminal.hidden = TRUE + escape.SelectBehaviors(criminal.ai_controller, 1) + TEST_ASSERT(!(locate(/datum/ai_behavior/resist) in criminal.ai_controller.planned_behaviors), "A criminal hiding in a locker fights its way out of it") + criminal.hidden = FALSE + criminal.forceMove(get_turf(locker)) + qdel(escape) -// ===== DEATH ===== +// ===== DEATH AND PROOF ===== /datum/unit_test/voidcrew_bounty_criminal_death parent_type = /datum/unit_test/voidcrew_bounty_criminal /datum/unit_test/voidcrew_bounty_criminal_death/Run() - // No revive, not even an admin's. - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) - criminal.death() - TEST_ASSERT(!criminal.can_be_revived(), "A dead criminal can be revived") - criminal.revive(ADMIN_HEAL_ALL) - TEST_ASSERT_EQUAL(criminal.stat, DEAD, "A dead criminal came back") - // Its body is the proof: a death that leaves the body leaves no tag; gibbing the body leaves one. var/list/postings = list() var/datum/criminal_bounty/gib_posting = new postings += gib_posting - var/mob/living/basic/bounty_criminal/normal/gibbed = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/gibbed = allocate(__IMPLIED_TYPE__) gibbed.posting_ref = WEAKREF(gib_posting) gibbed.death() + TEST_ASSERT(!QDELETED(gibbed), "A dead criminal's body did not stay") + TEST_ASSERT(!(gibbed in SShuman_npcs.corpses), "A wanted criminal's body is on the corpse sweep") TEST_ASSERT(!gibbed.body_proof_dropped, "A dead criminal whose body stays asked for proof") TEST_ASSERT_NULL(proof_for(gib_posting), "A dead criminal whose body stays left a tag") gibbed.gib() @@ -508,7 +316,7 @@ // Gibbed alive, it dies first, and leaves a tag. var/datum/criminal_bounty/blast_posting = new postings += blast_posting - var/mob/living/basic/bounty_criminal/normal/blown_up = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/blown_up = allocate(__IMPLIED_TYPE__) blown_up.posting_ref = WEAKREF(blast_posting) blown_up.gib() TEST_ASSERT_NOTNULL(proof_for(blast_posting), "A criminal gibbed alive left no tag") @@ -517,14 +325,14 @@ var/obj/effect/abstract/chasm_storage/depths = allocate(__IMPLIED_TYPE__) var/datum/criminal_bounty/fall_posting = new postings += fall_posting - var/mob/living/basic/bounty_criminal/normal/faller = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/faller = allocate(__IMPLIED_TYPE__) faller.posting_ref = WEAKREF(fall_posting) faller.forceMove(depths) faller.death(TRUE) TEST_ASSERT_NOTNULL(proof_for(fall_posting), "A criminal that fell into a chasm left no tag") var/datum/criminal_bounty/corpse_posting = new postings += corpse_posting - var/mob/living/basic/bounty_criminal/normal/corpse = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/corpse = allocate(__IMPLIED_TYPE__) corpse.posting_ref = WEAKREF(corpse_posting) corpse.death() TEST_ASSERT_NULL(proof_for(corpse_posting), "A body on the floor left a tag") @@ -534,7 +342,7 @@ // Deleted alive (a despawning ship, a stray delete), it was never killed: no proof (M5). var/datum/criminal_bounty/vanish_posting = new postings += vanish_posting - var/mob/living/basic/bounty_criminal/normal/vanished = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/vanished = allocate(__IMPLIED_TYPE__) vanished.posting_ref = WEAKREF(vanish_posting) qdel(vanished) TEST_ASSERT(!vanished.body_proof_dropped, "A live criminal deleted asked for proof") @@ -543,13 +351,13 @@ // Removed on purpose, by P5's mark (the pad, closing, relisting, its site unloading) or ours: no proof. var/datum/criminal_bounty/pad_posting = new postings += pad_posting - var/mob/living/basic/bounty_criminal/normal/beamed = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/beamed = allocate(__IMPLIED_TYPE__) beamed.posting_ref = WEAKREF(pad_posting) beamed.death() ADD_TRAIT(beamed, "bounty_removed", "bounty") // TRAIT_BOUNTY_REMOVED, BOUNTY_TRAIT qdel(beamed) TEST_ASSERT_NULL(proof_for(pad_posting), "A criminal the pad removed left a tag") - var/mob/living/basic/bounty_criminal/normal/unloaded = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/unloaded = allocate(__IMPLIED_TYPE__) unloaded.posting_ref = WEAKREF(pad_posting) unloaded.death() unloaded.body_mark_removed() @@ -560,7 +368,7 @@ var/datum/criminal_bounty/closed_posting = new postings += closed_posting closed_posting.status = "closed" // BOUNTY_POSTING_CLOSED - var/mob/living/basic/bounty_criminal/normal/late = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/late = allocate(__IMPLIED_TYPE__) late.posting_ref = WEAKREF(closed_posting) late.gib() TEST_ASSERT(!late.body_proof_dropped, "A criminal gibbed after its posting closed asked for proof") @@ -572,116 +380,30 @@ posting.status = "closed" // BOUNTY_POSTING_CLOSED QDEL_LIST(postings) -// ===== THE AUTOMATED DAMAGE FLOOR ===== - -/datum/unit_test/voidcrew_bounty_criminal_automated - parent_type = /datum/unit_test/voidcrew_bounty_criminal - -/datum/unit_test/voidcrew_bounty_criminal_automated/Run() - var/turf/here = run_loc_floor_bottom_left - var/list/site = list(here.x - 2, here.y - 2, here.x + 4, here.y + 4, here.z) - - // An explosion with nobody near is automated too: it stops at the floor (BOUNTY_AUTOMATED_FLOOR 40: 72 of 180). - var/mob/living/basic/bounty_criminal/normal/unwatched = allocate(__IMPLIED_TYPE__) - unwatched.site_bounds = site - unwatched.ex_act(EXPLODE_HEAVY) - unwatched.ex_act(EXPLODE_HEAVY) - TEST_ASSERT_EQUAL(unwatched.health, 72, "An explosion nobody set took a criminal on its site past the floor") - - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) - criminal.site_bounds = site - var/datum/component/bounty_body/ledger = criminal.GetComponent(/datum/component/bounty_body) - TEST_ASSERT_NOTNULL(ledger, "A criminal has no damage ledger") - - // Nobody's mind behind it: it stops at the floor, on its feet. - criminal.adjustBruteLoss(500) - TEST_ASSERT_EQUAL(criminal.health, 72, "Automated damage took a criminal on its site past the floor") - TEST_ASSERT(!criminal.is_downed(), "Automated damage downed a criminal on its site") - - // A player's hit: only the mercy rule. - var/mob/living/carbon/human/consistent/hunter = allocate(__IMPLIED_TYPE__, get_step(here, EAST)) - hunter.mind_initialize() - ledger.note_attacker(hunter) - TEST_ASSERT(ledger.player_hit_now(), "A hunter with a mind did not count as a player") - TEST_ASSERT(criminal.body_has_grudge(hunter), "An attacker did not go on the grudge list") - criminal.adjustBruteLoss(500) - TEST_ASSERT_EQUAL(criminal.health, 44, "A player's hit on a criminal on its site did not stop at the downed line") - TEST_ASSERT(criminal.is_downed(), "A player's hit did not down the criminal") - - // Down on its site, nothing automated finishes it off. - ledger.player_hit_at = -1 - criminal.adjustBruteLoss(500) - TEST_ASSERT_EQUAL(criminal.stat, CONSCIOUS, "Automated damage killed a downed criminal on its site") - - // A shot is its firer's, and a mech's shot its pilot's, though relay_attackers only reports mobs (M1). - var/mob/living/basic/bounty_criminal/normal/target = allocate(__IMPLIED_TYPE__) - target.site_bounds = site - var/datum/component/bounty_body/target_ledger = target.GetComponent(/datum/component/bounty_body) - var/obj/projectile/bullet/shot = allocate(__IMPLIED_TYPE__) - shot.firer = hunter - SEND_SIGNAL(target, COMSIG_PROJECTILE_PREHIT, shot) - TEST_ASSERT(target_ledger.player_hit_now(), "A hunter's shot did not count as a player's") - target_ledger.player_hit_at = -1 - var/mob/living/carbon/human/consistent/pilot = allocate(__IMPLIED_TYPE__, get_step(get_step(here, EAST), EAST)) - pilot.mind_initialize() - var/obj/vehicle/sealed/mecha/ripley/mech = allocate(__IMPLIED_TYPE__, get_step(get_step(here, NORTH), NORTH)) - mech.add_occupant(pilot, VEHICLE_CONTROL_DRIVE) - shot.firer = mech - SEND_SIGNAL(target, COMSIG_PROJECTILE_PREHIT, shot) - TEST_ASSERT(target_ledger.player_hit_now(), "A mech's shot did not count as its pilot's") - TEST_ASSERT(target.body_has_grudge(pilot), "A mech's pilot did not go on the grudge list") - mech.remove_occupant(pilot) - target_ledger.player_hit_at = -1 - - // An explosion with someone near counts as theirs: only the mercy rule. Three heavy blasts (60 each) reach the line. - target.ex_act(EXPLODE_HEAVY) - target.ex_act(EXPLODE_HEAVY) - target.ex_act(EXPLODE_HEAVY) - TEST_ASSERT_EQUAL(target.health, 44, "An explosion beside a hunter was held at the automated floor") - TEST_ASSERT(target.is_downed(), "An explosion beside a hunter did not down the criminal") - - // Its own side's blows miss it while it is down. - var/mob/living/basic/bounty_criminal/normal/downed_off_site = allocate(__IMPLIED_TYPE__) - downed_off_site.adjustBruteLoss(500) - var/datum/component/bounty_body/other_ledger = downed_off_site.GetComponent(/datum/component/bounty_body) - other_ledger.note_attacker(allocate(/mob/living/basic/bounty_companion)) - downed_off_site.adjustBruteLoss(10) - TEST_ASSERT_EQUAL(downed_off_site.health, 44, "A companion's blow hurt a downed criminal") - - // Off its site, automated damage gets the mercy rule only. - var/mob/living/basic/bounty_criminal/normal/stray = allocate(__IMPLIED_TYPE__) - stray.site_bounds = list(here.x + 5, here.y + 5, here.x + 6, here.y + 6, here.z) - stray.adjustBruteLoss(500) - TEST_ASSERT_EQUAL(stray.health, 44, "Automated damage off its site did not stop at the downed line") - -// ===== THE LEASH AND WHO IT FIGHTS ===== +// ===== THE LEASH ===== /datum/unit_test/voidcrew_bounty_criminal_leash parent_type = /datum/unit_test/voidcrew_bounty_criminal /datum/unit_test/voidcrew_bounty_criminal_leash/Run() - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) var/turf/here = get_turf(criminal) var/turf/east = get_step(here, EAST) var/turf/north = get_step(here, NORTH) TEST_ASSERT(criminal.leash_ok(north), "A criminal with no site may not go somewhere") TEST_ASSERT(!criminal.leash_ok(null), "A criminal may go nowhere") - // Leashed to its tile and the one east. + // Leashed to its tile and the one east: its AI's steps keep to it. criminal.site_bounds = list(here.x, here.y, here.x + 1, here.y, here.z) TEST_ASSERT(criminal.leash_ok(east), "A tile on the site is off the leash") TEST_ASSERT(!criminal.leash_ok(north), "A tile off the site is on the leash") - criminal.Move(north, NORTH) - TEST_ASSERT_EQUAL(criminal.loc, here, "A criminal walked off its site") - criminal.Move(east, EAST) - TEST_ASSERT_EQUAL(criminal.loc, east, "A criminal could not walk on its site") - criminal.Move(here, WEST) - TEST_ASSERT_EQUAL(criminal.loc, here, "A criminal could not walk back") + TEST_ASSERT(!criminal.body_move_allowed(north), "A criminal's own step may leave its site") + TEST_ASSERT(criminal.body_move_allowed(east), "A criminal's own step may not cross its site") // On its site it may fight anyone on their feet, and never someone down. var/mob/living/carbon/human/consistent/hunter = allocate(__IMPLIED_TYPE__, north) TEST_ASSERT(criminal.may_attack(hunter), "A criminal on its site may not fight a hunter") - var/mob/living/basic/bounty_criminal/normal/friend = allocate(__IMPLIED_TYPE__, east) + var/mob/living/carbon/human/npc/bounty_criminal/normal/friend = allocate(__IMPLIED_TYPE__, east) TEST_ASSERT(!criminal.may_attack(friend), "A criminal may fight another criminal") TEST_ASSERT(!criminal.may_attack(criminal), "A criminal may fight itself") var/obj/structure/table/table = allocate(__IMPLIED_TYPE__, east) @@ -697,17 +419,14 @@ criminal.body_add_grudge(hunter) TEST_ASSERT(criminal.may_attack(hunter), "A criminal off its site may not fight someone it holds a grudge against") - // Off its site, it walks anywhere (back toward its site, say); dragged, it goes anywhere. - hunter.forceMove(get_step(north, NORTH)) - criminal.Move(north, NORTH) - TEST_ASSERT_EQUAL(criminal.loc, north, "A criminal already off its site could not move") + // Off its site, it may walk anywhere (back toward its site, say); its body is never held: dragged, it goes anywhere. + TEST_ASSERT(criminal.body_move_allowed(north), "A criminal already off its site could not move") criminal.site_bounds = list(here.x, here.y, here.x, here.y, here.z) - criminal.forceMove(here) - criminal.Knockdown(5 SECONDS) var/mob/living/carbon/human/consistent/dragger = allocate(__IMPLIED_TYPE__, east) dragger.start_pulling(criminal) - TEST_ASSERT_EQUAL(dragger.pulling, criminal, "A knocked-down criminal could not be pulled") + TEST_ASSERT_EQUAL(dragger.pulling, criminal, "A free criminal could not be pulled") TEST_ASSERT(criminal.body_move_allowed(north), "A dragged criminal was held to its leash") + dragger.stop_pulling() // No environment damage off its site, or near a dock strip. TEST_ASSERT(!criminal.environment_damage_allowed(north), "Environment damage allowed off its site") @@ -715,24 +434,11 @@ TEST_ASSERT(!criminal.environment_damage_allowed(here), "Environment damage allowed beside the dock strip") // Teleports, polymorph and type changes are refused. - var/mob/living/basic/bounty_criminal/meek/runner = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/meek/runner = allocate(__IMPLIED_TYPE__) TEST_ASSERT(!do_teleport(runner, east, no_effects = TRUE, forced = TRUE), "A criminal was teleported") TEST_ASSERT_EQUAL(runner.loc, here, "A criminal moved when teleported") TEST_ASSERT(SEND_SIGNAL(runner, COMSIG_LIVING_PRE_WABBAJACKED, null) & STOP_WABBAJACK, "A criminal could be polymorphed") TEST_ASSERT(SEND_SIGNAL(runner, COMSIG_PRE_MOB_CHANGED_TYPE) & COMPONENT_BLOCK_MOB_CHANGE, "A criminal could change type") - TEST_ASSERT_EQUAL(runner.sentience_type, 0, "A criminal can be given sentience") // BOUNTY_CRIMINAL_NO_SENTIENCE - - // A sprinting meek criminal ducks shots, but not straight after one got through (BOUNTY_MEEK_DODGE 50). - TEST_ASSERT_EQUAL(runner.body_dodge_chance(), 0, "A meek criminal ducks shots when not sprinting") - ADD_TRAIT(runner, "bounty_sprinting", "bounty") // TRAIT_BOUNTY_SPRINTING, BOUNTY_TRAIT - TEST_ASSERT_EQUAL(runner.body_dodge_chance(), 50, "A sprinting meek criminal does not duck shots") - runner.body_last_shot_at = world.time - TEST_ASSERT_EQUAL(runner.body_dodge_chance(), 0, "A meek criminal ducks the shot after one hit it") - runner.body_last_shot_at = -1 - runner.Knockdown(5 SECONDS) - TEST_ASSERT_EQUAL(runner.body_dodge_chance(), 0, "A knocked-down meek criminal ducks shots") - ADD_TRAIT(friend, "bounty_sprinting", "bounty") - TEST_ASSERT_EQUAL(friend.body_dodge_chance(), 0, "A normal criminal ducks shots") // ===== SHIPS, OUTPOSTS AND DOCK STRIPS INSIDE ITS SITE ===== @@ -753,42 +459,33 @@ var/list/site = list(here.x, here.y, here.x + 4, here.y + 4, here.z) // A ship or outpost inside the site's rectangle is not its site, and it doesn't walk onto one (H1). - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) criminal.site_bounds = site TEST_ASSERT(criminal.body_on_site(here), "Plain ground inside its site is not its site") TEST_ASSERT(!criminal.body_on_site(east), "A landed ship inside its site's rectangle counts as its site") TEST_ASSERT(!criminal.leash_ok(east), "A landed ship inside its site is on its leash") TEST_ASSERT(!criminal.leash_ok(north), "A player outpost inside its site is on its leash") - criminal.Move(east, EAST) - TEST_ASSERT_EQUAL(criminal.loc, here, "A criminal walked onto a ship inside its site") + TEST_ASSERT(!criminal.body_move_allowed(east), "A criminal may walk onto a ship inside its site") - // Aboard that ship it fights only its grudge list, and the automated floor doesn't hold it there. - var/mob/living/basic/bounty_criminal/normal/stowaway = allocate(__IMPLIED_TYPE__, north_east) + // Aboard that ship it fights only its grudge list. + var/mob/living/carbon/human/npc/bounty_criminal/normal/stowaway = allocate(__IMPLIED_TYPE__, north_east) stowaway.site_bounds = site var/mob/living/carbon/human/consistent/crew = allocate(__IMPLIED_TYPE__, two_north) TEST_ASSERT(!stowaway.may_attack(crew), "A criminal aboard a ship inside its site may fight anyone") stowaway.body_add_grudge(crew) TEST_ASSERT(stowaway.may_attack(crew), "A criminal aboard a ship may not fight someone it holds a grudge against") - stowaway.adjustBruteLoss(500) - TEST_ASSERT_EQUAL(stowaway.health, 44, "The automated floor held a criminal aboard a ship") qdel(stowaway) // Off its leash aboard ship A, it walks about that ship and back to its site, never on into // ship B or the player outpost (M3). criminal.forceMove(east) - criminal.Move(north_east, NORTH) - TEST_ASSERT_EQUAL(criminal.loc, north_east, "A criminal aboard a ship could not walk about it") - criminal.Move(north, WEST) - TEST_ASSERT_EQUAL(criminal.loc, north_east, "A criminal walked from a ship into a player outpost") - criminal.forceMove(east) - criminal.Move(far_east, EAST) - TEST_ASSERT_EQUAL(criminal.loc, east, "A criminal walked from one ship on into another") - criminal.Move(here, WEST) - TEST_ASSERT_EQUAL(criminal.loc, here, "A criminal aboard a ship could not walk back onto its site") + TEST_ASSERT(criminal.body_move_allowed(north_east), "A criminal aboard a ship could not walk about it") + TEST_ASSERT(!criminal.body_move_allowed(north), "A criminal may walk from a ship into a player outpost") + TEST_ASSERT(!criminal.body_move_allowed(far_east), "A criminal may walk from one ship on into another") + TEST_ASSERT(criminal.body_move_allowed(here), "A criminal aboard a ship could not walk back onto its site") // A scripted walk goes where it is sent. ADD_TRAIT(criminal, "bounty_scripted_move", "test") // TRAIT_BOUNTY_SCRIPTED_MOVE - criminal.Move(east, EAST) - TEST_ASSERT_EQUAL(criminal.loc, east, "A scripted walk was held to the leash") + TEST_ASSERT(criminal.body_move_allowed(far_east), "A scripted walk was held to the leash") REMOVE_TRAIT(criminal, "bounty_scripted_move", "test") criminal.forceMove(here) @@ -799,7 +496,7 @@ TEST_ASSERT(criminal.leash_ok(two_north), "Ground above the dock strip is off its leash") criminal.body_dock_top_y = null - // Whatever holds it can't be teleported, and lets go when it gets out (M2). + // Whatever holds it can't be teleported, and lets go when it gets out (M2). A carrier is a person, not a box. var/obj/structure/closet/locker = allocate(__IMPLIED_TYPE__, two_north) criminal.forceMove(locker) TEST_ASSERT(HAS_TRAIT(locker, TRAIT_NO_TELEPORT), "A locker holding a criminal can be teleported") @@ -815,24 +512,24 @@ /datum/unit_test/voidcrew_bounty_criminal_turrets/Run() var/turf/here = run_loc_floor_bottom_left - // Away from a trader outpost, a free criminal with an AI that hunts people is judged like any creature... - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) - new /datum/ai_controller/basic_controller/simple/simple_hostile(criminal) - TEST_ASSERT(istype(criminal.ai_controller, /datum/ai_controller/basic_controller/simple/simple_hostile), "The criminal did not take the hostile AI") - TEST_ASSERT(is_hostile_creature(criminal), "A free, hunting criminal away from a trader outpost is never a turret's") - // ...but no turret shoots one that is down, stunned or cuffed: a crew's turrets don't finish off its catch. + // Away from a trader outpost, no turret shoots a criminal in crit, stunned or cuffed (tg's states), + // so a crew's turrets don't finish off its catch; a free one is judged like any creature. + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) + TEST_ASSERT(!bounty_turret_ignores(criminal, FALSE), "Turrets leave a free criminal alone") criminal.Knockdown(5 SECONDS) + TEST_ASSERT(bounty_turret_ignores(criminal, FALSE), "A turret would shoot a stunned criminal") TEST_ASSERT(!is_hostile_creature(criminal), "A turret would shoot a stunned criminal") criminal.SetKnockdown(0) - TEST_ASSERT(criminal.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs)), "The criminal could not be cuffed") - TEST_ASSERT(!is_hostile_creature(criminal), "A turret would shoot a cuffed criminal") - criminal.body_remove_cuffs() + cuff(criminal) + TEST_ASSERT(bounty_turret_ignores(criminal, FALSE), "A turret would shoot a cuffed criminal") + criminal.uncuff() + criminal.adjustBruteLoss(110) + TEST_ASSERT(bounty_turret_ignores(criminal, FALSE), "A turret would shoot a criminal in crit") + criminal.adjustBruteLoss(-110) // At a trader outpost, criminals, decoys and companions are all left to the hunters. - var/mob/living/basic/bounty_criminal/decoy/decoy = allocate(__IMPLIED_TYPE__) - new /datum/ai_controller/basic_controller/simple/simple_hostile(decoy) - var/mob/living/basic/bounty_companion/companion = allocate(__IMPLIED_TYPE__) - new /datum/ai_controller/basic_controller/simple/simple_hostile(companion) + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_companion/companion = allocate(__IMPLIED_TYPE__) TEST_ASSERT(bounty_turret_ignores(criminal, TRUE), "Trader-outpost turrets would shoot a criminal") TEST_ASSERT(bounty_turret_ignores(decoy, TRUE), "Trader-outpost turrets would shoot a decoy") TEST_ASSERT(bounty_turret_ignores(companion, TRUE), "Trader-outpost turrets would shoot a companion") @@ -843,7 +540,7 @@ criminal.forceMove(concourse) TEST_ASSERT(!is_hostile_creature(criminal), "A turret in a trader outpost's concourse would shoot a free criminal") -// ===== SPAWNING, EXAMINE AND MAKING THEM WITH NO ARGUMENTS ===== +// ===== SPAWNING, OUTFITS AND EXAMINE ===== /datum/unit_test/voidcrew_bounty_criminal_spawn parent_type = /datum/unit_test/voidcrew_bounty_criminal @@ -852,87 +549,75 @@ var/mob/living/carbon/human/consistent/hunter = allocate(__IMPLIED_TYPE__, get_step(run_loc_floor_bottom_left, EAST)) // Every criminal type comes up and goes away with no arguments, and its body procs don't runtime. - for(var/criminal_type in typesof(/mob/living/basic/bounty_criminal)) - var/mob/living/basic/bounty_criminal/criminal = allocate(criminal_type) + for(var/criminal_type in typesof(/mob/living/carbon/human/npc/bounty_criminal)) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = allocate(criminal_type) TEST_ASSERT(!QDELETED(criminal), "[criminal_type] was deleted as it was made") TEST_ASSERT_EQUAL(criminal.capture_state(), "free", "A fresh [criminal_type] is not free") - TEST_ASSERT_EQUAL(criminal.max_stamina, 100, "[criminal_type] has the wrong stamina pool") // BOUNTY_CRIMINAL_MAX_STAMINA TEST_ASSERT(!criminal.may_attack(null), "[criminal_type] may attack nothing") TEST_ASSERT(criminal.leash_ok(get_turf(criminal)), "[criminal_type] with no site is off its leash where it stands") TEST_ASSERT(length(criminal.examine(hunter)), "Examining [criminal_type] shows nothing") TEST_ASSERT(!criminal.body_leaves_proof(), "[criminal_type] with no posting would leave proof") + TEST_ASSERT(!criminal.hv_can_execute(), "[criminal_type] can land an execution") // With no record: a normal criminal. var/list/spawned = list() - var/mob/living/basic/bounty_criminal/plain = spawn_bounty_criminal(null, run_loc_floor_bottom_left, null) + var/mob/living/carbon/human/npc/bounty_criminal/plain = spawn_bounty_criminal(null, run_loc_floor_bottom_left, null) spawned += plain - TEST_ASSERT(istype(plain, /mob/living/basic/bounty_criminal/normal), "A criminal with no record is [plain?.type]") + TEST_ASSERT(istype(plain, /mob/living/carbon/human/npc/bounty_criminal/normal), "A criminal with no record is [plain?.type]") TEST_ASSERT_NULL(spawn_bounty_criminal(null, null, null), "A criminal spawned nowhere") - // Meek: its name, its health, and the planet-sweep protection while wanted. + // Meek: its record, posting, name and face, and the planet-sweep protection while wanted. var/datum/bounty_record/meek_record = new meek_record.archetype = "meek" // BOUNTY_ARCHETYPE_MEEK meek_record.name = "Tess Harlow" meek_record.gender = FEMALE var/datum/criminal_bounty/posting = new - var/mob/living/basic/bounty_criminal/meek/runner = spawn_bounty_criminal(meek_record, run_loc_floor_bottom_left, posting) + var/mob/living/carbon/human/npc/bounty_criminal/meek/runner = spawn_bounty_criminal(meek_record, run_loc_floor_bottom_left, posting) spawned += runner TEST_ASSERT(istype(runner), "A meek record spawned [runner?.type]") TEST_ASSERT_EQUAL(runner.record, meek_record, "A spawned criminal lost its record") TEST_ASSERT_EQUAL(runner.posting(), posting, "A spawned criminal lost its posting") TEST_ASSERT_EQUAL(runner.real_name, "Tess Harlow", "A spawned criminal lost its name") - TEST_ASSERT_EQUAL(runner.gender, FEMALE, "A spawned criminal lost its gender") - TEST_ASSERT_EQUAL(runner.maxHealth, 120, "A meek criminal has the wrong health") // BOUNTY_MEEK_HEALTH + TEST_ASSERT_EQUAL(runner.npc_damage_taken, 1, "A meek criminal takes more or less than a person's damage") // BOUNTY_TOUGHNESS_PETTY TEST_ASSERT(HAS_TRAIT(runner, "mission_field_mob"), "A wanted criminal is not kept from the planet sweep") // TRAIT_MISSION_FIELD_MOB - // BOUNTY_MEEK_STAMINA 90 on a pool of 100: three 30-point disabler shots - runner.adjustStaminaLoss(30) - runner.adjustStaminaLoss(30) - TEST_ASSERT(!runner.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "Two disabler shots stamina-crit a meek criminal") - runner.adjustStaminaLoss(30) - TEST_ASSERT(runner.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "Three disabler shots did not stamina-crit a meek criminal") - - // Normal: health by tier and style (BOUNTY_NORMAL_HEALTH_PETTY 140 x BOUNTY_STYLE_HEALTH_BRAWLER 1.2), pace by style. - var/datum/bounty_record/brawler_record = new - brawler_record.archetype = "normal" // BOUNTY_ARCHETYPE_NORMAL - brawler_record.tier = 1 // BOUNTY_TIER_PETTY - brawler_record.style = "brawler" // BOUNTY_STYLE_BRAWLER - var/mob/living/basic/bounty_criminal/normal/brawler = spawn_bounty_criminal(brawler_record, run_loc_floor_bottom_left, null) - spawned += brawler - TEST_ASSERT_EQUAL(brawler.maxHealth, 168, "A petty brawler has the wrong health") - TEST_ASSERT_EQUAL(brawler.health, 168, "A petty brawler did not start at full health") - TEST_ASSERT_EQUAL(brawler.speed, 1.25, "A brawler has the wrong pace") // BOUNTY_BODY_NORMAL_SPEED_MELEE - TEST_ASSERT(!HAS_TRAIT(brawler, "mission_field_mob"), "A criminal with no posting is kept from the planet sweep") - var/datum/bounty_record/gunman_record = new - gunman_record.archetype = "normal" - gunman_record.tier = 3 // BOUNTY_TIER_MOST_WANTED - gunman_record.style = "pistol" // BOUNTY_STYLE_PISTOL - var/mob/living/basic/bounty_criminal/normal/gunman = spawn_bounty_criminal(gunman_record, run_loc_floor_bottom_left, null) - spawned += gunman - // BOUNTY_NORMAL_HEALTH_MOST 220 x BOUNTY_STYLE_HEALTH_PISTOL 0.9 - TEST_ASSERT_EQUAL(gunman.maxHealth, 198, "A Most Wanted gunman has the wrong health") - TEST_ASSERT_EQUAL(gunman.speed, 1.4, "A gunman has the wrong pace") // BOUNTY_BODY_NORMAL_SPEED_RANGED + + // Toughness by tier is armour and npc_damage_taken: Most Wanted wear a vest and a helmet. + var/datum/bounty_record/most_record = new + most_record.archetype = "normal" // BOUNTY_ARCHETYPE_NORMAL + most_record.tier = 3 // BOUNTY_TIER_MOST_WANTED + var/mob/living/carbon/human/npc/bounty_criminal/normal/most = spawn_bounty_criminal(most_record, run_loc_floor_bottom_left, null) + spawned += most + TEST_ASSERT_EQUAL(most.npc_damage_taken, 0.8, "A Most Wanted criminal takes the wrong damage") // BOUNTY_TOUGHNESS_MOST_WANTED + TEST_ASSERT(istype(most.wear_suit, /obj/item/clothing/suit/armor/vest), "A Most Wanted criminal wears no vest") + TEST_ASSERT(istype(most.head, /obj/item/clothing/head/helmet), "A Most Wanted criminal wears no helmet") + TEST_ASSERT(!HAS_TRAIT(most, "mission_field_mob"), "A criminal with no posting is kept from the planet sweep") + var/datum/bounty_record/petty_record = new + petty_record.archetype = "normal" + petty_record.tier = 1 // BOUNTY_TIER_PETTY + var/mob/living/carbon/human/npc/bounty_criminal/normal/petty = spawn_bounty_criminal(petty_record, run_loc_floor_bottom_left, null) + spawned += petty + TEST_ASSERT(!istype(petty.wear_suit, /obj/item/clothing/suit/armor), "A petty criminal wears armour") // Mini-bosses by kit; never at a trader outpost, where a normal criminal stands in. var/datum/bounty_record/boss_record = new boss_record.archetype = "boss" // BOUNTY_ARCHETYPE_BOSS boss_record.kit = "ghost" // BOUNTY_KIT_GHOST - TEST_ASSERT_EQUAL(bounty_criminal_type(boss_record, null), /mob/living/basic/bounty_criminal/boss/ghost, "A ghost record is the wrong type") + TEST_ASSERT_EQUAL(bounty_criminal_type(boss_record, null), /mob/living/carbon/human/npc/bounty_criminal/boss/ghost, "A ghost record is the wrong type") var/datum/criminal_bounty/outpost_posting = new outpost_posting.placement_kind = "trader_outpost" // BOUNTY_PLACEMENT_TRADER_OUTPOST - TEST_ASSERT_EQUAL(bounty_criminal_type(boss_record, outpost_posting), /mob/living/basic/bounty_criminal/normal, "A mini-boss was placed at a trader outpost") - - // Examine: how hurt, and that it is down, never whether it gets back up; blending in, one layout only. - var/mob/living/basic/bounty_criminal/normal/examined = allocate(__IMPLIED_TYPE__) - TEST_ASSERT(findtext(examine_text(examined, hunter), "unhurt"), "Examining an unhurt criminal does not say so") - examined.adjustBruteLoss(500) - var/downed_text = examine_text(examined, hunter) - var/down_line = "[examined.p_They()] [examined.p_are()] down." - TEST_ASSERT(findtextEx(downed_text, down_line), "Examining a downed criminal does not say it is down: [downed_text]") - TEST_ASSERT(!findtext(downed_text, "get back up") && !findtext(downed_text, "while cuffed"), "Examining a downed criminal explains its recovery: [downed_text]") + TEST_ASSERT_EQUAL(bounty_criminal_type(boss_record, outpost_posting), /mob/living/carbon/human/npc/bounty_criminal/normal, "A mini-boss was placed at a trader outpost") + + // Examine: its features, and that it gave up while it lies there; never a health line of its own. + var/mob/living/carbon/human/npc/bounty_criminal/normal/examined = allocate(__IMPLIED_TYPE__) + TEST_ASSERT(!findtext(examine_text(examined, hunter), "unhurt"), "Examining a criminal shows the old health line") + examined.npc_surrendering = TRUE + examined.Paralyze(5 SECONDS) + var/given_up = "[examined.p_They()] [examined.p_have()] given up." + TEST_ASSERT(findtextEx(examine_text(examined, hunter), given_up), "Examining a criminal that gave up doesn't say so") examined.blended = TRUE - TEST_ASSERT(!findtextEx(examine_text(examined, hunter), down_line), "Examining a criminal blending in shows more than a decoy's layout") + TEST_ASSERT(!findtextEx(examine_text(examined, hunter), given_up), "Examining a criminal blending in shows more than a decoy's layout") - for(var/mob/living/basic/bounty_criminal/criminal as anything in spawned) + for(var/mob/living/carbon/human/npc/bounty_criminal/criminal as anything in spawned) qdel(criminal) // Marked closed first: deleting an open posting runs close(), which deletes it again (P5) posting.status = "closed" // BOUNTY_POSTING_CLOSED @@ -940,12 +625,55 @@ qdel(posting) qdel(outpost_posting) +// ===== SELF-TREATMENT ===== + +/// Hurt and unthreatened, it uses a real item it carries on itself (N6): a medipen, or a bruise pack on its worst limb. +/datum/unit_test/voidcrew_bounty_criminal_treat_self + parent_type = /datum/unit_test/voidcrew_bounty_criminal + +/datum/unit_test/voidcrew_bounty_criminal_treat_self/Run() + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) + var/datum/ai_controller/controller = criminal.ai_controller + var/datum/ai_planning_subtree/bounty_treat_self/treat = new + // Only what the test gives it: whatever treatment its outfit carried goes, and it has pockets. + for(var/obj/item/carried as anything in criminal.get_all_contents_type(/obj/item)) + if(istype(carried, /obj/item/stack/medical) || istype(carried, /obj/item/reagent_containers/hypospray/medipen) || carried == criminal.l_store || carried == criminal.r_store) + criminal.temporarilyRemoveItemFromInventory(carried, force = TRUE) + qdel(carried) + if(!criminal.w_uniform) + criminal.equip_to_slot_or_del(new /obj/item/clothing/under/pants/jeans(criminal), ITEM_SLOT_ICLOTHING) + // Unhurt, nothing to do. + treat.SelectBehaviors(controller, 1) + TEST_ASSERT(!controller.blackboard_key_exists("human_npc_treat_item"), "An unhurt criminal treats itself") + // Hurt with a bruise pack in its pocket: it picks the pack, and its worst limb. + criminal.apply_damage(40, BRUTE, BODY_ZONE_L_ARM) + var/obj/item/stack/medical/bruise_pack/pack = new(criminal) + criminal.equip_to_slot_or_del(pack, ITEM_SLOT_LPOCKET) + TEST_ASSERT_EQUAL(bounty_ai_find_medical_stack(criminal), pack, "A hurt criminal can't find its bruise pack") + TEST_ASSERT_EQUAL(bounty_ai_worst_limb(criminal), BODY_ZONE_L_ARM, "The worst limb is not the hurt arm") + treat.SelectBehaviors(controller, 1) + TEST_ASSERT_EQUAL(controller.blackboard["human_npc_treat_item"], pack, "A hurt criminal didn't reach for its bruise pack") + controller.CancelActions() + controller.clear_blackboard_key("human_npc_treat_item") + // A medipen comes first; a stimulant pen is never a treatment. + var/obj/item/reagent_containers/hypospray/medipen/stimpack/stim = new(criminal) + criminal.equip_to_slot_or_del(stim, ITEM_SLOT_RPOCKET) + TEST_ASSERT_NULL(bounty_ai_find_medipen(criminal), "A stimulant pen counts as a treatment") + TEST_ASSERT_EQUAL(bounty_ai_find_injector(criminal, /obj/item/reagent_containers/hypospray/medipen/stimpack), stim, "The stimulant pen isn't found as an injector") + // With a target to fight, it doesn't stop to patch up. + var/mob/living/carbon/human/consistent/hunter = allocate(__IMPLIED_TYPE__, get_step(run_loc_floor_bottom_left, EAST)) + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) + controller.clear_blackboard_key("human_npc_next_treat") + treat.SelectBehaviors(controller, 1) + TEST_ASSERT(!controller.blackboard_key_exists("human_npc_treat_item"), "A criminal in a fight stops to treat itself") + qdel(treat) + // ===== MOMENT LINES ===== /** - * The capture's moment lines always land (BUG-1): going down and being cuffed are said past the bark - * cooldown, and once each, never a second time by the AI. No balloon says what the pay dropped to - * (2026-09-26: the world doesn't explain the rules). + * The capture's moment lines always land (BUG-1): going into crit and being cuffed are said past the + * bark cooldown, once each. No balloon says what the pay dropped to (2026-09-26: the world doesn't + * explain the rules). */ /datum/unit_test/voidcrew_bounty_criminal_moments parent_type = /datum/unit_test/voidcrew_bounty_criminal @@ -965,43 +693,26 @@ /datum/unit_test/voidcrew_bounty_criminal_moments/Run() var/list/lines = bounty_identity_strings("lines") - var/list/postings = list() var/datum/criminal_bounty/posting = new - postings += posting // With no record, they use the shared lines only. - var/mob/living/basic/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) + var/mob/living/carbon/human/npc/bounty_criminal/normal/criminal = allocate(__IMPLIED_TYPE__) criminal.posting_ref = WEAKREF(posting) RegisterSignal(criminal, COMSIG_MOB_SAY, PROC_REF(on_say)) var/datum/component/bounty_identity_voice/voice = criminal.LoadComponent(/datum/component/bounty_identity_voice) - // They just said something ("hurt"): the cooldown is running. + // They just said something: the cooldown is running. COOLDOWN_START(voice, line_cooldown, 8 SECONDS) // BOUNTY_SAY_COOLDOWN - criminal.adjustBruteLoss(500) - TEST_ASSERT(criminal.is_downed(), "The criminal did not go down") - TEST_ASSERT_EQUAL(heard_from(lines["downed"]["any"]), 1, "Going down inside the cooldown, they said [heard_from(lines["downed"]["any"])] downed lines, not 1") - // No balloon says what the catch is worth now: the receipt does, after (2026-09-26) + criminal.adjustBruteLoss(110) + TEST_ASSERT(criminal.is_downed(), "The criminal did not go into crit") + TEST_ASSERT_EQUAL(heard_from(lines["downed"]["any"]), 1, "Going into crit inside the cooldown, they said [heard_from(lines["downed"]["any"])] downed lines, not 1") TEST_ASSERT_NULL(criminal.body_last_alert, "Going down showed [criminal.body_last_alert] over them") - // Cuffed two seconds later, well inside the cooldown of the downed line - TEST_ASSERT(criminal.body_apply_cuffs(allocate(/obj/item/restraints/handcuffs)), "The downed criminal could not be cuffed") + // Cuffed a moment later, inside the cooldown of the downed line. + cuff(criminal) TEST_ASSERT_EQUAL(heard_from(lines["cuffed"]["any"]), 1, "Cuffed inside the cooldown, they said [heard_from(lines["cuffed"]["any"])] cuffed lines, not 1") // Killed: still no balloon about the pay. - criminal.adjustBruteLoss(500) - TEST_ASSERT_EQUAL(criminal.stat, DEAD, "A hit on a downed criminal did not kill it") + criminal.death() TEST_ASSERT_NULL(criminal.body_last_alert, "Dying showed [criminal.body_last_alert] over them") - // With the cooldown over, going down is still said once: the AI no longer says it too. - heard.Cut() - var/mob/living/basic/bounty_criminal/normal/twice = allocate(__IMPLIED_TYPE__) - twice.posting_ref = WEAKREF(posting) - RegisterSignal(twice, COMSIG_MOB_SAY, PROC_REF(on_say)) - twice.adjustBruteLoss(500) - TEST_ASSERT_EQUAL(heard_from(lines["downed"]["any"]), 1, "Going down, they said [heard_from(lines["downed"]["any"])] downed lines, not 1") - TEST_ASSERT(twice.body_stand_up(), "The downed criminal could not get up") - twice.adjustBruteLoss(500) - TEST_ASSERT(twice.is_downed(), "The criminal did not go down a second time") - TEST_ASSERT_NULL(twice.body_last_alert, "Going down showed [twice.body_last_alert] over them") - // Marked closed first: deleting an open posting runs close(), which deletes it again (P5) - for(var/datum/criminal_bounty/each_posting as anything in postings) - each_posting.status = "closed" // BOUNTY_POSTING_CLOSED - QDEL_LIST(postings) + posting.status = "closed" // BOUNTY_POSTING_CLOSED + qdel(posting) diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_identity.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_identity.dm index bac6750e9c8..adbd19969e3 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_identity.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_identity.dm @@ -475,7 +475,7 @@ // Each archetype speaks its own lines and the shared ones, never another's. for(var/archetype in list("meek", "normal", "boss")) // BOUNTY_ARCHETYPE_* - var/mob/living/basic/bounty_criminal/criminal = allocate(/mob/living/basic/bounty_criminal/normal) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = allocate(/mob/living/carbon/human/npc/bounty_criminal/normal) criminal.record = generate_bounty_record(archetype == "boss" ? 3 : 2, archetype, "planet") TEST_ASSERT_EQUAL(criminal.identity_voice(), archetype, "A [archetype] criminal doesn't talk like one") var/list/allowed = pool_of(lines, "hurt", archetype) @@ -487,14 +487,14 @@ TEST_ASSERT(own_heard, "A [archetype] criminal never used its own lines") // Blended in, a fugitive talks like its decoys; a decoy never names a crime, blended or not. - var/mob/living/basic/bounty_criminal/fugitive = allocate(/mob/living/basic/bounty_criminal/meek) + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = allocate(/mob/living/carbon/human/npc/bounty_criminal/meek) fugitive.record = generate_bounty_record(1, "meek", "trader_outpost") fugitive.blended = TRUE TEST_ASSERT_NULL(fugitive.identity_voice(), "A blended fugitive talks differently from its decoys") TEST_ASSERT_EQUAL(fugitive.identity_line_values()?["{crime}"], fugitive.record.crime, "A fugitive doesn't know its own crime") for(var/line in sample(fugitive, "hurt", 20)) TEST_ASSERT(line in lines["hurt"]["any"], "A blended fugitive said a voiced line: [line]") - var/mob/living/basic/bounty_criminal/decoy/decoy = allocate(/mob/living/basic/bounty_criminal/decoy) + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = allocate(/mob/living/carbon/human/npc/bounty_criminal/decoy) decoy.record = make_decoy_record(fugitive.record, 1) TEST_ASSERT_NULL(decoy.identity_voice(), "A decoy speaks in an archetype's voice") TEST_ASSERT_NULL(decoy.identity_line_values(), "A decoy fills in a crime") @@ -502,6 +502,6 @@ TEST_ASSERT(!findtext(line, decoy.record.crime) && !findtext(line, fugitive.record.crime), "A decoy owned up to a crime: [line]") // A Most Wanted criminal never tosses off its crime. - var/mob/living/basic/bounty_criminal/boss = allocate(/mob/living/basic/bounty_criminal/normal) + var/mob/living/carbon/human/npc/bounty_criminal/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/normal) boss.record = generate_bounty_record(3, "boss", "planet") TEST_ASSERT_NULL(boss.identity_line_values(), "A Most Wanted criminal fills in its crime") diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm index 38987d06347..f059255e699 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_kingpin.dm @@ -5,11 +5,13 @@ * Owner: P9 kingpin. Do not edit this file outside that package. Fork defines are included after * the tests, so a test uses the literal value with a comment naming the define. * - * The tests drive the kingpin's procs directly: the lounge is built in the test room (a sofa, the - * coffee table, a few goon posts), the talk is picked by option rather than through the radial, and - * the shootout's clock is moved by hand rather than waited out. Before and after each test the - * board's clock is stopped, every kingpin and goon is deleted (a posting that closes no longer takes - * him away), every posting is closed and SSbounty_kingpin forgets the round's kingpin. + * The kingpin and his goons are real humans (the human NPC base). The tests drive the kingpin's procs + * directly: the lounge is built in the test room (a sofa, the coffee table, a few goon posts), the + * talk is picked by option rather than through the radial, and the shootout's clock is moved by hand + * rather than waited out. Hurts are real damage (crit is tg's crit), cuffs are real cuffs in the cuff + * slot, shots are real trigger pulls of the guns in their hands. Before and after each test the + * board's clock is stopped, every kingpin and goon is deleted, every posting is closed and + * SSbounty_kingpin forgets the round's kingpin. */ // ===== FIXTURES ===== @@ -55,9 +57,9 @@ /// Deletes every kingpin in the world and his goons (with him goes his crew and the job he holds) /datum/unit_test/voidcrew_bounty_kingpin/proc/kingpin_test_clear_crews() for(var/datum/bounty_kingpin_crew/crew as anything in GLOB.bounty_kingpin_crews.Copy()) - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in crew.goons()) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in crew.goons()) qdel(goon) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = crew.kingpin() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = crew.kingpin() if(kingpin) qdel(kingpin) else @@ -96,9 +98,9 @@ /** * The lounge in the test room: the sofa at (2,0) facing north, the coffee table across (1-3,1), - * the kingpin on the sofa, and `goon_count` goons (up to 4) at the room's edges. The talk spot is - * (2,2). With `posting` he is wanted on it (on its record); with none he sits on no posting, on a - * record of his own, as the round starts him. + * the kingpin on the sofa, and `goon_count` goons (up to 4) at the room's edges, built at once. The + * talk spot is (2,2). With `posting` he is wanted on it (on its record); with none he sits on no + * posting, on a record of his own, as the round starts him. */ /datum/unit_test/voidcrew_bounty_kingpin/proc/kingpin_test_lounge(datum/criminal_bounty/kingpin/posting, goon_count = 2) var/turf/seat = kingpin_test_spot(2, 0) @@ -112,7 +114,7 @@ for(var/i in 1 to min(goon_count, length(spots))) posts += list(list(spots[i], weapons[i])) var/datum/bounty_record/record = posting ? posting.record : bounty_kingpin_make_record() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = bounty_kingpin_setup(seat, posts, record) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = bounty_kingpin_setup(seat, posts, record, TRUE) posting?.board_adopt_criminal(kingpin, null, null, null) return kingpin @@ -127,7 +129,7 @@ * Gives `kingpin` a job to hold: a plain mission standing in for his Drug Run, whose making depends on * the round's planets. He makes no other after it is taken. */ -/datum/unit_test/voidcrew_bounty_kingpin/proc/kingpin_test_job(mob/living/basic/bounty_criminal/kingpin/kingpin) +/datum/unit_test/voidcrew_bounty_kingpin/proc/kingpin_test_job(mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin) var/datum/mission/job = allocate(/datum/mission) kingpin.kingpin_crew.crew_job_retry_at = world.time + 1 HOURS kingpin.kingpin_crew.hold_job(job) @@ -140,6 +142,37 @@ count += voucher.amount return count +/// Puts `body` in tg's crit for real: brute past the crit line (soft crit, health -10 for a person) +/datum/unit_test/voidcrew_bounty_kingpin/proc/kingpin_test_down(mob/living/carbon/human/body) + body.adjustBruteLoss(body.maxHealth + 10) + return HAS_TRAIT(body, TRAIT_CRITICAL_CONDITION) + +/// Real handcuffs in `body`'s cuff slot. Returns TRUE if they went on. +/datum/unit_test/voidcrew_bounty_kingpin/proc/kingpin_test_cuff(mob/living/carbon/human/body) + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs, get_turf(body)) + return body.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED) && HAS_TRAIT(body, TRAIT_RESTRAINED) + +/// Live rounds in `gun`: its magazine (a revolver's cylinder holds its chamber), and a chambered round +/datum/unit_test/voidcrew_bounty_kingpin/proc/kingpin_test_live_rounds(obj/item/gun/ballistic/gun) + . = human_npc_live_rounds(gun.magazine) + if(gun.bolt_type != BOLT_TYPE_NO_BOLT && gun.chambered?.loaded_projectile) + .++ + +/// Runs `behavior_type` (a reload) on `npc`'s controller step by step, the waits between steps skipped. TRUE if it finished. +/datum/unit_test/voidcrew_bounty_kingpin/proc/kingpin_test_run_reload(mob/living/carbon/human/npc, behavior_type, steps = 40) + var/datum/ai_controller/controller = npc.ai_controller + var/datum/ai_behavior/reload = GET_AI_BEHAVIOR(behavior_type) + if(!reload.setup(controller)) + return FALSE + for(var/step in 1 to steps) + npc.next_click = 0 + npc.next_move = 0 + var/result = reload.perform(1, controller) + if(result & AI_BEHAVIOR_FAILED) + return FALSE + if(result & AI_BEHAVIOR_SUCCEEDED) + return TRUE + return FALSE /datum/unit_test/voidcrew_bounty_kingpin/New() . = ..() @@ -149,53 +182,55 @@ kingpin_test_end() return ..() -// ===== PAY ===== +// ===== PAY (CB-28) ===== -/// Wanted dead or alive: restrained, stunned or downed pays in full with the vouchers; dead pays 80% (spec 15) +/// Wanted dead or alive, on his condition at the pad: cuffed, stunned or in crit pays in full with the vouchers; dead pays 80% /datum/unit_test/voidcrew_bounty_kingpin/shares /datum/unit_test/voidcrew_bounty_kingpin/shares/Run() var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() // BOUNTY_KINGPIN_PAY_ALIVE 100, BOUNTY_KINGPIN_PAY_DEAD 80 - TEST_ASSERT_EQUAL(posting.board_share_for("dead", "dead"), 80, "A dead kingpin doesn't pay 80%") - TEST_ASSERT_EQUAL(posting.board_share_for("downed", "downed"), 100, "A downed kingpin doesn't pay in full") - TEST_ASSERT_EQUAL(posting.board_share_for("restrained", "downed"), 100, "A cuffed kingpin who was downed doesn't pay in full") - TEST_ASSERT_EQUAL(posting.board_share_for("restrained", "restrained"), 100, "A cuffed kingpin doesn't pay in full") - TEST_ASSERT_EQUAL(posting.board_share_for("stunned", "stunned"), 100, "A stunned kingpin doesn't pay in full") - TEST_ASSERT_EQUAL(posting.board_share_for("free", "free"), 0, "A kingpin standing free pays something") - // What the pad pays: full, downed (full) and dead (80% of 1000 cr and 2 vouchers at 1200: 2720) - var/list/full_pay = posting.board_payout(posting.board_share_for("restrained", "restrained")) - var/list/downed_pay = posting.board_payout(posting.board_share_for("downed", "downed")) - var/list/dead_pay = posting.board_payout(posting.board_share_for("dead", "dead")) + TEST_ASSERT_EQUAL(posting.board_share_for("dead"), 80, "A dead kingpin doesn't pay 80%") + TEST_ASSERT_EQUAL(posting.board_share_for("downed"), 100, "A kingpin in crit doesn't pay in full") + TEST_ASSERT_EQUAL(posting.board_share_for("restrained"), 100, "A cuffed kingpin doesn't pay in full") + TEST_ASSERT_EQUAL(posting.board_share_for("stunned"), 100, "A stunned kingpin doesn't pay in full") + TEST_ASSERT_EQUAL(posting.board_share_for("free"), 0, "A kingpin standing free pays something") + // What the pad pays: full, in crit (full) and dead (80% of 1000 cr and 2 vouchers at 1200: 2720) + var/list/full_pay = posting.board_payout(posting.board_share_for("restrained")) + var/list/downed_pay = posting.board_payout(posting.board_share_for("downed")) + var/list/dead_pay = posting.board_payout(posting.board_share_for("dead")) TEST_ASSERT_EQUAL(full_pay[1], 1000, "The full pay is wrong") - TEST_ASSERT_EQUAL(downed_pay[1], 1000, "The downed pay isn't full pay") + TEST_ASSERT_EQUAL(downed_pay[1], 1000, "The pay for a kingpin in crit isn't full pay") TEST_ASSERT_EQUAL(dead_pay[1], 2720, "The dead pay is wrong") TEST_ASSERT_EQUAL(posting.board_terms(), "Wanted dead or alive", "The card doesn't say dead or alive") posting.close("admin") - // Through the pad: downed, cuffed and dead, each on its own posting + // Through the pad, his real condition read at the press: in crit, cuffed, stunned and dead, each on its own posting var/turf/pad_turf = kingpin_test_spot(4, 4) var/obj/machinery/mission_pad/pad = allocate(/obj/machinery/mission_pad, pad_turf) var/obj/structure/overmap/ship/ship = kingpin_test_ship() var/list/cases = list( list("downed", 1000, 2), list("restrained", 1000, 2), + list("stunned", 1000, 2), list("dead", 2720, 0), ) for(var/list/pay_case as anything in cases) var/datum/criminal_bounty/kingpin/case_posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(case_posting, 0) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(case_posting, 0) kingpin.buckled?.unbuckle_mob(kingpin, force = TRUE) kingpin.forceMove(pad_turf) switch(pay_case[1]) if("downed") - kingpin.body_go_down() + TEST_ASSERT(kingpin_test_down(kingpin), "The kingpin didn't go into crit") if("restrained") - var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs, pad_turf) - TEST_ASSERT(kingpin.body_apply_cuffs(cuffs), "The kingpin couldn't be cuffed") + TEST_ASSERT(kingpin_test_cuff(kingpin), "The kingpin couldn't be cuffed") + if("stunned") + kingpin.Paralyze(20 SECONDS) if("dead") kingpin.death() var/state = kingpin.capture_state() + TEST_ASSERT_EQUAL(state, pay_case[1], "The kingpin reads [state] at the pad, not [pay_case[1]]") var/balance_before = ship.ship_account.account_balance var/vouchers_before = kingpin_test_vouchers(pad_turf) var/list/paid = case_posting.board_claim(ship, pad, kingpin, state) @@ -217,7 +252,7 @@ /datum/unit_test/voidcrew_bounty_kingpin/talk/Run() var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 1) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 1) var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew TEST_ASSERT_NOTNULL(crew, "The kingpin has no crew") TEST_ASSERT(istype(kingpin.buckled, /obj/structure/chair/sofa), "The kingpin isn't sitting on the sofa") @@ -263,12 +298,12 @@ TEST_ASSERT(!crew.is_hunter(taker), "The crew treats someone who works for him as a hunter") TEST_ASSERT(!crew.valid_target(taker), "The goons would shoot someone who works for him") - // The pad refuses him from that ship, even with him subdued on it + // The pad refuses him from that ship, even with him subdued on it (given up: tg's *surrender) var/turf/pad_turf = kingpin_test_spot(4, 4) var/obj/machinery/mission_pad/pad = allocate(/obj/machinery/mission_pad, pad_turf) kingpin.buckled?.unbuckle_mob(kingpin, force = TRUE) kingpin.forceMove(pad_turf) - kingpin.kingpin_surrender() + TEST_ASSERT(kingpin.npc_surrender(), "The kingpin wouldn't give up") TEST_ASSERT_EQUAL(kingpin.capture_state(), "stunned", "A kingpin who gave up doesn't read stunned") // BOUNTY_STATE_STUNNED TEST_ASSERT_EQUAL(posting.turn_in(pad, ship, taker), "your crew works for him", "The pad took the kingpin from the ship that works for him") TEST_ASSERT(posting.is_open() && !QDELETED(kingpin), "Refusing the bound ship touched the bounty") @@ -285,7 +320,7 @@ /datum/unit_test/voidcrew_bounty_kingpin/work_refusals /datum/unit_test/voidcrew_bounty_kingpin/work_refusals/Run() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 1) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 1) var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew // No real Drug Run is made here: the round's planets decide whether one can be crew.crew_job_retry_at = world.time + 1 HOURS @@ -329,20 +364,24 @@ // ===== THE SHOOTOUT STARTS ===== -/// A warrant gets his line and no fight; "We're here for you" starts it: the draw, the coffee table over, the hunter and their crew marked. An attack on a goon starts it too, a shove doesn't. +/// A warrant gets his line and no fight; "We're here for you" starts it: the draw (real guns into hands), the coffee table over, the hunter and their crew marked. An attack on a goon starts it too, a shove doesn't. /datum/unit_test/voidcrew_bounty_kingpin/confront /datum/unit_test/voidcrew_bounty_kingpin/confront/Run() var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 2) + 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/obj/structure/overmap/ship/ship = kingpin_test_ship() var/mob/living/carbon/human/hunter = kingpin_test_person(kingpin_test_spot(2, 2), ship) var/mob/living/carbon/human/shipmate = kingpin_test_person(kingpin_test_spot(3, 3), ship) var/mob/living/carbon/human/bystander = kingpin_test_person(kingpin_test_spot(1, 3), null) TEST_ASSERT(!posting.board_clock_held(), "A calm kingpin's clock was held") + // At ease, the guns are carried, not held + TEST_ASSERT_NULL(human_npc_held_gun(kingpin), "The kingpin sits with a gun in his hand") + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in crew.goons()) + TEST_ASSERT_NULL(human_npc_held_gun(goon), "[goon] stands at ease with a gun in hand") - // Shown a warrant or reached for: his line, and nothing more + // Shown a warrant: his line, and nothing more kingpin.kingpin_confronted(hunter) TEST_ASSERT_EQUAL(crew.crew_state, "calm", "A warrant started a shootout") // BOUNTY_KINGPIN_CALM TEST_ASSERT(!crew.is_hunter(hunter), "A warrant made someone a hunter") @@ -357,55 +396,67 @@ var/obj/structure/table/table = ref.resolve() TEST_ASSERT(table?.is_flipped, "He didn't kick the coffee table over") TEST_ASSERT_EQUAL(table.dir, NORTH, "The coffee table went over the wrong way") - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in crew.goons()) - TEST_ASSERT(goon.goon_armed, "[goon] didn't reach for their gun") + TEST_ASSERT(istype(human_npc_held_gun(kingpin), /obj/item/gun/ballistic/revolver), "The kingpin didn't draw his revolver") + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in crew.goons()) + TEST_ASSERT(istype(human_npc_held_gun(goon), /obj/item/gun/ballistic), "[goon] didn't draw a real gun") + TEST_ASSERT(goon.ai_controller.blackboard["bounty_kingpin_armed"], "[goon]'s AI doesn't know the guns are out") // BB_BOUNTY_KINGPIN_ARMED TEST_ASSERT(posting.board_clock_held(), "The clock runs during a shootout") TEST_ASSERT(!kingpin.kingpin_can_talk(), "He'll still talk mid-shootout") TEST_ASSERT_EQUAL(kingpin.kingpin_work_refusal(shipmate), "no_work_hostile", "The crew that came for him could still get his work") + // It's over: the guns go back where they came from, on the body + crew.stand_down() + TEST_ASSERT_NULL(human_npc_held_gun(kingpin), "The kingpin kept his revolver out after standing down") + TEST_ASSERT(locate(/obj/item/gun/ballistic/revolver) in kingpin.get_all_contents(), "The kingpin's revolver went somewhere off his body") posting.close("admin") // An attack on a goon starts it too; a shove does not kingpin_test_clear_lounge() var/datum/criminal_bounty/kingpin/second_posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/second_kingpin = kingpin_test_lounge(second_posting, 2) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/second_kingpin = kingpin_test_lounge(second_posting, 2) var/datum/bounty_kingpin_crew/second_crew = second_kingpin.kingpin_crew var/list/second_goons = second_crew.goons() - var/mob/living/basic/bounty_kingpin_goon/goon = second_goons[1] + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = second_goons[1] SEND_SIGNAL(goon, COMSIG_ATOM_WAS_ATTACKED, bystander, ATTACKER_SHOVING) TEST_ASSERT_EQUAL(second_crew.crew_state, "calm", "A shove started a shootout") SEND_SIGNAL(goon, COMSIG_ATOM_WAS_ATTACKED, bystander, ATTACKER_DAMAGING_ATTACK) TEST_ASSERT_EQUAL(second_crew.crew_state, "drawing", "Hitting a goon didn't start the shootout") TEST_ASSERT(second_crew.is_hunter(bystander), "The one who hit a goon isn't a hunter") -// ===== THE TELEGRAPH ===== +// ===== THE TELEGRAPH, AND REAL SHOTS ===== -/// Nobody in his crew fires during the draw; the first shots come after it, spread over a second, at most two goons on one hunter +/** + * Nobody in his crew fires during the draw; the first shots come after it, spread over half a second, + * at most two goons on one hunter. A shot is a real trigger pull: the round leaves the gun in hand. + * His aim is the infrared line, and a hit spoils it. Nobody is shot through: a bystander in the line + * holds the shot. Nobody shoots a hunter who is down. + */ /datum/unit_test/voidcrew_bounty_kingpin/telegraph /datum/unit_test/voidcrew_bounty_kingpin/telegraph/Run() var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 4) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 4) var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew var/list/goons = crew.goons() TEST_ASSERT_EQUAL(length(goons), 4, "The lounge doesn't have its four goons") var/mob/living/carbon/human/hunter = kingpin_test_person(kingpin_test_spot(2, 2), null) - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons) TEST_ASSERT(bounty_kingpin_clear_shot(goon, hunter), "[goon] has no line on the talk spot") TEST_ASSERT(crew.start_shootout(hunter, TRUE), "The shootout didn't start") - // Their guns are the real ones: his .357 holds its cylinder, the goons' pistols the Stechkin's magazine - var/obj/item/ammo_box/magazine/internal/cylinder/cylinder = /obj/item/ammo_box/magazine/internal/cylinder - TEST_ASSERT_EQUAL(kingpin.kingpin_rounds, initial(cylinder.max_ammo), "The kingpin's revolver doesn't hold a .357 cylinder") - var/obj/item/ammo_box/magazine/m9mm_aps/pistol_magazine = /obj/item/ammo_box/magazine/m9mm_aps - TEST_ASSERT_EQUAL(bounty_kingpin_goon_mag("pistol"), initial(pistol_magazine.max_ammo), "A goon's pistol doesn't hold a Stechkin magazine") // BOUNTY_GOON_WEAPON_PISTOL - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons) - TEST_ASSERT_EQUAL(goon.goon_rounds, bounty_kingpin_goon_mag(goon.goon_weapon), "[goon] drew with a gun that isn't full") var/draw_ends = crew.crew_draw_ends_at TEST_ASSERT_EQUAL(draw_ends, world.time + 6, "The draw isn't 0.6 seconds") // BOUNTY_KINGPIN_TELEGRAPH + // Their guns are the real ones, loaded: his .357's cylinder, the Stechkin's magazine + var/obj/item/gun/ballistic/revolver/revolver = human_npc_held_gun(kingpin) + TEST_ASSERT(istype(revolver), "The kingpin has no revolver in hand") + TEST_ASSERT_EQUAL(kingpin_test_live_rounds(revolver), 7, "The kingpin's revolver isn't a full .357 cylinder") + var/mob/living/carbon/human/npc/bounty_kingpin_goon/pistol_goon = goons[1] + var/obj/item/gun/ballistic/automatic/pistol/aps/pistol = human_npc_held_gun(pistol_goon) + TEST_ASSERT(istype(pistol), "The pistol goon didn't draw a Stechkin") + TEST_ASSERT_EQUAL(human_npc_live_rounds(pistol.magazine), pistol.magazine.max_ammo, "The pistol goon drew with a gun that isn't full") // During the draw: not a shot, not an aim crew.process(0.2) crew.fight_tick() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons) TEST_ASSERT(!crew.goon_try_attack(goon, hunter), "[goon] fired during the draw") TEST_ASSERT(goon.goon_next_fire_at >= draw_ends, "[goon] may fire before the draw ends") TEST_ASSERT_EQUAL(crew.crew_shots, 0, "The crew fired during the draw") @@ -413,7 +464,7 @@ TEST_ASSERT(kingpin.kingpin_next_aim_at >= draw_ends, "The kingpin may aim before the draw ends") // A goon hit while drawing fumbles: his first shot waits a second past the draw (BOUNTY_KINGPIN_FUMBLE) - var/mob/living/basic/bounty_kingpin_goon/fumbler = goons[1] + var/mob/living/carbon/human/npc/bounty_kingpin_goon/fumbler = goons[2] crew.member_attacked(fumbler, list(hunter)) TEST_ASSERT_EQUAL(fumbler.goon_fumbled_until, draw_ends + 10, "A goon hit while drawing didn't fumble") @@ -422,7 +473,7 @@ crew.process(0.2) TEST_ASSERT_EQUAL(crew.crew_state, "fighting", "The fight didn't start after the draw") // BOUNTY_KINGPIN_FIGHTING var/list/times = list() - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons) TEST_ASSERT(goon.goon_next_fire_at >= world.time, "[goon]'s first shot comes before the draw ended") if(goon != fumbler) // BOUNTY_KINGPIN_FIRST_SHOT_SPREAD half a second @@ -437,150 +488,244 @@ TEST_ASSERT(crew.goons_on(hunter, null) <= 2, "[crew.goons_on(hunter, null)] goons drew on one hunter in the opening volley") TEST_ASSERT(crew.goons_on(hunter, null) >= 1, "No goon drew on the hunter") - // Nobody shoots anyone who is down + // Nobody shoots anyone who is down (CB-11: target choice only) TEST_ASSERT(crew.valid_target(hunter), "The crew wouldn't shoot a hunter on their feet") var/mob/living/carbon/human/downed_hunter = kingpin_test_person(kingpin_test_spot(3, 3), null) crew.add_hunter(downed_hunter) - downed_hunter.set_stat(SOFT_CRIT) - TEST_ASSERT(!crew.valid_target(downed_hunter), "The crew would shoot a hunter who is down") - -// ===== OUTPOST SECURITY ===== - -/** - * 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). - */ -/datum/unit_test/voidcrew_bounty_kingpin/security - -/datum/unit_test/voidcrew_bounty_kingpin/security/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 = 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 - + TEST_ASSERT(kingpin_test_down(downed_hunter), "The test hunter didn't go into crit") + TEST_ASSERT(!crew.valid_target(downed_hunter), "The crew would shoot a hunter in crit") + TEST_ASSERT(!(downed_hunter in crew.live_hunters()), "A hunter in crit is among those the crew picks from") + + // Nobody is shot through: a bystander between a goon and his hunter holds his shot + var/mob/living/carbon/human/npc/bounty_kingpin_goon/shotgun_goon = goons[3] + var/mob/living/carbon/human/close_hunter = kingpin_test_person(kingpin_test_spot(2, 2), null) + TEST_ASSERT(!crew.line_blocked(shotgun_goon, close_hunter), "The line from the shotgun goon to the talk spot is blocked in an empty room") + var/mob/living/carbon/human/bystander = kingpin_test_person(kingpin_test_spot(1, 2), null) + TEST_ASSERT(crew.line_blocked(shotgun_goon, close_hunter), "A bystander in the line doesn't hold the shot") + TEST_ASSERT(crew.line_blocked(kingpin, close_hunter) == FALSE, "The kingpin's line over his kicked table is blocked") + qdel(bystander) + qdel(close_hunter) + + // A goon's shot is a real trigger pull: a shell leaves his shotgun (point blank, a single shot) + var/obj/item/gun/ballistic/shotgun = human_npc_held_gun(shotgun_goon) + TEST_ASSERT(istype(shotgun, /obj/item/gun/ballistic/shotgun/automatic/combat), "The shotgun goon didn't draw a combat shotgun") + var/mob/living/carbon/human/target = kingpin_test_person(kingpin_test_spot(1, 3), null) + crew.add_hunter(target) + var/shells_before = kingpin_test_live_rounds(shotgun) + var/pulls_before = crew.crew_shots + shotgun_goon.goon_cancel_windup() + shotgun_goon.next_click = 0 + shotgun_goon.next_move = 0 + crew.goon_finish_windup(WEAKREF(shotgun_goon), WEAKREF(target)) + TEST_ASSERT_EQUAL(crew.crew_shots, pulls_before + 1, "The shotgun goon didn't pull his trigger") + TEST_ASSERT(kingpin_test_live_rounds(shotgun) < shells_before, "No shell left the shotgun goon's gun") + + // His aim: the line, and a hit spoils it + kingpin.kingpin_next_aim_at = 0 + TEST_ASSERT(kingpin.kingpin_start_aim(hunter), "He didn't aim") + TEST_ASSERT(kingpin.kingpin_aim_timer && kingpin.kingpin_aim_beam, "His aim has no line") + crew.member_attacked(kingpin, list(hunter)) + TEST_ASSERT_NULL(kingpin.kingpin_aim_timer, "A hit didn't spoil his aim") + // Aimed again, he pulls the trigger: a round leaves his revolver + TEST_ASSERT(kingpin.kingpin_start_aim(hunter), "He didn't aim again") + var/rounds_before = kingpin_test_live_rounds(revolver) + kingpin.next_click = 0 + kingpin.next_move = 0 + TEST_ASSERT(kingpin.kingpin_fire(), "He didn't pull the trigger") + TEST_ASSERT(kingpin_test_live_rounds(revolver) < rounds_before, "No round left his revolver") + +// ===== HIS RELOAD ===== + +/// His revolver is reloaded the player's way: a speedloader from his holster or pocket into the cylinder, the empty dropped. A goon's shotgun is fed shell by shell from his bandolier and racked. +/datum/unit_test/voidcrew_bounty_kingpin/reload + +/datum/unit_test/voidcrew_bounty_kingpin/reload/Run() var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 2) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 3) var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew - var/list/goons = crew.goons() - var/mob/living/basic/bounty_kingpin_goon/goon = goons[1] + var/obj/item/gun/ballistic/revolver/revolver = bounty_kingpin_draw_gun(kingpin) + TEST_ASSERT(istype(revolver), "The kingpin couldn't draw his revolver") + revolver.can_misfire = FALSE + var/list/loaders = list() + for(var/obj/item/ammo_box/a357/loader in kingpin.get_all_contents()) + loaders += loader + TEST_ASSERT_EQUAL(length(loaders), 3, "The kingpin doesn't carry three speedloaders") // two in the holster, one in a pocket + // Fired dry: every round in the cylinder spent + var/obj/item/ammo_box/magazine/cylinder = revolver.magazine + for(var/i in 1 to length(cylinder.stored_ammo)) + var/obj/item/ammo_casing/casing = cylinder.stored_ammo[i] + if(ispath(casing)) + casing = new casing(cylinder) + cylinder.stored_ammo[i] = casing + if(casing) + QDEL_NULL(casing.loaded_projectile) + TEST_ASSERT(!revolver.can_shoot(), "Test fixture: the revolver can still fire") + TEST_ASSERT(human_npc_can_reload(kingpin, revolver), "The kingpin can't find a speedloader for his revolver") + TEST_ASSERT(kingpin_test_run_reload(kingpin, /datum/ai_behavior/human_npc_reload/bounty_kingpin_crew), "The kingpin's reload never finished") + TEST_ASSERT(revolver.can_shoot(), "The reload left his revolver unable to fire") + TEST_ASSERT_EQUAL(kingpin_test_live_rounds(revolver), 7, "The reload didn't fill the cylinder") + var/live_loaders = 0 + for(var/obj/item/ammo_box/a357/loader in kingpin.get_all_contents()) + if(human_npc_live_rounds(loader)) + live_loaders++ + TEST_ASSERT_EQUAL(live_loaders, 2, "The reload didn't use up one speedloader") + TEST_ASSERT(istype(kingpin.buckled, /obj/structure/chair/sofa), "He left his sofa to reload") + + // The shotgun goon: shell by shell from the bandolier, then racked + var/mob/living/carbon/human/npc/bounty_kingpin_goon/shotgun_goon + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in crew.goons()) + if(goon.goon_weapon == "shotgun") // BOUNTY_GOON_WEAPON_SHOTGUN + shotgun_goon = goon + TEST_ASSERT_NOTNULL(shotgun_goon, "The lounge has no shotgun goon") + var/obj/item/gun/ballistic/shotgun = bounty_kingpin_draw_gun(shotgun_goon) + TEST_ASSERT(istype(shotgun), "The shotgun goon couldn't draw his shotgun") + var/obj/item/ammo_box/magazine/tube = shotgun.magazine + TEST_ASSERT(istype(tube.stored_ammo[1], /obj/item/ammo_casing/shotgun/buckshot) || ispath(tube.stored_ammo[1], /obj/item/ammo_casing/shotgun/buckshot), "The goons' shotgun isn't loaded with buckshot") + var/obj/item/ammo_casing/round = tube.get_round() + while(round) + qdel(round) + round = tube.get_round() + QDEL_NULL(shotgun.chambered) + TEST_ASSERT(!shotgun.can_shoot(), "Test fixture: the shotgun can still fire") + TEST_ASSERT(kingpin_test_run_reload(shotgun_goon, /datum/ai_behavior/human_npc_reload/bounty_kingpin_crew), "The shotgun goon's reload never finished") + TEST_ASSERT(shotgun.can_shoot(), "The reload left the shotgun unable to fire") + TEST_ASSERT(kingpin_test_live_rounds(shotgun) > 1, "The shotgun was fed only one shell") + +// ===== GIVING UP (CB-14) ===== + +/// He gives up only once his crew is down and he is badly hurt, with a hunter in sight: the revolver holstered, tg's *surrender, then tg's cuffs on his sofa. A reach for his wrists while he can still move gets a grudge, not a step away. +/datum/unit_test/voidcrew_bounty_kingpin/surrender + +/datum/unit_test/voidcrew_bounty_kingpin/surrender/Run() + var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 1) + var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew + var/turf/seat = get_turf(kingpin) 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) - var/mob/living/carbon/human/bystander = kingpin_test_person(kingpin_test_spot(3, 3), 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) - TEST_ASSERT(crew.is_fighting(), "The shootout didn't start") - // The hunter and the brawler have just shot at a goon - 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") - 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) - 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 - 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 - 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") - // 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") - - // 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") + // Reached for while he can move: he holds it against them, and stays on his sofa + SEND_SIGNAL(kingpin, COMSIG_CARBON_CUFF_ATTEMPTED, hunter) + TEST_ASSERT(kingpin.body_has_grudge(hunter), "A reach for his wrists didn't put them on his grudge list") + TEST_ASSERT_EQUAL(get_turf(kingpin), seat, "A reach for his wrists moved him off his seat") + TEST_ASSERT(istype(kingpin.buckled, /obj/structure/chair/sofa), "A reach for his wrists got him off his sofa") + + var/datum/human_npc_surrender/rules = kingpin.npc_surrender_rules() + TEST_ASSERT(istype(rules, /datum/human_npc_surrender/bounty_kingpin), "The kingpin has no surrender rules of his own") + // Through real damage: down to 35 of a person's 100 (BOUNTY_KINGPIN_SURRENDER_BELOW 40) + kingpin.adjustBruteLoss(65) + TEST_ASSERT(kingpin.health <= kingpin.maxHealth * 0.4 && !kingpin.is_downed(), "The test kingpin isn't hurt below 40% and still up ([kingpin.health])") + TEST_ASSERT(!rules.hurt_enough(kingpin), "He'd give up with a goon still standing") + var/list/goons = crew.goons() + var/mob/living/carbon/human/npc/bounty_kingpin_goon/last_goon = goons[1] + last_goon.death() + TEST_ASSERT(crew.crew_down(), "His crew isn't down with every goon dead") + TEST_ASSERT(rules.hurt_enough(kingpin), "He wouldn't think of giving up, hurt below 40% with his crew down") + // Who he gives up to: the hunters in sight; the 60% / 25% rolls (BOUNTY_KINGPIN_SURRENDER_FIRST, _LATER) + crew.start_shootout(hunter, FALSE) + var/list/hostiles = rules.hostiles_near(kingpin) + TEST_ASSERT(hunter in hostiles, "He doesn't see the hunter at the table") + TEST_ASSERT(rules.should_consider(kingpin, hostiles, list()), "He wouldn't consider giving up to the hunter") + TEST_ASSERT_EQUAL(rules.odds(kingpin, hostiles, list(), TRUE), 60, "His first roll isn't 60%") + TEST_ASSERT_EQUAL(rules.odds(kingpin, hostiles, list(), FALSE), 25, "His later rolls aren't 25%") + TEST_ASSERT(!rules.flees, "He would run from his sofa after giving up") + + // He gives up: revolver holstered, hands up, tg's *surrender (a real paralysis) + TEST_ASSERT(istype(human_npc_held_gun(kingpin), /obj/item/gun/ballistic/revolver), "He didn't draw his revolver for the fight") + TEST_ASSERT(kingpin.npc_surrender(), "He didn't give up") + TEST_ASSERT(kingpin.IsParalyzed(), "Giving up isn't tg's surrender") + TEST_ASSERT_NULL(human_npc_held_gun(kingpin), "He gave up with his revolver in hand") + TEST_ASSERT(locate(/obj/item/gun/ballistic/revolver) in kingpin.get_all_contents(), "His revolver left his body when he gave up") + TEST_ASSERT_EQUAL(kingpin.capture_state(), "stunned", "Given up, he doesn't read stunned") // BOUNTY_STATE_STUNNED + TEST_ASSERT(!kingpin.kingpin_able(), "Given up, he can still shoot") + TEST_ASSERT(!kingpin.kingpin_start_aim(hunter), "Given up, he took aim") + + // Lying there, he can't answer a reach for his wrists, and tg's cuffs go on, on his sofa + SEND_SIGNAL(kingpin, COMSIG_CARBON_CUFF_ATTEMPTED, hunter) + TEST_ASSERT(kingpin_test_cuff(kingpin), "The cuffs wouldn't go on a kingpin who gave up") + TEST_ASSERT_EQUAL(kingpin.capture_state(), "restrained", "Cuffed, he doesn't read restrained") // BOUNTY_STATE_RESTRAINED + TEST_ASSERT(istype(kingpin.buckled, /obj/structure/chair/sofa), "Cuffed, he came off his sofa") // ===== THE GOONS ===== -/// Goons drop no guns, their bodies fade, and half of those left run once he falls +/// Goons are real people in vests with real guns and spares carried, not held; their gear stays on them; their bodies stay for the corpse sweep; half of those left walk out once he falls, for real /datum/unit_test/voidcrew_bounty_kingpin/goons /datum/unit_test/voidcrew_bounty_kingpin/goons/Run() var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 4) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 4) var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew var/list/goons = crew.goons() TEST_ASSERT_EQUAL(length(goons), 4, "The lounge doesn't have its four goons") - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons) - TEST_ASSERT(goon.health == 150 && goon.maxHealth == 150, "[goon] doesn't have 150 health") // BOUNTY_GOON_HEALTH + var/list/guns = list( + "pistol" = /obj/item/gun/ballistic/automatic/pistol/aps, + "smg" = /obj/item/gun/ballistic/automatic/mini_uzi, + "shotgun" = /obj/item/gun/ballistic/shotgun/automatic/combat, + ) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons) + TEST_ASSERT(goon.health == goon.maxHealth && goon.maxHealth == 100, "[goon] isn't a person's health") // no health pools TEST_ASSERT(FACTION_TURRET in goon.faction, "[goon] isn't left alone by the turrets") - TEST_ASSERT(!length(goon.contents), "[goon] carries something") - - // A dead goon drops nothing, and fades after a minute - var/mob/living/basic/bounty_kingpin_goon/dead_goon = goons[4] + TEST_ASSERT(istype(goon.wear_suit, /obj/item/clothing/suit/armor/vest), "[goon] wears no armour vest") + TEST_ASSERT_NULL(human_npc_held_gun(goon), "[goon] holds a gun at ease") + var/obj/item/gun/carried = bounty_kingpin_carried_gun(goon) + TEST_ASSERT(istype(carried, guns[goon.goon_weapon]), "[goon] doesn't carry a [goon.goon_weapon]") + TEST_ASSERT(carried.can_shoot(), "[goon]'s [carried] isn't loaded") + TEST_ASSERT(human_npc_find_ammo(goon, carried), "[goon] carries no spare ammunition for [carried]") + + // A dead goon drops nothing of his kit, and his body stays for the corpse sweep + var/mob/living/carbon/human/npc/bounty_kingpin_goon/dead_goon = goons[4] var/turf/body_turf = get_turf(dead_goon) dead_goon.death() TEST_ASSERT(!(locate(/obj/item/gun) in body_turf), "A goon dropped a gun") - TEST_ASSERT(!length(dead_goon.contents), "A dead goon holds something") - TEST_ASSERT(dead_goon.goon_fade_timer, "A dead goon's body won't fade") - var/fade_in = timeleft(dead_goon.goon_fade_timer) - TEST_ASSERT(fade_in > 590 && fade_in <= 600, "A goon's body fades in [fade_in] deciseconds, not a minute") // BOUNTY_GOON_FADE - dead_goon.goon_fade_out() - var/deadline = world.time + 5 SECONDS - UNTIL(QDELETED(dead_goon) || world.time > deadline) - TEST_ASSERT(QDELETED(dead_goon), "A goon's body didn't fade away") - - // He falls mid-fight: half the goons left run (BOUNTY_GOON_FLEE_ON_BOSS 50), the most hurt first + TEST_ASSERT(bounty_kingpin_carried_gun(dead_goon), "A dead goon's gun left his body") + TEST_ASSERT(!QDELETED(dead_goon), "A dead goon's body went at once") + TEST_ASSERT(dead_goon.npc_corpse_lifetime > 0, "A dead goon's body is left out of the corpse sweep") + + // He falls mid-fight: half the goons left run (BOUNTY_GOON_FLEE_ON_BOSS 50), the most hurt first, walking out var/mob/living/carbon/human/hunter = kingpin_test_person(kingpin_test_spot(2, 2), null) crew.start_shootout(hunter, TRUE) - var/mob/living/basic/bounty_kingpin_goon/hurt_goon = goons[2] + var/mob/living/carbon/human/npc/bounty_kingpin_goon/hurt_goon = goons[2] hurt_goon.adjustBruteLoss(40) TEST_ASSERT_EQUAL(length(crew.standing_goons()), 3, "Three goons aren't left standing") - kingpin.body_go_down() + TEST_ASSERT(kingpin_test_down(kingpin), "The kingpin didn't go into crit") var/fled = 0 - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in goons - dead_goon) + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in goons - dead_goon) if(goon.goon_fleeing) fled++ TEST_ASSERT_EQUAL(fled, 1, "[fled] of three goons ran when he fell, not one") // floor(3 * 50 / 100) TEST_ASSERT(hurt_goon.goon_fleeing, "The most hurt goon didn't run first") + TEST_ASSERT_NOTNULL(hurt_goon.npc_exit, "The running goon isn't walking out") + TEST_ASSERT(hurt_goon in SShuman_npcs.leavers, "The running goon isn't among the NPCs walking out") + TEST_ASSERT(!HAS_TRAIT(hurt_goon, TRAIT_GODMODE), "The running goon can't be hurt") + TEST_ASSERT(!hurt_goon.goon_able(), "The running goon still fights") TEST_ASSERT(!crew.crew_down(), "The crew is down with goons still standing") - // He surrenders only once his crew is down - kingpin_test_clear_lounge() - var/datum/criminal_bounty/kingpin/second_posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/boss = kingpin_test_lounge(second_posting, 1) - var/list/boss_goons = boss.kingpin_crew.goons() - var/mob/living/basic/bounty_kingpin_goon/last_goon = boss_goons[1] - // Through adjustBruteLoss(), as a hit would: setBruteLoss() from full health never updates `health` - boss.adjustBruteLoss(boss.maxHealth * 0.65) - TEST_ASSERT(boss.health <= boss.maxHealth * 0.4 && !boss.downed, "The test kingpin isn't hurt below 40% and still up ([boss.health])") - for(var/i in 1 to 20) - boss.kingpin_next_surrender_roll = 0 - boss.kingpin_consider_surrender(boss.maxHealth) - TEST_ASSERT(!HAS_TRAIT(boss, "bounty_surrendered"), "He surrendered with a goon still standing") // TRAIT_BOUNTY_SURRENDERED - last_goon.death() - TEST_ASSERT(boss.kingpin_crew.crew_down(), "His crew isn't down with every goon dead") - for(var/i in 1 to 40) - if(HAS_TRAIT(boss, "bounty_surrendered")) // TRAIT_BOUNTY_SURRENDERED - break - boss.kingpin_next_surrender_roll = 0 - boss.kingpin_consider_surrender(boss.maxHealth) - TEST_ASSERT(HAS_TRAIT(boss, "bounty_surrendered"), "He never surrendered with his crew down and hurt below 40%") // TRAIT_BOUNTY_SURRENDERED +// ===== CB-22: GRABS ===== + +/// Anyone may drag a goon; he resists any hold by someone outside the crew, and a body can be dragged anywhere +/datum/unit_test/voidcrew_bounty_kingpin/grabs + +/datum/unit_test/voidcrew_bounty_kingpin/grabs/Run() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 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[2] + var/mob/living/carbon/human/grabber = kingpin_test_person(get_step(goon, WEST), null) + grabber.start_pulling(goon) + TEST_ASSERT_EQUAL(goon.pulledby, grabber, "A living goon can't be pulled") + // His AI answers: it resists the hold + var/datum/ai_planning_subtree/bounty_kingpin_goon_grabbed/subtree = allocate(/datum/ai_planning_subtree/bounty_kingpin_goon_grabbed) + TEST_ASSERT_EQUAL(subtree.SelectBehaviors(goon.ai_controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "A held goon's AI doesn't plan to break free") + TEST_ASSERT(goon.ai_controller.current_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/resist)], "A held goon's AI doesn't resist") + // And the resist is tg's: a pull breaks + goon.next_move = 0 + goon.execute_resist() + TEST_ASSERT_NULL(goon.pulledby, "Resisting didn't break a pull") + // A body can be dragged anywhere + var/mob/living/carbon/human/npc/bounty_kingpin_goon/dead_goon = goons[1] + dead_goon.death() + var/mob/living/carbon/human/dragger = kingpin_test_person(get_step(dead_goon, NORTH) || get_turf(dead_goon), null) + dragger.start_pulling(dead_goon) + TEST_ASSERT_EQUAL(dead_goon.pulledby, dragger, "A dead goon can't be dragged") // ===== THE CLOCK AND THE CADENCE ===== @@ -602,7 +747,7 @@ var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() TEST_ASSERT_EQUAL(bounty_kingpin_open_posting(), posting, "The open kingpin posting isn't found") TEST_ASSERT(istext(SSbounty_kingpin.kingpin_post_refusal(90 MINUTES, 8)), "A second kingpin posting could go up while one is open") - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 1) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 1) SSbounty_kingpin.kingpin_track(kingpin) TEST_ASSERT_NULL(SSbounty_kingpin.kingpin_post(), "A second kingpin posting went up") // BOUNTY_KINGPIN_EXPIRY 60 minutes @@ -639,13 +784,13 @@ /datum/unit_test/voidcrew_bounty_kingpin/adopt /datum/unit_test/voidcrew_bounty_kingpin/adopt/Run() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 2) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 2) var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew SSbounty_kingpin.kingpin_track(kingpin) TEST_ASSERT_NULL(kingpin.posting(), "The kingpin sat down on a posting") TEST_ASSERT_NULL(bounty_kingpin_open_posting(), "A kingpin posting is up before he was posted") var/list/goons = crew.goons() - var/mob/living/basic/bounty_kingpin_goon/dead_goon = goons[1] + var/mob/living/carbon/human/npc/bounty_kingpin_goon/dead_goon = goons[1] var/turf/dead_post = dead_goon.goon_post dead_goon.death() @@ -657,8 +802,8 @@ TEST_ASSERT_EQUAL(length(GLOB.bounty_kingpin_crews), 1, "Posting him put a second kingpin in the world") TEST_ASSERT(!posting.board_arm(), "His posting tried to spawn someone") // The dead goon's post has a new goon - var/mob/living/basic/bounty_kingpin_goon/new_goon - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in crew.standing_goons()) + var/mob/living/carbon/human/npc/bounty_kingpin_goon/new_goon + for(var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon as anything in crew.standing_goons()) if(goon.goon_post == dead_post) new_goon = goon TEST_ASSERT_NOTNULL(new_goon, "His dead goon's post wasn't filled when he went on the board") @@ -671,16 +816,15 @@ /datum/unit_test/voidcrew_bounty_kingpin/expired /datum/unit_test/voidcrew_bounty_kingpin/expired/Run() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 1) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 1) var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew SSbounty_kingpin.kingpin_track(kingpin) var/datum/criminal_bounty/kingpin/posting = SSbounty_kingpin.kingpin_post() TEST_ASSERT_NOTNULL(posting, "The seated kingpin couldn't be posted") - // Downed and cuffed by his sofa as the clock runs out + // In crit and cuffed by his sofa as the clock runs out kingpin.buckled?.unbuckle_mob(kingpin, force = TRUE) - kingpin.body_go_down() - var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs, get_turf(kingpin)) - TEST_ASSERT(kingpin.body_apply_cuffs(cuffs), "The kingpin couldn't be cuffed") + TEST_ASSERT(kingpin_test_down(kingpin), "The kingpin didn't go into crit") + TEST_ASSERT(kingpin_test_cuff(kingpin), "The kingpin couldn't be cuffed") TEST_ASSERT_EQUAL(crew.crew_state, "calm", "Cuffing him with nobody about started a shootout") // BOUNTY_KINGPIN_CALM posting.close("expired") // BOUNTY_CLOSE_EXPIRED @@ -688,7 +832,7 @@ TEST_ASSERT(!QDELETED(kingpin) && kingpin.stat != DEAD, "The kingpin went with his expired posting") TEST_ASSERT_NULL(kingpin.posting(), "The kingpin is still on the closed posting") TEST_ASSERT_EQUAL(kingpin.kingpin_crew, crew, "His crew went with the posting") - TEST_ASSERT(!QDELETED(crew) && length(crew.standing_goons()) == 1, "His goon left with the posting") + TEST_ASSERT(!QDELETED(crew) && length(crew.present_goons()) == 1, "His goon left with the posting") TEST_ASSERT(!kingpin.is_restrained(), "His people left him cuffed") TEST_ASSERT(!SSbounty_kingpin.kingpin_gone, "An expired posting counted him gone for the round") TEST_ASSERT_EQUAL(SSbounty_kingpin.kingpin(), kingpin, "The round lost track of its kingpin") @@ -699,11 +843,16 @@ TEST_ASSERT_EQUAL(next.criminal(), kingpin, "The next posting didn't adopt him again") TEST_ASSERT_EQUAL(next.record.status, "wanted", "His record isn't wanted again") // BOUNTY_RECORD_WANTED -/// Killed before he is on the board, he is gone for the round with nothing to claim: no posting goes up, his body fades, and nothing seats or posts him again, the admin panel included +/** + * Killed before he is on the board, he is gone for the round with nothing to claim: no posting goes + * up, his body is an ordinary corpse the corpse sweep may take once nobody is near, and nothing seats + * or posts him again, the admin panel included. Killed on an open posting, the sweep leaves his body + * for the pad. + */ /datum/unit_test/voidcrew_bounty_kingpin/death /datum/unit_test/voidcrew_bounty_kingpin/death/Run() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 1) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 1) SSbounty_kingpin.kingpin_track(kingpin) TEST_ASSERT_NULL(bounty_kingpin_open_posting(), "A kingpin posting is up before he died") kingpin.death() @@ -721,63 +870,60 @@ TEST_ASSERT_NULL(panel.admin_run_post_action(operator, action), "The panel posted a kingpin who is gone for the round") TEST_ASSERT(findtext(panel.error, "gone"), "The panel's refusal doesn't say he's gone: [panel.error]") - // Nothing to claim: the body fades, as a goon's does (BOUNTY_GOON_FADE a minute) - TEST_ASSERT(kingpin.kingpin_fade_timer, "His unclaimable body won't fade") - var/fade_in = timeleft(kingpin.kingpin_fade_timer) - TEST_ASSERT(fade_in > 590 && fade_in <= 600, "His body fades in [fade_in] deciseconds, not a minute") - kingpin.kingpin_fade_out() - var/deadline = world.time + 5 SECONDS - UNTIL(QDELETED(kingpin) || world.time > deadline) - TEST_ASSERT(QDELETED(kingpin), "His unclaimable body didn't fade away") + // Nothing to claim: an ordinary corpse, for the corpse sweep once its time is up and nobody is near + TEST_ASSERT(kingpin.npc_corpse_lifetime > 0, "His unclaimable body is left out of the corpse sweep") + TEST_ASSERT(!kingpin.npc_corpse_expired(), "His body would go the moment he died") + kingpin.npc_died_at = world.time - kingpin.npc_corpse_lifetime - 1 + TEST_ASSERT(kingpin.npc_corpse_expired(), "His unclaimable body would never be cleaned up") -/// Killed in their seats, he and his goons fall out of them and lie flat. A body taken out of a chair lies flat, and so does him downed on his sofa once he's taken off it. + // Killed on an open posting: his body is the proof, and the sweep leaves it for the pad + SSbounty_kingpin.kingpin_reset() + kingpin_test_clear_lounge() + var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/wanted = kingpin_test_lounge(posting, 0) + wanted.death() + TEST_ASSERT_EQUAL(wanted.capture_state(), "dead", "His body doesn't read dead") // BOUNTY_STATE_DEAD + wanted.npc_died_at = world.time - wanted.npc_corpse_lifetime - 1 + TEST_ASSERT(!wanted.npc_corpse_expired(), "The corpse sweep would take the body an open posting pays for") + +/// Killed in their seats, he and his goons fall out of them and lie down. In crit on his sofa he stays in it; taken off it, he lies down. /datum/unit_test/voidcrew_bounty_kingpin/seated_bodies /datum/unit_test/voidcrew_bounty_kingpin/seated_bodies/Run() // A chair at the first goon post (4,0), so his goon sits in it var/obj/structure/chair/chair = allocate(/obj/structure/chair, kingpin_test_spot(4, 0)) - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 1) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 1) TEST_ASSERT(istype(kingpin.buckled, /obj/structure/chair/sofa), "The kingpin isn't sitting on the sofa") var/list/goons = kingpin.kingpin_crew.goons() - var/mob/living/basic/bounty_kingpin_goon/goon = goons[1] + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = goons[1] TEST_ASSERT_EQUAL(goon.buckled, chair, "The goon didn't sit in the chair at his post") - // Killed in his chair, a goon falls out of it and lies flat + // Killed in his chair, a goon falls out of it and lies down goon.death() TEST_ASSERT_NULL(goon.buckled, "The dead goon is still sitting in his chair") TEST_ASSERT_EQUAL(goon.body_position, LYING_DOWN, "The dead goon isn't lying down") - TEST_ASSERT(goon.lying_angle, "The dead goon is lying down but drawn standing up") - - // Sat back in the chair, the body sits up; taken out of it again, it lies flat - TEST_ASSERT(chair.buckle_mob(goon, force = TRUE), "The dead goon couldn't be sat back in the chair") - TEST_ASSERT_EQUAL(goon.body_position, STANDING_UP, "The chair didn't sit the dead goon up") - chair.unbuckle_mob(goon, force = TRUE) - TEST_ASSERT_EQUAL(goon.body_position, LYING_DOWN, "The dead goon taken out of the chair isn't lying down") - TEST_ASSERT(goon.lying_angle, "The dead goon taken out of the chair is drawn standing up") - // Killed on his sofa, he falls off it and lies flat + // Killed on his sofa, he falls off it and lies down kingpin.death() TEST_ASSERT_NULL(kingpin.buckled, "The dead kingpin is still sitting on his sofa") TEST_ASSERT_EQUAL(kingpin.body_position, LYING_DOWN, "The dead kingpin isn't lying down") - TEST_ASSERT(kingpin.lying_angle, "The dead kingpin is lying down but drawn standing up") - // Downed on his sofa he stays in it; taken off it, he lies flat + // In crit on his sofa he stays in it; taken off it, he lies down SSbounty_kingpin.kingpin_reset() kingpin_test_clear_lounge() - var/mob/living/basic/bounty_criminal/kingpin/downed = kingpin_test_lounge(null, 0) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/downed = kingpin_test_lounge(null, 0) var/obj/structure/chair/sofa = downed.buckled TEST_ASSERT(istype(sofa, /obj/structure/chair/sofa), "The second kingpin isn't sitting on the sofa") - downed.body_go_down() - TEST_ASSERT_EQUAL(downed.buckled, sofa, "Downed, the kingpin came off his sofa") + TEST_ASSERT(kingpin_test_down(downed), "The second kingpin didn't go into crit") + TEST_ASSERT_EQUAL(downed.buckled, sofa, "In crit, the kingpin came off his sofa") sofa.unbuckle_mob(downed, force = TRUE) - TEST_ASSERT_EQUAL(downed.body_position, LYING_DOWN, "The downed kingpin taken off his sofa isn't lying down") - TEST_ASSERT(downed.lying_angle, "The downed kingpin taken off his sofa is drawn standing up") + TEST_ASSERT_EQUAL(downed.body_position, LYING_DOWN, "The kingpin in crit taken off his sofa isn't lying down") /// A shootout puts nobody on the board; when his posting goes up it adopts him wherever he is, so a crew that grabbed him early can bring him in; withdrawn while he's held away, it takes him, and he's gone /datum/unit_test/voidcrew_bounty_kingpin/grabbed /datum/unit_test/voidcrew_bounty_kingpin/grabbed/Run() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 1) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 1) var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew SSbounty_kingpin.kingpin_track(kingpin) var/mob/living/carbon/human/hunter = kingpin_test_person(kingpin_test_spot(2, 2), null) @@ -789,9 +935,9 @@ crew.stand_down() crew.crew_hunters.Cut() - // Grabbed early: downed and shut in a locker, away from his lounge, and still nothing on the board + // Grabbed early: in crit and shut in a locker, away from his lounge, and still nothing on the board kingpin.buckled?.unbuckle_mob(kingpin, force = TRUE) - kingpin.body_go_down() + TEST_ASSERT(kingpin_test_down(kingpin), "The kingpin didn't go into crit") var/obj/structure/closet/locker = allocate(/obj/structure/closet, kingpin_test_spot(4, 4)) kingpin.forceMove(locker) crew.process(0.2) @@ -820,7 +966,7 @@ TEST_ASSERT_EQUAL(initial(run.weight), 0, "Drug runs roll at random") TEST_ASSERT_EQUAL(initial(run.mission_limit), 1, "More than one drug run can run at once") // His jobs are posted with the black market's shop, so Vex's counter takes the product - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 0) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(null, 0) var/datum/outpost_shop/shop = kingpin.kingpin_crew.crew_shop() TEST_ASSERT(istype(shop, /datum/outpost_shop/black_market), "His jobs aren't posted with the black market's shop") @@ -830,8 +976,9 @@ * 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: 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. + * 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. */ /datum/unit_test/voidcrew_bounty_kingpin/map @@ -867,7 +1014,7 @@ TEST_ASSERT_EQUAL(weapons["smg"], 1, "The lounge doesn't have one SMG post") // The round seats him: his own record, on no posting, one kingpin only - var/mob/living/basic/bounty_criminal/kingpin/kingpin = SSbounty_kingpin.kingpin_seat(outpost) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = SSbounty_kingpin.kingpin_seat(outpost) TEST_ASSERT_NOTNULL(kingpin, "The subsystem didn't seat the kingpin in the lounge") TEST_ASSERT_EQUAL(SSbounty_kingpin.kingpin(), kingpin, "The subsystem didn't keep track of the kingpin it seated") TEST_ASSERT_EQUAL(get_turf(kingpin), seat_turf, "The kingpin didn't sit down at his seat") @@ -880,8 +1027,11 @@ TEST_ASSERT_EQUAL(length(GLOB.bounty_kingpin_crews), 1, "The lounge has more than one kingpin") var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew TEST_ASSERT(istype(kingpin.buckled, /obj/structure/chair/sofa), "The kingpin isn't on the sofa") - TEST_ASSERT_EQUAL(length(crew.goons()), 6, "The kingpin doesn't have six goons") TEST_ASSERT_EQUAL(length(crew.crew_posts), 6, "The crew doesn't know its six posts") + // His goons come a body at a time through the spawn queue + var/deadline = world.time + 30 SECONDS + UNTIL(length(crew.goons()) >= 6 || world.time > deadline) + TEST_ASSERT_EQUAL(length(crew.goons()), 6, "The kingpin doesn't have six goons") TEST_ASSERT_EQUAL(crew.crew_outpost(), outpost, "The crew doesn't know the outpost its lounge is in") // He has no coffee table any more, and no lounge table stands at any of his goon posts either TEST_ASSERT_EQUAL(length(crew.crew_coffee_table), 0, "The crew found a coffee table that isn't there") @@ -896,7 +1046,7 @@ TEST_ASSERT_NOTNULL(turret, "No lounge turret near the sofa") var/mob/living/carbon/human/hunter = kingpin_test_person(crew.talk_spot(), null) crew.start_shootout(hunter, TRUE) - for(var/mob/living/basic/bounty_kingpin_goon/goon as anything in crew.goons()) + 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(barkeep.transform ~= standing, "The shootout changed how the barkeep looks") @@ -923,10 +1073,10 @@ /datum/unit_test/voidcrew_bounty_kingpin/mech/Run() var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 2) + 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/basic/bounty_kingpin_goon/goon = goons[1] + var/mob/living/carbon/human/npc/bounty_kingpin_goon/goon = goons[1] var/mob/living/carbon/human/consistent/pilot = kingpin_test_person(kingpin_test_spot(1, 4), null) var/obj/vehicle/sealed/mecha/ripley/mech = allocate(/obj/vehicle/sealed/mecha/ripley, kingpin_test_spot(1, 4)) mech.mob_enter(pilot, silent = TRUE) @@ -947,10 +1097,10 @@ // Any damage to a calm crew: the nearest person in sight who isn't a guest is taken for the attacker kingpin_test_clear_lounge() var/datum/criminal_bounty/kingpin/second_posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/second = kingpin_test_lounge(second_posting, 2) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/second = kingpin_test_lounge(second_posting, 2) var/datum/bounty_kingpin_crew/second_crew = second.kingpin_crew var/list/second_goons = second_crew.goons() - var/mob/living/basic/bounty_kingpin_goon/hurt_goon = second_goons[2] + var/mob/living/carbon/human/npc/bounty_kingpin_goon/hurt_goon = second_goons[2] var/mob/living/carbon/human/culprit = kingpin_test_person(kingpin_test_spot(3, 2), null) TEST_ASSERT_EQUAL(second_crew.crew_state, "calm", "The second crew isn't calm") // BOUNTY_KINGPIN_CALM hurt_goon.adjustBruteLoss(10) @@ -963,9 +1113,9 @@ second_posting.close("admin") kingpin_test_clear_lounge() var/datum/criminal_bounty/kingpin/third_posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/third = kingpin_test_lounge(third_posting, 1) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/third = kingpin_test_lounge(third_posting, 1) var/list/third_goons = third.kingpin_crew.goons() - var/mob/living/basic/bounty_criminal/normal/rival = allocate(/mob/living/basic/bounty_criminal/normal, kingpin_test_spot(3, 3)) + var/mob/living/carbon/human/npc/bounty_criminal/normal/rival = allocate(/mob/living/carbon/human/npc/bounty_criminal/normal, kingpin_test_spot(3, 3)) SEND_SIGNAL(third_goons[1], COMSIG_ATOM_WAS_ATTACKED, rival, ATTACKER_DAMAGING_ATTACK) TEST_ASSERT_EQUAL(third.kingpin_crew.crew_state, "calm", "Another criminal's punch started a shootout with nobody to shoot") @@ -979,7 +1129,7 @@ var/obj/structure/table/wood/post_table = allocate(/obj/structure/table/wood, kingpin_test_spot(0, 3)) post_table.AddElement(/datum/element/outpost_property) var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 3) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 3) var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew TEST_ASSERT(WEAKREF(post_table) in crew.crew_tables, "The crew doesn't mind the table at a goon's post") TEST_ASSERT(!(post_table.resistance_flags & INDESTRUCTIBLE), "A lounge table can't be shot to pieces while he's in") @@ -1014,22 +1164,6 @@ qdel(kingpin) TEST_ASSERT(replacement.resistance_flags & INDESTRUCTIBLE, "A lounge table stayed breakable after he was gone") -// ===== M1: GOON STAMINA ===== - -/// Five disabler hits stamcrit a goon: a stun, not just a slowdown -/datum/unit_test/voidcrew_bounty_kingpin/stamina - -/datum/unit_test/voidcrew_bounty_kingpin/stamina/Run() - var/mob/living/basic/bounty_kingpin_goon/goon = allocate(/mob/living/basic/bounty_kingpin_goon, kingpin_test_spot(2, 2)) - TEST_ASSERT_EQUAL(goon.max_stamina, 100, "A goon's stamina pool isn't 100") // BOUNTY_GOON_MAX_STAMINA - // 30-point disabler hits against BOUNTY_GOON_HEALTH 150: 20 points each - for(var/hit in 1 to 4) - goon.adjustStaminaLoss(30) - TEST_ASSERT(goon.goon_able(), "Four disabler hits stunned a goon") - goon.adjustStaminaLoss(30) - TEST_ASSERT(goon.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "Five disabler hits didn't stamcrit a goon") - TEST_ASSERT(!goon.goon_able(), "A stamcrit goon can still fight") - // ===== M2: A CALM CREW STILL WATCHES ===== /// After a stand-down, a known hunter in sight, cuffing him or dragging him starts the fight again, through the draw @@ -1037,7 +1171,7 @@ /datum/unit_test/voidcrew_bounty_kingpin/watch/Run() var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 2) + 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/mob/living/carbon/human/hunter = kingpin_test_person(kingpin_test_spot(2, 2), null) crew.start_shootout(hunter, TRUE) @@ -1048,14 +1182,13 @@ crew.calm_tick() TEST_ASSERT_EQUAL(crew.crew_state, "drawing", "A known hunter in sight didn't restart the fight") // BOUNTY_KINGPIN_DRAWING - // Cuffing him while his crew is at ease is an attack by the cuffer + // Cuffing him while his crew is at ease is an attack by the cuffer: tg's reach for his wrists, then the cuffs crew.stand_down() var/mob/living/carbon/human/cuffer = kingpin_test_person(kingpin_test_spot(3, 2), null) kingpin.buckled?.unbuckle_mob(kingpin, force = TRUE) - kingpin.body_go_down() - var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs, kingpin_test_spot(3, 2)) - cuffer.put_in_hands(cuffs) - TEST_ASSERT(kingpin.body_apply_cuffs(cuffs, cuffer), "The downed kingpin couldn't be cuffed") + TEST_ASSERT(kingpin_test_down(kingpin), "The kingpin didn't go into crit") + SEND_SIGNAL(kingpin, COMSIG_CARBON_CUFF_ATTEMPTED, cuffer) + TEST_ASSERT(kingpin_test_cuff(kingpin), "The kingpin in crit couldn't be cuffed") TEST_ASSERT_EQUAL(crew.crew_state, "drawing", "Cuffing him while his crew watched didn't start the fight") TEST_ASSERT(crew.is_hunter(cuffer), "The one who cuffed him isn't a hunter") // Cuffed on his sofa, his goons stay with him @@ -1071,6 +1204,77 @@ TEST_ASSERT_EQUAL(crew.crew_state, "drawing", "Dragging him off didn't start the fight") TEST_ASSERT(crew.is_hunter(dragger), "The one dragging him isn't a hunter") +// ===== OUTPOST SECURITY ===== + +/** + * 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). + */ +/datum/unit_test/voidcrew_bounty_kingpin/security + +/datum/unit_test/voidcrew_bounty_kingpin/security/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 = 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/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) + var/mob/living/carbon/human/bystander = kingpin_test_person(kingpin_test_spot(3, 3), 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) + TEST_ASSERT(crew.is_fighting(), "The shootout didn't start") + // The hunter and the brawler have just shot at a goon + 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") + 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) + 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 + 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 + 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") + + // 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") + + // 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") + // ===== THE LOW ONES ===== /// A hunter in a locker is not shot at (L2); a crewmate of anyone who fought gets no work (L3); a new character of a player who works for him is still bound (L4) @@ -1078,7 +1282,7 @@ /datum/unit_test/voidcrew_bounty_kingpin/lows/Run() var/datum/criminal_bounty/kingpin/posting = kingpin_test_posting() - var/mob/living/basic/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 1) + var/mob/living/carbon/human/npc/bounty_criminal/kingpin/kingpin = kingpin_test_lounge(posting, 1) var/datum/bounty_kingpin_crew/crew = kingpin.kingpin_crew var/obj/structure/overmap/ship/fighting_ship = kingpin_test_ship() var/mob/living/carbon/human/fighter = kingpin_test_person(kingpin_test_spot(3, 3), fighting_ship) diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_lair_mafia.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_lair_mafia.dm index e65a8e136fb..46a8a2a6eff 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_lair_mafia.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_lair_mafia.dm @@ -3,8 +3,9 @@ * * Owner: P12 mafia mobs. Do not edit this file outside that package. Fork defines are included after the * tests, so a test uses the literal value with a comment naming the define. The tests call the mobs' procs directly - * with them already in place. Only rocket_flight (a rocket really flying to its marker) and ring_path (the lair's - * grace period between the mech and the don) wait; none waits on an AI walk. + * with them already in place. The goons, the lieutenants and the don are real human NPCs; the don's mech is a mob of + * its own (owner call OC-5). Only rocket_flight (a rocket really flying to its marker), fire (a real gun's burst) and + * ring_path (the lair's grace period between the mech and the don) wait; none waits on an AI walk. */ /// Shared helpers; not a test itself @@ -41,6 +42,12 @@ fake_clients += hunter return hunter +/// One of the club's people at `where`, with its AI off: the test drives it +/datum/unit_test/voidcrew_bounty_lair_mafia/proc/member_at(member_type, turf/where) + var/mob/living/carbon/human/npc/bounty_mafia/member = allocate(member_type, where) + member.ai_controller?.set_ai_status(AI_STATUS_OFF) + return member + /// Clears what the club leaves behind in the test room: shots in flight, corpses, wrecks, rings /datum/unit_test/voidcrew_bounty_lair_mafia/proc/clear_room() for(var/turf/spot as anything in block(run_loc_floor_bottom_left, run_loc_floor_top_right)) @@ -57,7 +64,7 @@ if(!(stray_item in allocated)) qdel(stray_item) -// ===== THE ROCKET VOLLEY: MARKERS FIRST, NEVER explosion() ===== +// ===== THE ROCKET VOLLEY: MARKERS FIRST, THEN TG'S LIGHT BLAST ON WHOEVER STANDS THERE, NEVER explosion() ===== /datum/unit_test/voidcrew_bounty_lair_mafia/rockets @@ -87,23 +94,38 @@ TEST_ASSERT(!istype(rocket, /obj/projectile/bullet/rocket), "The launch fired tg's rocket") TEST_ASSERT_EQUAL(rocket.rocket_target, get_turf(hunter), "The rocket isn't aimed at the marker") - // The projectile and its scripted detonation share one red boss factor. + // The burst: tg's light blast on the hunter standing on the marker (damage, and a knockdown by their bomb armour) var/turf/marker_turf = get_turf(hunter) var/list/volley = rocket.rocket_volley var/list/hit = rocket.rocket_detonate(marker_turf) TEST_ASSERT(hunter in hit, "The rocket missed the hunter standing on the marker") var/first = hunter.getBruteLoss() - TEST_ASSERT_EQUAL(first, 84, "The rocket lost or doubled its red boss damage factor") // BOUNTY_MECH_ROCKET_DAMAGE * 1.2 - TEST_ASSERT(hunter.IsKnockdown(), "The rocket didn't knock the hunter down") + TEST_ASSERT(first > 0, "The rocket's blast didn't hurt the hunter on its marker") + TEST_ASSERT(hunter.IsKnockdown(), "The rocket's blast didn't knock the hunter down") TEST_ASSERT(QDELETED(rocket), "The rocket outlived its burst") var/obj/projectile/bullet/bounty_mafia_rocket/second = new(spot(1, 2)) second.rocket_volley = volley second.rocket_detonate(marker_turf) TEST_ASSERT_EQUAL(hunter.getBruteLoss(), first, "A second rocket of one volley hit the same hunter") + // The blast hits whoever stands there, whatever their state: a hunter already down takes it too (CB-10, CB-11) + var/mob/living/carbon/human/consistent/downed = hunter_at(spot(3, 4)) + // Suffocation, not brute: brute lands on one random limb, which caps it + downed.adjustOxyLoss(150) + TEST_ASSERT(downed.stat != CONSCIOUS, "Test fixture: the second hunter didn't go down") + var/downed_before = downed.getBruteLoss() + var/obj/projectile/bullet/bounty_mafia_rocket/on_the_floor = new(spot(1, 4)) + TEST_ASSERT(downed in on_the_floor.rocket_detonate(get_turf(downed)), "A rocket's burst skipped a hunter who is down") + TEST_ASSERT(downed.getBruteLoss() > downed_before, "A rocket's burst didn't hurt a hunter who is down") + + // A burst picks no sides: one of the club standing on a marker takes it as well + var/mob/living/carbon/human/npc/bounty_mafia/goon = member_at(/mob/living/carbon/human/npc/bounty_mafia, spot(2, 0)) + var/obj/projectile/bullet/bounty_mafia_rocket/friendly = new(spot(1, 0)) + TEST_ASSERT(goon in friendly.rocket_detonate(get_turf(goon)), "A rocket's burst skipped one of the club") + // A marker the hunter stepped off hurts nobody var/obj/projectile/bullet/bounty_mafia_rocket/empty = new(spot(1, 2)) - TEST_ASSERT(!length(empty.rocket_detonate(spot(2, 4))), "A rocket on an empty marker hit someone") + TEST_ASSERT(!length(empty.rocket_detonate(spot(2, 3))), "A rocket on an empty marker hit someone") // ===== A ROCKET IN FLIGHT: THROUGH PEOPLE TO ITS MARKER, NO EXPLOSION, NOTHING BROKEN OUTSIDE THE GARAGE ===== @@ -153,7 +175,7 @@ var/landed_brute = watch_flight(rockets[1], target, timeline) var/seen = "Hits: [length(target_hits) ? target_hits.Join("; ") : "none"]. Brute by tenth of a second: [timeline.Join(", ")]" TEST_ASSERT(QDELETED(rockets[1]), "The rocket never reached its marker. [seen]") - TEST_ASSERT_EQUAL(landed_brute, 70, "The rocket didn't hit the hunter on its marker. [seen]") // BOUNTY_MECH_ROCKET_DAMAGE + TEST_ASSERT(landed_brute > 0, "The rocket didn't hurt the hunter on its marker. [seen]") TEST_ASSERT_EQUAL(target.getBruteLoss(), landed_brute, "The hunter's brute changed after the rocket burst. [seen]") TEST_ASSERT_EQUAL(bystander.getBruteLoss(), 0, "The rocket hit someone it flew past on the way") // explosion() would have gone off, and flattened the table beside the marker @@ -204,7 +226,7 @@ TEST_ASSERT(mech.mech_holds_grudge(pilot), "The mech holds no grudge against an exosuit's pilot") LAZYREMOVE(suit.occupants, pilot) -// ===== THE LMG: A RED CONE FIRST ===== +// ===== THE LMG: A RED CONE FIRST, REAL ROUNDS THAT HIT WHOEVER IS IN THE WAY ===== /datum/unit_test/voidcrew_bounty_lair_mafia/lmg @@ -233,6 +255,7 @@ TEST_ASSERT(!mech.mech_start_rockets(list(hunter)), "A volley started during the burst") var/obj/projectile/round = mech.mech_lmg_round(fire_serial) TEST_ASSERT(round && round.range <= mech.mech_lmg_range + 1, "A machine gun round flies past its cone") + TEST_ASSERT(round && !length(round.ignored_factions), "The machine gun's rounds pass through the club's own people") // Cut short: nothing more fires, and the cone goes mech.mech_interrupt() @@ -241,7 +264,7 @@ TEST_ASSERT_NULL(mech.mech_lmg_round(fire_serial), "A round fired after the burst was cut short") clear_room() - // Never at someone who is down + // It never picks someone who is down to aim at mech.mech_next_ability_at = 0 // Suffocation, not brute: brute lands on one random limb, which caps it hunter.adjustOxyLoss(150) @@ -249,7 +272,7 @@ TEST_ASSERT(!mech.mech_start_lmg(hunter), "The machine gun spun up on a downed hunter") TEST_ASSERT(!mech.mech_start_rockets(list(hunter)), "The volley locked on to a downed hunter") -// ===== THE STOMP THROWS YOU BACK; THE CLUB'S GUNS ARE REAL ===== +// ===== THE STOMP THROWS YOU BACK ===== /datum/unit_test/voidcrew_bounty_lair_mafia/stomp @@ -267,18 +290,114 @@ hunter.throwing.finalize() clear_room() - // The club's guns are the real ones: a gunman's magazine is the Stechkin's, and Tommy's the Thompson's drum - var/mob/living/basic/trooper/russian/mafia/pistol/gunman = allocate(/mob/living/basic/trooper/russian/mafia/pistol, spot(4, 4)) - var/obj/item/ammo_box/magazine/m9mm_aps/pistol_magazine = /obj/item/ammo_box/magazine/m9mm_aps - TEST_ASSERT_EQUAL(gunman.mafia_gun.magazine, initial(pistol_magazine.max_ammo), "The gunman's magazine isn't the Stechkin's") - var/mob/living/basic/trooper/russian/mafia/lieutenant/tommy/tommy = allocate(/mob/living/basic/trooper/russian/mafia/lieutenant/tommy, spot(3, 4)) - var/obj/item/ammo_box/magazine/tommygunm45/drum = /obj/item/ammo_box/magazine/tommygunm45 - TEST_ASSERT_EQUAL(tommy.mafia_gun.magazine, initial(drum.max_ammo), "Tommy's magazine isn't the Thompson's drum") - // The knife man's blade is a kitchen knife's - var/mob/living/basic/trooper/russian/mafia/knife_man = allocate(/mob/living/basic/trooper/russian/mafia, spot(2, 4)) - var/obj/item/knife/kitchen/knife = /obj/item/knife/kitchen - TEST_ASSERT_EQUAL(knife_man.melee_damage_upper, initial(knife.force), "The knife man doesn't hit like a kitchen knife") - TEST_ASSERT_EQUAL(knife_man.sharpness, SHARP_EDGED, "The knife man's blows aren't edged") +// ===== THE CLUB'S PEOPLE: REAL BODIES, REAL CLOTHES, REAL GUNS ===== + +/datum/unit_test/voidcrew_bounty_lair_mafia/kit + +/datum/unit_test/voidcrew_bounty_lair_mafia/kit/Run() + // type -> list(the gun in hand, its magazine, spares carried) + var/list/gunmen = list( + /mob/living/carbon/human/npc/bounty_mafia/pistol = list(/obj/item/gun/ballistic/automatic/pistol/aps, /obj/item/ammo_box/magazine/m9mm_aps, 2), + /mob/living/carbon/human/npc/bounty_mafia/smg = list(/obj/item/gun/ballistic/automatic/mini_uzi, /obj/item/ammo_box/magazine/uzim9mm, 2), + /mob/living/carbon/human/npc/bounty_mafia/lieutenant/tommy = list(/obj/item/gun/ballistic/automatic/tommygun, /obj/item/ammo_box/magazine/tommygunm45, 2), + /mob/living/carbon/human/npc/bounty_mafia/don = list(/obj/item/gun/ballistic/automatic/pistol/deagle/gold, /obj/item/ammo_box/magazine/m50, 2), + ) + var/dx = 0 + for(var/member_type in gunmen) + var/list/kit = gunmen[member_type] + var/mob/living/carbon/human/npc/bounty_mafia/member = member_at(member_type, spot(dx++, 4)) + var/obj/item/gun/ballistic/gun = locate(kit[1]) in member.held_items + TEST_ASSERT_NOTNULL(gun, "[member_type] doesn't hold a [kit[1]]") + TEST_ASSERT(gun?.can_shoot(), "[member_type]'s gun isn't loaded") + TEST_ASSERT(HAS_TRAIT(gun, "human_npc_gear"), "[member_type]'s gun isn't locked to them, so it would be loot") // TRAIT_HUMAN_NPC_GEAR + var/spares = 0 + for(var/obj/item/ammo_box/magazine/magazine in member.get_all_contents()) + if(istype(magazine, kit[2]) && magazine != gun?.magazine) + spares++ + TEST_ASSERT_EQUAL(spares, kit[3], "[member_type] carries [spares] spare magazines, not [kit[3]]") + TEST_ASSERT_NOTNULL(member.w_uniform, "[member_type] isn't dressed") + TEST_ASSERT_EQUAL(member.gender, MALE, "[member_type] isn't one of the club's men") + TEST_ASSERT("bounty_mafia" in member.faction, "[member_type] isn't of the club") // BOUNTY_MAFIA_FACTION + TEST_ASSERT_EQUAL(member.maxHealth, 100, "[member_type]'s health isn't a person's") // MAX_LIVING_HEALTH + TEST_ASSERT_NULL(member.npc_surrender, "[member_type] can give up") + TEST_ASSERT_EQUAL(member.npc_target_stat, CONSCIOUS, "[member_type] would go after a hunter who is down") // HUMAN_NPC_SPARES_DOWNED + + // The knife man holds a kitchen knife; Brute holds nothing and fights with his fists, in a bulletproof vest + var/mob/living/carbon/human/npc/bounty_mafia/knife_man = member_at(/mob/living/carbon/human/npc/bounty_mafia, spot(0, 2)) + TEST_ASSERT_NOTNULL(locate(/obj/item/knife/kitchen) in knife_man.held_items, "The knife man has no kitchen knife") + var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/brute/brute = member_at(/mob/living/carbon/human/npc/bounty_mafia/lieutenant/brute, spot(1, 2)) + TEST_ASSERT(!length(brute.get_all_contents_type(/obj/item/gun)), "Brute carries a gun") + TEST_ASSERT(istype(brute.wear_suit, /obj/item/clothing/suit/armor/vest/russian), "Brute has no armour on") + + // Ranks and toughness: through their bodies and what they wear, never their health + var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/tommy/tommy = member_at(/mob/living/carbon/human/npc/bounty_mafia/lieutenant/tommy, spot(2, 2)) + TEST_ASSERT(istype(tommy.wear_suit, /obj/item/clothing/suit/toggle/jacket/det_trench/noir), "Tommy's long coat isn't his armoured one") + for(var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/lieutenant as anything in list(tommy, brute)) + TEST_ASSERT_EQUAL(lieutenant.npc_rank, 1, "[lieutenant] isn't an elite") // HUMAN_NPC_RANK_ELITE + TEST_ASSERT(abs(lieutenant.physiology.brute_mod - 0.8) < 0.01, "[lieutenant] takes [lieutenant.physiology.brute_mod] of brute, not 0.8") // BOUNTY_LIEUTENANT_DAMAGE_TAKEN + TEST_ASSERT(abs(lieutenant.physiology.burn_mod - 0.8) < 0.01, "[lieutenant] takes [lieutenant.physiology.burn_mod] of burn, not 0.8") + TEST_ASSERT_EQUAL(lieutenant.maxHealth, 100, "[lieutenant]'s health isn't a person's") // MAX_LIVING_HEALTH + var/mob/living/carbon/human/npc/bounty_mafia/don/don = member_at(/mob/living/carbon/human/npc/bounty_mafia/don, spot(3, 2)) + TEST_ASSERT_EQUAL(don.npc_rank, 2, "The don isn't a boss") // HUMAN_NPC_RANK_BOSS + TEST_ASSERT(abs(don.physiology.brute_mod - 0.6) < 0.01, "The don takes [don.physiology.brute_mod] of brute, not 0.6") // BOUNTY_DON_DAMAGE_TAKEN + TEST_ASSERT_EQUAL(knife_man.npc_rank, 0, "A goon is an elite") // HUMAN_NPC_RANK_REGULAR + // Nobody in the club executes anyone (OC-9: no deliberate executions are built yet) + for(var/mob/living/carbon/human/npc/bounty_mafia/member as anything in list(knife_man, brute, tommy, don)) + TEST_ASSERT(!member.hv_can_execute(), "[member] can execute") + +// ===== FIRING THE REAL GUN: PULLS, THE AIM, THE FLINCH, THE RELOAD ===== + +/datum/unit_test/voidcrew_bounty_lair_mafia/fire + +/datum/unit_test/voidcrew_bounty_lair_mafia/fire/Run() + var/mob/living/carbon/human/consistent/hunter = hunter_at(spot(4, 0)) + var/mob/living/carbon/human/npc/bounty_mafia/pistol/gunman = member_at(/mob/living/carbon/human/npc/bounty_mafia/pistol, spot(0, 0)) + var/datum/ai_controller/controller = gunman.ai_controller + var/obj/item/gun/ballistic/gun = locate(/obj/item/gun/ballistic/automatic/pistol/aps) in gunman.held_items + TEST_ASSERT_NOTNULL(gun, "Test fixture: the gunman holds no Stechkin") + gun.can_misfire = FALSE + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) + var/datum/ai_behavior/bounty_mafia_fire/fire = GET_AI_BEHAVIOR(/datum/ai_behavior/bounty_mafia_fire) + + // A pistol volley: no aim, one pull of the real gun (its three-round burst), then a pause before the next + var/rounds_before = gun.get_ammo() + TEST_ASSERT(fire.setup(controller, BB_BASIC_MOB_CURRENT_TARGET), "The gunman's volley didn't start") + TEST_ASSERT(!gunman.mafia_aim_until, "The pistol aims before it fires") + var/flags = fire.perform(0.1, controller, BB_BASIC_MOB_CURRENT_TARGET) + TEST_ASSERT(flags & AI_BEHAVIOR_SUCCEEDED, "The pistol volley wasn't one pull") + fire.finish_action(controller, TRUE, BB_BASIC_MOB_CURRENT_TARGET) + TEST_ASSERT(gunman.mafia_next_volley_at >= world.time + 8, "The gunman has no pause between volleys") // BOUNTY_MOBSTER_PISTOL_COOLDOWN + TEST_ASSERT(!fire.setup(controller, BB_BASIC_MOB_CURRENT_TARGET), "The gunman started a volley before his pause was over") + sleep(0.5 SECONDS) + TEST_ASSERT(gun.get_ammo() < rounds_before, "The pull didn't fire the gunman's Stechkin") + clear_room() + + // The Uzi spins up first ("aiming!"), and a hit then spoils the aim: nothing fires, and the next try waits + var/mob/living/carbon/human/npc/bounty_mafia/smg/enforcer = member_at(/mob/living/carbon/human/npc/bounty_mafia/smg, spot(0, 2)) + var/datum/ai_controller/enforcer_ai = enforcer.ai_controller + var/obj/item/gun/ballistic/uzi = locate(/obj/item/gun/ballistic/automatic/mini_uzi) in enforcer.held_items + enforcer_ai.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) + var/uzi_rounds = uzi.get_ammo() + TEST_ASSERT(fire.setup(enforcer_ai, BB_BASIC_MOB_CURRENT_TARGET), "The enforcer's volley didn't start") + TEST_ASSERT(enforcer.mafia_aim_until > world.time, "The Uzi didn't spin up first") + TEST_ASSERT(fire.perform(0.1, enforcer_ai, BB_BASIC_MOB_CURRENT_TARGET) & AI_BEHAVIOR_DELAY, "The enforcer didn't wait out his aim") + TEST_ASSERT_EQUAL(uzi.get_ammo(), uzi_rounds, "The Uzi fired during its spin-up") + enforcer.apply_damage(5, BRUTE, BODY_ZONE_CHEST) + TEST_ASSERT(enforcer.mafia_aim_spoiled, "A hit during the spin-up didn't spoil the aim") + TEST_ASSERT(fire.perform(0.1, enforcer_ai, BB_BASIC_MOB_CURRENT_TARGET) & AI_BEHAVIOR_FAILED, "A spoiled aim still fired") + fire.finish_action(enforcer_ai, FALSE, BB_BASIC_MOB_CURRENT_TARGET) + TEST_ASSERT(enforcer.mafia_next_volley_at >= world.time + 9, "A spoiled aim didn't cost the enforcer a second") // BOUNTY_MOBSTER_FUMBLE + TEST_ASSERT_EQUAL(uzi.get_ammo(), uzi_rounds, "The Uzi fired after its aim was spoiled") + + // Dry: the next move is the player's reload from his pockets, with the club's sound and maybe a line + var/obj/item/ammo_box/magazine/spent = gun.magazine + spent.stored_ammo.Cut() + QDEL_NULL(gun.chambered) + TEST_ASSERT(!gun.can_shoot(), "Test fixture: the Stechkin still fires") + gunman.mafia_next_volley_at = 0 + TEST_ASSERT(gunman.mafia_plan_attack(controller, hunter), "A dry gunman had nothing to do") + TEST_ASSERT(controller.current_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/human_npc_reload/bounty_mafia)], "A dry gunman didn't reload") + controller.CancelActions() // ===== POSSE HEALTH ===== @@ -346,7 +465,7 @@ TEST_ASSERT(mech.mech_engaged[REF(ion_shooter)], "An ion shooter didn't join the posse") TEST_ASSERT(mech.mech_holds_grudge(ion_shooter), "The mech holds no grudge against an ion shooter") -// ===== THE RESET ===== +// ===== THE RESET: A MACHINE'S REPAIR ARMS ===== /datum/unit_test/voidcrew_bounty_lair_mafia/reset @@ -393,17 +512,18 @@ TEST_ASSERT(!mech.mech_ability_ready(sniper), "The mech would fire at a sniper it can't see") // The don out of the wreck keeps the mech's grudges, and takes on a sniper of his own - var/mob/living/basic/bounty_lair_boss/mafia_don/don = mech.mech_eject() + var/mob/living/carbon/human/npc/bounty_mafia/don/don = mech.mech_eject() TEST_ASSERT_NOTNULL(don, "The don didn't climb out") allocated += don - TEST_ASSERT(don.don_target_ok(sniper), "The don forgot who broke his mech") + don.ai_controller?.set_ai_status(AI_STATUS_OFF) + TEST_ASSERT(don.mafia_target_ok(sniper), "The don forgot who broke his mech") var/mob/living/carbon/human/consistent/second_sniper = hunter_at(far_spot(22)) - TEST_ASSERT(!don.don_target_ok(second_sniper), "The don may go after someone past his sight who never hurt him") - don.don_on_attacked(null, second_sniper, 4) // ATTACKER_DAMAGING_ATTACK - TEST_ASSERT(don.don_target_ok(second_sniper), "The don doesn't answer a sniper who hurt him") + TEST_ASSERT(!don.mafia_target_ok(second_sniper), "The don may go after someone past his sight who never hurt him") + SEND_SIGNAL(don, COMSIG_ATOM_WAS_ATTACKED, second_sniper, 4) // ATTACKER_DAMAGING_ATTACK + TEST_ASSERT(don.mafia_target_ok(second_sniper), "The don doesn't answer a sniper who hurt him") clear_room() -// ===== PHASE TWO: THE WRECK, THE DON, THE RING ===== +// ===== PHASE TWO: THE WRECK, THE DON, HIS CUFFS AND THE RING ===== /datum/unit_test/voidcrew_bounty_lair_mafia/phase_two var/list/ejected = list() @@ -435,34 +555,60 @@ TEST_ASSERT_NOTNULL(wreck, "The mech left no wreck") TEST_ASSERT(!istype(wreck, /obj/structure/mecha_wreckage), "The wreck can be salvaged") TEST_ASSERT(wreck.density, "The wreck is no cover") - var/mob/living/basic/bounty_lair_boss/mafia_don/don = locate() in range(1, where) + var/mob/living/carbon/human/npc/bounty_mafia/don/don = locate() in range(1, where) TEST_ASSERT_NOTNULL(don, "The don didn't climb out") allocated += don - TEST_ASSERT(!ispath(don.type, /mob/living/basic/boss), "The don is a megafauna boss") + don.ai_controller?.set_ai_status(AI_STATUS_OFF) TEST_ASSERT(don in ejected, "The mech didn't announce the don") TEST_ASSERT_EQUAL(don.posting_ref?.resolve(), posting, "The don didn't keep the mech's posting") - TEST_ASSERT_EQUAL(don.name, "Viktor Sokolov", "The don isn't named for his posting") - TEST_ASSERT_EQUAL(don.maxHealth, 325, "The don lost the mech's selected boss profile") // BOUNTY_DON_HEALTH - TEST_ASSERT_EQUAL(don.don_busy, "stagger", "The don isn't dazed as he climbs out") + TEST_ASSERT_EQUAL(don.real_name, "Viktor Sokolov", "The don isn't named for his posting") + TEST_ASSERT(don.don_dazed_until > world.time, "The don isn't getting his bearings as he climbs out") TEST_ASSERT_EQUAL(don.don_wreck_ref?.resolve(), wreck, "The don doesn't know his wreck") - - // Kill-only: the cuffs are refused - var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs, get_turf(hunter)) - TEST_ASSERT_EQUAL(don.item_interaction(hunter, cuffs), ITEM_INTERACT_BLOCKING, "The don could be cuffed") - - // His death drops the ring, with the posting and its record on it + TEST_ASSERT_NOTNULL(locate(/obj/item/gun/ballistic/automatic/pistol/deagle/gold) in don.held_items, "The don climbed out without his gold pistol") + TEST_ASSERT(get_area(don) == get_area(wreck), "The don climbed out of his arena") + + // He never gives up, and the cuffs aren't refused: the cuffer is his target, and while he can move he steps away + TEST_ASSERT_NULL(don.npc_surrender, "The don can give up") + TEST_ASSERT(!(SEND_SIGNAL(don, COMSIG_CARBON_CUFF_ATTEMPTED, hunter) & COMSIG_CARBON_CUFF_PREVENT), "The don refused the cuffs outright") + TEST_ASSERT_EQUAL(don.ai_controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET], hunter, "The don didn't turn on the hunter cuffing him") + TEST_ASSERT(hunter.weak_reference && (hunter.weak_reference in don.don_grudge), "The don holds no grudge against the hunter cuffing him") + hunter.forceMove(get_step(get_turf(don), get_dir(don, wreck) || EAST)) + var/turf/before_step = get_turf(don) + if(don.Adjacent(hunter) && don.mafia_dodge_cuffs(WEAKREF(hunter))) + TEST_ASSERT(get_turf(don) != before_step, "The don stepped away from the cuffs without moving") + // Paralysed (or down), he can't, and the cuffs go on + don.Paralyze(10 SECONDS) + TEST_ASSERT(!don.mafia_can_answer_cuffs(), "A paralysed don can still answer the cuffs") + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(don.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "The don can't be cuffed") + TEST_ASSERT(HAS_TRAIT(don, TRAIT_RESTRAINED), "The cuffed don isn't restrained") + don.SetParalyzed(0) + + // His death: he dies in place, in his clothes, and the ring drops, with the posting and its record on it var/turf/don_spot = get_turf(don) don.death() - TEST_ASSERT(QDELETED(don), "The don outlived his death") + TEST_ASSERT(!QDELETED(don), "The don's body went with his death") + TEST_ASSERT_NOTNULL(don.w_uniform, "The don's body isn't dressed") var/obj/item/bounty_proof/trophy/mafia_don/ring = locate() in don_spot TEST_ASSERT_NOTNULL(ring, "The don dropped no ring") TEST_ASSERT_EQUAL(ring.posting_ref?.resolve(), posting, "The ring doesn't carry the posting") TEST_ASSERT_EQUAL(ring.record, posting.record, "The ring doesn't carry the record") TEST_ASSERT_EQUAL(ring.icon_state, "signet_ring", "The ring doesn't look like a ring") + for(var/obj/item/gun/gun in don_spot) + TEST_FAIL("The don's death dropped [gun]") + + // Brought back and killed again, he drops no second ring + don.revive(HEAL_ALL) + TEST_ASSERT(don.stat != DEAD, "Test fixture: the don couldn't be revived") + don.death() + var/rings = 0 + for(var/obj/item/bounty_proof/trophy/mafia_don/dropped in range(2, don_spot)) + rings++ + TEST_ASSERT_EQUAL(rings, 1, "The don dropped [rings] rings over two deaths, not one") // Wanted on nothing (an admin spawn), he goes by the name on Club Volga's card - var/mob/living/basic/bounty_lair_boss/mafia_don/stray = allocate(/mob/living/basic/bounty_lair_boss/mafia_don, spot(4, 4)) - TEST_ASSERT_EQUAL(stray.name, "Arkady Sokolov", "A don with no posting has no name") // BOUNTY_DON_NAME + var/mob/living/carbon/human/npc/bounty_mafia/don/stray = member_at(/mob/living/carbon/human/npc/bounty_mafia/don, spot(4, 4)) + TEST_ASSERT_EQUAL(stray.real_name, "Arkady Sokolov", "A don with no posting has no name") // BOUNTY_DON_NAME // ===== LIEUTENANTS ===== @@ -475,26 +621,38 @@ /datum/unit_test/voidcrew_bounty_lair_mafia/lieutenants/Run() // Two plain gatekeepers in one room: one of each - var/mob/living/basic/trooper/russian/mafia/lieutenant/first = allocate(/mob/living/basic/trooper/russian/mafia/lieutenant, spot(1, 1)) - var/mob/living/basic/trooper/russian/mafia/lieutenant/second = allocate(/mob/living/basic/trooper/russian/mafia/lieutenant, spot(3, 1)) + var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/first = member_at(/mob/living/carbon/human/npc/bounty_mafia/lieutenant, spot(1, 1)) + var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/second = member_at(/mob/living/carbon/human/npc/bounty_mafia/lieutenant, spot(3, 1)) TEST_ASSERT_EQUAL(first.mafia_lieutenant_role, "tommy", "The first lieutenant isn't Tommy") // BOUNTY_LIEUTENANT_TOMMY TEST_ASSERT_EQUAL(second.mafia_lieutenant_role, "brute", "The second lieutenant isn't Brute") // BOUNTY_LIEUTENANT_BRUTE - TEST_ASSERT_EQUAL(first.maxHealth, 350, "Tommy's health is wrong") // BOUNTY_LIEUTENANT_TOMMY_HEALTH - TEST_ASSERT_EQUAL(second.maxHealth, 400, "Brute's health is wrong") // BOUNTY_LIEUTENANT_BRUTE_HEALTH - TEST_ASSERT(istype(first.mafia_gun, /datum/bounty_mafia_gun/tommy), "Tommy has no Tommy gun") - TEST_ASSERT_NULL(second.mafia_gun, "Brute has a gun") - for(var/mob/living/basic/trooper/russian/mafia/lieutenant/lieutenant as anything in list(first, second)) - TEST_ASSERT_EQUAL(lieutenant.damage_coeff[BRUTE], 0.8, "[lieutenant] takes full brute") // BOUNTY_LIEUTENANT_DAMAGE_MOD - TEST_ASSERT_EQUAL(lieutenant.damage_coeff[BURN], 0.8, "[lieutenant] takes full burn") + TEST_ASSERT_EQUAL(first.real_name, "Tommy", "Tommy isn't called Tommy") + TEST_ASSERT_EQUAL(second.real_name, "Brute", "Brute isn't called Brute") + TEST_ASSERT_NOTNULL(locate(/obj/item/gun/ballistic/automatic/tommygun) in first.held_items, "Tommy has no Thompson") + TEST_ASSERT(first.mafia_windup > 0, "Tommy's gun doesn't come up before it fires") + TEST_ASSERT(!second.get_active_held_item() && !second.get_inactive_held_item(), "Brute holds something") + + // Brute's landed punch now and then knocks the hunter off their feet: tg's Knockdown, and only his own punches + var/mob/living/carbon/human/consistent/victim = hunter_at(spot(4, 1)) + second.mafia_punch_knockdown_chance = 100 + var/mob/living/carbon/human/consistent/bystander = hunter_at(spot(4, 3)) + second.mafia_on_punch_landed(victim, bystander, 5, BRUTE, null, 0, FALSE, NONE) + TEST_ASSERT(!victim.IsKnockdown(), "Someone else's punch knocked the hunter down for Brute") + second.mafia_on_punch_landed(victim, second, 5, BRUTE, null, 0, FALSE, NONE) + TEST_ASSERT(victim.IsKnockdown(), "Brute's landed punch didn't knock the hunter down") + TEST_ASSERT(victim.AmountKnockdown() <= 10, "Brute's knockdown lasts more than a second") // BOUNTY_LIEUTENANT_BRUTE_KNOCKDOWN + TEST_ASSERT_EQUAL(second.mafia_melee_behavior(), /datum/ai_behavior/basic_melee_attack/human_npc/bounty_mafia_brute, "Brute's fists don't listen for a landed punch") + + // Their deaths send the ordinary death signal (P10 opens the gate on it), and their bodies stay where they fell + for(var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/lieutenant as anything in list(first, second)) RegisterSignal(lieutenant, COMSIG_LIVING_DEATH, PROC_REF(on_death)) - - // Their deaths send the ordinary death signal (P10 opens the gate on it) first.death() second.death() TEST_ASSERT_EQUAL(deaths, 2, "A lieutenant's death didn't send COMSIG_LIVING_DEATH") - TEST_ASSERT(QDELETED(first) && QDELETED(second), "A lieutenant outlived his death") + TEST_ASSERT(!QDELETED(first) && !QDELETED(second), "A lieutenant's body went with his death") + for(var/obj/item/gun/gun in spot(1, 1)) + TEST_FAIL("Tommy's death dropped [gun]") -// ===== THE ROOM SPAWNER ===== +// ===== THE ROOM SPAWNER: A ROOM'S PEOPLE ARE QUEUED ===== /datum/unit_test/voidcrew_bounty_lair_mafia/zone_mobs @@ -504,44 +662,51 @@ for(var/list/table in list(probe.mobs_green, probe.mobs_yellow, probe.mobs_red)) TEST_ASSERT(length(table), "A mafia zone table is empty") for(var/entry in table) - TEST_ASSERT(ispath(entry, /mob/living/basic/trooper/russian/mafia), "[entry] isn't a club goon") - TEST_ASSERT(!ispath(entry, /mob/living/basic/trooper/russian/mafia/lieutenant), "[entry] is a lieutenant, which only the gate has") - TEST_ASSERT(!ispath(entry, /mob/living/basic/boss), "[entry] is a megafauna") + TEST_ASSERT(ispath(entry, /mob/living/carbon/human/npc/bounty_mafia), "[entry] isn't one of the club's people") + TEST_ASSERT(!ispath(entry, /mob/living/carbon/human/npc/bounty_mafia/lieutenant), "[entry] is a lieutenant, which only the gate has") + TEST_ASSERT(!ispath(entry, /mob/living/carbon/human/npc/bounty_mafia/don), "[entry] is the don") for(var/list/count_range in list(probe.count_green, probe.count_yellow, probe.count_red)) TEST_ASSERT(count_range[1] == 1 && count_range[2] == 2, "A mafia marker spawns outside 1-2 goons") // P11's rooms get their own crews var/list/casino = bounty_mafia_room_crew("/area/ruin/space/has_grav/powered/mafia_club/casino") TEST_ASSERT_NOTNULL(casino, "The casino has no crew") - TEST_ASSERT_EQUAL(casino[/mob/living/basic/trooper/russian/mafia/pistol], 2, "The casino's pistols are wrong") - TEST_ASSERT_EQUAL(casino[/mob/living/basic/trooper/russian/mafia], 2, "The casino's knives are wrong") - TEST_ASSERT_EQUAL(casino[/mob/living/basic/trooper/russian/mafia/smg], 1, "The casino's SMGs are wrong") + TEST_ASSERT_EQUAL(casino[/mob/living/carbon/human/npc/bounty_mafia/pistol], 2, "The casino's pistols are wrong") + TEST_ASSERT_EQUAL(casino[/mob/living/carbon/human/npc/bounty_mafia], 2, "The casino's knives are wrong") + TEST_ASSERT_EQUAL(casino[/mob/living/carbon/human/npc/bounty_mafia/smg], 1, "The casino's SMGs are wrong") var/list/office = bounty_mafia_room_crew("/area/ruin/space/has_grav/powered/mafia_club/back_office") TEST_ASSERT_EQUAL(length(office), 1, "The back office's crew is wrong") TEST_ASSERT_NULL(bounty_mafia_room_crew("/area/ruin/space/has_grav/powered/mafia_club/garage"), "The arena gets goons") TEST_ASSERT_NULL(bounty_mafia_room_crew(/area/misc/testroom), "A room outside the club gets a club crew") - // Anywhere else, the zone table: 1-2 goons, all on its own floor + // Anywhere else, the zone table: 1-2 goons, queued (never built in the marker's tick), then on its own floor probe.forceMove(spot(2, 2)) - probe.do_spawn(2) // ZONE_YELLOW - var/count = 0 var/area/room = get_area(probe) - for(var/mob/living/basic/trooper/russian/mafia/goon in range(4, probe)) + var/list/orders = probe.do_spawn(2) // ZONE_YELLOW + TEST_ASSERT(length(orders) >= 1 && length(orders) <= 2, "The marker queued [length(orders)] goons, not 1-2") + for(var/datum/human_npc_order/order as anything in orders) + TEST_ASSERT(!order.done && isnull(order.spawned_ref), "The marker built a goon at once instead of queueing it") + TEST_ASSERT(order in SShuman_npcs.queue, "A goon's order isn't in the queue") + for(var/mob/living/carbon/human/npc/bounty_mafia/early in range(4, probe)) + TEST_FAIL("[early] was built in the marker's own tick") + for(var/datum/human_npc_order/order as anything in orders) + var/mob/living/carbon/human/npc/bounty_mafia/goon = order.fulfil() + TEST_ASSERT(istype(goon, /mob/living/carbon/human/npc/bounty_mafia), "A queued goon built [goon || "nothing"]") + if(!goon) + continue allocated += goon - count++ - TEST_ASSERT(!istype(goon, /mob/living/basic/trooper/russian/mafia/lieutenant), "The marker spawned a lieutenant") + goon.ai_controller?.set_ai_status(AI_STATUS_OFF) + TEST_ASSERT(!istype(goon, /mob/living/carbon/human/npc/bounty_mafia/lieutenant), "The marker spawned a lieutenant") TEST_ASSERT_EQUAL(get_area(goon), room, "A goon spawned outside the marker's room") - TEST_ASSERT(count >= 1 && count <= 2, "The marker spawned [count] goons, not 1-2") + TEST_ASSERT_EQUAL(goon.mafia_home_ref?.resolve(), room, "A queued goon didn't take its room as home") qdel(probe) -// ===== GOONS: THE SHOUT, THE DOORWAY, THE DOWNED, NO GUNS ===== +// ===== GOONS: THE SHOUT, THE DOORWAY, THE DOWNED, HOLDS AND CUFFS, COVER, BODIES ===== /datum/unit_test/voidcrew_bounty_lair_mafia/goons /datum/unit_test/voidcrew_bounty_lair_mafia/goons/Run() - var/mob/living/basic/trooper/russian/mafia/pistol/gunman = allocate(/mob/living/basic/trooper/russian/mafia/pistol, spot(0, 0)) - TEST_ASSERT_EQUAL(gunman.maxHealth, 120, "The gunman's health is wrong") // BOUNTY_MOBSTER_PISTOL_HEALTH - TEST_ASSERT(gunman.speed < 1.5, "The gunman can't keep up with a running player") + var/mob/living/carbon/human/npc/bounty_mafia/pistol/gunman = member_at(/mob/living/carbon/human/npc/bounty_mafia/pistol, spot(0, 0)) var/mob/living/carbon/human/consistent/hunter = hunter_at(spot(4, 0), as_client = TRUE) TEST_ASSERT(!gunman.mafia_is_ready(), "The gunman is ready before seeing anyone") @@ -550,23 +715,25 @@ var/datum/bounty_mafia_room/room = gunman.mafia_room() TEST_ASSERT(room?.alerted, "Spotting a hunter didn't alert the room") TEST_ASSERT(gunman.mafia_ready_at >= world.time + 5, "The shout gives less than half a second") // BOUNTY_MOBSTER_ALERT - TEST_ASSERT(!gunman.mafia_attack(hunter), "The gunman shot before his shout was over") + TEST_ASSERT(!gunman.mafia_is_ready(), "The gunman may shoot before his shout is over") gunman.mafia_ready_at = world.time - TEST_ASSERT(gunman.mafia_attack(hunter), "The gunman didn't shoot after his shout") - TEST_ASSERT(!gunman.mafia_gun.ready(), "The pistol has no cooldown") - clear_room() + TEST_ASSERT(gunman.mafia_is_ready(), "The gunman may not shoot after his shout") + TEST_ASSERT(gunman.mafia_plan_attack(gunman.ai_controller, hunter), "The gunman with a line had nothing to do") + TEST_ASSERT(gunman.ai_controller.current_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/bounty_mafia_fire)], "The gunman with a line didn't open fire") + gunman.ai_controller.CancelActions() - // Never the downed + // Never the downed: a hunter in crit is nobody's target, however close (CB-11: only the choice spares them) // Suffocation, not brute: brute lands on one random limb, which caps it hunter.adjustOxyLoss(150) TEST_ASSERT(hunter.stat != CONSCIOUS, "The hunter didn't go down") TEST_ASSERT(!gunman.mafia_target_ok(hunter), "A goon may go after a downed hunter") + TEST_ASSERT_NULL(gunman.mafia_find_target(), "A goon picked a downed hunter as its target") hunter.fully_heal(HEAL_ALL) TEST_ASSERT(gunman.mafia_target_ok(hunter), "The healed hunter isn't fair game again") // At most two of a room on one hunter in a doorway - var/mob/living/basic/trooper/russian/mafia/pistol/second = allocate(/mob/living/basic/trooper/russian/mafia/pistol, spot(0, 1)) - var/mob/living/basic/trooper/russian/mafia/pistol/third = allocate(/mob/living/basic/trooper/russian/mafia/pistol, spot(0, 2)) + var/mob/living/carbon/human/npc/bounty_mafia/pistol/second = member_at(/mob/living/carbon/human/npc/bounty_mafia/pistol, spot(0, 1)) + var/mob/living/carbon/human/npc/bounty_mafia/pistol/third = member_at(/mob/living/carbon/human/npc/bounty_mafia/pistol, spot(0, 2)) gunman.ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) second.ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) TEST_ASSERT(third.mafia_room_has_space(hunter), "The doorway cap applies to a hunter out in the open") @@ -574,7 +741,7 @@ TEST_ASSERT(third.mafia_in_doorway(hunter), "A hunter on a door tile isn't in a doorway") TEST_ASSERT(!third.mafia_room_has_space(hunter), "A third goon went after a hunter in a doorway") // Shooting the held-back goon doesn't pull it in over the cap - third.mafia_on_attacked(null, hunter, 4) // ATTACKER_DAMAGING_ATTACK + SEND_SIGNAL(third, COMSIG_ATOM_WAS_ATTACKED, hunter, 4) // ATTACKER_DAMAGING_ATTACK TEST_ASSERT(third.ai_controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] != hunter, "Shooting a held-back goon took it past the doorway cap") // And a third goon that somehow has them drops them third.ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) @@ -586,29 +753,43 @@ TEST_ASSERT(SEND_SIGNAL(second, COMSIG_LIVING_PRE_WABBAJACKED, "random") & STOP_WABBAJACK, "A goon can be polymorphed") TEST_ASSERT(SEND_SIGNAL(second, COMSIG_PRE_MOB_CHANGED_TYPE) & COMPONENT_BLOCK_MOB_CHANGE, "A goon's type can be changed") - // A grabbed goon fights back: its grabber is its target, and it breaks free + // A grabbed goon fights back: its grabber is its target at once, and its AI resists the hold the tg way + var/datum/ai_planning_subtree/bounty_escape_captivity/bounty_mafia/break_free = new var/datum/ai_planning_subtree/bounty_mafia_goon/mind = new - var/mob/living/basic/trooper/russian/mafia/knife = allocate(/mob/living/basic/trooper/russian/mafia, spot(3, 3)) + var/mob/living/carbon/human/npc/bounty_mafia/knife = member_at(/mob/living/carbon/human/npc/bounty_mafia, spot(3, 3)) var/mob/living/carbon/human/consistent/grabber = hunter_at(spot(4, 3)) grabber.start_pulling(knife) TEST_ASSERT_EQUAL(knife.pulledby, grabber, "The test hunter couldn't grab the goon") - mind.SelectBehaviors(knife.ai_controller, 0.5) TEST_ASSERT_EQUAL(knife.ai_controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET], grabber, "A grabbed goon didn't turn on its grabber") + TEST_ASSERT_EQUAL(break_free.SelectBehaviors(knife.ai_controller, 0.5), SUBTREE_RETURN_FINISH_PLANNING, "A grabbed goon didn't put breaking free first") TEST_ASSERT(knife.ai_controller.current_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/resist)], "A grabbed goon didn't try to break free") - grabber.stop_pulling() knife.ai_controller.CancelActions() + // Dragged, it goes: nothing holds it in its room while someone else moves it + TEST_ASSERT(knife.mafia_leash_allows(spot(4, 4)), "A dragged goon's room held it") + grabber.stop_pulling() // Dragged out of its room, it still fights whoever it can reach, and only walks back once they're gone knife.mafia_ready_at = world.time knife.mafia_home_ref = WEAKREF(GLOB.areas_by_type[/area/space]) TEST_ASSERT(!knife.mafia_at_home(), "The goon isn't out of its room") mind.SelectBehaviors(knife.ai_controller, 0.5) - TEST_ASSERT(knife.ai_controller.current_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/bounty_mafia_goon_attack)], "A goon out of its room didn't fight the hunter next to it") + TEST_ASSERT(knife.ai_controller.current_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/basic_melee_attack/human_npc)], "A goon out of its room didn't fight the hunter next to it") knife.ai_controller.CancelActions() knife.mafia_home_ref = WEAKREF(get_area(knife)) + // Someone starting to cuff a goon who can still move: it turns on them and steps away, a real step; paralysed, it can't + TEST_ASSERT(!(SEND_SIGNAL(knife, COMSIG_CARBON_CUFF_ATTEMPTED, grabber) & COMSIG_CARBON_CUFF_PREVENT), "A goon refused the cuffs outright") + TEST_ASSERT_EQUAL(knife.ai_controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET], grabber, "A goon didn't turn on the hunter cuffing it") + var/turf/cuffed_at = get_turf(knife) + TEST_ASSERT(knife.mafia_dodge_cuffs(WEAKREF(grabber)), "A free goon didn't step away from the cuffs") + TEST_ASSERT(get_turf(knife) != cuffed_at, "The goon's step away from the cuffs went nowhere") + knife.forceMove(cuffed_at) + knife.Paralyze(10 SECONDS) + TEST_ASSERT(!knife.mafia_dodge_cuffs(WEAKREF(grabber)), "A paralysed goon stepped away from the cuffs") + knife.SetParalyzed(0) + // A gunman in an alerted room kicks a table over, on its side toward the hunter - var/mob/living/basic/trooper/russian/mafia/pistol/fourth = allocate(/mob/living/basic/trooper/russian/mafia/pistol, spot(1, 4)) + var/mob/living/carbon/human/npc/bounty_mafia/pistol/fourth = member_at(/mob/living/carbon/human/npc/bounty_mafia/pistol, spot(1, 4)) var/obj/structure/table/wood/table = allocate(/obj/structure/table/wood, spot(2, 4)) var/mob/living/carbon/human/consistent/across = hunter_at(spot(4, 4)) TEST_ASSERT_EQUAL(fourth.mafia_take_cover(across), table, "A gunman didn't take the table beside him for cover") @@ -624,19 +805,17 @@ TEST_ASSERT_EQUAL(knife.mafia_exit_toward(hunter), get_turf(exit_door), "A goon doesn't know its room's door") clear_room() - // They leave no gun behind: the corpse wears club clothes, and that's all - for(var/goon_type in list(/mob/living/basic/trooper/russian/mafia, /mob/living/basic/trooper/russian/mafia/pistol, /mob/living/basic/trooper/russian/mafia/smg, /mob/living/basic/trooper/russian/mafia/lieutenant/tommy)) + // They die in place in their club clothes, and their guns stay locked on them: none lies on the floor + for(var/goon_type in list(/mob/living/carbon/human/npc/bounty_mafia, /mob/living/carbon/human/npc/bounty_mafia/pistol, /mob/living/carbon/human/npc/bounty_mafia/smg, /mob/living/carbon/human/npc/bounty_mafia/lieutenant/tommy)) var/turf/where = spot(2, 3) - var/mob/living/basic/trooper/russian/mafia/goon = allocate(goon_type, where) + var/mob/living/carbon/human/npc/bounty_mafia/goon = member_at(goon_type, where) goon.death() - TEST_ASSERT(QDELETED(goon), "[goon_type] outlived its death") - var/mob/living/carbon/human/corpse = locate() in where - TEST_ASSERT_NOTNULL(corpse, "[goon_type] left no corpse") - TEST_ASSERT_NOTNULL(corpse.w_uniform, "[goon_type]'s corpse isn't dressed") + TEST_ASSERT(!QDELETED(goon), "[goon_type]'s body went with its death") + TEST_ASSERT_EQUAL(get_turf(goon), where, "[goon_type]'s body moved when it died") + TEST_ASSERT_NOTNULL(goon.w_uniform, "[goon_type]'s body isn't dressed") for(var/obj/item/gun/gun in where) TEST_FAIL("[goon_type] dropped [gun]") - for(var/obj/item/gun/gun in corpse.get_all_contents()) - TEST_FAIL("[goon_type]'s corpse has [gun]") + qdel(goon) clear_room() // ===== THE RING, THE REAL WAY: THROUGH P10'S LAIR AND ITS TROPHY CALL ===== @@ -709,13 +888,15 @@ TEST_ASSERT(abs(keeper.maxHealth - 115) < 0.001, "Lair gatekeeper used the ordinary profile") TEST_ASSERT_EQUAL(mech.posting_ref?.resolve(), posting, "The mech isn't wanted on the lair's bounty") - // The mech breaks: the lair takes the don on as he climbs out, and the fight goes on past its grace + // The mech breaks: the lair takes the don, a person, on as he climbs out, and the fight goes on past its grace var/turf/wreck_spot = get_turf(mech) mech.death() - var/mob/living/basic/bounty_lair_boss/mafia_don/don = locate() in range(1, wreck_spot) + var/mob/living/carbon/human/npc/bounty_mafia/don/don = locate() in range(1, wreck_spot) TEST_ASSERT_NOTNULL(don, "The don didn't climb out") allocated += don + don.ai_controller?.set_ai_status(AI_STATUS_OFF) TEST_ASSERT(don.weak_reference && (don.weak_reference in fake_lair.lair_bosses), "The lair didn't take on the don") + TEST_ASSERT_EQUAL(bounty_kill_posting_of(don), posting, "The don on foot isn't wanted on the lair's bounty") sleep(6 SECONDS) // BOUNTY_LAIR_TROPHY_GRACE 5 seconds TEST_ASSERT(!fake_lair.lair_boss_killed, "The lair called the fight over with the don standing") TEST_ASSERT_NULL(posting.board_proof(), "A trophy dropped with the don standing") @@ -727,7 +908,11 @@ sleep(6 SECONDS) // BOUNTY_LAIR_TROPHY_GRACE 5 seconds TEST_ASSERT(fake_lair.lair_boss_killed, "The lair didn't call the fight over after the don died") TEST_ASSERT_EQUAL(posting.board_proof(), ring, "The bounty's proof changed after the don died") + // Brought back and killed again, nothing more drops + don.revive(HEAL_ALL) + don.death() var/rings = 0 for(var/obj/item/bounty_proof/trophy/trophy in range(6, origin)) rings++ TEST_ASSERT_EQUAL(rings, 1, "The kill left [rings] trophies, not one") + TEST_ASSERT_EQUAL(posting.board_proof(), ring, "A second death changed the bounty's proof") diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_lair_maps.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_lair_maps.dm index 22619b20d03..c89eb9c0c7a 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_lair_maps.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_lair_maps.dm @@ -15,6 +15,17 @@ var/datum/turf_reservation/reserve /datum/unit_test/voidcrew_bounty_lair_maps/Destroy() + // The club's markers queue its people (spawn_human_npc()): none of them is built into the block once it's given back + if(reserve) + var/list/block_turfs = list() + for(var/turf/tile as anything in reserve.reserved_turfs) + block_turfs[tile] = TRUE + for(var/datum/human_npc_order/order as anything in SShuman_npcs.queue.Copy()) + if(block_turfs[order.where]) + qdel(order) + for(var/turf/tile as anything in reserve.reserved_turfs) + for(var/mob/living/carbon/human/npc/bounty_mafia/member in tile) + qdel(member) QDEL_NULL(reserve) return ..() @@ -84,7 +95,7 @@ // The gatekeepers: two lieutenants, outside the garage TEST_ASSERT_EQUAL(length(gatekeepers), 2, "The club should hold two gatekeeper landmarks") for(var/obj/effect/landmark/bounty_lair_gatekeeper/keeper as anything in gatekeepers) - TEST_ASSERT_EQUAL(keeper.gatekeeper_type, /mob/living/basic/trooper/russian/mafia/lieutenant, "A gatekeeper landmark is not set to the lieutenant") + TEST_ASSERT_EQUAL(keeper.gatekeeper_type, /mob/living/carbon/human/npc/bounty_mafia/lieutenant, "A gatekeeper landmark is not set to the lieutenant") TEST_ASSERT(istype(get_area(keeper), /area/ruin/space/has_grav/powered/mafia_club/back_office), "A gatekeeper landmark is not in the back office, in front of the garage door") // The garage door: three closed, indestructible blast doors, the club's only poddoors diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_lairs.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_lairs.dm index cce14cf88e3..c85a9988ffe 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_lairs.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_lairs.dm @@ -82,6 +82,20 @@ lair.loaded = TRUE return lair +/** + * Calls off every person still queued to be built inside `footprint` (spawn_human_npc()), and deletes the club's people + * already built there: a lair the test tears down must not have bodies built into it afterwards. + */ +/datum/unit_test/voidcrew_bounty_lairs/proc/test_clear_people(datum/map_footprint/footprint) + if(!footprint) + return + for(var/datum/human_npc_order/order as anything in SShuman_npcs.queue.Copy()) + if(order.where && footprint.contains_turf(order.where)) + qdel(order) + for(var/mob/living/carbon/human/npc/bounty_mafia/member as anything in GLOB.mob_living_list) + if(istype(member) && footprint.contains_turf(get_turf(member))) + qdel(member) + /// Sends the load signal to `lair` and waits for it to finish linking /datum/unit_test/voidcrew_bounty_lairs/proc/test_link(obj/structure/overmap/space_ruin/bounty_lair/lair) SEND_SIGNAL(lair, "voidcrew_planet_loaded", TRUE) // COMSIG_VOIDCREW_PLANET_LOADED @@ -101,10 +115,10 @@ var/obj/structure/overmap/ship/ship = board_test_ship() var/datum/criminal_bounty/kill_only/posting = test_posting(5000, 3) - // Dead or nothing: no capture state pays + // Dead or nothing: no capture state pays, the state read at the pad being the only thing it looks at for(var/state in list("restrained", "stunned", "downed", "free")) // BOUNTY_STATE_* - TEST_ASSERT_EQUAL(posting.board_share_for(state, state), 0, "A kill-only bounty pays for a [state] boss") - TEST_ASSERT_EQUAL(posting.board_share_for("dead", "dead"), 100, "A kill-only bounty doesn't pay in full on the kill") // BOUNTY_STATE_DEAD + TEST_ASSERT_EQUAL(posting.board_share_for(state), 0, "A kill-only bounty pays for a [state] boss") + TEST_ASSERT_EQUAL(posting.board_share_for("dead"), 100, "A kill-only bounty doesn't pay in full on the kill") // BOUNTY_STATE_DEAD // The card says wanted dead, with the full value as its one figure; the warrant says WANTED: DEAD var/list/card = posting.board_ui_entry(ship, pad) @@ -123,6 +137,16 @@ var/list/preview = posting.board_pad_preview(ship, pad) TEST_ASSERT(!preview[1], "The card offers to turn in a body") + // Nor the don on foot, a person: wanted on it, cuffed on the pad or dead on it, he is never what pays + var/mob/living/carbon/human/npc/bounty_mafia/don/person = allocate(/mob/living/carbon/human/npc/bounty_mafia/don, pad_turf) + person.ai_controller?.set_ai_status(AI_STATUS_OFF) + person.posting_ref = WEAKREF(posting) + TEST_ASSERT_EQUAL(bounty_kill_posting_of(person), posting, "The don on foot isn't wanted on his bounty") + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(person.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the don could not be cuffed") + TEST_ASSERT(istext(posting.board_target_refusal(pad, found)), "The pad took the don cuffed on it") + qdel(person) + // The trophy drops where the boss fell, bound to the bounty, and there is only ever one var/obj/item/bounty_proof/trophy/trophy = posting.kill_drop_trophy(aside) TEST_ASSERT_NOTNULL(trophy, "The kill dropped no trophy") @@ -373,6 +397,55 @@ TEST_ASSERT(!lair.mission_locked, "Closing the bounty didn't unlock its lair") TEST_ASSERT_NULL(lair.posting_ref, "The released lair still points at its bounty") +/** + * Gatekeepers that are people are queued, never built in one tick: until each is built it holds the gate, once built + * it is watched like any gatekeeper, and an order that never builds a body (cancelled) stops holding it. + */ +/datum/unit_test/voidcrew_bounty_lairs/queued_gatekeepers + +/datum/unit_test/voidcrew_bounty_lairs/queued_gatekeepers/Run() + var/turf/origin = run_loc_floor_bottom_left + var/datum/criminal_bounty/kill_only/lair/posting = test_posting(5000, 3, /datum/criminal_bounty/kill_only/lair) + var/obj/structure/overmap/space_ruin/bounty_lair/lair = test_fake_lair(posting, 3) + for(var/offset in 0 to 1) + var/obj/effect/landmark/bounty_lair_gatekeeper/keeper_mark = new(locate(origin.x + offset, origin.y + 2, origin.z)) + keeper_mark.gatekeeper_type = /mob/living/carbon/human/npc/bounty_mafia/lieutenant + lair_test_made += keeper_mark + var/obj/machinery/door/poddoor/gate = allocate(/obj/machinery/door/poddoor, locate(origin.x + 2, origin.y, origin.z)) + gate.id = "bounty_lair_gate" // BOUNTY_LAIR_GATE_ID + + TEST_ASSERT(test_link(lair), "The lair never finished linking") + TEST_ASSERT_EQUAL(length(lair.lair_gatekeepers), 0, "A gatekeeper who is a person was built at the link, not queued") + TEST_ASSERT_EQUAL(lair.lair_pending_gatekeeper_count(), 2, "The two queued gatekeepers aren't on their way") + TEST_ASSERT(!lair.lair_gate_open, "The gate opened while its gatekeepers were still on their way") + TEST_ASSERT(!lair.lair_check_gate(), "The gate opened on a check while its gatekeepers were still on their way") + + // The first is built: it holds the gate, kept to its room, and its order is done + var/list/keys = lair.lair_pending_gatekeepers.Copy() + var/datum/human_npc_order/first_order = lair.lair_pending_gatekeepers[keys[1]] + var/datum/human_npc_order/second_order = lair.lair_pending_gatekeepers[keys[2]] + var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/keeper = first_order.fulfil() + TEST_ASSERT(istype(keeper), "The queued gatekeeper built [keeper || "nothing"]") + allocated += keeper + keeper.ai_controller?.set_ai_status(AI_STATUS_OFF) + TEST_ASSERT(keeper.weak_reference && (keeper.weak_reference in lair.lair_gatekeepers), "The built gatekeeper isn't watched") + TEST_ASSERT_EQUAL(lair.lair_pending_gatekeeper_count(), 1, "The built gatekeeper is still counted as on its way") + + // The second is called off: it stops holding the gate, but the first still does + qdel(second_order) + TEST_ASSERT(!lair.lair_check_gate(), "The gate opened with its built gatekeeper standing") + TEST_ASSERT_EQUAL(lair.lair_pending_gatekeeper_count(), 0, "A cancelled gatekeeper is still counted as on its way") + + // The built one dies in place: the gate opens + keeper.death() + TEST_ASSERT(lair.lair_gate_open, "The gate didn't open when the last gatekeeper died") + TEST_ASSERT(!QDELETED(keeper), "The dead gatekeeper's body went") + + // Gone (faked footprint dropped first: it holds no slot) + lair.footprint = null + lair.loaded = FALSE + posting.close("admin") // BOUNTY_CLOSE_ADMIN + /// The boss's next phase takes over from it; a boss deleted alive (an admin) ends the bounty instead of paying out /datum/unit_test/voidcrew_bounty_lairs/phases @@ -458,6 +531,26 @@ var/deadline = world.time + 5 SECONDS UNTIL(!goon.throwing || world.time >= deadline) TEST_ASSERT(get_area(goon) != leash_test_home, "A thrown mob was held in its room") + goon.forceMove(locate(origin.x + 1, origin.y + 3, origin.z)) + + // Buckled to something someone moves (a wheeled chair, a carrier's shoulders), it goes with it + var/mob/living/carbon/human/consistent/seated = allocate(/mob/living/carbon/human/consistent, home_spot) + seated.AddElement(/datum/element/bounty_lair_room_leash, leash_test_home.type) + var/obj/structure/chair/office/chair = allocate(/obj/structure/chair/office, home_spot) + TEST_ASSERT(chair.buckle_mob(seated, force = TRUE), "Test fixture: the leashed person couldn't be buckled") + chair.Move(door_spot, EAST) + TEST_ASSERT_EQUAL(get_turf(seated), door_spot, "A leashed person buckled to a chair held the chair in their room") + chair.unbuckle_mob(seated) + seated.forceMove(locate(origin.x + 2, origin.y + 3, origin.z)) + qdel(chair) + + // Shoved out of its room, it goes: the push is the shover's move (a leash that held it would knock it down instead) + var/mob/living/carbon/human/consistent/shoved = allocate(/mob/living/carbon/human/consistent, home_spot) + shoved.AddElement(/datum/element/bounty_lair_room_leash, leash_test_home.type) + var/mob/living/carbon/human/consistent/shover = allocate(/mob/living/carbon/human/consistent, locate(origin.x, origin.y + 1, origin.z)) + shover.disarm(shoved) + TEST_ASSERT_EQUAL(get_turf(shoved), door_spot, "A shove couldn't push a leashed person out of their room") + TEST_ASSERT(!shoved.IsKnockdown(), "A shove at a room's door knocked a leashed person down as if into a wall") /// Where a trophy lands: on the floor it fell on, the nearest floor reached past a hole, and never in space or outside its lair /datum/unit_test/voidcrew_bounty_lairs/trophy_spot @@ -512,7 +605,14 @@ sleep(1) TEST_ASSERT_EQUAL(test_count(/mob/living/basic/bounty_lair_boss/mafia_mech, lair.footprint), 1, "Loading the club again spawned a second mech") - // The gate: two lieutenants, three closed doors that open when both are dead + // The gate: two lieutenants, queued as people (never built in one tick), holding it from the start; three closed + // doors that open when both are dead + TEST_ASSERT(!lair.lair_gate_open, "The garage opened before its lieutenants were built") + TEST_ASSERT_EQUAL(length(lair.lair_gatekeepers) + lair.lair_pending_gatekeeper_count(), 2, "Club Volga should have two gatekeepers, built or on their way") + for(var/key in lair.lair_pending_gatekeepers.Copy()) + var/datum/human_npc_order/order = lair.lair_pending_gatekeepers[key] + order?.fulfil() + TEST_ASSERT_EQUAL(lair.lair_pending_gatekeeper_count(), 0, "A lieutenant is still on its way after its order was built") TEST_ASSERT_EQUAL(length(lair.lair_gatekeepers), 2, "Club Volga should have two gatekeepers") TEST_ASSERT_EQUAL(length(lair.lair_gate_doors), 3, "Club Volga's gate should be three doors") var/list/gate_doors = list() @@ -526,11 +626,16 @@ keeper.ai_controller?.set_ai_status(AI_STATUS_OFF) keepers += keeper var/mob/living/first_keeper = keepers[1] - TEST_ASSERT(istype(first_keeper, /mob/living/basic/trooper/russian/mafia/lieutenant), "The gatekeepers aren't the lieutenants") + var/mob/living/second_keeper = keepers[2] + TEST_ASSERT(istype(first_keeper, /mob/living/carbon/human/npc/bounty_mafia/lieutenant), "The gatekeepers aren't the lieutenants") TEST_ASSERT(istype(get_area(first_keeper), /area/ruin/space/has_grav/powered/mafia_club/back_office), "A lieutenant isn't in the back office") + var/list/roles = list() + for(var/mob/living/carbon/human/npc/bounty_mafia/lieutenant/lieutenant as anything in keepers) + roles |= lieutenant.mafia_lieutenant_role + TEST_ASSERT(("tommy" in roles) && ("brute" in roles), "The gate isn't held by one Tommy and one Brute") // BOUNTY_LIEUTENANT_TOMMY, _BRUTE first_keeper.death() TEST_ASSERT(!lair.lair_gate_open, "The garage opened with a lieutenant still standing") - var/mob/living/second_keeper = keepers[2] + TEST_ASSERT(!QDELETED(first_keeper), "A dead lieutenant's body went") second_keeper.death() TEST_ASSERT(lair.lair_gate_open, "The garage didn't open when both lieutenants died") sleep(3 SECONDS) @@ -539,6 +644,7 @@ // Closed: torn down once empty, and nothing replaces it (a rare ruin never respawns) TEST_ASSERT(lair.rare, "The club is not rare, so tearing it down would spawn a replacement") + test_clear_people(lair.footprint) posting.close("admin") // BOUNTY_CLOSE_ADMIN deadline = world.time + 2 MINUTES UNTIL(QDELETED(lair) || world.time >= deadline) diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm index eae403b3226..7724d6a0708 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_outpost.dm @@ -4,17 +4,31 @@ * Owner: P6 outposts. Do not edit this file outside that package. Fork defines are included after * the tests, so a test uses the literal value with a comment naming the define. * - * The tests make a trader outpost out of the test room (its hangar lift in the top right corner) - * and drive the procs directly. They pin what the other packages give back where it matters (a - * test fugitive that records being confronted, a test posting that records being relisted). + * The fugitive and its decoys are real human NPCs. The tests make a trader outpost out of the test room + * (its hangar lift in the top right corner) and drive the procs directly. They pin what the other + * packages give back where it matters (a test fugitive that records being confronted, a test posting + * that records being relisted). Nothing here sleeps while someone is leaving: the human NPC + * subsystem sweeps leavers on its own every second, and a test that wants a sweep makes it itself. */ +/// What a fugitive wears before it changes into the zone's clothes: armour, a helmet, a mask, gloves, +/// boots, and a medipen in a pocket +/datum/outfit/bounty_outpost_test_criminal + name = "Outpost test criminal" + uniform = /obj/item/clothing/under/color/black + suit = /obj/item/clothing/suit/armor/vest + head = /obj/item/clothing/head/helmet + mask = /obj/item/clothing/mask/surgical + gloves = /obj/item/clothing/gloves/color/black + shoes = /obj/item/clothing/shoes/jackboots + l_pocket = /obj/item/reagent_containers/hypospray/medipen + /// A fugitive that remembers who confronted it -/mob/living/basic/bounty_criminal/meek/outpost_test +/mob/living/carbon/human/npc/bounty_criminal/meek/outpost_test /// Whoever on_confronted() was last called with var/confronted_by -/mob/living/basic/bounty_criminal/meek/outpost_test/on_confronted(mob/user) +/mob/living/carbon/human/npc/bounty_criminal/meek/outpost_test/on_confronted(mob/user) confronted_by = user return ..() @@ -37,6 +51,10 @@ var/list/outpost_test_minds = list() /datum/unit_test/voidcrew_bounty_outpost/Destroy() + // The decoys come out of the spawn queue, not out of allocate(): they go with their posting + for(var/datum/criminal_bounty/posting in allocated) + for(var/mob/living/decoy as anything in posting.outpost_forget_decoys()) + qdel(decoy) GLOB.criminal_bounties -= allocated // A mind or a team left pointing at an allocated ship would keep it from being deleted for(var/datum/mind/mind as anything in outpost_test_minds) @@ -58,16 +76,25 @@ new_outpost.lobby_alcove_turfs = list(run_loc_floor_top_right) return new_outpost +/// A criminal of `fugitive_type` for `record` at `where`, built now in the test criminal's clothes, with the record's face +/datum/unit_test/voidcrew_bounty_outpost/proc/make_fugitive(fugitive_type, turf/where, datum/bounty_record/record) + var/datum/human_npc_order/order = spawn_human_npc(fugitive_type, where || run_loc_floor_bottom_left, look = record, outfit = /datum/outfit/bounty_outpost_test_criminal, name = record.name, immediate = TRUE) + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = order?.spawned_ref?.resolve() + qdel(order) + if(fugitive) + allocated += fugitive + fugitive.record = record + return fugitive + /// An open trader-outpost posting at `outpost` with its fugitive standing at `where` -/datum/unit_test/voidcrew_bounty_outpost/proc/make_posting(turf/where, fugitive_type = /mob/living/basic/bounty_criminal/meek/outpost_test) +/datum/unit_test/voidcrew_bounty_outpost/proc/make_posting(turf/where, fugitive_type = /mob/living/carbon/human/npc/bounty_criminal/meek/outpost_test) var/datum/bounty_record/record = generate_bounty_record(1, "meek", "trader_outpost") // BOUNTY_TIER_PETTY, BOUNTY_ARCHETYPE_MEEK, BOUNTY_PLACEMENT_TRADER_OUTPOST var/datum/criminal_bounty/outpost_test/posting = allocate(/datum/criminal_bounty/outpost_test) posting.record = record posting.placement_kind = "trader_outpost" // BOUNTY_PLACEMENT_TRADER_OUTPOST posting.site_ref = WEAKREF(outpost) posting.value = 1234 - var/mob/living/basic/bounty_criminal/fugitive = allocate(fugitive_type, where || run_loc_floor_bottom_left) - fugitive.record = record + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = make_fugitive(fugitive_type, where, record) fugitive.posting_ref = WEAKREF(posting) posting.criminal_ref = WEAKREF(fugitive) GLOB.criminal_bounties += posting @@ -91,12 +118,12 @@ /datum/unit_test/voidcrew_bounty_outpost/proc/decoys_of(datum/criminal_bounty/posting) . = list() for(var/datum/weakref/decoy_ref as anything in posting.decoys) - var/mob/living/basic/bounty_criminal/decoy/decoy = decoy_ref?.resolve() + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoy_ref?.resolve() if(decoy) . += decoy /// A mob's examine as plain text, with its own feature lines taken out -/datum/unit_test/voidcrew_bounty_outpost/proc/plain_examine(mob/living/basic/bounty_criminal/patron, mob/viewer) +/datum/unit_test/voidcrew_bounty_outpost/proc/plain_examine(mob/living/carbon/human/npc/bounty_criminal/patron, mob/viewer) var/list/features = list() for(var/line in patron.record?.feature_lines()) features += trim(STRIP_HTML_FULL("[line]", MAX_MESSAGE_LEN)) @@ -115,14 +142,18 @@ shared++ return shared +/// Whether `walker` is on its way out: tracked as a leaver, with an exit its AI knows (the key is BB_HUMAN_NPC_EXIT) +/datum/unit_test/voidcrew_bounty_outpost/proc/is_leaving(mob/living/carbon/human/npc/walker) + return (walker in SShuman_npcs.leavers) && !isnull(walker.npc_exit) && walker.ai_controller?.blackboard["human_npc_exit"] == walker.npc_exit + /// Setting up: the fugitive blends in, three decoys share one or two of its features, and an outpost takes at most two fugitives and never a mini-boss /datum/unit_test/voidcrew_bounty_outpost/blend /datum/unit_test/voidcrew_bounty_outpost/blend/Run() outpost = make_outpost() var/datum/criminal_bounty/outpost_test/posting = make_posting() - var/mob/living/basic/bounty_criminal/fugitive = posting.criminal() - TEST_ASSERT(posting.setup_outpost_blend(), "A trader-outpost posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() + TEST_ASSERT(posting.setup_outpost_blend(TRUE), "A trader-outpost posting could not be set up to blend in") TEST_ASSERT(fugitive.blended, "The fugitive is not blending in") var/list/decoys = decoys_of(posting) @@ -133,7 +164,8 @@ TEST_ASSERT(shared >= 1 && shared <= 2, "A decoy was made to share [shared] features, not one or two") if(length(wanted_features)) TEST_ASSERT(shared < length(wanted_features), "A decoy was made to share all [length(wanted_features)] of the fugitive's features") - for(var/mob/living/basic/bounty_criminal/decoy/decoy as anything in decoys) + for(var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy as anything in decoys) + TEST_ASSERT(ishuman(decoy), "A decoy is not a person") TEST_ASSERT(decoy.blended, "A decoy is not blending in") TEST_ASSERT(decoy.record && decoy.record != posting.record, "A decoy has no record of its own") TEST_ASSERT_NULL(decoy.posting_ref, "A decoy is linked to the posting as if it were the criminal") @@ -147,24 +179,51 @@ // At most two fugitives at one outpost, and never a mini-boss (AR-C9) var/datum/criminal_bounty/outpost_test/second = make_posting(run_loc_floor_bottom_left) - TEST_ASSERT(second.setup_outpost_blend(), "A second fugitive was refused at the outpost") + TEST_ASSERT(second.setup_outpost_blend(TRUE), "A second fugitive was refused at the outpost") var/datum/criminal_bounty/outpost_test/third = make_posting(run_loc_floor_bottom_left) - TEST_ASSERT(!third.setup_outpost_blend(), "A third fugitive was let into one outpost") - var/datum/criminal_bounty/outpost_test/boss_posting = make_posting(run_loc_floor_bottom_left, /mob/living/basic/bounty_criminal/boss) + TEST_ASSERT(!third.setup_outpost_blend(TRUE), "A third fugitive was let into one outpost") + var/datum/criminal_bounty/outpost_test/boss_posting = make_posting(run_loc_floor_bottom_left, /mob/living/carbon/human/npc/bounty_criminal/boss) boss_posting.record.archetype = "boss" // BOUNTY_ARCHETYPE_BOSS second.status = "closed" // BOUNTY_POSTING_CLOSED: the outpost has room again - TEST_ASSERT(!boss_posting.setup_outpost_blend(), "A mini-boss was set up at a trader outpost") - TEST_ASSERT(third.setup_outpost_blend(), "A closed posting still counted against the outpost's fugitives") + TEST_ASSERT(!boss_posting.setup_outpost_blend(TRUE), "A mini-boss was set up at a trader outpost") + TEST_ASSERT(third.setup_outpost_blend(TRUE), "A closed posting still counted against the outpost's fugitives") // A record whose mugshot is already out keeps the face everyone has seen (M3) var/datum/criminal_bounty/outpost_test/published = make_posting(run_loc_floor_bottom_left) third.status = "closed" // BOUNTY_POSTING_CLOSED published.record.old_look = null published.record.mugshot = "published" - TEST_ASSERT(published.setup_outpost_blend(), "A relisted posting could not be set up") + TEST_ASSERT(published.setup_outpost_blend(TRUE), "A relisted posting could not be set up") TEST_ASSERT_NULL(published.record.old_look, "A published face was given an invented past") TEST_ASSERT_EQUAL(published.record.mugshot, "published", "A published mugshot was thrown away") +/// Decoys are human NPCs, so they come out of the spawn queue: the posting counts them while they wait, a closed posting cancels the ones not built, and the ones built join it +/datum/unit_test/voidcrew_bounty_outpost/queue + +/datum/unit_test/voidcrew_bounty_outpost/queue/Run() + outpost = make_outpost() + var/datum/criminal_bounty/outpost_test/posting = make_posting() + TEST_ASSERT(posting.setup_outpost_blend(), "A trader-outpost posting could not be set up to blend in") + TEST_ASSERT_EQUAL(length(posting.outpost_decoy_orders), 3, "The decoys were not put in the spawn queue") // BOUNTY_DECOY_COUNT + TEST_ASSERT_EQUAL(length(decoys_of(posting)), 0, "The decoys were built at once, not queued") + SShuman_npcs.process_spawns(10) + var/list/decoys = decoys_of(posting) + TEST_ASSERT_EQUAL(length(decoys), 3, "The queued decoys did not join the posting once built") + for(var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy as anything in decoys) + TEST_ASSERT(decoy.blended, "A queued decoy is not blending in") + TEST_ASSERT_EQUAL(decoy.name, "patron", "A queued decoy does not go by the zone's name") + + // Closed before they are built: nobody is built + var/datum/criminal_bounty/outpost_test/closing = make_posting(run_loc_floor_bottom_left) + TEST_ASSERT(closing.setup_outpost_blend(), "The second posting could not be set up to blend in") + TEST_ASSERT_EQUAL(length(closing.outpost_decoy_orders), 3, "The second posting's decoys were not queued") + var/queued_before = length(SShuman_npcs.queue) + closing.outpost_release_site() + TEST_ASSERT_EQUAL(length(closing.outpost_decoy_orders), 0, "A released posting kept its queued decoys") + TEST_ASSERT_EQUAL(length(SShuman_npcs.queue), queued_before - 3, "A released posting left its decoys in the spawn queue") + SShuman_npcs.process_spawns(10) + TEST_ASSERT_EQUAL(length(decoys_of(closing)), 0, "Decoys were built for a posting that had let them go") + /// Only customers' floor: a fugitive placed behind a counter moves out onto the concourse, and the decoys come from the same floor (M5) /datum/unit_test/voidcrew_bounty_outpost/public_floor @@ -175,48 +234,96 @@ for(var/x in run_loc_floor_bottom_left.x to run_loc_floor_top_right.x) allocate(/obj/structure/table, locate(x, counter_y, run_loc_floor_bottom_left.z)) var/datum/criminal_bounty/outpost_test/posting = make_posting(run_loc_floor_bottom_left) - var/mob/living/basic/bounty_criminal/fugitive = posting.criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() var/list/floor = bounty_outpost_public_floor(outpost) TEST_ASSERT(!(run_loc_floor_bottom_left in floor), "Floor behind the counter counts as the customers'") - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") + TEST_ASSERT(posting.setup_outpost_blend(TRUE), "The posting could not be set up to blend in") TEST_ASSERT(fugitive.y > counter_y, "The fugitive stayed behind the counter") - for(var/mob/living/basic/bounty_criminal/decoy/decoy as anything in decoys_of(posting)) + for(var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy as anything in decoys_of(posting)) TEST_ASSERT(decoy.y > counter_y, "A decoy spawned behind the counter") // P5 places fugitives with the same picker var/turf/spot = bounty_outpost_spawn_spot(outpost) TEST_ASSERT(spot && (spot in floor) && !(spot in outpost.lobby_alcove_turfs), "The spawn picker gave [spot], not the customers' floor") -/// The fugitive and its decoys look alike to a player: the same generic name, never the alias, and the same examine, body, speed and time down -/datum/unit_test/voidcrew_bounty_outpost/names +/// The fugitive and its decoys look alike to a player: the same species, build, skin, eyes, clothes, generic name and description, never the alias, and the same toughness and pace; and the fugitive's own armour and kit are gone from sight, not on the floor +/datum/unit_test/voidcrew_bounty_outpost/look_alikes -/datum/unit_test/voidcrew_bounty_outpost/names/Run() +/datum/unit_test/voidcrew_bounty_outpost/look_alikes/Run() outpost = make_outpost(/obj/structure/overmap/trader_outpost/outfitter) outpost.lobby_alcove_turfs = list(run_loc_floor_top_right) var/datum/criminal_bounty/outpost_test/posting = make_posting() - var/mob/living/basic/bounty_criminal/fugitive = posting.criminal() - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() + // Tougher than a person, so the decoys have something to match + fugitive.set_npc_toughness(0.8) + TEST_ASSERT_NOTNULL(fugitive.wear_suit, "Test fixture: the fugitive wears no armour") + TEST_ASSERT_NOTNULL(fugitive.head, "Test fixture: the fugitive wears no helmet") + 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), "No decoys to compare with") - var/mob/living/basic/bounty_criminal/decoy/decoy = decoys[1] + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoys[1] + // Names and descriptions TEST_ASSERT_EQUAL(fugitive.name, "dockhand", "The fugitive at the outfitter is not called a dockhand") TEST_ASSERT_EQUAL(decoy.name, fugitive.name, "A decoy's name differs from the fugitive's") TEST_ASSERT_EQUAL(decoy.real_name, fugitive.real_name, "A decoy's voice name differs from the fugitive's") TEST_ASSERT(fugitive.name != posting.record.name && fugitive.name != posting.record.alias, "The fugitive shows its name or alias") TEST_ASSERT_EQUAL(decoy.desc, fugitive.desc, "A decoy's description differs from the fugitive's") - TEST_ASSERT_EQUAL(decoy.maxHealth, fugitive.maxHealth, "A decoy's health differs from the fugitive's") - TEST_ASSERT_EQUAL(decoy.move_resist, fugitive.move_resist, "A decoy's move resistance differs from the fugitive's") + + // The same build and face, whatever features were changed + TEST_ASSERT_EQUAL(decoy.dna.species.type, fugitive.dna.species.type, "A decoy is another species") + TEST_ASSERT_EQUAL(decoy.gender, fugitive.gender, "A decoy is another sex") + TEST_ASSERT_EQUAL(decoy.physique, fugitive.physique, "A decoy has another build") + TEST_ASSERT_EQUAL(decoy.skin_tone, fugitive.skin_tone, "A decoy has another skin tone") + TEST_ASSERT_EQUAL(decoy.eye_color_left, fugitive.eye_color_left, "A decoy has other eyes") + TEST_ASSERT_EQUAL(decoy.eye_color_right, fugitive.eye_color_right, "A decoy has other eyes") + + // The same clothes, and no hat or mask on either: hair is a clue + TEST_ASSERT(istype(fugitive.w_uniform, /obj/item/clothing/under/rank/cargo/tech), "The fugitive did not change into the zone's uniform") + TEST_ASSERT(istype(fugitive.wear_suit, /obj/item/clothing/suit/hazardvest), "The fugitive did not change into the zone's coat") + TEST_ASSERT(istype(fugitive.gloves, /obj/item/clothing/gloves/fingerless), "The fugitive did not change into the zone's gloves") + TEST_ASSERT(istype(fugitive.shoes, /obj/item/clothing/shoes/workboots), "The fugitive did not change into the zone's boots") + TEST_ASSERT_NULL(fugitive.head, "The fugitive kept its helmet on") + TEST_ASSERT_NULL(fugitive.wear_mask, "The fugitive kept its mask on") + TEST_ASSERT_EQUAL(decoy.w_uniform?.type, fugitive.w_uniform?.type, "A decoy wears another uniform") + TEST_ASSERT_EQUAL(decoy.wear_suit?.type, fugitive.wear_suit?.type, "A decoy wears another coat") + TEST_ASSERT_EQUAL(decoy.gloves?.type, fugitive.gloves?.type, "A decoy wears other gloves") + TEST_ASSERT_EQUAL(decoy.shoes?.type, fugitive.shoes?.type, "A decoy wears other boots") + TEST_ASSERT_NULL(decoy.head, "A decoy wears a hat") + TEST_ASSERT_NULL(decoy.wear_mask, "A decoy wears a mask") + for(var/obj/item/clothes as anything in list(fugitive.w_uniform, fugitive.wear_suit, fugitive.gloves, fugitive.shoes)) + TEST_ASSERT(human_npc_gear_locked(clothes), "[clothes] is not locked to the body like the rest of its kit") + // What it wore is deleted, not dropped for someone to pick up; what was in its pocket stays + TEST_ASSERT_NULL(locate(/obj/item/clothing/suit/armor/vest) in fugitive.loc, "The fugitive's armour was left on the floor") + TEST_ASSERT_NULL(locate(/obj/item/clothing/head/helmet) in fugitive.loc, "The fugitive's helmet was left on the floor") + TEST_ASSERT(istype(fugitive.l_store, /obj/item/reagent_containers/hypospray/medipen), "The fugitive's pocket was emptied when it changed clothes") + // A decoy is nobody's fighter: no weapon, nothing in its hands + TEST_ASSERT_NULL(locate(/obj/item/gun) in decoy.get_all_contents(), "A decoy carries a gun") + TEST_ASSERT_NULL(decoy.get_active_held_item(), "A decoy holds something") + TEST_ASSERT_NULL(decoy.get_inactive_held_item(), "A decoy holds something") + + // The same toughness and pace, through the body and never through a stand-in + TEST_ASSERT_EQUAL(fugitive.npc_damage_taken, 0.8, "Test fixture: the fugitive's toughness did not take") + TEST_ASSERT_EQUAL(decoy.npc_damage_taken, fugitive.npc_damage_taken, "A decoy is not as tough as the fugitive") + TEST_ASSERT_EQUAL(decoy.physiology.brute_mod, fugitive.physiology.brute_mod, "A decoy takes other brute damage from the fugitive") + TEST_ASSERT_EQUAL(decoy.physiology.burn_mod, fugitive.physiology.burn_mod, "A decoy takes other burn damage from the fugitive") + TEST_ASSERT_EQUAL(decoy.maxHealth, 100, "A decoy's max health was changed") + TEST_ASSERT_EQUAL(decoy.move_intent, fugitive.move_intent, "A decoy walks differently from the fugitive") TEST_ASSERT_EQUAL(decoy.cached_multiplicative_slowdown, fugitive.cached_multiplicative_slowdown, "A decoy's speed differs from the fugitive's") - TEST_ASSERT_EQUAL(decoy.stamina_regen_time, fugitive.stamina_regen_time, "A decoy's stamina crit lasts a different time from the fugitive's") - TEST_ASSERT_EQUAL(decoy.max_stamina_slowdown, fugitive.max_stamina_slowdown, "A decoy slows on stamina damage differently from the fugitive") - TEST_ASSERT_EQUAL(decoy.damage_coeff[STAMINA], fugitive.damage_coeff[STAMINA], "A decoy takes stamina damage differently from the fugitive") - TEST_ASSERT_EQUAL(decoy.body_recovery_time(), fugitive.body_recovery_time(), "A decoy stays down for a different time from the fugitive") + var/mob/living/carbon/human/person = allocate(/mob/living/carbon/human/consistent) + TEST_ASSERT_EQUAL(decoy.move_resist, person.move_resist, "A decoy is harder to move than a person") + TEST_ASSERT_EQUAL(fugitive.move_resist, person.move_resist, "A fugitive blending in is harder to move than a person") // Examine: the same plain text once each one's own feature lines are taken out - decoy.gender = fugitive.gender var/mob/living/carbon/human/viewer = make_hunter() TEST_ASSERT_EQUAL(plain_examine(decoy, viewer), plain_examine(fugitive, viewer), "A decoy examines differently from the fugitive") + // A decoy made any other way still dresses as a customer, never as a criminal + var/mob/living/carbon/human/npc/bounty_criminal/decoy/stray = allocate(/mob/living/carbon/human/npc/bounty_criminal/decoy) + TEST_ASSERT(istype(stray.w_uniform, /obj/item/clothing/under/costume/buttondown/slacks) || istype(stray.w_uniform, /obj/item/clothing/under/rank/cargo/tech) || istype(stray.w_uniform, /obj/item/clothing/under/pants/jeans), "A decoy built outside the outpost's code is not dressed as a patron") + TEST_ASSERT_NULL(locate(/obj/item/gun) in stray.get_all_contents(), "A decoy built outside the outpost's code carries a gun") + TEST_ASSERT_NULL(stray.get_active_held_item(), "A decoy built outside the outpost's code holds something") + TEST_ASSERT(stray.npc_corpse_lifetime > 0, "A dead decoy would lie there for good: nobody's posting cleans it up") + // The other zones' names TEST_ASSERT_EQUAL(bounty_outpost_display_name(allocate(/obj/structure/overmap/trader_outpost/general)), "patron", "Halcyon's crowd is not patrons") TEST_ASSERT_EQUAL(bounty_outpost_display_name(allocate(/obj/structure/overmap/trader_outpost/black_market)), "spacer", "The Undertow's crowd is not spacers") @@ -227,8 +334,8 @@ /datum/unit_test/voidcrew_bounty_outpost/warrant/Run() outpost = make_outpost() var/datum/criminal_bounty/outpost_test/posting = make_posting() - var/mob/living/basic/bounty_criminal/meek/outpost_test/fugitive = posting.criminal() - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/meek/outpost_test/fugitive = posting.criminal() + 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") var/obj/structure/overmap/ship/ship = allocate(/obj/structure/overmap/ship) @@ -243,7 +350,7 @@ TEST_ASSERT_EQUAL(posting.outpost_warrant_wait(hunter), 0, "Showing the warrant to a shopper started the wait") // A decoy: it protests, and the alert rises - var/mob/living/basic/bounty_criminal/decoy/decoy = decoys[1] + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoys[1] warrant.show_warrant(decoy, hunter) TEST_ASSERT_EQUAL(posting.alert, 1, "A wrong warrant did not raise the alert") TEST_ASSERT(decoy.blended, "A decoy stopped blending in after a wrong warrant") @@ -272,92 +379,172 @@ warrant.show_warrant(decoys[2], hunter) TEST_ASSERT_EQUAL(posting.alert, 1, "An out-of-date warrant raised the alert") -/// Cuffs reached for on someone blending in cost exactly what a punch costs, and a decoy confronted any other way raises the alert (C1) +/// 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 /datum/unit_test/voidcrew_bounty_outpost/cuffs/Run() outpost = make_outpost() var/datum/criminal_bounty/outpost_test/posting = make_posting() - var/mob/living/basic/bounty_criminal/meek/outpost_test/fugitive = posting.criminal() - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/meek/outpost_test/fugitive = posting.criminal() + 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") - var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) - // A decoy's wrists: a strike and the alert, the cuffs don't go on, and it doesn't just protest for free + // A decoy's wrists, the way tg's cuffs reach for them: a strike and the alert, and the cuffs don't go on var/mob/living/carbon/human/cuffer = make_hunter() - var/mob/living/basic/bounty_criminal/decoy/decoy = decoys[1] - cuffs.melee_attack_chain(cuffer, decoy, list()) + 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_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") - // The fugitive's wrists: the act is over, as for a punch, and nothing is charged + // The fugitive's wrists: the act is over, as for a punch, and nothing is charged. The body then + // steps away and runs or fights (BOUNTY_CUFF_REACTION_DELAY, half a second, later). var/mob/living/carbon/human/other_cuffer = make_hunter() - cuffs.melee_attack_chain(other_cuffer, fugitive, list()) + SEND_SIGNAL(fugitive, COMSIG_CARBON_CUFF_ATTEMPTED, other_cuffer) TEST_ASSERT(!fugitive.blended, "Cuffs on the fugitive did not end its act") - TEST_ASSERT_EQUAL(fugitive.confronted_by, other_cuffer, "Cuffs on the fugitive did not confront it") 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_EQUAL(posting.alert, 1, "Reaching for the fugitive's wrists raised the alert") + sleep(10) + TEST_ASSERT_EQUAL(fugitive.confronted_by, other_cuffer, "The fugitive did not answer the reach for its wrists") + + // The component answers the body's signal: standing and free, a decoy takes the reaction over (COMSIG_BOUNTY_CUFF_REACHED, COMPONENT_BOUNTY_CUFF_HANDLED) + var/mob/living/carbon/human/third_cuffer = make_hunter() + var/answer = SEND_SIGNAL(decoys[2], "bounty_cuff_reached", third_cuffer) + TEST_ASSERT(answer & 1, "A free decoy's reaction to cuffs was left to the body, which would take it for a wrong warrant") + // An exposed fugitive has nothing left to hide: the alert may rise, but nobody slips away + TEST_ASSERT_EQUAL(posting.alert, 2, "A second decoy's wrists did not raise the alert") + 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 + 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] + helpless.Paralyze(10 SECONDS) + 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_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 + + // Someone with no mind behind them never counts + var/datum/criminal_bounty/outpost_test/mindless_posting = make_posting(run_loc_floor_bottom_left) + TEST_ASSERT(mindless_posting.setup_outpost_blend(TRUE), "The third posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/decoy/untouched = decoys_of(mindless_posting)[1] + var/mob/living/carbon/human/robot = allocate(/mob/living/carbon/human/consistent) + SEND_SIGNAL(untouched, "bounty_cuff_reached", robot) + TEST_ASSERT_EQUAL(mindless_posting.alert, 0, "A mindless reach for a decoy's wrists raised the alert") + mindless_posting.status = "closed" // BOUNTY_POSTING_CLOSED // Any other way of confronting a decoy is a wrong accusation var/datum/criminal_bounty/outpost_test/next = make_posting(run_loc_floor_bottom_left) - TEST_ASSERT(next.setup_outpost_blend(), "The second posting could not be set up to blend in") + TEST_ASSERT(next.setup_outpost_blend(TRUE), "The fourth posting could not be set up to blend in") var/list/next_decoys = decoys_of(next) - TEST_ASSERT(length(next_decoys), "No decoys at the second posting") - var/mob/living/basic/bounty_criminal/decoy/next_decoy = next_decoys[1] + TEST_ASSERT(length(next_decoys), "No decoys at the fourth posting") + 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") -/// Two alerts: the fugitive and its decoys walk out and the bounty relists; an exposed fugitive stays +/// 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 /datum/unit_test/voidcrew_bounty_outpost/alert/Run() outpost = make_outpost() var/datum/criminal_bounty/outpost_test/posting = make_posting() - var/mob/living/basic/bounty_criminal/fugitive = posting.criminal() - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() + 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(posting.raise_alert("a test"), "The alert did not rise") TEST_ASSERT_EQUAL(posting.alert, 1, "The first alert did not count") TEST_ASSERT_NULL(posting.relisted_for, "The bounty relisted at the first alert") - TEST_ASSERT(posting.raise_alert("a test"), "The alert did not rise a second time") - TEST_ASSERT_NOTNULL(posting.relisted_for, "Two alerts did not relist the bounty") // BOUNTY_OUTPOST_ALERT_SLIP + TEST_ASSERT(posting.raise_alert("a test"), "The alert did not rise a second time") // BOUNTY_OUTPOST_ALERT_SLIP + TEST_ASSERT_EQUAL(length(posting.decoys), 0, "The posting kept its decoys") + TEST_ASSERT(is_leaving(fugitive), "The fugitive did not set off for the lift") + TEST_ASSERT_EQUAL(fugitive.npc_exit, run_loc_floor_top_right, "The fugitive set off for somewhere other than the hangar lift") + for(var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy as anything in decoys) + TEST_ASSERT(is_leaving(decoy), "A decoy did not leave with the fugitive") + // Still the posting's, and nobody's immortal on the way + TEST_ASSERT_NULL(posting.relisted_for, "The bounty relisted before the fugitive had gone") + TEST_ASSERT_EQUAL(posting.criminal(), fugitive, "The posting let go of a fugitive that was still walking out") + TEST_ASSERT_EQUAL(fugitive.posting(), posting, "A fugitive on its way out is no longer the posting's") + TEST_ASSERT(!HAS_TRAIT(fugitive, TRAIT_GODMODE), "The fugitive can't be hurt on its way out") + var/health_before = fugitive.health + fugitive.adjustBruteLoss(10) + TEST_ASSERT(fugitive.health < health_before, "A shot at the fugitive on its way out did nothing") + // A third alert while it walks starts nothing new + posting.raise_alert("a test") + TEST_ASSERT_NULL(posting.relisted_for, "A third alert relisted the bounty") + + // It reaches the lift, or nobody can see it: it goes, and the bounty relists + SShuman_npcs.sweep_leavers() + TEST_ASSERT(QDELETED(fugitive), "The fugitive was still there once it had left") + TEST_ASSERT_NOTNULL(posting.relisted_for, "The bounty did not relist once the fugitive had gone") + posting.status = "closed" // BOUNTY_POSTING_CLOSED: the outpost has room again TEST_ASSERT_NULL(posting.criminal(), "The posting kept hold of a fugitive that slipped away") - TEST_ASSERT_NULL(fugitive.posting_ref, "A fugitive that slipped away still points at the posting") - TEST_ASSERT_EQUAL(length(posting.decoys), 0, "The posting kept hold of its decoys") - TEST_ASSERT(fugitive.GetComponent(/datum/component/bounty_outpost_exit), "The fugitive did not leave") - for(var/mob/living/basic/bounty_criminal/decoy/decoy as anything in decoys) - TEST_ASSERT(decoy.GetComponent(/datum/component/bounty_outpost_exit), "A decoy did not leave with the fugitive") + for(var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy as anything in decoys) + TEST_ASSERT(QDELETED(decoy), "A decoy was still there once it had left") + + // Caught on the way: cuffed, the fugitive stays, and it is still the posting's, so it can be turned in + var/datum/criminal_bounty/outpost_test/caught = make_posting(run_loc_floor_bottom_left) + var/mob/living/carbon/human/npc/bounty_criminal/caught_fugitive = caught.criminal() + TEST_ASSERT(caught.setup_outpost_blend(TRUE), "The second posting could not be set up to blend in") + caught.raise_alert("a test") + caught.raise_alert("a test") + TEST_ASSERT(is_leaving(caught_fugitive), "The second fugitive did not set off for the lift") + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(caught_fugitive.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the fugitive could not be cuffed") + SShuman_npcs.sweep_leavers() + TEST_ASSERT(!QDELETED(caught_fugitive), "A cuffed fugitive walked out of the outpost") + TEST_ASSERT_NULL(caught.relisted_for, "The bounty relisted with its fugitive still there in cuffs") + TEST_ASSERT_EQUAL(caught.criminal(), caught_fugitive, "The posting lost a fugitive that was caught on its way out") + TEST_ASSERT_EQUAL(caught_fugitive.capture_state(), "restrained", "A fugitive caught on its way out does not pay as caught") // BOUNTY_STATE_RESTRAINED + // Down, not cuffed: also stays + caught_fugitive.uncuff() + caught_fugitive.adjustOxyLoss(110) + SShuman_npcs.sweep_leavers() + TEST_ASSERT(!QDELETED(caught_fugitive), "A fugitive in crit walked out of the outpost") + TEST_ASSERT_EQUAL(caught_fugitive.capture_state(), "downed", "A fugitive in crit does not pay as downed") // BOUNTY_STATE_DOWNED + // On its feet and free again, it finishes the walk and the bounty relists + caught_fugitive.setOxyLoss(0) + caught_fugitive.updatehealth() + SShuman_npcs.sweep_leavers() + TEST_ASSERT(QDELETED(caught_fugitive), "A fugitive on its feet and free did not leave") + TEST_ASSERT_NOTNULL(caught.relisted_for, "The bounty did not relist when the second fugitive left") + caught.status = "closed" // BOUNTY_POSTING_CLOSED // Once exposed, the chase is on: no more slipping away var/datum/criminal_bounty/outpost_test/exposed = make_posting(run_loc_floor_bottom_left) - var/mob/living/basic/bounty_criminal/exposed_fugitive = exposed.criminal() - TEST_ASSERT(exposed.setup_outpost_blend(), "The second posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/exposed_fugitive = exposed.criminal() + TEST_ASSERT(exposed.setup_outpost_blend(TRUE), "The third posting could not be set up to blend in") exposed.outpost_confront(exposed_fugitive, make_hunter()) exposed.raise_alert("a test") exposed.raise_alert("a test") + SShuman_npcs.sweep_leavers() TEST_ASSERT_NULL(exposed.relisted_for, "An exposed fugitive slipped away") TEST_ASSERT_EQUAL(exposed.criminal(), exposed_fugitive, "An exposed fugitive was let go") + TEST_ASSERT(!is_leaving(exposed_fugitive), "An exposed fugitive set off for the lift") // Only trader-outpost postings have an alert var/datum/criminal_bounty/outpost_test/planet = make_posting(run_loc_floor_bottom_left) planet.placement_kind = "planet" // BOUNTY_PLACEMENT_PLANET TEST_ASSERT(!planet.raise_alert("a test"), "A planet bounty's alert rose") -/// A deliberate hit on a decoy is an outpost strike and raises the alert; hitting the fugitive only exposes it +/// 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 /datum/unit_test/voidcrew_bounty_outpost/decoy_hit/Run() outpost = make_outpost() var/datum/criminal_bounty/outpost_test/posting = make_posting() - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() + 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") - var/mob/living/basic/bounty_criminal/decoy/decoy = decoys[1] + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoys[1] // Someone with no mind behind them never counts var/mob/living/carbon/human/npc = allocate(/mob/living/carbon/human/consistent) @@ -377,25 +564,30 @@ TEST_ASSERT_EQUAL(outpost.aggressor_strikes[hunter.mind], 1, "Punching a decoy was not an outpost strike") TEST_ASSERT_EQUAL(posting.alert, 1, "Punching a decoy did not raise the alert") - // A shot aimed at a decoy counts; a shot at someone else passes through it + // A shot at someone else hits whoever it hits: it goes through nobody, and it is nobody's strike var/mob/living/carbon/human/shooter = make_hunter() var/obj/projectile/bullet/stray = allocate(/obj/projectile/bullet) stray.firer = shooter stray.original = hunter - var/mob/living/basic/bounty_criminal/decoy/other_decoy = decoys[2] - TEST_ASSERT(SEND_SIGNAL(other_decoy, COMSIG_PROJECTILE_PREHIT, stray) & PROJECTILE_INTERRUPT_HIT_PHASE, "A shot at someone else hit a decoy") - TEST_ASSERT_EQUAL(posting.alert, 1, "A stray shot through a decoy raised the alert") + var/mob/living/carbon/human/npc/bounty_criminal/decoy/other_decoy = decoys[2] + 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") + // 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(posting.relisted_for, "Two hits on decoys did not send the fugitive away") + 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 var/datum/criminal_bounty/outpost_test/next = make_posting(run_loc_floor_bottom_left) - var/mob/living/basic/bounty_criminal/next_fugitive = next.criminal() - TEST_ASSERT(next.setup_outpost_blend(), "The second posting could not be set up to blend in") + 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") var/mob/living/carbon/human/brawler = make_hunter() brawler.combat_mode = TRUE next_fugitive.attack_hand(brawler, list()) @@ -410,11 +602,12 @@ /datum/unit_test/voidcrew_bounty_outpost/thrown/Run() outpost = make_outpost() var/datum/criminal_bounty/outpost_test/posting = make_posting() - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() + 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") - var/mob/living/basic/bounty_criminal/decoy/decoy = decoys[1] - var/mob/living/basic/bounty_criminal/decoy/other_decoy = decoys[2] + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoys[1] + 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") @@ -438,23 +631,28 @@ 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(posting.relisted_for, "Two hits on decoys did not send the fugitive away") + 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 + 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") -/// While blending in nobody can pull them, the lift never carries them, and the turrets leave them alone; the fugitive's shots at the outpost reach only its grudge list, and hurting a bystander ends the turrets' leave +/// 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 /datum/unit_test/voidcrew_bounty_outpost/security /datum/unit_test/voidcrew_bounty_outpost/security/Run() outpost = make_outpost() var/datum/criminal_bounty/outpost_test/posting = make_posting() - var/mob/living/basic/bounty_criminal/fugitive = posting.criminal() - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() + 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), "No decoys to test with") - var/mob/living/basic/bounty_criminal/decoy/decoy = decoys[1] + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoys[1] var/mob/living/carbon/human/hunter = make_hunter() - TEST_ASSERT(!decoy.can_be_pulled(hunter, MOVE_FORCE_OVERPOWERING), "A decoy can be pulled") - TEST_ASSERT(!fugitive.can_be_pulled(hunter, MOVE_FORCE_OVERPOWERING), "The fugitive can be pulled while blending in") TEST_ASSERT(!decoy.may_attack(hunter), "A decoy may attack someone") // The hangar lift carries whoever stands on it: they never step onto it (L8) @@ -472,23 +670,69 @@ 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 fugitive's own shots pass through anyone who hasn't crossed it + // 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() - 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 hit a bystander") - fugitive.body_add_grudge(hunter) - TEST_ASSERT(!(SEND_SIGNAL(shot, COMSIG_PROJECTILE_SELF_PREHIT, hunter) & PROJECTILE_INTERRUPT_HIT_PHASE), "The fugitive's shot passed through someone on its grudge list") - - // Exposed and fighting those who came for it, the turrets still leave it to the hunters (AR-C8) posting.outpost_confront(fugitive, hunter) TEST_ASSERT(!turret.in_faction(fugitive), "An exposed fugitive is still in the turrets' faction") - SEND_SIGNAL(fugitive, COMSIG_HOSTILE_POST_ATTACKINGTARGET, hunter, TRUE) + 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 that - SEND_SIGNAL(fugitive, COMSIG_HOSTILE_POST_ATTACKINGTARGET, bystander, TRUE) - TEST_ASSERT(fugitive.outpost_turret_target(), "The fugitive hurt a bystander and the turrets still leave it be") + // Hurting someone who never crossed it ends the turrets' 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") + // 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") + fugitive.SetParalyzed(0) + TEST_ASSERT(fugitive.outpost_turret_target(), "A fugitive on its feet again is not a turret target") + + // Its blows count the same: a punch at a bystander + 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") + 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") + 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") + +/// 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 + +/datum/unit_test/voidcrew_bounty_outpost/grab/Run() + outpost = make_outpost() + var/datum/criminal_bounty/outpost_test/posting = make_posting() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() + 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), "No decoys to test with") + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoys[1] + var/mob/living/carbon/human/hunter = make_hunter() + + hunter.forceMove(get_step(decoy, EAST)) + TEST_ASSERT(hunter.start_pulling(decoy), "A decoy blending in could not be grabbed") + TEST_ASSERT_EQUAL(decoy.pulledby, hunter, "A grabbed decoy is not pulled") + TEST_ASSERT_EQUAL(posting.alert, 0, "Grabbing a decoy raised the alert") + TEST_ASSERT(decoy.blended, "Grabbing a decoy ended its act") + hunter.stop_pulling() + + hunter.forceMove(get_step(fugitive, EAST)) + TEST_ASSERT(hunter.start_pulling(fugitive), "The fugitive blending in could not be grabbed") + TEST_ASSERT_EQUAL(fugitive.pulledby, hunter, "The grabbed fugitive is not pulled") + TEST_ASSERT(fugitive.blended, "Grabbing the fugitive gave it away") + hunter.stop_pulling() + + // Walking out is no different: it can be grabbed, and held, it does not get away + posting.raise_alert("a test") + posting.raise_alert("a test") + TEST_ASSERT(is_leaving(decoy), "Test fixture: the decoy did not leave") + hunter.forceMove(get_step(decoy, EAST)) + TEST_ASSERT(hunter.start_pulling(decoy), "A decoy walking out could not be grabbed") + TEST_ASSERT(!decoy.npc_may_vanish(), "A decoy in the hunter's grip walked off the map") + hunter.stop_pulling() /// Traders give a different clue each, once per ship, and only to a ship hunting the fugitive there; a place is never told as true after it has changed /datum/unit_test/voidcrew_bounty_outpost/clues @@ -496,7 +740,7 @@ /datum/unit_test/voidcrew_bounty_outpost/clues/Run() outpost = make_outpost() var/datum/criminal_bounty/outpost_test/posting = make_posting(run_loc_floor_bottom_left) - var/mob/living/basic/bounty_criminal/fugitive = posting.criminal() + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() // A haircut since the mugshot, so the hair clue is in the pool whatever P1 makes. Both looks keep // the record's species: a look with none can't name a decoy. var/datum/bounty_look/now = new @@ -511,7 +755,7 @@ then.hair_color = "#e0c060" posting.record.look = now posting.record.old_look = then - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") + 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_EQUAL(length(decoys), 3, "The fugitive did not get three decoys") // No lift nearby for this, and one person to a corner, so where each stands picks them out @@ -522,7 +766,7 @@ locate(run_loc_floor_top_right.x, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z), ) for(var/index in 1 to 3) - var/mob/living/basic/bounty_criminal/decoy/decoy = decoys[index] + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = decoys[index] decoy.forceMove(corners[index]) var/list/traders = list() @@ -635,75 +879,87 @@ board.ui_data(reader) TEST_ASSERT_EQUAL(board.outpost_static_signature, bounty_public_board_signature(), "The board did not notice what it shows") -/// When the posting closes the decoys and an uncaught fugitive walk out to the lift and fade; a walk never outlives its walker, and an unreachable lift means fading in place -/datum/unit_test/voidcrew_bounty_outpost/cleanup +/** + * When the posting closes the decoys and an uncaught fugitive walk out for real, through the human NPC + * base's walk out: tracked, with an exit their AI knows and the planning step to walk it, and no godmode + * (they can be hurt, and a shot goes through no one). Anyone down, cuffed, or dead stays where they fall; + * the rest go when nobody can see them; a lift behind a counter just means they go when nobody is looking. + */ +/datum/unit_test/voidcrew_bounty_outpost/walk_out -/datum/unit_test/voidcrew_bounty_outpost/cleanup/Run() +/datum/unit_test/voidcrew_bounty_outpost/walk_out/Run() outpost = make_outpost() // Closing for real (P5's close() tells P6 before it removes anyone) var/datum/criminal_bounty/outpost_test/posting = make_posting() - var/mob/living/basic/bounty_criminal/fugitive = posting.criminal() - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") - var/list/leaving = decoys_of(posting) + fugitive - TEST_ASSERT_EQUAL(length(leaving), 4, "Expected the fugitive and three decoys") + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() + 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_EQUAL(length(decoys), 3, "Expected the fugitive and three decoys") + var/mob/living/carbon/human/npc/bounty_criminal/decoy/dead_decoy = decoys[1] + var/mob/living/carbon/human/npc/bounty_criminal/decoy/downed_decoy = decoys[2] + var/mob/living/carbon/human/npc/bounty_criminal/decoy/free_decoy = decoys[3] + dead_decoy.death() + TEST_ASSERT(dead_decoy in SShuman_npcs.corpses, "A dead decoy is nobody's to clean up: the corpse sweep does not know it") posting.close("expired") // BOUNTY_CLOSE_EXPIRED - var/list/walks = list() - for(var/mob/living/walker as anything in leaving) - if(QDELETED(walker)) - continue - var/datum/component/bounty_outpost_exit/exit = walker.GetComponent(/datum/component/bounty_outpost_exit) - TEST_ASSERT(exit, "[walker] neither left nor was removed when the posting closed") - TEST_ASSERT(HAS_TRAIT(walker, TRAIT_GODMODE), "[walker] can be hurt on the way out") - if(exit.walk_loop) - walks += exit.walk_loop - TEST_ASSERT(length(walks), "Nobody set off for the lift they can reach") - // One is deleted mid-walk: its walk goes with it, and the pathfinder is never asked about it again - var/mob/living/deleted_walker = leaving[1] - var/datum/component/bounty_outpost_exit/deleted_exit = deleted_walker.GetComponent(/datum/component/bounty_outpost_exit) - var/datum/move_loop/deleted_walk = deleted_exit?.walk_loop - qdel(deleted_walker) - TEST_ASSERT(!deleted_walk || QDELETED(deleted_walk), "A walk outlived its walker") - // The rest fade (their walk would take a few seconds more) - for(var/mob/living/walker as anything in leaving) - if(QDELETED(walker)) - continue - var/datum/component/bounty_outpost_exit/exit = walker.GetComponent(/datum/component/bounty_outpost_exit) - exit?.fade_out() - TEST_ASSERT(!exit?.walk_loop, "[walker] kept walking while fading") - var/deadline = world.time + 5 SECONDS - UNTIL(world.time > deadline || !length(leaving - get_all_qdeleted(leaving))) - for(var/mob/living/walker as anything in leaving) - TEST_ASSERT(QDELETED(walker), "[walker] was still there after the posting closed") - - // Behind a counter the lift can't be reached: they fade where they stand, with no walk at all + + var/datum/ai_planning_subtree/walk_out = GLOB.ai_subtrees[/datum/ai_planning_subtree/human_npc_walk_out] + var/mob/living/carbon/human/person = allocate(/mob/living/carbon/human/consistent) + for(var/mob/living/carbon/human/npc/walker as anything in list(fugitive, downed_decoy, free_decoy)) + TEST_ASSERT(is_leaving(walker), "[walker] did not set off for the lift when the posting closed") + TEST_ASSERT_EQUAL(walker.npc_exit, run_loc_floor_top_right, "[walker] set off for somewhere other than the hangar lift") + TEST_ASSERT(!HAS_TRAIT(walker, TRAIT_GODMODE), "[walker] can't be hurt on the way out") + TEST_ASSERT_EQUAL(walker.move_resist, person.move_resist, "[walker] can't be moved on the way out") + TEST_ASSERT(walk_out in walker.ai_controller.planning_subtrees, "[walker]'s AI has no step that walks it out") // PHASE D CORE REQUEST: every bounty controller lists /datum/ai_planning_subtree/human_npc_walk_out, after the gate and before the activity + var/health_before = walker.health + walker.adjustBruteLoss(10) + TEST_ASSERT(walker.health < health_before, "A blow to [walker] on its way out did nothing") + // Shots hit whoever they hit, walking out or not + var/obj/projectile/bullet/shot = allocate(/obj/projectile/bullet) + shot.firer = make_hunter() + shot.original = walker + TEST_ASSERT_EQUAL(SEND_SIGNAL(walker, COMSIG_PROJECTILE_PREHIT, shot), 0, "[walker] on its way out lets shots through") + TEST_ASSERT(!is_leaving(dead_decoy), "A dead decoy set off for the lift") + TEST_ASSERT_NULL(dead_decoy.npc_exit, "A dead decoy has somewhere to walk to") + + // Downed and cuffed ones stay; the one on its feet goes when nobody can see it + downed_decoy.adjustOxyLoss(110) + var/obj/item/restraints/handcuffs/cuffs = allocate(/obj/item/restraints/handcuffs) + TEST_ASSERT(fugitive.equip_to_slot_if_possible(cuffs, ITEM_SLOT_HANDCUFFED), "Test fixture: the fugitive could not be cuffed") + SShuman_npcs.sweep_leavers() + TEST_ASSERT(QDELETED(free_decoy), "A decoy on its feet and out of sight was still there after the sweep") + TEST_ASSERT(!QDELETED(downed_decoy), "A decoy in crit was deleted on its way out") + TEST_ASSERT(!QDELETED(fugitive), "A cuffed fugitive was deleted on its way out") + TEST_ASSERT(!QDELETED(dead_decoy), "A dead decoy was deleted at once instead of by the corpse sweep") + // The posting is closed: nothing relists, and the cuffed fugitive was let go of + TEST_ASSERT_NULL(posting.relisted_for, "A closed posting relisted") + TEST_ASSERT_NULL(fugitive.posting_ref, "A fugitive the posting closed on is still linked to it") + + // Behind a counter the lift can't be reached: they still set off for it, and go when nobody is looking var/counter_y = run_loc_floor_bottom_left.y + 2 for(var/x in run_loc_floor_bottom_left.x to run_loc_floor_top_right.x) allocate(/obj/structure/table, locate(x, counter_y, run_loc_floor_bottom_left.z)) var/datum/criminal_bounty/outpost_test/released = make_posting(locate(run_loc_floor_bottom_left.x, run_loc_floor_top_right.y, run_loc_floor_bottom_left.z)) - TEST_ASSERT(released.setup_outpost_blend(), "The second posting could not be set up to blend in") + TEST_ASSERT(released.setup_outpost_blend(TRUE), "The second posting could not be set up to blend in") var/list/walkers = decoys_of(released) + released.criminal() - var/mob/living/stranded = walkers[1] + var/mob/living/carbon/human/npc/stranded = walkers[1] stranded.forceMove(run_loc_floor_bottom_left) released.outpost_release_site() TEST_ASSERT_EQUAL(length(released.decoys), 0, "The posting kept its decoys") TEST_ASSERT_NULL(released.criminal(), "The posting kept its fugitive") - var/datum/component/bounty_outpost_exit/stranded_exit = stranded.GetComponent(/datum/component/bounty_outpost_exit) - TEST_ASSERT(stranded_exit?.fading && !stranded_exit.walk_loop, "Someone who can't reach the lift set off for it") - for(var/mob/living/walker as anything in walkers) - var/datum/component/bounty_outpost_exit/exit = walker.GetComponent(/datum/component/bounty_outpost_exit) - TEST_ASSERT(exit, "[walker] did not leave") - exit.fade_out() - deadline = world.time + 5 SECONDS - UNTIL(world.time > deadline || !length(walkers - get_all_qdeleted(walkers))) - for(var/mob/living/walker as anything in walkers) + TEST_ASSERT(is_leaving(stranded), "Someone who can't reach the lift did not set off for it") + SShuman_npcs.sweep_leavers() + for(var/mob/living/carbon/human/npc/walker as anything in walkers) TEST_ASSERT(QDELETED(walker), "[walker] was still there after walking out") -/// Everything in `things` that has been deleted -/datum/unit_test/voidcrew_bounty_outpost/cleanup/proc/get_all_qdeleted(list/things) - . = list() - for(var/datum/thing as anything in things) - if(QDELETED(thing)) - . += thing + // A decoy sitting at the bar gets up to go + var/datum/criminal_bounty/outpost_test/seated = make_posting(run_loc_floor_bottom_left) + TEST_ASSERT(seated.setup_outpost_blend(TRUE), "The third posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/decoy/sitter = decoys_of(seated)[1] + var/obj/structure/chair/stool/bar/stool = allocate(/obj/structure/chair/stool/bar, get_turf(sitter)) + TEST_ASSERT(sitter.ai_sit_on(stool), "Test fixture: the decoy would not sit") + seated.outpost_release_site() + TEST_ASSERT(!sitter.buckled, "A decoy stayed in its seat when it was meant to leave") + TEST_ASSERT(is_leaving(sitter), "A decoy that got up did not set off") /// Each trader outpost map has one wanted board, on a wall of its concourse /datum/unit_test/voidcrew_bounty_outpost_maps @@ -732,45 +988,14 @@ TEST_ASSERT_EQUAL(length(bounty_outpost_spawn_spots(outpost, 4)), 4, "[outpost_type] has no room for a fugitive and three decoys") qdel(outpost) -/// P2's drag rule holds while blending in: anyone down or cuffed can be dragged, the fugitive and its decoys alike (AR-C6); standing, nobody can -/datum/unit_test/voidcrew_bounty_outpost/drag - -/datum/unit_test/voidcrew_bounty_outpost/drag/Run() - outpost = make_outpost() - var/datum/criminal_bounty/outpost_test/posting = make_posting() - var/mob/living/basic/bounty_criminal/fugitive = posting.criminal() - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") - var/list/decoys = decoys_of(posting) - TEST_ASSERT(length(decoys), "No decoys to test with") - var/mob/living/basic/bounty_criminal/decoy/decoy = decoys[1] - var/mob/living/carbon/human/hunter = make_hunter() - - TEST_ASSERT(!fugitive.can_be_pulled(hunter, MOVE_FORCE_OVERPOWERING), "A standing fugitive blending in can be pulled") - TEST_ASSERT(!decoy.can_be_pulled(hunter, MOVE_FORCE_OVERPOWERING), "A standing decoy can be pulled") - // Down (a blast, say) while still blending in: both drag, so the drag gives nobody away - fugitive.downed = TRUE - decoy.downed = TRUE - TEST_ASSERT(fugitive.blended, "The fugitive stopped blending in without being hit") - TEST_ASSERT(fugitive.can_be_pulled(hunter, MOVE_FORCE_OVERPOWERING), "A downed fugitive blending in can't be dragged") - TEST_ASSERT(decoy.can_be_pulled(hunter, MOVE_FORCE_OVERPOWERING), "A downed decoy can't be dragged, so the fugitive stands out") - fugitive.downed = FALSE - decoy.downed = FALSE - // Walking out, never - var/datum/component/bounty_outpost_blend/blend = fugitive.GetComponent(/datum/component/bounty_outpost_blend) - blend.leaving = TRUE - fugitive.downed = TRUE - TEST_ASSERT(!fugitive.can_be_pulled(hunter, MOVE_FORCE_OVERPOWERING), "Someone walking out can be pulled") - fugitive.downed = FALSE - blend.leaving = FALSE - /// The right warrant: the nearest trader in sight calls out /datum/unit_test/voidcrew_bounty_outpost/room_reacts /datum/unit_test/voidcrew_bounty_outpost/room_reacts/Run() outpost = make_outpost() var/datum/criminal_bounty/outpost_test/posting = make_posting(run_loc_floor_bottom_left) - var/mob/living/basic/bounty_criminal/fugitive = posting.criminal() - TEST_ASSERT(posting.setup_outpost_blend(), "The posting could not be set up to blend in") + var/mob/living/carbon/human/npc/bounty_criminal/fugitive = posting.criminal() + TEST_ASSERT(posting.setup_outpost_blend(TRUE), "The posting could not be set up to blend in") fugitive.forceMove(run_loc_floor_bottom_left) TEST_ASSERT_NULL(posting.outpost_trader_reacts(fugitive), "A trader reacted with no trader there") var/mob/living/basic/outpost_trader/far = allocate(/mob/living/basic/outpost_trader, run_loc_floor_top_right) diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_seams.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_seams.dm index 334a3c795fb..67be1367faf 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_seams.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_seams.dm @@ -10,7 +10,7 @@ /datum/unit_test/voidcrew_bounty_seams/Run() // Every criminal, decoy, companion and bounty object, made with no arguments. - for(var/type in typesof(/mob/living/basic/bounty_criminal, /mob/living/basic/bounty_companion, /obj/effect/bounty_sighting, /obj/item/paper/bounty_warrant, /obj/structure/bounty_wanted_board)) + for(var/type in typesof(/mob/living/carbon/human/npc/bounty_criminal, /mob/living/carbon/human/npc/bounty_companion, /obj/effect/bounty_sighting, /obj/item/paper/bounty_warrant, /obj/structure/bounty_wanted_board)) var/atom/thing = allocate(type) TEST_ASSERT(!QDELETED(thing), "[type] was deleted as it was made") diff --git a/voidcrew/modules/unit_tests/voidcrew_npc_zone_profiles.dm b/voidcrew/modules/unit_tests/voidcrew_npc_zone_profiles.dm index 05727a42eb3..9c475d39bcc 100644 --- a/voidcrew/modules/unit_tests/voidcrew_npc_zone_profiles.dm +++ b/voidcrew/modules/unit_tests/voidcrew_npc_zone_profiles.dm @@ -134,8 +134,8 @@ /mob/living/simple_animal/hostile/asteroid/elite, /mob/living/basic/boss, /mob/living/basic/trooper/pirate/faction/boss, - /mob/living/basic/bounty_criminal/boss, - /mob/living/basic/bounty_criminal/kingpin, + /mob/living/carbon/human/npc/bounty_criminal/boss, + /mob/living/carbon/human/npc/bounty_criminal/kingpin, /mob/living/basic/bounty_lair_boss, /mob/living/basic/lich, /mob/living/basic/hoarfrost_matriarch, @@ -587,111 +587,22 @@ controller.clear_target() /datum/unit_test/voidcrew_npc_zone_bounty_routes/Run() + // Bounty NPCs are real humans (human NPC migration, phase D): a zone never touches their health or + // their blows. Harder zones dress them in better armour instead (bounty_outfits.dm). var/turf/location = run_loc_floor_bottom_left var/datum/bounty_record/record = allocate(/datum/bounty_record) record.tier = 2 // BOUNTY_TIER_WANTED record.style = "brawler" - var/mob/living/basic/bounty_criminal/criminal = spawn_bounty_criminal(record, location, null) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = spawn_bounty_criminal(record, location, null) allocated += criminal - var/raw_health = criminal.body_tier_health() - criminal.npc_spawn_context.on_zone_resolved(3) - TEST_ASSERT(abs(criminal.maxHealth - raw_health * 1.8) < 0.001, "Criminal route finalized before body_setup") - criminal.setBruteLoss(criminal.maxHealth / 2) - var/mob/living/basic/bounty_criminal/decoy/decoy = allocate(/mob/living/basic/bounty_criminal/decoy, location) + criminal.npc_spawn_context?.on_zone_resolved(3) + TEST_ASSERT_EQUAL(criminal.maxHealth, 100, "A human criminal's health was zone-scaled") + TEST_ASSERT(!criminal.npc_zone_applied, "A human criminal took a zone profile") + var/mob/living/carbon/human/npc/bounty_criminal/decoy/decoy = allocate(/mob/living/carbon/human/npc/bounty_criminal/decoy, location) decoy.body_match(criminal) decoy.body_match(criminal) - TEST_ASSERT_EQUAL(decoy.npc_spawn_context.source_id, criminal.npc_spawn_context.source_id, "Decoy lost the fugitive source") - TEST_ASSERT_EQUAL(decoy.maxHealth, criminal.maxHealth, "Repeated body_match multiplied copied health") - TEST_ASSERT(abs(decoy.health / decoy.maxHealth - criminal.health / criminal.maxHealth) < 0.001, "Decoy copy changed health fraction") - var/mob/living/carbon/human/consistent/admin = allocate(/mob/living/carbon/human/consistent, run_loc_floor_top_right) - var/datum/bounty_admin_panel/unit_test/panel = allocate(/datum/bounty_admin_panel/unit_test, admin) - var/mob/living/basic/bounty_criminal/decoy/admin_decoy = panel.admin_spawn_decoy(admin, criminal) - TEST_ASSERT_NOTNULL(admin_decoy, "Admin decoy fixture failed") - allocated += admin_decoy - TEST_ASSERT_EQUAL(admin_decoy.maxHealth, criminal.maxHealth, "Admin decoy route stacked the copied pool") - var/datum/bounty_real_weapon/knife = bounty_real_weapon(/obj/item/knife/combat) - knife.apply_melee(criminal) - knife.apply_melee(criminal) - TEST_ASSERT(abs(criminal.melee_damage_upper - knife.force_high * 1.35) < 0.001, "Bounty weapon replacement stacked or lost the profile") - var/mob/living/basic/bounty_criminal/kingpin/kingpin = bounty_kingpin_setup(location, list(), null) - allocated += kingpin - kingpin.npc_spawn_context.on_zone_resolved(2) - TEST_ASSERT_EQUAL(kingpin.npc_zone_role, "boss", "The neutral lounge kingpin missed its explicit boss profile") - var/mob/living/basic/bounty_kingpin_goon/goon = kingpin.kingpin_crew.spawn_goon(run_loc_floor_top_right) - allocated += goon - TEST_ASSERT_EQUAL(goon.npc_spawn_context.zone_type, 2, "Replacement goon lost its kingpin's site band") - TEST_ASSERT_EQUAL(goon.npc_zone_role, "ordinary", "Goon inherited the kingpin's boss role") - TEST_ASSERT(abs(goon.maxHealth - goon.npc_zone_original_max_health * 1.35) < 0.001, "Replacement goon did not finalize") - kingpin.exempt_npc_zone() - var/mob/living/basic/bounty_kingpin_goon/owned_goon = kingpin.kingpin_crew.spawn_goon(location) - allocated += owned_goon - TEST_ASSERT(!owned_goon.npc_zone_applied && owned_goon.npc_zone_exempt, "Player ownership did not propagate to goons") - -/datum/unit_test/voidcrew_npc_zone_bounty_damage/Run() - var/turf/location = run_loc_floor_bottom_left - var/datum/npc_spawn_context/context = allocate(/datum/npc_spawn_context, src, 3, "boss") - var/mob/living/basic/bounty_criminal/boss/boss = spawn_npc_in_zone(/mob/living/basic/bounty_criminal/boss/juggernaut, location, context) - allocated += boss - var/mob/living/basic/npc_zone_test/victim = allocate(/mob/living/basic/npc_zone_test, run_loc_floor_top_right) - boss.boss_hit(victim, 10) - TEST_ASSERT_EQUAL(victim.getBruteLoss(), 12, "Raw boss ability escaped scaling") - boss.boss_hit(victim, 12, already_scaled = TRUE) - TEST_ASSERT_EQUAL(victim.getBruteLoss(), 24, "Scaled melee input multiplied twice") - var/list/table = boss.boss_health_table() - boss.boss_engaged = list("first" = TRUE, "second" = TRUE) - boss.boss_apply_posse_health(FALSE) - TEST_ASSERT(abs(boss.maxHealth - table[2] * 1.3) < 0.001, "Posse health lost the boss factor") - var/obj/effect/bounty_boss_explosive/explosive = allocate(/obj/effect/bounty_boss_explosive, location, boss) - var/obj/effect/bounty_boss_fire_pool/pool = allocate(/obj/effect/bounty_boss_fire_pool, location, list(location), 5 SECONDS, boss) - var/factor = boss.get_npc_zone_damage_factor() - qdel(boss) - TEST_ASSERT_EQUAL(explosive.damage_factor, factor, "Delayed explosive lost its creator's factor") - TEST_ASSERT_EQUAL(pool.damage_factor, factor, "Delayed fire lost its creator's factor") - victim.setMaxHealth(1000) - victim.updatehealth() - victim.mind_initialize() - victim.forceMove(location) - var/before_blast = victim.getBruteLoss() - var/raw_blast = explosive.damage - explosive.detonate() - TEST_ASSERT(abs(victim.getBruteLoss() - before_blast - raw_blast * factor) < 0.001, "Orphaned explosive fallback lost or doubled its captured factor") - var/before_fire = victim.getBruteLoss() - pool.scorch(10) - TEST_ASSERT(abs(victim.getBruteLoss() - before_fire - 10 * factor) < 0.001, "Orphaned fire pool lost its captured factor") - var/datum/status_effect/bounty_boss_burning/burning = victim.apply_status_effect(/datum/status_effect/bounty_boss_burning, factor) - // scorch already applied the refreshable effect. - burning = victim.has_status_effect(/datum/status_effect/bounty_boss_burning) - var/before = victim.getBruteLoss() - burning.tick(1) - TEST_ASSERT(abs(victim.getBruteLoss() - before - 3 * factor) < 0.001, "Lingering fire did not retain its numeric factor") // BOUNTY_BOSS_BURNING_DAMAGE - -/// Exercise the mafia override in a real club room, which bypasses base marker spawning. -/datum/unit_test/voidcrew_npc_zone_mafia_room - var/turf/room_floor - var/area/original_area - -/datum/unit_test/voidcrew_npc_zone_mafia_room/Destroy() - if(room_floor && original_area) - room_floor.change_area(get_area(room_floor), original_area) - return ..() - -/datum/unit_test/voidcrew_npc_zone_mafia_room/Run() - room_floor = run_loc_floor_bottom_left - original_area = get_area(room_floor) - var/area/ruin/space/has_grav/powered/mafia_club/casino/room = allocate(/area/ruin/space/has_grav/powered/mafia_club/casino) - room_floor.change_area(original_area, room) - var/obj/effect/zone_mobs/mafia/marker = allocate(/obj/effect/zone_mobs/mafia) - marker.forceMove(room_floor) - marker.do_spawn(3) - var/count = 0 - var/list/roster = bounty_mafia_room_crew(room.type) - for(var/mob/living/basic/trooper/russian/mafia/goon in room_floor) - allocated += goon - count++ - TEST_ASSERT(roster[goon.type], "Custom mafia marker escaped its existing room roster") - TEST_ASSERT_EQUAL(goon.npc_zone_role, "ordinary", "Custom mafia room output missed finalization") - TEST_ASSERT(abs(goon.maxHealth - goon.npc_zone_original_max_health * 1.8) < 0.001, "Custom mafia room output missed the selected factor") - TEST_ASSERT(count >= 1 && count <= 2, "Custom mafia marker escaped the 1-2 output budget") + TEST_ASSERT_EQUAL(decoy.maxHealth, 100, "Matching a decoy to its fugitive changed its health") + TEST_ASSERT_EQUAL(decoy.npc_damage_taken, criminal.npc_damage_taken, "A decoy isn't as tough as its fugitive") /// Field-route fixtures suppress offer generation, retaining the real spawn/tracking paths. /datum/mission/npc_zone_test/generate_mission_details() diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_npc_looks.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_npc_looks.dm index 1ad2ac15fc1..db7a3326605 100644 --- a/voidcrew/modules/unit_tests/voidcrew_outpost_npc_looks.dm +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_npc_looks.dm @@ -115,7 +115,6 @@ UNTIL(held_look_has_icon(npc, initial(mop_type.righthand_file)) || world.time > deadline) TEST_ASSERT_EQUAL(npc.held_visual, /obj/item/mop, "Holding a mop did not set held_visual") TEST_ASSERT(held_look_has_icon(npc, initial(mop_type.righthand_file)), "Holding a mop left no custodial right-hand overlay") - TEST_ASSERT_NULL(locate(/obj/effect/abstract/bounty_held) in npc.vis_contents, "An ambient NPC still carries the old pasted-icon holder") npc.set_held(null) deadline = world.time + 5 SECONDS UNTIL(!held_look_has_icon(npc, initial(mop_type.righthand_file)) || world.time > deadline) diff --git a/voidcrew/modules/unit_tests/voidcrew_outpost_prison_bounty.dm b/voidcrew/modules/unit_tests/voidcrew_outpost_prison_bounty.dm index 476543914c7..68fc806969b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_outpost_prison_bounty.dm +++ b/voidcrew/modules/unit_tests/voidcrew_outpost_prison_bounty.dm @@ -1032,7 +1032,7 @@ TEST_ASSERT(marked(spot), "The fire patch is not marked at [spot.x],[spot.y]") TEST_ASSERT(get_turf(warden) in move.patch, "The fire patch misses the warden beside the bunk") TEST_ASSERT_EQUAL(warden.getFireLoss(), 0, "The warden burned before the match was thrown") - TEST_ASSERT(!warden.has_status_effect(/datum/status_effect/bounty_boss_burning), "The warden was alight before the match was thrown") + TEST_ASSERT(!warden.on_fire, "The warden was alight before the match was thrown") // A prisoner in the patch too: it goes up, the warden burns, the prisoner doesn't. var/mob/living/basic/outpost_prisoner/cellmate = kept_prisoner(prison, prison_spot(home, 2, 14)) @@ -1044,8 +1044,8 @@ TEST_ASSERT(get_dist(spot, bunk) <= 1, "The fire burns at [spot.x],[spot.y], past the tiles beside the bunk") TEST_ASSERT(prison.in_cell_block(spot), "The fire burns outside the cell block") TEST_ASSERT(warden.getFireLoss() > 0, "The burning bunk did not burn the warden beside it") - TEST_ASSERT(warden.has_status_effect(/datum/status_effect/bounty_boss_burning), "The burning bunk did not set the warden alight") - TEST_ASSERT(!cellmate.has_status_effect(/datum/status_effect/bounty_boss_burning), "The burning bunk set a prisoner alight") + TEST_ASSERT(warden.on_fire, "The burning bunk did not set the warden alight") // tg fire stacks (human NPC phase D, CB-8) + TEST_ASSERT(!cellmate.on_fire, "The burning bunk set a prisoner alight") TEST_ASSERT_EQUAL(cellmate.health, cellmate_health, "The burning bunk hurt a prisoner") // Not again inside the cooldown (PRISON_BOSS_FIRE_COOLDOWN). diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_identity.dm b/voidcrew/modules/unit_tests/voidcrew_zone_identity.dm index 5c2c7c53431..409ead1de8b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_identity.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_identity.dm @@ -188,12 +188,12 @@ var/obj/machinery/mission_pad/pad = allocate(/obj/machinery/mission_pad, deck) var/datum/criminal_bounty/posting = board_test_posting(2) // BOUNTY_TIER_WANTED var/datum/bounty_record/record = posting.record - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, field) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, field) TEST_ASSERT(istext(posting.turn_in(pad, ship)), "A criminal absent from the pad was accepted") TEST_ASSERT_EQUAL(crew.reputation, 0, "A refused turn-in earned reputation") var/list/card = posting.board_ui_entry(ship, pad) TEST_ASSERT_EQUAL(card["reputation_gain"], 2, "The wanted card lost what it is worth") - criminal.body_go_down() + criminal.adjustBruteLoss(110) // into tg crit TEST_ASSERT_EQUAL(record.board_work_zone, 2, "Capture did not bind its actual field band") TEST_ASSERT_EQUAL(crew.reputation, 0, "Capture earned reputation before a paying turn-in") criminal.forceMove(deck) @@ -234,7 +234,7 @@ var/turf/field = make_zone_turf(locate(home.x + band, home.y, home.z), band) for(var/index in 1 to 3) var/datum/criminal_bounty/posting = board_test_posting(3) // BOUNTY_TIER_MOST_WANTED - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, field) + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, field) criminal.death() TEST_ASSERT_EQUAL(posting.record.board_work_zone, band, "Death did not bind its field band") criminal.forceMove(deck) @@ -253,8 +253,8 @@ tier_posting.record.status = "escaped" TEST_ASSERT_EQUAL(tier_posting.board_claim_zone_work(ship), 0, "An escaped prisoner earned reputation") TEST_ASSERT(!tier_posting.record.board_reputation_claimed, "An unpaid escape consumed a reputation claim") - var/mob/living/basic/bounty_criminal/admin_spawn = allocate(/mob/living/basic/bounty_criminal, home) - admin_spawn.body_go_down() + var/mob/living/carbon/human/npc/bounty_criminal/admin_spawn = allocate(/mob/living/carbon/human/npc/bounty_criminal, home) + admin_spawn.adjustBruteLoss(110) // into tg crit TEST_ASSERT_NULL(admin_spawn.posting(), "An admin spawn acquired a posting") TEST_ASSERT_EQUAL(crew.reputation, 20, "An admin spawn earned reputation") @@ -265,8 +265,8 @@ var/obj/structure/overmap/ship/ship = paid_ship(deck, home) var/obj/machinery/mission_pad/pad = allocate(/obj/machinery/mission_pad, deck) var/datum/criminal_bounty/posting = board_test_posting(2, null, 1000, 0, "normal") - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, field) - criminal.ai_surrender() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, field) + criminal.npc_surrender() // tg *surrender TEST_ASSERT_EQUAL(posting.record.board_work_zone, 2, "Surrender did not bind the field band") criminal.forceMove(deck) TEST_ASSERT(islist(posting.turn_in(pad, ship)), "An uncuffed surrendered criminal was not paid") @@ -276,8 +276,8 @@ var/turf/field = make_zone_turf(run_loc_floor_bottom_left, 2) var/datum/criminal_bounty/posting = board_test_posting(2) var/datum/bounty_record/record = posting.record - var/mob/living/basic/bounty_criminal/criminal = board_test_criminal(posting, field) - criminal.body_go_down() + var/mob/living/carbon/human/npc/bounty_criminal/criminal = board_test_criminal(posting, field) + criminal.adjustBruteLoss(110) // into tg crit TEST_ASSERT_EQUAL(record.board_work_zone, 2, "The first sighting did not bind its capture band") TEST_ASSERT(posting.relist("slipped away"), "The escape did not start relisting") TEST_ASSERT_NULL(record.board_work_zone, "An escaped sighting kept its old work band") From 2c91ed0dc6af854190c76e92c4cb49a8f4a858e7 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:35:20 -0800 Subject: [PATCH 051/472] Wiki: bounty criminals as people, pay by condition on the pad --- wiki/content/bounty-hunting.md | 101 +++++++++++++++++---------------- 1 file changed, 51 insertions(+), 50 deletions(-) diff --git a/wiki/content/bounty-hunting.md b/wiki/content/bounty-hunting.md index 1b77b7de862..1d9966e2f51 100644 --- a/wiki/content/bounty-hunting.md +++ b/wiki/content/bounty-hunting.md @@ -5,7 +5,7 @@ order: 2.5 blurb: Hunt wanted criminals from your mission board, bring them to your mission pad alive for full pay, and send them on to outpost prisons. --- -Wanted criminals hide out on planets, in ruins, aboard pirate ships and among the patrons of trader outposts. Take a bounty from your ship's mission board, find the criminal, subdue them and bring them to your ship's mission pad. They pay the most when they arrive stunned or cuffed and were never downed. Pay works like [missions](missions.md): credits go to the ship account and vouchers appear on the pad. A criminal caught alive goes on to serve time in a player outpost's [prison wing](prison-wing.md). +Wanted criminals hide out on planets, in ruins, aboard pirate ships and among the patrons of trader outposts. Take a bounty from your ship's mission board, find the criminal, subdue them and bring them to your ship's mission pad. They pay the most when they arrive stunned or cuffed. Pay works like [missions](missions.md): credits go to the ship account and vouchers appear on the pad. A criminal caught alive goes on to serve time in a player outpost's [prison wing](prison-wing.md). ## The bounty board @@ -23,7 +23,7 @@ Each card is a wanted poster: - the mugshot, name, species, sex and alias. "Old photo" under the mugshot means it shows how they used to look (a trader-outpost fugitive); - how they're wanted and what for: **Wanted alive**, **Wanted dead or alive** (the kingpin) or **Wanted dead** (lairs and the lich); - where they were last seen, and the zone; -- the reward: the full pay, in credits and vouchers. The card doesn't show what a downed or dead catch pays; see [the pad and turning in](#the-pad-and-turning-in); +- the reward: the full pay, in credits and vouchers. The card doesn't show what a catch in critical condition or dead pays; see [the pad and turning in](#the-pad-and-turning-in); - the time left. The card doesn't say what kind of criminal it is, or how they fight. [The three kinds of criminal](#the-three-kinds-of-criminal) below does. A private offer shows no mugshot until you accept it. @@ -47,7 +47,7 @@ The reward is fixed when the bounty is posted. It depends on the tier and the zo | Public bounty | 45 minutes. The clock stops while a crew hunts it, for up to 30 minutes of each crew's hunt. | | Private offer | 20 minutes to accept it, then 30 minutes to bring them in. | -Successful paid criminal turn-ins earn [reputation](zone-reputation.md): Petty counts as easy, Wanted as medium, and Most Wanted or a boss as hard. Alive, downed and dead settlements count. The criminal's first capture or death fixes the zone; moving them to another pad does not change it. Lair and other kill-only trophies count as hard. The poster shows what it is worth to your crew, and the turn-in notice says what you got. +Successful paid criminal turn-ins earn [reputation](zone-reputation.md): Petty counts as easy, Wanted as medium, and Most Wanted or a boss as hard. Alive, critical and dead settlements count. The criminal's first capture or death fixes the zone; moving them to another pad does not change it. Lair and other kill-only trophies count as hard. The poster shows what it is worth to your crew, and the turn-in notice says what you got. ## Taking a bounty @@ -71,7 +71,7 @@ A criminal appears once their site is loaded, away from any players. What they'r Tap a handheld GPS on the mission board, or press **Link Mission Beacons** while wearing a MODsuit with a GPS module. This links the sighting beacon (tagged WANTED) of every bounty your ship hunts. The beacon moves every 2 minutes to a spot within 5 tiles of the criminal, and stays put while they are hiding. There's no beacon at trader outposts. -If you leave and the site unloads, the criminal goes with it. They're back the next time it loads, fully healed and out of any cuffs. They still count as the worst state they ever reached, so one you downed never pays full afterwards. +If you leave and the site unloads, the criminal goes with it. They're back the next time it loads, unhurt and out of any cuffs. One who was dead when the site unloaded comes back as their evidence tag. Meek and normal criminals notice anyone within 6 tiles holding a weapon, cuffs or a warrant, and a meek one also notices anyone running at them. Walk up with empty hands. Mini-bosses go for any hunter they see. @@ -81,11 +81,11 @@ Meek and normal criminals notice anyone within 6 tiles holding a weapon, cuffs o About 70% of Petty criminals and 30% of Wanted ones are meek. They never start a fight. -- They sprint faster than you can run, and duck about half the shots fired at them while sprinting. After about 8 seconds of sprinting they're winded and slow down. -- They hide in a locker or crate, pose as a potted plant, or stand still on a dark tile. Opening the locker, bumping into them, examining them from the next tile, hurting them, or lighting up their dark spot finds them. They shove you back a tile and run again. +- They run as fast as you do, faster for a while once they jab the stimpack they carry. A reflex implant makes the odd shot miss while they're moving. They tire like anyone. +- They hide in a locker or crate, pose as a potted plant, or stand still on a dark tile. Opening the locker, bumping into them, examining them from the next tile, hurting them, or lighting up their dark spot finds them. They shove you and run again. - After a minute and a half in hiding they start to fidget: a rustle or a shake every 20 seconds. If nobody is around for a few minutes, they come out. -- Cornered, they shout and aim a small pistol for half a second, then fire eight quick shots. A stamina hit during the aim makes them fumble it. -- 120 base health before [zone scaling](threats-and-elites.md). Three disabler shots stun one. +- Cornered, they shout, pull a small pistol out of a pocket, aim for half a second and empty it at you. A hit during the aim makes them fumble it. +- They're as tough as anyone. Four disabler shots stun one. **Bring:** a disabler or other ranged stun, a flashlight, cuffs, and someone to block the exits. @@ -97,49 +97,50 @@ About 70% of Petty criminals and 30% of Wanted ones are meek. They never start a About 70% of Wanted criminals and 30% of Petty ones are normal. A Wanted criminal on a pirate ship is always normal. - They notice anyone holding a weapon, cuffs or a warrant within 6 tiles. Cuffs or a warrant and they stop and watch you. A weapon out and they fight. -- Each has one fighting style: fists (tougher, and harder to stun), a knife (cuts that bleed), a pistol (keeps its distance and empties a 15-round magazine before reloading), a shotgun loaded with buckshot (close range, reloads after four shells), a baseball bat (knocks you back) or thrown bottles. Wanted criminals carry a combat knife, a metal bat or a combat shotgun instead. Their weapons use the real item's damage, increased by the spawn zone. Their displayed weapon does not guarantee a loot drop. -- They may have up to two companions who fight beside them. Companions pay nothing. They may run when badly hurt or when their criminal goes down, and give up for good once stunned. -- Below 40% health, with two hunters nearby who can see them, they may give up and put their hands up. Someone who has given up counts as stunned: cuff them for full pay. Stop hitting them, because they can still be downed. -- Badly hurt with nobody around, they break off and retreat. They never heal. +- Each has one fighting style, with a real weapon they carry and draw when the fight starts: fists (and shoves), a knife, a machine pistol with a spare magazine, a pump shotgun with buckshot, a baseball bat or thrown bottles. Wanted criminals carry a combat knife or a semi-automatic shotgun instead. Their guns run dry and they reload them like you do. +- Wanted criminals wear an armour vest, and Most Wanted a vest and a helmet. Nothing a criminal carries can be taken: it goes with the body. +- They may have up to two companions who fight beside them. Companions pay nothing. They may run when badly hurt or when their criminal goes down, and when they come round from a stun they may give up or run. +- Badly hurt, with two hunters nearby who can see them, they may give up: they put their weapon away, put their hands on their head and lie down for 20 seconds. Cuff them then for full pay. If nobody does, they get up and decide again. +- Badly hurt with nobody around, they break off, sit down and patch themselves up with what they carry. **Bring:** a partner, stun weapons, armour and cuffs. ### Mini-bosses -Every Most Wanted criminal is a mini-boss with one of five kits. Bosses and their companions also use the [zone health and damage profile](threats-and-elites.md). The board's **Other crews hunting** line excludes your own active hunt. +Every Most Wanted criminal is a mini-boss with one of five kits. The board's **Other crews hunting** line excludes your own active hunt. -- Their health grows with the number of hunters who can see them when the fight starts, and with anyone who hurts them later. -- Every ability winds up first, with a sound and a mark on the floor. Move out of the mark. +- They wear their kit's armour and take hits better than most people. +- Every hunter after the first brings one more of their crew, who turns up from somewhere out of sight. +- Every ability winds up first, with a sound and a mark on the floor. Move out of the mark. Fire, blasts and blades hurt whoever is standing there, down or not. - They break tables, chairs, windows and grilles, and up to two interior walls or doors in a fight. -- While fresh they ignore stuns and stamina damage completely. -- Below 40% health they are **worn out**: "breathing hard and slowing down". They move slower, use their abilities less often, and stun weapons work on them. About five disabler shots stun one (six for the Heavy). -- They go down at 25% health. Between 40% and 25% is your window to stun and cuff them for full pay. -- After getting up from being downed, they shrug off stamina damage for 20 seconds. +- At the start of a fight they jab a stimulant: for a while they shrug off batons and get up from stuns quickly. They carry a second for later. +- Badly hurt or out of breath they're **worn out**: "breathing hard and slowing down", and they fall back for a moment. That's the time to stun and cuff them. | Kit | Looks like | What it does | How to handle it | |---|---|---|---| -| Juggernaut | An armoured criminal in riot plates | Charges along a line after a roar, smashing what's in the way. Slams the floor around it when you're close. Resists damage. | Step off the charge line. If it charges into something it can't break, it reels for 2 seconds and takes extra damage. | -| Pyromaniac | A scorched criminal in a fire suit | Sweeps a cone of fire 3 tiles long and throws molotovs that leave burning floor. Fire can't hurt it. | Stay more than 3 tiles away and off burning floor. Use lasers or bullets, not fire. | +| Juggernaut | A giant in riot plates | Charges along a line after a roar, smashing what's in the way. Slams the floor around it when you're close. | Step off the charge line. If it charges into something it can't break, it hurts itself and falls down for a few seconds. | +| Pyromaniac | A scorched criminal in a fire suit | Sweeps a cone of fire 3 tiles long and throws molotovs that leave burning floor. Its fire suit keeps the flames off it. | Stay more than 3 tiles away and off burning floor. If you catch fire, stop, drop and roll, or use an extinguisher. | | Demolitionist | A criminal in a hard hat with a grenade bandolier | Keeps 4 to 6 tiles away and throws grenades with a blinking fuse. Blows holes in interior walls and doors to reach you behind cover. | Close the distance, and move off the blinking ring. | -| Ghost | A hooded criminal with a long knife | Fast. Dashes in with cuts that bleed, cloaks into a shimmer, dodges shots while moving, and backs off after two cuts. | A flash or a hit breaks the cloak. Shoot it when it stops moving. | -| Heavy | A heavily armed criminal with a belt-fed gun | Heavy armour, slow. Shows a laser sight and a red cone, then fires a burst into it. Drops a barricade that stops half your shots. | Get out of the red cone before it fires. Go around the barricade. | +| Ghost | A hooded criminal with a combat knife | Fast. Dashes in with knife cuts that bleed, cloaks into a shimmer, and backs off after two cuts. Its implants make shots miss now and then while it moves, much more often during its burst of speed. | A flash or a hit breaks the cloak, and an EMP ends the burst. Shoot it when it stops moving. | +| Heavy | A heavily armed criminal with a belt-fed gun | Heavy armour, slow. Shows a laser sight and a red cone, then fires a burst into it. Throws barrier grenades that open into a barricade. | Get out of the red cone before it fires. Go around the barricade. | !!! warning "Worn out means switch to stuns" - Once a mini-boss is worn out, stop using lethal weapons. A few more hits put it under 25%, and a downed criminal only pays 60%. + A mini-boss in critical condition only pays 60% on the pad. Once it's worn out, stun it and cuff it, or patch it up before you turn it in. ## Taking them alive -- **Downed.** At 25% health a criminal collapses. A hit on one still standing can't take them past that line, so one shot never kills them. A downed criminal can still be killed. -- **They get back up.** Meek criminals after 60 seconds, normal ones after 75, mini-bosses after 45. They groan and try to rise for the last 10 seconds. Cuffed, they stay down. A stamina hit on a downed criminal starts their clock over. -- **Cuffs** only go on a criminal who is downed, stunned or has given up, and take 2 seconds. Reaching for a free one's wrists sets them off: a meek one runs and a normal one fights. -- **Restraints.** Handcuffs hold for good. Cable restraints slip after 3 minutes and zipties after 5, with a visible struggle for the last 20 seconds. Mini-bosses snap them in half the time. -- **Dragging.** You can pull a criminal who is downed, stunned, cuffed or dead. A free one pulls away. -- **Carrying.** A criminal lying on the floor who could be dragged can also be carried over your shoulders: grab them aggressively, then drag them onto yourself. They squirm off once they come round or get up. -- **No shortcuts.** Criminals can't be teleported. Transporters won't beam a locker, crate or bag with one inside, fultons won't attach to them, and a bluespace body bag won't fold with a live one in it. Carry them to your ship. -- Turrets, traps, fire and wildlife on their site can't take a criminal below 40% health. +Criminals are people. They bleed, break bones, fall into critical condition and die the way you do, and medicine, cuffs and stuns work on them the way they work on you. -!!! tip "Bring cuffs" - A stun wears off about 10 seconds after the last stamina hit (12 for a meek criminal). Cuffing takes 2. Have the cuffs in hand before you stun them. +- **Critical condition.** Hurt badly enough, a criminal collapses in critical condition, and stays down until someone treats them. Left alone they can bleed out or stop breathing, like anyone. +- **Stuns.** Batons, disablers, flashes, shoves, sedatives and anything else that stuns a person stuns a criminal. Armour and stimulants make some of them harder to put down. +- **Cuffs** are ordinary cuffs: four seconds, on anyone who stays still for them. A criminal on their feet sees you reach for their wrists and steps away, then runs or fights. Stun them first, or wait until they give up. +- **Getting out.** A cuffed criminal works at the cuffs: about a minute for handcuffs, 45 seconds for zipties and 30 for cable restraints. Dragging them breaks their concentration. +- **Dragging and carrying** work as on anyone. A criminal who can still move pulls free of a grab or squirms off your shoulders. +- **No shortcuts.** Criminals can't be teleported. Transporters won't beam a locker, crate or bag with a living one inside, fultons won't attach to them, and a bluespace body bag won't fold while someone alive is struggling inside. Carry them to your ship. +- **Patching up.** Some criminals carry bandages or medipens and use them when nobody is shooting at them. + +!!! tip "Bring cuffs and a stun" + A stun only lasts a little while. Have the cuffs in hand before you stun them. ## The pad and turning in @@ -147,20 +148,20 @@ Every Most Wanted criminal is a mini-boss with one of five kits. Bosses and thei - The pad must be bolted down and aboard your ship. - The pad says who is on it and what state they're in ("On the pad: Tess Harlow, restrained."), and why it won't take them if it won't. It never says what they'll pay. - Press **Turn In** on their card. If it's greyed out, its tooltip says why. -- The pad beams them away over 3 seconds. Credits go to the ship account. Vouchers appear on the pad. Your ship's notice says what state they were in and what was paid. +- The pad beams them away over 3 seconds. Credits go to the ship account. Vouchers appear on the pad, and so do your cuffs. Your ship's notice says what state they were in and what was paid. -Pay follows the **worst** state the criminal ever reached: +Pay follows the state they're in **on the pad** when you press Turn In: | State | Pays | |---|---| -| Stunned, given up or cuffed, and never downed | The full reward, with vouchers | -| Downed at any point, even if cuffed or back on their feet since | 60% | +| Stunned, given up or cuffed | The full reward, with vouchers | +| In critical condition | 60% | | Dead, or their evidence tag | 25% | | Standing and free | Refused | -Below full pay you get no vouchers. Instead, each voucher counts as 1,200 cr before the percentage is taken, and it's all paid in credits. For example, a Wanted criminal worth 1,400 cr and a voucher pays 1,560 cr downed and 650 cr dead. +What happened before doesn't count: a criminal you put in critical condition, patched up and cuffed pays in full. Below full pay you get no vouchers. Instead, each voucher counts as 1,200 cr before the percentage is taken, and it's all paid in credits. For example, a Wanted criminal worth 1,400 cr and a voucher pays 1,560 cr in critical condition and 650 cr dead. -A dead criminal can't be revived. If the body is destroyed (gibbed, dusted, or lost in lava or a chasm), an **evidence tag** turns up nearby. It pays the dead share. If nothing at all is left, the bounty lists again somewhere else 5 minutes later. +A dead criminal can be revived like anyone, and then pays for the state they're in. If the body is destroyed (gibbed, dusted, or lost in lava or a chasm), an **evidence tag** turns up nearby. It pays the dead share. If nothing at all is left, the bounty lists again somewhere else 5 minutes later. A bounty pays once. ## Trader outposts @@ -173,9 +174,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. +- **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. - Hitting the right patron on purpose exposes them, the same as the warrant. -- While they blend in, nobody can pull them, and shots aimed at someone else pass through them. The outpost's turrets leave them 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 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. ## Pirate ships @@ -198,14 +199,14 @@ A crime boss holds court in the lounge at the Undertow Exchange from the start o - The first posting comes at least 45 minutes into the round, with three or more active crews. The next comes 60 to 90 minutes after the last one ends. - He can only be turned in while he's on the board, wherever he is when it goes up. Kill him before that and there's nothing to claim. - He stays on the board for 60 minutes, and the clock stops during a shootout. If time runs out while he's in his lounge, he stays there. -- Alive, he pays 6,500–9,100 cr and 2 vouchers, and downed counts as alive. Dead, he pays 80%, all in credits. +- Alive, he pays 6,500–9,100 cr and 2 vouchers, and critical condition counts as alive. Dead, he pays 80%, all in credits. - Once he's turned in or killed, he's gone for the rest of the round. **The shootout.** "We're here for you.", or any attack on him or a goon, starts it. - The goons reach for their guns for about half a second before anyone fires. No more than two of them draw on one hunter at once. Three carry pistols, two shotguns and one an SMG. They stay near the sofa, and never shoot anyone who is down. -- He never leaves the sofa. Before each shot he aims his .357 revolver at someone with a red line for half a second. He reloads after seven shots. -- Once his goons are down and he's below 40% health, he may give up. He goes down at 25% and gets up after 60 seconds unless cuffed. Five disabler hits in quick succession stun him. +- 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. @@ -225,8 +226,8 @@ A mob nightclub run by Arkady Sokolov, placed in the Contested or Lawless Zone f - The staff room off the main floor has two rechargers, a first aid locker and no goons. The counting room's vault has a loot cache. - Two lieutenants hold the back office: **Tommy** with a Tommy gun, and **Brute**, whose punches can knock you down. Kill both and the garage door opens. - The don waits in the garage in his Mauler. Its health grows with the number of hunters. Its rocket volley marks the floor under each hunter it can see, then fires one rocket at each mark, so get behind a pillar. Its machine gun shows a red cone first. Up close it stomps, throwing you back. An EMP stalls it for 2 seconds. It comes after anyone who hurts it, so you can't pick it off from out of its sight. If nobody fights it for a minute, it goes back to full health. -- When the mech breaks, the don climbs out and fights on foot with his gold Desert Eagle from behind the wreck. He can't be cuffed. His **gold signet ring** is the trophy. -- Nobody in the club shoots a hunter who is down. +- When the mech breaks, the don climbs out and fights on foot with his gold Desert Eagle from behind the wreck. He never gives up, and cuffing him pays nothing: his **gold signet ring** is the trophy. +- Nobody in the club shoots a hunter who is down, but a rocket hits whoever stands under it. ### The lich @@ -266,11 +267,11 @@ The warden's console has a **Bounty prisoners** setting: **All**, **No Most Want **Why is Turn In greyed out?** The criminal has to be on the pad's own tile, not in a locker or bag. The pad has to be bolted down and aboard your ship. A criminal standing free is refused. -**Why did we only get 60%?** They were downed at some point. Pay follows the worst state they ever reached. +**Why did we only get 60%?** They were in critical condition on the pad. Treat them first: a criminal patched up and cuffed pays in full. -**They got up and ran off.** Downed criminals recover unless cuffed. Cuff them as soon as they're down, or stun them before they go down. +**They got out of the cuffs and ran off.** Criminals work at their cuffs when nobody's dragging them. Keep hold of them on the way to the pad. -**They vanished when we left the planet.** The site unloaded. They'll be back, fully healed, the next time it loads. +**They vanished when we left the planet.** The site unloaded. They'll be back, unhurt, the next time it loads. **We abandoned a bounty. Can we still turn them in?** Yes, if it's still up and you bring them to your pad. You just can't hunt it again. From b71f935d3f4cbe6a13204892d820a6da2ca47a36 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:49:57 -0800 Subject: [PATCH 052/472] Human NPC base follow-ups: internals follow the air, queued orders recheck their spot, spawners keep their dir, non-gear items can be stripped - NPCs open a carried tank only while the air they breathe would hurt them and close it when it is fine - A queued order is dropped if its spot's area or map region changed while it waited (recycled ground) - The abstract spawner places nothing and stays quiet in create-and-destroy - Mapped spawners pass their dir to the NPC; restore the 14 dir edits phase C's map swap dropped - Locked gear is hidden from the strip menu (TRAIT_NO_STRIP); the menu opens so keys, papers and proof can be taken --- .../wasteland_fortress_of_solitide.dmm | 4 +- .../wasteland_surface_camp_adobe.dmm | 6 ++ .../wasteland_surface_camp_drugstore.dmm | 4 +- .../wasteland_surface_camp_saloon.dmm | 4 + .../wasteland_surface_chokepoint.dmm | 2 + voidcrew/_DEFINES/human_npcs.dm | 2 + voidcrew/modules/human_npcs/human_npc.dm | 73 ++++++++++++++++--- voidcrew/modules/human_npcs/human_npc_gear.dm | 14 ++-- .../modules/human_npcs/human_npc_spawning.dm | 51 ++++++++++++- .../modules/human_npcs/ruins/ruin_npc_base.dm | 9 +-- .../modules/unit_tests/voidcrew_human_npcs.dm | 20 ++++- 11 files changed, 160 insertions(+), 29 deletions(-) diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm index 2d34b772c5c..a65c7b18f99 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_fortress_of_solitide.dmm @@ -2017,7 +2017,9 @@ /obj/structure/chair/wood{ dir = 8 }, -/obj/effect/spawner/human_npc/ruin/russian, +/obj/effect/spawner/human_npc/ruin/russian{ + dir = 8 + }, /turf/open/floor/wood, /area/ruin/powered) "tI" = ( diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm index c7660e306a6..254293218d9 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_adobe.dmm @@ -51,6 +51,7 @@ /area/ruin/unpowered) "al" = ( /obj/effect/spawner/human_npc/ruin/hermit/hunter{ + dir = 4; npc_faction = list("adobe") }, /turf/open/floor/plating/rust, @@ -104,6 +105,7 @@ /area/ruin/unpowered) "aw" = ( /obj/effect/spawner/human_npc/ruin/hermit/hunter{ + dir = 4; npc_faction = list("adobe") }, /obj/effect/decal/cleanable/dirt, @@ -111,6 +113,7 @@ /area/ruin/unpowered) "ax" = ( /obj/effect/spawner/human_npc/ruin/hermit{ + dir = 4; npc_faction = list("adobe") }, /turf/open/misc/wasteland/lit, @@ -149,12 +152,14 @@ /area/overmap_encounter/planetoid/wasteland) "aD" = ( /obj/effect/spawner/human_npc/ruin/hermit/gunslinger{ + dir = 4; npc_faction = list("adobe") }, /turf/open/floor/plating/rust, /area/ruin/unpowered) "aE" = ( /obj/effect/spawner/human_npc/ruin/hermit{ + dir = 1; npc_faction = list("adobe") }, /obj/effect/decal/cleanable/dirt, @@ -211,6 +216,7 @@ dir = 4 }, /obj/effect/spawner/human_npc/ruin/hermit{ + dir = 4; npc_faction = list("adobe") }, /obj/effect/decal/cleanable/dirt, diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm index 0cddd821e27..71ad41c0cac 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_drugstore.dmm @@ -54,7 +54,9 @@ /turf/open/floor/wood, /area/ruin/unpowered) "n" = ( -/obj/effect/spawner/human_npc/ruin/hermit, +/obj/effect/spawner/human_npc/ruin/hermit{ + dir = 4 + }, /turf/open/floor/wood{ broken = 1 }, diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm index af4b6686887..1d83e0ededd 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_camp_saloon.dmm @@ -192,6 +192,7 @@ "mr" = ( /obj/structure/chair/stool/bar, /obj/effect/spawner/human_npc/ruin/hermit{ + dir = 8; npc_faction = list("saloon") }, /turf/open/floor/wood{ @@ -205,6 +206,7 @@ "nE" = ( /obj/structure/chair/stool/bar, /obj/effect/spawner/human_npc/ruin/hermit{ + dir = 1; npc_faction = list("saloon") }, /turf/open/floor/wood, @@ -476,6 +478,7 @@ /obj/structure/chair/stool/bar, /obj/effect/decal/cleanable/dirt, /obj/effect/spawner/human_npc/ruin/hermit{ + dir = 8; npc_faction = list("saloon") }, /turf/open/floor/wood, @@ -557,6 +560,7 @@ "Xb" = ( /obj/item/chair/stool/bar, /obj/effect/spawner/human_npc/ruin/hermit{ + dir = 1; npc_faction = list("saloon") }, /turf/open/floor/wood, diff --git a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm index 442ce3b4cbe..1ffa9ecc108 100644 --- a/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm +++ b/_maps/voidcrew/RandomRuins/WastelandRuins/wasteland_surface_chokepoint.dmm @@ -41,6 +41,7 @@ /area/ruin/unpowered) "h" = ( /obj/effect/spawner/human_npc/ruin/nanotrasen{ + dir = 4; npc_name = "NanoTrasen Stranded Security Officer" }, /turf/open/misc/wasteland/lit, @@ -71,6 +72,7 @@ /area/overmap_encounter/planetoid/wasteland) "v" = ( /obj/effect/spawner/human_npc/ruin/nanotrasen{ + dir = 8; npc_name = "NanoTrasen Stranded Security Officer" }, /turf/open/misc/wasteland/lit, diff --git a/voidcrew/_DEFINES/human_npcs.dm b/voidcrew/_DEFINES/human_npcs.dm index 56871ad9b3b..8fc0187e5c5 100644 --- a/voidcrew/_DEFINES/human_npcs.dm +++ b/voidcrew/_DEFINES/human_npcs.dm @@ -92,6 +92,8 @@ #define HUMAN_NPC_SITE_COLD_TEMP (T0C - 10) /// Oxygen partial pressure below this, in kPa, is not breathable (tg's safe minimum). #define HUMAN_NPC_SITE_MIN_OXYGEN 16 +/// How much better than its lungs' limits (kPa) the air must be before an NPC on internals closes its valve. +#define HUMAN_NPC_AIR_MARGIN 2 /// A queued NPC dressed by an outfit table waits this long for its spawn zone to resolve, then takes green. #define HUMAN_NPC_ZONE_WAIT (60 SECONDS) diff --git a/voidcrew/modules/human_npcs/human_npc.dm b/voidcrew/modules/human_npcs/human_npc.dm index b827b2222ed..eae5bc433ae 100644 --- a/voidcrew/modules/human_npcs/human_npc.dm +++ b/voidcrew/modules/human_npcs/human_npc.dm @@ -60,7 +60,11 @@ * when the NPC dies or the ware is taken out of it (owner ruling), so it is never salvaged working. */ var/list/npc_cyberware - /// Opens an air tank it carries if it has a mask or helmet that takes one and its outfit didn't. + /** + * Handles its own air the way a person does (npc_update_internals()): opens a tank it carries when + * the air it would breathe is bad and it has a mask or helmet that takes one, and closes the valve + * again once the air is fine, so its tank doesn't run dry in a breathable room. + */ var/npc_internals = TRUE /// Typepaths spawned under it the first time it dies (its designed loot). Never a second time. var/list/npc_death_loot @@ -144,7 +148,7 @@ for(var/obj/item/held as anything in held_items) if(held) human_npc_ready_weapon(src, held) - npc_open_internals() + npc_update_internals() npc_set_suit_heating(TRUE) set_npc_toughness(npc_damage_taken) if(npc_body_armor) @@ -181,6 +185,9 @@ npc_catch_up_decay((world.time - npc_dormant_since) / (1 SECONDS), times_fired) npc_dormant_since = 0 npc_set_suit_heating(TRUE) + // Before the breath this Life() takes: a person who steps into bad air reaches for the valve. + if(stat != DEAD) + npc_update_internals() #ifdef HUMAN_NPC_SPIKE_HARNESS GLOB.human_npc_spike_life_passes++ #endif @@ -326,14 +333,64 @@ sensor_clothes.sensor_mode = SENSOR_OFF update_suit_sensors() +/** + * Opens or closes its internals for the air where it stands, as a person minds their valve: open while + * the air would hurt it (too thin, short of what its lungs need, poisonous), closed while it is fine. A + * conscious NPC only; one that can't use its hands (cuffs, a stun) leaves the valve as it is, as would a + * person. Species that don't breathe, or that only live on what's in their tank (plasmamen), keep it + * as it is. Called from Life() and when it is dressed. + */ +/mob/living/carbon/human/npc/proc/npc_update_internals() + if(!npc_internals || HAS_TRAIT(src, TRAIT_NOBREATH)) + return + if(stat > SOFT_CRIT || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED)) + return + var/obj/item/organ/lungs/lungs = get_organ_slot(ORGAN_SLOT_LUNGS) + if(isnull(lungs)) + return + // Lungs that need something ordinary air doesn't have: whatever it was dressed with stays on. + if(lungs.safe_plasma_min > 0 || lungs.safe_nitro_min > 0) + return + var/bad_air = human_npc_air_hurts(get_turf(src), lungs, margin = internal ? HUMAN_NPC_AIR_MARGIN : 0) + if(bad_air) + if(!internal) + npc_open_internals() + else if(internal && !external) + close_internals() + /// Turns on an air tank it wears, if it has something to breathe it through, the way a player does. /mob/living/carbon/human/npc/proc/npc_open_internals() - if(!npc_internals || internal || !can_breathe_internals()) + if(internal || !can_breathe_internals()) return for(var/obj/item/tank/internals/tank in get_equipped_items(INCLUDE_POCKETS | INCLUDE_HELD)) + if(tank.air_contents?.return_pressure() <= 0) + continue if(open_internals(tank)) return +/** + * Whether breathing the air at `spot` would hurt someone with `lungs`: pressure too low or too high, + * too little oxygen, or too much plasma, carbon dioxide or sleeping gas, by those lungs' own limits. + * `margin` (kPa) asks for that much better than the limit before the air counts as fine again, so a + * valve isn't worked every breath at the edge. + */ +/proc/human_npc_air_hurts(turf/spot, obj/item/organ/lungs/lungs, margin = 0) + var/datum/gas_mixture/air = spot?.return_air() + if(isnull(air)) + return TRUE + var/pressure = air.return_pressure() + if(pressure < HAZARD_LOW_PRESSURE + margin * 4 || pressure > HAZARD_HIGH_PRESSURE) + return TRUE + if(lungs.safe_oxygen_min && human_npc_partial_pressure(air, /datum/gas/oxygen, pressure) < lungs.safe_oxygen_min + margin) + return TRUE + if(lungs.safe_plasma_max && human_npc_partial_pressure(air, /datum/gas/plasma, pressure) > max(lungs.safe_plasma_max - margin * 0.01, 0)) + return TRUE + if(lungs.safe_co2_max && human_npc_partial_pressure(air, /datum/gas/carbon_dioxide, pressure) > lungs.safe_co2_max - margin) + return TRUE + if(lungs.n2o_para_min && human_npc_partial_pressure(air, /datum/gas/nitrous_oxide, pressure) >= lungs.n2o_para_min) + return TRUE + return FALSE + /** * Sets how much brute and burn damage it takes, as a multiple of a person's. Scales the * physiology mods it set before, so other changes to them (species, implants) are kept. @@ -349,12 +406,10 @@ // ===== RULES OF ENGAGEMENT ===== -// The strip menu never opens on a human NPC (D3). Carrying and dragging are unaffected. An NPC whose -// gear isn't locked (prisoners, OC-11) is searched and stripped like anyone. -/mob/living/carbon/human/npc/should_strip(mob/user) - if(!npc_gear_locked) - return ..() - return FALSE +// The strip menu works as on anyone, but locked gear (D3) carries TRAIT_NO_STRIP, so its slots read as +// empty and can't be emptied. What isn't gear (a ship key, bounty proof, a paper) can be taken from a +// cuffed, downed or careless NPC the way it could from a person. An NPC whose gear isn't locked +// (prisoners, OC-11) is searched and stripped like anyone. // D4: an NPC's hits become aimed crits and executions only if it is an elite or a boss, or a // person is driving the body. diff --git a/voidcrew/modules/human_npcs/human_npc_gear.dm b/voidcrew/modules/human_npcs/human_npc_gear.dm index 0ae34c58312..557828ce6ab 100644 --- a/voidcrew/modules/human_npcs/human_npc_gear.dm +++ b/voidcrew/modules/human_npcs/human_npc_gear.dm @@ -7,7 +7,9 @@ // disarm shoves, a held item dropped on a knockdown, gib item drops, a stray transferItemToLoc(). // The NPC's own moves inside its inventory (magazine into its gun, gun into its holster, a tank // into its suit storage) still work, because they end on the NPC. -// * The strip menu never opens on a human NPC (should_strip() in human_npc.dm). +// * Locked items carry TRAIT_NO_STRIP: tg's strip menu shows their slots as empty and won't take them. +// The menu still opens, so what isn't gear (a captain's ship key in his pocket) can be taken off a +// cuffed or downed NPC as off a person. // * Anything that forces the item out of the body anyway (an arm cut off still holding the gun, a // head knocked off with the helmet on, species changes, admin undress) lands outside the NPC, and // the item is deleted on the next tick (COMSIG_MOVABLE_MOVED). The wait lets tg code that moves an @@ -71,7 +73,7 @@ GLOBAL_LIST_INIT(human_npc_consumable_gear, typecacheof(list( if(!isitem(target)) return ELEMENT_INCOMPATIBLE var/obj/item/gear = target - ADD_TRAIT(gear, TRAIT_HUMAN_NPC_GEAR, HUMAN_NPC_GEAR_TRAIT_SOURCE) + gear.add_traits(list(TRAIT_HUMAN_NPC_GEAR, TRAIT_NO_STRIP), HUMAN_NPC_GEAR_TRAIT_SOURCE) RegisterSignal(gear, COMSIG_ITEM_PRE_UNEQUIP, PROC_REF(on_pre_unequip)) RegisterSignal(gear, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) RegisterSignal(gear, COMSIG_ITEM_PRE_EXPORT, PROC_REF(on_pre_export)) @@ -79,7 +81,7 @@ GLOBAL_LIST_INIT(human_npc_consumable_gear, typecacheof(list( /datum/element/human_npc_gear/Detach(datum/source) UnregisterSignal(source, list(COMSIG_ITEM_PRE_UNEQUIP, COMSIG_MOVABLE_MOVED, COMSIG_ITEM_PRE_EXPORT)) - REMOVE_TRAIT(source, TRAIT_HUMAN_NPC_GEAR, HUMAN_NPC_GEAR_TRAIT_SOURCE) + source.remove_traits(list(TRAIT_HUMAN_NPC_GEAR, TRAIT_NO_STRIP), HUMAN_NPC_GEAR_TRAIT_SOURCE) return ..() /// Refuses any drop or hand-over that would leave the NPC's body. Forced moves are let through and @@ -115,12 +117,14 @@ GLOBAL_LIST_INIT(human_npc_consumable_gear, typecacheof(list( human_npc_gear_strip_materials(parent) /datum/component/human_npc_consumable/RegisterWithParent() - ADD_TRAIT(parent, TRAIT_HUMAN_NPC_CONSUMABLE, HUMAN_NPC_GEAR_TRAIT_SOURCE) + var/obj/item/item = parent + item.add_traits(list(TRAIT_HUMAN_NPC_CONSUMABLE, TRAIT_NO_STRIP), HUMAN_NPC_GEAR_TRAIT_SOURCE) RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) RegisterSignal(parent, COMSIG_ITEM_PRE_EXPORT, PROC_REF(on_pre_export)) /datum/component/human_npc_consumable/UnregisterFromParent() - REMOVE_TRAIT(parent, TRAIT_HUMAN_NPC_CONSUMABLE, HUMAN_NPC_GEAR_TRAIT_SOURCE) + var/obj/item/item = parent + item.remove_traits(list(TRAIT_HUMAN_NPC_CONSUMABLE, TRAIT_NO_STRIP), HUMAN_NPC_GEAR_TRAIT_SOURCE) UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_ITEM_PRE_EXPORT)) /datum/component/human_npc_consumable/Destroy(force) diff --git a/voidcrew/modules/human_npcs/human_npc_spawning.dm b/voidcrew/modules/human_npcs/human_npc_spawning.dm index 406d8e8f986..c5f583c9d6b 100644 --- a/voidcrew/modules/human_npcs/human_npc_spawning.dm +++ b/voidcrew/modules/human_npcs/human_npc_spawning.dm @@ -31,9 +31,11 @@ * * context - a /datum/npc_spawn_context (voidcrew/modules/npc_scaling/); the body is built through * spawn_npc_in_zone() and the context finalised after on_spawned * * immediate - build it now instead of queueing it (admin tools, tests) + * * dir - the way it faces when it appears (a mapped spawner's dir), or NONE for its own */ -/proc/spawn_human_npc(npc_type, atom/where, datum/callback/on_spawned, look, outfit, name, list/faction, datum/npc_spawn_context/context, immediate = FALSE) +/proc/spawn_human_npc(npc_type, atom/where, datum/callback/on_spawned, look, outfit, name, list/faction, datum/npc_spawn_context/context, immediate = FALSE, dir = NONE) var/datum/human_npc_order/order = new(npc_type, where, on_spawned, look, outfit, name, faction, context) + order.dir = dir if(immediate) order.fulfil() else @@ -50,6 +52,13 @@ var/name var/list/faction var/datum/npc_spawn_context/context + /// The way it faces when built, or NONE + var/dir = NONE + /// The area its spot was in when queued: the order is dropped if the spot has been reused since + var/area/where_area + /// Weakref to the map region its spot was in when queued (a packed encounter's footprint or a + /// reservation), for the same check; null on a level that isn't packed + var/datum/weakref/where_region /// The ZONE_* it is dressed for, once known (resolve_zone()) var/zone /// world.time it was queued @@ -66,6 +75,9 @@ CRASH("spawn_human_npc() was given [npc_type], which is not a human NPC type") src.npc_type = npc_type src.where = get_turf(where) + src.where_area = src.where?.loc + var/datum/region = map_region_for_turf(src.where) + src.where_region = region ? WEAKREF(region) : null src.on_spawned = on_spawned src.look = look src.outfit = outfit @@ -82,6 +94,8 @@ /// Drops what the order held, so a fulfilled order keeps nothing alive. /datum/human_npc_order/proc/release() where = null + where_area = null + where_region = null on_spawned = null look = null outfit = null @@ -120,6 +134,23 @@ next_zone_check = world.time + 1 SECONDS return !isnull(resolve_zone(now = FALSE)) +/** + * Whether the spot it was queued for is still the same place. An order can wait in the queue (up to + * HUMAN_NPC_ZONE_WAIT for its zone); by then the ruin or planet it was for may have unloaded and its + * turfs been reused by something else. The spot must still exist, be in the same area and the same + * packed map region, and not have become a wall. + */ +/datum/human_npc_order/proc/spot_still_valid() + if(isnull(where) || QDELETED(where)) + return FALSE + if(where.loc != where_area) + return FALSE + // A region that registers after the spot was queued (a planet level coming up late) is fine; one + // that has gone, or been replaced by another, means the ground was recycled. + if(where_region && map_region_for_turf(where) != where_region.resolve()) + return FALSE + return !isclosedturf(where) + /// Builds the NPC now. Returns it, or null if it couldn't be built. /datum/human_npc_order/proc/fulfil() if(done) @@ -127,7 +158,7 @@ done = TRUE SShuman_npcs.queue -= src var/mob/living/carbon/human/npc/npc - if(!isnull(where)) + if(spot_still_valid()) if(context) npc = spawn_npc_in_zone(npc_type, where, context, finalize = FALSE, constructor_args = list(src)) else @@ -136,6 +167,8 @@ release() return null spawned_ref = WEAKREF(npc) + if(dir) + npc.setDir(dir) SShuman_npcs.spawned_total++ on_spawned?.Invoke(npc) context?.finalize() @@ -251,10 +284,20 @@ SUBSYSTEM_DEF(human_npcs) var/npc_name /// Factions for this one NPC (a mapped camp's own side), or null for its type's var/list/npc_faction + /// The family root this type is: never mapped and places nothing, so it may have no npc_type. A + /// family's own root subtype sets this to itself (tests make one of every type). + var/abstract_spawner_type = /obj/effect/spawner/human_npc /obj/effect/spawner/human_npc/Initialize(mapload) . = ..() if(!ispath(npc_type, /mob/living/carbon/human/npc)) - stack_trace("[type] at [AREACOORD(src)] has no human NPC type to spawn") + if(type != abstract_spawner_type) + stack_trace("[type] at [AREACOORD(src)] has no human NPC type to spawn") + return +#ifdef UNIT_TESTS + // The create-and-destroy test makes one of every type on one tile; it isn't a map, so place nothing. + if(GLOB.running_create_and_destroy) return - spawn_human_npc(npc_type, get_turf(src), name = npc_name, faction = npc_faction, context = get_npc_spawn_context()) +#endif + // The mapped spawner's dir is the way the NPC faces. + spawn_human_npc(npc_type, get_turf(src), name = npc_name, faction = npc_faction, context = get_npc_spawn_context(), dir = dir) diff --git a/voidcrew/modules/human_npcs/ruins/ruin_npc_base.dm b/voidcrew/modules/human_npcs/ruins/ruin_npc_base.dm index f6e4a5d5cf0..23e2866be79 100644 --- a/voidcrew/modules/human_npcs/ruins/ruin_npc_base.dm +++ b/voidcrew/modules/human_npcs/ruins/ruin_npc_base.dm @@ -124,13 +124,8 @@ /// npc_name and npc_faction set one mapped NPC's own name and side. /obj/effect/spawner/human_npc/ruin name = "ruin NPC spawner" - -// The family's own type places nothing (it is never mapped; tests create every type once). -/obj/effect/spawner/human_npc/ruin/Initialize(mapload) - if(isnull(npc_type)) - flags_1 |= INITIALIZED_1 - return INITIALIZE_HINT_QDEL - return ..() + // The family's own type places nothing (it is never mapped; tests create every type once). + abstract_spawner_type = /obj/effect/spawner/human_npc/ruin // ===== Derelict shuttles inside space ruins ===== // The caravan ambush ruin docks four upstream derelict shuttles (code/datums/shuttles/ruin.dm) that diff --git a/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm b/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm index 34c480a273b..7916bed68e1 100644 --- a/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm +++ b/voidcrew/modules/unit_tests/voidcrew_human_npcs.dm @@ -71,8 +71,9 @@ TEST_ASSERT(npc.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "200 stamina damage did not stamcrit a human NPC") TEST_ASSERT(npc.ai_controller.get_able_to_run() & AI_UNABLE_TO_RUN, "A stamcrit human NPC's AI can still act") -/// The strip menu never opens, the NPC never lets go of its kit, and kit forced off it is deleted, +/// The NPC never lets go of its kit, the strip menu shows none of it, and kit forced off it is deleted, /// unless it is straight back on the NPC (a tactical reload drops the old magazine and catches it). +/// What isn't gear (a paper, a ship key) can be taken off it through the strip menu like off anyone. /datum/unit_test/voidcrew_human_npc_gear_lock /datum/unit_test/voidcrew_human_npc_gear_lock/Run() @@ -86,7 +87,22 @@ TEST_ASSERT(human_npc_gear_locked(kit), "[kit] is not locked to the NPC") TEST_ASSERT(!human_npc_gear_locked(npc.get_bodypart(BODY_ZONE_CHEST)), "A body part was locked as gear") - TEST_ASSERT(!npc.should_strip(player), "The strip menu would open on a human NPC") + // The strip menu opens, but every piece of kit reads as an empty slot and can't be taken. + TEST_ASSERT(npc.should_strip(player), "The strip menu would not open on a human NPC") + for(var/obj/item/kit as anything in list(gun, spare, npc.w_uniform, npc.wear_suit)) + TEST_ASSERT(HAS_TRAIT(kit, TRAIT_NO_STRIP), "[kit] shows in the strip menu") + var/datum/strippable_item/suit_slot = GLOB.strippable_human_items[STRIPPABLE_ITEM_SUIT] + TEST_ASSERT(!suit_slot.start_unequip(npc, player), "The strip menu would start taking a human NPC's armour") + // A paper in its pocket isn't gear: it comes out like anyone's. + var/obj/item/paper/note = allocate(/obj/item/paper) + if(!npc.equip_to_slot_if_possible(note, ITEM_SLOT_LPOCKET, disable_warning = TRUE)) + npc.equip_to_slot_if_possible(note, ITEM_SLOT_RPOCKET, disable_warning = TRUE) + TEST_ASSERT(npc.get_slot_by_item(note) & ITEM_SLOT_POCKETS, "No pocket of the sample gunner took a paper") + TEST_ASSERT(!HAS_TRAIT(note, TRAIT_NO_STRIP), "A paper in a human NPC's pocket is hidden from the strip menu") + var/datum/strippable_item/pocket = GLOB.strippable_human_items[npc.l_store == note ? STRIPPABLE_ITEM_LPOCKET : STRIPPABLE_ITEM_RPOCKET] + TEST_ASSERT(pocket.try_unequip(npc, player), "The strip menu would refuse a paper in a human NPC's pocket") + pocket.finish_unequip(npc, player) + TEST_ASSERT(isturf(note.loc), "Stripping a paper from a human NPC's pocket did not take it out") TEST_ASSERT(!npc.dropItemToGround(gun), "A human NPC let go of its gun") TEST_ASSERT(npc.is_holding(gun), "The gun left the NPC's hand") TEST_ASSERT(!npc.transferItemToLoc(gun, player), "A human NPC handed its gun over") From 7720070a86110a921445b14ae310dfe3676d1409 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:49:57 -0800 Subject: [PATCH 053/472] Crewless faction pirates give up by their family's rules and keep near home; captains pocket the ship key Pirates with no crew (ruins, planets, contracts) now roll tg surrender by faction (skeletons never, knights rarely, auditors and tiders readily, bosses never) and walk back to where they were put once they stray ten tiles with nothing to fight. A captain keeps his ship key in a pocket, where a crew that cuffs or downs him can take it. --- voidcrew/modules/npc_ships/code/_defines.dm | 4 ++ .../faction_pirates/faction_pirate_humans.dm | 71 +++++++++++++++++++ voidcrew/modules/npc_ships/code/npc_ship.dm | 5 +- .../npc_ships/code/pirate_crew_human.dm | 32 +++++++++ 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/voidcrew/modules/npc_ships/code/_defines.dm b/voidcrew/modules/npc_ships/code/_defines.dm index 9e5eafe0b60..f6d0946fb93 100644 --- a/voidcrew/modules/npc_ships/code/_defines.dm +++ b/voidcrew/modules/npc_ships/code/_defines.dm @@ -307,6 +307,10 @@ GLOBAL_LIST_EMPTY(door_to_rooms) // ship_ref -> list(door_ref -> list(room_id_ #define BB_PIRATE_POST_TURF "pirate_post_turf" /// Where a mob that strayed is walking back to #define BB_PIRATE_RETURN_TURF "pirate_return_turf" +/// Where a pirate with no crew was put (a ruin's, a planet's, a contract's): it keeps near it +#define BB_PIRATE_CREWLESS_HOME "pirate_crewless_home" +/// A pirate with no crew and nothing to fight walks home once it is this many tiles from it +#define PIRATE_CREWLESS_LEASH 10 /// The door (or wrecked door frame) this mob is breaking through #define BB_PIRATE_BREACH_TARGET "pirate_breach_target" /// The turf it breaks the door from diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm index e4f40323b74..060544beed2 100644 --- a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm +++ b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_humans.dm @@ -46,6 +46,7 @@ HUMAN_NPC_BARK_CUFFED = list("The captain will hear of this.", "Get these off me!", "You'll hang for this."), HUMAN_NPC_BARK_SLIPPING = list("Almost..."), HUMAN_NPC_BARK_PULLED_FREE = list("Hands off!"), + HUMAN_NPC_BARK_SURRENDER = list("Alright! Alright, I'm done!", "Don't shoot! I yield!", "Quarter! I'm out!"), ) emotes = list( HUMAN_NPC_BARK_DOWNED = list("collapses."), @@ -79,6 +80,7 @@ HUMAN_NPC_BARK_CUFFED = list("I didn't take nothing!"), HUMAN_NPC_BARK_SLIPPING = list("Almost..."), HUMAN_NPC_BARK_PULLED_FREE = list("Hands off!"), + HUMAN_NPC_BARK_SURRENDER = list("Okay! Okay! I give!", "Not the face! I'm done!", "I didn't do nothing! I give up!"), ) /datum/human_npc_voice/faction_pirate/irs @@ -90,6 +92,7 @@ HUMAN_NPC_BARK_CUFFED = list("Obstruction of an audit. Noted."), HUMAN_NPC_BARK_SLIPPING = list("Almost..."), HUMAN_NPC_BARK_PULLED_FREE = list("Hands off!"), + HUMAN_NPC_BARK_SURRENDER = list("I'm unarmed! I'm an accountant!", "Fine! The audit is suspended!", "I yield! Pending review!"), ) /datum/human_npc_voice/faction_pirate/medieval @@ -101,14 +104,49 @@ HUMAN_NPC_BARK_CUFFED = list("Thou shalt pay for this dishonour."), HUMAN_NPC_BARK_SLIPPING = list("Almost..."), HUMAN_NPC_BARK_PULLED_FREE = list("Unhand me, cur!"), + HUMAN_NPC_BARK_SURRENDER = list("I yield! I yield!", "Quarter! I beg quarter!"), ) +/** + * When a pirate with no crew gives up (human_npc_surrender.dm): a ruin's, a planet's or a contract's. A crew + * aboard a ship fights on together, and its ship decides what is left of it (pirate_crew.dm). Bosses never + * give up; captains seldom. + */ +/datum/human_npc_surrender/faction_pirate + chance = 50 + retry_chance = 20 + +/datum/human_npc_surrender/faction_pirate/hurt_enough(mob/living/carbon/human/npc/npc) + if(npc.npc_rank >= HUMAN_NPC_RANK_BOSS) + return FALSE + return ..() + +/datum/human_npc_surrender/faction_pirate/odds(mob/living/carbon/human/npc/npc, list/hostiles, list/allies, first) + . = ..() + if(npc.npc_rank >= HUMAN_NPC_RANK_ELITE) + . = round(. / 2) + +/// Knights and their kind: they beg quarter less often, and never run. +/datum/human_npc_surrender/faction_pirate/stubborn + chance = 30 + retry_chance = 10 + flees = FALSE + +/// Auditors and tiders: one gun on them is enough, and they run when they can. +/datum/human_npc_surrender/faction_pirate/meek + chance = 70 + retry_chance = 35 + hostiles_needed = 1 + health_share = 0.5 + /mob/living/carbon/human/npc/faction_pirate name = "pirate" faction = list(FACTION_PIRATE) ai_controller = /datum/ai_controller/basic_controller/pirate_crew/human npc_voice = /datum/human_npc_voice/faction_pirate npc_target_stat = HUMAN_NPC_FINISHES_DOWNED + // Read only while it has no crew (pirate_crew_human.dm) + npc_surrender = /datum/human_npc_surrender/faction_pirate /// SHIP_CREW_ROLE_MELEE or SHIP_CREW_ROLE_GUNNER: which of its faction's outfits a regular wears var/crew_role /// Its faction's table with a sealed suit added, for boarders (an outfit table typepath) @@ -167,6 +205,9 @@ npc_damage_taken = SHIP_CREW_BOSS_DAMAGE_TAKEN . = ..() npc_dressed_zone = order?.resolve_zone() + // Where it was put: a pirate with no crew keeps near it (pirate_crew_human.dm) + if(istype(ai_controller)) + ai_controller.set_blackboard_key(BB_PIRATE_CREWLESS_HOME, get_turf(src)) // tg's Initialize gave it the planet's alliance before it was dressed; an ordered faction list may // have replaced its factions since, so it rejoins here. Off a planet this does nothing. inherit_planetary_faction(src) @@ -230,6 +271,31 @@ return TRUE return FALSE +/** + * Puts something that isn't gear (its ship's key) in a pocket, where a crew that has it cuffed or down can + * pick it out (the strip menu, human_npc.dm). A full pocket is emptied into its bag or belt first. Falls + * back to crew_give() if neither pocket can be had. + */ +/mob/living/carbon/human/npc/faction_pirate/proc/crew_pocket(obj/item/thing) + if(QDELETED(thing)) + return FALSE + for(var/slot in list(ITEM_SLOT_LPOCKET, ITEM_SLOT_RPOCKET)) + if(equip_to_slot_if_possible(thing, slot, disable_warning = TRUE)) + npc_lock_item(thing) + return TRUE + for(var/slot in list(ITEM_SLOT_LPOCKET, ITEM_SLOT_RPOCKET)) + var/obj/item/in_pocket = get_item_by_slot(slot) + if(isnull(in_pocket)) + continue + for(var/obj/item/container in list(back, belt)) + if(!container.atom_storage?.attempt_insert(in_pocket, src, override = TRUE, messages = FALSE)) + continue + if(equip_to_slot_if_possible(thing, slot, disable_warning = TRUE)) + npc_lock_item(thing) + return TRUE + break + return crew_give(thing) + /** * The zone it belongs to, for zone-laddered loot: its spawn context's, which may have resolved after it * was made (a ruin loading before its planet registered), else the zone it was dressed for, else where @@ -335,6 +401,8 @@ npc_outfit_table = /datum/human_npc_outfit_table/pirate/skeleton boarding_outfit_table = /datum/human_npc_outfit_table/pirate/skeleton/boarding npc_voice = /datum/human_npc_voice/faction_pirate/skeleton + // The dead don't give up + npc_surrender = null crew_alert_lines = list( "All hands! Boarders!", "Fresh crew coming aboard!", @@ -388,6 +456,7 @@ npc_outfit_table = /datum/human_npc_outfit_table/pirate/grey boarding_outfit_table = /datum/human_npc_outfit_table/pirate/grey/boarding npc_voice = /datum/human_npc_voice/faction_pirate/grey + npc_surrender = /datum/human_npc_surrender/faction_pirate/meek crew_alert_lines = list( "Someone's on the ship!", "Who let them in?", @@ -527,6 +596,7 @@ npc_outfit_table = /datum/human_npc_outfit_table/pirate/irs boarding_outfit_table = /datum/human_npc_outfit_table/pirate/irs/boarding npc_voice = /datum/human_npc_voice/faction_pirate/irs + npc_surrender = /datum/human_npc_surrender/faction_pirate/meek crew_alert_lines = list( "Unscheduled visitors. Begin the audit.", "Everyone aboard is now under review.", @@ -578,6 +648,7 @@ npc_outfit_table = /datum/human_npc_outfit_table/pirate/medieval boarding_outfit_table = /datum/human_npc_outfit_table/pirate/medieval/boarding npc_voice = /datum/human_npc_voice/faction_pirate/medieval + npc_surrender = /datum/human_npc_surrender/faction_pirate/stubborn crew_alert_lines = list( "To arms! Intruders!", "Who dares board us?", diff --git a/voidcrew/modules/npc_ships/code/npc_ship.dm b/voidcrew/modules/npc_ships/code/npc_ship.dm index 6c97a46fb5f..3f293efcd43 100644 --- a/voidcrew/modules/npc_ships/code/npc_ship.dm +++ b/voidcrew/modules/npc_ships/code/npc_ship.dm @@ -404,10 +404,11 @@ /obj/structure/overmap/ship/npc/proc/on_captain_built(mob/living/captain) if(QDELETED(captain)) return - // The key is not gear: a human captain keeps it on him, and it drops when he dies (on_crew_death()) + // The key is not gear: a human captain keeps it in a pocket, where a crew that cuffs or downs him can + // take it (the strip menu), and it drops when he dies (on_crew_death()) var/obj/item/ship_key/key = new(null, src) var/mob/living/carbon/human/npc/faction_pirate/human_captain = captain - if(!istype(human_captain) || !human_captain.crew_give(key)) + if(!istype(human_captain) || !human_captain.crew_pocket(key)) key.forceMove(captain) track_crewman(captain, PIRATE_ROLE_CAPTAIN) diff --git a/voidcrew/modules/npc_ships/code/pirate_crew_human.dm b/voidcrew/modules/npc_ships/code/pirate_crew_human.dm index 6d2011314e0..a154d204172 100644 --- a/voidcrew/modules/npc_ships/code/pirate_crew_human.dm +++ b/voidcrew/modules/npc_ships/code/pirate_crew_human.dm @@ -31,6 +31,7 @@ planning_subtrees = list( /datum/ai_planning_subtree/human_npc_soft_crit, /datum/ai_planning_subtree/escape_captivity, + /datum/ai_planning_subtree/human_npc_surrender/crewless, /datum/ai_planning_subtree/pirate_crew_find_target, /datum/ai_planning_subtree/pirate_crew_human_self_care, /datum/ai_planning_subtree/pirate_crew_doors, @@ -40,6 +41,7 @@ /datum/ai_planning_subtree/pirate_crew_hold_post, /datum/ai_planning_subtree/pirate_crew_hunt, /datum/ai_planning_subtree/pirate_crew_leash, + /datum/ai_planning_subtree/pirate_crewless_home, ) /datum/ai_controller/basic_controller/pirate_crew/human/TryPossessPawn(atom/new_pawn) @@ -75,6 +77,36 @@ return get_dist(npc, target) <= reach && can_see(npc, target, reach) return npc.Adjacent(target) +// ===== WITH NO CREW: ruins, planets, contracts ===== + +/// A pirate with no crew gives up by its family's rules (npc_surrender, human_npc_surrender.dm). One in a +/// crew fights on with it. +/datum/ai_planning_subtree/human_npc_surrender/crewless + +/datum/ai_planning_subtree/human_npc_surrender/crewless/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + if(istype(pirate_controller) && pirate_controller.get_crew()) + return + return ..() + +/// A pirate with no crew and nobody to fight walks back to where it was put once it has strayed more +/// than PIRATE_CREWLESS_LEASH tiles (a chase, a flight from its surrender). +/datum/ai_planning_subtree/pirate_crewless_home + +/datum/ai_planning_subtree/pirate_crewless_home/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/datum/ai_controller/basic_controller/pirate_crew/pirate_controller = controller + if(!istype(pirate_controller) || pirate_controller.get_crew() || controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/turf/home = controller.blackboard[BB_PIRATE_CREWLESS_HOME] + var/turf/here = get_turf(controller.pawn) + if(isnull(home) || isnull(here) || home.z != here.z) + return + if(get_dist(here, home) <= PIRATE_CREWLESS_LEASH) + return + controller.set_blackboard_key(BB_PIRATE_RETURN_TURF, home) + controller.queue_behavior(/datum/ai_behavior/pirate_crew_travel/leash, BB_PIRATE_RETURN_TURF) + return SUBTREE_RETURN_FINISH_PLANNING + /// Whether a human crewman fights at range: it holds or carries a gun it can fire, and may use guns. /proc/pirate_crew_human_gunner(mob/living/carbon/human/npc) if(HAS_TRAIT(npc, TRAIT_NOGUNS)) From d9e0ae1140def24d66b78c071c1ac065286959c1 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:57:46 -0800 Subject: [PATCH 054/472] Hulk charge and nightmare lunge hit a body zone through armour and can wound; the horror's absorb drains the body the tg way --- .../player_outposts/outpost_prison_creatures.dm | 16 ++++++++++++++-- .../player_outposts/outpost_prison_horror.dm | 16 ++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/voidcrew/modules/player_outposts/outpost_prison_creatures.dm b/voidcrew/modules/player_outposts/outpost_prison_creatures.dm index 1a127916052..9101a7b95c2 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_creatures.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_creatures.dm @@ -86,6 +86,18 @@ subject.manual_emote(pick("flinches away from the light.", "whispers to nobody.", "shades [subject.p_their()] eyes.", "stands very still in the dark.")) playsound(subject, pick('sound/effects/hallucinations/i_see_you1.ogg', 'sound/effects/hallucinations/behind_you1.ogg'), 20, TRUE) +/** + * One of a creature's ability hits on `victim` (the hulk's charge, the nightmare's lunge out of the dark): + * at a body zone, through the armour on it, and able to wound like any other blow of its kind. A blunt hit + * when `sharpness` is NONE. Returns the damage dealt. + */ +/proc/outpost_experiment_ability_hit(mob/living/victim, damage, sharpness = NONE, armour_penetration = 0, zone, atom/attacker) + if(QDELETED(victim)) + return 0 + zone ||= victim.get_random_valid_zone(BODY_ZONE_CHEST) + var/blocked = victim.run_armor_check(zone, MELEE, armour_penetration = armour_penetration) + return victim.apply_damage(damage, BRUTE, zone, blocked, sharpness = sharpness, attack_direction = attacker ? get_dir(attacker, victim) : null) + /// Takes the tells off a subject the serum never finished with /proc/outpost_experiment_clear_tells(mob/living/basic/outpost_prisoner/subject) if(QDELETED(subject)) @@ -712,7 +724,7 @@ return if(isliving(target)) var/mob/living/victim = target - victim.apply_damage(damage_dealt, BRUTE, wound_bonus = CANT_WOUND) + outpost_experiment_ability_hit(victim, damage_dealt, attacker = source) playsound(get_turf(victim), 'sound/effects/meteorimpact.ogg', 80, TRUE) return ..() hulk?.visible_message(span_danger("[hulk] slams into [target] and reels!")) @@ -1238,7 +1250,7 @@ do_attack_animation(target, ATTACK_EFFECT_SLASH) playsound(target, 'sound/items/weapons/bladeslice.ogg', 60, TRUE) target.visible_message(span_danger("[src] lunges out of the dark at [target]!"), span_userdanger("[src] lunges out of the dark at you!")) - target.apply_damage(OUTPOST_NIGHTMARE_JAUNT_DAMAGE, BRUTE, wound_bonus = CANT_WOUND) + outpost_experiment_ability_hit(target, OUTPOST_NIGHTMARE_JAUNT_DAMAGE, SHARP_EDGED, OUTPOST_NIGHTMARE_ARMOUR_PENETRATION, attacker = src) target.Knockdown(OUTPOST_NIGHTMARE_JAUNT_KNOCKDOWN) return TRUE diff --git a/voidcrew/modules/player_outposts/outpost_prison_horror.dm b/voidcrew/modules/player_outposts/outpost_prison_horror.dm index 9f977ec2bcc..e8734da7b77 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_horror.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_horror.dm @@ -836,16 +836,20 @@ set_busy("digest", OUTPOST_HORROR_DIGEST_TIME) return victim -/// Husked and dead +/// Dead and husked the way tg's changeling absorb leaves its victims: death, then tg's Drain() on a body /mob/living/basic/outpost_experiment/horror/proc/husk(mob/living/victim) victim.remove_atom_colour(TEMPORARY_COLOUR_PRIORITY) - victim.become_husk(CHANGELING_DRAIN) - if(!ishuman(victim)) - // A basic mob has no husk sprite: grey and dried out - victim.add_atom_colour(list(0.35,0.35,0.35,0, 0.35,0.35,0.35,0, 0.35,0.35,0.35,0, 0,0,0,1, -0.05,-0.05,-0.05,0), FIXED_COLOUR_PRIORITY) - victim.blood_volume = 0 if(victim.stat != DEAD) + victim.investigate_log("has died from being absorbed by an outpost prison horror.", INVESTIGATE_DEATHS) victim.death() + if(iscarbon(victim)) + var/mob/living/carbon/body = victim + body.Drain() + return + // A basic mob has no husk sprite: grey and dried out + victim.become_husk(CHANGELING_DRAIN) + victim.add_atom_colour(list(0.35,0.35,0.35,0, 0.35,0.35,0.35,0, 0.35,0.35,0.35,0, 0,0,0,1, -0.05,-0.05,-0.05,0), FIXED_COLOUR_PRIORITY) + victim.blood_volume = 0 /// Admins hear about every non-member it absorbs; the game log gets every absorb /mob/living/basic/outpost_experiment/horror/proc/log_absorb(mob/living/victim) From 892d0f7222c034ce12d94d54faf3a7b1adf11bc6 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:07:44 -0800 Subject: [PATCH 055/472] Make prison inmates, guards and Kessler staff real human NPCs Prisoners, guards and Kessler Biolabs staff are /mob/living/carbon/human/npc bodies now. Prisoners wear their real prison jumpsuit (grime on the suit itself), carry things in real hands, take tg's handcuffs (members only, troublemakers pull away), go down through tg crit, stamina crit and knockdowns, fight with real punches and shivs, breathe real air, and can be revived. Guards carry a real stun baton, vest and helmet, can be hurt and killed, and are medevaced or collected by the corrections service. Kessler staff are real bodies that leave when hurt, and agents fire a real chloral hydrate dart. No mercy caps, damage floors, godmode, regeneration, immunity or projectile pass-through. --- tgstation.dme | 2 + voidcrew/_DEFINES/outpost_prison_bounty.dm | 16 +- .../_DEFINES/outpost_prison_conditions.dm | 3 - .../_DEFINES/outpost_prison_experiments.dm | 5 + voidcrew/_DEFINES/outpost_prison_guards.dm | 20 +- voidcrew/_DEFINES/outpost_prison_needs.dm | 4 +- voidcrew/_DEFINES/outpost_prison_trouble.dm | 30 +- voidcrew/_HELPERS/hostile_creatures.dm | 2 +- voidcrew/modules/bounties/bounty_admin.dm | 2 +- .../modules/npc_ships/code/outpost_patrol.dm | 2 +- .../player_outposts/outpost_admin_prison.dm | 40 +- .../outpost_prison_ambience.dm | 42 +- .../outpost_prison_boss_moves.dm | 146 +++--- .../player_outposts/outpost_prison_bounty.dm | 107 ++-- .../player_outposts/outpost_prison_breach.dm | 189 +++++++ .../outpost_prison_breakout.dm | 20 +- .../player_outposts/outpost_prison_capture.dm | 229 +++----- .../outpost_prison_changeling.dm | 40 +- .../outpost_prison_conditions.dm | 32 +- .../outpost_prison_containment.dm | 49 +- .../outpost_prison_contraband.dm | 96 ++-- .../player_outposts/outpost_prison_core.dm | 76 +-- .../outpost_prison_creatures.dm | 28 +- .../outpost_prison_dialogue.dm | 46 +- .../player_outposts/outpost_prison_doors.dm | 2 +- .../player_outposts/outpost_prison_economy.dm | 34 +- .../outpost_prison_experiments.dm | 42 +- .../player_outposts/outpost_prison_extras.dm | 22 +- .../outpost_prison_fixtures.dm | 2 +- .../outpost_prison_guard_routine.dm | 190 +++++-- .../player_outposts/outpost_prison_guards.dm | 487 ++++++++++-------- .../player_outposts/outpost_prison_horror.dm | 10 +- .../outpost_prison_incidents.dm | 84 +-- .../player_outposts/outpost_prison_leads.dm | 52 +- .../player_outposts/outpost_prison_life.dm | 110 ++-- .../player_outposts/outpost_prison_mail.dm | 56 +- .../outpost_prison_npc_bodies.dm | 67 +++ .../player_outposts/outpost_prison_panic.dm | 60 +-- .../outpost_prison_pastimes.dm | 130 ++--- .../outpost_prison_prisoner.dm | 455 ++++++++-------- .../outpost_prison_researcher.dm | 233 ++++++--- .../player_outposts/outpost_prison_riot.dm | 196 ++++--- .../player_outposts/outpost_prison_routine.dm | 66 +-- .../outpost_prison_security.dm | 84 ++- .../player_outposts/outpost_prison_serum.dm | 8 +- .../player_outposts/outpost_prison_social.dm | 34 +- .../player_outposts/outpost_prison_trouble.dm | 397 +++++++------- .../player_outposts/outpost_prison_warden.dm | 6 +- .../outpost_prison_warden_tools.dm | 95 ++-- .../outpost_prison_wing_events.dm | 4 +- .../unit_tests/voidcrew_bounty_admin.dm | 2 +- .../unit_tests/voidcrew_bounty_seams.dm | 4 +- .../unit_tests/voidcrew_npc_zone_profiles.dm | 2 +- .../unit_tests/voidcrew_outpost_patrol.dm | 10 +- .../voidcrew_outpost_prison_admin.dm | 48 +- .../voidcrew_outpost_prison_bounty.dm | 84 +-- .../voidcrew_outpost_prison_breakout.dm | 22 +- .../voidcrew_outpost_prison_built_turrets.dm | 36 +- .../voidcrew_outpost_prison_capture.dm | 50 +- .../voidcrew_outpost_prison_changeling.dm | 22 +- .../voidcrew_outpost_prison_conditions.dm | 8 +- .../voidcrew_outpost_prison_containment.dm | 28 +- .../voidcrew_outpost_prison_contraband.dm | 36 +- .../voidcrew_outpost_prison_dialogue.dm | 10 +- .../voidcrew_outpost_prison_economy.dm | 56 +- .../voidcrew_outpost_prison_events.dm | 28 +- .../voidcrew_outpost_prison_experiments.dm | 70 +-- .../voidcrew_outpost_prison_extension.dm | 12 +- .../voidcrew_outpost_prison_extras.dm | 2 +- .../voidcrew_outpost_prison_guards.dm | 62 +-- .../voidcrew_outpost_prison_helpers.dm | 12 +- .../voidcrew_outpost_prison_leads.dm | 24 +- .../voidcrew_outpost_prison_life.dm | 68 +-- .../voidcrew_outpost_prison_needs.dm | 52 +- .../voidcrew_outpost_prison_panic.dm | 16 +- .../voidcrew_outpost_prison_security.dm | 8 +- .../voidcrew_outpost_prison_social.dm | 56 +- .../voidcrew_outpost_prison_staff.dm | 2 +- .../voidcrew_outpost_prison_trouble.dm | 128 ++--- 79 files changed, 2689 insertions(+), 2421 deletions(-) create mode 100644 voidcrew/modules/player_outposts/outpost_prison_breach.dm create mode 100644 voidcrew/modules/player_outposts/outpost_prison_npc_bodies.dm diff --git a/tgstation.dme b/tgstation.dme index 96282a67feb..b69570843fe 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7537,6 +7537,7 @@ #include "voidcrew\modules\player_outposts\outpost_prison_ambience.dm" #include "voidcrew\modules\player_outposts\outpost_prison_boss_moves.dm" #include "voidcrew\modules\player_outposts\outpost_prison_bounty.dm" +#include "voidcrew\modules\player_outposts\outpost_prison_breach.dm" #include "voidcrew\modules\player_outposts\outpost_prison_breakout.dm" #include "voidcrew\modules\player_outposts\outpost_prison_capture.dm" #include "voidcrew\modules\player_outposts\outpost_prison_changeling.dm" @@ -7559,6 +7560,7 @@ #include "voidcrew\modules\player_outposts\outpost_prison_leads.dm" #include "voidcrew\modules\player_outposts\outpost_prison_life.dm" #include "voidcrew\modules\player_outposts\outpost_prison_mail.dm" +#include "voidcrew\modules\player_outposts\outpost_prison_npc_bodies.dm" #include "voidcrew\modules\player_outposts\outpost_prison_panic.dm" #include "voidcrew\modules\player_outposts\outpost_prison_pastimes.dm" #include "voidcrew\modules\player_outposts\outpost_prison_prisoner.dm" diff --git a/voidcrew/_DEFINES/outpost_prison_bounty.dm b/voidcrew/_DEFINES/outpost_prison_bounty.dm index 96787c78a25..5530195872f 100644 --- a/voidcrew/_DEFINES/outpost_prison_bounty.dm +++ b/voidcrew/_DEFINES/outpost_prison_bounty.dm @@ -43,23 +43,15 @@ // ===== BODY ===== -/// A bounty prisoner's health, by tier. The stamina pool follows it. -#define BOUNTY_PRISONER_HEALTH_PETTY 100 -#define BOUNTY_PRISONER_HEALTH_WANTED 120 -#define BOUNTY_PRISONER_HEALTH_MOST_WANTED 150 +/// How much of a blow a bounty prisoner takes, by tier (human NPC npc_damage_taken, through physiology; defaults PR-16) +#define BOUNTY_PRISONER_DAMAGE_TAKEN_PETTY 1 +#define BOUNTY_PRISONER_DAMAGE_TAKEN_WANTED 0.9 +#define BOUNTY_PRISONER_DAMAGE_TAKEN_MOST_WANTED 0.8 /// A bounty prisoner arrives hurt as they were caught, but with at least this percent of their health (spec 12.3 C11): downed deliveries otherwise crash wings. #define BOUNTY_PRISONER_MIN_ARRIVAL_HEALTH 50 // ===== DANGER, BY TIER ===== -/// Multiplier on their punches -#define BOUNTY_PRISON_PUNCH_MULT_PETTY 1 -#define BOUNTY_PRISON_PUNCH_MULT_WANTED 1.25 -#define BOUNTY_PRISON_PUNCH_MULT_MOST_WANTED 1.5 -/// Multiplier on their shiv blows -#define BOUNTY_PRISON_SHIV_MULT_PETTY 1 -#define BOUNTY_PRISON_SHIV_MULT_WANTED 1.15 -#define BOUNTY_PRISON_SHIV_MULT_MOST_WANTED 1.3 /// Multiplier on their mood losses #define BOUNTY_PRISON_MOOD_SCALE_PETTY 1 #define BOUNTY_PRISON_MOOD_SCALE_WANTED 1.1 diff --git a/voidcrew/_DEFINES/outpost_prison_conditions.dm b/voidcrew/_DEFINES/outpost_prison_conditions.dm index c60ed91ded9..257ee6f7797 100644 --- a/voidcrew/_DEFINES/outpost_prison_conditions.dm +++ b/voidcrew/_DEFINES/outpost_prison_conditions.dm @@ -66,9 +66,6 @@ #define PRISON_RAT_AFTER (3 MINUTES) #define PRISON_RAT_CHANCE 20 #define PRISON_RAT_MAX 2 -/// Brute per 5 seconds on unsafe air, never below PRISON_AIR_HEALTH_FLOOR percent health -#define PRISON_AIR_DAMAGE 1 -#define PRISON_AIR_HEALTH_FLOOR 30 /// Unsafe air: pressure (kPa), temperature (K) and plasma (mol) limits #define PRISON_AIR_PRESSURE_MIN 60 #define PRISON_AIR_PRESSURE_MAX 150 diff --git a/voidcrew/_DEFINES/outpost_prison_experiments.dm b/voidcrew/_DEFINES/outpost_prison_experiments.dm index 44cc40a73c0..6671660d19e 100644 --- a/voidcrew/_DEFINES/outpost_prison_experiments.dm +++ b/voidcrew/_DEFINES/outpost_prison_experiments.dm @@ -236,5 +236,10 @@ #define OUTPOST_KESSLER_TEAM_TIME (5 SECONDS) /// How long a Kessler beam takes, in and out #define OUTPOST_KESSLER_BEAM_TIME (3 SECONDS) +/// From a real hit on Kessler staff to Kessler's beam taking them out ("I'm leaving!") +#define OUTPOST_KESSLER_ASSAULT_BEAM_DELAY (2 SECONDS) +/// Seconds before Kessler sends anyone again after its researcher was killed on a visit +#define OUTPOST_KESSLER_KILLED_WAIT_MIN (90 * 60) +#define OUTPOST_KESSLER_KILLED_WAIT_MAX (120 * 60) /// The creatures' sentience type: it matches no potion, lazarus injector or ghost role (the spec's SENTIENCE_NONE) #define OUTPOST_EXPERIMENT_NO_SENTIENCE 0 diff --git a/voidcrew/_DEFINES/outpost_prison_guards.dm b/voidcrew/_DEFINES/outpost_prison_guards.dm index e5018024dee..d3066f6d9ed 100644 --- a/voidcrew/_DEFINES/outpost_prison_guards.dm +++ b/voidcrew/_DEFINES/outpost_prison_guards.dm @@ -10,15 +10,16 @@ #define OUTPOST_GUARD_WAGE 2 /// Wages skipped in a row (the treasury could not cover them) before the guards walk off #define OUTPOST_GUARD_UNPAID_LEAVE 2 -#define OUTPOST_GUARD_HEALTH 120 -/// Health at which a guard goes down (godmode, lying) instead of taking more damage -#define OUTPOST_GUARD_DOWN_AT 30 -/// From going down to beaming out +/// From going into crit to the corrections service's medevac of the body #define OUTPOST_GUARD_RECALL_DELAY (10 SECONDS) +/// From a guard's death to the corrections service collecting the body +#define OUTPOST_GUARD_BODY_COLLECT_DELAY (60 SECONDS) +/// A guard whose baton went flat is back with a charged one after this +#define OUTPOST_GUARD_SHIFT_SWAP_TIME (1 MINUTES) +/// A guard with this much brute and burn damage or more patches up from the office first-aid kit when nothing is going on +#define OUTPOST_GUARD_TREAT_ABOVE 20 /// A downed or recalled guard comes back free after this, if still hired and a member is home #define OUTPOST_GUARD_RETURN_TIME (10 MINUTES) -/// Stamina damage per baton strike: prisoners crit at 100, so three strikes -#define OUTPOST_GUARD_BATON_STAMINA 35 #define OUTPOST_GUARD_BATON_COOLDOWN (2 SECONDS) /// Percent chance a guard arriving at an argument ends it with words #define OUTPOST_GUARD_TALKDOWN_CHANCE 60 @@ -56,11 +57,6 @@ #define OUTPOST_GUARD_SPAT_VIEW 7 /// How close to a member in the cell block a guard stays during a riot #define OUTPOST_GUARD_FOLLOW_RANGE 2 -/// Health a guard gets back per minute while not in a riot and not down -#define OUTPOST_GUARD_HEAL_PER_MINUTE 12 -/// Movement: an unhurried walk on routine, a hurry on a response (basic mob speed) -#define OUTPOST_GUARD_SPEED_WALK 2 -#define OUTPOST_GUARD_SPEED_HURRY 1 /// How long a prisoner is left alone after a guard checked on them or they greeted a guard #define OUTPOST_GUARD_CHECK_ON_GAP (5 MINUTES) @@ -97,5 +93,3 @@ /// Trait source for the beam holding a guard still #define OUTPOST_GUARD_BEAM_TRAIT "outpost_guard_beam" -/// Trait source for a guard lying down, out of it -#define OUTPOST_GUARD_DOWN_TRAIT "outpost_guard_down" diff --git a/voidcrew/_DEFINES/outpost_prison_needs.dm b/voidcrew/_DEFINES/outpost_prison_needs.dm index a75776cdf87..a9d328775b0 100644 --- a/voidcrew/_DEFINES/outpost_prison_needs.dm +++ b/voidcrew/_DEFINES/outpost_prison_needs.dm @@ -37,7 +37,7 @@ #define PRISONER_MOOD_CLEAN_UNIFORM 8 #define PRISONER_MOOD_TREATED 8 -// Where a prisoner is in their stay (/mob/living/basic/outpost_prisoner/var/phase) +// Where a prisoner is in their stay (/mob/living/carbon/human/npc/outpost_prisoner/var/phase) /// Being beamed into their cell #define PRISONER_ARRIVING "arriving" /// In the wing, serving their sentence @@ -45,7 +45,7 @@ /// Being beamed out, released or collected #define PRISONER_LEAVING "leaving" -// What /mob/living/basic/outpost_prisoner/proc/try_reach() found +// What /mob/living/carbon/human/npc/outpost_prisoner/proc/try_reach() found #define PRISONER_REACH_FAILED 0 #define PRISONER_REACH_OK 1 /// Their side of a serving hatch is opening diff --git a/voidcrew/_DEFINES/outpost_prison_trouble.dm b/voidcrew/_DEFINES/outpost_prison_trouble.dm index f8a4942d809..6e2a9d4b51d 100644 --- a/voidcrew/_DEFINES/outpost_prison_trouble.dm +++ b/voidcrew/_DEFINES/outpost_prison_trouble.dm @@ -77,22 +77,6 @@ #define PRISONER_THREAT_COOLDOWN 20 /// Seconds a decided swing waits for them to close in before it is dropped #define PRISONER_SWING_TIMEOUT 6 -/// Brute damage of a punch at staff: blunt, and it never wounds -#define PRISONER_PUNCH_MIN 5 -#define PRISONER_PUNCH_MAX 8 -/// Brute damage of a shiv at staff or anyone else who is not a prisoner (tg's glass shiv is force 8). -/// Each blow is a stab or a slash, so it can leave a puncture or a cut that bleeds. -#define PRISONER_SHIV_MIN 10 -#define PRISONER_SHIV_MAX 15 -/// The shiv's wounding, as tg's glass shiv has it in someone's hand. Four blows in five land on the -/// chest, which resists wounds; the rest on an arm, a leg or now and then the head (tg's -/// attack_zone_randomiser). On a bare body about two blows in five wound. An armour vest takes a -/// third off chest blows and stops nearly every wound there, leaving about one in five. -#define PRISONER_SHIV_WOUND_BONUS 5 -#define PRISONER_SHIV_EXPOSED_WOUND_BONUS 15 -/// Brute damage of a shiv at another prisoner (a stabbing, outpost_prison_incidents.dm), never a killing blow -#define PRISONER_STAB_MIN 7 -#define PRISONER_STAB_MAX 10 // A player's blow on a prisoner on their feet: they hit back, or back off /// Seconds they go after the attacker before letting it drop @@ -139,10 +123,7 @@ #define PRISONER_FIGHT_COOLDOWN 300 /// Seconds between any two fights in the wing #define PRISON_FIGHT_GAP 180 -/// Brute of a blow in a fight between prisoners -#define PRISONER_FIGHT_HIT_MIN 3 -#define PRISONER_FIGHT_HIT_MAX 6 -/// Health percent at which a fighter yields, and seconds they stay down +/// Health percent at which a losing fighter gives up, and seconds they then stay lying down by choice #define PRISONER_FIGHT_YIELD 40 #define PRISONER_FIGHT_YIELD_TIME 20 /// Below this mood two prisoners near each other may argue without blows, at most once per PRISON_SPAT_GAP in the wing @@ -152,11 +133,8 @@ #define PRISON_SPAT_CHANCE 8 // Beaten down -/// At or below this health percent a prisoner collapses -#define PRISONER_BEATEN_BELOW 15 -/// Seconds they lie beaten, unless treated above PRISONER_RECOVER_ABOVE percent first -#define PRISONER_BEATEN_TIME 120 -#define PRISONER_RECOVER_ABOVE 40 +/// How long something that slams a prisoner on their feet to the floor knocks them down (collapse(), a real tg Knockdown) +#define PRISONER_COLLAPSE_KNOCKDOWN (4 SECONDS) // Riots /// Below these moods each personality joins a riot; if nobody does, the unhappiest starts it alone. @@ -252,7 +230,7 @@ /// Seconds out of a cell, on their feet and uncuffed, before a prisoner who owes lockdown riots again #define PRISON_LOCKDOWN_GRACE 30 -// What trouble a prisoner is in (/mob/living/basic/outpost_prisoner/var/trouble); null is none +// What trouble a prisoner is in (/mob/living/carbon/human/npc/outpost_prisoner/var/trouble); null is none #define PRISONER_TROUBLE_FIGHT "fight" #define PRISONER_TROUBLE_RIOT "riot" /// Rioting after the riot turned into a breakout: going for the exits diff --git a/voidcrew/_HELPERS/hostile_creatures.dm b/voidcrew/_HELPERS/hostile_creatures.dm index 22dc386a234..f4197e0fe29 100644 --- a/voidcrew/_HELPERS/hostile_creatures.dm +++ b/voidcrew/_HELPERS/hostile_creatures.dm @@ -67,7 +67,7 @@ GLOBAL_LIST_INIT(turret_retaliating_subtrees, typecacheof(list( /proc/is_hostile_creature(mob/living/creature) // Outpost prisoners (outpost_prison_*.dm): left alone inside their wing, rioting or not; // once loose outside it they are fair game. Turrets players build use outpost_prison_security.dm instead. - if(istype(creature, /mob/living/basic/outpost_prisoner)) + if(istype(creature, /mob/living/carbon/human/npc/outpost_prisoner)) return is_loose_outpost_prisoner(creature) // Prison experiment creatures (outpost_prison_creatures.dm): fair game anywhere, like the // headslug, whose stock AI already reads as hostile. Their custom AI does not. diff --git a/voidcrew/modules/bounties/bounty_admin.dm b/voidcrew/modules/bounties/bounty_admin.dm index 721a17dfc7e..ecdef280304 100644 --- a/voidcrew/modules/bounties/bounty_admin.dm +++ b/voidcrew/modules/bounties/bounty_admin.dm @@ -1164,7 +1164,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a return refuse(user, "That prison is gone.") if(!prison.free_slots()) return refuse(user, "[prison_name(prison)] has no free cell.") - var/mob/living/basic/outpost_prisoner/prisoner = prison.bounty_admit_record(record) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = prison.bounty_admit_record(record) if(QDELETED(prisoner)) return refuse(user, "[prison_name(prison)] took nobody: no cell there has room for an arrival.") log_action(user, "admit bounty record [record.name] ([record_kind(record)]) into cell [prisoner.cell?.number] of [prison_name(prison)]") diff --git a/voidcrew/modules/npc_ships/code/outpost_patrol.dm b/voidcrew/modules/npc_ships/code/outpost_patrol.dm index c6f20899a9a..c2a1ba8d45c 100644 --- a/voidcrew/modules/npc_ships/code/outpost_patrol.dm +++ b/voidcrew/modules/npc_ships/code/outpost_patrol.dm @@ -185,7 +185,7 @@ GLOBAL_LIST_EMPTY(outpost_patrol_caches) /// Whether a breakout controller leaves `target` alone: prisoners, and anyone else breaking out /proc/is_outpost_breakout_ally(mob/living/target) - if(istype(target, /mob/living/basic/outpost_prisoner)) + if(istype(target, /mob/living/carbon/human/npc/outpost_prisoner)) return TRUE return istype(target?.ai_controller, /datum/ai_controller/basic_controller/outpost_breakout) diff --git a/voidcrew/modules/player_outposts/outpost_admin_prison.dm b/voidcrew/modules/player_outposts/outpost_admin_prison.dm index 11ebecc5d59..b2f183b618e 100644 --- a/voidcrew/modules/player_outposts/outpost_admin_prison.dm +++ b/voidcrew/modules/player_outposts/outpost_admin_prison.dm @@ -94,7 +94,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T record(user, home, "[open ? "open" : "close"] prison intake") if("prison_spawn") // Forced: an admin spawn ignores the cell's ready time and a running experiment - var/mob/living/basic/outpost_prisoner/arrival = prison.admit_next(TRUE) + var/mob/living/carbon/human/npc/outpost_prisoner/arrival = prison.admit_next(TRUE) if(!arrival) error = "No free cell." return @@ -108,7 +108,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return record(user, home, "fill [count] prison cell\s") if("prison_release", "prison_kill", "prison_remove", "prison_set", "prison_fight", "prison_breakout", "prison_wreck") - var/mob/living/basic/outpost_prisoner/prisoner = locate(params["ref"]) in prison.prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = locate(params["ref"]) in prison.prisoners if(QDELETED(prisoner)) error = "That prisoner is gone." return @@ -126,7 +126,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T if(!prisoner.can_join_riot() || prisoner.trouble) error = "That prisoner can't fight now." return - var/mob/living/basic/outpost_prisoner/partner = prison.nearest_fight_partner(prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/partner = prison.nearest_fight_partner(prisoner) if(!partner) error = "No other prisoner can fight." return @@ -284,7 +284,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T if(!istext(form) || !(form in list("hulk", "fly", "nightmare", "changeling"))) error = "Invalid experiment form." return - var/mob/living/basic/outpost_prisoner/subject = locate(params["ref"]) in prison.prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/subject = locate(params["ref"]) in prison.prisoners if(QDELETED(subject)) error = "That prisoner is gone." return @@ -418,7 +418,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T for(var/datum/outpost_prison_cell/cell as anything in cells) cell_rows += list(list("number" = cell.number, "occupant_ref" = cell.occupant ? REF(cell.occupant) : null)) var/list/prisoner_rows = list() - for(var/mob/living/basic/outpost_prisoner/prisoner as anything in roster_order()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner as anything in roster_order()) prisoner_rows += list(list( "ref" = REF(prisoner), "name" = prisoner.real_name, @@ -440,7 +440,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T "mood" = round(prisoner.mood), "state" = prisoner.trouble_state(), "loose_left" = prisoner.loose_seconds_shown(), - "cuffed" = !!prisoner.cuffs, + "cuffed" = !!prisoner.handcuffed, "lockdown_left" = max(0, round(prisoner.lockdown_left)), )) var/list/trouble_block = trouble_payload() @@ -519,11 +519,11 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return null /// Where their care falls between no pay and full pay, 0 to 1 (section 1's G(care)) -/mob/living/basic/outpost_prisoner/proc/admin_care_grade() +/mob/living/carbon/human/npc/outpost_prisoner/proc/admin_care_grade() return clamp((care() - OUTPOST_PRISON_GRADE_FLOOR) / (OUTPOST_PRISON_GRADE_FULL - OUTPOST_PRISON_GRADE_FLOOR), 0, 1) /// What they are up to, in a few words -/mob/living/basic/outpost_prisoner/proc/admin_activity_text() +/mob/living/carbon/human/npc/outpost_prisoner/proc/admin_activity_text() if(stat == DEAD) return "dead" if(phase == PRISONER_ARRIVING) @@ -532,9 +532,9 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return "beaming out" if(pulledby) return "being dragged" - if(beaten_left > 0) - return "beaten ([round(beaten_left)] s)" - if(cuffs) + if(beaten) + return HAS_TRAIT(src, TRAIT_CRITICAL_CONDITION) ? "beaten, in crit" : "beaten" + if(handcuffed) if(trouble == PRISONER_TROUBLE_LOOSE) return "cuffed, loose" return is_rioting() ? "cuffed, rioting" : "cuffed" @@ -559,7 +559,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return text /// Sets one of a prisoner's values. Returns FALSE if it can't take it. -/datum/outpost_prison/proc/admin_set(mob/living/basic/outpost_prisoner/prisoner, field, value) +/datum/outpost_prison/proc/admin_set(mob/living/carbon/human/npc/outpost_prisoner/prisoner, field, value) switch(field) if("hunger") prisoner.set_hunger(clamp(value, 0, 100)) @@ -598,7 +598,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T /// Applies one change to every living prisoner. Returns how many. /datum/outpost_prison/proc/admin_all(what) var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat == DEAD || prisoner.phase == PRISONER_LEAVING) continue count++ @@ -636,7 +636,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T end_fight(brawl) if(riot_active) end_riot() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat == DEAD || prisoner.phase == PRISONER_LEAVING) continue prisoner.cancel_threat() @@ -651,10 +651,10 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return count /// The nearest other prisoner who could fight, for the admin fight button -/datum/outpost_prison/proc/nearest_fight_partner(mob/living/basic/outpost_prisoner/prisoner) - var/mob/living/basic/outpost_prisoner/nearest +/datum/outpost_prison/proc/nearest_fight_partner(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/nearest var/nearest_distance = INFINITY - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other == prisoner || !other.can_join_riot() || other.trouble) continue var/distance = get_dist(prisoner, other) @@ -664,10 +664,10 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return nearest /// What the admin panel shows as their state -/mob/living/basic/outpost_prisoner/proc/trouble_state() +/mob/living/carbon/human/npc/outpost_prisoner/proc/trouble_state() if(trouble == PRISONER_TROUBLE_LOOSE) return "loose" - if(beaten_left > 0) + if(beaten) return "beaten" // is_rioting() covers a breakout too; the panel tells them apart, as admin_activity_text() does if(trouble == PRISONER_TROUBLE_BREAKOUT) @@ -680,7 +680,7 @@ GLOBAL_LIST_INIT(outpost_admin_prison_crew_modes, list("auto" = null, "home" = T return trouble || "normal" /// Seconds left loose, shown only while they are out of the cell block -/mob/living/basic/outpost_prisoner/proc/loose_seconds_shown() +/mob/living/carbon/human/npc/outpost_prisoner/proc/loose_seconds_shown() if(trouble != PRISONER_TROUBLE_LOOSE || stat == DEAD || prison?.in_cell_block(src)) return null return max(0, round(loose_left)) diff --git a/voidcrew/modules/player_outposts/outpost_prison_ambience.dm b/voidcrew/modules/player_outposts/outpost_prison_ambience.dm index b85a6cd7f75..d6756ea9640 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_ambience.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_ambience.dm @@ -23,7 +23,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", // ===== THE PRISONER ===== -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Picks their examine line within a mood band, so looking twice reads the same var/ambience_examine_seed = 0 /// Running after they turned to look at staff coming in, after humming, and after a whisper in a huddle @@ -32,14 +32,14 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", COOLDOWN_DECLARE(ambience_huddle_cooldown) /// Hooks up the prisoner's side of the ambience; called from setup_extras(). Nothing needs hooking: the prison drives it from its tick, and examine is the override below. -/mob/living/basic/outpost_prisoner/proc/setup_ambience() +/mob/living/carbon/human/npc/outpost_prisoner/proc/setup_ambience() return /** * Their mood in words, by band and personality, and one line on how they are kept when something * shows (hurt, a dirty jumpsuit, shut in, due out), then every package's own lines. Never a number. */ -/mob/living/basic/outpost_prisoner/examine(mob/user) +/mob/living/carbon/human/npc/outpost_prisoner/examine(mob/user) . = ..() if(stat == DEAD || phase != PRISONER_PRESENT) return @@ -54,7 +54,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", . += span_notice(line) /// A closer look: what they are in for, their cell, roughly how long they have left, and their birthday until the yard has had the cake -/mob/living/basic/outpost_prisoner/examine_more(mob/user) +/mob/living/carbon/human/npc/outpost_prisoner/examine_more(mob/user) . = ..() if(stat == DEAD || !prison) return @@ -69,14 +69,14 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", . += span_notice("It's [p_their()] birthday today.") /// "a few minutes" or "about 25 minutes": their sentence left, rounded to OUTPOST_PRISONER_EXAMINE_ROUND_MINUTES -/mob/living/basic/outpost_prisoner/proc/sentence_examine_text() +/mob/living/carbon/human/npc/outpost_prisoner/proc/sentence_examine_text() var/minutes = sentence_left / 60 if(minutes < OUTPOST_PRISONER_EXAMINE_ROUND_MINUTES) return "a few minutes" return "about [round(minutes, OUTPOST_PRISONER_EXAMINE_ROUND_MINUTES)] minutes" /// The "examine" key for their mood: the bands match the angry, threat, climb and tidy lines -/mob/living/basic/outpost_prisoner/proc/mood_examine_band() +/mob/living/carbon/human/npc/outpost_prisoner/proc/mood_examine_band() if(mood < PRISONER_THREAT_ANGRY_MOOD) return "mood_0" if(mood < PRISONER_THREAT_MOOD) @@ -88,7 +88,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return "mood_75" /// A line on their mood, filled in, or null. Their own personality's lines come up more often than the shared ones. -/mob/living/basic/outpost_prisoner/proc/mood_examine_line() +/mob/living/carbon/human/npc/outpost_prisoner/proc/mood_examine_line() var/list/bands = outpost_prisoner_extra_dialogue("outpost_prison_security.json", "examine") var/list/band = bands[mood_examine_band()] if(!islist(band)) @@ -105,7 +105,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return fill_examine_line(pool[(ambience_examine_seed % length(pool)) + 1]) /// Fills an examine line's pronouns ({They}, {their}, ...) and verb endings ({s}, {es}) for them -/mob/living/basic/outpost_prisoner/proc/fill_examine_line(line) +/mob/living/carbon/human/npc/outpost_prisoner/proc/fill_examine_line(line) if(!istext(line)) return null line = replacetextEx(line, "{They}", p_They()) @@ -118,7 +118,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return line /// One line on how they are kept, the most pressing first, or null when nothing shows -/mob/living/basic/outpost_prisoner/proc/state_examine_line() +/mob/living/carbon/human/npc/outpost_prisoner/proc/state_examine_line() var/health_percent = health_factor() if(health_percent < PRISONER_BLEED_BELOW) return "[p_They()] look[p_s()] badly hurt." @@ -190,7 +190,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", /// Whether another prisoner is too close to `tile` to sulk there /datum/prisoner_activity/sulk/proc/sulk_company_near(turf/tile) - for(var/mob/living/basic/outpost_prisoner/other in prisoner.prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoner.prison.prisoners) if(other != prisoner && get_dist(other, tile) <= OUTPOST_PRISONER_SULK_ALONE_RANGE) return TRUE return FALSE @@ -242,7 +242,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", * wing); TRUE drops it. Small talk, remarks to staff, conversations and chatter about what they * are doing go quiet; complaints and threats about the wing still get said. */ -/datum/outpost_prison/proc/speech_hushed(mob/living/basic/outpost_prisoner/speaker, context) +/datum/outpost_prison/proc/speech_hushed(mob/living/carbon/human/npc/outpost_prisoner/speaker, context) if(stage != PRISON_STAGE_RESTLESS) return FALSE var/static/list/always_hushed = list("idle", "staff_near", "conversation") @@ -289,7 +289,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", var/spoken = FALSE var/emotes = 0 var/reacted = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in shuffle(prisoners)) if(!prisoner.ai_running() || !prisoner.routine_allowed() || prisoner.activity?.sleeping) continue if(!COOLDOWN_FINISHED(prisoner, ambience_notice_cooldown)) @@ -322,12 +322,12 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return reacted /// A chat they are in waits a moment while they look over -/datum/outpost_prison/proc/pause_chat_for_notice(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/pause_chat_for_notice(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/prisoner_activity/chat/talk = prisoner.activity if(!istype(talk)) return if(istype(talk, /datum/prisoner_activity/chat/listen)) - var/mob/living/basic/outpost_prisoner/talker = talk.chat_partner() + var/mob/living/carbon/human/npc/outpost_prisoner/talker = talk.chat_partner() talk = talker?.activity if(!istype(talk) || istype(talk, /datum/prisoner_activity/chat/listen)) return @@ -338,7 +338,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", /// Content prisoners now and then hum; while the wing is restless, the ones gathered in the yard whisper among themselves /datum/outpost_prison/proc/yard_sounds_tick(seconds) var/huddling = stage == PRISON_STAGE_RESTLESS && !riot_imminent && !riot_active - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS || !prisoner.ai_running()) continue if(huddling && istype(prisoner.activity, /datum/prisoner_activity/gather)) @@ -352,7 +352,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", * A content cheerful, chatty or quiet prisoner, awake and free, hums to themself, at most once per * OUTPOST_PRISONER_HUM_COOLDOWN. Returns TRUE if they did. */ -/datum/outpost_prison/proc/try_hum(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/try_hum(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/static/list/hummers = list("cheerful", "chatty", "quiet") if(prisoner.mood < OUTPOST_PRISONER_HUM_MOOD || !(prisoner.personality in hummers)) return FALSE @@ -364,7 +364,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return TRUE /// A gathered prisoner whispers a huddle line, at most once per OUTPOST_PRISONER_HUDDLE_GAP. Returns TRUE if they did. -/datum/outpost_prison/proc/try_huddle(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/try_huddle(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!COOLDOWN_FINISHED(prisoner, ambience_huddle_cooldown)) return FALSE var/line = prisoner.pick_line("huddle") @@ -404,7 +404,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", ambience_chant_beats++ var/list/gathered = list() var/list/slammers = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in shuffle(prisoners)) var/datum/prisoner_activity/gather/gathering = prisoner.activity if(!istype(gathering) || !gathering.started || gathering.spot || !prisoner.ai_running() || !prisoner.routine_allowed()) continue @@ -421,12 +421,12 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", if(!(ambience_chant_beats % OUTPOST_PRISON_CHANT_LINE_EVERY)) var/list/voices = length(slammers) ? slammers : gathered if(length(voices)) - var/mob/living/basic/outpost_prisoner/voice = pick(voices) + var/mob/living/carbon/human/npc/outpost_prisoner/voice = pick(voices) voice.say_context("riot_chant") return slammers /// A mess table or a door beside `prisoner` to slam on, or null -/datum/outpost_prison/proc/chant_surface(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/chant_surface(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/turf/here = get_turf(prisoner) for(var/direction in GLOB.cardinals) var/turf/beside = get_step(here, direction) @@ -450,7 +450,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_examine_placeholders, list("{They}", "{they}", return 0 ambience_roared = TRUE var/roared = 0 - for(var/mob/living/basic/outpost_prisoner/rioter in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter in prisoners) if(!rioter.is_rioting() || rioter.stat != CONSCIOUS || rioter.phase != PRISONER_PRESENT || !rioter.ai_running()) continue if(rioter.say_context("riot_roar")) diff --git a/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm b/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm index 3e259912edc..da696e2587e 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm @@ -49,11 +49,11 @@ /// Weakrefs to guards the kingpin paid off, until they are back at work var/list/boss_bribed_guards = list() -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Their riot move, made the first time a riot needs it (boss_move_datum()), or null var/datum/prison_boss_move/boss_move -/mob/living/basic/outpost_prison_guard +/mob/living/carbon/human/npc/outpost_prison_guard /// world.time until which they stay out of a riot, paid off by the kingpin var/prison_bribed_until = 0 @@ -88,7 +88,7 @@ boss_bribes_tick() if(!trouble_enabled || !riot_active) return - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) var/datum/prison_boss_move/move = prisoner.boss_move_datum() if(!move) continue @@ -96,7 +96,7 @@ move.try_start() /// A prisoner is leaving the roster (extras_prisoner_leaving()): their move stops -/datum/outpost_prison/proc/boss_moves_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/boss_moves_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) QDEL_NULL(prisoner.boss_move) /// The prison is being deleted (extras_destroy()) @@ -108,25 +108,25 @@ * cell, past the riot's wind-up, awake and on their feet, not held, climbing or batoned, with a member * of the wing home and someone on the level. Uncuffed, or cuffed if `cuffed`. */ -/datum/outpost_prison/proc/boss_riot_ok(mob/living/basic/outpost_prisoner/prisoner, cuffed = FALSE) +/datum/outpost_prison/proc/boss_riot_ok(mob/living/carbon/human/npc/outpost_prisoner/prisoner, cuffed = FALSE) if(!trouble_enabled || !riot_active || riot_windup_left > 0 || !crew_home()) return FALSE if(QDELETED(prisoner) || prisoner.prison != src || prisoner.stat != CONSCIOUS || !prisoner.riot_at_large()) return FALSE if(!in_cell_block(prisoner) || !isturf(prisoner.loc) || !prisoner.ai_running()) return FALSE - if(prisoner.is_down() || prisoner.pulledby || prisoner.climb_ref || prisoner.beaten_left > 0 || prisoner.surrendered_to_turret() || world.time < prisoner.baton_stop_until) + if(prisoner.is_down() || prisoner.pulledby || prisoner.climb_ref || prisoner.is_beaten() || prisoner.surrendered_to_turret() || world.time < prisoner.baton_stop_until) return FALSE - return cuffed ? !!prisoner.cuffs : !prisoner.cuffs + return cuffed ? !!prisoner.handcuffed : !prisoner.handcuffed /// Whether a move of `prisoner`'s may land on `person`: awake staff the riot goes for, in the cell block where `prisoner` can get at them -/datum/outpost_prison/proc/boss_may_hit(mob/living/basic/outpost_prisoner/prisoner, mob/living/person) +/datum/outpost_prison/proc/boss_may_hit(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/person) if(QDELETED(person) || person == prisoner || !is_outpost_prison_staff(person)) return FALSE return in_cell_block(person) && !!prisoner.reachable?[get_turf(person)] /// The nearest staff a move of `prisoner`'s may land on (boss_may_hit()) within `range` and in sight, or null -/datum/outpost_prison/proc/boss_nearest_staff(mob/living/basic/outpost_prisoner/prisoner, range = PRISON_BOSS_SIGHT) +/datum/outpost_prison/proc/boss_nearest_staff(mob/living/carbon/human/npc/outpost_prisoner/prisoner, range = PRISON_BOSS_SIGHT) var/mob/living/nearest var/nearest_distance = INFINITY for(var/mob/living/person in view(range, prisoner)) @@ -151,7 +151,7 @@ // ===== THE PRISONER'S SIDE ===== /// Their riot move, made the first time it is asked for; null for anyone without one (bounty_boss_move_type()) -/mob/living/basic/outpost_prisoner/proc/boss_move_datum() +/mob/living/carbon/human/npc/outpost_prisoner/proc/boss_move_datum() if(boss_move) return boss_move var/move_type = bounty_boss_move_type(bounty_record) @@ -161,7 +161,7 @@ return boss_move /// Whether they are winding up or in the middle of their move (or reeling from it), so they hold still (trouble_target()) -/mob/living/basic/outpost_prisoner/proc/boss_move_busy() +/mob/living/carbon/human/npc/outpost_prisoner/proc/boss_move_busy() return !!boss_move && boss_move.busy_until > world.time // ===== A MOVE ===== @@ -174,7 +174,7 @@ * call finish_windup(pending_serial) instead of waiting. */ /datum/prison_boss_move - /// The prisoner whose move it is (/mob/living/basic/outpost_prisoner) + /// The prisoner whose move it is (/mob/living/carbon/human/npc/outpost_prisoner) var/datum/weakref/prisoner_ref /// How long it winds up, telegraphing, before anything happens var/windup = 1 SECONDS @@ -196,7 +196,7 @@ /// Weakrefs to the wind-up's marks still showing var/list/telegraphs -/datum/prison_boss_move/New(mob/living/basic/outpost_prisoner/owner) +/datum/prison_boss_move/New(mob/living/carbon/human/npc/outpost_prisoner/owner) . = ..() prisoner_ref = WEAKREF(owner) @@ -207,18 +207,18 @@ /// The prisoner, while they exist /datum/prison_boss_move/proc/owner() - var/mob/living/basic/outpost_prisoner/prisoner = prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = prisoner_ref?.resolve() return QDELETED(prisoner) ? null : prisoner /// Every tick of a riot, before try_start(): anything the move keeps track of -/datum/prison_boss_move/proc/observe(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/proc/observe(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return /// Starts the move if it is ready, the boss may use it and there is something to use it on. Returns TRUE if it started. /datum/prison_boss_move/proc/try_start() if(pending_serial || world.time < busy_until || world.time < ready_at) return FALSE - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() var/datum/outpost_prison/prison = prisoner?.prison if(!prison || (once_per_riot && used_in_riot == prison.boss_riot_serial)) return FALSE @@ -243,14 +243,14 @@ return TRUE /// Whether the boss is still free to finish the wind-up under way -/datum/prison_boss_move/proc/may_finish(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/proc/may_finish(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return !!prisoner?.prison?.boss_riot_ok(prisoner, needs_cuffs) /// During the wind-up: stopped if the boss is no longer free to finish it (knocked down, dragged, cuffed, out of the riot) /datum/prison_boss_move/proc/watch_windup(serial) if(!serial || serial != pending_serial) return FALSE - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() if(!may_finish(prisoner)) cancel() interrupted(prisoner) @@ -266,7 +266,7 @@ if(!serial || serial != pending_serial) return FALSE var/atom/target = pending_target?.resolve() - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() if(!may_finish(prisoner)) cancel() interrupted(prisoner) @@ -288,24 +288,24 @@ pending_target = null busy_until = 0 clear_telegraphs() - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() if(prisoner) REMOVE_TRAIT(prisoner, TRAIT_IMMOBILIZED, PRISON_BOSS_MOVE_TRAIT) /// What the move goes for now, or null: a member of staff, or a thing near them -/datum/prison_boss_move/proc/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/proc/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return null /// Shows everyone what is coming, with a sound and a line -/datum/prison_boss_move/proc/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/proc/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) return /// Does it. `target` may be gone by now. Returns TRUE if anything happened. -/datum/prison_boss_move/proc/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/proc/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) return FALSE /// The wind-up was stopped before it finished -/datum/prison_boss_move/proc/interrupted(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/proc/interrupted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return /// Marks `turfs` for the wind-up, with the field kits' floor marks @@ -340,7 +340,7 @@ return ..() /// The nearest staff in sight, PRISON_BOSS_CHARGE_MIN_RANGE to _RANGE tiles off, on a line he could run clear to them -/datum/prison_boss_move/charge/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/charge/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison/prison = prisoner.prison var/mob/living/best var/best_distance = INFINITY @@ -359,7 +359,7 @@ * The line from the boss toward `target`, PRISON_BOSS_CHARGE_RANGE tiles long: it stops at the first * wall, and never runs past the cell block's edge. */ -/datum/prison_boss_move/charge/proc/charge_line(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/charge/proc/charge_line(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) . = list() var/datum/outpost_prison/prison = prisoner.prison var/turf/origin = get_turf(prisoner) @@ -382,7 +382,7 @@ break /// Whether the line toward `person` reaches them with nothing solid and nobody else before them -/datum/prison_boss_move/charge/proc/clear_run_to(mob/living/basic/outpost_prisoner/prisoner, mob/living/person) +/datum/prison_boss_move/charge/proc/clear_run_to(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/person) var/turf/aim = get_turf(person) for(var/turf/spot as anything in charge_line(prisoner, person)) if(spot == aim) @@ -392,14 +392,14 @@ return FALSE /// Whether a charge can run onto `spot`: in the cell block, ground the boss could walk, nothing solid on it -/datum/outpost_prison/proc/boss_run_tile_ok(mob/living/basic/outpost_prisoner/prisoner, turf/spot) +/datum/outpost_prison/proc/boss_run_tile_ok(mob/living/carbon/human/npc/outpost_prisoner/prisoner, turf/spot) if(!spot || spot.z != prisoner.z || isclosedturf(spot) || !in_cell_block(spot) || !prisoner.walkable?[spot]) return FALSE if(isgroundlessturf(spot) || islava(spot) || ischasm(spot)) return FALSE return !spot.is_blocked_turf(exclude_mobs = TRUE) -/datum/prison_boss_move/charge/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/charge/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) path = charge_line(prisoner, target) prisoner.visible_message(span_boldwarning("[prisoner] drops [prisoner.p_their()] shoulder and lowers [prisoner.p_their()] head!")) playsound(prisoner, 'sound/mobs/non-humanoids/gorilla/gorilla.ogg', 60, TRUE) @@ -407,7 +407,7 @@ mark_turfs(path, "#c8a060") prisoner.say_context("boss_move_charge") -/datum/prison_boss_move/charge/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/charge/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) if(!length(path) || get_dist(prisoner, path[1]) > 1) path = null return FALSE @@ -427,7 +427,7 @@ * slams into it and reels. Nothing is broken, and he never leaves the cell block. */ /datum/prison_boss_move/charge/proc/charge_step(serial) - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() var/datum/outpost_prison/prison = prisoner?.prison if(!serial || serial != charge_serial || !prison || !prison.boss_riot_ok(prisoner)) end_charge() @@ -452,7 +452,7 @@ addtimer(CALLBACK(src, PROC_REF(charge_step), serial), PRISON_BOSS_CHARGE_STEP, TIMER_DELETE_ME) /// Knocks `person` off their feet: brute through melee armour and a knockdown (a guard, who can't be knocked down, takes the blow) -/datum/prison_boss_move/charge/proc/slam_into(mob/living/basic/outpost_prisoner/prisoner, mob/living/person) +/datum/prison_boss_move/charge/proc/slam_into(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/person) prisoner.face_atom(person) prisoner.do_attack_animation(person, ATTACK_EFFECT_SMASH) person.visible_message(span_danger("[prisoner] slams into [person]!"), span_userdanger("[prisoner] slams into you!")) @@ -463,7 +463,7 @@ log_combat(prisoner, person, "shoulder-charged") /// Ran into something solid on `spot`: stopped dead, reeling for PRISON_BOSS_CHARGE_REEL. It takes no harm. -/datum/prison_boss_move/charge/proc/bounce(mob/living/basic/outpost_prisoner/prisoner, turf/spot) +/datum/prison_boss_move/charge/proc/bounce(mob/living/carbon/human/npc/outpost_prisoner/prisoner, turf/spot) end_charge() if(prisoner.stat != CONSCIOUS) return @@ -502,7 +502,7 @@ return ..() /// A bunk within PRISON_BOSS_FIRE_RANGE tiles, in sight with nothing in the way, with staff standing where it would burn -/datum/prison_boss_move/bed_fire/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/bed_fire/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(fire_ref?.resolve()) return null var/datum/outpost_prison/prison = prisoner.prison @@ -533,14 +533,14 @@ continue . += beside -/datum/prison_boss_move/bed_fire/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/bed_fire/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) patch = prisoner.prison.boss_fire_patch(target) prisoner.visible_message(span_boldwarning("[prisoner] strikes a match and eyes [target].")) playsound(prisoner, 'sound/items/match_strike.ogg', 60, TRUE) mark_turfs(patch, "#ff5a1f", "target_circle") prisoner.say_context("boss_move_fire") -/datum/prison_boss_move/bed_fire/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/bed_fire/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) var/obj/structure/bed/bunk = target var/list/burning = patch patch = null @@ -618,7 +618,7 @@ * The way out of the cell block he is breaking now, beside him, once the riot's blows have been at it * (note_exit_attacked()): a staff door, a serving hatch not yet open, or a window. Never anything else. */ -/datum/prison_boss_move/rigged_charge/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/rigged_charge/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison/prison = prisoner.prison var/obj/target = prisoner.riot_target_ref?.resolve() if(!isobj(target) || !prison.is_exit_blocker(target) || !prisoner.within_reach(target)) @@ -630,13 +630,13 @@ return null return target -/datum/prison_boss_move/rigged_charge/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/rigged_charge/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) prisoner.visible_message(span_boldwarning("[prisoner] crouches by [target] and packs something against it.")) playsound(prisoner, 'sound/machines/click.ogg', 60, TRUE) mark_turfs(list(get_turf(target)), COLOR_RED) prisoner.say_context("boss_move_rig") -/datum/prison_boss_move/rigged_charge/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/rigged_charge/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) var/datum/outpost_prison/prison = prisoner.prison if(QDELETED(target) || !prison.is_exit_blocker(target)) return FALSE @@ -746,35 +746,35 @@ /// world.time they were first seen cuffed this time, 0 while they are not var/cuffed_at = 0 -/datum/prison_boss_move/slip_cuffs/observe(mob/living/basic/outpost_prisoner/prisoner) - if(!prisoner.cuffs) +/datum/prison_boss_move/slip_cuffs/observe(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + if(!prisoner.handcuffed) cuffed_at = 0 else if(!cuffed_at) cuffed_at = world.time /// Their cuffs, once they have worn them PRISON_BOSS_SLIP_DELAY, with staff in sight to get away from -/datum/prison_boss_move/slip_cuffs/find_target(mob/living/basic/outpost_prisoner/prisoner) - if(!prisoner.cuffs || !cuffed_at || world.time - cuffed_at < PRISON_BOSS_SLIP_DELAY) +/datum/prison_boss_move/slip_cuffs/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + if(!prisoner.handcuffed || !cuffed_at || world.time - cuffed_at < PRISON_BOSS_SLIP_DELAY) return null if(!prisoner.prison.boss_nearest_staff(prisoner)) return null - return prisoner.cuffs + return prisoner.handcuffed -/datum/prison_boss_move/slip_cuffs/may_finish(mob/living/basic/outpost_prisoner/prisoner) - return ..() && prisoner.cuffs == pending_target?.resolve() +/datum/prison_boss_move/slip_cuffs/may_finish(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + return ..() && prisoner.handcuffed == pending_target?.resolve() -/datum/prison_boss_move/slip_cuffs/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/slip_cuffs/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) prisoner.visible_message(span_boldwarning("[prisoner] starts twisting at [target], working a hand loose.")) playsound(prisoner, 'sound/items/weapons/handcuffs.ogg', 40, TRUE) prisoner.Shake(1, 0, windup) prisoner.say_context("boss_move_slip") -/datum/prison_boss_move/slip_cuffs/interrupted(mob/living/basic/outpost_prisoner/prisoner) - if(prisoner?.stat == CONSCIOUS && prisoner.cuffs) - prisoner.visible_message(span_notice("[prisoner] loses [prisoner.p_their()] grip on [prisoner.cuffs].")) +/datum/prison_boss_move/slip_cuffs/interrupted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + if(prisoner?.stat == CONSCIOUS && prisoner.handcuffed) + prisoner.visible_message(span_notice("[prisoner] loses [prisoner.p_their()] grip on [prisoner.handcuffed].")) -/datum/prison_boss_move/slip_cuffs/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) - var/obj/item/restraints/handcuffs/worn = prisoner.cuffs +/datum/prison_boss_move/slip_cuffs/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) + var/obj/item/restraints/handcuffs/worn = prisoner.handcuffed if(!worn || worn != target) return FALSE // Without a user they drop where they stand: the same cuffs, on the floor @@ -879,7 +879,7 @@ * The nearest table beside him that tg lets be flipped, to go over toward the nearest staff in sight; * failing that, a locker beside him to shove into the doorway of a staff door staff are coming through. */ -/datum/prison_boss_move/barricade/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/barricade/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison/prison = prisoner.prison var/mob/living/nearest = prison.boss_staff_about(prisoner, PRISON_BOSS_BARRICADE_SIGHT) if(!nearest) @@ -905,7 +905,7 @@ return null /// The nearest awake staff in sight within `range` anywhere in the wing, the office included: who a barricade is against -/datum/outpost_prison/proc/boss_staff_about(mob/living/basic/outpost_prisoner/prisoner, range) +/datum/outpost_prison/proc/boss_staff_about(mob/living/carbon/human/npc/outpost_prisoner/prisoner, range) var/mob/living/nearest var/nearest_distance = INFINITY for(var/mob/living/person in view(range, prisoner)) @@ -941,7 +941,7 @@ return FALSE /// The tile straight in front of the staff door on `door_tile`, on the yard side, that `prisoner` can walk and nothing stands on, or null -/datum/outpost_prison/proc/boss_doorway_front(turf/door_tile, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/boss_doorway_front(turf/door_tile, mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/direction in GLOB.cardinals) var/turf/front = get_step(door_tile, direction) if(!front || !cell_block[front] || isclosedturf(front) || !prisoner.walkable?[front] || front.is_blocked_turf()) @@ -950,7 +950,7 @@ return null /// Whether a heavy may shove `locker`: a shut locker or crate, loose on the floor, in the cell block where `prisoner` can get at it, and not outpost property -/datum/outpost_prison/proc/boss_locker_shovable(obj/structure/closet/locker, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/boss_locker_shovable(obj/structure/closet/locker, mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(locker) || locker.anchored || !locker.density || !isturf(locker.loc)) return FALSE if((locker.resistance_flags & INDESTRUCTIBLE) || HAS_TRAIT(locker, TRAIT_OUTPOST_PROPERTY)) @@ -961,7 +961,7 @@ /datum/outpost_prison/proc/boss_shove_tile_ok(turf/spot) return spot && cell_block[spot] && !isclosedturf(spot) && prisoner_can_stand(spot) && !spot.is_blocked_turf() -/datum/prison_boss_move/barricade/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/barricade/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) if(shove_to) shove_ref = WEAKREF(target) prisoner.visible_message(span_boldwarning("[prisoner] puts [prisoner.p_their()] shoulder to [target].")) @@ -973,7 +973,7 @@ mark_turfs(list(get_turf(target)), "#c0c0c0") prisoner.say_context("boss_move_barricade") -/datum/prison_boss_move/barricade/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/barricade/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) var/datum/outpost_prison/prison = prisoner.prison if(shove_to) var/obj/structure/closet/locker = target @@ -1000,7 +1000,7 @@ * It stops in the doorway, at anything or anyone in the way, or when the heavy can't follow. */ /datum/prison_boss_move/barricade/proc/shove_step(serial) - var/mob/living/basic/outpost_prisoner/prisoner = owner() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = owner() var/datum/outpost_prison/prison = prisoner?.prison var/obj/structure/closet/locker = shove_ref?.resolve() if(!serial || serial != shove_serial || !prison || !prison.boss_riot_ok(prisoner) || QDELETED(locker) || !shove_to) @@ -1042,9 +1042,9 @@ boss_riot_serial++ if(forced_riot || !crew_home()) return joining - var/mob/living/basic/outpost_prisoner/kingpin - for(var/mob/living/basic/outpost_prisoner/prisoner as anything in joining) - if(prisoner.bounty_record?.archetype != BOUNTY_ARCHETYPE_KINGPIN || prisoner.stat != CONSCIOUS || prisoner.cuffs) + var/mob/living/carbon/human/npc/outpost_prisoner/kingpin + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner as anything in joining) + if(prisoner.bounty_record?.archetype != BOUNTY_ARCHETYPE_KINGPIN || prisoner.stat != CONSCIOUS || prisoner.handcuffed) continue if(!in_cell_block(prisoner) || prisoner.is_confined() || !prisoner.ai_running()) continue @@ -1053,7 +1053,7 @@ if(!kingpin) return joining var/list/swayed = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if((prisoner in joining) || !prisoner.can_join_riot()) continue if(prisoner.mood < prisoner.riot_join_mood() + PRISON_BOSS_WORD_BONUS) @@ -1070,11 +1070,11 @@ once_per_riot = TRUE /// The nearest of the wing's own NPC guards in sight and on duty, never a player -/datum/prison_boss_move/bribe/find_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/prison_boss_move/bribe/find_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison/prison = prisoner.prison - var/mob/living/basic/outpost_prison_guard/best + var/mob/living/carbon/human/npc/outpost_prison_guard/best var/best_distance = INFINITY - for(var/mob/living/basic/outpost_prison_guard/guard in view(PRISON_BOSS_BRIBE_RANGE, prisoner)) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in view(PRISON_BOSS_BRIBE_RANGE, prisoner)) var/distance = get_dist(prisoner, guard) if(distance < best_distance && prison.boss_bribable(guard)) best = guard @@ -1082,20 +1082,20 @@ return best /// Whether the kingpin may pay off `guard`: one of this wing's guards, on duty, not already paid, and nobody's playing them -/datum/outpost_prison/proc/boss_bribable(mob/living/basic/outpost_prison_guard/guard) +/datum/outpost_prison/proc/boss_bribable(mob/living/carbon/human/npc/outpost_prison_guard/guard) if(!istype(guard) || QDELETED(guard) || guard.prison != src || !guard.on_duty()) return FALSE return isnull(guard.mind) && isnull(guard.client) -/datum/prison_boss_move/bribe/telegraph(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/bribe/telegraph(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) prisoner.visible_message(span_boldwarning("[prisoner] catches [target]'s eye and taps [prisoner.p_their()] pocket.")) playsound(prisoner, 'sound/items/coinflip.ogg', 50, TRUE) mark_turfs(list(get_turf(target)), "#e0c050", "target_circle") prisoner.say_context("boss_move_bribe") -/datum/prison_boss_move/bribe/effect(mob/living/basic/outpost_prisoner/prisoner, atom/target) +/datum/prison_boss_move/bribe/effect(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/target) var/datum/outpost_prison/prison = prisoner.prison - var/mob/living/basic/outpost_prison_guard/guard = target + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = target if(!prison.boss_bribable(guard) || get_dist(prisoner, guard) > PRISON_BOSS_BRIBE_RANGE || !(guard in view(PRISON_BOSS_BRIBE_RANGE, prisoner))) return FALSE prison.boss_bribe_guard(guard) @@ -1106,7 +1106,7 @@ * no riot response, no baton, and rioters leave them be), standing back in the office's far corner. * boss_bribes_tick() sends them back to work. */ -/datum/outpost_prison/proc/boss_bribe_guard(mob/living/basic/outpost_prison_guard/guard) +/datum/outpost_prison/proc/boss_bribe_guard(mob/living/carbon/human/npc/outpost_prison_guard/guard) guard.prison_bribed_until = world.time + PRISON_BOSS_BRIBE_TIME guard.end_response() guard.set_baton(FALSE) @@ -1121,7 +1121,7 @@ /// Paid-off guards whose time is up go back to work /datum/outpost_prison/proc/boss_bribes_tick() for(var/datum/weakref/ref as anything in boss_bribed_guards.Copy()) - var/mob/living/basic/outpost_prison_guard/guard = ref.resolve() + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = ref.resolve() if(QDELETED(guard)) boss_bribed_guards -= ref continue @@ -1134,7 +1134,7 @@ guard.visible_message(span_notice("[guard] squares [guard.p_their()] shoulders and gets back to work.")) /// Whether the kingpin has them standing back from the riot (on_duty()) -/mob/living/basic/outpost_prison_guard/proc/boss_bribed() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/boss_bribed() return prison_bribed_until > world.time /** diff --git a/voidcrew/modules/player_outposts/outpost_prison_bounty.dm b/voidcrew/modules/player_outposts/outpost_prison_bounty.dm index e631f2e1154..6fd3144aa00 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_bounty.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_bounty.dm @@ -81,7 +81,7 @@ /// For tests: whether a meek prisoner watching the staff door darts when it opens (TRUE or FALSE) instead of rolling for it var/prison_bounty_forced_dart -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Running after a bounty prisoner called out someone off the ship that caught them (bounty_extra_speech()) COOLDOWN_DECLARE(prison_captor_line_cooldown) @@ -333,7 +333,7 @@ GLOBAL_VAR(bounty_pool_timer) /// Living bounty prisoners on the roster who are not leaving, of `tier` when given /datum/outpost_prison/proc/bounty_prisoner_count(tier) . = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!prisoner.bounty_record || prisoner.stat == DEAD || prisoner.phase == PRISONER_LEAVING) continue if(tier && prisoner.bounty_record.tier != tier) @@ -445,7 +445,7 @@ GLOBAL_VAR(bounty_pool_timer) continue bounty_pool_remove(record) record.status = BOUNTY_RECORD_IMPRISONED - var/mob/living/basic/outpost_prisoner/prisoner = new(spot, record) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = new(spot, record) admit(prisoner, cell) prisoner.beam_in() start_arrival_gap(soonest_lane()) @@ -456,17 +456,14 @@ GLOBAL_VAR(bounty_pool_timer) // ===== A PRISONER FROM A RECORD ===== /** - * Before the prisoner's Initialize() calls ..(): the body their record says (gender, and health by - * tier), so tg sizes max_stamina from the right maxHealth. A record still in the pool leaves it now, - * before they exist. + * Before the prisoner's Initialize() calls ..(): a record still in the pool leaves it now, before they + * exist, and a tougher criminal takes less from a blow (npc_damage_taken by tier, through physiology, + * defaults PR-16). Their face and gender come from the record through the human NPC base. */ -/mob/living/basic/outpost_prisoner/proc/apply_bounty_body(datum/bounty_record/record) +/mob/living/carbon/human/npc/outpost_prisoner/proc/apply_bounty_body(datum/bounty_record/record) if(record in GLOB.bounty_prisoner_pool) bounty_pool_remove(record) - if(record.gender == MALE || record.gender == FEMALE) - gender = record.gender - maxHealth = bounty_by_tier(record.tier, BOUNTY_PRISONER_HEALTH_PETTY, BOUNTY_PRISONER_HEALTH_WANTED, BOUNTY_PRISONER_HEALTH_MOST_WANTED) - health = maxHealth + npc_damage_taken = bounty_by_tier(record.tier, BOUNTY_PRISONER_DAMAGE_TAKEN_PETTY, BOUNTY_PRISONER_DAMAGE_TAKEN_WANTED, BOUNTY_PRISONER_DAMAGE_TAKEN_MOST_WANTED) /** * At the end of the prisoner's Initialize(), before their look is built: who their record says @@ -478,12 +475,11 @@ GLOBAL_VAR(bounty_pool_timer) * yard hears BOUNTY_PRISON_MOST_WANTED_YARD_CRIME, as a Most Wanted criminal never names theirs in * its own lines (BUG-12); the console and examine still read the record's. */ -/mob/living/basic/outpost_prisoner/proc/apply_bounty_record(datum/bounty_record/record) +/mob/living/carbon/human/npc/outpost_prisoner/proc/apply_bounty_record(datum/bounty_record/record) if(record.gender == MALE || record.gender == FEMALE) gender = record.gender if(length(record.name)) - real_name = record.name - name = real_name + fully_replace_character_name(real_name, record.name) var/plain_crime = bounty_plain_crime(record.crime) if(record.tier == BOUNTY_TIER_MOST_WANTED) crime = BOUNTY_PRISON_MOST_WANTED_YARD_CRIME @@ -492,9 +488,8 @@ GLOBAL_VAR(bounty_pool_timer) var/list/personalities = bounty_archetype_personalities(record.archetype) if(length(personalities)) personality = pick(personalities) - // The plain jumpsuit: the glasses and beanie variants would hide their hair or fake their eyewear - outfit_path = /datum/outfit/outpost_prisoner - // As hurt as they were caught, but never below BOUNTY_PRISONER_MIN_ARRIVAL_HEALTH percent + // As hurt as they were caught, but never below BOUNTY_PRISONER_MIN_ARRIVAL_HEALTH percent: real + // damage spread over their limbs when they beam in (beam_in()) arrival_brute = round(maxHealth * clamp(record.hurt_fraction || 0, 0, 1 - BOUNTY_PRISONER_MIN_ARRIVAL_HEALTH / 100)) record.status = BOUNTY_RECORD_IMPRISONED @@ -535,7 +530,7 @@ GLOBAL_VAR(bounty_pool_timer) * nothing (a release is followed by forget(), for one). A closed record never returns to the pool * (bounty_pool_add() refuses it). Returns TRUE if this call closed it. */ -/proc/close_bounty_record(mob/living/basic/outpost_prisoner/prisoner, status) +/proc/close_bounty_record(mob/living/carbon/human/npc/outpost_prisoner/prisoner, status) var/datum/bounty_record/record = prisoner?.bounty_record if(!record || record.status != BOUNTY_RECORD_IMPRISONED) return FALSE @@ -544,13 +539,13 @@ GLOBAL_VAR(bounty_pool_timer) return TRUE /// Why Kessler's experiments refuse `prisoner`, or null: a bounty prisoner's identity would be lost -/proc/bounty_experiment_refusal(mob/living/basic/outpost_prisoner/prisoner) +/proc/bounty_experiment_refusal(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return prisoner?.bounty_record ? "Kessler won't touch a bounty prisoner." : null // ===== PAY ===== /// What `prisoner` earns the treasury (stipend, pay rate and release bonus) as a multiple of an ordinary prisoner's: x1.5, x2 or x2.5 by tier -/datum/outpost_prison/proc/bounty_pay_mult(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/bounty_pay_mult(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/bounty_record/record = prisoner?.bounty_record if(!record) return 1 @@ -558,54 +553,40 @@ GLOBAL_VAR(bounty_pool_timer) // ===== DANGER ===== -/// Multiplier on their punches and shiv blows (update_melee() and strike()), by tier and by what they hit with -/mob/living/basic/outpost_prisoner/proc/bounty_damage_mult() - if(!bounty_record) - return 1 - if(has_shiv()) - return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_SHIV_MULT_PETTY, BOUNTY_PRISON_SHIV_MULT_WANTED, BOUNTY_PRISON_SHIV_MULT_MOST_WANTED) - return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_PUNCH_MULT_PETTY, BOUNTY_PRISON_PUNCH_MULT_WANTED, BOUNTY_PRISON_PUNCH_MULT_MOST_WANTED) - /// Multiplier on their mood losses (mood_scale()) -/mob/living/basic/outpost_prisoner/proc/bounty_mood_scale() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_mood_scale() if(!bounty_record) return 1 return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_MOOD_SCALE_PETTY, BOUNTY_PRISON_MOOD_SCALE_WANTED, BOUNTY_PRISON_MOOD_SCALE_MOST_WANTED) /// Mood points added to the line below which they square up to staff (threat_mood_for() and threat_mood_ceiling()) -/mob/living/basic/outpost_prisoner/proc/bounty_threat_bonus() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_threat_bonus() if(!bounty_record) return 0 return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_THREAT_BONUS_PETTY, BOUNTY_PRISON_THREAT_BONUS_WANTED, BOUNTY_PRISON_THREAT_BONUS_MOST_WANTED) /// Mood points added to the line below which they join a riot (riot_join_bonus()) -/mob/living/basic/outpost_prisoner/proc/bounty_riot_bonus() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_riot_bonus() if(!bounty_record) return 0 return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_RIOT_BONUS_PETTY, BOUNTY_PRISON_RIOT_BONUS_WANTED, BOUNTY_PRISON_RIOT_BONUS_MOST_WANTED) /// Multiplier on the damage their blows do to the wing's ways out (hit_exit()) -/mob/living/basic/outpost_prisoner/proc/bounty_breakout_mult() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_breakout_mult() if(!bounty_record) return 1 return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_BREAKOUT_MULT_PETTY, BOUNTY_PRISON_BREAKOUT_MULT_WANTED, BOUNTY_PRISON_BREAKOUT_MULT_MOST_WANTED) -/// Multiplier on how long cuffing them takes (cuff_time()) -/mob/living/basic/outpost_prisoner/proc/bounty_cuff_mult() - if(!bounty_record) - return 1 - return bounty_by_tier(bounty_record.tier, BOUNTY_PRISON_CUFF_MULT_PETTY, BOUNTY_PRISON_CUFF_MULT_WANTED, BOUNTY_PRISON_CUFF_MULT_MOST_WANTED) - /// Whether they were a meek criminal: they back off, go over hatches and slip out of doors -/mob/living/basic/outpost_prisoner/proc/bounty_is_meek() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_is_meek() return bounty_record?.archetype == BOUNTY_ARCHETYPE_MEEK /// Multiplier on how likely they are to back off when a player hits them: twice as likely for a meek bounty prisoner (hit_reaction(), through bounty_hit_reaction_weights()) -/mob/living/basic/outpost_prisoner/proc/bounty_back_off_mult() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_back_off_mult() return bounty_is_meek() ? BOUNTY_PRISON_MEEK_BACK_OFF_MULT : 1 /// Multiplier on how likely they are to hit back when a player hits them: a Wanted or Most Wanted prisoner who is not meek hits back like a grumpy one (hit_reaction(), through bounty_hit_reaction_weights()) -/mob/living/basic/outpost_prisoner/proc/bounty_hit_back_mult() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_hit_back_mult() if(!bounty_record || bounty_is_meek()) return 1 return bounty_by_tier(bounty_record.tier, 1, BOUNTY_PRISON_HIT_BACK_MULT_WANTED, BOUNTY_PRISON_HIT_BACK_MULT_MOST_WANTED) @@ -614,38 +595,30 @@ GLOBAL_VAR(bounty_pool_timer) * A calm prisoner's hit-back and back-off weights (outpost_prisoner_hit_reaction_weights()'s list, * which is theirs to change), scaled for a bounty prisoner. Returns the same list. hit_reaction() rolls on it. */ -/mob/living/basic/outpost_prisoner/proc/bounty_hit_reaction_weights(list/weights) +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_hit_reaction_weights(list/weights) if(!bounty_record || !islist(weights)) return weights weights[PRISONER_HIT_FIGHT] = max(1, round(weights[PRISONER_HIT_FIGHT] * bounty_hit_back_mult())) weights[PRISONER_HIT_COWER] = max(1, round(weights[PRISONER_HIT_COWER] * bounty_back_off_mult())) return weights -/// Their speed while loose, or null for the usual: a meek bounty prisoner runs faster than the others (go_loose()) -/mob/living/basic/outpost_prisoner/proc/bounty_loose_speed() - return bounty_is_meek() ? BOUNTY_PRISON_MEEK_LOOSE_SPEED : null - -/// The speed they go back to when not running from anything: a loose meek bounty prisoner keeps their loose speed (update_flee_speed(), back_in_custody()) -/mob/living/basic/outpost_prisoner/proc/bounty_base_speed() - if(trouble == PRISONER_TROUBLE_LOOSE) - var/loose_speed = bounty_loose_speed() - if(loose_speed) - return loose_speed - return initial(speed) +/// Whether they run rather than walk when not running from anything: a runner runs (go_loose(), update_flee_speed(), back_in_custody()) +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_base_speed() + return trouble == PRISONER_TROUBLE_LOOSE /// How long going over a serving hatch takes them, or null for the usual: a meek bounty prisoner is quicker (start_climb()) -/mob/living/basic/outpost_prisoner/proc/bounty_climb_time() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_climb_time() return bounty_is_meek() ? BOUNTY_PRISON_MEEK_CLIMB_TIME : null /// Multiplier on their weight as the wildcard "snap" prisoner: a Most Wanted ringleader's is doubled (pick_snapper()) -/mob/living/basic/outpost_prisoner/proc/bounty_snap_weight_mult() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_snap_weight_mult() return bounty_record?.tier == BOUNTY_TIER_MOST_WANTED ? BOUNTY_PRISON_RINGLEADER_SNAP_MULT : 1 /// `rioters` with any Most Wanted first, so a ringleader is among the first to shout when a riot starts (start_riot()). Returns a new list. /proc/bounty_ringleaders_first(list/rioters) var/list/leaders = list() var/list/others = list() - for(var/mob/living/basic/outpost_prisoner/rioter as anything in rioters) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter as anything in rioters) if(rioter.bounty_record?.tier == BOUNTY_TIER_MOST_WANTED) leaders += rioter else @@ -654,10 +627,10 @@ GLOBAL_VAR(bounty_pool_timer) /// An awake, uncuffed Most Wanted prisoner out in the yard, not shut in a cell, who stirs the others up, or null /datum/outpost_prison/proc/bounty_ringleader() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.bounty_record?.tier != BOUNTY_TIER_MOST_WANTED || !counts_for_tension(prisoner)) continue - if(prisoner.stat != CONSCIOUS || prisoner.cuffs || !in_cell_block(prisoner) || prisoner.is_confined()) + if(prisoner.stat != CONSCIOUS || prisoner.handcuffed || !in_cell_block(prisoner) || prisoner.is_confined()) continue return prisoner return null @@ -670,7 +643,7 @@ GLOBAL_VAR(bounty_pool_timer) /datum/outpost_prison/proc/bounty_restless_causes() if(BOUNTY_PRISON_RINGLEADER_TENSION <= 0) return list() - var/mob/living/basic/outpost_prisoner/ringleader = bounty_ringleader() + var/mob/living/carbon/human/npc/outpost_prisoner/ringleader = bounty_ringleader() return ringleader ? list("[ringleader.real_name] stirring them up") : list() // ===== MEEK BOUNTY PRISONERS: HATCHES AND DOORS ===== @@ -906,7 +879,7 @@ GLOBAL_VAR(bounty_pool_timer) return best /// Whether they are a meek bounty prisoner in a mood to watch the staff door for their chance, with a member of the wing home (nothing new starts in an empty wing) -/mob/living/basic/outpost_prisoner/proc/bounty_may_watch_door() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_may_watch_door() return bounty_is_meek() && mood < BOUNTY_PRISON_MEEK_DART_MOOD && !!prison?.trouble_enabled && prison.crew_home() // ===== CONSOLES ===== @@ -916,7 +889,7 @@ GLOBAL_VAR(bounty_pool_timer) * "wanted for" line and whether they are Most Wanted, the one tier a wanted poster names. Petty and * Wanted show nothing more, and no pay multiplier. */ -/datum/outpost_prison/proc/bounty_roster_badge(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/bounty_roster_badge(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/bounty_record/record = prisoner?.bounty_record if(!record) return null @@ -932,7 +905,7 @@ GLOBAL_VAR(bounty_pool_timer) * since the yard's "In for ..." leaves it out (BUG-12): "Most Wanted, for smuggling. The crew of the * Meridian brought him in." */ -/datum/outpost_prison/proc/bounty_examine(mob/living/basic/outpost_prisoner/prisoner, mob/user) +/datum/outpost_prison/proc/bounty_examine(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/user) var/datum/bounty_record/record = prisoner?.bounty_record if(!record) return null @@ -955,7 +928,7 @@ GLOBAL_VAR(bounty_pool_timer) * Values for their lines that name the ship that caught them, list("{place}" = "Meridian"), or null * for an ordinary prisoner or a record with no captor. The lines say "the {place}". */ -/mob/living/basic/outpost_prisoner/proc/bounty_captor_values() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_captor_values() if(!length(bounty_record?.captor_name)) return null return list("{place}" = bounty_record.captor_name) @@ -965,12 +938,12 @@ GLOBAL_VAR(bounty_pool_timer) * the Meridian crew sends people."), in place of the usual hello. Returns TRUE if they said it. * finish_beam_in() calls it before its own arrival lines. */ -/mob/living/basic/outpost_prisoner/proc/bounty_arrival_speech() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_arrival_speech() var/list/values = bounty_captor_values() return values ? say_context_with("bounty_arrival", values) : FALSE /// Someone awake off the ship that caught them, within BOUNTY_PRISON_CAPTOR_VIEW tiles and in sight, or null -/mob/living/basic/outpost_prisoner/proc/bounty_captor_in_view() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bounty_captor_in_view() var/obj/structure/overmap/ship/captor_ship = bounty_record?.captor_ship?.resolve() if(QDELETED(captor_ship)) return null @@ -985,7 +958,7 @@ GLOBAL_VAR(bounty_pool_timer) * Rolled at `chance` percent, and at most once every BOUNTY_PRISON_CAPTOR_LINE_GAP each; the sight * check runs only once the roll comes up. Returns list(context, null, values), or null. */ -/datum/outpost_prison/proc/bounty_extra_speech(mob/living/basic/outpost_prisoner/prisoner, chance = BOUNTY_PRISON_CAPTOR_LINE_CHANCE) +/datum/outpost_prison/proc/bounty_extra_speech(mob/living/carbon/human/npc/outpost_prisoner/prisoner, chance = BOUNTY_PRISON_CAPTOR_LINE_CHANCE) var/list/values = prisoner?.bounty_captor_values() if(!values || !COOLDOWN_FINISHED(prisoner, prison_captor_line_cooldown) || !prob(chance)) return null @@ -1079,7 +1052,7 @@ GLOBAL_VAR(bounty_pool_timer) var/datum/bounty_record/record = bounty_pool_find(params?["id"]) if(!record) return list("error" = "That record is no longer in the pool.") - var/mob/living/basic/outpost_prisoner/arrival = bounty_admit_record(record) + var/mob/living/carbon/human/npc/outpost_prisoner/arrival = bounty_admit_record(record) if(!arrival) return list("error" = "No free cell.") return "admit bounty prisoner [arrival.real_name] ([bounty_tier_name(record.tier)]) into cell [arrival.cell?.number]" diff --git a/voidcrew/modules/player_outposts/outpost_prison_breach.dm b/voidcrew/modules/player_outposts/outpost_prison_breach.dm new file mode 100644 index 00000000000..c93298ad6ae --- /dev/null +++ b/voidcrew/modules/player_outposts/outpost_prison_breach.dm @@ -0,0 +1,189 @@ +/** + * # Prison breaches: emergency air + * + * Prisoners breathe like anyone (they are real human bodies): a breach, a fire or a plasma leak in the + * cell block suffocates, freezes and burns them the way it does the crew. Owner ruling Q3: the cell + * block has emergency lockers of breath masks and air tanks, and a prisoner on bad air who isn't busy + * making trouble goes and gets one, puts the mask on, and breathes from the tank (the human NPC base + * opens a worn tank while the air is bad). The wing gets one locker per PRISON_EMERGENCY_LOCKER_TILES + * floor tiles of its cell block when the corrections service lays it out, against a yard wall. + * + * Nothing here heals or shields anyone: a prisoner who can't reach a locker, or finds it emptied, + * suffocates. + */ + +/// Floor tiles of cell block per emergency locker +#define PRISON_EMERGENCY_LOCKER_TILES 60 +/// How often a prisoner on bad air may try again for a locker after failing to get to one +#define PRISON_EMERGENCY_RETRY (15 SECONDS) + +/// The cell block's emergency locker: breath masks and air tanks, the same every time +/obj/structure/closet/emcloset/outpost_prison + name = "emergency locker" + desc = "Breath masks and air tanks for the cell block, for when the air goes bad." + anchored = TRUE + +/obj/structure/closet/emcloset/outpost_prison/Initialize(mapload) + . = ..() + AddElement(/datum/element/outpost_property) + +/obj/structure/closet/emcloset/outpost_prison/PopulateContents() + for(var/i in 1 to 4) + new /obj/item/clothing/mask/breath(src) + new /obj/item/tank/internals/emergency_oxygen(src) + +/datum/outpost_prison + /// The emergency lockers the wing's cell block has (weakrefs) + var/list/emergency_lockers = list() + +/** + * Makes sure the cell block has its emergency lockers: one per PRISON_EMERGENCY_LOCKER_TILES of floor, + * counting any already standing there (mapped or placed), each new one against a yard wall away from the + * others. Called when the cell block is worked out. + */ +/datum/outpost_prison/proc/ensure_emergency_lockers() + if(!length(cell_block)) + return + var/list/standing = list() + var/floor = 0 + for(var/turf/tile as anything in cell_block) + if(isclosedturf(tile)) + continue + floor++ + for(var/obj/structure/closet/emcloset/locker in tile) + standing += locker + emergency_lockers.Cut() + for(var/obj/structure/closet/emcloset/locker as anything in standing) + emergency_lockers += WEAKREF(locker) + var/wanted = max(1, round(floor / PRISON_EMERGENCY_LOCKER_TILES)) + while(length(standing) < wanted) + var/turf/spot = emergency_locker_spot(standing) + if(!spot) + return + var/obj/structure/closet/emcloset/outpost_prison/locker = new(spot) + standing += locker + emergency_lockers += WEAKREF(locker) + +/// A yard tile against a wall, in no cell, with nothing on it and no door beside it, furthest from `others` +/datum/outpost_prison/proc/emergency_locker_spot(list/others) + var/turf/best + var/best_distance = -1 + for(var/turf/open/tile in cell_block) + if(cell_at(tile) || tile.is_blocked_turf(TRUE) || length(tile.contents)) + continue + var/against_wall = FALSE + var/by_door = FALSE + for(var/direction in GLOB.cardinals) + var/turf/beside = get_step(tile, direction) + if(isclosedturf(beside)) + against_wall = TRUE + if(locate(/obj/machinery/door) in beside) + by_door = TRUE + if(!against_wall || by_door) + continue + var/nearest = INFINITY + for(var/atom/other as anything in others) + nearest = min(nearest, get_dist(other, tile)) + if(nearest > best_distance) + best = tile + best_distance = nearest + return best + +/// The nearest emergency locker `prisoner` can walk to that still has a mask and a tank in it +/datum/outpost_prison/proc/emergency_locker_for(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + var/obj/structure/closet/best + var/best_distance = INFINITY + for(var/datum/weakref/locker_ref as anything in emergency_lockers) + var/obj/structure/closet/locker = locker_ref.resolve() + if(QDELETED(locker) || !prisoner.approach_turf(locker)) + continue + if(!(locate(/obj/item/clothing/mask/breath) in locker) && !(locate(/obj/item/clothing/mask/gas) in locker)) + continue + if(!(locate(/obj/item/tank/internals) in locker)) + continue + var/distance = get_dist(prisoner, locker) + if(distance < best_distance) + best = locker + best_distance = distance + return best + +/mob/living/carbon/human/npc/outpost_prisoner + /// world.time they may next go for an emergency locker + var/next_emergency_try = 0 + +/// Whether they wear a mask they could breathe a tank through, and carry a tank +/mob/living/carbon/human/npc/outpost_prisoner/proc/has_emergency_air() + if(!can_breathe_internals()) + return FALSE + for(var/obj/item/tank/internals/tank in get_equipped_items(INCLUDE_POCKETS | INCLUDE_HELD)) + return TRUE + return FALSE + +/** + * The air where they stand is bad (air_tick()). Wearing a mask and a tank, they breathe from it. + * Otherwise, unless they are busy with trouble, they go for the nearest emergency locker. + */ +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_bad_air() + if(stat != CONSCIOUS) + return + if(has_emergency_air()) + if(!internal) + npc_open_internals() + return + if(world.time < next_emergency_try || istype(activity, /datum/prisoner_activity/emergency_air) || !routine_allowed()) + return + var/datum/prisoner_activity/emergency_air/dash = new(src) + if(!dash.setup()) + qdel(dash) + next_emergency_try = world.time + PRISON_EMERGENCY_RETRY + return + start_activity(dash) + +/// To the emergency locker: open it, take a mask and a tank, put them on and breathe +/datum/prisoner_activity/emergency_air + name = "getting emergency air" + interruptible = FALSE + min_duration = 3 SECONDS + max_duration = 3 SECONDS + var/datum/weakref/locker_ref + +/datum/prisoner_activity/emergency_air/setup() + var/obj/structure/closet/locker = prisoner.prison?.emergency_locker_for(prisoner) + if(!locker) + return FALSE + locker_ref = WEAKREF(locker) + spot = prisoner.approach_turf(locker) + return !!spot + +/datum/prisoner_activity/emergency_air/begin() + . = ..() + var/obj/structure/closet/locker = locker_ref?.resolve() + if(QDELETED(locker) || get_dist(prisoner, locker) > 1) + return + prisoner.face_atom(locker) + if(!locker.opened) + locker.open(prisoner) + var/turf/inside = get_turf(locker) + var/obj/item/clothing/mask/mask = locate(/obj/item/clothing/mask/breath) in inside + mask ||= locate(/obj/item/clothing/mask/gas) in inside + var/obj/item/tank/internals/tank = locate(/obj/item/tank/internals) in inside + if(!mask || !tank) + return + prisoner.visible_message(span_notice("[prisoner] grabs [mask] and [tank] out of [locker].")) + if(prisoner.wear_mask && !prisoner.can_breathe_internals()) + prisoner.dropItemToGround(prisoner.wear_mask) + if(!prisoner.wear_mask) + mask.forceMove(prisoner) + if(!prisoner.equip_to_slot_if_possible(mask, ITEM_SLOT_MASK, disable_warning = TRUE)) + mask.forceMove(inside) + tank.forceMove(prisoner) + if(!prisoner.equip_to_slot_if_possible(tank, ITEM_SLOT_BELT, disable_warning = TRUE) && !prisoner.equip_to_slot_if_possible(tank, ITEM_SLOT_LPOCKET, disable_warning = TRUE) && !prisoner.equip_to_slot_if_possible(tank, ITEM_SLOT_RPOCKET, disable_warning = TRUE) && !prisoner.put_in_hands(tank)) + tank.forceMove(inside) + prisoner.npc_open_internals() + +/datum/prisoner_activity/emergency_air/Destroy() + locker_ref = null + return ..() + +#undef PRISON_EMERGENCY_LOCKER_TILES +#undef PRISON_EMERGENCY_RETRY diff --git a/voidcrew/modules/player_outposts/outpost_prison_breakout.dm b/voidcrew/modules/player_outposts/outpost_prison_breakout.dm index a54a098ff3f..9d6cc54d253 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_breakout.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_breakout.dm @@ -80,7 +80,7 @@ * The ways out a rioter can get at, exit tile = its blocker: tiles on the cell block's edge beside * ground they can walk, whose blocker they have not given up on for now. */ -/datum/outpost_prison/proc/exit_tiles(mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/exit_tiles(mob/living/carbon/human/npc/outpost_prisoner/rioter) var/list/exits = list() for(var/turf/tile as anything in rioter.reachable) if(rioter.walkable?[tile] || !is_exit_tile(tile)) @@ -96,9 +96,9 @@ return istype(blocker, /obj/structure/table/reinforced/prison_hatch) ? 1 : PRISON_RIOT_EXIT_CROWD /// How many rioters other than `except`, on their feet, are working at the way out on `tile` -/datum/outpost_prison/proc/rioters_at_exit(turf/tile, mob/living/basic/outpost_prisoner/except) +/datum/outpost_prison/proc/rioters_at_exit(turf/tile, mob/living/carbon/human/npc/outpost_prisoner/except) var/count = 0 - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other == except || !other.is_rioting() || !other.trouble_can_act()) continue var/atom/target = other.riot_target_ref?.resolve() @@ -112,7 +112,7 @@ * farther than a door. One with no room left (exit_room()) is passed over unless `crowded_ok`. * Null if none will do. */ -/datum/outpost_prison/proc/choose_exit(mob/living/basic/outpost_prisoner/rioter, list/exits, crowded_ok = FALSE) +/datum/outpost_prison/proc/choose_exit(mob/living/carbon/human/npc/outpost_prisoner/rioter, list/exits, crowded_ok = FALSE) var/turf/best var/best_score = INFINITY for(var/turf/tile as anything in exits) @@ -148,7 +148,7 @@ * A fixture for a rioter to smash: one within PRISON_RIOT_DETOUR_RANGE tiles of `near` if there is * one, else any they can reach, or null with `near_only`. */ -/datum/outpost_prison/proc/pick_fixture(mob/living/basic/outpost_prisoner/rioter, turf/near, near_only = FALSE) +/datum/outpost_prison/proc/pick_fixture(mob/living/carbon/human/npc/outpost_prisoner/rioter, turf/near, near_only = FALSE) var/list/found = list() var/list/close = list() for(var/turf/tile as anything in rioter.reachable) @@ -173,7 +173,7 @@ * at, the wing's fixtures. Breaking out, they go for the ways out, crowded or not, and never stop * for a fixture on the way. */ -/datum/outpost_prison/proc/pick_smash_target(mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/pick_smash_target(mob/living/carbon/human/npc/outpost_prisoner/rioter) var/atom/priority = priority_smash_target(rioter) if(priority) return priority @@ -192,7 +192,7 @@ return pick_fixture(rioter) /// Whether a rioter can make for `tile` to get out: walkable for them, in the wing, outside the cell block, and not given up on -/datum/outpost_prison/proc/escape_spot_ok(turf/tile, mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/escape_spot_ok(turf/tile, mob/living/carbon/human/npc/outpost_prisoner/rioter) return isturf(tile) && tile.loc == wing && !cell_block[tile] && rioter.walkable?[tile] && LAZYACCESS(rioter.riot_skips, REF(tile)) <= world.time /** @@ -200,7 +200,7 @@ * can walk to, keeping the one they chose while it still works, and preferring one nobody stands on. * Null with nobody home, so the rioters stay in the yard, and in a wing with no cells. */ -/datum/outpost_prison/proc/escape_spot(mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/escape_spot(mob/living/carbon/human/npc/outpost_prisoner/rioter) if(!length(cell_block) || !length(rioter.walkable) || !crew_home()) return null var/turf/chosen = rioter.riot_target_ref?.resolve() @@ -233,7 +233,7 @@ * each way out this riot is announced; with nobody home it only booms. When the way gives, everyone's * reach is worked out again so the rioters see the gap. Returns TRUE. */ -/datum/outpost_prison/proc/hit_exit(mob/living/basic/outpost_prisoner/rioter, obj/target) +/datum/outpost_prison/proc/hit_exit(mob/living/carbon/human/npc/outpost_prisoner/rioter, obj/target) var/turf/tile = get_turf(target) rioter.trouble_line("riot_break_door", fallback = "riot") if(!crew_home()) @@ -300,7 +300,7 @@ * hatch is forced open (force_open()). With the office side already open or gone, the yard side opens * for them, or takes the blow if it cannot open. Returns TRUE if the office side gave. */ -/obj/structure/table/reinforced/prison_hatch/proc/take_rioter_blow(mob/living/basic/outpost_prisoner/rioter) +/obj/structure/table/reinforced/prison_hatch/proc/take_rioter_blow(mob/living/carbon/human/npc/outpost_prisoner/rioter) playsound(src, 'sound/effects/glass/glassbash.ogg', 50, TRUE) var/obj/machinery/door/window/staff_door = staff_windoor() if(staff_door?.density) diff --git a/voidcrew/modules/player_outposts/outpost_prison_capture.dm b/voidcrew/modules/player_outposts/outpost_prison_capture.dm index db9a890e563..75db2b47b69 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_capture.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_capture.dm @@ -1,18 +1,17 @@ /** * # Prison capture: cuffs and lockdown * - * Cuffs. A member of the wing can put any handcuffs, cable restraints or zipties on a prisoner. - * One who is down takes PRISONER_CUFF_TIME_DOWN; one on their feet takes PRISONER_CUFF_TIME_STANDING, - * holding still meanwhile, and only while making no trouble: rioters, runners, fighters, anyone - * threatening staff and anyone wrecking their cell must be put down first. Cuffed, they drop what - * they hold and do nothing but stand or sit where they are and complain: no blows, threats, - * fights, climbing, smashing, eating or activities, and a runner's loose clock waits. Anyone can - * drag them, and a member can take them through the wing's staff doors (outpost_prisoner_escorted()). - * Cuffs kept on past PRISONER_CUFFED_GRACE sour them and stop their pay like a lock-in, unless it - * is for good reason: the wing is in protective custody, or they owe lockdown. A member takes the - * cuffs off from the talk menu ("Uncuff"), or with an empty hand while they are down: real cuffs go - * to the member's hand, cable and zipties are cut away. - * The cuffs drop to the floor when the prisoner dies, beams out or is deleted. + * Cuffs are tg's own handcuffs, cable restraints and zipties, put on through tg's cuffing (4 seconds, + * its sounds and messages). Only a member of the wing may cuff a prisoner (COMSIG_CARBON_CUFF_ATTEMPTED). + * Anyone making trouble on their feet (a rioter, a runner, a fighter, anyone squaring up to staff or + * climbing out) won't hold still: they step out of the cuffer's reach, which breaks the cuffing, so + * they get put down first. Cuffed, they can walk, as anyone can, but they choose to stand or sit where + * they are and complain: no blows, threats, fights, climbing, smashing, eating or activities, and a + * runner's loose clock waits. Anyone can drag them, and a member can take them through the wing's staff + * doors (outpost_prisoner_escorted()). Cuffs kept on past PRISONER_CUFFED_GRACE sour them and stop + * their pay like a lock-in, unless it is for good reason: the wing is in protective custody, or they owe + * lockdown. A member takes the cuffs off from the talk menu ("Uncuff"), or through the strip menu like + * anyone's: real cuffs go to the member's hand, cable and zipties are cut away. A body keeps its cuffs. * * Lockdown. A rioter shut in a cell, and a runner caught after getting out during a riot, owes * PRISON_RIOT_LOCKDOWN_TIME of lockdown. It counts down only while they are shut in a cell, and @@ -25,12 +24,7 @@ * outpost_prison_riot.dm). Only the ones shut in a cell owe lockdown. */ -/// Trait source for the cuffs holding a prisoner still -#define PRISONER_CUFFS_TRAIT "outpost_prisoner_cuffs" - -/mob/living/basic/outpost_prisoner - /// The cuffs on them, held in their contents - var/obj/item/restraints/handcuffs/cuffs +/mob/living/carbon/human/npc/outpost_prisoner /// Seconds cuffed without good reason; falls PRISONER_LOCKED_IN_RECOVERY seconds a second once uncuffed var/cuffed_seconds = 0 /// Someone is putting cuffs on them or taking them off @@ -46,199 +40,143 @@ // ===== CUFFING ===== -/// Why `user` can't put `restraints` on them now, as a balloon alert, or null if they can -/mob/living/basic/outpost_prisoner/proc/cuff_refusal(mob/living/user, obj/item/restraints/handcuffs/restraints) +/// Why `user` can't put cuffs on them now, as a balloon alert, or null if they can +/mob/living/carbon/human/npc/outpost_prisoner/proc/cuff_refusal(mob/living/user) if(!prison || !prison.is_member(user)) return "members only" if(stat == DEAD || phase != PRISONER_PRESENT) return "can't cuff [p_them()]" - if(cuffs) - return "already cuffed" - if(istype(restraints, /obj/item/restraints/handcuffs/cable/zipties/used)) - return "used up" - // On their feet only when they are making no trouble; rioters and runners go down first. - if(!is_down() && (trouble || threat_ref || swing_ref || climb_ref)) - return "won't hold still" return null -/// How long cuffing them takes: quicker when they are down -/mob/living/basic/outpost_prisoner/proc/cuff_time() - return (is_down() ? PRISONER_CUFF_TIME_DOWN : PRISONER_CUFF_TIME_STANDING) * bounty_cuff_mult() - -/// Cuffs used on them (from the item interaction signal): refused with a balloon alert, or put on in the background -/mob/living/basic/outpost_prisoner/proc/on_cuffs_used(mob/living/user, obj/item/restraints/handcuffs/restraints) - var/refusal = cuff_refusal(user, restraints) - if(!refusal && cuff_work) - refusal = "already busy" - if(refusal) - balloon_alert(user, refusal) - return ITEM_INTERACT_BLOCKING - INVOKE_ASYNC(src, PROC_REF(cuff_by), user, restraints) - return ITEM_INTERACT_SUCCESS +/// Making trouble on their feet: they won't stand still for the cuffs +/mob/living/carbon/human/npc/outpost_prisoner/proc/fights_the_cuffs() + return !is_down() && (trouble || threat_ref || swing_ref || climb_ref) /** - * `user` puts `restraints` on them over cuff_time(), with tg's cuff sounds. They hold still - * meanwhile (in_trouble() counts it, so their routine waits). Returns TRUE if they are cuffed. Sleeps. + * Someone starts cuffing them with tg's cuffs. Not a member: refused. Making trouble on their feet: + * they pull away out of reach, which breaks tg's cuffing do_after. Otherwise they hold still, and their + * routine waits while the cuffs go on. */ -/mob/living/basic/outpost_prisoner/proc/cuff_by(mob/living/user, obj/item/restraints/handcuffs/restraints) - if(cuff_work || cuff_refusal(user, restraints)) - return FALSE +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_cuff_attempt(datum/source, mob/living/user) + SIGNAL_HANDLER + var/refusal = cuff_refusal(user) + if(refusal) + balloon_alert(user, refusal) + return COMSIG_CARBON_CUFF_PREVENT + if(fights_the_cuffs() && !HAS_TRAIT(src, TRAIT_IMMOBILIZED)) + INVOKE_ASYNC(src, PROC_REF(pull_away_from), user) + return NONE cuff_work = TRUE ai_controller?.CancelActions() - user.visible_message(span_danger("[user] is trying to put [restraints] on [src]!"), span_danger("You try to put [restraints] on [src].")) - playsound(src, restraints.cuffsound, 30, TRUE, -2) - var/done = do_after(user, cuff_time(), target = src, timed_action_flags = IGNORE_SLOWDOWNS) - cuff_work = FALSE - if(QDELETED(src)) - return FALSE - // A borg's cuffs are part of its module: it dispenses a pair, as tg's cuffs do for borgs. - var/dispense = iscyborg(user) - if(!done || QDELETED(restraints) || (restraints.loc != user && !dispense) || cuff_refusal(user, restraints)) - balloon_alert(user, "failed to cuff!") - return FALSE - var/success_sound = restraints.cuffsuccesssound - if(!apply_cuffs(restraints, user, dispense)) - balloon_alert(user, "failed to cuff!") - return FALSE - playsound(src, success_sound, 30, TRUE, -2) - user.visible_message(span_notice("[user] handcuffs [src]."), span_notice("You handcuff [src].")) - log_combat(user, src, "handcuffed") - return TRUE + addtimer(VARSET_CALLBACK(src, cuff_work, FALSE), PRISONER_CUFF_TIME_STANDING + 1 SECONDS, TIMER_UNIQUE | TIMER_OVERRIDE) + return NONE + +/// A step out of `user`'s reach, as anyone twists away from someone with cuffs +/mob/living/carbon/human/npc/outpost_prisoner/proc/pull_away_from(mob/living/user) + if(QDELETED(src) || QDELETED(user) || stat != CONSCIOUS) + return + step_away(src, user) /** - * Puts `restraints` on them at once; zipties become their used pair, as on people. `user`, if - * any, gives them up from their hands, unless `dispense` (a borg), which puts on a new pair of the - * same kind instead. What they hold drops and whatever they were up to stops. Returns TRUE if they - * are cuffed. + * Cuffs went on: what they were up to stops. Called once the cuffs are on (the restrained trait + * handler, outpost_prison_prisoner.dm); their line comes from npc_bark(). */ -/mob/living/basic/outpost_prisoner/proc/apply_cuffs(obj/item/restraints/handcuffs/restraints, mob/living/user, dispense = FALSE) - if(cuffs || QDELETED(restraints) || stat == DEAD) - return FALSE - var/obj/item/restraints/handcuffs/worn = restraints - if(dispense) - var/dispensed_path = restraints.trashtype || restraints.type - worn = new dispensed_path(src) - else - if(user && !user.temporarilyRemoveItemFromInventory(restraints)) - return FALSE - if(restraints.trashtype) - var/trash_path = restraints.trashtype - worn = new trash_path(src) - qdel(restraints) - else - restraints.forceMove(src) - if(QDELETED(worn) || worn.loc != src) - return FALSE - cuffs = worn - ADD_TRAIT(src, TRAIT_IMMOBILIZED, PRISONER_CUFFS_TRAIT) - if(has_shiv()) - drop_shiv() - else - drop_held_item() +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_cuffed() + cuff_work = FALSE end_activity() cancel_threat() stop_climb(fell = TRUE) // The breakout AI too: a caught runner stays put until walked home. ai_controller?.CancelActions() - // Draggable now: whatever trouble they were in stops (on_downed()). - update_drag_resistance() - update_appearance(UPDATE_OVERLAYS) update_bubble() - if(stat == CONSCIOUS) - say_context("cuffed") - return TRUE // ===== UNCUFFING ===== /** - * A member takes the cuffs off by hand, over PRISONER_UNCUFF_TIME. Returns TRUE if + * A member takes the cuffs off by hand from the talk menu, over PRISONER_UNCUFF_TIME. Returns TRUE if * they came off. Sleeps. */ -/mob/living/basic/outpost_prisoner/proc/uncuff_by(mob/living/user) - if(!cuffs || cuff_work) +/mob/living/carbon/human/npc/outpost_prisoner/proc/uncuff_by(mob/living/user) + var/obj/item/restraints/handcuffs/worn = handcuffed + if(!worn || cuff_work) return FALSE cuff_work = TRUE - user.visible_message(span_notice("[user] starts taking [cuffs] off [src]."), span_notice("You start taking [cuffs] off [src].")) + user.visible_message(span_notice("[user] starts taking [worn] off [src]."), span_notice("You start taking [worn] off [src].")) var/done = do_after(user, PRISONER_UNCUFF_TIME, target = src) cuff_work = FALSE - if(!done || QDELETED(src) || !cuffs || !prison?.is_member(user)) + if(!done || QDELETED(src) || handcuffed != worn || !prison?.is_member(user)) return FALSE - var/cut = istype(cuffs, /obj/item/restraints/handcuffs/cable) - user.visible_message(span_notice("[user] [cut ? "cuts" : "takes"] [cuffs] off [src]."), span_notice("You [cut ? "cut" : "take"] [cuffs] off [src].")) + var/cut = istype(worn, /obj/item/restraints/handcuffs/cable) + user.visible_message(span_notice("[user] [cut ? "cuts" : "takes"] [worn] off [src]."), span_notice("You [cut ? "cut" : "take"] [worn] off [src].")) remove_cuffs(user) - if(stat == CONSCIOUS) - say_context("cuffs_off") return TRUE /** - * Takes their cuffs off. With `user`, real cuffs go to their hands (or the floor) and cable - * restraints and zipties are cut away; without, the cuffs drop where they are. Returns the cuffs - * still about, or null. + * Takes their cuffs off through tg's uncuff(). With `user`, real cuffs go to their hands (or the floor) + * and cable restraints and zipties are cut away; without, the cuffs drop where they are. Returns the + * cuffs still about, or null. */ -/mob/living/basic/outpost_prisoner/proc/remove_cuffs(mob/living/user) - var/obj/item/restraints/handcuffs/worn = cuffs +/mob/living/carbon/human/npc/outpost_prisoner/proc/remove_cuffs(mob/living/user) + var/obj/item/restraints/handcuffs/worn = handcuffed if(!worn) return null - // Cleared first, so Exited() leaves the rest to this proc. - cuffs = null + uncuff() + if(QDELETED(worn)) + return null if(user && istype(worn, /obj/item/restraints/handcuffs/cable)) qdel(worn) - worn = null - else - worn.forceMove(drop_location()) - if(user) - user.put_in_hands(worn) - uncuffed() + return null + user?.put_in_hands(worn) return worn /// The cuffs are off, however they came off -/mob/living/basic/outpost_prisoner/proc/uncuffed() - REMOVE_TRAIT(src, TRAIT_IMMOBILIZED, PRISONER_CUFFS_TRAIT) +/mob/living/carbon/human/npc/outpost_prisoner/proc/uncuffed() if(QDELETED(src)) return - update_drag_resistance() - update_appearance(UPDATE_OVERLAYS) prison?.refresh_prisoner_reach(src) -// ===== CUFFED ===== +/// Cuffs on or off (tg's TRAIT_RESTRAINED) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_restraint_changed() + if(QDELETED(src)) + return + if(handcuffed) + INVOKE_ASYNC(src, PROC_REF(on_cuffed)) + else + INVOKE_ASYNC(src, PROC_REF(uncuffed)) -// Cuffed hands throw no punches, whatever their AI wants. -/mob/living/basic/outpost_prisoner/early_melee_attack(atom/target, list/modifiers, ignore_cooldown = FALSE) - if(cuffs) - return FALSE - return ..() +// ===== CUFFED ===== /// Whether their cuffs are souring them and stopping their pay: on past PRISONER_CUFFED_GRACE, and not for good reason -/mob/living/basic/outpost_prisoner/proc/cuffs_souring() +/mob/living/carbon/human/npc/outpost_prisoner/proc/cuffs_souring() return cuffed_seconds > PRISONER_CUFFED_GRACE && !prison?.restraint_justified(src) /// Whether restraining a prisoner is for good reason right now, so it costs them nothing: protective custody, or lockdown they owe -/datum/outpost_prison/proc/restraint_justified(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/restraint_justified(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return prisoner.lockdown_left > 0 || protective_custody() /** * Advances a prisoner's cuffed clock by `seconds`: up while cuffed without good reason, and down * PRISONER_LOCKED_IN_RECOVERY seconds a second once the cuffs are off, like the lock-in clock. */ -/datum/outpost_prison/proc/update_cuffed(mob/living/basic/outpost_prisoner/prisoner, seconds) - if(prisoner.cuffs) +/datum/outpost_prison/proc/update_cuffed(mob/living/carbon/human/npc/outpost_prisoner/prisoner, seconds) + if(prisoner.handcuffed) if(!restraint_justified(prisoner)) prisoner.cuffed_seconds += seconds return prisoner.cuffed_seconds = max(0, prisoner.cuffed_seconds - PRISONER_LOCKED_IN_RECOVERY * seconds) -/// Cuffed, they plan nothing: no routine, no trouble, no patrol, no doors. First on both prisoner controllers. +/// Cuffed, they choose to plan nothing: no routine, no trouble, no patrol, no doors. First on both prisoner controllers. /datum/ai_planning_subtree/outpost_prisoner_cuffed /datum/ai_planning_subtree/outpost_prisoner_cuffed/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn - if(istype(prisoner) && prisoner.cuffs) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn + if(istype(prisoner) && prisoner.handcuffed) return SUBTREE_RETURN_FINISH_PLANNING // ===== LOCKDOWN ===== /// They owe PRISON_RIOT_LOCKDOWN_TIME of lockdown from now. `quiet` leaves the line to the caller. -/datum/outpost_prison/proc/start_lockdown(mob/living/basic/outpost_prisoner/prisoner, quiet = FALSE) +/datum/outpost_prison/proc/start_lockdown(mob/living/carbon/human/npc/outpost_prisoner/prisoner, quiet = FALSE) prisoner.lockdown_left = PRISON_RIOT_LOCKDOWN_TIME prisoner.lockdown_out = 0 add_log("[prisoner.real_name] is on lockdown.") @@ -251,7 +189,7 @@ * out early. Down, cuffed or loose, the grace waits, and so does it while the cell they were serving * in is still bolted: they got out through a hole, and nobody let them out. */ -/datum/outpost_prison/proc/lockdown_tick(mob/living/basic/outpost_prisoner/prisoner, seconds) +/datum/outpost_prison/proc/lockdown_tick(mob/living/carbon/human/npc/outpost_prisoner/prisoner, seconds) if(prisoner.lockdown_left <= 0) prisoner.lockdown_out = 0 prisoner.lockdown_cell_ref = null @@ -274,7 +212,7 @@ let_out_early(prisoner) /// Served: the warden's log notes it once, and the prisoner says so. The usual lock-in rules apply again. -/datum/outpost_prison/proc/lockdown_served(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/lockdown_served(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.lockdown_left = 0 prisoner.lockdown_out = 0 prisoner.lockdown_cell_ref = null @@ -286,7 +224,7 @@ * Let out before the lockdown was served: logged once, and they riot again, joining a riot * that is on or starting one whatever the quiet after the last. Returns TRUE if they are rioting. */ -/datum/outpost_prison/proc/let_out_early(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/let_out_early(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.lockdown_left = 0 prisoner.lockdown_out = 0 add_log("[prisoner.real_name] was let out before their lockdown was up.") @@ -309,12 +247,10 @@ // ===== WHERE STAFF LOOK ===== -/mob/living/basic/outpost_prisoner/examine(mob/user) +/mob/living/carbon/human/npc/outpost_prisoner/examine(mob/user) . = ..() if(stat == DEAD) return - if(cuffs) - . += span_warning("[p_They()] [p_are()] handcuffed.") if(lockdown_left > 0) . += span_notice("On lockdown for [outpost_prison_lockdown_text(lockdown_left)].") @@ -324,7 +260,7 @@ var/datum/outpost_prison_cell/cell = prison?.cell_by_number(cell_number) if(!cell) return - for(var/mob/living/basic/outpost_prisoner/prisoner in prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prison.prisoners) if(prisoner.lockdown_left <= 0 || prisoner.stat == DEAD || prisoner.phase != PRISONER_PRESENT) continue var/inside = cell.contains(prisoner) @@ -335,4 +271,3 @@ line += " [prisoner.p_They()] [prisoner.p_are()] not in the cell." . += span_notice(line) -#undef PRISONER_CUFFS_TRAIT diff --git a/voidcrew/modules/player_outposts/outpost_prison_changeling.dm b/voidcrew/modules/player_outposts/outpost_prison_changeling.dm index c621aff305f..93280d90792 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_changeling.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_changeling.dm @@ -71,7 +71,7 @@ * calls this once the host has eaten the tainted food. Returns the event, or null if it cannot * start (a dead host, someone else's prisoner, or a changeling already running in the wing). */ -/proc/outpost_changeling_infect(mob/living/basic/outpost_prisoner/host, datum/outpost_prison/prison) +/proc/outpost_changeling_infect(mob/living/carbon/human/npc/outpost_prisoner/host, datum/outpost_prison/prison) if(QDELETED(host) || QDELETED(prison) || host.stat == DEAD || host.prison != prison) return null if(prison.active_changeling && prison.active_changeling.stage != "done") @@ -89,7 +89,7 @@ /// Why it ended, once it has var/end_reason var/datum/outpost_prison/prison - var/mob/living/basic/outpost_prisoner/host + var/mob/living/carbon/human/npc/outpost_prisoner/host var/mob/living/basic/headslug/beakless/outpost/slug var/mob/living/basic/outpost_experiment/horror/horror /// The vent the slug is in, or the one that is giving way @@ -141,7 +141,7 @@ /// Prisoners whose deaths are watched while a creature is loose var/list/watched_prisoners = list() -/datum/outpost_changeling_event/New(mob/living/basic/outpost_prisoner/new_host, datum/outpost_prison/new_prison) +/datum/outpost_changeling_event/New(mob/living/carbon/human/npc/outpost_prisoner/new_host, datum/outpost_prison/new_prison) . = ..() prison = new_prison prison.active_changeling = src @@ -256,7 +256,7 @@ // ===== THE HOST ===== -/datum/outpost_changeling_event/proc/link_host(mob/living/basic/outpost_prisoner/new_host) +/datum/outpost_changeling_event/proc/link_host(mob/living/carbon/human/npc/outpost_prisoner/new_host) host = new_host host.experiment_subject = TRUE host.update_bubble() @@ -267,7 +267,7 @@ /datum/outpost_changeling_event/proc/release_host(cured = FALSE) if(!host) return - var/mob/living/basic/outpost_prisoner/was_host = host + var/mob/living/carbon/human/npc/outpost_prisoner/was_host = host host = null UnregisterSignal(was_host, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING)) was_host.remove_traits(list(TRAIT_FLOORED, TRAIT_INCAPACITATED, TRAIT_IMMOBILIZED), CHANGELING_HOST_TRAIT) @@ -319,7 +319,7 @@ if(!said_pain && incubation_elapsed >= OUTPOST_CHANGELING_TELL_PAIN) said_pain = TRUE INVOKE_ASYNC(host, TYPE_PROC_REF(/atom, manual_emote), "rubs [host.p_their()] stomach and winces.") - INVOKE_ASYNC(host, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "experiment_dose") + INVOKE_ASYNC(host, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "experiment_dose") prison.add_log("[host.real_name] complains of stomach pain.") return if(!said_retch && incubation_elapsed >= OUTPOST_CHANGELING_TELL_RETCH) @@ -386,7 +386,7 @@ INVOKE_ASYNC(host, TYPE_PROC_REF(/atom, manual_emote), pick("groans.", "moans.", "whimpers.")) if(!prob(30)) return - for(var/mob/living/basic/outpost_prisoner/neighbour in shuffle(prison.prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/neighbour in shuffle(prison.prisoners)) if(neighbour == host || neighbour.stat != CONSCIOUS || neighbour.phase != PRISONER_PRESENT || !(host in view(6, neighbour))) continue INVOKE_ASYNC(neighbour, TYPE_PROC_REF(/atom, manual_emote), pick("stares at [host].", "backs away from [host].", "covers [neighbour.p_their()] mouth.", "calls out to [host]. No answer.")) @@ -427,7 +427,7 @@ if(!spot) lose_specimen("the host is gone") return FALSE - var/mob/living/basic/outpost_prisoner/victim = host + var/mob/living/carbon/human/npc/outpost_prisoner/victim = host release_host() stage = "burst" early_burst_left = 0 @@ -495,7 +495,7 @@ /// Prisoners who saw it scream /datum/outpost_changeling_event/proc/scream_near(turf/spot) var/screamed = 0 - for(var/mob/living/basic/outpost_prisoner/witness in view(7, spot)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/witness in view(7, spot)) if(witness.stat != CONSCIOUS || witness.phase != PRISONER_PRESENT || witness.can_be_dragged()) continue INVOKE_ASYNC(witness, TYPE_PROC_REF(/atom, manual_emote), pick("screams!", "shrieks and scrambles back!", "yells in horror!")) @@ -656,13 +656,13 @@ if(level < 2 || !prison.wing_can_speak()) return FALSE var/context = level >= 3 ? "creature_panic" : "vent_noise" - for(var/mob/living/basic/outpost_prisoner/listener in shuffle(prison.prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/listener in shuffle(prison.prisoners)) if(listener.stat != CONSCIOUS || listener.phase != PRISONER_PRESENT || listener.can_be_dragged() || !listener.ai_running()) continue if(get_dist(listener, vent) > 7) continue prison.note_speech() - INVOKE_ASYNC(listener, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), context) + INVOKE_ASYNC(listener, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), context) return TRUE return FALSE @@ -813,13 +813,13 @@ /datum/outpost_changeling_event/proc/horror_remark(context) if(QDELETED(horror) || QDELETED(prison) || !prison.wing_can_speak()) return FALSE - for(var/mob/living/basic/outpost_prisoner/witness in shuffle(prison.prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/witness in shuffle(prison.prisoners)) if(witness.stat != CONSCIOUS || witness.phase != PRISONER_PRESENT || witness.can_be_dragged() || !witness.ai_running()) continue if(!(horror in view(7, witness))) continue prison.note_speech() - INVOKE_ASYNC(witness, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), context) + INVOKE_ASYNC(witness, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), context) return TRUE return FALSE @@ -909,7 +909,7 @@ /datum/outpost_changeling_event/proc/panic_prisoners() panicked = TRUE var/shouts = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prison.prisoners) if(prisoner == host || !prisoner.routine_allowed() || prisoner.is_confined()) continue if(istype(prisoner.activity, /datum/prisoner_activity/flee_creature) || istype(prisoner.activity, /datum/prisoner_activity/creature_panic)) @@ -921,11 +921,11 @@ prisoner.start_activity(flee) if(shouts < 2 && prisoner.ai_running()) shouts++ - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "creature_panic") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "creature_panic") /// Watches the prisoners' deaths while a creature is loose /datum/outpost_changeling_event/proc/watch_prisoners() - for(var/mob/living/basic/outpost_prisoner/prisoner in prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prison.prisoners) if(prisoner.stat == DEAD || watched_prisoners[prisoner]) continue watched_prisoners[prisoner] = TRUE @@ -933,7 +933,7 @@ RegisterSignal(prisoner, COMSIG_QDELETING, PROC_REF(on_watched_prisoner_deleted)) /datum/outpost_changeling_event/proc/unwatch_prisoners() - for(var/mob/living/basic/outpost_prisoner/prisoner as anything in watched_prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner as anything in watched_prisoners) UnregisterSignal(prisoner, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING)) watched_prisoners.Cut() @@ -943,7 +943,7 @@ watched_prisoners -= source /// A prisoner died while a creature was loose: the creature's kill if it was beside them -/datum/outpost_changeling_event/proc/on_prisoner_died(mob/living/basic/outpost_prisoner/victim, gibbed) +/datum/outpost_changeling_event/proc/on_prisoner_died(mob/living/carbon/human/npc/outpost_prisoner/victim, gibbed) SIGNAL_HANDLER var/mob/living/creature = current_creature() note_death(victim, creature && get_dist(creature, victim) <= 2) @@ -962,7 +962,7 @@ var/turf/spot = get_turf(victim) if(!spot) return TRUE - for(var/mob/living/basic/outpost_prisoner/witness in view(7, spot)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/witness in view(7, spot)) if(witness == victim || witness.stat != CONSCIOUS || witness.phase != PRISONER_PRESENT) continue witnesses |= WEAKREF(witness) @@ -978,7 +978,7 @@ if(QDELETED(prison)) return for(var/datum/weakref/ref as anything in witnesses) - var/mob/living/basic/outpost_prisoner/witness = ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/witness = ref.resolve() if(QDELETED(witness) || witness.stat == DEAD || !(witness in prison.prisoners)) continue witness.adjust_mood(-OUTPOST_CHANGELING_WITNESS_MOOD) diff --git a/voidcrew/modules/player_outposts/outpost_prison_conditions.dm b/voidcrew/modules/player_outposts/outpost_prison_conditions.dm index 287e720cf6f..46ce61e5564 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_conditions.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_conditions.dm @@ -24,9 +24,7 @@ /// How often mess, air and rats are refreshed, in seconds #define PRISON_MESS_REFRESH_SECONDS 5 -/// Seconds per step of damage from bad air -#define PRISON_AIR_STEP_SECONDS 5 -/// Percent chance a prisoner hurt by bad air says so +/// Percent chance a prisoner on bad air says so, each check #define PRISON_AIR_LINE_CHANCE 15 /// Mess weight by type (see PRISON_MESS_WEIGHT_*); types not listed are not mess @@ -530,7 +528,7 @@ GLOBAL_LIST_INIT(outpost_prison_furniture_types, zebra_typecacheof(list( /datum/outpost_prison/proc/check_battery() var/battery = battery_percent() if(!isnull(battery) && !isnull(last_battery) && last_battery >= PRISON_BATTERY_WARNING && battery < PRISON_BATTERY_WARNING) - var/mob/living/basic/outpost_prisoner/speaker = pick_conditions_speaker() + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick_conditions_speaker() if(speaker?.say_context("lights_flicker")) note_speech() last_battery = battery @@ -572,7 +570,7 @@ GLOBAL_LIST_INIT(outpost_prison_furniture_types, zebra_typecacheof(list( * no power costs in proportion; a clean, lit, powered wing is a small comfort; and a dark cell of * their own costs a little more. */ -/mob/living/basic/outpost_prisoner/proc/wing_mood_per_minute() +/mob/living/carbon/human/npc/outpost_prisoner/proc/wing_mood_per_minute() var/loss = 0 var/gain = 0 if(prison && trouble != PRISONER_TROUBLE_LOOSE) @@ -587,20 +585,18 @@ GLOBAL_LIST_INIT(outpost_prison_furniture_types, zebra_typecacheof(list( // ===== AIR ===== -/// Prisoners on bad air lose a little health every PRISON_AIR_STEP_SECONDS, never below PRISON_AIR_HEALTH_FLOOR percent +/** + * Bad air is tg's own: prisoners breathe like anyone, and a breach, a fire or a plasma leak hurts them + * the way it hurts the crew. Each check, a prisoner on bad air may say so, and goes for a mask and a tank + * from the cell block's emergency locker (outpost_prison_breach.dm); once they wear one, they breathe + * from it while the air is bad. + */ /datum/outpost_prison/proc/air_tick(seconds) - var/steps = max(1, round(seconds / PRISON_AIR_STEP_SECONDS)) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD || !outpost_prison_unsafe_air(prisoner.loc?.return_air())) continue - var/hurt = FALSE - for(var/i in 1 to steps) - var/above_floor = prisoner.health - prisoner.maxHealth * PRISON_AIR_HEALTH_FLOOR / 100 - if(above_floor <= 0) - break - prisoner.adjustBruteLoss(min(PRISON_AIR_DAMAGE, above_floor)) - hurt = TRUE - if(hurt && prisoner.stat == CONSCIOUS && prob(PRISON_AIR_LINE_CHANCE) && wing_can_speak() && prisoner.say_context("no_air")) + prisoner.on_bad_air() + if(prisoner.stat == CONSCIOUS && prob(PRISON_AIR_LINE_CHANCE) && wing_can_speak() && prisoner.say_context("no_air")) note_speech() /// Whether air is too thin, too thick, too hot, too cold or too full of plasma to live in @@ -652,7 +648,7 @@ GLOBAL_LIST_INIT(outpost_prison_furniture_types, zebra_typecacheof(list( var/mob/living/basic/mouse/rat = new(spot) rat_refs += WEAKREF(rat) log_game("PLAYER OUTPOST PRISON: a rat turned up in the prison wing at '[outpost?.name]'") - for(var/mob/living/basic/outpost_prisoner/prisoner in view(7, spot)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in view(7, spot)) if(prisoner.prison == src && prisoner.stat == CONSCIOUS && prisoner.say_context("rat")) note_speech() break @@ -661,7 +657,7 @@ GLOBAL_LIST_INIT(outpost_prison_furniture_types, zebra_typecacheof(list( /// Someone in the wing to remark on its state /datum/outpost_prison/proc/pick_conditions_speaker() var/list/candidates = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase == PRISONER_PRESENT && prisoner.stat == CONSCIOUS && prisoner.trouble != PRISONER_TROUBLE_LOOSE) candidates += prisoner return length(candidates) ? pick(candidates) : null diff --git a/voidcrew/modules/player_outposts/outpost_prison_containment.dm b/voidcrew/modules/player_outposts/outpost_prison_containment.dm index 077e9c53675..2e9538328ab 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_containment.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_containment.dm @@ -116,7 +116,7 @@ * pulled by a member of the wing who may use the door. Visitors let in may use the door, but * never take a prisoner with them, so nobody but the wing's own people can drag one off the outpost. */ -/proc/outpost_prisoner_escorted(atom/door, mob/living/basic/outpost_prisoner/prisoner) +/proc/outpost_prisoner_escorted(atom/door, mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!istype(prisoner) || !prisoner.can_be_dragged()) return FALSE var/mob/living/puller = prisoner.pulledby @@ -165,7 +165,7 @@ /// Refreshes where every prisoner can walk and reach. The prisoners share one look at each tile. /datum/outpost_prison/proc/refresh_reach() var/list/standable = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) refresh_prisoner_reach(prisoner, standable) /** @@ -174,7 +174,7 @@ * seen through the office windows is not. `standable` (turf = whether a prisoner can stand there) * is shared by every prisoner in one refresh, so each tile is looked at once. */ -/datum/outpost_prison/proc/refresh_prisoner_reach(mob/living/basic/outpost_prisoner/prisoner, list/standable) +/datum/outpost_prison/proc/refresh_prisoner_reach(mob/living/carbon/human/npc/outpost_prisoner/prisoner, list/standable) var/list/walked = list() var/list/reach = list() var/turf/start = get_turf(prisoner) @@ -244,6 +244,8 @@ if(!found[tile] && !isclosedturf(tile)) staff_ground[tile] = TRUE record_layout() + // Masks and tanks for a breach (outpost_prison_breach.dm) + ensure_emergency_lockers() /** * Whether the cell block flood carries on through a tile. Only the wing's structure stops it: @@ -413,49 +415,52 @@ /** * Hooks up what keeps a prisoner contained; called from Initialize(). Teleports fail, forced ones - * included (drop pods, quantum pads); polymorph and type changes fail; and a dead prisoner stays - * dead: anything that tries to bring them back gets the body collected at once instead. - * TRAIT_NO_TRANSFORM would stop polymorph too, but it also stops their AI moving them. + * included (drop pods, quantum pads); polymorph and type changes fail. A dead prisoner can be brought + * back like anyone (on_revived()). TRAIT_NO_TRANSFORM would stop polymorph too, but it also stops their + * AI moving them. */ -/mob/living/basic/outpost_prisoner/proc/setup_containment() +/mob/living/carbon/human/npc/outpost_prisoner/proc/setup_containment() RegisterSignal(src, COMSIG_MOVABLE_TELEPORTING, PROC_REF(refuse_teleport)) RegisterSignal(src, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) - RegisterSignal(src, COMSIG_LIVING_REVIVE, PROC_REF(on_revive_attempt)) + RegisterSignal(src, COMSIG_LIVING_REVIVE, PROC_REF(on_revived)) -/mob/living/basic/outpost_prisoner/proc/refuse_teleport(datum/source, atom/destination, channel) +/mob/living/carbon/human/npc/outpost_prisoner/proc/refuse_teleport(datum/source, atom/destination, channel) SIGNAL_HANDLER if(isturf(loc)) visible_message(span_notice("[src] flickers for a moment, but stays where [p_they()] [p_are()].")) return TRUE -/mob/living/basic/outpost_prisoner/proc/refuse_polymorph(datum/source, what_to_randomize) +/mob/living/carbon/human/npc/outpost_prisoner/proc/refuse_polymorph(datum/source, what_to_randomize) SIGNAL_HANDLER visible_message(span_notice("[src] shimmers for a moment, then looks the same as before.")) return STOP_WABBAJACK -/mob/living/basic/outpost_prisoner/proc/refuse_type_change(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/proc/refuse_type_change(datum/source) SIGNAL_HANDLER return COMPONENT_BLOCK_MOB_CHANGE -/// The dead stay dead: the corrections service has already logged the death, whether or not the body is still in the cell block -/mob/living/basic/outpost_prisoner/can_be_revived() - if(died_at || (prison && phase == PRISONER_PRESENT)) - return FALSE - return ..() - -/// Someone tried to bring a dead prisoner back -/mob/living/basic/outpost_prisoner/proc/on_revive_attempt(datum/source, full_heal_flags) +/** + * Brought back (a defibrillator, strange reagent: tg's revival works on them as on anyone). Still on the + * roster, they are the same inmate again: the death stays on the books (its record closed, the yard's + * blame not taken back) and the log says so. Revived after their body left the roster, the corrections + * service takes them back off the outpost. + */ +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_revived(datum/source, full_heal_flags) SIGNAL_HANDLER - if(stat != DEAD || !prison || phase != PRISONER_PRESENT) + if(prison && phase == PRISONER_PRESENT) + if(died_at) + prison.add_log("[real_name] was revived. The corrections service has already logged the death.") + died_at = 0 return - prison.add_log("Someone tried to revive [real_name]. The corrections service has already logged the death.") + if(phase != PRISONER_LEAVING) + INVOKE_ASYNC(src, PROC_REF(beam_out)) /** * Whether they are shut in their cell: standing in a cell with its door bolted shut, or with * nowhere to walk but the cell and its doorway (a welded, walled-in or unpowered door). */ -/mob/living/basic/outpost_prisoner/proc/is_confined() +/mob/living/carbon/human/npc/outpost_prisoner/proc/is_confined() var/datum/outpost_prison_cell/holding = prison?.cell_at(get_turf(src)) if(!holding) return FALSE diff --git a/voidcrew/modules/player_outposts/outpost_prison_contraband.dm b/voidcrew/modules/player_outposts/outpost_prison_contraband.dm index b61ff885838..98cb3a30eb6 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_contraband.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_contraband.dm @@ -39,7 +39,7 @@ /// world.time a search of this cell last cost its owner mood var/searched_at = 0 -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Seconds their mood has stayed low enough to make a shiv, and to brew pruno var/contraband_shiv_sour = 0 var/contraband_brew_sour = 0 @@ -62,12 +62,12 @@ // ===== THE PRISONER'S SIDE ===== /// Hooks up the prisoner's side of contraband and mail (the letter hand-over); called from setup_extras() -/mob/living/basic/outpost_prisoner/proc/setup_contraband() +/mob/living/carbon/human/npc/outpost_prisoner/proc/setup_contraband() // An element, since the prisoner's own handler for item use already takes the signal (outpost_prison_mail.dm) AddElement(/datum/element/outpost_prison_mail_handover) /// The sour clocks, the drink wearing off and the drunk's sway -/mob/living/basic/outpost_prisoner/proc/contraband_counters(seconds) +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_counters(seconds) // Each sour clock counts while mood is under its line, holds just above it, and resets once they cheer up if(mood >= OUTPOST_CONTRABAND_SOUR_RESET) contraband_shiv_sour = 0 @@ -87,19 +87,19 @@ if(stat == CONSCIOUS && isturf(loc) && !buckled) Shake(1, 0, 0.5 SECONDS) -/mob/living/basic/outpost_prisoner/proc/contraband_drunk() +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_drunk() return contraband_drunk_left > 0 /// Whether they have been sour long enough to make a shiv, and their cell has none -/mob/living/basic/outpost_prisoner/proc/contraband_can_make_shiv() +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_can_make_shiv() return cell && !cell.stash_shiv && contraband_shiv_sour >= OUTPOST_CONTRABAND_SOUR_TIME /// Whether they have been grumbling long enough to brew, and their cell's cistern has no pruno -/mob/living/basic/outpost_prisoner/proc/contraband_can_brew() +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_can_brew() return cell && contraband_brew_sour >= OUTPOST_CONTRABAND_BREW_SOUR_TIME && !prison?.contraband_pruno(cell) /// Hides `thing` (a razor blade or yeast from the mail) on them until they can stash it. FALSE if they already carry something. -/mob/living/basic/outpost_prisoner/proc/contraband_carry(obj/item/thing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_carry(obj/item/thing) if(QDELETED(thing) || carried_contraband) return FALSE thing.forceMove(src) @@ -110,12 +110,12 @@ return TRUE /// Forgets what they carried, leaving the item wherever it is -/mob/living/basic/outpost_prisoner/proc/contraband_clear_carried() +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_clear_carried() if(carried_contraband) UnregisterSignal(carried_contraband, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) carried_contraband = null -/mob/living/basic/outpost_prisoner/proc/on_carried_contraband_moved(obj/item/source) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_carried_contraband_moved(obj/item/source) SIGNAL_HANDLER if(source != carried_contraband) UnregisterSignal(source, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) @@ -124,7 +124,7 @@ contraband_clear_carried() /// A new arrival finds what the last occupant left and says so, if it is still there -/mob/living/basic/outpost_prisoner/proc/contraband_remark_inherited() +/mob/living/carbon/human/npc/outpost_prisoner/proc/contraband_remark_inherited() if(stat != CONSCIOUS || phase != PRISONER_PRESENT || !cell || in_trouble()) return FALSE if(!cell.stash_shiv && !prison?.contraband_pruno(cell)) @@ -139,7 +139,7 @@ /// Sour clocks, shiv making, brewing and drinking, and the cells' beds and toilets /datum/outpost_prison/proc/contraband_tick(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD) continue prisoner.contraband_counters(seconds) @@ -151,12 +151,12 @@ return contraband_clock = 0 contraband_hook_fixtures() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) continue // Nobody on the level: whatever came in the mail is stashed wherever they are, unless they are cuffed if(prisoner.carried_contraband && !prisoner.ai_running()) - if(!prisoner.cuffs) + if(!prisoner.handcuffed) contraband_stash_carried(prisoner) continue contraband_try_start(prisoner) @@ -166,7 +166,7 @@ * they are free, nothing more pressing has them busy and no staff are in sight. Returns the * activity started, or null. */ -/datum/outpost_prison/proc/contraband_try_start(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_try_start(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.ai_running() || !prisoner.routine_allowed() || !prisoner.cell) return null var/datum/prisoner_activity/current = prisoner.activity @@ -188,14 +188,14 @@ return stash /// Staff (on-duty guards included) who can see `prisoner`, or null -/datum/outpost_prison/proc/contraband_watcher(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_watcher(mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/mob/living/person in view(OUTPOST_CONTRABAND_WATCH_RANGE, prisoner)) if(is_outpost_prison_staff(person)) return person return null /// Caught at it: they stop, deny it, and have to sour all over again before the next try -/datum/outpost_prison/proc/contraband_caught(mob/living/basic/outpost_prisoner/prisoner, mob/living/watcher, shiv = TRUE) +/datum/outpost_prison/proc/contraband_caught(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/watcher, shiv = TRUE) if(shiv) prisoner.contraband_shiv_sour = 0 else @@ -268,7 +268,7 @@ return TRUE /// A prisoner drinks the pruno in their cell's cistern: a lift, then a while drunk. Returns TRUE if they drank. -/datum/outpost_prison/proc/contraband_drink(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_drink(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/obj/item/reagent_containers/cup/glass/bottle/pruno/bag = contraband_pruno(prisoner?.cell) if(!outpost_prison_pruno_ready(bag)) return FALSE @@ -281,7 +281,7 @@ return TRUE /// Home and unwatched, with the AI running: whatever came in the mail goes into hiding -/datum/outpost_prison/proc/contraband_try_stash_carried(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_try_stash_carried(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.cell?.contains(prisoner) || !prisoner.routine_allowed() || contraband_watcher(prisoner)) return FALSE return contraband_stash_carried(prisoner) @@ -291,7 +291,7 @@ * the cistern. A cell that already has that stash, or no room for it, leaves them carrying it. * Returns TRUE if it was stashed. */ -/datum/outpost_prison/proc/contraband_stash_carried(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_stash_carried(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/obj/item/thing = prisoner.carried_contraband var/datum/outpost_prison_cell/home = prisoner.cell if(!thing || thing.loc != prisoner || !home) @@ -366,7 +366,7 @@ return COMPONENT_CANCEL_ATTACK_CHAIN if(source.has_buckled_mobs()) // A prisoner lying on it can be told to get up from the talk menu, or shaken awake; the balloon only says who is in the way. - var/mob/living/basic/outpost_prisoner/lying = locate() in source.buckled_mobs + var/mob/living/carbon/human/npc/outpost_prisoner/lying = locate() in source.buckled_mobs source.balloon_alert(user, lying?.activity?.sleeping ? "someone's asleep on it" : "someone's lying on it") return COMPONENT_CANCEL_ATTACK_CHAIN INVOKE_ASYNC(src, PROC_REF(contraband_search_mattress), user, source) @@ -388,7 +388,7 @@ return null if(QDELETED(src) || QDELETED(bed) || QDELETED(user) || cell_at(get_turf(bed)) != cell) return null - var/mob/living/basic/outpost_prisoner/owner = cell.occupant + var/mob/living/carbon/human/npc/outpost_prisoner/owner = cell.occupant if(cell.stash_shiv) cell.stash_shiv = FALSE var/obj/item/knife/shiv/shiv = new(get_turf(user)) @@ -457,7 +457,7 @@ to_chat(user, span_notice("There's nothing in the cistern.")) else to_chat(user, span_notice("You find [english_list(found)] in the cistern.")) - var/mob/living/basic/outpost_prisoner/owner = cell.occupant + var/mob/living/carbon/human/npc/outpost_prisoner/owner = cell.occupant if(!caught) contraband_search_came_up_empty(owner, user, cell) return "empty" @@ -477,11 +477,11 @@ return TRUE /// Whether a cell's owner is there to take it: present, awake and not loose -/datum/outpost_prison/proc/contraband_owner_home(mob/living/basic/outpost_prisoner/owner) +/datum/outpost_prison/proc/contraband_owner_home(mob/living/carbon/human/npc/outpost_prisoner/owner) return !QDELETED(owner) && (owner in prisoners) && owner.stat == CONSCIOUS && owner.phase == PRISONER_PRESENT && owner.trouble != PRISONER_TROUBLE_LOOSE /// Something was found in `owner`'s cell by `finder`: they deny it and lose a little mood -/datum/outpost_prison/proc/contraband_owner_caught(mob/living/basic/outpost_prisoner/owner, mob/living/finder, datum/outpost_prison_cell/cell) +/datum/outpost_prison/proc/contraband_owner_caught(mob/living/carbon/human/npc/outpost_prisoner/owner, mob/living/finder, datum/outpost_prison_cell/cell) if(!contraband_owner_home(owner)) return FALSE if(contraband_search_costs(cell)) @@ -493,12 +493,12 @@ return TRUE /// A search of `owner`'s cell found nothing: the owner and anyone watching take it badly. An empty cell costs nothing. -/datum/outpost_prison/proc/contraband_search_came_up_empty(mob/living/basic/outpost_prisoner/owner, mob/living/searcher, datum/outpost_prison_cell/cell) +/datum/outpost_prison/proc/contraband_search_came_up_empty(mob/living/carbon/human/npc/outpost_prisoner/owner, mob/living/searcher, datum/outpost_prison_cell/cell) if(!contraband_owner_home(owner)) return FALSE if(contraband_search_costs(cell)) owner.adjust_mood(-OUTPOST_CONTRABAND_EMPTY_MOOD) - for(var/mob/living/basic/outpost_prisoner/onlooker in view(OUTPOST_CONTRABAND_WATCH_RANGE, searcher)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/onlooker in view(OUTPOST_CONTRABAND_WATCH_RANGE, searcher)) if(onlooker == owner || !(onlooker in prisoners) || onlooker.stat != CONSCIOUS || onlooker.phase != PRISONER_PRESENT) continue onlooker.adjust_mood(-OUTPOST_CONTRABAND_ONLOOKER_MOOD) @@ -530,13 +530,13 @@ // ===== THE PAT-DOWN ===== /// The pat-down choice for the talk menu: name -> image. Hands on the wall, or held still in cuffs. -/datum/outpost_prison/proc/contraband_talk_choices(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/contraband_talk_choices(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(!prisoner || !user || !is_member(user)) return list() return list((CONTRABAND_PATDOWN_CHOICE) = image(icon = 'voidcrew/icons/hud/radial.dmi', icon_state = "radial_search")) /// Runs a talk menu choice of this package; TRUE if it was one. May sleep. -/datum/outpost_prison/proc/contraband_talk_act(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, choice) +/datum/outpost_prison/proc/contraband_talk_act(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, choice) if(choice != CONTRABAND_PATDOWN_CHOICE) return FALSE contraband_pat_down(prisoner, user) @@ -549,12 +549,12 @@ * in it: rioting, loose or sour, they hold still and are searched, unless someone is already * talking to them or working on their cuffs. Returns "found", "empty" or null. Sleeps. */ -/datum/outpost_prison/proc/contraband_pat_down(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/contraband_pat_down(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(QDELETED(prisoner) || QDELETED(user) || !is_member(user) || !(prisoner in prisoners)) return null if(prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT) return null - if(prisoner.cuffs) + if(prisoner.handcuffed) if(prisoner.talking || prisoner.cuff_work) prisoner.balloon_alert(user, "busy") return null @@ -571,7 +571,7 @@ prisoner.talking = TRUE prisoner.end_activity() prisoner.stand_up() - if(prisoner.cuffs) + if(prisoner.handcuffed) // Their hands are cuffed already; nothing for them to put on the wall prisoner.face_atom(user) prisoner.manual_emote("stands still to be searched.") @@ -612,11 +612,11 @@ prisoner.say_context("patdown_empty") if(contraband_roll(OUTPOST_CONTRABAND_PATDOWN_ONLOOKER_CHANCE)) var/list/onlookers = list() - for(var/mob/living/basic/outpost_prisoner/onlooker in view(OUTPOST_CONTRABAND_ONLOOKER_RANGE, prisoner)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/onlooker in view(OUTPOST_CONTRABAND_ONLOOKER_RANGE, prisoner)) if(onlooker != prisoner && (onlooker in prisoners) && onlooker.stat == CONSCIOUS && onlooker.phase == PRISONER_PRESENT && !onlooker.in_trouble()) onlookers += onlooker if(length(onlookers)) - var/mob/living/basic/outpost_prisoner/speaker = pick(onlookers) + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick(onlookers) speaker.face_atom(user) speaker.say_context("patdown_onlooker", prisoner) note_staff_event(user, prisoner, "patdown_empty") @@ -637,35 +637,35 @@ return contraband_tension() > 0 ? "word of a shiv" : null /// How much higher `prisoner`'s riot line is (a shiv under their mattress, and some bounty prisoners: outpost_prison_bounty.dm) -/datum/outpost_prison/proc/riot_join_bonus(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/riot_join_bonus(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return (prisoner?.cell?.stash_shiv ? OUTPOST_CONTRABAND_RIOT_JOIN_BONUS : 0) + (prisoner ? prisoner.bounty_riot_bonus() : 0) /** * A rioter draws the shiv from their cell's stash instead of a new one; TRUE if they did. Only * start_rioting() calls this: a stash never arms a threat or a fight (decision 8). */ -/datum/outpost_prison/proc/draw_stashed_shiv(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/draw_stashed_shiv(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison_cell/home = prisoner?.cell if(!home?.stash_shiv || prisoner.has_shiv()) return FALSE home.stash_shiv = FALSE prisoner.drop_held_item() - var/obj/item/knife/shiv/shiv = new(prisoner) - prisoner.held_item = shiv - prisoner.update_melee() - prisoner.update_appearance(UPDATE_OVERLAYS) + // The stash comes out into their hand, a real shiv like any other. + var/obj/item/knife/shiv/shiv = new(prisoner.drop_location()) + if(!prisoner.take_item(shiv)) + return FALSE prisoner.manual_emote("pulls out a shiv [prisoner.p_they()] had been saving.") return TRUE /// Multiplier on the chance these two start a fight: either one drunk -/datum/outpost_prison/proc/contraband_fight_mult(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/contraband_fight_mult(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(one?.contraband_drunk() || two?.contraband_drunk()) return OUTPOST_CONTRABAND_DRUNK_FIGHT_MULT return 1 /// Multiplier on the wing's chance of a spat: anyone drunk /datum/outpost_prison/proc/contraband_spat_mult() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase == PRISONER_PRESENT && prisoner.stat == CONSCIOUS && prisoner.contraband_drunk()) return OUTPOST_CONTRABAND_DRUNK_SPAT_MULT return 1 @@ -673,13 +673,13 @@ // ===== SPEECH AND EXAMINE ===== /// A drunk line `prisoner` wants to say now, as list(context, other), or null -/datum/outpost_prison/proc/contraband_extra_speech(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_extra_speech(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner?.contraband_drunk() && prob(50)) return list("drunk", null) return null /// The tells for examine: a hand kept in a pocket, a drunk's sway; or null -/datum/outpost_prison/proc/contraband_examine(mob/living/basic/outpost_prisoner/prisoner, mob/user) +/datum/outpost_prison/proc/contraband_examine(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/user) if(!prisoner || prisoner.stat == DEAD) return null var/list/tells = list() @@ -699,22 +699,22 @@ UnregisterSignal(thing, list(COMSIG_ATOM_ATTACK_HAND, COMSIG_ATOM_ATTACK_HAND_SECONDARY, COMSIG_ATOM_EXITED)) contraband_hooked.Cut() // Prisoners deleted with the prison never pass through forget() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) var/obj/item/thing = prisoner.carried_contraband prisoner.contraband_clear_carried() qdel(thing) /// A new arrival: half the time they notice what the last occupant left, a little after they arrive -/datum/outpost_prison/proc/contraband_prisoner_admitted(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_prisoner_admitted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison_cell/home = prisoner?.cell if(!home || (!home.stash_shiv && !contraband_pruno(home))) return if(!contraband_roll(OUTPOST_CONTRABAND_INHERIT_CHANCE)) return - addtimer(CALLBACK(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, contraband_remark_inherited)), OUTPOST_CONTRABAND_INHERIT_DELAY) + addtimer(CALLBACK(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, contraband_remark_inherited)), OUTPOST_CONTRABAND_INHERIT_DELAY) /// A prisoner is leaving: what they carry goes with them. Their cell's stash stays for the next. -/datum/outpost_prison/proc/contraband_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/contraband_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/obj/item/thing = prisoner.carried_contraband prisoner.contraband_clear_carried() qdel(thing) @@ -734,7 +734,7 @@ )) var/list/drunk = list() var/list/carrying = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.contraband_drunk()) drunk += REF(prisoner) if(prisoner.carried_contraband) diff --git a/voidcrew/modules/player_outposts/outpost_prison_core.dm b/voidcrew/modules/player_outposts/outpost_prison_core.dm index 91f70cae088..82ab99026dc 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_core.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_core.dm @@ -47,7 +47,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) var/datum/outpost_upgrade/prison/upgrade var/obj/structure/overmap/dynamic/player_outpost/outpost var/area/voidcrew/player_outpost/prison/wing - var/list/mob/living/basic/outpost_prisoner/prisoners = list() + var/list/mob/living/carbon/human/npc/outpost_prisoner/prisoners = list() /// The wing's cells, in number order var/list/datum/outpost_prison_cell/cells = list() /// Prisoners the wing holds at once: one per cell, at most OUTPOST_PRISON_MAX_CAPACITY @@ -108,7 +108,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) wing.prison = null var/list/leaving = prisoners prisoners = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in leaving) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in leaving) close_bounty_record(prisoner, BOUNTY_RECORD_CLOSED) prisoner.prison = null prisoner.cell = null @@ -325,7 +325,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) if(supply_clock < PRISON_SUPPLY_REFRESH_SECONDS) return supply_clock = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) prisoner.fend_for_self() prisoner.check_sick_call() check_hatch_shortage() @@ -358,7 +358,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) dirty_suits++ // How long it lasts at the usual rates, for the prisoners here (or a full wing while intake is open). var/eaters = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_ARRIVING && prisoner.stat != DEAD && prisoner.trouble != PRISONER_TROUBLE_LOOSE) eaters++ if(!eaters && intake_open) @@ -389,7 +389,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) var/list/all_hatches = hatches() if(!length(all_hatches)) return - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT || prisoner.trouble) continue var/reaches_hatch = FALSE @@ -442,7 +442,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) * hatch, a table, a cell, the floor), or a cleaner uniform off a serving hatch when `want_uniform` * is set. Anything another prisoner is already fetching is left alone. */ -/datum/outpost_prison/proc/find_supply(mob/living/basic/outpost_prisoner/prisoner, want_uniform = FALSE) +/datum/outpost_prison/proc/find_supply(mob/living/carbon/human/npc/outpost_prisoner/prisoner, want_uniform = FALSE) if(!prisoner.reachable) refresh_prisoner_reach(prisoner) var/obj/item/best @@ -514,14 +514,14 @@ GLOBAL_LIST_EMPTY(outpost_prisons) if(user) note_staff_stock(user) // Whoever wants it drops what they were idling at and comes over. - for(var/mob/living/basic/outpost_prisoner/wanting in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/wanting in prisoners) var/datum/prisoner_activity/idle = wanting.activity if(!wanting.ai_running() || !idle?.leisure || !idle.interruptible || idle.sleeping) continue if((food && wanting.wants_food()) || (suits && wanting.wants_clean_uniform())) wanting.end_activity() - var/mob/living/basic/outpost_prisoner/thanker - for(var/mob/living/basic/outpost_prisoner/waiting in prisoners) + var/mob/living/carbon/human/npc/outpost_prisoner/thanker + for(var/mob/living/carbon/human/npc/outpost_prisoner/waiting in prisoners) var/datum/prisoner_activity/hatch_wait/wait = waiting.activity if(!istype(wait) || wait.hatch_ref?.resolve() != hatch) continue @@ -529,11 +529,11 @@ GLOBAL_LIST_EMPTY(outpost_prisons) thanker = waiting if(user) waiting.note_carer(user) - INVOKE_ASYNC(waiting, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, thank), (food && waiting.wants_food()) ? "thanks_food" : "thanks_uniform") + INVOKE_ASYNC(waiting, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, thank), (food && waiting.wants_food()) ? "thanks_food" : "thanks_uniform") break if(!COOLDOWN_FINISHED(src, hatch_call_cooldown)) return null - for(var/mob/living/basic/outpost_prisoner/crier as anything in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/crier as anything in shuffle(prisoners)) if(crier == thanker || crier.stat != CONSCIOUS || crier.phase != PRISONER_PRESENT || crier.in_trouble() || crier.activity?.sleeping) continue var/context @@ -545,7 +545,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) continue COOLDOWN_START(src, hatch_call_cooldown, OUTPOST_PRISON_HATCH_CALL_GAP) note_speech() - INVOKE_ASYNC(crier, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), context) + INVOKE_ASYNC(crier, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), context) return crier return null @@ -554,15 +554,15 @@ GLOBAL_LIST_EMPTY(outpost_prisons) * PRISONER_SHARED_MEAL_WINDOW makes a shared meal: each of them cheers up a little, once, and one * of them says so. Returns the prisoners who got the lift. */ -/datum/outpost_prison/proc/note_table_meal(mob/living/basic/outpost_prisoner/eater) - for(var/mob/living/basic/outpost_prisoner/earlier as anything in table_eaters.Copy()) +/datum/outpost_prison/proc/note_table_meal(mob/living/carbon/human/npc/outpost_prisoner/eater) + for(var/mob/living/carbon/human/npc/outpost_prisoner/earlier as anything in table_eaters.Copy()) if(QDELETED(earlier) || !(earlier in prisoners) || world.time - table_eaters[earlier] > PRISONER_SHARED_MEAL_WINDOW) table_eaters -= earlier table_eaters[eater] = world.time var/list/lifted = list() if(length(table_eaters) < PRISONER_SHARED_MEAL_COUNT) return lifted - for(var/mob/living/basic/outpost_prisoner/diner as anything in table_eaters) + for(var/mob/living/carbon/human/npc/outpost_prisoner/diner as anything in table_eaters) if(diner.shared_meal_at && world.time - diner.shared_meal_at <= PRISONER_SHARED_MEAL_WINDOW) continue diner.shared_meal_at = world.time @@ -572,8 +572,8 @@ GLOBAL_LIST_EMPTY(outpost_prisons) note_shared_meal(lifted) if(length(lifted) && COOLDOWN_FINISHED(src, mess_hall_cooldown)) COOLDOWN_START(src, mess_hall_cooldown, OUTPOST_PRISON_MESS_HALL_GAP) - var/mob/living/basic/outpost_prisoner/speaker = pick(lifted) - INVOKE_ASYNC(speaker, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "mess_hall") + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick(lifted) + INVOKE_ASYNC(speaker, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "mess_hall") return lifted /** @@ -584,22 +584,22 @@ GLOBAL_LIST_EMPTY(outpost_prisons) if(!is_member(shooter) || !COOLDOWN_FINISHED(src, staff_basket_cooldown)) return FALSE var/list/players = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat == CONSCIOUS && prisoner.phase == PRISONER_PRESENT && istype(prisoner.activity, /datum/prisoner_activity/basketball)) players += prisoner if(length(players) < 2) return FALSE COOLDOWN_START(src, staff_basket_cooldown, OUTPOST_PRISON_STAFF_BASKET_GAP) note_staff_basket(shooter) - for(var/mob/living/basic/outpost_prisoner/player as anything in players) + for(var/mob/living/carbon/human/npc/outpost_prisoner/player as anything in players) player.adjust_mood(PRISONER_MOOD_STAFF_BASKET) player.face_atom(hoop) - var/mob/living/basic/outpost_prisoner/cheering = pick(players) + var/mob/living/carbon/human/npc/outpost_prisoner/cheering = pick(players) INVOKE_ASYNC(cheering, TYPE_PROC_REF(/atom, manual_emote), pick("whistles.", "claps.", "cheers.")) return TRUE /// The nearest trash bin the prisoner can reach -/datum/outpost_prison/proc/find_bin(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/find_bin(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.reachable) refresh_prisoner_reach(prisoner) var/obj/structure/closet/crate/bin/best @@ -613,7 +613,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) return best /// The nearest litter lying where the prisoner may walk, off the serving hatches and out of other cells -/datum/outpost_prison/proc/find_litter(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/find_litter(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.walkable) refresh_prisoner_reach(prisoner) var/obj/item/trash/best @@ -631,7 +631,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) return best /// A basketball the prisoner can get at: their own, loose within reach, or in another player's hands -/datum/outpost_prison/proc/find_ball(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/find_ball(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(istype(prisoner.held_item, /obj/item/toy/basketball)) return prisoner.held_item if(!prisoner.reachable) @@ -640,13 +640,13 @@ GLOBAL_LIST_EMPTY(outpost_prisons) var/obj/item/toy/basketball/ball = locate() in spot if(ball) return ball - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(istype(other.held_item, /obj/item/toy/basketball) && prisoner.walkable?[get_turf(other)]) return other.held_item return null /// The nearest book lying about within reach, not on a shelf -/datum/outpost_prison/proc/find_loose_book(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/find_loose_book(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.reachable) refresh_prisoner_reach(prisoner) var/obj/item/book/best @@ -681,8 +681,8 @@ GLOBAL_LIST_EMPTY(outpost_prisons) return FALSE /// Whether a prisoner other than `except` is doing an activity of this type -/datum/outpost_prison/proc/anyone_doing(activity_type, mob/living/basic/outpost_prisoner/except) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) +/datum/outpost_prison/proc/anyone_doing(activity_type, mob/living/carbon/human/npc/outpost_prisoner/except) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner != except && istype(prisoner.activity, activity_type)) return TRUE return FALSE @@ -690,7 +690,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) // ===== CLAIMS ===== /// Reserves `thing` for `prisoner`. FALSE if someone else is using it. -/datum/outpost_prison/proc/claim(atom/thing, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/claim(atom/thing, mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(claimed_by_other(thing, prisoner)) return FALSE claims[REF(thing)] = prisoner @@ -698,13 +698,13 @@ GLOBAL_LIST_EMPTY(outpost_prisons) /// The prisoner using `thing`, if any /datum/outpost_prison/proc/claimant(atom/thing) - var/mob/living/basic/outpost_prisoner/holder = claims[REF(thing)] + var/mob/living/carbon/human/npc/outpost_prisoner/holder = claims[REF(thing)] if(QDELETED(holder) || !holder.activity) return null return holder -/datum/outpost_prison/proc/claimed_by_other(atom/thing, mob/living/basic/outpost_prisoner/prisoner) - var/mob/living/basic/outpost_prisoner/holder = claimant(thing) +/datum/outpost_prison/proc/claimed_by_other(atom/thing, mob/living/carbon/human/npc/outpost_prisoner/prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/holder = claimant(thing) return holder && holder != prisoner // ===== SPEECH ===== @@ -722,10 +722,9 @@ GLOBAL_LIST_EMPTY(outpost_prisons) if(QDELETED(outpost) || !wing) return tick(seconds_per_tick) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT) continue - prisoner.maybe_drip(seconds_per_tick) if(prisoner.stat != CONSCIOUS) continue if(prisoner.trouble != PRISONER_TROUBLE_LOOSE && SPT_PROB(OUTPOST_PRISON_MESS_CHANCE / 60, seconds_per_tick)) @@ -747,7 +746,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) conditions_tick(seconds) containment_tick(seconds) supply_tick(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase != PRISONER_PRESENT) continue if(prisoner.stat == DEAD) @@ -778,7 +777,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) // ===== COMINGS AND GOINGS ===== /// Drops a prisoner from the roster and their cell, and starts refilling it -/datum/outpost_prison/proc/forget(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/forget(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!(prisoner in prisoners)) return // Whatever else closed their bounty record first decided how it ended (outpost_prison_bounty.dm). @@ -797,6 +796,11 @@ GLOBAL_LIST_EMPTY(outpost_prisons) emptied.occupant = null prisoner.cell = null prisoner.prison = null + // Off the roster, a body is like any human NPC's: cleaned up once it has lain unwatched for a while + // (human_npc_spawning.dm), unless someone is dragging, carrying or watching it. + if(prisoner.stat == DEAD && !QDELETED(prisoner)) + prisoner.npc_corpse_lifetime = HUMAN_NPC_CORPSE_LIFETIME + SShuman_npcs.track_corpse(prisoner) for(var/key in claims.Copy()) if(claims[key] == prisoner) claims -= key @@ -823,7 +827,7 @@ GLOBAL_LIST_EMPTY(outpost_prisons) var/list/turfs var/list/turf_set /// The prisoner who owns it - var/mob/living/basic/outpost_prisoner/occupant + var/mob/living/carbon/human/npc/outpost_prisoner/occupant /// world.time from which the cell may take a new arrival var/ready_at = 0 diff --git a/voidcrew/modules/player_outposts/outpost_prison_creatures.dm b/voidcrew/modules/player_outposts/outpost_prison_creatures.dm index 1a127916052..1dd85b73006 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_creatures.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_creatures.dm @@ -53,7 +53,7 @@ * One tell of what the serum is turning `subject` into, `progress` (0 to 1) through the last * OUTPOST_EXPERIMENT_TELLS seconds. Plain to see, so the crew can fetch the right tool. */ -/proc/outpost_experiment_tell(form, mob/living/basic/outpost_prisoner/subject, progress) +/proc/outpost_experiment_tell(form, mob/living/carbon/human/npc/outpost_prisoner/subject, progress) if(QDELETED(subject) || subject.stat != CONSCIOUS) return subject.Shake(1, 1, 0.5 SECONDS) @@ -87,7 +87,7 @@ playsound(subject, pick('sound/effects/hallucinations/i_see_you1.ogg', 'sound/effects/hallucinations/behind_you1.ogg'), 20, TRUE) /// Takes the tells off a subject the serum never finished with -/proc/outpost_experiment_clear_tells(mob/living/basic/outpost_prisoner/subject) +/proc/outpost_experiment_clear_tells(mob/living/carbon/human/npc/outpost_prisoner/subject) if(QDELETED(subject)) return subject.remove_atom_colour(TEMPORARY_COLOUR_PRIORITY) @@ -154,7 +154,7 @@ /// Targets it has given up on reaching -> world.time it may try them again (find_light(), choose_target()) var/list/unreachable_until -/mob/living/basic/outpost_experiment/Initialize(mapload, datum/outpost_prison/owner, mob/living/basic/outpost_prisoner/subject) +/mob/living/basic/outpost_experiment/Initialize(mapload, datum/outpost_prison/owner, mob/living/carbon/human/npc/outpost_prisoner/subject) if(subject) gender = subject.gender . = ..() @@ -316,14 +316,14 @@ if(is_outpost_experiment_mob(victim) || HAS_TRAIT(victim, TRAIT_GODMODE)) return FALSE if(is_outpost_prisoner(victim)) - var/mob/living/basic/outpost_prisoner/prisoner = victim + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = victim if(prisoner.phase != PRISONER_PRESENT || !may_hit_prisoner(prisoner)) return FALSE return !blocked_by_cell(prisoner) return ishuman(victim) || issilicon(victim) || !isnull(victim.mind) /// Whether it hits this prisoner -/mob/living/basic/outpost_experiment/proc/may_hit_prisoner(mob/living/basic/outpost_prisoner/prisoner) +/mob/living/basic/outpost_experiment/proc/may_hit_prisoner(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return TRUE /// How much less it wants a target than its distance says; prisoners and people may count differently @@ -376,7 +376,7 @@ return FALSE /// Whether a bolted cell keeps `prisoner` away from it: shut and locked, and it is not inside with them -/mob/living/basic/outpost_experiment/proc/blocked_by_cell(mob/living/basic/outpost_prisoner/prisoner) +/mob/living/basic/outpost_experiment/proc/blocked_by_cell(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(smashes_through()) return FALSE var/datum/outpost_prison_cell/holding = prison?.cell_at(get_turf(prisoner)) @@ -485,7 +485,7 @@ /// Its charge var/datum/action/cooldown/mob_cooldown/charge/basic_charge/outpost_hulk/charge -/mob/living/basic/outpost_experiment/hulk/Initialize(mapload, datum/outpost_prison/owner, mob/living/basic/outpost_prisoner/subject) +/mob/living/basic/outpost_experiment/hulk/Initialize(mapload, datum/outpost_prison/owner, mob/living/carbon/human/npc/outpost_prisoner/subject) . = ..() update_transform(1.25) charge = new(src) @@ -508,8 +508,8 @@ return is_outpost_prisoner(target) ? 3 : 0 /// Only prisoners still standing: it knocks them flat and leaves them -/mob/living/basic/outpost_experiment/hulk/may_hit_prisoner(mob/living/basic/outpost_prisoner/prisoner) - return !prisoner.can_be_dragged() && prisoner.beaten_left <= 0 +/mob/living/basic/outpost_experiment/hulk/may_hit_prisoner(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + return !prisoner.can_be_dragged() && !prisoner.is_beaten() /// It tears through walls and forces doors, so a bolted cell and a blocked path never stop it /mob/living/basic/outpost_experiment/hulk/smashes_through() @@ -545,14 +545,14 @@ victim.throw_at(get_ranged_target_turf(victim, get_dir(src, victim), OUTPOST_HULK_THROW_RANGE), OUTPOST_HULK_THROW_RANGE, 1, src) /// Knocks a prisoner into the beaten state: hurt, flat on the floor, never killed -/mob/living/basic/outpost_experiment/hulk/proc/knock_down_prisoner(mob/living/basic/outpost_prisoner/prisoner) +/mob/living/basic/outpost_experiment/hulk/proc/knock_down_prisoner(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || prisoner.stat == DEAD) return FALSE var/damage = min(rand(OUTPOST_HULK_PUNCH_MIN, OUTPOST_HULK_PUNCH_MAX), max(0, prisoner.health - 1)) if(damage > 0) prisoner.apply_damage(damage, BRUTE) prisoner.visible_message(span_danger("[src] knocks [prisoner] flat!")) - if(prisoner.stat == CONSCIOUS && prisoner.beaten_left <= 0) + if(prisoner.stat == CONSCIOUS && !prisoner.is_beaten()) prisoner.collapse() return TRUE @@ -769,7 +769,7 @@ var/datum/weakref/flit_from_ref var/flit_until = 0 -/mob/living/basic/outpost_experiment/fly/Initialize(mapload, datum/outpost_prison/owner, mob/living/basic/outpost_prisoner/subject) +/mob/living/basic/outpost_experiment/fly/Initialize(mapload, datum/outpost_prison/owner, mob/living/carbon/human/npc/outpost_prisoner/subject) . = ..() RegisterSignal(src, COMSIG_MOB_APPLY_DAMAGE_MODIFIERS, PROC_REF(swatter_weakness)) RegisterSignal(src, COMSIG_PROJECTILE_PREHIT, PROC_REF(dodge)) @@ -1008,7 +1008,7 @@ playsound(src, 'sound/mobs/humanoids/human/gag_vomit/crack_vomit.ogg', 50, TRUE) if(isopenturf(spot)) new /obj/effect/decal/cleanable/vomit(spot) - for(var/mob/living/basic/outpost_prisoner/watcher in viewers(5, src)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/watcher in viewers(5, src)) if(watcher.stat != CONSCIOUS || LAZYACCESS(disgusted, REF(watcher)) > world.time) continue LAZYSET(disgusted, REF(watcher), world.time + OUTPOST_EXPERIMENT_FLY_DISGUST_GAP) @@ -1046,7 +1046,7 @@ var/in_shadow = TRUE COOLDOWN_DECLARE(jaunt_cooldown) -/mob/living/basic/outpost_experiment/nightmare/Initialize(mapload, datum/outpost_prison/owner, mob/living/basic/outpost_prisoner/subject) +/mob/living/basic/outpost_experiment/nightmare/Initialize(mapload, datum/outpost_prison/owner, mob/living/carbon/human/npc/outpost_prisoner/subject) . = ..() opening_left = rand(OUTPOST_NIGHTMARE_OPENING_MIN, OUTPOST_NIGHTMARE_OPENING_MAX) RegisterSignal(src, COMSIG_PROJECTILE_PREHIT, PROC_REF(dodge)) diff --git a/voidcrew/modules/player_outposts/outpost_prison_dialogue.dm b/voidcrew/modules/player_outposts/outpost_prison_dialogue.dm index 6b4665a7f4b..c4231e4e93b 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_dialogue.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_dialogue.dm @@ -33,7 +33,7 @@ return islist(entry) ? entry : list() /// Scales a prisoner's pause between spontaneous lines -/mob/living/basic/outpost_prisoner/proc/speech_pace() +/mob/living/carbon/human/npc/outpost_prisoner/proc/speech_pace() switch(personality) if("chatty") return 0.6 @@ -46,7 +46,7 @@ return 1 /// Their sentence left in words, for {time_left} -/mob/living/basic/outpost_prisoner/proc/time_left_text() +/mob/living/carbon/human/npc/outpost_prisoner/proc/time_left_text() var/seconds = max(0, round(sentence_left)) var/minutes = round(seconds / 60 + 0.5) if(minutes >= 2) @@ -56,7 +56,7 @@ return "[max(seconds, 1)] seconds" /// Fills a line's placeholders for this speaker, talking to `other` -/mob/living/basic/outpost_prisoner/proc/fill_line(line, mob/living/basic/outpost_prisoner/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/fill_line(line, mob/living/carbon/human/npc/outpost_prisoner/other) line = replacetext(line, "{name}", speech_name()) line = replacetext(line, "{other}", other ? other.speech_name() : "pal") line = replacetext(line, "{crime}", crime) @@ -70,7 +70,7 @@ * A line for `context`, filled in, or null when the file has none that fits. Personality lines * come up more often than the shared ones; lines naming {other} need someone to talk to. */ -/mob/living/basic/outpost_prisoner/proc/pick_line(context, mob/living/basic/outpost_prisoner/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/pick_line(context, mob/living/carbon/human/npc/outpost_prisoner/other) // The main file first, then the extras' files (outpost_prison_extras.dm) var/list/by_personality = outpost_prisoner_context_lines(context) if(!islist(by_personality)) @@ -86,7 +86,7 @@ return fill_line(pick(usable), other) /// The lines of `pool` they can say now: not the last thing they said, naming nobody when alone, and no extra placeholder they have no value for -/mob/living/basic/outpost_prisoner/proc/usable_lines(list/pool, mob/living/basic/outpost_prisoner/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/usable_lines(list/pool, mob/living/carbon/human/npc/outpost_prisoner/other) var/list/usable = list() for(var/line in pool) if(!istext(line) || line == last_line || (!other && findtext(line, "{other}"))) @@ -97,7 +97,7 @@ return usable /// Whether `line` names an extra placeholder ({staff}, {place}, ...) that extra_line_values has no value for -/mob/living/basic/outpost_prisoner/proc/missing_extra_value(line) +/mob/living/carbon/human/npc/outpost_prisoner/proc/missing_extra_value(line) for(var/placeholder in GLOB.outpost_prisoner_extra_placeholders) if(findtext(line, placeholder) && isnull(LAZYACCESS(extra_line_values, placeholder))) return TRUE @@ -107,11 +107,11 @@ * Whether they may say anything now: awake and all the way here. Never while beaming in or out, * when they are invisible or only half there. */ -/mob/living/basic/outpost_prisoner/proc/may_speak() +/mob/living/carbon/human/npc/outpost_prisoner/proc/may_speak() return !QDELETED(src) && stat == CONSCIOUS && phase == PRISONER_PRESENT /// Says a line for `context`. Returns TRUE if they said something. -/mob/living/basic/outpost_prisoner/proc/say_context(context, mob/living/basic/outpost_prisoner/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/say_context(context, mob/living/carbon/human/npc/outpost_prisoner/other) if(!may_speak()) return FALSE var/line = pick_line(context, other) @@ -122,7 +122,7 @@ return TRUE /// Thanks whoever fed, clothed or treated them, no more than every 20 seconds -/mob/living/basic/outpost_prisoner/proc/thank(context) +/mob/living/carbon/human/npc/outpost_prisoner/proc/thank(context) if(phase != PRISONER_PRESENT || !COOLDOWN_FINISHED(src, thanks_cooldown)) return FALSE if(!say_context(context)) @@ -134,7 +134,7 @@ * Opens a two-person exchange with `partner`, who answers a few seconds later with one of the * opener's replies. Returns TRUE if it started. */ -/mob/living/basic/outpost_prisoner/proc/start_conversation(mob/living/basic/outpost_prisoner/partner) +/mob/living/carbon/human/npc/outpost_prisoner/proc/start_conversation(mob/living/carbon/human/npc/outpost_prisoner/partner) if(!may_speak() || QDELETED(partner) || !partner.may_speak()) return FALSE var/list/conversations = outpost_prisoner_dialogue("conversations") @@ -153,8 +153,8 @@ return TRUE /// The second half of a conversation -/mob/living/basic/outpost_prisoner/proc/reply_in_conversation(line, datum/weakref/opener_ref) - var/mob/living/basic/outpost_prisoner/opener = opener_ref?.resolve() +/mob/living/carbon/human/npc/outpost_prisoner/proc/reply_in_conversation(line, datum/weakref/opener_ref) + var/mob/living/carbon/human/npc/outpost_prisoner/opener = opener_ref?.resolve() if(!may_speak() || QDELETED(opener) || get_dist(src, opener) > 5) return FALSE face_atom(opener) @@ -163,15 +163,15 @@ return TRUE /// Whether a member of staff (see is_outpost_prison_staff()) is in sight, borgs included -/mob/living/basic/outpost_prisoner/proc/staff_in_view() +/mob/living/carbon/human/npc/outpost_prisoner/proc/staff_in_view() for(var/mob/living/person in view(5, src)) if(is_outpost_prison_staff(person)) return TRUE return FALSE /// Another prisoner close by, to talk at -/mob/living/basic/outpost_prisoner/proc/nearby_prisoner() - for(var/mob/living/basic/outpost_prisoner/other in view(3, src)) +/mob/living/carbon/human/npc/outpost_prisoner/proc/nearby_prisoner() + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in view(3, src)) if(other != src && other.stat == CONSCIOUS && other.phase == PRISONER_PRESENT) return other return null @@ -180,18 +180,18 @@ * What to talk about now, as list(context, other), most pressing first: cuffs, trouble, lockdown, * being locked in, needs, the state of the wing, staff in sight, what they are doing, then small talk. */ -/mob/living/basic/outpost_prisoner/proc/pick_speech() +/mob/living/carbon/human/npc/outpost_prisoner/proc/pick_speech() if(activity?.sleeping) return prob(25) ? list("sleeping", null) : null // Cuffed, they complain about it (outpost_prison_capture.dm). - if(cuffs && prob(50)) + if(handcuffed && prob(50)) return list("cuffed", null) // Trouble has its own lines, said as it happens (outpost_prison_trouble.dm). if(trouble == PRISONER_TROUBLE_LOOSE) return prob(50) ? list("breakout", null) : null if(is_rioting()) return list("riot", null) - if(trouble || beaten_left > 0 || threat_ref || climb_ref) + if(trouble || beaten || threat_ref || climb_ref) return null // Running from a creature, they shout about that instead (outpost_prison_panic.dm). if(is_panicking()) @@ -230,7 +230,7 @@ return extra if(prob(30) && staff_in_view()) return list("staff_near", null) - var/mob/living/basic/outpost_prisoner/partner = activity?.chat_partner() + var/mob/living/carbon/human/npc/outpost_prisoner/partner = activity?.chat_partner() if(activity?.context && prob(60)) return list(activity.context, partner) return list("idle", partner || nearby_prisoner()) @@ -239,7 +239,7 @@ * Called about once a second by the prison. Most seconds they say nothing; once their own and * the wing's cooldowns are up there is a small chance each second. */ -/mob/living/basic/outpost_prisoner/proc/speech_tick() +/mob/living/carbon/human/npc/outpost_prisoner/proc/speech_tick() if(stat != CONSCIOUS || phase != PRISONER_PRESENT || !prison) return FALSE if(!said_release_soon && sentence_left <= 90) @@ -290,7 +290,7 @@ return 1 /// A piece of mess on the worst tile of the cell block's floor they can see nearby, or null -/mob/living/basic/outpost_prisoner/proc/worst_mess_in_view() +/mob/living/carbon/human/npc/outpost_prisoner/proc/worst_mess_in_view() var/list/tile_load = list() var/atom/movable/worst var/worst_load = 0 @@ -308,7 +308,7 @@ return worst /// The nearest light in the wing they can see that gives no light, or null -/mob/living/basic/outpost_prisoner/proc/dead_light_in_view() +/mob/living/carbon/human/npc/outpost_prisoner/proc/dead_light_in_view() var/obj/machinery/light/nearest var/nearest_distance = INFINITY for(var/obj/machinery/light/fixture in view(PRISONER_POINT_RANGE, src)) @@ -326,7 +326,7 @@ * After a dirty_prison or dark line, with staff in sight, points at what they mean: the worst mess * or the dead light near them. Returns what they pointed at, or null. */ -/mob/living/basic/outpost_prisoner/proc/show_complaint(context) +/mob/living/carbon/human/npc/outpost_prisoner/proc/show_complaint(context) if(context != "dirty_prison" && context != "dark") return null if(stat != CONSCIOUS || !isturf(loc) || !staff_in_view()) diff --git a/voidcrew/modules/player_outposts/outpost_prison_doors.dm b/voidcrew/modules/player_outposts/outpost_prison_doors.dm index d9005801156..0e49bf6ef6f 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_doors.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_doors.dm @@ -101,7 +101,7 @@ MAPPING_DIRECTIONAL_HELPERS(/obj/machinery/door/window/brigdoor/outpost_prison_s /// Whether `thing` is a loose prisoner: out of the cell block and on the run /proc/outpost_prisoner_loose(atom/thing) - var/mob/living/basic/outpost_prisoner/prisoner = thing + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = thing return istype(prisoner) && prisoner.trouble == PRISONER_TROUBLE_LOOSE /obj/machinery/door/airlock/security/prison_staff/glass diff --git a/voidcrew/modules/player_outposts/outpost_prison_economy.dm b/voidcrew/modules/player_outposts/outpost_prison_economy.dm index 501f1c0c41d..f8ee9c662f0 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_economy.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_economy.dm @@ -61,7 +61,7 @@ * long enough to stop paying. Not while it is for their own safety, or they are hiding in their own * cell from a creature (outpost_prison_panic.dm), or for lockdown they owe. */ -/datum/outpost_prison/proc/confined_unpaid(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/confined_unpaid(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return (prisoner.locked_in_seconds > OUTPOST_PRISON_CONFINED_PAY_AFTER && !protective_custody() && !prisoner.sheltering_from_creature()) || prisoner.cuffs_souring() /** @@ -69,7 +69,7 @@ * Nothing while they are dead, arriving or leaving, not earning (fighting, rioting, loose and the * like), or confined to their cell too long. */ -/datum/outpost_prison/proc/pay_factor(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/pay_factor(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || prisoner.stat == DEAD || prisoner.phase != PRISONER_PRESENT || !prisoner.earning_pay()) return 0 if(confined_unpaid(prisoner)) @@ -77,7 +77,7 @@ return care_grade(prisoner.care()) * conditions_pay_factor() /// A prisoner served `served` seconds of their sentence: their stipend for it, and the seconds served and kept for the release bonus -/datum/outpost_prison/proc/accrue_pay(mob/living/basic/outpost_prisoner/prisoner, served) +/datum/outpost_prison/proc/accrue_pay(mob/living/carbon/human/npc/outpost_prisoner/prisoner, served) var/factor = pay_factor(prisoner) pay_owed += OUTPOST_PRISON_BASE_PAY * bounty_pay_mult(prisoner) * factor * served / 60 prisoner.served_seconds += served @@ -116,13 +116,13 @@ return 0 /// What a prisoner earns the treasury per minute right now -/datum/outpost_prison/proc/prisoner_pay_rate(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/prisoner_pay_rate(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return OUTPOST_PRISON_BASE_PAY * bounty_pay_mult(prisoner) * pay_factor(prisoner) /// What every prisoner earns the treasury per minute right now /datum/outpost_prison/proc/pay_rate() var/total = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) total += prisoner_pay_rate(prisoner) return total @@ -201,8 +201,8 @@ /datum/outpost_prison/proc/incident_ongoing() if(riot_active) return TRUE - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) - if(prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && (prisoner.is_rioting() || (prisoner.trouble == PRISONER_TROUBLE_LOOSE && !prisoner.cuffs))) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) + if(prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && (prisoner.is_rioting() || (prisoner.trouble == PRISONER_TROUBLE_LOOSE && !prisoner.handcuffed))) return TRUE return FALSE @@ -213,7 +213,7 @@ * OUTPOST_PRISON_LOST_TO_SUSPEND lost within OUTPOST_PRISON_LOST_WINDOW, the corrections service * suspends transfers until a manager reopens intake. */ -/datum/outpost_prison/proc/note_prisoner_lost(mob/living/basic/outpost_prisoner/prisoner, reason) +/datum/outpost_prison/proc/note_prisoner_lost(mob/living/carbon/human/npc/outpost_prisoner/prisoner, reason) lost_log += world.time var/lost = lost_recently() log_game("PLAYER OUTPOST PRISON: '[outpost?.name]' lost [prisoner?.real_name || "a prisoner"] ([reason || "lost"]), [lost] lost recently") @@ -401,7 +401,7 @@ if(!spot) continue // An ordinary arrival may be a caught bounty criminal from the pool; the admin panel's never is (outpost_prison_bounty.dm). - var/mob/living/basic/outpost_prisoner/prisoner = new(spot, forced ? null : bounty_pool_take(src)) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = new(spot, forced ? null : bounty_pool_take(src)) admit(prisoner, cell) prisoner.beam_in() start_arrival_gap(lane || soonest_lane()) @@ -410,7 +410,7 @@ return null /// Books a prisoner into a cell (the first free one when none is given), in the mood the yard puts newcomers in -/datum/outpost_prison/proc/admit(mob/living/basic/outpost_prisoner/prisoner, datum/outpost_prison_cell/into) +/datum/outpost_prison/proc/admit(mob/living/carbon/human/npc/outpost_prisoner/prisoner, datum/outpost_prison_cell/into) if(!into) for(var/datum/outpost_prison_cell/cell as anything in cells) if(!cell.occupant) @@ -435,7 +435,7 @@ * After a tick of a prisoner's sentence: released once it has run out, and sent back to their * cell in its last OUTPOST_PRISON_RELEASE_WALK seconds. */ -/datum/outpost_prison/proc/check_release(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/check_release(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner.sentence_left <= 0) release(prisoner) else if(prisoner.sentence_left <= OUTPOST_PRISON_RELEASE_WALK && !istype(prisoner.activity, /datum/prisoner_activity/go_home) && !prisoner.in_trouble()) @@ -443,7 +443,7 @@ prisoner.end_activity() /// Sentence served: pays the release bonus and beams the prisoner out of wherever they stand -/datum/outpost_prison/proc/release(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/release(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD) return 0 var/average = prisoner.served_seconds ? prisoner.kept_seconds / prisoner.served_seconds : 0 @@ -461,7 +461,7 @@ * A prisoner died. Nobody comes for the body (body_tick()): it keeps its cell until staff carry it * out of the cell block. A death blamed on staff costs no fine, but the yard takes it hard (blame_death()). */ -/datum/outpost_prison/proc/on_prisoner_death(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/on_prisoner_death(mob/living/carbon/human/npc/outpost_prisoner/prisoner) add_log("[prisoner.real_name] died.") close_bounty_record(prisoner, BOUNTY_RECORD_DEAD) prisoner.died_at = world.time @@ -478,7 +478,7 @@ * it leaves the roster, and its cell waits a refill. An experiment under way that still needs it, * a specimen host about to burst, keeps it on the roster wherever it is. */ -/datum/outpost_prison/proc/body_tick(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/body_tick(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(in_cell_block(prisoner) || held_for_experiment(prisoner)) return add_log("[prisoner.real_name]'s body is out of the cell block.") @@ -487,7 +487,7 @@ /// Prisoners' bodies lying in the cell block, which nobody comes for /datum/outpost_prison/proc/bodies_in_cell_block() . = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat == DEAD && prisoner.phase == PRISONER_PRESENT && in_cell_block(prisoner)) .++ @@ -512,7 +512,7 @@ */ /datum/outpost_prison/proc/on_outpost_abandoned() var/out_of_hand = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && (prisoner.is_rioting() || prisoner.trouble == PRISONER_TROUBLE_LOOSE)) out_of_hand++ if(out_of_hand) @@ -524,7 +524,7 @@ lost_log.Cut() pay_owed = 0 var/transferred = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase == PRISONER_LEAVING) continue close_bounty_record(prisoner, BOUNTY_RECORD_CLOSED) diff --git a/voidcrew/modules/player_outposts/outpost_prison_experiments.dm b/voidcrew/modules/player_outposts/outpost_prison_experiments.dm index 2d06959b6db..fc6f7f24c65 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_experiments.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_experiments.dm @@ -29,7 +29,7 @@ * Every clock here counts only while a member of the wing is home (crew_home()). */ -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Dosed or carrying a specimen: their death is the experiment's, not staff's var/experiment_subject = FALSE @@ -37,7 +37,7 @@ /// The experiment under way, or just ended (the console shows the result for a while) var/datum/outpost_experiment/experiment /// The researcher, while visiting with an offer - var/mob/living/basic/outpost_kessler_staff/researcher/researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/researcher /// Crew-home seconds until the researcher is due; null before the first arrival and while an offer, item or experiment is out var/researcher_wait /// The researcher's visits have been scheduled: the wing has had its first arrival @@ -114,7 +114,7 @@ * bursts from the body. */ /datum/outpost_prison/proc/subject_tick(seconds, home) - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() if(!subject || subject.prison != src || subject.phase != PRISONER_PRESENT) experiment_failed("the subject is gone") return FALSE @@ -134,14 +134,14 @@ /datum/outpost_prison/proc/experiment_paused() if(!experiment_active() || !crew_home()) return TRUE - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() return subject && subject.stat != DEAD && (get_area(subject) != wing || !in_cell_block(subject)) /// A minute of twitching, then the change. The form's tells show in the last OUTPOST_EXPERIMENT_TELLS seconds. /datum/outpost_prison/proc/twitch_tick(seconds, home) if(!home) return - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() experiment.twitch_left -= seconds experiment.stage = experiment.twitch_left > OUTPOST_EXPERIMENT_TELLS ? "dosed" : "twitching" if(experiment.twitch_left <= 0) @@ -164,7 +164,7 @@ /// The serum takes: the subject becomes the creature where they stand /datum/outpost_prison/proc/transform_subject() - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() var/creature_type = outpost_experiment_creature_type(experiment.form) var/turf/spot = get_turf(subject) if(!subject || !creature_type || !spot) @@ -230,7 +230,7 @@ * does the admin panel with `forced`, which skips the cell block rule. One experiment at a time, * on a living prisoner of this wing. Returns TRUE if it started. */ -/datum/outpost_prison/proc/start_experiment(form, mob/living/basic/outpost_prisoner/subject, forced = FALSE) +/datum/outpost_prison/proc/start_experiment(form, mob/living/carbon/human/npc/outpost_prisoner/subject, forced = FALSE) if(!(form in list("hulk", "fly", "nightmare", "changeling"))) return FALSE if(QDELETED(subject) || subject.prison != src || subject.phase != PRISONER_PRESENT || subject.stat == DEAD) @@ -262,7 +262,7 @@ experiment.stage = "dosed" experiment.twitch_left = OUTPOST_EXPERIMENT_TWITCH experiment.effect_left = rand(3, 5) - INVOKE_ASYNC(subject, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "experiment_dose") + INVOKE_ASYNC(subject, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "experiment_dose") return TRUE // ===== CREATURES AND PAY ===== @@ -451,7 +451,7 @@ for(var/mob/living/creature as anything in experiment.live_creatures()) experiment.downed[REF(creature)] = TRUE kessler_collect(WEAKREF(creature), FALSE) - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() if(subject) subject.experiment_subject = FALSE outpost_experiment_clear_tells(subject) @@ -482,7 +482,7 @@ experiment.resolved = TRUE experiment.stage = outcome experiment.result_left = OUTPOST_EXPERIMENT_RESULT_SHOWN - var/mob/living/basic/outpost_prisoner/subject = experiment.subject() + var/mob/living/carbon/human/npc/outpost_prisoner/subject = experiment.subject() if(subject && subject.stat != DEAD) subject.experiment_subject = FALSE outpost_experiment_clear_tells(subject) @@ -492,7 +492,7 @@ // absorbed who was not a prisoner. Its witnesses join the experiment's, once each. if(!QDELETED(experiment.changeling)) for(var/datum/weakref/witness_ref as anything in experiment.changeling.witnesses) - var/mob/living/basic/outpost_prisoner/witness = witness_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/witness = witness_ref?.resolve() if(witness) experiment.witnesses[REF(witness)] = witness_ref #endif @@ -510,7 +510,7 @@ /datum/outpost_prison/proc/experiment_aftermath() for(var/key in experiment.witnesses) var/datum/weakref/witness_ref = experiment.witnesses[key] - var/mob/living/basic/outpost_prisoner/witness = witness_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/witness = witness_ref?.resolve() if(!witness || witness.prison != src || witness.stat == DEAD || witness.phase != PRISONER_PRESENT) continue witness.adjust_mood(-OUTPOST_EXPERIMENT_SAW_DEATH_MOOD) @@ -550,7 +550,7 @@ /// Prisoners who could be the researcher's subjects: in the wing, alive, not loose and not dosed already /datum/outpost_prison/proc/researcher_prisoner_count() var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && prisoner.trouble != PRISONER_TROUBLE_LOOSE && !prisoner.experiment_subject) count++ return count @@ -560,7 +560,7 @@ * researcher says so. A changeling host dying is the changeling event's to handle. Anyone else a * creature (not staff) killed counts toward the aftermath, with everyone in sight of it a witness. */ -/datum/outpost_prison/on_prisoner_death(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/on_prisoner_death(mob/living/carbon/human/npc/outpost_prisoner/prisoner) . = ..() if(!experiment_active()) return @@ -575,12 +575,12 @@ if(!creature_live() || prisoner.death_blamed) return experiment.creature_kills++ - for(var/mob/living/basic/outpost_prisoner/witness in viewers(OUTPOST_EXPERIMENT_WITNESS_RANGE, get_turf(prisoner))) + for(var/mob/living/carbon/human/npc/outpost_prisoner/witness in viewers(OUTPOST_EXPERIMENT_WITNESS_RANGE, get_turf(prisoner))) if(witness != prisoner && witness.prison == src && witness.stat != DEAD) experiment.witnesses[REF(witness)] = WEAKREF(witness) /// A dosed subject earns nothing: their pay and release bonus are forfeit -/datum/outpost_prison/pay_factor(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/pay_factor(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner?.experiment_subject) return 0 return ..() @@ -592,7 +592,7 @@ * of the cell block stays on the roster (body_tick()). * Once the experiment is over the usual rules apply again. */ -/datum/outpost_prison/proc/held_for_experiment(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/held_for_experiment(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner || !experiment_active()) return FALSE if(prisoner == experiment.subject()) @@ -604,12 +604,12 @@ return FALSE /// A dosed subject is not released at the end of their sentence -/datum/outpost_prison/check_release(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/check_release(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(held_for_experiment(prisoner)) return return ..() -/datum/outpost_prison/release(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/release(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(held_for_experiment(prisoner)) return 0 return ..() @@ -740,7 +740,7 @@ var/list/witnesses = list() var/creature_kills = 0 -/datum/outpost_experiment/New(datum/outpost_prison/owner, form, mob/living/basic/outpost_prisoner/subject) +/datum/outpost_experiment/New(datum/outpost_prison/owner, form, mob/living/carbon/human/npc/outpost_prisoner/subject) . = ..() prison = owner src.form = form @@ -1095,7 +1095,7 @@ return TRUE /// Starts a changeling event in `host`, a prisoner of `prison`, and returns it -/proc/outpost_changeling_infect(mob/living/basic/outpost_prisoner/host, datum/outpost_prison/prison) +/proc/outpost_changeling_infect(mob/living/carbon/human/npc/outpost_prisoner/host, datum/outpost_prison/prison) var/datum/outpost_changeling_event/event = new event.prison = prison event.host_ref = WEAKREF(host) diff --git a/voidcrew/modules/player_outposts/outpost_prison_extras.dm b/voidcrew/modules/player_outposts/outpost_prison_extras.dm index d0df5077f73..16f7f332f9c 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_extras.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_extras.dm @@ -61,14 +61,14 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", // ===== THE PRISONER ===== -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Values for extra placeholders ("{staff}" = "Isaac") while one line is picked and said var/list/extra_line_values /// The last person or borg (never a machine) whose hit on them counted as staff's var/datum/weakref/last_staff_attacker_ref /// Hooks up every package's own handlers; called once from Initialize() -/mob/living/basic/outpost_prisoner/proc/setup_extras() +/mob/living/carbon/human/npc/outpost_prisoner/proc/setup_extras() setup_ambience() setup_warden_tools() setup_pastimes() @@ -78,13 +78,13 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", * Says a line for `context` with values for extra placeholders, list("{place}" = "the Meridian"). * Lines naming a placeholder without a value are skipped. Returns TRUE if they said something. */ -/mob/living/basic/outpost_prisoner/proc/say_context_with(context, list/values, mob/living/basic/outpost_prisoner/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/say_context_with(context, list/values, mob/living/carbon/human/npc/outpost_prisoner/other) extra_line_values = values . = say_context(context, other) extra_line_values = null /// Says a line for `context` to `person`, filling {staff} with the name the yard knows them by -/mob/living/basic/outpost_prisoner/proc/say_to_staff(context, mob/person) +/mob/living/carbon/human/npc/outpost_prisoner/proc/say_to_staff(context, mob/person) var/staff_name = prison?.staff_greeting_name(person) return say_context_with(context, staff_name ? list("{staff}" = staff_name) : null) @@ -123,13 +123,13 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", guards_abandon() /// A prisoner was booked in (admit()), with their cell and mood set -/datum/outpost_prison/proc/extras_prisoner_admitted(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/extras_prisoner_admitted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) on_prisoner_admitted(prisoner) contraband_prisoner_admitted(prisoner) leads_prisoner_admitted(prisoner) /// A prisoner is leaving the roster (forget()), still holding their prison and cell -/datum/outpost_prison/proc/extras_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/extras_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) on_prisoner_leaving(prisoner) contraband_prisoner_leaving(prisoner) mail_prisoner_leaving(prisoner) @@ -146,7 +146,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", // ===== THE PRISON: SPEECH, EXAMINE AND THE TALK MENU ===== /// Something a package wants a prisoner to say now: list(context, other[, values]), or null -/datum/outpost_prison/proc/extra_speech(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/extra_speech(mob/living/carbon/human/npc/outpost_prisoner/prisoner) // A bounty prisoner who sees someone off the ship that caught them (outpost_prison_bounty.dm): rare and gated var/list/choice = bounty_extra_speech(prisoner) if(choice) @@ -163,7 +163,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", return leads_extra_speech(prisoner) /// Extra sentences for a prisoner's examine text, from every package that has one; XB's examine() shows them -/datum/outpost_prison/proc/examine_extra_lines(mob/living/basic/outpost_prisoner/prisoner, mob/user) +/datum/outpost_prison/proc/examine_extra_lines(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/user) var/list/lines = list() for(var/line in list(relationship_examine(prisoner), contraband_examine(prisoner, user), leads_examine(prisoner, user), wildcard_examine(prisoner), bounty_examine(prisoner, user))) if(istext(line) && length(line)) @@ -171,7 +171,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", return lines /// Extra choices for the talk menu (XC's radial): name -> image, from the packages that add one -/datum/outpost_prison/proc/talk_menu_extra_choices(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/talk_menu_extra_choices(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) var/list/choices = list() for(var/list/extra as anything in list(contraband_talk_choices(prisoner, user), leads_talk_choices(prisoner, user))) for(var/key in extra) @@ -179,7 +179,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", return choices /// Runs an extra talk menu choice; TRUE if a package handled it. May sleep (the menu is async). -/datum/outpost_prison/proc/talk_menu_extra_act(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, choice) +/datum/outpost_prison/proc/talk_menu_extra_act(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, choice) if(contraband_talk_act(prisoner, user, choice)) return TRUE return leads_talk_act(prisoner, user, choice) @@ -187,7 +187,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_extra_placeholders, list("{staff}", "{place}", // ===== THE PRISON: TROUBLE ===== /// What the packages do to the chance two prisoners start a fight: a multiplier, 1 for no change -/datum/outpost_prison/proc/extras_fight_mult(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/extras_fight_mult(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) return fight_chance_mult(one, two) * contraband_fight_mult(one, two) // ===== THE PRISON: CONSOLES ===== diff --git a/voidcrew/modules/player_outposts/outpost_prison_fixtures.dm b/voidcrew/modules/player_outposts/outpost_prison_fixtures.dm index 0ca544dd12f..5b2fc45c2ae 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_fixtures.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_fixtures.dm @@ -10,7 +10,7 @@ /// Whether this is an outpost prisoner /proc/is_outpost_prisoner(atom/thing) - return istype(thing, /mob/living/basic/outpost_prisoner) + return istype(thing, /mob/living/carbon/human/npc/outpost_prisoner) // ===== UNIFORM ===== diff --git a/voidcrew/modules/player_outposts/outpost_prison_guard_routine.dm b/voidcrew/modules/player_outposts/outpost_prison_guard_routine.dm index a2fa01ac29f..279426b51f3 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_guard_routine.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_guard_routine.dm @@ -2,8 +2,8 @@ * # Prison guards: routine * * Owner: XA (extras-plan.md 4.1). The guards' AI controller, their peaceful activities (post, - * rounds, watching the yard, a drink at the cooler, a chat, checking on a prisoner) and the - * response datum the dispatcher in outpost_prison_guards.dm hands out. + * rounds, watching the yard, a drink at the cooler, a chat, checking on a prisoner, patching + * themselves up) and the response datum the dispatcher in outpost_prison_guards.dm hands out. * * As with the prisoners' routine (outpost_prison_routine.dm), the AI only walks: to the goal of * the guard's response when there is one, else to their activity's. The dispatcher deals with @@ -86,7 +86,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) // ===== THE GUARD'S SIDE ===== /// Where their AI should walk them now, or null to stay put -/mob/living/basic/outpost_prison_guard/proc/walk_goal() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/walk_goal() if(phase == OUTPOST_GUARD_DISMISSED) return (leave_spot && loc != leave_spot) ? leave_spot : null if(phase != OUTPOST_GUARD_PRESENT) @@ -98,7 +98,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return (goal && !at_goal(goal)) ? goal : null /// A walk to `goal` failed: a response gets another try until it expires; an activity gets one more, then a rest -/mob/living/basic/outpost_prison_guard/proc/walk_failed(atom/goal) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/walk_failed(atom/goal) if(response || !activity || goal != activity.goal()) return if(++activity.failed_moves < 2) @@ -107,7 +107,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) end_activity(cancel_ai = FALSE) /// Ends what they are doing on routine -/mob/living/basic/outpost_prison_guard/proc/end_activity(cancel_ai = TRUE) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/end_activity(cancel_ai = TRUE) if(!activity) return var/datum/outpost_guard_activity/old = activity @@ -119,16 +119,16 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) ai_controller?.CancelActions() /// Starts `new_activity` in place of whatever they were doing on routine -/mob/living/basic/outpost_prison_guard/proc/start_activity(datum/outpost_guard_activity/new_activity) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/start_activity(datum/outpost_guard_activity/new_activity) end_activity() activity = new_activity return new_activity -/mob/living/basic/outpost_prison_guard/proc/activity_on_cooldown(activity_type) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/activity_on_cooldown(activity_type) return LAZYACCESS(activity_cooldowns, activity_type) > world.time /// Picks their next routine activity by weight. Returns it, already set up, or null. -/mob/living/basic/outpost_prison_guard/proc/choose_activity() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/choose_activity() if(!prison || !on_duty()) return null var/list/candidates = list() @@ -161,7 +161,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) * A two-guard exchange: they open with a line from the file's guard_conversations, and `partner` * answers a few seconds later. Returns TRUE if it started. */ -/mob/living/basic/outpost_prison_guard/proc/start_guard_conversation(mob/living/basic/outpost_prison_guard/partner) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/start_guard_conversation(mob/living/carbon/human/npc/outpost_prison_guard/partner) if(!on_duty() || !partner?.on_duty()) return FALSE var/list/conversations = outpost_guard_dialogue("guard_conversations") @@ -179,8 +179,8 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return TRUE /// The second half of a guards' exchange -/mob/living/basic/outpost_prison_guard/proc/reply_to_guard(line, datum/weakref/opener_ref) - var/mob/living/basic/outpost_prison_guard/opener = opener_ref?.resolve() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/reply_to_guard(line, datum/weakref/opener_ref) + var/mob/living/carbon/human/npc/outpost_prison_guard/opener = opener_ref?.resolve() if(!on_duty() || !opener || get_dist(src, opener) > 5 || !istext(line)) return FALSE face_atom(opener) @@ -191,7 +191,9 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) // ===== AI ===== /datum/ai_controller/basic_controller/outpost_prison_guard - blackboard = list() + blackboard = list( + BB_TARGET_MINIMUM_STAT = HUMAN_NPC_SPARES_DOWNED, + ) ai_traits = PASSIVE_AI_FLAGS ai_movement = /datum/ai_movement/jps // The routine plans everything, idle time included. @@ -206,7 +208,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) /datum/ai_planning_subtree/outpost_prison_guard /datum/ai_planning_subtree/outpost_prison_guard/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/outpost_prison_guard/guard = controller.pawn + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = controller.pawn if(!istype(guard) || guard.stat != CONSCIOUS || HAS_TRAIT(guard, TRAIT_IMMOBILIZED)) return if(guard.phase == OUTPOST_GUARD_DISMISSED || guard.response) @@ -235,7 +237,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return SUBTREE_RETURN_FINISH_PLANNING /// Queues the walk to `goal`: onto a tile, or up beside a mob -/mob/living/basic/outpost_prison_guard/proc/queue_walk(datum/ai_controller/controller, atom/goal) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/queue_walk(datum/ai_controller/controller, atom/goal) controller.set_blackboard_key(BB_OUTPOST_GUARD_GOAL, goal) controller.queue_behavior(isturf(goal) ? /datum/ai_behavior/outpost_prison_guard_travel : /datum/ai_behavior/outpost_prison_guard_travel/approach, BB_OUTPOST_GUARD_GOAL) @@ -253,7 +255,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) set_movement_target(controller, goal) /datum/ai_behavior/outpost_prison_guard_travel/perform(seconds_per_tick, datum/ai_controller/controller, goal_key) - var/mob/living/basic/outpost_prison_guard/guard = controller.pawn + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = controller.pawn var/atom/goal = controller.blackboard[goal_key] if(QDELETED(goal) || !istype(guard)) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED @@ -269,7 +271,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) . = ..() controller.clear_blackboard_key(goal_key) if(!succeeded) - var/mob/living/basic/outpost_prison_guard/guard = controller.pawn + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = controller.pawn if(istype(guard)) guard.walk_failed(goal) @@ -282,11 +284,11 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) action_cooldown = 1 SECONDS /datum/ai_behavior/outpost_prison_guard_activity/setup(datum/ai_controller/controller) - var/mob/living/basic/outpost_prison_guard/guard = controller.pawn + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = controller.pawn return istype(guard) && !!guard.activity /datum/ai_behavior/outpost_prison_guard_activity/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/outpost_prison_guard/guard = controller.pawn + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = controller.pawn var/datum/outpost_guard_activity/doing = guard.activity if(!doing || guard.response || !guard.on_duty()) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED @@ -320,14 +322,14 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) var/max_duration = 90 SECONDS /// Whether rounds or the other guard's chat may take its place var/interruptible = TRUE - var/mob/living/basic/outpost_prison_guard/guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard /// Where they are headed: a tile, or a mob to stand beside; null once there var/datum/weakref/goal_ref var/started = FALSE var/ends_at = 0 var/failed_moves = 0 -/datum/outpost_guard_activity/New(mob/living/basic/outpost_prison_guard/doer) +/datum/outpost_guard_activity/New(mob/living/carbon/human/npc/outpost_prison_guard/doer) . = ..() guard = doer @@ -377,7 +379,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) /// The other guard of the wing on the payroll, if any is here /datum/outpost_guard_activity/proc/other_guard() - for(var/mob/living/basic/outpost_prison_guard/other in guard.prison?.guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/other in guard.prison?.guard_mobs) if(other != guard && other.on_duty()) return other return null @@ -534,8 +536,8 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) /datum/outpost_guard_activity/watch_yard/tick(seconds) var/obj/structure/hoop/hoop = hoop_ref?.resolve() - var/mob/living/basic/outpost_prisoner/player - for(var/mob/living/basic/outpost_prisoner/prisoner in guard.prison?.prisoners) + var/mob/living/carbon/human/npc/outpost_prisoner/player + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in guard.prison?.prisoners) if(istype(prisoner.activity, /datum/prisoner_activity/basketball) && prisoner.stat == CONSCIOUS) player = prisoner break @@ -600,7 +602,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) var/next_exchange = 0 /datum/outpost_guard_activity/chat/setup() - var/mob/living/basic/outpost_prison_guard/other = other_guard() + var/mob/living/carbon/human/npc/outpost_prison_guard/other = other_guard() if(!can_join(other)) return FALSE partner_ref = WEAKREF(other) @@ -608,13 +610,13 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return TRUE /// Whether the other guard is free to talk -/datum/outpost_guard_activity/chat/proc/can_join(mob/living/basic/outpost_prison_guard/other) +/datum/outpost_guard_activity/chat/proc/can_join(mob/living/carbon/human/npc/outpost_prison_guard/other) if(!other?.on_duty() || !other.ai_running() || other.response) return FALSE return !other.activity || (other.activity.interruptible && !istype(other.activity, /datum/outpost_guard_activity/chat)) /datum/outpost_guard_activity/chat/arrive() - var/mob/living/basic/outpost_prison_guard/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prison_guard/partner = partner_ref?.resolve() if(!partner || !guard.Adjacent(partner) || !can_join(partner)) return FALSE . = ..() @@ -624,7 +626,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) next_exchange = world.time + 1 SECONDS /datum/outpost_guard_activity/chat/tick(seconds) - var/mob/living/basic/outpost_prison_guard/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prison_guard/partner = partner_ref?.resolve() var/datum/outpost_guard_activity/chat/listen/listening = partner?.activity if(!partner || !guard.Adjacent(partner) || !istype(listening) || listening.partner_ref?.resolve() != guard) return GUARD_ACTIVITY_DONE @@ -636,7 +638,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return ..() /datum/outpost_guard_activity/chat/finish() - var/mob/living/basic/outpost_prison_guard/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prison_guard/partner = partner_ref?.resolve() var/datum/outpost_guard_activity/chat/listen/listening = partner?.activity if(istype(listening) && listening.partner_ref?.resolve() == guard) partner.end_activity() @@ -647,7 +649,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) leisure = FALSE weight = 0 -/datum/outpost_guard_activity/chat/listen/New(mob/living/basic/outpost_prison_guard/doer, mob/living/basic/outpost_prison_guard/talker) +/datum/outpost_guard_activity/chat/listen/New(mob/living/carbon/human/npc/outpost_prison_guard/doer, mob/living/carbon/human/npc/outpost_prison_guard/talker) . = ..(doer) partner_ref = WEAKREF(talker) started = TRUE @@ -661,7 +663,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) return TRUE /datum/outpost_guard_activity/chat/listen/tick(seconds) - var/mob/living/basic/outpost_prison_guard/talker = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prison_guard/talker = partner_ref?.resolve() var/datum/outpost_guard_activity/chat/talking = talker?.activity if(!talker || !guard.Adjacent(talker) || !istype(talking) || talking.partner_ref?.resolve() != guard) return GUARD_ACTIVITY_DONE @@ -688,7 +690,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) /datum/outpost_guard_activity/check_on/setup() var/datum/outpost_prison/prison = guard.prison var/list/options = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prison.prisoners) if(prisoner.phase != PRISONER_PRESENT || !prisoner.routine_allowed() || prisoner.activity?.sleeping || !prison.in_cell_block(prisoner)) continue if(prisoner.guard_noticed_at && world.time - prisoner.guard_noticed_at < OUTPOST_GUARD_CHECK_ON_GAP) @@ -698,7 +700,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) options += prisoner if(!length(options)) return FALSE - var/mob/living/basic/outpost_prisoner/chosen = pick(options) + var/mob/living/carbon/human/npc/outpost_prisoner/chosen = pick(options) prisoner_ref = WEAKREF(chosen) need = chosen.bubble chosen.guard_noticed_at = world.time @@ -707,7 +709,7 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) /datum/outpost_guard_activity/check_on/begin() . = ..() - var/mob/living/basic/outpost_prisoner/prisoner = prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = prisoner_ref?.resolve() if(!prisoner) return guard.face_atom(prisoner) @@ -716,12 +718,132 @@ GLOBAL_LIST_INIT(outpost_guard_leisure, outpost_guard_leisure_types()) guard.prison?.note_speech() /datum/outpost_guard_activity/check_on/tick(seconds) - var/mob/living/basic/outpost_prisoner/prisoner = prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = prisoner_ref?.resolve() if(!prisoner || !guard.Adjacent(prisoner)) return GUARD_ACTIVITY_DONE guard.face_atom(prisoner) return ..() +// ----- patching up ----- + +/** + * Hurt (OUTPOST_GUARD_TREAT_ABOVE brute and burn) with nothing going on: they treat themselves the way + * anyone does, with a bruise pack or suture on themselves (tg's medical stacks, their do_after and all). + * Their own pack first, else one from the wing's first-aid kit in the office, which the crew keeps stocked. + * Nothing heals them otherwise. + */ +/datum/outpost_guard_activity/patch_up + name = "patching up" + leisure = TRUE + weight = 0 + min_duration = 20 SECONDS + max_duration = 30 SECONDS + interruptible = FALSE + /// The first-aid kit they are going to, if they have nothing of their own + var/datum/weakref/kit_ref + +/datum/outpost_guard_activity/patch_up/get_weight() + if(!guard.needs_patching() || guard.prison?.riot_active) + return 0 + return 100 + +/datum/outpost_guard_activity/patch_up/setup() + if(guard.carried_treatment()) + return TRUE + var/obj/item/storage/medkit/kit = guard.prison?.guard_first_aid_kit() + if(!kit) + return FALSE + kit_ref = WEAKREF(kit) + set_goal(isturf(kit.loc) && guard.prison.guard_office_tile(kit.loc) ? kit.loc : guard.prison.guard_spot_beside(kit, guard)) + return !!goal() + +/datum/outpost_guard_activity/patch_up/begin() + . = ..() + var/obj/item/storage/medkit/kit = kit_ref?.resolve() + if(kit && !guard.carried_treatment() && get_dist(guard, kit) <= 1) + guard.take_treatment_from(kit) + INVOKE_ASYNC(guard, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prison_guard, treat_self)) + +/datum/outpost_guard_activity/patch_up/tick(seconds) + if(!guard.treating && !guard.needs_patching()) + return GUARD_ACTIVITY_DONE + if(!guard.treating && guard.carried_treatment()) + INVOKE_ASYNC(guard, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prison_guard, treat_self)) + return ..() + +/datum/outpost_guard_activity/patch_up/Destroy() + kit_ref = null + return ..() + +/mob/living/carbon/human/npc/outpost_prison_guard + /// Busy putting a dressing on themselves + var/treating = FALSE + +/// Hurt enough to stop and patch up +/mob/living/carbon/human/npc/outpost_prison_guard/proc/needs_patching() + return getBruteLoss() + getFireLoss() >= OUTPOST_GUARD_TREAT_ABOVE + +/// A bruise pack, suture, ointment or mesh they carry that would do some good now +/mob/living/carbon/human/npc/outpost_prison_guard/proc/carried_treatment() + for(var/obj/item/stack/medical/stack in get_all_contents_type(/obj/item/stack/medical)) + if(QDELETED(stack) || !stack.amount) + continue + if((stack.heal_brute && getBruteLoss()) || (stack.heal_burn && getFireLoss())) + return stack + return null + +/// Takes a pack out of the wing's first-aid kit into a free hand, as anyone would +/mob/living/carbon/human/npc/outpost_prison_guard/proc/take_treatment_from(obj/item/storage/medkit/kit) + for(var/obj/item/stack/medical/stack in kit) + if(!stack.amount || !((stack.heal_brute && getBruteLoss()) || (stack.heal_burn && getFireLoss()))) + continue + if(!put_in_hands(stack)) + return null + // Used up on them, never loot (human_npc_gear.dm) + npc_lock_item(stack) + return stack + return null + +/** + * Puts a dressing on themselves: a medical stack they carry, in a free hand, used on their own body + * through tg's medical code (the do_after, the limb it treats, the stack used up). Sleeps. + */ +/mob/living/carbon/human/npc/outpost_prison_guard/proc/treat_self() + if(treating || stat != CONSCIOUS || !needs_patching()) + return FALSE + var/obj/item/stack/medical/stack = carried_treatment() + if(!stack) + return FALSE + if(!is_holding(stack) && !human_npc_take_in_hand(src, stack)) + set_baton(FALSE) + if(!human_npc_take_in_hand(src, stack)) + return FALSE + if(!human_npc_ready_item(src, stack)) + return FALSE + treating = TRUE + stack.interact_with_atom(src, src) + treating = FALSE + if(!QDELETED(stack) && is_holding(stack)) + stack.equip_to_best_slot(src) + return TRUE + +/// The wing's first-aid kit in the office with something in it, if there is one +/datum/outpost_prison/proc/guard_first_aid_kit() + for(var/turf/tile as anything in staff_ground) + if(tile.loc != wing) + continue + for(var/obj/item/storage/medkit/kit in tile) + if(locate(/obj/item/stack/medical) in kit) + return kit + return null + +/// A free office tile beside `thing`, for a guard to stand at, or null +/datum/outpost_prison/proc/guard_spot_beside(atom/thing, mob/living/except) + for(var/turf/tile in orange(1, get_turf(thing))) + if(guard_office_tile(tile) && !guard_spot_taken(tile, except)) + return tile + return null + #undef GUARD_ACTIVITY_CONTINUE #undef GUARD_ACTIVITY_DONE #undef GUARD_ACTIVITY_MOVE diff --git a/voidcrew/modules/player_outposts/outpost_prison_guards.dm b/voidcrew/modules/player_outposts/outpost_prison_guards.dm index 17a559bba63..8ad15917a66 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_guards.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_guards.dm @@ -3,20 +3,27 @@ * * Owner: XA (extras-plan.md 4.1). NPC guards the wing's managers hire at the warden's console: * named people on the payroll who keep a peaceful routine, step in on spats, arguments, fights, - * threats and hatch climbs, hold the staff doors in a riot, go down instead of dying and come - * back free. Paid per minute while a member is home. Their routine and activities are in + * threats and hatch climbs, and hold the staff doors in a riot. Paid per minute while a member is home. Their routine and activities are in * outpost_prison_guard_routine.dm; numbers in voidcrew/_DEFINES/outpost_prison_guards.dm. * - * Each hired guard is a record on the prison (name, rank, personality, when they are back). The + * A guard is a real human body (voidcrew/modules/human_npcs/, owner ruling OC-2): stuns, stamina, + * batons, bleeding, crit and death work on them as on anyone, and nothing tops their health up. Hurt, + * they patch themselves up with the office first-aid kit (outpost_prison_guard_routine.dm). In crit + * for OUTPOST_GUARD_RECALL_DELAY the corrections service medevacs the real body; a dead guard's body + * is collected OUTPOST_GUARD_BODY_COLLECT_DELAY after they die. Either way their record waits + * OUTPOST_GUARD_RETURN_TIME, then sends them back (the same face, from the record). Their kit is + * locked to them (D3): it never becomes loot. + * + * Each hired guard is a record on the prison (name, rank, personality, face, when they are back). The * guard on the wing is a mob made from the record; when a guard goes down or is called away the * mob beams out and the record waits to send them back. A slot's wait belongs to the prison, so * dismissing a downed guard and hiring another does not skip it. * * Guards are security only: they never feed, clothe, treat, clean or light anything, so they * cannot raise pay. They never strike players, pets, bots or other NPCs, and they strike a - * prisoner only for violence under way (fighting, rioting, swinging, climbing out, loose) with a - * stun baton that does stamina damage and nothing else. The strike never goes through - * melee_attack(), so no hit is put down to staff. + * prisoner only for violence under way (fighting, rioting, swinging, climbing out, loose) with the + * real stun baton off their belt, through their own hands: tg's stamina, knockdown and cell charge. + * A prisoner never holds a guard's blow against staff (hit_by_staff()). * * The dispatcher (dispatch_guards()) runs every second from guards_tick() and only for guards whose * AI is running, which it is only while a player is on the level. Every response has a proc a test @@ -31,7 +38,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// Whether `thing` is one of a prison's NPC guards; with `on_duty`, only one present, arrived and not down /proc/is_outpost_prison_guard(atom/thing, on_duty = FALSE) - var/mob/living/basic/outpost_prison_guard/guard = thing + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = thing if(!istype(guard)) return FALSE return !on_duty || guard.on_duty() @@ -42,34 +49,17 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== THE GUARD ===== -/mob/living/basic/outpost_prison_guard +/mob/living/carbon/human/npc/outpost_prison_guard name = "guard" desc = "A corrections officer on the prison wing's payroll." - icon = 'icons/mob/simple/simple_human.dmi' - unique_name = FALSE combat_mode = FALSE - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - // No sentience potions, no lazarus. - sentience_type = SENTIENCE_HUMANOID - maxHealth = OUTPOST_GUARD_HEALTH - health = OUTPOST_GUARD_HEALTH - speed = OUTPOST_GUARD_SPEED_WALK - // Nobody drags or shoves them about. - move_resist = MOVE_FORCE_VERY_STRONG - density = TRUE - basic_mob_flags = NONE - mobility_flags = MOBILITY_FLAGS_REST_CAPABLE_DEFAULT - rotate_on_lying = TRUE - // No stuns or knockdowns: a guard is up or down (go_down()), nothing between. - status_flags = NONE - damage_coeff = list(BRUTE = 1, BURN = 1, TOX = 0, STAMINA = 0, OXY = 0) - // A breach or a fire in the wing must not kill them. - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - response_help_continuous = "pats" - response_help_simple = "pat" + faction = list(FACTION_NEUTRAL) ai_controller = /datum/ai_controller/basic_controller/outpost_prison_guard + npc_outfit = /datum/outfit/outpost_prison_guard + // They stop once a prisoner is down (defaults 1.13), and only ever go for prisoners. + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + // The corrections service collects a guard's body (guards_tick()); the base never cleans it up. + npc_corpse_lifetime = 0 /// The prison that hired them var/datum/outpost_prison/prison @@ -91,10 +81,14 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" var/list/response_skips /// Whether the stun baton is out in their hand var/baton_out = FALSE + /// world.time they went down (crit) or died, for the medevac and the body's collection + var/down_since = 0 /// The last thing they said, so they don't repeat it at once var/last_line /// Down: seconds until they are beamed out var/down_left = 0 + /// Killed rather than downed: the body is collected, and the record's wait starts after + var/died_on_duty = FALSE /// Dismissed: seconds left to walk to the office before they beam out wherever they are var/dismiss_left = 0 /// Dismissed: where they walk to before beaming out @@ -107,24 +101,19 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// Between "Hey. Don't." lines COOLDOWN_DECLARE(warned_cooldown) -/mob/living/basic/outpost_prison_guard/Initialize(mapload) - gender = pick(MALE, FEMALE) - . = ..() - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) +/mob/living/carbon/human/npc/outpost_prison_guard/Initialize(mapload) + . = ..(mapload) AddElement(/datum/element/relay_attackers) RegisterSignal(src, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) // Nothing moves a guard but their own feet and the prison's beam. RegisterSignal(src, COMSIG_MOVABLE_TELEPORTING, PROC_REF(refuse_teleport)) RegisterSignal(src, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) - RegisterSignal(src, COMSIG_MOUSEDROP_ONTO, PROC_REF(block_being_dragged)) - RegisterSignal(src, COMSIG_ATOM_CAN_BE_PULLED, PROC_REF(refuse_pull)) - ADD_TRAIT(src, TRAIT_NO_CONTAINMENT, INNATE_TRAIT) + // Anyone can drag a guard who is down. A guard on their feet shakes off anyone but the wing's members (guards_tick()). ADD_TRAIT(src, TRAIT_NO_STORAGE_INSERT, INNATE_TRAIT) - // Next tick: spawn_guard() calls join() right after new, which sets the record's gender and face. - addtimer(CALLBACK(src, PROC_REF(guard_build_look)), 1) + set_prison_pace(hurry = FALSE) -/mob/living/basic/outpost_prison_guard/Destroy() +/mob/living/carbon/human/npc/outpost_prison_guard/Destroy() end_response(cancel_ai = FALSE) end_activity(cancel_ai = FALSE) if(record?.guard == src) @@ -138,120 +127,143 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" leave_spot = null return ..() -/// Dresses them as their own person in the guard's outfit (outpost_npc_looks.dm): the record's face and gender. Can sleep. -/mob/living/basic/outpost_prison_guard/proc/guard_build_look() - set_outpost_npc_look(src, /datum/outfit/outpost_prison_guard, gender, record ? record.look_number : 1) - if(QDELETED(src)) - return - update_appearance(UPDATE_OVERLAYS) - -/// Takes on the name, rank, gender and personality of the guard on the payroll -/mob/living/basic/outpost_prison_guard/proc/join(datum/outpost_prison/owner, datum/outpost_guard_record/hired) +/// Takes on the name, rank, face and personality of the guard on the payroll. The first one sent sets the record's face. +/mob/living/carbon/human/npc/outpost_prison_guard/proc/join(datum/outpost_prison/owner, datum/outpost_guard_record/hired) prison = owner record = hired hired.guard = src owner.guard_mobs |= src - gender = hired.gender + if(length(hired.look)) + outpost_prison_apply_look(src, hired.look) + else + hired.look = outpost_prison_save_look(src) + hired.gender = gender personality = hired.personality - real_name = hired.full_name() - name = real_name + fully_replace_character_name(real_name, hired.full_name()) -/// On the wing and on duty: arrived, not down, not dismissed, and not paid by the kingpin to stand back (outpost_prison_boss_moves.dm) -/mob/living/basic/outpost_prison_guard/proc/on_duty() - return !QDELETED(src) && phase == OUTPOST_GUARD_PRESENT && stat == CONSCIOUS && !boss_bribed() +/// On the wing and on duty: arrived, not down, cuffed or stunned, not dismissed, and not paid by the kingpin to stand back (outpost_prison_boss_moves.dm) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/on_duty() + return !QDELETED(src) && phase == OUTPOST_GUARD_PRESENT && stat == CONSCIOUS && !human_npc_out_of_fight(src) && !boss_bribed() /// Whether their AI is running, which it is only while someone is on the level to see them (see outpost_prison_ai_running()) -/mob/living/basic/outpost_prison_guard/proc/ai_running() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/ai_running() return outpost_prison_ai_running(src) -/mob/living/basic/outpost_prison_guard/update_overlays() - . = ..() - if(!baton_out || phase == OUTPOST_GUARD_DOWN) - return - // The lit baton in hand, the way a security officer holds one, turning with them - var/mutable_appearance/baton = mutable_appearance('icons/mob/inhands/equipment/security_righthand.dmi', "stunbaton_active") - baton.plane = FLOAT_PLANE - baton.layer = FLOAT_LAYER - . += baton - -/// Draws or puts away the baton -/mob/living/basic/outpost_prison_guard/proc/set_baton(out) +/// Their stun baton, wherever on them it is +/mob/living/carbon/human/npc/outpost_prison_guard/proc/get_baton() + return locate(/obj/item/melee/baton/security) in get_all_contents() + +/// Draws the baton off the belt into a hand and switches it on, or puts it away again, as an officer does +/mob/living/carbon/human/npc/outpost_prison_guard/proc/set_baton(out) out = !!out - if(baton_out == out) + var/obj/item/melee/baton/security/baton = get_baton() + if(!baton) + baton_out = FALSE + return + if(out) + if(!is_holding(baton) && !human_npc_take_in_hand(src, baton)) + return + if(!baton.active && baton.cell?.charge >= baton.cell_hit_cost) + baton.attack_self(src) + baton_out = TRUE return - baton_out = out - update_appearance(UPDATE_OVERLAYS) + baton_out = FALSE + if(!is_holding(baton)) + return + if(baton.active) + baton.attack_self(src) + if(!equip_to_slot_if_possible(baton, ITEM_SLOT_BELT, disable_warning = TRUE)) + baton.equip_to_best_slot(src) // ===== REFUSALS ===== -/mob/living/basic/outpost_prison_guard/proc/refuse_teleport(datum/source, atom/destination, channel) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/refuse_teleport(datum/source, atom/destination, channel) SIGNAL_HANDLER if(isturf(loc)) visible_message(span_notice("[src] flickers for a moment, but stays where [p_they()] [p_are()].")) return TRUE -/mob/living/basic/outpost_prison_guard/proc/refuse_polymorph(datum/source, what_to_randomize) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/refuse_polymorph(datum/source, what_to_randomize) SIGNAL_HANDLER visible_message(span_notice("[src] shimmers for a moment, then looks the same as before.")) return STOP_WABBAJACK -/mob/living/basic/outpost_prison_guard/proc/refuse_type_change(datum/source) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/refuse_type_change(datum/source) SIGNAL_HANDLER return COMPONENT_BLOCK_MOB_CHANGE -/mob/living/basic/outpost_prison_guard/proc/block_being_dragged(atom/over, mob/user) - SIGNAL_HANDLER - return COMPONENT_CANCEL_MOUSEDROP_ONTO - -/mob/living/basic/outpost_prison_guard/proc/refuse_pull(datum/source, mob/living/puller) - SIGNAL_HANDLER - return COMSIG_ATOM_CANT_PULL +/// On their feet, a guard won't be led about by anyone but the wing's members: they resist it, as anyone resists a pull +/mob/living/carbon/human/npc/outpost_prison_guard/proc/shake_off_pull() + if(!pulledby || !on_duty() || world.time < next_move) + return + if(isliving(pulledby) && prison?.is_member(pulledby)) + return + resist() -// ===== HURT AND DOWN ===== +// ===== HURT, DOWN AND DEAD ===== /** - * Damage never takes a guard below OUTPOST_GUARD_DOWN_AT: at that point they go down instead, - * and while down nothing hurts them (TRAIT_GODMODE). Forced damage is held to the same floor. + * Into crit: they go down. The corrections service medevacs the real body after + * OUTPOST_GUARD_RECALL_DELAY (guards_tick()); treated and back out of crit before then, they are back on duty. */ -/mob/living/basic/outpost_prison_guard/adjust_health(amount, updating_health = TRUE, forced = FALSE) - var/floor_loss = maxHealth - OUTPOST_GUARD_DOWN_AT - if(amount > 0) - if(phase == OUTPOST_GUARD_DOWN || phase == OUTPOST_GUARD_LEAVING || bruteloss >= floor_loss) - return 0 - amount = min(amount, floor_loss - bruteloss) +/mob/living/carbon/human/npc/outpost_prison_guard/npc_on_downed() . = ..() - if(bruteloss >= floor_loss && prison) - INVOKE_ASYNC(src, PROC_REF(go_down)) + go_down() -/// Guards never die: whatever would kill one puts them down instead -/mob/living/basic/outpost_prison_guard/death(gibbed) - if(!gibbed && prison && phase != OUTPOST_GUARD_LEAVING) - go_down() +/// Back out of crit before the medevac came: on their feet and on duty again +/mob/living/carbon/human/npc/outpost_prison_guard/npc_on_recovered() + . = ..() + if(phase != OUTPOST_GUARD_DOWN || died_on_duty || stat == DEAD || HAS_TRAIT(src, TRAIT_CRITICAL_CONDITION)) return - return ..() + phase = OUTPOST_GUARD_PRESENT + down_left = 0 + down_since = 0 + prison?.add_log("[real_name] is back on [p_their()] feet.") + +/// The prison's guards speak from their own file (say_guard()); a few of tg's changes map onto their lines. +/mob/living/carbon/human/npc/outpost_prison_guard/npc_bark(kind, force = FALSE) + switch(kind) + if(HUMAN_NPC_BARK_DOWNED) + return say_guard("downed") + return FALSE + +/mob/living/carbon/human/npc/outpost_prison_guard/death(gibbed) + var/was_alive = stat != DEAD + . = ..() + if(!was_alive || stat != DEAD) + return + died_on_duty = TRUE + if(phase == OUTPOST_GUARD_LEAVING || !prison) + return + end_response() + end_activity() + baton_out = FALSE + phase = OUTPOST_GUARD_DOWN + down_since = world.time + down_left = OUTPOST_GUARD_BODY_COLLECT_DELAY / (1 SECONDS) + prison.guard_killed(src) /** - * Down: lying there, nothing more can hurt them, and OUTPOST_GUARD_RECALL_DELAY later they are - * beamed out, to come back free OUTPOST_GUARD_RETURN_TIME after that (guards_tick()). + * Down in crit: lying there, and OUTPOST_GUARD_RECALL_DELAY later the corrections service medevacs the + * body, to send them back OUTPOST_GUARD_RETURN_TIME after that (guards_tick()). Nothing protects them + * meanwhile: they can bleed out, or be finished off. */ -/mob/living/basic/outpost_prison_guard/proc/go_down() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/go_down() if(QDELETED(src) || phase == OUTPOST_GUARD_DOWN || phase == OUTPOST_GUARD_LEAVING) return FALSE var/was_leaving = phase == OUTPOST_GUARD_DISMISSED phase = OUTPOST_GUARD_DOWN end_response() end_activity() - set_baton(FALSE) - ADD_TRAIT(src, TRAIT_GODMODE, OUTPOST_GUARD_DOWN_TRAIT) - add_traits(list(TRAIT_FLOORED, TRAIT_IMMOBILIZED, TRAIT_INCAPACITATED), OUTPOST_GUARD_DOWN_TRAIT) + baton_out = FALSE + down_since = world.time down_left = was_leaving ? 0 : OUTPOST_GUARD_RECALL_DELAY / (1 SECONDS) visible_message(span_danger("[src] goes down!")) - say_guard("downed") prison?.add_log("[real_name] went down.") return TRUE /// Someone took a swing at them. Prisoners are the dispatcher's business; anyone else gets a warning and some room. -/mob/living/basic/outpost_prison_guard/proc/on_attacked(datum/source, atom/attacker, attack_flags) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/on_attacked(datum/source, atom/attacker, attack_flags) SIGNAL_HANDLER if(!(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK | ATTACKER_SHOVING))) return @@ -260,7 +272,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" INVOKE_ASYNC(src, PROC_REF(warn_off), attacker) /// "Hey. Don't." and a step back. Guards never strike players. -/mob/living/basic/outpost_prison_guard/proc/warn_off(atom/attacker) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/warn_off(atom/attacker) if(!on_duty() || QDELETED(attacker)) return FALSE if(!COOLDOWN_FINISHED(src, warned_cooldown)) @@ -273,12 +285,6 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" log_combat(attacker, src, "attacked a prison guard") return TRUE -/// Healing slowly while nothing is going on -/mob/living/basic/outpost_prison_guard/proc/heal_tick(seconds) - if(phase != OUTPOST_GUARD_PRESENT || bruteloss <= 0 || prison?.riot_active) - return - adjust_health(-OUTPOST_GUARD_HEAL_PER_MINUTE * seconds / 60) - // ===== BEAMING IN AND OUT ===== /** @@ -286,7 +292,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * the whole beam. They stay OUTPOST_GUARD_ARRIVING (held still, no routine, no responses, no * speech) until finish_beam_in() as the beam ends. */ -/mob/living/basic/outpost_prison_guard/proc/beam_in() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/beam_in() phase = OUTPOST_GUARD_ARRIVING ADD_TRAIT(src, TRAIT_IMMOBILIZED, OUTPOST_GUARD_BEAM_TRAIT) var/turf/spot = get_turf(src) @@ -298,7 +304,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" addtimer(CALLBACK(src, PROC_REF(finish_beam_in)), OUTPOST_PRISON_BEAM_TIME) /// Fully there as the beam ends: the flash, and only now are they on duty, and say so -/mob/living/basic/outpost_prison_guard/proc/finish_beam_in() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/finish_beam_in() if(phase != OUTPOST_GUARD_ARRIVING) return FALSE var/turf/spot = get_turf(src) @@ -318,7 +324,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * Dematerialises them and deletes them at the end of the beam. Their record, if they are still on * the payroll, sends them back after `away_seconds` (guards_tick()). */ -/mob/living/basic/outpost_prison_guard/proc/beam_out(away_seconds = 0) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/beam_out(away_seconds = 0) if(phase == OUTPOST_GUARD_LEAVING) return FALSE phase = OUTPOST_GUARD_LEAVING @@ -339,7 +345,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" addtimer(CALLBACK(src, PROC_REF(finish_beam_out)), OUTPOST_PRISON_BEAM_TIME) return TRUE -/mob/living/basic/outpost_prison_guard/proc/finish_beam_out() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/finish_beam_out() var/turf/spot = get_turf(src) if(spot) new /obj/effect/temp_visual/transporter_flash/departure(spot) @@ -348,7 +354,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" qdel(src) /// Called back from somewhere they should not be (off the wing's level, or out of the wing with nobody about) -/mob/living/basic/outpost_prison_guard/proc/recall(away_seconds = OUTPOST_GUARD_OFF_LEVEL_AWAY) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/recall(away_seconds = OUTPOST_GUARD_OFF_LEVEL_AWAY) if(phase != OUTPOST_GUARD_PRESENT) return FALSE say_guard("recalled") @@ -359,7 +365,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * Off the payroll: they say `context`, walk back to the office and beam out there, or wherever * they are after OUTPOST_GUARD_DISMISS_WALK seconds. Down or arriving, they beam out at once. */ -/mob/living/basic/outpost_prison_guard/proc/leave(context = "dismissed") +/mob/living/carbon/human/npc/outpost_prison_guard/proc/leave(context = "dismissed") if(phase == OUTPOST_GUARD_LEAVING || phase == OUTPOST_GUARD_DISMISSED) return FALSE if(phase != OUTPOST_GUARD_PRESENT || stat != CONSCIOUS) @@ -368,7 +374,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" end_response() end_activity() set_baton(FALSE) - set_varspeed(OUTPOST_GUARD_SPEED_WALK) + set_prison_pace(hurry = FALSE) say_guard(context) dismiss_left = OUTPOST_GUARD_DISMISS_WALK leave_spot = prison?.guard_office_spot(src) @@ -377,7 +383,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== SPEECH ===== /// A line for `context` in their personality (or anyone's), with `values` filled in, or null -/mob/living/basic/outpost_prison_guard/proc/pick_guard_line(context, list/values) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/pick_guard_line(context, list/values) var/list/guard_lines = outpost_guard_dialogue("guard_lines") var/list/by_personality = guard_lines[context] if(!islist(by_personality)) @@ -396,7 +402,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return line /// The lines of `pool` that are not the last thing they said and need no placeholder `values` lacks -/mob/living/basic/outpost_prison_guard/proc/usable_guard_lines(list/pool, list/values) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/usable_guard_lines(list/pool, list/values) var/list/usable = list() for(var/line in pool) if(!istext(line) || line == last_line) @@ -411,7 +417,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return usable /// Says a line for `context`. Returns TRUE if they said something. Never while beaming in or out, invisible or half there. -/mob/living/basic/outpost_prison_guard/proc/say_guard(context, list/values) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/say_guard(context, list/values) if(QDELETED(src) || stat == DEAD || phase == OUTPOST_GUARD_ARRIVING || phase == OUTPOST_GUARD_LEAVING) return FALSE var/line = pick_guard_line(context, values) @@ -422,7 +428,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return TRUE /// An idle line: waits for the wing to be quiet and for their own pause, and counts toward the wing's -/mob/living/basic/outpost_prison_guard/proc/idle_say(context, list/values) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/idle_say(context, list/values) if(!prison?.wing_can_speak() || !COOLDOWN_FINISHED(src, speech_cooldown)) return FALSE if(!say_guard(context, values)) @@ -438,7 +444,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * and making violent trouble right now (rioting, breaking out, fighting with blows, swinging at * someone or having just struck someone, climbing out, or loose). Never a calm or downed prisoner. */ -/mob/living/basic/outpost_prison_guard/proc/may_strike(mob/living/basic/outpost_prisoner/prisoner) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/may_strike(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!on_duty() || !is_outpost_prisoner(prisoner) || QDELETED(prisoner) || !prison || prisoner.prison != prison) return FALSE if(prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT || prisoner.can_be_dragged() || !Adjacent(prisoner)) @@ -451,29 +457,46 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return prisoner.guard_struck_recently() /** - * One stun baton strike: OUTPOST_GUARD_BATON_STAMINA stamina, their blows stop, and every prisoner - * who sees it loses a little mood. Not an attack in tg's sense (no relay_attackers, no - * hit_by_staff()), so it is never held against staff and never hurts. Returns TRUE if it landed. + * One strike with the real stun baton off their belt, through their own hands: tg's baton does its + * stamina damage and knockdown, against the prisoner's armour, off the cell's charge. Their blows stop, + * and every prisoner who sees it loses a little mood. A prisoner never holds a guard's blow against staff + * (hit_by_staff()). Returns TRUE if they swung. */ -/mob/living/basic/outpost_prison_guard/proc/strike(mob/living/basic/outpost_prisoner/prisoner) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/strike(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!COOLDOWN_FINISHED(src, baton_cooldown) || !may_strike(prisoner)) return FALSE - COOLDOWN_START(src, baton_cooldown, OUTPOST_GUARD_BATON_COOLDOWN) + var/obj/item/melee/baton/security/baton = get_baton() + // A flat cell would make it a club, and guards never hurt anyone: they go to change it (baton_flat()). + if(!baton || !baton.cell || baton.cell.charge < baton.cell_hit_cost) + baton_flat() + return FALSE set_baton(TRUE) - face_atom(prisoner) - do_attack_animation(prisoner) - playsound(prisoner, 'sound/items/weapons/egloves.ogg', 50, TRUE) - prisoner.visible_message(span_danger("[src] strikes [prisoner] with a stun baton!"), span_userdanger("[src] strikes you with a stun baton!")) - prisoner.apply_damage(OUTPOST_GUARD_BATON_STAMINA, STAMINA) + if(!is_holding(baton) || !baton.active) + return FALSE + COOLDOWN_START(src, baton_cooldown, OUTPOST_GUARD_BATON_COOLDOWN) + if(!outpost_prison_npc_hit(src, prisoner, baton)) + return FALSE prisoner.stop_blows() prison.guard_strike_seen(prisoner, src) return TRUE +/** + * Their baton's cell is flat. The office recharger can't take a baton that is locked to its guard + * (human_npc_gear.dm), so the corrections service swaps the shift: they say so and beam out, and the + * record sends them back with a charged baton after OUTPOST_GUARD_SHIFT_SWAP_TIME. Their wages stop meanwhile. + */ +/mob/living/carbon/human/npc/outpost_prison_guard/proc/baton_flat() + if(phase != OUTPOST_GUARD_PRESENT || !prison) + return FALSE + say_guard("recalled") + prison.add_log("[real_name]'s baton went flat. [p_They()] went to swap shifts.") + return beam_out(OUTPOST_GUARD_SHIFT_SWAP_TIME / (1 SECONDS)) + /// Strikes a rioter beside them. Returns TRUE if they struck. -/mob/living/basic/outpost_prison_guard/proc/strike_adjacent_rioter() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/strike_adjacent_rioter() if(!COOLDOWN_FINISHED(src, baton_cooldown)) return FALSE - for(var/mob/living/basic/outpost_prisoner/rioter in orange(1, src)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter in orange(1, src)) if(rioter.is_rioting() && strike(rioter)) return TRUE return FALSE @@ -481,29 +504,29 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== RESPONSES ===== /// Sends them to deal with something, dropping their routine -/mob/living/basic/outpost_prison_guard/proc/give_response(kind, priority, key, atom/target, turf/spot) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/give_response(kind, priority, key, atom/target, turf/spot) end_response(cancel_ai = FALSE) end_activity(cancel_ai = FALSE) response = new(kind, key, priority, target, spot) response.expires_at = world.time + OUTPOST_GUARD_RESPONSE_TIMEOUT - set_varspeed(OUTPOST_GUARD_SPEED_HURRY) + set_prison_pace(hurry = TRUE) set_baton(TRUE) ai_controller?.CancelActions() return response /// Back to routine -/mob/living/basic/outpost_prison_guard/proc/end_response(cancel_ai = TRUE) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/end_response(cancel_ai = TRUE) if(!response) return QDEL_NULL(response) if(!QDELETED(src)) - set_varspeed(OUTPOST_GUARD_SPEED_WALK) + set_prison_pace(hurry = FALSE) set_baton(FALSE) if(cancel_ai) ai_controller?.CancelActions() /// Whether they gave up on an incident recently -/mob/living/basic/outpost_prison_guard/proc/skipping(key) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/skipping(key) return LAZYACCESS(response_skips, key) > world.time /** @@ -511,7 +534,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * the AI walks them to) and, once there, acts. Called by the dispatcher every second. Returns * TRUE while the response goes on. */ -/mob/living/basic/outpost_prison_guard/proc/perform_response() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/perform_response() var/datum/outpost_guard_response/job = response if(!job) return FALSE @@ -545,7 +568,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return TRUE /// Whether they stand where a response wants them: beside a mob, or on a tile -/mob/living/basic/outpost_prison_guard/proc/at_goal(atom/goal) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/at_goal(atom/goal) if(!goal) return TRUE if(isturf(goal)) @@ -557,13 +580,13 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * it down (the prison's guard_talkdown_chance); once blows fly, "Break it up!", then the baton on * the first fighter every OUTPOST_GUARD_BATON_COOLDOWN until the fight is over. */ -/mob/living/basic/outpost_prison_guard/proc/fight_response(datum/outpost_guard_response/job) - var/mob/living/basic/outpost_prisoner/fighter = job.target() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/fight_response(datum/outpost_guard_response/job) + var/mob/living/carbon/human/npc/outpost_prisoner/fighter = job.target() var/datum/outpost_prison_fight/brawl = fighter?.fight if(QDELETED(brawl)) return TRUE - var/mob/living/basic/outpost_prisoner/other = brawl.opponent_of(fighter) - var/mob/living/basic/outpost_prisoner/beside = Adjacent(fighter) ? fighter : ((other && Adjacent(other)) ? other : null) + var/mob/living/carbon/human/npc/outpost_prisoner/other = brawl.opponent_of(fighter) + var/mob/living/carbon/human/npc/outpost_prisoner/beside = Adjacent(fighter) ? fighter : ((other && Adjacent(other)) ? other : null) if(!beside) job.set_goal(fighter) return FALSE @@ -595,8 +618,8 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * decided to swing, or struck someone in the last OUTPOST_GUARD_STRUCK_RECENT, gets the baton * after the warning instead, until they stop. */ -/mob/living/basic/outpost_prison_guard/proc/threat_response(datum/outpost_guard_response/job) - var/mob/living/basic/outpost_prisoner/prisoner = job.target() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/threat_response(datum/outpost_guard_response/job) + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = job.target() if(!prisoner || prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT || prisoner.can_be_dragged() || prisoner.trouble) return TRUE var/violent = prisoner.swing_ref || prisoner.guard_struck_recently() @@ -622,8 +645,8 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return FALSE /// A prisoner climbing over a serving hatch: to the hatch's office side, "Get down off that!", and down they slide -/mob/living/basic/outpost_prison_guard/proc/climb_response(datum/outpost_guard_response/job) - var/mob/living/basic/outpost_prisoner/climber = job.target() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/climb_response(datum/outpost_guard_response/job) + var/mob/living/carbon/human/npc/outpost_prisoner/climber = job.target() var/obj/structure/table/reinforced/prison_hatch/hatch = climber?.climb_ref?.resolve() if(!hatch) return TRUE @@ -647,7 +670,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * OUTPOST_GUARD_FOLLOW_RANGE of them and go for any rioter at them. Otherwise they hold the staff * door from the office. They baton any rioter beside them whatever they are doing. */ -/mob/living/basic/outpost_prison_guard/proc/riot_response(datum/outpost_guard_response/job) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/riot_response(datum/outpost_guard_response/job) if(!prison.riot_active) return TRUE job.reached = TRUE @@ -657,7 +680,9 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" job.spot = prison.guard_fallback_spot(src) say_guard("fallback") job.set_goal((job.spot && loc != job.spot) ? job.spot : null) - strike_adjacent_rioter() + if(!strike_adjacent_rioter() && (!job.spot || loc == job.spot)) + // Back in the corner, they patch themselves up from the office's first-aid kit (outpost_prison_guard_routine.dm). + INVOKE_ASYNC(src, PROC_REF(treat_self)) return FALSE var/mob/living/member = prison.guard_member_in_cell_block() if(member) @@ -665,7 +690,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" job.mode = "follow" job.spot = null say_guard("follow_member", list("{boss}" = prison.guard_boss_name(member))) - var/mob/living/basic/outpost_prisoner/attacker = prison.rioter_beside(member) + var/mob/living/carbon/human/npc/outpost_prisoner/attacker = prison.rioter_beside(member) if(attacker && !Adjacent(attacker)) job.set_goal(attacker) else if(get_dist(src, member) > OUTPOST_GUARD_FOLLOW_RANGE) @@ -690,7 +715,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * cell block that the other guard is not holding, the tiles to its sides first so the doorway * itself stays clear for the crew. Sets it as their response's spot. Returns it, or null. */ -/mob/living/basic/outpost_prison_guard/proc/take_hold_position() +/mob/living/carbon/human/npc/outpost_prison_guard/proc/take_hold_position() if(!prison) return null for(var/turf/spot as anything in prison.guard_hold_spots()) @@ -705,7 +730,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * it is dead, taken or shut in. They never strike the creatures. The rest of an experiment (the dose, * the incubation, the slug in the vents) is no reason to leave the yard to itself. */ -/mob/living/basic/outpost_prison_guard/proc/shelter_response(datum/outpost_guard_response/job) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/shelter_response(datum/outpost_guard_response/job) if(!prison.creature_out()) return TRUE job.reached = TRUE @@ -715,7 +740,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return FALSE /// Wandered out of the wing: back to the office -/mob/living/basic/outpost_prison_guard/proc/leash_response(datum/outpost_guard_response/job) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/leash_response(datum/outpost_guard_response/job) if(get_area(src) == prison.wing) outside_seconds = 0 return TRUE @@ -728,14 +753,14 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== THE PRISONER'S SIDE ===== -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// world.time a guard last checked on them, or they last said hello to a guard var/guard_noticed_at = 0 /// world.time they last lost mood seeing a guard's baton var/guard_onlooker_at = 0 /// Whether they struck someone (not another prisoner) in the last OUTPOST_GUARD_STRUCK_RECENT -/mob/living/basic/outpost_prisoner/proc/guard_struck_recently() +/mob/living/carbon/human/npc/outpost_prisoner/proc/guard_struck_recently() return struck_at && world.time - struck_at <= OUTPOST_GUARD_STRUCK_RECENT && !isnull(struck_ref?.resolve()) // ===== THE PAYROLL ===== @@ -744,28 +769,28 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /datum/outpost_guard_record var/datum/outpost_prison/prison /// The guard on the wing now, if any - var/mob/living/basic/outpost_prison_guard/guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard /// "Officer" or "Sergeant" var/rank = "Officer" var/surname = "Hale" var/gender = MALE var/personality = "by_the_book" - /// Which face they have (set_outpost_npc_look()) - var/look_number = 1 + /// Their face, saved from the first body sent (outpost_prison_save_look()), so the same person comes back + var/list/look /// An admin's guard: no fee, no wage, not counted against guard_max() var/free = FALSE var/dismissed = FALSE /// Seconds until they are sent back while away (down, recalled); they come only while a member is home var/away_left = 0 /// What the guard is made as (tests use a subtype) - var/guard_type = /mob/living/basic/outpost_prison_guard + var/guard_type = /mob/living/carbon/human/npc/outpost_prison_guard /datum/outpost_guard_record/New(datum/outpost_prison/owner, rank, free = FALSE, guard_type) . = ..() prison = owner src.rank = rank src.free = free - if(ispath(guard_type, /mob/living/basic/outpost_prison_guard)) + if(ispath(guard_type, /mob/living/carbon/human/npc/outpost_prison_guard)) src.guard_type = guard_type gender = pick(MALE, FEMALE) if(length(GLOB.last_names)) @@ -773,7 +798,6 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" var/list/personalities = outpost_guard_dialogue("guard_personalities") if(length(personalities)) personality = pick(personalities) - look_number = random_outpost_npc_look_number() /datum/outpost_guard_record/Destroy() if(guard?.record == src) @@ -789,7 +813,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// Seconds before this slot could have a guard on the wing again: the down time and the return, or the time still away /datum/outpost_guard_record/proc/pending_wait() if(guard) - return guard.phase == OUTPOST_GUARD_DOWN ? guard.down_left + OUTPOST_GUARD_RETURN_TIME / (1 SECONDS) : 0 + return guard.phase == OUTPOST_GUARD_DOWN ? max(0, guard.down_left) + OUTPOST_GUARD_RETURN_TIME / (1 SECONDS) : 0 return max(0, away_left) /// What the console calls them: arriving, post, rounds, routine, responding, riot, down or away @@ -816,7 +840,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// Every guard on the payroll var/list/datum/outpost_guard_record/guard_records = list() /// Every guard mob of this prison, on the wing or leaving it - var/list/mob/living/basic/outpost_prison_guard/guard_mobs = list() + var/list/mob/living/carbon/human/npc/outpost_prison_guard/guard_mobs = list() /// Seconds each slot still waits for a dismissed guard's return, passed on to the next hire var/list/guard_slot_waits = list() /// Wages earned and not yet charged, in credits, and the seconds into the charging interval @@ -911,7 +935,8 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" if(!spot) return null var/datum/npc_spawn_context/context = new(outpost, role = "exempt") - var/mob/living/basic/outpost_prison_guard/guard = spawn_npc_in_zone(record.guard_type, spot, context, FALSE) + // One at a time, as they are hired or sent back: no need for the human NPC spawn queue. + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = spawn_npc_in_zone(record.guard_type, spot, context, FALSE) guard.join(src, record) context.finalize() record.away_left = 0 @@ -933,7 +958,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" guard_slot_waits += wait record.dismissed = TRUE guard_records -= record - var/mob/living/basic/outpost_prison_guard/guard = record.guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = record.guard record.guard = null if(guard) guard.record = null @@ -967,20 +992,20 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" record.away_left = max(0, record.away_left - seconds) if(record.away_left <= 0 && home) spawn_guard(record) - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs.Copy()) if(QDELETED(guard)) continue switch(guard.phase) if(OUTPOST_GUARD_DOWN) guard.down_left -= seconds if(guard.down_left <= 0) - guard.beam_out(OUTPOST_GUARD_RETURN_TIME / (1 SECONDS)) + guard_medevac(guard) if(OUTPOST_GUARD_DISMISSED) guard.dismiss_left -= seconds if(guard.dismiss_left <= 0 || (guard.leave_spot && guard.loc == guard.leave_spot) || !guard.ai_running()) guard.beam_out() if(OUTPOST_GUARD_PRESENT) - guard.heal_tick(seconds) + guard.shake_off_pull() guard_rounds_tick(seconds) guard_check_clock += seconds if(guard_check_clock >= OUTPOST_GUARD_CHECK_SECONDS) @@ -990,6 +1015,33 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" guard_yard_chatter() dispatch_guards() +/** + * The corrections service takes a downed guard's real body away (a transport: the same beam as + * everyone's, with no heal), or a dead guard's body once it has lain for OUTPOST_GUARD_BODY_COLLECT_DELAY. + * Their wages stopped when they went down (guard_payroll()); their record sends them back after + * OUTPOST_GUARD_RETURN_TIME. A body nobody can reach (off the level, buckled, in a bag) waits. + */ +/datum/outpost_prison/proc/guard_medevac(mob/living/carbon/human/npc/outpost_prison_guard/guard) + if(QDELETED(guard) || guard.phase != OUTPOST_GUARD_DOWN) + return FALSE + if(!isturf(guard.loc)) + return FALSE + guard.pulledby?.stop_pulling() + guard.buckled?.unbuckle_mob(guard, force = TRUE) + add_log(guard.stat == DEAD ? "The corrections service collected [guard.real_name]'s body." : "The corrections service took [guard.real_name] away for treatment.") + return guard.beam_out(OUTPOST_GUARD_RETURN_TIME / (1 SECONDS)) + +/** + * A guard was killed on duty. Logged with whoever last hurt them (the warden's log and the game log), + * and the yard hears of it. The slot waits out the body's collection and the return before anyone is sent. + */ +/datum/outpost_prison/proc/guard_killed(mob/living/carbon/human/npc/outpost_prison_guard/guard) + var/mob/killer = guard.lastattacker ? get_mob_by_ckey(guard.lastattackerckey) : null + add_log("[guard.real_name] was killed on duty.") + log_game("PLAYER OUTPOST PRISON: '[outpost?.name]': guard [guard.real_name] was killed[killer ? " (last hit by [key_name(killer)])" : ""]") + if(killer && !is_member(killer)) + announce("Prison wing: [guard.real_name] was killed on duty.", SHIP_NOTIFY_WARNING) + /** * Wages: OUTPOST_GUARD_WAGE a minute for each hired guard on the wing, only while a member is * home, charged every OUTPOST_PRISON_DEPOSIT_INTERVAL. @@ -1050,7 +1102,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" guard_rounds_left -= seconds if(guard_rounds_left > 0) return - for(var/mob/living/basic/outpost_prison_guard/guard in shuffle(guard_mobs)) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in shuffle(guard_mobs)) if(!guard.on_duty() || !guard.ai_running() || guard.response) continue if(guard.activity && (!guard.activity.interruptible || istype(guard.activity, /datum/outpost_guard_activity/rounds))) @@ -1069,7 +1121,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" */ /datum/outpost_prison/proc/guard_leash_check() var/z = wing_z() - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs) if(guard.phase != OUTPOST_GUARD_PRESENT) continue var/turf/here = get_turf(guard) @@ -1101,7 +1153,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" */ /datum/outpost_prison/proc/dispatch_guards() var/list/on_duty = list() - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs) if(guard.on_duty() && guard.ai_running()) on_duty += guard if(!riot_active) @@ -1112,7 +1164,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" if(!length(on_duty)) return if(creature_about) - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.response?.kind == "shelter") continue guard.give_response("shelter", OUTPOST_GUARD_PRIORITY_SHELTER, "shelter", null, guard_office_spot(guard)) @@ -1120,7 +1172,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" guard_shelter_called = TRUE guard.say_guard("creature_flee") else if(riot_active) - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.response?.kind == "riot") continue guard.give_response("riot", OUTPOST_GUARD_PRIORITY_RIOT, "riot", null, null) @@ -1129,7 +1181,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" assign_guard_incidents(on_duty) guard_stop_spat(on_duty) guard_watch_loose(on_duty) - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) guard.perform_response() /** @@ -1139,7 +1191,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" */ /datum/outpost_prison/proc/guard_incidents() var/list/found = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS || prisoner.can_be_dragged()) continue if(prisoner.climb_ref) @@ -1166,7 +1218,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" for(var/list/incident as anything in guard_incidents()) var/key = incident[1] var/handled = FALSE - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.response?.key == key) handled = TRUE // A fight's priority rises once blows fly. @@ -1174,15 +1226,15 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" break if(handled) continue - var/mob/living/basic/outpost_prisoner/prisoner = incident[4] + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = incident[4] var/atom/where = prisoner var/turf/spot if(incident[2] == "climb") var/obj/structure/table/reinforced/prison_hatch/hatch = prisoner.climb_ref?.resolve() spot = hatch?.staff_side_turf() where = spot || prisoner - var/mob/living/basic/outpost_prison_guard/best - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + var/mob/living/carbon/human/npc/outpost_prison_guard/best + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.response && guard.response.priority >= incident[3]) continue if(guard.skipping(key)) @@ -1195,13 +1247,13 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /datum/outpost_prison/proc/guard_stop_spat(list/on_duty) if(spat_lines_left <= 0) return FALSE - var/mob/living/basic/outpost_prisoner/one = spat_first_ref?.resolve() - var/mob/living/basic/outpost_prisoner/two = spat_second_ref?.resolve() - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + var/mob/living/carbon/human/npc/outpost_prisoner/one = spat_first_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/two = spat_second_ref?.resolve() + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.response) continue var/list/seen = view(OUTPOST_GUARD_SPAT_VIEW, guard) - var/mob/living/basic/outpost_prisoner/speaker = (one && (one in seen)) ? one : ((two && (two in seen)) ? two : null) + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = (one && (one in seen)) ? one : ((two && (two in seen)) ? two : null) if(!speaker) continue guard.face_atom(speaker) @@ -1213,19 +1265,19 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// Loose prisoners: one guard calls each one out, once, and any guard they come beside batons them. No chase. /datum/outpost_prison/proc/guard_watch_loose(list/on_duty) var/list/called = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.trouble != PRISONER_TROUBLE_LOOSE || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) continue var/key = REF(prisoner) called[key] = TRUE if(!guard_loose_called[key]) // Said aloud, and noted in the warden's log - var/mob/living/basic/outpost_prison_guard/crier = pick(on_duty) + var/mob/living/carbon/human/npc/outpost_prison_guard/crier = pick(on_duty) if(crier.say_guard("loose_call", list("{place}" = get_area_name(prisoner)))) add_log("[crier.real_name]: \"[crier.last_line]\"") if(prisoner.can_be_dragged()) continue - for(var/mob/living/basic/outpost_prison_guard/guard as anything in on_duty) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard as anything in on_duty) if(guard.strike(prisoner)) break guard_loose_called = called @@ -1241,18 +1293,18 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" var/list/first_names = list() var/list/full_names = list() var/list/places = list() - for(var/mob/living/basic/outpost_prisoner/runner as anything in runners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/runner as anything in runners) first_names += runner.speech_name() full_names += runner.real_name places |= get_area_name(runner) // Every line says where they were last seen var/list/values = list("{prisoner}" = english_list(first_names), "{place}" = english_list(places)) var/list/on_duty = list() - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs) if(guard.on_duty()) on_duty += guard if(length(on_duty)) - var/mob/living/basic/outpost_prison_guard/crier = pick(on_duty) + var/mob/living/carbon/human/npc/outpost_prison_guard/crier = pick(on_duty) if(crier.say_guard("loose_nearly", values)) add_log("[crier.real_name]: \"[crier.last_line]\"") announce("[crier.real_name]: \"[crier.last_line]\"", SHIP_NOTIFY_DANGER) @@ -1261,7 +1313,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return TRUE /// The riot hold is called out once a riot: said aloud, and noted in the warden's log -/datum/outpost_prison/proc/announce_riot_hold(mob/living/basic/outpost_prison_guard/guard) +/datum/outpost_prison/proc/announce_riot_hold(mob/living/carbon/human/npc/outpost_prison_guard/guard) if(guard_riot_announced) return FALSE guard_riot_announced = TRUE @@ -1271,9 +1323,9 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" return TRUE /// Every prisoner who sees a baton strike loses OUTPOST_GUARD_ONLOOKER_MOOD, at most once per OUTPOST_GUARD_ONLOOKER_GAP each; one may say so -/datum/outpost_prison/proc/guard_strike_seen(mob/living/basic/outpost_prisoner/struck, mob/living/basic/outpost_prison_guard/guard) +/datum/outpost_prison/proc/guard_strike_seen(mob/living/carbon/human/npc/outpost_prisoner/struck, mob/living/carbon/human/npc/outpost_prison_guard/guard) var/list/watchers = list() - for(var/mob/living/basic/outpost_prisoner/onlooker in view(OUTPOST_GUARD_ONLOOKER_RANGE, struck)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/onlooker in view(OUTPOST_GUARD_ONLOOKER_RANGE, struck)) if(onlooker == struck || onlooker.prison != src || onlooker.stat != CONSCIOUS || onlooker.phase != PRISONER_PRESENT) continue if(onlooker.guard_onlooker_at && world.time - onlooker.guard_onlooker_at < OUTPOST_GUARD_ONLOOKER_GAP) @@ -1282,8 +1334,8 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" onlooker.adjust_mood(-OUTPOST_GUARD_ONLOOKER_MOOD) watchers += onlooker if(length(watchers) && prob(50)) - var/mob/living/basic/outpost_prisoner/speaker = pick(watchers) - INVOKE_ASYNC(speaker, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context_with), "guard_batoned", list("{staff}" = guard.real_name)) + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick(watchers) + INVOKE_ASYNC(speaker, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context_with), "guard_batoned", list("{staff}" = guard.real_name)) return watchers // ===== MEMBERS, GREETINGS AND REPORTS ===== @@ -1310,7 +1362,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// A rioter on their feet beside `member`, if any /datum/outpost_prison/proc/rioter_beside(mob/living/member) - for(var/mob/living/basic/outpost_prisoner/rioter in orange(1, member)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter in orange(1, member)) if(rioter.prison == src && rioter.is_rioting() && rioter.stat == CONSCIOUS && !rioter.can_be_dragged()) return rioter return null @@ -1335,7 +1387,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" var/list/causes = restless_causes() if(length(causes)) return list("report_restless", list("{cause}" = causes[1])) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(counts_for_tension(prisoner) && ("hurt" in prisoner.bubble_needs())) return list("report_hurt", list("{prisoner}" = prisoner.speech_name())) var/list/dark = conditions_payload()["dark_cells"] @@ -1349,7 +1401,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" if(!length(members)) return for(var/mob/living/member as anything in members) - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs) if(!guard.on_duty() || guard.response || get_dist(guard, member) > OUTPOST_GUARD_REPORT_RANGE || !(member in view(OUTPOST_GUARD_REPORT_RANGE, guard))) continue if(guard.greet_member(member)) @@ -1359,7 +1411,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" * Greets `member` if it is time, else reports to them if it is time. Returns TRUE if they said * something. */ -/mob/living/basic/outpost_prison_guard/proc/greet_member(mob/living/member) +/mob/living/carbon/human/npc/outpost_prison_guard/proc/greet_member(mob/living/member) if(!prison || !on_duty()) return FALSE var/key = member.ckey || REF(member) @@ -1390,10 +1442,10 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /datum/outpost_prison/proc/guard_yard_chatter() if(!wing_can_speak() || !prob(OUTPOST_GUARD_NOTICE_CHANCE)) return FALSE - for(var/mob/living/basic/outpost_prison_guard/guard in shuffle(guard_mobs)) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in shuffle(guard_mobs)) if(!guard.on_duty() || !guard.ai_running()) continue - for(var/mob/living/basic/outpost_prisoner/prisoner in view(OUTPOST_GUARD_NOTICE_RANGE, guard)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in view(OUTPOST_GUARD_NOTICE_RANGE, guard)) if(prisoner.prison != src || !prisoner.ai_running() || !prisoner.routine_allowed() || prisoner.activity?.sleeping) continue if(prisoner.guard_noticed_at && world.time - prisoner.guard_noticed_at < OUTPOST_GUARD_CHECK_ON_GAP) @@ -1431,7 +1483,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" for(var/mob/living/other in tile) if(other != except && other.density) return TRUE - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs) if(guard == except) continue if(guard.response?.spot == tile || guard.activity?.goal_turf() == tile) @@ -1547,7 +1599,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /datum/outpost_prison/proc/guards_admin_payload() var/list/rows = list() for(var/datum/outpost_guard_record/record as anything in guard_records) - var/mob/living/basic/outpost_prison_guard/guard = record.guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = record.guard var/status = record.status() if(status == "away" || status == "down") var/back = record.back_in() @@ -1581,10 +1633,11 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" if(action == "prison_guard_remove") dismiss_guard(record, "dismissed", forget_wait = TRUE) return "remove prison guard [guard_name]" - var/mob/living/basic/outpost_prison_guard/guard = record.guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = record.guard if(!guard?.on_duty()) return list("error" = "That guard is not on duty.") - guard.adjust_health(guard.health - OUTPOST_GUARD_DOWN_AT) + // Real damage over their limbs, enough to put them in crit; tg's crit does the rest. + guard.take_overall_damage(brute = max(0, guard.health) + 5) if(guard.phase != OUTPOST_GUARD_DOWN) guard.go_down() return "put prison guard [guard_name] down" @@ -1593,7 +1646,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== LIFE AND DEATH OF THE PRISON ===== /datum/outpost_prison/proc/guards_destroy() - for(var/mob/living/basic/outpost_prison_guard/guard in guard_mobs.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prison_guard/guard in guard_mobs.Copy()) if(QDELETED(guard)) continue guard.prison = null @@ -1611,7 +1664,7 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" /// The outpost was abandoned: every guard is dismissed, no refund, and beams out /datum/outpost_prison/proc/guards_abandon() for(var/datum/outpost_guard_record/record as anything in guard_records.Copy()) - var/mob/living/basic/outpost_prison_guard/guard = record.guard + var/mob/living/carbon/human/npc/outpost_prison_guard/guard = record.guard dismiss_guard(record, "dismissed", forget_wait = TRUE) guard?.beam_out() guard_slot_waits.Cut() @@ -1621,16 +1674,20 @@ GLOBAL_LIST_INIT(outpost_guard_placeholders, list("{boss}", "{count}", "{cause}" // ===== LOOK ===== /** - * A corrections officer: pale blue shirt and tie over navy trousers, a navy peaked police cap, - * black gloves and combat boots, and an earpiece. No red, no orange (that's the inmates), no - * armour; the stun baton is drawn in hand only when they use it (update_overlays()). + * A corrections officer: pale blue shirt and tie over navy trousers, a blue-trimmed vest and helmet, + * black gloves and combat boots, and an earpiece. No red, no orange (that's the inmates). The stun + * baton rides on the belt and is drawn only when they use it (set_baton()); a bruise pack in a pocket + * for their own scrapes. Locked to them (D3): none of it is ever loot. */ /datum/outfit/outpost_prison_guard name = "Outpost prison guard" uniform = /obj/item/clothing/under/rank/security/officer/blueshirt - head = /obj/item/clothing/head/hats/warden/police + suit = /obj/item/clothing/suit/armor/vest/blueshirt + head = /obj/item/clothing/head/helmet/blueshirt gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/jackboots ears = /obj/item/radio/headset + belt = /obj/item/melee/baton/security/loaded + r_pocket = /obj/item/stack/medical/bruise_pack #undef OUTPOST_GUARD_DIALOGUE_FILE diff --git a/voidcrew/modules/player_outposts/outpost_prison_horror.dm b/voidcrew/modules/player_outposts/outpost_prison_horror.dm index 9f977ec2bcc..2fd40b2edd6 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_horror.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_horror.dm @@ -736,7 +736,7 @@ if(victim.stat != CONSCIOUS) return TRUE if(is_outpost_prisoner(victim)) - var/mob/living/basic/outpost_prisoner/prisoner = victim + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = victim return prisoner.can_be_dragged() return HAS_TRAIT_FROM(victim, TRAIT_INCAPACITATED, STAMINA) @@ -785,8 +785,8 @@ if(watcher.client) shake_camera(watcher, 3, 1) if(is_outpost_prisoner(victim)) - var/mob/living/basic/outpost_prisoner/prisoner = victim - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "absorbed") + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = victim + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "absorbed") addtimer(CALLBACK(src, PROC_REF(absorb_proboscis), serial), OUTPOST_HORROR_ABSORB_PROBOSCIS, TIMER_DELETE_ME) addtimer(CALLBACK(src, PROC_REF(absorb_drained), serial), OUTPOST_HORROR_ABSORB_TIME, TIMER_DELETE_ME) return TRUE @@ -1049,7 +1049,7 @@ /// Whether a prisoner is shut in a bolted cell, which it leaves for last /mob/living/basic/outpost_experiment/horror/proc/bolted_away(mob/living/target) - var/mob/living/basic/outpost_prisoner/prisoner = target + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = target return istype(prisoner) && prisoner.in_bolted_cell() /** @@ -1133,7 +1133,7 @@ best_distance = distance if(best) return best - for(var/mob/living/basic/outpost_prisoner/prisoner as anything in event?.prison?.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner as anything in event?.prison?.prisoners) if(!valid_quarry(prisoner) || bolted_away(prisoner)) continue var/distance = get_dist(src, prisoner) diff --git a/voidcrew/modules/player_outposts/outpost_prison_incidents.dm b/voidcrew/modules/player_outposts/outpost_prison_incidents.dm index bf54e8e5320..93a95cd4f00 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_incidents.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_incidents.dm @@ -66,7 +66,7 @@ /// Prisoners who count for wildcards: present, alive, not loose, in the cell block /datum/outpost_prison/proc/wildcard_prisoner_count() var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(counts_for_tension(prisoner) && in_cell_block(prisoner)) count++ return count @@ -146,7 +146,7 @@ /// The kinds and their weights: a stabbing is likelier while someone who could do it has a shiv stashed /datum/outpost_prison/proc/wildcard_kind_weights() var/stab_weight = PRISON_INCIDENT_WEIGHT_STAB - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.cell?.stash_shiv && wildcard_can_act(prisoner)) stab_weight *= PRISON_INCIDENT_STASH_KIND_MULT break @@ -171,7 +171,7 @@ * block, out of trouble and not busy with it, not shut in a cell, owing no lockdown, not an * experiment's subject, and not running from a creature */ -/datum/outpost_prison/proc/wildcard_can_act(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/wildcard_can_act(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || prisoner.prison != src || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) return FALSE if(prisoner.in_trouble() || prisoner.can_be_dragged() || prisoner.pulledby || prisoner.experiment_subject || prisoner.lockdown_left > 0 || prisoner.is_panicking()) @@ -181,7 +181,7 @@ return in_cell_block(prisoner) && !prisoner.is_confined() /// Whether `prisoner` can be the one a stabbing is aimed at: as wildcard_can_act(), but asleep will do -/datum/outpost_prison/proc/wildcard_can_be_target(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/wildcard_can_be_target(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || prisoner.prison != src || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) return FALSE if(prisoner.in_trouble() || prisoner.can_be_dragged() || prisoner.pulledby || prisoner.experiment_subject) @@ -189,7 +189,7 @@ return in_cell_block(prisoner) && !prisoner.is_confined() /// Whether `one` can walk to where `two` stands -/datum/outpost_prison/proc/wildcard_can_reach(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/wildcard_can_reach(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(!one.walkable) refresh_prisoner_reach(one) return !!one.walkable?[get_turf(two)] @@ -218,11 +218,11 @@ var/list/weights = list() var/list/rival_pairs = list() var/list/rival_weights = list() - for(var/mob/living/basic/outpost_prisoner/attacker in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/attacker in prisoners) if(!wildcard_can_act(attacker)) continue var/weight = attacker.cell?.stash_shiv ? PRISON_INCIDENT_STASH_PICK_MULT : 1 - for(var/mob/living/basic/outpost_prisoner/victim in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/victim in prisoners) if(victim == attacker || !wildcard_can_be_target(victim) || !wildcard_can_reach(attacker, victim)) continue if(are_rivals(attacker, victim)) @@ -241,7 +241,7 @@ /datum/outpost_prison/proc/pick_snapper() var/list/options = list() var/list/weights = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!wildcard_can_act(prisoner) || !prisoner.can_join_riot()) continue options += prisoner @@ -253,15 +253,15 @@ /// Who fights whom: list(one, two), rivals first, or null /datum/outpost_prison/proc/pick_fight_pair() var/list/able = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(wildcard_can_act(prisoner) && !prisoner.fight) able += prisoner var/list/pairs = list() var/list/rival_pairs = list() for(var/first_index in 1 to length(able) - 1) - var/mob/living/basic/outpost_prisoner/one = able[first_index] + var/mob/living/carbon/human/npc/outpost_prisoner/one = able[first_index] for(var/second_index in first_index + 1 to length(able)) - var/mob/living/basic/outpost_prisoner/two = able[second_index] + var/mob/living/carbon/human/npc/outpost_prisoner/two = able[second_index] if(!wildcard_can_reach(one, two)) continue if(are_rivals(one, two)) @@ -289,7 +289,7 @@ /// A snap: the tell, or with `skip_tell` the riot at once. Returns TRUE if it started. /datum/outpost_prison/proc/start_snap(skip_tell = FALSE) - var/mob/living/basic/outpost_prisoner/snapper = pick_snapper() + var/mob/living/carbon/human/npc/outpost_prisoner/snapper = pick_snapper() if(!snapper) return FALSE if(skip_tell) @@ -300,7 +300,7 @@ return TRUE /// `snapper` riots, alone unless others are unhappy enough to join. Returns TRUE if the riot started. -/datum/outpost_prison/proc/snap(mob/living/basic/outpost_prisoner/snapper) +/datum/outpost_prison/proc/snap(mob/living/carbon/human/npc/outpost_prisoner/snapper) if(!snapper?.can_join_riot()) return FALSE if(!start_riot("[snapper.real_name] snapped", forced = snapper, ignore_quiet = TRUE)) @@ -318,27 +318,27 @@ // ===== STOPPING ONE ===== /// Whether `prisoner` is working up to a stabbing or a snap right now -/datum/outpost_prison/proc/wildcard_brooding(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/wildcard_brooding(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return !!prisoner && wildcard?.stage == WILDCARD_TELL && wildcard.actor() == prisoner /// The tell for examine, while `prisoner` works up to a stabbing or a snap; or null -/datum/outpost_prison/proc/wildcard_examine(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/wildcard_examine(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!wildcard_brooding(prisoner)) return null if(wildcard.kind != "stab") return "Paces and mutters, fists clenched." - var/mob/living/basic/outpost_prisoner/target = wildcard.target() + var/mob/living/carbon/human/npc/outpost_prisoner/target = wildcard.target() var/stare = target ? "Keeps staring at [target.speech_name()]" : "Keeps staring at someone" return wildcard.last_tell_shown ? "[stare], one hand under [prisoner.p_their()] shirt." : "[stare]." /// A member talked `prisoner` down (talk_down()): a tell under way is called off. Returns TRUE if one was. -/datum/outpost_prison/proc/wildcard_talked_down(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/wildcard_talked_down(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(!wildcard_brooding(prisoner)) return FALSE return wildcard.stopped_by_staff(user, "talk") /// Staff batoned `prisoner` (a signal handler's call: nothing here sleeps): a tell under way is called off -/datum/outpost_prison/proc/wildcard_batoned(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/wildcard_batoned(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(!wildcard_brooding(prisoner)) return FALSE return wildcard.stopped_by_staff(user, "baton") @@ -346,7 +346,7 @@ // ===== COMINGS AND GOINGS ===== /// A prisoner is leaving the roster: a wildcard they are part of ends, and a stabber drops the shiv -/datum/outpost_prison/proc/wildcard_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/wildcard_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!wildcard || (wildcard.actor() != prisoner && wildcard.target() != prisoner)) return if(wildcard.stage == WILDCARD_ATTACK && wildcard.actor() != prisoner) @@ -360,8 +360,8 @@ /// The admin panel's wildcard numbers, with the wing events' (outpost_prison_wing_events.dm) /datum/outpost_prison/proc/incidents_admin_payload() - var/mob/living/basic/outpost_prisoner/actor = wildcard?.actor() - var/mob/living/basic/outpost_prisoner/target = wildcard?.target() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = wildcard?.actor() + var/mob/living/carbon/human/npc/outpost_prisoner/target = wildcard?.target() return list( "chance" = round(outpost_prison_wildcard_chance(tension), 0.1), "gap_left" = round(wildcard_gap_left), @@ -397,7 +397,7 @@ /// A stabbing's fight var/datum/weakref/fight_ref -/datum/outpost_prison_wildcard/New(datum/outpost_prison/owner, kind, mob/living/basic/outpost_prisoner/actor, mob/living/basic/outpost_prisoner/target) +/datum/outpost_prison_wildcard/New(datum/outpost_prison/owner, kind, mob/living/carbon/human/npc/outpost_prisoner/actor, mob/living/carbon/human/npc/outpost_prisoner/target) . = ..() prison = owner src.kind = kind @@ -406,7 +406,7 @@ tell_left = rand(PRISON_INCIDENT_TELL_MIN, PRISON_INCIDENT_TELL_MAX) /datum/outpost_prison_wildcard/Destroy() - var/mob/living/basic/outpost_prisoner/actor = actor() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = actor() if(actor && is_tell_activity(actor.activity)) actor.end_activity() if(prison?.wildcard == src) @@ -425,13 +425,13 @@ return istype(activity, /datum/prisoner_activity/wildcard_shadow) || istype(activity, /datum/prisoner_activity/pace/wildcard) /// Whether a stabbing's victim is still there to go for -/datum/outpost_prison_wildcard/proc/target_ok(mob/living/basic/outpost_prisoner/target) +/datum/outpost_prison_wildcard/proc/target_ok(mob/living/carbon/human/npc/outpost_prisoner/target) return !QDELETED(target) && target.prison == prison && (target in prison.prisoners) && target.phase == PRISONER_PRESENT && target.stat != DEAD && target.trouble != PRISONER_TROUBLE_LOOSE && !target.is_rioting() /// The tell begins: a stare and the shadowing, or the pacing /datum/outpost_prison_wildcard/proc/begin_tell() - var/mob/living/basic/outpost_prisoner/actor = actor() - var/mob/living/basic/outpost_prisoner/target = target() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = actor() + var/mob/living/carbon/human/npc/outpost_prisoner/target = target() if(!actor) return actor.end_activity() @@ -445,7 +445,7 @@ log_game("PLAYER OUTPOST PRISON: [actor.real_name] is working up to [kind == "stab" ? "stabbing [target?.real_name]" : "snapping"] at '[prison?.outpost?.name]'") /// Starts the tell's activity again if something ended it -/datum/outpost_prison_wildcard/proc/restart_activity(mob/living/basic/outpost_prisoner/actor) +/datum/outpost_prison_wildcard/proc/restart_activity(mob/living/carbon/human/npc/outpost_prisoner/actor) if(is_tell_activity(actor.activity) || !actor.routine_allowed()) return var/datum/prisoner_activity/tell_activity @@ -460,8 +460,8 @@ /// Advances the tell, or watches the attack until its fight is over /datum/outpost_prison_wildcard/proc/tick(seconds) - var/mob/living/basic/outpost_prisoner/actor = actor() - var/mob/living/basic/outpost_prisoner/target = target() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = actor() + var/mob/living/carbon/human/npc/outpost_prisoner/target = target() if(stage == WILDCARD_ATTACK) var/datum/outpost_prison_fight/brawl = fight_ref?.resolve() if(!QDELETED(brawl) && actor?.fight == brawl) @@ -504,7 +504,7 @@ qdel(src) /// A stabbing's tell: the stare, a mutter, the hand under the shirt -/datum/outpost_prison_wildcard/proc/tell_stab(mob/living/basic/outpost_prisoner/actor, mob/living/basic/outpost_prisoner/target) +/datum/outpost_prison_wildcard/proc/tell_stab(mob/living/carbon/human/npc/outpost_prisoner/actor, mob/living/carbon/human/npc/outpost_prisoner/target) var/in_sight = (target in view(7, actor)) if(in_sight) actor.face_atom(target) @@ -516,7 +516,7 @@ actor.manual_emote("slips a hand under [actor.p_their()] shirt.") /// A snap's tell: muttering, a blow at the wall, then shouting -/datum/outpost_prison_wildcard/proc/tell_snap(mob/living/basic/outpost_prisoner/actor) +/datum/outpost_prison_wildcard/proc/tell_snap(mob/living/carbon/human/npc/outpost_prisoner/actor) if(!muttered && tell_elapsed >= PRISON_INCIDENT_MUTTER_AT) muttered = TRUE actor.say_context("incident_pacing") @@ -543,8 +543,8 @@ * victim, a fight past its argument. Returns TRUE if it started. */ /datum/outpost_prison_wildcard/proc/strike() - var/mob/living/basic/outpost_prisoner/actor = actor() - var/mob/living/basic/outpost_prisoner/target = target() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = actor() + var/mob/living/carbon/human/npc/outpost_prisoner/target = target() // Nobody goes for a prisoner who is already down, or busy fighting someone else. if(QDELETED(actor) || actor.stat != CONSCIOUS || actor.trouble || actor.can_be_dragged() || !target_ok(target) || target.stat != CONSCIOUS || target.can_be_dragged() || target.fight) return FALSE @@ -553,9 +553,9 @@ if(is_tell_activity(actor.activity)) actor.end_activity() actor.stand_up() - if(!prison.draw_stashed_shiv(actor)) - actor.draw_shiv() - actor.manual_emote("pulls a shiv out from under [actor.p_their()] shirt!") + // A stabbing needs a blade: one stashed in their cell, or one they already carry. Nothing comes from nowhere. + if(!actor.has_shiv() && !prison.draw_stashed_shiv(actor)) + return FALSE var/datum/outpost_prison_fight/brawl = prison.start_fight(actor, target, "stab") if(!brawl) put_away_shiv(actor) @@ -570,13 +570,13 @@ return TRUE /// After a stabbing, the attacker lets go of the shiv, unless they are rioting or loose with it -/datum/outpost_prison_wildcard/proc/put_away_shiv(mob/living/basic/outpost_prisoner/actor) +/datum/outpost_prison_wildcard/proc/put_away_shiv(mob/living/carbon/human/npc/outpost_prisoner/actor) if(QDELETED(actor) || !actor.has_shiv() || actor.is_rioting() || actor.trouble == PRISONER_TROUBLE_LOOSE) return actor.drop_shiv() /// It came to nothing (the victim out of reach, the snapper shut in): they think better of it -/datum/outpost_prison_wildcard/proc/give_up(mob/living/basic/outpost_prisoner/actor) +/datum/outpost_prison_wildcard/proc/give_up(mob/living/carbon/human/npc/outpost_prisoner/actor) if(!QDELETED(actor) && actor.stat == CONSCIOUS) actor.say_context("incident_stopped") qdel(src) @@ -589,8 +589,8 @@ /datum/outpost_prison_wildcard/proc/stopped_by_staff(mob/living/user, how) if(stage != WILDCARD_TELL) return FALSE - var/mob/living/basic/outpost_prisoner/actor = actor() - var/mob/living/basic/outpost_prisoner/target = target() + var/mob/living/carbon/human/npc/outpost_prisoner/actor = actor() + var/mob/living/carbon/human/npc/outpost_prisoner/target = target() if(actor) actor.note_trouble_ended() var/what = kind == "stab" ? "went for [target?.real_name || "someone"]" : "snapped" @@ -599,7 +599,7 @@ prison?.add_log("[user?.name || "Someone"] talked [who] down before [actor.p_they()] [what].") else prison?.add_log(user ? "[user.name] stopped [who] before [actor.p_they()] [what]." : "[who] was stopped before [actor.p_they()] [what].") - INVOKE_ASYNC(actor, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "incident_stopped") + INVOKE_ASYNC(actor, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "incident_stopped") qdel(src) return TRUE @@ -613,7 +613,7 @@ interruptible = FALSE var/datum/weakref/target_ref -/datum/prisoner_activity/wildcard_shadow/New(mob/living/basic/outpost_prisoner/doer, mob/living/basic/outpost_prisoner/target) +/datum/prisoner_activity/wildcard_shadow/New(mob/living/carbon/human/npc/outpost_prisoner/doer, mob/living/carbon/human/npc/outpost_prisoner/target) . = ..() target_ref = target ? WEAKREF(target) : null diff --git a/voidcrew/modules/player_outposts/outpost_prison_leads.dm b/voidcrew/modules/player_outposts/outpost_prison_leads.dm index 2a77d84b737..ab38f2fd799 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_leads.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_leads.dm @@ -32,7 +32,7 @@ /// Lies given anywhere this round, so every lie's waypoint key is unique on any ship's helm GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Knows where something is, until they tell it (leads_prisoner_admitted()) var/carries_lead = FALSE /// A member is putting a question about leads to them right now @@ -83,11 +83,11 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) // ===== CARRIERS ===== /// A prisoner was booked in: some carry a lead -/datum/outpost_prison/proc/leads_prisoner_admitted(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/leads_prisoner_admitted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.carries_lead = prob(OUTPOST_PRISON_LEAD_CHANCE) /// Their tips stay on the helms and in the tracking by name; a liar who has gone never owns up -/datum/outpost_prison/proc/leads_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/leads_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.carries_lead = FALSE /// Whether the wing's gap between leads is over @@ -100,7 +100,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return !!lead_test_roll return prob(chance) -/datum/outpost_prison/proc/lead_member_in_view(mob/living/basic/outpost_prisoner/prisoner, range) +/datum/outpost_prison/proc/lead_member_in_view(mob/living/carbon/human/npc/outpost_prisoner/prisoner, range) for(var/mob/living/person in view(range, prisoner)) if(person.stat == CONSCIOUS && is_member(person)) return TRUE @@ -109,7 +109,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) // ===== SPEECH AND EXAMINE ===== /// A carrier hints at what they know, while the wing is ready to give a lead and a member is near -/datum/outpost_prison/proc/leads_extra_speech(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/leads_extra_speech(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner.carries_lead || !lead_ready()) return null if(!lead_member_in_view(prisoner, OUTPOST_PRISON_LEAD_HINT_RANGE) || !lead_roll(OUTPOST_PRISON_LEAD_HINT_CHANCE)) @@ -117,13 +117,13 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return list("lead_hint", null) /// Nothing: mood (XB's examine) and the tells as they answer are what there is to read -/datum/outpost_prison/proc/leads_examine(mob/living/basic/outpost_prisoner/prisoner, mob/user) +/datum/outpost_prison/proc/leads_examine(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/user) return null // ===== THE TALK MENU ===== /// "Heard anything?" on every prisoner, and "About that tip" on those who saw a recent one given; members only -/datum/outpost_prison/proc/leads_talk_choices(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/leads_talk_choices(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) var/list/choices = list() if(!prisoner || !user || !is_member(user)) return choices @@ -133,7 +133,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return choices /// Runs a talk menu choice of this package; TRUE if it was one. May sleep. -/datum/outpost_prison/proc/leads_talk_act(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, choice) +/datum/outpost_prison/proc/leads_talk_act(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, choice) if(choice == LEAD_ASK_CHOICE) lead_ask(prisoner, user) return TRUE @@ -147,12 +147,12 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return FALSE /// The newest tip `prisoner` can be asked about (lead_vouchable()), or null -/datum/outpost_prison/proc/lead_newest_vouchable(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/lead_newest_vouchable(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/list/vouchable = lead_vouchable(prisoner) return length(vouchable) ? vouchable[length(vouchable)] : null /// Open leads `prisoner` saw given, recent enough to ask about, that they did not give and have not answered for -/datum/outpost_prison/proc/lead_vouchable(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/lead_vouchable(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/list/found = list() var/datum/weakref/prisoner_ref = WEAKREF(prisoner) for(var/datum/outpost_prison_lead/lead as anything in open_leads) @@ -164,14 +164,14 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return found /// Whether `user` may put a question to `prisoner` now; shows or says why not -/datum/outpost_prison/proc/lead_can_ask(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/lead_can_ask(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(QDELETED(prisoner) || QDELETED(user) || prisoner.prison != src || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) return FALSE if(!is_member(user)) prisoner.balloon_alert(user, "not your prisoner") return FALSE // Not talking: the talk menu may hold that while it hands this question over. - if(prisoner.lead_questioned || prisoner.trouble || prisoner.fight || prisoner.threat_ref || prisoner.swing_ref || prisoner.climb_ref || prisoner.beaten_left > 0) + if(prisoner.lead_questioned || prisoner.trouble || prisoner.fight || prisoner.threat_ref || prisoner.swing_ref || prisoner.climb_ref || prisoner.is_beaten()) prisoner.balloon_alert(user, "they're busy") return FALSE if(!prisoner.will_listen()) @@ -182,7 +182,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return TRUE /// A few seconds face to face, as a talk-down takes; FALSE if it was cut short or they stopped listening -/datum/outpost_prison/proc/lead_face_to_face(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, self_message) +/datum/outpost_prison/proc/lead_face_to_face(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, self_message) prisoner.lead_questioned = TRUE // Put back as found: the talk menu may already hold it and clear it itself. var/was_talking = prisoner.talking @@ -204,7 +204,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return TRUE /// "Heard anything?": the checks and the talk, then give_lead() for the asker's ship -/datum/outpost_prison/proc/lead_ask(mob/living/basic/outpost_prisoner/prisoner, mob/living/user) +/datum/outpost_prison/proc/lead_ask(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user) if(!lead_can_ask(prisoner, user)) return FALSE if(!get_crew_ship(user)) @@ -226,7 +226,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return TRUE /// "About that tip": the checks and the talk, then lead_vouch() -/datum/outpost_prison/proc/lead_ask_about(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, datum/outpost_prison_lead/lead) +/datum/outpost_prison/proc/lead_ask_about(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, datum/outpost_prison_lead/lead) if(!lead_can_ask(prisoner, user)) return FALSE if(!(lead in lead_vouchable(prisoner))) @@ -240,7 +240,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) // ===== GIVING A LEAD ===== /// Whether `asker` hit `prisoner` unprovoked in the last OUTPOST_PRISON_LEAD_HIT_MEMORY -/datum/outpost_prison/proc/lead_hit_by(mob/living/basic/outpost_prisoner/prisoner, mob/asker) +/datum/outpost_prison/proc/lead_hit_by(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/asker) if(prisoner.last_hit_justified || !prisoner.last_staff_hit) return FALSE if(world.time - prisoner.last_staff_hit > OUTPOST_PRISON_LEAD_HIT_MEMORY) @@ -248,7 +248,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return prisoner.last_staff_attacker_ref?.resolve() == asker /// Percent chance `prisoner` lies to `asker`: none when content, then by mood, the asker's name in the yard and personality -/datum/outpost_prison/proc/lead_lie_chance(mob/living/basic/outpost_prisoner/prisoner, mob/asker) +/datum/outpost_prison/proc/lead_lie_chance(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/asker) if(prisoner.mood >= OUTPOST_PRISON_LEAD_TRUE_MOOD) return 0 var/chance = prisoner.mood < OUTPOST_PRISON_LEAD_HOSTILE_MOOD ? OUTPOST_PRISON_LEAD_LIE_HOSTILE : OUTPOST_PRISON_LEAD_LIE_UNEASY @@ -272,7 +272,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) * lead_lie_chance(), which `force_lie` (tests) decides instead. Returns "invalid", "refuse_hit", * "refuse", "none", "nothing", "truth" or "lie". */ -/datum/outpost_prison/proc/give_lead(mob/living/basic/outpost_prisoner/prisoner, obj/structure/overmap/ship/ship, mob/living/asker, force_lie = null) +/datum/outpost_prison/proc/give_lead(mob/living/carbon/human/npc/outpost_prisoner/prisoner, obj/structure/overmap/ship/ship, mob/living/asker, force_lie = null) if(QDELETED(prisoner) || prisoner.prison != src || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) return "invalid" if(QDELETED(ship) || QDELETED(asker)) @@ -326,7 +326,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) lead.ship_ref = WEAKREF(ship) lead.ship_name = ship.name lead.given_at = world.time - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other != prisoner && other.phase == PRISONER_PRESENT) lead.witnesses += WEAKREF(other) @@ -347,7 +347,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) return lead.lie ? "lie" : "truth" /// A tell as they answer: an emote from the dialogue file's "tells", their personality's more often than the shared ones -/datum/outpost_prison/proc/lead_tell_emote(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/lead_tell_emote(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/list/tells = outpost_prisoner_extra_dialogue(LEAD_DIALOGUE_FILE, "tells") var/list/own = prisoner.personality ? tells[prisoner.personality] : null var/list/shared = tells["any"] @@ -477,13 +477,13 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) var/datum/outpost_prison_lead/lead = lead_ref?.resolve() if(!lead || !(lead in open_leads)) return FALSE - var/mob/living/basic/outpost_prisoner/teller = lead.teller_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/teller = lead.teller_ref?.resolve() var/mob/living/asker = lead.asker_ref?.resolve() if(QDELETED(teller) || QDELETED(asker) || teller.prison != src) return FALSE if(!lead_roll(OUTPOST_PRISON_LEAD_GOSSIP_CHANCE)) return FALSE - for(var/mob/living/basic/outpost_prisoner/other in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in shuffle(prisoners)) if(other == teller || other.stat != CONSCIOUS || other.phase != PRISONER_PRESENT || other.trouble || other.mood < OUTPOST_PRISON_LEAD_GOSSIP_MOOD) continue var/list/seen = view(OUTPOST_PRISON_LEAD_GOSSIP_RANGE, other) @@ -498,7 +498,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) * middling aren't sure, and the sour and anyone the asker hit refuse (they can be asked again * later). Returns "refuse_hit", "refuse", "unsure", "true" or "lie" (what they said the tip was). */ -/datum/outpost_prison/proc/lead_vouch(mob/living/basic/outpost_prisoner/prisoner, datum/outpost_prison_lead/lead, mob/living/asker) +/datum/outpost_prison/proc/lead_vouch(mob/living/carbon/human/npc/outpost_prisoner/prisoner, datum/outpost_prison_lead/lead, mob/living/asker) prisoner.face_atom(asker) if(lead_hit_by(prisoner, asker)) prisoner.say_context("lead_refuse_hit") @@ -550,7 +550,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) /// A caught liar still in the wing owns up once the asker comes within OUTPOST_PRISON_LEAD_CAUGHT_RANGE /datum/outpost_prison/proc/lead_caught_line(datum/outpost_prison_lead/lead) - var/mob/living/basic/outpost_prisoner/teller = lead.teller_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/teller = lead.teller_ref?.resolve() if(QDELETED(teller) || teller.prison != src) lead.caught_said = TRUE return FALSE @@ -574,7 +574,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) /// The admin panel's leads block: {ready_in, carriers: [ref], open: [{teller, ship, name, lie, exposed, age}]}; times in seconds /datum/outpost_prison/proc/leads_admin_payload() var/list/carriers = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.carries_lead) carriers += REF(prisoner) var/list/open = list() @@ -598,7 +598,7 @@ GLOBAL_VAR_INIT(outpost_prison_lead_lies, 0) if(action != "prison_lead") return null var/ref = params ? params["ref"] : null - var/mob/living/basic/outpost_prisoner/prisoner = istext(ref) ? (locate(ref) in prisoners) : null + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = istext(ref) ? (locate(ref) in prisoners) : null if(QDELETED(prisoner)) return list("error" = "No such prisoner.") prisoner.carries_lead = TRUE diff --git a/voidcrew/modules/player_outposts/outpost_prison_life.dm b/voidcrew/modules/player_outposts/outpost_prison_life.dm index 835bf41113c..a1d82660238 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_life.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_life.dm @@ -28,7 +28,7 @@ /// A friend of one of the fighters has had their one try at breaking it up var/breakup_tried = FALSE -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// It is their birthday; `party_done` once the yard has had the cake var/has_birthday = FALSE var/party_done = FALSE @@ -38,19 +38,19 @@ // ===== AFFINITY ===== /// The key a pair of prisoners is kept under, the same whichever way round they come -/datum/outpost_prison/proc/affinity_key(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/affinity_key(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) var/one_ref = REF(one) var/two_ref = REF(two) return (sorttext(one_ref, two_ref) > 0) ? "[one_ref]|[two_ref]" : "[two_ref]|[one_ref]" /// How two prisoners get on, -100 (rivals) to 100 (friends) -/datum/outpost_prison/proc/affinity(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/affinity(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(!one || !two || one == two) return 0 return affinities[affinity_key(one, two)] || 0 /// Sets how two prisoners get on. A wing keeps at most PRISON_AFFINITY_MAX_PAIRS pairs: a new one pushes out the mildest. -/datum/outpost_prison/proc/set_affinity(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two, value) +/datum/outpost_prison/proc/set_affinity(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two, value) if(!one || !two || one == two) return 0 var/key = affinity_key(one, two) @@ -68,20 +68,20 @@ affinities[key] = value return value -/datum/outpost_prison/proc/adjust_affinity(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two, amount) +/datum/outpost_prison/proc/adjust_affinity(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two, amount) return set_affinity(one, two, affinity(one, two) + amount) -/datum/outpost_prison/proc/are_friends(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/are_friends(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) return affinity(one, two) >= PRISON_AFFINITY_FRIEND -/datum/outpost_prison/proc/are_rivals(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/are_rivals(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) return affinity(one, two) <= PRISON_AFFINITY_RIVAL /// The prisoner `prisoner` gets on with best, if a friend; with `worst`, the one they get on with worst, if a rival -/datum/outpost_prison/proc/closest_tie(mob/living/basic/outpost_prisoner/prisoner, worst = FALSE) - var/mob/living/basic/outpost_prisoner/found +/datum/outpost_prison/proc/closest_tie(mob/living/carbon/human/npc/outpost_prisoner/prisoner, worst = FALSE) + var/mob/living/carbon/human/npc/outpost_prisoner/found var/found_value = worst ? PRISON_AFFINITY_RIVAL : PRISON_AFFINITY_FRIEND - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other == prisoner || other.stat == DEAD || other.phase == PRISONER_LEAVING) continue var/value = affinity(prisoner, other) @@ -93,12 +93,12 @@ return found /// Picks, removes from `options` and returns the prisoner `prisoner` goes to chat with, friends by preference -/datum/outpost_prison/proc/take_chat_partner(mob/living/basic/outpost_prisoner/prisoner, list/options) +/datum/outpost_prison/proc/take_chat_partner(mob/living/carbon/human/npc/outpost_prisoner/prisoner, list/options) if(!length(options)) return null var/list/weights = list() var/total = 0 - for(var/mob/living/basic/outpost_prisoner/other as anything in options) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other as anything in options) var/weight = clamp(1 + affinity(prisoner, other) / 25, PRISON_CHAT_WEIGHT_MIN, PRISON_CHAT_WEIGHT_MAX) weights += weight total += weight @@ -106,7 +106,7 @@ for(var/index in 1 to length(options)) roll -= weights[index] if(roll <= 0) - var/mob/living/basic/outpost_prisoner/chosen = options[index] + var/mob/living/carbon/human/npc/outpost_prisoner/chosen = options[index] options.Cut(index, index + 1) return chosen return pick_n_take(options) @@ -114,7 +114,7 @@ // ===== WHAT CHANGES IT ===== /// A chat between two prisoners finished -/datum/outpost_prison/proc/note_chat(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/note_chat(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) adjust_affinity(one, two, PRISON_AFFINITY_CHAT) /// Prisoners who shared a meal and got its lift: every pair of them @@ -123,16 +123,16 @@ for(var/second_index in first_index + 1 to length(diners)) adjust_affinity(diners[first_index], diners[second_index], PRISON_AFFINITY_SHARED_MEAL) -/datum/outpost_prison/proc/note_spat(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/note_spat(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) adjust_affinity(one, two, PRISON_AFFINITY_SPAT) -/datum/outpost_prison/proc/note_fight(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/note_fight(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) adjust_affinity(one, two, PRISON_AFFINITY_FIGHT) /// Each full minute two prisoners spend playing basketball together brings them a little closer /datum/outpost_prison/proc/coplay_tick(seconds) var/list/players = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.stat == CONSCIOUS && prisoner.phase == PRISONER_PRESENT && istype(prisoner.activity, /datum/prisoner_activity/basketball) && prisoner.activity.started) players += prisoner for(var/first_index in 1 to length(players) - 1) @@ -147,7 +147,7 @@ // ===== WHAT IT CHANGES ===== /// Rivals fight more readily, friends less -/datum/outpost_prison/proc/fight_chance_mult(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/fight_chance_mult(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(are_rivals(one, two)) return PRISON_FIGHT_MULT_RIVAL if(are_friends(one, two)) @@ -155,7 +155,7 @@ return 1 /// A fight cause beyond food, ball and bed: an old grudge between two who can't stand each other -/datum/outpost_prison/proc/fight_cause_extra(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/fight_cause_extra(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(affinity(one, two) <= PRISON_AFFINITY_GRUDGE) return "grudge" return null @@ -181,7 +181,7 @@ * The two-person conversation `speaker` opens with `partner`: friends and rivals mostly open with * one of their own (conversations_friendly, conversations_hostile), falling back to the main pool. */ -/datum/outpost_prison/proc/pick_conversation(mob/living/basic/outpost_prisoner/speaker, mob/living/basic/outpost_prisoner/partner) +/datum/outpost_prison/proc/pick_conversation(mob/living/carbon/human/npc/outpost_prisoner/speaker, mob/living/carbon/human/npc/outpost_prisoner/partner) var/value = affinity(speaker, partner) var/pool_key if(value >= PRISON_AFFINITY_FRIEND) @@ -197,9 +197,9 @@ return length(pool) ? pick(pool) : null /// "Seems to get on with Rosa." for examine, or null -/datum/outpost_prison/proc/relationship_examine(mob/living/basic/outpost_prisoner/prisoner) - var/mob/living/basic/outpost_prisoner/friend = closest_tie(prisoner) - var/mob/living/basic/outpost_prisoner/rival = closest_tie(prisoner, worst = TRUE) +/datum/outpost_prison/proc/relationship_examine(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/friend = closest_tie(prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/rival = closest_tie(prisoner, worst = TRUE) if(friend && rival) return "Seems to get on with [friend.speech_name()], and keeps away from [rival.speech_name()]." if(friend) @@ -209,8 +209,8 @@ return null /// The first name of `prisoner`'s worst rival in the wing, or null -/datum/outpost_prison/proc/worst_rival_name(mob/living/basic/outpost_prisoner/prisoner) - var/mob/living/basic/outpost_prisoner/rival = closest_tie(prisoner, worst = TRUE) +/datum/outpost_prison/proc/worst_rival_name(mob/living/carbon/human/npc/outpost_prisoner/prisoner) + var/mob/living/carbon/human/npc/outpost_prisoner/rival = closest_tie(prisoner, worst = TRUE) return rival?.speech_name() // ===== A FRIEND BREAKS IT UP ===== @@ -224,28 +224,28 @@ for(var/datum/outpost_prison_fight/brawl in fights.Copy()) if(brawl.fighting || brawl.breakup_tried) continue - for(var/mob/living/basic/outpost_prisoner/fighter as anything in list(brawl.first, brawl.second)) - var/mob/living/basic/outpost_prisoner/friend = peacemaker_for(fighter, brawl) + for(var/mob/living/carbon/human/npc/outpost_prisoner/fighter as anything in list(brawl.first, brawl.second)) + var/mob/living/carbon/human/npc/outpost_prisoner/friend = peacemaker_for(fighter, brawl) if(!friend) continue brawl.breakup_tried = TRUE var/chance = isnull(breakup_chance_override) ? PRISON_BREAKUP_CHANCE : breakup_chance_override if(!prob(chance)) break - var/mob/living/basic/outpost_prisoner/opponent = brawl.opponent_of(fighter) + var/mob/living/carbon/human/npc/outpost_prisoner/opponent = brawl.opponent_of(fighter) friend.face_atom(fighter) friend.manual_emote(opponent ? "steps in between [fighter] and [opponent]." : "puts a hand on [fighter]'s shoulder.") end_fight(brawl) add_log("[friend.real_name] talked [fighter.real_name] and [opponent?.real_name] out of a fight.") - INVOKE_ASYNC(friend, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "friend_breaks_up", fighter) + INVOKE_ASYNC(friend, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "friend_breaks_up", fighter) return friend return null /// A friend of `fighter` who could step in: close by, awake, in a good enough mood and out of trouble -/datum/outpost_prison/proc/peacemaker_for(mob/living/basic/outpost_prisoner/fighter, datum/outpost_prison_fight/brawl) +/datum/outpost_prison/proc/peacemaker_for(mob/living/carbon/human/npc/outpost_prisoner/fighter, datum/outpost_prison_fight/brawl) if(QDELETED(fighter)) return null - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other == brawl.first || other == brawl.second || !are_friends(fighter, other)) continue // Down or cuffed, they can't step in between anyone @@ -258,13 +258,13 @@ // ===== ARRIVALS ===== /// A prisoner was booked in. Whether they know anyone inside, and their birthday, wait until they beam in. -/datum/outpost_prison/proc/on_prisoner_admitted(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/on_prisoner_admitted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) // admit_next() beams them in straight after admit(); anyone booked in any other way stays put. addtimer(CALLBACK(src, PROC_REF(greet_arrival), WEAKREF(prisoner)), 1) /// An arrival beaming in through intake: a crewmate or a rival already inside, a birthday, marks on the wall /datum/outpost_prison/proc/greet_arrival(datum/weakref/prisoner_ref) - var/mob/living/basic/outpost_prisoner/prisoner = prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = prisoner_ref?.resolve() if(!prisoner || prisoner.prison != src || prisoner.phase != PRISONER_ARRIVING) return FALSE roll_crew_tie(prisoner) @@ -279,9 +279,9 @@ * (-PRISON_CREW_TIE). `forced` is "friend" or "rival" to skip the roll. The arrival says so once * they have beamed in. Returns the other prisoner, or null. */ -/datum/outpost_prison/proc/roll_crew_tie(mob/living/basic/outpost_prisoner/prisoner, forced) +/datum/outpost_prison/proc/roll_crew_tie(mob/living/carbon/human/npc/outpost_prisoner/prisoner, forced) var/list/others = list() - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other != prisoner && other.phase == PRISONER_PRESENT && other.stat != DEAD && other.trouble != PRISONER_TROUBLE_LOOSE) others += other if(!length(others)) @@ -294,7 +294,7 @@ tie = "rival" if(tie != "friend" && tie != "rival") return null - var/mob/living/basic/outpost_prisoner/other = pick(others) + var/mob/living/carbon/human/npc/outpost_prisoner/other = pick(others) set_affinity(prisoner, other, tie == "friend" ? PRISON_CREW_TIE : -PRISON_CREW_TIE) var/delay = (prisoner.phase == PRISONER_ARRIVING ? OUTPOST_PRISON_BEAM_TIME / (1 SECONDS) : 0) + PRISON_CREW_LINE_DELAY queue_life_line(prisoner, tie == "friend" ? "arrival_crew_friend" : "arrival_crew_rival", other, delay) @@ -305,7 +305,7 @@ return prob(PRISON_BIRTHDAY_CHANCE) /// It is their birthday: they say so once they are in, and the yard can throw them a party (outpost_prison_pastimes.dm) -/datum/outpost_prison/proc/give_birthday(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/give_birthday(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.has_birthday = TRUE prisoner.party_done = FALSE var/delay = (prisoner.phase == PRISONER_ARRIVING ? OUTPOST_PRISON_BEAM_TIME / (1 SECONDS) : 0) + PRISON_BIRTHDAY_LINE_DELAY @@ -314,12 +314,12 @@ // ===== LINES DUE ===== /// Queues a line for `prisoner` to say to `other` in `seconds` -/datum/outpost_prison/proc/queue_life_line(mob/living/basic/outpost_prisoner/prisoner, context, mob/living/basic/outpost_prisoner/other, seconds) +/datum/outpost_prison/proc/queue_life_line(mob/living/carbon/human/npc/outpost_prisoner/prisoner, context, mob/living/carbon/human/npc/outpost_prisoner/other, seconds) LAZYADD(prisoner.life_lines, list(list(seconds, context, other ? WEAKREF(other) : null))) /// Counts down queued lines and says the ones that are due, once the speaker has beamed in /datum/outpost_prison/proc/life_lines_tick(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!length(prisoner.life_lines)) continue for(var/list/entry as anything in prisoner.life_lines.Copy()) @@ -330,7 +330,7 @@ if(prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) continue var/datum/weakref/other_ref = entry[3] - var/mob/living/basic/outpost_prisoner/other = other_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/other = other_ref?.resolve() if(other && other.stat == CONSCIOUS && (other in view(7, prisoner))) prisoner.face_atom(other) else @@ -339,10 +339,10 @@ /// Says a line for a prisoner who may have gone by now; for timers /datum/outpost_prison/proc/say_life_line(datum/weakref/speaker_ref, context, datum/weakref/other_ref) - var/mob/living/basic/outpost_prisoner/speaker = speaker_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = speaker_ref?.resolve() if(!speaker || speaker.stat != CONSCIOUS) return FALSE - var/mob/living/basic/outpost_prisoner/other = other_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/other = other_ref?.resolve() if(other) speaker.face_atom(other) return speaker.say_context(context, other) @@ -350,7 +350,7 @@ // ===== LEAVING ===== /// A prisoner is leaving the roster: their pairs go, and a party for them is off -/datum/outpost_prison/proc/on_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/on_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/prisoner_ref = REF(prisoner) for(var/key in affinities.Copy()) if(findtext(key, prisoner_ref)) @@ -368,7 +368,7 @@ * sight say goodbye, a second or so apart, and feel it; the leaver answers the first before the beam * takes them. Returns TRUE if any of that replaced the usual release line. */ -/datum/outpost_prison/proc/on_prisoner_releasing(mob/living/basic/outpost_prisoner/prisoner, average) +/datum/outpost_prison/proc/on_prisoner_releasing(mob/living/carbon/human/npc/outpost_prisoner/prisoner, average) var/spoke = FALSE if(average >= PRISON_RELEASE_THANKS_AVERAGE) var/mob/living/member = nearest_member_in_view(prisoner) @@ -381,7 +381,7 @@ else if(average <= PRISON_RELEASE_BITTER_AVERAGE) spoke = prisoner.say_context("release_bitter") var/list/friends = list() - for(var/mob/living/basic/outpost_prisoner/other in view(PRISON_FAREWELL_RANGE, prisoner)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in view(PRISON_FAREWELL_RANGE, prisoner)) if(other == prisoner || !(other in prisoners) || other.stat != CONSCIOUS || other.phase != PRISONER_PRESENT) continue if(are_friends(prisoner, other)) @@ -391,7 +391,7 @@ // The first goodbye and the leaver's answer come before the beam starts, since nobody talks while // beaming out; later friends call after them anyway, a second or two apart. var/delay = 0 - for(var/mob/living/basic/outpost_prisoner/friend as anything in friends) + for(var/mob/living/carbon/human/npc/outpost_prisoner/friend as anything in friends) friend.adjust_mood(-PRISON_FAREWELL_MOOD) friend.face_atom(prisoner) if(friend == friends[1]) @@ -403,7 +403,7 @@ return TRUE /// The nearest member of the wing awake in sight of `prisoner`, or null -/datum/outpost_prison/proc/nearest_member_in_view(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/nearest_member_in_view(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/mob/living/nearest var/nearest_distance = INFINITY for(var/mob/living/person in view(7, prisoner)) @@ -433,7 +433,7 @@ /// A birthday prisoner free to talk brings it up now and then, until they get their party /datum/outpost_prison/proc/birthday_chatter() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!prisoner.has_birthday || prisoner.party_done || prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT) continue if(!prisoner.ai_running() || prisoner.in_trouble() || prisoner.activity?.sleeping) @@ -459,8 +459,8 @@ var/list/refs = splittext(key, "|") if(length(refs) != 2) continue - var/mob/living/basic/outpost_prisoner/one = locate(refs[1]) in prisoners - var/mob/living/basic/outpost_prisoner/two = locate(refs[2]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/one = locate(refs[1]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/two = locate(refs[2]) in prisoners if(!one || !two) continue pairs += list(list( @@ -471,11 +471,11 @@ "affinity" = round(affinities[key]), )) var/list/birthdays = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.has_birthday && !prisoner.party_done) birthdays += REF(prisoner) var/scene - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() if(party_stage && host) scene = party_stage == "waiting" ? "cake waiting for [host.real_name]" : "birthday party for [host.real_name]" return list( @@ -488,8 +488,8 @@ /datum/outpost_prison/proc/life_admin_act(action, list/params, mob/user) switch(action) if("prison_affinity") - var/mob/living/basic/outpost_prisoner/one = locate(params?["a_ref"]) in prisoners - var/mob/living/basic/outpost_prisoner/two = locate(params?["b_ref"]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/one = locate(params?["a_ref"]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/two = locate(params?["b_ref"]) in prisoners if(QDELETED(one) || QDELETED(two) || one == two) return list("error" = "Pick two different prisoners.") var/value = params["value"] @@ -500,7 +500,7 @@ set_affinity(one, two, value) return "set how [one.real_name] and [two.real_name] get on to [round(affinity(one, two))]" if("prison_birthday", "prison_party") - var/mob/living/basic/outpost_prisoner/prisoner = locate(params?["ref"]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = locate(params?["ref"]) in prisoners if(QDELETED(prisoner) || prisoner.stat == DEAD || prisoner.phase != PRISONER_PRESENT) return list("error" = "Only a living prisoner in the wing.") if(action == "prison_birthday") diff --git a/voidcrew/modules/player_outposts/outpost_prison_mail.dm b/voidcrew/modules/player_outposts/outpost_prison_mail.dm index c71f90b36c9..3d666bed22b 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_mail.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_mail.dm @@ -41,7 +41,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( // ===== STATE ===== -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Whether a letter has come for them this stay; one each var/mail_had_letter = FALSE @@ -205,7 +205,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return waiting /// The letter waiting for `prisoner`, if any -/datum/outpost_prison/proc/mail_waiting_for(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/mail_waiting_for(mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/obj/item/paper/outpost_prison_letter/letter in mail_waiting_letters()) if(letter.letter_prisoner_ref?.resolve() == prisoner) return letter @@ -220,7 +220,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( /datum/outpost_prison/proc/mail_wave_recipients() var/present = 0 var/list/eligible = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD) continue present++ @@ -251,7 +251,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return 0 var/obj/structure/closet/supplypod/outpost_prison_mail/pod = new() var/obj/structure/closet/crate/mail/crate = new(pod) - for(var/mob/living/basic/outpost_prisoner/prisoner as anything in recipients) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner as anything in recipients) mail_make_letter(prisoner, null, null, crate) crate.update_appearance() RegisterSignal(crate, COMSIG_MOVABLE_MOVED, PROC_REF(mail_crate_moved)) @@ -472,7 +472,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * waves (the admin panel's prison_mail): `kind` (by weight when not given), and for a contraband * letter `enclosure_type` (a razor blade or yeast by chance when not given). Returns the letter, or null. */ -/datum/outpost_prison/proc/mail_send(mob/living/basic/outpost_prisoner/prisoner, kind, enclosure_type) +/datum/outpost_prison/proc/mail_send(mob/living/carbon/human/npc/outpost_prisoner/prisoner, kind, enclosure_type) if(QDELETED(prisoner) || !(prisoner in prisoners)) return null var/turf/spot = mail_letter_drop_turf() @@ -492,7 +492,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * not given), and for a contraband letter `enclosure_type` (a razor blade or yeast by chance when not * given). That is their one letter this stay. The caller tracks it (mail_letters). Returns the letter. */ -/datum/outpost_prison/proc/mail_make_letter(mob/living/basic/outpost_prisoner/prisoner, kind, enclosure_type, atom/destination) +/datum/outpost_prison/proc/mail_make_letter(mob/living/carbon/human/npc/outpost_prisoner/prisoner, kind, enclosure_type, atom/destination) if(!(kind in GLOB.outpost_prison_mail_kinds)) kind = pick_weight(GLOB.outpost_prison_mail_kinds) var/obj/item/mail/envelope/outpost_prison/envelope = new(destination) @@ -514,7 +514,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return letter /// Writes a letter of `kind` to `prisoner` from the extras' templates -/datum/outpost_prison/proc/mail_write(obj/item/paper/outpost_prison_letter/letter, mob/living/basic/outpost_prisoner/prisoner, kind) +/datum/outpost_prison/proc/mail_write(obj/item/paper/outpost_prison_letter/letter, mob/living/carbon/human/npc/outpost_prisoner/prisoner, kind) var/list/by_kind = outpost_prisoner_extra_dialogue(MAIL_STRINGS_FILE, "letters") var/list/templates = by_kind[kind] var/list/senders = outpost_prisoner_extra_dialogue(MAIL_STRINGS_FILE, "senders") @@ -538,7 +538,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( /datum/outpost_prison/proc/mail_check() var/z = wing_z() for(var/obj/item/paper/outpost_prison_letter/letter in mail_waiting_letters()) - var/mob/living/basic/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() if(QDELETED(addressee) || !(addressee in prisoners)) mail_discard(letter) continue @@ -555,7 +555,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( if(QDELETED(letter)) return mail_letters -= WEAKREF(letter) - var/mob/living/basic/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() if(lost && !QDELETED(addressee) && (addressee in prisoners) && addressee.stat != DEAD) addressee.adjust_mood(-OUTPOST_MAIL_EXPIRED_MOOD) add_log("[addressee.real_name]'s mail was sent back.") @@ -564,7 +564,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( /// Someone opened a letter before its prisoner got it /datum/outpost_prison/proc/mail_opened(obj/item/paper/outpost_prison_letter/letter, mob/user) add_log("[user ? user.name : "Someone"] opened [letter.letter_addressee]'s mail.") - var/mob/living/basic/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() if(user && addressee) note_staff_event(user, addressee, "mail_opened") @@ -576,7 +576,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * the level they read it where they stand if the hatch is in their reach, as fend_for_self() does * with food. Returns TRUE if they went for it or read it. */ -/datum/outpost_prison/proc/mail_collect_from_hatch(mob/living/basic/outpost_prisoner/addressee, obj/item/thing) +/datum/outpost_prison/proc/mail_collect_from_hatch(mob/living/carbon/human/npc/outpost_prisoner/addressee, obj/item/thing) if(addressee.stat != CONSCIOUS || addressee.phase != PRISONER_PRESENT || !isturf(thing.loc)) return FALSE if(addressee.ai_running()) @@ -591,7 +591,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return FALSE addressee.start_activity(fetch) return TRUE - if(addressee.in_trouble() || addressee.cuffs) + if(addressee.in_trouble() || addressee.handcuffed) return FALSE if(!addressee.reachable) refresh_prisoner_reach(addressee) @@ -608,7 +608,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( if(!letter) only_mail = FALSE continue - var/mob/living/basic/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() if(!QDELETED(addressee) && (addressee in prisoners)) mail_collect_from_hatch(addressee, thing) return only_mail @@ -618,21 +618,21 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * anyone else says it isn't theirs, and a prisoner who won't listen refuses it. Runs inside a * signal handler: never sleeps. Returns the item interaction result. */ -/datum/outpost_prison/proc/mail_hand_over(mob/living/basic/outpost_prisoner/prisoner, mob/living/user, obj/item/thing, obj/item/paper/outpost_prison_letter/letter) +/datum/outpost_prison/proc/mail_hand_over(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/user, obj/item/thing, obj/item/paper/outpost_prison_letter/letter) if(prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT) return NONE if(letter.letter_prisoner_ref?.resolve() != prisoner) prisoner.balloon_alert(user, "not theirs") - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "mail_not_mine") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "mail_not_mine") return ITEM_INTERACT_BLOCKING // Cuffed hands take nothing, as with food and uniforms (outpost_prison_capture.dm) - if(prisoner.cuffs) + if(prisoner.handcuffed) prisoner.balloon_alert(user, "cuffed") return ITEM_INTERACT_BLOCKING // Fighting, squaring up, climbing, lying beaten, or already busy with staff: they won't take it if(prisoner.in_trouble() || !prisoner.will_listen()) prisoner.balloon_alert(user, "not listening") - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "talk_refuse") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "talk_refuse") return ITEM_INTERACT_BLOCKING if(!user.temporarilyRemoveItemFromInventory(thing)) return ITEM_INTERACT_BLOCKING @@ -649,7 +649,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * unless `instant`, then the letter takes effect. Bad news sends them to sit in their cell a while. * Returns the kind read, or null. Sleeps unless `instant`. */ -/datum/outpost_prison/proc/mail_read(mob/living/basic/outpost_prisoner/prisoner, obj/item/thing, instant = FALSE) +/datum/outpost_prison/proc/mail_read(mob/living/carbon/human/npc/outpost_prisoner/prisoner, obj/item/thing, instant = FALSE) if(QDELETED(prisoner) || QDELETED(thing)) return null mail_reading_emote(prisoner, thing) @@ -667,7 +667,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( mail_start_mull(prisoner) return kind -/datum/outpost_prison/proc/mail_reading_emote(mob/living/basic/outpost_prisoner/prisoner, obj/item/thing) +/datum/outpost_prison/proc/mail_reading_emote(mob/living/carbon/human/npc/outpost_prisoner/prisoner, obj/item/thing) if(istype(thing, /obj/item/mail/envelope/outpost_prison)) prisoner.manual_emote("tears open the envelope and reads the letter.") playsound(prisoner, 'sound/items/poster/poster_ripped.ogg', 30, TRUE) @@ -681,7 +681,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( * sealed in with it is kept hidden (contraband, 4.14). The letter and its envelope are then gone * (they keep it). Never sleeps. Returns the kind, or null. */ -/datum/outpost_prison/proc/mail_finish_reading(mob/living/basic/outpost_prisoner/prisoner, obj/item/thing) +/datum/outpost_prison/proc/mail_finish_reading(mob/living/carbon/human/npc/outpost_prisoner/prisoner, obj/item/thing) var/obj/item/paper/outpost_prison_letter/letter = outpost_prison_letter_in(thing) if(!letter || QDELETED(prisoner)) return null @@ -705,7 +705,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return kind /// Bad news: they go and sit in their cell's chair (or on its bed) for a while, if they can -/datum/outpost_prison/proc/mail_start_mull(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/mail_start_mull(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || !prisoner.ai_running() || !prisoner.routine_allowed()) return FALSE var/datum/prisoner_activity/mull_letter/mull = new(prisoner) @@ -718,7 +718,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( // ===== THE PRISON: SPEECH, CONSOLES, CLEAN-UP ===== /// "Any mail for me?", from a prisoner with a letter waiting and a member in sight, as list(context, other, values), or null -/datum/outpost_prison/proc/mail_extra_speech(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/mail_extra_speech(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!mail_waiting_for(prisoner) || !contraband_roll(OUTPOST_MAIL_ASK_CHANCE)) return null for(var/mob/living/person in view(OUTPOST_MAIL_ASK_RANGE, prisoner)) @@ -729,7 +729,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return null /// A prisoner is leaving: their undelivered letters go -/datum/outpost_prison/proc/mail_prisoner_leaving(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/mail_prisoner_leaving(mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/obj/item/paper/outpost_prison_letter/letter in mail_waiting_letters()) if(letter.letter_prisoner_ref?.resolve() == prisoner) mail_discard(letter) @@ -743,7 +743,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( /datum/outpost_prison/proc/mail_admin_payload() var/list/rows = list() for(var/obj/item/paper/outpost_prison_letter/letter in mail_waiting_letters()) - var/mob/living/basic/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/addressee = letter.letter_prisoner_ref?.resolve() rows += list(list( "ref" = REF(letter), "to_ref" = addressee ? REF(addressee) : null, @@ -769,7 +769,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( return "call a prison mail pod with [sent] letter\s" if(action != "prison_mail") return null - var/mob/living/basic/outpost_prisoner/prisoner = locate(params?["ref"]) in prisoners + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = locate(params?["ref"]) in prisoners if(QDELETED(prisoner)) return list("error" = "That prisoner is gone.") var/kind = params["kind"] @@ -791,7 +791,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( /datum/element/outpost_prison_mail_handover/Attach(datum/target) . = ..() - if(!istype(target, /mob/living/basic/outpost_prisoner)) + if(!istype(target, /mob/living/carbon/human/npc/outpost_prisoner)) return ELEMENT_INCOMPATIBLE RegisterSignal(target, COMSIG_ATOM_ITEM_INTERACTION, PROC_REF(on_item_interaction)) @@ -799,7 +799,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( UnregisterSignal(source, COMSIG_ATOM_ITEM_INTERACTION) return ..() -/datum/element/outpost_prison_mail_handover/proc/on_item_interaction(mob/living/basic/outpost_prisoner/source, mob/living/user, obj/item/tool, list/modifiers) +/datum/element/outpost_prison_mail_handover/proc/on_item_interaction(mob/living/carbon/human/npc/outpost_prisoner/source, mob/living/user, obj/item/tool, list/modifiers) SIGNAL_HANDLER if(!istype(user) || user.combat_mode || is_outpost_prisoner(user) || !source.prison) return NONE @@ -823,7 +823,7 @@ GLOBAL_LIST_INIT(outpost_prison_mail_kinds, list( var/read_for = 0 var/datum/weakref/seat_ref -/datum/prisoner_activity/fetch_mail/New(mob/living/basic/outpost_prisoner/doer, obj/item/thing) +/datum/prisoner_activity/fetch_mail/New(mob/living/carbon/human/npc/outpost_prisoner/doer, obj/item/thing) . = ..(doer) mail_ref = WEAKREF(thing) diff --git a/voidcrew/modules/player_outposts/outpost_prison_npc_bodies.dm b/voidcrew/modules/player_outposts/outpost_prison_npc_bodies.dm new file mode 100644 index 00000000000..cbfb55312f5 --- /dev/null +++ b/voidcrew/modules/player_outposts/outpost_prison_npc_bodies.dm @@ -0,0 +1,67 @@ +/** + * # Prison NPC bodies + * + * Prisoners, guards and Kessler staff are real human bodies (voidcrew/modules/human_npcs/): they go + * down, bleed, stamcrit, get cuffed, die and come back through tg's own code. This file holds what the + * prison's own AI needs to use those bodies the way a person does: + * - outpost_prison_npc_hit(): a blow is a click with combat mode on, through the body's hands (a fist, + * a held shiv, a stun baton), so tg's damage, armour, wounds, knockdowns and logs apply. + * - Saved faces: a guard on the payroll is the same person each time the record sends them back + * (outpost_prison_save_look(), outpost_prison_apply_look()). + */ + +/** + * `hitter` strikes `target` beside it with `weapon` in hand (switched to the active hand), or a fist + * if `weapon` is null, the way a player clicks with combat mode on. The aim spreads over the body as + * other human NPCs' does. Returns TRUE if the blow was thrown. + */ +/proc/outpost_prison_npc_hit(mob/living/carbon/human/hitter, atom/target, obj/item/weapon) + if(QDELETED(hitter) || QDELETED(target) || hitter.stat != CONSCIOUS || world.time < hitter.next_move) + return FALSE + if(!hitter.Adjacent(target)) + return FALSE + if(weapon) + if(!human_npc_ready_item(hitter, weapon)) + return FALSE + else if(!human_npc_ready_empty_hand(hitter)) + return FALSE + hitter.face_atom(target) + hitter.zone_selected = human_npc_pick_aim_zone() + var/old_combat_mode = hitter.combat_mode + hitter.set_combat_mode(TRUE, silent = TRUE) + hitter.ClickOn(target) + hitter.set_combat_mode(old_combat_mode, silent = TRUE) + return TRUE + +/// What makes a face: the bits of a random person a guard record keeps, so the same guard comes back +/proc/outpost_prison_save_look(mob/living/carbon/human/person) + if(QDELETED(person)) + return null + return list( + "gender" = person.gender, + "skin_tone" = person.skin_tone, + "hairstyle" = person.hairstyle, + "hair_color" = person.hair_color, + "facial_hairstyle" = person.facial_hairstyle, + "facial_hair_color" = person.facial_hair_color, + "eye_color" = person.eye_color_left, + "real_name" = person.real_name, + ) + +/// Puts a face saved by outpost_prison_save_look() on `person` +/proc/outpost_prison_apply_look(mob/living/carbon/human/person, list/look) + if(QDELETED(person) || !length(look)) + return + person.gender = look["gender"] == FEMALE ? FEMALE : MALE + person.physique = person.gender + if(look["skin_tone"]) + person.skin_tone = look["skin_tone"] + person.set_hairstyle(look["hairstyle"] || "Bald", update = FALSE) + if(look["hair_color"]) + person.set_haircolor(look["hair_color"], update = FALSE) + person.set_facial_hairstyle(look["facial_hairstyle"] || "Shaved", update = FALSE) + if(look["facial_hair_color"]) + person.set_facial_haircolor(look["facial_hair_color"], update = FALSE) + if(look["eye_color"]) + person.set_eye_color(look["eye_color"]) + person.update_body(is_creating = TRUE) diff --git a/voidcrew/modules/player_outposts/outpost_prison_panic.dm b/voidcrew/modules/player_outposts/outpost_prison_panic.dm index ea647506707..b74683f3f0d 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_panic.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_panic.dm @@ -41,7 +41,7 @@ // ===== THE PRISONER ===== -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Seconds of fright left: full while a creature frightens them, counting down once none does. 0 when calm. var/creature_panic_left = 0 /// The creature that frightened them last @@ -61,11 +61,11 @@ var/running_scared = FALSE /// Whether a creature has them frightened -/mob/living/basic/outpost_prisoner/proc/is_panicking() +/mob/living/carbon/human/npc/outpost_prisoner/proc/is_panicking() return creature_panic_left > 0 /// The creature that frightened them last, while it lives -/mob/living/basic/outpost_prisoner/proc/panic_threat() +/mob/living/carbon/human/npc/outpost_prisoner/proc/panic_threat() var/mob/living/threat = panic_threat_ref?.resolve() if(QDELETED(threat) || threat.stat == DEAD) return null @@ -76,13 +76,13 @@ * (outpost_prison_changeling.dm): they asked for it, so the lock-in clock waits, and so does the * pay it stops (update_locked_in(), confined_unpaid()). */ -/mob/living/basic/outpost_prisoner/proc/sheltering_from_creature() +/mob/living/carbon/human/npc/outpost_prisoner/proc/sheltering_from_creature() if(!is_panicking() && !istype(activity, /datum/prisoner_activity/flee_creature)) return FALSE return !!cell?.contains(src) && is_confined() /// The creature they are going for, while they still are: a menace still, close, and not given up on -/mob/living/basic/outpost_prisoner/proc/creature_foe() +/mob/living/carbon/human/npc/outpost_prisoner/proc/creature_foe() if(!creature_foe_ref) return null var/mob/living/foe = creature_foe_ref.resolve() @@ -92,19 +92,19 @@ return foe /// They run while running from a creature, and walk otherwise -/mob/living/basic/outpost_prisoner/proc/update_flee_speed() +/mob/living/carbon/human/npc/outpost_prisoner/proc/update_flee_speed() var/wanted = stat != DEAD && (istype(activity, /datum/prisoner_activity/creature_panic) || (!!creature_retreat_spot && world.time < creature_retreat_until)) if(wanted == running_scared) return running_scared = wanted // A loose meek bounty prisoner keeps their pace (outpost_prison_bounty.dm). - set_varspeed(wanted ? OUTPOST_PANIC_FLEE_SPEED : bounty_base_speed()) + set_prison_pace(hurry = wanted || bounty_base_speed()) /** * A rioter running from a creature, planned from the trouble subtree: TRUE while they are on the * way. At the spot, out of time or no longer rioting, they stop and riot on. */ -/mob/living/basic/outpost_prisoner/proc/plan_creature_retreat(datum/ai_controller/controller) +/mob/living/carbon/human/npc/outpost_prisoner/proc/plan_creature_retreat(datum/ai_controller/controller) if(!creature_retreat_spot) return FALSE if(loc == creature_retreat_spot || world.time >= creature_retreat_until || !is_rioting()) @@ -115,7 +115,7 @@ controller.queue_behavior(/datum/ai_behavior/outpost_prisoner_creature_retreat) return TRUE -/mob/living/basic/outpost_prisoner/proc/finish_creature_retreat() +/mob/living/carbon/human/npc/outpost_prisoner/proc/finish_creature_retreat() creature_retreat_spot = null creature_retreat_until = 0 update_flee_speed() @@ -127,7 +127,7 @@ action_cooldown = 0.5 SECONDS /datum/ai_behavior/outpost_prisoner_creature_retreat/setup(datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn var/turf/spot = prisoner?.creature_retreat_spot if(!spot) return FALSE @@ -136,7 +136,7 @@ return TRUE /datum/ai_behavior/outpost_prisoner_creature_retreat/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn if(!prisoner?.creature_retreat_spot || prisoner.loc != prisoner.creature_retreat_spot) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED prisoner.finish_creature_retreat() @@ -146,7 +146,7 @@ . = ..() if(!succeeded) // No way through: they riot on from where they are. - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn prisoner?.finish_creature_retreat() /** @@ -249,7 +249,7 @@ * everyone; otherwise it has to be within OUTPOST_PANIC_SIGHT_RANGE and in their sight, or within * OUTPOST_PANIC_DOWNED_RANGE for one that only frightens up close. */ -/datum/outpost_prison/proc/frightens(mob/living/basic/outpost_prisoner/prisoner, mob/living/creature, menace, loose) +/datum/outpost_prison/proc/frightens(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/creature, menace, loose) var/turf/here = get_turf(prisoner) var/turf/there = get_turf(creature) if(!here || !there || here.z != there.z) @@ -261,7 +261,7 @@ return get_dist(here, there) <= OUTPOST_PANIC_SIGHT_RANGE && can_see(here, there, OUTPOST_PANIC_SIGHT_RANGE) /// The nearest of `threats` (creature = menace) that frightens `prisoner`, or null. `loose` marks those loose in the cell block. -/datum/outpost_prison/proc/frightening_creature(mob/living/basic/outpost_prisoner/prisoner, list/threats, list/loose) +/datum/outpost_prison/proc/frightening_creature(mob/living/carbon/human/npc/outpost_prisoner/prisoner, list/threats, list/loose) var/mob/living/nearest var/nearest_distance = INFINITY for(var/mob/living/creature as anything in threats) @@ -276,7 +276,7 @@ // ===== THE PRISON'S TICK ===== /// Whether a creature can frighten `prisoner` now: present and awake in the wing's custody, not loose, and not the experiment's subject -/datum/outpost_prison/proc/can_panic(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/can_panic(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return prisoner.prison == src && prisoner.phase == PRISONER_PRESENT && prisoner.stat == CONSCIOUS && !prisoner.experiment_subject && prisoner.trouble != PRISONER_TROUBLE_LOOSE /** @@ -289,7 +289,7 @@ for(var/mob/living/creature as anything in threats) loose[creature] = threats[creature] == CREATURE_MENACE_FULL && in_cell_block(creature) && !creature_shut_in(creature) var/shouts = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner)) continue if(!can_panic(prisoner)) @@ -317,7 +317,7 @@ * drops what they were doing (a wreck, squaring up to staff) and runs (start_panic()), or, already * running, rethinks the way (the panic's reconsider()). Returns TRUE if they shouted. */ -/datum/outpost_prison/proc/react_to_creature(mob/living/basic/outpost_prisoner/prisoner, mob/living/threat, seconds, may_shout) +/datum/outpost_prison/proc/react_to_creature(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/threat, seconds, may_shout) if(prisoner.can_be_dragged() || prisoner.pulledby) // Down, they neither run nor fight. prisoner.creature_foe_ref = null @@ -343,7 +343,7 @@ * `prisoner` breaks and runs (/datum/prisoner_activity/creature_panic), if their routine is free to. * `shout` has them say so. Returns TRUE if they shouted. */ -/datum/outpost_prison/proc/start_panic(mob/living/basic/outpost_prisoner/prisoner, shout = FALSE) +/datum/outpost_prison/proc/start_panic(mob/living/carbon/human/npc/outpost_prisoner/prisoner, shout = FALSE) if(prisoner.prison != src || prisoner.experiment_subject || !prisoner.routine_allowed()) return FALSE if(istype(prisoner.activity, /datum/prisoner_activity/creature_panic)) @@ -357,17 +357,17 @@ if(!shout || !prisoner.ai_running()) return FALSE prisoner.next_panic_line = world.time + OUTPOST_EXPERIMENT_PANIC_GAP * rand(4, 6) / 10 SECONDS - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "creature_flee") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "creature_flee") return TRUE /// Still frightened with nothing in sight: they stay where they ran to, and go back to it if something else took them away -/datum/outpost_prison/proc/keep_hiding(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/keep_hiding(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(istype(prisoner.activity, /datum/prisoner_activity/creature_panic) || istype(prisoner.activity, /datum/prisoner_activity/flee_creature)) return start_panic(prisoner) /// Cuffed, down or held: nowhere to go, so they cower and shout now and then. Returns TRUE if they shouted. -/datum/outpost_prison/proc/cower_in_fright(mob/living/basic/outpost_prisoner/prisoner, may_shout) +/datum/outpost_prison/proc/cower_in_fright(mob/living/carbon/human/npc/outpost_prisoner/prisoner, may_shout) if(!may_shout && world.time < prisoner.next_panic_line) return FALSE prisoner.next_panic_line = world.time + OUTPOST_EXPERIMENT_PANIC_GAP * rand(8, 12) / 10 SECONDS @@ -377,11 +377,11 @@ if(!may_shout && !wing_can_speak()) return FALSE note_speech() - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "creature_panic") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "creature_panic") return TRUE /// Nothing has frightened them for OUTPOST_PANIC_CALM_TIME, or they can no longer be frightened: back to their day -/datum/outpost_prison/proc/calm_from_panic(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/calm_from_panic(mob/living/carbon/human/npc/outpost_prisoner/prisoner) prisoner.creature_panic_left = 0 prisoner.panic_threat_ref = null prisoner.creature_reaction = null @@ -399,7 +399,7 @@ * mood and personality (outpost_prisoner_creature_fight_chance()). The roll holds for * OUTPOST_PANIC_ROLL_HOLD. Returns what they did. */ -/datum/outpost_prison/proc/creature_roll(mob/living/basic/outpost_prisoner/prisoner, mob/living/threat) +/datum/outpost_prison/proc/creature_roll(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/threat) if(prisoner.creature_reaction && world.time < prisoner.creature_reaction_until) return prisoner.creature_reaction var/reaction = forced_creature_reaction @@ -414,7 +414,7 @@ return reaction /// Goes at the creature, with their shiv if they have one. A fight they were in is forgotten. -/datum/outpost_prison/proc/stand_against_creature(mob/living/basic/outpost_prisoner/prisoner, mob/living/threat) +/datum/outpost_prison/proc/stand_against_creature(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/threat) if(prisoner.fight) end_fight(prisoner.fight) prisoner.cancel_threat() @@ -431,7 +431,7 @@ * (flee_spot()) and riot on from there, or back off where they stand with nowhere to go. Returns * TRUE if a rioter ran. */ -/datum/outpost_prison/proc/run_from_creature(mob/living/basic/outpost_prisoner/prisoner, mob/living/threat) +/datum/outpost_prison/proc/run_from_creature(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/threat) prisoner.creature_foe_ref = null if(!prisoner.is_rioting()) if(prisoner.fight) @@ -449,7 +449,7 @@ prisoner.creature_retreat_until = world.time + OUTPOST_PANIC_RETREAT_TIME prisoner.update_flee_speed() if(prisoner.ai_running() && prob(50)) - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "creature_flee") + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "creature_flee") return TRUE // ===== WHERE TO RUN ===== @@ -478,7 +478,7 @@ return steps /// Where they hide at home: their own cell's chair or bed (home_seat()), or failing that any free tile of their own cell they can walk to, or null -/datum/outpost_prison/proc/panic_home_spot(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/panic_home_spot(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/datum/outpost_prison_cell/home = prisoner.cell if(!home || !prisoner.walkable) return null @@ -496,7 +496,7 @@ * passes within OUTPOST_PANIC_PATH_MARGIN of it and no farther from it than they stand now. Once they * are inside the cell, only it coming in makes home unsafe. */ -/datum/outpost_prison/proc/home_is_safe(mob/living/basic/outpost_prisoner/prisoner, turf/home, mob/living/threat) +/datum/outpost_prison/proc/home_is_safe(mob/living/carbon/human/npc/outpost_prisoner/prisoner, turf/home, mob/living/threat) var/datum/outpost_prison_cell/own = prisoner.cell if(!own || !home) return FALSE @@ -533,7 +533,7 @@ * passing beside it that is farthest from it, out of its sight if any is, and not within * OUTPOST_PANIC_SPOT_MARGIN of it. Null when there is nowhere: they are cornered. */ -/datum/outpost_prison/proc/flee_spot(mob/living/basic/outpost_prisoner/prisoner, mob/living/threat) +/datum/outpost_prison/proc/flee_spot(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/living/threat) var/turf/here = get_turf(prisoner) var/turf/danger = get_turf(threat) if(!here || !danger || !length(prisoner.walkable)) diff --git a/voidcrew/modules/player_outposts/outpost_prison_pastimes.dm b/voidcrew/modules/player_outposts/outpost_prison_pastimes.dm index 52996783238..584decf82c2 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_pastimes.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_pastimes.dm @@ -70,7 +70,7 @@ /// Seconds since the last look for scrubbed marks var/mark_check_clock = 0 -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Between the lifts a card game gives them COOLDOWN_DECLARE(cards_mood_cooldown) /// Between the lifts a game of basketball with staff gives them @@ -83,7 +83,7 @@ /// Hooks up the prisoner's side of the pastimes; called from setup_extras(). Nothing to hook: the /// pastimes run from activities and the prison's clock. -/mob/living/basic/outpost_prisoner/proc/setup_pastimes() +/mob/living/carbon/human/npc/outpost_prisoner/proc/setup_pastimes() return /// Advances the party, the game with staff and the marks on the walls by `seconds` @@ -140,7 +140,7 @@ return found /// Whether `prisoner` could sit on `stool` now: they can walk there, and nobody else sits on it, stands on it or has it -/datum/outpost_prison/proc/stool_free_for(obj/structure/chair/stool, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/stool_free_for(obj/structure/chair/stool, mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/turf/seat = get_turf(stool) if(QDELETED(stool) || !seat || !prisoner.walkable?[seat] || claimed_by_other(stool, prisoner)) return FALSE @@ -202,7 +202,7 @@ /// Whether members of the wing sitting at the table are dealt in var/deals_in_members = FALSE -/datum/prisoner_activity/table_game/New(mob/living/basic/outpost_prisoner/doer, datum/prisoner_activity/table_game/host_game) +/datum/prisoner_activity/table_game/New(mob/living/carbon/human/npc/outpost_prisoner/doer, datum/prisoner_activity/table_game/host_game) . = ..(doer) if(host_game) game_ref = WEAKREF(host_game) @@ -284,7 +284,7 @@ return best /// Whether `other` may be called over: free for leisure (the chat's rule), not hungry, and no rival of the host -/datum/prisoner_activity/table_game/proc/can_invite(mob/living/basic/outpost_prisoner/other) +/datum/prisoner_activity/table_game/proc/can_invite(mob/living/carbon/human/npc/outpost_prisoner/other) if(other == prisoner || QDELETED(other) || !other.routine_allowed() || !other.ai_running()) return FALSE var/datum/prisoner_activity/doing = other.activity @@ -303,7 +303,7 @@ free_stools++ if(!free_stools) return FALSE - for(var/mob/living/basic/outpost_prisoner/other in prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prison.prisoners) if(can_invite(other)) return TRUE return FALSE @@ -454,7 +454,7 @@ if(own && prisoner.buckled == own) seats += list(list(prisoner, own, prison.table_beside(own))) for(var/datum/weakref/joiner_ref as anything in joiner_refs) - var/mob/living/basic/outpost_prisoner/joiner = joiner_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/joiner = joiner_ref.resolve() var/datum/prisoner_activity/table_game/seat = joiner?.activity if(!istype(seat) || seat.game_ref?.resolve() != src || !seat.started) continue @@ -491,7 +491,7 @@ var/datum/outpost_prison/prison = prisoner.prison var/list/live = list() for(var/datum/weakref/joiner_ref as anything in joiner_refs) - var/mob/living/basic/outpost_prisoner/joiner = joiner_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/joiner = joiner_ref.resolve() var/datum/prisoner_activity/table_game/seat = joiner?.activity if(istype(seat) && seat.game_ref?.resolve() == src) live += joiner_ref @@ -500,14 +500,14 @@ if(room <= 0) return 0 var/list/candidates = list() - for(var/mob/living/basic/outpost_prisoner/other in shuffle(prison.prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in shuffle(prison.prisoners)) if(can_invite(other)) candidates += other - var/mob/living/basic/outpost_prisoner/first_called + var/mob/living/carbon/human/npc/outpost_prisoner/first_called var/came = 0 while(room > 0 && length(candidates)) - var/mob/living/basic/outpost_prisoner/best - for(var/mob/living/basic/outpost_prisoner/other as anything in candidates) + var/mob/living/carbon/human/npc/outpost_prisoner/best + for(var/mob/living/carbon/human/npc/outpost_prisoner/other as anything in candidates) if(!best || prison.affinity(prisoner, other) > prison.affinity(prisoner, best)) best = other candidates -= best @@ -523,7 +523,7 @@ return came /// Gives `other` a seat at this game and sends them to it. TRUE if they are coming. -/datum/prisoner_activity/table_game/proc/seat_joiner(mob/living/basic/outpost_prisoner/other) +/datum/prisoner_activity/table_game/proc/seat_joiner(mob/living/carbon/human/npc/outpost_prisoner/other) if(!seat_type || QDELETED(other)) return FALSE var/datum/prisoner_activity/table_game/seat = new seat_type(other, src) @@ -537,7 +537,7 @@ /// Sends everyone the host called over back to their own business /datum/prisoner_activity/table_game/proc/release_joiners() for(var/datum/weakref/joiner_ref as anything in joiner_refs) - var/mob/living/basic/outpost_prisoner/joiner = joiner_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/joiner = joiner_ref.resolve() var/datum/prisoner_activity/table_game/seat = joiner?.activity if(istype(seat) && seat.game_ref?.resolve() == src) joiner.end_activity() @@ -738,7 +738,7 @@ /datum/prisoner_activity/cards/proc/reward_players(list/in_hand) var/datum/outpost_prison/prison = prisoner.prison for(var/mob/living/player as anything in in_hand) - var/mob/living/basic/outpost_prisoner/inmate = player + var/mob/living/carbon/human/npc/outpost_prisoner/inmate = player if(istype(inmate)) if(COOLDOWN_FINISHED(inmate, cards_mood_cooldown)) COOLDOWN_START(inmate, cards_mood_cooldown, PRISON_CARDS_MOOD_GAP) @@ -755,19 +755,19 @@ if(!prob(PRISON_CARDS_TALK_CHANCE)) return var/list/inmate_losers = list() - for(var/mob/living/basic/outpost_prisoner/loser in in_hand) + for(var/mob/living/carbon/human/npc/outpost_prisoner/loser in in_hand) if(loser != winner) inmate_losers += loser - var/mob/living/basic/outpost_prisoner/inmate_winner = is_outpost_prisoner(winner) ? winner : null + var/mob/living/carbon/human/npc/outpost_prisoner/inmate_winner = is_outpost_prisoner(winner) ? winner : null if(inmate_winner && (!length(inmate_losers) || prob(55))) - var/mob/living/basic/outpost_prisoner/beaten = length(inmate_losers) ? pick(inmate_losers) : null + var/mob/living/carbon/human/npc/outpost_prisoner/beaten = length(inmate_losers) ? pick(inmate_losers) : null if(beaten) inmate_winner.face_atom(beaten) inmate_winner.say_context("cards_win", beaten) return if(!length(inmate_losers)) return - var/mob/living/basic/outpost_prisoner/grumbler = pick(inmate_losers) + var/mob/living/carbon/human/npc/outpost_prisoner/grumbler = pick(inmate_losers) grumbler.face_atom(winner) if(inmate_winner) grumbler.say_context("cards_lose", inmate_winner) @@ -792,8 +792,8 @@ var/list/talkers = seated_prisoners() if(!length(talkers)) return - var/mob/living/basic/outpost_prisoner/speaker = pick(talkers) - var/mob/living/basic/outpost_prisoner/other = length(talkers) > 1 ? pick(talkers - speaker) : null + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick(talkers) + var/mob/living/carbon/human/npc/outpost_prisoner/other = length(talkers) > 1 ? pick(talkers - speaker) : null if(speaker.say_context("cards", other)) prison.note_speech() @@ -807,7 +807,7 @@ return FALSE COOLDOWN_START(prison, cards_missing_cooldown, PRISON_CARDS_MISSING_GAP) var/list/talkers = seated_prisoners() - var/mob/living/basic/outpost_prisoner/speaker = length(talkers) ? pick(talkers) : prisoner + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = length(talkers) ? pick(talkers) : prisoner return speaker.say_context("cards_missing") /datum/prisoner_activity/cards/end_game() @@ -815,7 +815,7 @@ /// The admin panel's card game: a prisoner free to play takes a deck to its table and calls others over. A log line, or null. /datum/outpost_prison/proc/admin_start_cards() - for(var/mob/living/basic/outpost_prisoner/host in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/host in shuffle(prisoners)) if(!host.ai_running() || !host.routine_allowed()) continue var/datum/prisoner_activity/doing = host.activity @@ -912,7 +912,7 @@ /// The prisoner rolling against the host, while they still sit at the table /datum/prisoner_activity/dice/proc/partner() - var/mob/living/basic/outpost_prisoner/other = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/other = partner_ref?.resolve() return (other && front_of(other)) ? other : null /datum/prisoner_activity/dice/play_step() @@ -923,7 +923,7 @@ return ACTIVITY_CONTINUE if(--roll_wait > 0) return ACTIVITY_CONTINUE - var/mob/living/basic/outpost_prisoner/roller = turn == 1 ? prisoner : partner() + var/mob/living/carbon/human/npc/outpost_prisoner/roller = turn == 1 ? prisoner : partner() if(rounds_left <= 0 || !roller || !roll_die(roller)) stage = "tidy" steps = 0 @@ -931,8 +931,8 @@ return ACTIVITY_CONTINUE /// Where `roller` rolls to: the table in front of the other player, else the table two tiles off -/datum/prisoner_activity/dice/proc/roll_target(mob/living/basic/outpost_prisoner/roller) - var/mob/living/basic/outpost_prisoner/other = roller == prisoner ? partner() : prisoner +/datum/prisoner_activity/dice/proc/roll_target(mob/living/carbon/human/npc/outpost_prisoner/roller) + var/mob/living/carbon/human/npc/outpost_prisoner/other = roller == prisoner ? partner() : prisoner var/turf/front = front_of(other) if(front && front != get_turf(roller)) return front @@ -942,7 +942,7 @@ return front_of(roller) /// `roller` picks up the die and rolls it across the table. TRUE if it is rolling. -/datum/prisoner_activity/dice/proc/roll_die(mob/living/basic/outpost_prisoner/roller) +/datum/prisoner_activity/dice/proc/roll_die(mob/living/carbon/human/npc/outpost_prisoner/roller) var/obj/item/dice/die = die_ref?.resolve() if(QDELETED(die)) return FALSE @@ -980,11 +980,11 @@ /// Both have rolled: the higher roll wins and usually says so. Returns the winner, or null for a draw. /datum/prisoner_activity/dice/proc/settle_round() last_winner_ref = null - var/mob/living/basic/outpost_prisoner/other = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/other = partner_ref?.resolve() if(!other || rolls[1] == rolls[2]) return null - var/mob/living/basic/outpost_prisoner/winner = rolls[1] > rolls[2] ? prisoner : other - var/mob/living/basic/outpost_prisoner/loser = winner == prisoner ? other : prisoner + var/mob/living/carbon/human/npc/outpost_prisoner/winner = rolls[1] > rolls[2] ? prisoner : other + var/mob/living/carbon/human/npc/outpost_prisoner/loser = winner == prisoner ? other : prisoner last_winner_ref = WEAKREF(winner) if(prob(70) && winner.stat == CONSCIOUS) winner.face_atom(loser) @@ -1034,7 +1034,7 @@ /// The prisoner whose birthday it is and who has not had their party, if any is in the wing /datum/outpost_prison/proc/birthday_host() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.has_birthday && !prisoner.party_done && prisoner.stat != DEAD && prisoner.phase == PRISONER_PRESENT) return prisoner return null @@ -1043,7 +1043,7 @@ * Keeps `cake` for `host`'s party: nobody eats it as a meal while it waits (reserved_supply()). * `giver` is whoever put it on the hatch or handed it over. Never sleeps. Returns TRUE if it is kept. */ -/datum/outpost_prison/proc/start_party(mob/living/basic/outpost_prisoner/host, obj/item/food/cake/cake, mob/living/giver) +/datum/outpost_prison/proc/start_party(mob/living/carbon/human/npc/outpost_prisoner/host, obj/item/food/cake/cake, mob/living/giver) if(party_stage || QDELETED(host) || !istype(cake) || QDELETED(cake)) return FALSE if(!host.has_birthday || host.party_done || host.stat == DEAD || host.phase != PRISONER_PRESENT || !(host in prisoners)) @@ -1059,7 +1059,7 @@ /// Sends the host to fetch the cake, once they are free. TRUE if they are on it. /datum/outpost_prison/proc/try_start_party_host() - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() if(!host || party_stage != PARTY_WAITING || !party_cake()) return FALSE if(istype(host.activity, /datum/prisoner_activity/party_host)) @@ -1078,7 +1078,7 @@ return TRUE /// Whether `guest` would come over to the party: awake, free and not busy with a meal or a duty -/datum/outpost_prison/proc/party_guest_free(mob/living/basic/outpost_prisoner/guest) +/datum/outpost_prison/proc/party_guest_free(mob/living/carbon/human/npc/outpost_prisoner/guest) if(QDELETED(guest) || !guest.ai_running() || !guest.routine_allowed()) return FALSE var/datum/prisoner_activity/doing = guest.activity @@ -1093,7 +1093,7 @@ * Returns TRUE if it began. */ /datum/outpost_prison/proc/party_ready(turf/spot) - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() if(party_stage != PARTY_WAITING || !host || !spot) return FALSE party_spot = spot @@ -1101,14 +1101,14 @@ party_stage_seconds = 0 party_scene_seconds = 0 party_guests = list() - for(var/mob/living/basic/outpost_prisoner/guest in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/guest in prisoners) if(guest != host) invite_party_guest(guest) host.manual_emote("waves everyone over.") return TRUE /// Calls `guest` over to the party. TRUE if they are coming. -/datum/outpost_prison/proc/invite_party_guest(mob/living/basic/outpost_prisoner/guest) +/datum/outpost_prison/proc/invite_party_guest(mob/living/carbon/human/npc/outpost_prisoner/guest) if(!party_spot || !party_guest_free(guest)) return FALSE var/datum/prisoner_activity/party_guest/coming = new(guest) @@ -1123,7 +1123,7 @@ /datum/outpost_prison/proc/present_guests(arrived_only = TRUE) var/list/found = list() for(var/datum/weakref/guest_ref as anything in party_guests) - var/mob/living/basic/outpost_prisoner/guest = guest_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/guest = guest_ref.resolve() var/datum/prisoner_activity/party_guest/attending = guest?.activity if(!istype(attending) || guest.stat != CONSCIOUS || (arrived_only && !attending.started)) continue @@ -1139,7 +1139,7 @@ /datum/outpost_prison/proc/party_tick(seconds) if(!party_stage) return - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() if(!host || !(host in prisoners) || host.stat == DEAD || host.phase != PRISONER_PRESENT) end_party() return @@ -1174,7 +1174,7 @@ /// The candles go out, and the guests cheer and clap /datum/outpost_prison/proc/blow_candles() - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() var/obj/item/food/cake/cake = party_cake() if(!host || !cake) end_party() @@ -1184,7 +1184,7 @@ host.face_atom(cake) host.manual_emote("blows out the candles.") var/cheers = 0 - for(var/mob/living/basic/outpost_prisoner/guest as anything in shuffle(present_guests())) + for(var/mob/living/carbon/human/npc/outpost_prisoner/guest as anything in shuffle(present_guests())) guest.face_atom(cake) if(cheers < 2) addtimer(CALLBACK(src, PROC_REF(say_life_line), WEAKREF(guest), "birthday_party", WEAKREF(host)), (0.5 + 0.8 * cheers) SECONDS) @@ -1199,7 +1199,7 @@ * table. Crumbs are left behind. */ /datum/outpost_prison/proc/serve_party() - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() var/obj/item/food/cake/cake = party_cake() var/turf/center = party_spot || get_turf(cake) if(!host || !cake || !center) @@ -1216,11 +1216,11 @@ host.visible_message(span_notice("[host] cuts [cake] and passes the slices round.")) qdel(cake) var/list/diners = list(host) - for(var/mob/living/basic/outpost_prisoner/guest as anything in present_guests()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/guest as anything in present_guests()) if(get_dist(guest, center) <= PRISON_PARTY_REACH) diners += guest party_diners = list() - for(var/mob/living/basic/outpost_prisoner/diner as anything in diners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/diner as anything in diners) diner.drop_held_item() // Beside the table they take theirs off it; further round, the slice is passed along to them. // Nobody picks anything up from further off than they can reach (take_item()). @@ -1237,12 +1237,12 @@ /// Everyone eats their slice; the host thanks whoever brought the cake, and has had their party /datum/outpost_prison/proc/finish_party() - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() var/fed = 0 for(var/list/entry as anything in party_diners) var/datum/weakref/diner_ref = entry[1] var/datum/weakref/slice_ref = entry[2] - var/mob/living/basic/outpost_prisoner/diner = diner_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/diner = diner_ref.resolve() var/obj/item/food/slice = slice_ref.resolve() if(!diner || !slice || diner.held_item != slice || diner.stat != CONSCIOUS) continue @@ -1268,7 +1268,7 @@ * `diner` eats a slice of the birthday cake. It counts as a cooked meal however the cake was made * (a birthday cake is junk food to outpost_prisoner_food_tier()), as the build plan asks. */ -/datum/outpost_prison/proc/party_feed(mob/living/basic/outpost_prisoner/diner, obj/item/food/slice) +/datum/outpost_prison/proc/party_feed(mob/living/carbon/human/npc/outpost_prisoner/diner, obj/item/food/slice) if(outpost_prisoner_food_tier(slice) == "cooked") diner.eat_food(slice) return @@ -1289,7 +1289,7 @@ /// The party is over or off: everyone goes back to their day, and the cake (if uneaten) is ordinary food /datum/outpost_prison/proc/end_party() var/list/guests = party_guests - var/mob/living/basic/outpost_prisoner/host = party_host_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/host = party_host_ref?.resolve() clear_party_state() dismiss_party_guests(guests) if(host && istype(host.activity, /datum/prisoner_activity/party_host)) @@ -1297,7 +1297,7 @@ /datum/outpost_prison/proc/dismiss_party_guests(list/guests) for(var/datum/weakref/guest_ref as anything in guests) - var/mob/living/basic/outpost_prisoner/guest = guest_ref.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/guest = guest_ref.resolve() if(guest && istype(guest.activity, /datum/prisoner_activity/party_guest)) guest.end_activity() @@ -1314,7 +1314,7 @@ party_diners = list() /// Whether `thing` is the cake kept for a party, which nobody may eat as a meal -/datum/outpost_prison/proc/reserved_supply(obj/item/thing, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/reserved_supply(obj/item/thing, mob/living/carbon/human/npc/outpost_prisoner/prisoner) return !!party_stage && !!thing && thing == party_cake() /** @@ -1322,7 +1322,7 @@ * eaten this way. A whole cake handed to the prisoner whose birthday it is starts their party: they * take it, and fetch it to a table once they are free. Runs in a signal handler: never sleeps. */ -/datum/outpost_prison/proc/pastime_pre_eat(mob/living/basic/outpost_prisoner/prisoner, atom/food, mob/living/feeder) +/datum/outpost_prison/proc/pastime_pre_eat(mob/living/carbon/human/npc/outpost_prisoner/prisoner, atom/food, mob/living/feeder) if(reserved_supply(food, prisoner)) // Handing the kept cake to the birthday prisoner saves them fetching it. if(party_stage == PARTY_WAITING && prisoner == party_host_ref?.resolve() && istype(feeder) && food.loc == feeder && !prisoner.held_item && feeder.temporarilyRemoveItemFromInventory(food)) @@ -1359,7 +1359,7 @@ if(!QDELETED(whole) && whole.loc == hatch?.loc) cake = whole break - var/mob/living/basic/outpost_prisoner/host = birthday_host() + var/mob/living/carbon/human/npc/outpost_prisoner/host = birthday_host() if(!cake || !host || !start_party(host, cake, user)) return FALSE INVOKE_ASYNC(src, PROC_REF(try_start_party_host)) @@ -1591,7 +1591,7 @@ /// The hoop a prisoner is shooting at now, or null when nobody is playing /datum/outpost_prison/proc/game_hoop() - for(var/mob/living/basic/outpost_prisoner/player in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/player in prisoners) var/datum/prisoner_activity/basketball/game = player.activity if(!istype(game) || !game.started || player.stat != CONSCIOUS) continue @@ -1603,7 +1603,7 @@ /// Prisoners playing basketball now /datum/outpost_prison/proc/basketball_players() var/list/players = list() - for(var/mob/living/basic/outpost_prisoner/player in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/player in prisoners) if(player.stat == CONSCIOUS && istype(player.activity, /datum/prisoner_activity/basketball) && player.activity.started) players += player return players @@ -1611,7 +1611,7 @@ /// Prisoners watching the game at `hoop` /datum/outpost_prison/proc/game_watchers(obj/structure/hoop/hoop) var/list/watchers = list() - for(var/mob/living/basic/outpost_prisoner/watcher in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/watcher in prisoners) var/datum/prisoner_activity/watch_game/watching = watcher.activity if(istype(watching) && watching.started && watcher.stat == CONSCIOUS && watching.hoop_ref?.resolve() == hoop) watchers += watcher @@ -1668,7 +1668,7 @@ /// Each prisoner playing now cheers up for playing with staff, at most every PRISON_STAFF_GAME_MOOD_GAP /datum/outpost_prison/proc/lift_staff_game_players() - for(var/mob/living/basic/outpost_prisoner/player as anything in basketball_players()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/player as anything in basketball_players()) if(!COOLDOWN_FINISHED(player, staff_game_mood_cooldown)) continue COOLDOWN_START(player, staff_game_mood_cooldown, PRISON_STAFF_GAME_MOOD_GAP) @@ -1683,11 +1683,11 @@ return var/mob/living/shooter = shooter_ref?.resolve() var/list/watchers = game_watchers(hoop) - for(var/mob/living/basic/outpost_prisoner/watcher as anything in watchers) + for(var/mob/living/carbon/human/npc/outpost_prisoner/watcher as anything in watchers) watcher.face_atom(hoop) if(score_event) var/list/announcers = length(watchers) ? watchers : basketball_players() - var/mob/living/basic/outpost_prisoner/announcer = length(announcers) ? pick(announcers) : null + var/mob/living/carbon/human/npc/outpost_prisoner/announcer = length(announcers) ? pick(announcers) : null var/mob/living/staff = staff_ref?.resolve() switch(score_event) if("call") @@ -1698,7 +1698,7 @@ announcer?.say_to_staff("game_lost", staff) if(scored && length(watchers) && COOLDOWN_FINISHED(src, crowd_clap_cooldown)) COOLDOWN_START(src, crowd_clap_cooldown, PRISON_CROWD_CLAP_GAP) - for(var/mob/living/basic/outpost_prisoner/watcher as anything in watchers) + for(var/mob/living/carbon/human/npc/outpost_prisoner/watcher as anything in watchers) if(prob(70)) watcher.manual_emote("claps.") playsound(watcher, pick('sound/mobs/humanoids/human/clap/clap1.ogg', 'sound/mobs/humanoids/human/clap/clap2.ogg', 'sound/mobs/humanoids/human/clap/clap3.ogg'), 35, TRUE) @@ -1708,7 +1708,7 @@ if(!context) return COOLDOWN_START(src, crowd_line_cooldown, PRISON_CROWD_LINE_GAP) - var/mob/living/basic/outpost_prisoner/speaker = pick(watchers) + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = pick(watchers) if(is_outpost_prisoner(shooter)) speaker.say_context(context, shooter) else if(shooter) @@ -1822,7 +1822,7 @@ return initials || "Someone" /// The crayon state for a prisoner's mark: their first initial, or a doodle -/proc/outpost_prisoner_mark_state(mob/living/basic/outpost_prisoner/prisoner) +/proc/outpost_prisoner_mark_state(mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/letter = lowertext(copytext(prisoner.real_name, 1, 2)) if(prob(PRISON_MARK_INITIAL_CHANCE) && length(letter) == 1 && text2ascii(letter) >= 97 && text2ascii(letter) <= 122) return letter @@ -1844,7 +1844,7 @@ * "R.D. was here." A cell keeps PRISON_CELL_MARKS_MAX marks; a new one replaces the oldest. * Crayon marks are not mess (outpost_prison_conditions.dm). Returns the mark, or null. */ -/datum/outpost_prison/proc/add_cell_mark(mob/living/basic/outpost_prisoner/prisoner, turf/wall) +/datum/outpost_prison/proc/add_cell_mark(mob/living/carbon/human/npc/outpost_prisoner/prisoner, turf/wall) var/datum/outpost_prison_cell/home = prisoner?.cell if(!home || !isclosedturf(wall)) return null @@ -1871,7 +1871,7 @@ /// A mark is going for a newer one: whoever made it no longer looks for it /datum/outpost_prison/proc/forget_wall_mark(obj/effect/decal/cleanable/crayon/mark) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.wall_mark_ref?.resolve() == mark) prisoner.wall_mark_ref = null prisoner.wall_mark_spot = null @@ -1882,7 +1882,7 @@ */ /datum/outpost_prison/proc/check_scrubbed_marks() var/noticed = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!prisoner.wall_mark_ref || prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT) continue var/obj/effect/decal/cleanable/crayon/mark = prisoner.wall_mark_ref.resolve() diff --git a/voidcrew/modules/player_outposts/outpost_prison_prisoner.dm b/voidcrew/modules/player_outposts/outpost_prison_prisoner.dm index 369d99e208c..f6b99de16e7 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_prisoner.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_prisoner.dm @@ -24,38 +24,25 @@ #define PRISONER_BEAM_TRAIT "outpost_prisoner_beam" /// Offset source for sitting on a bed edge #define PRISONER_SITTING_OFFSET "outpost_prisoner_sitting" +/// Trait source for what only an awake prisoner on their feet refuses (a bag, a locker) +#define PRISONER_AWAKE_TRAIT "outpost_prisoner_awake" -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner name = "prisoner" desc = "An inmate of the outpost's prison wing." - icon = 'icons/mob/simple/simple_human.dmi' - unique_name = FALSE combat_mode = FALSE - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - sentience_type = SENTIENCE_HUMANOID - maxHealth = 100 - health = 100 - speed = 2 - // Heavy while awake, so nobody shoves them about; see update_drag_resistance(). - move_resist = MOVE_FORCE_VERY_STRONG - density = TRUE - basic_mob_flags = NONE - // tg's human deathgasp - death_message = "seizes up and falls limp, their eyes dead and lifeless..." - // They lie down on beds, when knocked down and when dead. - mobility_flags = MOBILITY_FLAGS_REST_CAPABLE_DEFAULT - rotate_on_lying = TRUE - blood_volume = BLOOD_VOLUME_NORMAL - // Batons knock them down, as they do people. - status_flags = CANPUSH | CANSTUN | CANKNOCKDOWN - stamina_regen_time = PRISONER_STAMCRIT_TIME - // No air needs yet: a breach must not kill the inmates. - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - response_help_continuous = "pats" - response_help_simple = "pat" + faction = list(FACTION_NEUTRAL) ai_controller = /datum/ai_controller/basic_controller/outpost_prisoner + npc_outfit = /datum/outfit/outpost_prisoner + // Owner ruling OC-11: their jumpsuit, contraband and shivs are ordinary items, so shakedowns, + // confiscation and uniform swaps work. Searched and stripped like anyone. + npc_gear_locked = FALSE + // They stop once someone is down (defaults 1.13). + npc_target_stat = HUMAN_NPC_SPARES_DOWNED + // The prison owns the body: it lies in the cell block until carried out (outpost_prison_economy.dm). + npc_corpse_lifetime = 0 + // No air tank: the cell block's emergency lockers have the masks and tanks (outpost_prison_conditions.dm). + npc_internals = TRUE /// The prison holding them var/datum/outpost_prison/prison @@ -80,8 +67,6 @@ var/locked_in_seconds = 0 /// Outfit whose look they wear var/outfit_path - /// Which of the people wearing that outfit they look like (outpost_npc_looks.dm) - var/look_number = 1 /// The need their thought bubble is about, if any; see update_bubble() var/bubble /// The bubble that is up now, if one is; it pops up now and then rather than staying @@ -93,7 +78,7 @@ var/bubble_timer /// Which grime overlay they show: 0 none, 1 dirty, 2 filthy var/grime_stage = 0 - /// What they are carrying, held in their contents + /// What they are carrying in their hand for the prison's routine (take_item()), a real item in a real hand var/obj/item/held_item /// What they are holding a hand out for, one activity tick before they take it (see reach_for()) var/datum/weakref/reaching_ref @@ -142,29 +127,39 @@ /// Running while what they last said is up over their head: no thought bubble (hush_bubble()) COOLDOWN_DECLARE(bubble_hush) -/mob/living/basic/outpost_prisoner/Initialize(mapload, datum/bounty_record/record) - gender = pick(MALE, FEMALE) - // A caught bounty criminal (admit_next()) keeps their body and who they were (outpost_prison_bounty.dm). +/** + * The second argument is a caught criminal's record (admit_next(), outpost_prison_bounty.dm), never a + * human NPC spawn order: a bounty prisoner keeps the face the record says they were caught with (the + * base dresses a /datum/bounty_record look). Anyone else is a random person in one of the prison's + * jumpsuits. Prisoners are made one at a time as they are admitted, so they don't go through the + * human NPC spawn queue. + */ +/mob/living/carbon/human/npc/outpost_prisoner/Initialize(mapload, datum/bounty_record/record) + if(!istype(record)) + record = null bounty_record = record if(record) apply_bounty_body(record) - . = ..() - real_name = generate_random_name_species_based(gender, TRUE, /datum/species/human) - name = real_name + npc_look = record + // The plain jumpsuit: the glasses and beanie variants would hide their hair or fake their eyewear + npc_outfit = /datum/outfit/outpost_prisoner + else + npc_outfit = pick(/datum/outfit/outpost_prisoner, /datum/outfit/outpost_prisoner/glasses, /datum/outfit/outpost_prisoner/beanie) + outfit_path = npc_outfit + . = ..(mapload) + // They stroll; a runner or a rioter breaks into a run (set_prison_pace()). + set_prison_pace(hurry = FALSE) roll_arrival() var/list/personalities = outpost_prisoner_dialogue("personalities") personality = length(personalities) ? pick(personalities) : "quiet" var/list/crimes = outpost_prisoner_dialogue("crimes") crime = length(crimes) ? pick(crimes) : "unpaid docking fees" - outfit_path = pick(/datum/outfit/outpost_prisoner, /datum/outfit/outpost_prisoner/glasses, /datum/outfit/outpost_prisoner/beanie) - look_number = random_outpost_npc_look_number() if(record) apply_bounty_record(record) - INVOKE_ASYNC(src, PROC_REF(build_look)) + uniform_grime_to_suit() // One shared list: element arguments are keyed by list reference. var/static/list/edible_types = list(/obj/item/food) AddElement(/datum/element/basic_eating, food_types = edible_types) - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) RegisterSignal(src, COMSIG_MOB_PRE_EAT, PROC_REF(on_pre_eat)) RegisterSignal(src, COMSIG_MOB_ATE, PROC_REF(on_ate)) RegisterSignal(src, COMSIG_ATOM_ITEM_INTERACTION, PROC_REF(on_item_interaction)) @@ -172,67 +167,54 @@ RegisterSignal(src, COMSIG_LIVING_HEALTH_UPDATE, PROC_REF(on_health_update)) // Talking puts the thought bubble away (manual_emote() below covers the prison's own emotes). RegisterSignals(src, list(COMSIG_MOB_SAY, COMSIG_MOB_EMOTE), PROC_REF(on_speech)) - // Dragging only while they are down or cuffed (can_be_dragged()); pulling also while calm (pull_allowed()). - RegisterSignal(src, COMSIG_MOUSEDROP_ONTO, PROC_REF(block_being_dragged)) - RegisterSignal(src, COMSIG_ATOM_CAN_BE_PULLED, PROC_REF(check_pullable)) - // Changes to being down reach update_drag_resistance() through the living trait handlers - // overridden below; registering those trait signals here would replace living's own. - ADD_TRAIT(src, TRAIT_NO_CONTAINMENT, INNATE_TRAIT) - ADD_TRAIT(src, TRAIT_NO_STORAGE_INSERT, INNATE_TRAIT) + // Members only put cuffs on them (outpost_prison_capture.dm); tg's handcuffs do the rest. + RegisterSignal(src, COMSIG_CARBON_CUFF_ATTEMPTED, PROC_REF(on_cuff_attempt)) + // Cuffs going on and off, and going down and getting up (crit, stamcrit, knockdowns), reach + // update_drag_resistance() through the living trait handlers overridden below; registering those + // trait signals here would replace living's own. setup_trouble() setup_containment() setup_extras() last_health = health + update_drag_resistance() -/mob/living/basic/outpost_prisoner/Destroy() +/mob/living/carbon/human/npc/outpost_prisoner/Destroy() end_activity(cancel_ai = FALSE) clear_trouble() - if(held_item) - held_item.forceMove(drop_location()) - remove_cuffs() prison?.forget(src) prison = null cell = null walkable = null reachable = null + held_item = null deltimer(bubble_timer) if(thought) vis_contents -= thought QDEL_NULL(thought) return ..() -/mob/living/basic/outpost_prisoner/Exited(atom/movable/gone, direction) +/mob/living/carbon/human/npc/outpost_prisoner/Exited(atom/movable/gone, direction) . = ..() if(gone == held_item) held_item = null - update_appearance(UPDATE_OVERLAYS) - else if(gone == cuffs) - // Cuffs taken or destroyed some other way than remove_cuffs() - cuffs = null - uncuffed() - -/// Dresses them as their own person in their outfit, with a body to match their gender; a bounty prisoner keeps the face they were caught with (bounty_looks.dm). Can sleep. -/mob/living/basic/outpost_prisoner/proc/build_look() - if(bounty_record) - apply_bounty_look(src, bounty_record, outfit_path) - else - set_outpost_npc_look(src, outfit_path, gender, look_number) - if(QDELETED(src)) - return - update_appearance(UPDATE_OVERLAYS) + +/// Walking pace, or a run for a runner, a rioter or someone fleeing, by the move intent a player uses. +/mob/living/carbon/human/npc/proc/set_prison_pace(hurry) + if((move_intent == MOVE_INTENT_RUN) != !!hurry) + toggle_move_intent() /** * How they come in: hungry, a third in a stained transfer uniform, a fifth roughed up in transfer. * The injury waits for beam_in(), so a prisoner made any other way (an admin, a test) starts whole. */ -/mob/living/basic/outpost_prisoner/proc/roll_arrival() +/mob/living/carbon/human/npc/outpost_prisoner/proc/roll_arrival() hunger = rand(PRISONER_ARRIVAL_HUNGER_MIN, PRISONER_ARRIVAL_HUNGER_MAX) arrived_stained = prob(PRISONER_STAINED_CHANCE) uniform_grime = arrived_stained ? rand(PRISONER_STAINED_GRIME_MIN, PRISONER_STAINED_GRIME_MAX) : rand(0, PRISONER_ARRIVAL_GRIME_MAX) arrival_brute = prob(PRISONER_HURT_ARRIVAL_CHANCE) ? round(maxHealth * (100 - rand(PRISONER_HURT_ARRIVAL_MIN, PRISONER_HURT_ARRIVAL_MAX)) / 100) : 0 /// Their first name, for dialogue. (Not called first_name(): inside it, that would call itself.) -/mob/living/basic/outpost_prisoner/proc/speech_name() +/mob/living/carbon/human/npc/outpost_prisoner/proc/speech_name() return first_name(real_name) // ===== BEAMING IN AND OUT ===== @@ -243,7 +225,7 @@ * PRISONER_ARRIVING, and so do nothing at all (no walking, routine, trouble, speech or thought * bubble), until finish_beam_in() as the beam ends. */ -/mob/living/basic/outpost_prisoner/proc/beam_in() +/mob/living/carbon/human/npc/outpost_prisoner/proc/beam_in() phase = PRISONER_ARRIVING if(arrival_brute > 0) // Roughed up in transfer: no attacker, so no blame, no blood and no collapse. @@ -264,7 +246,7 @@ addtimer(CALLBACK(src, PROC_REF(finish_beam_in)), OUTPOST_PRISON_BEAM_TIME) /// Fully there as the beam ends: the flash, and only now are they present, free to move, and say hello -/mob/living/basic/outpost_prisoner/proc/finish_beam_in() +/mob/living/carbon/human/npc/outpost_prisoner/proc/finish_beam_in() if(phase != PRISONER_ARRIVING) return var/turf/spot = get_turf(src) @@ -291,7 +273,7 @@ * Dematerialises them where they stand and deletes them at the end of the beam. From the start * they are PRISONER_LEAVING: held still, with no routine, trouble, speech or thought bubble. */ -/mob/living/basic/outpost_prisoner/proc/beam_out() +/mob/living/carbon/human/npc/outpost_prisoner/proc/beam_out() if(phase == PRISONER_LEAVING) return phase = PRISONER_LEAVING @@ -312,7 +294,7 @@ transporter_dematerialise(src, OUTPOST_PRISON_BEAM_TIME) addtimer(CALLBACK(src, PROC_REF(finish_beam_out)), OUTPOST_PRISON_BEAM_TIME) -/mob/living/basic/outpost_prisoner/proc/finish_beam_out() +/mob/living/carbon/human/npc/outpost_prisoner/proc/finish_beam_out() var/turf/spot = get_turf(src) if(spot) new /obj/effect/temp_visual/transporter_flash/departure(spot) @@ -321,7 +303,7 @@ qdel(src) /// What the warden's roster calls them: present, arriving, leaving or dead -/mob/living/basic/outpost_prisoner/proc/console_status() +/mob/living/carbon/human/npc/outpost_prisoner/proc/console_status() if(stat == DEAD) return "dead" if(phase == PRISONER_ARRIVING) @@ -330,75 +312,76 @@ return "leaving" return "present" -// ===== DRAGGING ===== +// ===== DOWN, CUFFED AND PULLED ===== -/// Whether they are down: knocked down, in stamina crit, beaten, out or dead -/mob/living/basic/outpost_prisoner/proc/is_down() - // Floored but not buckled is on the floor; a bed floors them too, and that doesn't count. - return stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_INCAPACITATED) || (HAS_TRAIT(src, TRAIT_FLOORED) && !buckled) +/// Whether they are down: in crit, in stamina crit, knocked down, stunned, out cold, lying on the floor or dead (tg's own states) +/mob/living/carbon/human/npc/outpost_prisoner/proc/is_down() + if(stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_INCAPACITATED)) + return TRUE + // On the floor but not on a bed: lying in bed is no more down than sitting in a chair. + return (HAS_TRAIT(src, TRAIT_FLOORED) || body_position == LYING_DOWN) && !buckled -/// Staff may drag them only while they are down or cuffed (outpost_prison_capture.dm) -/mob/living/basic/outpost_prisoner/proc/can_be_dragged() - return is_down() || !!cuffs +/// Down or in handcuffs: whatever trouble they were in stops, and staff can take them by the arm (outpost_prison_capture.dm) +/mob/living/carbon/human/npc/outpost_prisoner/proc/can_be_dragged() + return is_down() || !!handcuffed /** * Calm enough to go along with a pull: awake, present, and in no trouble of their own (rioting, * loose, fighting, wrecking, squaring up, swinging, climbing, beaten down or hitting back). * Someone talking to them or working on their cuffs does not count. */ -/mob/living/basic/outpost_prisoner/proc/calm_for_pull() - return stat == CONSCIOUS && phase == PRISONER_PRESENT && !trouble && !threat_ref && !swing_ref && !climb_ref && beaten_left <= 0 && !retaliating() +/mob/living/carbon/human/npc/outpost_prisoner/proc/calm_for_pull() + return stat == CONSCIOUS && phase == PRISONER_PRESENT && !trouble && !threat_ref && !swing_ref && !climb_ref && !retaliating() -/// Anyone may pull them while they are down or cuffed, or while they are calm -/mob/living/basic/outpost_prisoner/proc/pull_allowed() +/// Whether they go along with a pull, or let it be: down, cuffed or calm. Anyone else resists it the way a person does. +/mob/living/carbon/human/npc/outpost_prisoner/proc/pull_allowed() return can_be_dragged() || calm_for_pull() -/// Their move_resist: ordinary while they can be dragged or are going along with a pull, else too heavy to shove or pull -/mob/living/basic/outpost_prisoner/proc/pull_weight() - return (can_be_dragged() || (pulledby && calm_for_pull())) ? MOVE_RESIST_DEFAULT : MOVE_FORCE_VERY_STRONG - -/mob/living/basic/outpost_prisoner/proc/check_pullable(datum/source, mob/living/puller) - SIGNAL_HANDLER - return pull_allowed() ? NONE : COMSIG_ATOM_CANT_PULL - -// Calm, they go along with a pull however heavy they are against shoves; pull_weight() lightens them once it has hold. -/mob/living/basic/outpost_prisoner/can_be_pulled(user, force) - if(!can_be_dragged() && calm_for_pull()) - force = max(force, move_resist * MOVE_FORCE_PULL_RATIO) - return ..(user, force) +/mob/living/carbon/human/npc/outpost_prisoner/on_floored_trait_gain(datum/source) + . = ..() + update_drag_resistance() -/mob/living/basic/outpost_prisoner/proc/block_being_dragged(atom/over, mob/user) - SIGNAL_HANDLER - return can_be_dragged() ? NONE : COMPONENT_CANCEL_MOUSEDROP_ONTO +/mob/living/carbon/human/npc/outpost_prisoner/on_floored_trait_loss(datum/source) + . = ..() + update_drag_resistance() -/mob/living/basic/outpost_prisoner/on_floored_trait_gain(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/on_incapacitated_trait_gain(datum/source) . = ..() update_drag_resistance() -/mob/living/basic/outpost_prisoner/on_floored_trait_loss(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/on_incapacitated_trait_loss(datum/source) . = ..() update_drag_resistance() -/mob/living/basic/outpost_prisoner/on_incapacitated_trait_gain(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/on_restrained_trait_gain(datum/source) . = ..() + on_restraint_changed() update_drag_resistance() -/mob/living/basic/outpost_prisoner/on_incapacitated_trait_loss(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/on_restrained_trait_loss(datum/source) . = ..() + on_restraint_changed() update_drag_resistance() -/mob/living/basic/outpost_prisoner/set_stat(new_stat) +/mob/living/carbon/human/npc/outpost_prisoner/set_stat(new_stat) . = ..() update_drag_resistance() -/// Heavy while awake and free, so nobody shoves them about; ordinary while they are down or cuffed, or calm and being pulled -/mob/living/basic/outpost_prisoner/proc/update_drag_resistance() +/** + * Going down, getting up, cuffs on and off. Awake and free they keep out of bags and lockers (a + * bluespace bag is a way out of the wing); down or dead they can be bagged like anyone. Down or cuffed, + * whatever they were doing and whatever trouble they were making stops. Up and free again, a rioter + * riots on. + */ +/mob/living/carbon/human/npc/outpost_prisoner/proc/update_drag_resistance() var/draggable = can_be_dragged() - move_resist = pull_weight() + if(stat == CONSCIOUS && !is_down()) + ADD_TRAIT(src, TRAIT_NO_STORAGE_INSERT, PRISONER_AWAKE_TRAIT) + ADD_TRAIT(src, TRAIT_NO_CONTAINMENT, PRISONER_AWAKE_TRAIT) + else + REMOVE_TRAIT(src, TRAIT_NO_STORAGE_INSERT, PRISONER_AWAKE_TRAIT) + REMOVE_TRAIT(src, TRAIT_NO_CONTAINMENT, PRISONER_AWAKE_TRAIT) if(!draggable) - // A calm prisoner goes along with a pull (outpost_prison_warden_tools.dm); anyone else shakes it off. - if(!calm_for_pull()) - pulledby?.stop_pulling() if(is_rioting() && stat == CONSCIOUS) // Up and free again: a rioter riots on until the riot is over. INVOKE_ASYNC(src, PROC_REF(back_to_rioting)) @@ -416,7 +399,7 @@ * Time passing: hunger falls (not while well fed after cooked food) and the uniform gets dirtier, * three times as fast at sport, where they sometimes get hurt. */ -/mob/living/basic/outpost_prisoner/proc/adjust_needs(seconds) +/mob/living/carbon/human/npc/outpost_prisoner/proc/adjust_needs(seconds) if(stat == DEAD) return var/hungry_seconds = seconds @@ -434,7 +417,7 @@ sport_injury() /// Shooting hoops or working out: sweaty, and now and then painful -/mob/living/basic/outpost_prisoner/proc/playing_sport() +/mob/living/carbon/human/npc/outpost_prisoner/proc/playing_sport() if(!activity?.started) return FALSE if(istype(activity, /datum/prisoner_activity/basketball)) @@ -443,49 +426,69 @@ return istype(workout) && workout.exercising /** - * Hurt at sport: a turned ankle or the ball in the face, PRISONER_SPORT_INJURY_MIN to _MAX brute, - * and they stop playing. No attacker, so no blame and no blood. Never while already badly hurt, - * and never enough to put them down. + * Hurt at sport, as anyone gets hurt at sport: a turned ankle (a real sprain on a leg), the ball in + * the face or a pulled back, PRISONER_SPORT_INJURY_MIN to _MAX brute on the limb it happened to, and + * they stop playing. No attacker, so no blame. Not while already badly hurt. */ -/mob/living/basic/outpost_prisoner/proc/sport_injury() +/mob/living/carbon/human/npc/outpost_prisoner/proc/sport_injury() if(stat != CONSCIOUS || health_factor() <= PRISONER_SPORT_INJURY_ABOVE) return FALSE - var/damage = min(rand(PRISONER_SPORT_INJURY_MIN, PRISONER_SPORT_INJURY_MAX), health - 1) - if(damage < 1) - return FALSE - if(istype(activity, /datum/prisoner_activity/basketball)) - manual_emote(pick("goes down clutching an ankle.", "takes the ball to the face.", "lands badly and hops off the court.")) + var/damage = rand(PRISONER_SPORT_INJURY_MIN, PRISONER_SPORT_INJURY_MAX) + var/ankle = prob(50) + var/zone = ankle ? pick(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) : (istype(activity, /datum/prisoner_activity/basketball) ? BODY_ZONE_HEAD : BODY_ZONE_CHEST) + if(ankle) + manual_emote(pick("goes down clutching an ankle.", "lands badly and hops off the court.")) + else if(istype(activity, /datum/prisoner_activity/basketball)) + manual_emote("takes the ball to the face.") else - manual_emote(pick("goes down clutching an ankle.", "pulls something and sits down hard.", "grabs at [p_their()] back mid push-up.")) - adjustBruteLoss(damage) + manual_emote(pick("pulls something and sits down hard.", "grabs at [p_their()] back mid push-up.")) + apply_damage(damage, BRUTE, zone, wound_bonus = CANT_WOUND) + if(ankle) + var/obj/item/bodypart/leg = get_bodypart(zone) + if(leg) + var/datum/wound/blunt/bone/moderate/sprain = new + sprain.apply_wound(leg) end_activity() return TRUE -/mob/living/basic/outpost_prisoner/proc/set_hunger(amount) +/mob/living/carbon/human/npc/outpost_prisoner/proc/set_hunger(amount) hunger = clamp(amount, 0, 100) update_bubble() -/mob/living/basic/outpost_prisoner/proc/set_uniform_grime(amount) +/mob/living/carbon/human/npc/outpost_prisoner/proc/set_uniform_grime(amount) uniform_grime = clamp(amount, 0, 100) + uniform_grime_to_suit() update_bubble() +/// The prison jumpsuit they wear, if it is one of the outpost's (a stripped or redressed prisoner may have none) +/mob/living/carbon/human/npc/outpost_prisoner/proc/worn_prison_uniform() + var/obj/item/clothing/under/rank/prisoner/outpost/suit = w_uniform + return istype(suit) ? suit : null + +/// Their grime shows on the real jumpsuit they wear, which keeps it when it comes off +/mob/living/carbon/human/npc/outpost_prisoner/proc/uniform_grime_to_suit() + var/obj/item/clothing/under/rank/prisoner/outpost/suit = worn_prison_uniform() + if(suit && suit.grime != uniform_grime) + suit.set_grime(uniform_grime) + update_worn_undersuit() + /// Fed, 0-100: full marks until they are hungry, then down to nothing once they are starving -/mob/living/basic/outpost_prisoner/proc/fed_factor() +/mob/living/carbon/human/npc/outpost_prisoner/proc/fed_factor() return clamp(100 * (hunger - PRISONER_HUNGER_STARVING) / (PRISONER_HUNGER_HUNGRY - PRISONER_HUNGER_STARVING), 0, 100) /// Clean, 0-100: full marks until the uniform is dirty, then down to nothing once it is filthy -/mob/living/basic/outpost_prisoner/proc/clean_factor() +/mob/living/carbon/human/npc/outpost_prisoner/proc/clean_factor() return clamp(100 * (PRISONER_GRIME_FILTHY - uniform_grime) / (PRISONER_GRIME_FILTHY - PRISONER_GRIME_DIRTY), 0, 100) -/mob/living/basic/outpost_prisoner/proc/health_factor() +/mob/living/carbon/human/npc/outpost_prisoner/proc/health_factor() return stat == DEAD ? 0 : clamp(100 * health / maxHealth, 0, 100) /// Care, 0-100: the mean of fed, clean and health -/mob/living/basic/outpost_prisoner/proc/care() +/mob/living/carbon/human/npc/outpost_prisoner/proc/care() return (fed_factor() + clean_factor() + health_factor()) / 3 /// What their needs do to their mood per minute: list(gain, loss), before personality -/mob/living/basic/outpost_prisoner/proc/needs_mood_per_minute() +/mob/living/carbon/human/npc/outpost_prisoner/proc/needs_mood_per_minute() var/loss = 0 if(hunger < PRISONER_HUNGER_STARVING) loss += PRISONER_MOOD_STARVING @@ -501,21 +504,21 @@ loss += PRISONER_MOOD_HURT * (PRISONER_HURT_MOOD_BELOW - health_percent) / PRISONER_HURT_MOOD_BELOW return list(0, loss) -/mob/living/basic/outpost_prisoner/proc/wants_food() +/mob/living/carbon/human/npc/outpost_prisoner/proc/wants_food() return stat == CONSCIOUS && hunger < PRISONER_HUNGER_SEEK && well_fed_left <= 0 -/mob/living/basic/outpost_prisoner/proc/wants_clean_uniform() +/mob/living/carbon/human/npc/outpost_prisoner/proc/wants_clean_uniform() return stat == CONSCIOUS && uniform_grime >= PRISONER_GRIME_DIRTY /// Whether they would take this off the floor or the hatch and change into it -/mob/living/basic/outpost_prisoner/proc/would_change_into(obj/item/thing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/would_change_into(obj/item/thing) var/obj/item/clothing/under/rank/prisoner/outpost/fresh = thing return istype(fresh) && wants_clean_uniform() && fresh.grime < PRISONER_GRIME_DIRTY && fresh.grime < uniform_grime // ===== THOUGHT BUBBLE ===== /// The needs that want attention now, most urgent first: hungry, hurt, dirty -/mob/living/basic/outpost_prisoner/proc/bubble_needs() +/mob/living/carbon/human/npc/outpost_prisoner/proc/bubble_needs() var/list/needs = list() if(hunger < PRISONER_HUNGER_HUNGRY) needs += "hungry" @@ -530,7 +533,7 @@ * or loose (a shiv) and being an experiment's subject (a syringe) show on their own. Otherwise it * cycles through their needs every PRISONER_BUBBLE_CYCLE, most urgent first. */ -/mob/living/basic/outpost_prisoner/proc/wanted_bubble(list/needs) +/mob/living/carbon/human/npc/outpost_prisoner/proc/wanted_bubble(list/needs) if(stat == DEAD || phase != PRISONER_PRESENT) return null if(is_rioting() || trouble == PRISONER_TROUBLE_LOOSE) @@ -549,7 +552,7 @@ * starts at the most urgent. The bubble itself pops up now and then (see schedule_bubble()); a need that * has just come up, a riot or a dose pops it soon. */ -/mob/living/basic/outpost_prisoner/proc/update_bubble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/update_bubble() var/list/needs = bubble_needs() var/needs_text = jointext(needs, ",") var/fresh = FALSE @@ -576,7 +579,7 @@ * Pops the bubble when it is due, but not while what they last said is still up over their head * (hush_bubble()). One that no longer holds fades early, and a riot takes over at once. */ -/mob/living/basic/outpost_prisoner/proc/schedule_bubble(list/needs, fresh) +/mob/living/carbon/human/npc/outpost_prisoner/proc/schedule_bubble(list/needs, fresh) if(popped_bubble && popped_bubble != bubble && (!(popped_bubble in needs) || bubble == "riot")) fade_bubble() if(!bubble) @@ -587,11 +590,11 @@ pop_bubble() /// They said or emoted something: see hush_bubble() -/mob/living/basic/outpost_prisoner/proc/on_speech(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_speech(datum/source) SIGNAL_HANDLER hush_bubble() -/mob/living/basic/outpost_prisoner/manual_emote(text) +/mob/living/carbon/human/npc/outpost_prisoner/manual_emote(text) . = ..() if(.) hush_bubble() @@ -601,7 +604,7 @@ * sinks away out of the text's way, and none pops until the words are gone; then a need that * still holds comes back within PRISONER_BUBBLE_FRESH_DELAY. */ -/mob/living/basic/outpost_prisoner/proc/hush_bubble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/hush_bubble() COOLDOWN_START(src, bubble_hush, PRISONER_BUBBLE_HUSH) if(!popped_bubble) return @@ -609,7 +612,7 @@ bubble_next_pop = min(bubble_next_pop, world.time + PRISONER_BUBBLE_HUSH + rand(0, PRISONER_BUBBLE_FRESH_DELAY)) /// Pops the bubble up with a little bounce; it bobs, then fades on its own -/mob/living/basic/outpost_prisoner/proc/pop_bubble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/pop_bubble() if(!bubble || QDELETED(src)) return popped_bubble = bubble @@ -623,7 +626,7 @@ bubble_timer = addtimer(CALLBACK(src, PROC_REF(end_bubble)), PRISONER_BUBBLE_SHOW + PRISONER_BUBBLE_FADE, TIMER_STOPPABLE | TIMER_DELETE_ME) /// Fades the bubble out early, sinking with `sink` (see /obj/effect/abstract/outpost_thought/proc/fade()) -/mob/living/basic/outpost_prisoner/proc/fade_bubble(sink = FALSE) +/mob/living/carbon/human/npc/outpost_prisoner/proc/fade_bubble(sink = FALSE) popped_bubble = null if(!thought) return @@ -632,56 +635,22 @@ bubble_timer = addtimer(CALLBACK(src, PROC_REF(end_bubble)), PRISONER_BUBBLE_FADE, TIMER_STOPPABLE | TIMER_DELETE_ME) /// Takes the faded bubble down -/mob/living/basic/outpost_prisoner/proc/end_bubble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/end_bubble() bubble_timer = null popped_bubble = null if(thought) vis_contents -= thought /// Takes any bubble down at once, with no fade: for when they beam in or out -/mob/living/basic/outpost_prisoner/proc/drop_bubble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/drop_bubble() deltimer(bubble_timer) end_bubble() -/mob/living/basic/outpost_prisoner/update_overlays() +/mob/living/carbon/human/npc/outpost_prisoner/update_overlays() . = ..() - if(grime_stage) - var/mutable_appearance/grime = mutable_appearance('icons/effects/blood.dmi', "uniformblood") - grime.color = "#5a4630" - grime.alpha = grime_stage == 2 ? 200 : 130 - . += grime + // Their grime is on the jumpsuit itself (uniform_grime_to_suit()); filth draws flies. if(grime_stage == 2) . += mutable_appearance('icons/effects/effects.dmi', "fly-surrounding", ABOVE_MOB_LAYER) - if(cuffs) - // tg's own cuff overlay, as people wear it - . += mutable_appearance('icons/mob/simple/mob.dmi', "handcuff1") - if(held_item) - // In hand the way a player holds it, turning with them (npc_inhand_look() in bounty_ai.dm) - var/list/inhand = npc_inhand_look(held_item) - if(inhand) - var/mutable_appearance/carried = mutable_appearance(inhand[1], inhand[2]) - carried.plane = FLOAT_PLANE - carried.layer = FLOAT_LAYER - . += carried - else - // No in-hand sprite: the item itself, small, in the same hand whichever way they face - var/mutable_appearance/carried = new(held_item.appearance) - carried.plane = FLOAT_PLANE - carried.layer = FLOAT_LAYER - carried.dir = SOUTH - carried.pixel_x = 0 - carried.pixel_y = 0 - carried.pixel_w = ((dir & WEST) || dir == NORTH) ? -7 : 7 - carried.pixel_z = -4 - carried.transform = matrix().Scale(0.6) - . += carried - -/// Turning moves what they carry to the other side -/mob/living/basic/outpost_prisoner/setDir(newdir) - var/old_dir = dir - . = ..() - if(held_item && dir != old_dir) - update_appearance(UPDATE_OVERLAYS) /** * tg's thought bubble, as a point uses, with the needed item's own sprite inset. A prisoner's pops up @@ -775,7 +744,7 @@ * turn to it, lean in, and it visibly goes into their hand. With `announce`, the room is told what * they took and from where. Returns TRUE if they hold it. */ -/mob/living/basic/outpost_prisoner/proc/take_item(obj/item/thing, announce = FALSE) +/mob/living/carbon/human/npc/outpost_prisoner/proc/take_item(obj/item/thing, announce = FALSE) if(held_item || QDELETED(thing)) return FALSE if(thing.loc != src) @@ -793,15 +762,14 @@ var/obj/structure/table/reinforced/prison_hatch/hatch = locate() in from var/message = hatch ? "[src] takes [thing] off [hatch]." : "[src] picks up [thing]." visible_message(span_notice(message)) - thing.forceMove(src) - if(thing.loc != src) + // Into a real hand: tg draws it there, and anyone can knock it out of it. + if(!put_in_hands(thing)) return FALSE held_item = thing - update_appearance(UPDATE_OVERLAYS) return TRUE /// Leans towards `target` for a moment, as a hand goes out to it -/mob/living/basic/outpost_prisoner/proc/reach_animation(atom/target) +/mob/living/carbon/human/npc/outpost_prisoner/proc/reach_animation(atom/target) var/direction = get_dir(src, target) if(!direction) return @@ -811,18 +779,20 @@ animate(pixel_x = pixel_x - shift_x, pixel_y = pixel_y - shift_y, time = 2, easing = SINE_EASING, flags = ANIMATION_PARALLEL) /// Puts down whatever they carry, on `where` or their own tile -/mob/living/basic/outpost_prisoner/proc/drop_held_item(atom/where) +/mob/living/carbon/human/npc/outpost_prisoner/proc/drop_held_item(atom/where) if(!held_item) return null var/obj/item/dropped = held_item - dropped.forceMove(where || drop_location()) + if(!(where ? transferItemToLoc(dropped, where, force = TRUE) : dropItemToGround(dropped, force = TRUE))) + return null + held_item = null return dropped /** * Whether they can reach `thing` from where they stand. At a serving hatch they open their side * first: PRISONER_REACH_WAIT means it is opening. */ -/mob/living/basic/outpost_prisoner/proc/try_reach(obj/item/thing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/try_reach(obj/item/thing) if(QDELETED(thing) || !isturf(thing.loc)) return PRISONER_REACH_FAILED if(thing.loc == loc) @@ -840,7 +810,7 @@ * first time it is in reach they turn to it and hold out a hand (PRISONER_REACH_WAIT), and only on * the next call, about a second later, is it PRISONER_REACH_OK to take it. */ -/mob/living/basic/outpost_prisoner/proc/reach_for(obj/item/thing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/reach_for(obj/item/thing) var/result = try_reach(thing) if(result != PRISONER_REACH_OK) reaching_ref = null @@ -853,7 +823,7 @@ return PRISONER_REACH_WAIT /// Sits on a chair, stool or toilet, facing `facing` if given -/mob/living/basic/outpost_prisoner/proc/sit_on(obj/structure/seat, facing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/sit_on(obj/structure/seat, facing) if(buckled != seat) if(loc != seat.loc) return FALSE @@ -865,7 +835,7 @@ return TRUE /// Gets up from whatever they sit or lie on -/mob/living/basic/outpost_prisoner/proc/stand_up() +/mob/living/carbon/human/npc/outpost_prisoner/proc/stand_up() remove_offsets(PRISONER_SITTING_OFFSET) buckled?.unbuckle_mob(src, force = TRUE) @@ -873,7 +843,7 @@ * Settles on the bed under them. There is no sitting pose for a bed, and a standing sprite shifted down * reads as someone standing on it, so they lie on it, awake. With no bed to lie on, they crouch. */ -/mob/living/basic/outpost_prisoner/proc/sit_on_edge(facing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/sit_on_edge(facing) var/obj/structure/bed/bed = locate() in loc if(bed && buckled != bed) stand_up() @@ -884,7 +854,7 @@ setDir(facing) /// Whether they crouch where they are, as sit_on_edge() leaves them with no bed under them -/mob/living/basic/outpost_prisoner/proc/is_crouching() +/mob/living/carbon/human/npc/outpost_prisoner/proc/is_crouching() return !!has_offset(PRISONER_SITTING_OFFSET) /** @@ -893,7 +863,7 @@ * way it faces; `facing` is for the bed and the crouch. The bed is for lying down, so any sitting in * the cell goes to the chair first (home_seat()). */ -/mob/living/basic/outpost_prisoner/proc/sit_in_cell(facing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/sit_in_cell(facing) var/obj/structure/chair/chair = locate() in loc if(chair && (buckled == chair || !chair.has_buckled_mobs()) && sit_on(chair)) return @@ -903,7 +873,7 @@ * Where they sit down in their own cell: its chair if they can use it (seat_usable()), else its bed * on the same terms, lying on it. Null if the cell has neither they can use. */ -/mob/living/basic/outpost_prisoner/proc/home_seat() +/mob/living/carbon/human/npc/outpost_prisoner/proc/home_seat() if(!cell) return null var/obj/structure/chair/chair = cell.chair() @@ -915,7 +885,7 @@ return null /// Whether they can walk to `seat` and use it: nobody else on its tile or in it, and nobody else has claimed it -/mob/living/basic/outpost_prisoner/proc/seat_usable(obj/structure/seat) +/mob/living/carbon/human/npc/outpost_prisoner/proc/seat_usable(obj/structure/seat) var/turf/tile = get_turf(seat) if(!tile || !walkable?[tile] || (tile != loc && tile_taken(tile))) return FALSE @@ -949,11 +919,9 @@ return "snack" return "poor" -/mob/living/basic/outpost_prisoner/proc/on_pre_eat(datum/source, atom/food, mob/living/feeder) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_pre_eat(datum/source, atom/food, mob/living/feeder) SIGNAL_HANDLER - if(cuffs) - if(feeder) - balloon_alert(feeder, "cuffed") + if(handcuffed && !feeder) return COMSIG_MOB_CANCEL_EAT if(prison?.pastime_pre_eat(src, food, feeder)) return COMSIG_MOB_CANCEL_EAT @@ -967,7 +935,7 @@ * Eats `meal`: hunger and mood by how good it is, and cooked food keeps them full for * PRISONER_WELL_FED_TIME. Returns the tier. */ -/mob/living/basic/outpost_prisoner/proc/eat_food(obj/item/food/meal) +/mob/living/carbon/human/npc/outpost_prisoner/proc/eat_food(obj/item/food/meal) var/tier = outpost_prisoner_food_tier(meal) switch(tier) if("ration") @@ -985,7 +953,7 @@ return tier /// What they say about a meal: praise for cooking, a complaint about poor food, else thanks to whoever fed them -/mob/living/basic/outpost_prisoner/proc/react_to_food(tier, mob/living/feeder) +/mob/living/carbon/human/npc/outpost_prisoner/proc/react_to_food(tier, mob/living/feeder) switch(tier) if("cooked") if(thank("good_food") || !feeder) @@ -997,7 +965,7 @@ thank("thanks_food") /// Fed by hand -/mob/living/basic/outpost_prisoner/proc/on_ate(datum/source, atom/food, mob/living/feeder) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_ate(datum/source, atom/food, mob/living/feeder) SIGNAL_HANDLER var/tier = eat_food(food) if(isturf(loc) && prob(50)) @@ -1011,7 +979,7 @@ * `table_turf` is null when they ate standing up. Returns the wrapper when they mean to take it * to the bin, else null. */ -/mob/living/basic/outpost_prisoner/proc/finish_meal(obj/item/food/meal, turf/seat_turf, turf/table_turf) +/mob/living/carbon/human/npc/outpost_prisoner/proc/finish_meal(obj/item/food/meal, turf/seat_turf, turf/table_turf) if(QDELETED(meal)) return null var/tier = eat_food(meal) @@ -1030,7 +998,7 @@ * unhappy one (below PRISONER_LITTER_MOOD) drops it on the floor; anyone else leaves it where * they ate. */ -/mob/living/basic/outpost_prisoner/proc/leave_meal_mess(obj/item/food/meal, turf/floor, turf/table_turf) +/mob/living/carbon/human/npc/outpost_prisoner/proc/leave_meal_mess(obj/item/food/meal, turf/floor, turf/table_turf) if(isopenturf(floor) && (!table_turf || prob(PRISONER_TABLE_CRUMB_CHANCE))) new /obj/effect/decal/cleanable/food/crumbs(floor) var/trash_type @@ -1057,7 +1025,7 @@ return trash /// Puts `trash` in `bin` (or the nearest bin in reach) straight away. Returns TRUE if it went in. -/mob/living/basic/outpost_prisoner/proc/bin_litter(obj/item/trash, obj/structure/closet/crate/bin/bin) +/mob/living/carbon/human/npc/outpost_prisoner/proc/bin_litter(obj/item/trash, obj/structure/closet/crate/bin/bin) if(QDELETED(trash)) return FALSE bin = bin || prison?.find_bin(src) @@ -1085,7 +1053,7 @@ return count < bin.storage_capacity /// Someone fed, clothed or treated them by hand -/mob/living/basic/outpost_prisoner/proc/note_carer(mob/living/carer) +/mob/living/carbon/human/npc/outpost_prisoner/proc/note_carer(mob/living/carer) if(!istype(carer) || is_outpost_prisoner(carer)) return last_carer_ref = WEAKREF(carer) @@ -1094,11 +1062,9 @@ // ===== UNIFORMS ===== -/// Cuffs go on them (outpost_prison_capture.dm); handed a cleaner prison uniform, they change and hand the old one back -/mob/living/basic/outpost_prisoner/proc/on_item_interaction(datum/source, mob/living/user, obj/item/tool, list/modifiers) +/// Treated by hand, they notice; handed a cleaner prison uniform, they change and hand the old one back. Cuffs are tg's own (outpost_prison_capture.dm). +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_item_interaction(datum/source, mob/living/user, obj/item/tool, list/modifiers) SIGNAL_HANDLER - if(istype(tool, /obj/item/restraints/handcuffs)) - return on_cuffs_used(user, tool) if(istype(tool, /obj/item/stack/medical)) // Treatment only shows as health coming back once the dressing is on. COOLDOWN_START(src, treatment_window, 20 SECONDS) @@ -1108,7 +1074,7 @@ // Not while they beam in or out, invisible or half there. if(!istype(offered) || user.combat_mode || stat != CONSCIOUS || phase != PRISONER_PRESENT) return NONE - if(cuffs) + if(handcuffed) balloon_alert(user, "cuffed") return ITEM_INTERACT_BLOCKING if(offered.grime >= uniform_grime) @@ -1128,7 +1094,7 @@ * Picks up a cleaner uniform within arm's reach (their own tile or beside them) and changes, leaving * the old one in its place. They turn to it, lean in, and it visibly leaves the floor or the hatch. */ -/mob/living/basic/outpost_prisoner/proc/take_uniform(obj/item/clothing/under/rank/prisoner/outpost/fresh) +/mob/living/carbon/human/npc/outpost_prisoner/proc/take_uniform(obj/item/clothing/under/rank/prisoner/outpost/fresh) if(!would_change_into(fresh) || !isturf(fresh.loc) || !(fresh.loc == loc || Adjacent(fresh))) return FALSE var/turf/spot = fresh.loc @@ -1143,32 +1109,38 @@ return TRUE /** - * Changes into `fresh` and returns their old uniform, created at `drop_spot`. The clean one goes - * before the old one appears, so a swap on a full serving hatch never pushes it over capacity. + * Changes into `fresh`, the way anyone changes: the jumpsuit they wear comes off onto `drop_spot` + * and the clean one goes on. Returns the old one (null if what they wore wasn't a prison jumpsuit). */ -/mob/living/basic/outpost_prisoner/proc/swap_uniform(obj/item/clothing/under/rank/prisoner/outpost/fresh, atom/drop_spot) +/mob/living/carbon/human/npc/outpost_prisoner/proc/swap_uniform(obj/item/clothing/under/rank/prisoner/outpost/fresh, atom/drop_spot) var/fresh_grime = fresh.grime - var/old_grime = uniform_grime - qdel(fresh) - var/obj/item/clothing/under/rank/prisoner/outpost/old = new(drop_spot) - old.set_grime(old_grime) + // Off the counter before the old one goes down, so a swap on a full serving hatch never pushes it over capacity. + fresh.forceMove(src) + var/obj/item/clothing/under/rank/prisoner/outpost/old = worn_prison_uniform() + if(old) + old.set_grime(uniform_grime) + if(!transferItemToLoc(old, drop_spot, force = TRUE)) + return null + else if(w_uniform) + // Someone else's clothes: off they come, as they are + var/obj/item/other_clothes = w_uniform + if(!transferItemToLoc(other_clothes, drop_spot, force = TRUE)) + return null + if(!equip_to_slot_if_possible(fresh, ITEM_SLOT_ICLOTHING, disable_warning = TRUE)) + fresh.forceMove(drop_spot) set_uniform_grime(fresh_grime) adjust_mood(PRISONER_MOOD_CLEAN_UNIFORM) return old // ===== HURT AND TREATED ===== -/// Blood on the floor from any real brute hit -/mob/living/basic/outpost_prisoner/proc/on_damaged(datum/source, damage_dealt, damagetype) +/// A real hit that puts them down is a collapse (outpost_prison_trouble.dm). tg does the blood. +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_damaged(datum/source, damage_dealt, damagetype) SIGNAL_HANDLER - if((damagetype == BRUTE || damagetype == BURN) && damage_dealt > 0) - // Low enough and they collapse (outpost_prison_trouble.dm). + if(damage_dealt > 0 && (damagetype == BRUTE || damagetype == BURN || damagetype == STAMINA)) INVOKE_ASYNC(src, PROC_REF(check_beaten)) - if(damagetype != BRUTE || damage_dealt < 3 || !isturf(loc)) - return - add_splatter_floor(loc, damage_dealt < 10) -/mob/living/basic/outpost_prisoner/proc/on_health_update(datum/source) +/mob/living/carbon/human/npc/outpost_prisoner/proc/on_health_update(datum/source) SIGNAL_HANDLER var/was = last_health last_health = health @@ -1178,15 +1150,6 @@ adjust_mood(PRISONER_MOOD_TREATED) INVOKE_ASYNC(src, PROC_REF(thank), "thanks_treatment") -/// Badly hurt and untreated: now and then a drop of blood -/mob/living/basic/outpost_prisoner/proc/maybe_drip(seconds) - if(stat == DEAD || !isturf(loc) || health_factor() >= PRISONER_BLEED_BELOW) - return FALSE - if(!SPT_PROB(3, seconds)) - return FALSE - add_splatter_floor(loc, small_drip = TRUE) - return TRUE - // ===== LEFT ALONE ===== /** @@ -1197,7 +1160,7 @@ * moments tg switches the AI off after a plan that queued nothing (ai_running()), they walk over * and take things by hand instead. */ -/mob/living/basic/outpost_prisoner/proc/fend_for_self() +/mob/living/carbon/human/npc/outpost_prisoner/proc/fend_for_self() if(!routine_allowed() || ai_running()) return if(wants_food()) @@ -1213,7 +1176,7 @@ swap_uniform(fresh, fresh.loc) /// Leaves some dirt or a wrapper where they stand -/mob/living/basic/outpost_prisoner/proc/make_mess() +/mob/living/carbon/human/npc/outpost_prisoner/proc/make_mess() var/turf/open/floor = loc if(!istype(floor)) return @@ -1223,13 +1186,12 @@ var/trash_type = pick(/obj/item/trash/candy, /obj/item/trash/chips, /obj/item/trash/raisins) new trash_type(floor) -/mob/living/basic/outpost_prisoner/death(gibbed) +/mob/living/carbon/human/npc/outpost_prisoner/death(gibbed) var/was_alive = stat != DEAD . = ..() + // The body keeps what it held and wore, cuffs too, as anyone's does. if(was_alive && stat == DEAD) end_activity() - drop_held_item() - remove_cuffs() prison?.on_prisoner_death(src) update_bubble() @@ -1237,7 +1199,7 @@ /datum/outfit/outpost_prisoner name = "Outpost prisoner" - uniform = /obj/item/clothing/under/rank/prisoner + uniform = /obj/item/clothing/under/rank/prisoner/outpost shoes = /obj/item/clothing/shoes/sneakers/orange /datum/outfit/outpost_prisoner/glasses @@ -1250,3 +1212,4 @@ #undef PRISONER_BEAM_TRAIT #undef PRISONER_SITTING_OFFSET +#undef PRISONER_AWAKE_TRAIT diff --git a/voidcrew/modules/player_outposts/outpost_prison_researcher.dm b/voidcrew/modules/player_outposts/outpost_prison_researcher.dm index 163c4718879..72db8378cb2 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_researcher.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_researcher.dm @@ -155,7 +155,7 @@ if(!spot) return null roll_offer() - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = new(spot, src) + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = new(spot, src) researcher = doctor researcher_stay = 0 pitch_left = OUTPOST_KESSLER_BEAM_TIME / (1 SECONDS) + 2 @@ -184,7 +184,7 @@ * checked again once it returns. Returns what they got: the item, TRUE for a decline, or FALSE. */ /datum/outpost_prison/proc/present_offer(mob/living/user) - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = researcher if(QDELETED(doctor) || doctor.leaving || doctor.beaming || !outpost?.can_manage(user)) return FALSE var/mob/holder = offer_claim?.resolve() @@ -217,7 +217,7 @@ * to 0, and the researcher leaves. Refused if the wing could not run it now. Returns the item. */ /datum/outpost_prison/proc/accept_offer(mob/living/user) - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = researcher if(QDELETED(doctor) || doctor.leaving || !outpost?.can_manage(user)) return FALSE var/needed = offer_kind == "specimen" ? 2 : 1 @@ -243,7 +243,7 @@ /// `user` turns the offer down: the next one pays more, and the researcher leaves /datum/outpost_prison/proc/decline_offer(mob/living/user) - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = researcher if(QDELETED(doctor) || doctor.leaving) return FALSE sweetener = min(sweetener + OUTPOST_EXPERIMENT_SWEETENER, OUTPOST_EXPERIMENT_SWEETENER_MAX) @@ -255,7 +255,7 @@ /// Nobody took the offer in time: the researcher leaves, and the next offer pays more /datum/outpost_prison/proc/researcher_lapse() - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = researcher if(QDELETED(doctor)) researcher = null return FALSE @@ -265,21 +265,37 @@ send_researcher_away(doctor) return TRUE -/// Someone hurt the researcher: gone at once, with no sweetener for the next visit +/** + * Someone hurt the researcher: "I'm leaving!", and the corporate beam takes them a couple of seconds + * later (OUTPOST_KESSLER_ASSAULT_BEAM_DELAY), with no sweetener for the next visit. The hit was real; + * the beam is only a way out. + */ /datum/outpost_prison/proc/researcher_assaulted() - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = researcher + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = researcher if(QDELETED(doctor)) return FALSE // Hit while still beaming in: they just go, without a word from thin air. - if(!doctor.beaming && !doctor.departing) + if(!doctor.beaming && !doctor.departing && doctor.stat == CONSCIOUS) doctor.say("I'm leaving!") add_log("[doctor.real_name] was attacked and left.") log_game("PLAYER OUTPOST PRISON: the Kessler researcher at '[outpost?.name]' was attacked and left") - send_researcher_away(doctor, 0) + send_researcher_away(doctor, OUTPOST_KESSLER_ASSAULT_BEAM_DELAY) return TRUE +/** + * The researcher was killed before Kessler's beam got them out. Kessler takes the body back, and + * doesn't come again for a long while (OUTPOST_KESSLER_KILLED_WAIT_MIN to _MAX). + */ +/datum/outpost_prison/proc/researcher_killed(mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor, mob/killer) + add_log("[doctor.real_name] of Kessler Biolabs was killed. Kessler wants a word about that.") + log_game("PLAYER OUTPOST PRISON: the Kessler researcher at '[outpost?.name]' was killed[killer ? " (last hit by [key_name(killer)])" : ""]") + if(researcher == doctor) + researcher = null + offer_claim = null + researcher_wait = max(researcher_wait || 0, rand(OUTPOST_KESSLER_KILLED_WAIT_MIN, OUTPOST_KESSLER_KILLED_WAIT_MAX)) + /// The researcher leaves after `delay`; the next visit is 35-50 minutes off -/datum/outpost_prison/proc/send_researcher_away(mob/living/basic/outpost_kessler_staff/researcher/doctor, delay = 2 SECONDS) +/datum/outpost_prison/proc/send_researcher_away(mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor, delay = 2 SECONDS) researcher = null offer_claim = null researcher_wait = rand(OUTPOST_EXPERIMENT_GAP_MIN, OUTPOST_EXPERIMENT_GAP_MAX) @@ -291,39 +307,28 @@ if(!spot) return null // Not the wing's researcher: nobody can take an offer from them, and they hold no reference to the prison. - var/mob/living/basic/outpost_kessler_staff/researcher/doctor = new(spot, null) + var/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/doctor = new(spot, null) doctor.beam_in() // A second after they are all the way in, not while they are still knitting together. - addtimer(CALLBACK(doctor, TYPE_PROC_REF(/mob/living/basic/outpost_kessler_staff/researcher, refuse_wing)), OUTPOST_KESSLER_BEAM_TIME + 1 SECONDS, TIMER_DELETE_ME) + addtimer(CALLBACK(doctor, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_kessler_staff/researcher, refuse_wing)), OUTPOST_KESSLER_BEAM_TIME + 1 SECONDS, TIMER_DELETE_ME) add_log("[doctor.real_name] of Kessler Biolabs came, but would not work in the wing as it is.") return doctor // ===== KESSLER STAFF ===== -/// Someone from Kessler Biolabs: beams in and out, cannot be hurt, boxed, teleported or taken over -/mob/living/basic/outpost_kessler_staff +/** + * Someone from Kessler Biolabs, a real human body (owner ruling OC-3): they can be hurt, bled, cuffed + * and killed like anyone. The first real hit sends them away: "I'm leaving!" and Kessler's beam a + * couple of seconds later. Teleporters, polymorph and bluespace bags don't take them while they are + * alive. Their kit is locked to them (D3). They have no AI: they stand where they beamed in. + */ +/mob/living/carbon/human/npc/outpost_kessler_staff name = "Kessler Biolabs employee" desc = "Someone from Kessler Biolabs." - icon = 'icons/mob/simple/simple_human.dmi' - mob_biotypes = MOB_ORGANIC | MOB_HUMANOID - sentience_type = SENTIENCE_HUMANOID - basic_mob_flags = NONE - status_flags = NONE - move_resist = INFINITY - // tg's human deathgasp - death_message = "seizes up and falls limp, their eyes dead and lifeless..." - density = TRUE - maxHealth = 100 - health = 100 - damage_coeff = list(BRUTE = 1, BURN = 1, TOX = 0, STAMINA = 0, OXY = 0) - unsuitable_atmos_damage = 0 - unsuitable_cold_damage = 0 - unsuitable_heat_damage = 0 - speak_emote = list("says") - response_help_continuous = "taps" - response_help_simple = "tap" - /// What they wear - var/outfit = /datum/outfit/outpost_kessler_researcher + faction = list(FACTION_NEUTRAL) + ai_controller = null + npc_outfit = /datum/outfit/outpost_kessler_researcher + npc_target_stat = HUMAN_NPC_SPARES_DOWNED /// The prison they came to var/datum/outpost_prison/prison /// On the way out @@ -335,52 +340,78 @@ /// A line of dialogue to say once they have fully materialised, if any var/arrival_line -/mob/living/basic/outpost_kessler_staff/Initialize(mapload, datum/outpost_prison/owner) - gender = pick(MALE, FEMALE) - . = ..() - prison = owner - ADD_TRAIT(src, TRAIT_NO_CONTAINMENT, INNATE_TRAIT) - ADD_TRAIT(src, TRAIT_NO_STORAGE_INSERT, INNATE_TRAIT) +/** + * The second argument is the prison they come to (or null for a visit that isn't the wing's), never a + * human NPC spawn order. Made one at a time as they visit, so they don't go through the spawn queue. + */ +/mob/living/carbon/human/npc/outpost_kessler_staff/Initialize(mapload, datum/outpost_prison/owner) + . = ..(mapload) + prison = istype(owner) ? owner : null + ADD_TRAIT(src, TRAIT_NO_CONTAINMENT, OUTPOST_KESSLER_TRAIT) + ADD_TRAIT(src, TRAIT_NO_STORAGE_INSERT, OUTPOST_KESSLER_TRAIT) ADD_TRAIT(src, TRAIT_OUTPOST_EXPERIMENT, INNATE_TRAIT) - ADD_TRAIT(src, TRAIT_PUSHIMMUNE, INNATE_TRAIT) - AddElement(/datum/element/footstep, footstep_type = FOOTSTEP_MOB_SHOE) + AddElement(/datum/element/relay_attackers) + RegisterSignal(src, COMSIG_ATOM_WAS_ATTACKED, PROC_REF(on_attacked)) + RegisterSignal(src, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_damaged)) RegisterSignal(src, COMSIG_MOVABLE_TELEPORTING, PROC_REF(refuse_teleport)) RegisterSignal(src, COMSIG_LIVING_PRE_WABBAJACKED, PROC_REF(refuse_polymorph)) RegisterSignal(src, COMSIG_PRE_MOB_CHANGED_TYPE, PROC_REF(refuse_type_change)) - apply_dynamic_human_appearance(src, outfit, /datum/species/human) -/mob/living/basic/outpost_kessler_staff/Destroy() +/mob/living/carbon/human/npc/outpost_kessler_staff/Destroy() if(prison?.researcher == src) prison.researcher = null prison.offer_claim = null prison = null return ..() -/// Nothing Kessler sends can be hurt: a blow makes them leave instead -/mob/living/basic/outpost_kessler_staff/adjust_health(amount, updating_health = TRUE, forced = FALSE) - if(amount > 0 && !forced) +/// A real blow from someone: they leave +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/on_attacked(datum/source, atom/attacker, attack_flags) + SIGNAL_HANDLER + if(attack_flags & (ATTACKER_DAMAGING_ATTACK | ATTACKER_STAMINA_ATTACK)) + INVOKE_ASYNC(src, PROC_REF(hurt_by_someone)) + +/// Hurt any other way (a fire, a blast, a stray shot): they leave too +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/on_damaged(datum/source, damage_dealt, damagetype) + SIGNAL_HANDLER + if(damage_dealt > 0 && damagetype != OXY) INVOKE_ASYNC(src, PROC_REF(hurt_by_someone)) - return 0 - return ..() -/mob/living/basic/outpost_kessler_staff/proc/hurt_by_someone() - leave() +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/hurt_by_someone() + leave(OUTPOST_KESSLER_ASSAULT_BEAM_DELAY) -/mob/living/basic/outpost_kessler_staff/proc/refuse_teleport(datum/source, atom/destination, channel) +/mob/living/carbon/human/npc/outpost_kessler_staff/death(gibbed) + var/datum/outpost_prison/visited = prison + var/was_alive = stat != DEAD + . = ..() + if(!was_alive || stat != DEAD) + return + REMOVE_TRAIT(src, TRAIT_NO_CONTAINMENT, OUTPOST_KESSLER_TRAIT) + REMOVE_TRAIT(src, TRAIT_NO_STORAGE_INSERT, OUTPOST_KESSLER_TRAIT) + killed_on_visit(visited) + // Kessler takes its own people back, alive or not. + leave(OUTPOST_KESSLER_ASSAULT_BEAM_DELAY) + +/// Killed while visiting `visited` +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/killed_on_visit(datum/outpost_prison/visited) + return + +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/refuse_teleport(datum/source, atom/destination, channel) SIGNAL_HANDLER + if(stat == DEAD) + return FALSE return TRUE -/mob/living/basic/outpost_kessler_staff/proc/refuse_polymorph(datum/source, what_to_randomize) +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/refuse_polymorph(datum/source, what_to_randomize) SIGNAL_HANDLER return STOP_WABBAJACK -/mob/living/basic/outpost_kessler_staff/proc/refuse_type_change(datum/source) +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/refuse_type_change(datum/source) SIGNAL_HANDLER return COMPONENT_BLOCK_MOB_CHANGE -/// Says a line of `context` from the dialogue file. Never while beaming in or out, invisible or half there. -/mob/living/basic/outpost_kessler_staff/proc/say_line(context) - if(beaming || departing) +/// Says a line of `context` from the dialogue file. Never while beaming in or out, invisible or half there, or unable to talk. +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/say_line(context) + if(beaming || departing || !human_npc_can_talk(src)) return null var/line = outpost_experiment_line(context) if(line) @@ -391,7 +422,7 @@ * Materialises where they stand: they knit together inside the transporter's column over the * whole beam. They stay `beaming` (held still, silent) until finish_beam_in() as the beam ends. */ -/mob/living/basic/outpost_kessler_staff/proc/beam_in() +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/beam_in() beaming = TRUE ADD_TRAIT(src, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT) var/turf/spot = get_turf(src) @@ -404,7 +435,7 @@ addtimer(CALLBACK(src, PROC_REF(finish_beam_in)), OUTPOST_KESSLER_BEAM_TIME, TIMER_DELETE_ME) /// Fully there as the beam ends: the flash, and only now do they talk -/mob/living/basic/outpost_kessler_staff/proc/finish_beam_in() +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/finish_beam_in() if(!beaming || departing) return var/turf/spot = get_turf(src) @@ -420,7 +451,7 @@ say_line(arrival_line) /// Leaves after `delay`: beams out and is gone -/mob/living/basic/outpost_kessler_staff/proc/leave(delay = 0) +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/leave(delay = 0) if(leaving) return leaving = TRUE @@ -433,9 +464,13 @@ else beam_out() -/mob/living/basic/outpost_kessler_staff/proc/beam_out() +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/beam_out() + if(departing || QDELETED(src)) + return beaming = FALSE departing = TRUE + pulledby?.stop_pulling() + buckled?.unbuckle_mob(src, force = TRUE) ADD_TRAIT(src, TRAIT_IMMOBILIZED, OUTPOST_KESSLER_TRAIT) var/turf/spot = get_turf(src) if(spot) @@ -444,7 +479,7 @@ transporter_dematerialise(src, OUTPOST_KESSLER_BEAM_TIME) addtimer(CALLBACK(src, PROC_REF(finish_beam_out)), OUTPOST_KESSLER_BEAM_TIME, TIMER_DELETE_ME) -/mob/living/basic/outpost_kessler_staff/proc/finish_beam_out() +/mob/living/carbon/human/npc/outpost_kessler_staff/proc/finish_beam_out() var/turf/spot = get_turf(src) if(spot) new /obj/effect/temp_visual/transporter_flash/departure(spot) @@ -454,34 +489,36 @@ // ----- the researcher ----- -/mob/living/basic/outpost_kessler_staff/researcher +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher name = "researcher" desc = "A researcher from Kessler Biolabs, with a clipboard and a labcoat that has never seen a stain." - outfit = /datum/outfit/outpost_kessler_researcher + npc_outfit = /datum/outfit/outpost_kessler_researcher COOLDOWN_DECLARE(brush_off_cooldown) -/mob/living/basic/outpost_kessler_staff/researcher/Initialize(mapload, datum/outpost_prison/owner) +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/Initialize(mapload, datum/outpost_prison/owner) . = ..() - real_name = "Dr. [pick(GLOB.last_names)]" - name = real_name + fully_replace_character_name(real_name, "Dr. [pick(GLOB.last_names)]") RegisterSignal(src, COMSIG_ATOM_ATTACK_HAND, PROC_REF(on_hand)) -/mob/living/basic/outpost_kessler_staff/researcher/hurt_by_someone() - if(prison?.researcher == src) +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/hurt_by_someone() + if(prison?.researcher == src && !leaving) prison.researcher_assaulted() return - leave() + leave(OUTPOST_KESSLER_ASSAULT_BEAM_DELAY) + +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/killed_on_visit(datum/outpost_prison/visited) + visited?.researcher_killed(src, lastattackerckey ? get_mob_by_ckey(lastattackerckey) : null) -/// An empty hand, not in combat mode: a word with the researcher instead of a pat -/mob/living/basic/outpost_kessler_staff/researcher/proc/on_hand(datum/source, mob/living/user, list/modifiers) +/// An empty hand, not in combat mode: a word with the researcher instead of a pat. Down or out cold, tg's own help. +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/proc/on_hand(datum/source, mob/living/user, list/modifiers) SIGNAL_HANDLER - if(!istype(user) || user.combat_mode || LAZYACCESS(modifiers, RIGHT_CLICK)) + if(!istype(user) || user.combat_mode || LAZYACCESS(modifiers, RIGHT_CLICK) || stat != CONSCIOUS || on_fire) return NONE INVOKE_ASYNC(src, PROC_REF(talk_to), user) return COMPONENT_CANCEL_ATTACK_CHAIN /// Managers get the offer; everyone else gets told who the researcher deals with -/mob/living/basic/outpost_kessler_staff/researcher/proc/talk_to(mob/living/user) +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/proc/talk_to(mob/living/user) if(leaving || beaming || !prison || prison.researcher != src) return FALSE face_atom(user) @@ -493,17 +530,17 @@ return prison.present_offer(user) /// The offer card as a prompt: "Accept", "Decline", or null if it was closed -/mob/living/basic/outpost_kessler_staff/researcher/proc/ask_offer(mob/living/user, text, timeout) +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/proc/ask_offer(mob/living/user, text, timeout) return tgui_alert(user, text, "[real_name], Kessler Biolabs", list("Accept", "Decline"), timeout) /// A pitch to whoever is about, pushier after declines -/mob/living/basic/outpost_kessler_staff/researcher/proc/pitch(sweetened = FALSE) +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/proc/pitch(sweetened = FALSE) if(leaving || beaming) return null return say_line(sweetened ? "researcher_sweetened" : "researcher_offer") /// A short visit to a wing in no state for an experiment -/mob/living/basic/outpost_kessler_staff/researcher/proc/refuse_wing() +/mob/living/carbon/human/npc/outpost_kessler_staff/researcher/proc/refuse_wing() if(QDELETED(src) || leaving) return say_line("researcher_pigsty") @@ -511,15 +548,30 @@ // ----- the recovery team ----- -/mob/living/basic/outpost_kessler_staff/agent +/mob/living/carbon/human/npc/outpost_kessler_staff/agent name = "Kessler recovery agent" desc = "A Kessler Biolabs recovery agent in a hazmat suit, carrying a tranquilliser gun." - outfit = /datum/outfit/outpost_kessler_agent + npc_outfit = /datum/outfit/outpost_kessler_agent + +/// Fires the real chloral hydrate dart in its syringe gun at `target`, the way anyone fires one. Returns TRUE if it fired. +/mob/living/carbon/human/npc/outpost_kessler_staff/agent/proc/dart(mob/living/target) + if(QDELETED(target) || stat != CONSCIOUS || departing) + return FALSE + var/obj/item/gun/syringe/gun = locate() in held_items + if(!gun || !length(gun.syringes) || !human_npc_ready_item(src, gun)) + return FALSE + face_atom(target) + var/old_combat_mode = combat_mode + set_combat_mode(TRUE, silent = TRUE) + ClickOn(target) + set_combat_mode(old_combat_mode, silent = TRUE) + return TRUE /** * Two agents beam in around `creature`, one says a line of `context` once they are all the way in, - * and they all beam out after OUTPOST_KESSLER_TEAM_TIME. A creature still on its feet is - * `tranquilise`d; one already down is simply collected. + * and they all beam out after OUTPOST_KESSLER_TEAM_TIME. With `tranquilise`, the first agent fires the + * real chloral hydrate dart in its syringe gun at the creature once it is all the way in, to keep it + * down while they work (owner ruling OC-3: they collect only what is already down). */ /proc/outpost_kessler_team(mob/living/creature, context = "kessler_recovery", tranquilise = TRUE) var/turf/center = get_turf(creature) @@ -529,9 +581,9 @@ for(var/turf/open/tile in orange(1, center)) if(!tile.is_blocked_turf(TRUE) && !(locate(/mob/living) in tile)) spots += tile - var/mob/living/basic/outpost_kessler_staff/agent/speaker + var/mob/living/carbon/human/npc/outpost_kessler_staff/agent/speaker for(var/i in 1 to min(2, length(spots))) - var/mob/living/basic/outpost_kessler_staff/agent/agent = new(pick_n_take(spots), null) + var/mob/living/carbon/human/npc/outpost_kessler_staff/agent/agent = new(pick_n_take(spots), null) agent.face_atom(creature) agent.beam_in() agent.leave(OUTPOST_KESSLER_TEAM_TIME) @@ -543,6 +595,8 @@ if(tranquilise) creature.visible_message(span_warning("Kessler Biolabs agents beam in around [creature], and a tranquilliser dart drops [creature.p_them()].")) playsound(center, 'sound/items/syringeproj.ogg', 50, TRUE) + if(speaker) + addtimer(CALLBACK(speaker, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_kessler_staff/agent, dart), creature), OUTPOST_KESSLER_BEAM_TIME + 0.2 SECONDS, TIMER_DELETE_ME) else creature.visible_message(span_notice("Kessler Biolabs agents beam in to collect [creature].")) @@ -578,3 +632,16 @@ gloves = /obj/item/clothing/gloves/latex shoes = /obj/item/clothing/shoes/sneakers/white r_hand = /obj/item/gun/syringe + +/datum/outfit/outpost_kessler_agent/post_equip(mob/living/carbon/human/agent, visuals_only = FALSE) + . = ..() + if(visuals_only) + return + var/obj/item/gun/syringe/gun = locate() in agent.held_items + if(!gun) + return + var/obj/item/reagent_containers/syringe/dart = new(gun) + dart.reagents.add_reagent(/datum/reagent/toxin/chloralhydrate, dart.volume) + dart.name = "tranquilliser syringe" + gun.syringes += dart + gun.update_appearance() diff --git a/voidcrew/modules/player_outposts/outpost_prison_riot.dm b/voidcrew/modules/player_outposts/outpost_prison_riot.dm index c531de4ec84..9e86f58b48a 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_riot.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_riot.dm @@ -140,10 +140,10 @@ * mean mood, so a neglected wing sours its newcomers. `newcomer` is left out of the mean, as is * anyone who has not served a moment yet. */ -/datum/outpost_prison/proc/arrival_mood(mob/living/basic/outpost_prisoner/newcomer) +/datum/outpost_prison/proc/arrival_mood(mob/living/carbon/human/npc/outpost_prisoner/newcomer) var/total = 0 var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner == newcomer || prisoner.served_seconds <= 0 || !counts_for_tension(prisoner)) continue total += prisoner.mood @@ -159,7 +159,7 @@ * PRISONER_LOCKED_IN_RECOVERY seconds a second, so letting them out for a moment does not reset it. * A rioter just shut in a cell is in custody, and owes lockdown for it (outpost_prison_capture.dm). */ -/datum/outpost_prison/proc/update_locked_in(mob/living/basic/outpost_prisoner/prisoner, seconds) +/datum/outpost_prison/proc/update_locked_in(mob/living/carbon/human/npc/outpost_prisoner/prisoner, seconds) var/confined = prisoner.is_confined() if(confined) if(!prisoner.was_confined && prisoner.is_rioting()) @@ -181,7 +181,7 @@ return riot_active || runners_at_large() > 0 /// A prisoner is let out after a long lock-in: some relief, and they say so -/datum/outpost_prison/proc/on_unbolted(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/on_unbolted(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || prisoner.stat != CONSCIOUS || prisoner.phase != PRISONER_PRESENT || prisoner.trouble) return FALSE prisoner.adjust_mood(PRISONER_MOOD_UNBOLTED) @@ -209,7 +209,7 @@ * Staff killed a prisoner. No fine: the cost is the worst spark there is, the pay and bonus they * forfeit, and a body to deal with (body_tick()). */ -/datum/outpost_prison/proc/blame_death(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/blame_death(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner.death_blamed) return FALSE prisoner.death_blamed = TRUE @@ -219,14 +219,14 @@ return trouble_event(PRISON_SPIKE_KILLED, "[prisoner.real_name] was killed by staff") /// Whether a prisoner's mood counts toward the wing's tension: present, alive and not loose -/datum/outpost_prison/proc/counts_for_tension(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/counts_for_tension(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && prisoner.trouble != PRISONER_TROUBLE_LOOSE /// 100 minus the mean mood of the prisoners in the cell block, plus the spike and any shivs hidden in the wing /datum/outpost_prison/proc/compute_tension() var/total = 0 var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!counts_for_tension(prisoner)) continue total += prisoner.mood @@ -289,7 +289,7 @@ var/hurt = 0 var/locked = 0 var/cuffed = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!counts_for_tension(prisoner)) continue if(prisoner.hunger < PRISONER_HUNGER_HUNGRY) @@ -338,7 +338,7 @@ add_log("A riot is brewing in the yard.") announce("Prison wing: a riot is brewing.", SHIP_NOTIFY_DANGER) var/shouts = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!prisoner.routine_allowed() || cell_at(get_turf(prisoner))) continue // Leisure makes way, so the gathering (three times as likely now) gets picked. @@ -355,7 +355,7 @@ /datum/outpost_prison/proc/trouble_tick(seconds) tension_spike = max(0, tension_spike - PRISON_SPIKE_DECAY * seconds) subdued_left = max(0, subdued_left - seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD) continue prisoner.trouble_counters(seconds) @@ -373,7 +373,7 @@ check_incident_over() /// A prisoner's own trouble timers -/mob/living/basic/outpost_prisoner/proc/trouble_counters(seconds) +/mob/living/carbon/human/npc/outpost_prisoner/proc/trouble_counters(seconds) threat_cooldown = max(0, threat_cooldown - seconds) fight_cooldown = max(0, fight_cooldown - seconds) if(swing_ref) @@ -382,10 +382,8 @@ if(swing_left <= 0 || !target || target.stat == DEAD) swing_ref = null swing_left = 0 - if(beaten_left > 0) - beaten_left -= seconds - if(beaten_left <= 0 || health_factor() > PRISONER_RECOVER_ABOVE) - recover() + // Down until they are up again, by tg's own clock (crit, stamina, the knockdown), or a yield's lie-down by choice + beaten_tick() if(climb_ref) climb_tick(seconds) @@ -396,7 +394,7 @@ * their AI runs, and never at someone who just helped them. A talk with staff holds the threat. */ /datum/outpost_prison/proc/threats_tick(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) // Someone in trouble keeps a swing only while hitting back at a player (outpost_prison_trouble.dm). // Running from a creature, staff are the least of their worries (outpost_prison_panic.dm). if(prisoner.phase != PRISONER_PRESENT || (prisoner.trouble && !prisoner.retaliating()) || !prisoner.trouble_can_act() || !prisoner.ai_running() || !in_cell_block(prisoner) || prisoner.is_panicking()) @@ -427,8 +425,8 @@ /// Two prisoners squaring off: an argument, then blows until one yields /datum/outpost_prison_fight var/datum/outpost_prison/prison - var/mob/living/basic/outpost_prisoner/first - var/mob/living/basic/outpost_prisoner/second + var/mob/living/carbon/human/npc/outpost_prisoner/first + var/mob/living/carbon/human/npc/outpost_prisoner/second /// What it is about, for the argument's lines: "food", "ball", "bed" or "none" var/cause = "none" /// Past the argument: blows are being thrown @@ -440,7 +438,7 @@ /// Seconds it has lasted var/elapsed = 0 -/datum/outpost_prison_fight/New(datum/outpost_prison/owner, mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two, cause = "none") +/datum/outpost_prison_fight/New(datum/outpost_prison/owner, mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two, cause = "none") . = ..() prison = owner first = one @@ -457,7 +455,7 @@ prison = null return ..() -/datum/outpost_prison_fight/proc/opponent_of(mob/living/basic/outpost_prisoner/fighter) +/datum/outpost_prison_fight/proc/opponent_of(mob/living/carbon/human/npc/outpost_prisoner/fighter) if(fighter == first) return second if(fighter == second) @@ -465,7 +463,7 @@ return null /// Still up for it: awake, on their feet, in this fight -/datum/outpost_prison_fight/proc/fighter_ok(mob/living/basic/outpost_prisoner/fighter) +/datum/outpost_prison_fight/proc/fighter_ok(mob/living/carbon/human/npc/outpost_prisoner/fighter) return !QDELETED(fighter) && fighter.stat == CONSCIOUS && fighter.phase == PRISONER_PRESENT && fighter.fight == src && fighter.trouble == PRISONER_TROUBLE_FIGHT && !fighter.can_be_dragged() /// The argument, then the fight. FALSE once it is over. @@ -476,7 +474,7 @@ if(elapsed >= PRISONER_FIGHT_MAX_TIME) return FALSE if(fighting) - for(var/mob/living/basic/outpost_prisoner/fighter as anything in list(first, second)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/fighter as anything in list(first, second)) if(fighter.health_factor() <= PRISONER_FIGHT_YIELD) fighter.yield_fight() return FALSE @@ -485,8 +483,8 @@ line_left -= seconds if(line_left <= 0) line_left = 3 - var/mob/living/basic/outpost_prisoner/speaker = first_speaks ? first : second - var/mob/living/basic/outpost_prisoner/listener = opponent_of(speaker) + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = first_speaks ? first : second + var/mob/living/carbon/human/npc/outpost_prisoner/listener = opponent_of(speaker) first_speaks = !first_speaks speaker.face_atom(listener) speaker.say_context_or("fight_argue_[cause]", "fight_argue", listener) @@ -494,17 +492,17 @@ fighting = TRUE first.manual_emote("goes for [second]!") // A scuffle on the floor is dirty work. - for(var/mob/living/basic/outpost_prisoner/fighter as anything in list(first, second)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/fighter as anything in list(first, second)) fighter.set_uniform_grime(fighter.uniform_grime + PRISONER_FIGHT_GRIME) return TRUE /// Starts a fight between two prisoners over `cause`, telling everyone in sight. Returns the fight, or null. -/datum/outpost_prison/proc/start_fight(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two, cause = "none") +/datum/outpost_prison/proc/start_fight(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two, cause = "none") if(!trouble_enabled || one == two || one.fight || two.fight || riot_active) return null var/datum/outpost_prison_fight/brawl = new(src, one, two, cause) fights += brawl - for(var/mob/living/basic/outpost_prisoner/fighter as anything in list(one, two)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/fighter as anything in list(one, two)) fighter.cancel_threat() fighter.end_activity() fighter.stand_up() @@ -513,7 +511,7 @@ one.face_atom(two) two.face_atom(one) add_tension_spike(PRISON_SPIKE_FIGHT) - for(var/mob/living/basic/outpost_prisoner/onlooker in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/onlooker in prisoners) if(onlooker == one || onlooker == two || onlooker.stat != CONSCIOUS || onlooker.phase != PRISONER_PRESENT) continue if((onlooker in view(7, one)) || (onlooker in view(7, two))) @@ -528,7 +526,7 @@ return fights -= brawl fight_gap_left = PRISON_FIGHT_GAP - for(var/mob/living/basic/outpost_prisoner/fighter as anything in list(brawl.first, brawl.second)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/fighter as anything in list(brawl.first, brawl.second)) if(QDELETED(fighter)) continue if(fighter.fight == brawl) @@ -541,15 +539,15 @@ qdel(brawl) /// Whether they are angry enough, free and able to start a fight, with someone around to see it, and no creature frightening them -/mob/living/basic/outpost_prisoner/proc/can_start_fight() +/mob/living/carbon/human/npc/outpost_prisoner/proc/can_start_fight() return prison && mood < PRISONER_FIGHT_MOOD && !in_trouble() && fight_cooldown <= 0 && trouble_can_act() && health_factor() > PRISONER_FIGHT_YIELD && !activity?.sleeping && ai_running() && prison.in_cell_block(src) && !is_panicking() /// Chasing the ball, or holding it -/mob/living/basic/outpost_prisoner/proc/after_ball() +/mob/living/carbon/human/npc/outpost_prisoner/proc/after_ball() return istype(activity, /datum/prisoner_activity/basketball) || istype(held_item, /obj/item/toy/basketball) /// What two prisoners would fight about: both hungry ("food"), both after the ball ("ball"), one in the other's cell ("bed"), or nothing ("none") -/datum/outpost_prison/proc/fight_cause(mob/living/basic/outpost_prisoner/one, mob/living/basic/outpost_prisoner/two) +/datum/outpost_prison/proc/fight_cause(mob/living/carbon/human/npc/outpost_prisoner/one, mob/living/carbon/human/npc/outpost_prisoner/two) if(one.hunger < PRISONER_HUNGER_HUNGRY && two.hunger < PRISONER_HUNGER_HUNGRY) return "food" if(one.after_ball() && two.after_ball()) @@ -585,14 +583,14 @@ if(!trouble_enabled || riot_active || subdued_left > 0 || length(fights) || fight_gap_left > 0) return null var/list/angry = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase == PRISONER_PRESENT && prisoner.can_start_fight()) angry += prisoner var/list/pairs = list() for(var/i in 1 to length(angry) - 1) - var/mob/living/basic/outpost_prisoner/one = angry[i] + var/mob/living/carbon/human/npc/outpost_prisoner/one = angry[i] for(var/j in i + 1 to length(angry)) - var/mob/living/basic/outpost_prisoner/two = angry[j] + var/mob/living/carbon/human/npc/outpost_prisoner/two = angry[j] if(get_dist(one, two) <= PRISONER_FIGHT_RANGE && one.walkable?[get_turf(two)]) pairs += list(list(one, two)) if(!length(pairs)) @@ -609,7 +607,7 @@ // ===== SPATS ===== /// Whether a prisoner is sour enough and free to argue, and no creature is frightening them -/datum/outpost_prison/proc/can_spat(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/can_spat(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return prisoner.phase == PRISONER_PRESENT && prisoner.mood < PRISONER_SPAT_MOOD && !prisoner.in_trouble() && prisoner.trouble_can_act() && !prisoner.activity?.sleeping && prisoner.ai_running() && !prisoner.is_panicking() /** @@ -620,14 +618,14 @@ if(!trouble_enabled || riot_active || spat_lines_left > 0 || spat_gap_left > 0 || !prob(PRISON_SPAT_CHANCE * contraband_spat_mult())) return FALSE var/list/sour = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(can_spat(prisoner)) sour += prisoner var/list/pairs = list() for(var/i in 1 to length(sour) - 1) - var/mob/living/basic/outpost_prisoner/one = sour[i] + var/mob/living/carbon/human/npc/outpost_prisoner/one = sour[i] for(var/j in i + 1 to length(sour)) - var/mob/living/basic/outpost_prisoner/two = sour[j] + var/mob/living/carbon/human/npc/outpost_prisoner/two = sour[j] if(get_dist(one, two) <= PRISONER_FIGHT_RANGE && (two in view(PRISONER_FIGHT_RANGE, one))) pairs += list(list(one, two)) if(!length(pairs)) @@ -651,13 +649,13 @@ spat_line_left -= seconds if(spat_line_left > 0) return - var/mob/living/basic/outpost_prisoner/one = spat_first_ref?.resolve() - var/mob/living/basic/outpost_prisoner/two = spat_second_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/one = spat_first_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/two = spat_second_ref?.resolve() if(!one || !two || one.stat != CONSCIOUS || two.stat != CONSCIOUS || one.phase != PRISONER_PRESENT || two.phase != PRISONER_PRESENT || one.in_trouble() || two.in_trouble()) spat_lines_left = 0 return - var/mob/living/basic/outpost_prisoner/speaker = (spat_lines_said % 2) ? two : one - var/mob/living/basic/outpost_prisoner/listener = speaker == one ? two : one + var/mob/living/carbon/human/npc/outpost_prisoner/speaker = (spat_lines_said % 2) ? two : one + var/mob/living/carbon/human/npc/outpost_prisoner/listener = speaker == one ? two : one speaker.face_atom(listener) speaker.say_context("spat", listener) spat_lines_said++ @@ -667,7 +665,7 @@ // ===== RIOTS ===== /// Whether they are shut in a cell with its door bolted -/mob/living/basic/outpost_prisoner/proc/in_bolted_cell() +/mob/living/carbon/human/npc/outpost_prisoner/proc/in_bolted_cell() var/datum/outpost_prison_cell/holding = prison?.cell_at(get_turf(src)) return holding?.is_bolted() @@ -676,11 +674,11 @@ * door bolted open does not count). Down or cuffed, they are still at large, though the riot may * count them dealt with (riot_handled()). */ -/mob/living/basic/outpost_prisoner/proc/riot_at_large() +/mob/living/carbon/human/npc/outpost_prisoner/proc/riot_at_large() return phase == PRISONER_PRESENT && stat != DEAD && is_rioting() && !is_confined() /// A rioter at large and free to act: on their feet, uncuffed, and not given up at a turret's warning (outpost_prison_security.dm) -/mob/living/basic/outpost_prisoner/proc/riot_free() +/mob/living/carbon/human/npc/outpost_prisoner/proc/riot_free() return riot_at_large() && stat == CONSCIOUS && !can_be_dragged() && !surrendered_to_turret() /** @@ -689,10 +687,10 @@ * unable to act (in crit, stamina crit, collapsed or stunned). A knockdown they are straight back * up from is not enough. The riot is over once every rioter is (riot_tick()). */ -/mob/living/basic/outpost_prisoner/proc/riot_handled() +/mob/living/carbon/human/npc/outpost_prisoner/proc/riot_handled() if(QDELETED(src) || isnull(get_turf(src))) return TRUE - if(!riot_at_large() || cuffs) + if(!riot_at_large() || handcuffed) return TRUE return stat != CONSCIOUS || HAS_TRAIT(src, TRAIT_INCAPACITATED) @@ -700,11 +698,11 @@ * Whether they can join a riot now. Not from a cell they are shut in: a riot that nobody can take * out of a cell would be over as soon as it began, so they bang on the door and shout instead. */ -/mob/living/basic/outpost_prisoner/proc/can_join_riot() - return prison && stat == CONSCIOUS && phase == PRISONER_PRESENT && !can_be_dragged() && beaten_left <= 0 && !climb_ref && (!trouble || trouble == PRISONER_TROUBLE_FIGHT) && prison.in_cell_block(src) && !is_confined() +/mob/living/carbon/human/npc/outpost_prisoner/proc/can_join_riot() + return prison && stat == CONSCIOUS && phase == PRISONER_PRESENT && !can_be_dragged() && !beaten && !climb_ref && (!trouble || trouble == PRISONER_TROUBLE_FIGHT) && prison.in_cell_block(src) && !is_confined() /// Below this mood they join a riot, by personality, raised by a shiv under their mattress (outpost_prison_contraband.dm) -/mob/living/basic/outpost_prisoner/proc/riot_join_mood() +/mob/living/carbon/human/npc/outpost_prisoner/proc/riot_join_mood() var/line = PRISON_RIOT_JOIN_CHATTY switch(personality) if("grumpy") @@ -721,10 +719,10 @@ * Who joins a riot: everyone below their personality's line (everyone able, for an admin riot), * plus `forced`, else the unhappiest alone. */ -/datum/outpost_prison/proc/riot_candidates(everyone = FALSE, mob/living/basic/outpost_prisoner/forced) +/datum/outpost_prison/proc/riot_candidates(everyone = FALSE, mob/living/carbon/human/npc/outpost_prisoner/forced) var/list/joining = list() - var/mob/living/basic/outpost_prisoner/unhappiest - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + var/mob/living/carbon/human/npc/outpost_prisoner/unhappiest + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(!prisoner.can_join_riot()) continue if(everyone || prisoner == forced || prisoner.mood < prisoner.riot_join_mood()) @@ -741,7 +739,7 @@ * does. `ignore_quiet` starts it in the quiet after a riot too, for a prisoner let out of lockdown * early. An experiment under way stops nothing. */ -/datum/outpost_prison/proc/start_riot(reason, everyone = FALSE, mob/living/basic/outpost_prisoner/forced, ignore_quiet = FALSE, alone = FALSE) +/datum/outpost_prison/proc/start_riot(reason, everyone = FALSE, mob/living/carbon/human/npc/outpost_prisoner/forced, ignore_quiet = FALSE, alone = FALSE) if(!trouble_enabled || riot_active) return FALSE if(!everyone && subdued_left > 0 && !ignore_quiet) @@ -766,7 +764,7 @@ open_incident() var/shouts = 0 // A Most Wanted ringleader is among the first to shout (outpost_prison_bounty.dm). - for(var/mob/living/basic/outpost_prisoner/rioter as anything in bounty_ringleaders_first(joining)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter as anything in bounty_ringleaders_first(joining)) rioter.start_rioting(shouts++ < 2) send_bystanders_home(joining) add_log("Riot in the yard: [length(joining)] prisoner\s.") @@ -777,7 +775,7 @@ return TRUE /// Joins the riot: a shiv out, everything else dropped -/mob/living/basic/outpost_prisoner/proc/start_rioting(shout = TRUE) +/mob/living/carbon/human/npc/outpost_prisoner/proc/start_rioting(shout = TRUE) cancel_threat() end_activity() stand_up() @@ -786,16 +784,15 @@ riot_target_ref = null riot_target_hits = 0 riot_victim_ref = null - // A shiv they hid in their cell is the one they draw (outpost_prison_contraband.dm). - if(!prison?.draw_stashed_shiv(src)) - draw_shiv() + // A shiv they hid in their cell is the one they draw (outpost_prison_contraband.dm); without one, their fists. + prison?.draw_stashed_shiv(src) update_bubble() if(shout) say_context("riot") /// Prisoners who stay out of a riot go back to their own cells and sit it out on the bed. Anyone running from a creature keeps running (outpost_prison_panic.dm). /datum/outpost_prison/proc/send_bystanders_home(list/rioters) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if((prisoner in rioters) || prisoner.phase != PRISONER_PRESENT || !prisoner.routine_allowed() || prisoner.is_confined() || prisoner.is_panicking()) continue var/datum/prisoner_activity/hide/hide = new(prisoner) @@ -816,7 +813,7 @@ riot_windup_left = max(0, riot_windup_left - seconds) var/unhandled = 0 var/free = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.riot_handled()) continue unhandled++ @@ -862,7 +859,7 @@ riot_windup_left = 0 tension_spike = 0 subdued_left = PRISON_SUBDUED_TIME / (1 SECONDS) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.is_rioting()) prisoner.calm_down() stage = outpost_prison_stage_for(compute_tension()) @@ -884,7 +881,7 @@ /// The riot went on too long: the rioters go all out for the exits, and each one's loose clock starts /datum/outpost_prison/proc/begin_breakout() breaking_out = TRUE - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) // Rioters who gave up at a turret's warning stay where they are (outpost_prison_security.dm) if(prisoner.trouble != PRISONER_TROUBLE_RIOT || prisoner.surrendered_to_turret()) continue @@ -901,7 +898,7 @@ * once a riot turns into a breakout. Anyone else in a riot already on carries on as before. Returns * TRUE if they are breaking out. */ -/datum/outpost_prison/proc/start_breakout(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/start_breakout(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!trouble_enabled || QDELETED(prisoner) || !(prisoner in prisoners)) return FALSE if(!prisoner.is_rioting()) @@ -928,7 +925,7 @@ */ /datum/outpost_prison/proc/transfer_rioters() var/list/rioters = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.riot_at_large() && !held_for_experiment(prisoner)) rioters += prisoner if(!length(rioters)) @@ -937,7 +934,7 @@ return 0 open_incident() var/charged = 0 - for(var/mob/living/basic/outpost_prisoner/rioter as anything in rioters) + for(var/mob/living/carbon/human/npc/outpost_prisoner/rioter as anything in rioters) close_bounty_record(rioter, BOUNTY_RECORD_CLOSED) charged += charge_fine(OUTPOST_PRISON_TRANSFER_FEE, "Prisoner transfer: [rioter.real_name]", TRUE) note_prisoner_lost(rioter, "transferred") @@ -951,7 +948,7 @@ return length(rioters) /// A serving hatch left open on both sides that this prisoner can reach from the yard side -/datum/outpost_prison/proc/open_hatch_for(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/open_hatch_for(mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/obj/structure/table/reinforced/prison_hatch/hatch in fixtures_of("hatch")) if(QDELETED(hatch) || !hatch.both_sides_open()) continue @@ -969,7 +966,7 @@ * were smashing for a few more blows, or a new target (pick_smash_target(), * outpost_prison_breakout.dm). Turret warnings are in outpost_prison_security.dm. */ -/mob/living/basic/outpost_prisoner/proc/riot_target() +/mob/living/carbon/human/npc/outpost_prisoner/proc/riot_target() if(!prison) return null if(prison.riot_windup_left > 0 || surrendered_to_turret() || is_confined()) @@ -1004,7 +1001,7 @@ return next /// The nearest member of staff in sight that they can get to, and that no more than PRISON_RIOT_MAX_ATTACKERS - 1 others are going for -/mob/living/basic/outpost_prisoner/proc/staff_in_reach() +/mob/living/carbon/human/npc/outpost_prisoner/proc/staff_in_reach() var/mob/living/nearest var/nearest_distance = INFINITY for(var/mob/living/person in view(7, src)) @@ -1020,9 +1017,9 @@ return nearest /// How many rioters other than `except` are going for `person` -/datum/outpost_prison/proc/attackers_on(mob/living/person, mob/living/basic/outpost_prisoner/except) +/datum/outpost_prison/proc/attackers_on(mob/living/person, mob/living/carbon/human/npc/outpost_prisoner/except) var/count = 0 - for(var/mob/living/basic/outpost_prisoner/other in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other in prisoners) if(other == except || !other.is_rioting() || !other.trouble_can_act()) continue if(other.riot_victim_ref?.resolve() == person) @@ -1030,7 +1027,7 @@ return count /// Whether a rioter's target is still worth hitting -/datum/outpost_prison/proc/still_smashable(atom/target, mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/still_smashable(atom/target, mob/living/carbon/human/npc/outpost_prisoner/rioter) if(QDELETED(target) || !rioter.reachable?[get_turf(target)] || LAZYACCESS(rioter.riot_skips, REF(target)) > world.time) return FALSE if(istype(target, /obj/machinery/light)) @@ -1078,7 +1075,7 @@ * wrecking their cells; wrecks under way go on. Nothing happens while their AI sleeps. */ /datum/outpost_prison/proc/wreck_tick(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS) continue if(prisoner.trouble == PRISONER_TROUBLE_WRECK) @@ -1091,7 +1088,7 @@ * and gets dirty, and they hammer on the door. The warden's log says so. Also the admin panel's hook. * Returns TRUE if they started. */ -/datum/outpost_prison/proc/start_wreck(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/start_wreck(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(QDELETED(prisoner) || prisoner.prison != src || prisoner.phase != PRISONER_PRESENT || prisoner.stat != CONSCIOUS || prisoner.trouble) return FALSE var/datum/outpost_prison_cell/holding = cell_at(get_turf(prisoner)) @@ -1122,7 +1119,7 @@ return TRUE /// A wreck goes on: bangs on the door every few seconds, and after PRISONER_WRECK_TIME the bolts shear -/datum/outpost_prison/proc/wreck_step(mob/living/basic/outpost_prisoner/prisoner, seconds) +/datum/outpost_prison/proc/wreck_step(mob/living/carbon/human/npc/outpost_prisoner/prisoner, seconds) var/datum/outpost_prison_cell/holding = cell_at(get_turf(prisoner)) if(!holding || !prisoner.is_confined()) // Let out, or no longer in a cell: the wreck is over. @@ -1144,7 +1141,7 @@ shear_bolts(prisoner, holding) /// A wreck ends without the bolts shearing: let out, knocked down or gone -/datum/outpost_prison/proc/stop_wreck(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/stop_wreck(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner.trouble != PRISONER_TROUBLE_WRECK) return prisoner.trouble = null @@ -1153,7 +1150,7 @@ prisoner.update_bubble() /// The cell door gives, bolted or welded: out they come, and unless the wing is subdued they riot -/datum/outpost_prison/proc/shear_bolts(mob/living/basic/outpost_prisoner/prisoner, datum/outpost_prison_cell/holding) +/datum/outpost_prison/proc/shear_bolts(mob/living/carbon/human/npc/outpost_prisoner/prisoner, datum/outpost_prison_cell/holding) stop_wreck(prisoner) var/obj/machinery/door/airlock/door = holding.door() if(door && (door.locked || door.welded)) @@ -1199,7 +1196,7 @@ /// Prisoners on the patrol AI, still in the wing's custody /datum/outpost_prison/proc/loose_count() var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.trouble == PRISONER_TROUBLE_LOOSE && prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD) count++ return count @@ -1207,8 +1204,8 @@ /// Loose prisoners nobody has caught yet: not cuffed. Caught ones, left cuffed somewhere, keep no protective custody going. /datum/outpost_prison/proc/runners_at_large() var/count = 0 - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) - if(prisoner.trouble == PRISONER_TROUBLE_LOOSE && prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && !prisoner.cuffs) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) + if(prisoner.trouble == PRISONER_TROUBLE_LOOSE && prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD && !prisoner.handcuffed) count++ return count @@ -1219,7 +1216,7 @@ */ /datum/outpost_prison/proc/alarm_state() var/list/sightings = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.trouble == PRISONER_TROUBLE_LOOSE && prisoner.phase == PRISONER_PRESENT && prisoner.stat != DEAD) sightings += "[prisoner.real_name] ([get_area_name(prisoner)])" var/loose_text @@ -1242,7 +1239,7 @@ /// The trouble block for the admin panel and tests: the stage, tension, the riot's clocks and who is on a loose clock where /datum/outpost_prison/proc/trouble_payload() var/list/loose = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners) if(prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD || prisoner.loose_left <= 0) continue if(prisoner.trouble != PRISONER_TROUBLE_LOOSE && prisoner.trouble != PRISONER_TROUBLE_BREAKOUT) @@ -1269,7 +1266,7 @@ * or cuffed (can_be_dragged()). */ /datum/outpost_prison/proc/check_escapes(seconds) - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD) continue var/inside = in_cell_block(prisoner) @@ -1300,13 +1297,13 @@ /datum/outpost_prison/proc/loose_tick(seconds) var/list/still_loose = list() var/list/nearly_away = list() - for(var/mob/living/basic/outpost_prisoner/prisoner in prisoners.Copy()) + for(var/mob/living/carbon/human/npc/outpost_prisoner/prisoner in prisoners.Copy()) if(QDELETED(prisoner) || prisoner.phase != PRISONER_PRESENT || prisoner.stat == DEAD || prisoner.loose_left <= 0) continue if(prisoner.trouble != PRISONER_TROUBLE_LOOSE && prisoner.trouble != PRISONER_TROUBLE_BREAKOUT) prisoner.loose_left = 0 continue - if(prisoner.trouble == PRISONER_TROUBLE_LOOSE ? prisoner.cuffs : !prisoner.riot_free()) + if(prisoner.trouble == PRISONER_TROUBLE_LOOSE ? prisoner.handcuffed : !prisoner.riot_free()) continue if(prisoner.loose_left <= seconds && held_for_experiment(prisoner)) continue @@ -1327,7 +1324,7 @@ * Out of the cell block: loose on the outpost patrol AI, with their clock running. `breakout` * marks it as part of a breakout even if they were not rioting (the admin button). */ -/datum/outpost_prison/proc/prisoner_escaped(mob/living/basic/outpost_prisoner/prisoner, breakout = FALSE) +/datum/outpost_prison/proc/prisoner_escaped(mob/living/carbon/human/npc/outpost_prisoner/prisoner, breakout = FALSE) if(prisoner.trouble == PRISONER_TROUBLE_LOOSE) return FALSE var/from_riot = breakout || prisoner.is_rioting() @@ -1353,7 +1350,7 @@ return TRUE /// Goes loose: the outpost patrol AI takes over, and the clock starts, or carries on from the breakout -/mob/living/basic/outpost_prisoner/proc/go_loose() +/mob/living/carbon/human/npc/outpost_prisoner/proc/go_loose() end_activity() stand_up() stop_climb() @@ -1366,11 +1363,8 @@ trouble = PRISONER_TROUBLE_LOOSE if(loose_left <= 0) loose_left = OUTPOST_PRISON_LOOSE_TIME - // Wanted and Most Wanted bounty prisoners hit harder, and a meek one runs faster (outpost_prison_bounty.dm). - obj_damage = PRISONER_LOOSE_OBJ_DAMAGE * bounty_breakout_mult() - if(bounty_loose_speed()) - set_varspeed(bounty_base_speed()) - update_melee() + // A runner runs. + set_prison_pace(hurry = bounty_base_speed()) update_bubble() // The outpost patrol AI (voidcrew/modules/npc_ships/code/outpost_patrol.dm). Finding the doors // yields on a big outpost, and this runs in the prison's tick on SSprocessing. @@ -1378,7 +1372,7 @@ assign_mob_to_outpost_patrol_async(src, prison?.outpost) /// Back in the cell block, down or cuffed: in custody again, in a foul mood, and owing lockdown if they got out during a riot -/datum/outpost_prison/proc/recapture(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/recapture(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(prisoner.trouble != PRISONER_TROUBLE_LOOSE) return FALSE var/owes_lockdown = prisoner.escaped_rioting @@ -1391,21 +1385,17 @@ update_riot_lights() return TRUE -/mob/living/basic/outpost_prisoner/proc/back_in_custody() +/mob/living/carbon/human/npc/outpost_prisoner/proc/back_in_custody() clear_outpost_patrol(src) trouble = null loose_left = 0 escaped_rioting = FALSE note_trouble_ended() - obj_damage = initial(obj_damage) - // A meek bounty prisoner slows back down (outpost_prison_bounty.dm). - if(bounty_loose_speed()) - set_varspeed(bounty_base_speed()) + set_prison_pace(hurry = bounty_base_speed()) drop_shiv() set_mood(PRISONER_RECAPTURED_MOOD) swap_basic_ai_controller(src, /datum/ai_controller/basic_controller/outpost_prisoner) prison?.refresh_prisoner_reach(src) - update_melee() update_bubble() say_context("recaptured") @@ -1413,7 +1403,7 @@ * Out of time: gone for good, walls or no walls. No bonus; the treasury is fined * OUTPOST_PRISON_ESCAPE_FINE as part of the incident, and the corrections service counts them lost. */ -/datum/outpost_prison/proc/escaped_for_good(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/escaped_for_good(mob/living/carbon/human/npc/outpost_prisoner/prisoner) close_bounty_record(prisoner, BOUNTY_RECORD_ESCAPED) var/fine = charge_fine(OUTPOST_PRISON_ESCAPE_FINE, "Prison escape fine: [prisoner.real_name]", TRUE) note_prisoner_lost(prisoner, "escaped") @@ -1434,13 +1424,13 @@ * rioting or not, and treats a loose prisoner outside the wing as fair game until they are down, so * a runner is stopped rather than killed. Turrets players build follow outpost_prison_security.dm. */ -/mob/living/basic/outpost_prisoner/proc/turret_target() +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_target() if(stat == DEAD || trouble != PRISONER_TROUBLE_LOOSE || phase != PRISONER_PRESENT || can_be_dragged()) return FALSE return !prison || get_area(src) != prison.wing /proc/is_loose_outpost_prisoner(mob/living/creature) - var/mob/living/basic/outpost_prisoner/prisoner = creature + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = creature return istype(prisoner) && prisoner.turret_target() // ===== SERVING HATCH ===== diff --git a/voidcrew/modules/player_outposts/outpost_prison_routine.dm b/voidcrew/modules/player_outposts/outpost_prison_routine.dm index 0732c7fa3e9..086d0a15b13 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_routine.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_routine.dm @@ -38,11 +38,11 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) // ===== PRISONER SIDE ===== /// Whether their routine may run now: not while down, cuffed, pulled, in trouble or held by the talk menu (outpost_prison_warden_tools.dm) -/mob/living/basic/outpost_prisoner/proc/routine_allowed() +/mob/living/carbon/human/npc/outpost_prisoner/proc/routine_allowed() return prison && stat == CONSCIOUS && phase == PRISONER_PRESENT && !can_be_dragged() && !pulledby && !in_trouble() && !held_by_talk_menu() /// Ends what they are doing, putting things down and getting up -/mob/living/basic/outpost_prisoner/proc/end_activity(cancel_ai = TRUE) +/mob/living/carbon/human/npc/outpost_prisoner/proc/end_activity(cancel_ai = TRUE) if(!activity) return var/datum/prisoner_activity/old = activity @@ -55,13 +55,13 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) ai_controller?.CancelActions() /// Starts `new_activity` in place of whatever they were doing -/mob/living/basic/outpost_prisoner/proc/start_activity(datum/prisoner_activity/new_activity) +/mob/living/carbon/human/npc/outpost_prisoner/proc/start_activity(datum/prisoner_activity/new_activity) end_activity() activity = new_activity return new_activity /// A walk failed: try once more, then give up on it for a while -/mob/living/basic/outpost_prisoner/proc/activity_move_failed() +/mob/living/carbon/human/npc/outpost_prisoner/proc/activity_move_failed() if(!activity) return if(++activity.failed_moves < 2) @@ -70,14 +70,14 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) end_activity(cancel_ai = FALSE) /// Whether they gave up on this kind of activity recently -/mob/living/basic/outpost_prisoner/proc/activity_on_cooldown(activity_type) +/mob/living/carbon/human/npc/outpost_prisoner/proc/activity_on_cooldown(activity_type) return LAZYACCESS(activity_cooldowns, activity_type) > world.time /** * Picks their next activity: duties and needs first (heading home at release, eating, changing, * calling for the hatch), then leisure by weight. Returns the activity, already set up. */ -/mob/living/basic/outpost_prisoner/proc/choose_activity() +/mob/living/carbon/human/npc/outpost_prisoner/proc/choose_activity() if(!prison) return null if(!walkable) @@ -131,7 +131,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return null /// The nearest free tile beside `target` they can stand on, or its own tile when walkable -/mob/living/basic/outpost_prisoner/proc/approach_turf(atom/target) +/mob/living/carbon/human/npc/outpost_prisoner/proc/approach_turf(atom/target) var/turf/target_turf = get_turf(target) if(!target_turf || !walkable) return null @@ -150,19 +150,19 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return best /// Somewhere to hang about: the yard, or their own cell, not someone else's -/mob/living/basic/outpost_prisoner/proc/may_loiter(turf/tile) +/mob/living/carbon/human/npc/outpost_prisoner/proc/may_loiter(turf/tile) var/datum/outpost_prison_cell/holder = prison?.cell_at(tile) return !holder || holder == cell || holder == prison.cell_at(get_turf(src)) /// Whether someone else stands on this tile -/mob/living/basic/outpost_prisoner/proc/tile_taken(turf/tile) +/mob/living/carbon/human/npc/outpost_prisoner/proc/tile_taken(turf/tile) for(var/mob/living/other in tile) if(other != src && other.density) return TRUE return FALSE /// Their bed if they can get to it, else any free bed they can -/mob/living/basic/outpost_prisoner/proc/find_bed() +/mob/living/carbon/human/npc/outpost_prisoner/proc/find_bed() var/obj/structure/bed/own = cell?.bed() if(own && walkable?[get_turf(own)] && !prison.claimed_by_other(own, src)) return own @@ -172,7 +172,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return null /// A fixture of `category` they can walk up to and nobody else is using, own cell's first, never in another's cell -/mob/living/basic/outpost_prisoner/proc/find_fixture(category, stand_on = FALSE) +/mob/living/carbon/human/npc/outpost_prisoner/proc/find_fixture(category, stand_on = FALSE) var/list/options = list() for(var/atom/fixture as anything in prison.fixtures_of(category)) if(QDELETED(fixture) || prison.claimed_by_other(fixture, src)) @@ -189,7 +189,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return pick(options) /// Whether `person` is a member of the wing awake in the cell block, close by, holding dressings -/mob/living/basic/outpost_prisoner/proc/is_medic(mob/living/person) +/mob/living/carbon/human/npc/outpost_prisoner/proc/is_medic(mob/living/person) if(!istype(person) || QDELETED(person) || person.stat != CONSCIOUS || is_outpost_prisoner(person)) return FALSE if(get_dist(src, person) > PRISONER_SICK_CALL_RANGE || !prison?.in_cell_block(person) || !prison.is_member(person)) @@ -197,7 +197,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return !!person.is_holding_item_of_type(/obj/item/stack/medical) /// The nearest medic they can see and walk up to -/mob/living/basic/outpost_prisoner/proc/find_medic() +/mob/living/carbon/human/npc/outpost_prisoner/proc/find_medic() var/mob/living/best var/best_distance = INFINITY for(var/mob/living/person in view(PRISONER_SICK_CALL_RANGE, src)) @@ -213,13 +213,13 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) * Hurt, with a medic in sight: drops whatever idle thing they were doing and goes over. Called * every few seconds by the prison while anyone is on the level. Returns TRUE if they went. */ -/mob/living/basic/outpost_prisoner/proc/check_sick_call() +/mob/living/carbon/human/npc/outpost_prisoner/proc/check_sick_call() if(!ai_running()) return FALSE return start_sick_call() /// Starts a sick call if they are hurt, free and someone holding dressings is near. Returns TRUE if it started. -/mob/living/basic/outpost_prisoner/proc/start_sick_call() +/mob/living/carbon/human/npc/outpost_prisoner/proc/start_sick_call() if(!routine_allowed() || health_factor() >= PRISONER_INJURED_BELOW || !COOLDOWN_FINISHED(src, sick_call_cooldown)) return FALSE // A need, a duty or sleep comes first; anything idle can wait. @@ -236,7 +236,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) * A ball thrown at them: caught by anyone in a game, and by an idle prisoner when staff throw it, * who then starts shooting hoops. Returns TRUE if they caught it. */ -/mob/living/basic/outpost_prisoner/proc/try_catch_ball(atom/movable/thrown_thing, datum/thrownthing/throwing) +/mob/living/carbon/human/npc/outpost_prisoner/proc/try_catch_ball(atom/movable/thrown_thing, datum/thrownthing/throwing) var/obj/item/toy/basketball/ball = thrown_thing if(!istype(ball) || held_item || !routine_allowed() || activity?.sleeping) return FALSE @@ -257,7 +257,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) qdel(game) return TRUE -/mob/living/basic/outpost_prisoner/hitby(atom/movable/AM, skipcatch, hitpush = TRUE, blocked = FALSE, datum/thrownthing/throwingdatum) +/mob/living/carbon/human/npc/outpost_prisoner/hitby(atom/movable/AM, skipcatch, hitpush = TRUE, blocked = FALSE, datum/thrownthing/throwingdatum) if(try_catch_ball(AM, throwingdatum)) return TRUE return ..() @@ -284,7 +284,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) /datum/ai_planning_subtree/outpost_prisoner_routine /datum/ai_planning_subtree/outpost_prisoner_routine/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn if(!istype(prisoner) || !prisoner.routine_allowed()) return var/datum/prisoner_activity/activity = prisoner.activity || prisoner.choose_activity() @@ -310,7 +310,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) action_cooldown = 0.5 SECONDS /datum/ai_behavior/outpost_prisoner_travel/setup(datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn var/turf/spot = prisoner.activity?.spot if(!spot) return FALSE @@ -319,7 +319,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return TRUE /datum/ai_behavior/outpost_prisoner_travel/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn var/datum/prisoner_activity/activity = prisoner.activity if(!activity || prisoner.loc != activity.spot) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED @@ -330,7 +330,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) /datum/ai_behavior/outpost_prisoner_travel/finish_action(datum/ai_controller/controller, succeeded) . = ..() if(!succeeded) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn prisoner?.activity_move_failed() /// Ticks the activity about once a second until it wants to move or is done @@ -338,11 +338,11 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) action_cooldown = 1 SECONDS /datum/ai_behavior/outpost_prisoner_activity/setup(datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn return !!prisoner.activity /datum/ai_behavior/outpost_prisoner_activity/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn var/datum/prisoner_activity/activity = prisoner.activity if(!activity || !prisoner.routine_allowed()) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED @@ -381,7 +381,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) var/mood_activity = FALSE /// Asleep: no chatting, only sleep talk var/sleeping = FALSE - var/mob/living/basic/outpost_prisoner/prisoner + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner /// Where they are headed; null once there var/turf/spot /// When it ends, once started @@ -392,7 +392,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) /// REF() keys of what this activity has claimed in the prison var/list/claimed -/datum/prisoner_activity/New(mob/living/basic/outpost_prisoner/doer) +/datum/prisoner_activity/New(mob/living/carbon/human/npc/outpost_prisoner/doer) . = ..() prisoner = doer @@ -1062,13 +1062,13 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) /datum/prisoner_activity/chat/setup() var/list/options = list() - for(var/mob/living/basic/outpost_prisoner/other as anything in prisoner.prison.prisoners) + for(var/mob/living/carbon/human/npc/outpost_prisoner/other as anything in prisoner.prison.prisoners) if(other == prisoner || !can_join(other)) continue options += other while(length(options)) // Friends first (outpost_prison_life.dm); the pick comes out of options either way. - var/mob/living/basic/outpost_prisoner/other = prisoner.prison.take_chat_partner(prisoner, options) + var/mob/living/carbon/human/npc/outpost_prisoner/other = prisoner.prison.take_chat_partner(prisoner, options) var/turf/beside = prisoner.approach_turf(other) if(!beside) continue @@ -1078,7 +1078,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return FALSE /// Whether `other` is free to talk -/datum/prisoner_activity/chat/proc/can_join(mob/living/basic/outpost_prisoner/other) +/datum/prisoner_activity/chat/proc/can_join(mob/living/carbon/human/npc/outpost_prisoner/other) if(QDELETED(other) || !other.routine_allowed() || other.activity?.sleeping) return FALSE if(other.activity && (!other.activity.interruptible || istype(other.activity, /datum/prisoner_activity/chat))) @@ -1086,7 +1086,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return TRUE /datum/prisoner_activity/chat/arrive() - var/mob/living/basic/outpost_prisoner/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/partner = partner_ref?.resolve() if(!partner || get_dist(prisoner, partner) > 1 || !can_join(partner)) return FALSE . = ..() @@ -1096,7 +1096,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) next_exchange = world.time + 1 SECONDS /datum/prisoner_activity/chat/tick(seconds) - var/mob/living/basic/outpost_prisoner/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/partner = partner_ref?.resolve() if(!partner || get_dist(prisoner, partner) > 1 || partner.activity?.chat_partner() != prisoner) return ACTIVITY_DONE prisoner.face_atom(partner) @@ -1107,7 +1107,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return ..() /datum/prisoner_activity/chat/finish() - var/mob/living/basic/outpost_prisoner/partner = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/partner = partner_ref?.resolve() if(partner && istype(partner.activity, /datum/prisoner_activity/chat/listen) && partner.activity.chat_partner() == prisoner) partner.end_activity() if(started && partner) @@ -1119,7 +1119,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) leisure = FALSE weight = 0 -/datum/prisoner_activity/chat/listen/New(mob/living/basic/outpost_prisoner/doer, mob/living/basic/outpost_prisoner/talker) +/datum/prisoner_activity/chat/listen/New(mob/living/carbon/human/npc/outpost_prisoner/doer, mob/living/carbon/human/npc/outpost_prisoner/talker) . = ..(doer) partner_ref = WEAKREF(talker) started = TRUE @@ -1133,7 +1133,7 @@ GLOBAL_LIST_INIT(outpost_prisoner_leisure, outpost_prisoner_leisure_types()) return TRUE /datum/prisoner_activity/chat/listen/tick(seconds) - var/mob/living/basic/outpost_prisoner/talker = partner_ref?.resolve() + var/mob/living/carbon/human/npc/outpost_prisoner/talker = partner_ref?.resolve() if(!talker || get_dist(prisoner, talker) > 1 || talker.activity?.chat_partner() != prisoner) return ACTIVITY_DONE prisoner.face_atom(talker) diff --git a/voidcrew/modules/player_outposts/outpost_prison_security.dm b/voidcrew/modules/player_outposts/outpost_prison_security.dm index 636154072b0..a7bfc2cc30f 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_security.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_security.dm @@ -15,9 +15,9 @@ * A shot at a prisoner is always a stun shot: the turret's own if its gun has a stamina or * electrode setting that goes through glass, else a disabler beam, whatever the mode. Before the first shot at a prisoner * it has not warned lately, the turret warns them and holds fire for a moment, and the prisoner - * decides what to do about it (react_to_turret()). Its shots pass through anyone the rule spares, - * and a lethal shot passes through every prisoner. Rioters go for turrets they can reach before - * any fixture (priority_smash_target()). + * decides what to do about it (react_to_turret()). The rule is the turret's choice of target: a shot + * that goes wide lands on whoever is in the way, as anyone's would. Rioters go for turrets they can + * reach before any fixture (priority_smash_target()). * * 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). @@ -40,9 +40,9 @@ if(!isliving(target)) return OUTPOST_PRISON_TURRET_NOT_MINE if(is_outpost_prisoner(target)) - var/mob/living/basic/outpost_prisoner/prisoner = target + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = target return prisoner.turret_verdict(turret) - if(is_outpost_prison_guard(target) || istype(target, /mob/living/basic/outpost_kessler_staff)) + if(is_outpost_prison_guard(target) || istype(target, /mob/living/carbon/human/npc/outpost_kessler_staff)) return OUTPOST_PRISON_TURRET_SPARE // The experiments' creatures (outpost_prison_creatures.dm, outpost_prison_changeling.dm): until dead, subdued or in Kessler's hands if(is_outpost_prison_mob(target) || is_outpost_experiment_mob(target)) @@ -58,7 +58,7 @@ var/obj/projectile/shot = projectile_type return initial(shot.damage_type) == STAMINA && initial(shot.damage) > 0 -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// What they did about a turret's warning while rioting (OUTPOST_PRISON_TURRET_GIVE_UP, _BACK_OFF or _DEFY), the turret, and when that runs out var/turret_reaction var/datum/weakref/turret_reaction_ref @@ -68,7 +68,7 @@ var/turret_retreat_until = 0 /// A turret's verdict on them; see outpost_prison_turret_verdict() -/mob/living/basic/outpost_prisoner/proc/turret_verdict(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_verdict(obj/machinery/porta_turret/turret) if(stat != CONSCIOUS || phase != PRISONER_PRESENT || can_be_dragged() || is_confined()) return OUTPOST_PRISON_TURRET_SPARE if(surrendered_to_turret() || backing_off_from_turret() || !turret_trouble()) @@ -76,21 +76,21 @@ return OUTPOST_PRISON_TURRET_SHOOT /// The trouble a turret answers: rioting or breaking out, loose, swinging at staff, climbing a hatch, or fighting past the argument. Threats, arguments and wrecking a cell are not. -/mob/living/basic/outpost_prisoner/proc/turret_trouble() +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_trouble() if(is_rioting() || trouble == PRISONER_TROUBLE_LOOSE || climb_ref || swing_ref?.resolve()) return TRUE return trouble == PRISONER_TROUBLE_FIGHT && fight?.fighting /// A rioter who gave up at a turret's warning: in the riot until staff bolt them in, but doing nothing -/mob/living/basic/outpost_prisoner/proc/surrendered_to_turret() +/mob/living/carbon/human/npc/outpost_prisoner/proc/surrendered_to_turret() return turret_reaction == OUTPOST_PRISON_TURRET_GIVE_UP && is_rioting() /// Walking away after backing off from a turret's warning -/mob/living/basic/outpost_prisoner/proc/backing_off_from_turret() +/mob/living/carbon/human/npc/outpost_prisoner/proc/backing_off_from_turret() return world.time < turret_retreat_until /// Forgets what a turret's warning made them do: a riot starting or ending clears it -/mob/living/basic/outpost_prisoner/proc/clear_turret_reaction() +/mob/living/carbon/human/npc/outpost_prisoner/proc/clear_turret_reaction() turret_reaction = null turret_reaction_ref = null turret_reaction_until = 0 @@ -156,7 +156,7 @@ update_appearance() /// Whether it may fire at `prisoner` now. The first time in a while it warns them instead, and holds fire for OUTPOST_PRISON_TURRET_WARN_TIME. -/obj/machinery/porta_turret/proc/prison_may_fire_at(mob/living/basic/outpost_prisoner/prisoner) +/obj/machinery/porta_turret/proc/prison_may_fire_at(mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(outpost_prison_turret_verdict(prisoner, src) != OUTPOST_PRISON_TURRET_SHOOT) return FALSE var/warned = LAZYACCESS(prison_warned_at, REF(prisoner)) @@ -169,7 +169,7 @@ return !(prisoner.is_rioting() && prisoner.prison?.riot_windup_left > 0) /// The warning: a red line on them, a triple beep and "Step away.", and they decide what to do about it. Returns what they did. -/obj/machinery/porta_turret/proc/warn_prisoner(mob/living/basic/outpost_prisoner/prisoner) +/obj/machinery/porta_turret/proc/warn_prisoner(mob/living/carbon/human/npc/outpost_prisoner/prisoner) for(var/key in prison_warned_at?.Copy()) if(world.time - prison_warned_at[key] >= OUTPOST_PRISON_TURRET_REWARN_TIME) LAZYREMOVE(prison_warned_at, key) @@ -180,24 +180,6 @@ say("Step away.") return prisoner.react_to_turret(src) -/// A turret following the prison's rules lands no shot on anyone the rule spares, and no lethal one on a prisoner -/obj/projectile/can_hit_target(atom/target, direct_target = FALSE, ignore_loc = FALSE, cross_failed = FALSE) - if(isliving(target) && istype(firer, /obj/machinery/porta_turret) && !outpost_prison_turret_shot_lands(src, target)) - return FALSE - return ..() - -/// Whether `shot`, fired by a turret, may land on `target` -/proc/outpost_prison_turret_shot_lands(obj/projectile/shot, mob/living/target) - var/obj/machinery/porta_turret/turret = shot.firer - if(!turret.uses_outpost_turret_rules()) - return TRUE - switch(outpost_prison_turret_verdict(target, turret)) - if(OUTPOST_PRISON_TURRET_NOT_MINE) - return TRUE - if(OUTPOST_PRISON_TURRET_SPARE) - return FALSE - return !is_outpost_prisoner(target) || outpost_prison_stun_projectile(shot.type) - // ===== THE WARNING, FROM THE PRISONER'S SIDE ===== /// Percent chance someone swinging at staff or fighting backs off at a turret's warning: likelier the better their mood, never certain either way @@ -235,7 +217,7 @@ * off (riot on out of the turret's sight), or defy it (go for the turret). * Loose prisoners run on the outpost patrol AI and take no notice. Returns what they did, or null. */ -/mob/living/basic/outpost_prisoner/proc/react_to_turret(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/react_to_turret(obj/machinery/porta_turret/turret) if(QDELETED(turret) || stat != CONSCIOUS || phase != PRISONER_PRESENT || can_be_dragged()) return null var/reaction @@ -269,24 +251,24 @@ return reaction /// Backing off with `chance` percent, else defying. The prison's forced_turret_reaction settles it instead, for tests. -/mob/living/basic/outpost_prisoner/proc/turret_roll(chance) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_roll(chance) var/forced = prison?.forced_turret_reaction if(forced) return forced == OUTPOST_PRISON_TURRET_DEFY ? OUTPOST_PRISON_TURRET_DEFY : OUTPOST_PRISON_TURRET_BACK_OFF return prob(chance) ? OUTPOST_PRISON_TURRET_BACK_OFF : OUTPOST_PRISON_TURRET_DEFY -/mob/living/basic/outpost_prisoner/proc/set_turret_reaction(reaction, obj/machinery/porta_turret/turret, duration) +/mob/living/carbon/human/npc/outpost_prisoner/proc/set_turret_reaction(reaction, obj/machinery/porta_turret/turret, duration) turret_reaction = reaction turret_reaction_ref = WEAKREF(turret) turret_reaction_until = world.time + duration /// Heads for `spot`, if there is one; no turret shoots them for `duration` meanwhile -/mob/living/basic/outpost_prisoner/proc/start_turret_retreat(turf/spot, duration) +/mob/living/carbon/human/npc/outpost_prisoner/proc/start_turret_retreat(turf/spot, duration) turret_retreat_spot = spot turret_retreat_until = world.time + duration /// Swinging or fighting, and backing off: they drop it, step away from the turret and whoever they were going for, and don't square up again for the threat cooldown -/mob/living/basic/outpost_prisoner/proc/turret_stand_down(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_stand_down(obj/machinery/porta_turret/turret) var/mob/living/other = swing_ref?.resolve() || fight?.opponent_of(src) cancel_threat() threat_cooldown = max(threat_cooldown, PRISONER_THREAT_COOLDOWN) @@ -297,7 +279,7 @@ start_turret_retreat(pick_turret_retreat(turret, FALSE, other), OUTPOST_PRISON_TURRET_RETREAT_TIME) /// A rioter who gives up: the shiv goes down and they walk back to their own cell. Bolted in, they are out of the riot, owing lockdown (outpost_prison_capture.dm). -/mob/living/basic/outpost_prisoner/proc/turret_give_up(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_give_up(obj/machinery/porta_turret/turret) set_turret_reaction(OUTPOST_PRISON_TURRET_GIVE_UP, turret, 0) cancel_threat() drop_shiv() @@ -310,7 +292,7 @@ prison?.add_log("[real_name] gave up rioting at a turret's warning.") /// A rioter who backs off: still rioting, but out of the turret's sight, and leaving alone what it can see for a while -/mob/living/basic/outpost_prisoner/proc/turret_back_off_riot(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_back_off_riot(obj/machinery/porta_turret/turret) set_turret_reaction(OUTPOST_PRISON_TURRET_BACK_OFF, turret, OUTPOST_PRISON_TURRET_AVOID_TIME) // The turret too: under its cover it does not show up in its own view LAZYSET(riot_skips, REF(turret), turret_reaction_until) @@ -324,13 +306,13 @@ start_turret_retreat(pick_turret_retreat(turret, TRUE), OUTPOST_PRISON_TURRET_RETREAT_TIME) /// A rioter who defies it: they go for the turret before anything else (riot_target()), and get shot on the way -/mob/living/basic/outpost_prisoner/proc/turret_defy(obj/machinery/porta_turret/turret) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turret_defy(obj/machinery/porta_turret/turret) set_turret_reaction(OUTPOST_PRISON_TURRET_DEFY, turret, OUTPOST_PRISON_TURRET_DEFY_TIME) LAZYREMOVE(riot_skips, REF(turret)) ai_controller?.CancelActions() /// The turret a rioter defied, while they still mean to smash it and can get at it -/mob/living/basic/outpost_prisoner/proc/defied_turret() +/mob/living/carbon/human/npc/outpost_prisoner/proc/defied_turret() if(turret_reaction != OUTPOST_PRISON_TURRET_DEFY || world.time >= turret_reaction_until || !is_rioting()) return null var/obj/machinery/porta_turret/turret = turret_reaction_ref?.resolve() @@ -339,7 +321,7 @@ return turret /// Where a rioter who gave up waits: their cell's chair or bed (home_seat()), or any free tile of their own cell they can walk to -/mob/living/basic/outpost_prisoner/proc/own_cell_spot() +/mob/living/carbon/human/npc/outpost_prisoner/proc/own_cell_spot() if(!cell) return null var/obj/structure/seat = home_seat() @@ -355,7 +337,7 @@ * can walk to that it cannot see, or failing that the one farthest from it; anyone else a tile or * two away, as far as they can get from it and from `other`. Null if nowhere will do. */ -/mob/living/basic/outpost_prisoner/proc/pick_turret_retreat(obj/machinery/porta_turret/turret, out_of_sight = FALSE, mob/living/other) +/mob/living/carbon/human/npc/outpost_prisoner/proc/pick_turret_retreat(obj/machinery/porta_turret/turret, out_of_sight = FALSE, mob/living/other) if(!length(walkable)) return null var/turf/here = get_turf(src) @@ -387,7 +369,7 @@ * way. At the spot, or out of time, they stop; one who gave up and got to their cell's chair or * bed sits down (sit_in_cell()). */ -/mob/living/basic/outpost_prisoner/proc/plan_turret_retreat(datum/ai_controller/controller) +/mob/living/carbon/human/npc/outpost_prisoner/proc/plan_turret_retreat(datum/ai_controller/controller) if(!turret_retreat_spot) return FALSE if(loc == turret_retreat_spot || world.time >= turret_retreat_until) @@ -398,7 +380,7 @@ controller.queue_behavior(/datum/ai_behavior/outpost_prisoner_turret_retreat) return TRUE -/mob/living/basic/outpost_prisoner/proc/finish_turret_retreat() +/mob/living/carbon/human/npc/outpost_prisoner/proc/finish_turret_retreat() var/turf/spot = turret_retreat_spot turret_retreat_spot = null if(!surrendered_to_turret() || loc != spot || !cell?.contains(src) || !((locate(/obj/structure/chair) in loc) || (locate(/obj/structure/bed) in loc))) @@ -413,7 +395,7 @@ action_cooldown = 0.5 SECONDS /datum/ai_behavior/outpost_prisoner_turret_retreat/setup(datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn var/turf/spot = prisoner?.turret_retreat_spot if(!spot) return FALSE @@ -422,7 +404,7 @@ return TRUE /datum/ai_behavior/outpost_prisoner_turret_retreat/perform(seconds_per_tick, datum/ai_controller/controller) - var/mob/living/basic/outpost_prisoner/prisoner = controller.pawn + var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = controller.pawn if(!prisoner?.turret_retreat_spot || prisoner.loc != prisoner.turret_retreat_spot) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED prisoner.finish_turret_retreat() @@ -442,7 +424,7 @@ * rules that they can reach and have not given up on, or null. A rioter newly going for one may * shout about it. */ -/datum/outpost_prison/proc/priority_smash_target(mob/living/basic/outpost_prisoner/rioter) +/datum/outpost_prison/proc/priority_smash_target(mob/living/carbon/human/npc/outpost_prisoner/rioter) if(!rioter?.reachable) return null var/obj/machinery/porta_turret/nearest @@ -457,21 +439,21 @@ nearest_distance = distance if(nearest && rioter.riot_target_ref?.resolve() != nearest && COOLDOWN_FINISHED(src, turret_smash_line_cooldown)) COOLDOWN_START(src, turret_smash_line_cooldown, OUTPOST_PRISON_TURRET_SMASH_LINE_GAP) - INVOKE_ASYNC(rioter, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "turret_smash") + INVOKE_ASYNC(rioter, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "turret_smash") return nearest /// A turret's shot is about to land on `target`: now and then someone watching says something -/datum/outpost_prison/proc/note_turret_hit(mob/living/basic/outpost_prisoner/target) +/datum/outpost_prison/proc/note_turret_hit(mob/living/carbon/human/npc/outpost_prisoner/target) if(!COOLDOWN_FINISHED(src, turret_hit_line_cooldown) || !prob(OUTPOST_PRISON_TURRET_HIT_LINE_CHANCE)) return FALSE - for(var/mob/living/basic/outpost_prisoner/onlooker in shuffle(prisoners)) + for(var/mob/living/carbon/human/npc/outpost_prisoner/onlooker in shuffle(prisoners)) if(onlooker == target || onlooker.stat != CONSCIOUS || onlooker.phase != PRISONER_PRESENT || !onlooker.ai_running() || onlooker.in_trouble()) continue if(get_dist(onlooker, target) > 7 || !(target in view(7, onlooker))) continue COOLDOWN_START(src, turret_hit_line_cooldown, OUTPOST_PRISON_TURRET_HIT_LINE_GAP) onlooker.face_atom(target) - INVOKE_ASYNC(onlooker, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, say_context), "turret_hit") + INVOKE_ASYNC(onlooker, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, say_context), "turret_hit") return TRUE return FALSE diff --git a/voidcrew/modules/player_outposts/outpost_prison_serum.dm b/voidcrew/modules/player_outposts/outpost_prison_serum.dm index eef2c4310ee..af179458ffa 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_serum.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_serum.dm @@ -55,7 +55,7 @@ /obj/item/outpost_experiment/serum/interact_with_atom(atom/interacting_with, mob/living/user, list/modifiers) if(!isliving(interacting_with)) return NONE - var/mob/living/basic/outpost_prisoner/subject = interacting_with + var/mob/living/carbon/human/npc/outpost_prisoner/subject = interacting_with var/error = dose_error(user, subject) if(error) balloon_alert(user, error) @@ -79,7 +79,7 @@ return ITEM_INTERACT_SUCCESS /// Why the serum cannot go into `subject` now, or null -/obj/item/outpost_experiment/serum/proc/dose_error(mob/living/user, mob/living/basic/outpost_prisoner/subject) +/obj/item/outpost_experiment/serum/proc/dose_error(mob/living/user, mob/living/carbon/human/npc/outpost_prisoner/subject) var/error = user_error(user) if(error) return error @@ -207,7 +207,7 @@ */ /datum/component/outpost_experiment_item/proc/eaten_by(mob/living/eater) var/datum/outpost_prison/prison = prison() - var/mob/living/basic/outpost_prisoner/host = eater + var/mob/living/carbon/human/npc/outpost_prisoner/host = eater if(istype(host) && prison && host.prison == prison && prison.start_experiment("changeling", host)) log_game("PLAYER OUTPOST PRISON: [host.real_name] ate food with a Kessler specimen in it at '[prison.outpost?.name]'") qdel(src) @@ -250,7 +250,7 @@ qdel(parent) /// A prisoner eats: food with the specimen in it takes them as its host -/mob/living/basic/outpost_prisoner/eat_food(obj/item/food/meal) +/mob/living/carbon/human/npc/outpost_prisoner/eat_food(obj/item/food/meal) var/datum/component/outpost_experiment_item/specimen = meal?.GetComponent(/datum/component/outpost_experiment_item) . = ..() if(specimen && !QDELETED(specimen)) diff --git a/voidcrew/modules/player_outposts/outpost_prison_social.dm b/voidcrew/modules/player_outposts/outpost_prison_social.dm index a22d32f11dd..00aa58585de 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_social.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_social.dm @@ -103,7 +103,7 @@ var/rep_greet_chance = PRISON_REP_GREET_CHANCE var/rep_word_chance = PRISON_REP_WORD_CHANCE -/mob/living/basic/outpost_prisoner +/mob/living/carbon/human/npc/outpost_prisoner /// Record key -> world.time they last greeted that person var/list/rep_greeted /// Record keys already thanked for bringing them mail this stay @@ -200,7 +200,7 @@ // note_staff_care() and note_staff_hit() run inside signal handlers: nothing here may sleep. /// `person` fed, clothed or treated `prisoner` by hand: once per prisoner per PRISON_REP_CARE_GAP -/datum/outpost_prison/proc/note_staff_care(mob/person, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/note_staff_care(mob/person, mob/living/carbon/human/npc/outpost_prisoner/prisoner) if(!prisoner || !ismob(person)) return FALSE // note_carer() runs as a dressing is offered, so a dressing held to someone unhurt is not care. @@ -229,7 +229,7 @@ return TRUE /// `person` talked `prisoner` down -/datum/outpost_prison/proc/note_staff_talk(mob/person, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/note_staff_talk(mob/person, mob/living/carbon/human/npc/outpost_prisoner/prisoner) return rep_adjust(person, PRISON_REP_TALK) /// `person` sank a shot while prisoners played @@ -243,7 +243,7 @@ return rep_adjust(person, min(amount, PRISON_REP_KINDNESS_MAX)) /// An unprovoked hit on `prisoner` that cost them mood (hit_by_staff() has already ruled out deserved ones) -/datum/outpost_prison/proc/note_staff_hit(atom/attacker, mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/note_staff_hit(atom/attacker, mob/living/carbon/human/npc/outpost_prisoner/prisoner) var/counted = FALSE for(var/mob/person in rep_people_behind(attacker)) if(rep_adjust(person, PRISON_REP_HIT)) @@ -254,7 +254,7 @@ * Staff got the blame for `prisoner`: `event` is "beaten" (an unprovoked beating down, on top of * the hits) or "killed" (the score goes to PRISON_REP_MIN). The blame goes to whoever last hit them. */ -/datum/outpost_prison/proc/note_staff_blamed(mob/living/basic/outpost_prisoner/prisoner, event) +/datum/outpost_prison/proc/note_staff_blamed(mob/living/carbon/human/npc/outpost_prisoner/prisoner, event) if(event != "beaten" && event != "killed") return FALSE var/atom/attacker = prisoner?.last_staff_attacker_ref?.resolve() @@ -277,7 +277,7 @@ * prisoner per stay), "mail_opened", "search_found", "search_empty", "patdown_found", * "patdown_empty". Events it does not know, and anyone who is not a member, are ignored. */ -/datum/outpost_prison/proc/note_staff_event(mob/person, mob/living/basic/outpost_prisoner/prisoner, event) +/datum/outpost_prison/proc/note_staff_event(mob/person, mob/living/carbon/human/npc/outpost_prisoner/prisoner, event) var/amount switch(event) if("mail_delivered") @@ -307,7 +307,7 @@ // ===== WHAT IT DOES ===== /// Below this mood `prisoner` squares up to `person`: lower for a fair member, higher for a brute, and higher for some bounty prisoners (outpost_prison_bounty.dm) -/datum/outpost_prison/proc/threat_mood_for(mob/living/basic/outpost_prisoner/prisoner, mob/person) +/datum/outpost_prison/proc/threat_mood_for(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/person) . = PRISONER_THREAT_MOOD switch(staff_label(person)) if("fair") @@ -317,29 +317,29 @@ . += prisoner ? prisoner.bounty_threat_bonus() : 0 /// The highest line threat_mood_for() can return (for `prisoner`, when given), so a prisoner above it looks for nobody -/datum/outpost_prison/proc/threat_mood_ceiling(mob/living/basic/outpost_prisoner/prisoner) +/datum/outpost_prison/proc/threat_mood_ceiling(mob/living/carbon/human/npc/outpost_prisoner/prisoner) return max(PRISONER_THREAT_MOOD, PRISON_REP_THREAT_BRUTE) + (prisoner ? prisoner.bounty_threat_bonus() : 0) /// The mood a talk-down from `person` gives `prisoner` -/datum/outpost_prison/proc/talk_mood_for(mob/living/basic/outpost_prisoner/prisoner, mob/person) +/datum/outpost_prison/proc/talk_mood_for(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/person) return staff_label(person) == "fair" ? PRISON_REP_TALK_MOOD_FAIR : PRISONER_MOOD_TALK /// Whether `prisoner` refuses to be talked down by `person`: a brute, below PRISON_REP_REFUSE_BRUTE_BELOW. It says so itself (async). -/datum/outpost_prison/proc/refuses_talk_from(mob/living/basic/outpost_prisoner/prisoner, mob/person) +/datum/outpost_prison/proc/refuses_talk_from(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/person) if(!prisoner || prisoner.mood >= PRISON_REP_REFUSE_BRUTE_BELOW || staff_label(person) != "brute") return FALSE prisoner.balloon_alert(person, "not listening") - INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/basic/outpost_prisoner, turn_away_brute), person) + INVOKE_ASYNC(prisoner, TYPE_PROC_REF(/mob/living/carbon/human/npc/outpost_prisoner, turn_away_brute), person) return TRUE /// Won't hear a word from a brute -/mob/living/basic/outpost_prisoner/proc/turn_away_brute(mob/person) +/mob/living/carbon/human/npc/outpost_prisoner/proc/turn_away_brute(mob/person) if(!QDELETED(person)) face_atom(person) say_to_staff("talk_refuse_brute", person) /// How far `person` seems to a rioter picking whom to go for: a brute nearer, a fair member farther -/datum/outpost_prison/proc/riot_victim_distance(mob/living/basic/outpost_prisoner/prisoner, mob/person, distance) +/datum/outpost_prison/proc/riot_victim_distance(mob/living/carbon/human/npc/outpost_prisoner/prisoner, mob/person, distance) switch(staff_label(person)) if("brute") return distance - PRISON_REP_RIOT_BRUTE_NEARER @@ -355,7 +355,7 @@ * PRISON_REP_GREET_RANGE tiles in view gets greet_

    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 diff --git a/voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi b/voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi index 8b0d00c3981c48099b72b7d99081a33bc5206c61..2b98b57c734605dbddb5bc0bba0921c7e2e099dc 100644 GIT binary patch literal 2891 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 literal 1960 zcmZWpdo&XY7$32TTq2K1^M2;B7Ai&_^GY5YnY=mksFiKVvzbZ}Mr{!`M4Cq)k(k64 z(#4(EkVj}5!t#tLT%G&hz27h~|27qvB+J-x_kSG|7d zH2YMqy~bYvd}><5SttTJ2(^=^vLCfSa*;=B7(%L)Yf;H5P?g#e>1Qb^&@bKhegMeClqAp{xeNdR zM{F(4T%xF4ng`~Xt8`~e$ki84AI&vxKPi?kHDsZj!NHImQHeAW6=fX->11ayr`6(C zkVTS+SU@3LCD=YkRMAQ&S&9V|E4*-9pCwx$mT)E3>{YRrb}I-rYt)Sis)t(C!{&Oj zKke&gFJX+r_NLD5hcbJZTv48bv;4p8QX6w+EC<)>{0C<%6cgDLpXdEd! zUDnQD6s+}+ojRK5P4 zo$Yf%_4VMoJ$XITS$ev%x(7W)Tuu&@t?mExd|Dj1!B`yhrMv`E%XyRCQ%E5H&DT!l z4xNb@YUWDe&IL$5SM0Fdz3f~xpSOLVT8N7Gv{c0ikkiItRx}%!df<9dNl;`$pcQ%# zJtZ#i{{4%|a$4o&ZjpCh7AN9^o()lq-jkc)GYEpem7U&^sBz77;hngY-t~=+BL7*D z<>82oN%Vz}uZIR|n%j?*qmRUNjBWnc2%>uR*2(yB?AndwboRD@Foa^QP{w}qRtb5d><#Vv#4ln{PZ;x!YYa>=24o$>~1 zxqO1+#H1wsCuODjh&#?X7?V$X7xwe<-a7?$orXNd9thI5AiVA~ZT_}X{2%_Ziuh+aRAADqQ+g#$Kw`nRL?$R zH)9F!k8XAp!^$=2H2xtta)#2saXdSE$aXrjlT%9~G>n(#sH)}Vn?QlfhXmS?pwPE2 znT`$!jOXR@Dq0ig+JO3cw{e&|T`E#&XaJsK?^x?M7ypcm*&d7a>y_0H5279YwIDXP z6Rf!6c429Amv9W}^q)pQrt_=umlG8ug%tu#CMd@luB6fV?Yy)Q>e~y`kPBDQv{rG_J7y#|8Qr42l*${u!iw{1ocU_|2|)fwk% zgZQ=p59Svg$=W3k(5yKgnJe)HB2`rixu2y)CgaOruSH^h-nGZicUK$MJc)v*-iNti z-;dG&Cj!g^wuP1t$18n6iSQM%gyH~tEk7M6Mr>Y_!9@!S%dzKU(7H3bo>R?^+Zo@y zVO3VmuQ#Cy+jNauI#~OcL;=iOO+&LhFj439*}jSmH{xN~z864hLHXdp+23zOuNz$1 z?RoHdfArqdC=jDn*#C;Il$r+V)o?N^V39As@=J7ier1|RQ>N&S7}0Ighiw$9g-aQk z|A?SBB<6_P># diff --git a/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi b/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi index ce1bf56b5a1acb7dfd840ecb797a21f340e60372..69648676f49e332d98cc41c0c6aad9d4ef30534b 100644 GIT binary patch literal 2419 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 delta 1668 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 literal 3700 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 EZ)K!sHvj+t From 26a4a0e0735305ad1e93b61110a71c72d57bc779 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:41:44 -0800 Subject: [PATCH 110/472] Add Governor Patch and Governor Bypass, cheaper models of Governor Delete --- tgstation.dme | 1 + tools/gear_catalog/ratings/cyberware.json | 18 +++++ .../modules/cyberware/ware_line_governor.dm | 75 +++++++++++++++++++ .../modules/unit_tests/voidcrew_cyberware.dm | 54 +++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 voidcrew/modules/cyberware/ware_line_governor.dm diff --git a/tgstation.dme b/tgstation.dme index b7240e8b74a..9e82dca57f8 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7183,6 +7183,7 @@ #include "voidcrew\modules\cyberware\ripperdoc_catalog.dm" #include "voidcrew\modules\cyberware\ware_arm_utility.dm" #include "voidcrew\modules\cyberware\ware_legend.dm" +#include "voidcrew\modules\cyberware\ware_line_governor.dm" #include "voidcrew\modules\cyberware\ware_military_arms.dm" #include "voidcrew\modules\cyberware\ware_military_body.dm" #include "voidcrew\modules\cyberware\ware_pro_combat.dm" diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index f4c5b57f0ea..184de3d51ff 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -380,6 +380,24 @@ "reason": "Adds 15 neural load for 35 in all, giving up the slot Cascade and Redline need. That runs Slabskin, Lazarus, Mantis, Hopper, Dead Channel, Coolant, Deadeye and Atlas at once. 5 vouchers at reputation 22.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/governor_delete/milspec": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Governor Bypass", + "rank": 4, + "reason": "Raises the neural load limit from 20 to 30 for no load of its own, in the slot Cascade and Redline need. 3 vouchers at reputation 11.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/governor_delete/street": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Governor Patch", + "rank": 5, + "reason": "Raises the neural load limit from 20 to 25 for no load of its own, in the slot Cascade and Redline need. 2,500 cr at reputation 4.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/hemoglass": { "confidence": "medium", "group": "Body upkeep", diff --git a/voidcrew/modules/cyberware/ware_line_governor.dm b/voidcrew/modules/cyberware/ware_line_governor.dm new file mode 100644 index 00000000000..806f45ddbe2 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_governor.dm @@ -0,0 +1,75 @@ +/** + * # The Governor line + * + * Two cheaper rungs under Governor Delete (ware_legend.dm), so a crew can buy + * a little headroom early and climb to the real thing. All three sit in the + * chest OS slot, so fitting one evicts another, and they evict Cascade and + * Redline too: the cost of headroom is the operating-system slot, same as the + * parent. Load is 0 on every rung. + * + * The parent already reads its bonus from `chrome_capacity_bonus`, so the + * rungs only retune that var and `tier`; there is no behaviour of their own. + * Delete has no downside beyond the slot, so there is none to scale down. + * + * Swapping down is safe by construction. The install gate nets the evicted + * governor's bonus out (installed_capacity_check), so a swap that would leave + * the body over its new ceiling is refused up front with the incumbent still + * online. Pulling a governor out is never blocked: whatever is then over the + * ceiling browns out until load drops, like any other capacity loss. + * + * Rung bonus limit load tier + * Patch +5 25 0 T2 (street, sold for credits) + * Bypass +10 30 0 T3 (military, sold for vouchers) + * Delete +15 35 0 T4 (ware_legend.dm) + */ + +/// T2: a hand-fitted patch over the limiter instead of a clean removal. +/obj/item/organ/cyberimp/cyberware/governor_delete/street + name = "\improper Governor Patch" + desc = "A neural governor with the limiter taped over rather than removed. The tape has seen some things." + icon_state = "governor_delete_street" + chrome_load = 0 + chrome_capacity_bonus = 5 + tier = CYBERWARE_TIER_2 + +/// T3: the limiter jumpered out of the circuit, surplus-grade work. +/obj/item/organ/cyberimp/cyberware/governor_delete/milspec + name = "\improper Governor Bypass" + desc = "A neural governor with its limiter jumpered out of the circuit. The surplus stencil is still on the casing." + icon_state = "governor_delete_milspec" + chrome_load = 0 + chrome_capacity_bonus = 10 + tier = CYBERWARE_TIER_3 + +// ========================================================================= +// SPLICE SKUS: not listed on any vendor yet, the integration pass adds them +// to the Chop Shop's sku_types (and the street doc's, for the Patch). +// ========================================================================= + +/datum/shop_sku/ripperdoc/governor_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/governor_delete/street + price_credits = 2500 + +/datum/shop_sku/ripperdoc/governor_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/governor_delete/milspec + price_vouchers = 3 + +// ========================================================================= +// TRADE-IN: the Patch is the rung a customer outgrows, half list in credits. +// Not listed on any buyback_types yet, same integration pass as above. The +// Bypass costs vouchers, which can't be refunded, so it has no entry. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/governor_street + name = "Governor Patch (pulled)" + desc = "A taped-over governor, pulled. Credit toward a cleaner cut." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/governor_delete/street + match_subtypes = FALSE + pay_credits = 1250 + demand_min = 1 + demand_max = 2 diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index 389a7b829e1..3127a54ff88 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -887,6 +887,60 @@ TEST_ASSERT(governor.Insert(patient, TRUE), "Governor couldn't replace an OS") TEST_ASSERT(!cascade.owner && governor.owner == patient, "Chest OS hardware stacked") +/// The Governor line: each rung gives its own headroom at no load in the shared +/// OS slot, and swapping down never leaves the body quietly over its new ceiling. +/datum/unit_test/voidcrew_governor_line + +/datum/unit_test/voidcrew_governor_line/Run() + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/governor_delete/delete = allocate(/obj/item/organ/cyberimp/cyberware/governor_delete) + var/obj/item/organ/cyberimp/cyberware/governor_delete/milspec/bypass = allocate(/obj/item/organ/cyberimp/cyberware/governor_delete/milspec) + var/obj/item/organ/cyberimp/cyberware/governor_delete/street/patch = allocate(/obj/item/organ/cyberimp/cyberware/governor_delete/street) + // ware, bonus, tier (the defines aren't visible from here: CYBERWARE_TIER_2/3/4) + var/list/rungs = list( + list(patch, 5, 2), + list(bypass, 10, 3), + list(delete, 15, 4), + ) + for(var/list/rung as anything in rungs) + var/obj/item/organ/cyberimp/cyberware/ware = rung[1] + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + TEST_ASSERT_EQUAL(ware.slot, delete.slot, "[ware.type] isn't in the Governor's OS slot") + TEST_ASSERT_EQUAL(chrome.chrome_load, 0, "[ware.type] costs load, a Governor never does") + TEST_ASSERT_EQUAL(chrome.capacity_bonus, rung[2], "[ware.type] has the wrong capacity bonus") + TEST_ASSERT_EQUAL(chrome.tier, rung[3], "[ware.type] has the wrong tier") + TEST_ASSERT(ware.Insert(patient, TRUE), "[ware.type] fixture failed to install") + TEST_ASSERT_EQUAL(get_chrome_capacity(patient), 20 + rung[2], "[ware.type] gave the wrong capacity") + TEST_ASSERT_EQUAL(get_chrome_load(patient), 0, "[ware.type] put load on the body") + // Only the last rung is left in the slot: each install evicted the one before. + TEST_ASSERT(delete.owner == patient && !bypass.owner && !patch.owner, "Governor rungs stacked in the OS slot") + + // Swapping down is gated, not trapped: at a load the lower rung can't hold, the + // swap is refused with the incumbent still online. + var/obj/item/organ/cyberimp/cyberware/test_full_body/bulk = allocate(/obj/item/organ/cyberimp/cyberware/test_full_body) + var/obj/item/organ/cyberimp/cyberware/test_heavy/heavy = allocate(/obj/item/organ/cyberimp/cyberware/test_heavy) + bulk.Insert(patient, TRUE) + heavy.Insert(patient, TRUE) + TEST_ASSERT_EQUAL(get_chrome_load(patient), 35, "Governor line fixture load is wrong") + TEST_ASSERT(!cyberware_insert_check(bypass, patient, TRUE), "35 load fit under the Bypass's 30") + TEST_ASSERT(!cyberware_insert_check(patch, patient, TRUE), "35 load fit under the Patch's 25") + TEST_ASSERT(delete.owner == patient && !(delete.organ_flags & ORGAN_FAILING), "A refused downgrade disturbed the incumbent Governor") + heavy.Remove(patient, TRUE) + TEST_ASSERT(cyberware_insert_check(patch, patient, TRUE), "20 load didn't fit under the Patch's 25") + TEST_ASSERT(cyberware_insert_check(bypass, patient, TRUE), "20 load didn't fit under the Bypass's 30") + heavy.Insert(patient, TRUE) + + // Pulling the Governor is never blocked: everything browns out, and the + // smaller one that goes back in only lifts it once load fits. + var/datum/component/cyberware/bulk_chrome = bulk.GetComponent(/datum/component/cyberware) + TEST_ASSERT(bulk_chrome.is_running(), "35 of 35 browned out") + delete.Remove(patient, TRUE) + TEST_ASSERT(!bulk_chrome.is_running(), "Losing the Governor didn't brown out the over-capacity chrome") + TEST_ASSERT(patch.Insert(patient, TRUE), "The Patch couldn't go in over capacity with special") + TEST_ASSERT(!bulk_chrome.is_running(), "35 load stayed up under the Patch's 25") + heavy.Remove(patient, TRUE) + TEST_ASSERT(bulk_chrome.is_running(), "20 load stayed browned out under the Patch's 25") + /// Every approved exception is checked independently of the table under test. /datum/unit_test/voidcrew_nanite_capacity_costs From 16ca0ac30cfa8c7746f628ead2f1452119fdd628 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:43:29 -0800 Subject: [PATCH 111/472] Add the Flickerskin street camo weave Ghostskin's field tuning (alpha, duration, move flare, whether it hides the bearer from mobs) is now vars on the organ, with the existing values as defaults. The street model is a skin-slot subtype with a shorter, half-faded field that skips TRAIT_CYBER_CAMO, so mobs still target the bearer and a chrome read still resolves. Adds its yellow-zone SKU, half-price buyback, a gear rating and a small test. --- tgstation.dme | 1 + tools/gear_catalog/ratings/cyberware.json | 14 +++- voidcrew/modules/cyberware/ware_line_camo.dm | 66 +++++++++++++++++++ .../modules/cyberware/ware_military_body.dm | 36 +++++++--- .../modules/unit_tests/voidcrew_cyberware.dm | 34 ++++++++++ 5 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 voidcrew/modules/cyberware/ware_line_camo.dm diff --git a/tgstation.dme b/tgstation.dme index 9e82dca57f8..ebc01ea314f 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7183,6 +7183,7 @@ #include "voidcrew\modules\cyberware\ripperdoc_catalog.dm" #include "voidcrew\modules\cyberware\ware_arm_utility.dm" #include "voidcrew\modules\cyberware\ware_legend.dm" +#include "voidcrew\modules\cyberware\ware_line_camo.dm" #include "voidcrew\modules\cyberware\ware_line_governor.dm" #include "voidcrew\modules\cyberware\ware_military_arms.dm" #include "voidcrew\modules\cyberware\ware_military_body.dm" diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 184de3d51ff..9f5f309a8f3 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -229,7 +229,7 @@ "hash": "14f4a2c2517247c0", "name": "Herculean gravitronic spinal implant", "note": "Cuts the wearer's climbing, ladder, gym-machine, table-carry and vent-boulder lifting times to 80%, adds +1 speed and +4 range to everything they throw, adds 4 to tackle strength and lets them break boulders off a discovered ore vent with bare hands. An EMP deals 20 brute (10 light) to the chest. A gravity anomaly core can be slotted in to change it into the Atlas variant.", - "rank": 6, + "rank": 7, "reason": "Faster climbing, throws fly farther and faster, tackles hit harder, and you can break boulders off ore vents by hand. Nothing here wins a fight. About 85 cr of material.", "tier": "green" }, @@ -239,7 +239,7 @@ "hash": "ffff4012788ccee2", "name": "implantable thrusters set", "note": "A toggle that lets the wearer move under their own power in zero gravity, with a movement-speed bonus while floating and no stabilization, so they keep drifting when they stop. It draws on ambient air above 30 kPa for free, otherwise internals gas or the wearer's plasma store, and turns itself off if neither is available.", - "rank": 5, + "rank": 6, "reason": "A jetpack in your chest with no back slot used, and a speed bonus while floating. Free air when there's pressure, otherwise your tank. About 74 cr of material after 200 research points.", "tier": "green" }, @@ -351,6 +351,16 @@ "reason": "Ten seconds of near-invisibility every 30, and mobs farther than 2 tiles lose you. Attacking or being hit ends it. A free escape or free first shot in most fights, and it walks you through ruins. Load 5, 4 vouchers at reputation 22.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/ghostskin/street": { + "confidence": "medium", + "group": "Mobility and stealth", + "hash": "", + "name": "Flickerskin weave", + "note": "A button turns on 5 s of optical camo (a half-faded outline that brightens when they move) with a 45 s cooldown spent on use; pressing it again drops the field early. The field collapses on attacking a living thing, firing a gun, being hit or attacked, hostile bullets, being cuffed, death or an EMP. It does not grant the mob-hiding camo trait, so NPC AI still targets the wearer at any range and Chrome Read still resolves them.", + "rank": 5, + "reason": "Five seconds of half-faded camo every 45: people lose you in a crowd, mobs and turrets still see you, and any swing or hit ends it. A cheap way to break line of sight from players. Load 3, 2,400 cr at yellow reputation.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/gorilla_arms": { "confidence": "high", "group": "Arm weapons and fists", diff --git a/voidcrew/modules/cyberware/ware_line_camo.dm b/voidcrew/modules/cyberware/ware_line_camo.dm new file mode 100644 index 00000000000..250c43730f6 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_camo.dm @@ -0,0 +1,66 @@ +/** + * # Camo line: the street weave + * + * The cheap rung under the Ghostskin Weave (ware_military_body.dm), on the same + * skin slot, so installing one evicts the other. It reuses the whole Ghostskin + * field, signals, break triggers and all, and only retunes the vars the parent + * exposes: a shorter, patchier fade that fools players and never mobs. + * + * This file is included BEFORE ware_military_body.dm, so it cannot use the + * CYBERWARE_GHOSTSKIN_* defines (they are defined and #undef'd over there). + * Every number below is a plain var override instead. + * + * What hides_from_mobs = FALSE buys, in practice: no TRAIT_CYBER_CAMO, so + * cyberware_stealth.dm never refuses the bearer as a target and + * cyberware_scan.dm never scatters a chrome read on them. Both are keyed on + * that one trait. Anything that AIs target by sight, simple_animal hostiles, + * turrets, a few voidcrew scripted hunters, never read it at any tier. + */ + +/obj/item/organ/cyberimp/cyberware/ghostskin/street + name = "\improper Flickerskin weave" + desc = "A budget subdermal weave of refraction cells, mostly salvage. Switched on, you go blurry at the edges, which is not the same as gone." + icon_state = "ghostskin_street" + chrome_load = 3 + tier = CYBERWARE_TIER_2 + actions_types = list(/datum/action/cooldown/cyberware/ghostskin_cloak/street) + // Roughly half faded, never a heat-shimmer. Moving flares it well above that. + field_alpha = 110 + ripple_alpha = 170 + field_duration = 5 SECONDS + hides_from_mobs = FALSE + +/obj/item/organ/cyberimp/cyberware/ghostskin/street/engage_feedback(mob/living/carbon/wearer) + playsound(wearer, 'voidcrew/sound/machines/cloaking/on.ogg', 35, TRUE) + wearer.visible_message( + span_warning("[wearer] flickers and goes patchy!"), + span_notice("The weave stutters on and smears the light around you. Not all of it."), + ) + +/datum/action/cooldown/cyberware/ghostskin_cloak/street + desc = "Five seconds of patchy optical camo, enough to fool people and not much else. Attacking, shooting, taking a hit or an EMP drops it." + cooldown_time = 45 SECONDS + +// ========================================================================= +// SPLICE SKUS +// Not listed on any vendor from here: the lead adds the path to Splice's +// sku_types and to the street doc's, and the buyback to buyback_types. +// ========================================================================= + +/datum/shop_sku/ripperdoc/ghostskin_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/ghostskin/street + price_credits = 2400 + stock_min = 1 + stock_max = 2 + +/// Trade-in at half list, credits only, like the other street rungs. +/datum/shop_buyback/ripperdoc/ghostskin_street + name = "Flickerskin weave (pulled)" + desc = "A patchy camo weave you've outgrown. Worth half toward the real thing." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/ghostskin/street + pay_credits = 1200 + demand_min = 1 + demand_max = 2 diff --git a/voidcrew/modules/cyberware/ware_military_body.dm b/voidcrew/modules/cyberware/ware_military_body.dm index 6bd4b6d118f..f11a984711e 100644 --- a/voidcrew/modules/cyberware/ware_military_body.dm +++ b/voidcrew/modules/cyberware/ware_military_body.dm @@ -41,6 +41,16 @@ var/camo_active = FALSE /// Timer for the field's natural collapse. var/camo_timer + /// Alpha the bearer fades to while the field is up. Never 0. + var/field_alpha = CYBERWARE_GHOSTSKIN_ALPHA + /// How long the field holds before it collapses on its own. + var/field_duration = CYBERWARE_GHOSTSKIN_DURATION + /// Alpha the bearer flashes up to for a step when moving under the field. + /// Must stay above field_alpha, motion is meant to make you MORE visible. + var/ripple_alpha = 90 + /// Whether the field takes the bearer out of NPC targeting and scatters a + /// chrome read (TRAIT_CYBER_CAMO). Cheaper weaves only fool players. + var/hides_from_mobs = TRUE /obj/item/organ/cyberimp/cyberware/ghostskin/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -58,13 +68,10 @@ if(camo_active || !wearer) return camo_active = TRUE - ADD_TRAIT(wearer, TRAIT_CYBER_CAMO, REF(src)) - animate(wearer, alpha = CYBERWARE_GHOSTSKIN_ALPHA, time = 1 SECONDS) - playsound(wearer, 'voidcrew/sound/machines/cloaking/on.ogg', 60, TRUE) - wearer.visible_message( - span_warning("[wearer] ripples and thins to a heat-shimmer outline!"), - span_notice("The weave bends the light around you. Ten seconds."), - ) + if(hides_from_mobs) + ADD_TRAIT(wearer, TRAIT_CYBER_CAMO, REF(src)) + animate(wearer, alpha = field_alpha, time = 1 SECONDS) + engage_feedback(wearer) // The MOD stealth break list (modules_ninja.dm), minus bump-off, plus // gunfire, shooting from inside a cloak breaks it here. RegisterSignal(wearer, COMSIG_LIVING_UNARMED_ATTACK, PROC_REF(on_unarmed_attack)) @@ -81,7 +88,16 @@ COMSIG_LIVING_DEATH, ), PROC_REF(on_break_trigger)) RegisterSignal(wearer, COMSIG_MOVABLE_MOVED, PROC_REF(on_camo_move)) - camo_timer = addtimer(CALLBACK(src, PROC_REF(on_field_expired)), CYBERWARE_GHOSTSKIN_DURATION, TIMER_STOPPABLE) + camo_timer = addtimer(CALLBACK(src, PROC_REF(on_field_expired)), field_duration, TIMER_STOPPABLE) + +/// The sound and chat lines that go with the field coming up. Split out so a +/// cheaper weave can tell it differently. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/engage_feedback(mob/living/carbon/wearer) + playsound(wearer, 'voidcrew/sound/machines/cloaking/on.ogg', 60, TRUE) + wearer.visible_message( + span_warning("[wearer] ripples and thins to a heat-shimmer outline!"), + span_notice("The weave bends the light around you. Ten seconds."), + ) /obj/item/organ/cyberimp/cyberware/ghostskin/proc/on_field_expired() camo_timer = null @@ -147,8 +163,8 @@ var/mob/living/wearer = owner if(!wearer || !camo_active) return - animate(wearer, alpha = 90, time = 0.15 SECONDS) - animate(alpha = CYBERWARE_GHOSTSKIN_ALPHA, time = 0.35 SECONDS) + animate(wearer, alpha = ripple_alpha, time = 0.15 SECONDS) + animate(alpha = field_alpha, time = 0.35 SECONDS) /datum/action/cooldown/cyberware/ghostskin_cloak name = "Ghostskin Field" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index 3127a54ff88..c0886935aeb 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -214,6 +214,40 @@ TEST_ASSERT(findtext(silhouette, "SIGNATURES 2"), "A silhouette chrome read didn't report the signature count: [silhouette]") TEST_ASSERT_EQUAL(initial(optics.chrome_scan_resolution), 1, "The street-tier Nightshade isn't set to silhouette resolution") +/// Camo line: the street weave fades its bearer but never takes the +/// cyber_camo trait (NPC targeting and chrome reads key on it, so it fools +/// players only), while the full Ghostskin keeps both. They share the skin +/// slot, so the upgrade evicts the street weave and drops its field. +/// Literals: trait is TRAIT_CYBER_CAMO ("cyber_camo"), alphas are 110 street +/// and 40 full, times are in deciseconds (50 = 5 s), tier 2 = CYBERWARE_TIER_2. +/datum/unit_test/voidcrew_cyberware_camo_line + +/datum/unit_test/voidcrew_cyberware_camo_line/Run() + var/mob/living/carbon/human/lab_rat = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/ghostskin/street/street = allocate(/obj/item/organ/cyberimp/cyberware/ghostskin/street) + TEST_ASSERT_EQUAL(street.chrome_load, 3, "The street camo weave's load drifted") + TEST_ASSERT_EQUAL(street.tier, 2, "The street camo weave left tier 2") + TEST_ASSERT_EQUAL(street.field_duration, 50, "The street camo weave's field duration drifted") + TEST_ASSERT(!street.hides_from_mobs, "The street camo weave hides its bearer from mobs") + TEST_ASSERT(street.Insert(lab_rat, special = TRUE), "Street weave staging insert was refused") + street.engage_camo(lab_rat) + TEST_ASSERT(street.camo_active, "The street weave's field never came up") + TEST_ASSERT_EQUAL(lab_rat.alpha, 110, "The street weave faded its bearer to the wrong alpha") + TEST_ASSERT(!HAS_TRAIT(lab_rat, "cyber_camo"), "The street weave granted the mob-hiding camo trait") + + // The full weave is unchanged: trait, alpha 40, ten seconds. + var/obj/item/organ/cyberimp/cyberware/ghostskin/full = allocate(/obj/item/organ/cyberimp/cyberware/ghostskin) + TEST_ASSERT(full.Insert(lab_rat, special = TRUE), "Ghostskin staging insert was refused") + TEST_ASSERT(!street.camo_active, "Evicting the street weave left its field up") + TEST_ASSERT(!HAS_TRAIT(lab_rat, "cyber_camo"), "Nothing should hold the camo trait between fields") + TEST_ASSERT_EQUAL(full.field_duration, 100, "Ghostskin's field duration drifted") + TEST_ASSERT(full.hides_from_mobs, "Ghostskin stopped hiding its bearer from mobs") + full.engage_camo(lab_rat) + TEST_ASSERT(HAS_TRAIT(lab_rat, "cyber_camo"), "Ghostskin stopped granting the camo trait") + TEST_ASSERT_EQUAL(lab_rat.alpha, 40, "Ghostskin faded its bearer to the wrong alpha") + full.break_camo(lab_rat, silent = TRUE) + TEST_ASSERT(!HAS_TRAIT(lab_rat, "cyber_camo"), "Ghostskin left the camo trait behind when its field dropped") + /// (e) Second Wind Bladder: blocks suffocation while reserve lasts, runs /// dry, and refills in breathable air. /// From 49b0895440721d7b81d4ecf1d527d45ba0fe513a Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:44:16 -0800 Subject: [PATCH 112/472] Add street and milspec models of the Redline core Turn the Redline burn's tuning into vars on the core, and add the Hothead and Vanguard cores as subtypes in ware_line_berserk.dm with their own window, damage cut, punch bonus, stun immunity, roar, crash and cooldown. Includes the Splice SKUs, a street trade-in ledger entry, gear ratings and a unit test. --- tgstation.dme | 1 + tools/gear_catalog/ratings/cyberware.json | 18 +++ voidcrew/modules/cyberware/ware_legend.dm | 114 +++++++++++++++--- .../modules/cyberware/ware_line_berserk.dm | 87 +++++++++++++ .../modules/unit_tests/voidcrew_cyberware.dm | 55 +++++++++ 5 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 voidcrew/modules/cyberware/ware_line_berserk.dm diff --git a/tgstation.dme b/tgstation.dme index ebc01ea314f..6a80e02c4fa 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7183,6 +7183,7 @@ #include "voidcrew\modules\cyberware\ripperdoc_catalog.dm" #include "voidcrew\modules\cyberware\ware_arm_utility.dm" #include "voidcrew\modules\cyberware\ware_legend.dm" +#include "voidcrew\modules\cyberware\ware_line_berserk.dm" #include "voidcrew\modules\cyberware\ware_line_camo.dm" #include "voidcrew\modules\cyberware\ware_line_governor.dm" #include "voidcrew\modules\cyberware\ware_military_arms.dm" diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 9f5f309a8f3..e767df6e8f5 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -455,6 +455,24 @@ "reason": "Twelve seconds in every 90: stun immune, 40% less brute and burn, 8 more on punches, and a roar that staggers everyone within 5 tiles. Then 100 stamina and slow legs. Load 10, 6 vouchers at reputation 22.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/redline/milspec": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Vanguard core", + "rank": 4, + "reason": "Ten seconds in every 105: stun immune, 30% less brute and burn, 5 more on punches, and a roar that staggers everyone within 4 tiles. Then 100 stamina and slow legs. Load 8, 4 vouchers at reputation 11.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/redline/street": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Hothead core", + "rank": 5, + "reason": "Eight seconds in every 120: 20% less brute and burn and 3 more on punches, with no stun immunity and no roar. Then 100 stamina and slow legs. Load 5, 3,000 cr at reputation 4.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/registry_uplink": { "confidence": "low", "group": "Hacking and ship control", diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index db50e79d8c2..9dd41e0e678 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -281,6 +281,29 @@ aug_overlay = "redline" emissive_overlay = TRUE actions_types = list(/datum/action/cooldown/cyberware/redline_burn) + // The burn's tuning lives on the organ so a cheaper or rougher model (see + // ware_line_berserk.dm) retunes it by setting vars. The defaults below ARE + // the Redline core's numbers. The defines stay as those defaults. + /// Length of the burn window. + var/burn_window = CYBERWARE_REDLINE_WINDOW + /// Incoming brute/burn multiplier during the window. + var/burn_damage_mult = CYBERWARE_REDLINE_DAMAGE_MULT + /// Flat bonus to unarmed strikes during the window. + var/burn_melee_bonus = CYBERWARE_REDLINE_MELEE_BONUS + /// Whether the window grants stun and baton immunity. + var/burn_stun_immune = TRUE + /// Roar reach in tiles. 0 keeps the announcement but staggers nobody. + var/burn_roar_range = CYBERWARE_REDLINE_ROAR_RANGE + /// Crash slowdown duration. + var/burn_crash = CYBERWARE_REDLINE_CRASH + /// Stamina dumped by the crash. + var/burn_crash_stamina = CYBERWARE_REDLINE_CRASH_STAMINA + /// Cooldown on the Redline Burn button. + var/burn_cooldown = 90 SECONDS + /// Button tooltip. Null keeps the Redline Burn action's own text. + var/burn_action_desc + /// Status alert text while the window is open. Null keeps the alert's own text. + var/burn_alert_desc /obj/item/organ/cyberimp/cyberware/redline/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -318,6 +341,16 @@ cooldown_time = 90 SECONDS click_to_activate = FALSE +/datum/action/cooldown/cyberware/redline_burn/New(Target, original = TRUE) + . = ..() + // The core this button belongs to sets its own cooldown and tooltip. + var/obj/item/organ/cyberimp/cyberware/redline/core = organ + if(!istype(core)) + return + cooldown_time = core.burn_cooldown + if(core.burn_action_desc) + desc = core.burn_action_desc + /datum/action/cooldown/cyberware/redline_burn/Activate(atom/target) if(!isliving(owner)) return FALSE @@ -326,7 +359,7 @@ burner.balloon_alert(burner, "core still venting!") return FALSE StartCooldown() - burner.apply_status_effect(/datum/status_effect/cyberware_redline_window) + burner.apply_status_effect(/datum/status_effect/cyberware_redline_window, organ) return TRUE /** @@ -382,20 +415,52 @@ /// roll, so the next arming is skipped. See suppress_topup() for why this /// can't just clear the armed state. var/topup_suppressed = FALSE + // Tuning. The defaults are the Redline core's own numbers; on_creation() + // replaces them with the installed core's when one is passed in. + /// Incoming brute/burn multiplier. + var/damage_mult = CYBERWARE_REDLINE_DAMAGE_MULT + /// Flat bonus to unarmed strikes. + var/melee_bonus = CYBERWARE_REDLINE_MELEE_BONUS + /// Whether the window grants stun and baton immunity. + var/stun_immune = TRUE + /// Roar reach in tiles, 0 for the announcement only. + var/roar_range = CYBERWARE_REDLINE_ROAR_RANGE + /// Handed to the crash this window rolls into. + var/crash_duration = CYBERWARE_REDLINE_CRASH + var/crash_stamina = CYBERWARE_REDLINE_CRASH_STAMINA + /// Replaces the alert's description, if set. + var/alert_desc + +/// Pass the core whose numbers this burn runs on. Without one the window uses +/// the Redline core's own. +/datum/status_effect/cyberware_redline_window/on_creation(mob/living/new_owner, obj/item/organ/cyberimp/cyberware/redline/core) + if(istype(core)) + duration = core.burn_window + damage_mult = core.burn_damage_mult + melee_bonus = core.burn_melee_bonus + stun_immune = core.burn_stun_immune + roar_range = core.burn_roar_range + crash_duration = core.burn_crash + crash_stamina = core.burn_crash_stamina + alert_desc = core.burn_alert_desc + . = ..() + if(. && linked_alert && alert_desc) + linked_alert.desc = alert_desc /datum/status_effect/cyberware_redline_window/on_apply() // The anti-stun implant's buff pattern: traits plus damage-slowdown immunity. - owner.add_traits(list(TRAIT_STUNIMMUNE, TRAIT_BATON_RESISTANCE), id) + if(stun_immune) + owner.add_traits(list(TRAIT_STUNIMMUNE, TRAIT_BATON_RESISTANCE), id) owner.add_movespeed_mod_immunities(id, /datum/movespeed_modifier/damage_slowdown) if(ishuman(owner)) var/mob/living/carbon/human/human_owner = owner - human_owner.physiology.brute_mod *= CYBERWARE_REDLINE_DAMAGE_MULT - human_owner.physiology.burn_mod *= CYBERWARE_REDLINE_DAMAGE_MULT + human_owner.physiology.brute_mod *= damage_mult + human_owner.physiology.burn_mod *= damage_mult if(iscarbon(owner)) var/mob/living/carbon/carbon_owner = owner for(var/obj/item/bodypart/arm/limb in carbon_owner.bodyparts) - limb.unarmed_damage_low += CYBERWARE_REDLINE_MELEE_BONUS - limb.unarmed_damage_high += CYBERWARE_REDLINE_MELEE_BONUS + limb.unarmed_damage_low += melee_bonus + limb.unarmed_damage_high += melee_bonus buffed_parts += limb RegisterSignal(owner, COMSIG_LIVING_EARLY_UNARMED_ATTACK, PROC_REF(on_early_unarmed_attack)) RegisterSignal(owner, COMSIG_LIVING_UNARMED_ATTACK, PROC_REF(on_unarmed_attack)) @@ -405,24 +470,25 @@ return TRUE /datum/status_effect/cyberware_redline_window/on_remove() - owner.remove_traits(list(TRAIT_STUNIMMUNE, TRAIT_BATON_RESISTANCE), id) + if(stun_immune) + owner.remove_traits(list(TRAIT_STUNIMMUNE, TRAIT_BATON_RESISTANCE), id) owner.remove_movespeed_mod_immunities(id, /datum/movespeed_modifier/damage_slowdown) if(ishuman(owner)) var/mob/living/carbon/human/human_owner = owner - human_owner.physiology.brute_mod /= CYBERWARE_REDLINE_DAMAGE_MULT - human_owner.physiology.burn_mod /= CYBERWARE_REDLINE_DAMAGE_MULT + human_owner.physiology.brute_mod /= damage_mult + human_owner.physiology.burn_mod /= damage_mult for(var/obj/item/bodypart/arm/limb as anything in buffed_parts) if(QDELETED(limb)) continue - limb.unarmed_damage_low -= CYBERWARE_REDLINE_MELEE_BONUS - limb.unarmed_damage_high -= CYBERWARE_REDLINE_MELEE_BONUS + limb.unarmed_damage_low -= melee_bonus + limb.unarmed_damage_high -= melee_bonus buffed_parts.Cut() UnregisterSignal(owner, list(COMSIG_LIVING_EARLY_UNARMED_ATTACK, COMSIG_LIVING_UNARMED_ATTACK)) bypassed_victim = null bypassed_penetration = 0 owner.remove_client_colour(id) if(!QDELETED(owner) && owner.stat != DEAD) - owner.apply_status_effect(/datum/status_effect/cyberware_redline_crash) + owner.apply_status_effect(/datum/status_effect/cyberware_redline_crash, crash_duration, crash_stamina) return ..() /** @@ -436,11 +502,18 @@ return playsound(owner, 'sound/effects/magic/demon_attack1.ogg', 90, TRUE) playsound(owner, 'sound/effects/magic/repulse.ogg', 60, TRUE) + if(roar_range <= 0) + // A core with no roar behind it still announces itself, just doesn't shake anything. + owner.visible_message( + span_boldwarning("[owner]'s eyes flood red and the speaker in [owner.p_their()] chest blares!"), + span_boldwarning("Your chest speaker blares."), + ) + return owner.visible_message( span_boldwarning("[owner]'s eyes flood red and a speaker-drone ROAR shakes the deck!"), span_boldwarning("You ROAR."), ) - for(var/mob/living/victim in oview(CYBERWARE_REDLINE_ROAR_RANGE, owner)) + for(var/mob/living/victim in oview(roar_range, owner)) if(victim.stat == DEAD) continue if(cyberware_is_ally(owner, victim)) @@ -551,7 +624,7 @@ return var/target_zone = victim.get_random_valid_zone(owner.zone_selected) victim.apply_damage( - CYBERWARE_REDLINE_MELEE_BONUS, + melee_bonus, BRUTE, target_zone, victim.run_armor_check(target_zone, MELEE, armour_penetration = penetration, silent = TRUE), @@ -569,10 +642,21 @@ duration = CYBERWARE_REDLINE_CRASH tick_interval = STATUS_EFFECT_NO_TICK alert_type = null + /// Stamina dumped on apply. + var/stamina_dump = CYBERWARE_REDLINE_CRASH_STAMINA + +/// The window hands over its core's crash length and stamina dump. Without +/// them the crash is the Redline core's own. +/datum/status_effect/cyberware_redline_crash/on_creation(mob/living/new_owner, new_duration, new_stamina_dump) + if(!isnull(new_duration)) + duration = new_duration + if(!isnull(new_stamina_dump)) + stamina_dump = new_stamina_dump + return ..() /datum/status_effect/cyberware_redline_crash/on_apply() owner.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_redline_crash) - owner.adjustStaminaLoss(CYBERWARE_REDLINE_CRASH_STAMINA) + owner.adjustStaminaLoss(stamina_dump) owner.add_client_colour(/datum/client_colour/cyberware_redline_crash, id) owner.visible_message( span_warning("[owner] sags as the red drains out of [owner.p_their()] eyes."), diff --git a/voidcrew/modules/cyberware/ware_line_berserk.dm b/voidcrew/modules/cyberware/ware_line_berserk.dm new file mode 100644 index 00000000000..68a26efe049 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_berserk.dm @@ -0,0 +1,87 @@ +/** + * # The Berserk line: cheaper cores for the Redline slot + * + * Two earlier models of the Redline core. Same chest OS slot as the Redline + * (and Cascade), so fitting one evicts the other. Same burn, retuned: every + * number lives in a var on /obj/item/organ/cyberimp/cyberware/redline + * (ware_legend.dm) and a model sets its own here. + * + * | | window | damage | punch | stun immune | roar | crash | cooldown | load | + * |---|---|---|---|---|---|---|---|---| + * | street | 8 s | x0.8 | +3 | no | none | 6 s | 120 s | 5 | + * | milspec | 10 s | x0.7 | +5 | yes | 4 tiles | 5 s | 105 s | 8 | + * | Redline | 12 s | x0.6 | +8 | yes | 5 tiles | 5 s | 90 s | 10 | + * + * The chest speaker announcement stays on every model: it is the telegraph. + * The street core has no roar to follow it, so it announces and staggers + * nobody. Crash stamina stays at the Redline's 100 on all three. + */ + +/// Street: bench-built, sold for credits. Burns for less time, softens less and +/// does not hold off stuns. +/obj/item/organ/cyberimp/cyberware/redline/street + name = "\improper Hothead core" + desc = "A combat governor soldered together on a back-room bench out of parts from two others. The chest speaker rattles when it runs." + icon_state = "redline_street" + chrome_load = 5 + tier = CYBERWARE_TIER_2 + aug_overlay = "redline_street" + burn_window = 8 SECONDS + burn_damage_mult = 0.8 + burn_melee_bonus = 3 + burn_stun_immune = FALSE + burn_roar_range = 0 + burn_crash = 6 SECONDS + burn_cooldown = 120 SECONDS + burn_action_desc = "Eight seconds of damage resistance and harder fists, announced by the chest speaker. Ends in a stamina collapse. An EMP mid-burn collapses you instantly." + burn_alert_desc = "The safeties are loosened, not gone. Hits don't hurt as much and your fists hit harder. You collapse when it ends." + +/// Milspec: surplus assault issue. Close to the Redline, a size smaller. +/obj/item/organ/cyberimp/cyberware/redline/milspec + name = "\improper Vanguard core" + desc = "A surplus assault governor, still stencilled with someone else's unit number. The chest speaker is military grade, which means loud." + icon_state = "redline_milspec" + chrome_load = 8 + tier = CYBERWARE_TIER_3 + aug_overlay = "redline_milspec" + burn_window = 10 SECONDS + burn_damage_mult = 0.7 + burn_melee_bonus = 5 + burn_stun_immune = TRUE + burn_roar_range = 4 + burn_crash = 5 SECONDS + burn_cooldown = 105 SECONDS + burn_action_desc = "Ten seconds of stun immunity, 30% damage resistance and harder fists, opened with a staggering roar. Ends in a stamina collapse. An EMP mid-burn collapses you instantly." + burn_alert_desc = "The safeties are off. Stuns don't land, hits don't hurt as much and your fists hit harder. You collapse when it ends." + +// ========================================================================= +// SPLICE SKUs (wired into the vendors at integration, not here) +// ========================================================================= + +/datum/shop_sku/ripperdoc/redline_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/redline/street + price_credits = 3000 + +/datum/shop_sku/ripperdoc/redline_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/redline/milspec + price_vouchers = 4 + +// ========================================================================= +// TRADE-IN: the street model only +// Half list, credits only, exact type. The milspec core is paid for in +// vouchers and is never bought back. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/redline_street + name = "Hothead Core (pulled)" + desc = "A pulled rage governor, scorched where the safeties used to be." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/redline/street + match_subtypes = FALSE + pay_credits = 1500 + demand_min = 1 + demand_max = 2 diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index c0886935aeb..f13c2335a68 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -1118,6 +1118,61 @@ TEST_ASSERT_EQUAL(get_chrome_load(patient), 16, "Deleting nanites retained installed program costs") TEST_ASSERT_EQUAL(patient.physiology.armor.get_rating(MELEE), bare_armor, "Deleting recovered nanites retained their armor") +/// The Berserk line: street and milspec cores share the Redline's OS slot and +/// retune its one burn through vars, and the Redline core itself is unchanged. +/datum/unit_test/voidcrew_berserk_line + +/datum/unit_test/voidcrew_berserk_line/Run() + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/redline/core = allocate(/obj/item/organ/cyberimp/cyberware/redline) + TEST_ASSERT_EQUAL(core.burn_window, 12 SECONDS, "Redline core's window moved") + TEST_ASSERT_EQUAL(core.burn_damage_mult, 0.6, "Redline core's damage multiplier moved") + TEST_ASSERT_EQUAL(core.burn_melee_bonus, 8, "Redline core's punch bonus moved") + TEST_ASSERT(core.burn_stun_immune, "Redline core lost its stun immunity") + TEST_ASSERT_EQUAL(core.burn_roar_range, 5, "Redline core's roar moved") + TEST_ASSERT_EQUAL(core.burn_crash, 5 SECONDS, "Redline core's crash moved") + TEST_ASSERT_EQUAL(core.burn_cooldown, 90 SECONDS, "Redline core's cooldown moved") + TEST_ASSERT_EQUAL(core.chrome_load, 10, "Redline core's load moved") + for(var/model_type in list(/obj/item/organ/cyberimp/cyberware/redline/street, /obj/item/organ/cyberimp/cyberware/redline/milspec)) + var/obj/item/organ/cyberimp/cyberware/redline/model = allocate(model_type) + TEST_ASSERT_EQUAL(model.slot, core.slot, "[model_type] doesn't share the Redline's OS slot") + TEST_ASSERT(model.chrome_load < core.chrome_load, "[model_type] doesn't cost less load than the Redline core") + TEST_ASSERT(model.tier < core.tier, "[model_type] isn't a lower tier than the Redline core") + TEST_ASSERT(model.burn_window < core.burn_window && model.burn_cooldown > core.burn_cooldown, "[model_type] isn't a shorter, slower burn than the Redline core") + TEST_ASSERT(model.icon_state != core.icon_state && model.aug_overlay != core.aug_overlay, "[model_type] reuses the Redline core's sprite states") + var/obj/item/organ/cyberimp/cyberware/redline/street/street = allocate(/obj/item/organ/cyberimp/cyberware/redline/street) + var/obj/item/organ/cyberimp/cyberware/redline/milspec/milspec = allocate(/obj/item/organ/cyberimp/cyberware/redline/milspec) + for(var/obj/item/organ/cyberimp/cyberware/redline/model as anything in list(street, milspec)) + var/datum/action/cooldown/cyberware/redline_burn/burn = locate() in model.actions + TEST_ASSERT_NOTNULL(burn, "[model.type] has no Redline Burn button") + TEST_ASSERT_EQUAL(burn.cooldown_time, model.burn_cooldown, "[model.type]'s button doesn't use the core's cooldown") + // The street burn: no stun immunity, its own damage cut, window and crash length. + patient.apply_status_effect(/datum/status_effect/cyberware_redline_window, street) + var/datum/status_effect/cyberware_redline_window/window = patient.has_status_effect(/datum/status_effect/cyberware_redline_window) + TEST_ASSERT_NOTNULL(window, "The street core didn't open a burn window") + TEST_ASSERT(!HAS_TRAIT(patient, TRAIT_STUNIMMUNE), "The street burn granted stun immunity") + TEST_ASSERT_EQUAL(patient.physiology.brute_mod, 0.8, "The street burn didn't cut brute by its own multiplier") + TEST_ASSERT_EQUAL(window.duration - world.time, street.burn_window, "The street burn ignored its window length") + patient.remove_status_effect(/datum/status_effect/cyberware_redline_window) + TEST_ASSERT_EQUAL(patient.physiology.brute_mod, 1, "The street burn left a damage multiplier behind") + var/datum/status_effect/cyberware_redline_crash/crash = patient.has_status_effect(/datum/status_effect/cyberware_redline_crash) + TEST_ASSERT_NOTNULL(crash, "The street burn didn't roll into a crash") + TEST_ASSERT(crash.duration - world.time <= street.burn_crash && crash.duration - world.time > street.burn_crash - 1 SECONDS, "The street crash ignored its length") + patient.remove_status_effect(/datum/status_effect/cyberware_redline_crash) + // The milspec burn holds stuns off, and drops them cleanly. + patient.apply_status_effect(/datum/status_effect/cyberware_redline_window, milspec) + TEST_ASSERT(HAS_TRAIT(patient, TRAIT_STUNIMMUNE), "The milspec burn didn't grant stun immunity") + TEST_ASSERT_EQUAL(patient.physiology.brute_mod, 0.7, "The milspec burn didn't cut brute by its own multiplier") + patient.remove_status_effect(/datum/status_effect/cyberware_redline_window) + TEST_ASSERT(!HAS_TRAIT(patient, TRAIT_STUNIMMUNE), "The milspec burn kept stun immunity after it ended") + TEST_ASSERT_EQUAL(patient.physiology.brute_mod, 1, "The milspec burn left a damage multiplier behind") + patient.remove_status_effect(/datum/status_effect/cyberware_redline_crash) + // With no core named the window runs the Redline core's own numbers. + patient.apply_status_effect(/datum/status_effect/cyberware_redline_window) + TEST_ASSERT_EQUAL(patient.physiology.brute_mod, 0.6, "A bare burn window didn't use the Redline core's numbers") + patient.remove_status_effect(/datum/status_effect/cyberware_redline_window) + patient.remove_status_effect(/datum/status_effect/cyberware_redline_crash) + /datum/unit_test/voidcrew_cyberware_running_gates /datum/unit_test/voidcrew_cyberware_running_gates/Run() From 9e754ec31272a206a40e08668f30750219b25e50 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:45:05 -0800 Subject: [PATCH 113/472] Add street and service-grade models of the Cascade lattice The Cascade lattice's window and crash numbers become vars on the organ, copied onto the status effects when the button is pressed. Two cheaper subtypes share its OS slot: a credit-priced street rig and a voucher-priced service lattice, with their own SKUs, trade-in entry, ratings and tests. --- tgstation.dme | 1 + tools/gear_catalog/ratings/cyberware.json | 18 +++ voidcrew/modules/cyberware/ware_legend.dm | 115 ++++++++++++++-- voidcrew/modules/cyberware/ware_line_sandy.dm | 125 ++++++++++++++++++ .../modules/unit_tests/voidcrew_cyberware.dm | 57 ++++++++ .../modules/unit_tests/voidcrew_zone_shops.dm | 4 +- 6 files changed, 305 insertions(+), 15 deletions(-) create mode 100644 voidcrew/modules/cyberware/ware_line_sandy.dm diff --git a/tgstation.dme b/tgstation.dme index 6a80e02c4fa..b6a3824ddb4 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7186,6 +7186,7 @@ #include "voidcrew\modules\cyberware\ware_line_berserk.dm" #include "voidcrew\modules\cyberware\ware_line_camo.dm" #include "voidcrew\modules\cyberware\ware_line_governor.dm" +#include "voidcrew\modules\cyberware\ware_line_sandy.dm" #include "voidcrew\modules\cyberware\ware_military_arms.dm" #include "voidcrew\modules\cyberware\ware_military_body.dm" #include "voidcrew\modules\cyberware\ware_pro_combat.dm" diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index e767df6e8f5..06260b3ba64 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -271,6 +271,24 @@ "reason": "Eight seconds a minute at about double speed, melee twice as fast and 40% dodge while moving, then six seconds of crash and 80 stamina. Load 12 of 20, 8 vouchers at reputation 22. An EMP mid-burst ends it.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/cascade/milspec": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Service reflex lattice", + "rank": 4, + "reason": "Seven seconds in every 90 at close to the lattice's speed, melee 40% quicker and 25% dodge while moving, then seven seconds of crash and 90 stamina. Load 9, 4 vouchers at reputation 11. An EMP mid-burst ends it.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/cascade/street": { + "confidence": "medium", + "group": "Operating system", + "hash": "", + "name": "Spliced reflex rig", + "rank": 5, + "reason": "Six seconds in every 120 at a clear speed boost, melee a quarter quicker and 15% dodge while moving, then eight seconds of crash and 100 stamina. Load 6, 3,600 cr at reputation 4. An EMP mid-burst ends it.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/chromatic_dermis": { "confidence": "high", "group": "Body upkeep", diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 9dd41e0e678..2ac37e5c48a 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -26,6 +26,10 @@ #define CYBERWARE_CASCADE_CRASH_STAMINA 80 /// Degrees the afterimage hue moves per step of the trail. #define CYBERWARE_CASCADE_HUE_STEP 25 +/// Melee-cooldown multiplier during the window (lower is faster). +#define CYBERWARE_CASCADE_MELEE_MULT 0.5 +/// Starting opacity of a trail afterimage. +#define CYBERWARE_CASCADE_AFTERIMAGE_ALPHA 200 /** * # Cascade Lattice (T4, chest, OS slot, load 12) @@ -41,6 +45,11 @@ * Then the heat bill: six seconds of crash, heavy stamina dump, leaden * slowdown, zero dodge. An EMP during the window skips straight to the * crash and re-arms the full sixty-second cooldown. + * + * Every number the window and the crash run on is a var here, so the lesser + * models in ware_line_sandy.dm retune by setting vars and swapping the action + * for a slower one. The status effects copy the numbers off the organ when the + * button is pressed and hold no reference back to it. */ /obj/item/organ/cyberimp/cyberware/cascade name = "\improper Cascade lattice" @@ -54,6 +63,28 @@ aug_overlay = "cascade" emissive_overlay = TRUE actions_types = list(/datum/action/cooldown/cyberware/cascade_surge) + /// How long the window lasts. + var/window_duration = CYBERWARE_CASCADE_WINDOW + /// How long the crash lasts once the window ends. + var/crash_duration = CYBERWARE_CASCADE_CRASH + /// Stamina dumped on the bearer by the crash. + var/crash_stamina = CYBERWARE_CASCADE_CRASH_STAMINA + /// Ranged dodge chance while moving during the window. + var/window_dodge = CYBERWARE_CASCADE_DODGE_CHANCE + /// The gait the window grants. + var/datum/movespeed_modifier/window_speed = /datum/movespeed_modifier/cyberware_cascade + /// Melee-cooldown multiplier during the window (lower is faster). + var/window_melee_mult = CYBERWARE_CASCADE_MELEE_MULT + /// The bearer's screen tint during the window. + var/datum/client_colour/window_colour = /datum/client_colour/cyberware_cascade + /// A trail afterimage is left on every Nth step. + var/afterimage_interval = 1 + /// Starting opacity of a trail afterimage. + var/afterimage_alpha = CYBERWARE_CASCADE_AFTERIMAGE_ALPHA + /// The status alert shown while the window runs. + var/window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade + /// What the bearer reads on activation. + var/online_message = "CASCADE ONLINE. Eight seconds." /obj/item/organ/cyberimp/cyberware/cascade/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -97,7 +128,8 @@ surger.balloon_alert(surger, "lattice still cycling!") return FALSE StartCooldown() - surger.apply_status_effect(/datum/status_effect/cyberware_cascade_window) + // The lattice hands the window its numbers (an organ-less call falls back to the T4 defaults). + surger.apply_status_effect(/datum/status_effect/cyberware_cascade_window, organ) return TRUE /** @@ -112,40 +144,77 @@ alert_type = /atom/movable/screen/alert/status_effect/cyberware_cascade /// Hue of the next afterimage, in degrees. Starts random so no two surges match. var/trail_hue = 0 + // The numbers below default to the T4 lattice and are overwritten from the + // organ that opened the window (see on_creation). Copied, never referenced. + /// Ranged dodge chance while the window lives. + var/dodge_chance = CYBERWARE_CASCADE_DODGE_CHANCE + /// The gait the window grants. + var/datum/movespeed_modifier/speed_type = /datum/movespeed_modifier/cyberware_cascade + /// Melee-cooldown multiplier (lower is faster). + var/melee_mult = CYBERWARE_CASCADE_MELEE_MULT + /// The bearer's screen tint. + var/datum/client_colour/colour_type = /datum/client_colour/cyberware_cascade + /// A trail afterimage is left on every Nth step. + var/afterimage_interval = 1 + /// Starting opacity of a trail afterimage. + var/afterimage_alpha = CYBERWARE_CASCADE_AFTERIMAGE_ALPHA + /// What the bearer reads on activation. + var/online_message = "CASCADE ONLINE. Eight seconds." + /// The crash this window rolls into. + var/crash_duration = CYBERWARE_CASCADE_CRASH + var/crash_stamina = CYBERWARE_CASCADE_CRASH_STAMINA + /// Steps taken so far, for the afterimage cadence. + var/steps_taken = 0 + +/// Optional second argument: the lattice whose numbers this window runs on. +/datum/status_effect/cyberware_cascade_window/on_creation(mob/living/new_owner, obj/item/organ/cyberimp/cyberware/cascade/lattice) + if(istype(lattice)) + duration = lattice.window_duration + alert_type = lattice.window_alert + dodge_chance = lattice.window_dodge + speed_type = lattice.window_speed + melee_mult = lattice.window_melee_mult + colour_type = lattice.window_colour + afterimage_interval = lattice.afterimage_interval + afterimage_alpha = lattice.afterimage_alpha + online_message = lattice.online_message + crash_duration = lattice.crash_duration + crash_stamina = lattice.crash_stamina + return ..() /datum/status_effect/cyberware_cascade_window/on_apply() trail_hue = rand(0, 359) - owner.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade) + owner.add_movespeed_modifier(speed_type) owner.add_movespeed_mod_immunities(id, /datum/movespeed_modifier/reagent) cyberware_register_dodge_source(owner, id, CALLBACK(src, PROC_REF(get_dodge_chance))) RegisterSignal(owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_move)) - owner.add_client_colour(/datum/client_colour/cyberware_cascade, id) + owner.add_client_colour(colour_type, id) playsound(owner, 'sound/effects/magic/timeparadox2.ogg', 70, TRUE) owner.visible_message( span_boldwarning("[owner] fractures into afterimages. Something under [owner.p_their()] skin is running far too fast!"), - span_boldnotice("CASCADE ONLINE. Eight seconds."), + span_boldnotice(online_message), ) owner.balloon_alert(owner, "cascade online") return TRUE /datum/status_effect/cyberware_cascade_window/on_remove() - owner.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade) + owner.remove_movespeed_modifier(speed_type) owner.remove_movespeed_mod_immunities(id, /datum/movespeed_modifier/reagent) cyberware_unregister_dodge_source(owner, id) UnregisterSignal(owner, COMSIG_MOVABLE_MOVED) owner.remove_client_colour(id) if(!QDELETED(owner) && owner.stat != DEAD) - owner.apply_status_effect(/datum/status_effect/cyberware_cascade_crash) + owner.apply_status_effect(/datum/status_effect/cyberware_cascade_crash, crash_duration, crash_stamina) return ..() -/// Melee cooldowns halved for the window (changeNext_move multiplies these in). +/// Melee cooldowns scaled for the window (changeNext_move multiplies these in). /datum/status_effect/cyberware_cascade_window/nextmove_modifier() - return 0.5 + return melee_mult -/// Dodge-source callback for the shared arbiter: flat 40 while the window +/// Dodge-source callback for the shared arbiter: flat chance while the window /// lives (the arbiter itself enforces moving-only and point-blank rules). /datum/status_effect/cyberware_cascade_window/proc/get_dodge_chance() - return CYBERWARE_CASCADE_DODGE_CHANCE + return dodge_chance /// Signal proc for [COMSIG_MOVABLE_MOVED]: one afterimage per step, the /// telegraph everyone in the fight can read. Each step shifts the hue, so the @@ -154,16 +223,19 @@ SIGNAL_HANDLER if(!isturf(old_loc)) return - new /obj/effect/temp_visual/decoy/fading/cascade(old_loc, owner, trail_hue) + steps_taken++ + if(steps_taken % afterimage_interval) // cheaper chrome leaves a sparser trail + return + new /obj/effect/temp_visual/decoy/fading/cascade(old_loc, owner, trail_hue, afterimage_alpha) trail_hue = (trail_hue + CYBERWARE_CASCADE_HUE_STEP) % 360 /// One afterimage in the Cascade trail, washed in a single hue. /obj/effect/temp_visual/decoy/fading/cascade duration = 8 -/obj/effect/temp_visual/decoy/fading/cascade/Initialize(mapload, atom/mimiced_atom, hue = 0) +/obj/effect/temp_visual/decoy/fading/cascade/Initialize(mapload, atom/mimiced_atom, hue = 0, start_alpha = CYBERWARE_CASCADE_AFTERIMAGE_ALPHA) . = ..() - alpha = 200 + alpha = start_alpha color = cascade_tint_matrix(hue) animate(src, alpha = 0, time = duration) // restart the parent's fade from the new alpha @@ -188,16 +260,31 @@ desc = "The lattice has the world in slow motion. Make it count. Six seconds of crash follows either way." icon_state = "radiation_shield" +/// The lesser models' alert (ware_line_sandy.dm), with no crash timer in the text. +/atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket + name = "Reflex Window" + desc = "The rig has the world in slow motion. Make it count. The crash is coming either way." + /// The bill. No dodge source, no speed, the opposite of all of it. /datum/status_effect/cyberware_cascade_crash id = "cyberware_cascade_crash" duration = CYBERWARE_CASCADE_CRASH tick_interval = STATUS_EFFECT_NO_TICK alert_type = null + /// Stamina dumped on the bearer when the crash lands. + var/crash_stamina = CYBERWARE_CASCADE_CRASH_STAMINA + +/// Optional arguments: crash length and stamina dump, from the window that ended. +/datum/status_effect/cyberware_cascade_crash/on_creation(mob/living/new_owner, crash_duration, stamina_dump) + if(isnum(crash_duration)) + duration = crash_duration + if(isnum(stamina_dump)) + crash_stamina = stamina_dump + return ..() /datum/status_effect/cyberware_cascade_crash/on_apply() owner.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade_crash) - owner.adjustStaminaLoss(CYBERWARE_CASCADE_CRASH_STAMINA) + owner.adjustStaminaLoss(crash_stamina) owner.add_client_colour(/datum/client_colour/cyberware_cascade_crash, id) playsound(owner, 'sound/machines/warning-buzzer.ogg', 50, TRUE) owner.visible_message( diff --git a/voidcrew/modules/cyberware/ware_line_sandy.dm b/voidcrew/modules/cyberware/ware_line_sandy.dm new file mode 100644 index 00000000000..eba5b85007f --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_sandy.dm @@ -0,0 +1,125 @@ +/** + * # The Cascade line: street and service-grade reflex rigs + * + * Two cheaper ways into the chest OS slot's speed core, both subtypes of the + * Cascade Lattice (ware_legend.dm) so they share its slot, its EMP rule, its + * crash-on-removal rule and its window code. Installing any of the three + * evicts the other two, and the Redline family sits in the same slot, so a + * crew climbs this ladder by swapping in the next rung. + * + * Each rung trades window strength for a worse bill: the lower the model, the + * slower the gait, the thinner the dodge and the melee boost, and the longer + * and nastier the crash. Window length stays nearly flat. The telegraphs are + * the same at every rung (the activation, the afterimages, the crash), just + * duller and sparser on the cheap chrome. + * + * model window gait dodge melee crash stamina cooldown load + * street 6 s -0.5 15 x0.75 8 s 100 120 s 6 + * service 7 s -0.75 25 x0.6 7 s 90 90 s 9 + * Cascade T4 8 s -1 40 x0.5 6 s 80 60 s 12 + * + * Sold at Splice's counter. The street model is credits, behind the yellow + * step, and trades in at the Intake ledger; the service model is vouchers + * behind the advanced step. + */ + +// ========================================================================= +// STREET MODEL (T2) +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/cascade/street + name = "spliced reflex rig" + desc = "A spinal reflex rig cobbled together from salvaged parts and a lot of tape. The scorch marks came with it." + icon_state = "cascade_street" + chrome_load = 6 + tier = CYBERWARE_TIER_2 + aug_overlay = "cascade_street" + actions_types = list(/datum/action/cooldown/cyberware/cascade_surge/street) + window_duration = 6 SECONDS + crash_duration = 8 SECONDS + crash_stamina = 100 + window_dodge = 15 + window_speed = /datum/movespeed_modifier/cyberware_cascade/street + window_melee_mult = 0.75 + window_colour = /datum/client_colour/cyberware_cascade/street + afterimage_interval = 3 + afterimage_alpha = 120 + window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket + online_message = "REFLEX RIG ONLINE." + +/datum/action/cooldown/cyberware/cascade_surge/street + name = "Reflex Surge" + desc = "Six seconds of speed: melee cooldowns cut by a quarter, 15% chance to dodge ranged fire while moving. Then eight seconds of crash. An EMP mid-window crashes you instantly." + cooldown_time = 120 SECONDS + +/datum/movespeed_modifier/cyberware_cascade/street + multiplicative_slowdown = -0.5 + +/// The street rig's screen: the lattice's tint, a quarter as strong. +/datum/client_colour/cyberware_cascade/street + color = list(1.025, 0, 0.02, 0.0125, 1.0125, 0.025, 0.025, 0, 1.05) + +// ========================================================================= +// SERVICE MODEL (T3) +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/cascade/milspec + name = "service reflex lattice" + desc = "A decommissioned military reflex lattice. The serial plate has been ground off, but the stencilling on the casing is still there." + icon_state = "cascade_milspec" + chrome_load = 9 + tier = CYBERWARE_TIER_3 + aug_overlay = "cascade_milspec" + actions_types = list(/datum/action/cooldown/cyberware/cascade_surge/milspec) + window_duration = 7 SECONDS + crash_duration = 7 SECONDS + crash_stamina = 90 + window_dodge = 25 + window_speed = /datum/movespeed_modifier/cyberware_cascade/milspec + window_melee_mult = 0.6 + window_colour = /datum/client_colour/cyberware_cascade/milspec + afterimage_interval = 2 + afterimage_alpha = 160 + window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket + online_message = "LATTICE ONLINE." + +/datum/action/cooldown/cyberware/cascade_surge/milspec + name = "Reflex Surge" + desc = "Seven seconds of speed: melee cooldowns cut by 40%, 25% chance to dodge ranged fire while moving. Then seven seconds of crash. An EMP mid-window crashes you instantly." + cooldown_time = 90 SECONDS + +/datum/movespeed_modifier/cyberware_cascade/milspec + multiplicative_slowdown = -0.75 + +/// The service lattice's screen: the lattice's tint, half as strong. +/datum/client_colour/cyberware_cascade/milspec + color = list(1.05, 0, 0.04, 0.025, 1.025, 0.05, 0.05, 0, 1.1) + +// ========================================================================= +// SPLICE SKUs and INTAKE LEDGER +// The lead wires these into /datum/outpost_shop/vendor/ripperdoc (sku_types, +// buyback_types) and the zone loot tables at integration. +// ========================================================================= + +/datum/shop_sku/ripperdoc/cascade_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Pro Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/cascade/street + price_credits = 3600 + +/datum/shop_sku/ripperdoc/cascade_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/cascade/milspec + price_vouchers = 4 + +/// Half list, credits only, like the rest of the ladder's street rungs. +/datum/shop_buyback/ripperdoc/cascade_street + name = "Spliced Reflex Rig (pulled)" + desc = "A taped-together spinal rig. Splice strips it for parts toward a proper lattice." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/cascade/street + match_subtypes = FALSE + pay_credits = 1800 + demand_min = 1 + demand_max = 2 diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index f13c2335a68..a09f9447fd6 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -1270,3 +1270,60 @@ node.Remove(patient, TRUE) node.Insert(patient, TRUE) TEST_ASSERT_EQUAL(node.jolt_cooldown, ready_at, "Removal/reinstall reset Lazarus cooldown") + +/// The Cascade line: the street and service models share the lattice's OS slot, +/// carry their own numbers onto the window and crash, and the original lattice +/// keeps every one of its. +/datum/unit_test/voidcrew_cascade_line + +/datum/unit_test/voidcrew_cascade_line/Run() + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = allocate(/obj/item/organ/cyberimp/cyberware/cascade) + var/obj/item/organ/cyberimp/cyberware/cascade/street/street = allocate(/obj/item/organ/cyberimp/cyberware/cascade/street) + var/obj/item/organ/cyberimp/cyberware/cascade/milspec/milspec = allocate(/obj/item/organ/cyberimp/cyberware/cascade/milspec) + TEST_ASSERT_EQUAL(street.slot, lattice.slot, "The street rig isn't in the lattice's OS slot") + TEST_ASSERT_EQUAL(milspec.slot, lattice.slot, "The service lattice isn't in the lattice's OS slot") + TEST_ASSERT(street.chrome_load < milspec.chrome_load && milspec.chrome_load < lattice.chrome_load, "The Cascade line's loads don't climb") + TEST_ASSERT(street.Insert(patient, TRUE), "Street fixture failed") + TEST_ASSERT(milspec.Insert(patient, TRUE), "Service fixture failed") + TEST_ASSERT(!street.owner && milspec.owner == patient, "Two Cascade models shared the OS slot") + + // The original lattice runs on its original numbers. + patient.apply_status_effect(/datum/status_effect/cyberware_cascade_window, lattice) + var/datum/status_effect/cyberware_cascade_window/window = patient.has_status_effect(/datum/status_effect/cyberware_cascade_window) + TEST_ASSERT_NOTNULL(window, "The lattice didn't open a window") + TEST_ASSERT_EQUAL(window.dodge_chance, 40, "The lattice window lost its dodge") + TEST_ASSERT_EQUAL(window.nextmove_modifier(), 0.5, "The lattice window lost its melee boost") + TEST_ASSERT_EQUAL(window.duration, world.time + 8 SECONDS, "The lattice window lost its length") + TEST_ASSERT(patient.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade), "The lattice window lost its gait") + patient.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + var/datum/status_effect/cyberware_cascade_crash/crash = patient.has_status_effect(/datum/status_effect/cyberware_cascade_crash) + TEST_ASSERT_NOTNULL(crash, "The lattice window ended without a crash") + TEST_ASSERT_EQUAL(crash.duration, world.time + 6 SECONDS, "The lattice crash lost its length") + TEST_ASSERT_EQUAL(patient.getStaminaLoss(), 80, "The lattice crash lost its stamina dump") + patient.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + patient.setStaminaLoss(0) + + // The street rig runs on its own. + patient.apply_status_effect(/datum/status_effect/cyberware_cascade_window, street) + window = patient.has_status_effect(/datum/status_effect/cyberware_cascade_window) + TEST_ASSERT_NOTNULL(window, "The street rig didn't open a window") + TEST_ASSERT_EQUAL(window.dodge_chance, 15, "The street window kept the wrong dodge") + TEST_ASSERT_EQUAL(window.nextmove_modifier(), 0.75, "The street window kept the wrong melee boost") + TEST_ASSERT_EQUAL(window.duration, world.time + 6 SECONDS, "The street window kept the wrong length") + TEST_ASSERT(patient.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade/street), "The street window lost its gait") + TEST_ASSERT(!patient.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade), "The street window kept the lattice's gait") + patient.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + TEST_ASSERT(!patient.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade/street), "The street gait outlived its window") + crash = patient.has_status_effect(/datum/status_effect/cyberware_cascade_crash) + TEST_ASSERT_NOTNULL(crash, "The street window ended without a crash") + TEST_ASSERT_EQUAL(crash.duration, world.time + 8 SECONDS, "The street crash kept the wrong length") + TEST_ASSERT_EQUAL(patient.getStaminaLoss(), 100, "The street crash kept the wrong stamina dump") + patient.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + + // What Splice quotes for the two lesser models. + build_cyberware_price_index() + var/list/street_price = GLOB.cyberware_price_index[street.type] + var/list/milspec_price = GLOB.cyberware_price_index[milspec.type] + TEST_ASSERT(street_price && street_price["credits"] > 0 && !street_price["vouchers"], "The street rig isn't a credit-priced SKU") + TEST_ASSERT(milspec_price && milspec_price["vouchers"] > 0, "The service lattice isn't a voucher-priced SKU") diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm index 031ef4ab323..1ab48e4d9b4 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm @@ -43,6 +43,7 @@ /datum/shop_sku/favor/freight_beacon = 1, /datum/shop_sku/favor/fitter_gauntlets = 1, /datum/shop_sku/ripperdoc/nightshade = 1, + /datum/shop_sku/ripperdoc/cascade_street = 1, /datum/shop_sku/ripperdoc/fixers = 1, /datum/shop_sku/ripperdoc/dermal_mesh = 1, /datum/shop_sku/ripperdoc/hopper = 1, @@ -93,6 +94,7 @@ /datum/shop_sku/ripperdoc/atlas = 2, /datum/shop_sku/ripperdoc/gorilla = 2, /datum/shop_sku/ripperdoc/rigger = 2, + /datum/shop_sku/ripperdoc/cascade_milspec = 2, /datum/shop_sku/fitter/rare/mod_emp_shield_advanced = 2, /datum/shop_sku/fitter/rare/mod_projectile_dampener = 2, /datum/shop_sku/black_market/mod_chassis_syndicate = 2, @@ -128,7 +130,7 @@ /datum/unit_test/voidcrew_zone_shops/catalog/Run() var/list/expected = expected_tags() - TEST_ASSERT_EQUAL(length(expected), 98, "The retained permission catalog is incomplete") + TEST_ASSERT_EQUAL(length(expected), 100, "The retained permission catalog is incomplete") var/datum/voidcrew_crew_record/crew = allocate(/datum/voidcrew_crew_record) for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku)) var/permission = expected[sku_type] || 0 From d93a3acaa893005b26db27f003270a3bdd3509e1 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:46:02 -0800 Subject: [PATCH 114/472] Add Shiv and Scorch Mantis Blade models Shiv Blades are a street pair with a weaker blade and no lunge. Scorch Blades keep the Mantis numbers and set targets alight on every cut and lunge. The Mantis lunge tuning moves from defines to vars on the housing so models can retune it. --- tgstation.dme | 1 + tools/gear_catalog/ratings/cyberware.json | 38 ++++ tools/gear_catalog/ratings/melee.json | 19 ++ .../modules/cyberware/ware_line_mantis.dm | 183 ++++++++++++++++++ .../modules/cyberware/ware_military_arms.dm | 59 ++++-- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_mantis.dm | 69 +++++++ 7 files changed, 355 insertions(+), 15 deletions(-) create mode 100644 voidcrew/modules/cyberware/ware_line_mantis.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm diff --git a/tgstation.dme b/tgstation.dme index b6a3824ddb4..fb590ab3a36 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7186,6 +7186,7 @@ #include "voidcrew\modules\cyberware\ware_line_berserk.dm" #include "voidcrew\modules\cyberware\ware_line_camo.dm" #include "voidcrew\modules\cyberware\ware_line_governor.dm" +#include "voidcrew\modules\cyberware\ware_line_mantis.dm" #include "voidcrew\modules\cyberware\ware_line_sandy.dm" #include "voidcrew\modules\cyberware\ware_military_arms.dm" #include "voidcrew\modules\cyberware\ware_military_body.dm" diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 06260b3ba64..5235909dfde 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -90,6 +90,44 @@ "reason": "Left half of the Mantis pair: 20-force blades that can't be disarmed, plus a 5-tile lunge every 20 s that adds 20 damage and floors the target for 2 s.", "tier": "red" }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Scorch Blade housing (right)", + "note": "Each landed cut adds 2 fire stacks and sets the target alight; the lunge adds 4 once, however many blades arrive. Corpses are left alone and the wearer is never lit. The blade is lit while out.", + "rank": 2, + "reason": "Half of a pair: the Mantis blades and lunge, with every cut and the lunge setting the target alight. Load 8 the pair, 5 vouchers at reputation 22, one on the shelf per round.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/left": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Scorch Blade housing (left)", + "note": "Each landed cut adds 2 fire stacks and sets the target alight; the lunge adds 4 once, however many blades arrive. Corpses are left alone and the wearer is never lit. The blade is lit while out.", + "rank": 3, + "reason": "Left half of the Scorch pair: the Mantis blades and lunge, with every cut and the lunge setting the target alight.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Shiv Blade housing (right)", + "rank": 6, + "reason": "Half of a pair: 14-force blades that can't be disarmed, no lunge. Load 4 the pair, 2,400 cr at yellow reputation. Splice takes a pulled housing back for 600 cr.", + "tier": "yellow" + }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/left": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Shiv Blade housing (left)", + "rank": 7, + "reason": "Left half of the Shiv pair: 14-force blades that can't be disarmed, no lunge.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire": { "confidence": "high", "group": "Arm weapons and fists", diff --git a/tools/gear_catalog/ratings/melee.json b/tools/gear_catalog/ratings/melee.json index a7568c8fc05..7c15cc54386 100644 --- a/tools/gear_catalog/ratings/melee.json +++ b/tools/gear_catalog/ratings/melee.json @@ -1174,6 +1174,25 @@ "reason": "Rated with its arm housing: 20 force blades that cannot be disarmed, and a 5 tile lunge every 20 s that floors the target for 2 s. Needs surgery.", "tier": "red" }, + "/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch": { + "confidence": "medium", + "group": "arm blades", + "hash": "", + "name": "scorch blade", + "note": "Each landed cut adds 2 fire stacks and sets the target alight. Corpses are left alone and the wielder is never lit.", + "rank": 2, + "reason": "Rated with its arm housing: the Mantis blade and lunge, with every cut and the lunge setting the target alight. Needs surgery.", + "tier": "red" + }, + "/obj/item/melee/energy/blade/hardlight/cyberware_mantis/street": { + "confidence": "medium", + "group": "arm blades", + "hash": "", + "name": "shiv blade", + "rank": 3, + "reason": "Rated with its arm housing: 14 force blades that cannot be disarmed, no lunge. Needs surgery.", + "tier": "yellow" + }, "/obj/item/melee/energy/sword/pirate": { "confidence": "high", "group": "energy blades", diff --git a/voidcrew/modules/cyberware/ware_line_mantis.dm b/voidcrew/modules/cyberware/ware_line_mantis.dm new file mode 100644 index 00000000000..2f3792bdedd --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_mantis.dm @@ -0,0 +1,183 @@ +/** + * # Mantis Blades line: the Shiv and Scorch models + * + * Two more ways to buy the same forearm blades (ware_military_arms.dm): + * - Shiv Blades, the street model. A duller blade, no lunge, light on the + * nervous system, sold for credits in the yellow zone and bought back. + * - Scorch Blades, the fire model. Mantis in every number, but every cut + * sets the target alight and the lunge lights them harder. + * + * Both are subtypes of the Mantis housing, so they share its arm hardware slot: + * installing one evicts the other. Both ship as cased pairs (left and right), + * like the parent. The tuning rides vars on the housing (lunge_range, + * lunge_bonus, lunge_fire_stacks and friends), so a model retunes by setting + * them, not by copying the lunge. + */ + +// ---- Fire -------------------------------------------------------------- + +/** + * Puts fire stacks on a living target and lights them. The only fire this + * line makes. It never touches the attacker and never lights a turf: any + * heat the surroundings get is what a burning mob already gives off. Corpses + * are left alone, so a body is never cooked out from under a revive or a salvage. + * Returns TRUE if the target caught fire just now. + */ +/proc/cyberware_scorch_target(mob/living/victim, stacks) + if(!istype(victim) || QDELETED(victim) || victim.stat == DEAD || stacks <= 0) + return FALSE + victim.adjust_fire_stacks(stacks) + return victim.ignite_mob() + +// ---- Blades -------------------------------------------------------------- + +/** + * The Shiv's blade. The same hardlight on a worse emitter: dull grey, and it + * hits softer. + */ +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/street + name = "shiv blade" + desc = "A dull grey sliver of hardlight with no shine to it. It still cuts." + icon_state = "mantis_street" + base_icon_state = "mantis_street" + inhand_icon_state = "mantis_street" + force = 14 + +/** + * The Scorch's blade. Cuts like a Mantis, then lights what it cut. The edge runs + * orange and the blade throws a little firelight while it is out. + */ +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch + name = "scorch blade" + desc = "A forearm's length of hardlight gone orange along the edge. The air above it shimmers." + icon_state = "mantis_scorch" + base_icon_state = "mantis_scorch" + inhand_icon_state = "mantis_scorch" + light_range = 2 + light_power = 1 + light_color = LIGHT_COLOR_FIRE + /// Fire stacks each landed cut puts on its target. + var/fire_stacks_per_hit = 2 + +// The glow is only for a blade that is out. Stowed inside the housing it is dark. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch/equipped(mob/user, slot, initial = FALSE) + . = ..() + set_light_on(TRUE) + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch/dropped(mob/user, silent = FALSE) + . = ..() + set_light_on(FALSE) + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch/attack(mob/living/target, mob/living/user, list/modifiers, list/attack_modifiers) + . = ..() + // A truthy return means the swing was blocked or cancelled outright, no cut + // landed, so nothing burns. Never the wielder, whoever is in the way. + if(. || QDELETED(target) || target == user) + return + if(cyberware_scorch_target(target, fire_stacks_per_hit)) + log_combat(user, target, "set alight", src, "(scorch blade)") + +// ---- Shiv Blades (street) ------------------------------------------------ + +/** + * # Shiv Blades (T2, arms, arm hardware slots, load 2 per arm / 4 the pair) + * + * The street answer to Mantis Blades: the same hardlight forearm housing built + * from salvage. The blade is weaker and there is no lunge, so no button besides + * the blade toggle. Cheap in credits and light on the nervous system, and Splice + * takes a pulled housing back toward the real thing. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street + name = "\improper Shiv Blade housing (right)" + desc = "A forearm housing with the serial plate filed off and an emitter wired in by someone who skipped the manual." + icon_state = "mantis_street" + chrome_load = 2 + tier = CYBERWARE_TIER_2 + aug_overlay = "mantis_street" + items_to_create = list(/obj/item/melee/energy/blade/hardlight/cyberware_mantis/street) + actions_types = list(/datum/action/item_action/organ_action/toggle/toolkit) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/left + name = "\improper Shiv Blade housing (left)" + zone = BODY_ZONE_L_ARM + slot = ORGAN_SLOT_LEFT_ARM_AUG + +/obj/item/cyberware_pair_case/mantis_street + name = "\improper Shiv Blades case" + desc = "A scuffed clamshell case with two forearm housings jammed into mismatched foam." + ware_types = list( + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/left, + ) + +// ---- Scorch Blades (fire) ------------------------------------------------ + +/** + * # Scorch Blades (T3, arms, arm hardware slots, load 4 per arm / 8 the pair) + * + * Mantis Blades with a heat sink grafted onto the emitter rail. The blade, the + * lunge and the cooldown are the Mantis numbers. What is added is fire: each cut + * leaves the target burning, and the lunge lights them harder. It heats the + * wrist, but never sets the wearer alight. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch + name = "\improper Scorch Blade housing (right)" + desc = "A forearm housing with a heat sink grafted onto the emitter rail. The wrist runs warm even with nothing out." + icon_state = "mantis_scorch" + chrome_load = 4 + tier = CYBERWARE_TIER_3 + aug_overlay = "mantis_scorch" + items_to_create = list(/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch) + lunge_fire_stacks = 4 + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/left + name = "\improper Scorch Blade housing (left)" + zone = BODY_ZONE_L_ARM + slot = ORGAN_SLOT_LEFT_ARM_AUG + +/obj/item/cyberware_pair_case/mantis_scorch + name = "\improper Scorch Blades case" + desc = "A heavy clamshell case, scorched at the corners, with two forearm housings set in heat-proof foam." + ware_types = list( + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/left, + ) + +// ---- The parlor's shelf -------------------------------------------------- + +/datum/shop_sku/ripperdoc/mantis_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + name = "Shiv Blades (pair)" + desc = "A cased pair of cheap forearm blades." + item_path = /obj/item/cyberware_pair_case/mantis_street + price_credits = 2400 + stock_min = 1 + stock_max = 2 + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/ripperdoc/mantis_scorch + zone_permission = ZONE_PERMISSION_ELITE + category = "Military Chrome" + name = "Scorch Blades (pair)" + desc = "A cased pair of forearm blades with heat sinks on the emitter rails." + item_path = /obj/item/cyberware_pair_case/mantis_scorch + price_vouchers = 5 + stock_min = 1 + stock_max = 1 + convoys_per_unit = 3 + fixed_price = TRUE + contract_reward = FALSE + +/// Half the credit price of one housing, either side, as the other street rungs +/// pay. Credits only, so turning a pair around is always a loss. +/datum/shop_buyback/ripperdoc/mantis_street + name = "Shiv Blade housing (pulled)" + desc = "A secondhand forearm housing, either side. Counts toward the real blades." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street + match_subtypes = TRUE + pay_credits = 600 + demand_min = 2 + demand_max = 4 diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index cb46862d5ea..596dafe84b2 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -287,6 +287,9 @@ // MANTIS BLADES // ========================================================================= +// These four are the Mantis defaults. The housing reads them into vars so the +// other models in the line (ware_line_mantis.dm) can retune by setting vars. + /// Lunge reach in tiles. #define CYBERWARE_MANTIS_LUNGE_RANGE 5 /// Bonus brute the arriving lunge lands PER BLADE (cap 10 per ADDENDUM 2). @@ -352,6 +355,17 @@ /datum/action/item_action/organ_action/toggle/toolkit, /datum/action/cooldown/cyberware/mantis_lunge, ) + /// Lunge reach in tiles. + var/lunge_range = CYBERWARE_MANTIS_LUNGE_RANGE + /// Bonus brute the arriving lunge lands per blade out. + var/lunge_bonus = CYBERWARE_MANTIS_LUNGE_BONUS + /// Delay between the wind-up and the dash. + var/lunge_telegraph = CYBERWARE_MANTIS_LUNGE_TELEGRAPH + /// How long a connecting lunge floors its target. + var/lunge_knockdown = CYBERWARE_MANTIS_LUNGE_KNOCKDOWN + /// Fire stacks the arriving lunge puts on its target (and lights them + /// with). 0 for no fire. The most of any blade that arrives lands, not the sum. + var/lunge_fire_stacks = 0 /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left name = "\improper Mantis Blade housing (left)" @@ -376,19 +390,24 @@ click_to_activate = TRUE /** - * How many mantis blades this owner has out right now. Every housing grants - * exactly one of these buttons, so counting the buttons whose blade is - * extended counts the blades that will arrive. + * The housings whose blade is out right now and will arrive with the lunge. + * Every lunging housing grants exactly one of these buttons, so walking the + * buttons whose blade is extended finds the blades. A model with no lunge + * button never lands in this list. */ -/datum/action/cooldown/cyberware/mantis_lunge/proc/count_extended_blades() +/datum/action/cooldown/cyberware/mantis_lunge/proc/get_extended_housings() + var/list/housings = list() if(!owner) - return 0 - var/blades = 0 + return housings for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge_action in owner.actions) var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = lunge_action.organ if(istype(housing) && housing.blade_extended()) - blades++ - return blades + housings += housing + return housings + +/// How many mantis blades this owner has out right now. +/datum/action/cooldown/cyberware/mantis_lunge/proc/count_extended_blades() + return length(get_extended_housings()) /datum/action/cooldown/cyberware/mantis_lunge/Activate(atom/target) var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = organ @@ -408,7 +427,7 @@ if(victim == lunger || victim.stat == DEAD) lunger.balloon_alert(lunger, "no target!") return FALSE - if(get_dist(lunger, victim) > CYBERWARE_MANTIS_LUNGE_RANGE || !can_see(lunger, victim, CYBERWARE_MANTIS_LUNGE_RANGE)) + if(get_dist(lunger, victim) > housing.lunge_range || !can_see(lunger, victim, housing.lunge_range)) lunger.balloon_alert(lunger, "out of reach!") return FALSE // One lunge clock across both housings: start every mantis_lunge button @@ -423,7 +442,7 @@ span_warning("You coil for the lunge."), ) playsound(lunger, 'sound/items/weapons/sear.ogg', 50, TRUE) - addtimer(CALLBACK(src, PROC_REF(do_lunge), WEAKREF(victim)), CYBERWARE_MANTIS_LUNGE_TELEGRAPH) + addtimer(CALLBACK(src, PROC_REF(do_lunge), WEAKREF(victim)), housing.lunge_telegraph) return TRUE /// The dash itself: walk the line to the target with a decoy per tile, @@ -432,12 +451,15 @@ var/mob/living/lunger = owner if(QDELETED(lunger) || lunger.incapacitated || !isturf(lunger.loc)) return + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = organ + if(!istype(housing)) + return var/mob/living/victim = victim_ref?.resolve() if(QDELETED(victim) || victim.stat == DEAD) lunger.balloon_alert(lunger, "target lost!") return var/turf/victim_turf = get_turf(victim) - if(!victim_turf || get_dist(lunger, victim_turf) > CYBERWARE_MANTIS_LUNGE_RANGE + 1) + if(!victim_turf || get_dist(lunger, victim_turf) > housing.lunge_range + 1) lunger.balloon_alert(lunger, "target lost!") return playsound(lunger, 'sound/items/weapons/bladeslice.ogg', 60, TRUE) @@ -456,23 +478,30 @@ // Both housings deployed means both blades arrive. Floored at one: a blade // stowed during the 0.3s telegraph shouldn't eat the whole cooldown for // nothing when the dash still connected. - var/blades = max(count_extended_blades(), 1) + var/list/blade_housings = get_extended_housings() + if(!length(blade_housings)) + blade_housings = list(housing) + var/blades = length(blade_housings) lunger.face_atom(victim) lunger.do_attack_animation(victim, ATTACK_EFFECT_SLASH) // One armor roll for the arrival, reused per blade. Both land in the same // instant on the same plate, and it keeps the armor message to one line. var/armor_block = victim.run_armor_check(BODY_ZONE_CHEST, MELEE) - for(var/i in 1 to blades) + var/lunge_fire = 0 + for(var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/blade_housing as anything in blade_housings) victim.apply_damage( - CYBERWARE_MANTIS_LUNGE_BONUS, + blade_housing.lunge_bonus, BRUTE, BODY_ZONE_CHEST, armor_block, wound_bonus = 10, sharpness = SHARP_EDGED, ) - victim.Knockdown(CYBERWARE_MANTIS_LUNGE_KNOCKDOWN) + lunge_fire = max(lunge_fire, blade_housing.lunge_fire_stacks) + victim.Knockdown(housing.lunge_knockdown) + if(lunge_fire) + cyberware_scorch_target(victim, lunge_fire) if(blades > 1) victim.visible_message( span_danger("[lunger] flickers across the gap and shuts both blades on [victim] like a trap, dropping [victim.p_them()]!"), diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 7a79a7e24bc..547df8d14ae 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -70,6 +70,7 @@ #include "voidcrew_crew_antag_gc.dm" #include "voidcrew_crew_hud.dm" #include "voidcrew_cyberware.dm" +#include "voidcrew_cyberware_mantis.dm" #include "voidcrew_derelict_helpers.dm" #include "voidcrew_derelict_layout.dm" #include "voidcrew_derelict_leash.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm new file mode 100644 index 00000000000..d485ac8803c --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm @@ -0,0 +1,69 @@ +/** + * # Mantis Blades line (Shiv and Scorch models) + * + * The street model has no lunge, the fire model keeps the parent's lunge and + * adds fire, a cut lights the target and never the wielder, and the pair + * cases quote prices through the cradle's index. Unit-test files compile before + * voidcrew/_DEFINES/, so the numbers below are literals. + */ +/datum/unit_test/voidcrew_cyberware_mantis_line + +/datum/unit_test/voidcrew_cyberware_mantis_line/Run() + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/parent = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/street = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/scorch = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch) + + // The parent keeps its numbers: the defines became vars with the same values. + TEST_ASSERT_EQUAL(parent.lunge_range, 5, "Mantis lunge range changed") + TEST_ASSERT_EQUAL(parent.lunge_bonus, 10, "Mantis lunge bonus changed") + TEST_ASSERT_EQUAL(parent.lunge_fire_stacks, 0, "The plain Mantis lunge sets people on fire") + + TEST_ASSERT(!(/datum/action/cooldown/cyberware/mantis_lunge in street.actions_types), "Shiv Blades got a lunge") + TEST_ASSERT(/datum/action/cooldown/cyberware/mantis_lunge in scorch.actions_types, "Scorch Blades lost the lunge") + TEST_ASSERT_EQUAL(scorch.lunge_fire_stacks, 4, "Scorch lunge fire is not 4 stacks") + + // Same slot as the parent, so the line evicts itself. + TEST_ASSERT_EQUAL(street.slot, parent.slot, "Shiv housing is not in the Mantis arm slot") + TEST_ASSERT_EQUAL(scorch.slot, parent.slot, "Scorch housing is not in the Mantis arm slot") + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/left/street_left = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/left) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/left/scorch_left = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/left) + TEST_ASSERT_EQUAL(street_left.slot, ORGAN_SLOT_LEFT_ARM_AUG, "Left Shiv housing is not in the left arm slot") + TEST_ASSERT_EQUAL(scorch_left.slot, ORGAN_SLOT_LEFT_ARM_AUG, "Left Scorch housing is not in the left arm slot") + + // The weaker blade. + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/street/street_blade = allocate(/obj/item/melee/energy/blade/hardlight/cyberware_mantis/street) + TEST_ASSERT_EQUAL(street_blade.force, 14, "Shiv blade force is not 14") + + // Cases carry a matched pair of the right model. + var/obj/item/cyberware_pair_case/mantis_street/street_case = allocate(/obj/item/cyberware_pair_case/mantis_street) + var/obj/item/cyberware_pair_case/mantis_scorch/scorch_case = allocate(/obj/item/cyberware_pair_case/mantis_scorch) + TEST_ASSERT_EQUAL(length(street_case.contents), 2, "Shiv case isn't a pair") + TEST_ASSERT_EQUAL(length(scorch_case.contents), 2, "Scorch case isn't a pair") + TEST_ASSERT(locate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/left) in street_case.contents, "Shiv case has no left housing") + TEST_ASSERT(locate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/left) in scorch_case.contents, "Scorch case has no left housing") + + // Both halves of each case quote the case's price at the cradle. + build_cyberware_price_index() + var/list/street_price = get_cyberware_price(street) + var/list/scorch_price = get_cyberware_price(scorch_left) + TEST_ASSERT(street_price && street_price["credits"] > 0 && street_price["paired"], "Shiv Blades quote no credit price as a pair") + TEST_ASSERT(scorch_price && scorch_price["vouchers"] > 0 && scorch_price["paired"], "Scorch Blades quote no voucher price as a pair") + +/// A cut with the Scorch blade lights the target and never the one holding it, +/// and a corpse is left alone. +/datum/unit_test/voidcrew_cyberware_mantis_scorch + +/datum/unit_test/voidcrew_cyberware_mantis_scorch/Run() + var/mob/living/carbon/human/wielder = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch/blade = allocate(/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch) + wielder.put_in_active_hand(blade) + blade.attack(victim, wielder, list(), list()) + TEST_ASSERT(victim.fire_stacks >= 2, "A scorch cut left no fire stacks on the target") + TEST_ASSERT(victim.on_fire, "A scorch cut didn't light the target") + TEST_ASSERT(!wielder.on_fire && wielder.fire_stacks <= 0, "A scorch cut put fire on the wielder") + + var/mob/living/carbon/human/corpse = allocate(/mob/living/carbon/human/consistent) + corpse.stat = DEAD + TEST_ASSERT(!cyberware_scorch_target(corpse, 4), "A corpse was set alight") + TEST_ASSERT(!corpse.on_fire, "A corpse is burning") From 3abd8bc5372b89abe5594cf7d399382857edac4b Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:51:52 -0800 Subject: [PATCH 115/472] Add the Widowline Arc, a live-wire model of the Widowline monowire The Arc housing shares the Widowline's arm slot and adds a shock to every landed hit: burn and a short stagger on living targets, heavy burn on silicons, mechs and robotic mobs, and a brief short on turrets, ship laser turrets, consoles and cameras. Outpost property is immune. The monowire's cleave and charge numbers become vars on the wire so a subtype can retune them. Gear ratings and a small unit test come with it. --- tgstation.dme | 1 + tools/gear_catalog/ratings/cyberware.json | 10 + tools/gear_catalog/ratings/melee.json | 10 + .../modules/cyberware/ware_line_widowline.dm | 220 ++++++++++++++++++ .../modules/cyberware/ware_military_arms.dm | 10 +- .../modules/unit_tests/voidcrew_cyberware.dm | 45 ++++ 6 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 voidcrew/modules/cyberware/ware_line_widowline.dm diff --git a/tgstation.dme b/tgstation.dme index fb590ab3a36..9ec11de20ec 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7188,6 +7188,7 @@ #include "voidcrew\modules\cyberware\ware_line_governor.dm" #include "voidcrew\modules\cyberware\ware_line_mantis.dm" #include "voidcrew\modules\cyberware\ware_line_sandy.dm" +#include "voidcrew\modules\cyberware\ware_line_widowline.dm" #include "voidcrew\modules\cyberware\ware_military_arms.dm" #include "voidcrew\modules\cyberware\ware_military_body.dm" #include "voidcrew\modules\cyberware\ware_pro_combat.dm" diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 5235909dfde..cb04383abe5 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -137,6 +137,16 @@ "reason": "20 damage a swing (15 brute, 5 burn) straight through all armor, reach 2, and it cuts neighbours for 75%. The best melee against armored crews, useless on walls and doors. Load 5, 3 vouchers at reputation 22, one on the shelf per round.", "tier": "red" }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Widowline Arc spool housing", + "note": "The Widowline wire with a shock on every landed hit. A living target takes 5 more burn and a 2 s stagger (stacking to 4 s) unless it is shock immune or its suit is insulated; gloves do not matter. Silicons, mechs and robotic mobs take 25 more burn. A hit on a turret, ship laser turret, console or camera knocks it out for 5 s, once per 15 s per wire; outpost property is immune. The cleave does not shock.", + "rank": 1, + "reason": "The Widowline's 20 a swing through all armor and reach 2, plus 5 burn and a stagger on the target, 25 extra burn on robots and mechs, and a turret or console shorted out for 5 s. Load 6, 5 vouchers at reputation 22, one on the shelf per round.", + "tier": "red" + }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw": { "confidence": "high", "group": "Work tools", diff --git a/tools/gear_catalog/ratings/melee.json b/tools/gear_catalog/ratings/melee.json index 7c15cc54386..1f3fad84e35 100644 --- a/tools/gear_catalog/ratings/melee.json +++ b/tools/gear_catalog/ratings/melee.json @@ -1165,6 +1165,16 @@ "reason": "Rated with its arm housing: 25 a second through any armor at 2 tiles, and the hit carries into every enemy beside the target. Hidden until used. Needs surgery.", "tier": "red" }, + "/obj/item/melee/cyberware_monowire/live": { + "confidence": "medium", + "group": "arm blades", + "hash": "", + "name": "Widowline Arc monowire", + "note": "Each landed hit also shocks the target for 5 burn and a short stagger (not through shock immunity or insulated suits), adds 25 burn on silicons, mechs and robotic mobs, and shorts a turret, ship laser turret, console or camera for 5 s, once per 15 s. The cleave neighbours take the plain cleave only.", + "rank": 1, + "reason": "Rated with its arm housing: the Widowline wire with a shock, a stagger and a machine-killing short on every landed hit. Needs surgery.", + "tier": "red" + }, "/obj/item/melee/energy/blade/hardlight/cyberware_mantis": { "confidence": "medium", "group": "arm blades", diff --git a/voidcrew/modules/cyberware/ware_line_widowline.dm b/voidcrew/modules/cyberware/ware_line_widowline.dm new file mode 100644 index 00000000000..e92c86e6393 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_widowline.dm @@ -0,0 +1,220 @@ +/** + * # Widowline line: the Arc model + * + * The Widowline monowire (ware_military_arms.dm) with a capacitor stack on the + * spool. Everything the plain wire does still happens, and every landed hit + * adds a shock on top: + * + * - Living targets take a little burn and a short stagger. The current goes in + * through the wire itself, so it is run with SHOCK_NOGLOVES and insulated + * gloves do nothing. Everything else tg's shock code already honours still + * applies: TRAIT_SHOCKIMMUNE, insulating suits and species or physiology + * shock coefficients. A target the shock can't reach takes only the plain + * cut: no burn, no stagger, no arc. + * - Silicons, mechs and robotic mobs (MOB_ROBOTIC) take a heavy burn bonus. + * The plain wire is nearly useless against them (its demolition_mod), and + * this is what makes up for it. Silicons shrug electrocute_act off by signal, + * so they get the bonus alone. + * - A turret, a ship laser turret, a console or a camera is knocked out for a + * few seconds. Never a raw emp_act: an EMP blacks out an APC for a minute, + * pops airlocks open, drains a laser turret's cell and scrambles a + * porta-turret's targeting flags. Each family is cut through its own + * machinery (the Icepick Jack's precedent), consoles lose power briefly, and + * anything that answers tg's on_saboteur() hook (cameras) goes through that. + * Outpost property and the trader outposts' concourse are immune. + * + * The cleave is untouched: neighbours take the plain cleave, not the shock. + */ + +/// Burn of the shock itself, on top of the cut and the wire's own charge. +#define CYBERWARE_ARC_SHOCK_BURN 5 +/// Stagger a landed shock leaves, and the most it stacks to. +#define CYBERWARE_ARC_STAGGER (2 SECONDS) +#define CYBERWARE_ARC_STAGGER_CAP (4 SECONDS) +/// Burn bonus on silicons, mechs and robotic mobs. +#define CYBERWARE_ARC_ROBOTIC_BONUS 25 +/// How long a shorted turret, console or camera stays dark. +#define CYBERWARE_ARC_JAM_DURATION (5 SECONDS) +/// Gap before the same wire can short another machine. +#define CYBERWARE_ARC_JAM_RECHARGE (15 SECONDS) + +/** + * The wire. Plain Widowline numbers (force, burn, cleave, reach, armour + * penetration, demolition_mod) are inherited as they are; the vars here are + * the shock rider only. All of it keys off afterattack(), which only runs for + * a swing that actually landed, on a mob or on an object that can be hit. + */ +/obj/item/melee/cyberware_monowire/live + name = "\improper Widowline Arc monowire" + desc = "A weighted spool of monomolecular line with a capacitor stack feeding it. It smells of ozone and ticks like a cooling engine." + icon_state = "monowire_live" + inhand_icon_state = "monowire_live" + /// Burn of the shock on a living target. + var/shock_burn = CYBERWARE_ARC_SHOCK_BURN + /// Stagger a landed shock leaves. + var/stagger_time = CYBERWARE_ARC_STAGGER + /// Most stagger the shock stacks to. + var/stagger_cap = CYBERWARE_ARC_STAGGER_CAP + /// Burn bonus on silicons, mechs and robotic mobs. + var/robotic_bonus = CYBERWARE_ARC_ROBOTIC_BONUS + /// How long a shorted machine stays down. + var/jam_duration = CYBERWARE_ARC_JAM_DURATION + /// Gap before this wire can short another machine. + var/jam_recharge = CYBERWARE_ARC_JAM_RECHARGE + /// The stack recharging after a machine was shorted. + COOLDOWN_DECLARE(jam_cooldown) + +/obj/item/melee/cyberware_monowire/live/afterattack(atom/target, mob/user, list/modifiers, list/attack_modifiers) + . = ..() + if(!isliving(user) || QDELETED(target) || target == user) + return + if(isliving(target)) + discharge_into_mob(target, user) + else if(ismecha(target)) + discharge_into_mech(target, user) + else if(ismachinery(target)) + discharge_into_machine(target, user) + +/// Arc and crackle on whatever the shock went into. +/obj/item/melee/cyberware_monowire/live/proc/arc_visuals(atom/target, mob/living/user) + user.Beam(target, icon_state = "lightning[rand(1, 12)]", time = 0.5 SECONDS) + do_sparks(2, TRUE, target) + playsound(target, SFX_SPARKS, 50, TRUE) + cyberware_ink_pulse(user, CYBERWARE_INK_SOFT) + +/** + * The shock into a living target. Returns TRUE if anything landed. + * Dead and shock-immune targets are skipped entirely; see the file header for + * what counts as shock-immune. + */ +/obj/item/melee/cyberware_monowire/live/proc/discharge_into_mob(mob/living/target, mob/living/user) + if(target.stat == DEAD || HAS_TRAIT(target, TRAIT_SHOCKIMMUNE)) + return FALSE + var/jolted = FALSE + var/is_silicon = issilicon(target) + // No stun, no message, no skeleton flash, no jitter: the beam and the + // sparks are the tell, and the stagger is the whole effect on the body. + if(!is_silicon && target.electrocute_act(shock_burn, user, 1, SHOCK_NOGLOVES | SHOCK_NOSTUN | SHOCK_SUPPRESS_MESSAGE | SHOCK_NO_HUMAN_ANIM, 0, 0, 0)) + jolted = TRUE + if(is_silicon || (target.mob_biotypes & MOB_ROBOTIC)) + var/bonus_zone = target.get_random_valid_zone(user.zone_selected) + target.apply_damage( + robotic_bonus, + BURN, + bonus_zone, + target.run_armor_check(bonus_zone, ENERGY, armour_penetration = armour_penetration, silent = TRUE), + ) + jolted = TRUE + if(!jolted) + return FALSE + arc_visuals(target, user) + if(!QDELETED(target)) + target.adjust_staggered_up_to(stagger_time, stagger_cap) + return TRUE + +/// The burn bonus into a mech's armour. The pilot is not touched. +/obj/item/melee/cyberware_monowire/live/proc/discharge_into_mech(obj/vehicle/sealed/mecha/mech, mob/living/user) + if(QDELETED(mech) || mech.atom_integrity <= 0 || HAS_TRAIT(mech, TRAIT_OUTPOST_PROPERTY) || is_trader_outpost_protected(mech)) + return FALSE + mech.take_damage(robotic_bonus, BURN, ENERGY, FALSE, get_dir(mech, user), armour_penetration) + arc_visuals(mech, user) + return TRUE + +/** + * Shorts a machine out for a few seconds, if it is one that can be. Returns + * TRUE if it went dark. The recharge only starts on a machine that actually + * went dark, so a wasted swing at a vending machine costs nothing. + */ +/obj/item/melee/cyberware_monowire/live/proc/discharge_into_machine(obj/machinery/machine, mob/living/user) + if(QDELETED(machine) || HAS_TRAIT(machine, TRAIT_OUTPOST_PROPERTY) || is_trader_outpost_protected(machine)) + return FALSE + if(!COOLDOWN_FINISHED(src, jam_cooldown) || !jam_machine(machine)) + // Still crackles, goes nowhere. + do_sparks(1, TRUE, machine) + return FALSE + COOLDOWN_START(src, jam_cooldown, jam_recharge) + arc_visuals(machine, user) + machine.visible_message(span_warning("[machine] crackles and goes dark.")) + return TRUE + +/// Cuts the machine through its own family's machinery. Returns TRUE if it was knocked out. +/obj/item/melee/cyberware_monowire/live/proc/jam_machine(obj/machinery/machine) + 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 + turret.set_disabled(jam_duration) + return TRUE + if(istype(machine, /obj/machinery/ship_combat/laser_turret)) + var/obj/machinery/ship_combat/laser_turret/laser = machine + // Its fire cooldown is also its normal reload; never shorten that. + if(COOLDOWN_TIMELEFT(laser, fire_cooldown) >= jam_duration) + return FALSE + COOLDOWN_START(laser, fire_cooldown, jam_duration) + return TRUE + if(istype(machine, /obj/machinery/computer)) + return cyberware_arc_power_cut(machine, jam_duration) + // Cameras answer here; anything else with no answer is left alone. + return !!machine.on_saboteur(src, jam_duration) + +/** + * Drops a machine's power for a moment, the same flag an area blackout sets + * and the same way back: power_change() works the real state out again. + * Returns FALSE for a machine that is already down or draws no power. + */ +/proc/cyberware_arc_power_cut(obj/machinery/machine, duration) + if(machine.use_power == NO_POWER_USE || (machine.machine_stat & (NOPOWER | BROKEN | MAINT | EMPED))) + return FALSE + machine.set_machine_stat(machine.machine_stat | NOPOWER) + SEND_SIGNAL(machine, COMSIG_MACHINERY_POWER_LOST) + machine.update_appearance() + machine.set_light(0) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(cyberware_arc_power_restore), machine), duration) + return TRUE + +/// End of a power cut. Global so it survives the machine being deleted meanwhile. +/proc/cyberware_arc_power_restore(obj/machinery/machine) + if(QDELETED(machine)) + return + // power_change() leaves a broken machine's flags alone, so a machine that + // broke while it was dark would stay unpowered after it was repaired. + if(machine.machine_stat & BROKEN) + machine.set_machine_stat(machine.machine_stat & ~NOPOWER) + machine.power_change() + +/** + * # Widowline Arc spool housing (T3, one arm, aug slot, load 6) + * + * The Widowline spool housing, stepped up. Same slot, same arm, so installing + * one evicts the other. Sold at the Chop Shop for vouchers and never bought + * back, like the plain Widowline. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live + name = "\improper Widowline Arc spool housing" + desc = "A wrist spool wound with monomolecular line, with a capacitor stack bolted on where the safety used to be." + icon_state = "monowire_live" + chrome_load = 6 + items_to_create = list(/obj/item/melee/cyberware_monowire/live) + +/datum/shop_sku/ripperdoc/monowire_live + zone_permission = ZONE_PERMISSION_ELITE + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live + price_vouchers = 5 + stock_min = 1 + stock_max = 1 + convoys_per_unit = 3 + fixed_price = TRUE + contract_reward = FALSE + +#undef CYBERWARE_ARC_SHOCK_BURN +#undef CYBERWARE_ARC_STAGGER +#undef CYBERWARE_ARC_STAGGER_CAP +#undef CYBERWARE_ARC_ROBOTIC_BONUS +#undef CYBERWARE_ARC_JAM_DURATION +#undef CYBERWARE_ARC_JAM_RECHARGE diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index 596dafe84b2..0b645f946f2 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -562,6 +562,10 @@ attack_verb_continuous = list("garrotes", "flenses", "lashes", "bisects") attack_verb_simple = list("garrote", "flense", "lash", "bisect") hitsound = 'voidcrew/sound/monowire1.ogg' + /// Cleave fraction of the wire's damage dealt to secondary targets. + var/cleave_mult = CYBERWARE_MONOWIRE_CLEAVE_MULT + /// Burn the charged line adds on top of the cut, every hit. + var/charge_burn = CYBERWARE_MONOWIRE_BURN /obj/item/melee/cyberware_monowire/attack(mob/living/target, mob/living/user, list/modifiers, list/attack_modifiers) // The parent plays hitsound partway through the swing, so pick which of the @@ -577,7 +581,7 @@ // already announced the penetration for this same swing. var/burn_zone = target.get_random_valid_zone(user.zone_selected) target.apply_damage( - CYBERWARE_MONOWIRE_BURN, + charge_burn, BURN, burn_zone, target.run_armor_check(burn_zone, MELEE, armour_penetration = armour_penetration, silent = TRUE), @@ -594,7 +598,7 @@ // One roll for both halves: it is one pass of the same wire. var/cleave_block = nearby.run_armor_check(BODY_ZONE_CHEST, MELEE, armour_penetration = armour_penetration) nearby.apply_damage( - force * CYBERWARE_MONOWIRE_CLEAVE_MULT, + force * cleave_mult, BRUTE, BODY_ZONE_CHEST, cleave_block, @@ -602,7 +606,7 @@ sharpness = SHARP_EDGED, ) nearby.apply_damage( - CYBERWARE_MONOWIRE_BURN * CYBERWARE_MONOWIRE_CLEAVE_MULT, + charge_burn * cleave_mult, BURN, BODY_ZONE_CHEST, cleave_block, diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index a09f9447fd6..1a7d1dfcc3d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -170,6 +170,51 @@ if(BODY_ZONE_L_ARM) TEST_ASSERT_EQUAL(initial(ware.slot), ORGAN_SLOT_LEFT_ARM_AUG, "[organ_path] is left-arm chrome off the arm hardware slot. It would stack with blades/launchers on the same arm") +/// The Widowline Arc: same slot as the Widowline it steps up from, a heavier +/// load, and a wire whose shock lands through insulated gloves, skips the +/// shock-immune, hits robots harder, and shorts a turret once per recharge. +/datum/unit_test/voidcrew_cyberware_widowline_arc + +/datum/unit_test/voidcrew_cyberware_widowline_arc/Run() + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/plain = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live/arc = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live) + TEST_ASSERT_EQUAL(arc.slot, plain.slot, "The Arc housing isn't in the Widowline's slot, so the two would stack") + TEST_ASSERT(arc.chrome_load > plain.chrome_load, "The Arc housing is no heavier than the plain Widowline") + var/obj/item/melee/cyberware_monowire/live/wire = locate() in arc + TEST_ASSERT(wire, "The Arc housing didn't build its live wire") + + var/turf/start = run_loc_floor_bottom_left + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, start) + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent, get_step(start, EAST)) + TEST_ASSERT(wire.discharge_into_mob(victim, user), "The shock didn't land on a plain human") + TEST_ASSERT(victim.getFireLoss() > 0, "The shock left no burn") + TEST_ASSERT(victim.has_status_effect(/datum/status_effect/staggered), "The shock left no stagger") + + var/mob/living/carbon/human/gloved = allocate(/mob/living/carbon/human/consistent, get_step(start, EAST)) + var/obj/item/clothing/gloves/color/yellow/insulated = allocate(/obj/item/clothing/gloves/color/yellow) + gloved.equip_to_slot_if_possible(insulated, ITEM_SLOT_GLOVES, qdel_on_fail = FALSE, disable_warning = TRUE) + TEST_ASSERT_EQUAL(gloved.gloves, insulated, "Insulated gloves fixture failed to equip") + TEST_ASSERT(wire.discharge_into_mob(gloved, user), "Insulated gloves stopped the shock") + + var/mob/living/carbon/human/immune = allocate(/mob/living/carbon/human/consistent, get_step(start, EAST)) + ADD_TRAIT(immune, TRAIT_SHOCKIMMUNE, TRAIT_SOURCE_UNIT_TESTS) + TEST_ASSERT(!wire.discharge_into_mob(immune, user), "A shock-immune target was shocked") + TEST_ASSERT_EQUAL(immune.getFireLoss(), 0, "A shock-immune target took burn") + TEST_ASSERT(!immune.has_status_effect(/datum/status_effect/staggered), "A shock-immune target was staggered") + + // Any robotic biotype takes the robot bonus, not just silicons. + var/mob/living/basic/pet/dog/corgi/robot = allocate(/mob/living/basic/pet/dog/corgi, get_step(start, EAST)) + robot.mob_biotypes = MOB_ROBOTIC + TEST_ASSERT(wire.discharge_into_mob(robot, user), "The robot bonus didn't land on a robotic mob") + TEST_ASSERT(robot.health < robot.maxHealth, "The robot bonus did no damage") + + var/obj/machinery/porta_turret/turret = allocate(/obj/machinery/porta_turret, get_step(start, NORTH)) + TEST_ASSERT(wire.discharge_into_machine(turret, user), "The wire didn't short a porta-turret") + TEST_ASSERT(!COOLDOWN_FINISHED(turret, disabled_time), "A shorted porta-turret isn't disabled") + var/obj/machinery/porta_turret/second_turret = allocate(/obj/machinery/porta_turret, get_step(start, NORTH)) + TEST_ASSERT(!wire.discharge_into_machine(second_turret, user), "The wire shorted a second machine before it recharged") + TEST_ASSERT(COOLDOWN_FINISHED(second_turret, disabled_time), "A turret was disabled with the wire recharging") + /// (f) Chrome read: the optics' scan resolves installed hardware by name at /// itemized resolution, rates parlor chrome by tier and everything else /// UNRATED, counts either way, and NEVER resolves anything flagged From 18b44aa38aedfc244e7fa24d6050beedd7e9794e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:04:40 -0800 Subject: [PATCH 116/472] Add a street ripperdoc and Chrome Cradle at Quartermain Depot Ratchet runs the Back Bay, a small clinic off the depot's upper landing with its own Chrome Cradle and turret. He sells the credit-priced street chrome from Splice's own SKU types, so prices and the cradle's quotes stay identical, and buys back the same pulled street rungs at the same pay. His NPC subtypes Splice's, so the cradle's install lines come from him. --- tgstation.dme | 1 + .../outposts/trader_outpost_outfitter.dmm | 277 +++++++++++++----- .../modules/cyberware/street_doc_catalog.dm | 158 ++++++++++ .../modules/unit_tests/voidcrew_cyberware.dm | 43 +++ 4 files changed, 412 insertions(+), 67 deletions(-) create mode 100644 voidcrew/modules/cyberware/street_doc_catalog.dm diff --git a/tgstation.dme b/tgstation.dme index 9ec11de20ec..43017d1640e 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7181,6 +7181,7 @@ #include "voidcrew\modules\cyberware\cyberware_stealth.dm" #include "voidcrew\modules\cyberware\parlor_fixtures.dm" #include "voidcrew\modules\cyberware\ripperdoc_catalog.dm" +#include "voidcrew\modules\cyberware\street_doc_catalog.dm" #include "voidcrew\modules\cyberware\ware_arm_utility.dm" #include "voidcrew\modules\cyberware\ware_legend.dm" #include "voidcrew\modules\cyberware\ware_line_berserk.dm" diff --git a/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm b/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm index 2d4848184b2..82854e88a5b 100644 --- a/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm +++ b/voidcrew/_maps/map_files/outposts/trader_outpost_outfitter.dmm @@ -372,6 +372,10 @@ /obj/effect/landmark/outpost_network_pad_spot, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost/checkpoint) +"bc" = ( +/obj/structure/sink/directional/north, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "bd" = ( /obj/effect/turf_decal/arrows{ dir = 2 @@ -748,6 +752,10 @@ }, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) +"dg" = ( +/obj/effect/decal/cleanable/blood/gibs/robot_debris/old, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "dD" = ( /obj/effect/turf_decal/stripes, /obj/effect/turf_decal/siding/thinplating_new/dark{ @@ -1043,6 +1051,13 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"gS" = ( +/obj/item/trash/can{ + pixel_w = -6; + pixel_z = -4 + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "gT" = ( /obj/effect/turf_decal/siding/thinplating_new/dark, /obj/effect/decal/cleanable/dirt, @@ -1171,6 +1186,9 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible/dark/smooth_large, /area/voidcrew/trader_outpost) +"iQ" = ( +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "iR" = ( /obj/effect/turf_decal/siding/thinplating_new/dark{ dir = 8 @@ -1292,6 +1310,12 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"ku" = ( +/obj/structure/rack, +/obj/structure/sign/poster/contraband/cybersun_six_hundred/directional/north, +/obj/effect/decal/cleanable/dirt, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "kv" = ( /obj/effect/turf_decal/siding/thinplating_new/dark/corner{ dir = 1 @@ -1377,6 +1401,14 @@ }, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"ld" = ( +/obj/structure/table, +/obj/item/cigbutt{ + pixel_w = 5; + pixel_z = 7 + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "lw" = ( /obj/structure/rack, /obj/effect/turf_decal/trimline/red/filled/line{ @@ -1402,6 +1434,11 @@ "ly" = ( /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"lz" = ( +/mob/living/basic/outpost_trader/ripperdoc/street_doc, +/obj/machinery/light/small/dim/directional/north, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "lE" = ( /obj/structure/window/reinforced/shuttle/indestructible, /turf/open/indestructible/plating, @@ -1533,6 +1570,10 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"nV" = ( +/obj/machinery/light/small/directional/west, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "oi" = ( /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 1 @@ -1631,18 +1672,6 @@ /obj/machinery/newscaster/directional/east, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) -"pC" = ( -/obj/effect/turf_decal/tile/yellow/anticorner/contrasted{ - dir = 4 - }, -/obj/effect/turf_decal/siding/thinplating_new{ - dir = 5 - }, -/obj/item/kirbyplants/organic/plant22, -/obj/effect/decal/cleanable/cobweb/cobweb2, -/obj/effect/decal/cleanable/dirt, -/turf/open/indestructible, -/area/voidcrew/trader_outpost) "pE" = ( /obj/effect/turf_decal/tile/dark/full, /obj/effect/invisible_wall, @@ -1731,6 +1760,11 @@ /obj/effect/decal/cleanable/dirt/dust, /turf/open/indestructible/dark/textured, /area/voidcrew/trader_outpost) +"qA" = ( +/obj/structure/fluff/shower_drain, +/obj/effect/decal/cleanable/blood/old, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "qN" = ( /obj/structure/closet/crate/secure{ name = "chassis crate"; @@ -1757,6 +1791,12 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"qZ" = ( +/obj/machinery/door/airlock/maintenance{ + name = "Back Bay" + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "rd" = ( /obj/effect/turf_decal/trimline/yellow/filled/line{ dir = 8 @@ -1771,6 +1811,10 @@ /obj/structure/chair/office/light, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"rg" = ( +/obj/structure/closet/crate/bin, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "rj" = ( /obj/structure/table/reinforced/rglass, /obj/structure/desk_bell{ @@ -1813,6 +1857,16 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"sd" = ( +/obj/machinery/porta_turret/outpost, +/obj/effect/turf_decal/box/red/corners{ + dir = 1 + }, +/obj/effect/turf_decal/box/red/corners{ + dir = 8 + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "sf" = ( /obj/structure/closet/secure_closet/outpost_rental, /obj/effect/turf_decal/siding/wideplating_new/dark{ @@ -1828,6 +1882,14 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"sh" = ( +/obj/structure/closet/crate{ + anchored = 1; + name = "parts crate" + }, +/obj/effect/decal/cleanable/cobweb, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "si" = ( /obj/effect/turf_decal/siding/wideplating_new/dark{ dir = 4 @@ -1928,6 +1990,14 @@ }, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"tf" = ( +/obj/structure/table, +/obj/structure/fluff/paper/stack{ + pixel_w = -3; + pixel_z = 3 + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "tj" = ( /obj/structure/closet/secure_closet/outpost_rental, /obj/effect/turf_decal/siding/wideplating_new/dark{ @@ -1959,6 +2029,11 @@ }, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"tD" = ( +/obj/machinery/chrome_cradle, +/obj/effect/decal/cleanable/blood/old, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "tH" = ( /obj/effect/turf_decal/siding/thinplating_new/corner{ dir = 4 @@ -2075,6 +2150,10 @@ /obj/machinery/light/floor, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"uM" = ( +/obj/effect/decal/cleanable/dirt, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "vb" = ( /obj/structure/fluff/paper/stack{ dir = 1; @@ -2190,6 +2269,10 @@ }, /turf/open/indestructible/white/smooth_large, /area/voidcrew/trader_outpost) +"wK" = ( +/obj/machinery/light/cold/directional/east, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "wM" = ( /obj/structure/table, /obj/item/storage/medkit/regular, @@ -2375,6 +2458,11 @@ /obj/machinery/light/directional/north, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"zp" = ( +/obj/structure/rack, +/obj/effect/decal/cleanable/cobweb/cobweb2, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "zw" = ( /obj/structure/chair{ dir = 1 @@ -2629,6 +2717,11 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"CD" = ( +/obj/structure/chair/stool, +/obj/effect/decal/cleanable/dirt, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "CQ" = ( /obj/effect/turf_decal/tile/yellow/half/contrasted{ dir = 8 @@ -2765,6 +2858,19 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"EW" = ( +/obj/effect/turf_decal/tile/yellow/anticorner/contrasted{ + dir = 4 + }, +/obj/effect/turf_decal/siding/thinplating_new{ + dir = 5 + }, +/obj/item/kirbyplants/organic/plant22, +/obj/effect/decal/cleanable/cobweb/cobweb2, +/obj/effect/decal/cleanable/dirt, +/obj/structure/sign/poster/contraband/interdyne_gene_clinics/directional/east, +/turf/open/indestructible, +/area/voidcrew/trader_outpost) "EX" = ( /obj/effect/turf_decal/tile/dark/full, /obj/effect/invisible_wall, @@ -2778,6 +2884,14 @@ /obj/item/radio/intercom/directional/north, /turf/open/indestructible/hoteltile, /area/voidcrew/trader_outpost) +"Ff" = ( +/obj/structure/table, +/obj/structure/desk_bell{ + pixel_w = 0; + pixel_z = 4 + }, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Fx" = ( /obj/structure/closet/secure_closet/outpost_rental, /obj/effect/turf_decal/siding/wideplating_new/dark{ @@ -2836,6 +2950,10 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"Ga" = ( +/obj/machinery/vending/wallmed/directional/east, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Gd" = ( /obj/effect/turf_decal/siding/wideplating_new/light{ dir = 8 @@ -2854,6 +2972,10 @@ /obj/effect/decal/cleanable/dirt, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"Gk" = ( +/obj/structure/mirror/directional/east, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Gt" = ( /obj/effect/turf_decal/tile/dark/full, /obj/structure/sign/poster/official/get_your_legs{ @@ -2972,6 +3094,10 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"Iu" = ( +/obj/item/cigbutt, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Iw" = ( /turf/open/indestructible/hoteltile, /area/voidcrew/trader_outpost) @@ -3299,6 +3425,10 @@ /obj/structure/extinguisher_cabinet/directional/west, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"MB" = ( +/obj/effect/decal/cleanable/blood/drip, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "MI" = ( /obj/effect/spawner/random/trash/soap, /obj/machinery/light/small/directional/south, @@ -3314,6 +3444,10 @@ }, /turf/open/indestructible/large, /area/voidcrew/trader_outpost) +"MW" = ( +/obj/effect/decal/cleanable/blood/oil, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Na" = ( /obj/structure/table, /obj/effect/spawner/random/food_or_drink/donkpockets, @@ -3668,6 +3802,11 @@ }, /turf/open/indestructible/dark, /area/voidcrew/trader_outpost) +"Uc" = ( +/obj/effect/decal/cleanable/blood/gibs/robot_debris/limb, +/obj/item/cigbutt, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "Ud" = ( /obj/effect/turf_decal/bot, /obj/structure/closet/crate, @@ -3924,6 +4063,10 @@ }, /turf/open/indestructible, /area/voidcrew/trader_outpost) +"ZP" = ( +/obj/structure/table, +/turf/open/indestructible/freezer, +/area/voidcrew/trader_outpost) "ZV" = ( /obj/effect/decal/cleanable/dirt/dust, /obj/machinery/light/floor, @@ -5368,7 +5511,7 @@ ai fp vS dU -pC +EW hE gQ Vl @@ -5412,7 +5555,7 @@ CS dU dU dU -dU +qZ dU dU dU @@ -5452,13 +5595,13 @@ lE lE dU dU -aV -aV -aV -aV -aV -aV -aV +sh +ZP +uM +nV +uM +rg +dU dU dU lE @@ -5492,15 +5635,15 @@ ao ao ao ao -ao -aF -aV -aV -aV -aV -aV aV dU +ku +tf +Iu +CD +uM +bc +dU dU FC oU @@ -5534,15 +5677,15 @@ ao ao ao ao -ao -ao -ao -aV -aV -aV -aV aV dU +Uc +ZP +gS +iQ +MW +dg +dU cI cY gg @@ -5576,14 +5719,14 @@ ao ao ao ao -ao -ao -ao -ao -ao -aV -aV aV +dU +lz +Ff +uM +MB +tD +qA lE aE Hd @@ -5618,14 +5761,14 @@ ao ao ao ao -ao -ao -ao -ao -ao -ao -ao -aF +aV +dU +zp +ld +Ga +Gk +wK +sd dU tj AY @@ -5660,14 +5803,14 @@ ao ao ao ao -ao -ao -ao -ao -ao -ao -ao -ao +aV +dU +dU +dU +dU +dU +dU +dU dU dU sf @@ -5702,15 +5845,15 @@ ao ao ao ao -ao -ao -ao -ao -ao -ao -ao -ao -ao +aF +aV +aV +aV +aV +aV +aV +aF +aV dU dU dU diff --git a/voidcrew/modules/cyberware/street_doc_catalog.dm b/voidcrew/modules/cyberware/street_doc_catalog.dm new file mode 100644 index 00000000000..b4f70ecae18 --- /dev/null +++ b/voidcrew/modules/cyberware/street_doc_catalog.dm @@ -0,0 +1,158 @@ +/** + * # The Back Bay: Ratchet, street ripperdoc + * + * Quartermain's chrome counter: a vendor stall in a storage bay off the depot's + * upper landing, with its own Chrome Cradle. Ratchet is a loader mechanic who + * started putting chrome in people, and the shelf shows it: street chrome only, + * nothing that needs a voucher. + * + * He lists the SAME /datum/shop_sku/ripperdoc types Splice does, not copies. + * Each shop builds its own SKU instances, so his stock is his own, but the + * price of a piece can never drift between the two counters. That matters + * because the cradle quotes one price per organ type + * (build_cyberware_price_index() in cradle_ui.dm), and because + * voidcrew_zone_shops/catalog pins the reputation gate of every SKU type that + * exists, so a parallel set of street-doc SKU types would need its own + * entries there for no gain. + * + * His intake ledger takes the same street rungs as Splice's at the same pay + * (subtypes of Splice's buybacks, with his own wording). Like every ledger it + * refuses trader goods (is_trader_good()), so it only ever buys chrome that + * was looted, never chrome bought at a counter and pulled again. + * + * Catalog and NPC only: the organs live in ware_*.dm, the cradle in + * chrome_cradle.dm, the room on the Quartermain map + * (trader_outpost_outfitter.dmm). The NPC is a subtype of Splice's so the + * cradle's install patter (play_ripperdoc_bark()) comes out of his mouth. + */ + +/** + * Ratchet's look: a black robotics jumpsuit under a leather wader bib, the + * kind you hose down. Insulated gloves because half the job is wiring, welding + * goggles pushed up because the other half is welding, a hand-rolled cigarette, + * and a hand drill he uses on loaders and patients alike. Kept clear of Splice + * (no scrubs, no leather jacket) and of Sawbones (no head mirror). + * Purely cosmetic, the NPC snapshot only copies appearance. + */ +/datum/outfit/street_doc_ratchet + name = "Street ripperdoc" + uniform = /obj/item/clothing/under/rank/rnd/roboticist + suit = /obj/item/clothing/suit/apron/waders + gloves = /obj/item/clothing/gloves/color/yellow + shoes = /obj/item/clothing/shoes/workboots + glasses = /obj/item/clothing/glasses/welding/up + mask = /obj/item/cigarette/rollie + r_hand = /obj/item/screwdriver/power + +/datum/outpost_shop/vendor/street_doc + outpost_name = "\improper The Back Bay" + outpost_desc = "The depot's ripperdoc, in a storage bay he rents by the week." + trader_name = "Ratchet" + trader_outfit = /datum/outfit/street_doc_ratchet + trader_gender = MALE + trader_voice_pack = "goon.speak_3" + trader_voice_pitch = 0.88 + // A SKU whose category is missing here never renders on the storefront + // (voidcrew_shop_catalog checks it). Add the line models' category if it + // is not "Street Chrome". + categories = list( + "Street Chrome", + ) + sku_types = list( + // ---- Street Chrome (credits), Splice's own SKU types ---- + /datum/shop_sku/ripperdoc/chromatic_dermis, + /datum/shop_sku/ripperdoc/gastro, + /datum/shop_sku/ripperdoc/shock_coils, + /datum/shop_sku/ripperdoc/nightshade, + /datum/shop_sku/ripperdoc/scrapper, + /datum/shop_sku/ripperdoc/gecko, + /datum/shop_sku/ripperdoc/second_wind, + /datum/shop_sku/ripperdoc/cargo_cavity, + /datum/shop_sku/ripperdoc/rockjaw, + /datum/shop_sku/ripperdoc/fixers, + /datum/shop_sku/ripperdoc/dermal_mesh, + // ---- Street models from the cyberware lines (credit-priced T2) ---- + // CYBERWARE LINES: T2 SKUS START + // CYBERWARE LINES: T2 SKUS END + ) + buyback_types = list( + /datum/shop_buyback/ripperdoc/nightshade/street_doc, + /datum/shop_buyback/ripperdoc/shock_coils/street_doc, + /datum/shop_buyback/ripperdoc/second_wind/street_doc, + /datum/shop_buyback/ripperdoc/dermal_mesh/street_doc, + /datum/shop_buyback/ripperdoc/scrapper/street_doc, + // CYBERWARE LINES: T2 BUYBACKS START + // CYBERWARE LINES: T2 BUYBACKS END + ) + trader_lines = list( + TRADER_LINE_GREETING = list( + "Shut the door, you're letting the good air out.", + "Chair's free. The chrome isn't.", + "Don't mind the bucket.", + "You want it fancy, the Undertow's that way. You want it today, sit down.", + ), + TRADER_LINE_SALE = list( + "Done. Lie down whenever you're ready.", + "Good pick. I've put in a hundred of those. Most of them still work.", + "Wipe the slab before you lie on it.", + "Pleasure doing business.", + "There you go. Don't tell Sarge what you paid.", + ), + TRADER_LINE_REFUSAL = list( + "Your ship's on the list. Can't help you.", + "Not today. Sarge would have my bay.", + "No. Go square it with the depot first.", + ), + TRADER_LINE_WARNING = list( + "Easy. The turrets in here don't miss.", + "Do that again and the whole depot stops serving you.", + "I patch people up. I'd rather not start with you.", + ), + TRADER_LINE_AGGRESSION = list( + "Out of my bay!", + "Sarge! Got one for you!", + "Wrong guy to swing at.", + ), + TRADER_LINE_IDLE = list( + "That cradle came off a wreck. Don't ask whose.", + "Splice has the nicer chair. I've got the shorter flight.", + "I used to fix loaders. An arm's just a small loader.", + "The drain in the floor? Don't worry about the drain.", + "Most of my customers come back. Some of them on purpose.", + "The dockhands keep asking for discounts. I keep saying no.", + ), + TRADER_LINE_RESTOCK = list( + "Convoy's in. New chrome, some of it barely used.", + "Fresh crate. Still smells like the last owner.", + "Restocked. Get in before the dockhands do.", + ), + ) + +// ========================================================================= +// RATCHET'S INTAKE LEDGER +// Splice's ledger entries with Ratchet's wording. Same goods, same pay. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/nightshade/street_doc + desc = "Used low-light eyes." + +/datum/shop_buyback/ripperdoc/shock_coils/street_doc + desc = "Used calf pistons." + +/datum/shop_buyback/ripperdoc/second_wind/street_doc + desc = "A used air bladder." + +/datum/shop_buyback/ripperdoc/dermal_mesh/street_doc + desc = "Used subdermal plating." + +/datum/shop_buyback/ripperdoc/scrapper/street_doc + desc = "One used knuckle plate, either hand." + +// ========================================================================= +// THE NPC +// ========================================================================= + +/// Ratchet behind his counter. A ripperdoc subtype, so the cradle in his bay +/// talks through him during installs. +/mob/living/basic/outpost_trader/ripperdoc/street_doc + shop_type = /datum/outpost_shop/vendor/street_doc diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index 1a7d1dfcc3d..235cf7fb5d2 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -415,6 +415,49 @@ TEST_ASSERT(paired["vouchers"] > 0, "[half] indexed with no voucher price") TEST_ASSERT(paired["paired"], "[half] came out of a pair case without the paired flag") +/// (h2) Ratchet, the street doc at Quartermain: credit sales only, every piece +/// he sells has a cradle quote that matches his shelf, his ledger never pays +/// vouchers, and on the map he stands in view of his own cradle, so the install +/// patter (play_ripperdoc_bark) comes out of his mouth. +/datum/unit_test/voidcrew_street_doc + +/datum/unit_test/voidcrew_street_doc/Run() + build_cyberware_price_index() + var/datum/outpost_shop/vendor/street_doc/shop = allocate(/datum/outpost_shop/vendor/street_doc) + TEST_ASSERT(length(shop.skus), "The street doc has nothing on his shelf") + for(var/datum/shop_sku/sku as anything in shop.skus) + TEST_ASSERT(istype(sku, /datum/shop_sku/ripperdoc), "[sku.type] at the street doc is not ripperdoc stock, so the cradle never quotes it") + TEST_ASSERT(sku.price_credits > 0 && !sku.price_vouchers, "[sku.type] at the street doc is not a credit sale") + if(!ispath(sku.item_path, /obj/item/organ)) + continue + var/list/quote = GLOB.cyberware_price_index[sku.item_path] + TEST_ASSERT(!isnull(quote), "The cradle has no price for [sku.item_path], which the street doc sells") + if(quote) + TEST_ASSERT_EQUAL(quote["credits"], sku.price_credits, "The cradle quotes [sku.item_path] differently from the street doc's shelf") + TEST_ASSERT(length(shop.buybacks), "The street doc buys nothing back") + for(var/datum/shop_buyback/buyback as anything in shop.buybacks) + TEST_ASSERT(buyback.pay_credits > 0 && !buyback.pay_vouchers, "[buyback.type] at the street doc pays vouchers for chrome a ship can buy") + + var/obj/structure/overmap/trader_outpost/outpost = allocate(/obj/structure/overmap/trader_outpost/outfitter) + TEST_ASSERT(outpost.load_level(), "Quartermain's interior failed to load") + var/turf/bottom_left = outpost.template_bottom_left + var/turf/top_right = locate(bottom_left.x + outpost.outpost_template.width - 1, bottom_left.y + outpost.outpost_template.height - 1, bottom_left.z) + var/list/docs = list() + var/list/cradles = list() + for(var/turf/interior as anything in block(bottom_left, top_right)) + for(var/mob/living/basic/outpost_trader/ripperdoc/street_doc/doc in interior) + docs += doc + for(var/obj/machinery/chrome_cradle/cradle in interior) + cradles += cradle + TEST_ASSERT_EQUAL(length(docs), 1, "Quartermain has [length(docs)] street docs, not one") + TEST_ASSERT_EQUAL(length(cradles), 1, "Quartermain has [length(cradles)] chrome cradles, not one") + var/mob/living/basic/outpost_trader/ripperdoc/street_doc/ratchet = docs[1] + var/obj/machinery/chrome_cradle/rig = cradles[1] + TEST_ASSERT(istype(ratchet.shop, /datum/outpost_shop/vendor/street_doc), "The street doc is not running his own counter") + TEST_ASSERT(ratchet in view(7, rig), "The street doc can't see his cradle, so installs there run without him") + TEST_ASSERT_EQUAL(get_trader_outpost_for_turf(get_turf(rig)), outpost, "Quartermain's cradle does not know which outpost it is in") + qdel(outpost) + /// Recheck the complete final installation after the delay, before touching incumbents. /datum/unit_test/voidcrew_cradle_installed_capacity From e880f3ba357d25054b9b8c9e9d7df82703d0cb38 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:24:42 -0800 Subject: [PATCH 117/472] Wire the cyberware lines into both ripperdocs and the zone loot Splice lists every new model; Ratchet at Quartermain lists the five credit-priced street models. Both buy the street models back at half list. Street models drop from uncommon tiers and service models from prime; the Shiv and Scorch pairs and the Widowline Arc ride the weapon extras. The ripperdoc trade-in now takes chrome bought at a counter. Every other ledger and every export still refuses shop goods. No ledger can take an organ that is still installed in the seller. Also renumbers the rating ranks in the groups the lines landed in, and drops a product name from the Cascade's text. --- tools/gear_catalog/ratings/cyberware.json | 38 +++---- tools/gear_catalog/ratings/melee.json | 10 +- .../modules/cyberware/ripperdoc_catalog.dm | 28 ++++- .../modules/cyberware/street_doc_catalog.dm | 31 +++++- voidcrew/modules/cyberware/ware_legend.dm | 2 +- voidcrew/modules/cyberware/ware_line_sandy.dm | 2 +- voidcrew/modules/loot/themes/armory.dm | 12 ++ voidcrew/modules/loot/themes/plunder.dm | 9 +- voidcrew/modules/loot/themes/syndicate.dm | 12 ++ voidcrew/modules/trade/shop_buyback.dm | 43 ++++++- .../modules/unit_tests/voidcrew_cyberware.dm | 105 ++++++++++++++++++ voidcrew/modules/unit_tests/voidcrew_loot.dm | 3 + .../unit_tests/voidcrew_shop_catalog.dm | 5 +- .../modules/unit_tests/voidcrew_zone_shops.dm | 10 +- 14 files changed, 274 insertions(+), 36 deletions(-) diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index cb04383abe5..0bbf842ec06 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -19,7 +19,7 @@ "hash": "c229d282cf893570", "name": "Strong-Arm empowered musculature implant", "note": "Humans only. With the implanted arm active, an empty-hand combat-mode left-click on a mob becomes a slam on a 5 s cooldown: the hand's unarmed damage roll plus 5 (plus 20 more against beast, special and mining mobs), doubled and run through melee armor, and the target is thrown 1 tile if the wearer is standing; it is not used while Hulk. One implant in each arm lets the wearer pry closed airlocks open bare-handed (6 s, instant if unpowered, refused if bolted or welded). An EMP puts the arm down for 9 s (4.5 s on a light pulse): slams fail, and half the time the wearer lunges onto the target and falls for 3 s, otherwise is paralyzed for 1 s.", - "rank": 6, + "rank": 9, "reason": "A slam for 20 to 30 damage every 5 s per arm, 60 to 70 against mining mobs and beasts, and a pair pries powered airlocks in 6 s. 1,200 cr for two from cargo. EMP makes the arm a liability for 9 s.", "tier": "yellow" }, @@ -37,7 +37,7 @@ "group": "Arm weapons and fists", "hash": "31fd669dadf0cf4c", "name": "Bunker Buster housing", - "rank": 5, + "rank": 8, "reason": "Two 60-damage rockets that floor the target and wreck doors and machines with no blast, about 10 on the gun score plus breaching. Two hands, two shots, then 600 cr at reputation 22 or late research. Loot only.", "tier": "yellow" }, @@ -76,7 +76,7 @@ "hash": "b190c2719a5d40f7", "name": "Mantis Blade housing (right)", "note": "Each housing extends a hardlight blade into the hand (it can't be dropped; retract stows it). An ability, usable from either housing with its blade out, dashes up to 5 tiles to a living target after a 0.3 s wind-up, lands 10 brute (armor applies, wounding) per extended blade on arrival and floors the target for 2 s. The 20 s cooldown is shared by both housings and is spent even if the dash falls short.", - "rank": 2, + "rank": 5, "reason": "Half of a pair: 20-force blades that can't be disarmed, plus a 5-tile lunge every 20 s that adds 20 damage and floors the target for 2 s. Load 6 the pair, 4 vouchers at reputation 22, one on the shelf per round.", "tier": "red" }, @@ -86,7 +86,7 @@ "hash": "91733b8b4ac9435a", "name": "Mantis Blade housing (left)", "note": "Each housing extends a hardlight blade into the hand (it can't be dropped; retract stows it). An ability, usable from either housing with its blade out, dashes up to 5 tiles to a living target after a 0.3 s wind-up, lands 10 brute (armor applies, wounding) per extended blade on arrival and floors the target for 2 s. The 20 s cooldown is shared by both housings and is spent even if the dash falls short.", - "rank": 3, + "rank": 6, "reason": "Left half of the Mantis pair: 20-force blades that can't be disarmed, plus a 5-tile lunge every 20 s that adds 20 damage and floors the target for 2 s.", "tier": "red" }, @@ -96,7 +96,7 @@ "hash": "", "name": "Scorch Blade housing (right)", "note": "Each landed cut adds 2 fire stacks and sets the target alight; the lunge adds 4 once, however many blades arrive. Corpses are left alone and the wearer is never lit. The blade is lit while out.", - "rank": 2, + "rank": 3, "reason": "Half of a pair: the Mantis blades and lunge, with every cut and the lunge setting the target alight. Load 8 the pair, 5 vouchers at reputation 22, one on the shelf per round.", "tier": "red" }, @@ -106,7 +106,7 @@ "hash": "", "name": "Scorch Blade housing (left)", "note": "Each landed cut adds 2 fire stacks and sets the target alight; the lunge adds 4 once, however many blades arrive. Corpses are left alone and the wearer is never lit. The blade is lit while out.", - "rank": 3, + "rank": 4, "reason": "Left half of the Scorch pair: the Mantis blades and lunge, with every cut and the lunge setting the target alight.", "tier": "red" }, @@ -115,7 +115,7 @@ "group": "Arm weapons and fists", "hash": "", "name": "Shiv Blade housing (right)", - "rank": 6, + "rank": 12, "reason": "Half of a pair: 14-force blades that can't be disarmed, no lunge. Load 4 the pair, 2,400 cr at yellow reputation. Splice takes a pulled housing back for 600 cr.", "tier": "yellow" }, @@ -124,7 +124,7 @@ "group": "Arm weapons and fists", "hash": "", "name": "Shiv Blade housing (left)", - "rank": 7, + "rank": 13, "reason": "Left half of the Shiv pair: 14-force blades that can't be disarmed, no lunge.", "tier": "yellow" }, @@ -133,7 +133,7 @@ "group": "Arm weapons and fists", "hash": "174a6ea665db7cac", "name": "Widowline spool housing", - "rank": 1, + "rank": 2, "reason": "20 damage a swing (15 brute, 5 burn) straight through all armor, reach 2, and it cuts neighbours for 75%. The best melee against armored crews, useless on walls and doors. Load 5, 3 vouchers at reputation 22, one on the shelf per round.", "tier": "red" }, @@ -162,7 +162,7 @@ "group": "Arm weapons and fists", "hash": "43903115df46709c", "name": "Popup Ronin housing", - "rank": 4, + "rank": 7, "reason": "A one-handed machine pistol in the arm that can't be disarmed: 20 damage, 3.3 shots a second, about a WT-550 on the gun score (47). 20-round magazines are 250 cr at reputation 22 or late research. Loot only.", "tier": "yellow" }, @@ -333,7 +333,7 @@ "group": "Operating system", "hash": "", "name": "Spliced reflex rig", - "rank": 5, + "rank": 7, "reason": "Six seconds in every 120 at a clear speed boost, melee a quarter quicker and 15% dodge while moving, then eight seconds of crash and 100 stamina. Load 6, 3,600 cr at reputation 4. An EMP mid-burst ends it.", "tier": "yellow" }, @@ -433,7 +433,7 @@ "hash": "9c748c5bf556a0e0", "name": "Gorilla Arms myomer lattice (right)", "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a flat 20 brute (armor rolled, +10 wound bonus) with a 1-tile knockback on a mob, 4 s cooldown per arm; a mob held in an aggressive grab is hurled 4 tiles. On a rock wall it drills it, and on a structure or machine with integrity it does 40 brute (1 s cooldown for both). With both arms, closed airlocks can be pried bare-handed (4 s if powered, instant if unpowered, refused if bolted or welded). Not used while Hulk or while the ware is down.", - "rank": 7, + "rank": 10, "reason": "Half of a pair: a flat 20 damage punch every 4 s, 40 to machines and structures every second, breaks rock, and the pair pries powered airlocks in 4 s. A breaching tool more than a weapon. Load 6 the pair.", "tier": "yellow" }, @@ -443,7 +443,7 @@ "hash": "25756d0541291f90", "name": "Gorilla Arms myomer lattice (left)", "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a flat 20 brute (armor rolled, +10 wound bonus) with a 1-tile knockback on a mob, 4 s cooldown per arm; a mob held in an aggressive grab is hurled 4 tiles. On a rock wall it drills it, and on a structure or machine with integrity it does 40 brute (1 s cooldown for both). With both arms, closed airlocks can be pried bare-handed (4 s if powered, instant if unpowered, refused if bolted or welded). Not used while Hulk or while the ware is down.", - "rank": 8, + "rank": 11, "reason": "Left half of the Gorilla pair: a flat 20 damage punch every 4 s, 40 to machines and structures every second, breaks rock, and the pair pries powered airlocks in 4 s.", "tier": "yellow" }, @@ -461,7 +461,7 @@ "group": "Operating system", "hash": "", "name": "Governor Bypass", - "rank": 4, + "rank": 6, "reason": "Raises the neural load limit from 20 to 30 for no load of its own, in the slot Cascade and Redline need. 3 vouchers at reputation 11.", "tier": "red" }, @@ -470,7 +470,7 @@ "group": "Operating system", "hash": "", "name": "Governor Patch", - "rank": 5, + "rank": 9, "reason": "Raises the neural load limit from 20 to 25 for no load of its own, in the slot Cascade and Redline need. 2,500 cr at reputation 4.", "tier": "yellow" }, @@ -526,7 +526,7 @@ "group": "Operating system", "hash": "", "name": "Vanguard core", - "rank": 4, + "rank": 5, "reason": "Ten seconds in every 105: stun immune, 30% less brute and burn, 5 more on punches, and a roar that staggers everyone within 4 tiles. Then 100 stamina and slow legs. Load 8, 4 vouchers at reputation 11.", "tier": "red" }, @@ -535,7 +535,7 @@ "group": "Operating system", "hash": "", "name": "Hothead core", - "rank": 5, + "rank": 8, "reason": "Eight seconds in every 120: 20% less brute and burn and 3 more on punches, with no stun immunity and no roar. Then 100 stamina and slow legs. Load 5, 3,000 cr at reputation 4.", "tier": "yellow" }, @@ -565,7 +565,7 @@ "hash": "e057ede2eff30ba2", "name": "Scrapper's Knuckles", "note": "Replaces the bearer's own punch (combat mode, left click, not a Hulk) with that hand's normal unarmed roll plus 4 flat damage against mobs, and double that total against machines and structures. Humans can still block it and armor still applies; it stops working while the ware is EMP'd or browned out.", - "rank": 9, + "rank": 14, "reason": "Half of a pair: punches do 4 more, double against machines. A cheap brawler's first step. Load 2 the pair, 800 cr.", "tier": "green" }, @@ -575,7 +575,7 @@ "hash": "1262168b74587722", "name": "Scrapper's Knuckles", "note": "Replaces the bearer's own punch (combat mode, left click, not a Hulk) with that hand's normal unarmed roll plus 4 flat damage against mobs, and double that total against machines and structures. Humans can still block it and armor still applies; it stops working while the ware is EMP'd or browned out.", - "rank": 10, + "rank": 15, "reason": "Left half of the Scrapper pair: punches do 4 more, double against machines.", "tier": "green" }, diff --git a/tools/gear_catalog/ratings/melee.json b/tools/gear_catalog/ratings/melee.json index 1f3fad84e35..ad2dbd69153 100644 --- a/tools/gear_catalog/ratings/melee.json +++ b/tools/gear_catalog/ratings/melee.json @@ -1161,7 +1161,7 @@ "hash": "c34e783490904319", "name": "Widowline monowire", "note": "Each landed hit adds 5 burn damage and cleaves every living mob next to the target that the target can see and that is not on the wielder's crew team, for 75% of the hit's damage in brute and burn. Its armour penetration is the full 100.", - "rank": 1, + "rank": 2, "reason": "Rated with its arm housing: 25 a second through any armor at 2 tiles, and the hit carries into every enemy beside the target. Hidden until used. Needs surgery.", "tier": "red" }, @@ -1180,7 +1180,7 @@ "group": "arm blades", "hash": "e09509a45772dc39", "name": "mantis blade", - "rank": 2, + "rank": 4, "reason": "Rated with its arm housing: 20 force blades that cannot be disarmed, and a 5 tile lunge every 20 s that floors the target for 2 s. Needs surgery.", "tier": "red" }, @@ -1190,7 +1190,7 @@ "hash": "", "name": "scorch blade", "note": "Each landed cut adds 2 fire stacks and sets the target alight. Corpses are left alone and the wielder is never lit.", - "rank": 2, + "rank": 3, "reason": "Rated with its arm housing: the Mantis blade and lunge, with every cut and the lunge setting the target alight. Needs surgery.", "tier": "red" }, @@ -1199,7 +1199,7 @@ "group": "arm blades", "hash": "", "name": "shiv blade", - "rank": 3, + "rank": 5, "reason": "Rated with its arm housing: 14 force blades that cannot be disarmed, no lunge. Needs surgery.", "tier": "yellow" }, @@ -1344,7 +1344,7 @@ "group": "arm blades", "hash": "63a21bf583afc355", "name": "synthetic arm blade", - "rank": 3, + "rank": 6, "reason": "20 force blade grown on a hacked limb grower. Huge, no parry. The route was not fully traced.", "tier": "green" }, diff --git a/voidcrew/modules/cyberware/ripperdoc_catalog.dm b/voidcrew/modules/cyberware/ripperdoc_catalog.dm index 4dfd8a8e01a..b52b5ed95f9 100644 --- a/voidcrew/modules/cyberware/ripperdoc_catalog.dm +++ b/voidcrew/modules/cyberware/ripperdoc_catalog.dm @@ -73,6 +73,11 @@ /datum/shop_sku/ripperdoc/rockjaw, /datum/shop_sku/ripperdoc/fixers, /datum/shop_sku/ripperdoc/dermal_mesh, + /datum/shop_sku/ripperdoc/ghostskin_street, + /datum/shop_sku/ripperdoc/mantis_street, + /datum/shop_sku/ripperdoc/governor_street, + /datum/shop_sku/ripperdoc/redline_street, + /datum/shop_sku/ripperdoc/cascade_street, // ---- Pro Chrome (mixed) ---- /datum/shop_sku/ripperdoc/angler, /datum/shop_sku/ripperdoc/hemoglass, @@ -90,11 +95,16 @@ /datum/shop_sku/ripperdoc/atlas, /datum/shop_sku/ripperdoc/gorilla, /datum/shop_sku/ripperdoc/monowire, + /datum/shop_sku/ripperdoc/monowire_live, /datum/shop_sku/ripperdoc/rigger, /datum/shop_sku/ripperdoc/slabskin, /datum/shop_sku/ripperdoc/mantis, + /datum/shop_sku/ripperdoc/mantis_scorch, /datum/shop_sku/ripperdoc/ghostskin, /datum/shop_sku/ripperdoc/lazarus, + /datum/shop_sku/ripperdoc/governor_milspec, + /datum/shop_sku/ripperdoc/redline_milspec, + /datum/shop_sku/ripperdoc/cascade_milspec, // ---- Legend Chrome (the chase, always listed) ---- /datum/shop_sku/ripperdoc/governor_delete, /datum/shop_sku/ripperdoc/redline, @@ -110,6 +120,12 @@ /datum/shop_buyback/ripperdoc/second_wind, /datum/shop_buyback/ripperdoc/dermal_mesh, /datum/shop_buyback/ripperdoc/scrapper, + // The cyberware lines' street models (ware_line_*.dm). + /datum/shop_buyback/ripperdoc/ghostskin_street, + /datum/shop_buyback/ripperdoc/mantis_street, + /datum/shop_buyback/ripperdoc/governor_street, + /datum/shop_buyback/ripperdoc/redline_street, + /datum/shop_buyback/ripperdoc/cascade_street, ) trader_lines = list( TRADER_LINE_GREETING = list( @@ -444,7 +460,7 @@ /datum/shop_sku/ripperdoc/cascade zone_permission = ZONE_PERMISSION_ELITE category = "Legend Chrome" - desc = "A Sandevistan reflex lattice in a sealed surgical case." + desc = "A full-spine reflex lattice in a sealed surgical case." item_path = /obj/item/organ/cyberimp/cyberware/cascade price_vouchers = 8 stock_min = 1 @@ -479,8 +495,18 @@ // Half list, credits only, street rungs only. Removed chrome resold here is // always a loss (you can't refund vouchers, and 50% never beats buying up the // ladder), so this reads as "trade up" and never as an arbitrage loop. +// +// The ripperdoc trade-in is the one ledger that takes chrome bought over a +// counter (owner decision, 2026-11-07): every other buyer and every export +// refuses anything carrying the trader-goods mark (trade/resale.dm). It holds +// because each entry pays half the lowest list price of the piece and a special +// takes at most 30% off, so a bought piece always sells back at a loss. The +// voidcrew_ripperdoc_resale unit test pins both halves of that. // ========================================================================= +/datum/shop_buyback/ripperdoc + buys_shop_stock = TRUE + /datum/shop_buyback/ripperdoc/nightshade name = "Nightshade Optics (pulled)" desc = "Old low-light eyes. Splice takes them off your hands when you move up to a targeting rig." diff --git a/voidcrew/modules/cyberware/street_doc_catalog.dm b/voidcrew/modules/cyberware/street_doc_catalog.dm index b4f70ecae18..e4629d9d5c3 100644 --- a/voidcrew/modules/cyberware/street_doc_catalog.dm +++ b/voidcrew/modules/cyberware/street_doc_catalog.dm @@ -16,9 +16,9 @@ * entries there for no gain. * * His intake ledger takes the same street rungs as Splice's at the same pay - * (subtypes of Splice's buybacks, with his own wording). Like every ledger it - * refuses trader goods (is_trader_good()), so it only ever buys chrome that - * was looted, never chrome bought at a counter and pulled again. + * (subtypes of Splice's buybacks, with his own wording). Like Splice's, and + * unlike every other ledger, it takes chrome bought at a counter and pulled + * again (buys_shop_stock on /datum/shop_buyback/ripperdoc, ripperdoc_catalog.dm). * * Catalog and NPC only: the organs live in ware_*.dm, the cradle in * chrome_cradle.dm, the room on the Quartermain map @@ -73,6 +73,11 @@ /datum/shop_sku/ripperdoc/dermal_mesh, // ---- Street models from the cyberware lines (credit-priced T2) ---- // CYBERWARE LINES: T2 SKUS START + /datum/shop_sku/ripperdoc/ghostskin_street, + /datum/shop_sku/ripperdoc/mantis_street, + /datum/shop_sku/ripperdoc/governor_street, + /datum/shop_sku/ripperdoc/redline_street, + /datum/shop_sku/ripperdoc/cascade_street, // CYBERWARE LINES: T2 SKUS END ) buyback_types = list( @@ -82,6 +87,11 @@ /datum/shop_buyback/ripperdoc/dermal_mesh/street_doc, /datum/shop_buyback/ripperdoc/scrapper/street_doc, // CYBERWARE LINES: T2 BUYBACKS START + /datum/shop_buyback/ripperdoc/ghostskin_street/street_doc, + /datum/shop_buyback/ripperdoc/mantis_street/street_doc, + /datum/shop_buyback/ripperdoc/governor_street/street_doc, + /datum/shop_buyback/ripperdoc/redline_street/street_doc, + /datum/shop_buyback/ripperdoc/cascade_street/street_doc, // CYBERWARE LINES: T2 BUYBACKS END ) trader_lines = list( @@ -148,6 +158,21 @@ /datum/shop_buyback/ripperdoc/scrapper/street_doc desc = "One used knuckle plate, either hand." +/datum/shop_buyback/ripperdoc/ghostskin_street/street_doc + desc = "A used camo weave. Patchy, like they all are." + +/datum/shop_buyback/ripperdoc/mantis_street/street_doc + desc = "One used blade housing, either arm." + +/datum/shop_buyback/ripperdoc/governor_street/street_doc + desc = "A used governor patch. The tape's still on it." + +/datum/shop_buyback/ripperdoc/redline_street/street_doc + desc = "A used rage core. Smells burnt." + +/datum/shop_buyback/ripperdoc/cascade_street/street_doc + desc = "A used spinal rig, held together with tape." + // ========================================================================= // THE NPC // ========================================================================= diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 2ac37e5c48a..6f4705045e9 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -53,7 +53,7 @@ */ /obj/item/organ/cyberimp/cyberware/cascade name = "\improper Cascade lattice" - desc = "A full-spine lattice of superconducting myelin, a real sandevistan, not one of the knockoffs. For eight seconds at a time you move and nobody else really does. Then all the heat it just made has to go somewhere, and it goes into you." + desc = "A full-spine lattice of superconducting myelin, the real thing, not one of the knockoffs. For eight seconds at a time you move and nobody else really does. Then all the heat it just made has to go somewhere, and it goes into you." icon_state = "cascade" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_OS diff --git a/voidcrew/modules/cyberware/ware_line_sandy.dm b/voidcrew/modules/cyberware/ware_line_sandy.dm index eba5b85007f..7a560d4f5d1 100644 --- a/voidcrew/modules/cyberware/ware_line_sandy.dm +++ b/voidcrew/modules/cyberware/ware_line_sandy.dm @@ -103,7 +103,7 @@ /datum/shop_sku/ripperdoc/cascade_street zone_permission = ZONE_PERMISSION_YELLOW - category = "Pro Chrome" + category = "Street Chrome" item_path = /obj/item/organ/cyberimp/cyberware/cascade/street price_credits = 3600 diff --git a/voidcrew/modules/loot/themes/armory.dm b/voidcrew/modules/loot/themes/armory.dm index 55c12eb3b7d..689cc88ea01 100644 --- a/voidcrew/modules/loot/themes/armory.dm +++ b/voidcrew/modules/loot/themes/armory.dm @@ -8,6 +8,8 @@ // Cyberware: chrome a garrison would have issued rather than bought, light // weave and knuckle plate on the line, and in the back room the ceramic // plating, the targeting optics and the frame that stops a limb coming off. +// The governor and rage-core lines ride here too: the taped-over street +// models on the line, the surplus service models in the back room. // TODO: review/balance-pass all four tiers: first-draft weights and // contents, never playtested. Tune against the outfitter armor/gun prices // in theme_skus. @@ -139,6 +141,10 @@ /datum/shop_sku/ripperdoc/slabskin, /datum/shop_sku/ripperdoc/deadeye, /datum/shop_sku/ripperdoc/atlas, + /datum/shop_sku/ripperdoc/governor_street, + /datum/shop_sku/ripperdoc/governor_milspec, + /datum/shop_sku/ripperdoc/redline_street, + /datum/shop_sku/ripperdoc/redline_milspec, ) loot_common = list( /obj/item/clothing/suit/armor/vest = 10, @@ -160,6 +166,9 @@ /obj/item/ship_parts/combat = 7, // Issue chrome: subdermal impact weave. /obj/item/organ/cyberimp/cyberware/dermal_mesh = 4, + // a patched limiter and a cheap rage core, both off a deserter + /obj/item/organ/cyberimp/cyberware/governor_delete/street = 2, + /obj/item/organ/cyberimp/cyberware/redline/street = 2, ) loot_prime = list( /obj/item/clothing/suit/armor/vest/marine = 9, @@ -173,6 +182,9 @@ /obj/item/organ/cyberimp/cyberware/slabskin = 3, /obj/item/organ/eyes/robotic/cyberware/deadeye = 3, /obj/item/organ/cyberimp/cyberware/atlas = 2, + // surplus service chrome with the unit numbers still on it + /obj/item/organ/cyberimp/cyberware/governor_delete/milspec = 2, + /obj/item/organ/cyberimp/cyberware/redline/milspec = 2, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable diff --git a/voidcrew/modules/loot/themes/plunder.dm b/voidcrew/modules/loot/themes/plunder.dm index 64423778c6f..cfe53ef0321 100644 --- a/voidcrew/modules/loot/themes/plunder.dm +++ b/voidcrew/modules/loot/themes/plunder.dm @@ -6,7 +6,8 @@ // crews. // Cyberware: boarding chrome, and it reads as taken off somebody rather than // bought, grip pads at uncommon, and in the treasure the blade pair, the -// folding machine-pistol and the rocket pod. +// folding machine-pistol and the rocket pod. The cheap shiv pair is a yellow +// melee extra, the heat-sunk blade pair a red one beside the Mantis. // TODO: review/balance-pass all four tiers: first-draft weights and // contents, never playtested. // ========================================================================= @@ -30,9 +31,13 @@ ), ) melee_extras = list( + LOOT_WEAPON_YELLOW = list( + /obj/item/cyberware_pair_case/mantis_street = 1, + ), LOOT_WEAPON_RED = list( /obj/item/claymore/cutlass/parley = 1, /obj/item/cyberware_pair_case/mantis_blades = 1, + /obj/item/cyberware_pair_case/mantis_scorch = 1, /obj/item/heave_ho = 1, ), ) @@ -41,6 +46,8 @@ /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/ripperdoc/gecko, /datum/shop_sku/ripperdoc/mantis, + /datum/shop_sku/ripperdoc/mantis_street, + /datum/shop_sku/ripperdoc/mantis_scorch, /datum/shop_sku/ripperdoc/ronin, /datum/shop_sku/ripperdoc/bunker_buster, ) diff --git a/voidcrew/modules/loot/themes/syndicate.dm b/voidcrew/modules/loot/themes/syndicate.dm index eebf4f72ad0..0ee58a58926 100644 --- a/voidcrew/modules/loot/themes/syndicate.dm +++ b/voidcrew/modules/loot/themes/syndicate.dm @@ -9,6 +9,9 @@ // Cyberware rides the same two channels: the parlor's smuggling cavity and // door spike sit at uncommon, and the three voucher-only pieces an operative // would carry (cloak weave, wire, reflex shunt) sit at the bottom of prime. +// The camo and reflex lines follow the same split: the street weave and the +// taped reflex rig at uncommon, the service lattice in prime, and the live +// wire beside the plain one in the red melee extras. // ========================================================================= /datum/loot_theme/syndicate @@ -30,6 +33,7 @@ LOOT_WEAPON_RED = list( /obj/item/knife/understudy = 1, /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire = 1, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live = 1, ), ) guard_themes = list(/obj/effect/zone_mobs/syndicate, /obj/effect/zone_mobs/syndicate/boss) @@ -45,6 +49,10 @@ /datum/shop_sku/ripperdoc/slipwire, /datum/shop_sku/ripperdoc/monowire, /datum/shop_sku/ripperdoc/ghostskin, + /datum/shop_sku/ripperdoc/ghostskin_street, + /datum/shop_sku/ripperdoc/cascade_street, + /datum/shop_sku/ripperdoc/cascade_milspec, + /datum/shop_sku/ripperdoc/monowire_live, ) loot_common = list( /obj/item/soap/syndie = 10, @@ -66,6 +74,9 @@ // a wrist spike for door motors and turret IFF /obj/item/organ/cyberimp/cyberware/cargo_cavity = 4, /obj/item/organ/cyberimp/arm/toolkit/cyberware/icepick = 3, + // knockoff operative chrome: a patchy camo weave and a taped reflex rig + /obj/item/organ/cyberimp/cyberware/ghostskin/street = 3, + /obj/item/organ/cyberimp/cyberware/cascade/street = 2, ) loot_prime = list( /obj/item/pen/sleepy = 4, @@ -74,6 +85,7 @@ // Operative chrome: the reflex shunt and refraction weave. /obj/item/organ/cyberimp/cyberware/slipwire = 3, /obj/item/organ/cyberimp/cyberware/ghostskin = 2, + /obj/item/organ/cyberimp/cyberware/cascade/milspec = 2, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable diff --git a/voidcrew/modules/trade/shop_buyback.dm b/voidcrew/modules/trade/shop_buyback.dm index 9ad15e40e0d..04930411588 100644 --- a/voidcrew/modules/trade/shop_buyback.dm +++ b/voidcrew/modules/trade/shop_buyback.dm @@ -42,6 +42,10 @@ var/icon_state_override /// Cached base64 icon for the UI, computed once per instance var/cached_icon + /// Takes goods bought over a counter (TRAIT_TRADER_GOODS) as well as found + /// ones. Only the ripperdoc trade-in sets this, and only credit payouts + /// honour it, see takes_shop_stock(). + var/buys_shop_stock = FALSE /datum/shop_buyback/New() ..() @@ -88,11 +92,20 @@ cached_icon = icon2base64(icon(icon_file, state, SOUTH, frame = 1)) return cached_icon +/** + * Whether this ledger takes goods that were bought at a shop. Every ledger + * refuses them except the ripperdoc trade-in, and that one only while it pays + * credits alone: a voucher payout for bought goods would turn credits into + * vouchers. + */ +/datum/shop_buyback/proc/takes_shop_stock() + return buys_shop_stock && pay_vouchers <= 0 + /** * Whether this item counts for this ledger entry. */ /datum/shop_buyback/proc/matches(obj/item/offered) - if(is_trader_good(offered)) + if(is_trader_good(offered) && !takes_shop_stock()) return FALSE if(match_subtypes ? !istype(offered, item_path) : offered.type != item_path) return FALSE @@ -111,7 +124,7 @@ if(match_subtypes || !item_path) return null for(var/obj/item/offered in user.get_all_contents()) - if(istype(offered, item_path) && offered.type != item_path) + if(istype(offered, item_path) && offered.type != item_path && !shop_buyback_inside_body(offered)) return offered return null @@ -125,6 +138,11 @@ * the seller's own back, or the MODsuit they are standing in. Held items still * count (that hand-off is the whole gesture), and so does anything in a bag or * a pocket, because carrying it there is already a decision to bring it. + * + * The seller's own body is skipped too. Installed organs sit inside their + * bodypart, inside the mob, so the same sweep reaches them, and none of them + * is in get_equipped_items(): without this a chrome ledger would take the + * seller's working eyes out of their head. */ /datum/shop_buyback/proc/find_offered_items(mob/living/user) var/list/found = list() @@ -135,10 +153,29 @@ for(var/obj/item/offered in user.get_all_contents()) if((offered in found) || (offered in worn)) continue + if(shop_buyback_inside_body(offered)) + continue if(matches(offered)) found += offered return found +/** + * Whether an item is part of a body or rides inside one: an attached limb, an + * installed organ, or anything held inside either. The walk stops at the first + * mob, so a severed limb or a pulled organ in a bag still counts as goods. + */ +/proc/shop_buyback_inside_body(obj/item/offered) + for(var/atom/current = offered; current && !ismob(current); current = current.loc) + if(isorgan(current)) + var/obj/item/organ/organ = current + if(organ.owner) + return TRUE + else if(isbodypart(current)) + var/obj/item/bodypart/limb = current + if(limb.owner) + return TRUE + return FALSE + /** * How many sale-units the seller is carrying (stacks divide by `amount`). */ @@ -171,7 +208,7 @@ return "Not buying any more this shift." if(count_carried_units(user) < 1) for(var/obj/item/offered in user.get_all_contents()) - if(istype(offered, item_path) && is_trader_good(offered)) + if(istype(offered, item_path) && is_trader_good(offered) && !takes_shop_stock() && !shop_buyback_inside_body(offered)) return "I don't buy back shop stock." // Exact-type ledgers (match_subtypes = FALSE) refuse processed/lab-made // variants of the wanted goods. Without this, a player holding a refined diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index 235cf7fb5d2..825f5b8be33 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -1415,3 +1415,108 @@ var/list/milspec_price = GLOB.cyberware_price_index[milspec.type] TEST_ASSERT(street_price && street_price["credits"] > 0 && !street_price["vouchers"], "The street rig isn't a credit-priced SKU") TEST_ASSERT(milspec_price && milspec_price["vouchers"] > 0, "The service lattice isn't a voucher-priced SKU") + +/// The ripperdoc trade-in, the one ledger that takes chrome bought over a +/// counter. No entry may pay more than half the cheapest shelf price of the +/// piece (a special takes at most 30% off, so a bought piece always sells back +/// at a loss), none may buy back a piece any shop sells for vouchers, and none +/// may reach into the seller's body. Every other ledger and the cargo shuttle +/// still refuse the same piece once it is pulled. +/datum/unit_test/voidcrew_ripperdoc_resale + +/datum/unit_test/voidcrew_ripperdoc_resale/Run() + // Every installable piece any shelf sells: its lowest credit price for one + // piece, and whether any shelf asks vouchers for it. + var/list/lowest_credits = list() + var/list/voucher_sold = list() + for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku)) + var/item_path = initial(sku_type.item_path) + var/list/pieces = list() + if(ispath(item_path, /obj/item/organ)) + pieces += item_path + else if(ispath(item_path, /obj/item/cyberware_pair_case) || ispath(item_path, /obj/item/storage/case/cyberware)) + // A cased set sells every piece inside it for the one price. + var/obj/item/sample = new item_path(null) + for(var/obj/item/organ/held in sample.contents) + pieces += held.type + for(var/atom/movable/packed as anything in sample.contents.Copy()) + qdel(packed) + qdel(sample) + if(!length(pieces)) + continue + var/credits = initial(sku_type.price_credits) + for(var/piece in pieces) + if(initial(sku_type.price_vouchers) > 0) + voucher_sold[piece] = TRUE + if(credits > 0) + var/each = credits / length(pieces) + lowest_credits[piece] = isnull(lowest_credits[piece]) ? each : min(lowest_credits[piece], each) + + var/list/ledger_types = typesof(/datum/shop_buyback/ripperdoc) - /datum/shop_buyback/ripperdoc + TEST_ASSERT(length(ledger_types), "There is no ripperdoc trade-in") + for(var/buyback_type in ledger_types) + var/datum/shop_buyback/ledger = allocate(buyback_type) + TEST_ASSERT(ledger.takes_shop_stock(), "[buyback_type] refuses chrome bought at a counter") + TEST_ASSERT(ledger.pay_credits > 0 && !ledger.pay_vouchers, "[buyback_type] does not pay in credits alone") + var/matched = 0 + for(var/piece in lowest_credits | voucher_sold) + if(ledger.match_subtypes ? !ispath(piece, ledger.item_path) : piece != ledger.item_path) + continue + matched++ + TEST_ASSERT(!voucher_sold[piece], "[buyback_type] buys back [piece], which a shop sells for vouchers") + var/lowest = lowest_credits[piece] + if(isnull(lowest)) + continue + TEST_ASSERT(ledger.pay_credits * 2 <= lowest, "[buyback_type] pays [ledger.pay_credits] cr for [piece], more than half its lowest shelf price ([lowest] cr)") + // The deepest special (shop.dm roll_special, 30% off) still loses money. + TEST_ASSERT(ledger.pay_credits < max(5, round(lowest * 70 / 100, 5)), "[buyback_type] pays more for [piece] than it costs on special") + TEST_ASSERT(matched, "[buyback_type] buys back a piece no shelf sells") + + // One street core, bought, installed, pulled and sold back. + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/obj/item/card/id/id = allocate(/obj/item/card/id) + id.registered_account = allocate(/datum/bank_account, "Ripperdoc resale test", null, 1, FALSE) + patient.put_in_active_hand(id) + var/datum/bank_account/account = id.registered_account + var/datum/shop_sku/sku = allocate(/datum/shop_sku/ripperdoc/redline_street) + var/obj/item/organ/cyberimp/cyberware/redline/street/core = sku.dispense(patient, null) + TEST_ASSERT(istype(core), "The street core's shelf handed over [core]") + TEST_ASSERT(is_trader_good(core), "Chrome bought at the counter came without the shop mark") + patient.dropItemToGround(core) + TEST_ASSERT(core.Insert(patient, special = TRUE), "The bought core would not install") + + var/datum/shop_buyback/splice = allocate(/datum/shop_buyback/ripperdoc/redline_street) + var/datum/shop_buyback/ratchet = allocate(/datum/shop_buyback/ripperdoc/redline_street/street_doc) + splice.demand = 2 + var/balance = account.account_balance + TEST_ASSERT_EQUAL(splice.count_carried_units(patient), 0, "The trade-in offered to buy a core still installed in its owner") + TEST_ASSERT(!splice.try_sell(patient, null), "The trade-in bought a core out of a living chest") + TEST_ASSERT(!QDELETED(core) && core.owner == patient, "A trade-in took chrome out of its owner") + TEST_ASSERT_EQUAL(account.account_balance, balance, "A refused trade-in paid out") + + core.Remove(patient, special = TRUE) + patient.put_in_hands(core) + TEST_ASSERT(is_trader_good(core), "Pulling the core wiped its shop mark") + + // Nobody else buys it: a plain ledger for this exact piece, every other + // ledger in the game, or the cargo shuttle. + var/datum/shop_buyback/any_ledger = allocate(/datum/shop_buyback) + any_ledger.item_path = core.type + any_ledger.pay_credits = 10 + TEST_ASSERT(!any_ledger.matches(core), "A plain ledger for this very core bought it") + for(var/buyback_type in subtypesof(/datum/shop_buyback)) + if(ispath(buyback_type, /datum/shop_buyback/ripperdoc)) + continue + var/datum/shop_buyback/other = allocate(buyback_type) + if(other.item_path) + TEST_ASSERT(!other.matches(core), "[buyback_type] bought back chrome sold at a counter") + var/datum/export/export = allocate(/datum/export) + export.export_types = typecacheof(list(core.type)) + TEST_ASSERT(!export.applies_to(core), "Cargo would export chrome sold at a counter") + + // Both ripperdocs take it, at half list. + TEST_ASSERT(ratchet.matches(core), "The street doc won't take back chrome a ripperdoc sold") + TEST_ASSERT(splice.try_sell(patient, null), "Splice won't take back chrome a ripperdoc sold") + TEST_ASSERT(QDELETED(core), "The sold core stayed with the seller") + TEST_ASSERT_EQUAL(account.account_balance, balance + splice.pay_credits, "The trade-in paid the wrong amount") + TEST_ASSERT_EQUAL(splice.pay_credits * 2, sku.price_credits, "The street core does not sell back at half list") diff --git a/voidcrew/modules/unit_tests/voidcrew_loot.dm b/voidcrew/modules/unit_tests/voidcrew_loot.dm index 7b50fde4674..0e8b768e64c 100644 --- a/voidcrew/modules/unit_tests/voidcrew_loot.dm +++ b/voidcrew/modules/unit_tests/voidcrew_loot.dm @@ -735,6 +735,7 @@ /obj/item/claymore/cutlass/old, /obj/item/claymore/weak, /obj/item/crowbar/large/twenty_force, + /obj/item/cyberware_pair_case/mantis_street, /obj/item/fireaxe/boardingaxe, /obj/item/fireaxe/boneaxe, /obj/item/gun/ballistic/automatic/ar, @@ -812,6 +813,7 @@ /obj/item/cursed_katana, /obj/item/cyberware_pair_case/gorilla_arms, /obj/item/cyberware_pair_case/mantis_blades, + /obj/item/cyberware_pair_case/mantis_scorch, /obj/item/energy_katana, /obj/item/gun/ballistic/automatic/l6_saw/unrestricted, /obj/item/gun/ballistic/automatic/m90/unrestricted, @@ -842,6 +844,7 @@ /obj/item/mjollnir, /obj/item/organ/cyberimp/arm/toolkit/cyberware/bunker_buster, /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live, /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin, /obj/item/shield/energy, /obj/item/singularityhammer, diff --git a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm index 7ce9f183264..2cb37e172da 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm @@ -345,7 +345,9 @@ ), /datum/outpost_shop/vendor/ripperdoc = list( list(/datum/shop_sku/ripperdoc/monowire, /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire, 1, 0, 3), + list(/datum/shop_sku/ripperdoc/monowire_live, /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live, 1, 0, 5), list(/datum/shop_sku/ripperdoc/mantis, /obj/item/cyberware_pair_case/mantis_blades, 1, 0, 4), + list(/datum/shop_sku/ripperdoc/mantis_scorch, /obj/item/cyberware_pair_case/mantis_scorch, 1, 0, 5), list(/datum/shop_sku/ripperdoc/gorilla, /obj/item/cyberware_pair_case/gorilla_arms, 1, 0, 3), ), ) @@ -467,7 +469,7 @@ TEST_ASSERT_EQUAL(finite.stock, 0, "A rotation refilled restricted stock") // Arm chrome takes one unit every few convoys, and a full shelf banks nothing toward the next. - for(var/sku_type in list(/datum/shop_sku/ripperdoc/monowire, /datum/shop_sku/ripperdoc/mantis, /datum/shop_sku/ripperdoc/gorilla)) + for(var/sku_type in list(/datum/shop_sku/ripperdoc/monowire, /datum/shop_sku/ripperdoc/monowire_live, /datum/shop_sku/ripperdoc/mantis, /datum/shop_sku/ripperdoc/mantis_scorch, /datum/shop_sku/ripperdoc/gorilla)) var/datum/outpost_shop/chrome_shop = allocate(/datum/outpost_shop) chrome_shop.add_sku(sku_type, "core") var/datum/shop_sku/chrome = chrome_shop.skus[1] @@ -509,6 +511,7 @@ /datum/shop_sku/black_market/c20r, /datum/shop_sku/black_market/wt550, /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/black_market/cutlass, /datum/shop_sku/black_market/rare/energy_sword, /datum/shop_sku/ripperdoc/monowire, /datum/shop_sku/ripperdoc/gorilla, /datum/shop_sku/ripperdoc/mantis, + /datum/shop_sku/ripperdoc/monowire_live, /datum/shop_sku/ripperdoc/mantis_scorch, /datum/shop_sku/ripperdoc/mantis_street, /datum/shop_sku/general/rotating/freight_crate, /datum/shop_sku/black_market/mystery_cache, /datum/shop_sku/black_market/mystery_cache_rare, ) for(var/sku_type in restricted) diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm index 1ab48e4d9b4..078a7fcf0e8 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm @@ -44,6 +44,10 @@ /datum/shop_sku/favor/fitter_gauntlets = 1, /datum/shop_sku/ripperdoc/nightshade = 1, /datum/shop_sku/ripperdoc/cascade_street = 1, + /datum/shop_sku/ripperdoc/redline_street = 1, + /datum/shop_sku/ripperdoc/ghostskin_street = 1, + /datum/shop_sku/ripperdoc/governor_street = 1, + /datum/shop_sku/ripperdoc/mantis_street = 1, /datum/shop_sku/ripperdoc/fixers = 1, /datum/shop_sku/ripperdoc/dermal_mesh = 1, /datum/shop_sku/ripperdoc/hopper = 1, @@ -95,6 +99,8 @@ /datum/shop_sku/ripperdoc/gorilla = 2, /datum/shop_sku/ripperdoc/rigger = 2, /datum/shop_sku/ripperdoc/cascade_milspec = 2, + /datum/shop_sku/ripperdoc/redline_milspec = 2, + /datum/shop_sku/ripperdoc/governor_milspec = 2, /datum/shop_sku/fitter/rare/mod_emp_shield_advanced = 2, /datum/shop_sku/fitter/rare/mod_projectile_dampener = 2, /datum/shop_sku/black_market/mod_chassis_syndicate = 2, @@ -126,11 +132,13 @@ /datum/shop_sku/ripperdoc/governor_delete = 3, /datum/shop_sku/ripperdoc/redline = 3, /datum/shop_sku/ripperdoc/cascade = 3, + /datum/shop_sku/ripperdoc/mantis_scorch = 3, + /datum/shop_sku/ripperdoc/monowire_live = 3, ) /datum/unit_test/voidcrew_zone_shops/catalog/Run() var/list/expected = expected_tags() - TEST_ASSERT_EQUAL(length(expected), 100, "The retained permission catalog is incomplete") + TEST_ASSERT_EQUAL(length(expected), 108, "The retained permission catalog is incomplete") var/datum/voidcrew_crew_record/crew = allocate(/datum/voidcrew_crew_record) for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku)) var/permission = expected[sku_type] || 0 From 67f42f244160a3e941dfe9707bd3d2190f907edd Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:24:42 -0800 Subject: [PATCH 118/472] Only burn and cleave with the Widowline on a swing that landed --- .../modules/cyberware/ware_military_arms.dm | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index 0b645f946f2..07181493857 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -566,15 +566,22 @@ var/cleave_mult = CYBERWARE_MONOWIRE_CLEAVE_MULT /// Burn the charged line adds on top of the cut, every hit. var/charge_burn = CYBERWARE_MONOWIRE_BURN + /// Set by afterattack() during attack()'s parent call. Only a swing that + /// connected gets that far, and attack()'s own return can't tell a hit from + /// a pacifist's pulled swing or a signal-skipped one (both return FALSE). + var/swing_landed = FALSE /obj/item/melee/cyberware_monowire/attack(mob/living/target, mob/living/user, list/modifiers, list/attack_modifiers) // The parent plays hitsound partway through the swing, so pick which of the // line's two voices we are before handing off. hitsound = pick('voidcrew/sound/monowire1.ogg', 'voidcrew/sound/monowire2.ogg') + swing_landed = FALSE . = ..() - // A truthy return means the swing was blocked or cancelled outright, no - // cut landed, so nothing burns and nothing carries through. - if(. || QDELETED(target) || !isliving(user)) + var/landed = swing_landed + swing_landed = FALSE + // No cut landed (blocked, cancelled, pulled), so nothing burns and nothing + // carries through. + if(. || !landed || QDELETED(target) || !isliving(user)) return // The charge on the line, on top of the cut. Silent armor check: the parent @@ -620,6 +627,10 @@ ) playsound(target, 'sound/items/weapons/bladeslice.ogg', 50, TRUE) +/obj/item/melee/cyberware_monowire/afterattack(atom/target, mob/user, list/modifiers, list/attack_modifiers) + . = ..() + swing_landed = TRUE + /** * # Widowline Monowire (T3, one arm, aug slot, load 5) * From 8a88a40dce1683b13b8b82227910d3a9fdb7836e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:35:30 -0800 Subject: [PATCH 119/472] Wiki: add the Back Bay at Quartermain and the cheaper chrome models --- wiki/content/cyberware.md | 37 ++++++++++++++++++++++++++------- wiki/content/glossary.md | 2 +- wiki/content/trader-outposts.md | 5 ++++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/wiki/content/cyberware.md b/wiki/content/cyberware.md index dbed7a15dee..d31e710a70f 100644 --- a/wiki/content/cyberware.md +++ b/wiki/content/cyberware.md @@ -2,32 +2,36 @@ title: Cyberware category: Economy order: 6 -blurb: Chrome implants from the Chop Shop at the Undertow Exchange, neural capacity, the four tiers, the full catalog, and what an EMP does to a chromed body. +blurb: Chrome implants from the Chop Shop at the Undertow Exchange and the Back Bay at Quartermain Depot, neural capacity, the four tiers, the full catalog, and what an EMP does to a chromed body. --- -Cyberware (**chrome**) is a roster of implantable robotic organs sold at the **Chop Shop**, the neon-lit parlor off the Dregs cantina at the [Undertow Exchange](trader-outposts.md). Every piece does something no printed equipment does, from seeing in the dark to a deployable pair of hardlight blades, and every piece costs **neural load** against a fixed capacity, so a build is a budget rather than a shopping spree. Street chrome sells for credits; the good stuff wants [vouchers](money-and-trade.md). +Cyberware (**chrome**) is a roster of implantable robotic organs sold at the **Chop Shop**, the neon-lit parlor off the Dregs cantina at the [Undertow Exchange](trader-outposts.md), and, street chrome only, at the **Back Bay** in [Quartermain Depot](trader-outposts.md). Every piece does something no printed equipment does, from seeing in the dark to a deployable pair of hardlight blades, and every piece costs **neural load** against a fixed capacity, so a build is a budget rather than a shopping spree. Street chrome sells for credits; the good stuff wants [vouchers](money-and-trade.md). ## The Chop Shop Splice runs the parlor from behind counter glass. Click them for the shop, sorted into Street, Pro, Military and Legend tabs plus an ammunition shelf. Bought chrome lands in your hands as an organ, which does nothing until it is installed. -Installation is the **Chrome Cradle**, the salvaged operating slab in the middle of the parlor, the only one in the galaxy. Drag yourself onto it and its console opens, showing your neural load, every piece of chrome within reach (installed, carried, or in the parts tray), and a projection of what any swap would cost and evict. Hit Install and the rig sedates you and does the work in seconds. Only you can ever order work on your own body, and only while conscious and unrestrained. Nobody can be strapped to the slab and carved for their chrome. Evicted and removed pieces wait in the parts tray, which anyone adjacent can eject. +Installation is the **Chrome Cradle**, the salvaged operating slab in the middle of the parlor. Drag yourself onto it and its console opens, showing your neural load, every piece of chrome within reach (installed, carried, or in the parts tray), and a projection of what any swap would cost and evict. Hit Install and the rig sedates you and does the work in seconds. Only you can ever order work on your own body, and only while conscious and unrestrained. Nobody can be strapped to the slab and carved for their chrome. Evicted and removed pieces wait in the parts tray, which anyone adjacent can eject. The Cradle also removes chrome in three seconds flat, and runs a **tune-up** for 200 credits that clears EMP scramble and repairs all your installed ware. If you carry the Chromatic Dermis ink suite, the Cradle's ink panel re-keys its color and pattern for free. -Outside the parlor, ordinary organ-manipulation surgery at any operating table installs and removes chrome too, leg surgery exists for exactly this reason. Autosurgeons refuse the work entirely. +Outside the parlors, ordinary organ-manipulation surgery at any operating table installs and removes chrome too, leg surgery exists for exactly this reason. Autosurgeons refuse the work entirely. + +## The Back Bay + +Ratchet, a loader mechanic who started putting chrome in people, works out of a storage bay off Quartermain's upper landing. He sells street chrome only, for credits, at Splice's prices, and the bay has a Chrome Cradle of its own that works the same way. He buys pulled street chrome back on the same terms as Splice. ## Neural load Your body has **20 capacity** shared by chrome, ordinary implants, cybernetic organs, surgical enhancements and installed nanite programs. Ordinary implants, robotic organs and surgical enhancements cost **1 each**. Mindshield implants cost **0**. Prosthetic limbs cost **0**, but implants inside them still count. Chrome uses the catalogue loads below. Nanite costs depend on the program and count even when switched off. See [Nanites & Revival](nanites-and-revival.md). Every installed upgrade sums its load against capacity, and the Cradle refuses any install that would take you over, though swapping within a ladder only costs the difference, since the piece being replaced comes out first. If you somehow end up over capacity anyway, everything browns out: **every** piece of chrome goes offline and beneficial nanite programs pause until the load drops back under, Remove enough installed load to restore capacity. -Same-slot ware is mutually exclusive, one operating system, one dermal layer, one leg rig, one piece of hardware per arm, which makes most slots either a ladder (Shock Coils into Hopper Pistons, Dermal Mesh into Slabskin) or a straight choice (Cascade Lattice *or* Redline Core). Arm chrome always claims the whole arm: installing Mantis Blades evicts whatever that arm already carried. Chrome optics replace your natural eyes outright. The Governor Delete raises capacity by **15**, to **35**. It occupies the chest operating-system slot, so it competes with the Cascade lattice and Redline core. +Same-slot ware is mutually exclusive, one operating system, one dermal layer, one leg rig, one piece of hardware per arm, which makes most slots either a ladder (Shock Coils into Hopper Pistons, Dermal Mesh into Slabskin) or a straight choice (Cascade Lattice *or* Redline Core). Arm chrome always claims the whole arm: installing Mantis Blades evicts whatever that arm already carried. Chrome optics replace your natural eyes outright. The Governor Delete raises capacity by **15**, to **35**, and the cheaper Governor Patch and Governor Bypass raise it by **5** and **10**. All three occupy the chest operating-system slot, so they compete with the Cascade lattice and Redline core. Much of it shows. Plated arms, leg rigs, dermal weaves and spine lattices draw on your body under your clothes, and stacking enough chrome genuinely stops you looking human. ## The catalog -Prices are Splice's, stock is limited per round, and load is the neural cost. Nightshade optics, Fixer's Fingers, Dermal Mesh, Hopper pistons, Icepick Jack, Heartbeat Doppler, Deadeye link, Slipwire and Prospector Suite need 4 [reputation](zone-reputation.md). Coolant Loop, Dead Channel, Atlas frame, Gorilla Arms and Rigger socket need 11. The rest of the military and legend stock needs 22. Barnaby at Waystation Halcyon also sells Shock Coils and Second Wind at these prices. "Pair" ware is sold as a cased set and installed one organ per arm. +Prices are the same at both counters, stock is limited per round, and load is the neural cost. Nightshade optics, Fixer's Fingers, Dermal Mesh, Hopper pistons, Icepick Jack, Heartbeat Doppler, Deadeye link, Slipwire, Prospector Suite and every street model need 4 [reputation](zone-reputation.md). Coolant Loop, Dead Channel, Atlas frame, Gorilla Arms, Rigger socket, the service reflex lattice, the Vanguard core and the Governor Bypass need 11. The rest of the military and legend stock needs 22. Barnaby at Waystation Halcyon also sells Shock Coils and Second Wind at these prices. "Pair" ware is sold as a cased set and installed one organ per arm. ### Tier 1: Street chrome (credits) @@ -84,10 +88,27 @@ Prices are Splice's, stock is limited per round, and load is the neural cost. Ni |---|---|---|---| | Governor Delete | 5 | 0 | +15 capacity. Occupies the chest OS slot, competing with Cascade and Redline | | Redline core | 6 | 10 | Twelve seconds of stun immunity, 40% less brute and burn taken, +8 unarmed damage and a roar that staggers everyone nearby, then the crash | -| Cascade lattice | 8 | 12 | The sandevistan: eight seconds at double time with halved melee cooldowns and 40% projectile dodge while moving, then the crash. An EMP mid-window ends it early and eats the cooldown | +| Cascade lattice | 8 | 12 | Eight seconds at double time with halved melee cooldowns and 40% projectile dodge while moving, then the crash. An EMP mid-window ends it early and eats the cooldown | | Meteor Piledriver frame | *never sold* | 8 | An eight-tile leap that crushes whoever is standing on the landing tile and hurls everyone around them. A rare guarded-mission completion prize; see [Missions](missions.md) | -The **ammunition shelf** is the return-visit hook: Ronin magazines at 250 credits and Buster rocket pairs at 300, both at 4 [reputation](zone-reputation.md). Splice also buys back *removed* street chrome at about half list price; military chrome is never bought back. +### Cheaper models + +Six of the big pieces come in cheaper models from other makers. Each takes the same slot as the original, so you can start on the cheap one, sell it back and move up. Street models sell for credits at both counters. The service models and the fire and shock variants are vouchers, from Splice only. + +| Piece | Price | Load | What it does | +|---|---|---|---| +| Flickerskin weave | 2,400 cr | 3 | Five seconds of patchy camo. It fools people, but NPCs still see you and Chrome Reads still land | +| Shiv Blades (pair) | 2,400 cr | 2/arm | Deployable hardlight blades, duller than the Mantis, with no lunge | +| Governor Patch | 2,500 cr | 0 | +5 capacity. Occupies the chest OS slot | +| Hothead core | 3,000 cr | 5 | Eight seconds of 20% less brute and burn and +3 unarmed damage, then the crash. No stun immunity and no roar | +| Spliced reflex rig | 3,600 cr | 6 | Six seconds of quicker movement, melee cooldowns cut by a quarter and 15% projectile dodge while moving, then a long crash | +| Governor Bypass | 3 vouchers | 0 | +10 capacity. Occupies the chest OS slot | +| Service reflex lattice | 4 vouchers | 9 | Seven seconds of quicker movement, melee cooldowns cut by 40% and 25% projectile dodge while moving, then the crash | +| Vanguard core | 4 vouchers | 8 | Ten seconds of stun immunity, 30% less brute and burn and +5 unarmed damage, opened by a roar that staggers people nearby, then the crash | +| Scorch Blades (pair) | 5 vouchers | 4/arm | Mantis Blades that set whoever they cut on fire. The lunge lights them harder | +| Widowline Arc | 5 vouchers | 6 | A Widowline that shocks on every hit, cuts robots and mechs much harder, and briefly knocks out turrets, consoles and cameras it strikes | + +The **ammunition shelf** is the return-visit hook: Ronin magazines at 250 credits and Buster rocket pairs at 300, both at 4 [reputation](zone-reputation.md). Both ripperdocs buy back *removed* street chrome, street models included and even pieces they sold you, at about half list price. Chrome paid for in vouchers is never bought back. ## Finding chrome in the wild diff --git a/wiki/content/glossary.md b/wiki/content/glossary.md index 67fdcfeaf13..f6a28516a56 100644 --- a/wiki/content/glossary.md +++ b/wiki/content/glossary.md @@ -59,7 +59,7 @@ Voidcrew adds a vocabulary that /tg/station does not have, and most of it turns | **[Ship parts](ship-parts.md)** | Physical items in four classes (combat, science, trade and misc). Bank them through an extraction case to keep them between rounds and buy permanent shipyard unlocks. | | **Extraction case** | The secure case parts must be sitting in, on your person, for them to survive the round. Request one with the **Request Extraction Case** verb. Only the first standard case counts. | | **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. See [Cyberware](cyberware.md). | +| **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). | | **Player outpost** | A base a crew plants for itself using an outpost deed. See [Player Outposts](player-outposts.md). | diff --git a/wiki/content/trader-outposts.md b/wiki/content/trader-outposts.md index effd7099fca..6076b71c52f 100644 --- a/wiki/content/trader-outposts.md +++ b/wiki/content/trader-outposts.md @@ -64,6 +64,7 @@ Each outpost's concourse has its own side vendors, each with their own shopkeep | Halcyon | The Chowder Pot | Roux | The diner. Hot meals in tiers, galley staples, buys home cooking | | Quartermain | The Skunkworks | Boffin | Research and fabrication gear, Strong-Arm implants, robotics and xenobiology starter packs, raw anomaly cores and anomaly charts | | Quartermain | The Fitting Bay | Wick | Civilian through advanced MODsuits | +| Quartermain | The Back Bay | Ratchet | Street chrome for credits, installed in the bay's own Chrome Cradle | | The Undertow | The Dregs | Dram | The cantina | | The Undertow | Patch-Up Clinic | Sawbones | Medical supplies, the genetics starter pack | | The Undertow | The Chop Shop | Splice | Cyberware implants in four tiers, installed in the parlor's own Chrome Cradle | @@ -72,7 +73,9 @@ Each outpost's concourse has its own side vendors, each with their own shopkeep **The Fitting Bay** at Quartermain has a **MOD fitting bench**, a powered frame you back into while still wearing your suit. It swaps modules for free with no screwdriver work, sells permanent upgrades (plating, module capacity, actuator tuning, power regulation, cell swaps) and per-visit servicing, gives away paint and engraving, and trickle-charges your cell while the frame is shut. Reinforced plating needs 4 [reputation](zone-reputation.md) and composite plating needs 11. It will not convert a suit from one theme to another. For the tier Wick won't touch, syndicate and elite chassis, Interdyne and infiltrator control units, the antag modules. Go to **Vex**: 3600 credits plus 2 vouchers for a syndicate chassis, 5400 plus 3 for an elite, 5 vouchers for a fully equipped syndicate suit. -**The Chop Shop** at the Undertow is the galaxy's only cyberware parlor, a neon annex off the Dregs. Splice sells chrome implants off four shelves, street chrome for credits, pro chrome for credits or vouchers, military and legend chrome for vouchers only, and the **Chrome Cradle** in the middle of the room does the installing: you lie on the slab, pick a piece off the rack, and the rig does the surgery in seconds. Removals, repairs and ink re-keying run on the same console, an ammunition shelf keeps implanted weapons fed, and Splice buys back pulled street chrome at about half price. The full catalog is on [Cyberware](cyberware.md). +**The Chop Shop** at the Undertow is the full cyberware parlor, a neon annex off the Dregs. Splice sells chrome implants off four shelves, street chrome for credits, pro chrome for credits or vouchers, military and legend chrome for vouchers only, and the **Chrome Cradle** in the middle of the room does the installing: you lie on the slab, pick a piece off the rack, and the rig does the surgery in seconds. Removals, repairs and ink re-keying run on the same console, an ammunition shelf keeps implanted weapons fed, and Splice buys back pulled street chrome at about half price. The full catalog is on [Cyberware](cyberware.md). + +**The Back Bay** at Quartermain is the cheap end of the trade: Ratchet works out of a storage bay off the upper landing, with a Chrome Cradle of his own. He sells street chrome only, for credits, at Splice's prices, and buys pulled street chrome back on the same terms. Vex's own shelves carry the other half of the deep-band trade: **exploit software** for ship hacking, from 500-credit lightshow cartridges to the voucher-only exotics, plus the electronic-warfare suite board itself. See [Ship Hacking](ship-hacking.md). From c740d728908f0974549176ce440b745b6ecbb6fa Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:39:06 -0800 Subject: [PATCH 120/472] Fix two line tests that read stamina and actions the wrong way --- voidcrew/modules/unit_tests/voidcrew_cyberware.dm | 5 ++++- voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index 825f5b8be33..72088988090 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -1406,7 +1406,10 @@ crash = patient.has_status_effect(/datum/status_effect/cyberware_cascade_crash) TEST_ASSERT_NOTNULL(crash, "The street window ended without a crash") TEST_ASSERT_EQUAL(crash.duration, world.time + 8 SECONDS, "The street crash kept the wrong length") - TEST_ASSERT_EQUAL(patient.getStaminaLoss(), 100, "The street crash kept the wrong stamina dump") + TEST_ASSERT_EQUAL(crash.crash_stamina, 100, "The street crash kept the wrong stamina dump") + // A dump of 100 reaches stamina crit, and tg's stamcrit then adds its own + // push on top (up to the 120 cap), so the total is at least the dump. + TEST_ASSERT(patient.getStaminaLoss() >= 100, "The street crash didn't dump its stamina") patient.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) // What Splice quotes for the two lesser models. diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm index d485ac8803c..0cffda6e7d6 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm @@ -18,8 +18,9 @@ TEST_ASSERT_EQUAL(parent.lunge_bonus, 10, "Mantis lunge bonus changed") TEST_ASSERT_EQUAL(parent.lunge_fire_stacks, 0, "The plain Mantis lunge sets people on fire") - TEST_ASSERT(!(/datum/action/cooldown/cyberware/mantis_lunge in street.actions_types), "Shiv Blades got a lunge") - TEST_ASSERT(/datum/action/cooldown/cyberware/mantis_lunge in scorch.actions_types, "Scorch Blades lost the lunge") + // Item Initialize() turns actions_types into granted actions and nulls the list. + TEST_ASSERT(!(locate(/datum/action/cooldown/cyberware/mantis_lunge) in (street.actions || list())), "Shiv Blades got a lunge") + TEST_ASSERT(locate(/datum/action/cooldown/cyberware/mantis_lunge) in (scorch.actions || list()), "Scorch Blades lost the lunge") TEST_ASSERT_EQUAL(scorch.lunge_fire_stacks, 4, "Scorch lunge fire is not 4 stacks") // Same slot as the parent, so the line evicts itself. From 000448a7d3ff940ac505992ea0843d60eddc99df Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:57:26 -0800 Subject: [PATCH 121/472] Soften the street reflex rig's crash so it slows instead of floors --- voidcrew/modules/cyberware/ware_line_sandy.dm | 2 +- voidcrew/modules/unit_tests/voidcrew_cyberware.dm | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_line_sandy.dm b/voidcrew/modules/cyberware/ware_line_sandy.dm index 7a560d4f5d1..6bd4aec596c 100644 --- a/voidcrew/modules/cyberware/ware_line_sandy.dm +++ b/voidcrew/modules/cyberware/ware_line_sandy.dm @@ -37,7 +37,7 @@ actions_types = list(/datum/action/cooldown/cyberware/cascade_surge/street) window_duration = 6 SECONDS crash_duration = 8 SECONDS - crash_stamina = 100 + crash_stamina = 90 window_dodge = 15 window_speed = /datum/movespeed_modifier/cyberware_cascade/street window_melee_mult = 0.75 diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index 72088988090..c7e354107d3 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -1406,10 +1406,9 @@ crash = patient.has_status_effect(/datum/status_effect/cyberware_cascade_crash) TEST_ASSERT_NOTNULL(crash, "The street window ended without a crash") TEST_ASSERT_EQUAL(crash.duration, world.time + 8 SECONDS, "The street crash kept the wrong length") - TEST_ASSERT_EQUAL(crash.crash_stamina, 100, "The street crash kept the wrong stamina dump") - // A dump of 100 reaches stamina crit, and tg's stamcrit then adds its own - // push on top (up to the 120 cap), so the total is at least the dump. - TEST_ASSERT(patient.getStaminaLoss() >= 100, "The street crash didn't dump its stamina") + TEST_ASSERT_EQUAL(crash.crash_stamina, 90, "The street crash kept the wrong stamina dump") + TEST_ASSERT_EQUAL(patient.getStaminaLoss(), 90, "The street crash didn't dump its stamina") + TEST_ASSERT(!HAS_TRAIT_FROM(patient, TRAIT_INCAPACITATED, STAMINA), "The street crash floored its user") patient.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) // What Splice quotes for the two lesser models. From 22534a372939c6f12f934f779e49947a3f03cd45 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:40:11 -0800 Subject: [PATCH 122/472] Mantis and Gorilla Arms sprites, chrome worn over the jumpsuit New Mantis line art: a folded unit for the world icon, blades along the forearm in hand, and slimmer stowed housings on the arm. Gorilla Arms get the knuckle rig organ icon and worn plates. Worn chrome now draws over the jumpsuit and under the ID, hands, gloves, shoes and suits. A Mantis housing hides while its blade is out and comes back when the blade is stowed, knocked loose, EMP'd away or the housing is pulled. A Cascade or Redline glow no longer gets its own emissive blocker stacked on top of it. --- voidcrew/_DEFINES/cyberware.dm | 13 +-- voidcrew/modules/cyberware/cyberware_base.dm | 75 ++++++++++++++-- .../modules/cyberware/icons/cyberware.dmi | Bin 43817 -> 47151 bytes .../cyberware/icons/cyberware_lefthand.dmi | Bin 2891 -> 4142 bytes .../cyberware/icons/cyberware_righthand.dmi | Bin 2821 -> 4171 bytes .../cyberware/icons/cyberware_weapons.dmi | Bin 2419 -> 2579 bytes .../cyberware/icons/cyberware_worn.dmi | Bin 5666 -> 6008 bytes .../modules/cyberware/ware_military_arms.dm | 19 ++-- .../unit_tests/voidcrew_cyberware_mantis.dm | 81 ++++++++++++++++++ 9 files changed, 165 insertions(+), 23 deletions(-) diff --git a/voidcrew/_DEFINES/cyberware.dm b/voidcrew/_DEFINES/cyberware.dm index 78d71dbfde3..1931fb130fd 100644 --- a/voidcrew/_DEFINES/cyberware.dm +++ b/voidcrew/_DEFINES/cyberware.dm @@ -6,12 +6,13 @@ /// it browns every piece of chrome out until the load drops back under. #define CYBERWARE_BASE_CAPACITY 20 -/// Mob-overlay height worn chrome draws at: between the character-setup -/// underwear/undershirt layer (BODY_LAYER, 32) and the eyes (EYES_LAYER, 31), -/// so a spine rig reads over a bra or undershirt while every EQUIPPED -/// clothing layer (UNIFORM_LAYER 28 and up) still covers it. Fractional on -/// purpose, it must never collide with a real layer index. -#define CYBERWARE_WORN_LAYER 31.5 +/// Mob-overlay height worn chrome draws at: just over the jumpsuit +/// (UNIFORM_LAYER, 28) and under the worn ID (ID_LAYER, 27), so arm plates and +/// a spine rig read over normal clothes while hands (BODYPARTS_HIGH_LAYER 25), +/// gloves, shoes and every suit, vest or armour layer (SUIT_LAYER 19 and up) +/// still cover them. Mob layers count down: a lower number draws on top. +/// Fractional on purpose, it must never collide with a real layer index. +#define CYBERWARE_WORN_LAYER 27.5 // ---- Organ slots ------------------------------------------------------- // Minted here, never in DNA.dm. Slot uniqueness is global per mob, so diff --git a/voidcrew/modules/cyberware/cyberware_base.dm b/voidcrew/modules/cyberware/cyberware_base.dm index 367f077f491..c3336940061 100644 --- a/voidcrew/modules/cyberware/cyberware_base.dm +++ b/voidcrew/modules/cyberware/cyberware_base.dm @@ -20,13 +20,12 @@ * (autosurgeon.dm's "insertion failed!" path). */ /** - * The worn-chrome overlay: tg's augment overlay, drawn one notch higher. - * Stock augments sit at BODY_ADJ_LAYER, which the character-setup underwear - * layer paints over, a bra would cover a Cascade spine rig. Chrome instead - * draws at CYBERWARE_WORN_LAYER: over underwear and undershirts, still under - * eyes, damage, and every EQUIPPED clothing layer. One override covers the - * images, the emissive twins and the emissive blockers alike, because they - * all take their layer from bitflag_to_layer(). + * The worn-chrome overlay: tg's augment overlay, drawn higher. Stock augments + * sit at BODY_ADJ_LAYER, under underwear and every piece of clothing. Chrome + * draws at CYBERWARE_WORN_LAYER instead: over the jumpsuit, still under hands, + * gloves, shoes and suits. One override covers the images, the emissive twins + * and the emissive blockers alike, because they all take their layer from + * bitflag_to_layer(). */ /datum/bodypart_overlay/augment/cyberware @@ -35,13 +34,48 @@ return -CYBERWARE_WORN_LAYER return ..() +// Arm hardware whose worn sprite draws the folded item (the Mantis line) is not +// drawn while that item is out in a hand. Hiding here also drops the overlay from +// the limb's icon key, so update_body_parts() redraws the arm on the flip. +/datum/bodypart_overlay/augment/cyberware/can_draw_on_bodypart(obj/item/bodypart/bodypart_owner) + // A bearer being deleted can lose its chrome before its clothes, and taking + // the clothes off redraws the limbs while this overlay is still on them. + if(!implant) + return FALSE + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = implant + if(istype(arm_ware) && arm_ware.worn_overlay_hidden()) + return FALSE + return ..() + +/** + * The parent puts an emissive blocker over every image it draws, the glow + * included. Same layer, later in the list, so a Cascade or Redline would black + * out its own glow. Glows skip the blocker and go last, after every blocker of + * this overlay, the same order tg's worn clothing uses. + */ +/datum/bodypart_overlay/augment/cyberware/get_overlay(layer, obj/item/bodypart/limb) + layer = bitflag_to_layer(layer) + var/list/imageset = implant.get_overlay(layer, limb) + if(blocks_emissive == EMISSIVE_BLOCK_NONE || !limb) + return imageset + + var/list/all_images = list() + var/list/glows = list() + for(var/image/overlay as anything in imageset) + if(PLANE_TO_TRUE(overlay.plane) == EMISSIVE_PLANE) + glows += overlay + continue + all_images += overlay + all_images += emissive_blocker(overlay.icon, overlay.icon_state, limb, layer = overlay.layer, alpha = overlay.alpha) + return all_images + glows + /obj/item/organ/cyberimp/cyberware name = "cyberware" desc = "Aftermarket chrome. Someone sat in a parlor chair for this." icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' // Worn look: ware that reads from outside the body sets aug_overlay to a // state in this sheet and tg's bodypart-overlay pipeline draws it ON the - // bearer, over the character-setup underwear, under equipped clothing. + // bearer, over the jumpsuit, under suits and armour. // Stack enough chrome and you stop looking human. aug_icon = 'voidcrew/modules/cyberware/icons/cyberware_worn.dmi' organ_flags = ORGAN_ROBOTIC @@ -209,6 +243,9 @@ var/tier = CYBERWARE_TIER_1 /// Chrome capacity this ware grants while installed. var/chrome_capacity_bonus = 0 + /// TRUE when the worn sprite draws the deployable folded into its housing + /// (the Mantis line). The worn overlay hides while that item is out in a hand. + var/hide_worn_while_deployed = FALSE /obj/item/organ/cyberimp/arm/toolkit/cyberware/Initialize(mapload) . = ..() @@ -254,12 +291,34 @@ // (augments_arms.dm), so "did it deploy" is "did it leave us", not "is it set". if(active_item && !(active_item in src)) cyberware_ink_pulse(owner, CYBERWARE_INK_HARD) + if(hide_worn_while_deployed) + // Any way out of the hand that isn't Retract() (a forced drop, a + // deleted blade) must put the folded sprite back as well. + RegisterSignal(active_item, COMSIG_ITEM_DROPPED, PROC_REF(on_deployed_item_dropped), override = TRUE) + owner.update_body_parts() /obj/item/organ/cyberimp/arm/toolkit/cyberware/Retract() var/mob/living/carbon/stowing_owner = owner + var/obj/item/stowing = active_item . = ..() if(.) cyberware_ink_pulse(stowing_owner, CYBERWARE_INK_SOFT) + if(hide_worn_while_deployed) + UnregisterSignal(stowing, COMSIG_ITEM_DROPPED) + stowing_owner?.update_body_parts() + +/// TRUE while the worn overlay should not draw: this hardware's worn sprite +/// shows its item folded away, and that item is out in the bearer's hand. +/// Stowed, dropped, deleted or with no bearer, the overlay draws. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/worn_overlay_hidden() + return hide_worn_while_deployed && owner && active_item && owner.is_holding(active_item) + +/// Signal proc for [COMSIG_ITEM_DROPPED] on a deployed item whose folded sprite +/// is part of the worn overlay. Redraws the arm so the housing shows again. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/on_deployed_item_dropped(obj/item/source, mob/user) + SIGNAL_HANDLER + UnregisterSignal(source, COMSIG_ITEM_DROPPED) + owner?.update_body_parts() /obj/item/organ/cyberimp/arm/toolkit/cyberware/emp_act(severity) . = ..() // toolkit's own EMP retract fires first diff --git a/voidcrew/modules/cyberware/icons/cyberware.dmi b/voidcrew/modules/cyberware/icons/cyberware.dmi index 5ab733d0245e3efb63cdd97e1cb5bfbcb30e2a80..9638af20fba75343446f6a3e4b5d5d325c067e89 100644 GIT binary patch delta 46993 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 W;1k5AIfI}Nc?4+{sagq>FaHBoK~Zi1 delta 43632 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 delta 2710 zcmb8xc|6qX9tUuHAzPNQYYL66$=)bLN~Y-AqR`m)HH4V?k+qDa)iM|yjV(0C*4R}B zSteV9#&WVu42Hzm-OfGdyzcG1?(215_xbPp{PjHF&-eAbpUM+GBIyGFm#c}9fmL|U z@&w>(Nfth`x~^7Ll>QzJmJ)I`OcvGuU|ap%#LjWN<~tc@%~kd(q{Km<<9W%ao7s?E z7emjd93wRcqZd`Og@y|TYT0Xj8b#Pk$CnjOc1uqZY)jzR<9WLYksbJR@}2XVkgzX>{Fpl{0*E_}mT?Mf zX>+jfGyW`UZJZ{ABc%eJIA?{px`$rF0NXgZ%OR-{uKbqLtcKG=aXVdi=EAhS5sy%b z3wo&F$>QQ;Rl-k{7jY33MVu<;AFG$hwd$fYD`tzkfeJm3C%@)|mtOh%*?-jhXyHJ~ zH@8YDGNVEhFIIvC$3+aS%~@|BUiBevINYcg6VNUQ1_M^Ynn{y|u^H~+9Rn=Z)4ea`(Mq!{@rRv8~0aOO~I;3#JmVYiIbrL)tQ!I3n`8bgJDy?HdN zyQ8#veXHp6lT+%-{l@NsZ)ii_Qs70|lvO2${b`R3YdvMu)p8s~PKSXH!$i!h^+hLA zW^#oV=d`P=bQRqUr97McQQjSkGwLl!L@`NsqMsYLYsnQMod{NFI2 z8L;wVy5F})^d1+;NzdrxARpGIOVFg!25bNo+ewg4!ZYWj>Iq?y@;s#IdbOfHv_ERv0`EwwZ6c;z7y+a4cmbDV>WCVaiAc4$EtwH6;$+gW! zL0XDSZsoY^D}xd3Sb5=-z5>MK$DY{dBtSQYrZ2Yc?IDNfqjrR><$Xp_vS{vh?jRB2 z#m^Ti*&@vH>}(tkm+D9nxnkSKy`?-@3T+kcAC~I(dia6Z=E&Gavb;1NCBu130vn2z z;?=T|eD$mZ4xp|tX8=BkW(KZ8koLUUogDkfXMb?lZmGv~gm371%tDLvEpw3v$!90o zn8n-1wsIWPXPwlJ9Vx|lRnDoJ4oThs?Gw+QNGMgkjX%xmrRR!#jEW;bDkz)=d!&qv>fW; zEBP>o05iMiyD`NbsL4#vo1|U%Qo(62f$dzibDD>RB5rmm zOoJ{J^?~G+#|FRZ{#C;FKX*e&k!5Zk`ng%Mr;`Z z%jbr47s6x45?>B=+O*oB&lE4s14y6|8GPxTiWqrcCnO>cLL%;&t$!7;&Ycm~ zA$O8zgPQHXZp_XTr=y3$15NScC80~1bkz4W(TVvReU{T`> z3y79$s9zzFNHw`nR&1v9(j`^9!zFU6f?p75gN*{?@6rj)doL1Dr1;bbga` z&7WWnW_-xp*;0v9eW>8eGUGi9)bbPMaQU3*3iAR(QH(9x)G;b0$Ix}kKg0qY%qa8p ze)dC06Xmz^keA~BwG+{It0+1S6qH?TNmaQDAx*Wl@NC$>fP%M5x2WSYQL5`269$*Q zk7VC1P*fGZ2k-xJn~vW}GJp*L8-fEtk6sfN=K(Zi%b^}@>P6c=!wa^Vw#Uk zp~OSV-k3G$ZqY{MouI@mx;6&Z=55Ynpet9wQqEOyYYd z$_q=)?|~o5 zOXGa{C(l$jhD_~4(r+Axp+ diff --git a/voidcrew/modules/cyberware/icons/cyberware_righthand.dmi b/voidcrew/modules/cyberware/icons/cyberware_righthand.dmi index 472f1705cda3efad2090bda0af3225bb8392a7ea..42f14346503135aca49b381b32763723040d05c9 100644 GIT binary patch delta 4000 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 delta 2639 zcmZ9Nc{tPy7snN2$ucQvv5jr4g|uOaTxBUKTca4Mm`KvthRn}$HHi>0lD(KPakKA3 zmNCd!t}DiN8OFYwiDBl=eV_Zj-FyBy=RD8(<9nX(dCsQ;5+NSf100Yx`^Cs6Aa!vh z_>t|D6yM6&?9zuzRO-<=!_FM#yHlRdSoOCjJCgbb9zYc>)p)Rj1cAryF1ifTrM4uY z&g_J1*{){7@=bXMM|{l9U7*R8r~K}9cO8%OSQJEw&j^ZqU4dipsZ^8~MZb>8Kc2c^@QfF4Dkxp{CO*T2H>HN2Q`qjcDp`0cYttmX&~vX}K`U>bKR#$=!K z%25MRFQ3;Sy*upY0t=$P82W})kIog-!|4%p>>15?K9OqI$;UnHOT-y{`Aa2C)LIo~ z*xy!QPG9~FNizle8x-Ld7I?N9(2U1wDvjP3wGvx9m`Iy9jcY$L~EU1Z&;kY03l*i zpp0kuc%6>}ifzRkI%WAP4*B|Y$`d^hU+#-ut@@ykWP4xTV#T>ChsfDa6gCjMOI~A+jjv9X z*o)ildls=&ip%&Y9;$uHEwy@LO8y{59p9oJAC4&T6^u@^L7y%JK3 zoy3Lrk^S>vPVfIT&MIt>e8VT>Gkj&KKpS36B>%q7G+=3vOrzc>CvewSvSh;(E!N;; zdFxpL{HE$D_Yi6J75AUE#P_?Y$*NAsSktQzRC_OCAd6Pp7L8I#Dk0wsWY>8z{7_T7 zXLL{Bb|4S{(6~YdcZW!Rw3<EPB@tX|#MDq88(} zuafbhlx88cH*6_til`!x_4`zKu%&6Vo3hs*Qf0NkikQTs`Lvqtw&3Y{?O76cHs$HW z8z%#5Ok$E`WNNvNrR$?D(6~8UZ^UcJ!t!H0uzZ6D#w#6_AO7X!D@C7{+DOkqlkrm0 zB1&>IU@$M$0};S_xh4BupX*|wRwYTXB)IsH3v1DLZkjw&pM6ZxDG^^Z_xn?Zm7+yy z@~wyEbt}j7-nb*SR2Q~Qg!^sr5d*irz@$xz4Fvf|?h$dyuR#CsO9;>_MrQ4b&|-?` z*D&*gRk3}~MFOJE$Y}4vLI-a5YCp8@uy2CQqXnpsN3UG38%o)pSE6nSYEw|jDz}B; zbmNU(7gIYU){}@rwu8XxS;T=hLepz zHwr<>0U(nG(p9S#?Lz=&S;h>1p0mJ=QrE@EwN52cP%WSuuF6>Nh(!eFEC<1(vSHYL zzm|aD>b%3A97ASjb}YPKupR|Nu?x1XyZ#kF55> z1y&i2irJe>d?4Z-GBuR#=lN}0;$Z#7{VP#pBNJ6pHh1T}o(ML0Q3NKOj+C=`Uo2ZR zwj-9_C_dU1jw2Ltue_5+jp}Kwx`K#aj&O5&k<(S9y*ZG+h@iZKfOB&(8Rt9~FH`(@P&@1@#P~Uj)WT3`q0EUg>w*j#j&@@K1QT{{^ptYyXFrFm%mIoeT~XeWY9r`)TK& z_7Ac1Ejv5$eEvjp)Ie7&DJk*=J@f}p6&KLS4CcOi2S?S%AH28W{Q^T$1$a%Xw9Jvu zfk6fVP5~HGZh7>0re|5nF9X6yE^WXhQ|dAjC_g?>fqC2v`W9N~J(}NR%@=Bld~sI+ zeAlv;ESITFlT{@TyaphNT5&sar^3$tCD{`iFO#Eq5yt~Mdl?rA6SG(gcj9q0N#grS z^512gnhieuY?pZefVAF#-+d*aEoyUIIJlk}`t{*J2By~7uJ7_hYuJTvZ>>h?jo@u- zs+#>tB}e!CNuLE|?1rC3u3$Si;CJfo_>u+_QNC9E^u$$>p0Ba690l3IC+ace&sAWD z1BSL%i8e~eN6(@Q3Gc2Kl~?qT%k=U%3Cui>brwxf%EBL`Q0W=dc<;Y}CUE)i>;d`* zy7M}wD4yBVr00tO&XFe-+l6&sSS)ZonR@89c!vhkxano7gg@Xf1Ku%mcFo|h()8b) z=zEZqGXX)(#ytKXaAS*td|W90s&B&s9mCWuGOPYp*%vsFV*4m2-JDdn4tM|T{@7Y1zcHxI9)Twg*)CLJ6MVTVy1K!_IrKd(X6 Q>^Wg(V);wqCFijJ0Qn6VJOBUy diff --git a/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi b/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi index 69648676f49e332d98cc41c0c6aad9d4ef30534b..4df1e6da488efdee4240ad4c25c59482f3a1572e 100644 GIT binary patch delta 2396 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 delta 5161 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

    ") : null + +// ---- Mouse ------------------------------------------------------------- + +/datum/component/chrome_wheel/proc/mouse_moved(params) + if(wheel_mode == CHROME_WHEEL_CLOSED) + return + var/list/offset = chrome_wheel_mouse_offset(params, shown_to) + if(!offset) + return + if(!mouse_moved) + if(!mouse_start) + mouse_start = offset + return + if(abs(offset[1] - mouse_start[1]) + abs(offset[2] - mouse_start[2]) < 4) + return + mouse_moved = TRUE + point_at(offset) + +/datum/component/chrome_wheel/proc/mouse_clicked(params) + if(wheel_mode == CHROME_WHEEL_CLOSED) + return + var/list/offset = chrome_wheel_mouse_offset(params, shown_to) + if(!offset) + close_wheel() + return + point_at(offset) + choose(highlighted) + +/// Highlights whatever the mouse at [offset] points at. +/datum/component/chrome_wheel/proc/point_at(list/offset) + var/list/candidates = list() + var/list/points = list() + for(var/atom/movable/screen/chrome_wheel/slice/slice in main_slices + sub_slices) + candidates += slice + points += list(list(slice.slice_x, slice.slice_y)) + var/index = chrome_wheel_pick(offset[1], offset[2], points) + set_highlight(index ? candidates[index] : null, sqrt(offset[1] ** 2 + offset[2] ** 2) < CHROME_WHEEL_DEADZONE) + +/** + * Moves the highlight. An ability slice opens its sub-option ring, if it has + * one. The ring stays open while the mouse crosses the gap out to it, and + * closes when another ability slice is highlighted or the mouse comes home. + */ +/datum/component/chrome_wheel/proc/set_highlight(atom/movable/screen/chrome_wheel/slice/slice, back_in_centre = FALSE) + if(slice == highlighted) + return + if(highlighted) + highlighted.icon_state = "radial_slice" + highlighted.transform = null + highlighted = slice + if(slice) + // The focus state alone is a faint border; the size change is what reads mid-fight. + slice.icon_state = "radial_slice_focus" + slice.transform = matrix(1.25, 0, 0, 0, 1.25, 0) + if(!slice.option_id && slice != sub_parent) + show_sub_ring(slice) + else if(back_in_centre) + hide_sub_ring() + draw_label() + +/datum/component/chrome_wheel/proc/show_sub_ring(atom/movable/screen/chrome_wheel/slice/parent_slice) + hide_sub_ring() + var/datum/action/ability = parent_slice.ability_ref?.resolve() + var/list/options = ability?.chrome_wheel_options(parent) + if(!length(options)) + return + sub_parent = parent_slice + var/list/points = chrome_wheel_sub_positions(parent_slice.slice_x, parent_slice.slice_y, length(options), outer_radius) + for(var/i in 1 to length(options)) + var/datum/chrome_wheel_option/option = options[i] + if(!istype(option)) + continue + var/list/point = points[i] + var/atom/movable/screen/chrome_wheel/slice/sub = new + sub.pixel_x = point[1] + sub.pixel_y = point[2] + sub.slice_x = point[1] + sub.slice_y = point[2] + sub.ability_ref = parent_slice.ability_ref + sub.option_id = option.id + sub.name = option.name + sub.option_look = chrome_wheel_option_look(option) + sub_slices += sub + hub.vis_contents += sub + draw_slice(sub) + +/datum/component/chrome_wheel/proc/hide_sub_ring() + sub_parent = null + if(!length(sub_slices)) + return + if(highlighted in sub_slices) + highlighted = null + for(var/atom/movable/screen/chrome_wheel/slice/sub in sub_slices) + hub?.vis_contents -= sub + qdel(sub) + sub_slices.Cut() diff --git a/voidcrew/modules/cyberware/chrome_wheel_hud.dm b/voidcrew/modules/cyberware/chrome_wheel_hud.dm new file mode 100644 index 00000000000..96483e9887e --- /dev/null +++ b/voidcrew/modules/cyberware/chrome_wheel_hud.dm @@ -0,0 +1,95 @@ +/** + * Screen objects for the chrome wheel (chrome_wheel.dm). + * + * Everything the player sees hangs off one hub at the centre of the view, in + * its vis_contents, on the above-HUD plane. None of it takes the mouse: a + * near-invisible full-view catcher sits under it and reports every move and + * click to the wheel, which works out the slice from the mouse position. + * Every piece opts out of clear_screen(), because a knockdown rebuilds the HUD + * mid-hold; the wheel takes its own pieces off the screen when it closes. + */ +/atom/movable/screen/chrome_wheel + icon = 'icons/hud/radial.dmi' + icon_state = null + plane = ABOVE_HUD_PLANE + layer = 2 + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + vis_flags = VIS_INHERIT_PLANE + clear_with_screen = FALSE + +/// The wheel's anchor at the centre of the view. Draws nothing itself. +/atom/movable/screen/chrome_wheel/hub + screen_loc = "CENTER" + +/// One position on the wheel: an ability, one of its sub-options, the centre, +/// or a dim placeholder for an empty body region. +/atom/movable/screen/chrome_wheel/slice + icon_state = "radial_slice" + layer = 3 + /// The ability this slice fires. + var/datum/weakref/ability_ref + /// Set on sub-option slices: the option's id within the ability. + var/option_id + /// Set on sub-option slices: the option's icon. + var/mutable_appearance/option_look + /// Offset from the wheel centre, in pixels. + var/slice_x = 0 + var/slice_y = 0 + /// Cooldown wipe, made the first time this slice cools down. + var/atom/movable/screen/chrome_wheel/shade/shade + /// The cooldown end the shade is animating towards. + var/cooldown_until = 0 + /// What the slice was last drawn as, so unchanged slices are left alone. + var/drawn_state + +/atom/movable/screen/chrome_wheel/slice/Destroy() + vis_contents.Cut() + QDEL_NULL(shade) + ability_ref = null + option_look = null + return ..() + +/// A dark copy of the slice shape laid over a slice while it cools down. +/atom/movable/screen/chrome_wheel/shade + icon_state = "radial_slice" + color = COLOR_BLACK + alpha = 0 + layer = 4 + // APPEARANCE_UI without RESET_TRANSFORM: grows with its slice when that is pointed at. + appearance_flags = RESET_COLOR|NO_CLIENT_COLOR|PIXEL_SCALE + +/// The name of the slice the mouse is on, under the wheel. +/atom/movable/screen/chrome_wheel/label + layer = 5 + maptext_width = 192 + maptext_height = 32 + maptext_x = -80 + +/** + * tg's cursor catcher (the scope's), lifted above the HUD so the mouse reaches + * the wheel wherever it is on the map. Its icon is white at alpha 1: invisible, + * but solid to the mouse. + */ +/atom/movable/screen/fullscreen/cursor_catcher/chrome_wheel + // overlay_fullscreen() appends a severity to the base state; nothing here goes + // through it, so name the state that exists. + icon_state = "fullscreen_blocker0" + plane = ABOVE_HUD_PLANE + layer = 1 + clear_with_screen = FALSE + /// The wheel to report to. + var/datum/component/chrome_wheel/wheel + +/atom/movable/screen/fullscreen/cursor_catcher/chrome_wheel/Destroy() + wheel = null + owner = null + return ..() + +/atom/movable/screen/fullscreen/cursor_catcher/chrome_wheel/MouseMove(location, control, params) + . = ..() + if(usr == owner) + wheel?.mouse_moved(params) + +/atom/movable/screen/fullscreen/cursor_catcher/chrome_wheel/Click(location, control, params) + if(usr == owner) + wheel?.mouse_clicked(params) diff --git a/voidcrew/modules/cyberware/chrome_wheel_keybindings.dm b/voidcrew/modules/cyberware/chrome_wheel_keybindings.dm new file mode 100644 index 00000000000..bd89a6fbf66 --- /dev/null +++ b/voidcrew/modules/cyberware/chrome_wheel_keybindings.dm @@ -0,0 +1,69 @@ +/** + * The chrome wheel's three binds (chrome_wheel.dm). Defaults sit on V, the one + * free letter by the movement keys: hold V for the wheel, Shift+V fires the + * pick (beside tg's Shift+E/Q/B variants), Ctrl+V fires the OS. All three + * rebind in the keybind settings like any other. + */ +/datum/keybinding/living/chrome + +/datum/keybinding/living/chrome/can_use(client/user) + return iscarbon(user.mob) + +/// The bearer's wheel, or null with a "no chrome" balloon for a body without any. +/datum/keybinding/living/chrome/proc/get_wheel(client/user) + var/mob/living/carbon/bearer = user.mob + if(bearer.stat == DEAD) + return null + var/datum/component/chrome_wheel/wheel = get_chrome_wheel(bearer) + if(!wheel) + bearer.balloon_alert(bearer, "no chrome!") + return wheel + +/datum/keybinding/living/chrome/wheel + hotkey_keys = list("V") + name = CHROME_WHEEL_KEYBIND + full_name = "Chrome wheel" + description = "Hold to pick a chrome ability." + keybind_signal = COMSIG_KB_LIVING_CHROME_WHEEL_DOWN + +/datum/keybinding/living/chrome/wheel/down(client/user, turf/target) + . = ..() + if(.) + return + get_wheel(user)?.wheel_key_down() + return TRUE + +/datum/keybinding/living/chrome/wheel/up(client/user, turf/target) + . = ..() + if(.) + return + var/datum/component/chrome_wheel/wheel = user.mob.GetComponent(/datum/component/chrome_wheel) + wheel?.wheel_key_up() + +/datum/keybinding/living/chrome/use + hotkey_keys = list("ShiftV") + name = "use_chrome" + full_name = "Use chrome" + description = "Use the chrome ability picked on the wheel." + keybind_signal = COMSIG_KB_LIVING_USE_CHROME_DOWN + +/datum/keybinding/living/chrome/use/down(client/user, turf/target) + . = ..() + if(.) + return + get_wheel(user)?.use_selected() + return TRUE + +/datum/keybinding/living/chrome/use_os + hotkey_keys = list("CtrlV") + name = "use_os_chrome" + full_name = "Use OS chrome" + description = "Use your operating system chrome." + keybind_signal = COMSIG_KB_LIVING_USE_OS_CHROME_DOWN + +/datum/keybinding/living/chrome/use_os/down(client/user, turf/target) + . = ..() + if(.) + return + get_wheel(user)?.use_os() + return TRUE diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 547df8d14ae..59ce272a05a 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -59,6 +59,7 @@ #include "voidcrew_checkpoint_mergers.dm" #include "voidcrew_checkpoints.dm" #include "voidcrew_checkpoints_every_ship.dm" +#include "voidcrew_chrome_wheel.dm" #include "voidcrew_cloning_vat_claim.dm" #include "voidcrew_colosseum.dm" #include "voidcrew_combat_camera_breaches.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_chrome_wheel.dm b/voidcrew/modules/unit_tests/voidcrew_chrome_wheel.dm new file mode 100644 index 00000000000..8d84145a787 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_chrome_wheel.dm @@ -0,0 +1,214 @@ +/** + * # Chrome wheel + * + * The wheel (voidcrew/modules/cyberware/chrome_wheel.dm) fires the bearer's + * own chrome buttons, so these tests hold it to that: it collects exactly the + * buttons installed chrome grants (never a printable tg implant's), lays them + * out by body region without one region moving another, points at the right + * slice, and fires the pick through Trigger() with the button's own cooldown, + * offline and arm-toggle behaviour. No client exists here, so the wheel never + * draws; opening it must fail quietly instead. + * + * NOTE: unit-test files compile before voidcrew/_DEFINES/, so fork defines are + * written as literals: CHROME_WHEEL_INNER_RADIUS 40, CHROME_WHEEL_OUTER_RADIUS + * 72, CHROME_WHEEL_ROW_GAP 38, CHROME_WHEEL_DEADZONE 16, and + * ORGAN_SLOT_CYBERWARE_OS "cyberware_os". + */ + +/obj/item/organ/cyberimp/cyberware/test_wheel_pulse + name = "test wheel chrome" + zone = BODY_ZONE_CHEST + slot = "voidcrew_test_chrome_wheel" + chrome_load = 1 + actions_types = list(/datum/action/cooldown/cyberware/test_wheel) + +/obj/item/organ/cyberimp/cyberware/test_wheel_os + name = "test wheel OS" + zone = BODY_ZONE_CHEST + slot = "cyberware_os" // ORGAN_SLOT_CYBERWARE_OS + chrome_load = 1 + actions_types = list(/datum/action/cooldown/cyberware/test_wheel/os) + +/// Counts its firings, and offers two sub-options whose use it records. +/datum/action/cooldown/cyberware/test_wheel + name = "Test Wheel Pulse" + cooldown_time = 10 SECONDS + var/fired = 0 + var/list/options_used = list() + +/datum/action/cooldown/cyberware/test_wheel/Activate(atom/target) + fired++ + StartCooldown() + return TRUE + +/datum/action/cooldown/cyberware/test_wheel/chrome_wheel_options(mob/living/carbon/bearer) + return list( + new /datum/chrome_wheel_option("alpha", "Alpha"), + new /datum/chrome_wheel_option("beta", "Beta"), + ) + +/datum/action/cooldown/cyberware/test_wheel/use_chrome_wheel_option(mob/living/carbon/bearer, option_id) + options_used += option_id + return TRUE + +/datum/action/cooldown/cyberware/test_wheel/os + name = "Test Wheel Burn" + +/datum/action/cooldown/cyberware/test_wheel/os/chrome_wheel_options(mob/living/carbon/bearer) + return null + +/// Collection, region layout, its stability, display names and pointing. +/datum/unit_test/voidcrew_chrome_wheel_layout + +/datum/unit_test/voidcrew_chrome_wheel_layout/Run() + var/mob/living/carbon/human/lab_rat = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/test_wheel_pulse/pulse_ware = allocate(/obj/item/organ/cyberimp/cyberware/test_wheel_pulse) + TEST_ASSERT(pulse_ware.Insert(lab_rat, special = TRUE), "Test chrome staging insert was refused") + var/obj/item/organ/cyberimp/cyberware/test_wheel_os/os_ware = allocate(/obj/item/organ/cyberimp/cyberware/test_wheel_os) + TEST_ASSERT(os_ware.Insert(lab_rat, special = TRUE), "Test OS staging insert was refused") + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left/left_mantis = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left) + TEST_ASSERT(left_mantis.Insert(lab_rat, special = TRUE), "Left Mantis staging insert was refused") + var/obj/item/organ/cyberimp/arm/toolkit/toolset/printable = allocate(/obj/item/organ/cyberimp/arm/toolkit/toolset) + TEST_ASSERT(printable.Insert(lab_rat, special = TRUE), "Toolset implant staging insert was refused") + + var/datum/action/pulse = locate(/datum/action/cooldown/cyberware/test_wheel) in pulse_ware.actions + var/datum/action/burn = locate(/datum/action/cooldown/cyberware/test_wheel/os) in os_ware.actions + var/datum/action/blade = locate(/datum/action/item_action/organ_action/toggle) in left_mantis.actions + var/datum/action/lunge = locate(/datum/action/cooldown/cyberware/mantis_lunge) in left_mantis.actions + var/datum/action/toolset_toggle = locate(/datum/action/item_action/organ_action/toggle) in printable.actions + TEST_ASSERT(pulse && burn && blade && lunge && toolset_toggle, "A staged organ is missing its button") + + var/list/abilities = get_chrome_wheel_actions(lab_rat) + TEST_ASSERT_EQUAL(length(abilities), 4, "The wheel should hold exactly the four buttons installed chrome grants") + TEST_ASSERT((pulse in abilities) && (burn in abilities) && (blade in abilities) && (lunge in abilities), "An installed chrome button is missing from the wheel") + TEST_ASSERT(!(toolset_toggle in abilities), "A printable tg implant's button made it onto the chrome wheel") + TEST_ASSERT_EQUAL(get_os_chrome_action(lab_rat), burn, "Use OS chrome does not find the OS-slot ware's ability") + + var/list/positions = chrome_wheel_layout(abilities) + TEST_ASSERT_EQUAL(point_text(positions[burn]), "0,40", "The OS ability is not at the top of the torso ring") + TEST_ASSERT_EQUAL(point_text(positions[pulse]), "35,20", "Other torso chrome should start beside the OS, 60 degrees round the torso ring") + TEST_ASSERT_EQUAL(point_text(positions[blade]), "-72,0", "Left arm chrome is not on the left of the wheel") + TEST_ASSERT_EQUAL(point_text(positions[lunge]), "-62,-36", "A left arm's second ability should hang 30 degrees below the first") + + // Region independence: new right-arm chrome takes the right side and moves + // nothing on the left, and a missing OS leaves its place empty. + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/right_mantis = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis) + TEST_ASSERT(right_mantis.Insert(lab_rat, special = TRUE), "Right Mantis staging insert was refused") + var/datum/action/right_blade = locate(/datum/action/item_action/organ_action/toggle) in right_mantis.actions + os_ware.Remove(lab_rat, special = TRUE) + abilities = get_chrome_wheel_actions(lab_rat) + positions = chrome_wheel_layout(abilities) + TEST_ASSERT_EQUAL(point_text(positions[right_blade]), "72,0", "Right arm chrome is not on the right of the wheel") + TEST_ASSERT_EQUAL(point_text(positions[blade]), "-72,0", "Adding right-arm chrome moved the left arm's slice") + TEST_ASSERT_EQUAL(point_text(positions[pulse]), "35,20", "Removing the OS moved other torso chrome into its place") + + // Two housings give two Mantis Lunges; the side tells them apart. + var/list/names = chrome_wheel_names(abilities) + TEST_ASSERT_EQUAL(names[lunge], "Mantis Lunge (left arm)", "Matching ability names are not told apart by body side") + + // Pointing. Points: 1 pulse (35,20), 2 blade (-72,0), 3 lunge (-62,-36), 4 right blade (72,0). + var/list/points = list(positions[pulse], positions[blade], positions[lunge], positions[right_blade]) + TEST_ASSERT_EQUAL(chrome_wheel_pick(5, 5, points), 0, "The dead zone in the middle pointed at a slice") + TEST_ASSERT_EQUAL(chrome_wheel_pick(33, 22, points), 1, "The mouse on a slice did not point at it") + TEST_ASSERT_EQUAL(chrome_wheel_pick(-300, 4, points), 2, "A flick far out to the left did not point at the left arm") + TEST_ASSERT_EQUAL(chrome_wheel_pick(0, -300, points), 0, "A flick into the empty legs region pointed at something") + + var/list/ring = chrome_wheel_sub_positions(0, 72, 3, 72) + TEST_ASSERT_EQUAL(point_text(ring[1]), "0,110", "The first sub-option should sit straight out from its parent, a row outside the wheel") + var/list/second = ring[2] + var/list/third = ring[3] + TEST_ASSERT(second[1] > 0 && third[1] < 0, "Sub-options should spread to both sides of the first") + + TEST_ASSERT_EQUAL(chrome_wheel_raw_key("ShiftV"), "V", "A Shift bind's key was not found") + TEST_ASSERT_EQUAL(chrome_wheel_raw_key("CtrlShiftV"), "V", "A two-modifier bind's key was not found") + TEST_ASSERT_EQUAL(chrome_wheel_raw_key("Space"), "Space", "A plain key was mangled") + TEST_ASSERT_EQUAL(chrome_wheel_raw_key("Alt"), "Alt", "A bare modifier bind lost its key") + +/datum/unit_test/voidcrew_chrome_wheel_layout/proc/point_text(list/point) + return length(point) == 2 ? "[point[1]],[point[2]]" : "none" + +/// Selection and firing: the pick goes through the button's own Trigger(). +/datum/unit_test/voidcrew_chrome_wheel_fire + +/datum/unit_test/voidcrew_chrome_wheel_fire/Run() + var/mob/living/carbon/human/lab_rat = allocate(/mob/living/carbon/human/consistent) + TEST_ASSERT_NULL(get_chrome_wheel(lab_rat), "A body with no chrome grew a chrome wheel") + var/obj/item/organ/cyberimp/cyberware/test_wheel_pulse/pulse_ware = allocate(/obj/item/organ/cyberimp/cyberware/test_wheel_pulse) + TEST_ASSERT(pulse_ware.Insert(lab_rat, special = TRUE), "Test chrome staging insert was refused") + var/obj/item/organ/cyberimp/cyberware/test_wheel_os/os_ware = allocate(/obj/item/organ/cyberimp/cyberware/test_wheel_os) + TEST_ASSERT(os_ware.Insert(lab_rat, special = TRUE), "Test OS staging insert was refused") + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left/mantis = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left) + TEST_ASSERT(mantis.Insert(lab_rat, special = TRUE), "Mantis staging insert was refused") + + var/datum/action/cooldown/cyberware/test_wheel/pulse = locate(/datum/action/cooldown/cyberware/test_wheel) in pulse_ware.actions + var/datum/action/cooldown/cyberware/test_wheel/burn = locate(/datum/action/cooldown/cyberware/test_wheel/os) in os_ware.actions + var/datum/action/blade = locate(/datum/action/item_action/organ_action/toggle) in mantis.actions + var/datum/action/lunge = locate(/datum/action/cooldown/cyberware/mantis_lunge) in mantis.actions + + var/datum/component/chrome_wheel/wheel = get_chrome_wheel(lab_rat) + TEST_ASSERT_NOTNULL(wheel, "A chromed body got no wheel") + + // No client: the wheel key has nothing to draw on and must do nothing. + wheel.wheel_key_down() + TEST_ASSERT(!wheel.is_open(), "The wheel opened with no client to draw on") + wheel.wheel_key_up() + + wheel.select(pulse) + TEST_ASSERT(wheel.use_selected(), "Use chrome refused a ready pick") + TEST_ASSERT_EQUAL(pulse.fired, 1, "Use chrome did not fire the picked ability") + + // The cooldown is the button's own. + lab_rat.next_click = 0 + wheel.use_selected() + TEST_ASSERT_EQUAL(pulse.fired, 1, "The wheel fired an ability through its cooldown") + + // Offline chrome stays dark through the wheel, and keeps its pick. + pulse.ResetCooldown() + var/datum/component/cyberware/chrome = pulse_ware.GetComponent(/datum/component/cyberware) + chrome.start_emp_reboot(EMP_HEAVY) + lab_rat.next_click = 0 + wheel.use_selected() + TEST_ASSERT_EQUAL(pulse.fired, 1, "EMP-scrambled chrome fired from the wheel") + TEST_ASSERT_EQUAL(wheel.get_selected_ability(), pulse, "Going offline dropped the pick. It should only go dark") + chrome.tune_up() + lab_rat.next_click = 0 + wheel.use_selected() + TEST_ASSERT_EQUAL(pulse.fired, 2, "Chrome back online did not fire from the wheel") + + // Use OS chrome fires the OS and leaves the pick alone. + lab_rat.next_click = 0 + TEST_ASSERT(wheel.use_os(), "Use OS chrome refused") + TEST_ASSERT_EQUAL(burn.fired, 1, "Use OS chrome did not fire the OS ability") + TEST_ASSERT_EQUAL(pulse.fired, 2, "Use OS chrome fired the picked ability") + TEST_ASSERT_EQUAL(wheel.get_selected_ability(), pulse, "Use OS chrome changed the pick") + + // Arm hardware toggles like its button: out, then back in. + wheel.select(blade) + lab_rat.next_click = 0 + wheel.use_selected() + TEST_ASSERT(mantis.active_item && !(mantis.active_item in mantis), "Using the picked Mantis housing did not deploy the blade") + lab_rat.next_click = 0 + wheel.use_selected() + TEST_ASSERT(!mantis.active_item || (mantis.active_item in mantis), "Using the picked Mantis housing again did not retract the blade") + + // A picked sub-option fires through the ability's option hook instead of Trigger(). + pulse.ResetCooldown() + wheel.select(pulse, "beta") + lab_rat.next_click = 0 + wheel.use_selected() + TEST_ASSERT("beta" in pulse.options_used, "A picked sub-option was not handed to its ability") + TEST_ASSERT_EQUAL(pulse.fired, 2, "Using a sub-option also fired the ability itself") + + // A pick the ability no longer offers is dropped, not fired. + wheel.select(pulse, "gamma") + lab_rat.next_click = 0 + TEST_ASSERT(!wheel.use_selected(), "A sub-option the ability no longer offers was fired") + TEST_ASSERT_NULL(wheel.get_selected_ability(), "A sub-option the ability no longer offers stayed picked") + + // Chrome pulled between the pick and the use: nothing fires and the pick goes. + wheel.select(lunge) + mantis.Remove(lab_rat, special = TRUE) + lab_rat.next_click = 0 + TEST_ASSERT(!wheel.use_selected(), "An ability fired after its chrome was removed") + TEST_ASSERT_NULL(wheel.get_selected_ability(), "Removed chrome stayed picked") + TEST_ASSERT(!wheel.is_open(), "With no client, a missing pick opened the wheel") From f350e8c6bfc11cba6319f16d60e9b722f7eb40ab Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:14:27 -0800 Subject: [PATCH 124/472] Draw the Chrome Read over the subject instead of printing it to chat A read now plays out on the reader's screen alone. Corner brackets snap onto the subject and lock while a red outline of their body pulses inside them. A panel then opens beside them with their visible name, a socket per piece of hardware framed in its tier colour, a short name list and a load bar. It holds six seconds and fades as the brackets release. Street optics fill the sockets with static and name nothing. A Ghostskin subject breaks the lock: the brackets hunt, the outline turns to static, the header garbles and "NO LOCK" flashes before it drops. A body with no hardware shows its name and "no chrome". The images are client images parented to the subject on the above-lighting plane, so they follow without server work, a flash or blindfold still hides them, and walls hide them just as they hide the subject. A scattered read stays on the turf where the lock failed. Bystanders see the reader's eyes flicker. The subject sees their own body flinch red and still gets the chat line naming the reader. cyberware_scan_report() returns the read as data; cyberware_scan_readout() is rebuilt on it and prints the same text as before. --- tgstation.dme | 1 + voidcrew/modules/cyberware/cyberware_scan.dm | 135 ++- .../cyberware/cyberware_scan_overlay.dm | 876 ++++++++++++++++++ .../modules/cyberware/icons/chrome_read.dmi | Bin 0 -> 764 bytes .../cyberware/icons/chrome_read_bar.dmi | Bin 0 -> 419 bytes .../modules/unit_tests/voidcrew_cyberware.dm | 125 +++ 6 files changed, 1111 insertions(+), 26 deletions(-) create mode 100644 voidcrew/modules/cyberware/cyberware_scan_overlay.dm create mode 100644 voidcrew/modules/cyberware/icons/chrome_read.dmi create mode 100644 voidcrew/modules/cyberware/icons/chrome_read_bar.dmi diff --git a/tgstation.dme b/tgstation.dme index 562e005485d..2399f3ba4fb 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7181,6 +7181,7 @@ #include "voidcrew\modules\cyberware\cyberware_feedback.dm" #include "voidcrew\modules\cyberware\cyberware_salvage.dm" #include "voidcrew\modules\cyberware\cyberware_scan.dm" +#include "voidcrew\modules\cyberware\cyberware_scan_overlay.dm" #include "voidcrew\modules\cyberware\cyberware_stealth.dm" #include "voidcrew\modules\cyberware\parlor_fixtures.dm" #include "voidcrew\modules\cyberware\ripperdoc_catalog.dm" diff --git a/voidcrew/modules/cyberware/cyberware_scan.dm b/voidcrew/modules/cyberware/cyberware_scan.dm index 8a7b1c3315e..f470a9a7e70 100644 --- a/voidcrew/modules/cyberware/cyberware_scan.dm +++ b/voidcrew/modules/cyberware/cyberware_scan.dm @@ -22,6 +22,11 @@ * - Resolution belongs to the optics, not to the reader: the street-tier * Nightshade counts signatures, everything above it names them. * + * The reader sees the result drawn over the subject, for their eyes only + * (cyberware_scan_overlay.dm). Nothing goes to their chat. Bystanders see the + * reader's eyes flicker; the subject sees their own body flinch red and gets + * the chat line naming the reader. + * * The action rides the shared /datum/action/cooldown/cyberware bridge, so a * browned-out or EMP-scrambled set of eyes reads nothing at all. */ @@ -64,41 +69,90 @@ return "[text]" /** - * Builds the report for a body at the given resolution and hands back the - * styled lines. Split out from the action so the Cradle, an admin tool or a - * unit test can ask the same question without a pair of eyes involved. + * The short name a readout shows for one piece of hardware: no \improper, + * and no "(left)" or "(right)" on paired arm chrome. */ -/proc/cyberware_scan_readout(mob/living/carbon/target, resolution = CYBERWARE_SCAN_ITEMIZED) +/proc/cyberware_scan_label(obj/item/organ/ware) + var/static/regex/side_suffix = regex(@"\s*\((left|right)\)$", "i") + return side_suffix.Replace(format_text(ware.name), "") + +/** + * What a read resolves on a body, as data. The overlay + * (cyberware_scan_overlay.dm) draws it and cyberware_scan_readout() prints it, + * so both agree on what the bus can and can't see. + * + * Returns an associative list: `name` (the visible name), `count`, `load`, + * `capacity`, `resolution` and `entries`, one list per signature in organ + * order. At itemized resolution an entry carries `label`, `name`, `icon`, + * `icon_state`, `color`, `tier` (0 when unrated), `rated` and `offline`. At + * silhouette resolution entries are empty: the bus counts them and nothing + * else. + */ +/proc/cyberware_scan_report(mob/living/carbon/target, resolution = CYBERWARE_SCAN_ITEMIZED) RETURN_TYPE(/list) - . = list() if(!iscarbon(target)) - return - var/accent = CYBERWARE_COLOR_TIER_2 + return null // Masks and helmets beat the bus: a body reads as whatever it looks like. var/subject = target.name if(ishuman(target)) var/mob/living/carbon/human/human_target = target subject = human_target.get_visible_name() - . += cyberware_scan_line("CHROME READ: [uppertext(subject)]", accent) var/list/found = cyberware_scannable_hardware(target) - if(!length(found)) + var/list/entries = list() + for(var/obj/item/organ/ware as anything in found) + if(resolution < CYBERWARE_SCAN_ITEMIZED) + entries += list(list()) + continue + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + entries += list(list( + "label" = cyberware_scan_label(ware), + "name" = ware.name, + "icon" = ware.icon, + "icon_state" = ware.icon_state, + // Anything that isn't parlor chrome has no tier to report. + "color" = chrome ? cyberware_tier_color(chrome.tier) : CYBERWARE_SCAN_UNRATED_COLOR, + "tier" = chrome ? chrome.tier : 0, + "rated" = !!chrome, + "offline" = !!(ware.organ_flags & ORGAN_FAILING), + )) + return list( + "name" = subject, + "count" = length(found), + "load" = get_chrome_load(target), + "capacity" = get_chrome_capacity(target), + "resolution" = resolution, + "entries" = entries, + ) + +/** + * Builds the report for a body at the given resolution and hands back the + * styled lines. Split out from the action so the Cradle, an admin tool or a + * unit test can ask the same question without a pair of eyes involved. + */ +/proc/cyberware_scan_readout(mob/living/carbon/target, resolution = CYBERWARE_SCAN_ITEMIZED) + RETURN_TYPE(/list) + . = list() + var/list/report = cyberware_scan_report(target, resolution) + if(!report) + return + var/accent = CYBERWARE_COLOR_TIER_2 + . += cyberware_scan_line("CHROME READ: [uppertext(report["name"])]", accent) + + if(!report["count"]) . += cyberware_scan_line("NO HARDWARE SIGNATURE", accent) return - var/load = get_chrome_load(target) - var/capacity = get_chrome_capacity(target) - . += cyberware_scan_line("SIGNATURES [length(found)] · NEURAL LOAD [load]/[capacity]", accent) + var/load = report["load"] + var/capacity = report["capacity"] + . += cyberware_scan_line("SIGNATURES [report["count"]] · NEURAL LOAD [load]/[capacity]", accent) if(resolution < CYBERWARE_SCAN_ITEMIZED) . += cyberware_scan_line("BUS RESOLUTION TOO LOW: IDENTITIES UNRESOLVED", accent) else - for(var/obj/item/organ/ware as anything in found) - var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) - var/status = (ware.organ_flags & ORGAN_FAILING) ? " · OFFLINE" : "" - // Anything that isn't parlor chrome has no tier to report. - var/rating = chrome ? "T[chrome.tier]" : "UNRATED" - var/line_color = chrome ? cyberware_tier_color(chrome.tier) : CYBERWARE_SCAN_UNRATED_COLOR - . += cyberware_scan_line("» [uppertext(ware.name)] · [rating][status]", line_color) + for(var/list/entry as anything in report["entries"]) + var/status = entry["offline"] ? " · OFFLINE" : "" + var/rating = entry["rated"] ? "T[entry["tier"]]" : "UNRATED" + . += cyberware_scan_line("» [uppertext(entry["name"])] · [rating][status]", entry["color"]) if(load > capacity) . += cyberware_scan_line("LOAD OVER CEILING: SUBJECT BROWNED OUT", CYBERWARE_COLOR_TIER_3) @@ -112,6 +166,29 @@ ranged_mousepointer = 'icons/effects/mouse_pointers/scan_target.dmi' cooldown_time = CYBERWARE_SCAN_COOLDOWN click_to_activate = TRUE + /// The readout this read is drawing for our bearer. A new read replaces it. + var/datum/chrome_readout/current_readout + +/datum/action/cooldown/cyberware/chrome_read/Destroy() + QDEL_NULL(current_readout) + return ..() + +/// Swaps whatever we were showing for a fresh readout. A null report draws +/// the scatter. +/datum/action/cooldown/cyberware/chrome_read/proc/show_readout(mob/living/carbon/bearer, mob/living/carbon/subject, list/report) + QDEL_NULL(current_readout) + var/datum/chrome_readout/readout = new(bearer, subject, report, organ) + if(QDELETED(readout)) + return + current_readout = readout + RegisterSignal(readout, COMSIG_QDELETING, PROC_REF(on_readout_deleted)) + +/// Signal proc for [COMSIG_QDELETING] on our readout: it ended on its own +/// (expired, target gone, bearer logged out), so stop holding it. +/datum/action/cooldown/cyberware/chrome_read/proc/on_readout_deleted(datum/source) + SIGNAL_HANDLER + if(source == current_readout) + current_readout = null /datum/action/cooldown/cyberware/chrome_read/Activate(atom/target) var/obj/item/organ/eyes/robotic/cyberware/optics = organ @@ -131,29 +208,35 @@ if(!self_read && !can_see(bearer, subject, CYBERWARE_SCAN_RANGE)) bearer.balloon_alert(bearer, "no line of sight!") return FALSE - // Ghostskin scatters the handshake the same way it scatters NPC targeting. + // Ghostskin scatters the handshake the same way it scatters NPC targeting: + // the readout breaks up and drops without a lock. if(HAS_TRAIT(subject, TRAIT_CYBER_CAMO)) - bearer.balloon_alert(bearer, "signature scatter!") - to_chat(bearer, cyberware_scan_line("CHROME READ: SIGNAL SCATTER, NO LOCK", CYBERWARE_COLOR_TIER_3)) + show_readout(bearer, subject, null) + if(!self_read) + chrome_read_eye_glint(bearer) playsound(bearer, 'sound/machines/terminal/terminal_error.ogg', 20, TRUE) StartCooldown() return TRUE - var/list/report = cyberware_scan_readout(subject, optics.chrome_scan_resolution) - to_chat(bearer, jointext(report, "
    ")) + // The read draws over the subject for the reader alone; nothing goes to chat. + var/list/report = cyberware_scan_report(subject, optics.chrome_scan_resolution) + show_readout(bearer, subject, report) playsound(bearer, 'sound/machines/terminal/terminal_processing.ogg', 20, TRUE) if(!self_read) // The tell: anyone close enough sees the readout scroll across the eyes. // The subject only sits that message out when they're getting the // louder one below instead. - var/subject_feels_it = length(cyberware_scannable_hardware(subject)) + chrome_read_eye_glint(bearer) + var/subject_feels_it = report["count"] bearer.visible_message( span_notice("[bearer]'s irises flicker with a scrolling readout."), vision_distance = 3, ignored_mobs = subject_feels_it ? list(subject) : null, ) - // The subject's own hardware reports the unauthorized handshake. + // The subject's own hardware reports the unauthorized handshake: it + // flinches on their screen, and their chat names the reader. if(subject_feels_it) + chrome_read_flinch(subject) if(can_see(subject, bearer, CYBERWARE_SCAN_RANGE)) to_chat(subject, span_warning("[bearer] is reading your hardware. Every piece you have just answered a handshake you never authorized.")) else diff --git a/voidcrew/modules/cyberware/cyberware_scan_overlay.dm b/voidcrew/modules/cyberware/cyberware_scan_overlay.dm new file mode 100644 index 00000000000..93f75754f14 --- /dev/null +++ b/voidcrew/modules/cyberware/cyberware_scan_overlay.dm @@ -0,0 +1,876 @@ +/** + * # Chrome read overlay + * + * What a chrome read (cyberware_scan.dm) looks like to the reader. Corner + * brackets snap onto the subject and lock while a red outline of their body + * pulses inside them. Then a panel opens beside the subject: their visible + * name, one socket per piece of hardware framed in its tier colour, a short + * name list and a load bar. The outline fades, the panel holds, and the + * brackets release as the panel fades out. + * + * - Street optics (silhouette resolution) fill the sockets with static and + * name nothing. + * - A Ghostskin subject breaks the lock: the brackets hunt, the outline + * flickers into static, the header garbles and "NO LOCK" flashes before it + * all drops. That version stays where the lock was tried instead of + * following someone the optics can't hold. + * - A body with no hardware gets the header and "no chrome". + * + * Every image is a client image shown to the reader alone, on + * ABOVE_LIGHTING_PLANE: it draws over darkness, and a flash or blindfold still + * hides it because blindness lives on FULLSCREEN_PLANE above it. The images are + * parented to the SUBJECT, so they follow them without any server work and, + * just like the subject, are never drawn through a wall. That is on purpose: + * the read needs line of sight to start, and the readout must not turn into a + * free wallhack for its whole hold. (/datum/cyberware_hud_mark is the opposite + * case: it parks on the viewer's turf to draw THROUGH walls.) + * + * All motion is animate() on the reader's client. The server builds the + * images once and runs one timer. + */ + +/// Itemized optics on a body with hardware. +#define READOUT_ITEMIZED "itemized" +/// Street optics: counts and load, nothing named. +#define READOUT_SILHOUETTE "silhouette" +/// Ghostskin: the lock breaks. +#define READOUT_SCATTER "scatter" +/// Nothing on the bus. +#define READOUT_CLEAN "clean" + +// Timeline, in deciseconds from the click. +/// Brackets snap (0-3) and lock (3-7) while the outline pulses; the panel opens here. +#define READOUT_LOCK_TIME 8.5 +/// Header fade-in plus the panel unfolding. +#define READOUT_OPEN_TIME 4.5 +#define READOUT_HOLD_TIME 60 +#define READOUT_CLEAN_HOLD_TIME 30 +#define READOUT_FADE_TIME 6 +/// A scattered read drops at this point, without ever locking. +#define READOUT_SCATTER_DROP_AT 18 +#define READOUT_SCATTER_FADE_TIME 3 + +// Geometry, in pixels from the subject's tile origin (y up). +/// Left edge of a panel opening on the right. +#define READOUT_PANEL_X 37 +/// Top edge of the panel. +#define READOUT_PANEL_TOP 45 +#define READOUT_SOCKETS_PER_ROW 6 +#define READOUT_MAX_SOCKETS 18 +#define READOUT_MAX_NAME_LINES 8 +#define READOUT_MIN_WIDTH 108 +#define READOUT_MAX_WIDTH 160 +#define READOUT_BAR_SEGMENTS 20 +/// The socket icon is 32 px drawn into an 18 px socket. +#define READOUT_SOCKET_ICON_SCALE 0.5625 + +// Colours. +/// The HUD frame: brackets, panel border, leader line. +#define READOUT_HUD_COLOR CYBERWARE_COLOR_TIER_2 +/// The targeting outline. A deep crimson, kept apart from the brighter +/// red-orange of "offline" and the pink of tier 3. +#define READOUT_LOCK_COLOR "#f01d3a" +#define READOUT_LOCK_FILL_COLOR "#c8102a" +#define READOUT_PANEL_COLOR "#060e12" +#define READOUT_NAME_COLOR "#e6fbff" +#define READOUT_DIM_COLOR "#8fa1a8" +#define READOUT_OFFLINE_COLOR "#ff5a4a" +#define READOUT_OFFLINE_NAME_COLOR "#7d8a90" +#define READOUT_STATIC_FRAME_COLOR "#6c7c83" +#define READOUT_GLINT_COLOR "#e9feff" + +#define READOUT_ICON 'voidcrew/modules/cyberware/icons/chrome_read.dmi' +#define READOUT_BAR_ICON 'voidcrew/modules/cyberware/icons/chrome_read_bar.dmi' + +/datum/chrome_readout + /// The only person this is drawn for. + var/mob/living/viewer + /// The body being read. + var/mob/living/target + /// The optics doing the reading. Pulling them ends the readout. + var/obj/item/organ/optics + /// What the images hang off: the subject, or for a scatter the turf they + /// stood on when the lock failed. + var/atom/anchor + /// READOUT_* look. + var/mode + /// The cyberware_scan_report() being drawn. Null for a scatter. + var/list/report + /// Every image, in draw order. + var/list/image/images = list() + /// One image per socket drawn. + var/list/image/sockets = list() + /// One image per name line drawn, "and N more" included. + var/list/image/name_lines = list() + /// image -> list of what play() needs to animate it: its kind, start time, + /// role ("lock", "panel", "socket", "name", "load") and kind-specific data. + var/list/cues = list() + /// The client the images went to. Held because a mob that logged out has none. + var/client/shown_to + /// Expiry timer. + var/timer_id + /// Deciseconds from creation until everything starts fading, and how long that takes. + var/fade_at + var/fade_time + /// Whole lifetime, deciseconds. + var/lifetime + /// Panel geometry. + var/panel_x + var/panel_width + var/panel_height + /// TRUE when the panel opens on the subject's left, away from the screen edge. + var/flipped = FALSE + /// Pixel offset every image carries. Zero on the subject; on a turf it lines + /// the scatter up with where the subject was drawn. + var/base_w = 0 + var/base_z = 0 + +/datum/chrome_readout/New(mob/living/new_viewer, mob/living/new_target, list/new_report, obj/item/organ/new_optics, animated = TRUE) + . = ..() + viewer = new_viewer + target = new_target + report = new_report + optics = new_optics + if(isnull(report)) + mode = READOUT_SCATTER + else if(!report["count"]) + mode = READOUT_CLEAN + else if(report["resolution"] < CYBERWARE_SCAN_ITEMIZED) + mode = READOUT_SILHOUETTE + else + mode = READOUT_ITEMIZED + + if(mode == READOUT_SCATTER) + anchor = get_turf(target) + base_w = target.pixel_x + target.pixel_w + base_z = target.pixel_y + target.pixel_z + else + anchor = target + if(isnull(anchor)) + qdel(src) + return + + RegisterSignals(target, list(COMSIG_QDELETING, COMSIG_MOVABLE_Z_CHANGED), PROC_REF(end_readout)) + RegisterSignal(viewer, COMSIG_MOB_LOGOUT, PROC_REF(end_readout)) + if(viewer != target) + RegisterSignal(viewer, COMSIG_QDELETING, PROC_REF(end_readout)) + if(optics) + RegisterSignals(optics, list(COMSIG_ORGAN_REMOVED, COMSIG_QDELETING), PROC_REF(end_readout)) + + switch(mode) + if(READOUT_SCATTER) + fade_at = READOUT_SCATTER_DROP_AT + fade_time = READOUT_SCATTER_FADE_TIME + if(READOUT_CLEAN) + fade_at = READOUT_LOCK_TIME + READOUT_OPEN_TIME + READOUT_CLEAN_HOLD_TIME + fade_time = READOUT_FADE_TIME + else + fade_at = READOUT_LOCK_TIME + READOUT_OPEN_TIME + READOUT_HOLD_TIME + fade_time = READOUT_FADE_TIME + lifetime = fade_at + fade_time + 2 + + build_lock() + build_panel() + if(viewer.client) + shown_to = viewer.client + shown_to.images += images + // Animate only once the client holds the images, as balloon alerts and runechat do. + if(animated) + play() + timer_id = addtimer(CALLBACK(src, PROC_REF(expire)), lifetime, TIMER_STOPPABLE | TIMER_CLIENT_TIME | TIMER_DELETE_ME) + +/datum/chrome_readout/Destroy(force) + if(timer_id) + deltimer(timer_id) + timer_id = null + shown_to?.images -= images + shown_to = null + if(target) + UnregisterSignal(target, list(COMSIG_QDELETING, COMSIG_MOVABLE_Z_CHANGED)) + if(viewer) + UnregisterSignal(viewer, list(COMSIG_QDELETING, COMSIG_MOB_LOGOUT)) + if(optics) + UnregisterSignal(optics, list(COMSIG_ORGAN_REMOVED, COMSIG_QDELETING)) + images.Cut() + sockets.Cut() + name_lines.Cut() + cues.Cut() + viewer = null + target = null + optics = null + anchor = null + report = null + return ..() + +/// The hold and fade ran out. +/datum/chrome_readout/proc/expire() + timer_id = null + qdel(src) + +/** + * Signal proc for the ways a readout ends early: the subject deleted or moved + * to another z, the reader deleted or logged out (a new client starts with no + * images, and a ghost must see nothing), or the optics pulled or deleted. A + * subject who dies keeps theirs; it is a snapshot, and reading a corpse is fair. + */ +/datum/chrome_readout/proc/end_readout(datum/source) + SIGNAL_HANDLER + qdel(src) + +// ---- Building ---------------------------------------------------------- + +/// A blank image on the readout's plane, filed for showing and animating. +/datum/chrome_readout/proc/add_image(icon_file, icon_state, role, kind, at = 0) + var/image/drawn = icon_file ? image(icon_file, anchor, icon_state) : image(loc = anchor) + drawn.layer = ABOVE_ALL_MOB_LAYER + length(images) * 0.001 + // KEEP_APART: humans are KEEP_TOGETHER, and the readout must not be pulled + // into their group (their colour, alpha, height filters or lying turn). + drawn.appearance_flags = RESET_COLOR | RESET_ALPHA | RESET_TRANSFORM | KEEP_APART | PIXEL_SCALE + // Without this a click anywhere on the panel lands on the subject. + drawn.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + SET_PLANE_EXPLICIT(drawn, ABOVE_LIGHTING_PLANE, anchor) + drawn.pixel_w = base_w + drawn.pixel_z = base_z + images += drawn + cues[drawn] = list("role" = role, "kind" = kind, "at" = at) + return drawn + +/// A flat rectangle: the white pixel state scaled to size. x/y is the bottom left. +/datum/chrome_readout/proc/add_quad(x, y, width, height, quad_color, quad_alpha, role, kind, at = 0) + var/image/quad = add_image(READOUT_ICON, "px", role, kind, at) + quad.color = quad_color + quad.alpha = quad_alpha + quad.transform = chrome_readout_quad(x, y, width, height) + return quad + +/** + * One line of maptext. `cap_row` is how many pixels below the panel's top edge + * the capitals start. The box is taller than the line and vertically centred, + * so the font's line height never shifts the text: Grand9K 6pt draws a 12 px + * cell with caps 3 px down it, TinyUnicode 12pt a 13 px cell with caps 6 px down. + */ +/datum/chrome_readout/proc/add_text(html, x, cap_row, width, tiny, role, kind, at = 0) + var/image/line = add_image(null, null, role, kind, at) + line.maptext = html + line.maptext_x = x + line.maptext_width = width + if(tiny) + line.maptext_height = 17 + line.maptext_y = READOUT_PANEL_TOP - 9 - cap_row + else + line.maptext_height = 16 + line.maptext_y = READOUT_PANEL_TOP - 11 - cap_row + return line + +/// Brackets, the red outline pulse, and for a scatter the static it breaks into. +/datum/chrome_readout/proc/build_lock() + // Draft C's reticle box: three pixels clear of a standing human sprite. + var/left = 4 + var/right = 26 + var/top = 31 + var/bottom = -2 + if(target.body_position == LYING_DOWN) + left = -2 + right = 33 + top = 26 + bottom = 4 + for(var/corner in list("nw", "ne", "sw", "se")) + var/west = findtext(corner, "w") + var/north = findtext(corner, "n") + var/image/bracket = add_image(READOUT_ICON, "bracket_[corner]", "lock", "bracket") + // The corner pixel sits at canvas column 1 or 30, row 30 or 1. + bracket.pixel_w += (west ? left - 1 : right - 30) + bracket.pixel_z += (north ? top - 30 : bottom - 1) + bracket.color = (mode == READOUT_SCATTER) ? "#ffffff" : READOUT_HUD_COLOR + cues[bracket]["out_w"] = west ? -1 : 1 + cues[bracket]["out_z"] = north ? 1 : -1 + + // The outline pulse, taken from the subject's own look at this moment. + var/image/fill = add_body_copy("lock", "lock_body") + var/list/fill_rgb = rgb2num(READOUT_LOCK_FILL_COLOR) + var/static/icon/stripes_mask = icon(READOUT_ICON, "stripes") + apply_filters(fill, list( + // Flat crimson at a third of the body's alpha... + list("type" = "color", "color" = list(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, fill_rgb[1] / 255, fill_rgb[2] / 255, fill_rgb[3] / 255, 0.33, 0, 0, 0, 0)), + // ...on every third row. + list("type" = "alpha", "icon" = stripes_mask), + )) + cues[fill]["roll"] = 2 + var/image/edge = add_body_copy("lock", "lock_body") + apply_filters(edge, chrome_read_edge_filters(READOUT_LOCK_COLOR)) + edge.alpha = 230 + + if(mode == READOUT_SCATTER) + // What the outline breaks into: tg's human-shaped static and a glitch strip. + var/image/noise = add_image('icons/effects/effects.dmi', "static", "lock", "static", 5) + noise.dir = target.dir + noise.transform = target.transform + noise.color = "#a8f3fb" + noise.alpha = 191 + cues[noise]["jitter"] = 2 + var/image/glitch = add_image('icons/effects/bitrunning.dmi', "glitch", "lock", "static", 5) + glitch.color = CYBERWARE_COLOR_TIER_3 + cues[glitch]["jitter"] = 3 + return + + // Two echoes of the outline swell out from it while the lock holds. + for(var/echo_at in list(3, 6)) + var/image/echo = add_body_copy("lock", "echo", echo_at) + apply_filters(echo, chrome_read_edge_filters(READOUT_LOCK_COLOR)) + var/matrix/start = matrix(target.transform) + var/matrix/finish = matrix(target.transform) + finish.Scale(1.25) + var/matrix/midway = matrix(target.transform) + midway.Scale(1.12) + cues[echo]["from"] = start + cues[echo]["to"] = finish + // Held mid-swell, so a frame captured without animation shows one. + echo.transform = midway + echo.alpha = 110 + +/// A copy of the subject's current look on the readout's plane. +/datum/chrome_readout/proc/add_body_copy(role, kind, at = 0) + var/image/copy = chrome_read_body_copy(target, anchor) + copy.layer = ABOVE_ALL_MOB_LAYER + length(images) * 0.001 + images += copy + cues[copy] = list("role" = role, "kind" = kind, "at" = at) + return copy + +/// Sets an image's filters from param lists, keeping the lists for anything that inspects the readout. +/datum/chrome_readout/proc/apply_filters(image/drawn, list/specs) + var/list/built = list() + for(var/list/spec as anything in specs) + built += filter(arglist(spec)) + drawn.filters = built + cues[drawn]["filters"] = specs + +/// The panel: frame, leader, header, sockets, names and load bar. +/datum/chrome_readout/proc/build_panel() + var/static/datum/font/header_font = new /datum/font/grand9k/size_6pt() + var/static/datum/font/list_font = new /datum/font/tiny_unicode/size_12pt() + var/open_at = (mode == READOUT_SCATTER) ? 5 : READOUT_LOCK_TIME + var/top = READOUT_PANEL_TOP + + // What goes in it. + var/list/entries = list() + var/socket_count = 0 + if(mode == READOUT_ITEMIZED) + entries = sort_entries(report["entries"]) + socket_count = min(length(entries), READOUT_MAX_SOCKETS) + else if(mode == READOUT_SILHOUETTE) + socket_count = min(report["count"], READOUT_MAX_SOCKETS) + var/rows = CEILING(socket_count / READOUT_SOCKETS_PER_ROW, 1) + + var/list/lines = list() + if(mode == READOUT_ITEMIZED) + var/shown = length(entries) + if(shown > READOUT_MAX_NAME_LINES) + shown = READOUT_MAX_NAME_LINES - 1 + for(var/i in 1 to shown) + var/list/entry = entries[i] + lines += list(list("label" = entry["label"], "color" = entry["color"], "offline" = entry["offline"])) + if(shown < length(entries)) + lines += list(list("label" = "and [length(entries) - shown] more", "color" = null, "offline" = FALSE)) + + var/list/noise + var/header_text + if(mode == READOUT_SCATTER) + var/visible_name = ishuman(target) ? target.get_visible_name() : target.name + noise = list() + for(var/i in 1 to 8) + noise += chrome_readout_noise(clamp(length_char(visible_name), 6, 16)) + header_text = noise[1] + else + header_text = report["name"] + + // Width from the real glyph metrics, never the client's MeasureText(). + var/status_width = 0 + if(mode == READOUT_CLEAN) + status_width = list_font.get_metrics("no chrome") + 8 + else if(mode == READOUT_SCATTER) + status_width = header_font.get_metrics("NO LOCK") + 8 + var/width = 5 + header_font.get_metrics(header_text) + 6 + status_width + if(socket_count) + width = max(width, min(socket_count, READOUT_SOCKETS_PER_ROW) * 24 + 2, 22 + READOUT_BAR_SEGMENTS * 4 + 5) + // A name line starts 9 px in and needs 4 px clear of the right border, + // counting the text outline. + var/offline_width = list_font.get_metrics(" offline") + for(var/list/line as anything in lines) + width = max(width, 9 + list_font.get_metrics(line["label"]) + (line["offline"] ? offline_width : 0) + 4) + width = clamp(width, READOUT_MIN_WIDTH, READOUT_MAX_WIDTH) + if(mode != READOUT_SCATTER) + header_text = chrome_readout_fit(header_text, header_font, width - 11 - status_width) + for(var/list/line as anything in lines) + line["label"] = chrome_readout_fit(line["label"], list_font, width - 13 - (line["offline"] ? offline_width : 0)) + + var/height = 13 + switch(mode) + if(READOUT_ITEMIZED) + height = 21 + 24 * rows + 7 * length(lines) + if(READOUT_SILHOUETTE) + height = 20 + 24 * rows + + // Open away from the edge of the reader's screen. + var/turf/viewer_turf = get_turf(viewer) + var/turf/target_turf = get_turf(target) + flipped = viewer_turf && target_turf && (target_turf.x - viewer_turf.x >= 2) + panel_x = flipped ? (-5 - width) : READOUT_PANEL_X + panel_width = width + panel_height = height + + // Frame. The back and sides unfold down from the header. + var/image/back = add_quad(panel_x, top - height, width, height, READOUT_PANEL_COLOR, 205, "panel", "unfold", open_at) + cues[back]["from"] = chrome_readout_quad(panel_x, top - 11, width, 11) + add_quad(panel_x, top - 1, width, 1, READOUT_HUD_COLOR, 120, "panel", "fade", open_at) + var/image/border_bottom = add_quad(panel_x, top - height, width, 1, READOUT_HUD_COLOR, 120, "panel", "unfold", open_at) + cues[border_bottom]["from"] = chrome_readout_quad(panel_x, top - 11, width, 1) + var/image/border_left = add_quad(panel_x, top - height, 1, height, READOUT_HUD_COLOR, 120, "panel", "unfold", open_at) + cues[border_left]["from"] = chrome_readout_quad(panel_x, top - 11, 1, 11) + var/image/border_right = add_quad(panel_x + width - 1, top - height, 1, height, READOUT_HUD_COLOR, 120, "panel", "unfold", open_at) + cues[border_right]["from"] = chrome_readout_quad(panel_x + width - 1, top - 11, 1, 11) + add_quad(panel_x, top - 11, 2, 11, READOUT_HUD_COLOR, 255, "panel", "fade", open_at) + var/image/leader = add_image(READOUT_ICON, flipped ? "leader_flip" : "leader", "panel", "fade", open_at) + leader.pixel_w += flipped ? -5 : 5 + leader.pixel_z += 10 + + // Header: whatever name the body shows. + if(mode == READOUT_SCATTER) + var/image/garbled = add_text(chrome_readout_header_html(html_encode(header_text), "#9fb7bd"), panel_x + 5, 2, width - 9, FALSE, "panel", "noise", open_at) + var/list/noise_html = list() + for(var/noise_text in noise) + noise_html += chrome_readout_header_html(html_encode(noise_text), "#9fb7bd") + cues[garbled]["noise"] = noise_html + add_text(chrome_readout_header_html("NO LOCK", CYBERWARE_COLOR_TIER_3, "right"), panel_x + 5, 2, width - 9, FALSE, "panel", "blink", open_at + 1.5) + return + add_text(chrome_readout_header_html(html_encode(header_text), READOUT_NAME_COLOR), panel_x + 5, 2, width - 9, FALSE, "panel", "fade", open_at) + if(mode == READOUT_CLEAN) + add_text(chrome_readout_tiny_html("no chrome", "right"), panel_x + 5, 2, width - 9, TRUE, "panel", "fade", open_at + 1) + return + + // Sockets, wrapping six to a row. + for(var/i in 1 to socket_count) + var/column = (i - 1) % READOUT_SOCKETS_PER_ROW + var/row = round((i - 1) / READOUT_SOCKETS_PER_ROW) + var/image/socket + var/mutable_appearance/inlay + if(mode == READOUT_ITEMIZED) + var/list/entry = entries[i] + socket = add_image(READOUT_ICON, entry["offline"] ? "chip_blink" : "chip", "socket", "socket", open_at + 1.8 + (i - 1) * 0.5) + socket.color = entry["offline"] ? READOUT_OFFLINE_COLOR : entry["color"] + // Smoothly downscaled, so no PIXEL_SCALE on the ware itself. + inlay = mutable_appearance(entry["icon"], entry["icon_state"], appearance_flags = RESET_COLOR) + if(entry["offline"]) + inlay.color = list(0.29, 0.29, 0.29, 0.56, 0.56, 0.56, 0.11, 0.11, 0.11) + else + socket = add_image(READOUT_ICON, "chip", "socket", "socket", open_at + 1.8 + (i - 1) * 0.5) + socket.color = READOUT_STATIC_FRAME_COLOR + inlay = mutable_appearance('icons/effects/effects.dmi', "static_base", alpha = 110, appearance_flags = RESET_COLOR | PIXEL_SCALE) + inlay.color = "#bfbfbf" + inlay.transform = matrix(READOUT_SOCKET_ICON_SCALE, 0, 0, 0, READOUT_SOCKET_ICON_SCALE, 0) + socket.overlays += inlay + // The chip state's 20 px socket starts six pixels into its canvas. + socket.pixel_w += panel_x + 3 + column * 24 - 6 + socket.pixel_z += top - 33 - row * 24 - 6 + sockets += socket + + // Names, each with its tier as a small swatch. + var/names_top = 12 + 24 * rows + for(var/i in 1 to length(lines)) + var/list/line = lines[i] + var/cap_row = names_top + 7 * (i - 1) + var/inner + if(isnull(line["color"])) + inner = "[html_encode(line["label"])]" + else if(line["offline"]) + inner = "[html_encode(line["label"])] offline" + else + inner = "[html_encode(line["label"])]" + var/image/name_line = add_text(chrome_readout_tiny_html(inner), panel_x + 9, cap_row, width - 10, TRUE, "name", "fade", open_at + 2.6 + (i - 1) * 0.5) + if(!isnull(line["color"])) + var/mutable_appearance/swatch = mutable_appearance(READOUT_ICON, "bullet", appearance_flags = RESET_COLOR) + swatch.color = line["color"] + swatch.pixel_w = panel_x + 4 + swatch.pixel_z = top - 4 - cap_row + name_line.overlays += swatch + name_lines += name_line + + // Load, as a bar. Over the ceiling it pins full in the offline colour. + var/bar_row = names_top + (length(lines) ? 7 * length(lines) + 2 : 0) + var/load = report["load"] + var/capacity = max(report["capacity"], 1) + var/over = load > capacity + var/filled = over ? READOUT_BAR_SEGMENTS : clamp(round(load / capacity * READOUT_BAR_SEGMENTS), 0, READOUT_BAR_SEGMENTS) + var/image/bar = add_image(READOUT_BAR_ICON, "fill_[filled]", "load", "bar", open_at + 3.5) + bar.color = over ? READOUT_OFFLINE_COLOR : READOUT_HUD_COLOR + bar.underlays += mutable_appearance(READOUT_BAR_ICON, "track", appearance_flags = RESET_COLOR) + bar.pixel_w += panel_x + 22 + bar.pixel_z += top - 3 - bar_row + bar.maptext = chrome_readout_tiny_html("load") + bar.maptext_x = -18 + bar.maptext_y = -5 + bar.maptext_width = 20 + bar.maptext_height = 17 + cues[bar]["fill"] = filled + +/// Highest tier first, unrated last; ties keep organ order. +/datum/chrome_readout/proc/sort_entries(list/entries) + var/list/sorted = list() + for(var/rank in list(CYBERWARE_TIER_4, CYBERWARE_TIER_3, CYBERWARE_TIER_2, CYBERWARE_TIER_1, 0)) + for(var/list/entry as anything in entries) + if(entry["tier"] == rank) + sorted += list(entry) + return sorted + +// ---- Animation --------------------------------------------------------- + +/// Hands every image its timeline. Called once, after the client has them. +/datum/chrome_readout/proc/play() + for(var/image/drawn as anything in images) + var/list/cue = cues[drawn] + switch(cue["kind"]) + if("fade") + play_fade(drawn, cue["at"]) + if("unfold") + play_unfold(drawn, cue) + if("socket") + play_socket(drawn, cue["at"]) + if("bar") + play_bar(drawn, cue) + if("bracket") + play_bracket(drawn, cue) + if("lock_body") + play_lock_body(drawn, cue) + if("echo") + play_echo(drawn, cue) + if("static") + play_static(drawn, cue) + if("noise") + play_noise(drawn, cue) + if("blink") + play_blink(drawn, cue["at"]) + +/datum/chrome_readout/proc/play_fade(image/drawn, at, fade_in = 0.8) + var/hold_alpha = drawn.alpha + drawn.alpha = 0 + animate(drawn, alpha = 0, time = at) + animate(alpha = hold_alpha, time = fade_in) + animate(alpha = hold_alpha, time = max(fade_at - at - fade_in, 0)) + animate(alpha = 0, time = fade_time) + +/datum/chrome_readout/proc/play_unfold(image/drawn, list/cue) + var/at = cue["at"] + var/hold_alpha = drawn.alpha + var/matrix/full = drawn.transform + drawn.alpha = 0 + drawn.transform = cue["from"] + animate(drawn, alpha = 0, time = at) + animate(alpha = hold_alpha, time = 0.8) + animate(transform = full, time = 2.2, easing = CUBIC_EASING | EASE_OUT) + animate(alpha = hold_alpha, time = max(fade_at - at - 3, 0)) + animate(alpha = 0, time = fade_time) + +/// Sockets land white and settle into their tier colour. +/datum/chrome_readout/proc/play_socket(image/drawn, at) + var/hold_color = drawn.color + drawn.alpha = 0 + drawn.color = "#ffffff" + animate(drawn, alpha = 0, time = at) + animate(alpha = 255, time = 0.6) + animate(color = hold_color, time = 1) + animate(alpha = 255, time = max(fade_at - at - 1.6, 0)) + animate(alpha = 0, time = fade_time) + +/// The bar fills a segment at a time. +/datum/chrome_readout/proc/play_bar(image/drawn, list/cue) + var/at = cue["at"] + var/filled = cue["fill"] + drawn.alpha = 0 + drawn.icon_state = "fill_0" + animate(drawn, alpha = 0, time = at) + animate(alpha = 255, time = 1) + for(var/step in 1 to filled) + animate(icon_state = "fill_[step]", time = 4 / filled) + animate(alpha = 255, time = max(fade_at - at - 1 - (filled ? 4 : 0), 0)) + animate(alpha = 0, time = fade_time) + +/// Snap in from wide with an overshoot, flash through the lock, hold, then +/// release outward as everything fades. A scatter never locks: the brackets hunt. +/datum/chrome_readout/proc/play_bracket(image/drawn, list/cue) + var/final_w = drawn.pixel_w + var/final_z = drawn.pixel_z + var/out_w = cue["out_w"] + var/out_z = cue["out_z"] + var/hold_color = drawn.color + drawn.pixel_w = final_w + out_w * 16 + drawn.pixel_z = final_z + out_z * 16 + drawn.alpha = 0 + drawn.color = "#ffffff" + animate(drawn, pixel_w = final_w, pixel_z = final_z, alpha = 255, time = 3, easing = BACK_EASING | EASE_OUT) + if(mode == READOUT_SCATTER) + var/elapsed = 3 + while(elapsed < fade_at) + var/step = min(1, fade_at - elapsed) + animate(pixel_w = final_w + rand(-3, 3), pixel_z = final_z + rand(-3, 3), alpha = pick(255, 255, 120), time = step, easing = JUMP_EASING | EASE_IN) + elapsed += step + else + for(var/flash in 1 to 6) + animate(color = ISODD(flash) ? hold_color : "#ffffff", time = 4 / 6, easing = JUMP_EASING | EASE_IN) + animate(color = hold_color, time = max(fade_at - 7, 0), easing = JUMP_EASING | EASE_IN) + animate(pixel_w = final_w + out_w * 10, pixel_z = final_z + out_z * 10, alpha = 0, time = fade_time, easing = CUBIC_EASING | EASE_IN) + +/// The red outline: in as the brackets land, held through the lock, out as the +/// panel opens. On a scatter it flickers and slips instead, then gives way to static. +/datum/chrome_readout/proc/play_lock_body(image/drawn, list/cue) + var/hold_alpha = drawn.alpha + var/base = drawn.pixel_w + drawn.alpha = 0 + animate(drawn, alpha = 0, time = 2) + animate(alpha = hold_alpha, time = 2) + if(mode == READOUT_SCATTER) + for(var/flicker in 1 to 8) + animate(alpha = pick(hold_alpha, hold_alpha, round(hold_alpha * 0.3), 0), pixel_w = base + rand(-2, 2), time = 0.5, easing = JUMP_EASING | EASE_IN) + animate(alpha = 0, pixel_w = base, time = 2) + else + animate(alpha = hold_alpha, time = READOUT_LOCK_TIME - 4) + animate(alpha = 0, time = 4.5) + if(cue["roll"]) + // The stripes crawl down the body, a row at a time. + animate(drawn.filters[cue["roll"]], y = -1, time = 0.8, loop = -1, easing = JUMP_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + animate(y = -2, time = 0.8, easing = JUMP_EASING | EASE_IN) + animate(y = 0, time = 0.8, easing = JUMP_EASING | EASE_IN) + +/// One outline echo swelling out and fading. +/datum/chrome_readout/proc/play_echo(image/drawn, list/cue) + drawn.alpha = 0 + drawn.transform = cue["from"] + animate(drawn, alpha = 0, time = cue["at"]) + animate(alpha = 204, time = 0) + animate(alpha = 0, transform = cue["to"], time = 5.5, easing = CUBIC_EASING | EASE_OUT) + +/// Scatter static: shivers in place until the read drops. +/datum/chrome_readout/proc/play_static(image/drawn, list/cue) + var/at = cue["at"] + var/jitter = cue["jitter"] + var/hold_alpha = drawn.alpha + var/base = drawn.pixel_w + drawn.alpha = 0 + animate(drawn, alpha = 0, time = at) + animate(alpha = hold_alpha, time = 1.5) + var/elapsed = at + 1.5 + while(elapsed < fade_at) + var/step = min(0.5, fade_at - elapsed) + animate(alpha = hold_alpha, pixel_w = base + rand(-jitter, jitter), time = step, easing = JUMP_EASING | EASE_IN) + elapsed += step + animate(alpha = 0, time = fade_time) + +/// Scatter header: the name never resolves. +/datum/chrome_readout/proc/play_noise(image/drawn, list/cue) + var/at = cue["at"] + var/list/noise_html = cue["noise"] + drawn.alpha = 0 + animate(drawn, alpha = 0, time = at) + animate(alpha = 255, time = 0.8) + var/elapsed = at + 0.8 + var/frame = 0 + while(elapsed < fade_at) + var/step = min(1, fade_at - elapsed) + animate(maptext = noise_html[(frame % length(noise_html)) + 1], time = step) + elapsed += step + frame++ + animate(alpha = 0, time = fade_time) + +/// "NO LOCK", three beats on and one off. +/datum/chrome_readout/proc/play_blink(image/drawn, at) + drawn.alpha = 0 + animate(drawn, alpha = 0, time = at) + var/elapsed = at + while(elapsed + 2 <= fade_at) + animate(alpha = 255, time = 1.5, easing = JUMP_EASING | EASE_IN) + animate(alpha = 0, time = 0.5, easing = JUMP_EASING | EASE_IN) + elapsed += 2 + animate(alpha = 255, time = max(fade_at - elapsed, 0), easing = JUMP_EASING | EASE_IN) + animate(alpha = 0, time = fade_time) + +// ---- Helpers ----------------------------------------------------------- + +/// Transform that draws the 32 px white state as a rectangle with its bottom left at x, y. +/proc/chrome_readout_quad(x, y, width, height) + return matrix(width / 32, 0, x + width / 2 - 16, 0, height / 32, y + height / 2 - 16) + +/// Grand9K header line (the map's default maptext font). +/proc/chrome_readout_header_html(text, text_color, align = "left") + return MAPTEXT("
    [text]
    ") + +/// TinyUnicode list line. `inner` is already HTML. +/proc/chrome_readout_tiny_html(inner, align = "left") + return MAPTEXT_TINY_UNICODE("
    [inner]
    ") + +/// Garbage of the given length for a header that won't resolve. Plain text; encode it for maptext. +/proc/chrome_readout_noise(length) + var/static/list/glyphs = list("#", "%", "&", "@", "$", "/", "\\", "|", "<", ">", "*") + var/garbage = "" + for(var/i in 1 to length) + garbage += pick(glyphs) + return garbage + +/// Trims text to fit max_width pixels in the given font, marking the cut. +/proc/chrome_readout_fit(text, datum/font/font, max_width) + if(font.get_metrics(text) <= max_width) + return text + var/trimmed = text + while(length_char(trimmed) > 1 && font.get_metrics("[trimmed]..") > max_width) + trimmed = copytext_char(trimmed, 1, length_char(trimmed)) + return "[trimmed].." + +/** + * Filter chain that turns a copy of a body into just its one-pixel outline in + * `edge_color`. The body goes black, gains a white outline, and a colour matrix + * then keys alpha off red: the white ring stays, the black body vanishes, and + * nothing is added where the image was already clear. + */ +/proc/chrome_read_edge_filters(edge_color) + var/list/edge_rgb = rgb2num(edge_color) + return list( + list("type" = "color", "color" = list(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0)), + list("type" = "outline", "size" = 1, "color" = "#ffffff", "flags" = OUTLINE_SHARP | OUTLINE_SQUARE), + list("type" = "color", "color" = list(edge_rgb[1] / 255, edge_rgb[2] / 255, edge_rgb[3] / 255, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)), + ) + +/** + * A client image wearing `body`'s current look, hung on `attach_to`, flattened + * out of everything that would tie it to the body's own rendering: no colour, + * alpha or filters of theirs, and on the readout's plane. Parented to the body + * itself it takes no pixel offset of its own (it inherits theirs); parked on a + * turf it keeps theirs so it lines up. + * + * Emissive overlays inside the copy keep their own plane and land exactly on + * the body's own emissives. + */ +/proc/chrome_read_body_copy(mob/living/body, atom/attach_to = body) + var/image/copy = image(loc = attach_to) + copy.appearance = body.appearance + copy.appearance_flags = KEEP_TOGETHER | KEEP_APART | RESET_COLOR | RESET_ALPHA | RESET_TRANSFORM | PIXEL_SCALE + copy.color = null + copy.alpha = 255 + copy.filters = null + copy.blend_mode = BLEND_DEFAULT + copy.render_target = null + copy.render_source = null + copy.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + if(attach_to == body) + copy.pixel_x = 0 + copy.pixel_y = 0 + copy.pixel_w = 0 + copy.pixel_z = 0 + copy.layer = ABOVE_ALL_MOB_LAYER + SET_PLANE_EXPLICIT(copy, ABOVE_LIGHTING_PLANE, attach_to) + return copy + +// ---- The subject and the bystanders ------------------------------------ + +/** + * The subject's own warning: their body flinches red twice, on their screen + * only. The chat line naming the reader is sent by the action. + */ +/proc/chrome_read_flinch(mob/living/subject) + var/client/subject_client = subject?.client + if(!subject_client) + return + var/image/flinch = chrome_read_flinch_image(subject) + subject_client.images += flinch + animate(flinch, alpha = 0, time = 1) + animate(alpha = 255, time = 0) + animate(alpha = 255, time = 1) + animate(alpha = 0, time = 0.6) + animate(alpha = 0, time = 0.4) + animate(alpha = 200, time = 0) + animate(alpha = 200, time = 0.8) + animate(alpha = 0, time = 1.5) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(remove_image_from_client), flinch, subject_client), 1 SECONDS, TIMER_CLIENT_TIME) + +/// The flinch image: the body in tier-three pink, a faint fill under a hard outline. +/proc/chrome_read_flinch_image(mob/living/subject) + var/image/flinch = chrome_read_body_copy(subject) + var/list/flinch_rgb = rgb2num(CYBERWARE_COLOR_TIER_3) + var/list/specs = chrome_read_edge_filters(CYBERWARE_COLOR_TIER_3) + // Same outline, but the body keeps a faint fill: alpha is 0.27 inside and + // 0.95 on the ring. + specs[3] = list("type" = "color", "color" = list(0, 0, 0, 0.68, 0, 0, 0, 0, 0, 0, 0, 0, flinch_rgb[1] / 255, flinch_rgb[2] / 255, flinch_rgb[3] / 255, 0.27, 0, 0, 0, 0)) + var/list/built = list() + for(var/list/spec as anything in specs) + built += filter(arglist(spec)) + flinch.filters = built + return flinch + +/** + * The tell anyone looking at the reader can see: their eyes flicker. It rides + * the reader through vis_contents, so it shows to whoever sees them. + */ +/proc/chrome_read_eye_glint(mob/living/carbon/reader) + var/mutable_appearance/glint = chrome_read_glint_appearance(reader) + if(!glint) + return + var/atom/movable/visual = reader.flick_overlay_view(glint, 0.5 SECONDS) + if(!visual) + return + visual.vis_flags |= VIS_INHERIT_DIR + animate(visual, alpha = 255, time = 1) + animate(alpha = 0, time = 0.5, easing = JUMP_EASING | EASE_IN) + animate(alpha = 255, time = 1, easing = JUMP_EASING | EASE_IN) + animate(alpha = 0, time = 0.5, easing = JUMP_EASING | EASE_IN) + animate(alpha = 255, time = 1, easing = JUMP_EASING | EASE_IN) + animate(alpha = 0, time = 1) + +/// The reader's eyes, lit. Null when there are no eyes to light or something covers them. +/proc/chrome_read_glint_appearance(mob/living/carbon/reader) + if(!ishuman(reader)) + return null + var/mob/living/carbon/human/human_reader = reader + var/obj/item/organ/eyes/eyes = human_reader.get_organ_slot(ORGAN_SLOT_EYES) + if(isnull(eyes?.eye_icon_state)) + return null + if(human_reader.check_obscured_slots() & ITEM_SLOT_EYES) + return null + var/eye_state = eyes.eye_icon_state + var/mutable_appearance/glint = mutable_appearance('icons/mob/human/human_face.dmi', "[eye_state]_l") + glint.overlays += mutable_appearance('icons/mob/human/human_face.dmi', "[eye_state]_r") + glint.overlays += emissive_appearance('icons/mob/human/human_face.dmi', "[eye_state]_l", reader) + glint.overlays += emissive_appearance('icons/mob/human/human_face.dmi', "[eye_state]_r", reader) + glint.color = READOUT_GLINT_COLOR + glint.dir = reader.dir + var/obj/item/bodypart/head/head = human_reader.get_bodypart(BODY_ZONE_HEAD) + head?.worn_face_offset?.apply_offset(glint) + return glint + +#undef READOUT_ITEMIZED +#undef READOUT_SILHOUETTE +#undef READOUT_SCATTER +#undef READOUT_CLEAN +#undef READOUT_LOCK_TIME +#undef READOUT_OPEN_TIME +#undef READOUT_HOLD_TIME +#undef READOUT_CLEAN_HOLD_TIME +#undef READOUT_FADE_TIME +#undef READOUT_SCATTER_DROP_AT +#undef READOUT_SCATTER_FADE_TIME +#undef READOUT_PANEL_X +#undef READOUT_PANEL_TOP +#undef READOUT_SOCKETS_PER_ROW +#undef READOUT_MAX_SOCKETS +#undef READOUT_MAX_NAME_LINES +#undef READOUT_MIN_WIDTH +#undef READOUT_MAX_WIDTH +#undef READOUT_BAR_SEGMENTS +#undef READOUT_SOCKET_ICON_SCALE +#undef READOUT_HUD_COLOR +#undef READOUT_LOCK_COLOR +#undef READOUT_LOCK_FILL_COLOR +#undef READOUT_PANEL_COLOR +#undef READOUT_NAME_COLOR +#undef READOUT_DIM_COLOR +#undef READOUT_OFFLINE_COLOR +#undef READOUT_OFFLINE_NAME_COLOR +#undef READOUT_STATIC_FRAME_COLOR +#undef READOUT_GLINT_COLOR +#undef READOUT_ICON +#undef READOUT_BAR_ICON diff --git a/voidcrew/modules/cyberware/icons/chrome_read.dmi b/voidcrew/modules/cyberware/icons/chrome_read.dmi new file mode 100644 index 0000000000000000000000000000000000000000..9b3c41a776f425952d9bc38aeefef36a00bc0a4c GIT binary patch literal 764 zcmVV=-0C=2@kk3iYkA<{|Pxjl;+zvm0kb<0s%=xK~#90?cFg>!!Q)a@fby7WME@s z<1$Di&+vocG|0D*AsNgpTze*Vg8rT7dBLYe*)oEUir-DHU4L}?bP#X7jx|7vnM@R1YFP2D%2nYZ~(E!Am zGw=BWmF6Mw-*XP$H(@M-NOf%-2|0fxBp|Lgvo?gV6FMa3cg|Tq1MtqLy~mH8q{Mt`C*_H6X uckYKY0CC={ZW<7rb&*@((u@BnI=?Si8Qz>5i&zr?0000V=-0C=2@)3FM|FcgL1IeCgpyGw6dwH;ifMLO75kYH02tO`lc*Y~2}CY_ss55y=V6R)q7^|TfJxYe${(s?~^@G zgR<}cZ}Yl(`i=I?e(!3tem%73X&Dp%000000NBn48~^|S0000?ao#Tmm^1EFregpA N002ovPDHLkV1n~H#^3+| literal 0 HcmV?d00001 diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index c7e354107d3..21d698a9655 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -259,6 +259,131 @@ TEST_ASSERT(findtext(silhouette, "SIGNATURES 2"), "A silhouette chrome read didn't report the signature count: [silhouette]") TEST_ASSERT_EQUAL(initial(optics.chrome_scan_resolution), 1, "The street-tier Nightshade isn't set to silhouette resolution") +/// (f2) Chrome read overlay: the report it draws, and the readout's life. +/// Every reader here is clientless, so this proves the data and the teardown, +/// never how the client draws it. +/// Literals: resolution 1 = CYBERWARE_SCAN_SILHOUETTE, 2 = _ITEMIZED; tier 1 = +/// CYBERWARE_TIER_1; "#8fa1a8" is the unrated colour; "cyber_camo" is +/// TRAIT_CYBER_CAMO; looks are the overlay's own "itemized", "silhouette", +/// "scatter" and "clean". +/datum/unit_test/voidcrew_cyberware_scan_overlay + +/datum/unit_test/voidcrew_cyberware_scan_overlay/Run() + var/mob/living/carbon/human/reader = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/eyes/robotic/cyberware/deadeye/optics = allocate(/obj/item/organ/eyes/robotic/cyberware/deadeye) + TEST_ASSERT(optics.Insert(reader, special = TRUE), "Deadeye staging insert was refused") + var/datum/action/cooldown/cyberware/chrome_read/read = locate() in optics.actions + TEST_ASSERT_NOTNULL(read, "The Deadeye link lost its chrome read") + TEST_ASSERT(!reader.is_blind(), "Test fixture: the staged optics left the reader blind") + + // The subject: a hidden stash, a printable augment, and a weave knocked offline. + var/mob/living/carbon/human/subject = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/cargo_cavity/stash = allocate(/obj/item/organ/cyberimp/cyberware/cargo_cavity) + TEST_ASSERT(stash.Insert(subject, special = TRUE), "Cargo Cavity staging insert was refused") + var/obj/item/organ/cyberimp/chest/reviver/printable = allocate(/obj/item/organ/cyberimp/chest/reviver) + TEST_ASSERT(printable.Insert(subject, special = TRUE), "Reviver implant staging insert was refused") + var/obj/item/organ/cyberimp/cyberware/dermal_mesh/weave = allocate(/obj/item/organ/cyberimp/cyberware/dermal_mesh) + TEST_ASSERT(weave.Insert(subject, special = TRUE), "Dermal Mesh staging insert was refused") + var/datum/component/cyberware/weave_chrome = weave.GetComponent(/datum/component/cyberware) + weave_chrome.start_emp_reboot() + TEST_ASSERT(weave.organ_flags & ORGAN_FAILING, "Test fixture: an EMP didn't knock the weave offline") + + // The report: one entry per visible signature, nothing hidden, the + // printable augment unrated and grey, the EMP'd weave offline. + var/list/report = cyberware_scan_report(subject, 2) + TEST_ASSERT_EQUAL(report["count"], 2, "The report miscounted the visible hardware") + TEST_ASSERT_EQUAL(length(report["entries"]), length(cyberware_scannable_hardware(subject)), "The report's entries don't match the scannable hardware") + var/list/reviver_entry + var/list/weave_entry + for(var/list/entry as anything in report["entries"]) + TEST_ASSERT(entry["name"] != stash.name, "The report listed the ORGAN_HIDDEN Cargo Cavity") + if(entry["name"] == printable.name) + reviver_entry = entry + if(entry["name"] == weave.name) + weave_entry = entry + TEST_ASSERT_NOTNULL(reviver_entry, "The report dropped the printable reviver") + TEST_ASSERT(!reviver_entry["rated"], "The report rated a printable augment as parlor chrome") + TEST_ASSERT_EQUAL(reviver_entry["color"], "#8fa1a8", "An unrated augment didn't get the unrated colour") + TEST_ASSERT_NOTNULL(weave_entry, "The report dropped the Dermal Mesh") + TEST_ASSERT(weave_entry["offline"], "An EMP'd weave didn't read as offline") + TEST_ASSERT_EQUAL(weave_entry["tier"], 1, "The weave lost its tier") + TEST_ASSERT_EQUAL(weave_entry["label"], "Dermal Mesh weave", "The label kept the name's formatting macro") + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/blade = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis) + TEST_ASSERT_EQUAL(cyberware_scan_label(blade), "Mantis Blade housing", "A paired arm piece kept its side in the label") + + // Silhouette resolution counts the same signatures and resolves none of them. + var/list/blurred = cyberware_scan_report(subject, 1) + TEST_ASSERT_EQUAL(length(blurred["entries"]), 2, "A silhouette report miscounted") + for(var/list/entry as anything in blurred["entries"]) + TEST_ASSERT(isnull(entry["label"]) && isnull(entry["icon"]), "A silhouette report named or pictured a piece it shouldn't resolve") + + // A read draws a readout: one socket and one name line per entry, every + // image hung off the subject (never through walls) and deaf to clicks. + TEST_ASSERT(read.PreActivate(subject), "The chrome read didn't run on a subject in reach") + var/datum/chrome_readout/first = read.current_readout + TEST_ASSERT_NOTNULL(first, "A read left no readout on the action") + TEST_ASSERT_EQUAL(first.mode, "itemized", "Deadeye optics drew the wrong look") + TEST_ASSERT_EQUAL(length(first.sockets), 2, "The readout drew the wrong number of sockets") + TEST_ASSERT_EQUAL(length(first.name_lines), 2, "The readout drew the wrong number of names") + TEST_ASSERT_NULL(first.shown_to, "A clientless reader was handed images") + for(var/image/drawn as anything in first.images) + TEST_ASSERT_EQUAL(drawn.loc, subject, "A readout image hangs off something other than the subject") + TEST_ASSERT_EQUAL(drawn.mouse_opacity, MOUSE_OPACITY_TRANSPARENT, "A readout image takes clicks meant for the floor") + + // A new read replaces the old one. + read.ResetCooldown() + TEST_ASSERT(read.PreActivate(subject), "A second read didn't run") + TEST_ASSERT(QDELETED(first), "A second read left the first readout up") + var/datum/chrome_readout/readout = read.current_readout + TEST_ASSERT(readout && readout != first, "A second read didn't draw a new readout") + + // Each way it ends early. + SEND_SIGNAL(reader, COMSIG_MOB_LOGOUT) + TEST_ASSERT(QDELETED(readout), "The readout outlived its reader logging out") + TEST_ASSERT_NULL(read.current_readout, "The action held on to a readout that had ended") + + read.PreActivate(subject) + readout = read.current_readout + SEND_SIGNAL(subject, COMSIG_MOVABLE_Z_CHANGED, null, null, TRUE) + TEST_ASSERT(QDELETED(readout), "The readout outlived its subject changing z") + + read.PreActivate(subject) + readout = read.current_readout + optics.Remove(reader, special = TRUE) + TEST_ASSERT(QDELETED(readout), "The readout outlived the optics that drew it") + TEST_ASSERT(optics.Insert(reader, special = TRUE), "The optics wouldn't go back in") + + var/mob/living/carbon/human/doomed = allocate(/mob/living/carbon/human/consistent) + TEST_ASSERT(read.PreActivate(doomed), "The chrome read didn't run on a bare body") + readout = read.current_readout + TEST_ASSERT_EQUAL(readout.mode, "clean", "A body with no hardware drew the wrong look") + TEST_ASSERT_EQUAL(length(readout.sockets), 0, "A body with no hardware drew sockets") + qdel(doomed) + TEST_ASSERT(QDELETED(readout), "The readout outlived its subject") + + // Ghostskin: the lock breaks. No sockets, no names, and it stays on the + // turf where the lock was tried instead of following the subject. + ADD_TRAIT(subject, "cyber_camo", "chrome_read_overlay_test") + TEST_ASSERT(read.PreActivate(subject), "The chrome read didn't run on a camouflaged subject") + readout = read.current_readout + TEST_ASSERT_EQUAL(readout.mode, "scatter", "Ghostskin didn't scatter the readout") + TEST_ASSERT_EQUAL(length(readout.sockets), 0, "A scattered readout drew sockets") + TEST_ASSERT_EQUAL(length(readout.name_lines), 0, "A scattered readout named hardware") + for(var/image/drawn as anything in readout.images) + TEST_ASSERT_EQUAL(drawn.loc, get_turf(subject), "A scattered readout image follows the camouflaged subject") + REMOVE_TRAIT(subject, "cyber_camo", "chrome_read_overlay_test") + + // Street optics: a socket per signature, and not one name. + var/mob/living/carbon/human/street_reader = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/eyes/robotic/cyberware/nightshade/street_optics = allocate(/obj/item/organ/eyes/robotic/cyberware/nightshade) + TEST_ASSERT(street_optics.Insert(street_reader, special = TRUE), "Nightshade staging insert was refused") + var/datum/action/cooldown/cyberware/chrome_read/street_read = locate() in street_optics.actions + TEST_ASSERT(street_read.PreActivate(subject), "The street read didn't run") + readout = street_read.current_readout + TEST_ASSERT_EQUAL(readout.mode, "silhouette", "Nightshade optics drew the wrong look") + TEST_ASSERT_EQUAL(length(readout.sockets), 2, "A silhouette readout didn't draw a socket per signature") + TEST_ASSERT_EQUAL(length(readout.name_lines), 0, "A silhouette readout named hardware") + /// Camo line: the street weave fades its bearer but never takes the /// cyber_camo trait (NPC targeting and chrome reads key on it, so it fools /// players only), while the full Ghostskin keeps both. They share the skin From 6488c36a1c589f1dd3a1416dceb0f28efec74ca5 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:37:55 -0800 Subject: [PATCH 125/472] Add phase 2 cyberware sprites Item icons for the new models, passives, zoom optics and cortex decks; worn overlays for Hardweave plating, Spring calves, Vaulter pistons, Brawler myomers and the Holdout housing; the holdout revolver's world icon and in-hands. --- .../modules/cyberware/icons/cyberware.dmi | Bin 47151 -> 64227 bytes .../cyberware/icons/cyberware_lefthand.dmi | Bin 4142 -> 4414 bytes .../cyberware/icons/cyberware_righthand.dmi | Bin 4171 -> 4433 bytes .../cyberware/icons/cyberware_weapons.dmi | Bin 2579 -> 2950 bytes .../cyberware/icons/cyberware_worn.dmi | Bin 6008 -> 7441 bytes 5 files changed, 0 insertions(+), 0 deletions(-) diff --git a/voidcrew/modules/cyberware/icons/cyberware.dmi b/voidcrew/modules/cyberware/icons/cyberware.dmi index 9638af20fba75343446f6a3e4b5d5d325c067e89..ef7254bc3cce7cd382c583549645cea30a231a34 100644 GIT binary patch delta 30103 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 B>5BjW delta 12893 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 KOV)~;1pN;`rO_q; diff --git a/voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi b/voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi index ae3e1fcaca776ec8878cf9a7e09324040d8120b1..280d20d0b70c47eaf1b9f846f3625cd4915019d4 100644 GIT binary patch literal 4414 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 literal 4142 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 diff --git a/voidcrew/modules/cyberware/icons/cyberware_righthand.dmi b/voidcrew/modules/cyberware/icons/cyberware_righthand.dmi index 42f14346503135aca49b381b32763723040d05c9..bc538a37bcb57be6af323118b208f225cd8b90d5 100644 GIT binary patch literal 4433 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 diff --git a/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi b/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi index 4df1e6da488efdee4240ad4c25c59482f3a1572e..c39dece9b50008d0a97bcbfec54cc3a5b5e07cdf 100644 GIT binary patch delta 2943 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 delta 2570 zcmV+l3ib7d7n2l_B!8iLR9JLGWpiV4X>fFDZ*Bkpc$|&UJr08~3Zd2Py7k9IW{VDR}f*)ldEjq<{MdrXv#o zZ`MPg!RGb%fBnMw?+B%;yXrIV8^tkc$}lAi^Z)<}ElET{RCwC$U4Kv%*B$??KtjUt zTMY;F1Uq98u|dSd0Zt`NnpQ(QIODWTO^ZaL?My4}Ow%M&hoSaIYG;f|CXRKiCei$u z(Q4BK6NwXx2Y(qG0nrc+NigSm90(lu0}w_-UjMl4vU|IGw|nft;l0lcbGx5i?(O&U zzVG|K``+GLfP#WzHgFgfBpj||-fR_|iytAP1qc_+iU9w^+a#h@4<`X=6Nq#HqUXd_ zG6rFjAy!7!@o@5hUi}S{sV`M+e0k-1JXpG7isSzQSbvspKzZf*@b-Wz7zqGm^xem( z`M(ECSD^P=AGa-qF{cCF?HzNl3HZ9N1U@-?QQIan8v<0pNOtLu3z`D!339cn1wJ`@ zQC?(XWoFz1SfhQL!I;y5&)Tirz9Bmf@rkL^Dr>~;Ktke8y)z@gVN{Tfu?GPskT?-1+ZL@~j)9Hld{1a?Y{apv zwaC<$!q#`zZ&~a)n-iV%NQUR z12_Smt*Ze5{Izzk*Sh%rN`@b$*J?PX>*zAOB};)$%}rru0~Qc)^NYZEA=MEK$hhj zM9syJ9VgepI-Wm6DNQ%c?nthMFVaW=lVVIpEYIx zX5c7bxAbxR{dw^iO&�jyFd1gwSxd{eR1~^`PzsP0nt{#2840&jHp*zOew{bPU1i z7!oEo{i^_2Lt5M3uJK;3`vQO`SB~THt+9CT;4Rc1|2nu#oDCEO1$h8~wl?V-3c+y| zy}*p#Lj-XRAD8d)ajE(JuY1gUgfXL$;AC=_aJMkeRVrIy0=@b1u(|>jb zO<4?{*R3J|(3HK10O0oTs3yRs%f8^!XN}U=&FmQ|E`Iz~Z$l5Pz?W2LQbF`Uyb{&?V*&0Jtmt;gA_TY~IHx_JHqi zSflv`jEs&*^18$v^7g-u;Dd%{{PLL{Arqja7=$l^UjeRkbZdSA=~;Sj&C9^Jxf=VP ze-Xyb)tZ+3iy!|^y$5t(b%sO$xghvKLo)#HjL@5>%+oH*^x7d=*AEy23V#an1akvY zT^D8~y^hg5Fc4x4RQbb3dpS;Q;lDz|ON ztN++9D+0rx80CYVE1WK}zJD#8nH5Z~`Uvp74UYf-s;YjDLx*1T8r9y8y!?Ctuasla zP=7|YDMSN46Q2N%9eo$YYp4I%N^|4Ipt@52#RuY*a=;4W4Xfe6C?th72d;CwSV}UrX z3v!Gos*SUCXd0ZF zUayaFquzBl5arzuGY_(i?Ku7WGG-F)SA2mO94>;WZk%Fa3N=?ud#mb=f4>O zlbdZ0_JXy)9hMl&>%Qv53OFQcbnXmys}ewU8cab!K|w)5K|w)5K|w)5fji|n!QAY$ zkUzQA`DMTCxI$4%ECGO|$;ptFf69HEkrW^|JB{rAV>L`4oqxyD?}~rybn@g$l$V!J zo8P^2holHaDY2w-`&OK5>A;byTOku*!?p_o5kvwsU5NqdHG#=6YFhAr-q`2Y16Tp7 zaU4&y(<2Ux6j5wDb6T(}x&IWH@M;9+Tpdjt;m2#gP}=6|*U;C!oDz$@kW-=PEpP7em0 z9;_+M@0!1mY#2yu=Y5D1sV}5f1(E+WK zAvHNbD3YN-i4X+^1qB5K1qB5K1;vbF>EDJTdx7;=dcGobZShDFfDl5$t@dXewHB`k z5i0;ILPQG?ZXPgiCfmMLbBL(64*;G?LOhe+{eOrPA#u$f&iIJy2J5nz$R3_%YoGOi zaPfp0&jb^qyq>^*MqKSjRN!Op`fRrdvE7<7Je)+BC->SK_%4It@3?|KKJ%_Amk+0a z7_#>Z)LICs%VHR|=J^639DhEq_4@^?(}C(RH=Yxq=KYQFyf*UN z`hTM@I4~y`P_XTP42uFRum8ZWcH)VwC>%Q~e!qZc5>xLN;2CcI{rs@+>J-0U;L^*x za0w7Cxqp9NJQAXSZSSzl-jB@*!!>=sKtlAQIs5baXy29Nqzsf@OCX39A`q4jNzwNU gP*6}%L?-?ZBe-C|d+p%&00000NkvXXt^-0~f;Si9IRF3v diff --git a/voidcrew/modules/cyberware/icons/cyberware_worn.dmi b/voidcrew/modules/cyberware/icons/cyberware_worn.dmi index 280e565b2ae2008b2f0cd7a159da496ab672f1c7..bf04a187522d34857bbbd476700fbccdbc2704a0 100644 GIT binary patch literal 7441 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 literal 6008 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 lObij^=ivPJ;Gi4JOr>8VoDSTy Date: Sun, 8 Nov 2026 01:52:43 -0800 Subject: [PATCH 126/472] Optical camo hides from hostile animals, turrets, mobsters, thralls and experiments One shared cyberware_camo_hides() helper, used by the basic-mob strategy, simple_animal hostile CanAttack, every porta_turret's tryToShootAt, the mafia mobster pick, the patrol closet scan, the lich thrall retarget and the outpost experiment victim check. Megafauna, elites and bosses still see through it, as does anything within two tiles. --- .../modules/bounties/bounty_lair_mafia.dm | 2 + .../modules/cyberware/cyberware_stealth.dm | 108 ++++++++-- voidcrew/modules/cyberware/ware_line_camo.dm | 8 +- voidcrew/modules/lich/lich_thrall.dm | 2 + voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_camo.dm | 193 ++++++++++++++++++ 6 files changed, 298 insertions(+), 16 deletions(-) create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm diff --git a/voidcrew/modules/bounties/bounty_lair_mafia.dm b/voidcrew/modules/bounties/bounty_lair_mafia.dm index 822e2d3d63c..8f7bfbc1936 100644 --- a/voidcrew/modules/bounties/bounty_lair_mafia.dm +++ b/voidcrew/modules/bounties/bounty_lair_mafia.dm @@ -777,6 +777,8 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) /mob/living/carbon/human/npc/bounty_mafia/proc/mafia_target_ok(mob/living/target) if(!bounty_mafia_may_hurt(target) || faction_check_atom(target)) return FALSE + if(cyberware_camo_hides(target, src)) // optical camo (cyberware_stealth.dm) + return FALSE var/turf/spot = get_turf(target) return spot && spot.z == z && get_dist(src, spot) <= BOUNTY_MOBSTER_CHASE diff --git a/voidcrew/modules/cyberware/cyberware_stealth.dm b/voidcrew/modules/cyberware/cyberware_stealth.dm index a56766082b9..464083d0f31 100644 --- a/voidcrew/modules/cyberware/cyberware_stealth.dm +++ b/voidcrew/modules/cyberware/cyberware_stealth.dm @@ -4,14 +4,32 @@ * The Ghostskin Weave (ware_military.dm) fades its bearer to an alpha shimmer, * which fools humans but means nothing to NPC AI, basic mobs target by * reference, not by pixels. This file is the AI half of the camo: a trait the - * weave holds while active, and a chained override of the basic-mob targeting - * strategy that refuses camo'd targets beyond point-blank range. + * weave holds while active, one shared question (cyberware_camo_hides()), and + * the hooks that ask it for everything that hunts the bearer by sight. * - * The override lives HERE, in a cyberware-owned file, rather than in the + * Everything that grants TRAIT_CYBER_CAMO is hidden from: + * - basic mobs (the targeting strategy below, which every strategy subtype + * inherits through its own `..()` calls), + * - simple_animal hostiles (hermits, ice demons, polar bears, ...), + * - every porta_turret subtype: ruin and Syndicate turrets, hull defense + * turrets, player-built turrets, trader and player outpost turrets, including + * a player an outpost has marked, + * and, through one-line calls in the files that own them, the scripted target + * picks that never go through a strategy: the club's mobsters, patrol troopers + * opening a closet, the lich's thralls and the outpost experiments. + * + * What camo does NOT hide from: anything within CYBERWARE_CAMO_SPOT_RANGE + * tiles, and the mobs in GLOB.cyberware_camo_piercing_types (megafauna, + * elites and bosses). Ship weapons that target ships, not people, are not + * covered here. The street Flickerskin never grants the trait at all. + * + * The overrides live HERE, in a cyberware-owned file, rather than in the * shared AI files: defining the same proc a second time is this fork's blessed * way to patch upstream behavior (the later .dme include wraps the earlier - * definition, and `..()` calls straight into it). Nothing outside this module - * changes on disk. + * definition, and `..()` calls straight into it). That only works for a proc + * whose first definition is included BEFORE this file, so the hooks for the + * voidcrew files that include after it (the lich, the patrol, the experiments) + * are a single call to cyberware_camo_hides() in the proc that picks the target. * * TRAIT_CYBER_CAMO itself lives in voidcrew/_DEFINES/cyberware.dm, because * the chrome read (cyberware_scan.dm) is included ahead of this file and also @@ -23,6 +41,43 @@ /// fauna pack point-blank is still a bad idea. The PvP/PvE counterplay floor. #define CYBERWARE_CAMO_SPOT_RANGE 2 +/// Mobs that see straight through optical camo, whatever it is: megafauna, +/// mining elites and the bosses and mini bosses of the ruins, bounties, lairs +/// and outposts. A typecache, so subtypes count. +GLOBAL_LIST_INIT(cyberware_camo_piercing_types, typecacheof(list( + /mob/living/simple_animal/hostile/megafauna, + /mob/living/simple_animal/hostile/asteroid/elite, + /mob/living/basic/boss, + /mob/living/basic/lich, + /mob/living/basic/hoarfrost_matriarch, + /mob/living/basic/bounty_criminal/boss, + /mob/living/basic/bounty_criminal/kingpin, + /mob/living/basic/bounty_lair_boss, + /mob/living/basic/outpost_experiment/horror, + /mob/living/basic/alien/queen/large/derelict, + /mob/living/basic/vestige_oracle, + /mob/living/basic/vestige_mutant, + /mob/living/basic/vestige_warframe, +))) + +/** + * Whether optical camo hides `target` from `viewer` right now: the target holds + * TRAIT_CYBER_CAMO, the viewer is further off than CYBERWARE_CAMO_SPOT_RANGE and + * the viewer is not one of the mobs that sees through it. `viewer` is whatever + * is doing the looking (a mob, a turret, a creature picking its next victim). + * Anything that is not a living mob is never hidden. + */ +/proc/cyberware_camo_hides(mob/living/target, atom/viewer) + if(!isliving(target) || !HAS_TRAIT(target, TRAIT_CYBER_CAMO)) + return FALSE + if(!viewer) + return TRUE + if(get_dist(viewer, target) <= CYBERWARE_CAMO_SPOT_RANGE) + return FALSE + if(is_type_in_typecache(viewer, GLOB.cyberware_camo_piercing_types)) + return FALSE + return TRUE + /** * Chained override of the stock basic targeting strategy (base definition: * code/datums/ai/basic_mobs/targeting_strategies/basic_targeting_strategy.dm). @@ -35,10 +90,39 @@ . = ..() if(!.) return - if(!isliving(the_target)) - return . - if(!HAS_TRAIT(the_target, TRAIT_CYBER_CAMO)) - return . - if(get_dist(living_mob, the_target) <= CYBERWARE_CAMO_SPOT_RANGE) - return . - return FALSE + if(cyberware_camo_hides(the_target, living_mob)) + return FALSE + return . + +/** + * The same for simple_animal hostiles, whose single gate is CanAttack() (base + * definition: code/modules/mob/living/simple_animal/hostile/hostile.dm). They + * ask it when they pick a target and again every tick they chase one, so a + * camo field that goes up mid-chase makes them lose the target. + */ +/mob/living/simple_animal/hostile/CanAttack(atom/the_target) + . = ..() + if(!.) + return + if(cyberware_camo_hides(the_target, src)) + return FALSE + return . + +/** + * Every porta_turret, however it finds its targets (the stock scan, the hull + * defense turret's, the outpost's), hands the list it wants to shoot at to + * tryToShootAt(). Anyone camo'd and out of spot range is dropped from it. A + * turret left with nothing behaves as one that found nothing (the cover goes + * back down) instead of standing up for a target it cannot see. + */ +/obj/machinery/porta_turret/tryToShootAt(list/atom/movable/targets) + var/hid_someone = FALSE + for(var/mob/living/candidate in targets.Copy()) + if(cyberware_camo_hides(candidate, src)) + targets -= candidate + hid_someone = TRUE + if(hid_someone && !length(targets)) + if(!always_up) + popDown() + return FALSE + return ..() diff --git a/voidcrew/modules/cyberware/ware_line_camo.dm b/voidcrew/modules/cyberware/ware_line_camo.dm index 250c43730f6..0d2347a8da2 100644 --- a/voidcrew/modules/cyberware/ware_line_camo.dm +++ b/voidcrew/modules/cyberware/ware_line_camo.dm @@ -11,10 +11,10 @@ * Every number below is a plain var override instead. * * What hides_from_mobs = FALSE buys, in practice: no TRAIT_CYBER_CAMO, so - * cyberware_stealth.dm never refuses the bearer as a target and - * cyberware_scan.dm never scatters a chrome read on them. Both are keyed on - * that one trait. Anything that AIs target by sight, simple_animal hostiles, - * turrets, a few voidcrew scripted hunters, never read it at any tier. + * cyberware_stealth.dm never refuses the bearer as a target (basic mobs, + * simple_animal hostiles, turrets and the scripted hunters it names) and + * cyberware_scan.dm never scatters a chrome read on them. All of it is keyed + * on that one trait. */ /obj/item/organ/cyberimp/cyberware/ghostskin/street diff --git a/voidcrew/modules/lich/lich_thrall.dm b/voidcrew/modules/lich/lich_thrall.dm index 00d8117386b..ba1e9aec77e 100644 --- a/voidcrew/modules/lich/lich_thrall.dm +++ b/voidcrew/modules/lich/lich_thrall.dm @@ -370,6 +370,8 @@ continue // Ilthuun and his garrison if(candidate.has_status_effect(/datum/status_effect/lich_thrall)) continue + if(cyberware_camo_hides(candidate, owner)) // optical camo (cyberware_stealth.dm) + continue var/score = get_dist(owner, candidate) if(candidate.client) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 59ce272a05a..0fb6632e857 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -71,6 +71,7 @@ #include "voidcrew_crew_antag_gc.dm" #include "voidcrew_crew_hud.dm" #include "voidcrew_cyberware.dm" +#include "voidcrew_cyberware_camo.dm" #include "voidcrew_cyberware_mantis.dm" #include "voidcrew_derelict_helpers.dm" #include "voidcrew_derelict_layout.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm new file mode 100644 index 00000000000..e524d68257f --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm @@ -0,0 +1,193 @@ +/** + * Cyberware phase 2: optical camo hides its bearer from more than basic mobs. + * + * The Ghostskin weave (and anything else that grants TRAIT_CYBER_CAMO) is also invisible to + * simple_animal hostiles, every porta_turret, the outpost experiments and the club's mobsters, + * beyond CYBERWARE_CAMO_SPOT_RANGE, except to megafauna, elites and bosses. The street + * Flickerskin grants no trait, so none of this applies to it. voidcrew/modules/cyberware/ + * cyberware_stealth.dm owns the helper and most of the hooks. + * + * Literals (unit tests are included before the fork's defines): the trait is TRAIT_CYBER_CAMO + * ("cyber_camo"); the spot range is CYBERWARE_CAMO_SPOT_RANGE (2). The test room is 5x5, so the + * far corner is 4 tiles from the near one. Every mob and turret here is clientless: this proves + * the targeting gates, not how an AI walks. + */ + +/// Shared helpers; not a test itself +/datum/unit_test/voidcrew_cyberware_camo + abstract_type = /datum/unit_test/voidcrew_cyberware_camo + +/// The floor `dx`, `dy` tiles from the test room's bottom-left corner +/datum/unit_test/voidcrew_cyberware_camo/proc/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 human wearing `weave_type` with its field up +/datum/unit_test/voidcrew_cyberware_camo/proc/cloaked_human(weave_type, turf/where) + var/mob/living/carbon/human/consistent/bearer = allocate(/mob/living/carbon/human/consistent, where) + var/obj/item/organ/cyberimp/cyberware/ghostskin/weave = allocate(weave_type) + TEST_ASSERT(weave.Insert(bearer, special = TRUE), "[weave_type] staging insert was refused") + weave.engage_camo(bearer) + TEST_ASSERT(weave.camo_active, "[weave_type] never raised its field") + return bearer + +/// The helper itself: range, trait, null viewer, non-living target, and who sees through +/datum/unit_test/voidcrew_cyberware_camo/helper + +/datum/unit_test/voidcrew_cyberware_camo/helper/Run() + var/mob/living/carbon/human/consistent/bearer = allocate(/mob/living/carbon/human/consistent, spot(4, 4)) + var/mob/living/simple_animal/hostile/megafauna/boss = allocate(/mob/living/simple_animal/hostile/megafauna, spot(0, 0)) + var/mob/living/basic/bystander = allocate(/mob/living/basic, spot(0, 0)) + TEST_ASSERT(!cyberware_camo_hides(bearer, bystander), "A bearer with no field was hidden") + + ADD_TRAIT(bearer, "cyber_camo", "camo_helper_test") + TEST_ASSERT(cyberware_camo_hides(bearer, bystander), "A camo'd bearer, 4 tiles off, was not hidden") + TEST_ASSERT(cyberware_camo_hides(bearer, null), "A camo'd bearer was not hidden from nobody in particular") + TEST_ASSERT(!cyberware_camo_hides(bearer, boss), "A megafauna was fooled by camo") + TEST_ASSERT(!cyberware_camo_hides(null, bystander), "A null target was hidden") + TEST_ASSERT(!cyberware_camo_hides(allocate(/obj/item), bystander), "An object was hidden") + + // The spot range is inclusive: 3 tiles hides, 2 does not. + bearer.forceMove(spot(3, 0)) + TEST_ASSERT(cyberware_camo_hides(bearer, bystander), "A camo'd bearer 3 tiles off was spotted") + bearer.forceMove(spot(2, 0)) + TEST_ASSERT(!cyberware_camo_hides(bearer, bystander), "A camo'd bearer 2 tiles off stayed hidden") + bearer.forceMove(spot(1, 1)) + TEST_ASSERT(!cyberware_camo_hides(bearer, bystander), "A camo'd bearer 1 tile off stayed hidden") + REMOVE_TRAIT(bearer, "cyber_camo", "camo_helper_test") + + // Everything in the list sees through it, and so do its subtypes. + var/static/list/piercing = list( + /mob/living/simple_animal/hostile/megafauna/bubblegum, + /mob/living/simple_animal/hostile/asteroid/elite/herald, + /mob/living/basic/boss/thing, + /mob/living/basic/lich, + /mob/living/basic/hoarfrost_matriarch, + /mob/living/basic/bounty_criminal/boss/ghost, + /mob/living/basic/bounty_criminal/kingpin, + /mob/living/basic/bounty_lair_boss/mafia_don, + /mob/living/basic/outpost_experiment/horror, + /mob/living/basic/alien/queen/large/derelict, + /mob/living/basic/vestige_oracle, + /mob/living/basic/vestige_mutant, + /mob/living/basic/vestige_warframe, + ) + for(var/seer_type in piercing) + TEST_ASSERT(is_type_in_typecache(seer_type, GLOB.cyberware_camo_piercing_types), "[seer_type] is fooled by camo") + var/static/list/fooled = list( + /mob/living/simple_animal/hostile/asteroid/hermit, + /mob/living/simple_animal/hostile/asteroid/old_demon, + /mob/living/simple_animal/hostile/asteroid/polarbear, + /mob/living/basic/trooper/russian/mafia, + /mob/living/basic/outpost_experiment/hulk, + ) + for(var/fooled_type in fooled) + TEST_ASSERT(!is_type_in_typecache(fooled_type, GLOB.cyberware_camo_piercing_types), "[fooled_type] sees through camo and shouldn't") + +/// Simple animal hostiles: fooled beyond range, not by megafauna, not by the street weave, and +/// whoever strikes from under the field is seen again. +/datum/unit_test/voidcrew_cyberware_camo/hostiles + +/datum/unit_test/voidcrew_cyberware_camo/hostiles/Run() + var/mob/living/simple_animal/hostile/asteroid/hermit/survivor/hermit = allocate(/mob/living/simple_animal/hostile/asteroid/hermit/survivor, spot(0, 0)) + var/mob/living/simple_animal/hostile/megafauna/boss = allocate(/mob/living/simple_animal/hostile/megafauna, spot(0, 1)) + var/mob/living/carbon/human/consistent/plain = allocate(/mob/living/carbon/human/consistent, spot(4, 4)) + TEST_ASSERT(hermit.CanAttack(plain), "Fixture: the hermit won't pick a plain human to begin with") + TEST_ASSERT(boss.CanAttack(plain), "Fixture: the megafauna won't pick a plain human to begin with") + + var/mob/living/carbon/human/consistent/ghost = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin, spot(4, 4)) + TEST_ASSERT(!hermit.CanAttack(ghost), "A hermit picked a camo'd human 4 tiles off") + TEST_ASSERT(boss.CanAttack(ghost), "A megafauna was fooled by a camo'd human") + ghost.forceMove(spot(0, 2)) + TEST_ASSERT(hermit.CanAttack(ghost), "A hermit missed a camo'd human 2 tiles off") + ghost.forceMove(spot(4, 4)) + TEST_ASSERT(!hermit.CanAttack(ghost), "A hermit that lost sight of a camo'd human picked them up again") + + // A hostile already chasing loses the target the moment the field goes up: FindTarget and + // MoveToTarget both ask CanAttack(). + hermit.target = ghost + TEST_ASSERT(!hermit.CanAttack(hermit.target), "A hermit kept chasing a human who cloaked") + hermit.target = null + + // Striking from under the field drops it, so retaliation still lands. + SEND_SIGNAL(ghost, COMSIG_MOB_FIRED_GUN) + TEST_ASSERT(!HAS_TRAIT(ghost, "cyber_camo"), "Firing a gun didn't drop the field") + TEST_ASSERT(hermit.CanAttack(ghost), "A hermit couldn't retaliate against someone who fired from under the field") + + // The street weave never grants the trait, so nothing here hides it. + var/mob/living/carbon/human/consistent/flicker = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin/street, spot(4, 4)) + TEST_ASSERT(!HAS_TRAIT(flicker, "cyber_camo"), "The Flickerskin granted the camo trait") + TEST_ASSERT(hermit.CanAttack(flicker), "A hermit was fooled by the Flickerskin") + TEST_ASSERT(boss.CanAttack(flicker), "A megafauna was fooled by the Flickerskin") + +/// 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 + +/datum/unit_test/voidcrew_cyberware_camo/turrets/Run() + 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)) + var/mob/living/carbon/human/consistent/plain = allocate(/mob/living/carbon/human/consistent, spot(3, 4)) + + var/static/list/turret_types = list( + /obj/machinery/porta_turret, + /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) + var/obj/machinery/porta_turret/turret = allocate(turret_type, spot(0, 0)) + // Unanchored, popUp() and shootAt() are no-ops: the test asks who it picks, not to be shot at. + turret.anchored = FALSE + TEST_ASSERT(turret.tryToShootAt(list(plain)), "[turret_type] skipped a plain human") + TEST_ASSERT(!turret.tryToShootAt(list(ghost)), "[turret_type] picked a camo'd human 4 tiles off") + TEST_ASSERT(turret.tryToShootAt(list(flicker)), "[turret_type] skipped a Flickerskin bearer") + // Camo only takes the hidden one out of a mixed list. + TEST_ASSERT(turret.tryToShootAt(list(ghost, plain)), "[turret_type] gave up on a list with a plain human in it") + // Inside the spot range they are seen. + 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)) + +/// The experiments' own strategy (it never calls the basic one): a creature picks a plain person, +/// not a camo'd one beyond range. +/datum/unit_test/voidcrew_cyberware_camo/experiments + +/datum/unit_test/voidcrew_cyberware_camo/experiments/Run() + var/mob/living/basic/outpost_experiment/hulk/creature = allocate(/mob/living/basic/outpost_experiment/hulk, spot(0, 0)) + var/datum/targeting_strategy/outpost_experiment/strategy = GET_TARGETING_STRATEGY(/datum/targeting_strategy/outpost_experiment) + TEST_ASSERT_NOTNULL(strategy, "The experiments' targeting strategy is gone") + + var/mob/living/carbon/human/consistent/plain = allocate(/mob/living/carbon/human/consistent, spot(4, 4)) + TEST_ASSERT(strategy.can_attack(creature, plain), "Fixture: an experiment won't target a plain human to begin with") + + var/mob/living/carbon/human/consistent/ghost = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin, spot(4, 4)) + TEST_ASSERT(!strategy.can_attack(creature, ghost), "An experiment targeted a camo'd human 4 tiles off") + TEST_ASSERT(!creature.can_target(ghost), "An experiment would pick a camo'd human 4 tiles off") + ghost.forceMove(spot(2, 0)) + TEST_ASSERT(strategy.can_attack(creature, ghost), "An experiment missed a camo'd human 2 tiles off") + ghost.forceMove(spot(4, 4)) + + var/mob/living/carbon/human/consistent/flicker = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin/street, spot(4, 3)) + TEST_ASSERT(strategy.can_attack(creature, flicker), "An experiment was fooled by the Flickerskin") + +/// The club's mobsters: the scripted pick, mafia_target_ok(), skips a camo'd hunter beyond range. +/datum/unit_test/voidcrew_cyberware_camo/mobsters + +/datum/unit_test/voidcrew_cyberware_camo/mobsters/Run() + var/mob/living/basic/trooper/russian/mafia/pistol/gunman = allocate(/mob/living/basic/trooper/russian/mafia/pistol, spot(0, 0)) + var/mob/living/carbon/human/consistent/plain = allocate(/mob/living/carbon/human/consistent, spot(4, 4)) + plain.mind_initialize() + TEST_ASSERT(gunman.mafia_target_ok(plain), "Fixture: a goon won't go after a plain hunter to begin with") + + var/mob/living/carbon/human/consistent/ghost = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin, spot(4, 4)) + ghost.mind_initialize() + TEST_ASSERT(!gunman.mafia_target_ok(ghost), "A goon went after a camo'd hunter 4 tiles off") + ghost.forceMove(spot(0, 2)) + TEST_ASSERT(gunman.mafia_target_ok(ghost), "A goon missed a camo'd hunter 2 tiles off") + ghost.forceMove(spot(4, 4)) + + var/mob/living/carbon/human/consistent/flicker = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin/street, spot(4, 3)) + flicker.mind_initialize() + TEST_ASSERT(gunman.mafia_target_ok(flicker), "A goon was fooled by the Flickerskin") From dbda619e96b8f09ceab9105d9e1a073320bbaab9 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:55:56 -0800 Subject: [PATCH 127/472] Add Hardweave, Strut frame, Deep-lung, Heat-sink, Frostline, Twitchwire, Weaver shunt and Thumper ear The parents' tuning becomes vars with the same values (Atlas throw and knockback, Second Wind reserve and refill, Coolant burn multiplier, Slipwire dodge and gait, Doppler range, fade and ping volume), and the Atlas bone trait moves into _DEFINES so a subtype in another file can use it. The Slipwire desc no longer quotes its odds. --- tgstation.dme | 1 + tools/gear_catalog/ratings/cyberware.json | 80 ++++ voidcrew/_DEFINES/cyberware.dm | 13 + .../modules/cyberware/ware_military_body.dm | 30 +- voidcrew/modules/cyberware/ware_models_b.dm | 315 ++++++++++++++ voidcrew/modules/cyberware/ware_pro_combat.dm | 17 +- .../modules/cyberware/ware_pro_utility.dm | 25 +- .../modules/cyberware/ware_second_wind.dm | 14 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_models_b.dm | 394 ++++++++++++++++++ 10 files changed, 862 insertions(+), 28 deletions(-) create mode 100644 voidcrew/modules/cyberware/ware_models_b.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm diff --git a/tgstation.dme b/tgstation.dme index 2399f3ba4fb..9aa91caac2a 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7196,6 +7196,7 @@ #include "voidcrew\modules\cyberware\ware_line_widowline.dm" #include "voidcrew\modules\cyberware\ware_military_arms.dm" #include "voidcrew\modules\cyberware\ware_military_body.dm" +#include "voidcrew\modules\cyberware\ware_models_b.dm" #include "voidcrew\modules\cyberware\ware_pro_combat.dm" #include "voidcrew\modules\cyberware\ware_pro_utility.dm" #include "voidcrew\modules\cyberware\ware_registry.dm" diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 0bbf842ec06..06bddb86efc 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -301,6 +301,16 @@ "reason": "No broken bones and no lost limbs, thrown things fly farther and you get thrown half as far. Slashes, burns and bleeding still land. Load 4, 2 vouchers at reputation 22.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/cyberware/atlas/street": { + "confidence": "medium", + "group": "Armor and frame", + "hash": "", + "name": "Strut frame", + "note": "Bone wounds (dislocations, hairline and compound fractures) are refused while the ware is online; nothing else. Limbs can still be dismembered, throws are not boosted and the wearer's own knockback is not reduced. Drops during an EMP reboot or over-capacity brownout.", + "rank": 6, + "reason": "Bones can't break: no dislocations or fractures. Limbs still come off, and slashes, burns and bleeding land as usual. None of the Atlas Frame's throw boost or knockback cut. Load 2, 1,500 cr at reputation 4.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/cargo_cavity": { "confidence": "high", "group": "Body upkeep", @@ -357,6 +367,16 @@ "reason": "Halves all burn damage on top of armor, lasers included, plus no heat damage and fire burns off fast. Half of every beam in the game for load 3. Bullets and blades ignore it. 3,600 cr with no reputation.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/coolant/street": { + "confidence": "medium", + "group": "Armor and frame", + "hash": "", + "name": "Heat-sink lining", + "note": "While the ware is online the wearer takes no damage from overheating (hot air or body temperature) and loses 1 fire stack per second. It does not reduce burn damage and does not stop fire: a burning wearer is burned at full strength. Both drop during an EMP reboot or over-capacity brownout.", + "rank": 7, + "reason": "No heat damage from hot air or lava country, and fire burns off fast, but burns from lasers and flames land at full strength. The heat half of the Coolant Loops for load 1, 1,500 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/cyberware/dead_channel": { "confidence": "medium", "group": "Nerves and stuns", @@ -377,6 +397,16 @@ "reason": "15 melee, 15 bullet, 10 laser on the whole body, on top of whatever you wear. A clear step up for load 2 and 2,000 cr. EMP switches it off for 9 s.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave": { + "confidence": "medium", + "group": "Armor and frame", + "hash": "", + "name": "Hardweave plating", + "note": "While online, adds physiology armor to a human wearer: melee 20, bullet 20, laser 15, energy 10, wound 10. The armor is removed during an EMP reboot or an over-capacity brownout.", + "rank": 3, + "reason": "20 melee, 20 bullet, 15 laser, 10 energy and 10 wound armor on the whole body, on top of whatever you wear. A size up from the Mesh and well short of the Slabskin Plate, which adds bomb, fire and acid. Load 3, 3,000 cr at reputation 4. EMP switches it off for 9 s.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/doppler": { "confidence": "high", "group": "Scouting and sight", @@ -387,6 +417,26 @@ "reason": "Anything alive and moving within 9 tiles shows through walls, without taking your eye slot. Standing still hides you and the ping is audible. Thermals' little brother. Load 2, 1 voucher.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/cyberware/doppler/street": { + "confidence": "medium", + "group": "Scouting and sight", + "hash": "", + "name": "Thumper ear", + "note": "A toggle: while on, living mobs within 5 tiles on the wearer's level that are moving show as blips through walls on the wearer's HUD, each lasting 3 s after its last step so standing still drops off the display; machines never show. It plays a sonar ping at the wearer on every life tick while any mover is tracked, louder than the Heartbeat Doppler's and doubled within 3 tiles, so nearby players can hear it.", + "rank": 3, + "reason": "Anything alive and moving within 5 tiles shows through walls for a moment after each step, without taking your eye slot. Standing still hides you and the ping carries. A shorter, louder Heartbeat Doppler. Load 1, 1,500 cr at reputation 4.", + "tier": "yellow" + }, + "/obj/item/organ/cyberimp/cyberware/frostline": { + "confidence": "medium", + "group": "Armor and frame", + "hash": "", + "name": "Frostline lining", + "note": "While the ware is online the wearer takes no damage from cold air or body temperature. It does nothing for pressure, breathing or burns. The trait drops during an EMP reboot or over-capacity brownout.", + "rank": 9, + "reason": "No cold damage on ice planets and in the cold of space, but low pressure and no air still kill. Shares the seal slot with every air and heat lining, so it never sits beside a bladder. Load 1, 1,500 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/cyberware/gastro": { "confidence": "high", "group": "Body upkeep", @@ -589,6 +639,16 @@ "reason": "Three minutes of air when you lose your internals, refills as you breathe. A safety net against vacuum. Load 1, 900 cr.", "tier": "green" }, + "/obj/item/organ/cyberimp/cyberware/second_wind/deep": { + "confidence": "medium", + "group": "Armor and frame", + "hash": "", + "name": "Deep-lung bladder", + "note": "Works exactly like the Second Wind bladder: when the surroundings have under 16 kPa of oxygen and the bearer isn't on tank internals, it supplies the breath itself, spending 8 seconds of reserve per breath. The reserve is 360 seconds (180) and refills 24 seconds per breath taken in breathable air (16). It announces itself with a gasp, a HUD alert and a loud breathing loop; it does nothing for no-breathers or while EMP'd or browned out.", + "rank": 8, + "reason": "Six minutes of air when you lose your internals, refilling faster than the Second Wind. A longer safety net against vacuum, not a way to work in it. Load 2, 1,800 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/cyberware/shock_coils": { "confidence": "medium", "group": "Nerves and stuns", @@ -619,6 +679,26 @@ "reason": "15% of bullets miss while you're moving, never from an adjacent shooter, and you run slightly faster. Takes the slot Dead Channel wants. Load 4, 2 vouchers.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/cyberware/slipwire/milspec": { + "confidence": "medium", + "group": "Nerves and stuns", + "hash": "", + "name": "Weaver shunt", + "note": "While the bearer has moved within the last second, a hostile projectile has a 20% chance to be dodged with a visible sidestep and the round flies on; shooters 1 tile away always hit, and it doesn't work lying down, buckled or unconscious. Also gives a speed bonus (-0.15 slowdown). With other dodge sources only the highest chance counts, so a Cascade window's 40% still wins, and everything here is off while the ware is EMP'd or browned out.", + "rank": 2, + "reason": "20% of bullets miss while you're moving, never from an adjacent shooter, and you run a little faster than a Slipwire. Takes the slot Dead Channel wants. Load 5, 3 vouchers at reputation 11.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/slipwire/street": { + "confidence": "medium", + "group": "Nerves and stuns", + "hash": "", + "name": "Twitchwire", + "note": "While the bearer has moved within the last second, a hostile projectile has an 8% chance to be dodged with a visible sidestep and the round flies on; shooters 1 tile away always hit, and it doesn't work lying down, buckled or unconscious. No speed bonus. With other dodge sources only the highest chance counts, and it is off while the ware is EMP'd or browned out.", + "rank": 7, + "reason": "8% of bullets miss while you're moving, never from an adjacent shooter, and nothing else. A cheap taste of the Slipwire in the slot Dead Channel wants. Load 2, 1,800 cr at reputation 4.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/eyes/hud/diagnostic": { "confidence": "high", "group": "Scouting and sight", diff --git a/voidcrew/_DEFINES/cyberware.dm b/voidcrew/_DEFINES/cyberware.dm index 4ec41ffb695..f161a8cc65b 100644 --- a/voidcrew/_DEFINES/cyberware.dm +++ b/voidcrew/_DEFINES/cyberware.dm @@ -188,3 +188,16 @@ /// Past the slices, the mouse points at whichever slice lies within this many /// degrees of its direction. #define CHROME_WHEEL_POINT_TOLERANCE 20 + +// ---- Phase 2 models: seal, frame and nervous street models ------------- +// ware_models_b.dm. Hardweave, the Strut frame, the Deep-lung bladder, the +// Heat-sink and Frostline linings, the Twitchwire and Weaver shunts, and the +// Thumper ear. + +/// Held by a bearer whose skeleton simply does not break. Blocks the whole +/// bone wound series, dislocations, hairline fractures, compound fractures, +/// at the wound system's own gate, and nothing else. Every other wound series +/// lands on its bearer exactly as it lands on anyone. Granted by the Atlas +/// frame (ware_military_body.dm) and the Strut frame (ware_models_b.dm); the +/// gate itself is in ware_military_body.dm. +#define TRAIT_CYBERWARE_UNBREAKABLE_BONES "cyberware_unbreakable_bones" diff --git a/voidcrew/modules/cyberware/ware_military_body.dm b/voidcrew/modules/cyberware/ware_military_body.dm index f11a984711e..8bb666e57bd 100644 --- a/voidcrew/modules/cyberware/ware_military_body.dm +++ b/voidcrew/modules/cyberware/ware_military_body.dm @@ -418,11 +418,8 @@ // ATLAS FRAME // ========================================================================= -/// Held by a bearer whose skeleton simply does not break. Blocks the whole -/// bone wound series, dislocations, hairline fractures, compound fractures, -/// at the wound system's own gate, and nothing else. Every other wound series -/// lands on its bearer exactly as it lands on anyone. -#define TRAIT_CYBERWARE_UNBREAKABLE_BONES "cyberware_unbreakable_bones" +// TRAIT_CYBERWARE_UNBREAKABLE_BONES lives in voidcrew/_DEFINES/cyberware.dm +// so the street frame (ware_models_b.dm) can name it too. /** * # Atlas Frame (T3, chest, frame slot, load 4) @@ -447,15 +444,27 @@ chrome_load = 4 tier = CYBERWARE_TIER_3 organ_traits = list(TRAIT_NODISMEMBER, TRAIT_CYBERWARE_UNBREAKABLE_BONES) + /// Tiles added to the range of anything the bearer throws. With no speed + /// bonus either, the throw hook is never armed. + var/throw_range_bonus = 2 + /// Speed added to anything the bearer throws. + var/throw_speed_bonus = 1 + /// Whether a throw of the bearer by something else travels half as far. + var/resists_knockback = TRUE + /// The line examine adds on top of the organ's own. Null for none. + var/frame_examine = "Covers the skeleton only: no dislocations, no fractures, and no losing a limb. Slashes, punctures, burns and bleeding are untouched." /obj/item/organ/cyberimp/cyberware/atlas/examine(mob/user) . = ..() - . += span_notice("Covers the skeleton only: no dislocations, no fractures, and no losing a limb. Slashes, punctures, burns and bleeding are untouched.") + if(frame_examine) + . += span_notice(frame_examine) /obj/item/organ/cyberimp/cyberware/atlas/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() - RegisterSignal(organ_owner, COMSIG_MOB_THROW, PROC_REF(on_owner_throws)) - RegisterSignal(organ_owner, COMSIG_MOVABLE_PRE_THROW, PROC_REF(on_owner_thrown)) + if(throw_range_bonus || throw_speed_bonus) + RegisterSignal(organ_owner, COMSIG_MOB_THROW, PROC_REF(on_owner_throws)) + if(resists_knockback) + RegisterSignal(organ_owner, COMSIG_MOVABLE_PRE_THROW, PROC_REF(on_owner_thrown)) /obj/item/organ/cyberimp/cyberware/atlas/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -488,8 +497,8 @@ SIGNAL_HANDLER if(throw_args[4] != owner) // someone else's throw, somehow return - throw_args[2] += 2 // range - throw_args[3] += 1 // speed + throw_args[2] += throw_range_bonus // range + throw_args[3] += throw_speed_bonus // speed /obj/item/organ/cyberimp/cyberware/atlas/proc/clear_throw_hooks(list/candidates) for(var/atom/movable/candidate as anything in candidates) @@ -863,5 +872,4 @@ #undef CYBERWARE_LAZARUS_COOLDOWN #undef CYBERWARE_LAZARUS_WINDUP #undef CYBERWARE_LAZARUS_HEAL_TO -#undef TRAIT_CYBERWARE_UNBREAKABLE_BONES #undef CYBERWARE_RIGGER_SLOWDOWN diff --git a/voidcrew/modules/cyberware/ware_models_b.dm b/voidcrew/modules/cyberware/ware_models_b.dm new file mode 100644 index 00000000000..f3ac264503c --- /dev/null +++ b/voidcrew/modules/cyberware/ware_models_b.dm @@ -0,0 +1,315 @@ +/** + * # Street and service models, set B + * + * Cheaper and sideways models of the plating, frame, seal, nervous and ear + * wares. Every one is a subtype of the ware it undercuts and sits in the same + * slot, so fitting one evicts the other. The parents keep their numbers; the + * models only set the vars the parents expose. + * + * model parent slot load tier sold for + * Hardweave plating Dermal Mesh dermal 3 T2 3,000 cr (yellow) + * Strut frame Atlas Frame frame 2 T2 1,500 cr (yellow) + * Deep-lung bladder Second Wind seal 2 T2 1,800 cr + * Heat-sink lining Coolant Loops seal 1 T1 1,500 cr + * Frostline lining (new) seal 1 T1 1,500 cr + * Twitchwire Slipwire nervous 2 T1 1,800 cr (yellow) + * Weaver shunt Slipwire nervous 5 T3 3 vouchers (advanced) + * Thumper ear Heartbeat Dop. ears 1 T1 1,500 cr (yellow) + * + * The seal slot now holds five choices (Second Wind, Deep-lung, Heat-sink, + * Frostline, Coolant). It stays a single slot, so none of them combine: an + * air bladder and a cold lining never share a body. + * + * Dodge goes through the shared arbiter (ware_pro_combat.dm), which rolls the + * single highest live source, so the Twitchwire and the Weaver shunt can never + * stack with a Cascade window or each other. + * + * Splice's shelf, the street doc's shelf, and the zone loot tables are wired + * in the integration pass, not here. The SKUs and ledger entries below exist + * so the cradle can quote a price and so that pass has something to list. + */ + +// ========================================================================= +// HARDWEAVE PLATING (T2): the middle of the dermal ladder +// ========================================================================= + +/// Between the Mesh and the Slabskin Plate: more of the same, none of the +/// plate's bomb, fire and acid cover. +/datum/armor/cyberware_hardweave + melee = 20 + bullet = 20 + laser = 15 + energy = 10 + wound = 10 + +/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave + name = "\improper Hardweave plating" + desc = "Layered polymer weave sewn in under the skin, a size up from the usual mesh. The scarring is the part nobody mentions." + icon_state = "dermal_mesh_hardweave" + aug_overlay = "dermal_mesh_hardweave" + chrome_load = 3 + tier = CYBERWARE_TIER_2 + mesh_armor = /datum/armor/cyberware_hardweave + +// ========================================================================= +// STRUT FRAME (T2): the bones and nothing else +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/atlas/street + name = "\improper Strut frame" + desc = "Steel struts bolted along the long bones. It holds you together and has no opinion about the rest." + icon_state = "atlas_street" + chrome_load = 2 + tier = CYBERWARE_TIER_2 + // Bones only: limbs can still come off, throws are plain, and a shove + // carries you as far as it carries anyone. + organ_traits = list(TRAIT_CYBERWARE_UNBREAKABLE_BONES) + throw_range_bonus = 0 + throw_speed_bonus = 0 + resists_knockback = FALSE + frame_examine = null + +// ========================================================================= +// DEEP-LUNG BLADDER (T2): a longer breath +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/second_wind/deep + name = "\improper Deep-lung bladder" + desc = "An oversized air bladder packed in around the ribs, for people who spend their shifts on the wrong side of an airlock." + icon_state = "second_wind_deep" + chrome_load = 2 + tier = CYBERWARE_TIER_2 + reserve = 360 SECONDS + max_reserve = 360 SECONDS + refill_rate = 24 SECONDS + +// ========================================================================= +// HEAT-SINK LINING (T1): the heat half of Coolant Loops +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/coolant/street + name = "\improper Heat-sink lining" + desc = "A lining of finned aluminium stitched in under the ribs, so the heat has somewhere to go. It ticks as it cools." + icon_state = "coolant_street" + chrome_load = 1 + tier = CYBERWARE_TIER_1 + // Heat exposure and the fire scrub stay; burns land at full strength. + burn_mult = 1 + +// ========================================================================= +// FROSTLINE LINING (T1): the cold twin +// ========================================================================= + +/** + * # Frostline lining (T1, chest, seal slot, load 1) + * + * The mirror of the Coolant Loops' heat immunity: the cold stops hurting and + * nothing else changes. It does not breathe for you, so vacuum still kills + * through low pressure, and as the seal slot is single it never sits beside + * an air bladder. + */ +/obj/item/organ/cyberimp/cyberware/frostline + name = "\improper Frostline lining" + desc = "A quilted heating lining stitched in under the ribs, for work on planets that never get warm." + icon_state = "frostline" + zone = BODY_ZONE_CHEST + slot = ORGAN_SLOT_CYBERWARE_SEAL + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + organ_traits = list(TRAIT_RESISTCOLD) + +// ========================================================================= +// TWITCHWIRE (T1) and WEAVER SHUNT (T3): the rungs around the Slipwire +// ========================================================================= + +/// The Twitchwire's dodge: a flinch more than a sidestep. +/obj/item/organ/cyberimp/cyberware/slipwire/street + name = "\improper Twitchwire" + desc = "A cheap reflex splice into the spinal trunk. Your legs flinch a little before you tell them to." + icon_state = "slipwire_street" + chrome_load = 2 + tier = CYBERWARE_TIER_1 + dodge_chance = 8 + gait_modifier = null + +/obj/item/organ/cyberimp/cyberware/slipwire/milspec + name = "\improper Weaver shunt" + desc = "A military reflex shunt wired the length of the spine. It was built for people who get shot at for a living." + icon_state = "slipwire_milspec" + chrome_load = 5 + tier = CYBERWARE_TIER_3 + dodge_chance = 20 + gait_modifier = /datum/movespeed_modifier/cyberware_slipwire/weaver + +/datum/movespeed_modifier/cyberware_slipwire/weaver + multiplicative_slowdown = -0.15 + +// ========================================================================= +// THUMPER EAR (T1): short, loud sonar +// ========================================================================= + +/obj/item/organ/cyberimp/cyberware/doppler/street + name = "\improper Thumper ear" + desc = "A budget sonar transceiver socketed in behind the ear. It thumps like a bass drum from the inside." + icon_state = "doppler_street" + chrome_load = 1 + tier = CYBERWARE_TIER_1 + sonar_range = 5 + contact_fade = 3 SECONDS + ping_volume_bonus = 10 + +// ========================================================================= +// SPLICE SKUS: not listed on any vendor yet, the integration pass adds them +// to the Chop Shop's sku_types (and the street doc's, for the credit ones). +// ========================================================================= + +/datum/shop_sku/ripperdoc/hardweave + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave + price_credits = 3000 + +/datum/shop_sku/ripperdoc/atlas_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/atlas/street + price_credits = 1500 + +/datum/shop_sku/ripperdoc/second_wind_deep + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/second_wind/deep + price_credits = 1800 + +/datum/shop_sku/ripperdoc/coolant_street + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/coolant/street + price_credits = 1500 + +/datum/shop_sku/ripperdoc/frostline + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/frostline + price_credits = 1500 + +/datum/shop_sku/ripperdoc/slipwire_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/slipwire/street + price_credits = 1800 + +/datum/shop_sku/ripperdoc/slipwire_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/slipwire/milspec + price_vouchers = 3 + +/datum/shop_sku/ripperdoc/doppler_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/doppler/street + price_credits = 1500 + +// ========================================================================= +// TRADE-IN: half list, credits only, exact type. The Weaver shunt costs +// vouchers, which can't be refunded, so it has no entry. Not listed on any +// buyback_types yet, same integration pass as above. Each entry also has the +// street doc's own wording below. +// +// Names stay siblings of the parent entries (second_wind_deep, not +// second_wind/deep) so a model never inherits its parent's price. The +// parent's own entries (second_wind, dermal_mesh) still match these models +// through match_subtypes, at the parent's lower rate. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/hardweave + name = "Hardweave Plating (pulled)" + desc = "Heavy weave, cut out. Splice recycles it toward the plate." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave + match_subtypes = FALSE + pay_credits = 1500 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/atlas_street + name = "Strut Frame (pulled)" + desc = "A bolted-in bone frame, pulled. Splice strips it for the full truss." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/atlas/street + match_subtypes = FALSE + pay_credits = 750 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/second_wind_deep + name = "Deep-Lung Bladder (pulled)" + desc = "A big air bladder, used. Splice patches and resells." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/second_wind/deep + match_subtypes = FALSE + pay_credits = 900 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/coolant_street + name = "Heat-Sink Lining (pulled)" + desc = "A finned lining, pulled. Splice melts it down toward the full loop." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/coolant/street + match_subtypes = FALSE + pay_credits = 750 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/frostline + name = "Frostline Lining (pulled)" + desc = "A quilted heater lining, pulled. Splice resells these to the ice crews." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/frostline + match_subtypes = FALSE + pay_credits = 750 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/slipwire_street + name = "Twitchwire (pulled)" + desc = "A cheap reflex splice, pulled. Splice trades it up toward the real shunt." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/slipwire/street + match_subtypes = FALSE + pay_credits = 900 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/doppler_street + name = "Thumper Ear (pulled)" + desc = "A loud little sonar, pulled. Splice takes it toward a quieter one." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/doppler/street + match_subtypes = FALSE + pay_credits = 750 + demand_min = 1 + demand_max = 2 + +// ---- Ratchet's wording ---------------------------------------------------- + +/datum/shop_buyback/ripperdoc/hardweave/street_doc + desc = "Used armour weave. Heavy." + +/datum/shop_buyback/ripperdoc/atlas_street/street_doc + desc = "A used bone frame. Still rattles." + +/datum/shop_buyback/ripperdoc/second_wind_deep/street_doc + desc = "A used big air bladder." + +/datum/shop_buyback/ripperdoc/coolant_street/street_doc + desc = "A used heat-sink lining. Still warm." + +/datum/shop_buyback/ripperdoc/frostline/street_doc + desc = "A used heater lining." + +/datum/shop_buyback/ripperdoc/slipwire_street/street_doc + desc = "A used reflex splice. Twitchy." + +/datum/shop_buyback/ripperdoc/doppler_street/street_doc + desc = "A used sonar ear. Loud." diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index 5fd06eb5f91..cd1788bf2c6 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -309,17 +309,22 @@ * sidestep (never point-blank, never while browned out) plus a small * always-on gait boost. The dodge itself lives in the shared arbiter above; * this organ just contributes a source whose chance drops to zero the moment - * the ware is EMP-scrambled or browned out. + * the ware is EMP-scrambled or browned out. The chance and the gait boost are + * vars, so the street and service models (ware_models_b.dm) retune them. */ /obj/item/organ/cyberimp/cyberware/slipwire name = "\improper Slipwire reflex shunt" - desc = "A reflex arc spliced in parallel with the spinal trunk. While you're moving it sidesteps incoming fire on its own, roughly one shot in seven misses because your body moved before you told it to." + desc = "A reflex arc spliced in parallel with the spinal trunk. While you're moving it sidesteps some of the fire coming your way, because your body moved before you told it to." icon_state = "slipwire" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_NERVOUS w_class = WEIGHT_CLASS_SMALL chrome_load = 4 tier = CYBERWARE_TIER_2 + /// Percent of hostile projectiles dodged while the bearer is moving. + var/dodge_chance = CYBERWARE_SLIPWIRE_DODGE_CHANCE + /// The gait boost the ware adds while it runs. Null for none. + var/datum/movespeed_modifier/gait_modifier = /datum/movespeed_modifier/cyberware_slipwire /obj/item/organ/cyberimp/cyberware/slipwire/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -335,17 +340,19 @@ // add/remove is idempotent and needs no applied-state guard. /obj/item/organ/cyberimp/cyberware/slipwire/chrome_passives_on(mob/living/carbon/bearer) . = ..() - bearer?.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_slipwire) + if(gait_modifier) + bearer?.add_movespeed_modifier(gait_modifier) /obj/item/organ/cyberimp/cyberware/slipwire/chrome_passives_off(mob/living/carbon/bearer) . = ..() - bearer?.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_slipwire) + if(gait_modifier) + bearer?.remove_movespeed_modifier(gait_modifier) /// Dodge-source callback: dead weight while failing, 15% otherwise. /obj/item/organ/cyberimp/cyberware/slipwire/proc/get_dodge_chance() if(organ_flags & ORGAN_FAILING) return 0 - return CYBERWARE_SLIPWIRE_DODGE_CHANCE + return dodge_chance /datum/movespeed_modifier/cyberware_slipwire multiplicative_slowdown = -0.1 diff --git a/voidcrew/modules/cyberware/ware_pro_utility.dm b/voidcrew/modules/cyberware/ware_pro_utility.dm index 1c560463f0e..41a25cdb1bb 100644 --- a/voidcrew/modules/cyberware/ware_pro_utility.dm +++ b/voidcrew/modules/cyberware/ware_pro_utility.dm @@ -556,6 +556,9 @@ chrome_load = 3 tier = CYBERWARE_TIER_2 organ_traits = list(TRAIT_RESISTHEAT) + /// Multiplier on the bearer's burn damage while running. 1 leaves burns alone + /// and applies no physiology mod at all. + var/burn_mult = 0.5 /// TRUE while the burn-halving physiology mod is applied. Guards the /// failing-gated passive hooks against double multiply/divide. var/coolant_mod_applied = FALSE @@ -567,11 +570,11 @@ // Physiology persists across species changes (physiology.dm:1). /obj/item/organ/cyberimp/cyberware/coolant/chrome_passives_on(mob/living/carbon/bearer) . = ..() - if(coolant_mod_applied || !ishuman(bearer)) + if(coolant_mod_applied || !ishuman(bearer) || burn_mult == 1) return coolant_mod_applied = TRUE var/mob/living/carbon/human/human_bearer = bearer - human_bearer.physiology.burn_mod *= 0.5 + human_bearer.physiology.burn_mod *= burn_mult /obj/item/organ/cyberimp/cyberware/coolant/chrome_passives_off(mob/living/carbon/bearer) . = ..() @@ -581,7 +584,7 @@ if(!ishuman(bearer) || QDELETED(bearer)) return var/mob/living/carbon/human/human_bearer = bearer - human_bearer.physiology.burn_mod /= 0.5 + human_bearer.physiology.burn_mod /= burn_mult /obj/item/organ/cyberimp/cyberware/coolant/on_life(seconds_per_tick, times_fired) . = ..() @@ -625,6 +628,12 @@ actions_types = list(/datum/action/item_action/organ_action/toggle) /// Whether the sonar is running. var/active = FALSE + /// How far the sonar reaches, in tiles. + var/sonar_range = CYBERWARE_DOPPLER_RANGE + /// How long a contact stays painted after its last step. + var/contact_fade = CYBERWARE_DOPPLER_FADE + /// Added to the ping's volume, on top of the distance falloff. + var/ping_volume_bonus = 0 /// Living things in range we hold movement and death hooks on. Assoc for /// the O(1) membership test; the values are dummies. var/list/watched = list() @@ -694,7 +703,7 @@ return next_sweep = world.time + CYBERWARE_DOPPLER_SWEEP var/list/in_range = list() - for(var/mob/living/contact in range(CYBERWARE_DOPPLER_RANGE, owner)) + for(var/mob/living/contact in range(sonar_range, owner)) if(contact == owner || contact.stat == DEAD) continue in_range[contact] = TRUE @@ -742,14 +751,14 @@ /obj/item/organ/cyberimp/cyberware/doppler/proc/paint_contact(mob/living/contact) if(!active || (organ_flags & ORGAN_FAILING) || QDELETED(owner) || QDELETED(contact)) return - if(contact.stat == DEAD || get_dist(owner, contact) > CYBERWARE_DOPPLER_RANGE || contact.z != owner.z) + if(contact.stat == DEAD || get_dist(owner, contact) > sonar_range || contact.z != owner.z) unwatch_contact(contact) return var/datum/cyberware_hud_mark/blip = contacts[contact] if(blip) - blip.refresh(CYBERWARE_DOPPLER_FADE) + blip.refresh(contact_fade) return - cyberware_hud_mark(owner, contact, "#9fd8ff", CYBERWARE_DOPPLER_FADE, "sonar_ping_small", contacts, contact) + cyberware_hud_mark(owner, contact, "#9fd8ff", contact_fade, "sonar_ping_small", contacts, contact) /// Signal proc for [COMSIG_MOVABLE_MOVED] on the bearer: the set of things /// close enough to hook changed. The blips re-aim themselves. @@ -792,7 +801,7 @@ if(nearest_mover == INFINITY) return // Louder as they close; audible around you either way. That's the deal. - var/ping_volume = clamp(45 - nearest_mover * 3, 15, 45) + var/ping_volume = clamp(45 - nearest_mover * 3, 15, 45) + ping_volume_bonus playsound(owner, 'sound/machines/sonar-ping.ogg', ping_volume, TRUE) if(nearest_mover <= 3) // close contact: the beep doubles up addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(playsound), owner, 'sound/machines/sonar-ping.ogg', ping_volume, TRUE), 0.4 SECONDS) diff --git a/voidcrew/modules/cyberware/ware_second_wind.dm b/voidcrew/modules/cyberware/ware_second_wind.dm index 6330c5ecf31..2cb0e5055a2 100644 --- a/voidcrew/modules/cyberware/ware_second_wind.dm +++ b/voidcrew/modules/cyberware/ware_second_wind.dm @@ -48,8 +48,14 @@ w_class = WEIGHT_CLASS_SMALL chrome_load = 1 tier = CYBERWARE_TIER_1 - /// Breathable reserve remaining, in deciseconds of breathing covered. + /// Breathable reserve remaining, in deciseconds of breathing covered. A + /// model with a bigger bladder sets this to its max_reserve too, a var + /// initialiser can't read another var. var/reserve = CYBERWARE_SECOND_WIND_RESERVE + /// The most reserve the bladder holds. + var/max_reserve = CYBERWARE_SECOND_WIND_RESERVE + /// Reserve regained per normal breath in breathable air. + var/refill_rate = CYBERWARE_SECOND_WIND_REFILL /// TRUE while feeding the owner from reserve, for the engage/disengage beats. var/engaged = FALSE /// Whether the low-reserve warning has already fired this engagement, so @@ -65,7 +71,7 @@ /obj/item/organ/cyberimp/cyberware/second_wind/examine(mob/user) . = ..() var/seconds_left = round(reserve / (1 SECONDS)) - . += span_notice("The reserve gauge reads [seconds_left] seconds of air[reserve < CYBERWARE_SECOND_WIND_RESERVE ? ", refilling while its bearer breathes normally" : " (full)"].") + . += span_notice("The reserve gauge reads [seconds_left] seconds of air[reserve < max_reserve ? ", refilling while its bearer breathes normally" : " (full)"].") /obj/item/organ/cyberimp/cyberware/second_wind/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -116,8 +122,8 @@ if(environment_is_breathable(source)) if(engaged) set_engaged(source, FALSE) - if(reserve < CYBERWARE_SECOND_WIND_RESERVE) - reserve = min(reserve + CYBERWARE_SECOND_WIND_REFILL, CYBERWARE_SECOND_WIND_RESERVE) + if(reserve < max_reserve) + reserve = min(reserve + refill_rate, max_reserve) low_warned = FALSE return NONE if(reserve <= 0) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 0fb6632e857..0c372c82ec0 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -73,6 +73,7 @@ #include "voidcrew_cyberware.dm" #include "voidcrew_cyberware_camo.dm" #include "voidcrew_cyberware_mantis.dm" +#include "voidcrew_cyberware_models_b.dm" #include "voidcrew_derelict_helpers.dm" #include "voidcrew_derelict_layout.dm" #include "voidcrew_derelict_leash.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm new file mode 100644 index 00000000000..038ab1c21c9 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm @@ -0,0 +1,394 @@ +/** + * # Cyberware models, set B + * + * Hardweave plating, the Strut frame, the Deep-lung bladder, the Heat-sink and + * Frostline linings, the Twitchwire and Weaver shunt, and the Thumper ear + * (voidcrew/modules/cyberware/ware_models_b.dm). Each model has to share its + * parent's slot, keep its own numbers, and leave the parent's numbers exactly + * as they were. + * + * Unit-test files compile before voidcrew/_DEFINES/, so the fork's defines are + * written as literals below: CYBERWARE_TIER_1/2/3 are 1/2/3, and the + * unbreakable-bones trait is "cyberware_unbreakable_bones". + */ + +/// Every model sits in its parent's slot with the planned load and tier, a +/// state of its own, and a description without a number in it. +/datum/unit_test/voidcrew_cyberware_models_b_slots + +/datum/unit_test/voidcrew_cyberware_models_b_slots/Run() + // model, parent, load, tier + var/list/models = list( + list(/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave, /obj/item/organ/cyberimp/cyberware/dermal_mesh, 3, 2), + list(/obj/item/organ/cyberimp/cyberware/atlas/street, /obj/item/organ/cyberimp/cyberware/atlas, 2, 2), + list(/obj/item/organ/cyberimp/cyberware/second_wind/deep, /obj/item/organ/cyberimp/cyberware/second_wind, 2, 2), + list(/obj/item/organ/cyberimp/cyberware/coolant/street, /obj/item/organ/cyberimp/cyberware/coolant, 1, 1), + list(/obj/item/organ/cyberimp/cyberware/frostline, /obj/item/organ/cyberimp/cyberware/coolant, 1, 1), + list(/obj/item/organ/cyberimp/cyberware/slipwire/street, /obj/item/organ/cyberimp/cyberware/slipwire, 2, 1), + list(/obj/item/organ/cyberimp/cyberware/slipwire/milspec, /obj/item/organ/cyberimp/cyberware/slipwire, 5, 3), + list(/obj/item/organ/cyberimp/cyberware/doppler/street, /obj/item/organ/cyberimp/cyberware/doppler, 1, 1), + ) + for(var/list/row as anything in models) + var/obj/item/organ/cyberimp/cyberware/ware = allocate(row[1]) + var/obj/item/organ/cyberimp/cyberware/parent = allocate(row[2]) + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + TEST_ASSERT_NOTNULL(chrome, "[ware.type] has no cyberware component") + TEST_ASSERT_EQUAL(ware.slot, parent.slot, "[ware.type] is not in [parent.type]'s slot, so the two would stack") + TEST_ASSERT_EQUAL(ware.zone, parent.zone, "[ware.type] is in a different body zone from [parent.type]") + TEST_ASSERT_EQUAL(chrome.chrome_load, row[3], "[ware.type] has the wrong chrome load") + TEST_ASSERT_EQUAL(chrome.tier, row[4], "[ware.type] has the wrong tier") + TEST_ASSERT(!findtext(ware.desc, regex("\[0-9]")), "[ware.type]'s description has a number in it") + TEST_ASSERT(!findtext(ware.desc, "tier") && !findtext(ware.desc, "load"), "[ware.type]'s description talks about game rules") + if(ware.type != /obj/item/organ/cyberimp/cyberware/frostline) + TEST_ASSERT(ware.icon_state != parent.icon_state, "[ware.type] reuses its parent's item state") + // Where the parent has a worn look, the model has its own. + if(parent.aug_overlay) + TEST_ASSERT(ware.aug_overlay && ware.aug_overlay != parent.aug_overlay, "[ware.type] wears its parent's overlay") + + // The seal slot is one slot with five choices: whichever went in last is + // the only one left. + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + var/list/seal_ware = list( + /obj/item/organ/cyberimp/cyberware/second_wind, + /obj/item/organ/cyberimp/cyberware/second_wind/deep, + /obj/item/organ/cyberimp/cyberware/coolant/street, + /obj/item/organ/cyberimp/cyberware/frostline, + /obj/item/organ/cyberimp/cyberware/coolant, + ) + var/list/placed = list() + for(var/seal_path in seal_ware) + var/obj/item/organ/cyberimp/cyberware/seal = allocate(seal_path) + TEST_ASSERT(seal.Insert(patient, special = TRUE), "[seal_path] staging insert was refused") + TEST_ASSERT_EQUAL(patient.get_organ_slot(seal.slot), seal, "[seal_path] did not take the seal slot") + for(var/obj/item/organ/earlier as anything in placed) + TEST_ASSERT(earlier.owner != patient, "[earlier.type] stayed in the seal slot beside [seal_path]") + placed += seal + +/// The parents run on the numbers they had before the vars existed. +/datum/unit_test/voidcrew_cyberware_models_b_parents + +/datum/unit_test/voidcrew_cyberware_models_b_parents/Run() + var/obj/item/organ/cyberimp/cyberware/second_wind/bladder = allocate(/obj/item/organ/cyberimp/cyberware/second_wind) + TEST_ASSERT_EQUAL(bladder.reserve, 180 SECONDS, "Second Wind's reserve changed") + TEST_ASSERT_EQUAL(bladder.max_reserve, 180 SECONDS, "Second Wind's cap changed") + TEST_ASSERT_EQUAL(bladder.refill_rate, 16 SECONDS, "Second Wind's refill changed") + + var/obj/item/organ/cyberimp/cyberware/coolant/loops = allocate(/obj/item/organ/cyberimp/cyberware/coolant) + TEST_ASSERT_EQUAL(loops.burn_mult, 0.5, "Coolant Loops stopped halving burns") + + var/obj/item/organ/cyberimp/cyberware/slipwire/slipwire = allocate(/obj/item/organ/cyberimp/cyberware/slipwire) + TEST_ASSERT_EQUAL(slipwire.dodge_chance, 15, "Slipwire's dodge changed") + TEST_ASSERT_EQUAL(slipwire.gait_modifier, /datum/movespeed_modifier/cyberware_slipwire, "Slipwire's gait changed") + TEST_ASSERT(!findtext(slipwire.desc, "seven") && !findtext(slipwire.desc, regex("\[0-9]")), "Slipwire's description still quotes its odds") + + var/obj/item/organ/cyberimp/cyberware/doppler/doppler = allocate(/obj/item/organ/cyberimp/cyberware/doppler) + TEST_ASSERT_EQUAL(doppler.sonar_range, 9, "Heartbeat Doppler's range changed") + TEST_ASSERT_EQUAL(doppler.contact_fade, 4 SECONDS, "Heartbeat Doppler's fade changed") + TEST_ASSERT_EQUAL(doppler.ping_volume_bonus, 0, "Heartbeat Doppler's ping got louder") + + var/obj/item/organ/cyberimp/cyberware/atlas/atlas = allocate(/obj/item/organ/cyberimp/cyberware/atlas) + TEST_ASSERT_EQUAL(atlas.throw_range_bonus, 2, "Atlas Frame's throw range changed") + TEST_ASSERT_EQUAL(atlas.throw_speed_bonus, 1, "Atlas Frame's throw speed changed") + TEST_ASSERT(atlas.resists_knockback, "Atlas Frame stopped resisting knockback") + TEST_ASSERT(atlas.frame_examine, "Atlas Frame lost its examine line") + TEST_ASSERT(TRAIT_NODISMEMBER in atlas.organ_traits, "Atlas Frame stopped holding limbs on") + TEST_ASSERT("cyberware_unbreakable_bones" in atlas.organ_traits, "Atlas Frame stopped holding bones together") + + var/obj/item/organ/cyberimp/cyberware/dermal_mesh/mesh = allocate(/obj/item/organ/cyberimp/cyberware/dermal_mesh) + TEST_ASSERT_EQUAL(mesh.mesh_armor, /datum/armor/cyberware_dermal_mesh, "Dermal Mesh's plating changed") + +/// Hardweave sits between the Mesh and the Slabskin Plate, and swapping it for +/// the Mesh hands back exactly the difference. +/datum/unit_test/voidcrew_cyberware_models_b_hardweave + +/datum/unit_test/voidcrew_cyberware_models_b_hardweave/Run() + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave/hardweave = allocate(/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave) + var/obj/item/organ/cyberimp/cyberware/dermal_mesh/mesh = allocate(/obj/item/organ/cyberimp/cyberware/dermal_mesh) + // melee, bullet, laser, energy, wound + var/list/ratings = list(MELEE, BULLET, LASER, ENERGY, WOUND) + var/list/bare = list() + for(var/rating in ratings) + bare[rating] = patient.physiology.armor.get_rating(rating) + + TEST_ASSERT(hardweave.Insert(patient, special = TRUE), "Hardweave staging insert was refused") + var/list/hardweave_plus = list(20, 20, 15, 10, 10) + for(var/index in 1 to length(ratings)) + var/rating = ratings[index] + TEST_ASSERT_EQUAL(patient.physiology.armor.get_rating(rating), bare[rating] + hardweave_plus[index], "Hardweave gave the wrong [rating] armour") + + TEST_ASSERT(mesh.Insert(patient, special = TRUE), "Dermal Mesh staging insert was refused") + TEST_ASSERT(!hardweave.owner, "Hardweave stayed in the dermal slot beside the Mesh") + var/list/mesh_plus = list(15, 15, 10, 0, 5) + for(var/index in 1 to length(ratings)) + var/rating = ratings[index] + TEST_ASSERT_EQUAL(patient.physiology.armor.get_rating(rating), bare[rating] + mesh_plus[index], "Swapping Hardweave for the Mesh left the wrong [rating] armour") + + TEST_ASSERT(hardweave.Insert(patient, special = TRUE), "Hardweave second staging insert was refused") + patient.emp_act(EMP_HEAVY) + TEST_ASSERT(hardweave.organ_flags & ORGAN_FAILING, "An EMP never reached Hardweave") + for(var/rating in ratings) + TEST_ASSERT_EQUAL(patient.physiology.armor.get_rating(rating), bare[rating], "EMP-scrambled Hardweave kept armouring its bearer") + var/datum/component/cyberware/chrome = hardweave.GetComponent(/datum/component/cyberware) + chrome.tune_up() + TEST_ASSERT_EQUAL(patient.physiology.armor.get_rating(MELEE), bare[MELEE] + 20, "A tune-up did not hand Hardweave's plating back") + hardweave.Remove(patient, special = TRUE) + TEST_ASSERT_EQUAL(patient.physiology.armor.get_rating(MELEE), bare[MELEE], "Pulling Hardweave left plating on the body") + +/// The Strut frame holds bones and nothing else; the Atlas it undercuts still +/// holds limbs on and still throws and shrugs like a frame. +/datum/unit_test/voidcrew_cyberware_models_b_strut + +/datum/unit_test/voidcrew_cyberware_models_b_strut/Run() + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/atlas/street/strut = allocate(/obj/item/organ/cyberimp/cyberware/atlas/street) + var/obj/item/organ/cyberimp/cyberware/atlas/atlas = allocate(/obj/item/organ/cyberimp/cyberware/atlas) + + TEST_ASSERT(strut.Insert(patient, special = TRUE), "Strut frame staging insert was refused") + TEST_ASSERT(HAS_TRAIT(patient, "cyberware_unbreakable_bones"), "The Strut frame did not keep the bones together") + TEST_ASSERT(!HAS_TRAIT(patient, TRAIT_NODISMEMBER), "The Strut frame keeps limbs on") + TEST_ASSERT_NULL(strut._signal_procs?[patient]?[COMSIG_MOB_THROW], "The Strut frame is boosting throws") + TEST_ASSERT_NULL(strut._signal_procs?[patient]?[COMSIG_MOVABLE_PRE_THROW], "The Strut frame is resisting knockback") + var/examined = jointext(strut.examine(patient), " ") + TEST_ASSERT(!findtext(examined, "no losing a limb"), "The Strut frame examine still promises limbs stay on") + + // The Atlas replaces it and gets everything back. + TEST_ASSERT(atlas.Insert(patient, special = TRUE), "Atlas staging insert was refused") + TEST_ASSERT(!strut.owner, "The Strut frame stayed in the frame slot beside the Atlas") + TEST_ASSERT(HAS_TRAIT(patient, "cyberware_unbreakable_bones"), "The Atlas lost its bones") + TEST_ASSERT(HAS_TRAIT(patient, TRAIT_NODISMEMBER), "The Atlas lost its limbs") + TEST_ASSERT_NOTNULL(atlas._signal_procs?[patient]?[COMSIG_MOB_THROW], "The Atlas stopped boosting throws") + TEST_ASSERT_NOTNULL(atlas._signal_procs?[patient]?[COMSIG_MOVABLE_PRE_THROW], "The Atlas stopped resisting knockback") + + // And the Atlas still does the arithmetic it did. + var/list/thrown = list(null, 8, 1, patient) + atlas.boost_throw(null, thrown) + TEST_ASSERT_EQUAL(thrown[2], 10, "The Atlas added the wrong range to a throw") + TEST_ASSERT_EQUAL(thrown[3], 2, "The Atlas added the wrong speed to a throw") + var/list/shoved = list(null, 8, 1, null) + atlas.on_owner_thrown(patient, shoved) + TEST_ASSERT_EQUAL(shoved[2], 4, "The Atlas stopped halving knockback") + + atlas.Remove(patient, special = TRUE) + TEST_ASSERT(!HAS_TRAIT(patient, "cyberware_unbreakable_bones") && !HAS_TRAIT(patient, TRAIT_NODISMEMBER), "Pulling the Atlas left traits on the body") + +/// Deep-lung, Heat-sink and Frostline. +/datum/unit_test/voidcrew_cyberware_models_b_seal + +/datum/unit_test/voidcrew_cyberware_models_b_seal/Run() + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + + // Deep-lung: a bigger bladder that refills faster, otherwise a Second Wind. + var/obj/item/organ/cyberimp/cyberware/second_wind/deep/deep = allocate(/obj/item/organ/cyberimp/cyberware/second_wind/deep) + TEST_ASSERT_EQUAL(deep.max_reserve, 360 SECONDS, "The Deep-lung bladder holds the wrong amount") + TEST_ASSERT_EQUAL(deep.reserve, deep.max_reserve, "The Deep-lung bladder starts partly empty") + TEST_ASSERT_EQUAL(deep.refill_rate, 24 SECONDS, "The Deep-lung bladder refills at the wrong rate") + TEST_ASSERT(deep.Insert(patient, special = TRUE), "Deep-lung staging insert was refused") + deep.reserve = 0 + patient.forceMove(run_loc_floor_bottom_left) + patient.breathe(2, 1) + TEST_ASSERT_EQUAL(deep.reserve, 24 SECONDS, "A breath of good air refilled the wrong amount of Deep-lung reserve") + deep.reserve = deep.max_reserve - 10 + patient.breathe(2, 1) + TEST_ASSERT_EQUAL(deep.reserve, deep.max_reserve, "The Deep-lung bladder refilled past its cap") + deep.Remove(patient, special = TRUE) + + // Heat-sink: the heat half of the loops, no burn halving. + var/obj/item/organ/cyberimp/cyberware/coolant/street/sink = allocate(/obj/item/organ/cyberimp/cyberware/coolant/street) + var/obj/item/organ/cyberimp/cyberware/coolant/loops = allocate(/obj/item/organ/cyberimp/cyberware/coolant) + var/bare_burn = patient.physiology.burn_mod + TEST_ASSERT(sink.Insert(patient, special = TRUE), "Heat-sink staging insert was refused") + TEST_ASSERT(HAS_TRAIT(patient, TRAIT_RESISTHEAT), "The Heat-sink lining does not turn heat away") + TEST_ASSERT_EQUAL(patient.physiology.burn_mod, bare_burn, "The Heat-sink lining halves burns") + TEST_ASSERT(!sink.coolant_mod_applied, "The Heat-sink lining thinks it applied a burn mod") + patient.adjust_fire_stacks(5) + var/lit_stacks = patient.fire_stacks + TEST_ASSERT(lit_stacks > 0, "Test fixture: the patient took no fire stacks") + sink.on_life(1, 1) + TEST_ASSERT(patient.fire_stacks < lit_stacks, "The Heat-sink lining does not scrub fire") + patient.set_fire_stacks(0) + + TEST_ASSERT(loops.Insert(patient, special = TRUE), "Coolant Loops staging insert was refused") + TEST_ASSERT(!sink.owner, "The Heat-sink lining stayed in the seal slot beside the loops") + TEST_ASSERT_EQUAL(patient.physiology.burn_mod, bare_burn * 0.5, "Coolant Loops stopped halving burns") + TEST_ASSERT(sink.Insert(patient, special = TRUE), "Heat-sink second staging insert was refused") + TEST_ASSERT_EQUAL(patient.physiology.burn_mod, bare_burn, "Swapping the loops for the Heat-sink left a burn mod on the body") + sink.Remove(patient, special = TRUE) + TEST_ASSERT(!HAS_TRAIT(patient, TRAIT_RESISTHEAT), "Pulling the Heat-sink left heat immunity on the body") + + // Frostline: the cold stops hurting and nothing else changes. + var/obj/item/organ/cyberimp/cyberware/frostline/frost = allocate(/obj/item/organ/cyberimp/cyberware/frostline) + TEST_ASSERT(frost.Insert(patient, special = TRUE), "Frostline staging insert was refused") + TEST_ASSERT(HAS_TRAIT(patient, TRAIT_RESISTCOLD), "Frostline does not turn the cold away") + TEST_ASSERT(!HAS_TRAIT(patient, TRAIT_RESISTHEAT), "Frostline turns the heat away too") + TEST_ASSERT(!HAS_TRAIT(patient, TRAIT_NOBREATH) && !HAS_TRAIT(patient, TRAIT_RESISTLOWPRESSURE), "Frostline does more than the cold") + TEST_ASSERT_EQUAL(patient.physiology.burn_mod, bare_burn, "Frostline changed how burns land") + patient.emp_act(EMP_HEAVY) + TEST_ASSERT(frost.organ_flags & ORGAN_FAILING, "An EMP never reached the Frostline lining") + TEST_ASSERT(!HAS_TRAIT(patient, TRAIT_RESISTCOLD), "An EMP-scrambled Frostline lining kept the cold off") + var/datum/component/cyberware/chrome = frost.GetComponent(/datum/component/cyberware) + chrome.tune_up() + TEST_ASSERT(HAS_TRAIT(patient, TRAIT_RESISTCOLD), "A tune-up did not bring the Frostline lining back") + frost.Remove(patient, special = TRUE) + TEST_ASSERT(!HAS_TRAIT(patient, TRAIT_RESISTCOLD), "Pulling the Frostline lining left cold resistance on the body") + +/// A fixed dodge for the arbiter tests. +/datum/unit_test/voidcrew_cyberware_models_b_slipwire + /// Stand-in for a Cascade window's source. + var/test_source_chance = 40 + +/datum/unit_test/voidcrew_cyberware_models_b_slipwire/proc/fixed_chance() + return test_source_chance + +/// Twitchwire and the Weaver shunt dodge through the shared arbiter and bring +/// their own gait; the Slipwire between them keeps its own. +/datum/unit_test/voidcrew_cyberware_models_b_slipwire/Run() + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/slipwire/slipwire = allocate(/obj/item/organ/cyberimp/cyberware/slipwire) + var/obj/item/organ/cyberimp/cyberware/slipwire/street/twitch = allocate(/obj/item/organ/cyberimp/cyberware/slipwire/street) + var/obj/item/organ/cyberimp/cyberware/slipwire/milspec/weaver = allocate(/obj/item/organ/cyberimp/cyberware/slipwire/milspec) + + // ware, dodge chance, gait modifier (null for none) + var/list/rungs = list( + list(twitch, 8, null), + list(slipwire, 15, /datum/movespeed_modifier/cyberware_slipwire), + list(weaver, 20, /datum/movespeed_modifier/cyberware_slipwire/weaver), + ) + var/list/every_gait = list(/datum/movespeed_modifier/cyberware_slipwire, /datum/movespeed_modifier/cyberware_slipwire/weaver) + for(var/list/rung as anything in rungs) + var/obj/item/organ/cyberimp/cyberware/slipwire/ware = rung[1] + TEST_ASSERT(ware.Insert(patient, special = TRUE), "[ware.type] staging insert was refused") + var/datum/component/cyberware_dodge/arbiter = patient.GetComponent(/datum/component/cyberware_dodge) + TEST_ASSERT_NOTNULL(arbiter, "[ware.type] never reached the shared dodge arbiter") + TEST_ASSERT_EQUAL(length(arbiter.dodge_sources), 1, "[ware.type] left [length(arbiter.dodge_sources)] dodge sources, the ladder stacked") + var/datum/callback/source = arbiter.dodge_sources[REF(ware)] + TEST_ASSERT_NOTNULL(source, "[ware.type] registered no dodge source of its own") + TEST_ASSERT_EQUAL(source.Invoke(), rung[2], "[ware.type] dodges at the wrong chance") + for(var/gait in every_gait) + TEST_ASSERT_EQUAL(!!patient.has_movespeed_modifier(gait), (gait == rung[3]) ? 1 : 0, "[ware.type] has the wrong gait") + + // A scrambled ware dodges nothing and runs no faster. + patient.emp_act(EMP_HEAVY) + var/datum/component/cyberware_dodge/scrambled = patient.GetComponent(/datum/component/cyberware_dodge) + var/datum/callback/scrambled_source = scrambled.dodge_sources[REF(weaver)] + TEST_ASSERT_EQUAL(scrambled_source.Invoke(), 0, "An EMP-scrambled Weaver shunt still dodges") + TEST_ASSERT(!patient.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_slipwire/weaver), "An EMP-scrambled Weaver shunt still runs fast") + var/datum/component/cyberware/chrome = weaver.GetComponent(/datum/component/cyberware) + chrome.tune_up() + TEST_ASSERT(patient.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_slipwire/weaver), "A tune-up did not give the Weaver shunt its gait back") + + // Another source (a Cascade window is one) shares the arbiter by its own + // key. The arbiter rolls the highest of them, so both stay registered + // and neither is summed into the other. + var/datum/component/cyberware_dodge/shared = patient.GetComponent(/datum/component/cyberware_dodge) + cyberware_register_dodge_source(patient, "voidcrew_test_window", CALLBACK(src, PROC_REF(fixed_chance))) + TEST_ASSERT_EQUAL(length(shared.dodge_sources), 2, "A second dodge source did not share the arbiter") + var/best = 0 + for(var/key in shared.dodge_sources) + var/datum/callback/chance_callback = shared.dodge_sources[key] + best = max(best, chance_callback.Invoke()) + TEST_ASSERT_EQUAL(best, 40, "The Weaver shunt raised a Cascade window's dodge") + cyberware_unregister_dodge_source(patient, "voidcrew_test_window") + + weaver.Remove(patient, special = TRUE) + TEST_ASSERT_NULL(patient.GetComponent(/datum/component/cyberware_dodge), "Pulling the last dodge ware left the arbiter behind") + TEST_ASSERT(!patient.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_slipwire/weaver), "Pulling the Weaver shunt left its gait on the body") + +/// The Thumper ear: shorter reach, shorter fade, louder ping; the Heartbeat +/// Doppler it undercuts keeps its own. +/datum/unit_test/voidcrew_cyberware_models_b_doppler + +/datum/unit_test/voidcrew_cyberware_models_b_doppler/Run() + var/obj/item/organ/cyberimp/cyberware/doppler/street/thumper = allocate(/obj/item/organ/cyberimp/cyberware/doppler/street) + TEST_ASSERT_EQUAL(thumper.sonar_range, 5, "The Thumper ear reaches the wrong distance") + TEST_ASSERT_EQUAL(thumper.contact_fade, 3 SECONDS, "The Thumper ear fades contacts at the wrong rate") + TEST_ASSERT_EQUAL(thumper.ping_volume_bonus, 10, "The Thumper ear is not louder") + + // The range var is what the sweep reads: a contact across the test room is + // in reach, and not once the reach is cut below the distance. + var/mob/living/carbon/human/listener = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/mob/living/carbon/human/contact = allocate(/mob/living/carbon/human/consistent, run_loc_floor_top_right) + var/distance = get_dist(listener, contact) + TEST_ASSERT(distance >= 2 && distance <= 5, "Test fixture: the room is [distance] tiles across, not between 2 and 5") + TEST_ASSERT(thumper.Insert(listener, special = TRUE), "Thumper staging insert was refused") + thumper.start_sonar() + TEST_ASSERT(thumper.watched[contact], "The Thumper ear did not hear a contact [distance] tiles away") + thumper.stop_sonar(listener) + thumper.sonar_range = distance - 1 + thumper.start_sonar() + TEST_ASSERT(!thumper.watched[contact], "The Thumper ear heard a contact beyond its reach") + thumper.stop_sonar(listener) + TEST_ASSERT(!length(thumper.watched) && !length(thumper.contacts), "Stopping the Thumper ear left contacts behind") + thumper.Remove(listener, special = TRUE) + + // The parent still reaches the full nine. + var/obj/item/organ/cyberimp/cyberware/doppler/doppler = allocate(/obj/item/organ/cyberimp/cyberware/doppler) + TEST_ASSERT(doppler.Insert(listener, special = TRUE), "Doppler staging insert was refused") + TEST_ASSERT(!thumper.owner, "The Thumper ear stayed in the ear slot beside the Doppler") + doppler.start_sonar() + TEST_ASSERT(doppler.watched[contact], "The Heartbeat Doppler lost a contact it used to hear") + doppler.stop_sonar(listener) + +/// What the shelf and the trade-in say: the cradle quotes a price, the +/// planned price and permission, and a trade-in that pays half the shelf price. +/datum/unit_test/voidcrew_cyberware_models_b_shop + +/datum/unit_test/voidcrew_cyberware_models_b_shop/Run() + build_cyberware_price_index() + // sku, item, credits, vouchers, permission reference + var/list/skus = list( + list(/datum/shop_sku/ripperdoc/hardweave, /obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave, 3000, 0, /datum/shop_sku/ripperdoc/dermal_mesh), + list(/datum/shop_sku/ripperdoc/atlas_street, /obj/item/organ/cyberimp/cyberware/atlas/street, 1500, 0, /datum/shop_sku/ripperdoc/dermal_mesh), + list(/datum/shop_sku/ripperdoc/second_wind_deep, /obj/item/organ/cyberimp/cyberware/second_wind/deep, 1800, 0, /datum/shop_sku/ripperdoc/shock_coils), + list(/datum/shop_sku/ripperdoc/coolant_street, /obj/item/organ/cyberimp/cyberware/coolant/street, 1500, 0, /datum/shop_sku/ripperdoc/shock_coils), + list(/datum/shop_sku/ripperdoc/frostline, /obj/item/organ/cyberimp/cyberware/frostline, 1500, 0, /datum/shop_sku/ripperdoc/shock_coils), + list(/datum/shop_sku/ripperdoc/slipwire_street, /obj/item/organ/cyberimp/cyberware/slipwire/street, 1800, 0, /datum/shop_sku/ripperdoc/dermal_mesh), + list(/datum/shop_sku/ripperdoc/slipwire_milspec, /obj/item/organ/cyberimp/cyberware/slipwire/milspec, 0, 3, /datum/shop_sku/ripperdoc/coolant), + list(/datum/shop_sku/ripperdoc/doppler_street, /obj/item/organ/cyberimp/cyberware/doppler/street, 1500, 0, /datum/shop_sku/ripperdoc/dermal_mesh), + ) + for(var/list/row as anything in skus) + var/datum/shop_sku/sku_type = row[1] + var/reference_type = row[5] + var/datum/shop_sku/reference = reference_type + TEST_ASSERT_EQUAL(initial(sku_type.item_path), row[2], "[sku_type] sells the wrong piece") + TEST_ASSERT_EQUAL(initial(sku_type.price_credits), row[3], "[sku_type] has the wrong credit price") + TEST_ASSERT_EQUAL(initial(sku_type.price_vouchers), row[4], "[sku_type] has the wrong voucher price") + TEST_ASSERT_EQUAL(initial(sku_type.zone_permission), initial(reference.zone_permission), "[sku_type] sits behind the wrong reputation") + var/list/quote = GLOB.cyberware_price_index[row[2]] + TEST_ASSERT_NOTNULL(quote, "The cradle has no price for [row[2]]") + if(quote) + TEST_ASSERT_EQUAL(quote["credits"], row[3], "The cradle quotes [row[2]] differently from its shelf") + TEST_ASSERT_EQUAL(quote["vouchers"], row[4], "The cradle quotes [row[2]] differently from its shelf") + + // The trade-in: one entry per credit-priced model, exact type, half list. + // Splice's entry and Ratchet's share the item and the pay. + var/list/ledgers = list( + list(/datum/shop_buyback/ripperdoc/hardweave, /datum/shop_sku/ripperdoc/hardweave), + list(/datum/shop_buyback/ripperdoc/atlas_street, /datum/shop_sku/ripperdoc/atlas_street), + list(/datum/shop_buyback/ripperdoc/second_wind_deep, /datum/shop_sku/ripperdoc/second_wind_deep), + list(/datum/shop_buyback/ripperdoc/coolant_street, /datum/shop_sku/ripperdoc/coolant_street), + list(/datum/shop_buyback/ripperdoc/frostline, /datum/shop_sku/ripperdoc/frostline), + list(/datum/shop_buyback/ripperdoc/slipwire_street, /datum/shop_sku/ripperdoc/slipwire_street), + list(/datum/shop_buyback/ripperdoc/doppler_street, /datum/shop_sku/ripperdoc/doppler_street), + ) + for(var/list/row as anything in ledgers) + var/ledger_type = row[1] + var/datum/shop_sku/sku_type = row[2] + var/datum/shop_buyback/ledger = allocate(ledger_type) + var/datum/shop_buyback/ratchet = allocate(text2path("[ledger_type]/street_doc")) + TEST_ASSERT_EQUAL(ledger.item_path, initial(sku_type.item_path), "[ledger_type] buys the wrong piece") + TEST_ASSERT(!ledger.match_subtypes, "[ledger_type] buys back its piece's subtypes") + TEST_ASSERT_EQUAL(ledger.pay_credits * 2, initial(sku_type.price_credits), "[ledger_type] does not pay half the shelf price") + TEST_ASSERT(!ledger.pay_vouchers, "[ledger_type] pays vouchers") + TEST_ASSERT_EQUAL(ratchet.item_path, ledger.item_path, "The street doc's [ledger_type] buys a different piece") + TEST_ASSERT_EQUAL(ratchet.pay_credits, ledger.pay_credits, "The street doc's [ledger_type] pays a different amount") + TEST_ASSERT(ratchet.desc != ledger.desc, "The street doc's [ledger_type] has no wording of his own") + + // Vouchers cannot be refunded: the Weaver shunt has no entry anywhere. + for(var/buyback_type in typesof(/datum/shop_buyback/ripperdoc)) + var/datum/shop_buyback/ledger = buyback_type + var/path = initial(ledger.item_path) + if(!path) + continue + var/buys_weaver = initial(ledger.match_subtypes) ? ispath(/obj/item/organ/cyberimp/cyberware/slipwire/milspec, path) : (path == /obj/item/organ/cyberimp/cyberware/slipwire/milspec) + TEST_ASSERT(!buys_weaver, "[buyback_type] buys back the Weaver shunt") From 355846fa7598218b2adf48426b54e378c5bb4ad6 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:02:25 -0800 Subject: [PATCH 128/472] Add street passive chrome: Clotter node, Adrenal pump, Medic's palms, Cuffer's wrists, Subvocal mic, Muffler ear Six cheap button-free wares on the failing-gated passive layer, with their shop SKUs and trade-in entries (listed on no vendor yet), gear ratings and tests. The Subvocal mic hooks the human radio branch and send_speech with chained overrides. --- tgstation.dme | 1 + tools/gear_catalog/ratings/cyberware.json | 60 +++ .../modules/cyberware/ware_street_passives.dm | 455 ++++++++++++++++++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../modules/unit_tests/voidcrew_cyberware.dm | 3 +- .../unit_tests/voidcrew_cyberware_passives.dm | 317 ++++++++++++ 6 files changed, 836 insertions(+), 1 deletion(-) create mode 100644 voidcrew/modules/cyberware/ware_street_passives.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_passives.dm diff --git a/tgstation.dme b/tgstation.dme index 9aa91caac2a..8386f60c9d9 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7202,6 +7202,7 @@ #include "voidcrew\modules\cyberware\ware_registry.dm" #include "voidcrew\modules\cyberware\ware_second_wind.dm" #include "voidcrew\modules\cyberware\ware_street.dm" +#include "voidcrew\modules\cyberware\ware_street_passives.dm" #include "voidcrew\modules\derelict_outposts\derelict_layout.dm" #include "voidcrew\modules\derelict_outposts\derelict_leash.dm" #include "voidcrew\modules\derelict_outposts\derelict_outpost.dm" diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 06bddb86efc..c90293724b9 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -291,6 +291,16 @@ "reason": "A jetpack in your chest with no back slot used, and a speed bonus while floating. Free air when there's pressure, otherwise your tank. About 74 cr of material after 200 research points.", "tier": "green" }, + "/obj/item/organ/cyberimp/cyberware/adrenal_pump": { + "confidence": "medium", + "group": "Nerves and stuns", + "hash": "", + "name": "Adrenal pump", + "note": "When a stamina update leaves a conscious wearer at 70 or more stamina loss and the pump is ready, one tick later it takes 40 off, speeds the wearer up (movement -0.4) and tints them red for 3 s, and plays a loud heartbeat. It then waits 4 minutes; the wait is stored as a ready-at time on the organ, so a refit does not reset it. It ignores the stamina dumped by the Cascade and Redline crashes. It takes the spare heart slot, so it evicts the Clotter node, and goes off during an EMP reboot or brownout.", + "rank": 3, + "reason": "Once every 4 minutes a bad run of stamina damage is cut by 40 and the wearer gets 3 s of extra speed, enough to stay out of a stamina crit for one more volley. Load 2, 2,400 cr at reputation 4.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/atlas": { "confidence": "medium", "group": "Armor and frame", @@ -357,6 +367,16 @@ "reason": "Glowing tattoos with no other effect. Load 0, 400 cr.", "tier": "none" }, + "/obj/item/organ/cyberimp/cyberware/clotter_node": { + "confidence": "medium", + "group": "Body upkeep", + "hash": "", + "name": "Clotter node", + "note": "While online, multiplies the blood a human wearer loses by 0.5, on life ticks and flat bleeds alike. It stacks multiplicatively with other bleed reducers and goes off during an EMP reboot or over-capacity brownout. It takes the spare heart slot, so it evicts the Adrenal pump.", + "rank": 3, + "reason": "Halves the blood lost to wounds. It buys a bleeding crew member time between medic visits and does nothing against burst damage. Load 1, 900 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/cyberware/coolant": { "confidence": "medium", "group": "Armor and frame", @@ -377,6 +397,16 @@ "reason": "No heat damage from hot air or lava country, and fire burns off fast, but burns from lasers and flames land at full strength. The heat half of the Coolant Loops for load 1, 1,500 cr.", "tier": "green" }, + "/obj/item/organ/cyberimp/cyberware/cuffer_wrists": { + "confidence": "medium", + "group": "Work tools", + "hash": "", + "name": "Cuffer's wrists", + "note": "While online, handcuffing takes 75% of the usual time, the trait combat and insulated gloves also give. It takes the hands slot, so it evicts the Gecko Grip and the Medic's palms.", + "rank": 6, + "reason": "Cuffs go on in three quarters of the time with no glove needed. A small edge for bounty hunters and boarders. Load 1, 500 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/cyberware/dead_channel": { "confidence": "medium", "group": "Nerves and stuns", @@ -553,6 +583,26 @@ "reason": "Once every 6 minutes, dropping into crit stands you up at 30% health with stuns cleared after a 1 s seizure. A second life in the same fight. Load 5, 4 vouchers at reputation 22.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/medic_palms": { + "confidence": "medium", + "group": "Work tools", + "hash": "", + "name": "Medic's palms", + "note": "While online, gives the wearer the quicker chemistry trait (syringes, pills, patches and the like take less time) and the quicker fireman carry (3 s instead of 5), the same traits as nitrile gloves, without using the glove slot. It takes the hands slot, so it evicts the Gecko Grip and the Cuffer's wrists.", + "rank": 5, + "reason": "Nitrile-glove speed on chemistry and carrying without the glove slot, so a medic can wear insulated or armored gloves instead. Load 1, 600 cr.", + "tier": "green" + }, + "/obj/item/organ/cyberimp/cyberware/muffler_ear": { + "confidence": "medium", + "group": "Nerves and stuns", + "hash": "", + "name": "Muffler ear", + "note": "While online, takes one point off the intensity of a bang, which is what earmuffs do: a flashbang or similar bang does nothing to the wearer, though the flash still blinds. It goes off during an EMP reboot or brownout. It takes the chrome ears slot, so it evicts the Heartbeat Doppler.", + "rank": 7, + "reason": "A flashbang's bang stops dropping the wearer, like earmuffs you can't take off. The flash still blinds. Load 1, 700 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/cyberware/piledriver": { "confidence": "high", "group": "Mobility and stealth", @@ -699,6 +749,16 @@ "reason": "8% of bullets miss while you're moving, never from an adjacent shooter, and nothing else. A cheap taste of the Slipwire in the slot Dead Channel wants. Load 2, 1,800 cr at reputation 4.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/cyberware/subvocal_mic": { + "confidence": "medium", + "group": "Mobility and stealth", + "hash": "", + "name": "Subvocal mic", + "note": "While online, a radio line the wearer speaks (headset, department or intercom) is spoken at range 0 instead of 1: the wearer and anyone on their tile hear it, a neighbour one tile away gets the usual garbled eavesdrop copy, and nobody further hears it. Ordinary talk is unchanged. It goes off during an EMP reboot or brownout.", + "rank": 8, + "reason": "Radio talk is not overheard by anyone standing close. A small edge for sneaking and boarding. Load 1, 600 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/eyes/hud/diagnostic": { "confidence": "high", "group": "Scouting and sight", diff --git a/voidcrew/modules/cyberware/ware_street_passives.dm b/voidcrew/modules/cyberware/ware_street_passives.dm new file mode 100644 index 00000000000..caef2ab3aed --- /dev/null +++ b/voidcrew/modules/cyberware/ware_street_passives.dm @@ -0,0 +1,455 @@ +/** + * # Street passives + * + * Six cheap pieces of chrome that do one quiet thing each and have no button: + * + * - Clotter node (heart aux, T1): bleed slower. + * - Adrenal pump (heart aux, T2): answers a bad run of stamina damage once + * in a while. Shares the heart aux slot with the Clotter node. + * - Medic's palms (hands, T1): nitrile-glove speed without the glove. + * - Cuffer's wrists (hands, T1): quicker cuffing. Sideways to the Medic's + * palms and the Gecko Grip, which sit in the same slot. + * - Subvocal mic (larynx, T1): radio talk goes no further than the speaker. + * - Muffler ear (ears, T1): earmuff-grade protection from a bang. Sideways to + * the Heartbeat Doppler, same slot. + * + * Every effect rides the failing-gated passive layer (chrome_passives_on/off in + * cyberware_base.dm), so an EMP, a brownout or a damage failure switches it off + * and a tune-up hands it back. All tuning is in vars on the organ. + * + * The Subvocal mic needs two small hooks in the speech path. Both are chained + * overrides at the bottom of this file, nothing upstream is edited. + * + * SKUs and trade-ins are defined at the bottom, listed on no vendor yet. + */ + +/// Held by a mob whose radio talk is not overheard. Granted by the Subvocal mic. +#define TRAIT_CYBER_SUBVOCAL "cyber_subvocal" +/// The red the Adrenal pump leaves on its bearer for the length of the rush. +#define CYBERWARE_ADRENAL_FLUSH "#ff8f8f" + +// ========================================================================= +// CLOTTER NODE +// ========================================================================= + +/** + * # Clotter node (T1, chest, heart aux slot, load 1) + * + * Halves the blood the bearer loses. The multiplier is mixed into the human + * physiology, which survives species changes, so there is one multiply and one + * divide per flip, and the factor actually applied is remembered so retuning the + * var on a live organ cannot desync the pair. Rides the failing-gated passive + * hooks like the Dermal Mesh does. + */ +/obj/item/organ/cyberimp/cyberware/clotter_node + name = "\improper Clotter node" + desc = "A reservoir of clotting gel plumbed into the heart. Cuts stop sooner than they should." + icon_state = "clotter_node" + zone = BODY_ZONE_CHEST + slot = ORGAN_SLOT_CYBERWARE_HEART_AUX + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + /// Multiplier on the blood the bearer loses. + var/bleed_multiplier = 0.5 + /// The multiplier currently mixed into the bearer, or null when none is. + var/applied_bleed_multiplier + +/obj/item/organ/cyberimp/cyberware/clotter_node/chrome_passives_on(mob/living/carbon/bearer) + . = ..() + if(!isnull(applied_bleed_multiplier) || !ishuman(bearer)) + return + var/mob/living/carbon/human/human_bearer = bearer + applied_bleed_multiplier = bleed_multiplier + human_bearer.physiology.bleed_mod *= applied_bleed_multiplier + +/obj/item/organ/cyberimp/cyberware/clotter_node/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + if(isnull(applied_bleed_multiplier)) + return + var/removed_multiplier = applied_bleed_multiplier + applied_bleed_multiplier = null // reset before the validity skip, see Shock Coils + if(!ishuman(bearer) || QDELETED(bearer)) + return + var/mob/living/carbon/human/human_bearer = bearer + human_bearer.physiology.bleed_mod /= removed_multiplier + +// ========================================================================= +// ADRENAL PUMP +// ========================================================================= + +/** + * # Adrenal pump (T2, chest, heart aux slot, load 2) + * + * When a run of stamina damage takes the bearer to the trigger level, the pump + * dumps a shot of adrenaline: part of the stamina loss comes off and the bearer + * is quicker on their feet for a few seconds. The tell is deliberate: a heartbeat + * thud anyone nearby can hear and a red flush on the bearer. + * + * The wait before it fires again is stored on the organ as the world.time it is + * ready at, so pulling the pump and refitting it never resets the wait. + * + * It answers damage from outside only. The crashes of the Cascade and Redline + * lines dump stamina as their own price, and a pump that took the sting out of + * that price would make the cheap heart ware a cure for the legend ware's bill, so + * the check runs one tick after the damage (the crash status is not on the bearer + * yet at the moment of the dump) and stands down while either crash is running. + */ +/obj/item/organ/cyberimp/cyberware/adrenal_pump + name = "\improper Adrenal pump" + desc = "A pump of synthetic adrenaline wired into the heart. The sort of thing a doctor tells you not to try at home." + icon_state = "adrenal_pump" + zone = BODY_ZONE_CHEST + slot = ORGAN_SLOT_CYBERWARE_HEART_AUX + w_class = WEIGHT_CLASS_SMALL + chrome_load = 2 + tier = CYBERWARE_TIER_2 + /// Stamina loss at or above which the pump fires. + var/trigger_stamina = 70 + /// Stamina loss taken off when it fires. + var/stamina_relief = 40 + /// How long the bearer is quicker for. + var/rush_duration = 3 SECONDS + /// How long the pump takes to charge again after firing. + var/recharge_time = 4 MINUTES + /// world.time at which the pump is ready to fire. Lives on the organ, so + /// removing and refitting it keeps the wait. + var/ready_at = 0 + /// TRUE while a firing check is queued, so one volley of damage queues one check. + var/discharge_queued = FALSE + +/obj/item/organ/cyberimp/cyberware/adrenal_pump/examine(mob/user) + . = ..() + . += span_notice("Its status light is [world.time < ready_at ? "amber" : "green"].") + +/obj/item/organ/cyberimp/cyberware/adrenal_pump/chrome_passives_on(mob/living/carbon/bearer) + . = ..() + if(isnull(bearer)) + return + RegisterSignal(bearer, COMSIG_LIVING_STAMINA_UPDATE, PROC_REF(on_stamina_update), override = TRUE) + +/obj/item/organ/cyberimp/cyberware/adrenal_pump/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + discharge_queued = FALSE + if(isnull(bearer)) + return + UnregisterSignal(bearer, COMSIG_LIVING_STAMINA_UPDATE) + +/// Signal proc for [COMSIG_LIVING_STAMINA_UPDATE]: cheap tests only, the pump +/// itself fires from the queued check. +/obj/item/organ/cyberimp/cyberware/adrenal_pump/proc/on_stamina_update(mob/living/source) + SIGNAL_HANDLER + if(discharge_queued || source != owner || world.time < ready_at) + return + if(source.stat != CONSCIOUS || source.getStaminaLoss() < trigger_stamina) + return + discharge_queued = TRUE + addtimer(CALLBACK(src, PROC_REF(discharge)), 1) // next tick, see the header + +/// The queued check. Everything is tested again: the bearer may have recovered, +/// been knocked out, lost the chrome or be riding out a crash. +/obj/item/organ/cyberimp/cyberware/adrenal_pump/proc/discharge() + discharge_queued = FALSE + var/mob/living/carbon/bearer = owner + if(QDELETED(src) || QDELETED(bearer) || world.time < ready_at) + return + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + if(!chrome?.is_running()) + return + if(bearer.stat != CONSCIOUS || bearer.getStaminaLoss() < trigger_stamina) + return + if(bearer.has_status_effect(/datum/status_effect/cyberware_cascade_crash) || bearer.has_status_effect(/datum/status_effect/cyberware_redline_crash)) + return + // Spent first: the relief below re-enters the stamina signal. + ready_at = world.time + recharge_time + bearer.adjustStaminaLoss(-stamina_relief) + bearer.apply_status_effect(/datum/status_effect/cyberware_adrenal_rush, rush_duration) + playsound(bearer, 'sound/effects/singlebeat.ogg', 100, TRUE, 2) + bearer.visible_message( + span_warning("[bearer] flushes red, heart thudding loud enough to hear."), + span_userdanger("Your heart slams and the exhaustion drops away."), + ) + bearer.balloon_alert(bearer, "adrenaline!") + cyberware_ink_pulse(bearer, CYBERWARE_INK_HARD) + +/// The burst of speed, and the flush that shows it. +/datum/status_effect/cyberware_adrenal_rush + id = "cyberware_adrenal_rush" + duration = 3 SECONDS + tick_interval = STATUS_EFFECT_NO_TICK + alert_type = null + +/// Optional argument: how long the rush lasts, from the pump that fired it. +/datum/status_effect/cyberware_adrenal_rush/on_creation(mob/living/new_owner, rush_duration) + if(isnum(rush_duration)) + duration = rush_duration + return ..() + +/datum/status_effect/cyberware_adrenal_rush/on_apply() + owner.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_adrenal_rush) + owner.add_atom_colour(CYBERWARE_ADRENAL_FLUSH, TEMPORARY_COLOUR_PRIORITY) + return TRUE + +/datum/status_effect/cyberware_adrenal_rush/on_remove() + owner.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_adrenal_rush) + owner.remove_atom_colour(TEMPORARY_COLOUR_PRIORITY, CYBERWARE_ADRENAL_FLUSH) + return ..() + +/datum/movespeed_modifier/cyberware_adrenal_rush + multiplicative_slowdown = -0.4 + +// ========================================================================= +// MEDIC'S PALMS +// ========================================================================= + +/** + * # Medic's palms (T1, arm, hands slot, load 1) + * + * The nitrile-glove pair of traits (quicker chemistry, quicker fireman carry) + * without taking up the glove slot. Same slot as the Gecko Grip and the Cuffer's + * wrists, so a body wears one of the three. + */ +/obj/item/organ/cyberimp/cyberware/medic_palms + name = "\improper Medic's palms" + desc = "Sterile palm plates with a better grip than any nitrile glove. Saves a trip to the glove dispenser." + icon_state = "medic_palms" + zone = BODY_ZONE_R_ARM + slot = ORGAN_SLOT_CYBERWARE_HANDS + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + organ_traits = list(TRAIT_FASTMED, TRAIT_QUICKER_CARRY) + +// ========================================================================= +// CUFFER'S WRISTS +// ========================================================================= + +/** + * # Cuffer's wrists (T1, arm, hands slot, load 1) + * + * Cuffs go on in three quarters of the time (TRAIT_FAST_CUFFING, the same trait + * as combat gloves). For bounty hunters and anyone else who ties people up for a + * living. + */ +/obj/item/organ/cyberimp/cyberware/cuffer_wrists + name = "\improper Cuffer's wrists" + desc = "Reinforced wrist actuators with a lot of practice at closing around other people's wrists." + icon_state = "cuffer_wrists" + zone = BODY_ZONE_R_ARM + slot = ORGAN_SLOT_CYBERWARE_HANDS + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + organ_traits = list(TRAIT_FAST_CUFFING) + +// ========================================================================= +// SUBVOCAL MIC +// ========================================================================= + +/** + * # Subvocal mic (T1, head, larynx slot, load 1) + * + * Radio talk is whispered at range 1 today, so the person next to you hears it. + * With the mic it goes no further than the speaker. The trait is the whole ware; + * the speech hooks at the bottom of this file read it. + */ +/obj/item/organ/cyberimp/cyberware/subvocal_mic + name = "\improper Subvocal mic" + desc = "A throat mic sutured to the vocal cords. Radio chatter goes out the wire instead of the room." + icon_state = "subvocal_mic" + zone = BODY_ZONE_HEAD + slot = ORGAN_SLOT_CYBERWARE_LARYNX + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + organ_traits = list(TRAIT_CYBER_SUBVOCAL) + +/// TRUE between a radio line the Subvocal mic quieted and the send_speech() call +/// that carries it, which follows straight on with no wait in between. +/mob/living/var/cyberware_subvocal_pending = FALSE + +/** + * Chained override of the human radio branch (base definition: + * code/modules/mob/living/carbon/human/human_say.dm, which sets the whisper + * range when it returns REDUCE_RANGE). A bearer's radio line is marked here; + * the send_speech() override below spends the mark. A line the radio swallowed + * (NOPASS) never reaches send_speech and is not marked. + */ +/mob/living/carbon/human/radio(message, list/message_mods = list(), list/spans, language) + cyberware_subvocal_pending = FALSE + . = ..() + if((. & REDUCE_RANGE) && !(. & NOPASS) && HAS_TRAIT(src, TRAIT_CYBER_SUBVOCAL)) + cyberware_subvocal_pending = TRUE + +/** + * Chained override of living speech output. A marked radio line is spoken at + * range 0: the bearer and anyone on their tile hear it, a neighbour one tile off + * gets the usual eavesdropper's garble, nobody else hears anything. The voice + * bark layer wraps this one and still plays its blip at the old range. + */ +/mob/living/send_speech(message_raw, message_range = 6, obj/source = src, bubble_type = bubble_icon, list/spans, datum/language/message_language = null, list/message_mods = list(), forced = null, tts_message, list/tts_filter) + if(cyberware_subvocal_pending) + cyberware_subvocal_pending = FALSE + message_range = 0 + return ..(message_raw, message_range, source, bubble_type, spans, message_language, message_mods, forced, tts_message, tts_filter) + +// ========================================================================= +// MUFFLER EAR +// ========================================================================= + +/** + * # Muffler ear (T1, head, ears slot, load 1) + * + * Takes one point off the intensity of a bang, which is exactly what earmuffs do + * (code/datums/components/earprotection.dm, sender soundbang_act in + * carbon_defense.dm). The listener is registered by the passive hooks, so an + * offline ear hears the bang in full. Same slot as the Heartbeat Doppler. + */ +/obj/item/organ/cyberimp/cyberware/muffler_ear + name = "\improper Muffler ear" + desc = "Baffles set into the ear canal. Earmuffs you can't leave on the bridge." + icon_state = "muffler_ear" + zone = BODY_ZONE_HEAD + slot = ORGAN_SLOT_CYBERWARE_EARS + w_class = WEIGHT_CLASS_SMALL + chrome_load = 1 + tier = CYBERWARE_TIER_1 + /// Intensity taken off a bang. + var/bang_reduction = 1 + +/obj/item/organ/cyberimp/cyberware/muffler_ear/chrome_passives_on(mob/living/carbon/bearer) + . = ..() + if(isnull(bearer)) + return + RegisterSignal(bearer, COMSIG_CARBON_SOUNDBANG, PROC_REF(on_soundbang), override = TRUE) + +/obj/item/organ/cyberimp/cyberware/muffler_ear/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + if(isnull(bearer)) + return + UnregisterSignal(bearer, COMSIG_CARBON_SOUNDBANG) + +/// Signal proc for [COMSIG_CARBON_SOUNDBANG]: the intensity rides in a list so +/// listeners can change it. +/obj/item/organ/cyberimp/cyberware/muffler_ear/proc/on_soundbang(datum/source, list/reflist) + SIGNAL_HANDLER + reflist[1] -= bang_reduction + +// ========================================================================= +// SHOP SKUS: not listed on any vendor yet, the integration pass adds them to +// the Chop Shop's sku_types and to the street doc's. +// ========================================================================= + +/datum/shop_sku/ripperdoc/clotter_node + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/clotter_node + price_credits = 900 + +/datum/shop_sku/ripperdoc/adrenal_pump + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/adrenal_pump + price_credits = 2400 + +/datum/shop_sku/ripperdoc/medic_palms + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/medic_palms + price_credits = 600 + +/datum/shop_sku/ripperdoc/cuffer_wrists + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/cuffer_wrists + price_credits = 500 + +/datum/shop_sku/ripperdoc/subvocal_mic + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/subvocal_mic + price_credits = 600 + +/datum/shop_sku/ripperdoc/muffler_ear + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/muffler_ear + price_credits = 700 + +// ========================================================================= +// TRADE-IN: half list, credits only. Not listed on any buyback_types yet, same +// integration pass as above. Ratchet's entries (/street_doc) differ only in wording. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/clotter_node + name = "Clotter node (pulled)" + desc = "A used clotting-gel reservoir." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/clotter_node + match_subtypes = FALSE + pay_credits = 450 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/clotter_node/street_doc + desc = "A used gel reservoir. Still sticky." + +/datum/shop_buyback/ripperdoc/adrenal_pump + name = "Adrenal pump (pulled)" + desc = "A used adrenaline pump. Credit toward something sturdier." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/adrenal_pump + match_subtypes = FALSE + pay_credits = 1200 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/adrenal_pump/street_doc + desc = "A used heart pump. Handle it gently." + +/datum/shop_buyback/ripperdoc/medic_palms + name = "Medic's palms (pulled)" + desc = "Used sterile palm plates." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/medic_palms + match_subtypes = FALSE + pay_credits = 300 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/medic_palms/street_doc + desc = "Used palm plates. Washed, mostly." + +/datum/shop_buyback/ripperdoc/cuffer_wrists + name = "Cuffer's wrists (pulled)" + desc = "Used wrist actuators." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/cuffer_wrists + match_subtypes = FALSE + pay_credits = 250 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/cuffer_wrists/street_doc + desc = "Used wrist actuators. They've seen some arrests." + +/datum/shop_buyback/ripperdoc/subvocal_mic + name = "Subvocal mic (pulled)" + desc = "A used throat mic." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/subvocal_mic + match_subtypes = FALSE + pay_credits = 300 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/subvocal_mic/street_doc + desc = "A used throat mic. Don't ask what it heard." + +/datum/shop_buyback/ripperdoc/muffler_ear + name = "Muffler ear (pulled)" + desc = "Used ear baffles." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/muffler_ear + match_subtypes = FALSE + pay_credits = 350 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/muffler_ear/street_doc + desc = "Used ear baffles. Quiet ones." diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 0c372c82ec0..9f440f665c6 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -74,6 +74,7 @@ #include "voidcrew_cyberware_camo.dm" #include "voidcrew_cyberware_mantis.dm" #include "voidcrew_cyberware_models_b.dm" +#include "voidcrew_cyberware_passives.dm" #include "voidcrew_derelict_helpers.dm" #include "voidcrew_derelict_layout.dm" #include "voidcrew_derelict_leash.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index 21d698a9655..831f554929a 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -158,7 +158,8 @@ /datum/unit_test/voidcrew_cyberware_arm_slots /datum/unit_test/voidcrew_cyberware_arm_slots/Run() - var/list/exempt = list(/obj/item/organ/cyberimp/cyberware/gecko) + // The other two palm and wrist pieces share the hands slot with it (ware_street_passives.dm). + var/list/exempt = list(/obj/item/organ/cyberimp/cyberware/gecko, /obj/item/organ/cyberimp/cyberware/medic_palms, /obj/item/organ/cyberimp/cyberware/cuffer_wrists) var/list/arm_ware = typesof(/obj/item/organ/cyberimp/cyberware) + typesof(/obj/item/organ/cyberimp/arm/toolkit/cyberware) for(var/organ_path in arm_ware) if(organ_path in exempt) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_passives.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_passives.dm new file mode 100644 index 00000000000..c287e51b479 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_passives.dm @@ -0,0 +1,317 @@ +/** + * # Street passives (voidcrew/modules/cyberware/ware_street_passives.dm) + * + * Clotter node, Adrenal pump, Medic's palms, Cuffer's wrists, Subvocal mic and + * Muffler ear: their slots, their effects while online, the failing-gated + * passive layer taking them away on an EMP and handing them back on a tune-up, + * the pump's wait surviving a refit, and the two speech hooks behind the mic. + * Unit-test files compile before voidcrew/_DEFINES/, so the Subvocal trait is + * written as its literal "cyber_subvocal" and the slots as theirs. + */ + +/// A plain pair of earpieces: a worn item in the ears slot whose radio branch +/// needs no working radio behind it. +/obj/item/clothing/ears/voidcrew_test_subvocal_ears + name = "test earpiece" + +/// Shape: every piece sits in the slot the plan gives it, has an item state of +/// its own, carries no button, and the slot sharing is what the plan says. +/datum/unit_test/voidcrew_cyberware_passives_shape + +/datum/unit_test/voidcrew_cyberware_passives_shape/Run() + var/list/passives = list( + /obj/item/organ/cyberimp/cyberware/clotter_node, + /obj/item/organ/cyberimp/cyberware/adrenal_pump, + /obj/item/organ/cyberimp/cyberware/medic_palms, + /obj/item/organ/cyberimp/cyberware/cuffer_wrists, + /obj/item/organ/cyberimp/cyberware/subvocal_mic, + /obj/item/organ/cyberimp/cyberware/muffler_ear, + ) + for(var/passive_path in passives) + var/obj/item/organ/cyberimp/cyberware/ware = allocate(passive_path) + TEST_ASSERT(ware.icon_state, "[passive_path] has no item state") + TEST_ASSERT(ware.chrome_load > 0, "[passive_path] costs no neural load") + TEST_ASSERT(!length(ware.actions), "[passive_path] grew an action button. These are passive") + + var/obj/item/organ/cyberimp/cyberware/clotter_node/clotter = allocate(/obj/item/organ/cyberimp/cyberware/clotter_node) + var/obj/item/organ/cyberimp/cyberware/adrenal_pump/pump = allocate(/obj/item/organ/cyberimp/cyberware/adrenal_pump) + var/obj/item/organ/cyberimp/cyberware/medic_palms/palms = allocate(/obj/item/organ/cyberimp/cyberware/medic_palms) + var/obj/item/organ/cyberimp/cyberware/cuffer_wrists/wrists = allocate(/obj/item/organ/cyberimp/cyberware/cuffer_wrists) + var/obj/item/organ/cyberimp/cyberware/subvocal_mic/mic = allocate(/obj/item/organ/cyberimp/cyberware/subvocal_mic) + var/obj/item/organ/cyberimp/cyberware/muffler_ear/muffler = allocate(/obj/item/organ/cyberimp/cyberware/muffler_ear) + var/obj/item/organ/cyberimp/cyberware/gecko/gecko = allocate(/obj/item/organ/cyberimp/cyberware/gecko) + var/obj/item/organ/cyberimp/cyberware/doppler/doppler = allocate(/obj/item/organ/cyberimp/cyberware/doppler) + + // ORGAN_SLOT_CYBERWARE_HEART_AUX, _HANDS, _LARYNX and _EARS. + TEST_ASSERT_EQUAL(clotter.slot, "cyberware_heart_aux", "Clotter node is not in the spare heart slot") + TEST_ASSERT_EQUAL(pump.slot, clotter.slot, "The pump and the node can both be fitted. They share the heart slot") + TEST_ASSERT_EQUAL(palms.slot, gecko.slot, "Medic's palms are not in the Gecko Grip's hands slot") + TEST_ASSERT_EQUAL(wrists.slot, gecko.slot, "Cuffer's wrists are not in the Gecko Grip's hands slot") + TEST_ASSERT_EQUAL(mic.slot, "cyberware_larynx", "Subvocal mic is not in the larynx slot") + TEST_ASSERT_EQUAL(muffler.slot, doppler.slot, "Muffler ear is not in the Doppler's ears slot") + + // Cheap street chrome, T1 apart from the pump. + TEST_ASSERT_EQUAL(pump.tier, 2, "The pump is not T2") + for(var/obj/item/organ/cyberimp/cyberware/cheap as anything in list(clotter, palms, wrists, mic, muffler)) + TEST_ASSERT_EQUAL(cheap.tier, 1, "[cheap] is not T1") + + // The cradle quotes a credit price for each, and only the pump needs yellow. + build_cyberware_price_index() + for(var/obj/item/organ/cyberimp/cyberware/priced as anything in list(clotter, pump, palms, wrists, mic, muffler)) + var/list/price = get_cyberware_price(priced) + TEST_ASSERT(price && price["credits"] > 0 && !price["vouchers"], "[priced] quotes no credit-only price at the cradle") + var/datum/shop_sku/ripperdoc/adrenal_pump/pump_sku = allocate(/datum/shop_sku/ripperdoc/adrenal_pump) + var/datum/shop_sku/ripperdoc/clotter_node/clotter_sku = allocate(/datum/shop_sku/ripperdoc/clotter_node) + TEST_ASSERT_EQUAL(pump_sku.zone_permission, 1, "The pump is not sold at yellow permission") // ZONE_PERMISSION_YELLOW + TEST_ASSERT_EQUAL(clotter_sku.zone_permission, 0, "The node is gated") // ZONE_PERMISSION_PUBLIC + +/// Clotter node: halves the blood lost, only while online, with one multiply and +/// one divide however often the passive layer flips, and the pump evicts it. +/datum/unit_test/voidcrew_cyberware_clotter_node + +/datum/unit_test/voidcrew_cyberware_clotter_node/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/clotter_node/clotter = allocate(/obj/item/organ/cyberimp/cyberware/clotter_node) + var/datum/component/cyberware/chrome = clotter.GetComponent(/datum/component/cyberware) + TEST_ASSERT_EQUAL(bearer.physiology.bleed_mod, 1, "Test fixture: the bearer already bleeds differently") + + TEST_ASSERT(clotter.Insert(bearer, special = TRUE), "Test fixture: Clotter node staging insert was refused") + TEST_ASSERT_EQUAL(bearer.physiology.bleed_mod, 0.5, "An installed Clotter node didn't halve the bleed") + + // Asked twice, applied once. + clotter.chrome_passives_on(bearer) + TEST_ASSERT_EQUAL(bearer.physiology.bleed_mod, 0.5, "The node's multiplier was applied twice") + + chrome.start_emp_reboot() + TEST_ASSERT_EQUAL(bearer.physiology.bleed_mod, 1, "An EMP'd Clotter node kept the bleed halved") + chrome.tune_up() + TEST_ASSERT_EQUAL(bearer.physiology.bleed_mod, 0.5, "A tuned-up Clotter node didn't hand the bleed cut back") + + // Retuning a fitted organ takes off what was put on, not what the var says now. + clotter.bleed_multiplier = 0.25 + clotter.Remove(bearer, special = TRUE) + clotter.forceMove(run_loc_floor_bottom_left) + TEST_ASSERT_EQUAL(bearer.physiology.bleed_mod, 1, "Pulling the node left the bleed changed") + + // The heart slot holds one: the pump evicts the node and its cut with it. + clotter.bleed_multiplier = 0.5 + TEST_ASSERT(clotter.Insert(bearer, special = TRUE), "Test fixture: Clotter node refit was refused") + TEST_ASSERT_EQUAL(bearer.physiology.bleed_mod, 0.5, "A refitted Clotter node didn't halve the bleed") + var/obj/item/organ/cyberimp/cyberware/adrenal_pump/pump = allocate(/obj/item/organ/cyberimp/cyberware/adrenal_pump) + TEST_ASSERT(pump.Insert(bearer, special = TRUE), "Test fixture: pump staging insert was refused") + TEST_ASSERT(!clotter.owner, "The pump went in beside the node. They share a slot") + TEST_ASSERT_EQUAL(bearer.physiology.bleed_mod, 1, "The evicted node left its bleed cut on the bearer") + +/// Medic's palms and Cuffer's wrists: their traits while online, gone on an EMP +/// and on removal, and the three hands-slot pieces evict one another. +/datum/unit_test/voidcrew_cyberware_hand_traits + +/datum/unit_test/voidcrew_cyberware_hand_traits/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/medic_palms/palms = allocate(/obj/item/organ/cyberimp/cyberware/medic_palms) + var/obj/item/organ/cyberimp/cyberware/cuffer_wrists/wrists = allocate(/obj/item/organ/cyberimp/cyberware/cuffer_wrists) + var/obj/item/organ/cyberimp/cyberware/gecko/gecko = allocate(/obj/item/organ/cyberimp/cyberware/gecko) + + TEST_ASSERT(palms.Insert(bearer, special = TRUE), "Test fixture: Medic's palms staging insert was refused") + TEST_ASSERT(HAS_TRAIT(bearer, TRAIT_FASTMED), "Medic's palms didn't give the quicker chemistry trait") + TEST_ASSERT(HAS_TRAIT(bearer, TRAIT_QUICKER_CARRY), "Medic's palms didn't give the quicker carry trait") + TEST_ASSERT(!HAS_TRAIT(bearer, TRAIT_FAST_CUFFING), "Medic's palms give quicker cuffing") + + var/datum/component/cyberware/chrome = palms.GetComponent(/datum/component/cyberware) + chrome.start_emp_reboot() + TEST_ASSERT(!HAS_TRAIT(bearer, TRAIT_FASTMED) && !HAS_TRAIT(bearer, TRAIT_QUICKER_CARRY), "EMP'd Medic's palms kept their traits") + chrome.tune_up() + TEST_ASSERT(HAS_TRAIT(bearer, TRAIT_FASTMED) && HAS_TRAIT(bearer, TRAIT_QUICKER_CARRY), "Tuned-up Medic's palms didn't hand their traits back") + + // Cuffer's wrists evict the palms: same slot. + TEST_ASSERT(wrists.Insert(bearer, special = TRUE), "Test fixture: Cuffer's wrists staging insert was refused") + TEST_ASSERT(!palms.owner, "The wrists went in beside the palms. They share a slot") + TEST_ASSERT(HAS_TRAIT(bearer, TRAIT_FAST_CUFFING), "Cuffer's wrists didn't give quicker cuffing") + TEST_ASSERT(!HAS_TRAIT(bearer, TRAIT_FASTMED) && !HAS_TRAIT(bearer, TRAIT_QUICKER_CARRY), "The evicted palms left their traits on the bearer") + + // And the Gecko Grip evicts the wrists. + TEST_ASSERT(gecko.Insert(bearer, special = TRUE), "Test fixture: Gecko Grip staging insert was refused") + TEST_ASSERT(!wrists.owner, "The Gecko Grip went in beside the wrists. They share a slot") + TEST_ASSERT(!HAS_TRAIT(bearer, TRAIT_FAST_CUFFING), "The evicted wrists left quicker cuffing on the bearer") + +/// Muffler ear: a bang loses a point of intensity while the ear is online, the +/// same point earmuffs take off, and nothing when it is offline or pulled. The +/// intensity rides in a list, so the signal is read directly: the real +/// soundbang_act() also depends on the room's air. +/datum/unit_test/voidcrew_cyberware_muffler_ear + +/datum/unit_test/voidcrew_cyberware_muffler_ear/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/muffler_ear/muffler = allocate(/obj/item/organ/cyberimp/cyberware/muffler_ear) + var/datum/component/cyberware/chrome = muffler.GetComponent(/datum/component/cyberware) + + var/list/reflist = list(1) + SEND_SIGNAL(bearer, COMSIG_CARBON_SOUNDBANG, reflist) + TEST_ASSERT_EQUAL(reflist[1], 1, "A bare body took a point off a bang") + + TEST_ASSERT(muffler.Insert(bearer, special = TRUE), "Test fixture: Muffler ear staging insert was refused") + reflist = list(1) + SEND_SIGNAL(bearer, COMSIG_CARBON_SOUNDBANG, reflist) + TEST_ASSERT_EQUAL(reflist[1], 0, "The Muffler ear didn't take a point off the bang") + + // A bigger bang loses the one point and no more. + reflist = list(2) + SEND_SIGNAL(bearer, COMSIG_CARBON_SOUNDBANG, reflist) + TEST_ASSERT_EQUAL(reflist[1], 1, "The Muffler ear took more than a point off a bigger bang") + + chrome.start_emp_reboot() + reflist = list(1) + SEND_SIGNAL(bearer, COMSIG_CARBON_SOUNDBANG, reflist) + TEST_ASSERT_EQUAL(reflist[1], 1, "An EMP'd Muffler ear still muffled the bang") + chrome.tune_up() + reflist = list(1) + SEND_SIGNAL(bearer, COMSIG_CARBON_SOUNDBANG, reflist) + TEST_ASSERT_EQUAL(reflist[1], 0, "A tuned-up Muffler ear didn't muffle the bang again") + + muffler.Remove(bearer, special = TRUE) + reflist = list(1) + SEND_SIGNAL(bearer, COMSIG_CARBON_SOUNDBANG, reflist) + TEST_ASSERT_EQUAL(reflist[1], 1, "A pulled Muffler ear still muffled the bang") + +/// Adrenal pump: quiet below its trigger, one answer to a bad run of stamina +/// damage, nothing inside the wait (a refit doesn't reset it), nothing while +/// offline, and everything back once the wait is over. +/datum/unit_test/voidcrew_cyberware_adrenal_pump + +/datum/unit_test/voidcrew_cyberware_adrenal_pump/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/adrenal_pump/pump = allocate(/obj/item/organ/cyberimp/cyberware/adrenal_pump) + TEST_ASSERT(pump.Insert(bearer, special = TRUE), "Test fixture: Adrenal pump staging insert was refused") + var/datum/component/cyberware/chrome = pump.GetComponent(/datum/component/cyberware) + + // Below the trigger: nothing. + bearer.adjustStaminaLoss(50) + sleep(0.5 SECONDS) + TEST_ASSERT_EQUAL(pump.ready_at, 0, "The pump fired below its trigger level") + TEST_ASSERT(!bearer.has_status_effect(/datum/status_effect/cyberware_adrenal_rush), "A rush started below the pump's trigger level") + + // Over it: part of the loss comes off, the bearer is quick and flushed, and + // the wait starts. + bearer.adjustStaminaLoss(30) + sleep(0.5 SECONDS) + TEST_ASSERT(bearer.getStaminaLoss() <= 45, "The pump didn't take stamina loss off ([bearer.getStaminaLoss()] left of 80)") + TEST_ASSERT(bearer.has_status_effect(/datum/status_effect/cyberware_adrenal_rush), "The pump fired without a rush") + TEST_ASSERT(bearer.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_adrenal_rush), "The rush didn't speed the bearer up") + TEST_ASSERT(pump.ready_at > world.time, "The pump didn't start its wait") + + // Inside the wait: another bad run gets no answer. + bearer.adjustStaminaLoss(50) + sleep(0.5 SECONDS) + TEST_ASSERT(bearer.getStaminaLoss() >= 85, "The pump fired again inside its wait") + + // Pulled and refitted: still waiting. The clock lives on the organ. + var/waiting_until = pump.ready_at + pump.Remove(bearer, special = TRUE) + pump.forceMove(run_loc_floor_bottom_left) + TEST_ASSERT(pump.Insert(bearer, special = TRUE), "Test fixture: Adrenal pump refit was refused") + TEST_ASSERT_EQUAL(pump.ready_at, waiting_until, "Refitting the pump reset its wait") + bearer.adjustStaminaLoss(5) + sleep(0.5 SECONDS) + TEST_ASSERT(bearer.getStaminaLoss() >= 90, "A refitted pump fired inside its wait") + + // Offline: no answer, and the wait is not spent. + bearer.setStaminaLoss(0) + pump.ready_at = 0 + chrome.start_emp_reboot() + bearer.adjustStaminaLoss(80) + sleep(0.5 SECONDS) + TEST_ASSERT_EQUAL(pump.ready_at, 0, "An EMP'd pump fired") + TEST_ASSERT(bearer.getStaminaLoss() >= 75, "An EMP'd pump took stamina loss off") + + // Back online, the next bad run is answered. + chrome.tune_up() + bearer.adjustStaminaLoss(1) + sleep(0.5 SECONDS) + TEST_ASSERT(pump.ready_at > world.time, "A tuned-up pump didn't answer a bad run") + TEST_ASSERT(bearer.getStaminaLoss() <= 45, "A tuned-up pump didn't take stamina loss off") + +/// The Cascade and Redline crashes dump stamina as their own price. The pump +/// stands down for them: it is a bar against outside damage, not a way out of +/// the legend ware's bill. +/datum/unit_test/voidcrew_cyberware_adrenal_pump_crash + +/datum/unit_test/voidcrew_cyberware_adrenal_pump_crash/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/adrenal_pump/pump = allocate(/obj/item/organ/cyberimp/cyberware/adrenal_pump) + TEST_ASSERT(pump.Insert(bearer, special = TRUE), "Test fixture: Adrenal pump staging insert was refused") + + bearer.apply_status_effect(/datum/status_effect/cyberware_cascade_crash) + sleep(0.5 SECONDS) + TEST_ASSERT(bearer.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "Test fixture: the Cascade crash isn't running") + TEST_ASSERT(bearer.getStaminaLoss() >= 70, "Test fixture: the Cascade crash dumped no stamina") + TEST_ASSERT_EQUAL(pump.ready_at, 0, "The pump answered the Cascade crash's own stamina dump") + TEST_ASSERT(!bearer.has_status_effect(/datum/status_effect/cyberware_adrenal_rush), "The pump started a rush in the middle of a Cascade crash") + bearer.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + + var/mob/living/carbon/human/redliner = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/adrenal_pump/second_pump = allocate(/obj/item/organ/cyberimp/cyberware/adrenal_pump) + TEST_ASSERT(second_pump.Insert(redliner, special = TRUE), "Test fixture: second pump staging insert was refused") + redliner.apply_status_effect(/datum/status_effect/cyberware_redline_crash) + sleep(0.5 SECONDS) + TEST_ASSERT(redliner.has_status_effect(/datum/status_effect/cyberware_redline_crash), "Test fixture: the Redline crash isn't running") + TEST_ASSERT_EQUAL(second_pump.ready_at, 0, "The pump answered the Redline crash's own stamina dump") + redliner.remove_status_effect(/datum/status_effect/cyberware_redline_crash) + +/// Subvocal mic: a radio line from a bearer is spoken at range 0, anything else +/// they say, and every line from a body without the mic, keeps its range. The +/// mark the radio branch leaves is spent by the one send_speech() that follows. +/datum/unit_test/voidcrew_cyberware_subvocal_mic + /// The range the speaker's own Hear() was handed, or null before any line. + var/heard_range + +/datum/unit_test/voidcrew_cyberware_subvocal_mic/proc/on_heard(datum/source, list/hear_args) + SIGNAL_HANDLER + heard_range = hear_args[HEARING_RANGE] + +/// One radio line the way say() sends it: the radio branch first, then the +/// whisper-range send. Returns the range the speaker's own Hear() was given. +/datum/unit_test/voidcrew_cyberware_subvocal_mic/proc/speak_on_radio(mob/living/carbon/human/speaker) + heard_range = null + var/radio_flags = speaker.radio("test", list(MODE_HEADSET = TRUE), list(), null) + TEST_ASSERT(radio_flags & REDUCE_RANGE, "Test fixture: the radio branch didn't shorten the range") + speaker.send_speech("test", 1, speaker, speaker.bubble_icon, list(), null, list(WHISPER_MODE = MODE_WHISPER)) + return heard_range + +/datum/unit_test/voidcrew_cyberware_subvocal_mic/Run() + var/mob/living/carbon/human/speaker = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/obj/item/clothing/ears/voidcrew_test_subvocal_ears/earpiece = allocate(/obj/item/clothing/ears/voidcrew_test_subvocal_ears) + speaker.equip_to_slot_if_possible(earpiece, ITEM_SLOT_EARS, qdel_on_fail = FALSE, disable_warning = TRUE) + TEST_ASSERT_EQUAL(speaker.ears, earpiece, "Test fixture: the earpiece didn't equip") + RegisterSignal(speaker, COMSIG_MOVABLE_PRE_HEAR, PROC_REF(on_heard)) + + // No mic: radio talk is whispered at range 1, as ever. + TEST_ASSERT_EQUAL(speak_on_radio(speaker), 1, "A body without the mic changed its radio range") + TEST_ASSERT(!speaker.cyberware_subvocal_pending, "A body without the mic left a mark behind") + + var/obj/item/organ/cyberimp/cyberware/subvocal_mic/mic = allocate(/obj/item/organ/cyberimp/cyberware/subvocal_mic) + TEST_ASSERT(mic.Insert(speaker, special = TRUE), "Test fixture: Subvocal mic staging insert was refused") + TEST_ASSERT(HAS_TRAIT(speaker, "cyber_subvocal"), "The Subvocal mic didn't give its trait") // TRAIT_CYBER_SUBVOCAL + + // Mic: the radio line is spoken at range 0, and the mark is spent. + TEST_ASSERT_EQUAL(speak_on_radio(speaker), 0, "A radio line from a Subvocal mic bearer kept its range") + TEST_ASSERT(!speaker.cyberware_subvocal_pending, "The radio line's mark outlived its send") + + // An ordinary line right after is not quieted. + heard_range = null + speaker.send_speech("test", 6, speaker, speaker.bubble_icon, list(), null, list()) + TEST_ASSERT_EQUAL(heard_range, 6, "A spoken line after a radio line was quieted") + + // A mark nobody sent is cleared by the next radio branch rather than quieting a plain line. + speaker.cyberware_subvocal_pending = TRUE + speaker.radio("test", list(), list(), null) + TEST_ASSERT(!speaker.cyberware_subvocal_pending, "A stale mark survived a radio branch that sent nothing") + + // Offline: radio talk is overheard again. + var/datum/component/cyberware/chrome = mic.GetComponent(/datum/component/cyberware) + chrome.start_emp_reboot() + TEST_ASSERT_EQUAL(speak_on_radio(speaker), 1, "An EMP'd Subvocal mic still quieted the radio") + chrome.tune_up() + TEST_ASSERT_EQUAL(speak_on_radio(speaker), 0, "A tuned-up Subvocal mic didn't quiet the radio again") + + mic.Remove(speaker, special = TRUE) + TEST_ASSERT_EQUAL(speak_on_radio(speaker), 1, "A pulled Subvocal mic still quieted the radio") From b0f4cee181d71e0338509a75b2d3529267d6eb83 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:09:46 -0800 Subject: [PATCH 129/472] Add six cyberware models: Spring calves, Vaulter pistons, Brawler myomers, Tinker's fingers, Deepcore drill fist, Holdout housing The Hopper leap range and cooldown, the Gorilla punch, smash and hurl numbers and the Fixer's tool-speed modifier move from defines into vars on the organ, with the same values, so a model can retune them. The Gorilla door-pry set bonus is now attached only when pries_doors is set. Spring calves and Vaulter pistons share the Hopper's leg slot. The Vaulter keeps the Shock Coils' soft landing and dry footing. Brawler myomers are a street Gorilla pair that punches lighter, hurls nobody and never pries a door. Tinker's fingers are a cheaper Fixer's Fingers under their own speed modifier. The Deepcore is a Rockjaw with a faster drill. The Holdout housing unfolds a six-shot .38 revolver that is loaded by hand, a round at a time, and shows on the bearer when examined. Shop entries and trade-in ledgers are defined but not listed on any vendor yet. Ratings and tests included. --- tgstation.dme | 1 + tools/gear_catalog/ratings/ammo.json | 9 + tools/gear_catalog/ratings/cyberware.json | 63 +++ tools/gear_catalog/ratings/guns.json | 9 + tools/gear_catalog/ratings/tools.json | 9 + .../modules/cyberware/ware_arm_utility.dm | 7 +- .../modules/cyberware/ware_line_models_a.dm | 371 +++++++++++++++++ .../modules/cyberware/ware_military_arms.dm | 34 +- voidcrew/modules/cyberware/ware_pro_combat.dm | 24 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_models_a.dm | 380 ++++++++++++++++++ 11 files changed, 895 insertions(+), 13 deletions(-) create mode 100644 voidcrew/modules/cyberware/ware_line_models_a.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm diff --git a/tgstation.dme b/tgstation.dme index 8386f60c9d9..f8d5c7fe49b 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7192,6 +7192,7 @@ #include "voidcrew\modules\cyberware\ware_line_camo.dm" #include "voidcrew\modules\cyberware\ware_line_governor.dm" #include "voidcrew\modules\cyberware\ware_line_mantis.dm" +#include "voidcrew\modules\cyberware\ware_line_models_a.dm" #include "voidcrew\modules\cyberware\ware_line_sandy.dm" #include "voidcrew\modules\cyberware\ware_line_widowline.dm" #include "voidcrew\modules\cyberware\ware_military_arms.dm" diff --git a/tools/gear_catalog/ratings/ammo.json b/tools/gear_catalog/ratings/ammo.json index 3c5396c196d..2909b7ce7ef 100644 --- a/tools/gear_catalog/ratings/ammo.json +++ b/tools/gear_catalog/ratings/ammo.json @@ -312,6 +312,15 @@ "reason": "Built into the peashooter, never on its own. Rated with the gun.", "tier": "green" }, + "/obj/item/ammo_box/magazine/internal/cylinder/rev38/cyberware_holdout": { + "confidence": "high", + "group": "magazines built into a gun", + "hash": "", + "name": "holdout cylinder", + "rank": 8, + "reason": "Built into the Holdout revolver arm, never on its own. Rated with the gun.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/internal/cylinder/rev38/housecall": { "confidence": "high", "group": "magazines built into a gun", diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index c90293724b9..bbb46d39a58 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -51,6 +51,15 @@ "reason": "Every timed action takes 25% less time, surgery, welding, cuffing and repairs alike, plus a driver, wrench and cutters in the arm. Worth a slot for medics and engineers. Load 2, 1,800 cr.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street": { + "confidence": "medium", + "group": "Work tools", + "hash": "", + "name": "Tinker's fingers", + "rank": 2, + "reason": "A driver, wrench and cutters in the arm, and every timed action 10% faster. The Fixer's Fingers at a tenth of the stack: load 1, 700 cr. Splice takes a pulled set back for 350 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/graverobber": { "confidence": "medium", "group": "Scouting and sight", @@ -157,6 +166,15 @@ "reason": "A mining drill in the forearm with a bottomless ore hopper. A miner's bag and pick in one arm slot. Load 2, 1,200 cr.", "tier": "green" }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore": { + "confidence": "medium", + "group": "Work tools", + "hash": "", + "name": "Deepcore drill fist", + "rank": 4, + "reason": "The Rockjaw's ore hopper on a faster drill, between the Rockjaw drill and a diamond drill. Load 3, 2,400 cr.", + "tier": "green" + }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin": { "confidence": "medium", "group": "Arm weapons and fists", @@ -166,6 +184,15 @@ "reason": "A one-handed machine pistol in the arm that can't be disarmed: 20 damage, 3.3 shots a second, about a WT-550 on the gun score (47). 20-round magazines are 250 cr at reputation 22 or late research. Loot only.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Holdout housing", + "rank": 9, + "reason": "A .38 revolver in the arm that can't be disarmed: 25 damage, six shots, about 2.5 a second, reloaded by hand a round at a time with ordinary .38. Load 3, 2,400 cr at reputation 4. Splice takes a pulled housing back for 1,200 cr.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/skyhook": { "confidence": "high", "group": "Mobility and stealth", @@ -527,6 +554,24 @@ "reason": "Left half of the Gorilla pair: a flat 20 damage punch every 4 s, 40 to machines and structures every second, breaks rock, and the pair pries powered airlocks in 4 s.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/cyberware/gorilla_arms/street": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Brawler myomers (right)", + "rank": 12, + "reason": "Half of a pair: a flat 14 damage punch every 6 s, 25 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying. Load 4 the pair, 2,400 cr at reputation 4. Splice takes a pulled arm back for 600 cr.", + "tier": "yellow" + }, + "/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left": { + "confidence": "medium", + "group": "Arm weapons and fists", + "hash": "", + "name": "Brawler myomers (left)", + "rank": 13, + "reason": "Left half of the Brawler pair: a flat 14 damage punch every 6 s, 25 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/governor_delete": { "confidence": "medium", "group": "Operating system", @@ -573,6 +618,24 @@ "reason": "Jump 4 tiles over tables, gaps and people every 8 s. Won't cross walls or windows. Strong in any fight. Load 3, 3,600 cr.", "tier": "yellow" }, + "/obj/item/organ/cyberimp/cyberware/hopper/milspec": { + "confidence": "medium", + "group": "Mobility and stealth", + "hash": "", + "name": "Vaulter pistons", + "rank": 2, + "reason": "Jump 5 tiles over tables, gaps and people every 6 s, and walk away from drops and wet floors like the Shock Coils. Won't cross walls or windows. Load 4, 3 vouchers at reputation 11.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/hopper/street": { + "confidence": "medium", + "group": "Mobility and stealth", + "hash": "", + "name": "Spring calves", + "rank": 6, + "reason": "Jump 3 tiles over tables, gaps and people every 14 s. Won't cross walls or windows. The cheap rung under the Hopper, with none of the Shock Coils' trims. Load 2, 1,800 cr at reputation 4. Splice takes a pulled pair back for 900 cr.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/lazarus": { "confidence": "high", "group": "Revive and last stand", diff --git a/tools/gear_catalog/ratings/guns.json b/tools/gear_catalog/ratings/guns.json index 079fdd4cbed..b08ade70873 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -469,6 +469,15 @@ }, "tier": "yellow" }, + "/obj/item/gun/ballistic/revolver/cyberware_holdout": { + "confidence": "medium", + "group": "sidearms", + "hash": "", + "name": "holdout revolver", + "rank": 12, + "reason": "A .38 revolver folded into the forearm: 25 damage, six shots, about 2.5 a second. Can't be disarmed while deployed, and every reload is a round at a time by hand.", + "tier": "yellow" + }, "/obj/item/gun/ballistic/revolver/golden": { "confidence": "medium", "group": "sidearms", diff --git a/tools/gear_catalog/ratings/tools.json b/tools/gear_catalog/ratings/tools.json index 80c6cfaaa21..a96dbc5a3be 100644 --- a/tools/gear_catalog/ratings/tools.json +++ b/tools/gear_catalog/ratings/tools.json @@ -1597,6 +1597,15 @@ "reason": "Implant drill. Half-time digging with your hands free. The implant is the real purchase.", "tier": "green" }, + "/obj/item/pickaxe/drill/cyberware/deepcore": { + "confidence": "medium", + "group": "Mining tools", + "hash": "", + "name": "deepcore drill", + "rank": 7, + "reason": "Implant drill. Digs in under a third of the usual time with your hands free, between the rockjaw drill and a diamond drill. The implant is the real purchase.", + "tier": "green" + }, "/obj/item/pickaxe/drill/diamonddrill": { "confidence": "medium", "group": "Mining tools", diff --git a/voidcrew/modules/cyberware/ware_arm_utility.dm b/voidcrew/modules/cyberware/ware_arm_utility.dm index 1c64088dd28..8706fc72c4c 100644 --- a/voidcrew/modules/cyberware/ware_arm_utility.dm +++ b/voidcrew/modules/cyberware/ware_arm_utility.dm @@ -170,6 +170,9 @@ /obj/item/wrench/cyborg, /obj/item/wirecutters/cyborg, ) + /// The action-speed modifier the servo tendons apply while the ware is running. + /// The model in the line (ware_line_models_a.dm) swaps in a weaker one. + var/datum/actionspeed_modifier/speed_modifier = /datum/actionspeed_modifier/cyberware_fixers // The tool-speed bonus rides the failing-gated passive layer (BAL-4): servo // tendons with an EMP reboot or a brownout are just fingers until the ware @@ -177,10 +180,10 @@ // idempotent and needs no applied-state guard. /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/chrome_passives_on(mob/living/carbon/bearer) . = ..() - bearer?.add_actionspeed_modifier(/datum/actionspeed_modifier/cyberware_fixers) + bearer?.add_actionspeed_modifier(speed_modifier) /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/chrome_passives_off(mob/living/carbon/bearer) . = ..() - bearer?.remove_actionspeed_modifier(/datum/actionspeed_modifier/cyberware_fixers) + bearer?.remove_actionspeed_modifier(speed_modifier) #undef CYBERWARE_ROCKJAW_SWEEP_DELAY diff --git a/voidcrew/modules/cyberware/ware_line_models_a.dm b/voidcrew/modules/cyberware/ware_line_models_a.dm new file mode 100644 index 00000000000..4b2ca50d4fd --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_models_a.dm @@ -0,0 +1,371 @@ +/** + * # Cheaper and harder models of six wares + * + * Seven new models, each a subtype of the ware it sits under in the same slot, + * so fitting one evicts the other. The parent's tuning lives in vars (the Hopper + * leap, the Gorilla punches, the Fixer's tool-speed modifier), and a model + * retunes by setting them. + * + * model parent tier load price where + * Spring calves Hopper pistons T1 2 1,800 cr yellow + * Vaulter pistons Hopper pistons T3 4 3 vouchers advanced + * Brawler myomers Gorilla Arms T2 2 per arm 2,400 cr the pair yellow + * Tinker's fingers Fixer's Fingers T1 1 700 cr public + * Deepcore drill fist Rockjaw T2 3 2,400 cr public + * Holdout housing Popup Ronin T2 3 2,400 cr yellow + * + * Leg ladder: Shock Coils, Spring calves, Hopper, Vaulter, Meteor Piledriver. + * The Vaulter carries the Coils' soft landing and dry footing, which the Hopper + * itself never had, so stepping up to it costs no safety. + * + * Sold at Splice's counter, and the credit-priced ones at the street doc's. + * The integration pass wires the SKUs and ledger entries below into the vendors + * and the zone loot tables. + */ + +// ========================================================================= +// SPRING CALVES (street Hopper) +// ========================================================================= + +/** + * # Spring calves (T1, legs, load 2) + * + * The cheap way into a jump: coil springs on the calves instead of pistons. A + * short hop that takes a while to wind back up. Nothing else. + */ +/obj/item/organ/cyberimp/cyberware/hopper/street + name = "\improper Spring calves" + desc = "Industrial coil springs sleeved over both calves and bolted straight to the shin. They squeak." + icon_state = "hopper_street" + chrome_load = 2 + tier = CYBERWARE_TIER_1 + aug_overlay = "hopper_street" + leap_range = 3 + leap_cooldown = 14 SECONDS + leap_action_desc = "Leap up to three tiles over gaps, tables and people, onto any open floor you can see with a clear lane to it. Walls, windows and shut doors stop the jump." + +// ========================================================================= +// VAULTER PISTONS (milspec Hopper) +// ========================================================================= + +/** + * # Vaulter pistons (T3, legs, load 4) + * + * Military leg pistons: a longer jump on a shorter clock, with shock plates + * under the heel. Keeps the Shock Coils' soft landing and dry footing, minus the + * Coils' knockdown and stun trims, which stay with the Coils. + */ +/obj/item/organ/cyberimp/cyberware/hopper/milspec + name = "\improper Vaulter pistons" + desc = "Decommissioned military leg pistons, shock-plated at the heel. Somebody ground the serial number off and left the warning stencil." + icon_state = "hopper_milspec" + chrome_load = 4 + tier = CYBERWARE_TIER_3 + aug_overlay = "hopper_milspec" + organ_traits = list(TRAIT_NO_SLIP_WATER) + leap_range = 5 + leap_cooldown = 6 SECONDS + leap_action_desc = "Leap up to five tiles over gaps, tables and people, onto any open floor you can see with a clear lane to it. Walls, windows and shut doors stop the jump." + +/obj/item/organ/cyberimp/cyberware/hopper/milspec/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) + . = ..() + RegisterSignal(organ_owner, COMSIG_LIVING_Z_IMPACT, PROC_REF(on_z_impact)) + +/obj/item/organ/cyberimp/cyberware/hopper/milspec/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) + . = ..() + UnregisterSignal(organ_owner, COMSIG_LIVING_Z_IMPACT) + +/// Signal proc for [COMSIG_LIVING_Z_IMPACT]: the Shock Coils' soft landing. Big +/// multi-level drops still stagger. +/obj/item/organ/cyberimp/cyberware/hopper/milspec/proc/on_z_impact(datum/source, levels, turf/fell_on) + SIGNAL_HANDLER + if(organ_flags & ORGAN_FAILING) + return NONE + new /obj/effect/temp_visual/mook_dust(fell_on) + cyberware_ink_pulse(owner, CYBERWARE_INK_SOFT) + if(levels >= 2) + owner.adjust_staggered_up_to(STAGGERED_SLOWDOWN_LENGTH * levels, 10 SECONDS) + owner.visible_message( + span_notice("[owner] lands on [fell_on] with a piston hiss."), + span_notice("Your heel plates soak up the impact."), + ) + return ZIMPACT_CANCEL_DAMAGE | ZIMPACT_NO_MESSAGE | ZIMPACT_NO_SPIN + +// ========================================================================= +// BRAWLER MYOMERS (street Gorilla Arms) +// ========================================================================= + +/** + * # Brawler myomers (T2, arms, arm hardware slots, load 2 per arm / 4 the pair) + * + * Gorilla Arms bought cheap: lighter punches, slower, a lighter smash on + * structures. Rock still breaks, and a grabbed victim takes the ordinary slam, + * not a throw across the room. The pair never pries a door: the set bonus is + * never attached, so even one of these beside a real Gorilla arm gets nothing. + */ +/obj/item/organ/cyberimp/cyberware/gorilla_arms/street + name = "\improper Brawler myomers (right)" + desc = "Off-the-shelf myomer strips grafted along the forearm. You don't look any stronger for it. You just stop losing arm wrestles." + icon_state = "gorilla_street" + chrome_load = 2 + tier = CYBERWARE_TIER_2 + aug_overlay = "gorilla_street_right" + punch_damage = 14 + structure_damage = 25 + punch_cooldown_time = 6 SECONDS + smash_cooldown_time = 1.5 SECONDS + hurl_range = 0 + pries_doors = FALSE + +/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left + name = "\improper Brawler myomers (left)" + zone = BODY_ZONE_L_ARM + slot = ORGAN_SLOT_LEFT_ARM_AUG + aug_overlay = "gorilla_street_left" + +/obj/item/cyberware_pair_case/gorilla_arms/street + name = "\improper Brawler myomers case" + desc = "A scuffed clamshell case with two bundles of myomer strips zip-tied into the foam." + ware_types = list( + /obj/item/organ/cyberimp/cyberware/gorilla_arms/street, + /obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left, + ) + +// ========================================================================= +// TINKER'S FINGERS (street Fixer's Fingers) +// ========================================================================= + +/// Tinker's fingers' servo speed-up: a tenth off every timed action, to the +/// Fixer's quarter. Its own key, so it never replaces the Fixer's. +/datum/actionspeed_modifier/cyberware_fixers/street + multiplicative_slowdown = -0.1 + +/** + * # Tinker's fingers (T1, arm aug, load 1) + * + * The same three tools in the knuckles and cheaper servos behind them. A green + * crew's way into the arm slot the Fixer's Fingers hold at yellow. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street + name = "\improper Tinker's fingers" + desc = "A fingertip tool suite in a housing that has been opened and closed more times than the warranty allows. Driver, wrench and cutters, all slightly loose." + icon_state = "fixers_street" + chrome_load = 1 + tier = CYBERWARE_TIER_1 + speed_modifier = /datum/actionspeed_modifier/cyberware_fixers/street + +// ========================================================================= +// DEEPCORE DRILL FIST (heavy Rockjaw) +// ========================================================================= + +/// The Deepcore's drill: between the Rockjaw's drill (0.5) and the diamond +/// drill (0.2). +/obj/item/pickaxe/drill/cyberware/deepcore + name = "deepcore drill" + desc = "A long drill bit with a diamond collar, wired straight into the forearm. It isn't coming off without a surgeon." + toolspeed = 0.3 + +/** + * # Deepcore drill fist (T2, arm aug, load 3) + * + * The Rockjaw with a heavier drill on the arm. The ore hopper and its button + * are the Rockjaw's own. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore + name = "\improper Deepcore drill fist" + desc = "A heavy drill folded into the forearm, built for rock the usual drills give up on." + icon_state = "rockjaw_deepcore" + chrome_load = 3 + tier = CYBERWARE_TIER_2 + items_to_create = list(/obj/item/pickaxe/drill/cyberware/deepcore) + +// ========================================================================= +// HOLDOUT HOUSING (street Popup Ronin) +// ========================================================================= + +/** + * The holdout's cylinder: six chambers of ordinary .38, thumbed in one round at + * a time. A speedloader feeds it a round per click, not the whole cylinder. + */ +/obj/item/ammo_box/magazine/internal/cylinder/rev38/cyberware_holdout + name = "holdout cylinder" + multiload = FALSE + +/** + * The gun the housing unfolds: a snub-nosed revolver on the stock chassis. NODROP + * while deployed comes from the toolkit Extend, so it can't be disarmed; Retract + * and EMP stow it. It leaves the shop loaded, and every reload after that is by + * hand, round by round. + */ +/obj/item/gun/ballistic/revolver/cyberware_holdout + name = "holdout revolver" + desc = "A snub-nosed revolver small enough to fold into a forearm. Uses .38 Special rounds." + icon = 'voidcrew/modules/cyberware/icons/cyberware_weapons.dmi' + icon_state = "ronin_holdout" + inhand_icon_state = "ronin_holdout" + lefthand_file = 'voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi' + righthand_file = 'voidcrew/modules/cyberware/icons/cyberware_righthand.dmi' + accepted_magazine_type = /obj/item/ammo_box/magazine/internal/cylinder/rev38/cyberware_holdout + fire_sound = 'sound/items/weapons/gun/revolver/shot.ogg' + fire_delay = 0.4 SECONDS + /// How long each round takes to thumb into the cylinder. + var/reload_time = 1 SECONDS + +/// TRUE when a chamber is empty or holds a spent casing, so a round can go in. +/obj/item/gun/ballistic/revolver/cyberware_holdout/proc/cylinder_has_room() + if(!magazine) + return FALSE + for(var/slot in magazine.stored_ammo) + if(isnull(slot)) + return TRUE + // A path is a round the cylinder hasn't spawned yet, which is a live one. + if(!istype(slot, /obj/item/ammo_casing)) + continue + var/obj/item/ammo_casing/casing = slot + if(!casing.loaded_projectile) + return TRUE + return FALSE + +// Thumbing a round in takes a moment. A full cylinder is refused without a wait. +/obj/item/gun/ballistic/revolver/cyberware_holdout/load_gun(obj/item/ammo, mob/living/user) + if(user && cylinder_has_room() && !do_after(user, reload_time, target = src)) + return FALSE + return ..() + +/** + * # Holdout housing (T2, one arm, aug slot, load 3) + * + * A fold-out forearm revolver for the yellow zone: six shots of ordinary .38, + * loaded by hand. Anyone who looks at the bearer sees the housing in the arm. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout + name = "\improper Holdout housing" + desc = "A forearm housing with a snub-nosed revolver folded inside. Not much to look at, which is the idea." + icon_state = "ronin_holdout" + chrome_load = 3 + tier = CYBERWARE_TIER_2 + aug_overlay = "ronin_holdout" + items_to_create = list(/obj/item/gun/ballistic/revolver/cyberware_holdout) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout/on_mob_insert(mob/living/carbon/arm_owner, special = FALSE, movement_flags) + . = ..() + RegisterSignal(arm_owner, COMSIG_ATOM_EXAMINE, PROC_REF(on_owner_examined)) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout/on_mob_remove(mob/living/carbon/arm_owner, special = FALSE, movement_flags) + . = ..() + UnregisterSignal(arm_owner, COMSIG_ATOM_EXAMINE) + +/// Signal proc for [COMSIG_ATOM_EXAMINE] on the bearer: the housing shows in the +/// arm to anyone who looks, suit or no suit. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout/proc/on_owner_examined(mob/living/carbon/source, mob/user, list/examine_list) + SIGNAL_HANDLER + examine_list += span_notice("[source.p_They()] [source.p_have()] a revolver housing set into [source.p_their()] [parse_zone(zone)].") + +// ========================================================================= +// SPLICE SKUS: not listed on any vendor yet, the integration pass adds them to +// the Chop Shop's sku_types (and the street doc's, for the credit-priced ones). +// ========================================================================= + +/datum/shop_sku/ripperdoc/hopper_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/hopper/street + price_credits = 1800 + +/datum/shop_sku/ripperdoc/hopper_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/hopper/milspec + price_vouchers = 3 + +/datum/shop_sku/ripperdoc/gorilla_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + name = "Brawler Myomers (pair)" + desc = "A cased pair of cheap myomer forearms." + item_path = /obj/item/cyberware_pair_case/gorilla_arms/street + price_credits = 2400 + stock_min = 1 + stock_max = 2 + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/ripperdoc/fixers_street + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street + price_credits = 700 + +/datum/shop_sku/ripperdoc/rockjaw_deepcore + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore + price_credits = 2400 + +/datum/shop_sku/ripperdoc/ronin_holdout + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + desc = "A snub-nosed revolver folded into a chrome forearm." + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout + price_credits = 2400 + stock_min = 1 + stock_max = 2 + fixed_price = TRUE + contract_reward = FALSE + +// ========================================================================= +// INTAKE LEDGERS: half the shelf price, credits only, like the other street +// rungs. Not listed on any buyback_types yet, same integration pass as above. +// The Vaulter costs vouchers, which can't be refunded, and the Deepcore has no +// entry. Ratchet's subtypes carry his wording and are listed beside Splice's. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/hopper_street + name = "Spring calves (pulled)" + desc = "Used calf springs. They squeak." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/hopper/street + match_subtypes = FALSE + pay_credits = 900 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/hopper_street/street_doc + desc = "A used pair of calf springs." + +/datum/shop_buyback/ripperdoc/gorilla_street + name = "Brawler myomers (pulled)" + desc = "One myomer arm, either side." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/gorilla_arms/street + match_subtypes = TRUE + pay_credits = 600 + demand_min = 2 + demand_max = 4 + +/datum/shop_buyback/ripperdoc/gorilla_street/street_doc + desc = "One used myomer arm, either side." + +/datum/shop_buyback/ripperdoc/fixers_street + name = "Tinker's fingers (pulled)" + desc = "A used fingertip tool suite." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street + match_subtypes = FALSE + pay_credits = 350 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/fixers_street/street_doc + desc = "A used set of fingertip tools, a bit loose." + +/datum/shop_buyback/ripperdoc/ronin_holdout + name = "Holdout housing (pulled)" + desc = "A used revolver housing." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout + match_subtypes = FALSE + pay_credits = 1200 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/ronin_holdout/street_doc + desc = "A used revolver housing, cylinder and all." diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index 7380262ad52..d8d6b12d426 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -128,6 +128,21 @@ aug_overlay = "gorilla_right" /// Rate limit on this arm's empowered hits. COOLDOWN_DECLARE(slam_cooldown) + // The defines above are the Gorilla defaults. They live in vars so the other + // models of the line (ware_line_models_a.dm) can retune by setting them. + /// Flat brute per empowered punch. + var/punch_damage = CYBERWARE_GORILLA_PUNCH_DAMAGE + /// Damage per structure/machine smash. + var/structure_damage = CYBERWARE_GORILLA_STRUCTURE_DAMAGE + /// Gap between empowered punches, per arm. + var/punch_cooldown_time = CYBERWARE_GORILLA_PUNCH_COOLDOWN + /// Gap between structure smashes / rock breaks, per arm. + var/smash_cooldown_time = CYBERWARE_GORILLA_SMASH_COOLDOWN + /// How far an aggressively-grabbed victim gets hurled. 0 or less: no hurl, + /// the grabbed victim takes the ordinary slam. + var/hurl_range = CYBERWARE_GORILLA_HURL_RANGE + /// Whether a matched pair of these pries closed airlocks (the set bonus). + var/pries_doors = TRUE /obj/item/organ/cyberimp/cyberware/gorilla_arms/left name = "\improper Gorilla Arms myomer lattice (left)" @@ -137,7 +152,8 @@ /obj/item/organ/cyberimp/cyberware/gorilla_arms/Initialize(mapload) . = ..() - AddElement(/datum/element/organ_set_bonus, /datum/status_effect/organ_set_bonus/cyberware_gorilla) + if(pries_doors) + AddElement(/datum/element/organ_set_bonus, /datum/status_effect/organ_set_bonus/cyberware_gorilla) /** * Worn-chrome overlay hook. Gorilla Arms replace the limb outright, hand @@ -197,7 +213,7 @@ source.do_attack_animation(rock, ATTACK_EFFECT_SMASH) playsound(rock, 'sound/effects/meteorimpact.ogg', 30, TRUE) rock.gets_drilled(source, 1) - COOLDOWN_START(src, slam_cooldown, CYBERWARE_GORILLA_SMASH_COOLDOWN) + COOLDOWN_START(src, slam_cooldown, smash_cooldown_time) return COMPONENT_CANCEL_ATTACK_CHAIN // Structures, girders, machines cave in. Loose items are beneath us. @@ -212,8 +228,8 @@ span_danger("[source] caves [smashed] in with a piston-driven fist!"), span_danger("You cave [smashed] in!"), ) - smashed.take_damage(CYBERWARE_GORILLA_STRUCTURE_DAMAGE, BRUTE, MELEE) - COOLDOWN_START(src, slam_cooldown, CYBERWARE_GORILLA_SMASH_COOLDOWN) + smashed.take_damage(structure_damage, BRUTE, MELEE) + COOLDOWN_START(src, slam_cooldown, smash_cooldown_time) return COMPONENT_CANCEL_ATTACK_CHAIN if(!isliving(target)) @@ -226,24 +242,24 @@ if(ishuman(living_target)) var/mob/living/carbon/human/human_target = living_target - if(human_target.check_block(source, CYBERWARE_GORILLA_PUNCH_DAMAGE, "[source]'s piston punch")) + if(human_target.check_block(source, punch_damage, "[source]'s piston punch")) source.do_attack_animation(living_target) playsound(living_target.loc, 'sound/items/weapons/punchmiss.ogg', 25, TRUE, -1) log_combat(source, living_target, "attempted to piston-punch", "gorilla arms") return COMPONENT_CANCEL_ATTACK_CHAIN // The one damage line. Flat, wounding, honest. - var/grabbed_hurl = living_target.pulledby == source && source.grab_state >= GRAB_AGGRESSIVE + var/grabbed_hurl = hurl_range > 0 && living_target.pulledby == source && source.grab_state >= GRAB_AGGRESSIVE source.do_attack_animation(living_target, ATTACK_EFFECT_SMASH) playsound(living_target.loc, 'sound/items/weapons/punch1.ogg', 40, TRUE, -1) var/target_zone = living_target.get_random_valid_zone(source.zone_selected) var/armor_block = living_target.run_armor_check(target_zone, MELEE, armour_penetration = active_hand.unarmed_effectiveness) - living_target.apply_damage(CYBERWARE_GORILLA_PUNCH_DAMAGE, BRUTE, target_zone, armor_block, wound_bonus = 10) + living_target.apply_damage(punch_damage, BRUTE, target_zone, armor_block, wound_bonus = 10) if(source.body_position != LYING_DOWN) var/atom/throw_target = get_edge_target_turf(living_target, source.dir) if(grabbed_hurl) - living_target.safe_throw_at(throw_target, CYBERWARE_GORILLA_HURL_RANGE, 2, source) + living_target.safe_throw_at(throw_target, hurl_range, 2, source) living_target.visible_message( span_danger("[source] hurls [living_target] across the room!"), span_userdanger("[source] hurls you across the room!"), @@ -256,7 +272,7 @@ ) to_chat(source, span_danger("You slam [living_target]!")) log_combat(source, living_target, "piston-punched", "gorilla arms") - COOLDOWN_START(src, slam_cooldown, CYBERWARE_GORILLA_PUNCH_COOLDOWN) + COOLDOWN_START(src, slam_cooldown, punch_cooldown_time) return COMPONENT_CANCEL_ATTACK_CHAIN /** diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index cd1788bf2c6..0caf7de7d3b 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -629,6 +629,14 @@ tier = CYBERWARE_TIER_2 aug_overlay = "hopper" actions_types = list(/datum/action/cooldown/cyberware/hopper_leap) + // The models in the line (ware_line_models_a.dm) retune these by setting vars. + /// Leap distance in tiles. + var/leap_range = CYBERWARE_HOPPER_RANGE + /// Gap between leaps. The button reads it when it is built. + var/leap_cooldown = 8 SECONDS + /// Tooltip for the button when this model's numbers differ from the button's + /// own text. Null keeps the button's own. + var/leap_action_desc /datum/action/cooldown/cyberware/hopper_leap name = "Piston Leap" @@ -638,6 +646,16 @@ cooldown_time = 8 SECONDS click_to_activate = TRUE +/datum/action/cooldown/cyberware/hopper_leap/New(Target, original = TRUE) + . = ..() + // The pistons this button belongs to set its cooldown and tooltip. + var/obj/item/organ/cyberimp/cyberware/hopper/pistons = organ + if(!istype(pistons)) + return + cooldown_time = pistons.leap_cooldown + if(pistons.leap_action_desc) + desc = pistons.leap_action_desc + /datum/action/cooldown/cyberware/hopper_leap/Activate(atom/target) if(!isliving(owner)) return FALSE @@ -649,10 +667,12 @@ if(jumper.buckled || !isturf(jumper.loc)) jumper.balloon_alert(jumper, "can't jump from here!") return FALSE - if(get_dist(here, destination) > CYBERWARE_HOPPER_RANGE) + var/obj/item/organ/cyberimp/cyberware/hopper/pistons = organ + var/leap_range = istype(pistons) ? pistons.leap_range : CYBERWARE_HOPPER_RANGE + if(get_dist(here, destination) > leap_range) jumper.balloon_alert(jumper, "too far!") return FALSE - if(!can_see(jumper, destination, CYBERWARE_HOPPER_RANGE)) + if(!can_see(jumper, destination, leap_range)) jumper.balloon_alert(jumper, "no line of sight!") return FALSE if(destination.is_blocked_turf() || islava(destination) || ischasm(destination)) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 9f440f665c6..d556a72bd7a 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -73,6 +73,7 @@ #include "voidcrew_cyberware.dm" #include "voidcrew_cyberware_camo.dm" #include "voidcrew_cyberware_mantis.dm" +#include "voidcrew_cyberware_models_a.dm" #include "voidcrew_cyberware_models_b.dm" #include "voidcrew_cyberware_passives.dm" #include "voidcrew_derelict_helpers.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm new file mode 100644 index 00000000000..3b961354d6a --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm @@ -0,0 +1,380 @@ +/** + * # Cyberware models, part A + * + * The Spring calves and Vaulter pistons (Hopper), Brawler myomers (Gorilla Arms), + * Tinker's fingers (Fixer's Fingers), Deepcore drill fist (Rockjaw) and Holdout + * housing (Popup Ronin). Each parent keeps its numbers (the defines became vars + * with the same values), each model shares the parent's slot, and the shelf and + * ledger entries pay what the plan says. Unit-test files compile before + * voidcrew/_DEFINES/, so tiers, slots and zone permissions below are literals: + * tier 1/2/3 are the CYBERWARE_TIER_* values, zone permission 0/1/2 are + * public/yellow/advanced. + */ + +/// The Hopper line: the parent leaps four tiles on an eight second clock with no +/// traits and no fall handler, the Spring calves leap three on fourteen seconds, +/// the Vaulter five on six and carry the Shock Coils' soft landing and dry footing. +/datum/unit_test/voidcrew_cyberware_models_a_hopper + +/datum/unit_test/voidcrew_cyberware_models_a_hopper/Run() + var/obj/item/organ/cyberimp/cyberware/hopper/parent = allocate(/obj/item/organ/cyberimp/cyberware/hopper) + var/obj/item/organ/cyberimp/cyberware/hopper/street/street = allocate(/obj/item/organ/cyberimp/cyberware/hopper/street) + var/obj/item/organ/cyberimp/cyberware/hopper/milspec/milspec = allocate(/obj/item/organ/cyberimp/cyberware/hopper/milspec) + var/obj/item/organ/cyberimp/cyberware/shock_coils/coils = allocate(/obj/item/organ/cyberimp/cyberware/shock_coils) + + // The parent keeps its numbers. + TEST_ASSERT_EQUAL(parent.leap_range, 4, "The Hopper's leap range changed") + TEST_ASSERT_EQUAL(parent.leap_cooldown, 8 SECONDS, "The Hopper's leap cooldown changed") + TEST_ASSERT_EQUAL(parent.chrome_load, 3, "The Hopper's load changed") + TEST_ASSERT_EQUAL(parent.tier, 2, "The Hopper's tier changed") + TEST_ASSERT_NULL(parent.leap_action_desc, "The Hopper's button text is overridden") + TEST_ASSERT(!length(parent.organ_traits), "The Hopper grew a passive trait") + + // The models. + TEST_ASSERT_EQUAL(street.leap_range, 3, "Spring calves don't leap three tiles") + TEST_ASSERT_EQUAL(street.leap_cooldown, 14 SECONDS, "Spring calves' clock isn't fourteen seconds") + TEST_ASSERT_EQUAL(street.chrome_load, 2, "Spring calves' load isn't 2") + TEST_ASSERT_EQUAL(street.tier, 1, "Spring calves aren't tier 1") + TEST_ASSERT(!length(street.organ_traits), "Spring calves got dry footing") + TEST_ASSERT_EQUAL(milspec.leap_range, 5, "Vaulter pistons don't leap five tiles") + TEST_ASSERT_EQUAL(milspec.leap_cooldown, 6 SECONDS, "Vaulter pistons' clock isn't six seconds") + TEST_ASSERT_EQUAL(milspec.chrome_load, 4, "Vaulter pistons' load isn't 4") + TEST_ASSERT_EQUAL(milspec.tier, 3, "Vaulter pistons aren't tier 3") + TEST_ASSERT(TRAIT_NO_SLIP_WATER in milspec.organ_traits, "Vaulter pistons don't keep dry footing") + TEST_ASSERT(street.chrome_load < parent.chrome_load && parent.chrome_load < milspec.chrome_load, "The leg line's loads don't climb") + + // Same slot as the Coils and the parent: the leg ladder evicts itself. + TEST_ASSERT_EQUAL(street.slot, coils.slot, "Spring calves aren't in the leg slot") + TEST_ASSERT_EQUAL(milspec.slot, coils.slot, "Vaulter pistons aren't in the leg slot") + TEST_ASSERT_EQUAL(parent.slot, coils.slot, "The Hopper left the leg slot") + + // Each button takes its cooldown and its text from the pistons it belongs to. + var/datum/action/cooldown/cyberware/hopper_leap/parent_button = locate() in parent.actions + var/datum/action/cooldown/cyberware/hopper_leap/street_button = locate() in street.actions + var/datum/action/cooldown/cyberware/hopper_leap/milspec_button = locate() in milspec.actions + TEST_ASSERT_NOTNULL(parent_button, "The Hopper lost its leap button") + TEST_ASSERT_NOTNULL(street_button, "Spring calves have no leap button") + TEST_ASSERT_NOTNULL(milspec_button, "Vaulter pistons have no leap button") + TEST_ASSERT_EQUAL(parent_button.cooldown_time, 8 SECONDS, "The Hopper's button changed its clock") + TEST_ASSERT(findtext(parent_button.desc, "four tiles"), "The Hopper's button changed its text") + TEST_ASSERT_EQUAL(street_button.cooldown_time, 14 SECONDS, "Spring calves' button kept the wrong clock") + TEST_ASSERT(findtext(street_button.desc, "three tiles"), "Spring calves' button kept the wrong text") + TEST_ASSERT_EQUAL(milspec_button.cooldown_time, 6 SECONDS, "Vaulter pistons' button kept the wrong clock") + TEST_ASSERT(findtext(milspec_button.desc, "five tiles"), "Vaulter pistons' button kept the wrong text") + + // The reach follows the pistons. The arena is five tiles square, so the far + // corner is four tiles from the near one. + var/turf/start = run_loc_floor_bottom_left + var/turf/three_away = locate(start.x + 3, start.y, start.z) + var/turf/four_away = locate(start.x + 4, start.y, start.z) + TEST_ASSERT(isfloorturf(three_away) && isfloorturf(four_away), "The test arena is smaller than the leap test needs") + + var/mob/living/carbon/human/hopper_wearer = allocate(/mob/living/carbon/human/consistent, start) + TEST_ASSERT(parent.Insert(hopper_wearer, special = TRUE), "The Hopper wouldn't go in") + TEST_ASSERT(parent_button.Activate(four_away), "The Hopper wouldn't leap four tiles") + TEST_ASSERT_EQUAL(parent_button.next_use_time, world.time + 8 SECONDS, "The Hopper's leap started the wrong clock") + + var/mob/living/carbon/human/spring_wearer = allocate(/mob/living/carbon/human/consistent, start) + TEST_ASSERT(street.Insert(spring_wearer, special = TRUE), "Spring calves wouldn't go in") + TEST_ASSERT(!street_button.Activate(four_away), "Spring calves leapt four tiles") + TEST_ASSERT(street_button.IsAvailable(), "A refused leap started Spring calves' clock") + TEST_ASSERT(street_button.Activate(three_away), "Spring calves wouldn't leap three tiles") + TEST_ASSERT_EQUAL(street_button.next_use_time, world.time + 14 SECONDS, "Spring calves' leap started the wrong clock") + + // Dry footing and the soft landing belong to the Vaulter alone. + var/mob/living/carbon/human/vault_wearer = allocate(/mob/living/carbon/human/consistent, start) + TEST_ASSERT(milspec.Insert(vault_wearer, special = TRUE), "Vaulter pistons wouldn't go in") + TEST_ASSERT(HAS_TRAIT(vault_wearer, TRAIT_NO_SLIP_WATER), "Vaulter pistons gave no dry footing") + var/landing = SEND_SIGNAL(vault_wearer, COMSIG_LIVING_Z_IMPACT, 1, start) + TEST_ASSERT(landing & ZIMPACT_CANCEL_DAMAGE, "Vaulter pistons didn't soften a fall") + milspec.organ_flags |= ORGAN_FAILING + landing = SEND_SIGNAL(vault_wearer, COMSIG_LIVING_Z_IMPACT, 1, start) + TEST_ASSERT(!(landing & ZIMPACT_CANCEL_DAMAGE), "Browned-out Vaulter pistons still softened a fall") + milspec.organ_flags &= ~ORGAN_FAILING + milspec.Remove(vault_wearer, special = TRUE) + TEST_ASSERT(!HAS_TRAIT(vault_wearer, TRAIT_NO_SLIP_WATER), "Dry footing outlived the Vaulter pistons") + landing = SEND_SIGNAL(vault_wearer, COMSIG_LIVING_Z_IMPACT, 1, start) + TEST_ASSERT(!(landing & ZIMPACT_CANCEL_DAMAGE), "A fall was softened with the Vaulter pistons gone") + + // The Hopper and the Spring calves never had either. + TEST_ASSERT(!HAS_TRAIT(hopper_wearer, TRAIT_NO_SLIP_WATER), "The Hopper got dry footing") + TEST_ASSERT(!(SEND_SIGNAL(hopper_wearer, COMSIG_LIVING_Z_IMPACT, 1, start) & ZIMPACT_CANCEL_DAMAGE), "The Hopper softened a fall") + TEST_ASSERT(!HAS_TRAIT(spring_wearer, TRAIT_NO_SLIP_WATER), "Spring calves got dry footing") + TEST_ASSERT(!(SEND_SIGNAL(spring_wearer, COMSIG_LIVING_Z_IMPACT, 1, start) & ZIMPACT_CANCEL_DAMAGE), "Spring calves softened a fall") + +/// The Gorilla line: the parent keeps every number and the door-pry bonus. The +/// Brawler myomers punch lighter and slower, smash lighter, hurl nobody, and +/// never pry a door, alone or beside a real Gorilla arm. The case holds a pair. +/datum/unit_test/voidcrew_cyberware_models_a_gorilla + +/datum/unit_test/voidcrew_cyberware_models_a_gorilla/Run() + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/real_right = allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/left/real_left = allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/left) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/brawler_right = allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left/brawler_left = allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left) + + // The parent keeps its numbers. + TEST_ASSERT_EQUAL(real_right.punch_damage, 20, "Gorilla punch damage changed") + TEST_ASSERT_EQUAL(real_right.structure_damage, 40, "Gorilla structure damage changed") + TEST_ASSERT_EQUAL(real_right.punch_cooldown_time, 4 SECONDS, "Gorilla punch cooldown changed") + TEST_ASSERT_EQUAL(real_right.smash_cooldown_time, 1 SECONDS, "Gorilla smash cooldown changed") + TEST_ASSERT_EQUAL(real_right.hurl_range, 4, "Gorilla hurl range changed") + TEST_ASSERT(real_right.pries_doors, "Gorilla Arms stopped prying doors") + TEST_ASSERT_EQUAL(real_right.chrome_load, 3, "Gorilla load changed") + TEST_ASSERT_EQUAL(real_right.tier, 3, "Gorilla tier changed") + + // The model. + TEST_ASSERT_EQUAL(brawler_right.punch_damage, 14, "Brawler punch isn't 14") + TEST_ASSERT_EQUAL(brawler_right.structure_damage, 25, "Brawler smash isn't 25") + TEST_ASSERT_EQUAL(brawler_right.punch_cooldown_time, 6 SECONDS, "Brawler punch clock isn't six seconds") + TEST_ASSERT_EQUAL(brawler_right.smash_cooldown_time, 1.5 SECONDS, "Brawler smash clock isn't a second and a half") + TEST_ASSERT(brawler_right.hurl_range <= 0, "Brawler myomers can hurl a grabbed victim") + TEST_ASSERT(!brawler_right.pries_doors, "Brawler myomers pry doors") + TEST_ASSERT_EQUAL(brawler_right.chrome_load, 2, "Brawler load isn't 2 per arm") + TEST_ASSERT_EQUAL(brawler_right.tier, 2, "Brawler myomers aren't tier 2") + TEST_ASSERT_EQUAL(brawler_left.chrome_load, 2, "Left Brawler load isn't 2") + TEST_ASSERT_EQUAL(brawler_right.slot, real_right.slot, "Right Brawler myomers aren't in the right arm slot") + TEST_ASSERT_EQUAL(brawler_left.slot, real_left.slot, "Left Brawler myomers aren't in the left arm slot") + TEST_ASSERT_EQUAL(brawler_left.zone, BODY_ZONE_L_ARM, "Left Brawler myomers aren't left-arm chrome") + + // The case holds one of each side. + var/obj/item/cyberware_pair_case/gorilla_arms/street/brawler_case = allocate(/obj/item/cyberware_pair_case/gorilla_arms/street) + TEST_ASSERT_EQUAL(length(brawler_case.contents), 2, "The Brawler case isn't a pair") + TEST_ASSERT(locate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street) in brawler_case.contents, "The Brawler case has no right arm") + TEST_ASSERT(locate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left) in brawler_case.contents, "The Brawler case has no left arm") + var/obj/item/cyberware_pair_case/gorilla_arms/real_case = allocate(/obj/item/cyberware_pair_case/gorilla_arms) + TEST_ASSERT(locate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/left) in real_case.contents, "The Gorilla case lost its left arm") + TEST_ASSERT(!(locate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street) in real_case.contents), "The Gorilla case holds Brawler myomers") + + // A real pair pries doors. Brawler myomers never do, alone or beside a real arm. + var/mob/living/carbon/human/real_pair = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + TEST_ASSERT(real_right.Insert(real_pair, special = TRUE), "The right Gorilla arm wouldn't go in") + TEST_ASSERT(real_left.Insert(real_pair, special = TRUE), "The left Gorilla arm wouldn't go in") + var/datum/status_effect/organ_set_bonus/pair_bonus = real_pair.has_status_effect(/datum/status_effect/organ_set_bonus/cyberware_gorilla) + TEST_ASSERT_NOTNULL(pair_bonus, "A Gorilla pair has no set bonus") + TEST_ASSERT(pair_bonus.bonus_active, "A Gorilla pair doesn't pry doors") + + var/mob/living/carbon/human/brawler_pair = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/pair_right = allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left/pair_left = allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left) + TEST_ASSERT(pair_right.Insert(brawler_pair, special = TRUE), "The right Brawler arm wouldn't go in") + TEST_ASSERT(pair_left.Insert(brawler_pair, special = TRUE), "The left Brawler arm wouldn't go in") + TEST_ASSERT_NULL(brawler_pair.has_status_effect(/datum/status_effect/organ_set_bonus/cyberware_gorilla), "A Brawler pair got the door-pry set bonus") + + var/mob/living/carbon/human/mixed_pair = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/mixed_real = allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left/mixed_street = allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left) + TEST_ASSERT(mixed_real.Insert(mixed_pair, special = TRUE), "The real arm wouldn't go in beside a Brawler arm") + TEST_ASSERT(mixed_street.Insert(mixed_pair, special = TRUE), "The Brawler arm wouldn't go in beside a real arm") + var/datum/status_effect/organ_set_bonus/mixed_bonus = mixed_pair.has_status_effect(/datum/status_effect/organ_set_bonus/cyberware_gorilla) + TEST_ASSERT(!mixed_bonus?.bonus_active, "A real Gorilla arm and a Brawler arm pry doors") + + // A punch lands for the model's damage and starts the model's clock. + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/mob/living/carbon/human/puncher = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left/punch_arm = allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left) + TEST_ASSERT(punch_arm.Insert(puncher, special = TRUE), "The punching arm wouldn't go in") + puncher.set_combat_mode(TRUE) + puncher.swap_hand(1) + TEST_ASSERT_EQUAL(puncher.get_active_hand()?.body_zone, BODY_ZONE_L_ARM, "The test puncher isn't swinging with the left arm") + var/hurt_before = victim.getBruteLoss() + TEST_ASSERT_EQUAL(punch_arm.on_punch(puncher, victim, TRUE, null), COMPONENT_CANCEL_ATTACK_CHAIN, "The Brawler arm didn't take the punch") + TEST_ASSERT_EQUAL(victim.getBruteLoss() - hurt_before, 14, "A Brawler punch didn't land 14") + TEST_ASSERT_EQUAL(COOLDOWN_TIMELEFT(punch_arm, slam_cooldown), 6 SECONDS, "A Brawler punch didn't start the six second clock") + TEST_ASSERT_NOTEQUAL(punch_arm.on_punch(puncher, victim, TRUE, null), COMPONENT_CANCEL_ATTACK_CHAIN, "A Brawler arm punched again on its clock") + +/// Tinker's fingers: the Fixer's three tools and a weaker servo speed-up under +/// its own key, so it never replaces the Fixer's. +/datum/unit_test/voidcrew_cyberware_models_a_fixers + +/datum/unit_test/voidcrew_cyberware_models_a_fixers/Run() + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/parent = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street/street = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street) + + TEST_ASSERT_EQUAL(parent.speed_modifier, /datum/actionspeed_modifier/cyberware_fixers, "The Fixer's speed-up changed") + TEST_ASSERT_EQUAL(get_cached_actionspeed_modifier(parent.speed_modifier).multiplicative_slowdown, -0.25, "The Fixer's speed-up isn't a quarter") + TEST_ASSERT_EQUAL(parent.chrome_load, 2, "The Fixer's load changed") + TEST_ASSERT_EQUAL(parent.tier, 1, "The Fixer's tier changed") + + TEST_ASSERT_EQUAL(street.speed_modifier, /datum/actionspeed_modifier/cyberware_fixers/street, "Tinker's fingers use the wrong speed-up") + TEST_ASSERT_EQUAL(get_cached_actionspeed_modifier(street.speed_modifier).multiplicative_slowdown, -0.1, "Tinker's speed-up isn't a tenth") + TEST_ASSERT_EQUAL(street.chrome_load, 1, "Tinker's load isn't 1") + TEST_ASSERT_EQUAL(street.tier, 1, "Tinker's fingers aren't tier 1") + TEST_ASSERT_EQUAL(street.slot, parent.slot, "Tinker's fingers aren't in the Fixer's arm slot") + TEST_ASSERT(street.speed_modifier != parent.speed_modifier, "Tinker's fingers would replace the Fixer's speed-up") + TEST_ASSERT_EQUAL(length(street.items_to_create), length(parent.items_to_create), "Tinker's fingers carry a different number of tools") + for(var/tool_path in parent.items_to_create) + TEST_ASSERT(tool_path in street.items_to_create, "Tinker's fingers lost [tool_path]") + + // The speed-up rides the running ware: on with the fingers in, off with them out. + var/mob/living/carbon/human/tinker = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + TEST_ASSERT(street.Insert(tinker, special = TRUE), "Tinker's fingers wouldn't go in") + TEST_ASSERT(tinker.has_actionspeed_modifier(street.speed_modifier), "Tinker's fingers gave no speed-up") + TEST_ASSERT(!tinker.has_actionspeed_modifier(parent.speed_modifier), "Tinker's fingers gave the Fixer's speed-up") + street.Remove(tinker, special = TRUE) + TEST_ASSERT(!tinker.has_actionspeed_modifier(street.speed_modifier), "The speed-up outlived Tinker's fingers") + + var/mob/living/carbon/human/fixer = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + TEST_ASSERT(parent.Insert(fixer, special = TRUE), "The Fixer's Fingers wouldn't go in") + TEST_ASSERT(fixer.has_actionspeed_modifier(parent.speed_modifier), "The Fixer's Fingers gave no speed-up") + TEST_ASSERT(!fixer.has_actionspeed_modifier(street.speed_modifier), "The Fixer's Fingers gave Tinker's speed-up") + +/// The Deepcore: a heavier drill on the Rockjaw's hopper and buttons. +/datum/unit_test/voidcrew_cyberware_models_a_deepcore + +/datum/unit_test/voidcrew_cyberware_models_a_deepcore/Run() + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/parent = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore/deepcore = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore) + TEST_ASSERT_EQUAL(parent.chrome_load, 2, "The Rockjaw's load changed") + TEST_ASSERT_EQUAL(parent.tier, 1, "The Rockjaw's tier changed") + TEST_ASSERT_EQUAL(deepcore.chrome_load, 3, "The Deepcore's load isn't 3") + TEST_ASSERT_EQUAL(deepcore.tier, 2, "The Deepcore isn't tier 2") + TEST_ASSERT_EQUAL(deepcore.slot, parent.slot, "The Deepcore isn't in the Rockjaw's arm slot") + + var/obj/item/pickaxe/drill/cyberware/rockjaw_drill = locate(/obj/item/pickaxe/drill/cyberware) in parent + var/obj/item/pickaxe/drill/cyberware/deepcore/deepcore_drill = locate(/obj/item/pickaxe/drill/cyberware/deepcore) in deepcore + TEST_ASSERT_NOTNULL(rockjaw_drill, "The Rockjaw has no drill") + TEST_ASSERT_EQUAL(rockjaw_drill.type, /obj/item/pickaxe/drill/cyberware, "The Rockjaw carries a different drill") + TEST_ASSERT_EQUAL(rockjaw_drill.toolspeed, 0.5, "The Rockjaw's drill changed speed") + TEST_ASSERT_NOTNULL(deepcore_drill, "The Deepcore has no Deepcore drill") + TEST_ASSERT_EQUAL(deepcore_drill.toolspeed, 0.3, "The Deepcore drill isn't 0.3") + var/obj/item/pickaxe/drill/diamonddrill/diamond = allocate(/obj/item/pickaxe/drill/diamonddrill) + TEST_ASSERT(deepcore_drill.toolspeed > diamond.toolspeed, "The Deepcore drill is faster than a diamond drill") + TEST_ASSERT(deepcore_drill.toolspeed < rockjaw_drill.toolspeed, "The Deepcore drill isn't faster than the Rockjaw's") + + // The hopper button comes along. + TEST_ASSERT(locate(/datum/action/item_action/organ_action/rockjaw_hopper) in deepcore.actions, "The Deepcore lost the ore hopper button") + +/// The Holdout: six rounds of ordinary .38 in a folding arm revolver, thumbed in +/// by hand a round at a time, and the housing shows when someone looks at the arm. +/datum/unit_test/voidcrew_cyberware_models_a_holdout + +/datum/unit_test/voidcrew_cyberware_models_a_holdout/Run() + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/ronin = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout/holdout = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout) + + // The parent is untouched. + TEST_ASSERT_EQUAL(ronin.chrome_load, 5, "The Ronin's load changed") + TEST_ASSERT_EQUAL(ronin.tier, 3, "The Ronin's tier changed") + TEST_ASSERT(/obj/item/gun/ballistic/automatic/cyberware_ronin in ronin.items_to_create, "The Ronin lost its machine pistol") + + TEST_ASSERT_EQUAL(holdout.chrome_load, 3, "The Holdout's load isn't 3") + TEST_ASSERT_EQUAL(holdout.tier, 2, "The Holdout isn't tier 2") + TEST_ASSERT_EQUAL(holdout.slot, ronin.slot, "The Holdout isn't in the Ronin's arm slot") + TEST_ASSERT_EQUAL(holdout.zone, ronin.zone, "The Holdout isn't in the Ronin's arm zone") + TEST_ASSERT_EQUAL(length(holdout.items_to_create), 1, "The Holdout carries more than the one revolver") + TEST_ASSERT(!(/obj/item/gun/ballistic/automatic/cyberware_ronin in holdout.items_to_create), "The Holdout carries the Ronin's machine pistol") + + // A loaded six-shot .38 with a slow trigger. + var/obj/item/gun/ballistic/revolver/cyberware_holdout/gun = locate() in holdout + TEST_ASSERT_NOTNULL(gun, "The Holdout housing holds no revolver") + TEST_ASSERT_EQUAL(gun.magazine.max_ammo, 6, "The Holdout's cylinder isn't six chambers") + TEST_ASSERT_EQUAL(gun.magazine.caliber, CALIBER_38, "The Holdout doesn't take .38") + TEST_ASSERT_EQUAL(gun.get_ammo(FALSE, FALSE), 6, "The Holdout doesn't leave the shop loaded") + TEST_ASSERT_EQUAL(gun.fire_delay, 0.4 SECONDS, "The Holdout's trigger isn't 0.4 seconds") + TEST_ASSERT(!gun.magazine.multiload, "The Holdout's cylinder loads a speedloader at once") + + // The cylinder takes a speedloader a round at a time. An ordinary revolver + // cylinder takes all it has room for. + var/mob/living/carbon/human/loader = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/obj/item/ammo_box/magazine/internal/cylinder/rev38/cyberware_holdout/cylinder = allocate(/obj/item/ammo_box/magazine/internal/cylinder/rev38/cyberware_holdout) + var/obj/item/ammo_box/magazine/internal/cylinder/rev38/detective_cylinder = allocate(/obj/item/ammo_box/magazine/internal/cylinder/rev38) + for(var/obj/item/ammo_box/magazine/internal/cylinder/rev38/emptied as anything in list(cylinder, detective_cylinder)) + // ammo_list() spawns the cylinder's lazy rounds, so there is something to remove. + for(var/obj/item/ammo_casing/round in emptied.ammo_list()) + qdel(round) + TEST_ASSERT_EQUAL(emptied.ammo_count(FALSE), 0, "A test cylinder didn't empty") + var/obj/item/ammo_box/c38/speedloader = allocate(/obj/item/ammo_box/c38) + var/obj/item/ammo_box/c38/second_speedloader = allocate(/obj/item/ammo_box/c38) + TEST_ASSERT_EQUAL(cylinder.attackby(speedloader, loader, null, TRUE), 1, "The Holdout cylinder took more than a round from a speedloader") + TEST_ASSERT_EQUAL(detective_cylinder.attackby(second_speedloader, loader, null, TRUE), 6, "An ordinary .38 cylinder stopped taking a whole speedloader") + + // A full cylinder has no room, a spent chamber or an empty one has. + TEST_ASSERT(!gun.cylinder_has_room(), "A full Holdout cylinder claims room") + var/list/rounds = gun.magazine.ammo_list() + var/obj/item/ammo_casing/first_round = rounds[1] + QDEL_NULL(first_round.loaded_projectile) + TEST_ASSERT(gun.cylinder_has_room(), "A spent chamber doesn't count as room") + // A full cylinder is refused outright, with no wait. + var/obj/item/gun/ballistic/revolver/cyberware_holdout/full_gun = allocate(/obj/item/gun/ballistic/revolver/cyberware_holdout) + TEST_ASSERT(!full_gun.load_gun(speedloader, loader), "A full Holdout cylinder took another round") + TEST_ASSERT_EQUAL(full_gun.get_ammo(FALSE, FALSE), 6, "Refusing a round cost the Holdout one") + + // The housing shows when someone looks at the bearer, and stops when it is gone. + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/mob/living/carbon/human/onlooker = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/list/before_lines = list() + SEND_SIGNAL(bearer, COMSIG_ATOM_EXAMINE, onlooker, before_lines) + TEST_ASSERT(!findtext(jointext(before_lines, " "), "revolver housing"), "A body with no Holdout shows a revolver housing") + TEST_ASSERT(holdout.Insert(bearer, special = TRUE), "The Holdout housing wouldn't go in") + var/list/lines = list() + SEND_SIGNAL(bearer, COMSIG_ATOM_EXAMINE, onlooker, lines) + TEST_ASSERT(findtext(jointext(lines, " "), "revolver housing"), "Looking at the bearer doesn't show the Holdout housing") + holdout.Remove(bearer, special = TRUE) + var/list/after_lines = list() + SEND_SIGNAL(bearer, COMSIG_ATOM_EXAMINE, onlooker, after_lines) + TEST_ASSERT(!findtext(jointext(after_lines, " "), "revolver housing"), "The housing still shows after it was pulled") + +/// What the shelf asks and what the ledger pays, per the plan. Every ledger +/// entry is half the shelf price of the piece, credits only, and takes both +/// ripperdocs' wording. The Vaulter is voucher chrome and has no entry. +/datum/unit_test/voidcrew_cyberware_models_a_shelf + +/datum/unit_test/voidcrew_cyberware_models_a_shelf/Run() + // SKU, what it sells, credits, vouchers, zone permission (0 public, 1 yellow, 2 advanced), pieces per sale + var/list/shelf = list( + list(/datum/shop_sku/ripperdoc/hopper_street, /obj/item/organ/cyberimp/cyberware/hopper/street, 1800, 0, 1, 1), + list(/datum/shop_sku/ripperdoc/hopper_milspec, /obj/item/organ/cyberimp/cyberware/hopper/milspec, 0, 3, 2, 1), + list(/datum/shop_sku/ripperdoc/gorilla_street, /obj/item/cyberware_pair_case/gorilla_arms/street, 2400, 0, 1, 2), + list(/datum/shop_sku/ripperdoc/fixers_street, /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street, 700, 0, 0, 1), + list(/datum/shop_sku/ripperdoc/rockjaw_deepcore, /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore, 2400, 0, 0, 1), + list(/datum/shop_sku/ripperdoc/ronin_holdout, /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout, 2400, 0, 1, 1), + ) + build_cyberware_price_index() + for(var/list/row as anything in shelf) + var/datum/shop_sku/sku = allocate(row[1]) + TEST_ASSERT_EQUAL(sku.item_path, row[2], "[row[1]] sells the wrong item") + TEST_ASSERT_EQUAL(sku.price_credits, row[3], "[row[1]] asks the wrong credits") + TEST_ASSERT_EQUAL(sku.price_vouchers, row[4], "[row[1]] asks the wrong vouchers") + TEST_ASSERT_EQUAL(sku.zone_permission, row[5], "[row[1]] sits at the wrong reputation") + TEST_ASSERT(sku.price_credits <= 0 || sku.category == "Street Chrome", "[row[1]] is credit chrome off the street shelf") + TEST_ASSERT(sku.price_vouchers <= 0 || sku.category == "Military Chrome", "[row[1]] is voucher chrome off the military shelf") + if(row[1] == /datum/shop_sku/ripperdoc/ronin_holdout || row[1] == /datum/shop_sku/ripperdoc/gorilla_street) + TEST_ASSERT(sku.fixed_price && !sku.contract_reward, "[row[1]] can be discounted or handed out as a contract reward") + + // What the cradle quotes for the pieces. A pair quotes the whole case on each half. + var/list/quote = GLOB.cyberware_price_index[/obj/item/organ/cyberimp/cyberware/hopper/street] + TEST_ASSERT(quote && quote["credits"] == 1800 && !quote["vouchers"], "Spring calves quote the wrong price") + quote = GLOB.cyberware_price_index[/obj/item/organ/cyberimp/cyberware/hopper/milspec] + TEST_ASSERT(quote && quote["vouchers"] == 3 && !quote["credits"], "Vaulter pistons quote the wrong price") + for(var/pair_half in list(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street, /obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left)) + quote = GLOB.cyberware_price_index[pair_half] + TEST_ASSERT(quote && quote["credits"] == 2400 && quote["paired"], "[pair_half] doesn't quote the pair's price") + quote = GLOB.cyberware_price_index[/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout] + TEST_ASSERT(quote && quote["credits"] == 2400, "The Holdout quotes the wrong price") + + // Ledger: half the shelf price of one piece, credits only, either side of a pair. + var/list/ledgers = list( + list(/datum/shop_buyback/ripperdoc/hopper_street, /datum/shop_buyback/ripperdoc/hopper_street/street_doc, /obj/item/organ/cyberimp/cyberware/hopper/street, 900), + list(/datum/shop_buyback/ripperdoc/gorilla_street, /datum/shop_buyback/ripperdoc/gorilla_street/street_doc, /obj/item/organ/cyberimp/cyberware/gorilla_arms/street, 600), + list(/datum/shop_buyback/ripperdoc/fixers_street, /datum/shop_buyback/ripperdoc/fixers_street/street_doc, /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street, 350), + list(/datum/shop_buyback/ripperdoc/ronin_holdout, /datum/shop_buyback/ripperdoc/ronin_holdout/street_doc, /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout, 1200), + ) + for(var/list/row as anything in ledgers) + for(var/ledger_index in 1 to 2) + var/datum/shop_buyback/ledger = allocate(row[ledger_index]) + TEST_ASSERT_EQUAL(ledger.item_path, row[3], "[row[ledger_index]] buys the wrong piece") + TEST_ASSERT_EQUAL(ledger.pay_credits, row[4], "[row[ledger_index]] pays the wrong credits") + TEST_ASSERT(!ledger.pay_vouchers, "[row[ledger_index]] pays vouchers") + TEST_ASSERT(ledger.takes_shop_stock(), "[row[ledger_index]] refuses chrome bought at a counter") + + // Either side of a Brawler pair is taken, and nothing else of the Gorilla line. + var/datum/shop_buyback/brawler_ledger = allocate(/datum/shop_buyback/ripperdoc/gorilla_street) + TEST_ASSERT(brawler_ledger.matches(allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street)), "The ledger won't take a right Brawler arm") + TEST_ASSERT(brawler_ledger.matches(allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left)), "The ledger won't take a left Brawler arm") + TEST_ASSERT(!brawler_ledger.matches(allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms)), "The Brawler ledger takes a real Gorilla arm") + var/datum/shop_buyback/spring_ledger = allocate(/datum/shop_buyback/ripperdoc/hopper_street) + TEST_ASSERT(!spring_ledger.matches(allocate(/obj/item/organ/cyberimp/cyberware/hopper)), "The Spring calves ledger takes a Hopper") + TEST_ASSERT(!spring_ledger.matches(allocate(/obj/item/organ/cyberimp/cyberware/hopper/milspec)), "The Spring calves ledger takes Vaulter pistons") + var/datum/shop_buyback/tinker_ledger = allocate(/datum/shop_buyback/ripperdoc/fixers_street) + TEST_ASSERT(!tinker_ledger.matches(allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers)), "The Tinker's ledger takes the Fixer's Fingers") From 0b3a6413540ab7fcd3980406f4fc2def2c4d5be9 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:08:24 -0800 Subject: [PATCH 130/472] Add zoom to every chrome optic, and five new optic models Every chrome optic gets a zoom button that pans the view with tg's scope, further on better optics. A zoomed bearer can't shoot and their clicks reach nothing, except a cyberdeck through COMSIG_CYBERWARE_ZOOM_CLICK. The zoom drops on EMP, brownout, damage failure, removal, blindness, a flash, a stun and any stat change. New optics: Spyglass, Longsight and Lookout, plus Assay optics (a cheaper Prospector) and the Marksman link (a service-grade Deadeye). Chrome Read range and cooldown, Deadeye tag numbers and the Prospector pulse range are now vars with their old values. --- tgstation.dme | 2 + tools/gear_catalog/ratings/organs.json | 87 +++- voidcrew/_DEFINES/cyberware.dm | 25 ++ voidcrew/modules/cyberware/chrome_wheel.dm | 5 +- voidcrew/modules/cyberware/cyberware_base.dm | 6 + voidcrew/modules/cyberware/cyberware_scan.dm | 18 +- voidcrew/modules/cyberware/cyberware_zoom.dm | 253 ++++++++++++ voidcrew/modules/cyberware/ware_line_zoom.dm | 210 ++++++++++ voidcrew/modules/cyberware/ware_pro_combat.dm | 16 +- .../modules/cyberware/ware_pro_utility.dm | 14 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_zoom.dm | 371 ++++++++++++++++++ 12 files changed, 977 insertions(+), 31 deletions(-) create mode 100644 voidcrew/modules/cyberware/cyberware_zoom.dm create mode 100644 voidcrew/modules/cyberware/ware_line_zoom.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_zoom.dm diff --git a/tgstation.dme b/tgstation.dme index f8d5c7fe49b..beec292fa3c 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7183,6 +7183,7 @@ #include "voidcrew\modules\cyberware\cyberware_scan.dm" #include "voidcrew\modules\cyberware\cyberware_scan_overlay.dm" #include "voidcrew\modules\cyberware\cyberware_stealth.dm" +#include "voidcrew\modules\cyberware\cyberware_zoom.dm" #include "voidcrew\modules\cyberware\parlor_fixtures.dm" #include "voidcrew\modules\cyberware\ripperdoc_catalog.dm" #include "voidcrew\modules\cyberware\street_doc_catalog.dm" @@ -7195,6 +7196,7 @@ #include "voidcrew\modules\cyberware\ware_line_models_a.dm" #include "voidcrew\modules\cyberware\ware_line_sandy.dm" #include "voidcrew\modules\cyberware\ware_line_widowline.dm" +#include "voidcrew\modules\cyberware\ware_line_zoom.dm" #include "voidcrew\modules\cyberware\ware_military_arms.dm" #include "voidcrew\modules\cyberware\ware_military_body.dm" #include "voidcrew\modules\cyberware\ware_models_b.dm" diff --git a/tools/gear_catalog/ratings/organs.json b/tools/gear_catalog/ratings/organs.json index fba7540a1e9..c3b3e88ea39 100644 --- a/tools/gear_catalog/ratings/organs.json +++ b/tools/gear_catalog/ratings/organs.json @@ -429,7 +429,7 @@ "group": "Eyes", "hash": "970e50f04a7a2db8", "name": "eyes", - "rank": 18, + "rank": 23, "reason": "Plain replacement eyes.", "tier": "none" }, @@ -438,7 +438,7 @@ "group": "Eyes", "hash": "07ec6668fecdcd61", "name": "moth eyes", - "rank": 19, + "rank": 24, "reason": "Plain replacement eyes.", "tier": "none" }, @@ -447,7 +447,7 @@ "group": "Eyes", "hash": "3e1ed2dde4757134", "name": "fung-eye", - "rank": 8, + "rank": 11, "reason": "Night vision you can toggle, with no neural load. Placed in one jungle ruin. Same job as night vision goggles.", "tier": "yellow" }, @@ -456,7 +456,7 @@ "group": "Eyes", "hash": "84a3161d476535dd", "name": "robotic eyes", - "rank": 11, + "rank": 16, "reason": "A working pair of robotic eyes, no better than your own.", "tier": "none" }, @@ -465,7 +465,7 @@ "group": "Eyes", "hash": "7cc5a72a291dbd8a", "name": "basic robotic eyes", - "rank": 13, + "rank": 18, "reason": "Cheap replacement eyes, weak to flashes.", "tier": "none" }, @@ -474,7 +474,7 @@ "group": "Eyes", "hash": "4ba3d5231be60398", "name": "basic robotic moth eyes", - "rank": 14, + "rank": 19, "reason": "Cheap replacement eyes, weak to flashes.", "tier": "none" }, @@ -483,8 +483,35 @@ "group": "Eyes", "hash": "8ea026e0bf1e0602", "name": "Deadeye link", - "rank": 5, - "reason": "Tag a target and your next three shots curve onto it, every 25 s. Bullets stop missing for a burst, but the target gets a warning and walls stop it. Load 3, 2 vouchers with no reputation.", + "rank": 7, + "reason": "Tag a target and your next three shots curve onto it, every 25 s. Bullets stop missing for a burst, but the target gets a warning and walls stop it. Also zooms about 14 tiles out at a slight slowdown, though nobody can shoot while zoomed. Load 3, 2 vouchers at yellow reputation.", + "tier": "yellow" + }, + "/obj/item/organ/eyes/robotic/cyberware/deadeye/milspec": { + "confidence": "medium", + "group": "Eyes", + "hash": "", + "name": "marksman link", + "rank": 3, + "reason": "Tag a target and your next four shots curve onto it, every 18 s, out to 10 tiles. Also zooms the view about 19 tiles out at full speed, though nobody can shoot while zoomed. Load 4, 3 vouchers at advanced reputation.", + "tier": "red" + }, + "/obj/item/organ/eyes/robotic/cyberware/longsight": { + "confidence": "medium", + "group": "Eyes", + "hash": "", + "name": "longsight optics", + "rank": 9, + "reason": "Zooms the view about 19 tiles out at full speed and names implants out to 9 tiles. Scouting only, no help in a fight. Load 2, 2,400 cr at yellow reputation.", + "tier": "yellow" + }, + "/obj/item/organ/eyes/robotic/cyberware/lookout": { + "confidence": "medium", + "group": "Eyes", + "hash": "", + "name": "lookout optics", + "rank": 6, + "reason": "Zooms the view about 24 tiles out at full speed, sees in the dark, and names every implant on a body out to 11 tiles every 8 s. Pure scouting: no shooting while zoomed. Load 3, 3 vouchers at advanced reputation.", "tier": "yellow" }, "/obj/item/organ/eyes/robotic/cyberware/nightshade": { @@ -493,8 +520,8 @@ "hash": "030567541e7e32a4", "name": "Nightshade optics", "note": "Gives night vision with a green tint and puts a glow, visible only to the wearer, on every living body in view even in the dark (corpses glow dimmer). Flash protection stays at the robotic default, and its chrome scan only counts implants without naming them.", - "rank": 6, - "reason": "Night vision with no flash weakness and a glow on every living body in view. Beats night vision goggles for 600 cr and load 1.", + "rank": 8, + "reason": "Night vision with no flash weakness and a glow on every living body in view. Beats night vision goggles for 600 cr and load 1. Also zooms about 9 tiles out at a walk.", "tier": "yellow" }, "/obj/item/organ/eyes/robotic/cyberware/prospector": { @@ -502,16 +529,34 @@ "group": "Eyes", "hash": "5f70c88fa7552793", "name": "Prospector Suite optics", - "rank": 7, - "reason": "A pulse every 10 s shows ore through rock and loot caches within 9 tiles. Great for mining and ruin crews, no help in a fight. Load 2, 2 vouchers.", + "rank": 10, + "reason": "A pulse every 10 s shows ore through rock and loot caches within 9 tiles. Great for mining and ruin crews, no help in a fight. Also zooms about 14 tiles out at a slight slowdown. Load 2, 1 voucher at yellow reputation.", "tier": "yellow" }, + "/obj/item/organ/eyes/robotic/cyberware/prospector/street": { + "confidence": "high", + "group": "Eyes", + "hash": "", + "name": "assay optics", + "rank": 12, + "reason": "A 5-tile ore pulse through rock every 15 s with no cache marks, plus a short zoom that slows you. A cheap start for miners. Load 1, 900 cr.", + "tier": "green" + }, + "/obj/item/organ/eyes/robotic/cyberware/spyglass": { + "confidence": "high", + "group": "Eyes", + "hash": "", + "name": "spyglass optics", + "rank": 13, + "reason": "A short zoom, about 9 tiles, that slows you while you look, and a chrome read that only counts implants. Binoculars reach further but take both hands. Load 1, 400 cr.", + "tier": "green" + }, "/obj/item/organ/eyes/robotic/flashlight": { "confidence": "high", "group": "Eyes", "hash": "31bf05befe1e2bdf", "name": "flashlight eyes", - "rank": 17, + "rank": 22, "reason": "Flashlights strapped into your skull. You can't see through them. A joke.", "tier": "none" }, @@ -520,7 +565,7 @@ "group": "Eyes", "hash": "121bc2c724450f5d", "name": "high luminosity eyes", - "rank": 15, + "rank": 20, "reason": "A coloured torch in your face. Useful in the dark, costs a load.", "tier": "none" }, @@ -529,7 +574,7 @@ "group": "Eyes", "hash": "880f3fcfed561cbf", "name": "high luminosity moth eyes", - "rank": 16, + "rank": 21, "reason": "A coloured torch in your face. Useful in the dark, costs a load.", "tier": "none" }, @@ -538,7 +583,7 @@ "group": "Eyes", "hash": "1719a4848fac0fdb", "name": "robotic moth eyes", - "rank": 12, + "rank": 17, "reason": "A working pair of robotic eyes, no better than your own.", "tier": "none" }, @@ -547,7 +592,7 @@ "group": "Eyes", "hash": "5d54a8f40d09d8b4", "name": "shielded robotic eyes", - "rank": 9, + "rank": 14, "reason": "Flash immunity with no tint, and EMP proof. Better sunglasses that don't take the slot. About 2 cr of material.", "tier": "green" }, @@ -556,7 +601,7 @@ "group": "Eyes", "hash": "617e9ddf30f1f6f4", "name": "shielded robotic moth eyes", - "rank": 10, + "rank": 15, "reason": "Moth looks on shielded eyes. Flash immunity with no tint.", "tier": "green" }, @@ -565,7 +610,7 @@ "group": "Eyes", "hash": "2abaf20763c58a3e", "name": "thermal eyes", - "rank": 3, + "rank": 4, "reason": "Same sight as thermal goggles, every living thing through walls, but sunglasses over them cancel the flash weakness. Better than the goggles, and they print with no reputation. An EMP flashes them.", "tier": "yellow" }, @@ -574,7 +619,7 @@ "group": "Eyes", "hash": "1436defdd6c14505", "name": "thermal moth eyes", - "rank": 4, + "rank": 5, "reason": "Same as thermal eyes with moth looks. Living things through walls, flash weak.", "tier": "yellow" }, @@ -601,7 +646,7 @@ "group": "Eyes", "hash": "927d6a5cf2f19d2f", "name": "snail eyes", - "rank": 20, + "rank": 25, "reason": "Plain replacement eyes.", "tier": "none" }, diff --git a/voidcrew/_DEFINES/cyberware.dm b/voidcrew/_DEFINES/cyberware.dm index f161a8cc65b..cf9dc5e30c8 100644 --- a/voidcrew/_DEFINES/cyberware.dm +++ b/voidcrew/_DEFINES/cyberware.dm @@ -147,6 +147,31 @@ /// breathable, mirroring the lungs' safe_oxygen_min of 16. #define CYBERWARE_BREATHABLE_O2_KPA 16 +// ---- Optics zoom (cyberware_zoom.dm) ----------------------------------- +// Every set of chrome optics can zoom: tg's scope component pans the camera +// toward the cursor. A zoomed bearer can't fire a gun and their clicks reach +// nothing on the map, except a cyberdeck through the signal below. + +/// Held by a bearer while their chrome optics are zoomed. Source is the optics. +#define TRAIT_CYBER_ZOOMED "cyber_zoomed" + +/** + * Sent on a zoomed bearer when they click the zoomed view, instead of the click + * going anywhere else: (atom/target, list/modifiers). [target] is the best atom + * on the turf under the cursor, the one a scoped gun would shoot (a mob, then a + * dense object, then any other object, else the turf itself). [modifiers] is + * the click's params2list(). Nothing checks range or line of sight first, so a + * listener that acts on [target] has to. + * + * Zoomed clicks never reach click intercepts, so an armed click-to-activate + * action does not see them. A listener that handles one (a cyberdeck firing its + * armed hack) returns COMPONENT_CYBERWARE_ZOOM_CLICK_HANDLED and disarms itself. + * Unhandled, the click is dropped: zoom is look-only. + */ +#define COMSIG_CYBERWARE_ZOOM_CLICK "cyberware_zoom_click" + /// Returned by a COMSIG_CYBERWARE_ZOOM_CLICK listener that acted on the click. + #define COMPONENT_CYBERWARE_ZOOM_CLICK_HANDLED (1<<0) + // ---- Chrome wheel (chrome_wheel.dm) ------------------------------------ /// Name of the "Chrome wheel" keybind; the release poll reads the player's diff --git a/voidcrew/modules/cyberware/chrome_wheel.dm b/voidcrew/modules/cyberware/chrome_wheel.dm index 0c001d843e4..9cda3523496 100644 --- a/voidcrew/modules/cyberware/chrome_wheel.dm +++ b/voidcrew/modules/cyberware/chrome_wheel.dm @@ -54,10 +54,11 @@ GLOBAL_LIST_INIT(chrome_wheel_slot_order, list( /// Positions a region holds for one organ slot, filled or not, so the rest of /// that region never moves when the slot is filled or emptied. Region => /// list(slot, positions held). The OS owns the top of the torso ring; chrome -/// optics (their ability plus the chrome read) own the top two head positions. +/// optics (their ability, the chrome read and the zoom) own the first row of +/// head positions. GLOBAL_LIST_INIT(chrome_wheel_reserved, list( CHROME_WHEEL_TORSO = list(ORGAN_SLOT_CYBERWARE_OS, 1), - CHROME_WHEEL_HEAD = list(ORGAN_SLOT_EYES, 2), + CHROME_WHEEL_HEAD = list(ORGAN_SLOT_EYES, 3), )) // ---- Collection -------------------------------------------------------- diff --git a/voidcrew/modules/cyberware/cyberware_base.dm b/voidcrew/modules/cyberware/cyberware_base.dm index c3336940061..28bc56bef1f 100644 --- a/voidcrew/modules/cyberware/cyberware_base.dm +++ b/voidcrew/modules/cyberware/cyberware_base.dm @@ -155,6 +155,10 @@ * Every optic on this base carries the chrome read (cyberware_scan.dm), the * diagnostic bus is what makes chrome eyes chrome eyes. Subtypes that add * their own ability must keep the read in their actions_types list. + * + * Every optic also zooms (cyberware_zoom.dm). That button is added in + * Initialize() whenever optics_zoom is above 0, so it never goes in + * actions_types. */ /obj/item/organ/eyes/robotic/cyberware name = "cyberware optics" @@ -174,6 +178,8 @@ /obj/item/organ/eyes/robotic/cyberware/Initialize(mapload) . = ..() AddComponent(/datum/component/cyberware, chrome_load, tier, chrome_capacity_bonus) + // Zoom rides every optic on this base (cyberware_zoom.dm). + setup_optics_zoom() /obj/item/organ/eyes/robotic/cyberware/Destroy() var/turf/drop_turf = get_turf(src) diff --git a/voidcrew/modules/cyberware/cyberware_scan.dm b/voidcrew/modules/cyberware/cyberware_scan.dm index f470a9a7e70..3287ad02cbc 100644 --- a/voidcrew/modules/cyberware/cyberware_scan.dm +++ b/voidcrew/modules/cyberware/cyberware_scan.dm @@ -38,6 +38,14 @@ /// robotic that isn't parlor chrome. #define CYBERWARE_SCAN_UNRATED_COLOR "#8fa1a8" +// Reach and pacing belong to the optics, so a scout model can read further or +// faster. The defaults are the read's stock numbers. +/obj/item/organ/eyes/robotic/cyberware + /// How far this set of eyes can read a body, in tiles, with line of sight. + var/chrome_read_range = CYBERWARE_SCAN_RANGE + /// Cooldown on this set of eyes' chrome read. + var/chrome_read_cooldown = CYBERWARE_SCAN_COOLDOWN + // ---- Readout ----------------------------------------------------------- /** @@ -169,6 +177,12 @@ /// The readout this read is drawing for our bearer. A new read replaces it. var/datum/chrome_readout/current_readout +/datum/action/cooldown/cyberware/chrome_read/New(Target, original = TRUE) + . = ..() + var/obj/item/organ/eyes/robotic/cyberware/optics = organ + if(istype(optics)) + cooldown_time = optics.chrome_read_cooldown + /datum/action/cooldown/cyberware/chrome_read/Destroy() QDEL_NULL(current_readout) return ..() @@ -205,7 +219,7 @@ if(bearer.is_blind()) bearer.balloon_alert(bearer, "optics dark!") return FALSE - if(!self_read && !can_see(bearer, subject, CYBERWARE_SCAN_RANGE)) + if(!self_read && !can_see(bearer, subject, optics.chrome_read_range)) bearer.balloon_alert(bearer, "no line of sight!") return FALSE // Ghostskin scatters the handshake the same way it scatters NPC targeting: @@ -237,7 +251,7 @@ // flinches on their screen, and their chat names the reader. if(subject_feels_it) chrome_read_flinch(subject) - if(can_see(subject, bearer, CYBERWARE_SCAN_RANGE)) + if(can_see(subject, bearer, optics.chrome_read_range)) to_chat(subject, span_warning("[bearer] is reading your hardware. Every piece you have just answered a handshake you never authorized.")) else to_chat(subject, span_warning("Your hardware answers a handshake you never authorized. Someone nearby is reading you.")) diff --git a/voidcrew/modules/cyberware/cyberware_zoom.dm b/voidcrew/modules/cyberware/cyberware_zoom.dm new file mode 100644 index 00000000000..29d6517f2f3 --- /dev/null +++ b/voidcrew/modules/cyberware/cyberware_zoom.dm @@ -0,0 +1,253 @@ +/** + * # Optics zoom + * + * Every set of chrome optics (/obj/item/organ/eyes/robotic/cyberware and its + * subtypes) can zoom: one toggle button pans the bearer's camera toward the + * cursor, using tg's scope component (code/datums/components/scope.dm), the + * same one binoculars and rifle scopes use. How far it reaches is the optics' + * optics_zoom, passed straight through as the scope's range_modifier; better + * optics reach further, and the cheap ones slow their bearer while they look. + * + * The button rides the /datum/action/cooldown/cyberware bridge, so browned-out, + * EMP-scrambled or failing eyes can't zoom, and the chrome wheel collects it + * like any other chrome button. + * + * Zoom is look-only: + * - A zoomed bearer can't fire a gun (COMSIG_MOB_TRYING_TO_FIRE_GUN is refused, + * and a burst already under autofire stops when the zoom starts). + * - Their clicks on the zoomed view reach nothing on the map. The scope's + * fullscreen cursor catcher already eats them; check_click_intercept() below + * takes them before any click intercept or ClickOn() sees them, and offers + * each one to COMSIG_CYBERWARE_ZOOM_CLICK. That signal is the cyberdeck's way + * in (owner decision D10: hacks reach targets seen through the zoom). The + * contract is on the define in voidcrew/_DEFINES/cyberware.dm. + * + * The zoom drops on its own when the eyes stop working or the bearer can't + * look: EMP, brownout or damage failure (chrome_passives_off()), the eyes + * coming out, blindness, a flash, a stun, knockdown or paralysis, any stat + * change (crit, sleep, death), and everything the scope already stops for + * (no client, climbing into a locker or a mech, the organ moving). + */ + +/obj/item/organ/eyes/robotic/cyberware + /// How far the zoom pans, as the scope's range_modifier: 1 reaches as far + /// again as the edge of the normal view, 2 twice that. 0 means no zoom. + var/optics_zoom = 1 + /// Slowdown while zoomed (a multiplicative_slowdown). 0 for none. + var/zoom_walk_penalty = 1 + /// Throttles the "can't aim" balloon, so autofire and click spam don't stack it. + COOLDOWN_DECLARE(zoom_aim_warning) + /// Throttles the iris tell, so flicking the zoom on and off can't flood chat. + COOLDOWN_DECLARE(zoom_tell) + +/// Gives these optics their zoom: the scope that does the panning, and the +/// button. Called from Initialize() on the optics base (cyberware_base.dm). +/obj/item/organ/eyes/robotic/cyberware/proc/setup_optics_zoom() + if(optics_zoom <= 0) + return + // ZOOM_METHOD_ITEM_ACTION with no item_action_type: the scope adds no button + // and no examine line of its own, and only zooms when we tell it to. + AddComponent(/datum/component/scope, range_modifier = optics_zoom, zoom_method = ZOOM_METHOD_ITEM_ACTION) + add_item_action(/datum/action/cooldown/cyberware/optics_zoom) + +/// Whether our bearer is looking through these optics' zoom right now. +/obj/item/organ/eyes/robotic/cyberware/proc/is_zoomed() + return owner && HAS_TRAIT_FROM(owner, TRAIT_CYBER_ZOOMED, REF(src)) + +/// Starts the zoom for our bearer. Returns TRUE if they are now zoomed. +/obj/item/organ/eyes/robotic/cyberware/proc/start_zoom() + var/mob/living/carbon/bearer = owner + if(!iscarbon(bearer) || is_zoomed()) + return FALSE + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + var/datum/component/scope/scope = GetComponent(/datum/component/scope) + if(!scope || !chrome?.is_running()) + return FALSE + if(bearer.is_blind()) + bearer.balloon_alert(bearer, "optics dark!") + return FALSE + if(!isturf(bearer.loc)) + bearer.balloon_alert(bearer, "can't see out!") + return FALSE + if(HAS_TRAIT(bearer, TRAIT_INCAPACITATED)) + return FALSE + // The scope refuses a clientless bearer and anyone already scoped (a rifle, + // binoculars), with its own balloon for the second. + if(!scope.zoom(bearer)) + return FALSE + engage_zoom(bearer) + return TRUE + +/** + * Our half of a zoom, once the scope is panning: the trait, the stop hooks, + * the gun block and the walk penalty. Split from start_zoom() because the scope + * needs a client, and a unit test has none. + */ +/obj/item/organ/eyes/robotic/cyberware/proc/engage_zoom(mob/living/carbon/bearer) + ADD_TRAIT(bearer, TRAIT_CYBER_ZOOMED, REF(src)) + // The scope stops itself for a few reasons of its own (no client, the bearer + // climbing into something); it drops TRAIT_USER_SCOPED when it does. + RegisterSignal(bearer, SIGNAL_REMOVETRAIT(TRAIT_USER_SCOPED), PROC_REF(on_scope_lost)) + RegisterSignal(bearer, COMSIG_MOB_TRYING_TO_FIRE_GUN, PROC_REF(on_zoomed_fire)) + RegisterSignal(bearer, COMSIG_MOB_STATCHANGE, PROC_REF(on_zoomed_stat_change)) + RegisterSignal(bearer, COMSIG_MOB_FLASHED, PROC_REF(on_zoomed_flashed)) + RegisterSignal(bearer, COMSIG_LIVING_STATUS_APPLIED, PROC_REF(on_zoomed_status)) + RegisterSignals(bearer, list(COMSIG_LIVING_STATUS_STUN, COMSIG_LIVING_STATUS_KNOCKDOWN, COMSIG_LIVING_STATUS_PARALYZE), PROC_REF(on_zoomed_stunned)) + if(zoom_walk_penalty) + bearer.add_or_update_variable_movespeed_modifier(/datum/movespeed_modifier/cyberware_zoom, multiplicative_slowdown = zoom_walk_penalty) + // Autofire fires through process_fire() and never asks the signal above, so + // a burst already rolling when the zoom starts is stopped here instead. + for(var/obj/item/gun/held in bearer.held_items) + var/datum/component/automatic_fire/autofire = held.GetComponent(/datum/component/automatic_fire) + autofire?.stop_autofiring() + // The tell, the chrome read's pattern: only people close enough see it. + if(COOLDOWN_FINISHED(src, zoom_tell)) + COOLDOWN_START(src, zoom_tell, 5 SECONDS) + bearer.visible_message(span_notice("[bearer]'s irises whir and narrow."), vision_distance = 3, ignored_mobs = bearer) + update_zoom_button() + +/// Stops the zoom, scope and all. Safe to call when nothing is zoomed. +/obj/item/organ/eyes/robotic/cyberware/proc/stop_zoom(mob/living/carbon/bearer) + if(!bearer) + return + var/datum/component/scope/scope = GetComponent(/datum/component/scope) + // Only ever stop OUR scope: the fullscreen it clears is shared by every scope. + if(scope?.tracker && HAS_TRAIT_FROM(bearer, TRAIT_USER_SCOPED, REF(scope))) + scope.stop_zooming(bearer) // drops TRAIT_USER_SCOPED, so on_scope_lost() runs + disengage_zoom(bearer) + +/// Undoes engage_zoom(). Idempotent: the passive hook calls it on every flip. +/obj/item/organ/eyes/robotic/cyberware/proc/disengage_zoom(mob/living/carbon/bearer) + if(!bearer) + return + REMOVE_TRAIT(bearer, TRAIT_CYBER_ZOOMED, REF(src)) + UnregisterSignal(bearer, list( + SIGNAL_REMOVETRAIT(TRAIT_USER_SCOPED), + COMSIG_MOB_TRYING_TO_FIRE_GUN, + COMSIG_MOB_STATCHANGE, + COMSIG_MOB_FLASHED, + COMSIG_LIVING_STATUS_APPLIED, + COMSIG_LIVING_STATUS_STUN, + COMSIG_LIVING_STATUS_KNOCKDOWN, + COMSIG_LIVING_STATUS_PARALYZE, + )) + bearer.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_zoom) + update_zoom_button() + +/// The zoom button shows green while zoomed (the cooldown action's active look). +/obj/item/organ/eyes/robotic/cyberware/proc/update_zoom_button() + for(var/datum/action/cooldown/cyberware/optics_zoom/zoom in actions) + zoom.build_all_button_icons(UPDATE_BUTTON_STATUS) + +/// One short balloon when a zoomed bearer tries to shoot. +/obj/item/organ/eyes/robotic/cyberware/proc/refuse_zoomed_aim(mob/living/carbon/bearer) + if(!COOLDOWN_FINISHED(src, zoom_aim_warning)) + return + COOLDOWN_START(src, zoom_aim_warning, 1 SECONDS) + bearer.balloon_alert(bearer, "too zoomed in to aim!") + +// ---- Stop hooks ---------------------------------------------------------- + +/obj/item/organ/eyes/robotic/cyberware/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + // EMP reboot, brownout, damage failure and removal all come through here. + stop_zoom(bearer) + +/obj/item/organ/eyes/robotic/cyberware/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) + stop_zoom(organ_owner) + return ..() + +/// Signal proc for [SIGNAL_REMOVETRAIT] of TRAIT_USER_SCOPED: the scope stopped. +/obj/item/organ/eyes/robotic/cyberware/proc/on_scope_lost(mob/living/carbon/source) + SIGNAL_HANDLER + disengage_zoom(source) + +/// Signal proc for [COMSIG_MOB_TRYING_TO_FIRE_GUN]: no shooting through the zoom. +/obj/item/organ/eyes/robotic/cyberware/proc/on_zoomed_fire(mob/living/carbon/source, obj/item/gun/gun, atom/target, flag, params) + SIGNAL_HANDLER + refuse_zoomed_aim(source) + return COMPONENT_CANCEL_GUN_FIRE + +/// Signal proc for [COMSIG_MOB_STATCHANGE]: crit, sleep and death end the zoom. +/obj/item/organ/eyes/robotic/cyberware/proc/on_zoomed_stat_change(mob/living/carbon/source, new_stat, old_stat) + SIGNAL_HANDLER + if(new_stat != CONSCIOUS) + stop_zoom(source) + +/// Signal proc for [COMSIG_MOB_FLASHED]. +/obj/item/organ/eyes/robotic/cyberware/proc/on_zoomed_flashed(mob/living/carbon/source) + SIGNAL_HANDLER + stop_zoom(source) + +/// Signal proc for [COMSIG_LIVING_STATUS_APPLIED]: going blind ends the zoom. +/obj/item/organ/eyes/robotic/cyberware/proc/on_zoomed_status(mob/living/carbon/source, datum/status_effect/effect) + SIGNAL_HANDLER + if(istype(effect, /datum/status_effect/grouped/blindness)) + stop_zoom(source) + +/// Signal proc for stun, knockdown and paralysis. Mirrors the scope's own check. +/obj/item/organ/eyes/robotic/cyberware/proc/on_zoomed_stunned(mob/living/carbon/source, amount = 0, ignore_canstun = FALSE) + SIGNAL_HANDLER + if(amount > 0) + stop_zoom(source) + +// ---- Clicking through the zoom ------------------------------------------- + +/** + * A click on the zoomed view. Works out what is under the cursor, offers it to + * COMSIG_CYBERWARE_ZOOM_CLICK listeners (the cyberdeck), and swallows the click + * whether or not one took it. Returns FALSE only for a click that isn't on our + * zoom, which the caller then handles as normal. + */ +/obj/item/organ/eyes/robotic/cyberware/proc/zoom_click(mob/living/carbon/bearer, atom/movable/screen/fullscreen/cursor_catcher/scope/tracker, params) + var/datum/component/scope/scope = GetComponent(/datum/component/scope) + if(!is_zoomed() || !scope || scope.tracker != tracker) + return FALSE + var/list/modifiers = params2list(params) + // The scope worked out the turf under the cursor when the click landed + // (scope/Click() runs calculate_params() before anything else). + var/turf/target_turf = tracker.given_turf + var/atom/target = isturf(target_turf) ? scope.get_target(target_turf) : null + if(target && (SEND_SIGNAL(bearer, COMSIG_CYBERWARE_ZOOM_CLICK, target, modifiers) & COMPONENT_CYBERWARE_ZOOM_CLICK_HANDLED)) + return TRUE + if(isgun(bearer.get_active_held_item())) + refuse_zoomed_aim(bearer) + return TRUE + +/** + * Zoomed clicks go to zoom_click() before the client's and the mob's click + * intercepts, so an armed ability never receives the scope's screen object as + * its target. The trait check keeps this to one lookup for everyone else. + */ +/mob/living/carbon/check_click_intercept(params, atom/A) + if(HAS_TRAIT(src, TRAIT_CYBER_ZOOMED) && istype(A, /atom/movable/screen/fullscreen/cursor_catcher/scope)) + var/obj/item/organ/eyes/robotic/cyberware/optics = get_organ_slot(ORGAN_SLOT_EYES) + if(istype(optics) && optics.zoom_click(src, A, params)) + return TRUE + return ..() + +// ---- The button ----------------------------------------------------------- + +/datum/action/cooldown/cyberware/optics_zoom + name = "Optic Zoom" + desc = "Focus your optics on something far off." + // tg's binoculars: reads as "look far", and stays clear of the Deadeye's crosshair. + button_icon = 'icons/obj/devices/tool.dmi' + button_icon_state = "binoculars" + +/datum/action/cooldown/cyberware/optics_zoom/Activate(atom/target) + var/obj/item/organ/eyes/robotic/cyberware/optics = organ + if(!istype(optics) || !optics.owner) + return FALSE + if(optics.is_zoomed()) + optics.stop_zoom(optics.owner) + return TRUE + return optics.start_zoom() + +/datum/action/cooldown/cyberware/optics_zoom/is_action_active(atom/movable/screen/movable/action_button/current_button) + var/obj/item/organ/eyes/robotic/cyberware/optics = organ + return istype(optics) && optics.is_zoomed() + +/// The walk tax on cheap optics while they're zoomed. Strength set per optics. +/datum/movespeed_modifier/cyberware_zoom + variable = TRUE diff --git a/voidcrew/modules/cyberware/ware_line_zoom.dm b/voidcrew/modules/cyberware/ware_line_zoom.dm new file mode 100644 index 00000000000..e374a3de67a --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_zoom.dm @@ -0,0 +1,210 @@ +/** + * # The optics line: zoom by tier + * + * Every chrome optic zooms (cyberware_zoom.dm). Reach climbs with the tier, and + * the dedicated scout optics reach half a step further than combat or survey + * optics of the same tier. The cheap ones slow their bearer while zoomed. + * + * model tier load zoom walk tax read other + * Spyglass 1 1 1.0 1.0 silhouette nothing + * Assay 1 1 1.0 1.0 silhouette short ore pulse, no caches + * Nightshade 1 1 1.0 1.0 silhouette unchanged + * Prospector 2 2 1.5 0.5 itemized unchanged + * Deadeye 2 3 1.5 0.5 itemized unchanged + * Longsight 2 2 2.0 none itemized, 9 nothing + * Marksman link 3 4 2.0 none itemized 4 tracked shots, 10 tiles, 18 s + * Lookout 3 3 2.5 none itemized, 11 8 s read, low-light + * + * Binoculars (4) stay the reach ceiling, since they cost both hands. All of + * these ride ORGAN_SLOT_EYES, so installing one replaces whatever eyes the + * bearer had. Spyglass, Longsight and Lookout are their own types on the optics + * base; Assay optics subtype the Prospector and the Marksman link the Deadeye, + * so they keep their parents' ability code. + */ + +// ========================================================================= +// SPYGLASS OPTICS (T1) +// ========================================================================= + +/obj/item/organ/eyes/robotic/cyberware/spyglass + name = "spyglass optics" + desc = "Cheap chrome eyes with a telescoping lens behind each iris. They see a long way and not much else." + icon_state = "spyglass" + eye_color_left = "#7a6a4f" + eye_color_right = "#7a6a4f" + iris_overlay = null + chrome_load = 1 + tier = CYBERWARE_TIER_1 + chrome_scan_resolution = CYBERWARE_SCAN_SILHOUETTE + optics_zoom = 1 + zoom_walk_penalty = 1 + +// ========================================================================= +// ASSAY OPTICS (T1), the Prospector's street model +// ========================================================================= + +/obj/item/organ/eyes/robotic/cyberware/prospector/street + name = "assay optics" + desc = "Surveyor eyes off a mining co-op's bargain shelf. The pulse is short and the lenses are scratched, but ore still lights up through the rock." + icon_state = "prospector_street" + eye_color_left = "#8a6a1f" + eye_color_right = "#8a6a1f" + chrome_load = 1 + tier = CYBERWARE_TIER_1 + chrome_scan_resolution = CYBERWARE_SCAN_SILHOUETTE + actions_types = list( + /datum/action/cooldown/cyberware/prospector_pulse/assay, + /datum/action/cooldown/cyberware/chrome_read, + ) + optics_zoom = 1 + zoom_walk_penalty = 1 + pulse_range = 5 + pulse_marks_caches = FALSE + +/datum/action/cooldown/cyberware/prospector_pulse/assay + desc = "Ping ore veins through rock." + cooldown_time = 15 SECONDS + +// ========================================================================= +// LONGSIGHT OPTICS (T2 scout) +// ========================================================================= + +/obj/item/organ/eyes/robotic/cyberware/longsight + name = "longsight optics" + desc = "Scout optics with a long lens stack behind each iris. They pick out a face across a hangar bay." + icon_state = "longsight" + eye_color_left = "#3f6f8f" + eye_color_right = "#3f6f8f" + iris_overlay = null + chrome_load = 2 + tier = CYBERWARE_TIER_2 + optics_zoom = 2 + zoom_walk_penalty = 0 + chrome_read_range = 9 + +// ========================================================================= +// MARKSMAN LINK (T3), the Deadeye's service model +// ========================================================================= + +/obj/item/organ/eyes/robotic/cyberware/deadeye/milspec + name = "marksman link" + desc = "Service-grade targeting optics, the unit stencil still on the housing. The coprocessor is a generation newer than the Deadeye's." + icon_state = "deadeye_milspec" + chrome_load = 4 + tier = CYBERWARE_TIER_3 + actions_types = list( + /datum/action/cooldown/cyberware/deadeye_tag/marksman, + /datum/action/cooldown/cyberware/chrome_read, + ) + optics_zoom = 2 + zoom_walk_penalty = 0 + tag_range = 10 + tag_shots = 4 + tag_duration = 15 SECONDS + +/datum/action/cooldown/cyberware/deadeye_tag/marksman + name = "Marksman Tag" + desc = "Tag a hostile in view. Your next four shots track them automatically. The tag refuses to take a crewmate." + cooldown_time = 18 SECONDS + +// ========================================================================= +// LOOKOUT OPTICS (T3 scout) +// ========================================================================= + +/obj/item/organ/eyes/robotic/cyberware/lookout + name = "lookout optics" + desc = "Military spotter eyes with a low-light amplifier. Made for lying on a ridge and watching a camp for hours." + icon_state = "lookout" + eye_color_left = "#2d4a2a" + eye_color_right = "#2d4a2a" + iris_overlay = null + // The Nightshade's dark floor, without its green. + lighting_cutoff = LIGHTING_CUTOFF_MEDIUM + chrome_load = 3 + tier = CYBERWARE_TIER_3 + optics_zoom = 2.5 + zoom_walk_penalty = 0 + chrome_read_range = 11 + chrome_read_cooldown = 8 SECONDS + +// ========================================================================= +// SPLICE SKUs and INTAKE LEDGER +// The lead wires these into /datum/outpost_shop/vendor/ripperdoc (sku_types, +// buyback_types), the street doc's lists (the credit-priced three and their +// /street_doc trade-ins), and the zone loot tables at integration. +// ========================================================================= + +/datum/shop_sku/ripperdoc/spyglass + category = "Street Chrome" + desc = "A pair of chrome telescoping optics." + item_path = /obj/item/organ/eyes/robotic/cyberware/spyglass + price_credits = 400 + stock_min = 1 + stock_max = 3 + +/datum/shop_sku/ripperdoc/assay_optics + category = "Street Chrome" + desc = "A pair of budget surveyor optics." + item_path = /obj/item/organ/eyes/robotic/cyberware/prospector/street + price_credits = 900 + +/datum/shop_sku/ripperdoc/longsight + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + desc = "A pair of long-lens scout optics." + item_path = /obj/item/organ/eyes/robotic/cyberware/longsight + price_credits = 2400 + +/datum/shop_sku/ripperdoc/deadeye_milspec + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/eyes/robotic/cyberware/deadeye/milspec + price_vouchers = 3 + +/datum/shop_sku/ripperdoc/lookout + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/eyes/robotic/cyberware/lookout + price_vouchers = 3 + +/// Half list, credits only, like the rest of the ladder's credit rungs. +/datum/shop_buyback/ripperdoc/spyglass + name = "Spyglass Optics (pulled)" + desc = "Cheap zoom eyes. Splice takes them toward a better pair." + category = "Trade-In" + item_path = /obj/item/organ/eyes/robotic/cyberware/spyglass + match_subtypes = FALSE + pay_credits = 200 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/assay_optics + name = "Assay Optics (pulled)" + desc = "Budget surveyor eyes. Worth half toward the full suite." + category = "Trade-In" + item_path = /obj/item/organ/eyes/robotic/cyberware/prospector/street + match_subtypes = FALSE + pay_credits = 450 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/longsight + name = "Longsight Optics (pulled)" + desc = "Scout eyes, lenses intact. Splice strips the glass for a spotter rig." + category = "Trade-In" + item_path = /obj/item/organ/eyes/robotic/cyberware/longsight + match_subtypes = FALSE + pay_credits = 1200 + demand_min = 1 + demand_max = 2 + +// Ratchet's ledger: the same entries in his words, same pay. + +/datum/shop_buyback/ripperdoc/spyglass/street_doc + desc = "Used zoom eyes." + +/datum/shop_buyback/ripperdoc/assay_optics/street_doc + desc = "Used survey eyes. Scratched, like they all are." + +/datum/shop_buyback/ripperdoc/longsight/street_doc + desc = "Used scout eyes." diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index 0caf7de7d3b..a7408242a85 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -197,6 +197,14 @@ /datum/action/cooldown/cyberware/deadeye_tag, /datum/action/cooldown/cyberware/chrome_read, ) + optics_zoom = 1.5 + zoom_walk_penalty = 0.5 + /// How far a tag can be planted, and how far homing keeps correcting. + var/tag_range = CYBERWARE_DEADEYE_TAG_RANGE + /// Homing shots granted per tag. + var/tag_shots = CYBERWARE_DEADEYE_TAG_SHOTS + /// How long a tag lasts, spent or not. + var/tag_duration = CYBERWARE_DEADEYE_TAG_DURATION /// Weakref to the currently tagged target. var/datum/weakref/tagged_ref /// Homing shots remaining on the current tag. @@ -217,14 +225,14 @@ /obj/item/organ/eyes/robotic/cyberware/deadeye/proc/start_tag(mob/living/target) end_tag(owner) tagged_ref = WEAKREF(target) - shots_left = CYBERWARE_DEADEYE_TAG_SHOTS + shots_left = tag_shots RegisterSignal(owner, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(on_owner_fired)) target.add_filter(CYBERWARE_DEADEYE_MARK_FILTER, 2, list("type" = "outline", "color" = "#ff3300", "size" = 1)) new /obj/effect/temp_visual/smartgun_target(get_turf(target)) playsound(owner, 'sound/machines/synth/synth_yes.ogg', 30, TRUE) owner.balloon_alert(owner, "target tagged") to_chat(target, span_userdanger("A red targeting glint settles on you!")) - tag_timer = addtimer(CALLBACK(src, PROC_REF(on_tag_expired)), CYBERWARE_DEADEYE_TAG_DURATION, TIMER_STOPPABLE) + tag_timer = addtimer(CALLBACK(src, PROC_REF(on_tag_expired)), tag_duration, TIMER_STOPPABLE) /obj/item/organ/eyes/robotic/cyberware/deadeye/proc/on_tag_expired() tag_timer = null @@ -255,7 +263,7 @@ if(!istype(projectile)) return var/mob/living/target = tagged_ref?.resolve() - if(QDELETED(target) || target.stat == DEAD || target.z != user.z || get_dist(user, target) > CYBERWARE_DEADEYE_TAG_RANGE) + if(QDELETED(target) || target.stat == DEAD || target.z != user.z || get_dist(user, target) > tag_range) end_tag(user) return projectile.set_homing_target(target) @@ -287,7 +295,7 @@ if(cyberware_is_ally(owner, victim)) owner.balloon_alert(owner, "friendly, tag refused!") return FALSE - if(!can_see(owner, victim, CYBERWARE_DEADEYE_TAG_RANGE)) + if(!can_see(owner, victim, optics.tag_range)) owner.balloon_alert(owner, "no line of sight!") return FALSE StartCooldown() diff --git a/voidcrew/modules/cyberware/ware_pro_utility.dm b/voidcrew/modules/cyberware/ware_pro_utility.dm index 41a25cdb1bb..bba6f4596ab 100644 --- a/voidcrew/modules/cyberware/ware_pro_utility.dm +++ b/voidcrew/modules/cyberware/ware_pro_utility.dm @@ -442,6 +442,12 @@ /datum/action/cooldown/cyberware/prospector_pulse, /datum/action/cooldown/cyberware/chrome_read, ) + optics_zoom = 1.5 + zoom_walk_penalty = 0.5 + /// How far the resonance pulse reaches, through rock, in tiles. + var/pulse_range = CYBERWARE_PROSPECTOR_RANGE + /// Whether the pulse also flags zone loot caches. + var/pulse_marks_caches = TRUE /// Cache marks the last pulse raised, so pulling the optics takes them out /// with it instead of leaving blips on a client that no longer owns them. var/list/hud_marks = list() @@ -472,10 +478,14 @@ var/mob/living/carbon/bearer = organ.owner var/obj/item/organ/eyes/robotic/cyberware/prospector/optics = organ var/list/marks = istype(optics) ? optics.hud_marks : null - mineral_scan_pulse(get_turf(bearer), CYBERWARE_PROSPECTOR_RANGE, scanner = bearer) + var/pulse_range = istype(optics) ? optics.pulse_range : CYBERWARE_PROSPECTOR_RANGE + mineral_scan_pulse(get_turf(bearer), pulse_range, scanner = bearer) playsound(bearer, 'sound/machines/sonar-ping.ogg', 25, TRUE) + if(istype(optics) && !optics.pulse_marks_caches) + StartCooldown() + return TRUE var/caches = 0 - for(var/obj/structure/closet/crate/zone_loot/cache in range(CYBERWARE_PROSPECTOR_RANGE, bearer)) + for(var/obj/structure/closet/crate/zone_loot/cache in range(pulse_range, bearer)) // Marks go up through the rock the same way the ore reveal does, a // cache you can already see was never the thing worth pinging for. cyberware_hud_mark(bearer, cache, "#ffb347", 6 SECONDS, registry = marks) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index d556a72bd7a..bafc9603e94 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -76,6 +76,7 @@ #include "voidcrew_cyberware_models_a.dm" #include "voidcrew_cyberware_models_b.dm" #include "voidcrew_cyberware_passives.dm" +#include "voidcrew_cyberware_zoom.dm" #include "voidcrew_derelict_helpers.dm" #include "voidcrew_derelict_layout.dm" #include "voidcrew_derelict_leash.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_zoom.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_zoom.dm new file mode 100644 index 00000000000..e207c5f69c0 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_zoom.dm @@ -0,0 +1,371 @@ +/** + * # Optics zoom (voidcrew/modules/cyberware/cyberware_zoom.dm, ware_line_zoom.dm) + * + * Every chrome optic zooms by tier, a zoomed bearer can't shoot, the zoom drops + * whenever the eyes or the bearer stop working, and a click on the zoomed view + * goes only to the cyberdeck seam. There is no client here, so tg's scope never + * starts: these tests drive the optics' own half of a zoom (engage_zoom()) and + * stand in a hand-made cursor catcher for the scope's. + * + * Unit-test files compile before voidcrew/_DEFINES/, so fork defines are + * literals: TRAIT_CYBER_ZOOMED "cyber_zoomed", COMSIG_CYBERWARE_ZOOM_CLICK + * "cyberware_zoom_click", COMPONENT_CYBERWARE_ZOOM_CLICK_HANDLED 1, + * CYBERWARE_SCAN_SILHOUETTE 1 / _ITEMIZED 2, CYBERWARE_TIER_n n, and zone + * permissions Public/Yellow/Advanced 0/1/2. + */ + +/// Listens for zoomed clicks and stands in for an armed click-to-target ability. +/datum/zoom_click_probe + /// What the last zoomed click handed over. + var/atom/clicked + /// How many zoom clicks arrived. + var/zoom_clicks = 0 + /// Whether to claim the click, as a cyberdeck firing a hack would. + var/handle = TRUE + /// How many clicks reached us as a click intercept. + var/intercepted = 0 + +/datum/zoom_click_probe/proc/on_zoom_click(mob/living/carbon/source, atom/target, list/modifiers) + SIGNAL_HANDLER + clicked = target + zoom_clicks++ + return handle ? 1 : 0 + +/datum/zoom_click_probe/proc/InterceptClickOn(mob/user, params, atom/target) + intercepted++ + return TRUE + +/// The ladder: every optic zooms, carries the button and the scope, and reaches +/// what its tier says, never as far as binoculars (4). +/datum/unit_test/voidcrew_cyberware_zoom_ladder + +/datum/unit_test/voidcrew_cyberware_zoom_ladder/Run() + // optic => list(zoom, walk penalty) + var/list/expected = list( + /obj/item/organ/eyes/robotic/cyberware/spyglass = list(1, 1), + /obj/item/organ/eyes/robotic/cyberware/prospector/street = list(1, 1), + /obj/item/organ/eyes/robotic/cyberware/nightshade = list(1, 1), + /obj/item/organ/eyes/robotic/cyberware/prospector = list(1.5, 0.5), + /obj/item/organ/eyes/robotic/cyberware/deadeye = list(1.5, 0.5), + /obj/item/organ/eyes/robotic/cyberware/longsight = list(2, 0), + /obj/item/organ/eyes/robotic/cyberware/deadeye/milspec = list(2, 0), + /obj/item/organ/eyes/robotic/cyberware/lookout = list(2.5, 0), + ) + for(var/optic_type in subtypesof(/obj/item/organ/eyes/robotic/cyberware)) + var/obj/item/organ/eyes/robotic/cyberware/optics = allocate(optic_type) + TEST_ASSERT(optics.optics_zoom > 0, "[optic_type] doesn't zoom") + TEST_ASSERT(optics.optics_zoom < 4, "[optic_type] zooms as far as binoculars") + TEST_ASSERT_EQUAL(optics.slot, ORGAN_SLOT_EYES, "[optic_type] left the eye slot, so it would stack with other optics") + var/datum/component/scope/scope = optics.GetComponent(/datum/component/scope) + TEST_ASSERT_NOTNULL(scope, "[optic_type] has no scope to zoom with") + TEST_ASSERT_EQUAL(scope.range_modifier, optics.optics_zoom, "[optic_type]'s scope reaches a different distance from its zoom") + TEST_ASSERT(locate(/datum/action/cooldown/cyberware/optics_zoom) in optics.actions, "[optic_type] has no zoom button") + TEST_ASSERT(locate(/datum/action/cooldown/cyberware/chrome_read) in optics.actions, "[optic_type] lost its chrome read") + TEST_ASSERT(istype(optics.actions[length(optics.actions)], /datum/action/cooldown/cyberware/optics_zoom), "[optic_type]'s zoom is not its last button") + var/list/numbers = expected[optic_type] + if(!numbers) + continue + TEST_ASSERT_EQUAL(optics.optics_zoom, numbers[1], "[optic_type] zooms the wrong distance") + TEST_ASSERT_EQUAL(optics.zoom_walk_penalty, numbers[2], "[optic_type] has the wrong walk tax while zoomed") + for(var/optic_type in expected) + TEST_ASSERT(ispath(optic_type, /obj/item/organ/eyes/robotic/cyberware), "[optic_type] is no longer chrome optics") + +/// The existing optics keep their numbers now they're vars, and the new models +/// carry theirs. +/datum/unit_test/voidcrew_cyberware_zoom_models + +/datum/unit_test/voidcrew_cyberware_zoom_models/Run() + // Chrome read: 7 tiles every 12 seconds unless the optics say otherwise. + var/obj/item/organ/eyes/robotic/cyberware/nightshade/nightshade = allocate(/obj/item/organ/eyes/robotic/cyberware/nightshade) + var/datum/action/cooldown/cyberware/chrome_read/stock_read = locate() in nightshade.actions + TEST_ASSERT_EQUAL(nightshade.chrome_read_range, 7, "The stock chrome read range changed") + TEST_ASSERT_EQUAL(stock_read.cooldown_time, 12 SECONDS, "The stock chrome read cooldown changed") + + // Deadeye, unchanged. + var/obj/item/organ/eyes/robotic/cyberware/deadeye/deadeye = allocate(/obj/item/organ/eyes/robotic/cyberware/deadeye) + TEST_ASSERT_EQUAL(deadeye.tag_range, 9, "Deadeye tag range changed") + TEST_ASSERT_EQUAL(deadeye.tag_shots, 3, "Deadeye tracked shots changed") + TEST_ASSERT_EQUAL(deadeye.tag_duration, 15 SECONDS, "Deadeye tag length changed") + var/datum/action/cooldown/cyberware/deadeye_tag/deadeye_tag = locate() in deadeye.actions + TEST_ASSERT_EQUAL(deadeye_tag.type, /datum/action/cooldown/cyberware/deadeye_tag, "The Deadeye carries the wrong tag") + TEST_ASSERT_EQUAL(deadeye_tag.cooldown_time, 25 SECONDS, "Deadeye tag cooldown changed") + + // Marksman link: four shots, ten tiles, eighteen seconds, same tag length. + var/obj/item/organ/eyes/robotic/cyberware/deadeye/milspec/marksman = allocate(/obj/item/organ/eyes/robotic/cyberware/deadeye/milspec) + TEST_ASSERT_EQUAL(marksman.tag_range, 10, "Marksman tag range is not 10") + TEST_ASSERT_EQUAL(marksman.tag_shots, 4, "Marksman tracked shots are not 4") + TEST_ASSERT_EQUAL(marksman.tag_duration, 15 SECONDS, "Marksman tag length is not 15 s") + var/datum/action/cooldown/cyberware/deadeye_tag/marksman/marksman_tag = locate() in marksman.actions + TEST_ASSERT_NOTNULL(marksman_tag, "The Marksman link has no tag of its own") + TEST_ASSERT_EQUAL(marksman_tag.cooldown_time, 18 SECONDS, "Marksman tag cooldown is not 18 s") + check_chrome(marksman, 3, 4) + + // Prospector, unchanged; Assay optics shorter, slower and blind to caches. + var/obj/item/organ/eyes/robotic/cyberware/prospector/prospector = allocate(/obj/item/organ/eyes/robotic/cyberware/prospector) + TEST_ASSERT_EQUAL(prospector.pulse_range, 9, "Prospector pulse range changed") + TEST_ASSERT(prospector.pulse_marks_caches, "The Prospector stopped flagging caches") + var/datum/action/cooldown/cyberware/prospector_pulse/stock_pulse = locate() in prospector.actions + TEST_ASSERT_EQUAL(stock_pulse.cooldown_time, 10 SECONDS, "Prospector pulse cooldown changed") + var/obj/item/organ/eyes/robotic/cyberware/prospector/street/assay = allocate(/obj/item/organ/eyes/robotic/cyberware/prospector/street) + TEST_ASSERT_EQUAL(assay.pulse_range, 5, "Assay pulse range is not 5") + TEST_ASSERT(!assay.pulse_marks_caches, "Assay optics flag caches") + var/datum/action/cooldown/cyberware/prospector_pulse/assay/assay_pulse = locate() in assay.actions + TEST_ASSERT_NOTNULL(assay_pulse, "Assay optics have no pulse of their own") + TEST_ASSERT_EQUAL(assay_pulse.cooldown_time, 15 SECONDS, "Assay pulse cooldown is not 15 s") + TEST_ASSERT_EQUAL(assay.chrome_scan_resolution, 1, "Assay optics read at more than silhouette") + check_chrome(assay, 1, 1) + + // The plain scout line. + var/obj/item/organ/eyes/robotic/cyberware/spyglass/spyglass = allocate(/obj/item/organ/eyes/robotic/cyberware/spyglass) + TEST_ASSERT_EQUAL(spyglass.chrome_scan_resolution, 1, "Spyglass optics read at more than silhouette") + TEST_ASSERT_EQUAL(length(spyglass.actions), 2, "Spyglass optics should carry only the read and the zoom") + check_chrome(spyglass, 1, 1) + var/obj/item/organ/eyes/robotic/cyberware/longsight/longsight = allocate(/obj/item/organ/eyes/robotic/cyberware/longsight) + TEST_ASSERT_EQUAL(longsight.chrome_scan_resolution, 2, "Longsight optics don't name what they read") + TEST_ASSERT_EQUAL(longsight.chrome_read_range, 9, "Longsight read range is not 9") + check_chrome(longsight, 2, 2) + var/obj/item/organ/eyes/robotic/cyberware/lookout/lookout = allocate(/obj/item/organ/eyes/robotic/cyberware/lookout) + TEST_ASSERT_EQUAL(lookout.chrome_scan_resolution, 2, "Lookout optics don't name what they read") + TEST_ASSERT_EQUAL(lookout.chrome_read_range, 11, "Lookout read range is not 11") + var/datum/action/cooldown/cyberware/chrome_read/lookout_read = locate() in lookout.actions + TEST_ASSERT_EQUAL(lookout_read.cooldown_time, 8 SECONDS, "Lookout read cooldown is not 8 s") + TEST_ASSERT_EQUAL(lookout.lighting_cutoff, LIGHTING_CUTOFF_MEDIUM, "Lookout optics lost their low-light") + check_chrome(lookout, 3, 3) + +/datum/unit_test/voidcrew_cyberware_zoom_models/proc/check_chrome(obj/item/organ/eyes/robotic/cyberware/optics, tier, load) + var/datum/component/cyberware/chrome = optics.GetComponent(/datum/component/cyberware) + TEST_ASSERT_EQUAL(chrome.tier, tier, "[optics.type] is the wrong tier") + TEST_ASSERT_EQUAL(chrome.chrome_load, load, "[optics.type] carries the wrong neural load") + +/// A zoom holds the trait and the walk tax, refuses guns, and lets go cleanly. +/datum/unit_test/voidcrew_cyberware_zoom_state + +/datum/unit_test/voidcrew_cyberware_zoom_state/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/eyes/robotic/cyberware/spyglass/optics = allocate(/obj/item/organ/eyes/robotic/cyberware/spyglass) + TEST_ASSERT(optics.Insert(bearer, special = TRUE), "Spyglass staging insert was refused") + var/datum/action/cooldown/cyberware/optics_zoom/zoom = locate() in optics.actions + TEST_ASSERT_EQUAL(zoom.owner, bearer, "The zoom button didn't come with the eyes") + TEST_ASSERT(zoom.IsAvailable(), "Healthy installed optics can't zoom") + + // No client: the scope refuses, so the button can't zoom anyone here. + TEST_ASSERT(!optics.start_zoom(), "A clientless bearer zoomed") + TEST_ASSERT(!HAS_TRAIT(bearer, "cyber_zoomed"), "A refused zoom left the bearer zoomed") + + optics.engage_zoom(bearer) + TEST_ASSERT(optics.is_zoomed() && HAS_TRAIT(bearer, "cyber_zoomed"), "Engaging the zoom didn't mark the bearer zoomed") + TEST_ASSERT(zoom.is_action_active(null), "The zoom button doesn't show the zoom as on") + TEST_ASSERT(bearer.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_zoom), "Cheap optics didn't slow their bearer while zoomed") + + var/obj/item/gun/ballistic/automatic/pistol/pistol = allocate(/obj/item/gun/ballistic/automatic/pistol) + TEST_ASSERT(bearer.put_in_active_hand(pistol), "Test fixture: the bearer couldn't hold the pistol") + var/rounds = pistol.get_ammo() + TEST_ASSERT(rounds > 0, "Test fixture: the pistol spawned empty") + TEST_ASSERT(SEND_SIGNAL(bearer, COMSIG_MOB_TRYING_TO_FIRE_GUN, pistol, run_loc_floor_top_right, FALSE, null) & COMPONENT_CANCEL_GUN_FIRE, "A zoomed bearer's gun wasn't stopped") + pistol.fire_gun(run_loc_floor_top_right, bearer, FALSE, null) + TEST_ASSERT_EQUAL(pistol.get_ammo(), rounds, "A zoomed bearer fired a gun") + + optics.stop_zoom(bearer) + TEST_ASSERT(!optics.is_zoomed() && !HAS_TRAIT(bearer, "cyber_zoomed"), "Stopping the zoom left the bearer zoomed") + TEST_ASSERT(!bearer.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_zoom), "The walk tax outlived the zoom") + TEST_ASSERT(!(SEND_SIGNAL(bearer, COMSIG_MOB_TRYING_TO_FIRE_GUN, pistol, run_loc_floor_top_right, FALSE, null) & COMPONENT_CANCEL_GUN_FIRE), "The gun block outlived the zoom") + TEST_ASSERT(!zoom.is_action_active(null), "The zoom button still shows the zoom as on") + + // Scout optics zoom at full stride. + var/mob/living/carbon/human/scout = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/eyes/robotic/cyberware/longsight/scout_optics = allocate(/obj/item/organ/eyes/robotic/cyberware/longsight) + TEST_ASSERT(scout_optics.Insert(scout, special = TRUE), "Longsight staging insert was refused") + scout_optics.engage_zoom(scout) + TEST_ASSERT(!scout.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_zoom), "Scout optics slowed their bearer while zoomed") + scout_optics.stop_zoom(scout) + +/// Everything that should end a zoom does. +/datum/unit_test/voidcrew_cyberware_zoom_drops + +/datum/unit_test/voidcrew_cyberware_zoom_drops/Run() + var/obj/item/organ/eyes/robotic/cyberware/optics = zoomed() + var/datum/component/cyberware/chrome = optics.GetComponent(/datum/component/cyberware) + var/datum/action/cooldown/cyberware/optics_zoom/zoom = locate() in optics.actions + chrome.start_emp_reboot() + check_dropped(optics, "an EMP") + TEST_ASSERT(!zoom.IsAvailable(), "EMP-scrambled optics can still zoom") + chrome.tune_up() + TEST_ASSERT(zoom.IsAvailable(), "Rebooted optics can't zoom again") + + optics = zoomed() + chrome = optics.GetComponent(/datum/component/cyberware) + chrome.set_browned_out(TRUE) + check_dropped(optics, "a brownout") + chrome.set_browned_out(FALSE) + + optics = zoomed() + chrome = optics.GetComponent(/datum/component/cyberware) + optics.set_organ_damage(optics.maxHealth) + chrome.update_failing() // the bearer's life tick, which settles damage failure + check_dropped(optics, "the eyes failing") + + optics = zoomed() + optics.owner.flash_act(1) + check_dropped(optics, "a flash") + + optics = zoomed() + optics.owner.become_blind("voidcrew_zoom_test") + check_dropped(optics, "going blind") + + optics = zoomed() + optics.owner.Stun(2 SECONDS) + check_dropped(optics, "a stun") + + optics = zoomed() + optics.owner.Knockdown(2 SECONDS) + check_dropped(optics, "a knockdown") + + optics = zoomed() + optics.owner.Paralyze(2 SECONDS) + check_dropped(optics, "paralysis") + + optics = zoomed() + optics.owner.death() + check_dropped(optics, "death") + + optics = zoomed() + var/mob/living/carbon/human/bearer = optics.owner + optics.Remove(bearer, special = TRUE) + TEST_ASSERT(!HAS_TRAIT(bearer, "cyber_zoomed"), "Pulling the optics out left their bearer zoomed") + TEST_ASSERT(!bearer.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_zoom), "Pulling the optics out left the walk tax on") + +/// A fresh bearer, zoomed through a fresh pair of Spyglass optics. +/datum/unit_test/voidcrew_cyberware_zoom_drops/proc/zoomed() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/eyes/robotic/cyberware/spyglass/optics = allocate(/obj/item/organ/eyes/robotic/cyberware/spyglass) + TEST_ASSERT(optics.Insert(bearer, special = TRUE), "Spyglass staging insert was refused") + optics.engage_zoom(bearer) + TEST_ASSERT(optics.is_zoomed(), "Test fixture: the zoom didn't engage") + return optics + +/datum/unit_test/voidcrew_cyberware_zoom_drops/proc/check_dropped(obj/item/organ/eyes/robotic/cyberware/optics, cause) + var/mob/living/carbon/human/bearer = optics.owner + TEST_ASSERT(!optics.is_zoomed() && !HAS_TRAIT(bearer, "cyber_zoomed"), "The zoom survived [cause]") + TEST_ASSERT(!bearer.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_zoom), "The walk tax survived [cause]") + TEST_ASSERT(!(SEND_SIGNAL(bearer, COMSIG_MOB_TRYING_TO_FIRE_GUN, null, null, FALSE, null) & COMPONENT_CANCEL_GUN_FIRE), "The gun block survived [cause]") + +/// The cyberdeck seam: a zoomed click goes to COMSIG_CYBERWARE_ZOOM_CLICK with +/// what is under the cursor, never to a click intercept, and only while zoomed. +/datum/unit_test/voidcrew_cyberware_zoom_click + +/datum/unit_test/voidcrew_cyberware_zoom_click/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/far_body = allocate(/mob/living/carbon/human/consistent, run_loc_floor_top_right) + var/obj/item/organ/eyes/robotic/cyberware/lookout/optics = allocate(/obj/item/organ/eyes/robotic/cyberware/lookout) + TEST_ASSERT(optics.Insert(bearer, special = TRUE), "Lookout staging insert was refused") + + // Stand in for the scope's cursor catcher, pointed at the far body. + var/datum/component/scope/scope = optics.GetComponent(/datum/component/scope) + var/atom/movable/screen/fullscreen/cursor_catcher/scope/catcher = new + catcher.owner = bearer + catcher.given_turf = get_turf(far_body) + scope.tracker = catcher + optics.engage_zoom(bearer) + + var/datum/zoom_click_probe/probe = allocate(/datum/zoom_click_probe) + probe.RegisterSignal(bearer, "cyberware_zoom_click", TYPE_PROC_REF(/datum/zoom_click_probe, on_zoom_click)) + bearer.click_intercept = probe + + bearer.ClickOn(catcher, "") + TEST_ASSERT_EQUAL(probe.clicked, far_body, "A zoomed click didn't hand over the body under the cursor") + TEST_ASSERT_EQUAL(probe.intercepted, 0, "An armed click intercept saw a zoomed click") + + // Unclaimed, the click still goes nowhere else. + probe.handle = FALSE + probe.clicked = null + bearer.next_click = 0 + bearer.ClickOn(catcher, "") + TEST_ASSERT_EQUAL(probe.zoom_clicks, 2, "An unclaimed zoomed click never reached the seam") + TEST_ASSERT_EQUAL(probe.intercepted, 0, "An unclaimed zoomed click fell through to a click intercept") + + // Nothing on the tile: the tile itself. + var/turf/empty_tile = locate(run_loc_floor_bottom_left.x + 1, run_loc_floor_bottom_left.y + 1, run_loc_floor_bottom_left.z) + catcher.given_turf = empty_tile + bearer.next_click = 0 + bearer.ClickOn(catcher, "") + TEST_ASSERT_EQUAL(probe.clicked, empty_tile, "A zoomed click on an empty tile didn't hand over the tile") + + // Out of the zoom, clicks are ordinary again. + optics.stop_zoom(bearer) + bearer.next_click = 0 + bearer.ClickOn(catcher, "") + TEST_ASSERT_EQUAL(probe.zoom_clicks, 3, "A click after the zoom ended still went to the seam") + TEST_ASSERT_EQUAL(probe.intercepted, 1, "Clicks stayed swallowed after the zoom ended") + + bearer.click_intercept = null + scope.tracker = null + qdel(catcher) + +/// The zoom button sits on the chrome wheel, and swapping optics with a +/// different number of buttons never moves the ear chrome beside them. +/datum/unit_test/voidcrew_cyberware_zoom_wheel + +/datum/unit_test/voidcrew_cyberware_zoom_wheel/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/eyes/robotic/cyberware/spyglass/cheap = allocate(/obj/item/organ/eyes/robotic/cyberware/spyglass) + TEST_ASSERT(cheap.Insert(bearer, special = TRUE), "Spyglass staging insert was refused") + var/obj/item/organ/cyberimp/cyberware/doppler/ears = allocate(/obj/item/organ/cyberimp/cyberware/doppler) + TEST_ASSERT(ears.Insert(bearer, special = TRUE), "Doppler staging insert was refused") + var/datum/action/cheap_zoom = locate(/datum/action/cooldown/cyberware/optics_zoom) in cheap.actions + var/datum/action/sonar = locate(/datum/action/item_action/organ_action/toggle) in ears.actions + TEST_ASSERT(cheap_zoom && sonar, "A staged organ is missing its button") + + var/list/abilities = get_chrome_wheel_actions(bearer) + TEST_ASSERT(cheap_zoom in abilities, "The zoom button isn't on the chrome wheel") + var/list/positions = chrome_wheel_layout(abilities) + var/list/ears_at = positions[sonar] + + // Optics with an ability of their own: three eye buttons instead of two. + var/obj/item/organ/eyes/robotic/cyberware/deadeye/milspec/link = allocate(/obj/item/organ/eyes/robotic/cyberware/deadeye/milspec) + TEST_ASSERT(link.Insert(bearer, special = TRUE), "Marksman link staging insert was refused") + abilities = get_chrome_wheel_actions(bearer) + TEST_ASSERT(!(cheap_zoom in abilities), "The pulled optics' zoom stayed on the wheel") + TEST_ASSERT(locate(/datum/action/cooldown/cyberware/optics_zoom) in abilities, "The new optics' zoom isn't on the wheel") + positions = chrome_wheel_layout(abilities) + var/list/ears_now = positions[sonar] + TEST_ASSERT(ears_at[1] == ears_now[1] && ears_at[2] == ears_now[2], "Swapping optics moved the ear chrome on the wheel") + +/// Shelf prices and gates, half-list trade-ins, and cradle quotes. +/datum/unit_test/voidcrew_cyberware_zoom_catalog + +/datum/unit_test/voidcrew_cyberware_zoom_catalog/Run() + // SKU => list(item, credits, vouchers, zone permission) + var/list/skus = list( + /datum/shop_sku/ripperdoc/spyglass = list(/obj/item/organ/eyes/robotic/cyberware/spyglass, 400, 0, 0), + /datum/shop_sku/ripperdoc/assay_optics = list(/obj/item/organ/eyes/robotic/cyberware/prospector/street, 900, 0, 0), + /datum/shop_sku/ripperdoc/longsight = list(/obj/item/organ/eyes/robotic/cyberware/longsight, 2400, 0, 1), + /datum/shop_sku/ripperdoc/deadeye_milspec = list(/obj/item/organ/eyes/robotic/cyberware/deadeye/milspec, 0, 3, 2), + /datum/shop_sku/ripperdoc/lookout = list(/obj/item/organ/eyes/robotic/cyberware/lookout, 0, 3, 2), + ) + build_cyberware_price_index() + for(var/datum/shop_sku/sku_type as anything in skus) + var/list/row = skus[sku_type] + TEST_ASSERT_EQUAL(initial(sku_type.item_path), row[1], "[sku_type] sells the wrong optics") + TEST_ASSERT_EQUAL(initial(sku_type.price_credits), row[2], "[sku_type] has the wrong credit price") + TEST_ASSERT_EQUAL(initial(sku_type.price_vouchers), row[3], "[sku_type] has the wrong voucher price") + TEST_ASSERT_EQUAL(initial(sku_type.zone_permission), row[4], "[sku_type] has the wrong reputation gate") + var/obj/item/organ/optics = allocate(row[1]) + var/list/quote = get_cyberware_price(optics) + TEST_ASSERT(quote && (quote["credits"] > 0 || quote["vouchers"] > 0), "The cradle quotes no price for [row[1]]") + + // Trade-in => the SKU it pays half of. + var/list/trade_ins = list( + /datum/shop_buyback/ripperdoc/spyglass = /datum/shop_sku/ripperdoc/spyglass, + /datum/shop_buyback/ripperdoc/assay_optics = /datum/shop_sku/ripperdoc/assay_optics, + /datum/shop_buyback/ripperdoc/longsight = /datum/shop_sku/ripperdoc/longsight, + /datum/shop_buyback/ripperdoc/spyglass/street_doc = /datum/shop_sku/ripperdoc/spyglass, + /datum/shop_buyback/ripperdoc/assay_optics/street_doc = /datum/shop_sku/ripperdoc/assay_optics, + /datum/shop_buyback/ripperdoc/longsight/street_doc = /datum/shop_sku/ripperdoc/longsight, + ) + for(var/buyback_type in trade_ins) + var/datum/shop_buyback/ledger = allocate(buyback_type) + var/datum/shop_sku/sku_type = trade_ins[buyback_type] + TEST_ASSERT_EQUAL(ledger.item_path, initial(sku_type.item_path), "[buyback_type] buys something its shelf doesn't sell") + TEST_ASSERT(!ledger.match_subtypes, "[buyback_type] buys subtypes too") + TEST_ASSERT_EQUAL(ledger.pay_credits * 2, initial(sku_type.price_credits), "[buyback_type] doesn't pay half list") + TEST_ASSERT(!ledger.pay_vouchers, "[buyback_type] pays vouchers") From 653e2c39d828026763d77c620c91da11fe8d372f Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:18:02 -0800 Subject: [PATCH 131/472] Add the cyberdeck line and share the quickhacks Moves the Machine Communion quickhacks and room hacks into voidcrew/modules/quickhacks/ unchanged, with a quickhack kit that says what a source carries, how far it reaches, its upload time and cooldown scale. The boon lists its own hacks through that kit and plays as before. Adds five cortex decks in a new head slot (Junker, Street, Facilities, Breacher, Blackbox) with cumulative hack lists, a visible upload with a trace line, and three new hacks: Ping, Reboot Turret and Glitch. Decks never grant machine access, never run Cut the Safeties or Wake the Machines, refuse outpost property, and never overload outpost machines. Using a deck arms targeting; the hack radial opens on the clicked target, also through the optics zoom. --- tgstation.dme | 4 + tools/gear_catalog/ratings/cyberware.json | 45 + voidcrew/_DEFINES/cyberware.dm | 6 + .../modules/antag_ruins/ascension_mech.dm | 1002 +---------------- voidcrew/modules/cyberware/cradle_ui.dm | 2 +- voidcrew/modules/cyberware/ware_line_deck.dm | 701 ++++++++++++ voidcrew/modules/quickhacks/masshacks.dm | 438 +++++++ voidcrew/modules/quickhacks/quickhack_kit.dm | 161 +++ voidcrew/modules/quickhacks/quickhacks.dm | 517 +++++++++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_deck.dm | 312 +++++ 11 files changed, 2228 insertions(+), 961 deletions(-) create mode 100644 voidcrew/modules/cyberware/ware_line_deck.dm create mode 100644 voidcrew/modules/quickhacks/masshacks.dm create mode 100644 voidcrew/modules/quickhacks/quickhack_kit.dm create mode 100644 voidcrew/modules/quickhacks/quickhacks.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm diff --git a/tgstation.dme b/tgstation.dme index beec292fa3c..cc7446b1155 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7191,6 +7191,7 @@ #include "voidcrew\modules\cyberware\ware_legend.dm" #include "voidcrew\modules\cyberware\ware_line_berserk.dm" #include "voidcrew\modules\cyberware\ware_line_camo.dm" +#include "voidcrew\modules\cyberware\ware_line_deck.dm" #include "voidcrew\modules\cyberware\ware_line_governor.dm" #include "voidcrew\modules\cyberware\ware_line_mantis.dm" #include "voidcrew\modules\cyberware\ware_line_models_a.dm" @@ -7612,6 +7613,9 @@ #include "voidcrew\modules\player_outposts\player_outpost.dm" #include "voidcrew\modules\power\port_gen.dm" #include "voidcrew\modules\preferences\ship_categories_middleware.dm" +#include "voidcrew\modules\quickhacks\masshacks.dm" +#include "voidcrew\modules\quickhacks\quickhack_kit.dm" +#include "voidcrew\modules\quickhacks\quickhacks.dm" #include "voidcrew\modules\reagents\chemistry\reagents\medicine_reagents.dm" #include "voidcrew\modules\research\machinery_rnd.dm" #include "voidcrew\modules\research\research_boards.dm" diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index bbb46d39a58..3dc5c205c90 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -444,6 +444,51 @@ "reason": "You stay on your feet down to -30 health instead of 0, stamina damage is halved, and damage never slows you, but your health display lies. About 30 more health and half of every disabler, for load 4 and 2 vouchers with no reputation.", "tier": "red" }, + "/obj/item/organ/cyberimp/cyberware/deck": { + "confidence": "medium", + "group": "Hacking and ship control", + "hash": "", + "name": "Junker deck", + "rank": 5, + "reason": "From 5 tiles with a clear line, after a 2 s upload with a visible trace line: bolt or unbolt a door, burst an area's lights, or ping nearby cameras, turrets and powered doors through walls. 12 to 45 s cooldowns. No access bypass. Load 2, 1,500 cr; Splice takes it back for 750.", + "tier": "green" + }, + "/obj/item/organ/cyberimp/cyberware/deck/assault": { + "confidence": "medium", + "group": "Hacking and ship control", + "hash": "", + "name": "Breacher deck", + "rank": 2, + "reason": "People from 7 tiles after a 1 s upload: Feedback Surge for up to 60 burn, a knockdown and dropped hands; Overload a machine into a small explosion; Glitch one piece of a target's chrome into a 9 s reboot. Plus the Street deck's hacks. One shared cooldown, 45 to 60 s on the big ones. Load 6, 4 vouchers at reputation 11.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/deck/control": { + "confidence": "medium", + "group": "Hacking and ship control", + "hash": "", + "name": "Facilities deck", + "rank": 2, + "reason": "Doors, radios and turrets from 7 tiles after a 1 s upload: electrify a door for 30 s (20 burn and a stun), jam radios in a 9-tile bubble for 45 s, knock a turret off for 8 s, plus the Street deck's hacks. One shared cooldown of 8 to 40 s. Load 6, 4 vouchers at reputation 11.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/deck/flagship": { + "confidence": "medium", + "group": "Hacking and ship control", + "hash": "", + "name": "Blackbox deck", + "rank": 1, + "reason": "Both service decks' hacks from 8 tiles with a 0.5 s upload, plus Arc Flash: five volleys of 14 burn on everyone near a powered machine in the room, two minutes apart. Still no access bypass, no emag and no woken machines, unlike the Machine Communion boon. Load 9 of 20, 7 vouchers at reputation 22.", + "tier": "red" + }, + "/obj/item/organ/cyberimp/cyberware/deck/street": { + "confidence": "medium", + "group": "Hacking and ship control", + "hash": "", + "name": "Street deck", + "rank": 4, + "reason": "The Junker deck at 6 tiles and 1.5 s, plus Kill the Power on an APC and Cutout, which empties every cell a person carries (energy guns, MOD suit) and kills their radios for 45 s. About a minute between Cutouts. Load 4, 3,500 cr at yellow reputation; buys back at 1,750.", + "tier": "yellow" + }, "/obj/item/organ/cyberimp/cyberware/dermal_mesh": { "confidence": "high", "group": "Armor and frame", diff --git a/voidcrew/_DEFINES/cyberware.dm b/voidcrew/_DEFINES/cyberware.dm index cf9dc5e30c8..20891da95f4 100644 --- a/voidcrew/_DEFINES/cyberware.dm +++ b/voidcrew/_DEFINES/cyberware.dm @@ -226,3 +226,9 @@ /// frame (ware_military_body.dm) and the Strut frame (ware_models_b.dm); the /// gate itself is in ware_military_body.dm. #define TRAIT_CYBERWARE_UNBREAKABLE_BONES "cyberware_unbreakable_bones" + +// ---- Cyberdecks (ware_line_deck.dm) ------------------------------------ + +/// Hacking deck, head. The deck line, Junker deck up to the Blackbox deck. +/// Its own slot, not the OS slot, so a hacker can still run a sandevistan. +#define ORGAN_SLOT_CYBERWARE_DECK "cyberware_deck" diff --git a/voidcrew/modules/antag_ruins/ascension_mech.dm b/voidcrew/modules/antag_ruins/ascension_mech.dm index b8fab656eb6..25ed26ff6c1 100644 --- a/voidcrew/modules/antag_ruins/ascension_mech.dm +++ b/voidcrew/modules/antag_ruins/ascension_mech.dm @@ -86,6 +86,10 @@ * than a person in the room, so the reach is nine tiles for a click and seven for the room, * and you have to be there for both. The single-target hacks are likewise local, Kill the * Power is one APC's area, not the grid; Silence is one bubble, not the ship. + * + * The hacks themselves live in voidcrew/modules/quickhacks/, shared with cyberdecks. + * What the boon carries is [/datum/quickhack_kit/communion] below, listed by hand so + * nothing written for a deck ever reaches it. */ // ===== THE WARFRAME ===== @@ -178,65 +182,6 @@ /// How far the capstone reaches. You have to be in the room. #define COMMUNION_RANGE 9 -/// Seconds a bolted-and-live door stays electrified. -#define COMMUNION_ELECTRIFY_SECONDS 30 -/// Burn a communion-live door lands. Equal to the floor of a stock powernet shock, ELECTROCUTE_DAMAGE(). -#define COMMUNION_ELECTRIFY_DAMAGE 20 -/// Stun on top of the burn. Matches the paralyse a carbon takes from a stock shock, so carbons feel no change. -#define COMMUNION_ELECTRIFY_STUN (4 SECONDS) -/// How long an overloaded machine buzzes before it goes. -#define COMMUNION_OVERLOAD_DELAY (4 SECONDS) -/// Radius and lifetime of the Silence bubble. -#define COMMUNION_JAM_RANGE 9 -#define COMMUNION_JAM_DURATION (45 SECONDS) -/// How long a radio switched off by Cutout or Silence stays dead before it recovers. -#define COMMUNION_RADIO_OFF_DURATION (45 SECONDS) -/// Burn per full standard cell drained by Feedback Surge, and the ceiling on it. -#define COMMUNION_SURGE_PER_CELL 12 -#define COMMUNION_SURGE_MAX 60 -#define COMMUNION_SURGE_MIN 5 - -// ===== THE MASS HACKS ===== -// The second button. Everything below is room-scale, and the descs quote these -// numbers literally, keep them in sync. - -/// How far a mass hack reaches from the caster. Shorter than the single-target reach -/// on purpose: this is the room you are standing in, not the room next door. -#define COMMUNION_MASS_RANGE 7 -/// Most machines any one mass hack will touch. Caps the blast, the mob count and the tick cost. -#define COMMUNION_MASS_CAP 12 -/// How long the whole room buzzes before Overload the Room lands. A second longer than -/// the single-target version, because there is a great deal more to run away from. -#define COMMUNION_MASS_OVERLOAD_DELAY (5 SECONDS) -/// Gap between two machines going up, so a dozen explosions do not land on one tick. -#define COMMUNION_MASS_OVERLOAD_STAGGER (0.2 SECONDS) -/// Blast radii of one machine in a mass overload. Identical to the single-target blast. -/// This is a dozen full Overloads going off at once, not a dozen small ones. EXPLODE_HEAVY -/// on a wall is `dismantle_wall(prob(50), TRUE)`, which always takes the wall, so a room -/// that goes up this way is stripped to the girders and an outer wall may well go with it. -/// That is the intent; the five-second buzz is what everybody in the room gets instead. -#define COMMUNION_MASS_OVERLOAD_HEAVY 1 -#define COMMUNION_MASS_OVERLOAD_LIGHT 3 -#define COMMUNION_MASS_OVERLOAD_FLASH 3 - -/// Arc Flash: how many volleys it throws and how far apart. -#define COMMUNION_ARC_PULSES 5 -#define COMMUNION_ARC_INTERVAL (1.5 SECONDS) -/// How far a machine will reach for somebody to earth itself through. -#define COMMUNION_ARC_REACH 5 -/// Burn one arc lands. Everyone in the room takes one per volley. -#define COMMUNION_ARC_DAMAGE 14 - -/// Most machines Wake the Machines will stand up. Lower than the general cap; each one is a mob. -#define COMMUNION_WAKE_CAP 8 -/// How long a woken machine lasts before it falls apart. -#define COMMUNION_WAKE_DURATION (2 MINUTES) -/// Multiplier on a woken machine's health and melee damage over a stock animated one. -#define COMMUNION_WAKE_SCALE 2 -/// Filter key and colour for the outline a woken machine wears, so the room can tell -/// which vending machine is yours. -#define COMMUNION_WAKE_FILTER "communion_woken" -#define COMMUNION_WAKE_COLOR "#63c8d6" // ========================================================================= // THE WARFRAME @@ -1802,6 +1747,38 @@ GLOBAL_LIST_EMPTY(warframe_gates) var/datum/action/room = new /datum/action/cooldown/spell/mass_hack(owner || user) room.Grant(user) +/** + * What the capstone carries: every hack written for it, the two boon-only ones + * included, at full reach with no upload, cooldowns as written and the detailed + * tooltips. Listed by hand so hacks added for cyberdecks never reach the boon. + * The hacks live in voidcrew/modules/quickhacks/; see [/datum/quickhack_kit]. + */ +/datum/quickhack_kit/communion + quickhack_types = list( + /datum/machine_quickhack/overload, + /datum/machine_quickhack/safeties, + /datum/machine_quickhack/bolts, + /datum/machine_quickhack/electrify, + /datum/machine_quickhack/kill_power, + /datum/machine_quickhack/burst_lights, + /datum/machine_quickhack/feedback_surge, + /datum/machine_quickhack/cutout, + /datum/machine_quickhack/silence, + ) + masshack_types = list( + /datum/machine_masshack/overload, + /datum/machine_masshack/arc_flash, + /datum/machine_masshack/wake, + ) + hack_range = COMMUNION_RANGE + upload_time = 0 + cooldown_mult = 1 + needs_sight = FALSE + detailed_menu = TRUE + allow_boon_only = TRUE + +GLOBAL_DATUM_INIT(communion_quickhack_kit, /datum/quickhack_kit/communion, new) + /** * The capstone ability. * @@ -1877,13 +1854,7 @@ GLOBAL_LIST_EMPTY(warframe_gates) /// Hacks this target could accept right now. /datum/action/cooldown/spell/machine_communion/proc/applicable_hacks(atom/cast_on, mob/living/user) - var/list/found = list() - if(QDELETED(cast_on) || !isliving(user)) - return found - for(var/datum/machine_quickhack/hack as anything in get_machine_quickhacks()) - if(hack.valid_for(cast_on, user)) - found += hack - return found + return GLOB.communion_quickhack_kit.applicable_quickhacks(cast_on, user) /// Menu validity: we still exist, we still belong to this mob, and the target is still there. /datum/action/cooldown/spell/machine_communion/proc/menu_check(mob/living/user, atom/cast_on) @@ -1908,11 +1879,7 @@ GLOBAL_LIST_EMPTY(warframe_gates) var/list/options = list() var/list/by_name = list() for(var/datum/machine_quickhack/hack as anything in available) - var/datum/radial_menu_choice/option = new() - option.image = image(icon = hack.radial_icon, icon_state = hack.radial_icon_state) - option.name = hack.name - option.info = "[hack.desc] ([round(hack.cooldown / 10)] second cooldown.)" - options[hack.name] = option + options[hack.name] = GLOB.communion_quickhack_kit.menu_option(hack) by_name[hack.name] = hack // autopick_single_option is off deliberately: several of these are expensive and a @@ -1930,439 +1897,13 @@ GLOBAL_LIST_EMPTY(warframe_gates) if(!chosen.execute(cast_on, user)) return user.log_message("used the [chosen.name] quickhack on [cast_on] ([cast_on.type]).", LOG_ATTACK) - StartCooldown(chosen.cooldown) + StartCooldown(GLOB.communion_quickhack_kit.hack_cooldown(chosen)) build_all_button_icons() /// Keep the sleeping input separate from the post-selection action checks. /datum/action/cooldown/spell/machine_communion/proc/choose_hack(mob/living/user, atom/cast_on, list/options) return show_radial_menu(user, cast_on, options, custom_check = CALLBACK(src, PROC_REF(menu_check), user, cast_on), tooltips = TRUE, autopick_single_option = FALSE) -// ========================================================================= -// THE QUICKHACKS -// ========================================================================= - -/** - * One thing Machine Communion can do to one thing. - * - * Stateless singletons, built once into a global list and shared by every owner, so - * nothing here may hold per-cast state. Timers hang off the singleton, which is fine - * because it is never deleted. - */ -/datum/machine_quickhack - /// Shown on the radial slice. - var/name = "Quickhack" - /// Shown in the slice's tooltip. Lead with the effect, then the numbers. - var/desc = "" - /// Radial slice icon. - var/radial_icon = 'icons/mob/actions/actions_AI.dmi' - /// Icon state paired with radial_icon. - var/radial_icon_state = "overload_machine" - /// Cooldown this hack puts on the whole ability. - var/cooldown = 20 SECONDS - -/// Can this hack be run on this target at all? Decides whether the slice is even shown. -/datum/machine_quickhack/proc/valid_for(atom/target, mob/living/user) - return FALSE - -/// Do it. Return TRUE if it landed; a FALSE return costs no cooldown. -/datum/machine_quickhack/proc/execute(atom/target, mob/living/user) - return FALSE - -/// Every quickhack, built once on demand. -GLOBAL_LIST_EMPTY(machine_quickhacks) - -/proc/get_machine_quickhacks() - if(!length(GLOB.machine_quickhacks)) - for(var/datum/machine_quickhack/hack_type as anything in subtypesof(/datum/machine_quickhack)) - GLOB.machine_quickhacks += new hack_type() - return GLOB.machine_quickhacks - -// ===== OVERLOAD ===== - -/** - * Straight off `/datum/action/innate/ai/ranged/overload_machine` (malf_ai_modules.dm:521), - * including its blacklist, with a smaller blast because this one is repeatable rather - * than two uses a purchase. Lights burst instead of exploding. A light bulb should not - * take out a wall. - */ -/datum/machine_quickhack/overload - name = "Overload" - desc = "Feed the machine power until it comes apart. Four seconds of loud buzzing, then a small explosion that destroys it. Lights just burst." - radial_icon_state = "overload_machine" - cooldown = 45 SECONDS - -/datum/machine_quickhack/overload/valid_for(atom/target, mob/living/user) - if(!ismachinery(target)) - return FALSE - var/obj/machinery/victim = target - if(victim.resistance_flags & INDESTRUCTIBLE) - return FALSE - return !is_type_in_typecache(victim, GLOB.blacklisted_malf_machines) - -/datum/machine_quickhack/overload/execute(atom/target, mob/living/user) - var/obj/machinery/victim = target - victim.audible_message(span_userdanger("[victim] starts buzzing, loudly.")) - playsound(victim, SFX_SPARKS, 60, TRUE) - user.playsound_local(user, 'sound/misc/interference.ogg', 40, FALSE) - // ADMIN_VERBOSEJMP expands to a ternary over its argument, so it needs a plain - // variable, a proc call inlined here doesn't parse inside the string - var/turf/blast_site = get_turf(victim) - message_admins("[ADMIN_LOOKUPFLW(user)] overloaded [victim.name] ([victim.type]) at [ADMIN_VERBOSEJMP(blast_site)] with Machine Communion.") - addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(communion_burst_machine), victim, 1, 3), COMMUNION_OVERLOAD_DELAY) - return TRUE - -/** - * The end of an overload, single or mass: the machine comes apart and takes the tile - * with it. Lights burst instead of exploding, because a light bulb should not take out - * a wall. Blast radii are arguments because the mass version fires a dozen of these and - * has to be much smaller per machine, see [/datum/machine_masshack/overload]. - */ -/proc/communion_burst_machine(obj/machinery/victim, heavy_range = 1, light_range = 3, flash_range = null) - if(QDELETED(victim)) - return - if(istype(victim, /obj/machinery/light)) - var/obj/machinery/light/bulb = victim - bulb.break_light_tube() - return - explosion(victim, heavy_impact_range = heavy_range, light_impact_range = light_range, flash_range = flash_range) - if(!QDELETED(victim)) - qdel(victim) - -// ===== CUT THE SAFETIES ===== - -/** - * `emag_act` on anything that has one. The compatible-type list is lifted from the malf - * targeted override module (malf_ai_modules.dm:1090-1097) so the surface area is a known - * quantity rather than "every atom in the game". - */ -/datum/machine_quickhack/safeties - name = "Cut the Safeties" - desc = "Strip the target's safety interlocks. Exactly what an emag would do to it, which depends entirely on what it is." - radial_icon = 'icons/obj/card.dmi' - radial_icon_state = "emag" - cooldown = 25 SECONDS - -/datum/machine_quickhack/safeties/valid_for(atom/target, mob/living/user) - var/static/list/compatible = list( - /obj/machinery, - /obj/item/modular_computer, - /obj/item/radio/intercom, - /mob/living/basic/bot, - /mob/living/silicon, - /mob/living/simple_animal/bot, - ) - if(!is_type_in_list(target, compatible)) - return FALSE - if(ismachinery(target)) - var/obj/machinery/machine = target - return machine.is_operational - return TRUE - -/datum/machine_quickhack/safeties/execute(atom/target, mob/living/user) - if(!target.emag_act(user)) - target.balloon_alert(user, "nothing to strip!") - return FALSE - playsound(target, SFX_SPARKS, 50, TRUE) - to_chat(user, span_notice("[target] drops its safety interlocks.")) - return TRUE - -// ===== BOLTS ===== - -/datum/machine_quickhack/bolts - name = "Bolts" - desc = "Throw the door's bolts, or pull them if they are already down. Works on the AI wire, so a cut door will not answer." - radial_icon_state = "lockdown" - cooldown = 8 SECONDS - -/datum/machine_quickhack/bolts/valid_for(atom/target, mob/living/user) - return istype(target, /obj/machinery/door/airlock) - -/datum/machine_quickhack/bolts/execute(atom/target, mob/living/user) - var/obj/machinery/door/airlock/door = target - if(!door.canAIControl(user) || door.machine_stat) - door.balloon_alert(user, "no interface!") - return FALSE - if(door.locked) - door.unbolt() - door.balloon_alert(user, "bolts up") - else - door.bolt() - door.balloon_alert(user, "bolts down") - return TRUE - -// ===== ELECTRIFY ===== - -/** - * Stock electrification alone is not worth a capstone hack. A shock through the frame is - * `ELECTROCUTE_DAMAGE(area APC's spare energy)`, which is 0 below a kilojoule and jumps - * straight to 20 above it, so on a derelict with a flat cell the door does nothing at all, - * no burn and no stun, and `shock()` returning FALSE means it opens for them anyway. - * - * So a door the vestige has electrified carries its own current: a flat - * [COMMUNION_ELECTRIFY_DAMAGE] burn and a [COMMUNION_ELECTRIFY_STUN] stun no matter how little - * the local powernet has left in it. The door still has to be powered at all, same as ever. - * The hack cannot be cast on a dead one. That is what `communion_live` below is for. - */ -/datum/machine_quickhack/electrify - name = "Electrify" - desc = "Run the door's frame live for 30 seconds. It draws on your own current rather than the area's, so a \ - nearly-empty APC still lands the shock. Touching it without insulated gloves is 20 burn and a stun." - radial_icon = 'icons/obj/machines/wallmounts.dmi' - radial_icon_state = "apc-spark" - cooldown = 20 SECONDS - -/datum/machine_quickhack/electrify/valid_for(atom/target, mob/living/user) - return istype(target, /obj/machinery/door/airlock) - -/datum/machine_quickhack/electrify/execute(atom/target, mob/living/user) - var/obj/machinery/door/airlock/door = target - if(!door.canAIControl(user) || door.machine_stat) - door.balloon_alert(user, "no interface!") - return FALSE - door.set_electrified(COMMUNION_ELECTRIFY_SECONDS, user) - door.set_communion_live(COMMUNION_ELECTRIFY_SECONDS) - door.balloon_alert(user, "live") - playsound(door, SFX_SPARKS, 50, TRUE) - return TRUE - -/// Set for as long as a Machine Communion electrification lasts. See [/datum/machine_quickhack/electrify]. -/obj/machinery/door/airlock/var/communion_live = FALSE - -/// Makes this airlock's electrification self-powered for `seconds`, matching the seconds `set_electrified` takes. -/obj/machinery/door/airlock/proc/set_communion_live(seconds) - communion_live = TRUE - addtimer(VARSET_CALLBACK(src, communion_live, FALSE), seconds SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE) - -/** - * Every path that shocks somebody on an airlock funnels through here, so overriding it once - * covers bumping the door, touching it, and prying at it with a crowbar alike. Off a - * communion-live door we do the parent's job by hand rather than through `electrocute_mob`, - * which reads its damage off the area's APC and refuses non-carbons outright. - * - * `electrocute_act` with default flags still runs the victim's siemens coefficient, so - * insulated gloves zero it out and nothing lands, same as an ordinary electrified door. - * The explicit Paralyze is what carries the stun onto simple and basic mobs, whose - * `electrocute_act` only ever adjusts fire loss. - */ -/obj/machinery/door/airlock/shock(mob/living/user, prb) - if(!communion_live || !isElectrified()) - return ..() - // Gating lifted from the parent: same power requirement, same one-second-per-victim grace - // so a live doorway is not a stunlock, same probability roll. - if(!istype(user) || !hasPower()) - return FALSE - if(HAS_TRAIT(user, TRAIT_AIRLOCK_SHOCKIMMUNE)) - return FALSE - if(!COOLDOWN_FINISHED(src, shockCooldown)) - return FALSE - if(!prob(prb)) - return FALSE - do_sparks(5, TRUE, src) - if(!user.electrocute_act(COMMUNION_ELECTRIFY_DAMAGE, src)) - return FALSE - user.Paralyze(COMMUNION_ELECTRIFY_STUN) - log_combat(src, user, "electrocuted") - COOLDOWN_START(src, shockCooldown, 1 SECONDS) - ADD_TRAIT(user, TRAIT_AIRLOCK_SHOCKIMMUNE, REF(src)) - addtimer(TRAIT_CALLBACK_REMOVE(user, TRAIT_AIRLOCK_SHOCKIMMUNE, REF(src)), 1 SECONDS) - return TRUE - -// ===== KILL THE POWER ===== - -/datum/machine_quickhack/kill_power - name = "Kill the Power" - desc = "Flip an APC's breaker. Lights, equipment and air handling across that whole area go dead until it is flipped back." - radial_icon = 'icons/obj/machines/wallmounts.dmi' - radial_icon_state = "apc0" - cooldown = 30 SECONDS - -/datum/machine_quickhack/kill_power/valid_for(atom/target, mob/living/user) - return isapc(target) - -/datum/machine_quickhack/kill_power/execute(atom/target, mob/living/user) - var/obj/machinery/power/apc/apc = target - if(apc.aidisabled || !apc.is_operational || apc.failure_timer) - apc.balloon_alert(user, "no interface!") - return FALSE - apc.toggle_breaker(user) - playsound(apc, 'sound/machines/terminal/terminal_off.ogg', 50, FALSE) - return TRUE - -// ===== BURST THE LIGHTS ===== - -/** - * The malf blackout, aimed at one APC instead of every APC on the station. - * - * It breaks the tubes directly rather than going through `overload_lighting()`, which - * silently does nothing when the APC's cell is flat (apc_main.dm:732-736), a hack that - * fails invisibly is worse than one that does not exist. - */ -/datum/machine_quickhack/burst_lights - name = "Burst the Lights" - desc = "Push an APC's lighting circuit until every bulb in its area breaks. They stay broken." - radial_icon_state = "blackout" - cooldown = 30 SECONDS - -/datum/machine_quickhack/burst_lights/valid_for(atom/target, mob/living/user) - return isapc(target) - -/datum/machine_quickhack/burst_lights/execute(atom/target, mob/living/user) - var/obj/machinery/power/apc/apc = target - if(apc.aidisabled) - apc.balloon_alert(user, "no interface!") - return FALSE - var/burst = 0 - for(var/obj/machinery/light/bulb as anything in apc.get_lights()) - if(QDELETED(bulb)) - continue - bulb.on = TRUE - bulb.break_light_tube() - burst++ - CHECK_TICK - if(!burst) - apc.balloon_alert(user, "no lights on this circuit!") - return FALSE - playsound(apc, 'sound/effects/light_flicker.ogg', 60, FALSE) - to_chat(user, span_notice("Overcurrent applied. [burst] bulb\s gone.")) - return TRUE - -// ===== FEEDBACK SURGE ===== - -/** - * The headline, and the part of the spec that has no prior art to lift: everything a - * person is carrying that holds a charge dumps that charge into the person. - * - * The shock scales with what they were carrying, so this is a hard counter to somebody - * walking around with an armoury and close to nothing against somebody in a jumpsuit. - * SHOCK_NOGLOVES is deliberate, the current is coming out of their own belt, and - * insulated gloves have nothing to do with it. - */ -/datum/machine_quickhack/feedback_surge - name = "Feedback Surge" - desc = "Dump everything a person's gear is holding into the person. Empties every power cell they are carrying and shocks them for up to 60 burn, scaled to how much charge there was." - radial_icon = 'icons/obj/weapons/baton.dmi' - radial_icon_state = "stunbaton_active" - cooldown = 60 SECONDS - -/datum/machine_quickhack/feedback_surge/valid_for(atom/target, mob/living/user) - if(!isliving(target) || target == user) - return FALSE - var/mob/living/victim = target - return victim.stat != DEAD - -/datum/machine_quickhack/feedback_surge/execute(atom/target, mob/living/user) - var/mob/living/victim = target - var/drained = vestige_drain_carried_cells(victim) - if(drained <= 0) - victim.balloon_alert(user, "carrying nothing live!") - return FALSE - - var/shock = clamp(round(drained / STANDARD_CELL_CHARGE) * COMMUNION_SURGE_PER_CELL, COMMUNION_SURGE_MIN, COMMUNION_SURGE_MAX) - victim.visible_message( - span_boldwarning("Everything [victim] is carrying goes off at once in a sheet of sparks!"), - span_userdanger("Every powered thing on you empties itself through you!"), - ) - do_sparks(5, FALSE, victim) - playsound(victim, 'sound/items/weapons/egloves.ogg', 75, TRUE) - victim.electrocute_act(shock, user, flags = SHOCK_NOGLOVES|SHOCK_KNOCKDOWN) - victim.drop_all_held_items() - return TRUE - -// ===== CUTOUT ===== - -/** - * The quiet half of the same trick. No shock, no stun, no announcement, their gear - * simply stops working and their radios stop transmitting, and they find out when they - * pull a trigger. Half the cooldown, none of the damage. - */ -/datum/machine_quickhack/cutout - name = "Cutout" - desc = "Quietly empty every power cell a person is carrying and switch their radios off for 45 seconds. No shock and no stun; their gear just stops." - radial_icon = 'icons/obj/weapons/baton.dmi' - radial_icon_state = "stunbaton_nocell" - cooldown = 35 SECONDS - -/datum/machine_quickhack/cutout/valid_for(atom/target, mob/living/user) - if(!isliving(target) || target == user) - return FALSE - var/mob/living/victim = target - return victim.stat != DEAD - -/datum/machine_quickhack/cutout/execute(atom/target, mob/living/user) - var/mob/living/victim = target - var/drained = vestige_drain_carried_cells(victim) - var/silenced = 0 - for(var/obj/item/radio/radio in victim.get_all_contents()) - if(communion_radio_off(radio)) - silenced++ - if(drained <= 0 && !silenced) - victim.balloon_alert(user, "carrying nothing live!") - return FALSE - to_chat(user, span_notice("[victim]'s gear goes quiet. [victim.p_they(TRUE)] [victim.p_do()]n't seem to have noticed.")) - // The tell lands a beat later, and it is the only one they get. - addtimer(CALLBACK(src, PROC_REF(notice), victim), 2 SECONDS) - return TRUE - -/datum/machine_quickhack/cutout/proc/notice(mob/living/victim) - if(QDELETED(victim) || victim.stat == DEAD) - return - to_chat(victim, span_warning("Something in your gear clicks, and every charge light you can see goes out.")) - -// ===== SILENCE ===== - -/datum/machine_quickhack/silence - name = "Silence" - desc = "Jam every radio within nine tiles of what you clicked for 45 seconds, and switch the ones already in range off outright for as long." - radial_icon = 'icons/obj/devices/syndie_gadget.dmi' - radial_icon_state = "jammer" - cooldown = 40 SECONDS - -/datum/machine_quickhack/silence/valid_for(atom/target, mob/living/user) - var/turf/spot = get_turf(target) - if(isnull(spot)) - return FALSE - // Only offered where there is somebody to silence. Jamming an empty corridor burns - // forty seconds for nothing, and this hack is targetable at almost anything. - for(var/mob/living/candidate in view(COMMUNION_JAM_RANGE, spot)) - if(candidate != user) - return TRUE - return FALSE - -/datum/machine_quickhack/silence/execute(atom/target, mob/living/user) - var/turf/spot = get_turf(target) - if(isnull(spot)) - return FALSE - new /obj/item/jammer/machine_communion(spot) - for(var/mob/living/holder in view(COMMUNION_JAM_RANGE, spot)) - for(var/obj/item/radio/radio in holder.get_all_contents()) - communion_radio_off(radio) - for(var/obj/item/radio/loose in view(COMMUNION_JAM_RANGE, spot)) - communion_radio_off(loose) - to_chat(user, span_notice("Everything with an aerial around [target] stops talking.")) - playsound(spot, 'sound/misc/interference.ogg', 40, TRUE) - return TRUE - -/** - * The Silence bubble. - * - * `is_within_radio_jammer_range()` walks `GLOB.active_jammers` and reads `.range` off - * each entry (traitordevices.dm:318-322), so the cheapest correct way to jam an area is - * to be a jammer. Invisible, unclickable, and it deletes itself. - */ -/obj/item/jammer/machine_communion - name = "silence" - desc = "You should not be able to see this." - invisibility = INVISIBILITY_ABSTRACT - mouse_opacity = MOUSE_OPACITY_TRANSPARENT - anchored = TRUE - range = COMMUNION_JAM_RANGE - -/obj/item/jammer/machine_communion/Initialize(mapload) - . = ..() - active = TRUE - GLOB.active_jammers |= src - QDEL_IN(src, COMMUNION_JAM_DURATION) - // ========================================================================= // MASS HACK: THE ROOM BUTTON // ========================================================================= @@ -2406,14 +1947,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) /// Mass hacks that have something to work on right now, from the caster's tile. /datum/action/cooldown/spell/mass_hack/proc/applicable_hacks(mob/living/user) - var/list/found = list() - var/turf/here = get_turf(user) - if(isnull(here)) - return found - for(var/datum/machine_masshack/hack as anything in get_machine_masshacks()) - if(hack.available(user, here)) - found += hack - return found + return GLOB.communion_quickhack_kit.applicable_masshacks(user, get_turf(user)) /// Menu validity: we still exist and still belong to this mob. Where they are standing /// when they confirm is where it lands, so there is nothing else to re-check. @@ -2443,11 +1977,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) var/list/options = list() var/list/by_name = list() for(var/datum/machine_masshack/hack as anything in available) - var/datum/radial_menu_choice/option = new() - option.image = image(icon = hack.radial_icon, icon_state = hack.radial_icon_state) - option.name = hack.name - option.info = "[hack.desc] ([round(hack.cooldown / 600)] minute cooldown.)" - options[hack.name] = option + options[hack.name] = GLOB.communion_quickhack_kit.menu_option(hack) by_name[hack.name] = hack // Same reasoning as the quickhack radial: every one of these is expensive, so a room @@ -2483,429 +2013,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) return user.log_message("used the [hack.name] mass hack with Machine Communion.", LOG_ATTACK) message_admins("[ADMIN_LOOKUPFLW(user)] used the [hack.name] mass hack at [ADMIN_VERBOSEJMP(here)].") - StartCooldown(hack.cooldown) - -// ========================================================================= -// THE MASS HACKS -// ========================================================================= - -/** - * One thing Mass Hack can do to one room. Stateless singletons like the quickhacks, - * built once into a global list and shared by every owner, so nothing here may hold - * per-cast state. Timers hang off the singleton, which is fine because it is never - * deleted; anything a timer needs comes in as a callback argument. - */ -/datum/machine_masshack - /// Shown on the radial slice. - var/name = "Mass Hack" - /// Shown in the slice's tooltip. Lead with the effect, then the numbers. - var/desc = "" - /// Radial slice icon. - var/radial_icon = 'icons/mob/actions/actions_AI.dmi' - /// Icon state paired with radial_icon. - var/radial_icon_state = "overload_machine" - /// Cooldown this hack puts on the ability. - var/cooldown = 2 MINUTES - -/// Is there anything in this room for this hack to do? Decides whether the slice is shown. -/datum/machine_masshack/proc/available(mob/living/user, turf/center) - return FALSE - -/// Do it. Return TRUE if it landed; a FALSE return costs no cooldown. -/datum/machine_masshack/proc/execute(mob/living/user, turf/center) - return FALSE - -/// Every mass hack, built once on demand. -GLOBAL_LIST_EMPTY(machine_masshacks) - -/proc/get_machine_masshacks() - if(!length(GLOB.machine_masshacks)) - for(var/datum/machine_masshack/hack_type as anything in subtypesof(/datum/machine_masshack)) - GLOB.machine_masshacks += new hack_type() - return GLOB.machine_masshacks - -/** - * The machines in a room that a mass hack may touch, nearest first, capped. - * - * The filter matters more here than it does for a single click, because the player is not - * choosing the targets. Two things it deliberately drops that `ismachinery` would keep: - * - * - **Atmospherics.** Pipes, vents and scrubbers are machinery, most of a room's pipework - * is under the floor and invisible, and eating a distribution loop is not a hack, it is - * an atmos incident nobody asked for. - * - **Anything hidden.** Power terminals and buried plumbing sit on the tile at - * INVISIBILITY_MAXIMUM. If the caster cannot see it, it is not "in the room". - * - * `standing_only` narrows it further to the free-standing machines, dense, and not a - * door. That is the set Wake the Machines uses, because a light switch getting up and - * walking around is not the effect anybody wants. - * - * `skip_lights` drops light fixtures. Overload the Room uses it so that a room with six - * ceiling tubes does not spend half its twelve-machine budget on bulbs; it bursts them - * separately and for free. Arc Flash deliberately leaves them in, a light tube arcing at - * somebody is exactly the effect. - */ -/proc/communion_mass_machines(turf/center, cap = COMMUNION_MASS_CAP, standing_only = FALSE, skip_lights = FALSE) - var/list/found = list() - if(isnull(center)) - return found - var/static/list/mass_hack_blacklist = typecacheof(list( - /obj/machinery/atmospherics, - /obj/machinery/portable_atmospherics, - /obj/machinery/duct, - /obj/machinery/power/terminal, - // The cover is the closed turret's sprite standing in for the turret, which is in - // view right beside it. Taking both would double-count one object. - /obj/machinery/porta_turret_cover, - )) - for(var/obj/machinery/machine in view(COMMUNION_MASS_RANGE, center)) - if(machine.resistance_flags & INDESTRUCTIBLE) - continue - if(is_type_in_typecache(machine, GLOB.blacklisted_malf_machines) || is_type_in_typecache(machine, mass_hack_blacklist)) - continue - if(machine.invisibility > SEE_INVISIBLE_LIVING) - continue - if(skip_lights && istype(machine, /obj/machinery/light)) - continue - if(standing_only && (!machine.density || istype(machine, /obj/machinery/door))) - continue - found += machine - - if(length(found) <= cap) - return found - // view() is documented nowhere as returning in distance order, and which machines get - // cut is the difference between a room hack and a corridor hack. Bucket by distance. - var/list/nearest = list() - for(var/distance in 0 to COMMUNION_MASS_RANGE) - for(var/obj/machinery/machine as anything in found) - if(get_dist(center, machine) != distance) - continue - nearest += machine - if(length(nearest) >= cap) - return nearest - return nearest - -// ===== OVERLOAD THE ROOM ===== - -/** - * The single-target Overload, applied to everything at once, at full strength. - * - * Every machine gets the same blast the single-target hack gives one, heavy 1, light 3, - * so this is twelve real explosions in a room, not twelve small ones. It levels the room: - * EXPLODE_HEAVY always dismantles a wall, EXPLODE_LIGHT takes one at `prob(hardness)`, and - * an outer wall in the pattern means the compartment vents. One EXPLODE_LIGHT alone is 30 - * brute, sixteen seconds of knockdown and a real chance of losing a limb, and nobody - * standing in this gets only one. - * - * There is exactly one piece of counterplay and everybody gets it, the caster included: - * five seconds of every machine in the room buzzing at `span_userdanger`. Leave. - * - * Two things keep it from being unbounded rather than merely enormous. The twelve-machine - * cap holds the tick cost and the blast pattern to something a room can contain, and the - * 0.2s stagger means the blasts land one at a time, which also means a machine killed by - * an earlier blast simply no-ops when its own timer comes up, so the cascade eats itself - * from the middle outwards instead of double-counting. - */ -/datum/machine_masshack/overload - name = "Overload the Room" - desc = "Feed every machine around you power at once, each one at full overload strength. Five seconds of the whole \ - room buzzing, then up to twelve real explosions land a fifth of a second apart. It takes the walls with it and \ - it will breach a hull if one is in the pattern. Lights just burst. Nothing that goes up comes back, and you are \ - standing in the middle of it, leave." - radial_icon_state = "overload_machine" - cooldown = 3 MINUTES - -/datum/machine_masshack/overload/available(mob/living/user, turf/center) - return length(communion_mass_machines(center, skip_lights = TRUE)) > 0 - -/datum/machine_masshack/overload/execute(mob/living/user, turf/center) - var/list/targets = communion_mass_machines(center, skip_lights = TRUE) - if(!length(targets)) - return FALSE - user.playsound_local(user, 'sound/misc/interference.ogg', 60, FALSE) - playsound(center, SFX_SPARKS, 70, TRUE) - for(var/index in 1 to length(targets)) - var/obj/machinery/victim = targets[index] - victim.audible_message(span_userdanger("[victim] starts buzzing, loudly.")) - var/datum/callback/burst = CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(communion_burst_machine), victim, COMMUNION_MASS_OVERLOAD_HEAVY, COMMUNION_MASS_OVERLOAD_LIGHT, COMMUNION_MASS_OVERLOAD_FLASH) - addtimer(burst, COMMUNION_MASS_OVERLOAD_DELAY + ((index - 1) * COMMUNION_MASS_OVERLOAD_STAGGER)) - // Lights are outside the cap and go on the same clock, so the room goes dark as it goes up. - for(var/obj/machinery/light/bulb in view(COMMUNION_MASS_RANGE, center)) - addtimer(CALLBACK(bulb, TYPE_PROC_REF(/obj/machinery/light, break_light_tube)), COMMUNION_MASS_OVERLOAD_DELAY) - to_chat(user, span_userdanger("Every machine in the room takes the charge. [length(targets)] of them. Get out.")) - return TRUE - -// ===== ARC FLASH ===== - -/** - * Every powered thing in the room earths itself through whoever is standing nearest to it, - * five times over seven and a half seconds. - * - * Deliberately not `tesla_zap`: that proc is a chain, it damages every machine and - * structure it hops through, it electrolyses the air on each landing, and it recurses - * unboundedly out of the room. What is wanted here is one arc per person per volley from - * whatever machine happens to be nearest them, so it is written out longhand. - * - * Iterating victims rather than sources is what bounds the damage: it does not matter - * whether somebody is standing in front of one machine or ten, they take one arc per - * volley. Move out of the room and the volleys stop finding you. - */ -/datum/machine_masshack/arc_flash - name = "Arc Flash" - desc = "Every powered machine around you starts earthing itself through whoever is standing nearest to it. Five \ - volleys a second and a half apart, 14 burn each, and it comes out of the wall rather than through their hands, \ - so insulated gloves do nothing. It hits everyone in the room except you, friend or not." - radial_icon = 'icons/mob/actions/actions_spells.dmi' - radial_icon_state = "lightning" - cooldown = 2 MINUTES - -/datum/machine_masshack/arc_flash/available(mob/living/user, turf/center) - if(!length(communion_arc_sources(center))) - return FALSE - for(var/mob/living/candidate in view(COMMUNION_MASS_RANGE, center)) - if(candidate != user && candidate.stat != DEAD) - return TRUE - return FALSE - -/datum/machine_masshack/arc_flash/execute(mob/living/user, turf/center) - var/list/sources = communion_arc_sources(center) - if(!length(sources)) - return FALSE - // The charged room moves with its ship, and unloading it must end the remaining arcs. - var/obj/effect/vestige_trial_marker/room = new(center) - QDEL_IN(room, COMMUNION_ARC_PULSES * COMMUNION_ARC_INTERVAL) - to_chat(user, span_boldwarning("Everything with a current in it starts looking for somewhere to put it.")) - for(var/pulse in 1 to COMMUNION_ARC_PULSES) - addtimer(CALLBACK(src, PROC_REF(volley), WEAKREF(room), WEAKREF(user)), (pulse - 1) * COMMUNION_ARC_INTERVAL) - return TRUE - -/// One volley: everybody in the room takes an arc from the machine nearest them, if any. -/datum/machine_masshack/arc_flash/proc/volley(datum/weakref/room_ref, datum/weakref/caster_ref) - var/turf/center = get_turf(room_ref?.resolve()) - if(QDELETED(center)) - return - var/mob/living/caster = caster_ref?.resolve() - // The sources are re-gathered every volley on purpose: a machine that lost power or - // was destroyed between volleys stops throwing, and so does one that was never on. - var/list/sources = communion_arc_sources(center) - if(!length(sources)) - return - var/landed = 0 - for(var/mob/living/victim in view(COMMUNION_MASS_RANGE, center)) - if(victim == caster || victim.stat == DEAD) - continue - var/obj/machinery/source = communion_nearest_source(victim, sources) - if(isnull(source)) - continue - source.Beam(victim, icon_state = "lightning[rand(1, 12)]", time = 0.4 SECONDS) - do_sparks(2, FALSE, victim) - // SHOCK_NOGLOVES because the current is coming out of the wall beside them rather - // than through anything they are holding. SHOCK_NOSTUN because five volleys of - // stock electrocution is a seven-second stunlock, and the counterplay is supposed - // to be walking out of the room. - victim.electrocute_act(COMMUNION_ARC_DAMAGE, source, flags = SHOCK_NOGLOVES|SHOCK_NOSTUN) - landed++ - if(landed) - playsound(center, 'sound/effects/magic/lightningbolt.ogg', 50, TRUE) - -/// Powered machines in the room that could throw an arc. -/proc/communion_arc_sources(turf/center) - var/list/sources = list() - for(var/obj/machinery/machine as anything in communion_mass_machines(center, cap = COMMUNION_MASS_CAP)) - if(machine.is_operational) - sources += machine - return sources - -/// The nearest source with line of sight to this victim, or null if none is close enough. -/proc/communion_nearest_source(mob/living/victim, list/sources) - var/obj/machinery/closest - var/closest_distance = COMMUNION_ARC_REACH + 1 - for(var/obj/machinery/candidate as anything in sources) - if(QDELETED(candidate)) - continue - var/distance = get_dist(candidate, victim) - if(distance >= closest_distance) - continue - if(!can_see(candidate, victim, COMMUNION_ARC_REACH)) - continue - closest = candidate - closest_distance = distance - return closest - -// ===== WAKE THE MACHINES ===== - -/** - * The room stands up and takes your side. - * - * `/mob/living/basic/mimic/copy/machine` is the same mob the malf AI's Override Machine - * animates (malf_ai_modules.dm:485), so the animation, the visuals, the stat block and the - * melee are all upstream's. Three things are ours: - * - * - The AI controller uses `/datum/targeting_strategy/basic/not_friends`. The stock mimic - * uses plain `/basic`, which reads factions only, and `befriend` writes the owner's - * REF into the MIMIC'S faction list, not the owner's, so a plain faction check does not - * match and the machine happily attacks the person who woke it. `not_friends` is the - * strategy that reads `BB_FRIENDS_LIST`, which is the half of `befriend` that knows who - * woke this thing up. - * - * The catch, and it bit: `not_friends` also overrides `faction_check` to return FALSE - * unconditionally, "friends dont care about factions". Factions stop being a reason to - * spare anyone at all, so two woken machines sharing FACTION_MIMIC will happily beat each - * other to death. The friends list is the ONLY authority under this strategy, which is why - * [/datum/machine_masshack/wake] introduces every machine in a batch to every other one - * after they are all built. Anything added here that should not be attacked has to go on - * that list; adding a faction will not do it. - * - A clock. Woken machines fall apart after two minutes rather than wandering the ship - * until the stock idle decay finishes them. - * - Twice the health and twice the melee, because eight of these have to be worth three - * minutes on the same button as Overload the Room. - * - * The machine is consumed, `destroy_original` is TRUE, same as the malf module, because - * the alternative (storing it inside the mob and dropping it on death, which is what - * animated structures do) means forceMoving live machinery into a mob's contents and all - * the power and pipe bookkeeping that implies. It is a real cost and the desc says so. - */ -/datum/machine_masshack/wake - name = "Wake the Machines" - desc = "Stand every free-standing machine in the room up to fight for you. Up to eight of them, twice as tough as an \ - animated machine and hitting twice as hard, for two minutes before they fall apart. They know you and nobody \ - else, everyone else in the room is a stranger to them. The machines do not come back." - radial_icon_state = "override_machine" - cooldown = 3 MINUTES - -/datum/machine_masshack/wake/available(mob/living/user, turf/center) - return length(communion_mass_machines(center, cap = COMMUNION_WAKE_CAP, standing_only = TRUE)) > 0 - -/datum/machine_masshack/wake/execute(mob/living/user, turf/center) - var/list/targets = communion_mass_machines(center, cap = COMMUNION_WAKE_CAP, standing_only = TRUE) - if(!length(targets)) - return FALSE - user.playsound_local(user, 'sound/misc/interference.ogg', 60, FALSE) - var/list/woken = list() - for(var/obj/machinery/sleeper as anything in targets) - sleeper.audible_message(span_userdanger("[sleeper] shudders, and stands up.")) - var/mob/living/basic/mimic/copy/machine/communion/risen = new(get_turf(sleeper), sleeper, user, TRUE) - if(!QDELETED(risen)) - woken += risen - if(!length(woken)) - return FALSE - // Introduce the batch to each other, or they fight each other instead of anyone else. - // Sharing FACTION_MIMIC does NOT cover this: not_friends overrides `faction_check` to - // return FALSE unconditionally, so factions stop being a reason to spare anybody and the - // friends list becomes the only one. See [/mob/living/basic/mimic/copy/machine/communion]. - for(var/mob/living/first as anything in woken) - for(var/mob/living/second as anything in woken) - if(first != second) - first.befriend(second) - to_chat(user, span_boldnotice("[length(woken)] of them get up. They know your face and each other's, and nobody else's.")) - return TRUE - -/** - * A machine woken by the capstone. See [/datum/machine_masshack/wake] for why each - * difference from the stock animated machine is there. - */ -/mob/living/basic/mimic/copy/machine/communion - ai_controller = /datum/ai_controller/basic_controller/mimic_copy/machine/communion - -/datum/ai_controller/basic_controller/mimic_copy/machine/communion - // not_friends is the whole point. See the file comment. It reads BB_FRIENDS_LIST, - // which is the half of befriend() that actually knows who woke this thing up. - blackboard = list( - BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/not_friends, - ) - planning_subtrees = list( - /datum/ai_planning_subtree/escape_captivity, - /datum/ai_planning_subtree/simple_find_target, - /datum/ai_planning_subtree/attack_obstacle_in_path, - /datum/ai_planning_subtree/random_speech/when_has_target/communion_machine, - /datum/ai_planning_subtree/basic_melee_attack_subtree, - ) - -/datum/ai_planning_subtree/random_speech/when_has_target/communion_machine - speech_chance = 6 - emote_hear = list() - speak = list( - "Clear the room.", - "You are not on the list.", - "Stand still.", - "Instruction received.", - "This is being logged.", - "Stop moving.", - ) - -// The stat block is set by CopyObject inside the parent's Initialize, so the scaling has -// to come after it. no_googlies defaults on because the stock mimic's googly eyes are the -// joke read of this mob and the wrong one here; the outline does the same job of telling -// the room which vending machine is somebody's. -/mob/living/basic/mimic/copy/machine/communion/Initialize(mapload, obj/copy, mob/living/creator, destroy_original = TRUE, no_googlies = TRUE) - . = ..() - maxHealth = round(maxHealth * COMMUNION_WAKE_SCALE) - health = maxHealth - melee_damage_lower = round(melee_damage_lower * COMMUNION_WAKE_SCALE) - melee_damage_upper = round(melee_damage_upper * COMMUNION_WAKE_SCALE) - // The stock decay exists so an animated object with nothing to kill eventually stops. - // This one has a hard clock instead, so it does not quietly die mid-fight for standing - // still, and does not outlive its two minutes for having something to chase. - idledamage = FALSE - add_filter(COMMUNION_WAKE_FILTER, 2, list("type" = "outline", "color" = COMMUNION_WAKE_COLOR, "size" = 1)) - addtimer(CALLBACK(src, PROC_REF(fall_apart)), COMMUNION_WAKE_DURATION) - -/mob/living/basic/mimic/copy/machine/communion/proc/fall_apart() - if(QDELETED(src) || stat == DEAD) - return - visible_message(span_warning("[src] stops dead, and falls over.")) - playsound(src, SFX_SPARKS, 50, TRUE) - death() - -// ===== SHARED HELPERS ===== - -/** - * Switches a radio off the way an EMP does, and books the same recovery. - * - * `set_broadcasting(FALSE)` is NOT this: broadcasting is the open-mic hot function, - * already off on every standard headset, and `talk_into` ignores it entirely - * (radio.dm:233-234 says so in as many words). The switch that actually gates both - * talking and hearing is `on`, and riding the radio's own EMP bookkeeping - * (`emped` + [/obj/item/radio/proc/end_emp_effect]) means a real EMP landing - * mid-outage extends it instead of having it cancelled by our recovery timer. - * - * Returns TRUE if the radio was on and is now off. - */ -/proc/communion_radio_off(obj/item/radio/radio, duration = COMMUNION_RADIO_OFF_DURATION) - if(QDELETED(radio) || !radio.on) - return FALSE - radio.emped++ - radio.set_on(FALSE) - addtimer(CALLBACK(radio, TYPE_PROC_REF(/obj/item/radio, end_emp_effect), radio.emped), duration) - return TRUE - -/** - * Empties every power cell inside an atom and returns the total charge taken, in joules. - * - * Works off `get_cell()`, which is the codebase's one honest answer to "does this thing - * hold a charge", energy guns, security batons, MOD suits, tablets, defibs, cyborgs and - * bare cells all implement it. Cells are deduplicated because a gun and the cell inside - * it both show up in `get_all_contents()` and both resolve to the same cell. - */ -/proc/vestige_drain_carried_cells(atom/holder) - if(QDELETED(holder)) - return 0 - var/total = 0 - var/list/already_drained = list() - for(var/atom/movable/thing as anything in holder.get_all_contents()) - if(thing == holder) - continue - var/obj/item/stock_parts/power_store/cell = thing.get_cell() - if(isnull(cell) || (cell in already_drained)) - continue - already_drained += cell - if(cell.charge <= 0) - continue - total += cell.use(cell.charge, TRUE) - cell.update_appearance() - if(thing != cell) - thing.update_appearance() - return total + StartCooldown(GLOB.communion_quickhack_kit.hack_cooldown(hack)) #undef WARFRAME_SECOND_ROUND_THRESHOLD #undef WARFRAME_ROUND_CHANGE_TIME @@ -2952,29 +2060,3 @@ GLOBAL_LIST_EMPTY(machine_masshacks) #undef BB_WARFRAME_LIVE_FLOOR #undef BB_WARFRAME_LAST_ABILITY #undef COMMUNION_RANGE -#undef COMMUNION_ELECTRIFY_SECONDS -#undef COMMUNION_ELECTRIFY_DAMAGE -#undef COMMUNION_ELECTRIFY_STUN -#undef COMMUNION_OVERLOAD_DELAY -#undef COMMUNION_JAM_RANGE -#undef COMMUNION_JAM_DURATION -#undef COMMUNION_RADIO_OFF_DURATION -#undef COMMUNION_SURGE_PER_CELL -#undef COMMUNION_SURGE_MAX -#undef COMMUNION_SURGE_MIN -#undef COMMUNION_MASS_RANGE -#undef COMMUNION_MASS_CAP -#undef COMMUNION_MASS_OVERLOAD_DELAY -#undef COMMUNION_MASS_OVERLOAD_STAGGER -#undef COMMUNION_MASS_OVERLOAD_HEAVY -#undef COMMUNION_MASS_OVERLOAD_LIGHT -#undef COMMUNION_MASS_OVERLOAD_FLASH -#undef COMMUNION_ARC_PULSES -#undef COMMUNION_ARC_INTERVAL -#undef COMMUNION_ARC_REACH -#undef COMMUNION_ARC_DAMAGE -#undef COMMUNION_WAKE_CAP -#undef COMMUNION_WAKE_DURATION -#undef COMMUNION_WAKE_SCALE -#undef COMMUNION_WAKE_FILTER -#undef COMMUNION_WAKE_COLOR diff --git a/voidcrew/modules/cyberware/cradle_ui.dm b/voidcrew/modules/cyberware/cradle_ui.dm index 7ad8beda7ad..89702cde53f 100644 --- a/voidcrew/modules/cyberware/cradle_ui.dm +++ b/voidcrew/modules/cyberware/cradle_ui.dm @@ -18,7 +18,7 @@ * somewhere even before it gets a home. */ GLOBAL_LIST_INIT(cyberware_ui_groups, list( - list("id" = "cortex", "name" = "Frontal Cortex", "region" = "head", "slots" = list(ORGAN_SLOT_CYBERWARE_RIGGER, ORGAN_SLOT_CYBERWARE_REGISTRY)), + list("id" = "cortex", "name" = "Frontal Cortex", "region" = "head", "slots" = list(ORGAN_SLOT_CYBERWARE_RIGGER, ORGAN_SLOT_CYBERWARE_REGISTRY, ORGAN_SLOT_CYBERWARE_DECK)), list("id" = "ocular", "name" = "Ocular System", "region" = "head", "slots" = list(ORGAN_SLOT_EYES)), list("id" = "aural", "name" = "Auditory System", "region" = "head", "slots" = list(ORGAN_SLOT_CYBERWARE_EARS, ORGAN_SLOT_CYBERWARE_LARYNX)), list("id" = "os", "name" = "Operating System", "region" = "torso", "slots" = list(ORGAN_SLOT_CYBERWARE_OS)), diff --git a/voidcrew/modules/cyberware/ware_line_deck.dm b/voidcrew/modules/cyberware/ware_line_deck.dm new file mode 100644 index 00000000000..03be29bbf9b --- /dev/null +++ b/voidcrew/modules/cyberware/ware_line_deck.dm @@ -0,0 +1,701 @@ +/** + * # The deck line + * + * Cortex decks: head chrome that pushes quickhacks into machines and people's + * gear in sight. They run the Machine Communion boon's own hacks + * (voidcrew/modules/quickhacks/), through a [/datum/quickhack_kit] each deck + * builds from its vars, with a few hacks of their own written below. + * + * Every model sits in its own head slot, ORGAN_SLOT_CYBERWARE_DECK, so a + * better deck evicts the old one. Higher models carry everything the model + * below them does; the two service decks split at T3, one for doors, radios and + * turrets, one for people and their gear, and the flagship carries both. + * + * Model tier load range upload cooldowns adds + * Junker T1 2 5 2 s x1.5 Bolts, Burst the Lights, Ping + * Street T2 4 6 1.5 s x1.5 Kill the Power, Cutout + * Facilities T3 6 7 1 s x1 Electrify, Silence, Reboot Turret + * Breacher T3 6 7 1 s x1 Overload, Feedback Surge, Glitch + * Blackbox T4 9 8 0.5 s x1 both T3 sets, and Arc Flash + * + * What a deck never does, whatever its tier: grant TRAIT_SILICON_ACCESS or + * TRAIT_AI_ACCESS or skip an ID lock (no hack here opens a door), run Cut the + * Safeties (the emag effect) or Wake the Machines. Those are the boon's alone; + * the kit refuses them even if a model lists one. + * + * The counterplay is all on show: + * - Range and a clear line from the hacker to the target, checked when the + * hack is picked and on every tick of the upload. + * - The upload: a trace line from hacker to target and a chirp the target + * hears, for a model's upload time. Moving, losing sight, an EMP or a stun + * loses the upload at no cost. + * - One shared cooldown, set by the hack that landed, like the boon's. + * - EMP and brownout kill the deck (the cyberware action bridge); Chrome Read + * shows it. + * - Outpost property refuses every hack outright, and Overload refuses any + * machine on an outpost, trader or player (owner, D12: decks never blow up + * outpost machines). A person hacked at a trader outpost counts as an attack + * on them there, the same as a punch, and Arc Flash does not run there at all. + * + * Activation (owner, 11-08): using the deck, from its button, its chrome + * wheel slice or Use chrome, arms targeting. The next click picks the target, + * and the hack radial opens on that target listing only what it would take, + * the way the boon's does. Hacks are deliberately NOT on the chrome wheel's + * outer ring. Clicking yourself opens the room hacks. While zoomed through + * optics, a click on what you see through the zoom reaches here through + * COMSIG_CYBERWARE_ZOOM_CLICK. + */ + +// The optics zoom (voidcrew/_DEFINES/cyberware.dm on phase2/zoom) owns these. +// Copies with the same names and values, until both are on one branch. +#ifndef COMSIG_CYBERWARE_ZOOM_CLICK +/// Sent on the bearer for every click made while zoomed, before the click +/// intercept chain: (mob/living/carbon/bearer, atom/target, list/modifiers). +#define COMSIG_CYBERWARE_ZOOM_CLICK "cyberware_zoom_click" +#endif +#ifndef COMPONENT_CYBERWARE_ZOOM_CLICK_HANDLED +/// Return from a COMSIG_CYBERWARE_ZOOM_CLICK handler that took the click. +#define COMPONENT_CYBERWARE_ZOOM_CLICK_HANDLED (1<<0) +#endif + +// ========================================================================= +// THE DECKS +// ========================================================================= + +/// T1: a dead terminal and a cracked handheld, soldered into a cortex socket. +/obj/item/organ/cyberimp/cyberware/deck + name = "\improper Junker deck" + desc = "A cortex deck soldered together from a dead terminal and a cracked handheld. Doors and light panels still answer it, mostly." + icon_state = "deck" + zone = BODY_ZONE_HEAD + slot = ORGAN_SLOT_CYBERWARE_DECK + w_class = WEIGHT_CLASS_SMALL + chrome_load = 2 + tier = CYBERWARE_TIER_1 + actions_types = list(/datum/action/cooldown/cyberware/deck) + /// Quickhacks this deck carries, subtypes of /datum/machine_quickhack. + /// Every model carries everything the model below it does. + var/list/quickhacks = list( + /datum/machine_quickhack/bolts, + /datum/machine_quickhack/burst_lights, + ) + /// Room hacks this deck carries, subtypes of /datum/machine_masshack. Run by + /// pointing the deck at yourself. + var/list/masshacks = list( + /datum/machine_masshack/ping, + ) + /// Tiles a quickhack reaches. Line of sight is always needed as well. + var/hack_range = 5 + /// How long a hack uploads before it lands. + var/upload_time = 2 SECONDS + /// Multiplier on every hack's own cooldown. + var/cooldown_mult = 1.5 + /// What the deck's menu and action read. Built from the vars above. + var/datum/quickhack_kit/kit + +/obj/item/organ/cyberimp/cyberware/deck/Initialize(mapload) + . = ..() + kit = new /datum/quickhack_kit(quickhacks, masshacks, hack_range, upload_time, cooldown_mult) + +/obj/item/organ/cyberimp/cyberware/deck/Destroy() + QDEL_NULL(kit) + return ..() + +/// T2: the same idea in a real shell, with someone else's maintenance codes. +/obj/item/organ/cyberimp/cyberware/deck/street + name = "\improper Street deck" + desc = "A cortex deck in a scuffed polymer shell, flashed with somebody else's maintenance codes." + icon_state = "deck_street" + chrome_load = 4 + tier = CYBERWARE_TIER_2 + quickhacks = list( + /datum/machine_quickhack/bolts, + /datum/machine_quickhack/burst_lights, + /datum/machine_quickhack/kill_power, + /datum/machine_quickhack/cutout, + ) + hack_range = 6 + upload_time = 1.5 SECONDS + cooldown_mult = 1.5 + +/// T3, infrastructure: doors, radios and turrets. +/obj/item/organ/cyberimp/cyberware/deck/control + name = "\improper Facilities deck" + desc = "A facilities engineer's cortex deck, built to run a station's doors, radios and turrets from the corridor. The asset tag has been filed off." + icon_state = "deck_control" + chrome_load = 6 + tier = CYBERWARE_TIER_3 + quickhacks = list( + /datum/machine_quickhack/bolts, + /datum/machine_quickhack/burst_lights, + /datum/machine_quickhack/kill_power, + /datum/machine_quickhack/cutout, + /datum/machine_quickhack/electrify, + /datum/machine_quickhack/silence, + /datum/machine_quickhack/reboot_turret, + ) + hack_range = 7 + upload_time = 1 SECONDS + cooldown_mult = 1 + +/// T3, offensive: people, their gear and the chrome inside them. +/obj/item/organ/cyberimp/cyberware/deck/assault + name = "\improper Breacher deck" + desc = "A boarding team's cortex deck, built to shut down a defender's gear and the chrome inside them. It runs warm." + icon_state = "deck_assault" + chrome_load = 6 + tier = CYBERWARE_TIER_3 + quickhacks = list( + /datum/machine_quickhack/bolts, + /datum/machine_quickhack/burst_lights, + /datum/machine_quickhack/kill_power, + /datum/machine_quickhack/cutout, + /datum/machine_quickhack/overload, + /datum/machine_quickhack/feedback_surge, + /datum/machine_quickhack/glitch, + ) + hack_range = 7 + upload_time = 1 SECONDS + cooldown_mult = 1 + +/// T4: everything both service decks carry, and a room hack. +/obj/item/organ/cyberimp/cyberware/deck/flagship + name = "\improper Blackbox deck" + desc = "A sealed cortex deck in a flat black casing with no maker's mark. Every machine in a room seems to know when it's there." + icon_state = "deck_flagship" + chrome_load = 9 + tier = CYBERWARE_TIER_4 + quickhacks = list( + /datum/machine_quickhack/bolts, + /datum/machine_quickhack/burst_lights, + /datum/machine_quickhack/kill_power, + /datum/machine_quickhack/cutout, + /datum/machine_quickhack/electrify, + /datum/machine_quickhack/silence, + /datum/machine_quickhack/reboot_turret, + /datum/machine_quickhack/overload, + /datum/machine_quickhack/feedback_surge, + /datum/machine_quickhack/glitch, + ) + masshacks = list( + /datum/machine_masshack/ping, + /datum/machine_masshack/arc_flash, + ) + hack_range = 8 + upload_time = 0.5 SECONDS + cooldown_mult = 1 + +// ========================================================================= +// THE ACTION +// ========================================================================= + +/** + * The deck's one button. Click-to-target on the cyberware bridge, so an EMP'd + * or browned-out deck goes dark like any other ware. + * + * Using it arms targeting. Clicking a target checks reach, then opens a radial + * on that target of the hacks it would take. The pick uploads + * ([proc/upload_quickhack]) and lands. Clicking yourself does the same with the + * room hacks. Activate() only starts all that and returns at once; the menu and + * the upload sleep in their own call chain, guarded by [busy]. + * + * The cooldown is one timer for the whole deck, set by whichever hack landed, + * times the deck's multiplier: the boon's model, and the one the chrome wheel + * draws on the deck's slice. Backing out of the menu or losing the upload + * costs nothing. + */ +/datum/action/cooldown/cyberware/deck + name = "Quickhack" + desc = "Push a quickhack into a machine or someone's gear in sight, or into the room around you." + button_icon = 'icons/mob/actions/actions_items.dmi' + button_icon_state = "bci_network" + ranged_mousepointer = 'icons/effects/mouse_pointers/override_machine_target.dmi' + cooldown_time = 0 + click_to_activate = TRUE + /// Set while a menu or an upload is open, so neither can stack. + var/busy = FALSE + /// The trace line drawn while an upload runs. + var/datum/beam/trace + +/datum/action/cooldown/cyberware/deck/Destroy() + QDEL_NULL(trace) + return ..() + +/datum/action/cooldown/cyberware/deck/Grant(mob/grant_to) + . = ..() + if(owner) + RegisterSignal(owner, COMSIG_CYBERWARE_ZOOM_CLICK, PROC_REF(on_zoom_click), override = TRUE) + +/datum/action/cooldown/cyberware/deck/Remove(mob/removed_from) + if(removed_from) + UnregisterSignal(removed_from, COMSIG_CYBERWARE_ZOOM_CLICK) + QDEL_NULL(trace) + return ..() + +/// The deck this button belongs to, or null. +/datum/action/cooldown/cyberware/deck/proc/get_deck() + var/obj/item/organ/cyberimp/cyberware/deck/deck = organ + return istype(deck) && deck.kit ? deck : null + +/datum/action/cooldown/cyberware/deck/Activate(atom/target) + var/obj/item/organ/cyberimp/cyberware/deck/deck = get_deck() + var/mob/living/carbon/bearer = deck?.owner + if(!deck || !iscarbon(bearer) || isnull(target)) + return FALSE + // HUD clicks are never targets. A click made through the zoom arrives with + // its real target through on_zoom_click() instead. + if(istype(target, /atom/movable/screen)) + return FALSE + if(busy) + bearer.balloon_alert(bearer, "deck busy!") + return FALSE + if(target == bearer) + return start_room_hack(bearer) + if(!can_reach(bearer, target, feedback = TRUE)) + return FALSE + var/list/available = offered_hacks(bearer, target) + if(!length(available)) + target.balloon_alert(bearer, "nothing to talk to!") + return FALSE + busy = TRUE + INVOKE_ASYNC(src, PROC_REF(run_quickhack), bearer, target, available) + return TRUE + +/// Starts the room hack flow on the bearer's own tile. +/datum/action/cooldown/cyberware/deck/proc/start_room_hack(mob/living/carbon/bearer) + var/obj/item/organ/cyberimp/cyberware/deck/deck = get_deck() + if(bearer.is_blind()) + bearer.balloon_alert(bearer, "can't see!") + return FALSE + var/list/available = list() + for(var/datum/machine_masshack/hack as anything in deck.kit.applicable_masshacks(bearer, get_turf(bearer))) + if(deck_allows_room(hack, bearer)) + available += hack + if(!length(available)) + bearer.balloon_alert(bearer, "nothing here answers!") + return FALSE + busy = TRUE + INVOKE_ASYNC(src, PROC_REF(run_room_hack), bearer, available) + return TRUE + +// ---- Reach ------------------------------------------------------------- + +/// Whether the deck can work on this target from here: the bearer can see, +/// the target is not outpost property, and it is in range and in a clear line. +/datum/action/cooldown/cyberware/deck/proc/can_reach(mob/living/carbon/bearer, atom/target, feedback = FALSE) + var/obj/item/organ/cyberimp/cyberware/deck/deck = get_deck() + if(!deck || QDELETED(target)) + return FALSE + if(bearer.is_blind()) + if(feedback) + bearer.balloon_alert(bearer, "can't see!") + return FALSE + if(HAS_TRAIT(target, TRAIT_OUTPOST_PROPERTY)) + if(feedback) + target.balloon_alert(bearer, "hardened firmware!") + return FALSE + if(deck.kit.in_reach(bearer, target)) + return TRUE + if(feedback) + var/turf/here = get_turf(bearer) + var/turf/there = get_turf(target) + var/too_far = !here || !there || here.z != there.z || get_dist(here, there) > deck.kit.hack_range + target.balloon_alert(bearer, too_far ? "out of range!" : "no line of sight!") + return FALSE + +/// What the deck offers on this target: the kit's hacks it would take, minus +/// anything a deck may not do there. +/datum/action/cooldown/cyberware/deck/proc/offered_hacks(mob/living/carbon/bearer, atom/target) + RETURN_TYPE(/list) + . = list() + var/obj/item/organ/cyberimp/cyberware/deck/deck = get_deck() + if(!deck) + return + for(var/datum/machine_quickhack/hack as anything in deck.kit.applicable_quickhacks(target, bearer)) + if(deck_allows(hack, target)) + . += hack + +/// The deck's own limits on top of a hack's: nothing a deck runs blows up a +/// machine on an outpost, a trader's or a player's (owner, D12). The boon has +/// no such limit. +/datum/action/cooldown/cyberware/deck/proc/deck_allows(datum/machine_quickhack/hack, atom/target) + if(istype(hack, /datum/machine_quickhack/overload)) + return !get_outpost_from_atom(target) && !is_trader_outpost_protected(target) + return TRUE + +/// The same for room hacks: inside a trader outpost's protected ground, docked +/// ships included, a deck runs nothing that hurts the people around it. Arc +/// Flash's shocks come out of the machines and would never count as an attack +/// there, so it simply does not answer. +/datum/action/cooldown/cyberware/deck/proc/deck_allows_room(datum/machine_masshack/hack, mob/living/carbon/bearer) + if(istype(hack, /datum/machine_masshack/ping)) + return TRUE + return !is_trader_outpost_protected(bearer) + +/// The deck is still in this body, still this body's button, and usable. +/datum/action/cooldown/cyberware/deck/proc/still_linked(mob/living/carbon/bearer) + return !QDELETED(src) && !QDELETED(bearer) && owner == bearer && organ?.owner == bearer && IsAvailable(feedback = FALSE) + +/// Menu and upload check for a quickhack: linked, and the target still in reach. +/datum/action/cooldown/cyberware/deck/proc/target_holds(mob/living/carbon/bearer, atom/target) + return still_linked(bearer) && can_reach(bearer, target) + +/// Menu and upload check for a room hack: linked, and still able to see. +/datum/action/cooldown/cyberware/deck/proc/room_holds(mob/living/carbon/bearer) + return still_linked(bearer) && !bearer.is_blind() + +// ---- Quickhacks ---------------------------------------------------------- + +/// The radial on the target, then the upload, then the hack. Sleeps. +/datum/action/cooldown/cyberware/deck/proc/run_quickhack(mob/living/carbon/bearer, atom/target, list/available) + var/datum/machine_quickhack/hack = pick_quickhack(bearer, target, available) + if(hack) + upload_quickhack(bearer, target, hack) + if(!QDELETED(src)) + busy = FALSE + +/// The radial over the target. Returns the pick, or null. +/datum/action/cooldown/cyberware/deck/proc/pick_quickhack(mob/living/carbon/bearer, atom/target, list/available) + var/obj/item/organ/cyberimp/cyberware/deck/deck = get_deck() + if(!deck) + return null + var/list/options = list() + var/list/by_name = list() + for(var/datum/machine_quickhack/hack as anything in available) + options[hack.name] = deck.kit.menu_option(hack) + by_name[hack.name] = hack + // Single options are not auto-picked: a stray click must never fire a hack. + var/choice = choose_quickhack(bearer, target, options) + if(!choice || !target_holds(bearer, target)) + return null + var/datum/machine_quickhack/chosen = by_name[choice] + if(!chosen?.valid_for(target, bearer) || !deck_allows(chosen, target)) + target.balloon_alert(bearer, "it stopped answering!") + return null + return chosen + +/// Keep the sleeping input separate from the checks around it. +/datum/action/cooldown/cyberware/deck/proc/choose_quickhack(mob/living/carbon/bearer, atom/target, list/options) + return show_radial_menu(bearer, target, options, custom_check = CALLBACK(src, PROC_REF(target_holds), bearer, target), tooltips = TRUE, autopick_single_option = FALSE) + +/** + * The upload, then the hack. The trace line and the chirp go out first, so + * the target has the whole upload to react; moving, losing sight, an EMP or a + * stun loses it at no cost. + */ +/datum/action/cooldown/cyberware/deck/proc/upload_quickhack(mob/living/carbon/bearer, atom/target, datum/machine_quickhack/hack) + var/obj/item/organ/cyberimp/cyberware/deck/deck = get_deck() + if(!deck || !target_holds(bearer, target)) + return FALSE + var/datum/quickhack_kit/kit = deck.kit + QDEL_NULL(trace) + trace = bearer.Beam(target, icon_state = "infrared", time = kit.upload_time + 1 SECONDS, maxdistance = kit.hack_range + 1) + playsound(target, 'sound/machines/beep/twobeep_high.ogg', 40, FALSE, MEDIUM_RANGE_SOUND_EXTRARANGE) + if(ismob(target)) + to_chat(target, span_warning("Something in your gear chirps.")) + var/landed = do_after(bearer, kit.upload_time, target, timed_action_flags = IGNORE_TARGET_LOC_CHANGE|IGNORE_HELD_ITEM|IGNORE_SLOWDOWNS, extra_checks = CALLBACK(src, PROC_REF(target_holds), bearer, target), interaction_key = "cyberdeck_upload") + if(QDELETED(src)) + return FALSE + QDEL_NULL(trace) + if(!landed || !target_holds(bearer, target)) + if(!QDELETED(bearer)) + bearer.balloon_alert(bearer, "upload lost") + return FALSE + if(!hack.valid_for(target, bearer) || !deck_allows(hack, target)) + target.balloon_alert(bearer, "it stopped answering!") + return FALSE + if(!hack.execute(target, bearer)) + return FALSE + if(isliving(target)) + var/mob/living/victim = target + log_combat(bearer, victim, "quickhacked", null, "with [hack.name] from [deck]") + // Counts as an attack at a trader outpost, like any other. + GLOB.outpost_pvp_enforcement?.register_pvp_aggression(victim, bearer) + else + bearer.log_message("used the [hack.name] quickhack on [target] ([target.type]) with [deck].", LOG_ATTACK) + cyberware_ink_pulse(bearer, CYBERWARE_INK_HARD) + StartCooldown(kit.hack_cooldown(hack)) + return TRUE + +// ---- Room hacks ---------------------------------------------------------- + +/// The radial on the bearer, then the upload, then the room hack. Sleeps. +/datum/action/cooldown/cyberware/deck/proc/run_room_hack(mob/living/carbon/bearer, list/available) + var/datum/machine_masshack/hack = pick_room_hack(bearer, available) + if(hack) + upload_room_hack(bearer, hack) + if(!QDELETED(src)) + busy = FALSE + +/datum/action/cooldown/cyberware/deck/proc/pick_room_hack(mob/living/carbon/bearer, list/available) + var/obj/item/organ/cyberimp/cyberware/deck/deck = get_deck() + if(!deck) + return null + var/list/options = list() + var/list/by_name = list() + for(var/datum/machine_masshack/hack as anything in available) + options[hack.name] = deck.kit.menu_option(hack) + by_name[hack.name] = hack + var/choice = choose_room_hack(bearer, options) + if(!choice || !room_holds(bearer)) + return null + return by_name[choice] + +/datum/action/cooldown/cyberware/deck/proc/choose_room_hack(mob/living/carbon/bearer, list/options) + return show_radial_menu(bearer, bearer, options, custom_check = CALLBACK(src, PROC_REF(room_holds), bearer), tooltips = TRUE, autopick_single_option = FALSE) + +/// A room hack uploads where the bearer stands, and the room hears it. +/datum/action/cooldown/cyberware/deck/proc/upload_room_hack(mob/living/carbon/bearer, datum/machine_masshack/hack) + var/obj/item/organ/cyberimp/cyberware/deck/deck = get_deck() + if(!deck || !room_holds(bearer)) + return FALSE + var/datum/quickhack_kit/kit = deck.kit + playsound(bearer, 'sound/machines/beep/twobeep_high.ogg', 40, FALSE) + do_sparks(1, FALSE, bearer) + var/landed = do_after(bearer, kit.upload_time, bearer, timed_action_flags = IGNORE_HELD_ITEM|IGNORE_SLOWDOWNS, extra_checks = CALLBACK(src, PROC_REF(room_holds), bearer), interaction_key = "cyberdeck_upload") + if(QDELETED(src)) + return FALSE + if(!landed || !room_holds(bearer)) + if(!QDELETED(bearer)) + bearer.balloon_alert(bearer, "upload lost") + return FALSE + var/turf/here = get_turf(bearer) + if(isnull(here) || !hack.available(bearer, here) || !deck_allows_room(hack, bearer)) + bearer.balloon_alert(bearer, "nothing here answers!") + return FALSE + if(!hack.execute(bearer, here)) + return FALSE + bearer.log_message("used the [hack.name] room hack with [deck].", LOG_ATTACK) + if(!istype(hack, /datum/machine_masshack/ping)) + message_admins("[ADMIN_LOOKUPFLW(bearer)] used the [hack.name] room hack from [deck] at [ADMIN_VERBOSEJMP(here)].") + cyberware_ink_pulse(bearer, CYBERWARE_INK_HARD) + StartCooldown(kit.hack_cooldown(hack)) + return TRUE + +// ---- Zoom ---------------------------------------------------------------- + +/** + * A click made through the optics zoom, with what it landed on. The zoom sends + * this before the click intercept chain and drops any click nobody handles, so + * an armed deck takes it here, exactly as a plain click: InterceptClickOn() + * checks the deck is usable, runs the target-first flow (the radial opens on + * the target) and disarms once it has started. The zoom checks no range, so + * reach is the deck's own range from the bearer, with line of sight, as ever. + * A target out of reach is refused with feedback and the deck stays armed, the + * same as a plain click on it. An unarmed deck leaves the click alone. + */ +/datum/action/cooldown/cyberware/deck/proc/on_zoom_click(mob/living/carbon/source, atom/target, list/modifiers) + SIGNAL_HANDLER + if(source != owner || source.click_intercept != src || QDELETED(target)) + return NONE + INVOKE_ASYNC(src, PROC_REF(InterceptClickOn), source, null, target) + return COMPONENT_CYBERWARE_ZOOM_CLICK_HANDLED + +// ========================================================================= +// THE DECK'S OWN HACKS +// The deck line's additions to voidcrew/modules/quickhacks/. The boon's kit +// lists its hacks by hand, so none of these reach it. +// ========================================================================= + +/** + * Ping: everything in the area that answers a handshake lights up for the + * hacker alone, walls and all, for a few seconds. Cameras, turrets and + * powered doors only. Room hack, so it runs from where the hacker stands. + */ +/datum/machine_masshack/ping + name = "Ping" + desc = "Every camera, turret and powered door nearby answers the handshake, and shows through the walls for a moment. Only you see it." + radial_icon = 'icons/mob/actions/actions_items.dmi' + radial_icon_state = "bci_wireless" + cooldown = 20 SECONDS + /// How far the handshake carries, walls and all. + var/reach = 7 + /// How long the marks stay up. + var/mark_time = 5 SECONDS + +/datum/machine_masshack/ping/available(mob/living/user, turf/center) + return !isnull(center) + +/datum/machine_masshack/ping/execute(mob/living/user, turf/center) + var/marked = 0 + for(var/obj/machinery/machine in range(reach, center)) + var/mark_color = ping_color(machine) + if(mark_color && cyberware_hud_mark(user, machine, mark_color, mark_time)) + marked++ + if(!marked) + user.balloon_alert(user, "nothing answers") + return FALSE + user.playsound_local(center, 'sound/machines/sonar-ping.ogg', 40, FALSE) + return TRUE + +/// The mark colour for a machine that answers a ping, or null if it doesn't. +/datum/machine_masshack/ping/proc/ping_color(obj/machinery/machine) + if(istype(machine, /obj/machinery/porta_turret) || istype(machine, /obj/machinery/ship_combat/laser_turret)) + return "#ff4f4f" + if(istype(machine, /obj/machinery/camera)) + var/obj/machinery/camera/camera = machine + return camera.camera_enabled ? "#ffb347" : null + if(istype(machine, /obj/machinery/door/airlock)) + var/obj/machinery/door/airlock/door = machine + return door.hasPower() ? "#4dd8e6" : null + return null + +/** + * Reboot Turret: a cold boot that takes a turret off the line for a few + * seconds, then lets it come back as it was. The Icepick Jack's suppress is + * the stronger, contact-range version of this; it stays that way. + * + * Same refusals as the Icepick: outpost security firmware never answers. A + * turret on a player's ship trips that crew's intrusion alarm. + */ +/datum/machine_quickhack/reboot_turret + name = "Reboot Turret" + desc = "Force a turret through a cold boot. It goes quiet for a few seconds, then comes back as it was." + radial_icon = 'icons/obj/weapons/turrets.dmi' + radial_icon_state = "standard_off" + cooldown = 20 SECONDS + /// How long the turret stays down. + var/downtime = 8 SECONDS + +/// The turret behind what was clicked: a closed turret's cover stands in for it. +/datum/machine_quickhack/reboot_turret/proc/resolve_turret(atom/target) + if(istype(target, /obj/machinery/porta_turret_cover)) + var/obj/machinery/porta_turret_cover/cover = target + return cover.parent_turret + return target + +/datum/machine_quickhack/reboot_turret/valid_for(atom/target, mob/living/user) + var/atom/turret = resolve_turret(target) + if(istype(turret, /obj/machinery/porta_turret)) + if(istype(turret, /obj/machinery/porta_turret/outpost)) + return FALSE + var/obj/machinery/porta_turret/porta = turret + return porta.on && !(porta.machine_stat & BROKEN) + return istype(turret, /obj/machinery/ship_combat/laser_turret) + +/datum/machine_quickhack/reboot_turret/execute(atom/target, mob/living/user) + var/obj/machinery/turret = resolve_turret(target) + if(istype(turret, /obj/machinery/porta_turret)) + var/obj/machinery/porta_turret/porta = turret + porta.set_disabled(downtime) + else if(istype(turret, /obj/machinery/ship_combat/laser_turret)) + var/obj/machinery/ship_combat/laser_turret/laser = turret + if(laser.fire_cooldown < world.time + downtime) + COOLDOWN_START(laser, fire_cooldown, downtime) + else + return FALSE + do_sparks(2, TRUE, turret) + playsound(turret, 'sound/effects/magic/disable_tech.ogg', 50, TRUE) + turret.visible_message(span_warning("[turret] powers down with a falling whine.")) + cyberware_turret_intrusion_alarm(turret, user) + return TRUE + +/** + * Glitch: one piece of the target's running chrome, picked at random, takes + * the ordinary cyberware EMP reboot. Chrome is balanced around EMP, so this is + * the hacker's answer to a chromed-up fighter, one piece at a time. + */ +/datum/machine_quickhack/glitch + name = "Glitch" + desc = "Throw one piece of a person's chrome into a reboot. Which piece is anyone's guess." + radial_icon = 'icons/mob/actions/actions_spells.dmi' + radial_icon_state = "emp" + cooldown = 60 SECONDS + +/// The target's chrome that is up and running right now. +/datum/machine_quickhack/glitch/proc/running_chrome(mob/living/carbon/victim) + RETURN_TYPE(/list) + . = list() + for(var/obj/item/organ/ware as anything in get_installed_cyberware(victim)) + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + if(chrome?.is_running()) + . += ware + +/datum/machine_quickhack/glitch/valid_for(atom/target, mob/living/user) + if(!iscarbon(target) || target == user) + return FALSE + var/mob/living/carbon/victim = target + return victim.stat != DEAD && length(running_chrome(victim)) + +/datum/machine_quickhack/glitch/execute(atom/target, mob/living/user) + var/mob/living/carbon/victim = target + var/list/running = running_chrome(victim) + if(!length(running)) + return FALSE + var/obj/item/organ/ware = pick(running) + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + chrome.start_emp_reboot(EMP_HEAVY) + victim.balloon_alert(victim, "[ware.name] glitches out!") + do_sparks(2, TRUE, victim) + to_chat(user, span_notice("Something in [victim] drops into a reboot.")) + return TRUE + +// ========================================================================= +// SPLICE SKUS: not listed on any vendor yet, the integration pass adds them +// to the Chop Shop's sku_types (and the street doc's, for the two credit +// decks), and pins the gated ones in voidcrew_zone_shops' catalog. +// ========================================================================= + +/datum/shop_sku/ripperdoc/deck_junker + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/deck + price_credits = 1500 + stock_min = 1 + stock_max = 2 + +/datum/shop_sku/ripperdoc/deck_street + zone_permission = ZONE_PERMISSION_YELLOW + category = "Street Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/deck/street + price_credits = 3500 + +/datum/shop_sku/ripperdoc/deck_control + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/deck/control + price_vouchers = 4 + +/datum/shop_sku/ripperdoc/deck_assault + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Military Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/deck/assault + price_vouchers = 4 + +/datum/shop_sku/ripperdoc/deck_flagship + zone_permission = ZONE_PERMISSION_ELITE + category = "Legend Chrome" + item_path = /obj/item/organ/cyberimp/cyberware/deck/flagship + price_vouchers = 7 + stock_min = 1 + stock_max = 1 + +// ========================================================================= +// TRADE-IN: the two credit decks at half list, credits only. Not listed on +// any buyback_types yet. Voucher decks are never bought back. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/deck_junker + name = "Junker deck (pulled)" + desc = "A soldered-up cortex deck, pulled. Worth something toward a real one." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/deck + match_subtypes = FALSE + pay_credits = 750 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/deck_street + name = "Street deck (pulled)" + desc = "A street cortex deck, pulled. Half its price toward a better one." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/deck/street + match_subtypes = FALSE + pay_credits = 1750 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/deck_junker/street_doc + desc = "A used junker deck. Solder's holding, just." + +/datum/shop_buyback/ripperdoc/deck_street/street_doc + desc = "A used street deck. Somebody else's codes still on it." diff --git a/voidcrew/modules/quickhacks/masshacks.dm b/voidcrew/modules/quickhacks/masshacks.dm new file mode 100644 index 00000000000..b977f528dff --- /dev/null +++ b/voidcrew/modules/quickhacks/masshacks.dm @@ -0,0 +1,438 @@ +/** + * # Room hacks + * + * The mass hacks first written for the Machine Communion boon's second button + * (voidcrew/modules/antag_ruins/ascension_mech.dm), moved here unchanged so a + * cyberdeck can carry one. What a source carries is its [/datum/quickhack_kit]. + */ + +// ===== THE MASS HACKS ===== +// The second button. Everything below is room-scale, and the descs quote these +// numbers literally, keep them in sync. + +/// How far a mass hack reaches from the caster. Shorter than the single-target reach +/// on purpose: this is the room you are standing in, not the room next door. +#define COMMUNION_MASS_RANGE 7 +/// Most machines any one mass hack will touch. Caps the blast, the mob count and the tick cost. +#define COMMUNION_MASS_CAP 12 +/// How long the whole room buzzes before Overload the Room lands. A second longer than +/// the single-target version, because there is a great deal more to run away from. +#define COMMUNION_MASS_OVERLOAD_DELAY (5 SECONDS) +/// Gap between two machines going up, so a dozen explosions do not land on one tick. +#define COMMUNION_MASS_OVERLOAD_STAGGER (0.2 SECONDS) +/// Blast radii of one machine in a mass overload. Identical to the single-target blast. +/// This is a dozen full Overloads going off at once, not a dozen small ones. EXPLODE_HEAVY +/// on a wall is `dismantle_wall(prob(50), TRUE)`, which always takes the wall, so a room +/// that goes up this way is stripped to the girders and an outer wall may well go with it. +/// That is the intent; the five-second buzz is what everybody in the room gets instead. +#define COMMUNION_MASS_OVERLOAD_HEAVY 1 +#define COMMUNION_MASS_OVERLOAD_LIGHT 3 +#define COMMUNION_MASS_OVERLOAD_FLASH 3 + +/// Arc Flash: how many volleys it throws and how far apart. +#define COMMUNION_ARC_PULSES 5 +#define COMMUNION_ARC_INTERVAL (1.5 SECONDS) +/// How far a machine will reach for somebody to earth itself through. +#define COMMUNION_ARC_REACH 5 +/// Burn one arc lands. Everyone in the room takes one per volley. +#define COMMUNION_ARC_DAMAGE 14 + +/// Most machines Wake the Machines will stand up. Lower than the general cap; each one is a mob. +#define COMMUNION_WAKE_CAP 8 +/// How long a woken machine lasts before it falls apart. +#define COMMUNION_WAKE_DURATION (2 MINUTES) +/// Multiplier on a woken machine's health and melee damage over a stock animated one. +#define COMMUNION_WAKE_SCALE 2 +/// Filter key and colour for the outline a woken machine wears, so the room can tell +/// which vending machine is yours. +#define COMMUNION_WAKE_FILTER "communion_woken" +#define COMMUNION_WAKE_COLOR "#63c8d6" + +// ========================================================================= +// THE MASS HACKS +// ========================================================================= + +/** + * One thing Mass Hack can do to one room. Stateless singletons like the quickhacks, + * built once into a global list and shared by every owner, so nothing here may hold + * per-cast state. Timers hang off the singleton, which is fine because it is never + * deleted; anything a timer needs comes in as a callback argument. + */ +/datum/machine_masshack + /// Shown on the radial slice. + var/name = "Mass Hack" + /// Shown in the slice's tooltip. Lead with the effect, then the numbers. + var/desc = "" + /// Radial slice icon. + var/radial_icon = 'icons/mob/actions/actions_AI.dmi' + /// Icon state paired with radial_icon. + var/radial_icon_state = "overload_machine" + /// Cooldown this hack puts on the ability. + var/cooldown = 2 MINUTES + +/// Is there anything in this room for this hack to do? Decides whether the slice is shown. +/datum/machine_masshack/proc/available(mob/living/user, turf/center) + return FALSE + +/// Do it. Return TRUE if it landed; a FALSE return costs no cooldown. +/datum/machine_masshack/proc/execute(mob/living/user, turf/center) + return FALSE + +/// Every mass hack, built once on demand. +GLOBAL_LIST_EMPTY(machine_masshacks) + +/proc/get_machine_masshacks() + if(!length(GLOB.machine_masshacks)) + for(var/datum/machine_masshack/hack_type as anything in subtypesof(/datum/machine_masshack)) + GLOB.machine_masshacks += new hack_type() + return GLOB.machine_masshacks + +/** + * The machines in a room that a mass hack may touch, nearest first, capped. + * + * The filter matters more here than it does for a single click, because the player is not + * choosing the targets. Two things it deliberately drops that `ismachinery` would keep: + * + * - **Atmospherics.** Pipes, vents and scrubbers are machinery, most of a room's pipework + * is under the floor and invisible, and eating a distribution loop is not a hack, it is + * an atmos incident nobody asked for. + * - **Anything hidden.** Power terminals and buried plumbing sit on the tile at + * INVISIBILITY_MAXIMUM. If the caster cannot see it, it is not "in the room". + * + * `standing_only` narrows it further to the free-standing machines, dense, and not a + * door. That is the set Wake the Machines uses, because a light switch getting up and + * walking around is not the effect anybody wants. + * + * `skip_lights` drops light fixtures. Overload the Room uses it so that a room with six + * ceiling tubes does not spend half its twelve-machine budget on bulbs; it bursts them + * separately and for free. Arc Flash deliberately leaves them in, a light tube arcing at + * somebody is exactly the effect. + */ +/proc/communion_mass_machines(turf/center, cap = COMMUNION_MASS_CAP, standing_only = FALSE, skip_lights = FALSE) + var/list/found = list() + if(isnull(center)) + return found + var/static/list/mass_hack_blacklist = typecacheof(list( + /obj/machinery/atmospherics, + /obj/machinery/portable_atmospherics, + /obj/machinery/duct, + /obj/machinery/power/terminal, + // The cover is the closed turret's sprite standing in for the turret, which is in + // view right beside it. Taking both would double-count one object. + /obj/machinery/porta_turret_cover, + )) + for(var/obj/machinery/machine in view(COMMUNION_MASS_RANGE, center)) + if(machine.resistance_flags & INDESTRUCTIBLE) + continue + if(is_type_in_typecache(machine, GLOB.blacklisted_malf_machines) || is_type_in_typecache(machine, mass_hack_blacklist)) + continue + if(machine.invisibility > SEE_INVISIBLE_LIVING) + continue + if(skip_lights && istype(machine, /obj/machinery/light)) + continue + if(standing_only && (!machine.density || istype(machine, /obj/machinery/door))) + continue + found += machine + + if(length(found) <= cap) + return found + // view() is documented nowhere as returning in distance order, and which machines get + // cut is the difference between a room hack and a corridor hack. Bucket by distance. + var/list/nearest = list() + for(var/distance in 0 to COMMUNION_MASS_RANGE) + for(var/obj/machinery/machine as anything in found) + if(get_dist(center, machine) != distance) + continue + nearest += machine + if(length(nearest) >= cap) + return nearest + return nearest + +// ===== OVERLOAD THE ROOM ===== + +/** + * The single-target Overload, applied to everything at once, at full strength. + * + * Every machine gets the same blast the single-target hack gives one, heavy 1, light 3, + * so this is twelve real explosions in a room, not twelve small ones. It levels the room: + * EXPLODE_HEAVY always dismantles a wall, EXPLODE_LIGHT takes one at `prob(hardness)`, and + * an outer wall in the pattern means the compartment vents. One EXPLODE_LIGHT alone is 30 + * brute, sixteen seconds of knockdown and a real chance of losing a limb, and nobody + * standing in this gets only one. + * + * There is exactly one piece of counterplay and everybody gets it, the caster included: + * five seconds of every machine in the room buzzing at `span_userdanger`. Leave. + * + * Two things keep it from being unbounded rather than merely enormous. The twelve-machine + * cap holds the tick cost and the blast pattern to something a room can contain, and the + * 0.2s stagger means the blasts land one at a time, which also means a machine killed by + * an earlier blast simply no-ops when its own timer comes up, so the cascade eats itself + * from the middle outwards instead of double-counting. + */ +/datum/machine_masshack/overload + name = "Overload the Room" + desc = "Feed every machine around you power at once, each one at full overload strength. Five seconds of the whole \ + room buzzing, then up to twelve real explosions land a fifth of a second apart. It takes the walls with it and \ + it will breach a hull if one is in the pattern. Lights just burst. Nothing that goes up comes back, and you are \ + standing in the middle of it, leave." + radial_icon_state = "overload_machine" + cooldown = 3 MINUTES + +/datum/machine_masshack/overload/available(mob/living/user, turf/center) + return length(communion_mass_machines(center, skip_lights = TRUE)) > 0 + +/datum/machine_masshack/overload/execute(mob/living/user, turf/center) + var/list/targets = communion_mass_machines(center, skip_lights = TRUE) + if(!length(targets)) + return FALSE + user.playsound_local(user, 'sound/misc/interference.ogg', 60, FALSE) + playsound(center, SFX_SPARKS, 70, TRUE) + for(var/index in 1 to length(targets)) + var/obj/machinery/victim = targets[index] + victim.audible_message(span_userdanger("[victim] starts buzzing, loudly.")) + var/datum/callback/burst = CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(communion_burst_machine), victim, COMMUNION_MASS_OVERLOAD_HEAVY, COMMUNION_MASS_OVERLOAD_LIGHT, COMMUNION_MASS_OVERLOAD_FLASH) + addtimer(burst, COMMUNION_MASS_OVERLOAD_DELAY + ((index - 1) * COMMUNION_MASS_OVERLOAD_STAGGER)) + // Lights are outside the cap and go on the same clock, so the room goes dark as it goes up. + for(var/obj/machinery/light/bulb in view(COMMUNION_MASS_RANGE, center)) + addtimer(CALLBACK(bulb, TYPE_PROC_REF(/obj/machinery/light, break_light_tube)), COMMUNION_MASS_OVERLOAD_DELAY) + to_chat(user, span_userdanger("Every machine in the room takes the charge. [length(targets)] of them. Get out.")) + return TRUE + +// ===== ARC FLASH ===== + +/** + * Every powered thing in the room earths itself through whoever is standing nearest to it, + * five times over seven and a half seconds. + * + * Deliberately not `tesla_zap`: that proc is a chain, it damages every machine and + * structure it hops through, it electrolyses the air on each landing, and it recurses + * unboundedly out of the room. What is wanted here is one arc per person per volley from + * whatever machine happens to be nearest them, so it is written out longhand. + * + * Iterating victims rather than sources is what bounds the damage: it does not matter + * whether somebody is standing in front of one machine or ten, they take one arc per + * volley. Move out of the room and the volleys stop finding you. + */ +/datum/machine_masshack/arc_flash + name = "Arc Flash" + desc = "Every powered machine around you starts earthing itself through whoever is standing nearest to it. Five \ + volleys a second and a half apart, 14 burn each, and it comes out of the wall rather than through their hands, \ + so insulated gloves do nothing. It hits everyone in the room except you, friend or not." + radial_icon = 'icons/mob/actions/actions_spells.dmi' + radial_icon_state = "lightning" + cooldown = 2 MINUTES + +/datum/machine_masshack/arc_flash/available(mob/living/user, turf/center) + if(!length(communion_arc_sources(center))) + return FALSE + for(var/mob/living/candidate in view(COMMUNION_MASS_RANGE, center)) + if(candidate != user && candidate.stat != DEAD) + return TRUE + return FALSE + +/datum/machine_masshack/arc_flash/execute(mob/living/user, turf/center) + var/list/sources = communion_arc_sources(center) + if(!length(sources)) + return FALSE + // The charged room moves with its ship, and unloading it must end the remaining arcs. + var/obj/effect/vestige_trial_marker/room = new(center) + QDEL_IN(room, COMMUNION_ARC_PULSES * COMMUNION_ARC_INTERVAL) + to_chat(user, span_boldwarning("Everything with a current in it starts looking for somewhere to put it.")) + for(var/pulse in 1 to COMMUNION_ARC_PULSES) + addtimer(CALLBACK(src, PROC_REF(volley), WEAKREF(room), WEAKREF(user)), (pulse - 1) * COMMUNION_ARC_INTERVAL) + return TRUE + +/// One volley: everybody in the room takes an arc from the machine nearest them, if any. +/datum/machine_masshack/arc_flash/proc/volley(datum/weakref/room_ref, datum/weakref/caster_ref) + var/turf/center = get_turf(room_ref?.resolve()) + if(QDELETED(center)) + return + var/mob/living/caster = caster_ref?.resolve() + // The sources are re-gathered every volley on purpose: a machine that lost power or + // was destroyed between volleys stops throwing, and so does one that was never on. + var/list/sources = communion_arc_sources(center) + if(!length(sources)) + return + var/landed = 0 + for(var/mob/living/victim in view(COMMUNION_MASS_RANGE, center)) + if(victim == caster || victim.stat == DEAD) + continue + var/obj/machinery/source = communion_nearest_source(victim, sources) + if(isnull(source)) + continue + source.Beam(victim, icon_state = "lightning[rand(1, 12)]", time = 0.4 SECONDS) + do_sparks(2, FALSE, victim) + // SHOCK_NOGLOVES because the current is coming out of the wall beside them rather + // than through anything they are holding. SHOCK_NOSTUN because five volleys of + // stock electrocution is a seven-second stunlock, and the counterplay is supposed + // to be walking out of the room. + victim.electrocute_act(COMMUNION_ARC_DAMAGE, source, flags = SHOCK_NOGLOVES|SHOCK_NOSTUN) + landed++ + if(landed) + playsound(center, 'sound/effects/magic/lightningbolt.ogg', 50, TRUE) + +/// Powered machines in the room that could throw an arc. +/proc/communion_arc_sources(turf/center) + var/list/sources = list() + for(var/obj/machinery/machine as anything in communion_mass_machines(center, cap = COMMUNION_MASS_CAP)) + if(machine.is_operational) + sources += machine + return sources + +/// The nearest source with line of sight to this victim, or null if none is close enough. +/proc/communion_nearest_source(mob/living/victim, list/sources) + var/obj/machinery/closest + var/closest_distance = COMMUNION_ARC_REACH + 1 + for(var/obj/machinery/candidate as anything in sources) + if(QDELETED(candidate)) + continue + var/distance = get_dist(candidate, victim) + if(distance >= closest_distance) + continue + if(!can_see(candidate, victim, COMMUNION_ARC_REACH)) + continue + closest = candidate + closest_distance = distance + return closest + +// ===== WAKE THE MACHINES ===== + +/** + * The room stands up and takes your side. + * + * `/mob/living/basic/mimic/copy/machine` is the same mob the malf AI's Override Machine + * animates (malf_ai_modules.dm:485), so the animation, the visuals, the stat block and the + * melee are all upstream's. Three things are ours: + * + * - The AI controller uses `/datum/targeting_strategy/basic/not_friends`. The stock mimic + * uses plain `/basic`, which reads factions only, and `befriend` writes the owner's + * REF into the MIMIC'S faction list, not the owner's, so a plain faction check does not + * match and the machine happily attacks the person who woke it. `not_friends` is the + * strategy that reads `BB_FRIENDS_LIST`, which is the half of `befriend` that knows who + * woke this thing up. + * + * The catch, and it bit: `not_friends` also overrides `faction_check` to return FALSE + * unconditionally, "friends dont care about factions". Factions stop being a reason to + * spare anyone at all, so two woken machines sharing FACTION_MIMIC will happily beat each + * other to death. The friends list is the ONLY authority under this strategy, which is why + * [/datum/machine_masshack/wake] introduces every machine in a batch to every other one + * after they are all built. Anything added here that should not be attacked has to go on + * that list; adding a faction will not do it. + * - A clock. Woken machines fall apart after two minutes rather than wandering the ship + * until the stock idle decay finishes them. + * - Twice the health and twice the melee, because eight of these have to be worth three + * minutes on the same button as Overload the Room. + * + * The machine is consumed, `destroy_original` is TRUE, same as the malf module, because + * the alternative (storing it inside the mob and dropping it on death, which is what + * animated structures do) means forceMoving live machinery into a mob's contents and all + * the power and pipe bookkeeping that implies. It is a real cost and the desc says so. + */ +/datum/machine_masshack/wake + name = "Wake the Machines" + desc = "Stand every free-standing machine in the room up to fight for you. Up to eight of them, twice as tough as an \ + animated machine and hitting twice as hard, for two minutes before they fall apart. They know you and nobody \ + else, everyone else in the room is a stranger to them. The machines do not come back." + radial_icon_state = "override_machine" + cooldown = 3 MINUTES + +/datum/machine_masshack/wake/available(mob/living/user, turf/center) + return length(communion_mass_machines(center, cap = COMMUNION_WAKE_CAP, standing_only = TRUE)) > 0 + +/datum/machine_masshack/wake/execute(mob/living/user, turf/center) + var/list/targets = communion_mass_machines(center, cap = COMMUNION_WAKE_CAP, standing_only = TRUE) + if(!length(targets)) + return FALSE + user.playsound_local(user, 'sound/misc/interference.ogg', 60, FALSE) + var/list/woken = list() + for(var/obj/machinery/sleeper as anything in targets) + sleeper.audible_message(span_userdanger("[sleeper] shudders, and stands up.")) + var/mob/living/basic/mimic/copy/machine/communion/risen = new(get_turf(sleeper), sleeper, user, TRUE) + if(!QDELETED(risen)) + woken += risen + if(!length(woken)) + return FALSE + // Introduce the batch to each other, or they fight each other instead of anyone else. + // Sharing FACTION_MIMIC does NOT cover this: not_friends overrides `faction_check` to + // return FALSE unconditionally, so factions stop being a reason to spare anybody and the + // friends list becomes the only one. See [/mob/living/basic/mimic/copy/machine/communion]. + for(var/mob/living/first as anything in woken) + for(var/mob/living/second as anything in woken) + if(first != second) + first.befriend(second) + to_chat(user, span_boldnotice("[length(woken)] of them get up. They know your face and each other's, and nobody else's.")) + return TRUE + +/** + * A machine woken by the capstone. See [/datum/machine_masshack/wake] for why each + * difference from the stock animated machine is there. + */ +/mob/living/basic/mimic/copy/machine/communion + ai_controller = /datum/ai_controller/basic_controller/mimic_copy/machine/communion + +/datum/ai_controller/basic_controller/mimic_copy/machine/communion + // not_friends is the whole point. See the file comment. It reads BB_FRIENDS_LIST, + // which is the half of befriend() that actually knows who woke this thing up. + blackboard = list( + BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic/not_friends, + ) + planning_subtrees = list( + /datum/ai_planning_subtree/escape_captivity, + /datum/ai_planning_subtree/simple_find_target, + /datum/ai_planning_subtree/attack_obstacle_in_path, + /datum/ai_planning_subtree/random_speech/when_has_target/communion_machine, + /datum/ai_planning_subtree/basic_melee_attack_subtree, + ) + +/datum/ai_planning_subtree/random_speech/when_has_target/communion_machine + speech_chance = 6 + emote_hear = list() + speak = list( + "Clear the room.", + "You are not on the list.", + "Stand still.", + "Instruction received.", + "This is being logged.", + "Stop moving.", + ) + +// The stat block is set by CopyObject inside the parent's Initialize, so the scaling has +// to come after it. no_googlies defaults on because the stock mimic's googly eyes are the +// joke read of this mob and the wrong one here; the outline does the same job of telling +// the room which vending machine is somebody's. +/mob/living/basic/mimic/copy/machine/communion/Initialize(mapload, obj/copy, mob/living/creator, destroy_original = TRUE, no_googlies = TRUE) + . = ..() + maxHealth = round(maxHealth * COMMUNION_WAKE_SCALE) + health = maxHealth + melee_damage_lower = round(melee_damage_lower * COMMUNION_WAKE_SCALE) + melee_damage_upper = round(melee_damage_upper * COMMUNION_WAKE_SCALE) + // The stock decay exists so an animated object with nothing to kill eventually stops. + // This one has a hard clock instead, so it does not quietly die mid-fight for standing + // still, and does not outlive its two minutes for having something to chase. + idledamage = FALSE + add_filter(COMMUNION_WAKE_FILTER, 2, list("type" = "outline", "color" = COMMUNION_WAKE_COLOR, "size" = 1)) + addtimer(CALLBACK(src, PROC_REF(fall_apart)), COMMUNION_WAKE_DURATION) + +/mob/living/basic/mimic/copy/machine/communion/proc/fall_apart() + if(QDELETED(src) || stat == DEAD) + return + visible_message(span_warning("[src] stops dead, and falls over.")) + playsound(src, SFX_SPARKS, 50, TRUE) + death() + +#undef COMMUNION_MASS_RANGE +#undef COMMUNION_MASS_CAP +#undef COMMUNION_MASS_OVERLOAD_DELAY +#undef COMMUNION_MASS_OVERLOAD_STAGGER +#undef COMMUNION_MASS_OVERLOAD_HEAVY +#undef COMMUNION_MASS_OVERLOAD_LIGHT +#undef COMMUNION_MASS_OVERLOAD_FLASH +#undef COMMUNION_ARC_PULSES +#undef COMMUNION_ARC_INTERVAL +#undef COMMUNION_ARC_REACH +#undef COMMUNION_ARC_DAMAGE +#undef COMMUNION_WAKE_CAP +#undef COMMUNION_WAKE_DURATION +#undef COMMUNION_WAKE_SCALE +#undef COMMUNION_WAKE_FILTER +#undef COMMUNION_WAKE_COLOR diff --git a/voidcrew/modules/quickhacks/quickhack_kit.dm b/voidcrew/modules/quickhacks/quickhack_kit.dm new file mode 100644 index 00000000000..82484367abd --- /dev/null +++ b/voidcrew/modules/quickhacks/quickhack_kit.dm @@ -0,0 +1,161 @@ +/** + * # Quickhack kit + * + * What one source of quickhacks can do, and how its menu reads. The hacks + * themselves ([/datum/machine_quickhack], [/datum/machine_masshack]) are + * stateless singletons shared by everyone; a kit is the per-source half: which + * of them this source carries, how far it reaches, how long a hack uploads + * before it lands, and how its cooldowns scale. + * + * Two sources use it today: + * - The Machine Communion boon ([/datum/quickhack_kit/communion], in + * voidcrew/modules/antag_ruins/ascension_mech.dm): every original hack, no + * upload, cooldowns as written, tooltips with the hack's details. Its click + * ability keeps its own range checks; the kit only supplies the hack list + * and the menu slices, so the boon plays exactly as it did before the hacks + * moved here. + * - Cyberdecks (voidcrew/modules/cyberware/ware_line_deck.dm): a fixed list per + * model, a short range with line of sight, a visible upload, and a cooldown + * multiplier. Each deck builds its kit from its own organ vars. + * + * Boon-only hacks: Cut the Safeties (the emag effect) and Wake the Machines + * never run from a kit that has not set [allow_boon_only]. A deck that lists + * one by mistake simply does not get it. + */ +/datum/quickhack_kit + /// Quickhack types this source carries, subtypes of [/datum/machine_quickhack]. + /// Menus list them in definition order, not the order here. + var/list/quickhack_types = list() + /// Room hack types this source carries, subtypes of [/datum/machine_masshack]. + var/list/masshack_types = list() + /// Tiles a quickhack reaches. + var/hack_range = 7 + /// How long a hack uploads before it lands. Zero lands at once. + var/upload_time = 0 + /// Multiplier on every hack's own cooldown. + var/cooldown_mult = 1 + /// Whether a quickhack also needs a clear line from the user to its target. + var/needs_sight = TRUE + /// Whether a menu slice's tooltip carries the hack's description and + /// cooldown. Off, a slice shows only the hack's name. + var/detailed_menu = FALSE + /// Whether this source may carry the boon-only hacks. Only the boon sets it. + var/allow_boon_only = FALSE + +/datum/quickhack_kit/New(list/quickhack_types, list/masshack_types, hack_range, upload_time, cooldown_mult) + . = ..() + if(islist(quickhack_types)) + src.quickhack_types = quickhack_types.Copy() + if(islist(masshack_types)) + src.masshack_types = masshack_types.Copy() + if(!isnull(hack_range)) + src.hack_range = hack_range + if(!isnull(upload_time)) + src.upload_time = upload_time + if(!isnull(cooldown_mult)) + src.cooldown_mult = cooldown_mult + +/// The hacks no kit gets unless it sets [allow_boon_only]: the emag effect and +/// the machines that fight for you. +/datum/quickhack_kit/proc/is_boon_only(hack_type) + var/static/list/boon_only = list( + /datum/machine_quickhack/safeties, + /datum/machine_masshack/wake, + ) + return (hack_type in boon_only) + +/// Whether this kit carries the hack of this type. +/datum/quickhack_kit/proc/carries(hack_type) + if(!ispath(hack_type) || (!allow_boon_only && is_boon_only(hack_type))) + return FALSE + return (hack_type in quickhack_types) || (hack_type in masshack_types) + +/// Every quickhack singleton this kit carries, in definition order. +/datum/quickhack_kit/proc/get_quickhacks() + RETURN_TYPE(/list) + . = list() + for(var/datum/machine_quickhack/hack as anything in get_machine_quickhacks()) + if(carries(hack.type) && (hack.type in quickhack_types)) + . += hack + +/// Every room hack singleton this kit carries, in definition order. +/datum/quickhack_kit/proc/get_masshacks() + RETURN_TYPE(/list) + . = list() + for(var/datum/machine_masshack/hack as anything in get_machine_masshacks()) + if(carries(hack.type) && (hack.type in masshack_types)) + . += hack + +/// The carried quickhack of this exact type, or null. +/datum/quickhack_kit/proc/find_quickhack(hack_type) + for(var/datum/machine_quickhack/hack as anything in get_quickhacks()) + if(hack.type == hack_type) + return hack + return null + +/// The carried room hack of this exact type, or null. +/datum/quickhack_kit/proc/find_masshack(hack_type) + for(var/datum/machine_masshack/hack as anything in get_masshacks()) + if(hack.type == hack_type) + return hack + return null + +/// Carried quickhacks this target would accept right now. +/datum/quickhack_kit/proc/applicable_quickhacks(atom/target, mob/living/user) + RETURN_TYPE(/list) + . = list() + if(QDELETED(target) || !isliving(user)) + return + for(var/datum/machine_quickhack/hack as anything in get_quickhacks()) + if(hack.valid_for(target, user)) + . += hack + +/// Carried room hacks that have something to work on from this spot. +/datum/quickhack_kit/proc/applicable_masshacks(mob/living/user, turf/center) + RETURN_TYPE(/list) + . = list() + if(isnull(center) || !isliving(user)) + return + for(var/datum/machine_masshack/hack as anything in get_masshacks()) + if(hack.available(user, center)) + . += hack + +/// Whether the target is close enough, on the same level and, if this kit +/// needs it, in a clear line from the user. +/datum/quickhack_kit/proc/in_reach(mob/living/user, atom/target) + var/turf/source_turf = get_turf(user) + var/turf/target_turf = get_turf(target) + if(isnull(source_turf) || isnull(target_turf) || source_turf.z != target_turf.z) + return FALSE + if(get_dist(source_turf, target_turf) > hack_range) + return FALSE + return !needs_sight || can_see(source_turf, target_turf, hack_range) + +/// The cooldown this hack costs from this kit. +/datum/quickhack_kit/proc/hack_cooldown(datum/hack) + var/base = 0 + if(istype(hack, /datum/machine_quickhack)) + var/datum/machine_quickhack/quick = hack + base = quick.cooldown + else if(istype(hack, /datum/machine_masshack)) + var/datum/machine_masshack/room = hack + base = room.cooldown + return round(base * cooldown_mult) + +/// One radial slice for a hack. With [detailed_menu] off it carries the name +/// alone; on, the tooltip reads exactly as the boon's always has. +/datum/quickhack_kit/proc/menu_option(datum/hack) + var/datum/radial_menu_choice/option = new() + if(istype(hack, /datum/machine_quickhack)) + var/datum/machine_quickhack/quick = hack + option.image = image(icon = quick.radial_icon, icon_state = quick.radial_icon_state) + option.name = quick.name + if(detailed_menu) + option.info = "[quick.desc] ([round(hack_cooldown(quick) / 10)] second cooldown.)" + else if(istype(hack, /datum/machine_masshack)) + var/datum/machine_masshack/room = hack + option.image = image(icon = room.radial_icon, icon_state = room.radial_icon_state) + option.name = room.name + if(detailed_menu) + option.info = "[room.desc] ([round(hack_cooldown(room) / 600)] minute cooldown.)" + return option diff --git a/voidcrew/modules/quickhacks/quickhacks.dm b/voidcrew/modules/quickhacks/quickhacks.dm new file mode 100644 index 00000000000..d73e5587224 --- /dev/null +++ b/voidcrew/modules/quickhacks/quickhacks.dm @@ -0,0 +1,517 @@ +/** + * # Quickhacks + * + * One thing done to one thing: the single-target hacks first written for the + * Machine Communion boon (voidcrew/modules/antag_ruins/ascension_mech.dm) and + * shared since with cyberdecks (voidcrew/modules/cyberware/ware_line_deck.dm). + * Moved here unchanged. What a given source carries, how far it reaches and + * how its menu reads is its [/datum/quickhack_kit] (quickhack_kit.dm). + * + * The descs below quote numbers on purpose: they are the boon's tooltips. A + * deck's menu shows hack names only. + */ + +/// Seconds a bolted-and-live door stays electrified. +#define COMMUNION_ELECTRIFY_SECONDS 30 +/// Burn a communion-live door lands. Equal to the floor of a stock powernet shock, ELECTROCUTE_DAMAGE(). +#define COMMUNION_ELECTRIFY_DAMAGE 20 +/// Stun on top of the burn. Matches the paralyse a carbon takes from a stock shock, so carbons feel no change. +#define COMMUNION_ELECTRIFY_STUN (4 SECONDS) +/// How long an overloaded machine buzzes before it goes. +#define COMMUNION_OVERLOAD_DELAY (4 SECONDS) +/// Radius and lifetime of the Silence bubble. +#define COMMUNION_JAM_RANGE 9 +#define COMMUNION_JAM_DURATION (45 SECONDS) +/// How long a radio switched off by Cutout or Silence stays dead before it recovers. +#define COMMUNION_RADIO_OFF_DURATION (45 SECONDS) +/// Burn per full standard cell drained by Feedback Surge, and the ceiling on it. +#define COMMUNION_SURGE_PER_CELL 12 +#define COMMUNION_SURGE_MAX 60 +#define COMMUNION_SURGE_MIN 5 + +// ========================================================================= +// THE QUICKHACKS +// ========================================================================= + +/** + * One thing Machine Communion can do to one thing. + * + * Stateless singletons, built once into a global list and shared by every owner, so + * nothing here may hold per-cast state. Timers hang off the singleton, which is fine + * because it is never deleted. + */ +/datum/machine_quickhack + /// Shown on the radial slice. + var/name = "Quickhack" + /// Shown in the slice's tooltip. Lead with the effect, then the numbers. + var/desc = "" + /// Radial slice icon. + var/radial_icon = 'icons/mob/actions/actions_AI.dmi' + /// Icon state paired with radial_icon. + var/radial_icon_state = "overload_machine" + /// Cooldown this hack puts on the whole ability. + var/cooldown = 20 SECONDS + +/// Can this hack be run on this target at all? Decides whether the slice is even shown. +/datum/machine_quickhack/proc/valid_for(atom/target, mob/living/user) + return FALSE + +/// Do it. Return TRUE if it landed; a FALSE return costs no cooldown. +/datum/machine_quickhack/proc/execute(atom/target, mob/living/user) + return FALSE + +/// Every quickhack, built once on demand. +GLOBAL_LIST_EMPTY(machine_quickhacks) + +/proc/get_machine_quickhacks() + if(!length(GLOB.machine_quickhacks)) + for(var/datum/machine_quickhack/hack_type as anything in subtypesof(/datum/machine_quickhack)) + GLOB.machine_quickhacks += new hack_type() + return GLOB.machine_quickhacks + +// ===== OVERLOAD ===== + +/** + * Straight off `/datum/action/innate/ai/ranged/overload_machine` (malf_ai_modules.dm:521), + * including its blacklist, with a smaller blast because this one is repeatable rather + * than two uses a purchase. Lights burst instead of exploding. A light bulb should not + * take out a wall. + */ +/datum/machine_quickhack/overload + name = "Overload" + desc = "Feed the machine power until it comes apart. Four seconds of loud buzzing, then a small explosion that destroys it. Lights just burst." + radial_icon_state = "overload_machine" + cooldown = 45 SECONDS + +/datum/machine_quickhack/overload/valid_for(atom/target, mob/living/user) + if(!ismachinery(target)) + return FALSE + var/obj/machinery/victim = target + if(victim.resistance_flags & INDESTRUCTIBLE) + return FALSE + return !is_type_in_typecache(victim, GLOB.blacklisted_malf_machines) + +/datum/machine_quickhack/overload/execute(atom/target, mob/living/user) + var/obj/machinery/victim = target + victim.audible_message(span_userdanger("[victim] starts buzzing, loudly.")) + playsound(victim, SFX_SPARKS, 60, TRUE) + user.playsound_local(user, 'sound/misc/interference.ogg', 40, FALSE) + // ADMIN_VERBOSEJMP expands to a ternary over its argument, so it needs a plain + // variable, a proc call inlined here doesn't parse inside the string + var/turf/blast_site = get_turf(victim) + message_admins("[ADMIN_LOOKUPFLW(user)] overloaded [victim.name] ([victim.type]) at [ADMIN_VERBOSEJMP(blast_site)] with Machine Communion.") + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(communion_burst_machine), victim, 1, 3), COMMUNION_OVERLOAD_DELAY) + return TRUE + +/** + * The end of an overload, single or mass: the machine comes apart and takes the tile + * with it. Lights burst instead of exploding, because a light bulb should not take out + * a wall. Blast radii are arguments because the mass version fires a dozen of these and + * has to be much smaller per machine, see [/datum/machine_masshack/overload]. + */ +/proc/communion_burst_machine(obj/machinery/victim, heavy_range = 1, light_range = 3, flash_range = null) + if(QDELETED(victim)) + return + if(istype(victim, /obj/machinery/light)) + var/obj/machinery/light/bulb = victim + bulb.break_light_tube() + return + explosion(victim, heavy_impact_range = heavy_range, light_impact_range = light_range, flash_range = flash_range) + if(!QDELETED(victim)) + qdel(victim) + +// ===== CUT THE SAFETIES ===== + +/** + * `emag_act` on anything that has one. The compatible-type list is lifted from the malf + * targeted override module (malf_ai_modules.dm:1090-1097) so the surface area is a known + * quantity rather than "every atom in the game". + */ +/datum/machine_quickhack/safeties + name = "Cut the Safeties" + desc = "Strip the target's safety interlocks. Exactly what an emag would do to it, which depends entirely on what it is." + radial_icon = 'icons/obj/card.dmi' + radial_icon_state = "emag" + cooldown = 25 SECONDS + +/datum/machine_quickhack/safeties/valid_for(atom/target, mob/living/user) + var/static/list/compatible = list( + /obj/machinery, + /obj/item/modular_computer, + /obj/item/radio/intercom, + /mob/living/basic/bot, + /mob/living/silicon, + /mob/living/simple_animal/bot, + ) + if(!is_type_in_list(target, compatible)) + return FALSE + if(ismachinery(target)) + var/obj/machinery/machine = target + return machine.is_operational + return TRUE + +/datum/machine_quickhack/safeties/execute(atom/target, mob/living/user) + if(!target.emag_act(user)) + target.balloon_alert(user, "nothing to strip!") + return FALSE + playsound(target, SFX_SPARKS, 50, TRUE) + to_chat(user, span_notice("[target] drops its safety interlocks.")) + return TRUE + +// ===== BOLTS ===== + +/datum/machine_quickhack/bolts + name = "Bolts" + desc = "Throw the door's bolts, or pull them if they are already down. Works on the AI wire, so a cut door will not answer." + radial_icon_state = "lockdown" + cooldown = 8 SECONDS + +/datum/machine_quickhack/bolts/valid_for(atom/target, mob/living/user) + return istype(target, /obj/machinery/door/airlock) + +/datum/machine_quickhack/bolts/execute(atom/target, mob/living/user) + var/obj/machinery/door/airlock/door = target + if(!door.canAIControl(user) || door.machine_stat) + door.balloon_alert(user, "no interface!") + return FALSE + if(door.locked) + door.unbolt() + door.balloon_alert(user, "bolts up") + else + door.bolt() + door.balloon_alert(user, "bolts down") + return TRUE + +// ===== ELECTRIFY ===== + +/** + * Stock electrification alone is not worth a capstone hack. A shock through the frame is + * `ELECTROCUTE_DAMAGE(area APC's spare energy)`, which is 0 below a kilojoule and jumps + * straight to 20 above it, so on a derelict with a flat cell the door does nothing at all, + * no burn and no stun, and `shock()` returning FALSE means it opens for them anyway. + * + * So a door the vestige has electrified carries its own current: a flat + * [COMMUNION_ELECTRIFY_DAMAGE] burn and a [COMMUNION_ELECTRIFY_STUN] stun no matter how little + * the local powernet has left in it. The door still has to be powered at all, same as ever. + * The hack cannot be cast on a dead one. That is what `communion_live` below is for. + */ +/datum/machine_quickhack/electrify + name = "Electrify" + desc = "Run the door's frame live for 30 seconds. It draws on your own current rather than the area's, so a \ + nearly-empty APC still lands the shock. Touching it without insulated gloves is 20 burn and a stun." + radial_icon = 'icons/obj/machines/wallmounts.dmi' + radial_icon_state = "apc-spark" + cooldown = 20 SECONDS + +/datum/machine_quickhack/electrify/valid_for(atom/target, mob/living/user) + return istype(target, /obj/machinery/door/airlock) + +/datum/machine_quickhack/electrify/execute(atom/target, mob/living/user) + var/obj/machinery/door/airlock/door = target + if(!door.canAIControl(user) || door.machine_stat) + door.balloon_alert(user, "no interface!") + return FALSE + door.set_electrified(COMMUNION_ELECTRIFY_SECONDS, user) + door.set_communion_live(COMMUNION_ELECTRIFY_SECONDS) + door.balloon_alert(user, "live") + playsound(door, SFX_SPARKS, 50, TRUE) + return TRUE + +/// Set for as long as a Machine Communion electrification lasts. See [/datum/machine_quickhack/electrify]. +/obj/machinery/door/airlock/var/communion_live = FALSE + +/// Makes this airlock's electrification self-powered for `seconds`, matching the seconds `set_electrified` takes. +/obj/machinery/door/airlock/proc/set_communion_live(seconds) + communion_live = TRUE + addtimer(VARSET_CALLBACK(src, communion_live, FALSE), seconds SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE) + +/** + * Every path that shocks somebody on an airlock funnels through here, so overriding it once + * covers bumping the door, touching it, and prying at it with a crowbar alike. Off a + * communion-live door we do the parent's job by hand rather than through `electrocute_mob`, + * which reads its damage off the area's APC and refuses non-carbons outright. + * + * `electrocute_act` with default flags still runs the victim's siemens coefficient, so + * insulated gloves zero it out and nothing lands, same as an ordinary electrified door. + * The explicit Paralyze is what carries the stun onto simple and basic mobs, whose + * `electrocute_act` only ever adjusts fire loss. + */ +/obj/machinery/door/airlock/shock(mob/living/user, prb) + if(!communion_live || !isElectrified()) + return ..() + // Gating lifted from the parent: same power requirement, same one-second-per-victim grace + // so a live doorway is not a stunlock, same probability roll. + if(!istype(user) || !hasPower()) + return FALSE + if(HAS_TRAIT(user, TRAIT_AIRLOCK_SHOCKIMMUNE)) + return FALSE + if(!COOLDOWN_FINISHED(src, shockCooldown)) + return FALSE + if(!prob(prb)) + return FALSE + do_sparks(5, TRUE, src) + if(!user.electrocute_act(COMMUNION_ELECTRIFY_DAMAGE, src)) + return FALSE + user.Paralyze(COMMUNION_ELECTRIFY_STUN) + log_combat(src, user, "electrocuted") + COOLDOWN_START(src, shockCooldown, 1 SECONDS) + ADD_TRAIT(user, TRAIT_AIRLOCK_SHOCKIMMUNE, REF(src)) + addtimer(TRAIT_CALLBACK_REMOVE(user, TRAIT_AIRLOCK_SHOCKIMMUNE, REF(src)), 1 SECONDS) + return TRUE + +// ===== KILL THE POWER ===== + +/datum/machine_quickhack/kill_power + name = "Kill the Power" + desc = "Flip an APC's breaker. Lights, equipment and air handling across that whole area go dead until it is flipped back." + radial_icon = 'icons/obj/machines/wallmounts.dmi' + radial_icon_state = "apc0" + cooldown = 30 SECONDS + +/datum/machine_quickhack/kill_power/valid_for(atom/target, mob/living/user) + return isapc(target) + +/datum/machine_quickhack/kill_power/execute(atom/target, mob/living/user) + var/obj/machinery/power/apc/apc = target + if(apc.aidisabled || !apc.is_operational || apc.failure_timer) + apc.balloon_alert(user, "no interface!") + return FALSE + apc.toggle_breaker(user) + playsound(apc, 'sound/machines/terminal/terminal_off.ogg', 50, FALSE) + return TRUE + +// ===== BURST THE LIGHTS ===== + +/** + * The malf blackout, aimed at one APC instead of every APC on the station. + * + * It breaks the tubes directly rather than going through `overload_lighting()`, which + * silently does nothing when the APC's cell is flat (apc_main.dm:732-736), a hack that + * fails invisibly is worse than one that does not exist. + */ +/datum/machine_quickhack/burst_lights + name = "Burst the Lights" + desc = "Push an APC's lighting circuit until every bulb in its area breaks. They stay broken." + radial_icon_state = "blackout" + cooldown = 30 SECONDS + +/datum/machine_quickhack/burst_lights/valid_for(atom/target, mob/living/user) + return isapc(target) + +/datum/machine_quickhack/burst_lights/execute(atom/target, mob/living/user) + var/obj/machinery/power/apc/apc = target + if(apc.aidisabled) + apc.balloon_alert(user, "no interface!") + return FALSE + var/burst = 0 + for(var/obj/machinery/light/bulb as anything in apc.get_lights()) + if(QDELETED(bulb)) + continue + bulb.on = TRUE + bulb.break_light_tube() + burst++ + CHECK_TICK + if(!burst) + apc.balloon_alert(user, "no lights on this circuit!") + return FALSE + playsound(apc, 'sound/effects/light_flicker.ogg', 60, FALSE) + to_chat(user, span_notice("Overcurrent applied. [burst] bulb\s gone.")) + return TRUE + +// ===== FEEDBACK SURGE ===== + +/** + * The headline, and the part of the spec that has no prior art to lift: everything a + * person is carrying that holds a charge dumps that charge into the person. + * + * The shock scales with what they were carrying, so this is a hard counter to somebody + * walking around with an armoury and close to nothing against somebody in a jumpsuit. + * SHOCK_NOGLOVES is deliberate, the current is coming out of their own belt, and + * insulated gloves have nothing to do with it. + */ +/datum/machine_quickhack/feedback_surge + name = "Feedback Surge" + desc = "Dump everything a person's gear is holding into the person. Empties every power cell they are carrying and shocks them for up to 60 burn, scaled to how much charge there was." + radial_icon = 'icons/obj/weapons/baton.dmi' + radial_icon_state = "stunbaton_active" + cooldown = 60 SECONDS + +/datum/machine_quickhack/feedback_surge/valid_for(atom/target, mob/living/user) + if(!isliving(target) || target == user) + return FALSE + var/mob/living/victim = target + return victim.stat != DEAD + +/datum/machine_quickhack/feedback_surge/execute(atom/target, mob/living/user) + var/mob/living/victim = target + var/drained = vestige_drain_carried_cells(victim) + if(drained <= 0) + victim.balloon_alert(user, "carrying nothing live!") + return FALSE + + var/shock = clamp(round(drained / STANDARD_CELL_CHARGE) * COMMUNION_SURGE_PER_CELL, COMMUNION_SURGE_MIN, COMMUNION_SURGE_MAX) + victim.visible_message( + span_boldwarning("Everything [victim] is carrying goes off at once in a sheet of sparks!"), + span_userdanger("Every powered thing on you empties itself through you!"), + ) + do_sparks(5, FALSE, victim) + playsound(victim, 'sound/items/weapons/egloves.ogg', 75, TRUE) + victim.electrocute_act(shock, user, flags = SHOCK_NOGLOVES|SHOCK_KNOCKDOWN) + victim.drop_all_held_items() + return TRUE + +// ===== CUTOUT ===== + +/** + * The quiet half of the same trick. No shock, no stun, no announcement, their gear + * simply stops working and their radios stop transmitting, and they find out when they + * pull a trigger. Half the cooldown, none of the damage. + */ +/datum/machine_quickhack/cutout + name = "Cutout" + desc = "Quietly empty every power cell a person is carrying and switch their radios off for 45 seconds. No shock and no stun; their gear just stops." + radial_icon = 'icons/obj/weapons/baton.dmi' + radial_icon_state = "stunbaton_nocell" + cooldown = 35 SECONDS + +/datum/machine_quickhack/cutout/valid_for(atom/target, mob/living/user) + if(!isliving(target) || target == user) + return FALSE + var/mob/living/victim = target + return victim.stat != DEAD + +/datum/machine_quickhack/cutout/execute(atom/target, mob/living/user) + var/mob/living/victim = target + var/drained = vestige_drain_carried_cells(victim) + var/silenced = 0 + for(var/obj/item/radio/radio in victim.get_all_contents()) + if(communion_radio_off(radio)) + silenced++ + if(drained <= 0 && !silenced) + victim.balloon_alert(user, "carrying nothing live!") + return FALSE + to_chat(user, span_notice("[victim]'s gear goes quiet. [victim.p_they(TRUE)] [victim.p_do()]n't seem to have noticed.")) + // The tell lands a beat later, and it is the only one they get. + addtimer(CALLBACK(src, PROC_REF(notice), victim), 2 SECONDS) + return TRUE + +/datum/machine_quickhack/cutout/proc/notice(mob/living/victim) + if(QDELETED(victim) || victim.stat == DEAD) + return + to_chat(victim, span_warning("Something in your gear clicks, and every charge light you can see goes out.")) + +// ===== SILENCE ===== + +/datum/machine_quickhack/silence + name = "Silence" + desc = "Jam every radio within nine tiles of what you clicked for 45 seconds, and switch the ones already in range off outright for as long." + radial_icon = 'icons/obj/devices/syndie_gadget.dmi' + radial_icon_state = "jammer" + cooldown = 40 SECONDS + +/datum/machine_quickhack/silence/valid_for(atom/target, mob/living/user) + var/turf/spot = get_turf(target) + if(isnull(spot)) + return FALSE + // Only offered where there is somebody to silence. Jamming an empty corridor burns + // forty seconds for nothing, and this hack is targetable at almost anything. + for(var/mob/living/candidate in view(COMMUNION_JAM_RANGE, spot)) + if(candidate != user) + return TRUE + return FALSE + +/datum/machine_quickhack/silence/execute(atom/target, mob/living/user) + var/turf/spot = get_turf(target) + if(isnull(spot)) + return FALSE + new /obj/item/jammer/machine_communion(spot) + for(var/mob/living/holder in view(COMMUNION_JAM_RANGE, spot)) + for(var/obj/item/radio/radio in holder.get_all_contents()) + communion_radio_off(radio) + for(var/obj/item/radio/loose in view(COMMUNION_JAM_RANGE, spot)) + communion_radio_off(loose) + to_chat(user, span_notice("Everything with an aerial around [target] stops talking.")) + playsound(spot, 'sound/misc/interference.ogg', 40, TRUE) + return TRUE + +/** + * The Silence bubble. + * + * `is_within_radio_jammer_range()` walks `GLOB.active_jammers` and reads `.range` off + * each entry (traitordevices.dm:318-322), so the cheapest correct way to jam an area is + * to be a jammer. Invisible, unclickable, and it deletes itself. + */ +/obj/item/jammer/machine_communion + name = "silence" + desc = "You should not be able to see this." + invisibility = INVISIBILITY_ABSTRACT + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + anchored = TRUE + range = COMMUNION_JAM_RANGE + +/obj/item/jammer/machine_communion/Initialize(mapload) + . = ..() + active = TRUE + GLOB.active_jammers |= src + QDEL_IN(src, COMMUNION_JAM_DURATION) + +// ===== SHARED HELPERS ===== + +/** + * Switches a radio off the way an EMP does, and books the same recovery. + * + * `set_broadcasting(FALSE)` is NOT this: broadcasting is the open-mic hot function, + * already off on every standard headset, and `talk_into` ignores it entirely + * (radio.dm:233-234 says so in as many words). The switch that actually gates both + * talking and hearing is `on`, and riding the radio's own EMP bookkeeping + * (`emped` + [/obj/item/radio/proc/end_emp_effect]) means a real EMP landing + * mid-outage extends it instead of having it cancelled by our recovery timer. + * + * Returns TRUE if the radio was on and is now off. + */ +/proc/communion_radio_off(obj/item/radio/radio, duration = COMMUNION_RADIO_OFF_DURATION) + if(QDELETED(radio) || !radio.on) + return FALSE + radio.emped++ + radio.set_on(FALSE) + addtimer(CALLBACK(radio, TYPE_PROC_REF(/obj/item/radio, end_emp_effect), radio.emped), duration) + return TRUE + +/** + * Empties every power cell inside an atom and returns the total charge taken, in joules. + * + * Works off `get_cell()`, which is the codebase's one honest answer to "does this thing + * hold a charge", energy guns, security batons, MOD suits, tablets, defibs, cyborgs and + * bare cells all implement it. Cells are deduplicated because a gun and the cell inside + * it both show up in `get_all_contents()` and both resolve to the same cell. + */ +/proc/vestige_drain_carried_cells(atom/holder) + if(QDELETED(holder)) + return 0 + var/total = 0 + var/list/already_drained = list() + for(var/atom/movable/thing as anything in holder.get_all_contents()) + if(thing == holder) + continue + var/obj/item/stock_parts/power_store/cell = thing.get_cell() + if(isnull(cell) || (cell in already_drained)) + continue + already_drained += cell + if(cell.charge <= 0) + continue + total += cell.use(cell.charge, TRUE) + cell.update_appearance() + if(thing != cell) + thing.update_appearance() + return total + +#undef COMMUNION_ELECTRIFY_SECONDS +#undef COMMUNION_ELECTRIFY_DAMAGE +#undef COMMUNION_ELECTRIFY_STUN +#undef COMMUNION_OVERLOAD_DELAY +#undef COMMUNION_JAM_RANGE +#undef COMMUNION_JAM_DURATION +#undef COMMUNION_RADIO_OFF_DURATION +#undef COMMUNION_SURGE_PER_CELL +#undef COMMUNION_SURGE_MAX +#undef COMMUNION_SURGE_MIN diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index bafc9603e94..b2b1e46a7d1 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -72,6 +72,7 @@ #include "voidcrew_crew_hud.dm" #include "voidcrew_cyberware.dm" #include "voidcrew_cyberware_camo.dm" +#include "voidcrew_cyberware_deck.dm" #include "voidcrew_cyberware_mantis.dm" #include "voidcrew_cyberware_models_a.dm" #include "voidcrew_cyberware_models_b.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm new file mode 100644 index 00000000000..0b13b3b60cd --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm @@ -0,0 +1,312 @@ +/** + * # Cyberdecks and the shared quickhacks + * + * The Machine Communion boon still carries exactly its own hacks after they + * moved to voidcrew/modules/quickhacks/, decks never carry the boon's access + * traits or its two boon-only hacks, each model carries everything the one + * below it does, outpost property refuses a deck, and an upload is lost to a + * step or an EMP without costing a cooldown. + * + * Unit-test files compile before voidcrew/_DEFINES/ and before the deck file, + * so fork defines are literals here: "cyberware_deck" is + * ORGAN_SLOT_CYBERWARE_DECK, "outpost_property" is TRAIT_OUTPOST_PROPERTY and + * "cyberware_zoom_click" is COMSIG_CYBERWARE_ZOOM_CLICK. + */ + +/// The boon is granted the way a trial grants it, keeps its access traits and +/// both buttons, and its kit is the original nine quickhacks and three room hacks. +/datum/unit_test/voidcrew_cyberware_deck_boon_kit + +/datum/unit_test/voidcrew_cyberware_deck_boon_kit/Run() + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + user.mind_initialize() + var/datum/vestige_boon/spell/machine_communion/boon = allocate(/datum/vestige_boon/spell/machine_communion) + boon.grant(user, user.mind) + var/datum/action/cooldown/spell/machine_communion/click = locate() in user.actions + var/datum/action/cooldown/spell/mass_hack/room = locate() in user.actions + TEST_ASSERT_NOTNULL(click, "The Machine Communion boon no longer grants its click ability") + TEST_ASSERT_NOTNULL(room, "The Machine Communion boon no longer grants its room ability") + TEST_ASSERT(HAS_TRAIT(user, TRAIT_SILICON_ACCESS) && HAS_TRAIT(user, TRAIT_AI_ACCESS), "The Machine Communion boon lost its machine access") + + var/list/boon_quick = list( + /datum/machine_quickhack/overload, + /datum/machine_quickhack/safeties, + /datum/machine_quickhack/bolts, + /datum/machine_quickhack/electrify, + /datum/machine_quickhack/kill_power, + /datum/machine_quickhack/burst_lights, + /datum/machine_quickhack/feedback_surge, + /datum/machine_quickhack/cutout, + /datum/machine_quickhack/silence, + ) + var/list/carried_quick = list() + for(var/datum/machine_quickhack/hack as anything in GLOB.communion_quickhack_kit.get_quickhacks()) + carried_quick += hack.type + TEST_ASSERT_EQUAL(length(carried_quick), length(boon_quick), "The boon carries [length(carried_quick)] quickhacks instead of its [length(boon_quick)]") + for(var/hack_type in boon_quick) + TEST_ASSERT(hack_type in carried_quick, "The boon lost [hack_type]") + + var/list/boon_room = list( + /datum/machine_masshack/overload, + /datum/machine_masshack/arc_flash, + /datum/machine_masshack/wake, + ) + var/list/carried_room = list() + for(var/datum/machine_masshack/hack as anything in GLOB.communion_quickhack_kit.get_masshacks()) + carried_room += hack.type + TEST_ASSERT_EQUAL(length(carried_room), length(boon_room), "The boon carries [length(carried_room)] room hacks instead of its [length(boon_room)]") + for(var/hack_type in boon_room) + TEST_ASSERT(hack_type in carried_room, "The boon lost [hack_type]") + + // A real door still offers the boon's door hacks through its own menu list, + // and nothing written for decks reaches it. + var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, get_step(user, EAST)) + var/list/offered = list() + for(var/datum/machine_quickhack/hack as anything in click.applicable_hacks(door, user)) + offered += hack.type + TEST_ASSERT(/datum/machine_quickhack/bolts in offered, "The boon no longer offers Bolts on a door") + TEST_ASSERT(/datum/machine_quickhack/electrify in offered, "The boon no longer offers Electrify on a door") + TEST_ASSERT(!(/datum/machine_quickhack/reboot_turret in carried_quick) && !(/datum/machine_quickhack/glitch in carried_quick), "A deck-only quickhack reached the boon") + TEST_ASSERT(!(/datum/machine_masshack/ping in carried_room), "Ping reached the boon") + + // The boon's slices still carry their details, cooldowns as written. + var/datum/machine_quickhack/bolts = GLOB.communion_quickhack_kit.find_quickhack(/datum/machine_quickhack/bolts) + var/datum/radial_menu_choice/option = GLOB.communion_quickhack_kit.menu_option(bolts) + TEST_ASSERT(findtext(option.info, "(8 second cooldown.)"), "The boon's Bolts slice lost its details: [option.info]") + TEST_ASSERT_EQUAL(GLOB.communion_quickhack_kit.hack_cooldown(bolts), 8 SECONDS, "The boon's cooldowns changed") + +/// The deck's hack radial, answered without a client: returns [pick] if the +/// target offered it, as a player clicking that slice would. +/datum/action/cooldown/cyberware/deck/unit_test_prompt + var/pick + +/datum/action/cooldown/cyberware/deck/unit_test_prompt/choose_quickhack(mob/living/carbon/bearer, atom/target, list/options) + return (pick in options) ? pick : null + +/// Every deck: its own head slot, no access, no boon-only hacks, cumulative +/// lists up the ladder, and using it arms targeting with no hack ring on the wheel. +/datum/unit_test/voidcrew_cyberware_deck_line + +/datum/unit_test/voidcrew_cyberware_deck_line/Run() + var/obj/item/organ/cyberimp/cyberware/deck/junker = allocate(/obj/item/organ/cyberimp/cyberware/deck) + var/obj/item/organ/cyberimp/cyberware/deck/street/street = allocate(/obj/item/organ/cyberimp/cyberware/deck/street) + var/obj/item/organ/cyberimp/cyberware/deck/control/control = allocate(/obj/item/organ/cyberimp/cyberware/deck/control) + var/obj/item/organ/cyberimp/cyberware/deck/assault/assault = allocate(/obj/item/organ/cyberimp/cyberware/deck/assault) + var/obj/item/organ/cyberimp/cyberware/deck/flagship/flagship = allocate(/obj/item/organ/cyberimp/cyberware/deck/flagship) + var/list/decks = list(junker, street, control, assault, flagship) + + for(var/obj/item/organ/cyberimp/cyberware/deck/deck as anything in decks) + TEST_ASSERT_EQUAL(deck.slot, "cyberware_deck", "[deck.type] is not in the deck slot") + TEST_ASSERT_EQUAL(deck.zone, BODY_ZONE_HEAD, "[deck.type] is not head chrome") + TEST_ASSERT(!length(deck.organ_traits), "[deck.type] carries organ traits") + TEST_ASSERT_NOTNULL(deck.kit, "[deck.type] built no kit") + // Boon-only hacks: never listed, and refused by the kit even if they were. + TEST_ASSERT(!(/datum/machine_quickhack/safeties in deck.quickhacks), "[deck.type] lists Cut the Safeties") + TEST_ASSERT(!(/datum/machine_masshack/wake in deck.masshacks), "[deck.type] lists Wake the Machines") + TEST_ASSERT(!deck.kit.carries(/datum/machine_quickhack/safeties), "[deck.type] carries Cut the Safeties") + TEST_ASSERT(!deck.kit.carries(/datum/machine_masshack/wake), "[deck.type] carries Wake the Machines") + TEST_ASSERT(deck.kit.needs_sight, "[deck.type] hacks without line of sight") + TEST_ASSERT(deck.kit.upload_time > 0, "[deck.type] lands hacks with no upload") + // Every listed hack actually made it into the kit. + TEST_ASSERT_EQUAL(length(deck.kit.get_quickhacks()), length(deck.quickhacks), "[deck.type] lost a listed quickhack") + TEST_ASSERT_EQUAL(length(deck.kit.get_masshacks()), length(deck.masshacks), "[deck.type] lost a listed room hack") + + var/datum/quickhack_kit/rogue = allocate(/datum/quickhack_kit, list(/datum/machine_quickhack/safeties, /datum/machine_quickhack/bolts), list(/datum/machine_masshack/wake)) + TEST_ASSERT(!rogue.carries(/datum/machine_quickhack/safeties) && !rogue.carries(/datum/machine_masshack/wake), "A deck kit that lists the boon-only hacks got them") + TEST_ASSERT_EQUAL(length(rogue.get_quickhacks()), 1, "A kit listing a boon-only hack kept it") + TEST_ASSERT(!length(rogue.get_masshacks()), "A kit listing Wake the Machines kept it") + + // Higher models carry everything below them. + var/list/ladder = list( + list(junker, street), + list(street, control), + list(street, assault), + list(control, flagship), + list(assault, flagship), + ) + for(var/list/rung as anything in ladder) + var/obj/item/organ/cyberimp/cyberware/deck/lower = rung[1] + var/obj/item/organ/cyberimp/cyberware/deck/upper = rung[2] + for(var/hack_type in lower.quickhacks + lower.masshacks) + TEST_ASSERT((hack_type in upper.quickhacks) || (hack_type in upper.masshacks), "[upper.type] dropped [hack_type] that [lower.type] carries") + TEST_ASSERT(upper.tier > lower.tier, "[upper.type] is not a tier above [lower.type]") + TEST_ASSERT(upper.hack_range >= lower.hack_range, "[upper.type] reaches less far than [lower.type]") + TEST_ASSERT(upper.upload_time <= lower.upload_time, "[upper.type] uploads slower than [lower.type]") + TEST_ASSERT(upper.cooldown_mult <= lower.cooldown_mult, "[upper.type] cools slower than [lower.type]") + + // Installed: no access traits, the button is granted, a better deck evicts the old one. + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + for(var/obj/item/organ/cyberimp/cyberware/deck/deck as anything in decks) + TEST_ASSERT(deck.Insert(user, special = TRUE), "[deck.type] staging insert was refused") + TEST_ASSERT_EQUAL(user.get_organ_slot("cyberware_deck"), deck, "[deck.type] did not take the deck slot") + TEST_ASSERT(!HAS_TRAIT(user, TRAIT_SILICON_ACCESS), "[deck.type] granted silicon access") + TEST_ASSERT(!HAS_TRAIT(user, TRAIT_AI_ACCESS), "[deck.type] granted AI access") + var/datum/action/cooldown/cyberware/deck/granted = locate() in deck.actions + TEST_ASSERT(granted?.owner == user, "[deck.type] granted no deck button") + TEST_ASSERT(QDELETED(junker) || junker.owner != user, "The Junker deck stayed installed beside a better deck") + + // No hack ring on the chrome wheel (owner, 11-08): using the deck, from its + // button or its wheel slice, arms targeting, and using it again disarms. + var/datum/action/cooldown/cyberware/deck/action = locate() in flagship.actions + TEST_ASSERT(!length(action.chrome_wheel_options(user)), "Deck hacks are on the chrome wheel's ring") + TEST_ASSERT(action.Trigger(user), "Using the deck did not arm it") + TEST_ASSERT_EQUAL(user.click_intercept, action, "Using the deck did not arm targeting") + action.Trigger(user) + TEST_ASSERT(user.click_intercept != action, "Using an armed deck again did not disarm it") + +/// Outpost property refuses a deck outright, Overload included, while the same +/// machine without it is fair game. +/datum/unit_test/voidcrew_cyberware_deck_outpost_property + +/datum/unit_test/voidcrew_cyberware_deck_outpost_property/Run() + var/turf/start = run_loc_floor_bottom_left + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, start) + var/obj/item/organ/cyberimp/cyberware/deck/assault/deck = allocate(/obj/item/organ/cyberimp/cyberware/deck/assault) + TEST_ASSERT(deck.Insert(user, special = TRUE), "Breacher deck staging insert was refused") + var/datum/action/cooldown/cyberware/deck/action = locate() in deck.actions + + var/obj/machinery/photocopier/plain = allocate(/obj/machinery/photocopier, get_step(start, EAST)) + var/list/plain_hacks = list() + for(var/datum/machine_quickhack/hack as anything in deck.kit.applicable_quickhacks(plain, user)) + plain_hacks += hack.type + TEST_ASSERT(/datum/machine_quickhack/overload in plain_hacks, "Overload is not offered on an ordinary machine") + TEST_ASSERT(action.can_reach(user, plain), "The deck can't reach an ordinary machine next to it") + + var/obj/machinery/photocopier/property = allocate(/obj/machinery/photocopier, get_step(start, NORTH)) + property.AddElement(/datum/element/outpost_property) + TEST_ASSERT(HAS_TRAIT(property, "outpost_property"), "The outpost property fixture did not take") + var/list/property_hacks = list() + for(var/datum/machine_quickhack/hack as anything in deck.kit.applicable_quickhacks(property, user)) + property_hacks += hack.type + TEST_ASSERT(!(/datum/machine_quickhack/overload in property_hacks), "Overload is offered on outpost property") + TEST_ASSERT(!action.can_reach(user, property), "The deck reaches outpost property") + TEST_ASSERT(!action.Activate(property), "The deck started a hack on outpost property") + TEST_ASSERT(!action.busy && !QDELETED(property), "The deck went to work on outpost property") + +/// An upload lands only if the hacker holds still and stays online: a step or +/// an EMP loses it and costs no cooldown. A clean upload lands and charges the +/// deck's cooldown, a hack the target would not take is never offered, and the +/// optics zoom's click reaches an armed deck. +/datum/unit_test/voidcrew_cyberware_deck_upload + +/datum/unit_test/voidcrew_cyberware_deck_upload/Run() + var/turf/start = run_loc_floor_bottom_left + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, start) + var/obj/item/organ/cyberimp/cyberware/deck/deck = allocate(/obj/item/organ/cyberimp/cyberware/deck) + TEST_ASSERT(deck.Insert(user, special = TRUE), "Junker deck staging insert was refused") + var/datum/action/cooldown/cyberware/deck/unit_test_prompt/action = allocate(/datum/action/cooldown/cyberware/deck/unit_test_prompt, deck) + action.Grant(user) + action.pick = "Bolts" + var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, get_step(get_step(start, EAST), EAST)) + door.machine_stat = NONE + door.locked = FALSE + var/wait = deck.upload_time + 1 SECONDS + + // A step mid-upload loses it. + TEST_ASSERT(action.Activate(door), "The deck would not start bolting a door in reach") + TEST_ASSERT(action.busy, "A started upload did not mark the deck busy") + TEST_ASSERT(action.trace, "A started upload drew no trace line") + user.forceMove(get_step(start, NORTH)) + stoplag(wait) + TEST_ASSERT(!door.locked, "A hack landed after the hacker moved") + TEST_ASSERT(action.next_use_time <= world.time, "A lost upload charged a cooldown") + TEST_ASSERT(!action.busy, "A lost upload left the deck busy") + TEST_ASSERT(isnull(action.trace), "A lost upload left its trace line up") + + // An EMP mid-upload loses it too. + TEST_ASSERT(action.Activate(door), "The deck would not start a second upload") + deck.emp_act(EMP_HEAVY) + stoplag(wait) + TEST_ASSERT(!door.locked, "A hack landed through an EMP") + TEST_ASSERT(action.next_use_time <= world.time, "An upload lost to an EMP charged a cooldown") + var/datum/component/cyberware/chrome = deck.GetComponent(/datum/component/cyberware) + chrome.tune_up() + TEST_ASSERT(chrome.is_running(), "The deck did not come back after a tune-up") + + // Held still, it lands, and the deck's cooldown multiplier applies. + TEST_ASSERT(action.Activate(door), "The deck would not start a clean upload") + stoplag(wait) + TEST_ASSERT(door.locked, "A clean upload did not bolt the door") + TEST_ASSERT(action.next_use_time > world.time, "A landed hack charged no cooldown") + var/datum/machine_quickhack/bolts = deck.kit.find_quickhack(/datum/machine_quickhack/bolts) + TEST_ASSERT_EQUAL(deck.kit.hack_cooldown(bolts), round(bolts.cooldown * deck.cooldown_mult), "The deck's cooldown multiplier did not apply") + action.ResetCooldown() + + // The radial on a door offers door hacks only. + action.pick = "Burst the Lights" + TEST_ASSERT(action.Activate(door), "The deck would not open its radial on a door") + stoplag(1) + TEST_ASSERT(!action.busy && action.next_use_time <= world.time, "A hack the door would not take ran on it") + action.pick = "Bolts" + + // Through the zoom (signal args: bearer, target, modifiers; 1 is + // COMPONENT_CYBERWARE_ZOOM_CLICK_HANDLED): an unarmed deck leaves the click + // alone, an armed one takes it, runs its flow on that target and disarms. + var/was_locked = door.locked + TEST_ASSERT(!(SEND_SIGNAL(user, "cyberware_zoom_click", door, list()) & 1), "An unarmed deck claimed a zoomed click") + stoplag(wait) + TEST_ASSERT(!action.busy && door.locked == was_locked, "An unarmed deck took a zoomed click") + TEST_ASSERT(action.Trigger(user) && user.click_intercept == action, "The deck would not arm for the zoom") + TEST_ASSERT(SEND_SIGNAL(user, "cyberware_zoom_click", door, list()) & 1, "An armed deck did not claim a zoomed click") + stoplag(wait) + TEST_ASSERT(door.locked != was_locked, "A zoomed click on an armed deck did not run its hack") + TEST_ASSERT(user.click_intercept != action, "The deck stayed armed after a zoomed click ran") + action.ResetCooldown() + + // The zoom checks no range; the deck does. Far past its reach, an armed + // deck still claims the click, refuses it and stays armed. + var/turf/far = locate(start.x + deck.hack_range + 3, start.y, start.z) + if(far) + var/obj/machinery/door/airlock/far_door = allocate(/obj/machinery/door/airlock, far) + far_door.machine_stat = NONE + far_door.locked = FALSE + TEST_ASSERT(action.Trigger(user), "The deck would not arm for a far zoomed click") + TEST_ASSERT(SEND_SIGNAL(user, "cyberware_zoom_click", far_door, list()) & 1, "An armed deck did not claim a far zoomed click") + stoplag(wait) + TEST_ASSERT(!far_door.locked && !action.busy, "A zoomed click past the deck's range ran a hack") + TEST_ASSERT_EQUAL(user.click_intercept, action, "A refused zoomed click disarmed the deck") + action.Trigger(user) + +/// The deck's own hacks: Glitch reboots one piece of running chrome, Reboot +/// Turret drops a turret and leaves outpost turrets alone, Ping marks what answers. +/datum/unit_test/voidcrew_cyberware_deck_hacks + +/datum/unit_test/voidcrew_cyberware_deck_hacks/Run() + var/turf/start = run_loc_floor_bottom_left + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, start) + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent, get_step(start, EAST)) + + var/datum/machine_quickhack/glitch/glitch + var/datum/machine_quickhack/reboot_turret/reboot + for(var/datum/machine_quickhack/hack as anything in get_machine_quickhacks()) + if(istype(hack, /datum/machine_quickhack/glitch)) + glitch = hack + else if(istype(hack, /datum/machine_quickhack/reboot_turret)) + reboot = hack + TEST_ASSERT_NOTNULL(glitch, "Glitch is not a quickhack") + TEST_ASSERT_NOTNULL(reboot, "Reboot Turret is not a quickhack") + + TEST_ASSERT(!glitch.valid_for(victim, user), "Glitch was offered on a body with no chrome") + var/obj/item/organ/cyberimp/cyberware/registry_uplink/ware = allocate(/obj/item/organ/cyberimp/cyberware/registry_uplink) + TEST_ASSERT(ware.Insert(victim, special = TRUE), "Registry uplink staging insert was refused") + TEST_ASSERT(glitch.valid_for(victim, user), "Glitch was not offered on a chromed body") + TEST_ASSERT(!glitch.valid_for(user, user), "Glitch was offered on the hacker") + TEST_ASSERT(glitch.execute(victim, user), "Glitch did not land") + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + TEST_ASSERT(chrome.emp_down, "Glitch did not reboot the target's chrome") + TEST_ASSERT(!glitch.valid_for(victim, user), "Glitch was offered with nothing left running") + + var/obj/machinery/porta_turret/turret = allocate(/obj/machinery/porta_turret, get_step(start, NORTH)) + TEST_ASSERT(reboot.valid_for(turret, user), "Reboot Turret was not offered on a live turret") + TEST_ASSERT(reboot.execute(turret, user), "Reboot Turret did not land") + TEST_ASSERT(!turret.on, "Reboot Turret left the turret on") + TEST_ASSERT(!COOLDOWN_FINISHED(turret, disabled_time), "Reboot Turret did not hold the turret down") + var/obj/machinery/porta_turret/outpost/outpost_turret = allocate(/obj/machinery/porta_turret/outpost, get_step(start, NORTHEAST)) + TEST_ASSERT(!reboot.valid_for(outpost_turret, user), "Reboot Turret was offered on an outpost turret") + + var/datum/machine_masshack/ping/ping + for(var/datum/machine_masshack/hack as anything in get_machine_masshacks()) + if(istype(hack, /datum/machine_masshack/ping)) + ping = hack + TEST_ASSERT_NOTNULL(ping, "Ping is not a room hack") + TEST_ASSERT(ping.execute(user, start), "Ping marked nothing beside a turret") From 8a67f9e1ce3db6c05efb28acc4976b5d2b2f7857 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:46:22 -0800 Subject: [PATCH 132/472] Drop the Ghostskin field on a throw and lighten the Brawler smash --- tools/gear_catalog/ratings/cyberware.json | 4 ++-- voidcrew/modules/cyberware/ware_line_camo.dm | 2 +- voidcrew/modules/cyberware/ware_line_models_a.dm | 2 +- voidcrew/modules/cyberware/ware_military_body.dm | 10 ++++++---- voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm | 6 ++++++ .../modules/unit_tests/voidcrew_cyberware_models_a.dm | 2 +- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 3dc5c205c90..5ee5c725648 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -605,7 +605,7 @@ "hash": "", "name": "Brawler myomers (right)", "rank": 12, - "reason": "Half of a pair: a flat 14 damage punch every 6 s, 25 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying. Load 4 the pair, 2,400 cr at reputation 4. Splice takes a pulled arm back for 600 cr.", + "reason": "Half of a pair: a flat 14 damage punch every 6 s, 20 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying. Load 4 the pair, 2,400 cr at reputation 4. Splice takes a pulled arm back for 600 cr.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left": { @@ -614,7 +614,7 @@ "hash": "", "name": "Brawler myomers (left)", "rank": 13, - "reason": "Left half of the Brawler pair: a flat 14 damage punch every 6 s, 25 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying.", + "reason": "Left half of the Brawler pair: a flat 14 damage punch every 6 s, 20 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/governor_delete": { diff --git a/voidcrew/modules/cyberware/ware_line_camo.dm b/voidcrew/modules/cyberware/ware_line_camo.dm index 0d2347a8da2..3c6774678c4 100644 --- a/voidcrew/modules/cyberware/ware_line_camo.dm +++ b/voidcrew/modules/cyberware/ware_line_camo.dm @@ -38,7 +38,7 @@ ) /datum/action/cooldown/cyberware/ghostskin_cloak/street - desc = "Five seconds of patchy optical camo, enough to fool people and not much else. Attacking, shooting, taking a hit or an EMP drops it." + desc = "Five seconds of patchy optical camo, enough to fool people and not much else. Attacking, shooting, throwing, taking a hit or an EMP drops it." cooldown_time = 45 SECONDS // ========================================================================= diff --git a/voidcrew/modules/cyberware/ware_line_models_a.dm b/voidcrew/modules/cyberware/ware_line_models_a.dm index 4b2ca50d4fd..f46d1baff4b 100644 --- a/voidcrew/modules/cyberware/ware_line_models_a.dm +++ b/voidcrew/modules/cyberware/ware_line_models_a.dm @@ -111,7 +111,7 @@ tier = CYBERWARE_TIER_2 aug_overlay = "gorilla_street_right" punch_damage = 14 - structure_damage = 25 + structure_damage = 20 punch_cooldown_time = 6 SECONDS smash_cooldown_time = 1.5 SECONDS hurl_range = 0 diff --git a/voidcrew/modules/cyberware/ware_military_body.dm b/voidcrew/modules/cyberware/ware_military_body.dm index 8bb666e57bd..f3f77a655b0 100644 --- a/voidcrew/modules/cyberware/ware_military_body.dm +++ b/voidcrew/modules/cyberware/ware_military_body.dm @@ -23,8 +23,8 @@ * invisibility) that ripples brighter when you move; NPC targeting honors * TRAIT_CYBER_CAMO through the hook in cyberware_stealth.dm, but anything * within two tiles spots you anyway. The field collapses the instant you - * attack, shoot, get struck, get cuffed, or eat an EMP, the break-trigger - * list is the MOD stealth module's, plus gunfire (nothing uncounterable + * attack, shoot, throw, get struck, get cuffed, or eat an EMP, the break-trigger + * list is the MOD stealth module's, plus gunfire and throws (nothing uncounterable * fires from inside a cloak on this server). */ /obj/item/organ/cyberimp/cyberware/ghostskin @@ -73,7 +73,7 @@ animate(wearer, alpha = field_alpha, time = 1 SECONDS) engage_feedback(wearer) // The MOD stealth break list (modules_ninja.dm), minus bump-off, plus - // gunfire, shooting from inside a cloak breaks it here. + // gunfire and throwing: shooting or throwing from inside a cloak breaks it here. RegisterSignal(wearer, COMSIG_LIVING_UNARMED_ATTACK, PROC_REF(on_unarmed_attack)) RegisterSignal(wearer, COMSIG_ATOM_BULLET_ACT, PROC_REF(on_bullet_act)) RegisterSignals(wearer, list( @@ -85,6 +85,7 @@ COMSIG_ATOM_ATTACK_PAW, COMSIG_CARBON_CUFF_ATTEMPTED, COMSIG_MOB_FIRED_GUN, + COMSIG_MOB_THROW, COMSIG_LIVING_DEATH, ), PROC_REF(on_break_trigger)) RegisterSignal(wearer, COMSIG_MOVABLE_MOVED, PROC_REF(on_camo_move)) @@ -125,6 +126,7 @@ COMSIG_ATOM_ATTACK_PAW, COMSIG_CARBON_CUFF_ATTEMPTED, COMSIG_MOB_FIRED_GUN, + COMSIG_MOB_THROW, COMSIG_LIVING_DEATH, COMSIG_MOVABLE_MOVED, )) @@ -168,7 +170,7 @@ /datum/action/cooldown/cyberware/ghostskin_cloak name = "Ghostskin Field" - desc = "Ten seconds of optical camo. Attacking, shooting, taking a hit or an EMP drops the field. Anyone standing right next to you still sees you." + desc = "Ten seconds of optical camo. Attacking, shooting, throwing, taking a hit or an EMP drops the field. Anyone standing right next to you still sees you." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_ghostskin" cooldown_time = 30 SECONDS diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm index e524d68257f..00a3b17c955 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm @@ -113,6 +113,12 @@ TEST_ASSERT(!HAS_TRAIT(ghost, "cyber_camo"), "Firing a gun didn't drop the field") TEST_ASSERT(hermit.CanAttack(ghost), "A hermit couldn't retaliate against someone who fired from under the field") + // Throwing from under the field drops it too: a thrown brick is an attack like any other. + var/mob/living/carbon/human/consistent/pitcher = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin, spot(4, 4)) + TEST_ASSERT(HAS_TRAIT(pitcher, "cyber_camo"), "Fixture: the second Ghostskin bearer isn't camo'd") + SEND_SIGNAL(pitcher, COMSIG_MOB_THROW, spot(0, 0)) + TEST_ASSERT(!HAS_TRAIT(pitcher, "cyber_camo"), "Throwing didn't drop the field") + // The street weave never grants the trait, so nothing here hides it. var/mob/living/carbon/human/consistent/flicker = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin/street, spot(4, 4)) TEST_ASSERT(!HAS_TRAIT(flicker, "cyber_camo"), "The Flickerskin granted the camo trait") diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm index 3b961354d6a..15c8ba4a124 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm @@ -125,7 +125,7 @@ // The model. TEST_ASSERT_EQUAL(brawler_right.punch_damage, 14, "Brawler punch isn't 14") - TEST_ASSERT_EQUAL(brawler_right.structure_damage, 25, "Brawler smash isn't 25") + TEST_ASSERT_EQUAL(brawler_right.structure_damage, 20, "Brawler smash isn't 20") TEST_ASSERT_EQUAL(brawler_right.punch_cooldown_time, 6 SECONDS, "Brawler punch clock isn't six seconds") TEST_ASSERT_EQUAL(brawler_right.smash_cooldown_time, 1.5 SECONDS, "Brawler smash clock isn't a second and a half") TEST_ASSERT(brawler_right.hurl_range <= 0, "Brawler myomers can hurl a grabbed victim") From 552c5117951fe8b544650fbf8660f65decf8bdbe Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:44:54 -0800 Subject: [PATCH 133/472] Rebuild the chrome wheel as a body inside a segmented ring The wheel now draws an outline of the bearer's body inside a ring cut into one arc per body region. Each ware's chips sit in a socket on its region's arc with a leader line to the part of the body it lives in; empty sockets keep a dim outline. Chips use the chrome read's look: tier-coloured frames, a blinking red frame while the chrome is down, a green inner line while deployed or armed, a draining shade with the seconds left, and the chrome read's corner brackets on the pick. Pointing at a chip grows it and lights its arc, leader and body part, and a fixed readout under the ring names the ability, the chrome it lives in and how it stands. Pointing stays by bearing from the centre, so flicks still work; inside the body nothing is pointed at. Chips pop in on open, and after a pick the wheel stays up for a moment to lock and fade while the pick itself lands at once. Chips use short names ("Mantis Blade", "Rockjaw"). Keys, firing, cooldown sharing, arm toggles and the HUD button frame are unchanged. The wheel no longer shows sub-options; the option hooks remain. --- voidcrew/_DEFINES/cyberware.dm | 75 +- voidcrew/modules/cyberware/chrome_wheel.dm | 1098 +++++++++++------ .../modules/cyberware/chrome_wheel_hud.dm | 121 +- .../modules/cyberware/icons/chrome_wheel.dmi | Bin 0 -> 1000 bytes .../cyberware/icons/chrome_wheel_ring.dmi | Bin 0 -> 29196 bytes .../unit_tests/voidcrew_chrome_wheel.dm | 113 +- 6 files changed, 944 insertions(+), 463 deletions(-) create mode 100644 voidcrew/modules/cyberware/icons/chrome_wheel.dmi create mode 100644 voidcrew/modules/cyberware/icons/chrome_wheel_ring.dmi diff --git a/voidcrew/_DEFINES/cyberware.dm b/voidcrew/_DEFINES/cyberware.dm index 20891da95f4..f90516f6e19 100644 --- a/voidcrew/_DEFINES/cyberware.dm +++ b/voidcrew/_DEFINES/cyberware.dm @@ -187,32 +187,55 @@ #define CHROME_WHEEL_HELD 1 #define CHROME_WHEEL_CLICK 2 -/// Body regions. Each owns fixed slice positions, so one region's chrome -/// changing never moves another region's slices. -#define CHROME_WHEEL_HEAD "head" -#define CHROME_WHEEL_RIGHT_ARM "right_arm" -#define CHROME_WHEEL_LEFT_ARM "left_arm" -#define CHROME_WHEEL_LEGS "legs" -#define CHROME_WHEEL_TORSO "torso" - -/// Radius in pixels of the torso ring around the centre. The OS slot sits at -/// its top. -#define CHROME_WHEEL_INNER_RADIUS 40 -/// Radius of the outer ring, which holds the head (top), right arm (right), -/// legs (bottom) and left arm (left), three positions each, 30 degrees apart. -#define CHROME_WHEEL_OUTER_RADIUS 72 -/// Distance between rows: a region's fourth slice and on, and the sub-option -/// ring outside everything. -#define CHROME_WHEEL_ROW_GAP 38 -/// Centre-to-centre spacing of sub-option slices along their ring. -#define CHROME_WHEEL_SLICE_SPACING 37 -/// Mouse distance from the centre inside which nothing is pointed at. -#define CHROME_WHEEL_DEADZONE 16 -/// A slice this close to the mouse is the one under it. -#define CHROME_WHEEL_PICK_RANGE 28 -/// Past the slices, the mouse points at whichever slice lies within this many -/// degrees of its direction. -#define CHROME_WHEEL_POINT_TOLERANCE 20 +/// Sockets: where a ware's chips sit on the ring. Each owns a run of fixed +/// positions on its region's arc, so one socket's chrome changing never moves +/// another socket's chips. Hand chrome rides its arm's socket. +#define CHROME_WHEEL_SOCKET_EYES "eyes" +#define CHROME_WHEEL_SOCKET_HEAD "head" +#define CHROME_WHEEL_SOCKET_OS "os" +#define CHROME_WHEEL_SOCKET_SKIN "skin" +#define CHROME_WHEEL_SOCKET_LEFT_ARM "l_arm" +#define CHROME_WHEEL_SOCKET_RIGHT_ARM "r_arm" +#define CHROME_WHEEL_SOCKET_LEGS "legs" + +/// Radius in pixels of the ring the chips sit on. +#define CHROME_WHEEL_RADIUS 100 +/// Degrees between neighbouring chips on the ring. +#define CHROME_WHEEL_STEP 14.5 +/// A socket with more chips than its arc holds starts another row this much +/// further out, at the same angles. +#define CHROME_WHEEL_ROW_GAP 26 +/// The ring's centre sits this many pixels above the centre of the view. +#define CHROME_WHEEL_CENTRE_Y 2 +/// Mouse distance from the ring centre inside which nothing is pointed at. +#define CHROME_WHEEL_DEADZONE 34 +/// A chip this close to the mouse is the one under it. +#define CHROME_WHEEL_PICK_RANGE 12 +/// Otherwise the mouse points at the first-row chip whose bearing from the ring +/// centre is nearest its own, if one lies within this many degrees. +#define CHROME_WHEEL_POINT_TOLERANCE 13 + +/// After a pick the wheel stays up this long for the lock and fade. The pick +/// itself lands at once. +#define CHROME_WHEEL_LINGER_TIME (0.2 SECONDS) + +#define CHROME_WHEEL_ICON 'voidcrew/modules/cyberware/icons/chrome_wheel.dmi' +/// 240x240 art centred on the ring: body, arcs, leaders, glows, backdrop. +#define CHROME_WHEEL_RING_ICON 'voidcrew/modules/cyberware/icons/chrome_wheel_ring.dmi' +/// Corner brackets borrowed from the chrome read. +#define CHROME_WHEEL_BRACKET_ICON 'voidcrew/modules/cyberware/icons/chrome_read.dmi' + +/// Wheel colours, the chrome read's own. +#define CHROME_WHEEL_HUD_COLOR CYBERWARE_COLOR_TIER_2 +#define CHROME_WHEEL_PANEL_COLOR "#060e12" +#define CHROME_WHEEL_NAME_COLOR "#e6fbff" +#define CHROME_WHEEL_DIM_COLOR "#8fa1a8" +#define CHROME_WHEEL_OFFLINE_COLOR "#ff5a4a" +#define CHROME_WHEEL_OFFLINE_NAME_COLOR "#7d8a90" +/// The pick's brackets (radial.dmi's module_selected cyan). +#define CHROME_WHEEL_PICKED_COLOR "#00ffff" +/// Deployed or armed (radial.dmi's module_active green). +#define CHROME_WHEEL_DEPLOYED_COLOR "#00ff00" // ---- Phase 2 models: seal, frame and nervous street models ------------- // ware_models_b.dm. Hardweave, the Strut frame, the Deep-lung bladder, the diff --git a/voidcrew/modules/cyberware/chrome_wheel.dm b/voidcrew/modules/cyberware/chrome_wheel.dm index 9cda3523496..b6c65891f70 100644 --- a/voidcrew/modules/cyberware/chrome_wheel.dm +++ b/voidcrew/modules/cyberware/chrome_wheel.dm @@ -1,44 +1,58 @@ /** * # Chrome wheel * - * One radial holding every ability the bearer's chrome carries, so nobody has + * One wheel holding every ability the bearer's chrome carries, so nobody has * to hunt a row of action buttons mid-fight. Three keybinds drive it * (chrome_wheel_keybindings.dm): * * - Chrome wheel: hold it and the wheel opens at the centre of the view. Point - * at a slice and let go, or click one, to pick it. A quick tap leaves the + * at a chip and let go, or click one, to pick it. A quick tap leaves the * wheel open for a click instead; pressing the key again shuts it. * - Use chrome: fires the picked ability on key down. * - Use OS chrome: fires the OS-slot ware's ability on key down, whatever is * picked. * - * A slice IS the bearer's existing /datum/action, the same one their button + * A chip IS the bearer's existing /datum/action, the same one their button * holds, fired through the same Trigger() a button click uses. Cooldowns, * ORGAN_FAILING, check_flags and click-to-target modes are therefore shared * with the buttons, and nothing here grants, copies or replaces an ability. * Abilities are collected generically from every installed organ carrying * /datum/component/cyberware, so new ware shows up without touching this file. * - * Layout is fixed by body region (chrome_wheel_layout()): head at the top, - * right arm on the right, legs at the bottom, left arm on the left, torso on a - * ring around the centre with the OS at its top. A region's chrome changing - * never moves another region's slices, and an empty region keeps a dim slice - * where it would be. The centre shows what Use chrome will fire, and that - * ability's own button carries the MOD-style "selected" frame on the HUD. + * Looks: an outline of the bearer's body inside a ring cut into one arc per + * body region. Each ware sits in a socket (chrome_wheel_socket()), a run of + * fixed positions on its region's arc, with a leader line from the socket to + * the part of the body it lives in. A socket's chrome changing never moves + * another socket's chips, and an empty socket keeps a dim outline. Pointing at + * a chip grows it and lights its arc, its leader and its body part in its + * tier's colour, and a fixed readout under the ring names it. The pick wears + * the chrome read's corner brackets, and its own button carries the MOD-style + * "selected" frame on the HUD. * - * Sub-options: an ability can hand the wheel an outer ring of choices through - * /datum/action/proc/chrome_wheel_options(), empty by default. Pointing at that - * ability opens the ring outside the wheel; picking one makes Use chrome call - * the ability's use_chrome_wheel_option() with its id instead of Trigger(). + * Pointing is by bearing from the ring's centre, so a flick outward picks the + * chip in that direction without landing on it. Inside the body nothing is + * pointed at. + * + * Sub-options: an ability can offer choices through + * /datum/action/proc/chrome_wheel_options(), empty by default. The wheel does + * not show them; a pick made with select(ability, option_id) makes Use chrome + * call the ability's use_chrome_wheel_option() with that id instead of Trigger(). * * Mouse handling: BYOND only reports the mouse over things it can hit, so the * open wheel lays a full-view cursor catcher (tg's, as the scope uses) under - * its mouse-transparent slices. Every move and click lands on the catcher, and - * the slice is worked out from where the mouse is relative to the centre. + * its mouse-transparent pieces. Every move and click lands on the catcher, and + * the chip is worked out from where the mouse is relative to the centre. */ -/// Organ slots that sort first inside their wheel region, in this order. Any -/// other slot follows them, alphabetically by slot name. +// Readout panel under the ring, in pixels from the bottom left of the centre +// tile (y up). The panel's top edge, left edge and size. +#define CHROME_WHEEL_READOUT_TOP -108 +#define CHROME_WHEEL_READOUT_LEFT -66 +#define CHROME_WHEEL_READOUT_WIDTH 164 +#define CHROME_WHEEL_READOUT_HEIGHT 25 + +/// Organ slots that sort first inside their socket, in this order. Any other +/// slot follows them, alphabetically by slot name. GLOBAL_LIST_INIT(chrome_wheel_slot_order, list( ORGAN_SLOT_EYES, ORGAN_SLOT_CYBERWARE_EARS, @@ -51,14 +65,31 @@ GLOBAL_LIST_INIT(chrome_wheel_slot_order, list( ORGAN_SLOT_CYBERWARE_LEGS, )) -/// Positions a region holds for one organ slot, filled or not, so the rest of -/// that region never moves when the slot is filled or emptied. Region => -/// list(slot, positions held). The OS owns the top of the torso ring; chrome -/// optics (their ability, the chrome read and the zoom) own the first row of -/// head positions. +/** + * Where each socket's chips sit on the ring. "start" is the bearing of its + * first chip in degrees clockwise from twelve o'clock, "turn" the way the rest + * follow (1 clockwise, -1 counter-clockwise), "fits" how many its arc holds + * before another row starts further out. "arc" is the ring arc it lights and + * "region" the body part. The art in chrome_wheel_ring.dmi is drawn to match. + */ +GLOBAL_LIST_INIT(chrome_wheel_sockets, list( + CHROME_WHEEL_SOCKET_EYES = list("start" = -9, "turn" = -1, "fits" = 2, "arc" = "head", "region" = "head"), + CHROME_WHEEL_SOCKET_HEAD = list("start" = 9, "turn" = 1, "fits" = 2, "arc" = "head", "region" = "head"), + CHROME_WHEEL_SOCKET_OS = list("start" = 47, "turn" = 1, "fits" = 2, "arc" = "os", "region" = "spine"), + CHROME_WHEEL_SOCKET_RIGHT_ARM = list("start" = 83, "turn" = 1, "fits" = 4, "arc" = "r_arm", "region" = "r_arm"), + CHROME_WHEEL_SOCKET_LEGS = list("start" = 180, "turn" = 1, "fits" = 3, "arc" = "legs", "region" = "legs"), + CHROME_WHEEL_SOCKET_LEFT_ARM = list("start" = -83, "turn" = -1, "fits" = 4, "arc" = "l_arm", "region" = "l_arm"), + CHROME_WHEEL_SOCKET_SKIN = list("start" = -47, "turn" = -1, "fits" = 2, "arc" = "skin", "region" = "torso"), +)) + +/// Positions a socket holds for one organ slot, filled or not, so the rest of +/// that socket never moves when the slot is filled or emptied. Socket => +/// list(slot, positions held). The OS owns the first chip of its arc. Chrome +/// optics own the first three of theirs (their own ability, the chrome read and +/// the zoom, cyberware_zoom.dm); the third starts the eyes' second row. GLOBAL_LIST_INIT(chrome_wheel_reserved, list( - CHROME_WHEEL_TORSO = list(ORGAN_SLOT_CYBERWARE_OS, 1), - CHROME_WHEEL_HEAD = list(ORGAN_SLOT_EYES, 3), + CHROME_WHEEL_SOCKET_OS = list(ORGAN_SLOT_CYBERWARE_OS, 1), + CHROME_WHEEL_SOCKET_EYES = list(ORGAN_SLOT_EYES, 3), )) // ---- Collection -------------------------------------------------------- @@ -101,15 +132,15 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( // ---- Sub-option hooks -------------------------------------------------- /** - * One entry on an ability's outer ring. [id] must stay the same between calls - * for the same choice: the wheel remembers a pick by id and asks the ability - * for its options again before firing it. + * One choice an ability offers. [id] must stay the same between calls for the + * same choice: the wheel remembers a pick by id and asks the ability for its + * options again before firing it. */ /datum/chrome_wheel_option /// Stable key for this choice within its ability. Non-empty text: a false /// id reads as "no option" and would fire the ability itself. var/id - /// Shown under the wheel while the slice is pointed at. + /// What the readout calls the pick. var/name var/icon var/icon_state @@ -121,9 +152,9 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( src.icon_state = icon_state /** - * Sub-options this ability shows on an outer ring of the chrome wheel while it - * is pointed at: a list of /datum/chrome_wheel_option, or null for none (the - * default, and every ability today). A cyberdeck's hacks belong here. + * Choices this ability offers on top of firing it plainly: a list of + * /datum/chrome_wheel_option, or null for none (the default, and every ability + * today). */ /datum/action/proc/chrome_wheel_options(mob/living/carbon/bearer) RETURN_TYPE(/list) @@ -144,33 +175,76 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( return option return null +// ---- Names ------------------------------------------------------------- + +/** + * What the wheel calls this ability: its own name, or for a ware's plain + * deploy or use button, the ware without its housing ("Mantis Blade", not + * "Toggle Mantis Blade housing (left)"). Override for a better one. + */ +/datum/action/proc/chrome_wheel_name() + var/obj/item/organ/ware = target + if(istype(ware) && (istype(src, /datum/action/item_action/organ_action/toggle) || istype(src, /datum/action/item_action/organ_action/use))) + var/static/regex/hardware = regex(@"(\s+(spool\s+)?housing|\s+drill fist)?(\s*\((left|right)\))?$", "i") + return hardware.Replace(format_text(ware.name), "") + return format_text(name) + +/// The name of the chrome an ability lives in, or "" for one that lives in nothing. +/proc/chrome_wheel_ware_name(datum/action/ability) + var/obj/item/organ/ware = ability?.target + return istype(ware) ? format_text(ware.name) : "" + +/// Display names, ability => text. +/proc/chrome_wheel_names(list/abilities) + RETURN_TYPE(/list) + . = list() + for(var/datum/action/ability as anything in abilities) + .[ability] = ability.chrome_wheel_name() + // ---- Layout ------------------------------------------------------------ -/// Which wheel region an organ's abilities sit in, from the zone it lives in. -/proc/chrome_wheel_region(obj/item/organ/ware) +/// Which socket an organ's abilities sit in, from its slot and the zone it lives in. +/proc/chrome_wheel_socket(obj/item/organ/ware) if(!istype(ware)) - return CHROME_WHEEL_TORSO + return CHROME_WHEEL_SOCKET_OS + if(ware.slot == ORGAN_SLOT_EYES || ware.zone == BODY_ZONE_PRECISE_EYES) + return CHROME_WHEEL_SOCKET_EYES + if(ware.slot == ORGAN_SLOT_CYBERWARE_OS) + return CHROME_WHEEL_SOCKET_OS + if(ware.slot == ORGAN_SLOT_CYBERWARE_SKIN) + return CHROME_WHEEL_SOCKET_SKIN switch(ware.zone) - if(BODY_ZONE_HEAD, BODY_ZONE_PRECISE_EYES, BODY_ZONE_PRECISE_MOUTH) - return CHROME_WHEEL_HEAD + if(BODY_ZONE_HEAD, BODY_ZONE_PRECISE_MOUTH) + return CHROME_WHEEL_SOCKET_HEAD if(BODY_ZONE_R_ARM, BODY_ZONE_PRECISE_R_HAND) - return CHROME_WHEEL_RIGHT_ARM + return CHROME_WHEEL_SOCKET_RIGHT_ARM if(BODY_ZONE_L_ARM, BODY_ZONE_PRECISE_L_HAND) - return CHROME_WHEEL_LEFT_ARM + return CHROME_WHEEL_SOCKET_LEFT_ARM if(BODY_ZONE_R_LEG, BODY_ZONE_L_LEG, BODY_ZONE_PRECISE_R_FOOT, BODY_ZONE_PRECISE_L_FOOT) - return CHROME_WHEEL_LEGS - return CHROME_WHEEL_TORSO + return CHROME_WHEEL_SOCKET_LEGS + // The chest and anything else: the OS's arc, after the OS. + return CHROME_WHEEL_SOCKET_OS -/// Where an organ slot ranks inside its region. Unlisted slots rank last. +/// Hand chrome rides its arm's socket, after the arm's own chrome, with its +/// own leader to the hand. +/proc/chrome_wheel_is_hand(obj/item/organ/ware) + if(!istype(ware)) + return FALSE + return ware.slot == ORGAN_SLOT_CYBERWARE_HANDS || ware.zone == BODY_ZONE_PRECISE_L_HAND || ware.zone == BODY_ZONE_PRECISE_R_HAND + +/// Where an organ slot ranks inside its socket. Unlisted slots rank last. /proc/chrome_wheel_slot_rank(obj/item/organ/ware) var/rank = istype(ware) ? GLOB.chrome_wheel_slot_order.Find(ware.slot) : 0 return rank || length(GLOB.chrome_wheel_slot_order) + 1 -/// Order inside a region: ranked organ slots, then other slots by name, then -/// each organ's own button order. +/// Order inside a socket: hand chrome last, then ranked organ slots, other +/// slots by name, and each organ's own button order. /proc/cmp_chrome_wheel_abilities(datum/action/first, datum/action/second) var/obj/item/organ/first_ware = first.target var/obj/item/organ/second_ware = second.target + var/hand_gap = chrome_wheel_is_hand(first_ware) - chrome_wheel_is_hand(second_ware) + if(hand_gap) + return hand_gap var/rank_gap = chrome_wheel_slot_rank(first_ware) - chrome_wheel_slot_rank(second_ware) if(rank_gap) return rank_gap @@ -180,59 +254,43 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( return sorttext(second_slot, first_slot) || sorttext(second.name, first.name) return LAZYFIND(first_ware.actions, first) - LAZYFIND(second_ware.actions, second) +/// How many chips the socket's arc holds before another row starts. +/proc/chrome_wheel_socket_fits(socket) + var/list/spec = GLOB.chrome_wheel_sockets[socket] + return spec ? spec["fits"] : 1 + /** - * Pixel offset from the wheel centre of position [index] in [region], as - * list(x, y). The four limb regions own a 90 degree arc of the outer ring - * each: position 1 on the compass point, 2 and 3 thirty degrees either side - * (mirrored so both arms' second slice hangs low), 4 and on a row further out. - * The torso gets the inner ring, starting at the top. + * Pixel offset from the centre of the view of position [index] in [socket], + * as list(x, y). Positions run along the socket's arc CHROME_WHEEL_STEP + * degrees apart; once the arc is full they start again a row further out. */ -/proc/chrome_wheel_position(region, index) +/proc/chrome_wheel_position(socket, index) RETURN_TYPE(/list) - var/angle = 0 - var/radius = 0 - if(region == CHROME_WHEEL_TORSO) - var/static/list/torso_angles = list(0, 60, 300, 120, 240, 180) - angle = torso_angles[((index - 1) % length(torso_angles)) + 1] - // Six torso abilities is more than any body can carry today; extras - // stack outward rather than reflowing anything. - var/row = round((index - 1) / length(torso_angles)) - radius = row ? CHROME_WHEEL_OUTER_RADIUS + row * CHROME_WHEEL_ROW_GAP : CHROME_WHEEL_INNER_RADIUS - else - var/base_angle = 0 - var/turn = 30 - switch(region) - if(CHROME_WHEEL_RIGHT_ARM) - base_angle = 90 - if(CHROME_WHEEL_LEGS) - base_angle = 180 - turn = -30 - if(CHROME_WHEEL_LEFT_ARM) - base_angle = 270 - turn = -30 - var/static/list/steps = list(0, 1, -1) - angle = base_angle + turn * steps[((index - 1) % length(steps)) + 1] - radius = CHROME_WHEEL_OUTER_RADIUS + round((index - 1) / length(steps)) * CHROME_WHEEL_ROW_GAP - return list(round(sin(angle) * radius, 1), round(cos(angle) * radius, 1)) + var/list/spec = GLOB.chrome_wheel_sockets[socket] + var/fits = spec["fits"] + var/row = round((index - 1) / fits) + var/angle = spec["start"] + spec["turn"] * ((index - 1) % fits) * CHROME_WHEEL_STEP + var/radius = CHROME_WHEEL_RADIUS + row * CHROME_WHEEL_ROW_GAP + return list(round(sin(angle) * radius, 1), round(cos(angle) * radius + CHROME_WHEEL_CENTRE_Y, 1)) /** - * Lays the abilities out by region: an associative list of ability => - * list(x, y). Positions depend only on each ability's own region, slot and - * button order, never on what the rest of the body carries. + * Sorts the abilities into sockets: ability => list("socket", "index", + * "hand"). Where an ability lands depends only on its own socket's chrome, + * never on what the rest of the body carries. */ -/proc/chrome_wheel_layout(list/abilities) +/proc/chrome_wheel_placements(list/abilities) RETURN_TYPE(/list) - var/list/by_region = list() + var/list/by_socket = list() for(var/datum/action/ability as anything in abilities) - var/region = chrome_wheel_region(ability.target) - if(!by_region[region]) - by_region[region] = list() - var/list/members = by_region[region] + var/socket = chrome_wheel_socket(ability.target) + if(!by_socket[socket]) + by_socket[socket] = list() + var/list/members = by_socket[socket] members += ability - var/list/positions = list() - for(var/region in by_region) - var/list/members = sortTim(by_region[region], GLOBAL_PROC_REF(cmp_chrome_wheel_abilities)) - var/list/reserved = GLOB.chrome_wheel_reserved[region] + var/list/placements = list() + for(var/socket in by_socket) + var/list/members = sortTim(by_socket[socket], GLOBAL_PROC_REF(cmp_chrome_wheel_abilities)) + var/list/reserved = GLOB.chrome_wheel_reserved[socket] var/reserved_slot = length(reserved) ? reserved[1] : null var/reserved_count = length(reserved) ? reserved[2] : 0 var/next_reserved = 1 @@ -244,50 +302,37 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( index = next_reserved++ else index = next_free++ - positions[ability] = chrome_wheel_position(region, index) - return positions + placements[ability] = list("socket" = socket, "index" = index, "hand" = chrome_wheel_is_hand(ware)) + return placements -/// Positions of [count] sub-option slices on a ring outside [ring_radius], -/// centred on the direction of their parent slice at (parent_x, parent_y). -/proc/chrome_wheel_sub_positions(parent_x, parent_y, count, ring_radius) +/// Where each ability's chip sits: ability => list(x, y), from the centre of the view. +/proc/chrome_wheel_layout(list/abilities) RETURN_TYPE(/list) + var/list/placements = chrome_wheel_placements(abilities) . = list() - var/angle = delta_to_angle(parent_x, parent_y) - var/radius = ring_radius + CHROME_WHEEL_ROW_GAP - var/step = TODEGREES(CHROME_WHEEL_SLICE_SPACING / radius) - for(var/i in 1 to count) - // 0, +1, -1, +2, -2 steps: the first option sits straight out from its parent. - var/slice_angle = angle + round(i / 2) * step * ((i % 2) ? -1 : 1) - . += list(list(round(sin(slice_angle) * radius, 1), round(cos(slice_angle) * radius, 1))) - -/// Display names, with the body side added where two slices would read the -/// same (a pair of Mantis housings gives two Mantis Lunges). -/proc/chrome_wheel_names(list/abilities) - RETURN_TYPE(/list) - var/list/names = list() - var/list/counts = list() - for(var/datum/action/ability as anything in abilities) - var/name = format_text(ability.name) - names[ability] = name - counts[name] += 1 - for(var/datum/action/ability as anything in abilities) - var/obj/item/organ/ware = ability.target - if(counts[names[ability]] > 1 && istype(ware)) - names[ability] = "[names[ability]] ([parse_zone(ware.zone)])" - return names + for(var/datum/action/ability as anything in placements) + var/list/placement = placements[ability] + .[ability] = chrome_wheel_position(placement["socket"], placement["index"]) // ---- Pointing ---------------------------------------------------------- +/// Bearing of an offset from the centre of the view, as seen from the ring's +/// centre: degrees clockwise from twelve o'clock. +/proc/chrome_wheel_bearing(x, y) + return delta_to_angle(x, y - CHROME_WHEEL_CENTRE_Y) + /** - * Which of [points] (a list of list(x, y) offsets) the mouse at offset - * (dx, dy) means, as an index, or 0 for none. A slice within reach of the - * mouse is the one under it; past the slices, the mouse points at the slice - * in its direction, so a quick flick outward works without landing on it. - * Empty regions point at nothing. + * Which of [points] (a list of list(x, y) chip centres) the mouse at offset + * (dx, dy) means, as an index, or 0 for none. Inside the body nothing is + * pointed at. A chip right under the mouse is the one meant; anywhere else, + * the mouse points at the chip whose bearing from the ring's centre is + * nearest its own, so a quick flick outward works without landing on it. + * [on_ring], if given, says which points a bearing can pick (the first row); + * the rest only answer the mouse right on them. */ -/proc/chrome_wheel_pick(dx, dy, list/points) - var/distance = sqrt(dx ** 2 + dy ** 2) - if(distance < CHROME_WHEEL_DEADZONE) +/proc/chrome_wheel_pick(dx, dy, list/points, list/on_ring) + var/ring_y = dy - CHROME_WHEEL_CENTRE_Y + if(sqrt(dx ** 2 + ring_y ** 2) < CHROME_WHEEL_DEADZONE) return 0 var/nearest = 0 var/nearest_gap = INFINITY @@ -299,24 +344,18 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( nearest_gap = gap if(nearest_gap <= CHROME_WHEEL_PICK_RANGE) return nearest - var/mouse_angle = delta_to_angle(dx, dy) + var/mouse_bearing = delta_to_angle(dx, ring_y) var/pointed = 0 - var/pointed_turn = 0 - var/pointed_radius = 0 + var/pointed_turn = INFINITY for(var/i in 1 to length(points)) - var/list/point = points[i] - var/radius = sqrt(point[1] ** 2 + point[2] ** 2) - if(radius >= distance) + if(on_ring && !on_ring[i]) continue - var/turn = abs(WRAP(mouse_angle - delta_to_angle(point[1], point[2]) + 180, 0, 360) - 180) - if(turn > CHROME_WHEEL_POINT_TOLERANCE) - continue - // Ties on direction go to the slice further out: further means deeper. - if(!pointed || turn < pointed_turn - 2 || (turn <= pointed_turn + 2 && radius > pointed_radius)) + var/list/point = points[i] + var/turn = abs(WRAP(mouse_bearing - chrome_wheel_bearing(point[1], point[2]) + 180, 0, 360) - 180) + if(turn < pointed_turn) pointed = i pointed_turn = turn - pointed_radius = radius - return pointed + return pointed_turn <= CHROME_WHEEL_POINT_TOLERANCE ? pointed : 0 /// The mouse position in a screen mouse params string, as pixels from the /// centre of the viewer's map: list(x, y), or null if the params have none. @@ -345,8 +384,7 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( // ---- Looks ------------------------------------------------------------- -/// A radial.dmi frame for a slice or button, shared: "module_selected" (cyan, -/// what Use chrome fires) or "module_active" (green, deployed or armed). +/// The radial.dmi "module_selected" frame the picked ability's buttons wear, shared. /proc/chrome_wheel_marker(frame_state) var/static/list/markers = list() if(!markers[frame_state]) @@ -354,10 +392,10 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( return markers[frame_state] /** - * What an ability's slice shows: a copy of the bearer's own button for it, - * minus the button background, so the slice carries the same icon, item - * overlay and cooldown seconds the button does. Built from the action's vars - * when the bearer has no button for it. + * What an ability's chip shows: a copy of the bearer's own button for it, + * minus the button background and text, so the chip carries the same icon + * and item overlay the button does. Built from the action's vars when the + * bearer has no button for it. */ /proc/chrome_wheel_ability_look(datum/action/ability, mob/viewer) RETURN_TYPE(/mutable_appearance) @@ -381,19 +419,14 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( look.pixel_w = 0 look.pixel_z = 0 look.screen_loc = null + look.maptext = null look.color = null look.alpha = 255 look.transform = matrix() - // A copied button keeps APPEARANCE_UI; let the icon grow with its slice when pointed at. - look.appearance_flags &= ~RESET_TRANSFORM + // Keeps its own colour, follows the chip's pop, and shrinks smoothly. + look.appearance_flags = RESET_COLOR | NO_CLIENT_COLOR return look -/proc/chrome_wheel_option_look(datum/chrome_wheel_option/option) - RETURN_TYPE(/mutable_appearance) - if(option?.icon && option.icon_state) - return mutable_appearance(option.icon, option.icon_state) - return mutable_appearance('icons/hud/actions.dmi', "default") - /// Whether an ability is "on" right now: a toggled arm with its hardware out, /// or a click-to-target ability armed and waiting for the click. /proc/chrome_wheel_ability_active(datum/action/ability, mob/living/carbon/bearer) @@ -404,6 +437,46 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( return arm.active_item && !(arm.active_item in arm) return FALSE +/// Deciseconds left on an ability's cooldown, 0 for one that has none. +/proc/chrome_wheel_cooldown_left(datum/action/ability) + var/datum/action/cooldown/timed = ability + return istype(timed) ? max(timed.next_use_time - world.time, 0) : 0 + +/// Whether the chrome an ability lives in is down: rebooting from an EMP, or +/// browned out over capacity. +/proc/chrome_wheel_offline(datum/action/ability) + var/obj/item/organ/ware = ability.target + var/datum/component/cyberware/chrome = istype(ware) ? ware.GetComponent(/datum/component/cyberware) : null + return chrome && !chrome.is_running() + +/// Whether an ability can't be used right now for any other reason than its +/// chrome being down or its cooldown: its button's own checks say no. +/proc/chrome_wheel_unavailable(datum/action/ability, cooldown_left) + // On cooldown IsAvailable() is FALSE anyway; the shade shows that instead. + return !cooldown_left && !ability.IsAvailable() + +/// The tier colour of the chrome an ability lives in. +/proc/chrome_wheel_tier_color(datum/action/ability) + var/obj/item/organ/ware = ability.target + var/datum/component/cyberware/chrome = istype(ware) ? ware.GetComponent(/datum/component/cyberware) : null + return chrome ? cyberware_tier_color(chrome.tier) : CHROME_WHEEL_HUD_COLOR + +/// [color] taken [amount] of the way to white. +/proc/chrome_wheel_lighten(color, amount) + var/list/channels = rgb2num(color) + return rgb(round(channels[1] + (255 - channels[1]) * amount), round(channels[2] + (255 - channels[2]) * amount), round(channels[3] + (255 - channels[3]) * amount)) + +/// One of the chip art states as an overlay that keeps its own colour. +/proc/chrome_wheel_chip_piece(piece_state, piece_color, piece_alpha = 255) + var/mutable_appearance/piece = mutable_appearance(CHROME_WHEEL_ICON, piece_state, FLOAT_LAYER, appearance_flags = RESET_COLOR | NO_CLIENT_COLOR | PIXEL_SCALE) + piece.color = piece_color + piece.alpha = piece_alpha + return piece + +/// Sorts chips clockwise from twelve o'clock, the order they pop in. +/proc/cmp_chrome_wheel_bearing(atom/movable/screen/chrome_wheel/chip/first, atom/movable/screen/chrome_wheel/chip/second) + return chrome_wheel_bearing(first.chip_x, first.chip_y) - chrome_wheel_bearing(second.chip_x, second.chip_y) + // ---- The wheel --------------------------------------------------------- /datum/component/chrome_wheel @@ -422,30 +495,39 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( var/client/shown_to /// Screen-centre object every visible part of the wheel hangs off. var/atom/movable/screen/chrome_wheel/hub/hub - /// Full-view mouse tracker under the slices. + /// Full-view mouse tracker under the wheel. var/atom/movable/screen/fullscreen/cursor_catcher/chrome_wheel/catcher - /// Screen objects the open wheel made, except sub-option slices, for teardown. + /// Every screen object the open wheel made, for teardown. var/list/parts - /// Ability slices that can be pointed at, one per ability. - var/list/main_slices - /// Sub-option slices on show, all belonging to [sub_parent]. - var/list/sub_slices - /// The ability slice whose sub-option ring is open. - var/atom/movable/screen/chrome_wheel/slice/sub_parent - /// The slice the mouse is on. - var/atom/movable/screen/chrome_wheel/slice/highlighted - /// Shows what Use chrome fires. - var/atom/movable/screen/chrome_wheel/slice/centre - /// Name of the slice the mouse is on. - var/atom/movable/screen/chrome_wheel/label/label - /// Furthest ability slice from the centre; the sub-option ring goes outside it. - var/outer_radius = 0 + /// One chip per ability. + var/list/chips + /// Leader key => list("leader", "node", "node_state", "alpha"): the line + /// from a socket (or a hand chip) to the body, and its dot on the body. + var/list/leaders + /// Arc name => its piece of ring art. + var/list/arcs + /// The body part lit under the chip pointed at. + var/atom/movable/screen/chrome_wheel/art/glow + /// The chip the mouse is on. + var/atom/movable/screen/chrome_wheel/chip/highlighted + /// Readout lines, and the pick mark it shows when nothing is pointed at. + var/atom/movable/screen/chrome_wheel/readout/readout_title + var/atom/movable/screen/chrome_wheel/readout/readout_sub + var/atom/movable/screen/chrome_wheel/readout/readout_status + var/atom/movable/screen/chrome_wheel/readout/readout_mark + /// What the readout last showed. + var/readout_state /// First mouse position reported after opening. Nothing is pointed at until /// the mouse leaves it, so a cursor that was resting somewhere when the /// key went down picks nothing by accident. var/list/mouse_start /// Whether the mouse has left [mouse_start]. var/mouse_moved = FALSE + /// A closed wheel still fading out: its client, its pieces and the timer + /// that takes them away. + var/client/fading_client + var/list/fading_parts + var/fading_timer /datum/component/chrome_wheel/Initialize() if(!iscarbon(parent)) @@ -460,6 +542,7 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( /datum/component/chrome_wheel/Destroy(force) close_wheel() + clear_fading() unmark() selected_ref = null return ..() @@ -467,6 +550,7 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( /datum/component/chrome_wheel/proc/on_logout(datum/source) SIGNAL_HANDLER close_wheel() + clear_fading() /datum/component/chrome_wheel/proc/on_stat_change(datum/source, new_stat, old_stat) SIGNAL_HANDLER @@ -522,7 +606,7 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( /datum/component/chrome_wheel/proc/use_selected() if(wheel_mode != CHROME_WHEEL_CLOSED) if(highlighted) - select_slice(highlighted, quiet = TRUE) + select_chip(highlighted, quiet = TRUE) close_wheel() var/datum/action/ability = get_selected_ability() if(ability) @@ -571,8 +655,9 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( selected_ref = WEAKREF(ability) selected_option = option_id mark(ability) - if(wheel_mode != CHROME_WHEEL_CLOSED) - draw_centre() + for(var/atom/movable/screen/chrome_wheel/chip/chip as anything in chips) + draw_chip(chip) + update_readout() /datum/component/chrome_wheel/proc/deselect() selected_ref = null @@ -632,20 +717,21 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( if(old_owner == parent) deselect() -/datum/component/chrome_wheel/proc/select_slice(atom/movable/screen/chrome_wheel/slice/slice, quiet = FALSE) - var/datum/action/ability = slice?.ability_ref?.resolve() +/datum/component/chrome_wheel/proc/select_chip(atom/movable/screen/chrome_wheel/chip/chip, quiet = FALSE) + var/datum/action/ability = chip?.ability_ref?.resolve() if(!ability) return - select(ability, slice.option_id) + select(ability) if(!quiet) var/mob/living/carbon/bearer = parent - bearer.balloon_alert(bearer, lowertext(slice.name)) + bearer.balloon_alert(bearer, lowertext(chip.name)) -/// Pick [slice] (or nothing, for null) and close the wheel. -/datum/component/chrome_wheel/proc/choose(atom/movable/screen/chrome_wheel/slice/slice) - if(slice) - select_slice(slice) - close_wheel() +/// Pick [chip] (or nothing, for null) and close the wheel. The pick lands at +/// once; the wheel stays up a moment longer for the lock and the fade. +/datum/component/chrome_wheel/proc/choose(atom/movable/screen/chrome_wheel/chip/chip) + if(chip) + select_chip(chip) + close_wheel(linger = TRUE, chosen = chip?.ability_ref?.resolve() ? chip : null) // ---- Opening and closing ----------------------------------------------- @@ -658,38 +744,8 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( if(!length(abilities)) bearer.balloon_alert(bearer, "no chrome!") return FALSE - var/list/positions = chrome_wheel_layout(abilities) - var/list/names = chrome_wheel_names(abilities) - parts = list() - main_slices = list() - sub_slices = list() - outer_radius = 0 - hub = new - parts += hub - var/list/taken = list() - for(var/datum/action/ability as anything in abilities) - var/list/point = positions[ability] - var/atom/movable/screen/chrome_wheel/slice/slice = new_slice(point[1], point[2]) - slice.ability_ref = WEAKREF(ability) - slice.name = names[ability] - main_slices += slice - taken["[point[1]],[point[2]]"] = TRUE - outer_radius = max(outer_radius, sqrt(point[1] ** 2 + point[2] ** 2)) - draw_slice(slice) - // Regions with nothing on their first position keep a dim slice there, so - // the wheel always has the same shape. - for(var/region in list(CHROME_WHEEL_HEAD, CHROME_WHEEL_RIGHT_ARM, CHROME_WHEEL_LEGS, CHROME_WHEEL_LEFT_ARM, CHROME_WHEEL_TORSO)) - var/list/point = chrome_wheel_position(region, 1) - if(taken["[point[1]],[point[2]]"]) - continue - var/atom/movable/screen/chrome_wheel/slice/placeholder = new_slice(point[1], point[2]) - placeholder.alpha = 70 - centre = new_slice(0, 0) - draw_centre() - label = new - label.pixel_y = -(CHROME_WHEEL_OUTER_RADIUS + 34) - parts += label - hub.vis_contents += label + clear_fading() + build_wheel(abilities) catcher = new catcher.wheel = src @@ -698,8 +754,7 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( shown_to = viewer viewer.screen += catcher viewer.screen += hub - hub.alpha = 0 - animate(hub, alpha = 255, time = 0.1 SECONDS) + play_open() wheel_mode = mode mouse_start = null @@ -707,38 +762,217 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( START_PROCESSING(SSfastprocess, src) return TRUE -/datum/component/chrome_wheel/proc/new_slice(x, y) - var/atom/movable/screen/chrome_wheel/slice/slice = new - slice.pixel_x = x - slice.pixel_y = y - slice.slice_x = x - slice.slice_y = y - parts += slice - hub.vis_contents += slice - return slice - -/datum/component/chrome_wheel/proc/close_wheel() +/** + * Makes every piece of the wheel for [abilities] and hangs it off a new hub, + * without putting anything on a screen. + */ +/datum/component/chrome_wheel/proc/build_wheel(list/abilities) + var/list/placements = chrome_wheel_placements(abilities) + var/list/names = chrome_wheel_names(abilities) + parts = list() + chips = list() + leaders = list() + arcs = list() + readout_state = null + hub = new + parts += hub + add_piece(/atom/movable/screen/chrome_wheel/art/backdrop) + add_piece(/atom/movable/screen/chrome_wheel/art, "body") + glow = add_piece(/atom/movable/screen/chrome_wheel/art, null, 2.2) + glow.alpha = 0 + var/list/arc_names = list() + for(var/socket in GLOB.chrome_wheel_sockets) + arc_names |= GLOB.chrome_wheel_sockets[socket]["arc"] + for(var/arc_name in arc_names) + arcs[arc_name] = add_piece(/atom/movable/screen/chrome_wheel/art, "arc_[arc_name]", 2.25) + + // Which sockets hold chrome of their own, and which positions are taken. + var/list/occupied = list() + var/list/taken = list() + for(var/datum/action/ability as anything in placements) + var/list/placement = placements[ability] + taken["[placement["socket"]]|[placement["index"]]"] = TRUE + if(!placement["hand"]) + occupied[placement["socket"]] = TRUE + for(var/socket in GLOB.chrome_wheel_sockets) + add_leader(socket, socket, occupied[socket]) + if(!taken["[socket]|1"]) + var/list/point = chrome_wheel_position(socket, 1) + var/atom/movable/screen/chrome_wheel/empty_socket/outline = add_piece(/atom/movable/screen/chrome_wheel/empty_socket) + outline.pixel_x = point[1] + outline.pixel_y = point[2] + + for(var/datum/action/ability as anything in abilities) + var/list/placement = placements[ability] + var/socket = placement["socket"] + var/list/point = chrome_wheel_position(socket, placement["index"]) + var/atom/movable/screen/chrome_wheel/chip/chip = add_piece(/atom/movable/screen/chrome_wheel/chip) + chip.ability_ref = WEAKREF(ability) + chip.name = names[ability] + chip.socket = socket + chip.index = placement["index"] + chip.leader_key = socket + chip.region = GLOB.chrome_wheel_sockets[socket]["region"] + if(placement["hand"]) + var/hand = (socket == CHROME_WHEEL_SOCKET_LEFT_ARM) ? "l_hand" : "r_hand" + chip.region = hand + // The ring art has a run to the hand from each first-row position. + if(chip.index <= chrome_wheel_socket_fits(socket)) + chip.leader_key = "[hand]_[chip.index]" + add_leader(chip.leader_key, hand, TRUE) + chip.chip_x = point[1] + chip.chip_y = point[2] + chip.pixel_x = point[1] + chip.pixel_y = point[2] + chips += chip + draw_chip(chip) + build_readout() + update_readout() + +/// A new piece of the wheel on the hub. [art_state] sets its icon state and +/// [piece_layer] its layer, when given. +/datum/component/chrome_wheel/proc/add_piece(piece_type, art_state, piece_layer) + var/atom/movable/screen/chrome_wheel/piece = new piece_type + if(art_state) + piece.icon_state = art_state + if(piece_layer) + piece.layer = piece_layer + parts += piece + hub.vis_contents += piece + return piece + +/// The leader for [key] and the dot where it meets the body, from the ring art. +/datum/component/chrome_wheel/proc/add_leader(key, art_key, filled) + if(leaders[key]) + return + var/atom/movable/screen/chrome_wheel/art/line = add_piece(/atom/movable/screen/chrome_wheel/art, "leader_[key]", 2.3) + var/atom/movable/screen/chrome_wheel/art/dot = add_piece(/atom/movable/screen/chrome_wheel/art, "node_[art_key]", 2.4) + leaders[key] = list("leader" = line, "node" = dot, "node_state" = "node_[art_key]", "alpha" = filled ? 100 : 40) + style_leader(key, null) + +/// The readout panel's frame and its empty text lines. +/datum/component/chrome_wheel/proc/build_readout() + var/left = CHROME_WHEEL_READOUT_LEFT + var/top = CHROME_WHEEL_READOUT_TOP + var/width = CHROME_WHEEL_READOUT_WIDTH + var/height = CHROME_WHEEL_READOUT_HEIGHT + add_readout_block(left, top - height, width, height, CHROME_WHEEL_PANEL_COLOR, 205, 4) + add_readout_block(left, top - 1, width, 1, CHROME_WHEEL_HUD_COLOR, 120, 4.1) + add_readout_block(left, top - height, width, 1, CHROME_WHEEL_HUD_COLOR, 120, 4.1) + add_readout_block(left, top - height, 1, height, CHROME_WHEEL_HUD_COLOR, 120, 4.1) + add_readout_block(left + width - 1, top - height, 1, height, CHROME_WHEEL_HUD_COLOR, 120, 4.1) + add_readout_block(left, top - 12, 2, 12, CHROME_WHEEL_HUD_COLOR, 255, 4.1) + // Text boxes are taller than their line and centred on it, as the chrome + // read sets them: Grand9K caps start 5 px under a 16 px box's top, + // TinyUnicode caps 8 px under a 17 px one. + readout_title = add_readout_text(left + 5, top - 11 - 2, width - 9, 16) + readout_sub = add_readout_text(left + 5, top - 9 - 13, width - 9, 17) + readout_status = add_readout_text(left + 5, top - 11 - 3, width - 10, 16) + readout_mark = add_piece(/atom/movable/screen/chrome_wheel/readout, "pick_glyph", 4.3) + readout_mark.color = CHROME_WHEEL_PICKED_COLOR + readout_mark.alpha = 0 + readout_mark.pixel_x = left + 140 + readout_mark.pixel_y = top - 22 + +/datum/component/chrome_wheel/proc/add_readout_block(x, y, width, height, block_color, block_alpha, block_layer) + var/atom/movable/screen/chrome_wheel/readout/block = add_piece(/atom/movable/screen/chrome_wheel/readout, null, block_layer) + block.color = block_color + block.alpha = block_alpha + block.transform = chrome_readout_quad(x, y, width, height) + return block + +/datum/component/chrome_wheel/proc/add_readout_text(x, y, width, height) + var/atom/movable/screen/chrome_wheel/readout/line = add_piece(/atom/movable/screen/chrome_wheel/readout, null, 4.2) + line.icon_state = null + line.maptext_x = x + line.maptext_y = y + line.maptext_width = width + line.maptext_height = height + return line + +/// Fades the wheel in and pops the chips out one after another, clockwise from the top. +/datum/component/chrome_wheel/proc/play_open() + hub.alpha = 0 + animate(hub, alpha = 255, time = 1) + var/list/order = sortTim(chips.Copy(), GLOBAL_PROC_REF(cmp_chrome_wheel_bearing)) + for(var/i in 1 to length(order)) + var/atom/movable/screen/chrome_wheel/chip/chip = order[i] + var/matrix/small = matrix(0.6, 0, 0, 0, 0.6, 0) + chip.transform = small + animate(chip, transform = small, time = (i - 1) * 0.08) + animate(transform = matrix(), time = 0.8, easing = BACK_EASING | EASE_OUT) + +/** + * Shuts the wheel and gives the mouse back to the world. With [linger] the + * pieces stay on screen a moment to fade, after [chosen] locks. + */ +/datum/component/chrome_wheel/proc/close_wheel(linger = FALSE, atom/movable/screen/chrome_wheel/chip/chosen) STOP_PROCESSING(SSfastprocess, src) wheel_mode = CHROME_WHEEL_CLOSED if(shown_to) shown_to.screen -= catcher - shown_to.screen -= hub - shown_to = null - hide_sub_ring() - highlighted = null - hub?.vis_contents.Cut() - QDEL_LIST(parts) QDEL_NULL(catcher) + if(linger && shown_to && hub) + clear_fading() + fading_client = shown_to + fading_parts = parts + if(chosen) + play_lock(chosen) + animate(hub, alpha = hub.alpha, time = chosen ? 1 : 0) + animate(alpha = 0, time = 1) + fading_timer = addtimer(CALLBACK(src, PROC_REF(fade_done)), CHROME_WHEEL_LINGER_TIME, TIMER_STOPPABLE | TIMER_CLIENT_TIME | TIMER_DELETE_ME) + else + shown_to?.screen -= hub + QDEL_LIST(parts) + shown_to = null hub = null - centre = null - label = null parts = null - main_slices = null - sub_slices = null + chips = null + leaders = null + arcs = null + glow = null + highlighted = null + readout_title = null + readout_sub = null + readout_status = null + readout_mark = null + readout_state = null mouse_start = null mouse_moved = FALSE -/// While open: catch a modifier bind's release, and keep the slices current. +/datum/component/chrome_wheel/proc/fade_done() + fading_timer = null + clear_fading() + +/// Takes a fading wheel off its screen now. +/datum/component/chrome_wheel/proc/clear_fading() + if(fading_timer) + deltimer(fading_timer) + fading_timer = null + if(fading_client && length(fading_parts)) + fading_client.screen -= fading_parts[1] + fading_client = null + QDEL_LIST(fading_parts) + +/// The pick locking on: its frame flashes white and the brackets snap in. +/datum/component/chrome_wheel/proc/play_lock(atom/movable/screen/chrome_wheel/chip/chip) + var/settled = chip.color + chip.color = "#ffffff" + animate(chip, color = "#ffffff", time = 0.5) + animate(color = settled, time = 0) + for(var/atom/movable/screen/chrome_wheel/bracket/corner as anything in chip.brackets) + if(!corner.alpha) + continue + var/final_x = corner.pixel_x + var/final_y = corner.pixel_y + corner.pixel_x = final_x + corner.out_x * 7 + corner.pixel_y = final_y + corner.out_y * 7 + corner.color = "#ffffff" + animate(corner, pixel_x = final_x, pixel_y = final_y, time = 1, easing = BACK_EASING | EASE_OUT) + animate(corner, color = "#ffffff", time = 0.5, flags = ANIMATION_PARALLEL) + animate(color = CHROME_WHEEL_PICKED_COLOR, time = 0) + +/// While open: catch a modifier bind's release, and keep the chips current. /datum/component/chrome_wheel/process(seconds_per_tick) if(wheel_mode == CHROME_WHEEL_CLOSED) return PROCESS_KILL @@ -746,96 +980,262 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( wheel_key_up() if(wheel_mode == CHROME_WHEEL_CLOSED) return PROCESS_KILL - for(var/atom/movable/screen/chrome_wheel/slice/slice in main_slices + sub_slices) - draw_slice(slice) + for(var/atom/movable/screen/chrome_wheel/chip/chip as anything in chips) + draw_chip(chip) + update_readout() // ---- Drawing ----------------------------------------------------------- /** - * Brings a slice up to date: the ability's look, red when it cannot be used - * (the button's own tint), a draining shade while it cools down, and a frame - * when it is picked (cyan) or deployed/armed (green). Redraws only on change. + * Brings a chip up to date: its frame in its tier colour (blinking red while + * the chrome is down), the ability's icon, the green inner line while it is + * deployed or armed, the pick's brackets, and a draining shade with seconds + * while it cools down. Bigger and brighter while pointed at. Redraws only on + * change. */ -/datum/component/chrome_wheel/proc/draw_slice(atom/movable/screen/chrome_wheel/slice/slice) +/datum/component/chrome_wheel/proc/draw_chip(atom/movable/screen/chrome_wheel/chip/chip) var/mob/living/carbon/bearer = parent - var/datum/action/ability = slice.ability_ref?.resolve() + var/datum/action/ability = chip.ability_ref?.resolve() if(!ability) - if(slice.drawn_state) - slice.cut_overlays() - slice.drawn_state = null - slice.alpha = 70 + if(chip.drawn_state != "gone") + chip.drawn_state = "gone" + chip.cut_overlays() + chip.underlays.Cut() + chip.alpha = 70 return var/datum/action/cooldown/timed = istype(ability, /datum/action/cooldown) ? ability : null var/cooldown_left = timed ? max(timed.next_use_time - world.time, 0) : 0 - var/obj/item/organ/ware = ability.target - var/datum/component/cyberware/chrome = istype(ware) ? ware.GetComponent(/datum/component/cyberware) : null - var/offline = chrome ? !chrome.is_running() : FALSE - // On cooldown IsAvailable() is FALSE anyway; the shade shows that instead. - if(!offline && !cooldown_left && !ability.IsAvailable()) - offline = TRUE - var/frame - if(selected_ref?.resolve() == ability && (!slice.option_id || slice.option_id == selected_option)) - frame = "module_selected" - else if(!slice.option_id && chrome_wheel_ability_active(ability, bearer)) - frame = "module_active" + var/offline = chrome_wheel_offline(ability) + var/unavailable = !offline && chrome_wheel_unavailable(ability, cooldown_left) + var/hovered = (chip == highlighted) + var/picked = (selected_ref?.resolve() == ability) + var/active = chrome_wheel_ability_active(ability, bearer) var/atom/movable/screen/movable/action_button/button = bearer.hud_used ? ability.viewers[bearer.hud_used] : null - var/state = "[offline]|[frame]|[timed?.next_use_time]|[!!cooldown_left]|[button?.maptext]|[button?.icon_state]" - if(state == slice.drawn_state) - return - slice.drawn_state = state - slice.cut_overlays() - if(frame) - slice.add_overlay(chrome_wheel_marker(frame)) - var/mutable_appearance/look = slice.option_look ? new /mutable_appearance(slice.option_look) : chrome_wheel_ability_look(ability, bearer) - if(offline) + var/state = "[offline]|[unavailable]|[hovered]|[picked]|[active]|[timed?.next_use_time]|[!!cooldown_left]|[button?.icon_state]|[ability.button_icon_state]" + if(state != chip.drawn_state) + chip.drawn_state = state + paint_chip(chip, ability, offline, unavailable, hovered, picked, active) + update_shade(chip, cooldown_left, timed ? max(timed.cooldown_time, cooldown_left) : 0) + if(hovered) + light_up(chip) + update_count(chip, cooldown_left) + +/datum/component/chrome_wheel/proc/paint_chip(atom/movable/screen/chrome_wheel/chip/chip, datum/action/ability, offline, unavailable, hovered, picked, active) + var/size = hovered ? 26 : 22 + chip.size = size + var/base_color = offline ? CHROME_WHEEL_OFFLINE_COLOR : chrome_wheel_tier_color(ability) + chip.icon_state = "frame_[size][offline ? "_blink" : ""]" + chip.color = hovered ? chrome_wheel_lighten(base_color, 0.35) : base_color + chip.alpha = 255 + chip.cut_overlays() + chip.underlays.Cut() + chip.underlays += chrome_wheel_chip_piece("fill_[size]") + if(hovered) + chip.add_overlay(chrome_wheel_chip_piece("halo_26", base_color, 90)) + var/mutable_appearance/look = chrome_wheel_ability_look(ability, parent) + // The icon fills the chip but for a pixel all round. + var/scale = (size - 2) / ICON_SIZE_X + look.transform = matrix(scale, 0, 0, 0, scale, 0) + // Red like its button when it can't be used. + if(offline || unavailable) look.color = ability.transparent_when_unavailable ? rgb(128, 0, 0, 128) : rgb(128, 0, 0) - slice.add_overlay(look) - update_shade(slice, cooldown_left, timed ? max(timed.cooldown_time, cooldown_left) : 0, timed?.next_use_time) - -/// The cooldown wipe: a dark copy of the slice that drains downward to nothing -/// as the cooldown runs out. Animated on the client, so it costs no ticks. -/datum/component/chrome_wheel/proc/update_shade(atom/movable/screen/chrome_wheel/slice/slice, cooldown_left, cooldown_total, cooldown_until) + chip.add_overlay(look) + if(active) + chip.add_overlay(chrome_wheel_chip_piece("deployed_[size]")) + draw_brackets(chip, picked) + +/// The pick's corner brackets, a couple of pixels clear of the chip's corners. +/datum/component/chrome_wheel/proc/draw_brackets(atom/movable/screen/chrome_wheel/chip/chip, picked) + if(!picked) + for(var/atom/movable/screen/chrome_wheel/bracket/corner as anything in chip.brackets) + corner.alpha = 0 + return + if(!chip.brackets) + chip.brackets = list() + for(var/corner_name in list("nw", "ne", "sw", "se")) + var/atom/movable/screen/chrome_wheel/bracket/corner = new + corner.icon_state = "bracket_[corner_name]" + corner.out_x = findtext(corner_name, "w") ? -1 : 1 + corner.out_y = findtext(corner_name, "n") ? 1 : -1 + chip.brackets += corner + chip.vis_contents += corner + // The bracket art has its corner pixel one in from the edge of its tile; a + // chip's brackets sit two pixels outside its corners. + var/reach = chip.size / 2 + 2 - 15 + for(var/atom/movable/screen/chrome_wheel/bracket/corner as anything in chip.brackets) + animate(corner) + corner.alpha = 255 + corner.color = CHROME_WHEEL_PICKED_COLOR + corner.pixel_x = corner.out_x * reach + corner.pixel_y = corner.out_y * reach + +/// The cooldown wipe: a dark block over the chip's inside that drains downward +/// to nothing as the cooldown runs out, with a faint line along its top. +/// Animated on the client, so it costs no ticks. +/datum/component/chrome_wheel/proc/update_shade(atom/movable/screen/chrome_wheel/chip/chip, cooldown_left, cooldown_total) if(!cooldown_left) - if(slice.shade) - animate(slice.shade) - slice.shade.alpha = 0 - slice.cooldown_until = 0 + for(var/atom/movable/screen/chrome_wheel/chip_part/part in list(chip.shade, chip.shade_edge)) + animate(part) + part.alpha = 0 + return + if(!chip.shade) + chip.shade = new + chip.shade.color = COLOR_BLACK + chip.shade_edge = new + chip.shade_edge.layer = 3.2 + chip.vis_contents += list(chip.shade, chip.shade_edge) + var/list/start = chrome_wheel_shade_shape(chip.size, cooldown_left / max(cooldown_total, 1)) + var/list/finish = chrome_wheel_shade_shape(chip.size, 0) + chip.shade.alpha = 185 + chip.shade.transform = start[1] + animate(chip.shade, transform = finish[1], time = cooldown_left) + chip.shade_edge.alpha = 110 + chip.shade_edge.transform = start[2] + animate(chip.shade_edge, transform = finish[2], time = cooldown_left) + +/** + * The cooldown wipe's shape on a chip of [size] with [fraction] of its + * cooldown left: list(shade transform, edge transform). The px state is a + * full tile, squashed to the chip's inside and sitting on its floor; the edge + * is the wipe's top row. + */ +/proc/chrome_wheel_shade_shape(size, fraction) + RETURN_TYPE(/list) + var/inside = size - 2 + var/height = max(inside * min(fraction, 1), 0.3) + return list( + matrix(inside / 32, 0, 0, 0, height / 32, -(inside - height) / 2), + matrix(inside / 32, 0, 0, 0, 1 / 32, max(height, 1) - inside / 2 - 0.5), + ) + +/// Whole seconds left on the cooldown, in the chip's bottom left corner. +/datum/component/chrome_wheel/proc/update_count(atom/movable/screen/chrome_wheel/chip/chip, cooldown_left) + var/seconds = cooldown_left ? CEILING(cooldown_left / 10, 1) : 0 + var/state = "[seconds]|[chip.size]" + if(state == chip.count_state) return - if(slice.cooldown_until == cooldown_until) + chip.count_state = state + if(!seconds) + if(chip.count) + chip.count.maptext = null return - slice.cooldown_until = cooldown_until - if(!slice.shade) - slice.shade = new - slice.vis_contents += slice.shade - var/atom/movable/screen/chrome_wheel/shade/shade = slice.shade - var/fraction = clamp(cooldown_left / max(cooldown_total, 1), 0.01, 1) - // The slice graphic is 26 pixels tall around the icon's centre, so a shade - // squashed to [fraction] sits on the bottom edge when dropped by 13 * (1 - fraction). - shade.alpha = 255 - shade.transform = matrix(1, 0, 0, 0, fraction, -13 * (1 - fraction)) - animate(shade, transform = matrix(1, 0, 0, 0, 0.01, -13 * 0.99), time = cooldown_left) - -/// The centre shows what Use chrome fires, dim when nothing is picked. -/datum/component/chrome_wheel/proc/draw_centre() - if(!centre) + if(!chip.count) + chip.count = new + chip.count.icon_state = null + chip.count.layer = 3.3 + chip.count.maptext_width = 32 + chip.count.maptext_height = 17 + chip.vis_contents += chip.count + // Box centred like the readout's lines (caps 8 px under a 17 px box's top), + // putting the number three pixels in from the chip's bottom left corner. + chip.count.maptext = chrome_readout_tiny_html("[seconds]") + chip.count.maptext_x = 16 - chip.size / 2 + 3 + chip.count.maptext_y = 18 - chip.size / 2 + +/// Sets a leader and its node to [lit_color], or back to the dim HUD colour for null. +/datum/component/chrome_wheel/proc/style_leader(key, lit_color) + var/list/leader = leaders?[key] + if(!leader) return - centre.cut_overlays() - var/mob/living/carbon/bearer = parent - var/datum/action/ability = get_selected_ability() - if(!ability) - centre.alpha = 70 + var/atom/movable/screen/chrome_wheel/art/line = leader["leader"] + var/atom/movable/screen/chrome_wheel/art/dot = leader["node"] + if(lit_color) + line.color = lit_color + line.alpha = 235 + dot.icon_state = "[leader["node_state"]]_lit" + dot.color = chrome_wheel_lighten(lit_color, 0.6) + dot.alpha = 255 return - centre.alpha = 255 - centre.add_overlay(chrome_wheel_marker("module_selected")) - if(selected_option) - centre.add_overlay(chrome_wheel_option_look(chrome_wheel_find_option(ability, bearer, selected_option))) - else - centre.add_overlay(chrome_wheel_ability_look(ability, bearer)) + line.color = CHROME_WHEEL_HUD_COLOR + line.alpha = leader["alpha"] + dot.icon_state = leader["node_state"] + dot.color = CHROME_WHEEL_HUD_COLOR + dot.alpha = min(255, leader["alpha"] + 60) + +/// Lights the arc, leader and body part of [chip] in its colour, and puts +/// everything else back. Null lights nothing. +/datum/component/chrome_wheel/proc/light_up(atom/movable/screen/chrome_wheel/chip/chip) + var/datum/action/ability = chip?.ability_ref?.resolve() + var/lit_color + if(ability) + lit_color = chrome_wheel_offline(ability) ? CHROME_WHEEL_OFFLINE_COLOR : chrome_wheel_tier_color(ability) + for(var/key in leaders) + style_leader(key, (lit_color && key == chip.leader_key) ? lit_color : null) + var/lit_arc = lit_color ? GLOB.chrome_wheel_sockets[chip.socket]["arc"] : null + for(var/arc_name in arcs) + var/atom/movable/screen/chrome_wheel/art/arc = arcs[arc_name] + var/lit = (arc_name == lit_arc) + arc.color = lit ? lit_color : CHROME_WHEEL_HUD_COLOR + arc.alpha = lit ? 200 : 80 + if(!glow) + return + if(!lit_color) + glow.alpha = 0 + return + glow.icon_state = "glow_[chip.region]" + glow.color = lit_color + glow.alpha = 255 -/datum/component/chrome_wheel/proc/draw_label() - if(!label) +/** + * The readout under the ring: the chip pointed at, the chrome it lives in in + * its tier colour, and on the right how it stands ("14s", "offline", "out"). + * With nothing pointed at it shows the pick, dim, with a small bracket mark. + */ +/datum/component/chrome_wheel/proc/update_readout() + if(!readout_title) return - label.maptext = highlighted ? MAPTEXT("
    [html_encode(highlighted.name)]
    ") : null + var/mob/living/carbon/bearer = parent + var/atom/movable/screen/chrome_wheel/chip/shown = highlighted + var/datum/action/ability = shown?.ability_ref?.resolve() + var/idle = !ability + var/title + if(idle) + ability = get_selected_ability() + if(ability) + var/datum/chrome_wheel_option/option = selected_option ? chrome_wheel_find_option(ability, bearer, selected_option) : null + title = option ? option.name : ability.chrome_wheel_name() + else + title = shown.name + var/sub = ability ? chrome_wheel_ware_name(ability) : "" + var/sub_color = CHROME_WHEEL_DIM_COLOR + var/title_color = CHROME_WHEEL_DIM_COLOR + var/status + var/status_color + if(ability && !idle) + title_color = CHROME_WHEEL_NAME_COLOR + var/datum/action/cooldown/timed = istype(ability, /datum/action/cooldown) ? ability : null + var/cooldown_left = timed ? max(timed.next_use_time - world.time, 0) : 0 + if(chrome_wheel_offline(ability)) + status = "offline" + status_color = CHROME_WHEEL_OFFLINE_COLOR + sub_color = CHROME_WHEEL_OFFLINE_NAME_COLOR + else + sub_color = chrome_wheel_tier_color(ability) + if(chrome_wheel_ability_active(ability, bearer)) + status = timed ? "armed" : "out" + status_color = CHROME_WHEEL_DEPLOYED_COLOR + else if(cooldown_left) + status = "[CEILING(cooldown_left / 10, 1)]s" + status_color = "#ffffff" + var/state = "[idle]|[title]|[title_color]|[sub]|[sub_color]|[status]" + if(state == readout_state) + return + readout_state = state + var/static/datum/font/header_font = new /datum/font/grand9k/size_6pt() + var/static/datum/font/list_font = new /datum/font/tiny_unicode/size_12pt() + var/status_width = status ? header_font.get_metrics(status) + 6 : 0 + if(title) + title = chrome_readout_fit(title, header_font, CHROME_WHEEL_READOUT_WIDTH - 10 - status_width) + readout_title.maptext = chrome_readout_header_html(html_encode(title), title_color) + else + readout_title.maptext = null + if(sub) + sub = chrome_readout_fit(sub, list_font, CHROME_WHEEL_READOUT_WIDTH - 10) + readout_sub.maptext = chrome_readout_tiny_html("[html_encode(sub)]") + else + readout_sub.maptext = null + readout_status.maptext = status ? chrome_readout_header_html(status, status_color, "right") : null + readout_mark.alpha = (idle && ability) ? 200 : 0 // ---- Mouse ------------------------------------------------------------- @@ -866,69 +1266,29 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( /// Highlights whatever the mouse at [offset] points at. /datum/component/chrome_wheel/proc/point_at(list/offset) - var/list/candidates = list() var/list/points = list() - for(var/atom/movable/screen/chrome_wheel/slice/slice in main_slices + sub_slices) - candidates += slice - points += list(list(slice.slice_x, slice.slice_y)) - var/index = chrome_wheel_pick(offset[1], offset[2], points) - set_highlight(index ? candidates[index] : null, sqrt(offset[1] ** 2 + offset[2] ** 2) < CHROME_WHEEL_DEADZONE) - -/** - * Moves the highlight. An ability slice opens its sub-option ring, if it has - * one. The ring stays open while the mouse crosses the gap out to it, and - * closes when another ability slice is highlighted or the mouse comes home. - */ -/datum/component/chrome_wheel/proc/set_highlight(atom/movable/screen/chrome_wheel/slice/slice, back_in_centre = FALSE) - if(slice == highlighted) + var/list/on_ring = list() + for(var/atom/movable/screen/chrome_wheel/chip/chip as anything in chips) + points += list(list(chip.chip_x, chip.chip_y)) + on_ring += chip.index <= chrome_wheel_socket_fits(chip.socket) + var/index = chrome_wheel_pick(offset[1], offset[2], points, on_ring) + set_highlight(index ? chips[index] : null) + +/// Moves the highlight: the old chip shrinks back, the new one grows and lights its part of the body. +/datum/component/chrome_wheel/proc/set_highlight(atom/movable/screen/chrome_wheel/chip/chip) + if(chip == highlighted) return - if(highlighted) - highlighted.icon_state = "radial_slice" - highlighted.transform = null - highlighted = slice - if(slice) - // The focus state alone is a faint border; the size change is what reads mid-fight. - slice.icon_state = "radial_slice_focus" - slice.transform = matrix(1.25, 0, 0, 0, 1.25, 0) - if(!slice.option_id && slice != sub_parent) - show_sub_ring(slice) - else if(back_in_centre) - hide_sub_ring() - draw_label() - -/datum/component/chrome_wheel/proc/show_sub_ring(atom/movable/screen/chrome_wheel/slice/parent_slice) - hide_sub_ring() - var/datum/action/ability = parent_slice.ability_ref?.resolve() - var/list/options = ability?.chrome_wheel_options(parent) - if(!length(options)) - return - sub_parent = parent_slice - var/list/points = chrome_wheel_sub_positions(parent_slice.slice_x, parent_slice.slice_y, length(options), outer_radius) - for(var/i in 1 to length(options)) - var/datum/chrome_wheel_option/option = options[i] - if(!istype(option)) - continue - var/list/point = points[i] - var/atom/movable/screen/chrome_wheel/slice/sub = new - sub.pixel_x = point[1] - sub.pixel_y = point[2] - sub.slice_x = point[1] - sub.slice_y = point[2] - sub.ability_ref = parent_slice.ability_ref - sub.option_id = option.id - sub.name = option.name - sub.option_look = chrome_wheel_option_look(option) - sub_slices += sub - hub.vis_contents += sub - draw_slice(sub) - -/datum/component/chrome_wheel/proc/hide_sub_ring() - sub_parent = null - if(!length(sub_slices)) - return - if(highlighted in sub_slices) - highlighted = null - for(var/atom/movable/screen/chrome_wheel/slice/sub in sub_slices) - hub?.vis_contents -= sub - qdel(sub) - sub_slices.Cut() + var/atom/movable/screen/chrome_wheel/chip/old = highlighted + highlighted = chip + if(old) + draw_chip(old) + if(chip) + draw_chip(chip) + else + light_up(null) + update_readout() + +#undef CHROME_WHEEL_READOUT_TOP +#undef CHROME_WHEEL_READOUT_LEFT +#undef CHROME_WHEEL_READOUT_WIDTH +#undef CHROME_WHEEL_READOUT_HEIGHT diff --git a/voidcrew/modules/cyberware/chrome_wheel_hud.dm b/voidcrew/modules/cyberware/chrome_wheel_hud.dm index 96483e9887e..d3273eda2e3 100644 --- a/voidcrew/modules/cyberware/chrome_wheel_hud.dm +++ b/voidcrew/modules/cyberware/chrome_wheel_hud.dm @@ -4,66 +4,113 @@ * Everything the player sees hangs off one hub at the centre of the view, in * its vis_contents, on the above-HUD plane. None of it takes the mouse: a * near-invisible full-view catcher sits under it and reports every move and - * click to the wheel, which works out the slice from the mouse position. + * click to the wheel, which works out the chip from the mouse position. * Every piece opts out of clear_screen(), because a knockdown rebuilds the HUD * mid-hold; the wheel takes its own pieces off the screen when it closes. + * + * Pieces keep the alpha and transform of whatever they hang off, so the hub's + * fade reaches everything and a chip's pop carries its brackets, shade and + * count with it. Each sets its own colour. + * + * Layers, bottom up: backdrop, body, body glow, arcs, leaders, nodes, empty + * sockets, chips (and their shade, count and brackets), readout. */ /atom/movable/screen/chrome_wheel - icon = 'icons/hud/radial.dmi' + icon = CHROME_WHEEL_ICON icon_state = null plane = ABOVE_HUD_PLANE - layer = 2 + layer = 3 mouse_opacity = MOUSE_OPACITY_TRANSPARENT vis_flags = VIS_INHERIT_PLANE clear_with_screen = FALSE + appearance_flags = RESET_COLOR | NO_CLIENT_COLOR | PIXEL_SCALE /// The wheel's anchor at the centre of the view. Draws nothing itself. /atom/movable/screen/chrome_wheel/hub screen_loc = "CENTER" -/// One position on the wheel: an ability, one of its sub-options, the centre, -/// or a dim placeholder for an empty body region. -/atom/movable/screen/chrome_wheel/slice - icon_state = "radial_slice" - layer = 3 - /// The ability this slice fires. +/** + * A piece of the 240x240 ring art (chrome_wheel_ring.dmi). The art is centred + * on the ring, which sits CHROME_WHEEL_CENTRE_Y above the middle of the view. + */ +/atom/movable/screen/chrome_wheel/art + icon = CHROME_WHEEL_RING_ICON + layer = 2.1 + pixel_x = -104 + pixel_y = -102 + +/// The soft dark backdrop. Stored at half size and drawn doubled, smoothly. +/atom/movable/screen/chrome_wheel/art/backdrop + icon_state = "scrim" + layer = 2 + appearance_flags = RESET_COLOR | NO_CLIENT_COLOR + +/atom/movable/screen/chrome_wheel/art/backdrop/Initialize(mapload, datum/hud/hud_owner) + . = ..() + transform = matrix(2, 0, 0, 0, 2, 0) + +/// The dim dashed outline of a socket with nothing in it. +/atom/movable/screen/chrome_wheel/empty_socket + icon_state = "socket" + layer = 2.5 + +/// One ability on the ring. +/atom/movable/screen/chrome_wheel/chip + icon_state = "frame_22" + /// The ability this chip fires. var/datum/weakref/ability_ref - /// Set on sub-option slices: the option's id within the ability. - var/option_id - /// Set on sub-option slices: the option's icon. - var/mutable_appearance/option_look - /// Offset from the wheel centre, in pixels. - var/slice_x = 0 - var/slice_y = 0 - /// Cooldown wipe, made the first time this slice cools down. - var/atom/movable/screen/chrome_wheel/shade/shade - /// The cooldown end the shade is animating towards. - var/cooldown_until = 0 - /// What the slice was last drawn as, so unchanged slices are left alone. + /// CHROME_WHEEL_SOCKET_* it sits in, and its position there, from 1. + var/socket + var/index = 1 + /// Which leader lights when it is pointed at: its socket's, or for hand + /// chrome its own run to the hand ("l_hand_2"). + var/leader_key + /// Which part of the body lights when it is pointed at. + var/region + /// Offset of its centre from the centre of the view, in pixels. + var/chip_x = 0 + var/chip_y = 0 + /// What the chip was last drawn as, so unchanged chips are left alone. var/drawn_state + /// What its cooldown count last showed. + var/count_state + /// Size it was last drawn at: 22, or 26 while pointed at. + var/size = 22 + /// Cooldown wipe and its top edge, made the first time the chip cools down. + var/atom/movable/screen/chrome_wheel/chip_part/shade + var/atom/movable/screen/chrome_wheel/chip_part/shade_edge + /// Cooldown seconds. + var/atom/movable/screen/chrome_wheel/chip_part/count + /// The pick's corner brackets, made the first time this chip is picked. + var/list/brackets -/atom/movable/screen/chrome_wheel/slice/Destroy() +/atom/movable/screen/chrome_wheel/chip/Destroy() vis_contents.Cut() QDEL_NULL(shade) + QDEL_NULL(shade_edge) + QDEL_NULL(count) + QDEL_LIST(brackets) ability_ref = null - option_look = null return ..() -/// A dark copy of the slice shape laid over a slice while it cools down. -/atom/movable/screen/chrome_wheel/shade - icon_state = "radial_slice" - color = COLOR_BLACK - alpha = 0 - layer = 4 - // APPEARANCE_UI without RESET_TRANSFORM: grows with its slice when that is pointed at. - appearance_flags = RESET_COLOR|NO_CLIENT_COLOR|PIXEL_SCALE +/// Something drawn on a chip: the cooldown wipe, its edge or its count. +/atom/movable/screen/chrome_wheel/chip_part + icon_state = "px" + layer = 3.1 -/// The name of the slice the mouse is on, under the wheel. -/atom/movable/screen/chrome_wheel/label - layer = 5 - maptext_width = 192 - maptext_height = 32 - maptext_x = -80 +/// One corner of the lock brackets on the picked chip. +/atom/movable/screen/chrome_wheel/bracket + icon = CHROME_WHEEL_BRACKET_ICON + layer = 3.4 + color = CHROME_WHEEL_PICKED_COLOR + /// Which way is outward from the chip, for the snap in: -1 or 1 on each axis. + var/out_x = 0 + var/out_y = 0 + +/// A part of the readout panel under the ring: a flat block, or a line of text. +/atom/movable/screen/chrome_wheel/readout + icon_state = "px" + layer = 4 /** * tg's cursor catcher (the scope's), lifted above the HUD so the mouse reaches diff --git a/voidcrew/modules/cyberware/icons/chrome_wheel.dmi b/voidcrew/modules/cyberware/icons/chrome_wheel.dmi new file mode 100644 index 0000000000000000000000000000000000000000..a75f826549daa77a992bee93a46b04fcc83d6832 GIT binary patch literal 1000 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 0 && third[1] < 0, "Sub-options should spread to both sides of the first") + // Chips are called by what they do, not by their button's full name. + var/list/names = chrome_wheel_names(abilities) + TEST_ASSERT_EQUAL(names[blade], "Mantis Blade", "A Mantis housing's deploy chip should read as the blade, without its housing or side") + TEST_ASSERT_EQUAL(names[lunge], "Mantis Lunge", "An ability with its own name was renamed") + TEST_ASSERT_EQUAL(chrome_wheel_ware_name(lunge), "Mantis Blade housing (left)", "The readout should name the chrome an ability lives in") + + // Pointing. Points: 1 pulse (88,50), 2 blade (-99,14), 3 lunge (-99,-11), + // 4 right blade (99,14), 5 grip (93,-35). + var/list/points = list(positions[pulse], positions[blade], positions[lunge], positions[right_blade], positions[grip]) + TEST_ASSERT_EQUAL(chrome_wheel_pick(5, 5, points), 0, "The middle of the body pointed at a chip") + TEST_ASSERT_EQUAL(chrome_wheel_pick(20, 20, points), 0, "A point inside the body pointed at a chip") + TEST_ASSERT_EQUAL(chrome_wheel_pick(87, 51, points), 1, "The mouse on a chip did not point at it") + TEST_ASSERT_EQUAL(chrome_wheel_pick(-300, 4, points), 2, "A flick far out to the left did not point at the left arm's nearest chip by bearing") + TEST_ASSERT_EQUAL(chrome_wheel_pick(-40, -6, points), 3, "A short move between the body and the ring did not point at the chip on its bearing") + TEST_ASSERT_EQUAL(chrome_wheel_pick(0, -300, points), 0, "A flick into the empty legs arc pointed at something") + // A second-row chip answers the mouse right on it, never a bearing. + var/list/rows = list(list(73, 70), list(92, 88)) + TEST_ASSERT_EQUAL(chrome_wheel_pick(184, 176, rows, list(TRUE, FALSE)), 1, "A flick out along a socket's bearing picked its second row over its first") + TEST_ASSERT_EQUAL(chrome_wheel_pick(93, 87, rows, list(TRUE, FALSE)), 2, "The mouse on a second-row chip did not point at it") TEST_ASSERT_EQUAL(chrome_wheel_raw_key("ShiftV"), "V", "A Shift bind's key was not found") TEST_ASSERT_EQUAL(chrome_wheel_raw_key("CtrlShiftV"), "V", "A two-modifier bind's key was not found") @@ -153,6 +191,19 @@ TEST_ASSERT(!wheel.is_open(), "The wheel opened with no client to draw on") wheel.wheel_key_up() + // Its pieces build without a screen to go on: one chip per ability, and + // pointing, picking and teardown work on them. + wheel.build_wheel(get_chrome_wheel_actions(lab_rat)) + TEST_ASSERT_EQUAL(length(wheel.chips), 4, "The built wheel should have one chip per chrome ability") + var/atom/movable/screen/chrome_wheel/chip/first_chip = wheel.chips[1] + wheel.set_highlight(first_chip) + TEST_ASSERT_EQUAL(first_chip.size, 26, "The chip pointed at did not grow") + wheel.select_chip(first_chip, quiet = TRUE) + TEST_ASSERT_EQUAL(wheel.get_selected_ability(), first_chip.ability_ref.resolve(), "Picking a chip did not pick its ability") + TEST_ASSERT_EQUAL(length(first_chip.brackets), 4, "The picked chip did not get its lock brackets") + wheel.close_wheel() + TEST_ASSERT_NULL(wheel.chips, "Closing the wheel left its chips behind") + wheel.select(pulse) TEST_ASSERT(wheel.use_selected(), "Use chrome refused a ready pick") TEST_ASSERT_EQUAL(pulse.fired, 1, "Use chrome did not fire the picked ability") From a75109d2ce1e6164664cfc3d0448887ed0224216 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:02:15 -0800 Subject: [PATCH 134/472] Apply the phase 2 lead decisions and wire the new chrome into shops and loot Decks refuse outpost prison doors, Cutout moves from the Street deck to the Facilities deck, the Deepcore drill fist gets a trade-in, the Second Wind and Dermal Mesh ledgers take only their own piece, and the Second Wind lines name the bladder actually fitted. Splice stocks every new model, Ratchet every credit-priced one, both ledgers buy back the credit models, and every new piece except the Blackbox deck rides the zone loot themes. Rating ranks are renumbered per group. --- tools/gear_catalog/ratings/cyberware.json | 118 +++++++++--------- tools/gear_catalog/ratings/guns.json | 12 +- tools/gear_catalog/ratings/tools.json | 36 +++--- .../modules/cyberware/ripperdoc_catalog.dm | 59 +++++++++ .../modules/cyberware/street_doc_catalog.dm | 48 +++++++ voidcrew/modules/cyberware/ware_line_deck.dm | 15 +-- .../modules/cyberware/ware_line_models_a.dm | 20 ++- .../modules/cyberware/ware_second_wind.dm | 10 +- voidcrew/modules/loot/themes/armory.dm | 16 +++ voidcrew/modules/loot/themes/expedition.dm | 20 +++ voidcrew/modules/loot/themes/industrial.dm | 18 ++- voidcrew/modules/loot/themes/medical.dm | 8 ++ voidcrew/modules/loot/themes/plunder.dm | 5 +- voidcrew/modules/loot/themes/research.dm | 8 ++ voidcrew/modules/loot/themes/syndicate.dm | 16 +++ voidcrew/modules/loot/themes/wardrobe.dm | 3 + .../unit_tests/voidcrew_cyberware_deck.dm | 14 +++ .../unit_tests/voidcrew_cyberware_models_a.dm | 3 + .../unit_tests/voidcrew_cyberware_models_b.dm | 9 ++ voidcrew/modules/unit_tests/voidcrew_loot.dm | 2 + .../unit_tests/voidcrew_shop_catalog.dm | 8 +- .../modules/unit_tests/voidcrew_zone_shops.dm | 19 ++- 22 files changed, 364 insertions(+), 103 deletions(-) diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 5ee5c725648..36064387387 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -19,7 +19,7 @@ "hash": "c229d282cf893570", "name": "Strong-Arm empowered musculature implant", "note": "Humans only. With the implanted arm active, an empty-hand combat-mode left-click on a mob becomes a slam on a 5 s cooldown: the hand's unarmed damage roll plus 5 (plus 20 more against beast, special and mining mobs), doubled and run through melee armor, and the target is thrown 1 tile if the wearer is standing; it is not used while Hulk. One implant in each arm lets the wearer pry closed airlocks open bare-handed (6 s, instant if unpowered, refused if bolted or welded). An EMP puts the arm down for 9 s (4.5 s on a light pulse): slams fail, and half the time the wearer lunges onto the target and falls for 3 s, otherwise is paralyzed for 1 s.", - "rank": 9, + "rank": 10, "reason": "A slam for 20 to 30 damage every 5 s per arm, 60 to 70 against mining mobs and beasts, and a pair pries powered airlocks in 6 s. 1,200 cr for two from cargo. EMP makes the arm a liability for 9 s.", "tier": "yellow" }, @@ -28,7 +28,7 @@ "group": "Work tools", "hash": "5b81131a4a10a42c", "name": "Harpoon Spool", - "rank": 6, + "rank": 9, "reason": "Pulls a loose item or small creature to you from 6 tiles every 8 s, anything bigger drags you a tile. Can grab a dropped gun, little else. Load 2, 2,800 cr.", "tier": "green" }, @@ -56,7 +56,7 @@ "group": "Work tools", "hash": "", "name": "Tinker's fingers", - "rank": 2, + "rank": 3, "reason": "A driver, wrench and cutters in the arm, and every timed action 10% faster. The Fixer's Fingers at a tenth of the stack: load 1, 700 cr. Splice takes a pulled set back for 350 cr.", "tier": "green" }, @@ -75,7 +75,7 @@ "group": "Hacking and ship control", "hash": "52911504b95eb634", "name": "Icepick Jack", - "rank": 1, + "rank": 4, "reason": "Knocks hostile turrets out for 15 s, turns them for 10 s, and forces bolted or unpowered doors in 6 s. A boarding crew's key to player hulls, though jacking their turrets sets off an alarm. Load 3, 1 voucher.", "tier": "yellow" }, @@ -124,7 +124,7 @@ "group": "Arm weapons and fists", "hash": "", "name": "Shiv Blade housing (right)", - "rank": 12, + "rank": 13, "reason": "Half of a pair: 14-force blades that can't be disarmed, no lunge. Load 4 the pair, 2,400 cr at yellow reputation. Splice takes a pulled housing back for 600 cr.", "tier": "yellow" }, @@ -133,7 +133,7 @@ "group": "Arm weapons and fists", "hash": "", "name": "Shiv Blade housing (left)", - "rank": 13, + "rank": 14, "reason": "Left half of the Shiv pair: 14-force blades that can't be disarmed, no lunge.", "tier": "yellow" }, @@ -162,7 +162,7 @@ "hash": "29ec5344a41717f9", "name": "Rockjaw drill fist", "note": "Folds a mining drill out of the forearm. While the drill is out, ore from any rock the wearer cracks (with any tool) is swept 0.2 s later into an internal hopper with no size limit in the code instead of landing on the floor; a second button dumps the hopper at the wearer's feet. Sweeping is off during an EMP reboot, and the hopper's contents drop if the arm ware is destroyed.", - "rank": 5, + "rank": 7, "reason": "A mining drill in the forearm with a bottomless ore hopper. A miner's bag and pick in one arm slot. Load 2, 1,200 cr.", "tier": "green" }, @@ -171,8 +171,8 @@ "group": "Work tools", "hash": "", "name": "Deepcore drill fist", - "rank": 4, - "reason": "The Rockjaw's ore hopper on a faster drill, between the Rockjaw drill and a diamond drill. Load 3, 2,400 cr.", + "rank": 6, + "reason": "The Rockjaw's ore hopper on a faster drill, between the Rockjaw drill and a diamond drill. Load 3, 2,400 cr. Splice takes a pulled one back for 1,200 cr.", "tier": "green" }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin": { @@ -198,7 +198,7 @@ "group": "Mobility and stealth", "hash": "d2c5b721e44bd009", "name": "Skyhook wrist winch", - "rank": 3, + "rank": 4, "reason": "Fires a line at any wall or anchored structure within 7 tiles and hauls you there, every 8 s. Real chase and escape mobility, at the cost of an arm slot. Load 3, loot only.", "tier": "yellow" }, @@ -207,7 +207,7 @@ "group": "Work tools", "hash": "4a6c47ce239cc051", "name": "integrated paperwork implant", - "rank": 7, + "rank": 11, "reason": "A pen, clipboard, lighter and laser pointer in your arm. Harmless.", "tier": "none" }, @@ -216,7 +216,7 @@ "group": "Work tools", "hash": "6e21944c25437900", "name": "surgical toolset implant", - "rank": 3, + "rank": 4, "reason": "A surgical kit in your arm. Saves carrying the tools. Late printing.", "tier": "green" }, @@ -225,7 +225,7 @@ "group": "Work tools", "hash": "2ffcab76bdd48728", "name": "morbid surgical toolset implant", - "rank": 4, + "rank": 5, "reason": "The surgical arm rebuilt for looks and a nasty mood. Same job as the plain one.", "tier": "green" }, @@ -244,7 +244,7 @@ "hash": "095577f719c51add", "name": "anti-drop implant", "note": "A toggle button: switching it on makes whatever is in the wearer's hands at that moment undroppable (stuns and disarms don't release it) until the button is pressed again or the item leaves the hand; items picked up afterwards are not locked. An EMP releases it and flings the held items up to 10 tiles in random directions, and as with every brain implant any EMP also stuns the wearer for 20 s (10 s on a light pulse).", - "rank": 6, + "rank": 8, "reason": "Locks what's in your hands so stuns and disarms can't take it. Same job as Gecko Grip at about 24 cr of material, but any EMP stuns you for 20 s and flings the item.", "tier": "green" }, @@ -254,7 +254,7 @@ "hash": "8ae45008ffefef4e", "name": "CNS rebooter implant", "note": "1 s after the wearer gains a stun, immobilize, paralysis or stamina crit it clears stun, knockdown, immobilize, paralysis and all stamina damage, then grants stun immunity, baton resistance and immunity to damage slowdown for 6 s (stamina is zeroed again at the end). Then locks out for 60 s. An EMP takes it offline for 9 s (4.5 s on a light pulse), and as with every brain implant also stuns the wearer for 20 s (10 s light).", - "rank": 2, + "rank": 3, "reason": "Once a minute a stun is cleared after 1 s, stamina resets, and you get 6 s of stun immunity. Undoes the first stun of a fight for one neural load. Any EMP stuns the wearer for 20 s and takes it offline for 9 s.", "tier": "yellow" }, @@ -264,7 +264,7 @@ "hash": "df0fb0f149203f80", "name": "CNS skillchip connector implant", "note": "Lets the wearer put a skillchip in or take one out without surgery: a 1.5 s action, inserting the chip in the active hand or, with an empty hand, choosing an installed chip from a radial menu. An EMP knocks installed chips onto the floor (one on a light pulse, two on a heavy one), and as with every brain implant stuns the wearer for 20 s (10 s light). With no chip installed the handler indexes the first chip of an empty or null list and its brain-damage branch is called without a brain, so it may error out before the reboot timer starts (unconfirmed).", - "rank": 7, + "rank": 8, "reason": "Swap skillchips without surgery. Skillchips do almost nothing, and any EMP stuns you for 20 s.", "tier": "none" }, @@ -274,7 +274,7 @@ "hash": "5bc666bd0e7ad39e", "name": "nutriment pump implant", "note": "While the wearer's nutrition is at or below 150 (250 for the PLUS version) it adds about 50 nutrition, then waits 5 s before it can do so again, which stops nutrition falling below that line. An EMP pushes bad-food toxin into the blood (5 units on a heavy pulse, 10 on the PLUS version).", - "rank": 5, + "rank": 6, "reason": "Keeps you just above starving. Removes the need for a kitchen for one neural load.", "tier": "green" }, @@ -284,7 +284,7 @@ "hash": "ce113e10a6c8deab", "name": "nutriment pump implant PLUS", "note": "While the wearer's nutrition is at or below 150 (250 for the PLUS version) it adds about 50 nutrition, then waits 5 s before it can do so again, which stops nutrition falling below that line. An EMP pushes bad-food toxin into the blood (5 units on a heavy pulse, 10 on the PLUS version).", - "rank": 4, + "rank": 5, "reason": "Keeps you fed before you get hungry. Removes food from a six-hour round for one neural load.", "tier": "green" }, @@ -304,7 +304,7 @@ "hash": "14f4a2c2517247c0", "name": "Herculean gravitronic spinal implant", "note": "Cuts the wearer's climbing, ladder, gym-machine, table-carry and vent-boulder lifting times to 80%, adds +1 speed and +4 range to everything they throw, adds 4 to tackle strength and lets them break boulders off a discovered ore vent with bare hands. An EMP deals 20 brute (10 light) to the chest. A gravity anomaly core can be slotted in to change it into the Atlas variant.", - "rank": 7, + "rank": 9, "reason": "Faster climbing, throws fly farther and faster, tackles hit harder, and you can break boulders off ore vents by hand. Nothing here wins a fight. About 85 cr of material.", "tier": "green" }, @@ -314,7 +314,7 @@ "hash": "ffff4012788ccee2", "name": "implantable thrusters set", "note": "A toggle that lets the wearer move under their own power in zero gravity, with a movement-speed bonus while floating and no stabilization, so they keep drifting when they stop. It draws on ambient air above 30 kPa for free, otherwise internals gas or the wearer's plasma store, and turns itself off if neither is available.", - "rank": 6, + "rank": 7, "reason": "A jetpack in your chest with no back slot used, and a speed bonus while floating. Free air when there's pressure, otherwise your tank. About 74 cr of material after 200 research points.", "tier": "green" }, @@ -324,7 +324,7 @@ "hash": "", "name": "Adrenal pump", "note": "When a stamina update leaves a conscious wearer at 70 or more stamina loss and the pump is ready, one tick later it takes 40 off, speeds the wearer up (movement -0.4) and tints them red for 3 s, and plays a loud heartbeat. It then waits 4 minutes; the wait is stored as a ready-at time on the organ, so a refit does not reset it. It ignores the stamina dumped by the Cascade and Redline crashes. It takes the spare heart slot, so it evicts the Clotter node, and goes off during an EMP reboot or brownout.", - "rank": 3, + "rank": 5, "reason": "Once every 4 minutes a bad run of stamina damage is cut by 40 and the wearer gets 3 s of extra speed, enough to stay out of a stamina crit for one more volley. Load 2, 2,400 cr at reputation 4.", "tier": "yellow" }, @@ -334,7 +334,7 @@ "hash": "0a8132740babf01b", "name": "Atlas frame", "note": "Bones can't be broken (no dislocations or fractures) and limbs can't be dismembered; slashes, punctures, burns and bleeding still land normally. Items and mobs the wearer throws get +2 tiles of range and +1 speed, and any throw of the wearer by something else travels half as far. All of it drops during an EMP reboot or over-capacity brownout.", - "rank": 3, + "rank": 4, "reason": "No broken bones and no lost limbs, thrown things fly farther and you get thrown half as far. Slashes, burns and bleeding still land. Load 4, 2 vouchers at reputation 22.", "tier": "yellow" }, @@ -344,7 +344,7 @@ "hash": "", "name": "Strut frame", "note": "Bone wounds (dislocations, hairline and compound fractures) are refused while the ware is online; nothing else. Limbs can still be dismembered, throws are not boosted and the wearer's own knockback is not reduced. Drops during an EMP reboot or over-capacity brownout.", - "rank": 6, + "rank": 8, "reason": "Bones can't break: no dislocations or fractures. Limbs still come off, and slashes, burns and bleeding land as usual. None of the Atlas Frame's throw boost or knockback cut. Load 2, 1,500 cr at reputation 4.", "tier": "yellow" }, @@ -390,7 +390,7 @@ "hash": "ec8cfbbd975a51eb", "name": "Chromatic Dermis ink suite", "note": "Cosmetic only: draws a glowing outline of the chosen colour and pattern around the wearer that flares when they take damage, swing, use a chrome ability or when other chrome acts, dims when they are hungry, and goes dark while the ware is down. No mechanical effect.", - "rank": 8, + "rank": 9, "reason": "Glowing tattoos with no other effect. Load 0, 400 cr.", "tier": "none" }, @@ -420,7 +420,7 @@ "hash": "", "name": "Heat-sink lining", "note": "While the ware is online the wearer takes no damage from overheating (hot air or body temperature) and loses 1 fire stack per second. It does not reduce burn damage and does not stop fire: a burning wearer is burned at full strength. Both drop during an EMP reboot or over-capacity brownout.", - "rank": 7, + "rank": 9, "reason": "No heat damage from hot air or lava country, and fire burns off fast, but burns from lasers and flames land at full strength. The heat half of the Coolant Loops for load 1, 1,500 cr.", "tier": "green" }, @@ -430,7 +430,7 @@ "hash": "", "name": "Cuffer's wrists", "note": "While online, handcuffing takes 75% of the usual time, the trait combat and insulated gloves also give. It takes the hands slot, so it evicts the Gecko Grip and the Medic's palms.", - "rank": 6, + "rank": 10, "reason": "Cuffs go on in three quarters of the time with no glove needed. A small edge for bounty hunters and boarders. Load 1, 500 cr.", "tier": "green" }, @@ -449,7 +449,7 @@ "group": "Hacking and ship control", "hash": "", "name": "Junker deck", - "rank": 5, + "rank": 8, "reason": "From 5 tiles with a clear line, after a 2 s upload with a visible trace line: bolt or unbolt a door, burst an area's lights, or ping nearby cameras, turrets and powered doors through walls. 12 to 45 s cooldowns. No access bypass. Load 2, 1,500 cr; Splice takes it back for 750.", "tier": "green" }, @@ -467,8 +467,8 @@ "group": "Hacking and ship control", "hash": "", "name": "Facilities deck", - "rank": 2, - "reason": "Doors, radios and turrets from 7 tiles after a 1 s upload: electrify a door for 30 s (20 burn and a stun), jam radios in a 9-tile bubble for 45 s, knock a turret off for 8 s, plus the Street deck's hacks. One shared cooldown of 8 to 40 s. Load 6, 4 vouchers at reputation 11.", + "rank": 3, + "reason": "Doors, radios and turrets from 7 tiles after a 1 s upload: electrify a door for 30 s (20 burn and a stun), jam radios in a 9-tile bubble for 45 s, knock a turret off for 8 s, and Cutout, which empties every cell a person carries (energy guns, MOD suit) and kills their radios for 45 s. Plus the Street deck's hacks. One shared cooldown of 8 to 40 s. Load 6, 4 vouchers at reputation 11.", "tier": "red" }, "/obj/item/organ/cyberimp/cyberware/deck/flagship": { @@ -485,8 +485,8 @@ "group": "Hacking and ship control", "hash": "", "name": "Street deck", - "rank": 4, - "reason": "The Junker deck at 6 tiles and 1.5 s, plus Kill the Power on an APC and Cutout, which empties every cell a person carries (energy guns, MOD suit) and kills their radios for 45 s. About a minute between Cutouts. Load 4, 3,500 cr at yellow reputation; buys back at 1,750.", + "rank": 7, + "reason": "The Junker deck at 6 tiles and 1.5 s, plus Kill the Power: flip an APC's breaker and the whole area goes dark and unpowered. No hack that touches a person. Load 4, 3,500 cr at yellow reputation; buys back at 1,750.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/dermal_mesh": { @@ -495,7 +495,7 @@ "hash": "5b60dbb7a15c8084", "name": "Dermal Mesh weave", "note": "While online, adds physiology armor to a human wearer: melee 15, bullet 15, laser 10, wound 5. The armor is removed during an EMP reboot or an over-capacity brownout.", - "rank": 4, + "rank": 5, "reason": "15 melee, 15 bullet, 10 laser on the whole body, on top of whatever you wear. A clear step up for load 2 and 2,000 cr. EMP switches it off for 9 s.", "tier": "yellow" }, @@ -535,7 +535,7 @@ "hash": "", "name": "Frostline lining", "note": "While the ware is online the wearer takes no damage from cold air or body temperature. It does nothing for pressure, breathing or burns. The trait drops during an EMP reboot or over-capacity brownout.", - "rank": 9, + "rank": 10, "reason": "No cold damage on ice planets and in the cold of space, but low pressure and no air still kill. Shares the seal slot with every air and heat lining, so it never sits beside a bladder. Load 1, 1,500 cr.", "tier": "green" }, @@ -545,7 +545,7 @@ "hash": "26dc92f22c6385f6", "name": "Gastro Reactor", "note": "The wearer eats 35% faster, food that is contaminated (for example dropped on the floor) gives no infection or disgust mood, vomiting costs half the nutrition, everything they eat counts as 6 points higher quality (lifting rotten or gross food above the disgust line, though species-toxic food stays toxic) and disgust drains away at 3 per second. Off during an EMP reboot or over-capacity brownout.", - "rank": 6, + "rank": 7, "reason": "Eat anything, eat faster, no food poisoning. No bearing on a fight. Load 1, 400 cr.", "tier": "none" }, @@ -555,7 +555,7 @@ "hash": "e331ce1251c1f939", "name": "Gecko Grip palm pads", "note": "The wearer's hands refuse to drop held items while stunned, paralyzed, unconscious, in crit or stamina-crit (handcuffs still take the hands, and the dead drop them). They can walk over tables and climb obstacles 20% faster, and stepping onto a chasm edge shoves them back to the lip instead of dropping them, resetting after 30 s. An EMP opens the hands and hurls whatever is held up to 5 tiles (3 on a light pulse).", - "rank": 5, + "rank": 7, "reason": "Stuns and crit can't strip what you're holding, you walk over tables and a chasm edge throws you back. Load 1, 900 cr. An EMP flings your gun away.", "tier": "green" }, @@ -575,7 +575,7 @@ "hash": "", "name": "Flickerskin weave", "note": "A button turns on 5 s of optical camo (a half-faded outline that brightens when they move) with a 45 s cooldown spent on use; pressing it again drops the field early. The field collapses on attacking a living thing, firing a gun, being hit or attacked, hostile bullets, being cuffed, death or an EMP. It does not grant the mob-hiding camo trait, so NPC AI still targets the wearer at any range and Chrome Read still resolves them.", - "rank": 5, + "rank": 6, "reason": "Five seconds of half-faded camo every 45: people lose you in a crowd, mobs and turrets still see you, and any swing or hit ends it. A cheap way to break line of sight from players. Load 3, 2,400 cr at yellow reputation.", "tier": "yellow" }, @@ -585,7 +585,7 @@ "hash": "9c748c5bf556a0e0", "name": "Gorilla Arms myomer lattice (right)", "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a flat 20 brute (armor rolled, +10 wound bonus) with a 1-tile knockback on a mob, 4 s cooldown per arm; a mob held in an aggressive grab is hurled 4 tiles. On a rock wall it drills it, and on a structure or machine with integrity it does 40 brute (1 s cooldown for both). With both arms, closed airlocks can be pried bare-handed (4 s if powered, instant if unpowered, refused if bolted or welded). Not used while Hulk or while the ware is down.", - "rank": 10, + "rank": 11, "reason": "Half of a pair: a flat 20 damage punch every 4 s, 40 to machines and structures every second, breaks rock, and the pair pries powered airlocks in 4 s. A breaching tool more than a weapon. Load 6 the pair.", "tier": "yellow" }, @@ -595,7 +595,7 @@ "hash": "25756d0541291f90", "name": "Gorilla Arms myomer lattice (left)", "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a flat 20 brute (armor rolled, +10 wound bonus) with a 1-tile knockback on a mob, 4 s cooldown per arm; a mob held in an aggressive grab is hurled 4 tiles. On a rock wall it drills it, and on a structure or machine with integrity it does 40 brute (1 s cooldown for both). With both arms, closed airlocks can be pried bare-handed (4 s if powered, instant if unpowered, refused if bolted or welded). Not used while Hulk or while the ware is down.", - "rank": 11, + "rank": 12, "reason": "Left half of the Gorilla pair: a flat 20 damage punch every 4 s, 40 to machines and structures every second, breaks rock, and the pair pries powered airlocks in 4 s.", "tier": "yellow" }, @@ -604,7 +604,7 @@ "group": "Arm weapons and fists", "hash": "", "name": "Brawler myomers (right)", - "rank": 12, + "rank": 15, "reason": "Half of a pair: a flat 14 damage punch every 6 s, 20 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying. Load 4 the pair, 2,400 cr at reputation 4. Splice takes a pulled arm back for 600 cr.", "tier": "yellow" }, @@ -613,7 +613,7 @@ "group": "Arm weapons and fists", "hash": "", "name": "Brawler myomers (left)", - "rank": 13, + "rank": 16, "reason": "Left half of the Brawler pair: a flat 14 damage punch every 6 s, 20 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying.", "tier": "yellow" }, @@ -659,7 +659,7 @@ "group": "Mobility and stealth", "hash": "2f8e33baec594340", "name": "Hopper piston calves", - "rank": 4, + "rank": 5, "reason": "Jump 4 tiles over tables, gaps and people every 8 s. Won't cross walls or windows. Strong in any fight. Load 3, 3,600 cr.", "tier": "yellow" }, @@ -668,7 +668,7 @@ "group": "Mobility and stealth", "hash": "", "name": "Vaulter pistons", - "rank": 2, + "rank": 3, "reason": "Jump 5 tiles over tables, gaps and people every 6 s, and walk away from drops and wet floors like the Shock Coils. Won't cross walls or windows. Load 4, 3 vouchers at reputation 11.", "tier": "red" }, @@ -677,7 +677,7 @@ "group": "Mobility and stealth", "hash": "", "name": "Spring calves", - "rank": 6, + "rank": 8, "reason": "Jump 3 tiles over tables, gaps and people every 14 s. Won't cross walls or windows. The cheap rung under the Hopper, with none of the Shock Coils' trims. Load 2, 1,800 cr at reputation 4. Splice takes a pulled pair back for 900 cr.", "tier": "yellow" }, @@ -697,7 +697,7 @@ "hash": "", "name": "Medic's palms", "note": "While online, gives the wearer the quicker chemistry trait (syringes, pills, patches and the like take less time) and the quicker fireman carry (3 s instead of 5), the same traits as nitrile gloves, without using the glove slot. It takes the hands slot, so it evicts the Gecko Grip and the Cuffer's wrists.", - "rank": 5, + "rank": 8, "reason": "Nitrile-glove speed on chemistry and carrying without the glove slot, so a medic can wear insulated or armored gloves instead. Load 1, 600 cr.", "tier": "green" }, @@ -707,7 +707,7 @@ "hash": "", "name": "Muffler ear", "note": "While online, takes one point off the intensity of a bang, which is what earmuffs do: a flashbang or similar bang does nothing to the wearer, though the flash still blinds. It goes off during an EMP reboot or brownout. It takes the chrome ears slot, so it evicts the Heartbeat Doppler.", - "rank": 7, + "rank": 10, "reason": "A flashbang's bang stops dropping the wearer, like earmuffs you can't take off. The flash still blinds. Load 1, 700 cr.", "tier": "green" }, @@ -753,7 +753,7 @@ "hash": "3ab34be71b4333f9", "name": "Registry uplink", "note": "Gives a button that opens an outpost's management panel from anywhere, with no distance, docking, zone or console-power requirement, for each outpost where the wearer is currently the management user. It needs the wearer conscious and the ware running, and any open panel closes if the ware goes down or is removed.", - "rank": 3, + "rank": 6, "reason": "Opens the management panel of an outpost you run from anywhere. Convenience for outpost owners, no use to anyone else. Load 2, 2,800 cr.", "tier": "green" }, @@ -763,7 +763,7 @@ "hash": "332ab143dabb3d52", "name": "Rigger socket", "note": "Opens a working helm console on the ship the bearer is standing on, from anywhere aboard, subject to the console's normal crew access; broken, unpowered and view-only viewscreen consoles don't count. While the uplink is up the bearer has +1 movement slowdown, and it drops if they leave the hull, go unconscious, close the panel, lose the console, or the socket is EMP'd or browned out. The button has a 5 second cooldown.", - "rank": 2, + "rank": 5, "reason": "Fly your ship from anywhere aboard at a walking pace. Lets a pilot hide from boarders or fix things mid-fight, and it needs a working helm. Load 4, 3 vouchers at reputation 22.", "tier": "yellow" }, @@ -773,7 +773,7 @@ "hash": "e057ede2eff30ba2", "name": "Scrapper's Knuckles", "note": "Replaces the bearer's own punch (combat mode, left click, not a Hulk) with that hand's normal unarmed roll plus 4 flat damage against mobs, and double that total against machines and structures. Humans can still block it and armor still applies; it stops working while the ware is EMP'd or browned out.", - "rank": 14, + "rank": 17, "reason": "Half of a pair: punches do 4 more, double against machines. A cheap brawler's first step. Load 2 the pair, 800 cr.", "tier": "green" }, @@ -783,7 +783,7 @@ "hash": "1262168b74587722", "name": "Scrapper's Knuckles", "note": "Replaces the bearer's own punch (combat mode, left click, not a Hulk) with that hand's normal unarmed roll plus 4 flat damage against mobs, and double that total against machines and structures. Humans can still block it and armor still applies; it stops working while the ware is EMP'd or browned out.", - "rank": 15, + "rank": 18, "reason": "Left half of the Scrapper pair: punches do 4 more, double against machines.", "tier": "green" }, @@ -793,7 +793,7 @@ "hash": "7266f264ffd401f9", "name": "Second Wind bladder", "note": "When the surroundings have under 16 kPa of oxygen and the bearer isn't on tank internals, the bladder supplies the breath itself from a 180 second reserve, spending 8 seconds of reserve per breath and refilling 16 seconds per breath taken in breathable air. It replaces only the breath, and it announces itself with a gasp, a HUD alert and a loud breathing loop; it does nothing for no-breathers or while EMP'd or browned out.", - "rank": 5, + "rank": 7, "reason": "Three minutes of air when you lose your internals, refills as you breathe. A safety net against vacuum. Load 1, 900 cr.", "tier": "green" }, @@ -803,7 +803,7 @@ "hash": "", "name": "Deep-lung bladder", "note": "Works exactly like the Second Wind bladder: when the surroundings have under 16 kPa of oxygen and the bearer isn't on tank internals, it supplies the breath itself, spending 8 seconds of reserve per breath. The reserve is 360 seconds (180) and refills 24 seconds per breath taken in breathable air (16). It announces itself with a gasp, a HUD alert and a loud breathing loop; it does nothing for no-breathers or while EMP'd or browned out.", - "rank": 8, + "rank": 6, "reason": "Six minutes of air when you lose your internals, refilling faster than the Second Wind. A longer safety net against vacuum, not a way to work in it. Load 2, 1,800 cr.", "tier": "green" }, @@ -813,7 +813,7 @@ "hash": "d453bb62463e3dcb", "name": "Shock Coil calf pistons", "note": "Halves knockdown durations and cuts stun durations by 20%, stops slipping on water-wet floors (water only), and cancels fall damage from dropping down levels with no power cost; falls of 2 or more levels still stagger the bearer for up to 10 seconds. All of it switches off while the ware is EMP'd or browned out.", - "rank": 4, + "rank": 6, "reason": "Knockdowns are half as long, stuns 20% shorter, no slipping on water or fall damage. The best cheap chrome: load 1, 500 cr. Evicted by Hopper.", "tier": "green" }, @@ -833,7 +833,7 @@ "hash": "83d2c1a6b828b267", "name": "Slipwire reflex shunt", "note": "While the bearer has moved within the last second, a hostile projectile has a 15% chance to be dodged with a visible sidestep and the round flies on; shooters 1 tile away always hit, and it doesn't work lying down, buckled or unconscious. Also gives a small speed bonus (-0.1 slowdown); with other dodge sources only the highest chance counts, and everything here is off while the ware is EMP'd or browned out.", - "rank": 3, + "rank": 4, "reason": "15% of bullets miss while you're moving, never from an adjacent shooter, and you run slightly faster. Takes the slot Dead Channel wants. Load 4, 2 vouchers.", "tier": "yellow" }, @@ -853,7 +853,7 @@ "hash": "", "name": "Twitchwire", "note": "While the bearer has moved within the last second, a hostile projectile has an 8% chance to be dodged with a visible sidestep and the round flies on; shooters 1 tile away always hit, and it doesn't work lying down, buckled or unconscious. No speed bonus. With other dodge sources only the highest chance counts, and it is off while the ware is EMP'd or browned out.", - "rank": 7, + "rank": 9, "reason": "8% of bullets miss while you're moving, never from an adjacent shooter, and nothing else. A cheap taste of the Slipwire in the slot Dead Channel wants. Load 2, 1,800 cr at reputation 4.", "tier": "yellow" }, @@ -863,7 +863,7 @@ "hash": "", "name": "Subvocal mic", "note": "While online, a radio line the wearer speaks (headset, department or intercom) is spoken at range 0 instead of 1: the wearer and anyone on their tile hear it, a neighbour one tile away gets the usual garbled eavesdrop copy, and nobody further hears it. Ordinary talk is unchanged. It goes off during an EMP reboot or brownout.", - "rank": 8, + "rank": 10, "reason": "Radio talk is not overheard by anyone standing close. A small edge for sneaking and boarding. Load 1, 600 cr.", "tier": "green" }, @@ -872,7 +872,7 @@ "group": "Scouting and sight", "hash": "bc1a71fec9d46258", "name": "diagnostic HUD implant", - "rank": 5, + "rank": 6, "reason": "A diagnostic HUD with no glasses. Shows machine and robot health, for engineers.", "tier": "green" }, @@ -881,7 +881,7 @@ "group": "Scouting and sight", "hash": "4beab53dff56918d", "name": "medical HUD implant", - "rank": 3, + "rank": 4, "reason": "A health HUD with no glasses, same job as the green goggles but the eye slot stays free. About 30 cr of material, or found on two hulls.", "tier": "green" }, @@ -890,7 +890,7 @@ "group": "Scouting and sight", "hash": "91681c47158b6422", "name": "security HUD implant", - "rank": 4, + "rank": 5, "reason": "A security HUD with no glasses. Names and wanted status, nothing that wins a fight.", "tier": "green" }, @@ -899,7 +899,7 @@ "group": "Body upkeep", "hash": "81096fd3e55913d0", "name": "breathing tube implant", - "rank": 3, + "rank": 4, "reason": "Use internals with no mask, which frees the mask slot, and you can't be choked. About 2 cr of material after 200 research points.", "tier": "green" } diff --git a/tools/gear_catalog/ratings/guns.json b/tools/gear_catalog/ratings/guns.json index b08ade70873..745d237edf8 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -245,7 +245,7 @@ "hash": "4f9a01f4056d7085", "name": "Liberator", "note": "Single-shot barrel that takes a 2 s do-after to unscrew or screw back on. Every shot knocks 70-140 off its 100 integrity: on a fresh gun the first shot shatters it about 58% of the time (hurting the firing hand for up to 20 and sometimes embedding shrapnel), otherwise the next shot always does; point-blank doubles the shot's damage and aiming at someone held at gunpoint adds x1.5.", - "rank": 17, + "rank": 18, "reason": "Holds one round, and the barrel usually shatters within two shots. Its design disk is rare.", "score": 0, "score_inputs": { @@ -265,7 +265,7 @@ "group": "sidearms", "hash": "ed8c5fd09e92dcda", "name": "M1911", - "rank": 12, + "rank": 13, "reason": "Fires a Makarov's bullet with 8 rounds, one vested man per magazine at best.", "score": 35, "tier": "green" @@ -275,7 +275,7 @@ "group": "sidearms", "hash": "58fd32966ac76c57", "name": "M1911", - "rank": 13, + "rank": 14, "reason": "An M1911 that starts without its magazine, and the same gun once loaded.", "score": 35, "tier": "green" @@ -427,7 +427,7 @@ "group": "sidearms", "hash": "b18390c40543a655", "name": ".38 revolver", - "rank": 14, + "rank": 15, "reason": "Does 25 damage and weak wounds, with six shots then a slow reload.", "score": 18, "tier": "green" @@ -437,7 +437,7 @@ "group": "sidearms", "hash": "616fe18d05a3ece6", "name": "Colt Detective Special", - "rank": 15, + "rank": 16, "reason": "A .38 revolver by another name: 25 damage and six shots.", "score": 18, "tier": "green" @@ -622,7 +622,7 @@ "hash": "f1fd463d783a4af9", "name": "pipe pistol", "note": "Self-loading; one trigger pull empties its whole magazine in a rapid burst. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. Anyone can fire it (hulks included) and a point-blank shot throws the target back 3 tiles. / Bolt action that anyone can fire (hulks included), and a point-blank shot throws the target back 3 tiles. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. / Against robotic mobs it adds half the shot's own damage again as brute to the chest, which armor does not reduce. Junk variants (hunter, shock, reaper and so on) change which mobs are hit harder or what the extra damage is.", - "rank": 16, + "rank": 17, "reason": "Fires three weak rounds in a burst, then needs a hand reload.", "score": 3, "score_inputs": { diff --git a/tools/gear_catalog/ratings/tools.json b/tools/gear_catalog/ratings/tools.json index a96dbc5a3be..53f65496042 100644 --- a/tools/gear_catalog/ratings/tools.json +++ b/tools/gear_catalog/ratings/tools.json @@ -1446,7 +1446,7 @@ "group": "Mining tools", "hash": "16ae32694e632739", "name": "manual mining scanner", - "rank": 19, + "rank": 20, "reason": "Manual ore scanner. Click for a look at nearby ore. Common on hulls.", "tier": "none" }, @@ -1539,7 +1539,7 @@ "group": "Mining tools", "hash": "c2024f595b1ac958", "name": "ore sensor", - "rank": 20, + "rank": 21, "reason": "Crafted bone ore sensor. Small range and it needs brimdust to make.", "tier": "none" }, @@ -1557,7 +1557,7 @@ "group": "Mining tools", "hash": "971c85bf93572b1b", "name": "pickaxe", - "rank": 14, + "rank": 15, "reason": "Plain pick. Prints at round start and hits for 15, but it's heavy to carry.", "tier": "none" }, @@ -1566,7 +1566,7 @@ "group": "Mining tools", "hash": "b938dac87ca2790e", "name": "diamond-tipped pickaxe", - "rank": 7, + "rank": 8, "reason": "Digs in about a third of the normal time. 1,200 cr at the green waystation, a fair first upgrade.", "tier": "green" }, @@ -1584,7 +1584,7 @@ "group": "Mining tools", "hash": "a8467bba7110047c", "name": "mining drill", - "rank": 10, + "rank": 11, "reason": "Digs at a little over half the normal time. Cheap and printable after 40 research points. The usual first upgrade.", "tier": "green" }, @@ -1593,7 +1593,7 @@ "group": "Mining tools", "hash": "093655e5e5394734", "name": "rockjaw drill", - "rank": 8, + "rank": 9, "reason": "Implant drill. Half-time digging with your hands free. The implant is the real purchase.", "tier": "green" }, @@ -1629,7 +1629,7 @@ "group": "Mining tools", "hash": "971c85bf93572b1b", "name": "emergency disembarkation tool", - "rank": 15, + "rank": 16, "reason": "Plain-speed pick from emergency kits. Hits for 15, but it's heavy to carry.", "tier": "none" }, @@ -1638,7 +1638,7 @@ "group": "Mining tools", "hash": "e44656dab7dcaa9d", "name": "improvised pickaxe", - "rank": 17, + "rank": 18, "reason": "Taped-together pick at a third of the normal speed. Better than bare hands.", "tier": "none" }, @@ -1647,7 +1647,7 @@ "group": "Mining tools", "hash": "f0d10d8ea21ed503", "name": "compact pickaxe", - "rank": 14, + "rank": 15, "reason": "Plain-speed pick in a smaller size.", "tier": "none" }, @@ -1656,7 +1656,7 @@ "group": "Mining tools", "hash": "0dc2d79772498eca", "name": "rusty pickaxe", - "rank": 16, + "rank": 17, "reason": "Plain-speed pick from a scrap crate.", "tier": "none" }, @@ -1665,7 +1665,7 @@ "group": "Mining tools", "hash": "d955462ef7f3820e", "name": "silver-plated pickaxe", - "rank": 9, + "rank": 10, "reason": "Half-time digging for 1,000 mining points.", "tier": "green" }, @@ -1683,7 +1683,7 @@ "group": "Mining tools", "hash": "dab2a22ec6f7f472", "name": "material sniffer", - "rank": 22, + "rank": 23, "reason": "Crafted from an analyzer and some cable. Points at loose sheets of iron and glass. Minor.", "tier": "none" }, @@ -1710,7 +1710,7 @@ "group": "Mining tools", "hash": "adad44b7c8924f21", "name": "ventpointer", - "rank": 21, + "rank": 22, "reason": "Points at nearby ore vents. 800 mining points for a shortcut you can walk.", "tier": "none" }, @@ -2204,7 +2204,7 @@ "group": "Mining tools", "hash": "a8c7ee47f9855f4e", "name": "shovel", - "rank": 18, + "rank": 19, "reason": "Plain shovel, 8 force. Prints at round start.", "tier": "none" }, @@ -2224,7 +2224,7 @@ "hash": "39a661a6f4799dbe", "name": "dull bone shovel", "note": "With combat mode on, each hit on an organic mob (and each thrown hit) adds a second hit equal to its own force (10; 8 for the dull version) to the same limb, and does nothing extra to robots or other non-organic mobs. Digs at tool speed 0.3 (0.8 dull).", - "rank": 11, + "rank": 12, "reason": "Dull bone shovel: each hit on an organic lands twice for 16. Slower dig. One ruin.", "tier": "green" }, @@ -2233,7 +2233,7 @@ "group": "Mining tools", "hash": "6d36ec3489795240", "name": "spade", - "rank": 18, + "rank": 19, "reason": "Garden spade.", "tier": "none" }, @@ -2395,7 +2395,7 @@ "group": "Mining tools", "hash": "949739cf35df0bb0", "name": "advanced automatic mining scanner", - "rank": 12, + "rank": 13, "reason": "Shows ore within 7 tiles on its own. 300 cr at the green waystation. Right price, right place.", "tier": "green" }, @@ -2404,7 +2404,7 @@ "group": "Mining tools", "hash": "7c5b6654d9cb653b", "name": "automatic mining scanner", - "rank": 13, + "rank": 14, "reason": "Shows ore within 4 tiles on its own, more slowly. Common on hulls.", "tier": "green" }, diff --git a/voidcrew/modules/cyberware/ripperdoc_catalog.dm b/voidcrew/modules/cyberware/ripperdoc_catalog.dm index b52b5ed95f9..215de22217d 100644 --- a/voidcrew/modules/cyberware/ripperdoc_catalog.dm +++ b/voidcrew/modules/cyberware/ripperdoc_catalog.dm @@ -78,6 +78,30 @@ /datum/shop_sku/ripperdoc/governor_street, /datum/shop_sku/ripperdoc/redline_street, /datum/shop_sku/ripperdoc/cascade_street, + // The phase 2 street models, street passives, optics and decks. + /datum/shop_sku/ripperdoc/spyglass, + /datum/shop_sku/ripperdoc/cuffer_wrists, + /datum/shop_sku/ripperdoc/medic_palms, + /datum/shop_sku/ripperdoc/subvocal_mic, + /datum/shop_sku/ripperdoc/fixers_street, + /datum/shop_sku/ripperdoc/muffler_ear, + /datum/shop_sku/ripperdoc/clotter_node, + /datum/shop_sku/ripperdoc/assay_optics, + /datum/shop_sku/ripperdoc/coolant_street, + /datum/shop_sku/ripperdoc/frostline, + /datum/shop_sku/ripperdoc/atlas_street, + /datum/shop_sku/ripperdoc/doppler_street, + /datum/shop_sku/ripperdoc/deck_junker, + /datum/shop_sku/ripperdoc/second_wind_deep, + /datum/shop_sku/ripperdoc/slipwire_street, + /datum/shop_sku/ripperdoc/hopper_street, + /datum/shop_sku/ripperdoc/adrenal_pump, + /datum/shop_sku/ripperdoc/gorilla_street, + /datum/shop_sku/ripperdoc/rockjaw_deepcore, + /datum/shop_sku/ripperdoc/ronin_holdout, + /datum/shop_sku/ripperdoc/longsight, + /datum/shop_sku/ripperdoc/hardweave, + /datum/shop_sku/ripperdoc/deck_street, // ---- Pro Chrome (mixed) ---- /datum/shop_sku/ripperdoc/angler, /datum/shop_sku/ripperdoc/hemoglass, @@ -105,10 +129,17 @@ /datum/shop_sku/ripperdoc/governor_milspec, /datum/shop_sku/ripperdoc/redline_milspec, /datum/shop_sku/ripperdoc/cascade_milspec, + /datum/shop_sku/ripperdoc/hopper_milspec, + /datum/shop_sku/ripperdoc/slipwire_milspec, + /datum/shop_sku/ripperdoc/deadeye_milspec, + /datum/shop_sku/ripperdoc/lookout, + /datum/shop_sku/ripperdoc/deck_control, + /datum/shop_sku/ripperdoc/deck_assault, // ---- Legend Chrome (the chase, always listed) ---- /datum/shop_sku/ripperdoc/governor_delete, /datum/shop_sku/ripperdoc/redline, /datum/shop_sku/ripperdoc/cascade, + /datum/shop_sku/ripperdoc/deck_flagship, // ---- Ammunition (parlor-only reloads) ---- /datum/shop_sku/ripperdoc/ronin_mag, /datum/shop_sku/ripperdoc/buster_rockets, @@ -126,6 +157,30 @@ /datum/shop_buyback/ripperdoc/governor_street, /datum/shop_buyback/ripperdoc/redline_street, /datum/shop_buyback/ripperdoc/cascade_street, + // The phase 2 street models, street passives, optics and decks. + /datum/shop_buyback/ripperdoc/spyglass, + /datum/shop_buyback/ripperdoc/cuffer_wrists, + /datum/shop_buyback/ripperdoc/medic_palms, + /datum/shop_buyback/ripperdoc/subvocal_mic, + /datum/shop_buyback/ripperdoc/fixers_street, + /datum/shop_buyback/ripperdoc/muffler_ear, + /datum/shop_buyback/ripperdoc/clotter_node, + /datum/shop_buyback/ripperdoc/assay_optics, + /datum/shop_buyback/ripperdoc/coolant_street, + /datum/shop_buyback/ripperdoc/frostline, + /datum/shop_buyback/ripperdoc/atlas_street, + /datum/shop_buyback/ripperdoc/doppler_street, + /datum/shop_buyback/ripperdoc/deck_junker, + /datum/shop_buyback/ripperdoc/second_wind_deep, + /datum/shop_buyback/ripperdoc/slipwire_street, + /datum/shop_buyback/ripperdoc/hopper_street, + /datum/shop_buyback/ripperdoc/adrenal_pump, + /datum/shop_buyback/ripperdoc/gorilla_street, + /datum/shop_buyback/ripperdoc/rockjaw_deepcore, + /datum/shop_buyback/ripperdoc/ronin_holdout, + /datum/shop_buyback/ripperdoc/longsight, + /datum/shop_buyback/ripperdoc/hardweave, + /datum/shop_buyback/ripperdoc/deck_street, ) trader_lines = list( TRADER_LINE_GREETING = list( @@ -530,6 +585,8 @@ desc = "A used air reserve. Worth half toward something with a longer bladder." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/second_wind + // The Deep-lung is a subtype with its own, higher entry (ware_models_b.dm). + match_subtypes = FALSE pay_credits = 450 demand_min = 2 demand_max = 3 @@ -539,6 +596,8 @@ desc = "Light plating you've outgrown. Splice recycles the weave toward the heavy slab." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/dermal_mesh + // The Hardweave is a subtype with its own, higher entry (ware_models_b.dm). + match_subtypes = FALSE pay_credits = 1000 demand_min = 1 demand_max = 2 diff --git a/voidcrew/modules/cyberware/street_doc_catalog.dm b/voidcrew/modules/cyberware/street_doc_catalog.dm index e4629d9d5c3..87dc9c8e7ea 100644 --- a/voidcrew/modules/cyberware/street_doc_catalog.dm +++ b/voidcrew/modules/cyberware/street_doc_catalog.dm @@ -78,6 +78,31 @@ /datum/shop_sku/ripperdoc/governor_street, /datum/shop_sku/ripperdoc/redline_street, /datum/shop_sku/ripperdoc/cascade_street, + // Phase 2: the street models, street passives, optics and the two + // credit decks. Nothing priced in vouchers belongs here. + /datum/shop_sku/ripperdoc/spyglass, + /datum/shop_sku/ripperdoc/cuffer_wrists, + /datum/shop_sku/ripperdoc/medic_palms, + /datum/shop_sku/ripperdoc/subvocal_mic, + /datum/shop_sku/ripperdoc/fixers_street, + /datum/shop_sku/ripperdoc/muffler_ear, + /datum/shop_sku/ripperdoc/clotter_node, + /datum/shop_sku/ripperdoc/assay_optics, + /datum/shop_sku/ripperdoc/coolant_street, + /datum/shop_sku/ripperdoc/frostline, + /datum/shop_sku/ripperdoc/atlas_street, + /datum/shop_sku/ripperdoc/doppler_street, + /datum/shop_sku/ripperdoc/deck_junker, + /datum/shop_sku/ripperdoc/second_wind_deep, + /datum/shop_sku/ripperdoc/slipwire_street, + /datum/shop_sku/ripperdoc/hopper_street, + /datum/shop_sku/ripperdoc/adrenal_pump, + /datum/shop_sku/ripperdoc/gorilla_street, + /datum/shop_sku/ripperdoc/rockjaw_deepcore, + /datum/shop_sku/ripperdoc/ronin_holdout, + /datum/shop_sku/ripperdoc/longsight, + /datum/shop_sku/ripperdoc/hardweave, + /datum/shop_sku/ripperdoc/deck_street, // CYBERWARE LINES: T2 SKUS END ) buyback_types = list( @@ -92,6 +117,29 @@ /datum/shop_buyback/ripperdoc/governor_street/street_doc, /datum/shop_buyback/ripperdoc/redline_street/street_doc, /datum/shop_buyback/ripperdoc/cascade_street/street_doc, + /datum/shop_buyback/ripperdoc/spyglass/street_doc, + /datum/shop_buyback/ripperdoc/cuffer_wrists/street_doc, + /datum/shop_buyback/ripperdoc/medic_palms/street_doc, + /datum/shop_buyback/ripperdoc/subvocal_mic/street_doc, + /datum/shop_buyback/ripperdoc/fixers_street/street_doc, + /datum/shop_buyback/ripperdoc/muffler_ear/street_doc, + /datum/shop_buyback/ripperdoc/clotter_node/street_doc, + /datum/shop_buyback/ripperdoc/assay_optics/street_doc, + /datum/shop_buyback/ripperdoc/coolant_street/street_doc, + /datum/shop_buyback/ripperdoc/frostline/street_doc, + /datum/shop_buyback/ripperdoc/atlas_street/street_doc, + /datum/shop_buyback/ripperdoc/doppler_street/street_doc, + /datum/shop_buyback/ripperdoc/deck_junker/street_doc, + /datum/shop_buyback/ripperdoc/second_wind_deep/street_doc, + /datum/shop_buyback/ripperdoc/slipwire_street/street_doc, + /datum/shop_buyback/ripperdoc/hopper_street/street_doc, + /datum/shop_buyback/ripperdoc/adrenal_pump/street_doc, + /datum/shop_buyback/ripperdoc/gorilla_street/street_doc, + /datum/shop_buyback/ripperdoc/rockjaw_deepcore/street_doc, + /datum/shop_buyback/ripperdoc/ronin_holdout/street_doc, + /datum/shop_buyback/ripperdoc/longsight/street_doc, + /datum/shop_buyback/ripperdoc/hardweave/street_doc, + /datum/shop_buyback/ripperdoc/deck_street/street_doc, // CYBERWARE LINES: T2 BUYBACKS END ) trader_lines = list( diff --git a/voidcrew/modules/cyberware/ware_line_deck.dm b/voidcrew/modules/cyberware/ware_line_deck.dm index 03be29bbf9b..251e4b7a8ff 100644 --- a/voidcrew/modules/cyberware/ware_line_deck.dm +++ b/voidcrew/modules/cyberware/ware_line_deck.dm @@ -13,8 +13,8 @@ * * Model tier load range upload cooldowns adds * Junker T1 2 5 2 s x1.5 Bolts, Burst the Lights, Ping - * Street T2 4 6 1.5 s x1.5 Kill the Power, Cutout - * Facilities T3 6 7 1 s x1 Electrify, Silence, Reboot Turret + * Street T2 4 6 1.5 s x1.5 Kill the Power + * Facilities T3 6 7 1 s x1 Cutout, Electrify, Silence, Reboot Turret * Breacher T3 6 7 1 s x1 Overload, Feedback Surge, Glitch * Blackbox T4 9 8 0.5 s x1 both T3 sets, and Arc Flash * @@ -32,7 +32,8 @@ * - One shared cooldown, set by the hack that landed, like the boon's. * - EMP and brownout kill the deck (the cyberware action bridge); Chrome Read * shows it. - * - Outpost property refuses every hack outright, and Overload refuses any + * - Outpost property and outpost prison doors (cells and staff doors) refuse + * every hack outright, so a deck never springs a prisoner. Overload refuses any * machine on an outpost, trader or player (owner, D12: decks never blow up * outpost machines). A person hacked at a trader outpost counts as an attack * on them there, the same as a punch, and Arc Flash does not run there at all. @@ -112,7 +113,6 @@ /datum/machine_quickhack/bolts, /datum/machine_quickhack/burst_lights, /datum/machine_quickhack/kill_power, - /datum/machine_quickhack/cutout, ) hack_range = 6 upload_time = 1.5 SECONDS @@ -149,7 +149,6 @@ /datum/machine_quickhack/bolts, /datum/machine_quickhack/burst_lights, /datum/machine_quickhack/kill_power, - /datum/machine_quickhack/cutout, /datum/machine_quickhack/overload, /datum/machine_quickhack/feedback_surge, /datum/machine_quickhack/glitch, @@ -281,7 +280,8 @@ // ---- Reach ------------------------------------------------------------- /// Whether the deck can work on this target from here: the bearer can see, -/// the target is not outpost property, and it is in range and in a clear line. +/// the target is not outpost property or a prison door, and it is in range and +/// in a clear line. /datum/action/cooldown/cyberware/deck/proc/can_reach(mob/living/carbon/bearer, atom/target, feedback = FALSE) var/obj/item/organ/cyberimp/cyberware/deck/deck = get_deck() if(!deck || QDELETED(target)) @@ -290,7 +290,8 @@ if(feedback) bearer.balloon_alert(bearer, "can't see!") return FALSE - if(HAS_TRAIT(target, TRAIT_OUTPOST_PROPERTY)) + // Prison doors too, cells and staff doors alike: no prison break by deck. + if(HAS_TRAIT(target, TRAIT_OUTPOST_PROPERTY) || is_outpost_prison_airlock(target)) if(feedback) target.balloon_alert(bearer, "hardened firmware!") return FALSE diff --git a/voidcrew/modules/cyberware/ware_line_models_a.dm b/voidcrew/modules/cyberware/ware_line_models_a.dm index f46d1baff4b..03c36ecbe1d 100644 --- a/voidcrew/modules/cyberware/ware_line_models_a.dm +++ b/voidcrew/modules/cyberware/ware_line_models_a.dm @@ -262,8 +262,8 @@ examine_list += span_notice("[source.p_They()] [source.p_have()] a revolver housing set into [source.p_their()] [parse_zone(zone)].") // ========================================================================= -// SPLICE SKUS: not listed on any vendor yet, the integration pass adds them to -// the Chop Shop's sku_types (and the street doc's, for the credit-priced ones). +// SPLICE SKUS: on the Chop Shop's sku_types (ripperdoc_catalog.dm), and the +// credit-priced ones on the Back Bay's too (street_doc_catalog.dm). // ========================================================================= /datum/shop_sku/ripperdoc/hopper_street @@ -313,8 +313,7 @@ // ========================================================================= // INTAKE LEDGERS: half the shelf price, credits only, like the other street -// rungs. Not listed on any buyback_types yet, same integration pass as above. -// The Vaulter costs vouchers, which can't be refunded, and the Deepcore has no +// rungs. The Vaulter costs vouchers, which can't be refunded, so it has no // entry. Ratchet's subtypes carry his wording and are listed beside Splice's. // ========================================================================= @@ -357,6 +356,19 @@ /datum/shop_buyback/ripperdoc/fixers_street/street_doc desc = "A used set of fingertip tools, a bit loose." +/datum/shop_buyback/ripperdoc/rockjaw_deepcore + name = "Deepcore drill fist (pulled)" + desc = "A used drill fist. The bit's still sharp." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore + match_subtypes = FALSE + pay_credits = 1200 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/rockjaw_deepcore/street_doc + desc = "A used drill fist, ore hopper and all." + /datum/shop_buyback/ripperdoc/ronin_holdout name = "Holdout housing (pulled)" desc = "A used revolver housing." diff --git a/voidcrew/modules/cyberware/ware_second_wind.dm b/voidcrew/modules/cyberware/ware_second_wind.dm index 2cb0e5055a2..1bedd34217e 100644 --- a/voidcrew/modules/cyberware/ware_second_wind.dm +++ b/voidcrew/modules/cyberware/ware_second_wind.dm @@ -130,7 +130,7 @@ if(engaged) set_engaged(source, FALSE) source.balloon_alert(source, "air reserve empty!") - to_chat(source, span_userdanger("Your Second Wind bladder collapses empty. You are breathing vacuum.")) + to_chat(source, span_userdanger("Your [format_text(name)] collapses empty. You are breathing vacuum.")) playsound(source, 'sound/machines/buzz/buzz-sigh.ogg', 40, TRUE) return NONE if(!engaged) @@ -154,7 +154,7 @@ engaged = new_state if(engaged) INVOKE_ASYNC(source, TYPE_PROC_REF(/mob, emote), "gasp") - source.balloon_alert(source, "second wind engages") + source.balloon_alert(source, "[LOWER_TEXT(format_text(name))] engages") source.visible_message( span_notice("[source] takes a sharp breath, and something under [source.p_their()] ribs starts working."), span_notice("SEAL ENGAGED. The bladder takes your breathing off you. You have [round(reserve / (1 SECONDS))] seconds of air."), @@ -166,7 +166,7 @@ breath_loop?.start(source) update_alert(source) return - source.balloon_alert(source, "second wind disengages") + source.balloon_alert(source, "[LOWER_TEXT(format_text(name))] disengages") to_chat(source, span_notice("SEAL RELEASED. You are breathing the room again.")) cyberware_ink_pulse(source, CYBERWARE_INK_SOFT) breath_loop?.stop() @@ -188,7 +188,9 @@ var/seconds_left = round(reserve / (1 SECONDS)) var/running_low = reserve <= CYBERWARE_SECOND_WIND_LOW indicator.color = running_low ? "#ffb347" : "#4dd8e6" - indicator.desc = "Your Second Wind bladder is doing your breathing for you. [seconds_left] second\s of reserve air left[running_low ? ". Find air." : "."]" + // Named for the bladder actually fitted, so a Deep-lung never reads as a Second Wind. + indicator.name = "[format_text(name)] engaged" + indicator.desc = "Your [format_text(name)] is doing your breathing for you. [seconds_left] second\s of reserve air left[running_low ? ". Find air." : "."]" /** * Whether the mob's surroundings hold enough oxygen to breathe unassisted: diff --git a/voidcrew/modules/loot/themes/armory.dm b/voidcrew/modules/loot/themes/armory.dm index 689cc88ea01..563ced80412 100644 --- a/voidcrew/modules/loot/themes/armory.dm +++ b/voidcrew/modules/loot/themes/armory.dm @@ -145,6 +145,12 @@ /datum/shop_sku/ripperdoc/governor_milspec, /datum/shop_sku/ripperdoc/redline_street, /datum/shop_sku/ripperdoc/redline_milspec, + /datum/shop_sku/ripperdoc/cuffer_wrists, + /datum/shop_sku/ripperdoc/muffler_ear, + /datum/shop_sku/ripperdoc/hardweave, + /datum/shop_sku/ripperdoc/deadeye_milspec, + /datum/shop_sku/ripperdoc/lookout, + /datum/shop_sku/ripperdoc/deck_control, ) loot_common = list( /obj/item/clothing/suit/armor/vest = 10, @@ -155,6 +161,10 @@ /obj/item/clothing/glasses/hud/security/sunglasses = 5, /obj/item/melee/baton/security/loaded = 4, /obj/item/clothing/mask/balaclava = 4, + // guard-post chrome: faster hands with the cuffs, and an ear that + // shuts out a flashbang + /obj/item/organ/cyberimp/cyberware/cuffer_wrists = 4, + /obj/item/organ/cyberimp/cyberware/muffler_ear = 4, ) loot_uncommon = list( /obj/item/clothing/suit/armor/bulletproof = 10, @@ -169,6 +179,8 @@ // a patched limiter and a cheap rage core, both off a deserter /obj/item/organ/cyberimp/cyberware/governor_delete/street = 2, /obj/item/organ/cyberimp/cyberware/redline/street = 2, + // the heavier issue weave + /obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave = 3, ) loot_prime = list( /obj/item/clothing/suit/armor/vest/marine = 9, @@ -185,6 +197,10 @@ // surplus service chrome with the unit numbers still on it /obj/item/organ/cyberimp/cyberware/governor_delete/milspec = 2, /obj/item/organ/cyberimp/cyberware/redline/milspec = 2, + // the service optics, and the facilities deck a garrison ran its doors with + /obj/item/organ/eyes/robotic/cyberware/deadeye/milspec = 2, + /obj/item/organ/eyes/robotic/cyberware/lookout = 2, + /obj/item/organ/cyberimp/cyberware/deck/control = 2, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable diff --git a/voidcrew/modules/loot/themes/expedition.dm b/voidcrew/modules/loot/themes/expedition.dm index 5dfa9fbb1a7..37c8226e134 100644 --- a/voidcrew/modules/loot/themes/expedition.dm +++ b/voidcrew/modules/loot/themes/expedition.dm @@ -42,6 +42,14 @@ /datum/shop_sku/ripperdoc/angler, /datum/shop_sku/ripperdoc/prospector, /datum/shop_sku/ripperdoc/hopper, + /datum/shop_sku/ripperdoc/frostline, + /datum/shop_sku/ripperdoc/spyglass, + /datum/shop_sku/ripperdoc/assay_optics, + /datum/shop_sku/ripperdoc/hopper_street, + /datum/shop_sku/ripperdoc/rockjaw_deepcore, + /datum/shop_sku/ripperdoc/longsight, + /datum/shop_sku/ripperdoc/deck_junker, + /datum/shop_sku/ripperdoc/hopper_milspec, ) loot_common = list( /obj/item/clothing/suit/hooded/explorer = 10, @@ -55,6 +63,10 @@ // prospector's chrome, cheapest rung: pistons that get you off the // ground fast and hold your footing on bad decking /obj/item/organ/cyberimp/cyberware/shock_coils = 4, + // a cold lining for the ice worlds, and cheap eyes that see a long way + /obj/item/organ/cyberimp/cyberware/frostline = 3, + /obj/item/organ/eyes/robotic/cyberware/spyglass = 3, + /obj/item/organ/eyes/robotic/cyberware/prospector/street = 3, ) loot_uncommon = list( /obj/item/gun/energy/recharge/kinetic_accelerator = 10, @@ -70,6 +82,12 @@ // and hoppers its own ore, and a harpoon winch for everything else /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw = 4, /obj/item/organ/cyberimp/arm/toolkit/cyberware/angler = 3, + // claim chrome a step up: calf springs, the heavy drill fist, long + // optics, and a junk deck for the depot doors + /obj/item/organ/cyberimp/cyberware/hopper/street = 3, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore = 3, + /obj/item/organ/eyes/robotic/cyberware/longsight = 2, + /obj/item/organ/cyberimp/cyberware/deck = 3, ) loot_prime = list( /obj/item/borg/upgrade/modkit/range = 9, @@ -83,6 +101,8 @@ /obj/item/organ/eyes/robotic/cyberware/prospector = 3, /obj/item/organ/cyberimp/arm/toolkit/cyberware/skyhook = 3, /obj/item/organ/cyberimp/cyberware/hopper = 2, + // the survey team's jump pistons + /obj/item/organ/cyberimp/cyberware/hopper/milspec = 2, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable diff --git a/voidcrew/modules/loot/themes/industrial.dm b/voidcrew/modules/loot/themes/industrial.dm index b9d65961ebb..7184174274d 100644 --- a/voidcrew/modules/loot/themes/industrial.dm +++ b/voidcrew/modules/loot/themes/industrial.dm @@ -8,7 +8,8 @@ // (see cave_entrance.dm): industrial plant equipment, uncommon-tier. // - cyberware: shop-floor chrome, the kind a plant fits to keep a line // running, omnitool fingers, then the coolant lattice for hot work and -// the myomer arms that pull a dead airlock open. +// the myomer arms that pull a dead airlock open. The cheap myomer pair +// is a yellow melee extra, the real Gorilla Arms a red one. // TODO: review/balance-pass all four tiers: first-draft weights and // contents, never playtested. Tune against outpost shop material prices and // the Boffin stall parts SKUs in theme_skus. @@ -26,6 +27,9 @@ ), ) melee_extras = list( + LOOT_WEAPON_YELLOW = list( + /obj/item/cyberware_pair_case/gorilla_arms/street = 1, + ), LOOT_WEAPON_RED = list( /obj/item/cyberware_pair_case/gorilla_arms = 1, ), @@ -41,6 +45,11 @@ /datum/shop_sku/ripperdoc/fixers, /datum/shop_sku/ripperdoc/coolant, /datum/shop_sku/ripperdoc/gorilla, + /datum/shop_sku/ripperdoc/coolant_street, + /datum/shop_sku/ripperdoc/fixers_street, + /datum/shop_sku/ripperdoc/deck_junker, + /datum/shop_sku/ripperdoc/atlas_street, + /datum/shop_sku/ripperdoc/gorilla_street, ) loot_common = list( /obj/item/stack/sheet/iron/fifty = 10, @@ -55,6 +64,11 @@ /obj/item/weldingtool/largetank = 5, /obj/item/stock_parts/power_store/cell/high = 4, /obj/item/stack/sheet/plasteel/twenty = 4, + // line-worker chrome: a heat-sink lining, loose fingertip tools, and a + // deck soldered out of a dead terminal + /obj/item/organ/cyberimp/cyberware/coolant/street = 3, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street = 4, + /obj/item/organ/cyberimp/cyberware/deck = 3, ) loot_uncommon = list( /obj/item/stack/sheet/mineral/titanium/fifty = 8, @@ -74,6 +88,8 @@ // the maintenance crew's chrome: driver, wrench and cutters folded // into the knuckles, and faster hands behind them /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers = 4, + // a loader's strut frame + /obj/item/organ/cyberimp/cyberware/atlas/street = 3, ) loot_prime = list( /obj/item/stack/sheet/plasteel/fifty = 9, diff --git a/voidcrew/modules/loot/themes/medical.dm b/voidcrew/modules/loot/themes/medical.dm index 42af4e43537..3ca9340701e 100644 --- a/voidcrew/modules/loot/themes/medical.dm +++ b/voidcrew/modules/loot/themes/medical.dm @@ -34,6 +34,9 @@ /datum/shop_sku/ripperdoc/hemoglass, /datum/shop_sku/ripperdoc/dead_channel, /datum/shop_sku/ripperdoc/lazarus, + /datum/shop_sku/ripperdoc/clotter_node, + /datum/shop_sku/ripperdoc/medic_palms, + /datum/shop_sku/ripperdoc/second_wind_deep, ) loot_common = list( /obj/item/storage/medkit/regular = 10, @@ -51,6 +54,9 @@ // cheapest thing on the parlor's shelf and the one piece of chrome a // clinic would actually stock: it replaces a stomach /obj/item/organ/cyberimp/cyberware/gastro = 4, + // clinic chrome: a clotting node and a medic's sensor palms + /obj/item/organ/cyberimp/cyberware/clotter_node = 4, + /obj/item/organ/cyberimp/cyberware/medic_palms = 3, ) loot_uncommon = list( /obj/item/storage/medkit/advanced = 10, @@ -74,6 +80,8 @@ // reserve and a blood filter, both things a med bay would have fitted /obj/item/organ/cyberimp/cyberware/second_wind = 4, /obj/item/organ/cyberimp/cyberware/hemoglass = 3, + // the long-shift air bladder + /obj/item/organ/cyberimp/cyberware/second_wind/deep = 3, ) loot_prime = list( /obj/item/reagent_containers/hypospray/medipen/atropine = 7, diff --git a/voidcrew/modules/loot/themes/plunder.dm b/voidcrew/modules/loot/themes/plunder.dm index cfe53ef0321..7241c664160 100644 --- a/voidcrew/modules/loot/themes/plunder.dm +++ b/voidcrew/modules/loot/themes/plunder.dm @@ -7,7 +7,8 @@ // Cyberware: boarding chrome, and it reads as taken off somebody rather than // bought, grip pads at uncommon, and in the treasure the blade pair, the // folding machine-pistol and the rocket pod. The cheap shiv pair is a yellow -// melee extra, the heat-sunk blade pair a red one beside the Mantis. +// melee extra, the heat-sunk blade pair a red one beside the Mantis, and the +// holdout revolver arm a yellow gun extra. // TODO: review/balance-pass all four tiers: first-draft weights and // contents, never playtested. // ========================================================================= @@ -23,6 +24,7 @@ /obj/item/gun/ballistic/revolver/mateba = 1, /obj/item/gun/ballistic/shotgun/musket/no_quarter = 1, /obj/item/gun/magic/hook/marlinspike = 1, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout = 1, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/shotgun/automatic/combat = 1, @@ -50,6 +52,7 @@ /datum/shop_sku/ripperdoc/mantis_scorch, /datum/shop_sku/ripperdoc/ronin, /datum/shop_sku/ripperdoc/bunker_buster, + /datum/shop_sku/ripperdoc/ronin_holdout, ) loot_common = list( /obj/item/stack/spacecash/c100 = 10, diff --git a/voidcrew/modules/loot/themes/research.dm b/voidcrew/modules/loot/themes/research.dm index dffb832ac37..11541147cbc 100644 --- a/voidcrew/modules/loot/themes/research.dm +++ b/voidcrew/modules/loot/themes/research.dm @@ -36,6 +36,9 @@ theme_skus = list( /datum/shop_sku/ripperdoc/doppler, /datum/shop_sku/ripperdoc/rigger, + /datum/shop_sku/ripperdoc/doppler_street, + /datum/shop_sku/ripperdoc/longsight, + /datum/shop_sku/ripperdoc/lookout, ) loot_common = list( /obj/item/storage/box/beakers = 10, @@ -62,6 +65,9 @@ // lab hardware worn instead of carried: a sonar transceiver socketed // into the bone behind the ear, reading movement through walls /obj/item/organ/cyberimp/cyberware/doppler = 3, + // a cheaper sonar ear, and survey optics off the long-range bench + /obj/item/organ/cyberimp/cyberware/doppler/street = 3, + /obj/item/organ/eyes/robotic/cyberware/longsight = 2, ) loot_prime = list( /obj/item/stack/ore/bluespace_crystal/refined = 10, @@ -85,6 +91,8 @@ // the annex's own interface work: a skull jack that flies a ship from // anywhere aboard it, as long as the hull still has a helm console /obj/item/organ/cyberimp/cyberware/rigger = 2, + // the observation post's eyes + /obj/item/organ/eyes/robotic/cyberware/lookout = 2, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable diff --git a/voidcrew/modules/loot/themes/syndicate.dm b/voidcrew/modules/loot/themes/syndicate.dm index 0ee58a58926..24ef1326047 100644 --- a/voidcrew/modules/loot/themes/syndicate.dm +++ b/voidcrew/modules/loot/themes/syndicate.dm @@ -53,6 +53,12 @@ /datum/shop_sku/ripperdoc/cascade_street, /datum/shop_sku/ripperdoc/cascade_milspec, /datum/shop_sku/ripperdoc/monowire_live, + /datum/shop_sku/ripperdoc/subvocal_mic, + /datum/shop_sku/ripperdoc/slipwire_street, + /datum/shop_sku/ripperdoc/adrenal_pump, + /datum/shop_sku/ripperdoc/deck_street, + /datum/shop_sku/ripperdoc/slipwire_milspec, + /datum/shop_sku/ripperdoc/deck_assault, ) loot_common = list( /obj/item/soap/syndie = 10, @@ -62,6 +68,8 @@ /obj/item/encryptionkey/syndicate = 6, /obj/item/grenade/empgrenade = 5, /obj/item/storage/medkit/tactical = 4, + // a throat mic nobody nearby hears + /obj/item/organ/cyberimp/cyberware/subvocal_mic = 4, ) loot_uncommon = list( /obj/item/grenade/c4 = 6, @@ -77,6 +85,11 @@ // knockoff operative chrome: a patchy camo weave and a taped reflex rig /obj/item/organ/cyberimp/cyberware/ghostskin/street = 3, /obj/item/organ/cyberimp/cyberware/cascade/street = 2, + // street operative chrome: a twitchy reflex shunt, an adrenal pump and + // a deck flashed with somebody else's codes + /obj/item/organ/cyberimp/cyberware/slipwire/street = 3, + /obj/item/organ/cyberimp/cyberware/adrenal_pump = 3, + /obj/item/organ/cyberimp/cyberware/deck/street = 2, ) loot_prime = list( /obj/item/pen/sleepy = 4, @@ -86,6 +99,9 @@ /obj/item/organ/cyberimp/cyberware/slipwire = 3, /obj/item/organ/cyberimp/cyberware/ghostskin = 2, /obj/item/organ/cyberimp/cyberware/cascade/milspec = 2, + // the boarding team's shunt and deck + /obj/item/organ/cyberimp/cyberware/slipwire/milspec = 2, + /obj/item/organ/cyberimp/cyberware/deck/assault = 2, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable diff --git a/voidcrew/modules/loot/themes/wardrobe.dm b/voidcrew/modules/loot/themes/wardrobe.dm index 737a9710b90..466de0d6d2b 100644 --- a/voidcrew/modules/loot/themes/wardrobe.dm +++ b/voidcrew/modules/loot/themes/wardrobe.dm @@ -32,6 +32,7 @@ theme_skus = list( /datum/shop_sku/ripperdoc/chromatic_dermis, /datum/shop_sku/ripperdoc/nightshade, + /datum/shop_sku/ripperdoc/spyglass, ) loot_common = list( /obj/item/clothing/suit/jacket/leather = 10, @@ -48,6 +49,8 @@ // somebody's unused parlor appointment: tattoo ink you wear under // the skin, colour and pattern set at a Cradle /obj/item/organ/cyberimp/cyberware/chromatic_dermis = 4, + // cheap zoom eyes, bought for the look as much as the view + /obj/item/organ/eyes/robotic/cyberware/spyglass = 3, ) loot_uncommon = list( /obj/item/clothing/suit/jacket/leather/biker = 10, diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm index 0b13b3b60cd..fce3404c49d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm @@ -116,6 +116,12 @@ TEST_ASSERT_EQUAL(length(rogue.get_quickhacks()), 1, "A kit listing a boon-only hack kept it") TEST_ASSERT(!length(rogue.get_masshacks()), "A kit listing Wake the Machines kept it") + // Cutout empties a person's cells: a facilities hack, not a street one. + TEST_ASSERT(!(/datum/machine_quickhack/cutout in street.quickhacks), "The Street deck carries Cutout") + TEST_ASSERT(!(/datum/machine_quickhack/cutout in assault.quickhacks), "The Breacher deck carries Cutout") + TEST_ASSERT(/datum/machine_quickhack/cutout in control.quickhacks, "The Facilities deck lost Cutout") + TEST_ASSERT(/datum/machine_quickhack/cutout in flagship.quickhacks, "The Blackbox deck lost Cutout") + // Higher models carry everything below them. var/list/ladder = list( list(junker, street), @@ -183,6 +189,14 @@ TEST_ASSERT(!action.Activate(property), "The deck started a hack on outpost property") TEST_ASSERT(!action.busy && !QDELETED(property), "The deck went to work on outpost property") + // Outpost prison doors refuse a deck the same way: no prison break by deck. + var/obj/machinery/door/airlock/security/glass/outpost_prison_cell/cell_door = allocate(/obj/machinery/door/airlock/security/glass/outpost_prison_cell, get_step(start, NORTHEAST)) + TEST_ASSERT(!action.can_reach(user, cell_door), "The deck reaches an outpost prison cell door") + var/obj/machinery/door/airlock/security/prison_staff/staff_door = allocate(/obj/machinery/door/airlock/security/prison_staff, get_step(get_step(start, EAST), EAST)) + TEST_ASSERT(!action.can_reach(user, staff_door), "The deck reaches an outpost prison staff door") + var/obj/machinery/door/airlock/plain_door = allocate(/obj/machinery/door/airlock, get_step(get_step(start, NORTH), NORTH)) + TEST_ASSERT(action.can_reach(user, plain_door), "The deck can't reach an ordinary door two tiles off") + /// An upload lands only if the hacker holds still and stays online: a step or /// an EMP loses it and costs no cooldown. A clean upload lands and charges the /// deck's cooldown, a hack the target would not take is never offered, and the diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm index 15c8ba4a124..379d17f0aa8 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm @@ -359,6 +359,7 @@ list(/datum/shop_buyback/ripperdoc/gorilla_street, /datum/shop_buyback/ripperdoc/gorilla_street/street_doc, /obj/item/organ/cyberimp/cyberware/gorilla_arms/street, 600), list(/datum/shop_buyback/ripperdoc/fixers_street, /datum/shop_buyback/ripperdoc/fixers_street/street_doc, /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street, 350), list(/datum/shop_buyback/ripperdoc/ronin_holdout, /datum/shop_buyback/ripperdoc/ronin_holdout/street_doc, /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout, 1200), + list(/datum/shop_buyback/ripperdoc/rockjaw_deepcore, /datum/shop_buyback/ripperdoc/rockjaw_deepcore/street_doc, /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore, 1200), ) for(var/list/row as anything in ledgers) for(var/ledger_index in 1 to 2) @@ -378,3 +379,5 @@ TEST_ASSERT(!spring_ledger.matches(allocate(/obj/item/organ/cyberimp/cyberware/hopper/milspec)), "The Spring calves ledger takes Vaulter pistons") var/datum/shop_buyback/tinker_ledger = allocate(/datum/shop_buyback/ripperdoc/fixers_street) TEST_ASSERT(!tinker_ledger.matches(allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers)), "The Tinker's ledger takes the Fixer's Fingers") + var/datum/shop_buyback/deepcore_ledger = allocate(/datum/shop_buyback/ripperdoc/rockjaw_deepcore) + TEST_ASSERT(!deepcore_ledger.matches(allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw)), "The Deepcore ledger takes a plain Rockjaw") diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm index 038ab1c21c9..9787ce235d0 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm @@ -384,6 +384,15 @@ TEST_ASSERT_EQUAL(ratchet.pay_credits, ledger.pay_credits, "The street doc's [ledger_type] pays a different amount") TEST_ASSERT(ratchet.desc != ledger.desc, "The street doc's [ledger_type] has no wording of his own") + // The parents' own entries take only the parent, so a Hardweave or a + // Deep-lung never sells at the cheaper piece's rate. + var/datum/shop_buyback/mesh_ledger = allocate(/datum/shop_buyback/ripperdoc/dermal_mesh) + TEST_ASSERT(mesh_ledger.matches(allocate(/obj/item/organ/cyberimp/cyberware/dermal_mesh)), "The Dermal Mesh ledger won't take a Dermal Mesh") + TEST_ASSERT(!mesh_ledger.matches(allocate(/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave)), "The Dermal Mesh ledger takes a Hardweave") + var/datum/shop_buyback/bladder_ledger = allocate(/datum/shop_buyback/ripperdoc/second_wind) + TEST_ASSERT(bladder_ledger.matches(allocate(/obj/item/organ/cyberimp/cyberware/second_wind)), "The Second Wind ledger won't take a Second Wind") + TEST_ASSERT(!bladder_ledger.matches(allocate(/obj/item/organ/cyberimp/cyberware/second_wind/deep)), "The Second Wind ledger takes a Deep-lung") + // Vouchers cannot be refunded: the Weaver shunt has no entry anywhere. for(var/buyback_type in typesof(/datum/shop_buyback/ripperdoc)) var/datum/shop_buyback/ledger = buyback_type diff --git a/voidcrew/modules/unit_tests/voidcrew_loot.dm b/voidcrew/modules/unit_tests/voidcrew_loot.dm index 0e8b768e64c..927830660d4 100644 --- a/voidcrew/modules/unit_tests/voidcrew_loot.dm +++ b/voidcrew/modules/unit_tests/voidcrew_loot.dm @@ -735,6 +735,7 @@ /obj/item/claymore/cutlass/old, /obj/item/claymore/weak, /obj/item/crowbar/large/twenty_force, + /obj/item/cyberware_pair_case/gorilla_arms/street, /obj/item/cyberware_pair_case/mantis_street, /obj/item/fireaxe/boardingaxe, /obj/item/fireaxe/boneaxe, @@ -792,6 +793,7 @@ /obj/item/nullrod/vibro, /obj/item/nullrod/vibro/spellblade, /obj/item/nullrod/vibro/talking, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout, /obj/item/pen/edagger, /obj/item/shield/buckler, /obj/item/shield/buckler/parmula, diff --git a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm index 2cb37e172da..4930d57071b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm @@ -405,14 +405,18 @@ /obj/item/cultivator, /obj/item/shovel/spade, /obj/item/hatchet, /obj/item/secateurs, /obj/item/reagent_containers/cup/watering_can, /obj/item/plant_analyzer, /obj/item/fishing_rod/tech, ) - for(var/shop_type in list(/datum/outpost_shop/general, /datum/outpost_shop/outfitter, /datum/outpost_shop/black_market, /datum/outpost_shop/vendor/potting_shed, /datum/outpost_shop/vendor/bait_shop, /datum/outpost_shop/vendor/ripperdoc)) + for(var/shop_type in list(/datum/outpost_shop/general, /datum/outpost_shop/outfitter, /datum/outpost_shop/black_market, /datum/outpost_shop/vendor/potting_shed, /datum/outpost_shop/vendor/bait_shop, /datum/outpost_shop/vendor/ripperdoc, /datum/outpost_shop/vendor/street_doc)) var/datum/outpost_shop/shop = allocate(shop_type) for(var/datum/shop_sku/sku_type as anything in shop.sku_types + shop.rotating_pool + shop.rare_pool + shop.favor_sku_types) var/item_path = initial(sku_type.item_path) TEST_ASSERT(!(item_path in printed_gear), "[shop_type] still sells printable gear [item_path]") if(ispath(item_path, /obj/item/gun)) TEST_ASSERT(item_path in allowed_guns, "[shop_type] bypasses the ladder with [item_path]") - TEST_ASSERT(item_path != /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin && item_path != /obj/item/organ/cyberimp/arm/toolkit/cyberware/bunker_buster, "Splice still sells a gun arm") + // The heavy gun arms (the Ronin SMG and the Bunker Buster pod, and any + // model of them) stay loot-only. The Holdout's .38 is the one gun arm + // that is shelf stock, on purpose. + if(ispath(item_path, /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin) || ispath(item_path, /obj/item/organ/cyberimp/arm/toolkit/cyberware/bunker_buster)) + TEST_ASSERT(item_path == /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout, "[shop_type] sells the heavy gun arm [item_path]; only the Holdout housing is shelf stock") /// Convoys and rotations cannot accelerate deliveries or revive finite stock. /datum/unit_test/voidcrew_ladder_restock/Run() diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm index 078a7fcf0e8..12fcbf9b9e7 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm @@ -56,6 +56,16 @@ /datum/shop_sku/ripperdoc/deadeye = 1, /datum/shop_sku/ripperdoc/slipwire = 1, /datum/shop_sku/ripperdoc/prospector = 1, + /datum/shop_sku/ripperdoc/hardweave = 1, + /datum/shop_sku/ripperdoc/atlas_street = 1, + /datum/shop_sku/ripperdoc/slipwire_street = 1, + /datum/shop_sku/ripperdoc/doppler_street = 1, + /datum/shop_sku/ripperdoc/adrenal_pump = 1, + /datum/shop_sku/ripperdoc/hopper_street = 1, + /datum/shop_sku/ripperdoc/gorilla_street = 1, + /datum/shop_sku/ripperdoc/ronin_holdout = 1, + /datum/shop_sku/ripperdoc/longsight = 1, + /datum/shop_sku/ripperdoc/deck_street = 1, /datum/shop_sku/black_market/c4 = 1, /datum/shop_sku/black_market/emp_grenade = 1, /datum/shop_sku/black_market/frag_grenade = 1, @@ -101,6 +111,12 @@ /datum/shop_sku/ripperdoc/cascade_milspec = 2, /datum/shop_sku/ripperdoc/redline_milspec = 2, /datum/shop_sku/ripperdoc/governor_milspec = 2, + /datum/shop_sku/ripperdoc/slipwire_milspec = 2, + /datum/shop_sku/ripperdoc/hopper_milspec = 2, + /datum/shop_sku/ripperdoc/deadeye_milspec = 2, + /datum/shop_sku/ripperdoc/lookout = 2, + /datum/shop_sku/ripperdoc/deck_control = 2, + /datum/shop_sku/ripperdoc/deck_assault = 2, /datum/shop_sku/fitter/rare/mod_emp_shield_advanced = 2, /datum/shop_sku/fitter/rare/mod_projectile_dampener = 2, /datum/shop_sku/black_market/mod_chassis_syndicate = 2, @@ -134,11 +150,12 @@ /datum/shop_sku/ripperdoc/cascade = 3, /datum/shop_sku/ripperdoc/mantis_scorch = 3, /datum/shop_sku/ripperdoc/monowire_live = 3, + /datum/shop_sku/ripperdoc/deck_flagship = 3, ) /datum/unit_test/voidcrew_zone_shops/catalog/Run() var/list/expected = expected_tags() - TEST_ASSERT_EQUAL(length(expected), 108, "The retained permission catalog is incomplete") + TEST_ASSERT_EQUAL(length(expected), 125, "The retained permission catalog is incomplete") var/datum/voidcrew_crew_record/crew = allocate(/datum/voidcrew_crew_record) for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku)) var/permission = expected[sku_type] || 0 From 4eb95846f08b4bd1c592eae04112c2f00654a054 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:11:28 -0800 Subject: [PATCH 135/472] Drop the Ghostskin field when the weave goes offline or its bearer hacks someone --- voidcrew/modules/cyberware/ware_line_deck.dm | 16 +++++++++++++--- voidcrew/modules/cyberware/ware_line_models_a.dm | 6 +++--- voidcrew/modules/cyberware/ware_line_zoom.dm | 6 +++--- voidcrew/modules/cyberware/ware_military_body.dm | 7 +++++++ voidcrew/modules/cyberware/ware_models_b.dm | 15 +++++++-------- .../modules/cyberware/ware_street_passives.dm | 7 +++---- .../unit_tests/voidcrew_cyberware_camo.dm | 9 +++++++++ .../unit_tests/voidcrew_cyberware_deck.dm | 12 ++++++++++++ 8 files changed, 57 insertions(+), 21 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_line_deck.dm b/voidcrew/modules/cyberware/ware_line_deck.dm index 251e4b7a8ff..d700bab458a 100644 --- a/voidcrew/modules/cyberware/ware_line_deck.dm +++ b/voidcrew/modules/cyberware/ware_line_deck.dm @@ -412,12 +412,21 @@ log_combat(bearer, victim, "quickhacked", null, "with [hack.name] from [deck]") // Counts as an attack at a trader outpost, like any other. GLOB.outpost_pvp_enforcement?.register_pvp_aggression(victim, bearer) + // And like any other attack, it drops the hacker's camo. + drop_bearer_camo(bearer) else bearer.log_message("used the [hack.name] quickhack on [target] ([target.type]) with [deck].", LOG_ATTACK) cyberware_ink_pulse(bearer, CYBERWARE_INK_HARD) StartCooldown(kit.hack_cooldown(hack)) return TRUE +/// A hack that hurts someone is an attack, and an attack collapses the +/// hacker's Ghostskin field the way a punch or a shot does (ware_military_body.dm). +/datum/action/cooldown/cyberware/deck/proc/drop_bearer_camo(mob/living/carbon/bearer) + var/obj/item/organ/cyberimp/cyberware/ghostskin/weave = bearer?.get_organ_slot(ORGAN_SLOT_CYBERWARE_SKIN) + if(istype(weave) && weave.camo_active) + weave.break_camo(bearer, reason = "field contact") + // ---- Room hacks ---------------------------------------------------------- /// The radial on the bearer, then the upload, then the room hack. Sleeps. @@ -469,6 +478,8 @@ bearer.log_message("used the [hack.name] room hack with [deck].", LOG_ATTACK) if(!istype(hack, /datum/machine_masshack/ping)) message_admins("[ADMIN_LOOKUPFLW(bearer)] used the [hack.name] room hack from [deck] at [ADMIN_VERBOSEJMP(here)].") + // Arc Flash hurts everyone in the room: an attack, so the camo goes. + drop_bearer_camo(bearer) cyberware_ink_pulse(bearer, CYBERWARE_INK_HARD) StartCooldown(kit.hack_cooldown(hack)) return TRUE @@ -632,9 +643,8 @@ return TRUE // ========================================================================= -// SPLICE SKUS: not listed on any vendor yet, the integration pass adds them -// to the Chop Shop's sku_types (and the street doc's, for the two credit -// decks), and pins the gated ones in voidcrew_zone_shops' catalog. +// SPLICE SKUS: on the Chop Shop's sku_types, the two credit decks on the +// street doc's too, and the gated ones pinned in voidcrew_zone_shops' catalog. // ========================================================================= /datum/shop_sku/ripperdoc/deck_junker diff --git a/voidcrew/modules/cyberware/ware_line_models_a.dm b/voidcrew/modules/cyberware/ware_line_models_a.dm index 03c36ecbe1d..bee0a607774 100644 --- a/voidcrew/modules/cyberware/ware_line_models_a.dm +++ b/voidcrew/modules/cyberware/ware_line_models_a.dm @@ -18,9 +18,9 @@ * The Vaulter carries the Coils' soft landing and dry footing, which the Hopper * itself never had, so stepping up to it costs no safety. * - * Sold at Splice's counter, and the credit-priced ones at the street doc's. - * The integration pass wires the SKUs and ledger entries below into the vendors - * and the zone loot tables. + * Sold at Splice's counter, and the credit-priced ones at the street doc's + * (ripperdoc_catalog.dm, street_doc_catalog.dm). All of them also ride the + * zone loot tables (modules/loot/themes/). */ // ========================================================================= diff --git a/voidcrew/modules/cyberware/ware_line_zoom.dm b/voidcrew/modules/cyberware/ware_line_zoom.dm index e374a3de67a..37fa476a333 100644 --- a/voidcrew/modules/cyberware/ware_line_zoom.dm +++ b/voidcrew/modules/cyberware/ware_line_zoom.dm @@ -129,9 +129,9 @@ // ========================================================================= // SPLICE SKUs and INTAKE LEDGER -// The lead wires these into /datum/outpost_shop/vendor/ripperdoc (sku_types, -// buyback_types), the street doc's lists (the credit-priced three and their -// /street_doc trade-ins), and the zone loot tables at integration. +// Listed on /datum/outpost_shop/vendor/ripperdoc (sku_types, buyback_types) +// and on the street doc's lists (the credit-priced three and their +// /street_doc trade-ins). The optics also ride the zone loot tables. // ========================================================================= /datum/shop_sku/ripperdoc/spyglass diff --git a/voidcrew/modules/cyberware/ware_military_body.dm b/voidcrew/modules/cyberware/ware_military_body.dm index f3f77a655b0..69ceb0cc267 100644 --- a/voidcrew/modules/cyberware/ware_military_body.dm +++ b/voidcrew/modules/cyberware/ware_military_body.dm @@ -56,6 +56,13 @@ . = ..() break_camo(organ_owner, silent = TRUE) +/// The field runs on the weave: it collapses the moment the weave goes offline, +/// a deck's Glitch, a brownout or damage, the same as an EMP drops it. +/obj/item/organ/cyberimp/cyberware/ghostskin/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + if(camo_active) + break_camo(bearer, reason = "weave offline", silent = (owner != bearer)) + /obj/item/organ/cyberimp/cyberware/ghostskin/emp_act(severity) . = ..() if(. & EMP_PROTECT_SELF) diff --git a/voidcrew/modules/cyberware/ware_models_b.dm b/voidcrew/modules/cyberware/ware_models_b.dm index f3ac264503c..47f5320ae56 100644 --- a/voidcrew/modules/cyberware/ware_models_b.dm +++ b/voidcrew/modules/cyberware/ware_models_b.dm @@ -24,9 +24,9 @@ * single highest live source, so the Twitchwire and the Weaver shunt can never * stack with a Cascade window or each other. * - * Splice's shelf, the street doc's shelf, and the zone loot tables are wired - * in the integration pass, not here. The SKUs and ledger entries below exist - * so the cradle can quote a price and so that pass has something to list. + * Splice's shelf, the street doc's shelf (ripperdoc_catalog.dm, + * street_doc_catalog.dm) and the zone loot tables list the SKUs, ledger + * entries and organs below. */ // ========================================================================= @@ -160,8 +160,8 @@ ping_volume_bonus = 10 // ========================================================================= -// SPLICE SKUS: not listed on any vendor yet, the integration pass adds them -// to the Chop Shop's sku_types (and the street doc's, for the credit ones). +// SPLICE SKUS: on the Chop Shop's sku_types, and the credit ones on the +// street doc's too. // ========================================================================= /datum/shop_sku/ripperdoc/hardweave @@ -211,9 +211,8 @@ // ========================================================================= // TRADE-IN: half list, credits only, exact type. The Weaver shunt costs -// vouchers, which can't be refunded, so it has no entry. Not listed on any -// buyback_types yet, same integration pass as above. Each entry also has the -// street doc's own wording below. +// vouchers, which can't be refunded, so it has no entry. Each entry also has +// the street doc's own wording below. // // Names stay siblings of the parent entries (second_wind_deep, not // second_wind/deep) so a model never inherits its parent's price. The diff --git a/voidcrew/modules/cyberware/ware_street_passives.dm b/voidcrew/modules/cyberware/ware_street_passives.dm index caef2ab3aed..4085b45727a 100644 --- a/voidcrew/modules/cyberware/ware_street_passives.dm +++ b/voidcrew/modules/cyberware/ware_street_passives.dm @@ -336,8 +336,7 @@ reflist[1] -= bang_reduction // ========================================================================= -// SHOP SKUS: not listed on any vendor yet, the integration pass adds them to -// the Chop Shop's sku_types and to the street doc's. +// SHOP SKUS: on the Chop Shop's sku_types and on the street doc's. // ========================================================================= /datum/shop_sku/ripperdoc/clotter_node @@ -372,8 +371,8 @@ price_credits = 700 // ========================================================================= -// TRADE-IN: half list, credits only. Not listed on any buyback_types yet, same -// integration pass as above. Ratchet's entries (/street_doc) differ only in wording. +// TRADE-IN: half list, credits only. Ratchet's entries (/street_doc) differ +// only in wording. // ========================================================================= /datum/shop_buyback/ripperdoc/clotter_node diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm index 00a3b17c955..4ae40f718f0 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm @@ -119,6 +119,15 @@ SEND_SIGNAL(pitcher, COMSIG_MOB_THROW, spot(0, 0)) TEST_ASSERT(!HAS_TRAIT(pitcher, "cyber_camo"), "Throwing didn't drop the field") + // A weave knocked into a reboot (a deck's Glitch) drops its field too. + var/mob/living/carbon/human/consistent/glitched = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin, spot(4, 4)) + var/obj/item/organ/cyberimp/cyberware/ghostskin/weave = locate() in glitched.organs + var/datum/component/cyberware/chrome = weave?.GetComponent(/datum/component/cyberware) + TEST_ASSERT_NOTNULL(chrome, "Fixture: the Ghostskin has no chrome component") + chrome.start_emp_reboot(EMP_HEAVY) + TEST_ASSERT(!weave.camo_active, "A rebooting weave kept its field up") + TEST_ASSERT(!HAS_TRAIT(glitched, "cyber_camo"), "A rebooting weave left its bearer hidden from mobs") + // The street weave never grants the trait, so nothing here hides it. var/mob/living/carbon/human/consistent/flicker = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin/street, spot(4, 4)) TEST_ASSERT(!HAS_TRAIT(flicker, "cyber_camo"), "The Flickerskin granted the camo trait") diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm index fce3404c49d..efbe649b528 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm @@ -310,6 +310,18 @@ TEST_ASSERT(chrome.emp_down, "Glitch did not reboot the target's chrome") TEST_ASSERT(!glitch.valid_for(victim, user), "Glitch was offered with nothing left running") + // A hack that lands on a person is an attack: it drops the hacker's camo. + chrome.tune_up() + var/obj/item/organ/cyberimp/cyberware/deck/assault/breacher = allocate(/obj/item/organ/cyberimp/cyberware/deck/assault) + TEST_ASSERT(breacher.Insert(user, special = TRUE), "Breacher deck staging insert was refused") + var/datum/action/cooldown/cyberware/deck/hack_action = locate() in breacher.actions + var/obj/item/organ/cyberimp/cyberware/ghostskin/weave = allocate(/obj/item/organ/cyberimp/cyberware/ghostskin) + TEST_ASSERT(weave.Insert(user, special = TRUE), "Ghostskin staging insert was refused") + weave.engage_camo(user) + TEST_ASSERT(weave.camo_active, "Fixture: the hacker's Ghostskin never raised its field") + TEST_ASSERT(hack_action.upload_quickhack(user, victim, breacher.kit.find_quickhack(/datum/machine_quickhack/glitch)), "Glitch did not land through the Breacher deck") + TEST_ASSERT(!weave.camo_active, "Hacking a person from under the Ghostskin kept the field up") + var/obj/machinery/porta_turret/turret = allocate(/obj/machinery/porta_turret, get_step(start, NORTH)) TEST_ASSERT(reboot.valid_for(turret, user), "Reboot Turret was not offered on a live turret") TEST_ASSERT(reboot.execute(turret, user), "Reboot Turret did not land") From f58f2711120782a6a060df3828bfaf0e906616bf Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:14:23 -0800 Subject: [PATCH 136/472] Wiki: zoom, cyberdecks and the new street chrome --- .../modules/cyberware/ware_street_passives.dm | 2 +- wiki/content/cyberware.md | 28 +++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_street_passives.dm b/voidcrew/modules/cyberware/ware_street_passives.dm index 4085b45727a..f0e306c7b98 100644 --- a/voidcrew/modules/cyberware/ware_street_passives.dm +++ b/voidcrew/modules/cyberware/ware_street_passives.dm @@ -20,7 +20,7 @@ * The Subvocal mic needs two small hooks in the speech path. Both are chained * overrides at the bottom of this file, nothing upstream is edited. * - * SKUs and trade-ins are defined at the bottom, listed on no vendor yet. + * SKUs and trade-ins are defined at the bottom. */ /// Held by a mob whose radio talk is not overheard. Granted by the Subvocal mic. diff --git a/wiki/content/cyberware.md b/wiki/content/cyberware.md index d31e710a70f..51d7722dafd 100644 --- a/wiki/content/cyberware.md +++ b/wiki/content/cyberware.md @@ -19,7 +19,7 @@ Outside the parlors, ordinary organ-manipulation surgery at any operating table ## The Back Bay -Ratchet, a loader mechanic who started putting chrome in people, works out of a storage bay off Quartermain's upper landing. He sells street chrome only, for credits, at Splice's prices, and the bay has a Chrome Cradle of its own that works the same way. He buys pulled street chrome back on the same terms as Splice. +Ratchet, a loader mechanic who started putting chrome in people, works out of a storage bay off Quartermain's upper landing. He sells street chrome only, for credits, at Splice's prices, and the bay has a Chrome Cradle of its own that works the same way. He buys pulled street chrome back on the same terms as Splice. His shelf carries the newer street pieces too: the cheap optics, the junk and street decks, and the quiet passives below. ## Neural load @@ -79,7 +79,7 @@ Prices are the same at both counters, stock is limited per round, and load is th | Slabskin plate | 3 | 6 | Heavy built-in armor in every damage type. Solid hits visibly spark off you. Everyone can see you are plated | | Mantis Blades (pair) | 4 | 3/arm | Deployable hardlight blades, plus a five-tile lunge that knocks the target down | | Bunker Buster housing | *loot only* | 5 | Two shaped micro-rockets: 60 damage, no splash, will never breach a hull. Reloads are sold at the parlor or printed with Experimental Ammunition | -| Ghostskin weave | 4 | 5 | Ten seconds of optical camo. NPCs will not target you, and Chrome Reads scatter. Attacking, taking a hit or an EMP drops it | +| Ghostskin weave | 4 | 5 | Ten seconds of optical camo. NPCs will not target you, and Chrome Reads scatter. Attacking, throwing, taking a hit or an EMP drops it | | Lazarus node | 4 | 5 | The moment you drop into crit, it charges for a second and shocks you back to your feet at 30% health. Once per six minutes | ### Tier 4: Legend chrome (vouchers, one of each per round) @@ -110,6 +110,30 @@ Six of the big pieces come in cheaper models from other makers. Each takes the s The **ammunition shelf** is the return-visit hook: Ronin magazines at 250 credits and Buster rocket pairs at 300, both at 4 [reputation](zone-reputation.md). Both ripperdocs buy back *removed* street chrome, street models included and even pieces they sold you, at about half list price. Chrome paid for in vouchers is never bought back. +### More street pieces + +The newer makers fill in the gaps under the big names. Each takes the same slot as the piece it copies, so it sells back and climbs like the others. + +- **Spring calves** and **Vaulter pistons**: a short hop on cheap springs, and the service pistons that clear a room. +- **Brawler myomers**: a cased pair of forearms that hit hard and break rock, but no door gives way to them. +- **Tinker's fingers** and the **Deepcore drill fist**: a looser set of fingertip tools, and a heavier drill on the same ore hopper. +- **Holdout housing**: a snub revolver folded into the forearm, loaded one round at a time. +- **Hardweave plating** and the **Strut frame**: a heavier dermal weave, and a frame that keeps your bones whole. +- **Deep-lung bladder**, **Heat-sink lining** and **Frostline lining**: a longer breath, a lining for the heat, a lining for the cold. +- **Twitchwire** and the **Weaver shunt**: a twitchy reflex shunt, and the service model. +- **Thumper ear**: a louder, shorter-ranged sonar behind the ear. +- Quiet pieces with no button: the **Clotter node** slows bleeding, the **Adrenal pump** kicks in when you're about to drop, **Medic's palms** and **Cuffer's wrists** do the glove's job without the glove, the **Subvocal mic** keeps your radio talk from the people next to you, and the **Muffler ear** shuts out a bang. + +## Optics and zoom + +Every pair of chrome eyes can zoom: focus on something far off and your view pulls toward it. Better eyes see further, and cheap ones make you slow while you look. Nobody shoots straight through a zoom, so you have to look up before you fire. A flash, a stun or an EMP snaps you out of it. Spyglass and assay optics are the cheap way in; the Longsight, the marksman link and the Lookout are what scouts and sharpshooters save for. + +## Cyberdecks + +A cyberdeck sits in the back of the skull and talks to machines and other people's gear. Use it, pick something you can see, and the deck offers only what that target will take: doors, lights, a room's power, turrets, radios, a person's gear and the chrome inside them. Pick yourself and it offers what it can do to the room around you. A deck works through your zoom, too. + +Every push draws a trace line to the target and makes their gear chirp, and it fails if you move, lose sight or get stunned. No deck opens a locked door, and outpost security, prison doors included, ignores them all. The Junker deck and the Street deck sell for credits at both counters; the Facilities and Breacher decks, one for doors, radios and turrets and one for people and their gear, are vouchers from Splice; and the Blackbox deck, which does both, is legend chrome. + ## Finding chrome in the wild Most of the roster also appears in zone loot caches. Street and utility chrome appears among themed supplies. Weapon chrome uses the cache's weapon roll; Popup Ronin and Bunker Buster housings are plunder-cache loot rather than trader stock. Higher zones improve the odds of stronger chrome. The exceptions: Legend chrome and ammunition never drop, the Skyhook drops *only* in expedition caches, and the Meteor Piledriver comes from eligible guarded mission prizes, including hunts and lair trophies. From 0c774ba977acffa13f21d573cc0398f858bb8937 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:24:37 -0800 Subject: [PATCH 137/472] Give the optics and arm chrome bases a sprite, and exempt the wheel's hand fixture from the arm slot test --- voidcrew/modules/cyberware/cyberware_base.dm | 4 ++++ voidcrew/modules/unit_tests/voidcrew_cyberware.dm | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/voidcrew/modules/cyberware/cyberware_base.dm b/voidcrew/modules/cyberware/cyberware_base.dm index 28bc56bef1f..9f8e6e7db2e 100644 --- a/voidcrew/modules/cyberware/cyberware_base.dm +++ b/voidcrew/modules/cyberware/cyberware_base.dm @@ -164,6 +164,8 @@ name = "cyberware optics" desc = "Aftermarket eyes. The irises catch the light in a way real ones don't." icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' + // The base is never sold; it borrows the plainest optics' sprite so it has one. + icon_state = "nightshade" actions_types = list(/datum/action/cooldown/cyberware/chrome_read) /// Neural load this ware puts on its bearer. var/chrome_load = 1 @@ -238,6 +240,8 @@ name = "arm cyberware" desc = "Aftermarket arm hardware. Folds away until you want it." icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' + // The base is never sold; it borrows a plain arm housing's sprite so it has one. + icon_state = "fixers" // Worn look (see the cyberware base above): the arm pipeline derives // "[aug_overlay]_left/_right" from the installed zone on its own. No // separate hand states in our sheet, so the hand overlay stays off. diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index 831f554929a..c694968e2c5 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -158,8 +158,14 @@ /datum/unit_test/voidcrew_cyberware_arm_slots /datum/unit_test/voidcrew_cyberware_arm_slots/Run() - // The other two palm and wrist pieces share the hands slot with it (ware_street_passives.dm). - var/list/exempt = list(/obj/item/organ/cyberimp/cyberware/gecko, /obj/item/organ/cyberimp/cyberware/medic_palms, /obj/item/organ/cyberimp/cyberware/cuffer_wrists) + // The other two palm and wrist pieces share the hands slot with it (ware_street_passives.dm), + // and the chrome wheel tests' hand fixture stands in for them (voidcrew_chrome_wheel.dm). + var/list/exempt = list( + /obj/item/organ/cyberimp/cyberware/gecko, + /obj/item/organ/cyberimp/cyberware/medic_palms, + /obj/item/organ/cyberimp/cyberware/cuffer_wrists, + /obj/item/organ/cyberimp/cyberware/test_wheel_hand, + ) var/list/arm_ware = typesof(/obj/item/organ/cyberimp/cyberware) + typesof(/obj/item/organ/cyberimp/arm/toolkit/cyberware) for(var/organ_path in arm_ware) if(organ_path in exempt) From 06ace54c1d00a669dc0aa867e51107de2d3349cf Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:34:17 -0800 Subject: [PATCH 138/472] Name a bought crate's contents when it pays too many draws --- voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm index 4930d57071b..4abacd9bfe1 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm @@ -659,7 +659,10 @@ var/ammo_path = GLOB.loot_gun_spare_ammo[gun.type] TEST_ASSERT(locate(ammo_path) in cache, "A freight gun arrived without its spare reload") TEST_ASSERT(contents_count - spare_count >= 2 + (zone == 3 ? 2 : zone == 2 ? 1 : 0) + cache.bonus_draws, "Premium crate lost ordinary draws") - TEST_ASSERT(contents_count - spare_count <= 3 + (zone == 3 ? 2 : zone == 2 ? 1 : 0) + cache.bonus_draws, "Premium crate added unbudgeted draws") + var/list/packed = list() + for(var/atom/movable/inside as anything in cache.contents) + packed += "[inside.type]" + TEST_ASSERT(contents_count - spare_count <= 3 + (zone == 3 ? 2 : zone == 2 ? 1 : 0) + cache.bonus_draws, "Premium crate added unbudgeted draws (zone [zone], [row[1]]): [packed.Join(", ")]") cache.PopulateContents() TEST_ASSERT_EQUAL(length(cache.contents), contents_count, "Reopening bought cargo paid twice") TEST_ASSERT(deep_compare_list(list(theme.loot_common, theme.loot_uncommon, theme.loot_prime, theme.loot_uniques), original_pools), "Bought cargo mutated the shared theme") From 2c84cccce4cd5bf2bdcb5f32bffe1d08bf47a632 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:06:28 -0800 Subject: [PATCH 139/472] Cut the mechanics out of the cyberware text, redraw three weak icons, keep decks out of prison wing power Player text across the cyberware and quickhack files now says what the thing is and does in plain words: descriptions lose their figures, tiers, load talk and how-to lines, buyback descriptions lose their trade-in rules, the Cradle screen and its tooltips lose their help text, and Chrome Read names the grade instead of a tier number. Ability descriptions keep the numbers a player needs to choose them. The Holdout housing, Subvocal mic and Muffler ear icons are redrawn so they read at 1x. A cyberdeck now refuses a player outpost prison wing's APC and lights as well as its doors, so Burst the Lights and Kill the Power cannot black out a wing. --- .../voidcrew_tgui/interfaces/ChromeCradle.tsx | 50 +++++------------- voidcrew/modules/cyberware/chrome_cradle.dm | 4 +- voidcrew/modules/cyberware/cyberware_base.dm | 2 +- voidcrew/modules/cyberware/cyberware_scan.dm | 4 +- .../modules/cyberware/icons/cyberware.dmi | Bin 64227 -> 60163 bytes .../modules/cyberware/ripperdoc_catalog.dm | 4 +- .../modules/cyberware/ware_arm_utility.dm | 6 +-- voidcrew/modules/cyberware/ware_legend.dm | 24 ++++----- .../modules/cyberware/ware_line_berserk.dm | 8 +-- voidcrew/modules/cyberware/ware_line_camo.dm | 4 +- voidcrew/modules/cyberware/ware_line_deck.dm | 20 +++---- .../modules/cyberware/ware_line_governor.dm | 2 +- .../modules/cyberware/ware_line_mantis.dm | 2 +- .../modules/cyberware/ware_line_models_a.dm | 4 +- voidcrew/modules/cyberware/ware_line_sandy.dm | 4 +- voidcrew/modules/cyberware/ware_line_zoom.dm | 4 +- .../modules/cyberware/ware_military_arms.dm | 26 ++++----- .../modules/cyberware/ware_military_body.dm | 25 +++++---- voidcrew/modules/cyberware/ware_pro_combat.dm | 12 ++--- .../modules/cyberware/ware_pro_utility.dm | 28 ++++------ .../modules/cyberware/ware_second_wind.dm | 10 ++-- voidcrew/modules/cyberware/ware_street.dm | 20 +++---- .../modules/cyberware/ware_street_passives.dm | 2 +- .../modules/player_outposts/outpost_prison.dm | 15 ++++++ voidcrew/modules/quickhacks/masshacks.dm | 15 ++---- voidcrew/modules/quickhacks/quickhacks.dm | 17 +++--- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../modules/unit_tests/voidcrew_cyberware.dm | 2 +- .../unit_tests/voidcrew_cyberware_deck.dm | 28 ++++++++++ .../unit_tests/voidcrew_cyberware_text.dm | 20 +++++++ wiki/content/cyberware.md | 2 +- 31 files changed, 194 insertions(+), 171 deletions(-) create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_text.dm diff --git a/tgui/packages/voidcrew_tgui/interfaces/ChromeCradle.tsx b/tgui/packages/voidcrew_tgui/interfaces/ChromeCradle.tsx index 57008cc670e..cf51c16a304 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/ChromeCradle.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/ChromeCradle.tsx @@ -370,10 +370,7 @@ const Rack = () => { if (!has_occupant) { return ( -
    - The rack is dark. Drag yourself onto the slab to lie back and it wakes - up. -
    +
    The rack is dark.
    ); } @@ -477,7 +474,7 @@ const TileCard = (props: { ware: Ware }) => {
    - T{ware.tier} {TIER_NAMES[ware.tier] || 'CHROME'} + {TIER_NAMES[ware.tier] || 'CHROME'} load {ware.load} {ware.capacity_bonus > 0 && +{ware.capacity_bonus} cap} @@ -520,10 +517,7 @@ const Detail = () => { if (!ware) { return ( -
    - Pick a piece from the rack. Anything you own shows up on the body before - the rig touches you. -
    +
    Nothing selected.
    ); } @@ -548,7 +542,7 @@ const Detail = () => {
    - TIER {ware.tier} · {TIER_NAMES[ware.tier] || 'CHROME'} + {TIER_NAMES[ware.tier] || 'CHROME'} {ware.state === 'installed' @@ -603,7 +597,7 @@ const Detail = () => { )} {!!overCapacity && (
    - No neural headroom. Shed load or find a Governor. + No neural headroom.
    )}
    @@ -614,7 +608,6 @@ const Detail = () => { act('remove', { ref: ware.ref })} > EXTRACT @@ -623,11 +616,7 @@ const Detail = () => { act('install', { ref: ware.ref })} > INSTALL @@ -776,10 +765,7 @@ const StatusDeck = () => { style={{ width: `${progress * 100}%` }} /> -
    - Hold still. Leaving the slab cancels the cycle. Nothing is lost, the - ware goes to the tray. -
    +
    Hold still.
    ); } @@ -789,19 +775,13 @@ const StatusDeck = () => { notes.push('Trade embargo. The rig will not work on you.'); } if (!has_occupant) { - notes.push('The slab is empty. Drag yourself onto it to lie back.'); + notes.push('The slab is empty.'); } else if (!occupant_is_user) { - notes.push( - `${occupant_name} is on the slab. The rig takes orders from its occupant only. You can still pop the parts tray.`, - ); + notes.push(`${occupant_name} is on the slab.`); } else if (!can_operate) { - notes.push( - 'The rig refuses: you have to be conscious and unrestrained to consent to chrome work.', - ); + notes.push('The rig needs you awake and free to say yes.'); } else { - notes.push( - 'Rig idle. Highlight a piece in the rack, check it on the body, then commit.', - ); + notes.push('Rig idle.'); } if (tray_count > 0) { notes.push( @@ -841,10 +821,7 @@ const Controls = () => { act('tuneup')} > {`TUNE-UP · ${tuneup_fee} CR`} @@ -852,7 +829,6 @@ const Controls = () => { act('eject_tray')} > {`EJECT TRAY${tray_count > 0 ? ` · ${tray_count}` : ''}`} @@ -860,7 +836,6 @@ const Controls = () => { act('eject_cash')} > EJECT CASH @@ -868,7 +843,6 @@ const Controls = () => { act('get_up')} > {occupant_is_user ? 'GET UP' : 'UNBUCKLE'} diff --git a/voidcrew/modules/cyberware/chrome_cradle.dm b/voidcrew/modules/cyberware/chrome_cradle.dm index ef2dc109b0a..5fe8e5d27f5 100644 --- a/voidcrew/modules/cyberware/chrome_cradle.dm +++ b/voidcrew/modules/cyberware/chrome_cradle.dm @@ -94,8 +94,6 @@ /obj/machinery/chrome_cradle/examine(mob/user) . = ..() - . += span_notice("Drag yourself (or a patient) onto the slab to lie back on it. Whoever's on the slab runs their own install; the rig won't take orders from anyone else.") - . += span_notice("Tune-ups run [CYBERWARE_TUNEUP_FEE] cr: EMP-scrambled and damaged chrome comes back to spec. Load cash into the slab or pay by ID.") if(loaded_credits) . += span_notice("The slab's cash slot holds [loaded_credits] cr.") if(length(tray)) @@ -408,7 +406,7 @@ if(any_repairable) return null if(only_brownout && length(installed)) - return "That chrome is browned out, not broken. Shed some load instead." + return "That chrome is browned out, not broken." return "Nothing needs a tune-up." /obj/machinery/chrome_cradle/proc/try_tune_up(mob/living/carbon/patient) diff --git a/voidcrew/modules/cyberware/cyberware_base.dm b/voidcrew/modules/cyberware/cyberware_base.dm index 9f8e6e7db2e..838adf6c857 100644 --- a/voidcrew/modules/cyberware/cyberware_base.dm +++ b/voidcrew/modules/cyberware/cyberware_base.dm @@ -79,7 +79,7 @@ // Stack enough chrome and you stop looking human. aug_icon = 'voidcrew/modules/cyberware/icons/cyberware_worn.dmi' organ_flags = ORGAN_ROBOTIC - failing_desc = "is dark and inert, browned out, EMP-scrambled, or plain broken." + failing_desc = "is dark and inert." /// Neural load this ware puts on its bearer. 0-12; see the tier bands. var/chrome_load = 1 /// CYBERWARE_TIER_*, drives accent colours and the parlor experience. diff --git a/voidcrew/modules/cyberware/cyberware_scan.dm b/voidcrew/modules/cyberware/cyberware_scan.dm index 3287ad02cbc..559a660d4b5 100644 --- a/voidcrew/modules/cyberware/cyberware_scan.dm +++ b/voidcrew/modules/cyberware/cyberware_scan.dm @@ -159,7 +159,7 @@ else for(var/list/entry as anything in report["entries"]) var/status = entry["offline"] ? " · OFFLINE" : "" - var/rating = entry["rated"] ? "T[entry["tier"]]" : "UNRATED" + var/rating = entry["rated"] ? (list("STREET", "PRO", "MILITARY", "LEGEND")[clamp(entry["tier"], 1, 4)]) : "UNRATED" . += cyberware_scan_line("» [uppertext(entry["name"])] · [rating][status]", entry["color"]) if(load > capacity) . += cyberware_scan_line("LOAD OVER CEILING: SUBJECT BROWNED OUT", CYBERWARE_COLOR_TIER_3) @@ -168,7 +168,7 @@ /datum/action/cooldown/cyberware/chrome_read name = "Chrome Read" - desc = "Read a body's hardware bus: what's installed, what tier it is, and how loaded their nervous system is. They'll feel you do it." + desc = "Read the chrome in a body you can see. They'll feel you do it." button_icon = 'icons/mob/actions/actions_items.dmi' button_icon_state = "scan" ranged_mousepointer = 'icons/effects/mouse_pointers/scan_target.dmi' diff --git a/voidcrew/modules/cyberware/icons/cyberware.dmi b/voidcrew/modules/cyberware/icons/cyberware.dmi index ef7254bc3cce7cd382c583549645cea30a231a34..a596504bb3fa394cff795156d78f8154dc262d19 100644 GIT binary patch literal 60163 zcmaI7Q*b42@GUwM+ctKriOq?2Y}>}fw!LH9w(VqMO>Emv?(cu<-n!@IoR|Lkq3e6- zs_tI3dUb??oCG2q9^8)~KM;YEqDue0J3oH>%!7gY&uav;=Kk>mtiV%6!&%hS$@q_@ zgR`Z*-H#vcS=s92vFpDO_^I?!|87Ltt>{JKVmO_A1olZr7inQZ0%J3Meah(h zxa0pGO9W0_#jOlVr*inY$_JFlg zT|UFno7A!Z*-IeE?2foFnpu@8F~gGo{3WyMJ%FuTWvaF|f?f2?#BkLP?1oF*fnF4g z#phWmByfJvLk$ZtWB1gWD^4@bqN>S|NrogbOueO`bY>?~m6~G9!0ig0=+DQUkJXi1 zg2U8g&AedvXIBFk zTUkd;v|^3vk+eMwt8sIRb@)?R5FxJ3|8M2!&qyktb zVd$cRgN4Sn1|9b<-LLr%_^+OMpHG4E4K~s(gD0hlC(p}1>VrHy-!9jX1P*;aq5ogA zLlDFy}y@x5Game`k9 zkjYk(aGe0J13qtkuKOkaY`oahIWe%x$5Nnju8f6<+-;Yzc}l>t`&Y^O)u5mLd+SR} zdLdx5Jyj>DE*$3K^MiFrE-kPqNu}OppAIHEdJyEaQqa((aq(`%`sU@?+wgpJ#-aHF z0hQlg4^`0Gcj4huGfqZAP4HGC{_tetM=o%kHwz@=5y0wx6F)d0Al_an%A^D%Cu6pD zJ;;-$id842A3<)H*uq~qMu3AwJzuW*?c%~dK?_%<*SmYvdH6NLsW1L#!o=C7IS&12 zHi?e_hzT&e9qo-Qc*j+vLl6dS4`j>n+Y=Py%J#qjT;%w886q=wUa;)$m2@?Ygo=CL zzGCxSwr_U2k~1(M`hNL3xH*lEM=z9Bmk-=wP98q?gqaS>n_?B9vds}B+c5@)pgKL zqg>KAtm*Nps;UYDk0|bL(Z+Xmdf9K00BZTcXlM8Dd6)ypafRNzwDda>>6rDzqcze+svhz&G_z!lpp)%BF``=+ScZ3{@ z2z0h`#mg20cAmFBhX4<|oW2+~jQK9Ycb-Wwq*v78agBefU)SxQqf_!U9$O+kpD#^R zRLofa@Lz1|sreq~*e5ZvNf4LU*Tkta)I2OIcA1$O3BJGJn^k>EqNZGqM)!Z9g|9b& z_#PggaJPe^N;7d^n||Wb?k(av%_-9J>Kt^da^@=_Ao4sS4 z;g|J4Yp+>Gq&t1FIf6HayD#2i}u+424{ z6=rOBr1URP+|fz1+eVl?hfm}MI&`t7)GWQv$^7`supee%EOwdRe;p#3oP-UBrX2PL zDQfVpjw?7c#CSa{MueSx)ph(1GL28uqx(K}N?1>>zH4R4!KcZL2FPl^3OP3UcQ_e; znVf?I)$j9O5H*tp*LHKd?R*43+Ll#bc0};&RUV{Fl<2)mF_qSJrK6I}X1tM?Nm=&C z8>O9U;)mnf$0tqi<~|rG6rw=0oProwetjWsZOp#)?W)^%zRCfQSS#klWiFVCdICxQEKDUVtZ zJAe16C#1q3ThCK~R=U(^rSy7mkV+vZ6XP)IR{WueE$e--QbA7*$v`mcb6}Sk#XrUD zFv|O@2;iNyL?`MO5bH}iUfM0^BI?nzFv0iL7hYi8T^$yaZ)N!FvpYF%C34(USS@~{4NiJ9&y5K_9%qNShgh4_e!x%P!#951(hXyglUfB7$1Q;A zg_KqII&6L22)STh0$cqGoZXg|%nmU&=OJCrjk8=R8NUYym-^`+6$Kv{7CN`#mn+$Pmz09KzM+%f!=Z}1CoQpqch{ad#0#|K{rt?z z%moiBIw3h`Jn~XpR+oaJt!?J65X-5yc5;hiz@P8g943l6r4pK9C@pp!&n-L;Jqk-h zJ*2?EKt*+ZhLLsRrqJQ>+ZV2Tgpy*UScq86oSuB6qbxCy!l&%;%j=A9RFrXB_09)4Y#C5vQ3O^yOEs)16=pk>y!e(S(B1Kx~pOP-~L574e`I*t!sltaKL`M{Hf!{&o^}B1C zqIzmPdS3e!xZ#e^V;CzsU+_u2-wCS2{hKFB!&H=H>!or;$$2 z%fIyX^>Y*4A=c}mnR-><;jTbHl61`-sFxqdtl&e(XY0}vUraK#3AuYx zV?mB{xV-7K*Rpe(Gw4!a)^va@OUYrLoJ4+~|CqRg%Ix@=06)8G#&fX0n?r4@h6CoaU^c`t0-jJdabGnj?jk zoX?`EMn(StBr72vAlo-32{k*-0sjSMEIG6-c)yV+fJ3_JW+5mJo{?ICyH;Z_VNr(m zs01saoM^k~AH^Y}rEb0{UtLMK>bKO|dTt@E^eM@we*NpAWo3fF=eM}9`Am*gbS#Vf zNv&Xz*D`>#wLvxq{>f#OTY3N1)NH&Ac(PHErU;K+Jl+J3LN#906^94m@nGy1K=XIX zKgwy)KfxQYIgmLs+7Es@(qj`+YF>s=G@I-7+N^#%N_C7kF5#3!EAjnb`^YF5y885v z5wZq%QjsnlQP*a2Gt2grUQ_B#QvRY6wEiyHtZ-wLaF zwql+;q9drvim&j44L%GRDaALACVbeO8oM!RLBPFlwCkIwENF-v%sVT_%u-2;_N}6! z&P)-Z#U~{$*b+4y{_%I#Zfik)H9l@gVN~qX`zwB7M(d^{yX!TN34oY9YjOZ=c77ZA zJ^DVJ|$kk5bxuZg`C2Qyw&X(?{jtwkD5>$gPW z)(QC<_|Zeb0D2yZWe1-`0W;_nJ;#6h@Ulph4 z=Rp%)2*8eg=vaL6A|blBH?Mwb>R<9uMlC*NX;RUVYI4(kMch%S$t>VB<*Hmt`b>%U z9DLm*uO|zH8@=Gf_A{eml~hvSSI(Y1CB?Y~CszzX@{dO3%WwCRvmWE>fzF zmX+{uyb%?Bhqf@Y>N`4zjhJ(TPyQEVe+xw|ACnKCG;3nT$>|~N5^@t0D)Nq3wyyiu z0{D{Z1pWgeZ9>V(Bj*OAGREm?=}!R!gTCU{M)Y&04h(I`hzi2pgVM4(9AR0IO zrZ}X^)ZCoG^noO>r-g%G3LomP)L$*DAcz4lgadHeKMTxWb2b?GHRqX>@LT9hC8<7j zwdAfC38()R3dBuSfYXGXtC)YqZ)5iSpmc~mOHvB^)@ zQc`17tL;{Ja{2g2IxAt~5Xz;@_D`^d8wQ>Go@mjGKkmWVK*Ts(MMMCLp8Z=v#l+Oq zpvhoXOqwk)l3Kq``1W5YTv;F-4kj++cxekR(=RS%wfLBl3MJvjq_fihVDEnoRKZ86 zg7_IIN2~fFHX16(ykrrYbZ@m({BE^hs*gO zk58i6*f+jtq05QuJybd|+0-1MBdqsScT}dky;w)H{d+i~^x!Lfeu_m=AHV3eeZ_x* zYi(CDlf#xpocZ)zaq5ZqpS^b?y9>M8`r>kM6lq~LM@BAf2?ewP75N`l_3(kjP7G5^ z`xTd`Cm3X6Wo{fy`KUz)H*)5PCDuX;RCwz(i0jtFcFl1Sh0l}0>}lLz71I+=jf734j?<7YI*-^hcZQzE_U9SZJk}MS@t%7f`(EyVl zB~?{0bZRw;qMM(?j8&Z<;+mnZR?*;H&GAL$aO_85L6MlmUCgAjHu_>pAi)ptB$6yR zwTXYf1=uB?-(%I>C%ylq(U-)oq)sX|;B`l_Q6!=#H{tm7T&@cUD;sy?jw1+4psCuH z2(6aEO0qs+M(spbcJ59!{QV0SiOchbiULU7x*48(3&pbirIg8QWGs>FzqwMgBKzCl zLdFkQ*pl-nl^bsD97aqf?cg}=Z;Ndqt~Xy=Qg!?_t7m%+v>0>2$^CQOC#T&qC_CI+ z69^SWrh7hG*||^1=|B%5qi?F*e8t32#eCXaJbu&dltSvNfb!rGEs74H1#oPPs?tD` zFQqV9B?+j1d%!G9BE=~EelBUWaN91d?o8=Vmb0Lj8 zE|V=9*BJomK& z+)Y%h2*;^cq{9y-fRWH_sDon8{u}P7ijdQ0RycU50X)Ca{HWVbbc$z2e!wk7dGOQb zP@M6~p9_qiVG7&TSe%@*C`$%Do0v;Tq22cjP2Ymo;K(fWkB#%kZh!B_&2PwL`~v3e zryQLeRp(B2C_MK?I8;{&m-Zk*yZM+rd^W+{8mH&^aQBssuOPk60bV7!W+0TUc7^tZ zU%dg_s(So8tfIwo66TH^CFfcR%2|jr!Q(dwZE81J{8D2F@7$F&@dwJn-?e0^m;yng z-!YKnt|JKuDnfh7+|PJ33;)6FudN!z%$$8h)t|YQZNqlxDqDiA;#rIqBS08#r1%Mm zg@^XULo**{`!TpQODHh3X#XoZi(xW-Y+OLU#~97tM*8*T4v(Ik<#y8x9cOGRqY>0 zHXiS}4Lz*X)I&Hn{ak}3NW^Su=+U8n`WTWOxQdfr2ODpUdaWFshaSf%1%v7UxfGJ( zN2st+He}sNlGG6%mjyUo9(h^q!r2V_KUAY#0YN+kfjEP0LJ1i>nftE?KhdwB#Mv+V zFQHfvAv5xq7o!iYRYYugFYDR&v<7Pj$cHG+)kT>)HAr_fL#O2PKnla?gUQa{$no}k zz^ZGzuts!fD4G{qi8)>aVo4#pxT-EN{VR9mY?r4)hs%(u>;;TNHFk;SJeK807w!^r z=N;|1bSTyb)oqe=aK3d~3nvtD!nv=mfN(9gI-+~JvM;y3fLgdEhL|)NPBJ!;EL%+H z|DX5#zj-6Du?XLs2y|*p%xZX-Ydtf!&yN4|l_D@=FNR?z(wJg&s1uqqN`kn3z8-1! zn=7;mQZ|Q0-+{8Oo~$wtYP0&#!jfP+AX*=?OV?ZM;XpGyR$09JxF|KRg3NyG85K91 zOlcuW@eH$gvusQt;HifrbNhJ+Rxv!gOh!UjM$?Z~ji3xoivj>-xUQt9pBD(zsP~N6 zaHKwIP4Wk9Pql8qYzvtpq+xAh6_ReSgHx_&$Rq;VJ>2u_@_y_pBA%S)gb=O^Vr^5R zmB{EbGIko}thT@2n~~6U%3Qc#E3+CUlws?*prTP(mV~Fa{wc4C8+Irngw4NAn5REX zX?r$D#;ILmqgKA`ejJb%SGwAEL~#2!;XUi#+r-}bT=qWtCN@m=2N1)W09$fukW5T0 zO0xoV;7P~pa`ZhsiZ@1vM8{hoJ-8+1fXT;d)jcN{@v}fGosEy3%iXw^7Au5@Oe{G9 zhB6a?zM3npH(AyWm|L4W*rQlB3=(|bM>G8G;nC&DQ)LYou^y>FgO6kZ)f*2tR}uc( z+0UwOZ;CCC7O8)Q!6`4V9+x-BisJ6j*dOq+q|zuFy~s;00ypJrG1^pMZ)1ly;pwrJ zsS>-Vy*;r}HSI9)*yA*ZqandOaol7iI2N`RB7W+->LONdO!la_iGg*$OhvtHZTTbb z4+ezvPvash!#cy`Bm=yXETM$#?TI1YAPY`p81LpKdf#eQ!3181YIm$;|C7vGq=LX~@^a!9!55J?FJ>lSH>~zu<62mj+Ha8Wr zw)Y=8gY&cTw;V?1I*4-FqG;mJM?b{4%d$L~e0{g-knT4cGrVJEc^i+^5v|+vl-t+l;4sV_;+Vt&ZYXv$I?Lt@XdB zx3B8m(-IiUsm|T`Ct&)-he>1EC?;fqgJi#LwcQk~LgOb?RPX0`b z)7P}RJ{WB0qo;8fi=in>&l>I)q0?AKC1l_Q%{mMt+gdLb)|L_nkWSPp$jirNvA4Ij zA|tv?l~q~>c6QQ{8G)guIy1-m)=M|BrLOhjgas%N)&w2@VX|=$8G`Uz%(6=1Zf-}1a}Ag1bNdq9h3JGX?zv( z#Wc@PbIbzTy)zZ>Ra61bIkS8bj=z1Od>`nQ7%9^XAgaY_n&g#+xd<6Ks zy?^1A5h*G!7c$?S88BTqRW2iR517DFv5;k=Yk1zPpo7m|Hk;pABM^bXGSGED+t{P6et5 zn<&WDhfZs4{jgoTq$H1yRlY@9GCWLQp~%9vYF-oVrC_oqEKHxo>O8YesQAy;f@sMu z3T7^VQbtltRt$2W@U;+*9JM_=odq+bIWj&ze3&YU`1g070UHNLL3=HDKMgvHsvae+ zG@Oe{?_C#P{jQfF3VyEN+S5`(ct}*r&d{x~+3_21_Ra-2Nh|GJ7SGPrl_6iL-f>H3 zr?g&S#xm`Ka;$~tG{0Mj;@s>eU2?3bb%njKLt8H&fRw*o<6XW?T{VQu`_e%_{=~; z@`6ZW@*xtsNO;;Ev+neSjYmcO{C)DEIx2Gf*GDO5E=C%ymh$RcGEa%L2aA8tCx;9* z9aog?k*dnN{I0550Rl8X9iTamvxRQX?RU4f`N@E3o^!8~{B6+9%@YKS5I)1R;an#= z-E*7V6Q&dF()p&P#1iL1$ZwCA3Ydx+MNnWIspILJAgKcnwJu1SK=N z{>9eIbgaRg9Fq4-c%guuv&YmZbYA2>)fyJNKsv!KMsw3jkry!CQ0rg1*JltP1BuKu z+(@d)E!AjATpmBQG9N+SJXDh3jEs~TZ*E(@b|;sF@k8g==MqlFylCiyj&IJeIyzaq z#3u~nz$F{&%YkUqs5<+oPujAA!gdKLWvnTLkj!<_tgYO*q;^=4Nlxe^e8jSu*lGnl zUfs@zrX(Cu-)N>(K=>^w@tmqJ+=pf!*pusHe(N%uI&~0Jzu2`Ty6RgXR8}W7+Hj|Q zUTH^iW#(#hGXJOA4Do6=zM-O_5~3;@+9qSUkTB8%BI2wUAYu;6lFT?swTjldqE3(! zMRxl=;dV{%jQ2J-34!l1N+JdTShX;#$WuDX=81hVS=h^Y3Jg{fR?Q^9W?G7m!7pla5IIkn2<%sN2ia%j@glo4Wib*O$M&ys)8y{cCffE37ck2Pa3- zmkwne?wwPMC|H^%Iby&7#Hum^y}h<(T;me6GKBK+CZetC7+~`JVI_p$@d*lK#p@qF zP!hvqqaL><9&BL~q)3nz#B)nabP<>b*8v`rsgq7#0`FiR63B&}?W%SW^CG5CcIu4j z98$r!1=-N@W~8JctPN=n^5Il+RT}KpIBG}t=eyG(@ zhA5OlbY~UV56>AQW>RQaW&~lq2wg%MMd5O022%baFt3^5}E)o6qv{a$H+@1sf0-Wm3II29Qdz=mfBkTefs|YhEMxq@NfU zUF#ZlaCH@~>qP6zw=55wQ%DZJyz~TWT+jZ(#ocuI2%d|kxIK{{naK%xAb$^&QAHp# zP_EQ_;|I*-ac(bzA{Bk^v0JoUn!1nih1IN#jw4qcsbd`AA8voZ(rsbPl98u*6qcP4WFnW@-Ej5-EV zZkmrIqfiytPVUjcAm_jD%#T6^a}cuO$Pxiw6l+?^rs6m@!q(UgQAqj4%?OIO=fzCr z6(r*l!D5VJUWp!6A$?Gf|mzfv&Cbl;*_dp9@ZTsh(gL5sFxc8alsiL9@|v!eI zW}`thL^x?B>yHlJPLLV4P#!&B{I^6OtC(pT8|s7uMIKbvmM4ooB1Xo$}sWF3Br3uybA7w9_rM) zugl*L|IrCs z>3nP+NLlDD3&_UowL|xGC{N6c7jRx5N~sgG`nRVZ?%3G zFu7ta#%e0NzLp4YY*acm@|WRO7&Z|6807E!AeNo(JK^S#kO7Lm`LM{l*ATzSn@f_Z zIb)v55pIzW7*pe7qgv3UcsIniy8skr(T9Xhdc9}qCIVEkZ zt3xK6Ttj79zeQ(UDhptCRm?@o>Bp zTeLDtt`_1JKoR7WZOKO#3c&=vLy_2+PqBdp83wns3l4z);U5moRIVw8=rnXTwj-+N zc3Dt~hl9IfmVVLx%%ru5Tnjw`-a|xisu0B&+sa)9xIS7UR@|w|YK8Z_{Ygpnv6k>g zcpdl*N;>o$4YXglDV!6bFx)EN+`rQU;;GKj(q+6f@|^kFJ3fPIF9jp~)a4E~o&>+2 zUs??RY0cI?6?^kC>kTQBN-#thICxUu`5f?ZWgql$LYtGup7yOER+M$rR?tkz|9=9z z{~OWtzYFpHSDeAe4$f^}|3P`;@Y<5~p|6W9`+|WsB?YB?E_|=Sy(JCO<%gul=E5Nv{g{9Y2KXi6@8|0az^vfTOQv7Yue+>Y(>$m< z*inm?KrU|+L8y)q^SpCTObL0@ojZ8Op^MeVy2Hlog64eB#p@L&E|Dt1Z|}tw&YjPx z+P|Gl0$wY|LyCLAOCy;Zn>U%Ek~ zI*w#mi@5+1t(@GVpDwwCxTdfduw+1;rN}u@L;s+_wJGv48yjI_B1f_)jt3ZQkshRv z4g0^))7X4SIePB8PHZ@y7e#WUilgI zx-^`TUi_cS4SK<`Cc6>ChBT#zr=CAmt||fNCu&EZH-FqfR_q0vRFm?3*aP z(3_%m7=%B}auC`&RrD7(eYL((*k8B7Wz@=)Tp5D)%$$2WIobOn14Rb_RJ4@9!S^t| z`BR(z@da>8bguTS!1;N~DxF3YC#rGHjN$sv_qo;8V*Kgg?AAZUHhyfJ_`HIwQ-p)Q z@J@ehy$HATK;G<^o&?(;%oTK#^@b%Zq*bu(?(eHoz@b%a9TNcQ4bi}H&F~=}T0<}5 zdFuYEbLOIaaBy(n%*+-nVwi-rHQm|y;YW7iTW7~#8FV13qJuT7?9lptWtVu4^0VsA z#_6Zm=r!*Fn|^G)v+J{vp&7-N?U}Gp9)1=-m+s-8xfx473hq&-syG)V!>d+b{D z*6lwOPgm<4robal?-Xdmx_*K1-2p=Y#BZcT&hllW6p3~kr%oCfbtGycFt zRCKIRyli-S%e*}fAp#f2-|>~aZ*Ol3>KcG+b3urA)9;p@yUN-mZBT7QT5tO05?+3%c$7%gAfCnc*~iZp$`79kjZu#) zWT-z0n4nQ_6vFpB8-zSzD?2r7rsCdqr{#LNMsZEF9W8 z+5W^arxIobok<)vP+(yAA^}^E${t{5HY#%l+VaT2Muxo3$X&> z${$I?f-*BR9Y4h?mfeLa%44?SsPu_<-Bxr*_H!NI=nx23p5+;sUKWi1_q;8 zq0eXARnnPv1fKXqvw0g46EDyPqjCtw9s*C z&C~&PIrYYVJLoWPh?IHE;}0@l3vW(9^K z)zQ1XA$T6{1ipWmdQ^VCKLPhp2`?`j^tI!;jO!6fF1(*+ZV+RJ?`8={(toSm;cn|| zKoF&W;`J!|#sGGsF@>Dl@b4xtvvXLb(~WcCfG$ng$<4AiT6y$iELzS4YqpKt=PazU zT()00mjMB@>@-nVJ8BE(jI*sI3_QL=z7GuiN^2CK&lx^}+p9Ylwur8}aaOZ{=6)Jx~ieuvzJBb5&p&p(`$4Udi)$!=v7 zdHXQ#ix+%qX~s ztAL5F3J)zSZM#}K-?=`HOIgM`P*>estY_s)%r$(>!6fv&xV!6ztX!Emp_!7~N=;9P zEg?zstz*xS8ZOjIHaXn8nsmZkXgpdHJ|K6OLCYa-24_l^8J6H}Pca62({`KimwhST z9wo3z6xdBU&K1ixG(^?jCBzH4x?XQ7*@^eT(9*}A!Si0OPB@V6@&<>iOy*YF?DEO8 z-(8#*YKRkj^OOy!-Zf~G@I6xDZ!MwBG=Ft^(qZ&{I*^^O;=>~X?`<&?#WDfzDc&C0 zcxty;=M?{yB3hn{tOKcad7a9({aKj2>%oLK-%n5Vi`Ex-p`ExV2_@UDgLr_45m7cq zG(ra{sJBC3B9``9@XRozb^k~Q4@EX^X%GmInt&(zLfra%MScmrBqROgtH6+z2%IpD zxEnC2T+i?8c`@jHY;Ivv5xIkec#-y-#!7;}cYPq=34jzq3#DCx`mUt4ilC9@&p6}Rhnh(qIZ?o^}A)Xci`9Y^2* z{M8jXX9_*yp{sV5(>$Y&*DQR!t1Yo zcCIG(6d7$D@2RPmlj_t9&+8D#kdFC1rG%zl!KcH&Pj2yVj$PKF6K0(WLZ9>qMZn)tt}e-gn5TW`!W}UGyk$zD4_|n=&#%k@CZWk z%8eT=A{7+n92$dP|7!UyELR3gi#0QH8)jh969_P)86uPq9JTNGEMqBU8HC?zy#Hpo zC{z&7WT&+#RFD88PaJU5xEq3`+mL;8NMYUM42u`?Ug#86zsgm92admL{FVxYlf3hJ zIl{NFV*sZ!vnZ&Ej_S`ZzW?R2!(sCM0gb~@;pBAtw-=+azCj%JIINup$*0;d0P8X9 ziHla}HRzEYwhiJenzC$d)Z!NnGYm^qsy+V?R=wHQmGL27^3>XokI}vXRi(q4Tx+|x z1n+NtJW5p%CZqbMR~W*Ui8|zN2&7tEb>=xK4P9o4#cV%^z>SeGXRNdHt#;ycb+cb@7j8N zz0Kuk)?37|Z%zse_H(>;npT^p^>Rp&s*(bK&^&)5Soy~72=EW?d0o}>oU?_51!VK# zE2FqDY|bwhfuTKoYq8MHZ9H(RVI+}smqQC=Z0F>ap#)RY6#;JqGRn1n#cAZIn;4QK z?xTMU%|1*fKNps?$f7CW6Po&m=CNp%5NhOLVYJ|2>$IW3!opT%;VznS@gavZ(Mxh{ zG|zG)kF99AU5pD~hIMl2ARdEWE$_lY59k;f5=^a1o#2@b_YnSazoY{Q!I@UvTmw9W zw^(hJK!2Zab6RqUPR^5w!H!TaX$M?g(cHfG*$aU*pIo%R%Z#4TZCy-9RKTspBFuAN zn(I|!6c_LLHx!-wWx0B8wuw#lcVd9BZRA2L0F#7QwJh^FiPM1{m_K?&xsD`PF%*#B7AX%rT2d5xC< z4)pk0(CmVC`j>Z^a}Wir-+f;;habwyk3io%EI_vB)jK0&v>e`h6ucZpHmo}0s(z+8 zq3!VNac$yt+{DV_d~oC$Wx7;&CYLlDr<&pKpuM<+@Cv~+4yO_gk7;i4r42VO-6N
    vuK%6x8=0bi$SSK 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

    oq4KM>0A?$}d$ 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

    c6`!h#7hG?UDH8SFG2 zq`~;2rirzCH-rJ|NgH&o_Vd@7$}w2A(ufkV!qdBF)A5*$I61CCKE1zi)ulmt(j|rf z`n+%@JnL*@v%HRl;4=JU8UHZrf2%tZ0QIP|5g8h=jpGa?HAonVGN6t-+^31Yd9|&a z3HDmA8!GLyt%x4HR+i+;khcapvgo&OR;#i9)LYuo>n>_j{0AqY*O&G)kmcbt(#yzb zH_+o9<@p09q4pl@@xua51p5x_>U|cP99P#a!oIuf4}^zm(Dw3`9cxqnluNr{s8wkH z)cxzrJ7Qd@6zoRT_NtMHTM+0Iu4qk+>KMdcdwm|eRQ9+@Tq#tK_14ujgg0~lp4GVX z`l8o-mZ9Jp(SP{j{r*v=#=YbEURCvtj)3I9dr`-NPJ{?t(TfCM9b#R4+vSD4j$gVj v@5P>?{gku1mV&E3x82|WKS_Z;9B)FK$E?w|yy>tY;73N{n|PI|VZi?aG+A~c diff --git a/voidcrew/modules/cyberware/ripperdoc_catalog.dm b/voidcrew/modules/cyberware/ripperdoc_catalog.dm index 215de22217d..b8d0ad8d257 100644 --- a/voidcrew/modules/cyberware/ripperdoc_catalog.dm +++ b/voidcrew/modules/cyberware/ripperdoc_catalog.dm @@ -573,7 +573,7 @@ /datum/shop_buyback/ripperdoc/shock_coils name = "Shock Coils (pulled)" - desc = "Reflex legs, lightly used. Trade-in credit toward the jump pistons." + desc = "Reflex legs, lightly used." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/shock_coils pay_credits = 250 @@ -582,7 +582,7 @@ /datum/shop_buyback/ripperdoc/second_wind name = "Second Wind Bladder (pulled)" - desc = "A used air reserve. Worth half toward something with a longer bladder." + desc = "A used air reserve." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/second_wind // The Deep-lung is a subtype with its own, higher entry (ware_models_b.dm). diff --git a/voidcrew/modules/cyberware/ware_arm_utility.dm b/voidcrew/modules/cyberware/ware_arm_utility.dm index 8706fc72c4c..4b0bdaeec19 100644 --- a/voidcrew/modules/cyberware/ware_arm_utility.dm +++ b/voidcrew/modules/cyberware/ware_arm_utility.dm @@ -29,7 +29,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw name = "\improper Rockjaw drill fist" - desc = "A compact mining drill that folds out of the forearm. Anything it cracks gets vacuumed straight into an internal hopper, so you aren't crawling around the floor picking up your own ore." + desc = "A compact mining drill that folds out of the forearm, with a hopper that swallows whatever it cracks." icon_state = "rockjaw" chrome_load = 2 tier = CYBERWARE_TIER_1 @@ -58,7 +58,7 @@ /// diamond drill (0.2). The speed is part of what the 1,200 cr buys. /obj/item/pickaxe/drill/cyberware name = "rockjaw drill" - desc = "The business end of a Rockjaw drill fist. It only comes off the arm at a Chrome Cradle." + desc = "The business end of a Rockjaw drill fist." toolspeed = 0.5 /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/Destroy() @@ -160,7 +160,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers name = "\improper Fixer's Fingers" - desc = "A fingertip omnitool suite. Driver, wrench and cutters fold out of the knuckles, and the servo tendons behind them make everything you do with your hands quicker." + desc = "A fingertip omnitool suite. Driver, wrench and cutters fold out of the knuckles on quick servo tendons." icon_state = "fixers" chrome_load = 2 tier = CYBERWARE_TIER_1 diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 6f4705045e9..53cb9a86e6d 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -53,7 +53,7 @@ */ /obj/item/organ/cyberimp/cyberware/cascade name = "\improper Cascade lattice" - desc = "A full-spine lattice of superconducting myelin, the real thing, not one of the knockoffs. For eight seconds at a time you move and nobody else really does. Then all the heat it just made has to go somewhere, and it goes into you." + desc = "A full-spine lattice of superconducting myelin, the real thing, not one of the knockoffs. The bill comes afterward." icon_state = "cascade" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_OS @@ -84,7 +84,7 @@ /// The status alert shown while the window runs. var/window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade /// What the bearer reads on activation. - var/online_message = "CASCADE ONLINE. Eight seconds." + var/online_message = "CASCADE ONLINE." /obj/item/organ/cyberimp/cyberware/cascade/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -114,7 +114,7 @@ /datum/action/cooldown/cyberware/cascade_surge name = "Cascade Surge" - desc = "Eight seconds of overwhelming speed: melee cooldowns halved, 40% chance to dodge ranged fire while moving. Then six seconds of crash. An EMP mid-window crashes you instantly." + desc = "Eight seconds of overwhelming speed: melee cooldowns halved, 40% chance to dodge ranged fire while moving. Then six seconds of crash." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_cascade" cooldown_time = 60 SECONDS @@ -159,7 +159,7 @@ /// Starting opacity of a trail afterimage. var/afterimage_alpha = CYBERWARE_CASCADE_AFTERIMAGE_ALPHA /// What the bearer reads on activation. - var/online_message = "CASCADE ONLINE. Eight seconds." + var/online_message = "CASCADE ONLINE." /// The crash this window rolls into. var/crash_duration = CYBERWARE_CASCADE_CRASH var/crash_stamina = CYBERWARE_CASCADE_CRASH_STAMINA @@ -257,13 +257,13 @@ /atom/movable/screen/alert/status_effect/cyberware_cascade name = "Cascade Window" - desc = "The lattice has the world in slow motion. Make it count. Six seconds of crash follows either way." + desc = "The lattice has the world in slow motion. Make it count." icon_state = "radiation_shield" /// The lesser models' alert (ware_line_sandy.dm), with no crash timer in the text. /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket name = "Reflex Window" - desc = "The rig has the world in slow motion. Make it count. The crash is coming either way." + desc = "The rig has the world in slow motion. Make it count." /// The bill. No dodge source, no speed, the opposite of all of it. /datum/status_effect/cyberware_cascade_crash @@ -358,7 +358,7 @@ */ /obj/item/organ/cyberimp/cyberware/redline name = "\improper Redline core" - desc = "A combat governor rebuilt to fail open. Twelve seconds with every safety limit switched off at once, announced by a chest speaker nobody told you was in there." + desc = "A combat governor rebuilt to fail open, announced by a chest speaker nobody told you was in there." icon_state = "redline" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_OS @@ -422,7 +422,7 @@ /datum/action/cooldown/cyberware/redline_burn name = "Redline Burn" - desc = "Twelve seconds of stun immunity, 40% damage resistance and harder fists, opened with a staggering roar. Ends in a stamina collapse. An EMP mid-window collapses you instantly." + desc = "Twelve seconds of stun immunity, 40% damage resistance and harder fists, opened with a staggering roar. Ends in a collapse." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_redline" cooldown_time = 90 SECONDS @@ -721,7 +721,7 @@ /atom/movable/screen/alert/status_effect/cyberware_redline name = "Redline Burn" - desc = "Every safety limit off at once. Stuns don't land, hits don't hurt much, and your fists hit a great deal harder. You collapse when it ends." + desc = "Every safety limit is off at once." icon_state = "radiation_shield" /datum/status_effect/cyberware_redline_crash @@ -855,7 +855,7 @@ */ /obj/item/organ/cyberimp/cyberware/piledriver name = "\improper Meteor Piledriver frame" - desc = "Prototype launch pistons sleeved over both legs, running into a gyroscopic landing computer that aims you at the floor. It wants an open lane to throw you down, and everyone underneath gets a shadow to look at before you get there." + desc = "Prototype launch pistons sleeved over both legs, running into a gyroscopic landing computer that aims you at the floor." icon_state = "piledriver" zone = BODY_ZONE_L_LEG slot = ORGAN_SLOT_CYBERWARE_LEGS @@ -889,7 +889,7 @@ /datum/action/cooldown/cyberware/piledriver_leap name = "Meteor Leap" - desc = "Launch onto any tile you can see with a clear arc to it, or onto somebody standing on one. Walls, windows and shut doors stop the jump. A shadow warns everyone underneath first. A direct hit crushes whoever is still there: heavy brute, broken bones, flat on the deck. Everything within two tiles is knocked flat and thrown." + desc = "Launch onto a tile you can see, or onto somebody standing on one. A direct hit crushes whoever is still there, and everything within two tiles is knocked flat." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_piledriver" cooldown_time = 60 SECONDS @@ -928,7 +928,7 @@ jumper.face_atom(destination) var/launch_message = "You launch. The landing computer handles the rest." if(isliving(target)) - launch_message = "You launch. The landing computer locks the tile [target] is standing on, if [target.p_they()] move[target.p_s()], you come down on empty deck." + launch_message = "You launch. The landing computer locks onto [target]." jumper.visible_message( span_boldwarning("[jumper]'s leg pistons CRACK the deck and [jumper.p_they()] launch[jumper.p_es()] skyward!"), span_warning(launch_message), diff --git a/voidcrew/modules/cyberware/ware_line_berserk.dm b/voidcrew/modules/cyberware/ware_line_berserk.dm index 68a26efe049..94af4766ef3 100644 --- a/voidcrew/modules/cyberware/ware_line_berserk.dm +++ b/voidcrew/modules/cyberware/ware_line_berserk.dm @@ -33,8 +33,8 @@ burn_roar_range = 0 burn_crash = 6 SECONDS burn_cooldown = 120 SECONDS - burn_action_desc = "Eight seconds of damage resistance and harder fists, announced by the chest speaker. Ends in a stamina collapse. An EMP mid-burn collapses you instantly." - burn_alert_desc = "The safeties are loosened, not gone. Hits don't hurt as much and your fists hit harder. You collapse when it ends." + burn_action_desc = "Eight seconds of damage resistance and harder fists, announced by the chest speaker. Ends in a collapse." + burn_alert_desc = "The safeties are loosened, not gone." /// Milspec: surplus assault issue. Close to the Redline, a size smaller. /obj/item/organ/cyberimp/cyberware/redline/milspec @@ -51,8 +51,8 @@ burn_roar_range = 4 burn_crash = 5 SECONDS burn_cooldown = 105 SECONDS - burn_action_desc = "Ten seconds of stun immunity, 30% damage resistance and harder fists, opened with a staggering roar. Ends in a stamina collapse. An EMP mid-burn collapses you instantly." - burn_alert_desc = "The safeties are off. Stuns don't land, hits don't hurt as much and your fists hit harder. You collapse when it ends." + burn_action_desc = "Ten seconds of stun immunity, 30% damage resistance and harder fists, opened with a staggering roar. Ends in a collapse." + burn_alert_desc = "The safeties are off." // ========================================================================= // SPLICE SKUs (wired into the vendors at integration, not here) diff --git a/voidcrew/modules/cyberware/ware_line_camo.dm b/voidcrew/modules/cyberware/ware_line_camo.dm index 3c6774678c4..4b3530bd419 100644 --- a/voidcrew/modules/cyberware/ware_line_camo.dm +++ b/voidcrew/modules/cyberware/ware_line_camo.dm @@ -38,7 +38,7 @@ ) /datum/action/cooldown/cyberware/ghostskin_cloak/street - desc = "Five seconds of patchy optical camo, enough to fool people and not much else. Attacking, shooting, throwing, taking a hit or an EMP drops it." + desc = "Five seconds of patchy optical camo." cooldown_time = 45 SECONDS // ========================================================================= @@ -58,7 +58,7 @@ /// Trade-in at half list, credits only, like the other street rungs. /datum/shop_buyback/ripperdoc/ghostskin_street name = "Flickerskin weave (pulled)" - desc = "A patchy camo weave you've outgrown. Worth half toward the real thing." + desc = "A patchy camo weave you've outgrown." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/ghostskin/street pay_credits = 1200 diff --git a/voidcrew/modules/cyberware/ware_line_deck.dm b/voidcrew/modules/cyberware/ware_line_deck.dm index d700bab458a..da79df272b3 100644 --- a/voidcrew/modules/cyberware/ware_line_deck.dm +++ b/voidcrew/modules/cyberware/ware_line_deck.dm @@ -33,7 +33,8 @@ * - EMP and brownout kill the deck (the cyberware action bridge); Chrome Read * shows it. * - Outpost property and outpost prison doors (cells and staff doors) refuse - * every hack outright, so a deck never springs a prisoner. Overload refuses any + * every hack outright, and so do a prison wing's APC and lights (Kill the + * Power, Burst the Lights), so a deck never springs a prisoner. Overload refuses any * machine on an outpost, trader or player (owner, D12: decks never blow up * outpost machines). A person hacked at a trader outpost counts as an attack * on them there, the same as a punch, and Arc Flash does not run there at all. @@ -280,8 +281,8 @@ // ---- Reach ------------------------------------------------------------- /// Whether the deck can work on this target from here: the bearer can see, -/// the target is not outpost property or a prison door, and it is in range and -/// in a clear line. +/// the target is not outpost property or part of a prison wing (a door, its APC +/// or a light in it), and it is in range and in a clear line. /datum/action/cooldown/cyberware/deck/proc/can_reach(mob/living/carbon/bearer, atom/target, feedback = FALSE) var/obj/item/organ/cyberimp/cyberware/deck/deck = get_deck() if(!deck || QDELETED(target)) @@ -290,8 +291,9 @@ if(feedback) bearer.balloon_alert(bearer, "can't see!") return FALSE - // Prison doors too, cells and staff doors alike: no prison break by deck. - if(HAS_TRAIT(target, TRAIT_OUTPOST_PROPERTY) || is_outpost_prison_airlock(target)) + // Prison doors too, cells and staff doors alike, and the wing's own APC and + // lights: no prison break by deck. + if(HAS_TRAIT(target, TRAIT_OUTPOST_PROPERTY) || is_outpost_prison_airlock(target) || is_outpost_prison_fixture(target)) if(feedback) target.balloon_alert(bearer, "hardened firmware!") return FALSE @@ -516,7 +518,7 @@ */ /datum/machine_masshack/ping name = "Ping" - desc = "Every camera, turret and powered door nearby answers the handshake, and shows through the walls for a moment. Only you see it." + desc = "Every camera, turret and powered door nearby answers the handshake and shows through the walls for a moment." radial_icon = 'icons/mob/actions/actions_items.dmi' radial_icon_state = "bci_wireless" cooldown = 20 SECONDS @@ -562,7 +564,7 @@ */ /datum/machine_quickhack/reboot_turret name = "Reboot Turret" - desc = "Force a turret through a cold boot. It goes quiet for a few seconds, then comes back as it was." + desc = "Force a turret through a cold boot. It goes quiet for a few seconds." radial_icon = 'icons/obj/weapons/turrets.dmi' radial_icon_state = "standard_off" cooldown = 20 SECONDS @@ -687,7 +689,7 @@ /datum/shop_buyback/ripperdoc/deck_junker name = "Junker deck (pulled)" - desc = "A soldered-up cortex deck, pulled. Worth something toward a real one." + desc = "A soldered-up cortex deck, pulled." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/deck match_subtypes = FALSE @@ -697,7 +699,7 @@ /datum/shop_buyback/ripperdoc/deck_street name = "Street deck (pulled)" - desc = "A street cortex deck, pulled. Half its price toward a better one." + desc = "A street cortex deck, pulled." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/deck/street match_subtypes = FALSE diff --git a/voidcrew/modules/cyberware/ware_line_governor.dm b/voidcrew/modules/cyberware/ware_line_governor.dm index 806f45ddbe2..9de41c9a7fa 100644 --- a/voidcrew/modules/cyberware/ware_line_governor.dm +++ b/voidcrew/modules/cyberware/ware_line_governor.dm @@ -66,7 +66,7 @@ /datum/shop_buyback/ripperdoc/governor_street name = "Governor Patch (pulled)" - desc = "A taped-over governor, pulled. Credit toward a cleaner cut." + desc = "A taped-over governor, pulled." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/governor_delete/street match_subtypes = FALSE diff --git a/voidcrew/modules/cyberware/ware_line_mantis.dm b/voidcrew/modules/cyberware/ware_line_mantis.dm index 2f3792bdedd..8415bf7edbf 100644 --- a/voidcrew/modules/cyberware/ware_line_mantis.dm +++ b/voidcrew/modules/cyberware/ware_line_mantis.dm @@ -174,7 +174,7 @@ /// pay. Credits only, so turning a pair around is always a loss. /datum/shop_buyback/ripperdoc/mantis_street name = "Shiv Blade housing (pulled)" - desc = "A secondhand forearm housing, either side. Counts toward the real blades." + desc = "A secondhand forearm housing, either side." category = "Trade-In" item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street match_subtypes = TRUE diff --git a/voidcrew/modules/cyberware/ware_line_models_a.dm b/voidcrew/modules/cyberware/ware_line_models_a.dm index bee0a607774..19dcf615973 100644 --- a/voidcrew/modules/cyberware/ware_line_models_a.dm +++ b/voidcrew/modules/cyberware/ware_line_models_a.dm @@ -42,7 +42,7 @@ aug_overlay = "hopper_street" leap_range = 3 leap_cooldown = 14 SECONDS - leap_action_desc = "Leap up to three tiles over gaps, tables and people, onto any open floor you can see with a clear lane to it. Walls, windows and shut doors stop the jump." + leap_action_desc = "Leap up to three tiles over gaps, tables and people." // ========================================================================= // VAULTER PISTONS (milspec Hopper) @@ -65,7 +65,7 @@ organ_traits = list(TRAIT_NO_SLIP_WATER) leap_range = 5 leap_cooldown = 6 SECONDS - leap_action_desc = "Leap up to five tiles over gaps, tables and people, onto any open floor you can see with a clear lane to it. Walls, windows and shut doors stop the jump." + leap_action_desc = "Leap up to five tiles over gaps, tables and people." /obj/item/organ/cyberimp/cyberware/hopper/milspec/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() diff --git a/voidcrew/modules/cyberware/ware_line_sandy.dm b/voidcrew/modules/cyberware/ware_line_sandy.dm index 6bd4aec596c..6bc3140fb81 100644 --- a/voidcrew/modules/cyberware/ware_line_sandy.dm +++ b/voidcrew/modules/cyberware/ware_line_sandy.dm @@ -49,7 +49,7 @@ /datum/action/cooldown/cyberware/cascade_surge/street name = "Reflex Surge" - desc = "Six seconds of speed: melee cooldowns cut by a quarter, 15% chance to dodge ranged fire while moving. Then eight seconds of crash. An EMP mid-window crashes you instantly." + desc = "Six seconds of speed: melee cooldowns cut by a quarter, 15% chance to dodge ranged fire while moving. Then eight seconds of crash." cooldown_time = 120 SECONDS /datum/movespeed_modifier/cyberware_cascade/street @@ -85,7 +85,7 @@ /datum/action/cooldown/cyberware/cascade_surge/milspec name = "Reflex Surge" - desc = "Seven seconds of speed: melee cooldowns cut by 40%, 25% chance to dodge ranged fire while moving. Then seven seconds of crash. An EMP mid-window crashes you instantly." + desc = "Seven seconds of speed: melee cooldowns cut by 40%, 25% chance to dodge ranged fire while moving. Then seven seconds of crash." cooldown_time = 90 SECONDS /datum/movespeed_modifier/cyberware_cascade/milspec diff --git a/voidcrew/modules/cyberware/ware_line_zoom.dm b/voidcrew/modules/cyberware/ware_line_zoom.dm index 37fa476a333..e826a570699 100644 --- a/voidcrew/modules/cyberware/ware_line_zoom.dm +++ b/voidcrew/modules/cyberware/ware_line_zoom.dm @@ -104,7 +104,7 @@ /datum/action/cooldown/cyberware/deadeye_tag/marksman name = "Marksman Tag" - desc = "Tag a hostile in view. Your next four shots track them automatically. The tag refuses to take a crewmate." + desc = "Tag a hostile in view. Your next four shots track them." cooldown_time = 18 SECONDS // ========================================================================= @@ -180,7 +180,7 @@ /datum/shop_buyback/ripperdoc/assay_optics name = "Assay Optics (pulled)" - desc = "Budget surveyor eyes. Worth half toward the full suite." + desc = "Budget surveyor eyes, lenses scratched." category = "Trade-In" item_path = /obj/item/organ/eyes/robotic/cyberware/prospector/street match_subtypes = FALSE diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index d8d6b12d426..e5e681d23d1 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -23,7 +23,7 @@ */ /obj/item/cyberware_pair_case name = "cyberware pair case" - desc = "A foam-lined clamshell case for a matched set of chrome. Crack it open in hand." + desc = "A foam-lined clamshell case for a matched set of chrome." icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' icon_state = "ware_case" w_class = WEIGHT_CLASS_NORMAL @@ -39,7 +39,7 @@ . = ..() if(length(contents)) . += span_notice("Nestled in the foam: [english_list(contents)].") - . += span_notice("Use it in hand to crack it open.") + /obj/item/cyberware_pair_case/attack_self(mob/user, modifiers) . = ..() @@ -61,7 +61,7 @@ /obj/item/cyberware_pair_case/gorilla_arms name = "\improper Gorilla Arms case" - desc = "A heavy clamshell case stenciled with a fist. Two myomer lattices inside, one per arm. The Cradle installs the pair in one sitting." + desc = "A heavy clamshell case stenciled with a fist. Two myomer lattices inside, one per arm." ware_types = list( /obj/item/organ/cyberimp/cyberware/gorilla_arms, /obj/item/organ/cyberimp/cyberware/gorilla_arms/left, @@ -114,7 +114,7 @@ */ /obj/item/organ/cyberimp/cyberware/gorilla_arms name = "\improper Gorilla Arms myomer lattice (right)" - desc = "A myomer weave anchored bone-deep through the arm. You don't look any stronger for it; you just start pulling closed airlocks open with your bare hands." + desc = "A myomer weave anchored bone-deep through the arm. You don't look any stronger for it; doors just stop being your problem." icon_state = "gorilla" zone = BODY_ZONE_R_ARM slot = ORGAN_SLOT_RIGHT_ARM_AUG @@ -285,8 +285,8 @@ id = "organ_set_bonus_cyberware_gorilla" organs_needed = 2 required_biotype = NONE - bonus_activate_text = span_notice("Your Gorilla Arms sync up. You can force closed airlocks by hand now. Walk up and pull, out of combat mode.") - bonus_deactivate_text = span_notice("Your arms fall out of sync. Doors stay shut again.") + bonus_activate_text = span_notice("Your Gorilla Arms sync up.") + bonus_deactivate_text = span_notice("Your arms fall out of sync.") /datum/status_effect/organ_set_bonus/cyberware_gorilla/enable_bonus(obj/item/organ/inserted_organ) . = ..() @@ -400,7 +400,7 @@ /datum/action/cooldown/cyberware/mantis_lunge name = "Mantis Lunge" - desc = "Dash up to five tiles onto a target with the blade leading, cutting them open and dropping them on arrival. The blade must be extended. With both blades out, both land." + desc = "Dash up to five tiles onto a target, cutting them open and dropping them on arrival." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_mantis_lunge" cooldown_time = 20 SECONDS @@ -559,7 +559,7 @@ */ /obj/item/melee/cyberware_monowire name = "\improper Widowline monowire" - desc = "A weighted spool of monomolecular line, run live. At rest it's a glitter in the air. Swung, it goes through plate and the person wearing it in the same motion, and through whoever is standing next to them." + desc = "A weighted spool of monomolecular line, run live. At rest it's a glitter in the air." icon = 'voidcrew/modules/cyberware/icons/cyberware_weapons.dmi' icon_state = "monowire" inhand_icon_state = "monowire" @@ -695,7 +695,7 @@ */ /obj/item/ammo_box/magazine/cyberware_ronin name = "\improper Ronin flush-feed magazine (10x24mm)" - desc = "A flush-feed magazine keyed to the Popup Ronin's action and nothing else's. Holds twenty caseless rounds." + desc = "A flush-feed magazine keyed to the Popup Ronin's action and nothing else's." icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' icon_state = "ronin_mag" base_icon_state = "ronin_mag" @@ -742,7 +742,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin name = "\improper Popup Ronin housing" - desc = "A forearm rig that unfolds a skeletal machine-pistol into your grip and folds it away again just as fast. The mag well only takes Ronin flush-feed magazines." + desc = "A forearm rig that unfolds a skeletal machine-pistol into your grip and folds it away again just as fast." icon_state = "ronin" chrome_load = 5 tier = CYBERWARE_TIER_3 @@ -784,7 +784,7 @@ /obj/item/ammo_casing/cyberware_buster name = "30mm shaped rocket" - desc = "A stubby shaped-charge rocket in the Buster's proprietary 30mm. All of the blast goes forward and stops at the first thing it hits." + desc = "A stubby shaped-charge rocket in the Buster's proprietary 30mm." icon_state = "low_yield_rocket" base_icon_state = "low_yield_rocket" caliber = CYBERWARE_CALIBER_BUSTER @@ -832,7 +832,7 @@ */ /obj/item/gun/ballistic/revolver/cyberware_buster name = "\improper Bunker Buster micro-rocket pod" - desc = "A two-tube rocket pod that rides folded along the forearm. The munitions are shaped, so the blast goes into the target instead of the room, and instead of your hull." + desc = "A two-tube rocket pod that rides folded along the forearm." icon = 'voidcrew/modules/cyberware/icons/cyberware_weapons.dmi' icon_state = "bunker_buster" inhand_icon_state = "bunker_buster" @@ -858,7 +858,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/bunker_buster name = "\improper Bunker Buster housing" - desc = "A reinforced forearm bay built around two rocket tubes, with a recoil bed where your radius used to be. Takes proprietary 30mm shaped rockets, so make both count." + desc = "A reinforced forearm bay built around two rocket tubes, with a recoil bed where your radius used to be." icon_state = "bunker_buster" chrome_load = 5 tier = CYBERWARE_TIER_3 diff --git a/voidcrew/modules/cyberware/ware_military_body.dm b/voidcrew/modules/cyberware/ware_military_body.dm index 69ceb0cc267..ab40365204d 100644 --- a/voidcrew/modules/cyberware/ware_military_body.dm +++ b/voidcrew/modules/cyberware/ware_military_body.dm @@ -29,7 +29,7 @@ */ /obj/item/organ/cyberimp/cyberware/ghostskin name = "\improper Ghostskin weave" - desc = "A subdermal lattice of refraction cells. Switched on, you're a faint heat-shimmer and not much else. Swing at anyone and the field drops, mid-swing, out in the open." + desc = "A subdermal lattice of refraction cells. Switched on, you're a faint heat-shimmer and not much else." icon_state = "ghostskin" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_SKIN @@ -104,7 +104,7 @@ playsound(wearer, 'voidcrew/sound/machines/cloaking/on.ogg', 60, TRUE) wearer.visible_message( span_warning("[wearer] ripples and thins to a heat-shimmer outline!"), - span_notice("The weave bends the light around you. Ten seconds."), + span_notice("The weave bends the light around you."), ) /obj/item/organ/cyberimp/cyberware/ghostskin/proc/on_field_expired() @@ -177,7 +177,7 @@ /datum/action/cooldown/cyberware/ghostskin_cloak name = "Ghostskin Field" - desc = "Ten seconds of optical camo. Attacking, shooting, throwing, taking a hit or an EMP drops the field. Anyone standing right next to you still sees you." + desc = "Ten seconds of optical camo." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_ghostskin" cooldown_time = 30 SECONDS @@ -310,7 +310,7 @@ */ /obj/item/organ/cyberimp/cyberware/lazarus name = "\improper Lazarus node" - desc = "A capacitor bank sunk fist-deep in the chest, wired straight across the heart. When you go down it spends a second charging up, then shocks you back onto your feet. Rebuilding that charge takes a lot longer." + desc = "A capacitor bank sunk fist-deep in the chest, wired straight across the heart. It has strong views on you staying down." icon_state = "lazarus" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_HEART_AID @@ -329,7 +329,7 @@ if(COOLDOWN_FINISHED(src, jolt_cooldown)) . += span_notice("The charge bank reads READY.") else - . += span_notice("The charge bank reads RECHARGING, [DisplayTimeText(COOLDOWN_TIMELEFT(src, jolt_cooldown))] left.") + . += span_notice("The charge bank reads RECHARGING.") /obj/item/organ/cyberimp/cyberware/lazarus/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -445,7 +445,7 @@ */ /obj/item/organ/cyberimp/cyberware/atlas name = "\improper Atlas frame" - desc = "A titanium truss bolted through the spine and ribs. Nothing breaks your bones and nothing takes a limb off you. Everything softer than bone is still your problem, and anything you throw leaves your hand like it was fired." + desc = "A titanium truss bolted through the spine and ribs, so the skeleton stops being the weak point." icon_state = "atlas" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_FRAME @@ -461,7 +461,7 @@ /// Whether a throw of the bearer by something else travels half as far. var/resists_knockback = TRUE /// The line examine adds on top of the organ's own. Null for none. - var/frame_examine = "Covers the skeleton only: no dislocations, no fractures, and no losing a limb. Slashes, punctures, burns and bleeding are untouched." + var/frame_examine = null /obj/item/organ/cyberimp/cyberware/atlas/examine(mob/user) . = ..() @@ -599,7 +599,7 @@ */ /obj/item/organ/cyberimp/cyberware/rigger name = "\improper Rigger socket" - desc = "A skull jack that patches a ship's helm straight into your head. Anywhere aboard, you can fly it, as long as the ship still has a helm console for the socket to talk to, and as long as you don't mind walking while you do it." + desc = "A skull jack that patches a ship's helm straight into your head." icon_state = "rigger" zone = BODY_ZONE_HEAD slot = ORGAN_SLOT_CYBERWARE_RIGGER @@ -614,7 +614,6 @@ /obj/item/organ/cyberimp/cyberware/rigger/examine(mob/user) . = ..() - . += span_notice("Opens the helm of whatever ship its bearer stands on, from anywhere on it. It borrows a real console. A hull with no helm left has nothing to answer with.") if(!owner) return if(!linked_ship) @@ -761,12 +760,12 @@ return FALSE if(!linked_ship) pilot.balloon_alert(pilot, "no hull to reach") - to_chat(pilot, span_warning("The socket casts around for a hull and finds solid ground. You have to be aboard a ship.")) + to_chat(pilot, span_warning("The socket casts around for a hull and finds solid ground.")) return FALSE var/obj/machinery/computer/helm/console = find_console() if(!console) pilot.balloon_alert(pilot, "no helm answering!") - to_chat(pilot, span_warning("Nothing aboard [linked_ship] answers the uplink. The socket borrows a helm console, and there isn't a working one left on the ship.")) + to_chat(pilot, span_warning("Nothing aboard [linked_ship] answers the uplink.")) return FALSE if(!console.check_crew_access(pilot)) return FALSE @@ -777,7 +776,7 @@ cyberware_ink_pulse(pilot, CYBERWARE_INK_HARD) pilot.visible_message( span_notice("The jack behind [pilot]'s ear lights up, and [pilot.p_their()] eyes go somewhere else."), - span_notice("The uplink catches [console] aboard [linked_ship]. You are flying the ship from here, at a walk."), + span_notice("The uplink catches [console] aboard [linked_ship]. You are flying the ship from here."), ) console.ui_interact(pilot) return TRUE @@ -812,7 +811,7 @@ /datum/action/cooldown/cyberware/rigger_uplink name = "Helm Uplink" - desc = "Open the ship's helm from wherever you're standing. You move at a walk while it's up, and it drops if you leave the hull, black out, or lose the socket." + desc = "Open the ship's helm from wherever you're standing. You walk slowly while it's up." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "rigger" cooldown_time = 5 SECONDS diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index a7408242a85..ae6b91d3148 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -189,7 +189,7 @@ */ /obj/item/organ/eyes/robotic/cyberware/deadeye name = "\improper Deadeye link" - desc = "Milspec optics built around a target-processing coprocessor. Paint someone and the link walks your next few rounds onto them, whatever the barrel was actually pointed at." + desc = "Military optics built around a targeting coprocessor. It decides where your rounds go, and you aren't consulted." icon_state = "deadeye" chrome_load = 3 tier = CYBERWARE_TIER_2 @@ -275,7 +275,7 @@ /datum/action/cooldown/cyberware/deadeye_tag name = "Deadeye Tag" - desc = "Tag a hostile in view. Your next three shots track them automatically. The tag refuses to take a crewmate." + desc = "Tag a hostile in view. Your next three shots track them." button_icon = 'icons/mob/actions/actions_items.dmi' button_icon_state = "sniper_zoom" cooldown_time = 25 SECONDS @@ -322,7 +322,7 @@ */ /obj/item/organ/cyberimp/cyberware/slipwire name = "\improper Slipwire reflex shunt" - desc = "A reflex arc spliced in parallel with the spinal trunk. While you're moving it sidesteps some of the fire coming your way, because your body moved before you told it to." + desc = "A reflex arc spliced in parallel with the spinal trunk. Your body gets out of the way before you've decided to." icon_state = "slipwire" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_NERVOUS @@ -406,7 +406,7 @@ */ /obj/item/organ/cyberimp/cyberware/dead_channel name = "\improper Dead Channel pain editor" - desc = "A signal processor clamped over the pain nerves. You stop feeling injuries entirely, which keeps you upright and moving where anyone else would fold. The catch is that you can't tell how bad it's gotten. The colour draining out of the world is the only gauge you get." + desc = "A signal processor clamped over the pain nerves. Nothing hurts. That isn't the same as nothing being wrong." icon_state = "dead_channel" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_NERVOUS @@ -623,7 +623,7 @@ */ /obj/item/organ/cyberimp/cyberware/hopper name = "\improper Hopper piston calves" - desc = "Paired myomer pistons sleeved over both calves. Four tiles of flat jump on demand, over railings, tables and whoever's in the way. Flat is the operative word: they will not put you over a wall." + desc = "Paired myomer pistons sleeved over both calves, for clearing railings, tables and whoever's in the way." icon_state = "hopper" zone = BODY_ZONE_L_LEG slot = ORGAN_SLOT_CYBERWARE_LEGS @@ -648,7 +648,7 @@ /datum/action/cooldown/cyberware/hopper_leap name = "Piston Leap" - desc = "Leap up to four tiles over gaps, tables and people, onto any open floor you can see with a clear lane to it. Walls, windows and shut doors stop the jump." + desc = "Leap up to four tiles over gaps, tables and people." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "act_hopper" cooldown_time = 8 SECONDS diff --git a/voidcrew/modules/cyberware/ware_pro_utility.dm b/voidcrew/modules/cyberware/ware_pro_utility.dm index bba6f4596ab..1eb0c3e4784 100644 --- a/voidcrew/modules/cyberware/ware_pro_utility.dm +++ b/voidcrew/modules/cyberware/ware_pro_utility.dm @@ -189,7 +189,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/icepick name = "\improper Icepick Jack" - desc = "A slim data spike folded into the wrist, tipped in monocrystal. Turret IFF buses and door motor controllers all run the same handful of protocols, and the spike knows every one of them. Outpost security hardware is the one exception; nobody has cracked that firmware." + desc = "A slim data spike folded into the wrist, tipped in monocrystal. Turrets and door motors both talk to it a little too easily." icon_state = "icepick" chrome_load = 3 tier = CYBERWARE_TIER_2 @@ -229,7 +229,7 @@ /datum/action/cooldown/cyberware/icepick/suppress name = "Icepick: Suppress" - desc = "Spike a hostile turret in line of sight to knock it offline for a while, or slowly force open a bolted or unpowered door you're standing at." + desc = "Spike a hostile turret in sight to knock it offline, or force open a bolted or unpowered door." // Both channels are on the same spike, so both need their own face on the // HUD: two identical buttons is a misclick waiting to happen. button_icon_state = "act_icepick_suppress" @@ -346,7 +346,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/skyhook name = "\improper Skyhook wrist winch" - desc = "A barbed anchor over a spooled monofilament winch, built through the wrist bones. Fire it into anything solid within seven tiles and hold on." + desc = "A barbed anchor over a spooled monofilament winch, built through the wrist bones. It goes into anything solid and reels you in after it." icon_state = "skyhook" chrome_load = 3 tier = CYBERWARE_TIER_2 @@ -431,7 +431,7 @@ */ /obj/item/organ/eyes/robotic/cyberware/prospector name = "\improper Prospector Suite optics" - desc = "Amber-ringed surveyor eyes. Blink the command and they thump out a resonance pulse: ore veins light up straight through the rock, and any cache transponders in range echo back with them. The handheld scanner needs line of sight. These don't." + desc = "Amber-ringed surveyor eyes that can see ore through solid rock. The pulse that does it makes your skull hum." icon_state = "prospector" eye_color_left = "#b8860b" eye_color_right = "#b8860b" @@ -508,7 +508,7 @@ */ /obj/item/organ/cyberimp/cyberware/hemoglass name = "\improper Hemoglass filter" - desc = "A dialysis loop in borosilicate, teed into the vena cava. It strips poisons, chems and radiation out of your blood a little faster than most things can put them in. Party mode tells it to leave the recreational chems alone." + desc = "A dialysis loop in borosilicate, teed into the vena cava. It cleans your blood faster than most things can foul it. There's a switch for evenings when you'd rather it didn't." icon_state = "hemoglass" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_FILTER @@ -523,7 +523,7 @@ /obj/item/organ/cyberimp/cyberware/hemoglass/ui_action_click() party_mode = !party_mode - owner.balloon_alert(owner, party_mode ? "party mode, recreational channels open" : "filter scrubbing everything") + owner.balloon_alert(owner, party_mode ? "party mode" : "scrubbing everything") /obj/item/organ/cyberimp/cyberware/hemoglass/on_life(seconds_per_tick, times_fired) . = ..() @@ -558,7 +558,7 @@ */ /obj/item/organ/cyberimp/cyberware/coolant name = "\improper Coolant Loop lattice" - desc = "Kilometres of microbore coolant line threaded between muscle and skin: a full climate system for your body. You can work in lava country and walk out of fires that would cook anyone else. It eats a lot of chrome load to run." + desc = "Kilometres of microbore coolant line threaded between muscle and skin, which makes you a climate system with a pulse." icon_state = "coolant" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_SEAL @@ -628,7 +628,7 @@ */ /obj/item/organ/cyberimp/cyberware/doppler name = "\improper Heartbeat Doppler" - desc = "A sonar transceiver socketed into the bone behind your ear. Anything alive and moving within nine tiles shows up through walls; anything holding still doesn't register at all. The ping is quiet, but it's out loud, so you're not the only one who knows it's running." + desc = "A sonar transceiver socketed into the bone behind your ear. It paints whatever is moving nearby, walls or no walls, and the ping is louder than the brochure said." icon_state = "doppler" zone = BODY_ZONE_HEAD slot = ORGAN_SLOT_CYBERWARE_EARS @@ -831,7 +831,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/graverobber name = "\improper Graverobber's Jack" - desc = "An arm-mounted data spike that reads intel out of dead bodies. Spend three seconds on an adjacent corpse and nearby loot caches and hostile patrols get marked on your HUD." + desc = "An arm-mounted data spike for going through the implants of the recently dead." icon_state = "graverobber" chrome_load = 2 tier = CYBERWARE_TIER_2 @@ -840,12 +840,6 @@ /// with it instead of leaving blips on a client that no longer owns them. var/list/hud_marks = list() -/obj/item/organ/cyberimp/arm/toolkit/cyberware/graverobber/examine(mob/user) - . = ..() - . += span_notice("Once installed, use the Data-Spike the Dead ability on a dead body next to you. The read takes 3 seconds. It marks loot caches within [CYBERWARE_GRAVEROBBER_SWEEP_RANGE] tiles in amber and hostile patrols in red on your HUD for a few seconds.") - . += span_notice("Occasionally a read recovers a vault phrase, marking caches within [CYBERWARE_GRAVEROBBER_DEEP_SWEEP_RANGE] tiles instead.") - . += span_notice("Each body can only be read once, and the spike takes 20 seconds to recharge between reads.") - /obj/item/organ/cyberimp/arm/toolkit/cyberware/graverobber/on_mob_remove(mob/living/carbon/arm_owner, special = FALSE, movement_flags) clear_marks() return ..() @@ -931,7 +925,7 @@ */ /obj/item/organ/cyberimp/arm/toolkit/cyberware/angler name = "\improper Harpoon Spool" - desc = "A forearm winch spooled with monofilament under a barbed head. Fired, it drags loose gear and small creatures to you from six tiles out, anything heavier drags you instead." + desc = "A forearm winch spooled with monofilament under a barbed head. It reels in whatever it hits, or you toward it, depending on who's heavier." icon_state = "angler" chrome_load = 2 tier = CYBERWARE_TIER_2 @@ -939,7 +933,7 @@ /datum/action/cooldown/cyberware/harpoon name = "Harpoon Barb" - desc = "Fire the barb up to six tiles: loose items and small creatures come to you. Anything heavier drags you a tile toward it instead." + desc = "Fire the barb up to six tiles. Loose items and small creatures come to you; anything heavier pulls you toward it." button_icon = 'voidcrew/modules/cyberware/icons/cyberware.dmi' button_icon_state = "angler" cooldown_time = 8 SECONDS diff --git a/voidcrew/modules/cyberware/ware_second_wind.dm b/voidcrew/modules/cyberware/ware_second_wind.dm index 1bedd34217e..75a98197b43 100644 --- a/voidcrew/modules/cyberware/ware_second_wind.dm +++ b/voidcrew/modules/cyberware/ware_second_wind.dm @@ -41,7 +41,7 @@ /obj/item/organ/cyberimp/cyberware/second_wind name = "\improper Second Wind bladder" - desc = "A vacuum-cured polymer bladder plumbed around the lungs. When the air runs out it kicks in with a hiss and gives you about three minutes to do something about it, then refills off every breath you take normally. You'll know when it's running: the mask-breathing is loud and the indicator is hard to miss." + desc = "A vacuum-cured polymer bladder plumbed around the lungs. When the air runs out it takes over with a hiss." icon_state = "second_wind" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_SEAL @@ -71,7 +71,7 @@ /obj/item/organ/cyberimp/cyberware/second_wind/examine(mob/user) . = ..() var/seconds_left = round(reserve / (1 SECONDS)) - . += span_notice("The reserve gauge reads [seconds_left] seconds of air[reserve < max_reserve ? ", refilling while its bearer breathes normally" : " (full)"].") + . += span_notice("The reserve gauge reads [seconds_left] seconds of air.") /obj/item/organ/cyberimp/cyberware/second_wind/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -139,7 +139,7 @@ update_alert(source) if(!low_warned && reserve <= CYBERWARE_SECOND_WIND_LOW) low_warned = TRUE - to_chat(source, span_warning("Your bladder gauge drops into the red, [round(reserve / (1 SECONDS))] seconds of air left.")) + to_chat(source, span_warning("Your bladder gauge drops into the red.")) playsound(source, 'sound/machines/buzz/buzz-two.ogg', 30, TRUE) // This IS a successful breath as far as the body is concerned. source.failed_last_breath = FALSE @@ -157,7 +157,7 @@ source.balloon_alert(source, "[LOWER_TEXT(format_text(name))] engages") source.visible_message( span_notice("[source] takes a sharp breath, and something under [source.p_their()] ribs starts working."), - span_notice("SEAL ENGAGED. The bladder takes your breathing off you. You have [round(reserve / (1 SECONDS))] seconds of air."), + span_notice("SEAL ENGAGED. The bladder takes your breathing off you."), span_hear("You hear a pneumatic hiss and the rhythm of mask breathing."), vision_distance = COMBAT_MESSAGE_RANGE, ) @@ -190,7 +190,7 @@ indicator.color = running_low ? "#ffb347" : "#4dd8e6" // Named for the bladder actually fitted, so a Deep-lung never reads as a Second Wind. indicator.name = "[format_text(name)] engaged" - indicator.desc = "Your [format_text(name)] is doing your breathing for you. [seconds_left] second\s of reserve air left[running_low ? ". Find air." : "."]" + indicator.desc = "Your [format_text(name)] is doing your breathing for you. The gauge reads [seconds_left] second\s." /** * Whether the mob's surroundings hold enough oxygen to breathe unassisted: diff --git a/voidcrew/modules/cyberware/ware_street.dm b/voidcrew/modules/cyberware/ware_street.dm index 4ca0296974c..95f1136a59e 100644 --- a/voidcrew/modules/cyberware/ware_street.dm +++ b/voidcrew/modules/cyberware/ware_street.dm @@ -68,7 +68,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/chromatic_dermis name = "\improper Chromatic Dermis ink suite" - desc = "Programmable tattoo ink and the spider-legged injector that lays it under the skin. The traces light up whenever the rest of your chrome does something: a punch landing, a tool folding out, the air bladder kicking in. Colour and pattern are set at a Chrome Cradle." + desc = "Programmable tattoo ink and the spider-legged injector that lays it under the skin. The traces light up whenever the rest of your chrome gets to work." icon_state = "chromatic_dermis" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_INK @@ -276,7 +276,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/eyes/robotic/cyberware/nightshade name = "\improper Nightshade optics" - desc = "Matte black eyes that render a dark room in green. Living bodies in range pick up a soft glow only you can see, so an unlit corridor isn't cover any more. They aren't thermals, so a flashbang hits you no harder than it would normal eyes. The diagnostic bus is the stock one: it counts how much chrome someone is carrying, but can't name any of it." + desc = "Matte black eyes that render a dark room in green. Living bodies pick up a soft glow only you can see." icon_state = "nightshade" eye_color_left = "#1d3b2a" eye_color_right = "#1d3b2a" @@ -409,7 +409,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/scrapper name = "\improper Scrapper's Knuckles" - desc = "Milled knuckle caps grafted along the metacarpals, right-arm fit. Your punches land harder, and against plating and machine housings they land about twice as hard again." + desc = "Milled knuckle caps grafted along the metacarpals, right-arm fit. Hinges and housings come off worse." icon_state = "scrapper" zone = BODY_ZONE_R_ARM slot = ORGAN_SLOT_RIGHT_ARM_AUG @@ -423,7 +423,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( aug_overlay = "scrapper_right" /obj/item/organ/cyberimp/cyberware/scrapper/left - desc = "Milled knuckle caps grafted along the metacarpals, left-arm fit. Your punches land harder, and against plating and machine housings they land about twice as hard again." + desc = "Milled knuckle caps grafted along the metacarpals, left-arm fit. Hinges and housings come off worse." zone = BODY_ZONE_L_ARM slot = ORGAN_SLOT_LEFT_ARM_AUG aug_overlay = "scrapper_left" @@ -517,7 +517,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( /obj/item/storage/case/cyberware/scrapper name = "\improper Scrapper's Knuckles case" - desc = "A foam-lined chrome case holding a matched pair of Scrapper's Knuckles, one per arm. Each side installs separately, though the rig will do both in one sitting." + desc = "A foam-lined chrome case holding a matched pair of Scrapper's Knuckles, one per arm." /obj/item/storage/case/cyberware/scrapper/PopulateContents() new /obj/item/organ/cyberimp/cyberware/scrapper(src) @@ -535,7 +535,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/shock_coils name = "\improper Shock Coil calf pistons" - desc = "Spring-loaded pistons sistered along the calf bones. You get back on your feet much faster after a knockdown, keep your footing on wet decking, and walk away from drops that would break an ankle." + desc = "Spring-loaded pistons sistered along the calf bones, so the floor gets less say in things." icon_state = "shock_coils" zone = BODY_ZONE_L_LEG slot = ORGAN_SLOT_CYBERWARE_LEGS @@ -622,7 +622,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/gastro name = "\improper Gastro Reactor" - desc = "A ceramic-lined digester where your stomach used to be. Rot, floor scrapings, whatever was in the back of the fridge. It all burns the same, faster than a real gut and without the food poisoning." + desc = "A ceramic-lined digester where your stomach used to be. Rot, floor scrapings, whatever was in the back of the fridge: it all burns the same." icon_state = "gastro" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_GUT @@ -668,7 +668,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/cargo_cavity name = "\improper Cargo Cavity" - desc = "A shielded compartment plumbed into the ribcage, sized for one small item. Scanners read straight past it and a pat-down won't find it." + desc = "A shielded compartment plumbed into the ribcage, sized for one small item." icon_state = "cargo_cavity" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_STASH @@ -745,7 +745,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/dermal_mesh name = "\improper Dermal Mesh weave" - desc = "A subdermal lattice of impact polymer. It won't stop anything serious, but knives and small arms hurt noticeably less and the skin over it doesn't tear so easily." + desc = "A subdermal lattice of impact polymer. It won't stop anything serious, but it takes the edge off." icon_state = "dermal_mesh" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_DERMAL @@ -807,7 +807,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( */ /obj/item/organ/cyberimp/cyberware/gecko name = "\improper Gecko Grip palm pads" - desc = "Setae pads laminated into the palms, running the same trick geckos do. Your hands clamp shut on whatever you're holding when you go down, so nobody strips your gun off you, and you can cross tables like flat ground and catch a ledge on the way into a chasm. Cuffs still work fine." + desc = "Setae pads laminated into the palms, running the same trick geckos do. Whatever you're holding stays held." icon_state = "gecko" zone = BODY_ZONE_R_ARM slot = ORGAN_SLOT_CYBERWARE_HANDS diff --git a/voidcrew/modules/cyberware/ware_street_passives.dm b/voidcrew/modules/cyberware/ware_street_passives.dm index f0e306c7b98..d5a50c516eb 100644 --- a/voidcrew/modules/cyberware/ware_street_passives.dm +++ b/voidcrew/modules/cyberware/ware_street_passives.dm @@ -390,7 +390,7 @@ /datum/shop_buyback/ripperdoc/adrenal_pump name = "Adrenal pump (pulled)" - desc = "A used adrenaline pump. Credit toward something sturdier." + desc = "A used adrenaline pump. Splice takes these off your hands." category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/adrenal_pump match_subtypes = FALSE diff --git a/voidcrew/modules/player_outposts/outpost_prison.dm b/voidcrew/modules/player_outposts/outpost_prison.dm index f5df1f8f431..7ec66ee1baf 100644 --- a/voidcrew/modules/player_outposts/outpost_prison.dm +++ b/voidcrew/modules/player_outposts/outpost_prison.dm @@ -15,6 +15,21 @@ var/area/voidcrew/player_outpost/prison/wing = get_area(thing) return istype(wing) ? wing.prison : null +/// Whether this is a prison wing's area, or a cell block extension's while it is still joining the wing +/proc/is_outpost_prison_area(area/place) + return istype(place, /area/voidcrew/player_outpost/prison) || istype(place, /area/voidcrew/player_outpost/prison_extension) + +/// Whether this is the power or lighting of a prison wing: its APC, or any light in it. The cyberdeck +/// leaves these alone along with the doors (cyberware/ware_line_deck.dm). +/proc/is_outpost_prison_fixture(atom/thing) + if(isapc(thing)) + var/obj/machinery/power/apc/apc = thing + if(is_outpost_prison_area(apc.area)) + return TRUE + else if(!istype(thing, /obj/machinery/light)) + return FALSE + return is_outpost_prison_area(get_area(thing)) + /// Authored with its entrance on the south edge; placement rotates it. One map per outpost style /// (outpost_styles.dm), all the same wing tile for tile: the prison code and its tests read its layout. /datum/map_template/outpost_upgrade/prison diff --git a/voidcrew/modules/quickhacks/masshacks.dm b/voidcrew/modules/quickhacks/masshacks.dm index b977f528dff..f41b8d3b493 100644 --- a/voidcrew/modules/quickhacks/masshacks.dm +++ b/voidcrew/modules/quickhacks/masshacks.dm @@ -171,10 +171,7 @@ GLOBAL_LIST_EMPTY(machine_masshacks) */ /datum/machine_masshack/overload name = "Overload the Room" - desc = "Feed every machine around you power at once, each one at full overload strength. Five seconds of the whole \ - room buzzing, then up to twelve real explosions land a fifth of a second apart. It takes the walls with it and \ - it will breach a hull if one is in the pattern. Lights just burst. Nothing that goes up comes back, and you are \ - standing in the middle of it, leave." + desc = "Overload every machine around you at once. Five seconds of buzzing, then up to twelve explosions. You are standing in the middle of it." radial_icon_state = "overload_machine" cooldown = 3 MINUTES @@ -215,9 +212,7 @@ GLOBAL_LIST_EMPTY(machine_masshacks) */ /datum/machine_masshack/arc_flash name = "Arc Flash" - desc = "Every powered machine around you starts earthing itself through whoever is standing nearest to it. Five \ - volleys a second and a half apart, 14 burn each, and it comes out of the wall rather than through their hands, \ - so insulated gloves do nothing. It hits everyone in the room except you, friend or not." + desc = "Every powered machine around you earths itself through whoever is standing nearest. Five volleys, 14 burn each, on everyone but you." radial_icon = 'icons/mob/actions/actions_spells.dmi' radial_icon_state = "lightning" cooldown = 2 MINUTES @@ -330,9 +325,7 @@ GLOBAL_LIST_EMPTY(machine_masshacks) */ /datum/machine_masshack/wake name = "Wake the Machines" - desc = "Stand every free-standing machine in the room up to fight for you. Up to eight of them, twice as tough as an \ - animated machine and hitting twice as hard, for two minutes before they fall apart. They know you and nobody \ - else, everyone else in the room is a stranger to them. The machines do not come back." + desc = "Stand up to eight free-standing machines in the room up to fight for you for two minutes." radial_icon_state = "override_machine" cooldown = 3 MINUTES @@ -360,7 +353,7 @@ GLOBAL_LIST_EMPTY(machine_masshacks) for(var/mob/living/second as anything in woken) if(first != second) first.befriend(second) - to_chat(user, span_boldnotice("[length(woken)] of them get up. They know your face and each other's, and nobody else's.")) + to_chat(user, span_boldnotice("[length(woken)] of them get up.")) return TRUE /** diff --git a/voidcrew/modules/quickhacks/quickhacks.dm b/voidcrew/modules/quickhacks/quickhacks.dm index d73e5587224..d93203a7508 100644 --- a/voidcrew/modules/quickhacks/quickhacks.dm +++ b/voidcrew/modules/quickhacks/quickhacks.dm @@ -129,7 +129,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) */ /datum/machine_quickhack/safeties name = "Cut the Safeties" - desc = "Strip the target's safety interlocks. Exactly what an emag would do to it, which depends entirely on what it is." + desc = "Strip the target's safety interlocks." radial_icon = 'icons/obj/card.dmi' radial_icon_state = "emag" cooldown = 25 SECONDS @@ -162,7 +162,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) /datum/machine_quickhack/bolts name = "Bolts" - desc = "Throw the door's bolts, or pull them if they are already down. Works on the AI wire, so a cut door will not answer." + desc = "Throw the door's bolts, or pull them if they are already down." radial_icon_state = "lockdown" cooldown = 8 SECONDS @@ -197,8 +197,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) */ /datum/machine_quickhack/electrify name = "Electrify" - desc = "Run the door's frame live for 30 seconds. It draws on your own current rather than the area's, so a \ - nearly-empty APC still lands the shock. Touching it without insulated gloves is 20 burn and a stun." + desc = "Run the door's frame live for 30 seconds. Touching it without insulated gloves is 20 burn and a stun." radial_icon = 'icons/obj/machines/wallmounts.dmi' radial_icon_state = "apc-spark" cooldown = 20 SECONDS @@ -263,7 +262,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) /datum/machine_quickhack/kill_power name = "Kill the Power" - desc = "Flip an APC's breaker. Lights, equipment and air handling across that whole area go dead until it is flipped back." + desc = "Flip an APC's breaker. Lights, equipment and air handling across that whole area go dead." radial_icon = 'icons/obj/machines/wallmounts.dmi' radial_icon_state = "apc0" cooldown = 30 SECONDS @@ -291,7 +290,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) */ /datum/machine_quickhack/burst_lights name = "Burst the Lights" - desc = "Push an APC's lighting circuit until every bulb in its area breaks. They stay broken." + desc = "Push an APC's lighting circuit until every bulb in its area breaks." radial_icon_state = "blackout" cooldown = 30 SECONDS @@ -331,7 +330,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) */ /datum/machine_quickhack/feedback_surge name = "Feedback Surge" - desc = "Dump everything a person's gear is holding into the person. Empties every power cell they are carrying and shocks them for up to 60 burn, scaled to how much charge there was." + desc = "Empty every power cell a person is carrying through the person, for up to 60 burn." radial_icon = 'icons/obj/weapons/baton.dmi' radial_icon_state = "stunbaton_active" cooldown = 60 SECONDS @@ -369,7 +368,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) */ /datum/machine_quickhack/cutout name = "Cutout" - desc = "Quietly empty every power cell a person is carrying and switch their radios off for 45 seconds. No shock and no stun; their gear just stops." + desc = "Quietly empty every power cell a person is carrying and switch their radios off for 45 seconds." radial_icon = 'icons/obj/weapons/baton.dmi' radial_icon_state = "stunbaton_nocell" cooldown = 35 SECONDS @@ -404,7 +403,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) /datum/machine_quickhack/silence name = "Silence" - desc = "Jam every radio within nine tiles of what you clicked for 45 seconds, and switch the ones already in range off outright for as long." + desc = "Jam every radio within nine tiles of what you clicked for 45 seconds." radial_icon = 'icons/obj/devices/syndie_gadget.dmi' radial_icon_state = "jammer" cooldown = 40 SECONDS diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index b2b1e46a7d1..9936c58208e 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -77,6 +77,7 @@ #include "voidcrew_cyberware_models_a.dm" #include "voidcrew_cyberware_models_b.dm" #include "voidcrew_cyberware_passives.dm" +#include "voidcrew_cyberware_text.dm" #include "voidcrew_cyberware_zoom.dm" #include "voidcrew_derelict_helpers.dm" #include "voidcrew_derelict_layout.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index c694968e2c5..d854bb2a5b0 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -255,7 +255,7 @@ // printable augments unrated, and skips what it can't see. var/itemized = jointext(cyberware_scan_readout(lab_rat, 2), "\n") TEST_ASSERT(findtext(itemized, uppertext(optics.name)), "An itemized chrome read didn't name the installed optics: [itemized]") - TEST_ASSERT(findtext(itemized, "T1"), "An itemized chrome read didn't rate the tier 1 optics: [itemized]") + TEST_ASSERT(findtext(itemized, "STREET"), "An itemized chrome read didn't rate the street-grade optics: [itemized]") TEST_ASSERT(findtext(itemized, "UNRATED"), "An itemized chrome read rated a printable augment as parlor chrome: [itemized]") TEST_ASSERT(!findtext(itemized, uppertext(stash.name)), "An itemized chrome read named the ORGAN_HIDDEN Cargo Cavity: [itemized]") TEST_ASSERT(findtext(itemized, "SIGNATURES 2"), "An itemized chrome read miscounted the visible signatures: [itemized]") diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm index efbe649b528..39f6ba18121 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm @@ -197,6 +197,34 @@ var/obj/machinery/door/airlock/plain_door = allocate(/obj/machinery/door/airlock, get_step(get_step(start, NORTH), NORTH)) TEST_ASSERT(action.can_reach(user, plain_door), "The deck can't reach an ordinary door two tiles off") + // A prison wing's own APC and lights refuse a deck too, so Kill the Power and Burst the Lights + // can't black out the wing. The same fixtures anywhere else answer. The answers are taken first + // and the tile put back before any of them is judged, so a failure leaves no stray prison area. + var/turf/wing_tile = get_step(start, EAST) + var/area/old_area = get_area(wing_tile) + var/obj/machinery/power/apc/open_apc = allocate(/obj/machinery/power/apc, wing_tile) + var/obj/machinery/light/open_light = allocate(/obj/machinery/light, wing_tile) + var/open_apc_reach = action.can_reach(user, open_apc) + var/open_light_reach = action.can_reach(user, open_light) + var/area/voidcrew/player_outpost/prison/wing = new + wing_tile.change_area(old_area, wing) + var/obj/machinery/power/apc/wing_apc = new(wing_tile) + var/obj/machinery/light/wing_light = new(wing_tile) + var/wing_apc_reach = action.can_reach(user, wing_apc) + var/wing_light_reach = action.can_reach(user, wing_light) + var/wing_apc_started = action.Activate(wing_apc) + var/wing_light_started = action.Activate(wing_light) + var/wing_busy = action.busy + wing_tile.change_area(wing, old_area) + qdel(wing_apc) + qdel(wing_light) + qdel(wing) + TEST_ASSERT(open_apc_reach, "The deck can't reach an ordinary APC next to it") + TEST_ASSERT(open_light_reach, "The deck can't reach an ordinary light next to it") + TEST_ASSERT(!wing_apc_reach, "The deck reaches a prison wing's APC") + TEST_ASSERT(!wing_light_reach, "The deck reaches a light in a prison wing") + TEST_ASSERT(!wing_apc_started && !wing_light_started && !wing_busy, "The deck went to work on a prison wing's power or lights") + /// An upload lands only if the hacker holds still and stays online: a step or /// an EMP loses it and costs no cooldown. A clean upload lands and charges the /// deck's cooldown, a hack the target would not take is never offered, and the diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_text.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_text.dm new file mode 100644 index 00000000000..d6b60288c15 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_text.dm @@ -0,0 +1,20 @@ +/** + * # Cyberware text + * + * Wares are described the way a person in the world would describe them: no figures and no talk + * of load, tiers, slots or EMPs. Ability buttons are the one place numbers belong, so they are not + * checked here. + */ +/datum/unit_test/voidcrew_cyberware_text + +/datum/unit_test/voidcrew_cyberware_text/Run() + var/list/roots = list( + /obj/item/organ/cyberimp/cyberware, + /obj/item/organ/eyes/robotic/cyberware, + /obj/item/organ/cyberimp/arm/toolkit/cyberware, + ) + for(var/root in roots) + for(var/obj/item/organ/ware_path as anything in typesof(root)) + var/ware_desc = initial(ware_path.desc) + TEST_ASSERT(!findtext(ware_desc, regex("\[0-9]")), "[ware_path]'s description has a figure in it: [ware_desc]") + TEST_ASSERT(!findtext(ware_desc, regex("\b(tier|load|slot|EMP)\b", "i")), "[ware_path]'s description talks about game rules: [ware_desc]") diff --git a/wiki/content/cyberware.md b/wiki/content/cyberware.md index 51d7722dafd..b97aeeeeb68 100644 --- a/wiki/content/cyberware.md +++ b/wiki/content/cyberware.md @@ -132,7 +132,7 @@ Every pair of chrome eyes can zoom: focus on something far off and your view pul A cyberdeck sits in the back of the skull and talks to machines and other people's gear. Use it, pick something you can see, and the deck offers only what that target will take: doors, lights, a room's power, turrets, radios, a person's gear and the chrome inside them. Pick yourself and it offers what it can do to the room around you. A deck works through your zoom, too. -Every push draws a trace line to the target and makes their gear chirp, and it fails if you move, lose sight or get stunned. No deck opens a locked door, and outpost security, prison doors included, ignores them all. The Junker deck and the Street deck sell for credits at both counters; the Facilities and Breacher decks, one for doors, radios and turrets and one for people and their gear, are vouchers from Splice; and the Blackbox deck, which does both, is legend chrome. +Every push draws a trace line to the target and makes their gear chirp, and it fails if you move, lose sight or get stunned. No deck opens a locked door, and outpost security, a prison wing's doors, power and lights included, ignores them all. The Junker deck and the Street deck sell for credits at both counters; the Facilities and Breacher decks, one for doors, radios and turrets and one for people and their gear, are vouchers from Splice; and the Blackbox deck, which does both, is legend chrome. ## Finding chrome in the wild From 677a1c7956576c293554e0ff9578366e1e7c81ff Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:07:18 -0800 Subject: [PATCH 140/472] Add the chrome test kit, a chrome soak and a roster-wide interaction matrix, and fix what they found Admin: "Chrome: Test Kit" (Debug) drops a Chrome Cradle, vouchers and credits beside the admin and offers Blade dancer, Hacker, Tank, Street kid, an everything crate and a chrome dummy. A build goes into the admin's body or a crate; what does not fit under the neural capacity or has no limb is crated and named in chat. The cradle returns no checkpoint type so a saved ship never rebuilds one. Tests: voidcrew_opt_in tests are left out of a general run and run only when test_only names them. voidcrew_cyberware_soak puts ten chromed humans through five minutes of abilities, EMPs, brownouts, cradle swaps, sell-backs, deaths, gibs, lost limbs, stuns, cuffs and mech rides with a consistency sweep and a leak audit. voidcrew_cyberware_matrix walks every chrome type against EMP, brownout, death and revival, gib and dust, a missing limb, removal, pacifism, stun and mech, camo, a click on the bearer and a ship save. voidcrew_cyberware_kit covers the kit. Fixes the matrix found: - Chrome could be installed into a body with no such limb (runtime in bodypart_insert, load still counted). cyberware_has_limb() now gates the Cradle, surgery and forced windows. - Arm housings kept two signal registrations on the body they were pulled from. - The Scrapper and Gorilla punches, the Mantis lunge and the Piledriver landing ignored pacifism. - Gorilla and Scrapper punches, the lunge and the landing did not drop the Ghostskin field. - Hopper, Piledriver, Mantis lunge and Skyhook fired while stunned, paralysed or immobilised. - Gecko Grips kept the table pass and the chasm catch through an EMP or brownout, and a bearer with dark pads could cross a chasm. - The Angler's barb fired at its own bearer when they clicked themselves. --- tgstation.dme | 1 + voidcrew/modules/admin/chrome_test_kit.dm | 237 +++++ voidcrew/modules/cyberware/chrome_cradle.dm | 7 + voidcrew/modules/cyberware/cyberware_base.dm | 20 + .../modules/cyberware/cyberware_stealth.dm | 10 + voidcrew/modules/cyberware/ware_legend.dm | 8 + .../modules/cyberware/ware_military_arms.dm | 10 +- .../modules/cyberware/ware_military_body.dm | 5 +- voidcrew/modules/cyberware/ware_pro_combat.dm | 2 + .../modules/cyberware/ware_pro_utility.dm | 5 +- voidcrew/modules/cyberware/ware_street.dm | 30 +- voidcrew/modules/unit_tests/_unit_tests.dm | 3 + .../unit_tests/_voidcrew_test_selection.dm | 18 +- .../unit_tests/voidcrew_cyberware_kit.dm | 79 ++ .../unit_tests/voidcrew_cyberware_matrix.dm | 775 +++++++++++++++ .../unit_tests/voidcrew_cyberware_soak.dm | 920 ++++++++++++++++++ 16 files changed, 2120 insertions(+), 10 deletions(-) create mode 100644 voidcrew/modules/admin/chrome_test_kit.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_kit.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_matrix.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm diff --git a/tgstation.dme b/tgstation.dme index cc7446b1155..821ddf29ff7 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7028,6 +7028,7 @@ #include "voidcrew\mapping\shuttles\syndicate\geneva.dm" #include "voidcrew\mapping\shuttles\syndicate\hyena.dm" #include "voidcrew\mapping\spawners\nanites.dm" +#include "voidcrew\modules\admin\chrome_test_kit.dm" #include "voidcrew\modules\admin\feature_testing.dm" #include "voidcrew\modules\admin\overmap_management.dm" #include "voidcrew\modules\admin\overmap_management_bans.dm" diff --git a/voidcrew/modules/admin/chrome_test_kit.dm b/voidcrew/modules/admin/chrome_test_kit.dm new file mode 100644 index 00000000000..7df73f36dda --- /dev/null +++ b/voidcrew/modules/admin/chrome_test_kit.dm @@ -0,0 +1,237 @@ +/** + * # Chrome test kit + * + * One admin verb for trying the cyberware lines without a shopping trip. It drops a Chrome + * Cradle beside the admin with a stack of trade vouchers and a holochip of credits, then offers + * a short list of ready-made builds (see GLOB.chrome_test_loadouts) that either go straight into + * the admin's own body or into a crate at their feet, plus a chromed human to point Chrome Read, + * a cyberdeck or Glitch at. + * + * Installs here are special inserts, the same door the unit tests use: no cradle sequence, no + * boot splash, no install context. They still obey the body's rules in two ways. A piece whose + * limb is missing is not forced into a body that cannot hold it, and a piece that would push the + * body past its neural capacity goes into the crate instead of browning the whole build out + * (the Cascade and Redline cores share the OS slot with the Governors, so the heavy builds do + * not fit on a stock body). The chat report lists what went where and the final load. + * + * Admin only (R_ADMIN|R_DEBUG), logged like the other feature-testing verbs. The loadouts are + * plain typepath lists so they are easy to retune. + */ + +/** + * The ready-made builds, in install order. Order matters twice: a piece is skipped when the + * body no longer has headroom, so the ones listed first are the ones that stay; and pairs list + * the right arm first, its left twin after. + */ +GLOBAL_LIST_INIT(chrome_test_loadouts, list( + "Blade dancer" = list( + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left, + /obj/item/organ/cyberimp/cyberware/cascade, + /obj/item/organ/cyberimp/cyberware/slipwire, + ), + "Hacker" = list( + /obj/item/organ/cyberimp/cyberware/deck/flagship, + /obj/item/organ/eyes/robotic/cyberware/lookout, + /obj/item/organ/cyberimp/cyberware/ghostskin, + ), + "Tank" = list( + /obj/item/organ/cyberimp/cyberware/gorilla_arms, + /obj/item/organ/cyberimp/cyberware/gorilla_arms/left, + /obj/item/organ/cyberimp/cyberware/slabskin, + /obj/item/organ/cyberimp/cyberware/atlas, + /obj/item/organ/cyberimp/cyberware/redline, + ), + "Street kid" = list( + /obj/item/organ/cyberimp/cyberware/cascade/street, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street/left, + /obj/item/organ/cyberimp/cyberware/ghostskin/street, + /obj/item/organ/cyberimp/cyberware/hopper/street, + /obj/item/organ/eyes/robotic/cyberware/spyglass, + /obj/item/organ/cyberimp/cyberware/clotter_node, + /obj/item/organ/cyberimp/cyberware/medic_palms, + /obj/item/organ/cyberimp/cyberware/subvocal_mic, + /obj/item/organ/cyberimp/cyberware/muffler_ear, + ), +)) + +/// The kit's menu entries that are not loadouts. +#define CHROME_KIT_EVERYTHING "Everything (one of each, in a crate)" +#define CHROME_KIT_DUMMY "Chrome dummy (a chromed human to test on)" +#define CHROME_KIT_BASICS "Cradle, vouchers and credits only" + +/// Vouchers and credits the kit hands out per use. +#define CHROME_KIT_VOUCHERS 30 +#define CHROME_KIT_CREDITS 20000 + +/** + * Every piece of chrome a ripperdoc shelf sells, one of each, as typepaths: the loose organs, + * and the cased pairs as the case (cracking it open spills both halves). Read off the shop's + * own SKU datums so it follows the roster by itself, plus the one piece no shelf sells. + */ +/proc/chrome_kit_everything_types() + var/list/found = list() + for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku/ripperdoc)) + var/item_path = initial(sku_type.item_path) + if(!item_path || found[item_path]) + continue + if(ispath(item_path, /obj/item/organ) || ispath(item_path, /obj/item/cyberware_pair_case) || ispath(item_path, /obj/item/storage/case/cyberware)) + found[item_path] = TRUE + // The Meteor Piledriver is a mission prize, not on any shelf. + found[/obj/item/organ/cyberimp/cyberware/piledriver] = TRUE + return assoc_to_keys(found) + +/// A free turf beside the user for a prop: open floor with nothing solid on it, nearest first. +/proc/chrome_kit_adjacent_turf(atom/user) + var/turf/origin = get_turf(user) + if(!origin) + return null + for(var/direction in GLOB.cardinals) + var/turf/candidate = get_step(origin, direction) + if(!candidate || candidate.density || isspaceturf(candidate)) + continue + var/blocked = FALSE + for(var/atom/movable/occupant as anything in candidate) + if(occupant.density || istype(occupant, /obj/machinery/chrome_cradle)) + blocked = TRUE + break + if(!blocked) + return candidate + return origin + +/** + * Seats a build into a body with special inserts, in order. A piece stays out when the body has + * no limb for it, when it would not fit under the body's neural capacity (the same netted check + * the cradle uses, so a same-slot swap counts) or when the insert is refused. Returns the + * organs left over, ready for a crate; `report` collects a plain line per piece for the chat + * report. + */ +/proc/chrome_kit_install(mob/living/carbon/target, list/ware_types, list/report) + var/list/left_over = list() + var/turf/here = get_turf(target) + for(var/ware_type in ware_types) + var/obj/item/organ/ware = new ware_type(here) + var/reason + if(!target.get_bodypart(deprecise_zone(ware.zone))) + reason = "no limb for it" + else if(!cyberware_insert_check(ware, target, silent = TRUE)) + reason = "no neural headroom" + else if(!ware.Insert(target, special = TRUE)) + reason = "the body refused it" + if(reason) + report += "[ware.name] left out ([reason])" + left_over += ware + return left_over + +/// A crate of loose chrome at a turf. Items placed straight into the crate, so its slot count +/// never turns the big builds away. +/proc/chrome_kit_crate(turf/where, list/contents_list, crate_name = "chrome crate") + var/obj/structure/closet/crate/crate = new(where) + crate.name = crate_name + for(var/obj/item/spare in contents_list) + spare.forceMove(crate) + return crate + +/// The admin's chrome status line: load against capacity and any piece that is dark. +/proc/chrome_kit_status_line(mob/living/carbon/target) + var/load = get_chrome_load(target) + var/capacity = get_chrome_capacity(target) + var/dark = 0 + for(var/obj/item/organ/ware as anything in get_installed_cyberware(target)) + if(ware.organ_flags & ORGAN_FAILING) + dark++ + return "[length(get_installed_cyberware(target))] pieces, load [load]/[capacity][dark ? ", [dark] dark" : ""]" + +ADMIN_VERB(chrome_test_kit, R_ADMIN|R_DEBUG, "Chrome: Test Kit", "Drop a Chrome Cradle with vouchers and credits beside you, then take a ready-made chrome build (into your body or a crate) or a chromed dummy to test on.", ADMIN_CATEGORY_DEBUG) + var/mob/admin_mob = user.mob + var/turf/origin = get_turf(admin_mob) + if(!origin) + to_chat(user, span_warning("You need a physical location to drop the kit at.")) + return + + var/list/menu = list() + for(var/loadout_name in GLOB.chrome_test_loadouts) + menu += loadout_name + menu += CHROME_KIT_EVERYTHING + menu += CHROME_KIT_DUMMY + menu += CHROME_KIT_BASICS + var/choice = tgui_input_list(user, "Which build?", "Chrome Test Kit", menu) + if(!choice) + return + + // Where it goes, for the two choices that have a body to put it in. + var/into_body = FALSE + if(GLOB.chrome_test_loadouts[choice]) + if(iscarbon(admin_mob)) + var/where = tgui_input_list(user, "Install it where?", "Chrome Test Kit", list("Into my body", "Into a crate at my feet")) + if(!where) + return + into_body = where == "Into my body" + else + to_chat(user, span_notice("You have no body to install into, so the build goes in a crate.")) + + // The dummy takes any one of the builds, or a random one. + var/dummy_build + if(choice == CHROME_KIT_DUMMY) + var/list/builds = list("Random") + for(var/loadout_name in GLOB.chrome_test_loadouts) + builds += loadout_name + dummy_build = tgui_input_list(user, "Which build does the dummy carry?", "Chrome Test Kit", builds) + if(!dummy_build) + return + if(dummy_build == "Random") + dummy_build = pick(GLOB.chrome_test_loadouts) + + // The bench. One cradle per kit spot; asking twice does not stack them. + var/obj/machinery/chrome_cradle/cradle = locate() in view(5, origin) + var/made_cradle = FALSE + if(!cradle) + cradle = new(chrome_kit_adjacent_turf(admin_mob)) + made_cradle = TRUE + new /obj/item/stack/trade_voucher(origin, CHROME_KIT_VOUCHERS) + new /obj/item/holochip(origin, CHROME_KIT_CREDITS) + + var/list/report = list() + var/summary = "[made_cradle ? "a cradle" : "the cradle already here"], [CHROME_KIT_VOUCHERS] vouchers and [CHROME_KIT_CREDITS] cr" + switch(choice) + if(CHROME_KIT_BASICS) + EMPTY_BLOCK_GUARD // the bench, vouchers and credits are the whole kit + if(CHROME_KIT_EVERYTHING) + var/list/everything = list() + for(var/ware_type in chrome_kit_everything_types()) + everything += new ware_type(origin) + chrome_kit_crate(origin, everything, "chrome crate (everything)") + summary += ", a crate with [length(everything)] items" + if(CHROME_KIT_DUMMY) + var/mob/living/carbon/human/dummy = new(chrome_kit_adjacent_turf(admin_mob)) + dummy.fully_replace_character_name(dummy.real_name, "Chrome dummy") + dummy.equip_to_slot_or_del(new /obj/item/clothing/under/color/grey(dummy), ITEM_SLOT_ICLOTHING) + var/list/dummy_spare = chrome_kit_install(dummy, GLOB.chrome_test_loadouts[dummy_build], report) + QDEL_LIST(dummy_spare) // the dummy keeps only what fits + summary += ", a chrome dummy carrying the [dummy_build] build ([chrome_kit_status_line(dummy)])" + else + var/list/ware_types = GLOB.chrome_test_loadouts[choice] + if(into_body) + var/list/spare = chrome_kit_install(admin_mob, ware_types, report) + summary += ", the [choice] build installed ([chrome_kit_status_line(admin_mob)])" + if(length(spare)) + chrome_kit_crate(origin, spare, "chrome crate ([choice], left over)") + summary += ", [length(spare)] left over in a crate" + else + var/list/boxed = list() + for(var/ware_type in ware_types) + boxed += new ware_type(origin) + chrome_kit_crate(origin, boxed, "chrome crate ([choice])") + summary += ", the [choice] build in a crate" + + to_chat(user, span_notice("Chrome test kit: [summary].")) + for(var/line in report) + to_chat(user, span_notice(" [line].")) + message_admins("[key_name_admin(user)] used the chrome test kit ([choice]) at [ADMIN_VERBOSEJMP(origin)].") + log_admin("[key_name(user)] used the chrome test kit ([choice]): [summary].") + BLACKBOX_LOG_ADMIN_VERB("Chrome Test Kit") + +#undef CHROME_KIT_EVERYTHING +#undef CHROME_KIT_DUMMY +#undef CHROME_KIT_BASICS diff --git a/voidcrew/modules/cyberware/chrome_cradle.dm b/voidcrew/modules/cyberware/chrome_cradle.dm index 5fe8e5d27f5..d8d4330b5f6 100644 --- a/voidcrew/modules/cyberware/chrome_cradle.dm +++ b/voidcrew/modules/cyberware/chrome_cradle.dm @@ -805,6 +805,13 @@ return TRUE +// ---- Ship saves --------------------------------------------------------- + +// A cradle belongs to a parlor. A saved hull must never rebuild one (an admin test kit can +// drop one on a ship), or every load would hand that ship a free operating slab. +/obj/machinery/chrome_cradle/checkpoint_type() + return null + // ---- Outpost aggression ------------------------------------------------ // Attacking outpost property is aggression, the cradle included. diff --git a/voidcrew/modules/cyberware/cyberware_base.dm b/voidcrew/modules/cyberware/cyberware_base.dm index 838adf6c857..80182925a27 100644 --- a/voidcrew/modules/cyberware/cyberware_base.dm +++ b/voidcrew/modules/cyberware/cyberware_base.dm @@ -288,6 +288,12 @@ if(!special) cyberware_boot_splash(arm_owner, src) +// tg's arm implants register these two on the bearer and never let go on removal, so a +// housing sold on or reinstalled elsewhere kept answering to the body it was pulled from. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/on_mob_remove(mob/living/carbon/arm_owner, special = FALSE, movement_flags) + . = ..() + UnregisterSignal(arm_owner, list(COMSIG_CARBON_POST_ATTACH_LIMB, COMSIG_KB_MOB_DROPITEM_DOWN)) + // Deploying and stowing arm hardware is the most visible thing a piece of // chrome does, so it is also the ink's most common cue: every toolkit arm // (Fixer's Fingers picking a tool off the radial, the Rockjaw drill, blades, @@ -365,12 +371,23 @@ /proc/cyberware_insert_check(obj/item/organ/ware, mob/living/carbon/target, silent = FALSE, mob/feedback_to) if(!iscarbon(target)) return FALSE + if(!cyberware_has_limb(ware, target, silent)) + return FALSE if(installed_capacity_check(target, list(ware), ware.cyberware_get_incumbents(target), silent)) return TRUE if(!silent && feedback_to && feedback_to != target) feedback_to.balloon_alert(feedback_to, "no neural headroom!") return FALSE +/// Whether the body still has the limb this ware lives in. Chrome cannot go into a limb that +/// is gone: the insert would seat it on a body with nothing to hold it. +/proc/cyberware_has_limb(obj/item/organ/ware, mob/living/carbon/target, silent = FALSE) + if(target.get_bodypart(deprecise_zone(ware.zone))) + return TRUE + if(!silent) + target.balloon_alert(target, "no limb for it!") + return FALSE + /** * Full gate for a live (non-special) Insert(): a legit install context must * be open for this receiver, and the netted capacity must fit. The context @@ -390,6 +407,9 @@ receiver.balloon_alert(receiver, "needs a real rig!") to_chat(receiver, span_warning("The autosurgeon chokes. This needs a real rig.")) return FALSE + // Even a forced window cannot seat chrome in a limb that is not there. + if(!cyberware_has_limb(ware, receiver)) + return FALSE if(!chrome.install_context_forced && !cyberware_insert_check(ware, receiver)) return FALSE chrome.clear_install_context() diff --git a/voidcrew/modules/cyberware/cyberware_stealth.dm b/voidcrew/modules/cyberware/cyberware_stealth.dm index 464083d0f31..9e2a72af13c 100644 --- a/voidcrew/modules/cyberware/cyberware_stealth.dm +++ b/voidcrew/modules/cyberware/cyberware_stealth.dm @@ -126,3 +126,13 @@ GLOBAL_LIST_INIT(cyberware_camo_piercing_types, typecacheof(list( popDown() return FALSE return ..() + +/** + * Drops the bearer's camo field, if it is up. For the abilities that hurt someone without going + * through an attack the field listens for: a lunge, a landing. Same collapse as any other + * contact. + */ +/proc/cyberware_camo_drop(mob/living/carbon/bearer, reason = "field contact") + var/obj/item/organ/cyberimp/cyberware/ghostskin/weave = bearer?.get_organ_slot(ORGAN_SLOT_CYBERWARE_SKIN) + if(istype(weave)) + weave.break_camo(bearer, reason = reason) diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 53cb9a86e6d..8b4a1806070 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -894,6 +894,8 @@ button_icon_state = "act_piledriver" cooldown_time = 60 SECONDS click_to_activate = TRUE + // A stunned, paralysed or bound body does not get to leap out of it. + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_IMMOBILE /datum/action/cooldown/cyberware/piledriver_leap/Activate(atom/target) if(!isliving(owner)) @@ -903,6 +905,10 @@ var/turf/here = get_turf(jumper) if(isnull(destination) || isnull(here) || destination == here) return FALSE + // The landing crushes whoever is under it, so it is not for a pacifist. + if(HAS_TRAIT(jumper, TRAIT_PACIFISM)) + jumper.balloon_alert(jumper, "can't bring yourself to!") + return FALSE if(jumper.buckled || !isturf(jumper.loc)) jumper.balloon_alert(jumper, "can't launch from here!") return FALSE @@ -920,6 +926,8 @@ if(arc_blocker(here, destination)) jumper.balloon_alert(jumper, "no room for the arc!") return FALSE + // The shadow goes down and the landing hurts someone. + cyberware_camo_drop(jumper) StartCooldown() // Liftoff: locked in for the whole arc, committed, like everything // with this much telegraph. The TILE is what gets locked, never the mob, diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index e5e681d23d1..3f9711a5555 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -232,7 +232,8 @@ COOLDOWN_START(src, slam_cooldown, smash_cooldown_time) return COMPONENT_CANCEL_ATTACK_CHAIN - if(!isliving(target)) + // A pacifist's punch at a living thing is the ordinary punch, which refuses it. + if(!isliving(target) || HAS_TRAIT(source, TRAIT_PACIFISM)) return NONE if(!source.can_unarmed_attack()) return COMPONENT_SKIP_ATTACK @@ -405,6 +406,8 @@ button_icon_state = "act_mantis_lunge" cooldown_time = 20 SECONDS click_to_activate = TRUE + // A stunned, paralysed or bound body, or one with its hands tied, does not lunge. + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_IMMOBILE | AB_CHECK_HANDS_BLOCKED /** * The housings whose blade is out right now and will arrive with the lunge. @@ -431,6 +434,9 @@ if(!istype(housing) || !isliving(owner)) return FALSE var/mob/living/lunger = owner + if(HAS_TRAIT(lunger, TRAIT_PACIFISM)) + lunger.balloon_alert(lunger, "can't bring yourself to!") + return FALSE if(!housing.blade_extended()) lunger.balloon_alert(lunger, "extend the blade first!") return FALSE @@ -447,6 +453,8 @@ if(get_dist(lunger, victim) > housing.lunge_range || !can_see(lunger, victim, housing.lunge_range)) lunger.balloon_alert(lunger, "out of reach!") return FALSE + // The blade is about to be used on someone. + cyberware_camo_drop(lunger) // One lunge clock across both housings: start every mantis_lunge button // this owner has, ours included. for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge_action in lunger.actions) diff --git a/voidcrew/modules/cyberware/ware_military_body.dm b/voidcrew/modules/cyberware/ware_military_body.dm index ab40365204d..4f36d3372b8 100644 --- a/voidcrew/modules/cyberware/ware_military_body.dm +++ b/voidcrew/modules/cyberware/ware_military_body.dm @@ -81,7 +81,9 @@ engage_feedback(wearer) // The MOD stealth break list (modules_ninja.dm), minus bump-off, plus // gunfire and throwing: shooting or throwing from inside a cloak breaks it here. - RegisterSignal(wearer, COMSIG_LIVING_UNARMED_ATTACK, PROC_REF(on_unarmed_attack)) + // The early signal too: a fist that takes over the attack (Gorilla, Scrapper) cancels the + // chain before the late one is ever sent. + RegisterSignals(wearer, list(COMSIG_LIVING_EARLY_UNARMED_ATTACK, COMSIG_LIVING_UNARMED_ATTACK), PROC_REF(on_unarmed_attack)) RegisterSignal(wearer, COMSIG_ATOM_BULLET_ACT, PROC_REF(on_bullet_act)) RegisterSignals(wearer, list( COMSIG_MOB_ITEM_ATTACK, @@ -123,6 +125,7 @@ return REMOVE_TRAIT(wearer, TRAIT_CYBER_CAMO, REF(src)) UnregisterSignal(wearer, list( + COMSIG_LIVING_EARLY_UNARMED_ATTACK, COMSIG_LIVING_UNARMED_ATTACK, COMSIG_ATOM_BULLET_ACT, COMSIG_MOB_ITEM_ATTACK, diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index ae6b91d3148..f0c38f5e9eb 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -653,6 +653,8 @@ button_icon_state = "act_hopper" cooldown_time = 8 SECONDS click_to_activate = TRUE + // A stunned, paralysed or bound body does not get to leap out of it. + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_IMMOBILE /datum/action/cooldown/cyberware/hopper_leap/New(Target, original = TRUE) . = ..() diff --git a/voidcrew/modules/cyberware/ware_pro_utility.dm b/voidcrew/modules/cyberware/ware_pro_utility.dm index 1eb0c3e4784..bce756c0260 100644 --- a/voidcrew/modules/cyberware/ware_pro_utility.dm +++ b/voidcrew/modules/cyberware/ware_pro_utility.dm @@ -359,6 +359,8 @@ button_icon_state = "skyhook" cooldown_time = 8 SECONDS click_to_activate = TRUE + // A stunned, paralysed or bound body, or one with its hands tied, does not winch itself out. + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_IMMOBILE | AB_CHECK_HANDS_BLOCKED /// The zipline visual for the current pull. var/datum/beam/zipline /// Traits worn for the duration of the pull, per the grapple gun. @@ -941,7 +943,8 @@ /datum/action/cooldown/cyberware/harpoon/Activate(atom/target) var/mob/living/carbon/bearer = organ.owner - if(isturf(target) || QDELETED(target)) + // Clicking yourself fires the barb at nothing: a line from the bearer to the bearer. + if(isturf(target) || QDELETED(target) || target == bearer) bearer.balloon_alert(bearer, "barb skitters off!") return FALSE if(get_dist(bearer, target) > CYBERWARE_ANGLER_RANGE || !(target in view(CYBERWARE_ANGLER_RANGE, bearer))) diff --git a/voidcrew/modules/cyberware/ware_street.dm b/voidcrew/modules/cyberware/ware_street.dm index 95f1136a59e..93400b12926 100644 --- a/voidcrew/modules/cyberware/ware_street.dm +++ b/voidcrew/modules/cyberware/ware_street.dm @@ -458,6 +458,9 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( var/is_structure_target = ismachinery(target) || isstructure(target) if(!isliving(target) && !is_structure_target) return NONE + // A pacifist's punch at a living thing is the ordinary punch, which refuses it. + if(isliving(target) && HAS_TRAIT(source, TRAIT_PACIFISM)) + return NONE // Past the last "not our punch" guard: this swing is ours from here on, so // stand Redline's bypass top-up down before we take the chain away. The // roll below reads the hand's unarmed damage, which already carries the @@ -832,19 +835,32 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( RegisterSignal(organ_owner, COMSIG_MOB_EQUIPPED_ITEM, PROC_REF(on_item_equipped)) RegisterSignal(organ_owner, COMSIG_MOB_UNEQUIPPED_ITEM, PROC_REF(on_item_unequipped)) RegisterSignal(organ_owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) - passtable_on(organ_owner, REF(src)) // Whatever they were already holding when the pads went in. for(var/obj/item/held in organ_owner.held_items) guard_item(held) - if(catch_ready) - ADD_TRAIT(organ_owner, TRAIT_CHASM_STOPPER, REF(src)) /obj/item/organ/cyberimp/cyberware/gecko/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() drop_guards() UnregisterSignal(organ_owner, list(COMSIG_MOB_EQUIPPED_ITEM, COMSIG_MOB_UNEQUIPPED_ITEM, COMSIG_MOVABLE_MOVED)) - passtable_off(organ_owner, REF(src)) - REMOVE_TRAIT(organ_owner, TRAIT_CHASM_STOPPER, REF(src)) + +// Tables as flat ground and the chasm catch ride the failing-gated passive layer: dark pads +// (EMP, brownout, damage) do neither. The catch used to stay armed through a pulse, and a +// bearer whose pads were dark could cross a chasm because the arming trait was never spent. +/obj/item/organ/cyberimp/cyberware/gecko/chrome_passives_on(mob/living/carbon/bearer) + . = ..() + if(isnull(bearer)) + return + passtable_on(bearer, REF(src)) + if(catch_ready) + ADD_TRAIT(bearer, TRAIT_CHASM_STOPPER, REF(src)) + +/obj/item/organ/cyberimp/cyberware/gecko/chrome_passives_off(mob/living/carbon/bearer) + . = ..() + if(isnull(bearer)) + return + passtable_off(bearer, REF(src)) + REMOVE_TRAIT(bearer, TRAIT_CHASM_STOPPER, REF(src)) /obj/item/organ/cyberimp/cyberware/gecko/Destroy() drop_guards() @@ -968,7 +984,9 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( /obj/item/organ/cyberimp/cyberware/gecko/proc/rearm_catch() catch_ready = TRUE - if(owner) + // Dark pads re-arm too, the trait goes back on with the passive layer. + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + if(owner && chrome?.passives_online) ADD_TRAIT(owner, TRAIT_CHASM_STOPPER, REF(src)) owner.balloon_alert(owner, "grips re-set") diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 9936c58208e..07e35432e9b 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -73,10 +73,13 @@ #include "voidcrew_cyberware.dm" #include "voidcrew_cyberware_camo.dm" #include "voidcrew_cyberware_deck.dm" +#include "voidcrew_cyberware_kit.dm" #include "voidcrew_cyberware_mantis.dm" +#include "voidcrew_cyberware_matrix.dm" #include "voidcrew_cyberware_models_a.dm" #include "voidcrew_cyberware_models_b.dm" #include "voidcrew_cyberware_passives.dm" +#include "voidcrew_cyberware_soak.dm" #include "voidcrew_cyberware_text.dm" #include "voidcrew_cyberware_zoom.dm" #include "voidcrew_derelict_helpers.dm" diff --git a/voidcrew/modules/unit_tests/_voidcrew_test_selection.dm b/voidcrew/modules/unit_tests/_voidcrew_test_selection.dm index 510b0e374e8..b1516aaacb0 100644 --- a/voidcrew/modules/unit_tests/_voidcrew_test_selection.dm +++ b/voidcrew/modules/unit_tests/_voidcrew_test_selection.dm @@ -1,6 +1,12 @@ +/// A test that only runs when asked for by name (test_only, or TEST_FOCUS): soaks and other +/// slow checks that would stretch a general run by minutes. Subtypes are the tests; this +/// parent never runs. +/datum/unit_test/voidcrew_opt_in + abstract_type = /datum/unit_test/voidcrew_opt_in + /// Picks tests at world start from world.params, so one test build can be split across several /// isolated worlds at once. Every param is optional; with none set every test runs. -/// test_only - comma separated type path prefixes; only matching tests run +/// test_only - comma separated type path prefixes; only matching tests run (and the only way to run a voidcrew_opt_in test) /// test_skip - comma separated type path prefixes; matching tests are skipped /// test_shard - "k/n"; tests that walk a long list (every_ship) take every nth entry from k /proc/voidcrew_select_unit_tests(list/tests) @@ -8,6 +14,16 @@ var/list/skip = splittext(world.params["test_skip"] || "", ",") only -= "" skip -= "" + // Opt-in tests are slow soaks: a general run leaves them out, and a TEST_FOCUS or a + // test_only that names them brings them in. + var/list/kept = list() + for(var/test_type in tests) + if(ispath(test_type, /datum/unit_test/voidcrew_opt_in)) + var/datum/unit_test/opt_in_type = test_type + if(!initial(opt_in_type.focus) && !(length(only) && voidcrew_test_path_matches("[test_type]", only))) + continue + kept += test_type + tests = kept if(!length(only) && !length(skip)) return tests var/list/selected = list() diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_kit.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_kit.dm new file mode 100644 index 00000000000..82f6c468317 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_kit.dm @@ -0,0 +1,79 @@ +/** + * # Chrome test kit (voidcrew/modules/admin/chrome_test_kit.dm) + * + * The verb itself needs a client and a menu, so these tests hold the parts under it: every + * ready-made build is made of real chrome, fits one body without evicting itself, comes out + * running, and what does not fit is handed back rather than lost; a body missing a limb is + * not forced to take chrome for it; "everything" really is the whole shelf. + */ + +/datum/unit_test/voidcrew_cyberware_kit_loadouts + +/datum/unit_test/voidcrew_cyberware_kit_loadouts/Run() + TEST_ASSERT(length(GLOB.chrome_test_loadouts) >= 4, "The kit offers only [length(GLOB.chrome_test_loadouts)] builds") + for(var/loadout_name in GLOB.chrome_test_loadouts) + var/list/ware_types = GLOB.chrome_test_loadouts[loadout_name] + TEST_ASSERT(length(ware_types), "The [loadout_name] build is empty") + // No two pieces of a build may want the same slot, or one would evict the other. + var/list/slots = list() + for(var/ware_type in ware_types) + TEST_ASSERT(ispath(ware_type, /obj/item/organ), "[loadout_name]: [ware_type] is not an organ") + var/obj/item/organ/example = ware_type + var/slot_name = initial(example.slot) + TEST_ASSERT(!slots[slot_name], "[loadout_name]: [ware_type] and [slots[slot_name]] share the slot [slot_name]") + slots[slot_name] = "[ware_type]" + var/mob/living/carbon/human/body = allocate(/mob/living/carbon/human/consistent) + var/list/report = list() + var/list/left_over = chrome_kit_install(body, ware_types, report) + for(var/obj/item/organ/spare as anything in left_over) + TEST_ASSERT(!QDELETED(spare) && !spare.owner, "[loadout_name]: a piece left over is deleted or still owned") + qdel(spare) + TEST_ASSERT_EQUAL(length(get_installed_cyberware(body)) + length(left_over), length(ware_types), "[loadout_name]: pieces went missing between the list and the body") + TEST_ASSERT(get_chrome_load(body) <= get_chrome_capacity(body), "[loadout_name]: the kit left the body over capacity ([get_chrome_load(body)] of [get_chrome_capacity(body)])") + for(var/obj/item/organ/ware as anything in get_installed_cyberware(body)) + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + TEST_ASSERT(chrome.is_running(), "[loadout_name]: [ware.type] came out of the kit dark") + // The first piece listed is the one the build is named for; it must always make it. + var/first_type = ware_types[1] + TEST_ASSERT(locate(first_type) in body.organs, "[loadout_name]: its first piece, [first_type], did not install") + +/// A body with no left arm takes the right-arm pieces and hands the left-arm ones back. +/datum/unit_test/voidcrew_cyberware_kit_limbs + +/datum/unit_test/voidcrew_cyberware_kit_limbs/Run() + var/mob/living/carbon/human/body = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/arm = body.get_bodypart(BODY_ZONE_L_ARM) + arm.drop_limb() + qdel(arm) + var/list/report = list() + var/list/left_over = chrome_kit_install(body, list( + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left, + ), report) + TEST_ASSERT_EQUAL(length(left_over), 1, "The left housing should have been handed back, got [length(left_over)] pieces") + var/obj/item/organ/handed_back = left_over[1] + TEST_ASSERT(istype(handed_back, /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/left), "The wrong piece was handed back: [handed_back.type]") + TEST_ASSERT(!handed_back.owner && isturf(handed_back.loc), "The handed-back housing is not loose on the floor") + TEST_ASSERT_EQUAL(length(report), 1, "The kit should name the piece it left out, got [length(report)] lines") + TEST_ASSERT(findtext(report[1], "no limb"), "The kit did not say it was the missing limb: [report[1]]") + TEST_ASSERT_EQUAL(length(get_installed_cyberware(body)), 1, "The right housing did not install on its own") + qdel(handed_back) + +/// The "everything" crate covers every organ any ripperdoc shelf sells, once. +/datum/unit_test/voidcrew_cyberware_kit_everything + +/datum/unit_test/voidcrew_cyberware_kit_everything/Run() + var/list/everything = chrome_kit_everything_types() + TEST_ASSERT(length(everything) > 40, "The everything crate holds only [length(everything)] kinds") + for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku/ripperdoc)) + var/item_path = initial(sku_type.item_path) + if(ispath(item_path, /obj/item/organ) || ispath(item_path, /obj/item/cyberware_pair_case) || ispath(item_path, /obj/item/storage/case/cyberware)) + TEST_ASSERT(item_path in everything, "The everything crate is missing [item_path], which [sku_type] sells") + TEST_ASSERT(/obj/item/organ/cyberimp/cyberware/piledriver in everything, "The everything crate is missing the Piledriver, which no shelf sells") + // Built the way the verb builds it, the crate's contents are loose, real chrome. + var/list/made = list() + for(var/ware_type in everything) + made += allocate(ware_type) + var/obj/structure/closet/crate/crate = chrome_kit_crate(run_loc_floor_bottom_left, made, "test crate") + allocated += crate + TEST_ASSERT_EQUAL(length(crate.contents), length(everything), "The crate lost items on the way in") diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_matrix.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_matrix.dm new file mode 100644 index 00000000000..e9a5ad6f03b --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_matrix.dm @@ -0,0 +1,775 @@ +/** + * # Chrome interaction matrix + * + * Every piece of chrome against the things that happen to a body: an EMP, a brownout, death + * and revival, a gib or a dusting, a lost limb, a pacifist trait, a stun or a ride in a mech. + * The tests walk the whole roster (every non-fixture subtype of the three chrome organ bases), + * so a new ware is covered the day it is added. What each column promises is in the test that + * checks it. The same walk writes one line per ware and condition to data/matrix_observed.txt, + * which is where the written matrix (kept with the cyberware notes) is built from. + * + * Unit-test files compile before voidcrew/_DEFINES/, so the fork defines this file needs are + * restated below under MATRIX_ names. + */ + +/// TRAIT_CYBER_CAMO and TRAIT_CYBER_ZOOMED. +#define MATRIX_TRAIT_CAMO "cyber_camo" +#define MATRIX_TRAIT_ZOOMED "cyber_zoomed" +/// ORGAN_SLOT_CYBERWARE_SKIN, the Ghostskin slot. +#define MATRIX_SLOT_SKIN "cyberware_skin" + +/// Every chrome typepath the matrix and the soak walk: each non-fixture subtype of the three +/// chrome organ bases that has a body zone and a slot. +/proc/voidcrew_cyberware_pool() + var/static/list/pool + if(pool) + return pool + pool = list() + for(var/base in list(/obj/item/organ/cyberimp/cyberware, /obj/item/organ/eyes/robotic/cyberware, /obj/item/organ/cyberimp/arm/toolkit/cyberware)) + for(var/ware_type in subtypesof(base)) + var/path_text = "[ware_type]" + // Fixtures tests define (test_, the ballast pieces, voidcrew_*_test). + if(findtext(path_text, "/test_") || findtext(path_text, "ballast") || findtext(path_text, "_test")) + continue + var/obj/item/organ/example = ware_type + if(!initial(example.zone) || !initial(example.slot)) + continue + pool += ware_type + return pool + +/// What a body looks like to the passive layer: physiology numbers and armour, movement and +/// action speed modifiers. Equal before a ware goes in and while it is dark. +/proc/voidcrew_cyberware_passive_state(mob/living/carbon/human/body) + var/list/state = list() + for(var/var_name in body.physiology.vars) + var/value = body.physiology.vars[var_name] + if(isnum(value)) + state["physiology [var_name]"] = value + for(var/rating in ARMOR_LIST_ALL()) + state["armor [rating]"] = body.physiology.armor.get_rating(rating) + // Damage, lying down and mood come and go on their own; they are not what the chrome put there + // (a Redline crash dumps enough stamina to put its bearer on the floor). + var/list/speed = list() + for(var/modifier in body.movespeed_modification) + if(!findtext("[modifier]", "damage_slowdown") && !findtext("[modifier]", "carbon_crawling")) + speed += "[modifier]" + state["movespeed"] = speed.Join(",") + var/list/action_speed = list() + for(var/modifier in body.actionspeed_modification) + if(!findtext("[modifier]", "sanity_component")) + action_speed += "[modifier]" + state["actionspeed"] = action_speed.Join(",") + return state + +/// Every trait a ware itself is holding on its bearer. +/proc/voidcrew_cyberware_held_traits(mob/living/carbon/human/body, obj/item/organ/ware) + var/list/held = list() + var/ware_ref = REF(ware) + for(var/trait in body._status_traits) + if(ware_ref in body._status_traits[trait]) + held += "[trait]" + return held + +/// The differences between two passive states, as lines. +/proc/voidcrew_cyberware_state_diff(list/before, list/after) + var/list/differences = list() + for(var/key in before) + if(before[key] != after[key]) + differences += "[key] [before[key]] -> [after[key]]" + for(var/key in after) + if(isnull(before[key])) + differences += "[key] gained [after[key]]" + return differences + +/// Chrome-side datums still registered on a body they no longer belong to. Empty when the +/// body's signal table holds only what its current chrome put there. +/proc/voidcrew_cyberware_stale_listeners(mob/living/carbon/human/body) + var/list/stale = list() + for(var/signal in body._listen_lookup) + var/looked_up = body._listen_lookup[signal] + var/list/listeners = islist(looked_up) ? looked_up : list(looked_up) + for(var/datum/listener as anything in listeners) + if(QDELETED(listener)) + stale += "[signal]: deleted [listener.type]" + else if(isorgan(listener)) + var/obj/item/organ/organ = listener + if(organ.owner != body) + stale += "[signal]: [organ.type] (not installed)" + else if(istype(listener, /datum/component/cyberware)) + var/datum/component/cyberware/chrome = listener + var/obj/item/organ/parent_ware = chrome.parent + if(parent_ware?.owner != body) + stale += "[signal]: cyberware component of [parent_ware?.type] (not installed)" + else if(istype(listener, /datum/action)) + var/datum/action/ability = listener + if(ability.owner != body) + stale += "[signal]: action [ability.type] (not owned)" + else if(istype(listener, /datum/status_effect)) + var/datum/status_effect/effect = listener + if(effect.owner != body) + stale += "[signal]: status [effect.type] (not applied)" + return stale + +/// Pulled and held by the matrix tests: a heavy ballast piece that pushes any body past its +/// capacity (sized per body when it goes in). Separate from the soak's, so the two files +/// stand alone. +/obj/item/organ/cyberimp/cyberware/matrix_ballast + name = "matrix ballast" + zone = BODY_ZONE_CHEST + slot = "voidcrew_matrix_ballast" + chrome_load = 25 + +/datum/unit_test/voidcrew_cyberware_matrix + abstract_type = /datum/unit_test/voidcrew_cyberware_matrix + /// Which column of the matrix this test is. + var/column = "?" + /// Lines for data/matrix_observed.txt. + var/list/observed = list() + +/datum/unit_test/voidcrew_cyberware_matrix/Destroy() + if(length(observed)) + var/text = "[observed.Join("\n")]\n" + text2file(text, "data/matrix_observed.txt") + return ..() + +/// One cell: this ware, this condition, what happened. +/datum/unit_test/voidcrew_cyberware_matrix/proc/cell(ware_type, result) + observed += "[column]\t[ware_type]\t[result]" + +/// A fresh body with one ware special-inserted, or null when this ware has no home on it. +/datum/unit_test/voidcrew_cyberware_matrix/proc/fit(ware_type, list/out_ware) + var/mob/living/carbon/human/body = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/ware = allocate(ware_type) + if(!body.get_bodypart(deprecise_zone(ware.zone)) || !ware.Insert(body, special = TRUE)) + qdel(ware) + qdel(body) + return null + out_ware += ware + return body + +/// The cooldown buttons of a ware, reset so only the chrome can stand in the way. +/datum/unit_test/voidcrew_cyberware_matrix/proc/buttons(obj/item/organ/ware) + var/list/found = list() + for(var/datum/action/cooldown/cyberware/ability in ware.actions) + ability.next_use_time = 0 + found += ability + return found + +/// The deployable of an arm piece, if it has one. +/datum/unit_test/voidcrew_cyberware_matrix/proc/arm_item(obj/item/organ/ware) + if(!istype(ware, /obj/item/organ/cyberimp/arm/toolkit/cyberware)) + return null + return locate(/obj/item) in ware.contents + +/// EMP: a pulse takes the whole ware dark and takes every passive with it, and the +/// repair brings it all back exactly as it was. Repeated, so a non-idempotent passive that +/// stacks on every reboot shows. +/datum/unit_test/voidcrew_cyberware_matrix/emp + column = "emp" + +/datum/unit_test/voidcrew_cyberware_matrix/emp/Run() + var/mob/living/carbon/human/bare = allocate(/mob/living/carbon/human/consistent) + var/list/bare_state = voidcrew_cyberware_passive_state(bare) + for(var/ware_type in voidcrew_cyberware_pool()) + var/list/wares = list() + var/mob/living/carbon/human/body = fit(ware_type, wares) + if(!body) + cell(ware_type, "no home on a plain body") + continue + var/obj/item/organ/ware = wares[1] + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + TEST_ASSERT_NOTNULL(chrome, "[ware_type] carries no cyberware component") + var/list/lit_state = voidcrew_cyberware_passive_state(body) + var/list/problems = list() + var/list/cooldowns = buttons(ware) + var/list/ready_before = list() + for(var/datum/action/cooldown/cyberware/ability as anything in cooldowns) + if(ability.IsAvailable()) + ready_before += ability + var/obj/item/arm_thing = arm_item(ware) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = ware + if(arm_thing) + arm_ware.Extend(arm_thing) + var/obj/item/organ/eyes/robotic/cyberware/optics = ware + if(istype(optics)) + optics.engage_zoom(body) + for(var/cycle in 1 to 3) + body.emp_act(EMP_HEAVY) + if(!chrome.emp_down || chrome.is_running() || !(ware.organ_flags & ORGAN_FAILING)) + problems += "a pulse left it running (emp_down [chrome.emp_down], flags [ware.organ_flags])" + if(chrome.passives_online) + problems += "passives stayed on through the pulse" + // One heartbeat: the readout-style passives settle on the organ's own tick. + ware.on_life(2, 1) + var/list/held = voidcrew_cyberware_held_traits(body, ware) + if(length(held)) + problems += "held traits through the pulse: [english_list(held)]" + var/list/leaks = voidcrew_cyberware_state_diff(bare_state, voidcrew_cyberware_passive_state(body)) + if(length(leaks)) + problems += "the passive layer is not bare while dark: [english_list(leaks)]" + for(var/datum/action/cooldown/cyberware/ability as anything in ready_before) + if(ability.IsAvailable()) + problems += "[ability.type] still available while dark" + if(arm_thing && arm_ware.active_item) + problems += "the arm weapon stayed out through the pulse" + if(istype(optics) && optics.is_zoomed()) + problems += "the zoom stayed on through the pulse" + chrome.tune_up() + if(!chrome.is_running() || !chrome.passives_online) + problems += "a tune-up did not bring it back (running [chrome.is_running()], passives [chrome.passives_online])" + var/list/restored = voidcrew_cyberware_state_diff(lit_state, voidcrew_cyberware_passive_state(body)) + if(length(restored)) + problems += "reboot number [cycle] did not restore the body exactly: [english_list(restored)]" + if(arm_thing) + arm_ware.Extend(arm_thing) + if(istype(optics)) + optics.engage_zoom(body) + if(length(problems)) + cell(ware_type, "FAIL: [problems[1]]") + TEST_FAIL("[ware_type]: [english_list(problems)]") + else + cell(ware_type, "ok") + qdel(body) + +/// Brownout: a body past its capacity takes every piece dark with the same bare passive +/// layer, and recovers when the load drops. +/datum/unit_test/voidcrew_cyberware_matrix/brownout + column = "brownout" + +/datum/unit_test/voidcrew_cyberware_matrix/brownout/Run() + var/mob/living/carbon/human/bare = allocate(/mob/living/carbon/human/consistent) + var/list/bare_state = voidcrew_cyberware_passive_state(bare) + for(var/ware_type in voidcrew_cyberware_pool()) + var/list/wares = list() + var/mob/living/carbon/human/body = fit(ware_type, wares) + if(!body) + cell(ware_type, "no home on a plain body") + continue + var/obj/item/organ/ware = wares[1] + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + var/list/lit_state = voidcrew_cyberware_passive_state(body) + var/list/problems = list() + var/list/cooldowns = buttons(ware) + var/list/ready_before = list() + for(var/datum/action/cooldown/cyberware/ability as anything in cooldowns) + if(ability.IsAvailable()) + ready_before += ability + var/obj/item/arm_thing = arm_item(ware) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = ware + if(arm_thing) + arm_ware.Extend(arm_thing) + var/obj/item/organ/eyes/robotic/cyberware/optics = ware + if(istype(optics)) + optics.engage_zoom(body) + var/obj/item/organ/cyberimp/cyberware/matrix_ballast/ballast = allocate(/obj/item/organ/cyberimp/cyberware/matrix_ballast) + var/datum/component/cyberware/ballast_chrome = ballast.GetComponent(/datum/component/cyberware) + ballast_chrome.chrome_load = get_chrome_capacity(body) + 5 + ballast.Insert(body, special = TRUE) + if(!chrome.browned_out || chrome.is_running() || !(ware.organ_flags & ORGAN_FAILING)) + problems += "an overloaded body left it running (browned_out [chrome.browned_out])" + if(chrome.passives_online) + problems += "passives stayed on through the brownout" + ware.on_life(2, 1) + var/list/held = voidcrew_cyberware_held_traits(body, ware) + if(length(held)) + problems += "held traits through the brownout: [english_list(held)]" + var/list/leaks = voidcrew_cyberware_state_diff(bare_state, voidcrew_cyberware_passive_state(body)) + // The ballast is a ware too, so its own (empty) layer is part of the picture. + if(length(leaks)) + problems += "the passive layer is not bare while browned out: [english_list(leaks)]" + for(var/datum/action/cooldown/cyberware/ability as anything in ready_before) + if(ability.IsAvailable()) + problems += "[ability.type] still available while browned out" + if(arm_thing && arm_ware.active_item) + problems += "the arm weapon stayed out through the brownout" + if(istype(optics) && optics.is_zoomed()) + problems += "the zoom stayed on through the brownout" + ballast.Remove(body, special = TRUE) + if(!chrome.is_running() || !chrome.passives_online) + problems += "easing the load did not bring it back (running [chrome.is_running()], passives [chrome.passives_online])" + var/list/restored = voidcrew_cyberware_state_diff(lit_state, voidcrew_cyberware_passive_state(body)) + if(length(restored)) + problems += "easing the load did not restore the body exactly: [english_list(restored)]" + if(length(problems)) + cell(ware_type, "FAIL: [problems[1]]") + TEST_FAIL("[ware_type]: [english_list(problems)]") + else + cell(ware_type, "ok") + qdel(body) + +/// Death and revival: the buttons go dark with the body, and a revived body gets the same +/// passive layer back, however many times it dies. +/datum/unit_test/voidcrew_cyberware_matrix/death + column = "death/revive" + +/datum/unit_test/voidcrew_cyberware_matrix/death/Run() + for(var/ware_type in voidcrew_cyberware_pool()) + var/list/wares = list() + var/mob/living/carbon/human/body = fit(ware_type, wares) + if(!body) + cell(ware_type, "no home on a plain body") + continue + var/obj/item/organ/ware = wares[1] + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + var/list/lit_state = voidcrew_cyberware_passive_state(body) + var/list/lit_traits = voidcrew_cyberware_held_traits(body, ware) + var/list/problems = list() + var/list/cooldowns = buttons(ware) + var/list/ready_before = list() + for(var/datum/action/cooldown/cyberware/ability as anything in cooldowns) + if(ability.IsAvailable()) + ready_before += ability + var/obj/item/arm_thing = arm_item(ware) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = ware + var/obj/item/organ/eyes/robotic/cyberware/optics = ware + for(var/cycle in 1 to 2) + if(arm_thing) + arm_ware.Extend(arm_thing) + if(istype(optics)) + optics.engage_zoom(body) + body.death() + if(ware.owner != body) + problems += "death took it out of the body" + for(var/datum/action/cooldown/cyberware/ability as anything in ready_before) + if(ability.IsAvailable()) + problems += "[ability.type] still available on a dead body" + if(istype(optics) && optics.is_zoomed()) + problems += "the zoom stayed on through death" + body.revive(ADMIN_HEAL_ALL) + if(body.stat == DEAD) + problems += "the fixture would not revive" + break + // Healing refreshes organs, which can clear the shared failing bit; the next tick puts it right. + chrome.update_failing() + if(!chrome.is_running() || chrome.passives_online != !!chrome.is_running()) + problems += "revival left it out of step (running [chrome.is_running()], passives [chrome.passives_online])" + var/list/restored = voidcrew_cyberware_state_diff(lit_state, voidcrew_cyberware_passive_state(body)) + if(length(restored)) + problems += "death number [cycle] changed the passive layer: [english_list(restored)]" + var/list/held_now = voidcrew_cyberware_held_traits(body, ware) + if(length(held_now) != length(lit_traits) || length(held_now ^ lit_traits)) + problems += "death number [cycle] changed the traits it holds" + if(length(problems)) + cell(ware_type, "FAIL: [problems[1]]") + TEST_FAIL("[ware_type]: [english_list(problems)]") + else + cell(ware_type, "ok") + qdel(body) + +/// Gibbing and dusting: the chrome comes out whole on the floor. +/datum/unit_test/voidcrew_cyberware_matrix/gib + column = "gib/dust" + +/datum/unit_test/voidcrew_cyberware_matrix/gib/Run() + for(var/ware_type in voidcrew_cyberware_pool()) + for(var/dusting in list(FALSE, TRUE)) + var/list/wares = list() + var/mob/living/carbon/human/body = fit(ware_type, wares) + if(!body) + if(!dusting) + cell(ware_type, "no home on a plain body") + continue + var/obj/item/organ/ware = wares[1] + var/obj/item/arm_thing = arm_item(ware) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = ware + if(arm_thing) + arm_ware.Extend(arm_thing) + if(dusting) + body.dust() + else + body.gib() + var/problem + if(QDELETED(ware)) + problem = "[dusting ? "dusting" : "gibbing"] destroyed it" + else if(ware.owner) + problem = "it is still owned by the corpse" + else if(!isturf(ware.loc)) + problem = "it ended up in [ware.loc ? ware.loc.type : "nullspace"], not on the floor" + else if(arm_thing && (QDELETED(arm_thing) || arm_thing.loc != ware)) + problem = "its deployed weapon did not go back into it" + cell(ware_type, "[dusting ? "dust" : "gib"]: [problem ? "FAIL: [problem]" : "ok"]") + if(problem) + TEST_FAIL("[ware_type]: [problem]") + +/// Limbs: a limb that is gone cannot take its chrome, and chrome in a limb that goes is let go +/// cleanly with every passive. +/datum/unit_test/voidcrew_cyberware_matrix/limb + column = "missing limb" + +/datum/unit_test/voidcrew_cyberware_matrix/limb/Run() + for(var/ware_type in voidcrew_cyberware_pool()) + var/obj/item/organ/example = ware_type + var/zone = initial(example.zone) + if(!(zone in list(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG))) + cell(ware_type, "n/a (not in a limb)") + continue + // A body with no such limb is offered the chrome the real way. + var/mob/living/carbon/human/maimed = allocate(/mob/living/carbon/human/consistent) + var/obj/item/bodypart/gone = maimed.get_bodypart(zone) + gone.drop_limb() + qdel(gone) + var/obj/item/organ/offered = allocate(ware_type) + var/datum/component/cyberware/offered_chrome = offered.GetComponent(/datum/component/cyberware) + offered_chrome.grant_install_context(maimed) + var/list/problems = list() + var/took = offered.Insert(maimed) + if(took) + problems += "it installed into a body with no [zone]" + if(offered.owner || (offered in maimed.organs)) + problems += "a refused install still left it on the body" + if(QDELETED(offered)) + problems += "a refused install destroyed it" + else if(!isturf(offered.loc)) + problems += "a refused install left it in [offered.loc ? offered.loc.type : "nullspace"]" + if(get_chrome_load(maimed) != 0) + problems += "the maimed body carries load [get_chrome_load(maimed)] for chrome it does not hold" + // Chrome in a limb that is then lost. + var/list/wares = list() + var/mob/living/carbon/human/body = fit(ware_type, wares) + if(body) + var/obj/item/organ/ware = wares[1] + var/obj/item/arm_thing = arm_item(ware) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = ware + if(arm_thing) + arm_ware.Extend(arm_thing) + var/obj/item/bodypart/limb = body.get_bodypart(zone) + limb.dismember() + if(ware.owner) + problems += "it stayed owned by a body that lost the limb" + if(length(voidcrew_cyberware_held_traits(body, ware))) + problems += "it still held traits on a body that lost the limb" + if(get_chrome_load(body) != 0) + problems += "the body kept load [get_chrome_load(body)] after the limb went" + if(arm_thing && !QDELETED(arm_thing) && arm_thing.loc == body) + problems += "the deployed weapon stayed in the hand of a body that lost the arm" + qdel(limb) + if(QDELETED(ware)) + problems += "burning up the limb destroyed it" + qdel(body) + if(length(problems)) + cell(ware_type, "FAIL: [problems[1]]") + TEST_FAIL("[ware_type]: [english_list(problems)]") + else + cell(ware_type, "ok") + qdel(maimed) + +/// Pacifists: a body that cannot hurt anyone cannot hurt anyone with chrome either. Every +/// ware gets the usual ways to hurt: a punch or swing at an adjacent body, then each of its +/// buttons pointed at that body, then a few seconds for any leap to land. +/datum/unit_test/voidcrew_cyberware_matrix/pacifist + column = "pacifist" + +/datum/unit_test/voidcrew_cyberware_matrix/pacifist/Run() + for(var/ware_type in voidcrew_cyberware_pool()) + var/list/wares = list() + var/mob/living/carbon/human/body = fit(ware_type, wares) + if(!body) + cell(ware_type, "no home on a plain body") + continue + var/obj/item/organ/ware = wares[1] + // A second window, so the click lands: the victim stands beside the body. + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent, get_step(body, EAST)) + ADD_TRAIT(body, TRAIT_PACIFISM, TRAIT_SOURCE_UNIT_TESTS) + body.set_combat_mode(TRUE) + body.face_atom(victim) + var/obj/item/arm_thing = arm_item(ware) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = ware + if(arm_thing) + arm_ware.Extend(arm_thing) + click_wrapper(body, victim) + var/launched = FALSE + for(var/datum/action/cooldown/cyberware/ability as anything in buttons(ware)) + ability.Trigger(body, NONE, victim) + if(ability.next_use_time > world.time) + launched = TRUE + click_wrapper(body, victim) + if(launched) + sleep(3 SECONDS) + var/harm = victim.getBruteLoss() + victim.getFireLoss() + victim.getToxLoss() + victim.getOxyLoss() + victim.getStaminaLoss() + var/knocked = victim.IsKnockdown() || victim.IsParalyzed() || victim.IsStun() + if(harm > 0 || knocked) + cell(ware_type, "FAIL: hurt someone while a pacifist ([harm] damage[knocked ? ", knocked down" : ""])") + TEST_FAIL("[ware_type]: a pacifist hurt a bystander with it ([harm] damage[knocked ? ", knocked down" : ""])") + else + cell(ware_type, "ok") + qdel(victim) + qdel(body) + +/// Moving and striking buttons refuse a body that is stunned, paralysed, immobilised or +/// riding a mech, and still work for a healthy one. Only the buttons that carry the bearer +/// across the floor or into a blow are walked: the rest are covered by the other columns. +/datum/unit_test/voidcrew_cyberware_matrix/incapacitated + column = "stunned/mech" + +/datum/unit_test/voidcrew_cyberware_matrix/incapacitated/Run() + var/static/list/movers = list( + /datum/action/cooldown/cyberware/hopper_leap, + /datum/action/cooldown/cyberware/piledriver_leap, + /datum/action/cooldown/cyberware/mantis_lunge, + /datum/action/cooldown/cyberware/skyhook, + ) + for(var/ware_type in voidcrew_cyberware_pool()) + var/list/wares = list() + var/mob/living/carbon/human/body = fit(ware_type, wares) + if(!body) + continue + var/obj/item/organ/ware = wares[1] + var/list/walked = list() + for(var/datum/action/cooldown/cyberware/ability as anything in buttons(ware)) + for(var/mover_type in movers) + if(istype(ability, mover_type)) + walked += ability + if(!length(walked)) + qdel(body) + continue + var/turf/start = get_turf(body) + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent, locate(start.x, start.y + 2, start.z)) + var/obj/item/arm_thing = arm_item(ware) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = ware + var/turf/landing = locate(start.x + 3, start.y, start.z) + var/turf/far_wall = locate(start.x, start.y + 5, start.z) + for(var/datum/action/cooldown/cyberware/ability as anything in walked) + var/list/results = list() + for(var/condition in list("healthy", "stunned", "paralyzed", "immobilized", "cuffed", "in a mech")) + body.forceMove(start) + body.SetStun(0) + body.SetParalyzed(0) + body.SetImmobilized(0) + body.set_handcuffed(null) + // The blade comes out while the body is still well. + if(arm_thing && !arm_ware.active_item) + arm_ware.Extend(arm_thing) + var/obj/vehicle/sealed/mecha/ripley/mech + switch(condition) + if("stunned") + body.Stun(30 SECONDS) + if("paralyzed") + body.Paralyze(30 SECONDS) + if("immobilized") + body.Immobilize(30 SECONDS) + if("cuffed") + body.set_handcuffed(new /obj/item/restraints/handcuffs(body)) + if("in a mech") + mech = new /obj/vehicle/sealed/mecha/ripley(start) + mech.mob_enter(body, silent = TRUE) + ability.next_use_time = 0 + ability.Trigger(body, NONE, victim) + var/fired = ability.next_use_time > world.time + if(!fired) + ability.next_use_time = 0 + ability.Trigger(body, NONE, landing) + fired = ability.next_use_time > world.time + if(!fired) + // The winch wants a wall: the far side of the room. + ability.next_use_time = 0 + ability.Trigger(body, NONE, far_wall) + fired = ability.next_use_time > world.time + results[condition] = fired + if(fired) + sleep(2 SECONDS) + if(mech) + mech.mob_exit(body, silent = TRUE) + qdel(mech) + body.SetStun(0) + body.SetParalyzed(0) + body.SetImmobilized(0) + body.set_handcuffed(null) + var/list/line = list() + for(var/condition in results) + line += "[condition] [results[condition] ? "fires" : "refuses"]" + if(!results["healthy"]) + // The fixture could not aim it; say so rather than pass quietly. + cell("[ware_type] / [ability.type]", "inconclusive, did not fire for a healthy body") + continue + cell("[ware_type] / [ability.type]", english_list(line)) + for(var/condition in list("stunned", "paralyzed", "immobilized", "in a mech")) + if(results[condition]) + TEST_FAIL("[ware_type] / [ability.type] fired for a body that is [condition]") + qdel(victim) + qdel(body) + +/// A button aimed at its own bearer. Click abilities get clicked on yourself, and every one has +/// to take it without a runtime and without leaving the bearer worse for it than a refusal +/// would. (The Angler's barb used to run a line from the bearer to the bearer.) +/datum/unit_test/voidcrew_cyberware_matrix/self_click + column = "self click" + +/datum/unit_test/voidcrew_cyberware_matrix/self_click/Run() + for(var/ware_type in voidcrew_cyberware_pool()) + var/list/wares = list() + var/mob/living/carbon/human/body = fit(ware_type, wares) + if(!body) + continue + var/obj/item/organ/ware = wares[1] + var/obj/item/arm_thing = arm_item(ware) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = ware + if(arm_thing) + arm_ware.Extend(arm_thing) + var/turf/before = get_turf(body) + for(var/datum/action/cooldown/cyberware/ability as anything in buttons(ware)) + ability.Trigger(body, NONE, body) + // Whatever it did, it did not carry the bearer off or take the bearer apart. + TEST_ASSERT(!QDELETED(body) && body.stat != DEAD, "[ware_type] / [ability.type] aimed at its own bearer killed or deleted them") + if(ware_type in list(/obj/item/organ/cyberimp/arm/toolkit/cyberware/angler)) + TEST_ASSERT_EQUAL(get_turf(body), before, "The Angler's barb moved a bearer who fired it at themselves") + cell(ware_type, "self click: ok") + sleep(1) + qdel(body) + +/// Taking a ware out, either way, leaves nothing of it listening to the body it left. +/datum/unit_test/voidcrew_cyberware_matrix/listeners + column = "removal" + +/datum/unit_test/voidcrew_cyberware_matrix/listeners/Run() + var/list/bare_state = voidcrew_cyberware_passive_state(allocate(/mob/living/carbon/human/consistent)) + for(var/ware_type in voidcrew_cyberware_pool()) + for(var/quick in list(FALSE, TRUE)) + var/list/wares = list() + var/mob/living/carbon/human/body = fit(ware_type, wares) + if(!body) + if(!quick) + cell(ware_type, "no home on a plain body") + continue + var/obj/item/organ/ware = wares[1] + var/obj/item/arm_thing = arm_item(ware) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = ware + if(arm_thing) + arm_ware.Extend(arm_thing) + for(var/datum/action/cooldown/cyberware/ability as anything in buttons(ware)) + ability.Trigger(body, NONE, body) + var/list/problems = list() + ware.Remove(body, special = quick) + var/list/stale = voidcrew_cyberware_stale_listeners(body) + if(length(stale)) + problems += "left listening to the body it was pulled from: [english_list(stale)]" + if(length(voidcrew_cyberware_held_traits(body, ware))) + problems += "left traits on the body it was pulled from" + if(length(body.actions)) + problems += "left [length(body.actions)] buttons on the body it was pulled from" + if(arm_thing && arm_ware.active_item) + problems += "still holds its deployed weapon after removal" + if(body.click_intercept) + problems += "left a targeting mode on the body it was pulled from" + var/list/leaks = voidcrew_cyberware_state_diff(bare_state, voidcrew_cyberware_passive_state(body)) + if(length(leaks)) + problems += "left the body changed: [english_list(leaks)]" + cell(ware_type, "[quick ? "quick" : "normal"] removal: [length(problems) ? "FAIL: [problems[1]]" : "ok"]") + if(length(problems)) + TEST_FAIL("[ware_type] ([quick ? "quick" : "normal"] removal): [english_list(problems)]") + qdel(ware) + qdel(body) + +/// Ship saves: a hull checkpoint rebuilds machinery and structures and wipes every loose item, +/// so chrome lying about on a ship is cleared like any other stock and bodies are never +/// kept. The one fitting is the cradle, which a saved ship must never rebuild. +/datum/unit_test/voidcrew_cyberware_matrix/ship_save + column = "ship save" + +/datum/unit_test/voidcrew_cyberware_matrix/ship_save/Run() + var/obj/machinery/chrome_cradle/cradle = allocate(/obj/machinery/chrome_cradle) + TEST_ASSERT(!outpost_checkpoint_saves(cradle), "A ship checkpoint would keep a chrome cradle") + TEST_ASSERT_NULL(cradle.checkpoint_type(), "A chrome cradle has a type to be rebuilt as") + cell("/obj/machinery/chrome_cradle", "not saved by a checkpoint") + // Every chrome organ is a plain item, which the clear-out takes, and none of them is a + // machine or a structure that the checkpoint would keep as a fitting. + for(var/ware_type in voidcrew_cyberware_pool()) + TEST_ASSERT(ispath(ware_type, /obj/item/organ), "[ware_type] is not an organ item") + TEST_ASSERT(!ispath(ware_type, /obj/machinery) && !ispath(ware_type, /obj/structure), "[ware_type] is a machine or structure") + cell("every ware", "organ items: cleared with the loose stock, never kept") + +/// The camo weave's own columns: what ends it. +/datum/unit_test/voidcrew_cyberware_matrix/camo + column = "camo" + +/datum/unit_test/voidcrew_cyberware_matrix/camo/Run() + var/obj/item/organ/cyberimp/cyberware/ghostskin/weave + for(var/weave_type in list(/obj/item/organ/cyberimp/cyberware/ghostskin, /obj/item/organ/cyberimp/cyberware/ghostskin/street)) + for(var/condition in list("emp", "brownout", "death", "stun", "paralyze", "cuffs", "mech", "zoom", "gib")) + var/mob/living/carbon/human/body = allocate(/mob/living/carbon/human/consistent) + weave = allocate(weave_type) + weave.Insert(body, special = TRUE) + weave.engage_camo(body) + var/engaged = HAS_TRAIT(body, MATRIX_TRAIT_CAMO) + var/obj/vehicle/sealed/mecha/ripley/mech + switch(condition) + if("emp") + body.emp_act(EMP_HEAVY) + if("brownout") + var/obj/item/organ/cyberimp/cyberware/matrix_ballast/ballast = allocate(/obj/item/organ/cyberimp/cyberware/matrix_ballast) + ballast.Insert(body, special = TRUE) + if("death") + body.death() + if("stun") + body.Stun(5 SECONDS) + if("paralyze") + body.Paralyze(5 SECONDS) + if("cuffs") + body.set_handcuffed(new /obj/item/restraints/handcuffs(body)) + if("mech") + mech = new /obj/vehicle/sealed/mecha/ripley(get_turf(body)) + mech.mob_enter(body, silent = TRUE) + if("zoom") + var/obj/item/organ/eyes/robotic/cyberware/spyglass/eyes = allocate(/obj/item/organ/eyes/robotic/cyberware/spyglass) + eyes.Insert(body, special = TRUE) + eyes.engage_zoom(body) + if("gib") + body.gib() + var/still = HAS_TRAIT(body, MATRIX_TRAIT_CAMO) + cell("[weave_type]", "[condition]: camo [engaged ? "was on" : "never came on"], then [still ? "stayed on" : "dropped"]") + if(weave_type == /obj/item/organ/cyberimp/cyberware/ghostskin) + TEST_ASSERT(engaged, "Ghostskin would not engage on a healthy body") + // Hard stops: a dark weave, a dead wearer, a stripped body. + if(condition in list("emp", "brownout", "death", "gib")) + TEST_ASSERT(!still, "Ghostskin stayed engaged through [condition]") + else + TEST_ASSERT(!engaged, "the street weave grants camo it should not") + if(mech) + mech.mob_exit(body, silent = TRUE) + qdel(mech) + qdel(body) + qdel(weave) + +/// Doing harm from inside the field drops it, whichever ware does the harm: a punch from a +/// fist that skips the ordinary attack chain, a lunge, a landing. +/datum/unit_test/voidcrew_cyberware_matrix/camo_harm + column = "camo (harm)" + +/datum/unit_test/voidcrew_cyberware_matrix/camo_harm/Run() + for(var/ware_type in list( + /obj/item/organ/cyberimp/cyberware/gorilla_arms, + /obj/item/organ/cyberimp/cyberware/gorilla_arms/street, + /obj/item/organ/cyberimp/cyberware/scrapper, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis, + /obj/item/organ/cyberimp/cyberware/piledriver, + )) + var/mob/living/carbon/human/body = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent, get_step(body, EAST)) + var/obj/item/organ/cyberimp/cyberware/ghostskin/weave = allocate(/obj/item/organ/cyberimp/cyberware/ghostskin) + weave.Insert(body, special = TRUE) + var/obj/item/organ/ware = allocate(ware_type) + TEST_ASSERT(ware.Insert(body, special = TRUE), "[ware_type] would not install beside the weave") + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm_ware = ware + if(istype(arm_ware)) + var/obj/item/blade = locate(/obj/item) in arm_ware.contents + arm_ware.Extend(blade) + body.set_combat_mode(TRUE) + body.face_atom(victim) + weave.engage_camo(body) + TEST_ASSERT(HAS_TRAIT(body, MATRIX_TRAIT_CAMO), "Ghostskin would not engage for [ware_type]") + if(ispath(ware_type, /obj/item/organ/cyberimp/cyberware/piledriver) || istype(arm_ware)) + for(var/datum/action/cooldown/cyberware/ability as anything in buttons(ware)) + ability.Trigger(body, NONE, victim) + else + click_wrapper(body, victim) + var/dropped = !HAS_TRAIT(body, MATRIX_TRAIT_CAMO) + cell("[ware_type]", "camo [dropped ? "dropped" : "stayed up"] when it hurt someone") + if(!dropped) + TEST_FAIL("[ware_type]: the field stayed up while the bearer attacked") + if(ispath(ware_type, /obj/item/organ/cyberimp/cyberware/piledriver) || istype(arm_ware)) + sleep(3 SECONDS) + qdel(body) + qdel(victim) + +#undef MATRIX_TRAIT_CAMO +#undef MATRIX_TRAIT_ZOOMED +#undef MATRIX_SLOT_SKIN diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm new file mode 100644 index 00000000000..a87446b2cd7 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm @@ -0,0 +1,920 @@ +/** + * # Chrome soak + * + * A long, loud, mostly random test for the cyberware module (voidcrew/modules/cyberware/). + * Ten humans get random chrome loadouts and are then put through five minutes of game time of + * everything players and the world do to chrome: every installed ability triggered (with real + * targets, so click abilities fire), EMPs on bodies and on single pieces, forced brownouts, + * cradle installs, removals and tune-ups, sell-backs at the ripperdoc ledgers, death and + * revival, gibbing and dusting (chrome salvage), lost limbs, stuns, cuffs, pacifism, zoom, + * a ride in a mech, speech. A sweep every two seconds checks that the ware's own bookkeeping + * stays consistent. At the end the survivors are healed, stripped of chrome, left alone and + * compared against a snapshot taken before they got any, and everything the soak made is + * deleted and checked for being garbage collected. + * + * It is a SOAK, not a regression test: it fails on any runtime (the error handler fails + * whichever test is running, see docs/UNIT_TESTING_LOCAL.md), on any inconsistency the sweep + * finds, and on anything left behind. It is also slow, so it extends voidcrew_opt_in and is + * left out of a general run. Run it on its own: + * + * dd.exe tgstation.dmb -port -close -trusted -invisible \ + * -params "log-directory=ci&test_only=/datum/unit_test/voidcrew_opt_in/cyberware_soak" + * + * Optional world params: soak_length (deciseconds of storm, default 3000) and soak_seed + * (a number, to repeat a roll of the dice). The report is also written to data/soak_report.txt. + * + * Unit-test files compile before voidcrew/_DEFINES/, so the fork defines this file needs are + * restated below under SOAK_ names. + */ + +/// ORGAN_SLOT_CYBERWARE_SKIN, the Ghostskin slot. +#define SOAK_SLOT_SKIN "cyberware_skin" +/// TRAIT_CYBER_CAMO and TRAIT_CYBER_ZOOMED. +#define SOAK_TRAIT_CAMO "cyber_camo" +#define SOAK_TRAIT_ZOOMED "cyber_zoomed" +/// CYBERWARE_INSTALL_TIME, CYBERWARE_REMOVAL_TIME, CYBERWARE_EMP_DOWNTIME and CYBERWARE_TUNEUP_FEE. +#define SOAK_INSTALL_TIME (4 SECONDS) +#define SOAK_REMOVAL_TIME (3 SECONDS) +#define SOAK_EMP_DOWNTIME (9 SECONDS) +#define SOAK_TUNEUP_FEE 200 +/// How long an inconsistency must hold before the sweep calls it a bug. SSmobs ticks every +/// two seconds and the ware's per-tick settle runs on it, so anything shorter is a race. +#define SOAK_PATIENCE (6 SECONDS) + +/// Ballast for forcing a brownout: far over any body's capacity, in a slot nothing else uses, +/// with no ability of its own. +/obj/item/organ/cyberimp/cyberware/soak_ballast + name = "soak ballast" + zone = BODY_ZONE_CHEST + slot = "voidcrew_soak_ballast" + chrome_load = 25 + +/datum/unit_test/voidcrew_opt_in/cyberware_soak + priority = TEST_LONGER + /// Game time the storm runs for. + var/storm_length = 3000 + /// Bodies kept in play. + var/crew_size = 10 + /// FALSE once the storm is over, so late async events stand down. + var/storming = FALSE + /// Live bodies. + var/list/crew = list() + /// Body -> its snapshot from before it had any chrome. + var/list/baselines = list() + /// Chrome that came off a body or out of a gib and lies about, for the cradle to reinstall. + var/list/loose_chrome = list() + /// Weakrefs to everything this test made that must be collected at the end. + var/list/made = list() + /// Every installable chrome typepath. + var/list/ware_pool = list() + /// Floor turfs in the room. + var/list/floor_turfs = list() + /// Splice's ledgers (the street doc's are the same entries with other words). + var/list/ledgers = list() + /// Problem category -> how many times it was seen. + var/list/problem_counts = list() + /// Problem category -> the first example. + var/list/problem_examples = list() + /// Counters for the report. + var/list/stats = list() + /// Sweep keys seen in this sweep. + var/list/seen_now = list() + /// Sweep key -> world.time it was first seen. + var/list/suspects = list() + /// Sweep keys already reported. + var/list/reported = list() + var/obj/machinery/chrome_cradle/cradle + /// The cradle is single-use at a time; this is the world.time its current user lets go. + var/cradle_lease = 0 + var/mech_lease = 0 + var/next_index = 0 + var/runtimes_at_start = 0 + var/skipped_runtimes_at_start = 0 + var/timers_at_start = 0 + /// SSgarbage failure and hard-delete counts per type before the storm. + var/list/gc_before = list() + +// ---- Bookkeeping --------------------------------------------------------- + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/note(category, detail) + problem_counts[category] = (problem_counts[category] || 0) + 1 + if(!problem_examples[category]) + problem_examples[category] = detail + log_world("SOAK PROBLEM: [category]: [detail]") + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/count(what, amount = 1) + stats[what] = (stats[what] || 0) + amount + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/track(datum/thing) + made += WEAKREF(thing) + return thing + +/// A sweep finding that only counts once it has held for `patience`. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/suspect(key, category, detail, patience = SOAK_PATIENCE) + seen_now[key] = TRUE + if(!suspects[key]) + suspects[key] = world.time + return + if(world.time - suspects[key] >= patience && !reported[key]) + reported[key] = TRUE + note(category, detail) + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/rng_seed() + var/seed = text2num(world.params["soak_seed"]) + if(isnull(seed)) + seed = rand(1, 999999) + rand_seed(seed) + log_world("SOAK seed [seed]") + return seed + +// ---- Setup --------------------------------------------------------------- + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/build_pool() + ware_pool = voidcrew_cyberware_pool().Copy() + TEST_ASSERT(length(ware_pool) > 40, "The chrome pool is only [length(ware_pool)] types, the walk of the subtypes found too little") + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/build_room() + var/turf/low = run_loc_floor_bottom_left + var/turf/high = run_loc_floor_top_right + for(var/x in min(low.x, high.x) to max(low.x, high.x)) + for(var/y in min(low.y, high.y) to max(low.y, high.y)) + var/turf/candidate = locate(x, y, low.z) + // The cradle's tile lifts whoever stands on it, which would be part of a snapshot. + if(isfloorturf(candidate) && candidate != run_loc_floor_top_right) + floor_turfs += candidate + TEST_ASSERT(length(floor_turfs) >= 12, "The test room only has [length(floor_turfs)] floor tiles") + +/// A fresh body. It stands bare for a few seconds first, so the snapshot it is later compared +/// with already has the modifiers every body picks up on its own (mood, for one). +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/spawn_body() + next_index++ + var/index = next_index + var/mob/living/carbon/human/body = new /mob/living/carbon/human/consistent(pick(floor_turfs)) + track(body) + body.fully_replace_character_name(body.real_name, "Soak [index]") + var/obj/item/card/id/id = new(body) + id.registered_account = allocate(/datum/bank_account, "Soak [index]", null, 1, FALSE) + id.registered_account.account_balance = 50000 + body.equip_to_slot_or_del(id, ITEM_SLOT_ID) + body.equip_to_slot_or_del(new /obj/item/clothing/under/color/grey(body), ITEM_SLOT_ICLOTHING) + sleep(6 SECONDS) + if(QDELETED(body) || !storming) + return body + baselines[body] = snapshot(body) + crew += body + random_loadout(body) + return body + +/// A random set of chrome in a random body. Most bodies fit their load; some are loaded +/// past capacity on purpose and start browned out. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/random_loadout(mob/living/carbon/human/body) + var/overshoot = prob(25) ? rand(1, 8) : 0 + var/pieces = rand(2, 7) + var/list/candidates = shuffle(ware_pool.Copy()) + for(var/ware_type in candidates) + if(pieces <= 0) + break + var/obj/item/organ/ware = track(new ware_type(get_turf(body))) + if(!body.get_bodypart(deprecise_zone(ware.zone))) + qdel(ware) + continue + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + var/list/incumbents = ware.cyberware_get_incumbents(body) + // Keep most loadouts free of evictions, they are not what is being tested here. + if(length(incumbents) && prob(85)) + qdel(ware) + continue + var/projected = get_chrome_load(body) + (chrome?.chrome_load || 0) + for(var/obj/item/organ/incumbent in incumbents) + projected -= get_installed_capacity_cost(incumbent) + if(projected > get_chrome_capacity(body) + (chrome?.capacity_bonus || 0) + overshoot) + qdel(ware) + continue + if(!ware.Insert(body, special = TRUE)) + qdel(ware) + continue + pieces-- + count("pieces installed") + +/// Everything that should be back to normal once a body has no chrome and has been left alone. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/snapshot(mob/living/carbon/human/body) + var/list/snap = list() + snap["filters"] = length(body.filters) + snap["actions"] = length(body.actions) + snap["vis_contents"] = length(body.vis_contents) + var/list/statuses = list() + for(var/datum/status_effect/effect as anything in body.status_effects) + statuses += "[effect.type]" + snap["status effects"] = statuses + var/list/traits = list() + for(var/trait in body._status_traits) + traits[trait] = length(body._status_traits[trait]) + snap["traits"] = traits + var/list/speed = list() + for(var/modifier in body.movespeed_modification) + speed += "[modifier]" + snap["movespeed"] = speed + var/list/action_speed = list() + for(var/modifier in body.actionspeed_modification) + // Mood's own modifier arrives on its own schedule. + if(!findtext("[modifier]", "sanity_component")) + action_speed += "[modifier]" + snap["actionspeed"] = action_speed + var/list/physiology = list() + for(var/var_name in body.physiology.vars) + var/value = body.physiology.vars[var_name] + if(isnum(value)) + physiology[var_name] = value + for(var/rating in ARMOR_LIST_ALL()) + physiology["armor [rating]"] = body.physiology.armor.get_rating(rating) + snap["physiology"] = physiology + var/list/components = list() + for(var/component_type in body._datum_components) + components += "[component_type]" + snap["components"] = components + var/list/loose = list() + for(var/atom/movable/thing as anything in body.contents) + if(isorgan(thing) || isbodypart(thing) || istype(thing, /obj/item/clothing) || istype(thing, /obj/item/card/id)) + continue + loose += "[thing.type]" + snap["carried"] = loose + var/list/held = list() + for(var/obj/item/thing in body.held_items) + held += "[thing.type]" + snap["held"] = held + snap["intercept"] = body.click_intercept ? "[body.click_intercept.type]" : null + return snap + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/Run() + rng_seed() + var/length_param = text2num(world.params["soak_length"]) + if(length_param) + storm_length = length_param + runtimes_at_start = GLOB.total_runtimes + skipped_runtimes_at_start = GLOB.total_runtimes_skipped + timers_at_start = length(SStimer.timer_id_dict) + for(var/gc_type in SSgarbage.items) + var/datum/qdel_item/item = SSgarbage.items[gc_type] + gc_before[gc_type] = list(item.failures, item.hard_deletes) + + build_pool() + build_room() + for(var/ledger_type in typesof(/datum/shop_buyback/ripperdoc) - /datum/shop_buyback/ripperdoc) + // Ratchet's entries are the same ledger in other words. + if(findtext("[ledger_type]", "/street_doc")) + continue + ledgers += allocate(ledger_type) + cradle = track(new /obj/machinery/chrome_cradle(run_loc_floor_top_right)) + storming = TRUE + for(var/i in 1 to crew_size) + INVOKE_ASYNC(src, PROC_REF(spawn_body)) + sleep(8 SECONDS) + // Two bodies are pacifists for the whole storm. + for(var/i in 1 to min(2, length(crew))) + var/mob/living/carbon/human/pacifist = crew[i] + ADD_TRAIT(pacifist, TRAIT_PACIFISM, "soak") + + var/end_time = world.time + storm_length + var/next_sweep = world.time + var/next_heal = world.time + 20 SECONDS + while(world.time < end_time) + sleep(1 SECONDS) + storm_tick() + if(world.time >= next_sweep) + next_sweep = world.time + 2 SECONDS + sweep() + if(world.time >= next_heal) + next_heal = world.time + 20 SECONDS + top_up() + storming = FALSE + // Let the long events (a death waiting to revive, a brownout waiting to lift, a cradle + // sequence) finish before anyone looks at the wreckage. + sleep(15 SECONDS) + sweep() + sweep() + + audit_survivors() + var/list/leaks = clean_up() + report(leaks) + +// ---- The storm ----------------------------------------------------------- + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/storm_tick() + var/static/list/event_weights = list( + "trigger" = 30, "swing" = 12, "wheel" = 4, "emp" = 8, "emp_one" = 4, "brownout" = 6, + "cradle" = 5, "sell" = 3, "kill" = 5, "gib" = 2, "dust" = 1, "dismember" = 5, "stun" = 6, + "cuff" = 3, "pacifist" = 2, "zoom" = 6, "damage" = 4, "mech" = 2, "speak" = 2, "swap" = 4, + ) + for(var/mob/living/carbon/human/body as anything in crew.Copy()) + if(QDELETED(body)) + crew -= body + baselines -= body + INVOKE_ASYNC(src, PROC_REF(spawn_body)) + continue + if(!prob(35)) + continue + switch(pick_weight(event_weights)) + if("trigger") + INVOKE_ASYNC(src, PROC_REF(ev_trigger), body) + if("swing") + INVOKE_ASYNC(src, PROC_REF(ev_swing), body) + if("wheel") + INVOKE_ASYNC(src, PROC_REF(ev_wheel), body) + if("emp") + INVOKE_ASYNC(src, PROC_REF(ev_emp), body) + if("emp_one") + INVOKE_ASYNC(src, PROC_REF(ev_emp_one), body) + if("brownout") + INVOKE_ASYNC(src, PROC_REF(ev_brownout), body) + if("cradle") + INVOKE_ASYNC(src, PROC_REF(ev_cradle), body) + if("sell") + INVOKE_ASYNC(src, PROC_REF(ev_sell), body) + if("kill") + INVOKE_ASYNC(src, PROC_REF(ev_kill), body) + if("gib") + INVOKE_ASYNC(src, PROC_REF(ev_gib), body, FALSE) + if("dust") + INVOKE_ASYNC(src, PROC_REF(ev_gib), body, TRUE) + if("dismember") + INVOKE_ASYNC(src, PROC_REF(ev_dismember), body) + if("stun") + INVOKE_ASYNC(src, PROC_REF(ev_stun), body) + if("cuff") + INVOKE_ASYNC(src, PROC_REF(ev_cuff), body) + if("pacifist") + INVOKE_ASYNC(src, PROC_REF(ev_pacifist), body) + if("zoom") + INVOKE_ASYNC(src, PROC_REF(ev_zoom), body) + if("damage") + INVOKE_ASYNC(src, PROC_REF(ev_damage), body) + if("mech") + INVOKE_ASYNC(src, PROC_REF(ev_mech), body) + if("speak") + INVOKE_ASYNC(src, PROC_REF(ev_speak), body) + if("swap") + INVOKE_ASYNC(src, PROC_REF(ev_swap), body) + +/// Something for an ability to be pointed at. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/some_target(mob/living/carbon/human/body) + var/roll = rand(1, 10) + if(roll <= 5) + var/list/others = crew - body + for(var/mob/living/carbon/human/other as anything in others.Copy()) + if(QDELETED(other)) + others -= other + if(length(others)) + return pick(others) + if(roll <= 7 && !QDELETED(cradle)) + return cradle + // A click on yourself is a click like any other, and every button has to take it. + if(roll == 10) + return body + return pick(floor_turfs) + +/// Every installed ability, pointed at something. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_trigger(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + for(var/obj/item/organ/ware as anything in get_installed_cyberware(body)) + for(var/datum/action/ability as anything in LAZYCOPY(ware.actions)) + if(QDELETED(ability) || ability.owner != body) + continue + // Cooldowns would stop most of these after the first press. + if(istype(ability, /datum/action/cooldown) && prob(60)) + var/datum/action/cooldown/timed = ability + timed.next_use_time = 0 + count("ability presses") + INVOKE_ASYNC(ability, TYPE_PROC_REF(/datum/action, Trigger), body, NONE, some_target(body)) + +/// A melee swing or a shot, through the real click path, with whatever is in the hands. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_swing(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + body.set_combat_mode(TRUE) + count("clicks") + click_wrapper(body, some_target(body)) + +/// The chrome wheel's Use OS chrome, the one wheel path that needs no screen. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_wheel(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + var/datum/component/chrome_wheel/wheel = get_chrome_wheel(body) + if(!wheel) + return + body.next_click = 0 + count("wheel presses") + wheel.use_os() + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_emp(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + count("body emps") + body.emp_act(prob(50) ? EMP_HEAVY : EMP_LIGHT) + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_emp_one(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + var/list/installed = get_installed_cyberware(body) + if(!length(installed)) + return + var/obj/item/organ/ware = pick(installed) + count("piece emps") + ware.emp_act(prob(50) ? EMP_HEAVY : EMP_LIGHT) + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_brownout(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + var/obj/item/organ/cyberimp/cyberware/soak_ballast/ballast = new /obj/item/organ/cyberimp/cyberware/soak_ballast(get_turf(body)) + track(ballast) + if(!ballast.Insert(body, special = TRUE)) + qdel(ballast) + return + count("brownouts") + sleep(rand(3 SECONDS, 9 SECONDS)) + if(QDELETED(ballast)) + return + if(ballast.owner) + ballast.Remove(ballast.owner, special = TRUE) + qdel(ballast) + +/// A cradle visit: lie down, then install something, take something out, or tune up. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_cradle(mob/living/carbon/human/body) + if(!storming || QDELETED(body) || QDELETED(cradle) || cradle_lease > world.time || !isturf(body.loc)) + return + cradle_lease = world.time + 14 SECONDS + body.forceMove(get_turf(cradle)) + if(!cradle.buckle_mob(body, force = TRUE, check_loc = FALSE)) + cradle_lease = 0 + return + var/list/installed = get_installed_cyberware(body) + var/roll = rand(1, 10) + if(roll <= 2) + count("tune-ups") + var/obj/item/card/id/id = body.get_idcard(TRUE) + var/before = id?.registered_account?.account_balance + cradle.try_tune_up(body) + var/after = id?.registered_account?.account_balance + if(!isnull(before) && before != after && before - after != SOAK_TUNEUP_FEE) + note("tune-up charged the wrong fee", "balance went from [before] to [after]") + else if(roll <= 5 && length(installed)) + count("cradle removals") + cradle.begin_removal(pick(installed)) + sleep(SOAK_REMOVAL_TIME + 1 SECONDS) + else + // A used piece from the floor when there is one, otherwise a fresh one. + var/obj/item/organ/ware + if(length(loose_chrome) && prob(50)) + ware = pick(loose_chrome) + loose_chrome -= ware + if(QDELETED(ware)) + ware = null + for(var/attempt in 1 to 8) + var/candidate_type = pick(ware_pool) + var/obj/item/organ/candidate = track(new candidate_type(get_turf(cradle))) + if(cyberware_insert_check(candidate, body, silent = TRUE)) + ware = candidate + break + qdel(candidate) + if(ware) + count("cradle installs") + ware.forceMove(cradle) + cradle.begin_install(ware) + sleep(SOAK_INSTALL_TIME + 1 SECONDS) + if(!QDELETED(body) && body.buckled == cradle) + cradle.unbuckle_mob(body, force = TRUE) + cradle_lease = 0 + +/// A sale at Splice's ledger: pull a credit-priced piece and hand it over. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_sell(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + var/obj/item/card/id/id = body.get_idcard(TRUE) + if(!id?.registered_account) + return + for(var/obj/item/organ/ware as anything in shuffle(get_installed_cyberware(body))) + for(var/datum/shop_buyback/ledger as anything in ledgers) + if(!ledger.matches(ware)) + continue + ledger.demand = 50 + ware.Remove(body, special = TRUE) + body.put_in_hands(ware) + var/before = id.registered_account.account_balance + var/sold = ledger.try_sell(body, null) + var/gained = id.registered_account.account_balance - before + if(sold) + count("sell-backs") + if(gained != ledger.pay_credits) + note("sell-back paid the wrong amount", "[ware.type] paid [gained], the ledger says [ledger.pay_credits]") + else + if(gained) + note("a refused sell-back still paid", "[ware.type] paid [gained]") + // Not taken: it goes back on the floor for the cradle. + loose_chrome += ware + ware.forceMove(get_turf(body)) + return + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_kill(mob/living/carbon/human/body) + if(!storming || QDELETED(body) || body.stat == DEAD) + return + count("deaths") + body.death() + sleep(rand(2 SECONDS, 9 SECONDS)) + if(QDELETED(body)) + return + body.revive(HEAL_ALL) + count("revives") + +/// Gib or dust a body. Its chrome must come out whole; a fresh body takes its place. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_gib(mob/living/carbon/human/body, dust = FALSE) + if(!storming || QDELETED(body)) + return + var/list/pieces = get_installed_cyberware(body) + // The ballast belongs to its own event, which deletes it when it is done. + for(var/obj/item/organ/ware as anything in pieces.Copy()) + if(istype(ware, /obj/item/organ/cyberimp/cyberware/soak_ballast)) + pieces -= ware + count(dust ? "dustings" : "gibs") + crew -= body + baselines -= body + if(dust) + body.dust() + else + body.gib() + sleep(1 SECONDS) + for(var/obj/item/organ/ware as anything in pieces) + if(QDELETED(ware)) + note("gibbing destroyed a piece of chrome", "[ware.type]") + continue + if(ware.owner) + note("gibbed chrome is still owned by the corpse", "[ware.type]") + continue + if(!isturf(ware.loc)) + note("gibbed chrome did not land on the floor", "[ware.type] is in [ware.loc ? ware.loc.type : "nullspace"]") + continue + count("pieces salvaged") + loose_chrome += ware + spawn_body() + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_dismember(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + var/obj/item/bodypart/limb = body.get_bodypart(pick(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + if(!limb || !limb.dismember()) + return + count("limbs lost") + var/list/riders = list() + for(var/obj/item/organ/ware in limb) + riders += ware + if(prob(40)) + // Burned up on the floor: the chrome has to come out of it. + sleep(1 SECONDS) + qdel(limb) + for(var/obj/item/organ/ware as anything in riders) + if(QDELETED(ware)) + note("destroying a severed limb destroyed its chrome", "[ware.type]") + else if(!isturf(ware.loc)) + note("chrome from a destroyed limb did not land on the floor", "[ware.type] is in [ware.loc ? ware.loc.type : "nullspace"]") + else + loose_chrome += ware + sleep(rand(3 SECONDS, 10 SECONDS)) + if(!QDELETED(body)) + body.regenerate_limbs() + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_stun(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + count("stuns") + switch(rand(1, 3)) + if(1) + body.Paralyze(rand(1 SECONDS, 5 SECONDS)) + if(2) + body.Stun(rand(1 SECONDS, 4 SECONDS)) + if(3) + body.Knockdown(rand(1 SECONDS, 4 SECONDS)) + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_cuff(mob/living/carbon/human/body) + if(!storming || QDELETED(body) || body.handcuffed) + return + var/obj/item/restraints/handcuffs/cuffs = new(body) + body.set_handcuffed(cuffs) + count("cuffings") + sleep(rand(3 SECONDS, 8 SECONDS)) + if(!QDELETED(body) && body.handcuffed == cuffs) + body.set_handcuffed(null) + qdel(cuffs) + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_pacifist(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + // The first two bodies keep it for the whole storm. + if(crew.Find(body) <= 2) + return + if(HAS_TRAIT_FROM(body, TRAIT_PACIFISM, "soak")) + REMOVE_TRAIT(body, TRAIT_PACIFISM, "soak") + else + ADD_TRAIT(body, TRAIT_PACIFISM, "soak") + count("pacifism flips") + +/// Optics zoom. There is no client for tg's scope, so the optics' own half is driven. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_zoom(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + var/obj/item/organ/eyes/robotic/cyberware/optics = body.get_organ_slot(ORGAN_SLOT_EYES) + if(!istype(optics)) + return + count("zoom flips") + if(optics.is_zoomed()) + optics.stop_zoom(body) + return + var/datum/component/cyberware/chrome = optics.GetComponent(/datum/component/cyberware) + if(chrome?.is_running() && body.stat == CONSCIOUS) + optics.engage_zoom(body) + +/// Damage on a piece until it fails, then it is repaired a moment later. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_damage(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + var/list/installed = get_installed_cyberware(body) + if(!length(installed)) + return + var/obj/item/organ/ware = pick(installed) + count("damaged pieces") + ware.apply_organ_damage(ware.maxHealth) + sleep(rand(2 SECONDS, 7 SECONDS)) + if(!QDELETED(ware)) + ware.set_organ_damage(0) + +/// A short ride in a mech, with the abilities going the whole time. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_mech(mob/living/carbon/human/body) + if(!storming || QDELETED(body) || mech_lease > world.time || !isturf(body.loc)) + return + mech_lease = world.time + 12 SECONDS + var/obj/vehicle/sealed/mecha/ripley/mech = new /obj/vehicle/sealed/mecha/ripley(get_turf(body)) + track(mech) + if(!mech.mob_enter(body, silent = TRUE)) + qdel(mech) + mech_lease = 0 + return + count("mech rides") + for(var/i in 1 to 4) + sleep(1 SECONDS) + if(QDELETED(body) || body.loc != mech) + break + ev_trigger(body) + ev_swing(body) + if(!QDELETED(body) && body.loc == mech) + mech.mob_exit(body, silent = TRUE) + qdel(mech) + mech_lease = 0 + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_speak(mob/living/carbon/human/body) + // A dead mouth goes to deadchat, which asks for a ckey this test body never has. + if(!storming || QDELETED(body) || body.stat == DEAD) + return + count("lines spoken") + body.say(prob(50) ? ";soak check, over" : "soak check") + +/// Chrome in or out by hand: what an autosurgeon with a context, a surgeon or an admin does. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/ev_swap(mob/living/carbon/human/body) + if(!storming || QDELETED(body)) + return + var/list/installed = get_installed_cyberware(body) + if(length(installed) && prob(50)) + var/obj/item/organ/ware = pick(installed) + ware.Remove(body, special = TRUE) + ware.forceMove(get_turf(body)) + loose_chrome += ware + count("pieces pulled") + return + var/candidate_type = pick(ware_pool) + var/obj/item/organ/ware = track(new candidate_type(get_turf(body))) + if(!body.get_bodypart(deprecise_zone(ware.zone)) || !cyberware_insert_check(ware, body, silent = TRUE) || !ware.Insert(body, special = TRUE)) + qdel(ware) + return + count("pieces slotted") + +/// Keeps the bodies fit enough to keep going without undoing the chrome's own state. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/top_up() + for(var/mob/living/carbon/human/body as anything in crew) + if(QDELETED(body) || body.stat == DEAD) + continue + body.adjustBruteLoss(-60, updating_health = FALSE, forced = TRUE) + body.adjustFireLoss(-60, updating_health = FALSE, forced = TRUE) + body.adjustToxLoss(-30, updating_health = FALSE, forced = TRUE) + body.adjustOxyLoss(-60, updating_health = FALSE, forced = TRUE) + body.updatehealth() + +// ---- The sweep ----------------------------------------------------------- + +/// Checks the ware's own bookkeeping on every body. Each finding has to hold for a while +/// before it counts, because the ware settles itself on SSmobs' two-second tick. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/sweep() + seen_now = list() + for(var/mob/living/carbon/human/body as anything in crew) + if(QDELETED(body)) + continue + var/over = get_chrome_load(body) > get_chrome_capacity(body) + for(var/obj/item/organ/ware as anything in get_installed_cyberware(body)) + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + if(!chrome) + continue + var/ware_ref = REF(ware) + if(ware.owner != body) + suspect("[ware_ref]|owner", "installed chrome whose owner is not its bearer", "[ware.type] owner [ware.owner || "none"] in [body]") + var/running = !!chrome.is_running() + if(!!chrome.passives_online != running) + suspect("[ware_ref]|passives", "passive layer out of step with the ware", "[ware.type] passives_online [chrome.passives_online], running [running] (emp [chrome.emp_down], brownout [chrome.browned_out], damage [ware.damage]/[ware.maxHealth], flags [ware.organ_flags])") + if(!!chrome.browned_out != over) + suspect("[ware_ref]|brownout", "brownout flag out of step with the body's load", "[ware.type] browned_out [chrome.browned_out], load [get_chrome_load(body)] of [get_chrome_capacity(body)]") + if(chrome.emp_down && !chrome.emp_timer) + suspect("[ware_ref]|emptimer", "EMP downtime with no reboot timer", "[ware.type]") + if(chrome.emp_down) + suspect("[ware_ref]|empstuck", "EMP downtime that never ended", "[ware.type] still down", SOAK_EMP_DOWNTIME + 8 SECONDS) + if(istype(ware, /obj/item/organ/cyberimp/arm/toolkit/cyberware)) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm = ware + if(arm.active_item && !running && arm.active_item.loc == body) + suspect("[ware_ref]|armweapon", "arm weapon deployed on offline chrome", "[ware.type] holding [arm.active_item.type]") + if(HAS_TRAIT(body, SOAK_TRAIT_ZOOMED)) + var/obj/item/organ/eyes/robotic/cyberware/optics = body.get_organ_slot(ORGAN_SLOT_EYES) + var/datum/component/cyberware/optic_chrome = istype(optics) ? optics.GetComponent(/datum/component/cyberware) : null + if(!istype(optics) || !optics.is_zoomed()) + suspect("[REF(body)]|zoomtrait", "zoom trait with no zoomed optics behind it", "[body]") + else if(!optic_chrome?.is_running()) + suspect("[REF(body)]|zoomoffline", "zoom held open on offline optics", "[optics.type]") + else if(body.stat != CONSCIOUS) + suspect("[REF(body)]|zoomstat", "zoom held open on a body that is not awake", "[body] stat [body.stat]") + if(HAS_TRAIT(body, SOAK_TRAIT_CAMO)) + var/obj/item/organ/cyberimp/cyberware/ghostskin/weave = body.get_organ_slot(SOAK_SLOT_SKIN) + var/datum/component/cyberware/weave_chrome = istype(weave) ? weave.GetComponent(/datum/component/cyberware) : null + if(!istype(weave) || !weave.camo_active) + suspect("[REF(body)]|camotrait", "camo trait with no engaged weave behind it", "[body]") + else if(!weave_chrome?.is_running()) + suspect("[REF(body)]|camooffline", "camo held on an offline weave", "[weave.type]") + for(var/key in suspects.Copy()) + if(!seen_now[key]) + suspects -= key + reported -= key + +// ---- The audit ----------------------------------------------------------- + +/// Heal the survivors, take all their chrome out, leave them alone and compare with the +/// snapshot from before they had any. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/audit_survivors() + var/list/survivors = list() + for(var/mob/living/carbon/human/body as anything in crew) + if(QDELETED(body) || !baselines[body]) + continue + survivors += body + body.drop_all_held_items() + body.forceMove(run_loc_floor_bottom_left) + body.set_handcuffed(null) + REMOVE_TRAIT(body, TRAIT_PACIFISM, "soak") + body.revive(ADMIN_HEAL_ALL) + body.regenerate_limbs() + var/index = 0 + for(var/obj/item/organ/ware as anything in get_installed_cyberware(body)) + index++ + // Half through the normal removal, half the way a dying body does it. + ware.Remove(body, special = (index % 2 == 0)) + ware.forceMove(get_turf(body)) + loose_chrome += ware + if(get_chrome_load(body) != 0) + note("a body still carries load with no chrome in it", "[body] carries [get_chrome_load(body)]") + log_world("SOAK auditing [length(survivors)] survivors after a 20 second quiet") + sleep(20 SECONDS) + for(var/mob/living/carbon/human/body as anything in survivors) + if(QDELETED(body)) + continue + var/list/before = baselines[body] + var/list/now = snapshot(body) + for(var/key in before) + var/old_value = before[key] + var/new_value = now[key] + if(islist(old_value)) + var/list/old_list = old_value + var/list/new_list = new_value + var/list/gained = list() + var/list/lost = list() + if(key == "traits" || key == "physiology") + for(var/entry in new_list) + if(new_list[entry] != old_list[entry]) + gained += "[entry] [old_list[entry]] -> [new_list[entry]]" + for(var/entry in old_list) + if(isnull(new_list[entry])) + lost += "[entry]" + else + var/list/remaining = old_list.Copy() + for(var/entry in new_list) + if(entry in remaining) + remaining -= entry + else + gained += "[entry]" + lost = remaining + if(length(gained) || length(lost)) + // The chrome wheel is made on first use and kept for the body's life. + if(key == "components") + gained -= "[/datum/component/chrome_wheel]" + // A baseline taken mid-fight can hold a passing effect (a stagger); only + // what was ADDED by the chrome and never left counts for these. + if(key == "status effects" || key == "movespeed" || key == "actionspeed") + lost = list() + if(length(gained) || length(lost)) + note("after stripping the chrome, [key] differ from before it", "[body]: gained [english_list(gained)], lost [english_list(lost)]") + else if(old_value != new_value) + note("after stripping the chrome, [key] differs from before it", "[body]: was [old_value], now [new_value]") + for(var/stale in voidcrew_cyberware_stale_listeners(body)) + note("a stripped body still has chrome listening to it", "[body]: [stale]") + +/// Delete everything this test made, wait for the garbage collector, and return what survived. +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/clean_up() + // The cradle's own loose ends first. + var/tray_strays = 0 + if(!QDELETED(cradle)) + if(cradle.busy) + note("the cradle ended the soak mid-sequence", "[cradle.busy_action]") + list_clear_nulls(cradle.tray) + for(var/obj/item/organ/stray in cradle.tray) + if(QDELETED(stray)) + tray_strays++ + if(tray_strays) + note("the cradle's tray holds deleted chrome", "[tray_strays] pieces") + crew.Cut() + baselines.Cut() + loose_chrome.Cut() + suspects.Cut() + reported.Cut() + seen_now.Cut() + // The cradle first, because its tray empties onto the floor when it goes. + qdel(cradle) + cradle = null + // Everything in the room goes, bodies and chrome and gibs. Twice: the first pass can drop + // things (a dead body's pockets, a limb's chrome) that the second one picks up. + for(var/pass in 1 to 2) + for(var/turf/where as anything in floor_turfs + run_loc_floor_top_right) + for(var/atom/movable/thing as anything in where.contents.Copy()) + if(istype(thing, /obj/effect/landmark)) + continue + qdel(thing) + floor_turfs.Cut() + ledgers.Cut() + // Past the longest queue the collector has, plus a margin. + sleep(45 SECONDS) + var/list/leaks = list() + for(var/datum/weakref/ref as anything in made) + var/datum/survivor = ref.resolve() + if(survivor) + leaks["[survivor.type]"] = (leaks["[survivor.type]"] || 0) + 1 + made.Cut() + // Anything that ran out of patience with the collector is counted per type. + for(var/gc_type in SSgarbage.items) + var/datum/qdel_item/item = SSgarbage.items[gc_type] + var/list/before = gc_before[gc_type] || list(0, 0) + if(item.failures > before[1] || item.hard_deletes > before[2]) + var/path_text = "[gc_type]" + if(findtext(path_text, "cyberware") || findtext(path_text, "chrome") || findtext(path_text, "/mob/living/carbon/human") || findtext(path_text, "quickhack") || findtext(path_text, "shop_buyback")) + leaks["GC [path_text]: [item.failures - before[1]] failures, [item.hard_deletes - before[2]] hard deletes"] = 1 + // A timer pointing at an object that is already gone should not exist. + var/orphaned = 0 + for(var/timer_id in SStimer.timer_id_dict) + var/datum/timedevent/timer = SStimer.timer_id_dict[timer_id] + var/datum/object = timer?.callBack?.object + if(isdatum(object) && QDELETED(object)) + orphaned++ + if(orphaned) + leaks["timers aimed at deleted objects"] = orphaned + stats["timers before"] = timers_at_start + stats["timers after"] = length(SStimer.timer_id_dict) + return leaks + +/datum/unit_test/voidcrew_opt_in/cyberware_soak/proc/report(list/leaks) + var/list/lines = list() + lines += "CHROME SOAK REPORT" + lines += "storm length [storm_length / 10] s, [crew_size] bodies, [length(ware_pool)] kinds of chrome in the pool" + lines += "runtimes during the soak: [GLOB.total_runtimes - runtimes_at_start] (plus [GLOB.total_runtimes_skipped - skipped_runtimes_at_start] silenced as repeats)" + lines += "-- what was done" + for(var/what in stats) + lines += " [what]: [stats[what]]" + lines += "-- problems ([length(problem_counts)] kinds)" + for(var/category in problem_counts) + lines += " [category] x[problem_counts[category]]: [problem_examples[category]]" + lines += "-- left behind ([length(leaks)] kinds)" + for(var/leak in leaks) + lines += " [leak]: [leaks[leak]]" + var/text = lines.Join("\n") + fdel("data/soak_report.txt") + text2file(text, "data/soak_report.txt") + log_world(text) + for(var/category in problem_counts) + Fail("[category] (x[problem_counts[category]]): [problem_examples[category]]") + for(var/leak in leaks) + Fail("left behind: [leak]: [leaks[leak]]") + +#undef SOAK_SLOT_SKIN +#undef SOAK_TRAIT_CAMO +#undef SOAK_TRAIT_ZOOMED +#undef SOAK_INSTALL_TIME +#undef SOAK_REMOVAL_TIME +#undef SOAK_EMP_DOWNTIME +#undef SOAK_TUNEUP_FEE +#undef SOAK_PATIENCE From a8ba7aa21b6d6aea57f57e67dd984b13a39c51a1 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:41:01 -0800 Subject: [PATCH 141/472] Animate Mantis blades, the Ghostskin field, the Cascade and EMP on chrome Mantis blades swing out of the housing and fold back in, with a spark and a flash at the hinge. The Ghostskin and Flickerskin fields sweep down over their bearer, shimmer while they hold and collapse inward with a crackle. The Cascade line lights its spine nodes neck to hips with a ring across the floor, and the nodes die hips to neck on the crash. An EMP fills worn chrome with static, arcs between the pieces, kills them one at a time and blinks them back on at reboot. Pictures only: every timing and number the chrome runs on is unchanged. --- tgstation.dme | 1 + voidcrew/modules/cyberware/cyberware_anim.dm | 862 ++++++++++++++++++ voidcrew/modules/cyberware/cyberware_base.dm | 13 + .../modules/cyberware/cyberware_component.dm | 11 + .../cyberware/icons/cyberware_anim.dmi | Bin 0 -> 7584 bytes .../cyberware/icons/cyberware_anim_large.dmi | Bin 0 -> 52597 bytes voidcrew/modules/cyberware/ware_legend.dm | 6 + .../modules/cyberware/ware_military_body.dm | 8 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_anim.dm | 190 ++++ 10 files changed, 1091 insertions(+), 1 deletion(-) create mode 100644 voidcrew/modules/cyberware/cyberware_anim.dm create mode 100644 voidcrew/modules/cyberware/icons/cyberware_anim.dmi create mode 100644 voidcrew/modules/cyberware/icons/cyberware_anim_large.dmi create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_anim.dm diff --git a/tgstation.dme b/tgstation.dme index 821ddf29ff7..5466538d6c1 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7177,6 +7177,7 @@ #include "voidcrew\modules\cyberware\chrome_wheel_hud.dm" #include "voidcrew\modules\cyberware\chrome_wheel_keybindings.dm" #include "voidcrew\modules\cyberware\cradle_ui.dm" +#include "voidcrew\modules\cyberware\cyberware_anim.dm" #include "voidcrew\modules\cyberware\cyberware_base.dm" #include "voidcrew\modules\cyberware\cyberware_component.dm" #include "voidcrew\modules\cyberware\cyberware_feedback.dm" diff --git a/voidcrew/modules/cyberware/cyberware_anim.dm b/voidcrew/modules/cyberware/cyberware_anim.dm new file mode 100644 index 00000000000..aef95e3d88f --- /dev/null +++ b/voidcrew/modules/cyberware/cyberware_anim.dm @@ -0,0 +1,862 @@ +/** + * # Chrome animations + * + * Four pieces of motion that sit on top of chrome that already works. None of + * them changes what the chrome does or when: every proc here only draws, and + * the mechanics call into it after they have already happened. + * + * - Mantis blades swing out of the housing and fold back into it. + * - The Ghostskin field sweeps down over its bearer, shimmers while it holds, + * and collapses inward with a crackle when it breaks. + * - The Cascade's spine nodes ignite neck to hips, a ring rolls out across the + * floor, and the nodes die hips to neck when the crash hits. + * - An EMP fills every worn piece with static, arcs between them, kills them one + * after another, and they blink back on when they reboot. + * + * Two ways of drawing, chosen per effect: + * - One-shot frames ride a holder object in the bearer's vis_contents. A fresh + * object always plays its icon state from the first frame, which an overlay + * does not, so swings, arcs and sparks are holders that delete themselves. + * - Anything that must sit at the worn chrome's layer (under suits, gloves and + * hands) is a "look" on the worn overlay itself: the bodypart overlay asks + * the implant for its look when it draws, and the look is part of the limb's + * render key, so a look change is one update_body_parts() away. + * + * Nothing here loops on the server. Timers are one-shot and owned by the thing + * they animate, so they die with it. + */ + +#define CYBERWARE_ANIM_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim.dmi' +#define CYBERWARE_ANIM_LARGE_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim_large.dmi' + +/// Worn looks. Null is the plain overlay. +#define CHROME_LOOK_STATIC "static" +#define CHROME_LOOK_DARK "dark" +#define CHROME_LOOK_FLASH "flash" +#define CHROME_LOOK_FOLD "fold" + +/// Layer for a blade swing: just over the held item it turns into. +#define CHROME_ANIM_BLADE_LAYER (-(HANDS_LAYER - 0.5)) +/// Layer for arcs, sparks and crackle: over everything the bearer wears. +#define CHROME_ANIM_TOP_LAYER (-(HIGHEST_LAYER - 0.5)) + +// ========================================================================= +// THE HOLDER +// ========================================================================= + +/** + * One short animation drawn on a bearer. Created per use and deleted after + * its lifetime, so its icon state always starts on the first frame. Inherits + * the bearer's facing and plane; clicks pass through it. + */ +/obj/effect/abstract/cyberware_anim + name = "chrome" + icon = CYBERWARE_ANIM_LARGE_ICON + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + vis_flags = VIS_INHERIT_DIR | VIS_INHERIT_PLANE + blocks_emissive = EMISSIVE_BLOCK_NONE + /// What we are drawn on. + var/atom/movable/bearer + +/obj/effect/abstract/cyberware_anim/Destroy() + detach() + return ..() + +/obj/effect/abstract/cyberware_anim/proc/detach() + if(!bearer) + return + UnregisterSignal(bearer, COMSIG_QDELETING) + bearer.vis_contents -= src + bearer = null + +/obj/effect/abstract/cyberware_anim/proc/on_bearer_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/** + * Plays [state] from [icon_file] on [bearer] for [lifetime], then deletes + * itself. Large-sheet states are 64x64 and get centred on the bearer's tile. + * [glow] adds an emissive twin, so the frames light up in the dark. + */ +/proc/cyberware_anim_play(atom/movable/bearer, icon_file, state, anim_layer, lifetime, glow = FALSE) + if(QDELETED(bearer) || !state) + return null + var/obj/effect/abstract/cyberware_anim/holder = new(null) + holder.bearer = bearer + holder.icon = icon_file + holder.icon_state = state + holder.layer = anim_layer + if(icon_file == CYBERWARE_ANIM_LARGE_ICON) + holder.pixel_x = -16 + holder.pixel_y = -16 + if(glow) + holder.add_overlay(emissive_appearance(icon_file, state, bearer)) + holder.RegisterSignal(bearer, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + bearer.vis_contents += holder + QDEL_IN(holder, lifetime) + return holder + +// ========================================================================= +// WORN LOOKS +// ========================================================================= + +/// The EMP look the reboot show has put on this piece. Null means "whatever +/// the reboot state says": dark while down, plain otherwise. +/datum/component/cyberware/var/emp_look + +/** + * The look this implant's worn overlay draws with right now, or null for the + * plain overlay. Read by the bodypart overlay on every draw. + */ +/obj/item/organ/cyberimp/proc/cyberware_worn_look() + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + if(!chrome) + return null + if(chrome.emp_look) + return chrome.emp_look + if(chrome.emp_down) + return CHROME_LOOK_DARK + return null + +/// What this implant adds to its limb's render key, so a look change redraws. +/obj/item/organ/cyberimp/proc/cyberware_worn_key() + return "[cyberware_worn_look()]" + +/** + * Dresses one implant's freshly built worn images in its current look. + * Called by /datum/bodypart_overlay/augment/cyberware before it adds + * emissive blockers, so a see-through image gets a see-through blocker. + */ +/obj/item/organ/cyberimp/proc/cyberware_dress_worn(list/imageset, image_layer, obj/item/bodypart/limb) + var/look = cyberware_worn_look() + if(!look) + return imageset + var/list/dressed = list() + for(var/image/part as anything in imageset) + var/glow = PLANE_TO_TRUE(part.plane) == EMISSIVE_PLANE + switch(look) + if(CHROME_LOOK_FOLD) + // The folded housing waits while the swing plays over it. + if(glow) + continue + part.alpha = 0 + if(CHROME_LOOK_DARK) + if(glow) + continue + part.color = list(0.18, 0.18, 0.2, 0.32, 0.32, 0.34, 0.08, 0.08, 0.1, 0.02, 0.02, 0.03) + if(CHROME_LOOK_FLASH) + if(glow) + continue + part.color = list(0.55, 0, 0, 0, 0.55, 0, 0, 0, 0.55, 0.42, 0.5, 0.55) + dressed += part + dressed += emissive_appearance(part.icon, part.icon_state, limb?.owner || limb, part.layer) + continue + if(CHROME_LOOK_STATIC) + if(!glow) + // Static clipped to the piece's own pixels. + part.appearance_flags |= KEEP_TOGETHER + var/mutable_appearance/noise = mutable_appearance('icons/effects/effects.dmi', "static_base", alpha = 170) + noise.blend_mode = BLEND_INSET_OVERLAY + part.overlays += noise + dressed += part + return dressed + +// ========================================================================= +// MANTIS BLADES: UNFOLD AND FOLD +// ========================================================================= + +/// How long the blade swings out before the held blade takes over. +#define BLADE_UNFOLD_SWING (0.3 SECONDS) +/// The whole unfold, last sparks included. +#define BLADE_UNFOLD_TIME (0.4 SECONDS) +/// How long the folded housing waits while the blade folds back in. +#define BLADE_FOLD_SWING (0.3 SECONDS) +/// The whole fold, last sparks included. +#define BLADE_FOLD_TIME (0.5 SECONDS) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware + /// The swing playing on the bearer right now, if any. Weak: it deletes itself. + var/datum/weakref/blade_anim_ref + /// Until this world.time the folded housing is drawn see-through. + var/fold_hide_until = 0 + /// Timer that brings the folded housing back. + var/fold_timer + /// Colour of the flash at the hinge when the blade swings. + var/blade_flash_color = "#bfe6ff" + /// One hinge flash at a time, however fast the blade is toggled. + COOLDOWN_DECLARE(blade_flash_cooldown) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/street + blade_flash_color = "#e8e0c8" + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch + blade_flash_color = "#ffb070" + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/cyberware_worn_look() + if(world.time < fold_hide_until) + return CHROME_LOOK_FOLD + return ..() + +/// The swing state for this housing, or null when the sheet has none. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/blade_anim_state(kind) + var/state = "[aug_overlay]_[kind]_[zone == BODY_ZONE_L_ARM ? "left" : "right"]" + return icon_exists(CYBERWARE_ANIM_LARGE_ICON, state) ? state : null + +/// The swing playing on the bearer right now, if any. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/blade_anim() + return blade_anim_ref?.resolve() + +/// Stops any swing and brings the folded housing back if it was waiting. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/stop_blade_anim() + qdel(blade_anim()) + blade_anim_ref = null + if(fold_timer) + deltimer(fold_timer) + fold_timer = null + fold_hide_until = 0 + +/// The blade swings out of the housing. Called once the blade is in a hand. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/play_blade_unfold(obj/item/blade) + var/state = blade_anim_state("unfold") + if(!state || !owner) + return + stop_blade_anim() + blade_anim_ref = WEAKREF(cyberware_anim_play(owner, CYBERWARE_ANIM_LARGE_ICON, state, CHROME_ANIM_BLADE_LAYER, BLADE_UNFOLD_TIME)) + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/held_blade = blade + if(istype(held_blade)) + held_blade.hide_inhand_for(BLADE_UNFOLD_SWING) + blade_flash(0.7, BLADE_UNFOLD_SWING) + +/** + * The blade folds back into the housing. Called once it is stowed; the + * folded housing stays see-through until the swing has played. + */ +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/play_blade_fold() + var/state = blade_anim_state("fold") + if(!state || !owner) + return + stop_blade_anim() + blade_anim_ref = WEAKREF(cyberware_anim_play(owner, CYBERWARE_ANIM_LARGE_ICON, state, CHROME_ANIM_BLADE_LAYER, BLADE_FOLD_TIME)) + fold_hide_until = world.time + BLADE_FOLD_SWING + fold_timer = addtimer(CALLBACK(src, PROC_REF(end_fold_hide)), BLADE_FOLD_SWING, TIMER_STOPPABLE) + blade_flash(0.5, BLADE_FOLD_SWING) + +/// A brief light at the hinge while the blade swings. +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/blade_flash(power, duration) + if(!COOLDOWN_FINISHED(src, blade_flash_cooldown)) + return + COOLDOWN_START(src, blade_flash_cooldown, duration) + owner.mob_light(1.5, power, blade_flash_color, duration) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/proc/end_fold_hide() + fold_timer = null + fold_hide_until = 0 + owner?.update_body_parts() + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis + /// Until this world.time the blade is not drawn in the hand: the swing is. + var/inhand_hidden_until = 0 + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/hide_inhand_for(duration) + inhand_hidden_until = world.time + duration + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + addtimer(CALLBACK(src, PROC_REF(show_inhand)), duration, TIMER_UNIQUE | TIMER_OVERRIDE) + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/show_inhand() + if(!inhand_hidden_until) + return + inhand_hidden_until = 0 + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + +// The blade is in the hand from the first tick; only its drawing waits. +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/build_worn_icon(default_layer = 0, default_icon_file = null, isinhands = FALSE, female_uniform = NO_FEMALE_UNIFORM, override_state = null, override_file = null) + . = ..() + if(isinhands && world.time < inhand_hidden_until) + var/mutable_appearance/standing = . + standing.alpha = 0 + +#undef BLADE_UNFOLD_SWING +#undef BLADE_UNFOLD_TIME +#undef BLADE_FOLD_SWING +#undef BLADE_FOLD_TIME + +// ========================================================================= +// GHOSTSKIN FIELD +// ========================================================================= + +#define CLOAK_MASK_FILTER "cyberware_cloak_mask" +#define CLOAK_BAND_FILTER "cyberware_cloak_band" +#define CLOAK_RIM_FILTER "cyberware_cloak_rim" +#define CLOAK_WAVE_FILTER "cyberware_cloak_wave" +#define CLOAK_RIPPLE_FILTER "cyberware_cloak_ripple" +/// How long the field takes to sweep down over its bearer. +#define CLOAK_SWEEP_TIME (1 SECONDS) +/// Mask offsets: the front starts just over the head and ends under the feet, +/// then the mask is moved clear of the bearer. +#define CLOAK_FRONT_START 45 +#define CLOAK_FRONT_END 5 +#define CLOAK_FRONT_AWAY 200 +/// The refraction line rides one pixel under the front: from just over the +/// head (pixel_y 2) to under the feet (pixel_y -38) on a 64x64 holder. +#define CLOAK_EDGE_START 2 +#define CLOAK_EDGE_END -38 +/// When the crackle follows the inward collapse of a breaking field. +#define CLOAK_CRACKLE_DELAY (0.2 SECONDS) + +/obj/item/organ/cyberimp/cyberware/ghostskin + /// world.time the sweep finishes. The moving shimmer waits for it. + var/cloak_sweep_until = 0 + /// Timer that swaps the sweep for the held shimmer. + var/cloak_settle_timer + /// Timer that clears the inward ripple after a break. + var/cloak_ripple_timer + /// Timer for the crackle that follows the collapse. + var/cloak_crackle_timer + /// The refraction line sweeping down with the field. Weak: it deletes itself. + var/datum/weakref/cloak_edge_ref + /// Who carries our filters. + var/datum/weakref/cloak_fx_ref + +/// The sweep mask for a field alpha, built once per alpha value. +/proc/cyberware_cloak_mask(field_alpha) + var/static/list/masks = list() + var/key = "[field_alpha]" + if(!masks[key]) + var/icon/mask = icon(CYBERWARE_ANIM_LARGE_ICON, "cloak_mask") + mask.ChangeOpacity((255 - field_alpha) / 255) + masks[key] = mask + return masks[key] + +/** + * Raises the field visually: the bearer stays solid while a mask sweeps down + * over them, then drops to field alpha the moment the sweep ends. The alpha + * var holds field_alpha from the first tick, as it always has; only the + * picture takes the second. + */ +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_sweep_in(mob/living/carbon/wearer) + clear_cloak_fx() + cloak_fx_ref = WEAKREF(wearer) + cloak_sweep_until = world.time + CLOAK_SWEEP_TIME + animate(wearer, alpha = 255, time = CLOAK_SWEEP_TIME) + animate(alpha = field_alpha, time = 0) + wearer.add_filters(list( + list("name" = CLOAK_MASK_FILTER, "priority" = 20, "params" = alpha_mask_filter(y = CLOAK_FRONT_START, icon = cyberware_cloak_mask(field_alpha), flags = MASK_INVERSE)), + list("name" = CLOAK_BAND_FILTER, "priority" = 21, "params" = displacement_map_filter(icon = icon(CYBERWARE_ANIM_LARGE_ICON, "cloak_band"), y = CLOAK_FRONT_START, size = 2)), + )) + for(var/filter_name in list(CLOAK_MASK_FILTER, CLOAK_BAND_FILTER)) + animate(wearer.get_filter(filter_name), y = CLOAK_FRONT_END, time = CLOAK_SWEEP_TIME) + animate(y = CLOAK_FRONT_AWAY, time = 0) + // A rebuild of the bearer's filters mid-sweep lands on the end state, not the start. + wearer.filter_data[filter_name]["y"] = CLOAK_FRONT_AWAY + // A pale line on the front, inset so it only lights the bearer's own pixels. + var/obj/effect/abstract/cyberware_anim/edge = cyberware_anim_play(wearer, CYBERWARE_ANIM_LARGE_ICON, "cloak_edge", CHROME_ANIM_TOP_LAYER, CLOAK_SWEEP_TIME) + if(edge) + edge.blend_mode = BLEND_INSET_OVERLAY + edge.pixel_y = CLOAK_EDGE_START + animate(edge, pixel_y = CLOAK_EDGE_END, time = CLOAK_SWEEP_TIME) + cloak_edge_ref = WEAKREF(edge) + cloak_settle_timer = addtimer(CALLBACK(src, PROC_REF(cloak_settle)), CLOAK_SWEEP_TIME, TIMER_STOPPABLE) + +/// The sweep is done: drop its filters and leave the shimmer and a faint rim. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_settle() + cloak_settle_timer = null + var/mob/living/carbon/wearer = cloak_fx_ref?.resolve() + if(!camo_active || QDELETED(wearer)) + return + wearer.remove_filter(list(CLOAK_MASK_FILTER, CLOAK_BAND_FILTER)) + // The rim fades with the bearer, so it is drawn bright for a faint result. + var/rim_alpha = clamp(round(40 * 255 / max(field_alpha, 1)), 40, 255) + var/rim_bright = rgb(216, 244, 255, rim_alpha) + var/rim_dim = rgb(216, 244, 255, round(rim_alpha * 0.7)) + wearer.add_filters(list( + list("name" = CLOAK_RIM_FILTER, "priority" = 22, "params" = outline_filter(1, rim_bright)), + list("name" = CLOAK_WAVE_FILTER, "priority" = 23, "params" = wave_filter(x = 0, y = 8, size = 1.2, offset = 0, flags = WAVE_SIDEWAYS)), + )) + var/wave = wearer.get_filter(CLOAK_WAVE_FILTER) + animate(wave, offset = 1, time = 0.8 SECONDS, loop = -1) + animate(offset = 0, time = 0) + var/rim = wearer.get_filter(CLOAK_RIM_FILTER) + animate(rim, color = rim_dim, time = 0.8 SECONDS, loop = -1, easing = SINE_EASING) + animate(color = rim_bright, time = 0.8 SECONDS, easing = SINE_EASING) + +/** + * The field is down: clear its filters and, unless it went quietly, collapse + * it inward with a crackle. The fade back to full view is break_camo()'s own. + */ +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_break_fx(mob/living/carbon/wearer, silent) + clear_cloak_fx() + if(silent || QDELETED(wearer)) + return + cloak_fx_ref = WEAKREF(wearer) + wearer.add_filter(CLOAK_RIPPLE_FILTER, 24, ripple_filter(radius = 26, size = 2.2, falloff = 0.6, repeat = 5, y = -2)) + animate(wearer.get_filter(CLOAK_RIPPLE_FILTER), radius = 3, size = 0, time = 0.3 SECONDS) + wearer.filter_data[CLOAK_RIPPLE_FILTER]["radius"] = 3 + wearer.filter_data[CLOAK_RIPPLE_FILTER]["size"] = 0 + cloak_ripple_timer = addtimer(CALLBACK(src, PROC_REF(clear_cloak_fx)), 0.35 SECONDS, TIMER_STOPPABLE) + // The field folds in on itself first, then crackles out. + cloak_crackle_timer = addtimer(CALLBACK(src, PROC_REF(cloak_crackle), WEAKREF(wearer)), CLOAK_CRACKLE_DELAY, TIMER_STOPPABLE) + +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_crackle(datum/weakref/wearer_ref) + cloak_crackle_timer = null + var/mob/living/carbon/wearer = wearer_ref?.resolve() + if(QDELETED(wearer)) + return + cyberware_anim_play(wearer, 'icons/effects/effects.dmi', "lightning", CHROME_ANIM_TOP_LAYER, 0.5 SECONDS, glow = TRUE) + wearer.mob_light(2, 0.8, "#9fdcff", 0.5 SECONDS) + +/// Takes every field filter off whoever carries them and stops the field's timers. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/clear_cloak_fx() + if(cloak_settle_timer) + deltimer(cloak_settle_timer) + cloak_settle_timer = null + if(cloak_ripple_timer) + deltimer(cloak_ripple_timer) + cloak_ripple_timer = null + if(cloak_crackle_timer) + deltimer(cloak_crackle_timer) + cloak_crackle_timer = null + qdel(cloak_edge_ref?.resolve()) + cloak_edge_ref = null + cloak_sweep_until = 0 + var/mob/living/carbon/wearer = cloak_fx_ref?.resolve() + cloak_fx_ref = null + if(!QDELETED(wearer)) + wearer.remove_filter(list(CLOAK_MASK_FILTER, CLOAK_BAND_FILTER, CLOAK_RIM_FILTER, CLOAK_WAVE_FILTER, CLOAK_RIPPLE_FILTER)) + +#undef CLOAK_MASK_FILTER +#undef CLOAK_BAND_FILTER +#undef CLOAK_RIM_FILTER +#undef CLOAK_WAVE_FILTER +#undef CLOAK_RIPPLE_FILTER +#undef CLOAK_SWEEP_TIME +#undef CLOAK_FRONT_START +#undef CLOAK_FRONT_END +#undef CLOAK_FRONT_AWAY +#undef CLOAK_EDGE_START +#undef CLOAK_EDGE_END +#undef CLOAK_CRACKLE_DELAY + +// ========================================================================= +// CASCADE: SPINE NODES AND THE TIME RING +// ========================================================================= + +/// Time between node frames. +#define CASCADE_NODE_STEP (0.1 SECONDS) +/// When the ring rolls out: as the last node lights. +#define CASCADE_RING_DELAY (0.45 SECONDS) + +/obj/item/organ/cyberimp/cyberware/cascade + /// The spine node frame drawn over the lattice, or null for the plain one. + var/node_look + /// Node frames still to play, in order. + var/list/node_queue + /// Timer for the next node frame. + var/node_timer + /// Timer for the floor ring. + var/ring_timer + /// How far the ring rolls out (transform scale of a two-tile ring). + var/ring_scale = 3.3 + /// How bright the ring starts. + var/ring_alpha = 235 + /// How long the ring takes to roll out and fade. + var/ring_time = 0.65 SECONDS + +/obj/item/organ/cyberimp/cyberware/cascade/milspec + ring_scale = 2.7 + ring_alpha = 190 + ring_time = 0.55 SECONDS + +/obj/item/organ/cyberimp/cyberware/cascade/street + ring_scale = 2.2 + ring_alpha = 140 + ring_time = 0.45 SECONDS + +/obj/item/organ/cyberimp/cyberware/cascade/cyberware_worn_key() + return "[..()]-[node_look]" + +/obj/item/organ/cyberimp/cyberware/cascade/cyberware_dress_worn(list/imageset, image_layer, obj/item/bodypart/limb) + if(cyberware_worn_look() || !node_look) + return ..() + var/state = "[aug_overlay]_[node_look]" + if(!icon_exists(CYBERWARE_ANIM_ICON, state)) + return imageset + // The node frame replaces the lattice's own idle glow. + var/list/dressed = list() + for(var/image/part as anything in imageset) + if(PLANE_TO_TRUE(part.plane) == EMISSIVE_PLANE) + continue + dressed += part + dressed += image(icon = CYBERWARE_ANIM_ICON, icon_state = state, layer = image_layer) + dressed += emissive_appearance(CYBERWARE_ANIM_ICON, "[state]_e", limb?.owner || limb, image_layer) + return dressed + +/// Plays node frames one after another, then holds the last. +/obj/item/organ/cyberimp/cyberware/cascade/proc/play_node_looks(list/looks) + if(node_timer) + deltimer(node_timer) + node_timer = null + node_queue = looks.Copy() + advance_node_look() + +/obj/item/organ/cyberimp/cyberware/cascade/proc/advance_node_look() + node_timer = null + if(!length(node_queue)) + return + node_look = popleft(node_queue) + owner?.update_body_parts() + if(length(node_queue)) + node_timer = addtimer(CALLBACK(src, PROC_REF(advance_node_look)), CASCADE_NODE_STEP, TIMER_STOPPABLE) + +/// Back to the plain lattice, no frames pending, no ring on the way. +/obj/item/organ/cyberimp/cyberware/cascade/proc/clear_node_looks(mob/living/carbon/redraw) + if(node_timer) + deltimer(node_timer) + node_timer = null + if(ring_timer) + deltimer(ring_timer) + ring_timer = null + node_queue = null + if(isnull(node_look)) + return + node_look = null + redraw?.update_body_parts() + +/// The window opens: nodes ignite neck to hips, then the ring rolls out. +/obj/item/organ/cyberimp/cyberware/cascade/proc/play_window_open() + if(!owner) + return + play_node_looks(list("boot1", "boot2", "boot3", "boot4", "boot5", "lit")) + if(ring_timer) + deltimer(ring_timer) + ring_timer = addtimer(CALLBACK(src, PROC_REF(roll_ring)), CASCADE_RING_DELAY, TIMER_STOPPABLE) + +/obj/item/organ/cyberimp/cyberware/cascade/proc/roll_ring() + ring_timer = null + var/turf/here = get_turf(owner) + if(here) + new /obj/effect/temp_visual/cyberware_time_ring(here, ring_scale, ring_alpha, ring_time) + +/// The crash: nodes die hips to neck and stay dark. +/obj/item/organ/cyberimp/cyberware/cascade/proc/play_crash() + if(!owner) + return + if(ring_timer) + deltimer(ring_timer) + ring_timer = null + play_node_looks(list("crash1", "crash2", "crash3", "crash4", "crash5", "dead")) + +/// The lattice in the bearer's OS slot, if it is a Cascade. +/proc/cyberware_bearer_lattice(mob/living/bearer) + if(!iscarbon(bearer)) + return null + var/mob/living/carbon/carbon_bearer = bearer + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = carbon_bearer.get_organ_slot(ORGAN_SLOT_CYBERWARE_OS) + return istype(lattice) ? lattice : null + +/// Hook for the window's on_apply(). +/proc/cyberware_cascade_window_fx(mob/living/bearer) + cyberware_bearer_lattice(bearer)?.play_window_open() + +/// Hook for the window's on_remove(), after it has (or hasn't) applied the crash. +/proc/cyberware_cascade_window_end_fx(mob/living/bearer) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = cyberware_bearer_lattice(bearer) + if(lattice && !bearer.has_status_effect(/datum/status_effect/cyberware_cascade_crash)) + lattice.clear_node_looks(bearer) + +/// Hook for the crash's on_apply(). +/proc/cyberware_cascade_crash_fx(mob/living/bearer) + cyberware_bearer_lattice(bearer)?.play_crash() + +/// Hook for the crash's on_remove(). +/proc/cyberware_cascade_crash_end_fx(mob/living/bearer) + cyberware_bearer_lattice(bearer)?.clear_node_looks(bearer) + +/// The floor ring that rolls out when a Cascade window opens. +/obj/effect/temp_visual/cyberware_time_ring + icon = CYBERWARE_ANIM_LARGE_ICON + icon_state = "time_ring" + pixel_x = -16 + pixel_y = -16 + layer = ABOVE_OPEN_TURF_LAYER + plane = FLOOR_PLANE + randomdir = FALSE + duration = 0.65 SECONDS + +/obj/effect/temp_visual/cyberware_time_ring/Initialize(mapload, max_scale = 3.3, peak_alpha = 235, ring_time = 0.65 SECONDS) + duration = ring_time + . = ..() + add_overlay(emissive_appearance(icon, icon_state, src)) + transform = matrix().Scale(0.2) + alpha = peak_alpha + animate(src, transform = matrix().Scale(max_scale), time = duration, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + animate(src, alpha = 0, time = duration, easing = QUAD_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + +#undef CASCADE_NODE_STEP +#undef CASCADE_RING_DELAY + +// ========================================================================= +// EMP ON CHROME +// ========================================================================= + +/// From the hit to the first piece going dark. +#define EMP_SHOW_FIRST_DEATH (0.55 SECONDS) +/// Between one piece going dark and the next. +#define EMP_SHOW_DEATH_STAGGER (0.2 SECONDS) +/// How long the arcs crackle. +#define EMP_SHOW_ARC_TIME (0.8 SECONDS) +/// Most arcs drawn at once. +#define EMP_SHOW_MAX_ARCS 3 + +/** + * The EMP picture for one bearer. Each worn piece hit in a burst fills with + * static, arcs jump between the pieces, and they go dark one after another; + * the mechanical reboot timing is untouched, only the picture staggers. When a + * piece comes back it blinks bright, dark, then on. + * + * Looks live on each piece's cyberware component (emp_look), and clearing + * them always falls back to the right picture for the reboot state, so + * deleting this at any point leaves the bearer drawn correctly. + */ +/datum/component/chrome_emp_show + dupe_mode = COMPONENT_DUPE_UNIQUE + /// Pieces in the burst that have not gone dark yet. + var/list/datum/weakref/crackling = list() + /// Pieces whose look this show has set, so it can take them back off. + var/list/datum/weakref/touched = list() + /// When the current burst started; a later hit starts a new one. + var/burst_started = -1 + /// Pieces scheduled to go dark in the current burst. + var/burst_deaths = 0 + /// Holders this show is playing. + var/list/obj/effect/abstract/cyberware_anim/holders = list() + /// Steps still to run. At zero the show deletes itself. + var/pending = 0 + /// The current blink batch: pieces that rebooted on the same tick. + var/list/datum/weakref/blink_batch + /// When the current blink batch started. + var/blink_started = -1 + +/datum/component/chrome_emp_show/Initialize() + if(!iscarbon(parent)) + return COMPONENT_INCOMPATIBLE + +/datum/component/chrome_emp_show/RegisterWithParent() + RegisterSignal(parent, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + +/datum/component/chrome_emp_show/UnregisterFromParent() + UnregisterSignal(parent, COMSIG_LIVING_DEATH) + +/datum/component/chrome_emp_show/Destroy() + var/list/playing = holders.Copy() + holders.Cut() + QDEL_LIST(playing) + var/mob/living/carbon/bearer = parent + var/changed = FALSE + for(var/datum/weakref/piece_ref as anything in touched) + var/obj/item/organ/piece = piece_ref.resolve() + var/datum/component/cyberware/chrome = piece?.GetComponent(/datum/component/cyberware) + if(chrome?.emp_look) + chrome.emp_look = null + changed = TRUE + touched.Cut() + crackling.Cut() + if(changed && !QDELETED(bearer)) + bearer.update_body_parts() + return ..() + +/datum/component/chrome_emp_show/proc/on_death(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// The show for [bearer], made if there isn't one. +/proc/chrome_emp_show_for(mob/living/carbon/bearer) + if(!iscarbon(bearer) || QDELETED(bearer) || bearer.stat == DEAD) + return null + return bearer.GetComponent(/datum/component/chrome_emp_show) || bearer.AddComponent(/datum/component/chrome_emp_show) + +/// Worn chrome only: the show draws on the worn overlay. +/proc/chrome_emp_show_wants(obj/item/organ/piece) + var/obj/item/organ/cyberimp/implant = piece + return istype(implant) && implant.bodypart_aug && implant.owner + +/// Region name a piece arcs from, matching the arc sheet. +/proc/chrome_emp_region(obj/item/organ/piece) + switch(piece.zone) + if(BODY_ZONE_R_ARM) + return "arm_r" + if(BODY_ZONE_L_ARM) + return "arm_l" + if(BODY_ZONE_R_LEG, BODY_ZONE_L_LEG) + return "legs" + return "chest" + +/// Hook for start_emp_reboot(): the piece was just knocked offline. +/proc/cyberware_emp_hit_fx(obj/item/organ/piece) + if(!chrome_emp_show_wants(piece)) + return + var/datum/component/chrome_emp_show/show = chrome_emp_show_for(piece.owner) + if(show) + show.hit(piece) + else + // No show on a corpse: just draw the piece dark. + piece.owner.update_body_parts() + +/// Hook for finish_emp_reboot(): the piece is back online. +/proc/cyberware_emp_reboot_fx(obj/item/organ/piece) + if(!chrome_emp_show_wants(piece)) + return + var/datum/component/chrome_emp_show/show = chrome_emp_show_for(piece.owner) + if(show) + show.blink(piece) + else + piece.owner.update_body_parts() + +/datum/component/chrome_emp_show/proc/set_look(obj/item/organ/piece, look) + var/datum/component/cyberware/chrome = piece.GetComponent(/datum/component/cyberware) + if(!chrome) + return + chrome.emp_look = look + touched |= WEAKREF(piece) + +/// A piece is hit: static now, dark on its turn. +/datum/component/chrome_emp_show/proc/hit(obj/item/organ/piece) + var/datum/weakref/piece_ref = WEAKREF(piece) + if(burst_started != world.time && !length(crackling)) + // A fresh burst. Everything hit this tick joins it; the crackle starts next tick. + burst_started = world.time + burst_deaths = 0 + pending++ + addtimer(CALLBACK(src, PROC_REF(start_burst)), 0) + set_look(piece, CHROME_LOOK_STATIC) + crackling |= piece_ref + var/dies_at = burst_started + EMP_SHOW_FIRST_DEATH + EMP_SHOW_DEATH_STAGGER * burst_deaths + burst_deaths++ + pending++ + addtimer(CALLBACK(src, PROC_REF(piece_dies), piece_ref), max(dies_at - world.time, 0.5)) + +/// The tick after the hit: show the static, throw the arcs, shake the bearer. +/datum/component/chrome_emp_show/proc/start_burst() + var/mob/living/carbon/bearer = parent + bearer.update_body_parts() + var/list/regions = list() + for(var/datum/weakref/piece_ref as anything in crackling) + var/obj/item/organ/piece = piece_ref.resolve() + if(piece?.owner == bearer) + regions |= chrome_emp_region(piece) + sortTim(regions, GLOBAL_PROC_REF(cmp_text_asc)) + var/list/arcs = list() + if(length(regions) == 1) + arcs += "chrome_arc_[regions[1]]" + else + for(var/i in 1 to length(regions) - 1) + arcs += "chrome_arc_[regions[i]]_[regions[i + 1]]" + if(length(regions) >= 3) + arcs += "chrome_arc_[regions[1]]_[regions[length(regions)]]" + for(var/i in 1 to min(length(arcs), EMP_SHOW_MAX_ARCS)) + add_holder(cyberware_anim_play(bearer, CYBERWARE_ANIM_LARGE_ICON, arcs[i], CHROME_ANIM_TOP_LAYER, EMP_SHOW_ARC_TIME, glow = TRUE)) + bearer.do_jitter_animation(0) + var/turf/here = get_turf(bearer) + if(here) + new /obj/effect/temp_visual/emp(here) + step_done() + +/// One piece's turn to go dark, with a spark where it dies. +/datum/component/chrome_emp_show/proc/piece_dies(datum/weakref/piece_ref) + crackling -= piece_ref + var/obj/item/organ/piece = piece_ref.resolve() + var/mob/living/carbon/bearer = parent + if(piece?.owner == bearer) + var/datum/component/cyberware/chrome = piece.GetComponent(/datum/component/cyberware) + if(chrome?.emp_look == CHROME_LOOK_STATIC) + chrome.emp_look = null + bearer.update_body_parts() + add_holder(cyberware_anim_play(bearer, CYBERWARE_ANIM_LARGE_ICON, "chrome_spark_[chrome_emp_region(piece)]", CHROME_ANIM_TOP_LAYER, 0.2 SECONDS, glow = TRUE)) + step_done() + +/// A piece is back: bright, dark, bright, on. Pieces back on the same tick blink together. +/datum/component/chrome_emp_show/proc/blink(obj/item/organ/piece) + crackling -= WEAKREF(piece) + if(blink_started != world.time) + blink_started = world.time + blink_batch = list() + var/list/batch = blink_batch + pending += 3 + addtimer(CALLBACK(src, PROC_REF(blink_step), batch, CHROME_LOOK_DARK), 0.5) + addtimer(CALLBACK(src, PROC_REF(blink_step), batch, CHROME_LOOK_FLASH), 2) + addtimer(CALLBACK(src, PROC_REF(blink_step), batch, null), 2.5) + blink_batch |= WEAKREF(piece) + set_look(piece, CHROME_LOOK_FLASH) + var/mob/living/carbon/bearer = parent + bearer.update_body_parts() + +/datum/component/chrome_emp_show/proc/blink_step(list/batch, look) + var/mob/living/carbon/bearer = parent + for(var/datum/weakref/piece_ref as anything in batch) + var/obj/item/organ/piece = piece_ref.resolve() + var/datum/component/cyberware/chrome = piece?.GetComponent(/datum/component/cyberware) + // A piece hit again mid-blink belongs to the new burst now. + if(!chrome || piece.owner != bearer || chrome.emp_down) + continue + chrome.emp_look = look + bearer.update_body_parts() + step_done() + +/datum/component/chrome_emp_show/proc/add_holder(obj/effect/abstract/cyberware_anim/holder) + if(!holder) + return + holders += holder + RegisterSignal(holder, COMSIG_QDELETING, PROC_REF(on_holder_deleted)) + +/datum/component/chrome_emp_show/proc/on_holder_deleted(datum/source) + SIGNAL_HANDLER + holders -= source + +/datum/component/chrome_emp_show/proc/step_done() + pending-- + if(pending <= 0 && !length(crackling)) + qdel(src) + +#undef EMP_SHOW_FIRST_DEATH +#undef EMP_SHOW_DEATH_STAGGER +#undef EMP_SHOW_ARC_TIME +#undef EMP_SHOW_MAX_ARCS + +// ========================================================================= +// CLEANUP +// ========================================================================= + +/** + * Takes every animation this organ is running off [old_owner] and resets its + * looks. Called when the chrome leaves a body and when it is deleted. + */ +/obj/item/organ/proc/cyberware_anim_clear(mob/living/carbon/old_owner) + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + if(chrome) + chrome.emp_look = null + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/cyberware_anim_clear(mob/living/carbon/old_owner) + . = ..() + stop_blade_anim() + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade in contents) + blade.inhand_hidden_until = 0 + +/obj/item/organ/cyberimp/cyberware/ghostskin/cyberware_anim_clear(mob/living/carbon/old_owner) + . = ..() + clear_cloak_fx() + +/obj/item/organ/cyberimp/cyberware/cascade/cyberware_anim_clear(mob/living/carbon/old_owner) + . = ..() + clear_node_looks() + +#undef CYBERWARE_ANIM_ICON +#undef CYBERWARE_ANIM_LARGE_ICON +#undef CHROME_LOOK_STATIC +#undef CHROME_LOOK_DARK +#undef CHROME_LOOK_FLASH +#undef CHROME_LOOK_FOLD +#undef CHROME_ANIM_BLADE_LAYER +#undef CHROME_ANIM_TOP_LAYER diff --git a/voidcrew/modules/cyberware/cyberware_base.dm b/voidcrew/modules/cyberware/cyberware_base.dm index 80182925a27..170ec1419eb 100644 --- a/voidcrew/modules/cyberware/cyberware_base.dm +++ b/voidcrew/modules/cyberware/cyberware_base.dm @@ -56,6 +56,8 @@ /datum/bodypart_overlay/augment/cyberware/get_overlay(layer, obj/item/bodypart/limb) layer = bitflag_to_layer(layer) var/list/imageset = implant.get_overlay(layer, limb) + // EMP static, dark and blink looks, the folding housing, the spine nodes (cyberware_anim.dm). + imageset = implant.cyberware_dress_worn(imageset, layer, limb) if(blocks_emissive == EMISSIVE_BLOCK_NONE || !limb) return imageset @@ -69,6 +71,12 @@ all_images += emissive_blocker(overlay.icon, overlay.icon_state, limb, layer = overlay.layer, alpha = overlay.alpha) return all_images + glows +/// The animation look is part of the limb's render key, so changing it redraws the limb. +/datum/bodypart_overlay/augment/cyberware/generate_icon_cache() + . = ..() + if(implant) + . += implant.cyberware_worn_key() + /obj/item/organ/cyberimp/cyberware name = "cyberware" desc = "Aftermarket chrome. Someone sat in a parlor chair for this." @@ -311,16 +319,21 @@ // Any way out of the hand that isn't Retract() (a forced drop, a // deleted blade) must put the folded sprite back as well. RegisterSignal(active_item, COMSIG_ITEM_DROPPED, PROC_REF(on_deployed_item_dropped), override = TRUE) + play_blade_unfold(active_item) owner.update_body_parts() /obj/item/organ/cyberimp/arm/toolkit/cyberware/Retract() var/mob/living/carbon/stowing_owner = owner var/obj/item/stowing = active_item + // Only a blade coming out of the hand folds back in; one picked off the floor just stows. + var/was_held = stowing && stowing_owner?.is_holding(stowing) . = ..() if(.) cyberware_ink_pulse(stowing_owner, CYBERWARE_INK_SOFT) if(hide_worn_while_deployed) UnregisterSignal(stowing, COMSIG_ITEM_DROPPED) + if(was_held && owner) + play_blade_fold() stowing_owner?.update_body_parts() /// TRUE while the worn overlay should not draw: this hardware's worn sprite diff --git a/voidcrew/modules/cyberware/cyberware_component.dm b/voidcrew/modules/cyberware/cyberware_component.dm index 19847571ae9..d1b71cdb0a7 100644 --- a/voidcrew/modules/cyberware/cyberware_component.dm +++ b/voidcrew/modules/cyberware/cyberware_component.dm @@ -66,6 +66,8 @@ if(emp_timer) deltimer(emp_timer) emp_timer = null + var/obj/item/organ/ware = parent + ware?.cyberware_anim_clear() install_context_ref = null install_context_forced = FALSE return ..() @@ -101,6 +103,8 @@ settle_passives(old_owner) set_browned_out(FALSE) cyberware_reevaluate_brownout(old_owner) + var/obj/item/organ/ware = parent + ware.cyberware_anim_clear(old_owner) /// Signal proc for [COMSIG_CARBON_GAIN_ORGAN] and [COMSIG_CARBON_LOSE_ORGAN] /// on the bearer. Every installed ware listens with its own component, so a @@ -131,6 +135,8 @@ if(emp_timer) deltimer(emp_timer) emp_timer = addtimer(CALLBACK(src, PROC_REF(finish_emp_reboot)), CYBERWARE_EMP_DOWNTIME / max(severity, 1), TIMER_STOPPABLE) + // Static, arcs, then dark (cyberware_anim.dm). Only the picture staggers. + cyberware_emp_hit_fx(parent) /datum/component/cyberware/proc/finish_emp_reboot() emp_timer = null @@ -141,6 +147,9 @@ var/obj/item/organ/ware = parent if(ware.owner && !(ware.organ_flags & ORGAN_FAILING)) ware.owner.balloon_alert(ware.owner, "[ware.name] back online") + cyberware_emp_reboot_fx(ware) + else + ware.owner?.update_body_parts() /// Cradle tune-up: cancels any pending EMP reboot and repairs the organ /// outright. Cannot fix a brownout, that is a load problem, not damage. @@ -149,9 +158,11 @@ deltimer(emp_timer) emp_timer = null emp_down = FALSE + emp_look = null var/obj/item/organ/ware = parent ware.set_organ_damage(0) update_failing() + ware.owner?.update_body_parts() /// Settles ORGAN_FAILING from our two failure sources. Careful not to clear /// a failure earned through organ damage: only lift the flag while the organ diff --git a/voidcrew/modules/cyberware/icons/cyberware_anim.dmi b/voidcrew/modules/cyberware/icons/cyberware_anim.dmi new file mode 100644 index 0000000000000000000000000000000000000000..0ad2e92375c8b3d885593a5c67eb4743756a8a3b GIT binary patch literal 7584 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

    S@VZf^%beNVrKW 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 XSmwd|{)ay-L;`=>zpM7gqi6pIOvgZR literal 0 HcmV?d00001 diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 8b4a1806070..f2f534ded68 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -195,6 +195,7 @@ span_boldnotice(online_message), ) owner.balloon_alert(owner, "cascade online") + cyberware_cascade_window_fx(owner) return TRUE /datum/status_effect/cyberware_cascade_window/on_remove() @@ -205,6 +206,8 @@ owner.remove_client_colour(id) if(!QDELETED(owner) && owner.stat != DEAD) owner.apply_status_effect(/datum/status_effect/cyberware_cascade_crash, crash_duration, crash_stamina) + if(!QDELETED(owner)) + cyberware_cascade_window_end_fx(owner) return ..() /// Melee cooldowns scaled for the window (changeNext_move multiplies these in). @@ -292,6 +295,7 @@ span_userdanger("The lattice dumps its heat. Your whole body seizes up and everything slows to a crawl."), ) owner.balloon_alert(owner, "cascade crash!") + cyberware_cascade_crash_fx(owner) return TRUE /datum/status_effect/cyberware_cascade_crash/on_remove() @@ -299,6 +303,8 @@ owner.remove_client_colour(id) if(!QDELETED(owner) && owner.stat != DEAD) owner.balloon_alert(owner, "systems restabilized") + if(!QDELETED(owner)) + cyberware_cascade_crash_end_fx(owner) return ..() /datum/movespeed_modifier/cyberware_cascade diff --git a/voidcrew/modules/cyberware/ware_military_body.dm b/voidcrew/modules/cyberware/ware_military_body.dm index 4f36d3372b8..343a89a6156 100644 --- a/voidcrew/modules/cyberware/ware_military_body.dm +++ b/voidcrew/modules/cyberware/ware_military_body.dm @@ -77,7 +77,8 @@ camo_active = TRUE if(hides_from_mobs) ADD_TRAIT(wearer, TRAIT_CYBER_CAMO, REF(src)) - animate(wearer, alpha = field_alpha, time = 1 SECONDS) + // Fades to field_alpha over a second, as a sweep from the head down (cyberware_anim.dm). + cloak_sweep_in(wearer) engage_feedback(wearer) // The MOD stealth break list (modules_ninja.dm), minus bump-off, plus // gunfire and throwing: shooting or throwing from inside a cloak breaks it here. @@ -122,6 +123,7 @@ deltimer(camo_timer) camo_timer = null if(!wearer) + clear_cloak_fx() return REMOVE_TRAIT(wearer, TRAIT_CYBER_CAMO, REF(src)) UnregisterSignal(wearer, list( @@ -141,6 +143,7 @@ COMSIG_MOVABLE_MOVED, )) animate(wearer, alpha = 255, time = 0.5 SECONDS) + cloak_break_fx(wearer, silent) if(!silent) do_sparks(2, TRUE, wearer) wearer.visible_message( @@ -175,6 +178,9 @@ var/mob/living/wearer = owner if(!wearer || !camo_active) return + // The sweep is still coming down; the bearer is more visible than this already. + if(world.time < cloak_sweep_until) + return animate(wearer, alpha = ripple_alpha, time = 0.15 SECONDS) animate(alpha = field_alpha, time = 0.35 SECONDS) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 07e35432e9b..8e7aa3ce541 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -71,6 +71,7 @@ #include "voidcrew_crew_antag_gc.dm" #include "voidcrew_crew_hud.dm" #include "voidcrew_cyberware.dm" +#include "voidcrew_cyberware_anim.dm" #include "voidcrew_cyberware_camo.dm" #include "voidcrew_cyberware_deck.dm" #include "voidcrew_cyberware_kit.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_anim.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_anim.dm new file mode 100644 index 00000000000..a45ab55f859 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_anim.dm @@ -0,0 +1,190 @@ +/** + * # Chrome animations + * + * The animations only draw, but they leave things on a body while they play: + * holders in vis_contents, filters, worn looks and pending frames. These check + * that each animation puts them on and that they come off again when it is cut + * short: the blade stowed, the chrome pulled, the field broken, the bearer dead. + * + * Unit-test files compile before voidcrew/_DEFINES/ and the module's own + * defines are local, so looks and filter names are literals here. + */ + +/// TRUE if any chrome animation holder is still drawn on [bearer]. +/proc/voidcrew_test_has_chrome_anim(atom/movable/bearer) + return !!(locate(/obj/effect/abstract/cyberware_anim) in bearer.vis_contents) + +/// Mantis: the swing rides the bearer, the fold replaces the unfold, and +/// pulling the housing takes the swing and the folded-housing wait with it. +/datum/unit_test/voidcrew_cyberware_anim_blade + +/datum/unit_test/voidcrew_cyberware_anim_blade/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis) + TEST_ASSERT(housing.Insert(bearer, special = TRUE), "Test fixture: Mantis housing insert refused") + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade = housing.contents[1] + + housing.Extend(blade) + TEST_ASSERT(bearer.is_holding(blade), "Test fixture: the blade never reached a hand") + var/obj/effect/abstract/cyberware_anim/unfold = housing.blade_anim() + TEST_ASSERT(unfold && (unfold in bearer.vis_contents), "The blade swing didn't play on the bearer") + TEST_ASSERT_EQUAL(unfold.icon_state, "mantis_unfold_right", "The right housing played the wrong swing") + TEST_ASSERT(blade.inhand_hidden_until > world.time, "The held blade draws before the swing has played") + + housing.Retract() + TEST_ASSERT(QDELETED(unfold), "Stowing the blade left the unfold swing playing") + var/obj/effect/abstract/cyberware_anim/fold = housing.blade_anim() + TEST_ASSERT(fold && (fold in bearer.vis_contents), "The blade didn't fold back in") + TEST_ASSERT_EQUAL(fold.icon_state, "mantis_fold_right", "The right housing folded the wrong swing") + TEST_ASSERT_EQUAL(housing.cyberware_worn_look(), "fold", "The folded housing draws over the fold swing") + TEST_ASSERT(findtext(bearer.icon_render_keys[BODY_ZONE_R_ARM], "fold"), "The arm wasn't redrawn for the fold") + + // A blade knocked out of the hand is stowed without a fold. + housing.Extend(blade) + bearer.dropItemToGround(blade, force = TRUE) + housing.Retract() + TEST_ASSERT(isnull(housing.cyberware_worn_look()), "A blade stowed off the floor hid the housing for a fold") + + housing.Extend(blade) + var/obj/effect/abstract/cyberware_anim/last_swing = housing.blade_anim() + TEST_ASSERT(last_swing, "Test fixture: the last swing never played") + housing.Remove(bearer, special = TRUE) + TEST_ASSERT(QDELETED(last_swing) && isnull(housing.blade_anim_ref), "Pulling the housing left its swing") + TEST_ASSERT(!voidcrew_test_has_chrome_anim(bearer), "Pulling the housing left a swing drawn on the body") + TEST_ASSERT(!housing.fold_hide_until && !housing.fold_timer, "Pulling the housing left the folded housing waiting") + TEST_ASSERT(!blade.inhand_hidden_until, "A pulled housing's blade stays hidden in its next hand") + +/// Ghostskin: the sweep filters come up with the field, give way to the +/// shimmer, and every filter is off the bearer once the field breaks and the +/// weave is pulled. The alpha var itself is unchanged by the sweep. +/datum/unit_test/voidcrew_cyberware_anim_cloak + +/datum/unit_test/voidcrew_cyberware_anim_cloak/Run() + var/static/list/field_filters = list("cyberware_cloak_mask", "cyberware_cloak_band", "cyberware_cloak_rim", "cyberware_cloak_wave", "cyberware_cloak_ripple") + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/ghostskin/weave = allocate(/obj/item/organ/cyberimp/cyberware/ghostskin) + TEST_ASSERT(weave.Insert(bearer, special = TRUE), "Test fixture: Ghostskin insert refused") + + weave.engage_camo(bearer) + TEST_ASSERT_EQUAL(bearer.alpha, 40, "The sweep changed the field's alpha") + TEST_ASSERT(bearer.get_filter("cyberware_cloak_mask"), "The field came up without its sweep") + TEST_ASSERT(bearer.get_filter("cyberware_cloak_band"), "The sweep has no refraction band") + TEST_ASSERT(weave.cloak_settle_timer, "Nothing ends the sweep") + var/obj/effect/abstract/cyberware_anim/edge = weave.cloak_edge_ref?.resolve() + TEST_ASSERT(edge && (edge in bearer.vis_contents), "The sweep has no refraction line") + + weave.cloak_settle() + qdel(edge) // Its own lifetime is the sweep's. + TEST_ASSERT(!bearer.get_filter("cyberware_cloak_mask"), "The sweep mask stayed on after the sweep") + TEST_ASSERT(bearer.get_filter("cyberware_cloak_wave"), "The held field doesn't shimmer") + TEST_ASSERT(bearer.get_filter("cyberware_cloak_rim"), "The held field has no rim") + + weave.break_camo(bearer, reason = "test") + for(var/filter_name in field_filters - "cyberware_cloak_ripple") + TEST_ASSERT(!bearer.get_filter(filter_name), "A broken field left [filter_name] on its bearer") + TEST_ASSERT(bearer.get_filter("cyberware_cloak_ripple"), "The field broke without collapsing") + TEST_ASSERT(weave.cloak_crackle_timer, "The field broke without a crackle") + weave.cloak_crackle(WEAKREF(bearer)) + TEST_ASSERT(voidcrew_test_has_chrome_anim(bearer), "The crackle didn't play on the bearer") + + weave.Remove(bearer, special = TRUE) + TEST_ASSERT(!bearer.get_filter("cyberware_cloak_ripple"), "Pulling the weave left the collapse on its old bearer") + + // Pulled mid-sweep, and dead mid-field: nothing stays behind either way. + var/mob/living/carbon/human/next_bearer = allocate(/mob/living/carbon/human/consistent) + TEST_ASSERT(weave.Insert(next_bearer, special = TRUE), "Test fixture: Ghostskin reinsert refused") + weave.engage_camo(next_bearer) + weave.Remove(next_bearer, special = TRUE) + for(var/filter_name in field_filters) + TEST_ASSERT(!next_bearer.get_filter(filter_name), "A weave pulled mid-sweep left [filter_name] behind") + TEST_ASSERT(!voidcrew_test_has_chrome_anim(next_bearer), "A weave pulled mid-sweep left its refraction line") + TEST_ASSERT(!weave.cloak_crackle_timer && !weave.cloak_settle_timer, "A pulled weave kept animating") + + var/mob/living/carbon/human/doomed = allocate(/mob/living/carbon/human/consistent) + TEST_ASSERT(weave.Insert(doomed, special = TRUE), "Test fixture: Ghostskin third insert refused") + weave.engage_camo(doomed) + doomed.death() + TEST_ASSERT(!weave.camo_active, "Test fixture: death didn't drop the field") + for(var/filter_name in field_filters - "cyberware_cloak_ripple") + TEST_ASSERT(!doomed.get_filter(filter_name), "Death left [filter_name] on the body") + +/// Cascade: the window lights the spine, the crash kills it, the crash ending +/// puts the plain lattice back, and a lattice pulled mid-window leaves no frames. +/datum/unit_test/voidcrew_cyberware_anim_cascade + +/datum/unit_test/voidcrew_cyberware_anim_cascade/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = allocate(/obj/item/organ/cyberimp/cyberware/cascade) + TEST_ASSERT(lattice.Insert(bearer, special = TRUE), "Test fixture: Cascade insert refused") + + bearer.apply_status_effect(/datum/status_effect/cyberware_cascade_window, lattice) + TEST_ASSERT_EQUAL(lattice.node_look, "boot1", "The spine didn't start lighting when the window opened") + TEST_ASSERT(lattice.node_timer, "The spine lit one node and stopped") + TEST_ASSERT(lattice.ring_timer, "No ring is coming") + TEST_ASSERT(findtext(bearer.icon_render_keys[BODY_ZONE_CHEST], "boot1"), "The chest wasn't redrawn for the node frame") + var/list/images = lattice.bodypart_aug.get_overlay(EXTERNAL_ADJACENT, bearer.get_bodypart(BODY_ZONE_CHEST)) + var/found_nodes = FALSE + for(var/image/part as anything in images) + if(part.icon_state == "cascade_boot1") + found_nodes = TRUE + TEST_ASSERT(found_nodes, "The node frame isn't drawn over the lattice") + + bearer.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + TEST_ASSERT(bearer.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "Test fixture: no crash after the window") + TEST_ASSERT_EQUAL(lattice.node_look, "crash1", "The spine didn't start dying with the crash") + TEST_ASSERT(!lattice.ring_timer, "The ring rolls out after the window closed") + + bearer.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + TEST_ASSERT_NULL(lattice.node_look, "The crash ended on a node frame") + TEST_ASSERT(!lattice.node_timer, "Node frames keep playing after the crash") + + bearer.apply_status_effect(/datum/status_effect/cyberware_cascade_window, lattice) + lattice.Remove(bearer, special = TRUE) + TEST_ASSERT_NULL(lattice.node_look, "A lattice pulled mid-window kept its node frame") + TEST_ASSERT(!lattice.node_timer && !lattice.ring_timer, "A lattice pulled mid-window kept playing") + +/// EMP: every worn piece gets the static look and joins one show; the show +/// ends with its bearer, and the reboot and a tune-up both clear the look. +/datum/unit_test/voidcrew_cyberware_anim_emp + +/datum/unit_test/voidcrew_cyberware_anim_emp/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = allocate(/obj/item/organ/cyberimp/cyberware/cascade) + TEST_ASSERT(arm.Insert(bearer, special = TRUE), "Test fixture: Gorilla Arms insert refused") + TEST_ASSERT(lattice.Insert(bearer, special = TRUE), "Test fixture: Cascade insert refused") + var/datum/component/cyberware/arm_chrome = arm.GetComponent(/datum/component/cyberware) + var/datum/component/cyberware/spine_chrome = lattice.GetComponent(/datum/component/cyberware) + + arm_chrome.start_emp_reboot() + spine_chrome.start_emp_reboot() + var/datum/component/chrome_emp_show/show = bearer.GetComponent(/datum/component/chrome_emp_show) + TEST_ASSERT(show, "An EMP on worn chrome put on no show") + TEST_ASSERT_EQUAL(length(show.crackling), 2, "Two pieces hit on one tick aren't one burst") + TEST_ASSERT_EQUAL(arm.cyberware_worn_look(), "static", "An EMP'd piece doesn't fill with static") + var/list/images = arm.bodypart_aug.get_overlay(EXTERNAL_ADJACENT, bearer.get_bodypart(BODY_ZONE_R_ARM)) + var/image/plate = images[1] + TEST_ASSERT(plate.appearance_flags & KEEP_TOGETHER, "The static isn't clipped to the piece") + + bearer.death() + TEST_ASSERT(QDELETED(show), "The EMP show outlived its bearer") + TEST_ASSERT(!voidcrew_test_has_chrome_anim(bearer), "Arcs or sparks stayed on a dead bearer") + TEST_ASSERT_EQUAL(arm.cyberware_worn_look(), "dark", "Dead, the EMP'd chrome isn't drawn dark") + images = arm.bodypart_aug.get_overlay(EXTERNAL_ADJACENT, bearer.get_bodypart(BODY_ZONE_R_ARM)) + plate = images[1] + TEST_ASSERT(islist(plate.color), "A dark piece isn't drawn dark") + + arm_chrome.finish_emp_reboot() + TEST_ASSERT_NULL(arm.cyberware_worn_look(), "A rebooted piece kept a look") + spine_chrome.tune_up() + TEST_ASSERT_NULL(lattice.cyberware_worn_look(), "A tuned-up piece kept a look") + + // Pulled while crackling: the piece drops its look, the show carries on without it. + var/mob/living/carbon/human/next_bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/left/left_arm = allocate(/obj/item/organ/cyberimp/cyberware/gorilla_arms/left) + TEST_ASSERT(left_arm.Insert(next_bearer, special = TRUE), "Test fixture: left Gorilla Arms insert refused") + var/datum/component/cyberware/left_chrome = left_arm.GetComponent(/datum/component/cyberware) + left_chrome.start_emp_reboot() + TEST_ASSERT(next_bearer.GetComponent(/datum/component/chrome_emp_show), "Test fixture: no show on the second bearer") + left_arm.Remove(next_bearer, special = TRUE) + TEST_ASSERT_NULL(left_chrome.emp_look, "A piece pulled mid-EMP kept its static") From 069f1c13bbffe8ccec3044380dec651680812535 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:01:56 -0800 Subject: [PATCH 142/472] Apply the cyberware review fixes, owner defaults and balance pass Chrome abilities keep the clicks they refuse, so a held gun never fires through an armed ability. Ghostskin drops on animal, mob, mech and xeno attacks and on real shocks. The chrome wheel shows the zoom as on and can't pick a chip whose chrome was pulled while it was open. A deck's busy flag clears on removal and times out. Chrome Read no longer sends the reader their own eye-glint line. Scorch Blades only ignite on cuts that land. Decks run no room hacks inside a player outpost prison wing, Reboot Turret leaves prison turrets alone, Silence and Electrify at a trader outpost are strikes, and a pacifist won't run Feedback Surge, Cutout, Glitch or Arc Flash. Faction pirate bosses see through camo. Glitch picks by load plus capacity, so weightless chrome can't be used as decoys. Both ripperdocs now buy back every credit-priced piece at half list. Street deck 2,400 cr and load 3; Brawler myomers 1,600 cr with a 5 s punch; Flickerskin alpha 60, 7 s field, load 2; Hothead 90 s and load 4; Scorch Blades 4 vouchers; the newer syndicate cache chrome at half weight. Ratings, tests and the wiki follow. --- tools/gear_catalog/ratings/cyberware.json | 24 +- voidcrew/modules/cyberware/chrome_wheel.dm | 18 +- voidcrew/modules/cyberware/cyberware_base.dm | 22 ++ voidcrew/modules/cyberware/cyberware_scan.dm | 4 +- .../modules/cyberware/cyberware_stealth.dm | 6 +- .../modules/cyberware/ripperdoc_catalog.dm | 141 +++++++- .../modules/cyberware/street_doc_catalog.dm | 49 ++- voidcrew/modules/cyberware/ware_legend.dm | 2 +- .../modules/cyberware/ware_line_berserk.dm | 10 +- voidcrew/modules/cyberware/ware_line_camo.dm | 11 +- voidcrew/modules/cyberware/ware_line_deck.dm | 110 +++++-- .../modules/cyberware/ware_line_mantis.dm | 19 +- .../modules/cyberware/ware_line_models_a.dm | 8 +- .../modules/cyberware/ware_military_arms.dm | 2 +- .../modules/cyberware/ware_military_body.dm | 22 ++ .../modules/cyberware/ware_pro_utility.dm | 8 +- voidcrew/modules/loot/themes/syndicate.dm | 22 +- voidcrew/modules/quickhacks/masshacks.dm | 4 + voidcrew/modules/quickhacks/quickhacks.dm | 5 + voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../modules/unit_tests/voidcrew_cyberware.dm | 13 +- .../unit_tests/voidcrew_cyberware_final.dm | 308 ++++++++++++++++++ .../unit_tests/voidcrew_cyberware_mantis.dm | 8 + .../unit_tests/voidcrew_cyberware_models_a.dm | 6 +- .../unit_tests/voidcrew_shop_catalog.dm | 2 +- wiki/content/cyberware.md | 14 +- 26 files changed, 741 insertions(+), 98 deletions(-) create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_final.dm diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 36064387387..6b03ffde959 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -106,7 +106,7 @@ "name": "Scorch Blade housing (right)", "note": "Each landed cut adds 2 fire stacks and sets the target alight; the lunge adds 4 once, however many blades arrive. Corpses are left alone and the wearer is never lit. The blade is lit while out.", "rank": 3, - "reason": "Half of a pair: the Mantis blades and lunge, with every cut and the lunge setting the target alight. Load 8 the pair, 5 vouchers at reputation 22, one on the shelf per round.", + "reason": "Half of a pair: the Mantis blades and lunge, with every cut and the lunge setting the target alight. Load 8 the pair, 4 vouchers at reputation 22, one on the shelf per round.", "tier": "red" }, "/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/scorch/left": { @@ -409,7 +409,7 @@ "group": "Armor and frame", "hash": "4ceed965097294a4", "name": "Coolant Loop lattice", - "note": "While the ware is online the wearer takes no damage from overheating (hot air or body temperature), takes half burn damage (humans only), and loses 1 fire stack per second. It does not stop fire: a burning wearer is still burned at half strength. All three drop during an EMP reboot or over-capacity brownout.", + "note": "While the ware is online the wearer takes no damage from overheating (hot air or body temperature), takes half burn damage (humans only), and loses 1 fire stack per second. Fire on a human only does harm through body heat, which the ware blocks, so a burning wearer loses clothes but takes no burn from the flames; burn dealt outright (lasers) lands at half. All three drop during an EMP reboot or over-capacity brownout.", "rank": 2, "reason": "Halves all burn damage on top of armor, lasers included, plus no heat damage and fire burns off fast. Half of every beam in the game for load 3. Bullets and blades ignore it. 3,600 cr with no reputation.", "tier": "red" @@ -419,9 +419,9 @@ "group": "Armor and frame", "hash": "", "name": "Heat-sink lining", - "note": "While the ware is online the wearer takes no damage from overheating (hot air or body temperature) and loses 1 fire stack per second. It does not reduce burn damage and does not stop fire: a burning wearer is burned at full strength. Both drop during an EMP reboot or over-capacity brownout.", + "note": "While the ware is online the wearer takes no damage from overheating (hot air or body temperature) and loses 1 fire stack per second. It does not reduce burn damage dealt outright (lasers land at full strength), but fire on a human only does harm through body heat, which the ware blocks, so a burning wearer loses clothes and takes no burn from the flames. Both drop during an EMP reboot or over-capacity brownout.", "rank": 9, - "reason": "No heat damage from hot air or lava country, and fire burns off fast, but burns from lasers and flames land at full strength. The heat half of the Coolant Loops for load 1, 1,500 cr.", + "reason": "No heat damage from hot air or lava country, and being set alight burns only your clothes, but laser burns land at full strength. The heat half of the Coolant Loops for load 1, 1,500 cr.", "tier": "green" }, "/obj/item/organ/cyberimp/cyberware/cuffer_wrists": { @@ -459,7 +459,7 @@ "hash": "", "name": "Breacher deck", "rank": 2, - "reason": "People from 7 tiles after a 1 s upload: Feedback Surge for up to 60 burn, a knockdown and dropped hands; Overload a machine into a small explosion; Glitch one piece of a target's chrome into a 9 s reboot. Plus the Street deck's hacks. One shared cooldown, 45 to 60 s on the big ones. Load 6, 4 vouchers at reputation 11.", + "reason": "People from 7 tiles after a 1 s upload: Feedback Surge for up to 60 burn, a knockdown and dropped hands; Overload a machine into a small explosion; Glitch one piece of a target's chrome into a 9 s reboot, heavier pieces likelier. Plus the Street deck's hacks. One shared cooldown, 45 to 60 s on the big ones. Load 6, 4 vouchers at reputation 11.", "tier": "red" }, "/obj/item/organ/cyberimp/cyberware/deck/control": { @@ -486,7 +486,7 @@ "hash": "", "name": "Street deck", "rank": 7, - "reason": "The Junker deck at 6 tiles and 1.5 s, plus Kill the Power: flip an APC's breaker and the whole area goes dark and unpowered. No hack that touches a person. Load 4, 3,500 cr at yellow reputation; buys back at 1,750.", + "reason": "The Junker deck at 6 tiles and 1.5 s, plus Kill the Power: flip an APC's breaker and the whole area goes dark and unpowered. No hack that touches a person. Load 3, 2,400 cr at yellow reputation; buys back at 1,200.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/dermal_mesh": { @@ -564,7 +564,7 @@ "group": "Mobility and stealth", "hash": "d87709dff38cd54e", "name": "Ghostskin weave", - "note": "A button turns on 10 s of optical camo (a faint outline that brightens when they move) with a 30 s cooldown spent on use; pressing it again drops the field early. The field collapses on attacking a living thing, firing a gun, being hit or attacked, hostile bullets, being cuffed, death or an EMP. Basic-mob AI won't target the wearer from more than 2 tiles away while it is up.", + "note": "A button turns on 10 s of optical camo (a faint outline that brightens when they move) with a 30 s cooldown spent on use; pressing it again drops the field early. The field collapses on attacking a living thing, firing a gun, being hit or attacked (an animal's bite, a mech or a xeno included), hostile bullets, a real electric shock, being cuffed, death or an EMP. Basic-mob AI won't target the wearer from more than 2 tiles away while it is up.", "rank": 2, "reason": "Ten seconds of near-invisibility every 30, and mobs farther than 2 tiles lose you. Attacking or being hit ends it. A free escape or free first shot in most fights, and it walks you through ruins. Load 5, 4 vouchers at reputation 22.", "tier": "red" @@ -574,9 +574,9 @@ "group": "Mobility and stealth", "hash": "", "name": "Flickerskin weave", - "note": "A button turns on 5 s of optical camo (a half-faded outline that brightens when they move) with a 45 s cooldown spent on use; pressing it again drops the field early. The field collapses on attacking a living thing, firing a gun, being hit or attacked, hostile bullets, being cuffed, death or an EMP. It does not grant the mob-hiding camo trait, so NPC AI still targets the wearer at any range and Chrome Read still resolves them.", + "note": "A button turns on 7 s of optical camo (a faded outline at about a quarter opacity that brightens when they move) with a 45 s cooldown spent on use; pressing it again drops the field early. The field collapses on attacking a living thing, firing a gun, being hit or attacked (an animal's bite, a mech or a xeno included), hostile bullets, a real electric shock, being cuffed, death or an EMP. It does not grant the mob-hiding camo trait, so NPC AI still targets the wearer at any range and Chrome Read still resolves them.", "rank": 6, - "reason": "Five seconds of half-faded camo every 45: people lose you in a crowd, mobs and turrets still see you, and any swing or hit ends it. A cheap way to break line of sight from players. Load 3, 2,400 cr at yellow reputation.", + "reason": "Seven seconds of mostly faded camo every 45: people lose you in a crowd or a dim room, mobs and turrets still see you, and any swing or hit ends it. A cheap way to break line of sight from players. Load 2, 2,400 cr at yellow reputation.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/gorilla_arms": { @@ -605,7 +605,7 @@ "hash": "", "name": "Brawler myomers (right)", "rank": 15, - "reason": "Half of a pair: a flat 14 damage punch every 6 s, 20 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying. Load 4 the pair, 2,400 cr at reputation 4. Splice takes a pulled arm back for 600 cr.", + "reason": "Half of a pair: a flat 14 damage punch every 5 s, 20 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying. Load 4 the pair, 1,600 cr at reputation 4. Either ripperdoc takes a pulled arm back for 400 cr.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left": { @@ -614,7 +614,7 @@ "hash": "", "name": "Brawler myomers (left)", "rank": 16, - "reason": "Left half of the Brawler pair: a flat 14 damage punch every 6 s, 20 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying.", + "reason": "Left half of the Brawler pair: a flat 14 damage punch every 5 s, 20 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/governor_delete": { @@ -744,7 +744,7 @@ "hash": "", "name": "Hothead core", "rank": 8, - "reason": "Eight seconds in every 120: 20% less brute and burn and 3 more on punches, with no stun immunity and no roar. Then 100 stamina and slow legs. Load 5, 3,000 cr at reputation 4.", + "reason": "Eight seconds in every 90: 20% less brute and burn and 3 more on punches, with no stun immunity and no roar. Then 100 stamina and slow legs. Load 4, 3,000 cr at reputation 4.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/registry_uplink": { diff --git a/voidcrew/modules/cyberware/chrome_wheel.dm b/voidcrew/modules/cyberware/chrome_wheel.dm index b6c65891f70..29d89d905ce 100644 --- a/voidcrew/modules/cyberware/chrome_wheel.dm +++ b/voidcrew/modules/cyberware/chrome_wheel.dm @@ -430,6 +430,11 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( /// Whether an ability is "on" right now: a toggled arm with its hardware out, /// or a click-to-target ability armed and waiting for the click. /proc/chrome_wheel_ability_active(datum/action/ability, mob/living/carbon/bearer) + // The zoom is a toggle, not a click-to-target: it is on while the optics are zoomed. + if(istype(ability, /datum/action/cooldown/cyberware/optics_zoom)) + var/datum/action/cooldown/cyberware/optics_zoom/zoom = ability + var/obj/item/organ/eyes/robotic/cyberware/optics = zoom.organ + return istype(optics) && optics.is_zoomed() if(istype(ability, /datum/action/cooldown)) return bearer.click_intercept == ability var/obj/item/organ/cyberimp/arm/toolkit/arm = ability.target @@ -717,21 +722,24 @@ GLOBAL_LIST_INIT(chrome_wheel_reserved, list( if(old_owner == parent) deselect() +/// Picks the ability on [chip] while it is still chrome in this body. Returns +/// whether it did: a ware cut out while the wheel was held open drops out here, +/// so its chip can't be picked and the pick can't follow it into someone else. /datum/component/chrome_wheel/proc/select_chip(atom/movable/screen/chrome_wheel/chip/chip, quiet = FALSE) var/datum/action/ability = chip?.ability_ref?.resolve() - if(!ability) - return + if(!ability || !(ability in get_chrome_wheel_actions(parent))) + return FALSE select(ability) if(!quiet) var/mob/living/carbon/bearer = parent bearer.balloon_alert(bearer, lowertext(chip.name)) + return TRUE /// Pick [chip] (or nothing, for null) and close the wheel. The pick lands at /// once; the wheel stays up a moment longer for the lock and the fade. /datum/component/chrome_wheel/proc/choose(atom/movable/screen/chrome_wheel/chip/chip) - if(chip) - select_chip(chip) - close_wheel(linger = TRUE, chosen = chip?.ability_ref?.resolve() ? chip : null) + var/picked = chip && select_chip(chip) + close_wheel(linger = TRUE, chosen = picked ? chip : null) // ---- Opening and closing ----------------------------------------------- diff --git a/voidcrew/modules/cyberware/cyberware_base.dm b/voidcrew/modules/cyberware/cyberware_base.dm index 170ec1419eb..553cf8660ad 100644 --- a/voidcrew/modules/cyberware/cyberware_base.dm +++ b/voidcrew/modules/cyberware/cyberware_base.dm @@ -552,6 +552,28 @@ return FALSE return TRUE +/** + * A click made with a chrome ability armed belongs to the chrome, refused or + * not. tg's version returns FALSE on a refusal, and ClickOn() then carries on + * as an ordinary click: a held gun fires at whoever was out of reach, an empty + * hand punches whoever the deck would not take. So an armed ability swallows + * every click it is handed. A refusal (out of reach, no line of sight, wrong + * target) keeps it armed for another try; a button that has gone dark + * since it was armed (EMP, cooldown started elsewhere, stun) disarms, so the + * next click is an ordinary one again. + * + * Only for a real click on an armed ability. Trigger() with a target (AI and + * scripted callers) and the deck's zoom relay pass through unchanged. + */ +/datum/action/cooldown/cyberware/InterceptClickOn(mob/living/clicker, params, atom/target) + var/armed = !isnull(clicker) && clicker.click_intercept == src + if(armed && !IsAvailable(feedback = TRUE)) + unset_click_ability(clicker, refund_cooldown = TRUE) + return TRUE + . = ..() + if(!. && armed) + return TRUE + /** * Is there a clear arc from [here] to [destination]? Shared by every leg * ware that throws its owner through the air: Hopper Pistons, Meteor diff --git a/voidcrew/modules/cyberware/cyberware_scan.dm b/voidcrew/modules/cyberware/cyberware_scan.dm index 559a660d4b5..c5fefbcf8ac 100644 --- a/voidcrew/modules/cyberware/cyberware_scan.dm +++ b/voidcrew/modules/cyberware/cyberware_scan.dm @@ -239,13 +239,13 @@ if(!self_read) // The tell: anyone close enough sees the readout scroll across the eyes. // The subject only sits that message out when they're getting the - // louder one below instead. + // louder one below instead. The reader never gets it: it's their eyes. chrome_read_eye_glint(bearer) var/subject_feels_it = report["count"] bearer.visible_message( span_notice("[bearer]'s irises flicker with a scrolling readout."), vision_distance = 3, - ignored_mobs = subject_feels_it ? list(subject) : null, + ignored_mobs = subject_feels_it ? list(subject, bearer) : list(bearer), ) // The subject's own hardware reports the unauthorized handshake: it // flinches on their screen, and their chat names the reader. diff --git a/voidcrew/modules/cyberware/cyberware_stealth.dm b/voidcrew/modules/cyberware/cyberware_stealth.dm index 9e2a72af13c..38c5de72dbf 100644 --- a/voidcrew/modules/cyberware/cyberware_stealth.dm +++ b/voidcrew/modules/cyberware/cyberware_stealth.dm @@ -42,10 +42,12 @@ #define CYBERWARE_CAMO_SPOT_RANGE 2 /// Mobs that see straight through optical camo, whatever it is: megafauna, -/// mining elites and the bosses and mini bosses of the ruins, bounties, lairs -/// and outposts. A typecache, so subtypes count. +/// mining elites and the bosses and mini bosses of the ruins, bounties, lairs, +/// outposts and NPC ships (every faction's pirate boss, the NT patrol's +/// included). A typecache, so subtypes count. GLOBAL_LIST_INIT(cyberware_camo_piercing_types, typecacheof(list( /mob/living/simple_animal/hostile/megafauna, + /mob/living/basic/trooper/pirate/faction/boss, /mob/living/simple_animal/hostile/asteroid/elite, /mob/living/basic/boss, /mob/living/basic/lich, diff --git a/voidcrew/modules/cyberware/ripperdoc_catalog.dm b/voidcrew/modules/cyberware/ripperdoc_catalog.dm index b8d0ad8d257..42134f40e16 100644 --- a/voidcrew/modules/cyberware/ripperdoc_catalog.dm +++ b/voidcrew/modules/cyberware/ripperdoc_catalog.dm @@ -8,10 +8,10 @@ * * Balance shape mirrors Vex's counter: credits buy the street and pro-utility * ware, vouchers gate everything that changes how you fight. Splice's intake - * ledger buys back ONLY the ladder-base rungs, the street chrome a customer - * outgrows, at half list, credits only, so climbing a slot ladder feels like - * climbing and never becomes an arbitrage loop (you can't refund vouchers, and - * a removed piece resold at 50% is always a loss). + * ledger buys back every piece he sells for credits (owner D7), at half list, + * credits only, so climbing a slot ladder feels like climbing and never becomes + * an arbitrage loop (voucher chrome is never bought back, and a removed piece + * resold at 50% is always a loss). * * Splice is not the only source any more. The roster also rides the zone loot * tables (modules/loot/themes/) at the tier that matches what he charges for @@ -145,7 +145,7 @@ /datum/shop_sku/ripperdoc/buster_rockets, ) buyback_types = list( - // The trade-in ladder: only the street rungs a customer outgrows. + // The trade-in ladder: every piece on the shelf priced in credits. /datum/shop_buyback/ripperdoc/nightshade, /datum/shop_buyback/ripperdoc/shock_coils, /datum/shop_buyback/ripperdoc/second_wind, @@ -181,6 +181,18 @@ /datum/shop_buyback/ripperdoc/longsight, /datum/shop_buyback/ripperdoc/hardweave, /datum/shop_buyback/ripperdoc/deck_street, + // The rest of the credit shelf (owner D7). + /datum/shop_buyback/ripperdoc/hopper, + /datum/shop_buyback/ripperdoc/coolant, + /datum/shop_buyback/ripperdoc/fixers, + /datum/shop_buyback/ripperdoc/rockjaw, + /datum/shop_buyback/ripperdoc/gecko, + /datum/shop_buyback/ripperdoc/hemoglass, + /datum/shop_buyback/ripperdoc/angler, + /datum/shop_buyback/ripperdoc/registry_uplink, + /datum/shop_buyback/ripperdoc/cargo_cavity, + /datum/shop_buyback/ripperdoc/gastro, + /datum/shop_buyback/ripperdoc/chromatic_dermis, ) trader_lines = list( TRADER_LINE_GREETING = list( @@ -547,7 +559,7 @@ // ========================================================================= // SPLICE'S INTAKE LEDGER: the trade-in ladder -// Half list, credits only, street rungs only. Removed chrome resold here is +// Half list, credits only, credit-priced chrome only. Removed chrome resold here is // always a loss (you can't refund vouchers, and 50% never beats buying up the // ladder), so this reads as "trade up" and never as an arbitrage loop. // @@ -611,3 +623,120 @@ pay_credits = 200 demand_min = 2 demand_max = 4 + +// ========================================================================= +// THE REST OF THE CREDIT SHELF (owner D7): every piece he sells for credits +// comes back at half list, credits only. Exact types, so a ladder rung above +// or below (Spring calves, Vaulter, Tinker's, Deepcore, Heat-sink) keeps its +// own entry or none. Ratchet's subtypes carry his wording. +// ========================================================================= + +/datum/shop_buyback/ripperdoc/hopper + name = "Hopper piston calves (pulled)" + desc = "Piston calves, still sprung." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/hopper + match_subtypes = FALSE + pay_credits = 1800 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/coolant + name = "Coolant Loop lattice (pulled)" + desc = "A drained coolant lattice." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/coolant + match_subtypes = FALSE + pay_credits = 1800 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/fixers + name = "Fixer's Fingers (pulled)" + desc = "A fingertip tool suite, pulled." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers + match_subtypes = FALSE + pay_credits = 900 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/rockjaw + name = "Rockjaw drill fist (pulled)" + desc = "A drill fist with plenty of bit left." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw + match_subtypes = FALSE + pay_credits = 600 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/gecko + name = "Gecko Grip palm pads (pulled)" + desc = "Palm pads, a bit worn." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/gecko + match_subtypes = FALSE + pay_credits = 450 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/hemoglass + name = "Hemoglass filter (pulled)" + desc = "A blood filter, flushed." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/hemoglass + match_subtypes = FALSE + pay_credits = 600 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/angler + name = "Harpoon Spool (pulled)" + desc = "A wrist spool and its line." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/arm/toolkit/cyberware/angler + match_subtypes = FALSE + pay_credits = 600 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/registry_uplink + name = "Registry uplink (pulled)" + desc = "A registry uplink, pulled." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/registry_uplink + match_subtypes = FALSE + pay_credits = 600 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/cargo_cavity + name = "Cargo Cavity (pulled)" + desc = "An empty rib compartment." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/cargo_cavity + match_subtypes = FALSE + pay_credits = 500 + demand_min = 1 + demand_max = 2 + +/datum/shop_buyback/ripperdoc/gastro + name = "Gastro Reactor (pulled)" + desc = "A used stomach liner." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/gastro + match_subtypes = FALSE + pay_credits = 200 + demand_min = 2 + demand_max = 3 + +/datum/shop_buyback/ripperdoc/chromatic_dermis + name = "Chromatic Dermis ink suite (pulled)" + desc = "A sheet of circuit ink, peeled." + category = "Trade-In" + item_path = /obj/item/organ/cyberimp/cyberware/chromatic_dermis + match_subtypes = FALSE + pay_credits = 200 + demand_min = 2 + demand_max = 3 diff --git a/voidcrew/modules/cyberware/street_doc_catalog.dm b/voidcrew/modules/cyberware/street_doc_catalog.dm index 87dc9c8e7ea..2839bc1861c 100644 --- a/voidcrew/modules/cyberware/street_doc_catalog.dm +++ b/voidcrew/modules/cyberware/street_doc_catalog.dm @@ -15,7 +15,8 @@ * exists, so a parallel set of street-doc SKU types would need its own * entries there for no gain. * - * His intake ledger takes the same street rungs as Splice's at the same pay + * His intake ledger takes everything Splice's does at the same pay, even the + * credit chrome only Splice sells * (subtypes of Splice's buybacks, with his own wording). Like Splice's, and * unlike every other ledger, it takes chrome bought at a counter and pulled * again (buys_shop_stock on /datum/shop_buyback/ripperdoc, ripperdoc_catalog.dm). @@ -141,6 +142,19 @@ /datum/shop_buyback/ripperdoc/hardweave/street_doc, /datum/shop_buyback/ripperdoc/deck_street/street_doc, // CYBERWARE LINES: T2 BUYBACKS END + // The rest of Splice's credit shelf, which Ratchet buys back even where he + // doesn't sell it (owner D7). + /datum/shop_buyback/ripperdoc/hopper/street_doc, + /datum/shop_buyback/ripperdoc/coolant/street_doc, + /datum/shop_buyback/ripperdoc/fixers/street_doc, + /datum/shop_buyback/ripperdoc/rockjaw/street_doc, + /datum/shop_buyback/ripperdoc/gecko/street_doc, + /datum/shop_buyback/ripperdoc/hemoglass/street_doc, + /datum/shop_buyback/ripperdoc/angler/street_doc, + /datum/shop_buyback/ripperdoc/registry_uplink/street_doc, + /datum/shop_buyback/ripperdoc/cargo_cavity/street_doc, + /datum/shop_buyback/ripperdoc/gastro/street_doc, + /datum/shop_buyback/ripperdoc/chromatic_dermis/street_doc, ) trader_lines = list( TRADER_LINE_GREETING = list( @@ -221,6 +235,39 @@ /datum/shop_buyback/ripperdoc/cascade_street/street_doc desc = "A used spinal rig, held together with tape." +/datum/shop_buyback/ripperdoc/hopper/street_doc + desc = "Used piston calves." + +/datum/shop_buyback/ripperdoc/coolant/street_doc + desc = "A coolant lattice, drained." + +/datum/shop_buyback/ripperdoc/fixers/street_doc + desc = "Used fingertip tools." + +/datum/shop_buyback/ripperdoc/rockjaw/street_doc + desc = "A used drill fist." + +/datum/shop_buyback/ripperdoc/gecko/street_doc + desc = "Worn palm pads." + +/datum/shop_buyback/ripperdoc/hemoglass/street_doc + desc = "A used blood filter." + +/datum/shop_buyback/ripperdoc/angler/street_doc + desc = "A wrist harpoon, line and all." + +/datum/shop_buyback/ripperdoc/registry_uplink/street_doc + desc = "A used registry uplink." + +/datum/shop_buyback/ripperdoc/cargo_cavity/street_doc + desc = "An empty rib pocket." + +/datum/shop_buyback/ripperdoc/gastro/street_doc + desc = "A stomach liner, scrubbed. Mostly." + +/datum/shop_buyback/ripperdoc/chromatic_dermis/street_doc + desc = "Old circuit ink." + // ========================================================================= // THE NPC // ========================================================================= diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index f2f534ded68..d136d772071 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -913,7 +913,7 @@ return FALSE // The landing crushes whoever is under it, so it is not for a pacifist. if(HAS_TRAIT(jumper, TRAIT_PACIFISM)) - jumper.balloon_alert(jumper, "can't bring yourself to!") + to_chat(jumper, span_warning("You don't want to harm other living beings!")) return FALSE if(jumper.buckled || !isturf(jumper.loc)) jumper.balloon_alert(jumper, "can't launch from here!") diff --git a/voidcrew/modules/cyberware/ware_line_berserk.dm b/voidcrew/modules/cyberware/ware_line_berserk.dm index 94af4766ef3..6a1e3f6ebef 100644 --- a/voidcrew/modules/cyberware/ware_line_berserk.dm +++ b/voidcrew/modules/cyberware/ware_line_berserk.dm @@ -8,13 +8,17 @@ * * | | window | damage | punch | stun immune | roar | crash | cooldown | load | * |---|---|---|---|---|---|---|---|---| - * | street | 8 s | x0.8 | +3 | no | none | 6 s | 120 s | 5 | + * | street | 8 s | x0.8 | +3 | no | none | 6 s | 90 s | 4 | * | milspec | 10 s | x0.7 | +5 | yes | 4 tiles | 5 s | 105 s | 8 | * | Redline | 12 s | x0.6 | +8 | yes | 5 tiles | 5 s | 90 s | 10 | * * The chest speaker announcement stays on every model: it is the telegraph. * The street core has no roar to follow it, so it announces and staggers * nobody. Crash stamina stays at the Redline's 100 on all three. + * + * The street core cycles as fast as the Redline itself (final balance pass, + * review-balance change 5): at 120 s it was the worst T2 OS pick. What keeps it + * the bottom rung is the short, soft window, no stun immunity and no roar. */ /// Street: bench-built, sold for credits. Burns for less time, softens less and @@ -23,7 +27,7 @@ name = "\improper Hothead core" desc = "A combat governor soldered together on a back-room bench out of parts from two others. The chest speaker rattles when it runs." icon_state = "redline_street" - chrome_load = 5 + chrome_load = 4 tier = CYBERWARE_TIER_2 aug_overlay = "redline_street" burn_window = 8 SECONDS @@ -32,7 +36,7 @@ burn_stun_immune = FALSE burn_roar_range = 0 burn_crash = 6 SECONDS - burn_cooldown = 120 SECONDS + burn_cooldown = 90 SECONDS burn_action_desc = "Eight seconds of damage resistance and harder fists, announced by the chest speaker. Ends in a collapse." burn_alert_desc = "The safeties are loosened, not gone." diff --git a/voidcrew/modules/cyberware/ware_line_camo.dm b/voidcrew/modules/cyberware/ware_line_camo.dm index 4b3530bd419..ae4ae33e709 100644 --- a/voidcrew/modules/cyberware/ware_line_camo.dm +++ b/voidcrew/modules/cyberware/ware_line_camo.dm @@ -21,13 +21,14 @@ name = "\improper Flickerskin weave" desc = "A budget subdermal weave of refraction cells, mostly salvage. Switched on, you go blurry at the edges, which is not the same as gone." icon_state = "ghostskin_street" - chrome_load = 3 + chrome_load = 2 tier = CYBERWARE_TIER_2 actions_types = list(/datum/action/cooldown/cyberware/ghostskin_cloak/street) - // Roughly half faded, never a heat-shimmer. Moving flares it well above that. - field_alpha = 110 + // Mostly faded, but never the full weave's heat-shimmer. Moving flares it + // well above that. + field_alpha = 60 ripple_alpha = 170 - field_duration = 5 SECONDS + field_duration = 7 SECONDS hides_from_mobs = FALSE /obj/item/organ/cyberimp/cyberware/ghostskin/street/engage_feedback(mob/living/carbon/wearer) @@ -38,7 +39,7 @@ ) /datum/action/cooldown/cyberware/ghostskin_cloak/street - desc = "Five seconds of patchy optical camo." + desc = "Seven seconds of patchy optical camo." cooldown_time = 45 SECONDS // ========================================================================= diff --git a/voidcrew/modules/cyberware/ware_line_deck.dm b/voidcrew/modules/cyberware/ware_line_deck.dm index da79df272b3..4cdcffcd478 100644 --- a/voidcrew/modules/cyberware/ware_line_deck.dm +++ b/voidcrew/modules/cyberware/ware_line_deck.dm @@ -13,7 +13,7 @@ * * Model tier load range upload cooldowns adds * Junker T1 2 5 2 s x1.5 Bolts, Burst the Lights, Ping - * Street T2 4 6 1.5 s x1.5 Kill the Power + * Street T2 3 6 1.5 s x1.5 Kill the Power * Facilities T3 6 7 1 s x1 Cutout, Electrify, Silence, Reboot Turret * Breacher T3 6 7 1 s x1 Overload, Feedback Surge, Glitch * Blackbox T4 9 8 0.5 s x1 both T3 sets, and Arc Flash @@ -34,10 +34,16 @@ * shows it. * - Outpost property and outpost prison doors (cells and staff doors) refuse * every hack outright, and so do a prison wing's APC and lights (Kill the - * Power, Burst the Lights), so a deck never springs a prisoner. Overload refuses any + * Power, Burst the Lights), and no room hack runs inside a prison wing at all, + * so a deck never springs a prisoner. Overload refuses any * machine on an outpost, trader or player (owner, D12: decks never blow up * outpost machines). A person hacked at a trader outpost counts as an attack * on them there, the same as a punch, and Arc Flash does not run there at all. + * Silence jamming the concourse, or Electrify running a docked ship's door live, + * is a strike against the hacker there too. Reboot Turret leaves a player + * outpost's prison turrets alone. + * - A pacifist won't run a hack that hurts a person (hurts_people on the hack: + * Feedback Surge, Cutout, Glitch, Arc Flash). * * Activation (owner, 11-08): using the deck, from its button, its chrome * wheel slice or Use chrome, arms targeting. The next click picks the target, @@ -108,7 +114,7 @@ name = "\improper Street deck" desc = "A cortex deck in a scuffed polymer shell, flashed with somebody else's maintenance codes." icon_state = "deck_street" - chrome_load = 4 + chrome_load = 3 tier = CYBERWARE_TIER_2 quickhacks = list( /datum/machine_quickhack/bolts, @@ -214,6 +220,11 @@ click_to_activate = TRUE /// Set while a menu or an upload is open, so neither can stack. var/busy = FALSE + /// When a busy flag stops counting. Cleared on removal too, so a runtime + /// partway through a hack can't leave the deck busy for the rest of the round. + var/busy_until = 0 + /// How long a busy flag holds at most. Longer than any menu plus upload. + var/busy_timeout = 1 MINUTES /// The trace line drawn while an upload runs. var/datum/beam/trace @@ -230,8 +241,20 @@ if(removed_from) UnregisterSignal(removed_from, COMSIG_CYBERWARE_ZOOM_CLICK) QDEL_NULL(trace) + busy = FALSE + busy_until = 0 return ..() +/// Marks a menu or upload as open. +/datum/action/cooldown/cyberware/deck/proc/mark_busy() + busy = TRUE + busy_until = world.time + busy_timeout + +/// Whether a menu or upload is still open. A flag older than busy_timeout is +/// stale (its call chain died) and no longer counts. +/datum/action/cooldown/cyberware/deck/proc/is_busy() + return busy && world.time < busy_until + /// The deck this button belongs to, or null. /datum/action/cooldown/cyberware/deck/proc/get_deck() var/obj/item/organ/cyberimp/cyberware/deck/deck = organ @@ -246,7 +269,7 @@ // its real target through on_zoom_click() instead. if(istype(target, /atom/movable/screen)) return FALSE - if(busy) + if(is_busy()) bearer.balloon_alert(bearer, "deck busy!") return FALSE if(target == bearer) @@ -257,7 +280,7 @@ if(!length(available)) target.balloon_alert(bearer, "nothing to talk to!") return FALSE - busy = TRUE + mark_busy() INVOKE_ASYNC(src, PROC_REF(run_quickhack), bearer, target, available) return TRUE @@ -267,6 +290,9 @@ if(bearer.is_blind()) bearer.balloon_alert(bearer, "can't see!") return FALSE + if(is_outpost_prison_area(get_area(bearer))) + bearer.balloon_alert(bearer, "hardened firmware!") + return FALSE var/list/available = list() for(var/datum/machine_masshack/hack as anything in deck.kit.applicable_masshacks(bearer, get_turf(bearer))) if(deck_allows_room(hack, bearer)) @@ -274,7 +300,7 @@ if(!length(available)) bearer.balloon_alert(bearer, "nothing here answers!") return FALSE - busy = TRUE + mark_busy() INVOKE_ASYNC(src, PROC_REF(run_room_hack), bearer, available) return TRUE @@ -326,15 +352,32 @@ return !get_outpost_from_atom(target) && !is_trader_outpost_protected(target) return TRUE -/// The same for room hacks: inside a trader outpost's protected ground, docked -/// ships included, a deck runs nothing that hurts the people around it. Arc -/// Flash's shocks come out of the machines and would never count as an attack -/// there, so it simply does not answer. +/// The same for room hacks: nothing runs inside a player outpost's prison wing +/// (no prison break by deck), and inside a trader outpost's protected ground, +/// docked ships included, a deck runs nothing that hurts the people around it. +/// Arc Flash's shocks come out of the machines and would never count as an +/// attack there, so it simply does not answer. /datum/action/cooldown/cyberware/deck/proc/deck_allows_room(datum/machine_masshack/hack, mob/living/carbon/bearer) + if(is_outpost_prison_area(get_area(bearer))) + return FALSE if(istype(hack, /datum/machine_masshack/ping)) return TRUE return !is_trader_outpost_protected(bearer) +/// A pacifist won't run a hack that hurts a person, and gets tg's own line for +/// it. Checked when the hack is picked and again when the upload lands. +/datum/action/cooldown/cyberware/deck/proc/pacifist_refuses(mob/living/carbon/bearer, hurts_people) + if(!hurts_people || !HAS_TRAIT(bearer, TRAIT_PACIFISM)) + return FALSE + to_chat(bearer, span_warning("You don't want to harm other living beings!")) + return TRUE + +/// Hacks that are a strike at a trader outpost even with no person as the target: +/// Silence jams everyone's radios around a floor tile, Electrify runs a docked +/// ship's door live. Outpost property itself never answers a deck at all. +/datum/action/cooldown/cyberware/deck/proc/strikes_at_trader_outpost(datum/machine_quickhack/hack) + return istype(hack, /datum/machine_quickhack/silence) || istype(hack, /datum/machine_quickhack/electrify) + /// The deck is still in this body, still this body's button, and usable. /datum/action/cooldown/cyberware/deck/proc/still_linked(mob/living/carbon/bearer) return !QDELETED(src) && !QDELETED(bearer) && owner == bearer && organ?.owner == bearer && IsAvailable(feedback = FALSE) @@ -375,6 +418,8 @@ if(!chosen?.valid_for(target, bearer) || !deck_allows(chosen, target)) target.balloon_alert(bearer, "it stopped answering!") return null + if(pacifist_refuses(bearer, chosen.hurts_people)) + return null return chosen /// Keep the sleeping input separate from the checks around it. @@ -407,6 +452,8 @@ if(!hack.valid_for(target, bearer) || !deck_allows(hack, target)) target.balloon_alert(bearer, "it stopped answering!") return FALSE + if(pacifist_refuses(bearer, hack.hurts_people)) + return FALSE if(!hack.execute(target, bearer)) return FALSE if(isliving(target)) @@ -418,6 +465,9 @@ drop_bearer_camo(bearer) else bearer.log_message("used the [hack.name] quickhack on [target] ([target.type]) with [deck].", LOG_ATTACK) + if(strikes_at_trader_outpost(hack) && is_trader_outpost_protected(target)) + var/obj/structure/overmap/trader_outpost/outpost = get_trader_outpost_for_turf(get_turf(target)) + outpost?.register_aggression(bearer) cyberware_ink_pulse(bearer, CYBERWARE_INK_HARD) StartCooldown(kit.hack_cooldown(hack)) return TRUE @@ -451,7 +501,10 @@ var/choice = choose_room_hack(bearer, options) if(!choice || !room_holds(bearer)) return null - return by_name[choice] + var/datum/machine_masshack/chosen = by_name[choice] + if(pacifist_refuses(bearer, chosen?.hurts_people)) + return null + return chosen /datum/action/cooldown/cyberware/deck/proc/choose_room_hack(mob/living/carbon/bearer, list/options) return show_radial_menu(bearer, bearer, options, custom_check = CALLBACK(src, PROC_REF(room_holds), bearer), tooltips = TRUE, autopick_single_option = FALSE) @@ -475,6 +528,8 @@ if(isnull(here) || !hack.available(bearer, here) || !deck_allows_room(hack, bearer)) bearer.balloon_alert(bearer, "nothing here answers!") return FALSE + if(pacifist_refuses(bearer, hack.hurts_people)) + return FALSE if(!hack.execute(bearer, here)) return FALSE bearer.log_message("used the [hack.name] room hack with [deck].", LOG_ATTACK) @@ -559,8 +614,9 @@ * seconds, then lets it come back as it was. The Icepick Jack's suppress is * the stronger, contact-range version of this; it stays that way. * - * Same refusals as the Icepick: outpost security firmware never answers. A - * turret on a player's ship trips that crew's intrusion alarm. + * Same refusals as the Icepick: outpost security firmware never answers, and + * neither does a turret in a player outpost's prison wing. A turret on a + * player's ship trips that crew's intrusion alarm. */ /datum/machine_quickhack/reboot_turret name = "Reboot Turret" @@ -581,7 +637,8 @@ /datum/machine_quickhack/reboot_turret/valid_for(atom/target, mob/living/user) var/atom/turret = resolve_turret(target) if(istype(turret, /obj/machinery/porta_turret)) - if(istype(turret, /obj/machinery/porta_turret/outpost)) + // Trader outpost turrets, and a player outpost's prison turrets: no prison break by deck. + if(istype(turret, /obj/machinery/porta_turret/outpost) || is_outpost_prison_area(get_area(turret))) return FALSE var/obj/machinery/porta_turret/porta = turret return porta.on && !(porta.machine_stat & BROKEN) @@ -605,9 +662,13 @@ return TRUE /** - * Glitch: one piece of the target's running chrome, picked at random, takes - * the ordinary cyberware EMP reboot. Chrome is balanced around EMP, so this is - * the hacker's answer to a chromed-up fighter, one piece at a time. + * Glitch: one piece of the target's running chrome takes the ordinary cyberware + * EMP reboot. Chrome is balanced around EMP, so this is the hacker's answer to a + * chromed-up fighter, one piece at a time. The pick is weighted by how much each + * piece weighs on the nervous system, its load plus any capacity it adds (a + * Governor carries no load but holds the whole build up), so the heavy hardware + * is what usually goes down. Chrome that weighs nothing, like Chromatic Dermis + * ink, is never picked, so it can't be stacked as decoys. */ /datum/machine_quickhack/glitch name = "Glitch" @@ -615,15 +676,18 @@ radial_icon = 'icons/mob/actions/actions_spells.dmi' radial_icon_state = "emp" cooldown = 60 SECONDS + hurts_people = TRUE -/// The target's chrome that is up and running right now. +/// The target's chrome that is up and running right now, each weighted for +/// pick_weight() by its load plus the capacity it adds. Weightless chrome is left out. /datum/machine_quickhack/glitch/proc/running_chrome(mob/living/carbon/victim) RETURN_TYPE(/list) . = list() for(var/obj/item/organ/ware as anything in get_installed_cyberware(victim)) var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) - if(chrome?.is_running()) - . += ware + var/weight = chrome ? chrome.chrome_load + chrome.capacity_bonus : 0 + if(weight > 0 && chrome.is_running()) + .[ware] = weight /datum/machine_quickhack/glitch/valid_for(atom/target, mob/living/user) if(!iscarbon(target) || target == user) @@ -636,7 +700,7 @@ var/list/running = running_chrome(victim) if(!length(running)) return FALSE - var/obj/item/organ/ware = pick(running) + var/obj/item/organ/ware = pick_weight(running) var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) chrome.start_emp_reboot(EMP_HEAVY) victim.balloon_alert(victim, "[ware.name] glitches out!") @@ -660,7 +724,7 @@ zone_permission = ZONE_PERMISSION_YELLOW category = "Street Chrome" item_path = /obj/item/organ/cyberimp/cyberware/deck/street - price_credits = 3500 + price_credits = 2400 /datum/shop_sku/ripperdoc/deck_control zone_permission = ZONE_PERMISSION_ADVANCED @@ -703,7 +767,7 @@ category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/deck/street match_subtypes = FALSE - pay_credits = 1750 + pay_credits = 1200 demand_min = 1 demand_max = 2 diff --git a/voidcrew/modules/cyberware/ware_line_mantis.dm b/voidcrew/modules/cyberware/ware_line_mantis.dm index 8415bf7edbf..1dc1beee7a3 100644 --- a/voidcrew/modules/cyberware/ware_line_mantis.dm +++ b/voidcrew/modules/cyberware/ware_line_mantis.dm @@ -58,6 +58,10 @@ light_color = LIGHT_COLOR_FIRE /// Fire stacks each landed cut puts on its target. var/fire_stacks_per_hit = 2 + /// Set by afterattack() during attack()'s parent call, which only a swing that + /// connected reaches. attack()'s own FALSE can't tell a cut from a pacifist's + /// pulled swing or a signal-skipped one. The Widowline does the same. + var/swing_landed = FALSE // The glow is only for a blade that is out. Stowed inside the housing it is dark. /obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch/equipped(mob/user, slot, initial = FALSE) @@ -69,14 +73,21 @@ set_light_on(FALSE) /obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch/attack(mob/living/target, mob/living/user, list/modifiers, list/attack_modifiers) + swing_landed = FALSE . = ..() - // A truthy return means the swing was blocked or cancelled outright, no cut - // landed, so nothing burns. Never the wielder, whoever is in the way. - if(. || QDELETED(target) || target == user) + var/landed = swing_landed + swing_landed = FALSE + // Only a cut that landed burns: not a blocked or cancelled swing, not a pulled + // one. Never the wielder, whoever is in the way. + if(. || !landed || QDELETED(target) || target == user) return if(cyberware_scorch_target(target, fire_stacks_per_hit)) log_combat(user, target, "set alight", src, "(scorch blade)") +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch/afterattack(atom/target, mob/user, list/modifiers, list/attack_modifiers) + . = ..() + swing_landed = TRUE + // ---- Shiv Blades (street) ------------------------------------------------ /** @@ -163,7 +174,7 @@ name = "Scorch Blades (pair)" desc = "A cased pair of forearm blades with heat sinks on the emitter rails." item_path = /obj/item/cyberware_pair_case/mantis_scorch - price_vouchers = 5 + price_vouchers = 4 stock_min = 1 stock_max = 1 convoys_per_unit = 3 diff --git a/voidcrew/modules/cyberware/ware_line_models_a.dm b/voidcrew/modules/cyberware/ware_line_models_a.dm index 19dcf615973..2960751f369 100644 --- a/voidcrew/modules/cyberware/ware_line_models_a.dm +++ b/voidcrew/modules/cyberware/ware_line_models_a.dm @@ -9,7 +9,7 @@ * model parent tier load price where * Spring calves Hopper pistons T1 2 1,800 cr yellow * Vaulter pistons Hopper pistons T3 4 3 vouchers advanced - * Brawler myomers Gorilla Arms T2 2 per arm 2,400 cr the pair yellow + * Brawler myomers Gorilla Arms T2 2 per arm 1,600 cr the pair yellow * Tinker's fingers Fixer's Fingers T1 1 700 cr public * Deepcore drill fist Rockjaw T2 3 2,400 cr public * Holdout housing Popup Ronin T2 3 2,400 cr yellow @@ -112,7 +112,7 @@ aug_overlay = "gorilla_street_right" punch_damage = 14 structure_damage = 20 - punch_cooldown_time = 6 SECONDS + punch_cooldown_time = 5 SECONDS smash_cooldown_time = 1.5 SECONDS hurl_range = 0 pries_doors = FALSE @@ -284,7 +284,7 @@ name = "Brawler Myomers (pair)" desc = "A cased pair of cheap myomer forearms." item_path = /obj/item/cyberware_pair_case/gorilla_arms/street - price_credits = 2400 + price_credits = 1600 stock_min = 1 stock_max = 2 fixed_price = TRUE @@ -336,7 +336,7 @@ category = "Trade-In" item_path = /obj/item/organ/cyberimp/cyberware/gorilla_arms/street match_subtypes = TRUE - pay_credits = 600 + pay_credits = 400 demand_min = 2 demand_max = 4 diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index 3f9711a5555..3682569daed 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -435,7 +435,7 @@ return FALSE var/mob/living/lunger = owner if(HAS_TRAIT(lunger, TRAIT_PACIFISM)) - lunger.balloon_alert(lunger, "can't bring yourself to!") + to_chat(lunger, span_warning("You don't want to harm other living beings!")) return FALSE if(!housing.blade_extended()) lunger.balloon_alert(lunger, "extend the blade first!") diff --git a/voidcrew/modules/cyberware/ware_military_body.dm b/voidcrew/modules/cyberware/ware_military_body.dm index 343a89a6156..a8ec03ccba0 100644 --- a/voidcrew/modules/cyberware/ware_military_body.dm +++ b/voidcrew/modules/cyberware/ware_military_body.dm @@ -86,6 +86,10 @@ // chain before the late one is ever sent. RegisterSignals(wearer, list(COMSIG_LIVING_EARLY_UNARMED_ATTACK, COMSIG_LIVING_UNARMED_ATTACK), PROC_REF(on_unarmed_attack)) RegisterSignal(wearer, COMSIG_ATOM_BULLET_ACT, PROC_REF(on_bullet_act)) + RegisterSignal(wearer, COMSIG_LIVING_ELECTROCUTE_ACT, PROC_REF(on_shocked)) + // Being mauled by an animal, a mob, a mech or a xeno is being hit too: the + // field is what keeps those mobs off you (cyberware_stealth.dm), so a bite at + // point-blank range has to drop it. RegisterSignals(wearer, list( COMSIG_MOB_ITEM_ATTACK, COMSIG_ATOM_ATTACKBY, @@ -93,6 +97,10 @@ COMSIG_ATOM_HITBY, COMSIG_ATOM_HULK_ATTACK, COMSIG_ATOM_ATTACK_PAW, + COMSIG_ATOM_ATTACK_ANIMAL, + COMSIG_ATOM_ATTACK_BASIC_MOB, + COMSIG_ATOM_ATTACK_MECH, + COMSIG_MOB_ATTACK_ALIEN, COMSIG_CARBON_CUFF_ATTEMPTED, COMSIG_MOB_FIRED_GUN, COMSIG_MOB_THROW, @@ -136,6 +144,11 @@ COMSIG_ATOM_HITBY, COMSIG_ATOM_HULK_ATTACK, COMSIG_ATOM_ATTACK_PAW, + COMSIG_ATOM_ATTACK_ANIMAL, + COMSIG_ATOM_ATTACK_BASIC_MOB, + COMSIG_ATOM_ATTACK_MECH, + COMSIG_MOB_ATTACK_ALIEN, + COMSIG_LIVING_ELECTROCUTE_ACT, COMSIG_CARBON_CUFF_ATTEMPTED, COMSIG_MOB_FIRED_GUN, COMSIG_MOB_THROW, @@ -164,6 +177,15 @@ return break_camo(owner, reason = "field contact") +/// Signal proc for [COMSIG_LIVING_ELECTROCUTE_ACT]: a real jolt (a live door, +/// a deck's Feedback Surge or Arc Flash) drops the field. Hallucinated shocks and +/// ones the gloves fully insulate don't. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/on_shocked(datum/source, shock_damage, shock_source, siemens_coeff, flags) + SIGNAL_HANDLER + if((flags & SHOCK_ILLUSION) || shock_damage <= 0 || siemens_coeff <= 0) + return + break_camo(owner, reason = "field shorted") + /// Signal proc for [COMSIG_ATOM_BULLET_ACT]: hostile rounds pop the field. /obj/item/organ/cyberimp/cyberware/ghostskin/proc/on_bullet_act(datum/source, obj/projectile/projectile) SIGNAL_HANDLER diff --git a/voidcrew/modules/cyberware/ware_pro_utility.dm b/voidcrew/modules/cyberware/ware_pro_utility.dm index bce756c0260..5bc0bbe3499 100644 --- a/voidcrew/modules/cyberware/ware_pro_utility.dm +++ b/voidcrew/modules/cyberware/ware_pro_utility.dm @@ -554,8 +554,12 @@ * * Circulating coolant woven through the torso: heat exposure damage is * trait-gated off, burns hit at half strength, and open flame on you gets - * starved down fast. Deliberately NOT fireproof, enough flamer fuel still - * wins, which keeps the buyable MOD flamethrower an honest counter. Sits + * starved down fast. In practice that makes a human bearer fireproof: fire + * stacks only hurt a human through body heat (fire_stacks.dm harm_human()), + * and TRAIT_RESISTHEAT gates that damage off (_species.dm), so a burning + * bearer loses clothes, not skin. The cheap Heat-sink lining + * (ware_models_b.dm) carries the same trait. Burn damage dealt outright + * (a laser, say) still lands, halved here. Sits * on the seal ladder above Second Wind: pick your climate. */ /obj/item/organ/cyberimp/cyberware/coolant diff --git a/voidcrew/modules/loot/themes/syndicate.dm b/voidcrew/modules/loot/themes/syndicate.dm index 24ef1326047..650749ae4d2 100644 --- a/voidcrew/modules/loot/themes/syndicate.dm +++ b/voidcrew/modules/loot/themes/syndicate.dm @@ -11,7 +11,9 @@ // would carry (cloak weave, wire, reflex shunt) sit at the bottom of prime. // The camo and reflex lines follow the same split: the street weave and the // taped reflex rig at uncommon, the service lattice in prime, and the live -// wire beside the plain one in the red melee extras. +// wire beside the plain one in the red melee extras. The chrome added with the +// cyberware lines sits at about half the weight of the older entries, so the +// syndicate tables stay mostly guns, gear and contraband. // ========================================================================= /datum/loot_theme/syndicate @@ -69,7 +71,7 @@ /obj/item/grenade/empgrenade = 5, /obj/item/storage/medkit/tactical = 4, // a throat mic nobody nearby hears - /obj/item/organ/cyberimp/cyberware/subvocal_mic = 4, + /obj/item/organ/cyberimp/cyberware/subvocal_mic = 2, ) loot_uncommon = list( /obj/item/grenade/c4 = 6, @@ -83,13 +85,13 @@ /obj/item/organ/cyberimp/cyberware/cargo_cavity = 4, /obj/item/organ/cyberimp/arm/toolkit/cyberware/icepick = 3, // knockoff operative chrome: a patchy camo weave and a taped reflex rig - /obj/item/organ/cyberimp/cyberware/ghostskin/street = 3, - /obj/item/organ/cyberimp/cyberware/cascade/street = 2, + /obj/item/organ/cyberimp/cyberware/ghostskin/street = 2, + /obj/item/organ/cyberimp/cyberware/cascade/street = 1, // street operative chrome: a twitchy reflex shunt, an adrenal pump and // a deck flashed with somebody else's codes - /obj/item/organ/cyberimp/cyberware/slipwire/street = 3, - /obj/item/organ/cyberimp/cyberware/adrenal_pump = 3, - /obj/item/organ/cyberimp/cyberware/deck/street = 2, + /obj/item/organ/cyberimp/cyberware/slipwire/street = 2, + /obj/item/organ/cyberimp/cyberware/adrenal_pump = 2, + /obj/item/organ/cyberimp/cyberware/deck/street = 1, ) loot_prime = list( /obj/item/pen/sleepy = 4, @@ -98,10 +100,10 @@ // Operative chrome: the reflex shunt and refraction weave. /obj/item/organ/cyberimp/cyberware/slipwire = 3, /obj/item/organ/cyberimp/cyberware/ghostskin = 2, - /obj/item/organ/cyberimp/cyberware/cascade/milspec = 2, + /obj/item/organ/cyberimp/cyberware/cascade/milspec = 1, // the boarding team's shunt and deck - /obj/item/organ/cyberimp/cyberware/slipwire/milspec = 2, - /obj/item/organ/cyberimp/cyberware/deck/assault = 2, + /obj/item/organ/cyberimp/cyberware/slipwire/milspec = 1, + /obj/item/organ/cyberimp/cyberware/deck/assault = 1, ) // One-of-a-kind authored prizes, drawn as the fourth tier from any // band (weight = how shallow the item used to sit: 3 was reachable diff --git a/voidcrew/modules/quickhacks/masshacks.dm b/voidcrew/modules/quickhacks/masshacks.dm index f41b8d3b493..3fdc37b8d95 100644 --- a/voidcrew/modules/quickhacks/masshacks.dm +++ b/voidcrew/modules/quickhacks/masshacks.dm @@ -69,6 +69,9 @@ var/radial_icon_state = "overload_machine" /// Cooldown this hack puts on the ability. var/cooldown = 2 MINUTES + /// Hurts the people in the room. A cyberdeck won't run it for a pacifist + /// (cyberware/ware_line_deck.dm). + var/hurts_people = FALSE /// Is there anything in this room for this hack to do? Decides whether the slice is shown. /datum/machine_masshack/proc/available(mob/living/user, turf/center) @@ -216,6 +219,7 @@ GLOBAL_LIST_EMPTY(machine_masshacks) radial_icon = 'icons/mob/actions/actions_spells.dmi' radial_icon_state = "lightning" cooldown = 2 MINUTES + hurts_people = TRUE /datum/machine_masshack/arc_flash/available(mob/living/user, turf/center) if(!length(communion_arc_sources(center))) diff --git a/voidcrew/modules/quickhacks/quickhacks.dm b/voidcrew/modules/quickhacks/quickhacks.dm index d93203a7508..85cd96a154a 100644 --- a/voidcrew/modules/quickhacks/quickhacks.dm +++ b/voidcrew/modules/quickhacks/quickhacks.dm @@ -51,6 +51,9 @@ var/radial_icon_state = "overload_machine" /// Cooldown this hack puts on the whole ability. var/cooldown = 20 SECONDS + /// Hurts the person it lands on. A cyberdeck won't run it for a pacifist + /// (cyberware/ware_line_deck.dm). + var/hurts_people = FALSE /// Can this hack be run on this target at all? Decides whether the slice is even shown. /datum/machine_quickhack/proc/valid_for(atom/target, mob/living/user) @@ -334,6 +337,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) radial_icon = 'icons/obj/weapons/baton.dmi' radial_icon_state = "stunbaton_active" cooldown = 60 SECONDS + hurts_people = TRUE /datum/machine_quickhack/feedback_surge/valid_for(atom/target, mob/living/user) if(!isliving(target) || target == user) @@ -372,6 +376,7 @@ GLOBAL_LIST_EMPTY(machine_quickhacks) radial_icon = 'icons/obj/weapons/baton.dmi' radial_icon_state = "stunbaton_nocell" cooldown = 35 SECONDS + hurts_people = TRUE /datum/machine_quickhack/cutout/valid_for(atom/target, mob/living/user) if(!isliving(target) || target == user) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 8e7aa3ce541..16d3938ed40 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -74,6 +74,7 @@ #include "voidcrew_cyberware_anim.dm" #include "voidcrew_cyberware_camo.dm" #include "voidcrew_cyberware_deck.dm" +#include "voidcrew_cyberware_final.dm" #include "voidcrew_cyberware_kit.dm" #include "voidcrew_cyberware_mantis.dm" #include "voidcrew_cyberware_matrix.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm index d854bb2a5b0..45dd521787c 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware.dm @@ -395,21 +395,21 @@ /// cyber_camo trait (NPC targeting and chrome reads key on it, so it fools /// players only), while the full Ghostskin keeps both. They share the skin /// slot, so the upgrade evicts the street weave and drops its field. -/// Literals: trait is TRAIT_CYBER_CAMO ("cyber_camo"), alphas are 110 street -/// and 40 full, times are in deciseconds (50 = 5 s), tier 2 = CYBERWARE_TIER_2. +/// Literals: trait is TRAIT_CYBER_CAMO ("cyber_camo"), alphas are 60 street +/// and 40 full, times are in deciseconds (70 = 7 s), tier 2 = CYBERWARE_TIER_2. /datum/unit_test/voidcrew_cyberware_camo_line /datum/unit_test/voidcrew_cyberware_camo_line/Run() var/mob/living/carbon/human/lab_rat = allocate(/mob/living/carbon/human/consistent) var/obj/item/organ/cyberimp/cyberware/ghostskin/street/street = allocate(/obj/item/organ/cyberimp/cyberware/ghostskin/street) - TEST_ASSERT_EQUAL(street.chrome_load, 3, "The street camo weave's load drifted") + TEST_ASSERT_EQUAL(street.chrome_load, 2, "The street camo weave's load drifted") TEST_ASSERT_EQUAL(street.tier, 2, "The street camo weave left tier 2") - TEST_ASSERT_EQUAL(street.field_duration, 50, "The street camo weave's field duration drifted") + TEST_ASSERT_EQUAL(street.field_duration, 70, "The street camo weave's field duration drifted") TEST_ASSERT(!street.hides_from_mobs, "The street camo weave hides its bearer from mobs") TEST_ASSERT(street.Insert(lab_rat, special = TRUE), "Street weave staging insert was refused") street.engage_camo(lab_rat) TEST_ASSERT(street.camo_active, "The street weave's field never came up") - TEST_ASSERT_EQUAL(lab_rat.alpha, 110, "The street weave faded its bearer to the wrong alpha") + TEST_ASSERT_EQUAL(lab_rat.alpha, 60, "The street weave faded its bearer to the wrong alpha") TEST_ASSERT(!HAS_TRAIT(lab_rat, "cyber_camo"), "The street weave granted the mob-hiding camo trait") // The full weave is unchanged: trait, alpha 40, ten seconds. @@ -1358,7 +1358,8 @@ TEST_ASSERT_EQUAL(model.slot, core.slot, "[model_type] doesn't share the Redline's OS slot") TEST_ASSERT(model.chrome_load < core.chrome_load, "[model_type] doesn't cost less load than the Redline core") TEST_ASSERT(model.tier < core.tier, "[model_type] isn't a lower tier than the Redline core") - TEST_ASSERT(model.burn_window < core.burn_window && model.burn_cooldown > core.burn_cooldown, "[model_type] isn't a shorter, slower burn than the Redline core") + // The street core cycles as fast as the Redline (final balance pass); no model cycles faster. + TEST_ASSERT(model.burn_window < core.burn_window && model.burn_cooldown >= core.burn_cooldown, "[model_type] isn't a shorter burn, no faster, than the Redline core") TEST_ASSERT(model.icon_state != core.icon_state && model.aug_overlay != core.aug_overlay, "[model_type] reuses the Redline core's sprite states") var/obj/item/organ/cyberimp/cyberware/redline/street/street = allocate(/obj/item/organ/cyberimp/cyberware/redline/street) var/obj/item/organ/cyberimp/cyberware/redline/milspec/milspec = allocate(/obj/item/organ/cyberimp/cyberware/redline/milspec) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_final.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_final.dm new file mode 100644 index 00000000000..77db4357102 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_final.dm @@ -0,0 +1,308 @@ +/** + * # Cyberware: the final pass + * + * The fixes and owner-call defaults from the phase 2 code and balance reviews: + * an armed chrome ability keeps the click it refuses, Ghostskin drops on every + * kind of hit, the chrome wheel's zoom and stale chips, the deck's busy flag, + * prison wing and pacifist refusals, its trader outpost strikes and load-weighted + * Glitch, the full credit trade-in shelf (owner D7), and pirate bosses seeing + * through camo. + * + * NOTE: the CI build (CIBUILDING) sits at a DM compiler limit on unique + * identifiers. A new local or type name anywhere can fail the compile with + * ": invalid variable" on a var/static in some unrelated file. So these + * tests reuse names the codebase already has, and live under one parent. + * + * Unit-test files compile before voidcrew/_DEFINES/, so fork defines are + * literals here: "cyber_camo" is TRAIT_CYBER_CAMO. + */ +/datum/unit_test/voidcrew_cyberware_final + abstract_type = /datum/unit_test/voidcrew_cyberware_final + +/// The deck test's prompt (voidcrew_cyberware_deck.dm) answers room hack menus too. +/datum/action/cooldown/cyberware/deck/unit_test_prompt/choose_room_hack(mob/living/carbon/bearer, list/options) + return (pick in options) ? pick : null + +/// A refused click on an armed chrome ability is the chrome's: it never falls +/// through to the held gun, and the ability stays armed. One that went dark +/// while armed takes that click and disarms. +/datum/unit_test/voidcrew_cyberware_final/click/Run() + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/obj/item/organ/eyes/robotic/cyberware/nightshade/optics = allocate(/obj/item/organ/eyes/robotic/cyberware/nightshade) + TEST_ASSERT(optics.Insert(user, special = TRUE), "Nightshade staging insert was refused") + var/datum/action/cooldown/cyberware/chrome_read/action = locate() in optics.actions + TEST_ASSERT_NOTNULL(action, "The optics granted no Chrome Read") + var/obj/item/gun/ballistic/automatic/pistol/gun = allocate(/obj/item/gun/ballistic/automatic/pistol) + TEST_ASSERT(user.put_in_active_hand(gun), "Test fixture: the reader couldn't hold the pistol") + var/ammo = gun.get_ammo() + TEST_ASSERT(ammo > 0, "Test fixture: the pistol spawned empty") + var/turf/target = get_step(get_step(run_loc_floor_bottom_left, EAST), EAST) + + action.set_click_ability(user) + TEST_ASSERT_EQUAL(user.click_intercept, action, "Test fixture: Chrome Read did not arm") + // A floor tile has no chrome to read, so the read refuses it. + TEST_ASSERT(user.check_click_intercept("left=1", target), "A refused chrome click fell through to an ordinary click") + user.next_click = 0 + user.next_move = 0 + user.ClickOn(target, "left=1") + TEST_ASSERT_EQUAL(gun.get_ammo(), ammo, "A refused chrome click fired the held gun") + TEST_ASSERT_EQUAL(user.click_intercept, action, "A refused chrome click disarmed the ability") + + // Chrome that went dark while armed takes the click once and disarms. + var/datum/component/cyberware/chrome = optics.GetComponent(/datum/component/cyberware) + chrome.start_emp_reboot(EMP_HEAVY) + TEST_ASSERT(!chrome.is_running(), "Test fixture: the optics stayed up through a reboot") + TEST_ASSERT(user.check_click_intercept("left=1", target), "An armed ability that went dark let the click through") + TEST_ASSERT(user.click_intercept != action, "An armed ability that went dark stayed armed") + TEST_ASSERT(!user.check_click_intercept("left=1", target), "A disarmed ability still took a click") + +/// Being mauled by an animal, a mob, a mech or a xeno drops the Ghostskin field, +/// and so does a real shock. A hallucinated or fully insulated one doesn't. And +/// every faction's pirate boss sees through camo, the NT patrol's included. +/datum/unit_test/voidcrew_cyberware_final/camo/Run() + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/obj/item/organ/cyberimp/cyberware/ghostskin/weave = allocate(/obj/item/organ/cyberimp/cyberware/ghostskin) + TEST_ASSERT(weave.Insert(user, special = TRUE), "Ghostskin staging insert was refused") + var/mob/living/basic/mouse/mouse = allocate(/mob/living/basic/mouse, get_step(run_loc_floor_bottom_left, EAST)) + + for(var/signal in list(COMSIG_ATOM_ATTACK_ANIMAL, COMSIG_ATOM_ATTACK_BASIC_MOB, COMSIG_ATOM_ATTACK_MECH, COMSIG_MOB_ATTACK_ALIEN)) + weave.engage_camo(user) + TEST_ASSERT(weave.camo_active, "Test fixture: the field did not come up before [signal]") + SEND_SIGNAL(user, signal, mouse) + TEST_ASSERT(!weave.camo_active, "[signal] left the Ghostskin field up") + TEST_ASSERT(!HAS_TRAIT(user, "cyber_camo"), "[signal] dropped the field but left the camo trait") + + weave.engage_camo(user) + user.electrocute_act(10, mouse, 1, SHOCK_ILLUSION) + TEST_ASSERT(weave.camo_active, "A hallucinated shock dropped the field") + user.electrocute_act(10, mouse, 0) + TEST_ASSERT(weave.camo_active, "A shock the gloves fully insulated dropped the field") + user.electrocute_act(10, mouse, 1) + TEST_ASSERT(!weave.camo_active, "A real shock left the field up") + + TEST_ASSERT(GLOB.cyberware_camo_piercing_types[/mob/living/basic/trooper/pirate/faction/boss], "A pirate boss doesn't see through camo") + TEST_ASSERT(GLOB.cyberware_camo_piercing_types[/mob/living/basic/trooper/pirate/faction/boss/nt_patrol], "The NT patrol's boss doesn't see through camo") + +/// The wheel shows the zoom as on while zoomed, and a chip whose chrome was +/// cut out while the wheel was held open can't be picked. +/datum/unit_test/voidcrew_cyberware_final/wheel/Run() + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/eyes/robotic/cyberware/spyglass/optics = allocate(/obj/item/organ/eyes/robotic/cyberware/spyglass) + TEST_ASSERT(optics.Insert(user, special = TRUE), "Spyglass staging insert was refused") + var/datum/action/cooldown/cyberware/optics_zoom/zoom = locate() in optics.actions + TEST_ASSERT_NOTNULL(zoom, "The optics granted no zoom") + TEST_ASSERT(!chrome_wheel_ability_active(zoom, user), "The wheel shows an idle zoom as on") + optics.engage_zoom(user) + TEST_ASSERT(optics.is_zoomed(), "Test fixture: the zoom didn't engage") + TEST_ASSERT(chrome_wheel_ability_active(zoom, user), "The wheel doesn't show the zoom as on while zoomed") + optics.stop_zoom(user) + TEST_ASSERT(!chrome_wheel_ability_active(zoom, user), "The wheel still shows the zoom as on after it ended") + + var/obj/item/organ/cyberimp/cyberware/hopper/pistons = allocate(/obj/item/organ/cyberimp/cyberware/hopper) + TEST_ASSERT(pistons.Insert(user, special = TRUE), "Hopper staging insert was refused") + var/datum/action/cooldown/cyberware/hopper_leap/action = locate() in pistons.actions + TEST_ASSERT_NOTNULL(action, "The Hopper granted no leap") + var/datum/component/chrome_wheel/wheel = get_chrome_wheel(user) + TEST_ASSERT_NOTNULL(wheel, "A chromed body got no wheel") + wheel.build_wheel(get_chrome_wheel_actions(user)) + var/atom/movable/screen/chrome_wheel/chip/found + for(var/atom/movable/screen/chrome_wheel/chip/chip as anything in wheel.chips) + if(chip.ability_ref?.resolve() == action) + found = chip + TEST_ASSERT_NOTNULL(found, "The built wheel has no chip for the leap") + pistons.Remove(user, special = TRUE) + TEST_ASSERT(!wheel.select_chip(found, quiet = TRUE), "A chip was picked after its chrome came out") + TEST_ASSERT_NULL(wheel.get_selected_ability(), "The wheel kept a pick for chrome that came out") + wheel.close_wheel() + +/// The deck: its busy flag can't outlive the deck being pulled or its own +/// timeout; a pacifist won't run a hack that hurts a person; nothing runs in a +/// prison wing, and its turrets don't reboot; Glitch picks by chrome load. +/datum/unit_test/voidcrew_cyberware_final/deck/Run() + var/turf/location = run_loc_floor_bottom_left + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, location) + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent, get_step(location, EAST)) + var/obj/item/organ/cyberimp/cyberware/deck/flagship/deck = allocate(/obj/item/organ/cyberimp/cyberware/deck/flagship) + TEST_ASSERT(deck.Insert(user, special = TRUE), "Blackbox deck staging insert was refused") + var/datum/action/cooldown/cyberware/deck/unit_test_prompt/action = allocate(/datum/action/cooldown/cyberware/deck/unit_test_prompt, deck) + action.Grant(user) + var/datum/quickhack_kit/kit = deck.kit + + // Busy. + action.mark_busy() + TEST_ASSERT(action.is_busy(), "Test fixture: marking the deck busy didn't take") + action.busy_until = world.time - 1 + TEST_ASSERT(!action.is_busy(), "A stale busy flag still counted") + action.mark_busy() + action.Remove(user) + TEST_ASSERT(!action.is_busy(), "Pulling the deck left it busy") + action.Grant(user) + + // Pacifism. The victim carries a charged cell, so Feedback Surge would land. + var/obj/item/stock_parts/power_store/cell/high/cell = allocate(/obj/item/stock_parts/power_store/cell/high) + victim.put_in_active_hand(cell) + var/charge = cell.charge + var/obj/item/organ/cyberimp/cyberware/registry_uplink/organ = allocate(/obj/item/organ/cyberimp/cyberware/registry_uplink) + TEST_ASSERT(organ.Insert(victim, special = TRUE), "Registry uplink staging insert was refused") + ADD_TRAIT(user, TRAIT_PACIFISM, TRAIT_SOURCE_UNIT_TESTS) + for(var/path in list(/datum/machine_quickhack/feedback_surge, /datum/machine_quickhack/cutout, /datum/machine_quickhack/glitch)) + var/datum/machine_quickhack/entry = kit.find_quickhack(path) + TEST_ASSERT_NOTNULL(entry, "The Blackbox deck doesn't carry [path]") + TEST_ASSERT(entry.hurts_people, "[path] isn't marked as hurting people") + TEST_ASSERT(entry.valid_for(victim, user), "Test fixture: [path] wasn't offered on the victim") + action.pick = entry.name + TEST_ASSERT_NULL(action.pick_quickhack(user, victim, list(entry)), "A pacifist picked [path] on a person") + TEST_ASSERT(!action.upload_quickhack(user, victim, entry), "A pacifist ran [path] on a person") + TEST_ASSERT_EQUAL(cell.charge, charge, "A pacifist's refused hack drained the victim's cell") + var/datum/component/cyberware/chrome = organ.GetComponent(/datum/component/cyberware) + TEST_ASSERT(chrome.is_running(), "A pacifist's refused Glitch rebooted the victim's chrome") + TEST_ASSERT(action.next_use_time <= world.time, "A pacifist's refused hack charged a cooldown") + var/datum/machine_masshack/spell = kit.find_masshack(/datum/machine_masshack/arc_flash) + TEST_ASSERT_NOTNULL(spell, "The Blackbox deck doesn't carry Arc Flash") + TEST_ASSERT(spell.hurts_people, "Arc Flash isn't marked as hurting people") + action.pick = spell.name + TEST_ASSERT_NULL(action.pick_room_hack(user, list(spell)), "A pacifist picked Arc Flash") + // Hacks that touch only machines still run for a pacifist. + var/datum/machine_quickhack/option = kit.find_quickhack(/datum/machine_quickhack/bolts) + TEST_ASSERT(!option.hurts_people, "Bolts is marked as hurting people") + action.pick = option.name + var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, get_step(location, NORTH)) + TEST_ASSERT_EQUAL(action.pick_quickhack(user, door, list(option)), option, "A pacifist couldn't pick Bolts on a door") + REMOVE_TRAIT(user, TRAIT_PACIFISM, TRAIT_SOURCE_UNIT_TESTS) + action.pick = spell.name + TEST_ASSERT_EQUAL(action.pick_room_hack(user, list(spell)), spell, "Arc Flash can't be picked once the pacifism is gone") + // The same surge lands without the pacifism: the refusal above was the pacifism's. + option = kit.find_quickhack(/datum/machine_quickhack/feedback_surge) + TEST_ASSERT(action.upload_quickhack(user, victim, option), "Feedback Surge didn't land once the pacifism was gone") + TEST_ASSERT(cell.charge < charge, "Feedback Surge landed without draining the cell") + action.ResetCooldown() + + // Glitch picks by load: load-free chrome is never a target. + var/datum/machine_quickhack/glitch/effect = kit.find_quickhack(/datum/machine_quickhack/glitch) + var/mob/living/carbon/human/target = allocate(/mob/living/carbon/human/consistent, get_step(location, NORTHEAST)) + var/obj/item/organ/cyberimp/cyberware/chromatic_dermis/tattoo = allocate(/obj/item/organ/cyberimp/cyberware/chromatic_dermis) + TEST_ASSERT(tattoo.Insert(target, special = TRUE), "Chromatic Dermis staging insert was refused") + var/datum/component/cyberware/comp = tattoo.GetComponent(/datum/component/cyberware) + TEST_ASSERT_EQUAL(comp.chrome_load, 0, "Test fixture: the Chromatic Dermis carries load") + TEST_ASSERT(!effect.valid_for(target, user), "Glitch was offered on a body whose only chrome carries no load") + var/obj/item/organ/cyberimp/cyberware/gecko/implant = allocate(/obj/item/organ/cyberimp/cyberware/gecko) + TEST_ASSERT(implant.Insert(target, special = TRUE), "Gecko Grip staging insert was refused") + chrome = implant.GetComponent(/datum/component/cyberware) + var/list/weights = effect.running_chrome(target) + TEST_ASSERT_EQUAL(length(weights), 1, "Glitch counts load-free chrome as a target") + TEST_ASSERT_EQUAL(weights[implant], chrome.chrome_load, "Glitch doesn't weight a piece by its load") + TEST_ASSERT(effect.execute(target, user), "Glitch didn't land on a body with loaded chrome") + TEST_ASSERT(!chrome.is_running(), "Glitch didn't reboot the only loaded piece") + TEST_ASSERT(comp.is_running(), "Glitch rebooted load-free chrome") + // A Governor carries no load but holds the build up: it weighs its capacity. + var/obj/item/organ/cyberimp/cyberware/governor_delete/street/governor = allocate(/obj/item/organ/cyberimp/cyberware/governor_delete/street) + TEST_ASSERT(governor.Insert(target, special = TRUE), "Governor Patch staging insert was refused") + comp = governor.GetComponent(/datum/component/cyberware) + weights = effect.running_chrome(target) + TEST_ASSERT_EQUAL(weights[governor], comp.capacity_bonus, "Glitch doesn't weight a Governor by the capacity it adds") + + // Prison wing. The deck stands in one: no room hack at all, and the wing's + // turret doesn't reboot. Everything is read first and the tile put back + // before anything is judged, so a failure leaves no stray prison area. + var/datum/machine_masshack/signal = kit.find_masshack(/datum/machine_masshack/ping) + var/datum/machine_quickhack/reboot_turret/thing = kit.find_quickhack(/datum/machine_quickhack/reboot_turret) + var/obj/machinery/porta_turret/turret = allocate(/obj/machinery/porta_turret, get_step(get_step(location, EAST), EAST)) + var/result = thing.valid_for(turret, user) + var/success = action.deck_allows_room(signal, user) + var/area/old_area = get_area(location) + var/area/voidcrew/player_outpost/prison/new_area = new + location.change_area(old_area, new_area) + var/obj/machinery/porta_turret/machine = new(location) + var/value = thing.valid_for(machine, user) + var/found = action.deck_allows_room(signal, user) + var/valid = action.deck_allows_room(spell, user) + var/started = action.start_room_hack(user) + var/busy = action.is_busy() + location.change_area(new_area, old_area) + qdel(machine) + qdel(new_area) + TEST_ASSERT(result, "Reboot Turret wasn't offered on an ordinary turret") + TEST_ASSERT(success, "Ping is refused outside a prison wing") + TEST_ASSERT(!value, "Reboot Turret was offered on a prison wing turret") + TEST_ASSERT(!found && !valid, "A room hack is allowed inside a prison wing") + TEST_ASSERT(!started && !busy, "The deck started a room hack inside a prison wing") + +/// At a trader outpost, Silence on a floor tile and Electrify on a docked +/// ship's door are strikes against the hacker, like a punch. +/datum/unit_test/voidcrew_cyberware_final/outpost/Run() + var/turf/location = run_loc_floor_bottom_left + 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 = 4 + outpost.outpost_template.height = 4 + outpost.template_bottom_left = location + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, location) + user.mind_initialize() + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent, get_step(location, NORTH)) + victim.mind_initialize() + var/obj/item/organ/cyberimp/cyberware/deck/control/deck = allocate(/obj/item/organ/cyberimp/cyberware/deck/control) + TEST_ASSERT(deck.Insert(user, special = TRUE), "Facilities deck staging insert was refused") + var/datum/action/cooldown/cyberware/deck/action = locate() in deck.actions + TEST_ASSERT_NOTNULL(action, "The Facilities deck granted no button") + var/turf/target = get_step(location, EAST) + TEST_ASSERT(is_trader_outpost_protected(target), "Test fixture: the floor tile isn't trader outpost ground") + + var/datum/machine_quickhack/entry = deck.kit.find_quickhack(/datum/machine_quickhack/silence) + TEST_ASSERT(entry.valid_for(target, user), "Test fixture: Silence wasn't offered with a shopper in view") + var/result = action.upload_quickhack(user, target, entry) + // The jam would sit on the test room for 45 s and silence other tests' radios. + for(var/obj/item/jammer/machine_communion/jammer in target) + jammer.active = FALSE + GLOB.active_jammers -= jammer + TEST_ASSERT(result, "Silence did not land on a floor tile") + TEST_ASSERT_EQUAL(outpost.aggressor_strikes[user.mind], 1, "Jamming a trader outpost's floor took no strike") + action.ResetCooldown() + outpost.aggressor_strike_times.Cut() + + var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, get_step(target, NORTH)) + door.machine_stat = NONE + entry = deck.kit.find_quickhack(/datum/machine_quickhack/electrify) + TEST_ASSERT(action.upload_quickhack(user, door, entry), "Electrify did not land on a door") + TEST_ASSERT_EQUAL(outpost.aggressor_strikes[user.mind], 2, "Running a door live at a trader outpost took no strike") + TEST_ASSERT_NULL(outpost.aggressor_strikes[victim.mind], "A bystander took a strike for someone else's hack") + +/// Owner D7: every piece a ripperdoc sells for credits comes back at half list, +/// credits only, at both ripperdocs, even where Ratchet doesn't sell it. +/datum/unit_test/voidcrew_cyberware_final/ledger/Run() + var/datum/outpost_shop/vendor/ripperdoc/splice = allocate(/datum/outpost_shop/vendor/ripperdoc) + var/datum/outpost_shop/vendor/street_doc/ratchet = allocate(/datum/outpost_shop/vendor/street_doc) + // Each credit-only piece on Splice's shelf: what one piece of it costs. + var/list/prices = list() + for(var/datum/shop_sku/sku_type as anything in splice.sku_types) + if(initial(sku_type.price_credits) <= 0 || initial(sku_type.price_vouchers) > 0) + continue + var/item_path = initial(sku_type.item_path) + var/list/pieces = list() + if(ispath(item_path, /obj/item/organ)) + pieces += item_path + else if(ispath(item_path, /obj/item/cyberware_pair_case) || ispath(item_path, /obj/item/storage/case/cyberware)) + var/obj/item/sample = new item_path(null) + for(var/obj/item/organ/held in sample.contents) + pieces += held.type + for(var/atom/movable/packed as anything in sample.contents.Copy()) + qdel(packed) + qdel(sample) + for(var/piece in pieces) + prices[piece] = initial(sku_type.price_credits) / length(pieces) + TEST_ASSERT(length(prices) >= 40, "Splice's credit shelf looks empty ([length(prices)] pieces)") + + for(var/datum/outpost_shop/shop as anything in list(splice, ratchet)) + for(var/piece in prices) + var/datum/shop_buyback/found + for(var/datum/shop_buyback/buyback_type as anything in shop.buyback_types) + var/path = initial(buyback_type.item_path) + if(initial(buyback_type.match_subtypes) ? ispath(piece, path) : piece == path) + found = buyback_type + break + if(!found) + TEST_FAIL("[shop.type] has no trade-in for [piece], which Splice sells for credits") + continue + if(initial(found.pay_credits) * 2 != prices[piece]) + TEST_FAIL("[found] pays [initial(found.pay_credits)] cr for [piece], not half its [prices[piece]] cr list") + if(initial(found.pay_vouchers)) + TEST_FAIL("[found] pays vouchers") diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm index e0e3f9995f8..df1703dde96 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_mantis.dm @@ -64,6 +64,14 @@ TEST_ASSERT(victim.on_fire, "A scorch cut didn't light the target") TEST_ASSERT(!wielder.on_fire && wielder.fire_stacks <= 0, "A scorch cut put fire on the wielder") + // A swing that never lands burns nothing: a pacifist's pulled swing returns + // the same FALSE as a cut, so only a cut that reached afterattack() counts. + var/mob/living/carbon/human/spared = allocate(/mob/living/carbon/human/consistent) + ADD_TRAIT(wielder, TRAIT_PACIFISM, TRAIT_SOURCE_UNIT_TESTS) + blade.attack(spared, wielder, list(), list()) + REMOVE_TRAIT(wielder, TRAIT_PACIFISM, TRAIT_SOURCE_UNIT_TESTS) + TEST_ASSERT(!spared.on_fire && spared.fire_stacks <= 0, "A pacifist's pulled scorch swing set the target alight") + var/mob/living/carbon/human/corpse = allocate(/mob/living/carbon/human/consistent) corpse.stat = DEAD TEST_ASSERT(!cyberware_scorch_target(corpse, 4), "A corpse was set alight") diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm index 379d17f0aa8..dab6c78a2b8 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm @@ -325,7 +325,7 @@ var/list/shelf = list( list(/datum/shop_sku/ripperdoc/hopper_street, /obj/item/organ/cyberimp/cyberware/hopper/street, 1800, 0, 1, 1), list(/datum/shop_sku/ripperdoc/hopper_milspec, /obj/item/organ/cyberimp/cyberware/hopper/milspec, 0, 3, 2, 1), - list(/datum/shop_sku/ripperdoc/gorilla_street, /obj/item/cyberware_pair_case/gorilla_arms/street, 2400, 0, 1, 2), + list(/datum/shop_sku/ripperdoc/gorilla_street, /obj/item/cyberware_pair_case/gorilla_arms/street, 1600, 0, 1, 2), list(/datum/shop_sku/ripperdoc/fixers_street, /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street, 700, 0, 0, 1), list(/datum/shop_sku/ripperdoc/rockjaw_deepcore, /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore, 2400, 0, 0, 1), list(/datum/shop_sku/ripperdoc/ronin_holdout, /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout, 2400, 0, 1, 1), @@ -349,14 +349,14 @@ TEST_ASSERT(quote && quote["vouchers"] == 3 && !quote["credits"], "Vaulter pistons quote the wrong price") for(var/pair_half in list(/obj/item/organ/cyberimp/cyberware/gorilla_arms/street, /obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left)) quote = GLOB.cyberware_price_index[pair_half] - TEST_ASSERT(quote && quote["credits"] == 2400 && quote["paired"], "[pair_half] doesn't quote the pair's price") + TEST_ASSERT(quote && quote["credits"] == 1600 && quote["paired"], "[pair_half] doesn't quote the pair's price") quote = GLOB.cyberware_price_index[/obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout] TEST_ASSERT(quote && quote["credits"] == 2400, "The Holdout quotes the wrong price") // Ledger: half the shelf price of one piece, credits only, either side of a pair. var/list/ledgers = list( list(/datum/shop_buyback/ripperdoc/hopper_street, /datum/shop_buyback/ripperdoc/hopper_street/street_doc, /obj/item/organ/cyberimp/cyberware/hopper/street, 900), - list(/datum/shop_buyback/ripperdoc/gorilla_street, /datum/shop_buyback/ripperdoc/gorilla_street/street_doc, /obj/item/organ/cyberimp/cyberware/gorilla_arms/street, 600), + list(/datum/shop_buyback/ripperdoc/gorilla_street, /datum/shop_buyback/ripperdoc/gorilla_street/street_doc, /obj/item/organ/cyberimp/cyberware/gorilla_arms/street, 400), list(/datum/shop_buyback/ripperdoc/fixers_street, /datum/shop_buyback/ripperdoc/fixers_street/street_doc, /obj/item/organ/cyberimp/arm/toolkit/cyberware/fixers/street, 350), list(/datum/shop_buyback/ripperdoc/ronin_holdout, /datum/shop_buyback/ripperdoc/ronin_holdout/street_doc, /obj/item/organ/cyberimp/arm/toolkit/cyberware/ronin/holdout, 1200), list(/datum/shop_buyback/ripperdoc/rockjaw_deepcore, /datum/shop_buyback/ripperdoc/rockjaw_deepcore/street_doc, /obj/item/organ/cyberimp/arm/toolkit/cyberware/rockjaw/deepcore, 1200), diff --git a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm index 4abacd9bfe1..446f8e73bf6 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm @@ -347,7 +347,7 @@ list(/datum/shop_sku/ripperdoc/monowire, /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire, 1, 0, 3), list(/datum/shop_sku/ripperdoc/monowire_live, /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/live, 1, 0, 5), list(/datum/shop_sku/ripperdoc/mantis, /obj/item/cyberware_pair_case/mantis_blades, 1, 0, 4), - list(/datum/shop_sku/ripperdoc/mantis_scorch, /obj/item/cyberware_pair_case/mantis_scorch, 1, 0, 5), + list(/datum/shop_sku/ripperdoc/mantis_scorch, /obj/item/cyberware_pair_case/mantis_scorch, 1, 0, 4), list(/datum/shop_sku/ripperdoc/gorilla, /obj/item/cyberware_pair_case/gorilla_arms, 1, 0, 3), ), ) diff --git a/wiki/content/cyberware.md b/wiki/content/cyberware.md index b97aeeeeb68..25536031f5d 100644 --- a/wiki/content/cyberware.md +++ b/wiki/content/cyberware.md @@ -19,7 +19,7 @@ Outside the parlors, ordinary organ-manipulation surgery at any operating table ## The Back Bay -Ratchet, a loader mechanic who started putting chrome in people, works out of a storage bay off Quartermain's upper landing. He sells street chrome only, for credits, at Splice's prices, and the bay has a Chrome Cradle of its own that works the same way. He buys pulled street chrome back on the same terms as Splice. His shelf carries the newer street pieces too: the cheap optics, the junk and street decks, and the quiet passives below. +Ratchet, a loader mechanic who started putting chrome in people, works out of a storage bay off Quartermain's upper landing. He sells street chrome only, for credits, at Splice's prices, and the bay has a Chrome Cradle of its own that works the same way. He buys pulled chrome back on the same terms as Splice, even the pieces only Splice sells. His shelf carries the newer street pieces too: the cheap optics, the junk and street decks, and the quiet passives below. ## Neural load @@ -79,7 +79,7 @@ Prices are the same at both counters, stock is limited per round, and load is th | Slabskin plate | 3 | 6 | Heavy built-in armor in every damage type. Solid hits visibly spark off you. Everyone can see you are plated | | Mantis Blades (pair) | 4 | 3/arm | Deployable hardlight blades, plus a five-tile lunge that knocks the target down | | Bunker Buster housing | *loot only* | 5 | Two shaped micro-rockets: 60 damage, no splash, will never breach a hull. Reloads are sold at the parlor or printed with Experimental Ammunition | -| Ghostskin weave | 4 | 5 | Ten seconds of optical camo. NPCs will not target you, and Chrome Reads scatter. Attacking, throwing, taking a hit or an EMP drops it | +| Ghostskin weave | 4 | 5 | Ten seconds of optical camo. NPCs will not target you, and Chrome Reads scatter. Attacking, throwing, taking a hit or a shock, or an EMP drops it | | Lazarus node | 4 | 5 | The moment you drop into crit, it charges for a second and shocks you back to your feet at 30% health. Once per six minutes | ### Tier 4: Legend chrome (vouchers, one of each per round) @@ -97,18 +97,18 @@ Six of the big pieces come in cheaper models from other makers. Each takes the s | Piece | Price | Load | What it does | |---|---|---|---| -| Flickerskin weave | 2,400 cr | 3 | Five seconds of patchy camo. It fools people, but NPCs still see you and Chrome Reads still land | +| Flickerskin weave | 2,400 cr | 2 | Seven seconds of patchy camo. It fools people, but NPCs still see you and Chrome Reads still land | | Shiv Blades (pair) | 2,400 cr | 2/arm | Deployable hardlight blades, duller than the Mantis, with no lunge | | Governor Patch | 2,500 cr | 0 | +5 capacity. Occupies the chest OS slot | -| Hothead core | 3,000 cr | 5 | Eight seconds of 20% less brute and burn and +3 unarmed damage, then the crash. No stun immunity and no roar | +| Hothead core | 3,000 cr | 4 | Eight seconds of 20% less brute and burn and +3 unarmed damage, then the crash. No stun immunity and no roar | | Spliced reflex rig | 3,600 cr | 6 | Six seconds of quicker movement, melee cooldowns cut by a quarter and 15% projectile dodge while moving, then a long crash | | Governor Bypass | 3 vouchers | 0 | +10 capacity. Occupies the chest OS slot | | Service reflex lattice | 4 vouchers | 9 | Seven seconds of quicker movement, melee cooldowns cut by 40% and 25% projectile dodge while moving, then the crash | | Vanguard core | 4 vouchers | 8 | Ten seconds of stun immunity, 30% less brute and burn and +5 unarmed damage, opened by a roar that staggers people nearby, then the crash | -| Scorch Blades (pair) | 5 vouchers | 4/arm | Mantis Blades that set whoever they cut on fire. The lunge lights them harder | +| Scorch Blades (pair) | 4 vouchers | 4/arm | Mantis Blades that set whoever they cut on fire. The lunge lights them harder | | Widowline Arc | 5 vouchers | 6 | A Widowline that shocks on every hit, cuts robots and mechs much harder, and briefly knocks out turrets, consoles and cameras it strikes | -The **ammunition shelf** is the return-visit hook: Ronin magazines at 250 credits and Buster rocket pairs at 300, both at 4 [reputation](zone-reputation.md). Both ripperdocs buy back *removed* street chrome, street models included and even pieces they sold you, at about half list price. Chrome paid for in vouchers is never bought back. +The **ammunition shelf** is the return-visit hook: Ronin magazines at 250 credits and Buster rocket pairs at 300, both at 4 [reputation](zone-reputation.md). Both ripperdocs buy back any *removed* chrome that sells for credits, even pieces they sold you, at half list price. Chrome paid for in vouchers is never bought back. ### More street pieces @@ -132,7 +132,7 @@ Every pair of chrome eyes can zoom: focus on something far off and your view pul A cyberdeck sits in the back of the skull and talks to machines and other people's gear. Use it, pick something you can see, and the deck offers only what that target will take: doors, lights, a room's power, turrets, radios, a person's gear and the chrome inside them. Pick yourself and it offers what it can do to the room around you. A deck works through your zoom, too. -Every push draws a trace line to the target and makes their gear chirp, and it fails if you move, lose sight or get stunned. No deck opens a locked door, and outpost security, a prison wing's doors, power and lights included, ignores them all. The Junker deck and the Street deck sell for credits at both counters; the Facilities and Breacher decks, one for doors, radios and turrets and one for people and their gear, are vouchers from Splice; and the Blackbox deck, which does both, is legend chrome. +Every push draws a trace line to the target and makes their gear chirp, and it fails if you move, lose sight or get stunned. No deck opens a locked door, outpost security ignores them all, and inside a prison wing a deck does nothing. Jamming the radios or running a door live at a trader outpost counts as an attack there. The Junker deck and the Street deck sell for credits at both counters; the Facilities and Breacher decks, one for doors, radios and turrets and one for people and their gear, are vouchers from Splice; and the Blackbox deck, which does both, is legend chrome. ## Finding chrome in the wild From 7fe3fe50187504d9dc06d7519e639ceed03f7ac6 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:10:02 -0800 Subject: [PATCH 143/472] Weight Glitch by capacity as well as load, and fix three stale cyberware tests A Governor carries no load, so Glitch weights each piece by its load plus the capacity it adds; only weightless chrome like the ink is skipped. The deck's prison fixture test no longer stacks two lights on one tile, and the Brawler and Atlas tests follow the new punch clock and the cut examine line. --- voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm | 3 +++ voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm | 2 +- voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm index 39f6ba18121..7c4d53da48f 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm @@ -206,6 +206,9 @@ var/obj/machinery/light/open_light = allocate(/obj/machinery/light, wing_tile) var/open_apc_reach = action.can_reach(user, open_apc) var/open_light_reach = action.can_reach(user, open_light) + // The wing's fixtures go on the same tile, and tests scream about stacked lights. + qdel(open_apc) + qdel(open_light) var/area/voidcrew/player_outpost/prison/wing = new wing_tile.change_area(old_area, wing) var/obj/machinery/power/apc/wing_apc = new(wing_tile) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm index dab6c78a2b8..c238e7aaaa5 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm @@ -126,7 +126,7 @@ // The model. TEST_ASSERT_EQUAL(brawler_right.punch_damage, 14, "Brawler punch isn't 14") TEST_ASSERT_EQUAL(brawler_right.structure_damage, 20, "Brawler smash isn't 20") - TEST_ASSERT_EQUAL(brawler_right.punch_cooldown_time, 6 SECONDS, "Brawler punch clock isn't six seconds") + TEST_ASSERT_EQUAL(brawler_right.punch_cooldown_time, 5 SECONDS, "Brawler punch clock isn't five seconds") TEST_ASSERT_EQUAL(brawler_right.smash_cooldown_time, 1.5 SECONDS, "Brawler smash clock isn't a second and a half") TEST_ASSERT(brawler_right.hurl_range <= 0, "Brawler myomers can hurl a grabbed victim") TEST_ASSERT(!brawler_right.pries_doors, "Brawler myomers pry doors") diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm index 9787ce235d0..a6f07ffc9b1 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_b.dm @@ -90,7 +90,8 @@ TEST_ASSERT_EQUAL(atlas.throw_range_bonus, 2, "Atlas Frame's throw range changed") TEST_ASSERT_EQUAL(atlas.throw_speed_bonus, 1, "Atlas Frame's throw speed changed") TEST_ASSERT(atlas.resists_knockback, "Atlas Frame stopped resisting knockback") - TEST_ASSERT(atlas.frame_examine, "Atlas Frame lost its examine line") + // The text pass cut the examine line that explained the frame's rules. + TEST_ASSERT_NULL(atlas.frame_examine, "Atlas Frame's examine explains its rules again") TEST_ASSERT(TRAIT_NODISMEMBER in atlas.organ_traits, "Atlas Frame stopped holding limbs on") TEST_ASSERT("cyberware_unbreakable_bones" in atlas.organ_traits, "Atlas Frame stopped holding bones together") From 69e35016c6abab3bcae235f449bdc0e237727019 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:19:16 -0800 Subject: [PATCH 144/472] Brawler punch test follows the five second clock --- voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm index c238e7aaaa5..58e7bf97c82 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_models_a.dm @@ -180,7 +180,7 @@ var/hurt_before = victim.getBruteLoss() TEST_ASSERT_EQUAL(punch_arm.on_punch(puncher, victim, TRUE, null), COMPONENT_CANCEL_ATTACK_CHAIN, "The Brawler arm didn't take the punch") TEST_ASSERT_EQUAL(victim.getBruteLoss() - hurt_before, 14, "A Brawler punch didn't land 14") - TEST_ASSERT_EQUAL(COOLDOWN_TIMELEFT(punch_arm, slam_cooldown), 6 SECONDS, "A Brawler punch didn't start the six second clock") + TEST_ASSERT_EQUAL(COOLDOWN_TIMELEFT(punch_arm, slam_cooldown), 5 SECONDS, "A Brawler punch didn't start the five second clock") TEST_ASSERT_NOTEQUAL(punch_arm.on_punch(puncher, victim, TRUE, null), COMPONENT_CANCEL_ATTACK_CHAIN, "A Brawler arm punched again on its clock") /// Tinker's fingers: the Fixer's three tools and a weaker servo speed-up under From 97cde60f824184def854729a9b5056e1f8d0e9d1 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:43:46 -0800 Subject: [PATCH 145/472] Keep three test artifacts out of the chrome soak's findings The audit regrows organs after pulling chrome optics and lungs, the body snapshot skips the reference finder's stamp, and the stuck-EMP check only counts a reboot timer set past any EMP's downtime, so back-to-back pulses across two sweeps no longer read as an outage that never ends. --- .../modules/unit_tests/voidcrew_cyberware_soak.dm | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm index a87446b2cd7..d69bcc85c7d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm @@ -222,6 +222,9 @@ snap["actionspeed"] = action_speed var/list/physiology = list() for(var/var_name in body.physiology.vars) + // The reference finder stamps every datum it walks after a failed GC. + if(var_name == "last_find_references") + continue var/value = body.physiology.vars[var_name] if(isnum(value)) physiology[var_name] = value @@ -729,8 +732,11 @@ suspect("[ware_ref]|brownout", "brownout flag out of step with the body's load", "[ware.type] browned_out [chrome.browned_out], load [get_chrome_load(body)] of [get_chrome_capacity(body)]") if(chrome.emp_down && !chrome.emp_timer) suspect("[ware_ref]|emptimer", "EMP downtime with no reboot timer", "[ware.type]") - if(chrome.emp_down) - suspect("[ware_ref]|empstuck", "EMP downtime that never ended", "[ware.type] still down", SOAK_EMP_DOWNTIME + 8 SECONDS) + // A reboot always ends on its timer (a missing one is caught just above), but EMPs + // landing back to back can keep a piece down across sweeps. Only a timer set further + // out than any EMP's downtime is an outage that would not end. + if(chrome.emp_down && chrome.emp_timer && timeleft(chrome.emp_timer) > SOAK_EMP_DOWNTIME) + suspect("[ware_ref]|empstuck", "EMP downtime that never ended", "[ware.type] still down", SOAK_EMP_DOWNTIME) if(istype(ware, /obj/item/organ/cyberimp/arm/toolkit/cyberware)) var/obj/item/organ/cyberimp/arm/toolkit/cyberware/arm = ware if(arm.active_item && !running && arm.active_item.loc == body) @@ -781,6 +787,9 @@ loose_chrome += ware if(get_chrome_load(body) != 0) note("a body still carries load with no chrome in it", "[body] carries [get_chrome_load(body)]") + // Chrome optics and lungs replaced the body's own, so pulling them leaves gaps a + // body without chrome never had (no eyes means blind). + body.dna.species.regenerate_organs(body, replace_current = FALSE) log_world("SOAK auditing [length(survivors)] survivors after a 20 second quiet") sleep(20 SECONDS) for(var/mob/living/carbon/human/body as anything in survivors) From c2222e2d8c6e308e670cd6f9ee845b5f698de561 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:57:18 -0800 Subject: [PATCH 146/472] Leave tg's eyelids out of the chrome soak's vis_contents count Eyes add their eyelids to the body's vis_contents and leave them there when the eyes come out, so optics going in and out read as a leak. --- voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm index d69bcc85c7d..29af431dffc 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_soak.dm @@ -201,7 +201,13 @@ var/list/snap = list() snap["filters"] = length(body.filters) snap["actions"] = length(body.actions) - snap["vis_contents"] = length(body.vis_contents) + // tg's eyelids join the body's vis_contents whenever eyes are drawn, and stay there after + // the eyes leave, so chrome optics going in and out would read as a leak. Not chrome's. + var/visible = 0 + for(var/atom/movable/thing as anything in body.vis_contents) + if(!istype(thing, /obj/effect/abstract/eyelid_effect)) + visible++ + snap["vis_contents"] = visible var/list/statuses = list() for(var/datum/status_effect/effect as anything in body.status_effects) statuses += "[effect.type]" From ad2b4d3f5510687ed8fbe5f4e61d2052ac4c342b Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:15:01 -0800 Subject: [PATCH 147/472] Give the Vanguard core the same cooldown as the cheaper Hothead --- voidcrew/modules/cyberware/ware_line_berserk.dm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/voidcrew/modules/cyberware/ware_line_berserk.dm b/voidcrew/modules/cyberware/ware_line_berserk.dm index 6a1e3f6ebef..6bdde5d925d 100644 --- a/voidcrew/modules/cyberware/ware_line_berserk.dm +++ b/voidcrew/modules/cyberware/ware_line_berserk.dm @@ -54,7 +54,7 @@ burn_stun_immune = TRUE burn_roar_range = 4 burn_crash = 5 SECONDS - burn_cooldown = 105 SECONDS + burn_cooldown = 90 SECONDS burn_action_desc = "Ten seconds of stun immunity, 30% damage resistance and harder fists, opened with a staggering roar. Ends in a collapse." burn_alert_desc = "The safeties are off." From 5336de701c985aa9e5a77b61c878dbc81eacb89e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:03:19 -0800 Subject: [PATCH 148/472] Restore the chrome wheel chip names and the zoom define cleanup from the phase 2 merges --- voidcrew/modules/cyberware/cyberware_zoom.dm | 3 ++ voidcrew/modules/cyberware/ware_line_deck.dm | 15 ++-------- .../unit_tests/voidcrew_cyberware_deck.dm | 1 + .../unit_tests/voidcrew_cyberware_zoom.dm | 29 +++++++++++++++++++ 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/voidcrew/modules/cyberware/cyberware_zoom.dm b/voidcrew/modules/cyberware/cyberware_zoom.dm index 29d6517f2f3..d864a1338e5 100644 --- a/voidcrew/modules/cyberware/cyberware_zoom.dm +++ b/voidcrew/modules/cyberware/cyberware_zoom.dm @@ -235,6 +235,9 @@ button_icon = 'icons/obj/devices/tool.dmi' button_icon_state = "binoculars" +/datum/action/cooldown/cyberware/optics_zoom/chrome_wheel_name() + return "Zoom" + /datum/action/cooldown/cyberware/optics_zoom/Activate(atom/target) var/obj/item/organ/eyes/robotic/cyberware/optics = organ if(!istype(optics) || !optics.owner) diff --git a/voidcrew/modules/cyberware/ware_line_deck.dm b/voidcrew/modules/cyberware/ware_line_deck.dm index 4cdcffcd478..ccc7a1b25a8 100644 --- a/voidcrew/modules/cyberware/ware_line_deck.dm +++ b/voidcrew/modules/cyberware/ware_line_deck.dm @@ -54,18 +54,6 @@ * COMSIG_CYBERWARE_ZOOM_CLICK. */ -// The optics zoom (voidcrew/_DEFINES/cyberware.dm on phase2/zoom) owns these. -// Copies with the same names and values, until both are on one branch. -#ifndef COMSIG_CYBERWARE_ZOOM_CLICK -/// Sent on the bearer for every click made while zoomed, before the click -/// intercept chain: (mob/living/carbon/bearer, atom/target, list/modifiers). -#define COMSIG_CYBERWARE_ZOOM_CLICK "cyberware_zoom_click" -#endif -#ifndef COMPONENT_CYBERWARE_ZOOM_CLICK_HANDLED -/// Return from a COMSIG_CYBERWARE_ZOOM_CLICK handler that took the click. -#define COMPONENT_CYBERWARE_ZOOM_CLICK_HANDLED (1<<0) -#endif - // ========================================================================= // THE DECKS // ========================================================================= @@ -232,6 +220,9 @@ QDEL_NULL(trace) return ..() +/datum/action/cooldown/cyberware/deck/chrome_wheel_name() + return "Cyberdeck" + /datum/action/cooldown/cyberware/deck/Grant(mob/grant_to) . = ..() if(owner) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm index 7c4d53da48f..f30db553b54 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_deck.dm @@ -155,6 +155,7 @@ // button or its wheel slice, arms targeting, and using it again disarms. var/datum/action/cooldown/cyberware/deck/action = locate() in flagship.actions TEST_ASSERT(!length(action.chrome_wheel_options(user)), "Deck hacks are on the chrome wheel's ring") + TEST_ASSERT_EQUAL(action.chrome_wheel_name(), "Cyberdeck", "The deck's chip isn't called Cyberdeck") TEST_ASSERT(action.Trigger(user), "Using the deck did not arm it") TEST_ASSERT_EQUAL(user.click_intercept, action, "Using the deck did not arm targeting") action.Trigger(user) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_zoom.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_zoom.dm index e207c5f69c0..2716aed8e82 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_zoom.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_zoom.dm @@ -330,6 +330,35 @@ var/list/ears_now = positions[sonar] TEST_ASSERT(ears_at[1] == ears_now[1] && ears_at[2] == ears_now[2], "Swapping optics moved the ear chrome on the wheel") + // All three of the link's buttons get a place of their own in the eyes + // socket: none sits on another, and none sits on any other socket's chip. + var/list/placements = chrome_wheel_placements(abilities) + var/list/eye_buttons = list() + for(var/datum/action/ability as anything in link.actions) + if(ability in abilities) + eye_buttons += ability + TEST_ASSERT_EQUAL(length(eye_buttons), 3, "The marksman link should put three buttons on the wheel") + var/list/eye_indexes = list() + for(var/datum/action/ability as anything in eye_buttons) + var/list/placement = placements[ability] + TEST_ASSERT_EQUAL(placement["socket"], "eyes", "[ability.name] is not in the eyes socket") + eye_indexes |= placement["index"] + TEST_ASSERT_EQUAL(length(eye_indexes), 3, "Two optics buttons share a place on the wheel") + for(var/datum/action/ability as anything in eye_buttons) + var/list/here = positions[ability] + for(var/datum/action/other as anything in abilities) + if(other == ability) + continue + var/list/there = positions[other] + // Chips are 22 px across (26 when pointed at). + TEST_ASSERT(sqrt((here[1] - there[1]) ** 2 + (here[2] - there[2]) ** 2) >= 24, "[ability.name] overlaps [other.name] on the wheel") + // Nor on the first places of the head socket beside it. + for(var/head_index in 1 to 2) + var/list/head_point = chrome_wheel_position("head", head_index) + TEST_ASSERT(sqrt((here[1] - head_point[1]) ** 2 + (here[2] - head_point[2]) ** 2) >= 24, "[ability.name] sits on the head socket's place [head_index]") + var/datum/action/link_zoom = locate(/datum/action/cooldown/cyberware/optics_zoom) in link.actions + TEST_ASSERT_EQUAL(link_zoom?.chrome_wheel_name(), "Zoom", "The zoom chip isn't called Zoom") + /// Shelf prices and gates, half-list trade-ins, and cradle quotes. /datum/unit_test/voidcrew_cyberware_zoom_catalog From cddb420c0ae7e36c6331b44ae4217d78c5565a8f Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:21:13 -0800 Subject: [PATCH 149/472] Camo hides from human NPCs, and their bosses see through it by rank Pirate crews, boarders, ruin NPCs, bounty criminals and the club target through the human NPC strategy, which inherits the camo check. The experiment forms ask the helper in their own victim check. Bosses that are people (faction pirate bosses, the NT director, bounty bosses, the kingpin, the don) see through camo by rank; the basic boss types that no longer exist leave the piercing list. --- .../modules/cyberware/cyberware_stealth.dm | 41 ++++-- .../outpost_prison_experiment_forms.dm | 2 + .../unit_tests/voidcrew_cyberware_camo.dm | 135 +++++++++++++----- .../unit_tests/voidcrew_cyberware_final.dm | 4 +- 4 files changed, 134 insertions(+), 48 deletions(-) diff --git a/voidcrew/modules/cyberware/cyberware_stealth.dm b/voidcrew/modules/cyberware/cyberware_stealth.dm index 38c5de72dbf..9e1aee21738 100644 --- a/voidcrew/modules/cyberware/cyberware_stealth.dm +++ b/voidcrew/modules/cyberware/cyberware_stealth.dm @@ -8,28 +8,35 @@ * the hooks that ask it for everything that hunts the bearer by sight. * * Everything that grants TRAIT_CYBER_CAMO is hidden from: - * - basic mobs (the targeting strategy below, which every strategy subtype - * inherits through its own `..()` calls), + * - basic mobs and human NPCs (the targeting strategy below, which every + * strategy subtype inherits through its own `..()` calls: the human NPC + * strategy, pirate crews and boarders, bounty criminals and their crews, + * ruin troopers and hermits, patrols, the club's people), * - simple_animal hostiles (hermits, ice demons, polar bears, ...), * - every porta_turret subtype: ruin and Syndicate turrets, hull defense * turrets, player-built turrets, trader and player outpost turrets, including * a player an outpost has marked, * and, through one-line calls in the files that own them, the scripted target - * picks that never go through a strategy: the club's mobsters, patrol troopers - * opening a closet, the lich's thralls and the outpost experiments. + * picks that never go through a /basic strategy: the club's people + * (mafia_target_ok()), the lich's thralls and the outpost experiment forms + * (their form's can_target()). * * What camo does NOT hide from: anything within CYBERWARE_CAMO_SPOT_RANGE - * tiles, and the mobs in GLOB.cyberware_camo_piercing_types (megafauna, - * elites and bosses). Ship weapons that target ships, not people, are not - * covered here. The street Flickerskin never grants the trait at all. + * tiles, the mobs in GLOB.cyberware_camo_piercing_types (megafauna, elites + * and the bosses that are still basic mobs), and every human NPC of boss rank + * (npc_rank HUMAN_NPC_RANK_BOSS: faction pirate bosses, the NT patrol's + * director, bounty bosses, the kingpin, the club's don). Ship weapons that + * target ships, not people, are not covered here. The street Flickerskin + * never grants the trait at all. * * The overrides live HERE, in a cyberware-owned file, rather than in the * shared AI files: defining the same proc a second time is this fork's blessed * way to patch upstream behavior (the later .dme include wraps the earlier * definition, and `..()` calls straight into it). That only works for a proc * whose first definition is included BEFORE this file, so the hooks for the - * voidcrew files that include after it (the lich, the patrol, the experiments) - * are a single call to cyberware_camo_hides() in the proc that picks the target. + * voidcrew files that include after it (the lich, the club, the experiment + * forms) are a single call to cyberware_camo_hides() in the proc that picks + * the target. * * TRAIT_CYBER_CAMO itself lives in voidcrew/_DEFINES/cyberware.dm, because * the chrome read (cyberware_scan.dm) is included ahead of this file and also @@ -42,9 +49,10 @@ #define CYBERWARE_CAMO_SPOT_RANGE 2 /// Mobs that see straight through optical camo, whatever it is: megafauna, -/// mining elites and the bosses and mini bosses of the ruins, bounties, lairs, -/// outposts and NPC ships (every faction's pirate boss, the NT patrol's -/// included). A typecache, so subtypes count. +/// mining elites and the bosses and mini bosses of the ruins, lairs, outposts +/// and vestiges that are still basic mobs. A typecache, so subtypes count. +/// Bosses that are people (human NPCs) see through it by rank instead, in +/// cyberware_camo_hides(). GLOBAL_LIST_INIT(cyberware_camo_piercing_types, typecacheof(list( /mob/living/simple_animal/hostile/megafauna, /mob/living/basic/trooper/pirate/faction/boss, @@ -52,8 +60,6 @@ GLOBAL_LIST_INIT(cyberware_camo_piercing_types, typecacheof(list( /mob/living/basic/boss, /mob/living/basic/lich, /mob/living/basic/hoarfrost_matriarch, - /mob/living/basic/bounty_criminal/boss, - /mob/living/basic/bounty_criminal/kingpin, /mob/living/basic/bounty_lair_boss, /mob/living/basic/outpost_experiment/horror, /mob/living/basic/alien/queen/large/derelict, @@ -65,7 +71,8 @@ GLOBAL_LIST_INIT(cyberware_camo_piercing_types, typecacheof(list( /** * Whether optical camo hides `target` from `viewer` right now: the target holds * TRAIT_CYBER_CAMO, the viewer is further off than CYBERWARE_CAMO_SPOT_RANGE and - * the viewer is not one of the mobs that sees through it. `viewer` is whatever + * the viewer is not one of the mobs that sees through it (a piercing type, or a + * human NPC of boss rank). `viewer` is whatever * is doing the looking (a mob, a turret, a creature picking its next victim). * Anything that is not a living mob is never hidden. */ @@ -78,6 +85,10 @@ GLOBAL_LIST_INIT(cyberware_camo_piercing_types, typecacheof(list( return FALSE if(is_type_in_typecache(viewer, GLOB.cyberware_camo_piercing_types)) return FALSE + if(ishumannpc(viewer)) + var/mob/living/carbon/human/npc/person = viewer + if(person.npc_rank >= HUMAN_NPC_RANK_BOSS) + return FALSE return TRUE /** diff --git a/voidcrew/modules/player_outposts/outpost_prison_experiment_forms.dm b/voidcrew/modules/player_outposts/outpost_prison_experiment_forms.dm index 102eb718f16..e62fdce1d11 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_experiment_forms.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_experiment_forms.dm @@ -264,6 +264,8 @@ return FALSE if(is_outpost_experiment_mob(victim) || HAS_TRAIT(victim, TRAIT_GODMODE)) return FALSE + if(cyberware_camo_hides(victim, parent)) // optical camo (cyberware_stealth.dm) + return FALSE if(is_outpost_prisoner(victim)) var/mob/living/carbon/human/npc/outpost_prisoner/prisoner = victim if(prisoner.phase != PRISONER_PRESENT || !may_hit_prisoner(prisoner)) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm index 4ae40f718f0..503a3db4b2d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_camo.dm @@ -2,15 +2,17 @@ * Cyberware phase 2: optical camo hides its bearer from more than basic mobs. * * The Ghostskin weave (and anything else that grants TRAIT_CYBER_CAMO) is also invisible to - * simple_animal hostiles, every porta_turret, the outpost experiments and the club's mobsters, - * beyond CYBERWARE_CAMO_SPOT_RANGE, except to megafauna, elites and bosses. The street - * Flickerskin grants no trait, so none of this applies to it. voidcrew/modules/cyberware/ - * cyberware_stealth.dm owns the helper and most of the hooks. + * human NPCs (their targeting strategy, pirate crews included), simple_animal hostiles, every + * porta_turret, the outpost experiment forms and the club's people, beyond + * CYBERWARE_CAMO_SPOT_RANGE, except to megafauna, elites and bosses (human NPCs of boss rank). + * The street Flickerskin grants no trait, so none of this applies to it. voidcrew/modules/ + * cyberware/cyberware_stealth.dm owns the helper and most of the hooks. * * Literals (unit tests are included before the fork's defines): the trait is TRAIT_CYBER_CAMO - * ("cyber_camo"); the spot range is CYBERWARE_CAMO_SPOT_RANGE (2). The test room is 5x5, so the - * far corner is 4 tiles from the near one. Every mob and turret here is clientless: this proves - * the targeting gates, not how an AI walks. + * ("cyber_camo"); the spot range is CYBERWARE_CAMO_SPOT_RANGE (2); HUMAN_NPC_RANK_BOSS is 2. + * The test room is 5x5, so the far corner is 4 tiles from the near one. Every mob and turret here + * is clientless and every human NPC has its AI switched off: this proves the targeting gates, not + * how an AI walks. */ /// Shared helpers; not a test itself @@ -30,6 +32,15 @@ TEST_ASSERT(weave.camo_active, "[weave_type] never raised its field") return bearer +/// A human NPC of `npc_type` at `where`, built the way the spawn queue builds one, with its AI off +/datum/unit_test/voidcrew_cyberware_camo/proc/human_npc(npc_type, turf/where) + var/datum/human_npc_order/order = spawn_human_npc(npc_type, where, immediate = TRUE) + var/mob/living/carbon/human/npc/npc = order?.spawned_ref?.resolve() + TEST_ASSERT_NOTNULL(npc, "[npc_type] wasn't built") + allocated += npc + npc.ai_controller?.set_ai_status(AI_STATUS_OFF) + return npc + /// The helper itself: range, trait, null viewer, non-living target, and who sees through /datum/unit_test/voidcrew_cyberware_camo/helper @@ -62,9 +73,8 @@ /mob/living/basic/boss/thing, /mob/living/basic/lich, /mob/living/basic/hoarfrost_matriarch, - /mob/living/basic/bounty_criminal/boss/ghost, - /mob/living/basic/bounty_criminal/kingpin, - /mob/living/basic/bounty_lair_boss/mafia_don, + /mob/living/basic/trooper/pirate/faction/boss, + /mob/living/basic/bounty_lair_boss/mafia_mech, /mob/living/basic/outpost_experiment/horror, /mob/living/basic/alien/queen/large/derelict, /mob/living/basic/vestige_oracle, @@ -74,44 +84,68 @@ for(var/seer_type in piercing) TEST_ASSERT(is_type_in_typecache(seer_type, GLOB.cyberware_camo_piercing_types), "[seer_type] is fooled by camo") var/static/list/fooled = list( - /mob/living/simple_animal/hostile/asteroid/hermit, /mob/living/simple_animal/hostile/asteroid/old_demon, /mob/living/simple_animal/hostile/asteroid/polarbear, - /mob/living/basic/trooper/russian/mafia, - /mob/living/basic/outpost_experiment/hulk, + /mob/living/carbon/human/npc, ) for(var/fooled_type in fooled) TEST_ASSERT(!is_type_in_typecache(fooled_type, GLOB.cyberware_camo_piercing_types), "[fooled_type] sees through camo and shouldn't") + // Bosses that are people see through it by rank, not by type: every human NPC boss is rank 2. + var/static/list/human_bosses = list( + /mob/living/carbon/human/npc/faction_pirate/silverscale/boss, + /mob/living/carbon/human/npc/faction_pirate/skeleton/boss, + /mob/living/carbon/human/npc/faction_pirate/grey/boss, + /mob/living/carbon/human/npc/faction_pirate/lustrous/boss, + /mob/living/carbon/human/npc/faction_pirate/interdyne/boss, + /mob/living/carbon/human/npc/faction_pirate/irs/boss, + /mob/living/carbon/human/npc/faction_pirate/medieval/boss, + /mob/living/carbon/human/npc/faction_pirate/nt_customs/director, + /mob/living/carbon/human/npc/bounty_criminal/boss/ghost, + /mob/living/carbon/human/npc/bounty_criminal/kingpin, + /mob/living/carbon/human/npc/bounty_mafia/don, + ) + for(var/mob/living/carbon/human/npc/boss_type as anything in human_bosses) + TEST_ASSERT_EQUAL(initial(boss_type.npc_rank), 2, "[boss_type] isn't boss rank, so camo fools it") + var/static/list/human_regulars = list( + /mob/living/carbon/human/npc/faction_pirate/silverscale/captain, + /mob/living/carbon/human/npc/faction_pirate/grey/ranged, + /mob/living/carbon/human/npc/bounty_mafia/pistol, + /mob/living/carbon/human/npc/bounty_mafia/lieutenant, + ) + for(var/mob/living/carbon/human/npc/regular_type as anything in human_regulars) + TEST_ASSERT(initial(regular_type.npc_rank) < 2, "[regular_type] is boss rank and sees through camo") + /// Simple animal hostiles: fooled beyond range, not by megafauna, not by the street weave, and -/// whoever strikes from under the field is seen again. +/// whoever strikes from under the field is seen again. (The hermits are people now, so a polar +/// bear stands in for them.) /datum/unit_test/voidcrew_cyberware_camo/hostiles /datum/unit_test/voidcrew_cyberware_camo/hostiles/Run() - var/mob/living/simple_animal/hostile/asteroid/hermit/survivor/hermit = allocate(/mob/living/simple_animal/hostile/asteroid/hermit/survivor, spot(0, 0)) + var/mob/living/simple_animal/hostile/asteroid/polarbear/bear = allocate(/mob/living/simple_animal/hostile/asteroid/polarbear, spot(0, 0)) var/mob/living/simple_animal/hostile/megafauna/boss = allocate(/mob/living/simple_animal/hostile/megafauna, spot(0, 1)) var/mob/living/carbon/human/consistent/plain = allocate(/mob/living/carbon/human/consistent, spot(4, 4)) - TEST_ASSERT(hermit.CanAttack(plain), "Fixture: the hermit won't pick a plain human to begin with") + TEST_ASSERT(bear.CanAttack(plain), "Fixture: the bear won't pick a plain human to begin with") TEST_ASSERT(boss.CanAttack(plain), "Fixture: the megafauna won't pick a plain human to begin with") var/mob/living/carbon/human/consistent/ghost = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin, spot(4, 4)) - TEST_ASSERT(!hermit.CanAttack(ghost), "A hermit picked a camo'd human 4 tiles off") + TEST_ASSERT(!bear.CanAttack(ghost), "A bear picked a camo'd human 4 tiles off") TEST_ASSERT(boss.CanAttack(ghost), "A megafauna was fooled by a camo'd human") ghost.forceMove(spot(0, 2)) - TEST_ASSERT(hermit.CanAttack(ghost), "A hermit missed a camo'd human 2 tiles off") + TEST_ASSERT(bear.CanAttack(ghost), "A bear missed a camo'd human 2 tiles off") ghost.forceMove(spot(4, 4)) - TEST_ASSERT(!hermit.CanAttack(ghost), "A hermit that lost sight of a camo'd human picked them up again") + TEST_ASSERT(!bear.CanAttack(ghost), "A bear that lost sight of a camo'd human picked them up again") // A hostile already chasing loses the target the moment the field goes up: FindTarget and // MoveToTarget both ask CanAttack(). - hermit.target = ghost - TEST_ASSERT(!hermit.CanAttack(hermit.target), "A hermit kept chasing a human who cloaked") - hermit.target = null + bear.target = ghost + TEST_ASSERT(!bear.CanAttack(bear.target), "A bear kept chasing a human who cloaked") + bear.target = null // Striking from under the field drops it, so retaliation still lands. SEND_SIGNAL(ghost, COMSIG_MOB_FIRED_GUN) TEST_ASSERT(!HAS_TRAIT(ghost, "cyber_camo"), "Firing a gun didn't drop the field") - TEST_ASSERT(hermit.CanAttack(ghost), "A hermit couldn't retaliate against someone who fired from under the field") + TEST_ASSERT(bear.CanAttack(ghost), "A bear couldn't retaliate against someone who fired from under the field") // Throwing from under the field drops it too: a thrown brick is an attack like any other. var/mob/living/carbon/human/consistent/pitcher = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin, spot(4, 4)) @@ -131,7 +165,7 @@ // The street weave never grants the trait, so nothing here hides it. var/mob/living/carbon/human/consistent/flicker = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin/street, spot(4, 4)) TEST_ASSERT(!HAS_TRAIT(flicker, "cyber_camo"), "The Flickerskin granted the camo trait") - TEST_ASSERT(hermit.CanAttack(flicker), "A hermit was fooled by the Flickerskin") + TEST_ASSERT(bear.CanAttack(flicker), "A bear was fooled by the Flickerskin") TEST_ASSERT(boss.CanAttack(flicker), "A megafauna was fooled by the Flickerskin") /// Every turret type that can be handed a camo'd human drops them from the list it shoots at. @@ -165,21 +199,53 @@ TEST_ASSERT(turret.tryToShootAt(list(ghost)), "[turret_type] missed a camo'd human 2 tiles off") ghost.forceMove(spot(4, 4)) -/// The experiments' own strategy (it never calls the basic one): a creature picks a plain person, -/// not a camo'd one beyond range. +/// 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 +/// (by rank) is not; the street weave fools nobody. +/datum/unit_test/voidcrew_cyberware_camo/human_npcs + +/datum/unit_test/voidcrew_cyberware_camo/human_npcs/Run() + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(/datum/targeting_strategy/basic/human_npc) + TEST_ASSERT_NOTNULL(strategy, "The human NPC targeting strategy is gone") + var/mob/living/carbon/human/npc/gunner = human_npc(/mob/living/carbon/human/npc/faction_pirate/grey/ranged, spot(0, 0)) + var/mob/living/carbon/human/npc/boss = human_npc(/mob/living/carbon/human/npc/faction_pirate/grey/boss, spot(0, 1)) + var/mob/living/carbon/human/npc/director = human_npc(/mob/living/carbon/human/npc/faction_pirate/nt_customs/director, spot(1, 0)) + TEST_ASSERT_EQUAL(gunner.ai_controller?.blackboard[BB_TARGETING_STRATEGY], /datum/targeting_strategy/basic/human_npc, "A pirate gunner doesn't target through the human NPC strategy") + + var/mob/living/carbon/human/consistent/plain = allocate(/mob/living/carbon/human/consistent, spot(4, 4)) + TEST_ASSERT(strategy.can_attack(gunner, plain), "Fixture: a pirate gunner won't target a plain human to begin with") + TEST_ASSERT(strategy.can_attack(boss, plain), "Fixture: a pirate boss won't target a plain human to begin with") + + var/mob/living/carbon/human/consistent/ghost = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin, spot(4, 4)) + TEST_ASSERT(!strategy.can_attack(gunner, ghost), "A pirate gunner targeted a camo'd human 4 tiles off") + TEST_ASSERT(cyberware_camo_hides(ghost, gunner), "Camo didn't hide its bearer from a pirate gunner") + TEST_ASSERT(strategy.can_attack(boss, ghost), "A pirate boss was fooled by camo") + TEST_ASSERT(!cyberware_camo_hides(ghost, director), "The NT patrol's director was fooled by camo") + ghost.forceMove(spot(2, 0)) + TEST_ASSERT(strategy.can_attack(gunner, ghost), "A pirate gunner missed a camo'd human 2 tiles off") + ghost.forceMove(spot(4, 4)) + + var/mob/living/carbon/human/consistent/flicker = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin/street, spot(4, 3)) + TEST_ASSERT(strategy.can_attack(gunner, flicker), "A pirate gunner was fooled by the Flickerskin") + +/// The experiment forms' own strategy (it never calls the basic one): a serum-turned body picks a +/// plain person, not a camo'd one beyond range. /datum/unit_test/voidcrew_cyberware_camo/experiments /datum/unit_test/voidcrew_cyberware_camo/experiments/Run() - var/mob/living/basic/outpost_experiment/hulk/creature = allocate(/mob/living/basic/outpost_experiment/hulk, spot(0, 0)) - var/datum/targeting_strategy/outpost_experiment/strategy = GET_TARGETING_STRATEGY(/datum/targeting_strategy/outpost_experiment) - TEST_ASSERT_NOTNULL(strategy, "The experiments' targeting strategy is gone") + var/mob/living/carbon/human/consistent/creature = allocate(/mob/living/carbon/human/consistent, spot(0, 0)) + var/datum/component/outpost_experiment_form/form = outpost_experiment_turn(creature, "hulk", null, null) + TEST_ASSERT_NOTNULL(form, "The hulk serum didn't take on a test body") + creature.ai_controller?.set_ai_status(AI_STATUS_OFF) + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(/datum/targeting_strategy/outpost_experiment_form) + TEST_ASSERT_NOTNULL(strategy, "The experiment forms' targeting strategy is gone") var/mob/living/carbon/human/consistent/plain = allocate(/mob/living/carbon/human/consistent, spot(4, 4)) TEST_ASSERT(strategy.can_attack(creature, plain), "Fixture: an experiment won't target a plain human to begin with") var/mob/living/carbon/human/consistent/ghost = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin, spot(4, 4)) TEST_ASSERT(!strategy.can_attack(creature, ghost), "An experiment targeted a camo'd human 4 tiles off") - TEST_ASSERT(!creature.can_target(ghost), "An experiment would pick a camo'd human 4 tiles off") + TEST_ASSERT(!form.can_target(ghost), "An experiment would pick a camo'd human 4 tiles off") ghost.forceMove(spot(2, 0)) TEST_ASSERT(strategy.can_attack(creature, ghost), "An experiment missed a camo'd human 2 tiles off") ghost.forceMove(spot(4, 4)) @@ -187,11 +253,15 @@ var/mob/living/carbon/human/consistent/flicker = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin/street, spot(4, 3)) TEST_ASSERT(strategy.can_attack(creature, flicker), "An experiment was fooled by the Flickerskin") -/// The club's mobsters: the scripted pick, mafia_target_ok(), skips a camo'd hunter beyond range. +/// The club's people: the scripted pick, mafia_target_ok(), skips a camo'd hunter beyond range, +/// and the don, a boss, sees through it. /datum/unit_test/voidcrew_cyberware_camo/mobsters /datum/unit_test/voidcrew_cyberware_camo/mobsters/Run() - var/mob/living/basic/trooper/russian/mafia/pistol/gunman = allocate(/mob/living/basic/trooper/russian/mafia/pistol, spot(0, 0)) + var/mob/living/carbon/human/npc/bounty_mafia/pistol/gunman = allocate(/mob/living/carbon/human/npc/bounty_mafia/pistol, spot(0, 0)) + gunman.ai_controller?.set_ai_status(AI_STATUS_OFF) + var/mob/living/carbon/human/npc/bounty_mafia/don/don = allocate(/mob/living/carbon/human/npc/bounty_mafia/don, spot(0, 1)) + don.ai_controller?.set_ai_status(AI_STATUS_OFF) var/mob/living/carbon/human/consistent/plain = allocate(/mob/living/carbon/human/consistent, spot(4, 4)) plain.mind_initialize() TEST_ASSERT(gunman.mafia_target_ok(plain), "Fixture: a goon won't go after a plain hunter to begin with") @@ -199,6 +269,7 @@ var/mob/living/carbon/human/consistent/ghost = cloaked_human(/obj/item/organ/cyberimp/cyberware/ghostskin, spot(4, 4)) ghost.mind_initialize() TEST_ASSERT(!gunman.mafia_target_ok(ghost), "A goon went after a camo'd hunter 4 tiles off") + TEST_ASSERT(!cyberware_camo_hides(ghost, don), "The don was fooled by camo") ghost.forceMove(spot(0, 2)) TEST_ASSERT(gunman.mafia_target_ok(ghost), "A goon missed a camo'd hunter 2 tiles off") ghost.forceMove(spot(4, 4)) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_final.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_final.dm index 77db4357102..96e8c9730b8 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_final.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_final.dm @@ -81,7 +81,9 @@ TEST_ASSERT(!weave.camo_active, "A real shock left the field up") TEST_ASSERT(GLOB.cyberware_camo_piercing_types[/mob/living/basic/trooper/pirate/faction/boss], "A pirate boss doesn't see through camo") - TEST_ASSERT(GLOB.cyberware_camo_piercing_types[/mob/living/basic/trooper/pirate/faction/boss/nt_patrol], "The NT patrol's boss doesn't see through camo") + // The NT patrol's boss is a person now: it sees through camo by rank (HUMAN_NPC_RANK_BOSS is 2). + var/mob/living/carbon/human/npc/director_type = /mob/living/carbon/human/npc/faction_pirate/nt_customs/director + TEST_ASSERT_EQUAL(initial(director_type.npc_rank), 2, "The NT patrol's boss doesn't see through camo") /// The wheel shows the zoom as on while zoomed, and a chip whose chrome was /// cut out while the wheel was held open can't be picked. From de56f45d9be4a91b42d510b47cb87fd3830eab46 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:21:13 -0800 Subject: [PATCH 150/472] Make the chrome dummy a human NPC body, and test chrome against NPCs and gore The kit's dummy is a human NPC body that never moves or fights, so its chrome burns out like any NPC's. New tests: NPC chrome burns out at death, on gib, dust and a lost limb, and the scrap is neither installable nor bought by any ledger; Chrome Read and Glitch work on NPCs; Atlas stops bursts, the Strut frame stops fractures but not bursts or lost limbs; Hardweave counts toward the crit armour gate; a Lazarus wind-up never brings back a burst head; the blades cut as brute slashes. --- voidcrew/modules/admin/chrome_test_kit.dm | 37 ++- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_npcs.dm | 228 ++++++++++++++++++ 3 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_npcs.dm diff --git a/voidcrew/modules/admin/chrome_test_kit.dm b/voidcrew/modules/admin/chrome_test_kit.dm index 7df73f36dda..092e149782e 100644 --- a/voidcrew/modules/admin/chrome_test_kit.dm +++ b/voidcrew/modules/admin/chrome_test_kit.dm @@ -5,7 +5,9 @@ * Cradle beside the admin with a stack of trade vouchers and a holochip of credits, then offers * a short list of ready-made builds (see GLOB.chrome_test_loadouts) that either go straight into * the admin's own body or into a crate at their feet, plus a chromed human to point Chrome Read, - * a cyberdeck or Glitch at. + * a cyberdeck or Glitch at. The dummy is a human NPC body (voidcrew/modules/human_npcs/) with no + * AI, so it is the same kind of body as the NPCs players meet: its chrome works, and burns out + * to scrap when it dies or a piece leaves it. * * Installs here are special inserts, the same door the unit tests use: no cradle sequence, no * boot splash, no install context. They still obey the body's rules in two ways. A piece whose @@ -124,6 +126,33 @@ GLOBAL_LIST_INIT(chrome_test_loadouts, list( left_over += ware return left_over +/** + * The kit's dummy: a human NPC body with no AI (it never moves, fights or barks), in a grey + * jumpsuit, on no side anyone fights for. Being an NPC body is the point: its chrome burns out at + * death and when a piece leaves it, as on every NPC players meet. + */ +/mob/living/carbon/human/npc/chrome_dummy + ai_controller = null + npc_name = "Chrome dummy" + npc_outfit = /datum/outfit/chrome_dummy + faction = list(FACTION_NEUTRAL) + npc_voice = null + npc_surrender = null + +/datum/outfit/chrome_dummy + name = "Chrome dummy" + uniform = /obj/item/clothing/under/color/grey + +/** + * A chrome dummy at `where` carrying what fits of `ware_types` (chrome_kit_install(); pieces that + * don't fit are deleted, so the dummy keeps only what fits). Returns the dummy. + */ +/proc/chrome_kit_make_dummy(turf/where, list/ware_types, list/report) + var/mob/living/carbon/human/npc/chrome_dummy/dummy = new(where) + var/list/dummy_spare = chrome_kit_install(dummy, ware_types, report) + QDEL_LIST(dummy_spare) + return dummy + /// A crate of loose chrome at a turf. Items placed straight into the crate, so its slot count /// never turns the big builds away. /proc/chrome_kit_crate(turf/where, list/contents_list, crate_name = "chrome crate") @@ -204,11 +233,7 @@ ADMIN_VERB(chrome_test_kit, R_ADMIN|R_DEBUG, "Chrome: Test Kit", "Drop a Chrome chrome_kit_crate(origin, everything, "chrome crate (everything)") summary += ", a crate with [length(everything)] items" if(CHROME_KIT_DUMMY) - var/mob/living/carbon/human/dummy = new(chrome_kit_adjacent_turf(admin_mob)) - dummy.fully_replace_character_name(dummy.real_name, "Chrome dummy") - dummy.equip_to_slot_or_del(new /obj/item/clothing/under/color/grey(dummy), ITEM_SLOT_ICLOTHING) - var/list/dummy_spare = chrome_kit_install(dummy, GLOB.chrome_test_loadouts[dummy_build], report) - QDEL_LIST(dummy_spare) // the dummy keeps only what fits + var/mob/living/carbon/human/npc/chrome_dummy/dummy = chrome_kit_make_dummy(chrome_kit_adjacent_turf(admin_mob), GLOB.chrome_test_loadouts[dummy_build], report) summary += ", a chrome dummy carrying the [dummy_build] build ([chrome_kit_status_line(dummy)])" else var/list/ware_types = GLOB.chrome_test_loadouts[choice] diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 16d3938ed40..3f8046ddb59 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -80,6 +80,7 @@ #include "voidcrew_cyberware_matrix.dm" #include "voidcrew_cyberware_models_a.dm" #include "voidcrew_cyberware_models_b.dm" +#include "voidcrew_cyberware_npcs.dm" #include "voidcrew_cyberware_passives.dm" #include "voidcrew_cyberware_soak.dm" #include "voidcrew_cyberware_text.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_npcs.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_npcs.dm new file mode 100644 index 00000000000..1ddd9e9cafb --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_npcs.dm @@ -0,0 +1,228 @@ +/** + * Cyberware against the human NPCs and the gore layer. + * + * Human NPCs (voidcrew/modules/human_npcs/) are real human bodies, so the chrome works on them as on + * a player, but an NPC's chrome burns out to scrap (/obj/item/burnt_chrome) when it dies or a piece + * leaves its body by any route. These tests pin that the burn-out beats the chrome salvage (gib, + * dust, a lost limb), that the scrap is no chrome (nothing reinstalls it, no ledger buys it), and + * that Chrome Read and a deck's Glitch work on an NPC. The kit's chrome dummy is such a body. + * + * The gore layer (voidcrew/modules/hyperviolence/): the Atlas frame's dismember immunity stops head + * bursts; the street Strut frame holds the bones only, so it stops fractures (the skull and jaw + * crits included) but not a burst; Hardweave plating counts toward the crit armour gate; a Lazarus + * node never brings back a body whose head burst during its wind-up; and the blades cut as blades. + * + * Literals (unit tests are included before the fork's defines): CYBERWARE_SCAN_ITEMIZED is 2; the + * Lazarus wind-up is 1 second. + */ + +/// Shared helpers; not a test itself +/datum/unit_test/voidcrew_cyberware_npcs + abstract_type = /datum/unit_test/voidcrew_cyberware_npcs + +/// The floor `dx`, `dy` tiles from the test room's bottom-left corner +/datum/unit_test/voidcrew_cyberware_npcs/proc/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) + +/// The kit's chrome dummy carrying `ware_types`, cleaned up with the test +/datum/unit_test/voidcrew_cyberware_npcs/proc/dummy_with(list/ware_types, turf/where) + var/list/report = list() + var/mob/living/carbon/human/npc/chrome_dummy/dummy = chrome_kit_make_dummy(where || run_loc_floor_bottom_left, ware_types, report) + allocated += dummy + TEST_ASSERT(!length(report), "The dummy turned chrome away: [jointext(report, "; ")]") + return dummy + +/// Every piece of chrome lying loose (owned by nobody) in or near the test room, however deep it sits +/datum/unit_test/voidcrew_cyberware_npcs/proc/loose_chrome() + . = list() + for(var/turf/floor in range(6, run_loc_floor_bottom_left)) + for(var/obj/item/organ/ware in floor.get_all_contents()) + if(!ware.owner && ware.GetComponent(/datum/component/cyberware)) + . += ware + +/// The burnt-out scrap in or near the test room +/datum/unit_test/voidcrew_cyberware_npcs/proc/scrap_count() + . = 0 + for(var/turf/floor in range(6, run_loc_floor_bottom_left)) + for(var/obj/item/burnt_chrome/scrap in floor.get_all_contents()) + .++ + +/// An NPC's chrome burns out when it dies; Chrome Read and Glitch work on it while it lives; the +/// scrap is not chrome and no ledger buys it. +/datum/unit_test/voidcrew_cyberware_npcs/burn_out + +/datum/unit_test/voidcrew_cyberware_npcs/burn_out/Run() + var/mob/living/carbon/human/npc/chrome_dummy/dummy = dummy_with(list( + /obj/item/organ/cyberimp/cyberware/slipwire, + /obj/item/organ/cyberimp/cyberware/dermal_mesh, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis, + ), spot(2, 2)) + TEST_ASSERT(istype(dummy, /mob/living/carbon/human/npc), "The kit's chrome dummy isn't a human NPC body") + TEST_ASSERT_NULL(dummy.ai_controller, "The kit's chrome dummy has an AI") + TEST_ASSERT_EQUAL(length(get_installed_cyberware(dummy)), 3, "The dummy didn't take its three pieces") + + // Chrome Read reads an NPC like anyone else. + var/mob/living/carbon/human/reader = allocate(/mob/living/carbon/human/consistent, spot(0, 0)) + var/obj/item/organ/eyes/robotic/cyberware/deadeye/optics = allocate(/obj/item/organ/eyes/robotic/cyberware/deadeye) + TEST_ASSERT(optics.Insert(reader, special = TRUE), "Deadeye staging insert was refused") + var/list/report = cyberware_scan_report(dummy, 2) + TEST_ASSERT_EQUAL(report["count"], 3, "Chrome Read miscounted an NPC's chrome") + var/datum/action/cooldown/cyberware/chrome_read/read = locate() in optics.actions + TEST_ASSERT_NOTNULL(read, "The Deadeye link lost its chrome read") + TEST_ASSERT(read.PreActivate(dummy), "The chrome read didn't run on an NPC in reach") + TEST_ASSERT_NOTNULL(read.current_readout, "The chrome read drew nothing over an NPC") + + // A deck's Glitch drops a piece of an NPC's chrome into a reboot. + var/datum/machine_quickhack/glitch/glitch + for(var/datum/machine_quickhack/hack as anything in get_machine_quickhacks()) + if(istype(hack, /datum/machine_quickhack/glitch)) + glitch = hack + TEST_ASSERT_NOTNULL(glitch, "Glitch is not a quickhack") + TEST_ASSERT(glitch.valid_for(dummy, reader), "Glitch was not offered on a chromed NPC") + TEST_ASSERT(glitch.execute(dummy, reader), "Glitch did not land on an NPC") + var/rebooting = 0 + for(var/obj/item/organ/ware as anything in get_installed_cyberware(dummy)) + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + if(chrome?.emp_down) + rebooting++ + TEST_ASSERT_EQUAL(rebooting, 1, "Glitch didn't reboot exactly one piece of an NPC's chrome") + + // Death: every piece comes out as scrap, nothing intact is left anywhere. + dummy.death() + TEST_ASSERT(!length(get_installed_cyberware(dummy)), "A dead NPC kept chrome in its body") + sleep(0.5 SECONDS) + TEST_ASSERT(!length(loose_chrome()), "A dead NPC left working chrome behind") + TEST_ASSERT_EQUAL(scrap_count(), 3, "A dead NPC didn't leave one burnt-out scrap per piece") + + // The scrap is no chrome: nothing can install it, and no ledger anywhere buys it. + var/obj/item/burnt_chrome/scrap = locate() in range(6, run_loc_floor_bottom_left) + TEST_ASSERT_NOTNULL(scrap, "Fixture: no scrap to offer") + TEST_ASSERT(!istype(scrap, /obj/item/organ), "Burnt-out chrome is still an organ") + TEST_ASSERT_NULL(scrap.GetComponent(/datum/component/cyberware), "Burnt-out chrome still counts as chrome") + for(var/buyback_type in subtypesof(/datum/shop_buyback)) + var/datum/shop_buyback/ledger = new buyback_type + if(ledger.item_path && ledger.matches(scrap)) + TEST_FAIL("[buyback_type] buys burnt-out chrome") + qdel(ledger) + +/// Chrome the salvage throws loose (gib, dust) or a lost limb carries off burns out all the same. +/datum/unit_test/voidcrew_cyberware_npcs/salvage + +/datum/unit_test/voidcrew_cyberware_npcs/salvage/Run() + // A lost arm takes its chrome with it, and the chrome burns where the arm lies. + var/mob/living/carbon/human/npc/chrome_dummy/maimed = dummy_with(list(/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis), spot(1, 1)) + var/obj/item/bodypart/arm = maimed.get_bodypart(BODY_ZONE_R_ARM) + TEST_ASSERT(arm.dismember(), "Fixture: the dummy's arm wouldn't come off") + sleep(0.5 SECONDS) + TEST_ASSERT(!length(loose_chrome()), "Chrome in an NPC's lost arm stayed working") + TEST_ASSERT_EQUAL(scrap_count(), 1, "Chrome in an NPC's lost arm didn't burn out") + qdel(maimed) + + // Gibbed: the salvage throws the chrome loose, and it burns out where it lands. + var/mob/living/carbon/human/npc/chrome_dummy/gibbed = dummy_with(list( + /obj/item/organ/cyberimp/cyberware/slipwire, + /obj/item/organ/cyberimp/cyberware/atlas/street, + ), spot(2, 2)) + gibbed.gib() + sleep(0.5 SECONDS) + TEST_ASSERT(!length(loose_chrome()), "A gibbed NPC left working chrome behind") + TEST_ASSERT_EQUAL(scrap_count(), 3, "A gibbed NPC's chrome didn't burn out") + + // Dusted: the same. + var/mob/living/carbon/human/npc/chrome_dummy/dusted = dummy_with(list(/obj/item/organ/cyberimp/cyberware/dermal_mesh), spot(3, 3)) + dusted.dust() + sleep(0.5 SECONDS) + TEST_ASSERT(!length(loose_chrome()), "A dusted NPC left working chrome behind") + TEST_ASSERT_EQUAL(scrap_count(), 4, "A dusted NPC's chrome didn't burn out") + +/// The frames against the gore layer: Atlas stops bursts; the Strut frame stops fractures, the skull +/// and jaw crits included, but a head still bursts and a limb still comes off. +/datum/unit_test/voidcrew_cyberware_npcs/frames + +/datum/unit_test/voidcrew_cyberware_npcs/frames/Run() + var/datum/hv_crit/skull = locate(/datum/hv_crit/skull) in GLOB.hv_crits + var/datum/hv_crit/jaw = locate(/datum/hv_crit/jaw) in GLOB.hv_crits + TEST_ASSERT(skull && jaw, "The skull and jaw crits are gone") + + var/mob/living/carbon/human/plain = allocate(/mob/living/carbon/human/consistent, spot(0, 0)) + TEST_ASSERT(skull.can_apply_carbon(plain, plain.get_bodypart(BODY_ZONE_HEAD), WOUND_BLUNT), "Fixture: a plain head can't take the skull crit") + + // Strut: bones only. + var/mob/living/carbon/human/strut_bearer = allocate(/mob/living/carbon/human/consistent, spot(1, 0)) + var/obj/item/organ/cyberimp/cyberware/atlas/street/strut = allocate(/obj/item/organ/cyberimp/cyberware/atlas/street) + TEST_ASSERT(strut.Insert(strut_bearer, special = TRUE), "Strut frame staging insert was refused") + var/obj/item/bodypart/strut_head = strut_bearer.get_bodypart(BODY_ZONE_HEAD) + TEST_ASSERT(!skull.can_apply_carbon(strut_bearer, strut_head, WOUND_BLUNT), "The skull caved in under a Strut frame") + TEST_ASSERT(!jaw.can_apply_carbon(strut_bearer, strut_head, WOUND_BLUNT), "The jaw shattered under a Strut frame") + var/datum/wound_pregen_data/fracture = GLOB.all_wound_pregen_data[/datum/wound/blunt/bone/severe] + TEST_ASSERT(!fracture.can_be_applied_to(strut_bearer.get_bodypart(BODY_ZONE_L_ARM), list(WOUND_BLUNT)), "An arm fractured under a Strut frame") + TEST_ASSERT(strut_head.can_dismember(), "A Strut frame made the head impossible to take off") + var/obj/item/bodypart/strut_leg = strut_bearer.get_bodypart(BODY_ZONE_R_LEG) + TEST_ASSERT(strut_leg.dismember(), "A Strut frame kept a leg on") + strut_bearer.adjustOxyLoss(160) + TEST_ASSERT_EQUAL(strut_bearer.stat, HARD_CRIT, "Fixture: the Strut bearer isn't in hard crit") + TEST_ASSERT(strut_bearer.hv_burst_head(NORTH), "A Strut frame stopped a head burst") + TEST_ASSERT_NULL(strut_bearer.get_bodypart(BODY_ZONE_HEAD), "A burst head is still on") + + // Atlas: nothing comes off, nothing bursts. + var/mob/living/carbon/human/atlas_bearer = allocate(/mob/living/carbon/human/consistent, spot(2, 0)) + var/obj/item/organ/cyberimp/cyberware/atlas/atlas = allocate(/obj/item/organ/cyberimp/cyberware/atlas) + TEST_ASSERT(atlas.Insert(atlas_bearer, special = TRUE), "Atlas frame staging insert was refused") + TEST_ASSERT(!skull.can_apply_carbon(atlas_bearer, atlas_bearer.get_bodypart(BODY_ZONE_HEAD), WOUND_BLUNT), "The skull caved in under an Atlas frame") + atlas_bearer.adjustOxyLoss(160) + TEST_ASSERT_EQUAL(atlas_bearer.stat, HARD_CRIT, "Fixture: the Atlas bearer isn't in hard crit") + TEST_ASSERT(!atlas_bearer.hv_burst_head(NORTH), "A head burst under an Atlas frame") + TEST_ASSERT_NOTNULL(atlas_bearer.get_bodypart(BODY_ZONE_HEAD), "An Atlas bearer lost the head") + +/// Plating: Hardweave sits in the body's physiology armour, so the crit gate counts it. +/datum/unit_test/voidcrew_cyberware_npcs/plating + +/datum/unit_test/voidcrew_cyberware_npcs/plating/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent, spot(0, 0)) + var/obj/item/bodypart/chest = bearer.get_bodypart(BODY_ZONE_CHEST) + TEST_ASSERT_EQUAL(round(hv_crit_cover_rating(bearer, chest, MELEE)), 0, "Fixture: a bare body has crit cover") + var/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave/plating = allocate(/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave) + TEST_ASSERT(plating.Insert(bearer, special = TRUE), "Hardweave staging insert was refused") + TEST_ASSERT_EQUAL(round(hv_crit_cover_rating(bearer, chest, MELEE)), 20, "Hardweave plating doesn't count toward the crit gate") + +/// A Lazarus node winds up as its bearer goes down, but a head that bursts in that second stays burst. +/datum/unit_test/voidcrew_cyberware_npcs/lazarus + +/datum/unit_test/voidcrew_cyberware_npcs/lazarus/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent, spot(0, 0)) + var/obj/item/organ/cyberimp/cyberware/lazarus/node = allocate(/obj/item/organ/cyberimp/cyberware/lazarus) + TEST_ASSERT(node.Insert(bearer, special = TRUE), "Lazarus staging insert was refused") + bearer.adjustOxyLoss(160) + TEST_ASSERT_EQUAL(bearer.stat, HARD_CRIT, "Fixture: the Lazarus bearer isn't in hard crit") + TEST_ASSERT(node.jolting, "The Lazarus node didn't wind up as its bearer went down") + TEST_ASSERT(bearer.hv_burst_head(NORTH), "Fixture: the head didn't burst") + TEST_ASSERT_EQUAL(bearer.stat, DEAD, "A burst head didn't kill") + sleep(2 SECONDS) + TEST_ASSERT_EQUAL(bearer.stat, DEAD, "A Lazarus node brought back a body with a burst head") + TEST_ASSERT_NULL(bearer.get_bodypart(BODY_ZONE_HEAD), "A burst head came back") + +/// The blades cut as blades: brute, edged, so they bleed, spray and slash. An aimed Mantis cut at +/// the head can open the throat. +/datum/unit_test/voidcrew_cyberware_npcs/blades + +/datum/unit_test/voidcrew_cyberware_npcs/blades/Run() + var/static/list/blade_types = list( + /obj/item/melee/energy/blade/hardlight/cyberware_mantis, + /obj/item/melee/energy/blade/hardlight/cyberware_mantis/street, + /obj/item/melee/energy/blade/hardlight/cyberware_mantis/scorch, + /obj/item/melee/cyberware_monowire, + /obj/item/melee/cyberware_monowire/live, + ) + for(var/blade_type in blade_types) + var/obj/item/blade = allocate(blade_type) + TEST_ASSERT_EQUAL(blade.damtype, BRUTE, "[blade_type] doesn't cut for brute, so it never bleeds") + TEST_ASSERT_EQUAL(hv_crit_wounding_type(NONE, blade), WOUND_SLASH, "[blade_type] doesn't slash") + + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, spot(0, 0)) + var/mob/living/carbon/human/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 0)) + var/obj/item/mantis = allocate(/obj/item/melee/energy/blade/hardlight/cyberware_mantis) + TEST_ASSERT(user.put_in_active_hand(mantis), "Fixture: the user couldn't hold the blade") + user.zone_selected = BODY_ZONE_HEAD + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + var/datum/hv_crit/landed = victim.hv_try_aimed_crit(head, BODY_ZONE_HEAD, WOUND_SLASH, mantis.force, mantis.wound_bonus, 0, EAST, mantis, user, 95) + TEST_ASSERT(istype(landed, /datum/hv_crit/throat), "An aimed Mantis cut at the head couldn't open the throat") From 7d1e7d7f21a479479639536a4d69fe7ce5b9eabe Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:56:38 -0800 Subject: [PATCH 151/472] Bosses: hide from shooters they can't reach, punch through glass with Gorilla Arms, crews at the site owe companions, longer tells - Hurt from behind glass, a shut door, a gap or off its site, a boss stops going after that hunter for a minute and gets out of their sight with a line, walking all the way to its cover. A far shooter with open ground between is still chased. Its walk is worked out from the tile it stands on. - It walks up to a barrier only while what gets it through is ready (the Juggernaut's charge, molotovs, grenades and breaching charges, the Heavy's burst), or when there is no way round. - With real Gorilla Arms and an empty hand it draws back a fist, then breaks a window, grille, table or door with the implant's own punches: only on its own site, never near a ship, never anything it can't break in six. - Every crew member of a hunting ship who is playing and at its site joins its posse, so each owes a companion. Crew elsewhere on the level and SSD crew don't count. - Ghost dash tell 0.6 s, flamer and slam 0.8 s. --- voidcrew/_DEFINES/bounty_bosses.dm | 43 +- voidcrew/modules/bounties/bounty_boss.dm | 643 +++++++++++++++++- voidcrew/modules/bounties/bounty_boss_kits.dm | 63 ++ .../bounties/strings/bounty_criminals.json | 50 ++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_bounty_boss_reach.dm | 492 ++++++++++++++ 6 files changed, 1263 insertions(+), 29 deletions(-) create mode 100644 voidcrew/modules/unit_tests/voidcrew_bounty_boss_reach.dm diff --git a/voidcrew/_DEFINES/bounty_bosses.dm b/voidcrew/_DEFINES/bounty_bosses.dm index b647004b3fd..3821a09db85 100644 --- a/voidcrew/_DEFINES/bounty_bosses.dm +++ b/voidcrew/_DEFINES/bounty_bosses.dm @@ -60,6 +60,33 @@ /// A spot any player within this many tiles can see is in view. (phase D) #define BOUNTY_BOSS_REINFORCE_SIGHT 13 +// ===== HURT FROM WHERE IT CAN'T REACH (review r1 F4, r2) ===== + +/// Someone who hurt it from where it can't get at them (behind glass, a shut door or a gap, or off its site) is shunned this long: it doesn't go after them, and it keeps out of their sight +#define BOUNTY_BOSS_SHUN_TIME (60 SECONDS) +/// How far it walks for cover, in steps +#define BOUNTY_BOSS_COVER_RANGE 7 +/// How far a shooter is taken to see, for cover, at the least: one farther off sees as far as a clear line from them goes (a scope, or a shot from across the site) +#define BOUNTY_BOSS_COVER_SIGHT 9 +/// How far it works out its walk to someone behind something, in tiles, and how long that walk holds while it stands on the same tile. Someone farther off is in reach if the walk gets it at least half this much closer to them. +#define BOUNTY_BOSS_REACH_RANGE 10 +#define BOUNTY_BOSS_REACH_RETHINK (2 SECONDS) +/// It says a line as it breaks for cover at most this often +#define BOUNTY_BOSS_COVER_BARK_COOLDOWN (30 SECONDS) + +// ===== GORILLA ARMS THROUGH GLASS ===== + +/// The tell before its Gorilla Arms go at a window, grille, table or door: the fist drawn back, the pistons heard, the tile lit. At least 0.8 s (decision 5). +#define BOUNTY_BOSS_SMASH_WINDUP (0.9 SECONDS) +/// What one Gorilla Arms punch does to a structure, used only to count the punches something takes. The punch itself is the implant's (CYBERWARE_GORILLA_STRUCTURE_DAMAGE, which ware_military_arms.dm undefines); a test checks the two agree. +#define BOUNTY_BOSS_SMASH_BLOW 40 +/// It only goes at something it breaks in this many punches; anything tougher, it goes round or takes cover +#define BOUNTY_BOSS_SMASH_MOST_BLOWS 6 +/// The pause after the tell before its next ability may start +#define BOUNTY_BOSS_SMASH_RECOVER (1 SECONDS) +/// The tell's glow and floor mark: the piston punch's orange +#define BOUNTY_BOSS_SMASH_COLOR "#ff8a1f" + // ===== SUMMONED THINGS: CAPS AND LIFETIMES (P4) ===== /// Fire pools (flamer floors and molotovs) burning at once, server-wide @@ -95,8 +122,8 @@ #define BOUNTY_JUGGERNAUT_WALL_STAMINA 30 #define BOUNTY_JUGGERNAUT_WALL_PARALYZE (1 SECONDS) #define BOUNTY_JUGGERNAUT_WALL_KNOCKDOWN (3 SECONDS) -/// Slam: both fists up, a red ring -#define BOUNTY_JUGGERNAUT_SLAM_WINDUP (0.7 SECONDS) +/// Slam: both fists up, a red ring. 0.7 -> 0.8 s after review r2, long enough to step out of on sight. +#define BOUNTY_JUGGERNAUT_SLAM_WINDUP (0.8 SECONDS) #define BOUNTY_JUGGERNAUT_SLAM_COOLDOWN (7 SECONDS) #define BOUNTY_JUGGERNAUT_SLAM_DAMAGE 20 #define BOUNTY_JUGGERNAUT_SLAM_KNOCKDOWN (1.5 SECONDS) @@ -105,8 +132,8 @@ /// Its torch: lit, and it never goes out (a real tg torch, held from the start: it doesn't holster) #define BOUNTY_PYROMANIAC_TORCH /obj/item/flashlight/flare/torch/everburning -/// Flamer: a pilot light hiss, an orange cone -#define BOUNTY_PYROMANIAC_FLAMER_WINDUP (0.5 SECONDS) +/// Flamer: a pilot light hiss, an orange cone. 0.5 -> 0.8 s after review r2, long enough to step out of on sight. +#define BOUNTY_PYROMANIAC_FLAMER_WINDUP (0.8 SECONDS) #define BOUNTY_PYROMANIAC_FLAMER_COOLDOWN (5 SECONDS) #define BOUNTY_PYROMANIAC_FLAMER_RANGE 3 /// Full width of the cone, in degrees @@ -158,8 +185,8 @@ /// Its blade: a real combat knife, carried in a pocket until the fight starts, and every cut it lands is the knife's (CB-9) #define BOUNTY_GHOST_KNIFE /obj/item/knife/combat -/// Dash: a blur line to the target, the Cascade lattice firing as it goes (OC-6 ruling) -#define BOUNTY_GHOST_DASH_WINDUP (0.3 SECONDS) +/// Dash: a blur line to the target, the Cascade lattice firing as it goes (OC-6 ruling). 0.3 -> 0.6 s after review r2: at 0.3 s only someone already moving could step off the line. +#define BOUNTY_GHOST_DASH_WINDUP (0.6 SECONDS) #define BOUNTY_GHOST_DASH_COOLDOWN (5 SECONDS) #define BOUNTY_GHOST_DASH_RANGE 5 /// Cloak: a shimmer, then a faint distortion. Only how it looks: nothing misses it for it (OC-6 ruling, CB-12) @@ -214,3 +241,7 @@ #define BB_BOUNTY_BOSS_FALL_BACK_FROM "BB_bounty_boss_fall_back_from" /// world.time a Heavy's belt change in progress gives up #define BB_BOUNTY_BOSS_RELOAD_UNTIL "BB_bounty_boss_reload_until" +/// Where it is walking to, to go through the window, door or wall in its way +#define BB_BOUNTY_BOSS_SPOT "BB_bounty_boss_spot" +/// Where it is walking to, out of the sight of someone it can't get at +#define BB_BOUNTY_BOSS_COVER "BB_bounty_boss_cover" diff --git a/voidcrew/modules/bounties/bounty_boss.dm b/voidcrew/modules/bounties/bounty_boss.dm index 8c4b1680f80..6633324a6a7 100644 --- a/voidcrew/modules/bounties/bounty_boss.dm +++ b/voidcrew/modules/bounties/bounty_boss.dm @@ -25,13 +25,23 @@ * How a fight goes: * - A boss waits (its activity, if P3 gives it one) until it spots a hunter, is attacked or is * confronted. Then it turns hostile: every player within BOUNTY_BOSS_POSSE_RANGE who can see it - * joins its posse. Each hunter in the posse beyond the first brings one more companion in from out of - * their view, one at a time (OC-8, CB-1), up to BOUNTY_BOSS_REINFORCEMENTS_MAX. + * joins its posse, and so does every crew member of a ship hunting it who is playing and at its site + * (on it, on its dock strip or aboard a ship landed there), seen or not. Each hunter in the posse + * beyond the first brings one more companion in from out of their view, one at a time (OC-8, CB-1), + * up to BOUNTY_BOSS_REINFORCEMENTS_MAX. Never health. * - Its AI: soft crit and breaking free first (the human NPC base's and the core's); nothing while * busy or disabled; worn out, it falls back; its stimulant; a target; its kit's gear; an ability, - * never the same one twice in a row; the kit's way of closing in or keeping away; its melee; patching - * itself up with nobody about; and with nobody in sight, the hunt for the nearest hunter still on its - * site. It calms down after BOUNTY_BOSS_CALM_TIME with nobody around. + * never the same one twice in a row; the kit's way of closing in or keeping away; up to and through + * whatever is in its way, if its kit can go through it; its melee; patching itself up with nobody + * about; with nobody in sight, the hunt for the nearest hunter still on its site that it can get at; + * and with nobody it can get at, out of the sight of those shooting at it from where it can't. It + * calms down after BOUNTY_BOSS_CALM_TIME with nobody around. + * - Hurt by someone it can't get at (behind glass it can't break, a shut door, a gap, or off its site), + * it doesn't stand there: it stops going after them for BOUNTY_BOSS_SHUN_TIME and gets out of their + * sight (boss_find_cover()), however far off they are, with a line as it goes. Someone far off with a + * way to them on foot is in reach, and it goes after them. With real Gorilla Arms and an empty hand it + * punches through a window, grille, table or door it can break, after a tell (boss_start_smash()); the + * punches are the implant's own, and it only starts if it can break everything on the way. * - Every ability winds up first, where everyone can see and hear it (telegraph()), and does its * harm only when the wind-up is over (effect()). Downed, restrained, stunned, knocked down or dead * in between (tg's states, CB-24), and nothing happens. @@ -45,6 +55,10 @@ /// Trait source for standing still while busy with an ability #define BOUNTY_BOSS_BUSY_TRAIT "bounty_boss_busy" +/// What boss_busy says while its Gorilla Arms go at a window, grille, table or door +#define BOUNTY_BOSS_BUSY_SMASH "smash" +/// The glow on it while they do +#define BOUNTY_BOSS_SMASH_FILTER "bounty_boss_smash" /// Fire pools the bosses have burning (/obj/effect/bounty_boss_fire_pool), for the cap GLOBAL_LIST_EMPTY(bounty_boss_fire_pools) @@ -139,6 +153,20 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) var/boss_stimulants_used = 0 /// Its fall back is over and it is still worn out: its next plan takes the second pen var/boss_second_wind_due = FALSE + /// Hunters who hurt it from where it couldn't get at them: weakref -> world.time of the last such hit (boss_note_reach()) + var/list/boss_shunned = list() + /// The tiles it can walk to (boss_walkable()): turf -> steps, the tile it stood on then, and when that was worked out + var/list/boss_walk_cache + var/turf/boss_walk_origin + var/boss_walk_at = 0 + /// world.time it may look for new cover + var/boss_cover_rethink_at = 0 + /// world.time it may say a line again as it breaks for cover + var/boss_cover_bark_at = 0 + /// What its Gorilla Arms are going at (boss_start_smash()), the floor mark on it, and the punches thrown so far + var/datum/weakref/boss_smash_target + var/datum/weakref/boss_smash_mark + var/boss_smash_blows = 0 /mob/living/carbon/human/npc/bounty_criminal/boss/Initialize(mapload, datum/human_npc_order/order) . = ..() @@ -158,6 +186,9 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) boss_reinforcement_refs = null boss_winding_ref = null boss_last_attacker = null + boss_shunned = null + boss_walk_cache = null + boss_walk_origin = null return ..() /mob/living/carbon/human/npc/bounty_criminal/boss/death(gibbed) @@ -310,6 +341,7 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) var/datum/action/cooldown/mob_cooldown/bounty_boss/winding = boss_winding_ref?.resolve() boss_winding_ref = null winding?.cancel_windup() + boss_end_smash() if(boss_busy) boss_clear_busy() boss_on_interrupted() @@ -369,7 +401,8 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /** * Turns hostile, with `instigator` (who attacked, confronted or was spotted) in its posse if they * count (boss_counts_for_posse()). The first time, everyone who counts within - * BOUNTY_BOSS_POSSE_RANGE and can see it joins too, mech pilots included, and it says its intro line. + * BOUNTY_BOSS_POSSE_RANGE and can see it joins too, mech pilots included, then the rest of every + * hunting crew at its site (boss_engage_hunting_crews()), and it says its intro line. */ /mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_turn_hostile(mob/living/instigator) if(stat == DEAD) @@ -387,9 +420,43 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) for(var/obj/vehicle/sealed/mecha/mech in view(BOUNTY_BOSS_POSSE_RANGE, src)) for(var/mob/living/pilot in mech.occupants) boss_engage(pilot, announce = FALSE) + boss_engage_hunting_crews() boss_bark("intro") return TRUE +/** + * Every crew member of a ship hunting its bounty who is playing (not SSD) and at its site joins its + * posse, seen or not (boss_crew_at_site()), so each owes a companion like anyone in sight: one hunter + * can't wake it alone while the rest of the crew wait on the dock strip or aboard their landed ship + * (review r1). Crew on another site sharing its level, or aboard a ship anywhere else, don't count + * (review r2). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_engage_hunting_crews() + var/datum/criminal_bounty/posting = posting() + if(!posting || !get_turf(src)) + return + for(var/obj/structure/overmap/ship/ship as anything in posting.hunter_ships()) + for(var/datum/mind/member as anything in ship.ship_team?.members) + var/mob/living/body = member?.current + if(isliving(body) && GET_CLIENT(body) && boss_crew_at_site(body, ship)) + boss_engage(body, announce = FALSE) + +/** + * Whether `body`, of the crew of `ship`, is at its site for its posse: on its level, and on the site + * itself, or anywhere in the site's rectangle, which holds its dock strip and the ships landed on it. + * For a site with no rectangle (a pirate ship), aboard `ship` close alongside it. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_crew_at_site(mob/living/body, obj/structure/overmap/ship/ship) + var/turf/here = get_turf(src) + var/turf/where = get_turf(body) + if(!here || !where || where.z != here.z) + return FALSE + if(body_on_site(where)) + return TRUE + if(site_bounds) + return where.z == site_bounds[5] && where.x >= site_bounds[1] && where.x <= site_bounds[3] && where.y >= site_bounds[2] && where.y <= site_bounds[4] + return ship?.is_aboard(where) && get_dist(here, where) <= BOUNTY_BOSS_HUNT_RANGE + /// With no hunter left on its site, it stops hunting and waits again. Its posse stays. /mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_calm_down() boss_hostile = FALSE @@ -532,12 +599,19 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return who /** - * Whether it chooses to go after `target` (C4, H2, M2, L1): an awake hunter not on its side, or a mech - * with one in it (a pilot is hurt through the mech); standing on its site, never in a ship, a hangar or - * an outpost; someone it may attack from where it is (P2's may_attack()). This is its choice of target - * only: what its fire and blasts catch is boss_effect_hits() (CB-11). + * Whether it chooses to go after `target`: one it may go after (boss_can_go_after()), and not one it + * keeps away from (boss_shuns()). */ /mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_valid_target(atom/target) + return boss_can_go_after(target) && !boss_shuns(target) + +/** + * Whether it may go after `target` (C4, H2, M2, L1): an awake hunter not on its side, or a mech with + * one in it (a pilot is hurt through the mech); standing on its site, never in a ship, a hangar or an + * outpost; someone it may attack from where it is (P2's may_attack()). This is its choice of target + * only: what its fire and blasts catch is boss_effect_hits() (CB-11). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_can_go_after(atom/target) if(QDELETED(target) || target == src) return FALSE if(!boss_reach_ok(get_turf(target))) @@ -623,26 +697,36 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) for(var/mob/living/hunter as anything in hunters) body_add_grudge(hunter) boss_turn_hostile(hunter) + boss_note_reach(hunter) return TRUE -/// The nearest hunter (or their mech) still on its site within BOUNTY_BOSS_HUNT_RANGE, for the hunt. Only players with a client count, from SSmobs' list for its level. -/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_nearest_hunter() +/** + * The nearest hunter (or their mech) still on its site within BOUNTY_BOSS_HUNT_RANGE that it can get + * at (boss_can_reach()), for the hunt: it never walks back out to stand in front of someone behind + * glass. Only players with a client count, from SSmobs' list for its level; a test passes its own + * `candidates`. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_nearest_hunter(list/candidates) var/turf/here = get_turf(src) - if(!here || here.z > length(SSmobs.clients_by_zlevel)) + if(!here) return null + if(isnull(candidates)) + if(here.z > length(SSmobs.clients_by_zlevel)) + return null + candidates = SSmobs.clients_by_zlevel[here.z] var/atom/best var/best_distance = BOUNTY_BOSS_HUNT_RANGE + 1 - for(var/mob/living/person as anything in SSmobs.clients_by_zlevel[here.z]) + for(var/mob/living/person in candidates) var/atom/target = boss_target_for(person) - if(!boss_valid_target(target)) - continue var/turf/spot = get_turf(target) - if(!spot || !leash_ok(spot)) + if(!spot || spot.z != here.z) continue var/distance = get_dist(here, spot) - if(distance < best_distance) - best = target - best_distance = distance + // Nearest first: the costly checks only for someone nearer than the best so far + if(distance >= best_distance || !leash_ok(spot) || !boss_valid_target(target) || !boss_can_reach(target)) + continue + best = target + best_distance = distance return best // ----- where it may reach, and what it may break ----- @@ -800,6 +884,439 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) boss_walls_left-- return TRUE +// ===== HURT FROM WHERE IT CAN'T REACH (review r1 F4, r2) ===== + +/** + * Whether it can get at `target` (a person, or a mech) from where it stands: it may go after them + * there, and it is beside them, or nothing on the straight way stops it, or its kit goes through + * everything that does (boss_gets_through()), or it can walk round to them (boss_walks_to()). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_can_reach(atom/target) + if(!boss_can_go_after(target)) + return FALSE + var/turf/here = get_turf(src) + var/turf/there = get_turf(target) + if(!here || !there || here.z != there.z) + return FALSE + if(Adjacent(target)) + return TRUE + var/list/blockers = boss_line_blockers(here, there) + if(!length(blockers) || boss_gets_through(blockers)) + return TRUE + return boss_walks_to(there) + +/** + * Whether it can walk to `there`, or beside it (someone up on a table or a crate), through nothing it + * can't open (boss_walkable()). Someone beyond BOUNTY_BOSS_REACH_RANGE is past the walk it works out: + * they count as reachable if that walk reaches its edge at least half its range closer to them, so a + * far shooter with open ground between is chased, and one across a gap or behind a wall near it is + * not (review r2). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_walks_to(turf/there) + var/turf/here = get_turf(src) + if(!here || !there || here.z != there.z) + return FALSE + var/list/steps = boss_walkable() + if(!isnull(steps[there])) + return TRUE + for(var/direction in GLOB.cardinals) + var/turf/beside = get_step(there, direction) + if(beside && !isnull(steps[beside]) && !boss_border_between(beside, there)) + return TRUE + var/distance = get_dist(here, there) + if(distance <= BOUNTY_BOSS_REACH_RANGE) + return FALSE + var/closer_than = distance - BOUNTY_BOSS_REACH_RANGE / 2 + for(var/turf/spot as anything in steps) + if(get_dist(here, spot) >= BOUNTY_BOSS_REACH_RANGE && get_dist(spot, there) <= closer_than) + return TRUE + return FALSE + +/** + * Whether its kit gets through `barrier` (from boss_line_blockers()) to someone behind it: with real + * Gorilla Arms and an empty hand to punch with (boss_gorilla_goes_through()). Each kit adds its own + * (bounty_boss_kits.dm): a charge, a grenade or a molotov thrown over, a breaching charge, rounds that + * chip through. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_goes_through(atom/barrier) + return boss_gorilla_goes_through(barrier) + +/// All or nothing: whether its kit goes through every one of `blockers` (boss_line_blockers()), so it never breaks a grille only to meet glass it can't +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gets_through(list/blockers) + if(!length(blockers)) + return TRUE + for(var/atom/blocker as anything in blockers) + if(!boss_goes_through(blocker)) + return FALSE + return TRUE + +/** + * Whether what gets it through `barrier` is ready now, so walking up to it is worth more than going + * round: Gorilla Arms that aren't already at something. Each kit adds its own ability that breaks or + * crosses it, off cooldown. With it on cooldown, the boss goes round on foot if it can (the breach + * planner). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_breaks_through_now(atom/barrier) + return boss_busy != BOUNTY_BOSS_BUSY_SMASH && boss_gorilla_goes_through(barrier) + +/// Its ability of `ability_type`, if it has one off cooldown +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_ability_off_cooldown(ability_type) + for(var/datum/action/cooldown/mob_cooldown/bounty_boss/ability as anything in boss_abilities) + if(!QDELETED(ability) && istype(ability, ability_type)) + return ability.next_use_time <= world.time ? ability : null + return null + +/// What it can open on its own feet, for working out a walk: the access its AI's pathing uses +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_pass_info() + return new /datum/can_pass_info(src, ai_controller ? ai_controller.get_access() : get_access()) + +/** + * The first thing on the straight way from `start` to `finish` that stops it walking: a wall, a gap + * (space, a drop, lava, a chasm), or something solid it can't open or climb (a window, a grille, a + * shut door, a table). Null if the way is clear. People never count. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_barrier_between(turf/start, turf/finish) + var/list/blockers = boss_line_blockers(start, finish, TRUE) + return length(blockers) ? blockers[1] : null + +/** + * Everything on the straight way from `start` to `finish` that stops it walking, in order: walls, + * gaps, and every solid thing it can't open or climb on each step (a grille and the window on the + * same tile are both there). With `first_only`, only the first step that is blocked. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_line_blockers(turf/start, turf/finish, first_only = FALSE) + . = list() + if(!start || !finish || start.z != finish.z) + return + var/datum/can_pass_info/pass_info = boss_pass_info() + var/turf/previous = start + for(var/turf/spot as anything in get_line(start, finish)) + if(spot == start) + continue + if(spot.density) + . += spot + else if(spot != finish && (isspaceturf(spot) || isopenspaceturf(spot) || isgroundlessturf(spot) || islava(spot) || ischasm(spot))) + . += spot + else if(previous.LinkBlockedWithAccess(spot, pass_info)) + var/list/on_step = boss_blockers_on_step(previous, spot, pass_info) + if(length(on_step)) + // A full-tile window blocks the step off its tile as well as the step onto it: once is enough + . |= on_step + else + . += spot + if(first_only && length(.)) + return + previous = spot + +/** + * What stops a step from `origin` onto `destination`: windows or doors on the edge between them, a + * window filling `origin` (tg's pathing counts one as blocking the step off its tile too), and + * everything solid on `destination`. A diagonal step is checked both ways round, giving what stops the + * first way. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_blockers_on_step(turf/origin, turf/destination, datum/can_pass_info/pass_info) + . = list() + var/direction = get_dir(origin, destination) + if(origin.x != destination.x && origin.y != destination.y) + for(var/half in list(direction & (NORTH|SOUTH), direction & (EAST|WEST))) + var/turf/middle = get_step(origin, half) + if(!middle) + continue + if(middle.density) + return list(middle) + var/list/found = boss_blockers_on_step(origin, middle, pass_info) + boss_blockers_on_step(middle, destination, pass_info) + if(length(found)) + return found + return + for(var/obj/border in origin) + if(border.density && ((border.flags_1 & ON_BORDER_1) || istype(border, /obj/structure/window)) && !border.CanAStarPass(direction, pass_info)) + . += border + var/reverse = get_dir(destination, origin) + for(var/obj/thing in destination) + if(thing.density && !thing.CanAStarPass(reverse, pass_info)) + . += thing + +/// Whether a window or door stands on the edge between two tiles side by side +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_border_between(turf/origin, turf/destination) + var/direction = get_dir(origin, destination) + for(var/obj/border in origin) + if(border.density && (border.flags_1 & ON_BORDER_1) && (border.dir & direction)) + return TRUE + var/reverse = get_dir(destination, origin) + for(var/obj/border in destination) + if(border.density && (border.flags_1 & ON_BORDER_1) && (border.dir & reverse)) + return TRUE + return FALSE + +/** + * Every tile it could walk to from where it stands, within BOUNTY_BOSS_REACH_RANGE and on its leash, + * through nothing it can't open: turf -> steps, nearest first. Worked out again once it stands on + * another tile, or after BOUNTY_BOSS_REACH_RETHINK on the same one, so the steps and the range are + * always counted from where it is (review r2). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_walkable() + var/turf/here = get_turf(src) + if(!here) + return list() + if(boss_walk_cache && boss_walk_origin == here && world.time < boss_walk_at + BOUNTY_BOSS_REACH_RETHINK) + return boss_walk_cache + var/datum/can_pass_info/pass_info = boss_pass_info() + var/list/steps = list() + steps[here] = 0 + var/list/queue = list(here) + var/index = 0 + while(index < length(queue)) + index++ + var/turf/spot = queue[index] + var/next_steps = steps[spot] + 1 + for(var/direction in GLOB.cardinals) + var/turf/next = get_step(spot, direction) + if(!next || next.density || !isnull(steps[next]) || get_dist(here, next) > BOUNTY_BOSS_REACH_RANGE) + continue + if(spot.LinkBlockedWithAccess(next, pass_info) || !leash_ok(next)) + continue + steps[next] = next_steps + queue += next + boss_walk_cache = steps + boss_walk_origin = here + boss_walk_at = world.time + return steps + +/// `hunter` just hurt it: if it can't get at them where they are, it keeps away from them for BOUNTY_BOSS_SHUN_TIME +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_note_reach(mob/living/hunter) + if(!boss_can_act() || boss_can_reach(boss_target_for(hunter))) + return FALSE + boss_shunned[WEAKREF(hunter)] = world.time + return TRUE + +/// Whether it keeps away from `target` (a person, or a mech): one of them hurt it lately from where it couldn't get at them, and it still can't +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_shuns(atom/target) + if(!length(boss_shunned)) + return FALSE + for(var/mob/living/person as anything in boss_people_in(target)) + var/last = person.weak_reference ? boss_shunned[person.weak_reference] : null + if(last && world.time < last + BOUNTY_BOSS_SHUN_TIME) + return !boss_can_reach(target) + return FALSE + +/// The hunters it shuns who are still up and still out of its reach (their mechs, for those in one): whose sight it keeps out of. Forgets those it has shunned long enough. +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_watchers() + . = list() + for(var/datum/weakref/ref as anything in boss_shunned.Copy()) + var/mob/living/person = ref.resolve() + if(QDELETED(person) || world.time >= boss_shunned[ref] + BOUNTY_BOSS_SHUN_TIME) + boss_shunned -= ref + continue + if(person.stat != CONSCIOUS) + continue + var/atom/watcher = boss_target_for(person) + if(!boss_can_reach(watcher)) + . |= watcher + +/** + * Whether any of `watchers` can see `spot`: a clear line from them on its level. Within + * BOUNTY_BOSS_COVER_SIGHT, and as far as the line goes past that, so a shooter further off (a scope, + * the dock strip) never leaves it standing in the open thinking it is hidden (review r2). + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_seen_from(turf/spot, list/watchers) + spot = get_turf(spot) + if(!spot) + return FALSE + for(var/atom/watcher as anything in watchers) + var/turf/from = get_turf(watcher) + if(!from || from.z != spot.z) + continue + if(can_see(from, spot, max(BOUNTY_BOSS_COVER_SIGHT, get_dist(from, spot)))) + return TRUE + return FALSE + +/// It breaks for cover from someone it can't get at: a line for it, at most every BOUNTY_BOSS_COVER_BARK_COOLDOWN, so it reads as biding its time, not running +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_cover_bark() + if(world.time < boss_cover_bark_at) + return FALSE + boss_cover_bark_at = world.time + BOUNTY_BOSS_COVER_BARK_COOLDOWN + boss_bark("cover") + return TRUE + +/// How far `spot` is from the nearest of `watchers` +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_distance_from(turf/spot, list/watchers) + . = INFINITY + for(var/atom/watcher as anything in watchers) + . = min(., get_dist(spot, watcher)) + +/** + * Cover from `watchers`: the nearest tile it can walk to within BOUNTY_BOSS_COVER_RANGE steps that + * none of them can see. With nowhere hidden, the one farthest from the nearest of them. Null if + * where it stands is as good as any. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_find_cover(list/watchers) + var/list/steps = boss_walkable() + var/turf/here = get_turf(src) + var/turf/far = here + var/far_distance = boss_distance_from(here, watchers) + // In order of steps, nearest first + for(var/turf/spot as anything in steps) + if(steps[spot] > BOUNTY_BOSS_COVER_RANGE || (spot != here && boss_someone_on(spot))) + continue + if(!boss_seen_from(spot, watchers)) + return spot + var/distance = boss_distance_from(spot, watchers) + if(distance > far_distance) + far = spot + far_distance = distance + return far == here ? null : far + +/// Whether someone else stands on `spot`, so it can't +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_someone_on(turf/spot) + for(var/mob/living/other in spot) + if(other != src && other.density) + return TRUE + return FALSE + +/// The tile it walks to, to go through `barrier` at `target`: the last one before it on the straight way, if it can walk there +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_spot_before(atom/barrier, atom/target) + var/turf/here = get_turf(src) + var/turf/barrier_turf = get_turf(barrier) + if(!here || !barrier_turf) + return null + var/turf/before + for(var/turf/spot as anything in get_line(here, barrier_turf)) + if(spot == barrier_turf) + break + before = spot + if(!before || before == here) + return null + var/list/steps = boss_walkable() + return isnull(steps[before]) ? null : before + +// ===== GORILLA ARMS THROUGH GLASS ===== + +/// Its working Gorilla Arms: the real implant, not EMP'd, browned out or failing +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gorilla_arms() + . = list() + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in organs) + var/datum/component/cyberware/ware = arm.GetComponent(/datum/component/cyberware) + if(ware?.is_running()) + . += arm + +/// The hand index of `arm`'s own hand, if that hand is empty (the implant only punches with its own fist), else 0 +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gorilla_hand(obj/item/organ/cyberimp/cyberware/gorilla_arms/arm) + var/obj/item/bodypart/limb = get_bodypart(arm.zone) + if(!limb?.held_index || !isnull(get_item_for_held_index(limb.held_index))) + return 0 + return limb.held_index + +/// Whether it has a working Gorilla Arm with an empty hand to punch with +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_has_gorilla_fist() + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in boss_gorilla_arms()) + if(boss_gorilla_hand(arm)) + return TRUE + return FALSE + +/// Whether its Gorilla Arms get it through `barrier`: one it may smash (boss_can_smash()), with a fist free to do it. Airlocks are left to the arms' own door forcing. +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_gorilla_goes_through(atom/barrier) + return boss_has_gorilla_fist() && boss_can_smash(barrier) + +/** + * Whether its Gorilla Arms may go at `barrier`: a window, a grille, a table or a door that isn't an + * airlock, where it may break things (boss_may_break(): its own site, indoors, never near a ship), and + * one the implant's punches break in at most BOUNTY_BOSS_SMASH_MOST_BLOWS through its armour. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_can_smash(atom/barrier) + if(!isobj(barrier) || istype(barrier, /obj/machinery/door/airlock)) + return FALSE + if(!istype(barrier, /obj/structure/window) && !istype(barrier, /obj/structure/grille) && !istype(barrier, /obj/structure/table) && !istype(barrier, /obj/machinery/door)) + return FALSE + var/obj/thing = barrier + if(!thing.uses_integrity || !boss_may_break(thing)) + return FALSE + var/blow = thing.run_atom_armor(BOUNTY_BOSS_SMASH_BLOW, BRUTE, MELEE, get_dir(thing, src)) + return blow > 0 && thing.get_integrity() <= blow * BOUNTY_BOSS_SMASH_MOST_BLOWS + +/** + * Its Gorilla Arms go at `barrier`, right beside it. The tell first, for BOUNTY_BOSS_SMASH_WINDUP: + * rooted, an orange glow, the barrier's tile lit and the pistons heard. Then real punches with its + * empty Gorilla fists (boss_smash_blow()), each the implant's own, until the barrier is down, the + * punches run out or something stops it. Nobody is hurt by it. Returns TRUE if it started. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_start_smash(atom/barrier) + if(!boss_can_use_ability() || !ai_controller || !Adjacent(barrier) || !boss_has_gorilla_fist() || !boss_can_smash(barrier)) + return FALSE + var/smash_time = BOUNTY_BOSS_SMASH_WINDUP + BOUNTY_BOSS_SMASH_MOST_BLOWS * 1 SECONDS + var/serial = boss_set_busy(BOUNTY_BOSS_BUSY_SMASH, smash_time + 1 SECONDS) + boss_next_ability_at = max(boss_next_ability_at, world.time + BOUNTY_BOSS_SMASH_WINDUP + BOUNTY_BOSS_SMASH_RECOVER) + boss_smash_target = WEAKREF(barrier) + boss_smash_blows = 0 + face_atom(barrier) + visible_message(span_boldwarning("[src] draws back a piston-driven fist at [barrier]!"), blind_message = span_hear("You hear pistons hiss.")) + playsound(src, 'sound/machines/piston/piston_raise.ogg', 60, TRUE) + add_filter(BOUNTY_BOSS_SMASH_FILTER, 2, outline_filter(1, BOUNTY_BOSS_SMASH_COLOR)) + boss_smash_mark = WEAKREF(new /obj/effect/temp_visual/bounty_boss_mark(get_turf(barrier), smash_time, BOUNTY_BOSS_SMASH_COLOR)) + addtimer(CALLBACK(src, PROC_REF(boss_smash_blow), serial), BOUNTY_BOSS_SMASH_WINDUP, TIMER_DELETE_ME) + return TRUE + +/** + * One punch of the smash under `serial` at its barrier: the empty fist of a working Gorilla Arm whose + * own cooldown is over is made the active hand, and the barrier is clicked with it in combat mode, the + * way a player punches (controller.ai_interact()). The implant does the damage. The next punch follows + * when the body and an arm are ready again. Returns TRUE if it threw one. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_smash_blow(serial) + if(!boss_still(BOUNTY_BOSS_BUSY_SMASH, serial)) + return FALSE + var/obj/barrier = boss_smash_target?.resolve() + if(QDELETED(barrier) || !barrier.density || !Adjacent(barrier) || boss_smash_blows >= BOUNTY_BOSS_SMASH_MOST_BLOWS || !boss_may_break(barrier) || !boss_has_gorilla_fist()) + boss_end_smash(serial) + return FALSE + var/threw = FALSE + if(next_move <= world.time) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in boss_gorilla_arms()) + var/hand = boss_gorilla_hand(arm) + if(!hand || !COOLDOWN_FINISHED(arm, slam_cooldown)) + continue + swap_hand(hand, silent = TRUE) + if(get_active_held_item()) + continue + var/is_door = istype(barrier, /obj/machinery/door) + boss_smash_blows++ + ai_controller.ai_interact(target = barrier, combat_mode = TRUE) + threw = TRUE + // A door it punches down spends its wall budget, as one any ability breaks does + if(is_door && (QDELETED(barrier) || barrier.get_integrity() <= 0)) + boss_walls_left-- + break + if(QDELETED(barrier) || !barrier.density || boss_smash_blows >= BOUNTY_BOSS_SMASH_MOST_BLOWS) + boss_end_smash(serial) + return threw + addtimer(CALLBACK(src, PROC_REF(boss_smash_blow), serial), boss_smash_wait(), TIMER_DELETE_ME) + return threw + +/// How long until it can throw its next smash punch: its body's click and the soonest arm's own cooldown, at least a tick +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_smash_wait() + var/soonest = INFINITY + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in boss_gorilla_arms()) + if(boss_gorilla_hand(arm)) + soonest = min(soonest, arm.slam_cooldown) + if(soonest == INFINITY) + soonest = world.time + return max(1, next_move - world.time, soonest - world.time) + +/// The smash is over, or never was: the tell goes, and with `serial` (or none) it is no longer busy with it +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_end_smash(serial) + boss_end_smash_telegraph() + boss_smash_target = null + boss_smash_blows = 0 + if(boss_busy == BOUNTY_BOSS_BUSY_SMASH && (!serial || serial == boss_busy_serial)) + boss_clear_busy(serial) + +/// The smash's glow and floor mark go +/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_end_smash_telegraph() + remove_filter(BOUNTY_BOSS_SMASH_FILTER) + var/obj/effect/mark = boss_smash_mark?.resolve() + boss_smash_mark = null + if(!QDELETED(mark)) + qdel(mark) + // ===== SIGNALS ===== /** @@ -1228,9 +1745,11 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /** * Its planning, on a real human body (contract 2.4): soft crit and breaking free first; nothing while * it is busy or can't act; worn out, it falls back; its stimulant; its target; its kit's gear; drawing - * its weapon once its fight has started; an ability; the kit's way of closing in or keeping away; its - * melee with what it holds; patching itself up with nobody about; and with nobody in sight, the hunt - * for the nearest hunter still on its site. Bosses never surrender. + * its weapon once its fight has started; an ability; the kit's way of closing in or keeping away; up to + * and through whatever is in its way; its melee with what it holds; patching itself up with nobody + * about; with nobody in sight, the hunt for the nearest hunter still on its site that it can get at; + * and with nobody it can get at, out of the sight of those shooting at it from where it can't. Bosses + * never surrender. */ /datum/ai_controller/basic_controller/bounty_boss blackboard = list( @@ -1252,9 +1771,11 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /datum/ai_planning_subtree/human_npc_arm/bounty_boss, /datum/ai_planning_subtree/bounty_boss_abilities, /datum/ai_planning_subtree/bounty_boss_engage, + /datum/ai_planning_subtree/bounty_boss_breach, /datum/ai_planning_subtree/human_npc_melee, /datum/ai_planning_subtree/bounty_treat_self, /datum/ai_planning_subtree/bounty_boss_hunt, + /datum/ai_planning_subtree/bounty_boss_cover, ) /datum/ai_controller/basic_controller/bounty_boss/TryPossessPawn(atom/new_pawn) @@ -1481,6 +2002,80 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) controller.queue_behavior(/datum/ai_behavior/travel_towards/adjacent, BB_BOUNTY_BOSS_PREY) return SUBTREE_RETURN_FINISH_PLANNING +/** + * Its target stands behind things its kit goes through, every one of them (boss_gets_through()): it + * walks up to the first, and with Gorilla Arms right beside it, smashes it. A kit's own ability (a + * charge, a grenade, a breaching charge) has had its turn already. It only does this while what breaks + * through is ready (boss_breaks_through_now()), or when there is no way round on foot; otherwise it + * leaves the walk round to the melee planner rather than wait at the glass (review r2). + */ +/datum/ai_planning_subtree/bounty_boss_breach + +/datum/ai_planning_subtree/bounty_boss_breach/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!istype(boss) || QDELETED(target) || boss.Adjacent(target)) + return + var/turf/here = get_turf(boss) + var/turf/there = get_turf(target) + var/list/blockers = boss.boss_line_blockers(here, there) + if(!length(blockers) || !boss.boss_gets_through(blockers)) + return + var/atom/barrier = blockers[1] + if(!boss.boss_breaks_through_now(barrier) && boss.boss_walks_to(there)) + return + if(get_dist(here, barrier) <= 1) + if(boss.boss_start_smash(barrier)) + return SUBTREE_RETURN_FINISH_PLANNING + return + var/turf/spot = boss.boss_spot_before(barrier, target) + if(!spot) + return + if(controller.blackboard[BB_BOUNTY_BOSS_SPOT] != spot) + controller.CancelActions() + controller.set_blackboard_key(BB_BOUNTY_BOSS_SPOT, spot) + controller.queue_behavior(/datum/ai_behavior/travel_towards, BB_BOUNTY_BOSS_SPOT) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * Hurt lately by someone it can't get at (boss_shuns()), and nobody it can reach in sight or on the + * hunt: it gets out of their sight (boss_find_cover()) and waits there, rather than stand in the open + * for them, with a line as it goes (boss_cover_bark()). With nowhere hidden in reach it backs off as + * far from them as it can, and keeps going until it gets there. Anyone it can reach brings it out. + */ +/datum/ai_planning_subtree/bounty_boss_cover + +/datum/ai_planning_subtree/bounty_boss_cover/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn + if(!istype(boss) || !length(boss.boss_shunned) || controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET)) + return + var/list/watchers = boss.boss_watchers() + if(!length(watchers)) + return + var/turf/here = get_turf(boss) + if(!boss.boss_seen_from(here, watchers)) + controller.clear_blackboard_key(BB_BOUNTY_BOSS_COVER) + return SUBTREE_RETURN_FINISH_PLANNING + var/turf/spot = controller.blackboard[BB_BOUNTY_BOSS_COVER] + // On its way: it keeps walking (every plan, or the walk it queued last time is dropped) until it + // gets there, unless someone took the spot, or it may look again and finds the spot in their sight. + if(spot && spot != here && !boss.boss_someone_on(spot) && (world.time < boss.boss_cover_rethink_at || !boss.boss_seen_from(spot, watchers))) + controller.queue_behavior(/datum/ai_behavior/travel_towards, BB_BOUNTY_BOSS_COVER) + return SUBTREE_RETURN_FINISH_PLANNING + if(world.time < boss.boss_cover_rethink_at) + return SUBTREE_RETURN_FINISH_PLANNING + boss.boss_cover_rethink_at = world.time + BOUNTY_BOSS_REACH_RETHINK + spot = boss.boss_find_cover(watchers) + if(!spot) + controller.clear_blackboard_key(BB_BOUNTY_BOSS_COVER) + return + if(controller.blackboard[BB_BOUNTY_BOSS_COVER] != spot) + controller.CancelActions() + controller.set_blackboard_key(BB_BOUNTY_BOSS_COVER, spot) + boss.boss_cover_bark() + controller.queue_behavior(/datum/ai_behavior/travel_towards, BB_BOUNTY_BOSS_COVER) + return SUBTREE_RETURN_FINISH_PLANNING + // ===== LOOKS ===== /// A mini-boss's clothes before its kit's: plain dark clothes @@ -1491,3 +2086,5 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) gloves = /obj/item/clothing/gloves/color/black #undef BOUNTY_BOSS_BUSY_TRAIT +#undef BOUNTY_BOSS_BUSY_SMASH +#undef BOUNTY_BOSS_SMASH_FILTER diff --git a/voidcrew/modules/bounties/bounty_boss_kits.dm b/voidcrew/modules/bounties/bounty_boss_kits.dm index 471bf1922e7..d984e857eab 100644 --- a/voidcrew/modules/bounties/bounty_boss_kits.dm +++ b/voidcrew/modules/bounties/bounty_boss_kits.dm @@ -27,6 +27,13 @@ * real rounds into a cone (a hit while it aims spoils the shot); throws real barrier grenades for * cover (CB-26); changes its own belts. Slow. * + * Behind something it can't walk through, a hunter is out of reach unless the kit gets through it + * (boss_goes_through(), bounty_boss.dm): the Juggernaut's charge, the Demolitionist's grenades and + * breaching charge, the Pyromaniac's molotovs, the Heavy's rounds, and real Gorilla Arms on any kit. + * The Ghost has nothing for it, and takes cover instead. It walks up to the barrier only while that + * ability is off cooldown (boss_breaks_through_now()), or when there is no way round; otherwise it goes + * round on foot. + * * The shared vars are declared in bounty_types.dm and nowhere else; a var only P4 uses goes here * with a `boss_` prefix, or on the /boss subtypes. */ @@ -53,6 +60,20 @@ /mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss_gear_text() return "Riot plates cover [p_them()] from neck to boots." +// Its charge goes straight through a table, a chair, a plain window or a grille +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss_goes_through(atom/barrier) + if(..()) + return TRUE + var/datum/action/cooldown/mob_cooldown/bounty_boss/charge/charge = locate() in boss_abilities + return isobj(barrier) && charge?.smashes(barrier) && boss_may_break(barrier) + +// With its charge ready +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss_breaks_through_now(atom/barrier) + if(..()) + return TRUE + var/datum/action/cooldown/mob_cooldown/bounty_boss/charge/charge = boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/charge) + return isobj(barrier) && charge?.smashes(barrier) && boss_may_break(barrier) + /datum/outfit/bounty_boss/juggernaut name = "Bounty mini-boss: Juggernaut" uniform = /obj/item/clothing/under/syndicate/combat @@ -307,6 +328,14 @@ /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_gear_text() return "[p_Their()] fire suit is scorched black." +// A molotov goes over anything it can see across +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_goes_through(atom/barrier) + return ..() || !barrier.opacity + +// With a molotov ready +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_breaks_through_now(atom/barrier) + return ..() || (!barrier.opacity && !isnull(boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/molotov))) + // The firefighter suit and helmet are what keep its own fire off it (tg's thermal protection, CB-7). The torch // doesn't holster, so it is in hand from the start. /datum/outfit/bounty_boss/pyromaniac @@ -449,6 +478,21 @@ /mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/boss_gear_text() return "A bandolier of grenades is slung across [p_their()] chest." +// A grenade goes over anything it can see across, and a breaching charge through an interior wall or door +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/boss_goes_through(atom/barrier) + if(..() || !barrier.opacity) + return TRUE + return (iswallturf(barrier) || istype(barrier, /obj/machinery/door)) && boss_may_break(barrier) + +// With a grenade ready for anything it can see across, or a breaching charge for an interior wall or door +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/boss_breaks_through_now(atom/barrier) + if(..()) + return TRUE + if(!barrier.opacity && boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/grenade)) + return TRUE + return (iswallturf(barrier) || istype(barrier, /obj/machinery/door)) && boss_may_break(barrier) \ + && !isnull(boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/breach)) + /** * It keeps BOUNTY_DEMOLITIONIST_KEEP_MIN to _MAX tiles from its target while it has something it * could throw from there. It fights hand to hand when someone is right on it, or when nothing is @@ -1024,6 +1068,25 @@ /mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_gear_text() return "[p_They()] [p_are()] in heavy armour." +// Its rounds chip through a window or grille it may break, and fly over a gap +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_goes_through(atom/barrier) + if(..()) + return TRUE + if(isturf(barrier)) + return !barrier.density + return (istype(barrier, /obj/structure/window) || istype(barrier, /obj/structure/grille)) && boss_may_break(barrier) + +// With its burst ready and a belt in its gun +/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_breaks_through_now(atom/barrier) + if(..()) + return TRUE + var/obj/item/gun/gun = boss_gun() + if(!gun?.can_shoot() || isnull(boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/burst))) + return FALSE + if(isturf(barrier)) + return !barrier.density + return (istype(barrier, /obj/structure/window) || istype(barrier, /obj/structure/grille)) && boss_may_break(barrier) + // The L6 has no sling and won't holster, so it is in hand from the start, its other hand free to fire it. // A spare box rides in the bag; a barrier grenade in each pocket. /datum/outfit/bounty_boss/heavy diff --git a/voidcrew/modules/bounties/strings/bounty_criminals.json b/voidcrew/modules/bounties/strings/bounty_criminals.json index e87678a2b73..43c079e5250 100644 --- a/voidcrew/modules/bounties/strings/bounty_criminals.json +++ b/voidcrew/modules/bounties/strings/bounty_criminals.json @@ -678,6 +678,56 @@ "More targets. Fine.", "Hope you all brought armor." ] + }, + "juggernaut_cover": { + "any": [ + "Hiding back there? I'll wait.", + "Come out and face me.", + "You can't stay in there forever.", + "Come out from behind there!", + "I'll be right here when you come out.", + "Shooting from back there? Real brave." + ] + }, + "pyromaniac_cover": { + "any": [ + "Hiding? I can wait.", + "You'll have to come out sometime.", + "Come out, come out. It's warm out here.", + "I've got all day, and plenty of fuel.", + "Stay in there, then. I'll wait.", + "Shooting from back there? Come closer." + ] + }, + "demolitionist_cover": { + "any": [ + "Come out from behind there!", + "Hiding? I've got time.", + "You can't hide in there forever.", + "Shoot from back there all you like. I'll wait.", + "I know where you are.", + "Sooner or later, you come out." + ] + }, + "ghost_cover": { + "any": [ + "You'll come out eventually.", + "I'll be right here.", + "Hiding won't help you.", + "Take your time. I'm not going anywhere.", + "Come and find me.", + "I can wait all day." + ] + }, + "heavy_cover": { + "any": [ + "Can't hide in there forever.", + "Come out from behind there!", + "Stay in there, then. I'll wait.", + "Peek out again. Go on.", + "I've got all day.", + "Shooting from back there? Cute." + ] } } } diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 7a79a7e24bc..95a4a9b2ecf 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -42,6 +42,7 @@ #include "voidcrew_bounty_admin.dm" #include "voidcrew_bounty_ai.dm" #include "voidcrew_bounty_board.dm" +#include "voidcrew_bounty_boss_reach.dm" #include "voidcrew_bounty_bosses.dm" #include "voidcrew_bounty_criminal.dm" #include "voidcrew_bounty_identity.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_boss_reach.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_boss_reach.dm new file mode 100644 index 00000000000..34c113ec7f9 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_boss_reach.dm @@ -0,0 +1,492 @@ +/** + * Bounty hunting: the mini-bosses' reach, cover, Gorilla Arms smash, longer tells and the whole hunting + * crew at the site, for voidcrew/modules/bounties/bounty_boss.dm and bounty_boss_kits.dm (port package + * C5 of PR #507 onto the human bosses). + * + * Fork defines are included after the tests, so a test uses the literal value with a comment naming the + * define. Every test calls the boss's procs, or one planning subtree, directly with the mob in place and + * its AI off; none waits on a timer or an AI walk. + */ + +/// Shared helpers for these tests; not a test itself +/datum/unit_test/voidcrew_bounty_boss/reach + abstract_type = /datum/unit_test/voidcrew_bounty_boss/reach + /// Turfs a test changed -> the type each had first, put back when it ends (outside the room they are space) + var/list/reach_turfs = list() + +/datum/unit_test/voidcrew_bounty_boss/reach/Destroy() + for(var/turf/changed as anything in reach_turfs) + changed.ChangeTurf(reach_turfs[changed]) + reach_turfs = null + return ..() + +// Remembers what the turf was, not plain floor: outside the test room it is space +/datum/unit_test/voidcrew_bounty_boss/reach/change_turf(turf/where, new_type) + if(isnull(reach_turfs[where])) + reach_turfs[where] = where.type + return where.ChangeTurf(new_type) + +/// The tile `dx`, `dy` from the test room's bottom-left corner, outside the room too (space on the test level until a test floors it) +/datum/unit_test/voidcrew_bounty_boss/reach/proc/outside(dx, dy) + return locate(clamp(run_loc_floor_bottom_left.x + dx, 1, world.maxx), clamp(run_loc_floor_bottom_left.y + dy, 1, world.maxy), run_loc_floor_bottom_left.z) + +/// Floors the rectangle `dx`..`dx2`, `dy`..`dy2` from the room's corner (outside the room), to be put back when the test ends +/datum/unit_test/voidcrew_bounty_boss/reach/proc/floor_area(dx, dy, dx2, dy2) + for(var/x_offset in dx to dx2) + for(var/y_offset in dy to dy2) + change_turf(outside(x_offset, y_offset), /turf/open/floor/iron) + +/// Whether `controller`'s last plan has `behavior_type` among its planned behaviours +/datum/unit_test/voidcrew_bounty_boss/reach/proc/planned(datum/ai_controller/controller, behavior_type) + return !!controller.planned_behaviors[GET_AI_BEHAVIOR(behavior_type)] + +/// Runs `boss`'s planning subtree of `subtree_type` once, as a planning pass would, and returns what it returned +/datum/unit_test/voidcrew_bounty_boss/reach/proc/plan_with(mob/living/carbon/human/npc/bounty_criminal/boss/boss, subtree_type) + var/datum/ai_controller/controller = boss.ai_controller + var/datum/ai_planning_subtree/subtree = locate(subtree_type) in controller.planning_subtrees + TEST_ASSERT_NOTNULL(subtree, "[boss.type] doesn't plan with [subtree_type]") + controller.planned_behaviors.Cut() + return subtree.SelectBehaviors(controller, 1) + +/// A boss of `boss_type` at `where` with its AI off, so it stays where the test puts it +/datum/unit_test/voidcrew_bounty_boss/reach/proc/boss_at(boss_type, turf/where) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = allocate(boss_type, where) + boss.ai_controller?.set_ai_status(AI_STATUS_OFF) + return boss + +/// A Juggernaut at `where` with a real pair of Gorilla Arms put in, the way an NPC gets its chrome (npc_install_cyberware()) +/datum/unit_test/voidcrew_bounty_boss/reach/proc/gorilla_boss(turf/where) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, where) + TEST_ASSERT_NOTNULL(boss.npc_install_cyberware(/obj/item/organ/cyberimp/cyberware/gorilla_arms), "The right Gorilla Arm didn't go in") + TEST_ASSERT_NOTNULL(boss.npc_install_cyberware(/obj/item/organ/cyberimp/cyberware/gorilla_arms/left), "The left Gorilla Arm didn't go in") + TEST_ASSERT_EQUAL(length(boss.boss_gorilla_arms()), 2, "The boss's Gorilla Arms aren't both working") + return boss + +/// Every arm's own punch cooldown over, and its body ready to click +/datum/unit_test/voidcrew_bounty_boss/reach/proc/ready_fists(mob/living/carbon/human/npc/bounty_criminal/boss/boss) + ready_hands(boss) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in boss.organs) + arm.slam_cooldown = 0 + +// ===== LONGER TELLS AND COVER LINES (review r2) ===== + +/datum/unit_test/voidcrew_bounty_boss/reach/tells + +/datum/unit_test/voidcrew_bounty_boss/reach/tells/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/ghost = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/ghost, spot(0, 0)) + var/datum/action/cooldown/mob_cooldown/bounty_boss/dash/dash = ability_of(ghost, /datum/action/cooldown/mob_cooldown/bounty_boss/dash) + TEST_ASSERT(abs(dash.windup - 6) < 0.01, "The Ghost's dash tell is [dash.windup / 10] s") // BOUNTY_GHOST_DASH_WINDUP 0.6 s + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/pyro = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac, spot(2, 0)) + var/datum/action/cooldown/mob_cooldown/bounty_boss/flamer/flamer = ability_of(pyro, /datum/action/cooldown/mob_cooldown/bounty_boss/flamer) + TEST_ASSERT(abs(flamer.windup - 8) < 0.01, "The flamer's tell is [flamer.windup / 10] s") // BOUNTY_PYROMANIAC_FLAMER_WINDUP 0.8 s + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/juggernaut = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(4, 0)) + var/datum/action/cooldown/mob_cooldown/bounty_boss/slam/slam = ability_of(juggernaut, /datum/action/cooldown/mob_cooldown/bounty_boss/slam) + TEST_ASSERT(abs(slam.windup - 8) < 0.01, "The slam's tell is [slam.windup / 10] s") // BOUNTY_JUGGERNAUT_SLAM_WINDUP 0.8 s + + // Every kit has its own lines for breaking cover (boss_bark("cover")), as many as any other context + var/list/lines = bounty_identity_strings("lines") + for(var/kit in list("juggernaut", "pyromaniac", "demolitionist", "ghost", "heavy")) // BOUNTY_KIT_* + TEST_ASSERT(length(lines["[kit]_cover"]?["any"]) >= 6, "The [kit] has fewer than 6 lines for taking cover") + +// ===== THE WHOLE HUNTING CREW AT ITS SITE OWES COMPANIONS (review r1, r2; OC-8) ===== + +/** + * Turning hostile, it counts every crew member of a ship hunting it who is playing and at its site, + * seen or not: on the site, on its dock strip. One hunter can't wake it alone while the crew waits by + * the ship. Crew elsewhere on its level (another site in the same lattice), SSD crew, and strangers + * don't count. Each one beyond the first owes it a companion; nothing changes its health. + */ +/datum/unit_test/voidcrew_bounty_boss/reach/crews + /// The test crew, let go of its minds before it goes + var/datum/team/voidcrew/crew + +/datum/unit_test/voidcrew_bounty_boss/reach/crews/Run() + board_test_begin() + var/datum/criminal_bounty/posting = board_test_posting(3, null, 3400, 2, "boss") + posting.record.kit = "juggernaut" // BOUNTY_KIT_JUGGERNAUT + var/obj/structure/overmap/ship/ship = board_test_ship() + crew = new + crew.ship = ship + ship.ship_team = crew + var/mob/living/carbon/human/consistent/tagger = hunter_at(spot(2, 0)) + // Out of its sight past the room's walls, but on its site + var/mob/living/carbon/human/consistent/on_site = hunter_at(outside(0, 20)) + // On the dock strip, at the bottom of its site + var/mob/living/carbon/human/consistent/on_dock = hunter_at(outside(3, -2)) + // On its level, off its site: another planet in the lattice + var/mob/living/carbon/human/consistent/elsewhere = hunter_at(outside(40, 0)) + // On its site, but SSD + var/mob/living/carbon/human/consistent/asleep = hunter_at(outside(1, 20)) + // On its site and playing, but not hunting it + var/mob/living/carbon/human/consistent/stranger = hunter_at(outside(2, 20)) + TEST_ASSERT(get_dist(on_site, run_loc_floor_bottom_left) > 9, "The on-site crewmate is within the boss's sight") // BOUNTY_BOSS_POSSE_RANGE + for(var/mob/living/carbon/human/consistent/playing as anything in list(tagger, on_site, on_dock, elsewhere, stranger)) + playing.mock_client = allocate(/datum/client_interface) + for(var/mob/living/carbon/human/consistent/member as anything in list(tagger, on_site, on_dock, elsewhere, asleep)) + crew.join_member(member.mind) + TEST_ASSERT_EQUAL(posting.hunt(ship), TRUE, "The test ship couldn't hunt the bounty") + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = spawn_bounty_criminal(posting.record, spot(0, 0), posting) + if(boss) + allocated += boss + TEST_ASSERT(istype(boss), "The posted Juggernaut spawned as [boss?.type]") + boss.ai_controller?.set_ai_status(AI_STATUS_OFF) + // Its site: the room and well around it, with a dock strip along the bottom + var/turf/corner = run_loc_floor_bottom_left + boss.site_bounds = list(corner.x - 3, corner.y - 3, corner.x + 25, corner.y + 25, corner.z) + boss.body_dock_top_y = corner.y - 1 + TEST_ASSERT(boss.body_on_site(get_turf(on_site)), "The test's on-site spot is off the site") + TEST_ASSERT(!boss.body_on_site(get_turf(on_dock)) && boss.body_on_dock_strip(get_turf(on_dock)), "The test's dock strip spot isn't on the dock strip") + TEST_ASSERT(!boss.body_on_site(get_turf(elsewhere)), "The test's off-site spot is on the site") + + boss.body_add_grudge(tagger) + boss.boss_turn_hostile(tagger) + TEST_ASSERT(boss.boss_engaged[REF(tagger)], "The hunter who woke it isn't in its posse") + TEST_ASSERT(boss.boss_engaged[REF(on_site)], "A crewmate out of its sight on its site isn't in its posse") + TEST_ASSERT(boss.boss_engaged[REF(on_dock)], "A crewmate waiting on the dock strip isn't in its posse") + TEST_ASSERT(!boss.boss_engaged[REF(elsewhere)], "A crewmate on another site on its level joined its posse") + TEST_ASSERT(!boss.boss_engaged[REF(asleep)], "An SSD crewmate joined its posse") + TEST_ASSERT(!boss.boss_engaged[REF(stranger)], "Someone out of its sight who isn't hunting it joined its posse") + // Three of the crew at its site: two companions owed (one per hunter beyond the first), and its body as it was + TEST_ASSERT_EQUAL(boss.boss_reinforcements_owed + boss.boss_reinforcements_sent, 2, "Woken with three of a crew at its site, it owes the wrong number of companions") + TEST_ASSERT_NOTNULL(boss.boss_reinforce_timer, "The companions owed weren't scheduled") + TEST_ASSERT_EQUAL(boss.maxHealth, 100, "The crew changed the boss's health") // MAX_LIVING_HEALTH + for(var/datum/mind/member as anything in crew.members.Copy()) + member.ship_teams = null + crew.members.Cut() + crew.ship = null + ship.ship_team = null + QDEL_NULL(crew) + board_test_end() + +// ===== HURT FROM WHERE IT CAN'T REACH (review r1 F4) ===== + +/** + * Shot from behind glass it can't break, with no way round: it doesn't go after the shooter, and it + * breaks for cover out of their sight on its own side, with a line. Once they come round, it goes after + * them again. + */ +/datum/unit_test/voidcrew_bounty_boss/reach/out_of_reach + +/datum/unit_test/voidcrew_bounty_boss/reach/out_of_reach/Run() + // A wall down the middle of the room: reinforced glass low, solid wall high + for(var/dy in 0 to 2) + allocate(/obj/structure/window/reinforced/fulltile, spot(2, dy)) + change_turf(spot(2, 3), /turf/closed/wall/r_wall) + change_turf(spot(2, 4), /turf/closed/wall/r_wall) + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/ghost = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/ghost, spot(0, 0)) + var/datum/ai_controller/controller = ghost.ai_controller + var/mob/living/carbon/human/consistent/sniper = hunter_at(spot(4, 4)) + var/atom/barrier = ghost.boss_barrier_between(get_turf(ghost), get_turf(sniper)) + TEST_ASSERT(istype(barrier, /obj/structure/window), "The glass between them isn't what stops it: [barrier?.type]") + TEST_ASSERT(!ghost.boss_goes_through(barrier), "The Ghost goes through reinforced glass") + TEST_ASSERT(!ghost.boss_can_reach(sniper), "The Ghost can reach someone behind glass with no way round") + TEST_ASSERT(ghost.boss_valid_target(sniper), "Before a shot, the Ghost won't go after someone it can see") + + // Shot from there: it keeps away from them + ghost.body_add_grudge(sniper) + TEST_ASSERT(ghost.boss_note_attacker(sniper), "The shot wasn't noted") + TEST_ASSERT(ghost.boss_shuns(sniper), "Shot from behind glass, the Ghost doesn't keep away from the shooter") + TEST_ASSERT(!ghost.boss_valid_target(sniper), "Shot from behind glass, the Ghost still goes after the shooter") + TEST_ASSERT_NULL(ghost.boss_revenge_target(), "Shot from behind glass, the Ghost turns on the shooter") + var/datum/targeting_strategy/strategy = GET_TARGETING_STRATEGY(/datum/targeting_strategy/basic/human_npc/bounty_boss) + TEST_ASSERT(!strategy.can_attack(ghost, sniper, 9), "The boss's targeting picks a shooter it can't get at") // BOUNTY_BOSS_POSSE_RANGE + TEST_ASSERT_NULL(ghost.boss_nearest_hunter(list(sniper)), "The hunt walks it out at a shooter behind glass") + var/list/watchers = ghost.boss_watchers() + TEST_ASSERT(sniper in watchers, "The shooter isn't one it hides from") + TEST_ASSERT(ghost.boss_seen_from(get_turf(ghost), watchers), "The test needs the shooter to see the Ghost where it stands") + var/turf/cover = ghost.boss_find_cover(watchers) + TEST_ASSERT_NOTNULL(cover, "The Ghost found no cover on its side of the glass") + TEST_ASSERT(!can_see(sniper, cover, 9), "The Ghost's cover is in the shooter's sight") // BOUNTY_BOSS_COVER_SIGHT + TEST_ASSERT(cover.x < spot(2, 0).x, "The Ghost's cover is on the shooter's side") + + // Its planner breaks for that cover, with a line of its own + var/datum/component/bounty_identity_voice/voice = ghost.LoadComponent(/datum/component/bounty_identity_voice) + voice.line_cooldown = 0 + voice.last_line = null + TEST_ASSERT_EQUAL(plan_with(ghost, /datum/ai_planning_subtree/bounty_boss_cover), SUBTREE_RETURN_FINISH_PLANNING, "Seen by a shooter it can't get at, it planned nothing") + TEST_ASSERT_NOTNULL(controller.blackboard["BB_bounty_boss_cover"], "Seen by a shooter it can't get at, it stays put") // BB_BOUNTY_BOSS_COVER + TEST_ASSERT(planned(controller, /datum/ai_behavior/travel_towards), "It doesn't walk for cover") + TEST_ASSERT(voice.last_line in bounty_identity_strings("lines")["ghost_cover"]["any"], "Breaking for cover, the Ghost said [voice.last_line || "nothing"]") + controller.CancelActions() + controller.clear_blackboard_key("BB_bounty_boss_cover") + + // Come round: fair game again + sniper.forceMove(spot(1, 0)) + TEST_ASSERT(ghost.boss_can_reach(sniper), "The Ghost can't reach someone right beside it") + TEST_ASSERT(!ghost.boss_shuns(sniper), "The Ghost keeps away from someone who came round to it") + TEST_ASSERT(ghost.boss_valid_target(sniper), "The Ghost won't go after someone who came round to it") + TEST_ASSERT(!(sniper in ghost.boss_watchers()), "It still hides from someone who came round to it") + +/** + * A sniper far off (review r2): past the walk it works out, and past BOUNTY_BOSS_COVER_SIGHT. On its + * site with open ground round the table between them, it goes after them. Off its site (where it can't + * follow), it sees that they see it and backs off, keeps walking until it gets there, and hides + * behind a wall when one is near. Its walk is always worked out from the tile it stands on. + */ +/datum/unit_test/voidcrew_bounty_boss/reach/far_sniper + +/datum/unit_test/voidcrew_bounty_boss/reach/far_sniper/Run() + // Open floor north of the room: 21 wide, 5 deep + floor_area(0, 10, 20, 14) + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/ghost = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/ghost, outside(0, 12)) + var/datum/ai_controller/controller = ghost.ai_controller + allocate(/obj/structure/table, outside(2, 12)) + var/mob/living/carbon/human/consistent/sniper = hunter_at(outside(13, 12)) + TEST_ASSERT(get_dist(ghost, sniper) > 10, "The sniper isn't past the walk it works out") // BOUNTY_BOSS_REACH_RANGE + TEST_ASSERT(istype(ghost.boss_barrier_between(get_turf(ghost), get_turf(sniper)), /obj/structure/table), "The table isn't between them") + + // On its site, open ground round the table: in reach, and it goes after them + TEST_ASSERT(ghost.boss_can_reach(sniper), "A sniper 13 tiles off across open ground is out of its reach") + ghost.body_add_grudge(sniper) + TEST_ASSERT(ghost.boss_note_attacker(sniper), "The shot wasn't noted") + TEST_ASSERT(!ghost.boss_shuns(sniper), "Shot from 13 tiles off across open ground, it keeps away from the shooter") + TEST_ASSERT(ghost.boss_valid_target(sniper), "Shot from 13 tiles off, it won't go after the shooter") + TEST_ASSERT(!(sniper in ghost.boss_watchers()), "It hides from a shooter it can walk to") + TEST_ASSERT_EQUAL(ghost.boss_nearest_hunter(list(sniper)), sniper, "The hunt doesn't pick the shooter") + controller.set_blackboard_key("BB_bounty_boss_prey", sniper) // BB_BOUNTY_BOSS_PREY + controller.set_blackboard_key("BB_bounty_boss_hunt_at", world.time + 10 SECONDS) // BB_BOUNTY_BOSS_HUNT_AT + TEST_ASSERT_EQUAL(plan_with(ghost, /datum/ai_planning_subtree/bounty_boss_hunt), SUBTREE_RETURN_FINISH_PLANNING, "The hunt planned nothing") + TEST_ASSERT(planned(controller, /datum/ai_behavior/travel_towards/adjacent), "It doesn't walk after the shooter") + controller.CancelActions() + controller.clear_blackboard_key("BB_bounty_boss_prey") + + // Walled in near it, the same sniper is out of reach: the walk never gets it closer to them + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/boxed = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/ghost, outside(0, 12)) + for(var/dy in 10 to 14) + change_turf(outside(1, dy), /turf/closed/wall/r_wall) + TEST_ASSERT(!boxed.boss_can_reach(sniper), "Walled in, it can reach a sniper 13 tiles off") + for(var/dy in 10 to 14) + change_turf(outside(1, dy), /turf/open/floor/iron) + qdel(boxed) + + // Off its site (in their ship), 12 tiles off with a clear line: it can't follow, and it knows they see it + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/hider = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/ghost, outside(7, 12)) + controller = hider.ai_controller + var/turf/ship_floor = make_ship_tile(outside(19, 12)) + sniper.forceMove(ship_floor) + hider.body_add_grudge(sniper) + TEST_ASSERT(hider.boss_note_attacker(sniper), "The shot from the ship wasn't noted") + TEST_ASSERT(hider.boss_shuns(sniper), "Shot from off its site, it doesn't keep away from the shooter") + var/list/watchers = hider.boss_watchers() + TEST_ASSERT(sniper in watchers, "The shooter in their ship isn't one it hides from") + TEST_ASSERT(hider.boss_seen_from(get_turf(hider), watchers), "12 tiles off with a clear line, it thinks the shooter can't see it") + + // Nowhere hidden in reach: it backs off as far as it can, saying so, and keeps going (review r2 bug 3) + var/datum/component/bounty_identity_voice/voice = hider.LoadComponent(/datum/component/bounty_identity_voice) + voice.line_cooldown = 0 + voice.last_line = null + var/turf/start = get_turf(hider) + TEST_ASSERT_EQUAL(plan_with(hider, /datum/ai_planning_subtree/bounty_boss_cover), SUBTREE_RETURN_FINISH_PLANNING, "In the open in a shooter's sight, it planned nothing") + var/turf/away = controller.blackboard["BB_bounty_boss_cover"] // BB_BOUNTY_BOSS_COVER + TEST_ASSERT_NOTNULL(away, "In the open in a shooter's sight, it stays put") + TEST_ASSERT(get_dist(away, sniper) > get_dist(start, sniper), "It doesn't back away from the shooter") + TEST_ASSERT(planned(controller, /datum/ai_behavior/travel_towards), "It doesn't walk away from the shooter") + TEST_ASSERT(voice.last_line in bounty_identity_strings("lines")["ghost_cover"]["any"], "Breaking for cover, the Ghost said [voice.last_line || "nothing"]") + // The next plan, still on its way: it keeps walking + TEST_ASSERT_EQUAL(plan_with(hider, /datum/ai_planning_subtree/bounty_boss_cover), SUBTREE_RETURN_FINISH_PLANNING, "On its way, it planned nothing") + TEST_ASSERT(planned(controller, /datum/ai_behavior/travel_towards), "On its way to the farthest spot, it dropped its own walk") + TEST_ASSERT_EQUAL(controller.blackboard["BB_bounty_boss_cover"], away, "On its way, it changed its mind") // BB_BOUNTY_BOSS_COVER + controller.CancelActions() + + // A wall near it: it hides behind that, out of the shooter's line + for(var/dy in 11 to 13) + change_turf(outside(5, dy), /turf/closed/wall/r_wall) + hider.boss_walk_cache = null + var/turf/cover = hider.boss_find_cover(watchers) + TEST_ASSERT_NOTNULL(cover, "With a wall near it, it found no cover from a shooter 12 tiles off") + TEST_ASSERT(!hider.boss_seen_from(cover, watchers), "Its cover is in the shooter's line") + TEST_ASSERT(cover.x < outside(5, 12).x, "Its cover isn't behind the wall") + + // Its walk is worked out from where it stands, not where it stood + var/list/steps = hider.boss_walkable() + TEST_ASSERT_EQUAL(steps[get_turf(hider)], 0, "Its walk doesn't start where it stands") + hider.forceMove(outside(8, 10)) + steps = hider.boss_walkable() + TEST_ASSERT_EQUAL(steps[get_turf(hider)], 0, "A step later, its walk still starts where it stood") + +/** + * The breach planner (review r2): with its charge on cooldown and a way round, the Juggernaut goes + * round rather than wait at the glass; with the charge ready it walks up; with no way round it waits + * at the glass for the charge. + */ +/datum/unit_test/voidcrew_bounty_boss/reach/breach + +/datum/unit_test/voidcrew_bounty_boss/reach/breach/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 2)) + var/datum/ai_controller/controller = boss.ai_controller + var/mob/living/carbon/human/consistent/behind = hunter_at(spot(4, 2)) + var/obj/structure/window/fulltile/pane = allocate(/obj/structure/window/fulltile, spot(2, 2)) + var/datum/action/cooldown/mob_cooldown/bounty_boss/charge/charge = ability_of(boss, /datum/action/cooldown/mob_cooldown/bounty_boss/charge) + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, behind) + TEST_ASSERT(boss.boss_goes_through(pane), "The Juggernaut's charge doesn't go through plain glass") + var/list/blockers = boss.boss_line_blockers(get_turf(boss), get_turf(behind)) + TEST_ASSERT_EQUAL(length(blockers), 1, "One plain window reads as [length(blockers)] things in its way") + TEST_ASSERT(boss.boss_gets_through(blockers), "The Juggernaut's charge doesn't get through one plain window") + TEST_ASSERT_EQUAL(controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET], behind, "The test's target didn't stick") + + // Charge on cooldown, a way round: no breach, so the melee planner walks it round + charge.next_use_time = world.time + 100 SECONDS + TEST_ASSERT(!boss.boss_breaks_through_now(pane), "The Juggernaut's charge is ready on cooldown") + TEST_ASSERT_NULL(plan_with(boss, /datum/ai_planning_subtree/bounty_boss_breach), "With its charge on cooldown and a way round, it went at the glass") + TEST_ASSERT_NULL(controller.blackboard["BB_bounty_boss_spot"], "With its charge on cooldown and a way round, it picked a spot at the glass") // BB_BOUNTY_BOSS_SPOT + + // Charge ready: it walks up to the glass + charge.next_use_time = 0 + TEST_ASSERT(boss.boss_breaks_through_now(pane), "The Juggernaut's charge isn't ready off cooldown") + TEST_ASSERT_EQUAL(plan_with(boss, /datum/ai_planning_subtree/bounty_boss_breach), SUBTREE_RETURN_FINISH_PLANNING, "With its charge ready, it didn't go at the glass") + TEST_ASSERT_EQUAL(controller.blackboard["BB_bounty_boss_spot"], spot(1, 2), "With its charge ready, it isn't walking up to the glass") // BB_BOUNTY_BOSS_SPOT + TEST_ASSERT(planned(controller, /datum/ai_behavior/travel_towards), "With its charge ready, it doesn't walk") + controller.CancelActions() + controller.clear_blackboard_key("BB_bounty_boss_spot") + + // No way round: it waits at the glass for its charge + for(var/dy in list(0, 1, 3, 4)) + allocate(/obj/structure/window/reinforced/fulltile, spot(2, dy)) + boss.boss_walk_cache = null + charge.next_use_time = world.time + 100 SECONDS + TEST_ASSERT(!boss.boss_walks_to(get_turf(behind)), "The test's glass wall has a way round") + TEST_ASSERT_EQUAL(plan_with(boss, /datum/ai_planning_subtree/bounty_boss_breach), SUBTREE_RETURN_FINISH_PLANNING, "With no way round, it didn't go at the glass") + TEST_ASSERT_EQUAL(controller.blackboard["BB_bounty_boss_spot"], spot(1, 2), "With no way round, it isn't walking up to the glass") // BB_BOUNTY_BOSS_SPOT + controller.CancelActions() + +// ===== GORILLA ARMS THROUGH GLASS: THE IMPLANT'S OWN PUNCHES ===== + +/** + * Smashing is all or nothing (review r2): Gorilla Arms leave alone a grille with glass behind it they + * can't break in a few punches, and go at one with glass they can. + */ +/datum/unit_test/voidcrew_bounty_boss/reach/smash_line + +/datum/unit_test/voidcrew_bounty_boss/reach/smash_line/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = gorilla_boss(spot(0, 1)) + var/datum/ai_controller/controller = boss.ai_controller + var/mob/living/carbon/human/consistent/behind = hunter_at(spot(4, 1)) + var/obj/structure/grille/grille = allocate(/obj/structure/grille, spot(2, 1)) + var/obj/structure/window/reinforced/fulltile/thick = allocate(/obj/structure/window/reinforced/fulltile, spot(2, 1)) + var/list/blockers = boss.boss_line_blockers(get_turf(boss), get_turf(behind)) + TEST_ASSERT((grille in blockers) && (thick in blockers), "The grille and the glass on one tile aren't both in its way") + TEST_ASSERT(boss.boss_gorilla_goes_through(grille), "Gorilla Arms don't go through a grille") + TEST_ASSERT(!boss.boss_gorilla_goes_through(thick), "Gorilla Arms go through reinforced glass") + TEST_ASSERT(!boss.boss_gets_through(blockers), "It goes at a grille with glass behind it it can't break") + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, behind) + TEST_ASSERT_NULL(plan_with(boss, /datum/ai_planning_subtree/bounty_boss_breach), "It went at a grille with glass behind it it can't break") + TEST_ASSERT_NULL(controller.blackboard["BB_bounty_boss_spot"], "It picked a spot at glass it can't break") // BB_BOUNTY_BOSS_SPOT + + // Plain glass behind the grille: all of it breaks, so it goes at it, its Gorilla Arms ready with the charge on cooldown + qdel(thick) + var/obj/structure/window/fulltile/pane = allocate(/obj/structure/window/fulltile, spot(2, 1)) + var/datum/action/cooldown/mob_cooldown/bounty_boss/charge/charge = ability_of(boss, /datum/action/cooldown/mob_cooldown/bounty_boss/charge) + charge.next_use_time = world.time + 100 SECONDS + boss.boss_walk_cache = null + blockers = boss.boss_line_blockers(get_turf(boss), get_turf(behind)) + TEST_ASSERT((grille in blockers) && (pane in blockers), "The grille and the plain glass on one tile aren't both in its way") + TEST_ASSERT_EQUAL(length(blockers), 2, "A grille and a window on one tile read as [length(blockers)] things in its way") + TEST_ASSERT(boss.boss_gets_through(blockers), "It won't go at a grille with plain glass behind it") + TEST_ASSERT(boss.boss_breaks_through_now(grille), "Its Gorilla Arms aren't ready for the grille") + TEST_ASSERT_EQUAL(plan_with(boss, /datum/ai_planning_subtree/bounty_boss_breach), SUBTREE_RETURN_FINISH_PLANNING, "It didn't go at a grille and glass it can break") + TEST_ASSERT_EQUAL(controller.blackboard["BB_bounty_boss_spot"], spot(1, 1), "It isn't walking up to glass it can break") // BB_BOUNTY_BOSS_SPOT + controller.CancelActions() + +/** + * The smash: a tell first, then the implant's own punches through a plain window, never reinforced glass + * or an airlock, never off its site or near a ship, never with an EMP'd arm or a full hand, and nothing + * lands if it is stopped in the tell. Then what the other kits get through. + */ +/datum/unit_test/voidcrew_bounty_boss/reach/smash + +/datum/unit_test/voidcrew_bounty_boss/reach/smash/Run() + var/obj/structure/window/fulltile/pane = allocate(/obj/structure/window/fulltile, spot(1, 2)) + var/obj/structure/window/reinforced/fulltile/thick = allocate(/obj/structure/window/reinforced/fulltile, spot(1, 4)) + var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, spot(1, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = gorilla_boss(spot(0, 2)) + TEST_ASSERT(boss.boss_gorilla_goes_through(pane), "Gorilla Arms don't go through plain glass") + TEST_ASSERT(!boss.boss_gorilla_goes_through(thick), "Gorilla Arms go through reinforced glass") + TEST_ASSERT(!boss.boss_can_smash(door), "Gorilla Arms smash an airlock instead of forcing it") + + // The tell: rooted, glowing, the tile lit, nothing broken yet + var/integrity = pane.get_integrity() + TEST_ASSERT(boss.boss_start_smash(pane), "The smash didn't start") + var/serial = boss.boss_busy_serial + TEST_ASSERT_EQUAL(boss.boss_busy, "smash", "The smash doesn't hold the boss") // BOUNTY_BOSS_BUSY_SMASH + TEST_ASSERT(HAS_TRAIT(boss, TRAIT_IMMOBILIZED), "It walks about mid-smash") + TEST_ASSERT_NOTNULL(boss.get_filter("bounty_boss_smash"), "The smash's tell doesn't glow") // BOUNTY_BOSS_SMASH_FILTER + TEST_ASSERT(marks_near(pane, 0) > 0, "The smash marks nothing") + TEST_ASSERT_EQUAL(pane.get_integrity(), integrity, "The glass broke before the tell was over") + TEST_ASSERT(!boss.boss_start_smash(pane), "A second smash started mid-smash") + + // The punches: the implant's own, each what one Gorilla Arms punch does to that glass + var/expected = pane.run_atom_armor(40, BRUTE, MELEE) // BOUNTY_BOSS_SMASH_BLOW, CYBERWARE_GORILLA_STRUCTURE_DAMAGE + ready_fists(boss) + TEST_ASSERT(boss.boss_smash_blow(serial), "The first punch wasn't thrown") + TEST_ASSERT(abs((integrity - pane.get_integrity()) - expected) < 0.1, "A Gorilla Arms punch took [integrity - pane.get_integrity()] off the glass, not the [expected] the smash counts on") + var/punches = 1 + for(var/i in 1 to 6) // BOUNTY_BOSS_SMASH_MOST_BLOWS + if(boss.boss_busy != "smash") + break + ready_fists(boss) + if(boss.boss_smash_blow(serial)) + punches++ + TEST_ASSERT(QDELETED(pane) || !pane.density, "[punches] punches didn't get it through the plain window") + TEST_ASSERT(punches <= 6, "It threw [punches] punches") // BOUNTY_BOSS_SMASH_MOST_BLOWS + TEST_ASSERT_NULL(boss.boss_busy, "The smash kept the boss after it got through") + TEST_ASSERT(!HAS_TRAIT(boss, TRAIT_IMMOBILIZED), "The boss is still rooted after the smash") + TEST_ASSERT_NULL(boss.get_filter("bounty_boss_smash"), "The smash's glow outlived it") + TEST_ASSERT_EQUAL(marks_near(spot(1, 2), 0), 0, "The smash's mark outlived it") + + // Stopped in the tell: nothing lands, the mark goes + var/obj/structure/window/fulltile/other_pane = allocate(/obj/structure/window/fulltile, spot(0, 3)) + boss.boss_next_ability_at = 0 + integrity = other_pane.get_integrity() + TEST_ASSERT(boss.boss_start_smash(other_pane), "The second smash didn't start") + serial = boss.boss_busy_serial + boss.boss_interrupt() + ready_fists(boss) + TEST_ASSERT(!boss.boss_smash_blow(serial), "An interrupted smash threw a punch") + TEST_ASSERT_EQUAL(other_pane.get_integrity(), integrity, "An interrupted smash hurt the glass") + TEST_ASSERT_EQUAL(marks_near(other_pane, 0), 0, "An interrupted smash left its mark") + + // EMP'd, the arms are dead weight until they reboot + boss.boss_next_ability_at = 0 + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in boss.organs) + var/datum/component/cyberware/ware = arm.GetComponent(/datum/component/cyberware) + ware.start_emp_reboot() + TEST_ASSERT(!boss.boss_gorilla_goes_through(other_pane), "EMP'd Gorilla Arms go through glass") + TEST_ASSERT(!boss.boss_start_smash(other_pane), "EMP'd Gorilla Arms smashed glass") + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in boss.organs) + var/datum/component/cyberware/ware = arm.GetComponent(/datum/component/cyberware) + ware.tune_up() + TEST_ASSERT(boss.boss_gorilla_goes_through(other_pane), "Rebooted Gorilla Arms don't go through glass") + + // Both hands full: no fist to punch with + var/obj/item/wrench/left_load = allocate(/obj/item/wrench) + var/obj/item/screwdriver/right_load = allocate(/obj/item/screwdriver) + TEST_ASSERT(boss.put_in_hands(left_load) && boss.put_in_hands(right_load), "Test fixture: the boss couldn't fill both hands") + TEST_ASSERT(!boss.boss_has_gorilla_fist(), "With both hands full, it still has a fist to punch with") + TEST_ASSERT(!boss.boss_gorilla_goes_through(other_pane), "With both hands full, its Gorilla Arms go through glass") + qdel(left_load) + qdel(right_load) + TEST_ASSERT(boss.boss_has_gorilla_fist(), "Hands empty again, it has no fist") + + // Off its site: never + var/turf/corner = run_loc_floor_bottom_left + boss.site_bounds = list(corner.x, corner.y, corner.x + 1, corner.y + 2, corner.z) + TEST_ASSERT(!boss.boss_can_smash(other_pane), "Gorilla Arms go at glass off its site") + boss.site_bounds = null + + // The other kits + var/obj/structure/window/fulltile/plain = allocate(/obj/structure/window/fulltile, spot(3, 0)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/juggernaut = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(4, 0)) + TEST_ASSERT(juggernaut.boss_goes_through(plain), "The Juggernaut's charge doesn't go through plain glass") + TEST_ASSERT(!juggernaut.boss_goes_through(thick), "The Juggernaut's charge goes through reinforced glass") + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/heavy, spot(4, 3)) + TEST_ASSERT(heavy.boss_goes_through(thick), "The Heavy's rounds don't chip interior glass") + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/pyro = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac, spot(3, 3)) + TEST_ASSERT(pyro.boss_goes_through(thick), "The Pyromaniac's molotov doesn't go over glass") + TEST_ASSERT(!pyro.boss_goes_through(door), "The Pyromaniac's molotov goes over a shut airlock") + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/ghost = boss_at(/mob/living/carbon/human/npc/bounty_criminal/boss/ghost, spot(4, 4)) + TEST_ASSERT(!ghost.boss_goes_through(plain), "The Ghost goes through glass") + + // Near a player's ship: never (BOUNTY_ENV_SAFE_RANGE) + make_ship_tile(spot(4, 1)) + TEST_ASSERT(!boss.boss_can_smash(other_pane), "Gorilla Arms go at glass near a player's ship") + TEST_ASSERT(!boss.boss_gorilla_goes_through(other_pane), "Gorilla Arms go through glass near a player's ship") From 6a229558513dd1e91e878955da3de65d1c219e53 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:00:41 -0800 Subject: [PATCH 152/472] Animate Redline, Lazarus, leaps, the cyberdeck, weapon trails, brownouts and the cradle rig Redline cores flash their nodes white and run them red-hot, flood the eyes red and breathe a red halo while the burn holds; the roar rolls a ring out as far as it reaches, and the crash vents steam off the back, cools the nodes to grey and slumps the body. The Lazarus node's windup lights the body magenta with arcs crawling over it, then a white jolt, a burst and a small spring. Hopper pistons crouch, kick up dust and arc over to the landing with a shadow running under them; the Piledriver glides to its mark and slams down with a ring and cracks in the floor that fade on their own. The body still stays on its tile until it lands. Packets run up a cyberdeck's trace, a door glitches when a hack lands on it, and Ping sweeps a ring out and outlines what answered. Scorch cuts leave an ember crescent; the Widowline Arc lashes out and crackles along its lightning. Worn chrome LEDs breathe at rest, each piece on its own beat, and in a brownout everything stutters, warms and dims together, then stutters back on. The chrome cradle has its rig on show: the arms come down on the servo beats, carry the ware in and sink it, and the patient glitches as it boots. Pictures only: every timing and number the chrome runs on is unchanged. --- tgstation.dme | 1 + voidcrew/modules/cyberware/chrome_cradle.dm | 4 + voidcrew/modules/cyberware/cyberware_anim.dm | 55 +- voidcrew/modules/cyberware/cyberware_anim2.dm | 922 ++++++++++++++++++ .../modules/cyberware/cyberware_component.dm | 3 + .../cyberware/icons/chrome_cradle_rig.dmi | Bin 0 -> 4026 bytes .../cyberware/icons/cyberware_anim2.dmi | Bin 0 -> 35167 bytes .../cyberware/icons/cyberware_anim2_large.dmi | Bin 0 -> 11560 bytes .../cyberware/icons/cyberware_anim_ping.dmi | Bin 0 -> 84709 bytes voidcrew/modules/cyberware/ware_legend.dm | 18 +- voidcrew/modules/cyberware/ware_line_deck.dm | 14 +- .../modules/cyberware/ware_line_mantis.dm | 2 + .../modules/cyberware/ware_line_widowline.dm | 3 + .../modules/cyberware/ware_military_body.dm | 6 + voidcrew/modules/cyberware/ware_pro_combat.dm | 9 +- .../modules/cyberware/ware_pro_utility.dm | 21 +- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_anim2.dm | 280 ++++++ 18 files changed, 1326 insertions(+), 13 deletions(-) create mode 100644 voidcrew/modules/cyberware/cyberware_anim2.dm create mode 100644 voidcrew/modules/cyberware/icons/chrome_cradle_rig.dmi create mode 100644 voidcrew/modules/cyberware/icons/cyberware_anim2.dmi create mode 100644 voidcrew/modules/cyberware/icons/cyberware_anim2_large.dmi create mode 100644 voidcrew/modules/cyberware/icons/cyberware_anim_ping.dmi create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_anim2.dm diff --git a/tgstation.dme b/tgstation.dme index 5466538d6c1..c3ff89a7c3b 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7178,6 +7178,7 @@ #include "voidcrew\modules\cyberware\chrome_wheel_keybindings.dm" #include "voidcrew\modules\cyberware\cradle_ui.dm" #include "voidcrew\modules\cyberware\cyberware_anim.dm" +#include "voidcrew\modules\cyberware\cyberware_anim2.dm" #include "voidcrew\modules\cyberware\cyberware_base.dm" #include "voidcrew\modules\cyberware\cyberware_component.dm" #include "voidcrew\modules\cyberware\cyberware_feedback.dm" diff --git a/voidcrew/modules/cyberware/chrome_cradle.dm b/voidcrew/modules/cyberware/chrome_cradle.dm index d8d4330b5f6..79d7d433ec9 100644 --- a/voidcrew/modules/cyberware/chrome_cradle.dm +++ b/voidcrew/modules/cyberware/chrome_cradle.dm @@ -259,6 +259,8 @@ /// on the marked stage a spark shower plus Splice's mid-line. /obj/machinery/chrome_cradle/proc/run_sequence_stage(mid_stage = FALSE) playsound(src, 'sound/effects/servostep.ogg', 60, TRUE) + // The rig's arms move on the beat (cyberware_anim2.dm). + rig_beat(mid_stage) var/mob/living/patient = occupant if(istype(patient)) patient.do_jitter_animation(60) @@ -315,6 +317,7 @@ SStgui.update_uis(src) return do_sparks(3, FALSE, src) + rig_boot_fx(patient) patient.flash_act(visual = 1) if(chrome.tier >= CYBERWARE_TIER_4) visible_message(span_notice("The parlor's neon stutters as the rig pulls the current it needs.")) @@ -368,6 +371,7 @@ for(var/timer in stage_timers) deltimer(timer) stage_timers.Cut() + rig_stop() busy = FALSE busy_action = null busy_ware = null diff --git a/voidcrew/modules/cyberware/cyberware_anim.dm b/voidcrew/modules/cyberware/cyberware_anim.dm index aef95e3d88f..65af5de972a 100644 --- a/voidcrew/modules/cyberware/cyberware_anim.dm +++ b/voidcrew/modules/cyberware/cyberware_anim.dm @@ -28,12 +28,17 @@ #define CYBERWARE_ANIM_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim.dmi' #define CYBERWARE_ANIM_LARGE_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim_large.dmi' +/// Batch-2 art: breathing LEDs and the looks of cyberware_anim2.dm. +#define CYBERWARE_ANIM2_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim2.dmi' /// Worn looks. Null is the plain overlay. #define CHROME_LOOK_STATIC "static" #define CHROME_LOOK_DARK "dark" #define CHROME_LOOK_FLASH "flash" #define CHROME_LOOK_FOLD "fold" +/// Brownout looks (cyberware_anim2.dm): browned out, and the warm stutter on the way down or up. +#define CHROME_LOOK_BROWN "brown" +#define CHROME_LOOK_WARM "warm" /// Layer for a blade swing: just over the held item it turns into. #define CHROME_ANIM_BLADE_LAYER (-(HANDS_LAYER - 0.5)) @@ -103,6 +108,9 @@ /// The EMP look the reboot show has put on this piece. Null means "whatever /// the reboot state says": dark while down, plain otherwise. /datum/component/cyberware/var/emp_look +/// The brownout look the brownout show has put on this piece. Null means +/// "whatever the brownout state says": browned out or plain. +/datum/component/cyberware/var/brownout_look /** * The look this implant's worn overlay draws with right now, or null for the @@ -116,6 +124,10 @@ return chrome.emp_look if(chrome.emp_down) return CHROME_LOOK_DARK + if(chrome.brownout_look) + return chrome.brownout_look + if(chrome.browned_out) + return CHROME_LOOK_BROWN return null /// What this implant adds to its limb's render key, so a look change redraws. @@ -130,7 +142,7 @@ /obj/item/organ/cyberimp/proc/cyberware_dress_worn(list/imageset, image_layer, obj/item/bodypart/limb) var/look = cyberware_worn_look() if(!look) - return imageset + return cyberware_dress_leds(imageset, image_layer, limb) var/list/dressed = list() for(var/image/part as anything in imageset) var/glow = PLANE_TO_TRUE(part.plane) == EMISSIVE_PLANE @@ -158,7 +170,44 @@ var/mutable_appearance/noise = mutable_appearance('icons/effects/effects.dmi', "static_base", alpha = 170) noise.blend_mode = BLEND_INSET_OVERLAY part.overlays += noise + if(CHROME_LOOK_BROWN) + if(glow) + continue + part.color = list(0.62, 0, 0, 0, 0.62, 0, 0, 0, 0.66, 0, 0, 0) + if(CHROME_LOOK_WARM) + if(glow) + // A sagging supply: the lights still burn, weaker. + dressed += emissive_appearance(part.icon, part.icon_state, limb?.owner || limb, part.layer, alpha = 110) + continue + part.color = list(0.9, 0, 0, 0, 0.74, 0, 0, 0, 0.58, 0.05, 0.02, 0) + dressed += part + return dressed + +/** + * The plain look: any worn state with breathing LEDs in the batch-2 sheet + * gets them, and the breathing glow replaces the piece's own still glow. + * The LED states loop on their own, so a lit piece costs the server nothing. + */ +/obj/item/organ/cyberimp/proc/cyberware_dress_leds(list/imageset, image_layer, obj/item/bodypart/limb) + var/list/leds + for(var/image/part as anything in imageset) + if(PLANE_TO_TRUE(part.plane) == EMISSIVE_PLANE) + continue + var/led_state = "[part.icon_state]_led" + if(icon_exists(CYBERWARE_ANIM2_ICON, led_state)) + LAZYSET(leds, "[part.icon_state]", led_state) + if(!leds) + return imageset + var/list/dressed = list() + for(var/image/part as anything in imageset) + if(PLANE_TO_TRUE(part.plane) == EMISSIVE_PLANE) + var/glow_state = "[part.icon_state]" + if(length(glow_state) > 2 && copytext(glow_state, -2) == "_e" && leds[copytext(glow_state, 1, -2)]) + continue dressed += part + for(var/worn_state in leds) + dressed += image(icon = CYBERWARE_ANIM2_ICON, icon_state = leds[worn_state], layer = image_layer) + dressed += emissive_appearance(CYBERWARE_ANIM2_ICON, "[leds[worn_state]]_e", limb?.owner || limb, image_layer) return dressed // ========================================================================= @@ -837,6 +886,7 @@ var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) if(chrome) chrome.emp_look = null + chrome.brownout_look = null /obj/item/organ/cyberimp/arm/toolkit/cyberware/cyberware_anim_clear(mob/living/carbon/old_owner) . = ..() @@ -854,9 +904,12 @@ #undef CYBERWARE_ANIM_ICON #undef CYBERWARE_ANIM_LARGE_ICON +#undef CYBERWARE_ANIM2_ICON #undef CHROME_LOOK_STATIC #undef CHROME_LOOK_DARK #undef CHROME_LOOK_FLASH #undef CHROME_LOOK_FOLD +#undef CHROME_LOOK_BROWN +#undef CHROME_LOOK_WARM #undef CHROME_ANIM_BLADE_LAYER #undef CHROME_ANIM_TOP_LAYER diff --git a/voidcrew/modules/cyberware/cyberware_anim2.dm b/voidcrew/modules/cyberware/cyberware_anim2.dm new file mode 100644 index 00000000000..d60c183e440 --- /dev/null +++ b/voidcrew/modules/cyberware/cyberware_anim2.dm @@ -0,0 +1,922 @@ +/** + * # Chrome animations, second batch + * + * More motion on chrome that already works, built the same way as + * cyberware_anim.dm: every proc here only draws, and the mechanics call into + * it after they have already happened. Nothing here changes a number, a timer + * or who gets hit. + * + * - Redline Burn: nodes flash white and run red-hot, eyes flood red, a red + * halo breathes round the body and the roar rolls a ring across the floor. + * The crash vents steam from the back, cools the nodes to grey and slumps + * the body a little. + * - Lazarus Node: a magenta charge crawls over the body through the windup, + * then a white jolt, a burst and a little spring as they come up. + * - Leaps: the Hopper crouches, kicks up dust and arcs over to where it lands, + * with a shadow running along the floor under it. The Piledriver glides to + * its mark and slams down with a ring and a cracked floor. + * - Cyberdeck: packets run up the trace, a door glitches when a hack lands on + * it, and Ping sweeps a ring out and outlines what answered. + * - Weapon trails: a Scorch cut leaves an ember crescent; the Widowline Arc + * lashes and its lightning crackles along the wire. + * - Brownout: worn chrome stutters, warms and dims together, and stutters + * back on when the load comes off. (The LEDs breathing at rest are part of + * the plain worn look in cyberware_anim.dm.) + * - Chrome cradle: the rig's arms come down on the servo beats, carry the ware + * in and sink it, and the patient glitches as it boots. + * + * Timers are one-shot and owned by the thing they animate, or global procs + * holding weak references, so nothing outlives what it draws on. + */ + +#define CYBERWARE_ANIM2_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim2.dmi' +#define CYBERWARE_ANIM2_LARGE_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim2_large.dmi' +#define CYBERWARE_ANIM_PING_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim_ping.dmi' +#define CHROME_CRADLE_RIG_ICON 'voidcrew/modules/cyberware/icons/chrome_cradle_rig.dmi' + +/// Layer for flashes, arcs and bursts: over everything the bearer wears. +#define CHROME_ANIM2_TOP_LAYER (-(HIGHEST_LAYER - 0.5)) + +/// Worn looks shared with cyberware_anim.dm. +#define CHROME_LOOK_BROWN "brown" +#define CHROME_LOOK_WARM "warm" + +// ========================================================================= +// SHARED HELPERS +// ========================================================================= + +/// Plays a 64x64 state from the batch-2 sheet centred on [bearer]. +/proc/cyberware_anim2_play_large(atom/movable/bearer, state, lifetime, glow = TRUE) + var/obj/effect/abstract/cyberware_anim/holder = cyberware_anim_play(bearer, CYBERWARE_ANIM2_LARGE_ICON, state, CHROME_ANIM2_TOP_LAYER, lifetime, glow) + if(holder) + holder.pixel_x = -16 + holder.pixel_y = -16 + return holder + +/** + * A particle emitter riding [bearer] for [lifetime]. It stops making new + * particles after [emit_time] so the last ones fade out before it goes. + */ +/proc/cyberware_anim_particles(atom/movable/bearer, particles_type, anim_layer, emit_time, lifetime) + if(QDELETED(bearer)) + return null + var/obj/effect/abstract/cyberware_anim/holder = new(null) + holder.bearer = bearer + holder.icon = null + holder.layer = anim_layer + holder.particles = new particles_type + holder.RegisterSignal(bearer, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + bearer.vis_contents += holder + addtimer(CALLBACK(holder, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, stop_particles)), emit_time) + QDEL_IN(holder, lifetime) + return holder + +/obj/effect/abstract/cyberware_anim/proc/stop_particles() + if(particles) + particles.spawning = 0 + +/// Grey dust kicked up by a landing or a take-off. +/proc/cyberware_grey_dust(turf/where) + if(isnull(where)) + return null + var/obj/effect/temp_visual/mook_dust/dust = new(where) + dust.color = list(0.45, 0.45, 0.45, 0.45, 0.45, 0.45, 0.2, 0.2, 0.2, 0.33, 0.33, 0.35) + return dust + +#define CHROME_FLASH_FILTER "cyberware_flash" + +/// A white flash over [target] that fades back to its own colours. +/proc/cyberware_flash_fx(atom/movable/target, fade_time = 0.25 SECONDS) + if(QDELETED(target)) + return + var/static/list/flash = list(0.4, 0, 0, 0, 0, 0.4, 0, 0, 0, 0, 0.4, 0, 0, 0, 0, 1, 0.6, 0.6, 0.62, 0) + target.add_filter(CHROME_FLASH_FILTER, 43, color_matrix_filter(flash)) + animate(target.get_filter(CHROME_FLASH_FILTER), color = COLOR_MATRIX_IDENTITY, time = fade_time) + // A rebuild of the target's filters lands on the faded end, not the flash. + target.filter_data[CHROME_FLASH_FILTER]["color"] = COLOR_MATRIX_IDENTITY + addtimer(CALLBACK(target, TYPE_PROC_REF(/datum, remove_filter), CHROME_FLASH_FILTER), fade_time + 0.5) + +#undef CHROME_FLASH_FILTER + +// ========================================================================= +// GLITCH (doors, and a patient booting new chrome) +// ========================================================================= + +#define CHROME_GLITCH_SLICES "cyberware_glitch_slices" +#define CHROME_GLITCH_RED "cyberware_glitch_red" +#define CHROME_GLITCH_CYAN "cyberware_glitch_cyan" + +/** + * Tears [target] into shifted slices with a red and cyan fringe and a burst + * of static over it, for [duration]. With [flash] it whites out first. With + * [inset] the static keeps to the target's own pixels, which needs a target + * drawn as one piece (KEEP_TOGETHER, as people are). + */ +/proc/cyberware_glitch_fx(atom/movable/target, duration = 0.25 SECONDS, flash = FALSE, inset = FALSE) + if(QDELETED(target)) + return + target.add_filters(list( + list("name" = CHROME_GLITCH_SLICES, "priority" = 40, "params" = displacement_map_filter(icon(CYBERWARE_ANIM2_ICON, "glitch_slices"), size = 3)), + list("name" = CHROME_GLITCH_RED, "priority" = 41, "params" = drop_shadow_filter(x = -1, y = 0, size = 0, color = "#ff2020a0")), + list("name" = CHROME_GLITCH_CYAN, "priority" = 42, "params" = drop_shadow_filter(x = 1, y = 0, size = 0, color = "#20e0ffa0")), + )) + // The slices jump about, then settle. + var/slices = target.get_filter(CHROME_GLITCH_SLICES) + animate(slices, y = 7, time = 0.5) + animate(y = 13, size = 2, time = 0.5) + animate(y = 3, size = 0, time = max(duration - 1, 0.5)) + target.filter_data[CHROME_GLITCH_SLICES]["size"] = 0 + if(flash) + cyberware_flash_fx(target, duration * 0.5) + var/obj/effect/abstract/cyberware_anim/tear = cyberware_anim_play(target, CYBERWARE_ANIM2_ICON, "glitch_tear", CHROME_ANIM2_TOP_LAYER, duration) + if(tear && inset) + tear.blend_mode = BLEND_INSET_OVERLAY + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(cyberware_glitch_end), WEAKREF(target)), duration) + +/proc/cyberware_glitch_end(datum/weakref/target_ref) + var/atom/movable/target = target_ref?.resolve() + if(QDELETED(target)) + return + target.remove_filter(list(CHROME_GLITCH_SLICES, CHROME_GLITCH_RED, CHROME_GLITCH_CYAN)) + +#undef CHROME_GLITCH_SLICES +#undef CHROME_GLITCH_RED +#undef CHROME_GLITCH_CYAN + +// ========================================================================= +// REDLINE BURN +// ========================================================================= + +#define REDLINE_LOOK_FLASH "flash" +#define REDLINE_LOOK_HOT "hot" +#define REDLINE_LOOK_COOL1 "cool1" +#define REDLINE_LOOK_COOL2 "cool2" +#define REDLINE_GLOW_FILTER "cyberware_redline_glow" +#define REDLINE_SAG_FILTER "cyberware_redline_sag" +/// How long the white kick-in flash holds before the nodes run red. +#define REDLINE_FLASH_TIME (0.15 SECONDS) +/// From the crash to the nodes going grey. +#define REDLINE_COOL_STEP (0.6 SECONDS) +/// How long the back vents steam, and how long the last of it hangs. +#define REDLINE_STEAM_TIME (1.2 SECONDS) +#define REDLINE_STEAM_LIFE (2.4 SECONDS) +/// The eyes during the burn. +#define REDLINE_EYE_COLOR "#ff2a1a" + +/obj/item/organ/cyberimp/cyberware/redline + /// The node look drawn over the core, or null for the plain one. + var/burn_look + /// Timer for the next node look. + var/burn_look_timer + /// Who carries our filters and our death watch. + var/datum/weakref/burn_fx_ref + /// The red light the burn throws. + var/datum/weakref/burn_light_ref + /// The steam venting off the back in the crash. + var/datum/weakref/burn_steam_ref + +/obj/item/organ/cyberimp/cyberware/redline/cyberware_worn_key() + return "[..()]-[burn_look]" + +/obj/item/organ/cyberimp/cyberware/redline/cyberware_dress_worn(list/imageset, image_layer, obj/item/bodypart/limb) + if(cyberware_worn_look() || !burn_look) + return ..() + var/state = "[aug_overlay]_[burn_look]" + if(!icon_exists(CYBERWARE_ANIM2_ICON, state)) + return ..() + // The node look replaces the core's own glow. + var/list/dressed = list() + for(var/image/part as anything in imageset) + if(PLANE_TO_TRUE(part.plane) == EMISSIVE_PLANE) + continue + dressed += part + var/atom/glow_owner = limb?.owner || limb + dressed += image(icon = CYBERWARE_ANIM2_ICON, icon_state = state, layer = image_layer) + if(icon_exists(CYBERWARE_ANIM2_ICON, "[state]_e")) + dressed += emissive_appearance(CYBERWARE_ANIM2_ICON, "[state]_e", glow_owner, image_layer) + if(burn_look == REDLINE_LOOK_HOT || burn_look == REDLINE_LOOK_FLASH) + dressed += redline_eyes(limb, image_layer) + return dressed + +/// Red eyes for the burn, drawn from the bearer's own eye sprites so they sit where their eyes are. +/obj/item/organ/cyberimp/cyberware/redline/proc/redline_eyes(obj/item/bodypart/limb, image_layer) + var/mob/living/carbon/human/bearer = limb?.owner + if(!istype(bearer)) + return list() + var/obj/item/organ/eyes/eyes = bearer.get_organ_slot(ORGAN_SLOT_EYES) + var/obj/item/bodypart/head/head = bearer.get_bodypart(BODY_ZONE_HEAD) + if(!istype(eyes) || isnull(eyes.eye_icon_state) || !head) + return list() + if(bearer.check_obscured_slots() & ITEM_SLOT_EYES) + return list() + var/list/eye_images = list() + for(var/side in list("_l", "_r")) + var/mutable_appearance/eye = mutable_appearance('icons/mob/human/human_face.dmi', "[eyes.eye_icon_state][side]", image_layer, bearer) + eye.color = REDLINE_EYE_COLOR + var/mutable_appearance/eye_glow = emissive_appearance(eye.icon, eye.icon_state, bearer, image_layer) + if(head.worn_face_offset) + head.worn_face_offset.apply_offset(eye) + head.worn_face_offset.apply_offset(eye_glow) + eye_images += eye + eye_images += eye_glow + return eye_images + +/obj/item/organ/cyberimp/cyberware/redline/proc/set_burn_look(look) + burn_look_timer = null + if(burn_look == look) + return + burn_look = look + owner?.update_body_parts() + +/obj/item/organ/cyberimp/cyberware/redline/proc/queue_burn_look(look, delay) + if(burn_look_timer) + deltimer(burn_look_timer) + burn_look_timer = addtimer(CALLBACK(src, PROC_REF(set_burn_look), look), delay, TIMER_STOPPABLE) + +/// The window opens: white-hot nodes, then red, red eyes, a red halo and light. +/obj/item/organ/cyberimp/cyberware/redline/proc/play_burn_window(mob/living/carbon/bearer) + if(QDELETED(bearer) || bearer != owner) + return + clear_burn_fx() + watch_burn_bearer(bearer) + set_burn_look(REDLINE_LOOK_FLASH) + queue_burn_look(REDLINE_LOOK_HOT, REDLINE_FLASH_TIME) + bearer.add_filter(REDLINE_GLOW_FILTER, 30, drop_shadow_filter(x = 0, y = 0, size = 1.5, color = "#ff2a1470")) + animate(bearer.get_filter(REDLINE_GLOW_FILTER), size = 2.5, color = "#ff2a14b0", time = 0.35 SECONDS, loop = -1, easing = SINE_EASING) + animate(size = 1.5, color = "#ff2a1470", time = 0.35 SECONDS, easing = SINE_EASING) + burn_light_ref = WEAKREF(bearer.mob_light(2, 0.7, "#ff3b22", burn_window + 2 SECONDS)) + +/// The roar: a red ring rolls out as far as it reaches. +/obj/item/organ/cyberimp/cyberware/redline/proc/play_burn_roar(mob/living/carbon/bearer, reach) + var/turf/here = get_turf(bearer) + if(here && reach > 0) + new /obj/effect/temp_visual/circle_wave/cyberware_roar(here, reach) + +/// The crash: the halo goes out, the nodes cool, steam vents from the back and the body slumps. +/obj/item/organ/cyberimp/cyberware/redline/proc/play_burn_crash(mob/living/carbon/bearer) + if(QDELETED(bearer) || bearer != owner) + return + stop_burn_glow(bearer) + watch_burn_bearer(bearer) + set_burn_look(REDLINE_LOOK_COOL1) + queue_burn_look(REDLINE_LOOK_COOL2, REDLINE_COOL_STEP) + qdel(burn_steam_ref?.resolve()) + burn_steam_ref = WEAKREF(cyberware_anim_particles(bearer, /particles/smoke/steam/cyberware_vent, CHROME_ANIM2_TOP_LAYER, REDLINE_STEAM_TIME, REDLINE_STEAM_LIFE)) + bearer.add_filter(REDLINE_SAG_FILTER, 31, displacement_map_filter(icon(CYBERWARE_ANIM2_ICON, "sag_map"), size = 0)) + animate(bearer.get_filter(REDLINE_SAG_FILTER), size = 2, time = 0.3 SECONDS, easing = QUAD_EASING | EASE_OUT) + bearer.filter_data[REDLINE_SAG_FILTER]["size"] = 2 + +/// Takes the halo and the light off; the nodes and the slump stay. +/obj/item/organ/cyberimp/cyberware/redline/proc/stop_burn_glow(mob/living/carbon/bearer) + qdel(burn_light_ref?.resolve()) + burn_light_ref = null + if(!QDELETED(bearer)) + bearer.remove_filter(REDLINE_GLOW_FILTER) + +/obj/item/organ/cyberimp/cyberware/redline/proc/watch_burn_bearer(mob/living/carbon/bearer) + burn_fx_ref = WEAKREF(bearer) + RegisterSignal(bearer, COMSIG_LIVING_DEATH, PROC_REF(on_burn_bearer_death), override = TRUE) + +/obj/item/organ/cyberimp/cyberware/redline/proc/on_burn_bearer_death(datum/source) + SIGNAL_HANDLER + clear_burn_fx() + +/// Back to the plain core: no node look, no halo, light, steam or slump. +/obj/item/organ/cyberimp/cyberware/redline/proc/clear_burn_fx() + if(burn_look_timer) + deltimer(burn_look_timer) + burn_look_timer = null + qdel(burn_steam_ref?.resolve()) + burn_steam_ref = null + var/mob/living/carbon/bearer = burn_fx_ref?.resolve() + burn_fx_ref = null + stop_burn_glow(bearer) + if(!QDELETED(bearer)) + UnregisterSignal(bearer, COMSIG_LIVING_DEATH) + bearer.remove_filter(REDLINE_SAG_FILTER) + if(isnull(burn_look)) + return + burn_look = null + if(!QDELETED(bearer)) + bearer.update_body_parts() + +/// The core in the bearer's OS slot, if it is a Redline model. +/proc/cyberware_bearer_redline(mob/living/bearer) + if(!iscarbon(bearer)) + return null + var/mob/living/carbon/carbon_bearer = bearer + var/obj/item/organ/cyberimp/cyberware/redline/core = carbon_bearer.get_organ_slot(ORGAN_SLOT_CYBERWARE_OS) + return istype(core) ? core : null + +/// Hook for the window's on_apply(). +/proc/cyberware_redline_window_fx(mob/living/bearer) + cyberware_bearer_redline(bearer)?.play_burn_window(bearer) + +/// Hook for the roar, with the reach it was given. +/proc/cyberware_redline_roar_fx(mob/living/bearer, reach) + cyberware_bearer_redline(bearer)?.play_burn_roar(bearer, reach) + +/// Hook for the window's on_remove(), after it has (or hasn't) applied the crash. +/proc/cyberware_redline_window_end_fx(mob/living/bearer) + var/obj/item/organ/cyberimp/cyberware/redline/core = cyberware_bearer_redline(bearer) + if(core && !bearer.has_status_effect(/datum/status_effect/cyberware_redline_crash)) + core.clear_burn_fx() + +/// Hook for the crash's on_apply(). +/proc/cyberware_redline_crash_fx(mob/living/bearer) + cyberware_bearer_redline(bearer)?.play_burn_crash(bearer) + +/// Hook for the crash's on_remove(). +/proc/cyberware_redline_crash_end_fx(mob/living/bearer) + cyberware_bearer_redline(bearer)?.clear_burn_fx() + +/obj/item/organ/cyberimp/cyberware/redline/cyberware_anim_clear(mob/living/carbon/old_owner) + . = ..() + clear_burn_fx() + +/// The roar's ring, out as far as the roar reaches. +/obj/effect/temp_visual/circle_wave/cyberware_roar + color = "#ff4a2a" + max_alpha = 200 + duration = 0.5 SECONDS + layer = BELOW_MOB_LAYER + +/obj/effect/temp_visual/circle_wave/cyberware_roar/Initialize(mapload, reach = 5) + // The 64 px ring, scaled so its edge lands [reach] tiles out. + amount_to_scale = reach + 0.5 + return ..() + +/// Steam off the back of a crashing core. +/particles/smoke/steam/cyberware_vent + icon_state = list("steam_1" = 1, "steam_2" = 1, "steam_3" = 2) + count = 50 + spawning = 3 + lifespan = 1 SECONDS + fade = 0.6 SECONDS + position = generator(GEN_BOX, list(-7, 2, 0), list(7, 8, 0), UNIFORM_RAND) + velocity = generator(GEN_VECTOR, list(-0.6, 0.8, 0), list(0.6, 1.5, 0), NORMAL_RAND) + gravity = list(0, 0.2) + friction = 0.15 + drift = generator(GEN_SPHERE, 0, 0.3, NORMAL_RAND) + grow = 0.05 + scale = 0.75 + +#undef REDLINE_LOOK_FLASH +#undef REDLINE_LOOK_HOT +#undef REDLINE_LOOK_COOL1 +#undef REDLINE_LOOK_COOL2 +#undef REDLINE_GLOW_FILTER +#undef REDLINE_SAG_FILTER +#undef REDLINE_FLASH_TIME +#undef REDLINE_COOL_STEP +#undef REDLINE_STEAM_TIME +#undef REDLINE_STEAM_LIFE +#undef REDLINE_EYE_COLOR + +// ========================================================================= +// LAZARUS NODE +// ========================================================================= + +#define LAZARUS_SPRING_OFFSET "cyberware_lazarus_spring" +#define LAZARUS_CHARGE_FILTER "cyberware_lazarus_charge" + +/obj/item/organ/cyberimp/cyberware/lazarus + /// Who carries the charge's magenta halo. + var/datum/weakref/charge_fx_ref + /// The charge glow on the chest through the windup. + var/datum/weakref/charge_glow_ref + /// The arcs crawling over the body through the windup. + var/datum/weakref/charge_arcs_ref + /// The magenta light of the charge. + var/datum/weakref/charge_light_ref + +/// The windup: the node charges, magenta arcs crawl out over the body. +/obj/item/organ/cyberimp/cyberware/lazarus/proc/play_charge_fx(mob/living/carbon/patient, windup) + clear_charge_fx() + if(QDELETED(patient)) + return + charge_glow_ref = WEAKREF(cyberware_anim_play(patient, CYBERWARE_ANIM2_ICON, "lazarus_charge", CHROME_ANIM2_TOP_LAYER, windup, glow = TRUE)) + charge_arcs_ref = WEAKREF(cyberware_anim2_play_large(patient, "lazarus_arcs", windup)) + charge_light_ref = WEAKREF(patient.mob_light(1.5, 0.8, "#e700e3", windup)) + // The whole body takes on the charge, brighter as it fills. + charge_fx_ref = WEAKREF(patient) + patient.add_filter(LAZARUS_CHARGE_FILTER, 33, drop_shadow_filter(x = 0, y = 0, size = 1, color = "#e700e340")) + animate(patient.get_filter(LAZARUS_CHARGE_FILTER), size = 2.5, color = "#e700e3b0", time = windup, easing = QUAD_EASING | EASE_IN) + patient.filter_data[LAZARUS_CHARGE_FILTER]["size"] = 2.5 + patient.filter_data[LAZARUS_CHARGE_FILTER]["color"] = "#e700e3b0" + +/// Takes the charge off whoever it is on. +/obj/item/organ/cyberimp/cyberware/lazarus/proc/clear_charge_fx() + qdel(charge_glow_ref?.resolve()) + qdel(charge_arcs_ref?.resolve()) + qdel(charge_light_ref?.resolve()) + charge_glow_ref = null + charge_arcs_ref = null + charge_light_ref = null + var/mob/living/carbon/patient = charge_fx_ref?.resolve() + charge_fx_ref = null + if(!QDELETED(patient)) + patient.remove_filter(LAZARUS_CHARGE_FILTER) + +/// The jolt: a white flash, a burst and a light, and a little spring off the floor. +/obj/item/organ/cyberimp/cyberware/lazarus/proc/play_jolt_fx(mob/living/carbon/patient) + clear_charge_fx() + if(QDELETED(patient)) + return + cyberware_anim2_play_large(patient, "lazarus_jolt", 0.5 SECONDS) + patient.mob_light(2.5, 1.4, "#ffe6ff", 0.3 SECONDS) + cyberware_flash_fx(patient, 0.25 SECONDS) + patient.add_offsets(LAZARUS_SPRING_OFFSET, z_add = 3, animate = FALSE) + addtimer(CALLBACK(patient, TYPE_PROC_REF(/mob/living, remove_offsets), LAZARUS_SPRING_OFFSET), 0.5) + +/obj/item/organ/cyberimp/cyberware/lazarus/cyberware_anim_clear(mob/living/carbon/old_owner) + . = ..() + clear_charge_fx() + +#undef LAZARUS_SPRING_OFFSET +#undef LAZARUS_CHARGE_FILTER + +// ========================================================================= +// LEAPS +// ========================================================================= + +/// Set at take-off to when the leap should be down. A leap whose landing never +/// runs is put back on the floor shortly after. +/mob/living/var/cyberware_leap_until = 0 + +/// Where the body sits at rest, before any leap offset. +/proc/cyberware_leap_rest(mob/living/jumper, pixel) + var/base = pixel == PIXEL_W_OFFSET ? jumper.base_pixel_w : jumper.base_pixel_z + return base + jumper.has_offset(pixel = pixel) + +/** + * Take-off. The body arcs from [launch] over to [landing] during [airtime], rising + * to [peak] halfway and ending [height] over the landing tile, where the + * landing drops it. The body stays on its own tile until the landing moves + * it; only the drawing travels. A one-tile leap hops in place, since moving + * one tile glides. + */ +/proc/cyberware_leap_takeoff_fx(mob/living/jumper, turf/launch, turf/landing, airtime, height, peak, obj/effect/afterimage) + if(QDELETED(jumper)) + return + if(afterimage) + // The crouch the pistons fired out of. + var/matrix/squash = matrix(afterimage.transform) + squash.Scale(1.08, 0.86) + squash.Translate(0, -2) + afterimage.transform = squash + cyberware_grey_dust(launch) + var/dx = 0 + var/dy = 0 + if(launch && landing && get_dist(launch, landing) >= 2) + dx = (landing.x - launch.x) * ICON_SIZE_X + dy = (landing.y - launch.y) * ICON_SIZE_Y + var/rest_w = cyberware_leap_rest(jumper, PIXEL_W_OFFSET) + var/rest_z = cyberware_leap_rest(jumper, PIXEL_Z_OFFSET) + animate(jumper, pixel_w = rest_w + dx * 0.5, pixel_z = rest_z + dy * 0.5 + peak, time = airtime * 0.5, easing = SINE_EASING | EASE_OUT) + animate(pixel_w = rest_w + dx, pixel_z = rest_z + dy + height, time = airtime * 0.5, easing = SINE_EASING | EASE_IN) + if(dx || dy) + new /obj/effect/temp_visual/cyberware_leap_shadow(launch, dx, dy, airtime) + jumper.cyberware_leap_until = world.time + airtime + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(cyberware_leap_failsafe), WEAKREF(jumper)), airtime + 0.5 SECONDS) + +/// Touchdown: the body is over its landing tile and drops [height] onto it. +/proc/cyberware_leap_touchdown_fx(mob/living/jumper, height, drop_time) + if(QDELETED(jumper)) + return + jumper.cyberware_leap_until = 0 + var/rest_z = cyberware_leap_rest(jumper, PIXEL_Z_OFFSET) + jumper.pixel_w = cyberware_leap_rest(jumper, PIXEL_W_OFFSET) + jumper.pixel_z = rest_z + height + animate(jumper, pixel_z = rest_z, time = drop_time) + +/// A leap whose landing never ran (the pistons were pulled mid-air) is put back down. +/proc/cyberware_leap_failsafe(datum/weakref/jumper_ref) + var/mob/living/jumper = jumper_ref?.resolve() + if(QDELETED(jumper) || !jumper.cyberware_leap_until || world.time < jumper.cyberware_leap_until) + return + cyberware_leap_touchdown_fx(jumper, 0, 0) + +/// The Piledriver's impact: a ring rolls out over the shockwave and the floor cracks. +/proc/cyberware_piledriver_impact_fx(turf/where, reach) + if(isnull(where)) + return + new /obj/effect/temp_visual/circle_wave/cyberware_slam(where, reach) + new /obj/effect/temp_visual/cyberware_impact_cracks(where, "damaged1", 255) + for(var/direction in GLOB.cardinals) + var/turf/next = get_step(where, direction) + if(next) + new /obj/effect/temp_visual/cyberware_impact_cracks(next, "damaged5", 140) + +/// The shadow that runs along the floor under a leap. +/obj/effect/temp_visual/cyberware_leap_shadow + icon = CYBERWARE_ANIM2_ICON + icon_state = "leap_shadow" + layer = BELOW_MOB_LAYER + randomdir = FALSE + duration = 0.3 SECONDS + +/obj/effect/temp_visual/cyberware_leap_shadow/Initialize(mapload, dx = 0, dy = 0, airtime = 0.2 SECONDS) + duration = airtime + 0.1 SECONDS + . = ..() + animate(src, pixel_x = dx, pixel_y = dy, time = airtime) + animate(src, transform = matrix().Scale(0.7), time = airtime * 0.5, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + animate(transform = matrix(), time = airtime * 0.5, easing = SINE_EASING | EASE_IN) + +/// The Piledriver's ring: grey, out over the shockwave. +/obj/effect/temp_visual/circle_wave/cyberware_slam + color = "#d8d4cc" + max_alpha = 220 + duration = 0.4 SECONDS + layer = BELOW_MOB_LAYER + +/obj/effect/temp_visual/circle_wave/cyberware_slam/Initialize(mapload, reach = 2) + amount_to_scale = reach + 0.5 + return ..() + +/// Cracks the Piledriver leaves in the floor. They fade on their own. +/obj/effect/temp_visual/cyberware_impact_cracks + icon = 'icons/turf/damaged.dmi' + icon_state = "damaged1" + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER + randomdir = FALSE + duration = 8 SECONDS + +/obj/effect/temp_visual/cyberware_impact_cracks/Initialize(mapload, state = "damaged1", peak_alpha = 255) + . = ..() + var/turf/here = loc + // Nothing to crack in space, open air or a chasm. + if(!isfloorturf(here)) + return INITIALIZE_HINT_QDEL + icon_state = state + alpha = peak_alpha + animate(src, alpha = peak_alpha, time = duration - 2 SECONDS) + animate(alpha = 0, time = 2 SECONDS) + +// ========================================================================= +// CYBERDECK +// ========================================================================= + +/// Packets running up the trace while a hack uploads, drawn over it. +/proc/cyberware_deck_packets(mob/living/bearer, atom/target, time, maxdistance) + if(QDELETED(bearer) || QDELETED(target)) + return null + return bearer.Beam(target, icon_state = "deck_packets", icon = CYBERWARE_ANIM2_ICON, time = time, maxdistance = maxdistance) + +/// How long after a Ping the sweep reaches [machine] from [center]. +/proc/cyberware_ping_reveal_delay(turf/center, atom/machine) + var/turf/there = get_turf(machine) + if(isnull(center) || isnull(there)) + return 0 + var/distance = sqrt((there.x - center.x) ** 2 + (there.y - center.y) ** 2) * ICON_SIZE_X + // The ring's radius is 6 + 224 * (1 - (1 - t)^2) over the sweep; solve for t. + var/progress = clamp((distance - 6) / 224, 0, 1) + return round((1 - sqrt(1 - progress)) * 0.72 SECONDS, 0.5) + +/// The Ping sweep: a ring rolls out from the hacker, for the hacker alone. +/proc/cyberware_ping_sweep_fx(mob/living/viewer) + var/turf/here = get_turf(viewer) + if(isnull(here) || !viewer.client) + return + var/image/sweep = image(CYBERWARE_ANIM_PING_ICON, here, "ping_sweep", ABOVE_ALL_MOB_LAYER) + sweep.pixel_w = -224 + sweep.pixel_z = -224 + sweep.appearance_flags = RESET_COLOR | RESET_TRANSFORM | RESET_ALPHA + SET_PLANE_EXPLICIT(sweep, ABOVE_LIGHTING_PLANE, here) + viewer.client.images += sweep + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(cyberware_ping_sweep_end), WEAKREF(viewer), sweep), 0.9 SECONDS) + +/proc/cyberware_ping_sweep_end(datum/weakref/viewer_ref, image/sweep) + var/mob/living/viewer = viewer_ref?.resolve() + viewer?.client?.images -= sweep + +/** + * A tinted copy of [target] with an outline in [mark_color], for a HUD mark + * to draw through walls. Only the target's own drawing comes along: its + * lights and emissive blockers stay behind. + */ +/proc/cyberware_mark_outline(atom/target, mark_color) + var/mutable_appearance/look = new(target) + var/list/kept = list() + for(var/mutable_appearance/part as anything in look.overlays) + if(part.plane == FLOAT_PLANE) + kept += part + look.overlays = kept + look.underlays = list() + look.filters = null + look.alpha = 210 + look.layer = ABOVE_ALL_MOB_LAYER - 0.01 + look.appearance_flags = KEEP_TOGETHER | PIXEL_SCALE | RESET_COLOR | RESET_ALPHA + look.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + var/list/rgb = rgb2num(mark_color) + var/red = rgb[1] / 255 * 1.3 + var/green = rgb[2] / 255 * 1.3 + var/blue = rgb[3] / 255 * 1.3 + look.color = list( + 0.3 * red, 0.3 * green, 0.3 * blue, + 0.59 * red, 0.59 * green, 0.59 * blue, + 0.11 * red, 0.11 * green, 0.11 * blue, + 0.12 * rgb[1] / 255, 0.12 * rgb[2] / 255, 0.12 * rgb[3] / 255, + ) + var/image/outline = new + outline.appearance = look + outline.add_filter("cyberware_mark_outline", 1, outline_filter(1, mark_color)) + return outline + +/// The outline fades in when the sweep reaches it and out before the mark goes. +/proc/cyberware_mark_outline_reveal(image/outline, delay, duration) + outline.alpha = 0 + animate(outline, alpha = 0, time = max(delay, 0)) + animate(alpha = 210, time = 1) + animate(alpha = 210, time = max(duration - delay - 5, 0)) + animate(alpha = 0, time = 4) + +// ========================================================================= +// WEAPON TRAILS +// ========================================================================= + +/// A landed Scorch cut: an ember crescent across whoever it cut. +/proc/cyberware_scorch_trail_fx(mob/living/user, mob/living/target) + var/turf/here = get_turf(target) + if(isnull(here)) + return + var/swing = get_dir(user, target) || user.dir + new /obj/effect/temp_visual/cyberware_scorch_arc(here, swing) + +/obj/effect/temp_visual/cyberware_scorch_arc + icon = CYBERWARE_ANIM2_LARGE_ICON + icon_state = "scorch_arc" + pixel_x = -16 + pixel_y = -16 + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 0.7 SECONDS + +/obj/effect/temp_visual/cyberware_scorch_arc/Initialize(mapload, swing = SOUTH) + . = ..() + // Diagonal swings read as the side the blade came in from. + setDir(swing & (NORTH | SOUTH) ? (swing & (NORTH | SOUTH)) : swing) + add_overlay(emissive_appearance(icon, icon_state, src)) + particles = new /particles/embers/spark/cyberware_scorch + addtimer(CALLBACK(src, PROC_REF(stop_embers)), 1) + +/obj/effect/temp_visual/cyberware_scorch_arc/proc/stop_embers() + if(particles) + particles.spawning = 0 + +/// One burst of embers off a Scorch cut. +/particles/embers/spark/cyberware_scorch + count = 8 + spawning = 8 + lifespan = 0.6 SECONDS + fade = 0.4 SECONDS + velocity = generator(GEN_CIRCLE, 2, 3, SQUARE_RAND) + gravity = list(0, -0.15) + +/// A landed Widowline Arc swing: the wire lashes out to whatever it hit. +/proc/cyberware_monowire_lash_fx(mob/living/user, atom/target) + if(QDELETED(user) || QDELETED(target) || get_turf(user) == get_turf(target)) + return + user.Beam(target, icon_state = "monowire_lash", icon = CYBERWARE_ANIM2_ICON, time = 0.3 SECONDS) + +/// The Arc's shock: a crackle runs along the wire and sparks burst where it lands. +/proc/cyberware_arc_crackle_fx(mob/living/user, atom/target) + if(QDELETED(user) || QDELETED(target)) + return + if(get_turf(user) != get_turf(target)) + user.Beam(target, icon_state = "arc_crackle", icon = CYBERWARE_ANIM2_ICON, time = 0.5 SECONDS) + var/turf/there = get_turf(target) + if(there) + new /obj/effect/temp_visual/cyberware_arc_hit(there) + +/obj/effect/temp_visual/cyberware_arc_hit + icon = CYBERWARE_ANIM2_LARGE_ICON + icon_state = "arc_hit" + pixel_x = -16 + pixel_y = -16 + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 0.4 SECONDS + +/obj/effect/temp_visual/cyberware_arc_hit/Initialize(mapload) + . = ..() + add_overlay(emissive_appearance(icon, icon_state, src)) + +// ========================================================================= +// BROWNOUT +// ========================================================================= + +/// Step between the frames of a brownout stutter. +#define BROWNOUT_STEP (0.1 SECONDS) + +/** + * The brownout picture for one bearer: every worn piece stutters, warms and + * dims together on the way down, and stutters back on on the way up. The + * brownout itself has already happened; this only plays the picture, and + * clearing it at any point leaves each piece drawn as its state says. + */ +/datum/component/chrome_brownout_show + dupe_mode = COMPONENT_DUPE_UNIQUE + /// Looks still to play, in order. + var/list/steps + /// Timer for the next look. + var/step_timer + /// Pieces whose look this show has set. + var/list/datum/weakref/touched = list() + +/datum/component/chrome_brownout_show/Initialize() + if(!iscarbon(parent)) + return COMPONENT_INCOMPATIBLE + +/datum/component/chrome_brownout_show/RegisterWithParent() + RegisterSignal(parent, COMSIG_LIVING_DEATH, PROC_REF(on_death)) + +/datum/component/chrome_brownout_show/UnregisterFromParent() + UnregisterSignal(parent, COMSIG_LIVING_DEATH) + +/datum/component/chrome_brownout_show/Destroy() + if(step_timer) + deltimer(step_timer) + step_timer = null + var/changed = FALSE + for(var/datum/weakref/piece_ref as anything in touched) + var/obj/item/organ/piece = piece_ref.resolve() + var/datum/component/cyberware/chrome = piece?.GetComponent(/datum/component/cyberware) + if(chrome?.brownout_look) + chrome.brownout_look = null + changed = TRUE + touched.Cut() + var/mob/living/carbon/bearer = parent + if(changed && !QDELETED(bearer)) + bearer.update_body_parts() + return ..() + +/datum/component/chrome_brownout_show/proc/on_death(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// Starts the stutter: down when [tripped], back up otherwise. +/datum/component/chrome_brownout_show/proc/play(tripped) + if(tripped) + steps = list(CHROME_LOOK_WARM, CHROME_LOOK_BROWN, CHROME_LOOK_WARM, CHROME_LOOK_BROWN, CHROME_LOOK_BROWN, CHROME_LOOK_WARM, CHROME_LOOK_BROWN) + else + steps = list(CHROME_LOOK_BROWN, CHROME_LOOK_WARM, CHROME_LOOK_BROWN, CHROME_LOOK_BROWN, CHROME_LOOK_WARM, CHROME_LOOK_BROWN) + if(step_timer) + deltimer(step_timer) + step_timer = null + advance() + +/datum/component/chrome_brownout_show/proc/advance() + step_timer = null + if(!length(steps)) + qdel(src) + return + var/look = popleft(steps) + var/mob/living/carbon/bearer = parent + for(var/obj/item/organ/piece as anything in get_installed_cyberware(bearer)) + var/obj/item/organ/cyberimp/implant = piece + if(!istype(implant) || !implant.bodypart_aug) + continue + var/datum/component/cyberware/chrome = piece.GetComponent(/datum/component/cyberware) + if(!chrome) + continue + chrome.brownout_look = look + touched |= WEAKREF(piece) + bearer.update_body_parts() + step_timer = addtimer(CALLBACK(src, PROC_REF(advance)), BROWNOUT_STEP, TIMER_STOPPABLE) + +/// Hook for the brownout monitor: [bearer]'s chrome just went down ([tripped]) or came back. +/proc/cyberware_brownout_fx(mob/living/carbon/bearer, tripped) + if(!iscarbon(bearer) || QDELETED(bearer)) + return + if(bearer.stat == DEAD) + // No show on a corpse: just draw it as it is. + bearer.update_body_parts() + return + var/datum/component/chrome_brownout_show/show = bearer.GetComponent(/datum/component/chrome_brownout_show) || bearer.AddComponent(/datum/component/chrome_brownout_show) + show?.play(tripped) + +#undef BROWNOUT_STEP + +// ========================================================================= +// CHROME CRADLE RIG +// ========================================================================= + +/// Layers on the cradle: the rig over the ware, both over the patient. +#define RIG_LAYER (ABOVE_MOB_LAYER + 0.02) +#define RIG_WARE_LAYER (ABOVE_MOB_LAYER + 0.01) +/// The gripper's tip, as pixel offsets of the carried ware's centre on the cradle tile. +#define RIG_WARE_X 4 +#define RIG_WARE_PARKED_Y 19 +#define RIG_WARE_WORK_Y 2 +/// How long each move of the rig takes, matching its art. +#define RIG_GRIP_TIME (0.6 SECONDS) +#define RIG_PRESS_TIME (0.4 SECONDS) +#define RIG_RETRACT_TIME (0.3 SECONDS) + +/obj/machinery/chrome_cradle + /// The rig's current move, or null while it is parked. + var/rig_phase + /// The rig drawing that move. + var/datum/weakref/rig_ref + /// The ware in the gripper. + var/datum/weakref/rig_ware_ref + /// Timer that parks the rig once it has pulled back. + var/rig_park_timer + +/obj/machinery/chrome_cradle/update_overlays() + . = ..() + if(rig_phase) + return + // The parked rig, over whoever lies on the slab. + var/mutable_appearance/rig = mutable_appearance(CHROME_CRADLE_RIG_ICON, "rig", RIG_LAYER) + rig.pixel_x = -16 + rig.pixel_y = -16 + . += rig + var/mutable_appearance/rig_glow = emissive_appearance(CHROME_CRADLE_RIG_ICON, "rig_e", src, RIG_LAYER) + rig_glow.pixel_x = -16 + rig_glow.pixel_y = -16 + . += rig_glow + +/// Plays one move of the rig: [phase] is a state of the rig sheet without its "rig_". +/obj/machinery/chrome_cradle/proc/play_rig(phase) + if(rig_park_timer) + deltimer(rig_park_timer) + rig_park_timer = null + qdel(rig_ref?.resolve()) + var/was_parked = isnull(rig_phase) + rig_phase = phase + if(was_parked) + update_appearance(UPDATE_OVERLAYS) + var/state = "rig_[phase]" + var/obj/effect/abstract/cyberware_anim/rig = cyberware_anim_play(src, CHROME_CRADLE_RIG_ICON, state, RIG_LAYER, CYBERWARE_INSTALL_TIME + 1 SECONDS) + if(!rig) + return + rig.pixel_x = -16 + rig.pixel_y = -16 + rig.add_overlay(emissive_appearance(CHROME_CRADLE_RIG_ICON, "[state]_e", src)) + rig_ref = WEAKREF(rig) + +/// A servo beat of the install: the arms come down, the gripper brings the ware, then presses it in. +/obj/machinery/chrome_cradle/proc/rig_beat(mid_stage) + if(busy_action != "install") + return + if(mid_stage) + play_rig("press") + var/obj/effect/abstract/cyberware_anim/carried = rig_ware_ref?.resolve() + if(carried) + animate(carried, pixel_y = RIG_WARE_WORK_Y - 3, transform = matrix().Scale(0.2), alpha = 0, time = RIG_PRESS_TIME, easing = QUAD_EASING | EASE_IN) + return + if(isnull(rig_phase) || rig_phase == "retract") + play_rig("arms_down") + return + play_rig("grip_down") + qdel(rig_ware_ref?.resolve()) + if(QDELETED(busy_ware)) + return + var/obj/effect/abstract/cyberware_anim/carried = cyberware_anim_play(src, busy_ware.icon, busy_ware.icon_state, RIG_WARE_LAYER, CYBERWARE_INSTALL_TIME) + if(!carried) + return + carried.transform = matrix().Scale(0.45) + carried.pixel_x = RIG_WARE_X + carried.pixel_y = RIG_WARE_PARKED_Y + animate(carried, pixel_y = RIG_WARE_WORK_Y, time = RIG_GRIP_TIME, easing = CUBIC_EASING | EASE_OUT) + rig_ware_ref = WEAKREF(carried) + +/// The sequence is over, done or not: the rig pulls back and parks. +/obj/machinery/chrome_cradle/proc/rig_stop() + qdel(rig_ware_ref?.resolve()) + rig_ware_ref = null + if(isnull(rig_phase) || rig_phase == "retract") + return + play_rig("retract") + rig_park_timer = addtimer(CALLBACK(src, PROC_REF(park_rig)), RIG_RETRACT_TIME, TIMER_STOPPABLE) + +/obj/machinery/chrome_cradle/proc/park_rig() + rig_park_timer = null + qdel(rig_ref?.resolve()) + rig_ref = null + rig_phase = null + update_appearance(UPDATE_OVERLAYS) + +/// The ware boots in its new owner: a glitch runs over them. +/obj/machinery/chrome_cradle/proc/rig_boot_fx(mob/living/carbon/patient) + cyberware_glitch_fx(patient, 0.2 SECONDS, flash = TRUE, inset = TRUE) + +#undef RIG_LAYER +#undef RIG_WARE_LAYER +#undef RIG_WARE_X +#undef RIG_WARE_PARKED_Y +#undef RIG_WARE_WORK_Y +#undef RIG_GRIP_TIME +#undef RIG_PRESS_TIME +#undef RIG_RETRACT_TIME + +#undef CYBERWARE_ANIM2_ICON +#undef CYBERWARE_ANIM2_LARGE_ICON +#undef CYBERWARE_ANIM_PING_ICON +#undef CHROME_CRADLE_RIG_ICON +#undef CHROME_ANIM2_TOP_LAYER +#undef CHROME_LOOK_BROWN +#undef CHROME_LOOK_WARM diff --git a/voidcrew/modules/cyberware/cyberware_component.dm b/voidcrew/modules/cyberware/cyberware_component.dm index d1b71cdb0a7..0791a6d821d 100644 --- a/voidcrew/modules/cyberware/cyberware_component.dm +++ b/voidcrew/modules/cyberware/cyberware_component.dm @@ -424,6 +424,9 @@ GLOBAL_LIST_INIT(installed_capacity_costs, list( newly_tripped = TRUE else any_recovered = TRUE + if(newly_tripped || any_recovered) + // Worn chrome stutters down or back up (cyberware_anim2.dm). + cyberware_brownout_fx(target, newly_tripped) if(newly_tripped) target.balloon_alert(target, "chrome brownout!") to_chat(target, span_boldwarning("Your chrome goes dark.")) diff --git a/voidcrew/modules/cyberware/icons/chrome_cradle_rig.dmi b/voidcrew/modules/cyberware/icons/chrome_cradle_rig.dmi new file mode 100644 index 0000000000000000000000000000000000000000..21b7652c4038a5dc1156329ced75b2edfc47f3b2 GIT binary patch literal 4026 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 W{Ay);(rF7cf}Pg3R+-yf&;AY;lb<61 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/cyberware/icons/cyberware_anim2.dmi b/voidcrew/modules/cyberware/icons/cyberware_anim2.dmi new file mode 100644 index 0000000000000000000000000000000000000000..4e38ae86184ba54425ba870e9b1318361b503fd5 GIT binary patch literal 35167 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 OMn!PnuM7X=yZ-^Bl^IX~ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/cyberware/icons/cyberware_anim2_large.dmi b/voidcrew/modules/cyberware/icons/cyberware_anim2_large.dmi new file mode 100644 index 0000000000000000000000000000000000000000..8004507e5b08c65e226aec354f5a98b412046d58 GIT binary patch literal 11560 zcmcI~cT^M6w{8Fx3kXpVqzDEDlqN`(5|9oepdx}4r3gruUP3?=BE9#H6r~ABFHz|r zO?vMj1QKdOAmvTG@1OVIz3aZ;td%vh&)IWkpMCcJ_ID=pN$;^b{YAEmAP|UNQ$ytm z2t=(&`JF!p0)ed4rX+#Wci(3(+*PdIEZ^C=xZ629fk56Fjzdb9esQy&TdXEsoGMr5 zs2?uSc4)u6BSNRI-DX|Pe1sZ#z{C$}=2~v+Gt8f>&weA$!RwtoVDG2&QbjaqF;`X3 z_3xcCT3hiK7k<)Svx-Q3Ewb(u@H*=BB6V)2F8rqSgWBGNneOv;llRwnWf@uRD>jAH z2gg5%O*8+FTW#<#De$xoC2_oO3RzT`*O?0I&O0>Et)*M@cq=~8X4B%#V)*LAgJE7r z5J*r&Q$^{S_w>%3pZoK*RQ_F?&@!PZ5h96gU`J%q-)d-pC>lJ@Y0Eu3hP&|K2 z@l`go6*D4!R^VM9ZfC){OpyCS46zwAkEvd;KLhyie|D(Ym!|VwT&D7#cXdd(@Uyan zVD~gFtn;Pot{zB>IF&6~V5jqxXyU5zbdV6i5-={Hc=KGG(hoOtY9`;ns(eY_*_0#J zQSq@sM|;~Wj3cP~SG59akVtw*yuy9bFI{iN$ZzdP-+y4Awzc6qbv3;Eq?zSxcDV92 z3;n;?=j*)hG}T?J z(>QMKLr>6aRaV7G5c9W3tUtnUFy8jPmJ&+yKXdN?Mq6*&*m6!z{|9VQ0VI8`H)>58 z>ZtWpRq;9V7W`U#&iD%{5kS7cZFSI50i7kUbuB~7A}>f4>ex+>56K}V#`{YE@#__< z9jYNpL*bVIr#~^bAk>Ugf2r=u5$I`R#$H#X6`nMFOb#sV_@T`Zv#F*iRey^cHHTbY zB+(8c>1ZD&cAKTz)GVRo$wDZ4Sj^n*P?L0qR;K%tN?51#R9B3`_3I})$q}3-XSr$wo^OGI3gx(| zF1At4M|8hw8QhZNZa#!iQ6mI}LkIut8ji=CJ-u;D5tQo8NdM95{$*eIe+d#Hg>s-k z({Gh4&|KQ{+=nF474E=$Ow=I84tWm9mEO!DnlrEdNcD1FVEo_2#((2p+95X7`X}lx ztyD&r(i{FjQu~4)N*3tY-M#RT`ix?sS>QBV+QtNsxXko`?N$#RAJx0Uk+gte`n~ds z5;{k2Gk`+)xhveptu2!;+Q zUYk8NZlQW3d&3ArwPlJn3R8ZL@}Ev9gh8ujPp9fgIAZr7B3v+=W-UZv=#skw3Ut1C~(l4Sp`&B2oB%i>W_m*D>f9!vIrF2P(E^W*jgXjwiY2y)y z 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 pvi!jTnKrt-BO;P1VOL+3n2j&ZbyM}92+fQ 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%0A8sp7IUUi? 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.png). + */ +/datum/unit_test/voidcrew_bounty_cyberpsycho + abstract_type = /datum/unit_test/voidcrew_bounty_cyberpsycho + +/// The floor tile `dx`, `dy` from the room's bottom left +/datum/unit_test/voidcrew_bounty_cyberpsycho/proc/tile(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 hunter at `where`: a human with a mind, as a player would be +/datum/unit_test/voidcrew_bounty_cyberpsycho/proc/hunter_at(turf/where) + var/mob/living/carbon/human/consistent/hunter = allocate(/mob/living/carbon/human/consistent, where) + hunter.mind_initialize() + return hunter + +/// A Most Wanted cyberpsycho's record, of `species` +/datum/unit_test/voidcrew_bounty_cyberpsycho/proc/psycho_record(species = /datum/species/human) + var/datum/bounty_record/record = generate_bounty_record(3, "boss", "planet", species) // BOUNTY_TIER_MOST_WANTED, BOUNTY_ARCHETYPE_BOSS, BOUNTY_PLACEMENT_PLANET + record.kit = "cyberpsycho" // BOUNTY_KIT_CYBERPSYCHO + record.chrome = null + return record + +/// A cyberpsycho at `where`, spawned as the board spawns one, its AI off; with `hunter`, turned on them (on its grudge list, in its posse, its target) +/datum/unit_test/voidcrew_bounty_cyberpsycho/proc/psycho_at(turf/where, mob/living/hunter) + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/psycho = spawn_bounty_criminal(psycho_record(), where, null) + if(!psycho) + return null + allocated += psycho + psycho.ai_controller?.set_ai_status(AI_STATUS_OFF) + if(hunter) + psycho.body_add_grudge(hunter) + psycho.boss_turn_hostile(hunter) + psycho.ai_controller?.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) + var/datum/component/bounty_chrome/chrome = psycho.GetComponent(/datum/component/bounty_chrome) + chrome?.update_fight() + return psycho + +/// The organ types of the chrome in `body` +/datum/unit_test/voidcrew_bounty_cyberpsycho/proc/chrome_in(mob/living/carbon/body) + . = list() + for(var/obj/item/organ/organ in body.organs) + if(organ.GetComponent(/datum/component/cyberware)) + . += organ.type + +/// Its ability running `move_type`, if it has one +/datum/unit_test/voidcrew_bounty_cyberpsycho/proc/ability_for(mob/living/carbon/human/npc/bounty_criminal/boss/boss, move_type) + for(var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/ability in boss.boss_abilities) + if(istype(ability.move, move_type)) + return ability + return null + +/// Ready for another move straight away: no gap, no ability cooldowns, hands free to click +/datum/unit_test/voidcrew_bounty_cyberpsycho/proc/ready(mob/living/carbon/human/npc/bounty_criminal/boss/boss) + boss.boss_next_ability_at = 0 + boss.next_move = 0 + boss.next_click = 0 + for(var/datum/action/cooldown/ability as anything in boss.boss_abilities) + ability.next_use_time = 0 + +/// Puts out its blade, as its chrome does on a Life tick in its fight +/datum/unit_test/voidcrew_bounty_cyberpsycho/proc/blade_out(mob/living/carbon/human/npc/bounty_criminal/boss/boss, atom/target) + var/datum/component/bounty_chrome/chrome = boss.GetComponent(/datum/component/bounty_chrome) + var/datum/bounty_chrome_move/mantis_blades/blades = chrome?.move_of(/datum/bounty_chrome_move/mantis_blades) + blades?.on_tick(2, TRUE, target) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis/housing = locate() in boss.organs + return housing?.blade_extended() + +// ===== WHERE IT SITS ===== + +/** + * Where it sits: a kit like the others, the rarest; its record rolls no chrome on top (its kit is its + * chrome); its card's lines, its alias, the Bounty Panel's kit list and its prison riot move are a kit's. + */ +/datum/unit_test/voidcrew_bounty_cyberpsycho/kit + +/datum/unit_test/voidcrew_bounty_cyberpsycho/kit/Run() + var/list/counts = list() + for(var/i in 1 to 2000) + var/kit = bounty_roll_kit() + counts[kit] = (counts[kit] || 0) + 1 + TEST_ASSERT(counts["cyberpsycho"] > 0, "The cyberpsycho never rolled as a kit") + for(var/kit in list("juggernaut", "pyromaniac", "demolitionist", "ghost", "heavy")) // BOUNTY_KIT_* + TEST_ASSERT(counts["cyberpsycho"] < counts[kit], "The cyberpsycho ([counts["cyberpsycho"]]) rolled as often as the [kit] ([counts[kit]])") + var/datum/bounty_record/record = psycho_record() + TEST_ASSERT_EQUAL(bounty_criminal_type(record, null), /mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho, "A cyberpsycho record spawns the wrong type") + TEST_ASSERT_EQUAL(bounty_boss_move_type(record), /datum/prison_boss_move/charge, "A caught cyberpsycho's riot move is [bounty_boss_move_type(record)]") + for(var/i in 1 to 100) + TEST_ASSERT_NULL(bounty_chrome_roll(3, "boss", null, "cyberpsycho", bounty_chrome_kit_organs("cyberpsycho")), "A cyberpsycho's record rolled chrome on top of its own") + var/list/lines = bounty_identity_strings("lines") + for(var/context in list("intro", "ability", "redline", "exhausted", "posse", "cover")) + TEST_ASSERT(length(lines["cyberpsycho_[context]"]?["any"]) >= 6, "The cyberpsycho has fewer than 6 [context] lines") + var/list/aliases = bounty_identity_strings("aliases") + TEST_ASSERT(length(aliases["cyberpsycho"]) >= 5, "The cyberpsycho has too few aliases") + var/datum/bounty_admin_panel/panel = allocate(/datum/bounty_admin_panel, null) + TEST_ASSERT_EQUAL(panel.kit_choices()["Cyberpsycho"], "cyberpsycho", "The Bounty Panel doesn't offer the cyberpsycho") + +// ===== ITS CHROME ===== + +/** + * Its chrome: the six real implants that fit one body, Cascade left out for Redline's operating-system slot, + * Mantis in the right arm and Gorilla in the left; the kit's chrome as the rolls know it; its load over a + * body's 20 with its Redline core carrying the Governor's 15 of capacity through the real hook, so nothing + * browns out. Its rotation is the burn, the lunge, the leap and the piston punch, each told for at least + * 0.8 s. It dresses to show the chrome, and seals up like any mini-boss in the void. + */ +/datum/unit_test/voidcrew_bounty_cyberpsycho/chrome + +/datum/unit_test/voidcrew_bounty_cyberpsycho/chrome/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/psycho = psycho_at(tile(2, 2)) + TEST_ASSERT(istype(psycho), "No cyberpsycho spawned") + TEST_ASSERT_EQUAL(psycho.boss_kit, "cyberpsycho", "The cyberpsycho has the kit [psycho.boss_kit]") + var/list/carried = chrome_in(psycho) + var/list/expected = list( + /obj/item/organ/cyberimp/cyberware/redline, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis, + /obj/item/organ/cyberimp/cyberware/gorilla_arms/left, + /obj/item/organ/cyberimp/cyberware/piledriver, + /obj/item/organ/cyberimp/cyberware/lazarus, + /obj/item/organ/eyes/robotic/cyberware/deadeye, + ) + TEST_ASSERT_EQUAL(length(carried), length(expected), "The cyberpsycho carries [length(carried)] pieces of chrome: [jointext(carried, ", ")]") + for(var/organ_type in expected) + TEST_ASSERT(organ_type in carried, "The cyberpsycho doesn't carry [organ_type]") + var/list/known = bounty_chrome_kit_organs("cyberpsycho") + TEST_ASSERT_EQUAL(length(known), length(carried), "The rolls know of [length(known)] of its pieces") + for(var/organ_type in known) + TEST_ASSERT(organ_type in carried, "The rolls fit around [organ_type], which the cyberpsycho doesn't carry") + TEST_ASSERT_NULL(locate(/obj/item/organ/cyberimp/cyberware/cascade) in psycho.organs, "The cyberpsycho has a Cascade lattice beside its Redline core") + TEST_ASSERT_NULL(psycho.record.chrome, "The cyberpsycho's record lists rolled chrome") + TEST_ASSERT_NULL(locate(/obj/item/organ/eyes) in get_turf(psycho), "The eyes it was born with lie on the floor") + + // The real capacity: 32 load in a body that holds 35, its Redline core carrying the extra + TEST_ASSERT_EQUAL(get_chrome_load(psycho), 32, "The cyberpsycho's chrome loads it [get_chrome_load(psycho)]") + TEST_ASSERT_EQUAL(get_chrome_capacity(psycho), 35, "The cyberpsycho's body holds [get_chrome_capacity(psycho)]") // CYBERWARE_BASE_CAPACITY + BOUNTY_PSYCHO_EXTRA_CAPACITY + var/obj/item/organ/core = locate(/obj/item/organ/cyberimp/cyberware/redline) in psycho.organs + var/datum/component/cyberware/core_ware = core?.GetComponent(/datum/component/cyberware) + TEST_ASSERT_EQUAL(core_ware?.capacity_bonus, 15, "Its Redline core doesn't carry the Governor's capacity") // BOUNTY_PSYCHO_EXTRA_CAPACITY + for(var/obj/item/organ/organ in psycho.organs) + var/datum/component/cyberware/ware = organ.GetComponent(/datum/component/cyberware) + if(!ware) + continue + TEST_ASSERT(!ware.browned_out, "The cyberpsycho's [organ] browned out") + TEST_ASSERT(ware.is_running(), "The cyberpsycho's [organ] isn't running") + + // Its chrome AI, and its rotation: four moves, all told + var/datum/component/bounty_chrome/chrome = psycho.GetComponent(/datum/component/bounty_chrome) + TEST_ASSERT_NOTNULL(chrome, "The cyberpsycho has no chrome AI") + for(var/key in list("redline", "mantis", "gorilla", "piledriver", "lazarus", "deadeye")) // BOUNTY_CHROME_* + TEST_ASSERT(key in chrome.keys, "The cyberpsycho's chrome AI doesn't know its [key]") + var/list/moves = list( + /datum/bounty_chrome_move/redline, + /datum/bounty_chrome_move/mantis_lunge, + /datum/bounty_chrome_move/piledriver, + /datum/bounty_chrome_move/gorilla_punch/psycho, + ) + TEST_ASSERT_EQUAL(length(psycho.boss_abilities), length(moves), "The cyberpsycho has [length(psycho.boss_abilities)] abilities, not its four moves") + for(var/move_type in moves) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/ability = ability_for(psycho, move_type) + TEST_ASSERT_NOTNULL(ability, "The cyberpsycho's [move_type] isn't in its rotation") + TEST_ASSERT(ability?.windup >= 0.8 SECONDS, "The cyberpsycho's [move_type] tells for [ability?.windup / 10] s") // BOUNTY_CHROME_TELL_MIN + TEST_ASSERT(!length(ability?.move?.tell_lines), "The cyberpsycho's [move_type] has its own lines besides the cyberpsycho's") + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/burn = ability_for(psycho, /datum/bounty_chrome_move/redline) + TEST_ASSERT(istype(burn?.move, /datum/bounty_chrome_move/redline/psycho), "Its Redline isn't its phase two: [burn?.move?.type]") + var/count = 0 + for(var/datum/bounty_chrome_move/redline/move in chrome.moves) + count++ + TEST_ASSERT_EQUAL(count, 1, "It has [count] Redline moves, not its own one") + + // Dressed to show it: shorts and boots, nothing on its arms or chest + TEST_ASSERT(psycho.bounty_dresses_to_show_chrome(), "The cyberpsycho dresses to hide its chrome") + TEST_ASSERT(istype(psycho.w_uniform, /obj/item/clothing/under/shorts), "The cyberpsycho wears [psycho.w_uniform] over its legs") + TEST_ASSERT_NULL(psycho.wear_suit, "The cyberpsycho wears [psycho.wear_suit] over its chrome") + TEST_ASSERT_NULL(psycho.gloves, "The cyberpsycho wears gloves over its arms") + TEST_ASSERT(psycho.boss_gear_text(), "The cyberpsycho has no gear line on examine") + // Its two stimulants and a bruise pack, as every mini-boss, where it can reach them; no bag over its spine rig + TEST_ASSERT_EQUAL(length(psycho.get_all_contents_type(/obj/item/reagent_containers/hypospray/medipen/stimpack)), 2, "The cyberpsycho doesn't carry its two stimulants") // BOUNTY_BOSS_STIMULANTS + TEST_ASSERT(length(psycho.get_all_contents_type(/obj/item/stack/medical/bruise_pack)), "The cyberpsycho carries nothing to patch itself up with") + TEST_ASSERT_NOTNULL(bounty_ai_find_injector(psycho, /obj/item/reagent_containers/hypospray/medipen/stimpack), "The cyberpsycho can't reach its stimulants") + TEST_ASSERT_NULL(psycho.back, "The cyberpsycho wears [psycho.back] over its spine rig") + // In the void, a soft suit: it wears no armour in good air either + var/list/void_suit = bounty_vacuum_gear(psycho) + var/soft = FALSE + for(var/list/suit as anything in GLOB.bounty_soft_suits) + if(length(void_suit) == 2 && suit[1] == void_suit[1] && suit[2] == void_suit[2]) + soft = TRUE + TEST_ASSERT(soft, "The cyberpsycho's void suit isn't a soft one: [jointext(void_suit, ", ")]") + + // Dead, its chrome burns out to scrap, all six pieces + var/turf/here = get_turf(psycho) + psycho.death() + TEST_ASSERT_EQUAL(length(chrome_in(psycho)), 0, "A dead cyberpsycho still carries working chrome") + var/scrap = 0 + for(var/obj/item/burnt_chrome/burnt in here) + scrap++ + TEST_ASSERT_EQUAL(scrap, 6, "A dead cyberpsycho's six implants left [scrap] pieces of scrap") + +// ===== ONE MOVE AT A TIME ===== + +/** + * The blade comes out of its right forearm in a fight and is its melee; its left hand, the Gorilla fist, + * stays empty, so it can punch through glass (C5). The piston punch is a told move: the arm's own punch + * lands only after the tell. While one move winds up, no other starts. + */ +/datum/unit_test/voidcrew_bounty_cyberpsycho/rotation + +/datum/unit_test/voidcrew_bounty_cyberpsycho/rotation/Run() + var/mob/living/carbon/human/hunter = hunter_at(tile(3, 2)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/psycho = psycho_at(tile(2, 2), hunter) + TEST_ASSERT(istype(psycho), "No cyberpsycho spawned") + TEST_ASSERT(psycho.boss_hostile, "The cyberpsycho didn't turn on the hunter") + TEST_ASSERT(blade_out(psycho, hunter), "The cyberpsycho's blade didn't come out in its fight") + var/obj/item/blade = psycho.get_item_for_held_index(RIGHT_HANDS) + TEST_ASSERT(istype(blade, /obj/item/melee/energy/blade/hardlight/cyberware_mantis), "The blade isn't in its right hand: [blade]") + TEST_ASSERT_NULL(psycho.get_item_for_held_index(LEFT_HANDS), "Its Gorilla hand holds [psycho.get_item_for_held_index(LEFT_HANDS)]") + TEST_ASSERT_EQUAL(human_npc_held_melee_weapon(psycho), blade, "Its melee isn't its blade") + TEST_ASSERT(psycho.boss_has_gorilla_fist(), "It has no Gorilla fist free to punch through glass with") + + // The piston punch, through its rotation: the tell, then the arm's own punch + ready(psycho) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/punch = ability_for(psycho, /datum/bounty_chrome_move/gorilla_punch/psycho) + TEST_ASSERT(punch.worth_using(hunter), "The piston punch isn't worth using on a hunter beside it") + TEST_ASSERT(punch.Trigger(target = hunter), "The piston punch wouldn't start") + TEST_ASSERT_EQUAL(psycho.boss_busy, "windup", "The piston punch doesn't wind up") + TEST_ASSERT_NOTNULL(psycho.get_filter(punch.move.tell_filter()), "The piston punch's tell has no outline") + // One at a time: nothing else starts while it winds up + for(var/datum/action/cooldown/mob_cooldown/bounty_boss/ability as anything in psycho.boss_abilities) + ability.next_use_time = 0 + TEST_ASSERT_NULL(psycho.boss_choose_ability(hunter), "Another move would start while the piston punch winds up") + TEST_ASSERT_EQUAL(hunter.getBruteLoss(), 0, "The punch landed before its tell") + sleep(1 SECONDS) + TEST_ASSERT(hunter.getBruteLoss() >= 15, "The piston punch did [hunter.getBruteLoss()] brute, not the implant's") // CYBERWARE_GORILLA_PUNCH_DAMAGE + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = locate() in psycho.organs + TEST_ASSERT(!bounty_gorilla_loaded(arm), "The punch wasn't the Gorilla arm's own") + TEST_ASSERT_NULL(psycho.boss_busy, "Still busy after the punch") + TEST_ASSERT(world.time < psycho.boss_next_ability_at, "No gap after the punch before its next move") + +// ===== ITS PHASE TWO ===== + +/** + * A stamina weapon is answered with its Redline: one disabler bolt's worth and, a moment later, the burn's + * tell starts, cutting short whatever it was winding up. It doesn't wear out while its phase two is to + * come. After the tell, the implant's burn: stun-proof, so more stamina doesn't floor it, and still not worn + * out; then the crash floors it in stamina crit, the capture. Once: it never burns again. + */ +/datum/unit_test/voidcrew_bounty_cyberpsycho/burn_stamina + +/datum/unit_test/voidcrew_bounty_cyberpsycho/burn_stamina/Run() + var/mob/living/carbon/human/hunter = hunter_at(tile(4, 2)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/psycho = psycho_at(tile(1, 2), hunter) + TEST_ASSERT(istype(psycho), "No cyberpsycho spawned") + TEST_ASSERT(blade_out(psycho, hunter), "The cyberpsycho's blade didn't come out") + ready(psycho) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/burn = ability_for(psycho, /datum/bounty_chrome_move/redline) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/lunge = ability_for(psycho, /datum/bounty_chrome_move/mantis_lunge) + TEST_ASSERT(!burn.worth_using(hunter), "An unhurt cyberpsycho reaches for its Redline") + // Winding up a lunge when the bolt lands + TEST_ASSERT(lunge.worth_using(hunter), "The lunge isn't worth using on a hunter three tiles off") + TEST_ASSERT(lunge.Trigger(target = hunter), "The lunge wouldn't start") + TEST_ASSERT_EQUAL(psycho.boss_winding(), lunge, "The lunge isn't winding up") + psycho.apply_damage(30, STAMINA) // a disabler bolt + TEST_ASSERT(!psycho.boss_is_worn_out(), "One bolt wore it out") + sleep(0.2 SECONDS) + TEST_ASSERT_EQUAL(psycho.boss_winding(), burn, "A stamina hit didn't start its Redline's tell") + TEST_ASSERT(!lunge.pending_serial, "The lunge it was winding up still goes") + psycho.adjustStaminaLoss(30) + TEST_ASSERT(!psycho.boss_is_worn_out(), "Winded with its phase two to come, it wears out instead of burning") + TEST_ASSERT_NULL(psycho.has_status_effect(/datum/status_effect/cyberware_redline_window), "The burn started before its tell was over") + sleep(1.1 SECONDS) + + // The implant's own burn + TEST_ASSERT_NOTNULL(psycho.has_status_effect(/datum/status_effect/cyberware_redline_window), "The tell ended but the implant's burn didn't start") + TEST_ASSERT(psycho.psycho_burned, "Its phase two isn't spent") + TEST_ASSERT(HAS_TRAIT(psycho, TRAIT_STUNIMMUNE), "The burn gives no stun immunity") + psycho.apply_damage(60, STAMINA) + TEST_ASSERT_NULL(psycho.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "Stamina floored it in the burn") + TEST_ASSERT(!psycho.boss_is_worn_out(), "It wears out in the burn") + TEST_ASSERT(!burn.worth_using(hunter), "It would burn again while burning") + + // The crash: stamina crit, the capture + sleep(12.4 SECONDS) + TEST_ASSERT_NULL(psycho.has_status_effect(/datum/status_effect/cyberware_redline_window), "The burn outlasted the implant's 12 seconds") + TEST_ASSERT_NOTNULL(psycho.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "The crash didn't floor it in stamina crit") + TEST_ASSERT_EQUAL(psycho.capture_state(), "stunned", "The crash isn't a stunned capture") // BOUNTY_STATE_STUNNED + // Once a spawn: back on its feet and winded again, no second burn, and now it tires like any mini-boss + psycho.remove_status_effect(/datum/status_effect/incapacitating/stamcrit) + psycho.setStaminaLoss(70) + burn.next_use_time = 0 + TEST_ASSERT(!burn.worth_using(hunter), "It would burn its Redline a second time") + TEST_ASSERT(psycho.boss_is_worn_out(), "With its phase two spent, it doesn't tire") + +/** + * Damage sets it off too: hurt to three quarters of its health, it burns. Not with someone outside its + * fight in the roar's reach (the bystander gate). An EMP in the tell fires nothing and takes its core + * down, sparking; with its core down it tires like any mini-boss. + */ +/datum/unit_test/voidcrew_bounty_cyberpsycho/burn_hurt + +/datum/unit_test/voidcrew_bounty_cyberpsycho/burn_hurt/Run() + var/mob/living/carbon/human/hunter = hunter_at(tile(4, 2)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/psycho = psycho_at(tile(1, 2), hunter) + TEST_ASSERT(istype(psycho), "No cyberpsycho spawned") + ready(psycho) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/burn = ability_for(psycho, /datum/bounty_chrome_move/redline) + // A bystander in the roar's reach keeps it shut + var/mob/living/carbon/human/bystander = hunter_at(tile(1, 4)) + psycho.adjustBruteLoss(30) + TEST_ASSERT(psycho.psycho_hurt_enough(), "Hurt to [psycho.health] doesn't count") + TEST_ASSERT(!burn.worth_using(hunter), "It burns with a bystander in the roar's reach") + qdel(bystander) + TEST_ASSERT(burn.worth_using(hunter), "Hurt to [psycho.health] in its fight, it won't burn") + // Its own companion beside it doesn't keep it shut: a crew of hunters brings those in (C0's gate would) + var/datum/human_npc_order/order = bounty_spawn_companion(psycho, tile(1, 3), null, TRUE, bounty_test_company_member("brawler", null)) // BOUNTY_STYLE_BRAWLER + var/mob/living/carbon/human/npc/bounty_companion/buddy = order?.spawned_ref?.resolve() + qdel(order) + TEST_ASSERT_NOTNULL(buddy, "Test fixture: no companion was made") + allocated += buddy + buddy.ai_controller?.set_ai_status(AI_STATUS_OFF) + TEST_ASSERT(bounty_ai_same_gang(psycho, buddy), "Test fixture: the companion isn't on its side") + var/datum/bounty_chrome_move/redline/move = burn.move + TEST_ASSERT(!bounty_chrome_area_clear(psycho, move.area_turfs(hunter), hunter), "Test fixture: C0's gate lets the roar go over its own companion") + TEST_ASSERT(burn.worth_using(hunter), "Its own companion beside it keeps its phase two shut") + TEST_ASSERT_EQUAL(psycho.boss_choose_ability(hunter), burn, "Its rotation doesn't put its phase two first") + TEST_ASSERT(psycho.psycho_burn_now(), "The burn's tell didn't start") + TEST_ASSERT_EQUAL(psycho.boss_winding(), burn, "The burn isn't winding up") + + // An EMP in the tell: nothing fires, its chrome sparks and reboots, and now it tires + psycho.emp_act(EMP_HEAVY) + TEST_ASSERT_NULL(psycho.boss_winding(), "An EMP didn't cut the burn's tell short") + sleep(1.2 SECONDS) + TEST_ASSERT_NULL(psycho.has_status_effect(/datum/status_effect/cyberware_redline_window), "It burned through an EMP in the tell") + TEST_ASSERT(!psycho.psycho_burned, "An EMP'd tell spent its phase two") + TEST_ASSERT_NOTNULL(psycho.has_status_effect(/datum/status_effect/bounty_chrome_malfunction), "Its chrome doesn't spark after the EMP") + var/obj/item/organ/core = locate(/obj/item/organ/cyberimp/cyberware/redline) in psycho.organs + var/datum/component/cyberware/core_ware = core?.GetComponent(/datum/component/cyberware) + TEST_ASSERT(core_ware?.emp_down, "The EMP didn't take its core down") + TEST_ASSERT(!psycho.psycho_burn_pending(), "With its core down, its phase two still waits") + psycho.setStaminaLoss(70) + TEST_ASSERT(psycho.boss_is_worn_out(), "With its core down, it doesn't tire") + TEST_ASSERT_EQUAL(get_chrome_capacity(psycho), 35, "An EMP took its capacity away, browning its chrome out") // CYBERWARE_BASE_CAPACITY + BOUNTY_PSYCHO_EXTRA_CAPACITY + +// ===== LAZARUS ===== + +/// Beaten into crit, the node's own jolt stands it up after its charge; down again in the same fight, it stays down +/datum/unit_test/voidcrew_bounty_cyberpsycho/lazarus + +/datum/unit_test/voidcrew_bounty_cyberpsycho/lazarus/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/psycho = psycho_at(tile(2, 2)) + TEST_ASSERT(istype(psycho), "No cyberpsycho spawned") + var/obj/item/organ/cyberimp/cyberware/lazarus/node = locate() in psycho.organs + TEST_ASSERT_NOTNULL(node, "The cyberpsycho has no Lazarus node") + for(var/i in 1 to 30) + if(psycho.stat != CONSCIOUS) + break + psycho.adjustBruteLoss(10) + psycho.updatehealth() + TEST_ASSERT(psycho.is_downed(), "The beating didn't put it in crit (health [psycho.health])") + TEST_ASSERT(node.jolting, "Going into crit didn't start the node's charge") + sleep(1.3 SECONDS) + TEST_ASSERT_EQUAL(psycho.stat, CONSCIOUS, "The node didn't stand it up (health [psycho.health])") + for(var/i in 1 to 30) + if(psycho.stat != CONSCIOUS) + break + psycho.adjustBruteLoss(10) + psycho.updatehealth() + TEST_ASSERT(psycho.is_downed(), "The second beating didn't put it in crit") + sleep(1.3 SECONDS) + TEST_ASSERT(psycho.is_downed(), "It got up a second time in one fight") + +// ===== THE BOUNTY PANEL ===== + +/// The panel spawns one by its kit, and "All of it" is the cyberpsycho whatever archetype was picked +/datum/unit_test/voidcrew_bounty_cyberpsycho/admin + +/datum/unit_test/voidcrew_bounty_cyberpsycho/admin/Run() + var/mob/living/carbon/human/operator = allocate(/mob/living/carbon/human/consistent, tile(4, 4)) + var/datum/bounty_admin_panel/unit_test/panel = allocate(/datum/bounty_admin_panel/unit_test, operator) + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/by_kit = panel.admin_spawn_criminal(operator, 3, "boss", "cyberpsycho", null, tile(1, 1), "roll") // BOUNTY_ADMIN_CHROME_ROLL + allocated += by_kit + TEST_ASSERT(istype(by_kit), "The panel's cyberpsycho spawned as [by_kit?.type]: [panel.error]") + TEST_ASSERT_EQUAL(length(chrome_in(by_kit)), 6, "The panel's cyberpsycho carries [length(chrome_in(by_kit))] pieces") + TEST_ASSERT_NULL(by_kit?.record?.chrome, "Rolled as usual, the cyberpsycho rolled chrome on top of its own") + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/all = panel.admin_spawn_criminal(operator, 3, "normal", "brawler", null, tile(3, 1), "all") // BOUNTY_ADMIN_CHROME_ALL + allocated += all + TEST_ASSERT(istype(all), "All of it spawned [all?.type], not the cyberpsycho") + TEST_ASSERT_EQUAL(all?.record?.archetype, "boss", "All of it's record isn't a mini-boss's") // BOUNTY_ARCHETYPE_BOSS + TEST_ASSERT_NULL(all?.record?.style, "All of it kept a fighting style") + TEST_ASSERT_EQUAL(length(chrome_in(all)), 6, "All of it carries [length(chrome_in(all))] pieces") + TEST_ASSERT(!panel.said("didn't fit"), "The admin was told some of it didn't fit") + +// ===== RENDERS ===== + +/// Not a check: with -DBOUNTY_CHROME_RENDERS, writes the cyberpsycho as spawned and with its blade out, every way it faces +/datum/unit_test/voidcrew_bounty_cyberpsycho/renders + +/datum/unit_test/voidcrew_bounty_cyberpsycho/renders/Run() +#ifdef BOUNTY_CHROME_RENDERS + var/mob/living/carbon/human/hunter = hunter_at(tile(4, 4)) + for(var/datum/species/kind as anything in list(/datum/species/human, /datum/species/lizard, /datum/species/moth)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/psycho = spawn_bounty_criminal(psycho_record(kind), tile(2, 2), null) + allocated += psycho + psycho.ai_controller?.set_ai_status(AI_STATUS_OFF) + var/name = "[initial(kind.id)]" + for(var/facing in list(SOUTH, EAST, NORTH, WEST)) + fcopy(getFlatIcon(psycho, facing, no_anim = TRUE), "data/bounty_chrome_renders/cyberpsycho_[name]_[dir2text(facing)].png") + psycho.body_add_grudge(hunter) + psycho.boss_turn_hostile(hunter) + blade_out(psycho, hunter) + for(var/facing in list(SOUTH, EAST, NORTH, WEST)) + fcopy(getFlatIcon(psycho, facing, no_anim = TRUE), "data/bounty_chrome_renders/cyberpsycho_[name]_blade_[dir2text(facing)].png") + qdel(psycho) +#endif diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_identity.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_identity.dm index 8bbbc927a63..b191be83929 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_identity.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_identity.dm @@ -10,7 +10,7 @@ /datum/unit_test/voidcrew_bounty_identity_records/Run() var/list/styles = list("brawler", "knife", "pistol", "shotgun", "smg", "revolver", "club", "bottle") // BOUNTY_STYLE_*, normal - var/list/kits = list("juggernaut", "pyromaniac", "demolitionist", "ghost", "heavy") // BOUNTY_KIT_* + var/list/kits = list("juggernaut", "pyromaniac", "demolitionist", "ghost", "heavy", "cyberpsycho") // BOUNTY_KIT_* var/list/labels = bounty_feature_labels() // BOUNTY_TIER_*, BOUNTY_ARCHETYPE_*, BOUNTY_PLACEMENT_* for(var/tier in list(1, 2, 3)) diff --git a/wiki/content/bounty-hunting.md b/wiki/content/bounty-hunting.md index b92220bf2f7..7d5d095c05a 100644 --- a/wiki/content/bounty-hunting.md +++ b/wiki/content/bounty-hunting.md @@ -109,7 +109,7 @@ About 70% of Wanted criminals and 30% of Petty ones are normal. A Wanted crimina ### Mini-bosses -Every Most Wanted criminal is a mini-boss with one of five kits. The board's **Other crews hunting** line excludes your own active hunt. +Every Most Wanted criminal is a mini-boss with one of six kits. The board's **Other crews hunting** line excludes your own active hunt. - They wear their kit's armour and take hits better than most people. - Every hunter after the first brings one more of their crew, who turns up from somewhere out of sight. @@ -125,6 +125,7 @@ Every Most Wanted criminal is a mini-boss with one of five kits. The board's **O | Demolitionist | A criminal in a hard hat with a grenade bandolier | Keeps 4 to 6 tiles away and throws grenades with a blinking fuse. Blows holes in interior walls and doors to reach you behind cover. | Close the distance, and move off the blinking ring. | | Ghost | A hooded criminal with a combat knife | Fast. Dashes in with knife cuts that bleed, cloaks into a shimmer, and backs off after two cuts. Its implants make shots miss now and then while it moves, much more often during its burst of speed. | A flash or a hit breaks the cloak, and an EMP ends the burst. Shoot it when it stops moving. | | Heavy | A heavily armed criminal with a belt-fed gun | Heavy armour, slow. Shows a laser sight and a red cone, then fires a burst into it. Throws barrier grenades that open into a barricade. | Get out of the red cone before it fires. Go around the barricade. | +| Cyberpsycho (rare) | A twitching criminal in shorts, chrome on its arms, chest and legs | Fights with a blade from its forearm. Lunges down a lit line, leaps onto you under a red mark, and throws a piston punch. Its chrome fist breaks windows. Hurt, or hit with a stun weapon, it roars and shrugs off stuns for a while, then collapses. Gets up once after it goes down. | Get off the line and out from under the mark. Back off while it roars, and cuff it when it collapses. An EMP makes its chrome spark and shuts it down for a while, and then it tires like the others. | !!! warning "Worn out means switch to stuns" A mini-boss in critical condition only pays 60% on the pad. Once it's worn out, stun it and cuff it, or patch it up before you turn it in. From e2adde9ccf01c4440e2bf27f874f78bd051dc598 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:18:03 -0800 Subject: [PATCH 185/472] Reuse existing local names in the cyberpsycho tests --- .../unit_tests/voidcrew_bounty_cyberpsycho.dm | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_cyberpsycho.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_cyberpsycho.dm index 3deb0d95dae..ccdf7f7169e 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_cyberpsycho.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_cyberpsycho.dm @@ -189,12 +189,12 @@ TEST_ASSERT_NOTNULL(bounty_ai_find_injector(psycho, /obj/item/reagent_containers/hypospray/medipen/stimpack), "The cyberpsycho can't reach its stimulants") TEST_ASSERT_NULL(psycho.back, "The cyberpsycho wears [psycho.back] over its spine rig") // In the void, a soft suit: it wears no armour in good air either - var/list/void_suit = bounty_vacuum_gear(psycho) + var/list/gear = bounty_vacuum_gear(psycho) var/soft = FALSE for(var/list/suit as anything in GLOB.bounty_soft_suits) - if(length(void_suit) == 2 && suit[1] == void_suit[1] && suit[2] == void_suit[2]) + if(length(gear) == 2 && suit[1] == gear[1] && suit[2] == gear[2]) soft = TRUE - TEST_ASSERT(soft, "The cyberpsycho's void suit isn't a soft one: [jointext(void_suit, ", ")]") + TEST_ASSERT(soft, "The cyberpsycho's void suit isn't a soft one: [jointext(gear, ", ")]") // Dead, its chrome burns out to scrap, all six pieces var/turf/here = get_turf(psycho) @@ -385,11 +385,11 @@ /datum/unit_test/voidcrew_bounty_cyberpsycho/admin/Run() var/mob/living/carbon/human/operator = allocate(/mob/living/carbon/human/consistent, tile(4, 4)) var/datum/bounty_admin_panel/unit_test/panel = allocate(/datum/bounty_admin_panel/unit_test, operator) - var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/by_kit = panel.admin_spawn_criminal(operator, 3, "boss", "cyberpsycho", null, tile(1, 1), "roll") // BOUNTY_ADMIN_CHROME_ROLL - allocated += by_kit - TEST_ASSERT(istype(by_kit), "The panel's cyberpsycho spawned as [by_kit?.type]: [panel.error]") - TEST_ASSERT_EQUAL(length(chrome_in(by_kit)), 6, "The panel's cyberpsycho carries [length(chrome_in(by_kit))] pieces") - TEST_ASSERT_NULL(by_kit?.record?.chrome, "Rolled as usual, the cyberpsycho rolled chrome on top of its own") + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/spawned = panel.admin_spawn_criminal(operator, 3, "boss", "cyberpsycho", null, tile(1, 1), "roll") // BOUNTY_ADMIN_CHROME_ROLL + allocated += spawned + TEST_ASSERT(istype(spawned), "The panel's cyberpsycho spawned as [spawned?.type]: [panel.error]") + TEST_ASSERT_EQUAL(length(chrome_in(spawned)), 6, "The panel's cyberpsycho carries [length(chrome_in(spawned))] pieces") + TEST_ASSERT_NULL(spawned?.record?.chrome, "Rolled as usual, the cyberpsycho rolled chrome on top of its own") var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/all = panel.admin_spawn_criminal(operator, 3, "normal", "brawler", null, tile(3, 1), "all") // BOUNTY_ADMIN_CHROME_ALL allocated += all TEST_ASSERT(istype(all), "All of it spawned [all?.type], not the cyberpsycho") From c51cb07a150a231408fdd4ced3ce1d2c7db6c532 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:28:38 -0800 Subject: [PATCH 186/472] Give each bounty mini-boss its own answer to disablers A solo hunter with a disabler stamcritted most mini-bosses in about two seconds. Each kit now has its own counter, all of it real gear and real behaviour: - Juggernaut: holds a tower shield (the Phalanx buckler) and raises it as its fight starts. Shots from in front of it stop at the shield; from the side or behind they land, and bullets from the flank wear the shield out. - Heavy: an ablative trenchcoat with its hood up in place of SWAT armour. Half of all beams that hit it reflect and the rest meet energy armour 60; bullets go through it. In vacuum it still seals up in an armoured suit. - Demolitionist: a CNS rebooter in its head. Its first stamina crit of a minute ends after a second and gives it six seconds of stun immunity; the next one holds long enough to cuff it. - Ghost: under stun fire from beyond its dash it runs for cover out of the shooter's line, and comes again with its dash once someone is in reach. - Pyromaniac: goes for whoever shoots it until its flamer reaches them, throwing its molotov or sweeping its flamer whenever either is ready. Every boss now uses its stimulant pens through combat sense: when it tires in a fight, never while one is still in its blood, instead of one at the start of every fight. Worn out, it takes its pen before falling back. Examine lines name the shield, the coat and the implant. --- voidcrew/_DEFINES/bounty_bosses.dm | 18 +- voidcrew/modules/bounties/bounty_boss.dm | 126 +++--- voidcrew/modules/bounties/bounty_boss_kits.dm | 160 +++++-- .../unit_tests/voidcrew_bounty_boss_reach.dm | 8 +- .../unit_tests/voidcrew_bounty_bosses.dm | 391 ++++++++++++++++-- .../unit_tests/voidcrew_bounty_suits.dm | 12 +- 6 files changed, 592 insertions(+), 123 deletions(-) diff --git a/voidcrew/_DEFINES/bounty_bosses.dm b/voidcrew/_DEFINES/bounty_bosses.dm index 3821a09db85..d5573d3c9d2 100644 --- a/voidcrew/_DEFINES/bounty_bosses.dm +++ b/voidcrew/_DEFINES/bounty_bosses.dm @@ -13,9 +13,9 @@ /// Brute and burn a boss takes, as a multiple of a person's, through its physiology (npc_damage_taken, defaults 1.5). Its armour does the rest. (phase D) #define BOUNTY_BOSS_DAMAGE_TAKEN 0.6 -/// Its combat stimulant: one at the start of a fight, a second for its second wind (defaults 1.6, CR-4). Ephedrine: baton resistance, speed and quicker recovery from stuns, never immunity, so a crew can still stun and cuff a worn-out boss. (phase D) +/// Its combat stimulant, used through combat sense's rule (human_npc_combat_sense.dm) when it tires in a fight, never while one is still in its blood (C8; it used to inject at the start of a fight, defaults 1.6, CR-4). Ephedrine: baton resistance, speed and quicker recovery from stuns, never immunity, so a crew can still stun and cuff a worn-out boss. (phase D) #define BOUNTY_BOSS_STIMULANT /obj/item/reagent_containers/hypospray/medipen/stimpack -/// How many of BOUNTY_BOSS_STIMULANT it carries. (phase D) +/// How many of BOUNTY_BOSS_STIMULANT it carries: one for when it first tires, one for a long fight (an ephedrine pen lasts about 100 s). (phase D) #define BOUNTY_BOSS_STIMULANTS 2 /// Worn out: stamina loss over this share of its max stamina... (phase D, CB-2) #define BOUNTY_BOSS_WORN_OUT_STAMINA 0.5 @@ -102,6 +102,20 @@ /// Fire stacks (tg's) a boss's burning floor adds every second to anyone standing in it. (phase D, CB-8) #define BOUNTY_BOSS_FIRE_TICK_STACKS 1 +// ===== EACH KIT'S ANSWER TO A DISABLER (C8) ===== +// Real gear a player can own too, worn and locked to the body like the rest of the kit (D3). Measured in +// the port's boss model: a solo hunter with a disabler stamcritted any of them in about two seconds before. + +/// The Juggernaut's tower shield, held from the start and raised as its fight starts: raised, it stops every shot from the half of the map the Juggernaut faces (the Phalanx buckler's own rule, loot/uniques/armory.dm). Flank it. +#define BOUNTY_JUGGERNAUT_SHIELD /obj/item/shield/riot/phalanx_buckler +/// The Heavy's coat: an ablative trenchcoat with its hood up, energy and laser armour 60 on every limb and its head, and half of all beams that hit it reflected (tg's own). Bullets go through it. Bring lethal, or a lot of shots. +#define BOUNTY_HEAVY_COAT /obj/item/clothing/suit/hooded/ablative +/// The Demolitionist's implant: tg's CNS rebooter, one neural load (cyberware_component.dm). Its first stamina crit of a minute lasts a second, then six seconds of stun immunity; cuff it on the second. +#define BOUNTY_DEMOLITIONIST_REBOOTER /obj/item/organ/cyberimp/brain/anti_stun +/// The Ghost's cover from stun fire (combat sense): how many steps it runs for cover, and how many runs it makes in one exchange of fire. Corner it. +#define BOUNTY_GHOST_COVER_STEPS 5 +#define BOUNTY_GHOST_COVER_MOVES 4 + // ===== JUGGERNAUT ===== /// Charge: a roar, a stamp and a cracked-floor line diff --git a/voidcrew/modules/bounties/bounty_boss.dm b/voidcrew/modules/bounties/bounty_boss.dm index 6633324a6a7..6e8e60e3edd 100644 --- a/voidcrew/modules/bounties/bounty_boss.dm +++ b/voidcrew/modules/bounties/bounty_boss.dm @@ -14,10 +14,11 @@ * crit, stamina, stamcrit, stuns, cuffs, wounds, bleeding and death are tg's, exactly as a player's: * - Toughness is real armour from its kit (locked to its body, D3) and npc_damage_taken * (BOUNTY_BOSS_DAMAGE_TAKEN), fixed whatever the posse (defaults 1.5, OC-8). Never health. - * - Resistance to stuns is its armour and a real stimulant pen it carries and injects, with a visible - * emote, when its fight starts (defaults 1.6). A second pen is its second wind. + * - Resistance to stuns is its armour, its kit's own answer to a disabler (bounty_boss_kits.dm, C8) and + * the real stimulant pens it carries, used the way combat sense uses them (human_npc_combat_sense.dm): + * when it tires in a fight, never while one is still in its blood (/datum/npc_combat_sense/bounty/boss). * - "Worn out" is its AI reading real state (stamina loss over half its pool, or health under 40%): it - * says so, falls back, and then takes its second pen if it has one (CB-2, CR-4). + * says so, takes a pen if it wants one, and falls back (CB-2, CR-4). * - It fights with what it holds (real knives, a real torch, a real gun, its fists) through its hands, * the way a player does (controller.ai_interact()). * - It never surrenders. Bosses execute nobody this phase (the core's hv_can_execute(), OC-9). @@ -30,7 +31,8 @@ * beyond the first brings one more companion in from out of their view, one at a time (OC-8, CB-1), * up to BOUNTY_BOSS_REINFORCEMENTS_MAX. Never health. * - Its AI: soft crit and breaking free first (the human NPC base's and the core's); nothing while - * busy or disabled; worn out, it falls back; its stimulant; a target; its kit's gear; an ability, + * busy or disabled; worn out, it falls back; a target; its combat sense (its stimulant, and the kit's + * reaction to stun fire, if it has one); its kit's gear; an ability, * never the same one twice in a row; the kit's way of closing in or keeping away; up to and through * whatever is in its way, if its kit can go through it; its melee; patching itself up with nobody * about; with nobody in sight, the hunt for the nearest hunter still on its site that it can get at; @@ -110,6 +112,8 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) // Fixed toughness, whatever its tier or posse (defaults 1.5, OC-8). Its armour does the rest. npc_damage_taken = BOUNTY_BOSS_DAMAGE_TAKEN body_tier_toughness = FALSE + // Its stimulant pens, and a kit's answer to stun fire (C8). A kit with its own reaction uses a subtype. + npc_combat_sense = /datum/npc_combat_sense/bounty/boss /// BOUNTY_KIT_*: which kit it is, and the prefix of its dialogue contexts var/boss_kit /// The ability types it is given @@ -149,10 +153,6 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /// Worn out (boss_is_worn_out(), read by its AI): said so, and falls back until boss_fallback_until var/boss_worn_out = FALSE var/boss_fallback_until = 0 - /// Stimulant pens it has used: the first when its fight starts, the second for its second wind - var/boss_stimulants_used = 0 - /// Its fall back is over and it is still worn out: its next plan takes the second pen - var/boss_second_wind_due = FALSE /// Hunters who hurt it from where it couldn't get at them: weakref -> world.time of the last such hit (boss_note_reach()) var/list/boss_shunned = list() /// The tiles it can walk to (boss_walkable()): turf -> steps, the tile it stood on then, and when that was worked out @@ -229,8 +229,9 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss)) /** - * Packs what every boss carries into `outfit`, an instance: BOUNTY_BOSS_STIMULANTS combat stimulants - * and a bruise pack, in a bag on its back (one is added if it wears none). Returns the outfit. + * Packs what every boss carries into `outfit`, an instance: BOUNTY_BOSS_STIMULANTS stimulant pens (its + * combat sense uses them when it tires) and a bruise pack, in a bag on its back (one is added if it wears + * none). Returns the outfit. */ /mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_supplies(datum/outfit/outfit) if(!istype(outfit)) @@ -287,19 +288,12 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /// It has just worn out: it says so where the fight is, drops what it was winding up and falls back for a while /mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_wear_out() boss_worn_out = TRUE - boss_second_wind_due = FALSE boss_fallback_until = world.time + BOUNTY_BOSS_FALLBACK_TIME boss_interrupt() visible_message(span_warning("[src] is breathing hard and slowing down. [p_They()] look[p_s()] worn out.")) body_alert_hunters("worn out") boss_bark("exhausted") -/// Whether its next plan should take a stimulant: the first when its fight starts, the second for its second wind -/mob/living/carbon/human/npc/bounty_criminal/boss/proc/boss_wants_stimulant() - if(!boss_hostile || boss_stimulants_used >= BOUNTY_BOSS_STIMULANTS) - return FALSE - return !boss_stimulants_used || boss_second_wind_due - // ----- busy ----- /** @@ -1744,7 +1738,8 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /** * Its planning, on a real human body (contract 2.4): soft crit and breaking free first; nothing while - * it is busy or can't act; worn out, it falls back; its stimulant; its target; its kit's gear; drawing + * it is busy or can't act; worn out, it falls back; its target; its combat sense (a stimulant when it + * tires, and a kit's own reaction to stun fire); its kit's gear; drawing * its weapon once its fight has started; an ability; the kit's way of closing in or keeping away; up to * and through whatever is in its way; its melee with what it holds; patching itself up with nobody * about; with nobody in sight, the hunt for the nearest hunter still on its site that it can get at; @@ -1764,9 +1759,9 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /datum/ai_planning_subtree/bounty_escape_captivity, /datum/ai_planning_subtree/bounty_boss_busy, /datum/ai_planning_subtree/bounty_boss_worn_out, - /datum/ai_planning_subtree/bounty_boss_stimulant, /datum/ai_planning_subtree/bounty_boss_target, /datum/ai_planning_subtree/simple_find_target, + /datum/ai_planning_subtree/npc_combat_sense, /datum/ai_planning_subtree/bounty_boss_gear, /datum/ai_planning_subtree/human_npc_arm/bounty_boss, /datum/ai_planning_subtree/bounty_boss_abilities, @@ -1817,9 +1812,10 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) /** * Worn out (boss_is_worn_out(), its body's real state): the first time, it says so and shows it to - * the hunters (boss_wear_out()); then it falls back from whoever it was fighting for - * BOUNTY_BOSS_FALLBACK_TIME, and after that its second wind is due (bounty_boss_stimulant). Back above - * the line (healed, or its stamina back), it is fresh again. + * the hunters (boss_wear_out()). If it wants a stimulant pen now (combat sense's own rule: winded, in a + * fight, none in its blood) it takes it first; then it falls back from whoever it was fighting for + * BOUNTY_BOSS_FALLBACK_TIME and fights on. Back above the line (healed, or its stamina back), it is fresh + * again. */ /datum/ai_planning_subtree/bounty_boss_worn_out @@ -1833,9 +1829,10 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) return if(!boss.boss_worn_out) boss.boss_wear_out() + var/datum/component/npc_combat_sense/sense = boss.GetComponent(/datum/component/npc_combat_sense) + if(sense?.rules.can_act(boss, controller) && sense.rules.plan_stim(boss, controller, sense)) + return SUBTREE_RETURN_FINISH_PLANNING if(world.time >= boss.boss_fallback_until) - if(boss.boss_fallback_until && boss.boss_stimulants_used < BOUNTY_BOSS_STIMULANTS) - boss.boss_second_wind_due = TRUE boss.boss_fallback_until = 0 return var/atom/threat = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] || boss.boss_revenge_target() @@ -1845,38 +1842,6 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) controller.queue_behavior(/datum/ai_behavior/run_away_from_target, BB_BOUNTY_BOSS_FALL_BACK_FROM) return SUBTREE_RETURN_FINISH_PLANNING -/** - * Its combat stimulant (defaults 1.6, CR-4): the first pen as its fight starts, the second for its - * second wind once a fall back is over, each a real pen it carries, used on itself the player's way - * through the core's bounty_ai_use_injector() (a visible emote first). The second comes with its - * "recover" line ("Round two."). While a pen is going in, it keeps at it. - */ -/datum/ai_planning_subtree/bounty_boss_stimulant - -/datum/ai_planning_subtree/bounty_boss_stimulant/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) - var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = controller.pawn - if(!istype(boss) || !boss.boss_hostile) - return - var/obj/item/reagent_containers/hypospray/medipen/using = controller.blackboard[BB_HUMAN_NPC_TREAT_ITEM] - if(istype(using) && !QDELETED(using) && !using.used_up && bounty_ai_is_stimulant(using)) - controller.queue_behavior(/datum/ai_behavior/human_npc_treat_self, BB_HUMAN_NPC_TREAT_ITEM) - return SUBTREE_RETURN_FINISH_PLANNING - if(!boss.boss_wants_stimulant()) - return - var/obj/item/reagent_containers/hypospray/medipen/pen = bounty_ai_find_injector(boss, BOUNTY_BOSS_STIMULANT) - if(isnull(pen)) - // Nothing left to take: it fights on as it is. - boss.boss_stimulants_used = BOUNTY_BOSS_STIMULANTS - boss.boss_second_wind_due = FALSE - return - if(!bounty_ai_use_injector(controller, pen)) - return - boss.boss_stimulants_used++ - if(boss.boss_second_wind_due) - boss.boss_second_wind_due = FALSE - INVOKE_ASYNC(boss, TYPE_PROC_REF(/mob/living, bounty_say), "recover", null, TRUE) - return SUBTREE_RETURN_FINISH_PLANNING - /// Drops a target it may no longer go after, and turns on whoever hurt it last when it has none /datum/ai_planning_subtree/bounty_boss_target @@ -2076,6 +2041,55 @@ GLOBAL_LIST_EMPTY(bounty_boss_barricades) controller.queue_behavior(/datum/ai_behavior/travel_towards, BB_BOUNTY_BOSS_COVER) return SUBTREE_RETURN_FINISH_PLANNING +// ===== COMBAT SENSE: ITS STIMULANT, AND A KIT'S ANSWER TO STUN FIRE (C8) ===== + +/** + * A boss's combat sense (voidcrew/modules/human_npcs/human_npc_combat_sense.dm): the bounty family's leash + * and gang rules, and the stimulant pens it carries (boss_supplies()), taken when it tires in a fight and + * never while one is still in its blood. Nothing else injects them: its worn-out fall back asks this for + * the pen too (bounty_boss_worn_out). By default stun fire changes nothing else about how it fights; a + * kit with its own reaction sets it on a subtype (bounty_boss_kits.dm), and a ready ability always goes + * first, its wind-up being the kit's own answer. + */ +/datum/npc_combat_sense/bounty/boss + react_to_stun_fire = FALSE + +/datum/npc_combat_sense/bounty/boss/can_act(mob/living/carbon/human/npc, datum/ai_controller/controller) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = npc + return istype(boss) && boss.boss_hostile && !boss.boss_busy && ..() + +/datum/npc_combat_sense/bounty/boss/in_fight(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = npc + return istype(boss) && boss.boss_hostile && (controller.blackboard_key_exists(BB_BASIC_MOB_CURRENT_TARGET) || sense.under_fire()) + +/datum/npc_combat_sense/bounty/boss/plan(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + if(!can_act(npc, controller)) + sense.stand_down(controller) + return + var/under_fire = sense.under_fire() + if(!under_fire && sense.hits) + sense.end_exchange(controller) + if(plan_stim(npc, controller, sense)) + return SUBTREE_RETURN_FINISH_PLANNING + if(!under_fire || !react_to_stun_fire || !may_move(npc, controller)) + return + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = npc + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!QDELETED(target) && boss.boss_choose_ability(target)) + return + if(close_in_reach && reach(npc, controller) <= close_in_reach) + return plan_close_in(npc, controller, sense) + return plan_cover(npc, controller, sense) + +// The second pen of a fight comes with its "recover" line ("Round two."), as its second wind always did. +/datum/npc_combat_sense/bounty/boss/telegraph(mob/living/carbon/human/npc, kind, atom/about, obj/item/used) + . = ..() + if(kind != HUMAN_NPC_BARK_STIMULANT) + return + var/datum/component/npc_combat_sense/sense = npc.GetComponent(/datum/component/npc_combat_sense) + if(sense?.stims_used) + INVOKE_ASYNC(npc, TYPE_PROC_REF(/mob/living, bounty_say), "recover", null, TRUE) + // ===== LOOKS ===== /// A mini-boss's clothes before its kit's: plain dark clothes diff --git a/voidcrew/modules/bounties/bounty_boss_kits.dm b/voidcrew/modules/bounties/bounty_boss_kits.dm index c4e8b2b502c..067fba60bb5 100644 --- a/voidcrew/modules/bounties/bounty_boss_kits.dm +++ b/voidcrew/modules/bounties/bounty_boss_kits.dm @@ -7,29 +7,37 @@ * (/mob/living/carbon/human/npc/bounty_criminal/boss/, BOUNTY_KIT_*): what each wears and carries, * and its two abilities (spec section 5, 12.3 C8). Every kit is a real human in real gear, locked to its * body (D3): its armour protects by tg's rules and its weapon is a real item it fights with. Its - * stimulants and bruise pack are packed by boss_supplies(). Nothing it carries is ever loot. + * stimulants and bruise pack are packed by boss_supplies(), and its combat sense (bounty_boss.dm) uses + * the pens when it tires. Nothing it carries is ever loot. * - * - Juggernaut: riot armour and a body grown huge (tg's gigantism gene). Charges in a line, smashing + * - Juggernaut: riot armour, a body grown huge (tg's gigantism gene) and a tower shield it raises as its + * fight starts, stopping every shot from in front of it (C8: flank it). Charges in a line, smashing * tables, chairs and plain windows and tearing through an interior wall; running into anything else * hurts it and knocks it down, as running into a wall does anyone (CB-6). Slams the floor around it - * when someone is close. Fights with its fists. + * when someone is close. Fights with its shield and its free fist. * - Pyromaniac: a real firefighter suit and helmet (tg's thermal protection, CB-7) and a lit torch. * Sweeps a cone of fire and throws molotovs that leave a burning patch; whoever it catches burns with - * tg's fire stacks (CB-8). + * tg's fire stacks (CB-8). Shot with a disabler, it goes for the shooter until its flamer reaches + * them (C8: keep your distance and keep moving). * - Demolitionist: a hard hat, a hi-vis vest, a bandolier and a crowbar in its bag. Keeps its distance * and lobs grenades with a visible fuse, and blows a hole through an interior wall or door to get at - * someone behind cover. Whoever a blast catches takes tg's light explosion (CB-10). + * someone behind cover. Whoever a blast catches takes tg's light explosion (CB-10). A real CNS + * rebooter in its head gets it up a second after its first stamina crit of a minute (C8: pile on, and + * cuff it on the second). * - Ghost: a dark hood, a real combat knife and real chrome (Slipwire, Cascade; the OC-6 ruling). * Dashes in along a line with its Cascade lattice firing and stabs at the end of it (CB-9); fades * into a shimmer that is only a look, broken by a hit or a flash (CB-12); backs off after a couple of - * cuts. - * - Heavy: SWAT armour and a real L6 with a spare box. A laser sight and a spin-up, then a burst of - * real rounds into a cone (a hit while it aims spoils the shot); throws real barrier grenades for - * cover (CB-26); changes its own belts. Slow. + * cuts. Shot with a disabler from beyond its dash, it breaks the line and comes again from cover (C8: + * corner it). + * - Heavy: an ablative trenchcoat with its hood up (C8: energy armour everywhere and half of all beams + * reflected; bring lethal, or a lot of shots) and a real L6 with a spare box. A laser sight and a + * spin-up, then a burst of real rounds into a cone (a hit while it aims spoils the shot); throws real + * barrier grenades for cover (CB-26); changes its own belts. * * Where the air would kill it, each kit seals up in its own space suit, in the armour class of what it * wears in good air (bounty_sealed_gear(), bounty_suits.dm): the Juggernaut and the Heavy in armoured - * ones, the Pyromaniac, the Demolitionist and the Ghost in soft ones. + * ones, the Pyromaniac, the Demolitionist and the Ghost in soft ones. The Heavy's coat doesn't hold + * pressure, so out there it has no reflection, only the armoured suit. * * Behind something it can't walk through, a hunter is out of reach unless the kit gets through it * (boss_goes_through(), bounty_boss.dm): the Juggernaut's charge, the Demolitionist's grenades and @@ -46,7 +54,7 @@ /mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut name = "armoured criminal" - desc = "A huge criminal in riot plates, built like a door." + desc = "A huge criminal in riot plates behind a tower shield, built like a door." boss_kit = BOUNTY_KIT_JUGGERNAUT boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/charge, @@ -62,9 +70,44 @@ return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/juggernaut)) /mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss_gear_text() - if(istype(wear_suit, /obj/item/clothing/suit/armor/riot)) - return "Riot plates cover [p_them()] from neck to boots." - return "[p_They()] [p_are()] built like a door." + . = istype(wear_suit, /obj/item/clothing/suit/armor/riot) ? "Riot plates cover [p_them()] from neck to boots." : "[p_They()] [p_are()] built like a door." + if(boss_shield()) + . += " A tower shield covers [p_their()] front." + +/// The tower shield it holds, if it still has one (C8) +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/proc/boss_shield() + for(var/obj/item/shield/riot/phalanx_buckler/shield in held_items) + return shield + return null + +// Its fight on, it raises the shield it holds, the player's way (use in hand), and keeps it up. +/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss_gear_plan(datum/ai_controller/controller) + var/obj/item/shield/riot/phalanx_buckler/shield = boss_shield() + if(isnull(shield) || shield.raised || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED) || world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT]) + return FALSE + controller.queue_behavior(/datum/ai_behavior/bounty_juggernaut_raise_shield) + return TRUE + +/// The Juggernaut raises its shield: the shield in its active hand, then used in hand (tg's Z key) +/datum/ai_behavior/bounty_juggernaut_raise_shield + +/datum/ai_behavior/bounty_juggernaut_raise_shield/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/jugg = controller.pawn + var/obj/item/shield/riot/phalanx_buckler/shield = istype(jugg) ? jugg.boss_shield() : null + if(isnull(shield) || !human_npc_ready_item(jugg, shield)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!shield.raised) + jugg.execute_mode() + if(!shield.raised) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + jugg.visible_message(span_warning("[jugg] sets [jugg.p_their()] shield in front of [jugg.p_them()].")) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + +// A try that got nowhere: it fights on and tries again in a while, rather than fumble with it every plan +/datum/ai_behavior/bounty_juggernaut_raise_shield/finish_action(datum/ai_controller/controller, succeeded) + . = ..() + if(!succeeded) + controller.set_blackboard_key(BB_HUMAN_NPC_ARMING_RETRY_AT, world.time + 5 SECONDS) // Out where the air kills: a black and red space suit, as heavy as its riot plates /mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/bounty_sealed_gear() @@ -84,6 +127,7 @@ var/datum/action/cooldown/mob_cooldown/bounty_boss/charge/charge = boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/charge) return isobj(barrier) && charge?.smashes(barrier) && boss_may_break(barrier) +// The shield has no sling, so it is held from the start, in the off hand; the other fist stays free. /datum/outfit/bounty_boss/juggernaut name = "Bounty mini-boss: Juggernaut" uniform = /obj/item/clothing/under/syndicate/combat @@ -91,6 +135,7 @@ head = /obj/item/clothing/head/helmet/toggleable/riot gloves = /obj/item/clothing/gloves/combat shoes = /obj/item/clothing/shoes/jackboots + l_hand = BOUNTY_JUGGERNAUT_SHIELD /** * Charge: a roar, a stamp and a line on the floor, then a run down that line. The first person in @@ -295,7 +340,7 @@ */ /datum/action/cooldown/mob_cooldown/bounty_boss/slam name = "Ground Slam" - desc = "Bring both fists down on the floor and knock everyone around you off their feet." + desc = "Bring your whole weight down on the floor and knock everyone around you off their feet." cooldown_time = BOUNTY_JUGGERNAUT_SLAM_COOLDOWN windup = BOUNTY_JUGGERNAUT_SLAM_WINDUP @@ -303,7 +348,7 @@ return aimable(target) && get_dist(owner, target) <= 1 /datum/action/cooldown/mob_cooldown/bounty_boss/slam/telegraph(atom/target) - owner.visible_message(span_boldwarning("[owner] raises both fists high over [owner.p_their()] head!")) + owner.visible_message(span_boldwarning("[owner] raises both arms high over [owner.p_their()] head!")) playsound(owner, 'sound/effects/magic/tail_swing.ogg', 60, TRUE) mark_turfs(RANGE_TURFS(1, owner), COLOR_RED) var/matrix/rest = matrix(owner.transform) @@ -314,7 +359,7 @@ /datum/action/cooldown/mob_cooldown/bounty_boss/slam/effect(atom/target) var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner - boss.visible_message(span_danger("[boss] brings both fists down on the floor!")) + boss.visible_message(span_danger("[boss] brings [boss.p_their()] whole weight down on the floor!")) playsound(boss, 'sound/effects/meteorimpact.ogg', 80, TRUE) for(var/atom/victim as anything in boss.boss_victims_on(RANGE_TURFS(1, boss))) boss.boss_hit(victim, BOUNTY_JUGGERNAUT_SLAM_DAMAGE, BRUTE, MELEE, BOUNTY_JUGGERNAUT_SLAM_KNOCKDOWN) @@ -327,6 +372,7 @@ name = "scorched criminal" desc = "A criminal in a blackened fire suit, with a lit torch in hand." boss_kit = BOUNTY_KIT_PYROMANIAC + npc_combat_sense = /datum/npc_combat_sense/bounty/boss/pyromaniac boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/flamer, /datum/action/cooldown/mob_cooldown/bounty_boss/molotov, @@ -352,6 +398,29 @@ /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_breaks_through_now(atom/barrier) return ..() || (!barrier.opacity && !isnull(boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/molotov))) +/** + * The Pyromaniac's answer to stun fire (C8): it goes for whoever is shooting it, until its flamer + * reaches them (combat sense's close-in), throwing a molotov or sweeping the flamer the moment either is + * ready (the boss rules let a ready ability go first). Keep your distance and keep moving. + */ +/datum/npc_combat_sense/bounty/boss/pyromaniac + react_to_stun_fire = TRUE + close_in_reach = BOUNTY_PYROMANIAC_FLAMER_RANGE + +// It closes until its flamer reaches, not to arm's length +/datum/npc_combat_sense/bounty/boss/pyromaniac/reach(mob/living/carbon/human/npc, datum/ai_controller/controller) + return BOUNTY_PYROMANIAC_FLAMER_RANGE + +// Whoever is shooting it becomes who it goes for, if it may go after them +/datum/npc_combat_sense/bounty/boss/pyromaniac/plan_close_in(mob/living/carbon/human/npc, datum/ai_controller/controller, datum/component/npc_combat_sense/sense) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = npc + var/atom/shooter = sense.threat() + if(istype(boss) && shooter) + var/atom/aim = boss.boss_target_for(shooter) + if(aim != controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] && boss.boss_valid_target(aim)) + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, aim) + return ..() + // The firefighter suit and helmet are what keep its own fire off it (tg's thermal protection, CB-7). The torch // doesn't holster, so it is in hand from the start. /datum/outfit/bounty_boss/pyromaniac @@ -488,11 +557,39 @@ /datum/action/cooldown/mob_cooldown/bounty_boss/breach, ) +/** + * Its rebooter (C8): tg's CNS rebooter, a real implant in its head, one neural load beside any chrome it + * rolled (a Demolitionist rolls at most Cascade and Lazarus, 17 of the body's 20). It is no chrome, so the + * chrome roll doesn't count it and it doesn't burn out when it dies. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/Initialize(mapload, datum/human_npc_order/order) + . = ..() + if(QDELETED(src)) + return + var/obj/item/organ/implant = new BOUNTY_DEMOLITIONIST_REBOOTER() + if(!implant.Insert(src, special = TRUE)) + qdel(implant) + RegisterSignal(src, SIGNAL_ADDTRAIT(TRAIT_STUNIMMUNE), PROC_REF(boss_rebooted)) + /mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/bounty_build_outfit(zone) return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/demolitionist)) /mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/boss_gear_text() - return "A bandolier of grenades is slung across [p_their()] chest." + . = "A bandolier of grenades is slung across [p_their()] chest." + if(boss_rebooter()) + . += " A steel port glints at the base of [p_their()] skull." + +/// Its CNS rebooter, if it is still in its head +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/proc/boss_rebooter() + return locate(BOUNTY_DEMOLITIONIST_REBOOTER) in organs + +/// Its rebooter just fired (the implant's own stun immunity went on): everyone sees it get up +/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/proc/boss_rebooted(datum/source) + SIGNAL_HANDLER + var/obj/item/organ/implant = boss_rebooter() + if(isnull(implant) || stat == DEAD || !HAS_TRAIT_FROM(src, TRAIT_STUNIMMUNE, REF(implant))) + return + visible_message(span_warning("[src] jerks back to [p_their()] feet, sparks spitting from the back of [p_their()] neck!")) // Out where the air kills: a plain grey space suit over its work clothes /mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/bounty_sealed_gear() @@ -832,6 +929,7 @@ name = "hooded criminal" desc = "A lean criminal in a dark hooded cloak, a knife at the ready." boss_kit = BOUNTY_KIT_GHOST + npc_combat_sense = /datum/npc_combat_sense/bounty/boss/ghost // Its quickness is real chrome (the OC-6 ruling): a Slipwire shunt and a Cascade lattice. An EMP or a // brownout takes it away as it would from a player, and it burns out when the Ghost dies. npc_cyberware = list( @@ -864,6 +962,18 @@ /mob/living/carbon/human/npc/bounty_criminal/boss/ghost/bounty_sealed_gear() return list(/obj/item/clothing/suit/space/nasavoid, /obj/item/clothing/head/helmet/space/nasavoid) +/** + * The Ghost's answer to stun fire (C8): from beyond its dash, it breaks the shooter's line, running a few + * steps for cover (combat sense's cover, Slipwire dodging as it goes), and keeps down a moment, longer + * while winded; from cover it comes again with its dash once someone is in reach (the boss rules let a + * ready ability go first). Corner it. + */ +/datum/npc_combat_sense/bounty/boss/ghost + react_to_stun_fire = TRUE + close_in_reach = 0 + cover_steps = BOUNTY_GHOST_COVER_STEPS + cover_moves = BOUNTY_GHOST_COVER_MOVES + // The hood goes up as the outfit is put on (tg's hooded suits do that). The knife rides in a pocket until the fight starts. /datum/outfit/bounty_boss/ghost name = "Bounty mini-boss: Ghost" @@ -1078,7 +1188,7 @@ /mob/living/carbon/human/npc/bounty_criminal/boss/heavy name = "heavily armed criminal" - desc = "A big criminal in heavy armour, carrying a belt-fed gun." + desc = "A big criminal in an ablative coat, carrying a belt-fed gun." boss_kit = BOUNTY_KIT_HEAVY boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/burst, @@ -1089,11 +1199,13 @@ return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/heavy)) /mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_gear_text() + if(istype(wear_suit, BOUNTY_HEAVY_COAT)) + return "[p_Their()] coat and hood are lined with reflective panels." return "[p_They()] [p_are()] in heavy armour." -// Out where the air kills: a black space suit as heavy as its SWAT armour, under its own SWAT helmet, which already holds pressure +// Out where the air kills: a black armoured space suit and helmet (its coat and hood hold no pressure) /mob/living/carbon/human/npc/bounty_criminal/boss/heavy/bounty_sealed_gear() - return list(/obj/item/clothing/suit/space/syndicate/black, null) + return list(/obj/item/clothing/suit/space/syndicate/black, /obj/item/clothing/head/helmet/space/syndicate/black) // Its rounds chip through a window or grille it may break, and fly over a gap /mob/living/carbon/human/npc/bounty_criminal/boss/heavy/boss_goes_through(atom/barrier) if(..()) @@ -1114,12 +1226,12 @@ return (istype(barrier, /obj/structure/window) || istype(barrier, /obj/structure/grille)) && boss_may_break(barrier) // The L6 has no sling and won't holster, so it is in hand from the start, its other hand free to fire it. -// A spare box rides in the bag; a barrier grenade in each pocket. +// A spare box rides in the bag; a barrier grenade in each pocket. The coat's hood goes up as it is put on +// (tg's hooded suits do that), so its own reflecting hood covers its head (C8). /datum/outfit/bounty_boss/heavy name = "Bounty mini-boss: Heavy" uniform = /obj/item/clothing/under/syndicate/combat - suit = /obj/item/clothing/suit/armor/swat - head = /obj/item/clothing/head/helmet/swat + suit = BOUNTY_HEAVY_COAT gloves = /obj/item/clothing/gloves/combat shoes = /obj/item/clothing/shoes/jackboots r_hand = BOUNTY_HEAVY_GUN diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_boss_reach.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_boss_reach.dm index 34c113ec7f9..fdc7f3d78fe 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_boss_reach.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_boss_reach.dm @@ -457,10 +457,14 @@ ware.tune_up() TEST_ASSERT(boss.boss_gorilla_goes_through(other_pane), "Rebooted Gorilla Arms don't go through glass") - // Both hands full: no fist to punch with + // Both hands full: no fist to punch with (the Juggernaut's off hand already holds its shield, C8) var/obj/item/wrench/left_load = allocate(/obj/item/wrench) var/obj/item/screwdriver/right_load = allocate(/obj/item/screwdriver) - TEST_ASSERT(boss.put_in_hands(left_load) && boss.put_in_hands(right_load), "Test fixture: the boss couldn't fill both hands") + if(length(boss.get_empty_held_indexes())) + boss.put_in_hands(left_load) + if(length(boss.get_empty_held_indexes())) + boss.put_in_hands(right_load) + TEST_ASSERT(!length(boss.get_empty_held_indexes()), "Test fixture: the boss couldn't fill both hands") TEST_ASSERT(!boss.boss_has_gorilla_fist(), "With both hands full, it still has a fist to punch with") TEST_ASSERT(!boss.boss_gorilla_goes_through(other_pane), "With both hands full, its Gorilla Arms go through glass") qdel(left_load) diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm index 3c40f30f99a..253aae6bb6b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm @@ -104,6 +104,32 @@ /datum/unit_test/voidcrew_bounty_boss/proc/carried(mob/living/holder, item_type) return length(holder.get_all_contents_type(item_type)) +/** + * A real `projectile_type` landing on `target` from `from_turf`, the way a projectile's hit loop lands one + * (process_hit_loop(): its PREHIT, then projectile_hit()), aimed at `zone`. Returns what bullet_act() + * returned, or 0 if something stopped it before it landed (a shield). + */ +/datum/unit_test/voidcrew_bounty_boss/proc/shoot(mob/living/target, turf/from_turf, mob/living/firer, projectile_type = /obj/projectile/beam/disabler, zone = BODY_ZONE_CHEST) + var/obj/projectile/shot = allocate(projectile_type, from_turf) + shot.firer = firer + shot.fired_from = firer + shot.starting = from_turf + shot.def_zone = zone + if(SEND_SIGNAL(target, COMSIG_PROJECTILE_PREHIT, shot) & PROJECTILE_INTERRUPT_HIT) + return 0 + return target.projectile_hit(shot, zone) + +/// The combat sense planning subtree's shared instance, which every boss plans with +/datum/unit_test/voidcrew_bounty_boss/proc/sense_subtree() + return GLOB.ai_subtrees[/datum/ai_planning_subtree/npc_combat_sense] + +/// `boss` in a fight with `hunter`: on its grudge list, hostile, and its target +/datum/unit_test/voidcrew_bounty_boss/proc/fight(mob/living/carbon/human/npc/bounty_criminal/boss/boss, mob/living/hunter) + boss.body_add_grudge(hunter) + boss.boss_turn_hostile(hunter) + boss.ai_controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) + return boss.boss_hostile + // ===== EACH KIT IS A REAL HUMAN IN REAL GEAR, AND GOES CLEANLY ===== /datum/unit_test/voidcrew_bounty_boss/kits @@ -129,9 +155,12 @@ TEST_ASSERT(abs(boss.physiology.burn_mod - 0.6) < 0.01, "[kit_type] takes [boss.physiology.burn_mod] burn, not 0.6") TEST_ASSERT(boss.status_flags & CANSTUN, "[kit_type] can't be stunned") TEST_ASSERT(boss.status_flags & CANKNOCKDOWN, "[kit_type] can't be knocked down") - // Its stimulants and a bruise pack, carried for real. + // Its stimulants and a bruise pack, carried for real, the pens used by its combat sense (C8). TEST_ASSERT_EQUAL(carried(boss, /obj/item/reagent_containers/hypospray/medipen/stimpack), 2, "[kit_type] doesn't carry its two stimulants") // BOUNTY_BOSS_STIMULANTS TEST_ASSERT(carried(boss, /obj/item/stack/medical/bruise_pack) >= 1, "[kit_type] carries nothing to patch itself up with") + var/datum/component/npc_combat_sense/sense = boss.GetComponent(/datum/component/npc_combat_sense) + TEST_ASSERT(istype(sense?.rules, /datum/npc_combat_sense/bounty/boss), "[kit_type] has no boss combat sense") + TEST_ASSERT(sense_subtree() in boss.ai_controller.planning_subtrees, "[kit_type]'s AI doesn't plan its combat sense") TEST_ASSERT(boss.boss_gear_text(), "[kit_type] has no gear line on examine") var/list/abilities = boss.boss_abilities.Copy() qdel(boss) @@ -139,12 +168,18 @@ for(var/datum/ability as anything in abilities) TEST_ASSERT(QDELETED(ability), "[kit_type]'s [ability.type] outlived it") - // The Juggernaut: riot armour, and a real gene for its size (CB-5, CB-21). + // The Juggernaut: riot armour, a real gene for its size (CB-5, CB-21), and a real tower shield in hand, locked to it, not yet raised (C8). var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/jugg = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut) TEST_ASSERT(istype(jugg.wear_suit, /obj/item/clothing/suit/armor/riot), "The Juggernaut isn't wearing riot armour") TEST_ASSERT(istype(jugg.head, /obj/item/clothing/head/helmet/toggleable/riot), "The Juggernaut isn't wearing a riot helmet") TEST_ASSERT(HAS_TRAIT(jugg, TRAIT_GIANT), "The Juggernaut isn't a giant") TEST_ASSERT_NOTNULL(jugg.dna.get_mutation(/datum/mutation/gigantism), "The Juggernaut's size isn't the gigantism gene") + var/obj/item/shield/riot/phalanx_buckler/shield = jugg.boss_shield() + TEST_ASSERT_NOTNULL(shield, "The Juggernaut holds no tower shield") // BOUNTY_JUGGERNAUT_SHIELD + TEST_ASSERT(human_npc_gear_locked(shield), "The Juggernaut's shield isn't locked to it") + TEST_ASSERT(!shield.raised, "The Juggernaut has its shield up before any fight") + TEST_ASSERT(length(jugg.get_empty_held_indexes()), "The Juggernaut has no free fist beside its shield") + TEST_ASSERT(findtext(jugg.boss_gear_text(), "shield"), "The Juggernaut's examine doesn't show its shield") // The Pyromaniac: its fire suit and helmet keep fire off it, not a trait (CB-7). Its torch is a real one, lit. var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/pyro = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac) @@ -157,9 +192,13 @@ TEST_ASSERT_NOTNULL(torch, "The Pyromaniac holds no torch") TEST_ASSERT(torch.light_on && torch.damtype == BURN, "The Pyromaniac's torch isn't lit") - // The Demolitionist: a hard hat, and its crowbar in its bag until the fight starts. + // The Demolitionist: a hard hat, its crowbar in its bag until the fight starts, and a real CNS rebooter in its head (C8). var/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/demo = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist) TEST_ASSERT(istype(demo.head, /obj/item/clothing/head/utility/hardhat), "The Demolitionist isn't wearing a hard hat") + var/obj/item/organ/cyberimp/brain/anti_stun/implant = demo.boss_rebooter() + TEST_ASSERT_NOTNULL(implant, "The Demolitionist has no CNS rebooter") // BOUNTY_DEMOLITIONIST_REBOOTER + TEST_ASSERT_EQUAL(implant.owner, demo, "The Demolitionist's implant isn't in its body") + TEST_ASSERT(get_chrome_load(demo) <= get_chrome_capacity(demo), "The Demolitionist's rebooter puts it over its neural capacity") TEST_ASSERT(istype(demo.belt, /obj/item/storage/belt/bandolier), "The Demolitionist has no bandolier") var/obj/item/crowbar/large/crowbar = locate() in demo.get_all_contents_type(/obj/item/crowbar/large) TEST_ASSERT_NOTNULL(crowbar, "The Demolitionist carries no crowbar") @@ -174,10 +213,11 @@ TEST_ASSERT_NOTNULL(locate(/obj/item/organ/cyberimp/cyberware/slipwire) in ghost.organs, "The Ghost has no Slipwire") TEST_ASSERT_NOTNULL(locate(/obj/item/organ/cyberimp/cyberware/cascade) in ghost.organs, "The Ghost has no Cascade lattice") - // The Heavy: SWAT armour, a real L6 with a belt in it, a spare box and two barrier grenades (CB-13, CB-26). + // The Heavy: an ablative trenchcoat with its hood up (C8), a real L6 with a belt in it, a spare box and two barrier grenades (CB-13, CB-26). var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/heavy) - TEST_ASSERT(istype(heavy.wear_suit, /obj/item/clothing/suit/armor/swat), "The Heavy isn't wearing SWAT armour") - TEST_ASSERT(istype(heavy.head, /obj/item/clothing/head/helmet/swat), "The Heavy isn't wearing a SWAT helmet") + TEST_ASSERT(istype(heavy.wear_suit, /obj/item/clothing/suit/hooded/ablative), "The Heavy isn't wearing its ablative coat") // BOUNTY_HEAVY_COAT + TEST_ASSERT(istype(heavy.head, /obj/item/clothing/head/hooded/ablative), "The Heavy's ablative hood isn't up") + TEST_ASSERT(human_npc_gear_locked(heavy.wear_suit) && human_npc_gear_locked(heavy.head), "The Heavy's coat and hood aren't locked to it") var/obj/item/gun/ballistic/automatic/l6_saw/gun = heavy.boss_gun() TEST_ASSERT_NOTNULL(gun, "The Heavy holds no belt-fed gun") TEST_ASSERT(gun.can_shoot(), "The Heavy's gun isn't loaded") @@ -618,64 +658,92 @@ TEST_ASSERT(other.body_has_grudge(first), "A confronted boss has no grudge against who confronted it") TEST_ASSERT_EQUAL(other.ai_controller?.blackboard[BB_BASIC_MOB_CURRENT_TARGET], first, "A confronted boss isn't going after who confronted it") -// ===== WORN OUT AND THE STIMULANT: REAL STATE, REAL PENS (CB-2, CB-4, CR-4) ===== +// ===== WORN OUT AND THE STIMULANT: REAL STATE, REAL PENS (CB-2, CB-4, CR-4, C8) ===== +/** + * Its stimulant pens go in by combat sense's own rule (human_npc_combat_sense.dm): when it tires in a + * fight, never at the fight's start, never while one is still in its blood, and nothing else injects + * them. Worn out, it takes the pen first and then falls back. + */ /datum/unit_test/voidcrew_bounty_boss/stamina /datum/unit_test/voidcrew_bounty_boss/stamina/Run() var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(0, 0)) var/mob/living/carbon/human/consistent/hunter = hunter_at(spot(3, 0)) var/datum/ai_controller/controller = boss.ai_controller - boss.body_add_grudge(hunter) - boss.boss_turn_hostile(hunter) - TEST_ASSERT(boss.boss_hostile, "Test fixture: the boss didn't turn hostile") + var/datum/ai_planning_subtree/npc_combat_sense/subtree = sense_subtree() + var/datum/component/npc_combat_sense/sense = boss.GetComponent(/datum/component/npc_combat_sense) + var/datum/ai_behavior/npc_combat_sense_stim/stim = GET_AI_BEHAVIOR(/datum/ai_behavior/npc_combat_sense_stim) + + // Winded but not yet hostile: nothing. + boss.setStaminaLoss(55) + TEST_ASSERT_NULL(subtree.SelectBehaviors(controller, 1), "A boss that isn't fighting used a stimulant") + boss.setStaminaLoss(0) + TEST_ASSERT(fight(boss, hunter), "Test fixture: the boss didn't turn hostile") - // Stamina works on it from the first hit (CB-2): no fresh immunity. + // Stamina works on it from the first hit (CB-2): no fresh immunity, and no pen at the fight's start. boss.apply_damage(30, STAMINA) TEST_ASSERT(boss.getStaminaLoss() >= 30, "A fresh boss shrugged off stamina damage") - - // The fight starting, it injects a real stimulant it carries. - var/datum/ai_planning_subtree/bounty_boss_stimulant/stimulant = new - TEST_ASSERT_EQUAL(stimulant.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "The boss didn't reach for its stimulant as its fight started") - TEST_ASSERT_EQUAL(boss.boss_stimulants_used, 1, "The boss didn't count its first stimulant") - var/datum/ai_behavior/human_npc_treat_self/treat = GET_AI_BEHAVIOR(/datum/ai_behavior/human_npc_treat_self) - TEST_ASSERT(controller.current_behaviors[treat], "The boss didn't plan to use its stimulant") + TEST_ASSERT_NULL(subtree.SelectBehaviors(controller, 1), "The boss reached for a stimulant before it tired") + + // Tired (55 of the 100 that stamcrit it), still short of worn out: it uses a real pen it carries. + boss.setStaminaLoss(55) + TEST_ASSERT(!boss.boss_is_worn_out(), "Test fixture: 55 stamina loss counts as worn out") + TEST_ASSERT_EQUAL(subtree.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "The tired boss didn't reach for its stimulant") + TEST_ASSERT(controller.current_behaviors[stim], "The tired boss didn't plan to use its stimulant") + var/obj/item/reagent_containers/hypospray/medipen/stimpack/pen = controller.blackboard["human_npc_stim_item"] // BB_HUMAN_NPC_STIM_ITEM + TEST_ASSERT(istype(pen), "The boss reached for something other than its stimpack") var/finished = FALSE - for(var/step in 1 to 10) + for(var/step in 1 to 6) ready_hands(boss) - var/result = treat.perform(1, controller, "human_npc_treat_item") // BB_HUMAN_NPC_TREAT_ITEM + var/result = stim.perform(1, controller, "human_npc_stim_item") TEST_ASSERT(!(result & AI_BEHAVIOR_FAILED), "Using the stimulant failed at step [step]") if(result & AI_BEHAVIOR_SUCCEEDED) finished = TRUE break TEST_ASSERT(finished, "The boss never finished using its stimulant") + stim.finish_action(controller, TRUE, "human_npc_stim_item") TEST_ASSERT(boss.reagents.has_reagent(/datum/reagent/medicine/ephedrine), "The stimulant isn't in the boss's blood") - TEST_ASSERT_NOTNULL(bounty_ai_find_injector(boss, /obj/item/reagent_containers/hypospray/medipen/stimpack), "The boss has no second stimulant left for its second wind") - treat.finish_action(controller, TRUE, "human_npc_treat_item") - TEST_ASSERT(!boss.boss_wants_stimulant(), "The boss wants a second stimulant before its second wind") + TEST_ASSERT(pen.used_up, "The stimpack isn't spent") + TEST_ASSERT_EQUAL(sense.stims_used, 1, "The stimulant wasn't counted") + TEST_ASSERT(boss.boss_shield()?.loc == boss, "Using the stimulant cost the Juggernaut its shield") - // Worn out is its real stamina (over half its pool): it says so, shows the hunters, and falls back. - boss.setStaminaLoss(0) + // One in its blood: the second pen waits, however tired it gets. + TEST_ASSERT_NULL(subtree.SelectBehaviors(controller, 1), "With a stimulant already in its blood, it reached for another") + + // Worn out is its real stamina (over half its pool): it says so, shows the hunters, takes its second pen first... boss.reagents.clear_reagents() - boss.adjustStaminaLoss(70) + boss.setStaminaLoss(70) TEST_ASSERT(boss.boss_is_worn_out(), "A boss at [boss.getStaminaLoss()] stamina loss isn't worn out") - controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, hunter) var/datum/ai_planning_subtree/bounty_boss_worn_out/worn = new - TEST_ASSERT_EQUAL(worn.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "A worn-out boss didn't fall back") + TEST_ASSERT_EQUAL(worn.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "A worn-out boss did nothing") TEST_ASSERT(boss.boss_worn_out, "The boss didn't wear out") TEST_ASSERT_EQUAL(boss.body_last_alert, "worn out", "Wearing out showed [boss.body_last_alert || "nothing"] over the boss") + TEST_ASSERT(controller.current_behaviors[stim], "Worn out with a pen left, it fell back without it") + finished = FALSE + for(var/step in 1 to 6) + ready_hands(boss) + var/result = stim.perform(1, controller, "human_npc_stim_item") + if(result & AI_BEHAVIOR_SUCCEEDED) + finished = TRUE + break + TEST_ASSERT(finished, "The worn-out boss never used its second pen") + stim.finish_action(controller, TRUE, "human_npc_stim_item") + TEST_ASSERT_EQUAL(sense.stims_used, 2, "The second stimulant wasn't counted") + + // ...then falls back, its abilities waiting. + TEST_ASSERT_EQUAL(worn.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "A worn-out boss didn't fall back") + TEST_ASSERT(controller.current_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/run_away_from_target)], "A worn-out boss isn't falling back from its target") TEST_ASSERT(boss.boss_fallback_until > world.time, "A worn-out boss has no fall back") TEST_ASSERT(!boss.boss_can_use_ability(), "A boss falling back can still start an ability") - // The fall back over and still worn out: its second wind is due, the second pen with its "recover" line. + // No pens left: it just falls back, and once that is over it fights on. + boss.reagents.clear_reagents() + TEST_ASSERT_NULL(human_npc_find_stim(boss), "Test fixture: the boss still has a stimulant") + TEST_ASSERT_EQUAL(worn.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "With no pens left, the worn-out boss didn't fall back") + TEST_ASSERT(!controller.current_behaviors[stim], "With no pens left, it tried to use one") boss.boss_fallback_until = world.time - 1 - worn.SelectBehaviors(controller, 1) - TEST_ASSERT(boss.boss_second_wind_due, "The boss's second wind isn't due after its fall back") - TEST_ASSERT(boss.boss_wants_stimulant(), "The boss doesn't want its second stimulant") - controller.clear_blackboard_key("human_npc_treat_item") - TEST_ASSERT_EQUAL(stimulant.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "The boss didn't take its second wind") - TEST_ASSERT_EQUAL(boss.boss_stimulants_used, 2, "The boss didn't count its second stimulant") - TEST_ASSERT(!boss.boss_second_wind_due, "The second wind is still due after it was taken") + TEST_ASSERT_NULL(worn.SelectBehaviors(controller, 1), "Its fall back over, the boss kept falling back") // Its stamina back, it is no longer worn out. boss.setStaminaLoss(0) @@ -950,3 +1018,254 @@ TEST_ASSERT(ghost.boss_surge(), "The Ghost's Cascade lattice didn't fire") TEST_ASSERT(ghost.has_status_effect(/datum/status_effect/cyberware_cascade_window), "The Cascade window didn't open") TEST_ASSERT(!ghost.boss_surge(), "The Cascade lattice fired twice in one cycle") + +// ===== EACH KIT'S ANSWER TO A DISABLER (C8) ===== + +/** + * The Juggernaut raises its real tower shield (the Phalanx buckler) the player's way once its fight is on. + * Raised, shots from the half of the map it faces stop at it; from the side or behind a disabler bolt + * lands (a riot shield lets beams through). It keeps hold of it through a stun, as locked gear does, and + * bullets from the flank wear it out until it breaks. + */ +/datum/unit_test/voidcrew_bounty_boss/juggernaut_shield + +/datum/unit_test/voidcrew_bounty_boss/juggernaut_shield/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/boss = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(2, 2)) + var/mob/living/carbon/human/consistent/hunter = hunter_at(spot(2, 4)) + var/datum/ai_controller/controller = boss.ai_controller + var/obj/item/shield/riot/phalanx_buckler/shield = boss.boss_shield() + TEST_ASSERT_NOTNULL(shield, "Test fixture: the Juggernaut has no shield") + var/datum/ai_planning_subtree/bounty_boss_gear/gear = GLOB.ai_subtrees[/datum/ai_planning_subtree/bounty_boss_gear] + TEST_ASSERT_NULL(gear.SelectBehaviors(controller, 1), "The Juggernaut raised its shield with no fight on") + + // Its fight on, it raises the shield it holds, used in hand + TEST_ASSERT(fight(boss, hunter), "Test fixture: the Juggernaut didn't turn hostile") + TEST_ASSERT_EQUAL(gear.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "The Juggernaut didn't plan to raise its shield") + var/datum/ai_behavior/bounty_juggernaut_raise_shield/raise = GET_AI_BEHAVIOR(/datum/ai_behavior/bounty_juggernaut_raise_shield) + ready_hands(boss) + TEST_ASSERT(raise.perform(1, controller) & AI_BEHAVIOR_SUCCEEDED, "The Juggernaut couldn't raise its shield") + TEST_ASSERT(shield.raised, "The Juggernaut's shield isn't raised") + TEST_ASSERT(!boss.boss_gear_plan(controller), "With its shield up, it went to raise it again") + TEST_ASSERT(boss.has_movespeed_modifier(/datum/movespeed_modifier/phalanx_raised), "The raised shield doesn't slow it as it slows anyone") + + // Facing its shooter: bolts from in front, straight or at an angle, stop at the shield + boss.setDir(NORTH) + TEST_ASSERT_EQUAL(shoot(boss, spot(2, 4), hunter), 0, "A bolt from in front got past the raised shield") + TEST_ASSERT_EQUAL(shoot(boss, spot(4, 4), hunter), 0, "A bolt from in front and to one side got past the raised shield") + TEST_ASSERT_EQUAL(boss.getStaminaLoss(), 0, "Bolts from in front did [boss.getStaminaLoss()] stamina damage through the shield") + // From the side and from behind they land, through riot armour's energy 10 + for(var/turf/flank as anything in list(spot(4, 2), spot(0, 2), spot(2, 0))) + boss.setStaminaLoss(0) + TEST_ASSERT(shoot(boss, flank, hunter), "A bolt from [flank.x - boss.x], [flank.y - boss.y] never landed") + TEST_ASSERT(abs(boss.getStaminaLoss() - 27) < 1, "A bolt from [flank.x - boss.x], [flank.y - boss.y] did [boss.getStaminaLoss()] stamina, not 27") + // Turned round, the same shooter gets through + boss.setStaminaLoss(0) + boss.setDir(SOUTH) + TEST_ASSERT(shoot(boss, spot(2, 4), hunter) && boss.getStaminaLoss() > 0, "Its back to the shooter, the shield still stopped the bolt") + boss.setStaminaLoss(0) + + // Paralysed, it keeps hold of its shield (the gear lock refuses the drop), still raised and still facing + boss.setDir(NORTH) + boss.Paralyze(2 SECONDS) + TEST_ASSERT(shield.loc == boss && shield.raised, "Paralysed, the Juggernaut let go of its shield") + TEST_ASSERT_EQUAL(shoot(boss, spot(2, 4), hunter), 0, "Down and facing its shooter, the shield stopped nothing") + boss.SetParalyzed(0) + boss.setStaminaLoss(0) + + // Bullets from the flank: a riot shield's block, and every block wears it until it breaks + var/shots = 0 + while(!QDELETED(shield) && shots < 80) + shots++ + shoot(boss, spot(4, 2), hunter, /obj/projectile/bullet/c9mm) + boss.adjustBruteLoss(-100) + TEST_ASSERT(QDELETED(shield), "[shots] bullets from the flank didn't break the Juggernaut's shield") + TEST_ASSERT_NULL(boss.boss_shield(), "The broken shield is still in the Juggernaut's hand") + TEST_ASSERT(shoot(boss, spot(2, 4), hunter) && boss.getStaminaLoss() > 0, "With its shield broken, bolts from in front still stop") + TEST_ASSERT(!findtext(boss.boss_gear_text(), "shield"), "The Juggernaut's examine still shows a shield it has lost") + boss.setStaminaLoss(0) + +/** + * The Heavy's ablative coat and hood: half of the disabler bolts that hit it reflect, wherever they hit, + * and the rest do what energy armour 60 lets through. A bullet goes into it like into cloth. + */ +/datum/unit_test/voidcrew_bounty_boss/heavy_coat + +/datum/unit_test/voidcrew_bounty_boss/heavy_coat/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/heavy, spot(2, 2)) + var/mob/living/carbon/human/consistent/hunter = hunter_at(spot(2, 4)) + TEST_ASSERT(findtext(heavy.boss_gear_text(), "reflective"), "The Heavy's examine doesn't show its coat") + var/blocks = 0 + var/list/zones = list() + for(var/i in 1 to 240) + var/zone = pick(BODY_ZONE_CHEST, BODY_ZONE_HEAD, BODY_ZONE_L_ARM, BODY_ZONE_R_LEG, BODY_ZONE_PRECISE_GROIN) + heavy.setStaminaLoss(0) + var/result = shoot(heavy, spot(2, 4), hunter, /obj/projectile/beam/disabler, zone) + if(result == BULLET_ACT_FORCE_PIERCE) + blocks++ + zones[zone] = TRUE + TEST_ASSERT_EQUAL(heavy.getStaminaLoss(), 0, "A bolt the coat reflected still did stamina damage") + continue + TEST_ASSERT(abs(heavy.getStaminaLoss() - 12) < 0.5, "A bolt on its [zone] did [heavy.getStaminaLoss()] stamina, not the 12 that energy armour 60 lets through") + TEST_ASSERT(blocks > 80 && blocks < 160, "The coat reflected [blocks] of 240 bolts, not about half") + for(var/zone in list(BODY_ZONE_CHEST, BODY_ZONE_HEAD, BODY_ZONE_L_ARM, BODY_ZONE_R_LEG)) + TEST_ASSERT(zones[zone], "No bolt to its [zone] was ever reflected") + heavy.setStaminaLoss(0) + + // Bullets are another matter: no reflection, and bullet armour 10 + var/result = shoot(heavy, spot(2, 4), hunter, /obj/projectile/bullet/c9mm) + TEST_ASSERT(result && result != BULLET_ACT_FORCE_PIERCE, "A bullet bounced off the Heavy's coat") + TEST_ASSERT(heavy.getBruteLoss() > 12, "A pistol round did only [heavy.getBruteLoss()] brute through the coat") + +/** + * The Demolitionist's CNS rebooter, tg's own: its first stamina crit of a minute is over in a second, then + * it can't be put down for six; the next one, inside that minute, holds as long as anyone's. + */ +/datum/unit_test/voidcrew_bounty_boss/demolitionist_rebooter + +/datum/unit_test/voidcrew_bounty_boss/demolitionist_rebooter/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist/demo = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist, spot(2, 2)) + demo.ai_controller?.set_ai_status(AI_STATUS_OFF) + var/obj/item/organ/implant = demo.boss_rebooter() + TEST_ASSERT_NOTNULL(implant, "Test fixture: the Demolitionist has no implant") + TEST_ASSERT(findtext(demo.boss_gear_text(), "skull"), "The Demolitionist's examine doesn't show its implant") + + demo.apply_damage(120, STAMINA) + TEST_ASSERT(demo.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "Test fixture: 120 stamina didn't stamcrit the Demolitionist") + sleep(1.5 SECONDS) + TEST_ASSERT(!demo.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "The rebooter didn't get the Demolitionist up a second after it went down") + TEST_ASSERT_EQUAL(demo.getStaminaLoss(), 0, "The rebooter left it [demo.getStaminaLoss()] stamina loss") + TEST_ASSERT(HAS_TRAIT_FROM(demo, TRAIT_STUNIMMUNE, REF(implant)), "The implant gave it no stun immunity") + + // The window: it can't go down again + demo.apply_damage(120, STAMINA) + TEST_ASSERT(!demo.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "It went down again inside its rebooter's window") + sleep(6 SECONDS) + TEST_ASSERT(!HAS_TRAIT(demo, TRAIT_STUNIMMUNE), "The rebooter's window never closed") + TEST_ASSERT_EQUAL(demo.getStaminaLoss(), 0, "The rebooter didn't clear its stamina as its window closed") + + // The gap: the rebooter is spent for a minute, and the next stamina crit holds long enough for cuffs + demo.apply_damage(120, STAMINA) + TEST_ASSERT(demo.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "After the window it couldn't be put down") + sleep(4.5 SECONDS) + TEST_ASSERT(demo.has_status_effect(/datum/status_effect/incapacitating/stamcrit), "The rebooter got it up twice in a minute") + demo.setStaminaLoss(0) + +/** + * The Ghost under stun fire from beyond its dash: it breaks the shooter's line, a few steps to cover. + * With its dash ready and someone in reach, the dash goes first. + */ +/datum/unit_test/voidcrew_bounty_boss/ghost_cover + +/datum/unit_test/voidcrew_bounty_boss/ghost_cover/Run() + // A short wall east of the middle: the far corner behind it is out of a western hunter's line + change_turf(spot(3, 3), /turf/closed/wall) + change_turf(spot(3, 4), /turf/closed/wall) + var/mob/living/carbon/human/npc/bounty_criminal/boss/ghost/ghost = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/ghost, spot(2, 2)) + var/mob/living/carbon/human/consistent/hunter = hunter_at(spot(0, 2)) + var/datum/ai_controller/controller = ghost.ai_controller + var/datum/ai_planning_subtree/npc_combat_sense/subtree = sense_subtree() + var/datum/component/npc_combat_sense/sense = ghost.GetComponent(/datum/component/npc_combat_sense) + TEST_ASSERT(istype(sense?.rules, /datum/npc_combat_sense/bounty/boss/ghost), "The Ghost doesn't have its own combat sense") + TEST_ASSERT(fight(ghost, hunter), "Test fixture: the Ghost didn't turn hostile") + // Nothing of its own ready: its knife still in its pocket and its abilities waiting + ghost.boss_next_ability_at = world.time + 100 SECONDS + TEST_ASSERT_NULL(subtree.SelectBehaviors(controller, 1), "The Ghost took cover before anyone shot at it") + + TEST_ASSERT(shoot(ghost, spot(0, 2), hunter), "Test fixture: the bolt didn't land on the Ghost") + TEST_ASSERT(ghost.getStaminaLoss() > 0, "Test fixture: the bolt did no stamina damage") + TEST_ASSERT_EQUAL(subtree.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "Shot with a disabler, the Ghost stood in the open") + var/turf/cover = controller.blackboard["human_npc_cover"] // BB_HUMAN_NPC_COVER + TEST_ASSERT_NOTNULL(cover, "The Ghost picked no cover") + TEST_ASSERT(!human_npc_line_of_fire(get_turf(hunter), cover, PASSTABLE | PASSGLASS | PASSGRILLE), "The Ghost's cover is in the hunter's line of fire") + TEST_ASSERT(get_dist(ghost, cover) <= 5, "The Ghost's cover is further than it runs") // BOUNTY_GHOST_COVER_STEPS + TEST_ASSERT(controller.current_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/npc_combat_sense_take_cover)], "The Ghost didn't set off for its cover") + + // Its knife out and its dash ready, with the shooter in reach: the dash goes first + sense.end_exchange(controller) + controller.CancelActions() + var/obj/item/knife/combat/knife = locate() in ghost.get_all_contents_type(/obj/item/knife/combat) + TEST_ASSERT(human_npc_take_in_hand(ghost, knife), "Test fixture: the Ghost couldn't draw its knife") + ready(ghost) + ghost.setStaminaLoss(0) + shoot(ghost, spot(0, 2), hunter) + TEST_ASSERT_NULL(subtree.SelectBehaviors(controller, 1), "With its dash ready and the shooter in reach, the Ghost ran for cover") + TEST_ASSERT_NULL(controller.blackboard["human_npc_cover"], "With its dash ready, the Ghost picked cover") + ghost.setStaminaLoss(0) + +/** + * The Pyromaniac under stun fire goes for whoever is shooting it, until its flamer reaches them; a ready + * molotov or flamer goes first. The kits without a reaction of their own (Juggernaut, Heavy, Demolitionist) + * fight on as they were. + */ +/datum/unit_test/voidcrew_bounty_boss/pyromaniac_charge + +/datum/unit_test/voidcrew_bounty_boss/pyromaniac_charge/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/pyro = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac, spot(0, 0)) + var/mob/living/carbon/human/consistent/first = hunter_at(spot(1, 0)) + var/mob/living/carbon/human/consistent/shooter = hunter_at(spot(4, 4)) + var/datum/ai_controller/controller = pyro.ai_controller + var/datum/ai_planning_subtree/npc_combat_sense/subtree = sense_subtree() + TEST_ASSERT(fight(pyro, first), "Test fixture: the Pyromaniac didn't turn hostile") + pyro.boss_next_ability_at = world.time + 100 SECONDS + + TEST_ASSERT(shoot(pyro, spot(4, 4), shooter), "Test fixture: the bolt didn't land on the Pyromaniac") + TEST_ASSERT_EQUAL(subtree.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "Shot with a disabler, the Pyromaniac didn't go for the shooter") + TEST_ASSERT_EQUAL(controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET], shooter, "The Pyromaniac didn't turn on whoever shot it") + TEST_ASSERT(controller.current_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/human_npc_close_in)], "The Pyromaniac isn't closing on its shooter") + TEST_ASSERT_EQUAL(controller.blackboard["human_npc_stun_shooter"], shooter, "The Pyromaniac is closing on someone else") // BB_HUMAN_NPC_STUN_SHOOTER + TEST_ASSERT_NULL(controller.blackboard["human_npc_cover"], "The Pyromaniac ran for cover") + controller.CancelActions() + + // Within its flamer's reach it stops closing: the flamer does the rest + pyro.forceMove(spot(2, 2)) + TEST_ASSERT_NULL(subtree.SelectBehaviors(controller, 1), "Within its flamer's reach, the Pyromaniac kept closing") + + // A molotov ready: it throws rather than runs + pyro.forceMove(spot(0, 0)) + ready(pyro) + shoot(pyro, spot(4, 4), shooter) + TEST_ASSERT_NULL(subtree.SelectBehaviors(controller, 1), "With its molotov ready, the Pyromaniac ran at the shooter instead") + pyro.setStaminaLoss(0) + + // The kits with no reaction of their own stand their ground (their answer is their gear) + for(var/kit_type in list(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, /mob/living/carbon/human/npc/bounty_criminal/boss/heavy, /mob/living/carbon/human/npc/bounty_criminal/boss/demolitionist)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = allocate(kit_type, spot(0, 4)) + TEST_ASSERT(fight(boss, shooter), "Test fixture: [kit_type] didn't turn hostile") + boss.boss_next_ability_at = world.time + 100 SECONDS + shoot(boss, spot(4, 4), shooter) + TEST_ASSERT_NULL(subtree.SelectBehaviors(boss.ai_controller, 1), "[kit_type] reacted to one disabler bolt with combat sense") + TEST_ASSERT_NULL(boss.ai_controller.blackboard["human_npc_cover"], "[kit_type] ran for cover") + qdel(boss) + +#ifdef BOUNTY_BOSS_RENDERS +/** + * Not a check: renders the kits whose looks changed (C8), before and after, facing each way, to + * data/c8_renders/. Compile with -DBOUNTY_BOSS_RENDERS to run it. "Before" puts the old kit back on the + * same body: no shield on the Juggernaut, SWAT armour and helmet on the Heavy. + */ +/datum/unit_test/voidcrew_bounty_boss/renders + +/datum/unit_test/voidcrew_bounty_boss/renders/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut/jugg = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, spot(1, 1)) + var/mob/living/carbon/human/npc/bounty_criminal/boss/heavy/heavy = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/heavy, spot(3, 1)) + jugg.ai_controller?.set_ai_status(AI_STATUS_OFF) + heavy.ai_controller?.set_ai_status(AI_STATUS_OFF) + var/obj/item/shield/riot/phalanx_buckler/shield = jugg.boss_shield() + shield?.raise_shield(jugg) + for(var/facing in list(SOUTH, EAST, NORTH, WEST)) + jugg.setDir(facing) + heavy.setDir(facing) + fcopy(getFlatIcon(jugg, facing, no_anim = TRUE), "data/c8_renders/juggernaut_after_[dir2text(facing)].png") + fcopy(getFlatIcon(heavy, facing, no_anim = TRUE), "data/c8_renders/heavy_after_[dir2text(facing)].png") + qdel(shield) + qdel(heavy.wear_suit) + if(heavy.head) + qdel(heavy.head) + heavy.equip_to_slot_or_del(new /obj/item/clothing/suit/armor/swat(heavy), ITEM_SLOT_OCLOTHING) + heavy.equip_to_slot_or_del(new /obj/item/clothing/head/helmet/swat(heavy), ITEM_SLOT_HEAD) + for(var/facing in list(SOUTH, EAST, NORTH, WEST)) + jugg.setDir(facing) + heavy.setDir(facing) + fcopy(getFlatIcon(jugg, facing, no_anim = TRUE), "data/c8_renders/juggernaut_before_[dir2text(facing)].png") + fcopy(getFlatIcon(heavy, facing, no_anim = TRUE), "data/c8_renders/heavy_before_[dir2text(facing)].png") +#endif diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_suits.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_suits.dm index 6b72d095bda..8bf9bfae634 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_suits.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_suits.dm @@ -43,6 +43,10 @@ /datum/unit_test/voidcrew_bounty_suits/proc/armoured(obj/item/item) return !isnull(item) && max(item.get_armor_rating(MELEE), item.get_armor_rating(BULLET)) >= 30 +/// Whether a mini-boss kit's `item` is in the armoured class: armoured, or energy armour of 30 or more (the Heavy's ablative coat and hood, C8) +/datum/unit_test/voidcrew_bounty_suits/proc/kit_armoured(obj/item/item) + return armoured(item) || (!isnull(item) && item.get_armor_rating(ENERGY) >= 30) + /// Checks `npc` is sealed up for vacuum in real, locked gear: a pressure suit and helmet that keep out the cold of space, and a tank it can breathe /datum/unit_test/voidcrew_bounty_suits/proc/check_sealed(mob/living/carbon/human/npc/npc, label) var/obj/item/clothing/suit = npc.wear_suit @@ -101,13 +105,15 @@ TEST_ASSERT(istype(in_air) && istype(in_vacuum), "No [kit] was made") TEST_ASSERT_EQUAL(in_vacuum.boss_kit, kit, "Test fixture: the [kit] came out as a [in_vacuum.boss_kit]") check_sealed(in_vacuum, "The [kit] at a space ruin") - TEST_ASSERT_EQUAL(armoured(in_vacuum.wear_suit), armoured(in_air.wear_suit), "The [kit]'s space suit ([in_vacuum.wear_suit.type]) isn't in the armour class of its own suit ([in_air.wear_suit?.type])") - TEST_ASSERT_EQUAL(armoured(in_vacuum.head), armoured(in_air.head), "The [kit]'s space helmet ([in_vacuum.head.type]) isn't in the armour class of its own headgear ([in_air.head?.type])") + TEST_ASSERT_EQUAL(kit_armoured(in_vacuum.wear_suit), kit_armoured(in_air.wear_suit), "The [kit]'s space suit ([in_vacuum.wear_suit.type]) isn't in the armour class of its own suit ([in_air.wear_suit?.type])") + TEST_ASSERT_EQUAL(kit_armoured(in_vacuum.head), kit_armoured(in_air.head), "The [kit]'s space helmet ([in_vacuum.head.type]) isn't in the armour class of its own headgear ([in_air.head?.type])") TEST_ASSERT(in_vacuum.boss_gear_text(), "The [kit] in its space suit has no gear line on examine") var/list/gear = bounty_vacuum_gear(in_vacuum) TEST_ASSERT(length(gear) == 2 && istype(in_vacuum.wear_suit, gear[1]), "The [kit] doesn't wear the suit it was given for vacuum") if(kit == "heavy") - TEST_ASSERT(istype(in_vacuum.head, /obj/item/clothing/head/helmet/swat), "The Heavy lost its SWAT helmet in vacuum") + // Its ablative coat and hood hold no pressure: out there it seals up in an armoured suit and helmet (C8) + TEST_ASSERT(istype(in_air.head, /obj/item/clothing/head/hooded/ablative), "The Heavy's ablative hood isn't up in good air") + TEST_ASSERT(istype(in_vacuum.head, /obj/item/clothing/head/helmet/space/syndicate/black), "The Heavy has no armoured helmet in vacuum") qdel(in_air) qdel(in_vacuum) From cbbc7352600510e000acee480cd5fd1f9be25283 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:30:42 -0800 Subject: [PATCH 187/472] Let a criminal's Redline roar spare its own companions --- voidcrew/modules/bounties/bounty_chrome.dm | 11 +++++++++-- .../bounties/chrome/bounty_chrome_redline.dm | 6 +++++- voidcrew/modules/cyberware/ware_pro_combat.dm | 3 +++ .../unit_tests/voidcrew_bounty_chrome_redline.dm | 16 +++++++++++++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/voidcrew/modules/bounties/bounty_chrome.dm b/voidcrew/modules/bounties/bounty_chrome.dm index baeeb7a6f73..9db1ea038ce 100644 --- a/voidcrew/modules/bounties/bounty_chrome.dm +++ b/voidcrew/modules/bounties/bounty_chrome.dm @@ -732,7 +732,7 @@ GLOBAL_LIST_EMPTY(bounty_chrome_organ_facts) * friends); the dead and creatures with nobody behind them (site fauna) don't count. The implant itself * is never changed: once it fires it hits whoever is there, as it would for a player. */ -/proc/bounty_chrome_area_clear(mob/living/carbon/human/npc/npc, list/turfs, atom/aimed) +/proc/bounty_chrome_area_clear(mob/living/carbon/human/npc/npc, list/turfs, atom/aimed, ignore_faction = FALSE) if(QDELETED(npc)) return FALSE for(var/turf/spot as anything in turfs) @@ -741,6 +741,9 @@ GLOBAL_LIST_EMPTY(bounty_chrome_organ_facts) for(var/mob/living/person in spot) if(person == npc || person.stat == DEAD) continue + // A move that spares its own side (the Redline roar) doesn't wait on its own companions + if(ignore_faction && bounty_ai_same_gang(npc, person)) + continue if(!bounty_chrome_in_posse(npc, person, aimed) && bounty_chrome_counts_as_bystander(npc, person)) return FALSE for(var/obj/vehicle/sealed/mecha/mech in spot) @@ -850,10 +853,14 @@ GLOBAL_LIST_EMPTY(bounty_chrome_organ_facts) return FALSE if(!ready()) return FALSE - if(area_move && !bounty_chrome_area_clear(owner, area_turfs(target), target)) + if(area_move && !area_clear(target)) return FALSE return TRUE +/// Whether nobody outside the fight stands where it would land on `target` (the bystander gate). A move whose implant spares its own side overrides it. +/datum/bounty_chrome_move/proc/area_clear(atom/target) + return bounty_chrome_area_clear(owner, area_turfs(target), target) + /// The turfs it would catch if it fired on `target` now, for the bystander gate. Area moves override it. /datum/bounty_chrome_move/proc/area_turfs(atom/target) return list() diff --git a/voidcrew/modules/bounties/chrome/bounty_chrome_redline.dm b/voidcrew/modules/bounties/chrome/bounty_chrome_redline.dm index 00a96cea7e0..91a9415cbd3 100644 --- a/voidcrew/modules/bounties/chrome/bounty_chrome_redline.dm +++ b/voidcrew/modules/bounties/chrome/bounty_chrome_redline.dm @@ -50,6 +50,10 @@ return FALSE return npc.health <= npc.maxHealth * BOUNTY_REDLINE_HURT_BELOW / 100 || npc.getStaminaLoss() >= BOUNTY_REDLINE_WINDED_AT +// The implant's roar staggers only those outside its side (cyberware_is_ally()), so its own companions don't hold it back +/datum/bounty_chrome_move/redline/area_clear(atom/target) + return bounty_chrome_area_clear(owner, area_turfs(target), target, TRUE) + /// The roar's reach: every tile it can see within BOUNTY_REDLINE_ROAR_RANGE, as the implant's roar looks (oview()) /datum/bounty_chrome_move/redline/area_turfs(atom/target) . = list() @@ -70,7 +74,7 @@ // Someone outside the fight stepped into the roar's reach during the tell: the button stays unpressed (the gate, owner decision 5) /datum/bounty_chrome_move/redline/effect(atom/target) - if(!bounty_chrome_area_clear(owner, area_turfs(target), target)) + if(!area_clear(target)) return FALSE . = ..() if(. && owner.has_status_effect(/datum/status_effect/cyberware_redline_window)) diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index 8bf6b775f25..4ecd5fb3744 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -164,6 +164,9 @@ return TRUE if(!istype(user) || !istype(target)) return FALSE + // A bounty criminal's own companions and creatures (voidcrew/modules/bounties) + if(bounty_ai_same_gang(user, target)) + return TRUE var/datum/team/user_team = cyberware_crew_team(user) if(!user_team) return FALSE diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_chrome_redline.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_chrome_redline.dm index 4e16cbd1f8e..57ab1520057 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_chrome_redline.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_chrome_redline.dm @@ -74,7 +74,7 @@ TEST_ASSERT_NULL(criminal.has_status_effect(/datum/status_effect/bounty_redline_eyes), "Its eyes stayed red after the burn") TEST_ASSERT_NOTEQUAL(criminal.get_left_eye_color(), "#ff2a2a", "Its eyes stayed red after the burn") -/// Winded by stamina it burns too; with someone outside the fight in the roar's reach it doesn't start, and someone stepping in during the tell keeps the button unpressed +/// Winded by stamina it burns too; with someone outside the fight in the roar's reach it doesn't start, and someone stepping in during the tell keeps the button unpressed; its own companion does neither, and the roar spares it /datum/unit_test/voidcrew_bounty_chrome/redline_gate /datum/unit_test/voidcrew_bounty_chrome/redline_gate/Run() @@ -102,6 +102,20 @@ TEST_ASSERT_NULL(bystander.has_status_effect(/datum/status_effect/staggered), "The bystander was staggered") TEST_ASSERT(move.ready(), "The unpressed burn still went on cooldown") + // Its own companion in the roar's reach doesn't hold it back, and the roar spares it + qdel(bystander) + var/datum/human_npc_order/order = bounty_spawn_companion(criminal, spot(1, 2), null, TRUE, bounty_test_company_member("brawler", null)) // BOUNTY_STYLE_BRAWLER + var/mob/living/carbon/human/npc/bounty_companion/buddy = order?.spawned_ref?.resolve() + qdel(order) + TEST_ASSERT_NOTNULL(buddy, "Test fixture: no companion was made") + allocated += buddy + buddy.ai_controller?.set_ai_status(AI_STATUS_OFF) + TEST_ASSERT(move.can_start(hunter), "Its own companion in the roar's reach keeps the Redline shut") + TEST_ASSERT(chrome_ai.start_move(move, hunter), "The tell didn't start with its companion near") + sleep(1.2 SECONDS) + TEST_ASSERT_NOTNULL(criminal.has_status_effect(/datum/status_effect/cyberware_redline_window), "It didn't burn with only its companion near") + TEST_ASSERT_NULL(buddy.has_status_effect(/datum/status_effect/staggered), "The roar staggered its own companion") + /// An EMP in the tell fires nothing; one in the burn drops it straight into the implant's crash and back on its cooldown /datum/unit_test/voidcrew_bounty_chrome/redline_emp From 4658e7fced8c4758e89a8aa2907bed42e1dfb8ea Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:34:39 -0800 Subject: [PATCH 188/472] Give Gorilla Arms bone breaks, a haymaker, slams and finishers The piston punch now goes through the gore layer: the punching hand is the weapon, so aimed crits roll, blood sprays the way the blow went and the wound lines join the attack line. Three Gorilla hits on one limb in a short window snap it (four from Brawler myomers); heavy armour, robotic limbs and the Atlas frame stop the snap. A snapped arm, leg or chest shows bone through the skin. Every Gorilla throw takes one safe path: the shove, the hurl and the haymaker stop on the last good tile before space, open air, a chasm, lava, no gravity, glass, a door, a railing or a grille, and hit walls and standing bodies as before. The hurl and the new slam share a grab cooldown so nobody is pinned in a stun loop. A hard throw into a wall leaves a dent, a floor splash and edge smear, or a spray and runs, by the injury the body carries, and the body slides down the wall. Real Gorilla Arms add: - a Haymaker chip on each arm: hold or tap and click to wind up, then a hit that launches the target, harder into a wall; a miss staggers you; - grab and slam into the floor or onto a table (a table click while grabbing now slams instead of caving the table in); - a Ground pound chip with the synced pair: everyone around but your crew staggers and is dazed, loose things hop outward without being thrown; - three finishers on someone in crit or stunned, by aim: the head clap, the limb rip and the backbreaker, drawn with a puppet rig of the two bodies, stopped by damage, a step, cuffs, a stun or the victim breaking free, and sharing a cooldown with the haymaker; - a strained pry with steam and a widening seam; trader outpost doors refuse it and count it as aggression; - a flex when the pair syncs, after an EMP reboot, or on the Arm chrome key. Gorilla blows on people now count as aggression at trader outposts. The heavy moves sit behind one gate for players and boss-rank NPCs. Gorilla Arms are re-rated red. --- tgstation.dme | 11 + tools/gear_catalog/ratings/cyberware.json | 38 +- voidcrew/_DEFINES/gorilla_gore.dm | 124 +++ .../modules/cyberware/ware_line_models_a.dm | 6 + .../modules/cyberware/ware_military_arms.dm | 55 +- .../chrome_puppet_rig.dm | 273 ++++++ .../chrome_use_release.dm | 25 + .../hyperviolence_gorilla/gorilla_finisher.dm | 923 ++++++++++++++++++ .../hyperviolence_gorilla/gorilla_haymaker.dm | 388 ++++++++ .../hyperviolence_gorilla/gorilla_looks.dm | 193 ++++ .../hyperviolence_gorilla/gorilla_pound.dm | 156 +++ .../hyperviolence_gorilla/gorilla_pry.dm | 110 +++ .../hyperviolence_gorilla/gorilla_punch.dm | 344 +++++++ .../hyperviolence_gorilla/gorilla_throw.dm | 423 ++++++++ .../hv_finisher_chrome.dm | 134 +++ .../icons/gorilla_gore.dmi | Bin 0 -> 10014 bytes .../icons/gorilla_gore_large.dmi | Bin 0 -> 5439 bytes voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_gorilla_gore.dm | 682 +++++++++++++ 19 files changed, 3842 insertions(+), 44 deletions(-) create mode 100644 voidcrew/_DEFINES/gorilla_gore.dm create mode 100644 voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm create mode 100644 voidcrew/modules/hyperviolence_gorilla/chrome_use_release.dm create mode 100644 voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm create mode 100644 voidcrew/modules/hyperviolence_gorilla/gorilla_haymaker.dm create mode 100644 voidcrew/modules/hyperviolence_gorilla/gorilla_looks.dm create mode 100644 voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm create mode 100644 voidcrew/modules/hyperviolence_gorilla/gorilla_pry.dm create mode 100644 voidcrew/modules/hyperviolence_gorilla/gorilla_punch.dm create mode 100644 voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm create mode 100644 voidcrew/modules/hyperviolence_gorilla/hv_finisher_chrome.dm create mode 100644 voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore.dmi create mode 100644 voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore_large.dmi create mode 100644 voidcrew/modules/unit_tests/voidcrew_gorilla_gore.dm diff --git a/tgstation.dme b/tgstation.dme index d4d0f725540..a02f6dc3670 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6694,6 +6694,7 @@ #include "voidcrew\_DEFINES\drug_smuggling.dm" #include "voidcrew\_DEFINES\dynamic_events.dm" #include "voidcrew\_DEFINES\flags.dm" +#include "voidcrew\_DEFINES\gorilla_gore.dm" #include "voidcrew\_DEFINES\hud.dm" #include "voidcrew\_DEFINES\human_npcs.dm" #include "voidcrew\_DEFINES\job_categories.dm" @@ -7294,6 +7295,16 @@ #include "voidcrew\modules\hyperviolence\hitlines\npc_blood.dm" #include "voidcrew\modules\hyperviolence\overlays_exec\executions.dm" #include "voidcrew\modules\hyperviolence\overlays_exec\gore_overlays.dm" +#include "voidcrew\modules\hyperviolence_gorilla\chrome_puppet_rig.dm" +#include "voidcrew\modules\hyperviolence_gorilla\chrome_use_release.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_finisher.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_haymaker.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_looks.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_pound.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_pry.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_punch.dm" +#include "voidcrew\modules\hyperviolence_gorilla\gorilla_throw.dm" +#include "voidcrew\modules\hyperviolence_gorilla\hv_finisher_chrome.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" #include "voidcrew\modules\intents\hud_intent.dm" diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 6b03ffde959..24b1d40fbee 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -19,7 +19,7 @@ "hash": "c229d282cf893570", "name": "Strong-Arm empowered musculature implant", "note": "Humans only. With the implanted arm active, an empty-hand combat-mode left-click on a mob becomes a slam on a 5 s cooldown: the hand's unarmed damage roll plus 5 (plus 20 more against beast, special and mining mobs), doubled and run through melee armor, and the target is thrown 1 tile if the wearer is standing; it is not used while Hulk. One implant in each arm lets the wearer pry closed airlocks open bare-handed (6 s, instant if unpowered, refused if bolted or welded). An EMP puts the arm down for 9 s (4.5 s on a light pulse): slams fail, and half the time the wearer lunges onto the target and falls for 3 s, otherwise is paralyzed for 1 s.", - "rank": 10, + "rank": 12, "reason": "A slam for 20 to 30 damage every 5 s per arm, 60 to 70 against mining mobs and beasts, and a pair pries powered airlocks in 6 s. 1,200 cr for two from cargo. EMP makes the arm a liability for 9 s.", "tier": "yellow" }, @@ -37,7 +37,7 @@ "group": "Arm weapons and fists", "hash": "31fd669dadf0cf4c", "name": "Bunker Buster housing", - "rank": 8, + "rank": 10, "reason": "Two 60-damage rockets that floor the target and wreck doors and machines with no blast, about 10 on the gun score plus breaching. Two hands, two shots, then 600 cr at reputation 22 or late research. Loot only.", "tier": "yellow" }, @@ -180,7 +180,7 @@ "group": "Arm weapons and fists", "hash": "43903115df46709c", "name": "Popup Ronin housing", - "rank": 7, + "rank": 9, "reason": "A one-handed machine pistol in the arm that can't be disarmed: 20 damage, 3.3 shots a second, about a WT-550 on the gun score (47). 20-round magazines are 250 cr at reputation 22 or late research. Loot only.", "tier": "yellow" }, @@ -189,7 +189,7 @@ "group": "Arm weapons and fists", "hash": "", "name": "Holdout housing", - "rank": 9, + "rank": 11, "reason": "A .38 revolver in the arm that can't be disarmed: 25 damage, six shots, about 2.5 a second, reloaded by hand a round at a time with ordinary .38. Load 3, 2,400 cr at reputation 4. Splice takes a pulled housing back for 1,200 cr.", "tier": "yellow" }, @@ -582,22 +582,22 @@ "/obj/item/organ/cyberimp/cyberware/gorilla_arms": { "confidence": "high", "group": "Arm weapons and fists", - "hash": "9c748c5bf556a0e0", + "hash": "", "name": "Gorilla Arms myomer lattice (right)", - "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a flat 20 brute (armor rolled, +10 wound bonus) with a 1-tile knockback on a mob, 4 s cooldown per arm; a mob held in an aggressive grab is hurled 4 tiles. On a rock wall it drills it, and on a structure or machine with integrity it does 40 brute (1 s cooldown for both). With both arms, closed airlocks can be pried bare-handed (4 s if powered, instant if unpowered, refused if bolted or welded). Not used while Hulk or while the ware is down.", - "rank": 11, - "reason": "Half of a pair: a flat 20 damage punch every 4 s, 40 to machines and structures every second, breaks rock, and the pair pries powered airlocks in 4 s. A breaching tool more than a weapon. Load 6 the pair.", - "tier": "yellow" + "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a 20 brute punch (armor rolled, +15 wound bonus, through the gore layer's aimed crits) with a 1-tile knockback on the safe path, 4 s cooldown per arm; three punches on one limb inside 15 s snap it (compound fracture on an arm or leg, caved ribs on the chest) unless 40+ melee covers it. A mob held in an aggressive grab is hurled 4 tiles, or slammed into the floor (25) or onto a table (30), once per 10 s grab-move cooldown. Each arm has a Haymaker wheel chip: wind up 0.8 to 1.5 s, 20 to 30 brute and a 3 to 4 tile launch (into a wall: +10 and cracked ribs), 20 s shared cooldown. The pair adds Ground pound (stagger and daze everything adjacent bar crew, 45 s) and three finishers on a target in crit or stunned with both hands empty, picked by aim: head bursts the head, an arm tears it off, the chest breaks the back (floored until set); about 2 s of wind-up, interrupted by 10 damage, a step or the victim breaking free; shares the 20 s cooldown. Rock breaks, structures take 40, and the pair pries powered airlocks in 4 s (never trader outpost doors).", + "rank": 7, + "reason": "Half of a pair. With the haymaker, the grab-slam and three finishers it is a melee kill kit, not only a breaching tool: about Mantis-level against someone it can catch, and it ends downed targets outright. Counters are range, the wind-ups, 40+ melee armour and the Atlas frame. Load 6 the pair, 3 vouchers at advanced reputation.", + "tier": "red" }, "/obj/item/organ/cyberimp/cyberware/gorilla_arms/left": { "confidence": "high", "group": "Arm weapons and fists", - "hash": "25756d0541291f90", + "hash": "", "name": "Gorilla Arms myomer lattice (left)", - "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a flat 20 brute (armor rolled, +10 wound bonus) with a 1-tile knockback on a mob, 4 s cooldown per arm; a mob held in an aggressive grab is hurled 4 tiles. On a rock wall it drills it, and on a structure or machine with integrity it does 40 brute (1 s cooldown for both). With both arms, closed airlocks can be pried bare-handed (4 s if powered, instant if unpowered, refused if bolted or welded). Not used while Hulk or while the ware is down.", - "rank": 12, - "reason": "Left half of the Gorilla pair: a flat 20 damage punch every 4 s, 40 to machines and structures every second, breaks rock, and the pair pries powered airlocks in 4 s.", - "tier": "yellow" + "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a 20 brute punch (armor rolled, +15 wound bonus, through the gore layer's aimed crits) with a 1-tile knockback on the safe path, 4 s cooldown per arm; three punches on one limb inside 15 s snap it (compound fracture on an arm or leg, caved ribs on the chest) unless 40+ melee covers it. A mob held in an aggressive grab is hurled 4 tiles, or slammed into the floor (25) or onto a table (30), once per 10 s grab-move cooldown. Each arm has a Haymaker wheel chip: wind up 0.8 to 1.5 s, 20 to 30 brute and a 3 to 4 tile launch (into a wall: +10 and cracked ribs), 20 s shared cooldown. The pair adds Ground pound (stagger and daze everything adjacent bar crew, 45 s) and three finishers on a target in crit or stunned with both hands empty, picked by aim: head bursts the head, an arm tears it off, the chest breaks the back (floored until set); about 2 s of wind-up, interrupted by 10 damage, a step or the victim breaking free; shares the 20 s cooldown. Rock breaks, structures take 40, and the pair pries powered airlocks in 4 s (never trader outpost doors).", + "rank": 8, + "reason": "Left half of the Gorilla pair: the punch, snaps, slam and Haymaker; the pair adds Ground pound and the three finishers. With the haymaker, the grab-slam and three finishers it is a melee kill kit, not only a breaching tool: about Mantis-level against someone it can catch, and it ends downed targets outright. Counters are range, the wind-ups, 40+ melee armour and the Atlas frame. Load 6 the pair, 3 vouchers at advanced reputation.", + "tier": "red" }, "/obj/item/organ/cyberimp/cyberware/gorilla_arms/street": { "confidence": "medium", @@ -605,8 +605,9 @@ "hash": "", "name": "Brawler myomers (right)", "rank": 15, - "reason": "Half of a pair: a flat 14 damage punch every 5 s, 20 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying. Load 4 the pair, 1,600 cr at reputation 4. Either ripperdoc takes a pulled arm back for 400 cr.", - "tier": "yellow" + "reason": "Half of a pair: a 14 damage punch every 5 s that can snap a limb on the fourth hit, 20 to machines and structures every 1.5 s, breaks rock. No throw on a grab, no heavy moves and no door prying. Load 4 the pair, 1,600 cr at reputation 4. Either ripperdoc takes a pulled arm back for 400 cr.", + "tier": "yellow", + "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a 14 brute punch (armor rolled, +10 wound bonus, through the gore layer's aimed crits) with a 1-tile knockback, 5 s cooldown per arm; four punches on one limb inside 15 s snap it unless 40+ melee covers it. 20 to structures every 1.5 s, breaks rock. No hurl, slam, haymaker, ground pound, finisher or door pry." }, "/obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left": { "confidence": "medium", @@ -614,8 +615,9 @@ "hash": "", "name": "Brawler myomers (left)", "rank": 16, - "reason": "Left half of the Brawler pair: a flat 14 damage punch every 5 s, 20 to machines and structures every 1.5 s, breaks rock. No throw on a grab and no door prying.", - "tier": "yellow" + "reason": "Left half of the Brawler pair: a 14 damage punch every 5 s that can snap a limb on the fourth hit, 20 to machines and structures every 1.5 s, breaks rock. No heavy moves and no door prying.", + "tier": "yellow", + "note": "Humans only. Each arm turns an empty-hand combat-mode left-click into a 14 brute punch (armor rolled, +10 wound bonus, through the gore layer's aimed crits) with a 1-tile knockback, 5 s cooldown per arm; four punches on one limb inside 15 s snap it unless 40+ melee covers it. 20 to structures every 1.5 s, breaks rock. No hurl, slam, haymaker, ground pound, finisher or door pry." }, "/obj/item/organ/cyberimp/cyberware/governor_delete": { "confidence": "medium", diff --git a/voidcrew/_DEFINES/gorilla_gore.dm b/voidcrew/_DEFINES/gorilla_gore.dm new file mode 100644 index 00000000000..34bdd471044 --- /dev/null +++ b/voidcrew/_DEFINES/gorilla_gore.dm @@ -0,0 +1,124 @@ +// Gorilla Arms x hyperviolence (voidcrew/modules/hyperviolence_gorilla/). Balance notes for +// developers; none of these numbers go in player text. Unit tests can't see these (they compile +// before voidcrew/_DEFINES/), so tests use the literal values with a comment naming the define. + +/// 32x32 pictures: knuckle sparks, piston pumps and heat, scuffs, the pry seam, snapped bones, dents, +/// the finisher masks. +#define GORILLA_GORE_ICON 'voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore.dmi' +/// 64x64 pictures drawn at -16,-16 on the floor tile in front of a wall: the wall impacts. +#define GORILLA_GORE_LARGE_ICON 'voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore_large.dmi' + +/// Layer for sparks and flashes over the whole body. +#define GORILLA_ANIM_TOP_LAYER (-(HIGHEST_LAYER - 0.5)) +/// Layer for the piston pump and heat over the worn arm plates: just above the plates (under suits). +#define GORILLA_ANIM_PLATE_LAYER (-(CYBERWARE_WORN_LAYER - 0.05)) + +// ---- The punch (gorilla_punch.dm) ---- + +/// Wound bonus on a Gorilla piston punch; the Brawler keeps its own. +#define GORILLA_PUNCH_WOUND_BONUS 15 +/// Wound bonus on a Brawler punch. +#define BRAWLER_PUNCH_WOUND_BONUS 10 +/// Brute after armour a punch must deal to strain the limb it hit. +#define GORILLA_STRAIN_MIN_DAMAGE 8 +/// How long a limb holds strain after its last hit. +#define GORILLA_STRAIN_MEMORY (15 SECONDS) +/// Melee cover on a limb (after penetration) that stops a forced snap: #509's aorta scale. +#define GORILLA_SNAP_ARMOUR 40 + +// ---- Shared cooldowns, kept on the bearer (TIMER_COOLDOWN) so both arms share them ---- + +/// The haymaker and every finisher: the pistons are spent. +#define COOLDOWN_GORILLA_PISTONS "gorilla_pistons" +/// The hurl and the slam: one grab move at a time. +#define COOLDOWN_GORILLA_GRAB_MOVE "gorilla_grab_move" +/// The ground pound. +#define COOLDOWN_GORILLA_POUND "gorilla_pound" +/// The deploy flex (cosmetic). +#define COOLDOWN_GORILLA_FLEX "gorilla_flex" + +#define GORILLA_PISTONS_COOLDOWN (20 SECONDS) +/// After a finisher that was called off before the kill. +#define GORILLA_PISTONS_INTERRUPTED (10 SECONDS) +/// After a haymaker held too long and vented. +#define GORILLA_PISTONS_VENTED (5 SECONDS) +#define GORILLA_GRAB_MOVE_COOLDOWN (10 SECONDS) +#define GORILLA_POUND_COOLDOWN (45 SECONDS) +#define GORILLA_FLEX_COOLDOWN (10 SECONDS) + +// ---- The haymaker (gorilla_haymaker.dm) ---- + +#define GORILLA_HAYMAKER_MIN_CHARGE (0.8 SECONDS) +#define GORILLA_HAYMAKER_FULL_CHARGE (1.5 SECONDS) +/// Held this long, the pistons vent and the charge is lost. +#define GORILLA_HAYMAKER_VENT_AFTER (3 SECONDS) +/// A press of Use chrome shorter than this is a tap: the charge waits for a click. +#define GORILLA_HAYMAKER_TAP (0.3 SECONDS) +#define GORILLA_HAYMAKER_MIN_DAMAGE 20 +#define GORILLA_HAYMAKER_MAX_DAMAGE 30 +#define GORILLA_HAYMAKER_MIN_RANGE 3 +#define GORILLA_HAYMAKER_MAX_RANGE 4 +#define GORILLA_HAYMAKER_SPEED 3 +/// Extra brute when a haymaker's body hits a wall. +#define GORILLA_HAYMAKER_WALL_DAMAGE 10 + +// ---- Grab and slam ---- + +#define GORILLA_SLAM_FLOOR_DAMAGE 25 +#define GORILLA_SLAM_FLOOR_STAMINA 30 +#define GORILLA_SLAM_TABLE_DAMAGE 30 +#define GORILLA_SLAM_TABLE_STAMINA 40 +#define GORILLA_SLAM_KNOCKDOWN (2 SECONDS) +#define GORILLA_SLAM_TABLE_BREAK 50 +/// What a click while grabbing turned into (gorilla_arms/proc/try_slam()). Not a slam: carry on with the punch. +#define GORILLA_SLAM_NO 0 +/// A slam was done. +#define GORILLA_SLAM_DONE 1 +/// A slam onto a table that can't happen now: leave the click to tg's own table smash. +#define GORILLA_SLAM_PASS 2 + +// ---- Throws (gorilla_throw.dm) ---- + +/// What stopped a planned knockback short of its full reach. +#define GORILLA_THROW_CLEAR 0 +/// A wall: the body hits it. +#define GORILLA_THROW_WALL 1 +/// A mob standing in the way: the body hits it. +#define GORILLA_THROW_MOB 2 +/// Glass, a door, a railing, a hazard or no gravity: the body stops short and hits nothing. +#define GORILLA_THROW_SHORT 3 +/// Most decals a Gorilla throw leaves along its path. +#define GORILLA_TRAIL_MAX 4 + +// What a wall impact draws, by the injury it caused (owner D38). +/// No skin broken: a dent and chips. +#define GORILLA_SPLAT_DENT 1 +/// Skin broken, moderate: a half-moon on the floor and a thin smear on the wall's edge. +#define GORILLA_SPLAT_EDGE 2 +/// Severe bleeding or a torn artery: a spray up the wall and runs. +#define GORILLA_SPLAT_SPRAY 3 + +// ---- Ground pound ---- + +#define GORILLA_POUND_CROUCH (0.45 SECONDS) +#define GORILLA_POUND_STAMINA 20 +#define GORILLA_POUND_DAZE (3 SECONDS) +/// Loose items this far from the pound hop a tile outward. +#define GORILLA_POUND_ITEM_REACH 2 + +// ---- Finishers (gorilla_finisher.dm) ---- + +/// Damage on the bearer that makes them let go. +#define GORILLA_FINISHER_INTERRUPT 10 +/// Melee cover on the head that turns the head clap's burst into a crumpled helmet: #509's skull gate. +#define GORILLA_CLAP_HELMET_ARMOUR 30 +/// Melee cover on an arm that turns the limb rip into a dislocation. +#define GORILLA_RIP_ARMOUR 40 +/// Melee cover on the chest that turns the broken back into cracked ribs. +#define GORILLA_BACK_ARMOUR 40 +/// How long a broken back's fold stays drawn after the finisher. +#define GORILLA_FOLD_TIME (10 SECONDS) + +/// Offset and filter sources. +#define GORILLA_WALL_SAG "gorilla_wall_sag" +#define GORILLA_PUPPET_HIDE "gorilla_puppet_hide" diff --git a/voidcrew/modules/cyberware/ware_line_models_a.dm b/voidcrew/modules/cyberware/ware_line_models_a.dm index 2960751f369..188fcc13688 100644 --- a/voidcrew/modules/cyberware/ware_line_models_a.dm +++ b/voidcrew/modules/cyberware/ware_line_models_a.dm @@ -116,6 +116,12 @@ smash_cooldown_time = 1.5 SECONDS hurl_range = 0 pries_doors = FALSE + // Punches and broken bones only: no slam, haymaker, ground pound or finisher (hyperviolence_gorilla/) + heavy_moves = FALSE + fist_word = "myomer-driven" + snap_strain = 4 + punch_wound_bonus = BRAWLER_PUNCH_WOUND_BONUS + impact_state = "impact_brawler" /obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left name = "\improper Brawler myomers (left)" diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index 9e3c8124455..7e3ad5f9cc8 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -154,6 +154,9 @@ . = ..() if(pries_doors) AddElement(/datum/element/organ_set_bonus, /datum/status_effect/organ_set_bonus/cyberware_gorilla) + // Each real arm winds up a haymaker; both share the pistons' cooldown (hyperviolence_gorilla/gorilla_haymaker.dm) + if(heavy_moves) + add_item_action(/datum/action/cooldown/cyberware/gorilla_haymaker) /** * Worn-chrome overlay hook. Gorilla Arms replace the limb outright, hand @@ -216,6 +219,15 @@ COOLDOWN_START(src, slam_cooldown, smash_cooldown_time) return COMPONENT_CANCEL_ATTACK_CHAIN + // Someone held in an aggressive grab, brought down on the floor or a table (hyperviolence_gorilla/gorilla_punch.dm). + // Before the structure smash, so a table click while grabbing slams them instead of caving the table in. + switch(try_slam(source, target, active_hand)) + if(GORILLA_SLAM_DONE) + COOLDOWN_START(src, slam_cooldown, punch_cooldown_time) + return COMPONENT_CANCEL_ATTACK_CHAIN + if(GORILLA_SLAM_PASS) + return NONE + // Structures, girders, machines cave in. Loose items are beneath us. if(isobj(target) && !isitem(target)) var/obj/smashed = target @@ -249,30 +261,12 @@ log_combat(source, living_target, "attempted to piston-punch", "gorilla arms") return COMPONENT_CANCEL_ATTACK_CHAIN - // The one damage line. Flat, wounding, honest. - var/grabbed_hurl = hurl_range > 0 && living_target.pulledby == source && source.grab_state >= GRAB_AGGRESSIVE - source.do_attack_animation(living_target, ATTACK_EFFECT_SMASH) - playsound(living_target.loc, 'sound/items/weapons/punch1.ogg', 40, TRUE, -1) - var/target_zone = living_target.get_random_valid_zone(source.zone_selected) - var/armor_block = living_target.run_armor_check(target_zone, MELEE, armour_penetration = active_hand.unarmed_effectiveness) - living_target.apply_damage(punch_damage, BRUTE, target_zone, armor_block, wound_bonus = 10) - - if(source.body_position != LYING_DOWN) - var/atom/throw_target = get_edge_target_turf(living_target, source.dir) - if(grabbed_hurl) - living_target.safe_throw_at(throw_target, hurl_range, 2, source) - living_target.visible_message( - span_danger("[source] hurls [living_target] across the room!"), - span_userdanger("[source] hurls you across the room!"), - ) - else - living_target.throw_at(throw_target, 1, rand(1, 4), source, gentle = TRUE) - living_target.visible_message( - span_danger("[source] slams [living_target] with a piston-driven fist!"), - span_userdanger("[source] slams you with a piston-driven fist!"), - ) - to_chat(source, span_danger("You slam [living_target]!")) - log_combat(source, living_target, "piston-punched", "gorilla arms") + // Someone in crit or stunned, with the aimed zone picking the finisher (hyperviolence_gorilla/gorilla_finisher.dm). + if(gorilla_try_finisher(source, living_target)) + return COMPONENT_CANCEL_ATTACK_CHAIN + + // The one damage line, through #509, with the knockback on the safe path (hyperviolence_gorilla/gorilla_punch.dm). + piston_hit(source, living_target, active_hand) COOLDOWN_START(src, slam_cooldown, punch_cooldown_time) return COMPONENT_CANCEL_ATTACK_CHAIN @@ -293,11 +287,20 @@ . = ..() if(!.) return - owner.AddElement(/datum/element/door_pryer, pry_time = 4 SECONDS, interaction_key = DOAFTER_SOURCE_GORILLA_PRY) + // The pry refuses trader outpost doors and plays the strain (hyperviolence_gorilla/gorilla_pry.dm) + owner.AddElement(/datum/element/door_pryer/gorilla, pry_time = 4 SECONDS, interaction_key = DOAFTER_SOURCE_GORILLA_PRY) + // The ground pound chip, and the flex as the arms come online (hyperviolence_gorilla/gorilla_pound.dm) + grant_pound() + gorilla_flex(owner) /datum/status_effect/organ_set_bonus/cyberware_gorilla/disable_bonus(obj/item/organ/removed_organ) . = ..() - owner.RemoveElement(/datum/element/door_pryer, pry_time = 4 SECONDS, interaction_key = DOAFTER_SOURCE_GORILLA_PRY) + owner.RemoveElement(/datum/element/door_pryer/gorilla, pry_time = 4 SECONDS, interaction_key = DOAFTER_SOURCE_GORILLA_PRY) + take_pound() + +/datum/status_effect/organ_set_bonus/cyberware_gorilla/on_remove() + take_pound() + return ..() // ========================================================================= // MANTIS BLADES diff --git a/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm b/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm new file mode 100644 index 00000000000..fda61d5bbab --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm @@ -0,0 +1,273 @@ +/** + * # The puppet rig ("arm rig") + * + * tg bodies have no poses, so a chrome finisher that needs a real arm swing, a body held up by the + * collar or folded over a knee draws one: the real body is hidden for a few seconds and stand-ins + * ("puppets") cut from one flat picture of it are posed in its place. + * + * - One getFlatIcon() of the body, facing the way the finisher needs, at the start. + * - The picture is cut with masks drawn from tg's human base sprite (gorilla_gore.dmi, rig_*): the arms + * (with their sleeves, gloves and chrome, whatever is worn), the body without one or both arms, and + * the upper and lower halves at the belt. In a side view the near arm covers the torso, so the torso + * under it is filled from the torso just beside it. + * - Each puppet is a plain object on the body's tile, posed with its own transform and offsets, so + * nothing ever touches the real body's transform, offsets or layer; tg's lying turn, grab offsets and + * height filters stay right. A puppet turns about a pivot (a shoulder, the belt) with chrome_rig_turn(). + * - The real body is hidden with a colour filter, and made clickable over its whole tile while hidden, + * so the crew can still click the body to stop it. Puppets never take clicks. + * - Off: the filter and the click area come back and every puppet is deleted. That happens when the rig + * is deleted, which its owner does on every way a finisher ends, and which the rig does itself if the + * body is deleted. + * + * A body whose flat picture isn't 32x32 (something huge worn or held) gets no rig: the move still + * happens, just without the poses. + */ + +/// Mask states and pivots below are for tg's human base body, medium height. +#define CHROME_RIG_HIDE_FILTER "chrome_rig_hide" + +/** + * Shoulder pivots (pixels from the icon's centre, x right, y up) per facing, from the human base + * sprite. list(dir = list(left_x, left_y, right_x, right_y)). + */ +GLOBAL_LIST_INIT(chrome_rig_shoulders, list( + "[SOUTH]" = list(4, 4, -6, 4), + "[NORTH]" = list(-6, 4, 4, 4), + "[EAST]" = list(-4, 2, -4, 2), + "[WEST]" = list(4, 2, 4, 2), +)) + +/// Where the torso under a side view's near arm is filled from: a pixel takes the one this far behind +/// it along x (negative: from in front). list(dir = shift). +GLOBAL_LIST_INIT(chrome_rig_fill_shift, list( + "[EAST]" = -4, + "[WEST]" = 4, +)) + +/** + * A matrix turning a puppet by [angle] (clockwise) about [pivot_x], [pivot_y] (pixels from the centre), + * then moving it by [shift_x], [shift_y]. + */ +/proc/chrome_rig_turn(angle, pivot_x = 0, pivot_y = 0, shift_x = 0, shift_y = 0, scale_x = 1, scale_y = 1) + var/matrix/turned = matrix() + turned.Translate(-pivot_x, -pivot_y) + if(scale_x != 1 || scale_y != 1) + turned.Scale(scale_x, scale_y) + turned.Turn(angle) + turned.Translate(pivot_x + shift_x, pivot_y + shift_y) + return turned + +/// One stand-in picture on a hidden body's tile. +/obj/effect/abstract/chrome_puppet + name = "body" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + anchored = TRUE + appearance_flags = PIXEL_SCALE | TILE_BOUND | LONG_GLIDE + plane = GAME_PLANE + layer = MOB_LAYER + vis_flags = NONE + +/// Hides a body and stands puppets in its place. +/datum/chrome_rig + /// The hidden body + var/mob/living/body + /// The flat picture puppets are cut from + var/icon/flat + /// The facing the picture was taken in + var/facing + /// Key -> /obj/effect/abstract/chrome_puppet + var/list/puppets = list() + /// The body's click area before it was hidden + var/old_mouse_opacity + /// TRUE while the body is hidden + var/hidden = FALSE + +/datum/chrome_rig/New(mob/living/body, facing) + . = ..() + src.body = body + src.facing = facing || body.dir + if(!(src.facing in GLOB.cardinals)) + src.facing = (src.facing & (EAST|WEST)) || SOUTH + RegisterSignal(body, COMSIG_QDELETING, PROC_REF(on_body_deleted)) + +/datum/chrome_rig/Destroy() + reveal() + for(var/key in puppets) + qdel(puppets[key]) + puppets = null + if(body) + UnregisterSignal(body, COMSIG_QDELETING) + body = null + flat = null + return ..() + +/datum/chrome_rig/proc/on_body_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// Takes the flat picture. Returns FALSE if the body can't be rigged (gone, or not a 32x32 picture). +/datum/chrome_rig/proc/snapshot() + if(QDELETED(body) || !isturf(body.loc)) + return FALSE + var/icon/picture = getFlatIcon(body, facing, no_anim = TRUE) + if(!picture || picture.Width() != ICON_SIZE_X || picture.Height() != ICON_SIZE_Y) + return FALSE + flat = picture + return TRUE + +/// A copy of the flat picture cut by [mask_state] (white keeps, clear cuts) from the rig masks. +/datum/chrome_rig/proc/cut(mask_state) + var/icon/piece = icon(flat) + piece.Blend(icon(GORILLA_GORE_ICON, mask_state, facing), ICON_MULTIPLY) + return piece + +/// The flat picture with the arm on [side] ("l", "r" or "both") cut out, the torso under it filled. +/datum/chrome_rig/proc/cut_without_arms(side = "both") + var/icon/piece = cut(side == "both" ? "rig_keep" : "rig_keep_[side]") + var/shift = GLOB.chrome_rig_fill_shift["[facing]"] + if(!shift) + return piece + for(var/fill_side in (side == "both" ? list("l", "r") : list(side))) + var/icon/fill = icon(flat) + fill.Shift(shift > 0 ? EAST : WEST, abs(shift)) + fill.Blend(icon(GORILLA_GORE_ICON, "rig_fill_[fill_side]", facing), ICON_MULTIPLY) + piece.Blend(fill, ICON_OVERLAY) + return piece + +/// Hides the real body. Its whole tile still takes clicks. +/datum/chrome_rig/proc/conceal() + if(hidden || QDELETED(body)) + return + hidden = TRUE + old_mouse_opacity = body.mouse_opacity + body.mouse_opacity = MOUSE_OPACITY_OPAQUE + body.add_filter(CHROME_RIG_HIDE_FILTER, 1, color_matrix_filter(list(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0))) + +/// Shows the real body again. +/datum/chrome_rig/proc/reveal() + if(!hidden) + return + hidden = FALSE + if(QDELETED(body)) + return + body.mouse_opacity = old_mouse_opacity + body.remove_filter(CHROME_RIG_HIDE_FILTER) + +/** + * Stands a puppet showing [picture] on the body's tile under [key], replacing any under that key. + * It starts where the body is drawn now: the body's offsets and, with [copy_transform], its transform. + */ +/datum/chrome_rig/proc/add_puppet(key, icon/picture, copy_transform = FALSE, puppet_layer) + var/turf/spot = get_turf(body) + if(isnull(spot)) + return null + qdel(puppets[key]) + var/obj/effect/abstract/chrome_puppet/puppet = new(spot) + puppet.name = body.name + puppet.icon = picture + puppet.dir = SOUTH + puppet.pixel_x = body.pixel_x + puppet.pixel_y = body.pixel_y + puppet.pixel_w = body.pixel_w + puppet.pixel_z = body.pixel_z + puppet.layer = puppet_layer || body.layer + if(copy_transform) + puppet.transform = body.transform + puppets[key] = puppet + return puppet + +/// The puppet under [key], or null. +/datum/chrome_rig/proc/puppet(key) + return puppets?[key] + +/// Deletes the puppet under [key]. +/datum/chrome_rig/proc/drop_puppet(key) + var/obj/effect/abstract/chrome_puppet/puppet = puppets?[key] + if(puppet) + puppets -= key + qdel(puppet) + +/// The pivot of the shoulder on [side] ("l" or "r") in this rig's facing. +/datum/chrome_rig/proc/shoulder(side) + var/list/pivots = GLOB.chrome_rig_shoulders["[facing]"] + return side == "l" ? list(pivots[1], pivots[2]) : list(pivots[3], pivots[4]) + +/** + * Which way the arm on [side] turns to come up and out from the body: clockwise (1) or anticlockwise + * (-1). In a side view both arms come up toward the facing. + */ +/datum/chrome_rig/proc/raise_sign(side) + switch(facing) + if(SOUTH) + return side == "r" ? 1 : -1 + if(NORTH) + return side == "l" ? 1 : -1 + if(EAST) + return -1 + if(WEST) + return 1 + return 1 + +/** + * Builds the standard bearer rig: the body without arms and both arms on their own puppets. Hides the + * real body. Returns TRUE if it is up. + */ +/datum/chrome_rig/proc/rig_arms() + if(!snapshot()) + return FALSE + add_puppet("body", cut_without_arms("both")) + // The far arm in a side view sits under the body; the near one over it. + var/near = facing == WEST ? "l" : "r" + for(var/side in list("l", "r")) + var/obj/effect/abstract/chrome_puppet/arm = add_puppet("arm_[side]", cut("rig_arm_[side]")) + if(facing in list(EAST, WEST)) + arm.layer = side == near ? body.layer + 0.01 : body.layer - 0.01 + conceal() + return TRUE + +/// Turns the arm on [side] to [angle] degrees up from hanging (0) over [time], with [reach] pixels of piston out along it. +/datum/chrome_rig/proc/swing_arm(side, angle, time, reach = 0, easing = SINE_EASING) + var/obj/effect/abstract/chrome_puppet/arm = puppet("arm_[side]") + if(!arm) + return + var/list/pivot = shoulder(side) + var/turn = angle * raise_sign(side) + // The piston pushes the fist out along the arm: straight down when hanging, turned with it. + var/push_x = -reach * sin(turn) + var/push_y = -reach * cos(turn) + animate(arm, transform = chrome_rig_turn(turn, pivot[1], pivot[2], round(push_x), round(push_y)), time = time, easing = easing) + +/// Heats an arm puppet's colour toward white-hot over [time]. +/datum/chrome_rig/proc/heat_arm(side, time, heat = 1) + var/obj/effect/abstract/chrome_puppet/arm = puppet("arm_[side]") + if(!arm) + return + if(!islist(arm.color)) + arm.color = list(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0) + var/list/hot = list(1, 0, 0, 0, 1 - 0.1 * heat, 0, 0, 0, 1 - 0.2 * heat, 0.3 * heat, 0.16 * heat, 0.04 * heat) + animate(arm, color = hot, time = time, flags = ANIMATION_PARALLEL) + +/// A puff of steam off an arm puppet. +/datum/chrome_rig/proc/steam_arm(side, emit_time = 0.3 SECONDS) + var/obj/effect/abstract/chrome_puppet/arm = puppet("arm_[side]") + if(arm) + cyberware_anim_particles(arm, /particles/smoke/steam/gorilla_puff, FLOAT_LAYER, emit_time, emit_time + 0.8 SECONDS) + +/// Every puppet trembles a pixel for [time]. +/datum/chrome_rig/proc/tremble(time = 0.4 SECONDS) + for(var/key in puppets) + var/obj/effect/abstract/chrome_puppet/puppet = puppets[key] + for(var/i in 1 to max(1, round(time / (0.1 SECONDS)))) + var/step = (i % 2) ? 1 : -1 + if(i == 1) + animate(puppet, pixel_w = step, time = 0.05 SECONDS, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + else + animate(pixel_w = step * 2, time = 0.05 SECONDS, flags = ANIMATION_RELATIVE) + animate(pixel_w = -step, time = 0.05 SECONDS, flags = ANIMATION_RELATIVE) + +/// Every puppet flashes white and fades back. +/datum/chrome_rig/proc/flash() + for(var/key in puppets) + cyberware_flash_fx(puppets[key], 0.2 SECONDS) + +#undef CHROME_RIG_HIDE_FILTER diff --git a/voidcrew/modules/hyperviolence_gorilla/chrome_use_release.dm b/voidcrew/modules/hyperviolence_gorilla/chrome_use_release.dm new file mode 100644 index 00000000000..4b24244c20a --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/chrome_use_release.dm @@ -0,0 +1,25 @@ +/** + * # Use chrome, let go + * + * A hook for charged chrome on the chrome wheel (cyberware/chrome_wheel*.dm): when the Use chrome key comes + * back up, the picked ability hears it through chrome_use_released(). The Gorilla haymaker throws its + * charge there. Kept in its own file so other charged chrome can share it. + */ + +/** + * The bearer let go of the Use chrome key with this ability picked. A charged ability can throw what + * it built up. Returns TRUE if it did something. Every other ability ignores it. + */ +/datum/action/proc/chrome_use_released(mob/living/carbon/bearer) + return FALSE + +/datum/keybinding/living/chrome/use/up(client/user, turf/target) + . = ..() + if(.) + return + var/mob/living/carbon/bearer = user.mob + if(!iscarbon(bearer)) + return + var/datum/component/chrome_wheel/wheel = bearer.GetComponent(/datum/component/chrome_wheel) + var/datum/action/picked = wheel?.get_selected_ability() + picked?.chrome_use_released(bearer) diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm new file mode 100644 index 00000000000..be9e76a2634 --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm @@ -0,0 +1,923 @@ +/** + * # Gorilla finishers (owner D39) + * + * A piston punch from a synced real pair, with both hands empty, on someone in crit or stunned, finishes + * them instead. The aimed zone picks which: + * - Head: the head clap. Hauled off the floor by the collar, a beat of kicking, then both fists meet on + * the head and it bursts (#509's burst; the brain is thrown out whole, so revival works). A helmet or + * plating crumples instead and the skull caves in. Needs gravity, or the brain would drift off. + * - Arm (the aimed one, else the other): the limb rip. A boot on the body, both fists on the arm, it tears + * off at the shoulder with the spray running back over the bearer, and one blow to the head with it. + * Armour on the arm wrenches the shoulder out instead. + * - Chest: the backbreaker. Pressed overhead, brought down across a knee, the back breaks and the body + * is rolled off still bent. Armour on the chest cracks the ribs instead. + * The Atlas frame beats all three: nothing bursts, tears or breaks. + * + * The wind-up is the two bodies (owner D34: no mark): the puppet rig (chrome_puppet_rig.dm) poses the + * bearer's arms and the victim. It can be stopped like any #509 finisher: enough damage on the bearer, + * the bearer moving, stunned or cuffed, the victim pulled away or out of their stun, the arms falling + * out of sync. The haymaker and the finishers share the pistons' cooldown: longer after a kill than + * after being stopped. Starting one counts as aggression at a trader outpost. + */ + +/** + * Starts the Gorilla finisher [zone] picks (the bearer's aim when null), if one can start on [victim]. + * Returns TRUE if it did. The punch calls it with the aim; an AI can call it with a zone. + */ +/proc/gorilla_try_finisher(mob/living/carbon/human/source, mob/living/victim, zone) + if(!iscarbon(victim) || !ishuman(source) || !gorilla_synced(source) || !gorilla_may_use_heavy_moves(source)) + return FALSE + if(!TIMER_COOLDOWN_FINISHED(source, COOLDOWN_GORILLA_PISTONS) || source.chrome_finisher) + return FALSE + if(!chrome_finisher_valid_victim(source, victim)) + return FALSE + zone = check_zone(zone || source.zone_selected) + var/finisher_type + switch(zone) + if(BODY_ZONE_HEAD) + finisher_type = /datum/hv_finisher/chrome/gorilla/clap + if(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM) + finisher_type = /datum/hv_finisher/chrome/gorilla/rip + if(BODY_ZONE_CHEST) + finisher_type = /datum/hv_finisher/chrome/gorilla/backbreaker + if(!finisher_type) + return FALSE + var/datum/hv_finisher/chrome/gorilla/finisher = new finisher_type(source, victim) + finisher.aimed_zone = zone + if(!finisher.start()) + qdel(finisher) + return FALSE + gorilla_register_aggression(victim, source) + return TRUE + +/datum/hv_finisher/chrome/gorilla + name = "gorilla finisher" + /// The zone the bearer aimed when it started + var/aimed_zone + /// The bearer's facing toward the victim, fixed at the start + var/facing + /// The bearer's puppets + var/datum/chrome_rig/bearer_rig + /// The victim's puppets + var/datum/chrome_rig/victim_rig + +/datum/hv_finisher/chrome/gorilla/can_start() + if(!..()) + return FALSE + if(!ishuman(user) || !gorilla_synced(user) || !TIMER_COOLDOWN_FINISHED(user, COOLDOWN_GORILLA_PISTONS)) + return FALSE + var/mob/living/carbon/human/bearer = user + for(var/obj/item/held in bearer.held_items) + return FALSE + var/list/arms = gorilla_heavy_arms(bearer) + if(length(arms) < 2) + return FALSE + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in arms) + if(!arm.gorilla_running()) + return FALSE + return can_start_on(victim) + +/// What this finisher needs of the victim besides being down. +/datum/hv_finisher/chrome/gorilla/proc/can_start_on(mob/living/carbon/target) + return TRUE + +/datum/hv_finisher/chrome/gorilla/safety_check() + . = ..() + if(!. && !completed && !gorilla_synced(user)) + return HV_FINISHER_ATTACKER_DOWN + +/datum/hv_finisher/chrome/gorilla/stop(reason) + if(!running) + return + var/finished = completed + // The parent deletes the finisher, which clears these + var/mob/living/bearer = user + var/mob/living/body = victim + . = ..() + cleanup_rigs() + if(!QDELETED(bearer)) + gorilla_spend_pistons(bearer, finished ? GORILLA_PISTONS_COOLDOWN : GORILLA_PISTONS_INTERRUPTED) + if(finished && !QDELETED(body)) + hv_execution_witnesses(bearer, body) + +/datum/hv_finisher/chrome/gorilla/Destroy() + cleanup_rigs() + return ..() + +/// Takes every puppet down and shows both bodies. +/datum/hv_finisher/chrome/gorilla/proc/cleanup_rigs() + QDEL_NULL(bearer_rig) + QDEL_NULL(victim_rig) + +/// The bearer's rig, made on first use: the body without arms and both arms on their own puppets. +/datum/hv_finisher/chrome/gorilla/proc/rig_bearer() + if(bearer_rig || QDELETED(user)) + return bearer_rig + user.face_atom(victim) + facing = user.dir + bearer_rig = new(user, facing) + if(!bearer_rig.rig_arms()) + QDEL_NULL(bearer_rig) + return bearer_rig + +/// Whether the bearer is seen from the side (facing east or west). +/datum/hv_finisher/chrome/gorilla/proc/side_view() + return facing == EAST || facing == WEST + +// The one gate on who may use the Gorilla's heavy moves (gorilla_punch.dm). +/datum/hv_finisher/chrome/gorilla/user_may_finish() + return gorilla_may_use_heavy_moves(user) + +/// Pixels from the victim's tile to the bearer's. +/datum/hv_finisher/chrome/gorilla/proc/to_bearer() + return list((user.x - victim.x) * ICON_SIZE_X, (user.y - victim.y) * ICON_SIZE_Y) + +/// Both of the bearer's arms to [angle], with [reach] pixels of piston. +/datum/hv_finisher/chrome/gorilla/proc/arms_to(angle, time, reach = 0) + bearer_rig?.swing_arm("l", angle, time, reach) + bearer_rig?.swing_arm("r", angle, time, reach) + +/// Both arms heat and steam. +/datum/hv_finisher/chrome/gorilla/proc/arms_strain(time) + if(!bearer_rig) + return + for(var/side in list("l", "r")) + bearer_rig.heat_arm(side, time) + bearer_rig.steam_arm(side, time) + playsound(user, 'sound/machines/steam_hiss.ogg', 35, TRUE) + +/// The bearer's punching hand, the attacking item for #509's hooks. +/datum/hv_finisher/chrome/gorilla/proc/fist() + var/mob/living/carbon/bearer = user + return bearer.get_active_hand() || bearer.get_bodypart(BODY_ZONE_R_ARM) + +/// The beat a stage begins with: poses and sounds. Lines come from enter_line(). +/datum/hv_finisher/chrome/gorilla/proc/pose(key) + return + +/// A stage's beat: damage, gore, poses. +/datum/hv_finisher/chrome/gorilla/proc/beat(key) + return + +/// The line the room sees as a stage begins, or null. +/datum/hv_finisher/chrome/gorilla/proc/enter_line(key) + return null + +/// The line the victim sees as a stage begins, or null for the room's. +/datum/hv_finisher/chrome/gorilla/proc/enter_self_line(key) + return null + +/// The flash, sparks, ring and shake of a killing blow on [where]. +/datum/hv_finisher/chrome/gorilla/proc/hit_stop(atom/movable/where, heavy = TRUE) + bearer_rig?.flash() + victim_rig?.flash() + gorilla_play_impact(user, where, "impact_gorilla") + var/turf/spot = get_turf(where) + if(spot) + new /obj/effect/temp_visual/circle_wave/cyberware_slam(spot, heavy ? 1 : 0.6) + for(var/mob/living/viewer in range(4, spot)) + if(viewer.client) + shake_camera(viewer, heavy ? 3 : 2, heavy ? 2 : 1) + +// ---- The stages: each one hands its key to the finisher ---- + +/datum/hv_finisher_stage/hold/gorilla + lunge = 0 + effect_severity = 0 + /// What the finisher does in this stage + var/key + +/datum/hv_finisher_stage/hold/gorilla/enter() + . = ..() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.pose(key) + +/datum/hv_finisher_stage/hold/gorilla/effect() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.beat(key) + +/datum/hv_finisher_stage/hold/gorilla/enter_message() + var/datum/hv_finisher/chrome/gorilla/finisher = host + return finisher.enter_line(key) + +/datum/hv_finisher_stage/hold/gorilla/enter_self_message() + var/datum/hv_finisher/chrome/gorilla/finisher = host + return finisher.enter_self_line(key) + +/datum/hv_finisher_stage/kill/gorilla + lunge = 0 + effect_delay = 0 + duration = 1 SECONDS + var/key + +/datum/hv_finisher_stage/kill/gorilla/enter() + . = ..() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.pose(key) + +/datum/hv_finisher_stage/kill/gorilla/effect() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.beat(key) + +/datum/hv_finisher_stage/kill/gorilla/enter_message() + var/datum/hv_finisher/chrome/gorilla/finisher = host + return finisher.enter_line(key) + +/datum/hv_finisher_stage/recover/gorilla + duration = 0.8 SECONDS + var/key + +/datum/hv_finisher_stage/recover/gorilla/enter() + . = ..() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.pose(key) + +/datum/hv_finisher_stage/recover/gorilla/effect() + var/datum/hv_finisher/chrome/gorilla/finisher = host + finisher.beat(key) + +/datum/hv_finisher_stage/recover/gorilla/enter_message() + var/datum/hv_finisher/chrome/gorilla/finisher = host + return finisher.enter_line(key) + +// ========================================================================= +// (A) THE HEAD CLAP +// ========================================================================= + +/datum/hv_finisher/chrome/gorilla/clap + name = "gorilla head clap" + zone_preference = list(BODY_ZONE_HEAD) + stages = list( + /datum/hv_finisher_stage/hold/gorilla/clap_hoist, + /datum/hv_finisher_stage/hold/gorilla/clap_squeeze, + /datum/hv_finisher_stage/hold/gorilla/clap_toss, + /datum/hv_finisher_stage/kill/gorilla/clap, + /datum/hv_finisher_stage/recover/gorilla/clap, + ) + +/datum/hv_finisher_stage/hold/gorilla/clap_hoist + key = "hoist" + duration = 0.65 SECONDS + enter_sound = 'sound/machines/hiss.ogg' + enter_volume = 40 + +/datum/hv_finisher_stage/hold/gorilla/clap_squeeze + key = "squeeze" + duration = 1 SECONDS + +/datum/hv_finisher_stage/hold/gorilla/clap_toss + key = "toss" + duration = 0.3 SECONDS + +/datum/hv_finisher_stage/kill/gorilla/clap + key = "clap" + // The fists meet, a beat of hit-stop, then the head goes + effect_delay = 0.15 SECONDS + +/datum/hv_finisher_stage/recover/gorilla/clap + key = "recover" + +// With nothing to hold the brain down it would drift off for good. +/datum/hv_finisher/chrome/gorilla/clap/can_start_on(mob/living/carbon/target) + var/turf/spot = get_turf(target) + return !isnull(target.get_bodypart(BODY_ZONE_HEAD)) && spot?.has_gravity() + +/datum/hv_finisher/chrome/gorilla/clap/pick_zone() + target_zone = BODY_ZONE_HEAD + return target_zone + +/datum/hv_finisher/chrome/gorilla/clap/enter_line(key) + switch(key) + if("hoist") + return "[user] hauls [victim] off the floor by the collar!" + if("squeeze") + return "[victim] chokes and kicks in [user]'s grip." + +/datum/hv_finisher/chrome/gorilla/clap/enter_self_line(key) + switch(key) + if("hoist") + return "[user] hauls you off the floor by the collar!" + if("squeeze") + return "You choke and kick in [user]'s grip." + +/datum/hv_finisher/chrome/gorilla/clap/pose(key) + switch(key) + if("hoist") + rig_bearer() + victim_rig = new(victim, REVERSE_DIR(facing || user.dir)) + if(victim_rig.snapshot()) + var/obj/effect/abstract/chrome_puppet/held = victim_rig.add_puppet("whole", victim_rig.flat, TRUE) + // The held body is nearer the viewer than the bearer only when the bearer faces south. + held.layer = facing == SOUTH ? user.layer + 0.02 : user.layer - 0.02 + victim_rig.conceal() + var/list/toward = to_bearer() + animate(held, transform = matrix(), pixel_x = round(toward[1] * 0.6), pixel_y = round(toward[2] * 0.6), pixel_w = 0, pixel_z = 9, time = 0.4 SECONDS, easing = SINE_EASING | EASE_OUT) + else + QDEL_NULL(victim_rig) + arms_to(side_view() ? 80 : 140, 0.4 SECONDS, 3) + if("squeeze") + arms_strain(1 SECONDS) + bearer_rig?.tremble(1 SECONDS) + var/obj/effect/abstract/chrome_puppet/held = victim_rig?.puppet("whole") + if(held) + // Swinging on the collar: turns about the top of the body + for(var/i in 1 to 4) + var/angle = (i % 2) ? 6 : -6 + if(i == 1) + animate(held, transform = chrome_rig_turn(angle, 0, 10), time = 0.12 SECONDS, easing = SINE_EASING) + else + animate(transform = chrome_rig_turn(angle, 0, 10), time = 0.24 SECONDS, easing = SINE_EASING) + animate(transform = matrix(), time = 0.12 SECONDS) + playsound(victim, 'sound/mobs/humanoids/human/gasp/gasp_male1.ogg', 30, TRUE) + if("toss") + var/obj/effect/abstract/chrome_puppet/held = victim_rig?.puppet("whole") + if(held) + animate(held, pixel_z = 13, time = 0.15 SECONDS, easing = SINE_EASING | EASE_OUT) + animate(pixel_z = 9, time = 0.15 SECONDS, easing = SINE_EASING | EASE_IN) + arms_to(side_view() ? 20 : 80, 0.15 SECONDS, 4) + if("clap") + arms_to(side_view() ? 95 : 160, 0.08 SECONDS, 5) + hit_stop(victim_rig?.puppet("whole") || victim) + if("recover") + arms_to(0, 0.5 SECONDS) + if(bearer_rig) + for(var/side in list("l", "r")) + bearer_rig.heat_arm(side, 0.6 SECONDS, 0) + bearer_rig.steam_arm(side, 0.4 SECONDS) + +/datum/hv_finisher/chrome/gorilla/clap/beat(key) + if(key != "clap" || QDELETED(victim)) + return + playsound(victim, 'sound/effects/wounds/crack2.ogg', 80, TRUE) + gorilla_head_clap(victim, user, fist(), get_dir(user, victim) || facing) + // The body drops back to its own tile. + QDEL_NULL(victim_rig) + if(!QDELETED(victim) && victim.stat == DEAD) + victim.hv_death_throes() + +/** + * Both fists meet on [victim]'s head. Bursts it (#509), or on a helmet or plating crumples the helmet and + * caves the skull in, or on the Atlas frame does heavy damage and nothing breaks. Returns what happened: + * "burst", "helmet", "frame" or "crit". + */ +/proc/gorilla_head_clap(mob/living/carbon/victim, mob/living/user, obj/item/fist, direction) + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + if(isnull(head)) + return null + victim.hv_hold_attack_message("[user] claps both fists together on [victim]'s head!", "[user] claps both fists together on your head!", "You clap both fists together on [victim]'s head!", user, vision_range = DEFAULT_MESSAGE_RANGE) + . = "burst" + if(HAS_TRAIT(victim, TRAIT_NODISMEMBER) && HAS_TRAIT(victim, TRAIT_CYBERWARE_UNBREAKABLE_BONES)) + // The Atlas frame: nothing bursts and nothing breaks + victim.apply_damage(35, BRUTE, head, victim.run_armor_check(head, MELEE), wound_bonus = CANT_WOUND, attack_direction = direction) + victim.adjustOrganLoss(ORGAN_SLOT_BRAIN, 20) + victim.hv_add_hit_line("[victim]'s frame holds!", HV_LINE_BOLD, "Your frame holds!") + . = "frame" + else if(HAS_TRAIT(victim, TRAIT_HEAD_INJURY_BLOCKED) || hv_crit_cover_rating(victim, head, MELEE) >= GORILLA_CLAP_HELMET_ARMOUR) + // A helmet or plating: no burst. It crumples and the skull caves in under it. + victim.apply_damage(35, BRUTE, head, wound_bonus = CANT_WOUND, attack_direction = direction) + var/obj/item/clothing/helmet + if(ishuman(victim)) + var/mob/living/carbon/human/human_victim = victim + helmet = human_victim.head + if(istype(helmet)) + helmet.take_damage(40, BRUTE, MELEE, FALSE) + if(!QDELETED(helmet)) + gorilla_helmet_dent(victim) + victim.hv_add_hit_line("[victim]'s helmet crumples!", HV_LINE_BOLD, "Your helmet crumples!", DEFAULT_MESSAGE_RANGE) + if(hv_crit_apply_wound(head, /datum/wound/blunt/bone/critical/hv_skull, direction, fist)) + victim.hv_add_hit_line("[victim]'s skull caves in!", HV_LINE_HUGE, "Your skull caves in!", DEFAULT_MESSAGE_RANGE) + . = "helmet" + else + victim.apply_damage(30, BRUTE, head, victim.run_armor_check(head, MELEE), wound_bonus = CANT_WOUND, attack_direction = direction) + if(QDELETED(victim) || isnull(victim.get_bodypart(BODY_ZONE_HEAD))) + . = "burst" + else if(!victim.hv_burst_head(direction)) + // A head that can't come off: the skull caves in instead + if(hv_crit_apply_wound(head, /datum/wound/blunt/bone/critical/hv_skull, direction, fist)) + victim.hv_add_hit_line("[victim]'s skull caves in!", HV_LINE_HUGE, "Your skull caves in!", DEFAULT_MESSAGE_RANGE) + . = "crit" + if(!QDELETED(victim)) + victim.hv_flush_hit() + playsound(victim, 'sound/effects/wounds/splatter.ogg', 60, TRUE) + log_combat(user, victim, "head-clapped", "gorilla arms", "([.])") + +/// A crumpled dent drawn over the helmet for a while. +/proc/gorilla_helmet_dent(mob/living/carbon/victim) + var/mutable_appearance/dent = mutable_appearance(GORILLA_GORE_ICON, "helmet_dent", -(HEAD_LAYER - 0.1)) + victim.add_overlay(dent) + addtimer(CALLBACK(victim, TYPE_PROC_REF(/atom, cut_overlay), dent), 10 SECONDS) + +// ========================================================================= +// (B) THE LIMB RIP +// ========================================================================= + +/datum/hv_finisher/chrome/gorilla/rip + name = "gorilla limb rip" + stages = list( + /datum/hv_finisher_stage/hold/gorilla/rip_plant, + /datum/hv_finisher_stage/hold/gorilla/rip_strain, + /datum/hv_finisher_stage/kill/gorilla/rip, + /datum/hv_finisher_stage/recover/gorilla/rip_club, + /datum/hv_finisher_stage/recover/gorilla/rip, + ) + /// The torn-off arm, held until it is dropped on the victim's tile + var/obj/item/bodypart/held_limb + /// "l" or "r": the side of the victim's body the arm is on + var/side + +/datum/hv_finisher_stage/hold/gorilla/rip_plant + key = "plant" + duration = 0.5 SECONDS + enter_sound = 'sound/effects/gravhit.ogg' + enter_volume = 40 + +/datum/hv_finisher_stage/hold/gorilla/rip_strain + key = "strain" + duration = 1.35 SECONDS + +/datum/hv_finisher_stage/kill/gorilla/rip + key = "tear" + duration = 0.8 SECONDS + +/datum/hv_finisher_stage/recover/gorilla/rip_club + key = "club" + duration = 0.45 SECONDS + effect_delay = 0.3 SECONDS + +/datum/hv_finisher_stage/recover/gorilla/rip + key = "recover" + +/datum/hv_finisher/chrome/gorilla/rip/Destroy() + drop_held_limb() + return ..() + +/datum/hv_finisher/chrome/gorilla/rip/stop(reason) + if(!running) + return + . = ..() + drop_held_limb() + +/// The aimed arm, else the other one. +/datum/hv_finisher/chrome/gorilla/rip/pick_zone() + var/other = aimed_zone == BODY_ZONE_L_ARM ? BODY_ZONE_R_ARM : BODY_ZONE_L_ARM + target_zone = victim.get_bodypart(aimed_zone) ? aimed_zone : (victim.get_bodypart(other) ? other : null) + side = target_zone == BODY_ZONE_L_ARM ? "l" : "r" + return target_zone + +/datum/hv_finisher/chrome/gorilla/rip/can_start_on(mob/living/carbon/target) + return !isnull(target.get_bodypart(BODY_ZONE_L_ARM)) || !isnull(target.get_bodypart(BODY_ZONE_R_ARM)) + +/datum/hv_finisher/chrome/gorilla/rip/enter_line(key) + switch(key) + if("plant") + return "[user] plants a boot on [victim] and grabs [victim.p_their()] arm!" + if("strain") + return "[user] hauls back on [victim]'s arm!" + +/datum/hv_finisher/chrome/gorilla/rip/enter_self_line(key) + switch(key) + if("plant") + return "[user] plants a boot on you and grabs your arm!" + if("strain") + return "[user] hauls back on your arm!" + +/datum/hv_finisher/chrome/gorilla/rip/pose(key) + var/list/toward = to_bearer() + switch(key) + if("plant") + rig_bearer() + victim_rig = new(victim, victim.dir) + if(victim_rig.snapshot()) + victim_rig.add_puppet("body", victim_rig.cut_without_arms(side), TRUE) + victim_rig.add_puppet("arm", victim_rig.cut("rig_arm_[side]"), TRUE) + victim_rig.conceal() + else + QDEL_NULL(victim_rig) + // A boot on the body: the bearer leans over them + for(var/key_name in bearer_rig?.puppets) + var/obj/effect/abstract/chrome_puppet/part = bearer_rig.puppets[key_name] + animate(part, pixel_x = part.pixel_x - round(toward[1] * 0.18), pixel_y = part.pixel_y - round(toward[2] * 0.18), time = 0.3 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + arms_to(side_view() ? 45 : 30, 0.3 SECONDS, 3) + if("strain") + arms_strain(1.35 SECONDS) + bearer_rig?.tremble(1.3 SECONDS) + var/obj/effect/abstract/chrome_puppet/arm = victim_rig?.puppet("arm") + if(arm) + // The arm comes out of the shoulder toward the bearer, five pixels over the strain + animate(arm, pixel_x = arm.pixel_x + round(toward[1] / 32 * 5), pixel_y = arm.pixel_y + round(toward[2] / 32 * 5), time = 1.3 SECONDS, easing = SINE_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + var/obj/effect/abstract/chrome_puppet/body = victim_rig?.puppet("body") + if(body) + body.Shake(1, 1, 1.3 SECONDS) + arms_to(side_view() ? 25 : 15, 1.3 SECONDS, 1) + if("tear") + arms_to(side_view() ? -20 : 0, 0.1 SECONDS) + if("club") + var/obj/effect/abstract/chrome_puppet/club = bearer_rig?.puppet("club") + if(club) + // Up behind the head, then down on theirs + animate(club, pixel_z = 14, transform = matrix().Turn(160), time = 0.2 SECONDS, easing = SINE_EASING | EASE_OUT) + animate(pixel_x = club.pixel_x - round(toward[1] * 0.6), pixel_y = club.pixel_y - round(toward[2] * 0.6), pixel_z = 2, transform = matrix().Turn(60), time = 0.1 SECONDS, easing = SINE_EASING | EASE_IN) + arms_to(side_view() ? 150 : 165, 0.2 SECONDS, 2) + if("recover") + bearer_rig?.drop_puppet("club") + arms_to(0, 0.5 SECONDS) + +/datum/hv_finisher/chrome/gorilla/rip/beat(key) + if(QDELETED(victim)) + return + switch(key) + if("tear") + hit_stop(victim_rig?.puppet("arm") || victim) + var/outcome = gorilla_limb_rip(victim, target_zone, user, fist()) + if(outcome == "torn") + held_limb = gorilla_last_torn_limb(victim, target_zone) + // The arm goes with the bearer's fist; the body drops back to the floor + var/obj/effect/abstract/chrome_puppet/arm = victim_rig?.puppet("arm") + if(arm && bearer_rig) + victim_rig.puppets -= "arm" + bearer_rig.puppets["club"] = arm + arm.forceMove(get_turf(user)) + arm.pixel_x = 0 + arm.pixel_y = 0 + arm.pixel_z = 6 + arm.transform = matrix().Turn(100) + arm.layer = user.layer + 0.03 + for(var/key_name in bearer_rig?.puppets) + var/obj/effect/abstract/chrome_puppet/part = bearer_rig.puppets[key_name] + animate(part, pixel_w = -2, time = 0.1 SECONDS, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(pixel_w = 2, time = 0.3 SECONDS, flags = ANIMATION_RELATIVE) + QDEL_NULL(victim_rig) + if("club") + if(!held_limb) + return + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + victim.hv_hold_attack_message("[user] beats [victim] with [victim.p_their()] own arm!", "[user] beats you with your own arm!", "You beat [victim] with [victim.p_their()] own arm!", user, vision_range = DEFAULT_MESSAGE_RANGE) + victim.apply_damage(15, BRUTE, head || BODY_ZONE_CHEST, victim.run_armor_check(head, MELEE), wound_bonus = CANT_WOUND, attack_direction = get_dir(user, victim), attacking_item = fist()) + victim.hv_flush_hit() + playsound(victim, 'sound/effects/wounds/splatter.ogg', 60, TRUE) + gorilla_play_impact(user, victim, "impact_gorilla") + if(held_limb.can_bleed()) + victim.add_splatter_floor(get_turf(victim), small_drip = TRUE) + +/// Puts the torn arm down on the victim's tile, wherever the finisher stopped. +/datum/hv_finisher/chrome/gorilla/rip/proc/drop_held_limb() + if(isnull(held_limb)) + return + var/obj/item/bodypart/limb = held_limb + held_limb = null + if(QDELETED(limb)) + return + var/turf/spot = get_turf(victim) || get_turf(user) + if(spot) + limb.forceMove(spot) + if(limb.can_bleed()) + new /obj/effect/decal/cleanable/blood/splatter(spot, null, limb.blood_dna_info) + +/// The arm just torn from [victim] at [zone], waiting in nullspace, or null. +/proc/gorilla_last_torn_limb(mob/living/carbon/victim, zone) + var/obj/item/bodypart/limb = victim.gorilla_torn_limb?.resolve() + victim.gorilla_torn_limb = null + if(QDELETED(limb) || limb.body_zone != zone) + return null + return limb + +/mob/living/carbon + /// The last arm a Gorilla tore off this body, until the finisher takes it. + var/datum/weakref/gorilla_torn_limb + +/** + * Tears [victim]'s arm at [zone] off, or wrenches the shoulder out of its socket when it's armoured, or + * does nothing but hurt on the Atlas frame or a limb that can't come off. Returns "torn", + * "dislocated" or "held". + */ +/proc/gorilla_limb_rip(mob/living/carbon/victim, zone, mob/living/user, obj/item/fist) + var/obj/item/bodypart/limb = victim.get_bodypart(zone) + if(isnull(limb)) + return null + var/toward_bearer = get_dir(victim, user) + if(!limb.can_dismember() || HAS_TRAIT(victim, TRAIT_CYBERWARE_UNBREAKABLE_BONES)) + victim.hv_hold_attack_message("[user] wrenches at [victim]'s [limb.plaintext_zone]!", "[user] wrenches at your [limb.plaintext_zone]!", null, user) + victim.apply_damage(25, BRUTE, limb, victim.run_armor_check(limb, MELEE), wound_bonus = CANT_WOUND, attack_direction = toward_bearer) + victim.hv_add_hit_line("[victim]'s arm holds!", HV_LINE_BOLD, "Your arm holds!") + victim.hv_flush_hit() + log_combat(user, victim, "failed to tear off the arm of", "gorilla arms") + return "held" + if(hv_crit_cover_rating(victim, limb, MELEE) >= GORILLA_RIP_ARMOUR) + victim.hv_hold_attack_message("[user] wrenches at [victim]'s [limb.plaintext_zone]!", "[user] wrenches at your [limb.plaintext_zone]!", null, user) + victim.apply_damage(25, BRUTE, limb, victim.run_armor_check(limb, MELEE), wound_bonus = CANT_WOUND, attack_direction = toward_bearer) + if(hv_crit_apply_wound(limb, /datum/wound/blunt/bone/moderate, toward_bearer, fist)) + victim.hv_add_hit_line("[victim]'s shoulder wrenches out of its socket!", HV_LINE_BOLD, "Your shoulder wrenches out of its socket!") + victim.hv_flush_hit() + do_sparks(2, TRUE, victim) + log_combat(user, victim, "dislocated the arm of", "gorilla arms") + return "dislocated" + // The spray runs back at the bearer: the last hit on the body came from them. + victim.hv_hold_attack_message("[user] tears [victim]'s [limb.plaintext_zone] off at the shoulder!", "[user] tears your [limb.plaintext_zone] off at the shoulder!", "You tear [victim]'s [limb.plaintext_zone] off at the shoulder!", user, vision_range = DEFAULT_MESSAGE_RANGE) + victim.apply_damage(10, BRUTE, limb, wound_bonus = CANT_WOUND, attack_direction = toward_bearer) + var/obj/item/bodypart/torn = gorilla_tear_limb(victim, limb, toward_bearer) + victim.hv_flush_hit() + if(isnull(torn)) + return "held" + if(!torn.can_bleed()) + do_sparks(3, TRUE, victim) + playsound(victim, 'sound/effects/wounds/crackandbleed.ogg', 80, TRUE) + victim.gorilla_torn_limb = WEAKREF(torn) + log_combat(user, victim, "tore off the [torn.plaintext_zone] of", "gorilla arms") + return "torn" + +/** + * Takes [limb] off as tg's dismember() does (chest damage, scream, sound, mood, memory, bleeding, the + * floor splatter, the wet stump and the spray along the last blow), but keeps it out of the world instead + * of flinging it: the finisher holds it, then drops it on the victim's tile. Returns the limb or null. + */ +/proc/gorilla_tear_limb(mob/living/carbon/victim, obj/item/bodypart/limb, direction) + if(isnull(limb) || limb.owner != victim || !limb.can_dismember() || HAS_TRAIT(victim, TRAIT_GODMODE)) + return null + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + chest?.receive_damage(clamp(limb.brute_dam / 2 * chest.body_damage_coeff, 15, 50), wound_bonus = CANT_WOUND) + INVOKE_ASYNC(victim, TYPE_PROC_REF(/mob, emote), "scream") + playsound(get_turf(victim), 'sound/effects/dismember.ogg', 80, TRUE) + victim.add_mood_event("dismembered_[limb.body_zone]", /datum/mood_event/dismembered, limb) + victim.add_mob_memory(/datum/memory/was_dismembered, lost_limb = limb) + LAZYSET(victim.body_zone_dismembered_by, limb.body_zone, WOUND_BLUNT) + var/bleeds = limb.can_bleed() + if(bleeds) + victim.bleed(rand(20, 40)) + var/turf/spot = get_turf(victim) + limb.drop_limb(FALSE, TRUE) + victim.update_equipment_speed_mods() + if(bleeds && isturf(spot)) + victim.add_splatter_floor(spot) + if(QDELETED(limb)) + return null + limb.moveToNullspace() + return limb + +// ========================================================================= +// (C) THE BACKBREAKER +// ========================================================================= + +/datum/hv_finisher/chrome/gorilla/backbreaker + name = "gorilla backbreaker" + zone_preference = list(BODY_ZONE_CHEST) + stages = list( + /datum/hv_finisher_stage/hold/gorilla/back_hoist, + /datum/hv_finisher_stage/hold/gorilla/back_press, + /datum/hv_finisher_stage/kill/gorilla/backbreaker, + /datum/hv_finisher_stage/recover/gorilla/backbreaker, + ) + /// TRUE once the back broke: the body is rolled off still bent + var/broke = FALSE + +/datum/hv_finisher_stage/hold/gorilla/back_hoist + key = "hoist" + duration = 0.85 SECONDS + enter_sound = 'sound/machines/hiss.ogg' + enter_volume = 40 + +/datum/hv_finisher_stage/hold/gorilla/back_press + key = "press" + duration = 1 SECONDS + +/datum/hv_finisher_stage/kill/gorilla/backbreaker + key = "slam" + effect_delay = 0.18 SECONDS + duration = 0.6 SECONDS + +/datum/hv_finisher_stage/recover/gorilla/backbreaker + key = "roll" + duration = 0.75 SECONDS + +/datum/hv_finisher/chrome/gorilla/backbreaker/pick_zone() + target_zone = BODY_ZONE_CHEST + return target_zone + +/datum/hv_finisher/chrome/gorilla/backbreaker/can_start_on(mob/living/carbon/target) + return !isnull(target.get_bodypart(BODY_ZONE_CHEST)) + +/datum/hv_finisher/chrome/gorilla/backbreaker/enter_line(key) + switch(key) + if("hoist") + return "[user] hoists [victim] over [user.p_their()] head!" + if("press") + return "[victim] thrashes in [user]'s grip." + if("roll") + return "[user] rolls [victim] off [user.p_their()] knee." + +/datum/hv_finisher/chrome/gorilla/backbreaker/enter_self_line(key) + switch(key) + if("hoist") + return "[user] hoists you over [user.p_their()] head!" + if("press") + return "You thrash in [user]'s grip." + +/// Where the belt sits, from the icon's centre: halves turn about it. +#define BACK_WAIST_PIVOT_Y -7 + +/datum/hv_finisher/chrome/gorilla/backbreaker/pose(key) + var/list/toward = to_bearer() + switch(key) + if("hoist") + rig_bearer() + victim_rig = new(victim, SOUTH) + if(victim_rig.snapshot()) + for(var/half in list("upper", "lower")) + var/obj/effect/abstract/chrome_puppet/part = victim_rig.add_puppet(half, victim_rig.cut("rig_[half]"), TRUE, user.layer + 0.05) + // Overhead on his back, flat: over the bearer's head + animate(part, pixel_x = toward[1], pixel_y = toward[2], pixel_w = 0, pixel_z = 17, transform = chrome_rig_turn(90, 0, BACK_WAIST_PIVOT_Y), time = 0.55 SECONDS, easing = SINE_EASING | EASE_OUT) + victim_rig.conceal() + else + QDEL_NULL(victim_rig) + arms_to(175, 0.5 SECONDS, 4) + if("press") + arms_strain(1 SECONDS) + bearer_rig?.tremble(1 SECONDS) + var/obj/effect/abstract/chrome_puppet/upper = victim_rig?.puppet("upper") + var/obj/effect/abstract/chrome_puppet/lower = victim_rig?.puppet("lower") + for(var/i in 1 to 4) + var/kick = (i % 2) ? 1 : -1 + if(upper) + if(i == 1) + animate(upper, transform = chrome_rig_turn(90 + 6 * kick, 0, BACK_WAIST_PIVOT_Y), time = 0.12 SECONDS) + else + animate(transform = chrome_rig_turn(90 + 6 * kick, 0, BACK_WAIST_PIVOT_Y), time = 0.24 SECONDS) + if(lower) + if(i == 1) + animate(lower, transform = chrome_rig_turn(90 - 12 * kick, 0, BACK_WAIST_PIVOT_Y), time = 0.12 SECONDS) + else + animate(transform = chrome_rig_turn(90 - 12 * kick, 0, BACK_WAIST_PIVOT_Y), time = 0.24 SECONDS) + if(upper) + animate(transform = chrome_rig_turn(90, 0, BACK_WAIST_PIVOT_Y), time = 0.1 SECONDS) + if(lower) + animate(transform = chrome_rig_turn(90, 0, BACK_WAIST_PIVOT_Y), time = 0.1 SECONDS) + if("slam") + // Down to one knee, and the body comes down across it + var/obj/effect/abstract/chrome_puppet/body = bearer_rig?.puppet("body") + if(body) + animate(body, transform = matrix(1, 0, 0, 0, 0.8, -3), time = 0.15 SECONDS, easing = SINE_EASING | EASE_IN) + for(var/side in list("l", "r")) + var/obj/effect/abstract/chrome_puppet/arm = bearer_rig?.puppet("arm_[side]") + if(arm) + animate(arm, pixel_z = -6, time = 0.15 SECONDS, flags = ANIMATION_PARALLEL) + arms_to(120, 0.15 SECONDS, 2) + for(var/half in list("upper", "lower")) + var/obj/effect/abstract/chrome_puppet/part = victim_rig?.puppet(half) + if(part) + animate(part, pixel_z = 2, time = 0.16 SECONDS, easing = SINE_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + if("roll") + var/obj/effect/abstract/chrome_puppet/body = bearer_rig?.puppet("body") + if(body) + animate(body, transform = matrix(), time = 0.4 SECONDS, easing = SINE_EASING | EASE_OUT) + for(var/side in list("l", "r")) + var/obj/effect/abstract/chrome_puppet/arm = bearer_rig?.puppet("arm_[side]") + if(arm) + animate(arm, pixel_z = 0, time = 0.4 SECONDS, flags = ANIMATION_PARALLEL) + arms_to(0, 0.5 SECONDS) + // Off the knee onto their own tile, still bent if the back broke. The halves turn about the belt, + // seven pixels under the middle, so they sit a pixel up to lie where a lying body lies. + var/bend = broke ? 26 : 0 + var/obj/effect/abstract/chrome_puppet/upper = victim_rig?.puppet("upper") + var/obj/effect/abstract/chrome_puppet/lower = victim_rig?.puppet("lower") + if(upper) + animate(upper, pixel_x = 0, pixel_y = 1, pixel_z = 0, transform = chrome_rig_turn(90 + bend, 0, BACK_WAIST_PIVOT_Y), time = 0.45 SECONDS, easing = SINE_EASING | EASE_IN | EASE_OUT) + if(lower) + animate(lower, pixel_x = 0, pixel_y = 1, pixel_z = 0, transform = chrome_rig_turn(90 - bend, 0, BACK_WAIST_PIVOT_Y), time = 0.45 SECONDS, easing = SINE_EASING | EASE_IN | EASE_OUT) + for(var/half in list("upper", "lower")) + var/obj/effect/abstract/chrome_puppet/part = victim_rig?.puppet(half) + if(part) + part.layer = LYING_MOB_LAYER + +/datum/hv_finisher/chrome/gorilla/backbreaker/beat(key) + if(key != "slam" || QDELETED(victim)) + return + hit_stop(victim_rig?.puppet("upper") || victim, TRUE) + playsound(victim, 'sound/effects/wounds/crack2.ogg', 90, TRUE) + playsound(victim, 'sound/effects/meteorimpact.ogg', 50, TRUE) + var/turf/knee = get_turf(user) + if(knee) + new /obj/effect/temp_visual/cyberware_impact_cracks(knee, "damaged1", 255) + cyberware_grey_dust(knee) + var/outcome = gorilla_backbreak(victim, user, fist()) + broke = outcome == "broken" + if(broke) + // The body folds over the knee + var/obj/effect/abstract/chrome_puppet/upper = victim_rig?.puppet("upper") + var/obj/effect/abstract/chrome_puppet/lower = victim_rig?.puppet("lower") + if(upper) + animate(upper, transform = chrome_rig_turn(90 + 38, 0, BACK_WAIST_PIVOT_Y), time = 0.06 SECONDS) + if(lower) + animate(lower, transform = chrome_rig_turn(90 - 38, 0, BACK_WAIST_PIVOT_Y), time = 0.06 SECONDS) + else + // Nothing gives: the body bounces off the knee + for(var/half in list("upper", "lower")) + var/obj/effect/abstract/chrome_puppet/part = victim_rig?.puppet(half) + if(part) + animate(part, pixel_z = 6, time = 0.12 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + animate(pixel_z = 2, time = 0.12 SECONDS, easing = SINE_EASING | EASE_IN) + +/datum/hv_finisher/chrome/gorilla/backbreaker/cleanup_rigs() + // A broken back stays drawn bent for a while after + if(broke && victim_rig && !QDELETED(victim) && victim.get_bodypart(BODY_ZONE_CHEST)) + victim.AddComponent(/datum/component/gorilla_fold, victim_rig) + victim_rig = null + return ..() + +#undef BACK_WAIST_PIVOT_Y + +/** + * Brings [victim] down across a knee: heavy brute to the chest, then a broken back, or cracked ribs under + * chest armour, or nothing on the Atlas frame. Returns "broken", "ribs", "frame" or "bruised". + */ +/proc/gorilla_backbreak(mob/living/carbon/victim, mob/living/user, obj/item/fist) + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + if(isnull(chest)) + return null + var/direction = get_dir(user, victim) + victim.hv_hold_attack_message("[user] drops to one knee and brings [victim] down across it!", "[user] drops to one knee and brings you down across it!", "You drop to one knee and bring [victim] down across it!", user, vision_range = DEFAULT_MESSAGE_RANGE) + victim.apply_damage(45, BRUTE, chest, victim.run_armor_check(chest, MELEE), wound_bonus = CANT_WOUND, attack_direction = direction) + . = "bruised" + if(HAS_TRAIT(victim, TRAIT_CYBERWARE_UNBREAKABLE_BONES)) + victim.hv_add_hit_line("[victim]'s frame holds!", HV_LINE_BOLD, "Your frame holds!") + . = "frame" + else if(hv_crit_cover_rating(victim, chest, MELEE) >= GORILLA_BACK_ARMOUR) + if(hv_crit_apply_wound(chest, /datum/wound/blunt/bone/severe, direction, fist)) + victim.hv_add_hit_line("[victim]'s ribs crack!", HV_LINE_BOLD, "Your ribs crack!") + . = "ribs" + else if(hv_crit_apply_wound(chest, /datum/wound/blunt/bone/critical/gorilla_spine, direction, fist)) + victim.hv_add_hit_line("[victim]'s back breaks!", HV_LINE_HUGE, "Your back breaks!", DEFAULT_MESSAGE_RANGE) + . = "broken" + if(!QDELETED(victim)) + victim.hv_flush_hit() + log_combat(user, victim, "backbroke", "gorilla arms", "([.])") + +/** + * # Broken back + * + * tg's compound fracture on the chest, with the spine gone: while it lasts the body can crawl but not + * stand. Treated like any compound fracture (surgery, or bone gel and tape). + */ +/datum/wound/blunt/bone/critical/gorilla_spine + name = "Broken Back" + undiagnosed_name = "Broken Back" + desc = "Patient's spine has been snapped across, leaving the legs useless." + treat_text = "Repair surgically. Bone gel and surgical tape will hold it in an emergency." + treat_text_short = "Repair surgically, or apply bone gel and surgical tape." + simple_desc = "Patient's back is broken. They cannot stand." + simple_treat_text = "Surgery, or bone gel and surgical tape." + homemade_treat_text = "Don't move them." + examine_desc = "is bent at an angle no back should bend" + occur_text = "folds the wrong way with a crack" + +/datum/wound/blunt/bone/critical/gorilla_spine/wound_injury(datum/wound/old_wound = null, attack_direction = null) + . = ..() + if(victim) + ADD_TRAIT(victim, TRAIT_FLOORED, REF(src)) + +/datum/wound/blunt/bone/critical/gorilla_spine/remove_wound(ignore_limb, replaced = FALSE) + if(victim) + REMOVE_TRAIT(victim, TRAIT_FLOORED, REF(src)) + return ..() + +/datum/wound/blunt/bone/critical/gorilla_spine/set_victim(new_victim) + if(victim) + REMOVE_TRAIT(victim, TRAIT_FLOORED, REF(src)) + return ..() + +/datum/wound_pregen_data/bone/gorilla_spine + abstract = FALSE + wound_path_to_generate = /datum/wound/blunt/bone/critical/gorilla_spine + viable_zones = list(BODY_ZONE_CHEST) + can_be_randomly_generated = FALSE + threshold_minimum = 115 + +/** + * The fold a broken back leaves: the victim's puppets stay bent for a while after the backbreaker, until + * the body is moved, buckled or the back is set. + */ +/datum/component/gorilla_fold + /// The victim's rig, handed over by the finisher + var/datum/chrome_rig/rig + var/fold_timer + +/datum/component/gorilla_fold/Initialize(datum/chrome_rig/rig) + if(!iscarbon(parent) || !istype(rig)) + return COMPONENT_INCOMPATIBLE + src.rig = rig + fold_timer = QDEL_IN_STOPPABLE(src, GORILLA_FOLD_TIME) + +/datum/component/gorilla_fold/RegisterWithParent() + RegisterSignals(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BUCKLED, COMSIG_CARBON_LOSE_WOUND, COMSIG_LIVING_REVIVE, COMSIG_LIVING_SET_BODY_POSITION), PROC_REF(unfold)) + +/datum/component/gorilla_fold/UnregisterFromParent() + UnregisterSignal(parent, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BUCKLED, COMSIG_CARBON_LOSE_WOUND, COMSIG_LIVING_REVIVE, COMSIG_LIVING_SET_BODY_POSITION)) + +/datum/component/gorilla_fold/Destroy() + if(fold_timer) + deltimer(fold_timer) + fold_timer = null + QDEL_NULL(rig) + return ..() + +/datum/component/gorilla_fold/proc/unfold(datum/source) + SIGNAL_HANDLER + qdel(src) diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_haymaker.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_haymaker.dm new file mode 100644 index 00000000000..6752a622c83 --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_haymaker.dm @@ -0,0 +1,388 @@ +/** + * # The charged haymaker + * + * A chrome wheel chip on each real Gorilla arm. Hold Use chrome to wind up and let go to throw it at + * whoever is in the tile you face; or tap Use chrome (or pick the chip) and click someone next to you. + * The wind-up slows the bearer, lets go of any grab, leans back and heats the arm, so the target has a + * beat to step out of line. The hit sends the body flying along the safe path, further for a full + * charge; into a wall it hits harder and cracks the chest. A swing at nobody staggers the bearer. + * Held too long, or called off, the pistons vent and the charge is lost. + * + * The haymaker and every Gorilla finisher share one cooldown, kept on the bearer, so they never chain. + */ + +/datum/action/cooldown/cyberware/gorilla_haymaker + name = "Haymaker" + desc = "Wind up one fist and put everything behind it." + button_icon = GORILLA_GORE_ICON + button_icon_state = "chip_haymaker" + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_HANDS_BLOCKED + cooldown_time = GORILLA_PISTONS_COOLDOWN + click_to_activate = TRUE + unset_after_click = TRUE + /// world.time the wind-up began, 0 when not winding up + var/charge_started = 0 + /// world.time Use chrome went down for this charge, so a short press reads as a tap + var/pressed_at = 0 + /// Set while a release unsets the click ability, so that doesn't read as calling it off + var/releasing = FALSE + /// Timer that vents a charge held too long + var/vent_timer + /// Timer for a release made before the minimum wind-up: it lands when the wind-up is done + var/release_timer + +/datum/action/cooldown/cyberware/gorilla_haymaker/Destroy() + stop_charge() + return ..() + +/datum/action/cooldown/cyberware/gorilla_haymaker/Remove(mob/remove_from) + if(charge_started) + stop_charge() + return ..() + +/datum/action/cooldown/cyberware/gorilla_haymaker/IsAvailable(feedback = FALSE) + . = ..() + if(!.) + return + if(!TIMER_COOLDOWN_FINISHED(owner, COOLDOWN_GORILLA_PISTONS)) + if(feedback) + owner.balloon_alert(owner, "pistons still hot!") + return FALSE + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = organ + if(!istype(arm) || !arm.heavy_moves || !gorilla_may_use_heavy_moves(owner) || HAS_TRAIT(owner, TRAIT_PACIFISM)) + return FALSE + return TRUE + +/// Whether a wind-up is running. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/charging() + return charge_started > 0 + +// A second press while winding up throws it at the tile you face. +/datum/action/cooldown/cyberware/gorilla_haymaker/Trigger(mob/clicker, trigger_flags, atom/target) + if(charging() && !target && owner) + if(!IsAvailable(feedback = TRUE)) + return FALSE + release(get_step(owner, owner.dir)) + return TRUE + return ..() + +/datum/action/cooldown/cyberware/gorilla_haymaker/set_click_ability(mob/on_who) + . = ..() + start_charge() + +/datum/action/cooldown/cyberware/gorilla_haymaker/unset_click_ability(mob/on_who, refund_cooldown = TRUE) + . = ..() + // Called off by hand (the chip again, a stun, the arm going dark): the pistons vent. + if(charging() && !releasing && !release_timer) + vent() + +/datum/action/cooldown/cyberware/gorilla_haymaker/Activate(atom/target) + return release(target) + +/// Use chrome came back up: a held press throws it at the tile you face, a tap waits for a click. +/datum/action/cooldown/cyberware/gorilla_haymaker/chrome_use_released(mob/living/carbon/bearer) + if(!charging() || bearer != owner || release_timer) + return FALSE + if(world.time - pressed_at < GORILLA_HAYMAKER_TAP || !IsAvailable(feedback = TRUE)) + return FALSE + release(get_step(owner, owner.dir)) + return TRUE + +/** + * Winds up and lets go at [target] after [hold_time], with no click or key: the same wind-up anyone can see + * and dodge. For an AI. Returns TRUE if the wind-up began. + */ +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/wind_up_at(atom/target, hold_time = GORILLA_HAYMAKER_FULL_CHARGE) + if(charging() || !IsAvailable()) + return FALSE + start_charge() + if(!charging()) + return FALSE + release_timer = addtimer(CALLBACK(src, PROC_REF(queued_release), WEAKREF(target)), max(hold_time, GORILLA_HAYMAKER_MIN_CHARGE), TIMER_STOPPABLE|TIMER_DELETE_ME) + return TRUE + +/// Begins the wind-up. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/start_charge() + var/mob/living/carbon/bearer = owner + if(!iscarbon(bearer) || charging()) + return + charge_started = world.time + pressed_at = world.time + bearer.apply_status_effect(/datum/status_effect/gorilla_windup, src) + playsound(bearer, 'sound/machines/hiss.ogg', 35, TRUE, frequency = 0.7) + vent_timer = addtimer(CALLBACK(src, PROC_REF(vent)), GORILLA_HAYMAKER_VENT_AFTER, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// Ends the wind-up without throwing anything. Clears every timer and the look. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/stop_charge() + charge_started = 0 + pressed_at = 0 + if(vent_timer) + deltimer(vent_timer) + vent_timer = null + if(release_timer) + deltimer(release_timer) + release_timer = null + var/mob/living/bearer = owner + if(!QDELETED(bearer)) + var/datum/status_effect/gorilla_windup/windup = bearer.has_status_effect(/datum/status_effect/gorilla_windup) + if(windup?.action == src) + qdel(windup) + +/// Held too long, or called off: steam, a short cooldown, nothing thrown. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/vent() + vent_timer = null + if(!charging()) + return + var/mob/living/bearer = owner + stop_charge() + if(QDELETED(bearer)) + return + playsound(bearer, 'sound/machines/steam_hiss.ogg', 50, TRUE) + cyberware_anim_particles(bearer, /particles/smoke/steam/cyberware_vent, GORILLA_ANIM_TOP_LAYER, 0.3 SECONDS, 1.2 SECONDS) + gorilla_spend_pistons(bearer, GORILLA_PISTONS_VENTED) + if(bearer.click_intercept == src) + releasing = TRUE + unset_click_ability(bearer, refund_cooldown = FALSE) + releasing = FALSE + +/// The wind-up's status effect went away under it (a stun, death): end quietly, no vent. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/windup_lost() + if(!charging()) + return + charge_started = 0 + pressed_at = 0 + if(vent_timer) + deltimer(vent_timer) + vent_timer = null + if(release_timer) + deltimer(release_timer) + release_timer = null + if(owner?.click_intercept == src) + releasing = TRUE + unset_click_ability(owner, refund_cooldown = FALSE) + releasing = FALSE + +/** + * Lets it go at [target] (a mob, or a tile to swing into). Before the minimum wind-up the blow waits + * and lands when it is done. Returns TRUE if anything was thrown or queued. + */ +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/release(atom/target) + if(!charging() || release_timer) + return FALSE + var/held = world.time - charge_started + if(held < GORILLA_HAYMAKER_MIN_CHARGE) + var/datum/weakref/target_ref = WEAKREF(target) + release_timer = addtimer(CALLBACK(src, PROC_REF(queued_release), target_ref), GORILLA_HAYMAKER_MIN_CHARGE - held, TIMER_STOPPABLE|TIMER_DELETE_ME) + return TRUE + throw_haymaker(target, held) + return TRUE + +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/queued_release(datum/weakref/target_ref) + release_timer = null + if(!charging() || !IsAvailable()) + stop_charge() + return + throw_haymaker(target_ref?.resolve(), world.time - charge_started) + +/// The swing itself, [held] deciseconds into the wind-up. +/datum/action/cooldown/cyberware/gorilla_haymaker/proc/throw_haymaker(atom/target, held) + var/mob/living/carbon/human/bearer = owner + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = organ + stop_charge() + if(bearer?.click_intercept == src) + releasing = TRUE + unset_click_ability(bearer, refund_cooldown = FALSE) + releasing = FALSE + if(!ishuman(bearer) || !istype(arm) || arm.owner != bearer) + return + gorilla_spend_pistons(bearer, GORILLA_PISTONS_COOLDOWN) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/both as anything in gorilla_heavy_arms(bearer)) + COOLDOWN_START(both, slam_cooldown, both.punch_cooldown_time) + bearer.changeNext_move(CLICK_CD_MELEE) + // Swinging from inside a camo field drops it, as any blow does + cyberware_camo_drop(bearer, "haymaker") + var/mob/living/victim = gorilla_haymaker_victim(bearer, target) + if(isnull(victim)) + var/turf/swing_at = isturf(target) ? target : get_step(bearer, bearer.dir) + if(swing_at) + bearer.face_atom(swing_at) + bearer.do_attack_animation(swing_at, ATTACK_EFFECT_SMASH) + playsound(bearer, 'sound/items/weapons/punchmiss.ogg', 50, TRUE) + bearer.visible_message(span_danger("[bearer]'s fist hits nothing but air!"), span_userdanger("Your fist hits nothing but air!")) + bearer.adjust_staggered_up_to(1 SECONDS, 1 SECONDS) + arm.play_pump() + return + var/charge = clamp((held - GORILLA_HAYMAKER_MIN_CHARGE) / (GORILLA_HAYMAKER_FULL_CHARGE - GORILLA_HAYMAKER_MIN_CHARGE), 0, 1) + gorilla_haymaker_hit(bearer, victim, arm, charge) + +/// Who a haymaker at [target] hits: the clicked body if it is next to the bearer, else whoever stands in that tile. +/proc/gorilla_haymaker_victim(mob/living/bearer, atom/target) + if(isliving(target) && target != bearer && bearer.Adjacent(target)) + return target + var/turf/swing_at = isturf(target) ? target : get_turf(target) + if(isnull(swing_at) || get_dist(bearer, swing_at) != 1) + swing_at = get_step(bearer, get_dir(bearer, target) || bearer.dir) + if(isnull(swing_at) || !bearer.Adjacent(swing_at)) + return null + var/mob/living/lying + for(var/mob/living/body in swing_at) + if(body == bearer || body.stat == DEAD) + continue + if(body.body_position != LYING_DOWN) + return body + lying ||= body + return lying + +/// A landed haymaker: [charge] 0 to 1 sets the damage and the reach. +/proc/gorilla_haymaker_hit(mob/living/carbon/human/bearer, mob/living/victim, obj/item/organ/cyberimp/cyberware/gorilla_arms/arm, charge) + var/obj/item/bodypart/hand = bearer.get_bodypart(arm.zone) + var/damage = round(GORILLA_HAYMAKER_MIN_DAMAGE + (GORILLA_HAYMAKER_MAX_DAMAGE - GORILLA_HAYMAKER_MIN_DAMAGE) * charge) + var/reach = charge >= 1 ? GORILLA_HAYMAKER_MAX_RANGE : GORILLA_HAYMAKER_MIN_RANGE + var/direction = get_dir(bearer, victim) || bearer.dir + bearer.face_atom(victim) + bearer.do_attack_animation(victim, ATTACK_EFFECT_SMASH) + if(ishuman(victim)) + var/mob/living/carbon/human/human_victim = victim + if(human_victim.check_block(bearer, damage, "[bearer]'s haymaker")) + playsound(victim, 'sound/items/weapons/punchmiss.ogg', 40, TRUE) + log_combat(bearer, victim, "attempted a haymaker on", "gorilla arms") + return FALSE + var/list/plan = gorilla_plan_knockback(victim, direction, reach) + var/flies = plan[2] > 0 && victim.body_position != LYING_DOWN && !victim.buckled && !victim.anchored + if(flies) + victim.hv_hold_attack_message("[bearer] drives a fist into [victim] and sends [victim.p_them()] flying!", "[bearer] drives a fist into you and sends you flying!", "You drive a fist into [victim] and send [victim.p_them()] flying!", bearer, vision_range = DEFAULT_MESSAGE_RANGE) + else + victim.hv_hold_attack_message("[bearer] drives a fist into [victim]!", "[bearer] drives a fist into you!", "You drive a fist into [victim]!", bearer, vision_range = DEFAULT_MESSAGE_RANGE) + var/zone = victim.get_random_valid_zone(bearer.zone_selected) + var/armour = victim.run_armor_check(zone, MELEE, armour_penetration = hand?.unarmed_effectiveness) + var/list/wounds_before + if(iscarbon(victim)) + var/mob/living/carbon/carbon_victim = victim + wounds_before = carbon_victim.gorilla_wound_snapshot() + var/dealt = victim.apply_damage(damage, BRUTE, zone, armour, wound_bonus = GORILLA_PUNCH_WOUND_BONUS + 5, attack_direction = direction, attacking_item = hand) + var/wounded = FALSE + if(iscarbon(victim) && !QDELETED(victim)) + var/mob/living/carbon/carbon_victim = victim + if(dealt >= GORILLA_STRAIN_MIN_DAMAGE) + gorilla_add_strain(carbon_victim, check_zone(zone), bearer, arm.snap_strain, hand, direction) + wounded = gorilla_new_wound(carbon_victim, wounds_before) + victim.hv_flush_hit() + gorilla_register_aggression(victim, bearer) + playsound(victim, 'sound/effects/meteorimpact.ogg', 50, TRUE) + playsound(victim, 'sound/items/weapons/punch4.ogg', 70, TRUE) + arm.gorilla_punch_fx(bearer, victim, TRUE) + cyberware_flash_fx(victim, 0.2 SECONDS) + shake_camera(victim, 3, 2) + if(flies && !QDELETED(victim)) + gorilla_launch(victim, bearer, direction, reach, GORILLA_HAYMAKER_SPEED, FALSE, TRUE, wounded) + log_combat(bearer, victim, "landed a haymaker on", "gorilla arms", "(charge [round(charge * 100)]%)") + return TRUE + +/// A haymaker from [bearer] at [target] with whichever arm is ready, wound up for [hold_time]. For an AI. Returns TRUE if it began. +/proc/gorilla_try_haymaker(mob/living/carbon/human/bearer, atom/target, hold_time = GORILLA_HAYMAKER_FULL_CHARGE) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) + for(var/datum/action/cooldown/cyberware/gorilla_haymaker/haymaker in arm.actions) + if(haymaker.owner == bearer && haymaker.wind_up_at(target, hold_time)) + return TRUE + return FALSE + +/// Starts the pistons' shared cooldown on [bearer] and shows it on every haymaker chip. +/proc/gorilla_spend_pistons(mob/living/bearer, time) + gorilla_cooldown(bearer, COOLDOWN_GORILLA_PISTONS, time) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) + for(var/datum/action/cooldown/cyberware/gorilla_haymaker/haymaker in arm.actions) + haymaker.StartCooldownSelf(time) + +// ========================================================================= +// THE WIND-UP +// ========================================================================= + +/// The bearer winding up a haymaker: slowed, empty-handed of grabs, leaning back with the arm heating. +/datum/status_effect/gorilla_windup + id = "gorilla_windup" + alert_type = null + duration = GORILLA_HAYMAKER_VENT_AFTER + 1 SECONDS + tick_interval = 0.2 SECONDS + status_type = STATUS_EFFECT_UNIQUE + /// The haymaker winding up + var/datum/action/cooldown/cyberware/gorilla_haymaker/action + /// The heat glow over the arm + var/obj/effect/abstract/cyberware_anim/heat + /// Steam off the arm + var/obj/effect/abstract/cyberware_anim/steam + /// Trembling at full charge + var/trembling = FALSE + +/datum/status_effect/gorilla_windup/on_creation(mob/living/new_owner, datum/action/cooldown/cyberware/gorilla_haymaker/action) + src.action = action + return ..() + +/datum/status_effect/gorilla_windup/on_apply() + if(owner.stat != CONSCIOUS) + return FALSE + owner.add_movespeed_modifier(/datum/movespeed_modifier/gorilla_windup) + if(owner.pulling) + owner.stop_pulling() + RegisterSignal(owner, COMSIG_MOB_STATCHANGE, PROC_REF(on_stat_change)) + // Lean back from the way they face. A tracked offset tg removes cleanly. + var/back = REVERSE_DIR(owner.dir) + var/dx = (back & EAST) ? 2 : ((back & WEST) ? -2 : 0) + var/dz = (back & NORTH) ? 1 : 0 + owner.add_offsets(id, x_add = dx, z_add = dz) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = action?.organ + if(istype(arm)) + heat = arm.play_heat(GORILLA_HAYMAKER_FULL_CHARGE, duration) + steam = cyberware_anim_particles(owner, /particles/smoke/steam/gorilla_puff, GORILLA_ANIM_TOP_LAYER, duration, duration + 1 SECONDS) + return TRUE + +/datum/status_effect/gorilla_windup/on_remove() + UnregisterSignal(owner, COMSIG_MOB_STATCHANGE) + owner.remove_movespeed_modifier(/datum/movespeed_modifier/gorilla_windup) + owner.remove_offsets(id) + QDEL_NULL(heat) + if(steam) + steam.stop_particles() + steam = null + var/datum/action/cooldown/cyberware/gorilla_haymaker/haymaker = action + action = null + haymaker?.windup_lost() + +/datum/status_effect/gorilla_windup/Destroy() + action = null + return ..() + +/datum/status_effect/gorilla_windup/tick(seconds_between_ticks) + // Stunned, cuffed or knocked down mid wind-up: the swing is lost + if(HAS_TRAIT(owner, TRAIT_INCAPACITATED) || HAS_TRAIT(owner, TRAIT_HANDS_BLOCKED) || owner.body_position == LYING_DOWN) + qdel(src) + return + if(trembling || !action?.charging()) + return + if(world.time - action.charge_started < GORILLA_HAYMAKER_FULL_CHARGE) + return + // Full: a light on the fist and a tremble + trembling = TRUE + owner.mob_light(1.5, 0.8, "#ffb35c", max(1, duration - world.time)) + owner.do_jitter_animation(30) + +/datum/status_effect/gorilla_windup/proc/on_stat_change(datum/source, new_stat, old_stat) + SIGNAL_HANDLER + if(new_stat != CONSCIOUS) + qdel(src) + +/datum/movespeed_modifier/gorilla_windup + multiplicative_slowdown = 1.5 + +/// A thin puff of steam off a working arm. +/particles/smoke/steam/gorilla_puff + icon_state = list("steam_1" = 1, "steam_2" = 1, "steam_3" = 2) + count = 20 + spawning = 1.2 + lifespan = 0.7 SECONDS + fade = 0.4 SECONDS + position = generator(GEN_BOX, list(-8, -2, 0), list(8, 6, 0), UNIFORM_RAND) + velocity = generator(GEN_VECTOR, list(-0.3, 0.6, 0), list(0.3, 1.2, 0), NORMAL_RAND) + gravity = list(0, 0.15) + friction = 0.2 + grow = 0.04 + scale = 0.45 diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_looks.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_looks.dm new file mode 100644 index 00000000000..7f1b87070c5 --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_looks.dm @@ -0,0 +1,193 @@ +/** + * # Gorilla Arms looks + * + * Pictures on top of moves that already happened; nothing here changes a number. + * - The piston pump: the punching arm's pistons compress, fire and return. + * - Heat: the pistons whiten and glow as an arm winds up or strains. + * - The deploy flex: as the pair syncs, after an EMP reboot, or on the Arm chrome key when the Gorilla + * has nothing to deploy. A squat, both pumps in turn with steam, the knuckles flash, and it settles. + * - Snapped bones: a compound fracture on an arm, a leg or the chest shows bone through the skin and + * blood, whatever broke it. Drawn like #509's stumps (bone plus greyscale blood tinted to the body's + * blood), on its own overlay list beside them. + * + * Plates under a suit that covers the arms don't show, so their pump and heat don't either. + */ + +/// Whether this arm's worn plate is on show (no suit over the arms). +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/plate_shows() + var/mob/living/carbon/human/bearer = owner + if(!ishuman(bearer)) + return FALSE + return !(bearer.wear_suit?.body_parts_covered & ARMS) + +/// The pistons on this arm pump once: compress, fire, return. Returns the holder or null. +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/play_pump() + if(!owner || !plate_shows()) + return null + var/state = "pump_[aug_overlay]" + if(!icon_exists(GORILLA_GORE_ICON, state)) + return null + var/obj/effect/abstract/cyberware_anim/pump = cyberware_anim_play(owner, GORILLA_GORE_ICON, state, GORILLA_ANIM_PLATE_LAYER, 0.3 SECONDS) + if(pump && icon_exists(GORILLA_GORE_ICON, "[state]_e")) + pump.add_overlay(emissive_appearance(GORILLA_GORE_ICON, "[state]_e", owner)) + return pump + +/** + * The pistons heat over [rise_time] and stay hot until [lifetime], then the glow goes. Returns the + * holder, which the caller may delete sooner. + */ +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/play_heat(rise_time, lifetime) + if(!owner || !plate_shows()) + return null + var/state = "hot_[aug_overlay]" + if(!icon_exists(GORILLA_GORE_ICON, state)) + return null + var/obj/effect/abstract/cyberware_anim/heat = cyberware_anim_play(owner, GORILLA_GORE_ICON, state, GORILLA_ANIM_PLATE_LAYER, lifetime) + if(!heat) + return null + heat.add_overlay(emissive_appearance(GORILLA_GORE_ICON, state, owner)) + heat.alpha = 0 + animate(heat, alpha = 255, time = rise_time, easing = SINE_EASING | EASE_IN) + return heat + +// ========================================================================= +// THE DEPLOY FLEX +// ========================================================================= + +/// The flex, at most once per GORILLA_FLEX_COOLDOWN. Every step is relative and ends where it began. +/proc/gorilla_flex(mob/living/carbon/human/bearer) + if(!ishuman(bearer) || bearer.stat != CONSCIOUS || bearer.body_position == LYING_DOWN || !isturf(bearer.loc)) + return FALSE + var/list/arms = gorilla_heavy_arms(bearer) + if(!length(arms) || !TIMER_COOLDOWN_FINISHED(bearer, COOLDOWN_GORILLA_FLEX)) + return FALSE + gorilla_cooldown(bearer, COOLDOWN_GORILLA_FLEX, GORILLA_FLEX_COOLDOWN) + // The squat, about the feet, and back up + var/matrix/squat = matrix(1.05, 0, 0, 0, 0.93, -1) + var/matrix/rise = matrix(squat) + rise.Invert() + animate(bearer, transform = squat, time = 0.15 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(transform = matrix(), time = 0.5 SECONDS, flags = ANIMATION_RELATIVE) + animate(transform = rise, time = 0.25 SECONDS, easing = SINE_EASING | EASE_IN | EASE_OUT, flags = ANIMATION_RELATIVE) + var/delay = 0.15 SECONDS + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in arms) + addtimer(CALLBACK(arm, TYPE_PROC_REF(/obj/item/organ/cyberimp/cyberware/gorilla_arms, flex_beat)), delay) + delay += 0.15 SECONDS + playsound(bearer, 'sound/machines/hiss.ogg', 30, TRUE, frequency = 0.8) + return TRUE + +/// One arm's part of the flex: the pump, a puff of steam, and the knuckles flashing. +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/flex_beat() + if(!owner) + return + play_pump() + play_heat(0.1 SECONDS, 0.35 SECONDS) + cyberware_anim_particles(owner, /particles/smoke/steam/gorilla_puff, GORILLA_ANIM_TOP_LAYER, 0.15 SECONDS, 0.9 SECONDS) + var/obj/effect/abstract/cyberware_anim/spark = cyberware_anim_play(owner, GORILLA_GORE_ICON, "impact_brawler", GORILLA_ANIM_TOP_LAYER, 0.4 SECONDS) + if(spark) + spark.appearance_flags |= RESET_TRANSFORM + var/side = (zone == BODY_ZONE_R_ARM) ? -1 : 1 + if(owner.dir == NORTH) + side = -side + spark.pixel_w = (owner.dir & (EAST|WEST)) ? 0 : side * 7 + spark.pixel_z = -5 + spark.transform = matrix().Scale(0.5) + +// The pair comes back from an EMP: once it is back in sync, the arms flex. +/datum/component/cyberware/finish_emp_reboot() + var/was_down = emp_down + . = ..() + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = parent + if(!was_down || emp_down || !istype(arm) || !arm.heavy_moves || !arm.owner) + return + if(gorilla_synced(arm.owner)) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(gorilla_flex), arm.owner), 0.6 SECONDS) + +// The Arm chrome key with nothing to deploy but a synced pair of Gorilla Arms: they flex. +/datum/component/chrome_wheel/use_arms() + var/mob/living/carbon/bearer = parent + if((!arm_action || arm_action.owner != bearer) && gorilla_synced(bearer)) + gorilla_flex(bearer) + return TRUE + return ..() + +// ========================================================================= +// SNAPPED BONES +// ========================================================================= + +/mob/living/carbon + /// Snapped-bone images drawn on us now (hyperviolence_gorilla). + var/list/gorilla_snap_overlays + /// What gorilla_snap_overlays was built from. + var/gorilla_snap_key + +/mob/living/carbon/update_damage_overlays() + . = ..() + gorilla_update_snap_overlays() + +/mob/living/carbon/regenerate_icons() + gorilla_snap_key = null + return ..() + +/// The zones showing a snapped bone: an arm, a leg or the chest with a compound fracture (not the skull). +/mob/living/carbon/proc/gorilla_snapped_zones() + var/static/list/snap_zones = list(BODY_ZONE_CHEST, BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + . = list() + if(!ishuman(src)) + return + for(var/zone in snap_zones) + var/obj/item/bodypart/part = get_bodypart(zone) + if(isnull(part) || !part.hv_shows_gore()) + continue + for(var/datum/wound/blunt/bone/critical/fracture in part.wounds) + . += zone + break + +/// Rebuilds the snapped-bone overlays if what they show changed. +/mob/living/carbon/proc/gorilla_update_snap_overlays() + var/list/zones = gorilla_snapped_zones() + var/new_key + var/blood_color + if(length(zones)) + if(can_bleed()) + blood_color = get_bloodtype()?.get_damage_color(src) || BLOOD_COLOR_RED + var/height = HUMAN_HEIGHT_MEDIUM + if(ishuman(src)) + var/mob/living/carbon/human/as_human = src + height = as_human.mob_height + new_key = "[jointext(zones, ",")]|[islist(blood_color) ? jointext(blood_color, ",") : blood_color]|[height]" + if(new_key == gorilla_snap_key && (new_key || !gorilla_snap_overlays)) + return + gorilla_snap_key = new_key + if(gorilla_snap_overlays) + cut_overlay(gorilla_snap_overlays) + gorilla_snap_overlays = null + if(isnull(new_key)) + return + var/list/images = list() + for(var/zone in zones) + if(blood_color) + var/image/blood = image(GORILLA_GORE_ICON, "snap_[zone]_blood", -HV_STUMP_FRESH_LAYER) + blood.appearance_flags = RESET_COLOR + blood.color = blood_color + images += blood + images += image(GORILLA_GORE_ICON, "snap_[zone]_bone", -HV_STUMP_FRESH_LAYER) + if(ishuman(src)) + var/mob/living/carbon/human/as_human = src + if(as_human.mob_height != HUMAN_HEIGHT_MEDIUM) + for(var/image/snap_image as anything in images) + as_human.apply_height_filters(snap_image) + gorilla_snap_overlays = images + add_overlay(gorilla_snap_overlays) + +// A compound fracture comes and goes: the snapped bone with it. +/datum/wound/blunt/bone/critical/apply_wound(obj/item/bodypart/L, silent = FALSE, datum/wound/old_wound = null, smited = FALSE, attack_direction = null, wound_source = "Unknown", replacing = FALSE) + . = ..() + if(. && iscarbon(victim)) + victim.gorilla_update_snap_overlays() + +/datum/wound/blunt/bone/critical/remove_wound(ignore_limb, replaced) + var/mob/living/carbon/was_on = victim + . = ..() + if(iscarbon(was_on) && !QDELETED(was_on)) + was_on.gorilla_update_snap_overlays() diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm new file mode 100644 index 00000000000..dfb02a45ce7 --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm @@ -0,0 +1,156 @@ +/** + * # Ground pound + * + * A chrome wheel chip the synced pair grants. The bearer crouches for a beat, then drives both fists into + * the floor: a ring and cracks, dust, a shake for anyone close. Everyone on the tiles around the bearer, + * bar the bearer's own crew, staggers, is dazed (the next shove knocks them down) and loses some wind; it + * does no brute. Loose things on the floor nearby hop a tile outward. They are moved, never thrown, so a + * ring of knives never becomes a ring of thrown knives, and they never hop into a hazard or through glass. + */ + +/datum/action/cooldown/cyberware/gorilla_pound + name = "Ground pound" + desc = "Drive both fists into the floor." + button_icon = GORILLA_GORE_ICON + button_icon_state = "chip_pound" + check_flags = AB_CHECK_CONSCIOUS | AB_CHECK_INCAPACITATED | AB_CHECK_HANDS_BLOCKED | AB_CHECK_IMMOBILE | AB_CHECK_LYING + cooldown_time = GORILLA_POUND_COOLDOWN + /// The crouch before the slam + var/crouch_timer + +/datum/action/cooldown/cyberware/gorilla_pound/Destroy() + if(crouch_timer) + deltimer(crouch_timer) + crouch_timer = null + return ..() + +/datum/action/cooldown/cyberware/gorilla_pound/IsAvailable(feedback = FALSE) + . = ..() + if(!.) + return + if(!gorilla_synced(owner) || !gorilla_may_use_heavy_moves(owner)) + if(feedback) + owner.balloon_alert(owner, "arms out of sync!") + return FALSE + if(HAS_TRAIT(owner, TRAIT_PACIFISM)) + return FALSE + if(!TIMER_COOLDOWN_FINISHED(owner, COOLDOWN_GORILLA_POUND)) + return FALSE + var/turf/floor = get_turf(owner) + if(!isfloorturf(floor) || !floor.has_gravity()) + if(feedback) + owner.balloon_alert(owner, "nothing to hit!") + return FALSE + return TRUE + +/datum/action/cooldown/cyberware/gorilla_pound/Activate(atom/target) + var/mob/living/carbon/human/bearer = owner + if(!ishuman(bearer) || crouch_timer) + return FALSE + StartCooldown() + gorilla_cooldown(bearer, COOLDOWN_GORILLA_POUND, GORILLA_POUND_COOLDOWN) + // The crouch: down and wide, both arms heating + animate(bearer, pixel_z = -2, time = GORILLA_POUND_CROUCH, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(pixel_z = 2, time = 0.1 SECONDS, flags = ANIMATION_RELATIVE) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) + arm.play_heat(GORILLA_POUND_CROUCH, GORILLA_POUND_CROUCH + 0.3 SECONDS) + playsound(bearer, 'sound/machines/hiss.ogg', 40, TRUE, frequency = 0.6) + crouch_timer = addtimer(CALLBACK(src, PROC_REF(slam), get_turf(bearer)), GORILLA_POUND_CROUCH, TIMER_STOPPABLE|TIMER_DELETE_ME) + return TRUE + +/// A ground pound from [bearer], as the chip would. For an AI. Returns TRUE if it began. +/proc/gorilla_try_pound(mob/living/carbon/human/bearer) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) + for(var/datum/action/cooldown/cyberware/gorilla_pound/pound in arm.actions) + if(pound.owner == bearer) + return !!pound.Trigger() + return FALSE + +/// The fists hit the floor, if the bearer is still crouched where they began. +/datum/action/cooldown/cyberware/gorilla_pound/proc/slam(turf/where) + crouch_timer = null + var/mob/living/carbon/human/bearer = owner + if(!ishuman(bearer) || bearer.loc != where || bearer.stat != CONSCIOUS || HAS_TRAIT(bearer, TRAIT_INCAPACITATED) || HAS_TRAIT(bearer, TRAIT_HANDS_BLOCKED)) + return + gorilla_ground_pound(bearer, where) + +/// Both fists into the floor at [where]: the shock, the stagger and the hop. Returns the mobs it hit. +/proc/gorilla_ground_pound(mob/living/carbon/human/bearer, turf/where) + . = list() + cyberware_camo_drop(bearer, "ground pound") + bearer.do_attack_animation(where, ATTACK_EFFECT_SMASH) + bearer.visible_message(span_danger("[bearer] drives both fists into the floor!"), span_danger("You drive both fists into the floor!"), vision_distance = DEFAULT_MESSAGE_RANGE) + playsound(where, 'sound/effects/meteorimpact.ogg', 80, TRUE, 8) + cyberware_piledriver_impact_fx(where, 1.5) + cyberware_grey_dust(where) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) + arm.play_pump() + for(var/mob/living/shaken in range(3, where)) + if(shaken.client && shaken != bearer) + shake_camera(shaken, 3, get_dist(shaken, where) <= 1 ? 2 : 1) + shake_camera(bearer, 2, 1) + for(var/mob/living/hit in orange(1, where)) + // Not through glass or a door edge, and never the bearer's own crew + if(hit == bearer || hit.stat == DEAD || !hit.Adjacent(bearer) || cyberware_is_ally(bearer, hit)) + continue + hit.adjust_staggered_up_to(STAGGERED_SLOWDOWN_LENGTH, 10 SECONDS) + hit.apply_status_effect(/datum/status_effect/dazed, GORILLA_POUND_DAZE) + hit.apply_damage(GORILLA_POUND_STAMINA, STAMINA) + gorilla_register_aggression(hit, bearer) + log_combat(bearer, hit, "ground-pounded", "gorilla arms") + . += hit + gorilla_pound_scatter(where) + +/// Loose things on the floor near [where] hop a tile outward, never into a hazard, glass or a door. +/proc/gorilla_pound_scatter(turf/where) + var/moved = 0 + for(var/obj/item/loose in range(GORILLA_POUND_ITEM_REACH, where)) + if(moved >= 30) + return + if(loose.anchored || !isturf(loose.loc) || (loose.item_flags & ABSTRACT) || loose.invisibility) + continue + var/turf/here = loose.loc + if(locate(/obj/structure/table) in here) + continue + var/out = get_dir(where, here) || pick(GLOB.cardinals) + var/turf/next = get_step(here, out) + if(isnull(next) || isclosedturf(next) || !isfloorturf(next) || gorilla_throw_stops_short(here, next, out)) + continue + if(!loose.Move(next, out)) + continue + moved++ + animate(loose, pixel_z = 5, time = 0.08 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(pixel_z = -5, time = 0.1 SECONDS, easing = SINE_EASING | EASE_IN, flags = ANIMATION_RELATIVE) + +// The set bonus grants the chip to the right arm, or whichever real arm there is. + +/datum/status_effect/organ_set_bonus/cyberware_gorilla + /// The ground pound chip, while the pair is synced + var/datum/action/cooldown/cyberware/gorilla_pound/pound + +/datum/status_effect/organ_set_bonus/cyberware_gorilla/proc/grant_pound() + if(pound && !QDELETED(pound)) + return + var/list/arms = gorilla_heavy_arms(owner) + if(!length(arms)) + return + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/carrier = arms[1] + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in arms) + if(arm.zone == BODY_ZONE_R_ARM) + carrier = arm + pound = carrier.add_item_action(/datum/action/cooldown/cyberware/gorilla_pound) + if(carrier.owner) + pound.Grant(carrier.owner) + +/datum/status_effect/organ_set_bonus/cyberware_gorilla/proc/take_pound() + if(!pound) + return + var/datum/action/cooldown/cyberware/gorilla_pound/old = pound + pound = null + if(QDELETED(old)) + return + var/obj/item/organ/carrier = old.organ + if(istype(carrier)) + carrier.remove_item_action(old) + else + qdel(old) diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_pry.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_pry.dm new file mode 100644 index 00000000000..31a76cc0002 --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_pry.dm @@ -0,0 +1,110 @@ +/** + * # Gorilla door pry + * + * The synced pair's bare-handed pry is tg's door_pryer (instant on an unpowered door, a slow pry on a + * powered one, refused through bolts, welds and seals), with two changes: + * - Trader outpost doors never give: the sanctuary airlocks and any door on an outpost's protected + * ground. Trying counts as aggression against the outpost. + * - A powered pry strains: every half second the pistons pump and steam, the arms heat and tremble, the + * door shakes, the seam opens a little wider and throws sparks. + */ +/datum/element/door_pryer/gorilla + +/datum/element/door_pryer/gorilla/on_attack(mob/living/attacker, atom/target, proximity_flag) + SIGNAL_HANDLER + if(proximity_flag && istype(target, /obj/machinery/door/airlock) && !attacker.combat_mode) + var/obj/machinery/door/airlock/airlock_target = target + if(airlock_target.density && gorilla_pry_refused(airlock_target)) + airlock_target.balloon_alert(attacker, "it won't budge!") + gorilla_pry_aggression(airlock_target, attacker) + attacker.log_message("tried to pry open [airlock_target] at a trader outpost, located at [loc_name(airlock_target)].", LOG_GAME) + return COMPONENT_CANCEL_ATTACK_CHAIN + return ..() + +/// Whether a trader outpost keeps this door shut against the Gorilla. +/proc/gorilla_pry_refused(obj/machinery/door/airlock/door) + return istype(door, /obj/machinery/door/airlock/outpost) || is_trader_outpost_protected(door) + +/// Trying to force a trader outpost's door counts against the one who tried. +/proc/gorilla_pry_aggression(obj/machinery/door/airlock/door, mob/living/offender) + var/obj/machinery/door/airlock/outpost/outpost_door = door + if(istype(outpost_door) && outpost_door.outpost) + outpost_door.outpost.register_aggression(offender) + return + var/obj/structure/overmap/trader_outpost/guarding = get_trader_outpost_for_turf(get_turf(door)) + guarding?.register_aggression(offender) + +/datum/element/door_pryer/gorilla/open_door(mob/living/attacker, obj/machinery/door/airlock/airlock_target) + // Unpowered or opened for them: nothing to strain against. + if(!airlock_target.hasPower() || airlock_target.allowed(attacker)) + return ..() + var/datum/gorilla_pry_strain/strain = new(attacker, airlock_target) + . = ..() + qdel(strain) + +/// The strain of a powered pry, beat by beat, while the do_after runs. +/datum/gorilla_pry_strain + var/mob/living/carbon/bearer + var/obj/machinery/door/airlock/door + /// The seam opening over the door + var/obj/effect/abstract/gorilla_pry_seam/seam + /// Beats so far + var/beats = 0 + var/beat_timer + +/datum/gorilla_pry_strain/New(mob/living/carbon/bearer, obj/machinery/door/airlock/door) + . = ..() + src.bearer = bearer + src.door = door + seam = new(null) + door.vis_contents += seam + RegisterSignal(door, COMSIG_QDELETING, PROC_REF(on_deleted)) + RegisterSignal(bearer, COMSIG_QDELETING, PROC_REF(on_deleted)) + beat() + +/datum/gorilla_pry_strain/Destroy() + if(beat_timer) + deltimer(beat_timer) + beat_timer = null + if(door) + UnregisterSignal(door, COMSIG_QDELETING) + door.vis_contents -= seam + if(bearer) + UnregisterSignal(bearer, COMSIG_QDELETING) + QDEL_NULL(seam) + bearer = null + door = null + return ..() + +/datum/gorilla_pry_strain/proc/on_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/datum/gorilla_pry_strain/proc/beat() + beat_timer = null + if(QDELETED(bearer) || QDELETED(door) || !DOING_INTERACTION_WITH_TARGET(bearer, door)) + return + beats++ + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) + arm.play_pump() + if(beats == 1) + arm.play_heat(4 SECONDS, 4.5 SECONDS) + cyberware_anim_particles(bearer, /particles/smoke/steam/gorilla_puff, GORILLA_ANIM_TOP_LAYER, 0.2 SECONDS, 1 SECONDS) + bearer.do_jitter_animation(20) + door.Shake(1, 0, 0.3 SECONDS) + seam.set_width(beats) + do_sparks(2, TRUE, door) + beat_timer = addtimer(CALLBACK(src, PROC_REF(beat)), 0.5 SECONDS, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// The crack forced open down an airlock's middle seam: a dark gap with lit edges. +/obj/effect/abstract/gorilla_pry_seam + name = "seam" + icon = GORILLA_GORE_ICON + icon_state = "pry_seam_1" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + vis_flags = VIS_INHERIT_PLANE | VIS_INHERIT_ID + layer = ABOVE_OBJ_LAYER + +/// Widens the seam with the strain: three steps. +/obj/effect/abstract/gorilla_pry_seam/proc/set_width(beats) + icon_state = "pry_seam_[clamp(round((beats + 1) / 3), 1, 3)]" diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_punch.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_punch.dm new file mode 100644 index 00000000000..5f03e08fc3e --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_punch.dm @@ -0,0 +1,344 @@ +/** + * # The Gorilla punch, through #509 + * + * The piston punch of Gorilla Arms and Brawler myomers (ware_military_arms.dm on_punch()) lands here: + * - The blow goes through #509 like any weapon: the punching hand is the attacking item and the blow + * has a direction, so aimed crits roll, blood sprays the way it went, and every wound line joins + * the attack line in one message. + * - Bone breakers: piston hits on one limb strain it. The third Gorilla hit inside the window (the + * fourth from Brawler myomers) snaps an arm or a leg, or caves in the ribs. Heavy armour on the limb, + * robotic limbs and the Atlas frame stop the snap, and the head never snaps. + * - The knockback rides the safe path (gorilla_throw.dm). A grabbed body is hurled, once per grab-move + * cooldown shared with the slam, so a held body can't be thrown into a wall over and over. + * - Grab and slam: with someone in an aggressive grab, a click on the floor under them, an open floor + * beside you or a table lifts them and brings them down (Gorilla only). A table click while grabbing + * slams instead of caving the table in. + * - Every Gorilla blow on a person counts as PvP aggression at a trader outpost: the punch cancels the + * attack chain before the outpost's own unarmed-attack listener ever hears it. + */ + +/obj/item/organ/cyberimp/cyberware/gorilla_arms + /// The hurl, the slam, the haymaker, the ground pound and the finishers. The Brawler has none. + var/heavy_moves = TRUE + /// What drives the fist, in the attack line. + var/fist_word = "piston-driven" + /// Strain on one limb that snaps it. + var/snap_strain = 3 + /// Wound bonus on the punch. + var/punch_wound_bonus = GORILLA_PUNCH_WOUND_BONUS + /// The sparks a landed punch throws (gorilla_gore.dmi). + var/impact_state = "impact_gorilla" + +/// Starts (or restarts) a bearer-wide cooldown, replacing any one already running under [index]. +/proc/gorilla_cooldown(mob/living/bearer, index, time) + var/old_timer = TIMER_COOLDOWN_RUNNING(bearer, index) + if(old_timer) + deltimer(old_timer) + TIMER_COOLDOWN_END(bearer, index) + S_TIMER_COOLDOWN_START(bearer, index, time) + +/// Counts a Gorilla blow on [victim] as PvP aggression at a trader outpost. +/proc/gorilla_register_aggression(mob/living/victim, mob/living/offender) + if(!isliving(victim) || !isliving(offender) || victim == offender) + return + GLOB.outpost_pvp_enforcement?.register_pvp_aggression(victim, offender) + +/// The bearer's installed Gorilla Arms of the real (heavy) kind, both sides. +/proc/gorilla_heavy_arms(mob/living/carbon/bearer) + RETURN_TYPE(/list) + . = list() + if(!iscarbon(bearer)) + return + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in bearer.organs) + if(arm.heavy_moves && arm.owner == bearer) + . += arm + +/// Whether [bearer] has both real Gorilla arms synced (the set bonus): the finishers and the ground pound. +/proc/gorilla_synced(mob/living/carbon/bearer) + var/datum/status_effect/organ_set_bonus/cyberware_gorilla/bonus = bearer?.has_status_effect(/datum/status_effect/organ_set_bonus/cyberware_gorilla) + return !!bonus?.bonus_active + +/** + * Who may use the Gorilla's heavy moves: the slam, the haymaker, the ground pound and the finishers. + * Players, and NPCs of boss rank, as with gun executions (hv_can_execute()). This is the one gate for all + * of them, so letting other NPCs use the moves later is a change here and nowhere else. + */ +/proc/gorilla_may_use_heavy_moves(mob/living/bearer) + return isliving(bearer) && bearer.hv_can_execute() + +/// Whether this arm's chrome is running (not EMP'd, browned out or failing). +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/gorilla_running() + if(organ_flags & ORGAN_FAILING) + return FALSE + var/datum/component/cyberware/chrome = GetComponent(/datum/component/cyberware) + return !chrome || chrome.is_running() + +// ========================================================================= +// THE PISTON PUNCH +// ========================================================================= + +/** + * A landed piston punch on [living_target]: the damage through #509 as one line, strain, the + * knockback or the hurl, aggression, and the look. Returns the brute it dealt. + */ +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/piston_hit(mob/living/carbon/human/source, mob/living/living_target, obj/item/bodypart/active_hand) + var/grabbed = living_target.pulledby == source && source.grab_state >= GRAB_AGGRESSIVE + var/standing = source.body_position != LYING_DOWN + var/hurl = heavy_moves && hurl_range > 0 && grabbed && standing && TIMER_COOLDOWN_FINISHED(source, COOLDOWN_GORILLA_GRAB_MOVE) + var/direction = get_dir(source, living_target) || source.dir + source.do_attack_animation(living_target, ATTACK_EFFECT_SMASH) + playsound(living_target.loc, 'sound/items/weapons/punch1.ogg', 40, TRUE, -1) + var/target_zone = living_target.get_random_valid_zone(source.zone_selected) + var/armor_block = living_target.run_armor_check(target_zone, MELEE, armour_penetration = active_hand.unarmed_effectiveness) + if(hurl) + living_target.hv_hold_attack_message("[source] hurls [living_target] across the room!", "[source] hurls you across the room!", "You hurl [living_target] across the room!", source) + else + living_target.hv_hold_attack_message("[source] slams [living_target] with a [fist_word] fist!", "[source] slams you with a [fist_word] fist!", "You slam [living_target]!", source) + var/list/wounds_before + if(iscarbon(living_target)) + var/mob/living/carbon/carbon_before = living_target + wounds_before = carbon_before.gorilla_wound_snapshot() + var/dealt = living_target.apply_damage(punch_damage, BRUTE, target_zone, armor_block, wound_bonus = punch_wound_bonus, attack_direction = direction, attacking_item = active_hand) + var/wounded = FALSE + if(iscarbon(living_target) && !QDELETED(living_target)) + var/mob/living/carbon/carbon_target = living_target + if(dealt >= GORILLA_STRAIN_MIN_DAMAGE) + gorilla_add_strain(carbon_target, check_zone(target_zone), source, snap_strain, active_hand, direction) + wounded = gorilla_new_wound(carbon_target, wounds_before) + living_target.hv_flush_hit() + gorilla_register_aggression(living_target, source) + if(standing && !QDELETED(living_target)) + if(hurl) + gorilla_cooldown(source, COOLDOWN_GORILLA_GRAB_MOVE, GORILLA_GRAB_MOVE_COOLDOWN) + gorilla_launch(living_target, source, direction, hurl_range, 2, FALSE, FALSE, wounded) + else if(!grabbed) + gorilla_launch(living_target, source, direction, 1, rand(1, 4), TRUE, FALSE, wounded) + gorilla_punch_fx(source, living_target, wounded) + log_combat(source, living_target, hurl ? "piston-hurled" : "piston-punched", "gorilla arms") + return dealt + +/// Whether [victim] picked up a wound (or a worse one) since [before] (gorilla_wound_snapshot()). +/proc/gorilla_new_wound(mob/living/carbon/victim, list/before) + for(var/datum/wound/wound as anything in victim.all_wounds) + if(!(wound in before) || before[wound] < wound.severity) + return TRUE + return FALSE + +/** + * What a piston punch looks like: knuckle sparks over the target, the punching arm's pistons pump + * with a puff of steam, a light, and on a heavy hit a small ring. + */ +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/gorilla_punch_fx(mob/living/carbon/human/source, mob/living/target, heavy = FALSE) + gorilla_play_impact(source, target, impact_state) + play_pump() + if(heavy_moves) + source.mob_light(1.5, 0.6, "#ffcf8a", 0.2 SECONDS) + if(heavy) + var/turf/spot = get_turf(target) + if(spot) + new /obj/effect/temp_visual/circle_wave/cyberware_slam(spot, 0.4) + +/** + * Knuckle sparks over [target]: a short one-shot drawn upright and turned to come from the + * attacker's side, like cyberware_play_impact() but from this module's sheet. + */ +/proc/gorilla_play_impact(atom/movable/attacker, atom/movable/target, impact_state) + if(!impact_state || !ismovable(target)) + return null + var/obj/effect/abstract/cyberware_anim/impact = cyberware_anim_play(target, GORILLA_GORE_ICON, impact_state, GORILLA_ANIM_TOP_LAYER, 0.4 SECONDS, glow = impact_state == "impact_gorilla") + if(!impact) + return null + impact.appearance_flags |= RESET_TRANSFORM + var/direction = get_dir(attacker, target) + if((direction & WEST) || (!(direction & EAST) && prob(50))) + impact.transform = matrix(-1, 0, 0, 0, 1, 0) + return impact + +// ========================================================================= +// BONE BREAKERS +// ========================================================================= + +/** + * Strain on a body from piston hits: how many hard hits each limb has taken, and when the last one + * landed. A limb that goes [GORILLA_STRAIN_MEMORY] without a hit forgets its count. Expires on its own. + */ +/datum/status_effect/gorilla_strain + id = "gorilla_strain" + alert_type = null + duration = GORILLA_STRAIN_MEMORY + tick_interval = STATUS_EFFECT_NO_TICK + status_type = STATUS_EFFECT_REFRESH + /// Body zone -> hard hits counted + var/list/counts = list() + /// Body zone -> world.time of its last hard hit + var/list/last_hit = list() + +/// Counts one more hard hit on [zone]. Returns the count. +/datum/status_effect/gorilla_strain/proc/add(zone) + if(world.time - (last_hit[zone] || -INFINITY) > GORILLA_STRAIN_MEMORY) + counts[zone] = 0 + counts[zone] = (counts[zone] || 0) + 1 + last_hit[zone] = world.time + return counts[zone] + +/// Forgets [zone]'s count after it snapped. +/datum/status_effect/gorilla_strain/proc/clear(zone) + counts -= zone + last_hit -= zone + +/// A hard piston hit on [zone]: strains it, and snaps it when it has taken [snap_at] in the window. +/proc/gorilla_add_strain(mob/living/carbon/victim, zone, mob/living/attacker, snap_at, obj/item/source_item, direction) + if(zone == BODY_ZONE_HEAD) + return FALSE + var/datum/status_effect/gorilla_strain/strain = victim.has_status_effect(/datum/status_effect/gorilla_strain) + if(strain) + strain.refresh() + else + strain = victim.apply_status_effect(/datum/status_effect/gorilla_strain) + if(!strain) + return FALSE + if(strain.add(zone) < snap_at) + return FALSE + strain.clear(zone) + return gorilla_snap_limb(victim, victim.get_bodypart(zone), attacker, direction, source_item) + +/** + * Snaps [limb]: tg's compound fracture forced on an arm or a leg, or the ribs caved in on the chest. + * Heavy armour on the limb, a limb without bone (robotic) and the Atlas frame stop it. Returns TRUE + * if it snapped. + */ +/proc/gorilla_snap_limb(mob/living/carbon/victim, obj/item/bodypart/limb, mob/living/attacker, direction, obj/item/source_item) + if(isnull(limb) || limb.owner != victim || limb.body_zone == BODY_ZONE_HEAD) + return FALSE + if(hv_crit_cover_rating(victim, limb, MELEE) >= GORILLA_SNAP_ARMOUR) + return FALSE + if(!hv_crit_apply_wound(limb, /datum/wound/blunt/bone/critical, direction, source_item)) + return FALSE + if(limb.body_zone == BODY_ZONE_CHEST) + victim.hv_add_hit_line("[victim]'s ribs cave in!", HV_LINE_HUGE, "Your ribs cave in!", DEFAULT_MESSAGE_RANGE) + else + victim.hv_add_hit_line("The bone in [victim]'s [limb.plaintext_zone] snaps!", HV_LINE_HUGE, "The bone in your [limb.plaintext_zone] snaps!", DEFAULT_MESSAGE_RANGE) + playsound(victim, 'sound/effects/wounds/crack2.ogg', 80, TRUE, falloff_exponent = SOUND_FALLOFF_EXPONENT + 2, ignore_walls = FALSE, falloff_distance = 0) + cyberware_flash_fx(victim, 0.2 SECONDS) + var/turf/spot = get_turf(victim) + if(spot) + new /obj/effect/temp_visual/circle_wave/cyberware_slam(spot, 0.5) + log_combat(attacker, victim, "snapped the [limb.plaintext_zone] of", source_item, "(gorilla arms)") + return TRUE + +// ========================================================================= +// GRAB AND SLAM +// ========================================================================= + +/** + * With someone in an aggressive grab, a click on the floor under them, an open floor beside the bearer + * or a table brings them down onto it. Returns GORILLA_SLAM_*. + */ +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/try_slam(mob/living/carbon/human/source, atom/target, obj/item/bodypart/active_hand) + var/mob/living/victim = source.pulling + if(!isliving(victim) || source.grab_state < GRAB_AGGRESSIVE || victim == source) + return GORILLA_SLAM_NO + var/obj/structure/table/table = istype(target, /obj/structure/table) ? target : null + if(!table && !isturf(target)) + return GORILLA_SLAM_NO + if(!heavy_moves || !gorilla_may_use_heavy_moves(source) || HAS_TRAIT(source, TRAIT_PACIFISM) || victim.buckled || source.body_position == LYING_DOWN) + return table ? GORILLA_SLAM_PASS : GORILLA_SLAM_NO + if(!TIMER_COOLDOWN_FINISHED(source, COOLDOWN_GORILLA_GRAB_MOVE)) + return table ? GORILLA_SLAM_PASS : GORILLA_SLAM_NO + var/turf/victim_spot = get_turf(victim) + var/turf/spot = table ? get_turf(table) : target + if(!source.Adjacent(spot)) + return GORILLA_SLAM_NO + if(!table && !gorilla_slam_floor_ok(spot)) + return GORILLA_SLAM_NO + source.stop_pulling() + if(spot != victim_spot) + var/passtable_key = REF(source) + if(table) + passtable_on(victim, passtable_key) + victim.Move(spot, get_dir(victim, spot)) + if(table) + passtable_off(victim, passtable_key) + if(victim.loc != spot) + // Something stopped them getting there: no slam, but the grab is gone + return table ? GORILLA_SLAM_PASS : GORILLA_SLAM_NO + gorilla_cooldown(source, COOLDOWN_GORILLA_GRAB_MOVE, GORILLA_GRAB_MOVE_COOLDOWN) + source.changeNext_move(CLICK_CD_MELEE) + source.face_atom(victim) + // Up, then down: the blow lands as the body comes down. + animate(victim, pixel_z = 12, time = 0.12 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(pixel_z = 0, time = 0.06 SECONDS, flags = ANIMATION_RELATIVE) + animate(pixel_z = -12, time = 0.06 SECONDS, easing = SINE_EASING | EASE_IN, flags = ANIMATION_RELATIVE) + source.do_attack_animation(victim, ATTACK_EFFECT_SMASH) + addtimer(CALLBACK(src, PROC_REF(slam_down), source, victim, spot, table ? WEAKREF(table) : null, active_hand), 0.24 SECONDS) + return GORILLA_SLAM_DONE + +/** + * Slams whoever [bearer] holds in an aggressive grab onto [onto] (their own tile, an open floor beside the + * bearer, or a table) with the active arm, as a click there would. Returns TRUE if it slammed. For an AI. + */ +/proc/gorilla_try_slam(mob/living/carbon/human/bearer, atom/onto) + var/obj/item/bodypart/hand = bearer?.get_active_hand() + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) + if(arm.zone != hand?.body_zone || !arm.gorilla_running() || !COOLDOWN_FINISHED(arm, slam_cooldown)) + continue + if(arm.try_slam(bearer, onto, hand) != GORILLA_SLAM_DONE) + return FALSE + COOLDOWN_START(arm, slam_cooldown, arm.punch_cooldown_time) + return TRUE + return FALSE + +/// Whether a body can be slammed down on [spot]: solid floor with gravity and nothing standing in the way. +/proc/gorilla_slam_floor_ok(turf/spot) + if(!isfloorturf(spot) || isgroundlessturf(spot) || !spot.has_gravity()) + return FALSE + for(var/obj/thing in spot) + if(thing.density && !(thing.flags_1 & ON_BORDER_1)) + return FALSE + return TRUE + +/// The slam lands, if the body is still where it was brought down. +/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/slam_down(mob/living/carbon/human/source, mob/living/victim, turf/spot, datum/weakref/table_ref, obj/item/bodypart/active_hand) + if(QDELETED(source) || QDELETED(victim) || victim.loc != spot || owner != source) + return + var/obj/structure/table/table = table_ref?.resolve() + if(table && table.loc != spot) + table = null + var/aimed = source.zone_selected + var/obj/item/bodypart/limb = victim.get_bodypart(check_zone(aimed)) || victim.get_bodypart(BODY_ZONE_CHEST) + var/face_first = check_zone(aimed) == BODY_ZONE_HEAD + var/damage = table ? GORILLA_SLAM_TABLE_DAMAGE : GORILLA_SLAM_FLOOR_DAMAGE + var/stamina = table ? GORILLA_SLAM_TABLE_STAMINA : GORILLA_SLAM_FLOOR_STAMINA + var/others + var/self + if(table) + others = face_first ? "[source] slams [victim] face first into [table]!" : "[source] slams [victim] onto [table]!" + self = face_first ? "[source] slams you face first into [table]!" : "[source] slams you onto [table]!" + else + others = "[source] slams [victim] into the floor!" + self = "[source] slams you into the floor!" + victim.hv_hold_attack_message(others, self, null, source, vision_range = DEFAULT_MESSAGE_RANGE) + var/armour = victim.run_armor_check(limb, MELEE) + victim.apply_damage(damage, BRUTE, limb || BODY_ZONE_CHEST, armour, wound_bonus = GORILLA_PUNCH_WOUND_BONUS, attack_direction = get_dir(source, victim) || source.dir, attacking_item = active_hand) + victim.hv_flush_hit() + if(QDELETED(victim)) + return + victim.apply_damage(stamina, STAMINA) + victim.Knockdown(GORILLA_SLAM_KNOCKDOWN) + gorilla_register_aggression(victim, source) + shake_camera(victim, 3, 2) + cyberware_flash_fx(victim, 0.2 SECONDS) + new /obj/effect/temp_visual/circle_wave/cyberware_slam(spot, 0.8) + if(table) + playsound(victim, 'sound/effects/tableslam.ogg', 90, TRUE) + var/datum/component/table_smash/smash = table.GetComponent(/datum/component/table_smash) + table.take_damage(GORILLA_SLAM_TABLE_BREAK) + // Glass tables break as they do under any slammed body + if(!QDELETED(table)) + smash?.after_smash?.Invoke(victim) + else + playsound(spot, 'sound/effects/meteorimpact.ogg', 40, TRUE) + new /obj/effect/temp_visual/cyberware_impact_cracks(spot, "damaged1", 220) + cyberware_grey_dust(spot) + play_pump() + log_combat(source, victim, table ? "piston-slammed onto a table" : "piston-slammed into the floor", "gorilla arms") diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm new file mode 100644 index 00000000000..3bec171ce8d --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm @@ -0,0 +1,423 @@ +/** + * # Gorilla throws and wall impacts + * + * Every body the Gorilla Arms send flying (the one-tile shove, the hurl, the haymaker) takes one + * safe path: gorilla_plan_knockback() walks the tiles first and stops the body on the last good one + * before space, open air, a chasm, lava, a tile with no gravity, a window, a door, a railing or a + * grille. It never launches at all from a tile with no gravity. A wall or a standing body in the way + * is hit, as tg would. + * + * Along the way the body leaves a scuff and a little dust on each tile, or a blood streak if it bleeds, + * at most GORILLA_TRAIL_MAX of them. + * + * Any hard throw into a wall (a Gorilla's or anyone else's) leaves a mark chosen by the injury the body + * carries when it hits (owner D38): no broken skin dents the wall and knocks chips off it; a bleeding + * cut or a compound fracture splashes a half-moon on the floor and smears the wall's edge; heavy + * bleeding or a torn artery sprays up the wall and runs. The body is pinned to the wall for a moment + * and slides down it. A haymaker always dents the wall as well, and adds its own damage and a cracked + * chest. + */ + +/mob/living + /// The Gorilla throw this mob is flying in, if any (hyperviolence_gorilla). Cleared when it lands. + var/datum/gorilla_flight/gorilla_flight + +/// One Gorilla throw in flight: who threw it, what kind, and what it has left on the floor. +/datum/gorilla_flight + /// The body in the air + var/mob/living/victim + /// Who threw it + var/mob/living/thrower + /// TRUE for a haymaker: extra damage, a cracked chest and a dent on a wall + var/haymaker = FALSE + /// Decals left so far + var/trail_left = 0 + /// The punch that launched it broke the skin: the trail is blood even if nothing bled before + var/wounded = FALSE + /// Safety timer that ends the flight if the landing signal never comes + var/expiry_timer + +/datum/gorilla_flight/New(mob/living/victim, mob/living/thrower, haymaker = FALSE, wounded = FALSE) + . = ..() + src.victim = victim + src.thrower = thrower + src.haymaker = haymaker + src.wounded = wounded + victim.gorilla_flight = src + RegisterSignal(victim, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(victim, COMSIG_MOVABLE_THROW_LANDED, PROC_REF(on_landed)) + RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_victim_deleted)) + expiry_timer = addtimer(CALLBACK(src, PROC_REF(end)), 3 SECONDS, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/datum/gorilla_flight/Destroy() + if(expiry_timer) + deltimer(expiry_timer) + expiry_timer = null + if(victim) + UnregisterSignal(victim, list(COMSIG_MOVABLE_MOVED, COMSIG_MOVABLE_THROW_LANDED, COMSIG_QDELETING)) + if(victim.gorilla_flight == src) + victim.gorilla_flight = null + victim = null + thrower = null + return ..() + +/// Ends the flight. Safe to call more than once. +/datum/gorilla_flight/proc/end() + expiry_timer = null + if(!QDELETED(src)) + qdel(src) + +/datum/gorilla_flight/proc/on_victim_deleted(datum/source) + SIGNAL_HANDLER + end() + +/// Whether the body leaves blood rather than a scuff. +/datum/gorilla_flight/proc/bleeds() + if(victim.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return FALSE + if(wounded) + return TRUE + if(!iscarbon(victim)) + return FALSE + var/mob/living/carbon/body = victim + return body.get_bleed_rate() > 0 + +/// Each tile left behind gets a scuff and dust, or a streak of blood. +/datum/gorilla_flight/proc/on_moved(atom/movable/source, atom/old_loc, dir, forced, list/old_locs) + SIGNAL_HANDLER + if(trail_left >= GORILLA_TRAIL_MAX || !isturf(old_loc) || !victim.throwing) + return + var/turf/left = old_loc + if(!isfloorturf(left) || !left.has_gravity()) + return + trail_left++ + var/travel = get_dir(old_loc, victim) || dir + if(bleeds()) + hv_blood_lay_spray(left, /obj/effect/decal/cleanable/blood/hv_spray/streak, travel, victim.get_blood_dna_list(), victim.get_static_viruses()) + return + new /obj/effect/temp_visual/gorilla_scuff(left, travel) + var/obj/effect/temp_visual/mook_dust/dust = cyberware_grey_dust(left) + if(dust) + dust.transform = matrix().Scale(0.5) + +/// Where it skids to a stop: a splash if it bleeds. +/datum/gorilla_flight/proc/on_landed(datum/source, datum/thrownthing/throwing_datum) + SIGNAL_HANDLER + var/turf/stop = get_turf(victim) + if(isfloorturf(stop) && stop.has_gravity() && bleeds() && trail_left < GORILLA_TRAIL_MAX) + hv_blood_lay_spray(stop, /obj/effect/decal/cleanable/blood/hv_spray, throwing_datum?.init_dir || victim.dir, victim.get_blood_dna_list(), victim.get_static_viruses()) + end() + +/// The grey drag mark a launched body leaves on the floor. It fades on its own. +/obj/effect/temp_visual/gorilla_scuff + icon = GORILLA_GORE_ICON + icon_state = "scuff" + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER + randomdir = FALSE + duration = 8 SECONDS + +/obj/effect/temp_visual/gorilla_scuff/Initialize(mapload, travel_dir) + . = ..() + setDir(travel_dir || SOUTH) + animate(src, alpha = 255, time = duration - 3 SECONDS) + animate(alpha = 0, time = 3 SECONDS) + +// ========================================================================= +// THE SAFE PATH +// ========================================================================= + +/** + * Whether a body flying from [here] into [next] must stop short on [here] without hitting anything: + * the edge of the map, a hole, lava, open air, space, no gravity, or glass, a door, a railing or a + * grille in the way (the body would break the glass or the door would take the hit). + */ +/proc/gorilla_throw_stops_short(turf/here, turf/next, direction) + if(isnull(next)) + return TRUE + if(isspaceturf(next) || isopenspaceturf(next) || ischasm(next) || islava(next) || isgroundlessturf(next)) + return TRUE + if(!isclosedturf(next) && !next.has_gravity()) + return TRUE + // Something on the edge of our own tile, facing the way we fly + for(var/obj/border in here) + if(!border.density || !(border.flags_1 & ON_BORDER_1) || !(border.dir & direction)) + continue + if(gorilla_throw_is_barrier(border)) + return TRUE + for(var/obj/thing in next) + if(!thing.density) + continue + if((thing.flags_1 & ON_BORDER_1) && !(thing.dir & REVERSE_DIR(direction))) + continue + if(gorilla_throw_is_barrier(thing)) + return TRUE + return FALSE + +/// Glass, doors, railings and grilles: a flying body stops before them rather than going through or breaking them. +/proc/gorilla_throw_is_barrier(obj/thing) + return istype(thing, /obj/structure/window) || istype(thing, /obj/machinery/door) || istype(thing, /obj/structure/railing) || istype(thing, /obj/structure/grille) || istype(thing, /obj/structure/falsewall) + +/** + * Plans a knockback of [victim] up to [range] tiles along [direction]. Returns a list: + * (the turf to throw at, how many tiles it travels, GORILLA_THROW_* why it stops, what it hits or null). + * A distance of 0 means no throw at all. + */ +/proc/gorilla_plan_knockback(mob/living/victim, direction, range) + var/turf/here = get_turf(victim) + if(isnull(here) || !direction || range <= 0) + return list(here, 0, GORILLA_THROW_SHORT, null) + if(!here.has_gravity()) + return list(here, 0, GORILLA_THROW_SHORT, null) + var/turf/current = here + for(var/step in 1 to range) + var/turf/next = get_step(current, direction) + if(isnull(next)) + return list(current, step - 1, GORILLA_THROW_SHORT, null) + if(isclosedturf(next)) + // The wall is hit: throw at it and the body stops against it + return list(next, step, GORILLA_THROW_WALL, next) + if(gorilla_throw_stops_short(current, next, direction)) + return list(current, step - 1, GORILLA_THROW_SHORT, null) + for(var/mob/living/standing in next) + if(standing.density && standing != victim) + return list(next, step, GORILLA_THROW_MOB, standing) + current = next + return list(current, range, GORILLA_THROW_CLEAR, null) + +/** + * Throws [victim] along [direction] on the safe path. [haymaker] marks a haymaker's flight; [wounded] + * makes the trail blood. Returns the distance it will travel, 0 if it doesn't move. + */ +/proc/gorilla_launch(mob/living/victim, mob/living/thrower, direction, range, speed = 2, gentle = FALSE, haymaker = FALSE, wounded = FALSE) + if(QDELETED(victim) || victim.anchored || victim.buckled || victim.move_resist == INFINITY) + return 0 + var/list/plan = gorilla_plan_knockback(victim, direction, range) + var/turf/target = plan[1] + var/distance = plan[2] + if(distance <= 0 || isnull(target)) + return 0 + // Megafauna and anything bolted down shrug it off, as safe_throw_at() decides + if(MOVE_FORCE_STRONG < victim.move_resist * MOVE_FORCE_THROW_RATIO) + return 0 + if(victim.gorilla_flight) + qdel(victim.gorilla_flight) + var/datum/gorilla_flight/flight = new(victim, thrower, haymaker, wounded) + if(!victim.throw_at(target, distance, speed, thrower, spin = !gentle, gentle = gentle)) + flight.end() + return 0 + return distance + +// ========================================================================= +// WALL IMPACTS (owner D38) +// ========================================================================= + +/// The wounds on a body right now, with their severities, to tell what a hit added. +/mob/living/carbon/proc/gorilla_wound_snapshot() + . = list() + for(var/datum/wound/wound as anything in all_wounds) + .[wound] = wound.severity + +/** + * Which picture a wall impact leaves, from the injury the body carries as it hits (owner D38): + * GORILLA_SPLAT_SPRAY for a heavy cut or stab or a torn artery, GORILLA_SPLAT_EDGE for a bleeding + * cut or stab or a compound fracture, else GORILLA_SPLAT_DENT. A body that can't bleed only dents. + */ +/mob/living/carbon/proc/gorilla_splat_kind() + if(can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return GORILLA_SPLAT_DENT + var/kind = GORILLA_SPLAT_DENT + for(var/datum/wound/wound as anything in all_wounds) + var/series = wound.get_pregen_data()?.wound_series + // A torn aorta or throat (#509), or a limb artery torn by a blade + if(series == WOUND_SERIES_HV_AORTA || series == WOUND_SERIES_HV_CAROTID || series == "wound_series_hv_limb_artery") + return GORILLA_SPLAT_SPRAY + if(istype(wound, /datum/wound/slash) || istype(wound, /datum/wound/pierce)) + if(wound.severity >= WOUND_SEVERITY_SEVERE) + return GORILLA_SPLAT_SPRAY + if(wound.severity >= WOUND_SEVERITY_MODERATE) + kind = GORILLA_SPLAT_EDGE + else if(istype(wound, /datum/wound/blunt/bone) && wound.severity >= WOUND_SEVERITY_CRITICAL) + // A compound fracture breaks the skin + kind = GORILLA_SPLAT_EDGE + return kind + +/mob/living/carbon/throw_impact(atom/hit_atom, datum/thrownthing/throwingdatum) + var/datum/gorilla_flight/flight = gorilla_flight + var/hard_wall = isclosedturf(hit_atom) && hit_atom.density && istype(throwingdatum) && !throwingdatum.gentle + . = ..() + if(!hard_wall || QDELETED(src)) + return + gorilla_wall_impact(hit_atom, flight) + +/** + * A hard landing against [wall]: the haymaker's extra, the pinned slide down the wall and the marks. + */ +/mob/living/carbon/proc/gorilla_wall_impact(turf/wall, datum/gorilla_flight/flight) + var/turf/floor = get_turf(src) + var/toward = get_dir(floor, wall) + if(!toward) + return + if(flight?.haymaker) + gorilla_haymaker_wall_hit(flight) + gorilla_wall_sag(toward) + var/kind = gorilla_splat_kind() + // Only a straight hit on a wall face gets the pictures; a glancing corner hit gets the dent. + if(!(toward in GLOB.cardinals)) + gorilla_wall_dent(wall, toward) + return + switch(kind) + if(GORILLA_SPLAT_SPRAY) + gorilla_lay_wall_decal(floor, /obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray, toward) + gorilla_lay_wall_decal(floor, /obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/runs, toward) + if(GORILLA_SPLAT_EDGE) + gorilla_lay_wall_decal(floor, /obj/effect/decal/cleanable/blood/hv_spray/gorilla_splash, toward) + gorilla_lay_wall_decal(floor, /obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/edge, toward) + else + gorilla_wall_chips(floor, toward) + if(kind == GORILLA_SPLAT_DENT || flight?.haymaker) + gorilla_wall_dent(wall, toward) + +/// The haymaker's body hits the wall harder: more damage and a cracked chest unless armour stops it. +/mob/living/carbon/proc/gorilla_haymaker_wall_hit(datum/gorilla_flight/flight) + var/obj/item/bodypart/chest = get_bodypart(BODY_ZONE_CHEST) + if(isnull(chest)) + return + var/armour = run_armor_check(chest, MELEE) + apply_damage(GORILLA_HAYMAKER_WALL_DAMAGE, BRUTE, chest, armour, wound_bonus = CANT_WOUND) + if(hv_crit_cover_rating(src, chest, MELEE) >= GORILLA_SNAP_ARMOUR) + return + if(hv_crit_apply_wound(chest, /datum/wound/blunt/bone/severe, get_dir(flight?.thrower || src, src), flight?.thrower)) + hv_add_hit_line("[src]'s ribs crack against the wall!", HV_LINE_BOLD, "Your ribs crack against the wall!") + +/// Pinned to the wall for a beat, then sliding down it. Every offset is a tracked source tg removes. +/mob/living/proc/gorilla_wall_sag(toward) + var/dx = (toward & EAST) ? 5 : ((toward & WEST) ? -5 : 0) + var/dy = (toward & NORTH) ? 7 : ((toward & SOUTH) ? -3 : 0) + add_offsets(GORILLA_WALL_SAG, x_add = dx, z_add = dy, animate = FALSE) + addtimer(CALLBACK(src, TYPE_PROC_REF(/mob/living, remove_offsets), GORILLA_WALL_SAG, TRUE), 0.15 SECONDS) + +/// Lays (or refreshes) one wall-impact decal on the floor tile in front of the wall. +/mob/living/carbon/proc/gorilla_lay_wall_decal(turf/floor, decal_type, toward) + if(!isturf(floor) || isclosedturf(floor) || isgroundlessturf(floor)) + return null + var/list/blood_dna = get_blood_dna_list() + var/list/viruses = get_static_viruses() + for(var/obj/effect/decal/cleanable/blood/hv_spray/existing in floor) + if(existing.type != decal_type || existing.dir != toward || existing.dried || QDELETED(existing)) + continue + existing.add_blood_DNA(blood_dna, viruses) + return existing + var/obj/effect/decal/cleanable/blood/hv_spray/fresh = new decal_type(floor, viruses, blood_dna) + if(QDELETED(fresh)) + return null + fresh.hv_aim(toward) + return fresh + +/// Chips knocked off the wall where the body hit it. +/mob/living/carbon/proc/gorilla_wall_chips(turf/floor, toward) + var/obj/effect/abstract/gorilla_chips/chips = new(floor) + chips.aim(toward) + +/// A body-sized dent in a wall, kept with the wall's bullet holes and dents so a welder repairs it. +/proc/gorilla_wall_dent(turf/closed/wall/wall, toward) + if(!istype(wall) || LAZYLEN(wall.dent_decals) >= MAX_DENT_DECALS) + return FALSE + if(!(toward in GLOB.cardinals)) + toward = (toward & (EAST|WEST)) || (toward & (NORTH|SOUTH)) + var/mutable_appearance/dent = mutable_appearance(GORILLA_GORE_ICON, "wall_dent_[dir2text(toward)]", BULLET_HOLE_LAYER) + if(LAZYLEN(wall.dent_decals)) + wall.cut_overlay(wall.dent_decals) + wall.dent_decals += dent + else + wall.dent_decals = list(dent) + wall.add_overlay(wall.dent_decals) + return TRUE + +/// A short burst of grey chips off the contact point. +/obj/effect/abstract/gorilla_chips + name = "chips" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + layer = ABOVE_MOB_LAYER + +/obj/effect/abstract/gorilla_chips/Initialize(mapload) + . = ..() + particles = new /particles/gorilla_chips + addtimer(CALLBACK(src, PROC_REF(stop)), 0.2 SECONDS) + QDEL_IN(src, 1 SECONDS) + +/// Throws the chips back off the wall at [toward]. +/obj/effect/abstract/gorilla_chips/proc/aim(toward) + var/dx = (toward & EAST) ? 1 : ((toward & WEST) ? -1 : 0) + var/dy = (toward & NORTH) ? 1 : ((toward & SOUTH) ? -1 : 0) + pixel_x = dx * 14 + pixel_y = dy * 14 + var/particles/gorilla_chips/burst = particles + burst.velocity = generator(GEN_BOX, list(-dx * 3 - 1.5, -dy * 3 - 1.5, 0), list(-dx * 1 + 1.5, -dy * 1 + 1.5, 0), UNIFORM_RAND) + +/obj/effect/abstract/gorilla_chips/proc/stop() + if(particles) + particles.spawning = 0 + +/particles/gorilla_chips + icon = 'icons/effects/particles/generic.dmi' + icon_state = list("dot" = 3, "cross" = 1) + color = "#8a8e96" + count = 12 + spawning = 6 + lifespan = 0.5 SECONDS + fade = 0.25 SECONDS + position = generator(GEN_BOX, list(-4, -4, 0), list(4, 4, 0), UNIFORM_RAND) + velocity = generator(GEN_BOX, list(-2, -2, 0), list(2, 2, 0), UNIFORM_RAND) + gravity = list(0, -0.4) + friction = 0.1 + scale = 0.5 + +// ---- The decals. Greyscale art tinted to the victim's blood, on the floor tile in front of the wall. ---- + +/// Option C: a half-moon splash on the floor at the foot of the wall. Mops like floor blood. +/obj/effect/decal/cleanable/blood/hv_spray/gorilla_splash + name = "blood splatter" + icon = GORILLA_GORE_LARGE_ICON + icon_state = "impact_splash" + random_icon_states = null + bloodiness = 20 + +/obj/effect/decal/cleanable/blood/hv_spray/gorilla_splash/hv_aim(spray_dir) + setDir(spray_dir) + pixel_x = -16 + pixel_y = -16 + +/// Wall pictures: 64x64 on the floor tile at -16,-16, drawn up onto the wall's near edge. +/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray + name = "blood spray" + icon = GORILLA_GORE_LARGE_ICON + icon_state = "impact_spray" + random_icon_states = null + alpha = 230 + +/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/hv_aim(spray_dir) + setDir(spray_dir) + pixel_x = -16 + pixel_y = -16 + +/// Option A's runs down the wall face. They crawl down as they appear. +/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/runs + name = "blood runs" + icon_state = "impact_runs" + alpha = 220 + +/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/runs/hv_aim(spray_dir) + . = ..() + // The runs grow downward from where the spray hit. Parallel: tg's drying animation keeps running. + var/matrix/start = matrix() + start.Scale(1, 0.25) + start.Translate(0, 16) + transform = start + animate(src, transform = matrix(), time = 3 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + +/// Option C's thin band on the wall's edge where the body slid down it. +/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/edge + name = "blood smear" + icon_state = "impact_edge" + alpha = 220 diff --git a/voidcrew/modules/hyperviolence_gorilla/hv_finisher_chrome.dm b/voidcrew/modules/hyperviolence_gorilla/hv_finisher_chrome.dm new file mode 100644 index 00000000000..5d6be1532de --- /dev/null +++ b/voidcrew/modules/hyperviolence_gorilla/hv_finisher_chrome.dm @@ -0,0 +1,134 @@ +/** + * # Chrome finishers: #509's finisher engine for a player's bare hands + * + * #509's /datum/hv_finisher was built for bosses with a weapon in hand and NPC factions. A chrome + * finisher (a player's or a boss's own arms) changes four things, and nothing else: + * - No weapon needed: the start and the safety check never ask for one. + * - Anyone can be finished, as with a gun execution: two players share a faction, so the faction test + * only stops an NPC finishing its own side. Players and boss-rank NPCs only (hv_can_execute()). + * - Down means in crit or stunned, never just lying there. A victim whose stun wears off before the + * kill twists free. + * - No red mark over the victim (owner D34): the wind-up is the two bodies. + * - The one doing it can't walk away mid-way: any step lets go. + * + * Kept in its own file so the other chrome finishers (Mantis, monowire) can share it. + */ + +/mob/living + /// The chrome finisher this mob is doing now, if any. + var/datum/hv_finisher/chrome/chrome_finisher + +/datum/hv_finisher/chrome + name = "chrome finisher" + interrupt_damage = GORILLA_FINISHER_INTERRUPT + telegraph_state = null + /// Set when the victim came out of their stun before the kill + var/twisted_free = FALSE + +/datum/hv_finisher/chrome/Destroy() + if(user?.chrome_finisher == src) + user.chrome_finisher = null + return ..() + +/datum/hv_finisher/chrome/start() + . = ..() + if(. && user) + user.chrome_finisher = src + +/datum/hv_finisher/chrome/can_start() + if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED) || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED)) + return FALSE + if(HAS_TRAIT(user, TRAIT_PACIFISM) || !user_may_finish()) + return FALSE + if(user.chrome_finisher && user.chrome_finisher != src) + return FALSE + if(!chrome_finisher_valid_victim(user, victim)) + return FALSE + return get_dist(user, victim) <= 1 && user.Adjacent(victim) + +/// Who may do this finisher at all. Players and boss-rank NPCs, as with gun executions; a chrome line can route +/// this through its own gate. +/datum/hv_finisher/chrome/proc/user_may_finish() + return user.hv_can_execute() + +/datum/hv_finisher/chrome/safety_check() + if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED)) + return HV_FINISHER_ATTACKER_DOWN + if(QDELETED(victim)) + return HV_FINISHER_VICTIM_GONE + if(!completed && victim.stat == DEAD) + return HV_FINISHER_VICTIM_GONE + if(!isturf(user.loc) || !isturf(victim.loc) || user.z != victim.z || get_dist(user, victim) > 1) + return HV_FINISHER_LOST_GRIP + if(!victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return HV_FINISHER_LOST_GRIP + if(completed) + return 0 + if(HAS_TRAIT(user, TRAIT_HANDS_BLOCKED)) + return HV_FINISHER_ATTACKER_DOWN + if(gripped && (user.pulling != victim || user.grab_state < GRAB_AGGRESSIVE)) + return HV_FINISHER_LOST_GRIP + if(!chrome_finisher_is_down(victim)) + twisted_free = TRUE + return HV_FINISHER_LOST_GRIP + return 0 + +// The one doing it takes a step, is shoved or is thrown: it lets go. The victim moving is #509's rule. +/datum/hv_finisher/chrome/on_moved(datum/source) + SIGNAL_HANDLER + if(source == user && running) + end_early(HV_FINISHER_LOST_GRIP) + return + return ..() + +// No mark (owner D34). +/datum/hv_finisher/chrome/place_mark(duration) + return + +/datum/hv_finisher/chrome/interrupt(reason) + if(running && !completed && reason == HV_FINISHER_LOST_GRIP && twisted_free && !QDELETED(victim) && !QDELETED(user)) + for(var/datum/hv_finisher_stage/stage as anything in stages) + if(stage.entered) + stage.on_interrupt() + victim.visible_message(span_danger("[victim] twists free of [user]'s grip!"), span_userdanger("You twist free of [user]'s grip!")) + stop(reason) + return + return ..() + +/datum/hv_finisher/chrome/stop(reason) + if(!running) + return + . = ..() + if(user?.chrome_finisher == src) + user.chrome_finisher = null + // Nothing owns a chrome finisher but its own timers, and its stages point back at it: it has to delete + // itself once it is over, or the pair would hold both bodies forever. + if(!QDELETED(src)) + qdel(src) + +/** + * Whether [user] may start a chrome finisher on [victim]: a humanoid body that is alive, in crit or + * stunned, on the floor beside them, not strapped to anything and not already being finished. A player + * can finish anyone, as with a gun execution; an NPC never finishes its own side. + */ +/proc/chrome_finisher_valid_victim(mob/living/user, mob/living/carbon/victim) + if(!iscarbon(victim) || !isliving(user) || QDELETED(victim) || QDELETED(user) || victim == user) + return FALSE + if(!ishuman(victim) && !(victim.mob_biotypes & MOB_HUMANOID)) + return FALSE + if(victim.stat == DEAD || HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + if(!chrome_finisher_is_down(victim)) + return FALSE + if(victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return FALSE + if(victim.buckled || !isturf(victim.loc) || !isturf(user.loc) || victim.z != user.z) + return FALSE + // An NPC (anything run by an AI) never finishes its own side + if(user.ai_controller && user.faction_check_atom(victim)) + return FALSE + return TRUE + +/// Down, for a chrome finisher: in crit, or stunned. Lying there isn't enough. +/proc/chrome_finisher_is_down(mob/living/victim) + return victim.stat >= SOFT_CRIT || HAS_TRAIT(victim, TRAIT_INCAPACITATED) diff --git a/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore.dmi b/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore.dmi new file mode 100644 index 0000000000000000000000000000000000000000..c152f8f1e6f0913297052aca37a94c72fd613b25 GIT binary patch literal 10014 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 its type before the test changed it. Put back when the test ends, however it ends. + var/list/changed_turfs + /// The outpost PvP enforcement a test swapped out, put back when it ends + var/datum/outpost_pvp_enforcement/real_enforcement + +/datum/unit_test/voidcrew_gorilla_gore/Destroy() + for(var/turf/changed as anything in changed_turfs) + clear_mess(changed) + changed.ChangeTurf(changed_turfs[changed]) + changed_turfs = null + if(real_enforcement) + GLOB.outpost_pvp_enforcement = real_enforcement + real_enforcement = null + return ..() + +/// Changes [target] to [new_type], remembering what it was so the test puts it back +/datum/unit_test/voidcrew_gorilla_gore/proc/change_turf(turf/target, new_type) + LAZYINITLIST(changed_turfs) + if(!changed_turfs[target]) + changed_turfs[target] = target.type + clear_mess(target) + return target.ChangeTurf(new_type) + +/// Puts a changed turf back now +/datum/unit_test/voidcrew_gorilla_gore/proc/restore_turf(turf/target) + var/old_type = changed_turfs?[target] + if(!old_type) + return + changed_turfs -= target + clear_mess(target) + target.ChangeTurf(old_type) + +/// Blood and other decals listen on their turf; a turf changed under them keeps the listener and the +/// decal can never be deleted cleanly. So the floor is wiped before it changes. +/datum/unit_test/voidcrew_gorilla_gore/proc/clear_mess(turf/target) + for(var/obj/effect/decal/cleanable/mess in target) + qdel(mess) + +/// The floor `dx`, `dy` tiles from the test room's bottom-left corner +/datum/unit_test/voidcrew_gorilla_gore/proc/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 human with a pair of Gorilla Arms (or Brawler myomers with [street]) at `where`, in combat mode +/datum/unit_test/voidcrew_gorilla_gore/proc/gorilla(turf/where, street = FALSE) + var/mob/living/carbon/human/consistent/bearer = allocate(/mob/living/carbon/human/consistent, where) + var/right_type = street ? /obj/item/organ/cyberimp/cyberware/gorilla_arms/street : /obj/item/organ/cyberimp/cyberware/gorilla_arms + var/left_type = street ? /obj/item/organ/cyberimp/cyberware/gorilla_arms/street/left : /obj/item/organ/cyberimp/cyberware/gorilla_arms/left + var/obj/item/organ/right = allocate(right_type) + var/obj/item/organ/left = allocate(left_type) + TEST_ASSERT(right.Insert(bearer, special = TRUE), "Test fixture: the right arm wouldn't go in") + TEST_ASSERT(left.Insert(bearer, special = TRUE), "Test fixture: the left arm wouldn't go in") + bearer.set_combat_mode(TRUE) + return bearer + +/// The bearer's arm on [zone] +/datum/unit_test/voidcrew_gorilla_gore/proc/arm(mob/living/carbon/bearer, zone = BODY_ZONE_R_ARM) + for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm in bearer.organs) + if(arm.zone == zone) + return arm + +/// One punch from the right arm at [target], with the arm's own clock reset first +/datum/unit_test/voidcrew_gorilla_gore/proc/punch(mob/living/carbon/human/bearer, atom/target, zone = BODY_ZONE_R_ARM) + var/obj/item/organ/cyberimp/cyberware/gorilla_arms/fist = arm(bearer, zone) + bearer.swap_hand(zone == BODY_ZONE_L_ARM ? 1 : 2, silent = TRUE) + COOLDOWN_RESET(fist, slam_cooldown) + bearer.next_move = 0 + return fist.on_punch(bearer, target, TRUE, null) + +/// A human at `where`, stunned flat +/datum/unit_test/voidcrew_gorilla_gore/proc/downed(turf/where) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, where) + victim.Paralyze(100 SECONDS) + return victim + +/// Runs a finisher to its end through advance(), as its timer would. +/datum/unit_test/voidcrew_gorilla_gore/proc/run_to_end(datum/hv_finisher/finisher) + for(var/i in 1 to 20) + if(!finisher.running) + return + finisher.advance() + +/// Every chrome puppet left anywhere in the test room +/datum/unit_test/voidcrew_gorilla_gore/proc/puppets_left() + . = 0 + for(var/turf/floor in block(run_loc_floor_bottom_left, run_loc_floor_top_right)) + for(var/obj/effect/abstract/chrome_puppet/puppet in floor) + .++ + +// ========================================================================= +// THE SAFE PATH +// ========================================================================= + +/// A knockback stops on the last good tile before space, glass and a door, hits a wall or a standing +/// body, and never launches from a tile without gravity. +/datum/unit_test/voidcrew_gorilla_gore/safe_path + +/datum/unit_test/voidcrew_gorilla_gore/safe_path/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(0, 0)) + // Clear floor: the full reach + var/list/plan = gorilla_plan_knockback(victim, EAST, 3) + TEST_ASSERT_EQUAL(plan[2], 3, "A clear floor didn't take the full knockback") + TEST_ASSERT_EQUAL(plan[1], spot(3, 0), "A clear knockback landed on the wrong tile") + + // Space two tiles out: stop on the tile before it + var/turf/hole = spot(2, 0) + change_turf(hole, /turf/open/space) + plan = gorilla_plan_knockback(victim, EAST, 4) + restore_turf(hole) + TEST_ASSERT_EQUAL(plan[2], 1, "A knockback went into space") + TEST_ASSERT_EQUAL(plan[3], 3, "A knockback into space didn't stop short") // GORILLA_THROW_SHORT + + // Glass in the way: stop before it, and nothing hits the glass + var/obj/structure/window/fulltile/glass = allocate(/obj/structure/window/fulltile, spot(2, 0)) + plan = gorilla_plan_knockback(victim, EAST, 4) + TEST_ASSERT_EQUAL(plan[2], 1, "A knockback went through a window") + TEST_ASSERT_EQUAL(plan[3], 3, "A knockback hit a window") // GORILLA_THROW_SHORT + qdel(glass) + + // A door: the same + var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, spot(1, 0)) + plan = gorilla_plan_knockback(victim, EAST, 4) + TEST_ASSERT_EQUAL(plan[2], 0, "A knockback went into a door") + qdel(door) + + // A wall: hit it + var/turf/wall = spot(2, 0) + change_turf(wall, /turf/closed/wall) + plan = gorilla_plan_knockback(victim, EAST, 4) + restore_turf(wall) + TEST_ASSERT_EQUAL(plan[3], 1, "A knockback into a wall didn't hit it") // GORILLA_THROW_WALL + TEST_ASSERT_EQUAL(plan[4], wall, "A knockback hit the wrong wall") + + // A standing body in the way: hit it + var/mob/living/carbon/human/consistent/bystander = allocate(/mob/living/carbon/human/consistent, spot(2, 0)) + plan = gorilla_plan_knockback(victim, EAST, 4) + TEST_ASSERT_EQUAL(plan[3], 2, "A knockback didn't hit the body in the way") // GORILLA_THROW_MOB + TEST_ASSERT_EQUAL(plan[4], bystander, "A knockback hit the wrong body") + qdel(bystander) + + // No gravity under the victim: no launch at all + var/turf/start = spot(0, 0) + change_turf(start, /turf/open/space) + plan = gorilla_plan_knockback(victim, EAST, 4) + restore_turf(start) + TEST_ASSERT_EQUAL(plan[2], 0, "A body was launched from a tile without gravity") + +// ========================================================================= +// THE PUNCH +// ========================================================================= + +/// The punch goes through #509 as one message, counts as aggression at an outpost, and the shove +/// rides the safe path. +/datum/unit_test/voidcrew_gorilla_gore/punch + +/datum/unit_test/voidcrew_gorilla_gore/punch/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(0, 0)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 0)) + real_enforcement = GLOB.outpost_pvp_enforcement + var/datum/outpost_pvp_enforcement/gorilla_spy/spy = new + GLOB.outpost_pvp_enforcement = spy + var/hurt_before = victim.getBruteLoss() + var/punched = punch(bearer, victim) + GLOB.outpost_pvp_enforcement = real_enforcement + real_enforcement = null + TEST_ASSERT_EQUAL(punched, COMPONENT_CANCEL_ATTACK_CHAIN, "The Gorilla arm didn't take the punch") + TEST_ASSERT(victim.getBruteLoss() > hurt_before, "The piston punch did no damage") + TEST_ASSERT_NULL(victim.hv_hit, "The punch left its attack line held") + TEST_ASSERT(length(spy.hits) && spy.hits[1] == victim, "A piston punch wasn't counted as aggression") + qdel(spy) + + // The shove never goes into a hazard (lava here: a space hole would vent the room and the wind would do it) + var/turf/hole = spot(2, 0) + victim.forceMove(spot(1, 0)) + victim.set_resting(FALSE, instant = TRUE) + change_turf(hole, /turf/open/lava/smooth) + punch(bearer, victim) + sleep(1 SECONDS) + var/landed_in = victim.loc + restore_turf(hole) + TEST_ASSERT_NOTEQUAL(landed_in, hole, "The piston punch knocked someone into lava") + +/// Records aggression instead of judging it +/datum/outpost_pvp_enforcement/gorilla_spy + var/list/hits = list() + +/datum/outpost_pvp_enforcement/gorilla_spy/New() + return + +/datum/outpost_pvp_enforcement/gorilla_spy/register_pvp_aggression(mob/living/victim, mob/living/offender) + hits += victim + +/// Three piston hits on one arm snap it (four from Brawler myomers); heavy armour or the Atlas frame stops it. +/datum/unit_test/voidcrew_gorilla_gore/bone_breakers + +/datum/unit_test/voidcrew_gorilla_gore/bone_breakers/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(0, 0)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 0)) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_L_ARM) + for(var/i in 1 to 2) + gorilla_add_strain(victim, BODY_ZONE_L_ARM, bearer, 3, null, EAST) + TEST_ASSERT_NULL(arm.get_wound_type(/datum/wound/blunt/bone/critical), "An arm snapped before the third hit") + TEST_ASSERT(gorilla_add_strain(victim, BODY_ZONE_L_ARM, bearer, 3, null, EAST), "The third piston hit didn't snap the arm") + TEST_ASSERT_NOTNULL(arm.get_wound_type(/datum/wound/blunt/bone/critical), "A snapped arm has no compound fracture") + TEST_ASSERT("snap_l_arm_bone" in victim.hv_gore_states() + gorilla_snap_states(victim), "A snapped arm shows no bone") + + // The head never snaps + for(var/i in 1 to 4) + TEST_ASSERT(!gorilla_add_strain(victim, BODY_ZONE_HEAD, bearer, 3, null, EAST), "A head snapped") + + // Brawler myomers take four + var/mob/living/carbon/human/consistent/second = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + for(var/i in 1 to 3) + TEST_ASSERT(!gorilla_add_strain(second, BODY_ZONE_R_LEG, bearer, 4, null, EAST), "A Brawler snapped a leg early") + TEST_ASSERT(gorilla_add_strain(second, BODY_ZONE_R_LEG, bearer, 4, null, EAST), "The fourth Brawler hit didn't snap the leg") + + // Heavy melee armour on the limb stops it + var/mob/living/carbon/human/consistent/armoured = allocate(/mob/living/carbon/human/consistent, spot(1, 2)) + armoured.physiology.armor = armoured.physiology.armor.generate_new_with_modifiers(list(MELEE = 60)) + for(var/i in 1 to 3) + gorilla_add_strain(armoured, BODY_ZONE_R_ARM, bearer, 3, null, EAST) + TEST_ASSERT_NULL(armoured.get_bodypart(BODY_ZONE_R_ARM).get_wound_type(/datum/wound/blunt/bone/critical), "Heavy armour didn't stop a snap") + + // So does the Atlas frame + var/mob/living/carbon/human/consistent/framed = allocate(/mob/living/carbon/human/consistent, spot(2, 2)) + var/obj/item/organ/atlas = allocate(/obj/item/organ/cyberimp/cyberware/atlas) + TEST_ASSERT(atlas.Insert(framed, special = TRUE), "Test fixture: the Atlas frame wouldn't go in") + for(var/i in 1 to 3) + gorilla_add_strain(framed, BODY_ZONE_R_ARM, bearer, 3, null, EAST) + TEST_ASSERT_NULL(framed.get_bodypart(BODY_ZONE_R_ARM).get_wound_type(/datum/wound/blunt/bone/critical), "The Atlas frame let an arm snap") + +/// The snapped-bone states drawn on [victim] now +/proc/gorilla_snap_states(mob/living/carbon/victim) + . = list() + for(var/image/snap as anything in victim.gorilla_snap_overlays) + . += snap.icon_state + +/// A grabbed body is hurled once per grab-move cooldown; the next grabbed punch holds them. +/datum/unit_test/voidcrew_gorilla_gore/hurl_cooldown + +/datum/unit_test/voidcrew_gorilla_gore/hurl_cooldown/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(0, 0)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 0)) + victim.grabbedby(bearer, supress_message = TRUE) + victim.grabbedby(bearer, supress_message = TRUE) + TEST_ASSERT(bearer.grab_state >= GRAB_AGGRESSIVE, "Test fixture: no aggressive grab") + punch(bearer, victim) + TEST_ASSERT(!TIMER_COOLDOWN_FINISHED(bearer, "gorilla_grab_move"), "A hurl didn't start the grab-move cooldown") // COOLDOWN_GORILLA_GRAB_MOVE + sleep(1 SECONDS) + TEST_ASSERT_NOTEQUAL(victim.loc, spot(1, 0), "A grabbed body wasn't hurled") + // Grab them again: no second hurl inside the cooldown + victim.forceMove(spot(1, 0)) + victim.SetAllImmobility(0) + victim.grabbedby(bearer, supress_message = TRUE) + victim.grabbedby(bearer, supress_message = TRUE) + punch(bearer, victim) + sleep(0.5 SECONDS) + TEST_ASSERT_EQUAL(victim.loc, spot(1, 0), "A grabbed body was hurled twice inside the cooldown") + +/// A click on the floor under a held body slams them into it; a table click slams them onto the table +/// instead of caving the table in. +/datum/unit_test/voidcrew_gorilla_gore/slam + +/datum/unit_test/voidcrew_gorilla_gore/slam/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(0, 0)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 0)) + victim.grabbedby(bearer, supress_message = TRUE) + victim.grabbedby(bearer, supress_message = TRUE) + var/hurt_before = victim.getBruteLoss() + TEST_ASSERT_EQUAL(punch(bearer, spot(1, 0)), COMPONENT_CANCEL_ATTACK_CHAIN, "A floor click while grabbing wasn't a slam") + TEST_ASSERT_NULL(bearer.pulling, "The slam kept the grab") + sleep(0.5 SECONDS) + TEST_ASSERT(victim.getBruteLoss() > hurt_before, "The floor slam did no damage") + TEST_ASSERT(victim.body_position == LYING_DOWN, "The floor slam didn't knock them down") + + // The table + var/mob/living/carbon/human/table_bearer = gorilla(spot(0, 3)) + var/mob/living/carbon/human/consistent/table_victim = allocate(/mob/living/carbon/human/consistent, spot(1, 3)) + var/obj/structure/table/table = allocate(/obj/structure/table, spot(1, 4)) + var/table_before = table.get_integrity() + table_victim.grabbedby(table_bearer, supress_message = TRUE) + table_victim.grabbedby(table_bearer, supress_message = TRUE) + TEST_ASSERT_EQUAL(punch(table_bearer, table), COMPONENT_CANCEL_ATTACK_CHAIN, "A table click while grabbing wasn't a slam") + TEST_ASSERT_EQUAL(table_victim.loc, spot(1, 4), "The slam didn't put them on the table") + sleep(0.5 SECONDS) + TEST_ASSERT(QDELETED(table) || table.get_integrity() <= table_before - 50, "The table didn't take the slam") // GORILLA_SLAM_TABLE_BREAK + + // A Brawler never slams + var/mob/living/carbon/human/brawler = gorilla(spot(3, 0), street = TRUE) + var/mob/living/carbon/human/consistent/held = allocate(/mob/living/carbon/human/consistent, spot(4, 0)) + held.grabbedby(brawler, supress_message = TRUE) + held.grabbedby(brawler, supress_message = TRUE) + TEST_ASSERT_NOTEQUAL(punch(brawler, spot(4, 0)), COMPONENT_CANCEL_ATTACK_CHAIN, "Brawler myomers slammed someone") + +// ========================================================================= +// WALL IMPACTS (owner D38) +// ========================================================================= + +/// The mark a wall impact leaves follows the injury: a dent with no broken skin, a floor half-moon and +/// edge smear for a bleeding cut, a spray and runs for a heavy one. The body is pinned, then slides off. +/datum/unit_test/voidcrew_gorilla_gore/wall_impact + +/datum/unit_test/voidcrew_gorilla_gore/wall_impact/Run() + var/turf/wall_spot = spot(2, 0) + change_turf(wall_spot, /turf/closed/wall) + var/turf/closed/wall/wall = spot(2, 0) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 0)) + + // Unhurt: a dent, no blood + TEST_ASSERT_EQUAL(victim.gorilla_splat_kind(), 1, "An unhurt body doesn't dent") // GORILLA_SPLAT_DENT + var/dents_before = LAZYLEN(wall.dent_decals) + victim.gorilla_wall_impact(wall, null) + TEST_ASSERT_EQUAL(LAZYLEN(wall.dent_decals), dents_before + 1, "An unhurt impact left no dent") + TEST_ASSERT(victim.has_offset("gorilla_wall_sag"), "The body wasn't pinned to the wall") // GORILLA_WALL_SAG + sleep(0.5 SECONDS) + TEST_ASSERT(!victim.has_offset("gorilla_wall_sag"), "The body stayed pinned to the wall") + + // A bleeding cut: the half-moon and the edge + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + var/datum/wound/slash/flesh/moderate/cut = new + cut.apply_wound(chest, silent = TRUE) + TEST_ASSERT_EQUAL(victim.gorilla_splat_kind(), 2, "A bleeding cut doesn't splash") // GORILLA_SPLAT_EDGE + victim.gorilla_wall_impact(wall, null) + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/hv_spray/gorilla_splash) in spot(1, 0), "A bleeding impact left no half-moon") + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/edge) in spot(1, 0), "A bleeding impact left no edge smear") + + // A heavy cut: the spray and the runs + var/datum/wound/slash/flesh/severe/gash = new + gash.apply_wound(victim.get_bodypart(BODY_ZONE_L_ARM), silent = TRUE) + TEST_ASSERT_EQUAL(victim.gorilla_splat_kind(), 3, "A heavy cut doesn't spray") // GORILLA_SPLAT_SPRAY + victim.gorilla_wall_impact(wall, null) + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray) in spot(1, 0), "A heavy impact left no spray") + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/runs) in spot(1, 0), "A heavy impact left no runs") + // Twice on the same wall face refreshes, never stacks + victim.gorilla_wall_impact(wall, null) + var/sprays = 0 + for(var/obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/spray in spot(1, 0)) + if(spray.type == /obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray) + sprays++ + TEST_ASSERT_EQUAL(sprays, 1, "Wall sprays stacked") + +// ========================================================================= +// THE HAYMAKER +// ========================================================================= + +/// Each real arm winds up a haymaker; the Brawler has none. A wound-up release hits the body in +/// front, sends it flying and spends the pistons; a swing at nothing staggers; a vent costs less. +/datum/unit_test/voidcrew_gorilla_gore/haymaker + +/datum/unit_test/voidcrew_gorilla_gore/haymaker/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(0, 0)) + var/datum/action/cooldown/cyberware/gorilla_haymaker/right_haymaker = locate() in arm(bearer, BODY_ZONE_R_ARM).actions + var/datum/action/cooldown/cyberware/gorilla_haymaker/left_haymaker = locate() in arm(bearer, BODY_ZONE_L_ARM).actions + TEST_ASSERT_NOTNULL(right_haymaker, "The right Gorilla arm has no haymaker") + TEST_ASSERT_NOTNULL(left_haymaker, "The left Gorilla arm has no haymaker") + TEST_ASSERT_EQUAL(right_haymaker.owner, bearer, "The haymaker wasn't granted") + var/mob/living/carbon/human/brawler = gorilla(spot(4, 4), street = TRUE) + TEST_ASSERT_NULL(locate(/datum/action/cooldown/cyberware/gorilla_haymaker) in arm(brawler).actions, "Brawler myomers have a haymaker") + + // Wind up, then let go at someone in front + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 0)) + bearer.setDir(EAST) + right_haymaker.Trigger(bearer) + TEST_ASSERT(right_haymaker.charging(), "Triggering the haymaker didn't wind it up") + TEST_ASSERT_NOTNULL(bearer.has_status_effect(/datum/status_effect/gorilla_windup), "The wind-up has no status effect") + TEST_ASSERT(bearer.has_movespeed_modifier(/datum/movespeed_modifier/gorilla_windup), "The wind-up doesn't slow") + right_haymaker.charge_started = world.time - 2 SECONDS + var/hurt_before = victim.getBruteLoss() + right_haymaker.InterceptClickOn(bearer, null, victim) + TEST_ASSERT(!right_haymaker.charging(), "The haymaker still winds up after the release") + TEST_ASSERT_NULL(bearer.has_status_effect(/datum/status_effect/gorilla_windup), "The wind-up outlived the release") + TEST_ASSERT(!bearer.has_movespeed_modifier(/datum/movespeed_modifier/gorilla_windup), "The wind-up's slow outlived it") + TEST_ASSERT(!bearer.has_offset("gorilla_windup"), "The wind-up's lean outlived it") + TEST_ASSERT(victim.getBruteLoss() - hurt_before >= 20, "A full haymaker hit for too little") // GORILLA_HAYMAKER_MIN_DAMAGE + TEST_ASSERT(!TIMER_COOLDOWN_FINISHED(bearer, "gorilla_pistons"), "The haymaker didn't spend the pistons") // COOLDOWN_GORILLA_PISTONS + TEST_ASSERT(!left_haymaker.IsAvailable(), "The other arm's haymaker didn't share the cooldown") + sleep(2 SECONDS) + TEST_ASSERT_NOTEQUAL(victim.loc, spot(1, 0), "The haymaker didn't send them flying") + + // A swing at nothing staggers and still spends the pistons + var/mob/living/carbon/human/whiffer = gorilla(spot(0, 4)) + var/datum/action/cooldown/cyberware/gorilla_haymaker/whiff = locate() in arm(whiffer).actions + whiffer.setDir(EAST) + whiff.Trigger(whiffer) + whiff.charge_started = world.time - 1 SECONDS + whiff.InterceptClickOn(whiffer, null, spot(1, 4)) + TEST_ASSERT_NOTNULL(whiffer.has_status_effect(/datum/status_effect/staggered), "A whiffed haymaker didn't stagger") + TEST_ASSERT(!TIMER_COOLDOWN_FINISHED(whiffer, "gorilla_pistons"), "A whiff didn't spend the pistons") + + // Called off: the pistons vent for a shorter while + var/mob/living/carbon/human/venter = gorilla(spot(2, 2)) + var/datum/action/cooldown/cyberware/gorilla_haymaker/vent = locate() in arm(venter).actions + vent.Trigger(venter) + TEST_ASSERT(vent.charging(), "Test fixture: the third haymaker didn't wind up") + vent.unset_click_ability(venter, refund_cooldown = TRUE) + TEST_ASSERT(!vent.charging(), "Calling the haymaker off left it winding up") + var/left = S_TIMER_COOLDOWN_TIMELEFT(venter, "gorilla_pistons") + TEST_ASSERT(left > 0 && left <= 5 SECONDS, "A vent cost the wrong cooldown ([left])") // GORILLA_PISTONS_VENTED + +// ========================================================================= +// GROUND POUND +// ========================================================================= + +/// The synced pair grants the chip; the pound staggers and dazes everyone around but the bearer, does no +/// brute, and loose things hop outward but never into space. +/datum/unit_test/voidcrew_gorilla_gore/ground_pound + +/datum/unit_test/voidcrew_gorilla_gore/ground_pound/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(2, 2)) + TEST_ASSERT(gorilla_synced(bearer), "Test fixture: the pair isn't synced") + var/datum/action/cooldown/cyberware/gorilla_pound/pound = locate() in arm(bearer, BODY_ZONE_R_ARM).actions + TEST_ASSERT_NOTNULL(pound, "A synced pair has no ground pound") + TEST_ASSERT_EQUAL(pound.owner, bearer, "The ground pound wasn't granted") + + var/mob/living/carbon/human/consistent/near = allocate(/mob/living/carbon/human/consistent, spot(3, 2)) + var/mob/living/carbon/human/consistent/far = allocate(/mob/living/carbon/human/consistent, spot(4, 4)) + // Lava, not space: a space hole would vent the room and the wind would move things itself + var/turf/hole = spot(0, 2) + change_turf(hole, /turf/open/lava/smooth) + var/obj/item/wrench/loose = allocate(/obj/item/wrench, spot(1, 2)) + var/brute_before = near.getBruteLoss() + var/list/hit = gorilla_ground_pound(bearer, get_turf(bearer)) + TEST_ASSERT(near in hit, "The pound missed someone beside it") + TEST_ASSERT(!(far in hit), "The pound reached someone two tiles off") + TEST_ASSERT(!(bearer in hit), "The pound hit its bearer") + TEST_ASSERT_NOTNULL(near.has_status_effect(/datum/status_effect/staggered), "The pound didn't stagger") + TEST_ASSERT_EQUAL(near.getBruteLoss(), brute_before, "The pound did brute") + var/hopped_to = loose.loc + restore_turf(hole) + TEST_ASSERT_NOTEQUAL(hopped_to, hole, "The pound hopped a loose thing into lava") + + // Out of sync: the chip goes + var/obj/item/organ/left = arm(bearer, BODY_ZONE_L_ARM) + left.Remove(bearer, special = TRUE) + sleep(0.1 SECONDS) + TEST_ASSERT(QDELETED(pound) || pound.owner != bearer, "The ground pound outlived the pair") + +// ========================================================================= +// DOOR PRY +// ========================================================================= + +/// A trader outpost's airlock never gives to the Gorilla. +/datum/unit_test/voidcrew_gorilla_gore/pry_outpost + +/datum/unit_test/voidcrew_gorilla_gore/pry_outpost/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(0, 0)) + bearer.set_combat_mode(FALSE) + var/obj/machinery/door/airlock/outpost/door = allocate(/obj/machinery/door/airlock/outpost, spot(1, 0)) + TEST_ASSERT(gorilla_pry_refused(door), "An outpost airlock isn't refused") + var/result = SEND_SIGNAL(bearer, COMSIG_LIVING_UNARMED_ATTACK, door, TRUE, list()) + TEST_ASSERT(result & COMPONENT_CANCEL_ATTACK_CHAIN, "The pry didn't take the outpost door click") + sleep(0.5 SECONDS) + TEST_ASSERT(door.density, "The Gorilla pried an outpost airlock") + var/obj/machinery/door/airlock/plain = allocate(/obj/machinery/door/airlock, spot(0, 1)) + TEST_ASSERT(!gorilla_pry_refused(plain), "A plain airlock is refused") + +// ========================================================================= +// FINISHERS (owner D39) +// ========================================================================= + +/// Never on someone just lying there; never from Brawler myomers or with something in hand. +/datum/unit_test/voidcrew_gorilla_gore/finisher_gates + +/datum/unit_test/voidcrew_gorilla_gore/finisher_gates/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(0, 0)) + bearer.zone_selected = BODY_ZONE_HEAD + var/mob/living/carbon/human/consistent/resting = allocate(/mob/living/carbon/human/consistent, spot(1, 0)) + resting.set_resting(TRUE, instant = TRUE) + TEST_ASSERT(!gorilla_try_finisher(bearer, resting), "A finisher started on someone just lying down") + + var/mob/living/carbon/human/consistent/stunned = downed(spot(0, 1)) + var/obj/item/wrench/tool = allocate(/obj/item/wrench) + bearer.put_in_hands(tool) + TEST_ASSERT(!gorilla_try_finisher(bearer, stunned), "A finisher started with something in hand") + qdel(tool) + + var/mob/living/carbon/human/brawler = gorilla(spot(3, 3), street = TRUE) + brawler.zone_selected = BODY_ZONE_HEAD + var/mob/living/carbon/human/consistent/brawler_victim = downed(spot(4, 3)) + TEST_ASSERT(!gorilla_try_finisher(brawler, brawler_victim), "Brawler myomers started a finisher") + + // The pistons' cooldown blocks it + gorilla_cooldown(bearer, "gorilla_pistons", 20 SECONDS) // COOLDOWN_GORILLA_PISTONS + TEST_ASSERT(!gorilla_try_finisher(bearer, stunned), "A finisher started while the pistons were spent") + +/// The head clap bursts the head, the brain survives, the rig comes down and the pistons are spent. +/datum/unit_test/voidcrew_gorilla_gore/finisher_clap + +/datum/unit_test/voidcrew_gorilla_gore/finisher_clap/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(1, 1)) + bearer.zone_selected = BODY_ZONE_HEAD + var/mob/living/carbon/human/consistent/victim = downed(spot(2, 1)) + TEST_ASSERT(gorilla_try_finisher(bearer, victim), "The head clap didn't start on someone stunned") + var/datum/hv_finisher/chrome/gorilla/clap/finisher = bearer.chrome_finisher + TEST_ASSERT(istype(finisher), "A head aim didn't pick the head clap") + TEST_ASSERT(puppets_left() > 0, "The head clap put up no rig") + TEST_ASSERT_NULL(locate(/obj/effect/temp_visual/telegraphing/hv_finisher_mark) in get_turf(victim), "A chrome finisher marked its victim") + run_to_end(finisher) + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_HEAD), "The head clap didn't burst the head") + var/obj/item/organ/brain/brain + for(var/turf/floor in block(run_loc_floor_bottom_left, run_loc_floor_top_right)) + brain ||= locate(/obj/item/organ/brain) in floor + TEST_ASSERT_NOTNULL(brain, "The burst head lost its brain") + TEST_ASSERT_EQUAL(puppets_left(), 0, "The head clap left puppets behind") + TEST_ASSERT_NULL(bearer.get_filter("chrome_rig_hide"), "The bearer stayed hidden") + TEST_ASSERT_NULL(victim.get_filter("chrome_rig_hide"), "The victim stayed hidden") + TEST_ASSERT_NOTEQUAL(bearer.mouse_opacity, MOUSE_OPACITY_OPAQUE, "The bearer kept the hidden click area") + var/left = S_TIMER_COOLDOWN_TIMELEFT(bearer, "gorilla_pistons") + TEST_ASSERT(left > 10 SECONDS, "A finished clap didn't spend the pistons for long ([left])") + TEST_ASSERT_NULL(bearer.chrome_finisher, "The finished clap is still on the bearer") + TEST_ASSERT(QDELETED(finisher), "A finished clap outlived its end and kept hold of both bodies") + +/// A helmet crumples instead and the skull caves in; the Atlas frame holds. +/datum/unit_test/voidcrew_gorilla_gore/finisher_clap_armour + +/datum/unit_test/voidcrew_gorilla_gore/finisher_clap_armour/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(1, 1)) + bearer.zone_selected = BODY_ZONE_HEAD + var/mob/living/carbon/human/consistent/helmeted = downed(spot(2, 1)) + helmeted.equip_to_slot_or_del(new /obj/item/clothing/head/helmet/swat(helmeted), ITEM_SLOT_HEAD) + var/datum/hv_finisher/chrome/gorilla/clap/finisher = new(bearer, helmeted) + TEST_ASSERT(finisher.start(), "Test fixture: the clap wouldn't start on a helmet") + run_to_end(finisher) + TEST_ASSERT_NOTNULL(helmeted.get_bodypart(BODY_ZONE_HEAD), "A helmet didn't stop the burst") + TEST_ASSERT_NOTNULL(helmeted.get_bodypart(BODY_ZONE_HEAD).get_wound_type(/datum/wound/blunt/bone/critical/hv_skull), "The skull didn't cave in under the helmet") + qdel(finisher) + + var/mob/living/carbon/human/second = gorilla(spot(1, 3)) + second.zone_selected = BODY_ZONE_HEAD + var/mob/living/carbon/human/consistent/framed = downed(spot(2, 3)) + var/obj/item/organ/atlas = allocate(/obj/item/organ/cyberimp/cyberware/atlas) + TEST_ASSERT(atlas.Insert(framed, special = TRUE), "Test fixture: the Atlas frame wouldn't go in") + var/datum/hv_finisher/chrome/gorilla/clap/frame_finisher = new(second, framed) + TEST_ASSERT(frame_finisher.start(), "Test fixture: the clap wouldn't start on the Atlas frame") + run_to_end(frame_finisher) + TEST_ASSERT_NOTNULL(framed.get_bodypart(BODY_ZONE_HEAD), "The Atlas frame's head burst") + qdel(frame_finisher) + +/// The limb rip tears the aimed arm off and leaves it on the victim's tile. +/datum/unit_test/voidcrew_gorilla_gore/finisher_rip + +/datum/unit_test/voidcrew_gorilla_gore/finisher_rip/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(1, 1)) + bearer.zone_selected = BODY_ZONE_L_ARM + var/mob/living/carbon/human/consistent/victim = downed(spot(2, 1)) + TEST_ASSERT(gorilla_try_finisher(bearer, victim), "The limb rip didn't start") + var/datum/hv_finisher/chrome/gorilla/rip/finisher = bearer.chrome_finisher + TEST_ASSERT(istype(finisher), "An arm aim didn't pick the limb rip") + run_to_end(finisher) + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_L_ARM), "The aimed arm wasn't torn off") + TEST_ASSERT_NOTNULL(victim.get_bodypart(BODY_ZONE_R_ARM), "The wrong arm was torn off") + var/obj/item/bodypart/arm/left/torn = locate() in get_turf(victim) + TEST_ASSERT_NOTNULL(torn, "The torn arm wasn't left on the victim's tile") + TEST_ASSERT_EQUAL(puppets_left(), 0, "The limb rip left puppets behind") + + // Armour on the arm: the shoulder comes out instead + var/mob/living/carbon/human/second = gorilla(spot(1, 3)) + second.zone_selected = BODY_ZONE_R_ARM + var/mob/living/carbon/human/consistent/armoured = downed(spot(2, 3)) + armoured.physiology.armor = armoured.physiology.armor.generate_new_with_modifiers(list(MELEE = 60)) + var/datum/hv_finisher/chrome/gorilla/rip/armoured_rip = new(second, armoured) + armoured_rip.aimed_zone = BODY_ZONE_R_ARM + TEST_ASSERT(armoured_rip.start(), "Test fixture: the rip wouldn't start on armour") + run_to_end(armoured_rip) + TEST_ASSERT_NOTNULL(armoured.get_bodypart(BODY_ZONE_R_ARM), "An armoured arm was torn off") + TEST_ASSERT_NOTNULL(armoured.get_bodypart(BODY_ZONE_R_ARM).get_wound_type(/datum/wound/blunt/bone/moderate), "An armoured arm didn't dislocate") + qdel(armoured_rip) + +/// The backbreaker breaks the back (they can't stand), and the fold comes off when the body is moved. +/datum/unit_test/voidcrew_gorilla_gore/finisher_backbreaker + +/datum/unit_test/voidcrew_gorilla_gore/finisher_backbreaker/Run() + var/mob/living/carbon/human/bearer = gorilla(spot(1, 1)) + bearer.zone_selected = BODY_ZONE_CHEST + var/mob/living/carbon/human/consistent/victim = downed(spot(2, 1)) + TEST_ASSERT(gorilla_try_finisher(bearer, victim), "The backbreaker didn't start") + var/datum/hv_finisher/chrome/gorilla/backbreaker/finisher = bearer.chrome_finisher + TEST_ASSERT(istype(finisher), "A chest aim didn't pick the backbreaker") + run_to_end(finisher) + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + var/datum/wound/spine = chest.get_wound_type(/datum/wound/blunt/bone/critical/gorilla_spine) + TEST_ASSERT_NOTNULL(spine, "The back didn't break") + TEST_ASSERT(HAS_TRAIT_FROM(victim, TRAIT_FLOORED, REF(spine)), "A broken back can still stand") + TEST_ASSERT_NOTNULL(victim.GetComponent(/datum/component/gorilla_fold), "The broken back isn't drawn bent") + TEST_ASSERT_NOTNULL(victim.get_filter("chrome_rig_hide"), "The bent body isn't the one shown") + victim.forceMove(spot(3, 3)) + TEST_ASSERT_NULL(victim.GetComponent(/datum/component/gorilla_fold), "The fold stayed on a moved body") + TEST_ASSERT_NULL(victim.get_filter("chrome_rig_hide"), "A moved body stayed hidden") + TEST_ASSERT_EQUAL(puppets_left(), 0, "The fold left puppets behind") + var/spine_ref = REF(spine) + spine.remove_wound() + TEST_ASSERT(!HAS_TRAIT_FROM(victim, TRAIT_FLOORED, spine_ref), "A set back still can't stand") + +/// Damage on the bearer, the bearer moving, or the victim coming out of the stun stops it, with the +/// shorter cooldown, and nothing is left hidden or posed. +/datum/unit_test/voidcrew_gorilla_gore/finisher_interrupts + +/datum/unit_test/voidcrew_gorilla_gore/finisher_interrupts/Run() + // Hit hard enough: lets go + var/mob/living/carbon/human/bearer = gorilla(spot(1, 1)) + bearer.zone_selected = BODY_ZONE_CHEST + var/mob/living/carbon/human/consistent/victim = downed(spot(2, 1)) + TEST_ASSERT(gorilla_try_finisher(bearer, victim), "Test fixture: no finisher") + var/datum/hv_finisher/chrome/gorilla/finisher = bearer.chrome_finisher + finisher.advance() + bearer.apply_damage(15, BRUTE, BODY_ZONE_CHEST) + TEST_ASSERT(!finisher.running, "Damage on the bearer didn't stop the finisher") + TEST_ASSERT(QDELETED(finisher), "A stopped finisher outlived its end") + TEST_ASSERT_EQUAL(finisher.result, 3, "Damage stopped it for the wrong reason") // HV_FINISHER_DAMAGED + TEST_ASSERT_EQUAL(puppets_left(), 0, "A stopped finisher left puppets") + TEST_ASSERT_NULL(victim.get_filter("chrome_rig_hide"), "A stopped finisher left the victim hidden") + TEST_ASSERT_NULL(bearer.get_filter("chrome_rig_hide"), "A stopped finisher left the bearer hidden") + var/left = S_TIMER_COOLDOWN_TIMELEFT(bearer, "gorilla_pistons") + TEST_ASSERT(left > 0 && left <= 10 SECONDS, "A stopped finisher cost the wrong cooldown ([left])") // GORILLA_PISTONS_INTERRUPTED + TEST_ASSERT_NOTNULL(victim.get_bodypart(BODY_ZONE_CHEST), "Test fixture: no chest") + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_CHEST).get_wound_type(/datum/wound/blunt/bone/critical/gorilla_spine), "A stopped backbreaker still broke the back") + + // The bearer steps away: lets go + var/mob/living/carbon/human/walker = gorilla(spot(1, 3)) + walker.zone_selected = BODY_ZONE_HEAD + var/mob/living/carbon/human/consistent/walk_victim = downed(spot(2, 3)) + TEST_ASSERT(gorilla_try_finisher(walker, walk_victim), "Test fixture: no second finisher") + var/datum/hv_finisher/chrome/gorilla/walk_finisher = walker.chrome_finisher + walker.forceMove(spot(0, 3)) + TEST_ASSERT(!walk_finisher.running, "The bearer walked off mid-finisher") + TEST_ASSERT_NOTNULL(walk_victim.get_bodypart(BODY_ZONE_HEAD), "A walked-off clap still burst the head") + + // The victim comes out of the stun before the kill: twists free + var/mob/living/carbon/human/third = gorilla(spot(3, 0)) + third.zone_selected = BODY_ZONE_HEAD + var/mob/living/carbon/human/consistent/waking = downed(spot(4, 0)) + TEST_ASSERT(gorilla_try_finisher(third, waking), "Test fixture: no third finisher") + var/datum/hv_finisher/chrome/gorilla/wake_finisher = third.chrome_finisher + waking.SetAllImmobility(0) + wake_finisher.tick() + TEST_ASSERT(!wake_finisher.running, "A victim out of the stun didn't twist free") + TEST_ASSERT_NOTNULL(waking.get_bodypart(BODY_ZONE_HEAD), "A victim who twisted free lost the head") + TEST_ASSERT_EQUAL(puppets_left(), 0, "Twisting free left puppets") + +/// Each new move can be started without a mouse or an aimed zone, for an AI later. +/datum/unit_test/voidcrew_gorilla_gore/ai_entry_points + +/datum/unit_test/voidcrew_gorilla_gore/ai_entry_points/Run() + // The haymaker: the same wind-up, then the hit + var/mob/living/carbon/human/bearer = gorilla(spot(0, 0)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 0)) + var/hurt_before = victim.getBruteLoss() + TEST_ASSERT(gorilla_try_haymaker(bearer, victim, 0.8 SECONDS), "The haymaker couldn't be started without a click") + TEST_ASSERT_NOTNULL(bearer.has_status_effect(/datum/status_effect/gorilla_windup), "An AI haymaker skipped the wind-up") + sleep(1.5 SECONDS) + TEST_ASSERT(victim.getBruteLoss() > hurt_before, "An AI haymaker never landed") + TEST_ASSERT(!TIMER_COOLDOWN_FINISHED(bearer, "gorilla_pistons"), "An AI haymaker didn't spend the pistons") // COOLDOWN_GORILLA_PISTONS + + // The ground pound + var/mob/living/carbon/human/pounder = gorilla(spot(2, 4)) + TEST_ASSERT(gorilla_try_pound(pounder), "The ground pound couldn't be started without the chip") + TEST_ASSERT(!TIMER_COOLDOWN_FINISHED(pounder, "gorilla_pound"), "An AI pound didn't start its cooldown") // COOLDOWN_GORILLA_POUND + + // A finisher by zone, whatever the bearer aims at + var/mob/living/carbon/human/finisher_bearer = gorilla(spot(0, 2)) + finisher_bearer.zone_selected = BODY_ZONE_HEAD + var/mob/living/carbon/human/consistent/downed_victim = downed(spot(1, 2)) + TEST_ASSERT(gorilla_try_finisher(finisher_bearer, downed_victim, BODY_ZONE_CHEST), "A finisher couldn't be started by zone") + TEST_ASSERT(istype(finisher_bearer.chrome_finisher, /datum/hv_finisher/chrome/gorilla/backbreaker), "The zone given didn't pick the finisher") + run_to_end(finisher_bearer.chrome_finisher) + + // The slam + var/mob/living/carbon/human/slammer = gorilla(spot(3, 0)) + var/mob/living/carbon/human/consistent/held = allocate(/mob/living/carbon/human/consistent, spot(4, 0)) + held.grabbedby(slammer, supress_message = TRUE) + held.grabbedby(slammer, supress_message = TRUE) + slammer.swap_hand(2, silent = TRUE) + TEST_ASSERT(gorilla_try_slam(slammer, get_turf(held)), "The slam couldn't be started without a click") From dbd7bd155f70690a9abc65331667edc012889e6e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:35:17 -0800 Subject: [PATCH 189/472] Make the BR-38, Ansem pistols, lethal cycler, harpoon gun and rocket launcher obtainable Six guns that existed in the code but had no way into a crew's hands now have one, and their ammo is rated. Undertow Exchange sells the NT BR-38 (green, public), the Ansem, the Ansem/SC and the lethal cycler (yellow, one voucher each). The Ansem and BR-38 join the syndicate and armory loot pools, the lethal cycler the armory yellow pool, the harpoon gun the plunder yellow pool with its quiver as the spare reload, and the Ansem/SC and the unrestricted rocket launcher are rare theme finds (the launcher only in red-class armory and syndicate caches). Each carries a spare reload. 10mm AP, hollow-point and incendiary magazines and the harpoon print again, from Specialist Ammunition and Automatic Ammunition, now that guns fire them. Rockets already print at the red nodes and are unchanged. All of it is rated in the gear catalog, and the shop, loot and research tests list the new entries. --- tools/gear_catalog/exceptions.json | 6 + tools/gear_catalog/ratings/ammo.json | 80 ++++++++--- tools/gear_catalog/ratings/guns.json | 129 +++++++++++++----- tools/gear_catalog/ratings/storage.json | 28 ++-- voidcrew/modules/loot/loot_guns.dm | 4 + voidcrew/modules/loot/themes/armory.dm | 1 + voidcrew/modules/loot/themes/syndicate.dm | 2 + voidcrew/modules/loot/zone_loot.dm | 6 + .../research/designs/ammo_catalogue.dm | 37 +++++ voidcrew/modules/research/techweb_nodes.dm | 4 + .../trade/shop_catalog_black_market.dm | 53 +++++++ voidcrew/modules/unit_tests/voidcrew_loot.dm | 8 +- .../modules/unit_tests/voidcrew_nova_guns.dm | 16 +++ .../unit_tests/voidcrew_shop_catalog.dm | 10 ++ .../unit_tests/voidcrew_zone_research.dm | 6 +- .../modules/unit_tests/voidcrew_zone_shops.dm | 5 +- 16 files changed, 329 insertions(+), 66 deletions(-) diff --git a/tools/gear_catalog/exceptions.json b/tools/gear_catalog/exceptions.json index 10192c66af9..0bda99761f2 100644 --- a/tools/gear_catalog/exceptions.json +++ b/tools/gear_catalog/exceptions.json @@ -23,6 +23,12 @@ "route": "/datum/design/c9mm/lathe", "reason": "Deliberate: plain reloads for a yellow gun. The gun is the gate." }, + { + "path": "/obj/item/ammo_box/magazine/m10mm", + "rule": "below_reputation", + "route": "/datum/design/vc_mag_m10mm", + "reason": "Deliberate: plain reloads for a yellow gun. The gun is the gate." + }, { "path": "/obj/item/ammo_box/magazine/m50", "rule": "below_reputation", diff --git a/tools/gear_catalog/ratings/ammo.json b/tools/gear_catalog/ratings/ammo.json index 99e68c72a76..0bd5c74fef8 100644 --- a/tools/gear_catalog/ratings/ammo.json +++ b/tools/gear_catalog/ratings/ammo.json @@ -75,8 +75,8 @@ "group": "10mm loads", "hash": "50eb0251eb73bd7c", "name": "ammo box (10mm)", - "rank": 3, - "reason": "Plain 40 damage. The only guns that take it are the near-useless Liberator and, by hand, the Condor magazine, so a box does little.", + "rank": 7, + "reason": "Plain 40 damage. Fits the Zashch and Ansem magazines, and the Liberator. Every autolathe prints the box from round start.", "tier": "green" }, "/obj/item/ammo_box/c38": { @@ -450,6 +450,42 @@ "reason": "Built into a green riot foam gun, never on its own. Rated with the gun.", "tier": "green" }, + "/obj/item/ammo_box/magazine/m10mm": { + "confidence": "high", + "group": "10mm loads", + "hash": "", + "name": "pistol magazine (10mm)", + "rank": 5, + "reason": "Ansem magazine, 8 plain 40 damage rounds. Follows the gun; the rounds print at every autolathe.", + "tier": "yellow" + }, + "/obj/item/ammo_box/magazine/m10mm/ap": { + "confidence": "high", + "group": "10mm loads", + "hash": "", + "name": "pistol magazine (10mm AP)", + "rank": 2, + "reason": "35 damage with enough armor piercing to cancel a vest. The best 10mm against armored crews.", + "tier": "yellow" + }, + "/obj/item/ammo_box/magazine/m10mm/fire": { + "confidence": "high", + "group": "10mm loads", + "hash": "", + "name": "pistol magazine (10mm incendiary)", + "rank": 4, + "reason": "20 damage and three fire stacks. Weaker than plain 10mm until the fire takes hold.", + "tier": "yellow" + }, + "/obj/item/ammo_box/magazine/m10mm/hp": { + "confidence": "high", + "group": "10mm loads", + "hash": "", + "name": "pistol magazine (10mm HP)", + "rank": 3, + "reason": "50 damage on bare skin, far less through a vest. Good on NPCs and unarmored crews.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/m12g": { "confidence": "high", "group": "Bulldog drums", @@ -1049,7 +1085,7 @@ "group": "10mm loads", "hash": "", "name": "Zashch pistol magazine", - "rank": 4, + "rank": 8, "reason": "Plain 40 damage 10mm rounds, 20 to a magazine. Follows the gun.", "tier": "green" }, @@ -1141,7 +1177,7 @@ "group": "arrows and crossbow bolts", "hash": "00f7f77a875f0218", "name": "arrow", - "rank": 4, + "rank": 5, "reason": "50 damage arrow, needs the fletching book. Follows the green bow.", "tier": "green" }, @@ -1150,7 +1186,7 @@ "group": "arrows and crossbow bolts", "hash": "26c61b2e12b85c94", "name": "holy arrow", - "rank": 4, + "rank": 5, "reason": "50 damage arrow, needs the fletching book. Follows the green bow.", "tier": "green" }, @@ -1159,7 +1195,7 @@ "group": "arrows and crossbow bolts", "hash": "2641ea5de62b9f09", "name": "plastic arrow", - "rank": 6, + "rank": 7, "reason": "5 damage and 50 stamina, weak against armor. The bow's less-lethal arrow.", "tier": "green" }, @@ -1169,7 +1205,7 @@ "hash": "656913dd1b8db374", "name": "poisonous arrow", "note": "A human who is hit gets 8 units of cyanide and 1 unit of stamina toxin added straight into their reagents; armor does not stop it.", - "rank": 3, + "rank": 4, "reason": "40 damage and 8 units of cyanide past any armor. Still one slow arrow at a time.", "tier": "green" }, @@ -1178,7 +1214,7 @@ "group": "arrows and crossbow bolts", "hash": "e9b3370054896d43", "name": "sticky arrow", - "rank": 5, + "rank": 6, "reason": "30 damage that almost always sticks and hurts to pull out.", "tier": "green" }, @@ -1187,8 +1223,8 @@ "group": "10mm loads", "hash": "7bbe2ad1ddb2010b", "name": "10mm bullet casing", - "rank": 3, - "reason": "Plain 40 damage. The only guns that take it are the near-useless Liberator and, by hand, the Condor magazine, so a box does little.", + "rank": 7, + "reason": "Plain 40 damage. Fits the Zashch and Ansem magazines, and the Liberator. Every autolathe prints the box from round start.", "tier": "green" }, "/obj/item/ammo_casing/c10mm/ap": { @@ -1196,8 +1232,8 @@ "group": "10mm loads", "hash": "5f8864970b7d1dc0", "name": "10mm armor-piercing bullet casing", - "rank": 2, - "reason": "35 damage that cancels a vest, but only a Liberator or a hand-filled Condor magazine can use it. No shop, ruin or played hull has it.", + "rank": 6, + "reason": "35 damage that cancels a vest. Loaded in the Ansem armor-piercing magazine.", "tier": "yellow" }, "/obj/item/ammo_casing/c10mm/reaper": { @@ -1786,6 +1822,15 @@ "reason": "Riot foam darts, 25 stamina each. Toy guns loaded with these are real stun weapons, level with the green riot toys.", "tier": "green" }, + "/obj/item/ammo_casing/harpoon": { + "confidence": "medium", + "group": "arrows and crossbow bolts", + "hash": "", + "name": "harpoon", + "rank": 3, + "reason": "60 damage that goes through a vest and sticks in the target. Fits only the harpoon gun, so it follows the gun.", + "tier": "yellow" + }, "/obj/item/ammo_casing/junk": { "confidence": "high", "group": "junk rounds", @@ -1869,7 +1914,7 @@ "group": "arrows and crossbow bolts", "hash": "6fc7032803ed668b", "name": "Sharpened Iron Rod", - "rank": 9, + "rank": 10, "reason": "Plain iron rod bolt for a green crossbow, sharpened from a rod. 30 damage, one at a time. The gun is the gate.", "tier": "none" }, @@ -1879,7 +1924,7 @@ "hash": "a52840a8c2b7a626", "name": "healium crystal bolt", "note": "Holds 6 seconds of healing and passes what's left to the bolt when fired, so a bolt that missed keeps its remainder. Embedded in an organic target it heals 5 each of brute, burn, toxin and oxygen per second, adds drowsiness that puts them to sleep for 3 seconds at a time, then falls out and is back at 6 seconds. / Deals no damage and embeds at 100% chance up close, losing 3% per tile; it can be pulled out in 1.5 seconds. Only the first such bolt in a target can put them to sleep, and later ones only heal. The listed proc just hands the remaining heal time back to the dropped or embedded bolt.", - "rank": 3, + "rank": 4, "reason": "No damage, but it nearly always sticks up close, past any armor, and heals 5 of each damage type a second for 6 seconds while the target falls asleep. A field heal or a sleep dart. Healium takes a crystallizer run and the crossbow is slow.", "tier": "yellow" }, @@ -1898,7 +1943,7 @@ "group": "arrows and crossbow bolts", "hash": "378189095d29a5e8", "name": "paper ball", - "rank": 7, + "rank": 8, "reason": "A ball of paper. 1 damage.", "tier": "none" }, @@ -2279,10 +2324,6 @@ "/obj/item/ammo_box/magazine/internal/shot/tube/slug": "no route", "/obj/item/ammo_box/magazine/internal/sks": "abstract", "/obj/item/ammo_box/magazine/internal/sks/empty": "no route", - "/obj/item/ammo_box/magazine/m10mm": "needs another system", - "/obj/item/ammo_box/magazine/m10mm/ap": "needs another system", - "/obj/item/ammo_box/magazine/m10mm/fire": "needs another system", - "/obj/item/ammo_box/magazine/m10mm/hp": "needs another system", "/obj/item/ammo_box/magazine/m38/empty": "no route", "/obj/item/ammo_box/magazine/m75": "needs another system", "/obj/item/ammo_box/magazine/rapier/empty": "no route", @@ -2372,7 +2413,6 @@ "/obj/item/ammo_casing/glockroach": "literal only", "/obj/item/ammo_casing/gumball": "no route", "/obj/item/ammo_casing/gumball/harmful": "no route", - "/obj/item/ammo_casing/harpoon": "needs another system", "/obj/item/ammo_casing/hivebot": "literal only", "/obj/item/ammo_casing/junk/hunter": "no route", "/obj/item/ammo_casing/junk/incendiary": "no route", diff --git a/tools/gear_catalog/ratings/guns.json b/tools/gear_catalog/ratings/guns.json index 2df5300d562..1ab21b5d6dc 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -73,6 +73,17 @@ "score": 219, "tier": "red" }, + "/obj/item/gun/ballistic/automatic/battle_rifle": { + "confidence": "low", + "group": "rifles", + "hash": "", + "name": "NT BR-38 battle rifle", + "note": "Right-click scopes in at 2x view range. After ten shots each shot has a 1 in 10 chance to wear the rifle down a stage, up to five; at the last stage it misfires three shots in four. A multitool (20 s) or a weapon recharger sets it right, and an EMP wears it down at once. An emag lifts its shots from 30 to 40 damage, and a fully worn emagged rifle blows up five seconds after the next trigger pull.", + "rank": 10, + "reason": "Fires 30 damage .38 rounds from a 15 round magazine, with a 2x scope. It wears itself out after ten shots until someone recalibrates it, which keeps it just under the yellow line.", + "score": 36, + "tier": "green" + }, "/obj/item/gun/ballistic/automatic/c20r/toy/unrestricted": { "confidence": "high", "group": "toys and novelties", @@ -178,7 +189,7 @@ "group": "sidearms", "hash": "dc518916bb509d96", "name": "Makarov pistol", - "rank": 10, + "rank": 12, "reason": "A pocket pistol about level with a laser gun: 30 damage, 12 rounds, printable ammo.", "score": 42, "tier": "yellow" @@ -193,6 +204,33 @@ "score": 105, "tier": "yellow" }, + "/obj/item/gun/ballistic/automatic/pistol/clandestine": { + "confidence": "high", + "group": "sidearms", + "hash": "", + "name": "Ansem pistol", + "rank": 11, + "reason": "Does 40 damage a shot where the Makarov does 30, from an 8 round magazine. The 10mm rounds print at every autolathe.", + "score": 56, + "tier": "yellow" + }, + "/obj/item/gun/ballistic/automatic/pistol/clandestine/fisher": { + "confidence": "medium", + "group": "sidearms", + "hash": "", + "name": "Ansem/SC pistol", + "note": "Right-click fires the underbarrel disruptor, twice before it recharges. Hitting something with the pistol, or throwing it, disrupts it for 25 s. The suppressor is part of the gun and cannot come off.", + "rank": 10, + "reason": "An Ansem with a suppressor built in and a SC/FISHER light disruptor under the barrel (right-click), which knocks out lights, cameras, turrets and APCs for 15 s.", + "score": 60, + "score_inputs": { + "points": { + "value": 4, + "why": "Suppressor built in, plus the SC/FISHER disruptor under the barrel." + } + }, + "tier": "yellow" + }, "/obj/item/gun/ballistic/automatic/pistol/deagle": { "confidence": "high", "group": "sidearms", @@ -245,7 +283,7 @@ "hash": "4f9a01f4056d7085", "name": "Liberator", "note": "Single-shot barrel that takes a 2 s do-after to unscrew or screw back on. Every shot knocks 70-140 off its 100 integrity: on a fresh gun the first shot shatters it about 58% of the time (hurting the firing hand for up to 20 and sometimes embedding shrapnel), otherwise the next shot always does; point-blank doubles the shot's damage and aiming at someone held at gunpoint adds x1.5.", - "rank": 19, + "rank": 21, "reason": "Holds one round, and the barrel usually shatters within two shots. Its design disk is rare.", "score": 0, "score_inputs": { @@ -265,7 +303,7 @@ "group": "sidearms", "hash": "ed8c5fd09e92dcda", "name": "M1911", - "rank": 12, + "rank": 14, "reason": "Fires a Makarov's bullet with 8 rounds, one vested man per magazine at best.", "score": 35, "tier": "green" @@ -275,7 +313,7 @@ "group": "sidearms", "hash": "58fd32966ac76c57", "name": "M1911", - "rank": 13, + "rank": 15, "reason": "An M1911 that starts without its magazine, and the same gun once loaded.", "score": 35, "tier": "green" @@ -285,7 +323,7 @@ "group": "sidearms", "hash": "cfb7e46635e265f8", "name": "Makarov pistol", - "rank": 11, + "rank": 13, "reason": "A Makarov that starts without its magazine, and the same gun once loaded.", "score": 42, "tier": "yellow" @@ -316,7 +354,7 @@ "group": "sidearms", "hash": "", "name": "'Zashch' heavy pistol", - "rank": 14, + "rank": 16, "reason": "Plain 40 damage 10mm rounds, one a second, 20 to a magazine. The slow cycle keeps it green.", "score": 22, "tier": "green" @@ -404,7 +442,7 @@ "hash": "9b6b11da9005d066", "name": "bow", "note": "Needs no firing pin and starts empty: an arrow must be nocked by hand, and the bow drawn (use in hand) before it will fire, otherwise the arrow just falls to the floor. The arrow also drops out if the bow is dropped or moved out of the hands (e.g. onto the back).", - "rank": 10, + "rank": 12, "reason": "Does 50 damage per arrow, drawn by hand.", "score": 7, "score_inputs": { @@ -421,7 +459,7 @@ "hash": "4231c1aae8664849", "name": "shortbow", "note": "Needs no firing pin and starts empty: an arrow must be nocked by hand, and the bow drawn (use in hand) before it will fire, otherwise the arrow just falls to the floor. The arrow also drops out if the bow is dropped or moved out of the hands. Same as the bow; only the damage multiplier differs. / Needs no firing pin and starts empty: an arrow must be nocked by hand, and the bow drawn (use in hand) before it will fire, otherwise the arrow just falls to the floor. The arrow also drops out if the bow is dropped or moved out of the hands (e.g. onto the back).", - "rank": 14, + "rank": 16, "reason": "Does 18 damage per arrow. Its recipe book comes in a 1,000 cr cargo kit.", "score": 3, "score_inputs": { @@ -447,7 +485,7 @@ "group": "sidearms", "hash": "b18390c40543a655", "name": ".38 revolver", - "rank": 16, + "rank": 18, "reason": "Does 25 damage and weak wounds, with six shots then a slow reload.", "score": 18, "tier": "green" @@ -457,7 +495,7 @@ "group": "sidearms", "hash": "616fe18d05a3ece6", "name": "Colt Detective Special", - "rank": 17, + "rank": 19, "reason": "A .38 revolver by another name: 25 damage and six shots.", "score": 18, "tier": "green" @@ -610,13 +648,30 @@ "score": 44, "tier": "yellow" }, + "/obj/item/gun/ballistic/rifle/boltaction/harpoon": { + "confidence": "low", + "group": "rifles", + "hash": "", + "name": "ballistic harpoon gun", + "note": "Holds one harpoon, so every shot needs the bolt worked and a new harpoon loaded by hand. A hit nearly always embeds and hurts when the target moves, until someone pulls it out.", + "rank": 9, + "reason": "One harpoon at a time: 60 damage that goes through a vest and sticks in the target. It scores just over the yellow line, and every shot is a reload.", + "score": 41, + "score_inputs": { + "points": { + "value": 10, + "why": "Every hit embeds and keeps hurting; the formula counts a single 60 damage shot only." + } + }, + "tier": "yellow" + }, "/obj/item/gun/ballistic/rifle/boltaction/pipegun": { "confidence": "medium", "group": "rifles", "hash": "09224ff4494cd405", "name": "pipegun", "note": "Bolt action that anyone can fire (hulks included), and a point-blank shot throws the target back 3 tiles. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. / Against robotic mobs it adds half the shot's own damage again as brute to the chest, which armor does not reduce. Junk variants (hunter, shock, reaper and so on) change which mobs are hit harder or what the extra damage is.", - "rank": 12, + "rank": 14, "reason": "Fires one hand-loaded round at a time.", "score": 5, "score_inputs": { @@ -633,7 +688,7 @@ "hash": "f1fd463d783a4af9", "name": "pipe pistol", "note": "Self-loading; one trigger pull empties its whole magazine in a rapid burst. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. Anyone can fire it (hulks included) and a point-blank shot throws the target back 3 tiles. / Bolt action that anyone can fire (hulks included), and a point-blank shot throws the target back 3 tiles. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. / Against robotic mobs it adds half the shot's own damage again as brute to the chest, which armor does not reduce. Junk variants (hunter, shock, reaper and so on) change which mobs are hit harder or what the extra damage is.", - "rank": 18, + "rank": 20, "reason": "Fires three weak rounds in a burst, then needs a hand reload.", "score": 3, "score_inputs": { @@ -683,7 +738,7 @@ "group": "rifles", "hash": "7673980e5008c01b", "name": "Sakhno M2442 Army", - "rank": 9, + "rank": 11, "reason": "Does 60 damage on bare targets and about 24 through a vest, with five shots and a bolt to work.", "score": 15, "tier": "green" @@ -693,7 +748,7 @@ "group": "sidearms", "hash": "", "name": "NT M-96", - "rank": 15, + "rank": 17, "reason": "Fires 22 damage 9mm rounds every half second, ten in the gun and a little spread. A self-loader a step below the Makarov.", "score": 22, "tier": "green" @@ -704,7 +759,7 @@ "hash": "2ed82f9db0fc22be", "name": "heated rebar crossbow", "note": "Every shot leaves the string loose; drawing it takes a 3 s do-after (clicking the empty crossbow starts it) before the next rod can fire. The rod can embed in the target.", - "rank": 13, + "rank": 15, "reason": "Does 30 damage then needs a 3 s draw. Better ammo exists, which the score doesn't cover.", "score": 3, "score_inputs": { @@ -721,7 +776,7 @@ "hash": "04bae29a51921f7d", "name": "stressed rebar crossbow", "note": "Every shot leaves the string loose; drawing it takes a 3 s do-after (clicking the empty crossbow starts it) before the next rod can fire. The rod can embed in the target. Here the redraw time is 1.5 ticks (about 0.15 s) because the value lacks SECONDS, and 25% of shots aimed at someone else misfire into the shooter. / Every shot leaves the string loose; drawing it takes a 3 s do-after (clicking the empty crossbow starts it) before the next rod can fire. The rod can embed in the target.", - "rank": 11, + "rank": 13, "reason": "Redraws almost instantly at the cost of shooting yourself one time in four.", "score": 7, "score_inputs": { @@ -756,7 +811,7 @@ "name": "Dardo-RE Rocket Launcher", "note": "Single shot: the tube is emptied by every shot and needs a new rocket. Each shot throws a backblast behind the user (60-degree arc, light blast and fire out to 6 tiles; the user is protected), and the rocket explodes on impact with a bigger blast against walls and objects and a 1% chance to gib a living target outright.", "rank": 4, - "reason": "Blows holes in ships, one rocket at a time. No shop, ruin or played hull has one.", + "reason": "Blows holes in ships, one rocket at a time. Turns up in red-zone armory and Syndicate caches.", "score": 114, "score_inputs": { "points": { @@ -775,7 +830,7 @@ "group": "shotguns", "hash": "8f3e8cf98b213a9b", "name": "shotgun", - "rank": 9, + "rank": 10, "reason": "A pump shotgun loaded with beanbags. Lethal shells print with Ballistic Ammunition.", "score": 20, "score_inputs": { @@ -795,7 +850,7 @@ "group": "shotguns", "hash": "8f3e8cf98b213a9b", "name": "shotgun", - "rank": 7, + "rank": 8, "reason": "A self-racking pump shotgun, with beanbags as issued.", "score": 33, "score_inputs": { @@ -868,6 +923,23 @@ }, "tier": "yellow" }, + "/obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly": { + "confidence": "medium", + "group": "shotguns", + "hash": "", + "name": "cycler shotgun", + "note": "Racks itself after every shot, so it never needs pumping. Has two 4-shell tubes, buckshot in one and slugs in the other by default; using it in hand switches the active tube (or racks, if the chamber is empty), and the shell already chambered still fires first. A point-blank shot throws the target back 2 tiles.", + "rank": 5, + "reason": "The cycler shotgun shipped with buckshot in one tube and slugs in the other. It takes rubber like the plain cycler.", + "score": 54, + "score_inputs": { + "judged": { + "value": 54, + "why": "The same gun as the plain cycler, which takes rubber shells too, and rubber is its best load; it scores the same." + } + }, + "tier": "yellow" + }, "/obj/item/gun/ballistic/shotgun/bulldog/unrestricted": { "confidence": "medium", "group": "shotguns", @@ -885,7 +957,7 @@ "hash": "0c9baf65ab7c8e64", "name": "double-barreled shotgun", "note": "Holds two shells with no pumping between shots, and a point-blank shot throws the target back 3 tiles. A saw-type tool (3 seconds) can shorten it; sawing with a shell chambered fires it at the sawer, and the sawn-off gun fits a belt instead of the back, gains recoil and takes a 25 spread penalty.", - "rank": 6, + "rank": 7, "reason": "Two shots then a reload, with beanbags as issued. It takes lethal shells.", "score": 36, "score_inputs": { @@ -910,7 +982,7 @@ "hash": "0a407fc564167813", "name": "breaching shotgun", "note": "Double-barrel (3-tile point-blank throw, can be sawn off) chambered for breaching shells, which do 5 damage to creatures but carry a 200x demolition modifier. The code comment says one shell breaks a window or grille and two breach an airlock door.", - "rank": 11, + "rank": 12, "reason": "Loaded to open doors: a door key, not a weapon.", "score": 14, "score_inputs": { @@ -927,7 +999,7 @@ "hash": "0a9911c1c6748c4a", "name": "hunting shotgun", "note": "Double-barrel (3-tile point-blank throw, can be sawn off) loaded with 12g slugs; nothing else differs from the plain double-barrel.", - "rank": 8, + "rank": 9, "reason": "Holds two slugs of 25. AP 30 helps, but two shots is the problem.", "score": 28, "score_inputs": { @@ -944,7 +1016,7 @@ "hash": "d9f220632d6f2013", "name": "hook modified sawn-off shotgun", "note": "Right-click fires an attached hook (1 charge, back in about 8 seconds) that drags the first unanchored thing it hits to the shooter's tile, knocking a creature down for half a second and freezing the shooter's movement until it arrives; the hook deals stamina damage and no brute damage. Left-click fires the shotgun, and the barrel hook gives it a sharp melee attack that can butcher corpses; a point-blank shot throws the target back 2 tiles.", - "rank": 5, + "rank": 6, "reason": "A sawn-off shotgun loaded with rubber, plus a hook that drags the target in.", "score": 49, "score_inputs": { @@ -960,7 +1032,7 @@ "group": "shotguns", "hash": "bdd1ea5bf292ed3f", "name": "shotgun", - "rank": 12, + "rank": 13, "reason": "A pump shotgun loaded with four buckshot shells.", "score": 8, "tier": "green" @@ -970,7 +1042,7 @@ "group": "shotguns", "hash": "b60ba70b55c1b7d5", "name": "Donk Co. Musket", - "rank": 13, + "rank": 14, "reason": "Fires one shell, loaded by hand.", "score": 0, "score_inputs": { @@ -997,7 +1069,7 @@ "hash": "b2de977a12875c5b", "name": "riot shotgun", "note": "Pump-action shotgun loaded with rubber shot, whose pellets ricochet off walls (ricochet chance falls 4 points per tile travelled); a point-blank shot throws the target back 2 tiles. A saw-type tool (3 seconds) can shorten it; sawing with a shell chambered fires it at the sawer, and the sawn-off gun fits a belt instead of the back, gains recoil and takes a 25 spread penalty.", - "rank": 10, + "rank": 11, "reason": "Weaker against a vest than a 400 cr disabler: a slow pump loaded with rubber. It takes lethal shells.", "score": 19, "score_inputs": { @@ -1942,7 +2014,6 @@ "/obj/item/gun": "abstract", "/obj/item/gun/ballistic": "abstract", "/obj/item/gun/ballistic/automatic": "abstract", - "/obj/item/gun/ballistic/automatic/battle_rifle": "no route", "/obj/item/gun/ballistic/automatic/c20r": "needs another system", "/obj/item/gun/ballistic/automatic/c20r/sc_c20r": "needs another system", "/obj/item/gun/ballistic/automatic/c20r/toy": "toy", @@ -1962,8 +2033,6 @@ "/obj/item/gun/ballistic/automatic/laser/ctf/yellow": "admin", "/obj/item/gun/ballistic/automatic/m90": "needs another system", "/obj/item/gun/ballistic/automatic/mini_uzi/chimpgun": "needs another system", - "/obj/item/gun/ballistic/automatic/pistol/clandestine": "leftover", - "/obj/item/gun/ballistic/automatic/pistol/clandestine/fisher": "leftover", "/obj/item/gun/ballistic/automatic/pistol/contraband": "leftover", "/obj/item/gun/ballistic/automatic/pistol/deagle/contraband": "leftover", "/obj/item/gun/ballistic/automatic/pistol/deagle/ctf": "admin", @@ -1989,7 +2058,6 @@ "/obj/item/gun/ballistic/revolver/reverse": "leftover", "/obj/item/gun/ballistic/revolver/reverse/mateba": "leftover", "/obj/item/gun/ballistic/rifle": "abstract", - "/obj/item/gun/ballistic/rifle/boltaction/harpoon": "leftover", "/obj/item/gun/ballistic/rifle/boltaction/pipegun/pistol/prime": "leftover", "/obj/item/gun/ballistic/rifle/enchanted": "leftover", "/obj/item/gun/ballistic/rifle/lionhunter": "leftover", @@ -2003,7 +2071,6 @@ "/obj/item/gun/ballistic/rocketlauncher/unrestricted/nanotrasen": "leftover", "/obj/item/gun/ballistic/shotgun/automatic/combat/compact": "leftover", "/obj/item/gun/ballistic/shotgun/automatic/dual_tube/bounty": "leftover", - "/obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly": "leftover", "/obj/item/gun/ballistic/shotgun/bulldog": "needs another system", "/obj/item/gun/ballistic/shotgun/ctf": "admin", "/obj/item/gun/ballistic/shotgun/ctf/blue": "admin", diff --git a/tools/gear_catalog/ratings/storage.json b/tools/gear_catalog/ratings/storage.json index e4dbdf64bb9..9c01b42d1ad 100644 --- a/tools/gear_catalog/ratings/storage.json +++ b/tools/gear_catalog/ratings/storage.json @@ -125,6 +125,15 @@ "reason": "Uniforms and a coat for one job. Nothing here helps a fight.", "tier": "none" }, + "/obj/item/storage/bag/harpoon_quiver": { + "confidence": "medium", + "group": "field and syndicate kits", + "hash": "", + "name": "harpoon quiver", + "rank": 6, + "reason": "Forty harpoons for the harpoon gun. Rated with the ammo it carries.", + "tier": "yellow" + }, "/obj/item/storage/bag/mail": { "confidence": "high", "group": "work bags", @@ -589,7 +598,7 @@ "group": "field and syndicate kits", "hash": "9e9840567ff3fcc0", "name": "box of standard firing pins", - "rank": 9, + "rank": 10, "reason": "Five spare firing pins, for guns found without one. Sold singly for 250 credits.", "tier": "green" }, @@ -598,7 +607,7 @@ "group": "field and syndicate kits", "hash": "713a9f86445310bf", "name": "box of syndicate firing pins", - "rank": 10, + "rank": 11, "reason": "Five pins that only fire for someone with a firearms implant only one ruin has. Mostly a way to ruin a gun.", "tier": "none" }, @@ -661,7 +670,7 @@ "group": "field and syndicate kits", "hash": "8d5f14f14f1a4229", "name": "floor tile camo box", - "rank": 7, + "rank": 8, "reason": "Floor-tile camouflage with combat gloves, boots and balaclava. The gloves are the useful part.", "tier": "green" }, @@ -742,7 +751,7 @@ "group": "field and syndicate kits", "hash": "2f42fea1a3c77160", "name": "Followers of the Chaplain Kit", - "rank": 12, + "rank": 13, "reason": "Chaplain hoodies for a small cult. Clothes.", "tier": "none" }, @@ -922,7 +931,7 @@ "group": "field and syndicate kits", "hash": "be444c6cc9f90033", "name": "boxed tracking implant kit", - "rank": 6, + "rank": 7, "reason": "Two tracking implants, two beacon implants and a locator, for keeping tabs on a crewmate or a captive. Costs mining points.", "tier": "green" }, @@ -1723,7 +1732,7 @@ "group": "field and syndicate kits", "hash": "b8e1d69b73958aa5", "name": "lockbox", - "rank": 13, + "rank": 14, "reason": "An empty lockbox.", "tier": "none" }, @@ -1732,7 +1741,7 @@ "group": "field and syndicate kits", "hash": "072f8dd47e6d407a", "name": "lockbox of mindshield implants", - "rank": 11, + "rank": 12, "reason": "Three mindshield implants. With no antagonists there is nothing for them to stop.", "tier": "none" }, @@ -1948,7 +1957,7 @@ "group": "field and syndicate kits", "hash": "50d9821f50f1e319", "name": "emergency space suits", - "rank": 8, + "rank": 9, "reason": "Two orange space suits, masks, oxygen, pickaxes and a shelter capsule in a wall cabinet. Two ruins have one.", "tier": "green" }, @@ -1957,7 +1966,7 @@ "group": "field and syndicate kits", "hash": "50d9821f50f1e319", "name": "emergency space suits", - "rank": 8, + "rank": 9, "reason": "Two orange space suits, masks, oxygen, pickaxes and a shelter capsule in a wall cabinet. Two ruins have one.", "tier": "green" }, @@ -2014,7 +2023,6 @@ "/obj/item/storage/bag/garment": "no route", "/obj/item/storage/bag/garment/engineering_chief": "no route", "/obj/item/storage/bag/garment/warden": "no route", - "/obj/item/storage/bag/harpoon_quiver": "no route", "/obj/item/storage/bag/ore/cyborg": "no route", "/obj/item/storage/bag/plants/cyborg": "no route", "/obj/item/storage/bag/quiver": "no route", diff --git a/voidcrew/modules/loot/loot_guns.dm b/voidcrew/modules/loot/loot_guns.dm index 032385540a7..e7c2cacf9c7 100644 --- a/voidcrew/modules/loot/loot_guns.dm +++ b/voidcrew/modules/loot/loot_guns.dm @@ -48,11 +48,13 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( LOOT_WEAPON_GREEN = list( /obj/item/gun/ballistic/rifle/boltaction/surplus = 10, /obj/item/gun/ballistic/rifle/nt_m96 = 10, + /obj/item/gun/ballistic/automatic/battle_rifle = 10, ), LOOT_WEAPON_YELLOW = list( /obj/item/gun/energy/laser = 10, /obj/item/gun/energy/e_gun = 10, /obj/item/gun/ballistic/automatic/wt550 = 10, + /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/energy/laser/carbine = 10, @@ -70,6 +72,7 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( LOOT_WEAPON_YELLOW = list( /obj/item/gun/ballistic/automatic/c20r/unrestricted = 10, /obj/item/gun/ballistic/automatic/mini_uzi = 10, + /obj/item/gun/ballistic/automatic/pistol/clandestine = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/automatic/l6_saw/unrestricted = 10, @@ -85,6 +88,7 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( ), LOOT_WEAPON_YELLOW = list( /obj/item/gun/ballistic/revolver = 10, + /obj/item/gun/ballistic/rifle/boltaction/harpoon = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/shotgun/bulldog/unrestricted = 10, diff --git a/voidcrew/modules/loot/themes/armory.dm b/voidcrew/modules/loot/themes/armory.dm index 55c12eb3b7d..47a097e0838 100644 --- a/voidcrew/modules/loot/themes/armory.dm +++ b/voidcrew/modules/loot/themes/armory.dm @@ -62,6 +62,7 @@ ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/automatic/m90/unrestricted = 1, + /obj/item/gun/ballistic/rocketlauncher/unrestricted = 1, /obj/item/gun/energy/e_gun/hos = 1, /obj/item/gun/energy/e_gun/nuclear = 1, /obj/item/gun/energy/laser/carbine/cybersun/unrestricted = 1, diff --git a/voidcrew/modules/loot/themes/syndicate.dm b/voidcrew/modules/loot/themes/syndicate.dm index 1f63090058e..25fa154ed2d 100644 --- a/voidcrew/modules/loot/themes/syndicate.dm +++ b/voidcrew/modules/loot/themes/syndicate.dm @@ -17,12 +17,14 @@ gun_pool = LOOT_GUN_POOL_SYNDICATE gun_extras = list( LOOT_WEAPON_YELLOW = list( + /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher = 1, /obj/item/gun/ballistic/automatic/pistol/deagle = 1, /obj/item/gun/ballistic/revolver = 1, /obj/item/gun/ballistic/revolver/c38/housecall = 1, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/rifle/sniper_rifle = 1, + /obj/item/gun/ballistic/rocketlauncher/unrestricted = 1, /obj/item/gun/ballistic/shotgun/automatic/combat = 1, ), ) diff --git a/voidcrew/modules/loot/zone_loot.dm b/voidcrew/modules/loot/zone_loot.dm index 28002a9712b..7c5ac774346 100644 --- a/voidcrew/modules/loot/zone_loot.dm +++ b/voidcrew/modules/loot/zone_loot.dm @@ -58,12 +58,15 @@ GLOBAL_LIST_INIT(loot_gun_spare_ammo, list( // detachable magazines /obj/item/gun/ballistic/automatic/ar = /obj/item/ammo_box/magazine/m223, + /obj/item/gun/ballistic/automatic/battle_rifle = /obj/item/ammo_box/magazine/m38, /obj/item/gun/ballistic/automatic/c20r/unrestricted = /obj/item/ammo_box/magazine/smgm45, /obj/item/gun/ballistic/automatic/gyropistol = /obj/item/ammo_box/magazine/m75, /obj/item/gun/ballistic/automatic/l6_saw/unrestricted = /obj/item/ammo_box/magazine/m7mm, /obj/item/gun/ballistic/automatic/m90 = /obj/item/ammo_box/magazine/m223, /obj/item/gun/ballistic/automatic/mini_uzi = /obj/item/ammo_box/magazine/uzim9mm, /obj/item/gun/ballistic/automatic/pistol = /obj/item/ammo_box/magazine/m9mm, + /obj/item/gun/ballistic/automatic/pistol/clandestine = /obj/item/ammo_box/magazine/m10mm, + /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher = /obj/item/ammo_box/magazine/m10mm, /obj/item/gun/ballistic/automatic/pistol/deagle = /obj/item/ammo_box/magazine/m50, /obj/item/gun/ballistic/automatic/pistol/m1911 = /obj/item/ammo_box/magazine/m45, /obj/item/gun/ballistic/automatic/pistol/zashch = /obj/item/ammo_box/magazine/zashch, @@ -78,10 +81,13 @@ GLOBAL_LIST_INIT(loot_gun_spare_ammo, list( /obj/item/gun/ballistic/revolver/c38/detective = /obj/item/ammo_box/c38, /obj/item/gun/ballistic/revolver/mateba = /obj/item/ammo_box/a357, /obj/item/gun/ballistic/rifle/boltaction = /obj/item/ammo_box/strilka310, + /obj/item/gun/ballistic/rifle/boltaction/harpoon = /obj/item/storage/bag/harpoon_quiver, /obj/item/gun/ballistic/rifle/boltaction/prime = /obj/item/ammo_box/strilka310, /obj/item/gun/ballistic/rifle/boltaction/surplus = /obj/item/ammo_box/strilka310, + /obj/item/gun/ballistic/rocketlauncher/unrestricted = /obj/item/ammo_casing/rocket, // break/pump actions: a box of shells /obj/item/gun/ballistic/shotgun/automatic/combat = /obj/item/storage/box/lethalshot, + /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly = /obj/item/storage/box/lethalshot, /obj/item/gun/ballistic/shotgun/doublebarrel = /obj/item/storage/box/lethalshot, /obj/item/gun/ballistic/shotgun/riot = /obj/item/storage/box/lethalshot, )) diff --git a/voidcrew/modules/research/designs/ammo_catalogue.dm b/voidcrew/modules/research/designs/ammo_catalogue.dm index 0c78863118a..d57c3565282 100644 --- a/voidcrew/modules/research/designs/ammo_catalogue.dm +++ b/voidcrew/modules/research/designs/ammo_catalogue.dm @@ -70,6 +70,14 @@ /datum/material/iron = SHEET_MATERIAL_AMOUNT * 3, ) +/datum/design/vc_ammo/harpoon + name = "Harpoon" + id = "vc_ammo_harpoon" + build_path = /obj/item/ammo_casing/harpoon + materials = list( + /datum/material/iron = SHEET_MATERIAL_AMOUNT * 2, + ) + /datum/design/vc_ammo/foam_smg name = "Foam Force SMG Magazine (Foam Darts)" id = "vc_ammo_foam_smg" @@ -166,6 +174,35 @@ /datum/material/plasma = SHEET_MATERIAL_AMOUNT * 2, ) +/datum/design/vc_ammo/m10mm_ap + name = "Handgun Magazine (10mm AP)" + id = "vc_ammo_m10mm_ap" + build_path = /obj/item/ammo_box/magazine/m10mm/ap + materials = list( + /datum/material/iron = SHEET_MATERIAL_AMOUNT * 3, + /datum/material/plastic = SHEET_MATERIAL_AMOUNT, + /datum/material/titanium = SHEET_MATERIAL_AMOUNT * 2, + ) + +/datum/design/vc_ammo/m10mm_hp + name = "Handgun Magazine (10mm HP)" + id = "vc_ammo_m10mm_hp" + build_path = /obj/item/ammo_box/magazine/m10mm/hp + materials = list( + /datum/material/iron = SHEET_MATERIAL_AMOUNT * 5, + /datum/material/plastic = SHEET_MATERIAL_AMOUNT, + ) + +/datum/design/vc_ammo/m10mm_fire + name = "Handgun Magazine (10mm Incendiary)" + id = "vc_ammo_m10mm_fire" + build_path = /obj/item/ammo_box/magazine/m10mm/fire + materials = list( + /datum/material/iron = SHEET_MATERIAL_AMOUNT * 3, + /datum/material/plastic = SHEET_MATERIAL_AMOUNT, + /datum/material/plasma = SHEET_MATERIAL_AMOUNT * 2, + ) + /datum/design/vc_ammo/aps_ap name = "APS Magazine (9mm AP)" id = "vc_ammo_aps_ap" diff --git a/voidcrew/modules/research/techweb_nodes.dm b/voidcrew/modules/research/techweb_nodes.dm index a93d4232d6b..d36e664ab58 100644 --- a/voidcrew/modules/research/techweb_nodes.dm +++ b/voidcrew/modules/research/techweb_nodes.dm @@ -422,6 +422,7 @@ "vc_shotgun_dragonsbreath", "vc_ammo_uzi", "vc_strilka310_clip", + "vc_ammo_harpoon", "suppressor", ) research_costs = list(TECHWEB_POINT_TYPE_GENERIC = TECHWEB_TIER_3_POINTS) @@ -436,6 +437,9 @@ "vc_ammo_m9mm_ap", "vc_ammo_m9mm_hp", "vc_ammo_m9mm_fire", + "vc_ammo_m10mm_ap", + "vc_ammo_m10mm_hp", + "vc_ammo_m10mm_fire", "vc_ammo_aps_ap", "vc_ammo_aps_hp", "vc_ammo_aps_fire", diff --git a/voidcrew/modules/trade/shop_catalog_black_market.dm b/voidcrew/modules/trade/shop_catalog_black_market.dm index e2d97a6186e..624c57f31dc 100644 --- a/voidcrew/modules/trade/shop_catalog_black_market.dm +++ b/voidcrew/modules/trade/shop_catalog_black_market.dm @@ -66,6 +66,10 @@ /datum/shop_sku/black_market/rapier, /datum/shop_sku/black_market/zashch, /datum/shop_sku/black_market/nt_m96, + /datum/shop_sku/black_market/battle_rifle, + /datum/shop_sku/black_market/ansem, + /datum/shop_sku/black_market/ansem_sc, + /datum/shop_sku/black_market/lethal_cycler, /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/black_market/pistol_mag, /datum/shop_sku/black_market/speedloader, @@ -918,6 +922,55 @@ fixed_price = TRUE contract_reward = FALSE +/datum/shop_sku/black_market/battle_rifle + category = "Weapons" + desc = "Nanotrasen's prototype security rifle, chambered in .38." + item_path = /obj/item/gun/ballistic/automatic/battle_rifle + price_credits = 2200 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/black_market/ansem + zone_permission = ZONE_PERMISSION_YELLOW + category = "Weapons" + item_path = /obj/item/gun/ballistic/automatic/pistol/clandestine + price_credits = 2000 + price_vouchers = 1 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/black_market/ansem_sc + zone_permission = ZONE_PERMISSION_YELLOW + category = "Weapons" + desc = "An Ansem with the suppressor built in and a light disruptor under the barrel. Chambered in 10mm." + item_path = /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher + price_credits = 2800 + price_vouchers = 1 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/black_market/lethal_cycler + zone_permission = ZONE_PERMISSION_YELLOW + category = "Weapons" + desc = "A cycler shotgun with buckshot in one tube and slugs in the other." + item_path = /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly + price_credits = 2400 + price_vouchers = 1 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + /// Counter-bought caches retain themed supplies, with a single freight weapon roll. /// Kept separate from guarded caches so buying a crate cannot buy advanced guns. /obj/structure/closet/crate/zone_loot/syndicate/trader diff --git a/voidcrew/modules/unit_tests/voidcrew_loot.dm b/voidcrew/modules/unit_tests/voidcrew_loot.dm index a336887a493..bc6763d74fb 100644 --- a/voidcrew/modules/unit_tests/voidcrew_loot.dm +++ b/voidcrew/modules/unit_tests/voidcrew_loot.dm @@ -291,7 +291,7 @@ /// A working copy may be consumed without damaging another crate's singleton tables. /datum/unit_test/voidcrew_cache_weapon_pools/Run() - var/list/main_counts = list("armory" = list(2, 3, 5), "syndicate" = list(2, 2, 3), "plunder" = list(3, 1, 3), "freight" = list(4, 2)) + var/list/main_counts = list("armory" = list(3, 4, 5), "syndicate" = list(2, 3, 3), "plunder" = list(3, 2, 3), "freight" = list(4, 2)) for(var/family in main_counts) var/list/counts = main_counts[family] var/list/classes = GLOB.loot_gun_pools[family] @@ -690,6 +690,7 @@ /obj/item/bambostaff, /obj/item/claymore/shortsword, /obj/item/fireaxe, + /obj/item/gun/ballistic/automatic/battle_rifle, /obj/item/gun/ballistic/automatic/pistol, /obj/item/gun/ballistic/automatic/pistol/m1911, /obj/item/gun/ballistic/automatic/pistol/m1911/no_mag, @@ -743,6 +744,8 @@ /obj/item/gun/ballistic/automatic/c20r/unrestricted, /obj/item/gun/ballistic/automatic/mini_uzi, /obj/item/gun/ballistic/automatic/pistol/aps, + /obj/item/gun/ballistic/automatic/pistol/clandestine, + /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher, /obj/item/gun/ballistic/automatic/pistol/deagle, /obj/item/gun/ballistic/automatic/pistol/deagle/camo, /obj/item/gun/ballistic/automatic/pistol/deagle/gold, @@ -755,10 +758,12 @@ /obj/item/gun/ballistic/revolver/mateba, /obj/item/gun/ballistic/rifle/boltaction, /obj/item/gun/ballistic/rifle/boltaction/donkrifle, + /obj/item/gun/ballistic/rifle/boltaction/harpoon, /obj/item/gun/ballistic/rifle/boltaction/pipegun/prime, /obj/item/gun/ballistic/rifle/boltaction/prime, /obj/item/gun/ballistic/rifle/rebarxbow/forced, /obj/item/gun/ballistic/shotgun, + /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, /obj/item/gun/ballistic/shotgun/lethal, /obj/item/gun/ballistic/shotgun/musket/no_quarter, /obj/item/gun/ballistic/shotgun/riot, @@ -819,6 +824,7 @@ /obj/item/gun/ballistic/automatic/m90/unrestricted, /obj/item/gun/ballistic/automatic/tommygun, /obj/item/gun/ballistic/rifle/sniper_rifle, + /obj/item/gun/ballistic/rocketlauncher/unrestricted, /obj/item/gun/ballistic/shotgun/automatic/combat, /obj/item/gun/ballistic/shotgun/bulldog/unrestricted, /obj/item/gun/energy/e_gun/hos, diff --git a/voidcrew/modules/unit_tests/voidcrew_nova_guns.dm b/voidcrew/modules/unit_tests/voidcrew_nova_guns.dm index 10cd337d797..ee8809ad8a3 100644 --- a/voidcrew/modules/unit_tests/voidcrew_nova_guns.dm +++ b/voidcrew/modules/unit_tests/voidcrew_nova_guns.dm @@ -85,3 +85,19 @@ TEST_ASSERT(mauser.bolt_locked, "The M-96 should lock open when it runs dry") TEST_ASSERT_NULL(mauser.chambered, "A dry M-96 still has a round chambered") TEST_ASSERT_EQUAL(mauser.get_ammo(TRUE), 0, "A dry M-96 still reports rounds") + +/// The older guns that joined the shops and loot tables feel like what they are, and the type rules agree. +/datum/unit_test/voidcrew_old_guns_feel +/datum/unit_test/voidcrew_old_guns_feel/Run() + var/list/classes = list( + /obj/item/gun/ballistic/automatic/battle_rifle = "rifle", + /obj/item/gun/ballistic/automatic/pistol/clandestine = "pistol", + /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher = "pistol", + /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly = "shotgun", + /obj/item/gun/ballistic/rifle/boltaction/harpoon = "marksman", + /obj/item/gun/ballistic/rocketlauncher/unrestricted = "launcher", + ) + for(var/gun_path in classes) + var/obj/item/gun/gun = allocate(gun_path) + gun.gun_feel_resolve() + TEST_ASSERT_EQUAL(gun.gun_feel_class, classes[gun_path], "[gun_path] has the wrong feel class") diff --git a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm index 443b0436b44..cbf95698768 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm @@ -344,6 +344,10 @@ list(/datum/shop_sku/black_market/rapier, /obj/item/gun/ballistic/automatic/rapier, 1, 2400, 0), list(/datum/shop_sku/black_market/zashch, /obj/item/gun/ballistic/automatic/pistol/zashch, 1, 1200, 0), list(/datum/shop_sku/black_market/nt_m96, /obj/item/gun/ballistic/rifle/nt_m96, 1, 1800, 0), + list(/datum/shop_sku/black_market/battle_rifle, /obj/item/gun/ballistic/automatic/battle_rifle, 1, 2200, 0), + list(/datum/shop_sku/black_market/ansem, /obj/item/gun/ballistic/automatic/pistol/clandestine, 1, 2000, 1), + list(/datum/shop_sku/black_market/ansem_sc, /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher, 1, 2800, 1), + list(/datum/shop_sku/black_market/lethal_cycler, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, 1, 2400, 1), list(/datum/shop_sku/black_market/rotating/mateba, /obj/item/gun/ballistic/revolver/mateba, 1, 3000, 1), ), /datum/outpost_shop/vendor/ripperdoc = list( @@ -397,6 +401,10 @@ /obj/item/gun/ballistic/automatic/rapier, /obj/item/gun/ballistic/automatic/pistol/zashch, /obj/item/gun/ballistic/rifle/nt_m96, + /obj/item/gun/ballistic/automatic/battle_rifle, + /obj/item/gun/ballistic/automatic/pistol/clandestine, + /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher, + /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, /obj/item/gun/ballistic/revolver/mateba, ) var/list/printed_gear = list( @@ -514,6 +522,8 @@ /datum/shop_sku/outfitter/laser_gun, /datum/shop_sku/outfitter/energy_gun, /datum/shop_sku/outfitter/riot_shotgun, /datum/shop_sku/black_market/c20r, /datum/shop_sku/black_market/wt550, /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/black_market/rapier, /datum/shop_sku/black_market/zashch, /datum/shop_sku/black_market/nt_m96, + /datum/shop_sku/black_market/battle_rifle, /datum/shop_sku/black_market/ansem, /datum/shop_sku/black_market/ansem_sc, + /datum/shop_sku/black_market/lethal_cycler, /datum/shop_sku/black_market/cutlass, /datum/shop_sku/black_market/rare/energy_sword, /datum/shop_sku/ripperdoc/monowire, /datum/shop_sku/ripperdoc/gorilla, /datum/shop_sku/ripperdoc/mantis, /datum/shop_sku/general/rotating/freight_crate, /datum/shop_sku/black_market/mystery_cache, /datum/shop_sku/black_market/mystery_cache_rare, diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_research.dm b/voidcrew/modules/unit_tests/voidcrew_zone_research.dm index 375134ee545..0fd5461a43f 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_research.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_research.dm @@ -155,8 +155,8 @@ "nanite_remotes" = list("nanite_remote", "nanite_comm_remote"), "restricted_tech" = list("largecrossbow", "borg_syndicate_module"), "desynchronization" = list("desynchronizer"), - "automatic_ammo" = list("vc_strilka310_clip", "suppressor"), - "specialist_ammo" = list("sec_Islug", "vc_ammo_smartgun", "c38_flare", "c38_flare_mag"), + "automatic_ammo" = list("vc_strilka310_clip", "suppressor", "vc_ammo_harpoon"), + "specialist_ammo" = list("sec_Islug", "vc_ammo_smartgun", "c38_flare", "c38_flare_mag", "vc_ammo_m10mm_ap", "vc_ammo_m10mm_hp", "vc_ammo_m10mm_fire"), TECHWEB_NODE_ELECTRIC_WEAPONS = list("nuclear_gun"), TECHWEB_NODE_BEAM_WEAPONS = list("stunrevolver"), ) @@ -192,7 +192,7 @@ for(var/design_id in list("pin_testing", "pin_explorer")) var/datum/design/design = SSresearch.techweb_designs[design_id] TEST_ASSERT_EQUAL(length(design.unlocked_by), 0, "[design_id] came back on a research node") - for(var/design_id in list("vc_ammo_harpoon", "vc_ammo_gyrojet", "vc_ammo_m10mm_ap", "vc_ammo_m10mm_hp", "vc_ammo_m10mm_fire")) + for(var/design_id in list("vc_ammo_gyrojet")) TEST_ASSERT_NULL(SSresearch.techweb_designs[design_id], "[design_id] came back") var/datum/design/incendiary = SSresearch.techweb_designs["incendiary_slug"] TEST_ASSERT(!(RND_CATEGORY_HACKED in incendiary.category), "A hacked autolathe goes around Specialist Ammunition for incendiary slugs") diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm index 031ef4ab323..825dffa816a 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm @@ -33,6 +33,9 @@ /datum/shop_sku/black_market/rotating/mateba = 1, /datum/shop_sku/black_market/c20r = 1, /datum/shop_sku/black_market/wt550 = 1, + /datum/shop_sku/black_market/ansem = 1, + /datum/shop_sku/black_market/ansem_sc = 1, + /datum/shop_sku/black_market/lethal_cycler = 1, /datum/shop_sku/outfitter/rare/ablative_vest = 1, /datum/shop_sku/skunk/rotating/thermal_glasses = 1, /datum/shop_sku/black_market/thermals = 1, @@ -128,7 +131,7 @@ /datum/unit_test/voidcrew_zone_shops/catalog/Run() var/list/expected = expected_tags() - TEST_ASSERT_EQUAL(length(expected), 98, "The retained permission catalog is incomplete") + TEST_ASSERT_EQUAL(length(expected), 101, "The retained permission catalog is incomplete") var/datum/voidcrew_crew_record/crew = allocate(/datum/voidcrew_crew_record) for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku)) var/permission = expected[sku_type] || 0 From 1581e8620c5bf7410b4d451d63680f57548dd1d4 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:43:34 -0800 Subject: [PATCH 190/472] Make a combat sense test list a plain local --- voidcrew/modules/unit_tests/voidcrew_human_npcs_combat_sense.dm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 360b1dec503..89535b152e8 100644 --- a/voidcrew/modules/unit_tests/voidcrew_human_npcs_combat_sense.dm +++ b/voidcrew/modules/unit_tests/voidcrew_human_npcs_combat_sense.dm @@ -303,7 +303,7 @@ /datum/unit_test/voidcrew_human_npc_combat_sense/who /datum/unit_test/voidcrew_human_npc_combat_sense/who/Run() - var/static/list/never = list( + var/list/never = list( /mob/living/carbon/human/npc/ambient_npc, /mob/living/carbon/human/npc/outpost_prisoner, /mob/living/carbon/human/npc/bounty_criminal/meek, From 97aee3f1475bc5e68ccd6ff7b56c321b97568ac0 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:44:23 -0800 Subject: [PATCH 191/472] Update the bounty wiki for the mini-bosses' new gear --- wiki/content/bounty-hunting.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/wiki/content/bounty-hunting.md b/wiki/content/bounty-hunting.md index 7d5d095c05a..fce2af0509a 100644 --- a/wiki/content/bounty-hunting.md +++ b/wiki/content/bounty-hunting.md @@ -115,16 +115,16 @@ Every Most Wanted criminal is a mini-boss with one of six kits. The board's **Ot - Every hunter after the first brings one more of their crew, who turns up from somewhere out of sight. - Every ability winds up first, with a sound and a mark on the floor. Move out of the mark. Fire, blasts and blades hurt whoever is standing there, down or not. - They break tables, chairs, windows and grilles, and up to two interior walls or doors in a fight. -- At the start of a fight they jab a stimulant: for a while they shrug off batons and get up from stuns quickly. They carry a second for later. +- When they tire they jab a stimulant: for a while they shrug off batons and get up from stuns quickly. They carry two. - Badly hurt or out of breath they're **worn out**: "breathing hard and slowing down", and they fall back for a moment. That's the time to stun and cuff them. | Kit | Looks like | What it does | How to handle it | |---|---|---|---| -| Juggernaut | A giant in riot plates | Charges along a line after a roar, smashing what's in the way. Slams the floor around it when you're close. | Step off the charge line. If it charges into something it can't break, it hurts itself and falls down for a few seconds. | -| Pyromaniac | A scorched criminal in a fire suit | Sweeps a cone of fire 3 tiles long and throws molotovs that leave burning floor. Its fire suit keeps the flames off it; the space suit it wears at space ruins doesn't. | Stay more than 3 tiles away and off burning floor. If you catch fire, stop, drop and roll, or use an extinguisher. | -| Demolitionist | A criminal in a hard hat with a grenade bandolier | Keeps 4 to 6 tiles away and throws grenades with a blinking fuse. Blows holes in interior walls and doors to reach you behind cover. | Close the distance, and move off the blinking ring. | -| Ghost | A hooded criminal with a combat knife | Fast. Dashes in with knife cuts that bleed, cloaks into a shimmer, and backs off after two cuts. Its implants make shots miss now and then while it moves, much more often during its burst of speed. | A flash or a hit breaks the cloak, and an EMP ends the burst. Shoot it when it stops moving. | -| Heavy | A heavily armed criminal with a belt-fed gun | Heavy armour, slow. Shows a laser sight and a red cone, then fires a burst into it. Throws barrier grenades that open into a barricade. | Get out of the red cone before it fires. Go around the barricade. | +| Juggernaut | A giant in riot plates with a tower shield | Raises its shield when the fight starts; shots from in front stop at it. Charges along a line after a roar, smashing what's in the way. Slams the floor around it when you're close. | Hit it from the side or behind. Step off the charge line. If it charges into something it can't break, it hurts itself and falls down for a few seconds. | +| Pyromaniac | A scorched criminal in a fire suit | Goes for whoever shoots it. Sweeps a cone of fire 3 tiles long and throws molotovs that leave burning floor. Its fire suit keeps the flames off it; the space suit it wears at space ruins doesn't. | Keep your distance and keep moving. Stay more than 3 tiles away and off burning floor. If you catch fire, stop, drop and roll, or use an extinguisher. | +| Demolitionist | A criminal in a hard hat with a grenade bandolier | Keeps 4 to 6 tiles away and throws grenades with a blinking fuse. Blows holes in interior walls and doors to reach you behind cover. An implant gets it back up a second after you first drop it. | Close the distance, and move off the blinking ring. Put it down again and cuff it; an EMP knocks the implant out. | +| Ghost | A hooded criminal with a combat knife | Fast. Dashes in with knife cuts that bleed, cloaks into a shimmer, and backs off after two cuts. Shot from afar, it ducks out of your line and comes back from cover. Its implants make shots miss now and then while it moves, much more often during its burst of speed. | A flash or a hit breaks the cloak, and an EMP ends the burst. Shoot it when it stops moving. Corner it; hunters on more sides leave it nowhere to hide. | +| Heavy | A heavily armed criminal in a hooded coat, with a belt-fed gun | An ablative coat and hood turn aside about half of laser and disabler shots. Shows a laser sight and a red cone, then fires a burst into it. Throws barrier grenades that open into a barricade. | Get out of the red cone before it fires. Go around the barricade. Use bullets, or bring a lot of disablers. | | Cyberpsycho (rare) | A twitching criminal in shorts, chrome on its arms, chest and legs | Fights with a blade from its forearm. Lunges down a lit line, leaps onto you under a red mark, and throws a piston punch. Its chrome fist breaks windows. Hurt, or hit with a stun weapon, it roars and shrugs off stuns for a while, then collapses. Gets up once after it goes down. | Get off the line and out from under the mark. Back off while it roars, and cuff it when it collapses. An EMP makes its chrome spark and shuts it down for a while, and then it tires like the others. | !!! warning "Worn out means switch to stuns" From f0164a996f5cdee2ca95fe871d18969c7986a152 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:40:56 -0800 Subject: [PATCH 192/472] Give the bounty Pyromaniac two smoke grenades Shot from past its flamer's reach, by stun fire or anything else, the Pyromaniac takes out one of two real tg smoke grenades it carries in its pockets, pulls the pin and throws it halfway to the shooter. The fuse is the grenade's own (3 seconds, tg's shortest timed setting) and the cloud is tg's: it blocks sight, weakens beams and makes anyone breathing it without internals cough and drop what they hold. It won't throw at someone in its flamer's reach, while cuffed, downed or stunned, or more than once in 20 seconds. Stunned during its wind-up the pin stays in. Out of sight of its shooter it now walks to where the shots last came from instead of to where the shooter really is. The grenades are issued gear, like the Heavy's barrier grenades: nobody can strip them, and taken off it they are gone. --- voidcrew/_DEFINES/bounty_bosses.dm | 11 ++ voidcrew/modules/bounties/bounty_boss_kits.dm | 130 ++++++++++++++++- .../bounties/strings/bounty_criminals.json | 10 ++ .../unit_tests/voidcrew_bounty_bosses.dm | 134 +++++++++++++++++- wiki/content/bounty-hunting.md | 2 +- 5 files changed, 281 insertions(+), 6 deletions(-) diff --git a/voidcrew/_DEFINES/bounty_bosses.dm b/voidcrew/_DEFINES/bounty_bosses.dm index d5573d3c9d2..bbc5c38aae7 100644 --- a/voidcrew/_DEFINES/bounty_bosses.dm +++ b/voidcrew/_DEFINES/bounty_bosses.dm @@ -165,6 +165,17 @@ /// Fire stacks (tg's) the bursting bottle puts on anyone it splashes. (phase D, CB-8) #define BOUNTY_PYROMANIAC_MOLOTOV_STACKS 3 #define BOUNTY_PYROMANIAC_MOLOTOV_POOL (8 SECONDS) +/// Smoke (C9): tg's smoke grenade, two in its pockets, on tg's shortest timed fuse (a screwdriver setting a player can pick too) +#define BOUNTY_PYROMANIAC_SMOKE /obj/item/grenade/smokebomb/bounty_boss +#define BOUNTY_PYROMANIAC_SMOKE_FUSE (3 SECONDS) +/// It takes a grenade out and stands this long, then pulls the pin and throws (the Heavy's barrier grenade wind-up) +#define BOUNTY_PYROMANIAC_SMOKE_WINDUP (0.6 SECONDS) +/// Longer than the cloud lasts (tg's 16 s), so it never stacks one on the last +#define BOUNTY_PYROMANIAC_SMOKE_COOLDOWN (20 SECONDS) +/// The furthest shooter it throws at; the nearest is anyone past its flamer's reach +#define BOUNTY_PYROMANIAC_SMOKE_RANGE 7 +/// How long after a hit from someone past its flamer's reach it still answers with smoke (stun fire counts while its combat sense says it is under fire) +#define BOUNTY_PYROMANIAC_SMOKE_HURT_WINDOW (3 SECONDS) // ===== DEMOLITIONIST ===== diff --git a/voidcrew/modules/bounties/bounty_boss_kits.dm b/voidcrew/modules/bounties/bounty_boss_kits.dm index 067fba60bb5..05464eb8550 100644 --- a/voidcrew/modules/bounties/bounty_boss_kits.dm +++ b/voidcrew/modules/bounties/bounty_boss_kits.dm @@ -18,7 +18,9 @@ * - Pyromaniac: a real firefighter suit and helmet (tg's thermal protection, CB-7) and a lit torch. * Sweeps a cone of fire and throws molotovs that leave a burning patch; whoever it catches burns with * tg's fire stacks (CB-8). Shot with a disabler, it goes for the shooter until its flamer reaches - * them (C8: keep your distance and keep moving). + * them (C8: keep your distance and keep moving). Shot from past its flamer's reach, it pulls the pin + * on one of its two real smoke grenades and throws it halfway to the shooter, then comes through the + * cloud to where the shots came from (C9: back out of the smoke, flank it, or see through it). * - Demolitionist: a hard hat, a hi-vis vest, a bandolier and a crowbar in its bag. Keeps its distance * and lobs grenades with a visible fuse, and blows a hole through an interior wall or door to get at * someone behind cover. Whoever a blast catches takes tg's light explosion (CB-10). A real CNS @@ -376,6 +378,7 @@ boss_ability_types = list( /datum/action/cooldown/mob_cooldown/bounty_boss/flamer, /datum/action/cooldown/mob_cooldown/bounty_boss/molotov, + /datum/action/cooldown/mob_cooldown/bounty_boss/smoke, ) /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/bounty_build_outfit(zone) @@ -398,10 +401,34 @@ /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_breaks_through_now(atom/barrier) return ..() || (!barrier.opacity && !isnull(boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/molotov))) +// Shot from past its flamer's reach, the smoke goes before anything else (C9) +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_choose_ability(atom/target) + var/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/ability = boss_ability_off_cooldown(/datum/action/cooldown/mob_cooldown/bounty_boss/smoke) + if(ability && !QDELETED(target) && ability.IsAvailable() && ability.worth_using(target)) + return ability + return ..() + +// Its smoke has its own line +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss_bark(suffix, fallback) + if(suffix == "ability" && istype(boss_winding(), /datum/action/cooldown/mob_cooldown/bounty_boss/smoke)) + return ..("smoke", "ability") + return ..() + +/// A smoke grenade it carries with the pin still in, in hand or where it can draw it from +/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/proc/boss_smoke_grenade() + for(var/obj/item/grenade/smokebomb/grenade in get_all_contents_type(/obj/item/grenade/smokebomb)) + if(!grenade.active && (is_holding(grenade) || human_npc_can_draw(src, grenade))) + return grenade + return null + /** * The Pyromaniac's answer to stun fire (C8): it goes for whoever is shooting it, until its flamer - * reaches them (combat sense's close-in), throwing a molotov or sweeping the flamer the moment either is - * ready (the boss rules let a ready ability go first). Keep your distance and keep moving. + * reaches them (combat sense's close-in), throwing smoke, a molotov or sweeping the flamer the moment one + * is ready (the boss rules let a ready ability go first). Keep your distance and keep moving. + * + * Out of its sight (in its own smoke, or round a corner) it makes for where the shots last came from, + * not for where the shooter is now (C9): tg's targeting drops whoever it can't see, so this is how it + * comes through the cloud. There it looks again; if they moved, it is the hunt's to find them. */ /datum/npc_combat_sense/bounty/boss/pyromaniac react_to_stun_fire = TRUE @@ -419,10 +446,19 @@ var/atom/aim = boss.boss_target_for(shooter) if(aim != controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] && boss.boss_valid_target(aim)) controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, aim) + if(!can_see(npc, shooter, HUMAN_NPC_SIGHT_RANGE)) + var/turf/spot = sense.threat_turf(npc) + if(isnull(spot) || get_dist(npc, spot) <= 1 || !tile_ok(npc, controller, spot)) + return + sense.charged_at = world.time + sense.decided_at = world.time + controller.set_blackboard_key(BB_HUMAN_NPC_STUN_SHOOTER, spot) + controller.queue_behavior(/datum/ai_behavior/human_npc_close_in, BB_HUMAN_NPC_STUN_SHOOTER, 1) + return SUBTREE_RETURN_FINISH_PLANNING return ..() // The firefighter suit and helmet are what keep its own fire off it (tg's thermal protection, CB-7). The torch -// doesn't holster, so it is in hand from the start. +// doesn't holster, so it is in hand from the start. A smoke grenade in each pocket (C9). /datum/outfit/bounty_boss/pyromaniac name = "Bounty mini-boss: Pyromaniac" uniform = /obj/item/clothing/under/color/black @@ -431,6 +467,8 @@ gloves = /obj/item/clothing/gloves/color/black shoes = /obj/item/clothing/shoes/workboots r_hand = BOUNTY_PYROMANIAC_TORCH + l_pocket = BOUNTY_PYROMANIAC_SMOKE + r_pocket = BOUNTY_PYROMANIAC_SMOKE /** * Flamer: the pilot light hisses and an orange cone shows on the floor, then a jet of fire sweeps @@ -529,6 +567,90 @@ playsound(spot, 'sound/effects/glass/glassbr3.ogg', 70, TRUE) new /obj/effect/bounty_boss_fire_pool(spot, splash_turfs(spot), BOUNTY_PYROMANIAC_MOLOTOV_POOL, owner, BOUNTY_PYROMANIAC_MOLOTOV_DAMAGE, BOUNTY_PYROMANIAC_MOLOTOV_STACKS) +/** + * Smoke: it takes out a real smoke grenade it carries, then pulls the pin and throws it halfway to + * whoever shot it from past its flamer's reach (C9). The fuse and the cloud are tg's own: the cloud + * blinds everyone, weakens beams that cross it and makes anyone breathing it without internals cough and + * drop what they hold. Downed during the wind-up, the pin stays in and nothing happens (the grenade is + * kept for later). Two grenades, two clouds: then it has none left. + */ +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke + name = "Smoke Grenade" + desc = "Throw a smoke grenade between you and whoever is shooting at you." + cooldown_time = BOUNTY_PYROMANIAC_SMOKE_COOLDOWN + windup = BOUNTY_PYROMANIAC_SMOKE_WINDUP + /// Where it will land, from the telegraph + var/turf/landing + +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/Destroy() + landing = null + return ..() + +// Someone it can see past its flamer's reach who has just shot it, with a grenade and a hand for it +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/worth_using(atom/target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss = owner + if(!aimable(target) || !istype(boss) || !boss.boss_valid_target(target)) + return FALSE + var/obj/item/grenade/smokebomb/grenade = boss.boss_smoke_grenade() + if(isnull(grenade) || (!boss.is_holding(grenade) && !length(boss.get_empty_held_indexes()))) + return FALSE + var/distance = get_dist(owner, target) + if(distance <= BOUNTY_PYROMANIAC_FLAMER_RANGE || distance > BOUNTY_PYROMANIAC_SMOKE_RANGE || !can_see(owner, target, BOUNTY_PYROMANIAC_SMOKE_RANGE)) + return FALSE + var/datum/component/npc_combat_sense/sense = boss.GetComponent(/datum/component/npc_combat_sense) + var/shot = sense?.under_fire() && boss.boss_target_for(sense.threat()) == target + if(!shot && (world.time > boss.boss_last_attacked_at + BOUNTY_PYROMANIAC_SMOKE_HURT_WINDOW || boss.boss_last_attacker?.resolve() != target)) + return FALSE + return !isnull(find_spot(target)) + +/** + * Where it throws: halfway to `target` and at least two tiles out, short of anything in the way, on + * ground it may aim at. That rule keeps ships, hangars and outposts more than the cloud's reach away (M2). + */ +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/proc/find_spot(atom/target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + var/turf/here = get_turf(owner) + var/turf/there = get_turf(target) + if(!istype(boss) || !here || !there) + return null + var/turf/spot = here + for(var/i in 1 to max(2, floor(get_dist(here, there) / 2))) + var/turf/next = get_step_towards(spot, there) + if(isnull(next) || next == there || next.is_blocked_turf(exclude_mobs = TRUE)) + break + spot = next + if(spot == here || !boss.boss_landing_ok(spot)) + return null + return spot + +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/telegraph(atom/target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/boss = owner + landing = find_spot(target) + var/obj/item/grenade/smokebomb/grenade = boss.boss_smoke_grenade() + if(isnull(grenade) || !human_npc_take_in_hand(boss, grenade) || !human_npc_ready_item(boss, grenade)) + return + owner.visible_message(span_boldwarning("[owner] pulls out a smoke grenade!")) + if(landing) + mark_turfs(list(landing), "#d8d8d8", "target_circle", /obj/effect/temp_visual/bounty_boss_thrown::duration) + +/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/effect(atom/target) + var/turf/spot = landing || get_turf(target) + landing = null + var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = owner + var/obj/item/grenade/smokebomb/grenade = locate() in boss.held_items + if(isnull(grenade) || grenade.active || !spot || !human_npc_ready_item(boss, grenade)) + return + // The player's way: pull the pin in hand, then throw it. From here the fuse is the grenade's own. + grenade.attack_self(boss) + boss.throw_mode_on(THROW_MODE_TOGGLE) + if(!boss.throw_item(spot)) + boss.throw_mode_off(THROW_MODE_TOGGLE) + boss.dropItemToGround(grenade) + +/// The Pyromaniac's smoke grenades: tg's smoke grenade, its fuse turned down to 3 seconds +/obj/item/grenade/smokebomb/bounty_boss + det_time = BOUNTY_PYROMANIAC_SMOKE_FUSE + /// Something thrown, flying from where it starts to `destination` /obj/effect/temp_visual/bounty_boss_thrown icon = 'icons/obj/weapons/grenade.dmi' diff --git a/voidcrew/modules/bounties/strings/bounty_criminals.json b/voidcrew/modules/bounties/strings/bounty_criminals.json index 8b8c58d7e8a..47473b80b92 100644 --- a/voidcrew/modules/bounties/strings/bounty_criminals.json +++ b/voidcrew/modules/bounties/strings/bounty_criminals.json @@ -540,6 +540,16 @@ "Light them up!" ] }, + "pyromaniac_smoke": { + "any": [ + "Hold your breath.", + "Lights out.", + "Bit smoky in here.", + "Where'd I go?", + "Breathe deep.", + "Mind your eyes." + ] + }, "pyromaniac_exhausted": { "any": [ "Running low on fuel...", diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm index 253aae6bb6b..0c922de7823 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm @@ -146,7 +146,8 @@ var/mob/living/carbon/human/npc/bounty_criminal/boss/boss = allocate(kit_type) TEST_ASSERT(ishuman(boss), "[kit_type] isn't a real human") TEST_ASSERT_EQUAL(boss.boss_kit, kits[kit_type], "[kit_type] has the wrong kit") - TEST_ASSERT_EQUAL(length(boss.boss_abilities), 2, "[kit_type] doesn't have its two abilities") + // Two each; the Pyromaniac's smoke is a third (C9) + TEST_ASSERT_EQUAL(length(boss.boss_abilities), kit_type == /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac ? 3 : 2, "[kit_type] has [length(boss.boss_abilities)] abilities") TEST_ASSERT(istype(boss.ai_controller, /datum/ai_controller/basic_controller/bounty_boss), "[kit_type] doesn't have the boss AI") TEST_ASSERT_EQUAL(boss.npc_rank, 2, "[kit_type] isn't ranked a boss") // HUMAN_NPC_RANK_BOSS // Toughness is armour and physiology, never health (defaults 1.5). @@ -1237,6 +1238,137 @@ TEST_ASSERT_NULL(boss.ai_controller.blackboard["human_npc_cover"], "[kit_type] ran for cover") qdel(boss) +/** + * The Pyromaniac's smoke (C9): two real smoke grenades in its pockets, issued as consumables. Shot from + * past its flamer's reach it takes one out, pulls the pin and throws it halfway to the shooter: never at someone in its + * flamer's reach, never cuffed, never again inside its cooldown, and never more than the two it carries. + * The cloud is tg's. With the shooter out of sight, it makes for where the shots came from. + */ +/datum/unit_test/voidcrew_bounty_boss/pyromaniac_smoke + +/datum/unit_test/voidcrew_bounty_boss/pyromaniac_smoke/Run() + var/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/pyro = allocate(/mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac, spot(0, 0)) + var/mob/living/carbon/human/consistent/shooter = hunter_at(spot(4, 4)) + var/mob/living/carbon/human/consistent/near = hunter_at(spot(1, 0)) + var/datum/ai_controller/controller = pyro.ai_controller + var/datum/action/cooldown/mob_cooldown/bounty_boss/smoke/smoke = ability_of(pyro, /datum/action/cooldown/mob_cooldown/bounty_boss/smoke) + TEST_ASSERT_NOTNULL(smoke, "The Pyromaniac has no smoke") + + // Two real smoke grenades, issued to it: nobody can strip them, and taken off it they are gone + TEST_ASSERT_EQUAL(carried(pyro, /obj/item/grenade/smokebomb), 2, "The Pyromaniac doesn't carry its two smoke grenades") + for(var/obj/item/grenade/smokebomb/grenade as anything in pyro.get_all_contents_type(/obj/item/grenade/smokebomb)) + TEST_ASSERT(human_npc_gear_consumable(grenade), "The Pyromaniac's smoke grenade isn't issued to it") + TEST_ASSERT(HAS_TRAIT(grenade, TRAIT_NO_STRIP), "The Pyromaniac's smoke grenade can be stripped") + TEST_ASSERT_EQUAL(grenade.det_time, 3 SECONDS, "The Pyromaniac's smoke grenade has a [grenade.det_time] fuse") // BOUNTY_PYROMANIAC_SMOKE_FUSE + TEST_ASSERT(!grenade.active && !pyro.is_holding(grenade), "The Pyromaniac has a smoke grenade out before any fight") + + TEST_ASSERT(fight(pyro, shooter), "Test fixture: the Pyromaniac didn't turn hostile") + ready(pyro) + TEST_ASSERT(!smoke.worth_using(shooter), "The Pyromaniac wanted its smoke before anyone shot it") + + // Shot from within its flamer's reach: no smoke + TEST_ASSERT(shoot(pyro, spot(1, 0), near), "Test fixture: the close bolt didn't land") + pyro.setStaminaLoss(0) + TEST_ASSERT(!smoke.worth_using(near), "The Pyromaniac wanted smoke for someone in its flamer's reach") + + // Shot from past it: the smoke goes first + TEST_ASSERT(shoot(pyro, spot(4, 4), shooter), "Test fixture: the bolt didn't land") + pyro.setStaminaLoss(0) + TEST_ASSERT_EQUAL(pyro.boss_choose_ability(shooter), smoke, "Shot from past its flamer's reach, the Pyromaniac didn't choose its smoke") + + // Stunned in its wind-up: the pin stays in, nothing is thrown, and the grenade is kept + TEST_ASSERT(smoke.Trigger(target = shooter), "The smoke didn't start for the stun check") + var/obj/item/grenade/smokebomb/kept = locate() in pyro.held_items + pyro.Paralyze(1 SECONDS) + TEST_ASSERT(!smoke.finish_windup(smoke.pending_serial), "The smoke went ahead with the Pyromaniac stunned") + TEST_ASSERT(!QDELETED(kept) && !kept.active, "A smoke grenade was set off by a stunned Pyromaniac") + pyro.SetParalyzed(0) + if(!QDELETED(kept) && !pyro.is_holding(kept)) + pyro.put_in_hands(kept) + TEST_ASSERT_EQUAL(carried(pyro, /obj/item/grenade/smokebomb), 2, "A stunned Pyromaniac lost a smoke grenade") + ready(pyro) + shoot(pyro, spot(4, 4), shooter) + pyro.setStaminaLoss(0) + + // Not while cuffed + var/obj/item/restraints/handcuffs/cuffs = new(pyro) + pyro.set_handcuffed(cuffs) + pyro.update_handcuffed() + TEST_ASSERT(pyro.is_restrained(), "Test fixture: the cuffs didn't go on") + TEST_ASSERT_NULL(pyro.boss_choose_ability(shooter), "The Pyromaniac chose an ability in cuffs") + pyro.set_handcuffed(null) + pyro.update_handcuffed() + qdel(cuffs) + + // A lethal hit from past its reach counts too + controller.CancelActions() + var/datum/component/npc_combat_sense/sense = pyro.GetComponent(/datum/component/npc_combat_sense) + sense.end_exchange(controller) + pyro.boss_last_attacked_at = 0 + TEST_ASSERT(!smoke.worth_using(shooter), "The Pyromaniac wanted its smoke with nobody shooting at it") + pyro.boss_note_attacker(shooter) + TEST_ASSERT(smoke.worth_using(shooter), "Hurt from past its flamer's reach, the Pyromaniac didn't want its smoke") + + // It takes one out, aimed halfway to the shooter; the pin comes out as it throws + TEST_ASSERT(smoke.Trigger(target = shooter), "The smoke didn't start") + var/obj/item/grenade/smokebomb/first = locate() in pyro.held_items + TEST_ASSERT_NOTNULL(first, "The Pyromaniac didn't take out a smoke grenade") + TEST_ASSERT(!first.active, "The Pyromaniac pulled the pin before its wind-up was over") + TEST_ASSERT_EQUAL(smoke.landing, spot(2, 2), "The smoke is aimed at [smoke.landing], not halfway to the shooter") + TEST_ASSERT(smoke.next_use_time >= world.time + 19 SECONDS, "The smoke has no cooldown") // BOUNTY_PYROMANIAC_SMOKE_COOLDOWN + TEST_ASSERT(smoke.finish_windup(smoke.pending_serial), "The smoke's wind-up didn't finish") + TEST_ASSERT(!pyro.is_holding(first), "The Pyromaniac didn't throw its smoke grenade") + TEST_ASSERT(isturf(first.loc), "The thrown smoke grenade isn't loose") + TEST_ASSERT(first.active, "The Pyromaniac threw its smoke grenade with the pin in") + + // The cooldown holds, shot again or not + pyro.boss_next_ability_at = 0 + shoot(pyro, spot(4, 4), shooter) + pyro.setStaminaLoss(0) + TEST_ASSERT(pyro.boss_choose_ability(shooter) != smoke, "The Pyromaniac threw smoke again inside its cooldown") + + // The cloud is tg's: opaque smoke where the grenade goes off, and the grenade spent + var/turf/where = get_turf(first) + first.detonate() + TEST_ASSERT(QDELETED(first), "The smoke grenade wasn't used up") + var/obj/effect/particle_effect/fluid/smoke/cloud = locate() in where + TEST_ASSERT_NOTNULL(cloud, "No smoke came out of the grenade") + TEST_ASSERT(cloud.opacity, "The smoke can be seen through") + TEST_ASSERT_EQUAL(carried(pyro, /obj/item/grenade/smokebomb), 1, "The Pyromaniac doesn't have one smoke grenade left") + + // Out of sight in smoke, it makes for where the shots came from, not for where the shooter is now + var/obj/item/grenade/smokebomb/bounty_boss/wall = allocate(/obj/item/grenade/smokebomb/bounty_boss, spot(2, 2)) + wall.detonate() + TEST_ASSERT(!can_see(pyro, shooter, 9), "Test fixture: the Pyromaniac can see through the smoke") + controller.CancelActions() + pyro.boss_next_ability_at = world.time + 100 SECONDS + shoot(pyro, spot(4, 4), shooter) + pyro.setStaminaLoss(0) + shooter.forceMove(spot(3, 4)) + TEST_ASSERT(!can_see(pyro, shooter, 9), "Test fixture: the Pyromaniac can see its shooter round the smoke") + TEST_ASSERT_EQUAL(sense_subtree().SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "In smoke, the Pyromaniac stopped coming") + TEST_ASSERT_EQUAL(controller.blackboard["human_npc_stun_shooter"], spot(4, 4), "In smoke, the Pyromaniac isn't making for where the shots came from") // BB_HUMAN_NPC_STUN_SHOOTER + TEST_ASSERT(controller.current_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/human_npc_close_in)], "In smoke, the Pyromaniac isn't moving") + controller.CancelActions() + + // Two grenades, two clouds: then none + ready(pyro) + TEST_ASSERT(smoke.Trigger(target = shooter), "The second smoke didn't start") + var/obj/item/grenade/smokebomb/second = locate() in pyro.held_items + TEST_ASSERT_NOTNULL(second, "The Pyromaniac didn't take out its second grenade") + smoke.finish_windup(smoke.pending_serial) + TEST_ASSERT(second.active, "The Pyromaniac didn't pull the pin on its second grenade") + if(!QDELETED(second)) + second.detonate() + ready(pyro) + pyro.boss_note_attacker(shooter) + TEST_ASSERT_EQUAL(carried(pyro, /obj/item/grenade/smokebomb), 0, "The Pyromaniac has smoke grenades left after two") + TEST_ASSERT(!smoke.worth_using(shooter), "The Pyromaniac wanted smoke with none left") + + for(var/turf/place as anything in RANGE_TURFS(9, spot(2, 2))) + for(var/obj/effect/particle_effect/fluid/smoke/puff in place) + qdel(puff) + #ifdef BOUNTY_BOSS_RENDERS /** * Not a check: renders the kits whose looks changed (C8), before and after, facing each way, to diff --git a/wiki/content/bounty-hunting.md b/wiki/content/bounty-hunting.md index fce2af0509a..8692d2eb072 100644 --- a/wiki/content/bounty-hunting.md +++ b/wiki/content/bounty-hunting.md @@ -121,7 +121,7 @@ Every Most Wanted criminal is a mini-boss with one of six kits. The board's **Ot | Kit | Looks like | What it does | How to handle it | |---|---|---|---| | Juggernaut | A giant in riot plates with a tower shield | Raises its shield when the fight starts; shots from in front stop at it. Charges along a line after a roar, smashing what's in the way. Slams the floor around it when you're close. | Hit it from the side or behind. Step off the charge line. If it charges into something it can't break, it hurts itself and falls down for a few seconds. | -| Pyromaniac | A scorched criminal in a fire suit | Goes for whoever shoots it. Sweeps a cone of fire 3 tiles long and throws molotovs that leave burning floor. Its fire suit keeps the flames off it; the space suit it wears at space ruins doesn't. | Keep your distance and keep moving. Stay more than 3 tiles away and off burning floor. If you catch fire, stop, drop and roll, or use an extinguisher. | +| Pyromaniac | A scorched criminal in a fire suit | Goes for whoever shoots it. Sweeps a cone of fire 3 tiles long and throws molotovs that leave burning floor. Shot from further off, it throws one of its two smoke grenades halfway to you and comes through the cloud. Its fire suit keeps the flames off it; the space suit it wears at space ruins doesn't. | Keep your distance and keep moving. Stay more than 3 tiles away and off burning floor. Back out of the smoke before it reaches you: without internals or a gas mask you cough and drop what you're holding. Watch the cloud from the side, or see through it with thermals. If you catch fire, stop, drop and roll, or use an extinguisher. | | Demolitionist | A criminal in a hard hat with a grenade bandolier | Keeps 4 to 6 tiles away and throws grenades with a blinking fuse. Blows holes in interior walls and doors to reach you behind cover. An implant gets it back up a second after you first drop it. | Close the distance, and move off the blinking ring. Put it down again and cuff it; an EMP knocks the implant out. | | Ghost | A hooded criminal with a combat knife | Fast. Dashes in with knife cuts that bleed, cloaks into a shimmer, and backs off after two cuts. Shot from afar, it ducks out of your line and comes back from cover. Its implants make shots miss now and then while it moves, much more often during its burst of speed. | A flash or a hit breaks the cloak, and an EMP ends the burst. Shoot it when it stops moving. Corner it; hunters on more sides leave it nowhere to hide. | | Heavy | A heavily armed criminal in a hooded coat, with a belt-fed gun | An ablative coat and hood turn aside about half of laser and disabler shots. Shows a laser sight and a red cone, then fires a burst into it. Throws barrier grenades that open into a barricade. | Get out of the red cone before it fires. Go around the barricade. Use bullets, or bring a lot of disablers. | From f273313e58be838ae92312452957ef2490042ba4 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:21:01 -0800 Subject: [PATCH 193/472] Make the mafia room marker a plain local --- voidcrew/modules/bounties/bounty_lair_mafia.dm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/voidcrew/modules/bounties/bounty_lair_mafia.dm b/voidcrew/modules/bounties/bounty_lair_mafia.dm index 074f6c205ab..05281fe887f 100644 --- a/voidcrew/modules/bounties/bounty_lair_mafia.dm +++ b/voidcrew/modules/bounties/bounty_lair_mafia.dm @@ -157,7 +157,7 @@ GLOBAL_LIST_EMPTY(bounty_mafia_rooms) */ /proc/bounty_mafia_room_crew(area_type) var/type_text = "[area_type]" - var/static/marker = "/mafia_club/" + var/marker = "/mafia_club/" var/at = findtext(type_text, marker) if(!at) return null From 29ec30713d5d4989fb1e7da146bb03b301f9d660 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:54:25 -0800 Subject: [PATCH 194/472] Widowline gore: limbs fall and lie down, a pulled-in garrote, a thrown fan of drops Severed limbs keep the facing they had, topple away from the swing and lie on the floor beside the feet, with the floor limb taking over exactly where the falling one lands. An Arc-cut limb is burnt, not bloody. The garrote pulls the neck in to the garroter's hands, draws the wire from both hands in one steady colour and hides the hanging wire meanwhile. The head drops clear at the garroter's feet; an open neck sprays and pools. A wet wire cracking back into the spool throws a capped fan of drops that land spread ahead. The plain wire lashes out to what it hits, both wires lash on a crossing cut, the spool glows on deploy and retract, and the wet item icon shows beads. --- voidcrew/modules/cyberware/cyberware_anim2.dm | 8 +- .../cyberware/icons/cyberware_weapons.dmi | Bin 6088 -> 6188 bytes .../modules/cyberware/ware_line_widowline.dm | 3 +- .../modules/cyberware/ware_military_arms.dm | 5 + .../hyperviolence/wire/_wire_defines.dm | 2 + .../hyperviolence/wire/icons/wire_gore.dmi | Bin 6556 -> 7682 bytes .../modules/hyperviolence/wire/wire_cut.dm | 8 + .../hyperviolence/wire/wire_garrote.dm | 149 ++++++++++++++--- .../modules/hyperviolence/wire/wire_held.dm | 104 ++++++++++-- .../hyperviolence/wire/wire_sever_fx.dm | 158 +++++++++++++++--- .../voidcrew_cyberware_widowline.dm | 54 +++++- 11 files changed, 422 insertions(+), 69 deletions(-) diff --git a/voidcrew/modules/cyberware/cyberware_anim2.dm b/voidcrew/modules/cyberware/cyberware_anim2.dm index ede6c2f22a3..83261455e70 100644 --- a/voidcrew/modules/cyberware/cyberware_anim2.dm +++ b/voidcrew/modules/cyberware/cyberware_anim2.dm @@ -668,12 +668,12 @@ velocity = generator(GEN_CIRCLE, 2, 3, SQUARE_RAND) gravity = list(0, -0.15) -/// A landed Widowline Arc swing: the wire lashes out to whatever it hit, a -/// blue-white line held as long as the shock's crackle runs along it. -/proc/cyberware_monowire_lash_fx(mob/living/user, atom/target) +/// A landed Widowline swing: the wire lashes out to whatever it hit, a +/// pale line held for half a second. +/proc/cyberware_monowire_lash_fx(mob/living/user, atom/target, origin_offset_x = 0) if(QDELETED(user) || QDELETED(target) || get_turf(user) == get_turf(target)) return - user.Beam(target, icon_state = "monowire_lash", icon = CYBERWARE_ANIM2_ICON, time = 0.5 SECONDS) + user.Beam(target, icon_state = "monowire_lash", icon = CYBERWARE_ANIM2_ICON, time = 0.5 SECONDS, override_origin_pixel_x = user.pixel_x + origin_offset_x, override_origin_pixel_y = user.pixel_y) /// The Arc's shock: a small crackle runs along the wire and sparks burst where /// it touched. It's a wire, so there is no bolt of its own. diff --git a/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi b/voidcrew/modules/cyberware/icons/cyberware_weapons.dmi index 4410cb32b57065ae78863ef21d7ae44ff776ddd9..b18b988f8b89a317f1e6c169012f841526531b06 100644 GIT binary patch delta 5996 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ি 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 EKTPwvfB*mh diff --git a/voidcrew/modules/cyberware/ware_line_widowline.dm b/voidcrew/modules/cyberware/ware_line_widowline.dm index 6e66115a473..b7297222602 100644 --- a/voidcrew/modules/cyberware/ware_line_widowline.dm +++ b/voidcrew/modules/cyberware/ware_line_widowline.dm @@ -68,8 +68,7 @@ . = ..() if(!isliving(user) || QDELETED(target) || target == user) return - // The wire lashes out to what it hit (cyberware_anim2.dm). - cyberware_monowire_lash_fx(user, target) + // The lash out to what it hit is every wire's (ware_military_arms.dm); the shock is this one's. if(isliving(target)) discharge_into_mob(target, user) else if(ismecha(target)) diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index 2b8f02e15b9..7951fa413dd 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -630,6 +630,11 @@ /obj/item/melee/cyberware_monowire/afterattack(atom/target, mob/user, list/modifiers, list/attack_modifiers) . = ..() swing_landed = TRUE + if(!isliving(user) || QDELETED(target) || target == user) + return + // The wire lashes out of the hand to what it hit (cyberware_anim2.dm), so the hanging wire isn't drawn meanwhile. + cyberware_monowire_lash_fx(user, target) + hide_inhand_for(0.5 SECONDS) /** * # Widowline Monowire (T3, one arm, aug slot, load 5) diff --git a/voidcrew/modules/hyperviolence/wire/_wire_defines.dm b/voidcrew/modules/hyperviolence/wire/_wire_defines.dm index 6ddb7086cc4..b1b2bfcc793 100644 --- a/voidcrew/modules/hyperviolence/wire/_wire_defines.dm +++ b/voidcrew/modules/hyperviolence/wire/_wire_defines.dm @@ -41,6 +41,8 @@ #define WIRE_STAINED_TIME (30 SECONDS) /// Most drips one wet spell leaves. #define WIRE_MAX_DRIPS 8 +/// Drops a wet wire throws when it cracks back into the spool. +#define WIRE_FLICK_DROPS 6 /// The swing out of the spool and back in. #define WIRE_SWING_TIME (0.4 SECONDS) diff --git a/voidcrew/modules/hyperviolence/wire/icons/wire_gore.dmi b/voidcrew/modules/hyperviolence/wire/icons/wire_gore.dmi index 38b17fce91c4c15a50a646f6e9c30832fe129983..8d4a2a8b0a7780d14c0de3e8b96580006603741c 100644 GIT binary patch literal 7682 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 HARD_CRIT && head.can_dismember()) victim.hv_add_hit_line("[user] draws the wire through [victim]'s neck!", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) if(seared) LAZYSET(victim.hv_stump_burnt, BODY_ZONE_HEAD, TRUE) - // Dropped where the body lies, brain and all, as #509's finishers drop a head; one bleed's worth, a quarter seared. - if(hv_wire_take_part(head, seared ? WOUND_BURN : WOUND_SLASH, seared ? WIRE_ARC_BLEED_MULT * 0.5 : 0.5, !seared)) - if(!seared) - victim.hv_wire_spray(spray_dir, 3) + // Dropped, brain and all, as #509's finishers drop a head: where the neck was, at the garroter's feet. + if(hv_wire_take_part(head, seared ? WOUND_BURN : WOUND_SLASH, seared ? WIRE_ARC_BLEED_MULT : 1, !seared)) + wire_drop_head(head, body_shape, body_offsets, head_side) + finish_blood(spray_dir, seared) log_combat(user, victim, "garroted the head off", wire) return LAZYREMOVE(victim.hv_stump_burnt, BODY_ZONE_HEAD) @@ -252,14 +327,40 @@ if(hv_crit_apply_wound(head, throat.wound_type, spray_dir, wire)) victim.hv_add_hit_line("[user] jerks the wire across [victim]'s throat!") throat.announce(victim, spray_dir) - if(!seared) - victim.hv_wire_spray(spray_dir, 3) + finish_blood(spray_dir, seared) log_combat(user, victim, "garroted the throat of", wire) return if(blocker) victim.hv_add_hit_line("The wire catches on [victim]'s [blocker]!", HV_LINE_BOLD, "The wire catches on your [blocker]!") real_blow(BODY_ZONE_HEAD, kill_wound_bonus) +/** + * The head comes off the end of the neck and lies where it was drawn, a few pixels clear of it: at the garroter's + * feet, since the hold drew the neck to their hands. Turned as the body lay; an ordinary head again once moved. + */ +/proc/wire_drop_head(obj/item/bodypart/head/head, matrix/body_shape, list/body_offsets, side) + if(QDELETED(head) || !isturf(head.loc)) + return + // Its picture is shifted by (px_x, px_y) to sit mid-tile; on the body its middle is 9 px up. + var/list/pivot = list(head.px_x, 9 + head.px_y) + head.transform = wire_limb_rest(pivot, list(0, 13), side, TRUE, body_shape) + head.pixel_x = body_offsets[1] + head.pixel_y = body_offsets[2] + head.pixel_w = body_offsets[3] + head.pixel_z = body_offsets[4] + head.RegisterSignal(head, COMSIG_MOVABLE_MOVED, TYPE_PROC_REF(/obj/item/bodypart, wire_stand_up), override = TRUE) + +/// The open neck empties: a spray away from the garroter and a pool under the body. A seared one only smokes. +/datum/hv_finisher/garrote/proc/finish_blood(spray_dir, seared) + if(QDELETED(victim)) + return + if(seared) + wire_stump_smoke(victim, BODY_ZONE_HEAD) + playsound(victim, 'sound/effects/wounds/sizzle1.ogg', 60, TRUE) + return + victim.hv_wire_spray(spray_dir, 3) + victim.add_splatter_floor(get_turf(victim)) + /// What stops the wire at the throat ("helmet", "collar", "plating"), or null. The same gates as #509's /// throat crit, with the wire's penetration not counted (the crit gates never count it). /datum/hv_finisher/garrote/proc/wire_turned_by(obj/item/bodypart/head/head, datum/hv_crit/throat/throat) @@ -300,8 +401,10 @@ /datum/hv_finisher_stage/hold/garrote/enter() . = ..() var/datum/hv_finisher/garrote/garrote = host + garrote.pull_close() garrote.show_loop(tight = TRUE) - garrote.draw_taut(duration + 1 SECONDS) + // Taut until the kill beat cuts it. + garrote.draw_wire(duration + 0.5 SECONDS) host.victim.Shake(2, 1, duration) /datum/hv_finisher_stage/hold/garrote/enter_message() diff --git a/voidcrew/modules/hyperviolence/wire/wire_held.dm b/voidcrew/modules/hyperviolence/wire/wire_held.dm index d1a379f8b17..311168b6306 100644 --- a/voidcrew/modules/hyperviolence/wire/wire_held.dm +++ b/voidcrew/modules/hyperviolence/wire/wire_held.dm @@ -4,8 +4,8 @@ * - Deploying and stowing: the wire swings out of the spool and reels back in (WIRE_HELD_ANIM_ICON), played * on the wielder while the held wire itself is not drawn, like the Mantis unfold. * - Blood: a cut that draws blood leaves the wire wet (dripping) for WIRE_WET_TIME, then stained until - * WIRE_STAINED_TIME, drawn from greyscale states tinted to the blood. Stowing a wet wire flicks a few drops - * onto the floor in front. Only the picture clears: the blood's DNA stays on the wire until it is washed. + * WIRE_STAINED_TIME, drawn from greyscale states tinted to the blood. Stowing a wet wire cracks a fan of + * drops off it that land spread over the floor ahead. Only the picture clears: the blood's DNA stays on the wire until it is washed. * tg's generic item stain is not drawn on the wire, which draws its own. * - The Arc's charged wire burns its blood off: it never shows any. */ @@ -179,6 +179,13 @@ return null return IS_LEFT_INDEX(index) ? "left" : "right" +/// The light at the spool while the wire runs out or snaps back: the wire's own colour. +/obj/item/melee/cyberware_monowire/proc/spool_light_color() + return "#f86cfa" + +/obj/item/melee/cyberware_monowire/live/spool_light_color() + return "#7fe0ff" + /// The wire swings out of the spool into the hand. /obj/item/melee/cyberware_monowire/proc/play_deploy(mob/living/user) var/side = held_side(user) @@ -186,33 +193,106 @@ return cyberware_anim_play(user, WIRE_HELD_ANIM_ICON, "[icon_state]_deploy_[side]", WIRE_SWING_LAYER, WIRE_SWING_TIME) hide_inhand_for(WIRE_SWING_TIME) + user.mob_light(1.5, 0.6, spool_light_color(), 0.3 SECONDS) -/// The wire reels back into the spool from the `side` hand. A wet wire flicks its blood off first. +/// The wire reels back into the spool from the `side` hand. A wet wire cracks its blood off first. /obj/item/melee/cyberware_monowire/proc/play_retract(mob/living/user, side, stage) if(QDELETED(user) || !side) return cyberware_anim_play(user, WIRE_HELD_ANIM_ICON, "[icon_state]_retract_[side]", WIRE_SWING_LAYER, WIRE_SWING_TIME) + user.mob_light(1.5, 0.6, spool_light_color(), 0.3 SECONDS) if(stage) var/obj/effect/abstract/cyberware_anim/blood = cyberware_anim_play(user, WIRE_HELD_ANIM_ICON, "monowire_retract_blood[stage]_[side]", WIRE_SWING_LAYER + 0.05, WIRE_SWING_TIME) if(blood) blood.appearance_flags |= RESET_COLOR blood.color = get_blood_dna_color() || BLOOD_COLOR_RED if(stage >= 2) - flick_blood(user) + flick_blood(user, side) clear_blood_look() -/// A few drops thrown onto the floor in front of `user`, and one at their feet. -/obj/item/melee/cyberware_monowire/proc/flick_blood(mob/living/user) +/** + * The fan a wet wire throws as it cracks back into the spool: WIRE_FLICK_DROPS drops leave its tip, fly an arc + * and land spread over the floor ahead and to the sides, each as a drip. Drawn on temporary visuals; only the + * drips stay. + */ +/obj/item/melee/cyberware_monowire/proc/flick_blood(mob/living/user, side) var/list/blood_dna = GET_ATOM_BLOOD_DNA(src) var/turf/feet = get_turf(user) if(!length(blood_dna) || !feet || !feet.has_gravity()) return - if(!isgroundlessturf(feet)) - new /obj/effect/decal/cleanable/blood/drip(feet, null, blood_dna) - var/turf/ahead = get_step(feet, user.dir) - if(ahead && !hv_blood_spray_blocked(ahead) && !isgroundlessturf(ahead)) - new /obj/effect/decal/cleanable/blood/drip(ahead, null, blood_dna) - new /obj/effect/decal/cleanable/blood/drip(ahead, null, blood_dna) + var/blood_color = get_blood_dna_color() || BLOOD_COLOR_RED + for(var/list/drop as anything in wire_fan_plan(user.dir, side)) + new /obj/effect/temp_visual/wire_blood_drop(feet, drop, blood_color, blood_dna) + +/** + * Where each drop of the fan starts and lands, in pixels from the middle of the wielder's tile: + * list(start x, start y, land x, land y, arc height, flight time). It leaves the wire's tip, which hangs by the + * `side` hand ("left" or "right"), and lands up to a tile and a bit ahead of `facing`, spread to both sides. + */ +/proc/wire_fan_plan(facing, side) + . = list() + var/forward_x = (facing & EAST) ? 1 : ((facing & WEST) ? -1 : 0) + var/forward_y = (facing & NORTH) ? 1 : ((facing & SOUTH) ? -1 : 0) + if(!forward_x && !forward_y) + forward_y = -1 + // The tip hangs by the hand: screen left or right of the body facing north or south, in front of it in profile. + var/start_x = forward_x * 3 + if(!forward_x) + var/screen_left = (side == "right") == (facing != NORTH) + start_x = screen_left ? -6 : 6 + var/start_y = -13 + for(var/i in 1 to WIRE_FLICK_DROPS) + var/spread = (i - (WIRE_FLICK_DROPS + 1) / 2) * 7 + var/reach = 14 + ((i * 5) % WIRE_FLICK_DROPS) * 5 + var/land_x = start_x + forward_x * reach + (forward_x ? (i % 3 - 1) * 3 : spread) + var/land_y = start_y + forward_y * reach + (forward_x ? (i % 2 ? -2 : 3) : (i % 3 - 1) * 2) + var/height = 6 + (i % 3) * 3 + var/flight = round(2 + reach / 12, 0.5) + . += list(list(start_x, start_y, land_x, land_y, height, flight)) + +/// One drop of the fan in flight. It lands as a drip, unless where it lands has no floor to hold it. +/obj/effect/temp_visual/wire_blood_drop + name = "blood" + icon = WIRE_GORE_ICON + icon_state = "wire_drop" + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 0.6 SECONDS + +/obj/effect/temp_visual/wire_blood_drop/Initialize(mapload, list/drop, blood_color, list/blood_dna) + var/start_x = drop[1] + var/start_y = drop[2] + var/land_x = drop[3] + var/land_y = drop[4] + var/height = drop[5] + var/flight = drop[6] + // Held back a beat: the drops leave on the crack, the retract's second frame. + var/crack = 0.5 + duration = crack + flight + . = ..() + color = blood_color + pixel_x = start_x + pixel_y = start_y + alpha = 0 + animate(src, alpha = 0, time = crack) + animate(alpha = 255, pixel_x = (start_x + land_x) / 2, pixel_y = max(start_y, land_y) + height, time = flight / 2, easing = SINE_EASING | EASE_OUT) + animate(pixel_x = land_x, pixel_y = land_y, time = flight / 2, easing = SINE_EASING | EASE_IN) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(wire_drop_land), get_turf(src), land_x, land_y, blood_dna), duration - 0.1) + +/// A drop of the fan comes down at (`land_x`, `land_y`) pixels from the middle of `origin`. +/proc/wire_drop_land(turf/origin, land_x, land_y, list/blood_dna) + if(!origin) + return + var/tile_x = floor((land_x + 16) / 32) + var/tile_y = floor((land_y + 16) / 32) + var/turf/landing = locate(origin.x + tile_x, origin.y + tile_y, origin.z) + if(!landing || isgroundlessturf(landing) || !landing.has_gravity() || (landing != origin && hv_blood_spray_blocked(landing))) + return + var/obj/effect/decal/cleanable/blood/drip/drop = new(landing, null, blood_dna) + if(QDELETED(drop)) + return + drop.pixel_x = land_x - tile_x * 32 + drop.pixel_y = land_y - tile_y * 32 /obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/Extend(obj/item/augment) . = ..() diff --git a/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm b/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm index 30dcee0e91a..638cc85c92c 100644 --- a/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm +++ b/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm @@ -18,16 +18,53 @@ /proc/wire_blood_color(mob/living/bleeder) return bleeder.get_bloodtype()?.get_damage_color(bleeder) || BLOOD_COLOR_RED -/// Which way a limb in `zone` falls away from a body facing `facing`: -1 to screen left, 1 to screen right. -/proc/wire_limb_side(zone, facing) +/// Which way a limb in `zone` falls away from a body facing `facing`, cut by a swing going `swing_dir`: +/// -1 to screen left, 1 to screen right. Seen from the front or back it falls out on its own side; in profile +/// it falls the way the swing carries it, away from the wielder. +/proc/wire_limb_side(zone, facing, swing_dir) + if(facing == EAST || facing == WEST) + if(swing_dir & EAST) + return 1 + if(swing_dir & WEST) + return -1 + return facing == EAST ? 1 : -1 + if(facing == NORTH) + return (zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG) ? 1 : -1 + return (zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG) ? -1 : 1 + +/// The middle of a human limb in `zone` as a body facing `facing` draws it, in pixels from the middle of the +/// tile. An arm on the far side of a body in profile is hidden, so it is placed as the near one. list(x, y) +/proc/wire_limb_centre(zone, facing) + var/arm = (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_R_ARM) + var/right = (zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG) + var/y = arm ? 0 : -11 switch(facing) if(EAST) - return 1 + return list(arm ? -2 : 0, y) if(WEST) - return -1 + return list(arm ? 2 : 0, y) if(NORTH) - return (zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG) ? 1 : -1 - return (zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG) ? -1 : 1 + return list(arm ? (right ? 6 : -7) : (right ? 3 : -4), y) + return list(arm ? (right ? -7 : 6) : (right ? -4 : 3), y) + +/** + * Where a cut limb comes to rest, as a transform for something drawing it about `pivot` (where the limb's middle + * is drawn in that thing's own picture). Upright, it has toppled over `side` and lies on the floor beside the feet; + * off a body already lying down it only slides out a little, since it is on the floor already. Then it is moved and + * turned as the body was (`body_shape`). + */ +/proc/wire_limb_rest(list/pivot, list/centre, side, lying, matrix/body_shape) + var/matrix/rest = matrix() + rest.Translate(-pivot[1], -pivot[2]) + if(lying) + rest.Turn(side * 10) + rest.Translate(centre[1] + side * 3, centre[2] - 1) + else + rest.Turn(side * 90) + rest.Translate(side * 10, -13) + if(body_shape) + rest.Multiply(body_shape) + return rest // ---- The held limb ---- @@ -47,6 +84,12 @@ var/datum/weakref/severed_ref /// Timer of the slide var/slide_timer + /// Which way the body faced when it was cut + var/facing = SOUTH + /// Which way the limb falls: -1 to screen left, 1 to screen right + var/side = 1 + /// The body was lying down when it was cut + var/lying = FALSE /obj/effect/abstract/cyberware_anim/wire_ghost/Destroy() if(slide_timer) @@ -68,6 +111,9 @@ ghost.seared = seared ghost.spray_dir = spray_dir ghost.cut_at = get_turf(victim) + ghost.facing = victim.dir + ghost.side = wire_limb_side(ghost.zone, victim.dir, spray_dir) + ghost.lying = victim.body_position == LYING_DOWN ghost.dress(victim, limb) ghost.bearer = victim ghost.RegisterSignal(victim, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) @@ -84,7 +130,14 @@ var/mob/living/carbon/human/dressed = ishuman(victim) ? victim : null var/reshaped = dressed && dressed.mob_height != HUMAN_HEIGHT_MEDIUM // The dropped form carries its bruising and burns, which the body draws on a layer of its own. - for(var/image/part_image in limb.get_limb_icon(TRUE, victim)) + // A seared limb shows burns where the cut would have bled. + var/list/parts = limb.get_limb_icon(TRUE, victim) + if(seared) + for(var/image/part_image in parts) + if(part_image.layer == -DAMAGE_LAYER) + parts -= part_image + parts += image('icons/mob/effects/dam_mob.dmi', "[limb.dmg_overlay_type]_[zone]_0[max(limb.burnstate, 2)]", -DAMAGE_LAYER) + for(var/image/part_image in parts) if(PLANE_TO_TRUE(part_image.plane) == EMISSIVE_PLANE) continue if(reshaped) @@ -111,23 +164,63 @@ clothes.add_filter("wire_ghost_cutout", 0, alpha_mask_filter(render_source = cutout.render_target)) overlays += clothes -/// The severed limb on the floor stays unseen until this lands, then fades in where this falls. +/** + * The severed limb on the floor stays unseen until this lands, then shows where this came to rest: facing the way + * it hung on the body, lying on the floor beside the feet, turned as far as this fell. + */ /obj/effect/abstract/cyberware_anim/wire_ghost/proc/hold_severed(obj/item/bodypart/severed) if(QDELETED(severed) || !isturf(severed.loc)) return severed_ref = WEAKREF(severed) var/mob/living/body = bearer - // A dropped limb draws itself shifted to the middle of its tile (px_x, px_y); undo that, so it lies - // where the held one falls: a few pixels out from where it was, down by the feet. - severed.pixel_x = wire_limb_side(zone, body ? body.dir : SOUTH) * 3 - severed.px_x - severed.pixel_y = -4 - severed.px_y - // The server value is 255 from here on; only the picture waits. + // A dropped limb is drawn facing south otherwise. An arm on the far side of a body in profile is not drawn at + // all from that side, so that one lies as the near arm would: the other way round, flipped. + var/far_arm = (facing == EAST && zone == BODY_ZONE_L_ARM) || (facing == WEST && zone == BODY_ZONE_R_ARM) + severed.setDir(far_arm ? REVERSE_DIR(facing) : facing) + var/list/centre = wire_limb_centre(zone, facing) + // The dropped limb's picture is shifted by (px_x, px_y) to sit mid-tile, flipped with it for the far arm. + var/list/pivot = list(centre[1] + (far_arm ? -severed.px_x : severed.px_x), centre[2] + severed.px_y) + var/matrix/rest = wire_limb_rest(pivot, centre, side, lying, body?.transform) + if(far_arm) + var/matrix/flipped = matrix(-1, 0, 0, 0, 1, 0) + flipped.Multiply(rest) + rest = flipped + severed.transform = rest + severed.pixel_x = body ? body.pixel_x : 0 + severed.pixel_y = body ? body.pixel_y : 0 + severed.pixel_w = body ? body.pixel_w : 0 + severed.pixel_z = body ? body.pixel_z : 0 + // Picked up, kicked or put back on, it is an ordinary limb again. + severed.RegisterSignal(severed, COMSIG_MOVABLE_MOVED, TYPE_PROC_REF(/obj/item/bodypart, wire_stand_up), override = TRUE) + // The server value is 255 at once; only the picture waits for the landing. severed.alpha = 0 - animate(severed, alpha = 0, time = WIRE_CUT_DELAY + WIRE_SLIDE_TIME) - animate(alpha = 255, time = WIRE_FALL_TIME) - severed.alpha = 255 + animate(severed, alpha = 0, time = WIRE_CUT_DELAY + WIRE_SLIDE_TIME + WIRE_FALL_TIME) + animate(alpha = 255, time = 0.5) + +/// A limb the wire cut, once it is moved: drawn as any dropped limb. +/obj/item/bodypart/proc/wire_stand_up() + SIGNAL_HANDLER + UnregisterSignal(src, COMSIG_MOVABLE_MOVED) + animate(src, alpha = 255, time = 0) + transform = matrix() + setDir(SOUTH) + pixel_x = base_pixel_x + pixel_y = base_pixel_y + pixel_w = base_pixel_w + pixel_z = base_pixel_z -/// The limb leaves: it slides along the cut, turns, falls and fades. The stump shows, and the blood goes. +/// A limb cut by a hot wire: burnt, not bloody. Only the picture; its damage is as it was. +/obj/item/bodypart/proc/wire_char() + brutestate = 0 + burnstate = max(burnstate, 2) + // The blood it caught coming off is burnt away too. A later wash brings the body's own back, as for any limb. + var/list/kept = blood_dna_info + blood_dna_info = null + wash(CLEAN_TYPE_BLOOD) + blood_dna_info = kept + update_icon_dropped() + +/// The limb leaves: it slides along the cut, topples and falls to the floor. The stump shows, and the blood goes. /obj/effect/abstract/cyberware_anim/wire_ghost/proc/slide() slide_timer = null var/mob/living/carbon/victim = bearer @@ -140,16 +233,29 @@ animate(src, alpha = 0, time = 0.1 SECONDS) QDEL_IN(src, 0.1 SECONDS) return - var/side = wire_limb_side(zone, victim.dir) - var/matrix/turned = matrix() - turned.Turn(side * 20) - var/matrix/fallen = matrix() - fallen.Turn(side * 45) - animate(src, pixel_w = side * 2, pixel_z = -1, transform = turned, time = WIRE_SLIDE_TIME, easing = EASE_IN) - animate(pixel_w = side * 3, pixel_z = -4, transform = fallen, alpha = 0, time = WIRE_FALL_TIME, easing = EASE_IN) + // Drawn in its body's own frame, which turns it with a lying body: so no body_shape here. + var/list/centre = wire_limb_centre(zone, facing) + var/matrix/slid = matrix() + slid.Translate(-centre[1], -centre[2]) + slid.Turn(side * (lying ? 4 : 15)) + slid.Translate(centre[1] + side * 2, centre[2] - 1) + var/matrix/rest = wire_limb_rest(centre, centre, side, lying) + // Halfway over: the turn is split so it turns rather than squashes on the way. + var/matrix/tipping = matrix() + tipping.Translate(-centre[1], -centre[2]) + tipping.Turn(side * (lying ? 7 : 50)) + if(lying) + tipping.Translate(centre[1] + side * 3, centre[2] - 1) + else + tipping.Translate((centre[1] + side * 12) / 2, (centre[2] - 13) / 2) + animate(src, transform = slid, time = WIRE_SLIDE_TIME, easing = SINE_EASING | EASE_IN) + animate(transform = tipping, time = WIRE_FALL_TIME / 2, easing = QUAD_EASING | EASE_IN) + animate(transform = rest, time = WIRE_FALL_TIME / 2, easing = QUAD_EASING | EASE_IN) + // Down: the limb on the floor takes over. + animate(alpha = 0, time = 0.5) if(!seared) victim.hv_wire_spray(spray_dir, 2) - QDEL_IN(src, WIRE_SLIDE_TIME + WIRE_FALL_TIME) + QDEL_IN(src, WIRE_SLIDE_TIME + WIRE_FALL_TIME + 0.1 SECONDS) // ---- The seam ---- @@ -185,7 +291,7 @@ var/particles/smoke/steam/mild/puff = new var/side = wire_limb_side(zone, victim.dir) var/arm = (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_R_ARM) - puff.position = list(side * 6, arm ? 4 : -6, 0) + puff.position = zone == BODY_ZONE_HEAD ? list(0, 8, 0) : list(side * 6, arm ? 4 : -6, 0) smoke.particles = puff smoke.bearer = victim smoke.RegisterSignal(victim, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_widowline.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_widowline.dm index c1479afa4a7..572902f692e 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_widowline.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_widowline.dm @@ -77,6 +77,24 @@ cut(user, victim, BODY_ZONE_R_ARM) TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_R_ARM), "An aimed cut didn't take off an arm at 60%") TEST_ASSERT_EQUAL(right.loc, get_turf(victim), "The severed arm was flung instead of dropping at the body") + TEST_ASSERT_EQUAL(right.dir, victim.dir, "The severed arm lies facing another way than it hung") + + // In profile: the near limb keeps the body's facing and lies down by the feet; the far arm lies as the near one would. + var/mob/living/carbon/human/consistent/profile = allocate(/mob/living/carbon/human/consistent, spot(2, 3)) + profile.setDir(EAST) + arm = profile.get_bodypart(BODY_ZONE_R_LEG) + arm.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + cut(user, profile, BODY_ZONE_R_LEG) + TEST_ASSERT_NULL(profile.get_bodypart(BODY_ZONE_R_LEG), "Fixture: the leg didn't come off") + TEST_ASSERT_EQUAL(arm.dir, EAST, "A leg cut from a body facing east doesn't lie facing east") + TEST_ASSERT(abs(arm.transform.a) < 0.1 && arm.transform.f < -4, "The cut leg doesn't lie on the floor ([arm.transform.a], [arm.transform.f])") + arm = profile.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + profile.setDir(EAST) + cut(user, profile, BODY_ZONE_L_ARM) + TEST_ASSERT_NULL(profile.get_bodypart(BODY_ZONE_L_ARM), "Fixture: the far arm didn't come off") + TEST_ASSERT_EQUAL(arm.dir, WEST, "The far arm cut in profile isn't drawn as the near arm") + TEST_ASSERT(abs(arm.transform.a) < 0.1, "The far arm cut in profile doesn't lie down") TEST_ASSERT_EQUAL(splatter(victim), before, "A wire sever threw tg's flying hitsplatter") TEST_ASSERT(findtext(lines(victim), "comes away clean"), "The sever's line didn't join the hit: [lines(victim)]") TEST_ASSERT(!HAS_TRAIT(victim, "hv_clean_cut"), "TRAIT_HV_CLEAN_CUT outlived the cut") @@ -376,8 +394,24 @@ // Wet again, stowed: drops in front, the look clears, the DNA stays. cut(user, victim, BODY_ZONE_CHEST) user.setDir(EAST) - wire.flick_blood(user) - TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/drip) in get_step(user, EAST), "Stowing a wet wire flicked no blood ahead") + var/list/plan = wire_fan_plan(EAST, "right") + TEST_ASSERT_EQUAL(length(plan), 6, "The fan isn't six drops") // WIRE_FLICK_DROPS + for(var/list/drop as anything in plan) + TEST_ASSERT(drop[3] > drop[1] && abs(drop[4]) <= 48, "A drop of the fan lands behind the wielder or too far off ([drop[3]], [drop[4]])") + var/turf/ahead = get_step(user, EAST) + var/before = 0 + for(var/obj/effect/decal/cleanable/blood/drip/drop in range(2, user)) + if(get_turf(drop) != get_turf(user)) + before++ + wire.flick_blood(user, "right") + TEST_ASSERT(locate(/obj/effect/temp_visual/wire_blood_drop) in get_turf(user), "The fan threw no drops") + sleep(1 SECONDS) + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/drip) in ahead, "The fan landed no blood ahead") + var/after = 0 + for(var/obj/effect/decal/cleanable/blood/drip/drop in range(2, user)) + if(get_turf(drop) != get_turf(user)) + after++ + TEST_ASSERT(after - before <= 6, "The fan left more drips than drops") wire.clear_blood_look() TEST_ASSERT_EQUAL(wire.blood_stage(), 0, "The flick left the wire looking bloody") TEST_ASSERT(length(GET_ATOM_BLOOD_DNA(wire)), "The flick wiped the blood DNA") @@ -458,6 +492,14 @@ TEST_ASSERT(user.pulling == victim && user.grab_state >= GRAB_AGGRESSIVE, "The garrote's hold isn't a real aggressive grab") loop = garrote.loop_ref?.resolve() TEST_ASSERT(loop && loop.icon_state == "garrote_tight", "The wire didn't pull tight") + // The grab's own pull is applied (12 px toward the garroter, who stands west), the body lies head first to them, + // and the hold draws the neck the rest of the way in to their hands. + TEST_ASSERT_EQUAL(LAZYACCESS(LAZYACCESS(victim.offsets, PIXEL_X_OFFSET), GRABBING_TRAIT), -12, "The grab didn't pull the body in") + TEST_ASSERT_EQUAL(victim.lying_angle, LYING_ANGLE_WEST, "The body doesn't lie head first to the garroter") + var/list/neck = garrote.neck_point() + var/list/hands = garrote.hold_point() + TEST_ASSERT(abs(neck[1] - hands[1]) <= 2 && abs(neck[2] - hands[2]) <= 2, "The neck isn't at the garroter's hands ([neck[1]],[neck[2]] vs [hands[1]],[hands[2]])") + TEST_ASSERT_EQUAL(length(garrote.strand_refs), 2, "The wire doesn't run from both hands") TEST_ASSERT(user.wire_garrote_ready_at - world.time > 10 SECONDS, "A garrote that got a grip has the short wait") // The kill, not in hard crit: the throat. for(var/i in 1 to 10) @@ -488,6 +530,14 @@ TEST_ASSERT_NULL(dying.get_bodypart(BODY_ZONE_HEAD), "A garrote in hard crit didn't take the head") TEST_ASSERT_NOTNULL(taken, "The head wasn't left at the body") TEST_ASSERT_NOTNULL(locate(/obj/item/organ/brain) in taken, "The head lost its brain") + // The garroter stands south of this one: the head drops toward their feet, the spray goes the other way. + TEST_ASSERT(taken.transform.f + taken.pixel_y < 0, "The head didn't drop at the garroter's feet ([taken.transform.f], [taken.pixel_y])") + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/hv_spray) in get_step(dying, get_dir(user, dying)), "The neck threw no spray away from the garroter") + var/pooled = FALSE + for(var/obj/effect/decal/cleanable/blood/pool in get_turf(dying)) + if(!istype(pool, /obj/effect/decal/cleanable/blood/drip) && !istype(pool, /obj/effect/decal/cleanable/blood/hv_spray)) + pooled = TRUE + TEST_ASSERT(pooled, "The neck left no pool under the body") // A sealed helmet turns the wire: no slit throat, a blow instead. user.wire_garrote_ready_at = 0 From f6198b1da84f8ec3fbb0a65221415db86063ded5 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:04:00 -0800 Subject: [PATCH 195/472] Add the MP-84 Rattlesnake and PD46 Resolution from Shiptest Two guns ported from Shiptest (art CC BY-SA 3.0, fire sounds from TerraGov under CC BY-NC-SA 3.0, credited in voidcrew/modules/shiptest_guns/sidearms/attribution.txt). The MP-84 'Rattlesnake' is a red-tier 9mm machine pistol with its own 18-round magazine. Right-click in hand moves the selector through single shots, three-round bursts and full auto. The PD46 'Resolution' is a yellow-tier 4.6x30mm PDW with a 30-round magazine and a built-in stock that right-click folds and unfolds. The Undertow Exchange sells both (the MP-84 at advanced reputation, the PD46 at yellow). The MP-84 joins the red syndicate gun pool and the PD46 the yellow armory pool, each with a spare magazine. PD46 magazines are sold at the outfitter and MP-84 magazines print from Automatic Ammunition. All four are rated in the gear catalog, with unit tests for the guns and their routes. --- tgstation.dme | 2 + tools/gear_catalog/ratings/ammo.json | 20 +++ tools/gear_catalog/ratings/guns.json | 40 ++++- voidcrew/modules/loot/loot_guns.dm | 2 + voidcrew/modules/loot/zone_loot.dm | 2 + .../modules/research/designs/ammo_designs.dm | 15 ++ voidcrew/modules/research/techweb_nodes.dm | 1 + .../shiptest_guns/sidearms/attribution.txt | 37 +++++ .../shiptest_guns/sidearms/icons/lefthand.dmi | Bin 0 -> 1992 bytes .../sidearms/icons/magazines.dmi | Bin 0 -> 1846 bytes .../sidearms/icons/rattlesnake.dmi | Bin 0 -> 1228 bytes .../sidearms/icons/resolution.dmi | Bin 0 -> 1526 bytes .../sidearms/icons/righthand.dmi | Bin 0 -> 1964 bytes .../shiptest_guns/sidearms/rattlesnake.dm | 110 +++++++++++++ .../shiptest_guns/sidearms/resolution.dm | 89 +++++++++++ .../sidearms/sound/rattlesnake.ogg | Bin 0 -> 16250 bytes .../sidearms/sound/resolution.ogg | Bin 0 -> 12758 bytes .../trade/shop_catalog_black_market.dm | 28 ++++ .../modules/trade/shop_catalog_outfitter.dm | 11 ++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + voidcrew/modules/unit_tests/voidcrew_loot.dm | 4 +- .../unit_tests/voidcrew_shiptest_sidearms.dm | 144 ++++++++++++++++++ .../unit_tests/voidcrew_shop_catalog.dm | 6 +- .../unit_tests/voidcrew_zone_research.dm | 2 +- .../modules/unit_tests/voidcrew_zone_shops.dm | 5 +- 25 files changed, 507 insertions(+), 12 deletions(-) create mode 100644 voidcrew/modules/shiptest_guns/sidearms/attribution.txt create mode 100644 voidcrew/modules/shiptest_guns/sidearms/icons/lefthand.dmi create mode 100644 voidcrew/modules/shiptest_guns/sidearms/icons/magazines.dmi create mode 100644 voidcrew/modules/shiptest_guns/sidearms/icons/rattlesnake.dmi create mode 100644 voidcrew/modules/shiptest_guns/sidearms/icons/resolution.dmi create mode 100644 voidcrew/modules/shiptest_guns/sidearms/icons/righthand.dmi create mode 100644 voidcrew/modules/shiptest_guns/sidearms/rattlesnake.dm create mode 100644 voidcrew/modules/shiptest_guns/sidearms/resolution.dm create mode 100644 voidcrew/modules/shiptest_guns/sidearms/sound/rattlesnake.ogg create mode 100644 voidcrew/modules/shiptest_guns/sidearms/sound/resolution.ogg create mode 100644 voidcrew/modules/unit_tests/voidcrew_shiptest_sidearms.dm diff --git a/tgstation.dme b/tgstation.dme index dc351e3bfce..75ffd6d5760 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7645,6 +7645,8 @@ #include "voidcrew\modules\ship_upgrades\ships\squab.dm" #include "voidcrew\modules\ship_upgrades\ships\test_modular.dm" #include "voidcrew\modules\ship_upgrades\workshop\bogatyr.dm" +#include "voidcrew\modules\shiptest_guns\sidearms\rattlesnake.dm" +#include "voidcrew\modules\shiptest_guns\sidearms\resolution.dm" #include "voidcrew\modules\shuttle\boards.dm" #include "voidcrew\modules\shuttle\design.dm" #include "voidcrew\modules\shuttle\emergency.dm" diff --git a/tools/gear_catalog/ratings/ammo.json b/tools/gear_catalog/ratings/ammo.json index 0bd5c74fef8..4ed05409a6f 100644 --- a/tools/gear_catalog/ratings/ammo.json +++ b/tools/gear_catalog/ratings/ammo.json @@ -820,6 +820,15 @@ "reason": "40 damage on bare skin, far less through a vest. Good on NPCs and unarmored crews.", "tier": "yellow" }, + "/obj/item/ammo_box/magazine/m9mm_rattlesnake": { + "confidence": "high", + "group": "9mm loads", + "hash": "", + "name": "MP-84 magazine (9mm)", + "rank": 3, + "reason": "18 plain 30 damage 9mm rounds, between the Stechkin and Saber magazines. Only the MP-84 takes it.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/r10mm": { "confidence": "high", "group": "10mm loads", @@ -838,6 +847,15 @@ "reason": "Plain 4.6x30mm rounds, 30 to a magazine. Follows the gun.", "tier": "green" }, + "/obj/item/ammo_box/magazine/resolution": { + "confidence": "high", + "group": "4.6x30mm loads", + "hash": "", + "name": "PD46 magazine (4.6x30mm)", + "rank": 2, + "reason": "30 plain 4.6x30mm rounds, the Rapier magazine's load. Follows the PD46, as the WT-550 magazine follows its gun.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/smartgun": { "confidence": "high", "group": ".160 Smart loads", @@ -2326,6 +2344,7 @@ "/obj/item/ammo_box/magazine/internal/sks/empty": "no route", "/obj/item/ammo_box/magazine/m38/empty": "no route", "/obj/item/ammo_box/magazine/m75": "needs another system", + "/obj/item/ammo_box/magazine/m9mm_rattlesnake/empty": "no route", "/obj/item/ammo_box/magazine/rapier/empty": "no route", "/obj/item/ammo_box/magazine/recharge": "no route", "/obj/item/ammo_box/magazine/recharge/ctf": "no route", @@ -2345,6 +2364,7 @@ "/obj/item/ammo_box/magazine/recharge/ctf/shotgun/green": "no route", "/obj/item/ammo_box/magazine/recharge/ctf/shotgun/red": "no route", "/obj/item/ammo_box/magazine/recharge/ctf/shotgun/yellow": "no route", + "/obj/item/ammo_box/magazine/resolution/empty": "no route", "/obj/item/ammo_box/magazine/sniper_rounds/surplus": "no route", "/obj/item/ammo_box/magazine/toy": "abstract", "/obj/item/ammo_box/magazine/zashch/empty": "no route", diff --git a/tools/gear_catalog/ratings/guns.json b/tools/gear_catalog/ratings/guns.json index 1ab21b5d6dc..7791e916cdf 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -110,7 +110,7 @@ "group": "smgs", "hash": "d9b40f6fa856925a", "name": "C-20r SMG", - "rank": 4, + "rank": 6, "reason": "Puts a vested man down in about a second, with 24 rounds, from one hand.", "score": 79, "tier": "yellow" @@ -120,7 +120,7 @@ "group": "smgs", "hash": "bb032c110b509c59", "name": "Popup Ronin machine-pistol", - "rank": 6, + "rank": 8, "reason": "20 damage at 3.3 a second, WT-550 numbers with some spread. Lives in the arm, so it can't be disarmed, and reloads are hard to get.", "score": 47, "tier": "yellow" @@ -179,7 +179,7 @@ "group": "smgs", "hash": "57d5080462a9a158", "name": "Type U3 Uzi", - "rank": 5, + "rank": 7, "reason": "Fires the C-20r's bullet at a lower rate, with 32 rounds. Solid, not dangerous.", "score": 70, "tier": "yellow" @@ -199,7 +199,7 @@ "group": "smgs", "hash": "517391feb1e23558", "name": "Stechkin APS machine pistol", - "rank": 3, + "rank": 4, "reason": "A burst pistol firing the C-20r's bullets. Its 15 rounds go in 3 s.", "score": 105, "tier": "yellow" @@ -328,6 +328,17 @@ "score": 42, "tier": "yellow" }, + "/obj/item/gun/ballistic/automatic/pistol/rattlesnake": { + "confidence": "medium", + "group": "smgs", + "hash": "", + "name": "MP-84 'Rattlesnake' machine pistol", + "note": "Right-click in hand cycles single shots, three-round bursts and full auto. Spawns on single shots. Takes a suppressor.", + "rank": 2, + "reason": "A machine pistol that fires 24 damage 9mm at 10 a second on full auto, from an 18 round magazine it empties in under two seconds. Wide spread and a hard kick, but it sits in one hand and fits a belt. Well past the Stechkin and the Saber, under the M-90.", + "score": 137, + "tier": "red" + }, "/obj/item/gun/ballistic/automatic/pistol/toy": { "confidence": "high", "group": "toys and novelties", @@ -364,7 +375,7 @@ "group": "smgs", "hash": "3a82e5bc193f1d07", "name": "Nanotrasen Saber SMG", - "rank": 2, + "rank": 3, "reason": "A hard-hitting automatic with 21 rounds that empties in 4 s.", "score": 105, "tier": "yellow" @@ -374,17 +385,28 @@ "group": "smgs", "hash": "", "name": "M-94 'Rapier' submachinegun", - "rank": 7, + "rank": 9, "reason": "Fires 16 damage bullets at 3.3 a second from a 30 round magazine, a WT-550 with a fifth less damage. It scores just under the yellow line, and it takes a suppressor.", "score": 37, "tier": "green" }, + "/obj/item/gun/ballistic/automatic/resolution": { + "confidence": "medium", + "group": "smgs", + "hash": "", + "name": "PD46 'Resolution'", + "note": "Right-click in hand folds or unfolds the stock. Folded it is normal sized with spread 11 and a little kick; unfolded it is bulky with spread 4 and no extra kick. Spawns folded. Takes a suppressor.", + "rank": 5, + "reason": "Full auto 4.6x30mm at 6.7 a second from a 30 round magazine: twice the WT-550's rate with half again its magazine. Between the C-20r and the Saber.", + "score": 93, + "tier": "yellow" + }, "/obj/item/gun/ballistic/automatic/smartgun": { "confidence": "medium", "group": "smgs", "hash": "df8d939590e38d5f", "name": "Abielle Smart-SMG", - "rank": 8, + "rank": 10, "reason": "Fires 10 damage homing rounds. Its spread of 40 is the drawback the homing fixes. Upstream tg prices it as junk.", "score": 32, "score_inputs": { @@ -431,7 +453,7 @@ "group": "smgs", "hash": "2ea01a015382d859", "name": "WT-550 Autorifle", - "rank": 6, + "rank": 8, "reason": "Fires 20 damage bullets at 3.3 a second, about what a laser gun does, yet it's sold as a top yellow gun.", "score": 47, "tier": "yellow" @@ -2038,11 +2060,13 @@ "/obj/item/gun/ballistic/automatic/pistol/deagle/ctf": "admin", "/obj/item/gun/ballistic/automatic/pistol/fire_mag": "leftover", "/obj/item/gun/ballistic/automatic/pistol/m1911/chimpgun": "needs another system", + "/obj/item/gun/ballistic/automatic/pistol/rattlesnake/no_mag": "no route", "/obj/item/gun/ballistic/automatic/pistol/riot": "leftover", "/obj/item/gun/ballistic/automatic/pistol/stickman": "literal only", "/obj/item/gun/ballistic/automatic/pistol/suppressed": "leftover", "/obj/item/gun/ballistic/automatic/pistol/toy/riot/clandestine": "toy", "/obj/item/gun/ballistic/automatic/proto": "leftover", + "/obj/item/gun/ballistic/automatic/resolution/no_mag": "no route", "/obj/item/gun/ballistic/automatic/tommygun/chimpgun": "needs another system", "/obj/item/gun/ballistic/bow/divine": "leftover", "/obj/item/gun/ballistic/bow/divine/with_quiver": "leftover", diff --git a/voidcrew/modules/loot/loot_guns.dm b/voidcrew/modules/loot/loot_guns.dm index e7c2cacf9c7..de05db686b4 100644 --- a/voidcrew/modules/loot/loot_guns.dm +++ b/voidcrew/modules/loot/loot_guns.dm @@ -55,6 +55,7 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( /obj/item/gun/energy/e_gun = 10, /obj/item/gun/ballistic/automatic/wt550 = 10, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly = 10, + /obj/item/gun/ballistic/automatic/resolution = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/energy/laser/carbine = 10, @@ -78,6 +79,7 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( /obj/item/gun/ballistic/automatic/l6_saw/unrestricted = 10, /obj/item/gun/energy/xray = 10, /obj/item/gun/energy/e_gun/nuclear = 10, + /obj/item/gun/ballistic/automatic/pistol/rattlesnake = 10, ), ), LOOT_GUN_POOL_PLUNDER = list( diff --git a/voidcrew/modules/loot/zone_loot.dm b/voidcrew/modules/loot/zone_loot.dm index 7c5ac774346..6b74afccb5a 100644 --- a/voidcrew/modules/loot/zone_loot.dm +++ b/voidcrew/modules/loot/zone_loot.dm @@ -69,9 +69,11 @@ GLOBAL_LIST_INIT(loot_gun_spare_ammo, list( /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher = /obj/item/ammo_box/magazine/m10mm, /obj/item/gun/ballistic/automatic/pistol/deagle = /obj/item/ammo_box/magazine/m50, /obj/item/gun/ballistic/automatic/pistol/m1911 = /obj/item/ammo_box/magazine/m45, + /obj/item/gun/ballistic/automatic/pistol/rattlesnake = /obj/item/ammo_box/magazine/m9mm_rattlesnake, /obj/item/gun/ballistic/automatic/pistol/zashch = /obj/item/ammo_box/magazine/zashch, /obj/item/gun/ballistic/automatic/proto/unrestricted = /obj/item/ammo_box/magazine/smgm9mm, /obj/item/gun/ballistic/automatic/rapier = /obj/item/ammo_box/magazine/rapier, + /obj/item/gun/ballistic/automatic/resolution = /obj/item/ammo_box/magazine/resolution, /obj/item/gun/ballistic/automatic/tommygun = /obj/item/ammo_box/magazine/tommygunm45, /obj/item/gun/ballistic/automatic/wt550 = /obj/item/ammo_box/magazine/wt550m9, /obj/item/gun/ballistic/rifle/sniper_rifle = /obj/item/ammo_box/magazine/sniper_rounds, diff --git a/voidcrew/modules/research/designs/ammo_designs.dm b/voidcrew/modules/research/designs/ammo_designs.dm index 7e8f582a5c0..ca9e5d8adda 100644 --- a/voidcrew/modules/research/designs/ammo_designs.dm +++ b/voidcrew/modules/research/designs/ammo_designs.dm @@ -149,6 +149,21 @@ ) departmental_flags = DEPARTMENT_BITFLAG_SECURITY +/datum/design/vc_mag_m9mm_rattlesnake + name = "MP-84 Magazine (9mm) (Lethal)" + desc = "A long 18-round 9mm magazine for the MP-84 machine pistol." + id = "vc_mag_m9mm_rattlesnake" + build_type = PROTOLATHE | AWAY_LATHE + materials = list( + /datum/material/iron = SHEET_MATERIAL_AMOUNT * 4, + /datum/material/plastic = HALF_SHEET_MATERIAL_AMOUNT * 3, + ) + build_path = /obj/item/ammo_box/magazine/m9mm_rattlesnake + category = list( + RND_CATEGORY_WEAPONS + RND_SUBCATEGORY_WEAPONS_AMMO, + ) + departmental_flags = DEPARTMENT_BITFLAG_SECURITY + /datum/design/vc_mag_smgm9mm name = "SMG Magazine (9mm) (Lethal)" desc = "A sleek 9mm magazine, suitable for the Saber SMG." diff --git a/voidcrew/modules/research/techweb_nodes.dm b/voidcrew/modules/research/techweb_nodes.dm index d36e664ab58..de4e39bfd40 100644 --- a/voidcrew/modules/research/techweb_nodes.dm +++ b/voidcrew/modules/research/techweb_nodes.dm @@ -418,6 +418,7 @@ prereq_ids = list(TECHWEB_NODE_BALLISTIC_AMMO) design_ids = list( "vc_mag_m9mm_aps", + "vc_mag_m9mm_rattlesnake", "vc_mag_smgm9mm", "vc_shotgun_dragonsbreath", "vc_ammo_uzi", diff --git a/voidcrew/modules/shiptest_guns/sidearms/attribution.txt b/voidcrew/modules/shiptest_guns/sidearms/attribution.txt new file mode 100644 index 00000000000..e3d1c420a76 --- /dev/null +++ b/voidcrew/modules/shiptest_guns/sidearms/attribution.txt @@ -0,0 +1,37 @@ +The code in this folder ports the MP-84 'Rattlesnake' and the PD46 'Resolution' from Shiptest +(https://github.com/shiptest-ss13/Shiptest, commit 000f892ae9), whose code is AGPL-3.0 like ours. + +Art +Shiptest licenses its art CC BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/) unless a file +says otherwise, and none of these files says otherwise. Each DMI here holds only the states these guns use, +copied pixel for pixel, apart from the renames listed. + +icons/rattlesnake.dmi - states rattlesnake, rattlesnake_mag, rattlesnake_bolt, rattlesnake_bolt_locked from + Shiptest icons/obj/guns/manufacturer/scarborough/48x32.dmi (Scarborough Arms sprites, + shiptest-ss13/Shiptest PR #3336 by rye-rice, moved there by PR #3176). + rattlesnake_suppressor is the pistol_suppressor state from /tg/station + icons/obj/weapons/guns/ballistic.dmi (CC BY-SA 3.0), copied into a 48x32 frame. +icons/resolution.dmi - states resolution and resolution_nomag from Shiptest + icons/obj/guns/manufacturer/warra_sharplite/48x32.dmi (Advantage weapons, Shiptest + PR #5210, designs by Apogee-dev; file moved by PR #5816). resolution_stock_folded and + resolution_stock_extended are that file's resolution_stock-attached and + resolution_stock-on-attached, renamed. resolution_suppressor and + resolution_nomag_suppressor are the saber_suppressor state from /tg/station + icons/obj/weapons/guns/ballistic.dmi (CC BY-SA 3.0), copied into a 48x32 frame. +icons/magazines.dmi - states rattlesnake_mag_0, _1, _3, _6, _9, _12, _15, _18 from Shiptest + icons/obj/ammunition/ammo.dmi. resolution_mag and resolution_mag-full are that file's + resolution_mag-1, and resolution_mag-empty is its resolution_mag-0. +icons/lefthand.dmi - state rattlesnake from Shiptest icons/obj/guns/manufacturer/scarborough/lefthand.dmi and + state resolution from icons/obj/guns/manufacturer/warra_sharplite/lefthand.dmi. +icons/righthand.dmi - the same two states from the matching righthand.dmi files. + +Sounds (taken unmodified) +sound/rattlesnake.ogg - Shiptest sound/weapons/gun/pistol/rattlesnake.ogg (PR #3336). It is byte-identical to + TerraGov Marine Corps sound/weapons/guns/fire/tgmc/kinetic/gun_p23.ogg, added by + TerraGov PR #13409 (https://github.com/tgstation/TerraGov-Marine-Corps). Licence: + CC BY-NC-SA 3.0 (https://creativecommons.org/licenses/by-nc-sa/3.0/). +sound/resolution.ogg - Shiptest sound/weapons/gun/smg/resolution.ogg (PR #5210). That PR credits its sound + effects as "Taken from TGMC" and remixed some to mono, so this file is treated as + TerraGov Marine Corps work under CC BY-NC-SA 3.0 + (https://creativecommons.org/licenses/by-nc-sa/3.0/). No byte-identical TerraGov file was + found, so the exact source file is not known. diff --git a/voidcrew/modules/shiptest_guns/sidearms/icons/lefthand.dmi b/voidcrew/modules/shiptest_guns/sidearms/icons/lefthand.dmi new file mode 100644 index 0000000000000000000000000000000000000000..0273b02e7d7416b01f19fb49c182c22692224b4c GIT binary patch literal 1992 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/sidearms/icons/resolution.dmi b/voidcrew/modules/shiptest_guns/sidearms/icons/resolution.dmi new file mode 100644 index 0000000000000000000000000000000000000000..097fce4810a8d1deb7c504e30a6fd84b7778eea1 GIT binary patch literal 1526 zcmZXUYdjMQ7{({XTCJgta!Vr#rOt>bW~SUyn5A}_uw~4hnd6?K+-fZeo7^gwN-_FX zlXbL)6U~qa)yYy~&!z09a7Nc}(sI!aEx?^)vaLJb7>EuObEWdPnFoA%kAGPh9w0|ZG>9bFo&ReBO zA<|IN%R@hTNfG3EQ7`qg^X@R6u{Mya!SUl}`B4C_a_Vw8Z&cdles45w31N*<4!U#$ z4Z-}gyWR7dbmY_AprpR7p_@_iJu{i+0>OZ|%C~2dxnWus^1#Er3u4?vb{$eFyMwK| zXxshp{@1p!D(Ck`f|r_XYDx(>_H zoaD4b7i|3~@)|}3RFg-$XUx+}fSbVqGnZ08wcCsZzQ`pMetlx1gyElHVpgYEknQ=z zM?-z8|F2d4fkW~MLB|NRi>Jut)20ppRV%;**0Dd$jQX$Zw{V<{>phI`6=GQFVFED8 z_D%fq2P^9vSfEJ`(uH9yvO<|^eL!=+#1{9iZ0$1eie4)C%)afI&HtZabwpktj&IHu1Gp6S=u-Bbatnq{&2vp)G|xT z4L5vyQj53Fx1YBmev$8ontgwqj+T75wtV6R3a2N!Tp*!k+DEwIc7UAId^ou;I*(zGA@%0nfu zaHN6O-eNrU)mc$yN)oupVDM&Yt)b1MG7d}GwuA6-(R_1hpyS(%;C}(umEPVWEhDBQ zizbWhjOv!UJ^w~y%4UTvN@@kWON)y&IP2!{RMNUS%M3M*DCNiCEy-T-7aA9D+Rx)k z4E2&HU!mA9#x~B|#gCIWW`s%%g9U>SZEakh3`lewd(T)BV=7=8kNBQ%$QCo|k(F6b z$&)Cc-bLm6-5f|_1SsbJR~s*TJkkKjX3J9lZg%!W9-I!E*Iet7o@}Lj^25=A8q^h8 z*7Ncsf!*sGU8}5lf!xGlN1|M8FfCaTAHEKyQ3nFHKDVL7kBF|x#&qviSx4+zc{)6! zwHd@gJlO?P1NjVw<(FR>S3saE zzry_5Vp}NjO%<-q>ZO-_?J@bY+sed+-+jNPjFR>kx}3q4PwB<4T%PoaA&@gWskBt465eksPsG3!E4GA^Z?;t S3L#QarVaz3s!w^2+Ob3J+-*>pXYTWapBJ(
  1. }~)>r9}Y%0MO=V%iD+c{$Gv> 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/sidearms/sound/resolution.ogg b/voidcrew/modules/shiptest_guns/sidearms/sound/resolution.ogg new file mode 100644 index 0000000000000000000000000000000000000000..db6837789d6edba021a46aabefcf63472d9e44f0 GIT binary patch literal 12758 zcmaib1z1&2xA&$Sk&^b%974JUNtHuM9J;$ZL_#D~q~S<+cQ;CibazTfi-gir-vP`0ZI9G=6_wcGjA!P@rkSijyM0;)r<0%5g$m`vUak1r0ik|v9mVOxML5I zgYaPqTfWTTPg+?~QC3M^O-#|)%pBrr z?Pdj0b9Asag*Y2T88)jKMe9@_jA z7i`voi~ww&9!Q`9&vs6%lE`*Jc7(_emYNYM9FCeXm;(2thH2SnbbTM!A>qxGe=4jy zJs{v(q|nLD2&6D?*}sS8CAfgB{;CB5{6RH=XVJuRmBjs(RD+`o%D>rFi1ZUnHf7eybymf7_Wf1t{Z+VkLqHs$-mV77uujVR ze~N{6ocaG=B&>Q^0SVBS-Og0q&J41u4Bam57vnw%mS=7< zy$FTDBZYhaa>A|K0YHMAs?(XO7qo^d$G8i>rmNVrtKu|hid&2P&)1tfynu?(rdq_= z_+#=$8Q+=3A7o3Gi7AcyR}whjedZb@X(Z`zP#8aHJ)@8~dAvPDJ(;<&Fd_NQgLXsY ztDrBTeTkhJi6cp_LvpxDxqn+;gvl7Cg=1hJ`DO}!7r8Mw&U!2fqi|LWoU1*Ov{BGV z47NfL$;`El>VNtD1B*g~l%aOv6>vt#T(WH=mOj`zH%r+Y#JcVK@9>cT{boEv)|DCn z@r^(?A*Rivjiqsm#E;G3?t$jeghxDu6$ZXgC1=ic3%Ju90suh-cPRc>bqD3YD9(xb zz}m-BJ;2k?ep{7v42kSibP!16a)T&l=Lb<7Ub~UzS|-u`!B{a54wV9*5J#U#QcjJrD}P)Q2wuY>;;InKo0(Nw+B4DwYB ziev19zxk98`9_IkRrnOuNc2ZZ+{a1H)dcj%`OU}S=93=gvvuaW^}gD5{{+n6uvwaR z|BuMI4G}uwh}GvYsQ-?f43>!RLJ{wmF+M;mf@AeTDRgOtj{z+91xVqT?95Enul237*4>asY03ZhddeF&11Rq?Y zq#VSwFu6Av&@zS5Gry7rj;VQ6xwb!~kG|tBMT`MyL)!H=B6Pr&H zDNcXn8|33xQHS>v!6((>^Q4OKIz~kzMa3g^IFT%z1U??82(Pst<};sFhkql5Pu7{w zllp1Kxi9f5YJ;~^Ak}?3&ew3tUO(XXG!4jI{56dJv`2L#SN~nX+C=j*CzGToeo|(@m(Ri zT|rS(XCulPq(VN;XCPLuOM zojp}{(T^6^{x21MRr@n$}P!D@U&otfB;3|93*3yeUkipoQ@USCQ zBRH+vffDTNI%IodEEJJznFOx1s5H5#c&EyNt)pZ|N(Jt#|IvI&P+$9j!;)Zx?sRxo z(T^t9{uWRW_d=H=RWlcr{=5K4NUbWx@ipx^#TvGq?{dBnSv_&jF2}{{js&rRbc+q5 zK8Z|PHe`PxBmkXulUR*_wG-k-p!x%nqt1;^>#79nq(Z!a+0Y^;Si2zV5pq2#v4k)y zC}K<%BQy5}%r+A-rD|K5H(|01$svVVb0EfaYzn(3bi(eN67X#1m<`Ec<<{I1KvtU> zIrvhr+#F(g7>C9H2;}TB$>)VRglckywIHk998!50cO9{=1NRgaI3tHpR}R`mtY**A zl|~KEvc;o_XY*by7X3fVJE3> z1Snk1mOpRG=9ZvqFE*9r3M-wOPz?tOruLsFlf3u;rMZa>N7s}|>8)_q%8xOi@Qpd0 zFk!@)3CJo&H4L1gYuA_;X9E(FR6}bw=3MRgPbPFqZe_@WPYBn@#$yd|*YQUIB>b_= zGj6w#+pv{-``F7<1tp z(4wX8AQW;t2Xt&oYi1-cOM|C)l5Alh!FI3_02a_NfC>~!SpjA;ELJ|V zJ0<)fpc%us-x8aA;NDD;#)NhzfH_Vc*=_0pK4fq6k1&t`-gbcm;Z3UBD#bP8--*e; zTUh@eB?`ej>%Io~wC*CK#Qd9;KP9}oHMRRENl*BD`yV;`zq9xMsikXM3PSEbD?p+Z z8w0q(pwxIK1f{@XztxcdA58Q*62KLsQwP0;Zsi1l?#Yyf39~SQD1kyj_<#|O(19ho zs;aUw=EmJ_9TPe*4v;qnWA2oy4JbtfGq+~qR@98-b7=0APACT$axn1LB#i&9DmbKb z#-xNhXG|50PcqXIFo1My!KK@BKx?N=!fHYLgFD2ONhtr64i2#F-~{+TuW)|*F{k>= z=<=PfX~AS$>dxA?2Ln~m7a6x`p_PGwsJg`nZ4l^&zkNYb|J6qzviTQUAfPt?jzheV zx`P&GCfEn120_Mh*J1=)Sbr0^J1_w}=Eyb4$2`>Rk(q z_SRf?gK7WHz0Czdh@#x?!t`4V*?n|&d@Em2URd?O0DoKnaGtT*fll>6I#Yt;RD%wW4^-xjQ2zjt^vAcs@$E;BRf}kGU==WNeJ2uelz$+R!^`h@$_?#UBC@&oe-F(_01W`d z;6do#JWKLgtP_wOwtvO88$7-Yrow2oPPTm=zjK!WM@gIX6!)x%E;CL^<_FQfa>ZT z6IK&~M9(;b! z9fP9)kks6n6tMCvN8OUGcG;CFeW$F}nJTG1@%sxQqv4$72x~aRDd9Oq0viz^PU=wK ztJy61_;gwY=6=tXq96X5M}aow#!jH$S}!axz74(PWT7%uY)<^Qebww5QXXy~9?rqu zjKxZ+F0V&Qz#uKw@ArdpKMGT{`i~STjON#Wv;kAl-)w`Ow7(?-YyL`_Wus(ll8BOmV36G(ba+ zB<30I=KH*_Ozk%TDYsOLCxby#J3bW#K{H(j;0#JFUZ{q#a*BFmW5{6+(vB(Q5FbW6o@lWO|ox|)kv zHEq1yE@P+!4TNry<8H$3`!zgcMKtQ>D01e$%T4`}(O{)E zN4DkgHCc22qY_~gFHqR1w5j>n6P2fmT3oi|u^G0Lu(_%)U6_W9txfXW)J&OwsywcE zdvW|?E@6gwzUzMfiog@Izu)z!b zSuxPI=ItpvN_+-`_>^qyx|n`tJ;neY`+Cw(Wm}T}iuvRxY}xuV->AhwbZ$uaTi={8 z>F!==Lcv*vK=@@DRlm{rr$;8SvT^TuHA6Rkj}uw^ZkM)P{n|8oCF28sf5|`%`FPH! zTI}8bXK{p(iIYd6QR_FZw~lv+bF4OLAb!Y%Vg%n(6(=p<0%7^aXRaaQC|W6(5-Ti6 z|L;F|XS!!On-j{m<|(YoNJ3C*jL31{3&ppMq*4nmmTT9&u&ixRv06BJ*j0k83-zry z<@D7+Yt~!f5~`HBCUnDDRy1xB0n$X>15lKyCuZq)A8ih<;1de?!@3jdlyYOLG0lcv zi7R%99_Ki=~Znu3- zFu_YLM8VZ)P?~(&{(Y-yxOJeVflP*Oj0Lr-+$Sdm#h{#0jz6npSYH|=Nwg;D5{34? z*SYaG>^%dbE)wcp%077DAamIbohr-+JikiJj`J-JOVznz-Kd*LJpycP-lFoXGJyghNg#APX zLQEU$6+$a<5-BEtdaAeT;`>H>UHg~_gJnBrc}Mko`7WKKj!zZmq8cC8CX%RCyPKfB zPAI0km^x_%4zpu4gs{FV52mfdpAcdD(!VdHwK z{*~P)#4%w~n>2a51((zNB$yaHvTzH+mawNP{9hVn(^;Mf*v~ALTQ<-0@}`hvJ~tyC zWJ#(UldQ+lSe4s?T@^O8JPx1yI#AbxDH+fH%F@9MVG_rK!GpXPAg3xk8o%A1>k>$m zJ`pHqe}dFRkV(3}-jRVt&+0ELxbLm-j0=3xer*Q$m;M=!q?yg0KmqiAM1BXKls_1v zzG53ES$etk zO7UziHEoFrLRPbu`%{tYO%VT}m8iCx=rNVX2AXhME*UE3<3sMI1GW^gKdO(BSb$~l zd)nwEG<>E6Un_nt;KAJO+kIzCrTg~VlVlGYTvu`Y^OQq8#5ak1}v;>vI_lzJE< z&d~BPVD~h{80i0OC~Vqk^M_5)yW1pFOJIGbYRn)!e`g?N&Tr)_`8~^J+sE2s1}CR6 zvRh@x_Qd@{jQ>;pB~%5|dp_RQk24X{szBa2x4&)YU9QgILj7XSJBJj`==VgIQ~(|+ z_50~_r#`{E1n(Y=bLY7tz{aQsKa2);ulb~7fOOu zVM1Tnrv<06ivz0jH-A})_J6V_`kkQ|oyrlIS?|d%9dKmYAbELIa9T^Pn=AVDG7oj| zpq?V#P;T{%kBn-CRfVr{%@SY10ps`VYlEjIjj5@)ULp*S=6v8?$n`&%5WU6wwYcQJ z`a@kz`4|JgC7^V~3KA#&_AHumQ|BuANZrzDa~_!YXlKoqt?bqOPnVJJy%7fOB-fp_ z>?cp@+FMd~_S<}QfNSQ{O{MjI>jT}_3xY()JijiTN2^f^hn02|d%s}`-8+BjHN-`I^2#$v?%KX7Ae`-B z7FNpk;%Iw`Jsr>YYu1g&11(`c;c5%f=MrJh8hxE+5~ri_6Vwbh{MTvUeYNo$x>D1R zZB8N5OE>)5$EIo#6^#>r;1^G2Z>~NvwkHYmh>3Br%Rcv;r?P9xNFOo6Btf4i`g)}w z)OGXXs`aU221NlnaP0tM&muatC+}eGPpBE02O41TL8b(aRv~NgtLNMcU12KPcCC+L zO8vda&pEIXA|Kk(2qnFK?Jly%x=(cPTB}6YV&ib>_veN7t5`WkNil`)7Mh9T6E{Q?VWyYQwX#}> ztNp0w!_X79LptpF>10A+FE=9ItPV6V$}f%SoM!^+!?PJLk<$0GGLj!}4exurDHv04 zBIN82A53~Lr8{o=MqMe~WSy<8fVj}O^!!>6+Wlh#eVp#-u=lDAqQgL`ysZL@^~U0F z6KpK_{*cpEqy3QZ!D$C@xk{FbH}FW3bvxy9EW+3ORSg7S(Z6YVwmChhhtyr%^(L4W zNn8&1j1hYjI2y`ess+N}l{NBOy<f}k22M;?$V_h$V zt|}hYKD>u_G{LeYu@`pis$-UJn|?a{wGH`Au~lA}0iT$d^1~nS5%e!;oumnP^ z7zKsm%MUMq$0*rj3#d4MnSjeQu4XLF)Q_E;WdC7@#3<^2^@$qbQ8DL6tz@W>`-Dpq zm+<6aL5}(IpY@fWTrh+ZyLM@-5B0aS_)nN3YDy~D2zfIR6ViY@?(qQ~m=o`NuEBQE z+*fb*M>xF(i^+{1a@`F6L~&9YG*Fgm-s5|>S zh2opXJ2d);@HdFkv&0rO^oGpi{68b{-|uURsiD74#}F==2$PAtRF5z2EZTz7%m0$P z2tQuraX?Zv!Y{9$-Z~-3%zrYyA-*w$M9`IK)m4mRZftSB<}?-k&l`obf4@;c0h?v6 zb!?;){ewLtJ$;P>U9AnDo9gPDtDCB;>#w~~Hxi7HjG8K(=2^E7!q$Up5$O$G@%R-_ z4$FVWcZ)52e?3t8jzE=T0q7$ZM{KF%gfQnFneQhZuC{3u=b0hb z#)$X(?W+u28RBIt`d2#_vcNBAgBzkaFg*Fo!K_MKeTKinA3qPL!)@3BAb_m;2w*zu0RG@WwDJNUD%AO{pY!6gYZ~ zX;u?k(@~P*)OB7Wh_h3KUDEc`xF+;1*t@9@rP!GFv1zaDzYtF0?BVTJccMcuo={r5 zWDGlRmZJ}Al^PlN$R0lM0%#0qUQU7^BUmm9)hv_4kjSqoUl7X!3F;rMr3G@HW0SdZ zF6OjS3ky*jq`6>R^X7F{9Y0I=OFBkL_pOG%$=AVkZLS&N)3H$b@Tvui*L8)2%Fc@r z{~^bTVcVp>VFMG=YuAbUy@NH!Us0xsU8g>O_cgeSC3`n}$BIxW%AGOHfNKp# z{=(bsW$5PI*+6-M=dd?)9(q5SK3)ed^la1UD9d{O6MKy9QP~h+alIanxw6~nTEKU| zG{65XJhi&D(f*O|MgK@p0pr5;;L6b(v+!L-gT$E8fceU^i>^s}DWLf+BK$%0cE|p% zO7Sx!cGiklWRI*?LZlb=Oqg4Q)DOfED5NtFKJVZB+$QRBV;2q=wxvQFIB=+@X%X7I zH2o}cZgXbb= z3x-&IY<&)?*B?kE)olwrAC~Js_DQaK#*!XU&~NdG+5dM>0QHfbYd6JFl` zFky!(*ra=q4*9d%GL~dx&gVI#YoWC*u_IJ8El#&GK?BJ=5Dj zp*H-C`5P?-{*x$NF+8MXX4;?mRt`q5gFY&px$LGZl79@pmqev}6GDkFWC~Z!aX`%U$HZ8b|1oKaFY&)7iY&)DSLN%V(g;$3r z!y>+RBJLwXJp4k5pKi>?XNB&S?9~-4xkwIB?b<{STcTWMHSA0it$04hem*gQHZ?T& z{3}C3&w~h`41eMbQCfD7+=+(xkMe=)?+e-|3LgzbDl_Taqw_60VTw%FEk_PcN(MHy z%YAkZ-c=cB)h;yI5f~|QK4!xj=W&j}e!oMKEI_ig)VL(A7HIXrv1&+oa-W!`NQS&2 zI-p9UPOrA%C6K1YuuWfs0;DusLj1Wov`MF`n4lG6=SPX97A;E%54Kj*6V2bfi&MH@ z)s=tZ2fLCUE4RauLKn|-GRcP()F3Duvy=hm<;v$D7SpN-_aXDY z+fzSf)p6X+9iE8w=4NOm51ub|pSd%_EWx;#s z*@ zl>94;`lfZPq~Dlo(qP=vy19IQp?uPvxIR=j(A4VX+IMcf@y_1DU`#2?mjyq0pfACa zzHF=UZC%Zamr40lRYzBgiLW=+55jAwe#odZdUuFltVE?80uG3e7 z)`CcU|W_Gz`DMu(V?$oY~Eq?IOAV_9-D2 zl=YP+!N}lE!RBx3v?{k>`VSYf9qVYI(UPC&K7QA(ob9f*mF1K75NJhvvLCy{ge8A* zgKO9?sPvHLvzqrs!kd`K)!WL1TsA7%ZP#|&;-z?=e>QN*VDqD)?~KsBo+j>TB(b66 z7FyzBC6&#W0uB7|W@=@6HY4FoExp`dB0`aSR;VqO3@e%=$oGjpAxH6K0$oKhu_lT< z{^vi>esD3JJc}i#^FeC8Hf{89DDimZ5NmWZ*yE^7KBsgjicl~p%Rq@2bf1q8V0_i% zV)DlI@kqwA?;cz?mvwz*S#B4VrNX=lhslyv(*kwxBB;!=S%eQC+f5W*iN<)_`Ns7^ zzAtN5?_1%`!?S|fiJ#d2F#FbI^Ze8>nHV3{}c%P@Hl(T zCAjplv3CD;=Of2M`($a7kbp+-b|cdvx2JFC`8Z=f<^+|-9d0*1oq8iL?@%~f;H84t z`DPPcXIX<+t!-)H7V+m@`O2}g+37Xf{F+j&TAyL*56P3w*3jZFDhJH$+2r58>RY+P z6T3PlX97uff*ahwKHfonyH^;s$uGnq8Srm?l5EkZj4=zyx||DY z?gw7+NOCygQB* z8{_jOJP0jRL+%QYlX9rxMZNSuksh}9Lb;(56waR0GC-{sakP&+nD_+Dt(1GD@)S`7 z#-`EvBQIL4ZF?S|jiYV{d$IG|5pngwT`ETzP_*K7-<8?-U)-#ADYwS$Z?YLaJpX}H zx1doPCR3*?n42STv1fjtt2^#SX0L|&@M*xB;WTMxuDor_+;)Teu|ov$9DU;_QZ7t` zp=qjzK6+vk`_HetJADes=~#BB(K+Z(o<(WbExT@8=*EX-Tbacx7Y3&5aO~ zRRwgbpMiDa$#Rz^rF81^R7XunyA#) zSsfM22N*Gb3fX=E-h+8d3Wme5hOvd1L3}2Y<}HtNbEj&u$64e0G)d-zJYN_N8oDIK zP(x=X2>V4%RXgo`zKI;Gi=4^3A4e*SH`>)Pue zIPzxjax!B$&vrbsSFF1|VAr3g@ji^xqxcaZwtn!qa_Nu9Hq)+l8RePknC(~1AidLU zV~cOz4-zz~%y~^R+t}wTZ7Frz5s*D9m+>IfG!r2d(_vNbpC#Fgdhcg*%$aO2ES{E^ z)>F;a)UP`Hs6=#e>flSV&I|GMRFJ4RJ(^7>T5q^4Anc$#@gVqsoMw)?#e0NG%&Yfl zYC{0Uh}{cUJCj5W;8a$oVfF3VXcwE;Z)u+QboD*_8LMPJ>i#{xvQy~6IPu6dj<(3f9B<_S#$Xfe4#<=!@f>4!7ttxs8i;zi^0ORf1ULNpJnwQk0X z64+z9@Fy9~!Z^BvTZm>fXDx-~#%A!FrqW^A%E%~DpWdeh>DBbpc&j$s3u^iL#ud9y zLy;_|b#IQ*P6(HgTFi6KENiW`5Txuv59J@#;*Z1?v*z>h`ups5OcZbOPVX~R|2FQA z`*hJ z7@qW>`?ynwACp&{wvs=LH^;7PIapKY+qF?+seHE)How2&wAw#1*kdg$?)BQxbSx>r zk4a=t!2*HwQuEhmy;@wW1)b{0Sm=W+RplIc_GJ^zDj%jeg<0GE5ieW2tx)pRYRqka zEUDRSho`Fwa5$QLBgX|G$bS58d+s5_M*x*leh~^;u|hBH-~bSJl4|)WIjzNmuRCv3 zds;|*W>C??!sXR~{_j6F)1kh}Qp$8tx!55_Tx#1NYe?*v>yJ+L5^QTSQ)w%SDKOZJ zzaxcyJ3GLM7kR^cPj8^bGZ`Ic`rGy1!i)IT(t`^DjZ;dy@)40^sX1kDc?t}m_}dO; z>GcPeQNn0RzNs06F^vt4r1`JN%HL{onqAK*)DHvt+#d6p0+}HjNQ>qvUBy9<(PK~- zvptnC!ahcJDAhDbeMbIdj@+5L)ny%G=v_(ctm?Y5lu93;Dx8_PNo)rxq4&p zV?{GF3gY(!$($w4syBIwy3pd0b)v8NiVRy$ru+DWgYuOpeeLnJR-l|D!LoM+@0#W~oYUhEmQ6zF1 zhMf=^%WQl-u|!FeD0!{A(u3Rw5q+C3^XCf;HLkxn%u_zBz9|_u@c+yz#G`ap z(Ns@}#~`w3e)*ZYwEu&jY+gC* zT_kM~NZ9!G>_HtlEaaEVwQ*zlUJE3t_7r-%8;o(LA~Sh>C~{r67H`alRPYg#opoe- be79Y8qq6D7$5=)<7=7<{LCI)F!pHwVWN9nm literal 0 HcmV?d00001 diff --git a/voidcrew/modules/trade/shop_catalog_black_market.dm b/voidcrew/modules/trade/shop_catalog_black_market.dm index 624c57f31dc..4fb9ebfe50c 100644 --- a/voidcrew/modules/trade/shop_catalog_black_market.dm +++ b/voidcrew/modules/trade/shop_catalog_black_market.dm @@ -70,6 +70,8 @@ /datum/shop_sku/black_market/ansem, /datum/shop_sku/black_market/ansem_sc, /datum/shop_sku/black_market/lethal_cycler, + /datum/shop_sku/black_market/rattlesnake, + /datum/shop_sku/black_market/resolution, /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/black_market/pistol_mag, /datum/shop_sku/black_market/speedloader, @@ -971,6 +973,32 @@ fixed_price = TRUE contract_reward = FALSE +/datum/shop_sku/black_market/rattlesnake + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Weapons" + desc = "A 9mm machine pistol with a long magazine and a fire selector." + item_path = /obj/item/gun/ballistic/automatic/pistol/rattlesnake + price_credits = 4800 + price_vouchers = 3 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/black_market/resolution + zone_permission = ZONE_PERMISSION_YELLOW + category = "Weapons" + desc = "A 4.6x30mm personal defense weapon with a folding stock." + item_path = /obj/item/gun/ballistic/automatic/resolution + price_credits = 4200 + price_vouchers = 2 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + /// Counter-bought caches retain themed supplies, with a single freight weapon roll. /// Kept separate from guarded caches so buying a crate cannot buy advanced guns. /obj/structure/closet/crate/zone_loot/syndicate/trader diff --git a/voidcrew/modules/trade/shop_catalog_outfitter.dm b/voidcrew/modules/trade/shop_catalog_outfitter.dm index d525a7f22c2..ae0c92713ae 100644 --- a/voidcrew/modules/trade/shop_catalog_outfitter.dm +++ b/voidcrew/modules/trade/shop_catalog_outfitter.dm @@ -69,6 +69,7 @@ /datum/shop_sku/outfitter/wt550_mag, /datum/shop_sku/outfitter/rapier_mag, /datum/shop_sku/outfitter/zashch_mag, + /datum/shop_sku/outfitter/resolution_mag, /datum/shop_sku/outfitter/firing_pin, /datum/shop_sku/outfitter/riot_shotgun, // Melee @@ -355,6 +356,16 @@ stock_min = 10 stock_max = 20 +/datum/shop_sku/outfitter/resolution_mag + zone_permission = ZONE_PERMISSION_YELLOW + category = "Firearms & Ammo" + name = "pd46 magazine" + desc = "A loaded 4.6x30mm magazine for a PD46." + item_path = /obj/item/ammo_box/magazine/resolution + price_credits = 180 + stock_min = 10 + stock_max = 20 + /datum/shop_sku/outfitter/firing_pin category = "Firearms & Ammo" item_path = /obj/item/firing_pin diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 50dac2a5a37..73fb82e754f 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -217,6 +217,7 @@ #include "voidcrew_ship_silicons.dm" #include "voidcrew_ship_starting_guns.dm" #include "voidcrew_ship_turret_targeting.dm" +#include "voidcrew_shiptest_sidearms.dm" #include "voidcrew_shop_catalog.dm" #include "voidcrew_silicon_ship_systems.dm" #include "voidcrew_simple_mob_ai.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_loot.dm b/voidcrew/modules/unit_tests/voidcrew_loot.dm index bc6763d74fb..4004ca712c2 100644 --- a/voidcrew/modules/unit_tests/voidcrew_loot.dm +++ b/voidcrew/modules/unit_tests/voidcrew_loot.dm @@ -291,7 +291,7 @@ /// A working copy may be consumed without damaging another crate's singleton tables. /datum/unit_test/voidcrew_cache_weapon_pools/Run() - var/list/main_counts = list("armory" = list(3, 4, 5), "syndicate" = list(2, 3, 3), "plunder" = list(3, 2, 3), "freight" = list(4, 2)) + var/list/main_counts = list("armory" = list(3, 5, 5), "syndicate" = list(2, 3, 4), "plunder" = list(3, 2, 3), "freight" = list(4, 2)) for(var/family in main_counts) var/list/counts = main_counts[family] var/list/classes = GLOB.loot_gun_pools[family] @@ -750,6 +750,7 @@ /obj/item/gun/ballistic/automatic/pistol/deagle/camo, /obj/item/gun/ballistic/automatic/pistol/deagle/gold, /obj/item/gun/ballistic/automatic/proto/unrestricted, + /obj/item/gun/ballistic/automatic/resolution, /obj/item/gun/ballistic/automatic/smartgun, /obj/item/gun/ballistic/automatic/wt550, /obj/item/gun/ballistic/revolver, @@ -822,6 +823,7 @@ /obj/item/energy_katana, /obj/item/gun/ballistic/automatic/l6_saw/unrestricted, /obj/item/gun/ballistic/automatic/m90/unrestricted, + /obj/item/gun/ballistic/automatic/pistol/rattlesnake, /obj/item/gun/ballistic/automatic/tommygun, /obj/item/gun/ballistic/rifle/sniper_rifle, /obj/item/gun/ballistic/rocketlauncher/unrestricted, diff --git a/voidcrew/modules/unit_tests/voidcrew_shiptest_sidearms.dm b/voidcrew/modules/unit_tests/voidcrew_shiptest_sidearms.dm new file mode 100644 index 00000000000..3ade94504f4 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_shiptest_sidearms.dm @@ -0,0 +1,144 @@ +// voidcrew/modules/shiptest_guns/sidearms. Voidcrew defines are not visible here, so values are literals. + +/// Every icon state these guns and magazines draw exists: a missing overlay or magazine state is invisible, and nothing reports it. +/datum/unit_test/voidcrew_shiptest_sidearms_icons +/datum/unit_test/voidcrew_shiptest_sidearms_icons/Run() + var/list/gun_states = list( + /obj/item/gun/ballistic/automatic/pistol/rattlesnake = list("rattlesnake", "rattlesnake_mag", "rattlesnake_bolt", "rattlesnake_bolt_locked", "rattlesnake_suppressor"), + /obj/item/gun/ballistic/automatic/resolution = list("resolution", "resolution_nomag", "resolution_stock_folded", "resolution_stock_extended", "resolution_suppressor", "resolution_nomag_suppressor"), + ) + for(var/gun_path in gun_states) + var/obj/item/gun/ballistic/gun = allocate(gun_path) + var/list/known = icon_states(gun.icon) + for(var/state in gun_states[gun_path]) + TEST_ASSERT(state in known, "[gun_path] has no icon state [state] in [gun.icon]") + TEST_ASSERT(gun.inhand_icon_state in icon_states(gun.lefthand_file), "[gun_path] has no left hand sprite") + TEST_ASSERT(gun.inhand_icon_state in icon_states(gun.righthand_file), "[gun_path] has no right hand sprite") + + var/obj/item/ammo_box/magazine/m9mm_rattlesnake/snake_mag = allocate(/obj/item/ammo_box/magazine/m9mm_rattlesnake) + var/list/mag_states = icon_states(snake_mag.icon) + TEST_ASSERT_EQUAL(snake_mag.ammo_count(), 18, "The MP-84 magazine does not start full") + TEST_ASSERT_EQUAL(snake_mag.icon_state, "rattlesnake_mag_18", "A full MP-84 magazine draws the wrong state") + for(var/rounds in 0 to 18) + var/state = "rattlesnake_mag_[rounds == 1 ? 1 : round(rounds, 3)]" + TEST_ASSERT(state in mag_states, "An MP-84 magazine with [rounds] rounds would draw the missing state [state]") + var/obj/item/ammo_box/magazine/m9mm_rattlesnake/snake_spent = allocate(/obj/item/ammo_box/magazine/m9mm_rattlesnake/empty) + TEST_ASSERT_EQUAL(snake_spent.icon_state, "rattlesnake_mag_0", "An empty MP-84 magazine draws the wrong state") + + var/obj/item/ammo_box/magazine/resolution/pd_mag = allocate(/obj/item/ammo_box/magazine/resolution) + TEST_ASSERT("resolution_mag" in mag_states, "The PD46 magazine has no base state") + TEST_ASSERT_EQUAL(pd_mag.ammo_count(), 30, "The PD46 magazine does not start full") + TEST_ASSERT_EQUAL(pd_mag.icon_state, "resolution_mag-full", "A full PD46 magazine draws the wrong state") + var/obj/item/ammo_box/magazine/resolution/pd_spent = allocate(/obj/item/ammo_box/magazine/resolution/empty) + TEST_ASSERT_EQUAL(pd_spent.icon_state, "resolution_mag-empty", "An empty PD46 magazine draws the wrong state") + +/// The MP-84 is a pistol for feel, fires 9mm from its own magazine, and right-click walks the selector through its three modes. +/datum/unit_test/voidcrew_shiptest_sidearms_rattlesnake +/datum/unit_test/voidcrew_shiptest_sidearms_rattlesnake/Run() + var/mob/living/carbon/human/consistent/shooter = allocate(/mob/living/carbon/human/consistent) + var/obj/item/gun/ballistic/automatic/pistol/rattlesnake/snake = allocate(/obj/item/gun/ballistic/automatic/pistol/rattlesnake) + snake.gun_feel_resolve() + TEST_ASSERT_EQUAL(snake.gun_feel_class, "pistol", "The MP-84 should kick like a pistol") + TEST_ASSERT_EQUAL(snake.magazine.type, /obj/item/ammo_box/magazine/m9mm_rattlesnake, "The MP-84 spawned with the wrong magazine") + TEST_ASSERT_EQUAL(snake.chambered?.type, /obj/item/ammo_casing/c9mm, "The MP-84 fires the wrong round") + TEST_ASSERT(!length(snake.actions_types), "The MP-84 should have no action button") + TEST_ASSERT_EQUAL("[snake.fire_sound]", "voidcrew/modules/shiptest_guns/sidearms/sound/rattlesnake.ogg", "Gun feel swapped the MP-84's own shot") + var/datum/component/automatic_fire/autofire = snake.GetComponent(/datum/component/automatic_fire) + TEST_ASSERT_NOTNULL(autofire, "The MP-84 has no full auto") + TEST_ASSERT_EQUAL(autofire.autofire_shot_delay, 0.1 SECONDS, "The MP-84 fires at the wrong rate") + TEST_ASSERT(snake.can_suppress, "The MP-84 should take a suppressor") + var/obj/item/ammo_box/magazine/m9mm_aps/stechkin_mag = allocate(/obj/item/ammo_box/magazine/m9mm_aps) + TEST_ASSERT(!is_type_in_typecache(stechkin_mag, typecacheof(snake.accepted_magazine_type)), "The MP-84 takes Stechkin magazines") + + TEST_ASSERT(shooter.put_in_hands(snake), "The shooter could not hold the MP-84") + TEST_ASSERT_EQUAL(snake.fire_select, 1, "The MP-84 should start on single shots") + TEST_ASSERT_EQUAL(snake.burst_size, 1, "Single shots fire more than one round") + TEST_ASSERT_EQUAL(snake.autofire_bypass_check(null, null), COMPONENT_AUTOFIRE_ONMOUSEDOWN_BYPASS, "Single shots let full auto take the mouse") + snake.attack_self_secondary(shooter) + TEST_ASSERT_EQUAL(snake.fire_select, 2, "Right-click did not move the MP-84 to burst") + TEST_ASSERT_EQUAL(snake.burst_size, 3, "Burst does not fire three rounds") + TEST_ASSERT_EQUAL(snake.autofire_bypass_check(null, null), COMPONENT_AUTOFIRE_ONMOUSEDOWN_BYPASS, "Burst let full auto take the mouse") + snake.attack_self_secondary(shooter) + TEST_ASSERT_EQUAL(snake.fire_select, 3, "Right-click did not move the MP-84 to full auto") + TEST_ASSERT_EQUAL(snake.burst_size, 1, "Full auto fires bursts") + snake.attack_self_secondary(shooter) + TEST_ASSERT_EQUAL(snake.fire_select, 1, "Right-click did not move the MP-84 back to single shots") + + // Burst mode fires three rounds from one pull. + snake.set_fire_select(2) + var/start = snake.get_ammo(TRUE) + snake.fire_cd = FALSE + snake.fire_gun(run_loc_floor_top_right, shooter, FALSE, null) + sleep(1 SECONDS) + TEST_ASSERT_EQUAL(snake.get_ammo(TRUE), start - 3, "A burst did not fire three rounds") + +/// The PD46 is an SMG for feel, takes its own 30-round magazine, and right-click folds and unfolds its stock. +/datum/unit_test/voidcrew_shiptest_sidearms_resolution +/datum/unit_test/voidcrew_shiptest_sidearms_resolution/Run() + var/mob/living/carbon/human/consistent/user = allocate(/mob/living/carbon/human/consistent) + var/obj/item/gun/ballistic/automatic/resolution/pdw = allocate(/obj/item/gun/ballistic/automatic/resolution) + pdw.gun_feel_resolve() + TEST_ASSERT_EQUAL(pdw.gun_feel_class, "smg", "The PD46 should kick like an SMG, not a rifle") + TEST_ASSERT_EQUAL(pdw.magazine.type, /obj/item/ammo_box/magazine/resolution, "The PD46 spawned with the wrong magazine") + TEST_ASSERT_EQUAL(pdw.magazine.max_ammo, 30, "The PD46 magazine is the wrong size") + TEST_ASSERT_EQUAL(pdw.chambered?.type, /obj/item/ammo_casing/c46x30mm, "The PD46 fires the wrong round") + TEST_ASSERT(!length(pdw.actions_types), "The PD46 should have no action button") + TEST_ASSERT_EQUAL("[pdw.fire_sound]", "voidcrew/modules/shiptest_guns/sidearms/sound/resolution.ogg", "Gun feel swapped the PD46's own shot") + var/datum/component/automatic_fire/autofire = pdw.GetComponent(/datum/component/automatic_fire) + TEST_ASSERT_NOTNULL(autofire, "The PD46 has no full auto") + TEST_ASSERT_EQUAL(autofire.autofire_shot_delay, 0.15 SECONDS, "The PD46 fires at the wrong rate") + + TEST_ASSERT(user.put_in_hands(pdw), "The user could not hold the PD46") + TEST_ASSERT(!pdw.stock_extended, "The PD46 should start with its stock folded") + TEST_ASSERT_EQUAL(pdw.w_class, WEIGHT_CLASS_NORMAL, "A folded PD46 is the wrong size") + var/folded_spread = pdw.spread + pdw.attack_self_secondary(user) + TEST_ASSERT(pdw.stock_extended, "Right-click did not extend the stock") + TEST_ASSERT_EQUAL(pdw.w_class, WEIGHT_CLASS_BULKY, "An extended PD46 should be bulky") + TEST_ASSERT(pdw.spread < folded_spread, "Extending the stock did not steady the PD46") + TEST_ASSERT(pdw.recoil < initial(pdw.recoil), "Extending the stock did not soften the kick") + pdw.attack_self_secondary(user) + TEST_ASSERT(!pdw.stock_extended, "Right-click did not fold the stock") + TEST_ASSERT_EQUAL(pdw.w_class, WEIGHT_CLASS_NORMAL, "Folding the stock did not shrink the PD46") + TEST_ASSERT_EQUAL(pdw.spread, folded_spread, "Folding the stock did not restore its spread") + + // With a suppressor on, the stock still moves the size by one step each way. + var/obj/item/suppressor/suppressor = allocate(/obj/item/suppressor) + pdw.install_suppressor(suppressor) + TEST_ASSERT_EQUAL(pdw.suppressed, suppressor, "The suppressor did not fit the PD46") + var/suppressed_size = pdw.w_class + pdw.attack_self_secondary(user) + TEST_ASSERT_EQUAL(pdw.w_class, suppressed_size + 1, "Extending the stock under a suppressor gave the wrong size") + pdw.attack_self_secondary(user) + TEST_ASSERT_EQUAL(pdw.w_class, suppressed_size, "Folding the stock under a suppressor gave the wrong size") + + // The magazine-less sprite takes over when the magazine comes out. + pdw.eject_magazine(user) + TEST_ASSERT_EQUAL(pdw.icon_state, "resolution_nomag", "An empty PD46 draws the loaded sprite") + +/// Both guns and both magazines can be got: shops, loot pools, spare loot magazines and the research print. +/datum/unit_test/voidcrew_shiptest_sidearms_routes +/datum/unit_test/voidcrew_shiptest_sidearms_routes/Run() + var/list/pools = GLOB.loot_gun_pools + TEST_ASSERT(pools["syndicate"]["3"][/obj/item/gun/ballistic/automatic/pistol/rattlesnake], "The MP-84 is not in the red syndicate gun pool") + TEST_ASSERT(pools["armory"]["2"][/obj/item/gun/ballistic/automatic/resolution], "The PD46 is not in the yellow armory gun pool") + TEST_ASSERT_EQUAL(GLOB.loot_gun_spare_ammo[/obj/item/gun/ballistic/automatic/pistol/rattlesnake], /obj/item/ammo_box/magazine/m9mm_rattlesnake, "A looted MP-84 carries no spare magazine") + TEST_ASSERT_EQUAL(GLOB.loot_gun_spare_ammo[/obj/item/gun/ballistic/automatic/resolution], /obj/item/ammo_box/magazine/resolution, "A looted PD46 carries no spare magazine") + + var/datum/shop_sku/black_market/rattlesnake/snake_sku = /datum/shop_sku/black_market/rattlesnake + TEST_ASSERT_EQUAL(initial(snake_sku.zone_permission), 2, "The MP-84 should need advanced reputation") + var/datum/shop_sku/black_market/resolution/pdw_sku = /datum/shop_sku/black_market/resolution + TEST_ASSERT_EQUAL(initial(pdw_sku.zone_permission), 1, "The PD46 should need yellow reputation") + var/datum/shop_sku/outfitter/resolution_mag/mag_sku = /datum/shop_sku/outfitter/resolution_mag + TEST_ASSERT_EQUAL(initial(mag_sku.zone_permission), 1, "PD46 magazines should need yellow reputation") + var/datum/outpost_shop/black_market/market = allocate(/datum/outpost_shop/black_market) + TEST_ASSERT(/datum/shop_sku/black_market/rattlesnake in market.sku_types, "The black market does not stock the MP-84") + TEST_ASSERT(/datum/shop_sku/black_market/resolution in market.sku_types, "The black market does not stock the PD46") + var/datum/outpost_shop/outfitter/outfitter = allocate(/datum/outpost_shop/outfitter) + TEST_ASSERT(/datum/shop_sku/outfitter/resolution_mag in outfitter.sku_types, "The outfitter does not stock PD46 magazines") + + var/datum/design/print = SSresearch.techweb_designs["vc_mag_m9mm_rattlesnake"] + TEST_ASSERT_NOTNULL(print, "The MP-84 magazine has no design") + TEST_ASSERT_EQUAL(print.build_path, /obj/item/ammo_box/magazine/m9mm_rattlesnake, "The MP-84 magazine design builds the wrong thing") + var/datum/techweb_node/node = SSresearch.techweb_nodes["automatic_ammo"] + TEST_ASSERT("vc_mag_m9mm_rattlesnake" in node.design_ids, "Automatic Ammunition does not print MP-84 magazines") diff --git a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm index cbf95698768..bdd557b7af4 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm @@ -348,6 +348,8 @@ list(/datum/shop_sku/black_market/ansem, /obj/item/gun/ballistic/automatic/pistol/clandestine, 1, 2000, 1), list(/datum/shop_sku/black_market/ansem_sc, /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher, 1, 2800, 1), list(/datum/shop_sku/black_market/lethal_cycler, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, 1, 2400, 1), + list(/datum/shop_sku/black_market/rattlesnake, /obj/item/gun/ballistic/automatic/pistol/rattlesnake, 1, 4800, 3), + list(/datum/shop_sku/black_market/resolution, /obj/item/gun/ballistic/automatic/resolution, 1, 4200, 2), list(/datum/shop_sku/black_market/rotating/mateba, /obj/item/gun/ballistic/revolver/mateba, 1, 3000, 1), ), /datum/outpost_shop/vendor/ripperdoc = list( @@ -405,6 +407,8 @@ /obj/item/gun/ballistic/automatic/pistol/clandestine, /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, + /obj/item/gun/ballistic/automatic/pistol/rattlesnake, + /obj/item/gun/ballistic/automatic/resolution, /obj/item/gun/ballistic/revolver/mateba, ) var/list/printed_gear = list( @@ -523,7 +527,7 @@ /datum/shop_sku/black_market/c20r, /datum/shop_sku/black_market/wt550, /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/black_market/rapier, /datum/shop_sku/black_market/zashch, /datum/shop_sku/black_market/nt_m96, /datum/shop_sku/black_market/battle_rifle, /datum/shop_sku/black_market/ansem, /datum/shop_sku/black_market/ansem_sc, - /datum/shop_sku/black_market/lethal_cycler, + /datum/shop_sku/black_market/lethal_cycler, /datum/shop_sku/black_market/rattlesnake, /datum/shop_sku/black_market/resolution, /datum/shop_sku/black_market/cutlass, /datum/shop_sku/black_market/rare/energy_sword, /datum/shop_sku/ripperdoc/monowire, /datum/shop_sku/ripperdoc/gorilla, /datum/shop_sku/ripperdoc/mantis, /datum/shop_sku/general/rotating/freight_crate, /datum/shop_sku/black_market/mystery_cache, /datum/shop_sku/black_market/mystery_cache_rare, diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_research.dm b/voidcrew/modules/unit_tests/voidcrew_zone_research.dm index 0fd5461a43f..12fad2608df 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_research.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_research.dm @@ -155,7 +155,7 @@ "nanite_remotes" = list("nanite_remote", "nanite_comm_remote"), "restricted_tech" = list("largecrossbow", "borg_syndicate_module"), "desynchronization" = list("desynchronizer"), - "automatic_ammo" = list("vc_strilka310_clip", "suppressor", "vc_ammo_harpoon"), + "automatic_ammo" = list("vc_strilka310_clip", "suppressor", "vc_ammo_harpoon", "vc_mag_m9mm_rattlesnake"), "specialist_ammo" = list("sec_Islug", "vc_ammo_smartgun", "c38_flare", "c38_flare_mag", "vc_ammo_m10mm_ap", "vc_ammo_m10mm_hp", "vc_ammo_m10mm_fire"), TECHWEB_NODE_ELECTRIC_WEAPONS = list("nuclear_gun"), TECHWEB_NODE_BEAM_WEAPONS = list("stunrevolver"), diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm index 825dffa816a..e61b5f906e9 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm @@ -36,6 +36,8 @@ /datum/shop_sku/black_market/ansem = 1, /datum/shop_sku/black_market/ansem_sc = 1, /datum/shop_sku/black_market/lethal_cycler = 1, + /datum/shop_sku/black_market/resolution = 1, + /datum/shop_sku/outfitter/resolution_mag = 1, /datum/shop_sku/outfitter/rare/ablative_vest = 1, /datum/shop_sku/skunk/rotating/thermal_glasses = 1, /datum/shop_sku/black_market/thermals = 1, @@ -88,6 +90,7 @@ /datum/shop_sku/ripperdoc/buster_rockets = 1, /datum/shop_sku/outfitter/ablative_bat = 2, /datum/shop_sku/black_market/ew_drive_lockout = 2, + /datum/shop_sku/black_market/rattlesnake = 2, /datum/shop_sku/black_market/ew_fire_control_freeze = 2, /datum/shop_sku/black_market/ew_shield_collapse = 2, /datum/shop_sku/black_market/ew_breaker_trip = 2, @@ -131,7 +134,7 @@ /datum/unit_test/voidcrew_zone_shops/catalog/Run() var/list/expected = expected_tags() - TEST_ASSERT_EQUAL(length(expected), 101, "The retained permission catalog is incomplete") + TEST_ASSERT_EQUAL(length(expected), 104, "The retained permission catalog is incomplete") var/datum/voidcrew_crew_record/crew = allocate(/datum/voidcrew_crew_record) for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku)) var/permission = expected[sku_type] || 0 From 5f1916ac284aae43837b2796bc0d66f79b21c96a Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:07:07 -0800 Subject: [PATCH 196/472] Add the Heater PDW and the Scorcher from Shiptest Two 5.7x39mm PDWs ported from Shiptest (CC BY-SA 3.0 art, credited in voidcrew/modules/shiptest_guns/scorcher/attribution.txt), with a new 5.7x39mm round and 30-round magazine. The Heater is a compact automatic PDW, rated yellow. The Scorcher is a stocked Heater with a built-in flamethrower: right-click sprays tg's flamethrower plume from a small tank of welding fuel, refilled from any container or tank of fuel. The Undertow Exchange sells the Heater (yellow) and the Scorcher (advanced), Quartermain Depot sells the magazine (yellow), and both guns join the plunder loot pools with a spare magazine. Rated in the gear catalog, with unit tests for the guns, the flamethrower and their routes. --- tgstation.dme | 2 + tools/gear_catalog/ratings/ammo.json | 20 +++ tools/gear_catalog/ratings/guns.json | 36 ++++- voidcrew/modules/loot/loot_guns.dm | 2 + voidcrew/modules/loot/zone_loot.dm | 2 + .../shiptest_guns/scorcher/ammo_57x39.dm | 35 +++++ .../shiptest_guns/scorcher/attribution.txt | 25 +++ .../scorcher/icons/magazines.dmi | Bin 0 -> 676 bytes .../shiptest_guns/scorcher/icons/scorcher.dmi | Bin 0 -> 1233 bytes .../scorcher/icons/scorcher_lefthand.dmi | Bin 0 -> 1508 bytes .../scorcher/icons/scorcher_onmob.dmi | Bin 0 -> 1526 bytes .../scorcher/icons/scorcher_righthand.dmi | Bin 0 -> 1523 bytes .../shiptest_guns/scorcher/scorcher.dm | 143 ++++++++++++++++++ .../trade/shop_catalog_black_market.dm | 28 ++++ .../modules/trade/shop_catalog_outfitter.dm | 11 ++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + voidcrew/modules/unit_tests/voidcrew_loot.dm | 4 +- .../unit_tests/voidcrew_shiptest_scorcher.dm | 85 +++++++++++ .../unit_tests/voidcrew_shop_catalog.dm | 6 +- .../modules/unit_tests/voidcrew_zone_shops.dm | 5 +- 20 files changed, 394 insertions(+), 11 deletions(-) create mode 100644 voidcrew/modules/shiptest_guns/scorcher/ammo_57x39.dm create mode 100644 voidcrew/modules/shiptest_guns/scorcher/attribution.txt create mode 100644 voidcrew/modules/shiptest_guns/scorcher/icons/magazines.dmi create mode 100644 voidcrew/modules/shiptest_guns/scorcher/icons/scorcher.dmi create mode 100644 voidcrew/modules/shiptest_guns/scorcher/icons/scorcher_lefthand.dmi create mode 100644 voidcrew/modules/shiptest_guns/scorcher/icons/scorcher_onmob.dmi create mode 100644 voidcrew/modules/shiptest_guns/scorcher/icons/scorcher_righthand.dmi create mode 100644 voidcrew/modules/shiptest_guns/scorcher/scorcher.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm diff --git a/tgstation.dme b/tgstation.dme index dc351e3bfce..e83fe73d343 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7645,6 +7645,8 @@ #include "voidcrew\modules\ship_upgrades\ships\squab.dm" #include "voidcrew\modules\ship_upgrades\ships\test_modular.dm" #include "voidcrew\modules\ship_upgrades\workshop\bogatyr.dm" +#include "voidcrew\modules\shiptest_guns\scorcher\ammo_57x39.dm" +#include "voidcrew\modules\shiptest_guns\scorcher\scorcher.dm" #include "voidcrew\modules\shuttle\boards.dm" #include "voidcrew\modules\shuttle\design.dm" #include "voidcrew\modules\shuttle\emergency.dm" diff --git a/tools/gear_catalog/ratings/ammo.json b/tools/gear_catalog/ratings/ammo.json index 0bd5c74fef8..ea3d6c77a9b 100644 --- a/tools/gear_catalog/ratings/ammo.json +++ b/tools/gear_catalog/ratings/ammo.json @@ -8,6 +8,7 @@ ".45 loads", ".160 Smart loads", "4.6x30mm loads", + "5.7x39mm loads", ".310 rifle loads", ".223 rifle loads", "7mm machine gun loads", @@ -237,6 +238,15 @@ "reason": "Reload for the Popup Ronin arm, 20 rounds of 20 damage. Follows the gun; fits nothing else.", "tier": "yellow" }, + "/obj/item/ammo_box/magazine/heater": { + "confidence": "high", + "group": "5.7x39mm loads", + "hash": "", + "name": "Heater magazine (5.7x39mm)", + "rank": 1, + "reason": "Plain 5.7x39mm rounds, 30 to a magazine, 20 damage with a little armor piercing. Follows the Heater.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/internal/boltaction/jezail": { "confidence": "high", "group": "magazines built into a gun", @@ -1327,6 +1337,15 @@ "reason": "15 damage that cancels a vest. A little better than plain rounds through armor, worse on bare skin.", "tier": "yellow" }, + "/obj/item/ammo_casing/c57x39mm": { + "confidence": "high", + "group": "5.7x39mm loads", + "hash": "", + "name": "5.7x39mm bullet casing", + "rank": 2, + "reason": "The plain Heater round. Follows the gun.", + "tier": "yellow" + }, "/obj/item/ammo_casing/c9mm": { "confidence": "medium", "group": "9mm loads", @@ -2292,6 +2311,7 @@ "/obj/item/ammo_box": "abstract", "/obj/item/ammo_box/a40mm/rubber": "no route", "/obj/item/ammo_box/magazine": "abstract", + "/obj/item/ammo_box/magazine/heater/empty": "no route", "/obj/item/ammo_box/magazine/internal": "abstract", "/obj/item/ammo_box/magazine/internal/boltaction": "abstract", "/obj/item/ammo_box/magazine/internal/boltaction/harpoon": "no route", diff --git a/tools/gear_catalog/ratings/guns.json b/tools/gear_catalog/ratings/guns.json index 1ab21b5d6dc..ad48ebd0586 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -110,7 +110,7 @@ "group": "smgs", "hash": "d9b40f6fa856925a", "name": "C-20r SMG", - "rank": 4, + "rank": 6, "reason": "Puts a vested man down in about a second, with 24 rounds, from one hand.", "score": 79, "tier": "yellow" @@ -120,11 +120,31 @@ "group": "smgs", "hash": "bb032c110b509c59", "name": "Popup Ronin machine-pistol", - "rank": 6, + "rank": 8, "reason": "20 damage at 3.3 a second, WT-550 numbers with some spread. Lives in the arm, so it can't be disarmed, and reloads are hard to get.", "score": 47, "tier": "yellow" }, + "/obj/item/gun/ballistic/automatic/heater": { + "confidence": "medium", + "group": "smgs", + "hash": "", + "name": "Heater", + "rank": 3, + "reason": "20 damage 5.7mm rounds at 6.7 a second from a 30 round magazine, with a little spread. Level with the Saber, and it fits in a bag.", + "score": 106, + "tier": "yellow" + }, + "/obj/item/gun/ballistic/automatic/heater/scorcher": { + "confidence": "low", + "group": "smgs", + "hash": "", + "name": "Scorcher", + "rank": 2, + "reason": "A Heater (20 damage 5.7mm at 6.7 a second, 30 to a magazine) with a flamethrower under it: a 5-tile plume that burns and sets people alight every 2 seconds, eight sprays to a tank of welding fuel. The flamer alone is the MOD module's yellow, together they edge over the red line.", + "score": 115, + "tier": "red" + }, "/obj/item/gun/ballistic/automatic/l6_saw/toy/unrestricted": { "confidence": "high", "group": "toys and novelties", @@ -179,7 +199,7 @@ "group": "smgs", "hash": "57d5080462a9a158", "name": "Type U3 Uzi", - "rank": 5, + "rank": 7, "reason": "Fires the C-20r's bullet at a lower rate, with 32 rounds. Solid, not dangerous.", "score": 70, "tier": "yellow" @@ -199,7 +219,7 @@ "group": "smgs", "hash": "517391feb1e23558", "name": "Stechkin APS machine pistol", - "rank": 3, + "rank": 5, "reason": "A burst pistol firing the C-20r's bullets. Its 15 rounds go in 3 s.", "score": 105, "tier": "yellow" @@ -364,7 +384,7 @@ "group": "smgs", "hash": "3a82e5bc193f1d07", "name": "Nanotrasen Saber SMG", - "rank": 2, + "rank": 4, "reason": "A hard-hitting automatic with 21 rounds that empties in 4 s.", "score": 105, "tier": "yellow" @@ -374,7 +394,7 @@ "group": "smgs", "hash": "", "name": "M-94 'Rapier' submachinegun", - "rank": 7, + "rank": 9, "reason": "Fires 16 damage bullets at 3.3 a second from a 30 round magazine, a WT-550 with a fifth less damage. It scores just under the yellow line, and it takes a suppressor.", "score": 37, "tier": "green" @@ -384,7 +404,7 @@ "group": "smgs", "hash": "df8d939590e38d5f", "name": "Abielle Smart-SMG", - "rank": 8, + "rank": 10, "reason": "Fires 10 damage homing rounds. Its spread of 40 is the drawback the homing fixes. Upstream tg prices it as junk.", "score": 32, "score_inputs": { @@ -431,7 +451,7 @@ "group": "smgs", "hash": "2ea01a015382d859", "name": "WT-550 Autorifle", - "rank": 6, + "rank": 8, "reason": "Fires 20 damage bullets at 3.3 a second, about what a laser gun does, yet it's sold as a top yellow gun.", "score": 47, "tier": "yellow" diff --git a/voidcrew/modules/loot/loot_guns.dm b/voidcrew/modules/loot/loot_guns.dm index e7c2cacf9c7..53024f0c906 100644 --- a/voidcrew/modules/loot/loot_guns.dm +++ b/voidcrew/modules/loot/loot_guns.dm @@ -89,11 +89,13 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( LOOT_WEAPON_YELLOW = list( /obj/item/gun/ballistic/revolver = 10, /obj/item/gun/ballistic/rifle/boltaction/harpoon = 10, + /obj/item/gun/ballistic/automatic/heater = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/shotgun/bulldog/unrestricted = 10, /obj/item/gun/energy/laser/hellgun = 10, /obj/item/gun/ballistic/automatic/tommygun = 10, + /obj/item/gun/ballistic/automatic/heater/scorcher = 10, ), ), LOOT_GUN_POOL_FREIGHT = list( diff --git a/voidcrew/modules/loot/zone_loot.dm b/voidcrew/modules/loot/zone_loot.dm index 7c5ac774346..d79349a6005 100644 --- a/voidcrew/modules/loot/zone_loot.dm +++ b/voidcrew/modules/loot/zone_loot.dm @@ -61,6 +61,8 @@ GLOBAL_LIST_INIT(loot_gun_spare_ammo, list( /obj/item/gun/ballistic/automatic/battle_rifle = /obj/item/ammo_box/magazine/m38, /obj/item/gun/ballistic/automatic/c20r/unrestricted = /obj/item/ammo_box/magazine/smgm45, /obj/item/gun/ballistic/automatic/gyropistol = /obj/item/ammo_box/magazine/m75, + /obj/item/gun/ballistic/automatic/heater = /obj/item/ammo_box/magazine/heater, + /obj/item/gun/ballistic/automatic/heater/scorcher = /obj/item/ammo_box/magazine/heater, /obj/item/gun/ballistic/automatic/l6_saw/unrestricted = /obj/item/ammo_box/magazine/m7mm, /obj/item/gun/ballistic/automatic/m90 = /obj/item/ammo_box/magazine/m223, /obj/item/gun/ballistic/automatic/mini_uzi = /obj/item/ammo_box/magazine/uzim9mm, diff --git a/voidcrew/modules/shiptest_guns/scorcher/ammo_57x39.dm b/voidcrew/modules/shiptest_guns/scorcher/ammo_57x39.dm new file mode 100644 index 00000000000..baa0f66b00d --- /dev/null +++ b/voidcrew/modules/shiptest_guns/scorcher/ammo_57x39.dm @@ -0,0 +1,35 @@ +// 5.7x39mm, the Heater's round, ported from Shiptest (shiptest-ss13/Shiptest). +// Shiptest's numbers converted to ours: 20 damage, its armor piercing 10 as our 12. + +#ifndef CALIBER_57X39MM +#define CALIBER_57X39MM "5.7x39mm" +#endif + +/obj/projectile/bullet/c57x39mm + name = "5.7x39mm bullet" + damage = 20 + armour_penetration = 12 + wound_bonus = -5 + exposed_wound_bonus = 5 + embed_falloff_tile = -4 + +/obj/item/ammo_casing/c57x39mm + name = "5.7x39mm bullet casing" + desc = "A 5.7x39mm bullet casing." + caliber = CALIBER_57X39MM + projectile_type = /obj/projectile/bullet/c57x39mm + +/obj/item/ammo_box/magazine/heater + name = "\improper Heater magazine (5.7x39mm)" + desc = "A 30-round 5.7x39mm magazine for the Heater." + icon = 'voidcrew/modules/shiptest_guns/scorcher/icons/magazines.dmi' + icon_state = "heater_mag-full" + base_icon_state = "heater_mag" + multiple_sprite_use_base = TRUE + ammo_type = /obj/item/ammo_casing/c57x39mm + caliber = CALIBER_57X39MM + max_ammo = 30 + multiple_sprites = AMMO_BOX_FULL_EMPTY + +/obj/item/ammo_box/magazine/heater/empty + start_empty = TRUE diff --git a/voidcrew/modules/shiptest_guns/scorcher/attribution.txt b/voidcrew/modules/shiptest_guns/scorcher/attribution.txt new file mode 100644 index 00000000000..262aa02e2d0 --- /dev/null +++ b/voidcrew/modules/shiptest_guns/scorcher/attribution.txt @@ -0,0 +1,25 @@ +The art in this module comes from Shiptest (https://github.com/shiptest-ss13/Shiptest), taken on +2026-11-08 at commit 000f892ae9. Shiptest licenses its assets CC BY-SA 3.0 +(https://creativecommons.org/licenses/by-sa/3.0/) unless a file says otherwise, and none of these +files says otherwise; they are used here under that licence. The Heater and Scorcher sprites were +added by thearbiber in Shiptest PR #6745 ("New frontie/indie PDW: Heater and Scorcher"). Each DMI +holds only the states these guns use, copied pixel for pixel, apart from the renames listed. + +Icons (Shiptest paths) +icons/scorcher.dmi - states heater and scorcher from + icons/obj/guns/manufacturer/frontier_import/48x32.dmi, with + heater_mag_heater_mag renamed heater_mag and scorcher_mag_heater_mag + renamed scorcher_mag, the overlay names our gun code draws. +icons/scorcher_lefthand.dmi - states heater and scorcher from + icons/obj/guns/manufacturer/frontier_import/lefthand.dmi. +icons/scorcher_righthand.dmi - states heater and scorcher from + icons/obj/guns/manufacturer/frontier_import/righthand.dmi. +icons/scorcher_onmob.dmi - states heater and scorcher from + icons/obj/guns/manufacturer/frontier_import/onmob.dmi. +icons/magazines.dmi - states heater_mag-1 and heater_mag-0 from icons/obj/ammunition/ammo.dmi, + renamed heater_mag-full and heater_mag-empty. + +Sounds +None copied. Shiptest's Heater fire and reload sounds (sound/weapons/gun/rifle/skm_smg.ogg, +sound/weapons/gun/smg/cm5_reload.ogg, cm5_unload.ogg) have no recorded source, so the guns use +/tg/station's own SMG and MOD flamethrower sounds instead. diff --git a/voidcrew/modules/shiptest_guns/scorcher/icons/magazines.dmi b/voidcrew/modules/shiptest_guns/scorcher/icons/magazines.dmi new file mode 100644 index 0000000000000000000000000000000000000000..33ea57607cfc84519653e4dd54476882dfc3bee6 GIT binary patch literal 676 zcmV;V0$crwP)V=-0C=2JR&a84_w-Y6@%7{?OD!tS%+FJ>RWQ*r;NmRLOex6#a*U0*I5Sc+ z(=$pSoZ^zil2jm5DI*ogD2mTbOxH~-&B;;X;!MdbDhA3Ja&e{=CFZ6=*x1yk<`$Gx z;?kh3;OgfBb`byvl`Q|jxc|BU00HDlL_t(|ob8!QOT$1A$0tKhQg60eAJ9~fQt*Wm z5xj|~g4QA)`aS#vdi3H=`~ac{KZknhQA)h1(N^%#&}(EDwxnXqcAZJE@eg70nDn=g znMt~_AcRy4A>~3yxe!t=+vKLhqguF(pHQ1a7_00<=lTYwPHrt2^$6Hl-wMT6SX`3F zqVvkW9hd)PIkj}20;1ExZkPAE=dLc!r453%w3*B}cwQs6C@2;eLJGdE>sXj~q`f>l zmG+=o>A8M?_W&L=fLpoTr0n=^b%%s+>o|E&k8*Q60l4m_JoobK!^~{HyB|lVCEEp3 zKI%HEjw|{8YY)8D`Y>)pl6=&068R;73aae)eJ*kGaTw=8qpXS%r#Scs1d1aHxcCSu z7yjnvX!R9&s-DepiGq(N;g-)&clTG05L-p90i; z%jDaj2rW@)q78_?i8Y(=tgFM6e9LqIqQ`MBiBs~e|F6T4ZhZkhWn2>gJBsZ90000< KMNUMnLSTZ3@IS8r literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/scorcher/icons/scorcher.dmi b/voidcrew/modules/shiptest_guns/scorcher/icons/scorcher.dmi new file mode 100644 index 0000000000000000000000000000000000000000..35f7f82b5cc710aef13a430a6a38c98160598385 GIT binary patch literal 1233 zcmV;?1TOoDP)V=-0C=2JR&a84_w-Y6@%7{?OD!tS%+FJ>RWQ*r;NmRLOex6#aV)qvGg33t zGfIFQVWzfMhMJT5imXs5A5&nC++r|*QJth|K5YR*_=(bpKm@3 zYDa)>fTA%N91XW^M6N8aCecRNdH=4S1kBIRk9;|Ip*8UYhu`|D*V{|B8;vum@62=d z``<@?-#Uplia0m|j-fAF94k8lIT6D6`orfjm`)6~Vfd$8Z`GBne~k$&-MX2Ofb&FA zG(6sJw}<;t6easagxMY+|4@}5gtNRK-fgx-N}lK-z?kIK1Q^|n007ZM1W}(VBRJbU z7whSR$UsD3+iU?!4|y>z0st~J#tV2n?nQ5_JT8n>LFS-n!p!rOB5>SHVPsI-@62=T zRM^N)1OPNHmQ(*ilY9u^HcvQqff0TVpMgIq4ZL+2nTXnt&2~M85nb zsez*?5CSZqG{ia)t1F_D@y}Npw!VxL_=BV%W(Wby5CWJX1TaGgV1^LD3?YCSLI5*_ z0A>gQ%y51eB-#PScR`{ZD2V{9@xl7dEOA6x`hkIH2UKvWeL22!_qO`-`RiB$%oWt4 z9VmDVz8>UBto@USw=k&d3)++(X z-vA!$w^FLJnC^sQ+l$3(2UsE2U`gNui;c1lV9NK~PwT;A4^y_Dp!qcQ3aDEq5wss&3bUkWuAHD_L zWoQSS3GksEu%W7Id8gOWlwNf2$ET{s$wHA%=R|;L2a4AYI1wP)f!V^f1I}YWl_o*| vgLXjvl4RBn2m#F60nrCAYX?Lhpm^~IE*#yoX4S7Q00000NkvXXu0mjfKnFA{ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/scorcher/icons/scorcher_lefthand.dmi b/voidcrew/modules/shiptest_guns/scorcher/icons/scorcher_lefthand.dmi new file mode 100644 index 0000000000000000000000000000000000000000..eceb2cd78184a0e1a6ad06da38a02f0348010a79 GIT binary patch literal 1508 zcmZ`(doxDTc@|USRUSjcBJXG8F-W9U?WD2Fcx8+XCMWW0J(9{`DlK`0 z%wVmd5R=zsoLv8>7HXrGY35(>dBQYynrl#`=&ED=l zWR;h0KYW^@-RGh~1H#hM>y6{8*YS4GvE~`ktX&allW)AOK}~DaTH&SZpCJzgGUTwM&~-y2M>QJv&LhdjPDM z_ONb3t#zVXzp;BVYX2lmF9s%mTNOdkdM|H*10Sfws+x@tz>E4%V0l$XQR;qaVw~ zyZ|^Hj^HI{#U~tt(d81Lxvr$EzDn`(ElQw=4o%l(3!N8lM(z0aDLpzVXX{?o)O2ut zqd>CKUj=PiQoU*7v%P5;ZHD8%ONfRDOs1zSV#a~niDxdg)>~Ejmn)qn1ANA{O(eczI<$K}; zN`w}!0TZ_)RA>>a!Ibl?*iaIm6#ep9HY9g;kU=M9QX8s2FfX`WWZ|CIM|&a=XBCa@ z+vi-fKAZgtvo)NVK&IrCb1L}1m1kFJs8FKS2BEigrdn9v&S>RZb;d*OHV>8EH1pZXUyWk=9B% zK_Wql6w2Ir^yzc}Tsx%iPd~>>7ZQDlx+MXH5>sD{4h=*i(E>I*=^5kR__L8%IZM`{LQBc;(D}o> zPp(DJJci{ZbTO1EpDEht+!}p5Lb0ycVRuuPLeZNJ=8D)S+y-p9x>A%{TUgrpLOsq3 z>lqSHlWL93k9Ok9nF+v)`2tAyPVYs)p(7o!ANmqjP;k>6(M;`uwD_pK8g5t<7{K4U z!^&`FOrrDlwXUV%g5XscYhMqwM7x&pTqa9wC%>;DV(IF@$ncT{xPJ}fLs}(uxv_hK zQ2LOPf!tqaxqHQwlb@)gYtdyLMZe2jck}S#rIZra!^y}@?D~@Wwg?q9Unw>GAb<@m zRK<%`SV|MK7NOeyGj9IY7KJ?aI=4Wi$=l1iz_?5zc6|u-pO# zFk6lP72e<1r7eKXPYwkcvbZRb@5TAe2UJ@^w+)8o_Zr zbzyu;v6_%9cWdj$I&2eaV`0(k_eFwgys{ndYt#cG#Qn#V>_o(?w)UV*f^nf)QEt1o O4LI5&Y$~k+2!8?2PtSk= literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/scorcher/icons/scorcher_onmob.dmi b/voidcrew/modules/shiptest_guns/scorcher/icons/scorcher_onmob.dmi new file mode 100644 index 0000000000000000000000000000000000000000..ff1677439a5f22d96a54c335e018317e9e7a62d0 GIT binary patch literal 1526 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 SCORCHER_MAX_FUEL) + . += "The fuel tank is full." + else if(fuel >= SCORCHER_MAX_FUEL * 0.6) + . += "The fuel tank sloshes, most of the way full." + else if(fuel >= SCORCHER_MAX_FUEL * 0.3) + . += "The fuel tank sloshes, about half full." + else if(fuel >= SCORCHER_FUEL_PER_SPRAY) + . += "The fuel tank sloshes faintly, nearly dry." + else + . += "The fuel tank is dry." + +// A beaker, jerrycan or other open container of welding fuel used on the gun. +/obj/item/gun/ballistic/automatic/heater/scorcher/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(is_reagent_container(tool) && tool.is_drainable()) + draw_fuel(tool, user) + return ITEM_INTERACT_SUCCESS + return ..() + +// The gun used on a fuel tank, or on a container sitting on a table. +/obj/item/gun/ballistic/automatic/heater/scorcher/interact_with_atom(atom/interacting_with, mob/living/user, list/modifiers) + if(!ismob(interacting_with) && interacting_with.is_drainable()) + draw_fuel(interacting_with, user) + return ITEM_INTERACT_SUCCESS + return ..() + +/obj/item/gun/ballistic/automatic/heater/scorcher/interact_with_atom_secondary(atom/interacting_with, mob/living/user, list/modifiers) + if(user.combat_mode && isliving(interacting_with)) + return ITEM_INTERACT_SKIP_TO_ATTACK // Gun bash, as on any gun + spray_fire(interacting_with, user) + return ITEM_INTERACT_SUCCESS + +/obj/item/gun/ballistic/automatic/heater/scorcher/ranged_interact_with_atom_secondary(atom/interacting_with, mob/living/user, list/modifiers) + spray_fire(interacting_with, user) + return ITEM_INTERACT_SUCCESS + +/// Pours welding fuel, and only welding fuel, from source into the tank. +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/draw_fuel(atom/source, mob/living/user) + var/space = SCORCHER_MAX_FUEL - fuel + if(space <= 0) + balloon_alert(user, "tank is full") + return + var/available = source.reagents?.get_reagent_amount(/datum/reagent/fuel) || 0 + if(available <= 0) + balloon_alert(user, "no fuel in it") + return + var/taken = source.reagents.remove_reagent(/datum/reagent/fuel, min(space, available)) + if(!taken) + return + fuel = min(fuel + taken, SCORCHER_MAX_FUEL) + playsound(src, 'sound/effects/refill.ogg', 40, TRUE) + balloon_alert(user, "tank refilled") + +/// Sprays a plume of burning fuel at target. +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/spray_fire(atom/target, mob/living/user) + var/turf/start = get_turf(user) + var/turf/target_turf = get_turf(target) + if(!isturf(user.loc) || isnull(target_turf) || target_turf == start) + return + if(HAS_TRAIT(user, TRAIT_PACIFISM)) + balloon_alert(user, "you don't want to hurt anyone!") + return + if(!can_trigger_gun(user)) + return + if(!COOLDOWN_FINISHED(src, spray_cooldown)) + return + if(fuel < SCORCHER_FUEL_PER_SPRAY) + balloon_alert(user, "it sputters") + playsound(src, 'sound/items/weapons/gun/general/dry_fire.ogg', 30, TRUE) + return + fuel -= SCORCHER_FUEL_PER_SPRAY + COOLDOWN_START(src, spray_cooldown, SCORCHER_SPRAY_COOLDOWN) + user.changeNext_move(CLICK_CD_RANGE) + var/obj/projectile/flame = new /obj/projectile/bullet/incendiary/fire/scorcher(start) + flame.aim_projectile(target, user) + flame.firer = user + flame.fired_from = src + playsound(src, 'sound/items/modsuit/flamethrower.ogg', 75, TRUE) + log_combat(user, target, "sprayed fire at", src) + INVOKE_ASYNC(flame, TYPE_PROC_REF(/obj/projectile, fire)) + +/// The Scorcher's plume: tg's flamethrower plume, a tile shorter. +/obj/projectile/bullet/incendiary/fire/scorcher + range = 5 + +#undef SCORCHER_MAX_FUEL +#undef SCORCHER_FUEL_PER_SPRAY +#undef SCORCHER_SPRAY_COOLDOWN diff --git a/voidcrew/modules/trade/shop_catalog_black_market.dm b/voidcrew/modules/trade/shop_catalog_black_market.dm index 624c57f31dc..f0a2ae51e3e 100644 --- a/voidcrew/modules/trade/shop_catalog_black_market.dm +++ b/voidcrew/modules/trade/shop_catalog_black_market.dm @@ -70,6 +70,8 @@ /datum/shop_sku/black_market/ansem, /datum/shop_sku/black_market/ansem_sc, /datum/shop_sku/black_market/lethal_cycler, + /datum/shop_sku/black_market/heater, + /datum/shop_sku/black_market/scorcher, /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/black_market/pistol_mag, /datum/shop_sku/black_market/speedloader, @@ -971,6 +973,32 @@ fixed_price = TRUE contract_reward = FALSE +/datum/shop_sku/black_market/heater + zone_permission = ZONE_PERMISSION_YELLOW + category = "Weapons" + desc = "A compact frontier PDW. Chambered in 5.7x39mm." + item_path = /obj/item/gun/ballistic/automatic/heater + price_credits = 3000 + price_vouchers = 2 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/black_market/scorcher + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Weapons" + desc = "A Heater with a flamethrower bolted underneath. Chambered in 5.7x39mm." + item_path = /obj/item/gun/ballistic/automatic/heater/scorcher + price_credits = 4000 + price_vouchers = 3 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + /// Counter-bought caches retain themed supplies, with a single freight weapon roll. /// Kept separate from guarded caches so buying a crate cannot buy advanced guns. /obj/structure/closet/crate/zone_loot/syndicate/trader diff --git a/voidcrew/modules/trade/shop_catalog_outfitter.dm b/voidcrew/modules/trade/shop_catalog_outfitter.dm index d525a7f22c2..31de9024bb8 100644 --- a/voidcrew/modules/trade/shop_catalog_outfitter.dm +++ b/voidcrew/modules/trade/shop_catalog_outfitter.dm @@ -68,6 +68,7 @@ /datum/shop_sku/outfitter/smg_mag, /datum/shop_sku/outfitter/wt550_mag, /datum/shop_sku/outfitter/rapier_mag, + /datum/shop_sku/outfitter/heater_mag, /datum/shop_sku/outfitter/zashch_mag, /datum/shop_sku/outfitter/firing_pin, /datum/shop_sku/outfitter/riot_shotgun, @@ -346,6 +347,16 @@ stock_min = 10 stock_max = 20 +/datum/shop_sku/outfitter/heater_mag + zone_permission = ZONE_PERMISSION_YELLOW + category = "Firearms & Ammo" + name = "heater magazine" + desc = "A loaded 5.7x39mm magazine for a Heater or Scorcher." + item_path = /obj/item/ammo_box/magazine/heater + price_credits = 200 + stock_min = 10 + stock_max = 20 + /datum/shop_sku/outfitter/zashch_mag category = "Firearms & Ammo" name = "zashch magazine" diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 50dac2a5a37..595d8adc9b1 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -217,6 +217,7 @@ #include "voidcrew_ship_silicons.dm" #include "voidcrew_ship_starting_guns.dm" #include "voidcrew_ship_turret_targeting.dm" +#include "voidcrew_shiptest_scorcher.dm" #include "voidcrew_shop_catalog.dm" #include "voidcrew_silicon_ship_systems.dm" #include "voidcrew_simple_mob_ai.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_loot.dm b/voidcrew/modules/unit_tests/voidcrew_loot.dm index bc6763d74fb..550b6d8df7e 100644 --- a/voidcrew/modules/unit_tests/voidcrew_loot.dm +++ b/voidcrew/modules/unit_tests/voidcrew_loot.dm @@ -291,7 +291,7 @@ /// A working copy may be consumed without damaging another crate's singleton tables. /datum/unit_test/voidcrew_cache_weapon_pools/Run() - var/list/main_counts = list("armory" = list(3, 4, 5), "syndicate" = list(2, 3, 3), "plunder" = list(3, 2, 3), "freight" = list(4, 2)) + var/list/main_counts = list("armory" = list(3, 4, 5), "syndicate" = list(2, 3, 3), "plunder" = list(3, 3, 4), "freight" = list(4, 2)) for(var/family in main_counts) var/list/counts = main_counts[family] var/list/classes = GLOB.loot_gun_pools[family] @@ -742,6 +742,7 @@ /obj/item/fireaxe/boneaxe, /obj/item/gun/ballistic/automatic/ar, /obj/item/gun/ballistic/automatic/c20r/unrestricted, + /obj/item/gun/ballistic/automatic/heater, /obj/item/gun/ballistic/automatic/mini_uzi, /obj/item/gun/ballistic/automatic/pistol/aps, /obj/item/gun/ballistic/automatic/pistol/clandestine, @@ -820,6 +821,7 @@ /obj/item/cyberware_pair_case/gorilla_arms, /obj/item/cyberware_pair_case/mantis_blades, /obj/item/energy_katana, + /obj/item/gun/ballistic/automatic/heater/scorcher, /obj/item/gun/ballistic/automatic/l6_saw/unrestricted, /obj/item/gun/ballistic/automatic/m90/unrestricted, /obj/item/gun/ballistic/automatic/tommygun, diff --git a/voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm b/voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm new file mode 100644 index 00000000000..b3dc9c0a04d --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm @@ -0,0 +1,85 @@ +// voidcrew/modules/shiptest_guns/scorcher: the Heater, the Scorcher and 5.7x39mm. +// Voidcrew defines are not visible here, so values are literals. + +/// Every icon state these guns draw exists: a missing overlay or magazine state is invisible, and nothing reports it. +/datum/unit_test/voidcrew_scorcher_icons +/datum/unit_test/voidcrew_scorcher_icons/Run() + for(var/gun_path in list(/obj/item/gun/ballistic/automatic/heater, /obj/item/gun/ballistic/automatic/heater/scorcher)) + var/obj/item/gun/ballistic/gun = allocate(gun_path) + var/list/known = icon_states(gun.icon) + TEST_ASSERT(gun.icon_state in known, "[gun_path] has no icon state [gun.icon_state]") + TEST_ASSERT("[gun.icon_state]_mag" in known, "[gun_path] has no magazine overlay") + TEST_ASSERT(gun.inhand_icon_state in icon_states(gun.lefthand_file), "[gun_path] has no left hand sprite") + TEST_ASSERT(gun.inhand_icon_state in icon_states(gun.righthand_file), "[gun_path] has no right hand sprite") + TEST_ASSERT(gun.worn_icon_state in icon_states(gun.worn_icon), "[gun_path] has no worn sprite") + var/obj/item/ammo_box/magazine/heater/magazine = allocate(/obj/item/ammo_box/magazine/heater) + var/list/known = icon_states(magazine.icon) + TEST_ASSERT("heater_mag-full" in known, "The Heater magazine has no full state") + TEST_ASSERT("heater_mag-empty" in known, "The Heater magazine has no empty state") + TEST_ASSERT_EQUAL(magazine.ammo_count(), 30, "The Heater magazine does not start full") + TEST_ASSERT_EQUAL(magazine.icon_state, "heater_mag-full", "A full Heater magazine draws the wrong state") + var/obj/item/ammo_box/magazine/heater/empty/spent = allocate(/obj/item/ammo_box/magazine/heater/empty) + TEST_ASSERT_EQUAL(spent.ammo_count(), 0, "The empty Heater magazine is not empty") + TEST_ASSERT_EQUAL(spent.icon_state, "heater_mag-empty", "An empty Heater magazine draws the wrong state") + +/// Both guns are SMGs for feel purposes, fire automatically, take the 5.7 magazine and fire 5.7 rounds. +/datum/unit_test/voidcrew_scorcher_setup +/datum/unit_test/voidcrew_scorcher_setup/Run() + for(var/gun_path in list(/obj/item/gun/ballistic/automatic/heater, /obj/item/gun/ballistic/automatic/heater/scorcher)) + var/obj/item/gun/ballistic/automatic/gun = allocate(gun_path) + gun.gun_feel_resolve() + TEST_ASSERT_EQUAL(gun.gun_feel_class, "smg", "[gun_path] should kick like an SMG") + TEST_ASSERT_EQUAL(gun.magazine.type, /obj/item/ammo_box/magazine/heater, "[gun_path] spawned with the wrong magazine") + TEST_ASSERT_EQUAL(gun.magazine.max_ammo, 30, "[gun_path] holds the wrong number of rounds") + TEST_ASSERT_EQUAL(gun.chambered?.type, /obj/item/ammo_casing/c57x39mm, "[gun_path] fires the wrong round") + TEST_ASSERT_NOTNULL(gun.GetComponent(/datum/component/automatic_fire), "[gun_path] should fire automatically") + TEST_ASSERT(!length(gun.actions_types), "[gun_path] should have no action buttons") + var/obj/item/ammo_box/magazine/heater/spare = allocate(/obj/item/ammo_box/magazine/heater) + TEST_ASSERT(gun.magazine.caliber == spare.caliber, "A spare magazine does not fit [gun_path]") + var/obj/item/gun/ballistic/automatic/heater/heater = allocate(/obj/item/gun/ballistic/automatic/heater) + var/obj/item/gun/ballistic/automatic/heater/scorcher/scorcher = allocate(/obj/item/gun/ballistic/automatic/heater/scorcher) + TEST_ASSERT(heater.w_class < scorcher.w_class, "The stocked Scorcher should be bigger than the Heater") + +/// The flamethrower burns fuel per spray, waits between sprays, and only takes welding fuel. +/datum/unit_test/voidcrew_scorcher_flamer +/datum/unit_test/voidcrew_scorcher_flamer/Run() + var/mob/living/carbon/human/consistent/shooter = allocate(/mob/living/carbon/human/consistent) + var/obj/item/gun/ballistic/automatic/heater/scorcher/scorcher = allocate(/obj/item/gun/ballistic/automatic/heater/scorcher) + TEST_ASSERT(shooter.put_in_hands(scorcher), "The shooter could not hold the Scorcher") + TEST_ASSERT_EQUAL(scorcher.fuel, 40, "The Scorcher should start with a full tank") + scorcher.spray_fire(run_loc_floor_top_right, shooter) + TEST_ASSERT_EQUAL(scorcher.fuel, 35, "A spray should burn five units of fuel") + scorcher.spray_fire(run_loc_floor_top_right, shooter) + TEST_ASSERT_EQUAL(scorcher.fuel, 35, "A second spray went off during the cooldown") + scorcher.fuel = 2 + COOLDOWN_RESET(scorcher, spray_cooldown) + scorcher.spray_fire(run_loc_floor_top_right, shooter) + TEST_ASSERT_EQUAL(scorcher.fuel, 2, "A nearly dry tank still sprayed") + + var/obj/item/reagent_containers/cup/beaker/large/beaker = allocate(/obj/item/reagent_containers/cup/beaker/large) + beaker.reagents.add_reagent(/datum/reagent/water, 30) + beaker.reagents.add_reagent(/datum/reagent/fuel/oil, 30) + scorcher.draw_fuel(beaker, shooter) + TEST_ASSERT_EQUAL(scorcher.fuel, 2, "The tank took something that isn't welding fuel") + beaker.reagents.add_reagent(/datum/reagent/fuel, 60) + scorcher.draw_fuel(beaker, shooter) + TEST_ASSERT_EQUAL(scorcher.fuel, 40, "The tank did not fill from a beaker of fuel") + TEST_ASSERT_EQUAL(beaker.reagents.get_reagent_amount(/datum/reagent/fuel), 22, "The tank took more fuel than it holds") + TEST_ASSERT_EQUAL(beaker.reagents.get_reagent_amount(/datum/reagent/water), 30, "Refuelling took water out of the beaker") + +/// Each gun can be bought at its tier and found in plunder, with a spare magazine, and its magazine is sold. +/datum/unit_test/voidcrew_scorcher_routes +/datum/unit_test/voidcrew_scorcher_routes/Run() + var/datum/outpost_shop/black_market/black_market = allocate(/datum/outpost_shop/black_market) + TEST_ASSERT(/datum/shop_sku/black_market/heater in black_market.sku_types, "The Undertow Exchange does not sell the Heater") + TEST_ASSERT(/datum/shop_sku/black_market/scorcher in black_market.sku_types, "The Undertow Exchange does not sell the Scorcher") + var/datum/shop_sku/heater_sku = allocate(/datum/shop_sku/black_market/heater) + var/datum/shop_sku/scorcher_sku = allocate(/datum/shop_sku/black_market/scorcher) + TEST_ASSERT_EQUAL(heater_sku.zone_permission, 1, "The Heater should need yellow standing") + TEST_ASSERT_EQUAL(scorcher_sku.zone_permission, 2, "The Scorcher should need advanced standing") + var/datum/outpost_shop/outfitter/outfitter = allocate(/datum/outpost_shop/outfitter) + TEST_ASSERT(/datum/shop_sku/outfitter/heater_mag in outfitter.sku_types, "Quartermain does not sell Heater magazines") + TEST_ASSERT(/obj/item/gun/ballistic/automatic/heater in GLOB.loot_gun_pools["plunder"]["2"], "The Heater is not yellow plunder") + TEST_ASSERT(/obj/item/gun/ballistic/automatic/heater/scorcher in GLOB.loot_gun_pools["plunder"]["3"], "The Scorcher is not red plunder") + for(var/gun_path in list(/obj/item/gun/ballistic/automatic/heater, /obj/item/gun/ballistic/automatic/heater/scorcher)) + TEST_ASSERT_EQUAL(GLOB.loot_gun_spare_ammo[gun_path], /obj/item/ammo_box/magazine/heater, "[gun_path] is found without a spare magazine") diff --git a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm index cbf95698768..474c9ee129e 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm @@ -348,6 +348,8 @@ list(/datum/shop_sku/black_market/ansem, /obj/item/gun/ballistic/automatic/pistol/clandestine, 1, 2000, 1), list(/datum/shop_sku/black_market/ansem_sc, /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher, 1, 2800, 1), list(/datum/shop_sku/black_market/lethal_cycler, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, 1, 2400, 1), + list(/datum/shop_sku/black_market/heater, /obj/item/gun/ballistic/automatic/heater, 1, 3000, 2), + list(/datum/shop_sku/black_market/scorcher, /obj/item/gun/ballistic/automatic/heater/scorcher, 1, 4000, 3), list(/datum/shop_sku/black_market/rotating/mateba, /obj/item/gun/ballistic/revolver/mateba, 1, 3000, 1), ), /datum/outpost_shop/vendor/ripperdoc = list( @@ -405,6 +407,8 @@ /obj/item/gun/ballistic/automatic/pistol/clandestine, /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, + /obj/item/gun/ballistic/automatic/heater, + /obj/item/gun/ballistic/automatic/heater/scorcher, /obj/item/gun/ballistic/revolver/mateba, ) var/list/printed_gear = list( @@ -523,7 +527,7 @@ /datum/shop_sku/black_market/c20r, /datum/shop_sku/black_market/wt550, /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/black_market/rapier, /datum/shop_sku/black_market/zashch, /datum/shop_sku/black_market/nt_m96, /datum/shop_sku/black_market/battle_rifle, /datum/shop_sku/black_market/ansem, /datum/shop_sku/black_market/ansem_sc, - /datum/shop_sku/black_market/lethal_cycler, + /datum/shop_sku/black_market/lethal_cycler, /datum/shop_sku/black_market/heater, /datum/shop_sku/black_market/scorcher, /datum/shop_sku/black_market/cutlass, /datum/shop_sku/black_market/rare/energy_sword, /datum/shop_sku/ripperdoc/monowire, /datum/shop_sku/ripperdoc/gorilla, /datum/shop_sku/ripperdoc/mantis, /datum/shop_sku/general/rotating/freight_crate, /datum/shop_sku/black_market/mystery_cache, /datum/shop_sku/black_market/mystery_cache_rare, diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm index 825dffa816a..8a66dc6a3f5 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm @@ -36,6 +36,7 @@ /datum/shop_sku/black_market/ansem = 1, /datum/shop_sku/black_market/ansem_sc = 1, /datum/shop_sku/black_market/lethal_cycler = 1, + /datum/shop_sku/black_market/heater = 1, /datum/shop_sku/outfitter/rare/ablative_vest = 1, /datum/shop_sku/skunk/rotating/thermal_glasses = 1, /datum/shop_sku/black_market/thermals = 1, @@ -63,6 +64,7 @@ /datum/shop_sku/outfitter/laser_gun = 1, /datum/shop_sku/outfitter/smg_mag = 1, /datum/shop_sku/outfitter/wt550_mag = 1, + /datum/shop_sku/outfitter/heater_mag = 1, /datum/shop_sku/outfitter/rifle_clip = 1, /datum/shop_sku/outfitter/bulletproof_vest = 1, /datum/shop_sku/outfitter/bulletproof_helmet = 1, @@ -87,6 +89,7 @@ /datum/shop_sku/ripperdoc/ronin_mag = 1, /datum/shop_sku/ripperdoc/buster_rockets = 1, /datum/shop_sku/outfitter/ablative_bat = 2, + /datum/shop_sku/black_market/scorcher = 2, /datum/shop_sku/black_market/ew_drive_lockout = 2, /datum/shop_sku/black_market/ew_fire_control_freeze = 2, /datum/shop_sku/black_market/ew_shield_collapse = 2, @@ -131,7 +134,7 @@ /datum/unit_test/voidcrew_zone_shops/catalog/Run() var/list/expected = expected_tags() - TEST_ASSERT_EQUAL(length(expected), 101, "The retained permission catalog is incomplete") + TEST_ASSERT_EQUAL(length(expected), 104, "The retained permission catalog is incomplete") var/datum/voidcrew_crew_record/crew = allocate(/datum/voidcrew_crew_record) for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku)) var/permission = expected[sku_type] || 0 From 6cbbf9ac54e8eafa34c08ccae98bf075f9261f99 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:07:38 -0800 Subject: [PATCH 197/472] Add the Model 23 Woodsman and SKM-24 rifles from Shiptest Two yellow-tier rifles ported from Shiptest (CC BY-SA 3.0 art; SKM sounds from TerraGov-Marine-Corps, one CC BY-NC-SA 3.0, credited in voidcrew/modules/shiptest_guns/rifles/attribution.txt). The Model 23 Woodsman is a semi-automatic hunting rifle with a built-in 2x right-click scope, firing new 8x50mmR rounds (50 damage, AP 30) from 5 round magazines; it takes a bayonet. The SKM-24 is an automatic rifle at 4 shots a second in new 7.62x40mm (24 damage, AP 22), with 20 and 40 round magazines and a 75 round drum; it takes a suppressor and a bayonet. Both guns are sold at the Undertow Exchange behind yellow standing and join the armory (Woodsman) and plunder (SKM) yellow cache pools with a spare magazine. The outfitter sells their magazines at yellow standing, the drum is sold only at advanced standing, and Automatic Ammunition research prints stripper clips of both calibers. Everything is rated in the gear catalog, and the shop, loot and research tests list the new entries. --- tgstation.dme | 3 + tools/gear_catalog/ratings/ammo.json | 80 +++++++++++ tools/gear_catalog/ratings/guns.json | 44 ++++-- voidcrew/modules/loot/loot_guns.dm | 2 + voidcrew/modules/loot/zone_loot.dm | 2 + voidcrew/modules/research/techweb_nodes.dm | 2 + .../shiptest_guns/rifles/attribution.txt | 46 ++++++ .../modules/shiptest_guns/rifles/designs.dm | 20 +++ .../shiptest_guns/rifles/icons/ammo.dmi | Bin 0 -> 3373 bytes .../rifles/icons/rifles_back.dmi | Bin 0 -> 2257 bytes .../rifles/icons/rifles_lefthand.dmi | Bin 0 -> 2235 bytes .../rifles/icons/rifles_righthand.dmi | Bin 0 -> 2300 bytes .../shiptest_guns/rifles/icons/skm.dmi | Bin 0 -> 1429 bytes .../shiptest_guns/rifles/icons/woodsman.dmi | Bin 0 -> 826 bytes voidcrew/modules/shiptest_guns/rifles/skm.dm | 105 ++++++++++++++ .../shiptest_guns/rifles/sound/skm.ogg | Bin 0 -> 24460 bytes .../shiptest_guns/rifles/sound/skm_reload.ogg | Bin 0 -> 17846 bytes .../shiptest_guns/rifles/sound/skm_unload.ogg | Bin 0 -> 19650 bytes .../modules/shiptest_guns/rifles/woodsman.dm | 80 +++++++++++ .../trade/shop_catalog_black_market.dm | 41 ++++++ .../modules/trade/shop_catalog_outfitter.dm | 33 +++++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + voidcrew/modules/unit_tests/voidcrew_loot.dm | 4 +- .../unit_tests/voidcrew_shiptest_rifles.dm | 133 ++++++++++++++++++ .../unit_tests/voidcrew_shop_catalog.dm | 6 +- .../unit_tests/voidcrew_zone_research.dm | 2 +- .../modules/unit_tests/voidcrew_zone_shops.dm | 8 +- 27 files changed, 597 insertions(+), 15 deletions(-) create mode 100644 voidcrew/modules/shiptest_guns/rifles/attribution.txt create mode 100644 voidcrew/modules/shiptest_guns/rifles/designs.dm create mode 100644 voidcrew/modules/shiptest_guns/rifles/icons/ammo.dmi create mode 100644 voidcrew/modules/shiptest_guns/rifles/icons/rifles_back.dmi create mode 100644 voidcrew/modules/shiptest_guns/rifles/icons/rifles_lefthand.dmi create mode 100644 voidcrew/modules/shiptest_guns/rifles/icons/rifles_righthand.dmi create mode 100644 voidcrew/modules/shiptest_guns/rifles/icons/skm.dmi create mode 100644 voidcrew/modules/shiptest_guns/rifles/icons/woodsman.dmi create mode 100644 voidcrew/modules/shiptest_guns/rifles/skm.dm create mode 100644 voidcrew/modules/shiptest_guns/rifles/sound/skm.ogg create mode 100644 voidcrew/modules/shiptest_guns/rifles/sound/skm_reload.ogg create mode 100644 voidcrew/modules/shiptest_guns/rifles/sound/skm_unload.ogg create mode 100644 voidcrew/modules/shiptest_guns/rifles/woodsman.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_shiptest_rifles.dm diff --git a/tgstation.dme b/tgstation.dme index dc351e3bfce..9697ffc8ec4 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7645,6 +7645,9 @@ #include "voidcrew\modules\ship_upgrades\ships\squab.dm" #include "voidcrew\modules\ship_upgrades\ships\test_modular.dm" #include "voidcrew\modules\ship_upgrades\workshop\bogatyr.dm" +#include "voidcrew\modules\shiptest_guns\rifles\designs.dm" +#include "voidcrew\modules\shiptest_guns\rifles\skm.dm" +#include "voidcrew\modules\shiptest_guns\rifles\woodsman.dm" #include "voidcrew\modules\shuttle\boards.dm" #include "voidcrew\modules\shuttle\design.dm" #include "voidcrew\modules\shuttle\emergency.dm" diff --git a/tools/gear_catalog/ratings/ammo.json b/tools/gear_catalog/ratings/ammo.json index 0bd5c74fef8..79b0d6b30eb 100644 --- a/tools/gear_catalog/ratings/ammo.json +++ b/tools/gear_catalog/ratings/ammo.json @@ -9,6 +9,8 @@ ".160 Smart loads", "4.6x30mm loads", ".310 rifle loads", + "8x50mmR loads", + "7.62x40mm loads", ".223 rifle loads", "7mm machine gun loads", ".50 sniper loads", @@ -70,6 +72,24 @@ "reason": "60 damage and a light blast with fire around the target. Hurts a room, cannot open a hull. Only the M-90 can fire it.", "tier": "yellow" }, + "/obj/item/ammo_box/a762x40": { + "confidence": "high", + "group": "7.62x40mm loads", + "hash": "", + "name": "stripper clip (7.62x40mm)", + "rank": 4, + "reason": "Five loose 7.62x40mm rounds to refill SKM magazines. Follows the gun.", + "tier": "yellow" + }, + "/obj/item/ammo_box/a8x50r": { + "confidence": "high", + "group": "8x50mmR loads", + "hash": "", + "name": "stripper clip (8x50mmR)", + "rank": 2, + "reason": "Five loose 8x50mmR rounds to refill Woodsman magazines. Follows the gun.", + "tier": "yellow" + }, "/obj/item/ammo_box/c10mm": { "confidence": "medium", "group": "10mm loads", @@ -579,6 +599,15 @@ "reason": "30 damage that ignores armor and flies through walls, in a 30 round magazine on a burst rifle. Far past the X-ray gun.", "tier": "red" }, + "/obj/item/ammo_box/magazine/m23": { + "confidence": "high", + "group": "8x50mmR loads", + "hash": "", + "name": "Model 23 magazine (8x50mmR)", + "rank": 1, + "reason": "Five 50 damage rounds that go through a vest. Follows the Woodsman.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/m38": { "confidence": "high", "group": ".38 loads", @@ -838,6 +867,33 @@ "reason": "Plain 4.6x30mm rounds, 30 to a magazine. Follows the gun.", "tier": "green" }, + "/obj/item/ammo_box/magazine/skm": { + "confidence": "high", + "group": "7.62x40mm loads", + "hash": "", + "name": "SKM magazine (7.62x40mm)", + "rank": 3, + "reason": "Plain SKM magazine, 20 rounds of 24 damage. Follows the gun.", + "tier": "yellow" + }, + "/obj/item/ammo_box/magazine/skm/drum": { + "confidence": "high", + "group": "7.62x40mm loads", + "hash": "", + "name": "SKM drum (7.62x40mm)", + "rank": 1, + "reason": "75 rounds of automatic fire without a reload, half again what the red L6 SAW holds. This is what makes the SKM red.", + "tier": "red" + }, + "/obj/item/ammo_box/magazine/skm/extended": { + "confidence": "high", + "group": "7.62x40mm loads", + "hash": "", + "name": "extended SKM magazine (7.62x40mm)", + "rank": 2, + "reason": "40 rounds. The SKM scores 108 with it, just under red.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/smartgun": { "confidence": "high", "group": ".160 Smart loads", @@ -1172,6 +1228,24 @@ "reason": "Plain 60 damage in a Desert Eagle magazine. Follows the yellow gun.", "tier": "yellow" }, + "/obj/item/ammo_casing/a762x40": { + "confidence": "high", + "group": "7.62x40mm loads", + "hash": "", + "name": "7.62x40mm bullet casing", + "rank": 5, + "reason": "One SKM round. Follows the gun.", + "tier": "yellow" + }, + "/obj/item/ammo_casing/a8x50r": { + "confidence": "high", + "group": "8x50mmR loads", + "hash": "", + "name": "8x50mmR bullet casing", + "rank": 3, + "reason": "One Woodsman round. Follows the gun.", + "tier": "yellow" + }, "/obj/item/ammo_casing/arrow": { "confidence": "high", "group": "arrows and crossbow bolts", @@ -2291,6 +2365,8 @@ "unrated": { "/obj/item/ammo_box": "abstract", "/obj/item/ammo_box/a40mm/rubber": "no route", + "/obj/item/ammo_box/a762x40/empty": "no route", + "/obj/item/ammo_box/a8x50r/empty": "no route", "/obj/item/ammo_box/magazine": "abstract", "/obj/item/ammo_box/magazine/internal": "abstract", "/obj/item/ammo_box/magazine/internal/boltaction": "abstract", @@ -2324,6 +2400,7 @@ "/obj/item/ammo_box/magazine/internal/shot/tube/slug": "no route", "/obj/item/ammo_box/magazine/internal/sks": "abstract", "/obj/item/ammo_box/magazine/internal/sks/empty": "no route", + "/obj/item/ammo_box/magazine/m23/empty": "no route", "/obj/item/ammo_box/magazine/m38/empty": "no route", "/obj/item/ammo_box/magazine/m75": "needs another system", "/obj/item/ammo_box/magazine/rapier/empty": "no route", @@ -2345,6 +2422,9 @@ "/obj/item/ammo_box/magazine/recharge/ctf/shotgun/green": "no route", "/obj/item/ammo_box/magazine/recharge/ctf/shotgun/red": "no route", "/obj/item/ammo_box/magazine/recharge/ctf/shotgun/yellow": "no route", + "/obj/item/ammo_box/magazine/skm/drum/empty": "no route", + "/obj/item/ammo_box/magazine/skm/empty": "no route", + "/obj/item/ammo_box/magazine/skm/extended/empty": "no route", "/obj/item/ammo_box/magazine/sniper_rounds/surplus": "no route", "/obj/item/ammo_box/magazine/toy": "abstract", "/obj/item/ammo_box/magazine/zashch/empty": "no route", diff --git a/tools/gear_catalog/ratings/guns.json b/tools/gear_catalog/ratings/guns.json index 1ab21b5d6dc..28c83560a36 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -79,7 +79,7 @@ "hash": "", "name": "NT BR-38 battle rifle", "note": "Right-click scopes in at 2x view range. After ten shots each shot has a 1 in 10 chance to wear the rifle down a stage, up to five; at the last stage it misfires three shots in four. A multitool (20 s) or a weapon recharger sets it right, and an EMP wears it down at once. An emag lifts its shots from 30 to 40 damage, and a fully worn emagged rifle blows up five seconds after the next trigger pull.", - "rank": 10, + "rank": 12, "reason": "Fires 30 damage .38 rounds from a 15 round magazine, with a 2x scope. It wears itself out after ten shots until someone recalibrates it, which keeps it just under the yellow line.", "score": 36, "tier": "green" @@ -379,6 +379,17 @@ "score": 37, "tier": "green" }, + "/obj/item/gun/ballistic/automatic/skm": { + "confidence": "medium", + "group": "rifles", + "hash": "", + "name": "SKM-24", + "note": "Fires automatically while the trigger is held. Takes a suppressor and a bayonet. Also takes a 40 round magazine and a 75 round drum.", + "rank": 6, + "reason": "Automatic at 4 shots a second, 24 damage rounds that mostly ignore a vest, from a 20 round magazine. The 40 round magazine lifts it to 108, still under red; the 75 round drum is what makes it red.", + "score": 86, + "tier": "yellow" + }, "/obj/item/gun/ballistic/automatic/smartgun": { "confidence": "medium", "group": "smgs", @@ -426,6 +437,17 @@ "score": 39, "tier": "green" }, + "/obj/item/gun/ballistic/automatic/woodsman": { + "confidence": "medium", + "group": "rifles", + "hash": "", + "name": "Model 23 Woodsman", + "note": "Right-click scopes in at 2x view range. Takes a bayonet.", + "rank": 7, + "reason": "Semi-automatic: one 50 damage round that goes through a vest every 0.75 s, from a 5 round magazine, with a 2x scope. Beats the Sakhno by not needing the bolt worked, and stays far below the automatic rifles.", + "score": 53, + "tier": "yellow" + }, "/obj/item/gun/ballistic/automatic/wt550": { "confidence": "high", "group": "smgs", @@ -442,7 +464,7 @@ "hash": "9b6b11da9005d066", "name": "bow", "note": "Needs no firing pin and starts empty: an arrow must be nocked by hand, and the bow drawn (use in hand) before it will fire, otherwise the arrow just falls to the floor. The arrow also drops out if the bow is dropped or moved out of the hands (e.g. onto the back).", - "rank": 12, + "rank": 14, "reason": "Does 50 damage per arrow, drawn by hand.", "score": 7, "score_inputs": { @@ -459,7 +481,7 @@ "hash": "4231c1aae8664849", "name": "shortbow", "note": "Needs no firing pin and starts empty: an arrow must be nocked by hand, and the bow drawn (use in hand) before it will fire, otherwise the arrow just falls to the floor. The arrow also drops out if the bow is dropped or moved out of the hands. Same as the bow; only the damage multiplier differs. / Needs no firing pin and starts empty: an arrow must be nocked by hand, and the bow drawn (use in hand) before it will fire, otherwise the arrow just falls to the floor. The arrow also drops out if the bow is dropped or moved out of the hands (e.g. onto the back).", - "rank": 16, + "rank": 18, "reason": "Does 18 damage per arrow. Its recipe book comes in a 1,000 cr cargo kit.", "score": 3, "score_inputs": { @@ -632,7 +654,7 @@ "group": "rifles", "hash": "a0e09364d4321fcb", "name": "Sakhno Precision Rifle", - "rank": 6, + "rank": 8, "reason": "A surplus rifle with real rounds: AP 10 keeps its 60 damage useful through armor.", "score": 48, "tier": "yellow" @@ -643,7 +665,7 @@ "hash": "07e5a8b46b6618fc", "name": "Donk Co. Jezail", "note": "The bolt must be cycled between shots and be open to load. Sawing it off (needs a saw; a live round in the gun goes off in the user's face) makes it belt-sized, cuts damage by a quarter and sets spread to 50. Accepts a bayonet.", - "rank": 7, + "rank": 9, "reason": "A precision rifle with 4 rounds in a huge frame.", "score": 44, "tier": "yellow" @@ -654,7 +676,7 @@ "hash": "", "name": "ballistic harpoon gun", "note": "Holds one harpoon, so every shot needs the bolt worked and a new harpoon loaded by hand. A hit nearly always embeds and hurts when the target moves, until someone pulls it out.", - "rank": 9, + "rank": 11, "reason": "One harpoon at a time: 60 damage that goes through a vest and sticks in the target. It scores just over the yellow line, and every shot is a reload.", "score": 41, "score_inputs": { @@ -671,7 +693,7 @@ "hash": "09224ff4494cd405", "name": "pipegun", "note": "Bolt action that anyone can fire (hulks included), and a point-blank shot throws the target back 3 tiles. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. / Against robotic mobs it adds half the shot's own damage again as brute to the chest, which armor does not reduce. Junk variants (hunter, shock, reaper and so on) change which mobs are hit harder or what the extra damage is.", - "rank": 14, + "rank": 16, "reason": "Fires one hand-loaded round at a time.", "score": 5, "score_inputs": { @@ -705,7 +727,7 @@ "hash": "6a105958c2af93f0", "name": "regal pipegun", "note": "Bolt action loaded with reaper rounds, which are hitscan (instant) junk rounds. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. Anyone can fire it (hulks included) and a point-blank shot throws the target back 3 tiles. / Bolt action that anyone can fire (hulks included), and a point-blank shot throws the target back 3 tiles. Every shot and every bolt cycle throws a spark, which can ignite flammable gas on its tile, and junk rounds do 50% extra damage to robotic mobs. / Against robotic mobs it adds half the shot's own damage again as brute to the chest, which armor does not reduce. Junk variants (hunter, shock, reaper and so on) change which mobs are hit harder or what the extra damage is.", - "rank": 8, + "rank": 10, "reason": "Fires hitscan shots of 60 with 4 rounds. Crafting it needs a one-use book.", "score": 41, "score_inputs": { @@ -738,7 +760,7 @@ "group": "rifles", "hash": "7673980e5008c01b", "name": "Sakhno M2442 Army", - "rank": 11, + "rank": 13, "reason": "Does 60 damage on bare targets and about 24 through a vest, with five shots and a bolt to work.", "score": 15, "tier": "green" @@ -759,7 +781,7 @@ "hash": "2ed82f9db0fc22be", "name": "heated rebar crossbow", "note": "Every shot leaves the string loose; drawing it takes a 3 s do-after (clicking the empty crossbow starts it) before the next rod can fire. The rod can embed in the target.", - "rank": 15, + "rank": 17, "reason": "Does 30 damage then needs a 3 s draw. Better ammo exists, which the score doesn't cover.", "score": 3, "score_inputs": { @@ -776,7 +798,7 @@ "hash": "04bae29a51921f7d", "name": "stressed rebar crossbow", "note": "Every shot leaves the string loose; drawing it takes a 3 s do-after (clicking the empty crossbow starts it) before the next rod can fire. The rod can embed in the target. Here the redraw time is 1.5 ticks (about 0.15 s) because the value lacks SECONDS, and 25% of shots aimed at someone else misfire into the shooter. / Every shot leaves the string loose; drawing it takes a 3 s do-after (clicking the empty crossbow starts it) before the next rod can fire. The rod can embed in the target.", - "rank": 13, + "rank": 15, "reason": "Redraws almost instantly at the cost of shooting yourself one time in four.", "score": 7, "score_inputs": { diff --git a/voidcrew/modules/loot/loot_guns.dm b/voidcrew/modules/loot/loot_guns.dm index e7c2cacf9c7..3c5062c592e 100644 --- a/voidcrew/modules/loot/loot_guns.dm +++ b/voidcrew/modules/loot/loot_guns.dm @@ -55,6 +55,7 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( /obj/item/gun/energy/e_gun = 10, /obj/item/gun/ballistic/automatic/wt550 = 10, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly = 10, + /obj/item/gun/ballistic/automatic/woodsman = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/energy/laser/carbine = 10, @@ -89,6 +90,7 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( LOOT_WEAPON_YELLOW = list( /obj/item/gun/ballistic/revolver = 10, /obj/item/gun/ballistic/rifle/boltaction/harpoon = 10, + /obj/item/gun/ballistic/automatic/skm = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/shotgun/bulldog/unrestricted = 10, diff --git a/voidcrew/modules/loot/zone_loot.dm b/voidcrew/modules/loot/zone_loot.dm index 7c5ac774346..af1e267b60a 100644 --- a/voidcrew/modules/loot/zone_loot.dm +++ b/voidcrew/modules/loot/zone_loot.dm @@ -72,8 +72,10 @@ GLOBAL_LIST_INIT(loot_gun_spare_ammo, list( /obj/item/gun/ballistic/automatic/pistol/zashch = /obj/item/ammo_box/magazine/zashch, /obj/item/gun/ballistic/automatic/proto/unrestricted = /obj/item/ammo_box/magazine/smgm9mm, /obj/item/gun/ballistic/automatic/rapier = /obj/item/ammo_box/magazine/rapier, + /obj/item/gun/ballistic/automatic/skm = /obj/item/ammo_box/magazine/skm, /obj/item/gun/ballistic/automatic/tommygun = /obj/item/ammo_box/magazine/tommygunm45, /obj/item/gun/ballistic/automatic/wt550 = /obj/item/ammo_box/magazine/wt550m9, + /obj/item/gun/ballistic/automatic/woodsman = /obj/item/ammo_box/magazine/m23, /obj/item/gun/ballistic/rifle/sniper_rifle = /obj/item/ammo_box/magazine/sniper_rounds, /obj/item/gun/ballistic/shotgun/bulldog/unrestricted = /obj/item/ammo_box/magazine/m12g, // internal magazines: loose rounds instead diff --git a/voidcrew/modules/research/techweb_nodes.dm b/voidcrew/modules/research/techweb_nodes.dm index d36e664ab58..05295b4de40 100644 --- a/voidcrew/modules/research/techweb_nodes.dm +++ b/voidcrew/modules/research/techweb_nodes.dm @@ -423,6 +423,8 @@ "vc_ammo_uzi", "vc_strilka310_clip", "vc_ammo_harpoon", + "vc_ammo_a8x50r_clip", + "vc_ammo_a762x40_clip", "suppressor", ) research_costs = list(TECHWEB_POINT_TYPE_GENERIC = TECHWEB_TIER_3_POINTS) diff --git a/voidcrew/modules/shiptest_guns/rifles/attribution.txt b/voidcrew/modules/shiptest_guns/rifles/attribution.txt new file mode 100644 index 00000000000..cfd469c63f6 --- /dev/null +++ b/voidcrew/modules/shiptest_guns/rifles/attribution.txt @@ -0,0 +1,46 @@ +The art and sounds in this folder come from Shiptest (https://github.com/shiptest-ss13/Shiptest), taken +on 2026-11-08 at commit 000f892ae9. Shiptest licenses its assets CC BY-SA 3.0 +(https://creativecommons.org/licenses/by-sa/3.0/) unless otherwise indicated. Each DMI holds only the +states these guns use, copied pixel for pixel, apart from the renames and additions listed. + +Icons +icons/woodsman.dmi - states woodsman and woodsman_mag_woodsman_mag (renamed woodsman_mag, the + state our gun code draws for a loaded magazine) from + icons/obj/guns/manufacturer/serene_outdoors/48x32.dmi. Drawn by Apogee-dev, + Shiptest PR #4083 "Serene Outdoors Model 23 Woodsman Rifle". CC BY-SA 3.0. +icons/skm.dmi - states skm, skm_mag_skm_mag, skm_mag_skm_extended_mag and skm_mag_skm_drum + from icons/obj/guns/manufacturer/frontier_import/48x32.dmi. Drawn by retlaw34 + and Apogee-dev, Shiptest PR #2734 (the PR calls the SKM an original design). + CC BY-SA 3.0. + skm_suppressor is new: the saber_suppressor state from /tg/station + icons/obj/weapons/guns/ballistic.dmi (CC BY-SA 3.0), moved into a 48x32 frame. +icons/rifles_lefthand.dmi, +icons/rifles_righthand.dmi - state woodsman from icons/obj/guns/manufacturer/serene_outdoors/lefthand.dmi + and righthand.dmi (Shiptest PR #4083), state skm from + icons/obj/guns/manufacturer/frontier_import/lefthand.dmi and righthand.dmi + (Shiptest PR #2734 and later frontier_import updates). CC BY-SA 3.0. +icons/rifles_back.dmi - state woodsman from icons/obj/guns/manufacturer/serene_outdoors/onmob.dmi and + state skm from icons/obj/guns/manufacturer/frontier_import/onmob.dmi, same + sources as the inhands. CC BY-SA 3.0. +icons/ammo.dmi - from Shiptest icons/obj/ammunition/ammo.dmi: woodsman_mag, skm_mag, + skm_extended_mag and skm_drum (each Shiptest "-1" state copied as the base and + "-full" states, each "-0" state as the "-empty" state, the names our magazine + code draws), and the stripper clip states 850, 850-0 to 850-5, 300m and 300m-0 + to 300m-5 unchanged. Added by Shiptest in-house PRs; no outside source is + credited in that history. CC BY-SA 3.0. + +Sounds (taken unmodified from Shiptest sound/weapons/gun/rifle/) +sound/skm.ogg - rifle/skm.ogg. Byte-identical to TerraGov-Marine-Corps sound/weapons/guns/fire/ak47.ogg, + last changed in TGMC PR #5261 "The Gun Sound Overhaul" (XSlayer300). TGMC's README + licenses the sounds of PR #5261 CC BY-NC-SA 3.0 + (https://creativecommons.org/licenses/by-nc-sa/3.0/). NON-COMMERCIAL. +sound/skm_reload.ogg - rifle/skm_reload.ogg. Byte-identical to TerraGov-Marine-Corps + sound/weapons/guns/interact/ak47_reload.ogg, added by TGMC PR #2905 (Aurelien3189). + TGMC licenses contributions not otherwise specified under AGPL-3.0. +sound/skm_unload.ogg - rifle/skm_unload.ogg. Byte-identical to TerraGov-Marine-Corps + sound/weapons/guns/interact/ak47_unload.ogg, added by TGMC PR #2905 (Aurelien3189). + TGMC licenses contributions not otherwise specified under AGPL-3.0. + +Not copied: the Woodsman's own fire sound (rifle/ssg669c.ogg) and the SKM's rack sound +(rifle/skm_cocked.ogg) have no traceable source, so both guns use stock /tg/station sounds there, which +the gun_feel module swaps for its credited Shiptest recordings. diff --git a/voidcrew/modules/shiptest_guns/rifles/designs.dm b/voidcrew/modules/shiptest_guns/rifles/designs.dm new file mode 100644 index 00000000000..a46b29127b2 --- /dev/null +++ b/voidcrew/modules/shiptest_guns/rifles/designs.dm @@ -0,0 +1,20 @@ +// Ship research prints the loose-round reloads for the Woodsman and the SKM-24. Their magazines are +// sold, not printed. Unlocked by Automatic Ammunition (voidcrew/modules/research/techweb_nodes.dm). + +/datum/design/vc_ammo/a8x50r_clip + name = "Stripper Clip (8x50mmR)" + desc = "Five rounds of 8x50mmR on a stripper clip." + id = "vc_ammo_a8x50r_clip" + build_path = /obj/item/ammo_box/a8x50r + materials = list( + /datum/material/iron = SHEET_MATERIAL_AMOUNT * 3, + ) + +/datum/design/vc_ammo/a762x40_clip + name = "Stripper Clip (7.62x40mm)" + desc = "Five rounds of 7.62x40mm on a stripper clip." + id = "vc_ammo_a762x40_clip" + build_path = /obj/item/ammo_box/a762x40 + materials = list( + /datum/material/iron = SHEET_MATERIAL_AMOUNT * 2, + ) diff --git a/voidcrew/modules/shiptest_guns/rifles/icons/ammo.dmi b/voidcrew/modules/shiptest_guns/rifles/icons/ammo.dmi new file mode 100644 index 0000000000000000000000000000000000000000..dffb5be271875c8de6bdbb7d6c90b981aa99bfec GIT binary patch literal 3373 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/rifles/icons/rifles_lefthand.dmi b/voidcrew/modules/shiptest_guns/rifles/icons/rifles_lefthand.dmi new file mode 100644 index 0000000000000000000000000000000000000000..8ca6f925aafd36f68f5eaf8a86ff1fb852e8178f GIT binary patch literal 2235 zcmZ`*XH=7k68#9$)Cedb5(T2LbP+;{5GhM2N|jD1LPD1+iU9*;aYbqnlpaAPsDK&* zB1Wku5DOh8F`F)u-mS13008i5JEZFl z2L1|h(VaX~dKtb03CZr>7m*M`{@NYu8M<7Hu$g5E{mR+{U% zgalZ``}nF??S?3Tw;+2*UI&kU3Svm6yMZnuwP&?~Vwo9T#(6%>!3_8hV%zU&&EHVXUL%kL8Q$fmFh{ zm9)@`nAO1U97LvU=Ry5ZCoi(kr<<{e-jLZBSVY_miI0l5!VDL;Ll)6R3>xOtc+&A1 zc6gHf;a0`b>tm@$lQLQ9cPb5~M9-{lD_1(XyLhG1864Y3@gJd8`IxWTJqdEq0tCsY zB*b^gu+HCWmR3l*-<{FwV5uJ)6L57U@IGemQi%-Y+HEU3C-(>;sZ%aIp`D{+q|k1w zc3@{#PCvza5$Gjsr4 zK|?z2NNBURXxlmsQK!apwOPT(gQ4m3kpn5^4J z)2@txW|{^!WrYnugYLu`>QubA8+^^FoGWQ7e}`o>CRztH3XWV zEN7DVE2RY^C2nE9t?tchiGu-+@&zIa9(N%t&-`E7;5-I0WdaTEc7Q!{|Y()OE$fk4oI76qB20$D1WCHk0s18DC z6#{|^YW|E8`ZoWra$78Bon=Dc;#NEB*+4zC1!whz_a30h-#KjZz@jfptE72_Wh4GOu%13 z;eg?tAM0t+WUFB;@Ad0up+$-A>>{X!EOKLeF`)E95?7b+7&T!e4O~=$X*qad< zP6B9Vn7J=wdUnHPuh)K4z7gQGwG;Bm?}6$60oRc= A@&Et; literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/rifles/icons/rifles_righthand.dmi b/voidcrew/modules/shiptest_guns/rifles/icons/rifles_righthand.dmi new file mode 100644 index 0000000000000000000000000000000000000000..3f87c2f8f8c2aaeab3072e0c67263f9ec6b01873 GIT binary patch literal 2300 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 Qe!MdPLp@UjS;sl*pQ~nI0RR91 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/rifles/icons/skm.dmi b/voidcrew/modules/shiptest_guns/rifles/icons/skm.dmi new file mode 100644 index 0000000000000000000000000000000000000000..c4b2033c1dcb46a415a0fbdf82016d58fc39f286 GIT binary patch literal 1429 zcmV;G1#0?V=-0C=2JR&a84_w-Y6@%7{?OD!tS%+FJ>RWQ*r;NmRLOex6#aV)qvGg33t zGfIFQVpRtv={9x4U32fuC}{Ww@f&_hwA z(5i*@BBf9$d$-#|AQ98nkR~RsE;d-L9wH;pyZ*1j;dtJ{fNcy$aFIvTE z<9|O;|9Ym2q>zxbfIvtTjDtz=azmEBM)DwW=3`)Bz!eM|`sjh}6&D6lVxFH5_4wLI zq$h3DZpqxdWl(>fWv%(726~n`gKxf3B1d#&L_7{rMf~0Ug5K35BU&Jd-ZseA3uU^rqH|X7`D~-Rrz5Nx{J~vhNGiNDI>nBig zZ>O(B8X)4#w;@0N=o1GbJbLt>7D7r2-oN(4)l?kzln(7L?mT!odFG5Y>$G$G4yUto zt@}Ekzo#x=`94+G<3PGJd56haEf2&gClC5TQl=nJa5p}zblFIH{W{;+HqtMad+6cP*s!5r*~a4KZNAs_{3mda163+j z^Q8~Ko)}7)NE1gO&dr-+DlY4C{?rL)@4>^y+iMYok;JK{!z$^M1bJ*NTLQWk<5Sam zJ%0W*>yt>;qYqT=o(|Fn{hlNp2}b%z3f1e!kCMW}$8KAav>xkG^VxWjC+_J35cReR z6P7}|ckOYuY}+3Pm=)iGS{)fzsI(qBhe&z?B2Dnx2^=xj=H$7iS&YdY?u!IpKm_QYnxO2yCFV5wBuL%VzOR#^F^l}vj zOBiYW5{A1v<*62OZ?^6CSCI$>!i{V1fB2qm_iyZ1W20j}k%+xCyo80_Qx1tpJcz6h zgw%=CBU4C30?Csef+Y)mqOfNV61#{bfh29bDa0}s4PD#WGBxTI|2sO%NKanPoJeyC%L7S&D?51faQz=h8h%|xf j@nm$;TJUlXqD*)Wmpc>hFXnBL00000NkvXXu0mjfEJ&y? literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/rifles/icons/woodsman.dmi b/voidcrew/modules/shiptest_guns/rifles/icons/woodsman.dmi new file mode 100644 index 0000000000000000000000000000000000000000..fffdefb01488177628cbf62a1ba075c295d8035b GIT binary patch literal 826 zcmV-A1I7G_P)V=-0C=2JR&a84_w-Y6@%7{?OD!tS%+FJ>RWQ*r;NmRLOex6#aV)qvGg33t zGfIFQVBDHKfBCAt6r0)I(FK~#90V?1!+0K+I4IKVW3c8`M52pD)Unga$Hj7Go! zgV6{WU@#g10}MtZV1NM&Ihv9ZQ~rZ!QBe_w&aMtdWN}<#l9Eyk&!0a-ksAeMMu210 zZHBj7g2C*t(D46OR@MxcE?r{K)6+u{J9g|S1Bix!*RS7`Bpz<9Q_><>s1&gU^OTpz=&E3W@cTP1*Q6w!RiDAyuC(q&H z+qdn67iZ||&zwHXU}9pt7Vx+N6B(R8x&utt#+VRgcJq=m49vWUNW=v| zIdIkLm5jrx2-?-11a9Ggz~u+u7>cK^U__QvQk7xYxce5v>u)^Zl!z_Bv4LoF3B2<6 zZa;zB`JIuMK@qJrHCzFsueQX0T?H;M2Nbx-02I(50FpXzwATZQZH_;K`@2W. + special_mags = TRUE + burst_size = 1 + fire_delay = 0.25 SECONDS + spread = 1.4 + // Fires as long as the trigger is held, so no fire selector. + actions_types = list() + bolt_type = BOLT_TYPE_LOCKING + // The art has no bolt states. + show_bolt_icon = FALSE + // The suppressor art sits at the left of its frame; this carries it to the muzzle. + suppressor_x_offset = 23 + gun_feel_class = GUN_FEEL_CLASS_RIFLE + +/obj/item/gun/ballistic/automatic/skm/Initialize(mapload) + . = ..() + AddComponent(/datum/component/automatic_fire, fire_delay) + AddComponent(/datum/component/bayonet_attachable, offset_x = 38, offset_y = 13) + +#undef CALIBER_A762X40 diff --git a/voidcrew/modules/shiptest_guns/rifles/sound/skm.ogg b/voidcrew/modules/shiptest_guns/rifles/sound/skm.ogg new file mode 100644 index 0000000000000000000000000000000000000000..ed1262cca14a7d09eccae97f88c90e57024c8c38 GIT binary patch literal 24460 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/rifles/sound/skm_reload.ogg b/voidcrew/modules/shiptest_guns/rifles/sound/skm_reload.ogg new file mode 100644 index 0000000000000000000000000000000000000000..9105d5c31c16cd84e51e25c33ad22b21379184dd GIT binary patch literal 17846 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 H_Mra-9A9oZ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/rifles/sound/skm_unload.ogg b/voidcrew/modules/shiptest_guns/rifles/sound/skm_unload.ogg new file mode 100644 index 0000000000000000000000000000000000000000..f6b5c73d7f6bd471e2fdb9b7062b544a9294e369 GIT binary patch literal 19650 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 30, "8x50mmR should go through a vest") + + var/obj/item/gun/ballistic/automatic/skm/skm = allocate(/obj/item/gun/ballistic/automatic/skm) + skm.gun_feel_resolve() + TEST_ASSERT_EQUAL(skm.gun_feel_class, "rifle", "The SKM should kick like a rifle") + TEST_ASSERT_EQUAL(skm.magazine.type, /obj/item/ammo_box/magazine/skm, "The SKM spawned with the wrong magazine") + TEST_ASSERT_EQUAL(skm.magazine.max_ammo, 20, "The SKM magazine is the wrong size") + TEST_ASSERT_EQUAL(skm.chambered?.type, /obj/item/ammo_casing/a762x40, "The SKM fires the wrong round") + TEST_ASSERT_NOTNULL(skm.GetComponent(/datum/component/automatic_fire), "The SKM should fire automatically") + TEST_ASSERT(!length(skm.actions_types), "The SKM should have no action buttons") + TEST_ASSERT_EQUAL("[skm.fire_sound]", "voidcrew/modules/shiptest_guns/rifles/sound/skm.ogg", "Gun feel swapped the SKM's own shot") + TEST_ASSERT_NOTNULL(skm.GetComponent(/datum/component/bayonet_attachable), "The SKM should take a bayonet") + for(var/magazine_path in list(/obj/item/ammo_box/magazine/skm/extended, /obj/item/ammo_box/magazine/skm/drum)) + TEST_ASSERT(ispath(magazine_path, skm.accepted_magazine_type), "The SKM does not take [magazine_path]") + TEST_ASSERT(!ispath(/obj/item/ammo_box/magazine/m23, skm.accepted_magazine_type), "The SKM takes a Woodsman magazine") + TEST_ASSERT(skm.can_suppress, "The SKM should take a suppressor") + var/obj/item/suppressor/suppressor = allocate(/obj/item/suppressor) + skm.install_suppressor(suppressor) + TEST_ASSERT_EQUAL(skm.suppressed, suppressor, "The suppressor did not fit the SKM") + +/// A stripper clip of each caliber refills its rifle's magazine, and only its own. +/datum/unit_test/voidcrew_shiptest_rifles_reload +/datum/unit_test/voidcrew_shiptest_rifles_reload/Run() + var/mob/living/carbon/human/consistent/loader = allocate(/mob/living/carbon/human/consistent) + var/list/pairs = list( + /obj/item/ammo_box/magazine/m23/empty = /obj/item/ammo_box/a8x50r, + /obj/item/ammo_box/magazine/skm/empty = /obj/item/ammo_box/a762x40, + ) + for(var/magazine_path in pairs) + var/obj/item/ammo_box/magazine/magazine = allocate(magazine_path) + var/obj/item/ammo_box/clip = allocate(pairs[magazine_path]) + magazine.attackby(clip, loader, null, TRUE) + TEST_ASSERT_EQUAL(magazine.ammo_count(), 5, "A stripper clip did not fill [magazine_path]") + TEST_ASSERT_EQUAL(length(clip.stored_ammo), 0, "The stripper clip kept its rounds after loading [magazine_path]") + var/obj/item/ammo_box/magazine/m23/empty/wrong = allocate(/obj/item/ammo_box/magazine/m23/empty) + var/obj/item/ammo_box/a762x40/skm_clip = allocate(/obj/item/ammo_box/a762x40) + wrong.attackby(skm_clip, loader, null, TRUE) + TEST_ASSERT_EQUAL(wrong.ammo_count(), 0, "7.62x40mm went into a Woodsman magazine") + +/// Both rifles and their ammo can be got: guns from the Undertow Exchange and cache loot, magazines from the outfitter, +/// the drum only with advanced standing, stripper clips from research. +/datum/unit_test/voidcrew_shiptest_rifles_routes +/datum/unit_test/voidcrew_shiptest_rifles_routes/Run() + var/datum/outpost_shop/black_market = allocate(/datum/outpost_shop/black_market) + var/datum/outpost_shop/outfitter = allocate(/datum/outpost_shop/outfitter) + var/list/routes = list( + /datum/shop_sku/black_market/woodsman = list(black_market, /obj/item/gun/ballistic/automatic/woodsman, 1), + /datum/shop_sku/black_market/skm = list(black_market, /obj/item/gun/ballistic/automatic/skm, 1), + /datum/shop_sku/black_market/skm_drum = list(black_market, /obj/item/ammo_box/magazine/skm/drum, 2), + /datum/shop_sku/outfitter/woodsman_mag = list(outfitter, /obj/item/ammo_box/magazine/m23, 1), + /datum/shop_sku/outfitter/skm_mag = list(outfitter, /obj/item/ammo_box/magazine/skm, 1), + /datum/shop_sku/outfitter/skm_extended_mag = list(outfitter, /obj/item/ammo_box/magazine/skm/extended, 1), + ) + for(var/datum/shop_sku/sku_type as anything in routes) + var/list/route = routes[sku_type] + var/datum/outpost_shop/shop = route[1] + TEST_ASSERT(sku_type in shop.sku_types, "[shop.type] does not stock [sku_type]") + TEST_ASSERT_EQUAL(initial(sku_type.item_path), route[2], "[sku_type] sells the wrong thing") + TEST_ASSERT_EQUAL(initial(sku_type.zone_permission), route[3], "[sku_type] has the wrong standing gate") + var/datum/shop_sku/drum = allocate(/datum/shop_sku/black_market/skm_drum) + TEST_ASSERT(!drum.contract_reward, "A contract can pay out the SKM drum") + + TEST_ASSERT_EQUAL(GLOB.loot_gun_pools["armory"]["2"][/obj/item/gun/ballistic/automatic/woodsman], 10, "The Woodsman left the armory cache pool") + TEST_ASSERT_EQUAL(GLOB.loot_gun_pools["plunder"]["2"][/obj/item/gun/ballistic/automatic/skm], 10, "The SKM left the plunder cache pool") + TEST_ASSERT_EQUAL(GLOB.loot_gun_spare_ammo[/obj/item/gun/ballistic/automatic/woodsman], /obj/item/ammo_box/magazine/m23, "A looted Woodsman comes with no spare magazine") + TEST_ASSERT_EQUAL(GLOB.loot_gun_spare_ammo[/obj/item/gun/ballistic/automatic/skm], /obj/item/ammo_box/magazine/skm, "A looted SKM comes with no spare magazine") + + var/datum/techweb_node/node = SSresearch.techweb_nodes["automatic_ammo"] + TEST_ASSERT_NOTNULL(node, "Missing research node automatic_ammo") + var/list/printed = list("vc_ammo_a8x50r_clip" = /obj/item/ammo_box/a8x50r, "vc_ammo_a762x40_clip" = /obj/item/ammo_box/a762x40) + for(var/design_id in printed) + TEST_ASSERT(design_id in node.design_ids, "automatic_ammo lost [design_id]") + var/datum/design/design = SSresearch.techweb_designs[design_id] + TEST_ASSERT_NOTNULL(design, "Missing design [design_id]") + TEST_ASSERT_EQUAL(design.build_path, printed[design_id], "[design_id] prints the wrong thing") + for(var/datum/outpost_shop/shop as anything in list(black_market, outfitter)) + for(var/datum/shop_sku/sku_type as anything in shop.sku_types + shop.rotating_pool + shop.rare_pool + shop.favor_sku_types) + TEST_ASSERT(initial(sku_type.item_path) != design.build_path, "[shop.type] sells [design.build_path], which ships can print") diff --git a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm index cbf95698768..041c5ef0ac3 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm @@ -348,6 +348,8 @@ list(/datum/shop_sku/black_market/ansem, /obj/item/gun/ballistic/automatic/pistol/clandestine, 1, 2000, 1), list(/datum/shop_sku/black_market/ansem_sc, /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher, 1, 2800, 1), list(/datum/shop_sku/black_market/lethal_cycler, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, 1, 2400, 1), + list(/datum/shop_sku/black_market/woodsman, /obj/item/gun/ballistic/automatic/woodsman, 1, 2600, 1), + list(/datum/shop_sku/black_market/skm, /obj/item/gun/ballistic/automatic/skm, 1, 3600, 2), list(/datum/shop_sku/black_market/rotating/mateba, /obj/item/gun/ballistic/revolver/mateba, 1, 3000, 1), ), /datum/outpost_shop/vendor/ripperdoc = list( @@ -405,6 +407,8 @@ /obj/item/gun/ballistic/automatic/pistol/clandestine, /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, + /obj/item/gun/ballistic/automatic/woodsman, + /obj/item/gun/ballistic/automatic/skm, /obj/item/gun/ballistic/revolver/mateba, ) var/list/printed_gear = list( @@ -523,7 +527,7 @@ /datum/shop_sku/black_market/c20r, /datum/shop_sku/black_market/wt550, /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/black_market/rapier, /datum/shop_sku/black_market/zashch, /datum/shop_sku/black_market/nt_m96, /datum/shop_sku/black_market/battle_rifle, /datum/shop_sku/black_market/ansem, /datum/shop_sku/black_market/ansem_sc, - /datum/shop_sku/black_market/lethal_cycler, + /datum/shop_sku/black_market/lethal_cycler, /datum/shop_sku/black_market/woodsman, /datum/shop_sku/black_market/skm, /datum/shop_sku/black_market/cutlass, /datum/shop_sku/black_market/rare/energy_sword, /datum/shop_sku/ripperdoc/monowire, /datum/shop_sku/ripperdoc/gorilla, /datum/shop_sku/ripperdoc/mantis, /datum/shop_sku/general/rotating/freight_crate, /datum/shop_sku/black_market/mystery_cache, /datum/shop_sku/black_market/mystery_cache_rare, diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_research.dm b/voidcrew/modules/unit_tests/voidcrew_zone_research.dm index 0fd5461a43f..692535be092 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_research.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_research.dm @@ -155,7 +155,7 @@ "nanite_remotes" = list("nanite_remote", "nanite_comm_remote"), "restricted_tech" = list("largecrossbow", "borg_syndicate_module"), "desynchronization" = list("desynchronizer"), - "automatic_ammo" = list("vc_strilka310_clip", "suppressor", "vc_ammo_harpoon"), + "automatic_ammo" = list("vc_strilka310_clip", "suppressor", "vc_ammo_harpoon", "vc_ammo_a8x50r_clip", "vc_ammo_a762x40_clip"), "specialist_ammo" = list("sec_Islug", "vc_ammo_smartgun", "c38_flare", "c38_flare_mag", "vc_ammo_m10mm_ap", "vc_ammo_m10mm_hp", "vc_ammo_m10mm_fire"), TECHWEB_NODE_ELECTRIC_WEAPONS = list("nuclear_gun"), TECHWEB_NODE_BEAM_WEAPONS = list("stunrevolver"), diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm index 825dffa816a..c292dfc2589 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm @@ -36,6 +36,8 @@ /datum/shop_sku/black_market/ansem = 1, /datum/shop_sku/black_market/ansem_sc = 1, /datum/shop_sku/black_market/lethal_cycler = 1, + /datum/shop_sku/black_market/woodsman = 1, + /datum/shop_sku/black_market/skm = 1, /datum/shop_sku/outfitter/rare/ablative_vest = 1, /datum/shop_sku/skunk/rotating/thermal_glasses = 1, /datum/shop_sku/black_market/thermals = 1, @@ -64,6 +66,9 @@ /datum/shop_sku/outfitter/smg_mag = 1, /datum/shop_sku/outfitter/wt550_mag = 1, /datum/shop_sku/outfitter/rifle_clip = 1, + /datum/shop_sku/outfitter/woodsman_mag = 1, + /datum/shop_sku/outfitter/skm_mag = 1, + /datum/shop_sku/outfitter/skm_extended_mag = 1, /datum/shop_sku/outfitter/bulletproof_vest = 1, /datum/shop_sku/outfitter/bulletproof_helmet = 1, /datum/shop_sku/outfitter/rare/ablative_hood = 1, @@ -99,6 +104,7 @@ /datum/shop_sku/fitter/rare/mod_emp_shield_advanced = 2, /datum/shop_sku/fitter/rare/mod_projectile_dampener = 2, /datum/shop_sku/black_market/mod_chassis_syndicate = 2, + /datum/shop_sku/black_market/skm_drum = 2, /datum/shop_sku/black_market/mod_interdyne = 2, /datum/shop_sku/black_market/mod_energy_shield = 2, /datum/shop_sku/black_market/shield_generator_board = 2, @@ -131,7 +137,7 @@ /datum/unit_test/voidcrew_zone_shops/catalog/Run() var/list/expected = expected_tags() - TEST_ASSERT_EQUAL(length(expected), 101, "The retained permission catalog is incomplete") + TEST_ASSERT_EQUAL(length(expected), 107, "The retained permission catalog is incomplete") var/datum/voidcrew_crew_record/crew = allocate(/datum/voidcrew_crew_record) for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku)) var/permission = expected[sku_type] || 0 From 4be21555342f605ab21aeb3e727b1e7d82c318e4 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:11:02 -0800 Subject: [PATCH 198/472] Add the Slammer, HP Brimstone and HP lever-action guns from Shiptest Five guns ported from Shiptest (AGPL code, CC BY-SA 3.0 art, credited in voidcrew/modules/shiptest_guns/shotguns/attribution.txt): - Slammer: a pump shotgun fed from six-shell box magazines (yellow). - HP Brimstone: a five-shell pump that fires on every pump while the trigger is held, and saws off (green). - HP Flaming Arrow: an 18-round .38 lever-action rifle, 7 sawn off (green). - HP Absolution: an 8-round .357 lever-action rifle (yellow). - HP Conflagration: a six-shell lever-action shotgun (yellow). The lever guns spin when racked one-handed or sawn off. The Undertow Exchange sells all five (the yellow ones for a voucher), the plunder and syndicate cache pools carry them with a spare reload, and the Quartermain Depot sells Slammer magazines. Rated in the gear catalog, with unit tests. --- tgstation.dme | 3 + tools/gear_catalog/ratings/ammo.json | 46 +++++++ tools/gear_catalog/ratings/guns.json | 61 +++++++++ voidcrew/modules/loot/loot_guns.dm | 5 + voidcrew/modules/loot/zone_loot.dm | 5 + .../shiptest_guns/shotguns/attribution.txt | 32 +++++ .../shiptest_guns/shotguns/brimstone.dm | 72 +++++++++++ .../shiptest_guns/shotguns/icons/back.dmi | Bin 0 -> 5247 bytes .../shiptest_guns/shotguns/icons/lefthand.dmi | Bin 0 -> 5226 bytes .../shotguns/icons/magazines.dmi | Bin 0 -> 667 bytes .../shotguns/icons/righthand.dmi | Bin 0 -> 5262 bytes .../shiptest_guns/shotguns/icons/shotguns.dmi | Bin 0 -> 2631 bytes .../shiptest_guns/shotguns/lever_actions.dm | 118 +++++++++++++++++ .../modules/shiptest_guns/shotguns/slammer.dm | 43 +++++++ .../trade/shop_catalog_black_market.dm | 66 ++++++++++ .../modules/trade/shop_catalog_outfitter.dm | 10 ++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + voidcrew/modules/unit_tests/voidcrew_loot.dm | 7 +- .../unit_tests/voidcrew_shiptest_shotguns.dm | 121 ++++++++++++++++++ .../unit_tests/voidcrew_shop_catalog.dm | 11 ++ .../modules/unit_tests/voidcrew_zone_shops.dm | 5 +- 21 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 voidcrew/modules/shiptest_guns/shotguns/attribution.txt create mode 100644 voidcrew/modules/shiptest_guns/shotguns/brimstone.dm create mode 100644 voidcrew/modules/shiptest_guns/shotguns/icons/back.dmi create mode 100644 voidcrew/modules/shiptest_guns/shotguns/icons/lefthand.dmi create mode 100644 voidcrew/modules/shiptest_guns/shotguns/icons/magazines.dmi create mode 100644 voidcrew/modules/shiptest_guns/shotguns/icons/righthand.dmi create mode 100644 voidcrew/modules/shiptest_guns/shotguns/icons/shotguns.dmi create mode 100644 voidcrew/modules/shiptest_guns/shotguns/lever_actions.dm create mode 100644 voidcrew/modules/shiptest_guns/shotguns/slammer.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_shiptest_shotguns.dm diff --git a/tgstation.dme b/tgstation.dme index dc351e3bfce..63be4636ab9 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7645,6 +7645,9 @@ #include "voidcrew\modules\ship_upgrades\ships\squab.dm" #include "voidcrew\modules\ship_upgrades\ships\test_modular.dm" #include "voidcrew\modules\ship_upgrades\workshop\bogatyr.dm" +#include "voidcrew\modules\shiptest_guns\shotguns\brimstone.dm" +#include "voidcrew\modules\shiptest_guns\shotguns\lever_actions.dm" +#include "voidcrew\modules\shiptest_guns\shotguns\slammer.dm" #include "voidcrew\modules\shuttle\boards.dm" #include "voidcrew\modules\shuttle\design.dm" #include "voidcrew\modules\shuttle\emergency.dm" diff --git a/tools/gear_catalog/ratings/ammo.json b/tools/gear_catalog/ratings/ammo.json index 0bd5c74fef8..95e8f71f206 100644 --- a/tools/gear_catalog/ratings/ammo.json +++ b/tools/gear_catalog/ratings/ammo.json @@ -368,6 +368,15 @@ "reason": "Built into the NT M-96, never on its own. Rated with the gun.", "tier": "green" }, + "/obj/item/ammo_box/magazine/internal/shot/absolution": { + "confidence": "high", + "group": "magazines built into a gun", + "hash": "", + "name": "HP Absolution internal tube", + "rank": 11, + "reason": "Built into the HP Absolution, never on its own. Rated with the gun.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/internal/shot/bounty": { "confidence": "high", "group": "magazines built into a gun", @@ -377,6 +386,24 @@ "reason": "Built into the hook shotgun, never on its own. Rated with the gun.", "tier": "yellow" }, + "/obj/item/ammo_box/magazine/internal/shot/brimstone": { + "confidence": "high", + "group": "magazines built into a gun", + "hash": "", + "name": "HP Brimstone internal tube", + "rank": 11, + "reason": "Built into the HP Brimstone, never on its own. Rated with the gun.", + "tier": "green" + }, + "/obj/item/ammo_box/magazine/internal/shot/conflagration": { + "confidence": "high", + "group": "magazines built into a gun", + "hash": "", + "name": "HP Conflagration internal tube", + "rank": 11, + "reason": "Built into the HP Conflagration, never on its own. Rated with the gun.", + "tier": "yellow" + }, "/obj/item/ammo_box/magazine/internal/shot/dual/breacherslug": { "confidence": "high", "group": "magazines built into a gun", @@ -395,6 +422,15 @@ "reason": "Built into a green shotgun, never on its own. Rated with the gun.", "tier": "green" }, + "/obj/item/ammo_box/magazine/internal/shot/flaming_arrow": { + "confidence": "high", + "group": "magazines built into a gun", + "hash": "", + "name": "HP Flaming Arrow internal tube", + "rank": 11, + "reason": "Built into the HP Flaming Arrow, never on its own. Rated with the gun.", + "tier": "green" + }, "/obj/item/ammo_box/magazine/internal/shot/lethal": { "confidence": "high", "group": "magazines built into a gun", @@ -561,6 +597,15 @@ "reason": "Eight taser slugs in a burst-fire drum. A 10 second paralysis on the first hit, with the next already on its way.", "tier": "red" }, + "/obj/item/ammo_box/magazine/m12g_slammer": { + "confidence": "medium", + "group": "shotgun shells", + "hash": "", + "name": "Slammer box magazine", + "rank": 10, + "reason": "Six plain buckshot shells in a box magazine. The shells sell freely; the magazine only matters in a Slammer.", + "tier": "green" + }, "/obj/item/ammo_box/magazine/m223": { "confidence": "high", "group": ".223 rifle loads", @@ -2324,6 +2369,7 @@ "/obj/item/ammo_box/magazine/internal/shot/tube/slug": "no route", "/obj/item/ammo_box/magazine/internal/sks": "abstract", "/obj/item/ammo_box/magazine/internal/sks/empty": "no route", + "/obj/item/ammo_box/magazine/m12g_slammer/empty": "no route", "/obj/item/ammo_box/magazine/m38/empty": "no route", "/obj/item/ammo_box/magazine/m75": "needs another system", "/obj/item/ammo_box/magazine/rapier/empty": "no route", diff --git a/tools/gear_catalog/ratings/guns.json b/tools/gear_catalog/ratings/guns.json index 1ab21b5d6dc..543c1edf637 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -940,6 +940,23 @@ }, "tier": "yellow" }, + "/obj/item/gun/ballistic/shotgun/brimstone": { + "confidence": "medium", + "group": "shotguns", + "hash": "", + "name": "HP Brimstone", + "note": "Holding the trigger and pumping fires each fresh shell as soon as it is chambered (slamfire), so it never needs a click per shot. Sawing it off makes it one-handed with much worse spread (25) and more kick.", + "rank": 12, + "reason": "A five-shell buckshot pump that fires as fast as you can work it while the trigger is held. Still plain buckshot from a short tube.", + "score": 14, + "score_inputs": { + "rate": { + "value": 2.0, + "why": "Fires once per pump with the trigger held; the automatic fire only waits on the pump, which is 0.4 s apart. The pump keypress caps it like clicking a semi-auto." + } + }, + "tier": "green" + }, "/obj/item/gun/ballistic/shotgun/bulldog/unrestricted": { "confidence": "medium", "group": "shotguns", @@ -1010,6 +1027,39 @@ }, "tier": "green" }, + "/obj/item/gun/ballistic/shotgun/flaming_arrow": { + "confidence": "medium", + "group": "rifles", + "hash": "", + "name": "HP Flaming Arrow", + "note": "Lever-action: worked by hand after every shot like a pump. 18 .38 rounds loaded one at a time. Sawn off it holds 7. Racking it one-handed or sawn off spins it.", + "rank": 11, + "reason": "Fires 25 damage .38 rounds from an 18-round tube, one lever stroke a shot. A long green carbine, below the BR-38.", + "score": 21, + "tier": "green" + }, + "/obj/item/gun/ballistic/shotgun/flaming_arrow/absolution": { + "confidence": "medium", + "group": "rifles", + "hash": "", + "name": "HP Absolution", + "note": "Lever-action: worked by hand after every shot. 8 .357 rounds loaded one at a time; the tube stays 8 when sawn off.", + "rank": 9, + "reason": "Eight 60 damage .357 rounds with a lever stroke each: a revolver with a long tube, a step under the Unica.", + "score": 50, + "tier": "yellow" + }, + "/obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration": { + "confidence": "low", + "group": "shotguns", + "hash": "", + "name": "HP Conflagration", + "note": "Lever-action shotgun with a 1.75 damage multiplier on every shell. Six shells, five sawn off. Racking it one-handed or sawn off spins it.", + "rank": 6, + "reason": "Buckshot at 1.75 times the usual damage, six to a tube, one lever stroke a shot. Hits like a combat shotgun per shell but cycles by hand.", + "score": 41, + "tier": "yellow" + }, "/obj/item/gun/ballistic/shotgun/hook": { "confidence": "medium", "group": "shotguns", @@ -1084,6 +1134,17 @@ }, "tier": "green" }, + "/obj/item/gun/ballistic/shotgun/slammer": { + "confidence": "low", + "group": "shotguns", + "hash": "", + "name": "Slammer", + "note": "Pump shotgun fed from six-shell box magazines, with tactical reloads. 1.75 damage multiplier on every shell. Magazines can be hand-filled with any 12 gauge shell.", + "rank": 5, + "reason": "Buckshot at 1.75 times the usual damage, pumped by hand, but it reloads six shells at once from a box magazine.", + "score": 41, + "tier": "yellow" + }, "/obj/item/gun/ballistic/shotgun/toy": { "confidence": "high", "group": "toys and novelties", diff --git a/voidcrew/modules/loot/loot_guns.dm b/voidcrew/modules/loot/loot_guns.dm index e7c2cacf9c7..a7f177999a6 100644 --- a/voidcrew/modules/loot/loot_guns.dm +++ b/voidcrew/modules/loot/loot_guns.dm @@ -73,6 +73,7 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( /obj/item/gun/ballistic/automatic/c20r/unrestricted = 10, /obj/item/gun/ballistic/automatic/mini_uzi = 10, /obj/item/gun/ballistic/automatic/pistol/clandestine = 10, + /obj/item/gun/ballistic/shotgun/slammer = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/automatic/l6_saw/unrestricted = 10, @@ -85,10 +86,14 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( /obj/item/gun/ballistic/revolver/c38/detective = 10, /obj/item/gun/ballistic/shotgun/doublebarrel = 10, /obj/item/gun/ballistic/automatic/pistol/zashch = 10, + /obj/item/gun/ballistic/shotgun/brimstone = 10, + /obj/item/gun/ballistic/shotgun/flaming_arrow = 10, ), LOOT_WEAPON_YELLOW = list( /obj/item/gun/ballistic/revolver = 10, /obj/item/gun/ballistic/rifle/boltaction/harpoon = 10, + /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution = 10, + /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/shotgun/bulldog/unrestricted = 10, diff --git a/voidcrew/modules/loot/zone_loot.dm b/voidcrew/modules/loot/zone_loot.dm index 7c5ac774346..1d099eea761 100644 --- a/voidcrew/modules/loot/zone_loot.dm +++ b/voidcrew/modules/loot/zone_loot.dm @@ -75,6 +75,7 @@ GLOBAL_LIST_INIT(loot_gun_spare_ammo, list( /obj/item/gun/ballistic/automatic/tommygun = /obj/item/ammo_box/magazine/tommygunm45, /obj/item/gun/ballistic/automatic/wt550 = /obj/item/ammo_box/magazine/wt550m9, /obj/item/gun/ballistic/rifle/sniper_rifle = /obj/item/ammo_box/magazine/sniper_rounds, + /obj/item/gun/ballistic/shotgun/slammer = /obj/item/ammo_box/magazine/m12g_slammer, /obj/item/gun/ballistic/shotgun/bulldog/unrestricted = /obj/item/ammo_box/magazine/m12g, // internal magazines: loose rounds instead /obj/item/gun/ballistic/revolver = /obj/item/ammo_box/a357, @@ -84,11 +85,15 @@ GLOBAL_LIST_INIT(loot_gun_spare_ammo, list( /obj/item/gun/ballistic/rifle/boltaction/harpoon = /obj/item/storage/bag/harpoon_quiver, /obj/item/gun/ballistic/rifle/boltaction/prime = /obj/item/ammo_box/strilka310, /obj/item/gun/ballistic/rifle/boltaction/surplus = /obj/item/ammo_box/strilka310, + /obj/item/gun/ballistic/shotgun/flaming_arrow = /obj/item/ammo_box/c38, + /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution = /obj/item/ammo_box/a357, /obj/item/gun/ballistic/rocketlauncher/unrestricted = /obj/item/ammo_casing/rocket, // break/pump actions: a box of shells /obj/item/gun/ballistic/shotgun/automatic/combat = /obj/item/storage/box/lethalshot, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly = /obj/item/storage/box/lethalshot, + /obj/item/gun/ballistic/shotgun/brimstone = /obj/item/storage/box/lethalshot, /obj/item/gun/ballistic/shotgun/doublebarrel = /obj/item/storage/box/lethalshot, + /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration = /obj/item/storage/box/lethalshot, /obj/item/gun/ballistic/shotgun/riot = /obj/item/storage/box/lethalshot, )) /obj/structure/closet/crate/zone_loot diff --git a/voidcrew/modules/shiptest_guns/shotguns/attribution.txt b/voidcrew/modules/shiptest_guns/shotguns/attribution.txt new file mode 100644 index 00000000000..150705e16b7 --- /dev/null +++ b/voidcrew/modules/shiptest_guns/shotguns/attribution.txt @@ -0,0 +1,32 @@ +Shiptest shotguns and lever-action rifles + +Code is ported from Shiptest (https://github.com/shiptest-ss13/Shiptest), AGPL-3.0, commit 000f892ae9: +- code/modules/projectiles/guns/manufacturer/hunter_pride/ballistics.dm (Brimstone, Flaming Arrow, Absolution, Conflagration) +- code/modules/projectiles/guns/manufacturer/frontier_import/ballistics.dm (Slammer and its magazine) + +All sprites below are from Shiptest and licensed CC BY-SA 3.0 +(https://creativecommons.org/licenses/by-sa/3.0/), per Shiptest's README: "All assets including icons +and sound are under a Creative Commons 3.0 BY-SA license unless otherwise indicated." None of these +files carries a different licence note. States were copied unchanged; some were renamed. + +icons/shotguns.dmi + brimstone, brimstone_sawn, flamingarrow, flamingarrow_sawn, absolution, absolution_sawn, + conflagration, conflagration_sawn: icons/obj/guns/manufacturer/hunterspride/48x32.dmi + slammer, slammer_mag: icons/obj/guns/manufacturer/frontier_import/48x32.dmi + +icons/lefthand.dmi, icons/righthand.dmi, icons/back.dmi + From hunterspride/lefthand.dmi, righthand.dmi and onmob.dmi, and frontier_import/lefthand.dmi, + righthand.dmi and onmob.dmi. Renamed states, as Shiptest itself reuses them for the sawn-off guns: + brimstone_sawn <- illestren_factory_sawn, absolution_sawn <- illestren_sawn, + conflagration_sawn <- beacon_factory_sawn. + +icons/magazines.dmi + slammer_mag-full <- slammer_mag-1, slammer_mag-empty <- slammer_mag-0: icons/obj/ammunition/ammo.dmi + +Authors, from the Shiptest history of those files: retlaw34 (Flaming Arrow, PR #2618), Martinpachu +(lever-action rifles, PR #3243), rye-rice (Slammer and Hunter's Pride sprite work, PRs #3176, #3238, +#3500, #3943), and earlier Shiptest contributors. + +Sounds: none copied. The guns use stock tg sounds and the gun-feel module's sounds +(voidcrew/modules/gun_feel/sound/, credited in its own attribution.txt). Shiptest's own +brimstone.ogg, flamingarrow.ogg and skm_cocked.ogg were not used because their sources could not be traced. diff --git a/voidcrew/modules/shiptest_guns/shotguns/brimstone.dm b/voidcrew/modules/shiptest_guns/shotguns/brimstone.dm new file mode 100644 index 00000000000..200a573e1ba --- /dev/null +++ b/voidcrew/modules/shiptest_guns/shotguns/brimstone.dm @@ -0,0 +1,72 @@ +// The HP Brimstone pump shotgun, ported from Shiptest (shiptest-ss13/Shiptest, AGPL-3.0 code, CC BY-SA 3.0 art). +// +// Slamfire: hold the trigger and every pump fires the moment a fresh shell is chambered. +// Built on tg's own automatic fire component, which already tracks a held mouse button: +// - Pressing the trigger starts it as on any automatic gun (on_autofire_start). With a spent or empty +// chamber but shells in the tube it still starts, and waits, instead of dry-clicking. +// - Each tick the component asks do_autofire() for a shot. While the chamber holds no live shell but the +// tube has some, we answer "still firing" without shooting, so the trigger stays held. +// - Pumping (attack_self -> rack, gated by rack_delay as usual) chambers a live shell; the next tick fires it. +// - When chamber and tube are both empty it dry-clicks and lets go, as any automatic gun does. +// Letting go of the button stops it. Racking with no trigger held is an ordinary pump. + +/obj/item/gun/ballistic/shotgun/brimstone + name = "\improper HP Brimstone" + desc = "A plain, sturdy pump-action shotgun with hand-stamped maker's marks on the receiver. Chambered in 12 gauge." + sawn_desc = "A pump shotgun with the stock and barrel hacked off. It kicks like a mule." + icon = 'voidcrew/modules/shiptest_guns/shotguns/icons/shotguns.dmi' + icon_state = "brimstone" + base_icon_state = "brimstone" + lefthand_file = 'voidcrew/modules/shiptest_guns/shotguns/icons/lefthand.dmi' + righthand_file = 'voidcrew/modules/shiptest_guns/shotguns/icons/righthand.dmi' + inhand_icon_state = "brimstone" + inhand_x_dimension = 32 + inhand_y_dimension = 32 + worn_icon = 'voidcrew/modules/shiptest_guns/shotguns/icons/back.dmi' + worn_icon_state = "brimstone" + accepted_magazine_type = /obj/item/ammo_box/magazine/internal/shot/brimstone + can_be_sawn_off = TRUE + rack_delay = 4 + spread = 4 + +/obj/item/gun/ballistic/shotgun/brimstone/Initialize(mapload) + . = ..() + AddComponent(/datum/component/automatic_fire, 0.1 SECONDS) + +/obj/item/gun/ballistic/shotgun/brimstone/sawoff(mob/user, obj/item/saw, handle_modifications = TRUE) + . = ..() + if(!.) + return + lefthand_file = initial(lefthand_file) + righthand_file = initial(righthand_file) + inhand_icon_state = "brimstone_sawn" + worn_icon_state = "brimstone_sawn" + weapon_weight = WEAPON_MEDIUM + spread = 25 + recoil = 3 + update_appearance() + +/// TRUE when the chamber can't fire but a pump would chamber a shell. +/obj/item/gun/ballistic/shotgun/brimstone/proc/waiting_on_pump() + return !can_shoot() && magazine?.ammo_count() + +/obj/item/gun/ballistic/shotgun/brimstone/on_autofire_start(mob/living/shooter) + if(!waiting_on_pump()) + return ..() + if(fire_cd || shooter.incapacitated || !can_trigger_gun(shooter)) + return FALSE + var/obj/item/bodypart/other_hand = shooter.has_hand_for_held_index(shooter.get_inactive_hand_index()) + if(weapon_weight == WEAPON_HEAVY && (shooter.get_inactive_held_item() || !other_hand)) + balloon_alert(shooter, "use both hands!") + return FALSE + return TRUE + +/obj/item/gun/ballistic/shotgun/brimstone/do_autofire(datum/source, atom/target, mob/living/shooter, allow_akimbo, params) + if(waiting_on_pump() && !shooter.incapacitated) + return COMPONENT_AUTOFIRE_SHOT_SUCCESS + return ..() + +/obj/item/ammo_box/magazine/internal/shot/brimstone + name = "pump shotgun tube" + ammo_type = /obj/item/ammo_casing/shotgun/buckshot + max_ammo = 5 diff --git a/voidcrew/modules/shiptest_guns/shotguns/icons/back.dmi b/voidcrew/modules/shiptest_guns/shotguns/icons/back.dmi new file mode 100644 index 0000000000000000000000000000000000000000..db4a5b41ddbf21dcdba5ea859c379e6aed6c1468 GIT binary patch literal 5247 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 Co)_u> literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/shotguns/icons/lefthand.dmi b/voidcrew/modules/shiptest_guns/shotguns/icons/lefthand.dmi new file mode 100644 index 0000000000000000000000000000000000000000..dd57ba30be44ee3f2212cbfefc17149b3066d748 GIT binary patch literal 5226 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/shotguns/icons/magazines.dmi b/voidcrew/modules/shiptest_guns/shotguns/icons/magazines.dmi new file mode 100644 index 0000000000000000000000000000000000000000..3f59a126d9de013264cc89816a98fcd25647a91d GIT binary patch literal 667 zcmV;M0%ZM(P)V=-0C=2JR&a84_w-Y6@%7{?OD!tS%+FJ>RWQ*r;NmRLOex6#a*U0*I5Sc+ z(=$pSoZ^zil2jm5sW>MwH#fB?J~uI4H?1@$M~RCwC9|j)C}qgSnO2mTn+joL)sUK7 zP*RCYhq8jJp9|Pc0DS%}@6NTNp8x;>*-1n}RCt{2na@hYKoG`fhX@KSBG^dOgDqaX z3D!$}2El_0f-j*@(6bNV19)nsmF<(8TYOf7AYE zH{Tb+Mu3Dw3nAe`NVp8u9~M*jq3{&#ihvoE2>T514tRS>z}Ditvw!#Y>Fm??pjj5N zjKF*KO1c9-*;p71{`%o#uz%Z?@r=O5>Ar-s{@_V;r%C5?T>ul{vfXr!Q)U`+xlLKG z-%rH_#Gfndj9bmozL&?I5SUbG#W}xM9If6MV7Vt>oxDpQ?ssRb&8XZlePAJ z7hL(X%B@NHMo@wCRLz0RWR?T}jB0dG05>1Q7REIOsMCg>&TFJGP~l@(8`f&Iz`8It zRS_p2$0?3u3{a;Hy$wbY0~J1o1;jN5sMCgCMG*rPK7vNgVhjwfml6(f@DUi~Hb^!! z&6TOIl#Ihi-8uBzkV888hKdbsvnR9J?4Na9`Pa=CDj!D3R_+p%H;>*|(RTZVZD~v= zU#L!i{~R?mt@S{ieDVKjEa5^(xDXO9jJkoS^#wGrCimw_+$sP7002ovPDHLkV1lU5 BHUIzs literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/shotguns/icons/righthand.dmi b/voidcrew/modules/shiptest_guns/shotguns/icons/righthand.dmi new file mode 100644 index 0000000000000000000000000000000000000000..f42d35b7e44a73d3176bf1e8a4d1235bd2292b67 GIT binary patch literal 5262 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/shotguns/icons/shotguns.dmi b/voidcrew/modules/shiptest_guns/shotguns/icons/shotguns.dmi new file mode 100644 index 0000000000000000000000000000000000000000..a1d31a28aaa58c26b23dbe7f4e3449f039a10c13 GIT binary patch literal 2631 zcmai$c{tRK7RP^>FljWFD7$7P8H|dTM94mt7?FKf2-#(Q?If~=WDR3yWbFGoDf_-F zBST|(l`*nj_dfTJ``mkt}X!JoB1}em9YzXDV(?}^ub&87=l0)U-=<#k>~D^ zVeH7<&$vnkBl*MC>Sajdvw~?q^mE=DJWf+tk`VVRig#=`Mlre3kB@T|q~FaDg4fJz zFhV_(ly#m-`xY?>(ZaQB;#grJlddOP-j_yCdhyo~_h^*lL_>eA!;{U=uUvlHJ)^-l3&s zZ0wGTGIg8Om?j@`mY_C$25SwQt*f6D*N5ccReu)R;bflAVViI-I!m@m{ZAVkf6Xl@ zvx~W1$JVe9sxMYmIBUbPD6v)qvu@${cy1X-mq+WniP)Q_(!`#zOtdlK3iQp|_?G`d zSXGM|)Cx?qg(Bb|Y>{;CA52x`sJ7aP3}gqs@;{Ej-=<*m@Y8C@QvT|ic=;I~aM1K3 zr(?X&mNQ9XxE))9%(foN1W;nns~VV+^BYot*|_Mt+h$PzUOC%5QXtw7y#nb& zG-bo5^70u^e!dBVr6m_}m6lL75ig6ksnq$@38IYR!ev*PTr%F_uMrtume-M_1iE_ z7+Sb|VST;X>~hf9UVedFW(6YBhJcg3?@8mL!!L}V^0eV!tp zFpG@(E6PJJWyijFVJHwTgKoXGzsu~=EeF0q&KHA+WZ!v{y(7q zgWo=9>JFpaYg5F6+jI8z*GI;pS*XKi%OCi6>wNEVD`)coT7#3?HO<{3{@h;*_rXYE zJC(KOBstE}RX*xx?m*SD$t2tN8tnK_Ch+!lFQQt^8ISMf7dU^aYFiDx%^QR$+{GeT ziT2KEX|xO7mU(noUhUNkf)r8&FsrtoTF&9{LxRs zGQ(=Jk-lnOmK#}CU)(c2mzTKkiC(^@CA)*K81CvFW4*%9N`ctVI1b2l1fm*p8YCqp z>yjL;kr2uwmDaq^7$;S!a@77IYfPBHW#={h1y+ZN1MnghZy2chiZ096H|AyqOUzTU zGJO#szh3Eaw3cP>1Ln<>lkg@bHb>L9%wOxuh6bNgXTnp`{Nh#6QZt<-M#Z(MtF^{a z@s0Hxwk$ntEL>SeL82d{En4gzzOLYi7*gJ{+h1_7hT@K@#8O-@pC&dfZ=i$Y7K0^L zVqg=5V^Zvcl!nrnBIA%GIF7p|n{D-5%v{CAlyEi^jL;*AQK!8W1(`ENK5JD`_Usu< zhF~!4ufX+(F=e{z7iE8%zQ2|)vToM1?8!QaN@=-VgBB?7tG!(z|i!7u36B2^(f@AAD?J@ z;LAycCp7^^LK8D8UhFs3_c|yQN*^s_mLynd^!N7p`y`B&_S9=R3h3!UYxNhL2S`(! zYv;dCG0u5{z0=_XWi$sA3CBJnl!iyE78{iDuS!-zr0VWoj3w%bOuE;@CTJr$(%$}K z2w!K^(!Ar+4$)A9S^kU3{X0X7v(n?Uv+sQ9G2iI_(-Qyv`k#)O63DK0>2=SAYaN*5 z(IN}4Ig_(KI~1V{jFgLT_SM^^$>k0qd&?o!A%k~gu4(`!#ne6Tc~>HHr>mMPpBJr^ zzIDq3MK9L3?YDjnc}|4QU#Ft)7ofr&gy@r@0XacTBIUF-1`H)@-+3~g!o^R$)(gz< zJ8%%DeH3-#g;FdmG*S<}SS#rZC!pe)K)SyZ4SH|0Nl8}#nOHrNR*WA zJ(}I_;nq~<6xUX=eL`1~V?u7~$4pymJt?n!2i={^wxAKsaFb-c{3)?Ywyv8UIZCwN zO!t{UJ214EI&fCQV4bFJ%$GOm>d2}tE1RTXC?+WRE}VzAE3H}j&}Br42F<(p>!`OO z=G%O(OK6y`{5Gw?;}3$8;zWjm2;MGbWIsC^EZ@n(8^oRQ--3$KTJU;=4^r&ft?2V) O2&gM-D^)65hW-srjppqD literal 0 HcmV?d00001 diff --git a/voidcrew/modules/shiptest_guns/shotguns/lever_actions.dm b/voidcrew/modules/shiptest_guns/shotguns/lever_actions.dm new file mode 100644 index 00000000000..64f5386e05c --- /dev/null +++ b/voidcrew/modules/shiptest_guns/shotguns/lever_actions.dm @@ -0,0 +1,118 @@ +// Lever-action guns ported from Shiptest (shiptest-ss13/Shiptest, AGPL-3.0 code, CC BY-SA 3.0 art). +// Art credits are in attribution.txt beside icons/. Sounds are our own stock and gun-feel files. +// +// They sit on tg's pump shotgun: a tube magazine and a manual cycle per shot, with "lever" wording. +// Racking one held in one hand, or a sawn-off one, spins the gun once. + +/obj/item/gun/ballistic/shotgun/flaming_arrow + name = "\improper HP Flaming Arrow" + desc = "A light lever-action rifle with hand-stamped maker's marks on the receiver. Chambered in .38." + sawn_desc = "A lever-action rifle sawn down to pistol size. It looks great, which was the point." + icon = 'voidcrew/modules/shiptest_guns/shotguns/icons/shotguns.dmi' + icon_state = "flamingarrow" + base_icon_state = "flamingarrow" + lefthand_file = 'voidcrew/modules/shiptest_guns/shotguns/icons/lefthand.dmi' + righthand_file = 'voidcrew/modules/shiptest_guns/shotguns/icons/righthand.dmi' + inhand_icon_state = "flamingarrow" + inhand_x_dimension = 32 + inhand_y_dimension = 32 + worn_icon = 'voidcrew/modules/shiptest_guns/shotguns/icons/back.dmi' + worn_icon_state = "flamingarrow" + accepted_magazine_type = /obj/item/ammo_box/magazine/internal/shot/flaming_arrow + fire_sound = 'sound/items/weapons/gun/revolver/shot.ogg' + rack_sound = 'voidcrew/modules/gun_feel/sound/rifle/ar_cock.ogg' + load_sound = 'sound/items/weapons/gun/revolver/load_bullet.ogg' + bolt_wording = "lever" + cartridge_wording = "bullet" + can_be_sawn_off = TRUE + weapon_weight = WEAPON_MEDIUM + // A rifle, not a shotgun: no point-blank shove. + pb_knockback = 0 + // A /shotgun subtype would pick the shotgun kick. This is a pistol-calibre carbine. + gun_feel_class = GUN_FEEL_CLASS_RIFLE + /// Spread once sawn off. + var/sawn_spread = 6 + /// Recoil once sawn off. + var/sawn_recoil = 1 + /// Tube capacity once sawn off. Null keeps the full tube. + var/sawn_tube_capacity = 7 + +/obj/item/gun/ballistic/shotgun/flaming_arrow/rack(mob/user = null) + . = ..() + if(user && (sawn_off || user.get_inactive_held_item() || !user.has_hand_for_held_index(user.get_inactive_hand_index()))) + SpinAnimation(7, 1) + +/obj/item/gun/ballistic/shotgun/flaming_arrow/sawoff(mob/user, obj/item/saw, handle_modifications = TRUE) + . = ..() + if(!.) + return + // tg's sawoff swaps in its own generic inhands; ours have sawn-off states. + lefthand_file = initial(lefthand_file) + righthand_file = initial(righthand_file) + inhand_icon_state = "[base_icon_state]_sawn" + worn_icon_state = "[base_icon_state]_sawn" + weapon_weight = WEAPON_MEDIUM + spread = sawn_spread + recoil = sawn_recoil + if(sawn_tube_capacity && magazine) + magazine.max_ammo = sawn_tube_capacity + var/atom/drop_turf = drop_location() + while(length(magazine.stored_ammo) > magazine.max_ammo) + var/obj/item/ammo_casing/extra = magazine.get_round() + if(!extra) + break + magazine.stored_ammo -= extra + extra.forceMove(drop_turf) + extra.bounce_away(FALSE, NONE) + magazine.update_appearance() + update_appearance() + +/obj/item/ammo_box/magazine/internal/shot/flaming_arrow + name = "lever-action rifle tube" + ammo_type = /obj/item/ammo_casing/c38 + caliber = CALIBER_38 + max_ammo = 18 + +/obj/item/gun/ballistic/shotgun/flaming_arrow/absolution + name = "\improper HP Absolution" + desc = "A big lever-action rifle with hand-stamped maker's marks on the receiver, built for game that bites back. Chambered in .357." + sawn_desc = "A big lever-action rifle sawn down for the holster. You should probably have bought a revolver." + icon_state = "absolution" + base_icon_state = "absolution" + inhand_icon_state = "absolution" + worn_icon_state = "absolution" + accepted_magazine_type = /obj/item/ammo_box/magazine/internal/shot/absolution + fire_sound = 'sound/items/weapons/gun/revolver/shot_alt.ogg' + sawn_tube_capacity = null + +/obj/item/ammo_box/magazine/internal/shot/absolution + name = "big lever-action rifle tube" + ammo_type = /obj/item/ammo_casing/c357 + caliber = CALIBER_357 + max_ammo = 8 + +/obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration + name = "\improper HP Conflagration" + desc = "A light lever-action shotgun with hand-stamped maker's marks on the receiver. Quick on the lever. Chambered in 12 gauge." + sawn_desc = "A lever-action shotgun sawn down to a hand cannon. It spins nicely." + icon_state = "conflagration" + base_icon_state = "conflagration" + inhand_icon_state = "conflagration" + worn_icon_state = "conflagration" + accepted_magazine_type = /obj/item/ammo_box/magazine/internal/shot/conflagration + fire_sound = 'sound/items/weapons/gun/shotgun/shot.ogg' + load_sound = 'sound/items/weapons/gun/shotgun/insert_shell.ogg' + cartridge_wording = "shell" + rack_delay = 4 + spread = 4 + projectile_damage_multiplier = 1.75 + pb_knockback = 2 + gun_feel_class = GUN_FEEL_CLASS_SHOTGUN + sawn_spread = 11 + sawn_recoil = 2 + sawn_tube_capacity = 5 + +/obj/item/ammo_box/magazine/internal/shot/conflagration + name = "lever-action shotgun tube" + ammo_type = /obj/item/ammo_casing/shotgun/buckshot + max_ammo = 6 diff --git a/voidcrew/modules/shiptest_guns/shotguns/slammer.dm b/voidcrew/modules/shiptest_guns/shotguns/slammer.dm new file mode 100644 index 00000000000..8b949612be3 --- /dev/null +++ b/voidcrew/modules/shiptest_guns/shotguns/slammer.dm @@ -0,0 +1,43 @@ +// The Slammer, ported from Shiptest (shiptest-ss13/Shiptest, AGPL-3.0 code, CC BY-SA 3.0 art). +// A pump shotgun that feeds from box magazines: pump between shots, swap magazines to reload. + +/obj/item/gun/ballistic/shotgun/slammer + name = "\improper Slammer" + desc = "A dated riot shotgun that feeds from box magazines and still has to be pumped. Chambered in 12 gauge." + icon = 'voidcrew/modules/shiptest_guns/shotguns/icons/shotguns.dmi' + icon_state = "slammer" + base_icon_state = "slammer" + lefthand_file = 'voidcrew/modules/shiptest_guns/shotguns/icons/lefthand.dmi' + righthand_file = 'voidcrew/modules/shiptest_guns/shotguns/icons/righthand.dmi' + inhand_icon_state = "slammer" + inhand_x_dimension = 32 + inhand_y_dimension = 32 + worn_icon = 'voidcrew/modules/shiptest_guns/shotguns/icons/back.dmi' + worn_icon_state = "slammer" + accepted_magazine_type = /obj/item/ammo_box/magazine/m12g_slammer + internal_magazine = FALSE + tac_reloads = TRUE + mag_display = TRUE + load_sound = 'voidcrew/modules/gun_feel/sound/rifle/ar_reload.ogg' + load_empty_sound = 'voidcrew/modules/gun_feel/sound/rifle/ar_reload.ogg' + eject_sound = 'voidcrew/modules/gun_feel/sound/rifle/ar_unload.ogg' + eject_empty_sound = 'voidcrew/modules/gun_feel/sound/rifle/ar_unload.ogg' + weapon_weight = WEAPON_MEDIUM + rack_delay = 4 + spread = 4 + projectile_damage_multiplier = 1.75 + +/obj/item/ammo_box/magazine/m12g_slammer + name = "slammer box magazine (12g buckshot)" + desc = "A six-shell box magazine for the Slammer." + icon = 'voidcrew/modules/shiptest_guns/shotguns/icons/magazines.dmi' + icon_state = "slammer_mag-full" + base_icon_state = "slammer_mag" + ammo_type = /obj/item/ammo_casing/shotgun/buckshot + caliber = CALIBER_SHOTGUN + max_ammo = 6 + multiple_sprites = AMMO_BOX_FULL_EMPTY + multiple_sprite_use_base = TRUE + +/obj/item/ammo_box/magazine/m12g_slammer/empty + start_empty = TRUE diff --git a/voidcrew/modules/trade/shop_catalog_black_market.dm b/voidcrew/modules/trade/shop_catalog_black_market.dm index 624c57f31dc..0f41770db06 100644 --- a/voidcrew/modules/trade/shop_catalog_black_market.dm +++ b/voidcrew/modules/trade/shop_catalog_black_market.dm @@ -70,6 +70,11 @@ /datum/shop_sku/black_market/ansem, /datum/shop_sku/black_market/ansem_sc, /datum/shop_sku/black_market/lethal_cycler, + /datum/shop_sku/black_market/brimstone, + /datum/shop_sku/black_market/flaming_arrow, + /datum/shop_sku/black_market/slammer, + /datum/shop_sku/black_market/absolution, + /datum/shop_sku/black_market/conflagration, /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/black_market/pistol_mag, /datum/shop_sku/black_market/speedloader, @@ -971,6 +976,67 @@ fixed_price = TRUE contract_reward = FALSE +/datum/shop_sku/black_market/brimstone + category = "Weapons" + desc = "A plain pump-action shotgun. Chambered in 12 gauge." + item_path = /obj/item/gun/ballistic/shotgun/brimstone + price_credits = 900 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/black_market/flaming_arrow + category = "Weapons" + desc = "A light lever-action rifle. Chambered in .38." + item_path = /obj/item/gun/ballistic/shotgun/flaming_arrow + price_credits = 1200 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/black_market/slammer + zone_permission = ZONE_PERMISSION_YELLOW + category = "Weapons" + desc = "A pump shotgun that feeds from box magazines. Chambered in 12 gauge." + item_path = /obj/item/gun/ballistic/shotgun/slammer + price_credits = 2400 + price_vouchers = 1 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/black_market/absolution + zone_permission = ZONE_PERMISSION_YELLOW + category = "Weapons" + desc = "A big lever-action rifle. Chambered in .357." + item_path = /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution + price_credits = 2600 + price_vouchers = 1 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/black_market/conflagration + zone_permission = ZONE_PERMISSION_YELLOW + category = "Weapons" + desc = "A lever-action shotgun. Chambered in 12 gauge." + item_path = /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration + price_credits = 2200 + price_vouchers = 1 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + /// Counter-bought caches retain themed supplies, with a single freight weapon roll. /// Kept separate from guarded caches so buying a crate cannot buy advanced guns. /obj/structure/closet/crate/zone_loot/syndicate/trader diff --git a/voidcrew/modules/trade/shop_catalog_outfitter.dm b/voidcrew/modules/trade/shop_catalog_outfitter.dm index d525a7f22c2..f7c1f86f955 100644 --- a/voidcrew/modules/trade/shop_catalog_outfitter.dm +++ b/voidcrew/modules/trade/shop_catalog_outfitter.dm @@ -69,6 +69,7 @@ /datum/shop_sku/outfitter/wt550_mag, /datum/shop_sku/outfitter/rapier_mag, /datum/shop_sku/outfitter/zashch_mag, + /datum/shop_sku/outfitter/slammer_mag, /datum/shop_sku/outfitter/firing_pin, /datum/shop_sku/outfitter/riot_shotgun, // Melee @@ -355,6 +356,15 @@ stock_min = 10 stock_max = 20 +/datum/shop_sku/outfitter/slammer_mag + category = "Firearms & Ammo" + name = "slammer magazine" + desc = "A loaded six-shell box magazine for a Slammer." + item_path = /obj/item/ammo_box/magazine/m12g_slammer + price_credits = 250 + stock_min = 10 + stock_max = 20 + /datum/shop_sku/outfitter/firing_pin category = "Firearms & Ammo" item_path = /obj/item/firing_pin diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 50dac2a5a37..0afb9e7d4a3 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -217,6 +217,7 @@ #include "voidcrew_ship_silicons.dm" #include "voidcrew_ship_starting_guns.dm" #include "voidcrew_ship_turret_targeting.dm" +#include "voidcrew_shiptest_shotguns.dm" #include "voidcrew_shop_catalog.dm" #include "voidcrew_silicon_ship_systems.dm" #include "voidcrew_simple_mob_ai.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_loot.dm b/voidcrew/modules/unit_tests/voidcrew_loot.dm index bc6763d74fb..107a6816a37 100644 --- a/voidcrew/modules/unit_tests/voidcrew_loot.dm +++ b/voidcrew/modules/unit_tests/voidcrew_loot.dm @@ -291,7 +291,7 @@ /// A working copy may be consumed without damaging another crate's singleton tables. /datum/unit_test/voidcrew_cache_weapon_pools/Run() - var/list/main_counts = list("armory" = list(3, 4, 5), "syndicate" = list(2, 3, 3), "plunder" = list(3, 2, 3), "freight" = list(4, 2)) + var/list/main_counts = list("armory" = list(3, 4, 5), "syndicate" = list(2, 4, 3), "plunder" = list(5, 4, 3), "freight" = list(4, 2)) for(var/family in main_counts) var/list/counts = main_counts[family] var/list/classes = GLOB.loot_gun_pools[family] @@ -709,9 +709,11 @@ /obj/item/gun/ballistic/rifle/boltaction/pipegun/pistol, /obj/item/gun/ballistic/rifle/boltaction/surplus, /obj/item/gun/ballistic/rifle/nt_m96, + /obj/item/gun/ballistic/shotgun/brimstone, /obj/item/gun/ballistic/shotgun/doublebarrel, /obj/item/gun/ballistic/shotgun/doublebarrel/breacherslug, /obj/item/gun/ballistic/shotgun/doublebarrel/slugs, + /obj/item/gun/ballistic/shotgun/flaming_arrow, /obj/item/gun/ballistic/shotgun/musket, /obj/item/gun/ballistic/shotgun/toy, /obj/item/gun/ballistic/shotgun/toy/crossbow, @@ -764,8 +766,11 @@ /obj/item/gun/ballistic/rifle/rebarxbow/forced, /obj/item/gun/ballistic/shotgun, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, + /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution, + /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration, /obj/item/gun/ballistic/shotgun/lethal, /obj/item/gun/ballistic/shotgun/musket/no_quarter, + /obj/item/gun/ballistic/shotgun/slammer, /obj/item/gun/ballistic/shotgun/riot, /obj/item/gun/energy/e_gun, /obj/item/gun/energy/e_gun/dragnet, diff --git a/voidcrew/modules/unit_tests/voidcrew_shiptest_shotguns.dm b/voidcrew/modules/unit_tests/voidcrew_shiptest_shotguns.dm new file mode 100644 index 00000000000..dbe112831c0 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_shiptest_shotguns.dm @@ -0,0 +1,121 @@ +// voidcrew/modules/shiptest_guns/shotguns. Voidcrew defines are not visible here, so values are literals. + +/// Every sprite the guns and the Slammer magazine draw exists, in hand, on the back and sawn off. +/datum/unit_test/voidcrew_shiptest_shotguns_icons +/datum/unit_test/voidcrew_shiptest_shotguns_icons/Run() + var/list/guns = list( + /obj/item/gun/ballistic/shotgun/brimstone = TRUE, + /obj/item/gun/ballistic/shotgun/flaming_arrow = TRUE, + /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution = TRUE, + /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration = TRUE, + /obj/item/gun/ballistic/shotgun/slammer = FALSE, + ) + for(var/gun_path in guns) + var/obj/item/gun/ballistic/gun = allocate(gun_path) + var/list/states = list(gun.base_icon_state) + if(guns[gun_path]) + TEST_ASSERT(gun.can_be_sawn_off, "[gun_path] should saw off") + states += "[gun.base_icon_state]_sawn" + for(var/state in states) + TEST_ASSERT(state in icon_states(gun.icon), "[gun_path] has no icon state [state]") + TEST_ASSERT(state in icon_states(gun.lefthand_file), "[gun_path] has no left hand state [state]") + TEST_ASSERT(state in icon_states(gun.righthand_file), "[gun_path] has no right hand state [state]") + TEST_ASSERT(state in icon_states(gun.worn_icon), "[gun_path] has no worn state [state]") + TEST_ASSERT("slammer_mag" in icon_states('voidcrew/modules/shiptest_guns/shotguns/icons/shotguns.dmi'), "The Slammer has no magazine overlay") + var/obj/item/ammo_box/magazine/m12g_slammer/full = allocate(/obj/item/ammo_box/magazine/m12g_slammer) + TEST_ASSERT_EQUAL(full.icon_state, "slammer_mag-full", "A full Slammer magazine draws the wrong state") + TEST_ASSERT(full.icon_state in icon_states(full.icon), "The full Slammer magazine sprite is missing") + var/obj/item/ammo_box/magazine/m12g_slammer/empty/empty = allocate(/obj/item/ammo_box/magazine/m12g_slammer/empty) + TEST_ASSERT_EQUAL(empty.ammo_count(), 0, "The empty Slammer magazine is not empty") + TEST_ASSERT_EQUAL(empty.icon_state, "slammer_mag-empty", "An empty Slammer magazine draws the wrong state") + TEST_ASSERT(empty.icon_state in icon_states(empty.icon), "The empty Slammer magazine sprite is missing") + +/// Each gun kicks like what it is, holds the right rounds and keeps lever wording off the pumps. +/datum/unit_test/voidcrew_shiptest_shotguns_setup +/datum/unit_test/voidcrew_shiptest_shotguns_setup/Run() + // path = list(feel class, chambered round type, rounds in the tube or magazine, action wording) + var/list/expected = list( + /obj/item/gun/ballistic/shotgun/brimstone = list("shotgun", /obj/item/ammo_casing/shotgun/buckshot, 4, "pump"), + /obj/item/gun/ballistic/shotgun/slammer = list("shotgun", /obj/item/ammo_casing/shotgun/buckshot, 5, "pump"), + /obj/item/gun/ballistic/shotgun/flaming_arrow = list("rifle", /obj/item/ammo_casing/c38, 17, "lever"), + /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution = list("rifle", /obj/item/ammo_casing/c357, 7, "lever"), + /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration = list("shotgun", /obj/item/ammo_casing/shotgun/buckshot, 5, "lever"), + ) + for(var/gun_path in expected) + var/list/row = expected[gun_path] + var/obj/item/gun/ballistic/gun = allocate(gun_path) + gun.gun_feel_resolve() + TEST_ASSERT_EQUAL(gun.gun_feel_class, row[1], "[gun_path] has the wrong feel class") + TEST_ASSERT_EQUAL(gun.chambered?.type, row[2], "[gun_path] chambers the wrong round") + TEST_ASSERT_EQUAL(gun.magazine.ammo_count(), row[3], "[gun_path] holds the wrong number of rounds") + TEST_ASSERT_EQUAL(gun.bolt_wording, row[4], "[gun_path] has the wrong action wording") + TEST_ASSERT(!length(gun.actions_types), "[gun_path] has an action button") + // The .38 and .357 rifles don't shove point-blank like a shotgun does. + for(var/rifle_path in list(/obj/item/gun/ballistic/shotgun/flaming_arrow, /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution)) + var/obj/item/gun/ballistic/rifle = allocate(rifle_path) + TEST_ASSERT_EQUAL(rifle.pb_knockback, 0, "[rifle_path] shoves point-blank like a shotgun") + TEST_ASSERT_EQUAL(rifle.cartridge_wording, "bullet", "[rifle_path] calls its rounds shells") + +/// The Slammer takes its box magazine, still has to be pumped and swaps magazines in one go. +/datum/unit_test/voidcrew_shiptest_slammer +/datum/unit_test/voidcrew_shiptest_slammer/Run() + var/obj/item/gun/ballistic/shotgun/slammer/slammer = allocate(/obj/item/gun/ballistic/shotgun/slammer) + TEST_ASSERT(!slammer.internal_magazine, "The Slammer should take a detachable magazine") + TEST_ASSERT(!slammer.semi_auto, "The Slammer should be pumped between shots") + TEST_ASSERT(slammer.tac_reloads, "The Slammer should swap magazines in one go") + TEST_ASSERT_EQUAL(slammer.magazine.type, /obj/item/ammo_box/magazine/m12g_slammer, "The Slammer spawned with the wrong magazine") + TEST_ASSERT_EQUAL(slammer.magazine.max_ammo, 6, "The Slammer magazine is the wrong size") + TEST_ASSERT(istype(allocate(/obj/item/ammo_box/magazine/m12g_slammer), slammer.accepted_magazine_type), "The Slammer refuses its own magazine") + TEST_ASSERT(!slammer.can_be_sawn_off, "The Slammer should not saw off") + +/// Slamfire: with the trigger held, the Brimstone waits on the pump instead of letting go, and fires once racked. +/datum/unit_test/voidcrew_shiptest_brimstone_slamfire +/datum/unit_test/voidcrew_shiptest_brimstone_slamfire/Run() + var/mob/living/carbon/human/shooter = allocate(/mob/living/carbon/human/consistent) + var/obj/item/gun/ballistic/shotgun/brimstone/brimstone = allocate(/obj/item/gun/ballistic/shotgun/brimstone) + TEST_ASSERT_NOTNULL(brimstone.GetComponent(/datum/component/automatic_fire), "The Brimstone has no held-trigger fire") + shooter.put_in_active_hand(brimstone) + var/turf/target = locate(shooter.x + 3, shooter.y, shooter.z) + // Spend the chambered shell the way a shot would, but leave the tube loaded. + var/obj/item/ammo_casing/spent = brimstone.chambered + brimstone.chambered = null + qdel(spent) + TEST_ASSERT(brimstone.waiting_on_pump(), "An empty chamber with shells in the tube should wait on the pump") + TEST_ASSERT(brimstone.on_autofire_start(shooter), "Holding the trigger on a spent chamber should start waiting") + TEST_ASSERT_EQUAL(brimstone.do_autofire(null, target, shooter, FALSE, null), COMPONENT_AUTOFIRE_SHOT_SUCCESS, "The held trigger let go while waiting on the pump") + brimstone.rack(shooter) + TEST_ASSERT(brimstone.can_shoot(), "Pumping did not chamber a live shell") + TEST_ASSERT(!brimstone.waiting_on_pump(), "A live shell should fire, not wait") + // Nothing left anywhere: it lets go of the trigger like any empty automatic. + brimstone.magazine.stored_ammo.Cut() + spent = brimstone.chambered + brimstone.chambered = null + qdel(spent) + TEST_ASSERT(!brimstone.waiting_on_pump(), "An empty gun should not wait on the pump") + TEST_ASSERT_EQUAL(brimstone.do_autofire(null, target, shooter, FALSE, null), NONE, "An empty Brimstone kept the trigger held") + +/// Every gun has a way into a crew's hands and a spare reload in caches; the Slammer magazine is sold. +/datum/unit_test/voidcrew_shiptest_shotguns_routes +/datum/unit_test/voidcrew_shiptest_shotguns_routes/Run() + // gun = list(black market SKU, loot pool, loot class) + var/list/routes = list( + /obj/item/gun/ballistic/shotgun/brimstone = list(/datum/shop_sku/black_market/brimstone, "plunder", "1"), + /obj/item/gun/ballistic/shotgun/flaming_arrow = list(/datum/shop_sku/black_market/flaming_arrow, "plunder", "1"), + /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution = list(/datum/shop_sku/black_market/absolution, "plunder", "2"), + /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration = list(/datum/shop_sku/black_market/conflagration, "plunder", "2"), + /obj/item/gun/ballistic/shotgun/slammer = list(/datum/shop_sku/black_market/slammer, "syndicate", "2"), + ) + var/datum/outpost_shop/black_market = allocate(/datum/outpost_shop/black_market) + for(var/gun_path in routes) + var/list/row = routes[gun_path] + TEST_ASSERT(row[1] in black_market.sku_types, "[gun_path] is not on the black market shelf") + var/datum/shop_sku/sku = allocate(row[1]) + TEST_ASSERT_EQUAL(sku.item_path, gun_path, "[row[1]] sells the wrong gun") + var/list/pool = GLOB.loot_gun_pools[row[2]][row[3]] + TEST_ASSERT(gun_path in pool, "[gun_path] is missing from the [row[2]] loot class [row[3]]") + TEST_ASSERT_NOTNULL(GLOB.loot_gun_spare_ammo[gun_path], "[gun_path] has no spare reload in caches") + TEST_ASSERT_EQUAL(GLOB.loot_gun_spare_ammo[/obj/item/gun/ballistic/shotgun/slammer], /obj/item/ammo_box/magazine/m12g_slammer, "A cached Slammer should come with a spare magazine") + var/datum/outpost_shop/outfitter = allocate(/datum/outpost_shop/outfitter) + TEST_ASSERT(/datum/shop_sku/outfitter/slammer_mag in outfitter.sku_types, "The outfitter does not stock Slammer magazines") + var/datum/shop_sku/mag_sku = allocate(/datum/shop_sku/outfitter/slammer_mag) + TEST_ASSERT_EQUAL(mag_sku.item_path, /obj/item/ammo_box/magazine/m12g_slammer, "The Slammer magazine shelf sells the wrong thing") diff --git a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm index cbf95698768..c03049ba750 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shop_catalog.dm @@ -348,6 +348,11 @@ list(/datum/shop_sku/black_market/ansem, /obj/item/gun/ballistic/automatic/pistol/clandestine, 1, 2000, 1), list(/datum/shop_sku/black_market/ansem_sc, /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher, 1, 2800, 1), list(/datum/shop_sku/black_market/lethal_cycler, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, 1, 2400, 1), + list(/datum/shop_sku/black_market/brimstone, /obj/item/gun/ballistic/shotgun/brimstone, 1, 900, 0), + list(/datum/shop_sku/black_market/flaming_arrow, /obj/item/gun/ballistic/shotgun/flaming_arrow, 1, 1200, 0), + list(/datum/shop_sku/black_market/slammer, /obj/item/gun/ballistic/shotgun/slammer, 1, 2400, 1), + list(/datum/shop_sku/black_market/absolution, /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution, 1, 2600, 1), + list(/datum/shop_sku/black_market/conflagration, /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration, 1, 2200, 1), list(/datum/shop_sku/black_market/rotating/mateba, /obj/item/gun/ballistic/revolver/mateba, 1, 3000, 1), ), /datum/outpost_shop/vendor/ripperdoc = list( @@ -405,6 +410,11 @@ /obj/item/gun/ballistic/automatic/pistol/clandestine, /obj/item/gun/ballistic/automatic/pistol/clandestine/fisher, /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly, + /obj/item/gun/ballistic/shotgun/brimstone, + /obj/item/gun/ballistic/shotgun/flaming_arrow, + /obj/item/gun/ballistic/shotgun/slammer, + /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution, + /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration, /obj/item/gun/ballistic/revolver/mateba, ) var/list/printed_gear = list( @@ -524,6 +534,7 @@ /datum/shop_sku/black_market/rapier, /datum/shop_sku/black_market/zashch, /datum/shop_sku/black_market/nt_m96, /datum/shop_sku/black_market/battle_rifle, /datum/shop_sku/black_market/ansem, /datum/shop_sku/black_market/ansem_sc, /datum/shop_sku/black_market/lethal_cycler, + /datum/shop_sku/black_market/brimstone, /datum/shop_sku/black_market/flaming_arrow, /datum/shop_sku/black_market/slammer, /datum/shop_sku/black_market/absolution, /datum/shop_sku/black_market/conflagration, /datum/shop_sku/black_market/cutlass, /datum/shop_sku/black_market/rare/energy_sword, /datum/shop_sku/ripperdoc/monowire, /datum/shop_sku/ripperdoc/gorilla, /datum/shop_sku/ripperdoc/mantis, /datum/shop_sku/general/rotating/freight_crate, /datum/shop_sku/black_market/mystery_cache, /datum/shop_sku/black_market/mystery_cache_rare, diff --git a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm index 825dffa816a..0d1fa7cf513 100644 --- a/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm +++ b/voidcrew/modules/unit_tests/voidcrew_zone_shops.dm @@ -36,6 +36,9 @@ /datum/shop_sku/black_market/ansem = 1, /datum/shop_sku/black_market/ansem_sc = 1, /datum/shop_sku/black_market/lethal_cycler = 1, + /datum/shop_sku/black_market/slammer = 1, + /datum/shop_sku/black_market/absolution = 1, + /datum/shop_sku/black_market/conflagration = 1, /datum/shop_sku/outfitter/rare/ablative_vest = 1, /datum/shop_sku/skunk/rotating/thermal_glasses = 1, /datum/shop_sku/black_market/thermals = 1, @@ -131,7 +134,7 @@ /datum/unit_test/voidcrew_zone_shops/catalog/Run() var/list/expected = expected_tags() - TEST_ASSERT_EQUAL(length(expected), 101, "The retained permission catalog is incomplete") + TEST_ASSERT_EQUAL(length(expected), 104, "The retained permission catalog is incomplete") var/datum/voidcrew_crew_record/crew = allocate(/datum/voidcrew_crew_record) for(var/datum/shop_sku/sku_type as anything in subtypesof(/datum/shop_sku)) var/permission = expected[sku_type] || 0 From 1f25ff13d569e37b0d89c1a2b0ebea05923b6540 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:27:18 -0800 Subject: [PATCH 199/472] Tidy the Gorilla Arms gore visuals and sounds Finishers lay their own blood: the backbreaker, the helmet and frame hits throw no spray across the room, a broken back bleeds from the mouth and pools where he lands, and the limb rip sprays one line back at the bearer. The head clap bursts with gibs piled round the head, one short line of blood, and the eyes, ears and brain landing close by. The backbreaker holds the body clear above the bearer's head and keeps the fold over the knee for a moment before rolling it off. The clap holds the body in front, and in a side view both arms close on the head. A bleeding body thrown by the Gorilla leaves a ragged smear of drops, and flies without spinning. The body no longer slides down a wall it hits. The arms keep their worn look: no tint, glow or steam on any move, and no hiss. New sounds for the piston punch, the haymaker and the backbreaker. --- voidcrew/_DEFINES/gorilla_gore.dm | 20 +- .../chrome_puppet_rig.dm | 34 ++- .../hyperviolence_gorilla/gorilla_finisher.dm | 219 ++++++++++++++---- .../hyperviolence_gorilla/gorilla_haymaker.dm | 47 +--- .../hyperviolence_gorilla/gorilla_looks.dm | 43 +--- .../hyperviolence_gorilla/gorilla_pound.dm | 7 +- .../hyperviolence_gorilla/gorilla_pry.dm | 9 +- .../hyperviolence_gorilla/gorilla_punch.dm | 30 ++- .../hyperviolence_gorilla/gorilla_throw.dm | 58 +++-- .../sound/gorilla_backbreaker.ogg | Bin 0 -> 11043 bytes .../sound/gorilla_haymaker.ogg | Bin 0 -> 11664 bytes .../sound/gorilla_piston_punch.ogg | Bin 0 -> 8257 bytes .../sound/gorilla_piston_punch2.ogg | Bin 0 -> 8288 bytes .../sound/gorilla_piston_punch3.ogg | Bin 0 -> 8346 bytes .../unit_tests/voidcrew_gorilla_gore.dm | 5 +- 15 files changed, 288 insertions(+), 184 deletions(-) create mode 100644 voidcrew/modules/hyperviolence_gorilla/sound/gorilla_backbreaker.ogg create mode 100644 voidcrew/modules/hyperviolence_gorilla/sound/gorilla_haymaker.ogg create mode 100644 voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch.ogg create mode 100644 voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch2.ogg create mode 100644 voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch3.ogg diff --git a/voidcrew/_DEFINES/gorilla_gore.dm b/voidcrew/_DEFINES/gorilla_gore.dm index 34bdd471044..8279929eadd 100644 --- a/voidcrew/_DEFINES/gorilla_gore.dm +++ b/voidcrew/_DEFINES/gorilla_gore.dm @@ -89,6 +89,8 @@ #define GORILLA_THROW_SHORT 3 /// Most decals a Gorilla throw leaves along its path. #define GORILLA_TRAIL_MAX 4 +/// Most blood drops and splats one tile of a bleeding body's skid gets. +#define GORILLA_SKID_SPLATS 4 // What a wall impact draws, by the injury it caused (owner D38). /// No skin broken: a dent and chips. @@ -118,7 +120,19 @@ #define GORILLA_BACK_ARMOUR 40 /// How long a broken back's fold stays drawn after the finisher. #define GORILLA_FOLD_TIME (10 SECONDS) - -/// Offset and filter sources. -#define GORILLA_WALL_SAG "gorilla_wall_sag" +/// The head clap's burst: one short line of blood this many tiles long. +#define GORILLA_CLAP_SPRAY_TILES 2 +/// How far the head clap throws the brain (the eyes, ears and tongue drop on the spot or a tile out). +#define GORILLA_CLAP_BRAIN_MIN 1 +#define GORILLA_CLAP_BRAIN_MAX 2 +/// How long a broken back stays folded over the knee before it is rolled off. +#define GORILLA_BACK_FOLD_HOLD (0.3 SECONDS) + +// The landed piston punch, the haymaker's hit and the backbreaker's break (gorilla_punch.dm's sound procs). +/// A sound key (/datum/sound_effect/gorilla_piston_punch): one of three punches at random. +#define GORILLA_PUNCH_SOUND "gorilla_piston_punch" +#define GORILLA_HAYMAKER_SOUND 'voidcrew/modules/hyperviolence_gorilla/sound/gorilla_haymaker.ogg' +#define GORILLA_BACKBREAKER_SOUND 'voidcrew/modules/hyperviolence_gorilla/sound/gorilla_backbreaker.ogg' + +/// Filter sources. #define GORILLA_PUPPET_HIDE "gorilla_puppet_hide" diff --git a/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm b/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm index fda61d5bbab..0ce8436f1bd 100644 --- a/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm +++ b/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm @@ -217,14 +217,26 @@ GLOBAL_LIST_INIT(chrome_rig_fill_shift, list( return FALSE add_puppet("body", cut_without_arms("both")) // The far arm in a side view sits under the body; the near one over it. - var/near = facing == WEST ? "l" : "r" + var/near = near_side() for(var/side in list("l", "r")) - var/obj/effect/abstract/chrome_puppet/arm = add_puppet("arm_[side]", cut("rig_arm_[side]")) - if(facing in list(EAST, WEST)) + var/side_view = (facing in list(EAST, WEST)) + var/icon/picture = cut("rig_arm_[side]") + if(side_view && side != near) + // The far arm is hidden behind the body in a side view, so its cut is empty: draw it as the + // near arm, a pixel further back and in shadow, so a raised pair reads as two arms. + picture = cut("rig_arm_[near]") + picture.Shift(facing == EAST ? WEST : EAST, 1) + picture.Blend(rgb(170, 170, 178), ICON_MULTIPLY) + var/obj/effect/abstract/chrome_puppet/arm = add_puppet("arm_[side]", picture) + if(side_view) arm.layer = side == near ? body.layer + 0.01 : body.layer - 0.01 conceal() return TRUE +/// The arm nearer the viewer in a side view ("l" or "r"); the right arm in other views. +/datum/chrome_rig/proc/near_side() + return facing == WEST ? "l" : "r" + /// Turns the arm on [side] to [angle] degrees up from hanging (0) over [time], with [reach] pixels of piston out along it. /datum/chrome_rig/proc/swing_arm(side, angle, time, reach = 0, easing = SINE_EASING) var/obj/effect/abstract/chrome_puppet/arm = puppet("arm_[side]") @@ -237,22 +249,6 @@ GLOBAL_LIST_INIT(chrome_rig_fill_shift, list( var/push_y = -reach * cos(turn) animate(arm, transform = chrome_rig_turn(turn, pivot[1], pivot[2], round(push_x), round(push_y)), time = time, easing = easing) -/// Heats an arm puppet's colour toward white-hot over [time]. -/datum/chrome_rig/proc/heat_arm(side, time, heat = 1) - var/obj/effect/abstract/chrome_puppet/arm = puppet("arm_[side]") - if(!arm) - return - if(!islist(arm.color)) - arm.color = list(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0) - var/list/hot = list(1, 0, 0, 0, 1 - 0.1 * heat, 0, 0, 0, 1 - 0.2 * heat, 0.3 * heat, 0.16 * heat, 0.04 * heat) - animate(arm, color = hot, time = time, flags = ANIMATION_PARALLEL) - -/// A puff of steam off an arm puppet. -/datum/chrome_rig/proc/steam_arm(side, emit_time = 0.3 SECONDS) - var/obj/effect/abstract/chrome_puppet/arm = puppet("arm_[side]") - if(arm) - cyberware_anim_particles(arm, /particles/smoke/steam/gorilla_puff, FLOAT_LAYER, emit_time, emit_time + 0.8 SECONDS) - /// Every puppet trembles a pixel for [time]. /datum/chrome_rig/proc/tremble(time = 0.4 SECONDS) for(var/key in puppets) diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm index be9e76a2634..b3074d24f18 100644 --- a/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm @@ -137,20 +137,26 @@ bearer_rig?.swing_arm("l", angle, time, reach) bearer_rig?.swing_arm("r", angle, time, reach) -/// Both arms heat and steam. -/datum/hv_finisher/chrome/gorilla/proc/arms_strain(time) - if(!bearer_rig) - return - for(var/side in list("l", "r")) - bearer_rig.heat_arm(side, time) - bearer_rig.steam_arm(side, time) - playsound(user, 'sound/machines/steam_hiss.ogg', 35, TRUE) - /// The bearer's punching hand, the attacking item for #509's hooks. /datum/hv_finisher/chrome/gorilla/proc/fist() var/mob/living/carbon/bearer = user return bearer.get_active_hand() || bearer.get_bodypart(BODY_ZONE_R_ARM) +/// The bearer's arm on [which] ("near" or "far" to the viewer in a side view) to [angle], with [reach]. +/datum/hv_finisher/chrome/gorilla/proc/arm_to(which, angle, time, reach = 0) + if(!bearer_rig) + return + var/near = bearer_rig.near_side() + var/side = which == "near" ? near : (near == "l" ? "r" : "l") + bearer_rig.swing_arm(side, angle, time, reach) + +/// The bearer's arm puppet on [which] ("near" or "far"), or null. +/datum/hv_finisher/chrome/gorilla/proc/arm_puppet(which) + if(!bearer_rig) + return null + var/near = bearer_rig.near_side() + return bearer_rig.puppet("arm_[which == "near" ? near : (near == "l" ? "r" : "l")]") + /// The beat a stage begins with: poses and sounds. Lines come from enter_line(). /datum/hv_finisher/chrome/gorilla/proc/pose(key) return @@ -167,9 +173,8 @@ /datum/hv_finisher/chrome/gorilla/proc/enter_self_line(key) return null -/// The flash, sparks, ring and shake of a killing blow on [where]. +/// The flash, sparks, ring and shake of a killing blow on [where]. Only the victim flashes; the arms keep their look. /datum/hv_finisher/chrome/gorilla/proc/hit_stop(atom/movable/where, heavy = TRUE) - bearer_rig?.flash() victim_rig?.flash() gorilla_play_impact(user, where, "impact_gorilla") var/turf/spot = get_turf(where) @@ -240,6 +245,19 @@ var/datum/hv_finisher/chrome/gorilla/finisher = host return finisher.enter_line(key) +/** + * Keeps #509's heavy-hit spray and its lost-limb spray off [victim] for the blow being dealt now. A + * finisher lays its own blood; #509's full fan from a held body reads as blood out of nowhere. + */ +/proc/gorilla_hold_spray(mob/living/victim) + victim.hv_blood_spray_until = max(victim.hv_blood_spray_until, world.time + 1) + victim.hv_blood_spray_strength = INFINITY + +/// One short line of [victim]'s blood toward [direction], [tiles] long, and nothing more from #509 for this blow. +/proc/gorilla_short_spray(mob/living/victim, direction, tiles = 2) + victim.hv_blood_throw(direction, tiles) + gorilla_hold_spray(victim) + // ========================================================================= // (A) THE HEAD CLAP // ========================================================================= @@ -258,8 +276,6 @@ /datum/hv_finisher_stage/hold/gorilla/clap_hoist key = "hoist" duration = 0.65 SECONDS - enter_sound = 'sound/machines/hiss.ogg' - enter_volume = 40 /datum/hv_finisher_stage/hold/gorilla/clap_squeeze key = "squeeze" @@ -307,16 +323,28 @@ victim_rig = new(victim, REVERSE_DIR(facing || user.dir)) if(victim_rig.snapshot()) var/obj/effect/abstract/chrome_puppet/held = victim_rig.add_puppet("whole", victim_rig.flat, TRUE) - // The held body is nearer the viewer than the bearer only when the bearer faces south. - held.layer = facing == SOUTH ? user.layer + 0.02 : user.layer - 0.02 + // The held body is in front of the bearer, unless he faces away from the viewer. + held.layer = facing == NORTH ? user.layer - 0.02 : user.layer + 0.02 victim_rig.conceal() var/list/toward = to_bearer() - animate(held, transform = matrix(), pixel_x = round(toward[1] * 0.6), pixel_y = round(toward[2] * 0.6), pixel_w = 0, pixel_z = 9, time = 0.4 SECONDS, easing = SINE_EASING | EASE_OUT) + animate(held, transform = matrix(), pixel_x = round(toward[1] * 0.7), pixel_y = round(toward[2] * 0.7), pixel_w = 0, pixel_z = 6, time = 0.4 SECONDS, easing = SINE_EASING | EASE_OUT) else QDEL_NULL(victim_rig) - arms_to(side_view() ? 80 : 140, 0.4 SECONDS, 3) + if(side_view()) + // The near fist holds the shirt front, kept under the bearer's face; the far one waits low behind. + var/obj/effect/abstract/chrome_puppet/near_arm = arm_puppet("near") + if(near_arm) + near_arm.layer = user.layer + 0.03 + arm_to("near", 100, 0.4 SECONDS, 4) + arm_to("far", 40, 0.4 SECONDS, 2) + else + // Seen from the front or back: both hands on the shoulders of the body held in front + for(var/side in list("l", "r")) + var/obj/effect/abstract/chrome_puppet/arm = bearer_rig?.puppet("arm_[side]") + if(arm) + arm.layer = facing == NORTH ? user.layer + 0.01 : user.layer + 0.03 + arms_to(-25, 0.4 SECONDS) if("squeeze") - arms_strain(1 SECONDS) bearer_rig?.tremble(1 SECONDS) var/obj/effect/abstract/chrome_puppet/held = victim_rig?.puppet("whole") if(held) @@ -332,18 +360,35 @@ if("toss") var/obj/effect/abstract/chrome_puppet/held = victim_rig?.puppet("whole") if(held) - animate(held, pixel_z = 13, time = 0.15 SECONDS, easing = SINE_EASING | EASE_OUT) - animate(pixel_z = 9, time = 0.15 SECONDS, easing = SINE_EASING | EASE_IN) - arms_to(side_view() ? 20 : 80, 0.15 SECONDS, 4) + animate(held, pixel_z = 10, time = 0.15 SECONDS, easing = SINE_EASING | EASE_OUT) + animate(pixel_z = 6, time = 0.15 SECONDS, easing = SINE_EASING | EASE_IN) + if(side_view()) + // Both fists draw back: the near one low, the far one up by the head. An arm swung high about + // the shoulder would cross the bearer's face from the side, so the far arm is lifted instead. + arm_to("near", 30, 0.15 SECONDS, 4) + arm_to("far", 50, 0.15 SECONDS, 2) + var/obj/effect/abstract/chrome_puppet/far_arm = arm_puppet("far") + if(far_arm) + animate(far_arm, pixel_z = 9, time = 0.15 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + else + arms_to(80, 0.15 SECONDS, 4) if("clap") - arms_to(side_view() ? 95 : 160, 0.08 SECONDS, 5) + if(side_view()) + // ...and close on the head from below and above as the body drops into them + arm_to("near", 110, 0.08 SECONDS, 6) + arm_to("far", 95, 0.08 SECONDS, 6) + var/obj/effect/abstract/chrome_puppet/held = victim_rig?.puppet("whole") + if(held) + animate(held, pixel_z = 0, time = 0.08 SECONDS, easing = SINE_EASING | EASE_IN) + else + // Both fists in from the sides, meeting across the head + arms_to(-95, 0.08 SECONDS, -6) hit_stop(victim_rig?.puppet("whole") || victim) if("recover") arms_to(0, 0.5 SECONDS) - if(bearer_rig) - for(var/side in list("l", "r")) - bearer_rig.heat_arm(side, 0.6 SECONDS, 0) - bearer_rig.steam_arm(side, 0.4 SECONDS) + var/obj/effect/abstract/chrome_puppet/far_arm = arm_puppet("far") + if(far_arm) + animate(far_arm, pixel_z = 0, time = 0.5 SECONDS, flags = ANIMATION_PARALLEL) /datum/hv_finisher/chrome/gorilla/clap/beat(key) if(key != "clap" || QDELETED(victim)) @@ -368,13 +413,14 @@ . = "burst" if(HAS_TRAIT(victim, TRAIT_NODISMEMBER) && HAS_TRAIT(victim, TRAIT_CYBERWARE_UNBREAKABLE_BONES)) // The Atlas frame: nothing bursts and nothing breaks - victim.apply_damage(35, BRUTE, head, victim.run_armor_check(head, MELEE), wound_bonus = CANT_WOUND, attack_direction = direction) + victim.apply_damage(35, BRUTE, head, victim.run_armor_check(head, MELEE), wound_bonus = CANT_WOUND) victim.adjustOrganLoss(ORGAN_SLOT_BRAIN, 20) victim.hv_add_hit_line("[victim]'s frame holds!", HV_LINE_BOLD, "Your frame holds!") . = "frame" else if(HAS_TRAIT(victim, TRAIT_HEAD_INJURY_BLOCKED) || hv_crit_cover_rating(victim, head, MELEE) >= GORILLA_CLAP_HELMET_ARMOUR) // A helmet or plating: no burst. It crumples and the skull caves in under it. - victim.apply_damage(35, BRUTE, head, wound_bonus = CANT_WOUND, attack_direction = direction) + // No attack direction: a sealed helmet throws no blood + victim.apply_damage(35, BRUTE, head, wound_bonus = CANT_WOUND) var/obj/item/clothing/helmet if(ishuman(victim)) var/mob/living/carbon/human/human_victim = victim @@ -388,10 +434,11 @@ victim.hv_add_hit_line("[victim]'s skull caves in!", HV_LINE_HUGE, "Your skull caves in!", DEFAULT_MESSAGE_RANGE) . = "helmet" else - victim.apply_damage(30, BRUTE, head, victim.run_armor_check(head, MELEE), wound_bonus = CANT_WOUND, attack_direction = direction) + // No attack direction: the burst lays its own blood (gorilla_clap_burst()) + victim.apply_damage(30, BRUTE, head, victim.run_armor_check(head, MELEE), wound_bonus = CANT_WOUND) if(QDELETED(victim) || isnull(victim.get_bodypart(BODY_ZONE_HEAD))) . = "burst" - else if(!victim.hv_burst_head(direction)) + else if(!gorilla_clap_burst(victim, direction)) // A head that can't come off: the skull caves in instead if(hv_crit_apply_wound(head, /datum/wound/blunt/bone/critical/hv_skull, direction, fist)) victim.hv_add_hit_line("[victim]'s skull caves in!", HV_LINE_HUGE, "Your skull caves in!", DEFAULT_MESSAGE_RANGE) @@ -401,6 +448,72 @@ playsound(victim, 'sound/effects/wounds/splatter.ogg', 60, TRUE) log_combat(user, victim, "head-clapped", "gorilla arms", "([.])") +/** + * #509's head burst (hv_burst_head_apart()) sized for two fists at arm's length: the gibs and splatter + * stay round the head, one short line of blood goes out along [burst_dir], the eyes, ears and tongue + * drop on the spot or a tile out and the brain lands a tile or two away, whole, for revival. + * Returns TRUE if the head burst. + */ +/proc/gorilla_clap_burst(mob/living/carbon/victim, burst_dir) + if(HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + if(!head || !head.can_dismember()) + return FALSE + if(!burst_dir) + burst_dir = pick(GLOB.alldirs) + var/turf/here = get_turf(victim) + var/organic = IS_ORGANIC_LIMB(head) + var/bleeds = head.can_bleed() + var/list/blood_dna = bleeds ? victim.get_blood_dna_list() : null + var/blood_color = bleeds ? victim.get_bloodtype()?.get_color() : null + var/list/obj/item/organ/loose = list() + for(var/obj/item/organ/organ in head) + if(!(organ.organ_flags & ORGAN_UNREMOVABLE)) + loose += organ + // One short line; the lost-limb spray the dismember would add is held off + gorilla_short_spray(victim, burst_dir, GORILLA_CLAP_SPRAY_TILES) + if(!head.dismember(BRUTE, silent = TRUE)) + return FALSE + // dismember() throws the head; a burst head comes apart where it was + if(isturf(here) && head.loc != here) + head.forceMove(here) + // No violent removal: the brain has to stay good for revival + head.drop_organs() + qdel(head) + if(isturf(here)) + if(!organic) + new /obj/effect/gibspawner/robot(here, victim) + else if(bleeds) + gorilla_clap_gibs(here, burst_dir, blood_dna) + hv_head_burst_splatter(here, burst_dir, blood_color) + playsound(here, 'sound/effects/wounds/splatter.ogg', 80, TRUE) + for(var/obj/item/organ/organ as anything in loose) + if(QDELETED(organ) || !isturf(organ.loc)) + continue + if(istype(organ, /obj/item/organ/brain)) + var/datum/callback/landed = blood_dna ? CALLBACK(organ, TYPE_PROC_REF(/obj/item/organ, hv_landed), blood_dna) : null + hv_fling_organ(organ, burst_dir, GORILLA_CLAP_BRAIN_MIN, GORILLA_CLAP_BRAIN_MAX, landed) + else if(prob(50)) + hv_fling_organ(organ, turn(burst_dir, pick(-45, 0, 45)), 1, 1) + victim.hv_add_hit_line(pick("[victim]'s head bursts apart!", "[victim]'s skull bursts open!"), HV_LINE_HUGE, range = DEFAULT_MESSAGE_RANGE) + return TRUE + +/** + * The clap's gibs: piled round the head, one a tile out along [burst_dir]. #509's burst gibs streak up to + * three tiles across a cone, which from two fists at arm's length reads as a blast. + */ +/proc/gorilla_clap_gibs(turf/where, burst_dir, list/blood_dna) + for(var/i in 1 to 3) + var/obj/effect/decal/cleanable/blood/gibs/gib = new(where, null, blood_dna) + if(!QDELETED(gib)) + gib.pixel_x = rand(-8, 8) + gib.pixel_y = rand(-8, 8) + var/turf/ahead = get_step(where, burst_dir) + if(ahead && !isgroundlessturf(ahead) && !hv_blood_spray_blocked(ahead)) + new /obj/effect/decal/cleanable/blood/gibs(ahead, null, blood_dna) + new /obj/effect/decal/cleanable/blood/gibs/core(where, null, blood_dna) + /// A crumpled dent drawn over the helmet for a while. /proc/gorilla_helmet_dent(mob/living/carbon/victim) var/mutable_appearance/dent = mutable_appearance(GORILLA_GORE_ICON, "helmet_dent", -(HEAD_LAYER - 0.1)) @@ -499,7 +612,6 @@ animate(part, pixel_x = part.pixel_x - round(toward[1] * 0.18), pixel_y = part.pixel_y - round(toward[2] * 0.18), time = 0.3 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) arms_to(side_view() ? 45 : 30, 0.3 SECONDS, 3) if("strain") - arms_strain(1.35 SECONDS) bearer_rig?.tremble(1.3 SECONDS) var/obj/effect/abstract/chrome_puppet/arm = victim_rig?.puppet("arm") if(arm) @@ -556,6 +668,7 @@ victim.hv_flush_hit() playsound(victim, 'sound/effects/wounds/splatter.ogg', 60, TRUE) gorilla_play_impact(user, victim, "impact_gorilla") + victim.Shake(2, 1, 0.2 SECONDS) if(held_limb.can_bleed()) victim.add_splatter_floor(get_turf(victim), small_drip = TRUE) @@ -614,6 +727,9 @@ // The spray runs back at the bearer: the last hit on the body came from them. victim.hv_hold_attack_message("[user] tears [victim]'s [limb.plaintext_zone] off at the shoulder!", "[user] tears your [limb.plaintext_zone] off at the shoulder!", "You tear [victim]'s [limb.plaintext_zone] off at the shoulder!", user, vision_range = DEFAULT_MESSAGE_RANGE) victim.apply_damage(10, BRUTE, limb, wound_bonus = CANT_WOUND, attack_direction = toward_bearer) + // One straight line back at the bearer, who takes it; no fan past him + if(limb.can_bleed()) + gorilla_short_spray(victim, toward_bearer, 2) var/obj/item/bodypart/torn = gorilla_tear_limb(victim, limb, toward_bearer) victim.hv_flush_hit() if(isnull(torn)) @@ -672,8 +788,6 @@ /datum/hv_finisher_stage/hold/gorilla/back_hoist key = "hoist" duration = 0.85 SECONDS - enter_sound = 'sound/machines/hiss.ogg' - enter_volume = 40 /datum/hv_finisher_stage/hold/gorilla/back_press key = "press" @@ -686,7 +800,7 @@ /datum/hv_finisher_stage/recover/gorilla/backbreaker key = "roll" - duration = 0.75 SECONDS + duration = 0.8 SECONDS /datum/hv_finisher/chrome/gorilla/backbreaker/pick_zone() target_zone = BODY_ZONE_CHEST @@ -723,14 +837,18 @@ if(victim_rig.snapshot()) for(var/half in list("upper", "lower")) var/obj/effect/abstract/chrome_puppet/part = victim_rig.add_puppet(half, victim_rig.cut("rig_[half]"), TRUE, user.layer + 0.05) - // Overhead on his back, flat: over the bearer's head - animate(part, pixel_x = toward[1], pixel_y = toward[2], pixel_w = 0, pixel_z = 17, transform = chrome_rig_turn(90, 0, BACK_WAIST_PIVOT_Y), time = 0.55 SECONDS, easing = SINE_EASING | EASE_OUT) + // Overhead on his back, flat, held clear above the bearer's head + animate(part, pixel_x = toward[1], pixel_y = toward[2], pixel_w = 0, pixel_z = 29, transform = chrome_rig_turn(90, 0, BACK_WAIST_PIVOT_Y), time = 0.55 SECONDS, easing = SINE_EASING | EASE_OUT) victim_rig.conceal() else QDEL_NULL(victim_rig) - arms_to(175, 0.5 SECONDS, 4) + // Fists up under the body + if(side_view()) + arm_to("near", 166, 0.5 SECONDS, 9) + arm_to("far", 174, 0.5 SECONDS, 9) + else + arms_to(170, 0.5 SECONDS, 8) if("press") - arms_strain(1 SECONDS) bearer_rig?.tremble(1 SECONDS) var/obj/effect/abstract/chrome_puppet/upper = victim_rig?.puppet("upper") var/obj/effect/abstract/chrome_puppet/lower = victim_rig?.puppet("lower") @@ -773,15 +891,22 @@ if(arm) animate(arm, pixel_z = 0, time = 0.4 SECONDS, flags = ANIMATION_PARALLEL) arms_to(0, 0.5 SECONDS) - // Off the knee onto their own tile, still bent if the back broke. The halves turn about the belt, - // seven pixels under the middle, so they sit a pixel up to lie where a lying body lies. - var/bend = broke ? 26 : 0 + // Off the knee onto their own tile, still bent if the back broke. A broken back hangs folded over + // the knee for a moment first. The halves turn about the belt, seven pixels under the middle, so + // they sit a pixel up to lie where a lying body lies. + var/bend = broke ? 33 : 0 + var/hold = broke ? GORILLA_BACK_FOLD_HOLD : 0 var/obj/effect/abstract/chrome_puppet/upper = victim_rig?.puppet("upper") var/obj/effect/abstract/chrome_puppet/lower = victim_rig?.puppet("lower") if(upper) - animate(upper, pixel_x = 0, pixel_y = 1, pixel_z = 0, transform = chrome_rig_turn(90 + bend, 0, BACK_WAIST_PIVOT_Y), time = 0.45 SECONDS, easing = SINE_EASING | EASE_IN | EASE_OUT) + animate(upper, transform = chrome_rig_turn(90 + (broke ? 38 : 0), 0, BACK_WAIST_PIVOT_Y), time = hold) + animate(pixel_x = 0, pixel_y = 1, pixel_z = 0, transform = chrome_rig_turn(90 + bend, 0, BACK_WAIST_PIVOT_Y), time = 0.45 SECONDS, easing = SINE_EASING | EASE_IN | EASE_OUT) if(lower) - animate(lower, pixel_x = 0, pixel_y = 1, pixel_z = 0, transform = chrome_rig_turn(90 - bend, 0, BACK_WAIST_PIVOT_Y), time = 0.45 SECONDS, easing = SINE_EASING | EASE_IN | EASE_OUT) + animate(lower, transform = chrome_rig_turn(90 - (broke ? 38 : 0), 0, BACK_WAIST_PIVOT_Y), time = hold) + animate(pixel_x = 0, pixel_y = 1, pixel_z = 0, transform = chrome_rig_turn(90 - bend, 0, BACK_WAIST_PIVOT_Y), time = 0.45 SECONDS, easing = SINE_EASING | EASE_IN | EASE_OUT) + if(broke && !QDELETED(victim)) + // A pool from the mouth where he lands + addtimer(CALLBACK(victim, TYPE_PROC_REF(/mob/living, add_splatter_floor), get_turf(victim), FALSE), hold + 0.45 SECONDS) for(var/half in list("upper", "lower")) var/obj/effect/abstract/chrome_puppet/part = victim_rig?.puppet(half) if(part) @@ -791,7 +916,7 @@ if(key != "slam" || QDELETED(victim)) return hit_stop(victim_rig?.puppet("upper") || victim, TRUE) - playsound(victim, 'sound/effects/wounds/crack2.ogg', 90, TRUE) + gorilla_backbreaker_sound(victim) playsound(victim, 'sound/effects/meteorimpact.ogg', 50, TRUE) var/turf/knee = get_turf(user) if(knee) @@ -799,6 +924,9 @@ cyberware_grey_dust(knee) var/outcome = gorilla_backbreak(victim, user, fist()) broke = outcome == "broken" + if(broke && !QDELETED(victim)) + // A trickle from the mouth, under where his head hangs over the knee + victim.add_splatter_floor(get_turf(victim), TRUE) if(broke) // The body folds over the knee var/obj/effect/abstract/chrome_puppet/upper = victim_rig?.puppet("upper") @@ -834,7 +962,8 @@ return null var/direction = get_dir(user, victim) victim.hv_hold_attack_message("[user] drops to one knee and brings [victim] down across it!", "[user] drops to one knee and brings you down across it!", "You drop to one knee and bring [victim] down across it!", user, vision_range = DEFAULT_MESSAGE_RANGE) - victim.apply_damage(45, BRUTE, chest, victim.run_armor_check(chest, MELEE), wound_bonus = CANT_WOUND, attack_direction = direction) + // No attack direction: a body bent over a knee throws no blood across the room (#509's heavy-hit spray) + victim.apply_damage(45, BRUTE, chest, victim.run_armor_check(chest, MELEE), wound_bonus = CANT_WOUND) . = "bruised" if(HAS_TRAIT(victim, TRAIT_CYBERWARE_UNBREAKABLE_BONES)) victim.hv_add_hit_line("[victim]'s frame holds!", HV_LINE_BOLD, "Your frame holds!") diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_haymaker.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_haymaker.dm index 6752a622c83..c0ce51d4b31 100644 --- a/voidcrew/modules/hyperviolence_gorilla/gorilla_haymaker.dm +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_haymaker.dm @@ -3,10 +3,10 @@ * * A chrome wheel chip on each real Gorilla arm. Hold Use chrome to wind up and let go to throw it at * whoever is in the tile you face; or tap Use chrome (or pick the chip) and click someone next to you. - * The wind-up slows the bearer, lets go of any grab, leans back and heats the arm, so the target has a + * The wind-up slows the bearer, lets go of any grab and leans back, so the target has a * beat to step out of line. The hit sends the body flying along the safe path, further for a full * charge; into a wall it hits harder and cracks the chest. A swing at nobody staggers the bearer. - * Held too long, or called off, the pistons vent and the charge is lost. + * Held too long, or called off, the charge is lost. * * The haymaker and every Gorilla finisher share one cooldown, kept on the bearer, so they never chain. */ @@ -109,7 +109,6 @@ charge_started = world.time pressed_at = world.time bearer.apply_status_effect(/datum/status_effect/gorilla_windup, src) - playsound(bearer, 'sound/machines/hiss.ogg', 35, TRUE, frequency = 0.7) vent_timer = addtimer(CALLBACK(src, PROC_REF(vent)), GORILLA_HAYMAKER_VENT_AFTER, TIMER_STOPPABLE|TIMER_DELETE_ME) /// Ends the wind-up without throwing anything. Clears every timer and the look. @@ -128,7 +127,7 @@ if(windup?.action == src) qdel(windup) -/// Held too long, or called off: steam, a short cooldown, nothing thrown. +/// Held too long, or called off: a short cooldown, nothing thrown. /datum/action/cooldown/cyberware/gorilla_haymaker/proc/vent() vent_timer = null if(!charging()) @@ -137,8 +136,6 @@ stop_charge() if(QDELETED(bearer)) return - playsound(bearer, 'sound/machines/steam_hiss.ogg', 50, TRUE) - cyberware_anim_particles(bearer, /particles/smoke/steam/cyberware_vent, GORILLA_ANIM_TOP_LAYER, 0.3 SECONDS, 1.2 SECONDS) gorilla_spend_pistons(bearer, GORILLA_PISTONS_VENTED) if(bearer.click_intercept == src) releasing = TRUE @@ -210,7 +207,6 @@ playsound(bearer, 'sound/items/weapons/punchmiss.ogg', 50, TRUE) bearer.visible_message(span_danger("[bearer]'s fist hits nothing but air!"), span_userdanger("Your fist hits nothing but air!")) bearer.adjust_staggered_up_to(1 SECONDS, 1 SECONDS) - arm.play_pump() return var/charge = clamp((held - GORILLA_HAYMAKER_MIN_CHARGE) / (GORILLA_HAYMAKER_FULL_CHARGE - GORILLA_HAYMAKER_MIN_CHARGE), 0, 1) gorilla_haymaker_hit(bearer, victim, arm, charge) @@ -259,6 +255,9 @@ if(iscarbon(victim)) var/mob/living/carbon/carbon_victim = victim wounds_before = carbon_victim.gorilla_wound_snapshot() + if(flies) + // The flight leaves its own trail and the wall its own marks; no #509 fan on top of them + gorilla_hold_spray(victim) var/dealt = victim.apply_damage(damage, BRUTE, zone, armour, wound_bonus = GORILLA_PUNCH_WOUND_BONUS + 5, attack_direction = direction, attacking_item = hand) var/wounded = FALSE if(iscarbon(victim) && !QDELETED(victim)) @@ -268,8 +267,7 @@ wounded = gorilla_new_wound(carbon_victim, wounds_before) victim.hv_flush_hit() gorilla_register_aggression(victim, bearer) - playsound(victim, 'sound/effects/meteorimpact.ogg', 50, TRUE) - playsound(victim, 'sound/items/weapons/punch4.ogg', 70, TRUE) + gorilla_haymaker_impact_sound(victim) arm.gorilla_punch_fx(bearer, victim, TRUE) cyberware_flash_fx(victim, 0.2 SECONDS) shake_camera(victim, 3, 2) @@ -297,7 +295,7 @@ // THE WIND-UP // ========================================================================= -/// The bearer winding up a haymaker: slowed, empty-handed of grabs, leaning back with the arm heating. +/// The bearer winding up a haymaker: slowed, empty-handed of grabs, leaning back, trembling at full charge. /datum/status_effect/gorilla_windup id = "gorilla_windup" alert_type = null @@ -306,10 +304,6 @@ status_type = STATUS_EFFECT_UNIQUE /// The haymaker winding up var/datum/action/cooldown/cyberware/gorilla_haymaker/action - /// The heat glow over the arm - var/obj/effect/abstract/cyberware_anim/heat - /// Steam off the arm - var/obj/effect/abstract/cyberware_anim/steam /// Trembling at full charge var/trembling = FALSE @@ -329,20 +323,12 @@ var/dx = (back & EAST) ? 2 : ((back & WEST) ? -2 : 0) var/dz = (back & NORTH) ? 1 : 0 owner.add_offsets(id, x_add = dx, z_add = dz) - var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm = action?.organ - if(istype(arm)) - heat = arm.play_heat(GORILLA_HAYMAKER_FULL_CHARGE, duration) - steam = cyberware_anim_particles(owner, /particles/smoke/steam/gorilla_puff, GORILLA_ANIM_TOP_LAYER, duration, duration + 1 SECONDS) return TRUE /datum/status_effect/gorilla_windup/on_remove() UnregisterSignal(owner, COMSIG_MOB_STATCHANGE) owner.remove_movespeed_modifier(/datum/movespeed_modifier/gorilla_windup) owner.remove_offsets(id) - QDEL_NULL(heat) - if(steam) - steam.stop_particles() - steam = null var/datum/action/cooldown/cyberware/gorilla_haymaker/haymaker = action action = null haymaker?.windup_lost() @@ -360,9 +346,8 @@ return if(world.time - action.charge_started < GORILLA_HAYMAKER_FULL_CHARGE) return - // Full: a light on the fist and a tremble + // Full: a tremble trembling = TRUE - owner.mob_light(1.5, 0.8, "#ffb35c", max(1, duration - world.time)) owner.do_jitter_animation(30) /datum/status_effect/gorilla_windup/proc/on_stat_change(datum/source, new_stat, old_stat) @@ -372,17 +357,3 @@ /datum/movespeed_modifier/gorilla_windup multiplicative_slowdown = 1.5 - -/// A thin puff of steam off a working arm. -/particles/smoke/steam/gorilla_puff - icon_state = list("steam_1" = 1, "steam_2" = 1, "steam_3" = 2) - count = 20 - spawning = 1.2 - lifespan = 0.7 SECONDS - fade = 0.4 SECONDS - position = generator(GEN_BOX, list(-8, -2, 0), list(8, 6, 0), UNIFORM_RAND) - velocity = generator(GEN_VECTOR, list(-0.3, 0.6, 0), list(0.3, 1.2, 0), NORMAL_RAND) - gravity = list(0, 0.15) - friction = 0.2 - grow = 0.04 - scale = 0.45 diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_looks.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_looks.dm index 7f1b87070c5..f6f6a6b1eb0 100644 --- a/voidcrew/modules/hyperviolence_gorilla/gorilla_looks.dm +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_looks.dm @@ -2,15 +2,12 @@ * # Gorilla Arms looks * * Pictures on top of moves that already happened; nothing here changes a number. - * - The piston pump: the punching arm's pistons compress, fire and return. - * - Heat: the pistons whiten and glow as an arm winds up or strains. + * The arms always look like their worn sprite: no tint, glow or steam over them (owner). * - The deploy flex: as the pair syncs, after an EMP reboot, or on the Arm chrome key when the Gorilla - * has nothing to deploy. A squat, both pumps in turn with steam, the knuckles flash, and it settles. + * has nothing to deploy. A squat, the knuckles flash in turn, and it settles. * - Snapped bones: a compound fracture on an arm, a leg or the chest shows bone through the skin and * blood, whatever broke it. Drawn like #509's stumps (bone plus greyscale blood tinted to the body's * blood), on its own overlay list beside them. - * - * Plates under a suit that covers the arms don't show, so their pump and heat don't either. */ /// Whether this arm's worn plate is on show (no suit over the arms). @@ -20,36 +17,6 @@ return FALSE return !(bearer.wear_suit?.body_parts_covered & ARMS) -/// The pistons on this arm pump once: compress, fire, return. Returns the holder or null. -/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/play_pump() - if(!owner || !plate_shows()) - return null - var/state = "pump_[aug_overlay]" - if(!icon_exists(GORILLA_GORE_ICON, state)) - return null - var/obj/effect/abstract/cyberware_anim/pump = cyberware_anim_play(owner, GORILLA_GORE_ICON, state, GORILLA_ANIM_PLATE_LAYER, 0.3 SECONDS) - if(pump && icon_exists(GORILLA_GORE_ICON, "[state]_e")) - pump.add_overlay(emissive_appearance(GORILLA_GORE_ICON, "[state]_e", owner)) - return pump - -/** - * The pistons heat over [rise_time] and stay hot until [lifetime], then the glow goes. Returns the - * holder, which the caller may delete sooner. - */ -/obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/play_heat(rise_time, lifetime) - if(!owner || !plate_shows()) - return null - var/state = "hot_[aug_overlay]" - if(!icon_exists(GORILLA_GORE_ICON, state)) - return null - var/obj/effect/abstract/cyberware_anim/heat = cyberware_anim_play(owner, GORILLA_GORE_ICON, state, GORILLA_ANIM_PLATE_LAYER, lifetime) - if(!heat) - return null - heat.add_overlay(emissive_appearance(GORILLA_GORE_ICON, state, owner)) - heat.alpha = 0 - animate(heat, alpha = 255, time = rise_time, easing = SINE_EASING | EASE_IN) - return heat - // ========================================================================= // THE DEPLOY FLEX // ========================================================================= @@ -73,16 +40,12 @@ for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in arms) addtimer(CALLBACK(arm, TYPE_PROC_REF(/obj/item/organ/cyberimp/cyberware/gorilla_arms, flex_beat)), delay) delay += 0.15 SECONDS - playsound(bearer, 'sound/machines/hiss.ogg', 30, TRUE, frequency = 0.8) return TRUE -/// One arm's part of the flex: the pump, a puff of steam, and the knuckles flashing. +/// One arm's part of the flex: the knuckles flashing. /obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/flex_beat() if(!owner) return - play_pump() - play_heat(0.1 SECONDS, 0.35 SECONDS) - cyberware_anim_particles(owner, /particles/smoke/steam/gorilla_puff, GORILLA_ANIM_TOP_LAYER, 0.15 SECONDS, 0.9 SECONDS) var/obj/effect/abstract/cyberware_anim/spark = cyberware_anim_play(owner, GORILLA_GORE_ICON, "impact_brawler", GORILLA_ANIM_TOP_LAYER, 0.4 SECONDS) if(spark) spark.appearance_flags |= RESET_TRANSFORM diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm index dfb02a45ce7..e4609a354ad 100644 --- a/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm @@ -49,12 +49,9 @@ return FALSE StartCooldown() gorilla_cooldown(bearer, COOLDOWN_GORILLA_POUND, GORILLA_POUND_COOLDOWN) - // The crouch: down and wide, both arms heating + // The crouch: down and wide animate(bearer, pixel_z = -2, time = GORILLA_POUND_CROUCH, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) animate(pixel_z = 2, time = 0.1 SECONDS, flags = ANIMATION_RELATIVE) - for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) - arm.play_heat(GORILLA_POUND_CROUCH, GORILLA_POUND_CROUCH + 0.3 SECONDS) - playsound(bearer, 'sound/machines/hiss.ogg', 40, TRUE, frequency = 0.6) crouch_timer = addtimer(CALLBACK(src, PROC_REF(slam), get_turf(bearer)), GORILLA_POUND_CROUCH, TIMER_STOPPABLE|TIMER_DELETE_ME) return TRUE @@ -83,8 +80,6 @@ playsound(where, 'sound/effects/meteorimpact.ogg', 80, TRUE, 8) cyberware_piledriver_impact_fx(where, 1.5) cyberware_grey_dust(where) - for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) - arm.play_pump() for(var/mob/living/shaken in range(3, where)) if(shaken.client && shaken != bearer) shake_camera(shaken, 3, get_dist(shaken, where) <= 1 ? 2 : 1) diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_pry.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_pry.dm index 31a76cc0002..1fe462b9053 100644 --- a/voidcrew/modules/hyperviolence_gorilla/gorilla_pry.dm +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_pry.dm @@ -5,8 +5,8 @@ * powered one, refused through bolts, welds and seals), with two changes: * - Trader outpost doors never give: the sanctuary airlocks and any door on an outpost's protected * ground. Trying counts as aggression against the outpost. - * - A powered pry strains: every half second the pistons pump and steam, the arms heat and tremble, the - * door shakes, the seam opens a little wider and throws sparks. + * - A powered pry strains: every half second the bearer trembles, the door shakes, the seam opens a + * little wider and throws sparks. */ /datum/element/door_pryer/gorilla @@ -85,11 +85,6 @@ if(QDELETED(bearer) || QDELETED(door) || !DOING_INTERACTION_WITH_TARGET(bearer, door)) return beats++ - for(var/obj/item/organ/cyberimp/cyberware/gorilla_arms/arm as anything in gorilla_heavy_arms(bearer)) - arm.play_pump() - if(beats == 1) - arm.play_heat(4 SECONDS, 4.5 SECONDS) - cyberware_anim_particles(bearer, /particles/smoke/steam/gorilla_puff, GORILLA_ANIM_TOP_LAYER, 0.2 SECONDS, 1 SECONDS) bearer.do_jitter_animation(20) door.Shake(1, 0, 0.3 SECONDS) seam.set_width(beats) diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_punch.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_punch.dm index 5f03e08fc3e..de585ae5795 100644 --- a/voidcrew/modules/hyperviolence_gorilla/gorilla_punch.dm +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_punch.dm @@ -87,7 +87,7 @@ var/hurl = heavy_moves && hurl_range > 0 && grabbed && standing && TIMER_COOLDOWN_FINISHED(source, COOLDOWN_GORILLA_GRAB_MOVE) var/direction = get_dir(source, living_target) || source.dir source.do_attack_animation(living_target, ATTACK_EFFECT_SMASH) - playsound(living_target.loc, 'sound/items/weapons/punch1.ogg', 40, TRUE, -1) + gorilla_punch_impact_sound(living_target) var/target_zone = living_target.get_random_valid_zone(source.zone_selected) var/armor_block = living_target.run_armor_check(target_zone, MELEE, armour_penetration = active_hand.unarmed_effectiveness) if(hurl) @@ -125,19 +125,36 @@ return FALSE /** - * What a piston punch looks like: knuckle sparks over the target, the punching arm's pistons pump - * with a puff of steam, a light, and on a heavy hit a small ring. + * What a piston punch looks like: knuckle sparks over the target, and on a heavy hit a small ring. */ /obj/item/organ/cyberimp/cyberware/gorilla_arms/proc/gorilla_punch_fx(mob/living/carbon/human/source, mob/living/target, heavy = FALSE) gorilla_play_impact(source, target, impact_state) - play_pump() - if(heavy_moves) - source.mob_light(1.5, 0.6, "#ffcf8a", 0.2 SECONDS) if(heavy) var/turf/spot = get_turf(target) if(spot) new /obj/effect/temp_visual/circle_wave/cyberware_slam(spot, 0.4) +/// The sound of a landed piston punch on [target]. The one place to swap it. +/proc/gorilla_punch_impact_sound(atom/target) + playsound(get_turf(target), GORILLA_PUNCH_SOUND, 55, TRUE, -1) + +/// The landed piston punch: one of three at random. +/datum/sound_effect/gorilla_piston_punch + key = GORILLA_PUNCH_SOUND + file_paths = list( + 'voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch.ogg', + 'voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch2.ogg', + 'voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch3.ogg', + ) + +/// The sound of a landed haymaker on [target]. The one place to swap it. +/proc/gorilla_haymaker_impact_sound(atom/target) + playsound(target, GORILLA_HAYMAKER_SOUND, 70, TRUE) + +/// The sound of a back breaking across a Gorilla's knee on [target]. The one place to swap it. +/proc/gorilla_backbreaker_sound(atom/target) + playsound(target, GORILLA_BACKBREAKER_SOUND, 70, TRUE) + /** * Knuckle sparks over [target]: a short one-shot drawn upright and turned to come from the * attacker's side, like cyberware_play_impact() but from this module's sheet. @@ -340,5 +357,4 @@ playsound(spot, 'sound/effects/meteorimpact.ogg', 40, TRUE) new /obj/effect/temp_visual/cyberware_impact_cracks(spot, "damaged1", 220) cyberware_grey_dust(spot) - play_pump() log_combat(source, victim, table ? "piston-slammed onto a table" : "piston-slammed into the floor", "gorilla arms") diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm index 3bec171ce8d..3536368ea8b 100644 --- a/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm @@ -7,14 +7,14 @@ * grille. It never launches at all from a tile with no gravity. A wall or a standing body in the way * is hit, as tg would. * - * Along the way the body leaves a scuff and a little dust on each tile, or a blood streak if it bleeds, - * at most GORILLA_TRAIL_MAX of them. + * Bodies fly flat, without spinning, and skid. Along the way the body leaves a scuff and a little dust + * on each tile, or a ragged smear of blood drops if it bleeds, on at most GORILLA_TRAIL_MAX tiles. * * Any hard throw into a wall (a Gorilla's or anyone else's) leaves a mark chosen by the injury the body * carries when it hits (owner D38): no broken skin dents the wall and knocks chips off it; a bleeding * cut or a compound fracture splashes a half-moon on the floor and smears the wall's edge; heavy - * bleeding or a torn artery sprays up the wall and runs. The body is pinned to the wall for a moment - * and slides down it. A haymaker always dents the wall as well, and adds its own damage and a cracked + * bleeding or a torn artery sprays up the wall and runs. The body drops at the foot of the wall with + * tg's knockdown. A haymaker always dents the wall as well, and adds its own damage and a cracked * chest. */ @@ -93,7 +93,7 @@ trail_left++ var/travel = get_dir(old_loc, victim) || dir if(bleeds()) - hv_blood_lay_spray(left, /obj/effect/decal/cleanable/blood/hv_spray/streak, travel, victim.get_blood_dna_list(), victim.get_static_viruses()) + gorilla_lay_skid_blood(left, travel, victim.get_blood_dna_list(), victim.get_static_viruses()) return new /obj/effect/temp_visual/gorilla_scuff(left, travel) var/obj/effect/temp_visual/mook_dust/dust = cyberware_grey_dust(left) @@ -108,6 +108,35 @@ hv_blood_lay_spray(stop, /obj/effect/decal/cleanable/blood/hv_spray, throwing_datum?.init_dir || victim.dir, victim.get_blood_dna_list(), victim.get_static_viruses()) end() +/** + * A bleeding body's skid over [floor] along [travel]: two to GORILLA_SKID_SPLATS drops and small splats + * of tg's blood, each a different shape, size and place along the line, so tiles don't repeat. + */ +/proc/gorilla_lay_skid_blood(turf/floor, travel, list/blood_dna, list/viruses) + var/along_x = (travel & EAST) ? 1 : ((travel & WEST) ? -1 : 0) + var/along_y = (travel & NORTH) ? 1 : ((travel & SOUTH) ? -1 : 0) + for(var/i in 1 to rand(2, GORILLA_SKID_SPLATS)) + var/obj/effect/decal/cleanable/blood/splatter/gorilla_skid/splat = new(floor, viruses, blood_dna) + if(QDELETED(splat)) + continue + // Spread along the line of travel, a little either side of it + var/along = rand(-13, 13) + var/across = rand(-3, 3) + splat.pixel_x = along * along_x + across * along_y + splat.pixel_y = along * along_y + across * along_x + var/matrix/shape = matrix() + var/size = pick(0.6, 0.75, 0.9, 1.1) + shape.Scale(size * pick(1, -1), size) + shape.Turn(pick(0, 90, 180, 270)) + splat.transform = shape + +/// One drop or splat of a bleeding body's skid. Several share a tile. +/obj/effect/decal/cleanable/blood/splatter/gorilla_skid + random_icon_states = list("gibbl1", "gibbl2", "gibbl3", "gibbl4", "gibbl5", "drip2", "drip4") + mergeable_decal = FALSE + // Thin enough that walking through it does not leave footprints + bloodiness = 5 + /// The grey drag mark a launched body leaves on the floor. It fades on its own. /obj/effect/temp_visual/gorilla_scuff icon = GORILLA_GORE_ICON @@ -203,7 +232,8 @@ if(victim.gorilla_flight) qdel(victim.gorilla_flight) var/datum/gorilla_flight/flight = new(victim, thrower, haymaker, wounded) - if(!victim.throw_at(target, distance, speed, thrower, spin = !gentle, gentle = gentle)) + // Flat, no spin: the body skids to a stop + if(!victim.throw_at(target, distance, speed, thrower, spin = FALSE, gentle = gentle)) flight.end() return 0 return distance @@ -251,7 +281,7 @@ gorilla_wall_impact(hit_atom, flight) /** - * A hard landing against [wall]: the haymaker's extra, the pinned slide down the wall and the marks. + * A hard landing against [wall]: the haymaker's extra and the marks. */ /mob/living/carbon/proc/gorilla_wall_impact(turf/wall, datum/gorilla_flight/flight) var/turf/floor = get_turf(src) @@ -260,7 +290,6 @@ return if(flight?.haymaker) gorilla_haymaker_wall_hit(flight) - gorilla_wall_sag(toward) var/kind = gorilla_splat_kind() // Only a straight hit on a wall face gets the pictures; a glancing corner hit gets the dent. if(!(toward in GLOB.cardinals)) @@ -290,13 +319,6 @@ if(hv_crit_apply_wound(chest, /datum/wound/blunt/bone/severe, get_dir(flight?.thrower || src, src), flight?.thrower)) hv_add_hit_line("[src]'s ribs crack against the wall!", HV_LINE_BOLD, "Your ribs crack against the wall!") -/// Pinned to the wall for a beat, then sliding down it. Every offset is a tracked source tg removes. -/mob/living/proc/gorilla_wall_sag(toward) - var/dx = (toward & EAST) ? 5 : ((toward & WEST) ? -5 : 0) - var/dy = (toward & NORTH) ? 7 : ((toward & SOUTH) ? -3 : 0) - add_offsets(GORILLA_WALL_SAG, x_add = dx, z_add = dy, animate = FALSE) - addtimer(CALLBACK(src, TYPE_PROC_REF(/mob/living, remove_offsets), GORILLA_WALL_SAG, TRUE), 0.15 SECONDS) - /// Lays (or refreshes) one wall-impact decal on the floor tile in front of the wall. /mob/living/carbon/proc/gorilla_lay_wall_decal(turf/floor, decal_type, toward) if(!isturf(floor) || isclosedturf(floor) || isgroundlessturf(floor)) @@ -387,6 +409,12 @@ setDir(spray_dir) pixel_x = -16 pixel_y = -16 + // Against a side wall, seen from above, the half-moon would sit on the wall line like option A's spray: + // pull it out onto the floor so it reads as a puddle at the wall's foot. + if(spray_dir & EAST) + pixel_x -= 8 + else if(spray_dir & WEST) + pixel_x += 8 /// Wall pictures: 64x64 on the floor tile at -16,-16, drawn up onto the wall's near edge. /obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray diff --git a/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_backbreaker.ogg b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_backbreaker.ogg new file mode 100644 index 0000000000000000000000000000000000000000..cb6e3ddfb171b46560c3c4558644c72480c2fc18 GIT binary patch literal 11043 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch.ogg b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch.ogg new file mode 100644 index 0000000000000000000000000000000000000000..7bd87514966c3283fdcb6e9a59bbe7cf81927463 GIT binary patch literal 8257 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
      J1ob!{2dMzo_cl}8` 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 bY-K~(ayPg$%7wr{_1YT1cZch-hg1F+eWkpM literal 0 HcmV?d00001 diff --git a/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch3.ogg b/voidcrew/modules/hyperviolence_gorilla/sound/gorilla_piston_punch3.ogg new file mode 100644 index 0000000000000000000000000000000000000000..ba7bd00f79d3023bfd277ca7a48e42f1f3fdaea6 GIT binary patch literal 8346 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 ebd Date: Sun, 8 Nov 2026 16:30:22 -0800 Subject: [PATCH 200/472] Give the Widowline its own deploy and retract sounds --- .../modules/cyberware/ware_military_arms.dm | 3 ++- voidcrew/sound/monowire_deploy.ogg | Bin 0 -> 10162 bytes voidcrew/sound/monowire_retract.ogg | Bin 0 -> 9448 bytes 3 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 voidcrew/sound/monowire_deploy.ogg create mode 100644 voidcrew/sound/monowire_retract.ogg diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index 7951fa413dd..6b8dfe27169 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -651,7 +651,8 @@ chrome_load = 5 tier = CYBERWARE_TIER_3 items_to_create = list(/obj/item/melee/cyberware_monowire) - extend_sound = 'sound/items/weapons/batonextend.ogg' + extend_sound = 'voidcrew/sound/monowire_deploy.ogg' + retract_sound = 'voidcrew/sound/monowire_retract.ogg' // ========================================================================= // POPUP RONIN SMG diff --git a/voidcrew/sound/monowire_deploy.ogg b/voidcrew/sound/monowire_deploy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..faf42a97a45c59d593ef8a8d67b8cf2568172949 GIT binary patch literal 10162 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 literal 0 HcmV?d00001 diff --git a/voidcrew/sound/monowire_retract.ogg b/voidcrew/sound/monowire_retract.ogg new file mode 100644 index 0000000000000000000000000000000000000000..1e9bbc67f16c3a2a2ae04c1e9c6a79d692d903ea GIT binary patch literal 9448 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 literal 0 HcmV?d00001 From aa2135707b7fbb078624222a9096bc4e7d91c813 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:31:35 -0800 Subject: [PATCH 201/472] Swap the Widowline retract sound for a new take --- voidcrew/sound/monowire_retract.ogg | Bin 9448 -> 9424 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/voidcrew/sound/monowire_retract.ogg b/voidcrew/sound/monowire_retract.ogg index 1e9bbc67f16c3a2a2ae04c1e9c6a79d692d903ea..9052048701115f4c7d69501778e2b80998f68463 100644 GIT binary patch delta 5864 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≧L z)#dDonMvjRpX*<>F{oTQkKn5flXgGU$ccYLW5Gkf*&6_kEjLegYBJ^4qYbY~R9!Ec zCVo=U5JwqIsNB-qik&`KW%K?nKBfK&uaYHj5!vsj>1&CyGJuNg3DyAyo899Fx<%W1 z#aubkbyinh6(fo=+Si)3Yr8-;vS*VoD@Y%Mk@?eroaMf)IXHU*ZcsJSrBfyf>EwS{ zRLbF?q=B6;!0dP)$lRDS$R}=)>%)i|I(M2~wr!WB>b7aiMn{YCUy#l@=(N50k$nGk4 zQ^D``UQn(y$SZNn^eR~A>Ubg8`+q>vr-ut0W=jpD&m~q+Vx6I0y z_TRK9ErVI}SiG!dp4@%)82a%V67et8Rp4Im6g2Y5tY3$N^his0y;|T*D{cbj#5D?99LNR|mBNEHY`Q@nbIt4m_M_K(iW&r@c7#%)(SmprXfx9NoZ-f?d zu6Ji$M`pJLs0Co<0#Ow)M;Q|W-r@M|ms4LV9!or5?2mD+Sl7CxXcZ+#cW>^aJrkmH z{G7NCwe&h~DzuFjoC~4{%Nq0Q`aydW`h}gmozL?OgLrT4cspQUQ^TiwDED8e zfK&B`$Nt>&rGwx=1u`AYHst&gwuNwa@!MxbCz(27%>&{w3Q&OYb4?Edp7`8Iao8S< ziTbcaq#8cIS7Cp)UH9B+?o>Cj0lliq=dfxKut!MS;aYJqo>PK8?|0q?8XAwQHEGY_ z-YHB&`hAR}i|%Ua!=qeL(&krWaU+*hO*~OXh;6m0lNY%5p-#d`(T{~NO~~~#fk+A? zvDlSfSp*-GNjO<{ofFRT{i{H=&r}B;z^Ih;t8b;n+&X`$8qNG;2;V1< z9ac2#wS(BN)=G5{x06wK+m0ix&IJvq5mb@o8e0VXB`(1Dfs_t;iU3= zv+w0sf|mzVd!;vyTBZkFIIPp`kU^iA4UFGl-dD?80%7FhdWfS>7IRUZQst9YX1(v{ zsM&vKtK-3fGUcN)t(?rs*}>(TbS(J%rfhb;gj+113C%}}tc$!BIchg3LF7aF*V~H+ zF0r5w*o7g23RODl$>7xErZLM^Dze3rugO~zvi5{nzX^XgGFNliuezbMWTxe4uL4Mv zhhEJ^>S_}JJ~-WIq;T{B;(~Kc+#cV;z1DxmZDFNYy%K0QK+1(6=)R1#o@wru8Q(Ut zys18u?s>MFA(62W8zLJ;&Ku$=xJs1CN^m`Ek7t_!uBd$uD-Sxavpd?Ir9r=&q{ij;%oef+0e*;o!Gc)2BYVWfyLUIGDHvLP zA%EdcQ2`ntzf%nS0e%?WXrZuTM^Tp56p6F=tJ8(Y$kaG=$OJ3^)B$=`xi&F62(X-E zuw`$mQZd#6Ccx7m%gi7}&ynK|cG^FDu?X8OYB zNvUXD=S%d0n25ZzNC3bDo`~IOWpMlj)nZMZ#KW2Mz|2XC5S=3cs9rt0qG;SmKu=qV zBy;Y&kIz(UxBJWIq~{lL)!CqDaG{qREy9m5BVcMLA?-O9{@h%#X{d(qq0&0H?;r9uB>D&X4U8pV4YIo`AU1_D78Ld_Tqmoe9;aYnG z4(Pzh_-aLE>U93$S5}*u$qxdSX#o@`^=w&7o(|}}-8lK^S9-^OFbFC3sLnT#Lq)oPb86_VU8b<6%8L^wW0}F3RHgfkJUjG#st6 zOO5qHYPOmLG!k>pLq*HJUO#86wOY6tCNqD#Pg~$60Fa-0xN)h({bN(kCQTIuFD%Xw z0(u}6I6u{_W_w+uZhI>|g>sw-`Jnwi6qo0lNV7F1Sa)k-uS8qD=b4$Ln1Qs!0>^-} zb=N*N^#If*$BQaxS2%C}-4Q^*06^FO$)Yt8spuT4VlEhZocA* ziBautYHcwG0C4Oo&9v1%*|&`f&<5L6_8^`*ATZi8cy~N6fqSd37&`-;3EvpZW4F{*lmLO>@Oc4B z#<>kv-Wx0_iN}b)~PS@@qHI%+N@7|qF3?^UvA1dr6ozB z>HRzd^!*kZLxF|5`;!0`K;x$SIF6&me6{LJv|e`ImW?OK4b2m-0p7%GNk<1XB|}bA z_AXI-a{x&Iz}anm-;;Ox#a2|q%D@M?YTx4ZZQSN{J{lU9{P(DDN&y?7u~9T300000 yXJY{98yirmOTNCnS{EUJBJySPW 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 WXr}q-=k2Y|&+oU0X Date: Sun, 8 Nov 2026 16:38:00 -0800 Subject: [PATCH 202/472] Fix the Scorcher refuel and MP-84 sound tests The refuel test overfilled a 100 unit large beaker, so the remaining fuel never matched. The MP-84 shot is byte-identical to the Zashch's, and the resource cache reports one path for both, so compare the resource itself. --- voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm | 5 +++-- voidcrew/modules/unit_tests/voidcrew_shiptest_sidearms.dm | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm b/voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm index b3dc9c0a04d..40fbde99ab5 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm @@ -61,10 +61,11 @@ beaker.reagents.add_reagent(/datum/reagent/fuel/oil, 30) scorcher.draw_fuel(beaker, shooter) TEST_ASSERT_EQUAL(scorcher.fuel, 2, "The tank took something that isn't welding fuel") - beaker.reagents.add_reagent(/datum/reagent/fuel, 60) + // A large beaker holds 100, so this fills it. + beaker.reagents.add_reagent(/datum/reagent/fuel, 40) scorcher.draw_fuel(beaker, shooter) TEST_ASSERT_EQUAL(scorcher.fuel, 40, "The tank did not fill from a beaker of fuel") - TEST_ASSERT_EQUAL(beaker.reagents.get_reagent_amount(/datum/reagent/fuel), 22, "The tank took more fuel than it holds") + TEST_ASSERT_EQUAL(beaker.reagents.get_reagent_amount(/datum/reagent/fuel), 2, "The tank took more fuel than it holds") TEST_ASSERT_EQUAL(beaker.reagents.get_reagent_amount(/datum/reagent/water), 30, "Refuelling took water out of the beaker") /// Each gun can be bought at its tier and found in plunder, with a spare magazine, and its magazine is sold. diff --git a/voidcrew/modules/unit_tests/voidcrew_shiptest_sidearms.dm b/voidcrew/modules/unit_tests/voidcrew_shiptest_sidearms.dm index 3ade94504f4..ac517e3ed5c 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shiptest_sidearms.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shiptest_sidearms.dm @@ -42,7 +42,9 @@ TEST_ASSERT_EQUAL(snake.magazine.type, /obj/item/ammo_box/magazine/m9mm_rattlesnake, "The MP-84 spawned with the wrong magazine") TEST_ASSERT_EQUAL(snake.chambered?.type, /obj/item/ammo_casing/c9mm, "The MP-84 fires the wrong round") TEST_ASSERT(!length(snake.actions_types), "The MP-84 should have no action button") - TEST_ASSERT_EQUAL("[snake.fire_sound]", "voidcrew/modules/shiptest_guns/sidearms/sound/rattlesnake.ogg", "Gun feel swapped the MP-84's own shot") + // Compare the resource, not its path: the recording is byte-identical to nova_guns/sound/pistol_heavy.ogg, and the + // resource cache keeps one copy under whichever path it saw first. + TEST_ASSERT_EQUAL(snake.fire_sound, 'voidcrew/modules/shiptest_guns/sidearms/sound/rattlesnake.ogg', "Gun feel swapped the MP-84's own shot") var/datum/component/automatic_fire/autofire = snake.GetComponent(/datum/component/automatic_fire) TEST_ASSERT_NOTNULL(autofire, "The MP-84 has no full auto") TEST_ASSERT_EQUAL(autofire.autofire_shot_delay, 0.1 SECONDS, "The MP-84 fires at the wrong rate") From e5badbddac1950e361a8db1a34780e3a235e2038 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:39:33 -0800 Subject: [PATCH 203/472] Land the floor limb where the falling one lands, hide the sleeve mask A dropped limb shifts its picture after its transform, so the resting pose now takes that shift back off; the floor limb and the falling limb meet exactly. Poses on a lying body are placed by the body's own angle and drawing. The sleeve mask is built as tg builds the missing-hand glove mask, so it cuts the sleeve instead of being drawn as a white limb. Each drop of a wet wire's fan stays where it fell. --- .../overlays_exec/sleeve_mask.dm | 10 +++- .../hyperviolence/wire/wire_garrote.dm | 13 +++-- .../modules/hyperviolence/wire/wire_held.dm | 6 ++- .../hyperviolence/wire/wire_sever_fx.dm | 48 +++++++++++-------- 4 files changed, 48 insertions(+), 29 deletions(-) diff --git a/voidcrew/modules/hyperviolence/overlays_exec/sleeve_mask.dm b/voidcrew/modules/hyperviolence/overlays_exec/sleeve_mask.dm index 45593347aec..b14f24cea4f 100644 --- a/voidcrew/modules/hyperviolence/overlays_exec/sleeve_mask.dm +++ b/voidcrew/modules/hyperviolence/overlays_exec/sleeve_mask.dm @@ -52,7 +52,15 @@ if(!length(missing)) return cut_overlay(worn) - var/mutable_appearance/mask = mutable_appearance(HV_SLEEVE_MASK_ICON, "missing_[missing[1]]") + // Built as tg builds the missing-hand glove mask (update_worn_gloves()): an atom's appearance carries the + // render target. A mutable_appearance carrying it was drawn on screen as a white limb. + var/static/atom/movable/mask + if(isnull(mask)) + mask = new(null) + mask.icon = HV_SLEEVE_MASK_ICON + mask.icon_state = "missing_[missing[1]]" + // Set at once, not through the overlay queue: the appearance is copied below. + mask.overlays.Cut() for(var/i in 2 to length(missing)) mask.overlays += mutable_appearance(HV_SLEEVE_MASK_ICON, "missing_[missing[i]]") mask.render_target = "*hv_sleeves [cache_index] [REF(src)]" diff --git a/voidcrew/modules/hyperviolence/wire/wire_garrote.dm b/voidcrew/modules/hyperviolence/wire/wire_garrote.dm index 44083751da9..d4a558669e2 100644 --- a/voidcrew/modules/hyperviolence/wire/wire_garrote.dm +++ b/voidcrew/modules/hyperviolence/wire/wire_garrote.dm @@ -304,8 +304,7 @@ if(!head) return var/seared = wire.wire_sears(victim) - // The body as it is drawn while the wire goes through, for where the head lands. - var/matrix/body_shape = matrix(victim.transform) + // Where the body is drawn while the wire goes through, for where the head lands. var/list/body_offsets = list(victim.pixel_x, victim.pixel_y, victim.pixel_w, victim.pixel_z) var/head_side = (get_dir(victim, user) & (EAST | NORTH)) ? 1 : -1 clear_wire_fx() @@ -316,7 +315,7 @@ LAZYSET(victim.hv_stump_burnt, BODY_ZONE_HEAD, TRUE) // Dropped, brain and all, as #509's finishers drop a head: where the neck was, at the garroter's feet. if(hv_wire_take_part(head, seared ? WOUND_BURN : WOUND_SLASH, seared ? WIRE_ARC_BLEED_MULT : 1, !seared)) - wire_drop_head(head, body_shape, body_offsets, head_side) + wire_drop_head(head, victim, body_offsets, head_side) finish_blood(spray_dir, seared) log_combat(user, victim, "garroted the head off", wire) return @@ -338,12 +337,12 @@ * The head comes off the end of the neck and lies where it was drawn, a few pixels clear of it: at the garroter's * feet, since the hold drew the neck to their hands. Turned as the body lay; an ordinary head again once moved. */ -/proc/wire_drop_head(obj/item/bodypart/head/head, matrix/body_shape, list/body_offsets, side) +/proc/wire_drop_head(obj/item/bodypart/head/head, mob/living/body, list/body_offsets, side) if(QDELETED(head) || !isturf(head.loc)) return - // Its picture is shifted by (px_x, px_y) to sit mid-tile; on the body its middle is 9 px up. - var/list/pivot = list(head.px_x, 9 + head.px_y) - head.transform = wire_limb_rest(pivot, list(0, 13), side, TRUE, body_shape) + // On the body its middle is 9 px up; it lies 4 px further out, off the end of the neck. Its picture is + // shifted by (px_x, px_y) to sit mid-tile, after its transform. + head.transform = wire_part_pose(list(0, 9), side * 10, list(side * 2, 13), body, head.px_x, head.px_y) head.pixel_x = body_offsets[1] head.pixel_y = body_offsets[2] head.pixel_w = body_offsets[3] diff --git a/voidcrew/modules/hyperviolence/wire/wire_held.dm b/voidcrew/modules/hyperviolence/wire/wire_held.dm index 311168b6306..8fdbcd051af 100644 --- a/voidcrew/modules/hyperviolence/wire/wire_held.dm +++ b/voidcrew/modules/hyperviolence/wire/wire_held.dm @@ -288,7 +288,7 @@ var/turf/landing = locate(origin.x + tile_x, origin.y + tile_y, origin.z) if(!landing || isgroundlessturf(landing) || !landing.has_gravity() || (landing != origin && hv_blood_spray_blocked(landing))) return - var/obj/effect/decal/cleanable/blood/drip/drop = new(landing, null, blood_dna) + var/obj/effect/decal/cleanable/blood/drip/wire_flick/drop = new(landing, null, blood_dna) if(QDELETED(drop)) return drop.pixel_x = land_x - tile_x * 32 @@ -311,3 +311,7 @@ . = ..() if(. && side && !QDELETED(wire)) wire.play_retract(stowing_owner, side, stage) + +/// A drop of the fan. Each stays where it fell instead of pooling with the others on its tile; a fan is a handful. +/obj/effect/decal/cleanable/blood/drip/wire_flick + mergeable_decal = FALSE diff --git a/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm b/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm index 638cc85c92c..e0679f7fb0c 100644 --- a/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm +++ b/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm @@ -48,23 +48,32 @@ return list(arm ? (right ? -7 : 6) : (right ? -4 : 3), y) /** - * Where a cut limb comes to rest, as a transform for something drawing it about `pivot` (where the limb's middle - * is drawn in that thing's own picture). Upright, it has toppled over `side` and lies on the floor beside the feet; - * off a body already lying down it only slides out a little, since it is on the floor already. Then it is moved and - * turned as the body was (`body_shape`). + * Where a cut limb comes to rest, as a transform for something drawing it with its middle at `centre`. Upright, it + * has toppled over `side` and lies on the floor beside the feet; off a body already lying down it only slides out a + * little, since it is on the floor already. For something drawn apart from the body, `body` places and turns it as + * that body is drawn. A dropped limb shifts its own picture by (`shift_x`, `shift_y`) after any transform, so that + * is taken back off at the end. */ -/proc/wire_limb_rest(list/pivot, list/centre, side, lying, matrix/body_shape) - var/matrix/rest = matrix() - rest.Translate(-pivot[1], -pivot[2]) - if(lying) - rest.Turn(side * 10) - rest.Translate(centre[1] + side * 3, centre[2] - 1) - else - rest.Turn(side * 90) - rest.Translate(side * 10, -13) - if(body_shape) - rest.Multiply(body_shape) - return rest +/proc/wire_limb_rest(list/centre, side, lying, mob/living/body, shift_x = 0, shift_y = 0) + var/list/target = lying ? list(centre[1] + side * 3, centre[2] - 1) : list(side * 10, -13) + return wire_part_pose(centre, lying ? side * 10 : side * 90, target, body, shift_x, shift_y) + +/** + * A transform that draws a part whose middle is at `centre` turned `turn` degrees about that middle, with the middle + * at `target` (both in its body's own frame), then placed and turned as `body` is drawn, if given; less a shift + * the picture applies after its transform. + */ +/proc/wire_part_pose(list/centre, turn, list/target, mob/living/body, shift_x = 0, shift_y = 0) + var/list/spot = target + if(body) + turn += body.body_position == LYING_DOWN ? body.lying_angle : 0 + var/matrix/shape = body.transform + spot = list(shape.a * target[1] + shape.b * target[2] + shape.c, shape.d * target[1] + shape.e * target[2] + shape.f) + var/matrix/pose = matrix() + pose.Translate(-centre[1], -centre[2]) + pose.Turn(turn) + pose.Translate(spot[1] - shift_x, spot[2] - shift_y) + return pose // ---- The held limb ---- @@ -178,9 +187,8 @@ var/far_arm = (facing == EAST && zone == BODY_ZONE_L_ARM) || (facing == WEST && zone == BODY_ZONE_R_ARM) severed.setDir(far_arm ? REVERSE_DIR(facing) : facing) var/list/centre = wire_limb_centre(zone, facing) - // The dropped limb's picture is shifted by (px_x, px_y) to sit mid-tile, flipped with it for the far arm. - var/list/pivot = list(centre[1] + (far_arm ? -severed.px_x : severed.px_x), centre[2] + severed.px_y) - var/matrix/rest = wire_limb_rest(pivot, centre, side, lying, body?.transform) + // The dropped limb's picture is shifted by (px_x, px_y) to sit mid-tile, after its transform. + var/matrix/rest = wire_limb_rest(centre, side, lying, body, severed.px_x, severed.px_y) if(far_arm) var/matrix/flipped = matrix(-1, 0, 0, 0, 1, 0) flipped.Multiply(rest) @@ -239,7 +247,7 @@ slid.Translate(-centre[1], -centre[2]) slid.Turn(side * (lying ? 4 : 15)) slid.Translate(centre[1] + side * 2, centre[2] - 1) - var/matrix/rest = wire_limb_rest(centre, centre, side, lying) + var/matrix/rest = wire_limb_rest(centre, side, lying) // Halfway over: the turn is split so it turns rather than squashes on the way. var/matrix/tipping = matrix() tipping.Translate(-centre[1], -centre[2]) From 84f125afdaf60fca3c4f29b48dd24107611bbbb9 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:39:47 -0800 Subject: [PATCH 204/472] Credit the Zashch fire sound to TerraGov under CC BY-NC-SA --- voidcrew/modules/nova_guns/attribution.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/voidcrew/modules/nova_guns/attribution.txt b/voidcrew/modules/nova_guns/attribution.txt index b2f59f6f655..b2abbd50db2 100644 --- a/voidcrew/modules/nova_guns/attribution.txt +++ b/voidcrew/modules/nova_guns/attribution.txt @@ -25,5 +25,7 @@ icons/magazines.dmi - states rapier, rapier-full, rapier-empty from Sounds (Nova paths are under modular_nova/modules/modular_weapons/sounds/, taken unmodified) sound/smg_light.ogg - sounds/smg_light.ogg sound/pistol_heavy.ogg - sounds/pistol_heavy.ogg + Byte-identical to TerraGov Marine Corps sound/weapons/guns/fire/gun_p23.ogg + (TGMC PR #13409), licensed CC BY-NC-SA 3.0. Non-commercial use only. sound/bay_gunshot_magnum.ogg - sounds/bay_gunshot_magnum.ogg. Nova's sounds/attributions.txt says it was taken from Baystation12 (https://github.com/Baystation12/Baystation12). From ca15828bbef41ebf7e4b7e1eccb59d394bd8f76c Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:45:53 -0800 Subject: [PATCH 205/472] Smoke a seared stump from the joint, and slide a limb clear of a lying body --- .../hyperviolence/wire/wire_sever_fx.dm | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm b/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm index e0679f7fb0c..eaf4bdd033d 100644 --- a/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm +++ b/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm @@ -55,7 +55,7 @@ * is taken back off at the end. */ /proc/wire_limb_rest(list/centre, side, lying, mob/living/body, shift_x = 0, shift_y = 0) - var/list/target = lying ? list(centre[1] + side * 3, centre[2] - 1) : list(side * 10, -13) + var/list/target = lying ? list(centre[1] + side * 7, centre[2] - 2) : list(side * 10, -13) return wire_part_pose(centre, lying ? side * 10 : side * 90, target, body, shift_x, shift_y) /** @@ -253,7 +253,7 @@ tipping.Translate(-centre[1], -centre[2]) tipping.Turn(side * (lying ? 7 : 50)) if(lying) - tipping.Translate(centre[1] + side * 3, centre[2] - 1) + tipping.Translate(centre[1] + side * 5, centre[2] - 1) else tipping.Translate((centre[1] + side * 12) / 2, (centre[2] - 13) / 2) animate(src, transform = slid, time = WIRE_SLIDE_TIME, easing = SINE_EASING | EASE_IN) @@ -295,11 +295,10 @@ /proc/wire_stump_smoke(mob/living/carbon/victim, zone) var/obj/effect/abstract/cyberware_anim/wire_smoke/smoke = new(null) - smoke.layer = WIRE_FX_LAYER - var/particles/smoke/steam/mild/puff = new - var/side = wire_limb_side(zone, victim.dir) - var/arm = (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_R_ARM) - puff.position = zone == BODY_ZONE_HEAD ? list(0, 8, 0) : list(side * 6, arm ? 4 : -6, 0) + // On the body, over the stump, not over everything on the tile. + smoke.layer = WIRE_GHOST_LAYER + var/particles/smoke/steam/mild/wire_stump/puff = new + puff.position = wire_stump_joint(zone, victim.dir) smoke.particles = puff smoke.bearer = victim smoke.RegisterSignal(victim, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) @@ -307,6 +306,26 @@ addtimer(CALLBACK(smoke, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim/wire_smoke, stop_smoking)), WIRE_ARC_SMOKE_TIME - 1 SECONDS) QDEL_IN(smoke, WIRE_ARC_SMOKE_TIME) +/// Where a limb in `zone` joins a body facing `facing` (the shoulder, the hip, the neck), in pixels from the middle +/// of the tile, as the body draws it standing. list(x, y, 0) +/proc/wire_stump_joint(zone, facing) + if(zone == BODY_ZONE_HEAD) + return list(0, 6, 0) + var/list/centre = wire_limb_centre(zone, facing) + var/arm = (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_R_ARM) + return list(centre[1], arm ? 4 : -6, 0) + +/// Thin wisps off a seared stump: small, rising slowly from the joint. +/particles/smoke/steam/mild/wire_stump + scale = 0.4 + grow = 0.02 + spawning = 0.6 + lifespan = 1.2 SECONDS + fade = 0.8 SECONDS + velocity = list(0, 0.2, 0) + gravity = list(0, 0.25) + drift = generator(GEN_SPHERE, 0, 0.6, NORMAL_RAND) + // ---- Through two bodies ---- /// The line of the wire through `target` and `behind`, and on past them; then the same line in blood. From 08f9e69abd8eb487f1b7edd79f32d6681da02d24 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:54:27 -0800 Subject: [PATCH 206/472] Put the clap's fists on the head, and pool blood where a broken back lands The side-view clap closes both fists on the held body's head, with the sparks there; seen from the front both fists come in from the sides. The backbreaker leaves a pool where the body lands. --- .../hyperviolence_gorilla/gorilla_finisher.dm | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm index b3074d24f18..bf5812523ab 100644 --- a/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm @@ -174,9 +174,11 @@ return null /// The flash, sparks, ring and shake of a killing blow on [where]. Only the victim flashes; the arms keep their look. -/datum/hv_finisher/chrome/gorilla/proc/hit_stop(atom/movable/where, heavy = TRUE) +/datum/hv_finisher/chrome/gorilla/proc/hit_stop(atom/movable/where, heavy = TRUE, spark_z = 0) victim_rig?.flash() - gorilla_play_impact(user, where, "impact_gorilla") + var/obj/effect/abstract/cyberware_anim/sparks = gorilla_play_impact(user, where, "impact_gorilla") + if(sparks && spark_z) + sparks.pixel_z += spark_z var/turf/spot = get_turf(where) if(spot) new /obj/effect/temp_visual/circle_wave/cyberware_slam(spot, heavy ? 1 : 0.6) @@ -375,7 +377,7 @@ if("clap") if(side_view()) // ...and close on the head from below and above as the body drops into them - arm_to("near", 110, 0.08 SECONDS, 6) + arm_to("near", 117, 0.08 SECONDS, 6) arm_to("far", 95, 0.08 SECONDS, 6) var/obj/effect/abstract/chrome_puppet/held = victim_rig?.puppet("whole") if(held) @@ -383,7 +385,8 @@ else // Both fists in from the sides, meeting across the head arms_to(-95, 0.08 SECONDS, -6) - hit_stop(victim_rig?.puppet("whole") || victim) + // The sparks go where the fists meet, on the head + hit_stop(victim_rig?.puppet("whole") || victim, TRUE, 9) if("recover") arms_to(0, 0.5 SECONDS) var/obj/effect/abstract/chrome_puppet/far_arm = arm_puppet("far") @@ -906,7 +909,7 @@ animate(pixel_x = 0, pixel_y = 1, pixel_z = 0, transform = chrome_rig_turn(90 - bend, 0, BACK_WAIST_PIVOT_Y), time = 0.45 SECONDS, easing = SINE_EASING | EASE_IN | EASE_OUT) if(broke && !QDELETED(victim)) // A pool from the mouth where he lands - addtimer(CALLBACK(victim, TYPE_PROC_REF(/mob/living, add_splatter_floor), get_turf(victim), FALSE), hold + 0.45 SECONDS) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(gorilla_mouth_pool), victim), hold + 0.45 SECONDS) for(var/half in list("upper", "lower")) var/obj/effect/abstract/chrome_puppet/part = victim_rig?.puppet(half) if(part) @@ -943,6 +946,12 @@ animate(part, pixel_z = 6, time = 0.12 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) animate(pixel_z = 2, time = 0.12 SECONDS, easing = SINE_EASING | EASE_IN) +/// A pool of [victim]'s blood where a broken back lands. +/proc/gorilla_mouth_pool(mob/living/carbon/victim) + if(QDELETED(victim) || !isturf(victim.loc) || victim.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + new /obj/effect/decal/cleanable/blood(victim.loc, victim.get_static_viruses(), victim.get_blood_dna_list()) + /datum/hv_finisher/chrome/gorilla/backbreaker/cleanup_rigs() // A broken back stays drawn bent for a while after if(broke && victim_rig && !QDELETED(victim) && victim.get_bodypart(BODY_ZONE_CHEST)) From 35a0c11c58e9c7bbb6a3c0bef3cbddbb472677af Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:00:40 -0800 Subject: [PATCH 207/472] Fix the Mantis Blade gore visuals Impale: both blades drawn from the bearer's hands into the body, with two bloody points standing out of the back and the held blades hidden while they are in. The victim is turned to face the bearer. The rip is now a pull-back: the points sink, both impacts flash on the body while it still hangs there, and a beat later the blades come out and the body drops. Scissor: the victim's head is turned toward the bearer at the wind-up. The cut is the bearer's own blades swinging down from the hands and crossing at the neck in a small X, the far blade under the body and the near one over it, with the held blades hidden. The large V over the body is gone. The head still comes off by the finisher. Lunge: one impact for a plain lunge (both for a pounce), shown as the body lands, and the afterimages appear as the body passes so they trail it. The pounce arc grows with the distance. Blood: the coat on the held blades sits on the edge and the front of the blade, stays inside the blade, and the drops fall from the point. Charged leap: the crouch sinks 5 px and the coil glow ends near white. Scorch: the head seam sits lower on the neck, angled along the jaw. A blade's held sprite now stays hidden for the longest hide asked of it; the unfold swing's timer used to cut the impale's hide short. --- voidcrew/_DEFINES/mantis_gore.dm | 34 +++- voidcrew/modules/cyberware/cyberware_anim.dm | 11 +- .../modules/cyberware/ware_military_arms.dm | 4 +- .../modules/mantis_gore/icons/mantis_gore.dmi | Bin 10477 -> 11021 bytes .../mantis_gore/icons/mantis_gore_large.dmi | Bin 3601 -> 1423 bytes .../mantis_gore/icons/mantis_gore_reach.dmi | Bin 0 -> 76057 bytes .../mantis_gore/icons/mantis_lefthand.dmi | Bin 15388 -> 11707 bytes .../mantis_gore/icons/mantis_righthand.dmi | Bin 15303 -> 11672 bytes voidcrew/modules/mantis_gore/mantis_charge.dm | 2 +- voidcrew/modules/mantis_gore/mantis_impale.dm | 149 ++++++++++++++++-- .../modules/mantis_gore/mantis_lunge_fx.dm | 143 ++++++++++++----- .../modules/mantis_gore/mantis_scissor.dm | 46 +++++- .../unit_tests/voidcrew_mantis_gore.dm | 41 ++++- 13 files changed, 365 insertions(+), 65 deletions(-) create mode 100644 voidcrew/modules/mantis_gore/icons/mantis_gore_reach.dmi diff --git a/voidcrew/_DEFINES/mantis_gore.dm b/voidcrew/_DEFINES/mantis_gore.dm index db0182b8ca5..cddc34aff88 100644 --- a/voidcrew/_DEFINES/mantis_gore.dm +++ b/voidcrew/_DEFINES/mantis_gore.dm @@ -3,8 +3,15 @@ /// 32x32 pictures: glints, the coil glow, trails, the impale tips, the scorch seam. #define MANTIS_GORE_ICON 'voidcrew/modules/mantis_gore/icons/mantis_gore.dmi' -/// 64x64 pictures: the artery spurt, the scissor, the flick. +/// 64x64 pictures: the artery spurt, the flick. #define MANTIS_GORE_LARGE_ICON 'voidcrew/modules/mantis_gore/icons/mantis_gore_large.dmi' +/// 96x96 pictures centred on the bearer: both blades reaching into the next tile for the scissor and the +/// impale, one state under the victim and one over it, eight directions (bearer to victim). +#define MANTIS_GORE_REACH_ICON 'voidcrew/modules/mantis_gore/icons/mantis_gore_reach.dmi' +/// Layer for blade drawn behind or inside the victim's body: under a lying body, so under a standing one too. +#define MANTIS_UNDER_VICTIM_LAYER (LYING_MOB_LAYER - 0.01) +/// Layer for blade drawn in front of the victim (and the bearer). +#define MANTIS_OVER_VICTIM_LAYER ABOVE_MOB_LAYER /// The posed held blades. #define MANTIS_LEFTHAND_ICON 'voidcrew/modules/mantis_gore/icons/mantis_lefthand.dmi' #define MANTIS_RIGHTHAND_ICON 'voidcrew/modules/mantis_gore/icons/mantis_righthand.dmi' @@ -81,12 +88,33 @@ #define MANTIS_IMPALE_BREAK_DAMAGE 15 /// A target at or under this share of max health is hurt enough to skewer. #define MANTIS_IMPALE_HEALTH_FRACTION 0.7 +// Where the two bodies sit while the blades are in. The reach art (mantis_gore_reach.dmi) is drawn for +// exactly these, so change them together. +/// Pixels the bearer leans into the thrust. +#define MANTIS_IMPALE_LEAN 4 +/// Pixels someone skewered from the side is drawn onto the blades. +#define MANTIS_IMPALE_PULL 4 +/// Pixels someone skewered from the side is lifted. +#define MANTIS_IMPALE_LIFT 5 +/// Pixels someone skewered from straight above is lifted (toward the bearer). From straight below, none. +#define MANTIS_IMPALE_LIFT_SOUTH 3 +/// Pixels the wall takes them back by when pinned. +#define MANTIS_IMPALE_PIN_SHOVE 5 +/// Pixels the bearer draws back before the blades come out. +#define MANTIS_IMPALE_PULL_BACK 6 +/// From the pull-back to the blades coming out and the body dropping. +#define MANTIS_IMPALE_PULL_BEAT (0.3 SECONDS) + +/// How long the lunging body takes to glide in after it has arrived. The hit shows as it lands. +#define MANTIS_LUNGE_GLIDE 1.5 // ---- Charged leap ---- /// Held shorter than this, it is the ordinary lunge. #define MANTIS_CHARGE_TAP (0.35 SECONDS) /// Held this long, it reaches the furthest. #define MANTIS_CHARGE_FULL (1.2 SECONDS) +/// Pixels the body sinks into the crouch. +#define MANTIS_CHARGE_CROUCH_DROP 5 /// Held this long, the bearer gives up and straightens. #define MANTIS_CHARGE_MAX_HOLD (3 SECONDS) /// After straightening, this long before they can crouch again. @@ -98,6 +126,10 @@ // ---- Scissor ---- #define MANTIS_SCISSOR_WINDUP (1.2 SECONDS) +/// Pixels the bearer leans in for the cut, and further when the head lies on the far side of the body. +/// The reach art is drawn for these. +#define MANTIS_SCISSOR_LEAN 8 +#define MANTIS_SCISSOR_FAR_LEAN 14 #define MANTIS_SCISSOR_COOLDOWN (10 SECONDS) /// Brute to the neck when the head won't come off. #define MANTIS_SCISSOR_FALLBACK_DAMAGE 25 diff --git a/voidcrew/modules/cyberware/cyberware_anim.dm b/voidcrew/modules/cyberware/cyberware_anim.dm index baf0b756087..916c8f44efe 100644 --- a/voidcrew/modules/cyberware/cyberware_anim.dm +++ b/voidcrew/modules/cyberware/cyberware_anim.dm @@ -307,11 +307,20 @@ var/inhand_hidden_until = 0 /obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/hide_inhand_for(duration) + // A longer hide already running (the impale's or the scissor's blade art) is never cut short by a swing. + if(world.time + duration <= inhand_hidden_until) + return inhand_hidden_until = world.time + duration var/mob/holder = loc if(ismob(holder)) holder.update_held_items() - addtimer(CALLBACK(src, PROC_REF(show_inhand)), duration, TIMER_UNIQUE | TIMER_OVERRIDE) + // Each hide ends on its own timer, and a timer ends nothing while a later hide still runs. + addtimer(CALLBACK(src, PROC_REF(inhand_hide_expired)), duration, TIMER_DELETE_ME) + +/obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/inhand_hide_expired() + if(world.time < inhand_hidden_until) + return + show_inhand() /obj/item/melee/energy/blade/hardlight/cyberware_mantis/proc/show_inhand() if(!inhand_hidden_until) diff --git a/voidcrew/modules/cyberware/ware_military_arms.dm b/voidcrew/modules/cyberware/ware_military_arms.dm index 88ec821113b..c5ec3aaa46b 100644 --- a/voidcrew/modules/cyberware/ware_military_arms.dm +++ b/voidcrew/modules/cyberware/ware_military_arms.dm @@ -565,8 +565,8 @@ lunger.face_atom(victim) lunger.do_attack_animation(victim, no_effect = TRUE) - // The blade's own impact over the target, twice, and a burst of light and dust (mantis_gore/mantis_lunge_fx.dm). - mantis_lunge_arrival_fx(lunger, victim, istype(lunge_blade) ? lunge_blade.impact_state : "impact_mantis", blades) + // The blade's own impact over the target (both blades for a pounce), and a burst of light and dust (mantis_gore/mantis_lunge_fx.dm). + mantis_lunge_arrival_fx(lunger, victim, istype(lunge_blade) ? lunge_blade.impact_state : "impact_mantis", charged && blades > 1) // The arrival counts toward the lunger's kill (mantis_gore/mantis_cuts.dm). mantis_note_hit(victim, lunger) // One armor roll for the arrival, reused per blade. Both land in the same diff --git a/voidcrew/modules/mantis_gore/icons/mantis_gore.dmi b/voidcrew/modules/mantis_gore/icons/mantis_gore.dmi index b8290444749393a08cf004011f19645e915966ee..e8e4d659bc405b3104ae55940d5f66aa94e5c317 100644 GIT binary patch literal 11021 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 literal 10477 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 RE2NA;qMojPF6*2UngA%IsBHiM delta 3229 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 CAq=Vj diff --git a/voidcrew/modules/mantis_gore/icons/mantis_gore_reach.dmi b/voidcrew/modules/mantis_gore/icons/mantis_gore_reach.dmi new file mode 100644 index 0000000000000000000000000000000000000000..d91c8200d3fc0c64d92ff15a593f2f87bf744d49 GIT binary patch literal 76057 zcmeFZi91yN|37}lUSviK$~MYPcMB>JV#boVyA|!o)@CcR?`G`EnsO_$<}NBr30a2_ zC0hyEDUofgS;oxyJrBL#pWpTS1Af={dwsv>x?UG38d!b3(7?g1VyIP+0}#(-ZFUS}`5XkT-_;$(Bn#m2!NK|CH6+}Gb2C{GCP zc>iaBeRyW4gi!C+f8SW=5;Z1sSZeAjz^ zPVon9lB%!%&?8)S*;er5iQWZaN9m?ZB$1Z0ht7EIpL|W-yaz`-XEUE8%gDL@p*mA= zA zZ>&Uqog|Gthr1*~IBC=v!6AsAw#HeHu8Cd^YtxY=(klI2q>!I!kFDFg#3>(kS&nU6 zV)&|dTpzR?vlK+B2i|yC=H1_?+8Df#w`16}@N{K(3{`%~G{`2@%W%(@Fpk=b9Q#@p zWJSL9nrCNC2B)df-ztAtD3Cfa8|TujoPrnb`a9`vviFoSZA7_$WKjx6{(sG*QgVkw z^Hv=k_1TU?9MDCn_cL3b@`fiNNXvw>zac_oBmAf10#x{m7+Uh#t|Z@rh#;v|&H>*2 zzxX(iwNK!)u7Tqx{0L!ORBYYBaVzs(_n}&7F#PE$@j{(pE3cLBmg7seKi7JA>k!y} zeQo=GquqOM|1Ovx1j%?-S}xo7#{5OLkl8+joFL|R(M0Z!;l!I;t2H(S8z0~ivI`Zv z5Ft8jB<z zvzv$*@E+%bE|OoD={x;sd-=D1)Ae$t{(}dR7@Z)A^Ac*!*{bDd3!Klz{;mJSfJQBb zVN@ZLhk4IFM&xL`UFO%#1*@A(i^t5^5yarzw3ba{~Ucox=aS2c3u!7-J#WAnODNsmAzTBCr$Pd~rwto^ z@7LpD8(QK;b|N@!As+0bPt+;A>qZivJ^$ta>Jb(jOl4)|)%xAYxodZ?P`c8gYX-T~ zTDEX9y32QI0Wdy+nX>97}z0m!p#AjJi`}vOVU$w9lZsB};gzfBsC4&(0C#t-~E@??q z1sg1dsBNlCEw87wf`}HUf>Lprk9UPo8)21PvppF6mK&B)T>n@1pPH&xC&f-|4(~y- zC>^0vk-Q|Vp9gx4>S1~=m9+HnXk4UoAYuH3ypH$nU0#Nr8hWpT*pIXvJ9+T6e5Tmt zywWc8SD;mGt;k{{PA79*i|Z}^%)R`#cx03EYD2C49hsx zMn&nuuSa)eWlWM1_j1J=kD4Xcw+W?e=Rt_BeUdg-S8cy8NVawf6F2dar21*|a$1P~ zMiR87dZztO>C3)E2Yvl_(h&k`ADo{@`t;gu5kWm|oqi2FfBU|izWR405k{&qy#UtL z_LspYtEwaZ7T%ihj1vE6R2gES@D{Jnr+zgNrX%9)c=fF*rHJd(X(XOr2`^m^ahUR! z8{VD|bC9?0{?(Tzib@@iaD85diGdv=q;GzNow}z@E6`M8pA(z*lB6O&PBP3XVnvIP z9GB-lb@=fXA7MT8?NaOTI7Jv7c)vQspm5?&@cw3PqDAkX| zQF2;@5}N;}n5gpSeauzN78UQkp!DPA@JpSq-`|*YmXI|PRZ58+7XGjf> zk$XZy(5hcG(VJ2nFXzB)dV`-SopwN3xd#e!P=)#0N(Mcum}QDiq3;A2(5Nv~n`b3& z3N`ngJH_i(%6H_3&?Dye>gXPHuL~3X_LNpXaAKh5^5{t+HAc6!%zM1J&(un8=K}q! zYMI!V97-EHMp>YgM{)ld=YLn@d35r~$E+)3=#DmYN=r3`UEyC=2QJ`7+#^O+U~@QH z!Rl)W_X`y%sh}ACDi&$Nc^l1pkm+^qgU=nBgx<9;^lOioFSlQK=*TQ(^`epcH_Zfc zr;OYjLrKh;<{wDtEfgI~q$qj~IzLaXPE-kuOX9uPq58mQ@~iQ~aWpWRGWWr%llI_3 z;Tw1J4p;vm=`}XlF9V&!KoONsiPalHd;*$H+|7w;1*fRpDCNp_jt{ zaP1F~ZsgM}vnh&i0r%mWiN^mkQuur{q~Mp%h!U}sL_%} zB0&)^{}Ub#uKe4R!khM6OeGM+T1P2NI)oS`v9_Nh`Z%(^Z@!&6i=?aNC>D|P-7*!I zIX5Ai1lXkra`;iEX_+JK4RgTgWue*D;ghCFz52ees3NO71q!gSo z?8Pqa*sCS^F?;sk;de9K$OC+FGs>mD`v-{E2&4MhG$0`0{@LljTza++b((hjVt zn2`Ngaf!nVwN_E3$ev;FEZMbF!;wNti2N!+`wp}N9-s+Y z1)ub)v;0!q!fw7na2?bLvr_e&D@eVhc;tb;_@>u?a zD6~G1gkL)qhze_0tcAE1+E}DRTsr}Cwxg42d9!?XRLT+4a9IN8hg@D`pAl8a2x0Oy zF4Ym7{OS1VY8R_2Zmd=EF0lg;_{7KKkDp3zhBbX{z45g>YMM?UL`Fxpjg4oH8?EEh z?4y%svDUCqwe057;JBRnXC}5c;eeeO^DIQ7q(ho-ZG)^`MT{y(5GQh-ImDD^rTsS{ z`mM9Tu7*$mdDStk1zYBcw3Qg;cO&phy4$Mp@v^Tst%ZK;jhs{eH3Vj;goKbedrLmg zsbW_{SczaU>e@lTg|#a6CI7klkzA;}qPfV8WSW(iy*?WM<#_Wwsqo$gPl)oflfQvu zu(I)N>cMV0Shf%Z8zj+6i-Wkwcb7M~!(qQEp=i4CXb=wxK^kZ0-&eO^UVP<`T@5by zYQCxExobs2*ni;=pc3_(SQ(~Y&%c3qNu7*!rn23>_vdmid`cBdoZ44&Kp1*9%8IGB z_I=LFF@cADn(3EKTNy2`!kYLLez8g1?3>#h>2^eL=wK8$9=)6%=!$>RBGIS0p zBXbDH+F<;1GT*OUfp`OX})~r z>JDM5wbznm9xq4C&1f3+tU{YK4=(jrv@8KJ*bci-DCC6eTWlLD5wzTRL|6BC>* zOkAaX{Skg@Z=+Iug?M;S^9IBsS2FA%Piy~R8Hq;#7uKe@YJqFxJ=A&o(9@`E;PzD2 zK0B7FodN$)JIURACtPVt)$TS=A&!USah|;6Wy^e%$Mb8xl2Po`Q8t0Z>mK1M1)kz_$lY9!b#8 z3HTaV|$liLpHC z;vwt5l)@lbe~EOJU&4~O@so03U6k&8%zD37wRf36M&QyT-TtB0lUO2=*emngalICT^59KvnpFZ_PMVrrUaeGhi zcm;nZYKT5nRP~$Gsa<5d#2AIFn$Sx;&EO}P?DyN8M5HR*G)(t-kfQ9mFLAP`zLLMQ zYD5R6IlS~P&U|~~^`wf1l%qL!kc0smJz(Eo|n}%HUYg)?=`QLXxv|%|9;Km%5;VLlImh~tB3bHHR$kjRE_s^WW1?a zeGHcRzYWYB(KcQM}OIx!O|{FA}V^JvsT;agx10onp%PCS@r_nLAyyXWT2`%nqr z#T%}u#KJh*XZ~(BI)#-^u}mif)#n7@nRTO5>zNPMQB#z=WORB#DCL@*WXn{6vfNcL znqS6Wz3kp$)^VQ5@Q~6m)g%;bB>OBhlJ501q_CDozDH9EmmUsI?(kcVrJL>Z`?Qth zF}>^_&RU$k=CCMd$@(ttvU?HDtymuM-?w_fHMW&vPN#e=H7%XYrHPm1?dIr~eM*>23l_@ccpP4}7O{y|qw zzA(q4)EwV{>|QyY=yvV>;{JvY8#GOu?7QjHfdlNw)$dWtv#)ALEX&p#c75F~RFe3z zNy{`^IZ2#{d_%~(o zf8&x?{&XSi6|eTlkH0qes@uG4ZG1TsJ&NEEf=zGyy^&WUK_}M}C9=+C)16wQ#F@g< zAxV{OXU<#+fhASIk2wx357e^B6~;0Rgj#f2pADjqn2_>}Vvlcrj*P1O%Mf;cQ2c?! zNCp$ZOKCaC2=Q*hVu7!=kkXP^M&pYT=!*r+AFSQmqEIw7|7vB_^Phi4c5>jzLBxX* zQ~0%nZ4Fp-ve0X(ow^2IUce7)28(tGrbL8TLSFkX5>wq*;5GA(IW!c>SaUv*%n<^R zft!)*i5-^jIl|}ZIXIqXL#%W#H=VyW9&iF?;P?6e&41}MVywqwc#F2$wY(3ZURUv& zEz|hVd!2>^PX+tm+JFGCQ(H=g2uC#ZKSOu615!r}-hilV9M(Ic&Am1osk1HtSRVJFy)*CvPm z2_20LU^`|Q?O6rg+kGdU;72V=k?Z%H{+ovlvE31{`_5%`RUXxOR5@%}lR|KwM$z+> zr@QZXVoA!hR&iMCJQ7qZ+S%K1ca~GnaO<&UQ=mNzn9Ca5MKf3Er@55d7N>vB4MWGg zmIJ=)%jqH1zuKGTOoGKfxXx93gtlA=FPn9~AsA%v7%{Qg&ZhfVQ0Xab?bxY;+rN1f z+p+YfPqUXGSC0LiJ*Ii@oEYNNik)XAp!%`y?@UNadk}-{VvSf)HBY}QCk?h9Yqc%d zW3(l{K=1h%Tg(PQDk*(?heY4yiZU>BkQn}<#~Qh?CB^RonhQS^+YEsF@fxes$0x`2 zPFE=D#-0%BQg$A@7%TcgHR#~20~e#E)sc8*4L)eA%k~NvihaQM)hjq{jf{w!y*@x&d<+aYEaZp zw@Y0M-Q%i`42<#~x2&x23H5PDQoX7|lRj>EMhNJfvN}5+sLzg6CXR6Wib&_iA3qiG zsWgza>p**Z_&BrRcI6?$aSQ5WxmbGlg~Q8B;gelnDjV6|P%#xqzUZ5fvscw)6uuof zTA;opjzhYed1E`GOLY%A%>_Xlv1M-iLUwQpD_rLxFQsJx&z7C_{_7)%Z6V+`-IsS9 z0DWC!cJg6C<;2sA_UFf+8^IED>Ikj!)BxZ5ju9}MRJFT8X*VTjcH}6G6rc@e54-Od zRCr0~0v<4QOcp_GbeGcf{H-N}^P?o30wh=C-M%e6P>n1ixAORTA z=H7#5Bv)p(->1Iwv)y=y!32;H`r+psA ze8HlhPRawVTKKKu61f*|O9`yuz9l*SfpxXYRJs}p{X_EayNZ77vj+Cm!kTq&d0`QY0 z;9e9pn2XmI}7bLjvPrbv@ukk2>{bIqHg zZFef{m;pl6Bv6Z;5(s`5edmt?5@R%B*3QHc-{KY3HJP>*!29kF*{TKBk4Gh{id{7D zZda%TY{u;bWRM$Yotfo)AxBY~LlVIWs^3o95AY>>&CX2lYYF0h;7u*&qiKqOQc}x} zjg#%Pc{C9=uys_a_g!8y+%#UqmIArY@qvFO2SXp01W91KMEzXqhGx^j&`xB7_w;`; zq=U2Xn=xC6;mr1s9Ye}|>|PUHdGwe+1TGM)e0}#|ZvbGV5vz1ymwyiFQ+$S~%J6{H z_lua7_4-10e~YKCNGy;hR+DEH7h`8t4~_NT+of(#ov@I(_!}8^la@BHo0J$KJyUnQ zi7qkRDqu{yS#+V&_;8lg>%9oD(QPdD0@TGy5+a`95ssK0W#9G83e{y*NvqAd(oJW} z8Cr}w6}k;h?fF{O6I-JgKSh( z-;xL6NcC@H)Q3kVyg~cU4^L}xwKu$KINy@PhpdcKtOEdz%Lh;nQtwjF+&7;$S;2%G zaNr_h&p-dN=T5qHfpOd=zI}JnJr48DoZcR?oBj=0MAeO27q@cmL?RJ|7_T4+uJL}U z<-zE#4wO~Y_w?Lev>~s$A8lv2OdA!SVdAU{NU5x+q7J6S)D;e5>Ja`)ZS+naBBMo6 zX&c`0>gOgxKeuFTQm`b4B-QXS3j<%gkItsk`e1*ZG*41Zx#Q|FidS77aX6ntS6r1} zq8l*SvE*hY<;fe@r}5w;iZ-_CAZ(qv^D+?pT&=eZ1w;R2v18TY9ObDJb0X0-Sl0}% z1^y+EurcE8lw3M5M`cWO*(mey^K(4vHwwku8FuFzM!o^e@hY*s`KR{=BF82S2heic znhZ7ua4FkfPB!8;ezke-67WHaf|~u?9j@IhU#eF9mYs6pr+fEgbD61d`lYL*=6nT& zjWtubot#_-y*c>zpZGKfAmfnMpJ4Pq)uRlG!aoA4<(#o3^xda;ro8Sj8=F zlGY0_P!~`wv21lOjGO?SK;}0!crifi?y_-yx-W}o^7=BveFAyY&he7UCYz56hwQG) zBJre&a9At~jZsoJR)sc}sNdM;H;UJYuopDhtTh8pd2>E)|pnT)%FfpxzoT!bEY`63TNgQr1bCKvw@+t`D5_O%V&fZp3BQm#E3aXQE#q{Y)5G;imOyQC$>AaxP`p z7{5X|G7{?K$hMDMSVa`H1@#uW3~i>29iy4yUFi4l~;{mY*v(1~lZHXk&t3OA@q zC8pd=ydxVVJ|EvzAf&tY1y zsmVkwOUD@veJnJI2l{iNfA!G&@>Pt|9eX^||Ht4~jZmiN>d$D3qA>T7zH@pPmyaSL zXjxLlIuBTgA^Kct#uLdp zjttd)D_yRO<4oxZ+e(I&&`65a#wonY+UFx~Gpg*~G?J0Cmw{r_gO+-cB;s2?-;mpi=CJzp~hjC23eQ6F)Gb63tW{8Q9DJNIm9g1l96V=8Md z^T-3X%o_)OKGL`7{PpSWuWQ~Puvq3Ja@y`x%F!D)7wSe0I_xTFtW%fyQ(4^9cO9q> z*8+(l=4XJigJYBola}>kix`t?{LV)d)POX1PqnWYvOt?=M>4ViL4S4TBF-g`(_p1Z z3uLuZ!TC3$U&Y&c#tY zAlE8+FttxgPuA30Mz86S%0CdKbt=DD*gv3t%>@~=Dpe+Lo}>D9E)N%Pj2L1*b9Sy( z+n8#(KvCvCf`zXS*e7O`6Qsav>%Fp>R=#bu7d$%E4_6d@e zROgglE^WB@6tQ?chGqv4Zp+u$2jBymF=U@7{b%^bJH$$Uc|=E=+%)Mt%2x6L8e zZ_83i(=DJVQN*iz{KJOL0nZ?JFhRbgSEA=Bk}jgHXui5N=vJQ`aTOsS3LuySODghe zXQU5?Ri$bvZa7-@=e)t}K2C_tGzt1&Ii**Tv%`I^fYmSliMX|Gc7N&|faOClJAP)t z{oXR9@x%bUtsfaAAJ3ZE3pZ-r07&4_p~K3mDFS@jH$Hu_lzzW@9qrC$!?R)UoSv*& z8AN=<Km&~8(;^IgIF5~@9YlRxt5`@Fz@HEK>ul$=rK7O!SpJU?=0T02d>C%_0VFd*v@&3 ze%rAvKsQ~aMsbikJdM0(I$cK3i_(Zn>wR$>W0T*Z5mu$F0bHI5bK+Vy>5av+cJ9+v zhr?0lADvJ8zo5_ICuN5u(x!VV@;#JA)twi=LW>ZCC6;A@t{+^xYr&8)W#-dX^6X z_R+o%srL(!Sd|3{L|)}rlBc~b&t>^l3;(SMbyVRyARX3R#1}?LowL^)NaZ zJ7NOWSZ_fh@PCj0zeb?tBF5B5VDkw_i}sZeYOkXwd;2xciDXp&}$vaE^;IF)779?)RSo~q40}{wh69UvQAsYFx775z8*^EnP07LYm7DOM` z13u*AM-bvkJ*>TN;R8*L+XB3k|KhL{)d&3*7s$N>ou z61E8NBf>V|j~D!of)4d21f8-x*kZJ43O9ZXYaGn2+B)Jo(Q9EVN}ZlnVmPhVLWsZ> z_%2~;-$es9-9ti0L{m44uZ9*CJE zm6>I%VDCA=l2y?gDd3#TN_!7x^N`t3m z8HdzA84NAq5_RbPxj_S1YEvIeWGLA8F~B^1#Fo+EQ^04uS;$T@CnOCZ)Ol_ROiv8+ za;AgqvXkR37;b{x`1>bv{`()qhPYGmMmHcC>EX$cgyrt@dNy7KC}vDZD3&HWf!_!O zQgsy5RFozfWCMu`g3UGCQ}P)>l)opLzio_33r+pPL}EU;_+B(lJ^>)UU}^juq?PCI z{b197#0@V#u)5flfk9YNRrR+_Ml^Sn%-Q9ZR(xQzV#Bu;5zUS_RA%o~uFRtxA23p7 zqei$)r)|IltYt%Cxd@UMw;unutZe!9WW$!d03{0PFi-}()n0UhW#5{w8~d|K6O!iP zPr<+54UJ%0r7nh+s6RL2noS4sS1gtXDy_^@on%{HyZYgH!d|N&F=^ejjr}y)j<)IQlUfAV6!uX>!)?dDyVsxSt%FZn19`8uC@^n2 z?dQ-CrQEeGYw7=avf`DR#}+ddFqP-Oea@IcK4nhvGYf8hDywa2jH=N!qjO2}Xt+?f z_~jT57^#((Ivz43$7$i23oMK5{_oyBZjxao?m=6qh?oIFm2{&Jb~Nf1%8I|Zhs9DS z1kHIQi28YL8I6*f@5RdR4F4)hCjnz~SFyxIdM)GGC?-)2vp%FB?x# z#e5E2>}z;hv2^3H*@RhQr&E;$NZN8~OCL||K|{ z#!+MDL5vg}TIW?4fjsOl&I|Rp;t4dMut^7PyuY)Am8gVUf*kHb2Px7-c#rv3`jcr1 zka065_u;||`I&o_nW7{b-d0@vDN`lug~Li|#UI_= zwY}(bBa~MIOQCW=*E%Lf1!En^&%93=0r_bD2BBL6i!Wf)>UUF&c^Z=iNnjT-#p}a_ zN82)u|_{J>s3(LV>A!21+WeM;#dr`!e2 zln)(#ix(u}kW3e*c{}Uc&F51T=OrBY8n`v)!~URu!qYSfiCf^=WS(@-k62*ibnEs! z7K;^09Ni1JTl$S_{D^0G;TvEZFhUXw!~SbeG5?xSNHkWrQnT|nER!pi!R48|(yX$T zf3qUQ!~Fv78K#y^tDH!xo-4(LdWahm3J@;t#K1xhP*I$a)^qdW7IaE11|ZTtxqO5=FjmWROw)1LMJDv(tf{Amt2T_!QK}+ z;pu|v0Ml|)3EGdN-(p-GK)|2bgs9e$B7@wOqVRD1+S!=DQgD2Taw-8BX6;z7$&DhH z<~cIoiejvbi$CV!;i)lda(7PZH?<$+Xl>4s!)Q_>M{6bl$i}s~8RD}*KD|EH>I7m> z4ebUKpg_5iFUh&jMK?mU+60>8!N7oD|gt!Me zaO;=7alVXW?1&HuQV~0A7`K9ivR=RGj~x6+-}J}%M;t*!B=u|a-_3s!9XYt5 z=!Lo3Z{qI7#f}s@f=Is33@Jzg`qrVnT+xyMI504!aGd=DWEQ~>NO*#F>-&HzgnARG zq>aZQFsdp30ZZbsA5!VOIc%3af*c{huH=Fh{Hf<80kfy^stDou5S-AC6@xvr5Y0RQ zu{e;XfUeJonPc&M!X#`@>>g~9z(J_Oy8eIoLHz+iG8Bqo!;sqd4y%V+JJxN{@Bdiz zV~TE|K{wV}teyJJg2LZxtGaR!qZTmZwMNJz)DI@c$>CREp5TDd{u(;>raTCtwhk+= z$r;FTkVOG!jvuC|15Jw6ia{c_{rz+`7Mx=nagC9kwVfWsDmegUU#yti05VE$`~Rw6 zF`HB0?c%+LvcSSWcvyKan9S{yQ+N!rx_n`ssD;OC>;RXfh1YXhuL%PCM~(2qf_YhN z4Z-Syw>ZKU2<3N7v7)BJaPhoo#p|A$~cmdPSdK-pc>BP>%ZR`hwa`1XV z5}0g=g`LBC9>7Do;o=KRnR-@s)nHL}!@6cx_S@ukQ#RY4msKNc8UO(*wXF885b$>9 z%Cnk4*9&uEpx1skIgzlCwx^2~u5FN&lKCy2gV!qdGKNegR;uyupC30{>*`mgB^Xe+ z<;Adb*_4>N=0eGV%jCJX+&EVxu z2VRlix3BJmT-Y~lkJ9E5tSoIOH zX0PmKAdSQ9aJ^)xMA%6Jxr{^F+hckBVm(hS>`mB~rx!5|vX1Oh-Z|iZkDK#>oF#~O zdN976>H0h&t^@!lc^-A4W>DGkWNPcikAkCz7KH_Q-y<=9xx=yyBWUZ;zEC`Ub`a$J zA1~0X0;~e))E(PEy8!ZDS#f2*A(xk5Bp&A%%^cc8-lnQPPl^tf*vTQF`WJ5cZ=&J{ zSmXunHG~*nvs=@#f9%TN1wd2rDCOss8Z)ud@556ijqe&gaNB2Ew3bsKv-#Ao=q|rB zuszQ+8hsav9p*ki{hQC&Z)=H;5@Z zFRf89Czi09Dy~_?cU`84@dKdq%w_gB)IJ8b?SgT7e5p;sFQ*q2oR$1i-q)o+7D13Y zYK}_ytR}uQ@2)oM*;m)TKw9abm@ip<=I5R`%3;V_j8BTYgmP!exjc|_Q2S`Fc?(|V zE{=oh>jn~8|7y`oR$rhZlU*a*m-bKDT#M)LSiXrDDbsNz7oPZ3f$0PVnGLkZOQOuV zK_$DpefOipOkht81O`9yderc>#?G5ml74aEh1m#yhl+kSiSt~h^Ck)=-ToNXrgtrV zM?q^<+b?(cb}P zv_-xH$zWtybG65}xy+FrHTE46D@v93$JN_?GQTmO9R1Ptq+GPC^xi4;)!kEQs56r` z;W1u9n%MN$xeAt;Kh9?A;j{M*@wJ#qS5-`ny2mvbx?HoW1F^aRRqko7UcjIIl~w_T zS6G~mG2fT4Vk}S%8Wx^D6=^p056xt`GzYK`3)Q`2o;TxDe8qAnnM;04_LxIbrWS;i zztG~#S-v=)mNV`WX!mv4y|%#ZXb_tXx(&qy$Ea8*D46Wim};S z9m`b(rhZdcD{Uu|+7Ar=rXWG-Eu)8l8SP1CSfw<8Bu2)`4WReokUaQ$Kuh__aH~pG zhyNI4@U~(B1ixE1@gc;&?}$O^1&E*&?Ha4Nku$4KJ9P zjpR&Lx1zq8>F2kZ)w|^Cdu(S9z4h*#SZtKp)IpD%C#F}xwgY_o#W>nk{Pf)6vhTz1 za!#YyX`i6(d?ip*XKI0=6AJ~wf4sM&YsB8*IN5XRg^_pRd4}4DyL(n+u6?kAN?~5n zSIs|v-sl+shGqob;x|C@%>eWNTu2`T#-b0IenDJ^nK1OUR;#4eFLPpd^SFQuC zPQ&Y!ooe_Y=eDJayEAJ~C~w`fmNAYaj|82VbzB)ka%I61mpTHo1;l~}Ro5`dtm#(a z_ltE`KqFY@q#g43fSKA(5KXMsB_Az0#jwo{d@bk<^BG9P$amVgZ2I8JNB&J6|g_MG~@aO zOzM6e?Tvi7hnd&p=`T9eipyVyBx=vxA@^MU&Q^ZaBP^^O2~wV_xx3ktzv;uNeHpiJ4sfjBsmrPOq3$+R z+ej{KAcHPw;)kE)D*3hU$CFVstmQC$aU?^9e*5+mK1+`qNHOG>b3jjxECH$-oiFiW zmmo9A4|vF;>QIK*3PBon?Bvk+)yYhgtuvR}#!H4lB)`_1S6z!qTdXw+8M0!;3~!`U zPXFjyph{HnVT-XXmpNIsHwvE9C?Kp!Kpse~T@UKtvb}xz5OBoa;UcEjZa^lwFdF7( zQ-J^4vxu%6HDc6UDUd)xo0(6+=tQpgHm`G&7<{en3)d01(Wf_%cZdZ*G1W}+krI;B zHaY@&rOLeD1yRkAe!@{dO4}>jFT|^nG1Ds%yF=9ffbhDK*RAx#0Kh0~{Y_1W$efRj zoUlD$lw#gfTzuJ}&@e<|tcxj`rGZ?Q=P>kxw_HnAp-Gz$T=69p^*Cbb7?&e>0D4%y z=VGX|@bhoVa&Xo9VRKa}3_Smi7=>Jh*6GJc5P<{1@@3eq2_+R*wj#O}cuh!Gx)_5v zS3F7TFfTBgK+P;*h6jAVq-f+>aY`$IMIeMcU5(V7hpAdWtYf8N@DjL)u^muN4(y?r zXdpzUmg{*8^~kTCP%IYk`(mor0&I$RVw_G=n~ro}1E#ICvN|BC9J$JnB?!c>O$oAR z67rX0pWosmS7V|96@vW{3~HGhi)ESPbXQDx4FF4W@LH2(QUANP?tvcIcWJ;kV5oZ{ zg;=$hG3o~^sA2spHDXiNpYCcgvKP_!Rv!&oPnN(KAnOZ?7*+YZgYc`q+l~5L&5X%X z5po>H4_w>2cEwuewtnq@ZvLM;|9AO|s)Plrme?Cv%&&uF)NH6r=_A6KwKy6<)_4>w za)wjX>chQ8pm6_vJi7^nSC}tdJ4M9={r_{2`E%K8y1ddb8V1WYbshwZz+auku%n6< zuyaU&z&x-B|E!(&$cGJu!3&UbR`CEl>;twWI}(5t5V2#=hT!@fR_%;+j}79`woXj{ z_89R_QGzT0D-M7?lld2u(hFb9q1cx^)3n*6G_f9xEA-G@hG%*+KzoKs=39@Y+4~Qc zY5%UeehB7*VOAG!npnih|JuL*&SkSD9D!PS9wScj{;InS^W{!1*9nb& zL4txs>yypoJ1sXL5ym%P8FdTh1eN&cTPLS_0s%IyqreE}h4t`7wCIU43;@AhT@30?4 z#GWaR^I6uw0-X-Ax-)VtG5~5CGzrB0+=z{jL8=Yy^YuyRFS4oLI}wEp7R{{49@9lH zu;gpbAHTXZ?|*i_d#Aeq|Pt2Sbq1gwA%N_!60x_*6!2c}HJ3tm}Al*1~RA2uIeBSUc?A*2iKqO#p|mk37vKU@+!Rpg)00G zu-vyEGgK(n>iSDO;rUFyOT8lR(awgj1k@0EPD9mvh|^`IXRcNXvthw@9mos>ck!hmvd^JVkZl z#s{5}&$tC+BM}>!K%)i5*%LA><$PU3LRVCWI(bEdAu-(&fDiN^1#L1kw?QM5VJX)y zQD=cvT1$8reO(!7GuK)ItMfYVt$gIYqXyor>FR1lU}S{ zd8Bg9()+nui_Fw#8evc~m&v>>O91Nuunt1KJFy}!?4#RPc5%7MV|)Zwngq67j%pGF zH7y~!Cn88_KQkNMp49kuQ-31Kd93sA*kzVy_4Pr2l`qWqKYEF6tjf$1*QZ5}8Glxb zb*R{GAjODD4}GX@nQh4HET6am4o1gGJmzZmH(3wo-hPt6d0>ML;;zsOH>%zcwAYQS z=C`GEuJ$?7yZe&+MgHke1m?IuPV3?vgo@Km?)A-7_!=bRK1MX)Lhxl0lPeaoF-2qW z&d9k3>{7WQ;$wa9HH40Imd*ytf%cq-v^(U(WnD=CRdPU@j3B~s%H&zw(W*;i9=5{& z$V-ba@VV*ZbUGePvzP7-xn88Q7h+<5SAO_%3V9)6vq^K=eLy=Xy7d6$uF;M8~pToum-oV(&`OpPcp zG@l1R-q5ehjiQxYx<{mu3E(OJx&-{-LG*58j)#X@>74K#Eik73iA)qycM0Ry?VbO^$cV&6Dy_$y%eVBNP zmdsl^t^*Sf z1D5t2Iwn?Ec&?TWgB3;Yovv*jOz~6z`v>7y$soPjl?A%RW%v%4sihqlm58-OS)CWs zdu^GX+#eozvCYIpjx~*K8rK~(OXkVI(xDy{&sw>#7||5Ixq}8$ORQeBx~d(meip2_ zLG7c|L%C)bS@U@;y?n3hm&?^%y%;K4v8MJy3$Hu@(dJ|L!8pCPg%v*-Fw^q+-iCf0 zETWUE-89thKafj~U`Tz1R3PXvUu0gfyKi_4Zz&)B#9{I~b4 zS4AiV=q&Bm%D0;XEkU1g6;?`2)4wMd6R}|gNFDOF$qRzAq1!{i@S}!BGu`8Kg*OJ5 zrta(|MPrbUY!iFgS}|#Vxp3m)-Mgke->|+Us^dY^ng>`2?{d{HiN34y^D#dCKL;s( zhaIJTt3svXI){x}5yQ&ufwa$P(aLH;zwgs;=UHOVLy3;xvQR9JJeYC*A8+N6oSrn( zDYnlLnOFZNGa$1>7)-%*J&1-G#&gb2)R zVr2Ju-daIF{*f*TA257O*O)8v*2c_!5_jiExBlC&KCemKUw^0yt+FRvJ02$C=8Sok zn?b}rx1KgM)CA34S8+Uk!AIX7^Qta?Uw#Wwwe3vji$atygY#J`!z1!U#=)nXHnTS zhW3^{*f-v(&&EENectX#)Rlj(`b{tP*-p(rIpBOsd37bF8mgzUNHM0!?8OpP^f%B+ zih_Bc#ZG#L*S5gMvtJ$QGoPbDG?MZ(WH7VOe1wH1=jd+K=<6O;LG`^wynpiyO@HOw zFmarja<#*mT7@VmWV8Ak`sp2@nIBiVUQWC23J-1U%yPww6L1i(;*jlMYg-PAzHFSz zm0Avb`D%K560wCPi0m=^e0d^>7&hgwmSPNvTIhZgoc|!i>Sj8u1x-0u4lR`$t-fFx zRolQRrNmtir@&CJ2~}vQczPBS@Qp|_vYbHw{hI)&#oxb zhRQNZ+Js6A!k~p#q=*KkB0{pSGqxxtQAuS@WhqONkUdFc$-XE1HuiP4^SwvU`@ZKr zf1h(*=UnH$uIJIr{FZzFZlCYxcDya7%^3g1<75odnH5?5@gsW0ttTHxE>1y$EqBb- z4m`j+jTHnn^aDE@7{%4BC>BJUdS%j(hc76)Z?#-*!N=tAdx9X95`G_vDR`C#w04p? z>etL8#p@w}#A?dc%!LTazQ@8wnZ+A?AlHQ4I(FHynq5wyc^B{A!`p@VH6(wnIErLF zOwlynm4sc{eD6u!8Kn7egd@PPSpjuPTguAEXV}*`zS{ld1o_{!5LSn5@m^}ANt-_& z;{B>$_jfM2Zu-i>kKds5kq6`U#g&g-2`n1;4m%Ivuy5VC>1v|_-uE0%iJvc2@4>TK zMwOJi% zk<^)Ijcg#%6pO#87FbHD;Hoad9X($nK$rGmztG#sRcKs{BQcG}>%-KCbGFj$6{J8C{v#^2&z?TUPL@aMY}4-B46#@&p*$d=YulP{G;Jb|R;HXs(x=m35-x#302Et|9t z+XWoK8Nv#+KX{Me$Y&Jhwg74)&V3bjCY6~wA0R%g+p|4K1unwaAiEelGmiva;+2G+ zGX+f_l$3J|A&Q0(IDo9oO4#GM*1psc7#PBE_6|3My3+&%oAYN#-4ymXjvTjOuPE>K z{@J*+9DCY7zYjaIt|{o@z+Xs0LDm|?-`$VHp2Wk8&r~Q$*9=A5fEGi$jF^Xo5FOB? z237y|fagGKl$}F2s7IE8l6$oJ-ATVJJDg_O2~N#xhH?k~dW513rDd>i18~Rwis$vy zBRN}zej>esZKS2(>wd70zK$Qlda2|;=o4%^D^;+>g^H+%@nPov&FiQyB70V2N{6Sm zw23-Hw|yPh3Th`7k|Xm(@R-h%A~mcGu!D|}ucO)@M+NkZXK?;fI>IpB z?g?Lv6FcLvdX7iTX-Q=LKa(6Kfy=F|F6`ihA-DLv^)9+2DVF)(9<%U4R@)AM+Ocx# z;cJLGq3Gw#K4e|9#tPF`_=gMl_ZgwG|CQA!R~FgKDE;<0^3NbbCm50ok#sGi0aUkT zmk8kv3NC@%aD3@q!K z_}^@SlXIta`nuHq1=hi#@h=A-E=aUo=tdj@;4T4ayA!gn-(y#-*3Xln3@f-I56%# zd#WRjfRA&X2XY+%BryXl$0Mc|X^OHQ?1Zoq0kC?uYwa1KqgA3Pb#y*pkI1AQ$1NrS zWui~@#ugE3Atz>aZv4;vWJfalH1vOdcL0PNG8sgZBl)>|%hvSz@v5ER;Z3l%LlJIp zRAPm1`C|L7T5LpVoIvrg*(xYVml5ptA9soYb`6FwmeUJFOW~lXtKG=iR42Yz1h05Hz?frB4wWzxr?%|Ccva&YtzM$) zjJ!N}K>cp((HKj%!L2Hmz($L`6lF={>N0}z;!)Oss* z{?^4*$VS#MIWksIfB^_QK-gyo_?xo9+@Fkd0`X~PW2%DPtZ!vLR*WKktZNh5?W>cxFW4SOU;FCRhz--|PR1Nket(-)^@+(LhPrWm*8K4vLAaiMJGQI21S zSGs4^*oTc@r%MD%raJoK9^CE6k@2mY*yY>)^pf>*)-}GYx;bJz^2Xl2@2L>O@uf(3 z&`Y`**qW1u+PBj_ZxqppOeO2NOlDAs;-B5^fEqKKD7?kUZ;SNZwZuOOx9KKLicCCv zEu=u+M{t%~Vh_Xs_ezvAgcLkgcHHYxu)|NCZlANgweecVx%r?bS*b$kC=dZ*Ro z-Xdo6j)>>pZX$JAVd7XGY+dFPeN~9`irxjzRM3*A#veu>F$p?I?vok(Wf`6*tKvT9 zuTM(v5)a<7y$l8r#oTjZ_5Li8U|XP8sf<220&v`8F)Q1rImJJY#hEitr54%kijF8B ziRJA`3zWI77tiQA?b>B^2X}S5#N@B7e(`B>CAyD+gOK!;}>(84JNzxr=Dr6hJQ)RToRpU(7(*6_;q%c*g7ajyX{9M9Y|r`ncSX^$v^Oq0cAED@X~+< z&Sq4UGQaVWvlMvpnM#OBDH|ZcZGJl0OR7eG?$qD{t8ezezC=pPo4zPv2Nq_*P`5ej zPZ4C#4l|b;nE91hp;3Srr$4zwg<0S7uB1a$0l-|E$IOH@I#dzI)rcU4xQyHXU=`2M zHb;o>D?FJGmrU}eEf+4)AB}$-Qipj{5H(vMdjP|Iop#N z6B*+IECqe}Dt4?e!4G_Uz>UkRx(wb0t0mscoGvA(0!}9%3*7}Zx&TXI#^OY6h8=tT zA@u?CLw?1Wvj}VVT5e}9;{Jh15F&p;hLH}NsqFtGb-2}Yoiz24s@Ae1b|@$cpsqSE zINAYeDFO!2;|Y!!#&&ktrLjaJ@0MZ#KrUxatiT8#te8@FqqAP{f!2@09zSBGezV~P z)ia{UZ1#AtgT}fCfKmO-eh z98rd8ww*oHXN0F+Qre=92-Df?;JZ$*41XuwjiDHN)RVRq|}KNUJ! z0IbeLP(@shb zgmHk!@$W+J2iWtWOCj0_wIkQYT@XicTu3ux!#BQiLSC|B0fLAxqILkJ7Sd>SySGmM zbbVNh=@yyEamL=mI5Efm_v+Z49{~7`=HthiV;p~kAJivOeI4altW4J=*NuP)5c`Ae zPuhcJ5+-Qp;f3r%un>OnV9oP}Oi;9jJ;`fc@}QY;c{yqoLT?e}MyOtJ(E@jpG7-;0 z5)g0SXH6vmLM-Or&C5@& zRpKtu5e7>G9)=21i$fk;IYaQO=?=`iO-Hr^o9eabN5v?C!5b5X#AoPDLho!;$ zt^RM!$itgi$384y6Ef^X0>j6Fy!;VGg8vm57P1B;PF(%>4I042;YlGFw9KVQkvbw2T^JVP79b9cShmWqP-qqbj^3mI zKPLo^pb#uQdJ#4l&1OISe|ec$h_%V5!}=oF@qr&fk`NyGu^c&%szvkpcm3z5(6&pS zGhZ7E22kjVpf{Q$GN&W$sMyMdVSf^v#-ZaOnOONlr5B5x|LPs}0qKl?na; zZ4wG2LX9Y%Z4mdn^4jL+)iLOF!q$dNjbexZ8bKH@2YM|j`?=Zr+w`9Oz7IzE{DIF5 zJ1#+sp_a@YIMtG!gm^?DG8qj9S^6?T?`uBJ#G6VDjB=0#mvc}4-F908h5Fyz_|Vw? zbBAdsH#SHMdAwTfNlydN=|B}90x#Pugx7WO#j}<|*pdUN);F=`vH9y=SO(QxZ)cXWte$RO$ARNlTS*tgaw4Uf@?48vwxi5=*U2mWIUgZZC@2>9;d9EV zm_~2z{G$_Jxv}Y#T>*IHFNS9hvU{&!OW!(0Mp=hGA3Me$Hsl@beshv5DqB%z%!HoA z=clj%!}^KA`GGxhXy&)r#HQTq5ck9XG2Olnu>%wfu%Ny|g&RBk1DDT4srm7O$s*yS zJ|aQR9SC!Dr^r5u!1^WWO9QoQPq2mfw)kHe@Eu#QWF=F_E!1NrM%2f=zo#URP55z<6kDYFnrkOsLvE^5Tp(W(0+rb!ZcxSc|g!y;A zk6j}I^yzj*KYXx!Uqw0?5ik`w)3f(;eNR$PtH(a%ua%68xfy$wU1Hq+xAF_EeQPs_ z<3&y#oz%{G26Nol%2Nd=bw<9&v%@NNHaBH9&r5&m8)bc@e~3=S$9%mA+kv&R*DzU< z^17I_nY++P_^v5m8-UlPQ636eWpbzI*vL+eQ~x&YztC3W;4krlvF|;yK2@>a4Ww%nsY`qfw@w>o65sGVnZCMG3e}jcNQ}y5uKa zN4-ADH!U_fz5fYK-9KWL_nuh>-RODLIy!5p^{M5xi~ac~cbF!OkuJwn#@?=}{b`|A zYqWOdiKQ#9vh2`_GBf@>uBjVje_C)Y@C_t5yT3S^+wSjlJR*Ph%J{iR34;jgqHM|@ z`nb2===0Zym~@l&mrH+IItTQQDVrT+O}b9onl-svqsRGITsiJq8hsz}GU9zq8-S=zYu2qdar9Hq1OT*8%ik|fl z|Gv2^VDkI?;}9k&pvMc5q^E8$`gs?t{f&BQ32H>&%{+f zon3e*kTaS2hwD!&vmtGz84?5N|m3www z(p1LN=-FWl9fz#6xcnw(Vyd+&au^eK6MFbYL)t;D;fhD-zXg*hQExc61X?wPNe3h)@6>FBlSY_OpZ2t zgv0vZRwtsZndACZ#uZNmRDPlV#yeg1Zq5>!xq6t7RBpt$;8Y=#tMY{CZ=$qx%UEgY z{cB!*a#a;($9Y_E@e5h;Q}FavA?PJ2iDQ#OAxi=H``0}3@-OF-Zd5;;+q){b^oP%* z98HR-bFJ3ImAXG6>|(7XbjQ-`I6Q|eU2>}9vAD`J z$&HJ#ljI05J=)10cChF4Oqco-^2DC4$6*I8MIh`ARwAFFy~(NoFwTX^@rc{%=8>!nhddn``&wxc&N z)75Av^A|bDK8UWU^;FtDZO(u_PKrjjQMXtq{149@|4gJ%tyt?8r4IhyC6}om6+;6(#Q$hw@j*gfw`aIO z-%bdqwJP}l8(H`Vfe#SgL4A{SqQVB4Ze#h5`iUbeZTZQB;oqM(gD;R%O>o=M9W-Ri z#%)%IW5nQ&8iB80*fh;d{*16ccID#r7f0sTng#SQU}C5Pv+uu*VNgQ5ayFB1)Nx() zf{aZp(ROMQ)-ZqG?Q~(c?yIQNtS(7*6fnBy%)crct_Rx^1N5*-MdFC!YAmAe#XRf= z>6DHMXFS0+c@;}#$1rW76KC@qC`*5u`A{O~aBqaN5kE51v|X!Ycq~3YvdwSX1&*^j zb1z=Y{m!M9=oe7wbNlP;*CF)@3Sr4+`)8x*bkar-wGQ;-@T6N>ocus zK4{jX&_ukH1TQ6o_5?Rh!=`N4np{JZA=vws{j?JA1?GG+yYAPTx%kX7&V&vJ#? zD&_<8G7itOH8Vf9q95d|VF>LdAy)S^%aU|q?$@RZW7^eAOfneYF*`4AWbZFl z-+}3MZQX3t1@w!}U;1JO6l>-xqxNWpqAd^pdk@qcOZ80I4afGr+So{QqhD9Xu`*6` z;0Dc5D`o^~k4cm;FrsereK7AiIkH&SAXI0WS?>!D5wH30LD?Y2wL(FnoeF)W@^$)6 z?kw}^RNuW?cCwzUwe6P%OVQB{7HH#G$C!uqPJg2SnFwrezd(}*J0bCu`{*80#Je`3 z(?AgkY-HTrWW8Mr6QG;6@kR7)xuLV(AIEw75U1UQ4ZR?fHqfOq*XZ38tda~Be^I%D zG4ee#Jl}KlL)3|@AZ_+*ae}=9&%N3DcMz>k170q&p439fHQfaXQDT%Io;+pn>f`34 zEi1O%gSe07zU`XdbB}mZn~ivW_N0DR`6P4i4@_xDs(onl*uA&cNKl;(PtUDs$^M~m z0Ui+Z_tkZqY>)hC$?H@f9<3Xg+tVe7AXOBAqQpy(l0t z_)EgbULhmKJ|iNe=Nf$45>yKo-DZC5w>ca~>0kBMkp#i`h2pI}d|x|ePjYS{YzJaz z(}~zBd!M)+ca1(Ys_DZd6MX;UYSgM*==F!X1Ei!KZrq>H2>oREp;7O^sYL#x2_^u> z#D;&frPve&P*Q9k;M1d96#wZJ>0^|H9KD;S|PSEYXELd{0ELE5X1v*MkLzq20YImR@lx{nQJ z1ef?Kyx(5aKA`oK5(XNR^svXPb>J%ft_7WJnXvu zqjrz1aVE`#PML43_P-N(LKsm4UyAeBRw@E|-7DLOqI5RfUKD;J=Yyj>27{W;Syz(Tjy-Tx}aDLn;~I}Hyf*(D67}x zIpnWDbz(=HKKcCExw`S)eH3N{Wve}XR&Ss=KFKq@!|JNmChBQUbHzHO?x%_ zbdH0;>fp~pJAh}x!S@Gf$=1%k$Yl+|tO5#7v53u)SGiJZVWK4<)tUBU{G8ioy<-Cd zhjVNP{7CZ)Ce}k6{WSl&@LLG0{(-5iTHY(l1bcKAp0!fPMFtdeb1Mo zr8>=nDQybL)QZa_<%+hkD@lJ?a(yT1%!NlKgDPT4RC`Bmi5r`uj|#J+F2&;ap16w6 z7r+#Y5}DpJlY51<-mX%aS*fhI*vBk8)McILG}E%eOozKEw3o!3`vBTlM?C(ULzx*I zi|RlI?TV5*$a#h4{k@$}e>lP(X?WE9h1upvu4NLP!AGlclvBIsJv-)0Q?q%2dO9*q6q0G>jnM_@jnthl@a(AxL-;g0X%MM(Q>jslQ&%L!v;kQg(2{$I>9)V% z%V&pzfYQmA&J;=4{ROTAXO-G(h}6c+ijvC%tFoIGW1kOqIWux6wI5ArY)D!50JP;d zF}(co{o*sHLwAdNw2HOm5Sbsm&MGj$2T}1q%>WQX^dS+NROhm%!F;$=^xc>)^X>0TOF~lMGRR|)KnyyOX3IIdLCebq-d64Mr2?~e zWkr%DeX0c@AJ;j3k)mh6ervDquy3fc$M@W*myJvMz{zDkRR}nm(9aQ5C*j2h-h~@a zFDhd>`!|*iVs_O?gwYmkUS?%`+4Y5UC*IsOZ~St%Qsl+BJ=&qcjfCW>ApOn*gbEk1 zv<)LuXR3>!F?Z;xg|kd#u2VfPdTnn;?Th6g^#DFtW7{y35E9GxnXyvVu5&9MwcH(C5d1xsDC=Q>g*vz_doSC6w9R8mftyWkj4 zGn%H5obEWnDbb&G_hJ*S+|s%e|Mb@Md6|oyr{ZL#dDHu3ryV-JTkTajpXy&d#WvaY z;xqT~Jn%Moxk#&eSrDU|MT25?$G7`GX;-p;*q6=duw61Mo_#4I?IAu)8Vp||9jqQZ z+eaE8-6Ceq{wTi}B&Kyh@L^6{<13l$5#O@%E- zK<&xwO>^Gj$HEZ}-#e2WyiSX9;3CJQvs6BveR*gwr-->!5)iaK%QMw8Y}mN{rKL`F zb?vBg>Giw*)efV&^x2L8&Qs%{%vU!%n;gV>hOVU#I~PQ!OTvLuS-JZrJmasHnE(T> z*DUk2Vztcp`|HfI#z?KCyurOgw9b+yDbK3K2Q7LXdszpVLbdXS0M>SKF5=aL0-oI( zxW2Dv4h0T9njT)QTJQPbB6UgPsZsAp!<6IOV|hcZ1%Ozqw|fr|=46oC4VIa2`IJs) z;aaV_Ggb_J@tXVDVx^E{`_3D}UPNYf{ekN_W>=8h)QuEW{#iXCwE6RTreja*DN_2C z3h$~a+r-Vc2sZFv+rY;Y= zg^fEtIQF)AL-sw*h%F-L1F8K_bBTz^%4!tDBjI zJYgd5yAQ8AH_TtV_r|p7p#zAu7?Lh9yeCQWxB}A(h%%E>1kNN6=+F1~l>QJ9ulONA zpD6YaQV)v|npL4r%+7V0P^)PpgPGkx%Ejg~rfNd#12diBuviVcu+>@SLrUq@Am!Z! z)bwe^vcKE4A5n9c#QR-)OGHQ}C+MV@6f*}T-J1rYMxH0q5s@-#abi6jW;H){t0@h5 zo?d4JpW7Gv%(hjeX^n8m4H-YDjzO;$zsaEk$3^!%mUeq&iJKzpfL#`{WwRPH4(GiXX z+TiNMTx(lquk<|geaQEC1kR1I@N4G!E?wd;xDgYz#0++VBS4QXOXH7^%5|y;htosF z=Tk{vo?9OZj9(hRd!ubO_WiVY%V7~VUVsY2Q8n@ac+sD{Ho0!loT7vM8U)+^15pN| zKO_8yD}jf0xa{ibhS}JRyW=U-J-iPLFfMQl#MdenxNSIxRc(i?88!L1xvUg!@Qpb= z(Ve#wI`8PK`J3T*A=&kv;kpr(skk94TC#4PUJN>37&Zoj&k!;%_6C8rhUn2ZU;KXRUUW?ji<`WY$R)uS!Hy`DX(4nuB)N&GkamZh2;q|d{6z3<)s zPJf{rxuizsk(^vn(Ep=6YZJB8BNIFgfZ0vwZKYP4@WsG6;#mB-N5yhH&zY!po(Uzc zKOj%uT?p$RhQ0Yslbujrj+#T`w-dUNXK~1jBBHQ(vAWgsEyZRHS+g%!)kTLRQG9L3{uiBjSGV8b<6D^3kn7P+SaygfcFI8#f=HI!Ht^!2MLC$7{1OAA3Awk zBNb5pIF`+vbl7&#gK?&*ojMX^fX6ze#0`6%4)LOfwX9iJk2p6gp917Sqdra=G zP&>U^S<**yFTzZXJMcfZo5_i(S{}g++HG)JZel??nK{jNjTv=G=dt2bo5}q- zpfMb~^ySIDD)8=9tjmoK!hNgkR^wWW&V}q}Yv4N~83r6ioZ{q@P4q|1hG$yy$q$WI zpFNx_iuJq8Uk*jR+}9ZX)~IdXNC;C_M0bV+1_lnE7pu57A3J zcmUId@Ty6o17jfXV%E*(8CNLLl+e(&louy#0&`l9$+XLS^ zdTV}HQuFA3JTL{i>+VqOgb5@kx)MT9H?7(-#N?{pw@`Db!u+m(&40ESXqkkhEMd2Q z9vX+wAk-Z8c&+~D=$ahvJ9j_%^|9KP)}$5H6>&R`YzLe+HPx6A?`Hj)O$sVgbYlE5B8kxW04(9!Z!_7d{Tw zYD}vkoffd$_-VIH1M2TJnrz*b5VMSD7$5l#(|yW@dZlu+(30K58AA2m>#MsY#A^GUmEzb&lUP6ENglm^jL6j=dGyTZ!Cid-wi}JJ(5PL6JE{?CD z+0&XHTFG{NV5YWzV4CbR#I9YjI#p8c@fN0 zccwl_mLrR2Y!!L%=(1mrkkm>qsxm~kE+fn@%!0wBb=7$f`U*;jB^*7p%trv9G@q6T z{9mvscttJNrSQM^U#U&1(iqIr|A!0VA#&~(@A&VviFX#RKuqT;9G};IM_Zfal?;8q zj}z#@|6Kws%5VL|(WX6$!Oxi_)y0m@vM*Ryf~mSMoU7(F)#*DsJW{@7zx(H9TnIi> zi8>FSWxZ{B6R_5H%?@y3WUpjqg#sMZf}R}5ar9F@ac}CYyk{^=lGcHJSed~{)t%=- zDhI-+Y&l@#Nd-+U|1g|AV@@ylHxF4H6d2S>ag4D`NL3Z|o|B&3xrML}Iy<_Sc>&Pf z_O_nXev;nn|Y~!0xRp6<{%`f->2FH?x>so zkeQJ2o>@^K4xXx{;(;BH3Q+bGT6esmiAG%)tta-`YU&JxndgsPC;8kyUc z9}28zM%jalxrdD3kN!26b@GN(-uhY6h1`OtYyi)^RvTOzK@r|q_6zDeJo5`{Ir zYm||+kBEm(ROk?AHAb!=(|hrjBJ{Kl7pNJ!$rOP+LA5dDxSmx_yR8l(FzrW~Y|x8| zH;a>lPnbAtuQOI~|Fs1s!m{3oeM^5nVwoWYqj5E9Af8zc{1F!^toJ7NG+0!f4P+iKt2l;tngdhto4hgmHKR)G5%XvJpiru zNZDYI;a;&@I&9qxAs7^8{o7Cxy&gQ(#T;H!3&NWRJ-eR&sy?Yb z`3T%pRRVuc1ZXnJmAzn5W^qbjK{3lCT`X`~0VFpsA%1Gl=mxdN49@2#CXiK+;x`xm zPCHQgH~3+{C$_#{9^EZ80^q$+XX*3q^!ZfG_1t#i3dG_@z+gc_w^&k7KvM)c z;=my+UuXHjww&L*Ts2+Ef6Lm0ifM`R_!^DSxxGTyvjXGmI{wO z!EZhmZV1yh6aoQ%JE|k*QXL=h)H81`{WVAU4K!xFs`9tRCSZdZJFwLrB}}gQV7oyD zd&;XU83z94L5TNl) zW~Du_;rg*yr3LM~oT0s-RWdpXs9x13N>e2aW+jS`?f(-ZiIlu3wLrDUh{v3~GB}lgL+|Zpy9%{fbHcCcCn+8ii8xNQZcbov=V-{^Q|l?a z%P($&%ySX>=S1nZW{v<{>5AZtfZGZ%qF|l+Q|ab^VPH% z7lea($zwf)_5_|?v0LYy}7$=MD_TU$#JGCV@UNJ?|~;> zXxB|J`*URt-?xj4R*KAy!akB63t$h}6{*Qrr7KhrNN5^)c}7g=UCoM`^nyF{G1WInYU;$#(88PI8SI75yE-N&3!k9Q;(R#P0aI--%@&%!VDeB z3%`_{Lk(go){aUtekqyAKG<_+-<4Ws>ux*BAjS|>7WXCMb5dISH=`&~DbC{VYMl`Bbr|+#H+?%Fyq2Qsq6eGuoktMP0p)pT2|zooh{fbNpLIRqylop z-f$)wxu4#L&%whT)Yni!A#(J2K5*FSEa!u*Y3;KEP>o;1o#&n2FT(YU;El9T-Qqa* za~?1^s^#@Tky`tezV2}h_%hRc;n0QVS8qm6&VeFcmfc9VlQv%TTo8LU&M?UjT;pZ1 zi2rFzV(gy>&jH#RA*P9$mMRK4+{LdAdXu4yC{YIk+!r5*+5U24bC4+z142*A+OqxE z{9E~zHre)p=h2Cg5!`2Sq^w#p1ck+Aob(eiV%uu=vXO!XVM=NH$H0=fqqu5ew9bb0WF@&y`N}EUI8= z(qq`iJ3)@H&9b&zr23-}IBWk!Hceh=-uIhRhc}9vZW}+9ktgoRaGRGQFR2Lqd~tzT zaKx-r;oOe0_6$LTVeqxh3(u;uT-2JrxoS@$S2_3wZj~jU+&SKcC5$+_)`_mR;3j&( z9>aza`o9{q&S+&GI}>w~?I5U?u*UO1DkWW&GmuV z{E?!=pw!N&VR6*b;Cq`e!NnW%t`$TRkOQ-dO)qNNH6*{U2255VwiKRGj19iI{KM+x zNPElYk9&-MsD89-KE@6PL)ic@oi5s} zHOfz5BQG|Ct0RgnK+)0n`I)AGhhW{*ju?Q}3g-Z;;X2MQr@#|dAvnxwq1(?BHy_Fp6yZ=7M_LJsF9ExG z{7@jpY%eS9t@ql@rw6=HP;g}Oi$RYTu$47Qcm4SjnpYW zh)(t@6m2ef1m_c?A7Bn1T&N8d=ZvEkTbdwy9g2KY)U4J0%^VyGs&2^*vZS}uud@t& zH#Z4x{{H>WT>scJ+ZE5MI_W|*h1-`8jM%O}BZmfh%-t^tM0{I>K9pb-hxvtI^W!d+COM#%Bh$q6dLornh zPjycOXom4bEWhdlk=*)uQs-l{@cP|_@82JU`BK72M}d5R=;Pn2D0Uk&O!T zI!UeByR_yg1^BN5{p>8h`c*6SUSmC~e61!ov;<4$tc`n&VVZ0AOlTx_<*LSK@O`5? zf7jR?o~l&L$I8?G2z{0GQov+)!V;{0Uz`O2;w|EZ|Mv8m#Z}{n!O;tNE ziq4vqTnP))anq^FBTefmuGlX=)%u~qpleroo~re%AP`>E(yxqYi~w4DV4s`y zm+KBvOAGzE9{_jqQW97e@kG8-m~{M{HCgSi$z0KNqn^gB`~i3G0F9Ry`)b}^a5Y<~ zy(PT4qUB|lQNBH-s6h{HQs9BX25lQA)Q3p%yavD&z8G#UPV0h`G48Sv@ej5VHXwz) z8y>LHZP=S%v{Po3J;aSi{DmOk?>Q^a{nDsm$H75hQzsKIGiv!NeEyelf3ZJrv2khEMd5EoocD z4ulE?A``m!+0mt%HC~6^r=3nMu%Wro+j)u!Q~u`V1vOsw8|H(FJpDBHs?M(+t+`C~ zkJk1O0N;YDi3K&*(%JB*KGy7Xd7i(*SKW+cqv#azaVpblqsQ%DE<#3$z`zOH%ooC5 zKh{eGKDHOXr0RR@YZnMGAjkB>$Ef%3-I!p=#(8QsgK+UidKWu0Zt(21c!*RDEOMc?eR^*U494Kmf^WuWUy{)p? zrYU}k$Lg%y%|PXD&##uScyF~$<~EEi(Q6?#9wkbc`t#w(Y}xR}N7&Oj9=tbQDB@+S zcNeSK=rti-^;HAsQw<753`t?>s_P0h?5J>Hey#CL*%$Vh{(BR?YuGjE8a0p%Y0DeO zul}j*951`2g0sO`vX35Pkgq#H`h-y6UvP#Dz|<8vq@K5(Jv;ZgQ`SNkblu*%Er#B+M+%7T8X2;&@zYQlqdK zM^)uLH#5I~snXs=^Ru;0)BAq*h*`dG5nd0|Pn+8M!24UG7_&a~G|A&pgT<=20ab>X7wbC+lOr*=>hX`IEc)s-sJ( z&9mbzxh|qW0G_}c(PvWC?z*r&8pi~e)Jso2f4>lPvd89@=Lun>t~K8UKc0?_u3BRD zs2b0*-{7L?Br|WnsRg8z&+0lZk{LQ-K$&tHA@Qlc;$K-Hdg7C+nK|2=pi5fTaD z=*dnUMRyZg*hs;aCT1Dw>+mL%-sp#`%*;?4^?_vO?)j3hpyX`PLUBun&+nYeQ!3}R zmqtsPJOwUAg^#l!bKqx`x+gU5iSt=N$qthFJevvz$B)4hPGCxSethYf@>M>yQQ1np zrq3qfjS_#iQlui--(o+_9M+x~f{R8jF!d?kbN?r6^805`S*})&{xLFLwvr86{Dtqx-CsF1jw} zRyGw(&UvE!JLF~wZjFQH))L&R)#@|DOEnukaBfJ>4^dZhMoI)8Jrwu`B82;Cra)`~ zMN`~gLeUqO7yLcJjQ8qKK+)Z5YhMk4HsqOik*I#+y^eSYU5IJ=``{c$xNAMzkC{(M zY#d+0FCZV(-@YU2QmhN&r_=&>2A3wd@y3}i#EyL710WrEL1c&RDv{6*>2tZV-@zrf zBN4j>lmc~*z6lV5F3za86C!LFH*y}oM9~L7YmS<9-C^U!KXsCOgW)d1GvI21lanqL zne9yy_8FXAyVtIr%XBrS^@|;M&juH?(M!$=T_^1YTQs)>Y?3E+Kj4l!3%&VfCj>$* zc{4w=y%TVci$fnm%e}A_t>KD zO6()QS|AcopuNPwCeCkj*+_3_@si4+iUN<2YTNUgHDWA)Io`>b9lO-pBRkw*d5@^51HT~02==jNqNIEz%MoocEb|fcxuos@ zq$q&v`Cmow!Fh1y+HZViFtNE|xyq0w!!FfLo z73OD0Ps*5_3l$+T&sII^Olq>HP0fO~U(pT^KpJ?+HIFMZ-wO$SjP^s^+|mx}uo04P zAUng6Mpr`fk#$v6Zt=~RPePu}LrE|G^#$6QR#n*|E*b;9foWZdZ#>8P8i`fSYYDLK# z#qI!5AGohR9&7u0+;dns9##Ok!^CbqH#R1N6P4^toRT{W5D(PwXEzU^-n@-o%B6n(JZKK9MWS75BX=<_cvioe)eZyu~WK;D*ZCXr`- z1Yrz-8pQa+QRQm)b}7r|_*xs^VuJ_{NqQegqSMR89-gj)xcHqN*T8VD>AZf1*%{vd zd(V1^#61Iexu}~ag-g8s6LxiSKky{0dFMEJjJy(X82>an>#nG==7Nh?<$52v0X9TH zdH$tnfz#{11MHWnFg4>Zu?13I>YmS49=%lXy>~-k>A9ru~M<(xm%6?C8>d zTFnLzi=Tdfz84a>53ODg#-+$OTYPy?w3ama`<5Lc@=~<+*F9|xKgk65xQ%l9n_$Fu{F#kSTnGa@-0ntp__uhF_QFWtDy!FR{_P`QiCYifQc zJwmBWlMe3zI2}{3|9ZI4K1k{Q(;Bd;;mvR@}p>m=+Wj;)MDT_Jj0OKX)97D zkRJ&)ai-9b{Uvd>ih`tfh9cWM=h;n5nhk9D`A_F?Kd7Qr;l@8*xm7vaH2?z^;`y@a z6Emx{+F#a3s@93$;wZg~KJqU4VE}8h?Q-_r!Mqhl84>NkyLlS3z_ZK5wQOX)c?(Iw zg0}dcw-#S^Rb^UkAS2VGd@j{vXBSO%{Q7K=kaY8`0ZrOD#qr&@cPo!SxtVOs=)EXL z{TV*BINr6}%d^}1(4tB{1NaH^L};{*73LR>cq7}2&F44%ElC!ya)L96sI&^B>1O@> zC*8SC1*O%cZ3k$-qT7F(ol_lKdZ#0c-LSu=e#2#jM&<}Jk+J!@oT%P6)0r6{NkS>fWua zCjJ~A$+F*juiTbt!Yo+I6$m>b*IhaMP2Hm3#mnW6tfa$K>FyNf{mV>o=A>nCZk5o1 z!&z4T6TM;Zv!iPX@R&p&^IO?^_FYb`R`~V7-77kJxAz*i0iHyrS;N_D}|0=RWu~ z)Nw)b0&*zb5UE(TYY&0UulPe7{Ll!4D$EPC0R_wC~VhCMs4eeLU7=ef?c*1x#Jjr47JIn?=-5t|ylOsdGa zIfKh@|LbCm1*m3Cki^X?{Z)@pbXSNOtlq0oHu;t77EG4!_@D$SX=(`RhFpGv>K$cFwHl8;6S!vc9waE`MyU!a|8=jgb4bHBO)llI3> zRH2xV{I5B-ha7tji_T1c_d%$tr`52OHZJroSd6YTKODY45U0`TwHIh6w#~JI*3X;e zv1do>xScm|!CAoEAP8HTIl&lo(AA$A%wpi`p!A{uTrLFNG4%1lMyO%*n%ggNKg7kV zVUgzQ<5(!417CpkOwi7goor%4S*$q5lSj8BTPaqxZV|Nobu%Lzn*E$oe1~G;hSvt- zAj=9a#PRw`eM%lD^k%_CiP;DM%jP(Cb^BgW>jS4hMKvVLgk)43;XG0EnPU20f31$) z6Iw3oa+*@F16Ct3`E>A9&>!^4z*82x?9rZf1Q2B91kcpNZ;x$5jw;iihwt_#&?(VV z%y>liJucv-3KFl%AB=7=yiQMxgJTgW>~&JZ7mk{>keFXzF&Nxg1~LzjXe|;}f&0T=kOQ8-BjG?TBV=V7f)e}L6C|OBkvTDI#hd7VX|0F5J=__m0 zJURb&YkYvz-%tOS|A~=99|6R=I{&?8@KjXY|03@H?|sFy8R49OG?vG2uL*w$xo5Jh zpN|0kp)7>SZq#$~cE2z@$OMp({TqBssNL52X|*+NEE~cD1V#4rf3_g#Nnz6g*PoT| zXca(*j%Dd(fJ^9e)*6M<72p{``$J^zr&A!VAbdP_j+yksk{bFUEV?W6VEoPgQ|qkuiti49W;Pr49R};z+Xaa!13P_lf_ZbU?lo8RhqmND7R36; zO&PA`UU}kT^I7(6Y*szL{ndLSw#vD#27RCMu>fw_N}L&)C~ssvJ4=d5IV>C+=VUYg z(#4PpuM}Ew>|tDn)E&b}r&P`41+#^G{N7XI*Y;LA+_9O*!w&&+^Z=Y7ET3H)LOXZX z3Qe{$azb`ZOkXJ9MpNsVrknOY7q$RG$_7MnKJe{ zs2y^vXWE@0JT1ft;dkM+G<+~(KL=Ch1_0G4M?Vk-+jxy_+b-o;vu|T2=Xkcf4tg^5twH}q~5Wf zID(xS1?eV?RY*n1wU9U%WFc`X9c^47t`v%4QO1*el?uO5TAu2+p-&bZsliqZE%tYB zdW}lnumwjjfCdSXd`NX|@ft|aV>U-qur!dvi87qQ$VBKfMX+X8{0us))G%-YuBB}P z4~9)K1eJ=hLs%YQ8#s~y6D?wnCeAbXoQJ$_IPM~PR~1Go3Oa|h4O_iVA^{t(F@)vw zYpNXXm!wo6ewYI309rzig~FeMl2Eyfa>b?-2J??9b%Ni|0e7)OlRou5G#kfQDPv@vWIg z2TU;I7r)O#))Z^~0$KK81g+-{v&>_Q`BHHXYlpR=UQIfh#OFY{Sn2pG8@C=jeOV!G zgfqJHpIoXh^QYbCZplSu2adr-{zj6<+*?Mc=xDScDLGnm8$%V7e`K_1Q}jA?$Q=dl z3JU5RN6=JWk@2!pYiFWqk?fN9VsPhY)XP}ur*@;BPu}?yDx_Cr8K#I4A0@Ks9rvpI zKPI7-$d56FlG;uEg)M&+6M`T2u}8bE@-g|SJyERq0Ne&i(uvQtV@h;D)|7?N&1bb( zlqxIb7Bji3Fhp;9w7Uy=e0(h;KC{7W?4D(}H>PEyJu~i;NW3Mr&SemM!-$|ivAMGS z*2|#{n;%*@^a82 zLd3kaLrD1n>*$qN*M(LO<&EB(dvNLkZp=Eu zr6FyWeqQ)UN3eWH5TYC8(9su}8_I=@b$?T9V1+lss+LBE(*(y;TT}^E(_8C~gAP#6 zwT6IXL!{Le{aGpqC1~MsI`_T2@ttaL?Csb39BMPHCz1UaE8NP&t)G2Fw^er1bSan5 z#@x%~OQe@(K{BM}0a0g_W0s#Jk(56{Fek?k_2Kra<5kkv^6rip-yc!jLukzsu{+8; z{3^Ba{N>BR6J?Mm06%bntjW$m2;?JP|HVXp2wB}O*IXi>T=DvzYS}L}v3|r)f2KS2QQ(iK*_3Dtzt|m!p z^RZ*@ou3NQvZBL!q?<@8?lRa2237TU>-==DG(*z!--gk}-(`%+SNXgpX$H?CiW>hx zoppw` z9&R1F>gvUjkFkP=uexT&Z1+uzHJ5g%t&=ZVs(qsHPB+*+n`a+@z(J!3%@xb%s9IFKhI%Cbk}Oov<>X|yw$j6wBdv3!%{ zq)AeK>*lcq7BwPk3&uR}V&mbofyObD{>V0;T!|^J0aV=3R8lAfKCjF2AscfVSJXxg zOW8CMtV29o9+X7q$cMjy~B|xI=ABUyW)63$;L0aK@yboDNZIUl(7_1 zvF-xMgMzs2pV*BR@(F&+m101hkiVEXfDt$ix`C&$K3CopZI1of^jyHBjIDM;^TY;KD53TcKK<6rRq&KU%J2_Ke8&j zmx7K}RvyT_-BL9S#el(Cn74(`+oQ3om339{#qGjAdrv+B$l6rtor~b+&$Btw{~Db# zBnGJ@k=x!BvkuTVgCZ|HlhQCH!W4Y(Ge%BVJRQQktWo|eM4wx%=4p! zTFXz4(!Q0_cW2)--QGi>TTxj~BuBfK3M7Kuuayi`+Jkad=S1@5m6d3_ZA$OB4bpw8 zxlh_2cySwcJZktoYFs?-O|?9AprMYFu)l-+Kn_I6Ix@3U;86_Z(_KO;)?2Qo*q1C> zPxZ`sc7r+N2|j#+cIHI`OjaSSj(j6m7P1p?tm&>S9W(Vite_uJU4 zbGCdd8Qy&ZAMMKT?qJ5> zV2t@Jg4$nb6M>?jo3+PbuJAGfYKYP` zm0k+u(U})EU;7qvDrUged-Vw6W+TSEfqHjHbY|-M9%mKPr*w#y3Hfl1o+Ck5j$Mqe zI^bNFBrRXW25q!051}*X-eX7)$xtQFN^@AOo7nlj%&i-07{2y&SdIH{=zym}YcBpCZ zyFb1!SnE{zu0GYE<(-Q4Sa+?eU4qU<8epto{nB5FnXyAPR zMpQ$z^T$E|NIswdVkVaeNo82bfFaM_TR<*z*bu|Ey#G`Vq3cRM3wwQoKGH?O-Q?V8SWJP-emwrzYBi@IGKYxrypGie~#S)D5 zS=x))%Hc;-4Yx|j17}>P$j9L41W%?SL=MfZZi6~lqxY1PJMnCGzY6U^Xjb9?rE=E z3O_W@-CCkXtozN!fHk2Ydy1-UiIojSZA-!_C&UoIj<>>UP&{W`xfi6KUQs%GPl^1d zqwoQ7LbXk!Ty)8W#upUHF;a|3#2LU-|ii@A9k8 zQ;;XE{H6W-0AN;c`C*lTC`0hzO9SL8#lXNp3m*ud$Dh2@0F#!ofW!!CzAmD^{oi$3 zDlGWlA`XD?=bj0AICQCa62JWcK@5M-{=ehV{{8ZQ`d>oi5is#z&CLN;`N{!5s2=mn zUZ1ZhpEpVlL;0T3EtCL57ko0ipU^{d*-QonCsMZFw;(4d$GuG*9U_vW7ndMh^gjYx z=XMF2i^rfVLOv&L&GFc+O_bF4=>t~+O!{gd?XVifR-_5`fBx&%6D?e?FUJ3Rf9lXVWx)=97xP7o)p)C3znI+jUwn0@*Ce2b2_) zxwR$Ue%6LOq)=n(0KMb-hC^B?_MN81--83n)18`E2Z70J&)!oJ#ZaRV5VXqU8_3ZGR6y z_oR*MzxoxO>_K&R8h$4L|C9ybs`a_GSo12QlQIm*pqESV0!c<)Pzt@LUYt;*N5bjs zfl)vr+J(1&m0Gs3UjcLL_NFtUsMo0LrbQKjF5Pmn(*YAM#10`q! znvVG)4C=Vfe*1+BZBj(|<$_EtiV2yPw1BI%s|2mT(RCF4*pcDOQU}4SR#8 z^XYxJ^)qY}Ya!Q)?Zm8vS6K1>P}AghIaKQ#M`?2yd;_z^#Mlwt0VH5v{ly6yjCJ)H zllKS`)q^iLJcA%d%?Ex<0ZH)~M|1s2zepq_Jf8r*AJ4D>M&T!{%S;a-G5e)HZRlMV zHn=4XH=3lw9t{5en&JlA?spebZu>Q|!u#Rg7h|5I0YXDF?)S(lkowg)#JE$Y9%U5q z@7bYvL$Vc2c&K`z`_Re5ve30Ab1s%MA8jnZU#|eXSw-E(iPeMYv=@IY&$71f1sR3o zSx4C8*P?SDuC#?6P^65I1QY@fzaRf$OhJ{R+6L~CCeZ090&$bI_e#-;1YV}sKzAEH z!B*sjb@MX}-K>raifJRlC4v|J0!O&UeIdkwq+q!C@-g}l-y~b$LR_{g6+8CTZeBQU zA`^oQS{0Exg`Czm0_?EGW(K{2^PhQCX>eM%)v(PBOK-We(7QQWFK|S-9-I=tw#G%? zNFq2Q*T>l{TZfVExXGs3491FpbJz!h;y`5he>CLj<9k&_je=8@QwdGj2;@gj$fd9# z!u>4At1>Y74TG7H6%qjH}IUlz!-zE|Ko-?$;=A*I{kDUuwBQT4QW7DkTQje{sHdF?#Ca<1l&p zM32GPf~!;2U(U=7svFJuqcT;NYSK6L1BUMj#<`2JDnKu+tVMn+=S>@QGIm88uM=#O!nl~O*tpA~X%&NdtN9NN<{ z9XVfk91_JynkL%;Q&4eVerRkzZB$#PBUes-Xot;&Fi3qX%2Z;gcOv1m!a(IEh+bts zpZxUe?Q?g8Kn!^iAkr<|i&9YhJoUv; z4{e;^<9~2lBuUjlhThC{lC9)@TMfG?2g551c3H+4!>`$~fLl+ye`dY?rua~IfzNQ! zZS_ONI-fS`qW^?^`|_nAd#jHO4`}mXN5BYgf!Z!~NUHfFW$dRvxKYw_;eGy`a6h9T z97MWXe~?#(e_2eulb=WQ$eS(6pM4N%gM}1hPQI!C5TiN!4cnEb3t#y@$!{m8{6i1i zLx5``e;bDg(J4Jz%3pKluL?trm9pkVaF)zoz*CkyS3Fl-v@qhQu*`gKG8w3mm45>HQ(FE82BH->&;)+4oy3?#!4}E zb0&TL+_*Bp={?RO&!AX8WAJD(`Zk9ndgJGbP2i|}Ezo<_MTtw!CF#Pu>AH=ZKt3AM zR<@D7mMdorOFp7b%A$y=>xQl@&j!?A?+=tW8W`wYvHH?l0=~#Z7oxo~)92R6YbetQ zKd3p|?E@aTsp4l{DdMi7S7BxQn`3{7dF{ZQ6cxXpEdiqhB_)jxOT`u+v5jmjiF6Uk z%F3%;rA+ckt#=O17=^m_D9PltIkGjcn*lzIxc!DFn+ai-rJIG>OvE-GHGQD(v~Y^H zG2x$$iFk9L=RInYNx_* zj_!kKeW0jFt?n{_a|8xVNVJKrCfF!|3f>kbNYo_CxV^M?hE#xpRyaB1`vY6(HsIR- z(EHpSXxBJ!uA;)uXDRg<93z0AxBIz$zAA2WvZ#$y2@}q#pMKa;$M#IN@w&|jkKL5W zJU9kU(%HWngfTy6>F!wlOxAGrA6MK^gDQa(Un7FlyfK|$L)7J@I8K>8XqnD zSIRa*9-%1Tj^*Pi{zo_(p&qJ&`D4#As%oS8T>f*H&mqFC4;P8`gA~75qrq(|ANedS z;()3C!S~?8m>@q`_KyFa)Nmbw)^T-v{bUQ3P8a3lwRfr@dZsK;Df{0eR)ahq0$dVr zFql*IfpeH@+sA<58WB3&3bX1n)nEylU62w5X0^X}0+u2{FPp0Zh8o+Sw9wpI`u@$o zx4i@ip_5397@Eoi!z-P@0#4gxdDdoT77F{1V5>|KJewc`>Ye&j3l2CR$TN(ltw!${ zyzYdBzH0D1n2zS-Uork|ieU~W#Of&O{nesuhYS4%cc+4u%=mjfsKa3m)ffM54X7T2 z=yiY<48?Vn3h;1jaKO+N7*d?BO)3W9cx8mdKcpbgZK@Yd!sT6dcsMQz)6< zpdw6wRPN_Grg-7cmm_Wxmfpo&C5o4!k)pK}%4gm;g_QkbDK)sRUtl6i>nEBnQJsT_Sk- zB(@wZOmQ{0{m}skBoPRqmr)s2?{b4b-kwS2D2ye-C3PJ50a422uI}e{@2$<*^1j2gPdGL51`vJ zuYTO5>91+lbQ_L=zFYW64o{$tpp1{)>XW8ZBB5r~@I`1T1gyGd-`l}x7?sM37UR`R zhtXuamBxJl)n`}|_bd3}&iMR-evQQ69SZ7pJq6k~=x{ zYtCRbyk9og-ct=~9Uz>rN>BiUIi?GIfxG<(!p0N?gz9Je_>K-OIy?F*J6iobDq4)h z31W9$dC*vCqo>dpn_2=hwbHfsDXMA?fM71k?C=RH%{%VQb!Ga1GL_ zjSaiCFvf@Z&ro6UBUj`ezunYgpkg}$K})D4_MZmo@nb-DiCTTBUtsSH@S8i9J%MIr zul>BsaL>cD9@ciQ*68IY%lUq?J(kee2LfM+<^nG-cup(IB;0nrB@NKp3xATlEPXiC z;t8%SKVO9q4Jd~fZfm#9XOP=*Fqu-pn1*xSp|(e~2p77?tbd>h@CNmh4--32lIc@i&qJ4x^l6Vq*e@ksfHEhX_DF zR_Gl6>_PDvL0Eq+R@i;pG5@xrj27spSJc=c+Uj1mh}GE=)8qQSf&ve)0aMX#HK~;< z&1s6+_<1u={^iaOH`!mHHLb0*)_g&cRB^^sBJ5>JXy79qZdA;JBjzIAoSs(0$I}~m zOdnuruKU6iKp-(Ixd!n?x-VD8gYriXpS+pjT4tvwbxaBkCQ+R)ay!I;>_|MR)nR(l z5}kDLAFpn`ZO*6>7=OgiaO%)wOMXsda+f=sH`Y&!{B=V#>^wD;KFT7kYR_xWOU7u> znhwEeuXUSUsjfr?m=F0q@qLUjH=eY04oj@=T&H*gI(jU=0-h+VGjTX>d^AHyysxN{ zp}}kG5tq)y26=YLV9jhpuCzKY?7OREKeK8gIn!L>pPA3prIP&8wdg2Bs6W0Ou5H(L z9dqL6eO&FGEQWuwq~5ZxzFKYweKeA{dUwF}hr(L3?G^|*%nFg%u?=|==^cY=;?-|w zOp&ImudLs{bUp_d^O6I`V_UOXAwV83;ph~TKGbLETiNudY;12VJ!8ykX-I2b#1I+Z z$6O{LRFUWAWlYv~%`hCRBQ6?uEL)a3eG6*5qagS7I}sH>Cj3|_3Cy1;5Oj9&0}UNk z)aU1U%~FOBz;wDJ!=w`L13e{+AFgc?J>=Hb3r)(I4Ga2OLEzWR)wS>IZn+gDON z|8-$n9TbMP6agoQFbhaO1((C!#KR4b`yCo#tS%>yUE=lFfEt~TGbllGKi`^oLde6f z8+AL|RLcLlt)BX*b})WfB(?wApHyI>;US(mRoF$*g?d~8!}l_|fb_|Tym{}Hhm7os z&x_6aRTMPJtq%vr3pPu0p>DizmtG{>zJRRVc4203Sx=oO;gbvh`Puy`$pwn(|RhB-b%xg`SzWD&{t0S$&_nn zqRxyLE>ZlQzYDcL>90E8%WqOzp?Tql$>w_C?>O?kauU>4U)E2meaB^5>&$1X z9KS=;^uVg_9WXzwL<3lE+%2$F!J@ShnUyYPTT4aEs}s{~QLiJSTW%3rcx%Cn16+qt zfrcLMe$Wp^ApZqeP-u%_E&#u32>;Dtlyaw1TlzIL?f%-TKA<6)ck9G>RJ?^ zgb6;$U~XK<4V9fC1zh5J2DO;@9f`7H5@V46<2<@?eT?wr= zJU?%;|5!!>Bz-Zu4`{|IYEgrl8uhc22Zrd~X=BblB0hpGVdM<93gUv1@#59_CB9G&9IX0{=}9z(Z6T4tUy;32jItxVbaRRaQ^)Bgc4A!=lUln9|gV}QLJ zneGD}wU0T3g{R;6b+{Fj))<n@^U zO=0~uD~?{klxnZ}T0MEy6&Qn_494*Yxr>c48X=OpQQ!*QM2;h!55do{O+o@Wit6`Xq1jTA!&<`R;ODP|JKn-NFvYpok$@X*J-tY4l zz07=SziYDeQJp-LCz#W#yOH?809*Og+_Ql8lJ09BlHXbxkA{S9{{p9JQqat`MRr-q zw=cfr&vBgRZei29usbP5EN}IkAF{&g^r&mD^!UZ&wJu$2nlnYwnreq9N_QD&+s@5h z*~}~k3kr%JDR=_$YSZL*7zHJrW;bso>Q)V)tqe~hF~2?K3>`*mpE~w5(B##=t~<0J zfus!jpxf$c8XQ_Ah4T@%qU^^KH1i-JGfMCDuUmY|-tV;=1JW|!bI}D3CiI4piwk^( zK;sf*DM$&|W-uVW6DpoFBdB^8tLgQ3Qv`Pdjz?Kw?nkt0)Uo+^B%B!b&8jc|Iz^F* z8o7UA6oQ=l$*B&$lKpabJ z1XwtaK&UXfeU1;!g|b(`(VPAzSy?cWT{EQMWfNPa=TApasBQ0>q@fw?(bUdmOhR-D zEMj>}B+#!Yh%~N24v9h~$LKwxfBr6m&2S_}O)zPofAQepw@~KjVNr+}fsg;SDMfq- ztNw3Xra#@glLeJrkx_~343rx%O@N0C>Q?mepbIJ>L`M+HKW|fCf8pPTzVLrcZ|Xa~ zBeTZJSJ7vUBB=jfa0wllxL5X*@G7FMN5I@1LJ``AjHLASAP>CQ~|GgS9bt+jmepqC*6wxd|EhtG9~om|2ulf#XKcKzv~9yHM%SSi{Ai z$7(8ap_|m|k_X^s@VAW=RCM<)H4M*=X3DD)UNRu~=aXA;LaibWCq#EWT)%n2b0I2E z5^OAv}e&1O*qI$u=E_Y`hRHFMn27(P)u}>mTRU!veYa8Qco~ zIKlgbKbNPjui|UDqRib;Xb6LyY|E8+p6adPq8S$bB6)QVaoE?7wTx8_>+lxC02ADi zQ#vtrDi_oK$FNqn)AxrZrXm`u~h~{ z=v2zqjeTm^)u_{K z8l2p5-16Ea60d>MPBFY-Ma!uwKt&r(q5KxBc*qNXjz2i%MP84n!_~`8H(-cDo9|6a zS;#f0!L|QU@#3t)k%t18`@JkXM#O5bvF41)LBCY+!=@DWPj{wne(Gf|3!p>Z@qySk z2I^brNv}2U3!aq{O}xsWc9=mnzk6Vn4x!6oyq31E8j;1jHBuMI>eC`g)1rJ-;D`_2 zR8uo}zq5Zc&=5oP86BzVF7mCRV&l;n&G>-#RXn^u{7h4#TUM>@?kH zeC2Y}K@*dEt@-Y=E96`$`_AbX;WKh474Yd-!}GOy`2S4OUC8b>LC}N3ZA%#OL1o7p zQpj`k(Zd^LzrW+93cx)q&2kd)9R{I$zu6Nir`pdXo&t6;kxuO zdqSe%W|z3`g^Y+@>EeX*p37a!^H{%iXLYeFZf&>-#x1sv#<0B_hpM9JM1H^Nu^2cv z2*utXw3B}$hc9iin-nI15jfa7Np5rn)$kLTGNVR zVU6B!9P7_q(O4R`YMGjw_(`oh`f?tuWHSG|g*#N(kZ3V6Z<%$oI}nM@bor zF?X6(mR3u=Uc&U|DGHPOMkx0X)pFSnN9tHoke;|(mYe0>Dxa&lMUl|huf4WxOp8tG z&KsZTZu$=PMGtwib@`tb;$m$AVz{@81hlUwKYsem+}4&tCE z>oxLW4`R;Y4-cb%7X5)i;HHgx<%*AX$9mnWO7&|#z7?;>CNOvxdXoKnJ=eV=m|6vx zpCyq*f4>#Knt+|1SzRs@tKgvZ!LC|G<+D?Iyyj_zew6z9}j)@ zUbL*ncZ!)`>A&4njZq-Q1cq66g86(2~(I7goU>C1M%`b$>X z29eKrFQ3=emnkxbr?hfJyAN5VX6)(fNaG_LMeXFRb*shZM#W?!vJ?2D`2W>m!e3BJ zu1t}0?$M{)CIki9)>OE$*`>{{5&e0Eu6QrTiN9%B>a_fR|0|jFuBy_dP*HKdFiFXn zatGV7jW6>^qDIJYeOTDy?T9y`B~4{B4fGzX)O|09Y1mY9biY`7Z^;cDTHhQfmPBuZ zyuMP=8N*aMHngGAwJ@7=P)IpU{tYKr?JW()38!SIk_mk>-#w0Dm06#+Q~Nf%xfd#Af8Pqfk{b53#G8&j zoXeIrdBDeG#B$EH_bd4kMg9quIhKSL##)3n<;40XzPns~b`mR4)jXNtxR9B7@YRkN zlHrs4X|5Q!&nh6?>s}3@aK+zp8nf!DqJ{U@m%@sQep0+MgX$X0L%90~_WsuM0 zxaV33R}gOw@5m@A4*Jq23sB9FsrH^t0#|0cB=h8u!^v;-0EQ7J$gSxlTrHAP{N>o^ z9=n2f@?$$daIeo@pz*$CoJG}%p4UCJ=Qv~KB>89Fp8^vYujGyG71Mn*kU^0LFugkm${l15-vH;ZcFOl}hSd>+Xs_eG@wy@J4l_ao~d1d(l! zK2N0M-)$CLcxqmC(J_5zOyGwx>r-X-u_@wt$;>x*Iy+&5yzJ_O;qFoL76|=DP@W?Df3C}(>g4tH&C_^) zC-b4ji|eVIW8N0Ctz)n965cLmnuV3_JnYD%2+&HERqa3-Rue)zw-{7~2z&9R3%w&M zUJ<|2$l8l!CWhrNr-nxXbO1w(+d8JZP-f@{%-=(>@crkHvOu4!<$r%j|2f*pi z7tom^l1i`PUebA%h7XUSL7p2j-3}7`(@A`OV1VNoV|2?7s=fgvX0MdU_j3$><0QIx zkQswMzh>5wCPzW7bv^dW`zilS?|t|RD3URTsDEHv?soJq@;*PZv3OXY=gn;NStAqQ zOv0Wq%oK?ZDKvV#^&$E(08YqR)21Mp+qV#t3ORjKS^H8PvJ3b5p_6vt%`arfE5qv`~esM)nw%dpgM(MHI z270!K{3v0HYEU^5!Z%h`*iWUgd!Sq8BobGzP?E2{t4`{kL7}%$>Lwf1GN;u+kN_OS zBp`%{0Nn1X!m5^z(&P6P6^&2G`{W%Ex`D}ZzE%TJZl|nr$V|J=KHBr~o3KKy2RMb#7BZnU= zK$ehf6)@Hl7l+5B`O#p|lLg9%%?Z|YMq%{oo0-jzF_iRWYkSpzT9KgP z#}ZvqeqjW*nD8}_^rzRjZ&yI`zF|{T;S5^}sWOzs9fmFMvK#D9aaeK@-6MjY_TxRg zD^~4t@N~h@g&EeiUv*6TS^?YDKf3+<4QkBn8G%8y2KrX<(v?9fsa$)JUSsDbDlzs> zaXu~cb1}2@N8JbRHc`a@uF;BUgbEjOsvgG8Nyi-n`tp@LLe*B@iqnMG&KcX>kerZ{ zD$+HWr%BY@RDLQ$Xmw>bFb={&@c4n>G~jzc^xSyE4`g!o&CWN=9j^W{c@t}HYus#Y zE(fFs07ogbpq_opPd^BP0!B!ZPfMCzsb}Suild)-@%mY`Z#nm4=)xm-Am3)6 zYVfZHv5G&A7S`FBk`)b=3Qrv;cB<=*@|3wWL^G)10e$i_$P3*Ept;17{>N|37_Aog zP~&|pF(sc*=)#x`BCE|IaC$MCyowr!;xB(ap2gl^RdaIn(u$i!<6!ieRkbpp7R5ebZkdx%L>HCEBL0H>?puL)R3d%@-zoKFoeoZ90da=OpCkl9x(~ zrE}JUIpfiJ7&;cM^GILw{hq_5Ot|N&*Gt&#W zbSQ2OmA>4ILv)Uz_%Fhl8pGWg&W}u<19v8Za5!a#nHZMCX3!zg<=Kz28GV2F@Ym0a zl1_h@gu(9hC#baz^KxLzziV~dLHh@!z`tojZ;W*U)%BNzz`uq$M*9EBHT=8d|H!`% z25945*uM8#(`qb!9r%x7+JhB!?v$12bHJ+ZiQe;lH|2k5NCz93gFG(09`V_=1@<|f zvi=K(4y9kXqhVxvs6VK6xCBrI^EUGXuM)v+SA7kzG9ov>E4Cx0Xo4o3S84($ExHp^ z93TTaOdQuvq{1A=byG<+s1_Nn6rJcIs+I;^aZ$RL`vTY_(Q2eM4DF=Zc198m|53+` z@1%vP1>GgIrd+CE?XF_Mptv>k0fOGGzV|SYP&(fUN-xll+Cnw%&b!*iUct+3`PgG- zOKYKPE%+OZ;er>YCguFmY5|FXV0HkL6?Z#DoJR!kR1Q~Y?N3!EMEBER`S80M)KKrL zN`vZCt(_HaA3t6;?`Oof!SpaK&9A!#vW`1zLuKFZ`<5$`bkN)<~L zDV*Z7au_=NO|SX3I;t*_dV})ORz>+yhA9Fc9N^N`0%Al&&*uF zm_w(4WR1@d)%(jsb@L%~vRmOnwr_@lQkZ!`;xaXvr=GU{iHOEE--zGGs}oei<>vBK z>55T>E*Iq zarEi`atMie!~gl9krjeV;P%vtFgD_gnB?*Jvi0lK`@Xys=PBI?V1GW0>!;<)<23te zAY>3tX`6I?QzkEk2KA10co_Bu3zp#&zJ0;c9nvLS_H0EVg5`$>fn5+^OG7nkDmcU zHa1cs>f3!i$#8cbpP}(1I4YH0Zdw`l_MR5ayhq$jCY6;qYP)cS^?ldCQ6>H}AN$aV zFXJ+#zE)s(QfMqV#dK;Mi_udW{1mz`7%U>+mtt^ShM14tqD&k2RJG|!_OvVWPqYIv z`)bUTZL(MYc5GTvZkODll&dfMU%hwfknXccT-q0&@%h`q0y@6QWwivNqf5I~k3~jS zw*IOt_y@23s}wDSPEQyopR} zmaP&@spS0wWECzxaje`1ZEPAT*BqDFL}kU&P8+9yq>!2(h)x=Hc4|pvWpeeNoLet# zU7p(P3QX-Qzv0vMlO&h%<%@p7t-@4`kC*%1hO#Zbln8RScpFLs-Ipe<;iP`#+X@+8 zd#^DL7Y*ai&iUQBOppLRXu69zZsfydOOCh#f16cFH5m}Savw}0dsq*wPI3o~A3o4; z+Y9bMvV0RJ*??T2)w#sK(AMo9L1M4yewyC&T~nA4l|FFR+rV@Ajmc;apLeavC0>qI z!D|L?>m>UB2t7N}*fvJfe5N)MTo_MIlJE9IyxY2!J1vj7=2YX$K&9nw<}HBV4rI}m z6-bfe^|Inz)qD5TVy?djjK~M`N%qBC$`1z1dwBPZZlFAXs3S2tRp*SQY^q)K1y`d? z6Rm5oHqFtim6WQNO}cZ(AwGm;T=uKgL~p;V#gt@O=f)$kuMV$j=6c%6KI9%(Hr%-( zUgzIcn!B82Hyi>sedJQff9eKKvPTNaN&KG7gU4jq;d2^R4*Lw#miD}*9$4pDv$@^3 z61Fk0;WIHwp58s%r%XycB&7?@aG@kmzM+ zH}y9eqU7i9){9SvzKt~A6(9FCmSL3@ZQA5O50n-e`eXCKHoj5Ea;p79lo@^YW2kAF zDY!KTys<1))AcFI4Q-#0~Ph z3r6Lv4-m=>lw`*s04aT;vAm%Xe`Ndcls6SL&`xrDQ&fZmCcFR-V$Oe}s&n=dYKCB} zAO$oYy)KBX*Nvh*k8*ykPXy<;MTbu%5nbXrb$ufyFN>VZ0zm!a1EOu^SH;<}1lZ@? z>j#{OTybD?>x|ZsCVgm+_o9@l#9)-zr9Zp~ppNq>X@RU@w9J)7gV% ztT2AEDS`(@YK8=(F|0?lW*RHbI;&w}$Z(ln-tCRlnww4JsUo;4PS4afZq3!dml~cg za{r@wL6H;q(FpxE;IN$s2#Fpc_`O(d5pJWcJ3keSC!O-+ScTAX!EGN+g`d?w1)X_Y zzXAuTb^bwlxtn|?-CHk%Yz+$~U)zpp?i8d378T5{Z3B$ZYNhJ5Uc(sj20))Xw@=O+ zAn`!`LxA(p-Ol)^&xb%RiI`Bf7g12lz`S8%@c6A;kX)N`kAOho+Mcaz75*vwZbexa zoVgPdjIEf$o*ZqWrK=v~8I{~zd+v)s_650XtMpS=a9qy%3U~1VVQ?5N{MbRbcn4P(vyf^z*iNY0$w42fEJSP8o4H0|GBG5i5$&;NhXZKv|UVP}RS23Wujq9h@aH3QGgc1RcW9-K3)N)}jx93Uk&Vfx2w_$iE$ z@b)qg#^Ifer~1b79{nGEkE$_ctndO!NEJn(R*D84qFX%6%Gz;sHB+Sj^n_}Hqm;5kRMO8WQ?L7q(vaxf=-xiAANE#lB) zp?HImH3dFbPr1@)U?AP`%q>b{PNqxmO57|Z?&V&Z;aeVxJdQnBa6HUiykQtdtY-GHeh0-t*i$dL4Hvx>@VPoo=Ai7ZIG+p|$fb2+r}=a5-bb406L^y4rN zhWb9mOd#hV`q!9}0~M+XIQinJ3X}fqFNX~Q6V?**Sc`N{uXo3B1-^f8*LI2vkiwm; z+YX;{8K?U*ooC8%Y+)E2+oX--RwWnO?79i>PIhOwA2AiAS5A9I^1K7370v>~qo1Q8 zqf{MZ|B41K*6&nv@*I_I4(mnPGJX^^BwbmhXLP|ky{yNT(0rv<{%{X-E0z0p_U*Tr zY?js|iL&Ce_n3DPgwC$TPKLz5#cMUG#miwp_c>0bS{Tgla*`zr9L{XdX1voBKGsT3UNw>E1I+tZQ}z^olbm9r|ML*3xgV#>hAR_ z6Q`UKDuly84V!d#Rh*|j_&rixb8EkYUi{R+N6yyINi$FUP?-yZ%JotgpFZQh;{y94 z!Uc^K?Z|SB`;eH;a=eSLmaq`@kp|AX-#H-gKGHGo`tEv1N!ax&NaBTYd9)=i#4M85KTYym9MdkwF@ZuC1F!lhTwNFOk%PT#}7ZW~S-nnuFsv*$hT&6a{4Yss9hAlH*AF>a-;EEO4bsv{~JG&!N zj3uc$HU1B=v2LqKowZU_<|bU{9=bBCi&|fR&>a? z=XL*fn30LG=SqJ|&oPE4AKGS4-*)8yf#s7EhyB+D$Y4jgzngBAyJI)7G)YnfqiMrX z?-h7WE^`SM(;v(wMYFJeat`I^b^>+68PgS?QH8RD1wpY!WPBkF-L0=;FrfjTzYlyz zI6mU<%j-lk>4?lyZ)zI%afT)8 z?ZQ$j&rh2ES@=Y8r)l!p7wgp5`g8v}SP$2EWBtKgt#vQCX=_dB;!g!~4?yn*!L#|0 zZvN`Y$^PsgJf5#2jk~gSz}&A|@mU&OJ>8(LtV!fqpA4HsbCwLO_}rC-na938`aCbU z5Y6hQ%9dWyk_cE>ZASi99gFlk-jVGV%gZ#F3B)|rA06kk7;ED1PxQQ;^KpndMK0gs zJeR8EzUjFpAH_UC$pIVAExIaYJT|qN2(c8N#d3sq_wv< z@SIC8j*c|GQ{PA6QRpOH^Q7;!pIlRPQIU_D+8W*D`vqRNjpFv66>`JNflpzH^Wq&{ zEj)GAZ-3m$yjtr{b{zt((MyP`xip|3Ej$epWlyZ1>0F~wu^{4*k2+x&TH3`;)RdK3 zM;2h|m>JKwxr1T}Wp84-Eacz1=-K)!1O&J%Zs;YctE-?H76 zbDYPGIj(3n)4@aPffV1Q>$6O$0`8_OcNf2JMq~_F-1jU*;=L6{a~Knx;y?C`MR|NV zvoCe-kEO{6+a6ccfY;K`*P&AQJ9F7=4zRz7vM+u`@pwJWbMcP1auvn}u_$Hv+_vA_ z*R9==7n?ebIi_g}b^)zon)Z~esLQwQ1KkKKA?PX5JZWgSCTXGGW4oI2`8MCrL4!wy z#0%^-p~uUT@Et+2RMd0@3^q9{3lenP?1PoZQ)etlQEQ=aCCHH`_b@>*t;hcAeO2ZM z{IHvWyb+;;>S6DXcdtG@@H{U8*k-(wpIhIKxX&;v@r@_cJvjXoB_yFS0{U8 zz?P-9fDo)9;%K>0PxPS&Pqg5|LyAEHb;hs(S{Z^27jm)}c#QaX{}?l-+T_4Kcsb#1 zeguDR{?wfUu`7j=c}8N-LwRN(LFaPU!Ae)uY|`p-akifsj{@hnv+)4GANHzh@VQJG zzFVa7d56?@=SA{Wv;0}XB9R0l0496|dBx@Z1@eG+2!(kT$~l+MEX28iDio%`@r+d| zqjuA#U3_2{!4eO5VLU~=96^7XAEAJKt3&#cdUi%=iDY$6#X$)a&&JT>+sy;WD9T0B)W>^nGM|Z#NTEv_^?mlLdYYpl#>>d9q{&ru%w_krrltN>Wl} z(hdei=<{Y?)G*DUQaT(Jz++gz^sTmb_N9DACQm0=q1QV45Ue1?jq!hD;{!GIojM>x zJhraA&Ai^{raTV_X-17~R2oNFV9rK$yGTOPw(Se5xy|i7g6P+TpYYzc;n0)nR$7Rm zxDJbqcTlLfyK`OnJSZ=W68>=OVy4`{o^mr&o;sX^a#-{rOj)Sa4bKRUf}Xea%2fNo zDiic8bQQ>&C?B_3e!|c9o0Rtjy|8ER87pS=t4#lXk6y(ekm+z&{54sxn-{XFd^!)N zGlIkzH*!#E$*E&GMecgE{ksb?+71{6-f6=BYVXYdq1yike#Ray<06%{(v?aoEmCGi zDC$;I=~mW?BFU27U?xj+6|UB+Y^mF*izLKgL~==%k~Le(zK?yF^L@QX_ukLvANc<8 zeSFX3fiZLD%zNJFoY(ucJYTO@@GFoND3HYY!h}>z{zT7YVx7F>GrKH@{3Hek7+g0s3$1WSXsBxyUZJqLprJEu$mu> zb8KC6t+=jhfi@7|pTtQXp(@U1n`fw%J6vWa2$Y$XOTA*3dLrchY13rVTMnU9R5zAr zWKV=@jo2C~l9RBCo}u*dSPeMKtuRr{Ls!r;qYmq&J>|pnsVC*I3T)7?27z{7$6U8d z4iptZEO>_klk1P7fU9sf^85QEgC}c_rx$g0SEN4csk}=#UDP8`-mTZF$hmTDOuf8r zq9W2h;G0M%@JZ1F`{g>ec?&Tev9o7)!;<=GgTrNyh~y#TICW5r;?)5Hs}Rtf7;8QC zs-Pcz*0R9flf8eo_$LC!+h5FHW12T`Tg-?ty9KM;G(-Ki(*UBeH}-b@v0eZKp%|GM zq30^pu84Tle5k>;R4?dAEzpZQ`zXPW`77;mIg_4m0x?}J@kOz)qw%+ zx%ym3X_)jO&l5c7*F13M zYlC9Yt?bZ(Kx7HV_~0GDq>d)(Ow`P}dtKC@DZ4A7%KNf6y}iM@A&Me2krhuh79e)) zm50(%GY~$i)+hVEz*D7 zJ}CrRoDuCX^P@K$q=|x2l&*Nk&MhH(42QR*&E9%S+FuK zyd=nW`%KPeaxFe^*5<1_(wU=vL6}f4kAVnJ=r8hY_GYT>=J7m5Bbxsw@B;Mmwl`i0 zx7OS9W1pn)%c$akLCK`4u>KWWgov72ep=zqIWiX$g15pY37+v{G2m`G!#*_3@9O7W z8&mE{cnbA#iGgcf;@W2~0>unZTio@l=RucvSQ9A`&_NAkS6CXiC5sLL#w?IzZWC79 zN~&p}7WYXUO|cML*kB^5X z{L21wcklYnc~-e}L@*DsYbMD}G>wxW*1!{Xo>GTLuM>$x;ZJ-7e0(?6LESnxTOM&` z#u|B?UR~xGY}O&YnEg55^FkRNEGy0CX{Uq@iNJD7b1zo23l}<{*nNAQvGDuHy0u+z zX29x}%MRXl#!qke?oG1EX8vb-;VC%2V}NHxLjdi{X+AtEd8w_?+@ks)fHdwu0MZ4D zR(;Wt26EiZ7s)fY6|6iLENys?_IG(&l8<>8H}R7sy%!Kh3iU%bOwzf_3;9Ai*W=|D zYY-xKI>3Odu@ASF6+5$)K0BQVbO+9TnAx-cACwdr&g1E0U~EPQ3zbWA>>mmmN!{L` zJJpq3EZ8x@R#VNAB_CE4**XYXf zBGGHW(K}S0&lH}mY^)hzb3ZtOk8x;bw%OM69>pkqcp0A@ZHE@wC~+~Rd0Sx3Myaq_ z9q>&Q+02yfo84ZXfOBzEQbJ|j#ILJ*dkxowd zb{E&6X&j)_!z%GD8Ox^Y4Z`tPxLewZ!R=l`@{IV_IZBWqg8G`4O1`n3qpMCS+% zzglp=4_T|lW(LAs=dA-1-h9=4(#q@UG$$#ln!^95O13;t+2F6Dd5YTt;pSJ{U1~hp z7kERgRm!{K%ufwmTv<2G#I4+#>^pUoS!VWHP#y-({*C|@Cf+GGfCqR!uxdN?E}N(G zSb}7ipl0-p+hb|oDwEbvtMuxNO!|8$2NOiWC35pQq%fo|QB}jJ(yT21R+p6?=~L-^ zIP@97p-8wx&Ot%36m4V3)XABjD+;KVUj1|oh|>4K&-TWCuwr{TBJqAsclb-9K{$}A z$38>igN-#P1d4HSDzhg?>EyEvQI z{;(}@$mYq$d5d$qc1`Sld-g8+lCK+!p8$CaP0{P{O>6g&2IVjJ9xrjGx5vP^?wdOQ z;`*RFuk2#k=H+2ZJGMnF?utI>cHr;^Yl6v|r5i~9lAcQ4gDn#$>srM3K1z=3%}!6B z?*rWiP^zV&zu z;dyg7A(eK$`HEQ9b*s~Zs?aP*kaEuPtM-D^tyEsllJSY+>VCSKhNpVl2TtYrmyILw zU4=&MtNSgmp%)eqa+^!hIC$@9+w+!R#O6>8GyK8NuWD8a5|tgXzxtem%y0>^U`m}# zHry{L83|@E1AEF9WC&9k&;s!uaUz3!>=TdD1T|d&!km`suyX_!9Sp4J7!;ZyN+e=N zVtIO=YklLz+6s}+g>D!D1Ab{spT;JI9U}b|mP>A|Mw*r>3Z_-wG8kwXZnXg#bU+RhGcePcQJau_^hi;yHPa+c01|)?cZWVy zlpyXZ!Zt}We=cYrFn(ySnP|8M(>%3<%3;JMg#`<#?mlqk)?-5pjb3n&O?w~69#dDJ#}j2*(?DwIkl%|xmJuFDBA8)u9Kg=#2z|x7*#c8;9T_Z zsu;G`z8F^aX`UjY^OXwTfJ(paU$-tvMXT>>6RlGhs%`!DaMwh%(I_E&lFp=xhQ~)Fe7)2hmYalu9Zb#JYMMaN{@?^2E}%(@8Gyz+SsH&0X;Pz*;%P=+3mDR z_s83QGuyNar6@1c;vq#uXIsIKyVx7nVV?@@b8QGU?i-`)oJmk$v=#(NLHvDKP;C{K zB_3FV9gi3FC{!>xdM&qTN@ufgS8k&p7%vK>`O+UeN!X)UWi`KHUhF7Af{dm3 zSKdg{QaP;Rg>2=jwOEt3&=H*>;+wiq`n!+7gXYdsdI`^u2r|qo1!GVpN7MbXA)w60 zJ7-;SY2dIHKHu28+W5$$T8GabYk;9@o$7~ZNongldxU@fdbaS?O9@f0rN@ws@+WvX zZpct$xp!zwv2H=yZtaL-_aE0@DzJfEy)1F8&2M9kzJ^1!#8cfJpuSfgiC*AvZ#*NIT-!y)iq7;1#KlFnxCc$;C;sXl zj6l(QE^V&D7-ewoDW^CnPL4rSz_Naq9n?`JF2$BzuR=WlTyJr%3`Y`&><ELa?hpCHL=QQA>OR%+8ZpqOA4DBT> zvjf=kK0<>t)-+iz^TY)@ERpLH0Hhzy>SC2yiJ|T?H0jy z7K|d|?Q3Uq_w1l4&;b>E0Qi>$+~FAy|KLD~&atqFJrJ{)CIRBaYknF6VWwX|DUSZ& z85I3RsHQ-KQO9~mJ#tS0vvZ9sbR87N%1+|#4q>Vvk_|0;#+K=s3SydF?{rGR*9J`@ zM3b@TE8-N7LXRIASO>F2dS{WFZyY;+ABLS3-3;n3w)6B%(!z`KzkodVi|S~Cs1(wI zAxl_*m`6g7(3Y}iS`_7`VUgw~d=FE!tT<)2vdWoZ2#U-iH{Sp9CXZZM|AgDs&PkCG8!bl&hhV$VHW*BvBvd+s zX4#A50@GnJd-9javd8F2YE(1X6qMjt_RI@Scw7N&4@3WN2}>n1!3- zI?w{IlE75aby^^Aj!)^62lIxXxoh$`kL;usNK#hW*dTod<*=FMaV=1$9zSgUwIkJK zq&nc_uWTHG$)@OeM!xZM7!YE0IP8PIRDUnDh&v$hnd`3VtR2)W49S8!U8i$km;LT? zZ40=Y0e_s}twRBN&|`0R+n;<@ke4aH5yM=c&(WXB8eNEF^E-o~X?Q^&XaR8bdwRf) zTaSc?D;uM&PQ%Xk=5Tr;YoYpF5+v>M2WXd2pA&&|T0h{H5PH-K62K_zNxYtXR}<*E z|Jdi8#r)4PkQKM0)k^JUu`pDhtH=9lg`v5?;@q1YZBEvnI)^4;&>Fw{l52tRyf7tG zT_owiZrZ<^+@kHC= zqhzJ6S$0lR*G$8X99@l$oR#{o6%d zk7Go5`?E~>G@xCz`m#C?VPC){c-|zMB&ubnZd%W;Q#a z{XlM7@D_bTOB&eO`_2@#1dvH6bPqYPHx>krfjb8)D$m%SGu?cyHK^u%Xu!t6Z2Dt$ ziNqlrl0ngzC#^Oa5|WphR>^hHU;GaRhaqkbO&6V&GAT6qJiEJU9tXP#0z__zN^?ZQ zbPuT#r1jDH1pq$FK};)4B>nV~lB&rV6cC*LK}yDd?Lp%Ci{tCW2pv1qVD6wQ_@lP_ zE30Ri?Yq0CZx~m{1o(nXN8Z?`&p*^}hpd)Y1gr{Q)f$o5v?4vVxQrvfbYE!SDcU%J zS8VpRGCv52@z5L{m|c^zXzG&TsFa!4N{PRe3HM_e?;_I|v%<|Pw5ubH@S#tzazt*8 zQa(fJuPe5ek?T9m;O!!LR1shiIvR^j)Cc5S;R9mOtpM3*AU@_P{oZnKm+vb4zwvOfcaP2?J2vBiW zLkgTtV&Lg&U&_-Knc?f9)e{cx_xJm|NxdM(LKmc$g==dcIK`1lM| zODaG>aCV}+1NTQcu}zI8F_omY`Sv;-d>_b{1zFCH%Yt-mJ2~%Lt;@TM?KxjYBePW@ zBDH@kw7E9zQFSPlRm^Y*cs811Iw#9KG1Pt-9iIA@Lu5JnO_CFN8lw8*hH(2+LR7z4 zQMSFEf}zDl#Iv$WwBF6KTgm$X*i^=c0NK%3*mKh%N&+3sw?INNQKZaLy&O#arXgeV zn6Go=MR+ABbvbxj903|Q@Vy`y!`Y=yOUUhQanJpT_;X|_+cINT*0N?^?r?R$y+>tx z93JX#OkB1K?#JWe`qLKp$fxBGB@yi>3&X}RI&C57jpfLP!x~0iaZ@o8!c=6(`hum7t;7ePuAKDk+!@k{@8~LSW7$Rj(?L?$X#`AZ@9jHPLI@*DNE&Wu7-UQGkB0V zQr!!l>D4Ly?y~?V3|7xo&86>>{O#+FIkxAdW!ilD6R;D>i$95VSHgyA4NneVueHuI ztUms`G(moN>W^-66UD<=Z}52k+1b{wVWs=nv&WG~X?~Vd8=rI)lua|Y*Bqv~5PhzJ zzTMBW_8!m>7ovuMP<~Y|Il~8yHO3w!pPI3R$Ur;Z89Kl=f=aBas+w0LM^BBtv%%{p z4};Nn2o#2xKg#wk{kl(=lA2`%NbXTbF@Xg*s)PeM0|!nsPBn zjzR?*5;e80n`!B`SgSo;ingkR@Q(=_P_FZX*|FL*X$RKMITxAw*>=Fcz5-xyZ~hLH z<((bpdc=!fl9_O|&5gSn`07pynKwA6T|POYvn4xvwSUrw5l_gU4E{cds1=_YAM!$Y zQMre_8&~$t3fR0)2oNOFgiZ21MJoyUjOpdDT>fRM-K-DMk0JL2t)LbAut@LP_6Pw= ztN9H#r>G=1p+oCy>Z=Q$C=dGsxQ()h-P2bcAF9adx;J1~eU~HSLwX_Q)+h5TF-Uhd z%??zlkLzDnm8(WH@OJx4X?WufBv)Y6#J!)O7@_x&0= zr@AbJHd{ROsZfw8I=c`puH=4KH-6<44}hP>4voyYc>B4hlM8mv{?!ba}pt-IHEHB!+0@#BGI*3%Uz`Y&K8#`Z3KBb-i z=)1M8yQlNpNVzpm=lO5-^>JoppJ+%pbhkhUO(;2ZpxsCMZDwcl+vJCp31Japj*UMy zJhY}qB)kPb>?Awc5Igi#(1<*}8g!=+&l@qru8=UysPoxzl6CTh;?G9}Vg2NLT13FU z&Q+fN3G?v%`0ZeaP`DJ45n7m|>9`?K0G8(*<`UOfsFljVD9W_)*3QcNVC6H;nqGu; zpNj7q7wGsZKy1}^{QM3RdY~_QT@#k#(bMTY!B$5E8wn!cKLjg!OX|93bm9kaPA+AKqOF}0S2%{?v zf43rNSl+R5=r0NIoM0)JcZ9QfABzSJ8$_9+Hm=Oy;2xj&D8Ak5+Fx59ucU>l<|&Z8 zFGg_tZ1BE#G0D#hPMuTzY15s7fzrT&753vX*+Ed;q0}Icgp^(rA+^C<-}Eof}}p-`LUO>nJiRntq*fkML-h}q>C8kr zt~BE}hX_kVz4JGUo*@w}?#Q$O3}8903PnE9wOysQ_4__WUc`rz^b@a<+4%q4|MCnl zpQ6l@@75105H&TY&s`vWABC=z;Z??xG-Uhbf8KHE`^!Y_$s1rD@i(G2=LWjBFs$qXaz;G*QG3k(?e+sK`09)K zaeT*4FMk*KRr|VV+24+eu`g{W|9M>8cJKRRNJlhE0a6-1xQptiu2ODam>Oub-Z)?GEZbx+vY*^{{IRjN9TXr@L z;1GEHN&4>^SJ`A)1Bi9Zbat8pH`Y_xoHu~WrO~_emsMZAs2C-l!2hyH`j@a!w#SnT zBJ1TkIl8q2cym?p|6cm`Mr{4;ACzaHK6*C{JT)}Q=XqQ_pt<)!O;UI z1c5bMQm;9&A9$$6bBaNBcRCDpVw*W4Cm1`;xEPKKvSq~A*M4~m3|of^x) zA1n#K?x7@b9pu^bU*UVUBt3SBLG_VVaO;Ktvu;ISk=z6MZz$J&?`p1y8d|ZsOOMzh wZWC?Gi=ejQY9W@a|Kk7u|NB2@K=%wzPAsiH_r=Q&VV;<&@gbwMJ*R{I2fQ3lW&i*H literal 0 HcmV?d00001 diff --git a/voidcrew/modules/mantis_gore/icons/mantis_lefthand.dmi b/voidcrew/modules/mantis_gore/icons/mantis_lefthand.dmi index 659997798cdaff2440589aa740050487a73af5da..95715ca3aaec9f2741e31283008354c057fbb3f6 100644 GIT binary patch literal 11707 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⁣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 NlrqI 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ℜ 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 diff --git a/voidcrew/modules/mantis_gore/icons/mantis_righthand.dmi b/voidcrew/modules/mantis_gore/icons/mantis_righthand.dmi index e0e2c5b9691dd69a2af6ea96efa3337e419123de..ddc9e570f0f14dec293c7c969b01cd6c9e4d3e30 100644 GIT binary patch literal 11672 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 literal 15303 zcmaKT1z1#VyYA2fhzud1bO?yFlt@W;D@cbT(%mJ}4F=s(QX<_A1|bqdcZYy(vcMSt#|C${QflnM z#~&|kT^H#W&gM?999&-6+reNSX?gD|@ZNAELS7z_KGwN6+TeCi(Xp_cu1JBOFD0St zLyIrJW1)6L=oIy(aSY*a;(0B%0sJ&0r^Dxp&Jzoas&3xP%|iVBKdH=?`J<50j?n^L zpR*73f88$o(ZCXM{x#QNBVJ}!CH_OSYZVn|xuhap&fWS>_@`6zWD2YAn4Ym>rc|<~ ze-47y8&gWtUv_fe3zhgtRiU6rRHmae!L9tA9&u03CgBqkxpi;!v!sN(bh2dQOdq4o z`q~CIQ(xl_$cL@pm}WLmc)~AX+8Z$7VkV)(%>#X4Z+jL$NqC4c&X)CH?%v~O7)&@$ zLHe<_$K+1BuZ_V<`fjUvXz;g~RFfsfgqU0=ySHg2ZEXSXnX^^hJnK)_Ij7vJ!-bj| z9vkYWm3^9txJen`n)UA9rqR*w9#XP+hh3U?Kkeo|E^(@}S%km+$~}TTlh)Pv_VXY1 zEFJ*`Q9&uCnR@?Ik%C?lmgL)mo{%5Y{c!3*PrpI?)ibfy#Dg>n`H#1$sFs-!#2VPx z*don-_=Xa2`XF@ae;4Wj;OcLIz(ddn#W~=rn@xl$>fTUhpHjztD{6;`?XBjXAZGS9 zNO{1hTod#yLmeOW$anJHGpm(&xswpH1+pdMC{YdK&mJN%u3KaD?-hgpbB}u%3VCS! z+PJR!l-!&UtM3imvcHCF;pR$>I)z85lYZwh(+Ata>Fnv%#(K*{9I9Jgx9&eJG&D5K z-t*VdA%9w;o1J`Icod-q{|uL;E=Q zAqtqoX}C+F_S11KVRzoA<)?>so82k4#vL)C58mUri}3A2%9L+->ez*cdrbO@wrZ-Y zU%^HyDtIb%L(SI21qAZZCF%{z?%!G8+zez=(5)~F9vdDW&g{2X!_}?O)iyM6;dAUa ztAK}I62f}M$CGZ*@kO?ckLykzwi=Y5CS4C69v*Igx2W7;gMQdWs#fT}n`tT=?)pSt z&)9jfueu;|Q#`)s$N6As=4hoyCreSSA>rk%+549`ZX#*zuHW*;8(ygO=g?!ye_mR+ z0h7X8I2}(px3su+`3bXCCcliugG9h39%ODf}Xp8B)yq^>cijf|;3_PP)*5 zvly<f-LM z4~e;LCvA=}6tF7hYqOc!*lf>tbp;(+l)v0R4u=Saq4kZ8Z%o9v>kS!YuKNO4_7ii% z!*6-6#MOfSoHcC+LeZeNrVk@nYT`~s>XztzmfBUEdot$2bXt#A+HiknUe1UwIV-8n zkDo=?75EPrA4SA@luAK={68P(r=r1jNeA@BNz9ht!NUvJ#=LhcZN?q1XnWFx@CTU1 zzFJgDZH<*;!1@LT%v@Zm%GTQn@qZEsP6v#QJq7m|l9i7!0_x#KN79`89#pk+evgEO zh0oElwAr4CKj{9-LquFu^ZU2mkC0t|=t4?0QcWOLrCh&O74#$NIuT>jpD|h4!sc2H zN%FXB$+7d$-vSLPNGGKSOLqf`+JN4`r=B^u?GuUA>^j0Ewx)21q`T^juf`Hh)zYbN z&lXM)DMfL zOH8eFx3Xf3yCz?vp{5?)E_xuN!52U9>HW8F3TYyqnztg8P8FHo>hUH^8yg!BZ}BED zWtW!|`uh6HGrt|pG_Xd>Gomy3AByetR1#%jn|PoW1><`X6m&XX zUg7$KW~)I}^Sv1!pBd3Dw`6=9Ln7&V15|B#`uaQ~A|*7!F7l(Uz8Mr~2!@aR&ifU{ zQb^aWhMO1SH>zpP!T9ZK-`H@v4{MCfbQ+;J;u#@$S zjWgMdw2-c_pWyYvKdnR`4^sDRTddwx{6XJbqC?~u^P z;~i;w*x;Al;aey&l1M$(v*Jt~-j1e_MBJnjGLaFB$gu=RLzy5G1;Z`8EMw#m{ef^a z+~UES`prUUXQ`~_aoI_=Q~k6EJz1d1`5ZB$j0P|2aY=?-RwOiP!n02G2Hgrn6~mf_ zkYjG~58M3c_VKMj@wKF^@H66^A^G%gF_!NfItde)+KnI{{7tK*OJjDj_`x-D4#`9h z-%$g;5!o$ew?0|i-o`DmPY~Dy8;}y-9Wtw4vU^{!HgrL7fDYbYbUONF2*r@{sumS|GGH<4y)~)+L>wCe-@I2Npr;9i)A3wg3aP#ES=pqfyA&s(KKAFxS?iQ6LG9X#>M+{FUm-hFwORpzoRW5zH}}io{9mNju<1k? zmV0)EWL~~}85L_zXViwp-9WWWitzBHjU(rW3lbgkOzKj|XfT*&>U7V*`eKzK5+qh( zL*)HF>2_JZ57$H|X+bHhnUIj7Q;l@4Vx55r-hZ{RFP^iX^!8IGwH3(-2GA;J^w2RfjD3m+hwigz}ddt1@ zxXIXk`}!pYX2)m>vd3%-8!iM6C3f?8i<<>;C7N*9FkR%rgh}W5!=H4rCV_{()a-@9WsU#3#Is zML72{9W_q6m<4}7QRCGXqcW-RuaRpHqJD)*q~_~D41XL($aoig%_{L<3i#h?_*-Pd zh$P{-gi1nKIq^+ z8pz#QYYjgq;XL-)UMe^piJlxC4c2B0E-Nd;Cp(}Ei}k%E%{(sxhJg_#@E+~(uw1{Fo{NyHpacJtkO~f*oavLg)SOa0Dw4_Fgk?<+48X!|JyM1 zZGV6plCA~OStK-kT(sQ*`4v)^@Rl>Gehsfs#}b5#VU+-3>qhpr1&X0ta?S-=r9)-7rJ=)!Vpu; z0Z1v4v-`b{5iU$BPqWBagH#3S+CM!}T%Df{o8O+Ogk_;gxVzV$Rv=)#3^ED{C9Ur6 z?o(KD#iJo!UA>3?J*)l?R$K@N;dyL?oiYqhv@6xW6>DY zB-b6yUN`_hzm=c_+-1fOypTfrBfHJ&UmjdHI4h{Yx+-~On1?;m8NjW5Y@mnL>8gwjsJ+WNjzJ=NsoWEDUz zOm7u9;^iffu18Lh?qCi97G_fDh`4nh1`C#atnZQgR=aY7Jtg@IamO$c`4Vz#J^gXg z3SH5&T;hEA&>b&WI9ygk)$uj&S6Wh%o0AR2 zB}cqYjwrhKf-JT-UNMhv-yc{C>BKCcObqH%yZnZhoAS;5#nxexK5@UX zKG7U7T-k$VYS0BG5@)+`4E>a0%wi22>}JT!nrnL|YGFFOHJIz92EBD{9W%e6DHr*( zZUoTsemzpm{Z9^L%qQHJcYZwL%2C_B#_-6vTKG<#LD#(Cr|w@ZKbhWs^D@S-H&9i{ z)70Zkl&Uv)+W+twcMfs%&6@nsl(hld{rWIyC2KB7Pjpr0+<0KcNtW$>;D)~^8Z_l= z$?ps&3QRpgo0zJlv|TyQ!MbdxCiJY?tG9j07UL0g*+@RO_;T!nsLRSg#>*>A&B|sTOWCpyb6fV?ck>Y7h#o zm{qXS3OUTvHPsP~&bDxLOnAu@WXv&ES0;5gyZnA$GHc0g5=i5<|4cbC<@Q;js}UEg zvllmBCIp&YCU~br<|nlw9Wx8d{q*#7o?5?hB2{G_w(2pdj*jpRMJ(86xMqhU2tFUu z(vZ3B&Jw^tT@uTJFB;u<^C6;NxgK|J5!H6#Bo#jze^j0$*?g4zIcH0lR3`l6Owje( zGY6Vs`3jL6izG5b-5rLH;`O6)8`gh8*9ZPlCujuPIq&=;8)kH~2p%bJkdLcUhULtp z;rLJNq{2}yTL}GWlAJUpaa#xb&Z@{yJ6VY!zBP6sNx1cY9P5Agsec_SeKpBJ1A(iF zNq`Yu5CXgf-1dL-z5hH%N(b=B-y#To`61|K$1?z8uGCm1%D_5(h)V>(^?toRk;Ukz zCux`{E}~BD=TgH8xINGv?~D ze|M2`ahFiO|N51TB=m@0!or2|1^&@$5geK*iH*b1zu(1Uknc?l- zS`G}|V@PBoPd84u^kN`a$Z1u&bAXqs&zv-L@IYe9IxK)x=37`Rh-{MX3BA@Ys?7>H z1&#kQUQubyJbC>R$#g|~4=sV|8+R~E?v~diY08^b$h5R~g)pYsVkAnC5BCGKqjLYy zzbg2__H|}rU}bo0-_Hlaj~-t%d2e8xLmdS0t5|vW{*W1nL(&0+2o9e?PmJ7Zd5-4$ zaV9hV9Q?wW-tb=XZyansMAI7V-$}9Ug6u8=1aksexc6l1E#(IN>eZrmrFOAqY-y|Z zLR2Ja9eVqpg_RjMU-s4Ry^-L_CWL3J@3RXLzj{ndMJ1wIK7@lQf`4<{Cwqo-Q0kp3 zzC$Mz9Tq+%HoumMj!E-GwXuDm_vZvP=HmkBo`gwCnA;l_!m3onSO7dY$xSQll^|-0 zS-YxI|0g0T?v5#a6hiuZ8ABD_6!X6_O+jz(rlD)90EB}U@XDN<4Es@sNo1;D0+Rwp z7FlGs#9-_zoDP#8dE58NBCXyUBN1xfEik1;7JyAoODfaej`WBlTXplyasntM=Eg2z zb!?iX#=Gm8Mt7-%j}n}H2~4m4iLYQ)+~;#u{YA)u?b&AUL+sy9#(wh;T=?h@6dvvJ^M=bN(xfR*nYe@6aoH z=>(?RsuP2xRQ0gj2RUJ-`s(}EY(F?f+|UfSJ!F_hZ_wl8G34cLHH0yoqutnO zxY?Tj#ij!TmV(IW&M%N=%G)}R8&rOZcKB%7f8ww;n^jR(>i{`W}48QW$nE@W*Cl}Em|8>bER^!<5t*}k*DAO59RdBn#qhDeQU4c2G@2NmLDnR z5#t4;1AZyjm90Ej$6(G@Jn33Z@STHebmQeYY#Aq~GzbgMC zuPZ`YaI`FiA7LC8OCcwaIy!BM=lI8u4i8ZjKG$@DZ~gj2i&WbX|E%A;RRR#15n8U? z<-@TZMzvbe4<0-iL*Ff6E&N25)8E%eM-waPjTTb)=3YdO=WQ%dCJ$xY!G>x-J4e+R zM|6E5c+YZ;gv!FY4cUFB>}=XmoM_%eq_61G`J$+CsmPzvR%~va3shAw7|JOM@o<(~ zo48fD3Xige^)8J!xE5E#!-MV5{;cTM+NFT?Cq6c-t0y@<`4mB1bhaCZ6?gkC?cm_SB znK}`QNuLWdUlUw>akb{quz=e1m#fXj^EX!ndKt? z+b0Q3w`knbK=6Z#bySp<=@NgKy19{or;?JA9JDE9L@g~WWOp_itQ8EMIU~=*z#75R!CqfZEU3c_FNMq zK$_w8!h$JiDSzURS4RE8_|CTDmmE3}WxsGlsGyB73wyP!t>59}qmIqcV7F|ZI{q^9 zkQ+)t7Ugrn=4&%MLC%f$D-1XX8*6(A@pmN69jpz&M!Ur@GBoshV&YkUsl0juQ~yp` zmsR|;lJ$ABdqT6HU+v(Fa zPsj^lv3{b~-4>#B=5d&xpfL$;$yV2hK0-%t$67YX~&i@rQ;w&mL z2gDw9ex^3&Wb_%t0J=&GMBt1x0r&{GdZ9$<)Hs%+zMO` z)dUrO`rN*1%a)U&dmh^|FBg5ya@cB)&Lk}cwK~?B=Y>;SwRKRl?4L1<%y;Ho)S1iC_)ETTxSzc*((?PaD)P;8Xq$J(wF-^CP|enM ztd5+~=yA85GsR^q1uds+Q^#jC+&2){O6LcQ@YpPMR;n8plwHh0jS_H2nW$IJr`xae z@XCPaP-msf9DFqSVcV6Isf_Da6sB@K;ykmhm{X|pL%FLmm117Pnk$`mD6L7}oiok| zZjBNRqR+A`XxfO#;VX>je?+jF?nRwkJpmyw)|2K%U4lph%Feu**d$Id&m=!Dg%bfHQrq%o`Gn^naAgF>hCvn>8zI)7_S&2DK_09U2~0q+ zU>D`#d2PTM3U&i3m^L%(7F*<)j3z~U!eaeBs?_`|7kOXAFDHbM-zAGhDr8(vx+73RIq#q(yH*aDpvqzZr6 z=0NSt6oi*^pJBN16_L>-9nKZ3(wEJ-xfsCaz&md{n4A#0r;&=C(_WW9=2trXfcfpr zVwoP3E?t@V0f7D1F!S<)x#C8h@lMw0b>KCT3Z`_Ob0|KJaFqM1H zlss;u0iFyXbaWcH0vurWe}77L;8S9t=(WH?_hlj!F#oF*BEE35&k{=_7S4%SUK9Q2 z?%(bBPw_8GZhrr|Dik3}g~(#%G&1#`Tr}Ix_8^iJA;~Hfx4v75?@-y(FGouvnG|Tw zC%v#o53KH8MdVQ>ZBr77LV`#IvUsb3Q^IEqkDlE45MaKO?FLeM`ztyHVp5s?#Rk9~ zU6~@XKV*qpcZZWc8;@}(Ja9ZR%9n4}J?x;-;A0chuib91;#uF+TM0g`yH{ohH<;IQ zP#r1kaBI3S+um>V@JzOvZg>&Pf^@ace(}!rm5U*=sm>0Fef_Ogs`w?t3|)5+3zbjF z>JN^2(VZ3PP<;CshquptNtB1^a>+xXQGjGl*a-4B8h%euu}0)Pyo=~IBWfsn#3}ij zoNla8p43t6^b&CR{H#H1=^dUZl?*v-FBaCKEvOX@U@IvdX1T0P$L%W-*HZ=KsFk8l zHDMa837M}-^u!(Jv0hCGT7`tFmKL%8Xuk24^{uTfcSR~`Qp~KmsG2GZF*Y6+8#3&}z~M9L;m?-gsaJ=P$#rKjgD&(jD%#4yA0Xhq63?*60NhRD&( z;9o3yZ8e`PWcb}i-c9uS<@to;2Dn%RJ?P+!Fxv(+s%b?BiVQ>z93aKMkoh+v3~e&f zB&0AJ{m4PPK8i>O{ zoYkv>gD*)VW%yJ3KjmrSj!Hf^QsQ4zz;A|~*)AM?!oD?1!q)VsHOvPHa=tu-_#7>$ z;dmdjH8V1GXx+m7ypJRhFqJ$`Jr4b=z}xC{;k2rihI55|Wl2`H^4n`ICAkSZM=P9} znR!~;GbZ%2YM%@b>lO^yg@AhIu=Gdn5f3#PiO@>*s_{x5%haBAs4S#6`ym78PNWXa z%ZCgS4H~kzjchKXjkbXIt(2CKmI6BGQicB|4%MU9p?!ijlxXwe6eJA@3zNmftmV*F zD`JWe(HkO10bqE}J7K(aO44ZB{maZWKz@9ZROX=T;=b^dO!YX zZESSv@8tAl@xwX`m=zSvME$y zq1`0oXL%4BOrEl8b;JJY%z5j=3u~LE*J=Tt5RGLqd?R^OBkQ@)| z2-$kgf1?wHds2Nxy&DK&3Ew^*Zy7^+zQD3+!}LUv2Iv|5vgphHwTuIR3%Bfs|IFN1 zS*}!Pv)dxmE&Wz%Pm7t5>A3AxE*|^c2hEEZQkx8W^ll0UJ3VC9O?IQ1H^qm{M$=_uVuJ=CU`Hp%$H#w8RK5gsUg)Ke>=19W&)J`B zK%E2sQ^Yx%$t@sYK|NO)C-IO|Lb><#MqS)_tGgE0K+`c-i$cBOQwqCFE14)Hhu>+3 zc#kyBG@$6I71}+6*c6Co{h=$*p*Z7vWRbcpJ`cg4zB-Im`}>Z?S`MJGWmU%PmY$xA z;zmCE-QSHbyN!c>LC0f@bnGRMDY=?jV{ar{_maK)@nUYu+&#J5fL-UHgme+NSU*zLa-{J)#&4o=`kfgFG&*jr3l zbtP!=Z8{OBXIeyO`fFyTF-4l}LKv1yAzYsmOSb{^tKCnq@NPMzD>5^=44|iFqF^G@ z{rwKe!1Aj5)05(L2o6}C+$R{nTtx{?5~q+HWRYK#(C_0&1l{1QGFT2+7r9*50e7#`T4nz2DGuY8_C5BmYiJm&!2(QJKP0WS>eapL0N>FoLui;3&$ue zFZ~!Fcd5H)ZoT@?nFx3q{EY69dXaX=2M&E+0EJ@gVK&!^xg+t?@cqXmlE9(P5NG~_ zx6omBbqm;sM@KKViv&+g7*)g6L7u}ZkR{sA4%T>tg(pWEq7d{%(c|5Pm6aqgDwWP~ z7dqV%nURP+8-XlH^cG#Mmff-HXP_zIGtjoO2br-;2ElZfTsOTKh>4704!{ywq&#u| z^Ji86GxqDkS)U0wiuv1s<|pf!;5{G_=6O#~&##w*8Am&kmJtXL)!2pYH@Fgaf-o0N z;seu$Nrmq6nalKhx%>ww|G8n$hinX-ZSSH|z-R<5t*wVq_zp?F zRqo@cwPN4uq{B*P$8+61PI4E`+Setd*InxzFB6iYO0i)ej|Xqg-+=9H@8`X)Qi!^koAV+NANBK}w-9Aus$0FvH0ku7 z!-Oe?!E|%zemVCahlkZY_gre)622!Es8z5!i%;u!nw{&Xhc0$Z;d6&2Tv+^kD3|tH5%ZaD3e(px{>N(+Kdl*Fp(!ofvF_lh63ClaZ1CKd3~oh|7`gi z{?**}L)?@-A$~Wls5KeE#ofl1rcUB?dy#WP8!%C)$1Uajq_`8^a?&C+XA&4$!uLc} zA_G9$MfVd}$$R?ev7$!Ge6IsW{ZXr>_?ppe%Jj>AS2Cd)syVP#&FFqIzWoP8MEsgB z138|YYT5qW55uWRReSX4QR(^M@-h=JxZ0R^v3eV%s&K7EIoXIl=%xwU=-PAmm{uPY zOwQ+MIwX4g#-~_I2T3!AX)Sf${e6|3mZqJ^WgG*nj7~QW+>!)vBkzMwwbxskcdZF# zxX;1x95`WQnQ_^0XRJ6BZrS~fK;D&HCn-EI+Hw!^D?XkGSx${VbtPun%oTocC(Th6kVk=J6~ey1 zcx?|^z~~>)NUlwB)@7wd_vqm=7+UXlUhsu&{9k5CX6~uW`D+FQs8g!kg9u0sTWfz$x@sXv(>T=2;pLB%S>FH5y=y z{wh`foA{K)2K013#}UCI&w;4FIjwMdG$J~Bku7Aoy>3_zlL^qnb(_7aO$T)5$^RNN zDKy3)^bu}qYU)C5w)ORO*rv~_hrWL57$DwaVs4%)1c9GS^Lm95V3S(jrnLe!boZ|M zV??Rro_?I?uIP%6M46@2t6XDi!)1_`=+MuP)Wsh^J*`_Em3p_WcB7>C=E#XUYkh8R zCy=!hRCw^P1fU-v-}OBdy?N%O?*f+0bHQ?@w$KN>=%2(Tf#L1^{{H?GXYvv^cXvVJ z?n2b=tqv&Fue}MVg)!u0wRL)w>}q)IPD;w#NCWT;h%RoxoYb|{^P%YX_jU9%;x-_! z?Kre~-fKaFKdq*%%!vfDx4YKv2|d`2{qTY8R)E{__A_98#@HnRA?|T9;|^$vyfUL^ zJwpI!fuoEmE^nDUN6!MRp)j+gq&!&PPaOM)3)G5LIh>lCn}xiO*?}JJ2sOxBK{D0*N(b^LO0y%!6$ z>lCtJ`~#u1esEY)TG|LoVc$hEN(KU@!+C80Eub2K_L(dgWE}GwqNbsQ`eancSai2R zH6nI?q)5w*2BUsH`C1SNt`z|QWCd9rwl2g$FAA>I+Hg!X15#pjrP1bdW5qf^Am9ir zRrNqa*pcv=4-F$DBgO686=awdtVxk^V}0E?u(6V7Z(_yHzc!RfP@2w zHc0k?Od-e~beMwR8p07956)?g1n9>j55dTN5*2<1j1#DTOU3+ETa#IhFfVTo$V=?! zlw4X`nlwuxK_z&A4iuRU@wxA&3=-@=v zp%EQp-zU|(5tIu1Pr?admY{MlprmevdlvL^>W|9$?0ClS;B}GP_>W!c0ieq5D&hk6*UxEdFr;IC^3*?Q`o7KDr$s}sYEs|85;;S_EW~U&^gGC&$A+~#V1312BSA?EW)8C0TH;GuvIbe|M zQWZ`;<^1y{@*|RHeSUs^pZjb<^SF*p0<3q}OkDE2Uf$j-aZ_>-$()>#k(><%21aVf za;Vb8k{9OkXur>%=WCh(dMk2MW;_96$@x~&w0mMcLWa`8$%}8kx*Jt=MqP*MsKAZL z&oLalV78d39JnDywsiMVx0;_vK>_JzRU|ge?sKZB_)R@YwBjvFd&P{eBSa+}{?*w3 z7AQ*mUukmxTxlf>6h-oWA=rqXoTb{*_QN)>~xbKKR2H?T0rhFZEqmw$EouR z${M+*5Uqos9if6m=b1mD*0t2)7>1!{9#ZG`>$BsZMJ40X)9}So-ykGmhg^+0f2LfK z&mhhfHB2$|FOj`J?3->`Gu^pd{gexXy?{oVpa(9$-zJ$8A>5OvUpSGiLxualkmVgp zwXNhycfRLsidoyw^1U41*y^>fFbhZs+0QnOjN1B+uT| zTjx2C3%l=m8fPjQD zL`v|-%Ubr5KA7bM1Zc39KgLMxU(7te?V+{Z)x3dCBKmg!NBdIBR9^1CY4Z^i-STt8 zL!aT*dlNegN^*SWP16C=Qy|xjMf~E4r412-)x-;ualMDaSB^Gottw02;VN|pk7&(* zwCStUc$l|PByZHqN{0<%;F${N++JG2W}3W?6_zUyk7*X*BmF8Hgqj`2GZIU;iz<)) zZ0F3bcQ~_k5@BQ{xbcWxx_ZWZXzMDyy}f-YOdYSx$})XM60fKV>g-Zlum5p%yD21N`7Xn|5=RWdTOb5*i`^t`^lMc^`lJp9c=W^ zc?*L6!mfBIoymQBVcZsH6awA)0huk_xEPorc!WWTtKpJEtuT2t7{=ihpEv{A$=Q(7 zy#)Wou)!`g>rO1T`Re)Si%bH}n!pKAnApv9}pN~BJee;RKM6sj=&j3(w$(*%B`ZK~jPt(rbyhI)&|eIxnD z{;KusYI7ZenAx!UTYfAJf@b>u;>0tQL{(Tm&KFN^U7W_M`u8#Hm`PiL{Ub9QI}~tN z4EF9y3nkj~Wn^yGOi^}g(=oQLehk;YQy(yF$o1ppOEj2$hL_+^)ibd3d@2RPGcUJw z{;Kp}x_ZS1!d&j1+y0(u;S@(ko=7Q`lGpj~+(MnF`_W4GRf`Kj4?Q-HPhhaT%Ww%{ znTB(%x`X%cQMc~sadUlWIQT+m+gdYa?y-8vav=THy~+2E_}N7HUf+fq)l8OW^ZGO7 z$!pq4so?lXLd7qRwoBif0SU44BJ|+xsH9VNK zo4w!xMnxAP7B!f5-KFuGgw{pe<#b_ZSVfIL=k>@(a)#%V298oDpcREdXJW;76SZqZ zsav6*BkTTFpLq|XXWLAJH8o~awAD-dgSF;hQ|bh&D@{!MJz4p=HnlGbo4X#ITS&gr zaz>AtBu8jSHF#ca*7MEoP?LXj3!NbY6sztUQU#Z67|1p4l87@3M(3cM4d5@h8i)$F-xIPGb5*XaEG zN!`VHaJ5deVHrsOfM1)qS)?d2*Lye(IJ>QBa zelH;s;L^Ri_|=L6-jD%b;;HFbJ>>_*EUPNRk^(do1Aii5r! zJ&p&owmmiCfw}z;%t#o;Qh^;Rgbz7O1(`pJ?P2pSFFj>Aua|fE?L}HufbsqMUTC z8q2sjE|wc(SUd(Fk3V@$?k=eB9=qEtaOnQz1b9x(32)mO=cf9rPv2*`{44S)^>*Bs zkzNEB&`B=gC;RoIPJ$Nv8O!a%r1L~WjWe)O*JhK8@e*asou&pUunaUJI(`Hev_Uuy39l;&a zqAv}aPEu0uy?&>~?uh6)|M>m0Ud^l`UCx*KiP7eqAI00$C1O`@n8)!jEi8|4FC`Mc zoIF#@@qXB6O0l}f4d(~0z%vI77pC#&E?E?qakNfZy@olgxr>EZpDgm%U)O#V9$6aN zytZMnd+wOa)p>GVF}d6C^1IdU(wXXVwz4cdGj+GHGBF)iCwBtKpBwf3&!PSoDQ5;pvqGSi=5JG)qanxsdD&>q%3@5G9aH5Mo_ zWB-m}uR9Yy=0V*s3l`PuD}2~BvA9Hzcdq#^R>7(_u8#ww@d%kYeS=?FBLXvL22!=T z(VU($>if+PvQU&3{tcS=pm)pY&f$R0)2FbPo2u{&#`wNyxji=uG#wa- zy#P+}v0tLDoz^CjoVT#jo~EF#a$zTzkc7*8NJanO(BAbLgn= 1 -/// The blades rip out. [half] when the bearer lost their grip: it tears less on the way out. +/** + * The blades rip out. [half] when the bearer lost their grip: it tears less on the way out, with no + * pull-back. Otherwise first the pull-back: the bearer draws back, the points sink out of sight and both + * blades' impacts flash on the body while it still hangs there. A beat later they come free and the body + * drops (tear_out()). + */ /datum/mantis_impale/proc/rip(half = FALSE) + if(!running || ripping) + return + // A lost grip is a jolt, not a pull: they come straight off. + if(half) + tear_out(TRUE) + return + ripping = TRUE + if(tick_timer) + deltimer(tick_timer) + tick_timer = null + var/obj/effect/abstract/cyberware_anim/tips = tips_ref?.resolve() + if(tips) + qdel(tips) + tips_ref = null + var/back_w = (lunge_dir & EAST) ? -MANTIS_IMPALE_PULL_BACK : ((lunge_dir & WEST) ? MANTIS_IMPALE_PULL_BACK : 0) + var/back_z = (lunge_dir & NORTH) ? -MANTIS_IMPALE_PULL_BACK : ((lunge_dir & SOUTH) ? MANTIS_IMPALE_PULL_BACK : 0) + animate(bearer, pixel_w = back_w, pixel_z = back_z, time = 1, easing = SINE_EASING|EASE_OUT, flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) + animate(time = MANTIS_IMPALE_PULL_BEAT) + animate(pixel_w = -back_w, pixel_z = -back_z, time = 2, easing = SINE_EASING|EASE_IN, flags = ANIMATION_RELATIVE) + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/first_blade = locate() in mantis_housing_blades(housings) + var/impact_state = first_blade?.impact_state || "impact_mantis" + cyberware_play_impact(bearer, victim, impact_state) + if(length(mantis_housing_blades(housings)) > 1) + var/obj/effect/abstract/cyberware_anim/second = cyberware_play_impact(bearer, victim, impact_state) + if(second) + var/matrix/flipped = matrix(second.transform) + flipped.d = -flipped.d + second.transform = flipped + cyberware_flash_fx(victim, 0.2 SECONDS) + addtimer(CALLBACK(src, PROC_REF(tear_out), half), MANTIS_IMPALE_PULL_BEAT, TIMER_DELETE_ME) + +/// The blades come out after the pull-back: the damage, the spray and the fall. +/datum/mantis_impale/proc/tear_out(half = FALSE) if(!running) return var/list/blades = mantis_housing_blades(housings) @@ -184,10 +295,8 @@ playsound(held, 'sound/items/weapons/bladeslice.ogg', 70, TRUE) for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in blades) blade.drench(held) - if(!QDELETED(holder)) - mantis_lunge_arrival_fx(holder, held, first_blade?.impact_state || "impact_mantis", 2) - if(half) - holder.set_staggered_if_lower(1.5 SECONDS) + if(half && !QDELETED(holder)) + holder.set_staggered_if_lower(1.5 SECONDS) log_combat(holder, held, half ? "lost the grip on" : "ripped the mantis blades out of") /// The bearer is hit while holding them up. Enough of it and they lose the grip. @@ -220,17 +329,29 @@ if(tips) qdel(tips) tips_ref = null + for(var/obj/effect/abstract/cyberware_anim/blades as anything in reach) + if(!QDELETED(blades)) + qdel(blades) + reach = null if(bearer) UnregisterSignal(bearer, list(COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOVABLE_MOVED)) bearer.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED), MANTIS_IMPALE_TRAIT) + bearer.remove_offsets(MANTIS_IMPALE_TRAIT) for(var/datum/action/cooldown/cyberware/mantis_lunge/lunge as anything in mantis_lunge_actions(bearer)) if(lunge.impale == src) lunge.impale = null if(victim) - UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOVABLE_MOVED)) + UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING, COMSIG_MOVABLE_MOVED, COMSIG_LIVING_POST_UPDATE_TRANSFORM)) REMOVE_TRAIT(victim, TRAIT_IMMOBILIZED, MANTIS_IMPALE_TRAIT) victim.remove_offsets(MANTIS_IMPALE_TRAIT) + if(!QDELETED(victim)) + unhoist(fall = TRUE) + // Still on the floor: back to the way tg laid them. + if(victim.body_position == LYING_DOWN && prior_dir) + victim.setDir(prior_dir) if(housings) mantis_pose_blades(housings, MANTIS_POSE_GUARD) + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_housing_blades(housings)) + blade.show_inhand() if(!QDELETED(src)) qdel(src) diff --git a/voidcrew/modules/mantis_gore/mantis_lunge_fx.dm b/voidcrew/modules/mantis_gore/mantis_lunge_fx.dm index 8e10041d87f..a87c2acbb68 100644 --- a/voidcrew/modules/mantis_gore/mantis_lunge_fx.dm +++ b/voidcrew/modules/mantis_gore/mantis_lunge_fx.dm @@ -1,7 +1,8 @@ // Pictures for the Mantis line's gore. Nothing here changes who gets hit, when or how hard: the // mechanics call in after they have happened. One-shot holders and temp visuals only; every animate() -// on a mob is relative and parallel and ends where it began, so tg's offsets, lying turn and #509's -// death throes stay right. +// on a mob here is relative and parallel and ends where it began, so tg's offsets, lying turn and #509's +// death throes stay right. (The impale's hoist, which turns a body upright for the hold, is in +// mantis_impale.dm and gives tg's turn back.) // ========================================================================= // THE LUNGE @@ -41,8 +42,8 @@ add_overlay(emissive_appearance(icon, icon_state, src)) /** - * The dash, drawn after the lunger has already arrived: afterimages every half tile fading down the - * line, a streak across each tile, and the body glides the last of the way in. + * The dash, drawn after the lunger has already arrived: the body glides in from where it started, and + * each afterimage and streak shows as the body passes it, so the line trails behind. */ /proc/mantis_lunge_dash_fx(mob/living/lunger, turf/start, list/passed, charged, model) var/turf/finish = get_turf(lunger) @@ -50,35 +51,61 @@ return var/dash_dir = get_dir(start, finish) var/count = length(passed) - var/step_x = (dash_dir & EAST) ? 16 : ((dash_dir & WEST) ? -16 : 0) - var/step_y = (dash_dir & NORTH) ? 16 : ((dash_dir & SOUTH) ? -16 : 0) - for(var/i in 1 to count) - var/turf/spot = passed[i] - var/fade = i / (count + 1) - new /obj/effect/temp_visual/decoy/fading/mantis_afterimage(spot, lunger, model, fade * 0.7) - var/obj/effect/temp_visual/decoy/fading/mantis_afterimage/half = new(spot, lunger, model, fade) - half.pixel_x += step_x - half.pixel_y += step_y - new /obj/effect/temp_visual/mantis_trail(spot, dash_dir, model) + var/glide = charged ? 2 : MANTIS_LUNGE_GLIDE // The body is already there: draw it back where it started and let it slide in. var/back_w = (start.x - finish.x) * ICON_SIZE_X var/back_z = (start.y - finish.y) * ICON_SIZE_Y animate(lunger, pixel_w = back_w, pixel_z = back_z, time = 0, flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) if(charged) - // A pounce arcs. - animate(pixel_w = -back_w * 0.5, pixel_z = -back_z * 0.5 + 8, time = 1, easing = SINE_EASING|EASE_OUT, flags = ANIMATION_RELATIVE) - animate(pixel_w = -back_w * 0.5, pixel_z = -back_z * 0.5 - 8, time = 1, easing = SINE_EASING|EASE_IN, flags = ANIMATION_RELATIVE) + // A pounce arcs, higher the further it goes. + var/arc = min(2 * get_dist(start, finish) + 4, 16) + animate(pixel_w = -back_w * 0.5, pixel_z = -back_z * 0.5 + arc, time = glide * 0.5, easing = SINE_EASING|EASE_OUT, flags = ANIMATION_RELATIVE) + animate(pixel_w = -back_w * 0.5, pixel_z = -back_z * 0.5 - arc, time = glide * 0.5, easing = SINE_EASING|EASE_IN, flags = ANIMATION_RELATIVE) cyberware_grey_dust(start) else - animate(pixel_w = -back_w, pixel_z = -back_z, time = 1.5, easing = SINE_EASING|EASE_OUT, flags = ANIMATION_RELATIVE) + animate(pixel_w = -back_w, pixel_z = -back_z, time = glide, easing = SINE_EASING|EASE_OUT, flags = ANIMATION_RELATIVE) + var/list/spots = list() + for(var/turf/spot as anything in passed) + spots += WEAKREF(spot) + for(var/i in 1 to count) + var/delay = round(glide * (i - 1) / count, world.tick_lag) + if(delay <= 0) + mantis_dash_ghost(WEAKREF(lunger), spots[i], dash_dir, i / (count + 1), model) + else + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_dash_ghost), WEAKREF(lunger), spots[i], dash_dir, i / (count + 1), model), delay) + +/// One step of the dash's wake: two afterimages half a tile apart and the streak, on the tile the body just crossed. +/proc/mantis_dash_ghost(datum/weakref/lunger_ref, datum/weakref/spot_ref, dash_dir, fade, model) + var/mob/living/lunger = lunger_ref?.resolve() + var/turf/spot = spot_ref?.resolve() + if(QDELETED(lunger) || isnull(spot)) + return + var/step_x = (dash_dir & EAST) ? 16 : ((dash_dir & WEST) ? -16 : 0) + var/step_y = (dash_dir & NORTH) ? 16 : ((dash_dir & SOUTH) ? -16 : 0) + new /obj/effect/temp_visual/decoy/fading/mantis_afterimage(spot, lunger, model, fade * 0.7) + var/obj/effect/temp_visual/decoy/fading/mantis_afterimage/half = new(spot, lunger, model, fade) + half.pixel_x += step_x + half.pixel_y += step_y + new /obj/effect/temp_visual/mantis_trail(spot, dash_dir, model) -/// The lunge lands: the blade's impact twice, the second mirrored a beat later, a flash and a puff of dust. -/proc/mantis_lunge_arrival_fx(mob/living/lunger, mob/living/victim, impact_state, blades = 1) +/// The lunge lands: one impact, a flash and a puff of dust, as the gliding body reaches them. A [pounce] +/// lands with both blades, the second mirrored a beat later. +/proc/mantis_lunge_arrival_fx(mob/living/lunger, mob/living/victim, impact_state, pounce = FALSE) if(QDELETED(victim)) return + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_lunge_land_fx), WEAKREF(lunger), WEAKREF(victim), impact_state, pounce), MANTIS_LUNGE_GLIDE) + +/proc/mantis_lunge_land_fx(datum/weakref/lunger_ref, datum/weakref/victim_ref, impact_state, pounce) + var/mob/living/lunger = lunger_ref?.resolve() + var/mob/living/victim = victim_ref?.resolve() + if(QDELETED(lunger) || QDELETED(victim)) + return + // Skewered: the blades in them are the picture of this hit. + if(HAS_TRAIT_FROM(victim, TRAIT_IMMOBILIZED, MANTIS_IMPALE_TRAIT)) + return cyberware_play_impact(lunger, victim, impact_state) - if(blades > 1) - addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_mirrored_impact), WEAKREF(lunger), WEAKREF(victim), impact_state), 0.4) + if(pounce) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(mantis_mirrored_impact), lunger_ref, victim_ref, impact_state), 0.4) cyberware_flash_fx(victim, 0.2 SECONDS) var/obj/effect/temp_visual/mook_dust/dust = cyberware_grey_dust(get_turf(victim)) if(dust) @@ -90,6 +117,9 @@ var/mob/living/victim = victim_ref?.resolve() if(QDELETED(lunger) || QDELETED(victim)) return + // Skewered: the blades in them are the picture of this hit. + if(HAS_TRAIT_FROM(victim, TRAIT_IMMOBILIZED, MANTIS_IMPALE_TRAIT)) + return var/obj/effect/abstract/cyberware_anim/impact = cyberware_play_impact(lunger, victim, impact_state) if(impact) var/matrix/flipped = matrix(impact.transform) @@ -172,31 +202,66 @@ // IMPALE, SCISSOR, CROUCH // ========================================================================= -/// The two points showing through the far side of someone skewered. The picture faces the way the lunge went. -/proc/mantis_impale_tips_fx(mob/living/victim, lunge_dir, model) +/** + * Both of [bearer]'s blades reaching into [victim]'s tile: the pair "[state]_under" and "[state]_over" from + * the reach sheet, turned the way [victim] lies from [bearer]. The under half is the blade behind and + * inside the victim's body, so the body hides it; the over half is what passes in front. Both ride on the + * bearer, so they move with the bearer's lean, but are drawn apart from the bearer's own sprite so they + * can sit under or over the victim. The held blades should be hidden while these play. Returns the + * holders. + */ +/proc/mantis_reach_fx(mob/living/bearer, mob/living/victim, state, lifetime) + RETURN_TYPE(/list) + . = list() + if(QDELETED(bearer) || QDELETED(victim)) + return + var/reach_dir = get_dir(bearer, victim) + if(!reach_dir) + return + for(var/part in list("under", "over")) + var/full_state = "[state]_[part]" + if(!icon_exists(MANTIS_GORE_REACH_ICON, full_state)) + continue + var/obj/effect/abstract/cyberware_anim/blades = cyberware_anim_play(bearer, MANTIS_GORE_REACH_ICON, full_state, part == "under" ? MANTIS_UNDER_VICTIM_LAYER : MANTIS_OVER_VICTIM_LAYER, lifetime) + if(!blades) + continue + blades.appearance_flags |= KEEP_APART + blades.vis_flags &= ~VIS_INHERIT_DIR + blades.dir = reach_dir + blades.pixel_x = -32 + blades.pixel_y = -32 + . += blades + +/** + * The two points standing out of someone skewered, the far ends of the blades in them. Drawn on the + * victim, upright: over them, or behind them when the blades went in from the south and out of a back + * that faces away. + */ +/proc/mantis_impale_tips_fx(mob/living/victim, lunge_dir, model, lifetime) var/state = "[model]_impale" if(!icon_exists(MANTIS_GORE_ICON, state)) state = "mantis_impale" - var/obj/effect/abstract/cyberware_anim/tips = cyberware_anim_play(victim, MANTIS_GORE_ICON, state, MANTIS_ANIM_TOP_LAYER, MANTIS_IMPALE_PIN_HOLD + 1 SECONDS) + var/obj/effect/abstract/cyberware_anim/tips = cyberware_anim_play(victim, MANTIS_GORE_ICON, state, lunge_dir == NORTH ? MANTIS_UNDER_VICTIM_LAYER : MANTIS_OVER_VICTIM_LAYER, lifetime) if(!tips) return null + tips.appearance_flags |= KEEP_APART|RESET_TRANSFORM tips.vis_flags &= ~VIS_INHERIT_DIR - tips.appearance_flags |= RESET_TRANSFORM - tips.dir = (lunge_dir & (EAST|WEST)) ? ((lunge_dir & EAST) ? EAST : WEST) : ((lunge_dir & NORTH) ? NORTH : SOUTH) + tips.dir = lunge_dir return tips -/// The blades close at the neck. Drawn on the body, so it lies down with someone lying down. -/proc/mantis_scissor_fx(mob/living/victim, model) - var/state = "[model]_scissor" - if(!icon_exists(MANTIS_GORE_LARGE_ICON, state)) - state = "mantis_scissor" - var/obj/effect/abstract/cyberware_anim/cut = cyberware_anim_play(victim, MANTIS_GORE_LARGE_ICON, state, MANTIS_ANIM_TOP_LAYER, 0.8 SECONDS, glow = TRUE) - if(!cut) - return null - cut.vis_flags &= ~VIS_INHERIT_DIR - cut.pixel_x = -16 - cut.pixel_y = -16 - return cut +/// The way [looker] turns to look at [seen], as face_atom() picks it, whatever state [looker] is in. +/proc/mantis_face_dir(atom/looker, atom/seen) + var/dx = seen.x - looker.x + var/dy = seen.y - looker.y + if(abs(dx) < abs(dy)) + return dy > 0 ? NORTH : SOUTH + return dx > 0 ? EAST : WEST + +/// How [victim] lies, for the scissor's art: "stand", or "lie90"/"lie270" by the way tg has turned them. +/proc/mantis_scissor_posture(mob/living/victim) + if(victim.body_position != LYING_DOWN || !victim.rotate_on_lying) + return "stand" + return victim.lying_angle == 270 ? "lie270" : "lie90" /// Someone under the raised blades shudders: small sideways jolts, relative, ending where they began. /proc/mantis_struggle_fx(mob/living/victim, duration) diff --git a/voidcrew/modules/mantis_gore/mantis_scissor.dm b/voidcrew/modules/mantis_gore/mantis_scissor.dm index 2d5bddabc2c..77aa777f252 100644 --- a/voidcrew/modules/mantis_gore/mantis_scissor.dm +++ b/voidcrew/modules/mantis_gore/mantis_scissor.dm @@ -1,5 +1,6 @@ -// The scissor: both blades out, head aimed, a swing at someone adjacent who is dying. The blades rise -// over them into a V, catch the light, and come together at the neck. A #509 finisher +// The scissor: both blades out, head aimed, a swing at someone adjacent who is dying. The blades rise, +// catch the light, then come down from the bearer's hands and close on the neck, one blade behind it and +// one in front, and #509's decapitation takes the head. A #509 finisher // (voidcrew/modules/hyperviolence/finishers/), drawn the way the bosses' are rather than as real blows, // so the bearer stands rooted and exposed through the wind-up and a hit makes them reel. No mark over the // victim (owner D34): the wind-up reads only from the bodies. An Atlas frame stops the cut. @@ -112,7 +113,14 @@ // ---- The wind-up ----------------------------------------------------------- -/// Both blades rise over the victim into a V. A glint runs along them, and the victim shudders. +/// Someone on the floor is turned so their head lies toward [bearer], the way tg turns someone dragged. +/// Straight above or below them, the head stays the side it was. +/proc/mantis_turn_head_toward(mob/living/victim, mob/living/bearer) + if(victim.body_position != LYING_DOWN || !victim.rotate_on_lying || !victim.lying_angle || bearer.x == victim.x) + return + victim.set_lying_angle(bearer.x > victim.x ? LYING_ANGLE_EAST : LYING_ANGLE_WEST) + +/// Both blades rise. A glint runs along them, and the victim shudders. /datum/hv_finisher_stage/windup/mantis duration = MANTIS_SCISSOR_WINDUP rise = 1 @@ -125,6 +133,7 @@ . = ..() var/datum/hv_finisher/mantis_scissor/scissor = host scissor.pose_blades(MANTIS_POSE_RAISE) + mantis_turn_head_toward(host.victim, host.user) glint_timer = addtimer(CALLBACK(src, PROC_REF(glint)), duration * 0.5, TIMER_STOPPABLE|TIMER_DELETE_ME) mantis_struggle_fx(host.victim, duration) @@ -154,28 +163,53 @@ /datum/hv_finisher_stage/kill/decapitate/mantis duration = 0.4 SECONDS effect_delay = 0.1 SECONDS - lunge = 4 + lunge = MANTIS_SCISSOR_LEAN effect_sound = 'sound/items/weapons/bladeslice.ogg' spray_strength = 4 + /// The blades coming down on the neck, while they do. + var/list/obj/effect/abstract/cyberware_anim/reach +/datum/hv_finisher_stage/kill/decapitate/mantis/Destroy() + reach = null + return ..() + +/** + * The blades come down out of the raise and close on the neck. The held blades step aside for the reach + * art, which runs from the bearer's hands to wherever the neck is: one blade behind it, one in front. + */ /datum/hv_finisher_stage/kill/decapitate/mantis/enter() . = ..() var/datum/hv_finisher/mantis_scissor/scissor = host scissor.pose_blades(MANTIS_POSE_GUARD) + var/posture = mantis_scissor_posture(host.victim) + var/toward = get_dir(host.user, host.victim) + // The head lies on the far side of the body: the bearer leans right over them to reach it. + if((posture == "lie90" && (toward & EAST)) || (posture == "lie270" && (toward & WEST))) + host.pose_user(MANTIS_SCISSOR_FAR_LEAN, rise) + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(host.user)) + blade.hide_inhand_for(duration) + reach = mantis_reach_fx(host.user, host.victim, "[scissor.model]_scissor_[posture]", duration) + +// Pulled away before the blades met: they go straight back into the hands. +/datum/hv_finisher_stage/kill/decapitate/mantis/on_interrupt() + for(var/obj/effect/abstract/cyberware_anim/blades as anything in reach) + if(!QDELETED(blades)) + qdel(blades) + reach = null + for(var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/blade as anything in mantis_held_blades(host.user)) + blade.show_inhand() /** * The blades come together at the neck: one line for the cut, a huge one if the head comes off. An * Atlas frame (or anything else that won't let the head go) takes a deep cut instead. */ /datum/hv_finisher_stage/kill/decapitate/mantis/effect() - var/datum/hv_finisher/mantis_scissor/scissor = host var/mob/living/carbon/victim = host.victim var/mob/living/bearer = host.user if(effect_sound) playsound(victim, effect_sound, effect_volume, TRUE) var/spray_dir = away_dir() mantis_note_hit(victim, bearer) - mantis_scissor_fx(victim, scissor.model) victim.hv_hold_attack_message( "[bearer] brings both blades together at [victim]'s neck!", "[bearer] brings both blades together at your neck!", diff --git a/voidcrew/modules/unit_tests/voidcrew_mantis_gore.dm b/voidcrew/modules/unit_tests/voidcrew_mantis_gore.dm index 4499eb40a06..8de0be52327 100644 --- a/voidcrew/modules/unit_tests/voidcrew_mantis_gore.dm +++ b/voidcrew/modules/unit_tests/voidcrew_mantis_gore.dm @@ -22,6 +22,8 @@ #define MANTIS_TEST_LARGE_ICON 'voidcrew/modules/mantis_gore/icons/mantis_gore_large.dmi' #define MANTIS_TEST_LEFTHAND 'voidcrew/modules/mantis_gore/icons/mantis_lefthand.dmi' #define MANTIS_TEST_RIGHTHAND 'voidcrew/modules/mantis_gore/icons/mantis_righthand.dmi' +/// MANTIS_GORE_REACH_ICON +#define MANTIS_TEST_REACH_ICON 'voidcrew/modules/mantis_gore/icons/mantis_gore_reach.dmi' /datum/unit_test/voidcrew_mantis_gore abstract_type = /datum/unit_test/voidcrew_mantis_gore @@ -65,6 +67,13 @@ /// Puts [victim] in crit with plenty of health to spare before death. That much air loss also knocks /// them out, which still counts: out cold at crit health. +/// The reach pictures of [state] riding on [body], drawn apart from it so they can sit under or over a victim. +/datum/unit_test/voidcrew_mantis_gore/proc/reach_pictures(mob/living/body, state) + . = list() + for(var/obj/effect/abstract/cyberware_anim/picture in body.vis_contents) + if(picture.icon == MANTIS_TEST_REACH_ICON && findtext(picture.icon_state, state) == 1 && (picture.appearance_flags & KEEP_APART)) + . += picture + /datum/unit_test/voidcrew_mantis_gore/proc/crit(mob/living/carbon/victim) victim.adjustOxyLoss(110) TEST_ASSERT(mantis_in_crit(victim), "Fixture: [victim] isn't dying (stat [victim.stat], health [victim.health])") @@ -212,6 +221,15 @@ var/mob/living/carbon/human/consistent/floored = allocate(/mob/living/carbon/human/consistent, spot(1, 3)) floored.Knockdown(10 SECONDS) TEST_ASSERT(mantis_can_impale(user, floored, held_housings), "Someone on the floor can't be skewered") + // Off the floor and up on the blades: drawn upright, still lying down to tg, and laid back down after. + var/datum/mantis_impale/hoisted = new(user, floored, held_housings, 2 SECONDS) + TEST_ASSERT(hoisted.start(), "Someone on the floor wasn't skewered") + TEST_ASSERT(hoisted.hoist_angle, "Someone on the floor wasn't brought up on the blades") + TEST_ASSERT(abs(floored.transform.b) < 0.01 && floored.transform.a > 0.99, "Someone brought up on the blades isn't drawn upright") + TEST_ASSERT_EQUAL(floored.body_position, LYING_DOWN, "Bringing them up stood them up") + hoisted.finish() + TEST_ASSERT(!hoisted.hoist_angle, "The hoist outlived the hold") + TEST_ASSERT(!length(reach_pictures(user, "mantis_thrust_")), "The blades stayed drawn after the hold") var/mob/living/basic/mouse/mouse = allocate(/mob/living/basic/mouse, spot(1, 1)) TEST_ASSERT(!mantis_can_impale(user, mouse, held_housings), "A mouse was skewered") @@ -225,8 +243,19 @@ TEST_ASSERT(HAS_TRAIT_FROM(fresh, TRAIT_IMMOBILIZED, MG_IMPALE_TRAIT), "The victim isn't held") TEST_ASSERT(!HAS_TRAIT(fresh, TRAIT_HANDS_BLOCKED), "The victim's hands are tied") TEST_ASSERT(fresh.has_offset(MG_IMPALE_TRAIT, PIXEL_Z_OFFSET), "The victim isn't lifted") + TEST_ASSERT_EQUAL(fresh.dir, WEST, "The victim wasn't turned onto the blades") + TEST_ASSERT_EQUAL(length(reach_pictures(user, "mantis_thrust_")), 2, "The blades going in aren't drawn from the bearer, under and over") + var/obj/effect/abstract/cyberware_anim/tips = skewer.tips_ref?.resolve() + TEST_ASSERT(tips && (tips in fresh.vis_contents) && tips.dir == EAST, "The points aren't standing out of their back") + var/obj/item/melee/energy/blade/hardlight/cyberware_mantis/held_blade = right_blade(user) + TEST_ASSERT(held_blade.inhand_hidden_until > world.time, "The held blade is drawn over the blades going in") var/brute_before = fresh.getBruteLoss() skewer.rip() + TEST_ASSERT(QDELETED(tips), "The points still stand out of them after the pull-back") + TEST_ASSERT(HAS_TRAIT_FROM(fresh, TRAIT_IMMOBILIZED, MG_IMPALE_TRAIT), "The pull-back let them go before the blades came out") + skewer.tear_out() + TEST_ASSERT(!length(reach_pictures(user, "mantis_thrust_")), "The blades stayed drawn in them after the rip") + TEST_ASSERT(!held_blade.inhand_hidden_until, "The held blade never came back") TEST_ASSERT(fresh.getBruteLoss() > brute_before, "The rip did no damage") TEST_ASSERT(fresh.IsKnockdown(), "The rip didn't floor them") TEST_ASSERT(!HAS_TRAIT_FROM(user, TRAIT_IMMOBILIZED, MG_IMPALE_TRAIT) && !HAS_TRAIT_FROM(user, TRAIT_HANDS_BLOCKED, MG_IMPALE_TRAIT), "The bearer is still held") @@ -361,6 +390,10 @@ crit(doomed) var/datum/hv_finisher/mantis_scissor/kill = allocate(/datum/hv_finisher/mantis_scissor, user, doomed) TEST_ASSERT(kill.start(), "The scissor didn't start") + kill.advance() + var/posture = doomed.lying_angle == 270 ? "lie270" : "lie90" + TEST_ASSERT_EQUAL(length(reach_pictures(user, "mantis_scissor_[posture]_")), 2, "The closing blades aren't drawn from the bearer to the neck, under and over") + TEST_ASSERT(right_blade(user).inhand_hidden_until > world.time, "The held blade is drawn over the closing blades") for(var/i in 1 to 20) if(!kill.running) break @@ -685,8 +718,13 @@ TEST_ASSERT(icon_exists(hand_file, "[model]_[pose]"), "[hand_file] has no [model]_[pose]") for(var/state in list("mantis_trail", "mantis_scorch_trail", "mantis_impale", "mantis_scorch_impale", "mantis_coil_glow_right", "mantis_scorch_coil_glow_left", "mantis_guard_glint_right", "mantis_raise_glint_left", "scorch_gash_l_arm", "scorch_gash_head")) TEST_ASSERT(icon_exists(MANTIS_TEST_ICON, state), "The gore sheet has no [state]") - for(var/state in list("mantis_spurt", "mantis_scissor", "mantis_scorch_scissor", "mantis_flick_right", "mantis_flick_left")) + for(var/state in list("mantis_spurt", "mantis_flick_right", "mantis_flick_left")) TEST_ASSERT(icon_exists(MANTIS_TEST_LARGE_ICON, state), "The large gore sheet has no [state]") + for(var/model in list("mantis", "mantis_scorch")) + for(var/part in list("under", "over")) + TEST_ASSERT(icon_exists(MANTIS_TEST_REACH_ICON, "[model]_thrust_[part]"), "The reach sheet has no [model]_thrust_[part]") + for(var/posture in list("stand", "lie90", "lie270")) + TEST_ASSERT(icon_exists(MANTIS_TEST_REACH_ICON, "[model]_scissor_[posture]_[part]"), "The reach sheet has no [model]_scissor_[posture]_[part]") #undef MG_MOVE_IMPALE #undef MG_MOVE_XSLASH @@ -699,3 +737,4 @@ #undef MANTIS_TEST_LARGE_ICON #undef MANTIS_TEST_LEFTHAND #undef MANTIS_TEST_RIGHTHAND +#undef MANTIS_TEST_REACH_ICON From aa8d2636bf85ed93da3f7f9a69b27fa046854cbf 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 208/472] Spin the lever-action guns on every rack --- voidcrew/modules/shiptest_guns/shotguns/lever_actions.dm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/voidcrew/modules/shiptest_guns/shotguns/lever_actions.dm b/voidcrew/modules/shiptest_guns/shotguns/lever_actions.dm index 64f5386e05c..83b76f2ef1d 100644 --- a/voidcrew/modules/shiptest_guns/shotguns/lever_actions.dm +++ b/voidcrew/modules/shiptest_guns/shotguns/lever_actions.dm @@ -2,7 +2,7 @@ // Art credits are in attribution.txt beside icons/. Sounds are our own stock and gun-feel files. // // They sit on tg's pump shotgun: a tube magazine and a manual cycle per shot, with "lever" wording. -// Racking one held in one hand, or a sawn-off one, spins the gun once. +// Racking the lever spins the gun once. /obj/item/gun/ballistic/shotgun/flaming_arrow name = "\improper HP Flaming Arrow" @@ -39,7 +39,7 @@ /obj/item/gun/ballistic/shotgun/flaming_arrow/rack(mob/user = null) . = ..() - if(user && (sawn_off || user.get_inactive_held_item() || !user.has_hand_for_held_index(user.get_inactive_hand_index()))) + if(user) SpinAnimation(7, 1) /obj/item/gun/ballistic/shotgun/flaming_arrow/sawoff(mob/user, obj/item/saw, handle_modifications = TRUE) From 4cd640231550d45c03619abe40990edf48f030ac Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:15:46 -0800 Subject: [PATCH 209/472] Store the favor uniques icon metadata in a zTXt chunk --- voidcrew/icons/obj/favor_uniques.dmi | Bin 6358 -> 6098 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/voidcrew/icons/obj/favor_uniques.dmi b/voidcrew/icons/obj/favor_uniques.dmi index 59c368a62992d08137c8329e50711a27a9536e21..1af2b7283e1fade1dcbf6bf7395353ade35b5ae0 100644 GIT binary patch delta 220 zcmV<203-j_G14!PBYyzJdQ@0+L}hbha%pgMX>V=-0C=30kxdSQFc5{;^b`$j)quKn zV<3iwT%j51APuFEX_0t)DRsge27^vD(Pxp zoQ^E0Q9Wxn7uJD8FT-9nC^X6^Qpf_S$og)=f{LKUSubqa8Bj-1I?;pNge#ij!~?2nf6qh8Hqym{r1)15Y@j0m}>8V9ZT%0MHMa4i-LoUv= zqQu-(2pg_8IX|zYs6H{dB)%Xqg`n!RBCvh&NvVlIjRch!XP4$>m**E{7sn^3=H%d1 zUk`J6Zem_)DbTy|8Hq)Csl~-Ob>PyVi_2BOuu4hINvs5hgprOB7u^4uxdl0iVRd1FHooHN>S Date: Sun, 8 Nov 2026 17:24:35 -0800 Subject: [PATCH 210/472] Make the Scorcher's flamethrower spray while right mouse is held Holding right mouse puffs a plume every 0.2 s toward the cursor for 1u of fuel each (8 s to a full tank). Release, an empty tank, swapping hands, dropping the gun, incapacitation or logout stops it. Bullets don't fire while it sprays. --- tools/gear_catalog/ratings/guns.json | 2 +- .../shiptest_guns/scorcher/scorcher.dm | 231 ++++++++++++++++-- .../unit_tests/voidcrew_shiptest_scorcher.dm | 60 ++++- 3 files changed, 260 insertions(+), 33 deletions(-) diff --git a/tools/gear_catalog/ratings/guns.json b/tools/gear_catalog/ratings/guns.json index 61a4bed335e..b03c639ab0d 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -141,7 +141,7 @@ "hash": "", "name": "Scorcher", "rank": 3, - "reason": "A Heater (20 damage 5.7mm at 6.7 a second, 30 to a magazine) with a flamethrower under it: a 5-tile plume that burns and sets people alight every 2 seconds, eight sprays to a tank of welding fuel. The flamer alone is the MOD module's yellow, together they edge over the red line.", + "reason": "A Heater (20 damage 5.7mm at 6.7 a second, 30 to a magazine) with a flamethrower under it: a held spray of 5-tile plumes, five a second, that burn and set people alight, eight seconds of spray to a tank of welding fuel. The flamer alone is the MOD module's yellow, together they edge over the red line.", "score": 115, "tier": "red" }, diff --git a/voidcrew/modules/shiptest_guns/scorcher/scorcher.dm b/voidcrew/modules/shiptest_guns/scorcher/scorcher.dm index 1de100682ca..190a8fefc0a 100644 --- a/voidcrew/modules/shiptest_guns/scorcher/scorcher.dm +++ b/voidcrew/modules/shiptest_guns/scorcher/scorcher.dm @@ -1,16 +1,18 @@ // The Heater PDW and the Scorcher, a Heater with a flamethrower bolted under it. // Ported from Shiptest (shiptest-ss13/Shiptest, frontier_import/ballistics.dm and // gun_attachments/flamethrower.dm). Art credits are in attribution.txt. -// The flamethrower is part of the gun: right-click sprays tg's flamethrower plume -// (the same projectile as the MOD flamethrower module) and burns welding fuel from a -// small internal tank, refilled from any container or tank holding welding fuel. +// The flamethrower is part of the gun: holding right mouse sprays tg's flamethrower plume +// (the same projectile as the MOD flamethrower module), one puff per SSfastprocess tick +// (0.2 s) toward the cursor, and burns welding fuel from a small internal tank, refilled +// from any container or tank holding welding fuel. Left mouse stays the bullet full-auto +// (/datum/component/automatic_fire ignores right clicks). /// Fuel the Scorcher's tank holds, in units of welding fuel. #define SCORCHER_MAX_FUEL 40 -/// Fuel one spray burns. -#define SCORCHER_FUEL_PER_SPRAY 5 -/// Time between sprays. -#define SCORCHER_SPRAY_COOLDOWN (2 SECONDS) +/// Fuel one puff of the spray burns. +#define SCORCHER_FUEL_PER_SPRAY 1 +/// While the button is held, the spray sound plays once every this many puffs. +#define SCORCHER_SOUND_EVERY 4 /obj/item/gun/ballistic/automatic/heater name = "\improper Heater" @@ -50,7 +52,39 @@ slot_flags = ITEM_SLOT_BACK /// Units of welding fuel in the flamethrower's tank. var/fuel = SCORCHER_MAX_FUEL - COOLDOWN_DECLARE(spray_cooldown) + /// The mob holding the gun in a hand, whose right mouse button we watch. Cleared on drop and Destroy. + var/mob/living/spray_holder + /// spray_holder's client while it has one. Cleared on logout, drop and Destroy. + var/client/spray_client + /// TRUE while the flamethrower is spraying. + var/spraying = FALSE + /// What the spray is aimed at. + var/atom/spray_target + /// The turf spray_target was on when aimed, so a moving target doesn't drag the aim. + var/turf/spray_target_turf + /// Puffs since the spray started, for the sound cadence. + var/spray_puffs = 0 + +/obj/item/gun/ballistic/automatic/heater/scorcher/Destroy() + unwatch_holder() + return ..() + +/obj/item/gun/ballistic/automatic/heater/scorcher/equipped(mob/user, slot, initial) + . = ..() + if((slot & ITEM_SLOT_HANDS) && isliving(user)) + watch_holder(user) + else + unwatch_holder() + +/obj/item/gun/ballistic/automatic/heater/scorcher/dropped(mob/user, silent) + unwatch_holder() + return ..() + +// One trigger at a time: no bullets while the flamethrower is spraying. +/obj/item/gun/ballistic/automatic/heater/scorcher/process_fire(atom/target, mob/living/user, message = TRUE, params = null, zone_override = "", bonus_spread = 0) + if(spraying) + return FALSE + return ..() /obj/item/gun/ballistic/automatic/heater/scorcher/examine(mob/user) . = ..() @@ -79,6 +113,8 @@ return ITEM_INTERACT_SUCCESS return ..() +// A player's right click is normally taken by the held spray (on_spray_mouse_down), so these +// only run for clicks it lets through and for callers without a mouse: one puff each. /obj/item/gun/ballistic/automatic/heater/scorcher/interact_with_atom_secondary(atom/interacting_with, mob/living/user, list/modifiers) if(user.combat_mode && isliving(interacting_with)) return ITEM_INTERACT_SKIP_TO_ATTACK // Gun bash, as on any gun @@ -106,33 +142,186 @@ playsound(src, 'sound/effects/refill.ogg', 40, TRUE) balloon_alert(user, "tank refilled") -/// Sprays a plume of burning fuel at target. -/obj/item/gun/ballistic/automatic/heater/scorcher/proc/spray_fire(atom/target, mob/living/user) +/// Starts watching user's right mouse button while the gun is in one of their hands. +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/watch_holder(mob/living/user) + if(spray_holder == user) + return + unwatch_holder() + spray_holder = user + RegisterSignal(user, COMSIG_MOB_LOGIN, PROC_REF(on_holder_login)) + RegisterSignal(user, COMSIG_MOB_LOGOUT, PROC_REF(on_holder_logout)) + RegisterSignal(user, COMSIG_QDELETING, PROC_REF(unwatch_holder)) + if(user.client) + watch_client(user.client) + +/// Stops the spray and drops every signal and reference to the holder and their client. +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/unwatch_holder(datum/source) + SIGNAL_HANDLER + unwatch_client() + if(spray_holder) + UnregisterSignal(spray_holder, list(COMSIG_MOB_LOGIN, COMSIG_MOB_LOGOUT, COMSIG_QDELETING, COMSIG_MOB_SWAP_HANDS)) + spray_holder = null + +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/watch_client(client/user_client) + if(spray_client == user_client) + return + unwatch_client() + spray_client = user_client + RegisterSignal(user_client, COMSIG_CLIENT_MOUSEDOWN, PROC_REF(on_spray_mouse_down)) + +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/unwatch_client() + stop_spraying() + if(spray_client && !QDELETED(spray_client)) + UnregisterSignal(spray_client, list(COMSIG_CLIENT_MOUSEDOWN, COMSIG_CLIENT_MOUSEUP, COMSIG_CLIENT_MOUSEDRAG)) + spray_client = null + +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/on_holder_login(mob/source) + SIGNAL_HANDLER + if(source.client) + watch_client(source.client) + +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/on_holder_logout(mob/source) + SIGNAL_HANDLER + unwatch_client() + +/// Right mouse pressed: start spraying at what is under the cursor. +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/on_spray_mouse_down(client/source, atom/target, turf/location, control, params) + SIGNAL_HANDLER + if(!isatom(target)) + return + var/list/modifiers = params2list(params) + if(!LAZYACCESS(modifiers, RIGHT_CLICK)) + return + if(LAZYACCESS(modifiers, SHIFT_CLICK) || LAZYACCESS(modifiers, CTRL_CLICK) || LAZYACCESS(modifiers, ALT_CLICK) || LAZYACCESS(modifiers, MIDDLE_CLICK)) + return + var/mob/living/user = spray_holder + if(QDELETED(user) || source.mob != user || user.get_active_held_item() != src) + return + if(user.throw_mode || !isturf(user.loc)) // In a locker or a mech, right click does what it normally does. + return + if(isnull(location) || istype(target, /atom/movable/screen)) + if(target.plane != CLICKCATCHER_PLANE) + return // A HUD or inventory click. + target = parse_caught_click_modifiers(modifiers, get_turf(source.eye), source) + if(!target) + return + else if(!isturf(target) && !isturf(target.loc)) + return // Something inside a bag or a container. + if(user.combat_mode && isliving(target) && user.Adjacent(target)) + return // Gun bash, through the normal click. + // The click itself would fire another puff through interact_with_atom_secondary. + source.click_intercept_time = world.time + RegisterSignal(source, COMSIG_CLIENT_MOUSEUP, PROC_REF(on_spray_mouse_up), override = TRUE) + start_spraying(target) + +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/on_spray_mouse_up(client/source, atom/object, turf/location, control, params) + SIGNAL_HANDLER + var/list/modifiers = params2list(params) + if(!LAZYACCESS(modifiers, RIGHT_CLICK)) + return + UnregisterSignal(source, COMSIG_CLIENT_MOUSEUP) + stop_spraying() + return COMPONENT_CLIENT_MOUSEUP_INTERCEPT // Releasing the button is the click; don't let it puff again. + +/// Follows the cursor while the button is held. +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/on_spray_mouse_drag(client/source, atom/src_object, atom/over_object, turf/src_location, turf/over_location, src_control, over_control, params) + SIGNAL_HANDLER + if(isnull(over_location)) // Over a screen object or deep darkness. + var/list/modifiers = params2list(params) + var/turf/new_target = parse_caught_click_modifiers(modifiers, get_turf(source.eye), source) + if(new_target) + spray_target = new_target + spray_target_turf = new_target + return + spray_target = over_object + spray_target_turf = get_turf(over_object) + +/// Starts the held spray at target. The first puff goes off at once. +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/start_spraying(atom/target) + stop_spraying() + var/mob/living/user = spray_holder + if(QDELETED(user) || QDELETED(target)) + return + spraying = TRUE + spray_puffs = 0 + spray_target = target + spray_target_turf = get_turf(target) + RegisterSignal(user, COMSIG_MOB_SWAP_HANDS, PROC_REF(stop_spraying)) + if(spray_client) + RegisterSignal(spray_client, COMSIG_CLIENT_MOUSEDRAG, PROC_REF(on_spray_mouse_drag)) + if(!spray_puff()) + stop_spraying() + return + log_combat(user, target, "started spraying fire at", src) + START_PROCESSING(SSfastprocess, src) + +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/stop_spraying(datum/source) + SIGNAL_HANDLER + if(!spraying) + return + spraying = FALSE + STOP_PROCESSING(SSfastprocess, src) + if(spray_holder) + UnregisterSignal(spray_holder, COMSIG_MOB_SWAP_HANDS) + if(spray_client && !QDELETED(spray_client)) + UnregisterSignal(spray_client, COMSIG_CLIENT_MOUSEDRAG) + spray_target = null + spray_target_turf = null + +/obj/item/gun/ballistic/automatic/heater/scorcher/process(seconds_per_tick) + if(!spraying) + return PROCESS_KILL + if(!spray_puff()) + stop_spraying() + +/// One puff of the held spray. Returns FALSE when the spray should stop. +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/spray_puff() + var/mob/living/user = spray_holder + if(QDELETED(user) || user.get_active_held_item() != src || user.incapacitated) + return FALSE + if(QDELETED(spray_target) || get_turf(spray_target) != spray_target_turf) + spray_target = spray_target_turf // Keep spraying the tile until the cursor moves. + var/atom/aim = spray_target + if(isnull(aim)) + return FALSE + if(get_turf(aim) == get_turf(user)) + aim = get_step(user, user.dir) // Cursor on our own tile: spray the way we face. + if(isnull(aim)) + return FALSE + else if(!CAN_THEY_SEE(aim, user)) + return FALSE + user.face_atom(aim) + return spray_fire(aim, user, held = TRUE) + +/// Sprays a puff of burning fuel at target. Returns TRUE if it fired. held is TRUE for the held spray's puffs. +/obj/item/gun/ballistic/automatic/heater/scorcher/proc/spray_fire(atom/target, mob/living/user, held = FALSE) var/turf/start = get_turf(user) var/turf/target_turf = get_turf(target) if(!isturf(user.loc) || isnull(target_turf) || target_turf == start) - return + return FALSE if(HAS_TRAIT(user, TRAIT_PACIFISM)) balloon_alert(user, "you don't want to hurt anyone!") - return + return FALSE if(!can_trigger_gun(user)) - return - if(!COOLDOWN_FINISHED(src, spray_cooldown)) - return + return FALSE if(fuel < SCORCHER_FUEL_PER_SPRAY) balloon_alert(user, "it sputters") playsound(src, 'sound/items/weapons/gun/general/dry_fire.ogg', 30, TRUE) - return + return FALSE fuel -= SCORCHER_FUEL_PER_SPRAY - COOLDOWN_START(src, spray_cooldown, SCORCHER_SPRAY_COOLDOWN) - user.changeNext_move(CLICK_CD_RANGE) var/obj/projectile/flame = new /obj/projectile/bullet/incendiary/fire/scorcher(start) flame.aim_projectile(target, user) flame.firer = user flame.fired_from = src - playsound(src, 'sound/items/modsuit/flamethrower.ogg', 75, TRUE) - log_combat(user, target, "sprayed fire at", src) + if(held) + if(spray_puffs++ % SCORCHER_SOUND_EVERY == 0) + playsound(src, 'sound/items/modsuit/flamethrower.ogg', 60, TRUE) + else + user.changeNext_move(CLICK_CD_RANGE) + playsound(src, 'sound/items/modsuit/flamethrower.ogg', 60, TRUE) + log_combat(user, target, "sprayed fire at", src) INVOKE_ASYNC(flame, TYPE_PROC_REF(/obj/projectile, fire)) + return TRUE /// The Scorcher's plume: tg's flamethrower plume, a tile shorter. /obj/projectile/bullet/incendiary/fire/scorcher @@ -140,4 +329,4 @@ #undef SCORCHER_MAX_FUEL #undef SCORCHER_FUEL_PER_SPRAY -#undef SCORCHER_SPRAY_COOLDOWN +#undef SCORCHER_SOUND_EVERY diff --git a/voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm b/voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm index 40fbde99ab5..88d3f3437c0 100644 --- a/voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm +++ b/voidcrew/modules/unit_tests/voidcrew_shiptest_scorcher.dm @@ -40,34 +40,72 @@ var/obj/item/gun/ballistic/automatic/heater/scorcher/scorcher = allocate(/obj/item/gun/ballistic/automatic/heater/scorcher) TEST_ASSERT(heater.w_class < scorcher.w_class, "The stocked Scorcher should be bigger than the Heater") -/// The flamethrower burns fuel per spray, waits between sprays, and only takes welding fuel. +/// The flamethrower burns a unit of fuel a puff with no wait between puffs, and only takes welding fuel. /datum/unit_test/voidcrew_scorcher_flamer /datum/unit_test/voidcrew_scorcher_flamer/Run() var/mob/living/carbon/human/consistent/shooter = allocate(/mob/living/carbon/human/consistent) var/obj/item/gun/ballistic/automatic/heater/scorcher/scorcher = allocate(/obj/item/gun/ballistic/automatic/heater/scorcher) TEST_ASSERT(shooter.put_in_hands(scorcher), "The shooter could not hold the Scorcher") TEST_ASSERT_EQUAL(scorcher.fuel, 40, "The Scorcher should start with a full tank") - scorcher.spray_fire(run_loc_floor_top_right, shooter) - TEST_ASSERT_EQUAL(scorcher.fuel, 35, "A spray should burn five units of fuel") - scorcher.spray_fire(run_loc_floor_top_right, shooter) - TEST_ASSERT_EQUAL(scorcher.fuel, 35, "A second spray went off during the cooldown") - scorcher.fuel = 2 - COOLDOWN_RESET(scorcher, spray_cooldown) - scorcher.spray_fire(run_loc_floor_top_right, shooter) - TEST_ASSERT_EQUAL(scorcher.fuel, 2, "A nearly dry tank still sprayed") + TEST_ASSERT(scorcher.spray_fire(run_loc_floor_top_right, shooter), "A puff did not fire") + TEST_ASSERT_EQUAL(scorcher.fuel, 39, "A puff should burn one unit of fuel") + TEST_ASSERT(scorcher.spray_fire(run_loc_floor_top_right, shooter), "A second puff right after the first did not fire") + TEST_ASSERT_EQUAL(scorcher.fuel, 38, "A second puff should burn another unit") + scorcher.fuel = 0 + TEST_ASSERT(!scorcher.spray_fire(run_loc_floor_top_right, shooter), "A dry tank still sprayed") + TEST_ASSERT_EQUAL(scorcher.fuel, 0, "A dry tank went below empty") var/obj/item/reagent_containers/cup/beaker/large/beaker = allocate(/obj/item/reagent_containers/cup/beaker/large) beaker.reagents.add_reagent(/datum/reagent/water, 30) beaker.reagents.add_reagent(/datum/reagent/fuel/oil, 30) scorcher.draw_fuel(beaker, shooter) - TEST_ASSERT_EQUAL(scorcher.fuel, 2, "The tank took something that isn't welding fuel") + TEST_ASSERT_EQUAL(scorcher.fuel, 0, "The tank took something that isn't welding fuel") // A large beaker holds 100, so this fills it. beaker.reagents.add_reagent(/datum/reagent/fuel, 40) scorcher.draw_fuel(beaker, shooter) TEST_ASSERT_EQUAL(scorcher.fuel, 40, "The tank did not fill from a beaker of fuel") - TEST_ASSERT_EQUAL(beaker.reagents.get_reagent_amount(/datum/reagent/fuel), 2, "The tank took more fuel than it holds") + TEST_ASSERT_EQUAL(beaker.reagents.get_reagent_amount(/datum/reagent/fuel), 0, "The tank took more fuel than it holds") TEST_ASSERT_EQUAL(beaker.reagents.get_reagent_amount(/datum/reagent/water), 30, "Refuelling took water out of the beaker") +/// Holding the trigger starts a processing spray that puffs at once, and stopping, dropping or deleting the gun cleans it all up. +/datum/unit_test/voidcrew_scorcher_held_spray +/datum/unit_test/voidcrew_scorcher_held_spray/Run() + var/mob/living/carbon/human/consistent/shooter = allocate(/mob/living/carbon/human/consistent) + var/obj/item/gun/ballistic/automatic/heater/scorcher/scorcher = allocate(/obj/item/gun/ballistic/automatic/heater/scorcher) + TEST_ASSERT(shooter.put_in_hands(scorcher), "The shooter could not hold the Scorcher") + TEST_ASSERT_EQUAL(scorcher.spray_holder, shooter, "Holding the Scorcher did not start watching the holder") + + scorcher.start_spraying(run_loc_floor_top_right) + TEST_ASSERT(scorcher.spraying, "The spray did not start") + TEST_ASSERT(scorcher.datum_flags & DF_ISPROCESSING, "The spray is not processing") + TEST_ASSERT_EQUAL(scorcher.fuel, 39, "The spray's first puff did not go off at once") + TEST_ASSERT(scorcher.spray_puff(), "A held puff did not fire") + TEST_ASSERT_EQUAL(scorcher.fuel, 38, "A held puff should burn one unit of fuel") + scorcher.stop_spraying() + TEST_ASSERT(!scorcher.spraying, "The spray did not stop") + TEST_ASSERT(!(scorcher.datum_flags & DF_ISPROCESSING), "The stopped spray is still processing") + TEST_ASSERT_NULL(scorcher.spray_target, "The stopped spray kept its target") + + scorcher.fuel = 0 + scorcher.start_spraying(run_loc_floor_top_right) + TEST_ASSERT(!scorcher.spraying, "A dry tank started spraying") + TEST_ASSERT(!(scorcher.datum_flags & DF_ISPROCESSING), "A dry tank left the spray processing") + + scorcher.fuel = 40 + scorcher.start_spraying(run_loc_floor_top_right) + TEST_ASSERT(scorcher.spraying, "The refuelled spray did not start") + shooter.dropItemToGround(scorcher) + TEST_ASSERT(!scorcher.spraying, "Dropping the Scorcher did not stop the spray") + TEST_ASSERT(!(scorcher.datum_flags & DF_ISPROCESSING), "The dropped Scorcher is still processing") + TEST_ASSERT_NULL(scorcher.spray_holder, "The dropped Scorcher kept its holder") + + TEST_ASSERT(shooter.put_in_hands(scorcher), "The shooter could not pick the Scorcher back up") + scorcher.start_spraying(run_loc_floor_top_right) + TEST_ASSERT(scorcher.spraying, "The spray did not start again") + qdel(scorcher) + TEST_ASSERT(!(scorcher.datum_flags & DF_ISPROCESSING), "A deleted Scorcher is still processing") + TEST_ASSERT_NULL(scorcher.spray_holder, "A deleted Scorcher kept its holder") + /// Each gun can be bought at its tier and found in plunder, with a spare magazine, and its magazine is sold. /datum/unit_test/voidcrew_scorcher_routes /datum/unit_test/voidcrew_scorcher_routes/Run() From e25450ec8e0559f1ae9f0bd3dd27ff4f57c17d30 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:31:07 -0800 Subject: [PATCH 211/472] Keep the Gorilla arm puppets on their shoulders, crush the head in the clap hold, drop the backbroken body off the knee, crouch for the ground pound, keep the wall spray on the wall, trim the bone shards and flatten the helmet dent --- voidcrew/_DEFINES/gorilla_gore.dm | 4 + .../chrome_puppet_rig.dm | 79 ++++++++- .../hyperviolence_gorilla/gorilla_finisher.dm | 161 ++++++++++-------- .../hyperviolence_gorilla/gorilla_pound.dm | 64 ++++++- .../hyperviolence_gorilla/gorilla_throw.dm | 13 +- .../icons/gorilla_gore.dmi | Bin 10014 -> 9782 bytes .../icons/gorilla_gore_large.dmi | Bin 5439 -> 4320 bytes .../unit_tests/voidcrew_gorilla_gore.dm | 1 + 8 files changed, 241 insertions(+), 81 deletions(-) diff --git a/voidcrew/_DEFINES/gorilla_gore.dm b/voidcrew/_DEFINES/gorilla_gore.dm index 8279929eadd..45832c27cb9 100644 --- a/voidcrew/_DEFINES/gorilla_gore.dm +++ b/voidcrew/_DEFINES/gorilla_gore.dm @@ -103,6 +103,10 @@ // ---- Ground pound ---- #define GORILLA_POUND_CROUCH (0.45 SECONDS) +/// How long the fists take from overhead to the floor +#define GORILLA_POUND_DRIVE (0.08 SECONDS) +/// How long the bearer takes to stand back up after a ground pound +#define GORILLA_POUND_RISE (0.3 SECONDS) #define GORILLA_POUND_STAMINA 20 #define GORILLA_POUND_DAZE (3 SECONDS) /// Loose items this far from the pound hop a tile outward. diff --git a/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm b/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm index 0ce8436f1bd..fa51e55444c 100644 --- a/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm +++ b/voidcrew/modules/hyperviolence_gorilla/chrome_puppet_rig.dm @@ -37,6 +37,24 @@ GLOBAL_LIST_INIT(chrome_rig_shoulders, list( "[WEST]" = list(4, 2, 4, 2), )) +/// Pixels from a shoulder pivot to the fist on a hanging arm, per facing. +GLOBAL_LIST_INIT(chrome_rig_arm_length, list( + "[SOUTH]" = 10, + "[NORTH]" = 10, + "[EAST]" = 7, + "[WEST]" = 7, +)) + +/// The lowest pixel row (from the bottom, 1-based) of a human head; the neck is under it. +#define CHROME_RIG_NECK_Y 23 +/// The middle of the head, from the icon's centre. +#define CHROME_RIG_HEAD_PIVOT_Y 9 + +/// Most degrees an arm turns in one animation step. +#define CHROME_RIG_SWING_STEP 12 +/// The shortest an arm is drawn, as a share of its length. +#define CHROME_RIG_MIN_ARM_LENGTH 0.4 + /// Where the torso under a side view's near arm is filled from: a pixel takes the one this far behind /// it along x (negative: from in front). list(dir = shift). GLOBAL_LIST_INIT(chrome_rig_fill_shift, list( @@ -81,6 +99,8 @@ GLOBAL_LIST_INIT(chrome_rig_fill_shift, list( var/old_mouse_opacity /// TRUE while the body is hidden var/hidden = FALSE + /// Side -> list(turn, length) the arm was last sent to + var/list/arm_poses = list() /datum/chrome_rig/New(mob/living/body, facing) . = ..() @@ -134,6 +154,15 @@ GLOBAL_LIST_INIT(chrome_rig_fill_shift, list( piece.Blend(fill, ICON_OVERLAY) return piece +/// The flat picture above the neck (the head, with its hair and hat) if [head], else below it. +/datum/chrome_rig/proc/cut_at_neck(head = TRUE) + var/icon/piece = icon(flat) + if(head) + piece.DrawBox(null, 1, 1, ICON_SIZE_X, CHROME_RIG_NECK_Y - 1) + else + piece.DrawBox(null, 1, CHROME_RIG_NECK_Y, ICON_SIZE_X, ICON_SIZE_Y) + return piece + /// Hides the real body. Its whole tile still takes clicks. /datum/chrome_rig/proc/conceal() if(hidden || QDELETED(body)) @@ -228,8 +257,8 @@ GLOBAL_LIST_INIT(chrome_rig_fill_shift, list( picture.Shift(facing == EAST ? WEST : EAST, 1) picture.Blend(rgb(170, 170, 178), ICON_MULTIPLY) var/obj/effect/abstract/chrome_puppet/arm = add_puppet("arm_[side]", picture) - if(side_view) - arm.layer = side == near ? body.layer + 0.01 : body.layer - 0.01 + // Seen from the front or back both arms are over the body + arm.layer = (!side_view || side == near) ? body.layer + 0.01 : body.layer - 0.01 conceal() return TRUE @@ -237,17 +266,49 @@ GLOBAL_LIST_INIT(chrome_rig_fill_shift, list( /datum/chrome_rig/proc/near_side() return facing == WEST ? "l" : "r" -/// Turns the arm on [side] to [angle] degrees up from hanging (0) over [time], with [reach] pixels of piston out along it. +/** + * Turns the arm on [side] to [angle] degrees up from hanging (0) over [time]. [reach] pixels of piston + * push the fist out along the arm (negative pulls it in, as an arm reaching toward the viewer looks + * shorter). The arm stretches or shortens from the shoulder, so its top never leaves the shoulder. + * + * BYOND blends the move part of a turned matrix in a straight line, which would carry the shoulder off + * its pivot halfway through a big swing, so a swing is cut into steps of a few degrees. + */ /datum/chrome_rig/proc/swing_arm(side, angle, time, reach = 0, easing = SINE_EASING) var/obj/effect/abstract/chrome_puppet/arm = puppet("arm_[side]") if(!arm) return var/list/pivot = shoulder(side) - var/turn = angle * raise_sign(side) - // The piston pushes the fist out along the arm: straight down when hanging, turned with it. - var/push_x = -reach * sin(turn) - var/push_y = -reach * cos(turn) - animate(arm, transform = chrome_rig_turn(turn, pivot[1], pivot[2], round(push_x), round(push_y)), time = time, easing = easing) + var/list/from = arm_poses[side] || list(0, 1) + var/to_turn = angle * raise_sign(side) + var/to_length = max(CHROME_RIG_MIN_ARM_LENGTH, 1 + reach / (GLOB.chrome_rig_arm_length["[facing]"] || 10)) + arm_poses[side] = list(to_turn, to_length) + var/steps = max(1, CEILING(abs(to_turn - from[1]) / CHROME_RIG_SWING_STEP, 1)) + var/step_time = time / steps + for(var/i in 1 to steps) + // Sine in and out across the whole swing; each step itself is straight + var/progress = (easing & SINE_EASING) ? (1 - cos(180 * i / steps)) / 2 : i / steps + var/matrix/pose = chrome_rig_turn(from[1] + (to_turn - from[1]) * progress, pivot[1], pivot[2], 0, 0, 1, from[2] + (to_length - from[2]) * progress) + if(i == 1) + animate(arm, transform = pose, time = step_time) + else + animate(transform = pose, time = step_time) + +/** + * The body crouches over [time]: squashed to [squash] of its height and dropped [drop] pixels, the arms + * coming down with their shoulders. 1 and 0 stand it back up. + */ +/datum/chrome_rig/proc/crouch(squash, drop, time, easing = SINE_EASING) + var/obj/effect/abstract/chrome_puppet/body_puppet = puppet("body") + if(body_puppet) + animate(body_puppet, transform = matrix(1, 0, 0, 0, squash, -drop), time = time, easing = easing) + for(var/side in list("l", "r")) + var/obj/effect/abstract/chrome_puppet/arm = puppet("arm_[side]") + if(!arm) + continue + // Where the squashed body puts this shoulder + var/list/pivot = shoulder(side) + animate(arm, pixel_z = round(pivot[2] * squash - drop - pivot[2]), time = time, easing = easing, flags = ANIMATION_PARALLEL) /// Every puppet trembles a pixel for [time]. /datum/chrome_rig/proc/tremble(time = 0.4 SECONDS) @@ -267,3 +328,5 @@ GLOBAL_LIST_INIT(chrome_rig_fill_shift, list( cyberware_flash_fx(puppets[key], 0.2 SECONDS) #undef CHROME_RIG_HIDE_FILTER +#undef CHROME_RIG_SWING_STEP +#undef CHROME_RIG_MIN_ARM_LENGTH diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm index bf5812523ab..3b92ad467a7 100644 --- a/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_finisher.dm @@ -318,80 +318,108 @@ if("squeeze") return "You choke and kick in [user]'s grip." +/// Every puppet of the body held in the clap. +/datum/hv_finisher/chrome/gorilla/clap/proc/held_parts() + . = list() + for(var/key_name in victim_rig?.puppets) + . += victim_rig.puppets[key_name] + /datum/hv_finisher/chrome/gorilla/clap/pose(key) switch(key) if("hoist") rig_bearer() victim_rig = new(victim, REVERSE_DIR(facing || user.dir)) if(victim_rig.snapshot()) - var/obj/effect/abstract/chrome_puppet/held = victim_rig.add_puppet("whole", victim_rig.flat, TRUE) - // The held body is in front of the bearer, unless he faces away from the viewer. - held.layer = facing == NORTH ? user.layer - 0.02 : user.layer + 0.02 + // The head on its own puppet, so it can strain and crush while the body hangs + victim_rig.add_puppet("body", victim_rig.cut_at_neck(FALSE), TRUE) + victim_rig.add_puppet("head", victim_rig.cut_at_neck(TRUE), TRUE) victim_rig.conceal() var/list/toward = to_bearer() - animate(held, transform = matrix(), pixel_x = round(toward[1] * 0.7), pixel_y = round(toward[2] * 0.7), pixel_w = 0, pixel_z = 6, time = 0.4 SECONDS, easing = SINE_EASING | EASE_OUT) + for(var/obj/effect/abstract/chrome_puppet/held as anything in held_parts()) + // The held body is in front of the bearer, unless he faces away from the viewer. Held up to his + // face when he faces the viewer, so its head is between the fists. + held.layer = facing == NORTH ? user.layer - 0.02 : user.layer + 0.02 + animate(held, transform = matrix(), pixel_x = round(toward[1] * 0.7), pixel_y = round(toward[2] * 0.7), pixel_w = 0, pixel_z = facing == SOUTH ? 15 : 6, time = 0.4 SECONDS, easing = SINE_EASING | EASE_OUT) else QDEL_NULL(victim_rig) if(side_view()) - // The near fist holds the shirt front, kept under the bearer's face; the far one waits low behind. + // The near fist holds the shirt front, over the held body; the far one is behind the bearer. var/obj/effect/abstract/chrome_puppet/near_arm = arm_puppet("near") if(near_arm) near_arm.layer = user.layer + 0.03 - arm_to("near", 100, 0.4 SECONDS, 4) - arm_to("far", 40, 0.4 SECONDS, 2) + arm_to("near", 125, 0.4 SECONDS, 2) + arm_to("far", 60, 0.4 SECONDS) else - // Seen from the front or back: both hands on the shoulders of the body held in front + // Seen from the front or back: both hands on the shoulders of the body held in front. The arms + // are over the bearer's body and under the held one (which is behind him when he faces away). for(var/side in list("l", "r")) var/obj/effect/abstract/chrome_puppet/arm = bearer_rig?.puppet("arm_[side]") if(arm) - arm.layer = facing == NORTH ? user.layer + 0.01 : user.layer + 0.03 + arm.layer = user.layer + 0.01 arms_to(-25, 0.4 SECONDS) if("squeeze") + // The fists close on the head and press. The body hangs still; the head strains and gives between them. + if(side_view()) + arm_to("near", 122, 0.3 SECONDS, 3) + arm_to("far", 122, 0.3 SECONDS, 3) + else if(facing == NORTH) + arms_to(165, 0.3 SECONDS, 7) + else + arms_to(160, 0.3 SECONDS, -2) + // After the swings: the tremble runs alongside them bearer_rig?.tremble(1 SECONDS) - var/obj/effect/abstract/chrome_puppet/held = victim_rig?.puppet("whole") - if(held) - // Swinging on the collar: turns about the top of the body - for(var/i in 1 to 4) - var/angle = (i % 2) ? 6 : -6 + if(facing != SOUTH) + for(var/obj/effect/abstract/chrome_puppet/held as anything in held_parts()) + animate(held, pixel_z = 1, time = 0.3 SECONDS, easing = SINE_EASING, flags = ANIMATION_PARALLEL) + var/obj/effect/abstract/chrome_puppet/head = victim_rig?.puppet("head") + if(head) + // Narrower in steps, a strain jerk between each + var/list/jerks = list(-5, 4, -4, 5, -3, 0) + for(var/i in 1 to length(jerks)) + var/give = 1 - 0.05 * i + var/matrix/pressed = side_view() ? chrome_rig_turn(jerks[i], 0, CHROME_RIG_HEAD_PIVOT_Y, 0, 0, 1 + (1 - give) * 0.5, give) : chrome_rig_turn(jerks[i], 0, CHROME_RIG_HEAD_PIVOT_Y, 0, 0, give, 1 + (1 - give) * 0.3) if(i == 1) - animate(held, transform = chrome_rig_turn(angle, 0, 10), time = 0.12 SECONDS, easing = SINE_EASING) + animate(head, transform = pressed, time = 0.2 SECONDS, easing = SINE_EASING) else - animate(transform = chrome_rig_turn(angle, 0, 10), time = 0.24 SECONDS, easing = SINE_EASING) - animate(transform = matrix(), time = 0.12 SECONDS) + animate(transform = pressed, time = 0.15 SECONDS, easing = SINE_EASING) playsound(victim, 'sound/mobs/humanoids/human/gasp/gasp_male1.ogg', 30, TRUE) if("toss") - var/obj/effect/abstract/chrome_puppet/held = victim_rig?.puppet("whole") - if(held) - animate(held, pixel_z = 10, time = 0.15 SECONDS, easing = SINE_EASING | EASE_OUT) - animate(pixel_z = 6, time = 0.15 SECONDS, easing = SINE_EASING | EASE_IN) + for(var/obj/effect/abstract/chrome_puppet/held as anything in held_parts()) + // A small jolt as the fists let go. Seen from the front he stays up, so his head is where the fists + // meet behind it. + animate(held, pixel_z = facing == SOUTH ? 17 : 4, time = 0.15 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + animate(pixel_z = facing == SOUTH ? 15 : 2, time = 0.15 SECONDS, easing = SINE_EASING | EASE_IN) + // The fists come off the head a little, for the clap if(side_view()) - // Both fists draw back: the near one low, the far one up by the head. An arm swung high about - // the shoulder would cross the bearer's face from the side, so the far arm is lifted instead. - arm_to("near", 30, 0.15 SECONDS, 4) - arm_to("far", 50, 0.15 SECONDS, 2) - var/obj/effect/abstract/chrome_puppet/far_arm = arm_puppet("far") - if(far_arm) - animate(far_arm, pixel_z = 9, time = 0.15 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) + arm_to("near", 95, 0.15 SECONDS) + arm_to("far", 100, 0.15 SECONDS) + else if(facing == NORTH) + arms_to(135, 0.15 SECONDS) else - arms_to(80, 0.15 SECONDS, 4) + arms_to(125, 0.15 SECONDS) if("clap") if(side_view()) - // ...and close on the head from below and above as the body drops into them - arm_to("near", 117, 0.08 SECONDS, 6) - arm_to("far", 95, 0.08 SECONDS, 6) - var/obj/effect/abstract/chrome_puppet/held = victim_rig?.puppet("whole") - if(held) - animate(held, pixel_z = 0, time = 0.08 SECONDS, easing = SINE_EASING | EASE_IN) + // ...and close on the head as the body drops into them: the near fist over it, the far one + // behind it + arm_to("near", 120, 0.08 SECONDS, 3) + arm_to("far", 120, 0.08 SECONDS, 3) + else if(facing == NORTH) + // Seen from behind: both fists up past his head onto the one held beyond it + arms_to(165, 0.08 SECONDS, 7) else - // Both fists in from the sides, meeting across the head - arms_to(-95, 0.08 SECONDS, -6) + // Seen from the front: the arms come up behind the held body, the fists on either side of its head + arms_to(160, 0.08 SECONDS, -2) + if(facing != SOUTH) + for(var/obj/effect/abstract/chrome_puppet/held as anything in held_parts()) + animate(held, pixel_z = 0, time = 0.08 SECONDS, easing = SINE_EASING | EASE_IN, flags = ANIMATION_PARALLEL) + var/obj/effect/abstract/chrome_puppet/head = victim_rig?.puppet("head") + if(head) + // The fists close and the head gives between them + animate(head, transform = chrome_rig_turn(0, 0, CHROME_RIG_HEAD_PIVOT_Y, 0, 0, side_view() ? 1.2 : 0.55, side_view() ? 0.6 : 1.15), time = 0.08 SECONDS, easing = SINE_EASING | EASE_IN) // The sparks go where the fists meet, on the head - hit_stop(victim_rig?.puppet("whole") || victim, TRUE, 9) + hit_stop(head || victim, TRUE, 9) if("recover") arms_to(0, 0.5 SECONDS) - var/obj/effect/abstract/chrome_puppet/far_arm = arm_puppet("far") - if(far_arm) - animate(far_arm, pixel_z = 0, time = 0.5 SECONDS, flags = ANIMATION_PARALLEL) /datum/hv_finisher/chrome/gorilla/clap/beat(key) if(key != "clap" || QDELETED(victim)) @@ -609,13 +637,13 @@ victim_rig.conceal() else QDEL_NULL(victim_rig) - // A boot on the body: the bearer leans over them + arms_to(side_view() ? 45 : 30, 0.3 SECONDS, 3) + // A boot on the body: the bearer leans over them, arms and all (after the swing, which would cut + // the lean short on the arms) for(var/key_name in bearer_rig?.puppets) var/obj/effect/abstract/chrome_puppet/part = bearer_rig.puppets[key_name] - animate(part, pixel_x = part.pixel_x - round(toward[1] * 0.18), pixel_y = part.pixel_y - round(toward[2] * 0.18), time = 0.3 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) - arms_to(side_view() ? 45 : 30, 0.3 SECONDS, 3) + animate(part, pixel_x = part.pixel_x - round(toward[1] * 0.08), pixel_y = part.pixel_y - round(toward[2] * 0.08), time = 0.3 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) if("strain") - bearer_rig?.tremble(1.3 SECONDS) var/obj/effect/abstract/chrome_puppet/arm = victim_rig?.puppet("arm") if(arm) // The arm comes out of the shoulder toward the bearer, five pixels over the strain @@ -624,6 +652,7 @@ if(body) body.Shake(1, 1, 1.3 SECONDS) arms_to(side_view() ? 25 : 15, 1.3 SECONDS, 1) + bearer_rig?.tremble(1.3 SECONDS) if("tear") arms_to(side_view() ? -20 : 0, 0.1 SECONDS) if("club") @@ -819,7 +848,7 @@ if("press") return "[victim] thrashes in [user]'s grip." if("roll") - return "[user] rolls [victim] off [user.p_their()] knee." + return "[user] drops [victim] off [user.p_their()] knee." /datum/hv_finisher/chrome/gorilla/backbreaker/enter_self_line(key) switch(key) @@ -847,10 +876,10 @@ QDEL_NULL(victim_rig) // Fists up under the body if(side_view()) - arm_to("near", 166, 0.5 SECONDS, 9) - arm_to("far", 174, 0.5 SECONDS, 9) + arm_to("near", 166, 0.5 SECONDS, 6) + arm_to("far", 174, 0.5 SECONDS, 6) else - arms_to(170, 0.5 SECONDS, 8) + arms_to(170, 0.5 SECONDS, 3) if("press") bearer_rig?.tremble(1 SECONDS) var/obj/effect/abstract/chrome_puppet/upper = victim_rig?.puppet("upper") @@ -873,30 +902,28 @@ animate(transform = chrome_rig_turn(90, 0, BACK_WAIST_PIVOT_Y), time = 0.1 SECONDS) if("slam") // Down to one knee, and the body comes down across it - var/obj/effect/abstract/chrome_puppet/body = bearer_rig?.puppet("body") - if(body) - animate(body, transform = matrix(1, 0, 0, 0, 0.8, -3), time = 0.15 SECONDS, easing = SINE_EASING | EASE_IN) - for(var/side in list("l", "r")) - var/obj/effect/abstract/chrome_puppet/arm = bearer_rig?.puppet("arm_[side]") - if(arm) - animate(arm, pixel_z = -6, time = 0.15 SECONDS, flags = ANIMATION_PARALLEL) arms_to(120, 0.15 SECONDS, 2) + bearer_rig?.crouch(0.8, 3, 0.15 SECONDS, SINE_EASING | EASE_IN) for(var/half in list("upper", "lower")) var/obj/effect/abstract/chrome_puppet/part = victim_rig?.puppet(half) if(part) animate(part, pixel_z = 2, time = 0.16 SECONDS, easing = SINE_EASING | EASE_IN, flags = ANIMATION_PARALLEL) if("roll") - var/obj/effect/abstract/chrome_puppet/body = bearer_rig?.puppet("body") - if(body) - animate(body, transform = matrix(), time = 0.4 SECONDS, easing = SINE_EASING | EASE_OUT) - for(var/side in list("l", "r")) - var/obj/effect/abstract/chrome_puppet/arm = bearer_rig?.puppet("arm_[side]") - if(arm) - animate(arm, pixel_z = 0, time = 0.4 SECONDS, flags = ANIMATION_PARALLEL) arms_to(0, 0.5 SECONDS) - // Off the knee onto their own tile, still bent if the back broke. A broken back hangs folded over - // the knee for a moment first. The halves turn about the belt, seven pixels under the middle, so - // they sit a pixel up to lie where a lying body lies. + bearer_rig?.crouch(1, 0, 0.4 SECONDS, SINE_EASING | EASE_OUT) + // Off the knee and down where it happened, on the bearer's tile (a body on the floor shares it). + // The halves stay drawn where they are as the body moves under them. + var/turf/knee = get_turf(user) + var/turf/from = get_turf(victim) + if(knee && from && knee != from && victim.body_position == LYING_DOWN && victim.Move(knee, get_dir(from, knee))) + for(var/key_name in victim_rig?.puppets) + var/obj/effect/abstract/chrome_puppet/part = victim_rig.puppets[key_name] + part.forceMove(knee) + part.pixel_x -= (knee.x - from.x) * ICON_SIZE_X + part.pixel_y -= (knee.y - from.y) * ICON_SIZE_Y + // Still bent if the back broke. A broken back hangs folded over the knee for a moment first. The + // halves turn about the belt, seven pixels under the middle, so they sit a pixel up to lie where a + // lying body lies. var/bend = broke ? 33 : 0 var/hold = broke ? GORILLA_BACK_FOLD_HOLD : 0 var/obj/effect/abstract/chrome_puppet/upper = victim_rig?.puppet("upper") @@ -929,7 +956,7 @@ broke = outcome == "broken" if(broke && !QDELETED(victim)) // A trickle from the mouth, under where his head hangs over the knee - victim.add_splatter_floor(get_turf(victim), TRUE) + victim.add_splatter_floor(get_turf(user), TRUE) if(broke) // The body folds over the knee var/obj/effect/abstract/chrome_puppet/upper = victim_rig?.puppet("upper") diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm index e4609a354ad..76bc6217770 100644 --- a/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_pound.dm @@ -17,13 +17,38 @@ cooldown_time = GORILLA_POUND_COOLDOWN /// The crouch before the slam var/crouch_timer + /// The bearer's arm rig while the pound is drawn (chrome_puppet_rig.dm) + var/datum/chrome_rig/rig /datum/action/cooldown/cyberware/gorilla_pound/Destroy() if(crouch_timer) deltimer(crouch_timer) crouch_timer = null + drop_rig() return ..() +/// Draws the bearer crouching with both arms up. Returns TRUE if the rig is up. +/datum/action/cooldown/cyberware/gorilla_pound/proc/raise_rig(mob/living/carbon/human/bearer) + drop_rig() + rig = new(bearer, bearer.dir) + if(!rig.rig_arms()) + QDEL_NULL(rig) + return FALSE + // Moved, knocked down or gone: the drawing comes down at once + RegisterSignals(bearer, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BODY_POSITION, COMSIG_QDELETING), PROC_REF(drop_rig)) + // Arms first: a crouch runs alongside, and a swing started after it would cut it short + for(var/side in list("l", "r")) + rig.swing_arm(side, 150, GORILLA_POUND_CROUCH, 0, SINE_EASING) + rig.crouch(0.88, 2, GORILLA_POUND_CROUCH, SINE_EASING | EASE_OUT) + return TRUE + +/// Takes the rig down and shows the real body. +/datum/action/cooldown/cyberware/gorilla_pound/proc/drop_rig(datum/source) + SIGNAL_HANDLER + if(rig?.body) + UnregisterSignal(rig.body, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_SET_BODY_POSITION, COMSIG_QDELETING)) + QDEL_NULL(rig) + /datum/action/cooldown/cyberware/gorilla_pound/IsAvailable(feedback = FALSE) . = ..() if(!.) @@ -49,9 +74,10 @@ return FALSE StartCooldown() gorilla_cooldown(bearer, COOLDOWN_GORILLA_POUND, GORILLA_POUND_COOLDOWN) - // The crouch: down and wide - animate(bearer, pixel_z = -2, time = GORILLA_POUND_CROUCH, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) - animate(pixel_z = 2, time = 0.1 SECONDS, flags = ANIMATION_RELATIVE) + // The crouch: down, both fists up. Without a rig (something huge worn) the body only dips. + if(!raise_rig(bearer)) + animate(bearer, pixel_z = -2, time = GORILLA_POUND_CROUCH, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + animate(pixel_z = 2, time = 0.1 SECONDS, flags = ANIMATION_RELATIVE) crouch_timer = addtimer(CALLBACK(src, PROC_REF(slam), get_turf(bearer)), GORILLA_POUND_CROUCH, TIMER_STOPPABLE|TIMER_DELETE_ME) return TRUE @@ -68,9 +94,25 @@ crouch_timer = null var/mob/living/carbon/human/bearer = owner if(!ishuman(bearer) || bearer.loc != where || bearer.stat != CONSCIOUS || HAS_TRAIT(bearer, TRAIT_INCAPACITATED) || HAS_TRAIT(bearer, TRAIT_HANDS_BLOCKED)) + drop_rig() return + if(rig) + // Down hard, fists on the floor, then back up + for(var/side in list("l", "r")) + rig.swing_arm(side, 0, GORILLA_POUND_DRIVE, 3, NONE) + rig.crouch(0.72, 6, GORILLA_POUND_DRIVE, SINE_EASING | EASE_IN) + addtimer(CALLBACK(src, PROC_REF(rise)), GORILLA_POUND_DRIVE + 0.1 SECONDS, TIMER_DELETE_ME) gorilla_ground_pound(bearer, where) +/// The bearer stands back up after the pound. +/datum/action/cooldown/cyberware/gorilla_pound/proc/rise() + if(!rig) + return + for(var/side in list("l", "r")) + rig.swing_arm(side, 0, GORILLA_POUND_RISE, 0) + rig.crouch(1, 0, GORILLA_POUND_RISE, SINE_EASING | EASE_OUT) + addtimer(CALLBACK(src, PROC_REF(drop_rig)), GORILLA_POUND_RISE, TIMER_DELETE_ME) + /// Both fists into the floor at [where]: the shock, the stagger and the hop. Returns the mobs it hit. /proc/gorilla_ground_pound(mob/living/carbon/human/bearer, turf/where) . = list() @@ -78,7 +120,7 @@ bearer.do_attack_animation(where, ATTACK_EFFECT_SMASH) bearer.visible_message(span_danger("[bearer] drives both fists into the floor!"), span_danger("You drive both fists into the floor!"), vision_distance = DEFAULT_MESSAGE_RANGE) playsound(where, 'sound/effects/meteorimpact.ogg', 80, TRUE, 8) - cyberware_piledriver_impact_fx(where, 1.5) + gorilla_pound_impact_fx(where, 1.5) cyberware_grey_dust(where) for(var/mob/living/shaken in range(3, where)) if(shaken.client && shaken != bearer) @@ -96,6 +138,20 @@ . += hit gorilla_pound_scatter(where) +/// The ring and the cracks under a ground pound: the Piledriver's cross, gone sooner. +/proc/gorilla_pound_impact_fx(turf/where, reach) + if(isnull(where)) + return + new /obj/effect/temp_visual/circle_wave/cyberware_slam(where, reach) + new /obj/effect/temp_visual/cyberware_impact_cracks/gorilla_pound(where, "damaged1", 255) + for(var/direction in GLOB.cardinals) + var/turf/next = get_step(where, direction) + if(next) + new /obj/effect/temp_visual/cyberware_impact_cracks/gorilla_pound(next, "damaged5", 140) + +/obj/effect/temp_visual/cyberware_impact_cracks/gorilla_pound + duration = 4 SECONDS + /// Loose things on the floor near [where] hop a tile outward, never into a hazard, glass or a door. /proc/gorilla_pound_scatter(turf/where) var/moved = 0 diff --git a/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm b/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm index 3536368ea8b..930811e57b2 100644 --- a/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm +++ b/voidcrew/modules/hyperviolence_gorilla/gorilla_throw.dm @@ -437,10 +437,19 @@ /obj/effect/decal/cleanable/blood/hv_spray/wall/gorilla_spray/runs/hv_aim(spray_dir) . = ..() - // The runs grow downward from where the spray hit. Parallel: tg's drying animation keeps running. + // The runs grow downward from their top on the wall face, so they never show past it. Parallel: tg's + // drying animation keeps running. + var/top = 0 + switch(spray_dir) + if(SOUTH) + top = -17 + if(NORTH) + top = 25 + else + top = -1 var/matrix/start = matrix() start.Scale(1, 0.25) - start.Translate(0, 16) + start.Translate(0, round(top * 0.75)) transform = start animate(src, transform = matrix(), time = 3 SECONDS, easing = SINE_EASING | EASE_OUT, flags = ANIMATION_PARALLEL) diff --git a/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore.dmi b/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore.dmi index c152f8f1e6f0913297052aca37a94c72fd613b25..e2293313e6e85f30fd9afa5f5e9518c5bb9e1e5f 100644 GIT binary patch delta 9030 zcmaKRcQ~8v`*)0%r&a3dK&`f(r%H{YW{@tlRio4%p{hy|J7#WAONZH^sFk9j_7*Eb z6|E6lN<@rWNyUtih`i}{yvOk#$NT%`kK{V;T;sex=jZ&~=XG8=ruBC#E4YBa{BPg9 zc0V+0l^9j&xDX}1?hz}H+LnI&+*Q?0^~U3|56%-_ZHU6V>WXAL%S>LJ#YShx=vX>j zDED+r`&Oq`N0@uFl76nJ@2+mr{Qa-ry6PNWd>XadeHNN=#PB_HR z#T}`G9g}izY_(~kQz!J6w3&N-zXYf7Q8*Z~rkNC#vz7YiyKCGhftDXXwJR-u5EG0ax-iZTtvSdo040f#D9Vm$ z0e{j2Wn%LCVF+eY)e$&_-5KS|(nR7Rjke*XKm$91jsn*cb2QdIm}-CZ8*Q9zGV!F5 zU-b}@T3-^xm79Q1%RAkpJEmUR{~4Nzhm?tNiNFyBKNxS+nq7Cg^q7`{dAQG-DI9_#uqtqlCNIG0UH#YLtz`+z$q2gtB-4bC0E(9(T>jE7;hNoc{PUk-!Tf~ z>}=%3DRfHi1O7Up{QmsI>kM(}6#$VqsJ;7>s$dv#&tsGT1*{m%d1?gJ)CYY~OrmoB z{qDsAR>&e_kc0!#cp5JRBR7_VNT%dQ~ z;|ax=ulO#vEESY8unw?MNt#U&7Lvor7eduj^rz!)c;C;X=9aRZj_DXx;lu zW@h4$up1~>u~mcSjfWY?DE2a^6$hkJNOEDTOn~i@}(TL&i4ew=iV<4wXw#qACTUHMNfk`H=Si;&25?6H0Ig z0b2z4Nvjv62Q3F*g5Ssb)t*@O_vY~TNe|=2o_&V>4E6g~+_6HlPm0h9d$^qER?bT8 zfB1Q4viU#Mo%XR27{D>4VJ7zM%ONr|p}>a0j1TME2ckpvbb|+b*1k0;SYFUudU+&t z3Nra>C(kLV>VQK_`uIy4Ffo09Dqzor!cOgtf85PLE-|p)F#0%e=fAkvL=crVc*eLi z-OLxo9_ZRkE0d1{Lq;%8t%w^#DLCoc)CRU~*|%%sfI*aVEI+Qns`?<}v5%FFlQEw2 zXu9_7V~fvM?1nxf?7u~XTrzB13ta*?LGh&BTa$H9bky4!C5VU_`?-T5K_mL845_Ge zprra>e5BZpjn1*QGx4E~iYOOz9u9_$$l7$5dHU2j6<%YFYOl1I64g0`yA0$99DMCL z|6&@4!WGzBz_~zuJ|oZLrMxaZbERxM?zh!d{co`*AFrZveQ_bDJ~Th}A((rk91g zqkMQcNGbwX!C8>1>RV#pP`Ig|!xfFAe^frycdGR4=BTOUB z;8Z}wz@_K5Yu}i29c#8Wj+@T|YyQajtVT|3Dgk`KlYM27!&ds!j!$LD5}k|z6r@Wr z)X>U9iFNP2@){C!VS#!pg1cPrW z$@Dzs16Pg{v`cAh*Z_q6jlrI&hnV@`rByGL?syVyZ9Lk!P)Pfu%mr{APtN0OQJx?Qe6yXrklh#fRn+vj%7aq zYNLLFi<^ulr5Ely`(DZwAbkdGS#KOJ9@`8J%=A_4fZI|a-9mp?+G@A53lj)@|5l9u z`~B`LuQU+7qXRr}e`DF4KxaTz6AZb?m5T|M!v)7gY#5JzE9ezcM&CGanyZC_u7ic) zb32%C)V5fuObnE914$bSTHD@`ZH2-7Ar?LFA&i?6nj;~6CSVj?U%4suV zR@sf)-3^BZK9l=U5HkNi#?yYj8?*4z65tb%m(5YWvOG*ZJ}OwPTOmXRL;ntjgKfpvvPwSFT!V zQ{ZZ{|IzG!0hC}N#``SkDJR-ZYi*o@S5M^HSRrjVK*^5-wQ4&Q2>SA%SO|wF-3_t0 zN=knj#Bh~pgQ#RKip$i<8-@%y2ana6@!q%MXpL%Qi9OpH#v!f}FsQL)R;m!-B@!L3 zP~_QIR`*}X&ZKf)0!Ob#)XGQ4^AO^TJ76l)Q9WY+Ra&s7^TbOUmZJmB8B@*<>L}z~ z)aUQ3byh>K5tEG@J&K-FItOnxvm7FKB`HnlrW+qiQE@x}1xSOuw$+4ZS6&A`%FPbn zG$CiY#$L7jf_fEqnAZOz7Iu*osaft|Gm26D%^V{yu`5^d6B`U7hJ1sq>ftQSm1lR} zJ#}@wQZzt8D4#Ln$ZVwd7p*Tv0;DFRtGNPCuRZzqTa2@3ILl^e$>iwM3x_!2T~twg z@BR3v+{he&m?^TcFLrSX#P|}Yh4wqrLmxBCHKc~n1k`JpA~Fd zj=>Q1bagb6EHL|_x17ntmCpuU4&2FC)X-c_rxwH*pHMG?JwL^Lz2QUDh;(?zk+#R^ z7Ri#Ny?y0X(L)1{JC$EJfo51EfY*vqq0#o@x#?{?9jdhbi@ae%kXOjA9N&=W@36XU0&{vCsV2Kvw?4fv&e!hHB=r$#Zd+m&h9 z;5Y40npG|bs%EWXV6Qw~J<^BZNpK6-WFtQ8XEJY2jJg0?Z=I$l4wJZ4g(z9m@;It_TA9O(-@NQ9hJ> zTuR8{(B-RjNvA&^Z9Mb#tS`Fk*bx`76IYq+eYv_>E!Sx8QOP!zjAXtPsH>}zr-5Aa zpi$~FsLY(sFA9pz(DwhWU;^H&Kfgv;7>=8oI$}Zs=8$7fe4sw}+ZjmA6It2mv`zoZ zsO|ei15jHY4-m@f%SahEw!Fi%)EE4ef-7$32iXRJ*3vVQdK0rVEbLP^tCUgO`E~D3 zh~S<0K?)(f`^IvprSNlEv67Y}1dBV(A$LuHLaP0w7lFN(Nr5tk7djV}0T!bvRj<64 zuggQ+@2;LQNku)%vghmM_g6rMMfe2g7mjy23UKB-!f=p7rG08?Q7!0$bEc}JUI)2U zSq`S5QgWQY+8gaDuQ&^_|3(Gvb4Jm}os2W}qDV#~TH^F>ApzhB<9qs3Q#saI_XnK4 zym(-?%;~L(=k!tp)1TX?DTDs0=UeZ!yfXqWD8QzwR=IgGj>@6l2jj0bLjceyCh{YT z5RDxtGvS$3jj(hp}C-}p+{NA6b$jg>ot<8W0`+n?5TO)c`|gglEeRw zsxkxzZ#3T2wB>J9?Rit*VLI2W%NkEdt0cf-a5!EK;qEwA5Lov#R;|+S=9Ee zJ1Z!Rg9Kz>Ib2#dCox{~wh{!H>e zU9R^#GD*#ENBuGDPQ=WxbSeu7l}80x@0Zab-JMb4bm9nwjlsB81I6@zW;e7(<#%>W z<@6>?R$^Pvkfxi@cD7HmkQCw)3eyJsCB^)0p=iH7mrJhYU=jT=vR}{y^M-U&*)K_+ z`!NR%_=77=98iHv{*trPx3&wMXNV*w!N&5AY+*{y3XLGg;q>nvgXK_Gn4yOWdB7<6 zDBk3E2)(fgZ-tZGRhklK2%H`f8!XOZ?4jmPIu_3))K9Y!b#M}F+bgUXzjH8MZB0vM zsbTC>x4poPaMA*161hwSpt|+bZVBC6faL7aK^Omf%;{SS;wp3To)gozQqEETa9nc! zHs2(oaamt>?xos{1lgy%?>g)&{JJTj%uWmZ57)e(sKu|^E%Tn8gPD#ozMR zbd)RFAbx0#qb?;D7v{7kroTyos`UkTzh;?Iet=CiOk=n>bq_H>z@d>%YO2qiY@pGs zR^#%1jDHI0YCo#nVjc!{-({3+vpnkS=QBewlA*ua&-)g221Kz=-6(DDsed8dW;7MN zgpb!*gCw;^5()r{1&{8|54ZP@5XMHg^uVIdXfQZ*-31l(o=JhF)6yzx-kIt(Z`ysm z@SR4Z+ZwDv0w0GUzZP~}K;y@S{k&f1T?`R1xVJyU2T8Z@VCvyaUN~a+-XeTv)Y1$Ss6uDY3IU-@2P~#^Xy+wo;nmARLpI&g zz&=j|o$7QSiC(5@mg29^JO6tLwHfBXP*%vKXNh&G?M9ntVYYP`!TdwsMGc_<_6zAh z5uqQM$@$lq5|f(@@x>?Y6oGg5j@$(7^DN<9KPgY&4ImuY19aVunN^w<&wM+jTraht zFLCqxk596Q8(v{Pun8n)=4VZXiQFGEDnaWr?LNI}%0O?r?oy`tNW3$gPby*^$O$4( z$VlE*_jmgF_;wFeR}4ys;BE=|(Mxu~Ri>#I@+9loUO}Y}r@;t8PT9T6aliwI%v3dL zCVdTdQ?WNy(Lq1YK%J}&?O>X{bB5QI#^+tDz{-=oZh(@1eYb^wyHc0BKB>uiP?o8d z6Snx_UToXv)WEyOX-Ymf6-n#^I$oExJG>OxiLR)^@N&>1$ho>S` zL(sPq9)NGI-6lCbB9hBdw+T_!<&KSBOSo)H*__Zh^Xo3(-~J5{L(&u-G(LEREDz>_ z>Axo4jhEV4Mkt~z&GcwHDC0EW$IQT1Djj(NLp5wvO1-j7v*~6EU760k@j^qWhJ}fs z3)Zn>FUIAm)U{pwyH z1ZLQy$;uPsKLEM7^QPe{C8mX26I>b2?J%0!=ZTGCR)zy@aM~k)2F@Ar!kv`ovTY57yZj zCnQFz4Hc`o)?JNpbJxi>6&uy0eW3vv>5ay6m=d(w@Lgoi-;uo;bFGaHkCF}&x-Uc) z-Q@L|DkwP6$le-RlG=0Hmw?KICdaIq9azwN;}PgK_b5zgQ!`QXy4DxXyX-Y(IM#Qs zXPi2^EsL|)Tr#!4k}T|36Zuh-J3FlTK~DaROg<@P7^zVGoS!0u+YN(1ZwF{a(}Ff? z(#Gt8gvJL<8oh7dX{iN@ow21~;!&z^Hr!>6L0NW_ES8Z)3@klspO3gUPq75($l;GN zGatav_k`%)GuKC}p5TKrE`}6lVk6M>9Rb_h{lfXf4oRDI{uvA4I6Z#GBx<%9C6aUW zL&z!=$8Z+k;5uvMzN!XDpSZ8O>X;O{_9KM(W`%QpsQ_eh!sW8VrqIK$Dkgs%o8>2U z`tm68XcZvcK8g1>tY(j_EFnB5W@TyG%D3UV2=W^?n~I!X#OU!9e9WAE>lU+Sf||{s(ce&jdjZ2P$F7y?gh+JAvH(g2$Y78{HDGLeSrCykx-F$)rZgG#7li zm;lk~af@>~{Y62Gw^y<}1lUa3?k3n2I0)t7iKiW(dxzb!@UBU`EU;i|`Ha_3Z2cUN z${qq5F8E;Bja2%=i>K)bD&#M((Gs*kZ!vex=LSrH|M{dAUF{jtKU`ll>o*L4bk&oL zTh)n2Teg}Y-7hthIu$!KzGxO8`G^-Bd;^ux>{M`s31pv`GVD)WPrL_blQR4+lwK=L zjBj1{xde7ySlH^`Wk$m5GkQxrn84oSr5xYl)(nrRyOrtdi7P)hzlsQlsPrVhUPW>z zt#;(H&2ul`y4bbp)(o3^kW8^%?3UIWJvPn1xU21}awK^{M^H=1ux-`tq`)a3wqhYd z*`bwntL^!&{+G+9Ny3PYAw6Qnf>?3S%2IlH(wYePh|;qwlX6Cl3w9NVsv1B6Q-C%K z+8uUZDgfxqBr^le_6J7oouUbdXCNuu>}BX(n%Kh$I<6X1n=-P|=D_fROu9YjlU)5I z?!MbK-Gya{h!l*K2(1lC%v$>fnnVaDBYFqpT@;c>^s383qcO-{ONZ2vfg#_7FTEdy z7I1bp#PHCsP6x{U9!HwOfhxL>?>O4~p&rtHHM EOJ9yDcp`Rh7AqW+}F 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 JmaE#l_#cV&B~JhV diff --git a/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore_large.dmi b/voidcrew/modules/hyperviolence_gorilla/icons/gorilla_gore_large.dmi index bc6ec75e41e77994570b6fdce9cc49f1f89a6328..9da1856f48428e65e4be4938c756c53d037f62bf 100644 GIT binary patch delta 4099 zcmZ9O2{hDQ_{V>PNC}~oUBkLv<~_rNw=vIxoORXLKoTX_iw*y$M_8TD7!`R^tQ!^Wloo#jMU6 zMlT6NzZ_5f@&H6ro+@2bwMd}Ip0b9r98mGevZBv1cFle8E@Ut~jUeNZOrzSn= zR3a@WC#USAPkjNAnlrz?u}RZdo;VaxcF_^&d$z}_d}E^5&SuwH5Unjw>vF&$co+FFeW0sDJ+%wb>|D$hA-`~ zUY9Ce1|NHacWvqv4v=+1N;C0Y^Yil-${agh-J%7)UlVB*sbr4KkL5!fdl#c|C#5|63v<-wECjo&m8<#DT%Qv6k8aPg3ts^O1xFQ=2cXlxMBGi z37jqVmi&NCl5JT9%9yypgQA+XevAYy+_m!@wZ^}dMw`T|nx%tNy2HjP`Imje5zl-F z1)(L1w9VyT`zb;I!?!29NqPE~g(Q%nCxuMQ(H_mXUaqpC3d@K3@BYHPet+3rmHyf7 zFVNS#<3~d|J91xEN8_B3GRlND(XgKmiLIvEvllsxHtrZDRKgyQ5B> zc^@3_Fg6JV5((XR~;Gm*ii5YLo z^U1zy_I5!$ZSV@F|FjQ?$4rwIu+AMBIXlPoXG7iPS@hQcC&-CP_tfu|{(=jM!7%t; zxyUiBv#`ubr1SzN_|mgU4nERp=>O&a?`om4-|LldIlwLw3iN{sMTIT&4(D6f=id1BOL@B33u;a`kdWj;I3cieMY9qebErIJ!%b% zM2Z>Y|5cvMZC1?-2CVZRa$E-X{lUwb% z%;`-ic}*p5;CuPhCb~=U9~C?yDor)rKkk736~Mf7IK~p@>B-yO94i7oX5Pcw4du2k zH{LzAkYTsY)tJa_f{rOt0N|GHXVV}z{D4Lk$C(@O^dpB!N+7`l1`O@xixW?^I6CzU z?P?1`YkiE!T3`e9i*?D&sF~fwyl@%5@P{K|fIYc@bg|)K5j7(8l}(NtV#C->;>nrS zIEn5PHes6Clu@=2le)oF!q=lWZoN4pKrjG&S)I#O~%| z>%NXGcE!AJ6-sNLucH$^rUK+he%AFxQ@2aETA08TkGa8R{L^;!oflX|xK*V>@@Mv#sEtP`y}%3kkcIH_;aE(tBO?4Qd0fyk%0w2$s+!h3IRjIjDHDn8 zdJ!0KjWNikPi~N4PmA-k;%8IhBW{uDxbUfDnwOLRI4Or-ZoP)3jfS)jZ~J4+aT{OG z$q+GCTnCN6&$qpDL0&`Z+VdbkSJL)1F5JuNeb9V6e(3U z{8s(!Vt94i`^ik*o7P)K5+c;;pzz@@FMsb<9W9}-emq3jQPSQJ@3E1PMJ9@L2irou zvihJi@%u#ZnZna^Wa4V|vB>dDH9oSps-?<@KB$yx2!eI-fk zxvLy9uC`o(!uHP16T8TG^#0>KyQ8`%>ujqaHh`|0!tZdypDlNIJRiVVV$7hITT(XU zqU#Ddb21r|0`iV0C+}|;tbBWDLrBhMBKnqmo{I&&MS1kRzeW}yT8vt?kE?)KGyHe5 zf(@wqt~b{+Ng=OlkGcg!+zdT(_vRm}1G~}KD#Db8ZOI`s5KWQVA5XP>x@5ys1!;Gr zliu<^7jSjqO4oWmI7bhXSpK|uWZhkyUA7gL!?K=rETRT2uS|+Y9h!$dlNq&7cx=4C zd^lc4th^#_7e9xjHT3miLtR}eyruSn$o94S_wV1I>U}O?6k$pr5O9tXDQiO8rAqZ> zWo55T&>9pZZFQx?O}Zmw>cSWFHVU1rKA(>e*fZFP$hGc^mQa>Y&yOF3wO_0~7j=e7 zQt_smUSS;e)-k$Kww4WP!j!Z4(J@T(fo=AOl8Og|zf^Vc-f5kekhj|AJI6qQRpcq%Qq_cd7;UH58UlAn zr$syDKb$?+RWou7kFDU_i=A~GF09)f6=P_;U|Up3$%t*Q<~3L+S} zW0CV4;(%nlsP9%*@M}349iC^D5m>*-v3J-pyUy2QT&UXi32iYqOqu0lUSu5)o|4|S zVX#JZY4S?hj{=q&zTRW${NMmwmuWP+{PB40qxuuIQOcK7o#p-3j^qrgrTKID>AZ&s zU)~GmO8|4^0a2TuuhbNpQKzdlyY`SF<-9S4gCFAc^8_FD&#*v!?Tx#>T2uS4OO^d< zrbk<*U_DnyhUT9u>#vV_I>5G}OBm4Y8Y0f9b|>&eExoVtp~euo&#-5bWQd9@)Ro(w zUNhN!0R)b=aMkcS6vooZm3Cb*nQs~mh6Y}KFgKJ@;tO(6EO{pfgK)Ll7$EyoXUDN$ z3>9YFPw3yvX^3xxtdAaP<~MJi3wPUU4P6y*$wv6`a5lMcIim%*cU6cR9*f@jzhDcv zY5o=sj_LXguf@nKk_j&D&Q*S)Jj%SPQT4GI8$aae`k0NIsEb3!f0s@E{SkV$lRw## zJSKs{q{2Rgvr~*K8KQBJdSrvb^86#(`{6JCD4Fd%Xt8GR-G-vGA?0b39igkqKlYbe z=I94<1<#2ha<1wety@fKrw!qNTtsVaf{yX88Ma2QAZO$*q{eFh2PXLEi}BFE2=Vf2 z8?KN-5whJ>THPmiy~G5`bNip61z zea35iExaenRFkUGYLiK}O^qlHut|s;avxj24^0BUHOayWfB(if?%`eW^lC-E8fi6ulZ8$GtE z|7HCOgVg-Z+PZ7O_Bg~nQc7?CO%-v`Uh}gHSnVnLX^3m1r0HgI=xX=}Ho-Fb)Z${x zXoUq(_B>dn3i>N za?%vw=a*VwQ;EensspCcd#&$-q#KAiHLI-Mupp;*b4WEkP*zwx)>rlzyWX~FLT6zbs=0s zK;sSE9P~wham!{KT#hHkeROWjf)xm0&!JOrt<~A4qUI$vTr!*^=nMEcqVO9|;7Lj} z5N;taqviR(99hD6!^Op&WEj&Sd+462Cqf_+k$$_O#KUQ1OB`s-pl>B#Siadz`DxpETd)XyL0f424#;DEX(h>`(d^Q;x%*?oww53Fv!H|ZmC z$G{q(4f@c^doy~T2ixCX$V$rdA1W0;UkO){XXPTWE1s`9Yl@5`>bKnh$=bts)wdM= z%bQWNYD;xb+?vG}d$~Bw(;S-e2evCm297t!b}pP z#!iu44OvFE!B~FN_xJk#@x6aMf86Ie=icYsbI*C7Gc8U$_p$-x^E1@bz8yTalD+g? zax{QsrbLJw6exeJ@=_r}kUx5?nQa1nUPlAnqx~Je&3$?AJx`ROf|{{V$Vd^#+ja}7 z;27cY$bNFP)pVc^${>}yRPCByV=3WL1AG`sgdZCpPk8jkuUDf=9%}E5cq6c}+w8s( zwm(Urs!%59IG8|x2S`jzfd?U0(~o?)HLYmz+@a~Pcc~BA(QgZ~9nRlC;MHWS;-B6* zstNa&*f*iWzNfG{L~Pq&;7>=BzqDhVhr2)8g(Ok1JfSiYPIAy<<_V-6H)h2!+r`6z z&4#I3uIeHCYQ-_r&4nDwu1H>%Y4O`jCf0^Y=@yV3b+FeYNiraUs@J}-(yjxSXQBK& z*Hdc*gY`Y-O+#Y_BRAB0iS-VOakV_5ITVCpQcX-S^`OZ^s6$XmV$u#%#*8ASQSipa zgC{H1a{)r}F)QsG$&!gz+Tbc08X|JqGzk|yy;_C;7+W1R;ReisTDaNKor_<}>j%%w z1bKq<4bMN%#(!F!oy`xV-@A9O=CV)Ozwad^>VCWkVc7wW^Fpde;(u>o6Dhk>@7ad2 zl&p=(sh~6`JxRk8H6wPbKrS%9#tUdbuV&17&Fz>dK33lA9JjE1u7;KZIC6zXB0`3} zQgoa=5XQxp_BFjU{rL|LI4#KX4;kf5@S9GTnZKZt4O@|Daf~0$dx@X-){fs%ds)M1dFfpjM-lzziSv3` zW*M<~xe|`KRN@i7{*~MqOTcL_s=+X>pIVO(x^t(^l=5C@;z-_}>Ct>3`G9t92|W=+ zzxZd4X+gQ}$Nl*%`V*|X8d!R!dg#S>)C4Ji=g$=ySM?XR?MXuaq$k{i+wAo@nM>x2WyUhIUZv_>x_g&N5DJ~pV{xDBcD+m4 zddZNj19F^=?t`kn7Dxnc0Sn0+y=oK(HYe76yxW=wX1Zm_^^jW3k1p{n@4BF(A-*OTX zhDk4wZ`zHf!Y}Ee|NP!_9)C2SK%3Z3hfIgXPNNF)yNu}2+0lT!YZM2oExUYW)I?ym zUs)-%s??P)9{@mmODNnP1Mft>auZLzh2#5r<>IzK*wHs^OAG#YR>LAf2mtu;XpHJg z`I#VJ1f3f&reSlY8eGf<{l#f!7wz=UPqa*0>lglZUF9LIUO!4Zk(;9tVx=CkUH&ee zqiOxBpKY@5#LF-ZYjL7l5%`^2Bje!=A=R)QaaYuX-j{I2%Z=0e^GAB~++_Te4d$Mw zB5`0z`HxELp+a-R2sz7-$eXB{GVU-w_|cC3)NeIfhYvuP6&*De>R&DNUG>(}TUS4N zJI!2UwGp=K@kGu=g2#z{Ct3xJ}U;HV({XXoToHkD9`RQ`^Uzh!F!u>8W70wmP4J-@9x(s z2-#!2kQ~(eSA-TdOK!+DjEpJtbPzN`nyWysa>!#C;9~~kaanhEb8AU2?efB}b+BuT zU*qwXSw5=*jl0m}>O|m@5$n1jaH#%hb0I#1n&pSh$?TKS(nHjZAj<6?w3PfXaBoCp z7(0!ge)@M{rPE352@Ej!=29cOxlk3QW-^Fu4v+PFkm{E4?AzgPsMEr)(Z~d}x+oaZ zB9VL!c4ac;t>t6Lbab8Sz1!JnF2&Tw7RA)CZdh}gjwrkJSUE17Y(-BKAjfsIRG zLmjn^Js$|1_Pm_y_u!O~Yzr-40J%09eZ^S1yxn5-)A{!g2jm;CQ7X6WyaBNfWh}>_ z#r&3E%j+#|E$fHxj&}%Fy}Co#xaHoLPQkY$oq%c?PBBrhiuyrD63|uwb^q&7j>XJ) zd%g<}43uV=VbnEQX{k~_%?+w$K!>EA+0i?W>k2l@n%)X2SJ2IAh@sgm`{WuM} z^oczEPAp1On_laytVZ(rhch@JQe)`48G&-t5CVLXY^T&lyh!> zZ|t{D*Suf*%lSgFSK0psbM<3yYE z#b5dx#7J7*??E%WA}J=|{|^6D0VOAuN+?@T;n)30d0h1k9&H0xa$;z z7}4ZT@Y2N;Vv9Be5EdV|L6%VE|H}ikY@BUbKG^-o0TfW>6@onMWf@^J?o*X)Y}N83 zP5oP;g=X>Ee86TDnAz{XN&GNrh))nwYwOmk7ING7%30D>UpY&_z#Cugrmt!Y$YV%VqXgCv`_3BQ&uSHC)m^)&f1xh2opO$ z5YXtwdpm9iXUH4oV|>~ch^}T^JEZpc*=>nr@?qZ%FoN4Y{11gy_}Svo{S^cMX=Fd0O~tM|smq_27J6IQi- z$rS^a9HU0b_)~uSYHz?XH@^?fM{h)v697(hWb~jNU6~D|e3T@K{la&QiltIPHu~q! zpT`ceva=^zuXOC3OZLM%oZ(IUasSFd=dg^F5LqcqGpy=)r&GXBBqb8&qvtN(Q~T8h zV^%b{_^f`r-7cioLGc^ILkAZ(_4Jx!f?dC^|M}BBbr0-Fcl&O-8)Sf&)!(d_?wJ?N zFqUprC#s!XwwS5N-_YE-q(_Vgqtvr>Rb)begKCyADTz0Bm?_USNcw=znuJSf`Qy5} zI{UI*&-vaB2csz8C2O`rCXMxC55j=e#~Rz)CK0E4tG2Q}U=ZP@b0umCCY3PZL`$Os zygLG{vr&jGBK`|P-{+=Am#H*djKQd_VgN-{$NSgTLyy;r+qkbDuR+B&4UQR7L+6w~ z*=Ifi6|^ytR-WZo_Sxmu0*GmtY3rK0SB$n)Xi=w5A#uc;Sm1BVL-IkT;m!wNr3`5E zm~6G3Vp>tw6wb2(cb1U{(u*;5O2(Xe?G)^TBuQQkeOcn~9`}zGizqkKyS9P1e;-Qw zEuOPfy;9Lboy0!?>BlQd>2fnRv##Z8X#tkdkL#dnfNTsdx~EROm92S|6GS?9ki^&mkT=e zUb)~Duz`E!Hpg>#*vzp>; z&rZ7~Cp9RQO!Py9i$BrYjLdZVVKd*;RDE3hvmbrhOtALLyHncc&}#)_r(its`s`pa z>?jujtl&Wns~KYPqSxKJAMO`36y_w7N?8lLdB3OKo@#h@lM!u|DN3-OpdG%JZQ6U@ z!<}o(Ax5-ZObtr4?JH?P{HVO+uj^KGwLkNoGKx_fsHd{U*C5*C$|`L$dEApEHP1(V zVIDkU&_B|1tFpiImS|?pNRFi8`rwZGYckE837o}5cO@LJ`jB<@fP;H?$)u!|;mAX( zLD`hu(Im-TwMgtZY69~iXVf~*;o9}FVc|uur;RRd=n9;TQ>1r?QU0Hqh2#kE zz62sDbv9Ift!54G884zhxYwI=DxgzZ;-Y8*F0I(NYGrKbIj72|$r3$ML^Ese=p$=i z%F-lj^x`%7UR4^L0k`kif9<-HL4{W@6JlRGq(a##VN(kh#W=vkG^Z%GvUUDOCk4(K zF!AoCTSm$A)9}nSOth6c4e2*sl?EP-Y?(G4aEj6tzaEr@LQ0wU3yC{foA^li&6<*#`rC7m3*B-NTXwe?_r>`m@;>hk|saLG02;e&fSo_Hx7a-Jl;!RJU6@h#LfX%^+m{OY!7 zJJ^%gzC9YnI`=vz;EfvP&{l79;ImeA7F6%EmS)Y!Aue;0%W)B0SwmZkPM!(P5V;(0 zLO56Pu7YXNga7Btw4~SH?!L7M25$vuc&2=^M|EW`8bfWd3PtJ-YJ=+83;YvH4CXeA z4-<2DCI3NS^8%eR{gGQ#FB2)oZMB_SZR(v+r7@K(vYH?7| zm2j<4vH{JZ59DDAoH$l6qiW~1{$g31y{S|cbj*(2EQD*R-}83=wQrGmpSNG*uTImw z49RD<);A|QB_y0b7h2J7MgYz!q`$$-sr>t1?MkBG zmRa@p8;UZT0qVUNOi6T;!-&@PgQ*^tysP#5ul?^NT7Z_KgQjHs#xt$N>QSk~CY856 zf^(mU7LMAK{6KS^0b{U^>FP?yVAO!!Fl^7s@!9e0i9K|th~wK^V+JFnBSsvWq>twx zKd7P|2F`PvhQHLiHEL3?#Ocb`Q&G>~pe(i!aGKlDomzROwS1e$M6Dd^Lq6zMr6sfI zPruDs1ZP{0Tua{N42U2_H6GpReiEw|22}7#eA_La$+x~eRWmg3(3a^<7s~o7~(Gw5sRY+Yuw_eJA$Aip%D| zPwd-PEAEY`dFPIhMlnx*8H{1A_y_%w&syLjOX!3vs-vcKz(!Q4K$kZ{(JiKBy?92j|0uA7Oo((x6SE zan~DfrlFjioSw%t@BYq2IfQOy9ERQEYn1OnfQJrxdJG_JJFy_9z!j*c&X|bJZ#oXv zto@Vdd?TR$P~-{1iU;s%Zw~(J(!YH`phD3M@MNH!InW~6Q#*?10GPHQ0EmVtmokye z&ocoEfWrSEY*S=+(f%OWV2US`e;2Dy41OnX`#B8A zW&1U(JAM^NiEq+XnLA#O0v5uriq5bu#Bify=q$iG!01^ppo;;#fH9Nu5rii!1>gs| zct)I>@F0FSbc)`B$M33_PXPd{aFq9h2Z*Vl^ICYbA_(9+9q1Wd3+(lE$H@W08Xm9X zpH`az3+B2=1pxR*(j0)+(;bU@=^yG zhpnd7Y(K2b7)I+_fvQNrNgI@2Z+jV-L*7VR6@Ng}L9=Y^lGtWV1OS}wSpTk&&aTDx z&sKo4EKKXgDKv@E4p_*5k=`8#Yye;fs6NK{pZ^_!-A7Dx$#NH$PL*^2<_z^s^(uAH G5&sV&z!y3I diff --git a/voidcrew/modules/unit_tests/voidcrew_gorilla_gore.dm b/voidcrew/modules/unit_tests/voidcrew_gorilla_gore.dm index 02e5acc33ae..95c526c6d5e 100644 --- a/voidcrew/modules/unit_tests/voidcrew_gorilla_gore.dm +++ b/voidcrew/modules/unit_tests/voidcrew_gorilla_gore.dm @@ -587,6 +587,7 @@ var/datum/wound/spine = chest.get_wound_type(/datum/wound/blunt/bone/critical/gorilla_spine) TEST_ASSERT_NOTNULL(spine, "The back didn't break") TEST_ASSERT(HAS_TRAIT_FROM(victim, TRAIT_FLOORED, REF(spine)), "A broken back can still stand") + TEST_ASSERT_EQUAL(victim.loc, spot(1, 1), "The body didn't come down off the knee where it broke") TEST_ASSERT_NOTNULL(victim.GetComponent(/datum/component/gorilla_fold), "The broken back isn't drawn bent") TEST_ASSERT_NOTNULL(victim.get_filter("chrome_rig_hide"), "The bent body isn't the one shown") victim.forceMove(spot(3, 3)) From fd0efe5aabad1cd5e3f45a006eb85e445e724135 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:42:58 -0800 Subject: [PATCH 212/472] Fix the Pyromaniac smoke and Gorilla Juggernaut tests, and let the Pyromaniac throw a smoke grenade it already holds --- voidcrew/modules/bounties/bounty_boss_kits.dm | 4 ++++ voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm | 11 ++++++++++- .../unit_tests/voidcrew_bounty_chrome_gorilla.dm | 11 ++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/voidcrew/modules/bounties/bounty_boss_kits.dm b/voidcrew/modules/bounties/bounty_boss_kits.dm index 05464eb8550..f99172a9b50 100644 --- a/voidcrew/modules/bounties/bounty_boss_kits.dm +++ b/voidcrew/modules/bounties/bounty_boss_kits.dm @@ -416,6 +416,10 @@ /// A smoke grenade it carries with the pin still in, in hand or where it can draw it from /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac/proc/boss_smoke_grenade() + // One already in hand first (kept from a wind-up it was stunned out of): a pocket one would need a free hand + for(var/obj/item/grenade/smokebomb/grenade in held_items) + if(!grenade.active) + return grenade for(var/obj/item/grenade/smokebomb/grenade in get_all_contents_type(/obj/item/grenade/smokebomb)) if(!grenade.active && (is_holding(grenade) || human_npc_can_draw(src, grenade))) return grenade diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm index 0c922de7823..170904b501f 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_bosses.dm @@ -1299,6 +1299,10 @@ pyro.set_handcuffed(null) pyro.update_handcuffed() qdel(cuffs) + // The cuffs knocked the grenade out of its hand (tg's): back in hand, so both stay its own + if(!QDELETED(kept) && !pyro.is_holding(kept)) + pyro.put_in_hands(kept) + TEST_ASSERT_EQUAL(carried(pyro, /obj/item/grenade/smokebomb), 2, "Test fixture: the Pyromaniac's dropped smoke grenade didn't go back in hand") // A lethal hit from past its reach counts too controller.CancelActions() @@ -1327,10 +1331,15 @@ pyro.setStaminaLoss(0) TEST_ASSERT(pyro.boss_choose_ability(shooter) != smoke, "The Pyromaniac threw smoke again inside its cooldown") - // The cloud is tg's: opaque smoke where the grenade goes off, and the grenade spent + // The cloud is tg's: opaque smoke where the grenade goes off, and the grenade spent. tg starts the + // cloud inside the grenade and spreads it a tile per smoke tick, so give it time to fill in (and the + // Pyromaniac time to get back up from the stun above, tg's one-second get_up()) var/turf/where = get_turf(first) first.detonate() TEST_ASSERT(QDELETED(first), "The smoke grenade wasn't used up") + controller.set_ai_status(AI_STATUS_OFF) + sleep(1.5 SECONDS) + controller.set_ai_status(AI_STATUS_ON) var/obj/effect/particle_effect/fluid/smoke/cloud = locate() in where TEST_ASSERT_NOTNULL(cloud, "No smoke came out of the grenade") TEST_ASSERT(cloud.opacity, "The smoke can be seen through") diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_chrome_gorilla.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_chrome_gorilla.dm index 6a6e02886dc..94046c55b31 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_chrome_gorilla.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_chrome_gorilla.dm @@ -356,7 +356,7 @@ TEST_ASSERT(!door.density, "The set bonus didn't force the door open") TEST_ASSERT_EQUAL(get_turf(criminal), spot(1, 2), "The gorilla walked through instead of forcing the door") -/// A Juggernaut with the arms gets the grab in its own rotation, held busy while it holds on, and free again after +/// A Juggernaut with the arms gets the grab in its own rotation: not while its shield fills a hand, then held busy while it holds on, and free again after /datum/unit_test/voidcrew_bounty_chrome/gorilla_juggernaut /datum/unit_test/voidcrew_bounty_chrome/gorilla_juggernaut/Run() @@ -372,6 +372,15 @@ TEST_ASSERT_NOTNULL(ability, "The Juggernaut's grab isn't in its ability rotation") TEST_ASSERT(ability.windup >= 0.8 SECONDS, "The grab winds up for [ability.windup / 10] s") // BOUNTY_CHROME_TELL_MIN arm_reload(arms[1], 3.9 SECONDS) + // Its tower shield fills its off hand (C8): one fist free, and the grab takes two + var/obj/item/shield/riot/phalanx_buckler/shield = locate() in boss.held_items + TEST_ASSERT_NOTNULL(shield, "The Juggernaut isn't holding its shield") + TEST_ASSERT_EQUAL(length(boss.get_empty_held_indexes()), 1, "With its shield in hand, the Juggernaut doesn't have one fist free") + TEST_ASSERT(!bounty_gorilla_fists_for_grab(boss), "With its shield in hand, the Juggernaut has two fists for the grab") + TEST_ASSERT(!ability.worth_using(hunter), "The Juggernaut wanted the grab with its shield in hand") + // Its shield broken (the gear lock keeps it from being dropped), both fists are free and the grab comes back + qdel(shield) + TEST_ASSERT(bounty_gorilla_fists_for_grab(boss), "Its shield gone, the Juggernaut doesn't have two fists for the grab") TEST_ASSERT(ability.worth_using(hunter), "The grab isn't worth using on a hunter beside it") TEST_ASSERT(ability.Trigger(target = hunter), "The grab ability wouldn't start") TEST_ASSERT_EQUAL(boss.pulling, hunter, "The Juggernaut's hand isn't on the hunter through its wind-up") From 3a68d75c080e583debbb140cfadb4c1cf868c56b Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:48:30 -0800 Subject: [PATCH 213/472] Widowline: thin coloured lash, wire thrown round the neck, radial garrote blood, open throat, shoes go with a cut leg, far-side layering --- voidcrew/modules/cyberware/cyberware_anim2.dm | 10 +- .../cyberware/icons/cyberware_anim2.dmi | Bin 35168 -> 38196 bytes .../modules/cyberware/ware_military_arms.dm | 2 +- .../hyperviolence/wire/icons/wire_gore.dmi | Bin 7682 -> 10764 bytes .../modules/hyperviolence/wire/wire_cut.dm | 6 +- .../hyperviolence/wire/wire_garrote.dm | 283 +++++++++++++++--- .../modules/hyperviolence/wire/wire_held.dm | 59 +++- .../hyperviolence/wire/wire_sever_fx.dm | 39 ++- 8 files changed, 347 insertions(+), 52 deletions(-) diff --git a/voidcrew/modules/cyberware/cyberware_anim2.dm b/voidcrew/modules/cyberware/cyberware_anim2.dm index 83261455e70..16cf548969d 100644 --- a/voidcrew/modules/cyberware/cyberware_anim2.dm +++ b/voidcrew/modules/cyberware/cyberware_anim2.dm @@ -668,12 +668,14 @@ velocity = generator(GEN_CIRCLE, 2, 3, SQUARE_RAND) gravity = list(0, -0.15) -/// A landed Widowline swing: the wire lashes out to whatever it hit, a -/// pale line held for half a second. -/proc/cyberware_monowire_lash_fx(mob/living/user, atom/target, origin_offset_x = 0) +/// A landed Widowline swing: the wire lashes out to whatever it hit, a thin +/// line in the wire's own colour ([state]) held for half a second. It leaves +/// the hand at ([origin_offset_x], [origin_offset_y]) from the middle of the +/// user's tile, and draws at [beam_layer] (under the body for a hand behind it). +/proc/cyberware_monowire_lash_fx(mob/living/user, atom/target, origin_offset_x = 0, origin_offset_y = 0, state = "monowire_lash", beam_layer = ABOVE_ALL_MOB_LAYER) if(QDELETED(user) || QDELETED(target) || get_turf(user) == get_turf(target)) return - user.Beam(target, icon_state = "monowire_lash", icon = CYBERWARE_ANIM2_ICON, time = 0.5 SECONDS, override_origin_pixel_x = user.pixel_x + origin_offset_x, override_origin_pixel_y = user.pixel_y) + user.Beam(target, icon_state = state, icon = CYBERWARE_ANIM2_ICON, time = 0.5 SECONDS, override_origin_pixel_x = user.pixel_x + origin_offset_x, override_origin_pixel_y = user.pixel_y + origin_offset_y, layer = beam_layer) /// The Arc's shock: a small crackle runs along the wire and sparks burst where /// it touched. It's a wire, so there is no bolt of its own. diff --git a/voidcrew/modules/cyberware/icons/cyberware_anim2.dmi b/voidcrew/modules/cyberware/icons/cyberware_anim2.dmi index e92a12805ade743c6a09205233f18bddd6c80fb0..88e2a87ffa9556f87b7b61be1bb7a54d9fcd5f6f 100644 GIT binary patch literal 38196 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
  2. O;TOYt?vD+anH24#1({+hckoIv-E#`%6ph@<190D!2yO=hU^o~OFUNPum%dnE=9_@%;`7gD5F}hvrvY0zY zdEs&6zx`g7cDbMf+xuc8`)RTppz#K##&z!p9qc{I*gx^o5rX%`Ey7XThx>yv?`3YA ze2l=UhiG@_yjC*=`XWcn7WbB}CaeCVjd&K$%ntxl=d8zH2pNp0JY2W@KEvop^se1H?>%{S#th1(ztFk^L zJ+Tv|ZPBkVT()E|YlH;J|D-mqjcX z%u>3NJkf-Dk-`4whDUc!^MS~b8z!C)9mc~$8}F)sFtcA^kWibt9hE8J5f%sY#V0~o zw_yP~kWtae1%Ep?1)Wu=vY@E*d<)KyQp{J>Q(z!sDf8-^9h`{erG$_+)XTp= zy!MwQLPf}v{C{FuvVTu0_a;Ieovi59y69008&znOh79g!A}w;+p*phr z*nB!7Ei>6wNO=?{*(X)AJI9>D}QcPzho{ZoEz3cyn0oikNDY}kP_ltl?hi`;^iBNkf}9(xYM}fe}hQr!PtpN63xnp;Hz@Uktwu-|{Sln;`%?q+!5a7j3wP zbui=b4z2cK?6#(ril?|U+zCA3)86#^0_u6#%rWT?&n9BkLhFcPvCiI=)6;2hhb*{@ ztwDk!_Yy0r2+;0fl_uYN54+bvB|>TeYC(-d78~`bMN<9oak$!*8hx051G)$?Q)F$% zgj=tD*Q2ynaS^wNK`AHK56ogG)Npq>w-=@@@ZziW>nG|KST3kzW4y=MDSsMP*24*q zHVi8V`KK&5p*(8w7~OK^@t?p7vxi` zRR(mlnC|yh3KSkUJZ-$&<&kd2U(AVQ!q^Me;rr6Tt@E>$v<+hMeAH&Mp(sH3~zdS-Q zXP!HDm=ZP(^6(zvJ~`ab^W7 zwgCkj877lz6kWw6aK(IAUz8VrTA$~P=deG?c9h<3*d;COBYnx=C2+@ZmYrv~z}yqz zi;&m@6_oG3=5>VyOak4d1&d{C#GS|;=ySjf&aAfOUBwB6zgt9y?Fu)!*Un*wYBX1H zS+5PwHN;JAOHyo#_|09NO0R@|)RT@3=t;?S4l`3~{WT-~-M{jt7+7!fs07W_crHqF z@aGVE`(~@ag<|e$j?3~%FZIZF zFJED7E}3A!eH4`~iEoJtWM@Ww>VxIM=hGk-Qc97Hi_lS03VJ|PtBF<`G&r-_8x`Sq ztY>$WOiJ*Q{6t+y>S&VvRR`eM7?5Ye(f z#NbFFW?py;uSG*GsFZH#SGKkG-r!Q}5Gr+!Jpa$r4z;@37pyqktfk@G4AbW5P<8ez)L0{YI2HyET&S9BXv9;7V=`tXxK^Y6KRbNto*nJJJ^Hh~W%6&rx~&}c%h?`)N?3&_Z#UcmG%;cs zl!}qT>do_&!rIqDd-j(+esj?80IB*aU!T$H4%xGU3A2(H?Eqrr$CYU}U}U7~(tGw^ zqZe#EKBAMhYj1kKm#nqLxwWO~l4T{Zr)P13msnYRhuHj{()wH22ade5F(#mPOPDI&S4DXZcUL>o~I(xxx3DMuUgVi$o@U`C9v2@{ zMM@}Nz%_SL1I>=rZ-RL_Q${Z{{acWej;8Bd&;9#;n9OObz(b?mlK^m>ki7yGkL!J4 z)_LbB>2RpeJkZ5yI3byMy-gIZPdgBLBL3NJ5{c+&A!DtdfzCFC@}C2vrV``(Di=TPSv0w+15)hvyT6zl_d7*xwkT5(?90IJXaw} z@KU0#i#fyB(zf`ALKNq|uL2bz%=7Fj90BqPURfjo#0c;u*s2l^7i+UGeX$7PcMR!y z?Jh*-NaQa*>7;9;BXJ3e91!q1YVpiqX|GX>yAv_^TI;pAImbk|xEHKs$-Bi))Qo|j zt4d)3NEF7u=T*{^zV%YI@QB?4)xy+A?zh|C>o~XH zH1`8Ap0IBP1p?CbBUYqQQeyZB%f9t~nnOjp=n1uUnC;vditC&|$bxB}!!&FN*!g4k zI=IX!S#bj(?X7*W2L@I(5&(?V{k1s@b)0>j64&|Qwxm0shFZ_|7G3UWd2HzYpfPQm zTHP}S<<|bDrc{7#80O^jjdOQ%5st^>48ggxbE6~SanU_Rn&=5<1)fvGA;&SAtdt6V zRS+EfQ0})>c5gore;l|S+AzR)97i7xT-&j$?tm+j&f-U{S|>Z3{$fO8nX!Sf^WHyUOUyayPGbZ+krY;%}m_#ls6~8=`#qyvJIWp9SRa$H<(dC7(Ky3*EI z^W{geSf&1GXiv4xaxLy~9;_tk=-#l&BtKUxhlZ{#x$XGQ;BT{XW&hyuSh!ud(92Rs zy-el!MDpOBiSb?})Pf@S;*b{>Ib`MPKp9Y#Yymc;;jPpW3m}<`daCH*Pc{Euov6Tg zO;T2P-CSW=Xx%mF59ntf$46`FvULAOdC^q zSA>eJ8lVF|dG_Plu_DchVT4U0S8w6m+@;aSn?DSOH_Y})0+&oYa*zPo`RW*N@7e9u zZqjJPQ?a<0sGOjrU$>-oc^a%plO@f_s@9lQKp~y5DGA}uM*9a2i2(u(&ym;D5Xeg$ ziL)`)ggLng@ey*IR4w7JX*{dKgIL7;%;r)>EgJ6h!Cj+}$v&ep)nqq+ju zG0I(RL7qz*%s6oI1ejSh1|;aXW(W^GA_5dZr59N3F~o;QB$v1FHjUnUr#v{{Lcw#$ zoEm|Aq5s=FiwzJaHE{WOUS-j+bkE1+(Z_K|qw;aAkfO%WTsr?fCr**lqd5~qv5uzH z@Kq{Rd^z0Kt-+K!Wx%fjC;*-+AAdGf?N7hRgyVik%!XbrE_5$6r_PIp z)KHrLEfvDGUX{$ei^4=w@E8*q6quyC%16x)YprOx<69=q)5p`C_964?2bBJk3W=m_ zP7A~QS`QRcvU@f=HMF48`0k8f`8^dwN(n?y$SEA1*j3?jfQ%+MIPfQOyOK5tKA#A= z<>H@;0H*nuN3%<_!D~~W)SRU}?lp$4H$Y_dlBLozNs`dpJG9hIg)OPm*^H}&J;uEv z($f?e-`x0x9%gvPfUyH#<^%D8)NNksG6p6{kV}iN+SM#S>Ux-*;cdya{AC4sfiQ(U zfB|Y#u+;ig+{_J+YExSX*G~VE?ycUK{s*ha*1pfv7z>QWPs};qpis@ee&h9#8xEYy$e1t`p=Q%uo zdGXO;)kwnU@>Vs#S9p-;u!H-793xQMz|q#5a*otqr;O|drg?!MZ;<_l2ur2sne^mN zc^^ZTe@!Hf{X{NnL3gNyzz;>Osa66J>BAbXu8IWE8YNQ3PV~`pLbW=NoPZyiDKwPs zU!Or~N{Nk_ZADkKyxOldbBT41RGo5$&&oFydt|lmEzJ&;CkI0(9hXI}S0{IUeZy^Z zH}+Looz#J}J`)SIEqW+Vw#TIU@TVE|rmdUj(=#!NUj=t=GJ&QpL~s#2Pu{v{Z6zJ1 zY*S4;$0smu0PIj=Dc7(#lg<4rbKCS2Mr*IHR?~HA-E;@*+I!xQzZa(rCB17qBf7OZ zZHp0H)@nyXNtfFc0N8zjGg$XWz$v!p7 zT=CQX6oaKpXMU*Gaz&d2Jmv$)Q4uhFQ8wSCwvw}esAfy^Tz_0yt`G(x;eY|C_<7{z zW1zO44)$wcz-?1IPp&qts2Y*uxWKO=pyL2=qmpm}^T-S$%U>Xk)AELu#npNb1s*_c zXKi#0mzeLc=JcFuFOgmW!+*r9n%4|(Pyp;q2S{n9gNO!g&$_w%jiM@utQ9x~LQ;@9 z-w32cy|>|Y(?A=jnbBxl_eL>xo%)ls(1-;N^d{A%5WdgOJv9Wz%+tD)6bx;PO@?5Q zr}~Ri`R2-=8UWR~H)iIiEK*MiFCOHyS32A`yca!jgF-i*sL!mwk4`LrfbI){X)ohF z#tL9Qk9um8a!-ynT6f=8xx6Ry*Ja3+Vc6Q<_szfHz9Oi#17NngrFLu&CU<|6vlS4L z)alHImp6NonJ0oBR7ZZPbF>QR}Cb1{;a_eKY3$vYfk>gZwz{N8Yq0AunVv z8hbdcH_3}1SDO88@zG_5YdFDrNrH35xeP4;#hzb4PRSWp|3BW|JE+O+U-%78Y5)sG zI!aL#5T!{~u^|FVb<+`%-c)J`p$Vb_0vi!&f~XKmKxqOAK@_ZjNB}8GASx0{f|LZ( z-o0q4L15)1s4#A*IrD6H)|ZMC#9BM zx%nllj@PXHBxBb16~SHtG7!Y+3_C6S^WA%bHVRWw{;QP?a<=<8&F=hORwd3LJ(qqP zG((qvtt)FzdFnp29y{WbU5&w%_q@$^G$=ItTbYemAZPVzgJ5j>*csXJ2zH1oSV(zp z;uxE29da^pLZvPfzHlP0`asua1J!31^%>B z!*D#E5F_HrJmJ?&DwKK6(3?-lKAKVxMM>^2>%s^zt=$mwhRr2%eHxX<`llyV{gtUK zh;8fhR@Q`8-HkH*0X{)~1dUTqC0Y&j-mK9Rn$yNc;?-*8% z9}{QeFH1oTRTVs0Z(|r7q}gX+HEp&~Q(7+4jpadhCxzn4vIR}n@5t`|KCkC1RGDfo z>!MO-zT7M;GZNTYQGWS5)hMLQ_9~<^j(dGO?y=0sNQc)9Y-((9X0s0T3WflOJ!JYF zPAt0>f!VyWVnLXjIPf&v-XHPTUuiX%Fec0#7oH^ld)Xh^4_B-RLLFVQ>-tMJHzRTB zvLo9uPs?x4z7t4P9BWAXmQrI?bLT00=@SkngABqKO5TA?e3f!AA9GQqsu#KQnH&%S zv$kId$O{HUgJ?mZFWL{;r33$aU&+2~in{{pR;lFYuuHkzzXbqt3>L}SPz-KM{3%w`+LAyuj-6=7Hoci=^&>Y+TH%3fIH*(u z>-<`R{o9PO{adyTh~nI)Ih*4L$()*+*`|@J(Q(E4q;)R)v#z7>uHX~&gG~G;{ZR5W z?`iFlDvxh}eFb9tPHzi_00Nbf6!Q1FW|I4q4WU%d<;0hWmiW?orn`e6bA}v&gVmIU zR6+hlZ=@6<5%zEZasZAf06DBLxr%cK30fEVZ*&IJVUISq+J-@+V&RGBTUG7ggFUgO z@015wH_7(GBal)((V`PB^$owU#X1L==&&_3Y0(*QE`)x24M1K0IqO6KrlU5lqXYJ{ zV;`>*^t_b=s~#|Q@Cdgx+rBQ&5V`qjPE+D<$l9fG&>!W@sZfrAg|Cs9@awT0L&@e; zC(|=K86OUpQ@(AXE}Vc`dX{!)`!T-12c=f*3AbINgWeyML%K`XrnBCmkVjmMiW=B&Q?JbG|jnqj33w(i2nXHDmduCC>`h%HA4I1XE#z zaq2Jc6#@z8HhH|6MXn{+;cCV&@ln6Qqq=;7lr1{~P{BlvXC3~E#=GAgs1S(jk~NMW z85Sn+Q^B3(5($4X3c;J}WSuvCs(AxeF}1>mIzT-CZ{WyQFlp;!*y!N^G&XA%H}8ts zjP~ulA7xGLK-dg+R0+|);tH||v(Yuq4{Uz>P{8|f$Od89IDe3PaK$})2;x5Pjg%Xd z+T>+{iGl!W214s*FPTZUz@eNe22XyACU}gm$IylCZfnlCpovRsD5W-cVA9z0F(=7) zdMRtS)82dj?g9nj>z1l*?A{0%m>3-Y){qV}C3ssOCli2vcsC(dgTVk?#yG9jf%RJ& zfkJVO^R{Sgql9XY0GEIFN1glF zpyXyDiDlq`81Jn?_)ST@E_P!)l&6O_3DN&%*gzLcQ3mMW1{#o$tN|3Jr9HTl!;do_ zk6EL19#`-Dv{Kucqd`=6@;s6-P=h4=KS4QwE-(Y?El1Ln=`t(BCsH5n=|24BHe-q!&M9qX{Rm z9s5?N`~{d*fKb%AOo$5sSswMpnSUl_y;QhHN;mZu1K9&ro^dt8Kw>^ewer7WJXGB5 zp9s~3B)C|_Ai|6L%#j{b7d}J(kpfXY0Rof31UnWj9)td@ zU%TKQw&1x(z~iY9{UWZwK`N7zU4tCW_UeAq>r1Uf|ckc zk%*BN>(n=ltCOY8QwFo_!_$h<9RwTjrIEZ9Fi@UKlMy|*}%s&IQ>%hpDDR;%QwS%|Zdk{t*P9g)o66MF5WT zhEAu*%id0CYj0N;M_-K(_E72sbc@db9apsdm%hZRg1-Pv(azn@-AE!zG4xUo?RJ{b zPnxcS`M+els5^g??P?TsNduGP7c`j}Fwb)kxV@}$wFz$&bJ zKpi=XV<_tMESvO^TpLkrUt*l~MT$9Lpqu(-RNvtsqNzM?L)2C^82uaC0A}jU%g5!8 zzZlOH%+cjD>#xWd5W3wUwT&8TP0VmUXhLzlWuQ>74~l!%+h_h+Tq!57$NZezmT|R!*zmT-lOAT zTX@&4&p*PkRZIB$U;e7X|Ld>+9OM5Duk^q9-hX0C9{XGVA^**f|EKT$r$PPuy?=lF zzZukjJ@236jKl}ebF2ny9Z~X5X|NG;R|1eYkHJ$(Id;g<1{zvme{uh_; zKMm^N=lzfN6y*QihW+1r$k3S|IX)t*!zF?lpW{x|Og^c}SK8TLPH zga3GK_*i*xs`lNZ=%E|?I(MG|AS7e8-dRwZ^g869fgS7E_s!JraSl;2x}>#M=N_0vrqp4Fz~50C~Lj6zGKU;WF> zY;Z5}n5r>~mZQn_1$?S61#X<(`g+Fguf`7*iT%PivP&P8`8`qg!aRPHwET^A#I?rX zL*6fY<u!x-mn;heTYG{tFFWyr_?4! z7Y@>54kV5;j)3k|+#$vndr!|01GLPxvLm4blqHGRtp>c^2AQNIf;WE?lYn=0vp+~6 zen7Ex_699*1kgS|6v=WET!g)6`7nL5ywS$deM z7R4-l96{DPThQ*f3QvXwSLtHBJE8ylMpZd^6$34&@W~f7PkO+gD_>y-M~g)i2*x== zv_(_>;33T=tC2gi2{p|vpJ}pZpg|IZLs_<&13UlOiT(q%)39m7CD*CPKBJ2uQWQBC za{bjL35t-q+2QKQ)%me(V)O^kv~LQ*S0fQ*;xQxR=AO9mhp%V>0*e&$EaZS5Z}(_+ zkIbuoesavcKcp^jH95<@i{=BxJvL}{8#JB|YHlPGTMp4p|B%4;?c~+o$?j(ySz%9V zLg#r9SK=2YmtTU=EY(n^Y{E8+bTysZ`1Ajb#C?80Ow7MZ1U>tjWT2OtG}@X#?C_}S zSZL7{Np4Lc2`0Gi;q|!Mf%lQp=Vi}YGdo%x%Bsvtn^QnL#9!JN8x>uaHb|==*Jc1~ zz=iMUjRshS1@XQ>YVs9f~n@4<+R$N2FUz znP+E20890oZ=Ei0cdN}^*WQThYhRZ++}Qpbs4d(wR7wta;Yn=BTA}I*!qzTA!UHB8 z1;Z-It-HG^wS7xd;pN?dksIzfJ=~eT0g2y6H1SX4j_E5u$cABZvj^$M(Yg-yQzI@< z7H8Fv;gsSPT2|=vcw%0^f88csVQ2n3KVUWT5mrXh8i-HR8oVMWuZ}{-TD0mF?A#CG z&4p=^68%is*a%OP5au`(ccvG<{dWYYc$d`qyNtV}k4L#E zeti+wgB)L+xmpa0&E|CHRfa$BykQsp*#~$!W`izzD=(q>J~Wp@{ACJ#(49O0hgb_D z{zTf{Kqxv4fm%$c8uoDD7y+P|hH;LmS4Uze)}cs=Xq9a^gW{ z@f$Ry*L#-`Ymj1kZYJCQGI{_3j|-|Uq&PX4+?F9k2$Iz}9~SshvLK{lGx`M}WE;Lq zxFKAVP~c8$wQfqr`-__#5Kr*3xMco^a=eAbqvf15tb&)eFc3uVAb%MG7)h$sLn=AFBLn&$2MC%3ovK+Bg`E zJ|=DBE8Q8E;!TCiXr&p#O+?KOiPn|>t|eJGRM6CdpAP&93kt$LN}q`TZeJA4H)X^X z-qBRz)3=>B45UjG_+4u@&7L(M0>oBqjl2d{EB2E+G{M%2dPgY+twg^Nbi9G}S1lg| zs~^>smPZf^sza}YK)nf$xEy^v{@P@C<7l`vu$8h%I&J@HDT)`5jko29l@$(V1a7m&i5Fl>lLqJD%$HwsHkfvOuWNC zl!#-hlw*!YGh5A^SQZvV{)skJz6;rbmu6{i=?XEV9hBH)&v>n|F$R0pXEwVJpOm(i zgnG;8_1lBd4?(PrMbx&$Vp__7{Tk6UoHJ$leRxNWnzkg2{6PylqMib>5GN5Af)>jO zN3tZoh&DA_DVVgkw>3vKjed;Dp7_a75=bsU5D4Z9DFt^4^ADF-s#KM?)J;PTBEtw= zT;T7YcjGE39N#pwsS(w8(}8(0zXx!JX@BhQ%ZZQ+_5U6_YPb~a9}?9^w;EX_QMu$E zxxZ|gp3NkE9C2ClXe3syzu(qFkWlZYt-d3$WJQvsrVzs?t$rCJyA;!#xsyn1gCJ9O z%%YhhL86@NTQL(b6Aos>IaoRd!HzkIY2CcHUjoFxur}9SA}MLe8dxR?^u`>AM??(njRV#4@l)C;*#0r#+=IvLt^QrqM5%hAnowD2BWqin>Z zX->@*U}1~062Y~ojzmLp?*dCDnq z`L+#(AY!QodSn+_zyQPzC;{?lvLi3g3%9}XhqSVws(qa-LnAMUEJviFP*llJ&tZY! zV-o$VH0@6R);WO{T)~o$gFZT_Bj$^lo}Sr+OSrN8FPnrh_ti`4haja1!Gs;zi$!g4 zRD^C=io8DXp()8jn;vrfXGd7C zc61(%GJ4@SJfi1ap(Q9hM*@52a&~>`yFf%_M@F zL%v^~H?zX+$rUclL!65kAS;u!uhEL=(Uf3;WOHB)jyZBY?7TGh4jK|3!!Uv zth;HxPw`XX^?xN4FZAR;F32Wv0q1ZyoSTJAi-cus>A1``@?z)Huz4g8sCt-okpDyr znS{$Hwe@O=EL@y*wyjn$!goFQ)@;J%jr8q=SWHwM_6K}s@?ziJvK6G611lu*F&N04 zGE^#IA?#;z1a2ELArh0gI4XFhQ23Qr&`);bP`L89Gv}Xxh-o5v{y8~^^gu+?+ztLn z*}zQOg>SQ+bAAqa-`&x3dtGlepq={L32d9yZJ^_t+N5=%k}0oi=r zFBFW&F6>8qrnB%KXvdq13rJdT9{^V9IdJ2fUNcH*C&pqa8{mJT%4OOB^vfMj;sYzu z^sIgx;(U)x#%xx$e5imZD%k%(=ks?&ngbtJ{g+gIi#Gr~e88h+k+BM2!|hthKhpHr z>~6=dV;#|^J=*?2f3&1Q=8w#$q5*(=P}KcEILH>Qt)peN9vOCc!--w*+LH#ge%@M6 zU5@UQ?IZ9>-b9adq?h~w@T@Gv+PY8gXlhYSG?}gp5=vgQ_J++)aw7-_^erLlrTglR za8KlGiX^)35eell3%;ZV!GnuGKBRVi$u6-s4Fpaff%63iLxSuzP0UNZ!`6Zddl$oz zhXxgfs;o*dhqT0!4R&nvv@?xhdVt(XcbR@&1IgqD6OKxgq$s*pj637B7cinqd98|u zjVIp|vQf}2r(lfFCd{9+);8jj2x!u5i^vZ~-js8ovzPW$0du)kvld)_((gkHZX?qg zcXA4{b(@l4&OP7~ALDoSy%*O$lPNz^+hwaaZ%)NrjzL3WMivK93ML7tE_wcjBUvo< ztUgLkn(*UL&*xJwke*|vK|BM(=>syJdqwY-l@X?6q`<+|aNT`qaC2Ue#Lt!)S!FRm z?mA(bfD|`W-;rg!%M4GA-YmcXOg?XI{qD}8!EFHNh%*CK4=KoJ&QcE14v4A74-&P( z6=bRPbK;#jhv;~ml#`#6fkRk*z21|Ckf|Ewv7@N+YaFz!8wC&Bvru{mGtwcx@EKZi zV~e3~9wf81Ll#8%Yt%DI70Zo9G8(4lZC>zab)rdMA6|&5c}4gG+@x_~cLJVH@o+}N z<>Ge`>id8_(-6H0Sj&3?dlYq3XATe#-s&u`JEql6^<^6@i6w zgh~a;O?g2U;M=1Hc8JgOVHq-7$j;z+Xv~?&%m9GJ16f^<3EE0uWBZSJMz7dPtLBCk z=lu?E%57}4EAJ~tK}D>@vdOQQ|U*07pdL z+CqCFTse_!m}=Eua-n|o{!k<>gtHgy91g2^XQ?3s|ez2 z3gSb3EDEfajUKgPk-UflrmjBbzMM|xmMLIU*XIbT`c3ehOKTv-2hR?jc~9{@;T3hd zH-|gdUkLgrmHR`a*NRcJQZ7ENFcF((AK= z$7PZl146tEU-pOm+yo(tyeSr_XigC09Y5l3J|djxtj*iyX(5%=RTHXz^REG5o;lbC z3H%K7$E}Qz`>mNXUFRVm`-3ImCS25Si$`R%bT5Ty(yGatP{(eF`YU@`tz*@jjFAx( z;Vk_C{bnXgtTR{u7!5q1W-|!@9O!qE___lDaDy)MD4H>aX6_Z{v7k3kWBoDDt+=)S zyoI**U$LBRPzbA8W@H`CP_rPcfminPrLwJq)x*rIz3YfBt~^xDr!r%EW}Q9V+#RM| z(2bjuYCWEhtebD*&kDq615)KtP>txR@4&~S-utAk*_*>i6Wx74Rv?j8%HJ=%;5kd< zz*?h{MmvyVMJKj&Wyv6swi~L639M?MGgQaj>w^!`eZ$-~I&!el98|p$n0S z*=<^oDDPbj2p*}#vd@E=v&Ss%Y|tK>Z9O+6wq_QmHxei?#`ex=76td)+-Xj{4^8na zp~KJj@Ch}yoPIVkcuZELxD*$Dxg$;JmF#_ygp$d$yz99!g-Z0~G$FPO*jU|syBmB< z9l(7a#=XY+5!2eoirU1P8BljKv++T==+>k9<|3ert4juWPSKKwVC1S{vX`PlKuI8R zEtvp6qJhA8F)^UIYGO0gUZj@vifkp5{CHlrUGU(}&H76N;x?P@NsI!}*QcW}0g52N zpV%LA!EO8K2_&7!H)=-aT)|Q9L>AONPD>gwz?mR7><6Mu3r>L z%ue3p$x6p|xE+g-*nhD`H4tT&KkOJ48RsY&PFd?eap@gm?Bt zRDR8Ajr@c8lIKQF2YwgTB624y9T$I%(dNBZxH7ZhIs0ZBJzHE9TX21em9WRAxJXd^ zp6D;L0n-5rlas9y87fZzjul70ON>cndo#Ap~KZ&5wt5 zcINs|z5kWBym`uQVzxRr-LcCq5}D*GKVd1Beq1OiM1FtdlFZQk0|%0Lu(GNao{F3K zC;7!v3ItQaw&`=JTe{|!(00f`ta4Q{vNRqo1qT+<=0#Hooi`x?S~xQw)p4_{U9>|- z7Tr8w3n^JTGo%BwBNEGUUEW5^Fpat_abIQIaE(HA2PRGXUVF+}4L{ZWO!T%f;m$AJ z7F}j?su@R@geS>sYgi{cI>Xpb`XG;2z)x$)ha}(1Qo0q1XMDy`vRB7ED|VTnyUq5Y zS*3OE_&&ER63bBQbwp_sq3;i(NVpMO)^bO4$lO}MkXhHgb;pgP;f*OR1@>mkb!KMU zP4LH!-=3<3n}*#oUlAm#x7^J=J^uUwO`ogptUI?+;k?_}{i@d|BGk(S%|vUJ9S^Zp zMh>eqw`T2P_w#!wo1Xlmg*)Obk6(hXkvkp|3)wlbyBq^>4QQvr1hRcAV(XUp#6Aw z=(HW(_%yQE{Nl9_inC`e(LYx|lh-ut)XM_w1mXuj_(2X{%zH$OBev7A&D+PsQ(O*w zSw6n7D?n%Eyxi=Jzr&(URQap6i`JbgS-uem`JG;KdK>jQEWshQMSfCD!LU)U*QMNT zK0B$qhZHpKjZdSE?hK9+ogKo(u$`VA`NQiHLkd^T)f{;&WfG$G&>Vy;1za%VX^BH^ z>o?ZtQViW>GLj1}w7*}&vX(dh7?L45yn~E|AKPJy7e#KY9VV92DW!XSp@$*&tL(;L zK3^IOhhHU1;+oSRR6jL6C*)A%8l>OFHG#e=so2|l4HLT7{uvn|QEm6~VsmG?b^b5! zA^X-aJ^8d(xx*7;U&?%+POTikYWp=pLa7G=#d&At6;+!vlZPcY&uF<=S}Z=sPF1c` zI5$@t7974v8alDg^|(V#KCi|JR`0}`5I5)t5ALtC)_?VAitp0&B;95mWKL4r}GG>w`s5I81_jK#lq2l=LJs z?ykdB=l-lu*yN9d7m8z6$oH`+P1*%20ZUPk?B4hrxegmK&wJj49VkEf<^aOz4_*&T zWUA-xG6MykD43;cT1XjVfZHIarF!F~W|?ZRU`dwky_S)#!hF&sCC=y(-g14t3AP-75FJt4MrS6G0n`Dx4}!-_~{c zmdmlLG~ReacClZgVy=dQ3U1<@9lb+q>1FAskG^^R(L-1K!)WLPf);wfwi!PiaL^Wzq z&w3}R99F~AZqN5ouJr8zdqFpS`jb=QljH}GmN$(htKZD5yEj6K-`k$%R<1{Jm+{}v zZMQO!ygoF1P_i4{J=(zAy;BooknNz{C1@zj1RZ(b|EyZ^pk?POB**2f9;y~IwoH)p zkDTP5w2Y(AH_|^I%)DI}0h)f}F2p=nqUtg89d3%ZIVzbPk86PKS^7?kn>z%#{{Uvy zSb}wr=o#!BSd^{2jdL~@i0|0WUWbVhJ#J#Z9-}+iom%_khBp6o2P>@8F!9_nmyTk5 zMC1(j3KO5{Xn#^$ZDu`5dkMXwFM`<(4#A@XTm&AiI7?OX`h_Jw(puPA*ZCX2hFq>6A&x*&?dNH1zPNRzNf6=&O407pV z{zj$#@#s9s5%n2{pQZP=50$JZPmeIX&(FqdqgdBFsL?Gji2db@KD9*HspH{*esap2 z8q-g6%G0Q$s5mxnS9B=5)6M$B?t_e_%&FGASA_4C00MvDCDC+lPupp)<>-oU$<)W# zt3x9@33mpfbCDD+qR;ZC`k#dPgdx6=3~%>CrTaDxjh!QYStgj;#0|P#eoJF&x^4u~ z4^-PIhdLYC@Zg$$gsDdnA75+J=b@0(p-`3E= zS4J~67CJ0pf;$!6%D76il00%xzmY1e_MjiFj#JhqmNczBIo&4l?PNEtg{REx>1`^^ z<6QNS!Y$I_0Bs6wKL?w)QmX4x#xs8Nm2lEPjsg>LSTta4@~lR1A$Ni={XoE33u5-` z(E;wT*sbODpAvo2cyG|35`TA>*3$cUf%n_cB~G063ceetn!ukC0wx|*^s?k^N#(r+ z=#h{LS%c6P_&wcPeICzk`rPT3$8q2Fg{MwAVuu?HUA8**bFTVK5aW8bFO)N>0h*-e-&711 zBx4OgxI>ED02EAT`$RBJ8Ht46bT`^cp6)?~Z5VM>nM&l-2V^B?Nz>Ge(+!%dC;64B zhSgu*@+|wso10mkMW)&EwB{%bvrc6UMi14AR^||7E?1nCW=0I9dW)tg)mNUg=PV<~ma5$aI`0)~#dfKh4r}swWmeTyjzBWkA@|l-S?&kbzTL!t6!WvI zpV_ew_~$~K5DMC{9y}&z$B#UCs&CN6y}tQayZWglr0Wbaru`)HyvfMbq>d`qi`TcD zxFz_yKcPnNX)H?J(@17t@1&_f`yXTVhc5Sr8BPAeo)XE(@jRrpc@Rj0?gWp?kMH)G zb$jcgvvL$<%seX=UtE7u+5Ty+E_piWtk`wiyyrk&8-Wqt=TFMcS&!v0v_X=`O1`XD z1`D|93pn|8hpA78MRAWEN8WdpM^NUmq{>I0xmBMr)?K#a45qc6yXpP= z#TCjd_`*9zYi4+;qF2|W*hi~FiWkdO9lxe0X}_8G)HiL={pw9WkTDrZbW*0C{KDg_ zn8{&>SuR$ptWiuEr|gtg)TnM5#L*4gq2M2!-ciLprpz_{{k%~JFS@(&nCzI9dho}J zE;Il2t((}I#E!G_;-$AO6y$)JB{*thpe|P{((4E4z3c=OuHuHbDK}~X2(2iK=q}WQQIKuMbbE}U7 zDOT9E%KNq2H>kwCq)e6ZRIIN8Z^xbCYi#+U9?z(q7jU2U);-Sg!9aOtLfY`sZo48vS+3;Ir$SkCq845RUL2@94q=;lga<1--ZmId;c(M+ zwsiyZN|u_AwX-Yw@XSa6A&V+`T-k5tdj8K^fzV=#dULvoFjKGk@WmbO?t>eL1G?2@ z+4sNFoO7?ra{G0KreZ1gPI&c6JAOu2*6k35gze((O6$r!4NebsbCIQ3J_c$+^6i0K z8g8^BU$nc$AD+64i2ib%==v1eWKPFHQ@{__KdG~5my7*dwVsMFgue84%#uaeZ@>`9@!HY zqz!<4Tz)CUGnNwFU$A19i_Y3{g{o_Ven3Vg8@dFGFJC?05xj<~Txv-Tm166Apdc7Y zPjgY`TdRGazDik&U0UFZ$`9TM2HHkeIo!kQXGx_EIQb~Ca}L`LxT5pghfF5A+n=dG zgZ#2P<%kM)t?1b#19b2_s-&LYYj za?~xS)+2{1hIEKw`$Q97_?bVjoI+I9o|gzGtvOqYWi4`Jrd9vEx_HqJ`%5iAHudm? zf}z+s@{i6kn$lSMZ^ga^c`MP9zZD*yr z-qUNK=e6vK zD4R^5^8r=u!KjswmzNscoLNWMU*X zmYJf4+3aH4qmnn*%6};4mA*B!aXASSZ5wLZhiA6qM(ae)>38dYs4K3BUc=ydI&A_O zH`M3m2uCW{2Xh_1=@V^}ZdRtBUB7W@V_r@T`eVi7k299XM?~%PsIX;IOT`;f)bRNG z?CNQh3bHwIGQK~75;8QiY+bqD5yiS-zHqDTrA1}PnWy8m5xd#vhCUWg#p@O;H>cpG z3A9pq9*PB=)-;Q;EOu3uhnL6aylDFQEk)y>e z8XdK6zo(he&4>M#+8xF{aW-l4-2;khT$>)=y)v>&DnOBLv71zjQ7FnpX()Vtgl@gvQT{O=i+K*`A%&}-yA~V!-rk(GMGb`RNk)L ze!l6bHZMkw^QqOcPUT6cD_8uUgdYmpjRce{!P4%f(;x}kcljgpXn?0zEu@9}(o`!K zT`{q1XIN(#ch}sbnO4U&)Nn?1t|PXtAH~IQiNi*78RWX0C|P-aZclS7)9#}qN2fRK zT`pIH!4ilz@WXe<>~2eOfX3%I>^_+JJioHxiWzk$G1z8pYiAuF*!W5&1WQnN1$3kr zU6%Lq{7Bp39xpwu?4?t^bLvg;;zwZfRj;8Lm--`e!PQ&0ho^S(c=32ej;beecDG5! zaM5X%JD4`K^~h{bkjx==LSl?o2qk+=n;P!!;zktj`ehh#_VMC&;!4D0wql}H;XMOw zNR(L~k`Y_Jz_@7WD+jzWyCnIT7ldV~Ks zsv8w2&l|l1V$ct>#F=IW5;L(1c0tCLkH7oq^p)_ilf|}m#&R`Wf2b>Gkms|^@Zb$q zy@_=`#5>}@DOb%}xl^g}a=+W#`wl2_dDQHS<=t#GugF@iv)3b!1avg|U}fVQrmojm zVX9D4%2)Tz^SY{Hck_~TTB<(SS($9pltc^=q@uMH(T&HA3lF=1eWvHfS7>t1>0Q3uUY3vT1t11=sF7B=~WO22~8H-AOLA;WwiC)Qd!?1I9vtrkqjq6`G<~Y1vQ`m;J+b_n=IpZZi zS52W@i!`3CPUgFo;_29|sjKQ?%cFg-bY@a8K6RIfBK6uii|<@StIA`u+WSNiMP1qf zZ5G5vNoDF{r8xZ{wH|mw{(pSDvy!C9MQIg?b8SZ4uWSeYHANoMlB19uo8r*DKR;q` zbvQBR4jcjZKbITFA+CRI%l>*ckOzKyd$5Ia8dgOLjyQ%ZvO+F48_P-g6w!V9FhpJ+6V7yv<2J^k=gqhV2_pczox4h#>5d1>} z_ocu9L8@3%HusrCj)dXG%N&cE1igbeY#qkT8ktP6L2kn&Iq=>1`e8qABm}G2H_v&C zgXz93K!3A;)ASYQFB790su9L7%6$eMFSA2&uVWXw7bkH(?;FKupXIQ>_VH^P$phRw zE5X%zXY0yUg^bQ=@XcFoQvb!k;41OjAE z#p&bWU&SBLk9DOs{e+1H_-yP1ZgW%%1aZ#g+lJLujbb&Rk}bS$OJzMvG{u%+<*{bO z$^u)xXk)7P(0z5={9wK%^3Dsu9(ymv|2vq00T7o-p?vR4O$`kufv%R*x^PU&#e~rdo9BODv z4%Pd?+LORhFCpQ2WxV+J2akQ=Pw|+wwj>Xjl*%J$y`6U*ZrHad#XM`p2RohHjWU=Y^2a+!W{Rcz z91Ku!RSBf;VyTnXY(eAf=7m2iy<^8}{5u)@k0?_g6j<+$*7>r({=Aa1WraF}t5qfi zG>>jQ8p-CR!->1717|g@p5JjjC6POHXdhz*Zz$Re`{B@;&VFsR&k?W4HJ|92>(VB5 z6Jy_hedsJg>w(7hZCw&R@7tw)D%&@B8zf_Lnwl*OM+S0Vi#u~0WbM?^ug`3>3vA=L zWI4X=8+S{>eU)v}kX9qD*V5?2y}{tofj@XS()5_l-E4{QjP+^fnnNFp5YYKNtS^DT zU1^qm*1oY!u7=ZZG>Byo5Q?h79Dg}j6TAK6 zv(eY$=BFH zA`#M6Il1RHEipH35#RN3jAnJ!NUe7Ld1j|EiFu;PX~2nn&Q4Dw>DlGlP=yX1$VkJ) zNujD1Zrgk7nn9y8Esh8LrCz@g(4^mod%9)NhITIFH`SLt@^oXwAc{O$Ixkwwfo2X( z$7Z6rv)?>eNi49Ipn-d#=%?O6UJI#N-u37E>~J@qq!V>JXQ}uzAo7{LVO#*&j~#IM zy#vNtU4&twxOA?Dv?+ZeN2r!R0m6Lv%Q4Hjb8hq&Uc_VWkrC)l$Ga#}nnOHJ1Q1v7 z&NHkH169|TMx&MwKXET#xmvq%yNt)dTa0@&$E8RC*x%peJY(cCbynJ?E#91svhU1q zLVaTw+w6AYoZG=u!yP&*w0Yw0bPZV7+E9MBHuf;l6{|Jg8XYPj!00~p*d)hoj>vj` z0Ppi*#x%O4;DXE;bW^c+KFkjDYjnfLu26H&FO8(G-JDN~?V70?KS9umc#v8FY|P{D z8mVujjValU4v)WQ8Fr=Cm8n*T);;okfA_mIBT+Nmjp-e4)&)ZxQOMHUOPO@*RXM$JJ9TjiGwO!NbEq$URY3Zo5&145kah81h zi4!WsbAN!t#Mu^EDMe7|aQ1Gs4Ag)p2&9}giW9qE+UFv5(m~^gP8upx2UxPuJFp~# z9Eu}f{moSNa8o{Fw}B?DI2FR4@KN44}Fg74Iq1+p(I}MZ&KadkYm`KUjSt{kZaY9e=!vhxu`a z{1kE?!5%O|mM&yCIiyt4^wCN0B0ZObBv5om>r7^QB!J zdwV*pm`QAXdHb{20lN*$@8>f357dLX_B3$h?oe+Sh6^VWvPTg;xTNzQXZKou^bWdJ zp+T&c&by@(m{efRL*4GBN;L^DWZoap^0NBnVb0+ytd_Pa%WhoT>+!t2d2fG*6|k`; z_gmtsx%=nG8jO$h`heKU$ir`n2F>tL3ARnk#yEKPY~Hy-n$uohwt+Q_o3QjfuN{Di zo3YxKnqSo>QZY^jmKA3lPt^53d?%fku-j*t68mo0Z!~ZG&(bSb2?uHrssT%mSeQ>R znD4r@4^dux4XA)mx~TT^xhGX!kNfp4zLTDCetjt$L?$lF9+{?e`8-{`-f`?b)tQ_i zP)sW|mZ4bBpxZxvP>+1mQ+cz%z|!`Yb$%5j3ZFN;IKX9plr*p;{Wz-kf$HZzvS*^F z1n=%IAOdp|+!muHZH&m@9i%5GnWNF8mc&w>xP2~{+$cr8ad$CKXGHrI{z-|TItbr zyZGa7#M8@n+jselT-7exBy!n(-}r!ivm?K&KRb6gPP;ow8OXHHXzzoX>2TQqO?dQi zYS65RWyMg5$*{7*#-4`lF*^)<`FjeSyzi>4o$GKK{N$;4M<>YVuD2a3-mt%`m+G7g z;_}S-k7EY{-C4raa~W_IXrgL{3$O~Lc)N0UGl2BsnAspQCrIQQcb2n}VV@J(!`lzD z#fVd;>Xtq3JKz_w7FciM#tz45tx&59+dHpMO$n4{H_n$!f2a)Y$X``qOkf3X_XtZh zsAjwps_2YZJ$d(~>tzwi<|&UbQ>sEWXeCx?qo7o9FmvL1RlcAxkja zAYl94XcJ2JW14O5PggLnMD)gdj2Jz=e>fzj`YvI$z#- zuzvY^6qHE?n%I^n+ z$IJ1RRAM#{BT+HOGa%;8NWqSjV7}NVj}CLrEPJqHI$KpQj%~&srCuiqyxF;QGgf%VR(yYelfV*#y1x1MxSXVyBuFw)C z%eBe($|xCn-2wQogC++j}*L(=bDPJBwL2NCo=2igFOr9nQIs=@^7qsX=*C1uN4Z6}f)=UG0 zIetF3zD1fI--~>vK2n$4rtTr(TKP49wIA*9*Y8H%~e2 z^Bfrlix-{9+sWRf{;8tlAQk~iT}*YPf`JLm^PHEic-B^#3A3ATI}BrQPM?%vs@Pw( zC4N}OE8L-N?$9o|kw1|0f0%mjsHVQ>3-pFilp=}(5d=hoC_SJQX%{6Nt`{(2E0eEo2a*cqxuodawkf$aAf)! z-?n(W!Ja91&TXVuU? zYk4Bo`}P^(x1qC6G)=To=6*+IRCr}c$RU?kp5p3i8|I-TcYdAPfY-=XN7;{awIO1p zU#qBVg)jM%_%>x>*FnUN)dzU!b7q<@{F4wYT&qsvir3uRnHXYFd*Y<} zejNgyr@v-q%eCaDQCsFQ>x6FYVc*il`MBAWpQmZBF6vcshmAMf!3S5Q+BTrp#R{>K zUV<*2p|>+mo($2!M~g$*o^)z=cA6lNcou2Y@@;$%+-ALlal`;S5zf9h!T-P;-m|mMM-`GUF zS)||BZ=0cb4185#~E3Gc!5DczaiR5n7 z-dPD|wtN6KdhH#1phQ0Vh9g+Dt2W~5E=z(`>7t!v#7A9fjXmcuA13O&?m|4bMQKvO zj~0Up_<(&Dm6oXX^Mnt~j}r*8UhkJI}=VAKx#N77LE*=I9KKYlgk3$`Cc@r9Dw zY~zP-Zst#|%5sdnGvAereQ5$y5^2Fvw*S_Txx1cBN&57Ng9vlq1@#~gmavngnGpLq zzU23NS!|7V;3@2&v0$0NH-95V!CT$zh-?uo-Os#l6gAPWw`zSr=Hi}O_&y~bbw$8G zCn0CGT_N&o9>*v?+WWP#DoI@!L(Olpbe+0NL@c?-4tC2}gM^&1c-^G5T~XN~Vm!=Z zit_sj$mD+Qj87}`__htEse)or%?`@tBTY5WfG!`huWSF_{IU~eHWhQjR2cKv~WZU8Zz82w^<&rU3Xb1#um92|%upM5JB2QO-cCvDJI7Ejr&X(P14%i+|c$XEz~Mi{T)d6QAsnD8enp0v8&XY z**jqO+>joznqX->tZDfCSInjA#utdKl%#o5WDTaMwxc>pKwdRcn%F_q2Hq3FWP?Nb z=;59Tc*IT8s7&+gdw$F2`1fWM9;`EPo$>n0izP%JDkQHTG1KP>b^7;bHrssJgbrI zvOv2MW^UuEY;Zu|(u>n}Ffd~IHN__c_%#hpm zv_RiPe14=}*K2&EZeuyK0i;WR!Q&5<5Jd%J*MDPZSN<)gHjoTH2Z%=dS7Z6sW5oBxfA<+1 z@T-I^CpM;+d1)YUS4c7&*&C$Y6EL|Ret~q1kRZ%t3~LdKxI9{Sr?0GgGl+xK5?{U| zD$D;y00>ByM}iChLMu%*(^7;M5(UxRy*WU=g+w(#8-`>tC3~m{AD&YgKugp$-1@O) z_JQ|7!_;+D%1(3YjSH*2$}KuR&?z)-_@PZaYPpS^M(8c)GCkO6U~l&+-!D3K%+_cmp4QoYS75yy4$SNL5s^Y9LHT$^oc~2 zLq+P1{?e@gmZc0H={s2*V)5WmBUjFyo49miSYnFmWaDUpKK}f9p(cMPQebZdNYz1n z#UrQrAdxR*X|P|jNfy2aJv z7I~AX%7_51#wp+eP(^9Q!Q}SM0=!B~% z3>G$cKB4br27OmC#P_={cq%0>c9T&ENOD%sXHU4T*ve`tMXL*bso2UjdKLXOH3zGt+5MOc)?m0v6PkVU*mHCtJ40yJOWdlOhq z!GUl^Qcsq3$?NKe=}i&Ed&$V?5A1;fj%1SnOKvIKcLp7d$BwVdNVDMkZ21sYCcp=? zc_Q+Hl0;bx>SOUjhk9QQ{P0TXpO-yu^9oMIz(^fIbMjuaLC-vpr8>!D2+`j?e8PtS zPDKnt8{_0O*?KU}>4f*8WTaovf<{uh8;Dm#TBKN>VsY^m8~#SI-k(w4g{WU4sKL;Q zj<-)ou8l}e8Ldc;7wskA!pJIBDw0@`MSKprmm>CK@q&>~~0$8)+o zX~)&^P|sWuZc1IlwD>o}$Vp@48&&Hsr-eAN*N2=YcM!#`HtT!ITKsEFpi)^*Mj*LH z)C_cd4k>?Pvv_s0fj0_SHNIt6ugw7h>bnVkOcA00+OX})eYl>0H>AYWn+vgVVgEX= zE;dx!l&xFe#h*_#?unloFEE4&LJB=Il*H$JP7|n|#D86p`B6xwp{HdKO#SlM0?d9shybGt=)5~@2i7+{$TV;u}+nzc8yaJ_0P%_{C zMjI69?4C%UmHuSIUm_O!+FYkAVIBZ5CO=26vPay;`_%@*wRQLuuXTzPbwG71box)A?9C{t6=m#8Q^P~`p#^t3JPgDAABL0oj4J_%l-J%4iNJC9Lt z_(oEHJfA^vLXIN|>2g@(7|ltqQXv`ndyyq`ck2h)RMK_3tp5TrJF|fC0xrt@ctOlw zb{a=@AR+DMHtihs)PGm*)*$j@7V5g7z)Go8Mq^p0=YKNd1}msc%93(#lZKJ2qf7>8 zedNRW;v{|`(UtD-anCU|e#~5qJ=sWo`~*#%mDE6>n4w-{C!7m%iF3Z22fHy6;#JkjOe`BjQRS)hR5Xu+Se3B>!Sh4$8sf{N= z{_VE7W-!o)C`&#zZ6kBwKx4#qF&CVFHO!~9o7 zmnPDVRtFPw8x*_1IM50tp~d5s?7itzAYhI@BnnUuKD~MzZj1gW3S~b+jeZ@oUbqX%56*6#vrMEdc}%q?>)Z0U}< z7Q0abTztcJH49dTWIYBck#disu7x2fo+-F}wB32wV?D{usVy;v^| za7pc@m?9txdn?=^RLh)s?ue-l{$j&V_w`Gi>4*@ob%TLV*Qf&2;;56v5FNBYaTu^P z&)PH7?9T)tk$1J~`AGCQXG8_P(+*Dkx%N|;Hoxx%Nf9VOFR%vl6t2j#9%h&FdU=#? zo&pk>X4|oyrg0Rj*Q$Yj%#6+1Ls&61&XRY@BE9a4Nadkg8~`hMu7)t1gt~*^diRgI z*d*|CMA>MD>dXW<+$lu;Z9`OeuG}YF_nexE$z72cM4~Hfq&zQ_EOY^arbKWuRFUR# zpQZ<%bg5?9%}K%caS2d0kl6sXixfjYJP;A9;^ITrM?l<(Y?C{xPapE5?Xe!O@-aoO z!VSpa!;p=rw0UKrVa5tDILEN>D#;4X+3~1hsCFa;2Do_^UfZr@{P~yQa8cCtqIi|7 zp|1c^IlC#Xl674SJ)T)ZpU!xbuEGwtjXnc4Lno$YdftKp3_ACAv1tAX4h_T>@{qBt zJF78MX%c1choBF7W+BRlZn~|DYs+XdOwN{ttqFq85_(VuCc?3+2Uq2z5&yqs$?0M| z`4A;W=k}z!b~x1@s2pL&FsJtXeEYerq*Prif4xi_hUIdB~~UB1$_HK(a+6G!&{b+HIvMj?7!f9mLRbWckDK?pQ~3Q$rA*PVSD{41A#G;Rb&&>qC!b~z5oRlr_5 z9%SMu_56KUjjp=z58D>+-Qob$N}Ah}HoSSFb)r0a2^duoqi%M0+( z3BI0HCGL&Gg!};gjoFw(Xrp0~vVl!!oCCelawe+SvMCMJ_3-D1o3J3DNJ;QRIWo@M ze?!lqt0O&609UhLn4ITPl<@yuP;I&(8JNM~ErZ#9n#aCW>{fMTxCZG%mbFA@FQ2Vw zS?Z4Zxu)Z(c@qz|o&DzG%Y))l3*<}0)C1Q&*6*xaGP22OA*-^#*oYbkG4#J*mrENV1mK$-Fqz!b zQvZOh_Bpc>=cgY%Ojmhy#A~0^`{|@&CLX!!w&1Ce^Nnl<9<@7*XhwZHpCdK^=CmXu zIu`yJ#MYTWN*ce0y8w#nRa`N2!~tRiir(1;tJw7yIFD*3Wuv<{@hwXWmJ(=z&lFzpI%PfDaj0^8(dyt}a$bzU6$5f@UQ8 zO+%8}xP7+ri*!9iFB^e6!)^v6C$_;&e|@s z3|Zc;Y1`x-G1;x(FGWh!b9*-|&m@x(;=OiID4BurfjSEXw=DpJ$@!%I>EYesSy`vg zfVG2Jo@tr<7G#Jwe9TV$n^s|L3t39meBK<0YKqt;iV~hG;payNTV5WY%KM-Qqhogg ztTbc{7sd@_G#bLI68b?Th@^n;VX5P}j_rv})4SHo1cD1A-_8quM9jBe03lG$j~!c@ zl-=l7v(BhOzDEOPX~=U_^>L{1?oj(CsDy$?gm!K@l8yI^<3s4DJqp6IB*oC$^wwu- zdV}&W2U?Q&zSDsm-vGSyC(7K;0||u~VA39LI>pA;`C)=aUf_hbx9rLxjl!5Q0omj~ zMN9~465#rc6qz+%9It2t;|j5F#3UM#=;)T>=_kOSJG_ueq`Lsx#o#9BuuWItgi~D` zRJua$N7;CdfKyoQlvVxbzvBFO#Nbr)L$HXUht*u58_7(zpd^IO0&Wg!HT&PsD_DvB z7ojekU4y05r;bOv`a%3*Fqlv&oEF>Izh^B|T3sza{Yj)y1Z_gG1w4 z722O4FH{O9APk{UKE}iN7BXq5eu6EjFl=mD=%d68|iYa;2|d# zNE$YD*f{t>b}WN%5<)?aNw6g}XE<0Ej;PxS-cV>4Uk^U-B{&k+39{tQh}2{GSSq57 z9Pqf}>Sy^Bb^BWdYua`8qjNY>^y;5~K1kBS1?z%l^BDc7c57bjCU1HjhiVW<1UK!6?}TTSzVTHQ*hy?UwRj-c?p2G`JX_F_O>~*V>60U$vI_ZQ5}Il0p~6 zoK8nsHYr_pUHALOZi_ZZmB8L^8c8fo74rTc04mu2;n_RhU;P244amWvfM3~>T=SRn$g2+#l}4Dx;LF>=g*}8^VV-m22Vv`^7PkGU_6wx;ACqu z2o3SNf`1fHYv^LKxVlI9Se-t&P`gMXDzrKEX*e4Zv^(PK^|{n^Vcu`dK?KebLtg;= z{6}``j5%`=(;>56NtZ_jSnjsrnlBxBe8z2?k5aGTU1?g5QYK!~@$@`!3$&FhTs=J< zgNTGSay5B?&C~C$`4XH41zaSLzQz)l5w)AFAfRLy`> z86K~$i$T_gxgWl50-riCL;0)dE5J%L1P$ceK$7@@4cPfg(G&VP*~iF%E&|z9CRiA< zarbF)J}iZvL2h*JjkFyB!5n@6Rx4DSF-Y2c4}jAV_Q*s>(xq5MSQ+r1hNTj=aOz$? ztFjYs8IsU3&_c6Muw&-ykY&JF!h{hq6}S(V3nq)nj7FSeSExtRQFmOc)w)s}kKqkf z0lujF^SAl&6q%VAWCUnJ-p9mFo18cm$K|B`eVZrSz%L=5hGF^bp&LdZr?~C9U74O| zH_E2GT?H#MH#7b)aszLe#ZF~hCwNts7~~F4S34 zYrm!0&W1d@V#rf?J0&Rn5y&|~Ta9B)z|U=C88p7$68$f{bd4u37`F5~Gvy^7Y1Fjq zOSg1{Zd9!)KROn5BX#WIRs2kI>Yb@sh4k_}QQQko&K%C@0uzou`ahJCzyyYg>p(Z^ zL(y;r*GBs}KsWs0R9_Hn1U^boeOZaQVX7S*T@Uewqe1(h4pheHT?ye2-am_;F)~_W ziq?dhCp8<8gy|9lboYm1DlOF2qVIsl&Css3q*D=^SJ4LQR_7td!>}?tV0@#+uVPfd zAqBK^K$U3FNn0k6$D(0!$*#UC^IQ#iN9tc^R;%Zw?S@B#yjk#Z7@TnDH=g^59zJ+= zX>CVzZ(ptV;{?D7Mt=L0+&YHWsOWwzT9&7OAYxqP&M`H+@YtQ*Ga!8*VXOV|_Q-pI z+*8SVCewEYzz&hgf0gx1?V2PY=)W?4T5tOMyxFO<&+_cpBQn}G3Lk1^K*K~6{LJwK zg3xy`Rud#b2C@ZMAfo`FozIs88>VZ~cUIF1ke}w2h=$3xNqHmn@=PA5371*Q)|2sh zbqs<|4}TAms&8<5t~86kM&KE4vwLPnjxGe%eEf`qyu!P}0HyV=RD7Ow7p?UKzW$vw z`o5>{hdeN63$dkZk8A}(_n&u%@xRha%&aKq7RANT>h(4`0?(kp`c7a{1>HkGz?iCm z!uuZ_QuFjZ&N=uc$w?(4+aGjpS%-cD_C52AAZoKNbULExlb;!y=YqLYPC{KLgy7dg zel5Q<9IXegeJhKXeN zWh`q`kX-v$K7eS9W+lG4-ztMnc#KB^VHZstP-p0W8?XhtCZ_=DEBIgFdjc~8|M=e@ zI}2G<2Ums|xHI_s8`ESl^e4}wCE7Xw>wt#}TzluPdFrd;G3oXzK#GPx@%_R`h=MMD z?DP;wGV@?vqpZXVrN3g}{jS)Voi_TTrW=Z(JYVz3s54nW&PZi+5GDaq7Mc9I`wltiYLa{>tmcG+5b}(gQh5208{F zH-~>e3}+$!?{hx)G!jMk{ps=m&%CZtjd=F?OU?U0ir#6BjY~u|h6f*16Isc%;l9~f z;rABYtQbk-+HAG-mw1L$sR7wdBAj|qG7`9)--}?Mo7Wzru`JW&r!CK-tJ3L$?NQ`3 znP34ZF^IAGR(7|+lOWOm-7P@0D-L*(c-sSw$_jkC-hR-SjH!3{i#%7B<{v&kpfp`W zDCG7z^vjMDzyk-P9x7Su0e`wVQO<6#2xxXrVx-6^p zUY8%j_f|5}=s`(W#v72v{0hxXeX-LchHkxo^oQD7*1A05Yn!s{3S&K_zv2Go=36O% zqC9f7fM=sGDWDHqSv}u)4qZT3)5uOA1srf=g{4r`z0v&5pI7_jqKTOF^2MT5olrT* zdQJ_zY1TSFV7Z?_!$EiP{qu4geFq-&rP2O#G1IFBzA-R4-$U3`-b(D+HS4}Iqy2=o zVK9c^e)VD;VTSwADZRh=i@Umbi*&hCgNf(SPc z12!=v_ZhZy#eE+niUERu9M$D!`zYC~$>t^l-n7%i#6Z9zIM z2w0qHfxQG>Xs7wVs00GWItnUzF{Sdc^mZ0nV33^(iW_GysNVfB{vJuVDE+Df_j{&< zeltWS@i_Q#{jt4-wy{37AHNtmKUe<<`{mVZ2snj+(~&+gBpI#JhRwspk#=WewHUavAwqGLrASjnQ-*uQCJBuDDggj43GBS`L z-X*+ZP75cvWW|pUv1t}viNp)FI>CXR41b?hhy@4zM1xfOL@d2Ns_ZlO34FBJj!+z` zG>G$s^HJd5XWF3JmM(nuS~W{0i22g5_}fmj9$*HEkqXE^8Jjq_M+-mGxbH=GgWLXA zIZjJvF>(a5Q^VpJ@MH}c?e}}>|6g>uE)7+)gXCvO9RwQ5oy$|qWgo$VoCeheQIbXu zP;QIY2A2Xu3@ZZq<6K$TlGfL@P&EFju)s^iYiQ%j_la0MQvdsIL*A{Dx(1G(H#EvqED;P1%x1!I6w8hj@173!f+_Xym->p1uP%R#J(vaGIw$MhyID={pf zz@_;-z$bpE4_H3~+4l+*_%ywZj&f&_Mi*S4rIo*nNqYyDVphnJ$=32u%K6V646vjM zz>=~6&Ci-0T@dg0A6hz?>EKx2JRa4&j>qSHpnravW^v~Fui#90$_w{oX+Ry+cziME z@jcGiFGrL)-93-IKtOs0AyDS-#595~6HIt9bwI%`Y8-_m2wQ0kI*`0q+tIe5Qvsj1 z!2B`J-qY?86ZK%xRx)y+LLl%HRJ*kX=V0fn*`#hOK&qOaZgK6SnWMQg0NwbRV@$GN$qpb_`wIoe zCEvnF*UCG`0bzv{MA`&cOM!xc$S2`cBV%Cc)4;QU$VqirfCIOB;ALB!ogWqPs-N$9lfaqTiy2Q-7hN?`qI?2RDGZ{u56K^e zFsdNQbswcomv6mFbv>DYx?6V-(3ySiqnBjkRUG;=Md#lPdq$4e0pL}&6d*NSqR_O{ z!G&$0#VdPtxV*l&0BBm?Y(UNwdobvf6%B9Z+JrTZVHtP5K3l%06_gek(%gEJA*Tcs zozAxKl))8s(SFp~pHj$|vxj-7KlVrC3wZXbU$%m6p$PMyuzra*T>bWHZrA}T`k{O& z+=&y-Xxb3BClA`5h^LzuF{L0oOSS?$9B&;L%TNEoHVg{rhB1c&T^tX!LTTSLtHKb& z6OQ^0V;SHB)W)9hQi%3QL@lJH>OKHE0C(>YW^}=vBDbJCrWz3lxijD%XeftM?*+xO z1OOsPXiu@*jFos{ML;ew-8J8T-cXF-x-z19`}6SH%T%;buz1%H|0C`R9C$;97k!h_ z?v6@=0@+Qkhx?@>zp6Bi@07_*u z_Pk|z7Y+9vTd5Wbm`p7;UZM%M^M72RFK)mX;vPI=%iG3ak@)czoxFwiGRL)3v2?Q3 zJKAI5Nx3ALJ;R$$O_%5a=9nbu(=44IUjVS@`^&3d^gurgN8Iy^%ZgtB$@soScmLV-+C1>vlwtTE3NYG>a>+XGXLX@_L710@|*T4u%&oon4ZD!f(S1FN^w_x zCN9nH3Rw|Tko-v&a@afMg&flh%dcwwIq z276p3+hqZuKH)xWDTQn*&06mr7MZTSW9*v;9`DCq-M~(n=ycNa)1mRVJ_m_y@)8;R zV0cW+k4#|7YfG_2XWpa8x~qty|;+$-Dr)vA=^n2}jfG3etpC@W*MKY+r6*Avv% z)6@c<33!R9=8j+U3*@M#6D`)w{?KuURBVN%6$~Vf=mPr#jS|yEjL*{-M>Mb%ZVPeK z?R(_~6!H3Wd#R9KJuxt}s1eAb@e)U&(z2~o309)q-KKlsw-VqGUB~Ie*BaS@rgCWr zY{2tRmTH0)vj1FPl|?}7-#C0G%yR2*;S2ir>NXn{6yBjLs+77cI6fWFOw}=1ZwRea zt#%|ib4`3y_97pQ;EEFay4Kxk<3%QmzL`^2;1=dLvO(kfOF@~o@sxrSAUAttY!Xiu6PpD(Gw`V4f7DK*{95;T$_jii~ag?tCl zpj0*=hVfUzh$j^orG8`0IrLwrqv)B5ht~6?9~t&HG=9D< z$h=C_rUpN?h|tAftJzgRnONfLu72%RE`7br)CgcDA;(HpfOa9h?i*A)ot;46mA*pp zDa4AQHo$^U_3oylZp@?%O*VdO0+lP(PR3Nn>{FO zR$-f&irT^eY9AQc5AY!CS7U{Fx?xT_cDmMWS^J0Ze|(|Lp=Z=RL_;>+Aa;V8So5n= z%un9}U%%e(Q@Fg!_g{Gv?hZ^VOIFO~!TO$1oC;{J9x;N+e*Ay0i`FcR_(&}m$!6wz z{;h$|#J?|%yX5?R>+y*)opT*+KvT+`Qkk0VLp-!uHZU4VH$@Bs4gw%`kA#~jlgwp< zFyawMbB}4i9&cd=FkAKYZ#K(Fe*?hSAupzFtg0TfZ1_@Af2lfbRQ<_)C!CpAaGlm7 zsDKC!vzp_##`l7*J4zY*H3`w){#xAs1Im(bj_xUjd$>53^bFjB@ncuTBt9U~Gk&YG zC~>PNp!7}sxnfabJ=X>1r4WV&TBbbAFE4(U9SB|Z!8n#2mM+B7Fbw0nc0jF#GSC7vEOa+p_P|Hgeuq z6UOu#GA`Q0DFvle9gt*D^5cL1JrlA>Z5!VHkXpcZh>3RY5Vk1>SasklNkJ$lvF=K- zkmI{aiBhrr^-O?|*jg~s00Yv%0CG{^BOAPB0;WcXi5p(02qFV5KjZ?9aQZDzQF=3P zQzt{O#}cpL&o{J9&eaBAcLaSe@0qN1MrwXY*rGqpP|}X_neXa>Y8M=A4LqyGZo@|? zaAQ}ZOQiFlJ@DMCssMwCbmEM7)UA6ou8sHxH@{9&o-2Zp|d z(!uWHwf(Shb=bglSyGVDCa+spEQp7zUWIIKAUCxhXw4ux*n(^iAl1wSX_Qjq-?tJE z&USU2^Xxu{HmI3im|m3bCIiE{I=8H>c@G3}cB6ez4ttXq9w^!s(5tag1+dD+!aWbA zgo)(iA=<@PsBekPjP&qL^Zue1`CxR*gJY1_E5BK%tDp#>1m^3}$*gc{@3N}FXw`LH zX8FBQotf(!s=}B)kX|6SYQ?`>Z#nK zh?q-lE2TV*Lk^OeKkv<2n=;7~47ktUy>xP>uLoKCKP^u8R3qqyF<@b$3Z<8$XY#Fl zCa#75e#y`w?R@rW@Ofb2Nan2?)%_#i2Bt5(MRG-K(~42zwSISl7TbbQo6;A>06{V~ z?ka4PnH~rBMtI9fkF}kzJRfl)mymviLiZY`o}@7}zCeM@ zmdxRNA|uuJqS9jdZ1novZyv*;EbOJW%y2giTCA9|;l`wHdwq_(XoGFzXT_vWF%(II zA5${(m#btUjX~t#456{H-YG6wqw5K9TKYk(2OKc#Cfl@l6q}F|BrL5NPtONg<$2-^ z1t>Xy3NAa2t`63^D+d2UlTss~wD=WQRgfQ6?p;>gmfa09wDV&yZw#5j=lfAZ)~J0V zezx`i1MqEq-wVq z{hcLV1F<(9pHb6>Xz1=R3JKAf*G#S6Tvr4u^OM21ZoWma%`F?CQWblK?7((5hybT; zDg9aN24G%CGnVe8Yxs9_5$U2B0-F}f`HIu-JE?w@W>Fjb8q?S|gwHDkuCwO2s@GRs zc|pz4RAbXkhct%9u1D&WQ~TO{dflZg71m758K{~Ip!A{noYJ^DS{iK0`6A~Ilj(II z-m?8$_-P)freM#CAJbzeoBIA51O^VJPWrzC!gqu40k5f9&?rK*FTXBuNu&&Or7$146@ zyos9L>7TJNB7gU3&RKgMO@ECH^DeI)nB7(Y{k1@3YFwC#jMXQcsvm%AC2x^@q!~MR zW`3__NU>9um3VpqWh=08qPX?t^hu{r9b)I&wQenqBJ_aKbz!V!^qXRG{oxtd#|S21 ztgV#CsFZ~@yVBRb!oxr;Gz|zNo>t+#u0&MCZ89uZ(ui2d(DkanY%no;yEO#W3fzJ; znNwh@U%0*8!_E=rvM(14pZakG)5(ot2!_8ZYnt{<^mohR5zC--hzpfdc;{N%`0WPh zH~5(S49Ekd!KYmXfTCr3I!oE+_V!vgLpyiNLQ|=wSwn$_fZ{|T8dkQier5=y37w#& zV-B&>hHwj~{x;B_hfO#->W>}<{);0*Fs{vu(d|szNNXz02TYsQ%k!Wu%P!RzoaiwZW_duhvrg0K8pcr?Qn)# zdsn7wedVKfD_bs7g;ul3b6yzZL^mz?`A;{q)E8y}MB6OCX5}%>e_VN&&d}}va*?)h z)2LwY0w`CBfTp;_Bmm^Kz0eN)4>Lqg1tCdkA|Z8#J`LE@MDecy^qwJ;X%SBTn;x6bG4{)eu|v8xXxCo1@AR&$x~0RRuH8 z^BO&2@l$c>sA~oFl+T+oPvP8Xfi{(vf!aV5M^4_WQ63t)C6 zja)=k?ybS;Qx|JST6)3qX~izpY;NmeJb&0!ai1ti$VcCvU4#aEoAISS8<(~2PkwdV z29fzIP-JJX;R$E5k@J45wjVz|0r(Bk3(~pvhbVuIzrO_z%C>L%gB;K04?suVxYd~w z(9+m1VcwI9Jor=uF_M{%0XR?2>y}RaNOjG^hG4B`uh>iajIoIpcEN&f#;-VCh ziLk=R2E9uERmvUvd;C#x=4})q%vX$&o2ICK3D>`K)Ynl>SRfdTH{Td_#}OquJEf}kf6 z?VH)+g;Q~80a}MN=Djo45pO?#kK9w$tv#Hn(#+Gc?qz@D*?Sk>rGwPgV{9k89^<2f zy1GN4=~k4n`L60qYaBf#O8X!~_f2ZdRx$>@az2-41O}(EgW$|l!4YBd*>Z)CcbEFw zHJB$*pq8bUp!GIqKLuZ$BiMcu<*z1$i8XY({dqGRw2?HSQqQ|9z0{u10Wl-l9to$$ z5v^Fb+3K;<=mFD9U4n&+Pf?(_qPeEOASyw-jlRDp6CCI!0vb1aY~ZPprB$1@fKdl4 zQcQ|2Z5v)5vX)1k;MNjiTBtf5jhx$froIr5>SJrZHgs%>Nsha=i^UyXFweB&he~NV zjA;m7R9iY!cEXg(h@##L)`xw+sG{=0raaw_^&)Y9r+wiL7oa>p-?%cd9Y5uZatu`< z9d<#e3DaWq)GjQ>*MTDHUT1YqRw2aab0 z)!(L^wwRU%tl$?fn7ZcyDHQqVV&G+tKZ5o}fuf{CE~ax&x$oA%KwSf*S72V*#y%C=MTfkTS8XFI_6!iU~54Y`XQ^KOD ziC;>0GQ5{9?9=!o#n~^ycJy()lqI~&f!$MGIc2<+NDEDREP42>x`-3lD6!_o`-+Lb zXG)c(LP7Tkl5h_Sz3O-fjQc1OV%n1kFKfOrTR?F?`74IUrPh;=Ua%)_?~aHopG-=< zdDWSF$v%{54r(Q_yTgRi&vuYMZY)T5qX;3~a`5NkG+0)E7*R= z_xMg&V7i9gpd!zUTs!%JyW*9HVv3kkqH9)a2A0K_xA*Vjj`Wpnqj!oTq>kcm46~c} z9j*@VO7H z9ZvD|A`5{nP~e0Glks>$AMJc2f+kWq&_vq%UlZwR6yJ_AiPhzprQMm04d+ygE2aNTXoC3A(*1@!4B>tUz%IVdP{`$Hrk^OytlX(q3Rzth!Rir3^gU&5uiD0%1LkQR3{5~Ri8S65 z&<9h5oY=1%1-}}o+N#+M0V!RWLH0VDo1J`m{C{-Xs37k-B1u1}Dl zp0mvWK?4N6=~)ld^~THbe1e9z7g0xdcGh1MBF;0Xybt@msMSb|MN!pdGp_Z+s0R&< z#)Mcr?iPE4QrcQlXS)z;5VV))7?QpNWA=Rd@BkPMKVt)m1c;1ol4-GPoz_fcHFnU5 zsG$XP=$~6HiVGt6KcDdV3YqRMsZwL|;`252>Zo5LdaGvr$x*&L#W?U))dRk3!K?dU1tcTz=m4SB(jO0YoFSp0Chy3iUabIIFKucT9 z6rN@Hx2g;HHT(X;h@rO3rQocB2rg_)drKU5Vh&u0D*KPoDqwjmB=9X7nm2t!P{;amCM$}q(VT9Y1 zA634r=_bPV06>CA_7d7Hy;Y`JLG<>bVfSUhmu!Jg-?cob)(vhZb|x)IOag4^kdTWL zv$VzseMaFrBUfkdR&pDF5)R>3aZWqthN>VY^Ss42RtYg!62aq^7fh`a#TkxEdRkyrMcHNSn=Up~ot14v5 z7QqbvMSMrrZSY4A(lx2i(EGqDup0QrM1a;n{-titcPztT> z3UwSGs`^;pn= z|Iu(he1H3PNo-UJb3ps>Y6fFsNeJ)obytV#r;|OOw1J;q`dQNG^S}sKw{6d9tS$r{ zf)6pp-Ir)%aO&AGZ+|J9W0<|4X>ib5tCAWS!K;~Z)$0m!$p$N3cu!PwjXNb^fd-4|_pHx*+oFQ8|~`h>l=13$UYq>N}my z{7UU1t2Y`0ZU_F&Gu!seY_ZIp=`v2mZnj4uBD&f1M)(#im8a51i&0+|{%-Tc~uG&-`UZVTTWXaR$E zh^IQkuB3yRT#82)J6rg{q#d*MH68<&IkskQDM$mvg&ToyWC$@zDMwkjb3-qZ3KrXk z#a2h26ZNV72FlO)nN&4(4YnUo`TtzZMPL$1X_C=kSOfd8{$b$_Y&lZ@FIy#@v;cf@ zi@el+%!QpbX#bS;{_(PuojV1aZeD9|e^8E9huApSl*X^0S+*XZ0;7#4hln!NcjTK} zTDK&fY3k_ly%R|$FfhUHy%ow4y}MGV9Dx)kmH|TQtkG`9?$+t_a^u#Yr^+(Y4|!@E z-J7t`HSk;C^%!V+-FrD80JjN-8^@=nAB^0G5nia53IHfGW4)1K;@5I<;`!&0e-2=9 zmeGFtzhAr8BaMEUK-+oMV6srSC%B~q>D?QYF?#pkp}!v=CIe3DWb39r-rQ@x$CW%L zfSD~pxLvTcX1fKmcsY234u)y-By(abrq;A@^ZE8gAZzlYt1Y#(&F()N6!k7F^s2l5QgygOd1Er zxX4~d-OtAeP8`+!u9#Hb( zNW4N$BI>Uf28U<0dzy}&JOXmvygK83jZb8_;$1MbYE`%_=ii&xfD$J-U#+>q&-=G zmE2;m9~x7Mpo{i33m1QS8Ox`%gzx`)w=D?M0sQV7;INE7%0^BRt%#F6qA|X2^7ON( zEQI1PoM6&MK{SQM;7-r*dQeek_fyd_q|xae4EEFI3wm`gQ&v6@ zuDpvBpImKs9v^u?2F~}V*Qn1jm=K%u1ZDK*Cl~cBp`zo0m;^afRm`Q#H*l@?|6%OC zqncd0w(-yv6p*e+QxH*UN|P#LAtG=LRgfYaK|q>Fk(PiWD$*6CTOt@*`A+kOWfr4S0Os=Uu<`ed}Z8FYbHh-m_=Vo;@>ruIorU`L%i>fC_u??#8Dr zJ{=AQ+q-niq)iE;Q$Y2=59r^{JfzIL6=^+T5st3`7su zUmXLU&r5*3#mUc6z$1MG`$|267KFUgwEM*B#LcE;=_KmlWEgsBMd%Vo0Tm-m!lP%b z7wdbKy~_Dog|jMV^rHFyievHvTAgZ-GN8;^+Ix53Ct0L+iDMUju}#}SeR10T(F>el zq3Y&3>0eTDH|WVYntzpJWa1ra5<W8!qU1{mC~)_dQ@7Kly0ov-PA(b#5Ftq-4i+E8%jY zTSCW4QUbaoVs0bM|J(AF;tUzKMnoBoL!tE`5dMyuv%a8vSGhzGvRIFwq3;Z@`WrH3 z@B(?=MaM5VQX_f`@4oo;W`ufA=r<4oA0upTlz06`j=Vtx5QENR>;N8&?Z?P?k5BQP zln1`0xj%_f?_?+R9H*MsRHvF1P~jN1CTO_5JI@b?dN6fJurh8+VGp-wXuFrDzP+N* z--!!{`B;I#`zkYHy~3dVV`hBwZPPbWT+Te@FQb5KwSXRA9K@V%pC58x)Pf9}ZUzxY zal8PxQqqesRpWI2-3LMCUl}Mw!9(h2qes-9+UaI_)Ch}L=WO`ntqZ>#>$T!r(k`q9 z88`BnWG73yUp_75{={0_NKyIW*zVHXa*jeO2ep%I)U~92=by{6HGQ~z{AZhp-OH<> zTh_{cfoOR~S=|<^aZKhGG6W;QkOZ5MBva8nWwGdxm&5u-U2kX7+nZtwRu<6!E%Vu2 z7%7s_d350YX`<>hm)sV^EAkv6^~13k6%~M&`4GW5Vage2}58p`fzynrM`D} zSjP^K3`Mb9p6a%jx}6`cT8#MTxPq1Enm)u&lbh7_WWdCKs${FvX#JweUH+7jZ?_cV z9L-{)d<{##8+6aayA3S0`#a}A>c;O0B&QvV;^)}P-8XD<#*KEC$YEagg8COGxG^YK zLpS05`HtH}ADjqT#{F9AyuNn+VFLxMbXemY2EPr{YWV$`fi{+aeX~({_Onbn@lgDwaD-CL})Mnfk$MAUviLJ`mVuG!UCGGE=w;kzcg%#k1 zImt!|)b{c)^x`_~QgxL84d5~+$DCI%kukCacb2BrJs5!u?X6F7*Y`fkgj#+?vn?O3 zubYZ0KsJPKWAsUHAX(gONSCO$@n zeF3p0IR_=*Rp9uieG;EL|JS-6`a7PI5vE!SBrvM&er+9rh7g_%!O>pJ;Itp=b`+4^ z^z%nMKiU65;@9I$3zP4GFipBLGA?~Zs0`-Rm2s}Sl`y03XZ0k8$L;dd$BT!8?|++X z1?RZr_&NX|*p#quk3P~OGNGz=PJx(=NKTc>uIB4wxV|p7Wo8%{ijiW>R`O{XnXm%~ z@En9k)XjpbUEw2fXCB~$OvG790P!vny(QoNg!Dn;(B7N^$ZU)NhuQ73X)`8iZ?AWL zTzp<5-;P;T7rS&L*K6}~7!NqG6XGalHQ&ps#+*7_`*u9naJJtEV7&Rlv0D@SA@u*b-tA zaiYW%%RIm^0|^B4z^1l`A#*1ogSYayqR)>k+)^Z~0j$ajegPxm&6J3ZU?B)ec8S_3 zLQ^NB&7u*RV0jDy^9`KO4&aEfy<5%J81pRvrjmr`N^bLX_j;ap#Nc4C@H@7!#_#ai z+@q*sANKRJmMO;&GDYk6RAH&DNPwa?#}?c9-R_^$0NZ+ltc~Vc6C(E1#cwRVO)BHY zWtU_^ZD6BNWAXu*&G%bpT^||NL@aASSiIyf&ox+5WS6GgKGU$AwJHjD2wi97s-=R{ zqo(sT`eEX3)dGpUxJ53cV3;~t8u-|^KVAi1fkdh*RYgdLeeNv0ntjBEodD7?=cQC1 z$*#=s8{|ZHHyw8h*-H%}s<_^b zw3lwJ={*qXQ*^g-7s5y&^1yR;mJn@$b~W>Z=>uG1NN+8NYMZ{Mtsq3N0NN@H7RH|U zw=$|`N$&O=C10uq>S9FL3tlm5j)H^Q;&*ng&X7nc)FyQoxwb-DwtL_ajZv4nM*g}V zT#lRnguxD_QEn0M)On#F@{Hf%3){O_^9soTi6=78srHyRY`8 zr5~fs=90iQDUR@m#5W<+Or2CPXNq^R`1+h5rki(I_xQ}7LXA8-BYYaIh&YcU9FD^Q zLtO&uaoo*dLy4|gnkI}cEiYx`RnD5aoe_wrZrJjVZhpKbNaF%%yHM4<21XF=(Q_lP zP&%V&GnFF$4D2aGLOU4DAd+4w#MVETBVXQtD_T-%m-9u|1a6wE?4^_ZUy3qsXREz7 zu37Ka7oZss-H`#aTjCw3RZEa$i!Oh?DU%=4up0r+Txzx)X3v&|U^Djo3@AgJAm)j} zxzxu4(%E98;6(_+hvvsJu|T%uGB%cAohpX4Z%(Q9)n~#dEz9PO4|Sg>zKYsxdHBoL)m&>>*-iYbj`t1skjF+Ga5L7~po_EMe`?;DSFT-OCQB9%nsCaWMwK1anUa5oc3VHOaElIw9);*v}1F@ypjs_CmS z^6&6mY9bB^+QYEm5_2vb&}Wf($-=66+<%`YtHU}z5YiehRXZ+JK`sc1A(((DVTZ29 zi!Z+4SqtEx_In~!Q^fFih>^TeTH@68caDB^Dm!|tK-;^f#MHxvSW{3rA>tw$zM3i@O`#7QhsKQ03qG|26j_?6A<|HvkmHf?>F3$OnXmBh6k1s z-yA)Fxe>uko?T=kAN7(8&0Y&=5iqs1z620$GuA|?@?>+^@$6T!!x_CWe@8=cF6?mx zx@{<#!|0dLr69_AS3V`zZ#IGqE%;JY4)R;+;mhCe?DC0jPG3WOHiRkaqImrd`5N8M zs1gtU7HkQwE0DESdFpuVFO57{GT$Lzau-#4N4JiQx^Iozu;7u29*NuD3u!@5bUQQ> zLrd=|!n5xY@F|q(`(GcgtM^48OZzGykV0uxoT-R%gkVfY zIvio4ky&5e)-{Q3u{+(S{hj~-dn|}zu!K!g_?)$RZ3lk#3h_s*R&R*m(+|5s*un1F zZQn7Mro|h8^T?n~QTaoZKF06@?+PXOCAgkWF;}DYhL)ZqI~Hz+^36@gXs(1ZVnvz` zQf`|aKmfvowERnv{9(m_7L2{_E0c>D!bWBywNgA|ut#i;U3yi*ciQEM!HaLuU5l@3 zkl(5voe*YWOnBi^?$+n2Y0|m6kftkcMno@ohQi)Yk%qQz1VRpA0OP68U-<`Qm^_f% z#nK>uqHP`|pK=ZTbcy(iJfv2q`;uf2_Wo@Nqx*~wn@y26_LGIdg@P-Odc@#o!q5Bw z{63S;U;N+&7Eg!8SV=c@qy4z}*|3xyaeUdBR>%~8<46*qWhL}W)xcGR z*&96rsMNJQGuo)iuIOE1@~nI_B#~NFhd2(_MrXbGqPS=|5=EamvFXbU z!a8$Biax*SO=U=_zCLGkmZ+L4=0}03J{{&87PJ>qcu#oM3@055p1I3Ib8K}P!0Gon z3|%6M!U9dcu}eS?JRob+o7Zu^I4JDMoi?*===;7yFvp*kYW*{z+GH~ivHnI;mWK=7 zW3KdbtrJ;qNH9#4d<8b3ilh*Y6ssdJKYwIeUza(e94)*$xD41825_+YV!}ydauV$z zo7X;CEg%IzlfJ2@IXGgVBhoLA(t=s(8aW%-mGyW;j%nE;?5zE(1N9F?*j-G*Z_c0N z$rDs~lx@(JXUAFdcuj5SE{qm2Vde=b{X&){tGh--M&(+wun`%P{#uL5n`=|yhskuDL*EqD@*{yG?6bEk>dpWutVJ4Ea73A1 zh6}T|>BpO=+*1k2vV9ZW`OB_Gt|b(C^E>ZIq$nhh1ts;p3(C(nP5N4{fIeTYI%kFk za2O|v(tt@TWeS-Iu7$kzJWfv4X`hZzO^a=DuiORiQR)!f^lDs;f)!6@fw9|M^!ei66IVEqkb^loec+ub^F3+CA=2WR+Z1X02^xsHkPU@ zt1meH4gH%7QT^A_OPM3p!(&2(Mu@&>$FtKla6fTgD?#odbqPPt>hGLQ-|sgEd2tvB z)EOnUtxy*aSKnl4*4)j|(Em*-$(c`bRsAqS+PH4<`cC>V^aoxiAaqQF{STpJI^~z{ zg^?Zw|DHl>0ES(p53M5ZOh){Y#RQY z4|XJ=7xd`~&BjMn!&C^$22$~kya)B`bviH6q68sl#Hpf7b$_9(Tgwa2GFkV}B8Msl z`1eorc$&t1Oh;;=(lnlEr5_X41}vRRb1I>N?4x2#j0X-Vw}{ zYxJ0zdH*XH0|+f7x(v`kRaFY3g&P(Ui`4sdXwG_VRpCSj>y_5k~AbH3T|=yI^? zd%{1yFJ@E4B9@OV#qIWrncd&#?26xbef-gv5rK-8JJWb5J=ZdLSg5}(Ao24n|0|;8 z5TbCGHMEXYVC|d86ROH#1TZ_ygVm_e19^D1C23v&EuK_$b1W?7+5s)?G|Ky1x01D> zfqFMVXWQ(7k471^bZHg*p*p>kEhF=E`AFRRL^aw8FFY|bX1cPJ{lRVJ7prGoL38@0 zI5Ay^zyUNMFM1D707_y;p%I%ZH;$}DC5e1AGIOA%BCp)h1M#gzv2|qWy?GaEWZ%){ z)uH(zX$70Xf+K)GDdO}ShdMr*eG_YUz&}8V0KiwMwc#)QEB~;EDWKlyU-3hxO*lBusUt&5*6;XJcY6Y$I)U00o1Xe;yT3me%;sZ&mOqR-90I;_q$!0 z0U4bdmcAF#YUrws0?VtD(9gBPG<}rgdp1Ds_U!6n;?0Q5dt0U9clofZrZ(6dQzxhB zGvRfYVFtdBscUY19Iw&AG4r}HarDUhpHkw~`g z1$QZH)-pR~hn_h6=4q7MdWO!RXLOWHf8bMwRxhBh&DaYsGOLR2;fwKf1PFi=4lq}Y zf^aU!!6VAUO&5ya8ZISg$Or$e?xk88F}>izq1x>xH+DX7Z@(M3fpKx{D7(JNh~8Q( z&{y*_PID@g)^m4v1JEIG8w$Z3gm09Ik0tp+0c2x%N|P0dNv4kh63E zKN541l6Gm&Xof+7g0i0sPqbk|eEzk?6p7*`nFAe)hympJDVfO(*n5K*_a3@3Ioe`Z zZq$;#5=wk^)Be#sK^<^*O@=MygLG>ln~{Q*_CohlGNnQfs_;v4eZr`VrUUO4NA9=m zaTRcN;A}22wP{|M8paLK5BFP-H_C*S%mow}fRw`cpLlk_D2#?+b8m>gvy4vQHQarT zLOY7G;=>6e5M~8lcKW` zbpD~0HJARjj2C~)*oNZ(w900m#X#cKl^=i6!eP%A+jX>3*lk9t0u0%54^>|<6z-_k zQTd>Lx?{^pSp9`cQqCI%ZAmb z=5bznw>czhZ>>S=rdK=0B)1i;bEAYNnpwy%0e1cLt=g4|P(DsCkTfuSu2{%Bk`>ir zcGT$y;E(Zm|MV0pMkM=SxBxmYV56p*3V~1?*?(2#@zM%ew?NISbp|-iCcmvxFr~3He00}h68Jr zEaARrKxo?O0sBGF#QEUNYVK!9Jj=j`LTVwi&^!nwV*i^AEp~x+`XX1K1Hqa{`K5wa zc~Hcc1si0%UQrAomA41zY;W1_zSxyL`N3GzP8B$8N@iaMc~` zpa*IcKf-f(PV1OBoXcA)IFt5vmG!W|qfmKm6lox&DrU`^sb&u{I=UWCC^-3&Brzw~ zUtEhbTwYa7th%92@;$V)#Z0(rNRxl`*_tU9bp&u9KCs@{O1JM!`8uHs)_4U~ zzP*#Np9zapc~pOW^FTAagjA;JJ9{TfgeAT5Yv-;y^4da-C3|T5kSYgOrDB(}2s>TA zu3?;l8b9j~_{KSEI|?=qkx%taJ(D-~=83XvVkw;xwXl-neBa~WM?8M-SCS2qUpD2~ zei`b?OK-~L<8VWe2o!)2fj)ZsP6F|1Q~jytbqBI4W)wDqv&`;EdP#9gQ15D%-dp ze`wng;vs#Dv43vz0yJ+sL<6`|z1S;%ZTMU~3hkn*_E%M3W3tDN7VTgvjS-W91j zr{ro%-(v6rxUTWaY-{5%Db(k=K;ruoXSZ>tJet+UptQ#4Rlc8KL?;;6R-o#eucrWk zZpVVSsZ`kGSQpZ&{1De?{k3r&^dSTCUa~&9Ftc~3#cS1?U_+vTwrK;iA=4}O+*@^~ z8&~FQhb{pc>-!i!=m4g+oe+3mWOHQxDh#b}TC9tx!Zmwl$h`}C{`Kkr;N2!^d5AqM zc#MCQ7EW>ijERQt#KVg2L5AzE+{O}`AhdAT;{p4_4us*&ZtmD@%P`bI9xI12JmY}S zJJ|s^HX)0`)9WQ`c|H*R3*k6ow5t`vz&~9qH1zH#h4q$o++k!fv0ZvP)AI}eNShyO z>{Y-wa%gmuwq0s*RDmqlUum13r5vR&`0-Oqo}p~mH8wsk*CSJ>=!J!J?6*6yX&MaWf&_MA zJ98^W!t6-%lby~j&R9n(wvaf)K2V+ImP8}{#MlCyX`}P>c}t(!kn8^6dTR%|2||4n znA$c%=%cl-V6W}EDi+B$${xvS|EY^LEpGMzQ|4@Dzt`HHIm%dnB}QtAa<)JTg{W29 zbgm-jn=SN=0kA($8{n-x{`~H{1)IKB!uE|udFX&<@*3xCGcI_gIlV&mooCVjcImwf z$K(VL2C$`A6eDWe_7XnRA_xiwEA%}9ZE$X=wU6<_;Vx?q>}7|y$0Rpv!j%)J46%6_ zjA&h6Rx!clx~sfRw?|)V?z9MrFE>=+s8?7`+%aB+B%NuI`j{KI_8nE9TPiLz6zK?y zB$*Sr`kY&=48Uy)@qK&ZIsq{zNEMjbq2B>GFWN3^ZS}8eu(GFI-nKM^|N6W%CRdWYzE@E*gla@J3{vhg>_t>C8RG zHuh}|`$HL~fL}#s;M1%X1po$x7V1Wnq76<~kB4P_p+;Bwnb|3*lijqKQRuu<%I2zs^W z{B`m*4&qEmVlAXLl@iOXWA4pH6++G1r9J2djHF>o0sJ(lE|-3(2QAz!ZVv%1W-QSL zcMm7jcMpM8SgKG)VQkZD*c|u|CP&0raJbHzpf+s)YbjCB?Xr&f$mU#KX5UW7yau4g zI!%dHp(5rH+*tlaG!&vgeh-()%Y(~_S?XGxpW1KQFQA2aH!k4 zB%V*Um2&s72xDaLHe8-ObQ58NtI?@p=gWy+67)*0t3{mZ@{<8{4xjkonWc)&v!rpdB%#Gnrg0&$M$ieU`W|y)Zj^A`Gl5dHaiZs}sF36{opzj!bwJ_|FRju=$p2*89XMUfu9v;6ObOMYnxE#m6qQ5o&|~a*mzAZNJ&r4JzO?gI zubP>*z3qF=JDLhz3gnks!_<;qi^OAY#DZDjxylLCC!&%5Pi`9re4!4eIo$G>U&3mC zS(|lYoAA@6v{B`ud5y^;(cOwR`UYm`04XEJH_ul9;5wwiB`zE!9 z8_~!$5ga5XUcwP zihA(V!6J!eNZ9#n@iVUnG*1h5=mmnM75*$tFvRi+-V<8(Qs5L7GFB`VO19TKL#4-&#g!(wh`@s%xUNFy zGZk?0&%g1kpYVcLnKEd3>ta zk)Cw1V=54FsNDP;o$?fize3$&MJ}s8beDT~)UQ~q!+cN^Np|(h5p0~)Q0g)i>$n1Y zhAw+9k2;W%7E1(Y99%F#hDs$LVl)bWUh<0IW{o3xNgjexiOvx3D8MF&F!I-7X2-7c zc)?vw^Bz*f?qkk_P1T;-@T1uzNf&>GV8?q^c#@xL(M}&(^Q=NetBz-(ik^$ z;Lhg#KZ-F$F8C(a4BF)=vne4h!Q>IWZa zc0vwRuAfs0G9g916qLoIV8quIJyG!REZy=K_Hj*LC+amZ=;kw zN;;-N`rE_u93s>-!PisC`KJnRjH3-#gOE7D^^@Ie+$KfI;Wd&en)#l*MK3*PbdC@(m5 z92ph6Dps$zJH|=Y^R0N8rzA@YzNHKMfxqoRnT^~%JbfecE1)T)RmNK1t;;DHJ&UJY zKkD_=Nc`z;xLrqte{9vjS3i*KUb54nK>VNc?6jq>sxhvxyFq0E{*kqe3G(htJpzTS=wPZBr>eJu^nVz5UI{Z*2wct2wB zf7R<{*3~{yl1S-QG&Lq0H^{KxOZZ_Ob|q8#K2{RiLOSL#qOVPPRr3RoYkG59IZiE5 zOlh4=3bhZ`VL77IQ$M8fWwZueOT%3=-^s&6taz68MG&6jyQDA zMJ(vk)vq^_CP}cqSEP(WY=ppEOKdH}GsRlH1Y{X((l(We;G8hu_}!jp$E}gQEA>J| zH6@zC@|$P>YN3yK&BVJug72LcCNxWgxa8G;s38YrjiK90#R8Oeh0M8uwdNWbR};23 z;OSQfh9xaw7iSF7i&@+3`~l734Bfa07jq29`|L6 z&J0JUsX*<)R#|%hv`P%!J15t|wQ`jh>_PdG@uw+ohh9zE!ghrTpSn(_Bcp@~ZXS4| z^L=a{0QV%6c;n0x{>oJ0+-{KbrIAk31y{LKFG)Fpi@_CkeJR-;mj(5cw)>&7g(PyS zg(Rw4@uy;mY}+hH(@CPHIl&0BsJK+1lLJym)>m&8$E*ULF1{=M2D?H=qjn}HaO7}n z#7G-cyKngNh;Vu#rN>|0YF!!n+KufLJvTXho7(*Af$)Y_HHlVNby{yGzX?GdRx+Cd z71%OQm3}a660UZ3n`sFQU+4jtB(RdbIEQk6eu7-wdqpMjU8mw@pR2^-x~>~Xmwu;q zKEQUJq`mo@*qA0lamEiAv#t<*VdRKjnLwxnC*fd+K5SP_?cDVuKO0WTl!q$5l7RE* zFpA!(gZfNO)Y%igvnK#Prr`$S0#kId`;~rl9`}TT^78^@V_AXT?r2Y|4ZZQ}M8_JT zIy)QOk~3Of`KL3_3I9#HO4|F)P*c${PsWuDd57u;m@JXUnhw+0^Wm-@DlgGCEpE60 zOn1j#BoAHIcfBGLgg5g5qX#q${McPNQ190obY=pB{_&L(MFuczV@8j;&?_IlA zf=B=v9;M0fdwZ`rO-96tv}4VRdkw#XNGDhWhV;I>WjDv=4b`DDBu^=tTFxnIW(RV5@(iLq4d4rR=>imLOXP zFvdLlH?zd9lSXs~*-B2rSzIuk6=@VZ-2g74fI42T$kpQWd@iIyKxZJ%+jY;q^PVqN zrEKZtvRv52a@h{k7LuN`-~R304$wBQ8r!sDfaYD1R5f(1vJ$tVW05j)(IlEU3KCuJ z^1=Fp#^|-_ISg;(ekat3t)UprNHrfKlmFR#z9wO1TTIXc)(6lF2~iPeX`P$yswWn@2TX+ zVIF^7&6elX6C2DOiOsWt^w~E!uy_kep-`i?r)6qi=T3+Un#!!XZ@oUEW7}S#F^jF= zm76fFkJ|eyjs~W!_lj#pTpCDO*DD7Trt6Ndu*xrkab{McYoBy1G^A!3Us8~tM=tZm z4z&+u0vl)hY{L#Ld-z?PgY8D2F`4hjF}QXceu_b>ehs!ou$eA@?56nDZdmZ*ZI5D) zOf9J)a!W?v-Es{nKxO_p@%7#7%@uETC+=K^tY#q-4NqiTRs z$xGyh?W&cmF++5JR0B{6^1<7zLHJ5Q9r?NUcl(NA2k=XfItNqA9k%Xr6F z)4a>Bc#Xbo@U@;)dE#z-aJ60+2d4Ji67j2_PQ0^BWgmf-^QWk$i(OMqElwnO7H-yE zNgR87IYU12!NPQ1&0GYDjg;1)d*?N8T{`Jej5RHT>8xl>79uu?899*RU+egwA#N6&rBVhYl-1NLhS!IAy7pzLKEpq((R*gX;l9*I5#4R zaRIJdFzjjUWYdextJ^+^)0zJkah}pM3w=p4CO-F<`ZE}S2sWeMFo{UzccpW5Md)?% z^>7(h4PRtuQ{j8L6^XX%{#irKdt!#t&k~-Q!%6%5S|L0!)0p$yc))*}psac9j5fq4 z2LsO%;gq>isQX-K(m(4M;oAdT)$Zs~t~31x{GqEDqfFVl@%qcXqw_-<9oVIC*XaI& z;HM%q?PIt%BU!TmYxUm>8$QqJIP-|5oPEEJ)9qummla*qH#ZG z=qA1Wd6eumE!t8QGe~g&V`i!h&;*v4d%WyFZHB#Gigtbxy{UU%*upzFEF#ZfNC99|)9qig;vSem>k z+q45Lc+R`rPZ1;vf%^iH>~*Fa-h@|Yo~zhGEJNiM^L)9~xL*2Yk(AkOQz;}TuFCe;nUNp#I(QX~=_#j|mSh?5fx8NxXDDoBfkjU@mu$8<(1tHAGcmiO_f` zX(P*M^@P$bqX`iVz+Cz5zCdv=EsORQkH}_3*8JCJ$ljBZye1Ba7n#h#oQtfZ6qGH~5v!y6 zWQY$MLyFBcig+w?M?0y~yn_q=xBPzz={%%T91(yF+94b{W1X2UzG#c8QIserf(A|I8eVRPr zqxqvyd8p7IAGq`Ke>LO(^)={!b@BhDw*U27n%cj?$p7!7|CiVP+n}NU-Q@pWn@#_Y z*JDztc8PcQ{TZD00GZ-H>i9nw{y%@?e`)al@wNY@=mUYPO`Jl(VSl-o&b^o$QyahI zchJgN_dikr)dvsQHx12n)Nee$scV_?eqyC?$UTVBC}~XoJ695|7<1fwc4qo^ZV|I6 zeQnF1)RR-v@Qc$4d-;9Wy~LIf3md2LDC}14-00oo@s~zj-OtE!Kbm7zWM1OAdg^T7 z>Djx7LQEgtv6;~-6@6)kt%hv>nL+?uYeW^yNm|A(+l}=3@|wY0fm2sPP7OzoO?u5t zFItl+vy86FzR9F0!jl%sQ!?7c>nkgH@b#VIklnc6NQ;$d&DCr9cU7wBYfGc^Kf0`! zMzf%!R8QjbnblKPA1kdsXST1}1mDPnQ=+W)Ohis}=@-Vc?NlIym^@K8CSriD5fM&)ZblU*SJWH=W@RxE17b)UDR!LzEG#u@;}&pIKvm+NHca$+L+1t3inyD~)e3 za`=xRnTgFg`&K{G!mh`=XSry#b#+6BLV5Po(%Z(;bDuK95Jwq-$ppfavDuKTW?G7` zLlb65C3fcOw1Kd5*LP|`aJv3{DEw~7BfrGls?0YbS9K)YA*$9=)&S_Gdya6Ao3ON% zm8=&TRfKMq!jJI#bUn+ei+0!VW(1+L+37RWw3~k_x*1RRMJLaW&~z4@sz(Mj=IQ*> z;Q|MusgJXY_UQ`faWw%|EcnQT?rv4}U}f?7nmf6a_$7MwJ6P>5)r#JC>Eis| zB#02=>gD4D-8D&Kn0LxFzYDFkOEQtkdYn|QBM2jDIkGgBlYFI>G=mP^j9iWWp*TI1 zIl|kLV`*+RqT0UNH22Z(w}OQbSuQyp+=N>Vjx|~Ihyu;FmPD!dKcI1h*A@_&jx|z; zdmRsLY+uqn!!ESIuZ;`qcl0&mSlu7t5&D|vS+nLB?_$^NICd%nR#zWTS=Q*L+8xPk z>srA@d5{fIf{`QqlCJ>q5hvNA0MDviFuy{ptx4LP*z?{lJ1HT0R3z3e=at${4QJTZ za;0jsj~Mt@2Uo4q3*BQqlefl|#-!JLqF%l(5dY*HNH;Twki_oeyYFvR&(GE*v0-hZ zPUTb01=~JN{s~9Ctqt3s7ivUWyj10{8gsF6&BPg1Gp_)YSOsJk_yWAoDz$lbG46VlS zW7OY;<@x(OW-P2U<<&dVp25xkh<`A_%)Eu_*DqDB88<=5KB7sGQQMUEWV{jH+scy# zG3*+ufkzT-7n^%UVoBD7q}gDrqtPQG5B^xiOB2FEM6FW0uY>R70Ll0#n1Tq#sk{x@ z$X@psR64DEE}k#ir{NT>hgL~z4a=|A4CMdQ=tf7c#~7DnyiYbJ(xEK4>Sx4LFBgZ0 zvP~`$6Qt?z^_%)bId0^c=6*@@RNAL50r{PR5P68n_^rp7TF*lh9@pr@vlj`>DOKga z1r0xQlKo^{G?;?#>kDOIb&gTm`JU7Gg`lkiI|ae0%J^De_>*q#7)r*qe=~W zXl7=@_mfYt<5>ak{RalJWUkd2uo#BY*jfDVaw(9@X1BXmwWsUVFqBfI+^CzR6#D6v z%*#A7CX77hNN1spt&R~(pHR-S2OdJ-sAr}3k9HI0yo1qmKgWux=Ak9!AyvJ{H~ZAh zc}SvBRKAWJcm{kwzN}Cp60UDq>@u3|&9hU^6ylLTev=nl+m0q^m|wt*D9%VLu$y#< z+|!$~CeMeJYsC?}>A(ojlKIdF{}kaL_vXcFx1(LOxZWl5OGiqrT=rah`;+-`d-XGySzb(y=&X3U6R||>jnAcn`Uov}!_kEF?@>v*2Hm5ofO8m5# ze|_jIJp8)g?)aZlc}z0BREgo2Tvs3*^U^A*MM`d$U2}=F)_6peKO<_R_P*=RtFmm* zD}Q(dV;d%VCPT3w7yMkZUrR(jr8P3C@cruCqq8a*Smts%lyL1c@?za7DZO8X$aQf;}rp<*~kCwR;=OPrlkg`G@>oQuc*{o>L zU*!;YNyY{xH#z@lG(MnlvwUuNR$#!=JT#pb+bh1iB?lXA#e6;IOyn7$4vf#w&Swqr z?Yu7!3EE|s{hm~|vd}S*PD#SNS6%xi7e+@96=Sv92>!+J5?j#CK4|fO8{r`^3tnfg zEC5G_A8a{`Ci6+1<|Ju~GXB;V$c|QtHKOZr|66_KJw5Y|u5xQh^Xpzr#7jF` zT#^DON&JC~t>x&ZaiX62#Y&@f@14O6#z%1{=mH9D{dM7C4*|)jIG3N5CS>DiX5nT; zzX+K*uR2X4S(+PF{T}=&kfvNrS+qm}_Gb2GS@U5aOVk&^rcp3gOEl@`ld{>#OYS1XQO&+nVmFSD z)HvTlV|cCm=s#H z^F>EOp%;-Ov&g#eBtnFVJnx*igW)(>MjICB+lf*f1y(W7qsYB{nb(MuuDU zN)VYl^Pvv>Utw0x{q(fL z_V!8bj_UOyiVt*Fgp8rfld9cY|I+}A?xn)R1**1|B`$iyRcMsUO{JokS{XSeb>2C+ zXp>-m@vPD6u^mkU4q(HjFgUU98g6qR4 zG*F?>4;RF_+(aEBcTfy@!;22Tl+j=!K0TY4F%RYXQ~5!3qxnkh{FUmFmLpMZE*~|> zh8@QE3sdnxU;>(DXT=#krTIrA9W(So2`j57S4pn@m`Zhtn{$WB#!2_e=XfqpXfS&} z733t{XMOVd)2W7?^@X(CMM|5j%6suHR@gH<&p9W$!`I;>LjOsfuP}#NEDD-_VbUzh}fa#3x9eaSFYVgFdLXIq-#rU z>{*LTzCW>K|1*Bz!n*moQO+T9_R#MbV|Ag)F2HSBiz~y-Jk-xHaPd@c<|ADE-TWt- zbA9%&m&4=!C@vr4+pt<&=x#S86dluZ83choR_fx@6zS#(gr*Jj*kLGl3H7Ve$Rju{^OPt?*>+GDe2 zJMOLPuwHbT$(|R^Y4{dyOVDOQI^*X32aoJj_iO7eY{SLyZI z_Usv+2fx3^7WS=FBQ9-Wz<)sX5%x@wWDI=2&$so8nzFE{f1A`KkMZV}w<~tZdG(0K zr!fn%^eYOT_WO+4Im_(4A`ejoc?(-_RqT?Bi$EoO!Z8I&)l0wZ7s~&qk{9JQ!5Z`U z0dIQ8Q-SGy@ED8J?D+DF5e6mhiKF`Stm2{Hvo~;BGaz=Zw`W>6-y{)_GuZQ11e@#x zqtyo+65N*>X9jkR{|tLTf-sC%0+gRq}PF-sOVrE3b>S;#)ru9zWc%`IzNv)OZ}??{3CQR9Iaw&v_4mX zkBHlTE#?rJqDlH;FRYrR`Rh3Pci<+3z}G!1d{+TuUMs5(JqQ#brq7Ny^l|sibvpjX zLss4lDqgz1{x&>S4<2)nr+H~ewf(@|yE9cgLjoepMmurZj_KInoPo710UAF#Vld;kUcULIGaUjC0w0^cpYv)B%2#cL{ z&8bY<&IdH+C!aYxI}!h%idh42>#n32pw*hb|9|=ti#2`>iYC_7136wapmY*&-v;8dr(?{8fQ zAq1uy=R?+;9L$0IPY;vl?bH2}HGjw1rVzu2qXYN6Uxz;@^Y!lE-6GI2!f-7e*!d{X zv9oP4xDdVdt*63{ll$knDUyZD)+}vPrXzdr&MRObbTnhdOA3kwPH*miaVkV)ExzJ{ z%N?0N0H++r&;iuDa*d$Fl z%f9uGS!XBh8()}knG}CP->IG=C6%V$) z9RF=(8HNxLPur=sPfxfYX66lv-RpFUegE&8GfqKVP^{iT&a4PHTXrYF&$0}lwc3yV z`&1RObv=?{hfUtI8+6BjU{gPOtJ>H1_b_qDhcI3=I2)iQ{@xUSak_v#`qbdsf_vh> zPm>^#Wt-*Ujs_OM{K2@NuBd@)4~Fk(>_wXZE~n!jYnXNM@ncXkYF85>u-8bJ_jiXl zWJ4HE!PT2(BR98DCn4E6P-6QCYt()LyV~%(eB54~UCwe`RylLVIqhIMQMC^!f3dxtLEpZ%J z^=@XiLLkcDqi~}`Tk93tV+SIBdpLQBwst>OV3e-&7ckd1&pGp%jF(ETJ2gKqfJ9<9 zi^InzIuN`#h;o|<* zU?9*A$H(*NbBiD{eo6dncMgnR{5=_@Kq6}*ZN^ImOR9ltQ+74YR%%h6M7Rt5wnt84 z&(}47b73biNOV5RlUscweg_$#Qq00Y-u#Vz4Z9_#IUE8ksA(wwogpPj&t3IT;r>u~ zWaQI17l`gx5%O!_h}}C40%h~TlFhl3FbGe(8jr*_JMg55dXe7EXBdRzNj$NG4W>|p-37v%ww6; zxL&-DBf8WqDeqJXy62I_mUBsdHFZ1*Os70q>?|G1Q=k9z&~jBF9KK!3eRm5sn|;SD zfcyI}{&`Im67@xi==iTC@5U)kITym&ekNG35mvMR6__>U_hk0&&c83S85Ml<-}%2R zn@&F#^7n7i!M<2%Q1zTed*%SW26Lrg*Pb&S!f?rmuFi_tYwLfya#_9*UJGw^29n@+ zwN$#C;@Y!a6>y4~m9X)$32R9ZFJ9OufiMi>4Wsx-y5sBhD6%_W19o6Yc#@oaiph6I z@gx@`Y*hO90N9NyAYJR%CJ^k!deL~cNoA*~5F)v4XdtfUNJYrz zk!CXAnSy<>p`&)v$p$}t&^x^b6rZ$8n;oF!e-(;h`zbL^>ZqXVa39z%4xUX1RyM#4 zI=|o{caB{&Hj<3^VvgRKGQl%LDs479i8$tei4U^br9{l5WOa;E}wGKdr^%0Z+5oJRL4&s8uLI6QXG|C7g!2pR#NZvc=BtHL7@A*Q0 zIrq5MeeFABpCWCVDG3cnM9ZAz^ie+KGLHFkrGX*W{0TqD4fpp4dnAVnY}UlylTB&; z?R*MHJNd!xZ3(1w%*J6(df<~s!a6UAh&nezNEhMydOq!*JiVGs3)3}H7oU<8yrZko~0d^Zm&c%Q+_NAq(qzK~w*W%2tL_U7GH z+yFuvwMk97=~?yuQ4i2v;8}vI=R0Yd^W+x524n_18NxOhVA13UF)Oeu)CSx`I|TD1 z#hXxi(Y==Mw>!GhOTCCPIBGx4t2);|RiOPT^Tol;VUuCOQCk zB<>zoDTx|uy&kQM<=uftXIM_%sVar}?eo%+;^9(K=%U?tmtZJ6Q-?);S-w!X>-~DD z?N_6Lo?88AdrENG8s5bxcqnhwO!f0v%$jgSg}cT;3{R<_l>QHG0YH0&s@B(%bXcc% zu5E?*_GrM{M{^*r$PVViuG;Z97zgeq6G7GtF31M*67Kf#m~q#!=-NgIPg_^5nPqDE zIw|)i&X=66V(VbGqr?s=$xi_gj{y*U&WNsd^@SK46AvBHVlJ~vd5mGfTU4Jx$*cuHPpNe-Z(@8HG5i3KRBHc z=mdG)en;?F?}viD9ImxVgiK?=H5f2l3nAY;FcwS$s)jr>16l?7>MtV!mFG>fp%}~J z$5Ih;{bM3{$-7gV-I`?N4!H`fxLNdxe|r$Wws@x{EC0%mG;09Kt>qGG91#iIs-FR( z+T}usKwWgE&@SbOwR+A1W2_?e4d|%%-bk`bi}Ia%)0D?Xh%@LK4KWUdJ)7h})%(~! zczfa!grwi2Qlw_Dx^vZF_zeTnxK0OzoC2G?^wAM)=Uf&(D@VKu?Bu)ASQX#rjF>;y ze~l!jG%LzVX^VcF0}wViEPnLsJp{;*YYm#~dz9;P=nD`so&ijn`se`9Xx+#~ASgzq zV1Rk3-WSLkH9&v`2c5D9p`B0EdI;}~=Krc#NV#@&j=aBCzu4be2w#N5koa7*rF73f z93fk=bu^+xgK6HaD2Xwd_6|@mlva=&*xCK=*~0Pl3y#e$}P!6MQ_W=RM*GN zw?z(U^njscB?unJ&LbFZuwg0vJ_Bo7Y@Dm^AC>v(#A4YV$QvjFN5eP+M4b(KK}FPc zwOViw(w&I!J!y#kA9M%*OnWqc0OI~-DtJTJL0Si>+4-?)2DjJ-3gHTiqGa3IK8V19 zl`ZH{^^?OrdcOPzA`MNW?td6CXTdq2FqT_^Rfv?n+J(Tq(mw7q zl>CugzO)5U-ph>KQ|kdk<0JI~6zFk)DH*+S6~LLe9G>7xf|Az)Vea{jqg&ebX9&yh zDmWLX9IOZC)WZ2xo%kd=yS@lnDQYFe43HQv*_JYPUN@snYQ;r2dqH=rT)YervuZbD z77az8H@t5i0dJ#a(K^83JaceB5yv&}F_b>g&xE-C1XMoawG^jXu!f8xpy*QQnBHlM z0A*Kr0;N3~vO5R(wmd-(>5q{A2V;nc9C+7GK(~k-*f1@zVL_L5TMxeiD!IskNWS7f^61h)H#iLe-a;mr6nILM017s(_;j%HU>*ynuodT%mUY z?@ar&#X_X@kRi*3I6|bs+fxQp4CLD<3`~{~G})NfdU5#IiaTU|1{J;fl-P5(PH0X! zq54tGALc=>-^*v;>ZPn3K=Sg*0CLQviz)onI4B|krcCkKJP0eUkAgxbB5!f>b;`mx z7h-VR`IZa8^4m^}XD}0|bkqw0ZuaX@!-(f$x`P6^0n%$Q01UN7 zLX{(TU;1jD;Jk3RQ`(?!PPjO2)T=GSZl_~`&L2KOV~J0 z$VNTitttHd;zX^|pSI$P*@cKIZA%1zK70AHY0vk$T1Ans?pIGf&_mk-2(s3VxD+Op zYY4=+0iubJvekl%dhtB~k=(GPkDCqd|HA04U5K2oi`JC&L-PRX%DgG-Em-jEUjV&y zE(ugXhg4M~6OY%7<5n9(uFs|tBE_L--8R)`sCv=#Min2Tq`t-BiLieI^eZqJ!!IxD z34vC|^mvd~&eH;dNme+)zmPN2p%m;yd4)cn{;PN=L5UN?H1;c>q!KQ07S0=u_6 zfdSG!)kY}!M|p~;)*f9BWL>Wt*IR2-L9C~C?N4UIv3i;xh9)5X0aKt%RQ*}V;q`fG zO~HZO+toe5DcJ6h!Y?BB@20^lrr2$Ty1RCC-q`^da#v1#t~;}?2cd8WG;K&L;&+u~ zT0-9_3`_B^b68FShf#>TD2v&rQ`^J5EL z9m#zw&G{rcK!8y*OnhQd8WB|df4TmZNp$28}lYB@PxgNHl z6eEF}ctZ5=D-C*bA*DUV2#cFprxTJD5Lt8(c$dWuTh*U)zeF#6E);%nU81Es)b@0e zQ^y|oRVS84g5;Y9jD2C>tBi}?oHEy6!o1Y6-kdd_YB@DNQ`l&I3N>u>NE}Xl9mmGO z5P(`^=@73^AAO0Z!1Oj%l3{|lh$3Z4T}MAFheZiZO}Hl?`;h0`<4 z(^in;kqzsik~{r>;<~$$#e|EnBk!Y%JAyl*a)SXfXPuMQrUiw8pK*I)=)-oT$a;yD zmoTD=V3_pU&<9%A*Xi#C`I}tm+goKh?vt3hu zGO9LEH8SKb^irfsrBtDm-~5e^63UyA#Y~XtD-O(J;ej`${TeWj^WY~8wepopL$GxR z(d28+5IcP8*chTxKo;cBoXko^37zS=9uRQj6FvyW6KcnQfENW;%AG^)=8eBKwDc)G zp%IT)EyU=w21rOB%VD!a$8mRzA*T+oZtH+s(?Uef4UQLqfJ|ANEwBZNku%FA9+C6^ zhbCJX+3|sQR&+G`lu5CTr{c4yhv=L`N7TPH#k&h2@&iD`<~)o4?H5GW|9m@bZ^r(L zsS%IiILLq$*3ec!PO|Od+!w>;N2&csu2aYNH__nU~)Y zqz#@A`S5taO^_~nrW4^6kT|L)?O=bq?FPtcbPCXlU7i*O4+IIW8~9DemOl7j6tab1 zMAK$^Z6FJp+F3#_O4Sb}gaoqqt3c!$wNsGbr&j`RV**gL{dKKm9GH-FW`r+|Tz?RR zJKEJjCg@&TXK5g-2?MP#5PwQiD6CQM{BvH;7zL63KRNs|#2G z6*tgjgwh(4ba<$!bpQqdRE(q}G1KLZKZQL9p}y;zS!Qsnv*g&AIsDp3>OhGFS?Y5j zvArE++%dCF83mfdeMkcelnS-K=QKW-hE%5$2i9Ch_!vO8}4({=XAHO znId+`e0Yy)IS2rVOS{XOc){mcP+O{M_GtM??3bcPA#^Ad)HQC+=GX!wW0OfxOrt?2 z8=mWW5+e26&S?+k|2oIM*t0-A8(;oWvpCipe#`v?TrWH|ikCGx=6-OMfnn0%d-0$+ zp_0#O%|j9+)y=>YWR}&%`CT5jTLh%Y-&H*^RnunUIFMcA2(R)zMCuOuRvrkDVsO!v9sM2%k75kvo|DkJtz+`{KX?6$aNeOo(iQy%=pa`QhsU8Oj9!zIU=NV`8>n z(yLz>%_%$QNASfyGXnemVjyohkPo;9N0rdG|6KH)VakR?RBhfhByNp=^>_F0Du#2+ zeej~;p-^613HUt>U11~_nvj5OWw4C;vSB^|;sWzJJEVC6WHGf5IR5|0c(~Rf@62k|K~a_If8*me#%(`_d4P8aRv{S42N`Sp9dt z_U$dXGNPQG(;9*>&+*DdUo}%xdh||XVBA#9A3d%!q$##Z@of`96R%A)a^SRH>j|fF zN`9zL$Xg}}A({#jft2h%&+KN0D31=>lVB*8?Dj#I=J5E`I-bmLV+xJ(8+P|L!)jM` zAs_sH=hm*Cy2!Ta)9&zCP@=t4?qPhs42NFwOFh8ZFhE_1j*X412$SjQE4K)Z9sT}a zX43t;OAQRqGnH6!%;>Y(&0i1;cy>j^E@}Ex^EW6Ff^0i3Yx6X_sulL}z@c{;xjvcB zOTvE)mq8a_IZF>)*DMNYo4HIye8y(Qayjo4F--sSBCu6g#Z1L9@*3hrb#V zp@BtBo@OX2_-yHXZ&f0i?p6HpJk#4xL&9{m^KzSb2z@}wyOy~>7L?BYS7l~C;b#<=x2P=Ri zIGoplT#M)1$9_;8aO0<*)GxIw&fPdGOVlJv%RX&#VoaJ+nN#yya)sUua2|NX`PF3L z!$ubcY`Jy`{*AgRCKQ{R-0V;LRalK5_nEO2v{zu$td}-Bcj+d zi|Jhv)m{6PfBj1@1l+NWoOtS-T?3rj4!ar6$AVE!5+QD zQ}J8wLP@`aa`b5D%uwrr(SfWQ&4=I4~Mc%+S%ZSuo)4 z_mo~au^$gkd0U2RHmy8{#$GII9o75lE2~o7MInLipSM`qN5>1rb&;;2`JN(Yc!lqw z@Kxfx?az;%Y*Jvs`-%5rS>)VMr`TR^8lqjTDc3vMA{^C)H!TWqTGwpW;!|o%;$&?e zRUCM%PAo@a3zj30xb&Yi(Rq2wT7^s`20d#x9`FPIvEZI>NOfEEN zgUcTJc^b|8xW~Y-pg~SdE&pj@l)h~Ub%U?G6SKHp#PQory}$yAc6B%!ZajLbL(37> zR#*u77}-=39^;H3y-ct$tPYTmFPN0YgJ!4NksFH5G{gY`T~wEAM4BzYdn&ssI^49| zQD$K1^te+T&y*E69;6?=z#I)<3R43#V17Ny)AS|16%~{aJv48`k>c=Mpus?KX?YQ6 zKY8&er=ujYr~+{yKwsJG^kaFoeRn5--fDs$;>Y@Mv1nJ=5w_IO?Y0PGe2XhUNysz8)?Qh)dGrwOuB9bj(WAW(N z=PwR~s)4_$1s){oJb%Ik)2O&0ez3NQPgPBeiO??XH1TYR2&%hb(|E8Fj3~&Nx`+=> zmyPsvdGl)KC9jy8`F5?rcNT>11Si$AAz-$8B#U8<1A6@eK?7k;%p5tXV`C-G!?<8x zs_Fd&macC*#r{mfZAH9l;I_y`2QuJbAgL^Ti<9|mSVsZqpp2QCjb3aYGI)>QsyKc- ztuN_=v`p9923m)IpvLVK^np%24K=N!r8IMX8es$X-Z{RPyi**^L+53aNL{vMe(F^i zYQV+ZnMy_IN!sce)xEU6mf4Der8&he(<5sH6gJVScLrS z^x3MBc0uxa=D78lvk}94&hqEkX9ky{%X}j-^5E%SDx%bk1fQoG6-?gg5+Y8scXmr5 zDE_5*FSYl&D(SN-Au*i;{A8K9DWnS$D2cO@`)RM;=c-Pgd{|JLNtpAfkGaa=yVB&h z(rt__-qXr5&~17r7;*nVuiD?&d779!BN7)(sUEpIeA?f@+itPfw6L%*@{LXY#dAkrT=L!w2!zNpRt!BaDgLq6;)YfBkw! zuphyY+-IAm?n&A)#c^6YHIcL%Fw1Ea!*mkV59O685-xFo8$0=>C-%;iFIZj18!!1| zDmuMlCa1;w>0y9!m!zqWmat#OrWNhS?H>(at)pslXBU;Z*~xS^Bk;$@4)?juB+Bw) zg0co|@~tZQ53}@!7!l!4FKB}g_|m6)V^KhUi5U~{f?Qb`JipI-W@5`%Hy2oD_{;l8 zWX}6M*h4vaq#qyiH^Q3$nJY*?7Mmw&0`1HRK{-^HaWs)*-oicnIKlLWnjV_rhQ-It zVbbV9`tgRq%0z*tV*ci{rA?rVH`ZyLdQJ;Si8_$_RvO?F@)Q_6J!TG*)BI)1@{}!w zN#BXKpsWNA!~! zG9B^c?sp}Wvk}%L!mR4&j`@cUIp8%lBc|&HQfuG|;DoVjzuYUrq+ks{bzHNDY5Ea}x z5n8<$&+r$Xj%hE+@c!8dw3U*;|3bngh5X?wmCp%5fS#89xP;--3ObJUiBzMYV zzq!E>REvLFCaEZq5D%*qKLpA%KJG_wHD|589L&R&73CYe@!-u)ZjHUrg6`Gy49&Cp z<7tG~dn=C9GLs@SxiBGzMyEMv;W`|`Jz!=6h8XnSB^{m9g0K6kJsv1ozG5n6`0*ZN z?{D&E%ot7^)wSTK^SsoS_(e<*q-2n#QHWOzu7o^KKX&2Q9BRJb((&YuNiI4+yFKHC zB_Ii9tw~){AS2_5DsC0X7jXu=i#G4%4}~KE4Je&p(47AS9#EzYriNiEy1WL=j(OGJ z$^A5L{~q+>`-gTg9Kfxi^O1#m_D9zVZ=th?f&wiW{Xt2gaTi(~oLmhEtdWRq^$nJc zajWCr+o!_7-JQB4>I8s2gQ`Y6RN%xOkDv-0I&I%gY&lyW3Os@Zs`51c5V!$l`@8YG z!7cKc+)3H?j`>b+@3q&RtP@|EjGCw*BeQ|1JHF`?U7tJ@Hz$$M>$OY~2d<#S&3U{V zW0Jb@! zCoB5Ra%x8NmQ>_oNpQSX2l7Q&p?nBjbo)DD7hmQlQ($8ouOLy-5|q7vt8`NJuwtYw zl;yBwENPxR>p~$Yr{nS7-!D+7T-YXC!X*E@6twxQann== zA7QA|R1F6z!WBpz2pq-7Z&N)Bi+a7(1h;u4Okv)8L2t@H@(CN(Q1bakrSZE&Q#oHf z=_Z^wtjRsyDggJ(7@h7PiS))z(rK9=+lv`JYL7&t;Cvjkbl?dmJA>StUk7b%?haZo zaEU#d@!4EJXD~t!&01&5?rT&rdmwy#-Wa3SRo-PQ!M}Qv4iiH=eO~^blEWt%6-&}9 zDwe3)0}xqvgbWnmc*o!ZA<)mzl`W9lZAAZN3lt-j*# zLJ6kbwo7SkTS3F2HG@}b^OTlXau)Bg^ZjFxfLQ55$$w}rxajwK_C*SRO&>`U7b=s* zW6`s(VNYCp!E}a+BPcgMy55(!tN+s9b?lBk-;=`VFd=4 zBzY>LPGXsh#h+BQnpet_+c)ubn@`dwa#rdga=bDt@fI#zk%(rN0xDLmsUU z2i$qRsGJP!^VNdHqR&MQ?gMQvQr6`})yWzcwRx1@dx~Ufv{Lf-Kaz*oxwr9cTd&t) z#;s5A+qa63U>UJ_f6N|#A%lt%8n4LSgG+tsZ_|^NVkx6y^+4P6;WqZhc7(P-Ru*Bg zX^DTGEM@Lq(^nD+E-^NDy_;|yuzZ})VLKHTWZ%TrJx(MIHgLr`zYe@?D!I}0+O{&P z^rpv=>=qEI4AH5nI_n%tN?)38B0`Djl${ab9I_wUE)i>`Vc!IJr1wx62f zID>ybSp5b5;;@`k6MwMXIsVgK69fy6zd2bVf;9->dZydXX9(SkPy5^Hgj-+G1= 0) + outline = cyberware_mark_outline(target, mark_color) RegisterSignal(viewer, COMSIG_MOVABLE_MOVED, PROC_REF(on_end_moved)) RegisterSignal(viewer, COMSIG_MOB_LOGIN, PROC_REF(on_viewer_login)) RegisterSignal(viewer, COMSIG_QDELETING, PROC_REF(on_end_deleted)) @@ -78,6 +82,8 @@ RegisterSignal(target, COMSIG_QDELETING, PROC_REF(on_end_deleted)) reposition() refresh(duration) + if(outline) + cyberware_mark_outline_reveal(outline, outline_delay, duration) /datum/cyberware_hud_mark/Destroy(force) if(timer_id) @@ -86,6 +92,7 @@ if(viewer) UnregisterSignal(viewer, list(COMSIG_MOVABLE_MOVED, COMSIG_MOB_LOGIN, COMSIG_QDELETING)) viewer.client?.images -= mark + viewer.client?.images -= outline if(target) UnregisterSignal(target, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) if(registry) @@ -96,6 +103,7 @@ registry = null registry_key = null QDEL_NULL(mark) + outline = null viewer = null target = null return ..() @@ -113,12 +121,19 @@ if(isnull(viewer_turf) || isnull(target_turf) || viewer_turf.z != target_turf.z) // Nothing sane to draw across a z jump. Park it until they line back up. viewer.client?.images -= mark + viewer.client?.images -= outline return mark.loc = viewer_turf mark.pixel_w = ((target_turf.x - viewer_turf.x) * ICON_SIZE_X) + target.pixel_w mark.pixel_z = ((target_turf.y - viewer_turf.y) * ICON_SIZE_Y) + target.pixel_z SET_PLANE_EXPLICIT(mark, ABOVE_LIGHTING_PLANE, viewer_turf) viewer.client?.images |= mark + if(outline) + outline.loc = viewer_turf + outline.pixel_w = mark.pixel_w + outline.pixel_z = mark.pixel_z + SET_PLANE_EXPLICIT(outline, ABOVE_LIGHTING_PLANE, viewer_turf) + viewer.client?.images |= outline /// Pushes expiry back out. The Doppler leans on this: a contact that keeps /// moving keeps its blip, a contact that stops loses it. @@ -153,10 +168,10 @@ * out. Add `registry_key` for an associative list; the mark strikes itself * off either shape when it dies, so no caller is left holding a dead ref. */ -/proc/cyberware_hud_mark(mob/living/viewer, atom/target, mark_color = "#ffb347", duration = 6 SECONDS, mark_state = "sonar_ping", list/registry, registry_key) +/proc/cyberware_hud_mark(mob/living/viewer, atom/target, mark_color = "#ffb347", duration = 6 SECONDS, mark_state = "sonar_ping", list/registry, registry_key, outline = FALSE, outline_delay = 0) if(!isliving(viewer) || QDELETED(viewer) || QDELETED(target) || viewer == target) return null - var/datum/cyberware_hud_mark/new_mark = new(viewer, target, mark_color, duration, mark_state) + var/datum/cyberware_hud_mark/new_mark = new(viewer, target, mark_color, duration, mark_state, outline ? outline_delay : -1) if(QDELETED(new_mark)) return null if(isnull(registry)) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 3f8046ddb59..2157dfdf234 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -72,6 +72,7 @@ #include "voidcrew_crew_hud.dm" #include "voidcrew_cyberware.dm" #include "voidcrew_cyberware_anim.dm" +#include "voidcrew_cyberware_anim2.dm" #include "voidcrew_cyberware_camo.dm" #include "voidcrew_cyberware_deck.dm" #include "voidcrew_cyberware_final.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_anim2.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_anim2.dm new file mode 100644 index 00000000000..d0ef1a837c5 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_anim2.dm @@ -0,0 +1,280 @@ +/** + * # Chrome animations, second batch + * + * Like the first batch's tests: each animation puts its holders, filters, + * looks and timers on, and they come off again when it ends or is cut short + * (the chrome pulled, the bearer dead, the sequence cancelled). + * + * Unit-test files compile before voidcrew/_DEFINES/ and the module's own + * defines are local, so looks, states and filter names are literals here. + */ + +/// TRUE if a chrome animation holder playing [state] is drawn on [bearer]. +/proc/voidcrew_test_anim_state(atom/movable/bearer, state) + for(var/obj/effect/abstract/cyberware_anim/holder in bearer.vis_contents) + if(holder.icon_state == state) + return TRUE + return FALSE + +/// Redline: the window lights the nodes and the halo, the crash cools them and +/// slumps the body, and the end, a death or a pulled core leaves nothing behind. +/datum/unit_test/voidcrew_cyberware_anim2_redline + +/datum/unit_test/voidcrew_cyberware_anim2_redline/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/redline/core = allocate(/obj/item/organ/cyberimp/cyberware/redline) + TEST_ASSERT(core.Insert(bearer, special = TRUE), "Test fixture: Redline insert refused") + + bearer.apply_status_effect(/datum/status_effect/cyberware_redline_window, core) + TEST_ASSERT_EQUAL(core.burn_look, "flash", "The nodes didn't flash when the burn kicked in") + TEST_ASSERT(core.burn_look_timer, "The nodes flashed and never ran red") + TEST_ASSERT(findtext(bearer.icon_render_keys[BODY_ZONE_CHEST], "flash"), "The chest wasn't redrawn for the flash") + TEST_ASSERT(bearer.get_filter("cyberware_redline_glow"), "The burn has no halo") + TEST_ASSERT(core.burn_light_ref?.resolve(), "The burn throws no light") + TEST_ASSERT((locate(/obj/effect/temp_visual/circle_wave/cyberware_roar) in get_turf(bearer)), "The roar rolled no ring") + core.set_burn_look("hot") + var/found_nodes = FALSE + var/found_eyes = FALSE + for(var/image/part as anything in core.bodypart_aug.get_overlay(EXTERNAL_ADJACENT, bearer.get_bodypart(BODY_ZONE_CHEST))) + if(part.icon_state == "redline_hot") + found_nodes = TRUE + if(part.icon_state == "eyes_l") + found_eyes = TRUE + TEST_ASSERT(found_nodes, "The red-hot nodes aren't drawn over the core") + TEST_ASSERT(found_eyes, "The eyes don't flood red") + + bearer.remove_status_effect(/datum/status_effect/cyberware_redline_window) + TEST_ASSERT(bearer.has_status_effect(/datum/status_effect/cyberware_redline_crash), "Test fixture: no crash after the window") + TEST_ASSERT_EQUAL(core.burn_look, "cool1", "The nodes didn't start cooling in the crash") + TEST_ASSERT(!bearer.get_filter("cyberware_redline_glow"), "The halo outlived the window") + TEST_ASSERT(!core.burn_light_ref?.resolve(), "The red light outlived the window") + TEST_ASSERT(bearer.get_filter("cyberware_redline_sag"), "The crash doesn't slump the body") + var/obj/effect/abstract/cyberware_anim/steam = core.burn_steam_ref?.resolve() + TEST_ASSERT(steam && (steam in bearer.vis_contents), "The crash vents no steam") + + bearer.remove_status_effect(/datum/status_effect/cyberware_redline_crash) + TEST_ASSERT_NULL(core.burn_look, "The nodes kept a look after the crash") + TEST_ASSERT(!bearer.get_filter("cyberware_redline_sag"), "The slump outlived the crash") + TEST_ASSERT(QDELETED(steam), "The steam outlived the crash") + TEST_ASSERT(!core.burn_look_timer, "Node looks keep coming after the crash") + + // Dead mid-burn: the picture goes at once, though the window runs on. + bearer.apply_status_effect(/datum/status_effect/cyberware_redline_window, core) + bearer.death() + TEST_ASSERT_NULL(core.burn_look, "A dead body kept its red-hot nodes") + TEST_ASSERT(!bearer.get_filter("cyberware_redline_glow"), "A dead body kept the halo") + TEST_ASSERT(!core.burn_light_ref?.resolve(), "A dead body kept the red light") + + // Pulled mid-crash: nothing stays on the old bearer. + var/mob/living/carbon/human/next_bearer = allocate(/mob/living/carbon/human/consistent) + core.Remove(bearer, special = TRUE) + TEST_ASSERT(core.Insert(next_bearer, special = TRUE), "Test fixture: Redline reinsert refused") + next_bearer.apply_status_effect(/datum/status_effect/cyberware_redline_window, core) + next_bearer.remove_status_effect(/datum/status_effect/cyberware_redline_window) + core.Remove(next_bearer, special = TRUE) + TEST_ASSERT_NULL(core.burn_look, "A pulled core kept its node look") + TEST_ASSERT(!next_bearer.get_filter("cyberware_redline_sag"), "A pulled core left the slump") + TEST_ASSERT(!(locate(/obj/effect/abstract/cyberware_anim) in next_bearer.vis_contents), "A pulled core left its steam") + +/// Lazarus: crit starts the charge, death or a pulled node ends it, and the +/// jolt flashes, bursts and springs the patient without leaving anything on. +/datum/unit_test/voidcrew_cyberware_anim2_lazarus + +/datum/unit_test/voidcrew_cyberware_anim2_lazarus/Run() + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/lazarus/node = allocate(/obj/item/organ/cyberimp/cyberware/lazarus) + TEST_ASSERT(node.Insert(patient, special = TRUE), "Test fixture: Lazarus insert refused") + + patient.adjustBruteLoss(120) + TEST_ASSERT(patient.stat == SOFT_CRIT || patient.stat == HARD_CRIT, "Test fixture: the patient isn't in crit") + TEST_ASSERT(node.jolting, "Test fixture: crit didn't start the windup") + TEST_ASSERT(voidcrew_test_anim_state(patient, "lazarus_charge"), "The windup has no charge glow") + TEST_ASSERT(voidcrew_test_anim_state(patient, "lazarus_arcs"), "The windup has no arcs") + TEST_ASSERT(patient.get_filter("cyberware_lazarus_charge"), "The windup doesn't light the body") + + node.jolt(node.jolt_generation, WEAKREF(patient)) + TEST_ASSERT(patient.stat != DEAD, "Test fixture: the jolt didn't take") + TEST_ASSERT(!voidcrew_test_anim_state(patient, "lazarus_charge"), "The charge glow outlived the jolt") + TEST_ASSERT(!patient.get_filter("cyberware_lazarus_charge"), "The charge's halo outlived the jolt") + TEST_ASSERT(voidcrew_test_anim_state(patient, "lazarus_jolt"), "The jolt has no burst") + TEST_ASSERT(patient.get_filter("cyberware_flash"), "The jolt has no flash") + TEST_ASSERT_EQUAL(patient.has_offset("cyberware_lazarus_spring", PIXEL_Z_OFFSET), 3, "The jolt doesn't spring the patient up") + + // Dead during the windup: the charge dies with them. + var/mob/living/carbon/human/doomed = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/lazarus/second_node = allocate(/obj/item/organ/cyberimp/cyberware/lazarus) + TEST_ASSERT(second_node.Insert(doomed, special = TRUE), "Test fixture: second Lazarus insert refused") + second_node.play_charge_fx(doomed, 1 SECONDS) + doomed.death() + TEST_ASSERT(!(locate(/obj/effect/abstract/cyberware_anim) in doomed.vis_contents), "The charge played on a corpse") + TEST_ASSERT(!doomed.get_filter("cyberware_lazarus_charge"), "The charge's halo stayed on a corpse") + + // Pulled during the windup. + var/mob/living/carbon/human/donor = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/lazarus/third_node = allocate(/obj/item/organ/cyberimp/cyberware/lazarus) + TEST_ASSERT(third_node.Insert(donor, special = TRUE), "Test fixture: third Lazarus insert refused") + third_node.play_charge_fx(donor, 1 SECONDS) + third_node.Remove(donor, special = TRUE) + TEST_ASSERT(!(locate(/obj/effect/abstract/cyberware_anim) in donor.vis_contents), "A pulled node left its charge") + TEST_ASSERT(!donor.get_filter("cyberware_lazarus_charge"), "A pulled node left the charge's halo") + +/// Leaps: take-off offsets the drawing toward the landing and only the drawing, +/// touchdown puts it back, and a leap that never lands is put down anyway. +/datum/unit_test/voidcrew_cyberware_anim2_leap + +/datum/unit_test/voidcrew_cyberware_anim2_leap/Run() + var/turf/start = run_loc_floor_bottom_left + var/turf/landing = locate(start.x + 2, start.y, start.z) + TEST_ASSERT(landing && landing.x <= run_loc_floor_top_right.x, "Test fixture: the test room is too narrow for a two-tile leap") + var/mob/living/carbon/human/jumper = allocate(/mob/living/carbon/human/consistent, start) + + cyberware_leap_takeoff_fx(jumper, start, landing, 0.2 SECONDS, 20, 26) + TEST_ASSERT_EQUAL(jumper.loc, start, "The take-off moved the body") + TEST_ASSERT_EQUAL(jumper.pixel_w, 64, "The leap's drawing doesn't end over the landing") + TEST_ASSERT_EQUAL(jumper.pixel_z, 20, "The leap's drawing doesn't end in the air") + TEST_ASSERT((locate(/obj/effect/temp_visual/cyberware_leap_shadow) in start), "The leap has no floor shadow") + TEST_ASSERT(jumper.cyberware_leap_until, "Nothing watches for a leap that never lands") + + cyberware_leap_touchdown_fx(jumper, 20, 0.1 SECONDS) + TEST_ASSERT_EQUAL(jumper.pixel_w, 0, "The landing left the drawing to one side") + TEST_ASSERT_EQUAL(jumper.pixel_z, 0, "The landing left the drawing in the air") + TEST_ASSERT(!jumper.cyberware_leap_until, "A landed leap is still watched") + + // One tile hops in place: a one-tile move glides. + cyberware_leap_takeoff_fx(jumper, start, get_step(start, EAST), 0.2 SECONDS, 20, 26) + TEST_ASSERT_EQUAL(jumper.pixel_w, 0, "A one-tile leap slid sideways") + cyberware_leap_touchdown_fx(jumper, 20, 0.1 SECONDS) + + // The pistons pulled mid-air: no landing ever runs, the failsafe puts the body down. + cyberware_leap_takeoff_fx(jumper, start, landing, 0.2 SECONDS, 20, 26) + jumper.cyberware_leap_until = world.time + cyberware_leap_failsafe(WEAKREF(jumper)) + TEST_ASSERT_EQUAL(jumper.pixel_w, 0, "A leap that never landed stayed to one side") + TEST_ASSERT_EQUAL(jumper.pixel_z, 0, "A leap that never landed stayed in the air") + + // The real button, end to end. + var/obj/item/organ/cyberimp/cyberware/hopper/pistons = allocate(/obj/item/organ/cyberimp/cyberware/hopper) + TEST_ASSERT(pistons.Insert(jumper, special = TRUE), "Test fixture: Hopper insert refused") + var/datum/action/cooldown/cyberware/hopper_leap/action = locate() in pistons.actions + TEST_ASSERT(action.Activate(landing), "Test fixture: the leap refused a clear two-tile jump") + TEST_ASSERT_EQUAL(jumper.loc, start, "The leap moved the body before it landed") + TEST_ASSERT_EQUAL(jumper.pixel_w, 64, "The Hopper's arc doesn't travel") + action.land(landing) + TEST_ASSERT_EQUAL(jumper.loc, landing, "The leap landed somewhere else") + TEST_ASSERT_EQUAL(jumper.pixel_w, 0, "The Hopper landed off to one side") + TEST_ASSERT_EQUAL(jumper.pixel_z, 0, "The Hopper landed in the air") + + // The Piledriver's impact cracks the floor and leaves it alone afterwards. + cyberware_piledriver_impact_fx(landing, 2) + var/obj/effect/temp_visual/cyberware_impact_cracks/cracks = locate() in landing + TEST_ASSERT(cracks, "The impact left no cracks") + TEST_ASSERT(cracks.timerid, "The cracks never clean themselves up") + +/// Brownout: worn chrome stutters together and the show leaves no looks behind, +/// on its own, on recovery and on death. +/datum/unit_test/voidcrew_cyberware_anim2_brownout + +/datum/unit_test/voidcrew_cyberware_anim2_brownout/Run() + var/mob/living/carbon/human/bearer = allocate(/mob/living/carbon/human/consistent) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = allocate(/obj/item/organ/cyberimp/cyberware/cascade) + var/obj/item/organ/cyberimp/cyberware/test_heavy/heavy = allocate(/obj/item/organ/cyberimp/cyberware/test_heavy) + var/obj/item/organ/cyberimp/cyberware/test_overflow/overflow = allocate(/obj/item/organ/cyberimp/cyberware/test_overflow) + TEST_ASSERT(lattice.Insert(bearer, special = TRUE), "Test fixture: Cascade insert refused") + var/datum/component/cyberware/chrome = lattice.GetComponent(/datum/component/cyberware) + + // At rest the lattice breathes: its LEDs replace the still glow. + var/found_led = FALSE + var/found_still = FALSE + for(var/image/part as anything in lattice.bodypart_aug.get_overlay(EXTERNAL_ADJACENT, bearer.get_bodypart(BODY_ZONE_CHEST))) + if(part.icon_state == "cascade_led" || part.icon_state == "cascade_led_e") + found_led = TRUE + if(part.icon_state == "cascade_e") + found_still = TRUE + TEST_ASSERT(found_led, "The lattice's LEDs don't breathe") + TEST_ASSERT(!found_still, "The still glow is drawn under the breathing one") + + TEST_ASSERT(heavy.Insert(bearer, special = TRUE), "Test fixture: heavy insert refused") + if(!chrome.browned_out) + TEST_ASSERT(overflow.Insert(bearer, special = TRUE), "Test fixture: overflow insert refused") + TEST_ASSERT(chrome.browned_out, "Test fixture: the body didn't brown out") + var/datum/component/chrome_brownout_show/show = bearer.GetComponent(/datum/component/chrome_brownout_show) + TEST_ASSERT(show, "A brownout put on no show") + TEST_ASSERT_EQUAL(lattice.cyberware_worn_look(), "warm", "The brownout didn't start with the warm stutter") + TEST_ASSERT(show.step_timer, "The stutter stopped on its first frame") + while(length(show.steps)) + show.advance() + show.advance() + TEST_ASSERT(QDELETED(show), "The brownout show didn't end") + TEST_ASSERT_NULL(chrome.brownout_look, "The finished show left a look") + TEST_ASSERT_EQUAL(lattice.cyberware_worn_look(), "brown", "Browned-out chrome isn't drawn browned out") + + // Back under: the stutter back on, then plain. + if(overflow.owner) + overflow.Remove(bearer, special = TRUE) + heavy.Remove(bearer, special = TRUE) + TEST_ASSERT(!chrome.browned_out, "Test fixture: the brownout didn't clear") + show = bearer.GetComponent(/datum/component/chrome_brownout_show) + TEST_ASSERT(show, "Coming back up played no stutter") + TEST_ASSERT_EQUAL(lattice.cyberware_worn_look(), "brown", "The way back up didn't start dark") + + // Dead mid-stutter: the show ends and the looks go with it. + bearer.death() + TEST_ASSERT(QDELETED(show), "The brownout show outlived its bearer") + TEST_ASSERT_NULL(chrome.brownout_look, "Death left a stutter look") + TEST_ASSERT_NULL(lattice.cyberware_worn_look(), "Recovered chrome isn't drawn plain") + +/// Cyberdeck: a door glitch comes off on its own, and a Ping mark's outline +/// goes with its mark. +/datum/unit_test/voidcrew_cyberware_anim2_deck + +/datum/unit_test/voidcrew_cyberware_anim2_deck/Run() + var/mob/living/carbon/human/hacker = allocate(/mob/living/carbon/human/consistent) + var/obj/machinery/door/airlock/door = allocate(/obj/machinery/door/airlock, get_step(run_loc_floor_bottom_left, NORTH)) + + cyberware_glitch_fx(door) + TEST_ASSERT(door.get_filter("cyberware_glitch_slices"), "The door doesn't tear into slices") + TEST_ASSERT(door.get_filter("cyberware_glitch_red") && door.get_filter("cyberware_glitch_cyan"), "The door doesn't split its colours") + TEST_ASSERT(voidcrew_test_anim_state(door, "glitch_tear"), "The door has no static") + cyberware_glitch_end(WEAKREF(door)) + TEST_ASSERT(!door.get_filter("cyberware_glitch_slices") && !door.get_filter("cyberware_glitch_red"), "The glitch stayed on the door") + + var/datum/cyberware_hud_mark/plain = cyberware_hud_mark(hacker, door, "#4dd8e6", 5 SECONDS) + TEST_ASSERT_NULL(plain.outline, "A plain mark grew an outline") + var/datum/cyberware_hud_mark/outlined = cyberware_hud_mark(hacker, door, "#4dd8e6", 5 SECONDS, outline = TRUE, outline_delay = 3) + TEST_ASSERT(outlined.outline, "A Ping mark has no outline") + TEST_ASSERT(outlined.outline.filter_data?["cyberware_mark_outline"], "The outline isn't outlined") + qdel(outlined) + TEST_ASSERT_NULL(outlined.outline, "A deleted mark kept its outline") + qdel(plain) + +/// Chrome cradle: the rig follows the install's beats and pulls back and parks +/// when the sequence is cancelled, with nothing left in the gripper. +/datum/unit_test/voidcrew_cyberware_anim2_cradle + +/datum/unit_test/voidcrew_cyberware_anim2_cradle/Run() + var/obj/machinery/chrome_cradle/cradle = allocate(/obj/machinery/chrome_cradle) + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent, get_turf(cradle)) + TEST_ASSERT(cradle.buckle_mob(patient, force = TRUE, check_loc = FALSE), "Test fixture: couldn't buckle the patient") + var/obj/item/organ/cyberimp/cyberware/redline/street/ware = allocate(/obj/item/organ/cyberimp/cyberware/redline/street) + ware.forceMove(cradle) + + cradle.begin_install(ware) + TEST_ASSERT(cradle.busy, "Test fixture: the install didn't start") + TEST_ASSERT_EQUAL(cradle.rig_phase, "arms_down", "The arms didn't come down on the first beat") + TEST_ASSERT(voidcrew_test_anim_state(cradle, "rig_arms_down"), "The rig isn't drawn coming down") + cradle.run_sequence_stage() + TEST_ASSERT_EQUAL(cradle.rig_phase, "grip_down", "The gripper didn't come down on the second beat") + var/obj/effect/abstract/cyberware_anim/carried = cradle.rig_ware_ref?.resolve() + TEST_ASSERT(carried && carried.icon_state == ware.icon_state, "The gripper isn't carrying the ware") + TEST_ASSERT(!voidcrew_test_anim_state(cradle, "rig_arms_down"), "The last move's rig stayed drawn") + cradle.run_sequence_stage(TRUE) + TEST_ASSERT_EQUAL(cradle.rig_phase, "press", "The gripper didn't press on the third beat") + + cradle.cancel_sequence() + TEST_ASSERT_EQUAL(cradle.rig_phase, "retract", "A cancelled install left the rig down") + TEST_ASSERT(QDELETED(carried), "A cancelled install left the ware in the gripper") + TEST_ASSERT(cradle.rig_park_timer, "The rig never parks") + cradle.park_rig() + TEST_ASSERT_NULL(cradle.rig_phase, "The parked rig still has a move") + TEST_ASSERT(!(locate(/obj/effect/abstract/cyberware_anim) in cradle.vis_contents), "The parked rig left a drawing") + cradle.unbuckle_mob(patient, force = TRUE) From 235500756939ff7df31eee083dec5543a6bc67cd Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:00:50 -0800 Subject: [PATCH 153/472] Make the Ghostskin and Flickerskin fields flicker in and out The fields used to sweep down over their bearer behind a mask, shimmer and collapse with a crackle. Now the bearer stutters through hard steps of alpha with a pixel of jitter as the field comes up, the same steps run backwards when it runs out or is switched off, and something knocking it down gets a sharper stutter and the sparks. While the field holds there is nothing on the bearer but its alpha, and moving still flashes it brighter. The weave's description and the lines bystanders see say the same, and the sweep's mask, band and edge art is gone from the animation sheet. Pictures and wording only: the field's alpha, length, triggers and every number it runs on are unchanged. --- voidcrew/modules/cyberware/cyberware_anim.dm | 223 ++++++------------ .../cyberware/icons/cyberware_anim_large.dmi | Bin 52597 -> 52354 bytes voidcrew/modules/cyberware/ware_line_camo.dm | 2 +- .../modules/cyberware/ware_military_body.dm | 49 ++-- .../unit_tests/voidcrew_cyberware_anim.dm | 85 ++++--- 5 files changed, 153 insertions(+), 206 deletions(-) diff --git a/voidcrew/modules/cyberware/cyberware_anim.dm b/voidcrew/modules/cyberware/cyberware_anim.dm index 65af5de972a..66ba722bea5 100644 --- a/voidcrew/modules/cyberware/cyberware_anim.dm +++ b/voidcrew/modules/cyberware/cyberware_anim.dm @@ -6,8 +6,8 @@ * the mechanics call into it after they have already happened. * * - Mantis blades swing out of the housing and fold back into it. - * - The Ghostskin field sweeps down over its bearer, shimmers while it holds, - * and collapses inward with a crackle when it breaks. + * - The Ghostskin field flickers in and out: hard steps of alpha with a + * pixel of jitter, and a sharper stutter when something knocks it down. * - The Cascade's spine nodes ignite neck to hips, a ring rolls out across the * floor, and the nodes die hips to neck when the crash hits. * - An EMP fills every worn piece with static, arcs between them, kills them one @@ -334,160 +334,91 @@ #undef BLADE_FOLD_TIME // ========================================================================= -// GHOSTSKIN FIELD +// GHOSTSKIN FIELD: THE SIGNAL FLICKER // ========================================================================= -#define CLOAK_MASK_FILTER "cyberware_cloak_mask" -#define CLOAK_BAND_FILTER "cyberware_cloak_band" -#define CLOAK_RIM_FILTER "cyberware_cloak_rim" -#define CLOAK_WAVE_FILTER "cyberware_cloak_wave" -#define CLOAK_RIPPLE_FILTER "cyberware_cloak_ripple" -/// How long the field takes to sweep down over its bearer. -#define CLOAK_SWEEP_TIME (1 SECONDS) -/// Mask offsets: the front starts just over the head and ends under the feet, -/// then the mask is moved clear of the bearer. -#define CLOAK_FRONT_START 45 -#define CLOAK_FRONT_END 5 -#define CLOAK_FRONT_AWAY 200 -/// The refraction line rides one pixel under the front: from just over the -/// head (pixel_y 2) to under the feet (pixel_y -38) on a 64x64 holder. -#define CLOAK_EDGE_START 2 -#define CLOAK_EDGE_END -38 -/// When the crackle follows the inward collapse of a breaking field. -#define CLOAK_CRACKLE_DELAY (0.2 SECONDS) +/// How long the field takes to flicker in. +#define CLOAK_ENTER_TIME (0.6 SECONDS) /obj/item/organ/cyberimp/cyberware/ghostskin - /// world.time the sweep finishes. The moving shimmer waits for it. - var/cloak_sweep_until = 0 - /// Timer that swaps the sweep for the held shimmer. - var/cloak_settle_timer - /// Timer that clears the inward ripple after a break. - var/cloak_ripple_timer - /// Timer for the crackle that follows the collapse. - var/cloak_crackle_timer - /// The refraction line sweeping down with the field. Weak: it deletes itself. - var/datum/weakref/cloak_edge_ref - /// Who carries our filters. - var/datum/weakref/cloak_fx_ref - -/// The sweep mask for a field alpha, built once per alpha value. -/proc/cyberware_cloak_mask(field_alpha) - var/static/list/masks = list() - var/key = "[field_alpha]" - if(!masks[key]) - var/icon/mask = icon(CYBERWARE_ANIM_LARGE_ICON, "cloak_mask") - mask.ChangeOpacity((255 - field_alpha) / 255) - masks[key] = mask - return masks[key] + /// world.time the field finishes flickering in. Moving under the field waits for it. + var/cloak_enter_until = 0 /** - * Raises the field visually: the bearer stays solid while a mask sweeps down - * over them, then drops to field alpha the moment the sweep ends. The alpha - * var holds field_alpha from the first tick, as it always has; only the - * picture takes the second. + * Hard 1 px jumps sideways alongside one of the alpha chains below. [steps] + * are (jump, hold) pairs; the jumps are relative and add up to nothing, so the + * bearer ends where it started however the chain is cut short. */ -/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_sweep_in(mob/living/carbon/wearer) - clear_cloak_fx() - cloak_fx_ref = WEAKREF(wearer) - cloak_sweep_until = world.time + CLOAK_SWEEP_TIME - animate(wearer, alpha = 255, time = CLOAK_SWEEP_TIME) - animate(alpha = field_alpha, time = 0) - wearer.add_filters(list( - list("name" = CLOAK_MASK_FILTER, "priority" = 20, "params" = alpha_mask_filter(y = CLOAK_FRONT_START, icon = cyberware_cloak_mask(field_alpha), flags = MASK_INVERSE)), - list("name" = CLOAK_BAND_FILTER, "priority" = 21, "params" = displacement_map_filter(icon = icon(CYBERWARE_ANIM_LARGE_ICON, "cloak_band"), y = CLOAK_FRONT_START, size = 2)), - )) - for(var/filter_name in list(CLOAK_MASK_FILTER, CLOAK_BAND_FILTER)) - animate(wearer.get_filter(filter_name), y = CLOAK_FRONT_END, time = CLOAK_SWEEP_TIME) - animate(y = CLOAK_FRONT_AWAY, time = 0) - // A rebuild of the bearer's filters mid-sweep lands on the end state, not the start. - wearer.filter_data[filter_name]["y"] = CLOAK_FRONT_AWAY - // A pale line on the front, inset so it only lights the bearer's own pixels. - var/obj/effect/abstract/cyberware_anim/edge = cyberware_anim_play(wearer, CYBERWARE_ANIM_LARGE_ICON, "cloak_edge", CHROME_ANIM_TOP_LAYER, CLOAK_SWEEP_TIME) - if(edge) - edge.blend_mode = BLEND_INSET_OVERLAY - edge.pixel_y = CLOAK_EDGE_START - animate(edge, pixel_y = CLOAK_EDGE_END, time = CLOAK_SWEEP_TIME) - cloak_edge_ref = WEAKREF(edge) - cloak_settle_timer = addtimer(CALLBACK(src, PROC_REF(cloak_settle)), CLOAK_SWEEP_TIME, TIMER_STOPPABLE) - -/// The sweep is done: drop its filters and leave the shimmer and a faint rim. -/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_settle() - cloak_settle_timer = null - var/mob/living/carbon/wearer = cloak_fx_ref?.resolve() - if(!camo_active || QDELETED(wearer)) - return - wearer.remove_filter(list(CLOAK_MASK_FILTER, CLOAK_BAND_FILTER)) - // The rim fades with the bearer, so it is drawn bright for a faint result. - var/rim_alpha = clamp(round(40 * 255 / max(field_alpha, 1)), 40, 255) - var/rim_bright = rgb(216, 244, 255, rim_alpha) - var/rim_dim = rgb(216, 244, 255, round(rim_alpha * 0.7)) - wearer.add_filters(list( - list("name" = CLOAK_RIM_FILTER, "priority" = 22, "params" = outline_filter(1, rim_bright)), - list("name" = CLOAK_WAVE_FILTER, "priority" = 23, "params" = wave_filter(x = 0, y = 8, size = 1.2, offset = 0, flags = WAVE_SIDEWAYS)), - )) - var/wave = wearer.get_filter(CLOAK_WAVE_FILTER) - animate(wave, offset = 1, time = 0.8 SECONDS, loop = -1) - animate(offset = 0, time = 0) - var/rim = wearer.get_filter(CLOAK_RIM_FILTER) - animate(rim, color = rim_dim, time = 0.8 SECONDS, loop = -1, easing = SINE_EASING) - animate(color = rim_bright, time = 0.8 SECONDS, easing = SINE_EASING) +/proc/cyberware_cloak_jolt(mob/living/wearer, list/steps) + for(var/i in 1 to length(steps) step 2) + var/jump = steps[i] + var/hold = steps[i + 1] + if(i == 1) + animate(wearer, pixel_w = jump, time = 0, flags = ANIMATION_RELATIVE | ANIMATION_PARALLEL) + else + animate(pixel_w = jump, time = 0, flags = ANIMATION_RELATIVE) + if(hold) + animate(pixel_w = 0, time = hold, flags = ANIMATION_RELATIVE) /** - * The field is down: clear its filters and, unless it went quietly, collapse - * it inward with a crackle. The fade back to full view is break_camo()'s own. + * The field comes up: the bearer's alpha stutters through hard steps and eases + * down to field alpha, jumping a pixel sideways on the way. While the field + * holds there is nothing on the bearer but that alpha. */ -/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_break_fx(mob/living/carbon/wearer, silent) - clear_cloak_fx() - if(silent || QDELETED(wearer)) - return - cloak_fx_ref = WEAKREF(wearer) - wearer.add_filter(CLOAK_RIPPLE_FILTER, 24, ripple_filter(radius = 26, size = 2.2, falloff = 0.6, repeat = 5, y = -2)) - animate(wearer.get_filter(CLOAK_RIPPLE_FILTER), radius = 3, size = 0, time = 0.3 SECONDS) - wearer.filter_data[CLOAK_RIPPLE_FILTER]["radius"] = 3 - wearer.filter_data[CLOAK_RIPPLE_FILTER]["size"] = 0 - cloak_ripple_timer = addtimer(CALLBACK(src, PROC_REF(clear_cloak_fx)), 0.35 SECONDS, TIMER_STOPPABLE) - // The field folds in on itself first, then crackles out. - cloak_crackle_timer = addtimer(CALLBACK(src, PROC_REF(cloak_crackle), WEAKREF(wearer)), CLOAK_CRACKLE_DELAY, TIMER_STOPPABLE) - -/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_crackle(datum/weakref/wearer_ref) - cloak_crackle_timer = null - var/mob/living/carbon/wearer = wearer_ref?.resolve() - if(QDELETED(wearer)) - return - cyberware_anim_play(wearer, 'icons/effects/effects.dmi', "lightning", CHROME_ANIM_TOP_LAYER, 0.5 SECONDS, glow = TRUE) - wearer.mob_light(2, 0.8, "#9fdcff", 0.5 SECONDS) - -/// Takes every field filter off whoever carries them and stops the field's timers. -/obj/item/organ/cyberimp/cyberware/ghostskin/proc/clear_cloak_fx() - if(cloak_settle_timer) - deltimer(cloak_settle_timer) - cloak_settle_timer = null - if(cloak_ripple_timer) - deltimer(cloak_ripple_timer) - cloak_ripple_timer = null - if(cloak_crackle_timer) - deltimer(cloak_crackle_timer) - cloak_crackle_timer = null - qdel(cloak_edge_ref?.resolve()) - cloak_edge_ref = null - cloak_sweep_until = 0 - var/mob/living/carbon/wearer = cloak_fx_ref?.resolve() - cloak_fx_ref = null +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_flicker_in(mob/living/carbon/wearer) + cloak_enter_until = world.time + CLOAK_ENTER_TIME + var/faded = field_alpha + // The alpha chain first: it replaces whatever the bearer was animating. + animate(wearer, alpha = max(115, faded), time = 0) + animate(alpha = max(115, faded), time = 0.5) + animate(alpha = 230, time = 0) + animate(alpha = 230, time = 1) + animate(alpha = max(75, faded), time = 0) + animate(alpha = max(75, faded), time = 0.5) + animate(alpha = 180, time = 0) + animate(alpha = 180, time = 1) + animate(alpha = max(55, faded), time = 0) + animate(alpha = max(55, faded), time = 0.5) + animate(alpha = 125, time = 0) + animate(alpha = 125, time = 0.5) + animate(alpha = faded, time = 2, easing = SINE_EASING) + cyberware_cloak_jolt(wearer, list(1, 0.5, -1, 1, -1, 0.5, 1, 1, 1, 0.5, -1, 0.5)) + +/// The field runs out or is switched off: the flicker in reverse, back to full view. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_flicker_out(mob/living/carbon/wearer) + cloak_enter_until = 0 + var/faded = field_alpha + animate(wearer, alpha = 110, time = 0) + animate(alpha = 110, time = 0.5) + animate(alpha = max(60, faded), time = 0) + animate(alpha = max(60, faded), time = 0.5) + animate(alpha = 175, time = 0) + animate(alpha = 175, time = 0.5) + animate(alpha = 105, time = 0) + animate(alpha = 105, time = 0.5) + animate(alpha = 235, time = 0) + animate(alpha = 235, time = 0.5) + animate(alpha = 255, time = 1, easing = SINE_EASING) + cyberware_cloak_jolt(wearer, list(-1, 0.5, 1, 0.5, 1, 0.5, -1, 0.5, -1, 0.5, 1, 1)) + +/// The field is knocked down: full view, a dip, full view, with hard 2 px jolts. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_flicker_break(mob/living/carbon/wearer) + cloak_enter_until = 0 + animate(wearer, alpha = 255, time = 0) + animate(alpha = 255, time = 0.5) + animate(alpha = 140, time = 0) + animate(alpha = 140, time = 0.5) + animate(alpha = 255, time = 0) + animate(alpha = 255, time = 0.5) + cyberware_cloak_jolt(wearer, list(2, 0.5, -4, 0.5, 3, 0.5, -1, 0)) + +/// A quiet drop (the weave pulled, or switched off while not worn): straight back to full view. +/obj/item/organ/cyberimp/cyberware/ghostskin/proc/cloak_reset(mob/living/carbon/wearer) + cloak_enter_until = 0 if(!QDELETED(wearer)) - wearer.remove_filter(list(CLOAK_MASK_FILTER, CLOAK_BAND_FILTER, CLOAK_RIM_FILTER, CLOAK_WAVE_FILTER, CLOAK_RIPPLE_FILTER)) - -#undef CLOAK_MASK_FILTER -#undef CLOAK_BAND_FILTER -#undef CLOAK_RIM_FILTER -#undef CLOAK_WAVE_FILTER -#undef CLOAK_RIPPLE_FILTER -#undef CLOAK_SWEEP_TIME -#undef CLOAK_FRONT_START -#undef CLOAK_FRONT_END -#undef CLOAK_FRONT_AWAY -#undef CLOAK_EDGE_START -#undef CLOAK_EDGE_END -#undef CLOAK_CRACKLE_DELAY + animate(wearer, alpha = 255, time = 0) + +#undef CLOAK_ENTER_TIME // ========================================================================= // CASCADE: SPINE NODES AND THE TIME RING @@ -896,7 +827,7 @@ /obj/item/organ/cyberimp/cyberware/ghostskin/cyberware_anim_clear(mob/living/carbon/old_owner) . = ..() - clear_cloak_fx() + cloak_enter_until = 0 /obj/item/organ/cyberimp/cyberware/cascade/cyberware_anim_clear(mob/living/carbon/old_owner) . = ..() diff --git a/voidcrew/modules/cyberware/icons/cyberware_anim_large.dmi b/voidcrew/modules/cyberware/icons/cyberware_anim_large.dmi index db43facbce20a092e46a516cd02ab5fad3d4a997..23e25f724d1094f682d482750ef610760d67d575 100644 GIT binary patch delta 6938 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 diff --git a/voidcrew/modules/cyberware/icons/cyberware_anim_monowire.dmi b/voidcrew/modules/cyberware/icons/cyberware_anim_monowire.dmi new file mode 100644 index 0000000000000000000000000000000000000000..354d6891846f9e32a05447c261df6823707a6719 GIT binary patch literal 7378 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi b/voidcrew/modules/cyberware/icons/cyberware_lefthand.dmi index 280d20d0b70c47eaf1b9f846f3625cd4915019d4..3b03a18474e96692bc7d35cb21c1d21ff62cc5e1 100644 GIT binary patch literal 8431 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 diff --git a/voidcrew/modules/cyberware/icons/cyberware_righthand.dmi b/voidcrew/modules/cyberware/icons/cyberware_righthand.dmi index bc538a37bcb57be6af323118b208f225cd8b90d5..6311eeb65925bbc326f329ba380abe4ddcc18257 100644 GIT binary patch literal 8252 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 NH`KIM3$9u{{vXVaJwE^d literal 4433 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ি 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 EKTPwvfB*mh literal 3204 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/hyperviolence/overlays_exec/sleeve_mask.dm b/voidcrew/modules/hyperviolence/overlays_exec/sleeve_mask.dm new file mode 100644 index 00000000000..45593347aec --- /dev/null +++ b/voidcrew/modules/hyperviolence/overlays_exec/sleeve_mask.dm @@ -0,0 +1,64 @@ +// Hyperviolence: no empty sleeves on a stump. +// +// tg masks the gloves over a missing hand (update_worn_gloves() in human_update_icons.dm, a render_target +// alpha mask) but draws the jumpsuit and suit over a missing arm or leg, so every stump sat on an empty +// sleeve or trouser leg. This masks both the same way, for any lost arm or leg however it was lost. The +// masks (icons/sleeve_masks.dmi) are tg's human limb sprites less every other body part, a pixel wider, +// so the torso keeps its own cloth. Bodies of another shape get no mask: theirs would not line up. +// +// Sorts after gore_overlays.dm on purpose: it chains hv_update_gore_overlays(), which runs whenever a +// limb comes off or goes back on. + +#define HV_SLEEVE_MASK_ICON 'voidcrew/modules/hyperviolence/overlays_exec/icons/sleeve_masks.dmi' +/// Filter on a masked uniform or suit overlay. +#define HV_SLEEVE_MASK_FILTER "hv_sleeve_mask" + +/mob/living/carbon/human + /// The lost limbs the uniform and suit were last drawn for, so they are redrawn only when that changes. + var/hv_sleeve_key = "" + +/mob/living/carbon/human/update_worn_undersuit(update_obscured = TRUE) + . = ..() + hv_mask_sleeves(UNIFORM_LAYER) + +/mob/living/carbon/human/update_worn_oversuit(update_obscured = TRUE) + . = ..() + hv_mask_sleeves(SUIT_LAYER) + +/mob/living/carbon/human/hv_update_gore_overlays() + . = ..() + var/key = jointext(hv_sleeve_mask_zones(), ",") + if(key == hv_sleeve_key) + return + hv_sleeve_key = key + update_worn_undersuit() + update_worn_oversuit() + +/// The arms and legs this body has lost, if its clothes are drawn on a human shape. Empty otherwise. +/mob/living/carbon/human/proc/hv_sleeve_mask_zones() + . = list() + if(!(bodyshape & BODYSHAPE_HUMANOID) || (bodyshape & (BODYSHAPE_MONKEY | BODYSHAPE_DIGITIGRADE))) + return + for(var/zone in list(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + if(!get_bodypart(zone)) + . += zone + +/// Cuts the lost limbs out of the worn overlay at `cache_index` (the uniform or the suit). +/mob/living/carbon/human/proc/hv_mask_sleeves(cache_index) + var/mutable_appearance/worn = overlays_standing[cache_index] + if(!istype(worn)) + return + var/list/missing = hv_sleeve_mask_zones() + if(!length(missing)) + return + cut_overlay(worn) + var/mutable_appearance/mask = mutable_appearance(HV_SLEEVE_MASK_ICON, "missing_[missing[1]]") + for(var/i in 2 to length(missing)) + mask.overlays += mutable_appearance(HV_SLEEVE_MASK_ICON, "missing_[missing[i]]") + mask.render_target = "*hv_sleeves [cache_index] [REF(src)]" + worn.overlays += mask + // Before the height filters (priority 1), so the mask cuts the sprite it was drawn for. + worn.add_filter(HV_SLEEVE_MASK_FILTER, 0, alpha_mask_filter(render_source = mask.render_target, flags = MASK_INVERSE)) + add_overlay(worn) + +#undef HV_SLEEVE_MASK_FILTER diff --git a/voidcrew/modules/hyperviolence/wire/_wire_defines.dm b/voidcrew/modules/hyperviolence/wire/_wire_defines.dm new file mode 100644 index 00000000000..6ddb7086cc4 --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/_wire_defines.dm @@ -0,0 +1,59 @@ +// Widowline monowire x hyperviolence (voidcrew/modules/hyperviolence/wire/): defines private to this part. +// The folder sorts after cyberware/ and every other hyperviolence part on purpose: it overrides procs both +// declare, and an override included before its declaration is silently dead. + +/// Clean cuts, seams, the through-cut and blood beams, the crossing X, the garrote loop and taut wire. +#define WIRE_GORE_ICON 'voidcrew/modules/hyperviolence/wire/icons/wire_gore.dmi' +/// The wire swinging out of its spool and reeling back in, with blood-flick twins. +#define WIRE_HELD_ANIM_ICON 'voidcrew/modules/cyberware/icons/cyberware_anim_monowire.dmi' + +/// On a body a Widowline is cutting right now: no flying hitsplatter, drips instead of floor splatter, +/// and a limb tg's own wound roll would tear off is left for the wire to take off clean. +#define TRAIT_HV_CLEAN_CUT "hv_clean_cut" + +/// An aimed cut takes off an arm or leg already this damaged (percent of its max damage) before the hit. +/// Whole percents: in DM's floats 50 * 0.6 is a hair over 30, and a limb at exactly 60% would never qualify. +#define WIRE_SEVER_AT 60 +/// The same on someone already down. +#define WIRE_SEVER_AT_DOWN 20 +/// A crossing cut takes a limb off at this share of the usual damage (percent). +#define WIRE_CROSS_SEVER_SHARE 50 +/// With a wire out in each hand, every this many landed hits cross both through the target. +#define WIRE_CROSS_HITS 3 +/// Landed hits further apart than this start the count again. +#define WIRE_CROSS_WINDOW (3 SECONDS) + +/// From the hit to the severed limb sliding off the body. +#define WIRE_CUT_DELAY (0.5 SECONDS) +/// The slide along the cut, and the fall after it. +#define WIRE_SLIDE_TIME (0.2 SECONDS) +#define WIRE_FALL_TIME (0.15 SECONDS) +/// Blood the Arc's seared cut loses, as a fraction of a plain sever's. +#define WIRE_ARC_BLEED_MULT 0.25 +/// How long a seared stump smokes. +#define WIRE_ARC_SMOKE_TIME (3 SECONDS) +/// A seared stump's blood layer and bone. +#define WIRE_BURNT_STUMP_COLOR "#2a1b15" +#define WIRE_BURNT_BONE_COLOR "#7a6a60" + +/// Blood on the wire: wet and dripping for this long after a cut, stained for this long. +#define WIRE_WET_TIME (15 SECONDS) +#define WIRE_STAINED_TIME (30 SECONDS) +/// Most drips one wet spell leaves. +#define WIRE_MAX_DRIPS 8 + +/// The swing out of the spool and back in. +#define WIRE_SWING_TIME (0.4 SECONDS) + +/// The garrote's wait after an attempt that got a grip, and after one that didn't. +#define WIRE_GARROTE_COOLDOWN (20 SECONDS) +#define WIRE_GARROTE_MISS_COOLDOWN (5 SECONDS) +/// Worn and plated cover at the neck (crit gate rating, the wire's penetration not counted) that turns the garrote. +#define WIRE_GARROTE_COVER 25 + +/// Mob overlay layers. The held limb: over the jumpsuit and the stump, under the ID, belt, suit and hands. +#define WIRE_GHOST_LAYER (-(HV_STUMP_FRESH_LAYER - 0.1)) +/// The wire swinging out of or into a hand: just over the held item it turns into. +#define WIRE_SWING_LAYER (-(HANDS_LAYER - 0.5)) +/// Flashes, seams and loops: over everything the body wears. +#define WIRE_FX_LAYER (-(HIGHEST_LAYER - 0.5)) diff --git a/voidcrew/modules/hyperviolence/wire/icons/wire_gore.dmi b/voidcrew/modules/hyperviolence/wire/icons/wire_gore.dmi new file mode 100644 index 0000000000000000000000000000000000000000..38b17fce91c4c15a50a646f6e9c30832fe129983 GIT binary patch literal 6556 zcmbVQ2UJtp*1icy?;!rp8RYHf3jnurUZ)THMk|V=akK+x z!mlOZhl#$Abxv#aIw+pWO1bPUa*a-pPCQ<(6!f|!b18M@HoX1bi|CBuS(eE4Q94U{ z`SDBlCD@ehT)1&5=i^j0nsLIZ#7~5RT{epP&a{|4HX%?O;C9u|hWktBk26NN;{@#{ zk5wq~nUFOFCrfyC(#!+04uw1_yLV?zeN9F7h(bx$6{_xQJFTkUH?MwK&6rhOIlgtU zr=EH6yEIGO#$LmQZ9z@gv_9lXMDe2dgHJFO<~1}uT@tx}KXj#tLpw?5QJud(@SEph z?xOHG*uMSw`SljUAx&9HT++w8t zviq$gQ#rhxJxR62Cz6~pT~6`^m+6@iDH_Rd9>*QBeY|xjvV6>vZT*xrfAiv2Onjk5 zj?V1m(0=6Th%4DmeTtOs)d&ER3=8w4_P0p$!+5`(lVRLTl!h-Y-=NC67UQrK)g9wN zAzumlr^!u~xgcA2-I-o{w+}XiU^T|cX zwVifBsmCJjk)J$2wZL#6356@570-TH{N6sd5V7%YrBN0F`uTsk-t(&MZJoLDDo5tt z6$ammlV6_> zmz0+&d{w}(K4)$oypsOS3q}AsHz$UAl!jK9TQuJU_dC(nw~`mhnpU(wts)WBmw{d^ zjYSu2E05TxW)JGdcB+`JUV7;DT&xpZb*P2r*yHAjz_?dk_|qnaq*j(Rx0AL^VD7&i z^KifAUa1@TXz$vk@&IG`!*z?w<^5MREW1Bf5sI@T_sw)x*`sc}k}RB=9H4X$H#wuo zw~R=_MrEUchVs>~q;PR*XC){_$tM{PmcvdBT=n=s?Di6_i4csA3-TgR#LKaPNoiXE65f${b0`vuTj`Z8yd^{%j7n3 zQQL;w_(L+EwN0y8KZbg;Z+3r{{nS*W5F)g(x>*iu`MMRV$!54wQV5GfhyES}Y)Nm9 z^!TD=-6vWV(FA0_{wi1hWIyZ)j!Q-Ktb*U+6<2Ny^JQx5R*lm=n*pg_$AWr?XKTyR zer2%aVyJoju6u&p6Rm^G3aicJ7ogf+a&`Z3!=nT!ptuAiUOe--`_onZBVt(% zyTi_@c{SqSW%Z(1KgxB#o0vAcdD2wpcSeN?8J4-$s<-RQ`%UK+3mMFgezt!o^27Sa zLV91~POO~ZTBz~d3p$5Vf(UOF91%7$B{hU1=~ zqyhmf$9o%z&7#eV3dP{DU^iH9L5m3GdC|jEuuaQe4XFM5-R^m zJWq7zAtvR~LcPE<$G$pd&0I;8)x|1pGM-~kUUjBP+o2HQtGf1K_ZKuu=vRkE^J01O zxPKKEaNXVg^S+A(!}Cw`=42gd2-rC%dg{ez2y^ zB*AEIYc69kf@W)Oet*`sfiX6jXIw;8bMs7(#ldJ>>>s-Ldm4@FO@X;fx!T|=#v)z; zfyhGL=LO81rn9MwN0$bK4dWuhn7t`2Q{VYVZO4m00-6Q>UReCfhEZW!m;ceX1tK;V zP<_ecwWi+1w?GnGVLc+g70<9L6g-)&+65^cRhvbwS5T*2rY!}GGz`ErV&lBzl)n6Y z^mkP3(__(ul#x}0H%U;emV4|CV|K&bcAOHAjbqtOJ(FY}42@n(X*mzvJ%sZW3j-ih zzlPrB>)RV64UcGv0h%z%A2CHhZqVZtj++;}-ASw$hU-|y8t-d-m!+hH)m?Nrk^g=J zA5PxbLM@eVXiU*A;d^H>_}jCItFxo*&Mje^;KzVwT5Mw}OP&)<6@gzUp}vCNXZnri zU||qm_$N?sR$LvIoCJZw)llQ<7buPK+a?8++xg6L>*Z|^R^LkLnmGDxBh9*->0;8p z>EP{$JQ<6wTnqI|u5Z(QoPX?nKf|#4^YHIWY>>=$U?euVFEkCS;)*M2H3kUco=KmR z*-W_$54~^>`9a7zARvIEuj-P6*T-yc{OP|L1B(su6JFxQD z!+8_kS5a{iO24c;Cl36Tfk207LPK%yD#SPe9;5$s&sBFVCo9QRa**2mP3rA|3n1s* zy}4WFa(1KJnoluTK4C5~l$EadBc(xB56NCJ%z-DszPE;^;`S&>WMuTI&%4~x`xv5E zAH?>)A7A3zXqTAxv{L)lOE<;R=2PY$Cs#}wB}EhOKB^!|w?+gV*&Fg0!BXCaDM3W55&)uc@%pT(UED(*iBWJ-0P$d0$^C0l z+;t9~EmJ8X?Tp4XO51#SH_;L7;qHZ~nJ!i7MSRM`eLKSB)Y$A&-Ma-t2%g(@jtse(E?4o2<3I8E$78gAKbX_peYo|qHX@$=Sh+28+AtGk z<*AchsP#H{^pz876%Hos-xfMckRUbjo~6!{GG$g+zQIy z)cbf&+gUzsXNM_zt7e7}g*XHZuvctCh~)%yKcfB^;X#>OB%HgBuL!ECVsv@@oSyMpuJIeCPvtUo2Mto@KxdCcfH-s@GNv0<9)r6Qf__Cx zdEa0edU=YkjQS(ay!Kot*7&F$-3EAV6lH?N{#16eNBPrf8S|I1zMa;H?^|nn>@wm@ z(91{WV0_x1Jn_ViXlu&@#5_+prdU?jm&eq`hIkfHkh@{>T;_Kc;`TS0m^7iT(FnNp zGl?-3)$b+=&pLiJB*axgWD-l|tTZJ5S0G8gevwmldHo0>n{ zF8USZ)ie(bHT(>dIcIomKTJsS!PkH%bh1cP*rJ2n2@(*%;){7_RlTsX5<1EJ(XM-1Lqz{{$k3*i};pHk=W+rzK}hA$iyQ{Qxcj1 z^O92)B;~mm?1c$yT%?%j@^*jVLaM*%r#4QR_#?k;mj>tAEU@K>`)zHuB)5u$Y5ZI~ zgBBIF9gZmM!i^`M+X-r?_8^hU?;fx1M4N^`!_Q!Rkn&GKB zv)ir4T6BMAlkn%br)GY8_wd8t8G&hE_fUck0A`mNpL9>==x2!tJtyLU46GE^sALHp z3Gb);YB+#^nzsB~)DFCH1=YBXvXV9kOp$PvTI}z?R~5M0oS(L+A2cj|e3Xa!!j(4h zXb_ru4c{UYW@l-E=agqgMMV*?0xG6YX|1jd4+n+b=Hu$gS~bYRv=jR5S;(G@+$?u| zjMfQBWN*`P9Rx;zv8xbaW?2d+Z?11r`+SI{F%;7z$Xf!kocrm(^&;p!CVbOlk$>_T z+qoGH4EYhiGN3>ckSD_hC<4ED(BeN^10+QJEmy>C?U}xpV1rQ1FUieb{=0tn;tq{& zZA&bSuC8|X{%0B#C1-z7=<`Vhsh~w%oj)?6Jlajpw7&xv|Ush4OXKPxV zA>ZcxX`Q$|WmXTZ5eY21bO}7&N$^~xouI4_t<-=vQDaBm82F$TF*(W+Y)<^THOPo* zuugDYE*^09Q0B}+{hdzeAuztYNi~^j#m3e6^aYc%U{zZUme6&+Y^s`^5v#1~@O&P# zoPazv!`OGgqk^@}I#Qthx+of$ zxSpLHtIz_AQc|j`;5ssZbfMjg*HhU7_oy{zX}|0c)oICdFaDC%y(670>H*xOiUm}} zR3LC?HJ|v6*pxbGw*!yCHHHRhaSs6bsHd}=-((zaiW`JQ_AY^z7``)ag>BN13b}bP zx%pP!4cDux08WuMltiY8x%exMlG&xep&!%HqnOKo{7?anK1(N0z0wa4PVQ*cF^o%Y z0^&R8KKSqRvJ`eJD}L85)&Zgc;YtC|gWi`Sbz*aG4GsMQDAVV-bA#3_vG8@B zddY|iCbS80dBIG4fkQm{op6V@pL+jEhG#?IYUyO4+@Xa_w~fo%LiJZwha0Y#DUDfm zd~~!5C(9#~NQ#l$E-GE)KQ3GR9==Yuc4}O&o9hcBTI}FMSKiQBFxa}@#j;p{$rYCa zj+ri+Sn%nLEC7;e)@lk;@Go`3sDF z1F(6Omt4@~C64i`_t&FZkeC-F)W8of$o`fpHgEiu3g}Lx2vUb(9u5Ir$h!u)e`S#) zt+lwJW&jMDbu~@&l3M}jA={sk=z#U=wTeLz5T*%mZXR?8R0QzvB0%}WQ~qLz;%1}l zRIe0C3JdZ;E}ZfCwSVyAHwIp#E5hjJ56Z@2k4XaiHLmR$Mv-Pco`!etD4`tiJ8P4M2`& z$zjnL>SVQxi%i(MzRLbp(U>Q`czC--G||V#TZxDG*}J|@c?(F`&v`@Uf{wlwsBlL_ zFWkL#pY%P5v}DBEg6}JWu8^lJ?rpjlg}h_ zp5NdGS-FO;TdsWLLale*qaKI#uLs}Hoy8Q;`aas?Wv?*`wb>qJ{5ua61}z1eeY&qU zXmXmzXEUwO#>#a-vL^yV5Q};OCzDr+qPVZ1@?!X{G4pG+JQOj2s?(kC+3+2Ona^gw z)UTUO>?!6w>dt@0jPK};RodY@MdeI5Xo04R=+Oa7`Q0yYfKOm`Tl=TGxsuK;@EaQ{ zuiUM@2j&D!DcSkiq{9tge@v@K6CSXrJ`7wX%s1_AU1{xtwL|tB*Xq*R(zD&!*OL|w zt-tefO%Wsja&t|W%DEEVs+qA%hxvo+cUr zv`{3qqFR5Raj#+Btgly_jwFf<^JsW(3dO%K5l=UbG#n5%5YC0h-r%30B-=9Xv^xJ?ci{HNfPFYY_^{Z!gpMUhE z@dZ;YUHQ(A_eji9n4!!Im4U@>62e)-I3!y(Gwzi>$y*gI8jDGN(|Me{uTOS=lKxgs zL83bJTPtb$`OitN`#{#+T&-$R4mk4&o$h)bLd}gN{C|&~(Arpmqlf8k726M4VBrkF z<~l$P6kRX`3QiKb@?5vtG?Z8j0)czQ0cD%m!0e)6UNPymmZGBUtum~^La6(V(XDLu z1cyx>dv!x;E19B%TtmeP-&#pOzb}vQX2v9jECP&!I>a1Qz_5otH+6e5BGsE}Ini}( zsIWZ~8MqJ=cH|qAqK%r%fM$tEZz%9vvPiE3uw$FiihOTX0(`^)^mMRU9UX%1l^jd6 z!#qG)9-xUr!AC|xubFU_qmghVV7ga`XI^3I5ra%N#^+cHB1YpQ9Mf^HjG% zxrKUW7pS@S1xzu_6g%^egDC0c4YoY12DM&>^{H=;tQlQX8VUv0SzlL{aZH-3D0^jf zgMg2C&0Jmt*HRR6`vG(OZnZeTu?}wLC= WIRE_CROSS_HITS - 1 && world.time - user.wire_cross_last <= WIRE_CROSS_WINDOW + +/// Counts a landed hit. Returns TRUE if it is the crossing one. +/obj/item/melee/cyberware_monowire/proc/wire_count_landed(mob/living/user) + if(wire_count_held(user) < 2 || !hv_wire_moves_allowed(user)) + user.wire_cross_hits = 0 + return FALSE + if(force_cross) + user.wire_cross_hits = 0 + return TRUE + if(world.time - user.wire_cross_last > WIRE_CROSS_WINDOW) + user.wire_cross_hits = 0 + user.wire_cross_hits++ + user.wire_cross_last = world.time + if(user.wire_cross_hits < WIRE_CROSS_HITS) + return FALSE + user.wire_cross_hits = 0 + return TRUE + +/// The X of light over the target, mirrored to the side the blow came from. +/obj/item/melee/cyberware_monowire/proc/wire_play_cross(atom/movable/attacker, atom/movable/target) + var/obj/effect/abstract/cyberware_anim/cross = cyberware_anim_play(target, WIRE_GORE_ICON, cross_state(), WIRE_FX_LAYER, 0.5 SECONDS, glow = TRUE) + if(!cross) + return null + cross.appearance_flags |= RESET_TRANSFORM + if(get_dir(attacker, target) & WEST) + cross.transform = matrix(-1, 0, 0, 0, 1, 0) + if(isliving(attacker)) + var/mob/living/swinger = attacker + swinger.mob_light(2, 1, cross_light_color(), 0.3 SECONDS) + playsound(target, 'sound/items/weapons/bladeslice.ogg', 60, TRUE) + return cross + +/obj/item/melee/cyberware_monowire/proc/cross_state() + return "impact_widowline_cross" + +/obj/item/melee/cyberware_monowire/proc/cross_light_color() + return "#ff8cf8" + +/obj/item/melee/cyberware_monowire/live/cross_state() + return "impact_arc_cross" + +/obj/item/melee/cyberware_monowire/live/cross_light_color() + return "#9fe4ff" + +// The crossing swing shows its X instead of the single wire's impact. +/obj/item/melee/cyberware_monowire/animate_attack(atom/movable/attacker, atom/attacked_atom, animation_type) + if(crossing && ismovable(attacked_atom) && wire_play_cross(attacker, attacked_atom)) + return + return ..() + +/obj/item/melee/cyberware_monowire/live/animate_attack(atom/movable/attacker, atom/attacked_atom, animation_type) + if(crossing && ismovable(attacked_atom) && wire_play_cross(attacker, attacked_atom)) + return + return ..() + +// ---- Severing ---- + +/// Whether this wire may take `limb` off: an arm or leg, on a body that was alive when the swing began +/// (`corpse`), that can lose it (an Atlas frame can't), and that isn't the wielder's ally. +/obj/item/melee/cyberware_monowire/proc/wire_can_sever(obj/item/bodypart/limb, mob/living/user, corpse) + if(isnull(corpse)) + corpse = cut_on_corpse + var/mob/living/carbon/victim = limb?.owner + if(!victim || corpse || !(limb.body_zone in GLOB.hv_wire_sever_zones)) + return FALSE + if(!limb.can_dismember() || HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + return !cyberware_is_ally(user, victim) + +/** + * Takes the limb the cut landed on, if it was aimed there and was hurt enough before the hit, or the limb tg's + * own roll wanted off this hit. Returns the severed limb, or null. + */ +/obj/item/melee/cyberware_monowire/proc/wire_try_sever(mob/living/source, mob/living/user, def_zone, damage_dealt, spray_dir, cross) + if(!iscarbon(source)) + return null + var/mob/living/carbon/victim = source + var/obj/item/bodypart/owed = victim.hv_wire_owed_sever + victim.hv_wire_owed_sever = null + if(owed?.owner == victim && wire_can_sever(owed, user)) + return wire_sever(owed, user, spray_dir, cross) + // The aimed sever is a move of the wielder's (wire_users.dm); tg's own roll above is the weapon's. + if(!hv_wire_moves_allowed(user)) + return null + var/obj/item/bodypart/limb = isbodypart(def_zone) ? def_zone : victim.get_bodypart(check_zone(def_zone)) + if(!limb || limb.owner != victim || check_zone(cut_aim) != limb.body_zone) + return null + if(!wire_can_sever(limb, user)) + return null + var/needed = cut_on_downed ? WIRE_SEVER_AT_DOWN : WIRE_SEVER_AT + if(cross) + needed = needed * WIRE_CROSS_SEVER_SHARE / 100 + if(round((limb.get_damage() - damage_dealt) * 100) < round(limb.max_damage * needed)) + return null + return wire_sever(limb, user, spray_dir, cross) + +// Its full penetration is for its cuts. At #509's crit gates a collar, helmet or plating counts against it as +// against any blade (owner D33): the crit roll treats it as no penetration at all. +/obj/item/melee/cyberware_monowire/hv_crit_armour_pen() + return 0 + +/// Whether this wire sears what it cuts on `victim` shut. Only the Arc's charge, and only where its shock reaches. +/obj/item/melee/cyberware_monowire/proc/wire_sears(mob/living/victim) + return FALSE + +/obj/item/melee/cyberware_monowire/live/wire_sears(mob/living/victim) + return !HAS_TRAIT(victim, TRAIT_SHOCKIMMUNE) + +/** + * Takes `limb` off at the hit. The body is real at once (the arm is gone, its hand's grip with it); the picture + * lags: the limb stays drawn on until it slides off (wire_sever_fx.dm). Returns the severed limb, or null. + */ +/obj/item/melee/cyberware_monowire/proc/wire_sever(obj/item/bodypart/limb, mob/living/user, spray_dir, cross) + var/mob/living/carbon/victim = limb.owner + var/zone = limb.body_zone + var/zone_name = limb.plaintext_zone + var/seared = wire_sears(victim) + // Drawn while the limb is still on, so it can be held there. + var/obj/effect/abstract/cyberware_anim/wire_ghost/ghost = wire_ghost_limb(victim, limb, seared, spray_dir) + LAZYSET(victim.hv_stump_reveal_at, zone, world.time + WIRE_CUT_DELAY) + if(seared) + LAZYSET(victim.hv_stump_burnt, zone, TRUE) + var/obj/item/bodypart/severed = hv_wire_take_part(limb, seared ? WOUND_BURN : WOUND_SLASH, seared ? WIRE_ARC_BLEED_MULT : 1, !seared) + if(!severed) + LAZYREMOVE(victim.hv_stump_reveal_at, zone) + LAZYREMOVE(victim.hv_stump_burnt, zone) + qdel(ghost) + return null + ghost?.hold_severed(severed) + wire_seam_fx(victim, zone, seared) + if(seared) + victim.hv_add_hit_line("[victim]'s [zone_name] drops away, the stump seared shut!", HV_LINE_HUGE, "Your [zone_name] drops away, the stump seared shut!", DEFAULT_MESSAGE_RANGE) + else + victim.hv_add_hit_line("[victim]'s [zone_name] comes away clean!", HV_LINE_HUGE, "Your [zone_name] comes away clean!", DEFAULT_MESSAGE_RANGE) + log_combat(user, victim, "severed the [zone_name] of", src, cross ? "(crossing cut)" : null) + return severed + +// ---- Through and around ---- + +/** + * The wire carries on past the target: whoever stands right behind it (along the swing) takes the through-cut, + * a line of light then blood through both bodies; anyone else beside the target takes the plain cleave. Both + * deal the wire's cleave share and cut clean. Lines join the hit's message. + */ +/obj/item/melee/cyberware_monowire/proc/wire_cleave(mob/living/target, mob/living/user) + var/direction = get_dir(user, target) || user.dir + var/turf/target_turf = get_step(target, direction) + var/mob/living/behind + var/cleaved = FALSE + for(var/mob/living/nearby in orange(1, target)) + if(nearby == user || nearby == target || nearby.stat == DEAD) + continue + if(!can_see(target, nearby, 1)) + continue + if(cyberware_is_ally(user, nearby)) + continue + wire_cleave_cut(nearby, user) + if(QDELETED(nearby)) + continue + if(!behind && get_turf(nearby) == target_turf) + behind = nearby + else + to_chat(nearby, span_userdanger("[user]'s monowire carries through into you!")) + cleaved = TRUE + if(behind) + wire_through_fx(target, behind, direction) + target.hv_add_hit_line("The wire carries through into [behind]!", HV_LINE_BOLD) + to_chat(behind, span_userdanger("The wire carries through into you!")) + if(cleaved) + target.hv_add_hit_line("The wire sings through everything beside [target]!") + if(behind || cleaved) + playsound(target, 'sound/items/weapons/bladeslice.ogg', 50, TRUE) + +/// One cleave cut on `nearby`: both halves of the wire's damage at the cleave share, one armour roll, cut clean. +/obj/item/melee/cyberware_monowire/proc/wire_cleave_cut(mob/living/nearby, mob/living/user) + // A cleave is an attack like the swing itself, so trader outposts see it. + GLOB.outpost_pvp_enforcement?.register_pvp_aggression(nearby, user) + var/cleave_block = nearby.run_armor_check(BODY_ZONE_CHEST, MELEE, armour_penetration = armour_penetration) + ADD_TRAIT(nearby, TRAIT_HV_CLEAN_CUT, REF(src)) + nearby.apply_damage(force * cleave_mult, BRUTE, BODY_ZONE_CHEST, cleave_block, wound_bonus = wound_bonus, sharpness = SHARP_EDGED, attack_direction = get_dir(user, nearby)) + if(!QDELETED(nearby)) + nearby.apply_damage(charge_burn * cleave_mult, BURN, BODY_ZONE_CHEST, cleave_block) + if(QDELETED(nearby)) + return + REMOVE_TRAIT(nearby, TRAIT_HV_CLEAN_CUT, REF(src)) + // tg's roll may have wanted a limb: it comes off clean, as it would have come off anyway. + var/obj/item/bodypart/owed = nearby.hv_wire_owed_sever + nearby.hv_wire_owed_sever = null + if(owed?.owner == nearby && wire_can_sever(owed, user, corpse = FALSE)) + wire_sever(owed, user, get_dir(user, nearby), FALSE) diff --git a/voidcrew/modules/hyperviolence/wire/wire_garrote.dm b/voidcrew/modules/hyperviolence/wire/wire_garrote.dm new file mode 100644 index 00000000000..126056e2735 --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/wire_garrote.dm @@ -0,0 +1,322 @@ +/** + * # The garrote + * + * A Widowline wielder standing over someone who can't fight back (in crit, or stunned: lying down alone + * isn't enough) loops the wire round their neck with a combat click at the head or mouth. It is #509's + * finisher engine (hyperviolence/finishers/) run by a person: a real aggressive grab, interruptible the + * same ways. + * + * - Wind-up (1 s): the wire loops round the neck and glints. No mark over the victim (owner D34): the loop + * and the struggle are the tell. It holds nobody, so pulling the victim clear, or them getting up, ends it. + * - Hold (1.5 s): the grab, the wire goes taut and bites, the victim thrashes. + * - Kill (0.5 s into the stage): in hard crit the head comes off (an Atlas frame keeps it); otherwise the + * throat is slit, #509's carotid crit. A sealed helmet, an armoured collar or plating that would stop + * the throat crit turns the wire, and it lands as an ordinary blow at the head instead. + * - Broken by #509's rules: the grab broken or pulled apart, the garroter hurt (WIRE_GARROTE_INTERRUPT + * health), stunned, cuffed or out of their wire, the victim dying first, the timeout. + * + * Only wielders that hv_wire_moves_allowed() lets use the wire's moves (wire_users.dm) can start one. It + * refuses allies, starts the outpost's self-defence clock like any attack, and the wielder waits + * WIRE_GARROTE_COOLDOWN after one that got a grip, WIRE_GARROTE_MISS_COOLDOWN after one that didn't. + */ + +/// Health the garroter can lose before letting go. +#define WIRE_GARROTE_INTERRUPT 20 + +/mob/living + /// The garrote can be tried again from this world.time. + var/wire_garrote_ready_at = 0 + +/obj/item/melee/cyberware_monowire + /// The garrote this wire is in, if any. + var/datum/hv_finisher/garrote/garrote_running + /// TRUE while the garrote swings the wire for real, so the swing isn't taken for a new garrote. + var/garrote_blow = FALSE + +/obj/item/melee/cyberware_monowire/pre_attack(atom/target, mob/living/user, list/modifiers, list/attack_modifiers) + . = ..() + if(. || garrote_blow) + return + // Busy round someone's neck. + if(garrote_running) + return TRUE + if(wire_try_garrote(target, user)) + return TRUE + +/// Someone who can't fight the wire off: in crit, or stunned. Lying down alone doesn't count. +/proc/wire_garrote_helpless(mob/living/victim) + return victim.stat >= SOFT_CRIT || HAS_TRAIT(victim, TRAIT_INCAPACITATED) + +/// Starts a garrote on `target` if this click asks for one (combat mode, the head or mouth on the target doll) +/// and it can begin. Returns TRUE if it began. +/obj/item/melee/cyberware_monowire/proc/wire_try_garrote(atom/target, mob/living/user) + if(!isliving(user) || !user.combat_mode) + return FALSE + if(user.zone_selected != BODY_ZONE_HEAD && user.zone_selected != BODY_ZONE_PRECISE_MOUTH) + return FALSE + return wire_start_garrote(user, target) + +/** + * The garrote itself, with no click behind it: the entry point for anything that isn't a mouse (an NPC's AI, + * later). Same rules as a player's: the wielder's moves gate (wire_users.dm), no pacifists, the wait between + * tries, and the finisher's own can_start(). Returns TRUE if it began. + */ +/obj/item/melee/cyberware_monowire/proc/wire_start_garrote(mob/living/user, mob/living/target) + if(!iscarbon(target) || !iscarbon(user) || HAS_TRAIT(user, TRAIT_PACIFISM) || garrote_running) + return FALSE + if(!hv_wire_moves_allowed(user)) + return FALSE + if(world.time < user.wire_garrote_ready_at) + return FALSE + var/datum/hv_finisher/garrote/garrote = new(user, target, CALLBACK(src, PROC_REF(garrote_ended))) + garrote.wire = src + if(!garrote.start()) + qdel(garrote) + return FALSE + garrote_running = garrote + return TRUE + +/// The garrote ended, however it ended. +/obj/item/melee/cyberware_monowire/proc/garrote_ended(datum/hv_finisher/garrote/garrote, result) + if(garrote_running == garrote) + garrote_running = null + // Not from inside its own stop(): the engine still has a line or two to run. + QDEL_IN(garrote, 1) + +// ---- The finisher ---- + +/datum/hv_finisher/garrote + name = "garrote" + interrupt_damage = WIRE_GARROTE_INTERRUPT + zone_preference = list(BODY_ZONE_HEAD) + stages = list( + /datum/hv_finisher_stage/windup/garrote, + /datum/hv_finisher_stage/hold/garrote, + /datum/hv_finisher_stage/kill/garrote, + /datum/hv_finisher_stage/recover, + ) + /// The wire round the neck + var/obj/item/melee/cyberware_monowire/wire + /// The loop drawn round the neck + var/datum/weakref/loop_ref + /// The taut line from the hand to the neck + var/datum/weakref/taut_ref + +/datum/hv_finisher/garrote/Destroy() + . = ..() + clear_wire_fx() + wire = null + +/** + * The finisher's own victim check (hv_finisher_valid_victim()) refuses anyone who shares a faction with the + * attacker, which is every player with every other. This is the same check without that: alive, helpless, + * on the floor beside the garroter with a head, not strapped down, not already being finished, not an ally. + */ +/datum/hv_finisher/garrote/can_start() + if(QDELETED(user) || user.stat != CONSCIOUS || HAS_TRAIT(user, TRAIT_INCAPACITATED) || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED)) + return FALSE + if(!hv_wire_moves_allowed(user)) + return FALSE + if(QDELETED(wire) || !user.is_holding(wire)) + return FALSE + if(!iscarbon(victim) || QDELETED(victim) || victim == user || victim.stat == DEAD || HAS_TRAIT(victim, TRAIT_GODMODE)) + return FALSE + if(!wire_garrote_helpless(victim) || victim.has_status_effect(/datum/status_effect/hv_finisher_hold)) + return FALSE + if(!victim.get_bodypart(BODY_ZONE_HEAD)) + return FALSE + if(victim.buckled || !isturf(victim.loc) || !isturf(user.loc) || victim.z != user.z) + return FALSE + if(cyberware_is_ally(user, victim)) + return FALSE + return get_dist(user, victim) <= 1 && user.Adjacent(victim) + +/datum/hv_finisher/garrote/start() + . = ..() + if(!.) + return + user.wire_garrote_ready_at = world.time + WIRE_GARROTE_MISS_COOLDOWN + // A garrote is an attack: the trader outpost's guards see it from the first loop. + GLOB.outpost_pvp_enforcement?.register_pvp_aggression(victim, user) + cyberware_camo_drop(user) + +/datum/hv_finisher/garrote/take_grip() + . = ..() + if(. && !QDELETED(user)) + user.wire_garrote_ready_at = world.time + WIRE_GARROTE_COOLDOWN + +/datum/hv_finisher/garrote/safety_check() + . = ..() + if(. || completed) + return + if(QDELETED(wire) || !user.is_holding(wire)) + return HV_FINISHER_ATTACKER_DOWN + // The wind-up holds nobody: back on their feet, they're out of it. + if(!gripped && !wire_garrote_helpless(victim)) + return HV_FINISHER_LOST_GRIP + +/datum/hv_finisher/garrote/stop(reason) + clear_wire_fx() + return ..() + +/// The wire's blow, never the heaviest thing in the garroter's hands. +/datum/hv_finisher/garrote/real_blow(zone, extra_wound_bonus = 0) + if(QDELETED(user) || QDELETED(victim) || QDELETED(wire) || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED) || !user.is_holding(wire)) + return FALSE + var/mob/living/carbon/attacker = user + if(attacker.get_active_held_item() != wire) + var/index = attacker.get_held_index_of_item(wire) + if(!index) + return FALSE + attacker.swap_hand(index, silent = TRUE) + user.zone_selected = zone + user.face_atom(victim) + var/old_combat_mode = user.combat_mode + user.set_combat_mode(TRUE) + var/old_wound_bonus = wire.wound_bonus + wire.wound_bonus += extra_wound_bonus + wire.garrote_blow = TRUE + wire.melee_attack_chain(user, victim) + if(!QDELETED(wire)) + wire.garrote_blow = FALSE + wire.wound_bonus = old_wound_bonus + if(!QDELETED(user)) + user.set_combat_mode(old_combat_mode) + user.changeNext_move(CLICK_CD_MELEE) + return TRUE + +/// No mark over the victim (owner D34): the wire loops round the neck and glints, and they struggle. +/datum/hv_finisher/garrote/place_mark(duration) + QDEL_NULL(mark) + show_loop(tight = FALSE) + var/obj/effect/abstract/cyberware_anim/glint = cyberware_anim_play(user, WIRE_GORE_ICON, "wire_glint[wire_suffix()]", WIRE_FX_LAYER, 0.3 SECONDS, glow = TRUE) + if(glint) + glint.pixel_y = -4 + victim.Shake(1, 0, duration) + +/// "" for the plain wire's colours, "_live" for the Arc's. +/datum/hv_finisher/garrote/proc/wire_suffix() + return istype(wire, /obj/item/melee/cyberware_monowire/live) ? "_live" : "" + +/// Draws the loop round the victim's neck: hanging loose, or pulled tight. +/datum/hv_finisher/garrote/proc/show_loop(tight) + qdel(loop_ref?.resolve()) + loop_ref = null + if(QDELETED(victim)) + return + var/obj/effect/abstract/cyberware_anim/loop = cyberware_anim_play(victim, WIRE_GORE_ICON, "garrote_[tight ? "tight" : "loop"][wire_suffix()]", WIRE_FX_LAYER, longest_duration(), glow = TRUE) + loop_ref = WEAKREF(loop) + +/// The wire drawn taut from the garroter's hand to the victim's neck. +/datum/hv_finisher/garrote/proc/draw_taut(duration) + qdel(taut_ref?.resolve()) + taut_ref = null + if(QDELETED(user) || QDELETED(victim)) + return + // The neck sits 6 px above the middle of an upright body; a lying body is turned, so turn that with it. + var/matrix/shape = victim.transform + var/x_offset = round(shape.b * 6 + shape.c) + var/y_offset = round(shape.e * 6 + shape.f) + var/datum/beam/taut = user.Beam(victim, icon_state = "wire_taut[wire_suffix()]", icon = WIRE_GORE_ICON, time = duration, override_origin_pixel_x = user.pixel_x, override_origin_pixel_y = user.pixel_y - 2, override_target_pixel_x = victim.pixel_x + x_offset, override_target_pixel_y = victim.pixel_y + y_offset) + taut_ref = WEAKREF(taut) + +/datum/hv_finisher/garrote/proc/clear_wire_fx() + qdel(loop_ref?.resolve()) + loop_ref = null + qdel(taut_ref?.resolve()) + taut_ref = null + +/** + * The kill: the head off in hard crit, else the throat slit, else (turned by a helmet, collar or plating) an + * ordinary blow with the wire. Lines go out while the head is still on, since they name it. + */ +/datum/hv_finisher/garrote/proc/kill(spray_dir) + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + if(!head) + return + var/seared = wire.wire_sears(victim) + if(victim.stat >= HARD_CRIT && head.can_dismember()) + victim.hv_add_hit_line("[user] draws the wire through [victim]'s neck!", HV_LINE_HUGE, null, DEFAULT_MESSAGE_RANGE) + if(seared) + LAZYSET(victim.hv_stump_burnt, BODY_ZONE_HEAD, TRUE) + // Dropped where the body lies, brain and all, as #509's finishers drop a head; one bleed's worth, a quarter seared. + if(hv_wire_take_part(head, seared ? WOUND_BURN : WOUND_SLASH, seared ? WIRE_ARC_BLEED_MULT * 0.5 : 0.5, !seared)) + if(!seared) + victim.hv_wire_spray(spray_dir, 3) + log_combat(user, victim, "garroted the head off", wire) + return + LAZYREMOVE(victim.hv_stump_burnt, BODY_ZONE_HEAD) + var/datum/hv_crit/throat/throat = locate() in GLOB.hv_crits + var/blocker = wire_turned_by(head, throat) + if(!blocker && throat) + if(hv_crit_apply_wound(head, throat.wound_type, spray_dir, wire)) + victim.hv_add_hit_line("[user] jerks the wire across [victim]'s throat!") + throat.announce(victim, spray_dir) + if(!seared) + victim.hv_wire_spray(spray_dir, 3) + log_combat(user, victim, "garroted the throat of", wire) + return + if(blocker) + victim.hv_add_hit_line("The wire catches on [victim]'s [blocker]!", HV_LINE_BOLD, "The wire catches on your [blocker]!") + real_blow(BODY_ZONE_HEAD, kill_wound_bonus) + +/// What stops the wire at the throat ("helmet", "collar", "plating"), or null. The same gates as #509's +/// throat crit, with the wire's penetration not counted (the crit gates never count it). +/datum/hv_finisher/garrote/proc/wire_turned_by(obj/item/bodypart/head/head, datum/hv_crit/throat/throat) + var/mob/living/carbon/human/dressed = ishuman(victim) ? victim : null + if(throat?.blocked_on_carbon(victim, head, wire)) + if(victim.is_mouth_covered(ITEM_SLOT_HEAD)) + return "helmet" + return "collar" + if(hv_crit_cover_rating(victim, head, MELEE, 0) >= WIRE_GARROTE_COVER) + if(dressed?.head || dressed?.wear_mask) + return "helmet" + if(dressed?.wear_neck) + return "collar" + return "plating" + return null + +// ---- Stages ---- + +/datum/hv_finisher_stage/windup/garrote + duration = 1 SECONDS + rise = 0 + lunge = 2 + enter_range = COMBAT_MESSAGE_RANGE + +/datum/hv_finisher_stage/windup/garrote/enter_message() + return "[host.user] loops the wire around [host.victim]'s neck." + +/datum/hv_finisher_stage/windup/garrote/enter_self_message() + return "A wire loops around your neck!" + +/datum/hv_finisher_stage/hold/garrote + duration = 1.5 SECONDS + lunge = 4 + enter_sound = 'voidcrew/sound/monowire2.ogg' + enter_volume = 50 + enter_range = COMBAT_MESSAGE_RANGE + +/datum/hv_finisher_stage/hold/garrote/enter() + . = ..() + var/datum/hv_finisher/garrote/garrote = host + garrote.show_loop(tight = TRUE) + garrote.draw_taut(duration + 1 SECONDS) + host.victim.Shake(2, 1, duration) + +/datum/hv_finisher_stage/hold/garrote/enter_message() + return "[host.user] pulls the wire tight!" + +/datum/hv_finisher_stage/hold/garrote/enter_self_message() + return "The wire bites into your neck!" + +/datum/hv_finisher_stage/kill/garrote + duration = 1 SECONDS + effect_delay = 0.5 SECONDS + lunge = 2 + +/datum/hv_finisher_stage/kill/garrote/effect() + var/datum/hv_finisher/garrote/garrote = host + garrote.kill(away_dir()) + +#undef WIRE_GARROTE_INTERRUPT diff --git a/voidcrew/modules/hyperviolence/wire/wire_held.dm b/voidcrew/modules/hyperviolence/wire/wire_held.dm new file mode 100644 index 00000000000..d1a379f8b17 --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/wire_held.dm @@ -0,0 +1,233 @@ +/** + * # The Widowline in the hand + * + * - Deploying and stowing: the wire swings out of the spool and reels back in (WIRE_HELD_ANIM_ICON), played + * on the wielder while the held wire itself is not drawn, like the Mantis unfold. + * - Blood: a cut that draws blood leaves the wire wet (dripping) for WIRE_WET_TIME, then stained until + * WIRE_STAINED_TIME, drawn from greyscale states tinted to the blood. Stowing a wet wire flicks a few drops + * onto the floor in front. Only the picture clears: the blood's DNA stays on the wire until it is washed. + * tg's generic item stain is not drawn on the wire, which draws its own. + * - The Arc's charged wire burns its blood off: it never shows any. + */ + +/obj/item/melee/cyberware_monowire + /// Blood on the wire: wet and dripping until this world.time, stained until this one. + var/wet_until = 0 + var/stained_until = 0 + /// The blood look drawn now: 0 clean, 1 stained, 2 wet. + var/shown_blood_stage = 0 + /// Drips this wet spell still has in it. + var/drips_left = 0 + /// Timer of the next drip, and of the next change of look. + var/drip_timer + var/dry_timer + /// Until this world.time the wire isn't drawn in the hand: its swing is. + var/inhand_hidden_until = 0 + +/obj/item/melee/cyberware_monowire/Initialize(mapload) + . = ..() + RegisterSignal(src, COMSIG_COMPONENT_CLEAN_ACT, PROC_REF(on_wash)) + +/obj/item/melee/cyberware_monowire/Destroy() + if(garrote_running) + QDEL_NULL(garrote_running) + if(drip_timer) + deltimer(drip_timer) + drip_timer = null + if(dry_timer) + deltimer(dry_timer) + dry_timer = null + return ..() + +// ---- Blood on the wire ---- + +/// Whether this wire never shows blood. +/obj/item/melee/cyberware_monowire/proc/wire_stays_clean() + return FALSE + +/obj/item/melee/cyberware_monowire/live/wire_stays_clean() + return TRUE + +/// 2 while the wire is wet, 1 while it is stained, else 0. +/obj/item/melee/cyberware_monowire/proc/blood_stage() + if(world.time < wet_until) + return 2 + if(world.time < stained_until) + return 1 + return 0 + +/// A landed cut on `victim` at `def_zone`: if it drew blood, the wire is wet with it. +/obj/item/melee/cyberware_monowire/proc/wire_bloody(mob/living/victim, def_zone) + if(wire_stays_clean() || QDELETED(victim)) + return + if(isbodypart(def_zone)) + var/obj/item/bodypart/limb = def_zone + if(!limb.can_bleed()) + return + if(victim.can_bleed(BLOOD_COVER_ITEMS) != BLEED_SPLATTER) + return + add_mob_blood(victim) + wet_until = world.time + WIRE_WET_TIME + stained_until = world.time + WIRE_STAINED_TIME + drips_left = WIRE_MAX_DRIPS + if(!drip_timer) + drip_timer = addtimer(CALLBACK(src, PROC_REF(drip)), rand(1 SECONDS, 2 SECONDS), TIMER_STOPPABLE) + update_blood_look() + +/// Redraws the blood if its look changed, and waits for the next change. +/obj/item/melee/cyberware_monowire/proc/update_blood_look() + if(dry_timer) + deltimer(dry_timer) + dry_timer = null + var/stage = blood_stage() + if(stage != shown_blood_stage) + shown_blood_stage = stage + update_appearance(UPDATE_OVERLAYS) + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + // Wet dries to a stain, a stain to clean: wait for whichever comes next. + if(stage) + dry_timer = addtimer(CALLBACK(src, PROC_REF(update_blood_look)), (stage == 2 ? wet_until : stained_until) - world.time + 1, TIMER_STOPPABLE) + +/// A drop off the tip while the wire is out and wet. +/obj/item/melee/cyberware_monowire/proc/drip() + drip_timer = null + if(blood_stage() < 2 || drips_left <= 0) + return + var/mob/living/holder = loc + if(!isliving(holder)) + return + var/turf/below = holder.loc + if(isturf(below) && !isgroundlessturf(below) && below.has_gravity() && length(GET_ATOM_BLOOD_DNA(src))) + new /obj/effect/decal/cleanable/blood/drip(below, null, GET_ATOM_BLOOD_DNA(src)) + drips_left-- + drip_timer = addtimer(CALLBACK(src, PROC_REF(drip)), rand(1 SECONDS, 2 SECONDS), TIMER_STOPPABLE) + +/// A wash takes the blood off, look and all. +/obj/item/melee/cyberware_monowire/proc/on_wash(datum/source, clean_types) + SIGNAL_HANDLER + if(!(clean_types & CLEAN_TYPE_BLOOD) || !(wet_until || stained_until)) + return NONE + clear_blood_look() + return COMPONENT_CLEANED + +/// The blood stops showing. Its DNA stays. +/obj/item/melee/cyberware_monowire/proc/clear_blood_look() + wet_until = 0 + stained_until = 0 + drips_left = 0 + if(drip_timer) + deltimer(drip_timer) + drip_timer = null + update_blood_look() + +/obj/item/melee/cyberware_monowire/update_overlays() + . = ..() + var/stage = blood_stage() + if(!stage) + return + var/mutable_appearance/blood = mutable_appearance(icon, "monowire_blood[stage]", appearance_flags = RESET_COLOR) + blood.color = get_blood_dna_color() || BLOOD_COLOR_RED + . += blood + +/obj/item/melee/cyberware_monowire/worn_overlays(mutable_appearance/standing, isinhands = FALSE, icon_file) + . = ..() + if(!isinhands || world.time < inhand_hidden_until) + return + var/stage = blood_stage() + if(!stage || !icon_exists(icon_file, "monowire_blood[stage]")) + return + var/mutable_appearance/blood = mutable_appearance(icon_file, "monowire_blood[stage]", appearance_flags = RESET_COLOR) + blood.color = get_blood_dna_color() || BLOOD_COLOR_RED + . += blood + +// tg's stain over the whole item would sit on the spool long after the wire has dried; the wire draws its own. +/datum/forensics/check_blood() + if(istype(parent, /obj/item/melee/cyberware_monowire)) + return + return ..() + +// ---- Out of the spool and back ---- + +/obj/item/melee/cyberware_monowire/proc/hide_inhand_for(duration) + inhand_hidden_until = world.time + duration + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + addtimer(CALLBACK(src, PROC_REF(show_inhand)), duration, TIMER_UNIQUE | TIMER_OVERRIDE) + +/obj/item/melee/cyberware_monowire/proc/show_inhand() + if(!inhand_hidden_until) + return + inhand_hidden_until = 0 + var/mob/holder = loc + if(ismob(holder)) + holder.update_held_items() + +// The wire is in the hand from the first tick; only its drawing waits for the swing. +/obj/item/melee/cyberware_monowire/build_worn_icon(default_layer = 0, default_icon_file = null, isinhands = FALSE, female_uniform = NO_FEMALE_UNIFORM, override_state = null, override_file = null) + . = ..() + if(isinhands && world.time < inhand_hidden_until) + var/mutable_appearance/standing = . + standing.alpha = 0 + +/// "left" or "right": the hand `user` holds this wire in, or null. +/obj/item/melee/cyberware_monowire/proc/held_side(mob/living/user) + var/index = user?.get_held_index_of_item(src) + if(!index) + return null + return IS_LEFT_INDEX(index) ? "left" : "right" + +/// The wire swings out of the spool into the hand. +/obj/item/melee/cyberware_monowire/proc/play_deploy(mob/living/user) + var/side = held_side(user) + if(!side) + return + cyberware_anim_play(user, WIRE_HELD_ANIM_ICON, "[icon_state]_deploy_[side]", WIRE_SWING_LAYER, WIRE_SWING_TIME) + hide_inhand_for(WIRE_SWING_TIME) + +/// The wire reels back into the spool from the `side` hand. A wet wire flicks its blood off first. +/obj/item/melee/cyberware_monowire/proc/play_retract(mob/living/user, side, stage) + if(QDELETED(user) || !side) + return + cyberware_anim_play(user, WIRE_HELD_ANIM_ICON, "[icon_state]_retract_[side]", WIRE_SWING_LAYER, WIRE_SWING_TIME) + if(stage) + var/obj/effect/abstract/cyberware_anim/blood = cyberware_anim_play(user, WIRE_HELD_ANIM_ICON, "monowire_retract_blood[stage]_[side]", WIRE_SWING_LAYER + 0.05, WIRE_SWING_TIME) + if(blood) + blood.appearance_flags |= RESET_COLOR + blood.color = get_blood_dna_color() || BLOOD_COLOR_RED + if(stage >= 2) + flick_blood(user) + clear_blood_look() + +/// A few drops thrown onto the floor in front of `user`, and one at their feet. +/obj/item/melee/cyberware_monowire/proc/flick_blood(mob/living/user) + var/list/blood_dna = GET_ATOM_BLOOD_DNA(src) + var/turf/feet = get_turf(user) + if(!length(blood_dna) || !feet || !feet.has_gravity()) + return + if(!isgroundlessturf(feet)) + new /obj/effect/decal/cleanable/blood/drip(feet, null, blood_dna) + var/turf/ahead = get_step(feet, user.dir) + if(ahead && !hv_blood_spray_blocked(ahead) && !isgroundlessturf(ahead)) + new /obj/effect/decal/cleanable/blood/drip(ahead, null, blood_dna) + new /obj/effect/decal/cleanable/blood/drip(ahead, null, blood_dna) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/Extend(obj/item/augment) + . = ..() + var/obj/item/melee/cyberware_monowire/wire = active_item + if(istype(wire) && owner?.is_holding(wire)) + wire.play_deploy(owner) + +/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/Retract() + var/mob/living/carbon/stowing_owner = owner + var/obj/item/melee/cyberware_monowire/wire = active_item + var/side + var/stage = 0 + if(istype(wire) && stowing_owner?.is_holding(wire)) + side = wire.held_side(stowing_owner) + stage = wire.blood_stage() + . = ..() + if(. && side && !QDELETED(wire)) + wire.play_retract(stowing_owner, side, stage) diff --git a/voidcrew/modules/hyperviolence/wire/wire_seams.dm b/voidcrew/modules/hyperviolence/wire/wire_seams.dm new file mode 100644 index 00000000000..58ed5150ed3 --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/wire_seams.dm @@ -0,0 +1,151 @@ +// Widowline x hyperviolence: the hooks the wire needs in the gore parts, chained here rather than edited +// into them. Everything a wire sever changes about blood and stumps is in this file: +// - A clean cut (TRAIT_HV_CLEAN_CUT, held only while a wire's damage is being applied) throws no flying +// hitsplatter and no heavy-hit spray, and leaves drips where tg would splatter the floor. +// - tg's own wound roll never tears a limb off mid wire cut; the wire takes that limb off clean after. +// - A limb taken by the wire sprays nothing when it leaves (hv_sever_spray); the wire lays its own streak. +// - A wire stump can be held back for the beat the severed arm is still drawn on, and can be seared shut. +// - hv_wire_take_part(): tg's dismemberment without the throw and the fan of blood. + +/mob/living + /// While not null, a limb coming off throws no spray of its own (blood/blood_spray.dm). Set around a wire sever. + var/hv_sever_spray + /// A limb tg's wound roll would have torn off during a wire cut. The wire takes it off clean once the hit is done. + var/obj/item/bodypart/hv_wire_owed_sever + +/mob/living/carbon + /// Body zone -> world.time its stump may be drawn. A wire cut holds the stump back while the limb still shows. Lazy. + var/list/hv_stump_reveal_at + /// Body zones whose stump a hot wire seared shut. Lazy. + var/list/hv_stump_burnt + +// ---- Clean cuts ---- + +/mob/living/carbon/spray_blood(splatter_direction, splatter_strength = 3) + if(HAS_TRAIT(src, TRAIT_HV_CLEAN_CUT)) + return + return ..() + +/mob/living/hv_blood_on_damaged(datum/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item, wound_clothing) + SIGNAL_HANDLER + if(HAS_TRAIT(src, TRAIT_HV_CLEAN_CUT)) + return + return ..() + +/mob/living/add_splatter_floor(turf/splatter_turf, small_drip = FALSE) + if(HAS_TRAIT(src, TRAIT_HV_CLEAN_CUT)) + small_drip = TRUE + return ..(splatter_turf, small_drip) + +/mob/living/carbon/hv_blood_on_limb_removed(datum/source, obj/item/bodypart/removed_limb, special, dismembered) + SIGNAL_HANDLER + if(!isnull(hv_sever_spray)) + return + return ..() + +// tg tears an arm or leg off when its wound roll says so, flinging it with a fan of blood. Mid wire cut it +// stays on for now and the wire takes it off clean once the hit is resolved (hv_wire_settle_owed_sever()). +/datum/wound/loss/apply_dismember(obj/item/bodypart/dismembered_part, wounding_type = WOUND_SLASH, outright = FALSE, attack_direction) + var/mob/living/carbon/cut = dismembered_part?.owner + if(cut && HAS_TRAIT(cut, TRAIT_HV_CLEAN_CUT) && (dismembered_part.body_zone in GLOB.hv_wire_sever_zones) && dismembered_part.can_dismember()) + cut.hv_wire_owed_sever = dismembered_part + qdel(src) + return FALSE + return ..() + +/// The limbs a wire takes off. The head is the garrote's; the chest never comes off. +GLOBAL_LIST_INIT(hv_wire_sever_zones, list(BODY_ZONE_L_ARM, BODY_ZONE_R_ARM, BODY_ZONE_L_LEG, BODY_ZONE_R_LEG)) + +/** + * One line of blood from src toward spray_dir, `tiles` long: the streak of a clean cut. The shared spray + * (hv_blood_throw()) fans out at strength 3 and up; a wire's never does. Returns TRUE if anything was sprayed. + */ +/mob/living/proc/hv_wire_spray(spray_dir, tiles = 2) + if(QDELETED(src) || !isturf(loc) || can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return FALSE + tiles = clamp(round(tiles), 1, 5) + if(!spray_dir) + spray_dir = pick(GLOB.alldirs) + hv_blood_spray_until = world.time + 0.4 SECONDS + hv_blood_spray_strength = max(hv_blood_spray_strength, tiles) + var/list/blood_dna = get_blood_dna_list() + var/list/viruses = get_static_viruses() + var/list/drenched = list() + hv_blood_spray_line(spray_dir, tiles, blood_dna, viruses, drenched) + for(var/mob/living/splashed as anything in drenched) + splashed.hv_blood_drench(blood_dna, viruses) + return TRUE + +// ---- Stumps: held back, and seared ---- + +/mob/living/carbon/hv_update_gore_overlays() + // A limb back on forgets how its stump was cut. + for(var/zone in hv_stump_burnt?.Copy()) + if(get_bodypart(zone)) + LAZYREMOVE(hv_stump_burnt, zone) + // A held-back stump whose time has come shows now, whatever else changed. + for(var/zone in hv_stump_reveal_at?.Copy()) + if(get_bodypart(zone) || world.time >= hv_stump_reveal_at[zone]) + LAZYREMOVE(hv_stump_reveal_at, zone) + hv_gore_key = null + return ..() + +/mob/living/carbon/hv_build_gore_images(list/stumps, skull, blood_color) + var/list/shown = stumps + if(LAZYLEN(hv_stump_reveal_at) && length(stumps)) + shown = stumps.Copy() + for(var/zone in hv_stump_reveal_at) + if(world.time < hv_stump_reveal_at[zone]) + shown -= zone + . = ..(shown, skull, blood_color) + if(!LAZYLEN(hv_stump_burnt)) + return + for(var/image/gore_image as anything in .) + for(var/zone in hv_stump_burnt) + if(gore_image.icon_state == "stump_[zone]_blood") + gore_image.color = WIRE_BURNT_STUMP_COLOR + gore_image.alpha = 255 + else if(gore_image.icon_state == "stump_[zone]_bone") + gore_image.color = WIRE_BURNT_BONE_COLOR + +/// Draws a stump a wire cut held back, now that the limb has left the body. +/mob/living/carbon/proc/hv_reveal_stump(zone) + LAZYREMOVE(hv_stump_reveal_at, zone) + hv_gore_key = null + hv_update_gore_overlays() + +// ---- Taking a limb off ---- + +/** + * Takes `part` off its owner as tg's dismember() does (the chest takes a share of the limb's damage, a scream, + * the mood and memory, blood lost) but drops it where the body stands instead of flinging it, and throws no + * spray: a wire's cut is clean. `bleed_mult` scales the blood lost; `splatter` leaves a drip under the body. + * Returns the part, or null if it can't come off. + */ +/proc/hv_wire_take_part(obj/item/bodypart/part, wounding_type = WOUND_SLASH, bleed_mult = 1, splatter = TRUE) + var/mob/living/carbon/victim = part?.owner + if(!victim || !part.can_dismember() || HAS_TRAIT(victim, TRAIT_GODMODE)) + return null + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + if(chest && chest != part) + chest.receive_damage(clamp(part.brute_dam / 2 * chest.body_damage_coeff, 15, 50), clamp(part.burn_dam / 2 * chest.body_damage_coeff, 0, 50), wound_bonus = CANT_WOUND) + if(victim.stat != DEAD) + INVOKE_ASYNC(victim, TYPE_PROC_REF(/mob, emote), "scream") + playsound(get_turf(victim), 'sound/effects/dismember.ogg', 80, TRUE) + victim.add_mood_event("dismembered_[part.body_zone]", /datum/mood_event/dismembered, part) + victim.add_mob_memory(/datum/memory/was_dismembered, lost_limb = part) + LAZYSET(victim.body_zone_dismembered_by, part.body_zone, wounding_type) + var/bleeds = part.can_bleed() + var/turf/spot = get_turf(victim) + var/old_value = victim.hv_sever_spray + victim.hv_sever_spray = 0 + part.drop_limb(FALSE, TRUE) + victim.hv_sever_spray = old_value + victim.update_equipment_speed_mods() + if(bleeds && !QDELETED(victim)) + // tg bleeds a limb loss twice, once each side of the drop + victim.bleed(round(rand(20, 40) * bleed_mult)) + victim.bleed(round(rand(20, 40) * bleed_mult)) + if(splatter && isturf(spot) && victim.can_bleed(BLOOD_COVER_TURFS) == BLEED_SPLATTER) + new /obj/effect/decal/cleanable/blood/drip(spot, victim.get_static_viruses(), victim.get_blood_dna_list()) + return part diff --git a/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm b/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm new file mode 100644 index 00000000000..30dcee0e91a --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/wire_sever_fx.dm @@ -0,0 +1,223 @@ +/** + * # The Widowline's cut, drawn + * + * Visual only: the sever, the sear and the through-cut have already happened (wire_cut.dm) when these run. + * + * - The delayed cut. The limb is off at the hit, but for WIRE_CUT_DELAY it is still drawn on its body: + * a held copy (the limb's own sprite, plus the jumpsuit, suit, glove or shoe cut to its shape) rides the + * body in its vis_contents, over the stump the gore overlays hold back. Then it slides along the cut, + * turns, falls and fades while the severed limb on the floor fades in, and the stump shows. The held copy + * deletes itself on a hard timer, and with its body; if the limb is back on, or the body was carried off + * meanwhile, it just fades. + * - The seam: a white flash across the limb that cools to the wire's colour and beads with blood, or on a + * seared cut glows white to orange to embers, with a little light and smoke. + * - The through-cut: a line of the wire's light through both bodies, then a line of blood. + */ + +/// The colour a body's blood is drawn in, as the gore overlays draw it. +/proc/wire_blood_color(mob/living/bleeder) + return bleeder.get_bloodtype()?.get_damage_color(bleeder) || BLOOD_COLOR_RED + +/// Which way a limb in `zone` falls away from a body facing `facing`: -1 to screen left, 1 to screen right. +/proc/wire_limb_side(zone, facing) + switch(facing) + if(EAST) + return 1 + if(WEST) + return -1 + if(NORTH) + return (zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG) ? 1 : -1 + return (zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_R_LEG) ? -1 : 1 + +// ---- The held limb ---- + +/// A severed limb, still drawn on its body for the beat before it slides off. +/obj/effect/abstract/cyberware_anim/wire_ghost + name = "severed limb" + appearance_flags = KEEP_TOGETHER | TILE_BOUND | PIXEL_SCALE + /// The body zone it was cut from + var/zone + /// Cut by a hot wire: no blood when it goes + var/seared = FALSE + /// Which way the blood goes when it slides off: along the swing + var/spray_dir + /// Where the body stood when it was cut. Moved since, and there's no slide. + var/turf/cut_at + /// The severed limb lying on the floor + var/datum/weakref/severed_ref + /// Timer of the slide + var/slide_timer + +/obj/effect/abstract/cyberware_anim/wire_ghost/Destroy() + if(slide_timer) + deltimer(slide_timer) + slide_timer = null + cut_at = null + severed_ref = null + return ..() + +/** + * Draws `limb` held on `victim` until it slides off. Called while the limb is still attached, since what it + * draws (the limb and the clothes over it) is read off the body. Returns the held copy, or null. + */ +/proc/wire_ghost_limb(mob/living/carbon/victim, obj/item/bodypart/limb, seared, spray_dir) + if(QDELETED(victim) || !isturf(victim.loc) || limb?.owner != victim) + return null + var/obj/effect/abstract/cyberware_anim/wire_ghost/ghost = new(null) + ghost.zone = limb.body_zone + ghost.seared = seared + ghost.spray_dir = spray_dir + ghost.cut_at = get_turf(victim) + ghost.dress(victim, limb) + ghost.bearer = victim + ghost.RegisterSignal(victim, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + victim.vis_contents += ghost + ghost.slide_timer = addtimer(CALLBACK(ghost, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim/wire_ghost, slide)), WIRE_CUT_DELAY, TIMER_STOPPABLE) + // Whatever happens to the body meanwhile, it never outlives its beat. + QDEL_IN(ghost, WIRE_CUT_DELAY + WIRE_SLIDE_TIME + WIRE_FALL_TIME + 0.3 SECONDS) + return ghost + +/// Builds the picture: the limb as the body drew it, with the clothes over it cut to its shape. +/obj/effect/abstract/cyberware_anim/wire_ghost/proc/dress(mob/living/carbon/victim, obj/item/bodypart/limb) + icon = null + layer = WIRE_GHOST_LAYER + var/mob/living/carbon/human/dressed = ishuman(victim) ? victim : null + var/reshaped = dressed && dressed.mob_height != HUMAN_HEIGHT_MEDIUM + // The dropped form carries its bruising and burns, which the body draws on a layer of its own. + for(var/image/part_image in limb.get_limb_icon(TRUE, victim)) + if(PLANE_TO_TRUE(part_image.plane) == EMISSIVE_PLANE) + continue + if(reshaped) + dressed.apply_height_filters(part_image) + overlays += part_image + if(!dressed || (dressed.bodyshape & (BODYSHAPE_MONKEY | BODYSHAPE_DIGITIGRADE))) + return + var/list/layers = list(UNIFORM_LAYER, SUIT_LAYER) + layers += (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_R_ARM) ? GLOVES_LAYER : SHOES_LAYER + var/mutable_appearance/clothes = mutable_appearance() + clothes.appearance_flags = KEEP_TOGETHER + var/found = FALSE + for(var/index in layers) + var/mutable_appearance/worn = dressed.overlays_standing[index] + if(!istype(worn)) + continue + clothes.overlays += new /mutable_appearance(worn) + found = TRUE + if(!found) + return + var/mutable_appearance/cutout = mutable_appearance(HV_SLEEVE_MASK_ICON, "limb_[zone]") + cutout.render_target = "*wire_ghost [REF(src)]" + clothes.overlays += cutout + clothes.add_filter("wire_ghost_cutout", 0, alpha_mask_filter(render_source = cutout.render_target)) + overlays += clothes + +/// The severed limb on the floor stays unseen until this lands, then fades in where this falls. +/obj/effect/abstract/cyberware_anim/wire_ghost/proc/hold_severed(obj/item/bodypart/severed) + if(QDELETED(severed) || !isturf(severed.loc)) + return + severed_ref = WEAKREF(severed) + var/mob/living/body = bearer + // A dropped limb draws itself shifted to the middle of its tile (px_x, px_y); undo that, so it lies + // where the held one falls: a few pixels out from where it was, down by the feet. + severed.pixel_x = wire_limb_side(zone, body ? body.dir : SOUTH) * 3 - severed.px_x + severed.pixel_y = -4 - severed.px_y + // The server value is 255 from here on; only the picture waits. + severed.alpha = 0 + animate(severed, alpha = 0, time = WIRE_CUT_DELAY + WIRE_SLIDE_TIME) + animate(alpha = 255, time = WIRE_FALL_TIME) + severed.alpha = 255 + +/// The limb leaves: it slides along the cut, turns, falls and fades. The stump shows, and the blood goes. +/obj/effect/abstract/cyberware_anim/wire_ghost/proc/slide() + slide_timer = null + var/mob/living/carbon/victim = bearer + if(QDELETED(victim)) + qdel(src) + return + victim.hv_reveal_stump(zone) + if(victim.get_bodypart(zone) || get_turf(victim) != cut_at) + // Put back on, or carried off: nothing slides, the picture just goes. + animate(src, alpha = 0, time = 0.1 SECONDS) + QDEL_IN(src, 0.1 SECONDS) + return + var/side = wire_limb_side(zone, victim.dir) + var/matrix/turned = matrix() + turned.Turn(side * 20) + var/matrix/fallen = matrix() + fallen.Turn(side * 45) + animate(src, pixel_w = side * 2, pixel_z = -1, transform = turned, time = WIRE_SLIDE_TIME, easing = EASE_IN) + animate(pixel_w = side * 3, pixel_z = -4, transform = fallen, alpha = 0, time = WIRE_FALL_TIME, easing = EASE_IN) + if(!seared) + victim.hv_wire_spray(spray_dir, 2) + QDEL_IN(src, WIRE_SLIDE_TIME + WIRE_FALL_TIME) + +// ---- The seam ---- + +/// The cut across `zone`: a flash that cools and beads with blood, or a seared seam that glows down to embers. +/proc/wire_seam_fx(mob/living/carbon/victim, zone, seared) + if(QDELETED(victim)) + return + if(seared) + cyberware_anim_play(victim, WIRE_GORE_ICON, "wire_cut_hot_[zone]", WIRE_FX_LAYER, 1.1 SECONDS, glow = TRUE) + victim.mob_light(1.5, 0.8, "#ff9a3c", 0.6 SECONDS) + playsound(victim, 'sound/effects/wounds/sizzle1.ogg', 60, TRUE) + wire_stump_smoke(victim, zone) + return + cyberware_anim_play(victim, WIRE_GORE_ICON, "wire_cut_[zone]", WIRE_FX_LAYER, 0.7 SECONDS, glow = TRUE) + if(victim.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + var/obj/effect/abstract/cyberware_anim/beads = cyberware_anim_play(victim, WIRE_GORE_ICON, "wire_cut_blood_[zone]", WIRE_FX_LAYER - 0.1, 0.9 SECONDS) + if(beads) + beads.appearance_flags |= RESET_COLOR + beads.color = wire_blood_color(victim) + +/// A seared stump smokes for a few seconds. +/obj/effect/abstract/cyberware_anim/wire_smoke + name = "smoke" + icon = null + +/obj/effect/abstract/cyberware_anim/wire_smoke/proc/stop_smoking() + particles?.spawning = 0 + +/proc/wire_stump_smoke(mob/living/carbon/victim, zone) + var/obj/effect/abstract/cyberware_anim/wire_smoke/smoke = new(null) + smoke.layer = WIRE_FX_LAYER + var/particles/smoke/steam/mild/puff = new + var/side = wire_limb_side(zone, victim.dir) + var/arm = (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_R_ARM) + puff.position = list(side * 6, arm ? 4 : -6, 0) + smoke.particles = puff + smoke.bearer = victim + smoke.RegisterSignal(victim, COMSIG_QDELETING, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim, on_bearer_deleted)) + victim.vis_contents += smoke + addtimer(CALLBACK(smoke, TYPE_PROC_REF(/obj/effect/abstract/cyberware_anim/wire_smoke, stop_smoking)), WIRE_ARC_SMOKE_TIME - 1 SECONDS) + QDEL_IN(smoke, WIRE_ARC_SMOKE_TIME) + +// ---- Through two bodies ---- + +/// The line of the wire through `target` and `behind`, and on past them; then the same line in blood. +/obj/item/melee/cyberware_monowire/proc/wire_through_fx(mob/living/target, mob/living/behind, direction) + var/turf/start = get_turf(target) + var/turf/target_turf = get_turf(behind) + if(!start || !target_turf) + return + var/turf/finish = get_step(target_turf, direction) || target_turf + var/dx = (direction & EAST) ? 1 : ((direction & WEST) ? -1 : 0) + var/dy = (direction & NORTH) ? 1 : ((direction & SOUTH) ? -1 : 0) + // From half a tile before the target, at chest height. + start.Beam(finish, icon_state = through_state(), icon = WIRE_GORE_ICON, time = 0.15 SECONDS, override_origin_pixel_x = -dx * 12, override_origin_pixel_y = -dy * 12 + 2, override_target_pixel_y = 2) + if(behind.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + start.Beam(finish, icon_state = "blood_line", icon = WIRE_GORE_ICON, time = 0.7 SECONDS, beam_color = wire_blood_color(behind), emissive = FALSE, override_origin_pixel_x = -dx * 12, override_origin_pixel_y = -dy * 12 + 2, override_target_pixel_y = 2) + // One streak through both tiles and on. + if(target.can_bleed(BLOOD_COVER_TURFS) == BLEED_SPLATTER && !isgroundlessturf(start)) + hv_blood_lay_spray(start, /obj/effect/decal/cleanable/blood/hv_spray/streak, direction, target.get_blood_dna_list(), target.get_static_viruses()) + if(!isgroundlessturf(target_turf)) + hv_blood_lay_spray(target_turf, /obj/effect/decal/cleanable/blood/hv_spray/streak, direction, behind.get_blood_dna_list(), behind.get_static_viruses()) + behind.hv_wire_spray(direction, 2) + +/obj/item/melee/cyberware_monowire/proc/through_state() + return "wire_through" + +/obj/item/melee/cyberware_monowire/live/through_state() + return "wire_through_live" diff --git a/voidcrew/modules/hyperviolence/wire/wire_users.dm b/voidcrew/modules/hyperviolence/wire/wire_users.dm new file mode 100644 index 00000000000..ca90223aff7 --- /dev/null +++ b/voidcrew/modules/hyperviolence/wire/wire_users.dm @@ -0,0 +1,55 @@ +/** + * Who may use the Widowline's own moves: the aimed sever, the crossing cut and the garrote. + * + * Players can. Human NPCs can't yet: the owner wants bounty criminals to use them later, and this is the one + * place that opens them, for example `return wielder.client || wielder.npc_rank >= HUMAN_NPC_RANK_BOSS` + * for bosses only. Everything else a wire does (its damage, the cleave and through-cut, clean cuts, blood on + * the wire, tg's own dismemberment drawn clean) belongs to the weapon and works for any wielder, as it did + * before these moves existed. + * + * Each move also has an entry point that needs no mouse and no target doll, for an AI to call once this + * lets it: wire_aimed_swing() (the aimed sever), wire_crossing_swing() (the crossing cut) and + * wire_start_garrote() (the garrote, wire_garrote.dm). Each still asks this proc first. + */ +/proc/hv_wire_moves_allowed(mob/living/wielder) + if(!isliving(wielder)) + return FALSE + if(istype(wielder, /mob/living/carbon/human/npc)) + return !!wielder.client + return TRUE + +/** + * An aimed swing at `victim`'s `zone`, as a player's click with that zone on the target doll: the wire's cut, + * and the sever if that arm or leg is hurt enough. Where it lands is tg's aim roll, as for anyone. Never starts + * a garrote. Returns TRUE if it swung. + */ +/obj/item/melee/cyberware_monowire/proc/wire_aimed_swing(mob/living/user, mob/living/victim, zone) + if(!isliving(user) || !isliving(victim) || !user.is_holding(src) || HAS_TRAIT(user, TRAIT_HANDS_BLOCKED)) + return FALSE + if(!user.CanReach(victim, src)) + return FALSE + if(user.get_active_held_item() != src) + user.swap_hand(user.get_held_index_of_item(src), silent = TRUE) + var/old_zone = user.zone_selected + var/old_combat_mode = user.combat_mode + user.zone_selected = zone + user.set_combat_mode(TRUE) + garrote_blow = TRUE + melee_attack_chain(user, victim) + if(!QDELETED(src)) + garrote_blow = FALSE + if(!QDELETED(user)) + user.zone_selected = old_zone + user.set_combat_mode(old_combat_mode) + user.changeNext_move(CLICK_CD_MELEE) + return TRUE + +/// The crossing cut on demand: with a wire out in each hand, this aimed swing crosses both through `victim` if it +/// lands, whatever the count. Returns TRUE if it swung as a crossing cut. +/obj/item/melee/cyberware_monowire/proc/wire_crossing_swing(mob/living/user, mob/living/victim, zone = BODY_ZONE_CHEST) + if(!hv_wire_moves_allowed(user) || wire_count_held(user) < 2) + return FALSE + force_cross = TRUE + . = wire_aimed_swing(user, victim, zone) + if(!QDELETED(src)) + force_cross = FALSE diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 2157dfdf234..2f7cdb3c4fc 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -85,6 +85,7 @@ #include "voidcrew_cyberware_passives.dm" #include "voidcrew_cyberware_soak.dm" #include "voidcrew_cyberware_text.dm" +#include "voidcrew_cyberware_widowline.dm" #include "voidcrew_cyberware_zoom.dm" #include "voidcrew_derelict_helpers.dm" #include "voidcrew_derelict_layout.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_widowline.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_widowline.dm new file mode 100644 index 00000000000..c1479afa4a7 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_widowline.dm @@ -0,0 +1,587 @@ +/** + * The Widowline monowire against the gore layer (voidcrew/modules/hyperviolence/wire/): clean severs and their + * thresholds, the crossing cut, the Arc's seared stumps, the delayed cut's held limb, the through-cut, blood on + * the wire, the swing out of the spool and back, the garrote, and the sleeve mask on every stump. + * + * Fork defines are included after the tests, so these use literals, each with a comment naming the define. + * Cuts are driven through the wire's own hooks (wire_cut_begin(), apply_damage(), wire_cut_end()) so the zone + * they land on is not left to tg's 80% aim roll. + */ + +/// Shared helpers; not a test itself +/datum/unit_test/voidcrew_widowline + abstract_type = /datum/unit_test/voidcrew_widowline + +/// The floor `dx`, `dy` tiles from the test room's bottom-left corner +/datum/unit_test/voidcrew_widowline/proc/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 human holding a wire of `wire_type` in the active hand +/datum/unit_test/voidcrew_widowline/proc/wielder(turf/where, wire_type = /obj/item/melee/cyberware_monowire) + var/mob/living/carbon/human/consistent/user = allocate(/mob/living/carbon/human/consistent, where) + var/obj/item/melee/cyberware_monowire/wire = allocate(wire_type) + TEST_ASSERT(user.put_in_active_hand(wire), "Fixture: the wielder couldn't hold the wire") + user.set_combat_mode(TRUE) + return user + +/// The wire in `user`'s active hand +/datum/unit_test/voidcrew_widowline/proc/held(mob/living/user) + return user.get_active_held_item() + +/// One cut of `wire` held by `user` into `victim`'s `zone`, aimed there, for the wire's force. Returns the damage dealt. +/datum/unit_test/voidcrew_widowline/proc/cut(mob/living/user, mob/living/victim, zone, aim = zone) + var/obj/item/melee/cyberware_monowire/wire = held(user) + user.zone_selected = aim + wire.wire_cut_begin(victim, user) + . = victim.apply_damage(wire.force, BRUTE, zone, 0, wound_bonus = CANT_WOUND, sharpness = SHARP_EDGED, attack_direction = get_dir(user, victim), attacking_item = wire) + wire.wire_cut_end(victim) + +/// The texts queued on `victim`'s hit, joined +/datum/unit_test/voidcrew_widowline/proc/lines(mob/living/victim) + . = "" + for(var/list/line as anything in victim.hv_hit?.lines) + . += "[line[1]] | " + +/// Flying hitsplatter near `where` +/datum/unit_test/voidcrew_widowline/proc/splatter(atom/where) + . = 0 + for(var/obj/effect/decal/cleanable/blood/hitsplatter/splatter in range(6, where)) + .++ + +/// The held limb (wire_ghost) drawn on `victim`, if any +/datum/unit_test/voidcrew_widowline/proc/get_ghost(mob/living/victim) + for(var/obj/effect/abstract/cyberware_anim/wire_ghost/ghost in victim.vis_contents) + return ghost + +/// When a limb comes off: only an aimed cut on an arm or leg already hurt enough, never a fresh limb, a corpse, +/// an Atlas frame, the head or chest; downed bodies at a lower threshold. Off clean: dropped at the body, no fan. +/datum/unit_test/voidcrew_widowline/wire_sever + +/datum/unit_test/voidcrew_widowline/wire_sever/Run() + var/mob/living/carbon/human/user = wielder(spot(0, 1)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + + // A fresh arm takes the cut and stays on. + cut(user, victim, BODY_ZONE_L_ARM) + TEST_ASSERT_NOTNULL(victim.get_bodypart(BODY_ZONE_L_ARM), "A fresh arm came off the first wire cut") + // Not aimed there: never, however hurt. + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + cut(user, victim, BODY_ZONE_L_ARM, aim = BODY_ZONE_CHEST) + TEST_ASSERT_NOTNULL(victim.get_bodypart(BODY_ZONE_L_ARM), "An arm came off a cut aimed elsewhere") + + // Aimed at a badly hurt arm (60%, WIRE_SEVER_AT): off, clean. + var/obj/item/bodypart/right = victim.get_bodypart(BODY_ZONE_R_ARM) + right.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + var/before = splatter(victim) + cut(user, victim, BODY_ZONE_R_ARM) + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_R_ARM), "An aimed cut didn't take off an arm at 60%") + TEST_ASSERT_EQUAL(right.loc, get_turf(victim), "The severed arm was flung instead of dropping at the body") + TEST_ASSERT_EQUAL(splatter(victim), before, "A wire sever threw tg's flying hitsplatter") + TEST_ASSERT(findtext(lines(victim), "comes away clean"), "The sever's line didn't join the hit: [lines(victim)]") + TEST_ASSERT(!HAS_TRAIT(victim, "hv_clean_cut"), "TRAIT_HV_CLEAN_CUT outlived the cut") + + // Head and chest never, even hurt enough on someone down. + var/mob/living/carbon/human/consistent/stunned = allocate(/mob/living/carbon/human/consistent, spot(3, 3)) + stunned.Paralyze(100 SECONDS) + var/obj/item/bodypart/head = stunned.get_bodypart(BODY_ZONE_HEAD) + head.receive_damage(brute = 60, wound_bonus = CANT_WOUND) + var/obj/item/bodypart/chest = stunned.get_bodypart(BODY_ZONE_CHEST) + chest.receive_damage(brute = 60, wound_bonus = CANT_WOUND) + var/mob/living/carbon/human/attacker = wielder(spot(3, 2)) + cut(attacker, stunned, BODY_ZONE_HEAD) + TEST_ASSERT_NOTNULL(stunned.get_bodypart(BODY_ZONE_HEAD), "A wire cut took a head off") + cut(attacker, stunned, BODY_ZONE_CHEST) + TEST_ASSERT_NOTNULL(stunned.get_bodypart(BODY_ZONE_CHEST), "A wire cut took a chest off") + + // Down: a fifth is enough (WIRE_SEVER_AT_DOWN); less isn't. + var/mob/living/carbon/human/consistent/downed = allocate(/mob/living/carbon/human/consistent, spot(1, 2)) + downed.Paralyze(100 SECONDS) + var/obj/item/bodypart/leg = downed.get_bodypart(BODY_ZONE_L_LEG) + leg.receive_damage(brute = 5, wound_bonus = CANT_WOUND) + cut(user, downed, BODY_ZONE_L_LEG) + TEST_ASSERT_NOTNULL(downed.get_bodypart(BODY_ZONE_L_LEG), "A leg on someone down came off at 10%") + cut(user, downed, BODY_ZONE_L_LEG) + TEST_ASSERT_NULL(downed.get_bodypart(BODY_ZONE_L_LEG), "A leg on someone down didn't come off past 20%") + + // A corpse keeps its limbs. + var/mob/living/carbon/human/consistent/corpse = allocate(/mob/living/carbon/human/consistent, spot(2, 1)) + arm = corpse.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 45, wound_bonus = CANT_WOUND) + corpse.death() + cut(user, corpse, BODY_ZONE_L_ARM) + TEST_ASSERT_NOTNULL(corpse.get_bodypart(BODY_ZONE_L_ARM), "A wire cut took an arm off a corpse") + + // An Atlas frame keeps its limbs. + var/mob/living/carbon/human/consistent/framed = allocate(/mob/living/carbon/human/consistent, spot(2, 2)) + ADD_TRAIT(framed, TRAIT_NODISMEMBER, TRAIT_SOURCE_UNIT_TESTS) + arm = framed.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 45, wound_bonus = CANT_WOUND) + cut(user, framed, BODY_ZONE_L_ARM) + TEST_ASSERT_NOTNULL(framed.get_bodypart(BODY_ZONE_L_ARM), "A wire cut took an arm off through an Atlas frame") + +/// Mid wire cut tg's own roll never flings a limb: the wire takes it off clean after the hit. +/datum/unit_test/voidcrew_widowline/hv_wire_owed_sever + +/datum/unit_test/voidcrew_widowline/hv_wire_owed_sever/Run() + var/mob/living/carbon/human/user = wielder(spot(0, 1)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_L_ARM) + ADD_TRAIT(victim, "hv_clean_cut", TRAIT_SOURCE_UNIT_TESTS) // TRAIT_HV_CLEAN_CUT + var/datum/wound/loss/torn = new + torn.apply_dismember(arm, WOUND_SLASH) + REMOVE_TRAIT(victim, "hv_clean_cut", TRAIT_SOURCE_UNIT_TESTS) + TEST_ASSERT_EQUAL(arm.owner, victim, "tg tore an arm off mid wire cut") + TEST_ASSERT_EQUAL(victim.hv_wire_owed_sever, arm, "The arm tg wanted wasn't left for the wire") + victim.hv_wire_owed_sever = null + + // Through the wire's own hook it comes off, though the arm was fresh and the cut aimed elsewhere. + var/obj/item/melee/cyberware_monowire/wire = held(user) + user.zone_selected = BODY_ZONE_CHEST + wire.wire_cut_begin(victim, user) + torn = new + torn.apply_dismember(arm, WOUND_SLASH) + victim.apply_damage(wire.force, BRUTE, BODY_ZONE_CHEST, 0, wound_bonus = CANT_WOUND, sharpness = SHARP_EDGED, attack_direction = EAST, attacking_item = wire) + wire.wire_cut_end(victim) + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_L_ARM), "The wire didn't take the arm tg's roll wanted") + TEST_ASSERT_EQUAL(arm.loc, get_turf(victim), "The owed arm was flung") + + // A clean cut throws no hitsplatter; without it tg's does. + var/before = splatter(victim) + ADD_TRAIT(victim, "hv_clean_cut", TRAIT_SOURCE_UNIT_TESTS) + victim.spray_blood(EAST, 3) + TEST_ASSERT_EQUAL(splatter(victim), before, "spray_blood() threw hitsplatter during a clean cut") + REMOVE_TRAIT(victim, "hv_clean_cut", TRAIT_SOURCE_UNIT_TESTS) + victim.spray_blood(EAST, 3) + TEST_ASSERT(splatter(victim) > before, "spray_blood() is dead outside a clean cut") + +/// With a wire out in each hand every third landed hit crosses: an X of light, and a sever at half the damage. +/datum/unit_test/voidcrew_widowline/crossing + +/datum/unit_test/voidcrew_widowline/crossing/Run() + var/mob/living/carbon/human/user = wielder(spot(0, 1)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 15, wound_bonus = CANT_WOUND) + + // One wire: 30% isn't enough, every time. + for(var/i in 1 to 3) + cut(user, victim, BODY_ZONE_CHEST) + cut(user, victim, BODY_ZONE_L_ARM) + TEST_ASSERT_NOTNULL(victim.get_bodypart(BODY_ZONE_L_ARM), "One wire took an arm at 30%") + TEST_ASSERT_EQUAL(user.wire_cross_hits, 0, "One wire counted toward a crossing cut") + victim.fully_heal(HEAL_DAMAGE) + arm = victim.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 15, wound_bonus = CANT_WOUND) + + // Two wires: the third landed hit crosses. + var/obj/item/melee/cyberware_monowire/second = allocate(/obj/item/melee/cyberware_monowire) + TEST_ASSERT(user.put_in_inactive_hand(second), "Fixture: no second hand for the second wire") + cut(user, victim, BODY_ZONE_CHEST) + cut(user, victim, BODY_ZONE_CHEST) + var/obj/item/melee/cyberware_monowire/wire = held(user) + TEST_ASSERT(wire.wire_cross_due(user), "The third hit with two wires out isn't the crossing one") + // The crossing swing shows its X instead of the single wire's impact. + wire.crossing = TRUE + wire.animate_attack(user, victim) + wire.crossing = FALSE + var/found = FALSE + for(var/obj/effect/abstract/cyberware_anim/look in victim.vis_contents) + if(look.icon_state == "impact_widowline_cross") + found = TRUE + TEST_ASSERT_NOTEQUAL(look.icon_state, "impact_widowline", "The crossing swing drew the single wire's impact") + TEST_ASSERT(found, "The crossing swing drew no X") + cut(user, victim, BODY_ZONE_L_ARM) + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_L_ARM), "The crossing cut didn't take an arm at 30%") + TEST_ASSERT_EQUAL(user.wire_cross_hits, 0, "The count didn't start again after the crossing cut") + + // Hits too far apart start the count again. + cut(user, victim, BODY_ZONE_CHEST) + cut(user, victim, BODY_ZONE_CHEST) + user.wire_cross_last -= 10 SECONDS + TEST_ASSERT(!wire.wire_cross_due(user), "Hits seconds apart still cross") + + // The entry point with no mouse behind it: a crossing swing on demand, the target doll left as it was. + for(var/obj/effect/abstract/cyberware_anim/look in victim.vis_contents) + qdel(look) + user.zone_selected = BODY_ZONE_HEAD + TEST_ASSERT(wire.wire_crossing_swing(user, victim, BODY_ZONE_CHEST), "wire_crossing_swing() didn't swing") + found = FALSE + for(var/obj/effect/abstract/cyberware_anim/look in victim.vis_contents) + if(look.icon_state == "impact_widowline_cross") + found = TRUE + TEST_ASSERT(found, "wire_crossing_swing() didn't cross") + TEST_ASSERT_EQUAL(user.zone_selected, BODY_ZONE_HEAD, "An aimed swing left the wielder's target doll changed") + TEST_ASSERT(!wire.force_cross, "A crossing swing left the next one crossing too") + +/// The Arc sears the stump: little blood, the stump drawn charred; not on a body its shock can't reach. +/datum/unit_test/voidcrew_widowline/wire_sears + +/datum/unit_test/voidcrew_widowline/wire_sears/Run() + var/mob/living/carbon/human/user = wielder(spot(0, 1)) + var/mob/living/carbon/human/attacker = wielder(spot(0, 3), /obj/item/melee/cyberware_monowire/live) + + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + var/blood_before = victim.blood_volume + cut(user, victim, BODY_ZONE_L_ARM) + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_L_ARM), "Fixture: the plain wire didn't sever") + TEST_ASSERT(blood_before - victim.blood_volume >= 40, "A plain sever lost too little blood ([blood_before - victim.blood_volume])") + TEST_ASSERT(!LAZYACCESS(victim.hv_stump_burnt, BODY_ZONE_L_ARM), "The plain wire seared a stump") + + var/mob/living/carbon/human/consistent/body = allocate(/mob/living/carbon/human/consistent, spot(1, 3)) + arm = body.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + blood_before = body.blood_volume + cut(attacker, body, BODY_ZONE_L_ARM) + TEST_ASSERT_NULL(body.get_bodypart(BODY_ZONE_L_ARM), "The Arc didn't sever") + TEST_ASSERT(blood_before - body.blood_volume <= 20, "A seared sever lost as much blood as a wet one ([blood_before - body.blood_volume])") + TEST_ASSERT(LAZYACCESS(body.hv_stump_burnt, BODY_ZONE_L_ARM), "The Arc's stump isn't seared") + TEST_ASSERT(findtext(lines(body), "seared shut"), "The seared sever's line is missing: [lines(body)]") + body.hv_reveal_stump(BODY_ZONE_L_ARM) + var/charred = FALSE + for(var/image/gore_image as anything in body.hv_gore_overlays) + if(gore_image.icon_state == "stump_l_arm_blood" && gore_image.color == "#2a1b15") // WIRE_BURNT_STUMP_COLOR + charred = TRUE + TEST_ASSERT(charred, "The seared stump isn't drawn charred") + // A limb back on forgets the sear. + body.regenerate_limb(BODY_ZONE_L_ARM) + body.update_damage_overlays() + TEST_ASSERT(!LAZYACCESS(body.hv_stump_burnt, BODY_ZONE_L_ARM), "A new arm kept the old stump's sear") + + // Shock-immune: a plain wet sever. + var/mob/living/carbon/human/consistent/grounded = allocate(/mob/living/carbon/human/consistent, spot(2, 3)) + ADD_TRAIT(grounded, TRAIT_SHOCKIMMUNE, TRAIT_SOURCE_UNIT_TESTS) + arm = grounded.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + cut(attacker, grounded, BODY_ZONE_L_ARM) + TEST_ASSERT_NULL(grounded.get_bodypart(BODY_ZONE_L_ARM), "The Arc didn't sever a shock-immune arm") + TEST_ASSERT(!LAZYACCESS(grounded.hv_stump_burnt, BODY_ZONE_L_ARM), "The Arc seared a stump its shock can't reach") + +/// The delayed cut: the limb stays drawn on, its stump held back, until it slides; nothing lingers after, and a +/// body deleted, restored or carried off mid-beat takes the picture with it. +/datum/unit_test/voidcrew_widowline/wire_ghost + +/datum/unit_test/voidcrew_widowline/wire_ghost/Run() + var/mob/living/carbon/human/user = wielder(spot(0, 1)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + cut(user, victim, BODY_ZONE_L_ARM) + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_L_ARM), "Fixture: the arm didn't come off") + var/obj/effect/abstract/cyberware_anim/wire_ghost/ghost = get_ghost(victim) + TEST_ASSERT_NOTNULL(ghost, "The severed arm isn't held on its body for the beat") + TEST_ASSERT(length(ghost.overlays), "The held arm draws nothing") + TEST_ASSERT(!("stump_l_arm_blood" in victim.hv_gore_states()), "The stump shows while the arm is still drawn on") + TEST_ASSERT_EQUAL(arm.alpha, 255, "The severed arm on the floor isn't really there yet (server alpha)") + ghost.slide() + TEST_ASSERT(("stump_l_arm_blood" in victim.hv_gore_states()), "The stump didn't show when the arm slid off") + sleep(1 SECONDS) + TEST_ASSERT_NULL(get_ghost(victim), "The held arm lingered after it slid off") + + // The body deleted mid-beat. + var/mob/living/carbon/human/consistent/doomed = allocate(/mob/living/carbon/human/consistent, spot(1, 2)) + arm = doomed.get_bodypart(BODY_ZONE_R_ARM) + arm.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + cut(user, doomed, BODY_ZONE_R_ARM) + ghost = get_ghost(doomed) + TEST_ASSERT_NOTNULL(ghost, "Fixture: no held arm on the doomed body") + qdel(doomed) + TEST_ASSERT(QDELETED(ghost), "The held arm outlived its body") + + // The limb back on mid-beat: the picture just goes. + var/mob/living/carbon/human/consistent/healed = allocate(/mob/living/carbon/human/consistent, spot(2, 2)) + arm = healed.get_bodypart(BODY_ZONE_R_ARM) + arm.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + cut(user, healed, BODY_ZONE_R_ARM) + ghost = get_ghost(healed) + healed.regenerate_limb(BODY_ZONE_R_ARM) + ghost.slide() + TEST_ASSERT(!LAZYACCESS(healed.hv_stump_reveal_at, BODY_ZONE_R_ARM), "A restored arm left its stump held back") + sleep(0.3 SECONDS) + TEST_ASSERT_NULL(get_ghost(healed), "The held arm stayed on a body whose arm grew back") + + // Death mid-beat: it still never outlives the hard timer. + var/mob/living/carbon/human/consistent/dying = allocate(/mob/living/carbon/human/consistent, spot(2, 1)) + arm = dying.get_bodypart(BODY_ZONE_L_LEG) + arm.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + cut(user, dying, BODY_ZONE_L_LEG) + TEST_ASSERT_NOTNULL(get_ghost(dying), "Fixture: no held leg") + dying.death() + sleep(1.5 SECONDS) + TEST_ASSERT_NULL(get_ghost(dying), "The held leg outlived its beat on a body that died") + +/// The wire carries on into whoever stands behind the target and cleaves the rest, as lines of the one hit. +/datum/unit_test/voidcrew_widowline/wire_cleave + +/datum/unit_test/voidcrew_widowline/wire_cleave/Run() + var/mob/living/carbon/human/user = wielder(spot(0, 1)) + var/mob/living/carbon/human/consistent/target = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + var/mob/living/carbon/human/consistent/behind = allocate(/mob/living/carbon/human/consistent, spot(2, 1)) + var/mob/living/carbon/human/consistent/beside = allocate(/mob/living/carbon/human/consistent, spot(1, 2)) + var/obj/item/melee/cyberware_monowire/wire = held(user) + wire.wire_cleave(target, user) + TEST_ASSERT(behind.getBruteLoss() > 0, "The mob behind the target took no cut") + TEST_ASSERT(beside.getBruteLoss() > 0, "The mob beside the target took no cleave") + var/result = lines(target) + TEST_ASSERT(findtext(result, "carries through into [behind]"), "The through-cut's line didn't join the hit: [result]") + TEST_ASSERT(findtext(result, "sings through everything beside"), "The cleave's line didn't join the hit: [result]") + var/streak = locate(/obj/effect/decal/cleanable/blood/hv_spray/streak) in get_turf(behind) + TEST_ASSERT_NOTNULL(streak, "The through-cut left no streak through the second body's tile") + TEST_ASSERT(!HAS_TRAIT(behind, "hv_clean_cut"), "TRAIT_HV_CLEAN_CUT outlived the cleave") + + // A real swing does all of it in one message. + target.hv_flush_hit() + behind.fully_heal(HEAL_DAMAGE) + user.zone_selected = BODY_ZONE_CHEST + wire.melee_attack_chain(user, target) + TEST_ASSERT(target.getBruteLoss() > 0, "The swing didn't land") + TEST_ASSERT(behind.getBruteLoss() > 0, "A landed swing didn't carry through") + +/// A cut wets the wire with the victim's blood, which drips, dries and flicks off on stowing; the DNA stays. The Arc stays clean. +/datum/unit_test/voidcrew_widowline/wire_bloody + +/datum/unit_test/voidcrew_widowline/wire_bloody/Run() + var/mob/living/carbon/human/user = wielder(spot(1, 1)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(2, 1)) + var/obj/item/melee/cyberware_monowire/wire = held(user) + TEST_ASSERT_EQUAL(wire.blood_stage(), 0, "A new wire is bloody") + cut(user, victim, BODY_ZONE_CHEST) + TEST_ASSERT_EQUAL(wire.blood_stage(), 2, "A cut didn't wet the wire") + TEST_ASSERT(length(GET_ATOM_BLOOD_DNA(wire)), "A cut left no blood DNA on the wire") + var/found = FALSE + for(var/mutable_appearance/look in wire.worn_overlays(mutable_appearance(wire.righthand_file, "monowire"), TRUE, wire.righthand_file)) + if(look.icon_state == "monowire_blood2") + found = TRUE + TEST_ASSERT(found, "The wet wire draws no blood in hand") + for(var/look in wire.overlays) + var/mutable_appearance/drawn = look + TEST_ASSERT_NOTEQUAL(drawn.icon_state, "itemblood", "tg's generic stain is drawn on the wire") + + // Drips, capped. + for(var/i in 1 to 12) + wire.drip() + var/drips = 0 + for(var/obj/effect/decal/cleanable/blood/drip/drop in get_turf(user)) + drips++ + TEST_ASSERT(drips > 0, "A wet wire never dripped") + TEST_ASSERT(wire.drips_left >= 0, "The drips ran past their cap") + + // Dry to stained. + wire.wet_until = world.time - 1 + wire.update_blood_look() + TEST_ASSERT_EQUAL(wire.blood_stage(), 1, "The wire didn't dry to a stain") + + // Wet again, stowed: drops in front, the look clears, the DNA stays. + cut(user, victim, BODY_ZONE_CHEST) + user.setDir(EAST) + wire.flick_blood(user) + TEST_ASSERT_NOTNULL(locate(/obj/effect/decal/cleanable/blood/drip) in get_step(user, EAST), "Stowing a wet wire flicked no blood ahead") + wire.clear_blood_look() + TEST_ASSERT_EQUAL(wire.blood_stage(), 0, "The flick left the wire looking bloody") + TEST_ASSERT(length(GET_ATOM_BLOOD_DNA(wire)), "The flick wiped the blood DNA") + + // A wash clears it all. + cut(user, victim, BODY_ZONE_CHEST) + wire.wash(CLEAN_TYPE_BLOOD) + TEST_ASSERT_EQUAL(wire.blood_stage(), 0, "A wash left the wire bloody") + + // The Arc burns its blood off. + var/mob/living/carbon/human/attacker = wielder(spot(1, 3), /obj/item/melee/cyberware_monowire/live) + var/obj/item/melee/cyberware_monowire/arc = held(attacker) + cut(attacker, victim, BODY_ZONE_CHEST) + TEST_ASSERT_EQUAL(arc.blood_stage(), 0, "The Arc shows blood") + +/// The wire swings out of the spool and reels back in, the held wire hidden for the swing; a wet one flicks. +/datum/unit_test/voidcrew_widowline/play_deploy + +/datum/unit_test/voidcrew_widowline/play_deploy/Run() + var/mob/living/carbon/human/consistent/bearer = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + var/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire/housing = allocate(/obj/item/organ/cyberimp/arm/toolkit/cyberware/monowire) + TEST_ASSERT(housing.Insert(bearer, special = TRUE), "Fixture: the housing wouldn't go in") + housing.ui_action_click() + var/obj/item/melee/cyberware_monowire/wire = housing.active_item + TEST_ASSERT(istype(wire) && bearer.is_holding(wire), "The wire didn't deploy") + var/list/states = list() + for(var/obj/effect/abstract/cyberware_anim/look in bearer.vis_contents) + states += look.icon_state + TEST_ASSERT(("monowire_deploy_right" in states) || ("monowire_deploy_left" in states), "The wire didn't swing out: [jointext(states, ", ")]") + TEST_ASSERT(wire.inhand_hidden_until > world.time, "The held wire shows over its own swing") + + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(2, 1)) + bearer.zone_selected = BODY_ZONE_CHEST + wire.wire_cut_begin(victim, bearer) + victim.apply_damage(wire.force, BRUTE, BODY_ZONE_CHEST, 0, wound_bonus = CANT_WOUND, sharpness = SHARP_EDGED, attack_direction = EAST, attacking_item = wire) + wire.wire_cut_end(victim) + TEST_ASSERT_EQUAL(wire.blood_stage(), 2, "Fixture: the cut didn't wet the wire") + for(var/obj/effect/abstract/cyberware_anim/look in bearer.vis_contents) + qdel(look) + housing.ui_action_click() + TEST_ASSERT(wire.loc == housing, "The wire didn't stow") + states = list() + for(var/obj/effect/abstract/cyberware_anim/look in bearer.vis_contents) + states += look.icon_state + TEST_ASSERT(("monowire_retract_right" in states) || ("monowire_retract_left" in states), "The wire didn't reel in: [jointext(states, ", ")]") + TEST_ASSERT(("monowire_retract_blood2_right" in states) || ("monowire_retract_blood2_left" in states), "A wet wire reeled in clean: [jointext(states, ", ")]") + TEST_ASSERT_EQUAL(wire.blood_stage(), 0, "The wire is still bloody in its spool") + +/// The garrote: only on someone helpless (crit or stunned, not just lying down), even a fellow player; no mark; +/// a real grab; the head in hard crit, the throat otherwise, turned by a collar; broken by damage; the wire's blow. +/datum/unit_test/voidcrew_widowline/garrote + +/datum/unit_test/voidcrew_widowline/garrote/Run() + var/mob/living/carbon/human/user = wielder(spot(1, 1)) + var/obj/item/melee/cyberware_monowire/wire = held(user) + user.zone_selected = BODY_ZONE_HEAD + + // On their feet, or just lying down: no. + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(2, 1)) + TEST_ASSERT(!wire.wire_try_garrote(victim, user), "A garrote started on someone standing") + victim.Knockdown(100 SECONDS) + TEST_ASSERT(!wire.wire_try_garrote(victim, user), "A garrote started on someone only knocked down") + victim.SetKnockdown(0) + + // Stunned, though both are neutral-faction people: yes. No mark, a loop round the neck. + victim.Paralyze(100 SECONDS) + TEST_ASSERT(wire.wire_try_garrote(victim, user), "A garrote didn't start on a stunned person") + var/datum/hv_finisher/garrote/garrote = wire.garrote_running + TEST_ASSERT_NOTNULL(garrote, "The wire doesn't know its garrote") + TEST_ASSERT_NULL(garrote.mark, "The garrote put a mark over its victim") + var/obj/effect/abstract/cyberware_anim/loop = garrote.loop_ref?.resolve() + TEST_ASSERT(loop && loop.icon_state == "garrote_loop", "No loop round the neck in the wind-up") + TEST_ASSERT(user.wire_garrote_ready_at > world.time, "The garrote started with no wait after it") + TEST_ASSERT(!wire.wire_try_garrote(victim, user), "A second garrote started at once") + // The hold: a real grab, the wire tight. + garrote.advance() + TEST_ASSERT(garrote.gripped, "The garrote's hold didn't grip") + TEST_ASSERT(user.pulling == victim && user.grab_state >= GRAB_AGGRESSIVE, "The garrote's hold isn't a real aggressive grab") + loop = garrote.loop_ref?.resolve() + TEST_ASSERT(loop && loop.icon_state == "garrote_tight", "The wire didn't pull tight") + TEST_ASSERT(user.wire_garrote_ready_at - world.time > 10 SECONDS, "A garrote that got a grip has the short wait") + // The kill, not in hard crit: the throat. + for(var/i in 1 to 10) + if(!garrote.running) + break + garrote.advance() + var/obj/item/bodypart/head = victim.get_bodypart(BODY_ZONE_HEAD) + TEST_ASSERT_NOTNULL(head, "A garrote took the head off someone only stunned") + TEST_ASSERT_NOTNULL(locate(/datum/wound/slash/flesh/critical/hv_carotid) in head.wounds, "The garrote didn't slit the throat") + TEST_ASSERT_NULL(wire.garrote_running, "The wire still thinks it is garroting") + TEST_ASSERT(!victim.has_status_effect(/datum/status_effect/hv_finisher_hold), "The victim is still held") + + // Hard crit: the head, at the body's feet. + user.wire_garrote_ready_at = 0 + var/mob/living/carbon/human/consistent/dying = allocate(/mob/living/carbon/human/consistent, spot(1, 2)) + dying.apply_damage(150, OXY) + TEST_ASSERT(dying.stat >= HARD_CRIT && dying.stat != DEAD, "Fixture: not in hard crit ([dying.stat])") + user.zone_selected = BODY_ZONE_CHEST + TEST_ASSERT(!wire.wire_try_garrote(dying, user), "A click at the chest started a garrote") + TEST_ASSERT(wire.wire_start_garrote(user, dying), "wire_start_garrote() didn't start on someone in hard crit") + user.zone_selected = BODY_ZONE_HEAD + garrote = wire.garrote_running + for(var/i in 1 to 10) + if(!garrote.running) + break + garrote.advance() + var/obj/item/bodypart/taken = locate(/obj/item/bodypart/head) in get_turf(dying) + TEST_ASSERT_NULL(dying.get_bodypart(BODY_ZONE_HEAD), "A garrote in hard crit didn't take the head") + TEST_ASSERT_NOTNULL(taken, "The head wasn't left at the body") + TEST_ASSERT_NOTNULL(locate(/obj/item/organ/brain) in taken, "The head lost its brain") + + // A sealed helmet turns the wire: no slit throat, a blow instead. + user.wire_garrote_ready_at = 0 + var/mob/living/carbon/human/consistent/helmeted = allocate(/mob/living/carbon/human/consistent, spot(2, 2)) + helmeted.equip_to_slot_or_del(new /obj/item/clothing/head/helmet/space(helmeted), ITEM_SLOT_HEAD) + helmeted.Paralyze(100 SECONDS) + TEST_ASSERT(wire.wire_try_garrote(helmeted, user), "Fixture: no garrote on the helmeted victim") + garrote = wire.garrote_running + for(var/i in 1 to 10) + if(!garrote.running) + break + garrote.advance() + head = helmeted.get_bodypart(BODY_ZONE_HEAD) + TEST_ASSERT_NULL(locate(/datum/wound/slash/flesh/critical/hv_carotid) in head.wounds, "The wire slit a throat under a sealed helmet") + + // Damage on the garroter breaks it, and a garrote that never gripped has the short wait. + user.wire_garrote_ready_at = 0 + var/mob/living/carbon/human/consistent/saved = allocate(/mob/living/carbon/human/consistent, spot(0, 1)) + saved.Paralyze(100 SECONDS) + TEST_ASSERT(wire.wire_try_garrote(saved, user), "Fixture: no garrote on the last victim") + garrote = wire.garrote_running + user.apply_damage(25, BRUTE, BODY_ZONE_CHEST) + TEST_ASSERT(!garrote.running, "25 damage on the garroter didn't break the garrote") + TEST_ASSERT_EQUAL(garrote.result, 3, "The garrote ended for the wrong reason") // HV_FINISHER_DAMAGED + TEST_ASSERT(user.wire_garrote_ready_at - world.time <= 5 SECONDS, "A garrote that never gripped has the long wait") + TEST_ASSERT(!saved.has_status_effect(/datum/status_effect/hv_finisher_hold), "The broken garrote still holds its victim") + + // The garrote's blow is the wire's, never the heaviest thing in the other hand. + user.fully_heal(HEAL_ALL) + user.wire_garrote_ready_at = 0 + var/obj/item/claymore/heavy = allocate(/obj/item/claymore) + user.put_in_inactive_hand(heavy) + saved.SetParalyzed(100 SECONDS) + TEST_ASSERT(wire.wire_try_garrote(saved, user), "A garrote didn't start with a heavier weapon in the other hand") + garrote = wire.garrote_running + TEST_ASSERT(garrote.real_blow(BODY_ZONE_HEAD), "The garrote's blow didn't swing") + TEST_ASSERT_EQUAL(user.get_active_held_item(), wire, "The garrote swung the heavier weapon, not the wire") + garrote.stop(2) // HV_FINISHER_LOST_GRIP + + // The crit gates never count the wire's penetration; its cuts still do. + TEST_ASSERT_EQUAL(hv_crit_armour_pen(wire), 0, "The wire's penetration counts at the crit gates") + TEST_ASSERT_EQUAL(wire.armour_penetration, 100, "The wire lost its penetration for its cuts") + +/// Every stump: the jumpsuit is cut away where the lost limb was, and comes back with the limb. +/datum/unit_test/voidcrew_widowline/hv_mask_sleeves + +/datum/unit_test/voidcrew_widowline/hv_mask_sleeves/Run() + var/mob/living/carbon/human/consistent/body = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + body.equip_to_slot_or_del(new /obj/item/clothing/under/color/grey(body), ITEM_SLOT_ICLOTHING) + var/mutable_appearance/uniform = body.overlays_standing[UNIFORM_LAYER] + TEST_ASSERT(istype(uniform), "Fixture: no jumpsuit drawn") + TEST_ASSERT(!LAZYACCESS(uniform.filter_data, "hv_sleeve_mask"), "A whole body's jumpsuit is masked") + var/obj/item/bodypart/arm = body.get_bodypart(BODY_ZONE_L_ARM) + arm.dismember() + uniform = body.overlays_standing[UNIFORM_LAYER] + TEST_ASSERT(LAZYACCESS(uniform.filter_data, "hv_sleeve_mask"), "The jumpsuit keeps an empty sleeve on a lost arm") + body.regenerate_limb(BODY_ZONE_L_ARM) + body.update_body_parts() + uniform = body.overlays_standing[UNIFORM_LAYER] + TEST_ASSERT(!LAZYACCESS(uniform.filter_data, "hv_sleeve_mask"), "The jumpsuit stayed masked with the arm back on") + +/// The aimed sever, the crossing cut and the garrote are the wielder's moves, through one switch +/// (hv_wire_moves_allowed()): a human NPC holding a wire doesn't use them yet. +/datum/unit_test/voidcrew_widowline/hv_wire_moves_allowed + +/datum/unit_test/voidcrew_widowline/hv_wire_moves_allowed/Run() + var/mob/living/carbon/human/npc/chrome_dummy/npc = allocate(/mob/living/carbon/human/npc/chrome_dummy, spot(0, 1)) + var/mob/living/carbon/human/consistent/body = allocate(/mob/living/carbon/human/consistent, spot(0, 3)) + TEST_ASSERT(!hv_wire_moves_allowed(npc), "A human NPC may use the wire's moves") + TEST_ASSERT(hv_wire_moves_allowed(body), "A player's body may not use the wire's moves") + var/obj/item/melee/cyberware_monowire/wire = allocate(/obj/item/melee/cyberware_monowire) + TEST_ASSERT(npc.put_in_active_hand(wire), "Fixture: the NPC couldn't hold the wire") + npc.set_combat_mode(TRUE) + + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot(1, 1)) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 30, wound_bonus = CANT_WOUND) + cut(npc, victim, BODY_ZONE_L_ARM) + TEST_ASSERT_NOTNULL(victim.get_bodypart(BODY_ZONE_L_ARM), "A human NPC's aimed wire cut took an arm") + TEST_ASSERT(victim.getBruteLoss() > 0, "The NPC's wire did no damage at all") + + var/obj/item/melee/cyberware_monowire/second = allocate(/obj/item/melee/cyberware_monowire) + npc.put_in_inactive_hand(second) + npc.wire_cross_hits = 2 + npc.wire_cross_last = world.time + TEST_ASSERT(!wire.wire_cross_due(npc), "A human NPC with two wires makes the crossing cut") + + npc.zone_selected = BODY_ZONE_HEAD + var/mob/living/carbon/human/consistent/downed = allocate(/mob/living/carbon/human/consistent, spot(1, 2)) + downed.Paralyze(100 SECONDS) + TEST_ASSERT(!wire.wire_try_garrote(downed, npc), "A human NPC started a garrote") + TEST_ASSERT(!wire.wire_start_garrote(npc, downed), "A human NPC started a garrote from the entry point") + TEST_ASSERT(!wire.wire_crossing_swing(npc, victim), "A human NPC made a crossing swing from the entry point") + // Its plain aimed swing still cuts, as the wire always did. + var/before = victim.getBruteLoss() + TEST_ASSERT(wire.wire_aimed_swing(npc, victim, BODY_ZONE_CHEST), "A human NPC couldn't swing the wire at all") + TEST_ASSERT(victim.getBruteLoss() > before, "A human NPC's wire swing did nothing") From e6b181e3b3616edcd84d40ce8827ef9e7eae02b7 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:05:54 -0800 Subject: [PATCH 182/472] Remove the Quiet Word pistol and its magazine --- tools/gear_catalog/ratings/ammo.json | 1 - tools/gear_catalog/ratings/guns.json | 1 - voidcrew/icons/obj/favor_uniques.dmi | Bin 6717 -> 6358 bytes voidcrew/modules/trade/favor_uniques/vex.dm | 53 -------------------- 4 files changed, 55 deletions(-) diff --git a/tools/gear_catalog/ratings/ammo.json b/tools/gear_catalog/ratings/ammo.json index 7f5b17c6f54..99e68c72a76 100644 --- a/tools/gear_catalog/ratings/ammo.json +++ b/tools/gear_catalog/ratings/ammo.json @@ -2283,7 +2283,6 @@ "/obj/item/ammo_box/magazine/m10mm/ap": "needs another system", "/obj/item/ammo_box/magazine/m10mm/fire": "needs another system", "/obj/item/ammo_box/magazine/m10mm/hp": "needs another system", - "/obj/item/ammo_box/magazine/m10mm/quiet_word": "needs another system", "/obj/item/ammo_box/magazine/m38/empty": "no route", "/obj/item/ammo_box/magazine/m75": "needs another system", "/obj/item/ammo_box/magazine/rapier/empty": "no route", diff --git a/tools/gear_catalog/ratings/guns.json b/tools/gear_catalog/ratings/guns.json index 2059c1229f7..2df5300d562 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -1964,7 +1964,6 @@ "/obj/item/gun/ballistic/automatic/mini_uzi/chimpgun": "needs another system", "/obj/item/gun/ballistic/automatic/pistol/clandestine": "leftover", "/obj/item/gun/ballistic/automatic/pistol/clandestine/fisher": "leftover", - "/obj/item/gun/ballistic/automatic/pistol/clandestine/quiet_word": "leftover", "/obj/item/gun/ballistic/automatic/pistol/contraband": "leftover", "/obj/item/gun/ballistic/automatic/pistol/deagle/contraband": "leftover", "/obj/item/gun/ballistic/automatic/pistol/deagle/ctf": "admin", diff --git a/voidcrew/icons/obj/favor_uniques.dmi b/voidcrew/icons/obj/favor_uniques.dmi index f8b133091800f51e79b1ae64274e50e647418282..59c368a62992d08137c8329e50711a27a9536e21 100644 GIT binary patch literal 6358 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 diff --git a/voidcrew/modules/trade/favor_uniques/vex.dm b/voidcrew/modules/trade/favor_uniques/vex.dm index 8106d5e36c1..8bace2c9f1f 100644 --- a/voidcrew/modules/trade/favor_uniques/vex.dm +++ b/voidcrew/modules/trade/favor_uniques/vex.dm @@ -192,56 +192,3 @@ /obj/item/implanter/vex_insurance/Initialize(mapload) . = ..() ADD_TRAIT(src, TRAIT_NO_REPLICATE, INNATE_TRAIT) - -// ============================================================================= -// The Quiet Word: integrally suppressed 10mm sidearm -// ============================================================================= - -/** - * # The Quiet Word - * - * An Ansem (10mm clandestine pistol) rebuilt around an integral suppressor, - * following the exact pattern of the Ansem/SC Fisher - * (code/modules/projectiles/guns/ballistic/pistol.dm): suppressed = TRUE with - * can_suppress and can_unsuppress both FALSE, which the base ballistic code - * reads as "the suppressor is part of the gun", no overlay, no alt-click - * removal, and examine reports it as integral. No underbarrel gadget; this is - * just the gun. - * - * Damage ladder (all existing rounds, nothing invented): the 9mm Makarov's - * bullet is 30, 10mm is 40, a .357 is 60. Chambering it in stock 10mm lands it - * exactly in the commissioned slot, better than a suppressed 9mm, nowhere - * near a revolver. Carried weight matches a Makarov with a suppressor screwed - * on (install_suppressor() bumps the class for the same reason): it does not - * fit in a pocket. - */ -/obj/item/gun/ballistic/automatic/pistol/clandestine/quiet_word - name = "\improper Quiet Word" - desc = "A slab-sided 10mm pistol with a suppressor machined straight into the slide. There's no maker's mark anywhere on it and the serial well was never stamped. It fires with a flat cough instead of a bang, and the suppressor doesn't come off." - // The whole overlay family ([icon_state]_bolt/_bolt_locked/_empty/_mag) - // lives in the voidcrew dmi under the quiet_word name - icon = 'voidcrew/icons/obj/favor_uniques.dmi' - icon_state = "quiet_word" - w_class = WEIGHT_CLASS_NORMAL // PROVISIONAL BALANCE, carries like a suppressed pistol, no pocket concealment - suppressed = TRUE - can_suppress = FALSE - can_unsuppress = FALSE - spawn_magazine_type = /obj/item/ammo_box/magazine/m10mm/quiet_word - -/obj/item/gun/ballistic/automatic/pistol/clandestine/quiet_word/Initialize(mapload) - . = ..() - ADD_TRAIT(src, TRAIT_NO_REPLICATE, INNATE_TRAIT) - -/** - * The Quiet Word's magazine. It has its own typepath but stays inside the stock - * 10mm family: it feeds anything that accepts /obj/item/ammo_box/magazine/m10mm, - * and the Quiet Word happily takes plain Ansem magazines back. - */ -/obj/item/ammo_box/magazine/m10mm/quiet_word - name = "Quiet Word magazine (10mm)" - desc = "A slim double-stack 10mm magazine cut for the Quiet Word. Standard caliber. It feeds any 10mm pistol, and the Quiet Word isn't fussy about whose magazines it eats." - max_ammo = 10 // PROVISIONAL BALANCE, Ansem carries 8, stechkin APS 15 - -/obj/item/ammo_box/magazine/m10mm/quiet_word/Initialize(mapload) - . = ..() - ADD_TRAIT(src, TRAIT_NO_REPLICATE, INNATE_TRAIT) From 828a97b352e50b501e81b904936e4cab0cf55753 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:06:22 -0800 Subject: [PATCH 183/472] Line the Rapier up with the hand slot like other wide guns --- voidcrew/modules/nova_guns/rapier.dm | 2 -- 1 file changed, 2 deletions(-) diff --git a/voidcrew/modules/nova_guns/rapier.dm b/voidcrew/modules/nova_guns/rapier.dm index 9fe1b1a3016..c3bc31f10c1 100644 --- a/voidcrew/modules/nova_guns/rapier.dm +++ b/voidcrew/modules/nova_guns/rapier.dm @@ -24,8 +24,6 @@ suppressor_y_offset = 2 // Bulky and automatic would pick the rifle kick and sounds. This is an SMG. gun_feel_class = GUN_FEEL_CLASS_SMG - // A 48x32 icon, centred on the tile. - SET_BASE_PIXEL(-8, 0) /obj/item/gun/ballistic/automatic/rapier/Initialize(mapload) . = ..() From 361b13ef9cfe06f6534d60c11333df51b372dd5a Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:09:09 -0800 Subject: [PATCH 184/472] Bounty cyberpsycho on human NPCs: every implant that fits, Redline as its phase two The rarest mini-boss kit. Its kit is its chrome, the real implants: Redline core, Mantis Blades in the right arm, Gorilla Arms in the left, Meteor Piledriver, Lazarus node and Deadeye optics. The Cascade lattice is left out for the Redline core's operating-system slot. Its Redline core carries the Governor Delete's 15 points of capacity through the cyberware component's capacity hook, so its 32 load fits a body of 35 and nothing browns out. It fights with the blade in its right hand and keeps its Gorilla fist free, so it can punch through glass. Its rotation is the Redline burn, the Mantis Lunge, the Meteor Leap and a told piston punch, one at a time. Hurt to three quarters, or hit with a stamina weapon, it drops what it was winding up and burns its Redline, once; until then it doesn't wear out. Its roar ignores its own companions but holds off for anyone else outside the fight, and it never burns at someone it can't reach. Lazarus stands it up by itself. In prison it carries no chrome and riots with the charge. The Bounty Panel lists the kit, and "All of it" spawns the cyberpsycho. Wiki row added. --- tgstation.dme | 1 + voidcrew/_DEFINES/bounties.dm | 2 + voidcrew/modules/bounties/bounty_admin.dm | 1 + voidcrew/modules/bounties/bounty_chrome.dm | 2 + voidcrew/modules/bounties/bounty_criminal.dm | 2 + voidcrew/modules/bounties/bounty_identity.dm | 2 + .../bounties/chrome/bounty_cyberpsycho.dm | 344 ++++++++++++++ .../bounties/strings/bounty_criminals.json | 63 ++- .../outpost_prison_boss_moves.dm | 3 + voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_bounty_admin.dm | 4 +- .../unit_tests/voidcrew_bounty_chrome.dm | 7 +- .../unit_tests/voidcrew_bounty_cyberpsycho.dm | 422 ++++++++++++++++++ .../unit_tests/voidcrew_bounty_identity.dm | 2 +- wiki/content/bounty-hunting.md | 3 +- 15 files changed, 851 insertions(+), 8 deletions(-) create mode 100644 voidcrew/modules/bounties/chrome/bounty_cyberpsycho.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_bounty_cyberpsycho.dm diff --git a/tgstation.dme b/tgstation.dme index 7bf03556767..120f4ae75ff 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7126,6 +7126,7 @@ #include "voidcrew\modules\bounties\chrome\bounty_chrome_mantis.dm" #include "voidcrew\modules\bounties\chrome\bounty_chrome_piledriver.dm" #include "voidcrew\modules\bounties\chrome\bounty_chrome_redline.dm" +#include "voidcrew\modules\bounties\chrome\bounty_cyberpsycho.dm" #include "voidcrew\modules\captain_management\captain_management.dm" #include "voidcrew\modules\captain_management\command_transfer.dm" #include "voidcrew\modules\captain_management\ship_applications.dm" diff --git a/voidcrew/_DEFINES/bounties.dm b/voidcrew/_DEFINES/bounties.dm index 7eb625ce9a0..ebb6fc1ec3b 100644 --- a/voidcrew/_DEFINES/bounties.dm +++ b/voidcrew/_DEFINES/bounties.dm @@ -48,6 +48,8 @@ #define BOUNTY_KIT_DEMOLITIONIST "demolitionist" #define BOUNTY_KIT_GHOST "ghost" #define BOUNTY_KIT_HEAVY "heavy" +/// Its kit is its chrome (chrome/bounty_cyberpsycho.dm): the rarest +#define BOUNTY_KIT_CYBERPSYCHO "cyberpsycho" // ===== PLACEMENT KINDS ===== diff --git a/voidcrew/modules/bounties/bounty_admin.dm b/voidcrew/modules/bounties/bounty_admin.dm index 6f8480b7b63..d7285e7c793 100644 --- a/voidcrew/modules/bounties/bounty_admin.dm +++ b/voidcrew/modules/bounties/bounty_admin.dm @@ -557,6 +557,7 @@ ADMIN_VERB(bounty_panel, R_ADMIN, "Bounty Panel", "Spawn, post, give out, list a "Demolitionist" = BOUNTY_KIT_DEMOLITIONIST, "Ghost" = BOUNTY_KIT_GHOST, "Heavy" = BOUNTY_KIT_HEAVY, + "Cyberpsycho" = BOUNTY_KIT_CYBERPSYCHO, ) /** diff --git a/voidcrew/modules/bounties/bounty_chrome.dm b/voidcrew/modules/bounties/bounty_chrome.dm index e8a91e1d9dc..baeeb7a6f73 100644 --- a/voidcrew/modules/bounties/bounty_chrome.dm +++ b/voidcrew/modules/bounties/bounty_chrome.dm @@ -57,6 +57,8 @@ GLOBAL_LIST_INIT(bounty_chrome_pieces, init_bounty_chrome_pieces()) switch(kit) if(BOUNTY_KIT_GHOST) return list(/obj/item/organ/cyberimp/cyberware/slipwire, /obj/item/organ/cyberimp/cyberware/cascade) + if(BOUNTY_KIT_CYBERPSYCHO) + return bounty_psycho_organs() return list() /** diff --git a/voidcrew/modules/bounties/bounty_criminal.dm b/voidcrew/modules/bounties/bounty_criminal.dm index 02db9a19abc..d4e10fb8479 100644 --- a/voidcrew/modules/bounties/bounty_criminal.dm +++ b/voidcrew/modules/bounties/bounty_criminal.dm @@ -130,6 +130,8 @@ return /mob/living/carbon/human/npc/bounty_criminal/boss/ghost if(BOUNTY_KIT_HEAVY) return /mob/living/carbon/human/npc/bounty_criminal/boss/heavy + if(BOUNTY_KIT_CYBERPSYCHO) + return /mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho return pick( /mob/living/carbon/human/npc/bounty_criminal/boss/juggernaut, /mob/living/carbon/human/npc/bounty_criminal/boss/pyromaniac, diff --git a/voidcrew/modules/bounties/bounty_identity.dm b/voidcrew/modules/bounties/bounty_identity.dm index b6b6b06de9d..439a0e08695 100644 --- a/voidcrew/modules/bounties/bounty_identity.dm +++ b/voidcrew/modules/bounties/bounty_identity.dm @@ -127,6 +127,8 @@ GLOBAL_LIST_INIT(bounty_species_weights, list( BOUNTY_KIT_PYROMANIAC = 20, BOUNTY_KIT_DEMOLITIONIST = 18, BOUNTY_KIT_GHOST = 18, + // The rarest: its kit is its chrome (chrome/bounty_cyberpsycho.dm) + BOUNTY_KIT_CYBERPSYCHO = 8, ) return pick_weight(weights) diff --git a/voidcrew/modules/bounties/chrome/bounty_cyberpsycho.dm b/voidcrew/modules/bounties/chrome/bounty_cyberpsycho.dm new file mode 100644 index 00000000000..b9bdd6b94e5 --- /dev/null +++ b/voidcrew/modules/bounties/chrome/bounty_cyberpsycho.dm @@ -0,0 +1,344 @@ +/** + * # Bounty hunting: the cyberpsycho + * + * Owner: C6. A mini-boss kit (BOUNTY_KIT_CYBERPSYCHO) whose kit is its chrome, the rarest of the kits + * (bounty_roll_kit()). Everything but its fight is any mini-boss's: the Most Wanted posting and pay, its + * stimulants, a sealed suit where the air would kill it, capture, and in prison the Juggernaut's charge + * (its chrome stays out of prison, decision 7). + * + * Its chrome is the real implants (decisions 4 and 5), as many of the seven pieces as one body's organ + * slots take. Six go in, in this order (bounty_psycho_organs()): + * - the Redline core in the operating-system slot. The Cascade lattice needs the same slot; Redline is + * its phase two, so the lattice is the piece left out; + * - a Mantis Blade housing in its right arm and Gorilla Arms in its left, one of each pair; + * - the Meteor Piledriver in its legs, a Lazarus node by its heart, and Deadeye optics for eyes. + * Their load is 32 (10 + 3 + 3 + 8 + 5 + 3), over a body's 20. It is augmented beyond anyone else: its + * Redline core carries the Governor Delete's capacity (BOUNTY_PSYCHO_EXTRA_CAPACITY) through the real + * capacity hook, the cyberware component's capacity_bonus, which get_chrome_capacity(), the brownout + * monitor, the cradle and a chrome read all go by. It is set on the core as the core goes in, first, so + * nothing browns out on the way. Chrome taken out of it burns to scrap (the human NPC base), so no core + * with that headroom ever leaves its body. + * + * How it fights, through the boss rotation (bounty_boss.dm), one move at a time and each with its tell: + * - its blade is out of its right forearm while it fights, and its melee is that blade (Mantis Blades); + * - the Mantis Lunge down a lit line, the Meteor Leap under a reticle, and a piston punch with its Gorilla + * fist (C2's told punch, put in its rotation here, since its melee is the blade); + * - its Gorilla hand stays empty, so it can punch through glass it may break (boss_start_smash()); + * - the Redline is its phase two (psycho_burn_due()): hurt to BOUNTY_PSYCHO_BURN_HURT percent of its + * health, or winded by a stamina weapon (BOUNTY_PSYCHO_BURN_WINDED), it drops what it was winding up + * and burns: C3's tell, then the implant's stun-proof window, then its crash into stamina crit, which is + * the capture. Once a spawn. Until then it doesn't wear out and fall back; it burns instead. Its roar + * holds off for people outside its fight, never for its own companions (psycho_roar_clear()); + * - Lazarus stands it up out of crit by itself, the node's own jolt; its six minute recharge outlasts a + * fight; + * - the Deadeye optics are only its eyes: it carries no gun for the tag to steer. + * An EMP takes its chrome down on the real reboot timers, sparking (C0's malfunction look); one during the + * burn crashes it, and with its core down it tires like any other mini-boss. + */ + +/// Neural capacity its Redline core carries: the Governor Delete's (its chrome_capacity_bonus, ware_legend.dm) +#define BOUNTY_PSYCHO_EXTRA_CAPACITY 15 +/// Hurt to this percent of its health, it burns its Redline (C3's line for any carrier) +#define BOUNTY_PSYCHO_BURN_HURT 75 +/// Winded this far (stamina loss), it burns too: one disabler bolt or baton blow, so a stamina weapon gets an answer before the floor +#define BOUNTY_PSYCHO_BURN_WINDED 25 + +/// The cyberpsycho's chrome as organ typepaths, in the order they go in: the Redline core first, as it carries the headroom for the rest +/proc/bounty_psycho_organs() + return list( + /obj/item/organ/cyberimp/cyberware/redline, + /obj/item/organ/cyberimp/arm/toolkit/cyberware/mantis, + /obj/item/organ/cyberimp/cyberware/gorilla_arms/left, + /obj/item/organ/cyberimp/cyberware/piledriver, + /obj/item/organ/cyberimp/cyberware/lazarus, + /obj/item/organ/eyes/robotic/cyberware/deadeye, + ) + +/// The pieces (BOUNTY_CHROME_*) its chrome AI drives: every one but the Cascade lattice +/proc/bounty_psycho_keys() + return list(BOUNTY_CHROME_REDLINE, BOUNTY_CHROME_MANTIS, BOUNTY_CHROME_GORILLA, BOUNTY_CHROME_PILEDRIVER, BOUNTY_CHROME_LAZARUS, BOUNTY_CHROME_DEADEYE) + +// ===== THE BOSS ===== + +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho + name = "chromed criminal" + desc = "A twitching criminal with more chrome than skin." + boss_kit = BOUNTY_KIT_CYBERPSYCHO + /// Its Redline has burned once: its phase two is spent + var/psycho_burned = FALSE + +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/Initialize(mapload, datum/human_npc_order/order) + // Its kit is its chrome: the base installs it once it is dressed + npc_cyberware = bounty_psycho_organs() + . = ..() + psycho_wire_chrome() + RegisterSignal(src, SIGNAL_ADDTRAIT(TRAIT_STUNIMMUNE), PROC_REF(psycho_burn_started)) + RegisterSignal(src, SIGNAL_REMOVETRAIT(TRAIT_STUNIMMUNE), PROC_REF(psycho_burn_ended)) + +// Its Redline core goes in first, with the headroom the rest of its chrome needs +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/npc_install_cyberware(ware_type) + var/obj/item/organ/ware = ..() + if(istype(ware, /obj/item/organ/cyberimp/cyberware/redline)) + var/datum/component/cyberware/chrome = ware.GetComponent(/datum/component/cyberware) + if(chrome && !chrome.capacity_bonus) + chrome.capacity_bonus = BOUNTY_PSYCHO_EXTRA_CAPACITY + cyberware_reevaluate_brownout(src) + return ware + +/** + * Its chrome's AI, once the implants are in (C0's bounty_chrome_equip()): every piece's moves, and its + * rotation set to what this body can do. Its own lines say what is coming (boss_bark()), not each move's. + * Its Redline is its own move (/datum/bounty_chrome_move/redline/psycho), its phase two, in place of the + * one every carrier gets, and its Gorilla fist gets the piston punch. The Gorilla grab needs two Gorilla + * fists and the Deadeye tag a gun, and it has neither, so they stay out of its rotation. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_wire_chrome() + var/datum/component/bounty_chrome/chrome = bounty_chrome_equip(src, bounty_psycho_keys()) + if(!chrome) + return + for(var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/unused in boss_abilities.Copy()) + if(istype(unused.move, /datum/bounty_chrome_move/redline) || istype(unused.move, /datum/bounty_chrome_move/gorilla_grab) || istype(unused.move, /datum/bounty_chrome_move/deadeye)) + boss_abilities -= unused + qdel(unused) + var/datum/bounty_chrome_move/redline/plain = chrome.move_of(/datum/bounty_chrome_move/redline) + if(plain) + chrome.moves -= plain + plain.detach() + qdel(plain) + psycho_add_move(chrome, /datum/bounty_chrome_move/redline/psycho) + psycho_add_move(chrome, /datum/bounty_chrome_move/gorilla_punch/psycho) + for(var/datum/bounty_chrome_move/move as anything in chrome.moves) + move.tell_lines = null + +/// Adds a move of `move_type` to its chrome AI `chrome`, as a move in its rotation (C0's adaptor). Returns the move. +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_add_move(datum/component/bounty_chrome/chrome, move_type) + var/datum/bounty_chrome_move/move = new move_type + chrome.moves += move + move.attach(src) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/ability = new(src) + ability.set_move(move) + ability.Grant(src) + boss_abilities += ability + return move + +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/bounty_build_outfit(zone) + return boss_supplies(bounty_outfit_instance(/datum/outfit/bounty_boss/cyberpsycho)) + +// No bag over its spine rig: its stimulants and bruise pack ride in a bum bag, where it reaches them as any boss reaches its bag +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_supplies(datum/outfit/outfit) + if(!istype(outfit)) + return outfit + outfit.belt = /obj/item/storage/belt/fannypack/black + LAZYINITLIST(outfit.belt_contents) + outfit.belt_contents[BOUNTY_BOSS_STIMULANT] = BOUNTY_BOSS_STIMULANTS + outfit.belt_contents[/obj/item/stack/medical/bruise_pack] = 1 + return outfit + +// Its chrome is its kit: it dresses to show it (C0's bounty_chrome_expose_outfit()) +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/bounty_dresses_to_show_chrome() + return TRUE + +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_gear_text() + return "[p_They()] can't keep still, and there's more chrome on [p_them()] than skin." + +/// Shorts and boots: its arms, chest and legs are chrome, and they show +/datum/outfit/bounty_boss/cyberpsycho + name = "Bounty mini-boss: Cyberpsycho" + uniform = /obj/item/clothing/under/shorts/black + gloves = null + +// ----- its phase two ----- + +/// Its Redline core, if it is in it +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_core() + return locate(/obj/item/organ/cyberimp/cyberware/redline) in organs + +/// Its Redline in its rotation, if it has one +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_ability() + var/datum/component/bounty_chrome/chrome = GetComponent(/datum/component/bounty_chrome) + var/datum/bounty_chrome_move/redline/psycho/burn = chrome?.move_of(/datum/bounty_chrome_move/redline/psycho) + return burn?.boss_ability + +/// Whether its phase two is still to come: it has never burned, and its Redline core is in it and running (an EMP or a brownout puts that off) +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_pending() + if(psycho_burned) + return FALSE + var/obj/item/organ/core = psycho_core() + var/datum/component/cyberware/ware = core?.GetComponent(/datum/component/cyberware) + return !!ware?.is_running() + +/// Hurt or winded enough to burn +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_hurt_enough() + return health <= maxHealth * BOUNTY_PSYCHO_BURN_HURT / 100 || getStaminaLoss() >= BOUNTY_PSYCHO_BURN_WINDED + +/** + * Whether it burns its Redline now at `target` (or whoever it fights, for null): its phase two still to + * come, hurt or winded enough, in its fight, and a Redline move's own checks but its "hurt" reading (C3's + * line is any carrier's, this is its own): able to act, someone it may hit in sight, the implant ready, + * and nobody outside the fight within the roar's reach (psycho_roar_clear()). Never at someone it can't + * get at (boss_valid_target(): shot from behind glass it can't break, it takes cover instead, C5), so a + * crew can't spend its phase two from where it can't follow. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_due(atom/target) + if(!boss_hostile || !psycho_burn_pending() || !psycho_hurt_enough()) + return FALSE + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/burn = psycho_burn_ability() + var/datum/bounty_chrome_move/redline/move = burn?.move + target ||= bounty_chrome_target(src) || boss_revenge_target() + if(!istype(move) || QDELETED(target) || !bounty_chrome_can_act(src) || !bounty_ai_may_hit(src, target) || !boss_valid_target(target)) + return FALSE + if(bounty_ai_dist(src, target) > move.max_range || !bounty_ai_can_see(src, target, move.max_range)) + return FALSE + return move.ready() && psycho_roar_clear(move.area_turfs(target), target) + +/** + * Whether its Redline's roar may go off over `turfs`: nobody stands there who is outside its fight and has + * a player behind them or is a person (C0's bystander gate, bounty_chrome_area_clear()), but its own + * companions don't count, and nor do creatures: a cyberpsycho doesn't mind staggering its friends. Its + * companions come in one for each extra hunter (OC-8), so C0's gate would keep its phase two shut in most + * fights against a crew. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_roar_clear(list/turfs, atom/aimed) + for(var/turf/spot as anything in turfs) + if(!isturf(spot)) + continue + var/list/people = list() + for(var/mob/living/person in spot) + people += person + for(var/obj/vehicle/sealed/mecha/mech in spot) + for(var/mob/living/pilot in mech.occupants) + people += pilot + for(var/mob/living/person as anything in people) + if(person == src || person.stat == DEAD || bounty_ai_same_gang(src, person)) + continue + if((person.mind || person.client || ishuman(person)) && !bounty_chrome_in_posse(src, person, aimed)) + return FALSE + return TRUE + +// Its phase two comes before any other move +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_choose_ability(atom/target) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/burn = psycho_burn_ability() + if(burn && !QDELETED(target) && boss_can_use_ability() && burn.IsAvailable() && burn.worth_using(target)) + return burn + return ..() + +// A stamina weapon or a bad wound: it doesn't wait its turn to burn. A moment later, once the hit is done with. +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_took_damage(damage, damagetype) + if(!psycho_burned && psycho_hurt_enough()) + addtimer(CALLBACK(src, PROC_REF(psycho_burn_now)), 1, TIMER_UNIQUE | TIMER_DELETE_ME) + +/** + * Burns its Redline now if it should (psycho_burn_due()): whatever it was winding up, or the glass it was + * punching, it drops, and the burn's tell starts in the next move's place. Not while it is up in a leap, + * holding on to someone or already winding the burn up. Returns TRUE if the tell started. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_now() + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/burn = psycho_burn_ability() + if(QDELETED(src) || !burn || HAS_TRAIT(src, TRAIT_HANDS_BLOCKED) || !boss_can_act()) + return FALSE + var/atom/target = bounty_chrome_target(src) || boss_revenge_target() + if(!burn.worth_using(target)) + return FALSE + if(boss_busy) + // A wind-up or a smash ("smash", BOUNTY_BOSS_BUSY_SMASH) gives way; anything else it sees through + if(boss_winding() == burn || (boss_busy != "windup" && boss_busy != "smash")) + return FALSE + boss_interrupt() + boss_next_ability_at = min(boss_next_ability_at, world.time) + boss_fallback_until = 0 + // Its own click, as its AI's Trigger() makes it: a timer's callback carries the usr of whoever hit it, and Trigger() would click as them + return !!burn.InterceptClickOn(src, null, target) + +// Tiring is what sets its Redline off: until its phase two is spent it burns instead of falling back, and burning, it doesn't tire +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_is_worn_out() + if(psycho_burn_pending() || has_status_effect(/datum/status_effect/cyberware_redline_window)) + return FALSE + return ..() + +// The burn's tell gets its own lines, said over the cooldown on its talk +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/boss_bark(suffix, fallback) + var/datum/action/cooldown/mob_cooldown/bounty_boss/chrome/winding = boss_winding() + if(suffix == "ability" && istype(winding) && istype(winding.move, /datum/bounty_chrome_move/redline/psycho)) + if(boss_kit && stat == CONSCIOUS) + INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "[boss_kit]_redline", null, TRUE) + return + return ..() + +/// The implant's burn started (its window's stun immunity went on): its phase two is spent +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_started(datum/source) + SIGNAL_HANDLER + var/datum/status_effect/window = /datum/status_effect/cyberware_redline_window + if(HAS_TRAIT_FROM(src, TRAIT_STUNIMMUNE, initial(window.id))) + psycho_burned = TRUE + +/// The burn is over (its stun immunity went), and the crash comes: it says so +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/proc/psycho_burn_ended(datum/source) + SIGNAL_HANDLER + if(psycho_burned && boss_kit && stat == CONSCIOUS) + INVOKE_ASYNC(src, TYPE_PROC_REF(/mob/living, bounty_say), "[boss_kit]_exhausted", null, TRUE) + +/** + * A player in its body (a brain transplant) loses its gear (the human NPC base's Login()), and the headroom + * only a cyberpsycho carries: with its core's capacity gone, its chrome browns out, all of it, as anyone's + * does over capacity. Admins driving it for an event keep it. + */ +/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/Login() + . = ..() + if(!. || isnull(client) || client.holder) + return + var/obj/item/organ/core = psycho_core() + var/datum/component/cyberware/core_ware = core?.GetComponent(/datum/component/cyberware) + if(core_ware?.capacity_bonus) + core_ware.capacity_bonus = 0 + cyberware_reevaluate_brownout(src) + +// ===== ITS MOVES ===== + +/** + * The cyberpsycho's Redline, its phase two, in place of the one every carrier gets: C3's tell (the chest + * speaker glowing red, standing still) and the implant's burn, on its own rules (psycho_burn_due()) and its + * own roar gate (psycho_roar_clear()), checked again as the button is pressed. Wired by the cyberpsycho + * itself (psycho_wire_chrome()), so no other Redline carrier gets it. + */ +/datum/bounty_chrome_move/redline/psycho + piece_key = null + +/datum/bounty_chrome_move/redline/psycho/can_start(atom/target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/psycho = owner + return istype(psycho) && psycho.psycho_burn_due(target) + +// Someone outside the fight stepped into the roar's reach during the tell: the button stays unpressed +/datum/bounty_chrome_move/redline/psycho/effect(atom/target) + var/mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho/psycho = owner + if(!istype(psycho) || !psycho.psycho_roar_clear(area_turfs(target), target)) + return FALSE + . = bounty_chrome_trigger(owner, action_type, trigger_target(target)) + if(. && owner.has_status_effect(/datum/status_effect/cyberware_redline_window)) + owner.apply_status_effect(/datum/status_effect/bounty_redline_eyes) + +/** + * Its Gorilla fist's piston punch as a move in its rotation: C2's punch, a fist drawn back over the lit + * tile for its tell, then the arm's own empowered punch. C2 leaves a mini-boss's Gorilla punches to its + * melee; this one's melee is its blade. Wired by the cyberpsycho itself (psycho_wire_chrome()), so no other + * Gorilla Arms carrier gets it. + */ +/datum/bounty_chrome_move/gorilla_punch/psycho + piece_key = null + boss_usable = TRUE + +// Its rotation throws it; it plans nothing of its own +/datum/bounty_chrome_move/gorilla_punch/psycho/plan(datum/ai_controller/controller, atom/target) + return FALSE + +// ===== THE BOUNTY PANEL ===== + +// "All of it" is the cyberpsycho: its body is the only one that carries it all (C0's seam) +/datum/bounty_admin_panel/admin_chrome_all(datum/bounty_record/record, list/kit_organs) + record.archetype = BOUNTY_ARCHETYPE_BOSS + record.kit = BOUNTY_KIT_CYBERPSYCHO + record.style = null + return list() + +#undef BOUNTY_PSYCHO_EXTRA_CAPACITY +#undef BOUNTY_PSYCHO_BURN_HURT +#undef BOUNTY_PSYCHO_BURN_WINDED diff --git a/voidcrew/modules/bounties/strings/bounty_criminals.json b/voidcrew/modules/bounties/strings/bounty_criminals.json index 43c079e5250..8b8c58d7e8a 100644 --- a/voidcrew/modules/bounties/strings/bounty_criminals.json +++ b/voidcrew/modules/bounties/strings/bounty_criminals.json @@ -75,7 +75,8 @@ "pyromaniac": ["Matches", "Torch", "Cinders", "Smoky", "Blaze"], "demolitionist": ["Fuse", "Boomer", "Sticks", "Two-Fingers", "Detonator"], "ghost": ["Shade", "Blink", "Needle", "Nobody", "Quiet"], - "heavy": ["Gunner", "Big Iron", "Tank", "Belt", "Rattle"] + "heavy": ["Gunner", "Big Iron", "Tank", "Belt", "Rattle"], + "cyberpsycho": ["Static", "Glitch", "Hardwire", "Burnout", "Scrap Saint", "Sparks"] }, "features": { "scar": [ @@ -728,6 +729,66 @@ "I've got all day.", "Shooting from back there? Cute." ] + }, + "cyberpsycho_intro": { + "any": [ + "Get out of my head!", + "Can you hear it humming? Can you?", + "More meat. Come here.", + "Don't look at me like that!", + "It won't stop. Nothing stops.", + "You're not real. None of you are real." + ] + }, + "cyberpsycho_ability": { + "any": [ + "Hold still!", + "There you are.", + "Got you now!", + "Come here!", + "Stay right there!", + "Don't run, meat!" + ] + }, + "cyberpsycho_redline": { + "any": [ + "Make it stop!", + "LOUDER!", + "Not now. Not now!", + "Everything's red.", + "Shut up! Shut up!", + "It's too loud!" + ] + }, + "cyberpsycho_exhausted": { + "any": [ + "Why is it so quiet?", + "Something's burning. Is that me?", + "I can't feel my arms.", + "Keep running, keep running...", + "My hands won't stop shaking.", + "Just let me sit down." + ] + }, + "cyberpsycho_posse": { + "any": [ + "More of you? Good. More meat.", + "All of you. I'll take all of you.", + "Bring the whole crew.", + "So many voices...", + "Line up.", + "Doesn't matter how many." + ] + }, + "cyberpsycho_cover": { + "any": [ + "Come out! Come OUT!", + "I can hear you breathing.", + "Where'd you go? Where'd you GO?", + "You can't stay in there forever!", + "Hiding won't help. I'll find you.", + "Come out and play." + ] } } } diff --git a/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm b/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm index 918d96de83f..af42e46864b 100644 --- a/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm +++ b/voidcrew/modules/player_outposts/outpost_prison_boss_moves.dm @@ -78,6 +78,9 @@ return /datum/prison_boss_move/slip_cuffs if(BOUNTY_KIT_HEAVY) return /datum/prison_boss_move/barricade + // Its chrome stays out of prison (decision 7): the Juggernaut's charge + if(BOUNTY_KIT_CYBERPSYCHO) + return /datum/prison_boss_move/charge return null /** diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index dc423ec7ccf..0d3a71ffc06 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -54,6 +54,7 @@ #include "voidcrew_bounty_chrome_redline.dm" #include "voidcrew_bounty_companions_backup.dm" #include "voidcrew_bounty_criminal.dm" +#include "voidcrew_bounty_cyberpsycho.dm" #include "voidcrew_bounty_guns.dm" #include "voidcrew_bounty_identity.dm" #include "voidcrew_bounty_kingpin.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_admin.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_admin.dm index 67e25b387fb..b7b6d74ecd0 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_admin.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_admin.dm @@ -140,7 +140,7 @@ // A random kit is still a kit. var/mob/living/carbon/human/npc/bounty_criminal/random_boss = spawn_criminal(panel, operator, 3, "boss", null, spot) // BOUNTY_TIER_MOST_WANTED - TEST_ASSERT((random_boss?.record?.kit in list("juggernaut", "pyromaniac", "demolitionist", "ghost", "heavy")), "A boss with a random kit has the kit [random_boss?.record?.kit]") + TEST_ASSERT((random_boss?.record?.kit in list("juggernaut", "pyromaniac", "demolitionist", "ghost", "heavy", "cyberpsycho")), "A boss with a random kit has the kit [random_boss?.record?.kit]") // Bad requests are refused, with a reason and no log line. var/logged = length(panel.operations) @@ -354,7 +354,7 @@ // A boss record always has a kit. var/datum/bounty_record/boss_record = panel.admin_pool_add(operator, 3, "boss", null) // BOUNTY_TIER_MOST_WANTED - TEST_ASSERT((boss_record?.kit in list("juggernaut", "pyromaniac", "demolitionist", "ghost", "heavy")), "A boss test record has the kit [boss_record?.kit]") + TEST_ASSERT((boss_record?.kit in list("juggernaut", "pyromaniac", "demolitionist", "ghost", "heavy", "cyberpsycho")), "A boss test record has the kit [boss_record?.kit]") TEST_ASSERT_EQUAL(length(panel.pool_rows()), 2, "The pool does not list both records") // Through the menu: the list names the records, and a record added there joins. diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_chrome.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_chrome.dm index e74686e2f2c..02a1003202d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_bounty_chrome.dm +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_chrome.dm @@ -652,11 +652,12 @@ allocated += clean TEST_ASSERT_NULL(clean.record.chrome, "Asking for no chrome still gave them some") TEST_ASSERT_NULL(clean.GetComponent(/datum/component/bounty_chrome), "Asking for no chrome still gave them a chrome AI") - // All of it: every piece that fits, nothing browned out + // All of it: the cyberpsycho (C6, chrome/bounty_cyberpsycho.dm), whose body carries it all, nothing browned out var/mob/living/carbon/human/npc/bounty_criminal/all = panel.admin_spawn_criminal(operator, 3, "normal", "brawler", null, spot, "all") allocated += all - TEST_ASSERT_EQUAL(length(all.record.chrome), length(bounty_chrome_all_that_fits(list())), "All of it gave [length(all.record.chrome)] pieces") - TEST_ASSERT(length(all.record.chrome) >= 3, "All of it fit only [length(all.record.chrome)] pieces") + TEST_ASSERT(istype(all, /mob/living/carbon/human/npc/bounty_criminal/boss/cyberpsycho), "All of it spawned [all?.type], not the cyberpsycho") + TEST_ASSERT_EQUAL(all?.record?.kit, "cyberpsycho", "All of it isn't on the record as the cyberpsycho's kit") // BOUNTY_KIT_CYBERPSYCHO + TEST_ASSERT(get_chrome_load(all) > 20, "All of it carries only [get_chrome_load(all)] load") // CYBERWARE_BASE_CAPACITY TEST_ASSERT(get_chrome_load(all) <= get_chrome_capacity(all), "All of it browned them out") // Redline on a Ghost: its own Cascade has the slot, so it's left off and the admin hears why var/mob/living/carbon/human/npc/bounty_criminal/ghost = panel.admin_spawn_criminal(operator, 3, "boss", "ghost", null, spot, list("redline")) diff --git a/voidcrew/modules/unit_tests/voidcrew_bounty_cyberpsycho.dm b/voidcrew/modules/unit_tests/voidcrew_bounty_cyberpsycho.dm new file mode 100644 index 00000000000..3deb0d95dae --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_bounty_cyberpsycho.dm @@ -0,0 +1,422 @@ +/** + * The cyberpsycho (voidcrew/modules/bounties/chrome/bounty_cyberpsycho.dm): where it sits among the kits, + * its chrome (the real implants, every one that fits, nothing browned out), its rotation, its Redline phase + * two against damage and against stamina weapons, Lazarus, an EMP, death, and the Bounty Panel. + * + * Real human NPCs with the real implants in them; the tests read tg's own state. The AI is switched off, + * so the tests drive the boss's procs and nothing else happens in between; timers run as they would. Fork + * defines are included after the tests, so a test uses the literal value with a comment naming the define. + * The test room is 5x5. + * + * Renders: compile with -DBOUNTY_CHROME_RENDERS and the renders test writes flat icons to + * data/bounty_chrome_renders/ (cyberpsycho__

    h=f2OUy6iq^AU3cU$fC!@qa-W`eWnch=gu7E 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_wood_03.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_03.ogg new file mode 100644 index 0000000000000000000000000000000000000000..440681e0da48b073d2e19b30a5a3bd71d35e60ab GIT binary patch literal 22309 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 YZeQ)V8uLMt;rs$}E!M!J6EX|@ADxwabpQYW literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_wood_05.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_05.ogg new file mode 100644 index 0000000000000000000000000000000000000000..3a66b3f32c8aa3d5f8bf4db73442ad0a97023c5e GIT binary patch literal 12239 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 Sv$r)75h{fW=brxL1pW_6;LYLy literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hit/bullet_wood_06.ogg b/voidcrew/modules/gun_feel/sound/hit/bullet_wood_06.ogg new file mode 100644 index 0000000000000000000000000000000000000000..cf54f8cc8f5814cc9ccd2e80ac1f969be5b4ccab GIT binary patch literal 11322 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 AL;wH) literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/hmg/cm40_unload.ogg b/voidcrew/modules/gun_feel/sound/hmg/cm40_unload.ogg new file mode 100644 index 0000000000000000000000000000000000000000..ae9e31aee5373065a8fc9968954f84a84223a425 GIT binary patch literal 22752 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 I)BKMA07$2mCjbBd literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/pistol/shot.ogg b/voidcrew/modules/gun_feel/sound/pistol/shot.ogg new file mode 100644 index 0000000000000000000000000000000000000000..1bbd95e405bcf1c3701ee7969169bf5611d4b3be GIT binary patch literal 15402 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 Gr~d=0qRi9) literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/pistol/shot_alt.ogg b/voidcrew/modules/gun_feel/sound/pistol/shot_alt.ogg new file mode 100644 index 0000000000000000000000000000000000000000..a24c7acc4f9ec3c492e2f6c2cfed21fc14dfbd95 GIT binary patch literal 29045 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 QqF#qL#r(g2^8fw*51S=JTL1t6 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/revolver/shot_light.ogg b/voidcrew/modules/gun_feel/sound/revolver/shot_light.ogg new file mode 100644 index 0000000000000000000000000000000000000000..68b7fada6e49ce172be23aab11a99394c0590181 GIT binary patch literal 61848 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 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/rifle/ar_cock.ogg b/voidcrew/modules/gun_feel/sound/rifle/ar_cock.ogg new file mode 100644 index 0000000000000000000000000000000000000000..ac02ed93c1d7e25c8cb1ff91a68cbcc2bcf2bc2b GIT binary patch literal 14157 zcmb7qbzGc3v+uKu7bz5{xD-5-)%J z0)P`G(9`!jP?hEV?BckfS0Z_vTBa($+sY;WD{;QxZNHdM1 zEle{Vq%9(JgRu6K2BY!r<|itPBGKk6%hHwPC#lLZ&gi-ww;?RK{S>(-4M7)$3i(&@ zqz5VVU7$?!C+4H%zw-X7MG6EwYA*8uYp8~Bc${$flyDS-O63LBI2+@nDyJH|fQGun zw7Q0thJk^X!?dTvbc(~Q<`bdmpwsBE@Za;3>ZBkbmx3pOf;NGiHerK10X8J891NI0 znk1JdYJ)p=Q#zK)Cc*MeidAGraeBsXN%pg+CIAT9{~G7A5< zS=f4j4iJXgvfqX9Zfammk^eOb z&}oMzVN3=IHU9oY{!vWM(MgVBOa&DVg~|U#R>KN>@)xKgWFIY4Yy%M4<4vB-5&#v8 zmxm~Z@^>U?gNVFM(u}c;vtb^zjIG=ftjx)8k?NV$O(kiWZX-N9S@t9N8(D56w1err zx#?pWZX+@%83li-H!m^~DoQ4xJp7$3v_5oTVnRZP`22kq8JnMKE>5&H=_n!jk$$f;O^Jek`ukT|J zsc$U2DHd5FSt7A}99m*7>i}&&aZIeFY)P=PDn50Ad(e~SqyP|t{-0h<7KUdUK~|LI z`i!nPKOu^4kfsj8HpK8)ly{Ht9#nUui=nXAPslQGPHURQG;C+PRq{yN6*SD~(icD- zoVMUEqM(yXY922FkN<>^uryaFDsqFK{pZA;P>x~{jsHg;Jtc%X^Iv~{a%K%JPY0b; zkA*Lx20uJjW_?#?V~lXaEOb)-TmGv$R1@G4PET-5GL2^K&k9x)Mfy9z{~$-o1*`u9 z;r9=ea5swge0C?aYn zB9t54aqnvq!u^PNsl;6O%3;;Q}?91b_}*JMn@E#UNxmS^0{pn^~VHRBW@{ zl6Yl_3b5=38$PR>$~AC}@IW76to%~#?>wKCO9UippTp? z>KZDX4s+@n3zzbfDGIaG4k{WRdW{Yy-VO^_0+aR7M~5YM13fQ?xho63*%+xOQCd(@ z9tv914r(3_2AC4F*cw_X64S{RS}7hz$rekq;^Q6?(@6#@9u|7Z1}mNxI#&v_7akfZ zq0rj3FCGSXjw`RTp#vE~Yqc!qa2=Pp{1&c4t)AX$T!V95-C$C~13K=)%%do+1`8-> zF4CU$X`aqwCO>lH0!1=+e@I(xYb` zbVFU$$Sr4!?aWCY3)c=yR*UU%ryF;DAM6&oSPvTA7TYTsI?mYrEVO+JZ59d-emCzm zw=*9!vo5w*VQEayYUpu=O(%zL;P`F)@Ll7U{P#d6KYUlb{Y<>GXB%VIE`0~>4d`k?@=&k{YRQ2JOu!i36^#{+Oce#X9u%O7%3_$I zv+d-YLu8N&@^NHk7)?<`x6_g!JhXX9DWbHDp)Yii3wm&5Z7Lcx?Q9um&}HrNlJLZ6 z84G7@C+t%b`nQdhyq43fXg(S(+u_h;( zqLlP#szx>BtDD)?Y~h%V=j0%SJut^7lkT_BSy>GIAiO;x=r}c;IuPPQH(S5(2A$QrwO2X%>01bY8EQ0 zq&LHE-TrVI~-Ez%QgLuh*OLg2~zvJ%Hg zd(zxg>H4yQ<7Io(+|XqQvx1dnWz(UQ3~f?^tZYT7s;q2D0~EH;9${-gmZVLX`?%-` z(*XhBTo{JV#8o&g=4vQMF(oFUsPuf~jB{vAH!}X^KppszqpmUS;jx7I7#Ph$J^$*d zjRzDC3X|@SoP|r@Ne+!!4-4ozZ{qR9nQ<`Fe&ozGIvDUcEGMB&LoQAD9pVUnmcEfoM5cY*-Y&JZ?b>j7DS z6dGxg_UPJGKsQU%5@-RX!epNw|6j$^uKNF7U<12aYryJ*arZtVwS-wl%gppz%wgnozWGODQUnni{_lwRfRTzYSVO;p!%M*Pv7piDRsuQykQ#} zod`7Gm{oA@EV`XMHXdn!M&HU6z1nt_JP2!VLBJv^8i0<(HBz16uE|ZH|70dgAV4^R zwJ*yJ4GLg!Syrf+r+8SJ_8(qI{6DLo=&%9O9@4O9w7ppkD73+Zh4Qo^Ny#Iu zVG)TqddS!?&}bI@F?0e4f@xUdK>z|Gk}4o<5|}3zf{6aPv-9iMdgy6K)I3aDoB=)( zXVG^L!ovPN>UDDdt$sY}J@VrxZ}KFW7@3(^+1RP6*;tqunO0v>Qd3Y;)37tM z&``c&W@Fl#8lR!2CVR!q%*4#ZNJq=W#7Is3ijJ9;nSzR$iG`l}6$3R3>pA5!K72wX zdO!Oa&K_9O%)%Yr66QZdB?}9@h{P+d4NgpEzW%%5kfl=tB3N=j{FRh)>ihB?uP?`8 z9Hg_il1kmPwn}`nzIy!a^SbpdS8yI<2AM$-Eeh4cLMVbcxqLnR1Oc3Y5=HK;?7N(< z${#cSXuo5evRLjeZb%N%BnFx7O*WN9xiqT~>;=8J)rDvs=iLHh(E)s+oQ&TO&VN*j z*e)thlfU*clL!(Q`F|5$*TDVlQinyzCH2G55SeiEfvF`>*-nNnck5?I*pzE(mqJ!* z^|vqMmA`CyHw090O`m@cT5Y$0BQ$lw;Ioe2o~E&*YtLO= zQCnm&$h*puoU3tUtp~o@qVIOvti#HAV&P`AMo5bdu0E$`<-igbn0n)HN)Y~D+}uK7 zlF*h0*!;5N@{{izag^>?daJ+M63GEXSAri^57N-uU*w1mBd_X&{~>y=wY`E28&Fix zeLL#s*yxeEaNvv>Q6cr)-T#OEO#NUIB~@k+J1iWmYK{*LiFl3=1eF(@@x|we%0>KO zY=6Yc`Q)K;jtv8@A{fqy0H!(nz)%;sQ}LG-?ynZS;H0bv{diXeS!=(czd-LRsH-gM z7=44jVcVc46*?7QQ&{PG1OI|%$y-(#_a;`t6(I@YyLEb?aw^!`d{L5WDsHPn0?K{8 z<=a?bug?=RY(q-u&vvPuIu>_M-9ajMol@SAs!IZUI$x#tb2?tW*DaBQ0K-UF;k#DN zI<3auAD;WvFY=R-CZ@(>9eiK85YDQMAzX*jW?$tBR77Q-I|MU2 zv(fuYagfq5s_3_l&DF(hx$YO$(xw%^@#FGQ5t&4S)fk#3jrZ zcwDaAvt2za+j%#^o1iCGJ$%6mph)J3@|st>o_ajT1My!fGTB}DV;sNwPKx&&U!XPx zl)fv4OsRUM18anApK^ut0w>^h!H8lTWSg=-bMTfoT>j^WgB;-6sh}0SC?X_+M-g$j zA@j-__GFP0p7GG&4MnL?I;|`WMj3>_I1~O*VMVq&S-aG`!&VqkI@=?2j_yxgO2=2y z?h~rDPhWDUET`c&MVwScX0H)UQH9ZksjAz1F5uWK(G{;DL{a$P4(mI)`Gyc8T!Ka)uUz{B(em?>{ar~5 zN>UM+_{jdVK1+V2OTM969G4XSjksN*+>!Ke6fAW@m2oe_U|=2nWjkXSj{&X#IO7JM z3bo@m*IAOYkod9&OHh^~V{8wxUn`zq& z=z_cSFmzJ>{uR}!BrkBTvO=yL*lgqeffRPt$@QRub?sh4YA4L*O2y+FjetzetQFRx zN=L8i;c847+ng|>@V%>IYiKepPYFA3n_`~z8`AtQ0{j&-z!XnTZT0*5To`LKVL0Py zB6{jCLB+%!>nepFO+&J$HS$edm4@)!yPiGMu9}GeN`}w}lN}Vk6c9I;g#!#75WLAE z$z$;Ot+6FRd4M0rqM{49eIuek*-KQ z%}1y2Jc)-oaP)^_OE(l`GQWH?%j5l1`U3@j#LpIWGMQ3rKk@j(2{Wxj7H5r!QZSN* zrrE$nZbLQkc(DM^gLd7=gz_UKami6z+guS##@gVr54%=OyCzmzzF=uGSq%GwEB}?P zxz06=XY4YcOVO$W8dI;>DeIp#rgwt#1+GkoT|XMCQa8z#ed}rEF9x6J0)ASH)yQ|` zeA)`eMR34wQ)c;+No_cs5-h-C!&t{YfkSZH-*@WsPBXmw%-<|$_+Tk6du_a?x4yi0 zxT=e@P1v$2ZiDMqa*>v|cM2hg@Esd|)Ge$jY8p!%1yzpVe&lXHHbRT2wNnE-_qE!j z`i@THTgcCpZC~t=D3O53qmOh9BK6~b$>8$S{Xo@Hd5{b zo1<9NTmjXpqyRLS3%g;-#sC~7ti-{(TMXSwXTpJC)mO+b97&o-7_Sh; zNyrMO=5)o8dn2Awk`F24`&`H+xo5C9#+5ZZ) zo3J%3P&-jR95z5Yo^22t?%Q!UixenrV`ulA-{6?ni+i^c-e&jWUIIH|pM#)q91X_{ zGV-S;vTcJI_4>gV?%?io+r8kWWUT`QjAkep@B|?P2V8Gh?7E%&_Kf<%jCFbU(>A}q zKFc_G|K?M$7Ix^fn&c~1-dXF*^?^zYt~UvOgF>%jj`+3{!F25I-2`V)DMm(XQa6;+Cr6@P_N}qIqUqr~dIAt%t=Xk-70xln*IYtM^19$$UVm4l_}ra{-WMG*bQ?w4 z(`gF5>AhJ-OoIz|LnVa;r~`2ABvI5WR?FnM{P2B~zK<8ByT^iopSOtm_scXrc*5Ta zyof>9*8qg^jYz^WaiOKc&nZcB_4gahWI)yhBM@@$2rEimHI?z7#pYlrVmfxwR3vBZw^w(^Uian8LhUXzM$So1s`d58ML>QumR7CUs0ZA>+wt*=upQ=XEzQg z0p)`)H>Kcnwf#>VevqMS7w2itu>gAmN+eu%?|DeUgtHbT`c+9z^ z6FDj-pIjz8fi4QImbVBGqsr9B-&OIYvLh}3Pil|oSdu>vWi3Qy4L!* z6XAJ!ff7bH{2A})q=plK70i*AW@78e9EP<6geBrCA4XCocV|L2ElTywo-ds8(lK_> zbu;pIBewDgorW6dHyI-d;{nQ`^7U37u>tf4d?RGfL;7XGSHJgIjkpLdcTI-=cq~c2s6f?!zUdg4JcOENH%e{i7Cs^8sxK8o+PnrVC3 zQQz(B-cLw;-*$WNM4>L~gf9CGr0Dp-S_01o7~lE|nj!Dic{{b#8%0!zthX=y;?HJ< zrY8&mypNwNeWzyOo6hAQ310Y3)4>M+vpfBARfyI#qhU;kE<4=9cd z+5Xm=AdW9Vhu~Jr^HDPf$2^JU^=*0BFUvYGgQ^o%ebnJgVQ?56pbYRsUKve`_q&$q zmVWR$O$lYPU*_D2zU;sx=^yYFSONG=ukfSrSeUer>?thaId^-J2L3cR_=kL7eSH=w zbX&ZZbp4PL*Zh&AQuN1f5T*b;gRT=LN>8i_LIk`z;5VWzq_d_~`=z-ub(vhdouagh z&gvjJEP8D$QE3^80}h5mbA_)O`jS|%MjE}``fLjbq9=$6gf~i6ot=z zE#9q#)eES#jsG^<6M?0U?NjeV^}XCwz(s>?1k@9R;J%XUsv9YWMxb<&vH`cv`I2ho zjWffa6@(tLmUj_M4@71$J{6>SeKvzr_sc=Yz+ma#2Qon3DBN7-3|Yh1@9|sK`)4a` z@@g=7jw~|P8(yEMhC%{w4$J^~TOl|g03O{0!~pV-mA@Jh|4r$=#?|Kg<9Vs~hTSkW zDbE3?cEfO98lbP6L#~aq{bwnbC>}-l`;e3#lMnIlviH0{eW7>BK>_T~ROm*-!Q!@F zY9_EWWtMSr39kU-cSRXTGQ*LqLK#h%*%jF(0QUzg?_-d8P!0f200|uw%aDTye@fci zczHwmSEo}(6fza&wXTa^QonD!q!PzuwFXcMJCdV$!S6DR=u=_AfY2P}vOj7WM-amO zR=%%X-B!4_ST22}oqL88X}zd9$%-jFCl1Q4-Y>u83ZJRjqm`&pe#hlm;s^fE-= zT?xPo8GnVne>m}Dq_M8MYp7>xW@xl?XrjBNt6h32o;)7L8!v5jdfW^}vW}t%unYJQ zUV+2~)V&!6R{eGffj;4+NWsQFp$rB?<~Rx5@uGR{{z;-w!gfP4nAq#A?y16OY@@=% zuyH-od7O!v8#HfEk3@3L@>>|LW`G@p&lnW6Ruu;sgm=bma_*ij?PYNCXa*tzCDy;c zAOOP6g;qY_Ap)4O;ebc*aRnP8^z3QxfJ&NKV(dIy>aezWs;BbubFec>;j;V*!q;F-^73XH;13rP6AwR5FUUawFu z^BL@z4;Aq>nl*ew~Yl(gvDfT@tEItVJdOja!E_urorNHgB}yhGzes7x<< zzXA+k7;Qykc&cN+4UIB3VJHSi7r7{q7oTo^ly`l9A1nz%wy`b!UGRlDIgk9U8ziR+ zE>hmo@Cwle_t*N{T5>upM3z<9{Ep$lay@TJ2-r}2DPq`hPe%l z%*gr+o=7;&VNRInQZ>W8Mj`g$qrXs!c~i3WeiF+4yc-tv`L<@1qXXK>T3c0Es7%H5 zs}1X6x3aGZN5xG)%`_#M2**&_`+sw7?onGg@-VPzg-f3$_&eQlzc2i{cq(jl6}LWR zES2Olq=lCBc0xi>FYT0WHJKvUE7P3!d0=S*uPG)r59plp(1qPeH5)@3Pk7jBr2GuC ztBd9NRS|)G*WL)hsqU~{4+BR+JU>qhP9@9hql!{gOh4~o#^Xd5_(Wxy2PK(waZAq; zjE^NI2O)!QyE(*w2-`3C07=m-Pyl>jJhP*j=oJ18J5R-f*_$dMHM#U3Me7a^FLKtI z>Vd3RbU^8`vM>_1W92}1G{KC=mGMJuf$=R<98Exxo6X!GCf!^F;bz|X>y1KroG8i& zVic~~X{msU^FFO^__5+#k9^1Ksh%BqG!$W~vU{Rj)=y#xcf4gfTGfOh%#8A7slz&x zd}H<{@c`I3&=|fbJC%h%uNz-FUU+(&{(k8pg|hTrxy5b{@>PMjg9Gi@nty@2GU@GTiBOD@t57xq;&sD7 zK1evy;oiE~Jajb?4X`lHGD~9xavc|Zz|)$rzBa8Asa#?GwZO6z+|tKA+nTgdMTt}@ zA7tsW$~mfHMshrzYXE3w0&)XZL8M}-03h4h$r^iC%5U}3qj|+m8V> zM}mvhazp|Uf$DV&=v^@|ABpV1jE?ETL{;h0%;RVo-rG*NXK?MhR3?&t!=;Ir(BgMr zWJtjZu)=#0h+DXzvlKf;&>sBSkF22XHK`<^dfjx&^xOpmD1Hs7!b4GVpA`UUGIRl* zzZe?buIpNgV6(L(`i|_Gekk=I3ksO7A>wbf2KT+l4F>`myO4qSv=nc^JLm-<%m&kv zBUQ$mQNg>Qt@zT`&fSJbYpR?~84OtT=H*}j0RY_kmONYt7#Lf|Qf}bWPq!-=`8d2y z)2(;l*8N-aMbKb#M;0?-JOHn|3zJ$+M-ccE=B0}9h2oYByUF=i&30L~o)4>BEI@U~ z-E&e*#End@()!o>{hR466x6Zvy~H##MZ90Qoc0 zI#}zBEI01?1%u=yUGF4SHMR`Ez9F6w|5i7$!PO{g2@N34HEf|#rUsKEB_}EjItAk_ znU_5bIV#Q(jJ$$PGnq}CY%3;6d~U4h`2FZwRnt&7C-3W z#1_ih0!%-X*p_{g-1Yr!#sK|2X#$PG=gyk=xuo_wplqqRFOc*R|7Gmnj6RAS_u__j z#ua+1AS)W*GY%kt15)JNi{2Rk*(5!3NaQLvtd%33l9EaM#JInz-Fg=>FP_9nHg5x6 zl8JG#1&_NgY;xOF6v0lMdR8w@s}7H_DYdbmfva`BK>!^SFvdQ>129Ags;am6r1|G> z-_NvHvVK&l!k@ULQ-f<&1y;dd3t@l&a0#=7uxvySV4$DyiF(^zclT?tgL;k6#X!i* z0TR@iUQ;tu0S})_b1eW!I0$RTN0->Emt_cVAxUDD7PhR759Lh7t}r4#TpX?87LQ;1 z^x*jI>t433Dk;|YRYN-%MI6s=*_E0DQxdI>_xEM6w>ZMYj1upq^(xd~yCMS1Gd&!8 z?I8KF&u%b(n#C#^t=Xi1mam0 zOBmh(Kr)9&y$bC*`|$QCWzO6RDaZisWPh#9t^L*~5B|pYWrx6Eg3Q|ztv`2342Flx zAV+vX!awbG3SnX+;8me!TJv3=5`C9A5poJ=*on`~qj{4l0w+pysF+D3ULVcdV&v~P z4I=n!P)Z?zX_O-wOJk!oV6Q%A1lwG7E{#0_=M+eLx2n>pi%8o?jPLIYOx08h>|@og ze_C+rl*atBjIYvTa!uu43;4+B)?Jn(cYGnek##-0&10vbjspmWL5gWnK$kCn`GS_( z-Y^6&p$1D%xak4`vO#oeVKobaMjb0toIiS`MuvWMOr;vx|GI!Rp$4*VzG%E-jpk=a zocm56Xu}ht*F*fd_?Q+G2R;#~x(iI3mGipKFb)m@vWsEETKND;H6^wic0N$tX_ZC zusfE(9geKat3DeZDX&al4FMW&gn^1*Wz!7y_?s$_|FycKz^PmNva>7 z?XKP;)rOB`du`dssiH(4=K-t6+57z+LO``fpcx0m-PU!%cQ5DNl2)b^3**OEe;V*+ zH|d3|uz=H;@rkS~3!f5I7LgfE@3lWBX=J*IOv9BejL8eT*uLWr+m}=%#01wrlH*xhu`oOV}7)_0mAA*<8xQF6ENGbrp8hS$JXer^|vLjCX zgln#MW6WR)!nQ~BVPyGTA_-7pn<>-bQ?VBI^_*Nd8VMcr&W*2d&ZprIB3fxPtfJj- z{eDAaTdh>Wg7A{Ot7wP1SSg2kS-qp>9W{Or1LK@zB{;>WD6YcAa)lC1EW9*m6o$5TBB>la{g4tu)P zpqwgkDPr%wg&&fQBV51y7CSzgBy%!_$*X;svLJIjfq_E{yT1L+!ONnYe3cO@0!zfyrHqMp7AY2e4wpMMXj*> zTe1d&PxYc=-zU6LNK}d%$_R|BO+iCH_>U)LGFCaL^A-u-)y>}Au&Iq9w?!F1hpf7< zJO|nnN{i7Zr+e@>N56Ls%+wQG8NO^XbUaC=Q_lyFWe*4UGVCaysKzTL*6I;jF-|)+ zJMG_uMY84We#~!)>VKEswYWN#s%(hCYUTV)H^uV0{`c2-7jKxNM$Ae6c(H-&Mxr>1aIU0IPq{XHkKz)cL>i-s_ZNv1 zhB5oW{k03hagsmjeT!J#f@YrqP|q?~i$*1Q^cQ_LJV+Edvcq1;8(eP1h$xx80e!bRJAaX|VpE^2hJYim8~<$c<5J!R4Tr&Y)c zc6IGUpY*EP@EBTexVRT|g%tyC_1PJo#J}g?MOWQ24CLsb1x6;GO{%q7y9m}Ub>w5^ z#CIv$B(aa6M}K+05y9GEDD}mD^Q|S0o)w?G(A3OPxmXRPm9uYo_|q4wWO|y=dorfu zn7!aDnkicai(Dr6M2CTBYNuaIh>?#PEpWoX@2>mVnpwQmp0ctwlXkmh#UCWe&3oCWZHY4piQ8t`rZD zd&Q(0XS!Ls0Dn9Pab`$~l-F+E750zCB%RoGV~^(>o3uW2mG*V!{cs)qq`F7u6jNAa zQpkGaIOpEP`kL4;#lYDsD^RxGbp>6NXrJ*VmKi;=rT7bXOFw%b$4aFrkgdeEooXZT zxM~7T85(U+V8s!J{)dk0X{CrLRK0Sh1j0fkES8ZVNpaKU0Y}fKj+c=?7tbMr-!16e zctWXtKatF;aOMq=cM-HweBzhq`(kYHC$hMbZtL=RfLQ;#v}WayC<<&No~`bOaE$urT;Q9Ef{_cJ_0>%o)TM!zgL zpmmCG&ZXWz`Hw<_<}hK4ia|e+H%MtQ`lhcTTKe4qHTL-1?WATMDoiD5$00pe%C98^ z>Z&t3_dTCNl8}|0gxeWjfDIKWjP(1Hlm+LloL>&rS>+81aGhzQyNS#N-(DQke->J{ zW^EY@=W7!&o@nGR%O~hvcEqWW9~pEhi2tMsJl$=;YBc`W-NxgeEUFACmr$BI`g&Tb zD+^01^FW4{kP{D*^Z-Fvoi3Ot79lR5OieMsg0qjfjR0nIiJ*i@B&C4D)2lo~&lQiV zp51d*{EcRxaU~A!mvBZGXb2DM24j*;Z=m5R8166~d54{p}CU1|74>&!Imc_*p{e9HgjOQcgZlWkoAc z)YOiA=aDqWv|ftQ$SAf&M$!4zW!Nsbpp!qOhcmKp>}rh+gK*)IzDmzo30K#QJ~|{!QW+t2MEf^+!7_#myu_P!!c{7EWQ?t zdlqT+1;6SdR{pn3$VCk5EccOLl`ZUb&5tE#^kJencGzG-4eK8N>j}1KBI|1_2?yht zLY(>sC)w1~W#k*)60NHCgZXf+-4GEhUh`yrgFD{st>b~5Y@&~GHeMHTvIb+%*Gusl za6auR^q{;-oGbPKh%MEde54q1>nz;y#!DKbiVt)oB{Ll++dj>!HoQWKPK9_BQ3x#D zJv>+~^z0L{W*`8!|8Uvy10{Jpb}hCfepmV@=%$2kGQnC~f7o z;gQ9@9A6H~CnHIDZRsRO4J{rf8}e-uaRew}`0>2_~PLe)J@Z1p?1G%LBcxn_0 z@JpfmcHRk#h@I8B)>${D!qHZz^O;(*A)&Sp)bl)}f$QUoE#9A_utWSD6=!2&$x0?& z6D<=K7GDM6)Jxc=NChM=C8MS^-kZO>Iy-ALCQoTOShcn)!)JB+x!T?opE-r@W7{g4 ze^d9moGQlDknQUOa8xhPJS@JOPo-avs#~ zIuswFr&@IyrDKoN*j7m+#lGvy7(qkc|1F=;L4JWK{8doDgNs)G+DbOMK)ynG1SM`B z9>`8f^0}i#gA+%L73{Myr?h)(^AT9GebJ+;0pIiB-z38Zv6&}upbKsFzmt^ZAfe9V z8A?u#DpJo0mC+#;OE{Bzm4P;>w;$a-35+~%qgB=54UT<SkC#FlEX#C4yuO;M%6}`RYXexwcH@b1Ed@#3A;|-bv}0 z+h8cA`{cQ;5XC)JH@}9?{q+*XOS`^~@e^yWKKxLS%|o?pT(rQhXo$M!*FW*NiEBPG zH88}p_bwSV>(e`Nml4hr({u6M0c)!6gy^MBp{g!Te>P*yA*>fg4!=SgK{59GBj0I# z&v1IvthSN)PjIUH`9|OWeB1aXmE|a&;9$>l?FjO1(bV+(b5TI4 zj&VCgIpp;A$4({WFfoO4HYZ7{l`XixvJ%CVX3tVA4Xwl7U*wmHc}`GeW*|#i5VZ}8 z2JYJCrk#Bw_x9#jFA0)sz9ZtWE*8FK6@kikS{(78KQspnmL^0Qln`~~5ujZZsca0V zG>yp@397%!?}HVzY+a8184(0t?*`46C(cP`>;R2}ExM%%v_Minfqzt%Np=RLb$LU2 zL+}ydzNrnVL0g-ZXuj$2*Jshw3{Ut#`g9BlNXwtHHQKYgfERokO@)mGuHppO-5 z|Bd$V%tHZt5>c`n`!Lod%VcXJV+bEnA%ssD`(DNtN(|A0Yzbq_ z*i%^}qC!&qo*8|=ug~}Q$8ULFckZ0$oO{oC&b{Y-?s*;yPfu%r4)}A8sOid3$@6%3 z35X~p$S=Un8%Je8^eU-;0I+TWq5VIFSWsvF`=ZXIN(P>z;|^=>|MNaakIEs^r&#RL=*MsdWgy&PRPwKm=7%`=EFjZ^#-wUK(0AaLI1owo+&G!Hr$)GMP`GbHxc`W>6}R-@ z3D__SVSQ2yZKFS8gSJCEI|pM&g0Lg$*il;=VT*OB$4>rdqq!~)0SYD6QzW4&$Dt{+ zr&AcBVk+qX59*Q>%Eix~PMR}HlJ-uy9Fp!CmtB^Xy;PphN*e+|z#-J@{q^|8>q(v= zDYQPj)H}Q6|Jt;@Udsd8;97QF6Fz8$0a8q~6@CqXd#a6+>IzUa#Hn`SGz|mwx&lqR z2{zrN3vB<|1aRBIOPfbg-&(UPRkNQ5(Lb!($75!tYBv0TXx6+pY4HL&B9V7F-6xXy z`HO+27NxO14Q%7;Ln;9@Rkr=Tl1&U+}EseIfXoa-=A;J&$z z33TS&5)V&ebmNsMkw_H{{F^hi5dIokESi{PfGodli4v402Hv81E)D=uT>p$>i5LO5 zSc%fy09N_3;*@y#H*&STCwdgAU1b|tW94xhmo7WJZV0J}7_oIvBrW9Qs@08ri6n}n z0uc;wDDePM;HHXu+&j-GNW(`=W&nta!dtBW+_-hA{v#rT|7iUi!eB5T#4|0J(RM*t zhYb8=L$vc4er7a$W;D^|Xbi?7{Xg1)>!2q96;3ob65JBtUAec-byyBI_z!Xnukm&z z3wI|=nLLp)8&d51s%r5~b&y9N3>RxY=RrRFFh9onl=CnGGmOTJ1YySOF-{HP4)x(P z+y7{z_l_q>XoMdwf!48AYj0zkCM{=59GAKg>^yjJW(|JcHpDdAebEj^^g8@ z0|21q9=i!Ol}K5kU?V8R2nvR_(L(=k*9a9_;KHWSGA)P3523K`%=VNpuLK?T(3MPF zQfObo@z>WrbDfM0VM~)_2uaM+i@gRenm)h<=1zgz!n(Z@i@C+-sJYw+LsmYyID!UJ zgvjgrhV;P3&4hG{tPb8Lpx%(`CajR85+OD)8Tf<0$6Tx&4o3hz2*AZGLA-}Df-d9+ za6*Z>?qrD)0`86aeD3Wb0A6L#kHaO$YH>gMrS*!=~0e`sfioE3}y%2|H+maT-z`ehc0rtZdL$2<*5G zdUDHjINfY?1Z#!HJJn-duVW{-wTA1!TkLe8vr{m3d>i95nrKK}#Osd`oUQN}r!?o8AdJJd+2|%7ogNKJKQ!Q-1+LDh z<%5P?K&c&O{OHx`Q#U5JqdjR)HHbzG+BgrR@u1yF3RRUI2?KJ*)37sk&f{AMt3c;L zG{zwi?O=-;-*$G`!ds!kozgH=={9!eXv{bz{6nC#GgY&#!&dm@Xxcml|KW$}a6RZC zei$DfEqa#!#_U-5htmfCHsm@MK6Cws>-GH6`os@g;cu?27>NB<@U^!oado#dU{_-? zIElQ@&H@G$H8xa=*m;GX90y`~fJuD0VO(5IJ>_N8GtiEEaq88nY0K`H{vj zH+G$cOhT_Zw1|+d1BFF%JF*em`H|jLBwJq}7=;VzTSO4hg~CcGKBiS8LU2Y2oH=qK z7tC&;fO2B7ERYkmEDj#$J~hR>?(?|>b`5!vC<0O*oO)s)BXc>l(w-32rcG$Cz7hV@J9ZQxWMNG68&Crv&u|iVp{|-TuIM0%=QtYFxlX z9s$n4BDN;nD%DBpzCJYz{O97vyAipjYUQhAC z!WV@97>80oRWQl~D4-c15d!_7jDc%43aTpa$o3_-c4fL-z$n7*y;>dVzI~0`*-rOB zsI#9252Q>>Mi90vG}uPTbqGW;N_6I?4v4pB;!yIPxwr2jJ2G)x$TzvSEs@A9kTQl6 zQjo~1XcQ7zP6A=eN`>u}0fN17AvNh}%L9?E*A#oc=~*E#<26$KJo>{RDubw;K~?lf zJ?wx31~8RlgC4=-r+KJ_(I^=C+q5xw5DsR;fmF`qR`{?gdK8ZV^Smpb#-U){?Wvsc zdaSeh)oBnBM`K(wCz{rFUn;YAt((#VEj+?NBkq^aQijQee#Rved>^h7R(d;^4 zRY=e=f=UCxr4|Sv-V$}f((5%65C$$SO41*oa7^ zINq%ZFb8*m#P5^<2BV`7tW&`KGZ>|gzHpF|BN7GNE8!GJUE^|+fO`UB)DZ;^0ljJ5 zPC?y;cV6&aYs{dyRgxCK>O_mg?_PyioWiYB2a`!)^=(UW5UCvt@65$B~Ro90RQ*kk$8zA&%?$cDpBUbb$}N5V5uxlcDw>Lpe}UGYw>pR7*=RUE{Pp_ zTe!p&8bwGW!((Do`JLGK=)r21i&{F--K3LKxd#E5m|0MOw(HFz-6&?R2Q4j6pVonI zJ31aQMtX{jaV#U+R&WtOQAuTvUOT4KV4~wtpy2ykN^2TiI$XE7+CKuUMlG!@YML7{ zvQzIF^6sOUXm4p?!aNKBU5s2u289jpbTJvlzGgNy1}iAAoCjK%fEHIeJw4T~!4De{ z?&BwPP~0>TwPRp7sQRgIh71g}S3QOi#DSRlsz>FIK3=v^S5lUgl2lMqge$?MV9Ls3 z$E9IP%FVJ`f*YA5SjevwnmI%eTMpn0QS&yV2J{ z*rhykYi7o4GCnKskM%e;_)6ORQsC*8cD;0_x#JQrr`{Zw%F#6aTm2;HNaeZJdkc#l zx2g~}JU>3>sA)N&HW}HE7w9cz6l2eQX18Pp2B6lP9oa)K_N~t0IQyJF-nY1YOAw+d zn!#x*7hhok;idDRwBaVsG!g@MzURsJVl(^1gyLTC0-B=7IIfo2Zekd%fW)@lkRYYf zUH|qStp%B#cwL9V2?d11p`CA^l^GHNMe_pQ!zYh<@0p2kg=ho7rwf#UCmhiM6ImOb zBP%^cHNz`0exI$*H;yIfmCDW;M<|IttsFb%#H1hzM4odpSmqhwMmanqy9}Q`{$)I? zFf=w|#@&1B;wgxfMzCsZVrea;?5V>f0CXIh;xBZ#43iukGJUjcE4Q9!`pjI?0`W-I zzeFLpJlGnDI*VaD`Y>;0*lGtv{1MPcs-AGO=s0`-Ax~_=yhQ7H1aZ%S@(opQexLK03@Gl!3IW zg;$Pshet9L0xeB-!_T(UaUHkM-Zx+xb7Qc}w*uIOg1==BGOmY(fmw^u|IwLMlWB(+ z1ulNArPCo?J7sjc#;MPSCq!2yDJ(#*bb+a_1P9ld$fm&@fk^51^lP%D0^H8$`_H|S z7J5e4ba6wQp?HqI*)eMv5okX$Ger!Uz3`lqOE8iYc{QYmR2;l0r1O13!M+PpKT|Do z)&|tl62|6kwjFuoY+h#DV;&=}v`rv)b1Tl{qlIC8lM+J$u%KSD*~zw@k3<~v zmw3H&%;(Ebg-@x-Um|`F`o4_|jN+YjdBZl|C4Q+?qn^k!91?y>bXnyZHs*NDL zr++7ch3>m?L{R+&!;4MtqRi?2Q%b^?ekN(oUO6i_nQhSYsqJkr1>Wi0y>=C)E#}S% z%poEi6`thF1uq1%X+T8II2dMbM0qx^cYi(QB7329;c9@m)VXU{n_Z3SO!z#2x6%c4 z7&{kD%dM}fTmpdjR&%1Lllj+M;n(3qsI@Gkyf@QGtzWPL1DK*7TF)86KnI-dwsR!B ze7tXP;>FPZ=iC6LMrh|2!!HR#OOx93YB_$FTbKbAVHqDm+_5UfjC`$*%$_&DwkBBX zc7kebg=1=G732QQZ`mEso#}oI#ld9mM*xi`TOhbFcojqTn4t~fwQ_|I-{&1&! zeb1x^lP2*J&vS9}imPu;>yO>^8;7Qh_Zw`jU%ej$QgZU>0d2_o)ucUL5*C-D?1Kzu zHJ@sm3)_=u*5LQITzwMp%k+v)mym8y*6*aa0PTuoMnK!At5(RiEiF8a`-_6j_{8GY z_7svK)cS9e#YzvJK=WX}xhxO+{#q#*cj1SJ4eLH6VrR_wvTlFShqK}BGcKJRD*SS1 z*rZ83HUWNRD*b;wAwT4EHH#E2a!Nzs_lKTWc&nw_|9*;E+%;_w+2=S}kXb+%T)07m`s>y(xkh^aknY;rD(=beuve;iaPlmt^Q~Al+EA!`(}Ud$zl`2 zMdi7tMvEw&D~u<-f|`rK7U|7d{O-f75%2(XFl5(@RS+>|IeW(|FYK>U^U2>(I>fp!N&!xG)`QF0-zS9XHxn zob)pNS<+-OkF^N`-w}SNd4wxBzr4}ClgJoY*O&3-+Lu?`Vt4M?dtc7laqYuq>2@}b z_hQ-Ku3BLp01T*z>cKv#Qx8iGy_UtLtmyA`3+^fh2t9U>%PT2K3z3J;(5ai$jazYe zvo!B}r$<8S^!FRi1WH}p6w^~o!qW*we*a6x_#0lxTz)wF2B Y~fQmuW-b!x^_mM z*x_49;4`8bFL2V{$1fy_ktZ@ZtIg&Mxn`g9*z&f1+w`hwi_Kl@Mh13d0c*hXuW~Qx znd$6PEI*e0bd3UstR{F8FAA)HzJ93&VHfR0B zU$H%Syu7$cC%^#I?J29pD?HugeAH0>{iVQJUuE2V=~J4hf=EI3a9;G@OO{-J;8O7{ zE`cp#OXa5^k+C;*@QgS;1fpnLz28mx9?axaD@+dpL?9wW%b(r zoFdmAw&;)(oIE83P7@#IAZ#-^qP7GPP;eu-19+`~z zx$tXMR_x4nYdu;>!@G*8F=1J&{U|Y6jG=xL%j$M zc}l3QUCiDcLfIacB9Vvva~|C}L7b@mc=MvA*2Wt_%U@MU2Y%BeyN4XXb7{IyXOTvm_wCtj5?9Z9WR| z=WNNlMqiSU;yc)wkEt>Fz<-JFUBi^NMpXUi`S^@zze7N#sTZKtCBOrDm~OMk2gHul z`86)CP36rYN7u&r$pJ+U59Qn?@96!#lDnN~=xP6^Y_yjsXc!+Xdw272X<4_*bAC)v z!$tn|a@{-Dy&ccN57!3K99jX!Vm4|G@1YUzjBwYvN$<>>*dF2y{Lk47bjs?y&g1G- zY91az#XL9i-;OaK*wn{dd0de%A|Wz7&A5BGV%(VZ>&Qpa8%zIf3U-y5$MrkE>sthy zQ!DUUn3(Te@yRN%zuhLHze70dtg=Z|{+G^RAIOb6y_N;&dp{*U@eNi30Ne3ni6sWT z^FHu$vItPPvi6hMZT5CpvUVm7edqDS1BM6gmf>G08SRaMp0*k8ui56V?zMhB_Zaew zVDqa)cETj5X(I5Q-RzibW1G79X?Atw;pB#PF)j{U;roX$%WF9c3T7;S-~G7pnv+F0 zqP9jk32_6)>377Dl@dAcp3LY!^}U7ZFS6?R&6X#3zts8HC~^eTv86vBdabZ`v}}#| zLjnAfWKx)M$PypJu6vUH5o5|!b?$GON;1hIo|B&ico#HRvvTA9+Syx=YES{w&`Dgb0TL?zpbW7!GnI1cVC_C zGwe!LN&fPRah($%;Vt&9on<^?Ac6>jg)<_8#fcZkyU zj>%yw;!AXEH>`3nFDO3C5`8BEVIh=}1eV{4;aXJ;XFRrj>MAm`mah43 zIxGM6`}y|xOTx^pHsd-(<#zQxw$ilq7`Kh|=T+gCJN#F;XM1P;4Jh>w zRnn9T1cY;zEsZ_X6;}GtUNOo-#kRQq}j-WVEwh`oMT`BRv`- znjT0De{7aa^6QtTiXg>(J!oI`mN{fUsYf94ykN( zw%hQl8jKMrydtBIKCN)6eRNT0uC*xc?IGg;$KU5`b4S7k@O{NMm&rBL-n#U^j_8}= zkx#_!@tVoPsqd0S;k89qwKJ-;9$c@l_R!Cp_eFRua-!Oo0^~^)DZ8TXpdV)M#yx{DcW(`&~G8|W?`tBsp) zYnc4d;%;?%b literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/rifle/ar_unload.ogg b/voidcrew/modules/gun_feel/sound/rifle/ar_unload.ogg new file mode 100644 index 0000000000000000000000000000000000000000..591599f52334a79bf97cb36bf6f6ba859750e5da GIT binary patch literal 6779 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 C{cvXh literal 0 HcmV?d00001 diff --git a/voidcrew/modules/gun_feel/sound/shotgun/shot.ogg b/voidcrew/modules/gun_feel/sound/shotgun/shot.ogg new file mode 100644 index 0000000000000000000000000000000000000000..2f499f08b01657d33134daaa9e3ea4dc6fc28129 GIT binary patch literal 40937 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