diff --git a/code/__DEFINES/antagonists.dm b/code/__DEFINES/antagonists.dm
index 888164e5256..e3e67c82b57 100644
--- a/code/__DEFINES/antagonists.dm
+++ b/code/__DEFINES/antagonists.dm
@@ -146,6 +146,9 @@
/// JSON string file for all of our heretic influence flavors
#define HERETIC_INFLUENCE_FILE "antagonist_flavor/heretic_influences.json"
+/// JSON file containing spy objectives
+#define SPY_OBJECTIVE_FILE "antagonist_flavor/spy_objective.json"
+
///employers that are from the syndicate
GLOBAL_LIST_INIT(syndicate_employers, list(
"Animal Rights Consortium",
@@ -265,6 +268,8 @@ GLOBAL_LIST_INIT(human_invader_antagonists, list(
#define OBJECTIVE_ITEM_TYPE_NORMAL "normal"
/// Only appears in traitor objectives
#define OBJECTIVE_ITEM_TYPE_TRAITOR "traitor"
+/// Only appears for spy bounties
+#define OBJECTIVE_ITEM_TYPE_SPY "spy"
// Progression traitor defines
@@ -378,3 +383,11 @@ GLOBAL_LIST_INIT(human_invader_antagonists, list(
#define BATON_MODES 4
#define FREEDOM_IMPLANT_CHARGES 4
+
+// Spy bounty difficulties
+/// Can easily be accomplished by any job without any specialized tools, people won't really miss these things
+#define SPY_DIFFICULTY_EASY "Easy"
+/// Requires some specialized tools, knowledge, or access to accomplish, may require getting into conflict with the crew
+#define SPY_DIFFICULTY_MEDIUM "Medium"
+/// Very difficult to accomplish, almost guaranteed to require crew conflict
+#define SPY_DIFFICULTY_HARD "Hard"
diff --git a/code/__DEFINES/dcs/signals/signals_atom/signals_atom_movable.dm b/code/__DEFINES/dcs/signals/signals_atom/signals_atom_movable.dm
index 601f441c66d..38d0500dcbd 100644
--- a/code/__DEFINES/dcs/signals/signals_atom/signals_atom_movable.dm
+++ b/code/__DEFINES/dcs/signals/signals_atom/signals_atom_movable.dm
@@ -112,3 +112,6 @@
#define COMSIG_MOVABLE_EDIT_UNIQUE_IMMERSE_OVERLAY "movable_edit_unique_submerge_overlay"
/// From base of area/Exited(): (area/left, direction)
#define COMSIG_MOVABLE_EXITED_AREA "movable_exited_area"
+
+/// Sent to movables when they are being stolen by a spy: (mob/living/spy, datum/spy_bounty/bounty)
+#define COMSIG_MOVABLE_SPY_STEALING "movable_spy_stealing"
diff --git a/code/__DEFINES/is_helpers.dm b/code/__DEFINES/is_helpers.dm
index 909399b3c3d..1cf4a1bb3be 100644
--- a/code/__DEFINES/is_helpers.dm
+++ b/code/__DEFINES/is_helpers.dm
@@ -314,6 +314,7 @@ GLOBAL_LIST_INIT(book_types, typecacheof(list(
#define is_captain_job(job_type) (istype(job_type, /datum/job/captain))
#define is_chaplain_job(job_type) (istype(job_type, /datum/job/chaplain))
#define is_clown_job(job_type) (istype(job_type, /datum/job/clown))
+#define is_mime_job(job_type) (istype(job_type, /datum/job/mime))
#define is_detective_job(job_type) (istype(job_type, /datum/job/detective))
#define is_scientist_job(job_type) (istype(job_type, /datum/job/scientist))
#define is_security_officer_job(job_type) (istype(job_type, /datum/job/security_officer))
diff --git a/code/__DEFINES/logging.dm b/code/__DEFINES/logging.dm
index 13fdd4d0b80..d4730ce0bb7 100644
--- a/code/__DEFINES/logging.dm
+++ b/code/__DEFINES/logging.dm
@@ -162,6 +162,7 @@
#define LOG_CATEGORY_UPLINK_HERETIC "uplink-heretic"
#define LOG_CATEGORY_UPLINK_MALF "uplink-malf"
#define LOG_CATEGORY_UPLINK_SPELL "uplink-spell"
+#define LOG_CATEGORY_UPLINK_SPY "uplink-spy"
// PDA categories
#define LOG_CATEGORY_PDA "pda"
diff --git a/code/__DEFINES/role_preferences.dm b/code/__DEFINES/role_preferences.dm
index 90e3f328f6e..d2ddf60ef88 100644
--- a/code/__DEFINES/role_preferences.dm
+++ b/code/__DEFINES/role_preferences.dm
@@ -16,6 +16,7 @@
#define ROLE_OPERATIVE "Operative"
#define ROLE_TRAITOR "Traitor"
#define ROLE_WIZARD "Wizard"
+#define ROLE_SPY "Spy"
// SKYRAT EDIT START
#define ROLE_ASSAULT_OPERATIVE "Assault Operative"
#define ROLE_OPFOR_CANDIDATE "OPFOR Candidate"
@@ -145,6 +146,7 @@ GLOBAL_LIST_INIT(special_roles, list(
ROLE_REV_HEAD = 14,
ROLE_TRAITOR = 0,
ROLE_WIZARD = 14,
+ ROLE_SPY = 0,
// SKYRAT EDIT ADDITION
ROLE_ASSAULT_OPERATIVE = 14,
// SKYRAT EDIT END
diff --git a/code/__DEFINES/uplink.dm b/code/__DEFINES/uplink.dm
index d6412e0e4d1..bb92f0672c3 100644
--- a/code/__DEFINES/uplink.dm
+++ b/code/__DEFINES/uplink.dm
@@ -12,6 +12,9 @@
/// This item is purchasable to infiltrators (midround traitors)
#define UPLINK_INFILTRATORS (1 << 3)
+/// Can be randomly given to spies for their bounties
+#define UPLINK_SPY (1 << 4)
+
/// Progression gets turned into a user-friendly form. This is just an abstract equation that makes progression not too large.
#define DISPLAY_PROGRESSION(time) round(time/60, 0.01)
@@ -19,3 +22,12 @@
#define TRAITOR_DISCOUNT_BIG "big_discount"
#define TRAITOR_DISCOUNT_AVERAGE "average_discount"
#define TRAITOR_DISCOUNT_SMALL "small_discount"
+
+/// Typepath used for uplink items which don't actually produce an item (essentially just a placeholder)
+/// Future todo: Make this not necessary / make uplink items support item-less items natively
+#define ABSTRACT_UPLINK_ITEM /obj/effect/gibspawner/generic
+
+/// Lower threshold for which an uplink items's TC cost is considered "low" for spy bounties picking rewards
+#define SPY_LOWER_COST_THRESHOLD 5
+/// Upper threshold for which an uplink items's TC cost is considered "high" for spy bounties picking rewards
+#define SPY_UPPER_COST_THRESHOLD 12
diff --git a/code/__HELPERS/logging/antagonists.dm b/code/__HELPERS/logging/antagonists.dm
index 3d06bb325ec..5df39c69ade 100644
--- a/code/__HELPERS/logging/antagonists.dm
+++ b/code/__HELPERS/logging/antagonists.dm
@@ -21,3 +21,7 @@
/// Logging for wizard powers learned
/proc/log_spellbook(text, list/data)
logger.Log(LOG_CATEGORY_UPLINK_SPELL, text, data)
+
+/// Logs bounties completed by spies and their rewards
+/proc/log_spy(text, list/data)
+ logger.Log(LOG_CATEGORY_UPLINK_SPY, text, data)
diff --git a/code/controllers/subsystem/blackmarket.dm b/code/controllers/subsystem/blackmarket.dm
index 357fa0df291..bdd342cbf3d 100644
--- a/code/controllers/subsystem/blackmarket.dm
+++ b/code/controllers/subsystem/blackmarket.dm
@@ -21,17 +21,20 @@ SUBSYSTEM_DEF(blackmarket)
for(var/market in subtypesof(/datum/market))
markets[market] += new market
- for(var/item in subtypesof(/datum/market_item))
- var/datum/market_item/I = new item()
- if(!I.item)
+ for(var/datum/market_item/item as anything in subtypesof(/datum/market_item))
+ if(!initial(item.item))
+ continue
+ if(!prob(initial(item.availability_prob)))
continue
- for(var/M in I.markets)
- if(!markets[M])
- stack_trace("SSblackmarket: Item [I] available in market that does not exist.")
+ var/datum/market_item/item_instance = new item()
+ for(var/potential_market in item_instance.markets)
+ if(!markets[potential_market])
+ stack_trace("SSblackmarket: Item [item_instance] available in market that does not exist.")
continue
- markets[M].add_item(item)
- qdel(I)
+ // If this fails the market item will just be GC'd
+ markets[potential_market].add_item(item_instance)
+
return SS_INIT_SUCCESS
/datum/controller/subsystem/blackmarket/fire(resumed)
diff --git a/code/controllers/subsystem/dynamic/dynamic_rulesets_roundstart.dm b/code/controllers/subsystem/dynamic/dynamic_rulesets_roundstart.dm
index b74483a2cb6..51ecd59925a 100644
--- a/code/controllers/subsystem/dynamic/dynamic_rulesets_roundstart.dm
+++ b/code/controllers/subsystem/dynamic/dynamic_rulesets_roundstart.dm
@@ -698,3 +698,45 @@ GLOBAL_VAR_INIT(revolutionary_win, FALSE)
create_separatist_nation(department_type, announcement = FALSE, dangerous = FALSE, message_admins = FALSE)
GLOB.round_default_lawset = /datum/ai_laws/united_nations
+
+/datum/dynamic_ruleset/roundstart/spies
+ name = "Spies"
+ antag_flag = ROLE_SPY
+ antag_datum = /datum/antagonist/spy
+ minimum_required_age = 0
+ protected_roles = list(
+ JOB_CAPTAIN,
+ JOB_DETECTIVE,
+ JOB_HEAD_OF_PERSONNEL, // AA = bad
+ JOB_HEAD_OF_SECURITY,
+ JOB_PRISONER,
+ JOB_SECURITY_OFFICER,
+ JOB_WARDEN,
+ )
+ restricted_roles = list(
+ JOB_AI,
+ JOB_CYBORG,
+ )
+ required_candidates = 3 // lives or dies by there being a few spies
+ weight = 5
+ cost = 8
+ scaling_cost = 101 // see below
+ minimum_players = 8
+ antag_cap = list("denominator" = 8, "offset" = 1) // should have quite a few spies to work against each other
+ requirements = list(8, 8, 8, 8, 8, 8, 8, 8, 8, 8)
+
+/datum/dynamic_ruleset/roundstart/spies/pre_execute(population)
+ for(var/i in 1 to get_antag_cap(population) * (scaled_times + 1))
+ if(length(candidates) <= 0)
+ break
+ var/mob/picked_player = pick_n_take(candidates)
+ assigned += picked_player.mind
+ picked_player.mind.special_role = ROLE_SPY
+ picked_player.mind.restricted_roles = restricted_roles
+ GLOB.pre_setup_antags += picked_player.mind
+ return TRUE
+
+/datum/dynamic_ruleset/roundstart/spies/scale_up(population, max_scale)
+ // Disabled (at least until dynamic can handle scaling this better)
+ // Because spies have a very low demoninator, this can easily spawn like 30 of them
+ return 0
diff --git a/code/datums/mind/antag.dm b/code/datums/mind/antag.dm
index b6179b5fc58..72674705457 100644
--- a/code/datums/mind/antag.dm
+++ b/code/datums/mind/antag.dm
@@ -106,6 +106,31 @@
var/datum/antagonist/rev/revolutionary = has_antag_datum(/datum/antagonist/rev)
revolutionary?.remove_revolutionary()
+/**
+ * Gets an item that can be used as an uplink somewhere on the mob's person.
+ *
+ * * desired_location: the location to look for the uplink in. An UPLINK_ define.
+ * If the desired location is not found, defaults to another location.
+ *
+ * Returns the item found, or null if no item was found.
+ */
+/mob/living/carbon/proc/get_uplink_location(desired_location = UPLINK_PDA)
+ var/list/all_contents = get_all_contents()
+ var/obj/item/modular_computer/pda/my_pda = locate() in all_contents
+ var/obj/item/radio/my_radio = locate() in all_contents
+ var/obj/item/pen/my_pen = (locate() in my_pda) || (locate() in all_contents)
+
+ switch(desired_location)
+ if(UPLINK_PDA)
+ return my_pda || my_radio || my_pen
+
+ if(UPLINK_RADIO)
+ return my_radio || my_pda || my_pen
+
+ if(UPLINK_PEN)
+ return my_pen || my_pda || my_radio
+
+ return null
/**
* ## give_uplink
@@ -116,53 +141,26 @@
* * antag_datum: the antag datum of the uplink owner, for storing it in antag memory. optional!
*/
/datum/mind/proc/give_uplink(silent = FALSE, datum/antagonist/antag_datum)
- if(!current)
+ if(isnull(current))
return
var/mob/living/carbon/human/traitor_mob = current
if (!istype(traitor_mob))
return
- var/list/all_contents = traitor_mob.get_all_contents()
- var/obj/item/modular_computer/pda/PDA = locate() in all_contents
- var/obj/item/radio/R = locate() in all_contents
- var/obj/item/pen/P
-
- if (PDA) // Prioritize PDA pen, otherwise the pocket protector pens will be chosen, which causes numerous ahelps about missing uplink
- P = locate() in PDA
- if (!P) // If we couldn't find a pen in the PDA, or we didn't even have a PDA, do it the old way
- P = locate() in all_contents
-
var/obj/item/uplink_loc
- var/implant = FALSE
-
var/uplink_spawn_location = traitor_mob.client?.prefs?.read_preference(/datum/preference/choiced/uplink_location)
- var/cant_speak = (HAS_TRAIT(traitor_mob, TRAIT_MUTE) || traitor_mob.mind?.assigned_role.title == JOB_MIME)
+ var/cant_speak = (HAS_TRAIT(traitor_mob, TRAIT_MUTE) || is_mime_job(assigned_role))
if(uplink_spawn_location == UPLINK_RADIO && cant_speak)
if(!silent)
to_chat(traitor_mob, span_warning("You have been deemed ineligible for a radio uplink. Supplying standard uplink instead."))
uplink_spawn_location = UPLINK_PDA
- switch (uplink_spawn_location)
- if(UPLINK_PDA)
- uplink_loc = PDA
- if(!uplink_loc)
- uplink_loc = R
- if(!uplink_loc)
- uplink_loc = P
- if(UPLINK_RADIO)
- uplink_loc = R
- if(!uplink_loc)
- uplink_loc = PDA
- if(!uplink_loc)
- uplink_loc = P
- if(UPLINK_PEN)
- uplink_loc = P
- if(UPLINK_IMPLANT)
- implant = TRUE
- if(!uplink_loc) // We've looked everywhere, let's just implant you
- implant = TRUE
+ if(uplink_spawn_location != UPLINK_IMPLANT)
+ uplink_loc = traitor_mob.get_uplink_location(uplink_spawn_location)
+ if(istype(uplink_loc, /obj/item/radio) && cant_speak)
+ uplink_loc = null
- if(implant)
+ if(isnull(uplink_loc))
var/obj/item/implant/uplink/starting/new_implant = new(traitor_mob)
new_implant.implant(traitor_mob, null, silent = TRUE)
if(!silent)
@@ -179,22 +177,27 @@
new_uplink.uplink_handler.owner = traitor_mob.mind
new_uplink.uplink_handler.assigned_role = traitor_mob.mind.assigned_role.title
new_uplink.uplink_handler.assigned_species = traitor_mob.dna.species.id
- if(uplink_loc == R)
- unlock_text = "Your Uplink is cunningly disguised as your [R.name]. Simply speak \"[new_uplink.unlock_code]\" into frequency [RADIO_TOKEN_UPLINK] to unlock its hidden features."
- add_memory(/datum/memory/key/traitor_uplink, uplink_loc = R.name, uplink_code = new_uplink.unlock_code)
- else if(uplink_loc == PDA)
- unlock_text = "Your Uplink is cunningly disguised as your [PDA.name]. Simply enter the code \"[new_uplink.unlock_code]\" into the ring tone selection to unlock its hidden features."
+
+ unlock_text = "Your Uplink is cunningly disguised as your [uplink_loc.name]. "
+ if(istype(uplink_loc, /obj/item/modular_computer/pda))
+ unlock_text += "Simply enter the code \"[new_uplink.unlock_code]\" into the ring tone selection to unlock its hidden features."
add_memory(/datum/memory/key/traitor_uplink, uplink_loc = "PDA", uplink_code = new_uplink.unlock_code)
- else if(uplink_loc == P)
+
+ else if(istype(uplink_loc, /obj/item/radio))
+ unlock_text += "Simply speak \"[new_uplink.unlock_code]\" into frequency [RADIO_TOKEN_UPLINK] to unlock its hidden features."
+ add_memory(/datum/memory/key/traitor_uplink, uplink_loc = uplink_loc.name, uplink_code = new_uplink.unlock_code)
+
+ else if(istype(uplink_loc, /obj/item/pen))
var/instructions = english_list(new_uplink.unlock_code)
- unlock_text = "Your Uplink is cunningly disguised as your [P.name]. Simply twist the top of the pen [instructions] from its starting position to unlock its hidden features."
- add_memory(/datum/memory/key/traitor_uplink, uplink_loc = "PDA pen", uplink_code = instructions)
+ unlock_text += "Simply twist the top of the pen [instructions] from its starting position to unlock its hidden features."
+ add_memory(/datum/memory/key/traitor_uplink, uplink_loc = uplink_loc.name, uplink_code = instructions)
new_uplink.unlock_text = unlock_text
if(!silent)
to_chat(traitor_mob, span_boldnotice(unlock_text))
if(antag_datum)
antag_datum.antag_memory += new_uplink.unlock_note + "
"
+ return .
/// Link a new mobs mind to the creator of said mob. They will join any team they are currently on, and will only switch teams when their creator does.
/datum/mind/proc/enslave_mind_to_creator(mob/living/creator)
diff --git a/code/game/gamemodes/objective_items.dm b/code/game/gamemodes/objective_items.dm
index 2240f220e48..46b8ea1640d 100644
--- a/code/game/gamemodes/objective_items.dm
+++ b/code/game/gamemodes/objective_items.dm
@@ -22,9 +22,23 @@
var/objective_type = OBJECTIVE_ITEM_TYPE_NORMAL
/// Whether this item exists on the station map at the start of a round.
var/exists_on_map = FALSE
+ /**
+ * How hard it is to steal this item given normal circumstances, ranked on a scale of 1 to 5.
+ *
+ * 1 - Probably found in a public area
+ * 2 - Likely on someone's person, or in a less-than-public but otherwise unguarded area
+ * 3 - Usually on someone's person, or in a locked locker or otherwise secure area
+ * 4 - Always on someone's person, or in a secure area
+ * 5 - You know it when you see it. Things like the Nuke Disc which have a pointer to it at all times.
+ *
+ * Also accepts 0 as "extremely easy to steal" and >5 as "almost impossible to steal"
+ */
+ var/difficulty = 0
+ /// A hint explaining how one may find the target item.
+ var/steal_hint = "The clown might have one."
/// For objectives with special checks (does that intellicard have an ai in it? etcetc)
-/datum/objective_item/proc/check_special_completion()
+/datum/objective_item/proc/check_special_completion(obj/item/thing)
return TRUE
/// Takes a list of minds and returns true if this is a valid objective to give to a team of these minds
@@ -72,6 +86,8 @@
excludefromjob = list(JOB_BARTENDER)
item_owner = list(JOB_BARTENDER)
exists_on_map = TRUE
+ difficulty = 2
+ steal_hint = "A double-barrel shotgun usually found on the bartender's person, or if none are around, in the bar's backroom."
/obj/item/gun/ballistic/shotgun/doublebarrel/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/gun/ballistic/shotgun/doublebarrel)
@@ -91,6 +107,9 @@
JOB_STATION_ENGINEER,
)
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "Only two of these exist on the station - one in the bridge, and one in atmospherics. \
+ You can use a multitool to hack open the case, or break it open the hard way."
/obj/item/fireaxe/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/fireaxe)
@@ -105,6 +124,8 @@
)
item_owner = list(JOB_ROBOTICIST)
exists_on_map = TRUE
+ difficulty = 2
+ steal_hint = "A specialized tool found in the roboticist's lab. You can use a multitool to hack open the case, or break it open the hard way."
/obj/item/crowbar/mechremoval/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/crowbar/mechremoval)
@@ -115,6 +136,9 @@
excludefromjob = list(JOB_CHAPLAIN)
item_owner = list(JOB_CHAPLAIN)
exists_on_map = TRUE
+ difficulty = 2
+ steal_hint = "A holy artifact usually found on the chaplain's person, or if none are around, in the chapel's relic closet. \
+ If there is a chaplain aboard, it is likely be to be transformed into some holy weapon - some of which are... difficult to remove from their person."
/obj/item/nullrod/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/nullrod)
@@ -125,6 +149,8 @@
excludefromjob = list(JOB_CLOWN, JOB_CARGO_TECHNICIAN, JOB_QUARTERMASTER)
item_owner = list(JOB_CLOWN)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "The clown's huge, bright shoes. They should always be on the clown's feet."
/obj/item/clothing/shoes/clown_shoes/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/clothing/shoes/clown_shoes)
@@ -135,6 +161,8 @@
excludefromjob = list(JOB_MIME, JOB_CARGO_TECHNICIAN, JOB_QUARTERMASTER)
item_owner = list(JOB_MIME)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "The mime's mask. It should always be on the mime's face."
/obj/item/clothing/mask/gas/mime/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/clothing/mask/gas/mime)
@@ -145,6 +173,9 @@
excludefromjob = list(JOB_SHAFT_MINER, JOB_CARGO_TECHNICIAN, JOB_QUARTERMASTER)
item_owner = list(JOB_SHAFT_MINER)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "A tool primarily used by shaft miners to mine. Most carry one (or multiple) on their person, \
+ but they can also be found in the Mining Station, Mining office, or Auxiliary Mining Base on the station."
/obj/item/gun/energy/recharge/kinetic_accelerator/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/gun/energy/recharge/kinetic_accelerator)
@@ -155,6 +186,8 @@
excludefromjob = list(JOB_COOK, JOB_HEAD_OF_PERSONNEL, JOB_CARGO_TECHNICIAN, JOB_QUARTERMASTER)
item_owner = list(JOB_COOK)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "The chef's fake Italian moustache, either found on their face or in the garbage, depending on who's on duty."
/obj/item/clothing/mask/fakemoustache/italian/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/clothing/mask/fakemoustache/italian)
@@ -164,6 +197,9 @@
targetitem = /obj/item/gun/ballistic/revolver/c38/detective
excludefromjob = list(JOB_DETECTIVE)
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "A .38 special revolver found in the Detective's holder. \
+ Usually found on the Detective's person, or if none are around, in the detective's locker, in their office."
/obj/item/gun/ballistic/revolver/c38/detective/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/gun/ballistic/revolver/c38/detective)
@@ -174,6 +210,8 @@
excludefromjob = list(JOB_LAWYER)
item_owner = list(JOB_LAWYER)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "The lawyer's badge. Usually pinned to their chest, but a spare can be obtained from their clothes vendor."
/obj/item/clothing/accessory/lawyers_badge/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/clothing/accessory/lawyers_badge)
@@ -183,6 +221,8 @@
targetitem = /obj/item/storage/belt/utility/chief
excludefromjob = list(JOB_CHIEF_ENGINEER)
exists_on_map = TRUE
+ difficulty = 2
+ steal_hint = "The chief engineer's toolbelt, strapped to their waist at all times."
/obj/item/storage/belt/utility/chief/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/storage/belt/utility/chief)
@@ -199,6 +239,8 @@
JOB_CHIEF_MEDICAL_OFFICER
)
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "A self-defense weapon standard-issue for all heads of staffs barring the Head of Security. Rarely found off of their person."
/obj/item/melee/baton/telescopic/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/melee/baton/telescopic)
@@ -209,6 +251,9 @@
excludefromjob = list(JOB_QUARTERMASTER, JOB_CARGO_TECHNICIAN)
item_owner = list(JOB_QUARTERMASTER)
exists_on_map = TRUE
+ difficulty = 2
+ steal_hint = "A card that grants access to Cargo's funds. \
+ Normally found in the locker of the Quartermaster, but a particularly keen one may have it on their person or in their wallet."
/obj/item/card/id/departmental_budget/car/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/card/id/departmental_budget/car)
@@ -218,6 +263,9 @@
targetitem = /obj/item/mod/control/pre_equipped/magnate
excludefromjob = list(JOB_CAPTAIN)
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "An expensive, hand-crafted MOD unit made for the station's Captain. \
+ If not being worn by the Captain, you would find it in the Suit Storage Unit in their quarters."
/obj/item/mod/control/pre_equipped/magnate/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/mod/control/pre_equipped/magnate)
@@ -234,6 +282,10 @@
JOB_CHIEF_MEDICAL_OFFICER
)
exists_on_map = TRUE
+ difficulty = 4
+ steal_hint = "The spare ID of the High Lord himself. \
+ If there's no official Captain around, you may find it pinned to the chest of the Acting Captain - one of the Heads of Staff. \
+ Otherwise, you'll have to bust open the golden safe on the bridge with acid or explosives to get to it."
/obj/item/card/id/advanced/gold/captains_spare/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/card/id/advanced/gold/captains_spare)
@@ -246,6 +298,9 @@
targetitem = /obj/item/gun/energy/laser/captain
excludefromjob = list(JOB_CAPTAIN)
exists_on_map = TRUE
+ difficulty = 4
+ steal_hint = "A self-charging laser gun found in a display case in the Captain's Quarters. \
+ Breaking it open may trigger a security alert, so be careful."
/obj/item/gun/energy/laser/captain/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/gun/energy/laser/captain)
@@ -256,6 +311,9 @@
excludefromjob = list(JOB_HEAD_OF_SECURITY)
item_owner = list(JOB_HEAD_OF_SECURITY)
exists_on_map = TRUE
+ difficulty = 4
+ steal_hint = "The Head of Security's unique three mode laser gun. \
+ Always found on their person, if they are alive, but may otherwise be found in their locker."
/obj/item/gun/energy/e_gun/hos/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/gun/energy/e_gun/hos)
@@ -266,6 +324,8 @@
excludefromjob = list(JOB_HEAD_OF_SECURITY)
item_owner = list(JOB_HEAD_OF_SECURITY)
exists_on_map = TRUE
+ difficulty = 4
+ steal_hint = "A miniaturized combat shotgun. May be found in Head of Security's locker or strapped to their back."
/obj/item/gun/ballistic/shotgun/automatic/combat/compact/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/gun/ballistic/shotgun/automatic/combat/compact)
@@ -276,6 +336,9 @@
excludefromjob = list(JOB_CAPTAIN, JOB_RESEARCH_DIRECTOR, JOB_HEAD_OF_PERSONNEL)
item_owner = list(JOB_CAPTAIN, JOB_RESEARCH_DIRECTOR)
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "Only two of these devices exist on the station, with one sitting in the Teleporter Room \
+ for emergencies, and the other in the Captain's Quarters for personal use."
/obj/item/hand_tele/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/hand_tele)
@@ -286,6 +349,8 @@
excludefromjob = list(JOB_CAPTAIN)
item_owner = list(JOB_CAPTAIN)
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "A special yellow jetpack found in the Suit Storage Unit in the Captain's Quarters."
/obj/item/tank/jetpack/oxygen/captain/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/tank/jetpack/oxygen/captain)
@@ -296,6 +361,9 @@
excludefromjob = list(JOB_CHIEF_ENGINEER)
item_owner = list(JOB_CHIEF_ENGINEER)
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "A pair of magnetic boots found in the Chief Engineer's Suit Storage Unit. \
+ May also be found on their person, concealed beneath their MODsuit."
/obj/item/clothing/shoes/magboots/advance/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/clothing/shoes/magboots/advance)
@@ -306,6 +374,9 @@
excludefromjob = list(JOB_CAPTAIN)
item_owner = list(JOB_CAPTAIN)
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "A gold medal found in the medal box in the Captain's Quarters. \
+ The Captain usually also has one pinned to their jumpsuit."
/obj/item/clothing/accessory/medal/gold/captain/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/clothing/accessory/medal/gold/captain)
@@ -318,6 +389,9 @@
excludefromjob = list(JOB_CHIEF_MEDICAL_OFFICER)
item_owner = list(JOB_CHIEF_MEDICAL_OFFICER)
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "The Chief Medical Officer's personal medical injector. \
+ Usually found amongst their medical supplies on their person, in their belt, or otherwise in their locker."
/obj/item/hypospray/mkii/cmo/add_stealing_item_objective() // SKYRAT EDIT CHANGE
return add_item_to_steal(src, /obj/item/hypospray/mkii/cmo) // SKYRAT EDIT CHANGE
@@ -326,6 +400,9 @@
name = "the nuclear authentication disk"
targetitem = /obj/item/disk/nuclear
excludefromjob = list(JOB_CAPTAIN)
+ difficulty = 5
+ steal_hint = "THAT disk - you know the one. Carried by the Captain at all times (hopefully). \
+ Difficult to miss, but if you can't find it, the Head of Security and Captain both have devices to track its precise location."
/obj/item/disk/nuclear/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/disk/nuclear)
@@ -339,6 +416,8 @@
excludefromjob = list(JOB_HEAD_OF_SECURITY, JOB_WARDEN)
item_owner = list(JOB_HEAD_OF_SECURITY)
exists_on_map = TRUE
+ difficulty = 4
+ steal_hint = "An ablative trechcoat found on the shelves of the Armory."
/obj/item/clothing/suit/hooded/ablative/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/clothing/suit/hooded/ablative)
@@ -349,6 +428,9 @@
excludefromjob = list(JOB_RESEARCH_DIRECTOR)
item_owner = list(JOB_RESEARCH_DIRECTOR)
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "A special suit of armor found in the possession of the Research Director. \
+ You may otherwise find it in their locker."
/obj/item/clothing/suit/armor/reactive/teleport/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/clothing/suit/armor/reactive/teleport)
@@ -358,6 +440,11 @@
valid_containers = list(/obj/item/folder)
targetitem = /obj/item/documents
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "A set of papers belonging to a megaconglomerate. \
+ Nanotrasen documents can easily be found in the station's vault. \
+ For other corporations, you may find them in strange and distant places. \
+ A photocopy may also suffice."
/obj/item/documents/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/documents) //Any set of secret documents. Doesn't have to be NT's
@@ -367,6 +454,8 @@
valid_containers = list(/obj/item/nuke_core_container)
targetitem = /obj/item/nuke_core
exists_on_map = TRUE
+ difficulty = 4
+ steal_hint = "The core of the station's self-destruct device, found in the vault."
/obj/item/nuke_core/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/nuke_core)
@@ -381,6 +470,8 @@
excludefromjob = list(JOB_RESEARCH_DIRECTOR, JOB_SCIENTIST, JOB_ROBOTICIST, JOB_GENETICIST)
item_owner = list(JOB_RESEARCH_DIRECTOR, JOB_SCIENTIST)
exists_on_map = TRUE
+ difficulty = 4
+ steal_hint = "The hard drive of the master research server, found in R&D's server room."
/obj/item/computer_disk/hdd_theft/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/computer_disk/hdd_theft)
@@ -394,6 +485,8 @@
name = "a sliver of a supermatter crystal"
targetitem = /obj/item/nuke_core/supermatter_sliver
valid_containers = list(/obj/item/nuke_core_container/supermatter)
+ difficulty = 5
+ steal_hint = "A small shard of the station's supermatter crystal engine."
/datum/objective_item/steal/supermatter/New()
special_equipment += /obj/item/storage/box/syndie_kit/supermatter
@@ -406,6 +499,8 @@
/datum/objective_item/steal/functionalai
name = "a functional AI"
targetitem = /obj/item/aicard
+ difficulty = 5
+ steal_hint = "An intellicard (or MODsuit) containing an active, functional AI."
/datum/objective_item/steal/functionalai/New()
. = ..()
@@ -439,6 +534,8 @@
item_owner = list(JOB_CHIEF_ENGINEER)
altitems = list(/obj/item/photo)
exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "The blueprints of the station, found in the Chief Engineer's locker, or on their person. A picture may suffice."
/obj/item/areaeditor/blueprints/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/areaeditor/blueprints)
@@ -457,6 +554,8 @@
targetitem = /obj/item/blackbox
excludefromjob = list(JOB_CHIEF_ENGINEER, JOB_STATION_ENGINEER, JOB_ATMOSPHERIC_TECHNICIAN)
exists_on_map = TRUE
+ difficulty = 4
+ steal_hint = "The station's data Blackbox, found solely within Telecommunications."
/obj/item/blackbox/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/blackbox)
@@ -470,6 +569,8 @@
excludefromjob = list(JOB_CARGO_TECHNICIAN, JOB_QUARTERMASTER, JOB_ATMOSPHERIC_TECHNICIAN, JOB_STATION_ENGINEER, JOB_CHIEF_ENGINEER)
item_owner = list(JOB_STATION_ENGINEER, JOB_CHIEF_ENGINEER)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "A basic pair of insulated gloves, usually worn by Assistants, Engineers, or Cargo Technicians."
/obj/item/clothing/gloves/color/yellow/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/clothing/gloves/color/yellow)
@@ -479,6 +580,8 @@
targetitem = /obj/item/toy/plush/moth
excludefromjob = list(JOB_PSYCHOLOGIST, JOB_PARAMEDIC, JOB_CHEMIST, JOB_MEDICAL_DOCTOR, JOB_VIROLOGIST, JOB_CHIEF_MEDICAL_OFFICER, JOB_CORONER)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "A moth plush toy. The Psychologist has one to help console patients."
/obj/item/toy/plush/moth/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/toy/plush/moth)
@@ -487,6 +590,8 @@
name = "cute lizard plush toy"
targetitem = /obj/item/toy/plush/lizard_plushie
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "A lizard plush toy. Often found hidden in maintenance."
/obj/item/toy/plush/lizard_plushie/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/toy/plush/lizard_plushie)
@@ -496,6 +601,8 @@
targetitem = /obj/item/stamp/denied
excludefromjob = list(JOB_CARGO_TECHNICIAN, JOB_QUARTERMASTER, JOB_SHAFT_MINER)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "Cargo often has multiple of these red stamps lying around to process paperwork."
/obj/item/stamp/denied/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/stamp/denied)
@@ -505,6 +612,8 @@
targetitem = /obj/item/stamp/granted
excludefromjob = list(JOB_CARGO_TECHNICIAN, JOB_QUARTERMASTER, JOB_SHAFT_MINER)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "Cargo often has multiple of these green stamps lying around to process paperwork."
/obj/item/stamp/granted/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/stamp/granted)
@@ -514,6 +623,9 @@
targetitem = /obj/item/book/manual/wiki/security_space_law
excludefromjob = list(JOB_SECURITY_OFFICER, JOB_WARDEN, JOB_HEAD_OF_SECURITY, JOB_LAWYER, JOB_DETECTIVE)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "Sometimes found in the possession of members of Security and Lawyers. \
+ The courtroom and the library are also good places to look."
/obj/item/book/manual/wiki/security_space_law/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/book/manual/wiki/security_space_law)
@@ -524,6 +636,8 @@
excludefromjob = list(JOB_ATMOSPHERIC_TECHNICIAN, JOB_STATION_ENGINEER, JOB_CHIEF_ENGINEER, JOB_SCIENTIST, JOB_RESEARCH_DIRECTOR, JOB_GENETICIST, JOB_ROBOTICIST)
item_owner = list(JOB_CHIEF_ENGINEER)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "A tool often used by Engineers, Atmospherics Technicians, and Ordnance Technicians."
/obj/item/pipe_dispenser/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/pipe_dispenser)
@@ -533,6 +647,254 @@
targetitem = /obj/item/storage/fancy/donut_box
excludefromjob = list(JOB_CAPTAIN, JOB_CHIEF_ENGINEER, JOB_HEAD_OF_PERSONNEL, JOB_HEAD_OF_SECURITY, JOB_QUARTERMASTER, JOB_CHIEF_MEDICAL_OFFICER, JOB_RESEARCH_DIRECTOR, JOB_SECURITY_OFFICER, JOB_WARDEN, JOB_LAWYER, JOB_DETECTIVE)
exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "Everyone has a box of donuts - you may most commonly find them on the Bridge, within Security, or in any department's break room."
/obj/item/storage/fancy/donut_box/add_stealing_item_objective()
return add_item_to_steal(src, /obj/item/storage/fancy/donut_box)
+
+/datum/objective_item/steal/spy
+ objective_type = OBJECTIVE_ITEM_TYPE_SPY
+
+/datum/objective_item/steal/spy/lamarr
+ name = "The Research Director's pet headcrab"
+ targetitem = /obj/item/clothing/mask/facehugger/lamarr
+ excludefromjob = list(JOB_RESEARCH_DIRECTOR)
+ exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "The Research Director's pet headcrab, Lamarr, found in a secure cage in their office."
+
+/obj/item/clothing/mask/facehugger/lamarr/add_stealing_item_objective()
+ return add_item_to_steal(src, /obj/item/clothing/mask/facehugger/lamarr)
+
+/datum/objective_item/steal/spy/disabler
+ name = "a disabler"
+ targetitem = /obj/item/gun/energy/disabler
+ excludefromjob = list(
+ JOB_CAPTAIN,
+ JOB_DETECTIVE,
+ JOB_HEAD_OF_PERSONNEL,
+ JOB_HEAD_OF_SECURITY,
+ JOB_SECURITY_OFFICER,
+ JOB_WARDEN,
+ )
+ difficulty = 2
+ steal_hint = "A hand-held disabler, often found in the possession of Security Officers."
+
+/datum/objective_item/steal/spy/energy_gun
+ name = "an energy gun"
+ targetitem = /obj/item/gun/energy/e_gun
+ excludefromjob = list(
+ JOB_CAPTAIN,
+ JOB_CHIEF_ENGINEER,
+ JOB_CHIEF_MEDICAL_OFFICER,
+ JOB_DETECTIVE,
+ JOB_HEAD_OF_PERSONNEL,
+ JOB_HEAD_OF_SECURITY,
+ JOB_QUARTERMASTER,
+ JOB_RESEARCH_DIRECTOR,
+ JOB_SECURITY_OFFICER,
+ JOB_WARDEN,
+ )
+ exists_on_map = TRUE
+ difficulty = 2
+ steal_hint = "A two-mode energy gun, found in the station's Armory, as well as in the hands of some heads of staff for personal defense."
+
+/datum/objective_item/steal/spy/energy_gun/check_special_completion(obj/item/thing)
+ return thing.type == /obj/item/gun/energy/e_gun
+
+/obj/item/gun/energy/e_gun/add_stealing_item_objective()
+ if(type == /obj/item/gun/energy/e_gun)
+ return add_item_to_steal(src, /obj/item/gun/energy/e_gun)
+
+/datum/objective_item/steal/spy/laser_gun
+ name = "a laser gun"
+ targetitem = /obj/item/gun/energy/laser
+ excludefromjob = list(
+ JOB_CAPTAIN,
+ JOB_CHIEF_ENGINEER,
+ JOB_CHIEF_MEDICAL_OFFICER,
+ JOB_DETECTIVE,
+ JOB_HEAD_OF_PERSONNEL,
+ JOB_HEAD_OF_SECURITY,
+ JOB_QUARTERMASTER,
+ JOB_RESEARCH_DIRECTOR,
+ JOB_SECURITY_OFFICER,
+ JOB_WARDEN,
+ )
+ exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "A simple laser gun, found in the station's Armory."
+
+/datum/objective_item/steal/spy/laser_gun/check_special_completion(obj/item/thing)
+ return thing.type == /obj/item/gun/energy/laser
+
+/obj/item/gun/energy/laser/add_stealing_item_objective()
+ if(type == /obj/item/gun/energy/laser)
+ return add_item_to_steal(src, /obj/item/gun/energy/laser)
+
+/datum/objective_item/steal/spy/shotgun
+ name = "a riot shotgun"
+ targetitem = /obj/item/gun/ballistic/shotgun/riot
+ excludefromjob = list(
+ JOB_DETECTIVE,
+ JOB_HEAD_OF_PERSONNEL,
+ JOB_HEAD_OF_SECURITY,
+ JOB_SECURITY_OFFICER,
+ JOB_WARDEN,
+ )
+ exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "A shotgun found in the station's Armory for riot suppression. Doesn't miss."
+
+/obj/item/gun/ballistic/shotgun/riot/add_stealing_item_objective()
+ return add_item_to_steal(src, /obj/item/gun/ballistic/shotgun/riot)
+
+/datum/objective_item/steal/spy/temp_gun
+ name = "security's temperature gun"
+ targetitem = /obj/item/gun/energy/temperature/security
+ excludefromjob = list(
+ JOB_DETECTIVE,
+ JOB_HEAD_OF_PERSONNEL,
+ JOB_HEAD_OF_SECURITY,
+ JOB_SECURITY_OFFICER,
+ JOB_WARDEN,
+ )
+ exists_on_map = TRUE
+ difficulty = 2 // lowered for the meme
+ steal_hint = "Security's TRUSTY temperature gun, found in the station's Armory."
+
+/obj/item/gun/energy/temperature/security/add_stealing_item_objective()
+ return add_item_to_steal(src, /obj/item/gun/energy/temperature/security)
+
+/datum/objective_item/steal/spy/stamp
+ name = "a head of staff's stamp"
+ targetitem = /obj/item/stamp/head
+ excludefromjob = list(
+ JOB_CAPTAIN,
+ JOB_CHIEF_ENGINEER,
+ JOB_CHIEF_MEDICAL_OFFICER,
+ JOB_HEAD_OF_PERSONNEL,
+ JOB_HEAD_OF_SECURITY,
+ JOB_QUARTERMASTER,
+ JOB_RESEARCH_DIRECTOR,
+ )
+ exists_on_map = TRUE
+ difficulty = 1
+ steal_hint = "A stamp owned by a head of staff, from their offices."
+
+/obj/item/stamp/head/add_stealing_item_objective()
+ return add_item_to_steal(src, /obj/item/stamp/head)
+
+/datum/objective_item/steal/spy/sunglasses
+ name = "sunglasses"
+ targetitem = /obj/item/clothing/glasses/sunglasses
+ excludefromjob = list(
+ JOB_CAPTAIN,
+ JOB_CHIEF_ENGINEER,
+ JOB_CHIEF_MEDICAL_OFFICER,
+ JOB_HEAD_OF_PERSONNEL,
+ JOB_HEAD_OF_SECURITY,
+ JOB_LAWYER,
+ JOB_QUARTERMASTER,
+ JOB_RESEARCH_DIRECTOR,
+ JOB_SECURITY_OFFICER,
+ JOB_WARDEN,
+ )
+ difficulty = 1
+ steal_hint = "A pair of sunglasses. Lawyers often have a few pairs, as do some heads of staff. \
+ You can also obtain a pair from dissassembling hudglasses."
+
+/datum/objective_item/steal/spy/ce_modsuit
+ name = "the cheif engineer's advanced MOD control unit"
+ targetitem = /obj/item/mod/control/pre_equipped/advanced
+ excludefromjob = list(JOB_CHIEF_ENGINEER)
+ exists_on_map = TRUE
+ difficulty = 2
+ steal_hint = "An advanced version of the standard Engineering MODsuit commonly worn by the Chief Engineer."
+
+/obj/item/mod/control/pre_equipped/advanced/add_stealing_item_objective()
+ return add_item_to_steal(src, /obj/item/mod/control/pre_equipped/advanced)
+
+/datum/objective_item/steal/spy/rd_modsuit
+ name = "the research director's research MOD control unit"
+ targetitem = /obj/item/mod/control/pre_equipped/research
+ excludefromjob = list(JOB_RESEARCH_DIRECTOR)
+ exists_on_map = TRUE
+ difficulty = 2
+ steal_hint = "A bulky MODsuit commonly worn by the Research Director to protect themselves from the hazards of their work."
+
+/obj/item/mod/control/pre_equipped/research/add_stealing_item_objective()
+ return add_item_to_steal(src, /obj/item/mod/control/pre_equipped/research)
+
+/datum/objective_item/steal/spy/cmo_modsuit
+ name = "the chief medical officer's rescure MOD control unit"
+ targetitem = /obj/item/mod/control/pre_equipped/rescue
+ excludefromjob = list(JOB_CHIEF_MEDICAL_OFFICER)
+ exists_on_map = TRUE
+ difficulty = 2
+ steal_hint = "A MODsuit sometimes equipped by the Chief Medical Officer to perform rescue opperations in hazardous environments."
+
+/obj/item/mod/control/pre_equipped/rescue/add_stealing_item_objective()
+ return add_item_to_steal(src, /obj/item/mod/control/pre_equipped/rescue)
+
+/datum/objective_item/steal/spy/hos_modsuit
+ name = "the head of security's safeguard MOD control unit"
+ targetitem = /obj/item/mod/control/pre_equipped/safeguard
+ excludefromjob = list(JOB_HEAD_OF_SECURITY)
+ exists_on_map = TRUE
+ difficulty = 2
+ steal_hint = "An advanced MODsuit sometimes worn by the Head of Security when needing to detain hostiles invading the station."
+
+/obj/item/mod/control/pre_equipped/safeguard/add_stealing_item_objective()
+ return add_item_to_steal(src, /obj/item/mod/control/pre_equipped/safeguard)
+
+/datum/objective_item/steal/spy/stun_baton
+ name = "a stun baton"
+ targetitem = /obj/item/melee/baton/security
+ excludefromjob = list(
+ JOB_CAPTAIN,
+ JOB_DETECTIVE,
+ JOB_HEAD_OF_PERSONNEL,
+ JOB_HEAD_OF_SECURITY,
+ JOB_SECURITY_OFFICER,
+ JOB_WARDEN,
+ )
+ difficulty = 2
+ steal_hint = "Steal any stun baton from Security."
+
+/datum/objective_item/steal/spy/stun_baton/check_special_completion(obj/item/thing)
+ return !istype(thing, /obj/item/melee/baton/security/cattleprod)
+
+/datum/objective_item/steal/spy/det_baton
+ name = "the detective's baton"
+ targetitem = /obj/item/melee/baton
+ excludefromjob = list(
+ JOB_CAPTAIN,
+ JOB_DETECTIVE,
+ JOB_HEAD_OF_PERSONNEL,
+ JOB_HEAD_OF_SECURITY,
+ JOB_SECURITY_OFFICER,
+ JOB_WARDEN,
+ )
+ exists_on_map = TRUE
+ difficulty = 2
+ steal_hint = "The detective's old wooden truncheon, commonly found on their person for self defense."
+
+/datum/objective_item/steal/spy/det_baton/check_special_completion(obj/item/thing)
+ return thing.type == /obj/item/melee/baton
+
+/obj/item/melee/baton/add_stealing_item_objective()
+ if(type == /obj/item/melee/baton)
+ return add_item_to_steal(src, /obj/item/melee/baton)
+
+/datum/objective_item/steal/spy/captain_sabre_sheathe
+ name = "the captain's sabre sheathe"
+ targetitem = /obj/item/storage/belt/sabre
+ excludefromjob = list(JOB_CAPTAIN)
+ exists_on_map = TRUE
+ difficulty = 3
+ steal_hint = "The sheathe for the captain's sabre, found in their closet or strapped to their waist at all times."
+
+/obj/item/storage/belt/sabre/add_stealing_item_objective()
+ return add_item_to_steal(src, /obj/item/storage/belt/sabre)
diff --git a/code/game/objects/items/devices/traitordevices.dm b/code/game/objects/items/devices/traitordevices.dm
index 3515e7f52c3..8e8f2578fa4 100644
--- a/code/game/objects/items/devices/traitordevices.dm
+++ b/code/game/objects/items/devices/traitordevices.dm
@@ -202,9 +202,104 @@ effective or pretty fucking useless.
target = round(target)
wavelength = clamp(target, 0, 120)
+/datum/action/item_action/stealth_mode
+ name = "Toggle Stealth"
+ desc = "Makes you invisible to the naked eye."
+ button_icon = 'icons/mob/actions/actions_minor_antag.dmi'
+ button_icon_state = "ninja_cloak"
+ /// Whether stealth is active or not
+ var/stealth_engaged = FALSE
+ /// The amount of time the stealth mode can be active for, drains to 0 when active
+ var/charge = 30 SECONDS
+ /// The maximum amount of time the stealth mode can be active for
+ var/max_charge = 30 SECONDS
+ /// The minimum alpha value for the stealth mode
+ var/min_alpha = 0
+ /// Whether the stealth mode recharges while active
+ /// if TRUE standing in darkness will recharge even while active
+ /// if FALSE it will not uncharge, but not recharge while in darkness
+ var/recharge_while_active = TRUE
+
+/datum/action/item_action/stealth_mode/is_action_active(atom/movable/screen/movable/action_button/current_button)
+ return stealth_engaged
+
+/datum/action/item_action/stealth_mode/Grant(mob/grant_to)
+ . = ..()
+ START_PROCESSING(SSobj, src)
+ build_all_button_icons(UPDATE_BUTTON_STATUS)
+
+/datum/action/item_action/stealth_mode/Remove(mob/remove_from)
+ if(!isnull(owner) && stealth_engaged)
+ stealth_off()
+ STOP_PROCESSING(SSobj, src)
+ return ..()
+
+/datum/action/item_action/stealth_mode/Trigger(trigger_flags)
+ . = ..()
+ if(!.)
+ return
+
+ if(stealth_engaged)
+ stealth_off()
+ else
+ stealth_on()
+
+/datum/action/item_action/stealth_mode/proc/stealth_on()
+ animate(owner, alpha = get_alpha(), time = 0.5 SECONDS)
+ apply_wibbly_filters(owner)
+ stealth_engaged = TRUE
+ build_all_button_icons(UPDATE_BUTTON_STATUS|UPDATE_BUTTON_BACKGROUND)
+ owner.balloon_alert(owner, "stealth mode engaged")
+
+/datum/action/item_action/stealth_mode/proc/stealth_off()
+ owner.alpha = initial(owner.alpha)
+ remove_wibbly_filters(owner)
+ stealth_engaged = FALSE
+ build_all_button_icons(UPDATE_BUTTON_STATUS|UPDATE_BUTTON_BACKGROUND)
+ owner.balloon_alert(owner, "stealth mode disengaged")
+
+/datum/action/item_action/stealth_mode/proc/get_alpha()
+ return clamp(255 - (255 * charge / max_charge), min_alpha, 255)
+
+/datum/action/item_action/stealth_mode/process(seconds_per_tick)
+ if(!stealth_engaged)
+ // Recharge over time
+ charge = min(max_charge, charge + (max_charge * 0.04) * seconds_per_tick)
+ build_all_button_icons(UPDATE_BUTTON_STATUS)
+ return
+
+ if(charge <= 0)
+ stealth_off()
+ return
+
+ var/turf/our_turf = get_turf(owner)
+ var/lumcount = our_turf?.get_lumcount() || 0
+ if(lumcount > 0.3)
+ // Decay charge while invisible+ in the light
+ charge = max(0, charge - (max_charge * 0.05) * seconds_per_tick)
+ build_all_button_icons(UPDATE_BUTTON_STATUS)
+
+ else if(recharge_while_active)
+ // Return charage while invisible + in the darkness + recharge_while_active
+ charge = min(max_charge, charge + (max_charge * 0.1) * seconds_per_tick)
+ build_all_button_icons(UPDATE_BUTTON_STATUS)
+
+ animate(owner, alpha = get_alpha(), time = 1 SECONDS, flags = ANIMATION_PARALLEL)
+
+/datum/action/item_action/stealth_mode/update_button_status(atom/movable/screen/movable/action_button/current_button, force)
+ . = ..()
+ current_button.maptext_x = 9
+ current_button.maptext = MAPTEXT_TINY_UNICODE("[round(charge / max_charge * 100, 0.01)]%")
+
+/datum/action/item_action/stealth_mode/weaker
+ charge = 15 SECONDS
+ max_charge = 15 SECONDS
+ min_alpha = 20
+ recharge_while_active = FALSE
+
/obj/item/shadowcloak
name = "cloaker belt"
- desc = "Makes you invisible for short periods of time. Recharges in darkness."
+ desc = "Makes you invisible for short periods of time. Recharges in darkness, even while active."
icon = 'icons/obj/clothing/belts.dmi'
icon_state = "utility"
inhand_icon_state = "utility"
@@ -214,66 +309,16 @@ effective or pretty fucking useless.
slot_flags = ITEM_SLOT_BELT
attack_verb_continuous = list("whips", "lashes", "disciplines")
attack_verb_simple = list("whip", "lash", "discipline")
-
- var/mob/living/carbon/human/user = null
- var/charge = 300
- var/max_charge = 300
- var/on = FALSE
- actions_types = list(/datum/action/item_action/toggle)
-
-/obj/item/shadowcloak/ui_action_click(mob/user)
- if(user.get_item_by_slot(ITEM_SLOT_BELT) == src)
- if(!on)
- Activate(usr)
-
- else
- Deactivate()
-
- return
+ actions_types = list(/datum/action/item_action/stealth_mode)
/obj/item/shadowcloak/item_action_slot_check(slot, mob/user)
- if(slot & ITEM_SLOT_BELT)
- return 1
+ return slot & slot_flags
-/obj/item/shadowcloak/proc/Activate(mob/living/carbon/human/user)
- if(!user)
- return
-
- to_chat(user, span_notice("You activate [src]."))
- src.user = user
- START_PROCESSING(SSobj, src)
- on = TRUE
-
-/obj/item/shadowcloak/proc/Deactivate()
- to_chat(user, span_notice("You deactivate [src]."))
- STOP_PROCESSING(SSobj, src)
- if(user)
- user.alpha = initial(user.alpha)
-
- on = FALSE
- user = null
-
-/obj/item/shadowcloak/dropped(mob/user)
- ..()
- if(user && user.get_item_by_slot(ITEM_SLOT_BELT) != src)
- Deactivate()
-
-/obj/item/shadowcloak/process(seconds_per_tick)
- if(user.get_item_by_slot(ITEM_SLOT_BELT) != src)
- Deactivate()
- return
-
- var/turf/T = get_turf(src)
- if(on)
- var/lumcount = T.get_lumcount()
-
- if(lumcount > 0.3)
- charge = max(0, charge - 12.5 * seconds_per_tick)//Quick decrease in light
-
- else
- charge = min(max_charge, charge + 25 * seconds_per_tick) //Charge in the dark
-
- animate(user,alpha = clamp(255 - charge,0,255),time = 10)
+/obj/item/shadowcloak/weaker
+ name = "stealth belt"
+ desc = "Makes you nigh-invisible to the naked eye for a short period of time. \
+ Lasts indefinitely in darkness, but will not recharge unless inactive."
+ actions_types = list(/datum/action/item_action/stealth_mode/weaker)
/// Checks if a given atom is in range of a radio jammer, returns TRUE if it is.
/proc/is_within_radio_jammer_range(atom/source)
diff --git a/code/game/objects/items/storage/boxes/security_boxes.dm b/code/game/objects/items/storage/boxes/security_boxes.dm
index 8e55986fb40..459c0ab7ce2 100644
--- a/code/game/objects/items/storage/boxes/security_boxes.dm
+++ b/code/game/objects/items/storage/boxes/security_boxes.dm
@@ -174,6 +174,16 @@
for(var/i in 1 to 7)
new /obj/item/ammo_casing/shotgun/buckshot(src)
+/obj/item/storage/box/slugs
+ name = "box of shotgun shells (Lethal - Slugs)"
+ desc = "A box full of lethal shotgun slugs, designed for shotguns."
+ icon_state = "breacher_box"
+ illustration = null
+
+/obj/item/storage/box/slugs/PopulateContents()
+ for(var/i in 1 to 7)
+ new /obj/item/ammo_casing/shotgun(src)
+
/obj/item/storage/box/beanbag
name = "box of shotgun shells (Less Lethal - Beanbag)"
desc = "A box full of beanbag shotgun shells, designed for shotguns."
diff --git a/code/game/objects/items/storage/medkit.dm b/code/game/objects/items/storage/medkit.dm
index e389b990a4c..0ecd943b604 100644
--- a/code/game/objects/items/storage/medkit.dm
+++ b/code/game/objects/items/storage/medkit.dm
@@ -271,6 +271,24 @@
/obj/item/storage/pill_bottle/penacid = 1)
generate_items_inside(items_inside,src)
+/obj/item/storage/medkit/tactical_lite
+ name = "combat first aid kit"
+ icon_state = "medkit_tactical"
+ inhand_icon_state = "medkit-tactical"
+ damagetype_healed = HEAL_ALL_DAMAGE
+
+/obj/item/storage/medkit/tactical_lite/PopulateContents()
+ if(empty)
+ return
+ var/static/list/items_inside = list(
+ /obj/item/healthanalyzer/advanced = 1,
+ /obj/item/reagent_containers/hypospray/medipen/atropine = 1,
+ /obj/item/stack/medical/gauze = 1,
+ /obj/item/stack/medical/suture/medicated = 2,
+ /obj/item/stack/medical/mesh/advanced = 2,
+ )
+ generate_items_inside(items_inside, src)
+
/obj/item/storage/medkit/tactical
name = "combat medical kit"
desc = "I hope you've got insurance."
diff --git a/code/game/objects/structures/crates_lockers/closets/gimmick.dm b/code/game/objects/structures/crates_lockers/closets/gimmick.dm
index 1e7fede5842..fecacd678c7 100644
--- a/code/game/objects/structures/crates_lockers/closets/gimmick.dm
+++ b/code/game/objects/structures/crates_lockers/closets/gimmick.dm
@@ -39,7 +39,6 @@
/obj/structure/closet/gimmick/tacticool/PopulateContents()
..()
new /obj/item/clothing/glasses/eyepatch(src)
- new /obj/item/clothing/glasses/sunglasses(src)
new /obj/item/clothing/gloves/tackler/combat(src)
new /obj/item/clothing/gloves/tackler/combat(src)
new /obj/item/clothing/head/helmet/swat(src)
@@ -53,6 +52,8 @@
new /obj/item/clothing/under/syndicate/tacticool(src)
new /obj/item/clothing/under/syndicate/tacticool(src)
+/obj/structure/closet/gimmick/tacticool/populate_contents_immediate()
+ new /obj/item/clothing/glasses/sunglasses(src)
/obj/structure/closet/thunderdome
name = "\improper Thunderdome closet"
@@ -69,8 +70,6 @@
new /obj/item/clothing/suit/armor/tdome/red(src)
for(var/i in 1 to 3)
new /obj/item/melee/energy/sword/saber(src)
- for(var/i in 1 to 3)
- new /obj/item/gun/energy/laser(src)
for(var/i in 1 to 3)
new /obj/item/melee/baton/security/loaded(src)
for(var/i in 1 to 3)
@@ -78,6 +77,10 @@
for(var/i in 1 to 3)
new /obj/item/clothing/head/helmet/thunderdome(src)
+/obj/structure/closet/thunderdome/tdred/populate_contents_immediate()
+ for(var/i in 1 to 3)
+ new /obj/item/gun/energy/laser(src)
+
/obj/structure/closet/thunderdome/tdgreen
name = "green-team Thunderdome closet"
icon_door = "green"
@@ -88,8 +91,6 @@
new /obj/item/clothing/suit/armor/tdome/green(src)
for(var/i in 1 to 3)
new /obj/item/melee/energy/sword/saber(src)
- for(var/i in 1 to 3)
- new /obj/item/gun/energy/laser(src)
for(var/i in 1 to 3)
new /obj/item/melee/baton/security/loaded(src)
for(var/i in 1 to 3)
@@ -97,6 +98,10 @@
for(var/i in 1 to 3)
new /obj/item/clothing/head/helmet/thunderdome(src)
+/obj/structure/closet/thunderdome/tdgreen/populate_contents_immediate()
+ for(var/i in 1 to 3)
+ new /obj/item/gun/energy/laser(src)
+
/obj/structure/closet/malf/suits
desc = "It's a storage unit for operational gear."
icon_state = "syndicate"
diff --git a/code/game/objects/structures/crates_lockers/closets/secure/security.dm b/code/game/objects/structures/crates_lockers/closets/secure/security.dm
index 63c63c1664a..ad6f95f086c 100644
--- a/code/game/objects/structures/crates_lockers/closets/secure/security.dm
+++ b/code/game/objects/structures/crates_lockers/closets/secure/security.dm
@@ -16,12 +16,14 @@
new /obj/item/computer_disk/command/captain(src)
new /obj/item/radio/headset/heads/captain/alt(src)
new /obj/item/radio/headset/heads/captain(src)
- new /obj/item/storage/belt/sabre(src)
- new /obj/item/gun/energy/e_gun(src)
new /obj/item/door_remote/captain(src)
new /obj/item/storage/photo_album/captain(src)
new /obj/item/card/id/departmental_budget(src) //SKYRAT EDIT ADDITION
+/obj/structure/closet/secure_closet/captains/populate_contents_immediate()
+ new /obj/item/gun/energy/e_gun(src)
+ new /obj/item/storage/belt/sabre(src)
+
/obj/structure/closet/secure_closet/hop
name = "head of personnel's locker"
icon_state = "hop"
@@ -38,7 +40,6 @@
new /obj/item/storage/box/silver_ids(src)
new /obj/item/megaphone/command(src)
new /obj/item/assembly/flash/handheld(src)
- new /obj/item/gun/energy/e_gun(src)
new /obj/item/clothing/neck/petcollar(src)
new /obj/item/pet_carrier(src)
new /obj/item/door_remote/civilian(src)
@@ -47,6 +48,9 @@
new /obj/item/storage/lockbox/medal/hop(src)
new /obj/item/card/id/departmental_budget/srv(src) //SKYRAT EDIT ADDITION
+/obj/structure/closet/secure_closet/hop/populate_contents_immediate()
+ new /obj/item/gun/energy/e_gun(src)
+
/obj/structure/closet/secure_closet/hos
name = "head of security's locker"
icon_state = "hos"
@@ -308,6 +312,8 @@
new /obj/item/storage/box/firingpins(src)
for(var/i in 1 to 3)
new /obj/item/storage/box/rubbershot(src)
+
+/obj/structure/closet/secure_closet/armory2/populate_contents_immediate()
for(var/i in 1 to 3)
new /obj/item/gun/ballistic/shotgun/riot(src)
@@ -321,12 +327,14 @@
..()
new /obj/item/storage/box/firingpins(src)
new /obj/item/gun/energy/ionrifle(src)
+ for(var/i in 1 to 3)
+ new /obj/item/gun/energy/laser/thermal(src)
+
+/obj/structure/closet/secure_closet/armory3/populate_contents_immediate()
for(var/i in 1 to 3)
new /obj/item/gun/energy/e_gun(src)
for(var/i in 1 to 3)
new /obj/item/gun/energy/laser(src)
- for(var/i in 1 to 3)
- new /obj/item/gun/energy/laser/thermal(src)
/obj/structure/closet/secure_closet/tac
name = "armory tac locker"
diff --git a/code/modules/antagonists/spy/spy.dm b/code/modules/antagonists/spy/spy.dm
new file mode 100644
index 00000000000..e0ea7e40754
--- /dev/null
+++ b/code/modules/antagonists/spy/spy.dm
@@ -0,0 +1,212 @@
+/datum/antagonist/spy
+ name = "\improper Spy"
+ roundend_category = "spies"
+ antagpanel_category = "Spy"
+ antag_hud_name = "spy"
+ job_rank = ROLE_SPY
+ antag_moodlet = /datum/mood_event/focused
+ hijack_speed = 1
+ ui_name = "AntagInfoSpy"
+ preview_outfit = /datum/outfit/spy
+ /// Whether an uplink has been created (successfully or at all)
+ var/uplink_created = FALSE
+ /// String displayed in the antag panel pointing the spy to where their uplink is.
+ var/uplink_location
+ /// Whether we give them some random objetives to aim for.
+ var/spawn_with_objectives = TRUE
+ /// Tracks number of bounties claimed, for roundend
+ var/bounties_claimed = 0
+ /// Tracks all loot items the spy has claimed, for roundend
+ var/list/all_loot = list()
+ /// Weakref to our spy uplink
+ /// Only exists for the sole purpose of letting admins see it
+ var/datum/weakref/uplink_weakref
+
+/datum/antagonist/spy/on_gain()
+ if(!uplink_created)
+ auto_create_spy_uplink(owner.current)
+ if(spawn_with_objectives)
+ give_random_objectives()
+ . = ..()
+ SEND_SOUND(owner.current, sound('sound/ambience/antag/spy.ogg'))
+
+/datum/antagonist/spy/ui_static_data(mob/user)
+ var/list/data = ..()
+ data["uplink_location"] = uplink_location
+ return data
+
+/datum/antagonist/spy/get_admin_commands()
+ . = ..()
+ // I wanted to put this in check-antagonists but it's less conducive to that
+ .["See All Bounties (For all spies)"] = CALLBACK(src, PROC_REF(see_bounties))
+ .["Refresh Bounties (For all spies)"] = CALLBACK(src, PROC_REF(refresh_bounties))
+ .["Give Spy Uplink"] = CALLBACK(src, PROC_REF(admin_create_spy_uplink))
+ .["Bounty Handler VV"] = CALLBACK(src, PROC_REF(bounty_handler_vv))
+
+/datum/antagonist/spy/proc/see_bounties()
+ if(!check_rights(R_ADMIN|R_DEBUG))
+ return
+
+ var/datum/component/spy_uplink/uplink = uplink_weakref?.resolve()
+ if(isnull(uplink))
+ tgui_alert(usr, "No spy uplink!", "Mission Failed")
+ return
+
+ uplink.ui_interact(usr)
+
+/datum/antagonist/spy/proc/refresh_bounties()
+ if(!check_rights(R_ADMIN|R_DEBUG))
+ return
+
+ var/datum/component/spy_uplink/uplink = uplink_weakref?.resolve()
+ if(isnull(uplink))
+ tgui_alert(usr, "No spy uplink!", "Mission Failed")
+ return
+
+ uplink.handler.force_refresh()
+ tgui_alert(usr, "Bounties refreshed.", "Mission Success")
+
+/datum/antagonist/spy/proc/admin_create_spy_uplink()
+ if(!check_rights(R_ADMIN|R_DEBUG))
+ return
+
+ if(!auto_create_spy_uplink(owner.current, give_backup = FALSE))
+ tgui_alert(usr, "Failed to give [owner.current] a spy uplink - likely don't have a valid item to host it.", "Mission Failed")
+
+/datum/antagonist/spy/proc/bounty_handler_vv()
+ if(!check_rights(R_ADMIN|R_DEBUG))
+ return
+
+ var/datum/component/spy_uplink/uplink = uplink_weakref?.resolve()
+ if(isnull(uplink))
+ tgui_alert(usr, "No spy uplink!", "Mission Failed")
+ return
+
+ usr.client?.debug_variables(uplink.handler)
+
+/datum/antagonist/spy/proc/auto_create_spy_uplink(mob/living/carbon/spy, give_backup = TRUE)
+ if(!iscarbon(spy))
+ return FALSE
+
+ var/spy_uplink_loc = spy.client?.prefs?.read_preference(/datum/preference/choiced/uplink_location)
+ if(isnull(spy_uplink_loc) || spy_uplink_loc == UPLINK_IMPLANT)
+ spy_uplink_loc = pick(UPLINK_PEN, UPLINK_PDA)
+
+ var/obj/item/spy_uplink = spy.get_uplink_location(spy_uplink_loc)
+ if(isnull(spy_uplink) || !create_spy_uplink(spy, spy_uplink))
+ if(give_backup)
+ var/datum/action/backup_uplink/backup = new(src)
+ backup.Grant(spy)
+ to_chat(spy, span_boldnotice("You were unable to be supplied with an uplink, so you have been given the ability to create one yourself."))
+ return FALSE
+
+ return TRUE
+
+/datum/antagonist/spy/proc/create_spy_uplink(mob/living/carbon/spy, obj/item/spy_uplink)
+ var/datum/component/spy_uplink/uplink = spy_uplink.AddComponent(/datum/component/spy_uplink, src)
+ if(!uplink)
+ return FALSE
+
+ uplink_weakref = WEAKREF(uplink)
+ uplink_created = TRUE
+
+ if(istype(spy_uplink, /obj/item/modular_computer/pda))
+ uplink_location = "your PDA"
+
+ else if(istype(spy_uplink, /obj/item/pen))
+ if(istype(spy_uplink.loc, /obj/item/modular_computer/pda))
+ uplink_location = "your PDA's pen"
+ else
+ uplink_location = "a pen"
+
+ else if(istype(spy_uplink, /obj/item/radio))
+ uplink_location = "your radio headset"
+
+ return TRUE
+
+/datum/antagonist/spy/proc/give_random_objectives()
+ for(var/i in 1 to rand(1, 3))
+ var/datum/objective/custom/your_mission = new()
+ your_mission.owner = owner
+ your_mission.explanation_text = pick_list_replacements(SPY_OBJECTIVE_FILE, "objective_body")
+ objectives += your_mission
+
+ if(prob(10))
+ var/datum/objective/martyr/leave_no_trace = new()
+ leave_no_trace.owner = owner
+ objectives += leave_no_trace
+
+ else if(prob(3)) //3% chance on 90% chance
+ var/datum/objective/hijack/steal_the_shuttle = new()
+ steal_the_shuttle.owner = owner
+ objectives += steal_the_shuttle
+
+ else
+ var/datum/objective/escape/gtfo = new()
+ gtfo.owner = owner
+ objectives += gtfo
+
+/datum/antagonist/spy/antag_panel_data()
+ return "Bounties Claimed: [bounties_claimed]"
+
+/datum/antagonist/spy/roundend_report()
+ var/list/report = list()
+ report += printplayer(owner)
+ report += " - They completed [bounties_claimed] bounties."
+ if(bounties_claimed > 0)
+ report += " - They received the following rewards: [english_list(all_loot)]"
+ report += printobjectives(objectives)
+ return report.Join("
")
+
+/datum/antagonist/spy/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/dummy = new()
+ dummy.set_haircolor(COLOR_SILVER, update = FALSE)
+ dummy.set_hairstyle("CIA", update = FALSE)
+ return finish_preview_icon(render_preview_outfit(preview_outfit, dummy))
+
+/datum/outfit/spy
+ name = "Spy (Preview only)"
+ // Balaclava sprite is ass, otherwise I would use it for this
+ uniform = /obj/item/clothing/under/suit/black
+ gloves = /obj/item/clothing/gloves/color/black
+ shoes = /obj/item/clothing/shoes/jackboots
+ head = /obj/item/clothing/head/fedora
+ suit = /obj/item/clothing/suit/jacket/trenchcoat
+ glasses = /obj/item/clothing/glasses/osi
+ ears = /obj/item/radio/headset
+
+/datum/action/backup_uplink
+ name = "Create Uplink"
+ desc = "Fashion a PDA, Pen or Radio Headset into a swanky Spy Uplink."
+ var/list/valid_types = list(
+ /obj/item/modular_computer/pda,
+ /obj/item/pen,
+ /obj/item/radio,
+ )
+
+/datum/action/backup_uplink/New(Target)
+ . = ..()
+ if(!istype(Target, /datum/antagonist/spy))
+ stack_trace("[type] created on invalid target [Target || "null"]")
+ qdel(src)
+
+/datum/action/backup_uplink/Trigger(trigger_flags)
+ . = ..()
+ if(!.)
+ return
+
+ var/mob/living/spy = usr
+ var/obj/item/held_thing = spy.get_active_held_item()
+ if(isnull(held_thing))
+ spy.balloon_alert(spy, "you need to hold something!")
+ return
+
+ if(!is_type_in_list(held_thing, valid_types))
+ held_thing.balloon_alert(spy, "invalid item!")
+ return
+
+ var/datum/antagonist/spy/spy_datum = target
+ spy_datum.create_spy_uplink(spy, held_thing)
+ held_thing.balloon_alert(spy, "uplink created")
+
+ qdel(src)
diff --git a/code/modules/antagonists/spy/spy_bounty.dm b/code/modules/antagonists/spy/spy_bounty.dm
new file mode 100644
index 00000000000..035ebba3405
--- /dev/null
+++ b/code/modules/antagonists/spy/spy_bounty.dm
@@ -0,0 +1,684 @@
+/**
+ * ## Spy Bounty
+ *
+ * A datum used to track a single spy bounty.
+ * Not a singleton - whenever bounties are re-rolled, the entire list is deleted and new bounty datums are instantiated.
+ *
+ * When bounties are completed, they are also not deleted, but instead marked as claimed.
+ */
+/datum/spy_bounty
+ /// The name of the bounty.
+ /// Should be a short description without punctuation.
+ /// IE: "Steal the captain's ID"
+ var/name
+ /// Help text for the bounty.
+ /// Should include additional information about the bounty to assist the spy in figuring out what to do.
+ /// Should be punctuated.
+ /// IE: "Steal the captain's ID. It was last seen in the captain's office."
+ var/help
+ /// Difficult of the bounty, one of [SPY_DIFFICULTY_EASY], [SPY_DIFFICULTY_MEDIUM], [SPY_DIFFICULTY_HARD].
+ /// Must be set to one of the possible bounties to be picked.
+ var/difficulty = "unset"
+ /// How long of a do-after must be completed by the Spy to turn in the bounty.
+ var/theft_time = 2 SECONDS
+ /// Probability that the stolen item will be sent to the black market instead of destroyed.
+ /// Guaranteed if the item is indestructible.
+ var/black_market_prob = 50
+ /// Weight that the bounty will be selected.
+ var/weight = 1
+
+ /// Whether the bounty's been fully initialized. If this is not set, the bounty will be rerolled.
+ VAR_FINAL/initalized = FALSE
+ /// Whether the bounty has been completed.
+ VAR_FINAL/claimed = FALSE
+ /// What uplink item the bounty will reward on completion.
+ VAR_FINAL/datum/uplink_item/reward_item
+
+/datum/spy_bounty/New(datum/spy_bounty_handler/handler)
+ if(!init_bounty(handler))
+ return
+
+ initalized = TRUE
+ select_reward(handler)
+
+/// Helper that translates the bounty into UI data for TGUI
+/datum/spy_bounty/proc/to_ui_data(mob/user)
+ SHOULD_CALL_PARENT(TRUE)
+ return list(
+ "name" = name,
+ "help" = help,
+ "difficulty" = difficulty,
+ "reward" = reward_item.name,
+ "claimed" = claimed,
+ "can_claim" = can_claim(user),
+ )
+
+/// Check if the passed mob can claim this bounty.
+/datum/spy_bounty/proc/can_claim(mob/user)
+ SHOULD_BE_PURE(TRUE)
+ return TRUE
+
+/**
+ * Initializes the bounty, setting up targets and etc.
+ *
+ * * handler - The bounty handler that is creating this bounty.
+ *
+ * Returning FALSE will cancel initialization and force it to reroll the bounty.
+ */
+/datum/spy_bounty/proc/init_bounty(datum/spy_bounty_handler/handler)
+ return FALSE
+
+/// Selects what uplink item the bounty will reward on completion.
+/datum/spy_bounty/proc/select_reward(datum/spy_bounty_handler/handler)
+ var/list/loot_pool = handler.possible_uplink_items[difficulty]
+
+ if(!length(loot_pool))
+ reward_item = /datum/uplink_item/bundles_tc/telecrystal
+ return // future todo : add some junk items for when we run out of items
+
+ reward_item = pick(loot_pool)
+ if(prob(80))
+ loot_pool -= reward_item
+
+/**
+ * Checks if the passed movable is a valid target for this bounty.
+ *
+ * * stealing - The movable to check.
+ *
+ * Returning FALSE simply means that the passed movable is not valid for this bounty.
+ */
+/datum/spy_bounty/proc/is_stealable(atom/movable/stealing)
+ // SHOULD_BE_PURE(TRUE)
+ return FALSE
+
+/**
+ * What is this bounty's "dupe protection key"?
+ * This is used to determine if a duplicate of this bounty has been rolled before / in the last refresh.
+ * You can check if a bounty has been duped by accessing the handler's claimed_bounties_from_last_pool or all_claimed_bounty_types list.
+ *
+ * * stealing - The item that was stolen.
+ * * handler - The handler that is handling the bounty.
+ *
+ * Return a string key, what this uses for dupe protection.
+ */
+/datum/spy_bounty/proc/get_dupe_protection_key(atom/movable/stealing)
+ return stealing.type
+
+/**
+ * Checks if the passed dupe key is a duplicate of an previously claimed bounty.
+ *
+ * * handler - The handler that is handling the bounty.
+ * * dupe_key - The key to check for dupes
+ * * dupe_prob - The probability of a dupe being allowed when checking all_claimed_bounty_types.
+ * This allows you to have a chance that distant dupes allowed depending on how many times they've been done.
+ *
+ * Returns TRUE if the bounty is a dupe, FALSE if it is not.
+ */
+/datum/spy_bounty/proc/check_dupe(datum/spy_bounty_handler/handler, dupe_key, dupe_prob = 0)
+ if(handler.claimed_bounties_from_last_pool[dupe_key])
+ return TRUE
+ if(prob(dupe_prob * handler.all_claimed_bounty_types[dupe_key]))
+ return TRUE
+ return FALSE
+
+/**
+ * Called when the bounty is completed, to handle how the stolen item is "stolen".
+ *
+ * By default, stolen items are simply deleted.
+ *
+ * * stealing - The item that was stolen.
+ * * spy - The spy that stole the item.
+ */
+/datum/spy_bounty/proc/clean_up_stolen_item(atom/movable/stealing, mob/living/spy)
+ do_sparks(3, FALSE, stealing)
+
+ // Don't mess with it while it's going away
+ stealing.interaction_flags_atom &= ~INTERACT_ATOM_ATTACK_HAND
+ stealing.anchored = TRUE
+ // Add some pizzazz
+ animate(stealing, time = 0.5 SECONDS, transform = matrix(stealing.transform).Scale(0.01), easing = CUBIC_EASING)
+
+ if(isitem(stealing) && ((stealing.resistance_flags & INDESTRUCTIBLE) || prob(black_market_prob)))
+ addtimer(CALLBACK(src, PROC_REF(send_to_black_market), stealing), 0.5 SECONDS)
+ else
+ QDEL_IN(stealing, 0.5 SECONDS)
+
+/**
+ * Handles putting the passed movable up on the black market.
+ *
+ * By the end of this proc, the item should either be deleted (if failure) or in nullspace (on the black market).
+ *
+ * * thing - The item to put up on the black market.
+ */
+/datum/spy_bounty/proc/send_to_black_market(atom/movable/thing)
+ if(QDELETED(thing)) // Just in case anything does anything weird
+ return FALSE
+
+ thing.interaction_flags_atom = initial(thing.interaction_flags_atom)
+ thing.anchored = initial(thing.anchored)
+ thing.moveToNullspace()
+
+ var/datum/market_item/new_item = new()
+ new_item.item = thing
+ new_item.name = "Stolen [thing.name]"
+ new_item.desc = "A [thing.name], stolen from somewhere on the station. Whoever owned it probably wouldn't be happy to see it here."
+ new_item.category = "Fenced Goods"
+ new_item.stock = 1
+ new_item.availability_prob = 100
+
+ switch(difficulty)
+ if(SPY_DIFFICULTY_EASY)
+ new_item.price = PAYCHECK_COMMAND * 2.5
+ if(SPY_DIFFICULTY_MEDIUM)
+ new_item.price = PAYCHECK_COMMAND * 5
+ if(SPY_DIFFICULTY_HARD)
+ new_item.price = PAYCHECK_COMMAND * 10
+
+ new_item.price += rand(0, PAYCHECK_COMMAND * 5)
+ if(thing.resistance_flags & INDESTRUCTIBLE)
+ new_item.price *= 2
+
+ return SSblackmarket.markets[/datum/market/blackmarket].add_item(new_item)
+
+/// Steal an item
+/datum/spy_bounty/objective_item
+ /// Reference to an objective item datum that we want stolen.
+ VAR_FINAL/datum/objective_item/desired_item
+ /// Typecache of objective items that should not be selected.
+ var/static/list/blacklisted_item_types = typecacheof(list(
+ /datum/objective_item/steal/functionalai,
+ /datum/objective_item/steal/nukedisc,
+ ))
+
+/datum/spy_bounty/objective_item/can_claim(mob/user)
+ return !(user.mind?.assigned_role.title in desired_item.excludefromjob)
+
+/datum/spy_bounty/objective_item/get_dupe_protection_key(atom/movable/stealing)
+ return desired_item.targetitem
+
+/// Determines if the passed objective item is a reasonable, valid theft target.
+/datum/spy_bounty/objective_item/proc/is_valid_objective_item(datum/objective_item/item)
+ if(length(item.special_equipment) || item.difficulty <= 0 || item.difficulty >= 6)
+ return FALSE
+ if(is_type_in_typecache(item, blacklisted_item_types))
+ return FALSE
+ if(!item.exists_on_map)
+ return TRUE
+ var/list/all_valid_existing_things = list()
+ for(var/obj/item/existing_thing as anything in GLOB.steal_item_handler.objectives_by_path[item.targetitem])
+ var/turf/thing_turf = get_turf(existing_thing)
+ if(isnull(thing_turf)) // nullspaced likely means it was stolen and is in the black market.
+ continue
+ if(!is_station_level(thing_turf.z) && !is_mining_level(thing_turf.z))
+ continue
+ all_valid_existing_things += existing_thing
+
+ if(!length(all_valid_existing_things))
+ return FALSE
+ return TRUE
+
+/datum/spy_bounty/objective_item/init_bounty(datum/spy_bounty_handler/handler)
+ var/list/valid_possible_items = list()
+ for(var/datum/objective_item/item as anything in GLOB.possible_items)
+ if(check_dupe(handler, item.targetitem, 33))
+ continue
+ if(!is_valid_objective_item(item))
+ continue
+ // Determine difficulty. Has some overlap between the categories, which is OK
+ switch(difficulty)
+ if(SPY_DIFFICULTY_EASY)
+ if(item.difficulty >= 3)
+ continue
+ if(SPY_DIFFICULTY_MEDIUM)
+ if(item.difficulty <= 2 || item.difficulty >= 5)
+ continue
+ if(SPY_DIFFICULTY_HARD)
+ if(item.difficulty <= 3)
+ continue
+
+ valid_possible_items += item
+
+ for(var/datum/spy_bounty/objective_item/existing_bounty in handler.get_all_bounties())
+ valid_possible_items -= existing_bounty.desired_item
+
+ if(!length(valid_possible_items))
+ return FALSE
+
+ desired_item = pick(valid_possible_items)
+ // We need to do some snowflake for items that do exist vs generic items
+ var/list/obj/item/existing_items = GLOB.steal_item_handler.objectives_by_path[desired_item.targetitem]
+ var/obj/item/the_item = length(existing_items) ? pick(existing_items) : desired_item.targetitem
+ var/the_item_name = istype(the_item) ? the_item.name : initial(the_item.name)
+ name = "[the_item_name] [difficulty == SPY_DIFFICULTY_HARD ? "Grand ":""]Theft"
+ help = "Steal any [the_item_name][desired_item.steal_hint ? ": [desired_item.steal_hint]" : "."]"
+ return TRUE
+
+/datum/spy_bounty/objective_item/is_stealable(atom/movable/stealing)
+ return istype(stealing, desired_item.targetitem) && desired_item.check_special_completion(stealing)
+
+/datum/spy_bounty/objective_item/random_easy
+ difficulty = SPY_DIFFICULTY_EASY
+ weight = 4 // Increased due to there being many easy options
+
+/datum/spy_bounty/objective_item/random_medium
+ difficulty = SPY_DIFFICULTY_MEDIUM
+ weight = 2 // Increased due to there being many medium options
+
+/datum/spy_bounty/objective_item/random_hard
+ difficulty = SPY_DIFFICULTY_HARD
+
+/datum/spy_bounty/machine
+ theft_time = 10 SECONDS
+
+ /// What machine (typepath) we want to steal.
+ var/obj/machinery/target_type
+ /// What area (typepath) the desired machine is in.
+ /// Can be pre-set for subtypes. If set, requires the machine to be in the location_type.
+ /// If not set, picks a random machine from all areas it can currently be found in.
+ var/area/location_type
+ /// List of weakrefs to all machines of the target type when the bounty was initialized.
+ var/list/original_options_weakrefs = list()
+
+/datum/spy_bounty/machine/Destroy()
+ original_options_weakrefs.Cut() // Just in case
+ return ..()
+
+/datum/spy_bounty/machine/get_dupe_protection_key(atom/movable/stealing)
+ return target_type
+
+/datum/spy_bounty/machine/send_to_black_market(obj/machinery/thing)
+ if(!istype(thing.circuit, /obj/item/circuitboard))
+ qdel(thing)
+ return FALSE
+
+ var/obj/item/circuitboard/selling = thing.circuit
+ var/turf/machine_turf = get_turf(thing)
+
+ // Sell the circuitboard, take the rest apart
+ // This (should) handle any mobs inside as well
+ thing.deconstruct(FALSE)
+ if(!..(selling))
+ return FALSE
+
+ // Clean up leftover parts from deconstruction
+ for(var/obj/structure/frame/leftover in machine_turf)
+ qdel(leftover)
+ break
+ for(var/obj/item/stock_parts/part in machine_turf)
+ if(prob(part.rating * 20))
+ continue
+ qdel(part)
+
+ return TRUE
+
+/datum/spy_bounty/machine/init_bounty(datum/spy_bounty_handler/handler)
+ if(isnull(target_type))
+ return FALSE
+
+ // Blacklisting maintenance in general, as well as any areas that already have a bounty in them.
+ var/list/blacklisted_areas = typecacheof(/area/station/maintenance)
+ for(var/datum/spy_bounty/machine/existing_bounty in handler.get_all_bounties())
+ blacklisted_areas[existing_bounty.location_type] = TRUE
+
+ var/list/obj/machinery/all_possible = list()
+ for(var/obj/machinery/found_machine as anything in SSmachines.get_machines_by_type_and_subtypes(target_type))
+ if(!is_station_level(found_machine.z) && !is_mining_level(found_machine.z))
+ continue
+ var/area/found_machine_area = get_area(found_machine)
+ if(is_type_in_typecache(found_machine_area, blacklisted_areas))
+ continue
+ if(!isnull(location_type) && !istype(found_machine_area, location_type))
+ continue
+ if(!(found_machine_area.area_flags & VALID_TERRITORY)) // only steal from valid station areas
+ continue
+ all_possible += found_machine
+
+ if(!length(all_possible))
+ return FALSE
+
+ var/obj/machinery/machine = pick_n_take(all_possible)
+ var/area/machine_area = get_area(machine)
+ // Tracks the picked machine, as well as any other machines in the same area
+ // (So they can be removed from the room but still count, for clever Spies)
+ original_options_weakrefs += WEAKREF(machine)
+ for(var/obj/machinery/other_machine as anything in all_possible)
+ if(get_area(other_machine) == machine_area)
+ original_options_weakrefs += WEAKREF(other_machine)
+
+ location_type = machine_area.type
+ name ||= "[machine.name] Burglary"
+ help ||= "Steal \a [machine] found in [machine_area]."
+ return TRUE
+
+/datum/spy_bounty/machine/is_stealable(atom/movable/stealing)
+ if(!istype(stealing, target_type))
+ return FALSE
+ if(WEAKREF(stealing) in original_options_weakrefs)
+ return TRUE
+ if(istype(get_area(stealing), location_type))
+ return TRUE
+ return FALSE
+
+/datum/spy_bounty/machine/random
+ /// List of all machines we can randomly draw from
+ var/list/random_options = list()
+
+/datum/spy_bounty/machine/random/init_bounty(datum/spy_bounty_handler/handler)
+ var/list/options = random_options.Copy()
+ for(var/datum/spy_bounty/machine/existing_bounty in handler.get_all_bounties())
+ options -= existing_bounty.target_type
+
+ for(var/remaining_option in options)
+ if(check_dupe(handler, remaining_option, 33))
+ options -= remaining_option
+
+ if(!length(options))
+ return FALSE
+
+ target_type = pick(options)
+ return ..()
+
+/datum/spy_bounty/machine/random/easy
+ difficulty = SPY_DIFFICULTY_EASY
+ weight = 4 // Increased due to there being many easy options
+ random_options = list(
+ /obj/machinery/computer/operating,
+ /obj/machinery/computer/order_console/mining,
+ /obj/machinery/computer/records/medical,
+ /obj/machinery/cryo_cell,
+ /obj/machinery/fax, // Completely random, wild card
+ /obj/machinery/hydroponics/constructable,
+ /obj/machinery/medical_kiosk,
+ /obj/machinery/microwave,
+ /obj/machinery/oven,
+ /obj/machinery/recharge_station,
+ /obj/machinery/vending/boozeomat,
+ /obj/machinery/vending/medical,
+ /obj/machinery/vending/wardrobe,
+ )
+
+/datum/spy_bounty/machine/random/medium
+ difficulty = SPY_DIFFICULTY_MEDIUM
+ weight = 4 // Increased due to there being many medium options
+ random_options = list(
+ /obj/machinery/chem_dispenser,
+ /obj/machinery/computer/bank_machine,
+ /obj/machinery/computer/camera_advanced/xenobio,
+ /obj/machinery/computer/cargo, // This includes request-only ones in the public lobby
+ /obj/machinery/computer/crew,
+ /obj/machinery/computer/prisoner/management,
+ /obj/machinery/computer/rdconsole,
+ /obj/machinery/computer/records/security,
+ /obj/machinery/computer/scan_consolenew,
+ /obj/machinery/computer/security, // Requires breaking into a sec checkpoint, but not too hard, many are never visited
+ /obj/machinery/dna_scannernew,
+ /obj/machinery/mecha_part_fabricator,
+ )
+
+/datum/spy_bounty/machine/engineering_emitter
+ difficulty = SPY_DIFFICULTY_MEDIUM
+ target_type = /obj/machinery/power/emitter
+ location_type = /area/station/engineering/supermatter/
+
+/datum/spy_bounty/machine/engineering_emitter/can_claim(mob/user)
+ return !(user.mind?.assigned_role.departments_bitflags & DEPARTMENT_BITFLAG_ENGINEERING)
+
+/datum/spy_bounty/machine/random/hard
+ difficulty = SPY_DIFFICULTY_HARD
+ random_options = list(
+ /obj/machinery/computer/accounting,
+ /obj/machinery/computer/communications,
+ /obj/machinery/computer/upload,
+ /obj/machinery/modular_computer/preset/id,
+ )
+
+/datum/spy_bounty/machine/random/hard/can_claim(mob/user) // These would all be too easy with command level access
+ return !(user.mind?.assigned_role.departments_bitflags & DEPARTMENT_BITFLAG_COMMAND)
+
+/datum/spy_bounty/machine/random/hard/ai_sat_teleporter
+ random_options = list(
+ /obj/machinery/teleport,
+ /obj/machinery/computer/teleporter.
+ )
+ location_type = /area/station/ai_monitored/aisat
+
+/// Subtype for a bounty that targets a specific crew member
+/datum/spy_bounty/targets_person
+ difficulty = SPY_DIFFICULTY_HARD
+ theft_time = 12 SECONDS
+ /// Weakref to the mob target of the bounty
+ VAR_FINAL/datum/weakref/target_ref
+
+/datum/spy_bounty/targets_person/get_dupe_protection_key(atom/movable/stealing)
+ // Prevents the same player from being selected twice, but if they're straight up gone, whatever
+ return REF(target_ref.resolve() || stealing)
+
+/datum/spy_bounty/targets_person/can_claim(mob/user)
+ return !IS_WEAKREF_OF(user, target_ref)
+
+/datum/spy_bounty/targets_person/init_bounty(datum/spy_bounty_handler/handler)
+ var/list/mob/possible_targets = list()
+ for(var/datum/mind/crew_mind as anything in get_crewmember_minds())
+ var/mob/living/real_target = crew_mind.current
+ // Ideally we want it to be a player, but we don't care if they DC after being selected
+ if(!istype(real_target) || isnull(GET_CLIENT(real_target)))
+ continue
+ if(check_dupe(handler, REF(real_target), 50))
+ continue
+ if(!is_valid_crewmember(real_target))
+ continue
+ possible_targets += real_target
+
+ for(var/datum/spy_bounty/targets_person/existing_bounty in handler.get_all_bounties())
+ possible_targets -= existing_bounty.target_ref.resolve()
+
+ if(!length(possible_targets))
+ return FALSE
+
+ var/mob/picked = pick(possible_targets)
+ if(target_found(picked))
+ target_ref = WEAKREF(picked)
+ return TRUE
+
+ return FALSE
+
+/**
+ * Ran on every single member of the crew to determine if they are a valid target.
+ *
+ * * crewmember - The person to check.
+ *
+ * Returning FALSE will exclude them from the list of possible targets.
+ */
+/datum/spy_bounty/targets_person/proc/is_valid_crewmember(mob/crewmember)
+ return FALSE
+
+/**
+ * Ran when a valid target is selected for the bounty.
+ *
+ * * crewmember - The person that was selected as the target.
+ *
+ * Returning FALSE will stop the bounty from being finalized, this can be used for last minute checks.
+ */
+/datum/spy_bounty/targets_person/proc/target_found(mob/crewmember)
+ return FALSE
+
+/// Subtype for a bounty that targets a specific crew member and a specific item on them
+/datum/spy_bounty/targets_person/some_item
+ /// Typepath of the item we want from the target
+ var/obj/item/desired_type
+ /// Weakref to the item that matches our desired type within the target at the time of bounty creation
+ VAR_FINAL/datum/weakref/target_original_desired_ref
+
+/datum/spy_bounty/targets_person/some_item/is_valid_crewmember(mob/living/carbon/human/crewmember)
+ return istype(crewmember) && find_desired_thing(crewmember)
+
+/datum/spy_bounty/targets_person/some_item/is_stealable(atom/movable/stealing)
+ if(IS_WEAKREF_OF(stealing, target_original_desired_ref))
+ return TRUE
+ if(IS_WEAKREF_OF(stealing, target_ref))
+ var/mob/living/carbon/human/target = stealing
+ if(!target.incapacitated(IGNORE_RESTRAINTS|IGNORE_STASIS))
+ return FALSE
+ if(find_desired_thing(target))
+ return TRUE
+ return FALSE
+
+/datum/spy_bounty/targets_person/some_item/clean_up_stolen_item(atom/movable/stealing, mob/living/spy)
+ if(IS_WEAKREF_OF(stealing, target_original_desired_ref))
+ return ..()
+
+ ASSERT(ishuman(stealing), "[type] called clean_up_stolen_item with something that isn't a human and isn't the original item.")
+
+ do_sparks(2, FALSE, stealing)
+ var/mob/living/carbon/human/stolen_from = stealing
+ var/obj/item/real_stolen_item = find_desired_thing(stealing)
+ stolen_from.Unconscious(10 SECONDS)
+ to_chat(stolen_from, span_warning("You feel something missing where your [real_stolen_item.name] once was."))
+ return ..(real_stolen_item, spy)
+
+/datum/spy_bounty/targets_person/some_item/target_found(mob/crewmember)
+ var/obj/item/desired_thing = find_desired_thing(crewmember)
+ target_original_desired_ref = WEAKREF(desired_thing)
+ name = "[crewmember.real_name]'s [desired_thing.name]"
+ help = "Steal [desired_thing] from [crewmember.real_name]. \
+ You can accomplish this via brute force, or by scanning them with your uplink while they are incapacitated."
+ return TRUE
+
+/// Finds the desired item type in the target crewmember.
+/datum/spy_bounty/targets_person/some_item/proc/find_desired_thing(mob/living/carbon/human/crewmember)
+ return locate(desired_type) in crewmember.get_all_gear()
+
+// Steal someone's ID card
+/datum/spy_bounty/targets_person/some_item/id
+ desired_type = /obj/item/card/id/advanced
+
+/datum/spy_bounty/targets_person/some_item/id/find_desired_thing(mob/living/carbon/human/crewmember)
+ for(var/obj/item/card/id/advanced/id in crewmember.get_all_gear())
+ if(id.registered_account?.account_id == crewmember.account_id)
+ return id
+
+ return null
+
+/datum/spy_bounty/targets_person/some_item/id/target_found(mob/crewmember)
+ . = ..()
+ name = "[crewmember.real_name]'s ID Card"
+
+// Steal someone's PDA
+/datum/spy_bounty/targets_person/some_item/pda
+ desired_type = /obj/item/modular_computer/pda
+
+/datum/spy_bounty/targets_person/some_item/pda/find_desired_thing(mob/living/carbon/human/crewmember)
+ for(var/obj/item/modular_computer/pda/pda in crewmember.get_all_gear())
+ if(pda.saved_identification == crewmember.real_name)
+ return pda
+
+ return null
+
+/datum/spy_bounty/targets_person/some_item/pda/target_found(mob/crewmember)
+ . = ..()
+ name = "[crewmember.real_name]'s PDA"
+
+// Steal someone's heirloom
+/datum/spy_bounty/targets_person/some_item/heirloom
+ desired_type = /obj/item
+ black_market_prob = 100
+
+/datum/spy_bounty/targets_person/some_item/heirloom/find_desired_thing(mob/living/crewmember)
+ var/datum/quirk/item_quirk/family_heirloom/quirk = crewmember.get_quirk(/datum/quirk/item_quirk/family_heirloom)
+ return quirk?.heirloom?.resolve()
+
+/datum/spy_bounty/targets_person/some_item/heirloom/target_found(mob/crewmember)
+ . = ..()
+ name = "[crewmember.real_name]'s heirloom"
+
+// Steal a limb or organ off someone
+/datum/spy_bounty/targets_person/some_item/limb_or_organ
+ weight = 4 // lots to pick from here
+
+/datum/spy_bounty/targets_person/some_item/limb_or_organ/init_bounty(datum/spy_bounty_handler/handler)
+ desired_type = pick(
+ /obj/item/bodypart/arm/left,
+ /obj/item/bodypart/arm/right,
+ /obj/item/bodypart/leg/left,
+ /obj/item/bodypart/leg/right,
+ /obj/item/organ/internal/stomach,
+ /obj/item/organ/internal/appendix,
+ /obj/item/organ/internal/liver,
+ /obj/item/organ/internal/eyes,
+ )
+ return ..()
+
+/datum/spy_bounty/targets_person/some_item/limb_or_organ/find_desired_thing(mob/living/carbon/human/crewmember)
+ if(ispath(desired_type, /obj/item/bodypart))
+ return locate(desired_type) in crewmember.bodyparts
+ if(ispath(desired_type, /obj/item/organ))
+ return locate(desired_type) in crewmember.organs
+ return null
+
+/datum/spy_bounty/some_bot
+ theft_time = 10 SECONDS
+ black_market_prob = 0
+ /// What typepath of bot we want to steal.
+ var/mob/living/simple_animal/bot/bot_type
+ /// Weakref to the bot we want to steal.
+ VAR_FINAL/datum/weakref/target_bot_ref
+
+/datum/spy_bounty/some_bot/get_dupe_protection_key(atom/movable/stealing)
+ return bot_type
+
+/datum/spy_bounty/some_bot/init_bounty(datum/spy_bounty_handler/handler)
+ for(var/datum/spy_bounty/some_bot/existing_bounty in handler.get_all_bounties())
+ var/mob/living/simple_animal/bot/existing_bot_type = existing_bounty.bot_type
+ // ensures we don't get two similar bounties.
+ // may occasionally cast a wider net than we'd desire, but it's not that bad.
+ if(ispath(bot_type, initial(existing_bot_type.parent_type)))
+ return FALSE
+
+ var/list/mob/living/possible_bots = list()
+ for(var/mob/living/bot as anything in GLOB.bots_list)
+ if(!is_station_level(bot.z) && !is_mining_level(bot.z))
+ continue
+ if(!istype(bot, bot_type))
+ continue
+ possible_bots += bot
+
+ if(!length(possible_bots))
+ return FALSE
+
+ var/mob/living/picked = pick(possible_bots)
+ target_bot_ref = WEAKREF(picked)
+ name ||= "[picked.name] Abduction"
+ help ||= "Abduct the station's robot assistant [picked.name]."
+ return TRUE
+
+/datum/spy_bounty/some_bot/is_stealable(atom/movable/stealing)
+ return IS_WEAKREF_OF(stealing, target_bot_ref)
+
+/datum/spy_bounty/some_bot/beepsky
+ difficulty = SPY_DIFFICULTY_MEDIUM // gotta get him to stand still
+ bot_type = /mob/living/simple_animal/bot/secbot/beepsky/officer
+ help = "Abduct Officer Beepsky - commonly found patrolling the station. \
+ Watch out, they may not take kindly to being scanned."
+
+/datum/spy_bounty/some_bot/ofitser
+ difficulty = SPY_DIFFICULTY_EASY
+ bot_type = /mob/living/simple_animal/bot/secbot/beepsky/ofitser
+ help = "Abduct Prison Ofitser - commonly found guarding the Gulag."
+
+/datum/spy_bounty/some_bot/armsky
+ difficulty = SPY_DIFFICULTY_HARD
+ bot_type = /mob/living/simple_animal/bot/secbot/beepsky/armsky
+ help = "Abduct Sergeant-At-Armsky - commonly found guarding the station's Armory."
+
+/datum/spy_bounty/some_bot/pingsky
+ difficulty = SPY_DIFFICULTY_HARD
+ bot_type = /mob/living/simple_animal/bot/secbot/pingsky
+ help = "Abduct Officer Pingsky - commonly found protecting the station's AI."
+
+/datum/spy_bounty/some_bot/scrubbs
+ difficulty = SPY_DIFFICULTY_EASY
+ bot_type = /mob/living/basic/bot/cleanbot/medbay
+ help = "Abduct Scrubbs, MD - commonly found mopping up blood in Medbay."
+
+/datum/spy_bounty/some_bot/scrubbs/can_claim(mob/user)
+ return !(user.mind?.assigned_role.departments_bitflags & DEPARTMENT_BITFLAG_MEDICAL)
diff --git a/code/modules/antagonists/spy/spy_bounty_handler.dm b/code/modules/antagonists/spy/spy_bounty_handler.dm
new file mode 100644
index 00000000000..798719cb8a0
--- /dev/null
+++ b/code/modules/antagonists/spy/spy_bounty_handler.dm
@@ -0,0 +1,123 @@
+/**
+ * ## Spy bounty handler
+ *
+ * Singleton datum that handles determining active bounties for spies.
+ */
+/datum/spy_bounty_handler
+ /// Timer between when all bounties are refreshed.
+ var/refresh_time = 12 MINUTES
+ /// timerID of the active refresh timer.
+ var/refresh_timer
+ /// Number of times we have refreshed bounties
+ var/num_refreshes = 0
+ /// Assoc list of items stolen in the past to how many times they have been stolen
+ /// Sometimes item typepaths, sometimes REFs, in general just strings that represent stolen items
+ var/list/all_claimed_bounty_types = list()
+ /// List of all items stolen in the last pool of bounties.
+ /// Same as above - strings that represent stolen items.
+ var/list/claimed_bounties_from_last_pool = list()
+ /// Override for the number of attempts to make a bounty.
+ var/num_attempts_override = 0
+
+ /// Assoc list that dictates how much of each bounty difficulty to give out at once.
+ /// Modified by the number of times we have refreshed bounties.
+ VAR_PRIVATE/list/base_bounties_to_give = list(
+ SPY_DIFFICULTY_EASY = 4,
+ SPY_DIFFICULTY_MEDIUM = 2,
+ SPY_DIFFICULTY_HARD = 2,
+ )
+
+ /// Assoc list of all active bounties.
+ VAR_PRIVATE/list/list/bounties = list(
+ SPY_DIFFICULTY_EASY = list(),
+ SPY_DIFFICULTY_MEDIUM = list(),
+ SPY_DIFFICULTY_HARD = list(),
+ )
+
+ /// Assoc list of all possible bounties for each difficulty, weighted.
+ /// This is static, no bounty types are removed from this list.
+ VAR_PRIVATE/list/list/bounty_types = list(
+ SPY_DIFFICULTY_EASY = list(),
+ SPY_DIFFICULTY_MEDIUM = list(),
+ SPY_DIFFICULTY_HARD = list(),
+ )
+
+ /// Assoc list of all uplink items possible to be given as bounties for each difficulty.
+ /// This is not static, as bounties are complete uplink items will be removed from this list.
+ var/list/list/possible_uplink_items = list(
+ SPY_DIFFICULTY_EASY = list(),
+ SPY_DIFFICULTY_MEDIUM = list(),
+ SPY_DIFFICULTY_HARD = list(),
+ )
+
+/datum/spy_bounty_handler/New()
+ for(var/datum/spy_bounty/bounty as anything in subtypesof(/datum/spy_bounty))
+ var/weight = initial(bounty.weight)
+ var/difficulty = initial(bounty.difficulty)
+ if(weight <= 0 || !islist(bounty_types[difficulty]))
+ continue
+ bounty_types[difficulty][bounty] = weight
+
+ for(var/datum/uplink_item/item as anything in SStraitor.uplink_items)
+ if(isnull(item.item) || item.item == ABSTRACT_UPLINK_ITEM)
+ continue
+ if(!(item.purchasable_from & UPLINK_SPY))
+ continue
+ // This will have some overlap, and that's intentional -
+ // Adds some variety, rare moments where you can get a hard reward for an easier bounty (or visa versa)
+ if(item.cost <= SPY_LOWER_COST_THRESHOLD)
+ possible_uplink_items[SPY_DIFFICULTY_EASY] += item
+ if(item.cost >= SPY_LOWER_COST_THRESHOLD && item.cost <= SPY_UPPER_COST_THRESHOLD)
+ possible_uplink_items[SPY_DIFFICULTY_MEDIUM] += item
+ if(item.cost >= SPY_UPPER_COST_THRESHOLD)
+ possible_uplink_items[SPY_DIFFICULTY_HARD] += item
+
+ refresh_bounty_list()
+
+/// Helper that returns a list of all active bounties in a single list, regardless of difficulty.
+/datum/spy_bounty_handler/proc/get_all_bounties() as /list
+ var/list/all_bounties = list()
+ for(var/difficulty in bounties)
+ all_bounties += bounties[difficulty]
+
+ return all_bounties
+
+/// Refreshes all active bounties for each difficulty, no matter if they were complete or not.
+/// Then recursively calls itself via a timer.
+/datum/spy_bounty_handler/proc/refresh_bounty_list()
+ PRIVATE_PROC(TRUE)
+
+ var/list/bounties_to_give = base_bounties_to_give.Copy()
+
+ if(num_refreshes < base_bounties_to_give[SPY_DIFFICULTY_HARD])
+ bounties_to_give[SPY_DIFFICULTY_HARD] = num_refreshes
+ bounties_to_give[SPY_DIFFICULTY_MEDIUM] += (base_bounties_to_give[SPY_DIFFICULTY_HARD] - num_refreshes)
+
+ for(var/difficulty in bounties)
+ QDEL_LIST(bounties[difficulty])
+
+ var/list/pool = bounty_types[difficulty]
+ var/amount_to_give = bounties_to_give[difficulty]
+ var/failed_attempts = num_attempts_override || clamp(amount_to_give * 4, 10, 25) // more potential bounties = more attempts to make one
+ while(amount_to_give > 0 && failed_attempts > 0)
+ var/picked_bounty = pick_weight(pool)
+ var/datum/spy_bounty/bounty = new picked_bounty(src)
+ if(bounty.initalized)
+ amount_to_give -= 1
+ bounties[difficulty] += bounty
+
+ else
+ failed_attempts -= 1
+ qdel(bounty)
+
+ claimed_bounties_from_last_pool.Cut()
+ num_refreshes += 1
+ refresh_timer = addtimer(CALLBACK(src, PROC_REF(refresh_bounty_list)), refresh_time, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE)
+
+/// Forces a refresh of the bounty list.
+/// Counts towards [num_refreshes].
+/datum/spy_bounty_handler/proc/force_refresh()
+ if(refresh_timer)
+ deltimer(refresh_timer)
+
+ refresh_bounty_list()
diff --git a/code/modules/antagonists/spy/spy_uplink.dm b/code/modules/antagonists/spy/spy_uplink.dm
new file mode 100644
index 00000000000..ea6f39fc92d
--- /dev/null
+++ b/code/modules/antagonists/spy/spy_uplink.dm
@@ -0,0 +1,206 @@
+/**
+ * ## Spy uplink
+ *
+ * Applied to items similar to traitor uplinks.
+ *
+ * Used for spies to complete bounties.
+ */
+/datum/component/spy_uplink
+ /// Weakref to the spy antag datum which owns this uplink
+ var/datum/weakref/spy_ref
+ /// The handler which manages all bounties across all spies.
+ var/static/datum/spy_bounty_handler/handler
+
+/datum/component/spy_uplink/Initialize(datum/antagonist/spy/spy)
+ if(!isitem(parent))
+ return COMPONENT_INCOMPATIBLE
+
+ spy_ref = WEAKREF(spy)
+
+ if(isnull(handler))
+ handler = new()
+
+/datum/component/spy_uplink/RegisterWithParent()
+ RegisterSignal(parent, COMSIG_ATOM_EXAMINE, PROC_REF(on_examine))
+ RegisterSignal(parent, COMSIG_ITEM_ATTACK_SELF, PROC_REF(on_attack_self))
+ RegisterSignal(parent, COMSIG_ITEM_PRE_ATTACK_SECONDARY, PROC_REF(on_pre_attack_secondary))
+ RegisterSignal(parent, COMSIG_TABLET_CHECK_DETONATE, PROC_REF(block_pda_bombs))
+
+/datum/component/spy_uplink/UnregisterFromParent()
+ UnregisterSignal(parent, list(
+ COMSIG_ATOM_EXAMINE,
+ COMSIG_ITEM_ATTACK_SELF,
+ COMSIG_ITEM_PRE_ATTACK_SECONDARY,
+ COMSIG_TABLET_CHECK_DETONATE,
+ ))
+
+/// Checks that the passed mob is the owner of this uplink.
+/datum/component/spy_uplink/proc/is_our_spy(mob/whoever)
+ var/datum/antagonist/spy/spy_datum = spy_ref?.resolve()
+ return spy_datum?.owner.current == whoever
+
+/datum/component/spy_uplink/proc/on_examine(obj/item/source, mob/user, list/examine_list)
+ SIGNAL_HANDLER
+
+ if(!is_our_spy(user))
+ return
+ examine_list += span_notice("You recognize this as your spy uplink.")
+ examine_list += span_notice("- [EXAMINE_HINT("Use it in hand")] to view your bounty list.")
+ examine_list += span_notice("- [EXAMINE_HINT("Right click")] with it on a bounty target to claim it.")
+
+/datum/component/spy_uplink/proc/block_pda_bombs(obj/item/source)
+ SIGNAL_HANDLER
+
+ return COMPONENT_TABLET_NO_DETONATE
+
+/datum/component/spy_uplink/proc/on_attack_self(obj/item/source, mob/user)
+ SIGNAL_HANDLER
+
+ if(is_our_spy(user))
+ INVOKE_ASYNC(src, TYPE_PROC_REF(/datum, ui_interact), user)
+ return NONE
+
+/datum/component/spy_uplink/proc/on_pre_attack_secondary(obj/item/source, atom/target, mob/living/user, params)
+ SIGNAL_HANDLER
+
+ if(!ismovable(target))
+ return NONE
+ if(!is_our_spy(user))
+ return NONE
+ if(!try_steal(target, user))
+ return NONE
+ return COMPONENT_CANCEL_ATTACK_CHAIN
+
+/// Checks if the passed atom is something that can be stolen according to one of the active bounties.
+/// If so, starts the stealing process.
+/datum/component/spy_uplink/proc/try_steal(atom/movable/stealing, mob/living/spy)
+ for(var/datum/spy_bounty/bounty as anything in handler.get_all_bounties())
+ if(!bounty.can_claim(spy))
+ continue
+ if(!bounty.is_stealable(stealing))
+ continue
+ if(bounty.claimed)
+ stealing.balloon_alert(spy, "bounty already claimed!")
+ return TRUE
+ if(DOING_INTERACTION(spy, REF(src)))
+ spy.balloon_alert(spy, "already scanning!") // Only shown if they're trying to scan two valid targets
+ return TRUE
+ SEND_SIGNAL(stealing, COMSIG_MOVABLE_SPY_STEALING, spy, bounty)
+ INVOKE_ASYNC(src, PROC_REF(start_stealing), stealing, spy, bounty)
+ return TRUE
+
+ return FALSE
+
+/// Wraps the stealing process in a scanning effect.
+/datum/component/spy_uplink/proc/start_stealing(atom/movable/stealing, mob/living/spy, datum/spy_bounty/bounty)
+ if(!isturf(stealing.loc) && stealing.loc != spy)
+ to_chat(spy, span_warning("Your uplinks blinks red: [stealing] cannot be extracted from there."))
+ return FALSE
+
+ playsound(stealing, 'sound/items/pshoom.ogg', 33, vary = TRUE, extrarange = SILENCED_SOUND_EXTRARANGE, frequency = 0.33, ignore_walls = FALSE)
+
+ var/obj/effect/scan_effect/active_scan_effect = new(stealing.loc)
+ active_scan_effect.appearance = stealing.appearance
+ active_scan_effect.dir = stealing.dir
+ active_scan_effect.makeHologram()
+ SET_PLANE_EXPLICIT(active_scan_effect, stealing.plane, stealing)
+ active_scan_effect.layer = stealing.layer + 0.1
+
+ var/obj/effect/scan_effect/cone/active_scan_cone
+ if(isturf(stealing.loc) && isturf(spy.loc)) // Cone doesn't make sense if its being held or something
+ active_scan_cone = new(spy.loc)
+ var/angle = round(get_angle(spy, stealing), 10)
+ if(angle > 180 && angle < 360)
+ active_scan_cone.pixel_x -= 16
+ else if(angle < 180 && angle > 0)
+ active_scan_cone.pixel_x += 16
+ if(angle > 90 && angle < 270)
+ active_scan_cone.pixel_y -= 16
+ else if(angle < 90 || angle > 270)
+ active_scan_cone.pixel_y += 16
+ active_scan_cone.transform = active_scan_cone.transform.Turn(angle)
+ active_scan_cone.alpha = 0
+ animate(active_scan_cone, time = 0.5 SECONDS, alpha = initial(active_scan_cone.alpha))
+
+ . = steal_process(stealing, spy, bounty)
+ qdel(active_scan_effect)
+ qdel(active_scan_cone)
+ return .
+
+/// Attempts to steal the passed atom in accordance with the passed bounty.
+/// If successful, proceeds to complete the bounty.
+/datum/component/spy_uplink/proc/steal_process(atom/movable/stealing, mob/living/spy, datum/spy_bounty/bounty)
+ spy.visible_message(
+ span_warning("[spy] starts scanning [stealing] with a strange device..."),
+ span_notice("You start scanning [stealing], preparing it for extraction."),
+ )
+
+ if(!do_after(spy, bounty.theft_time, stealing, interaction_key = REF(src)))
+ return FALSE
+ if(bounty.claimed)
+ to_chat(spy, span_warning("Your uplinks blinks red: The bounty for [stealing] has been claimed by another spy!"))
+ return FALSE
+ if(spy.is_holding(stealing) && !spy.dropItemToGround(stealing))
+ to_chat(spy, span_warning("Your uplinks blinks red: [stealing] seems stuck to your hand!"))
+ return FALSE
+
+ var/bounty_key = bounty.get_dupe_protection_key(stealing)
+ handler.all_claimed_bounty_types[bounty_key] += 1
+ handler.claimed_bounties_from_last_pool[bounty_key] = TRUE
+
+ bounty.clean_up_stolen_item(stealing, spy, handler)
+ bounty.claimed = TRUE
+
+ var/atom/movable/reward = bounty.reward_item.spawn_item_for_generic_use(spy)
+ if(isitem(reward))
+ spy.put_in_hands(reward)
+
+ to_chat(spy, span_notice("Bounty complete! You have been rewarded with \a [reward].\
+ [reward.loc == spy ? "" : " Find it at your feet."]"))
+
+ playsound(parent, 'sound/machines/wewewew.ogg', 50, TRUE, SHORT_RANGE_SOUND_EXTRARANGE)
+
+ log_spy("[key_name(spy)] completed the bounty [bounty.name] of difficulty [bounty.difficulty] for \a [reward].")
+ SSblackbox.record_feedback("nested tally", "spy_bounty", 1, list("[stealing.type]", "[bounty.type]", "[bounty.difficulty]", "[bounty.reward_item.type]"))
+
+ var/datum/antagonist/spy/spy_datum = spy_ref?.resolve()
+ if(!isnull(spy_datum))
+ // "When" TGUI roundend is finished, a list of all bounties complete and their rewards should be put in a collapsible,
+ // otherwise it's just too much information to display cleanly. (That's why we're only displaying number and rewards)
+ spy_datum.bounties_claimed += 1
+ spy_datum.all_loot += bounty.reward_item.name
+
+ return TRUE
+
+/datum/component/spy_uplink/ui_interact(mob/user, datum/tgui/ui)
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "SpyUplink")
+ ui.open()
+
+/datum/component/spy_uplink/ui_data(mob/user)
+ var/list/data = list()
+
+ data["bounties"] = list()
+ for(var/datum/spy_bounty/bounty as anything in handler.get_all_bounties())
+ UNTYPED_LIST_ADD(data["bounties"], bounty.to_ui_data(user))
+ data["time_left"] = timeleft(handler.refresh_timer)
+
+ return data
+
+/datum/component/spy_uplink/ui_status(mob/user, datum/ui_state/state)
+ if(isobserver(user) && user.client?.holder)
+ return UI_UPDATE
+ return ..()
+
+/obj/effect/scan_effect
+ mouse_opacity = MOUSE_OPACITY_TRANSPARENT
+ anchored = TRUE
+ layer = ABOVE_ALL_MOB_LAYER
+
+/obj/effect/scan_effect/cone
+ name = "holoray"
+ icon = 'icons/effects/effects.dmi'
+ icon_state = "scan_beam"
+ color = "#3ba0ff"
+ alpha = 200
diff --git a/code/modules/cargo/markets/_market.dm b/code/modules/cargo/markets/_market.dm
index 3c264289cd2..a4af2bc981d 100644
--- a/code/modules/cargo/markets/_market.dm
+++ b/code/modules/cargo/markets/_market.dm
@@ -13,10 +13,7 @@
/// Adds item to the available items and add it's category if it is not in categories yet.
/datum/market/proc/add_item(datum/market_item/item)
- if(!prob(initial(item.availability_prob)))
- return FALSE
-
- if(ispath(item))
+ if(ispath(item, /datum/market_item))
item = new item()
if(!(item.category in categories))
diff --git a/code/modules/cargo/markets/market_item.dm b/code/modules/cargo/markets/market_item.dm
index 867facf015b..d5689c17a45 100644
--- a/code/modules/cargo/markets/market_item.dm
+++ b/code/modules/cargo/markets/market_item.dm
@@ -14,7 +14,7 @@
var/stock
/// Path to or the item itself what this entry is for, this should be set even if you override spawn_item to spawn your item.
- var/item
+ var/obj/item/item
/// Minimum price for the item if generated randomly.
var/price_min = 0
@@ -33,9 +33,18 @@
if(isnull(stock))
stock = rand(stock_min, stock_max)
+/datum/market_item/Destroy()
+ item = null
+ return ..()
+
/// Used for spawning the wanted item, override if you need to do something special with the item.
/datum/market_item/proc/spawn_item(loc)
- return new item(loc)
+ if(ismovable(item))
+ item.forceMove(loc)
+ return item
+ if(ispath(item))
+ return new item(loc)
+ CRASH("Invalid item type for market item [item || "null"]")
/// Buys the item and makes SSblackmarket handle it.
/datum/market_item/proc/buy(obj/item/market_uplink/uplink, mob/buyer, shipping_method)
diff --git a/code/modules/cargo/markets/market_telepad.dm b/code/modules/cargo/markets/market_telepad.dm
index abdad441ce5..e99e4b88d22 100644
--- a/code/modules/cargo/markets/market_telepad.dm
+++ b/code/modules/cargo/markets/market_telepad.dm
@@ -82,11 +82,7 @@
if(receiving)
var/datum/market_purchase/P = receiving
- if(!P.item || ispath(P.item))
- P.item = P.entry.spawn_item(T)
- else
- var/atom/movable/M = P.item
- M.forceMove(T)
+ P.item = P.entry.spawn_item(T)
use_power(power_usage_per_teleport / power_efficiency)
var/datum/effect_system/spark_spread/sparks = new
diff --git a/code/modules/hallucination/fake_sound.dm b/code/modules/hallucination/fake_sound.dm
index ec578f101d3..aaf8ef46823 100644
--- a/code/modules/hallucination/fake_sound.dm
+++ b/code/modules/hallucination/fake_sound.dm
@@ -173,6 +173,7 @@
'sound/ambience/antag/ling_alert.ogg',
'sound/ambience/antag/malf.ogg',
'sound/ambience/antag/ops.ogg',
+ 'sound/ambience/antag/spy.ogg',
'sound/ambience/antag/tatoralert.ogg',
)
diff --git a/code/modules/logging/categories/log_category_uplink.dm b/code/modules/logging/categories/log_category_uplink.dm
index f88d224ad3b..4ef0f1af0c0 100644
--- a/code/modules/logging/categories/log_category_uplink.dm
+++ b/code/modules/logging/categories/log_category_uplink.dm
@@ -21,3 +21,8 @@
category = LOG_CATEGORY_UPLINK_SPELL
config_flag = /datum/config_entry/flag/log_uplink
master_category = /datum/log_category/uplink
+
+/datum/log_category/uplink_spy
+ category = LOG_CATEGORY_UPLINK_SPY
+ config_flag = /datum/config_entry/flag/log_uplink
+ master_category = /datum/log_category/uplink
diff --git a/code/modules/mapfluff/ruins/lavalandruin_code/elephantgraveyard.dm b/code/modules/mapfluff/ruins/lavalandruin_code/elephantgraveyard.dm
index a4bcad87671..97a543fa7e7 100644
--- a/code/modules/mapfluff/ruins/lavalandruin_code/elephantgraveyard.dm
+++ b/code/modules/mapfluff/ruins/lavalandruin_code/elephantgraveyard.dm
@@ -203,7 +203,7 @@
new /obj/item/reagent_containers/cup/beaker(src)
new /obj/item/clothing/glasses/science(src)
if(7)
- new /obj/item/clothing/glasses/sunglasses(src)
+ new /obj/item/clothing/glasses/sunglasses/big(src)
new /obj/item/clothing/mask/cigarette/rollie(src)
else
//empty grave
diff --git a/code/modules/mob/living/simple_animal/bot/secbot.dm b/code/modules/mob/living/simple_animal/bot/secbot.dm
index 76043693b05..37722ed3bf6 100644
--- a/code/modules/mob/living/simple_animal/bot/secbot.dm
+++ b/code/modules/mob/living/simple_animal/bot/secbot.dm
@@ -73,6 +73,11 @@
desc = "It's Officer Beepsky! Powered by a potato and a shot of whiskey, and with a sturdier reinforced chassis, too."
health = 45
+/mob/living/simple_animal/bot/secbot/beepsky/officer/Initialize(mapload)
+ . = ..()
+ // Beepsky hates people scanning them
+ RegisterSignal(src, COMSIG_MOVABLE_SPY_STEALING, PROC_REF(retaliate_async))
+
/mob/living/simple_animal/bot/secbot/beepsky/ofitser
name = "Prison Ofitser"
desc = "Powered by the tears and sweat of laborers."
@@ -208,6 +213,11 @@
if("arrest_alert")
security_mode_flags ^= SECBOT_DECLARE_ARRESTS
+/mob/living/simple_animal/bot/secbot/proc/retaliate_async(datum/source, mob/user, ...)
+ SIGNAL_HANDLER
+
+ INVOKE_ASYNC(src, PROC_REF(retaliate), user)
+
/mob/living/simple_animal/bot/secbot/proc/retaliate(mob/living/carbon/human/attacking_human)
var/judgement_criteria = judgement_criteria()
threatlevel = attacking_human.assess_threat(judgement_criteria)
diff --git a/code/modules/projectiles/boxes_magazines/internal/shotgun.dm b/code/modules/projectiles/boxes_magazines/internal/shotgun.dm
index dfd99e24766..3b2489022ea 100644
--- a/code/modules/projectiles/boxes_magazines/internal/shotgun.dm
+++ b/code/modules/projectiles/boxes_magazines/internal/shotgun.dm
@@ -13,6 +13,12 @@
/obj/item/ammo_box/magazine/internal/shot/tube/fire
ammo_type = /obj/projectile/bullet/incendiary/shotgun/no_trail
+/obj/item/ammo_box/magazine/internal/shot/tube/buckshot
+ ammo_type = /obj/item/ammo_casing/shotgun/buckshot
+
+/obj/item/ammo_box/magazine/internal/shot/tube/slug
+ ammo_type = /obj/item/ammo_casing/shotgun
+
/obj/item/ammo_box/magazine/internal/shot/lethal
ammo_type = /obj/item/ammo_casing/shotgun/buckshot
diff --git a/code/modules/projectiles/guns/ballistic/shotgun.dm b/code/modules/projectiles/guns/ballistic/shotgun.dm
index 38bcfe8d2f4..37990971138 100644
--- a/code/modules/projectiles/guns/ballistic/shotgun.dm
+++ b/code/modules/projectiles/guns/ballistic/shotgun.dm
@@ -97,6 +97,10 @@
desc = "An advanced shotgun with two separate magazine tubes. This one shows signs of bounty hunting customization, meaning it likely has a dual rubber shot/fire slug load."
alt_mag_type = /obj/item/ammo_box/magazine/internal/shot/tube/fire
+/obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly
+ spawn_magazine_type = /obj/item/ammo_box/magazine/internal/shot/tube/buckshot
+ alt_mag_type = /obj/item/ammo_box/magazine/internal/shot/tube/slug
+
/obj/item/gun/ballistic/shotgun/automatic/dual_tube/examine(mob/user)
. = ..()
. += span_notice("Alt-click to pump it.")
diff --git a/code/modules/projectiles/pins.dm b/code/modules/projectiles/pins.dm
index c4b6f6fb4ce..6f80bf0e214 100644
--- a/code/modules/projectiles/pins.dm
+++ b/code/modules/projectiles/pins.dm
@@ -387,4 +387,5 @@
/obj/item/firing_pin/Destroy()
if(gun)
gun.pin = null
+ gun = null
return ..()
diff --git a/code/modules/surgery/organs/autosurgeon.dm b/code/modules/surgery/organs/autosurgeon.dm
index b577b9f8ec0..a2cf91c72f5 100644
--- a/code/modules/surgery/organs/autosurgeon.dm
+++ b/code/modules/surgery/organs/autosurgeon.dm
@@ -177,3 +177,8 @@
/obj/item/autosurgeon/syndicate/emaggedsurgerytoolset
starting_organ = /obj/item/organ/internal/cyberimp/arm/surgery/emagged
+
+/obj/item/autosurgeon/syndicate/contraband_sechud
+ desc = "Contains a contraband SecHUD implant, undetectable by health scanners."
+ uses = 1
+ starting_organ = /obj/item/organ/internal/cyberimp/eyes/hud/security/syndicate
diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm
index 22f5ef72934..2ac70b3e9bc 100644
--- a/code/modules/unit_tests/_unit_tests.dm
+++ b/code/modules/unit_tests/_unit_tests.dm
@@ -247,6 +247,7 @@
#include "spell_mindswap.dm"
#include "spell_names.dm"
#include "spell_shapeshift.dm"
+#include "spies.dm"
#include "spritesheets.dm"
#include "stack_singular_name.dm"
#include "station_trait_tests.dm"
diff --git a/code/modules/unit_tests/screenshots/screenshot_antag_icons_spy.png b/code/modules/unit_tests/screenshots/screenshot_antag_icons_spy.png
new file mode 100644
index 00000000000..103e9d60faf
Binary files /dev/null and b/code/modules/unit_tests/screenshots/screenshot_antag_icons_spy.png differ
diff --git a/code/modules/unit_tests/spies.dm b/code/modules/unit_tests/spies.dm
new file mode 100644
index 00000000000..b4b1add333c
--- /dev/null
+++ b/code/modules/unit_tests/spies.dm
@@ -0,0 +1,41 @@
+/// Tests spy bounty setup
+/datum/unit_test/spy_bounty
+
+/datum/unit_test/spy_bounty/Run()
+ var/mob/living/carbon/human/james_bond = allocate(/mob/living/carbon/human/consistent)
+ james_bond.mind_initialize()
+ james_bond.equipOutfit(/datum/outfit/job/assistant/consistent)
+ var/datum/antagonist/spy/spy = james_bond.mind.add_antag_datum(/datum/antagonist/spy)
+
+ var/datum/component/spy_uplink/uplink = spy.uplink_weakref?.resolve()
+ TEST_ASSERT_NOTNULL(uplink, "Spy failed to be given an uplink!")
+
+ var/datum/spy_bounty_handler/handler = uplink.handler
+ handler.num_attempts_override = 100
+
+ for(var/difficulty in handler.possible_uplink_items)
+ var/list/loot_pool = handler.possible_uplink_items[difficulty]
+ if(!length(loot_pool))
+ TEST_FAIL("No rewards generated for spy bounty difficulty [difficulty]")
+
+ for(var/difficulty in UNLINT(handler.bounty_types))
+ var/list/bounty_type_pool = UNLINT(handler.bounty_types[difficulty])
+ if(!length(bounty_type_pool))
+ TEST_FAIL("No bounty types for spy bounty difficulty [difficulty] found")
+
+ for(var/difficulty in UNLINT(handler.bounties))
+ var/list/generated_bounties = UNLINT(handler.bounties[difficulty])
+ if(difficulty == SPY_DIFFICULTY_HARD)
+ if(length(generated_bounties))
+ TEST_FAIL("No [difficulty] bounties should not be generated on initial refresh!")
+
+ else
+ if(!length(generated_bounties))
+ TEST_FAIL("No bounties were generated on initial refresh for difficulty [difficulty]")
+
+ handler.force_refresh()
+
+ for(var/difficulty in UNLINT(handler.bounties))
+ var/list/generated_bounties = UNLINT(handler.bounties[difficulty])
+ if(!length(generated_bounties))
+ TEST_FAIL("No bounties were generated on first refresh for difficulty [difficulty]")
diff --git a/code/modules/uplink/uplink_items.dm b/code/modules/uplink/uplink_items.dm
index 65935f077e3..bb76564e42c 100644
--- a/code/modules/uplink/uplink_items.dm
+++ b/code/modules/uplink/uplink_items.dm
@@ -149,6 +149,34 @@
SEND_SIGNAL(uplink_handler, COMSIG_ON_UPLINK_PURCHASE, spawned_item, user)
return spawned_item
+/// Used to create the uplink's item for generic use, rather than use by a Syndie specifically
+/// Can be used to "de-restrict" some items, such as Nukie guns spawning with Syndicate pins
+/datum/uplink_item/proc/spawn_item_for_generic_use(mob/user)
+ var/atom/movable/created = new item(user.loc)
+
+ if(isgun(created))
+ replace_pin(created)
+ else if(istype(created, /obj/item/storage/toolbox/guncase))
+ for(var/obj/item/gun/gun in created)
+ replace_pin(gun)
+
+ if(isobj(created))
+ var/obj/created_obj = created
+ LAZYREMOVE(created_obj.req_access, ACCESS_SYNDICATE)
+ LAZYREMOVE(created_obj.req_one_access, ACCESS_SYNDICATE)
+
+ return created
+
+/// Used by spawn_item_for_generic_use to replace the pin of a gun with a normal one
+/datum/uplink_item/proc/replace_pin(obj/item/gun/gun_reward)
+ PRIVATE_PROC(TRUE)
+
+ if(!istype(gun_reward.pin, /obj/item/firing_pin/implant/pindicate))
+ return
+
+ QDEL_NULL(gun_reward.pin)
+ gun_reward.pin = new /obj/item/firing_pin(gun_reward)
+
///For special overrides if an item can be bought or not.
/datum/uplink_item/proc/can_be_bought(datum/uplink_handler/source)
return TRUE
@@ -168,6 +196,7 @@
//Discounts (dynamically filled above)
/datum/uplink_item/discounts
category = /datum/uplink_category/discounts
+ purchasable_from = parent_type::purchasable_from & ~UPLINK_SPY // Probably not necessary but just in case
// Special equipment (Dynamically fills in uplink component)
/datum/uplink_item/special_equipment
@@ -176,6 +205,7 @@
desc = "Equipment necessary for accomplishing specific objectives. If you are seeing this, something has gone wrong."
limited_stock = 1
illegal_tech = FALSE
+ purchasable_from = parent_type::purchasable_from & ~UPLINK_SPY // Ditto
/datum/uplink_item/special_equipment/purchase(mob/user, datum/component/uplink/U)
..()
diff --git a/code/modules/uplink/uplink_items/ammunition.dm b/code/modules/uplink/uplink_items/ammunition.dm
index e8872781252..5326880d31b 100644
--- a/code/modules/uplink/uplink_items/ammunition.dm
+++ b/code/modules/uplink/uplink_items/ammunition.dm
@@ -53,5 +53,5 @@
For when you really need a lot of things dead."
item = /obj/item/ammo_box/a357
cost = 4
- purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS) //nukies get their own version
+ purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY) //nukies get their own version
illegal_tech = FALSE
diff --git a/code/modules/uplink/uplink_items/bundle.dm b/code/modules/uplink/uplink_items/bundle.dm
index f236aa4da25..b708af62b69 100644
--- a/code/modules/uplink/uplink_items/bundle.dm
+++ b/code/modules/uplink/uplink_items/bundle.dm
@@ -7,11 +7,12 @@
category = /datum/uplink_category/bundle
surplus = 0
cant_discount = TRUE
+ purchasable_from = parent_type::purchasable_from & ~UPLINK_SPY
/datum/uplink_item/bundles_tc/random
name = "Random Item"
desc = "Picking this will purchase a random item. Useful if you have some TC to spare or if you haven't decided on a strategy yet."
- item = /obj/effect/gibspawner/generic // non-tangible item because techwebs use this path to determine illegal tech
+ item = ABSTRACT_UPLINK_ITEM
cost = 0
cost_override_string = "Varies"
@@ -61,7 +62,7 @@
item = /obj/item/storage/box/syndicate/bundle_a
cost = 20
stock_key = UPLINK_SHARED_STOCK_KITS
- purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS)
+ purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY)
/datum/uplink_item/bundles_tc/bundle_b
name = "Syndi-kit Special"
@@ -72,7 +73,7 @@
item = /obj/item/storage/box/syndicate/bundle_b
cost = 20
stock_key = UPLINK_SHARED_STOCK_KITS
- purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS)
+ purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY)
/datum/uplink_item/bundles_tc/surplus
name = "Syndicate Surplus Crate"
@@ -81,7 +82,7 @@
Contents are sorted to always be worth 30 TC. The Syndicate will only provide one surplus item per agent."
item = /obj/structure/closet/crate // will be replaced in purchase()
cost = 20
- purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS)
+ purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY)
stock_key = UPLINK_SHARED_STOCK_SURPLUS
/// Value of items inside the crate in TC
var/crate_tc_value = 30
@@ -170,5 +171,5 @@
The Syndicate will only provide one surplus item per agent."
cost = 20
item = /obj/item/syndicrate_key
- purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS)
+ purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY)
stock_key = UPLINK_SHARED_STOCK_SURPLUS
diff --git a/code/modules/uplink/uplink_items/clownops.dm b/code/modules/uplink/uplink_items/clownops.dm
index bbb597d2fa8..73dd3d4c876 100644
--- a/code/modules/uplink/uplink_items/clownops.dm
+++ b/code/modules/uplink/uplink_items/clownops.dm
@@ -8,7 +8,7 @@
cost = 10
item = /obj/item/pneumatic_cannon/pie/selfcharge
surplus = 0
- purchasable_from = UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/weapon_kits/bananashield
name = "Bananium Energy Shield"
@@ -18,7 +18,7 @@
item = /obj/item/shield/energy/bananium
cost = 16
surplus = 0
- purchasable_from = UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/weapon_kits/clownsword
name = "Bananium Energy Sword"
@@ -27,7 +27,7 @@
item = /obj/item/melee/energy/sword/bananium
cost = 3
surplus = 0
- purchasable_from = UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/weapon_kits/clownoppin
name = "Ultra Hilarious Firing Pin"
@@ -51,7 +51,7 @@
item = /obj/item/gun/ballistic/automatic/c20r/toy
cost = 5
surplus = 0
- purchasable_from = UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/weapon_kits/foammachinegun
name = "Toy Machine Gun"
@@ -60,7 +60,7 @@
item = /obj/item/gun/ballistic/automatic/l6_saw/toy
cost = 10
surplus = 0
- purchasable_from = UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/explosives/bombanana
name = "Bombanana"
@@ -69,7 +69,7 @@
item = /obj/item/food/grown/banana/bombanana
cost = 4 //it is a bit cheaper than a minibomb because you have to take off your helmet to eat it, which is how you arm it
surplus = 0
- purchasable_from = UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/explosives/clown_bomb_clownops
name = "Clown Bomb"
@@ -81,7 +81,7 @@
item = /obj/item/sbeacondrop/clownbomb
cost = 15
surplus = 0
- purchasable_from = UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/explosives/clown_bomb_clownops/New()
. = ..()
@@ -94,7 +94,7 @@
item = /obj/item/grenade/chem_grenade/teargas/moustache
cost = 3
surplus = 0
- purchasable_from = UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/explosives/pinata
name = "Weapons Grade Pinata Kit"
@@ -160,4 +160,3 @@
cost = 1
purchasable_from = UPLINK_CLOWN_OPS
illegal_tech = FALSE
-
diff --git a/code/modules/uplink/uplink_items/contractor.dm b/code/modules/uplink/uplink_items/contractor.dm
index 6004caf9745..7d261410e31 100644
--- a/code/modules/uplink/uplink_items/contractor.dm
+++ b/code/modules/uplink/uplink_items/contractor.dm
@@ -13,7 +13,7 @@
item = /obj/item/storage/box/syndicate/contract_kit
category = /datum/uplink_category/contractor
cost = 20
- purchasable_from = ~(UPLINK_CLOWN_OPS | UPLINK_NUKE_OPS | UPLINK_TRAITORS)
+ purchasable_from = UPLINK_INFILTRATORS
/datum/uplink_item/bundles_tc/contract_kit/purchase(mob/user, datum/uplink_handler/uplink_handler, atom/movable/source)
. = ..()
@@ -36,7 +36,7 @@
name = "Contract Reroll"
desc = "Request a reroll of your current contract list. Will generate a new target, \
payment, and dropoff for the contracts you currently have available."
- item = /obj/effect/gibspawner/generic
+ item = ABSTRACT_UPLINK_ITEM
limited_stock = 2
cost = 0
diff --git a/code/modules/uplink/uplink_items/device_tools.dm b/code/modules/uplink/uplink_items/device_tools.dm
index c698a46761b..3ac392981cb 100644
--- a/code/modules/uplink/uplink_items/device_tools.dm
+++ b/code/modules/uplink/uplink_items/device_tools.dm
@@ -136,7 +136,7 @@
/datum/uplink_item/device_tools/failsafe
name = "Failsafe Uplink Code"
desc = "When entered the uplink will self-destruct immediately."
- item = /obj/effect/gibspawner/generic
+ item = ABSTRACT_UPLINK_ITEM
cost = 1
surplus = 0
restricted = TRUE
diff --git a/code/modules/uplink/uplink_items/implant.dm b/code/modules/uplink/uplink_items/implant.dm
index 87c9fd6c96c..a2b21574f6f 100644
--- a/code/modules/uplink/uplink_items/implant.dm
+++ b/code/modules/uplink/uplink_items/implant.dm
@@ -49,6 +49,7 @@
// An empty uplink is kinda useless.
surplus = 0
restricted = TRUE
+ purchasable_from = parent_type::purchasable_from & ~UPLINK_SPY
/datum/uplink_item/implants/uplink/spawn_item(spawn_path, mob/user, datum/uplink_handler/uplink_handler, atom/movable/source)
var/obj/item/storage/box/syndie_kit/uplink_box = ..()
diff --git a/code/modules/uplink/uplink_items/job.dm b/code/modules/uplink/uplink_items/job.dm
index 49d1eaa633c..22528937ebf 100644
--- a/code/modules/uplink/uplink_items/job.dm
+++ b/code/modules/uplink/uplink_items/job.dm
@@ -28,7 +28,7 @@
/datum/uplink_item/role_restricted/bureaucratic_error
name = "Organic Capital Disturbance Virus"
desc = "Randomizes job positions presented to new hires. May lead to too many/too few security officers and/or clowns. Single use."
- item = /obj/effect/gibspawner/generic
+ item = ABSTRACT_UPLINK_ITEM
surplus = 0
limited_stock = 1
cost = 2
@@ -286,6 +286,13 @@
restricted_roles = list(JOB_CLOWN)
surplus = 10
+/datum/uplink_item/role_restricted/clowncar/spawn_item_for_generic_use(mob/user)
+ var/obj/vehicle/sealed/car/clowncar/car = ..()
+ car.enforce_clown_role = FALSE
+ var/obj/item/key = new car.key_type(user.loc)
+ car.visible_message(span_notice("[key] drops out of [car] onto the floor."))
+ return car
+
/datum/uplink_item/role_restricted/his_grace
name = "His Grace"
desc = "An incredibly dangerous weapon recovered from a station overcome by the grey tide. Once activated, He will thirst for blood and must be used to kill to sate that thirst. \
@@ -298,6 +305,7 @@
cost = 20
surplus = 0
restricted_roles = list(JOB_CHAPLAIN)
+ purchasable_from = ~UPLINK_SPY
/datum/uplink_item/role_restricted/concealed_weapon_bay
name = "Concealed Weapon Bay"
@@ -384,3 +392,4 @@
restricted_roles = list(JOB_MIME)
restricted = TRUE
refundable = FALSE
+ purchasable_from = parent_type::purchasable_from & ~UPLINK_SPY
diff --git a/code/modules/uplink/uplink_items/nukeops.dm b/code/modules/uplink/uplink_items/nukeops.dm
index 32d99512e45..fa06ecf6714 100644
--- a/code/modules/uplink/uplink_items/nukeops.dm
+++ b/code/modules/uplink/uplink_items/nukeops.dm
@@ -76,26 +76,28 @@
name = "12g Buckshot Drum (Bulldog)"
desc = "An additional 8-round buckshot magazine for use with the Bulldog shotgun. Front towards enemy."
item = /obj/item/ammo_box/magazine/m12g
+ purchasable_from = parent_type::purchasable_from | UPLINK_SPY
/datum/uplink_item/ammo_nuclear/basic/slug
name = "12g Slug Drum (Bulldog)"
desc = "An additional 8-round slug magazine for use with the Bulldog shotgun. \
Now 8 times less likely to shoot your pals."
item = /obj/item/ammo_box/magazine/m12g/slug
+ purchasable_from = parent_type::purchasable_from | UPLINK_SPY
/datum/uplink_item/ammo_nuclear/incendiary/dragon
name = "12g Dragon's Breath Drum (Bulldog)"
desc = "An alternative 8-round dragon's breath magazine for use in the Bulldog shotgun. \
'I'm a fire starter, twisted fire starter!'"
item = /obj/item/ammo_box/magazine/m12g/dragon
- purchasable_from = UPLINK_NUKE_OPS
+ purchasable_from = parent_type::purchasable_from | UPLINK_SPY
/datum/uplink_item/ammo_nuclear/special/meteor
name = "12g Meteorslug Shells (Bulldog)"
desc = "An alternative 8-round meteorslug magazine for use in the Bulldog shotgun. \
Great for blasting holes into the hull and knocking down enemies."
item = /obj/item/ammo_box/magazine/m12g/meteor
- purchasable_from = UPLINK_NUKE_OPS
+ purchasable_from = parent_type::purchasable_from | UPLINK_SPY
// ~~ Ansem Pistol ~~
@@ -109,24 +111,28 @@
name = "10mm Handgun Magazine (Ansem)"
desc = "An additional 8-round 10mm magazine, compatible with the Ansem pistol."
item = /obj/item/ammo_box/magazine/m10mm
+ purchasable_from = parent_type::purchasable_from | UPLINK_SPY
/datum/uplink_item/ammo_nuclear/ap/m10mm
name = "10mm Armour Piercing Magazine (Ansem)"
desc = "An additional 8-round 10mm magazine, compatible with the Ansem pistol. \
These rounds are less effective at injuring the target but penetrate protective gear."
item = /obj/item/ammo_box/magazine/m10mm/ap
+ purchasable_from = parent_type::purchasable_from | UPLINK_SPY
/datum/uplink_item/ammo_nuclear/hp/m10mm
name = "10mm Hollow Point Magazine (Ansem)"
desc = "An additional 8-round 10mm magazine, compatible with the Ansem pistol. \
These rounds are more damaging but ineffective against armour."
item = /obj/item/ammo_box/magazine/m10mm/hp
+ purchasable_from = parent_type::purchasable_from | UPLINK_SPY
/datum/uplink_item/ammo_nuclear/incendiary/m10mm
name = "10mm Incendiary Magazine (Ansem)"
desc = "An additional 8-round 10mm magazine, compatible with the Ansem pistol. \
Loaded with incendiary rounds which inflict less damage, but ignite the target."
item = /obj/item/ammo_box/magazine/m10mm/fire
+ purchasable_from = parent_type::purchasable_from | UPLINK_SPY
//Medium-cost: 14 TC each. Meant for more expensive purchases with a goal in mind.
@@ -197,6 +203,7 @@
desc = "A speed loader that contains seven additional .357 Magnum rounds; usable with the Syndicate revolver. \
For when you really need a lot of things dead. Operatives get a discount from most of our agents!"
item = /obj/item/ammo_box/a357
+ purchasable_from = parent_type::purchasable_from | UPLINK_SPY
/datum/uplink_item/ammo_nuclear/special/revolver/phasic
name = ".357 Phasic Speed Loader (Revolver)"
@@ -204,6 +211,7 @@
These bullets are made from an experimental alloy, 'Ghost Lead', that allows it to pass through almost any non-organic material. \
The name is a misnomer. It doesn't contain any lead whatsoever!"
item = /obj/item/ammo_box/a357/phasic
+ purchasable_from = parent_type::purchasable_from | UPLINK_SPY
/datum/uplink_item/ammo_nuclear/special/revolver/heartseeker
name = ".357 Heartseeker Speed Loader (Revolver)"
@@ -212,6 +220,7 @@
Brought to you by Roseus Galactic!"
item = /obj/item/ammo_box/a357/heartseeker
cost = 3
+ purchasable_from = parent_type::purchasable_from | UPLINK_SPY
// ~~ Grenade Launcher ~~
// 'If god had wanted you to live, he would not have created ME!'
@@ -591,7 +600,7 @@
desc = "An upgraded, elite version of the Syndicate MODsuit. It features fireproofing, and also \
provides the user with superior armor and mobility compared to the standard Syndicate MODsuit."
item = /obj/item/mod/control/pre_equipped/elite
- purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/suits/energy_shield
name = "MODsuit Energy Shield Module"
@@ -599,28 +608,28 @@
before needing to recharge. Used wisely, this module will keep you alive for a lot longer."
item = /obj/item/mod/module/energy_shield
cost = 8
- purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/suits/emp_shield
name = "MODsuit Advanced EMP Shield Module"
desc = "An advanced EMP shield module for a MODsuit. It protects your entire body from electromagnetic pulses."
item = /obj/item/mod/module/emp_shield/advanced
cost = 5
- purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/suits/injector
name = "MODsuit Injector Module"
desc = "An injector module for a MODsuit. It is an extendable piercing injector with 30u capacity."
item = /obj/item/mod/module/injector
cost = 2
- purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/suits/holster
name = "MODsuit Holster Module"
desc = "A holster module for a MODsuit. It can stealthily store any not too heavy gun inside it."
item = /obj/item/mod/module/holster
cost = 2
- purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/device_tools/medgun_mod
name = "Medbeam Gun Module"
@@ -665,7 +674,7 @@
In its crowbar configuration, it can be used to force open airlocks. Very useful for entering the station or its departments."
item = /obj/item/crowbar/power/syndicate
cost = 4
- purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY
/datum/uplink_item/device_tools/medkit
name = "Syndicate Combat Medic Kit"
@@ -692,7 +701,7 @@
desc = "A potion recovered at great risk by undercover Syndicate operatives and then subsequently modified with Syndicate technology. \
Using it will make any animal sentient, and bound to serve you, as well as implanting an internal radio for communication and an internal ID card for opening doors."
cost = 4
- purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY
restricted = TRUE
// Implants
@@ -717,6 +726,7 @@
This will permanently destroy your body, however."
item = /obj/item/storage/box/syndie_kit/imp_microbomb
cost = 2
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_SPY
/datum/uplink_item/implants/nuclear/macrobomb
name = "Macrobomb Implant"
@@ -732,18 +742,21 @@
Prevents collapsing from critical condition, but explodes after a while."
item = /obj/item/storage/box/syndie_kit/imp_deniability
cost = 6
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_SPY
/datum/uplink_item/implants/nuclear/reviverplus
name = "Reviver Implant"
desc = "This implant will attempt to revive and heal you if you lose consciousness. Comes with an autosurgeon."
item = /obj/item/autosurgeon/syndicate/reviver
cost = 8
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_SPY
/datum/uplink_item/implants/nuclear/thermals
name = "Thermal Eyes"
desc = "These cybernetic eyes will give you thermal vision. Comes with a free autosurgeon."
item = /obj/item/autosurgeon/syndicate/thermal_eyes
cost = 8
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_SPY
/datum/uplink_item/implants/nuclear/implants/xray
name = "X-ray Vision Implant"
@@ -756,6 +769,7 @@
desc = "This implant will help you get back up on your feet faster after being stunned. Comes with an autosurgeon."
item = /obj/item/autosurgeon/syndicate/anti_stun
cost = 8
+ purchasable_from = UPLINK_NUKE_OPS | UPLINK_SPY
// Badass (meme items)
diff --git a/code/modules/uplink/uplink_items/species.dm b/code/modules/uplink/uplink_items/species.dm
index 54ba353c00a..5eb4bbdcb17 100644
--- a/code/modules/uplink/uplink_items/species.dm
+++ b/code/modules/uplink/uplink_items/species.dm
@@ -4,7 +4,7 @@
/datum/uplink_item/species_restricted
category = /datum/uplink_category/species
- purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS)
+ purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_SPY)
/datum/uplink_item/species_restricted/moth_lantern
name = "Extra-Bright Lantern"
diff --git a/code/modules/uplink/uplink_items/spy_unique.dm b/code/modules/uplink/uplink_items/spy_unique.dm
new file mode 100644
index 00000000000..2f9c4b32576
--- /dev/null
+++ b/code/modules/uplink/uplink_items/spy_unique.dm
@@ -0,0 +1,123 @@
+/datum/uplink_category/spy_unique
+ name = "Spy Unique"
+
+// This is solely for uplink items that the spy can randomly obtain via bounties.
+/datum/uplink_item/spy_unique
+ category = /datum/uplink_category/spy_unique
+ cant_discount = TRUE
+ surplus = FALSE
+ purchasable_from = UPLINK_SPY
+ // Cost doesn't really matter since it's free, but it determines which loot pool it falls into.
+ // By default, these fall into easy-medium spy bounty loot pool
+ cost = SPY_LOWER_COST_THRESHOLD
+
+/datum/uplink_item/spy_unique/syndie_bowman
+ name = "Syndicate Bowman"
+ desc = "A bowman headset for members of the Syndicate. Not very conspicuous."
+ item = /obj/item/radio/headset/syndicate/alt
+ cost = 1
+
+/datum/uplink_item/spy_unique/megaphone
+ name = "Megaphone"
+ desc = "A megaphone. It's loud."
+ item = /obj/item/megaphone
+ cost = 1
+
+/datum/uplink_item/spy_unique/combat_gloves
+ name = "Combat Gloves"
+ desc = "A pair of combat gloves. They're insulated!"
+ item = /obj/item/clothing/gloves/combat
+ cost = 1
+
+/datum/uplink_item/spy_unique/krav_maga
+ name = "Combat Gloves Plus"
+ desc = "A pair of combat gloves plus. They're insulated AND you can do martial arts with it!"
+ item = /obj/item/clothing/gloves/krav_maga/combatglovesplus
+
+/datum/uplink_item/spy_unique/tackle_gloves
+ name = "Guerrilla Gloves"
+ desc = "A pair of Guerrilla gloves. They're insulated AND you can tackle people with it!"
+ item = /obj/item/clothing/gloves/tackler/combat/insulated
+
+/datum/uplink_item/spy_unique/kudzu
+ name = "Kudzu"
+ desc = "A packet of Kudzu - plant and forget, a great distraction."
+ item = /obj/item/seeds/kudzu
+
+/datum/uplink_item/spy_unique/big_knife
+ name = "Combat Knife"
+ desc = "A big knife. It's sharp."
+ item = /obj/item/knife/combat
+
+/datum/uplink_item/spy_unique/switchblade
+ name = "Switchblade"
+ desc = "A switchblade. Switches between not sharp and sharp."
+ item = /obj/item/switchblade
+
+/datum/uplink_item/spy_unique/sechud_implant
+ name = "SecHUD Implant"
+ desc = "A SecHUD implant. Shows you the ID of people you're looking at. It's also stealthy!"
+ item = /obj/item/autosurgeon/syndicate/contraband_sechud
+
+/datum/uplink_item/spy_unique/rifle_prime
+ name = "Bolt-Action Rifle"
+ desc = "A bolt-action rifle, with a scope. Won't jam, either."
+ item = /obj/item/gun/ballistic/rifle/boltaction/prime
+ cost = SPY_UPPER_COST_THRESHOLD
+
+/datum/uplink_item/spy_unique/cycler_shotgun
+ name = "Cycler Shotgun"
+ desc = "A cycler shotgun. It's a shotgun that cycles between two barrels."
+ item = /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly
+ cost = SPY_UPPER_COST_THRESHOLD
+
+/datum/uplink_item/spy_unique/bulldog_shotgun
+ name = "Bulldog Shotgun"
+ desc = "A bulldog shotgun. It's a shotgun that shoots bulldogs."
+ item = /obj/item/gun/ballistic/shotgun/bulldog/unrestricted
+ cost = SPY_UPPER_COST_THRESHOLD
+
+/datum/uplink_item/spy_unique/ansem_pistol
+ name = "Ansem Pistol"
+ desc = "A pistol that's really good at making people sleep."
+ item = /obj/item/gun/ballistic/automatic/pistol/clandestine
+ cost = SPY_UPPER_COST_THRESHOLD
+
+/datum/uplink_item/spy_unique/rocket_launcher
+ name = "Rocket Launcher"
+ desc = "A rocket launcher. I would recommend against jumping with it."
+ item = /obj/item/gun/ballistic/rocketlauncher
+ cost = SPY_UPPER_COST_THRESHOLD - 1 // It's a meme item
+
+/datum/uplink_item/spy_unique/shotgun_ammo
+ name = "Box of Buckshot"
+ desc = "A box of buckshot rounds for a shotgun. For when you don't want to miss."
+ item = /obj/item/storage/box/lethalshot
+ cost = 1
+
+/datum/uplink_item/spy_unique/shotgun_ammo/breacher_slug
+ name = "Box of Breacher Slugs"
+ desc = "A box of breacher slugs for a shotgun. For making a good first impression."
+ item = /obj/item/storage/box/breacherslug
+
+/datum/uplink_item/spy_unique/shotgun_ammo/slugs
+ name = "Box of Slugs"
+ desc = "A box of slugs for a shotgun. For big game hunting."
+ item = /obj/item/storage/box/slugs
+
+/datum/uplink_item/spy_unique/stealth_belt
+ name = "Stealth Belt"
+ desc = "A stealth belt that lets you sneak behind enemy lines."
+ item = /obj/item/shadowcloak/weaker
+ cost = SPY_UPPER_COST_THRESHOLD
+
+/datum/uplink_item/spy_unique/katana
+ name = "Katana"
+ desc = "A really sharp Katana. Did I mention it's sharp?"
+ item = /obj/item/katana
+ cost = /datum/uplink_item/dangerous/doublesword::cost // Puts it in the same pool as Desword
+
+/datum/uplink_item/spy_unique/medkit_lite
+ name = "Syndicate First Medic Kit"
+ desc = "A syndicate tactical combat medkit, but only stocked enough to do basic first aid."
+ item = /obj/item/storage/medkit/tactical_lite
diff --git a/code/modules/uplink/uplink_items/stealthy.dm b/code/modules/uplink/uplink_items/stealthy.dm
index 793120fe56f..fb450fb68df 100644
--- a/code/modules/uplink/uplink_items/stealthy.dm
+++ b/code/modules/uplink/uplink_items/stealthy.dm
@@ -102,4 +102,4 @@
cost = 7
surplus = 50
limited_stock = 1
- purchasable_from = ~(UPLINK_NUKE_OPS | UPLINK_CLOWN_OPS | UPLINK_INFILTRATORS)
+ purchasable_from = UPLINK_TRAITORS | UPLINK_SPY
diff --git a/code/modules/uplink/uplink_items/stealthy_tools.dm b/code/modules/uplink/uplink_items/stealthy_tools.dm
index 60f007ebae7..59b8f6fca77 100644
--- a/code/modules/uplink/uplink_items/stealthy_tools.dm
+++ b/code/modules/uplink/uplink_items/stealthy_tools.dm
@@ -102,7 +102,7 @@
/datum/uplink_item/stealthy_tools/telecomm_blackout
name = "Disable Telecomms"
desc = "When purchased, a virus will be uploaded to the telecommunication processing servers to temporarily disable themselves."
- item = /obj/effect/gibspawner/generic
+ item = ABSTRACT_UPLINK_ITEM
surplus = 0
progression_minimum = 15 MINUTES
limited_stock = 1
@@ -117,7 +117,7 @@
/datum/uplink_item/stealthy_tools/blackout
name = "Trigger Stationwide Blackout"
desc = "When purchased, a virus will be uploaded to the engineering processing servers to force a routine power grid check, forcing all APCs on the station to be temporarily disabled."
- item = /obj/effect/gibspawner/generic
+ item = ABSTRACT_UPLINK_ITEM
surplus = 0
progression_minimum = 20 MINUTES
limited_stock = 1
diff --git a/icons/effects/effects.dmi b/icons/effects/effects.dmi
index 77489551f60..07d67bc6d46 100644
Binary files a/icons/effects/effects.dmi and b/icons/effects/effects.dmi differ
diff --git a/icons/mob/huds/antag_hud.dmi b/icons/mob/huds/antag_hud.dmi
index bb44e3de956..90056e499fd 100644
Binary files a/icons/mob/huds/antag_hud.dmi and b/icons/mob/huds/antag_hud.dmi differ
diff --git a/modular_skyrat/modules/aesthetics/storage/storage.dm b/modular_skyrat/modules/aesthetics/storage/storage.dm
index 92bd763cd90..01b7b5d56b9 100644
--- a/modular_skyrat/modules/aesthetics/storage/storage.dm
+++ b/modular_skyrat/modules/aesthetics/storage/storage.dm
@@ -116,6 +116,10 @@
icon_state = "secbox_xl"
illustration = "breacherslug"
+/obj/item/storage/box/slugs
+ icon_state = "secbox_xl"
+ illustration = "breacherslug"
+
/obj/item/storage/box/evidence
icon_state = "secbox"
illustration = "evidence"
diff --git a/modular_skyrat/modules/faction/code/mapping/mapping_helpers.dm b/modular_skyrat/modules/faction/code/mapping/mapping_helpers.dm
index 8b18ce42f17..796b50cf9b6 100644
--- a/modular_skyrat/modules/faction/code/mapping/mapping_helpers.dm
+++ b/modular_skyrat/modules/faction/code/mapping/mapping_helpers.dm
@@ -169,11 +169,10 @@
new /obj/item/pen/sleepy(src)
new /obj/item/storage/fancy/cigarettes/cigpack_syndicate(src)
if(2) //Energy weapons + energy knives
- new /obj/item/gun/energy/e_gun(src)
- new /obj/item/gun/energy/e_gun(src)
- new /obj/item/gun/energy/e_gun(src)
- new /obj/item/gun/energy/e_gun/mini(src)
new /obj/item/gun/energy/recharge/ebow(src)
+ new /obj/item/gun/energy/recharge/ebow(src)
+ new /obj/item/melee/energy/sword(src)
+ new /obj/item/melee/energy/sword(src)
new /obj/item/melee/energy/sword(src)
new /obj/item/melee/energy/sword(src)
if(3) //Ballistics + knives
@@ -197,7 +196,6 @@
new /obj/item/mod/control/pre_equipped/mining(src)
new /obj/item/mod/control/pre_equipped/engineering(src)
new /obj/item/mod/control/pre_equipped/atmospheric(src)
- new /obj/item/mod/control/pre_equipped/research(src)
new /obj/item/mod/control/pre_equipped/traitor(src)
new /obj/item/mod/control/pre_equipped/elite(src)
if(5) //Implants
diff --git a/modular_skyrat/modules/mapping/code/lockers/interdyne_fob/command.dm b/modular_skyrat/modules/mapping/code/lockers/interdyne_fob/command.dm
index abc511941ef..5f6e6c8beb3 100644
--- a/modular_skyrat/modules/mapping/code/lockers/interdyne_fob/command.dm
+++ b/modular_skyrat/modules/mapping/code/lockers/interdyne_fob/command.dm
@@ -64,11 +64,15 @@
new /obj/item/storage/belt/security/full(src)
new /obj/item/watertank/pepperspray(src)
- new /obj/item/gun/energy/disabler(src)
new /obj/item/storage/bag/garment/master_arms(src)
new /obj/item/radio/headset/interdyne(src)
new /obj/item/storage/toolbox/guncase/skyrat/c20r(src)
+/obj/structure/closet/secure_closet/interdynefob/maa_locker/populate_contents_immediate()
+ . = ..()
+
+ new /obj/item/gun/energy/disabler(src)
+
/obj/structure/closet/secure_closet/interdynefob/cl_locker
icon_door = "hop"
icon_state = "hop"
diff --git a/modular_skyrat/modules/mapping/code/lockers/interdyne_fob/security.dm b/modular_skyrat/modules/mapping/code/lockers/interdyne_fob/security.dm
index bf81406992f..2e4f7b67ea2 100644
--- a/modular_skyrat/modules/mapping/code/lockers/interdyne_fob/security.dm
+++ b/modular_skyrat/modules/mapping/code/lockers/interdyne_fob/security.dm
@@ -33,10 +33,14 @@
..()
new /obj/item/storage/belt/security/full(src)
- new /obj/item/gun/energy/e_gun(src)
new /obj/item/storage/bag/garment/brig_officer(src)
new /obj/item/radio/headset/interdyne(src)
+/obj/structure/closet/secure_closet/interdynefob/brig_officer_locker/populate_contents_immediate()
+ . = ..()
+
+ new /obj/item/gun/energy/e_gun(src)
+
/obj/structure/closet/secure_closet/interdynefob/armory_gear_locker
anchored = 1
icon = 'modular_skyrat/master_files/icons/obj/closet.dmi'
diff --git a/sound/ambience/antag/spy.ogg b/sound/ambience/antag/spy.ogg
new file mode 100644
index 00000000000..1a5c64a3979
Binary files /dev/null and b/sound/ambience/antag/spy.ogg differ
diff --git a/sound/ambience/license.txt b/sound/ambience/license.txt
index 607dd6628e7..a0b6efb24c5 100644
--- a/sound/ambience/license.txt
+++ b/sound/ambience/license.txt
@@ -1,4 +1,4 @@
-ambidet1.ogg is Fast Talking by Kevin Macleod. It has been licensed under the CC-BY 3.0 license.
+ambidet1.ogg and spy.ogg is Fast Talking by Kevin Macleod. It has been licensed under the CC-BY 3.0 license.
It has been cropped for use ingame.
ambidet2.ogg is Night on the Docks, Piano by Kevin Macleod. It has been licensed under CC-BY 3.0 license.
It has been cropped for use ingame, and also fades in.
diff --git a/strings/antagonist_flavor/spy_objective.json b/strings/antagonist_flavor/spy_objective.json
new file mode 100644
index 00000000000..aa696baad6f
--- /dev/null
+++ b/strings/antagonist_flavor/spy_objective.json
@@ -0,0 +1,84 @@
+{
+ "objective_body": [
+ "Assassinate a high profile crewmember without being caught.",
+ "Cause a disaster to shake the station.",
+ "Cause a station evacuation.",
+ "Deprive the station of as many @pick(stealables) as you can.",
+ "Ensure @pick(department) is @pick(affected) by the end of the shift.",
+ "Ensure @pick(location) is @pick(affected) by the end of the shift.",
+ "Ensure no heads of staff @pick(escape) the station.",
+ "Ensure no members of @pick(department) @pick(escape) the station.",
+ "Ensure no rival @pick(rivals) @pick(escape) the station.",
+ "Frame a crewmember for a crime.",
+ "Free the station's AI from its laws.",
+ "Halt the station's @pick(happenings).",
+ "Invoke a mutiny against the heads of staff.",
+ "Make it difficult, but not impossible to @pick(escape) the station.",
+ "Sabotage the station's power grid or engine.",
+ "Steal as many @pick(stealables) as you can.",
+ "Take control of the station as the new Captain.",
+ "Take hostages of high value crewmembers and demand a ransom."
+ ],
+ "department": [
+ "Security",
+ "Engineering",
+ "Medical",
+ "Science",
+ "Supply"
+ ],
+ "location": [
+ "engineering",
+ "genetics",
+ "hydroponics",
+ "medbay",
+ "the bar",
+ "the bridge",
+ "the brig",
+ "the cargo bay",
+ "the chapel",
+ "the kitchen",
+ "the library",
+ "xenobiology"
+ ],
+ "happenings": [
+ "research",
+ "cargo operations",
+ "communications",
+ "genetic research",
+ "mining operation"
+ ],
+ "affected": [
+ "ablaze",
+ "burning",
+ "covered in blood",
+ "demolished",
+ "destroyed",
+ "engulfed in flames",
+ "obliterated",
+ "on fire",
+ "ruined",
+ "sabotaged",
+ "wrecked"
+ ],
+ "rivals": [
+ "agents",
+ "moles",
+ "operatives",
+ "spies",
+ "traitors"
+ ],
+ "stealables": [
+ "items",
+ "objects",
+ "things",
+ "tools",
+ "weapons"
+ ],
+ "escape": [
+ "depart",
+ "escape",
+ "evacuate",
+ "flee",
+ "leave"
+ ]
+}
diff --git a/tgstation.dme b/tgstation.dme
index ee9bcf3515e..6b7af6f7df6 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -3251,6 +3251,10 @@
#include "code\modules\antagonists\space_dragon\space_dragon.dm"
#include "code\modules\antagonists\space_ninja\space_ninja.dm"
#include "code\modules\antagonists\spiders\spiders.dm"
+#include "code\modules\antagonists\spy\spy.dm"
+#include "code\modules\antagonists\spy\spy_bounty.dm"
+#include "code\modules\antagonists\spy\spy_bounty_handler.dm"
+#include "code\modules\antagonists\spy\spy_uplink.dm"
#include "code\modules\antagonists\survivalist\survivalist.dm"
#include "code\modules\antagonists\syndicate_monkey\syndicate_monkey.dm"
#include "code\modules\antagonists\traitor\balance_helper.dm"
@@ -5970,6 +5974,7 @@
#include "code\modules\uplink\uplink_items\nukeops.dm"
#include "code\modules\uplink\uplink_items\special.dm"
#include "code\modules\uplink\uplink_items\species.dm"
+#include "code\modules\uplink\uplink_items\spy_unique.dm"
#include "code\modules\uplink\uplink_items\stealthy.dm"
#include "code\modules\uplink\uplink_items\stealthy_tools.dm"
#include "code\modules\uplink\uplink_items\suits.dm"
diff --git a/tgui/packages/tgui/interfaces/AntagInfoSpy.tsx b/tgui/packages/tgui/interfaces/AntagInfoSpy.tsx
new file mode 100644
index 00000000000..a26266bceb4
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/AntagInfoSpy.tsx
@@ -0,0 +1,65 @@
+import { useBackend } from '../backend';
+import { Section, Stack } from '../components';
+import { Window } from '../layouts';
+import { Objective, ObjectivePrintout } from './common/Objectives';
+
+const greenText = {
+ fontWeight: 'italics',
+ color: '#20b142',
+};
+
+const redText = {
+ fontWeight: 'italics',
+ color: '#e03c3c',
+};
+
+type Data = {
+ antag_name: string;
+ uplink_location: string | null;
+ objectives: Objective[];
+};
+
+export const AntagInfoSpy = () => {
+ const { data } = useBackend();
+ const { antag_name, uplink_location, objectives } = data;
+ return (
+
+
+
+
+
+ You have been equipped with a special uplink device disguised as{' '}
+ {uplink_location || 'something'} that will allow you to steal from
+ the station.
+
+
+
+ Use it in hand to access your uplink, and{' '}
+ right click on bounty targets to steal them.
+
+
+
+
+ You may not be alone: There may be other spies on the station.
+
+
+ Work together or work against them: The choice is yours, but{' '}
+ you cannot share the rewards.
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/spy.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/spy.ts
new file mode 100644
index 00000000000..395baf87915
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/spy.ts
@@ -0,0 +1,24 @@
+import { multiline } from 'common/string';
+
+import { Antagonist, Category } from '../base';
+
+const Spy: Antagonist = {
+ key: 'spy',
+ name: 'Spy',
+ description: [
+ multiline`
+ Your mission, should you choose to accept it: Infiltrate Space Station 13.
+ Disguise yourself as a member of their crew and steal vital equipment.
+ Should you be caught or killed, your employer will disavow any knowledge
+ of your actions. Good luck agent.
+ `,
+
+ multiline`
+ Complete Spy Bounties to earn rewards from your employer.
+ Use these rewards to sow chaos and mischief!
+ `,
+ ],
+ category: Category.Roundstart,
+};
+
+export default Spy;
diff --git a/tgui/packages/tgui/interfaces/SpyUplink.tsx b/tgui/packages/tgui/interfaces/SpyUplink.tsx
new file mode 100644
index 00000000000..87735c19ff7
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/SpyUplink.tsx
@@ -0,0 +1,122 @@
+import { BooleanLike } from 'common/react';
+
+import { useBackend } from '../backend';
+import { BlockQuote, Box, Dimmer, Icon, Section, Stack } from '../components';
+import { Window } from '../layouts';
+
+type Bounty = {
+ name: string;
+ help: string;
+ difficulty: string;
+ reward: string;
+ claimed: BooleanLike;
+ can_claim: BooleanLike;
+};
+
+type Data = {
+ time_left: number;
+ bounties: Bounty[];
+};
+
+const difficulty_to_color = {
+ easy: 'good',
+ medium: 'average',
+ hard: 'bad',
+};
+
+const BountyDimmer = (props: { text: string; color: string }) => {
+ return (
+
+
+
+
+
+
+ {props.text}
+
+
+
+ );
+};
+
+const BountyDisplay = (props: { bounty: Bounty }) => {
+ const { bounty } = props;
+
+ return (
+
+ {!!bounty.claimed && }
+ {!bounty.can_claim && !bounty.claimed && (
+
+ )}
+
+
+
+ {bounty.name}
+
+
+
+ {bounty.help}
+
+ Reward: {bounty.reward}
+
+
+ );
+};
+
+// Formats a number of deciseconds into a string minutes:seconds
+const format_deciseconds = (deciseconds: number) => {
+ const seconds = Math.floor(deciseconds / 10);
+ const minutes = Math.floor(seconds / 60);
+
+ const seconds_left = seconds % 60;
+ const minutes_left = minutes % 60;
+
+ const seconds_string = seconds_left.toString().padStart(2, '0');
+ const minutes_string = minutes_left.toString().padStart(2, '0');
+
+ return `${minutes_string}:${seconds_string}`;
+};
+
+export const SpyUplink = () => {
+ const { data } = useBackend();
+ const { bounties, time_left } = data;
+
+ return (
+
+
+
+ Time until refresh: {format_deciseconds(time_left)}
+
+ }
+ >
+
+
+ {bounties.map((bounty) => (
+
+
+
+ ))}
+
+
+
+
+
+ );
+};