From c739e14942342e73c5ca89f2a88967bc935a064e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:19:07 -0800 Subject: [PATCH 1/3] Let dermal plating deflect bullets from the front Dermal Mesh, Hardweave and Slabskin turn 15, 30 and 45 percent of bullets and buckshot pellets from the front. A glanced round turns off at a random angle and keeps flying, with sparks, a ricochet ping and a small stamina cost. Dodge and deflect together stop at most 60 percent of shots. --- tools/gear_catalog/ratings/cyberware.json | 16 +-- .../modules/cyberware/ware_military_body.dm | 12 +- voidcrew/modules/cyberware/ware_models_b.dm | 3 +- voidcrew/modules/cyberware/ware_pro_combat.dm | 132 ++++++++++++++++-- voidcrew/modules/cyberware/ware_street.dm | 16 ++- voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_deflect.dm | 70 ++++++++++ wiki/content/cyberware.md | 7 +- 8 files changed, 230 insertions(+), 27 deletions(-) create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_deflect.dm diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 65fdbe71618..f6116cd316f 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -449,11 +449,11 @@ "/obj/item/organ/cyberimp/cyberware/dermal_mesh": { "confidence": "high", "group": "Armor and frame", - "hash": "5b60dbb7a15c8084", + "hash": "", "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.", + "note": "While online, adds physiology armor to a human wearer: melee 15, bullet 15, laser 10, wound 5. Bullets and buckshot pellets from the front glance off at 15% (each pellet rolls on its own), keep flying at reduced damage and cost the wearer 5 stamina each. Never lasers, energy, melee or shots into the back. Shares the dodge arbiter: dodge plus glance never stop more than 60% of shots. The armor and the glance are off during an EMP reboot or an over-capacity brownout.", "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.", + "reason": "15 melee, 15 bullet, 10 laser on the whole body, on top of whatever you wear, and one bullet in seven from the front glances off. 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": { @@ -461,9 +461,9 @@ "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.", + "note": "While online, adds physiology armor to a human wearer: melee 20, bullet 20, laser 15, energy 10, wound 10. Bullets and buckshot pellets from the front glance off at 30% (each pellet rolls on its own), keep flying at reduced damage and cost the wearer 5 stamina each. Never lasers, energy, melee or shots into the back. Shares the dodge arbiter: dodge plus glance never stop more than 60% of shots. The armor and the glance are off 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.", + "reason": "20 melee, 20 bullet, 15 laser, 10 energy and 10 wound armor on the whole body, on top of whatever you wear, and nearly a third of bullets from the front glance off. A size up from the Mesh and well short of the Slabskin Plate, which adds bomb, fire, acid and a bigger glance. Load 3, 3,000 cr at reputation 4. Strong for yellow against gunfire from the front, still useless against lasers and flanking. EMP switches it off for 9 s.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/doppler": { @@ -779,11 +779,11 @@ "/obj/item/organ/cyberimp/cyberware/slabskin": { "confidence": "high", "group": "Armor and frame", - "hash": "7b785a3adf8b7c37", + "hash": "", "name": "Slabskin plate", - "note": "While online it adds built-in armor to the bearer (melee 25, bullet 25, laser 25, energy 20, bomb 25, fire 20, acid 20, wound 15), and any brute or burn hit of 5 or more throws sparks and often a ricochet sound so others can see the plate. The armor switches off while the ware is EMP'd or browned out.", + "note": "While online it adds built-in armor to the bearer (melee 25, bullet 25, laser 25, energy 20, bomb 25, fire 20, acid 20, wound 15), and any brute or burn hit of 5 or more throws sparks and often a ricochet sound so others can see the plate. Bullets and buckshot pellets from the front glance off at 45% (each pellet rolls on its own), keep flying at reduced damage and cost the wearer 5 stamina each. Never lasers, energy, melee or shots into the back. Shares the dodge arbiter: dodge plus glance never stop more than 60% of shots. The armor and the glance switch off while the ware is EMP'd or browned out.", "rank": 1, - "reason": "25 armor against melee, bullets and lasers on the whole body, multiplied on top of whatever you wear, plus bomb, fire, acid and wounds. About a second armor vest that covers your head and limbs. Load 6, 3 vouchers at reputation 22. EMP switches it off for 9 s.", + "reason": "25 armor against melee, bullets and lasers on the whole body, multiplied on top of whatever you wear, plus bomb, fire, acid and wounds, and almost half of bullets from the front glance off. About a second armor vest that covers your head and limbs, and the best answer to a gunline in the game. Load 6, 3 vouchers at reputation 22. EMP switches it off for 9 s.", "tier": "red" }, "/obj/item/organ/cyberimp/cyberware/slipwire": { diff --git a/voidcrew/modules/cyberware/ware_military_body.dm b/voidcrew/modules/cyberware/ware_military_body.dm index a5cf534aba9..ffd818d7dd5 100644 --- a/voidcrew/modules/cyberware/ware_military_body.dm +++ b/voidcrew/modules/cyberware/ware_military_body.dm @@ -264,7 +264,7 @@ */ /obj/item/organ/cyberimp/cyberware/slabskin name = "\improper Slabskin plate" - desc = "A keel and rib plates of sintered ceramic, grown into the skin of the chest and back. Bullets leave scuffs and a noise like a dropped pan." + desc = "A keel and rib plates of sintered ceramic, grown into the skin of the chest and back. Bullets leave scuffs, when they don't bounce off with a noise like a dropped pan." icon_state = "slabskin" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_DERMAL @@ -276,6 +276,8 @@ /// TRUE while the plate armor is mixed into the bearer's physiology. /// Guards the failing-gated passive hooks against double add/subtract. var/plate_armor_applied = FALSE + /// Percent of bullets from the front that glance off, through the shared dodge arbiter. The top of the dermal ladder. + var/deflect_chance = 45 /obj/item/organ/cyberimp/cyberware/slabskin/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -283,6 +285,7 @@ /obj/item/organ/cyberimp/cyberware/slabskin/chrome_passives_on(mob/living/carbon/bearer) . = ..() + cyberware_register_deflect_source(bearer, REF(src), CALLBACK(src, PROC_REF(get_deflect_chance))) if(plate_armor_applied || !ishuman(bearer)) return plate_armor_applied = TRUE @@ -291,6 +294,7 @@ /obj/item/organ/cyberimp/cyberware/slabskin/chrome_passives_off(mob/living/carbon/bearer) . = ..() + cyberware_unregister_deflect_source(bearer, REF(src)) if(!plate_armor_applied) return plate_armor_applied = FALSE // reset before the validity skip, or the plate never re-arms after a bearer deletes @@ -303,6 +307,12 @@ . = ..() UnregisterSignal(organ_owner, COMSIG_MOB_APPLY_DAMAGE) +/// Deflect-source callback: nothing while failing. +/obj/item/organ/cyberimp/cyberware/slabskin/proc/get_deflect_chance() + if(organ_flags & ORGAN_FAILING) + return 0 + return deflect_chance + /** * Signal proc for [COMSIG_MOB_APPLY_DAMAGE]: the telegraph. Solid brute or * burn hits spark and ping off the plate where everyone can see them. diff --git a/voidcrew/modules/cyberware/ware_models_b.dm b/voidcrew/modules/cyberware/ware_models_b.dm index 9db419c8cba..7de0991d93c 100644 --- a/voidcrew/modules/cyberware/ware_models_b.dm +++ b/voidcrew/modules/cyberware/ware_models_b.dm @@ -44,12 +44,13 @@ /obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave name = "\improper Hardweave plating" - desc = "Rib plates over the chest and back with a layered polymer weave sewn in below them, a size up from the usual mesh. The scarring is the part nobody mentions." + desc = "Rib plates over the chest and back with a layered polymer weave sewn in below them, a size up from the usual mesh. Rounds that come in at an angle skip off. 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 + deflect_chance = 30 // ========================================================================= // STRUT FRAME (T2): the bones and nothing else diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index 30371c5e83e..78b2a138ebb 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -13,7 +13,10 @@ * COMSIG_ATOM_PRE_BULLET_ACT handler. A source registered with melee = TRUE * (the Cascade line's windows) also dodges blows, through tg's own * check_block() via COMSIG_LIVING_CHECK_BLOCK, so a dodged blow is a real - * miss and never fake damage. + * miss and never fake damage. The same arbiter rolls the dermal plating's + * bullet deflection (see cyberware_register_deflect_source()), capped so a + * dodge and a deflect together never stop more than CYBERWARE_AVOID_CAP + * percent of shots. * - cyberware_is_ally(): the crew filter for auto-targeting ware (Deadeye's * tag, Widowline's cleave). Allies are people who share a ship team with * you; NPC boarders standing on your deck are NOT allies, and rival players @@ -27,6 +30,10 @@ #define CYBERWARE_DODGE_MOVE_WINDOW (1 SECONDS) /// The shortest gap between two dodge lines from a bearer nobody is playing (an NPC under fire). #define CYBERWARE_DODGE_LINE_GAP (1.5 SECONDS) +/// Dodge and deflect together never stop more than this percent of shots. +#define CYBERWARE_AVOID_CAP 60 +/// Stamina each deflected bullet costs the bearer, so focused fire still wears them down. +#define CYBERWARE_DEFLECT_STAMINA 5 /** * The one dodge roll for all cyberware. Lives on the mob; wares and status @@ -53,6 +60,8 @@ var/list/dodge_sources = list() /// key -> /datum/callback for the sources that also dodge blows. Each is in dodge_sources too. var/list/melee_sources = list() + /// key -> /datum/callback returning that source's live bullet-deflect chance (the dermal plating). + var/list/deflect_sources = list() /// world.time of the bearer's last move. var/last_move_time = 0 /// world.time a dodge was last announced: an NPC under fire says it at most once every CYBERWARE_DODGE_LINE_GAP, so chat isn't flooded @@ -73,6 +82,7 @@ /datum/component/cyberware_dodge/Destroy() dodge_sources = null melee_sources = null + deflect_sources = null return ..() /// Signal proc for [COMSIG_MOVABLE_MOVED]: timestamp the movement window. @@ -80,25 +90,83 @@ SIGNAL_HANDLER last_move_time = world.time -/// Signal proc for [COMSIG_ATOM_PRE_BULLET_ACT]: the actual dodge roll. +/** + * Signal proc for [COMSIG_ATOM_PRE_BULLET_ACT]: the dodge roll, then the + * deflect roll. One handler for both, so the cap on the two together holds + * no matter which chrome registered first. + */ /datum/component/cyberware_dodge/proc/on_pre_bullet_act(mob/living/source, obj/projectile/hitting_projectile, def_zone, piercing_hit, blocked) SIGNAL_HANDLER - if(world.time - last_move_time > CYBERWARE_DODGE_MOVE_WINDOW) - return NONE - if(source.stat != CONSCIOUS || source.body_position == LYING_DOWN || source.buckled) - return NONE if(!istype(hitting_projectile) || !hitting_projectile.is_hostile_projectile()) return NONE + var/dodge_chance = live_dodge_chance(source, hitting_projectile) + if(dodge_chance > 0 && prob(dodge_chance)) + INVOKE_ASYNC(src, PROC_REF(perform_sidestep), source, hitting_projectile) + return COMPONENT_BULLET_PIERCED + var/deflect_chance = min(live_deflect_chance(source, hitting_projectile), cyberware_deflect_cap(dodge_chance)) + if(deflect_chance <= 0 || !prob(deflect_chance)) + return NONE + glance(source, hitting_projectile) + return COMPONENT_BULLET_PIERCED + +/// The dodge chance against this shot right now: 0 unless the bearer is moving, upright and not point-blank. +/datum/component/cyberware_dodge/proc/live_dodge_chance(mob/living/source, obj/projectile/hitting_projectile) + if(!length(dodge_sources)) + return 0 + if(world.time - last_move_time > CYBERWARE_DODGE_MOVE_WINDOW) + return 0 + if(source.stat != CONSCIOUS || source.body_position == LYING_DOWN || source.buckled) + return 0 // Point-blank shots always land: an adjacent shooter is inside the // reflex loop. This is the anti-"unhittable in a brawl" rule. var/atom/shooter = hitting_projectile.firer if(shooter && shooter != source && get_dist(source, shooter) <= 1) - return NONE - var/best_chance = best_chance(dodge_sources) - if(best_chance <= 0 || !prob(best_chance)) - return NONE - INVOKE_ASYNC(src, PROC_REF(perform_sidestep), source, hitting_projectile) - return COMPONENT_BULLET_PIERCED + return 0 + return best_chance(dodge_sources) + +/** + * The deflect chance against this shot right now. Only real bullets (and + * each buckshot pellet on its own, since every pellet is its own projectile) + * glance off; lasers, energy and everything else never do. The plate only + * covers what the bearer is facing: a shot into the back always lands. + */ +/datum/component/cyberware_dodge/proc/live_deflect_chance(mob/living/source, obj/projectile/hitting_projectile) + if(!length(deflect_sources)) + return 0 + if(!istype(hitting_projectile, /obj/projectile/bullet) || hitting_projectile.armor_flag != BULLET) + return 0 + if(source.stat == DEAD) + return 0 + if(cyberware_shot_from_behind(source, hitting_projectile)) + return 0 + return best_chance(deflect_sources) + +/** + * The real glance: the bullet's heading turns back off the plate at a random + * angle and the round keeps flying (COMPONENT_BULLET_PIERCED, the same exit + * tg's own reflect takes), so it can still hit whoever stands in its new + * path. It loses energy like any tg ricochet. The bearer stays in the + * bullet's impacted list, so it can't come back for them. + */ +/datum/component/cyberware_dodge/proc/glance(mob/living/source, obj/projectile/bullet) + bullet.set_angle(SIMPLIFY_DEGREES(bullet.angle + pick(-1, 1) * rand(100, 170))) + bullet.damage *= bullet.ricochet_decay_damage + bullet.stamina *= bullet.ricochet_decay_damage + INVOKE_ASYNC(src, PROC_REF(perform_glance), source, bullet.name) + +/// The visible and physical half of a glance: sparks, a ping, and the stamina the hit still costs. +/datum/component/cyberware_dodge/proc/perform_glance(mob/living/source, bullet_name) + if(QDELETED(source)) + return + do_sparks(1, TRUE, source) + playsound(source, SFX_RICOCHET, 60, TRUE) + source.adjustStaminaLoss(CYBERWARE_DEFLECT_STAMINA) + if(source.client || world.time - last_message_at >= CYBERWARE_DODGE_LINE_GAP) + last_message_at = world.time + source.visible_message( + span_danger("\The [bullet_name] glances off [source]!"), + span_danger("\The [bullet_name] glances off you!"), + ) /// The highest live chance among these sources. Never additive. /datum/component/cyberware_dodge/proc/best_chance(list/sources) @@ -206,9 +274,45 @@ return arbiter.dodge_sources -= key arbiter.melee_sources -= key - if(!length(arbiter.dodge_sources)) + if(!length(arbiter.dodge_sources) && !length(arbiter.deflect_sources)) qdel(arbiter) +/** + * Adds (or refreshes) a keyed bullet-deflect source on the bearer, through + * the same arbiter as the dodge. The callback returns the source's CURRENT + * chance. Never additive: the highest live source rolls. + */ +/proc/cyberware_register_deflect_source(mob/living/bearer, key, datum/callback/chance_callback) + if(!istype(bearer) || !key || !chance_callback) + return + var/datum/component/cyberware_dodge/arbiter = bearer.LoadComponent(/datum/component/cyberware_dodge) + arbiter.deflect_sources[key] = chance_callback + +/// Removes a keyed deflect source; the arbiter cleans itself up with the last source of either kind. +/proc/cyberware_unregister_deflect_source(mob/living/bearer, key) + var/datum/component/cyberware_dodge/arbiter = bearer?.GetComponent(/datum/component/cyberware_dodge) + if(!arbiter) + return + arbiter.deflect_sources -= key + if(!length(arbiter.dodge_sources) && !length(arbiter.deflect_sources)) + qdel(arbiter) + +/** + * The most a deflect may roll after a dodge roll at dodge_chance missed, so + * that dodge and deflect together never stop more than CYBERWARE_AVOID_CAP + * percent of shots: dodge + (1 - dodge) * deflect <= cap. + */ +/proc/cyberware_deflect_cap(dodge_chance) + dodge_chance = clamp(dodge_chance, 0, 100) + if(dodge_chance >= CYBERWARE_AVOID_CAP) + return 0 + return (CYBERWARE_AVOID_CAP - dodge_chance) / (100 - dodge_chance) * 100 + +/// Whether this shot is travelling the way the bearer faces, so it strikes them in the back. +/proc/cyberware_shot_from_behind(mob/living/bearer, obj/projectile/shot) + var/offset = SIMPLIFY_DEGREES(shot.angle - dir2angle(bearer.dir) + 180) - 180 + return abs(offset) < 90 + // ---- Shared ally filter ------------------------------------------------ /** @@ -811,6 +915,8 @@ #undef CYBERWARE_DODGE_MOVE_WINDOW #undef CYBERWARE_DODGE_LINE_GAP +#undef CYBERWARE_AVOID_CAP +#undef CYBERWARE_DEFLECT_STAMINA #undef CYBERWARE_DEADEYE_TAG_RANGE #undef CYBERWARE_DEADEYE_TAG_SHOTS #undef CYBERWARE_DEADEYE_TAG_DURATION diff --git a/voidcrew/modules/cyberware/ware_street.dm b/voidcrew/modules/cyberware/ware_street.dm index 70d6d2a1751..9fe5c186db2 100644 --- a/voidcrew/modules/cyberware/ware_street.dm +++ b/voidcrew/modules/cyberware/ware_street.dm @@ -745,10 +745,14 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( * add/subtract pair per flip is the whole lifecycle; re-applying on species * gain would stack the armor. The pair rides the failing-gated passive hooks * (BAL-4): EMP-scrambled or browned-out mesh armors nothing. + * + * The dermal ladder (Mesh, Hardweave, Slabskin) also turns bullets: a + * passive deflect source in the shared dodge arbiter (ware_pro_combat.dm), + * 15/30/45% by rung, front arc only, rides the same passive hooks. */ /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 it takes the edge off." + desc = "A subdermal lattice of impact polymer. It won't stop anything serious, but now and then a round skips off it." icon_state = "dermal_mesh" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_DERMAL @@ -761,9 +765,12 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( var/datum/armor/mesh_armor = /datum/armor/cyberware_dermal_mesh /// TRUE while mesh_armor is mixed in; guards against double add/subtract. var/mesh_armor_applied = FALSE + /// Percent of bullets from the front that glance off, through the shared dodge arbiter. + var/deflect_chance = 15 /obj/item/organ/cyberimp/cyberware/dermal_mesh/chrome_passives_on(mob/living/carbon/bearer) . = ..() + cyberware_register_deflect_source(bearer, REF(src), CALLBACK(src, PROC_REF(get_deflect_chance))) if(mesh_armor_applied || !ishuman(bearer)) return mesh_armor_applied = TRUE @@ -772,6 +779,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( /obj/item/organ/cyberimp/cyberware/dermal_mesh/chrome_passives_off(mob/living/carbon/bearer) . = ..() + cyberware_unregister_deflect_source(bearer, REF(src)) if(!mesh_armor_applied) return mesh_armor_applied = FALSE // reset before the validity skip, see Shock Coils @@ -780,6 +788,12 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( var/mob/living/carbon/human/human_bearer = bearer human_bearer.physiology.armor = human_bearer.physiology.armor.subtract_other_armor(mesh_armor) +/// Deflect-source callback: nothing while failing. +/obj/item/organ/cyberimp/cyberware/dermal_mesh/proc/get_deflect_chance() + if(organ_flags & ORGAN_FAILING) + return 0 + return deflect_chance + // ---- 11. Gecko Grips --------------------------------------------------- /** diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 412f552333c..c8f4bd7281b 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -92,6 +92,7 @@ #include "voidcrew_cyberware_anim.dm" #include "voidcrew_cyberware_anim2.dm" #include "voidcrew_cyberware_camo.dm" +#include "voidcrew_cyberware_deflect.dm" #include "voidcrew_cyberware_final.dm" #include "voidcrew_cyberware_kit.dm" #include "voidcrew_cyberware_mantis.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_deflect.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_deflect.dm new file mode 100644 index 00000000000..48774034957 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_deflect.dm @@ -0,0 +1,70 @@ +/// The dermal ladder turns bullets from the front: a real glance that leaves +/// the bearer unhurt and keeps the round flying on a new heading, costs a +/// little stamina, never touches lasers or shots into the back, switches off +/// with the chrome, and never lifts dodge plus deflect past the shared cap. +/datum/unit_test/voidcrew_cyberware_deflect + +/datum/unit_test/voidcrew_cyberware_deflect/Run() + var/obj/item/organ/cyberimp/cyberware/dermal_mesh/mesh = allocate(/obj/item/organ/cyberimp/cyberware/dermal_mesh) + 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/slabskin/slab = allocate(/obj/item/organ/cyberimp/cyberware/slabskin) + TEST_ASSERT_EQUAL(mesh.deflect_chance, 15, "Dermal Mesh deflects at the wrong chance") + TEST_ASSERT_EQUAL(hardweave.deflect_chance, 30, "Hardweave deflects at the wrong chance") + TEST_ASSERT_EQUAL(slab.deflect_chance, 45, "Slabskin deflects at the wrong chance") + + // The cap: dodge + (1 - dodge) * deflect never passes 60%. + TEST_ASSERT_EQUAL(cyberware_deflect_cap(0), 60, "The deflect cap is wrong with no dodge") + TEST_ASSERT_EQUAL(cyberware_deflect_cap(60), 0, "A full-cap dodge still left room to deflect") + for(var/dodge in list(0, 15, 30, 45, 59)) + var/deflect = min(slab.deflect_chance, cyberware_deflect_cap(dodge)) + var/combined = dodge + (100 - dodge) * deflect / 100 + TEST_ASSERT(combined <= 60.001, "Dodge [dodge] plus deflect stops [combined]% of shots") + + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/shooter = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + TEST_ASSERT(slab.Insert(patient, TRUE), "Slabskin fixture failed") + var/datum/component/cyberware_dodge/arbiter = patient.GetComponent(/datum/component/cyberware_dodge) + TEST_ASSERT_NOTNULL(arbiter, "Slabskin didn't register a deflect source") + slab.deflect_chance = 100 + patient.setDir(SOUTH) + + // A bullet from the front (travelling north into a bearer facing south) glances off. + var/obj/projectile/bullet/c9mm/front = make_shot(/obj/projectile/bullet/c9mm, shooter, 0) + var/front_damage = front.damage + TEST_ASSERT_EQUAL(patient.bullet_act(front, BODY_ZONE_CHEST), BULLET_ACT_FORCE_PIERCE, "A bullet from the front didn't glance off") + TEST_ASSERT_EQUAL(patient.getBruteLoss(), 0, "A glanced bullet still hurt") + TEST_ASSERT(front.angle != 0, "The glanced bullet kept its heading") + TEST_ASSERT(front.damage < front_damage, "The glanced bullet lost no energy") + TEST_ASSERT(patient.getStaminaLoss() > 0, "A glance cost no stamina") + + // Every buckshot pellet is a bullet of its own and rolls on its own. + var/obj/projectile/bullet/pellet/shotgun_buckshot/pellet = make_shot(/obj/projectile/bullet/pellet/shotgun_buckshot, shooter, 0) + TEST_ASSERT_EQUAL(patient.bullet_act(pellet, BODY_ZONE_CHEST), BULLET_ACT_FORCE_PIERCE, "A buckshot pellet didn't glance off") + + // A bullet into the back always lands. + var/obj/projectile/bullet/c9mm/back = make_shot(/obj/projectile/bullet/c9mm, shooter, 180) + TEST_ASSERT_NOTEQUAL(patient.bullet_act(back, BODY_ZONE_CHEST), BULLET_ACT_FORCE_PIERCE, "A bullet into the back glanced off") + TEST_ASSERT(patient.getBruteLoss() > 0, "A bullet into the back did nothing") + + // Lasers are never deflected. + var/obj/projectile/beam/laser/laser = make_shot(/obj/projectile/beam/laser, shooter, 0) + TEST_ASSERT_NOTEQUAL(patient.bullet_act(laser, BODY_ZONE_CHEST), BULLET_ACT_FORCE_PIERCE, "A laser glanced off the plate") + TEST_ASSERT(patient.getFireLoss() > 0, "The laser did nothing") + + // A failing plate deflects nothing. + slab.organ_flags |= ORGAN_FAILING + var/obj/projectile/bullet/c9mm/failing = make_shot(/obj/projectile/bullet/c9mm, shooter, 0) + TEST_ASSERT_NOTEQUAL(patient.bullet_act(failing, BODY_ZONE_CHEST), BULLET_ACT_FORCE_PIERCE, "A failing plate still deflected") + slab.organ_flags &= ~ORGAN_FAILING + + // Taking the plate out takes the deflect with it. + slab.Remove(patient) + TEST_ASSERT_NULL(patient.GetComponent(/datum/component/cyberware_dodge), "The deflect source outlived the plate") + +/// A hostile shot from the shooter, heading at the given angle, ready for bullet_act(). +/datum/unit_test/voidcrew_cyberware_deflect/proc/make_shot(shot_type, mob/living/shooter, heading) + var/obj/projectile/shot = allocate(shot_type) + shot.firer = shooter + shot.def_zone = BODY_ZONE_CHEST + shot.set_angle(heading) + return shot diff --git a/wiki/content/cyberware.md b/wiki/content/cyberware.md index abc3f7f1a7b..aefaa0e9ca4 100644 --- a/wiki/content/cyberware.md +++ b/wiki/content/cyberware.md @@ -47,7 +47,7 @@ Prices are the same at both counters, stock is limited per round, and load is th | Cargo Cavity | 1,000 cr | 1 | A chest stash for one small item that no scanner, pat-down or Chrome Read finds | | Rockjaw drill fist | 1,200 cr | 2 | Deployable forearm mining drill; ore from walls you crack auto-pockets into a hopper | | Fixer's Fingers | 1,800 cr | 2 | Fingertip screwdriver, wrench and wirecutters, plus 25% faster timed actions full-time | -| Dermal Mesh weave | 2,000 cr | 2 | Light built-in armor: melee 15, bullet 15, laser 10 | +| Dermal Mesh weave | 2,000 cr | 2 | Light built-in armor: melee 15, bullet 15, laser 10. 15% of bullets from the front glance off | ### Tier 2: Pro chrome (credits or vouchers) @@ -76,7 +76,7 @@ Prices are the same at both counters, stock is limited per round, and load is th | Widowline monowire | 3 | 5 | A two-tile whip that ignores armor entirely and cleaves bystanders for 75% | | Popup Ronin housing | *loot only* | 5 | Deployable full-auto machine pistol in 10x24mm caseless. Reloads are sold at the parlor or printed with Experimental Ammunition | | Rigger socket | 3 | 4 | Jack into your own ship's helm from anywhere aboard and fly it from a corridor | -| Slabskin plate | 3 | 6 | Heavy built-in armor in every damage type. Solid hits visibly spark off you. Everyone can see you are plated | +| Slabskin plate | 3 | 6 | Heavy built-in armor in every damage type. 45% of bullets from the front glance off. 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. Press and hold the lunge on a target to crouch, and let go to spring up to seven tiles; a solid hit or a stun while you crouch wastes it. A kill brings the lunge back almost at once. Three quick cuts to one limb open its artery, a two-blade lunge skewers someone already hurt, every third cut with both blades out crosses them and staggers, and a swing at the head of someone dying takes it off | | 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 a shock, or an EMP drops it | @@ -118,7 +118,7 @@ The newer makers fill in the gaps under the big names. Each takes the same slot - **Brawler myomers**: a cased pair of forearms that hit hard, break rock, slam a held body down and force an airlock, slower than Gorilla Arms. - **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. +- **Hardweave plating** and the **Strut frame**: a heavier dermal weave that turns 30% of bullets from the front, 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. @@ -137,4 +137,5 @@ Most of the roster also appears in zone loot caches. Street and utility chrome a - **EMP is the hard counter.** A pulse downs every piece you carry (nine seconds on a heavy blast, four and a half on a light one) then it reboots on its own. Gecko Grips spasm and throw whatever you are holding, Ghostskin camo collapses, and a Cascade window crashes on the spot. A Cradle tune-up cancels the reboot early. Actions and arm weapons stay unavailable while the chrome is offline. An EMP interrupting Lazarus during its charge does not spend the revival charge. - **Detection cuts both ways.** Chrome optics carry the Chrome Read, a scan that names a target's ware (street optics only count it), and the subject always feels the handshake. The Cargo Cavity never resolves on any scan, and Ghostskin scatters reads entirely. - **Death does not keep it.** Gibbing, dusting or destroying a limb ejects every piece onto the floor, and killers absolutely loot a chromed corpse. Cloning leaves the chrome on the body. +- **Plating turns bullets.** Dermal Mesh, Hardweave and Slabskin make bullets and buckshot pellets glance off with a spark and a ping, each pellet on its own. It never stops lasers, energy or blows, and never a shot into your back. The round keeps flying, so whoever stands beside you can still catch it. Each glance costs a little stamina, so steady fire still wears you down. A dodge and a glance together never stop more than 60% of shots. - **Nothing is subtle.** Slabskin sparks on every hit, the Chromatic Dermis flares whenever your chrome fires, and the heavy installs change how you look. Chromed is a fashion statement whether you want it or not. From 5b8102e19c88e4b689eb19b1b12cdc3d840e86e5 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:25:49 -0800 Subject: [PATCH 2/3] Compute the deflect cap without float drift --- voidcrew/modules/cyberware/ware_pro_combat.dm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index 78b2a138ebb..f70189ca13e 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -306,7 +306,7 @@ dodge_chance = clamp(dodge_chance, 0, 100) if(dodge_chance >= CYBERWARE_AVOID_CAP) return 0 - return (CYBERWARE_AVOID_CAP - dodge_chance) / (100 - dodge_chance) * 100 + return (CYBERWARE_AVOID_CAP - dodge_chance) * 100 / (100 - dodge_chance) /// Whether this shot is travelling the way the bearer faces, so it strikes them in the back. /proc/cyberware_shot_from_behind(mob/living/bearer, obj/projectile/shot) From f003e3e0aba562c2061b4e8d64c1a76bf1c92ef6 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:44:39 -0800 Subject: [PATCH 3/3] Let the deflect test force a glance past the shared cap --- voidcrew/modules/cyberware/ware_pro_combat.dm | 12 +++++++----- .../modules/unit_tests/voidcrew_cyberware_deflect.dm | 2 ++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index f70189ca13e..9a7e77c66a0 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -62,6 +62,8 @@ var/list/melee_sources = list() /// key -> /datum/callback returning that source's live bullet-deflect chance (the dermal plating). var/list/deflect_sources = list() + /// Dodge and deflect together never stop more than this percent of shots. + var/avoid_cap = CYBERWARE_AVOID_CAP /// world.time of the bearer's last move. var/last_move_time = 0 /// world.time a dodge was last announced: an NPC under fire says it at most once every CYBERWARE_DODGE_LINE_GAP, so chat isn't flooded @@ -103,7 +105,7 @@ if(dodge_chance > 0 && prob(dodge_chance)) INVOKE_ASYNC(src, PROC_REF(perform_sidestep), source, hitting_projectile) return COMPONENT_BULLET_PIERCED - var/deflect_chance = min(live_deflect_chance(source, hitting_projectile), cyberware_deflect_cap(dodge_chance)) + var/deflect_chance = min(live_deflect_chance(source, hitting_projectile), cyberware_deflect_cap(dodge_chance, avoid_cap)) if(deflect_chance <= 0 || !prob(deflect_chance)) return NONE glance(source, hitting_projectile) @@ -299,14 +301,14 @@ /** * The most a deflect may roll after a dodge roll at dodge_chance missed, so - * that dodge and deflect together never stop more than CYBERWARE_AVOID_CAP + * that dodge and deflect together never stop more than avoid_cap * percent of shots: dodge + (1 - dodge) * deflect <= cap. */ -/proc/cyberware_deflect_cap(dodge_chance) +/proc/cyberware_deflect_cap(dodge_chance, avoid_cap = CYBERWARE_AVOID_CAP) dodge_chance = clamp(dodge_chance, 0, 100) - if(dodge_chance >= CYBERWARE_AVOID_CAP) + if(dodge_chance >= avoid_cap) return 0 - return (CYBERWARE_AVOID_CAP - dodge_chance) * 100 / (100 - dodge_chance) + return (avoid_cap - dodge_chance) * 100 / (100 - dodge_chance) /// Whether this shot is travelling the way the bearer faces, so it strikes them in the back. /proc/cyberware_shot_from_behind(mob/living/bearer, obj/projectile/shot) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_deflect.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_deflect.dm index 48774034957..696e4b85e95 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_deflect.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_deflect.dm @@ -25,7 +25,9 @@ TEST_ASSERT(slab.Insert(patient, TRUE), "Slabskin fixture failed") var/datum/component/cyberware_dodge/arbiter = patient.GetComponent(/datum/component/cyberware_dodge) TEST_ASSERT_NOTNULL(arbiter, "Slabskin didn't register a deflect source") + // Force every glance: a 100% plate under a lifted cap. The cap itself is checked above. slab.deflect_chance = 100 + arbiter.avoid_cap = 100 patient.setDir(SOUTH) // A bullet from the front (travelling north into a bearer facing south) glances off.