diff --git a/_maps/map_files/shared/CentCom.dmm b/_maps/map_files/shared/CentCom.dmm
index 381cdd545c3..d6b11615074 100644
--- a/_maps/map_files/shared/CentCom.dmm
+++ b/_maps/map_files/shared/CentCom.dmm
@@ -5665,10 +5665,10 @@
pixel_x = -24
},
/obj/structure/mannequin/male,
-/obj/item/clothing/head/rare/dwarfplate,
-/obj/item/clothing/armor/rare/dwarfplate,
-/obj/item/clothing/shoes/boots/rare/dwarfplate,
-/obj/item/clothing/gloves/rare/dwarfplate,
+/obj/item/clothing/head/helmet/heavy/dwarven,
+/obj/item/clothing/shoes/boots/armor/dwarven,
+/obj/item/clothing/armor/plate/full/dwarven,
+/obj/item/clothing/gloves/plate/dwarven,
/obj/machinery/light/fueled/torchholder/l{
pixel_x = 4
},
diff --git a/_maps/map_files/vanderlin/vanderlin_mountain.dmm b/_maps/map_files/vanderlin/vanderlin_mountain.dmm
index 92830bd5c3e..e810939074a 100644
--- a/_maps/map_files/vanderlin/vanderlin_mountain.dmm
+++ b/_maps/map_files/vanderlin/vanderlin_mountain.dmm
@@ -1219,7 +1219,7 @@
/turf/open/lava,
/area/under/cavelava)
"afL" = (
-/obj/item/clothing/shoes/boots/rare/dwarfplate,
+/obj/item/clothing/shoes/boots/armor/dwarven,
/obj/item/reagent_containers/food/snacks/rotten/egg,
/turf/open/floor/naturalstone,
/area/under/mountains/anvil/lower)
@@ -3488,7 +3488,7 @@
/area/indoors/town/church/inquisition)
"aqZ" = (
/obj/structure/rack,
-/obj/item/clothing/gloves/rare/dwarfplate,
+/obj/item/clothing/gloves/plate/dwarven,
/turf/open/floor/sand,
/area/indoors/mountains/anvil/keep)
"ara" = (
@@ -9050,7 +9050,7 @@
/area/under/mountains/anvil/upper)
"aRq" = (
/obj/structure/closet/crate/drawer,
-/obj/item/clothing/head/rare/dwarfplate,
+/obj/item/clothing/head/helmet/heavy/dwarven,
/obj/effect/spawner/map_spawner/loot/coin/med,
/turf/open/floor/cobble,
/area/indoors/mountains/anvil/keep)
diff --git a/_maps/matthios_tomb/room/hctomb3.dmm b/_maps/matthios_tomb/room/hctomb3.dmm
index e54902299dc..1c3a37db4da 100644
--- a/_maps/matthios_tomb/room/hctomb3.dmm
+++ b/_maps/matthios_tomb/room/hctomb3.dmm
@@ -618,11 +618,11 @@
/area/under/tomb/cave/spider)
"ws" = (
/obj/structure/rack,
-/obj/item/clothing/shoes/boots/rare/dwarfplate{
+/obj/item/clothing/shoes/boots/armor/dwarven{
pixel_x = -6;
pixel_y = 7
},
-/obj/item/clothing/gloves/rare/dwarfplate{
+/obj/item/clothing/gloves/plate/dwarven{
pixel_y = -3;
pixel_x = 1
},
@@ -1181,7 +1181,7 @@
/area/under/tomb/indoors)
"LV" = (
/obj/structure/rack,
-/obj/item/clothing/head/rare/dwarfplate{
+/obj/item/clothing/head/helmet/heavy/dwarven{
pixel_y = 5;
pixel_x = -6
},
@@ -1418,7 +1418,7 @@
/obj/structure/spider/stickyweb,
/obj/machinery/light/fueled/wallfire/candle/blue/l,
/obj/structure/closet/crate/chest/lootbox,
-/obj/item/clothing/armor/rare/dwarfplate,
+/obj/item/clothing/armor/plate/full/dwarven,
/turf/open/floor/blocks/stonered/tiny,
/area/under/tomb/indoors)
"Vz" = (
diff --git a/_maps/matthios_tomb/unusable/Malphpiece7.dmm b/_maps/matthios_tomb/unusable/Malphpiece7.dmm
index 4a9991d0835..1ef3ed891b5 100644
--- a/_maps/matthios_tomb/unusable/Malphpiece7.dmm
+++ b/_maps/matthios_tomb/unusable/Malphpiece7.dmm
@@ -14,7 +14,7 @@
/obj/item/clothing/head/hatfur,
/obj/item/clothing/shoes/boots/furlinedboots,
/obj/item/clothing/armor/plate,
-/obj/item/clothing/head/rare/dwarfplate,
+/obj/item/clothing/head/helmet/heavy/dwarven,
/obj/item/weapon/polearm/spear/billhook,
/obj/item/clothing/ring/gold/ravox,
/obj/item/coin/gold/pile,
diff --git a/code/__DEFINES/elastic.dm b/code/__DEFINES/elastic.dm
index b6ca62e210a..cea46c190ef 100644
--- a/code/__DEFINES/elastic.dm
+++ b/code/__DEFINES/elastic.dm
@@ -11,6 +11,7 @@
#define ELASCAT_COMBAT "combat"
#define ELASCAT_CRAFTING "crafting"
#define ELASCAT_ECONOMY "economy"
+ #define ELASCAT_SHOP "shop"
#define ELASCAT_STORYTELLER "storyteller"
#define ELASCAT_BALANCE "balance"
#define ELASCAT_MEDICAL "medical"
diff --git a/code/__DEFINES/loadout.dm b/code/__DEFINES/loadout.dm
new file mode 100644
index 00000000000..95b0b4fbfba
--- /dev/null
+++ b/code/__DEFINES/loadout.dm
@@ -0,0 +1,18 @@
+// Bitflags for loadout item restrictions/properties
+#define LOADOUT_FLAG_NONE 0
+#define LOADOUT_FLAG_NO_RENT (1<<0) // Cannot be rented for single rounds, must be owned permanently
+#define LOADOUT_FLAG_NO_EQUIP (1<<1) // Cannot be manually equipped (granted automatically on spawn, cosmetic/species grants)
+#define LOADOUT_FLAG_PATREON_LOCKED (1<<2) // Requires active patreon/donator status to use
+#define LOADOUT_FLAG_ACHIEVEMENT_LOCKED (1<<3) // requires required_award to be satisfied (replaces checking required_award != null implicitly)
+#define LOADOUT_FLAG_NO_DONATOR_FREE (1<<4)
+
+#define TICKET_TYPE_LOADOUT "loadout"
+#define TICKET_TYPE_SPECIAL "special"
+#define TICKET_TYPE_JOB_BOOST "job_boost"
+#define TICKET_TYPE_UNKNOWN "unknown"
+#define TICKET_TYPE_TRIUMPH "triumph"
+
+// Seconds the *sender* must wait before a cancel is processed
+// (prevents accepting+cancelling race that would dupe tickets)
+#define TICKET_TRADE_CANCEL_LOCK 5
+#define TRIUMPH_TICKET_MIN_CONVERT 10
diff --git a/code/__DEFINES/triumphs.dm b/code/__DEFINES/triumphs.dm
index 1f032ae15c6..c52c0ba7830 100644
--- a/code/__DEFINES/triumphs.dm
+++ b/code/__DEFINES/triumphs.dm
@@ -1,5 +1,5 @@
// Character category and its buys
-#define TRIUMPH_CAT_CHARACTER "CHARACTER"
+#define TRIUMPH_CAT_CHARACTER "Character"
#define TRIUMPH_BUY_RACE_ALL "race_all"
#define TRIUMPH_BUY_ANY_CLASS "pick_any"
@@ -8,13 +8,13 @@
#define TRIUMPH_BUY_SECRET_OFFICIANT "secret_officiant"
// Character category and its buys
-#define TRIUMPH_CAT_CHALLENGES "CHALLENGES"
+#define TRIUMPH_CAT_CHALLENGES "Challenges"
#define TRIUMPH_BUY_LEPROSY "leprosy"
#define TRIUMPH_BUY_CURSE "curse"
// Storyteller category and its buys
-#define TRIUMPH_CAT_STORYTELLER "STORYTELLER"
+#define TRIUMPH_CAT_STORYTELLER "Storyteller"
#define TRIUMPH_BUY_ASTRATA_INFLUENCE "astrata_influence"
#define TRIUMPH_BUY_NOC_INFLUENCE "noc_influence"
@@ -47,22 +47,27 @@
#define TRIUMPH_BUY_ASTRATA_INFLUENCE_REDUCTION "astrata_influence_reduction"
// Misc category and its buys
-#define TRIUMPH_CAT_MISC "MISC"
+#define TRIUMPH_CAT_MISC "Misc"
#define TRIUMPH_BUY_PSYDON_FAVOURITE "psydon_favourite"
// Misc category and its buys
-#define TRIUMPH_CAT_COMMUNAL "COMMUNAL"
+#define TRIUMPH_CAT_COMMUNAL "Communal"
#define TRIUMPH_BUY_PSYDON_RETIREMENT "psydon_retirement"
#define TRIUMPH_BUY_ORPHANAGE_RENOVATION "orphanage_renovation"
#define TRIUMPH_BUY_LONGER_WEEK "longer_week"
#define TRIUMPH_BUY_EXOTIC_TASTES "exotic_tastes"
-#define TRIUMPH_CAT_SEASONAL "SEASONAL"
+#define TRIUMPH_CAT_SEASONAL "Seasonal"
#define TRIUMPH_BUY_SUBTERRAN_DWARF "subterran_dwarf"
#define TRIUMPH_BUY_FORMIKRAG_KOBOLD "formikrag_kobold"
// Bought triumph buys category
-#define TRIUMPH_CAT_ACTIVE_DATUMS "BOUGHT"
+#define TRIUMPH_CAT_ACTIVE_DATUMS "Bought"
+
+/// Cost to roll a random eligible special trait
+#define TRIUMPH_COST_RANDOM_SPECIAL 1
+/// Cost to directly pick a specific eligible special trait
+#define TRIUMPH_COST_SPECIFIC_SPECIAL 400
diff --git a/code/_globalvars/lists/mobs.dm b/code/_globalvars/lists/mobs.dm
index f267c0259c5..8e6b2ab7e68 100644
--- a/code/_globalvars/lists/mobs.dm
+++ b/code/_globalvars/lists/mobs.dm
@@ -1,5 +1,6 @@
GLOBAL_LIST_EMPTY(keys_by_ckey) //all client ckeys, and their associated keys (keys_by_ckey[ckey] -> key), isn't cleared when the client leaves the game
GLOBAL_LIST_EMPTY(clients) //all clients
+GLOBAL_LIST_EMPTY(key_list)
GLOBAL_LIST_EMPTY(admins) //all clients whom are admins
GLOBAL_PROTECT(admins)
GLOBAL_LIST_EMPTY(deadmins) //all ckeys who have used the de-admin verb.
diff --git a/code/_globalvars/special_traits/special_traits.dm b/code/_globalvars/special_traits/special_traits.dm
index ec67e6d2c0b..ba2ad66df1a 100644
--- a/code/_globalvars/special_traits/special_traits.dm
+++ b/code/_globalvars/special_traits/special_traits.dm
@@ -30,6 +30,7 @@ GLOBAL_LIST_INIT(special_traits, build_special_traits())
/proc/try_apply_character_post_equipment(mob/living/carbon/human/character, client/player)
var/datum/job/job
+ apply_loadouts(character, player)
if(character.job)
job = SSjob.name_occupations[character.job]
if(!job)
@@ -41,7 +42,6 @@ GLOBAL_LIST_INIT(special_traits, build_special_traits())
return
// Apply the stuff if we have a job that has no adv classes
apply_character_post_equipment(character, player)
- apply_loadouts(arglist(args))
/proc/apply_character_post_equipment(mob/living/carbon/human/character, client/player)
if(!player)
@@ -61,6 +61,7 @@ GLOBAL_LIST_INIT(special_traits, build_special_traits())
return
apply_special_trait_if_able(character, player, trait_type)
player.prefs.next_special_trait = null
+ player.prefs.save_preferences()
/proc/apply_voicepacks(mob/living/carbon/human/character, client/player)
switch(player.prefs.voice_type)
@@ -72,16 +73,6 @@ GLOBAL_LIST_INIT(special_traits, build_special_traits())
character.dna.species.soundpack_f = new /datum/voicepack/female/haughty()
return
-/proc/apply_loadouts(mob/living/carbon/human/character, client/player)
- if(!player)
- player = character.client
- if(!player?.prefs)
- return
- for(var/i in 1 to 3)
- if(isnull(player.prefs.vars["loadout[i]"]))
- continue
- character.mind.special_items["[player.prefs.vars["loadout[i]"]:item_path:name]"] = player.prefs.vars["loadout[i]"]:item_path
-
/proc/apply_special_trait_if_able(mob/living/carbon/human/character, client/player, trait_type)
if(!charactet_eligible_for_trait(character, player, trait_type))
log_game("SPECIALS: Failed to apply [trait_type] for [key_name(character)]")
diff --git a/code/_globalvars/special_traits/traits.dm b/code/_globalvars/special_traits/traits.dm
index 06228e4c542..eb3ab0b526e 100644
--- a/code/_globalvars/special_traits/traits.dm
+++ b/code/_globalvars/special_traits/traits.dm
@@ -19,6 +19,7 @@
var/list/restricted_races
var/list/restricted_jobs
var/allowed_flaw
+ var/cost_modifier = 2
/// check if this characters can be applied this special_trait
/datum/special_trait/proc/can_apply(mob/living/carbon/human/character)
diff --git a/code/controllers/subsystem/achievements.dm b/code/controllers/subsystem/achievements.dm
index 415f3134130..8322297a18a 100644
--- a/code/controllers/subsystem/achievements.dm
+++ b/code/controllers/subsystem/achievements.dm
@@ -11,6 +11,12 @@ SUBSYSTEM_DEF(achievements)
var/list/datum/award/awards = list()
/datum/controller/subsystem/achievements/Initialize(timeofday)
+ setup()
+ return ..()
+
+/datum/controller/subsystem/achievements/proc/setup()
+ if(length(awards))
+ return
// Instantiate all award singletons
for(var/T in subtypesof(/datum/award/achievement))
var/datum/award/A = new T
@@ -33,7 +39,6 @@ SUBSYSTEM_DEF(achievements)
if(C.player_details?.achievements && !C.player_details.achievements.initialized)
C.player_details.achievements.InitializeData()
- return ..()
/datum/controller/subsystem/achievements/Shutdown()
force_save_all()
diff --git a/code/controllers/subsystem/elastic/config.dm b/code/controllers/subsystem/elastic/config.dm
index df0f54372c0..55955c62e39 100644
--- a/code/controllers/subsystem/elastic/config.dm
+++ b/code/controllers/subsystem/elastic/config.dm
@@ -3,6 +3,9 @@
/datum/config_entry/string/elastic_endpoint
protection = CONFIG_ENTRY_HIDDEN
+/datum/config_entry/string/shop_endpoint
+ protection = CONFIG_ENTRY_HIDDEN
+
/datum/config_entry/string/combat_endpoint
protection = CONFIG_ENTRY_HIDDEN
diff --git a/code/controllers/subsystem/elastic/shards/shop.dm b/code/controllers/subsystem/elastic/shards/shop.dm
new file mode 100644
index 00000000000..7c7f2a07943
--- /dev/null
+++ b/code/controllers/subsystem/elastic/shards/shop.dm
@@ -0,0 +1,7 @@
+/datum/elastic_shard/shop
+ name = "Shop"
+ upload_frequency = 5 MINUTES
+ shard_category = ELASCAT_SHOP
+
+/datum/elastic_shard/shop/get_endpoint()
+ return CONFIG_GET(string/shop_endpoint)
diff --git a/code/controllers/subsystem/ticker.dm b/code/controllers/subsystem/ticker.dm
index 02f24623c28..8c8ec0fcdee 100644
--- a/code/controllers/subsystem/ticker.dm
+++ b/code/controllers/subsystem/ticker.dm
@@ -469,7 +469,6 @@ SUBSYSTEM_DEF(ticker)
job_change_locked = FALSE
- SStriumphs.fire_on_PostSetup()
for(var/obj/effect/landmark/start/S as anything in GLOB.roundstart_landmarks)
if(!istype(S))//we can not runtime here. not in this important of a proc.
stack_trace("[S] [S.type] found in roundstart landmarks list, which isn't a start landmark!")
@@ -533,7 +532,7 @@ SUBSYSTEM_DEF(ticker)
livings += living
GLOB.character_ckey_list[living.real_name] = living.ckey
if(ishuman(living))
- try_apply_character_post_equipment(living)
+ try_apply_character_post_equipment(living, living.client)
if(livings.len)
addtimer(CALLBACK(src, PROC_REF(release_characters), livings), 30, TIMER_CLIENT_TIME)
diff --git a/code/controllers/subsystem/triumph/triumph_buy_menu.dm b/code/controllers/subsystem/triumph/triumph_buy_menu.dm
deleted file mode 100644
index d87f6859ff4..00000000000
--- a/code/controllers/subsystem/triumph/triumph_buy_menu.dm
+++ /dev/null
@@ -1,340 +0,0 @@
-/datum/triumph_buy_menu
- /// These are the menu datum vars
- var/client/linked_client
- /// Current page of triumphs we are viewing and yes its a number in a string
- var/current_page = "1"
- /// Current category we are viewing
- var/current_category = TRIUMPH_CAT_CHARACTER
- /// Number of pages we have
- var/page_count = 0
-
-/datum/triumph_buy_menu/New()
- ..()
-
-/datum/triumph_buy_menu/Destroy(force)
- linked_client = null
- . = ..()
-
-/datum/triumph_buy_menu/proc/triumph_menu_startup_slop()
- var/datum/asset/thicc_assets = get_asset_datum(/datum/asset/simple/stonekeep_triumph_buy_menu_slop_layout)
- thicc_assets.send(linked_client)
-
- show_menu()
-
-/datum/triumph_buy_menu/proc/show_menu()
- if(!linked_client)
- return
-
- var/data = {"
-
-
-
-
-
-
-
-
-
-
- I have [SStriumphs.get_triumphs(linked_client.ckey)] Triumphs
-
-
-
- "}
-
- data += "
"
- for(var/cat_key in SStriumphs.central_state_data)
- if(cat_key == current_category)
- data += "
[cat_key]"
- else
- data += "
[cat_key]"
-
- data += {"
-
-
- "}
-
- if(current_category == TRIUMPH_CAT_ACTIVE_DATUMS)
- var/found_one = FALSE
- var/list/active_items = list()
-
- for(var/datum/triumph_buy/found_triumph_buy in SStriumphs.active_triumph_buy_queue)
- if(!found_triumph_buy.visible_on_active_menu || usr.ckey != found_triumph_buy.ckey_of_buyer)
- continue
-
- active_items += found_triumph_buy
- found_one = TRUE
-
- if(found_one)
- data += {"
-
-
-
- | Description |
- Redeem |
-
-
-
- "}
-
- for(var/datum/triumph_buy/found_triumph_buy in active_items)
- data += {"
-
- |
- [found_triumph_buy.name]
- [found_triumph_buy.desc]
- |
- "}
-
- if(SSticker.HasRoundStarted() && found_triumph_buy.pre_round_only)
- data += "ROUND STARTED | "
- else
- data += "REFUND | "
-
- data += "
"
-
- data += {"
-
-
- "}
- else
- data += {"
- YOU HAVE NOTHING
- "}
- else if(current_category == TRIUMPH_CAT_COMMUNAL)
- data += ""
-
- for(var/datum/triumph_buy/communal/communal_buy in SStriumphs.central_state_data[current_category]["[current_page]"])
- var/total = SStriumphs.communal_pools[communal_buy.type]
- var/progress = communal_buy.maximum_pool ? (total / communal_buy.maximum_pool) * 100 : 0
- var/is_preround = istype(communal_buy, /datum/triumph_buy/communal/preround)
-
- data += "
"
- data += "
[communal_buy.name]
"
- data += "
[communal_buy.desc]
"
-
- data += "
"
- data += "
"
- data += "
[total]/[communal_buy.maximum_pool]
"
- data += "
"
-
- data += "
"
- if(communal_buy.activated)
- data += "
ACTIVE
"
- else if(is_preround && SSticker.HasRoundStarted())
- data += "
PREROUND ONLY
"
- else
- data += "
CONTRIBUTE"
- data += "
"
- data += "
"
-
- data += "
"
-
- else
- data += {"
-
-
-
- | Description |
- Cost |
- Stock |
- Redeem |
-
-
-
- "}
-
- for(var/datum/triumph_buy/current_check in SStriumphs.central_state_data[current_category]["[current_page]"])
- data += {"
-
- |
- [current_check.name]
- [current_check.desc]
- |
- [current_check.triumph_cost] |
- [current_check.limited ? SStriumphs.triumph_buy_stocks[current_check.type] : "∞"] |
- "}
-
- var/string = "BUY | "
- if(current_check.limited && SStriumphs.triumph_buy_stocks[current_check.type] <= 0)
- string = "OUT OF STOCK | "
- else if(SSticker.HasRoundStarted() && current_check.pre_round_only)
- string = "CONFLICT | "
- else
- for(var/datum/triumph_buy/conflict_check in SStriumphs.active_triumph_buy_queue)
- if(current_check.type in conflict_check.conflicts_with)
- string = "CONFLICT | "
- if(!current_check.allow_multiple_buys && linked_client?.has_triumph_buy(current_check.triumph_buy_id))
- string = "PURCHASED | "
- if(current_check.disabled)
- string = "DISABLED | "
- data += string
- data += "
"
-
- data += {"
-
-
- "}
-
- data += ""
- data += {"
-
-
- "}
- linked_client << browse(data, "window=triumph_buy_window;size=675x855;can_close=1;can_minimize=0;can_maximize=0;can_resize=1;titlebar=1")
- for(var/i in 1 to 10)
- if(!linked_client)
- break
- if(winexists(linked_client, "triumph_buy_window"))
- winset(linked_client, "triumph_buy_window", "on-close=\".windowclose [REF(src)]\"")
- break
-
-/datum/triumph_buy_menu/Topic(href, list/href_list)
- . = ..()
-
- if(href_list["select_a_category"])
- var/sent_category = href_list["select_a_category"]
- if(SStriumphs.central_state_data[sent_category])
- if(sent_category != current_category)
- current_category = sent_category
- current_page = "1"
- show_menu()
-
- if(href_list["select_a_page"])
- var/sent_page = href_list["select_a_page"]
- if(SStriumphs.central_state_data[current_category]["[sent_page]"])
- if(sent_page != current_page)
- current_page = sent_page
- show_menu()
-
- if(href_list["contribute"])
- if(!linked_client?.ckey)
- return
- if(SSticker.current_state == GAME_STATE_FINISHED)
- to_chat(linked_client, span_warning("You cannot contribute after the round has ended!"))
- return
-
- var/datum/triumph_buy/communal/communal_buy = locate(href_list["contribute"])
- if(communal_buy && istype(communal_buy))
- if(communal_buy.disabled)
- to_chat(linked_client, span_warning("This Triumph Buy has been disabled by administrators!"))
- return
- if(communal_buy.activated)
- to_chat(linked_client, span_warning("The item is already active!"))
- return
- if(istype(communal_buy, /datum/triumph_buy/communal/preround) && SSticker.HasRoundStarted())
- to_chat(linked_client, span_warning("This can only be contributed to before the round starts!"))
- return
-
- var/available = SStriumphs.get_triumphs(linked_client.ckey)
- var/max_possible = communal_buy.maximum_pool ? communal_buy.maximum_pool - SStriumphs.communal_pools[communal_buy.type] : INFINITY
- var/amount = input(linked_client, "How much to contribute?", "Communal Contribution", 0) as num|null
-
- if(!linked_client?.ckey)
- return
- if(!amount || amount <= 0)
- return
-
- if(SSticker.current_state == GAME_STATE_FINISHED)
- to_chat(linked_client, span_warning("You cannot contribute after the round has ended!"))
- return
- if(communal_buy.activated)
- to_chat(linked_client, span_warning("The item is already active!"))
- return
- if(istype(communal_buy, /datum/triumph_buy/communal/preround) && SSticker.HasRoundStarted())
- to_chat(linked_client, span_warning("This can only be contributed to before the round starts!"))
- return
-
- amount = round(amount)
- if(amount <= 0)
- to_chat(linked_client, span_warning("You must contribute at least one whole triumph!"))
- return
- if(amount > available)
- to_chat(linked_client, span_warning("You don't have [amount] triumph\s! You only have [available] triumph\s."))
- return
-
- amount = min(amount, available, max_possible)
- if(amount > 0)
- linked_client.adjust_triumphs(-amount, counted = FALSE, silent = TRUE)
- SStriumphs.communal_pools[communal_buy.type] += amount
- LAZYADD(SStriumphs.communal_contributions[communal_buy.type][linked_client.ckey], amount)
- to_chat(linked_client, span_notice("You have contributed [amount] triumph\s to the [communal_buy.name]."))
-
- if(amount >= 5 && SSticker.current_state < GAME_STATE_SETTING_UP)
- to_chat(world, span_notice("[amount] triumph\s were contributed to the [communal_buy.name] communal buy!"))
-
- if(communal_buy.maximum_pool && SStriumphs.communal_pools[communal_buy.type] >= communal_buy.maximum_pool)
- communal_buy.on_activate()
-
- SStriumphs.refresh_communal_menus()
- else
- show_menu()
-
- if(href_list["handle_buy_button"])
- if(!linked_client?.ckey)
- return
- if(SSticker.current_state == GAME_STATE_FINISHED)
- to_chat(linked_client, span_warning("You cannot buy anything after the round has ended!"))
- return
-
- var/datum/triumph_buy/target_datum = locate(href_list["handle_buy_button"])
- if(target_datum)
- var/conflicting = FALSE
-
- for(var/datum/triumph_buy/current_actives in SStriumphs.active_triumph_buy_queue)
- if(target_datum.type in current_actives.conflicts_with)
- conflicting = TRUE
-
- if(SSticker.HasRoundStarted() && target_datum.pre_round_only)
- conflicting = TRUE
-
- if(!conflicting)
- // Well we already made sure it wasn't going to conflict before we sent the path in, im sleepy and I hope this isn't REALLY fuckedu p when i look at it later
- if(current_category == TRIUMPH_CAT_ACTIVE_DATUMS) // ACTIVE datums are ones already bought anyways
- SStriumphs.attempt_to_unbuy_triumph_condition(linked_client, target_datum) // By unbuy, i mean you unbuy someone elses buy and thus we need a ref to it anyways
- else
- SStriumphs.attempt_to_buy_triumph_condition(linked_client, target_datum) // regular buy, just send over the ref to the reference case
- show_menu()
-
- if(href_list["close"])
- SStriumphs.remove_triumph_buy_menu(linked_client)
diff --git a/code/controllers/subsystem/triumph/triumphs.dm b/code/controllers/subsystem/triumph/triumphs.dm
index 1777d2f68ae..2890336454a 100644
--- a/code/controllers/subsystem/triumph/triumphs.dm
+++ b/code/controllers/subsystem/triumph/triumphs.dm
@@ -211,7 +211,7 @@ SUBSYSTEM_DEF(triumphs)
attempt_to_unbuy_triumph_condition(C, active_datum, reason = "CONFLICTS")
triumph_buy.on_buy(C)
- call_menu_refresh()
+ add_abstract_elastic_data(ELASCAT_SHOP, "[triumph_buy.name]", 1)
return TRUE
/// This occurs when you try to unbuy a triumph condition and removes it, also used for refunding due to conflicts
@@ -254,48 +254,8 @@ SUBSYSTEM_DEF(triumphs)
/datum/controller/subsystem/triumphs/proc/startup_triumphs_menu(client/C)
if(!C || !triumph_buys_enabled)
return
- var/datum/triumph_buy_menu/triumph_buy = active_triumph_menus[C.ckey]
- if(triumph_buy)
- triumph_buy.linked_client = C
- triumph_buy.triumph_menu_startup_slop()
- return
- var/datum/triumph_buy_menu/BIGBOY = new()
- BIGBOY.linked_client = C
- active_triumph_menus[C.ckey] = BIGBOY
- BIGBOY.triumph_menu_startup_slop()
-
-/// This tells all alive triumph datums to re_update their visuals, shitty but ya
-/datum/controller/subsystem/triumphs/proc/call_menu_refresh()
- for(var/MENS in active_triumph_menus)
- var/datum/triumph_buy_menu/triumph_buy = active_triumph_menus[MENS]
- if(!triumph_buy) // Insure we actually have something yes?
- active_triumph_menus.Remove(MENS)
- continue
-
- if(!triumph_buy.linked_client) // We have something and it has no client
- active_triumph_menus.Remove(MENS)
- qdel(triumph_buy)
- continue
-
- triumph_buy.show_menu()
-
-/datum/controller/subsystem/triumphs/proc/refresh_communal_menus()
- for(var/ckey in active_triumph_menus)
- var/datum/triumph_buy_menu/menu = active_triumph_menus[ckey]
- if(menu && menu.current_category == TRIUMPH_CAT_COMMUNAL)
- menu.show_menu()
-
-/// We cleanup the datum thats just holding the stuff for displaying the menu.
-/datum/controller/subsystem/triumphs/proc/remove_triumph_buy_menu(client/C)
- if(C && active_triumph_menus[C.ckey])
- var/datum/triumph_buy_menu/triumph_buy = active_triumph_menus[C.ckey]
- C << browse(null, "window=triumph_buy_window")
- active_triumph_menus.Remove(C.ckey)
- qdel(triumph_buy)
-
-/// Called from the place its slopped in in SSticker, this will occur right after the gamemode starts ideally, aka roundstart.
-/datum/controller/subsystem/triumphs/proc/fire_on_PostSetup()
- call_menu_refresh()
+ var/datum/tgui_triumph_shop/ui = new /datum/tgui_triumph_shop(C)
+ ui.ui_interact(C.mob)
/// We save everything when its time for reboot
/datum/controller/subsystem/triumphs/proc/end_triumph_saving_time()
diff --git a/code/datums/components/storage/storage_grid_types.dm b/code/datums/components/storage/storage_grid_types.dm
index 60bd038e2cf..60263f1bfc5 100644
--- a/code/datums/components/storage/storage_grid_types.dm
+++ b/code/datums/components/storage/storage_grid_types.dm
@@ -88,6 +88,11 @@
screen_max_rows = 10
screen_max_columns = 10
+/datum/component/storage/concrete/grid/bandolier
+ max_w_class = WEIGHT_CLASS_NORMAL
+ screen_max_rows = 4
+ screen_max_columns = 2
+
/datum/component/storage/concrete/grid/mailmaster/show_to(mob/M)
. = ..()
if(!.)
diff --git a/code/datums/loadouts.dm b/code/datums/loadouts.dm
deleted file mode 100644
index e756df445cc..00000000000
--- a/code/datums/loadouts.dm
+++ /dev/null
@@ -1,316 +0,0 @@
-GLOBAL_LIST_INIT(loadout_items, init_loadout_items())
-
-/proc/init_loadout_items()
- . = list()
- for(var/datum/loadout_item/item as anything in subtypesof(/datum/loadout_item))
- if(IS_ABSTRACT(item))
- continue
- .[item] = new item()
- return .
-
-/datum/loadout_item
- abstract_type = /datum/loadout_item
- /// Visible name for selection
- var/name = "Parent loadout datum"
- /// Visible description for item
- var/description
- /// Path to the item to spawn
- var/item_path
- /// Typepath of a /datum/award that must be unlocked to use this loadout item. Null = no requirement.
- var/required_award = null
-
-/// Returns TRUE if the given client has satisfied this loadout item's award requirement.
-/datum/loadout_item/proc/is_unlocked_for(client/C)
- if(!required_award)
- return TRUE
- if(!C?.player_details?.achievements)
- return FALSE
- var/datum/award/A = SSachievements.awards[required_award]
- if(!A)
- return FALSE
- if(istype(A, /datum/award/achievement/progress))
- var/datum/award/achievement/progress/PA = A
- return C.player_details.achievements.get_achievement_status(required_award) >= PA.required_progress
- if(istype(A, /datum/award/achievement))
- return C.player_details.achievements.get_achievement_status(required_award) == TRUE
- if(istype(A, /datum/award/score))
- return C.player_details.achievements.get_achievement_status(required_award) > 0
- return FALSE
-
-//Miscellaneous
-
-/datum/loadout_item/card_deck
- name = "Card Deck"
- item_path = /obj/item/toy/cards/deck
-
-/datum/loadout_item/rosa_bouquet
- name = "Rosa Bouquet"
- item_path = /obj/item/bouquet/rosa
-
-/datum/loadout_item/salvia_bouquet
- name = "Salvia Bouquet"
- item_path = /obj/item/bouquet/salvia
-
-/datum/loadout_item/matricaria_bouquet
- name = "Matricaria Bouquet"
- item_path = /obj/item/bouquet/matricaria
-
-/datum/loadout_item/calendula_bouquet
- name = "Calendula Bouquet"
- item_path = /obj/item/bouquet/calendula
-
-/datum/loadout_item/cane
- name = "Wooden Cane"
- item_path = /obj/item/weapon/mace/cane/
-
-/datum/loadout_item/natural_cane
- name = "Natural Wooden Cane"
- item_path = /obj/item/weapon/mace/cane/natural
-
-/datum/loadout_item/wooden_sword
- name = "Training Sword"
- item_path = /obj/item/weapon/mace/woodclub/train_sword
-
-/datum/loadout_item/keyring
- name = "Key Ring"
- item_path = /obj/item/storage/keyring
-
-/datum/loadout_item/soap
- name = "Bar of Soap"
- item_path = /obj/item/soap
-
-/datum/loadout_item/servant_bell
- name = "Unbound Servant Bell"
- item_path = /obj/item/servant_bell
-
-//HATS
-/datum/loadout_item/zalad
- name = "Keffiyeh"
- item_path = /obj/item/clothing/neck/keffiyeh
-
-/datum/loadout_item/turban
- name = "Turban"
- item_path = /obj/item/clothing/head/turban
-
-/datum/loadout_item/rosa_flower_crown
- name = "Rosa Flower Crown"
- item_path = /obj/item/clothing/head/flowercrown/rosa
-
-/datum/loadout_item/salvia_flower_crown
- name = "Salvia Flower Crown"
- item_path = /obj/item/clothing/head/flowercrown/salvia
-
-/datum/loadout_item/strawhat
- name = "Straw Hat"
- item_path = /obj/item/clothing/head/strawhat
-
-/datum/loadout_item/witchhat
- name = "Witch Hat"
- item_path = /obj/item/clothing/head/wizhat/witch
-
-/datum/loadout_item/bardhat
- name = "Bard Hat"
- item_path = /obj/item/clothing/head/bardhat
-
-/datum/loadout_item/fancyhat
- name = "Fancy Hat"
- item_path = /obj/item/clothing/head/fancyhat
-
-/datum/loadout_item/furhat
- name = "Fur Hat"
- item_path = /obj/item/clothing/head/hatfur
-
-/datum/loadout_item/tallhat
- name = "Tallhat (Dwarf + Halfling only)"
- item_path = /obj/item/clothing/head/gnomecap
-
-/datum/loadout_item/headband
- name = "Headband"
- item_path = /obj/item/clothing/head/headband
-
-/datum/loadout_item/nunveil
- name = "Nun Veil"
- item_path = /obj/item/clothing/head/nun
-
-/datum/loadout_item/papakha
- name = "Papakha"
- item_path = /obj/item/clothing/head/papakha
-
-//CLOAKS
-/datum/loadout_item/tabard
- name = "Tabard"
- item_path = /obj/item/clothing/cloak/tabard
-
-/datum/loadout_item/surcoat
- name = "Surcoat"
- item_path = /obj/item/clothing/cloak/stabard
-
-/datum/loadout_item/jupon
- name = "Jupon"
- item_path = /obj/item/clothing/cloak/stabard/jupon
-
-/datum/loadout_item/cape
- name = "Cape"
- item_path = /obj/item/clothing/cloak/cape
-
-/datum/loadout_item/halfcloak
- name = "Halfcloak"
- item_path = /obj/item/clothing/cloak/half
-
-/datum/loadout_item/volfmantle
- name = "Volf Mantle"
- item_path = /obj/item/clothing/cloak/volfmantle
-
-/datum/loadout_item/sash
- name = "Cloth Sash"
- item_path = /obj/item/clothing/shirt/undershirt/sash/colored/random
-
-/datum/loadout_item/poncho
- name = "Cloth Poncho"
- item_path = /obj/item/clothing/cloak/poncho
-
-/datum/loadout_item/vest
- name = "Cloth Vest"
- item_path = /obj/item/clothing/shirt/clothvest/colored/random
-
-/datum/loadout_item/wicker
- name = "Wicker Cloak"
- item_path = /obj/item/clothing/cloak/wickercloak
-
-/datum/loadout_item/shredded
- name = "Shredded Cloak"
- item_path = /obj/item/clothing/cloak/shredded
-
-//SHOES
-
-/datum/loadout_item/babouche
- name = "Babouche"
- item_path = /obj/item/clothing/shoes/shalal
-
-/datum/loadout_item/sandals
- name = "Sandals"
- item_path = /obj/item/clothing/shoes/sandals
-
-/datum/loadout_item/gladsandals
- name = "Gladiatorial Sandals"
- item_path = /obj/item/clothing/shoes/gladiator
-
-/datum/loadout_item/ankletscloth
- name = "Cloth Anklets"
- item_path = /obj/item/clothing/shoes/boots/clothlinedanklets
-
-//SHIRTS
-
-/datum/loadout_item/robe
- name = "Robe"
- item_path = /obj/item/clothing/shirt/robe
-
-/datum/loadout_item/longshirt
- name = "Shirt"
- item_path = /obj/item/clothing/shirt
-
-/datum/loadout_item/shortshirt
- name = "Short-sleeved Shirt"
- item_path = /obj/item/clothing/shirt/shortshirt
-
-/datum/loadout_item/sailorshirt
- name = "Striped Shirt"
- item_path = /obj/item/clothing/shirt/undershirt/sailor
-
-/datum/loadout_item/bottomtunic
- name = "Low-cut Tunic"
- item_path = /obj/item/clothing/shirt/undershirt/lowcut
-
-/datum/loadout_item/tunic
- name = "Tunic"
- item_path = /obj/item/clothing/shirt/tunic/colored/random
-
-/datum/loadout_item/dress
- name = "Dress"
- item_path = /obj/item/clothing/shirt/dress/gen
-
-/datum/loadout_item/bardress
- name = "Bar Dress"
- item_path = /obj/item/clothing/shirt/dress
-
-/datum/loadout_item/nun_habit
- name = "Nun Habit"
- item_path = /obj/item/clothing/shirt/robe/nun
-
-/datum/loadout_item/corset
- name = "Corset"
- item_path = /obj/item/clothing/armor/corset
-
-//PANTS
-/datum/loadout_item/tights
- name = "Cloth Tights"
- item_path = /obj/item/clothing/pants/tights
-
-/datum/loadout_item/sailorpants
- name = "Seafaring Pants"
- item_path = /obj/item/clothing/pants/tights/sailor
-
-/datum/loadout_item/skirt
- name = "Skirt"
- item_path = /obj/item/clothing/pants/skirt
-
-//ACCESSORIES
-
-/datum/loadout_item/elf_ear_necklace
- name = "Elf Ear Necklace"
- item_path = /obj/item/clothing/neck/elfears
-
-/datum/loadout_item/men_ear_necklace
- name = "Men Ear Necklace"
- item_path = /obj/item/clothing/neck/menears
-
-/datum/loadout_item/wrappings
- name = "Handwraps"
- item_path = /obj/item/clothing/wrists/wrappings
-
-/datum/loadout_item/loincloth
- name = "Loincloth"
- item_path = /obj/item/clothing/pants/loincloth
-
-/datum/loadout_item/fingerless
- name = "Fingerless Gloves"
- item_path = /obj/item/clothing/gloves/fingerless
-
-/datum/loadout_item/feather
- name = "Feather"
- item_path = /obj/item/natural/feather
-
-/datum/loadout_item/collar
- name = "Collar"
- item_path = /obj/item/clothing/neck/leathercollar
-
-/datum/loadout_item/bell_collar
- name = "Bell Collar"
- item_path = /obj/item/clothing/neck/bellcollar
-
-/datum/loadout_item/chaperon
- name = "Chaperon (Normal)"
- item_path = /obj/item/clothing/head/chaperon
-
-/datum/loadout_item/jesterhat
- name = "Jester's Hat"
- item_path = /obj/item/clothing/head/jester
-
-/datum/loadout_item/jestertunick
- name = "Jester's Tunick"
- item_path = /obj/item/clothing/shirt/jester
-
-/datum/loadout_item/jestershoes
- name = "Jester's Shoes"
- item_path = /obj/item/clothing/shoes/jester
-
-//FACE
-
-/datum/loadout_item/ragmask
- name = "Halfmask"
- item_path = /obj/item/clothing/face/shepherd/rag
-
-/datum/loadout_item/pocket_rous
- name = "Pocket Rous"
- item_path = /obj/item/reagent_containers/food/snacks/smallrat
- required_award = /datum/award/achievement/progress/rat_genocide
diff --git a/code/datums/loadouts/_base_loadout_item.dm b/code/datums/loadouts/_base_loadout_item.dm
new file mode 100644
index 00000000000..ee89bca58a3
--- /dev/null
+++ b/code/datums/loadouts/_base_loadout_item.dm
@@ -0,0 +1,89 @@
+GLOBAL_LIST_INIT(loadout_items, init_loadout_items())
+
+/proc/init_loadout_items()
+ . = list()
+ for(var/datum/loadout_item/item as anything in subtypesof(/datum/loadout_item))
+ if(IS_ABSTRACT(item))
+ continue
+ .[item] = new item()
+ return .
+
+/datum/loadout_item
+ abstract_type = /datum/loadout_item
+ /// Visible name for selection
+ var/name = "Parent loadout datum"
+ /// Visible description for item
+ var/description
+ /// Path to the item to spawn
+ var/item_path
+ /// Typepath of a /datum/award that must be unlocked to use this loadout item. Null = no requirement.
+ var/required_award = null
+ /// Triumphs spent to permanently own this item. Saved to owned_loadout_items.
+ var/triumph_cost_permanent = 0
+ /// DMI file for the shop sprite, auto-derived from item_path in New()
+ var/ui_icon = null
+ /// Icon state within the DMI, auto-derived from item_path in New()
+ var/ui_icon_state = null
+ /// Category tab shown in the shop
+ var/ui_category = "Miscellaneous"
+ /// Bitfield of LOADOUT_FLAG_* defines controlling rental, equip, and access restrictions.
+ var/loadout_flags = LOADOUT_FLAG_NONE
+
+/datum/loadout_item/New()
+ . = ..()
+ if(item_path && isnull(ui_icon))
+ ui_icon = initial(item_path:icon)
+ ui_icon_state = initial(item_path:icon_state)
+
+/datum/loadout_item/proc/is_permanently_owned_by(client/C)
+ return ("[type]" in C.prefs.owned_loadout_items)
+
+/datum/loadout_item/proc/can_afford_single(client/C)
+ if(triumph_cost_permanent)
+ return TRUE
+ return get_triumph_amount(C.ckey) >= CEILING(triumph_cost_permanent * 0.05, 1)
+
+/datum/loadout_item/proc/can_afford_permanent(client/C)
+ if(!triumph_cost_permanent)
+ return TRUE
+ return get_triumph_amount(C.ckey) >= triumph_cost_permanent
+
+/// Returns TRUE if the given client has satisfied this loadout item's award requirement.
+/datum/loadout_item/proc/is_unlocked_for(client/C)
+ if(required_award)
+ if(!length(SSachievements.awards))
+ SSachievements.setup()
+ var/datum/award/A = SSachievements.awards[required_award]
+ if(!A)
+ return FALSE
+ if(istype(A, /datum/award/achievement/progress))
+ var/datum/award/achievement/progress/PA = A
+ if(C.player_details.achievements.get_achievement_status(required_award) < PA.required_progress)
+ return FALSE
+ else if(istype(A, /datum/award/achievement))
+ if(C.player_details.achievements.get_achievement_status(required_award) != TRUE)
+ return FALSE
+ else if(istype(A, /datum/award/score))
+ if(C.player_details.achievements.get_achievement_status(required_award) <= 0)
+ return FALSE
+ if(loadout_flags & LOADOUT_FLAG_PATREON_LOCKED)
+ if(!C?.patreon?.is_donator())
+ return FALSE
+ return TRUE
+
+/// Returns TRUE if this item is currently owned by the client and all runtime access checks pass.
+/// Use this as the authoritative check in other systems (species checks, perk grants, etc).
+/datum/loadout_item/proc/is_owned_and_accessible(client/C)
+ if(!C?.prefs)
+ return FALSE
+ if(!("[type]" in C.prefs.owned_loadout_items))
+ return FALSE
+ // Patreon can lapse after purchase; re-validate at runtime.
+ if(loadout_flags & LOADOUT_FLAG_PATREON_LOCKED)
+ if(!C?.patreon?.is_donator())
+ return FALSE
+ return TRUE
+
+/proc/owns_loadout_item(client/client, datum/loadout_item/loadout_item)
+ var/datum/loadout_item/singleton = GLOB.loadout_items[loadout_item]
+ return singleton.is_owned_and_accessible(client)
diff --git a/code/datums/loadouts/accessory_loadout_items.dm b/code/datums/loadouts/accessory_loadout_items.dm
new file mode 100644
index 00000000000..5a01ec88e61
--- /dev/null
+++ b/code/datums/loadouts/accessory_loadout_items.dm
@@ -0,0 +1,125 @@
+/datum/loadout_item/wrappings
+ name = "Handwraps"
+ item_path = /obj/item/clothing/wrists/wrappings
+ ui_category = "Accessories"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/fingerless
+ name = "Fingerless Gloves"
+ item_path = /obj/item/clothing/gloves/fingerless
+ ui_category = "Accessories"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/feather
+ name = "Feather"
+ item_path = /obj/item/natural/feather
+ ui_category = "Accessories"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/shawl
+ name = "Shawl"
+ item_path = /obj/item/storage/belt/leather/shawl
+ ui_category = "Accessories"
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/double_belt
+ name = "Pair of Belts"
+ item_path = /obj/item/storage/belt/leather/double
+ ui_category = "Accessories"
+ triumph_cost_permanent = 125
+
+/datum/loadout_item/breechcloth
+ name = "Breechcloth"
+ item_path = /obj/item/storage/belt/leather/breechcloth
+ ui_category = "Accessories"
+ triumph_cost_permanent = 125
+
+/datum/loadout_item/jestershoes
+ name = "Jester's Shoes"
+ item_path = /obj/item/clothing/shoes/jester
+ ui_category = "Accessories"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/wedding_band
+ name = "Silver Wedding Band"
+ item_path = /obj/item/clothing/ring/band
+ ui_category = "Accessories"
+ triumph_cost_permanent = 150
+
+/datum/loadout_item/wedding_band_gold
+ name = "Gold Wedding Band"
+ item_path = /obj/item/clothing/ring/band/gold
+ ui_category = "Accessories"
+ triumph_cost_permanent = 150
+
+/datum/loadout_item/wedding_band_bronze
+ name = "Bronze Wedding Band"
+ item_path = /obj/item/clothing/ring/band/bronze
+ ui_category = "Accessories"
+ triumph_cost_permanent = 150
+
+/datum/loadout_item/wedding_band_ancient
+ name = "Ancient Wedding Band"
+ item_path = /obj/item/clothing/ring/band/paalloy
+ ui_category = "Accessories"
+ triumph_cost_permanent = 150
+
+/datum/loadout_item/duelist_ring
+ name = "Duelist's Ring"
+ item_path = /obj/item/clothing/ring/duelist
+ ui_category = "Accessories"
+ triumph_cost_permanent = 400
+
+
+/datum/loadout_item/blacksteel_emerald
+ name = "Gemerald Ring of Blacksteel"
+ item_path = /obj/item/clothing/ring/emeraldbs
+ ui_category = "Accessories"
+ triumph_cost_permanent = 300
+ loadout_flags = LOADOUT_FLAG_NO_RENT
+
+/datum/loadout_item/blacksteel_ruby
+ name = "Rontz Ring of Blacksteel"
+ item_path = /obj/item/clothing/ring/rubybs
+ ui_category = "Accessories"
+ triumph_cost_permanent = 300
+ loadout_flags = LOADOUT_FLAG_NO_RENT
+
+/datum/loadout_item/blacksteel_topaz
+ name = "Toper Ring of Blacksteel"
+ item_path = /obj/item/clothing/ring/topazbs
+ ui_category = "Accessories"
+ triumph_cost_permanent = 300
+ loadout_flags = LOADOUT_FLAG_NO_RENT
+
+/datum/loadout_item/blacksteel_quartz
+ name = "Blortz Ring of Blacksteel"
+ item_path = /obj/item/clothing/ring/quartzbs
+ ui_category = "Accessories"
+ triumph_cost_permanent = 300
+ loadout_flags = LOADOUT_FLAG_NO_RENT
+
+/datum/loadout_item/blacksteel_sapphire
+ name = "Saffira Ring of Blacksteel"
+ item_path = /obj/item/clothing/ring/sapphirebs
+ ui_category = "Accessories"
+ triumph_cost_permanent = 300
+ loadout_flags = LOADOUT_FLAG_NO_RENT
+
+/datum/loadout_item/blacksteel_diamond
+ name = "Dorpel Ring of Blacksteel"
+ item_path = /obj/item/clothing/ring/diamondbs
+ ui_category = "Accessories"
+ triumph_cost_permanent = 300
+ loadout_flags = LOADOUT_FLAG_NO_RENT
+
+/datum/loadout_item/puffer
+ name = "\"Puffer\""
+ item_path = /obj/item/gun/ballistic/powder/wheellock/puffer
+ ui_category = "Accessories"
+ triumph_cost_permanent = 100000 //this is straigrght up not feasible to get in a season lol
+ loadout_flags = LOADOUT_FLAG_NO_RENT
diff --git a/code/datums/loadouts/armor_loadout_items.dm b/code/datums/loadouts/armor_loadout_items.dm
new file mode 100644
index 00000000000..7f383687818
--- /dev/null
+++ b/code/datums/loadouts/armor_loadout_items.dm
@@ -0,0 +1,13 @@
+/datum/loadout_item/leathercoat
+ name = "Leathercoat"
+ item_path = /obj/item/clothing/armor/leather/jacket/leathercoat
+ ui_category = "Armor"
+
+ triumph_cost_permanent = 150
+
+/datum/loadout_item/leathercoat_black
+ name = "Black Leathercoat"
+ item_path = /obj/item/clothing/armor/leather/jacket/leathercoat/black
+ ui_category = "Armor"
+
+ triumph_cost_permanent = 150
diff --git a/code/datums/loadouts/cloak_loadout_items.dm b/code/datums/loadouts/cloak_loadout_items.dm
new file mode 100644
index 00000000000..38ccde8bb72
--- /dev/null
+++ b/code/datums/loadouts/cloak_loadout_items.dm
@@ -0,0 +1,172 @@
+
+/datum/loadout_item/tabard
+ name = "Tabard"
+ item_path = /obj/item/clothing/cloak/tabard
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/tabard
+ name = "Sleeved Tabard"
+ item_path = /obj/item/clothing/cloak/sleevedtabard
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/toga
+ name = "Toga"
+ item_path = /obj/item/clothing/cloak/tabard/toga
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/toga
+ name = "Black Toga"
+ item_path = /obj/item/clothing/cloak/psydontabard/black
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/surcoat
+ name = "Surcoat"
+ item_path = /obj/item/clothing/cloak/stabard
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/justicesurcoat
+ name = "Surcoat of the Justice Order"
+ item_path = /obj/item/clothing/cloak/stabard/templar/justice
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 150
+
+/datum/loadout_item/jupon
+ name = "Jupon"
+ item_path = /obj/item/clothing/cloak/stabard/jupon
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/cape
+ name = "Cape"
+ item_path = /obj/item/clothing/cloak/cape
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/shadowcloak
+ name = "Stalker Cloak"
+ item_path = /obj/item/clothing/cloak/half/shadowcloak
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/halfcloak
+ name = "Halfcloak"
+ item_path = /obj/item/clothing/cloak/half
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/volfmantle
+ name = "Volf Mantle"
+ item_path = /obj/item/clothing/cloak/volfmantle
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 150
+
+/datum/loadout_item/sash
+ name = "Cloth Sash"
+ item_path = /obj/item/clothing/shirt/undershirt/sash/colored/random
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/poncho
+ name = "Cloth Poncho"
+ item_path = /obj/item/clothing/cloak/poncho
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/vest
+ name = "Cloth Vest"
+ item_path = /obj/item/clothing/shirt/clothvest/colored/random
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/wicker
+ name = "Wicker Cloak"
+ item_path = /obj/item/clothing/cloak/wickercloak
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/shredded
+ name = "Shredded Cloak"
+ item_path = /obj/item/clothing/cloak/shredded
+ ui_category = "Cloaks"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/furcloak
+ name = "Fur Cloak"
+ ui_category = "Cloaks"
+ item_path = /obj/item/clothing/cloak/raincloak/furcloak
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/bandolier
+ name = "Bandolier"
+ item_path = /obj/item/clothing/cloak/bandolier
+ ui_category = "Cloaks"
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/scaledcloak
+ name = "Scaled Cloak"
+ item_path = /obj/item/clothing/cloak/scaledcloak
+ ui_category = "Cloaks"
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/fancycoat
+ name = "Fancy Coat"
+ item_path = /obj/item/clothing/cloak/poncho/fancycoat
+ ui_category = "Cloaks"
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/kazengun_coat
+ name = "Jinbaori"
+ item_path = /obj/item/clothing/cloak/kazengun
+ ui_category = "Cloaks"
+ triumph_cost_permanent = 250
+
+/datum/loadout_item/graggar_heavy
+ name = "Vicious Halfcloak"
+ item_path = /obj/item/clothing/cloak/graggar/heavy
+ ui_category = "Cloaks"
+ triumph_cost_permanent = 500
+
+/datum/loadout_item/graggar
+ name = "Vicious Cloak"
+ item_path = /obj/item/clothing/cloak/graggar
+ ui_category = "Cloaks"
+ triumph_cost_permanent = 500
+
+/datum/loadout_item/lirvan_silk
+ name = "Warrior Silks"
+ item_path = /obj/item/clothing/cloak/ordinatorcape/lirvas
+ ui_category = "Cloaks"
+ triumph_cost_permanent = 1000
+
+/datum/loadout_item/silver_order
+ name = "Silver Order Cloak"
+ item_path = /obj/item/clothing/cloak/cape/inquisitorsilver
+ ui_category = "Cloaks"
+ triumph_cost_permanent = 2000
+
+/datum/loadout_item/golden_order
+ name = "Golden Order Cloak"
+ item_path = /obj/item/clothing/cloak/cape/inquisitorgold
+ ui_category = "Cloaks"
+ triumph_cost_permanent = 3000
diff --git a/code/datums/loadouts/dye_loadout_items.dm b/code/datums/loadouts/dye_loadout_items.dm
new file mode 100644
index 00000000000..ee00bcf9765
--- /dev/null
+++ b/code/datums/loadouts/dye_loadout_items.dm
@@ -0,0 +1,137 @@
+/datum/loadout_item/dye_color
+ abstract_type = /datum/loadout_item/dye_color
+ ui_category = "Colors"
+ ui_icon = 'icons/roguetown/items/misc.dmi'
+ ui_icon_state = "bait"
+ loadout_flags = LOADOUT_FLAG_NO_EQUIP | LOADOUT_FLAG_NO_RENT
+ /// The hex color this item represents, e.g. "#3a7d44"
+ var/color_hex = "#FFFFFF"
+ /// Which dye palette this comes from: "peasant", "noble", "royal", "mage"
+ var/palette = "peasant"
+
+/datum/loadout_item/dye_color/proc/is_color_owned_by(client/C)
+ return is_owned_and_accessible(C)
+
+/datum/loadout_item/dye_color/peasant
+ abstract_type = /datum/loadout_item/dye_color/peasant
+ triumph_cost_permanent = 0
+ palette = "peasant"
+
+/datum/loadout_item/dye_color/peasant/undyed
+ name = "Undyed Linen"
+ color_hex = "#d4c5a9"
+
+/datum/loadout_item/dye_color/peasant/mud_brown
+ name = "Mud Brown"
+ color_hex = "#6b4226"
+
+/datum/loadout_item/dye_color/peasant/ash_grey
+ name = "Ash Grey"
+ color_hex = "#7a7a7a"
+
+/datum/loadout_item/dye_color/peasant/woad_blue
+ name = "Woad Blue"
+ color_hex = "#4a6fa5"
+
+/datum/loadout_item/dye_color/peasant/madder_red
+ name = "Madder Red"
+ color_hex = "#8b2020"
+
+/datum/loadout_item/dye_color/peasant/weld_yellow
+ name = "Weld Yellow"
+ color_hex = "#c8a415"
+
+/datum/loadout_item/dye_color/peasant/forest_green
+ name = "Forest Green"
+ color_hex = "#3a7d44"
+
+/datum/loadout_item/dye_color/peasant/onyx_black
+ name = "Onyx Black"
+ color_hex = "#1a1a1a"
+
+/datum/loadout_item/dye_color/peasant/bone_white
+ name = "Bone White"
+ color_hex = "#f0ead6"
+
+/datum/loadout_item/dye_color/noble
+ abstract_type = /datum/loadout_item/dye_color/noble
+ triumph_cost_permanent = 120
+ palette = "noble"
+
+/datum/loadout_item/dye_color/noble/royal_blue
+ name = "Royal Blue"
+ color_hex = "#1a3a6b"
+
+/datum/loadout_item/dye_color/noble/deep_crimson
+ name = "Deep Crimson"
+ color_hex = "#8b0000"
+
+/datum/loadout_item/dye_color/noble/forest_emerald
+ name = "Forest Emerald"
+ color_hex = "#1a6b3a"
+
+/datum/loadout_item/dye_color/noble/midnight_purple
+ name = "Midnight Purple"
+ color_hex = "#3a1a6b"
+
+/datum/loadout_item/dye_color/noble/burnt_sienna
+ name = "Burnt Sienna"
+ color_hex = "#8b4513"
+
+/datum/loadout_item/dye_color/noble/slate_teal
+ name = "Slate Teal"
+ color_hex = "#2d7a7a"
+
+/datum/loadout_item/dye_color/noble/ivory_cream
+ name = "Ivory Cream"
+ color_hex = "#fffff0"
+
+/datum/loadout_item/dye_color/noble/charcoal
+ name = "Charcoal"
+ color_hex = "#36454f"
+
+/datum/loadout_item/dye_color/royal
+ abstract_type = /datum/loadout_item/dye_color/royal
+ triumph_cost_permanent = 300
+ palette = "royal"
+
+/datum/loadout_item/dye_color/royal/tyrian_purple
+ name = "Tyrian Purple"
+ color_hex = "#66023c"
+
+/datum/loadout_item/dye_color/royal/imperial_gold
+ name = "Imperial Gold"
+ color_hex = "#d4af37"
+
+/datum/loadout_item/dye_color/royal/kings_scarlet
+ name = "King's Scarlet"
+ color_hex = "#cc2200"
+
+/datum/loadout_item/dye_color/royal/azure_cerulean
+ name = "Azure Cerulean"
+ color_hex = "#007fff"
+
+/datum/loadout_item/dye_color/royal/ivory_silk
+ name = "Ivory Silk"
+ color_hex = "#f8f4e3"
+
+/datum/loadout_item/dye_color/mage
+ abstract_type = /datum/loadout_item/dye_color/mage
+ triumph_cost_permanent = 200
+ palette = "mage"
+
+/datum/loadout_item/dye_color/mage/mage_green
+ name = "Mage Green"
+ color_hex = CLOTHING_MAGE_GREEN
+
+/datum/loadout_item/dye_color/mage/mage_yellow
+ name = "Mage Yellow"
+ color_hex = CLOTHING_MAGE_YELLOW
+
+/datum/loadout_item/dye_color/mage/mage_orange
+ name = "Mage Orange"
+ color_hex = CLOTHING_MAGE_ORANGE
+
+/datum/loadout_item/dye_color/mage/mage_blue
+ name = "Mage Blue"
+ color_hex = CLOTHING_MAGE_BLUE
diff --git a/code/datums/loadouts/face_loadout_items.dm b/code/datums/loadouts/face_loadout_items.dm
new file mode 100644
index 00000000000..056048153c6
--- /dev/null
+++ b/code/datums/loadouts/face_loadout_items.dm
@@ -0,0 +1,34 @@
+/datum/loadout_item/ragmask
+ name = "Halfmask"
+ item_path = /obj/item/clothing/face/shepherd/rag
+ ui_category = "Face"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/faceveil
+ name = "Simple Veil"
+ item_path = /obj/item/clothing/face/faceveil
+ ui_category = "Face"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/xylixmask
+ name = "Xylix Mask"
+ item_path = /obj/item/clothing/face/xylixmask
+ ui_category = "Face"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/xylixmask_weathered
+ name = "Weathered Xylix Mask"
+ item_path = /obj/item/clothing/face/xylixmask/weathered
+ ui_category = "Face"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/oni
+ name = "Oni Mask"
+ item_path = /obj/item/clothing/face/facemask/yoruku_oni
+ ui_category = "Face"
+
+ triumph_cost_permanent = 100
diff --git a/code/datums/loadouts/hat_loadout_items.dm b/code/datums/loadouts/hat_loadout_items.dm
new file mode 100644
index 00000000000..5d67898bada
--- /dev/null
+++ b/code/datums/loadouts/hat_loadout_items.dm
@@ -0,0 +1,167 @@
+/datum/loadout_item/zalad
+ name = "Keffiyeh"
+ item_path = /obj/item/clothing/neck/keffiyeh
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/turban
+ name = "Turban"
+ item_path = /obj/item/clothing/head/turban
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/archercap
+ name = "Archer's Cap"
+ item_path = /obj/item/clothing/head/archercap
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/fedora
+ name = "Archeologist's Hat"
+ item_path = /obj/item/clothing/head/fedora
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/gravehat
+ name = "Gravetender's Hat"
+ item_path = /obj/item/clothing/head/leather/inqhat/gravehat
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/explorer
+ name = "Explorer's Hat"
+ item_path = /obj/item/clothing/head/explorerhat
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/headdress
+ name = "Foreign Headdress"
+ item_path = /obj/item/clothing/head/headdress
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/dancer_headdress
+ name = "Dancer's Headdress"
+ item_path = /obj/item/clothing/head/dancer_headdress
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/rosa_flower_crown
+ name = "Rosa Flower Crown"
+ item_path = /obj/item/clothing/head/flowercrown/rosa
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/salvia_flower_crown
+ name = "Salvia Flower Crown"
+ item_path = /obj/item/clothing/head/flowercrown/salvia
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/calendula_flower_crown
+ name = "Calendula Flower Crown"
+ item_path = /obj/item/clothing/head/flowercrown/calendula
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/matricaria_flower_crown
+ name = "Matricaria Flower Crown"
+ item_path = /obj/item/clothing/head/flowercrown/matricaria
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/manabloom_flower_crown
+ name = "Manabloom Flower Crown"
+ item_path = /obj/item/clothing/head/flowercrown/manabloom
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/strawhat
+ name = "Straw Hat"
+ item_path = /obj/item/clothing/head/strawhat
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/witchhat
+ name = "Witch Hat"
+ item_path = /obj/item/clothing/head/wizhat/witch
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/bardhat
+ name = "Bard Hat"
+ item_path = /obj/item/clothing/head/bardhat
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/fancyhat
+ name = "Fancy Hat"
+ item_path = /obj/item/clothing/head/fancyhat
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/furhat
+ name = "Fur Hat"
+ item_path = /obj/item/clothing/head/hatfur
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/tallhat
+ name = "Tallhat (Dwarf + Halfling only)"
+ item_path = /obj/item/clothing/head/gnomecap
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/headband
+ name = "Headband"
+ item_path = /obj/item/clothing/head/headband
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/nunveil
+ name = "Nun Veil"
+ item_path = /obj/item/clothing/head/nun
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/papakha
+ name = "Papakha"
+ item_path = /obj/item/clothing/head/papakha
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/chaperon
+ name = "Chaperon (Normal)"
+ item_path = /obj/item/clothing/head/chaperon
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/jesterhat
+ name = "Jester's Hat"
+ item_path = /obj/item/clothing/head/jester
+ ui_category = "Hats"
+
+ triumph_cost_permanent = 75
diff --git a/code/datums/loadouts/item_loadout_items.dm b/code/datums/loadouts/item_loadout_items.dm
new file mode 100644
index 00000000000..145174c7089
--- /dev/null
+++ b/code/datums/loadouts/item_loadout_items.dm
@@ -0,0 +1,21 @@
+
+/datum/loadout_item/cane
+ name = "Wooden Cane"
+ item_path = /obj/item/weapon/mace/cane
+ ui_category = "Held Item"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/natural_cane
+ name = "Natural Wooden Cane"
+ item_path = /obj/item/weapon/mace/cane/natural
+ ui_category = "Held Item"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/wooden_sword
+ name = "Training Sword"
+ item_path = /obj/item/weapon/mace/woodclub/train_sword
+ ui_category = "Held Item"
+
+ triumph_cost_permanent = 50
diff --git a/code/datums/loadouts/misc_loadout_item.dm b/code/datums/loadouts/misc_loadout_item.dm
new file mode 100644
index 00000000000..17f84f829f0
--- /dev/null
+++ b/code/datums/loadouts/misc_loadout_item.dm
@@ -0,0 +1,64 @@
+
+/datum/loadout_item/card_deck
+ name = "Card Deck"
+ item_path = /obj/item/toy/cards/deck
+ ui_category = "Miscellaneous"
+ triumph_cost_permanent = 0
+
+/datum/loadout_item/rosa_bouquet
+ name = "Rosa Bouquet"
+ item_path = /obj/item/bouquet/rosa
+ ui_category = "Miscellaneous"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/salvia_bouquet
+ name = "Salvia Bouquet"
+ item_path = /obj/item/bouquet/salvia
+ ui_category = "Miscellaneous"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/matricaria_bouquet
+ name = "Matricaria Bouquet"
+ item_path = /obj/item/bouquet/matricaria
+ ui_category = "Miscellaneous"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/calendula_bouquet
+ name = "Calendula Bouquet"
+ item_path = /obj/item/bouquet/calendula
+ ui_category = "Miscellaneous"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/keyring
+ name = "Key Ring"
+ item_path = /obj/item/storage/keyring
+ ui_category = "Miscellaneous"
+
+ triumph_cost_permanent = 0
+
+/datum/loadout_item/soap
+ name = "Bar of Soap"
+ item_path = /obj/item/soap
+ ui_category = "Miscellaneous"
+
+ triumph_cost_permanent = 0
+
+/datum/loadout_item/servant_bell
+ name = "Unbound Servant Bell"
+ item_path = /obj/item/servant_bell
+ ui_category = "Miscellaneous"
+
+ triumph_cost_permanent = 150
+
+/datum/loadout_item/pocket_rous
+ name = "Pocket Rous"
+ description = "It's like a normal rat, but it fits in your pocket."
+ item_path = /obj/item/reagent_containers/food/snacks/smallrat
+ ui_category = "Companions"
+ required_award = /datum/award/achievement/progress/rat_genocide
+ triumph_cost_permanent = 0 // free once the achievement is earned
+
diff --git a/code/datums/loadouts/neck_loadout_items.dm b/code/datums/loadouts/neck_loadout_items.dm
new file mode 100644
index 00000000000..3c55c6a06c3
--- /dev/null
+++ b/code/datums/loadouts/neck_loadout_items.dm
@@ -0,0 +1,146 @@
+/datum/loadout_item/collar
+ name = "Collar"
+ item_path = /obj/item/clothing/neck/leathercollar
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/woolencollar
+ name = "Woolen Collar"
+ item_path = /obj/item/clothing/neck/woolen
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/bell_collar
+ name = "Bell Collar"
+ item_path = /obj/item/clothing/neck/bellcollar
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/pearlcross
+ name = "Pearl Cross"
+ item_path = /obj/item/clothing/neck/psycross/pearl
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/bpearl
+ name = "Blue Pearl Cross"
+ item_path = /obj/item/clothing/neck/psycross/bpearl
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/elf_ear_necklace
+ name = "Elf Ear Necklace"
+ item_path = /obj/item/clothing/neck/elfears
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/men_ear_necklace
+ name = "Men Ear Necklace"
+ item_path = /obj/item/clothing/neck/menears
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/matthios_cross
+ name = "Amulet of Matthios"
+ item_path = /obj/item/clothing/neck/psycross/matthios
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/graggar_cross
+ name = "Amulet of Graggar"
+ item_path = /obj/item/clothing/neck/psycross/graggar
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/baotha_cross
+ name = "Amulet of Baotha"
+ item_path = /obj/item/clothing/neck/psycross/baotha
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/zizo_cross
+ name = "Amulet of Zizo"
+ item_path = /obj/item/clothing/neck/psycross/zizo
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/necra_cross
+ name = "Amulet of Necra"
+ item_path = /obj/item/clothing/neck/psycross/silver/divine/necra
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/abyssor_cross
+ name = "Amulet of Abyssor"
+ item_path = /obj/item/clothing/neck/psycross/silver/divine/abyssor
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/dendor_cross
+ name = "Amulet of Dendor"
+ item_path = /obj/item/clothing/neck/psycross/silver/divine/dendor
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/ravox_cross
+ name = "Amulet of Ravox"
+ item_path = /obj/item/clothing/neck/psycross/silver/divine/ravox
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/eora_cross
+ name = "Amulet of Eora"
+ item_path = /obj/item/clothing/neck/psycross/silver/divine/eora
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/xylix_cross
+ name = "Amulet of Xylix"
+ item_path = /obj/item/clothing/neck/psycross/silver/divine/xylix
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/noc_cross
+ name = "Amulet of Noc"
+ item_path = /obj/item/clothing/neck/psycross/silver/divine/noc
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/astrata_cross
+ name = "Amulet of Astrata"
+ item_path = /obj/item/clothing/neck/psycross/silver/divine/astrata
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/pestra_cross
+ name = "Amulet of Pestra"
+ item_path = /obj/item/clothing/neck/psycross/silver/divine/pestra
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/malum_cross
+ name = "Amulet of Malum"
+ item_path = /obj/item/clothing/neck/psycross/silver/divine/malum
+ ui_category = "Neck"
+
+ triumph_cost_permanent = 100
diff --git a/code/datums/loadouts/pants_loadout_items.dm b/code/datums/loadouts/pants_loadout_items.dm
new file mode 100644
index 00000000000..20675f33027
--- /dev/null
+++ b/code/datums/loadouts/pants_loadout_items.dm
@@ -0,0 +1,55 @@
+/datum/loadout_item/tights
+ name = "Cloth Tights"
+ item_path = /obj/item/clothing/pants/tights
+ ui_category = "Pants"
+
+ triumph_cost_permanent = 0
+
+/datum/loadout_item/sailorpants
+ name = "Seafaring Pants"
+ item_path = /obj/item/clothing/pants/tights/sailor
+ ui_category = "Pants"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/skirt
+ name = "Skirt"
+ item_path = /obj/item/clothing/pants/skirt
+ ui_category = "Pants"
+
+ triumph_cost_permanent = 0
+
+/datum/loadout_item/loincloth
+ name = "Loincloth"
+ item_path = /obj/item/clothing/pants/loincloth
+ ui_category = "Pants"
+
+ triumph_cost_permanent = 0
+
+/datum/loadout_item/shepherd_pants
+ name = "Shephred's Pants"
+ item_path = /obj/item/clothing/pants/trou/leather/shepherd
+ ui_category = "Pants"
+
+ triumph_cost_permanent = 150
+
+/datum/loadout_item/explorerpants
+ name = "Explorer's Pants"
+ item_path = /obj/item/clothing/pants/tights/explorerpants
+ ui_category = "Pants"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/kazengunpants
+ name = "Gamebson Trousers"
+ item_path = /obj/item/clothing/pants/trou/leather/kazengun
+ ui_category = "Pants"
+
+ triumph_cost_permanent = 150
+
+/datum/loadout_item/pontifex
+ name = "Pontifex's Chaqchur"
+ item_path = /obj/item/clothing/pants/trou/leather/pontifex
+ ui_category = "Pants"
+
+ triumph_cost_permanent = 200
diff --git a/code/datums/loadouts/shirt_loadout_items.dm b/code/datums/loadouts/shirt_loadout_items.dm
new file mode 100644
index 00000000000..db77ab8974d
--- /dev/null
+++ b/code/datums/loadouts/shirt_loadout_items.dm
@@ -0,0 +1,133 @@
+
+/datum/loadout_item/robe
+ name = "Robe"
+ item_path = /obj/item/clothing/shirt/robe
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/shepherdvest
+ name = "Shepherd Vest"
+ item_path = /obj/item/clothing/shirt/robe/shepherdvest
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/longshirt
+ name = "Shirt"
+ item_path = /obj/item/clothing/shirt
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 0
+
+/datum/loadout_item/shortshirt
+ name = "Short-sleeved Shirt"
+ item_path = /obj/item/clothing/shirt/shortshirt
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 0
+
+/datum/loadout_item/sailorshirt
+ name = "Striped Shirt"
+ item_path = /obj/item/clothing/shirt/undershirt/sailor
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/bottomtunic
+ name = "Low-cut Tunic"
+ item_path = /obj/item/clothing/shirt/undershirt/lowcut
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 0
+
+/datum/loadout_item/tunic
+ name = "Tunic"
+ item_path = /obj/item/clothing/shirt/tunic/colored/random
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/dress
+ name = "Dress"
+ item_path = /obj/item/clothing/shirt/dress/gen
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/bardress
+ name = "Bar Dress"
+ item_path = /obj/item/clothing/shirt/dress
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/nun_habit
+ name = "Nun Habit"
+ item_path = /obj/item/clothing/shirt/robe/nun
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 100
+
+/datum/loadout_item/corset
+ name = "Corset"
+ item_path = /obj/item/clothing/armor/corset
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/jestertunick
+ name = "Jester's Tunick"
+ item_path = /obj/item/clothing/shirt/jester
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/explorer_vest
+ name = "Explorer's Vest"
+ item_path = /obj/item/clothing/shirt/explorer
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/fancyjacket
+ name = "Fancy Jacket"
+ item_path = /obj/item/clothing/shirt/fancyjacket
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 125
+
+/datum/loadout_item/saree
+ name = "Saree"
+ item_path = /obj/item/clothing/shirt/saree
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 125
+
+/datum/loadout_item/slitted_dress
+ name = "Slitted Dress"
+ item_path = /obj/item/clothing/shirt/dress/slit
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 125
+
+/datum/loadout_item/velvetdress
+ name = "Velvet Dress"
+ item_path = /obj/item/clothing/shirt/dress/velvetdress
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 150
+
+/datum/loadout_item/nobledress
+ name = "Noble Dress"
+ item_path = /obj/item/clothing/shirt/dress/nobledress
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 200
+
+/datum/loadout_item/hag
+ name = "Wyrd Robe"
+ item_path = /obj/item/clothing/shirt/robe/hag
+ ui_category = "Shirts"
+
+ triumph_cost_permanent = 250
diff --git a/code/datums/loadouts/shoes_loadout_items.dm b/code/datums/loadouts/shoes_loadout_items.dm
new file mode 100644
index 00000000000..c8560d2f5f0
--- /dev/null
+++ b/code/datums/loadouts/shoes_loadout_items.dm
@@ -0,0 +1,48 @@
+/datum/loadout_item/babouche
+ name = "Babouche"
+ item_path = /obj/item/clothing/shoes/shalal
+ ui_category = "Shoes"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/sandals
+ name = "Sandals"
+ item_path = /obj/item/clothing/shoes/sandals
+ ui_category = "Shoes"
+
+ triumph_cost_permanent = 0
+
+/datum/loadout_item/gladsandals
+ name = "Gladiatorial Sandals"
+ item_path = /obj/item/clothing/shoes/gladiator
+ ui_category = "Shoes"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/ankletscloth
+ name = "Cloth Anklets"
+ item_path = /obj/item/clothing/shoes/boots/clothlinedanklets
+ ui_category = "Shoes"
+
+ triumph_cost_permanent = 25
+
+/datum/loadout_item/shortboots
+ name = "Short Boots"
+ item_path = /obj/item/clothing/shoes/shortboots
+ ui_category = "Shoes"
+
+ triumph_cost_permanent = 50
+
+/datum/loadout_item/nobleboots
+ name = "Noble Boots"
+ item_path = /obj/item/clothing/shoes/nobleboot
+ ui_category = "Shoes"
+
+ triumph_cost_permanent = 75
+
+/datum/loadout_item/thighboots
+ name = "Thigh Boots"
+ item_path = /obj/item/clothing/shoes/nobleboot/thighboots
+ ui_category = "Shoes"
+
+ triumph_cost_permanent = 75
diff --git a/code/datums/materials/metals/ancient_alloy.dm b/code/datums/materials/metals/ancient_alloy.dm
new file mode 100644
index 00000000000..159762ef1df
--- /dev/null
+++ b/code/datums/materials/metals/ancient_alloy.dm
@@ -0,0 +1,8 @@
+/datum/material/ancient_alloy
+ name = "Ancient Alloy"
+ show_as_filling = TRUE
+ color = "#8b521c"
+ hardness = MAT_VALUE_FLEXIBLE + 5
+ integrity_modifier = 0.96
+ solid_form = /obj/item/ingot/aalloy
+ value_modiifer = 1.25
diff --git a/code/datums/materials/metals/avantyne.dm b/code/datums/materials/metals/avantyne.dm
new file mode 100644
index 00000000000..7b0cc38abb5
--- /dev/null
+++ b/code/datums/materials/metals/avantyne.dm
@@ -0,0 +1,9 @@
+/datum/material/avantyne
+ name = "Avantyne"
+ show_as_filling = TRUE
+ color = "#3E3236"
+ hardness = MAT_VALUE_HARD + 10
+ integrity_modifier = 1.5
+ solid_form = /obj/item/ingot/avantyne
+ melting_point = 1866
+ value_modiifer = 2.75
diff --git a/code/datums/materials/metals/draconic.dm b/code/datums/materials/metals/draconic.dm
new file mode 100644
index 00000000000..a3afd08f6f8
--- /dev/null
+++ b/code/datums/materials/metals/draconic.dm
@@ -0,0 +1,9 @@
+/datum/material/draconic
+ name = "Draconic"
+ show_as_filling = TRUE
+ color = "#70b8ff"
+ hardness = MAT_VALUE_HARD + 10
+ integrity_modifier = 1.5
+ solid_form = /obj/item/ingot/draconic
+ melting_point = 1866
+ value_modiifer = 3
diff --git a/code/datums/materials/metals/glimmering_slag.dm b/code/datums/materials/metals/glimmering_slag.dm
new file mode 100644
index 00000000000..6526c795b26
--- /dev/null
+++ b/code/datums/materials/metals/glimmering_slag.dm
@@ -0,0 +1,8 @@
+/datum/material/glimmering_slag
+ name = "Glimmering Slag"
+ show_as_filling = TRUE
+ color = "#8b521c"
+ hardness = MAT_VALUE_FLEXIBLE + 10
+ integrity_modifier = 0.85
+ solid_form = /obj/item/ingot/aaslag
+ value_modiifer = 1.1
diff --git a/code/datums/materials/metals/ketryl.dm b/code/datums/materials/metals/ketryl.dm
new file mode 100644
index 00000000000..01189a20917
--- /dev/null
+++ b/code/datums/materials/metals/ketryl.dm
@@ -0,0 +1,9 @@
+/datum/material/ketryl
+ name = "Ketryl"
+ show_as_filling = TRUE
+ color = "#264B5E"
+ hardness = MAT_VALUE_HARD + 10
+ integrity_modifier = 1.5
+ solid_form = /obj/item/ingot/ketryl
+ melting_point = 1866
+ value_modiifer = 2.75
diff --git a/code/datums/materials/metals/lithmyc.dm b/code/datums/materials/metals/lithmyc.dm
new file mode 100644
index 00000000000..742310b55ed
--- /dev/null
+++ b/code/datums/materials/metals/lithmyc.dm
@@ -0,0 +1,8 @@
+/datum/material/lithmyc
+ name = "Lithmyc"
+ show_as_filling = TRUE
+ color = "#9CD446"
+ hardness = MAT_VALUE_SOFT
+ integrity_modifier = 0.85
+ solid_form = /obj/item/ingot/lithmyc
+ value_modiifer = 4
diff --git a/code/datums/materials/metals/purified_alloy.dm b/code/datums/materials/metals/purified_alloy.dm
new file mode 100644
index 00000000000..e8708830c19
--- /dev/null
+++ b/code/datums/materials/metals/purified_alloy.dm
@@ -0,0 +1,7 @@
+/datum/material/purified_alloy
+ name = "Purified Alloy"
+ show_as_filling = TRUE
+ color = "#8b521c"
+ hardness = MAT_VALUE_VERY_HARD - 10
+ solid_form = /obj/item/ingot/purifiedaalloy
+ value_modiifer = 1.25
diff --git a/code/datums/materials/metals/weeping.dm b/code/datums/materials/metals/weeping.dm
new file mode 100644
index 00000000000..30399c0379b
--- /dev/null
+++ b/code/datums/materials/metals/weeping.dm
@@ -0,0 +1,13 @@
+/datum/material/weeping
+ name = "Enduring"
+ show_as_filling = TRUE
+ color = "#CECA9C"
+ hardness = MAT_VALUE_FLEXIBLE + 10
+ integrity_modifier = 1.2
+ solid_form = /obj/item/ingot/weeping
+
+ traits = list(
+ /datum/material_trait/silver_bane
+ )
+
+ value_modiifer = 2.25
diff --git a/code/datums/mind.dm b/code/datums/mind.dm
index 6a521a93d41..8083d566ea1 100644
--- a/code/datums/mind.dm
+++ b/code/datums/mind.dm
@@ -111,6 +111,7 @@ GLOBAL_LIST_EMPTY(personal_objective_minds)
///List of learned recipe TYPES.
var/list/learned_recipes
var/list/special_items = list()
+ var/list/loadout_item_colors = null
var/list/areas_entered = list()
diff --git a/code/datums/shop/shop.dm b/code/datums/shop/shop.dm
new file mode 100644
index 00000000000..5d22906c7d4
--- /dev/null
+++ b/code/datums/shop/shop.dm
@@ -0,0 +1,826 @@
+/datum/config_entry/number/special_rerolls
+ default = 0
+
+/datum/preferences/proc/open_loadout_shop(mob/mob)
+ var/datum/tgui_triumph_shop/ui = new /datum/tgui_triumph_shop(mob.client)
+ ui.ui_interact(mob)
+
+/datum/preferences/proc/load_triumph_shop_character_data(savefile/S)
+ if(!S)
+ return
+ S["equipped_loadout"] >> equipped_loadout
+ if(!islist(equipped_loadout))
+ equipped_loadout = list()
+ S["single_round_loadout"] >> single_round_loadout
+ if(!islist(single_round_loadout))
+ single_round_loadout = list()
+
+ validate_loadouts()
+ load_loadout_colors(S)
+
+/datum/preferences/proc/load_loadout_colors(savefile/S)
+ if(!S)
+ return
+ S["equipped_loadout_colors"] >> equipped_loadout_colors
+ S["single_round_loadout_colors"] >> single_round_loadout_colors
+ if(!islist(equipped_loadout_colors))
+ equipped_loadout_colors = list()
+ if(!islist(single_round_loadout_colors))
+ single_round_loadout_colors = list()
+
+ // Scrub stale color entries whose path no longer exists in equipped lists
+ var/list/clean_ec = list()
+ for(var/path_str in equipped_loadout_colors)
+ if(path_str in equipped_loadout)
+ clean_ec[path_str] = equipped_loadout_colors[path_str]
+ equipped_loadout_colors = clean_ec
+
+ var/list/clean_sc = list()
+ for(var/path_str in single_round_loadout_colors)
+ if(path_str in single_round_loadout)
+ clean_sc[path_str] = single_round_loadout_colors[path_str]
+ single_round_loadout_colors = clean_sc
+
+/datum/preferences/proc/validate_loadouts()
+ var/list/clean_owned = list()
+ for(var/path_str in owned_loadout_items)
+ var/datum/loadout_item/item = GLOB.loadout_items[text2path(path_str)]
+ if(!item)
+ continue
+ // Achievement check: item must still be unlocked.
+ if(!item.is_unlocked_for(parent))
+ continue
+ // Patreon check: lapsed subs lose patreon-locked items.
+ if((item.loadout_flags & LOADOUT_FLAG_PATREON_LOCKED) && !parent?.patreon?.is_donator())
+ continue
+ clean_owned += path_str
+ owned_loadout_items = clean_owned
+
+ var/list/clean_equipped = list()
+ for(var/path_str in equipped_loadout)
+ if(!(path_str in owned_loadout_items))
+ continue
+ var/datum/loadout_item/item = GLOB.loadout_items[text2path(path_str)]
+ // NO_EQUIP items must not occupy a loadout slot under any circumstances.
+ if(item && (item.loadout_flags & LOADOUT_FLAG_NO_EQUIP))
+ continue
+ clean_equipped += path_str
+ if(length(clean_equipped) > 3)
+ clean_equipped = clean_equipped.Copy(1, 4)
+ equipped_loadout = clean_equipped
+ return TRUE
+
+
+/proc/apply_loadouts(mob/living/carbon/human/character, client/player)
+ if(!player)
+ player = character.client //???
+ if(!player?.prefs)
+ stack_trace("No Client passed into apply_loadouts, [character]. This means something is funky")
+ return
+
+ var/slot = 1
+ for(var/path_str in player.prefs.equipped_loadout)
+ if(slot > 3)
+ break
+ var/datum/loadout_item/item = GLOB.loadout_items[text2path(path_str)]
+ if(!item)
+ continue
+
+ var/atom/item_path = item.item_path
+ character.mind.special_items[initial(item_path.name)] = item.item_path
+
+ var/list/colors = player.prefs.equipped_loadout_colors[path_str]
+ if(colors)
+ if(!character.mind.loadout_item_colors)
+ character.mind.loadout_item_colors = list()
+ character.mind.loadout_item_colors[initial(item_path.name)] = colors
+
+ slot++
+
+ for(var/path_str in player.prefs.single_round_loadout)
+ if(slot > 3)
+ break
+ if(path_str in player.prefs.equipped_loadout)
+ continue
+ var/datum/loadout_item/item = GLOB.loadout_items[text2path(path_str)]
+ if(!item)
+ continue
+
+ var/atom/item_path = item.item_path
+ character.mind.special_items[initial(item_path.name)] = item.item_path
+
+ var/list/colors = player.prefs.single_round_loadout_colors[path_str]
+ if(colors)
+ if(!character.mind.loadout_item_colors)
+ character.mind.loadout_item_colors = list()
+ character.mind.loadout_item_colors[initial(item_path.name)] = colors
+
+ slot++
+
+ if(!player.is_donator())
+ player.prefs.single_round_loadout = list()
+ player.prefs.single_round_loadout_colors = list()
+ player.prefs.save_preferences()
+ player.prefs.save_character()
+
+/proc/apply_item_colors(obj/item/spawned_item, datum/mind/mind)
+ if(!spawned_item || !mind?.loadout_item_colors)
+ return
+ if(!(spawned_item.name in mind.loadout_item_colors))
+ return
+ var/list/colors = mind.loadout_item_colors[spawned_item.name]
+ if(!colors)
+ return
+ if(spawned_item.dyeable)
+ var/base_hex = colors["base"]
+ if(base_hex)
+ spawned_item.add_atom_colour(base_hex, FIXED_COLOUR_PRIORITY)
+ var/detail_hex = colors["detail"]
+ if(detail_hex && !isnull(spawned_item.get_detail_tag()))
+ spawned_item.detail_color = detail_hex
+ spawned_item.update_appearance(UPDATE_OVERLAYS)
+
+/datum/tgui_triumph_shop
+ var/client/owner
+ var/lookup_result_ckey = null
+ var/list/lookup_result_tickets = null
+
+/datum/tgui_triumph_shop/New(client/C)
+ owner = C
+
+/datum/tgui_triumph_shop/ui_interact(mob/user, datum/tgui/ui)
+ ui = SStgui.try_update_ui(user, src, ui)
+ if (!ui)
+ ui = new(user, src, "TriumphShop")
+ ui.open()
+
+/datum/tgui_triumph_shop/ui_state(mob/user, datum/tgui/ui)
+ return GLOB.always_state
+
+/datum/tgui_triumph_shop/ui_data(mob/user)
+ var/list/data = list()
+ var/total_weight = 0
+ for(var/tt in GLOB.special_traits)
+ var/datum/special_trait/trait = SPECIAL_TRAIT(tt)
+ total_weight += trait.weight
+
+ data["triumph_balance"] = get_triumph_amount(owner.ckey)
+ data["cost_random_special"] = owner.is_donator() ? 0 : TRIUMPH_COST_RANDOM_SPECIAL
+ data["donator"] = owner.is_donator()
+ data["online_ckeys"] = GLOB.key_list
+
+ // Pending special for next spawn
+ var/pending = owner.prefs.next_special_trait
+ data["pending_special"] = pending ? "[pending]" : null
+
+ var/list/tb_cats = list()
+ for(var/cat_key in SStriumphs.central_state_data)
+ if(cat_key == TRIUMPH_CAT_ACTIVE_DATUMS) continue // handled separately
+ var/list/cat_items = list()
+ for(var/page_key in SStriumphs.central_state_data[cat_key])
+ for(var/datum/triumph_buy/tb in SStriumphs.central_state_data[cat_key][page_key])
+ // resolve conflicts server-side so client never has to iterate
+ var/conflicted = FALSE
+ for(var/datum/triumph_buy/active in SStriumphs.active_triumph_buy_queue)
+ if(tb.type in active.conflicts_with) conflicted = TRUE
+ if(SSticker.HasRoundStarted() && tb.pre_round_only) conflicted = TRUE
+ var/already_owned = !tb.allow_multiple_buys && owner?.has_triumph_buy(tb.triumph_buy_id)
+ cat_items += list(list(
+ "ref" = REF(tb),
+ "triumph_buy_id" = tb.triumph_buy_id,
+ "name" = tb.name,
+ "desc" = tb.desc,
+ "cost" = tb.triumph_cost,
+ "category" = cat_key,
+ "is_communal" = istype(tb, /datum/triumph_buy/communal),
+ "communal_current" = istype(tb,/datum/triumph_buy/communal) ? SStriumphs.communal_pools[tb.type] : 0,
+ "communal_max" = istype(tb,/datum/triumph_buy/communal) ? tb:maximum_pool : 0,
+ "communal_activated" = istype(tb,/datum/triumph_buy/communal) ? tb.activated : FALSE,
+ "pre_round_only" = tb.pre_round_only,
+ "limited" = tb.limited,
+ "stock" = tb.limited ? SStriumphs.triumph_buy_stocks[tb.type] : -1,
+ "conflicted" = conflicted,
+ "disabled" = tb.disabled,
+ "allow_multiple" = tb.allow_multiple_buys,
+ "already_owned" = already_owned,
+ "can_be_refunded" = tb.can_be_refunded,
+ "activated" = tb.activated,
+ "is_seasonal" = istype(tb, /datum/triumph_buy/seasonal),
+ "visible_active" = tb.visible_on_active_menu,
+ ))
+ tb_cats[cat_key] = cat_items
+
+ var/list/active_items = list()
+ for(var/datum/triumph_buy/tb in SStriumphs.active_triumph_buy_queue)
+ if(!tb.visible_on_active_menu || owner.ckey != tb.ckey_of_buyer) continue
+ active_items += list(list(
+ "ref" = REF(tb),
+ "triumph_buy_id" = tb.triumph_buy_id,
+ "name" = tb.name,
+ "desc" = tb.desc,
+ "cost" = tb.triumph_cost,
+ "pre_round_only" = tb.pre_round_only,
+ "can_be_refunded" = tb.can_be_refunded,
+ "activated" = tb.activated,
+ "is_seasonal" = istype(tb, /datum/triumph_buy/seasonal),
+ ))
+ data["triumph_buy_categories"] = tb_cats
+ data["active_triumph_buys"] = active_items
+
+ // Loadout categories
+ var/list/categories = list()
+ for(var/path in GLOB.loadout_items)
+ var/datum/loadout_item/item = GLOB.loadout_items[path]
+ var/cat = item.ui_category
+ if(!(cat in categories))
+ categories[cat] = list()
+ var/path_str = "[path]"
+ categories[cat] += list(list(
+ "path" = path_str,
+ "name" = item.name,
+ "description" = item.description,
+ "cost_single" = CEILING(item.triumph_cost_permanent * 0.05, 1),
+ "cost_permanent" = item.triumph_cost_permanent,
+ "free" = (!item.triumph_cost_permanent),
+ "owned" = (path_str in owner.prefs.owned_loadout_items),
+ "equipped" = (path_str in owner.prefs.equipped_loadout),
+ "rented" = (path_str in owner.prefs.single_round_loadout),
+ "can_afford_single" = item.can_afford_single(owner),
+ "can_afford_perm" = item.can_afford_permanent(owner),
+ "award_locked" = !!(item.required_award && !item.is_unlocked_for(owner)),
+ "ui_icon" = item.ui_icon,
+ "ui_icon_state" = item.ui_icon_state,
+ "no_rent" = !!(item.loadout_flags & LOADOUT_FLAG_NO_RENT),
+ "no_equip" = !!(item.loadout_flags & LOADOUT_FLAG_NO_EQUIP),
+ "patreon_locked" = !!(item.loadout_flags & LOADOUT_FLAG_PATREON_LOCKED),
+ "donator_free" = !(item.loadout_flags & LOADOUT_FLAG_NO_DONATOR_FREE),
+ "category" = cat
+ ))
+ data["categories"] = categories
+
+ var/list/colors_list = list()
+ for(var/path in GLOB.loadout_items)
+ var/datum/loadout_item/item = GLOB.loadout_items[path]
+ if(!istype(item, /datum/loadout_item/dye_color))
+ continue
+ var/datum/loadout_item/dye_color/color_item = item
+ var/path_str = "[path]"
+ colors_list += list(list(
+ "name" = color_item.name,
+ "hex" = color_item.color_hex,
+ "owned" = color_item.is_owned_and_accessible(owner),
+ "purchase_path" = path_str,
+ "cost" = color_item.triumph_cost_permanent,
+ "palette" = color_item.palette,
+ ))
+ data["available_colors"] = colors_list
+
+ // Equipped slots (ordered, always 3 entries)
+ var/list/slots = list()
+ for(var/i in 1 to 3)
+ var/path_str = (length(owner.prefs.equipped_loadout) >= i) \
+ ? owner.prefs.equipped_loadout[i] : null
+ if(path_str)
+ var/datum/loadout_item/item = GLOB.loadout_items[text2path(path_str)]
+ var/list/slot_entry = list(
+ "path" = path_str,
+ "name" = item ? item.name : "Unknown",
+ "permanent" = TRUE
+ )
+ enrich_slot_with_dye_info(slot_entry, path_str, owner.prefs.equipped_loadout_colors)
+ slots += list(slot_entry)
+ else
+ var/rent_idx = i - length(owner.prefs.equipped_loadout)
+ if(rent_idx >= 1 && rent_idx <= length(owner.prefs.single_round_loadout))
+ var/rpath = owner.prefs.single_round_loadout[rent_idx]
+ var/datum/loadout_item/ritem = GLOB.loadout_items[text2path(rpath)]
+ var/list/slot_entry = list(
+ "path" = rpath,
+ "name" = ritem ? "[ritem.name] (this round)" : "Rental",
+ "permanent" = FALSE
+ )
+ enrich_slot_with_dye_info(slot_entry, rpath, owner.prefs.single_round_loadout_colors)
+ slots += list(slot_entry)
+ else
+ // four dye keys stubbed out since empty
+ slots += list(list(
+ "path" = null,
+ "name" = "Empty",
+ "permanent" = FALSE,
+ "dyeable" = FALSE,
+ "has_detail" = FALSE,
+ "base_color" = null,
+ "detail_color" = null
+ ))
+ data["equipped_slots"] = slots
+ var/mob/living/carbon/human/preview = owner.mob
+ var/list/specials_list = list()
+ for(var/trait_type in GLOB.special_traits)
+ var/datum/special_trait/trait = SPECIAL_TRAIT(trait_type)
+ var/expected_rolls = (trait.weight > 0) ? (total_weight / trait.weight) : 999
+ var/patreon_modifier = user.client.is_donator() ? 0.5 : 1
+ var/computed_cost = FLOOR(expected_rolls * TRIUMPH_COST_RANDOM_SPECIAL * trait.cost_modifier * patreon_modifier, 1)
+ var/eligible = TRUE
+ if(istype(preview, /mob/living/carbon/human))
+ eligible = !!charactet_eligible_for_trait(preview, owner, trait_type)
+ specials_list += list(list(
+ "path" = "[trait_type]",
+ "name" = trait.name,
+ "greet_text" = trait.greet_text,
+ "req_text" = trait.req_text,
+ "weight" = trait.weight,
+ "total_weight" = total_weight,
+ "eligible" = eligible,
+ "cost_random" = owner.is_donator() ? 0 : TRIUMPH_COST_RANDOM_SPECIAL,
+ "cost_specific" = computed_cost,
+ "is_pending" = (pending == trait_type)
+ ))
+ data["specials"] = specials_list
+
+ var/list/tickets_list = list()
+ for(var/datum/ticket/t in owner.prefs.owned_tickets)
+ var/list/tdata = t.to_list()
+ t.enrich_ui_entry(tdata)
+ tickets_list += list(tdata)
+ data["owned_tickets"] = tickets_list
+ data["ticket_history"] = owner.prefs.ticket_history
+
+ var/list/locked_offering_ids = list()
+ var/list/incoming_trades = list()
+ var/list/outgoing_trades = list()
+
+ for(var/datum/ticket_trade/trade in GLOB.ticket_trade_manager.pending)
+ if(trade.from_ckey == owner.ckey)
+ for(var/id in trade.offered_ticket_ids)
+ locked_offering_ids += id
+ outgoing_trades += list(list(
+ "trade_id" = trade.trade_id,
+ "to_ckey" = trade.to_ckey,
+ "offered_ticket_ids" = trade.offered_ticket_ids,
+ "offered_ticket_names" = trade.offered_ticket_names,
+ "requested_ticket_ids" = trade.requested_ticket_ids,
+ "requested_ticket_names" = trade.requested_ticket_names,
+ "cancelling" = !!(trade.cancel_requested_at),
+ ))
+ else if(trade.to_ckey == owner.ckey)
+ var/client/from_client = GLOB.directory[trade.from_ckey]
+ var/list/sender_raw = GLOB.ticket_trade_manager.get_raw_ticket_list(trade.from_ckey, from_client)
+
+ var/list/offered_enriched = list()
+ for(var/id in trade.offered_ticket_ids)
+ if(islist(sender_raw))
+ for(var/list/entry in sender_raw)
+ if(entry["ticket_id"] == id)
+ var/list/enriched = entry.Copy()
+ // Use a temporary datum purely for enrich_ui_entry
+ var/datum/ticket/temp = ticket_from_list(entry)
+ if(temp)
+ temp.enrich_ui_entry(enriched)
+ offered_enriched += list(enriched)
+ break
+
+ var/list/requested_enriched = list()
+ for(var/id in trade.requested_ticket_ids)
+ var/datum/ticket/t = find_ticket_in_prefs(owner.prefs, id)
+ if(t)
+ var/list/tdata = t.to_list()
+ t.enrich_ui_entry(tdata)
+ requested_enriched += list(tdata)
+ else
+ requested_enriched += list(list(
+ "ticket_id" = id,
+ "name" = "(no longer owned)",
+ "ticket_type" = "unknown",
+ ))
+
+ incoming_trades += list(list(
+ "trade_id" = trade.trade_id,
+ "from_ckey" = trade.from_ckey,
+ "offered_tickets" = offered_enriched,
+ "offered_ticket_names" = trade.offered_ticket_names,
+ "requested_tickets" = requested_enriched,
+ "requested_ticket_names" = trade.requested_ticket_names,
+ "cancelling" = !!(trade.cancel_requested_at),
+ ))
+
+ data["locked_offering_ids"] = locked_offering_ids
+ data["incoming_trades"] = incoming_trades
+ data["outgoing_trades"] = outgoing_trades
+
+ data["lookup_result_ckey"] = src.lookup_result_ckey
+ data["lookup_result_tickets"] = src.lookup_result_tickets
+
+ return data
+
+
+/proc/enrich_slot_with_dye_info(list/slot_entry, path_str, list/color_store)
+ var/datum/loadout_item/item = GLOB.loadout_items[text2path(path_str)]
+ if(!item || !item.item_path)
+ slot_entry["dyeable"] = FALSE
+ slot_entry["has_detail"] = FALSE
+ slot_entry["base_color"] = null
+ slot_entry["detail_color"] = null
+ return
+
+ var/atom/movable/item_type = item.item_path
+ var/is_dyeable = initial(item_type:dyeable)
+ var/has_detail = FALSE
+ if(is_dyeable)
+ var/obj/item/probe = new item_type(null)
+ if(istype(probe))
+ has_detail = !isnull(probe.get_detail_tag())
+ qdel(probe)
+
+ slot_entry["dyeable"] = is_dyeable
+ slot_entry["has_detail"] = has_detail
+
+ var/list/colors = color_store[path_str]
+ slot_entry["base_color"] = colors ? colors["base"] : null
+ slot_entry["detail_color"] = colors ? colors["detail"] : null
+
+/datum/tgui_triumph_shop/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ switch(action)
+ if("buy_permanent")
+ return handle_buy_permanent(params["path"])
+ if("buy_single")
+ return handle_buy_single(params["path"])
+ if("equip_item")
+ return handle_equip(params["path"])
+ if("unequip_item")
+ return handle_unequip(params["path"])
+ if("buy_random_special")
+ return handle_random_special()
+ if("buy_specific_special")
+ return handle_specific_special(params["path"])
+ if("clear_pending_special")
+ return handle_clear_pending_special()
+ if("set_loadout_color")
+ return handle_set_loadout_color(params["path"], params["layer"], params["hex"])
+ if("clear_loadout_color")
+ return handle_clear_loadout_color(params["path"], params["layer"])
+ if("triumph_buy")
+ var/datum/triumph_buy/tb = locate(params["ref"])
+ if(!tb) return FALSE
+ return SStriumphs.attempt_to_buy_triumph_condition(owner, tb)
+ if("triumph_refund")
+ var/datum/triumph_buy/tb = locate(params["ref"])
+ if(!tb) return FALSE
+ return SStriumphs.attempt_to_unbuy_triumph_condition(owner, tb)
+ if("triumph_contribute")
+ var/datum/triumph_buy/communal/tb = locate(params["ref"])
+ if(!tb || !istype(tb)) return FALSE
+ var/amount = text2num(params["amount"])
+ var/available = SStriumphs.get_triumphs(owner.ckey)
+ if(!amount || amount <= 0) return FALSE
+ if(!owner?.ckey)
+ return
+ if(!amount || amount <= 0)
+ return
+
+ var/max_possible = tb.maximum_pool ? tb.maximum_pool - SStriumphs.communal_pools[tb.type] : INFINITY
+
+ if(SSticker.current_state == GAME_STATE_FINISHED)
+ to_chat(owner, span_warning("You cannot contribute after the round has ended!"))
+ return
+ if(tb.activated)
+ to_chat(owner, span_warning("The item is already active!"))
+ return
+ if(istype(tb, /datum/triumph_buy/communal/preround) && SSticker.HasRoundStarted())
+ to_chat(owner, span_warning("This can only be contributed to before the round starts!"))
+ return
+
+ amount = round(amount)
+ if(amount <= 0)
+ to_chat(owner, span_warning("You must contribute at least one whole triumph!"))
+ return
+ if(amount > available)
+ to_chat(owner, span_warning("You don't have [amount] triumph\s! You only have [available] triumph\s."))
+ return
+
+ amount = min(amount, available, max_possible)
+ if(amount > 0)
+ owner.adjust_triumphs(-amount, counted = FALSE, silent = TRUE)
+ SStriumphs.communal_pools[tb.type] += amount
+ LAZYADD(SStriumphs.communal_contributions[tb.type][owner.ckey], amount)
+ to_chat(owner, span_notice("You have contributed [amount] triumph\s to the [tb.name]."))
+
+ if(amount >= 5 && SSticker.current_state < GAME_STATE_SETTING_UP)
+ to_chat(world, span_notice("[amount] triumph\s were contributed to the [tb.name] communal buy!"))
+
+ if(tb.maximum_pool && SStriumphs.communal_pools[tb.type] >= tb.maximum_pool)
+ tb.on_activate()
+ return TRUE
+ if("use_ticket")
+ var/datum/ticket/t = find_ticket_in_prefs(owner.prefs, params["ticket_id"])
+ if(!t) return FALSE
+ return use_ticket(owner, t)
+
+ if("offer_trade")
+ var/list/offered_ids = params["offered_ids"]
+ var/list/requested_ids = params["requested_ids"]
+ if(!islist(offered_ids)) offered_ids = list()
+ if(!islist(requested_ids)) requested_ids = list()
+ return GLOB.ticket_trade_manager.offer_trade(owner, params["to_ckey"], offered_ids, requested_ids)
+
+ if("accept_trade")
+ return GLOB.ticket_trade_manager.accept_trade(owner, params["trade_id"])
+
+ if("cancel_trade")
+ return GLOB.ticket_trade_manager.cancel_trade(owner, params["trade_id"])
+
+ if("lookup_ckey_tickets")
+ var/target_ckey = params["target_ckey"]
+ if(!target_ckey) return FALSE
+ if(target_ckey == owner.ckey) return FALSE
+ var/list/raw = GLOB.ticket_trade_manager.lookup_inventory(target_ckey)
+ var/list/enriched = list()
+ if(islist(raw))
+ for(var/list/entry in raw)
+ var/list/e = entry.Copy()
+ var/datum/ticket/temp = ticket_from_list(entry)
+ if(temp)
+ temp.enrich_ui_entry(e)
+ enriched += list(e)
+ src.lookup_result_ckey = target_ckey
+ src.lookup_result_tickets = enriched
+ SStgui.update_uis(src)
+ return TRUE
+ if("convert_triumphs_to_ticket")
+ var/amount = text2num(params["amount"])
+ return handle_convert_triumphs_to_ticket(amount)
+
+ return FALSE
+
+/datum/tgui_triumph_shop/proc/handle_convert_triumphs_to_ticket(amount)
+ amount = round(text2num("[amount]")) // paranoia, never trust client
+ if(!amount || amount <= 0)
+ return FALSE
+ if(amount < TRIUMPH_TICKET_MIN_CONVERT)
+ to_chat(owner.mob, span_warning("You must convert at least [TRIUMPH_TICKET_MIN_CONVERT] triumphs at a time."))
+ return FALSE
+ var/balance = get_triumph_amount(owner.ckey)
+ if(balance < amount)
+ to_chat(owner.mob, span_warning("You only have [balance] triumphs, not [amount]."))
+ return FALSE
+
+ adjust_triumphs(owner, -amount, TRUE, "Triumph Shop: converted [amount] triumphs to ticket", FALSE, TRUE)
+
+ var/datum/ticket/triumph/t = new
+ t.ticket_id = generate_ticket_id()
+ t.name = "[amount] Triumph Ticket"
+ t.description = "Redeemable for [amount] triumphs."
+ t.triumph_amount = amount
+ t.granted_by = "Triumph Shop"
+ t.granted_at = "[time2text(world.realtime, "YYYY-MM-DD hh:mm:ss")]"
+ t.grant_reason = "Self-converted"
+ owner.prefs.owned_tickets += t
+ owner.prefs.ticket_history += list(list(
+ "event" = "converted",
+ "description" = "granted [t.name] ([t.ticket_type][t.details()]) by Triumph Shop (self-converted).",
+ "timestamp" = time2text(world.realtime, "YYYY-MM-DD hh:mm:ss"),
+ "ticket_id" = t.ticket_id,
+ "name" = t.name,
+ "type" = t.ticket_type,
+ ))
+ owner.prefs.save_preferences()
+ owner.prefs.save_character()
+
+ log_game("TRIUMPH SHOP: [owner.ckey] converted [amount] triumphs into a triumph ticket.")
+ to_chat(owner.mob, span_notice("Converted [amount] triumphs into a tradeable ticket!"))
+ return TRUE
+
+/datum/tgui_triumph_shop/proc/handle_buy_permanent(path_str)
+ var/datum/loadout_item/item = GLOB.loadout_items[text2path(path_str)]
+ if(!item)
+ return FALSE
+ if(path_str in owner.prefs.owned_loadout_items)
+ return FALSE
+ if(!item.is_unlocked_for(owner))
+ if(item.required_award)
+ to_chat(owner.mob, span_warning("You haven't unlocked the achievement required for [item.name]."))
+ else if(item.loadout_flags & LOADOUT_FLAG_PATREON_LOCKED)
+ to_chat(owner.mob, span_warning("[item.name] requires an active Patreon subscription."))
+ return FALSE
+ if(item.triumph_cost_permanent > 0)
+ var/balance = get_triumph_amount(owner.ckey)
+ if(balance < item.triumph_cost_permanent)
+ to_chat(owner.mob, span_warning("You need [item.triumph_cost_permanent] triumphs to permanently unlock [item.name]. You have [balance]."))
+ return FALSE
+ adjust_triumphs(owner, -item.triumph_cost_permanent, TRUE, "Triumph Shop: permanent unlock [item.name]", FALSE, TRUE)
+ owner.prefs.owned_loadout_items += path_str
+ owner.prefs.save_preferences()
+ owner.prefs.save_character()
+ log_game("TRIUMPH SHOP: [owner.ckey] permanently unlocked [path_str] for [item.triumph_cost_permanent] triumphs.")
+ to_chat(owner.mob, span_notice("Permanently unlocked [item.name]!"))
+ if(item.triumph_cost_permanent)
+ add_abstract_elastic_data(ELASCAT_SHOP, "[item.name]", 1)
+ return TRUE
+
+/datum/tgui_triumph_shop/proc/handle_buy_single(path_str)
+ var/datum/loadout_item/item = GLOB.loadout_items[text2path(path_str)]
+ if(!item)
+ return FALSE
+ if(item.loadout_flags & LOADOUT_FLAG_NO_RENT)
+ to_chat(owner.mob, span_warning("[item.name] cannot be rented, it must be permanently unlocked."))
+ return FALSE
+ if(item.loadout_flags & LOADOUT_FLAG_NO_EQUIP)
+ to_chat(owner.mob, span_warning("[item.name] cannot be equipped as a loadout item."))
+ return FALSE
+ if(path_str in owner.prefs.owned_loadout_items)
+ return FALSE
+ if(path_str in owner.prefs.single_round_loadout)
+ return FALSE
+ if(!item.is_unlocked_for(owner))
+ to_chat(owner.mob, span_warning("You don't meet the requirements for [item.name]."))
+ return FALSE
+ var/used = length(owner.prefs.equipped_loadout) + length(owner.prefs.single_round_loadout)
+ if(used >= 3)
+ to_chat(owner.mob, span_warning("All 3 loadout slots are in use."))
+ return FALSE
+ if(CEILING(item.triumph_cost_permanent * 0.05, 1) > 0 && !(owner.is_donator() && !(item.loadout_flags & LOADOUT_FLAG_NO_DONATOR_FREE)))
+ var/balance = get_triumph_amount(owner.ckey)
+ if(balance < CEILING(item.triumph_cost_permanent * 0.05, 1))
+ to_chat(owner.mob, span_warning("You need [CEILING(item.triumph_cost_permanent * 0.05, 1)] triumphs to rent [item.name]. You have [balance]."))
+ return FALSE
+ adjust_triumphs(owner, -CEILING(item.triumph_cost_permanent * 0.05, 1), TRUE, "Triumph Shop: single-round rent [item.name]", FALSE, TRUE)
+ add_abstract_elastic_data(ELASCAT_SHOP, "[item.name] - Rented", 1)
+
+ var/donator_free_use = owner.is_donator() && !(item.loadout_flags & LOADOUT_FLAG_NO_DONATOR_FREE)
+ owner.prefs.single_round_loadout += path_str
+ owner.prefs.save_preferences()
+ owner.prefs.save_character()
+ log_game("TRIUMPH SHOP: [owner.ckey] [donator_free_use ? "trialing" : "rented"] [path_str] [donator_free_use ? "(free, donator)" : "for one round ([CEILING(item.triumph_cost_permanent * 0.05, 1)] triumphs)"].")
+ to_chat(owner.mob, span_notice("[donator_free_use ? "Trying out [item.name] for this round (Patreon perk, no cost)." : "Rented [item.name] for this round."]"))
+ return TRUE
+
+/datum/tgui_triumph_shop/proc/handle_equip(path_str)
+ if(!(path_str in owner.prefs.owned_loadout_items))
+ return FALSE
+ if(path_str in owner.prefs.equipped_loadout)
+ return FALSE
+ var/datum/loadout_item/item = GLOB.loadout_items[text2path(path_str)]
+ if(item && (item.loadout_flags & LOADOUT_FLAG_NO_EQUIP))
+ to_chat(owner.mob, span_warning("[item.name] cannot be equipped as a loadout slot item."))
+ return FALSE
+ var/used = length(owner.prefs.equipped_loadout) + length(owner.prefs.single_round_loadout)
+ if(used >= 3)
+ to_chat(owner.mob, span_warning("All 3 loadout slots are in use."))
+ return FALSE
+ owner.prefs.equipped_loadout += path_str
+ owner.prefs.save_preferences()
+ owner.prefs.save_character()
+ return TRUE
+
+/datum/tgui_triumph_shop/proc/handle_unequip(path_str)
+ if(path_str in owner.prefs.equipped_loadout)
+ owner.prefs.equipped_loadout -= path_str
+ owner.prefs.save_preferences()
+ owner.prefs.save_character()
+ return TRUE
+ if(path_str in owner.prefs.single_round_loadout)
+ owner.prefs.single_round_loadout -= path_str
+ owner.prefs.save_preferences()
+ owner.prefs.save_character()
+ var/datum/loadout_item/item = GLOB.loadout_items[text2path(path_str)]
+ var/donator_free_use = owner.is_donator() && !(item?.loadout_flags & LOADOUT_FLAG_NO_DONATOR_FREE)
+ if(!donator_free_use && CEILING(item?.triumph_cost_permanent * 0.05, 1) > 0)
+ adjust_triumphs(owner, CEILING(item.triumph_cost_permanent * 0.05, 1), TRUE, "Triumph Shop: refund rent [item.name]", FALSE, TRUE)
+ return TRUE
+ return FALSE
+
+/datum/tgui_triumph_shop/proc/handle_random_special()
+ if(owner.prefs.next_special_trait)
+ to_chat(owner.mob, span_warning("You already have a special trait queued. Clear it first."))
+ return FALSE
+ var/balance = get_triumph_amount(owner.ckey)
+ var/cost = owner.is_donator() ? 0 : TRIUMPH_COST_RANDOM_SPECIAL
+ if(balance < cost)
+ to_chat(owner.mob, span_warning("You need [cost] triumphs to roll a random special. You have [balance]."))
+ return FALSE
+ // roll_random_special uses weight-based pickweight across ALL specials
+ // (not filtered by character eligibility, this is intentional for random rolls)
+ var/rolled = roll_random_special(owner)
+ if(!rolled)
+ to_chat(owner.mob, span_warning("No specials available to roll."))
+ return FALSE
+ if(cost)
+ adjust_triumphs(owner, -cost, TRUE, "Triumph Shop: random special roll", FALSE, TRUE)
+ owner.prefs.next_special_trait = rolled
+ owner.prefs.save_preferences()
+ owner.prefs.save_character()
+ print_special_text(owner, owner.prefs.next_special_trait)
+ owner.mob.playsound_local(owner.mob, 'sound/misc/alert.ogg', 100)
+ var/datum/special_trait/trait = SPECIAL_TRAIT(rolled)
+ log_game("TRIUMPH SHOP: [owner.ckey] rolled random special [rolled] ([trait?.name]) for [TRIUMPH_COST_RANDOM_SPECIAL] triumphs.")
+ to_chat(owner.mob, span_notice("You rolled: [trait?.name]! Applies on your next spawn."))
+ return TRUE
+
+/datum/tgui_triumph_shop/proc/handle_specific_special(path_str)
+ if(owner.prefs.next_special_trait)
+ to_chat(owner.mob, span_warning("You already have a special trait queued. Clear it first."))
+ return FALSE
+ var/trait_type = text2path(path_str)
+ if(!trait_type || !(trait_type in GLOB.special_traits))
+ return FALSE
+ var/mob/living/carbon/human/preview = owner.mob
+ if(istype(preview, /mob/living/carbon/human))
+ if(!charactet_eligible_for_trait(preview, owner, trait_type))
+ to_chat(owner.mob, span_warning("Your character does not meet the requirements for that trait."))
+ return FALSE
+ //never trust client-sent cost I don't want some fuck ass -1000000 cost
+ var/total_weight = 0
+ for(var/tw in GLOB.special_traits)
+ var/datum/special_trait/special = SPECIAL_TRAIT(tw)
+ total_weight += special.weight
+ var/datum/special_trait/trait = SPECIAL_TRAIT(trait_type)
+ var/expected_rolls = (trait.weight > 0) ? (total_weight / trait.weight) : 999
+ var/patreon_modifier = owner.is_donator() ? 0.5 : 1
+ var/cost = FLOOR(expected_rolls * TRIUMPH_COST_RANDOM_SPECIAL * trait.cost_modifier * patreon_modifier, 1)
+ var/balance = get_triumph_amount(owner.ckey)
+ if(balance < cost)
+ to_chat(owner.mob, span_warning("You need [cost] triumphs to pick [trait.name]. You have [balance]."))
+ return FALSE
+ adjust_triumphs(owner, -cost, TRUE, "Triumph Shop: specific special [path_str]", FALSE, TRUE)
+ owner.prefs.next_special_trait = trait_type
+ owner.prefs.save_preferences()
+ print_special_text(owner, owner.prefs.next_special_trait)
+ owner.mob.playsound_local(owner.mob, 'sound/misc/alert.ogg', 100)
+ log_game("TRIUMPH SHOP: [owner.ckey] purchased specific special [path_str] ([trait?.name]) for [cost] triumphs.")
+ to_chat(owner.mob, span_notice("Selected: [trait?.name]! Applies on your next spawn."))
+ add_abstract_elastic_data(ELASCAT_SHOP, "[trait.name]", 1)
+ return TRUE
+
+/datum/tgui_triumph_shop/proc/handle_clear_pending_special()
+ if(!owner.prefs.next_special_trait)
+ return FALSE
+ if(owner.player_details.rerolls <= 0)
+ return FALSE
+ owner.player_details.rerolls--
+ owner.prefs.next_special_trait = null
+ owner.prefs.save_preferences()
+ owner.prefs.save_character()
+ to_chat(owner.mob, span_notice("Pending special trait cleared. No refund issued."))
+ return TRUE
+
+
+/datum/tgui_triumph_shop/proc/handle_set_loadout_color(path_str, layer, hex)
+ //must be a 7-char string starting with # or the vanderlin will quite literally explode!!!
+ if(!istext(hex) || length(hex) != 7 || copytext(hex, 1, 2) != "#")
+ return FALSE
+ if(layer != "base" && layer != "detail")
+ return FALSE
+
+ //no freebies fr fr
+ var/found_color = FALSE
+ for(var/cpath in GLOB.loadout_items)
+ var/datum/loadout_item/item = GLOB.loadout_items[cpath]
+ if(!istype(item, /datum/loadout_item/dye_color))
+ continue
+ var/datum/loadout_item/dye_color/color_item = item
+ if(color_item.color_hex == hex && color_item.is_owned_and_accessible(owner))
+ found_color = TRUE
+ break
+ if(!found_color)
+ to_chat(owner.mob, span_warning("I do not own that color."))
+ return FALSE
+
+ // path_str must be in an equipped or rented slot
+ var/in_equipped = (path_str in owner.prefs.equipped_loadout)
+ var/in_rented = (path_str in owner.prefs.single_round_loadout)
+ if(!in_equipped && !in_rented)
+ return FALSE
+
+ var/list/color_store = in_equipped \
+ ? owner.prefs.equipped_loadout_colors \
+ : owner.prefs.single_round_loadout_colors
+
+ if(!(path_str in color_store) || !islist(color_store[path_str]))
+ color_store[path_str] = list("base" = null, "detail" = null)
+
+ color_store[path_str][layer] = hex
+ owner.prefs.save_preferences()
+ owner.prefs.save_character()
+
+ log_game("TRIUMPH SHOP: [owner.ckey] set [layer] color of [path_str] to [hex].")
+ return TRUE
+
+/datum/tgui_triumph_shop/proc/handle_clear_loadout_color(path_str, layer)
+ if(layer != "base" && layer != "detail")
+ return FALSE
+
+ var/list/color_store = null
+ if(path_str in owner.prefs.equipped_loadout)
+ color_store = owner.prefs.equipped_loadout_colors
+ else if(path_str in owner.prefs.single_round_loadout)
+ color_store = owner.prefs.single_round_loadout_colors
+ else
+ return FALSE
+
+ if(!(path_str in color_store))
+ return TRUE // already clear
+
+ color_store[path_str][layer] = null
+ owner.prefs.save_preferences()
+ owner.prefs.save_character()
+ return TRUE
diff --git a/code/datums/shop/tickets/__helpers.dm b/code/datums/shop/tickets/__helpers.dm
new file mode 100644
index 00000000000..6de84c4ec10
--- /dev/null
+++ b/code/datums/shop/tickets/__helpers.dm
@@ -0,0 +1,51 @@
+/proc/ticket_type_to_path(ticket_type)
+ switch(ticket_type)
+ if(TICKET_TYPE_LOADOUT)
+ return /datum/ticket/loadout
+ if(TICKET_TYPE_SPECIAL)
+ return /datum/ticket/special
+ if(TICKET_TYPE_JOB_BOOST)
+ return /datum/ticket/job_boost
+ if(TICKET_TYPE_TRIUMPH)
+ return /datum/ticket/triumph
+ return /datum/ticket
+
+// Deserialise one savefile assoc-list entry into the right subtype.
+/proc/ticket_from_list(list/L)
+ if(!islist(L))
+ return null
+ var/subtype = ticket_type_to_path(L["ticket_type"])
+ var/datum/ticket/t = new subtype()
+ t.from_list(L)
+ return t
+
+/proc/generate_ticket_id()
+ return "[time2text(world.realtime, "YYYYMMDD-hhmmss")]-[rand(1000,9999)]"
+
+/// Find a ticket datum in a preferences list by ticket_id string.
+/proc/find_ticket_in_prefs(datum/preferences/prefs, ticket_id)
+ for(var/datum/ticket/t in prefs.owned_tickets)
+ if(t.ticket_id == ticket_id)
+ return t
+ return null
+
+/proc/use_ticket(client/user, datum/ticket/t)
+ if(!user?.prefs || !(t in user.prefs.owned_tickets))
+ return FALSE
+
+ if(!t.use(user))
+ return FALSE
+
+ user.prefs.ticket_history += list(list(
+ "event" = "used",
+ "description" = "used [t.name], [t.ticket_type][t.details()]",
+ "timestamp" = time2text(world.realtime, "YYYY-MM-DD hh:mm:ss"),
+ "ticket_id" = t.ticket_id,
+ "name" = t.name,
+ "type" = t.ticket_type,
+ ))
+ user.prefs.owned_tickets -= t
+ user.prefs.save_preferences()
+ user.prefs.save_character()
+ log_game("TICKETS: [user.ckey] used ticket '[t.name]' ([t.ticket_id]).")
+ return TRUE
diff --git a/code/datums/shop/tickets/__trade.dm b/code/datums/shop/tickets/__trade.dm
new file mode 100644
index 00000000000..ec5c5c6a061
--- /dev/null
+++ b/code/datums/shop/tickets/__trade.dm
@@ -0,0 +1,375 @@
+GLOBAL_DATUM_INIT(ticket_trade_manager, /datum/ticket_trade_manager, new)
+#define TICKET_TRADES_FILE "data/ticket_trades.sav"
+
+/datum/ticket_trade
+ var/trade_id
+ var/from_ckey
+ var/to_ckey
+ var/list/offered_ticket_ids = list()
+ var/list/offered_ticket_names = list()
+ var/list/requested_ticket_ids = list()
+ var/list/requested_ticket_names = list()
+ var/created_at
+ var/cancel_requested_at = null
+
+/datum/ticket_trade_manager
+ var/list/pending = list()
+
+/datum/ticket_trade_manager/New()
+ . = ..()
+ load_pending()
+
+/// Returns list of assoc-lists for a ckey, regardless of online status.
+/datum/ticket_trade_manager/proc/get_raw_ticket_list(target_ckey, client/online_client)
+ if(online_client?.prefs)
+ var/list/result = list()
+ for(var/datum/ticket/t in online_client.prefs.owned_tickets)
+ result += list(t.to_list())
+ return result
+ return get_raw_ticket_list_offline(target_ckey)
+
+/datum/ticket_trade_manager/proc/get_raw_ticket_list_offline(target_ckey)
+ var/target_file = file("data/player_saves/[target_ckey[1]]/[target_ckey]/preferences.sav")
+ if(!fexists(target_file))
+ return null
+ var/savefile/S = new(target_file)
+ var/list/raw
+ S["owned_tickets"] >> raw
+ return islist(raw) ? raw : list()
+
+/// Returns list of ticket_ids that from_ckey has locked in outgoing trades.
+/datum/ticket_trade_manager/proc/get_locked_ids_for(from_ckey)
+ var/list/locked = list()
+ for(var/datum/ticket_trade/tr in pending)
+ if(tr.from_ckey == from_ckey)
+ for(var/id in tr.offered_ticket_ids)
+ locked += id
+ return locked
+
+/// Edit an offline player's savefile atomically.
+/datum/ticket_trade_manager/proc/savefile_remove_and_add_tickets(
+ target_ckey,
+ list/ids_to_remove,
+ list/datums_to_add,
+ partner_ckey,
+ timestamp,
+ list/removed_names,
+ list/added_names,
+)
+ var/target_file = file("data/player_saves/[target_ckey[1]]/[target_ckey]/preferences.sav")
+ if(!fexists(target_file))
+ return
+ var/savefile/S = new(target_file)
+ var/list/raw
+ S["owned_tickets"] >> raw
+ if(!islist(raw))
+ raw = list()
+
+ var/list/clean = list()
+ for(var/list/entry in raw)
+ if(!(entry["ticket_id"] in ids_to_remove))
+ clean += list(entry)
+ for(var/datum/ticket/t in datums_to_add)
+ clean += list(t.to_list())
+ S["owned_tickets"] << clean
+
+ var/list/raw_hist
+ S["ticket_history"] >> raw_hist
+ if(!islist(raw_hist))
+ raw_hist = list()
+ if(length(ids_to_remove))
+ raw_hist += list(list(
+ "event" = "traded_away",
+ "timestamp" = timestamp,
+ "ticket_ids"= ids_to_remove.Join(","),
+ "names" = removed_names.Join(", "),
+ "partner" = partner_ckey,
+ ))
+ if(length(datums_to_add))
+ raw_hist += list(list(
+ "event" = "traded_received",
+ "timestamp" = timestamp,
+ "ticket_ids" = "",
+ "names" = added_names.Join(", "),
+ "partner" = partner_ckey,
+ ))
+ S["ticket_history"] << raw_hist
+
+/datum/ticket_trade_manager/proc/offer_trade(client/from_client, to_ckey, list/offered_ids, list/requested_ids)
+ if(!from_client?.prefs)
+ return FALSE
+ if(!islist(offered_ids)) offered_ids = list()
+ if(!islist(requested_ids)) requested_ids = list()
+ if(!length(offered_ids) && !length(requested_ids))
+ to_chat(from_client, span_warning("A trade must include at least one ticket on either side."))
+ return FALSE
+
+ // Validate offered tickets exist and aren't locked
+ var/list/locked_ids = get_locked_ids_for(from_client.ckey)
+ var/list/offered_datums = list()
+ for(var/id in offered_ids)
+ var/datum/ticket/t = find_ticket_in_prefs(from_client.prefs, id)
+ if(!t)
+ to_chat(from_client, span_warning("You no longer have ticket [id]."))
+ return FALSE
+ if(id in locked_ids)
+ to_chat(from_client, span_warning("Ticket '[t.name]' is already in a pending trade."))
+ return FALSE
+ offered_datums += t
+
+ // Validate recipient savefile exists
+ var/client/to_client = GLOB.directory[to_ckey]
+ if(!to_client)
+ var/target_file = file("data/player_saves/[to_ckey[1]]/[to_ckey]/preferences.sav")
+ if(!fexists(target_file))
+ to_chat(from_client, span_warning("No player with ckey '[to_ckey]' found."))
+ return FALSE
+
+ var/list/offered_names = list()
+ for(var/datum/ticket/t in offered_datums)
+ offered_names += t.name
+
+ var/list/requested_names = list()
+ if(length(requested_ids))
+ var/list/their_raw = get_raw_ticket_list(to_ckey, to_client)
+ for(var/id in requested_ids)
+ var/found_name = null
+ for(var/list/entry in their_raw)
+ if(entry["ticket_id"] == id)
+ found_name = entry["name"]
+ break
+ if(!found_name)
+ to_chat(from_client, span_warning("Could not find requested ticket [id] in [to_ckey]'s inventory."))
+ return FALSE
+ requested_names += found_name
+
+ var/datum/ticket_trade/trade = new
+ trade.trade_id = generate_ticket_id()
+ trade.from_ckey = from_client.ckey
+ trade.to_ckey = to_ckey
+ trade.offered_ticket_ids = offered_ids.Copy()
+ trade.offered_ticket_names = offered_names
+ trade.requested_ticket_ids = requested_ids.Copy()
+ trade.requested_ticket_names = requested_names
+ trade.created_at = world.time
+
+ pending += trade
+ save_pending()
+
+ if(to_client)
+ var/list/offer_summary = offered_names.len ? offered_names : list("nothing")
+ var/list/want_summary = requested_names.len ? requested_names : list("nothing")
+ to_chat(to_client, span_notice( \
+ "[from_client.ckey] has sent you a trade offer.\n \
+ They offer: [english_list(offer_summary)].\n \
+ They want: [english_list(want_summary)].\n \
+ Check the Triumph Shop → Tickets tab." \
+ ))
+
+ log_game("TICKETS: [from_client.ckey] offered trade [trade.trade_id] to [to_ckey]. \
+ Offering: [english_list(offered_names)]. \
+ Requesting: [english_list(requested_names.len ? requested_names : list("nothing"))].")
+ return TRUE
+
+/datum/ticket_trade_manager/proc/accept_trade(client/accepting_client, trade_id)
+ if(!accepting_client)
+ return FALSE
+
+ var/datum/ticket_trade/trade = find_trade(trade_id)
+ if(!trade)
+ to_chat(accepting_client, span_warning("That trade no longer exists."))
+ return FALSE
+ if(trade.to_ckey != accepting_client.ckey)
+ to_chat(accepting_client, span_warning("That trade is not addressed to you."))
+ return FALSE
+ if(trade.cancel_requested_at && (world.time - trade.cancel_requested_at) < (TICKET_TRADE_CANCEL_LOCK * 10))
+ to_chat(accepting_client, span_warning("The sender is cancelling this trade, please wait a moment."))
+ return FALSE
+
+ var/timestamp = time2text(world.realtime, "YYYY-MM-DD hh:mm:ss")
+ var/client/from_client = GLOB.directory[trade.from_ckey]
+
+ // Validate offered tickets still exist in sender's inventory
+ var/list/offered_datums = list()
+ if(from_client?.prefs)
+ for(var/id in trade.offered_ticket_ids)
+ var/datum/ticket/t = find_ticket_in_prefs(from_client.prefs, id)
+ if(!t)
+ to_chat(accepting_client, span_warning("The sender no longer has one of the offered tickets, trade cancelled."))
+ pending -= trade
+ save_pending()
+ return FALSE
+ offered_datums += t
+ else
+ var/list/sender_raw = get_raw_ticket_list_offline(trade.from_ckey)
+ if(isnull(sender_raw))
+ to_chat(accepting_client, span_warning("Could not read the sender's save, trade cancelled."))
+ pending -= trade
+ save_pending()
+ return FALSE
+ for(var/id in trade.offered_ticket_ids)
+ var/found = FALSE
+ for(var/list/entry in sender_raw)
+ if(entry["ticket_id"] == id)
+ found = TRUE
+ break
+ if(!found)
+ to_chat(accepting_client, span_warning("The sender no longer has one of the offered tickets, trade cancelled."))
+ pending -= trade
+ save_pending()
+ return FALSE
+
+ // Validate requested tickets still in recipient's inventory
+ var/list/requested_datums = list()
+ for(var/id in trade.requested_ticket_ids)
+ var/datum/ticket/t = find_ticket_in_prefs(accepting_client.prefs, id)
+ if(!t)
+ to_chat(accepting_client, span_warning("You no longer have one of the requested tickets ([id])."))
+ return FALSE
+ requested_datums += t
+
+ if(from_client?.prefs)
+ for(var/datum/ticket/t in offered_datums)
+ from_client.prefs.owned_tickets -= t
+ from_client.prefs.ticket_history += list(list(
+ "event" = "traded_away",
+ "timestamp" = timestamp,
+ "ticket_ids" = trade.offered_ticket_ids.Join(","),
+ "names" = trade.offered_ticket_names.Join(", "),
+ "received_ids" = trade.requested_ticket_ids.Join(","),
+ "received_names" = trade.requested_ticket_names.Join(", "),
+ "partner" = accepting_client.ckey,
+ ))
+ for(var/datum/ticket/t in requested_datums)
+ from_client.prefs.owned_tickets += t
+ from_client.prefs.ticket_history += list(list(
+ "event" = "traded_received",
+ "timestamp" = timestamp,
+ "ticket_ids" = trade.requested_ticket_ids.Join(","),
+ "names" = trade.requested_ticket_names.Join(", "),
+ "partner" = accepting_client.ckey,
+ ))
+ from_client.prefs.save_character()
+ else
+ savefile_remove_and_add_tickets(
+ trade.from_ckey,
+ trade.offered_ticket_ids,
+ requested_datums,
+ accepting_client.ckey,
+ timestamp,
+ trade.offered_ticket_names,
+ trade.requested_ticket_names,
+ )
+
+ for(var/datum/ticket/t in requested_datums)
+ accepting_client.prefs.owned_tickets -= t
+ for(var/datum/ticket/t in offered_datums)
+ accepting_client.prefs.owned_tickets += t
+ accepting_client.prefs.ticket_history += list(list(
+ "event" = "traded_received",
+ "timestamp" = timestamp,
+ "ticket_ids" = trade.offered_ticket_ids.Join(","),
+ "names" = trade.offered_ticket_names.Join(", "),
+ "partner" = trade.from_ckey,
+ ))
+ if(length(trade.requested_ticket_ids))
+ accepting_client.prefs.ticket_history += list(list(
+ "event" = "traded_away",
+ "timestamp" = timestamp,
+ "ticket_ids" = trade.requested_ticket_ids.Join(","),
+ "names" = trade.requested_ticket_names.Join(", "),
+ "partner" = trade.from_ckey,
+ ))
+ accepting_client.prefs.save_character()
+
+ pending -= trade
+ save_pending()
+
+ to_chat(accepting_client, span_notice("Trade accepted!"))
+ if(from_client)
+ to_chat(from_client, span_notice("[accepting_client.ckey] accepted your trade offer."))
+
+ log_game("TICKETS: [accepting_client.ckey] accepted trade [trade.trade_id] from [trade.from_ckey].")
+ return TRUE
+
+/datum/ticket_trade_manager/proc/cancel_trade(client/cancelling_client, trade_id)
+ if(!cancelling_client)
+ return FALSE
+
+ var/datum/ticket_trade/trade = find_trade(trade_id)
+ if(!trade)
+ to_chat(cancelling_client, span_warning("That trade no longer exists."))
+ return FALSE
+ if(trade.from_ckey != cancelling_client.ckey)
+ to_chat(cancelling_client, span_warning("You can only cancel your own outgoing trades."))
+ return FALSE
+ if(trade.cancel_requested_at)
+ to_chat(cancelling_client, span_warning("Cancel already in progress."))
+ return FALSE
+
+ trade.cancel_requested_at = world.time
+ to_chat(cancelling_client, span_notice("Cancel requested. Processing in [TICKET_TRADE_CANCEL_LOCK] seconds..."))
+
+ spawn(TICKET_TRADE_CANCEL_LOCK SECONDS)
+ if(!(trade in pending))
+ return
+ pending -= trade
+ save_pending()
+ to_chat(cancelling_client, span_notice("Trade [trade.trade_id] cancelled."))
+ var/client/to_client = GLOB.directory[trade.to_ckey]
+ if(to_client)
+ to_chat(to_client, span_warning("Trade offer from [trade.from_ckey] was cancelled."))
+ log_game("TICKETS: [cancelling_client.ckey] cancelled trade [trade.trade_id].")
+
+ return TRUE
+
+/datum/ticket_trade_manager/proc/find_trade(trade_id)
+ for(var/datum/ticket_trade/tr in pending)
+ if(tr.trade_id == trade_id)
+ return tr
+ return null
+
+/// Look up any player's ticket list for the trade UI.
+/datum/ticket_trade_manager/proc/lookup_inventory(target_ckey)
+ var/client/C = GLOB.directory[target_ckey]
+ return get_raw_ticket_list(target_ckey, C)
+
+/datum/ticket_trade_manager/proc/save_pending()
+ var/savefile/S = new(TICKET_TRADES_FILE)
+ var/list/serialized = list()
+ for(var/datum/ticket_trade/tr in pending)
+ serialized += list(list(
+ "trade_id" = tr.trade_id,
+ "from_ckey" = tr.from_ckey,
+ "to_ckey" = tr.to_ckey,
+ "offered_ticket_ids" = tr.offered_ticket_ids.Join(","),
+ "offered_ticket_names" = tr.offered_ticket_names.Join(","),
+ "requested_ticket_ids" = tr.requested_ticket_ids.Join(","),
+ "requested_ticket_names"= tr.requested_ticket_names.Join(","),
+ "created_at" = tr.created_at,
+ "cancel_requested_at" = tr.cancel_requested_at,
+ ))
+ S["pending"] << serialized
+
+/datum/ticket_trade_manager/proc/load_pending()
+ if(!fexists(TICKET_TRADES_FILE))
+ return
+ var/savefile/S = new(TICKET_TRADES_FILE)
+ var/list/raw
+ S["pending"] >> raw
+ if(!islist(raw))
+ return
+ for(var/list/entry in raw)
+ var/datum/ticket_trade/tr = new
+ tr.trade_id = entry["trade_id"]
+ tr.from_ckey = entry["from_ckey"]
+ tr.to_ckey = entry["to_ckey"]
+ tr.offered_ticket_ids = entry["offered_ticket_ids"] != "" ? splittext(entry["offered_ticket_ids"], ",") : list()
+ tr.offered_ticket_names = entry["offered_ticket_names"] != "" ? splittext(entry["offered_ticket_names"], ",") : list()
+ tr.requested_ticket_ids = entry["requested_ticket_ids"] != "" ? splittext(entry["requested_ticket_ids"], ",") : list()
+ tr.requested_ticket_names= entry["requested_ticket_names"]!= "" ? splittext(entry["requested_ticket_names"],",") : list()
+ tr.created_at = entry["created_at"]
+ tr.cancel_requested_at = null
+ pending += tr
+
+#undef TICKET_TRADES_FILE
diff --git a/code/datums/shop/tickets/_base.dm b/code/datums/shop/tickets/_base.dm
new file mode 100644
index 00000000000..c204b709f9f
--- /dev/null
+++ b/code/datums/shop/tickets/_base.dm
@@ -0,0 +1,68 @@
+
+/datum/ticket
+ /// Unique ID, generated at grant time, never reused
+ var/ticket_id
+ /// Human-readable name shown in UI
+ var/name
+ /// Longer description shown in trade / use panels
+ var/description
+ /// Metadata
+ var/granted_by
+ var/granted_at
+ var/grant_reason
+
+ /// Must match a TICKET_TYPE_* constant.
+ /// Subtypes should override this with a fixed value.
+ var/ticket_type = TICKET_TYPE_UNKNOWN
+
+// Serialise to an assoc-list that is stored in the savefile
+// and sent verbatim to the UI. Subtypes call ..() then add
+// their own payload keys so the shape stays compatible.
+/datum/ticket/proc/to_list()
+ return list(
+ "ticket_id" = ticket_id,
+ "ticket_type" = ticket_type,
+ "name" = name,
+ "description" = description,
+ "loadout_item_path" = null,
+ "special_trait_path"= null,
+ "job_boost_job" = null,
+ // metadata
+ "granted_by" = granted_by,
+ "granted_at" = granted_at,
+ "grant_reason" = grant_reason,
+ )
+
+///this is a string of details we pass back for history purposes
+/datum/ticket/proc/details()
+ return
+
+// Reconstruct from a savefile assoc-list.
+// The manager calls the right subtype constructor before calling
+// this, so subtypes only need to ..() and then pull their keys.
+/datum/ticket/proc/from_list(list/L)
+ ticket_id = L["ticket_id"]
+ ticket_type = L["ticket_type"]
+ name = L["name"]
+ description = L["description"]
+ granted_by = L["granted_by"]
+ granted_at = L["granted_at"]
+ grant_reason= L["grant_reason"]
+
+// Attempt to use this ticket for the given client.
+// Returns TRUE on success, FALSE on failure (with a to_chat message).
+// Subtypes implement the actual effect.
+/datum/ticket/proc/use(client/user)
+ return FALSE
+
+// Enrich a ui_data ticket list-entry with sprite/display info.
+// Subtypes that have associated assets override this.
+/datum/ticket/proc/enrich_ui_entry(list/entry)
+ // Subtypes override to fill these four keys.
+ // The UI renders exactly these and nothing else.
+ entry["ui_icon"] = null // icon file, or null for font-awesome fallback
+ entry["ui_icon_state"] = null
+ entry["ui_fa_icon"] = "ticket-alt" // font-awesome name
+ entry["ui_color"] = "#9e9e9e" // hex accent colour
+ entry["ui_type_label"] = "Ticket" // short badge text
+ entry["ui_grant_summary"] = name // one-line "what this does" string
diff --git a/code/datums/shop/tickets/_preference_save.dm b/code/datums/shop/tickets/_preference_save.dm
new file mode 100644
index 00000000000..58fcfe8b5d6
--- /dev/null
+++ b/code/datums/shop/tickets/_preference_save.dm
@@ -0,0 +1,23 @@
+/datum/preferences/proc/save_tickets(savefile/S)
+ if(!S)
+ return
+ var/list/serial = list()
+ for(var/datum/ticket/t in owned_tickets)
+ serial += list(t.to_list())
+ S["owned_tickets"] << serial
+ S["ticket_history"] << ticket_history
+
+/datum/preferences/proc/load_tickets(savefile/S)
+ if(!S)
+ return
+ var/list/raw
+ S["owned_tickets"] >> raw
+ owned_tickets = list()
+ if(islist(raw))
+ for(var/list/entry in raw)
+ var/datum/ticket/t = ticket_from_list(entry)
+ if(t)
+ owned_tickets += t
+ var/list/raw_hist
+ S["ticket_history"] >> raw_hist
+ ticket_history = islist(raw_hist) ? raw_hist : list()
diff --git a/code/datums/shop/tickets/admin_panel.dm b/code/datums/shop/tickets/admin_panel.dm
new file mode 100644
index 00000000000..9e282cac01c
--- /dev/null
+++ b/code/datums/shop/tickets/admin_panel.dm
@@ -0,0 +1,208 @@
+/datum/admin_ticket_granter
+ var/client/admin_client
+
+/datum/admin_ticket_granter/New(client/C)
+ admin_client = C
+
+/datum/admin_ticket_granter/ui_interact(mob/user, datum/tgui/ui)
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new /datum/tgui(user, src, "AdminTicketGranter", "Admin Ticket Granter")
+ ui.open()
+
+/datum/admin_ticket_granter/ui_state(mob/user)
+ return ADMIN_STATE(R_VAREDIT)
+
+/datum/admin_ticket_granter/ui_data(mob/user) //this is kinda weird, but I couldn't think of a way to do this that was "clean" for each subtype, maybe move this to the ticket datum itself?
+ var/list/type_schemas = list(
+ list(
+ "ticket_type"= TICKET_TYPE_LOADOUT,
+ "label"= "Loadout Item",
+ "fa_icon"= "box-open",
+ "color"= "#2196f3",
+ "fields" = list(
+ list("key" = "loadout_item_path", "label" = "Loadout Item Path",
+ "type" = "typepath","base" = "/datum/loadout_item",
+ "placeholder" = "/datum/loadout_item/...",
+ "required" = TRUE),
+ ),
+ ),
+ list(
+ "ticket_type"= TICKET_TYPE_SPECIAL,
+ "label"= "Special Trait",
+ "fa_icon"= "dice",
+ "color"= "#9c27b0",
+ "fields" = list(
+ list("key" = "special_trait_path", "label" = "Special Trait Path",
+ "type" = "typepath","base" = "/datum/special_trait",
+ "placeholder" = "/datum/special_trait/...",
+ "required" = TRUE),
+ ),
+ ),
+ list(
+ "ticket_type"= TICKET_TYPE_JOB_BOOST,
+ "label"= "Job Boost",
+ "fa_icon"= "briefcase",
+ "color"= "#ff9800",
+ "fields" = list(
+ list("key" = "job_boost_job","label" = "Job / Whitelist (blank = global)",
+ "type" = "text",
+ "placeholder" = "e.g. Artificer, or leave blank"),
+ list("key" = "job_boost_typepath", "label" = "Boost Type Path",
+ "type" = "typepath","base" = "/datum/job_priority_boost",
+ "placeholder" = "/datum/job_priority_boost/minor",
+ "required" = TRUE),
+ ),
+ ),
+ list(
+ "ticket_type"= TICKET_TYPE_TRIUMPH,
+ "label"= "Triumphs",
+ "fa_icon"= "trophy",
+ "color"= "#ffd700",
+ "fields" = list(
+ list("key" = "triumph_amount", "label" = "Amount",
+ "type" = "number", "min" = 1,
+ "placeholder" = "e.g. 100",
+ "required" = TRUE),
+ ),
+ ),
+ //here is how we do stuff
+ // list(
+ // "ticket_type" = TICKET_TYPE_WHATEVER,
+ // "label" = "Whatever",
+ // "fa_icon" = "star",
+ // "color" = "#e91e63",
+ // "fields"= list(
+ // list("key" = "whatever_field", "label" = "Field Label",
+ //"type" = "text", "required" = TRUE),
+ // ),
+ // ),
+ )
+
+ // Resolve all child typepaths so the UI can show a dropdown.
+ // We send them keyed by base path string.
+ var/list/typepath_options = list()
+ typepath_options["/datum/loadout_item"] = _collect_subtypes(/datum/loadout_item)
+ typepath_options["/datum/special_trait"]= _collect_subtypes(/datum/special_trait)
+ typepath_options["/datum/job_priority_boost"] = _collect_subtypes(/datum/job_priority_boost)
+
+ return list(
+ "type_schemas" = type_schemas,
+ "typepath_options" = typepath_options,
+ )
+
+/datum/admin_ticket_granter/proc/_collect_subtypes(base)
+ var/list/out = list()
+ for(var/path in subtypesof(base))
+ var/datum/D = path
+ if(initial(D.abstract_type) == path)
+ continue
+ out += "[path]"
+ return out
+
+/datum/admin_ticket_granter/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ if(.)
+ return
+
+ switch(action)
+ if("grant_ticket")
+ handle_grant(params, ui.user)
+ return TRUE
+
+/datum/admin_ticket_granter/proc/handle_grant(list/params, mob/admin_mob)
+ var/target_ckey = trim(params["target_ckey"])
+ var/ticket_type = params["ticket_type"]
+ var/t_name = trim(params["name"])
+ var/t_desc = trim(params["description"])
+ var/grant_reason = trim(params["grant_reason"])
+
+ if(!target_ckey || !ticket_type || !t_name)
+ to_chat(admin_mob, span_warning("TICKETS: Missing required fields (ckey, type, name)."))
+ return
+
+ var/subtype = ticket_type_to_path(ticket_type)
+ var/datum/ticket/t = new subtype()
+ t.ticket_id = generate_ticket_id()
+ t.name = t_name
+ t.description = t_desc
+ t.granted_by = admin_mob.ckey
+ t.granted_at = time2text(world.realtime, "YYYY-MM-DD hh:mm:ss")
+ t.grant_reason = grant_reason
+
+ switch(ticket_type)
+ if(TICKET_TYPE_LOADOUT)
+ var/datum/ticket/loadout/lt = t
+ lt.loadout_item_path = params["loadout_item_path"] ? text2path(params["loadout_item_path"]) : null
+ if(TICKET_TYPE_SPECIAL)
+ var/datum/ticket/special/st = t
+ st.special_trait_path = params["special_trait_path"] ? text2path(params["special_trait_path"]) : null
+ if(TICKET_TYPE_JOB_BOOST)
+ var/datum/ticket/job_boost/jt = t
+ jt.job_boost_job = params["job_boost_job"] || null
+ jt.boost_typepath = params["job_boost_typepath"] ? text2path(params["job_boost_typepath"]) : /datum/job_priority_boost/minor
+ if(TICKET_TYPE_TRIUMPH)
+ var/datum/ticket/triumph/tt = t
+ tt.triumph_amount = text2num(params["triumph_amount"])
+
+ deliver_ticket(t, target_ckey, admin_mob)
+
+/datum/admin_ticket_granter/proc/deliver_ticket(datum/ticket/t, target_ckey, mob/admin_mob)
+ var/client/C = GLOB.directory[target_ckey]
+ if(C?.prefs)
+ C.prefs.owned_tickets += t
+ C.prefs.ticket_history += list(list(
+ "event" = "granted",
+ "description" = "granted [t.name] ([t.ticket_type][t.details()]) by [admin_mob.ckey]. Reason: [t.grant_reason]",
+ "timestamp" = time2text(world.realtime, "YYYY-MM-DD hh:mm:ss"),
+ "ticket_id" = t.ticket_id,
+ "name" = t.name,
+ "type" = t.ticket_type,
+ ))
+ C.prefs.save_preferences()
+ to_chat(C, span_notice("You have received a ticket: [t.name]!"))
+ to_chat(admin_mob, span_notice("TICKETS: Granted '[t.name]' to [target_ckey] (online)."))
+ message_admins("[key_name(admin_mob)] granted ticket '[t.name]' (triumph) to [target_ckey].")
+ log_game("TICKETS: [admin_mob.ckey] granted '[t.name]' (triumph) to [target_ckey] via UI (online).")
+ return
+
+ var/target_file = file("data/player_saves/[target_ckey[1]]/[target_ckey]/preferences.sav")
+ if(!fexists(target_file))
+ to_chat(admin_mob, span_warning("TICKETS: No savefile found for [target_ckey]."))
+ return
+ var/savefile/S = new(target_file)
+
+ var/list/raw
+ S["owned_tickets"] >> raw
+ if(!islist(raw))
+ raw = list()
+ raw += list(t.to_list())
+ S["owned_tickets"] << raw
+
+ var/list/history
+ S["ticket_history"] >> history
+ if(!islist(history))
+ history = list()
+ history += list(list(
+ "event" = "granted",
+ "description" = "granted [t.name] ([t.ticket_type][t.details()]) by [admin_mob.ckey]. Reason: [t.grant_reason]",
+ "timestamp" = time2text(world.realtime, "YYYY-MM-DD hh:mm:ss"),
+ "ticket_id" = t.ticket_id,
+ "name" = t.name,
+ "type" = t.ticket_type,
+ ))
+ S["ticket_history"] << history
+
+ to_chat(admin_mob, span_notice("TICKETS: Granted '[t.name]' to [target_ckey] (offline)."))
+ message_admins("[key_name(admin_mob)] granted ticket '[t.name]' (triumph) to [target_ckey].")
+ log_game("TICKETS: [admin_mob.ckey] granted '[t.name]' (triumph) to [target_ckey] via UI (offline).")
+
+/client/proc/open_ticket_granter()
+ set name = "Grant Ticket"
+ set category = "GameMaster.Fun"
+
+ if(!check_rights(R_ADMIN))
+ return
+
+ var/datum/admin_ticket_granter/granter = new(src)
+ granter.ui_interact(mob)
diff --git a/code/datums/shop/tickets/job_boost.dm b/code/datums/shop/tickets/job_boost.dm
new file mode 100644
index 00000000000..b82597498ca
--- /dev/null
+++ b/code/datums/shop/tickets/job_boost.dm
@@ -0,0 +1,52 @@
+/datum/ticket/job_boost
+ ticket_type = TICKET_TYPE_JOB_BOOST
+ var/job_boost_job
+ var/boost_typepath = /datum/job_priority_boost/minor
+
+/datum/ticket/job_boost/to_list()
+ var/list/L = ..()
+ L["job_boost_job"] = job_boost_job
+ L["boost_typepath"] = ispath(boost_typepath) ? "[boost_typepath]" : null
+ return L
+
+/datum/ticket/job_boost/details()
+ var/datum/job_priority_boost/boost = boost_typepath
+ return ", [job_boost_job] - [initial(boost.name)]"
+
+/datum/ticket/job_boost/from_list(list/L)
+ ..()
+ job_boost_job = L["job_boost_job"]
+ if(L["boost_typepath"])
+ var/path = text2path(L["boost_typepath"])
+ if(ispath(path, /datum/job_priority_boost))
+ boost_typepath = path
+
+/datum/ticket/job_boost/enrich_ui_entry(list/entry)
+ entry["ui_fa_icon"] = "briefcase"
+ entry["ui_color"] = "#ff9800"
+ entry["ui_type_label"] = "Job Boost"
+ var/datum/job_priority_boost/preview = new boost_typepath()
+ var/target = job_boost_job ? job_boost_job : "all jobs"
+ entry["ui_grant_summary"] = "Job boost ([preview.boost_amount]x): [target]"
+ qdel(preview)
+
+/datum/ticket/job_boost/use(client/user)
+ if(!ispath(boost_typepath, /datum/job_priority_boost))
+ return FALSE
+
+ var/datum/job_priority_boost/boost = new boost_typepath()
+ // Only set applicable_jobs if this is a targeted boost
+ // If job_boost_job is null and the subtype has no applicable_jobs, it's a global boost
+ if(job_boost_job && !length(boost.applicable_jobs))
+ boost.applicable_jobs = list(job_boost_job)
+
+ if(!islist(user.job_priority_boosts))
+ user.job_priority_boosts = list()
+ user.job_priority_boosts += boost
+
+ spawn(1)
+ SSjob.save_player_boosts(user.ckey)
+
+ var/boost_target = job_boost_job ? " for [job_boost_job]" : " (all jobs)"
+ to_chat(user, span_notice("Ticket used! Job boost applied: [boost.name][boost_target]!"))
+ return TRUE
diff --git a/code/datums/shop/tickets/loadout.dm b/code/datums/shop/tickets/loadout.dm
new file mode 100644
index 00000000000..e813184e28c
--- /dev/null
+++ b/code/datums/shop/tickets/loadout.dm
@@ -0,0 +1,40 @@
+
+/datum/ticket/loadout
+ ticket_type = TICKET_TYPE_LOADOUT
+ var/datum/loadout_item/loadout_item_path
+
+/datum/ticket/loadout/to_list()
+ var/list/L = ..()
+ L["loadout_item_path"] = loadout_item_path
+ return L
+
+/datum/ticket/loadout/details()
+ return ", [initial(loadout_item_path.name)]"
+
+/datum/ticket/loadout/from_list(list/L)
+ ..()
+ loadout_item_path = L["loadout_item_path"]
+
+/datum/ticket/loadout/use(client/user)
+ if(!loadout_item_path)
+ return FALSE
+ var/datum/loadout_item/item = GLOB.loadout_items[loadout_item_path]
+ if(!item)
+ to_chat(user, span_warning("That loadout item no longer exists."))
+ return FALSE
+ if(loadout_item_path in user.prefs.owned_loadout_items)
+ to_chat(user, span_warning("You already own that loadout item."))
+ return FALSE
+ user.prefs.owned_loadout_items += loadout_item_path
+ to_chat(user, span_notice("Ticket used! Permanently unlocked: [item.name]!"))
+ return TRUE
+
+/datum/ticket/loadout/enrich_ui_entry(list/entry)
+ entry["ui_icon"] = loadout_item_path ? initial(loadout_item_path.ui_icon) : null
+ entry["ui_icon_state"] = loadout_item_path ? initial(loadout_item_path.ui_icon_state) : null
+ entry["item_name"] = loadout_item_path ? initial(loadout_item_path.name) : null
+ entry["ui_fa_icon"] = "box-open"
+ entry["ui_color"] = "#2196f3"
+ entry["ui_type_label"] = "Loadout Item"
+ entry["ui_grant_summary"] = loadout_item_path ? "Grants: [initial(loadout_item_path.name)]" : "Grants a loadout item"
+
diff --git a/code/datums/shop/tickets/special.dm b/code/datums/shop/tickets/special.dm
new file mode 100644
index 00000000000..242141d7b84
--- /dev/null
+++ b/code/datums/shop/tickets/special.dm
@@ -0,0 +1,35 @@
+
+/datum/ticket/special
+ ticket_type = TICKET_TYPE_SPECIAL
+ var/datum/special_trait/special_trait_path
+
+/datum/ticket/special/to_list()
+ var/list/L = ..()
+ L["special_trait_path"] = special_trait_path
+ return L
+
+/datum/ticket/special/details()
+ return ", [initial(special_trait_path.name)]"
+
+/datum/ticket/special/from_list(list/L)
+ ..()
+ special_trait_path = L["special_trait_path"]
+
+/datum/ticket/special/enrich_ui_entry(list/entry)
+ entry["ui_icon"] = null
+ entry["ui_icon_state"] = null
+ entry["ui_fa_icon"] = "dice"
+ entry["ui_color"] = "#9c27b0"
+ entry["ui_type_label"] = "Special Trait"
+ entry["ui_grant_summary"] = special_trait_path ? "Queues special: [initial(special_trait_path.name)]" : "Queues a special trait"
+
+/datum/ticket/special/use(client/user)
+ if(!special_trait_path)
+ return FALSE
+ if(user.prefs.next_special_trait)
+ to_chat(user, span_warning("You already have a special trait queued. Clear it first."))
+ return FALSE
+ user.prefs.next_special_trait = special_trait_path
+ var/datum/special_trait/trait = SPECIAL_TRAIT(special_trait_path)
+ to_chat(user, span_notice("Ticket used! Special trait queued: [trait?.name]!"))
+ return TRUE
diff --git a/code/datums/shop/tickets/triumph.dm b/code/datums/shop/tickets/triumph.dm
new file mode 100644
index 00000000000..6280752948b
--- /dev/null
+++ b/code/datums/shop/tickets/triumph.dm
@@ -0,0 +1,29 @@
+/datum/ticket/triumph
+ ticket_type = TICKET_TYPE_TRIUMPH
+ var/triumph_amount
+
+/datum/ticket/triumph/to_list()
+ var/list/L = ..()
+ L["triumph_amount"] = triumph_amount
+ return L
+
+/datum/ticket/triumph/details()
+ return ", [triumph_amount] triumphs"
+
+/datum/ticket/triumph/from_list(list/L)
+ ..()
+ triumph_amount = L["triumph_amount"]
+
+/datum/ticket/triumph/use(client/user)
+ if(!triumph_amount || triumph_amount <= 0)
+ return FALSE
+ adjust_triumphs(user, triumph_amount, FALSE, "Ticket Used", override_bonus = TRUE)
+ return TRUE
+
+/datum/ticket/triumph/enrich_ui_entry(list/entry)
+ entry["ui_icon"] = null
+ entry["ui_icon_state"] = null
+ entry["ui_fa_icon"] = "trophy"
+ entry["ui_color"] = "#ffd700"
+ entry["ui_type_label"] = "Triumphs"
+ entry["ui_grant_summary"] = triumph_amount > 0 ? "Grants: [triumph_amount] triumphs" : "Grants triumphs"
diff --git a/code/game/objects/items.dm b/code/game/objects/items.dm
index f349edcfc0c..888dceefad7 100644
--- a/code/game/objects/items.dm
+++ b/code/game/objects/items.dm
@@ -42,6 +42,8 @@ GLOBAL_DATUM_INIT(fire_overlay, /mutable_appearance, mutable_appearance('icons/e
obj_flags = NONE
var/item_flags = NONE
+ ///do we have a child icon?
+ var/childcore = FALSE
var/list/hitsound
///Played when the item is used, for example tools
diff --git a/code/game/objects/items/bouquet.dm b/code/game/objects/items/bouquet.dm
index 56341ae8a68..e741232b495 100644
--- a/code/game/objects/items/bouquet.dm
+++ b/code/game/objects/items/bouquet.dm
@@ -65,3 +65,20 @@
desc = "A crown of salvia, often worn by consorts and princesses of particularly joyful royal courts"
item_state = "salvia_crown"
icon_state = "salvia_crown"
+
+/obj/item/clothing/head/flowercrown/matricaria
+ name = "crown of matricaria"
+ item_state = "matricaria_crown"
+ icon_state = "matricaria_crown"
+
+/obj/item/clothing/head/flowercrown/calendula
+ name = "crown of calendula"
+ item_state = "calendula_crown"
+ icon_state = "calendula_crown"
+
+/obj/item/clothing/head/flowercrown/manabloom
+ name = "crown of manabloom"
+ desc = "A crown formed of manabloom flowers. Often worn by those who find themselves in need of a \
+ deeper attunement to the arcyne; a favourite of young apprentices and faltering old masters both."
+ item_state = "manabloom_crown"
+ icon_state = "manabloom_crown"
diff --git a/code/game/objects/items/gems.dm b/code/game/objects/items/gems.dm
index b16b5c830eb..a5e5b5b74a3 100644
--- a/code/game/objects/items/gems.dm
+++ b/code/game/objects/items/gems.dm
@@ -169,6 +169,25 @@
if(GEM_PERFECT) return 1.6
return 1.0
+/obj/item/gem/blood_diamond
+ name = "glut"
+ icon_state = "blood"
+ sellprice = 188
+ desc = "Something about this gem just doesn't sit right with you. Holding it makes the blood leave your fingertips."
+ smeltresult = /obj/item/ingot/component/glutcrystal
+ dropshrink = 1
+
+/obj/item/gem/blood_diamond/examine(mob/user)
+ . = ..()
+ if(ishuman(user))
+ var/mob/living/carbon/human/H = user
+ if(H.patron.type == /datum/patron/inhumen/graggar)
+ . += span_danger("You know this gem well. They are born out of great violence, but only if it involves the mightiest of warriors. Fleshcrafting it with the meat of whatever warrior birthed this gem will allow me to summon another of their kind into this world.")
+
+/obj/item/gem/blood_diamond/Initialize()
+ . = ..()
+ add_filter(FORCE_FILTER, 2, list("type" = "outline", "color" = "#8B0000", "alpha" = 188, "size" = 1))
+
/obj/item/gem/green
name = "gemerald"
desc = "Glints with verdant brilliance."
diff --git a/code/game/objects/items/ore.dm b/code/game/objects/items/ore.dm
index 533b0b334f2..45e30bfed0e 100644
--- a/code/game/objects/items/ore.dm
+++ b/code/game/objects/items/ore.dm
@@ -329,3 +329,179 @@
melting_material = /datum/material/steel
sellprice = 40
item_weight = 5.5 KILOGRAMS
+
+/obj/item/ingot/aalloy
+ name = "decrepit ingot"
+ desc = "A decrepit slab of wrought bronze, uncomfortably cold to the touch. The gales shift into whispers, when held for long enough; 'progress commands sacrifice'."
+ icon_state = "ingotancient"
+ smeltresult = /obj/item/ingot/aaslag
+ melting_material = /datum/material/ancient_alloy
+ color = "#bb9696"
+ sellprice = 33
+ item_weight = 5.5 KILOGRAMS
+
+/obj/item/ingot/purifiedaalloy
+ name = "ancient alloy"
+ desc = "An ingot of polished gilbranze, teeming with forbidden knowledge. The reflection on its surface isn't yours; it smiles back at you with eternal malice."
+ icon_state = "ingotancient"
+ smeltresult = /obj/item/ingot/purifiedaalloy
+ melting_material = /datum/material/purified_alloy
+ sellprice = 111
+ item_weight = 5.5 KILOGRAMS
+
+/obj/item/ingot/aaslag
+ name = "glimmering slag"
+ desc = "A mass of wrought bronze, rendered lame from the forge's heat. Sometimes, dead is better. Yet, perhaps alloying it in equal parts with another glimmering piece of ore could resurrect its secrets."
+ icon_state = "ancientslag"
+ smeltresult = /obj/item/ingot/aaslag
+ melting_material = /datum/material/glimmering_slag
+ sellprice = 6
+ item_weight = 6.15 KILOGRAMS
+
+/obj/item/ingot/aaslag/Initialize()
+ . = ..()
+ add_filter(FORCE_FILTER, 2, list("type" = "outline", "color" = "#FF4500", "alpha" = 50, "size" = 1))
+
+//Anomalous Smeltings
+/obj/item/ingot/weeping
+ name = "enduring ingot"
+ desc = "A slab of metal, aged and bare. You finally know what it is, yet no word can be sired to describe it. '..none will ever know the greatest truths; of Aeon's grasp, of Adonai's presence, of Psydon's fate..' '..but, perhaps, that's for the better. The malaise is gone, but the evils of this world are still very real..' '..find a way to give the remains a new life; a new vessel that may yet make the followers of evil weep..'"
+ icon_state = "ingotsilv"
+ smeltresult = /obj/item/ingot/weeping
+ melting_material = /datum/material/weeping
+ color = "#CECA9C"
+ sellprice = 222
+ item_weight = 6.65 KILOGRAMS
+
+/obj/item/ingot/weeping/Initialize()
+ . = ..()
+ add_filter(FORCE_FILTER, 2, list("type" = "outline", "color" = "#8B0000", "alpha" = 100, "size" = 1))
+
+/obj/item/ingot/draconic
+ name = "draconic ingot"
+ desc = "A slab of obsidian, crackling with energy. Your fingers blister from the sheer heat, radiating off of its glassy surface. '..no man, be-they a saint or sinner, can truly withstand such power..' '..but, perhaps, you are different..' '..find a way to give the remains a new life; a new vessel that may yet make the followers of evil weep..'"
+ icon_state = "ingotblacksteel"
+ smeltresult = /obj/item/ingot/draconic
+ melting_material = /datum/material/draconic
+ color = "#70b8ff"
+ sellprice = 333
+ item_weight = 5.5 KILOGRAMS
+
+/obj/item/ingot/draconic/Initialize()
+ . = ..()
+ add_filter(FORCE_FILTER, 2, list("type" = "outline", "color" = "#FF4500", "alpha" = 100, "size" = 1))
+
+/obj/item/ingot/avantyne
+ name = "avantyne wafer"
+ desc = "This ingot, though borne of unholy circumstance, rumbles with otherworldly potential. Chiseled onto the darksteel is a forbidden iteration of the psycross; a foreboding sign for those who bow to lesser gods."
+ icon_state = "ingotavantyne"
+ smeltresult = null
+ sellprice = 130
+ smeltresult = /obj/item/ingot/avantyne
+ melting_material = /datum/material/avantyne
+
+/obj/item/ingot/ketryl
+ name = "ketryl ingot"
+ desc = "Named after its mythical status, this ingot is forged as per the dwarven standards etched in a small imprint on the ingot's surface. Ketryl is often folded in thin layers, stronger than steel, yet unusually light at the same time."
+ icon_state = "ingotketryl"
+ smeltresult = null
+ sellprice = 555
+ smeltresult = /obj/item/ingot/ketryl
+ melting_material = /datum/material/ketryl
+
+/obj/item/ingot/lithmyc
+ name = "lithmyc ingot"
+ desc = "A strange green ingot. It seems to be covered in an oily metal-liquid, though it refuses to leave the ingot-shape no matter how you much you try. No one in the region yet knows what the metal can be shaped into, as it's exceedingly stubborn. But, it sure seems priceless."
+ icon_state = "ingotlithmyc"
+ smeltresult = /obj/item/ingot/lithmyc
+ melting_material = /datum/material/lithmyc
+ sellprice = 444
+
+/obj/item/ingot/lithmyc/Initialize()
+ . = ..()
+ add_filter(FORCE_FILTER, 2, list("type" = "outline", "color" = "#A0E65C", "alpha" = 100, "size" = 1))
+
+
+/obj/item/ingot/component //Root. Don't use under most circumstances.
+ name = "substanceless presence"
+ desc = "Something that you were likely never meant to see. Pray to a higher presence for assistance, before rendering it asunder in the forge's flames once more."
+ icon_state = "oreada"
+ smeltresult = /obj/item/ingot/iron
+ melting_material = /datum/material/iron
+ sellprice = 1
+
+/obj/item/ingot/component/glutcrystal
+ name = "crystalline glut"
+ desc = "Fractal violence, gleaming with a crimson haze that beckons for its final purpose to be accomplished."
+ icon_state = "component_blood"
+ smeltresult = /obj/item/gem/blood_diamond //Ensures that it can be reused for any Glut-specific ritual, should one find this in its crystalline form.
+ sellprice = 33
+
+/obj/item/ingot/component/glutcrystal/examine(mob/user)
+ . = ..()
+ if(ishuman(user))
+ var/mob/living/carbon/human/H = user
+ if(H.patron.type == /datum/patron/inhumen/graggar)
+ . += span_danger("You know this gem well. They are born out of great violence, but only if it involves the mightiest of warriors. Fleshcrafting it with the meat of whatever warrior birthed this gem will allow me to summon another of their kind into this world. Melting away its crystalline shell is ideal, if you wish to ensure no chance for error while conducting such a ritual.")
+
+/obj/item/ingot/component/glutcrystal/Initialize()
+ . = ..()
+ add_filter(FORCE_FILTER, 2, list("type" = "outline", "color" = "#8B0000", "alpha" = 120, "size" = 1))
+
+/obj/item/ingot/component/heapofrawiron
+ name = "heap of raw iron"
+ desc = "A massive hunk, born from the incoherent fusion of molten iron. Chunks of ore-and-ingotry peak out from its jagged surface, yearning to be refined - be it into ingots, or something more purposeful."
+ icon_state = "component_berserkheap"
+ melting_material = /datum/material/iron
+ melt_amount = 300
+ sellprice = 44
+
+/obj/item/ingot/component/berserkswordblade
+ name = "blade of the berserkers sword"
+ desc = "A massive blade, forged from a raw heap of iron. The unique spike-styled tang seems to be longer than what'd be seen on most greatswords, stowable only by the innards of a fittingly large handle."
+ icon_state = "component_berserkblade"
+ melting_material = /datum/material/iron
+ melt_amount = 400
+ sellprice = 33
+
+/obj/item/ingot/component/berserkswordgrip
+ name = "handle of the berserkers sword"
+ desc = "A massive handle, assembled from the double-handed grip of an Executioner's Sword. The unique crescent-styled crossguard seems to have a slot, fittable only by the tang of a fittingly large blade."
+ icon_state = "component_berserkhandle"
+ sellprice = 33
+
+/obj/item/ingot/component/threadavantyne
+ name = "avantyne thread"
+ desc = "These strands, though borne of unholy circumstance, shimmer with otherworldly potential. Each wire of darksteel seem to twitch with vigor, whenever brought close to another alloy; like a parasite drawn to a host."
+ icon_state = "component_avantynethread"
+ sellprice = 66
+
+/obj/item/ingot/component/threadketryl
+ name = "ketryl thread"
+ desc = "Named after its mythical status, these glimmering strands are stronger than steel, yet unusually light at the same time."
+ icon_state = "component_ketrylthread"
+ sellprice = 111
+
+/obj/item/ingot/component/zizo
+ name = "avantyne fragment"
+ desc = "Whispering fragments of an otherworldly alloy. Power always comes at a price."
+ icon_state = "component_zizo"
+ dropshrink = 0.7
+
+/obj/item/ingot/component/graggar
+ name = "vicious fragment"
+ desc = "Bleeding fragments of an otherworldly alloy. Murder is nothing more than justice without arbitration."
+ icon_state = "component_graggar"
+ dropshrink = 0.7
+
+/obj/item/ingot/component/matthios
+ name = "gilded fragment"
+ desc = "Glimmering fragments of an otherworldly alloy. Wealth drags even the noblest souls down to perdition."
+ icon_state = "component_matthios"
+ dropshrink = 0.7
+
+/obj/item/ingot/component/baotha
+ name = "saccharine fragment"
+ desc = "Aromatic fragments of an otherworldly alloy. Despair is the gravest, most agonizing poison of them all."
+ icon_state = "component_baotha"
+ dropshrink = 0.7
diff --git a/code/game/objects/items/storage/backpack.dm b/code/game/objects/items/storage/backpack.dm
index ca61b5cec77..32955463c8b 100644
--- a/code/game/objects/items/storage/backpack.dm
+++ b/code/game/objects/items/storage/backpack.dm
@@ -18,6 +18,7 @@
slot_flags = ITEM_SLOT_BACK
resistance_flags = NONE
max_integrity = 300
+ childcore = TRUE
sewrepair = /datum/attribute/skill/craft/tanning/patching
dyeable = TRUE
diff --git a/code/game/objects/structures.dm b/code/game/objects/structures.dm
index 21d42647c4a..7d39cc2b113 100644
--- a/code/game/objects/structures.dm
+++ b/code/game/objects/structures.dm
@@ -179,3 +179,20 @@
crumpled_mob.Stun(1)
crumpled_mob.AdjustKnockdown(levels * 20)
crumpled_mob.take_overall_damage(impact_damage, damage_type = BCLASS_BLUNT)
+
+/obj/structure/proc/try_fetch_special_item(mob/user)
+ if(!user.mind && isliving(user))
+ return FALSE
+
+ if(!length(user.mind.special_items))
+ return FALSE
+ var/item = browser_input_list(user, "What will I take?", "STASH", user.mind.special_items)
+ if(item)
+ if(user.Adjacent(src))
+ if(user.mind.special_items[item])
+ var/path2item = user.mind.special_items[item]
+ user.mind.special_items -= item
+ var/obj/item/I = new path2item(user.loc)
+ apply_item_colors(I, user.mind)
+ user.put_in_hands(I)
+ return TRUE
diff --git a/code/game/objects/structures/fluff.dm b/code/game/objects/structures/fluff.dm
index 2499108d60a..16f550d8441 100644
--- a/code/game/objects/structures/fluff.dm
+++ b/code/game/objects/structures/fluff.dm
@@ -411,17 +411,8 @@
. = ..()
if(. == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN)
return
- if(user.mind && isliving(user))
- if(user.mind.special_items && user.mind.special_items.len)
- var/item = browser_input_list(user, "What will I take?", "STASH", user.mind.special_items)
- if(item)
- if(user.Adjacent(src))
- if(user.mind.special_items[item])
- var/path2item = user.mind.special_items[item]
- user.mind.special_items -= item
- var/obj/item/I = new path2item(user.loc)
- user.put_in_hands(I)
- return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
+ if(try_fetch_special_item(user))
+ return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
/obj/structure/fluff/clock/examine(mob/user)
. = ..()
@@ -665,17 +656,8 @@
. = ..()
if(. == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN)
return
- if(user.mind && isliving(user))
- if(user.mind.special_items && user.mind.special_items.len)
- var/item = browser_input_list(user, "What will I take?", "STASH", user.mind.special_items)
- if(item)
- if(user.Adjacent(src))
- if(user.mind.special_items[item])
- var/path2item = user.mind.special_items[item]
- user.mind.special_items -= item
- var/obj/item/I = new path2item(user.loc)
- user.put_in_hands(I)
- return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
+ if(try_fetch_special_item(user))
+ return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
/obj/structure/fluff/statue/CanPass(atom/movable/mover, turf/target)
. = ..()
diff --git a/code/game/objects/structures/newtree.dm b/code/game/objects/structures/newtree.dm
index 1f330d4a377..8cca4b7555d 100644
--- a/code/game/objects/structures/newtree.dm
+++ b/code/game/objects/structures/newtree.dm
@@ -40,16 +40,7 @@
. = ..()
if(. == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN)
return
- if(user.mind && isliving(user))
- if(user.mind.special_items && user.mind.special_items.len)
- var/item = browser_input_list(user, "What will I take?", "STASH", user.mind.special_items)
- if(item)
- if(user.Adjacent(src))
- if(user.mind.special_items[item])
- var/path2item = user.mind.special_items[item]
- user.mind.special_items -= item
- var/obj/item/I = new path2item(user.loc)
- user.put_in_hands(I)
+ if(try_fetch_special_item(user))
return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
/obj/structure/flora/newtree/attack_hand(mob/user)
diff --git a/code/game/objects/structures/rogueflora.dm b/code/game/objects/structures/rogueflora.dm
index 4a1a29ae788..6fca51dacdd 100644
--- a/code/game/objects/structures/rogueflora.dm
+++ b/code/game/objects/structures/rogueflora.dm
@@ -38,16 +38,7 @@
. = ..()
if(. == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN)
return
- if(user.mind && isliving(user))
- if(user.mind.special_items && user.mind.special_items.len)
- var/item = browser_input_list(user, "What will I take?", "STASH", user.mind.special_items)
- if(item)
- if(user.Adjacent(src))
- if(user.mind.special_items[item])
- var/path2item = user.mind.special_items[item]
- user.mind.special_items -= item
- var/obj/item/I = new path2item(user.loc)
- user.put_in_hands(I)
+ if(try_fetch_special_item(user))
return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
/obj/structure/flora/tree/attacked_by(obj/item/I, mob/living/user)
@@ -595,16 +586,7 @@
. = ..()
if(. == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN)
return
- if(user.mind && isliving(user))
- if(user.mind.special_items && user.mind.special_items.len)
- var/item = browser_input_list(user, "What will I take?", "STASH", user.mind.special_items)
- if(item)
- if(user.Adjacent(src))
- if(user.mind.special_items[item])
- var/path2item = user.mind.special_items[item]
- user.mind.special_items -= item
- var/obj/item/I = new path2item(user.loc)
- user.put_in_hands(I)
+ if(try_fetch_special_item(user))
return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
/obj/structure/flora/shroom_tree/Initialize()
diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm
index c6642e8b92f..2c3013e8f24 100644
--- a/code/modules/admin/admin_verbs.dm
+++ b/code/modules/admin/admin_verbs.dm
@@ -146,6 +146,7 @@ GLOBAL_LIST_INIT(admin_verbs_fun, list(
/client/proc/cmd_admin_gib_self,
/client/proc/drop_bomb,
/client/proc/set_dynex_scale,
+ /client/proc/open_ticket_granter,
/client/proc/drop_dynex_bomb,
/client/proc/object_say,
/client/proc/toggle_random_events,
@@ -207,6 +208,7 @@ GLOBAL_PROTECT(admin_verbs_debug)
/client/proc/get_dynex_power, //*debug verbs for dynex explosions.
/client/proc/get_dynex_range, //*debug verbs for dynex explosions.
/client/proc/set_dynex_scale,
+ /client/proc/open_ticket_granter,
/client/proc/cmd_display_del_log,
/client/proc/debug_huds,
/client/proc/map_export,
@@ -265,6 +267,7 @@ GLOBAL_LIST_INIT(admin_verbs_hideable, list(
/client/proc/get_dynex_range,
/client/proc/get_dynex_power,
/client/proc/set_dynex_scale,
+ /client/proc/open_ticket_granter,
/client/proc/cmd_admin_create_announcement,
/client/proc/object_say,
/client/proc/toggle_random_events,
diff --git a/code/modules/antagonists/villain/lich/lich.dm b/code/modules/antagonists/villain/lich/lich.dm
index f52f8400fca..3cd56d9bf8d 100644
--- a/code/modules/antagonists/villain/lich/lich.dm
+++ b/code/modules/antagonists/villain/lich/lich.dm
@@ -242,8 +242,8 @@
/obj/item/phylactery
name = "phylactery"
desc = "Looks like it is filled with some intense power."
- icon = 'icons/obj/wizard.dmi'
- icon_state = "soulstone"
+ icon = 'icons/roguetown/items/gems.dmi'
+ icon_state = "necro_crystal"
item_state = "electronic"
lefthand_file = 'icons/mob/inhands/misc/devices_lefthand.dmi'
righthand_file = 'icons/mob/inhands/misc/devices_righthand.dmi'
diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm
index bc34aa1c83f..722fcab455d 100644
--- a/code/modules/client/client_procs.dm
+++ b/code/modules/client/client_procs.dm
@@ -433,6 +433,7 @@ GLOBAL_LIST_EMPTY(respawncounts)
return null
GLOB.clients += src
+ GLOB.key_list += ckey
GLOB.keys_by_ckey[ckey] = key
GLOB.directory[ckey] = src
@@ -475,6 +476,20 @@ GLOBAL_LIST_EMPTY(respawncounts)
twitch = new(src)
native_say = new(src)
+ ///because we do award validation on login for certain things this needs to exist
+ var/full_version = "[byond_version].[byond_build ? byond_build : "xxx"]"
+ var/reconnecting = FALSE
+ if(GLOB.player_details[ckey])
+ reconnecting = TRUE
+ player_details = GLOB.player_details[ckey]
+ player_details.byond_version = full_version
+ player_details.byond_build = byond_build
+ else
+ player_details = new(ckey)
+ player_details.byond_version = full_version
+ player_details.byond_build = byond_build
+ GLOB.player_details[ckey] = player_details
+
//preferences datum - also holds some persistent data for the client (because we may as well keep these datums to a minimum)
prefs = GLOB.preferences_datums[ckey]
if(prefs)
@@ -496,7 +511,6 @@ GLOBAL_LIST_EMPTY(respawncounts)
if(fexists(roundend_report_file()))
add_verb(src, /client/proc/show_previous_roundend_report)
- var/full_version = "[byond_version].[byond_build ? byond_build : "xxx"]"
log_access("Login: [key_name(src)] from [address ? address : "localhost"]-[computer_id] || BYOND v[full_version]")
var/alert_mob_dupe_login = FALSE
@@ -532,18 +546,6 @@ GLOBAL_LIST_EMPTY(respawncounts)
set_right_click_menu_mode(TRUE)
- var/reconnecting = FALSE
- if(GLOB.player_details[ckey])
- reconnecting = TRUE
- player_details = GLOB.player_details[ckey]
- player_details.byond_version = full_version
- player_details.byond_build = byond_build
- else
- player_details = new(ckey)
- player_details.byond_version = full_version
- player_details.byond_build = byond_build
- GLOB.player_details[ckey] = player_details
-
. = ..() //calls mob.Login()
@@ -769,6 +771,7 @@ GLOBAL_LIST_EMPTY(respawncounts)
GLOB.admins -= src
GLOB.clients -= src
+ GLOB.key_list -= ckey
GLOB.directory -= ckey
QDEL_NULL(tgui_panel)
diff --git a/code/modules/client/player_details.dm b/code/modules/client/player_details.dm
index ab6b8e3ae74..357f8475672 100644
--- a/code/modules/client/player_details.dm
+++ b/code/modules/client/player_details.dm
@@ -8,10 +8,12 @@
var/byond_version = "Unknown"
var/byond_build
var/datum/achievement_data/achievements
+ var/rerolls = 0
/datum/player_details/New(key)
src.ckey = ckey(key)
achievements = new(key)
+ rerolls = CONFIG_GET(number/special_rerolls)
/datum/player_details/Destroy(force)
if(!force)
diff --git a/code/modules/client/preferences/_preferences.dm b/code/modules/client/preferences/_preferences.dm
index db093973ce1..b25e7563408 100644
--- a/code/modules/client/preferences/_preferences.dm
+++ b/code/modules/client/preferences/_preferences.dm
@@ -254,6 +254,20 @@ GLOBAL_LIST_INIT(name_adjustments, list())
/// culture datum type
var/datum/culture/culture = /datum/culture/universal/ambiguous
+ /// Typepath strings the player has permanently purchased (persisted)
+ var/list/owned_loadout_items = list()
+ /// Up to 3 equipped slots (typepath strings); must be in owned_loadout_items
+ /// to survive validate_loadouts(). Persisted.
+ var/list/equipped_loadout = list()
+ /// Single-round rentals queued for this spawn only. NOT persisted.
+ var/list/single_round_loadout = list()
+
+ var/list/equipped_loadout_colors = list()
+ var/list/single_round_loadout_colors = list()
+
+ var/list/owned_tickets = list() // list of /datum/ticket subtypes
+ var/list/ticket_history = list() // list of assoc lists
+
/datum/preferences/New(client/C)
parent = C
@@ -785,11 +799,17 @@ GLOBAL_LIST_INIT(name_adjustments, list())
if(update_all || ("accent" in fields_to_update))
params["accent"] = selected_accent
if(update_all || ("loadout1" in fields_to_update))
- params["loadout1"] = loadout1 ? loadout1.name : "None"
+ var/loadout1_str = _get_loadout_slot(1)
+ var/datum/loadout_item/loadout1_item = loadout1_str ? GLOB.loadout_items[text2path(loadout1_str)] : null
+ params["loadout1"] = loadout1_item ? loadout1_item.name : "None"
if(update_all || ("loadout2" in fields_to_update))
- params["loadout2"] = loadout2 ? loadout2.name : "None"
+ var/loadout2_str = _get_loadout_slot(2)
+ var/datum/loadout_item/loadout2_item = loadout2_str ? GLOB.loadout_items[text2path(loadout2_str)] : null
+ params["loadout2"] = loadout2_item ? loadout2_item.name : "None"
if(update_all || ("loadout3" in fields_to_update))
- params["loadout3"] = loadout3 ? loadout3.name : "None"
+ var/loadout3_str = _get_loadout_slot(3)
+ var/datum/loadout_item/loadout3_item = loadout3_str ? GLOB.loadout_items[text2path(loadout3_str)] : null
+ params["loadout3"] = loadout3_item ? loadout3_item.name : "None"
if(update_all || ("triumphs" in fields_to_update))
params["triumphs"] = user.get_triumphs() ? "\Roman [user.get_triumphs()]" : "0"
if(update_all || ("headshot" in fields_to_update))
@@ -805,6 +825,13 @@ GLOBAL_LIST_INIT(name_adjustments, list())
user << output(list2params(params), "preferences_browser:updateCharacterData")
update_preview_icon()
+/datum/preferences/proc/_get_loadout_slot(slot)
+ if(length(equipped_loadout) >= slot)
+ return equipped_loadout[slot]
+ var/rent_idx = slot - length(equipped_loadout)
+ if(rent_idx >= 1 && rent_idx <= length(single_round_loadout))
+ return single_round_loadout[rent_idx]
+ return null
/datum/preferences/proc/set_ui_theme(new_theme)
if(new_theme in list("dusty", "grimshart", "paper", "parchment"))
@@ -1702,39 +1729,7 @@ GLOBAL_LIST_INIT(name_adjustments, list())
popup.set_content(dat.Join())
popup.open(use_onclose = FALSE)
if("loadout_item")
- var/list/loadouts_available = list("None" = null)
- for(var/datum/loadout_item/item as anything in GLOB.loadout_items)
- var/datum/loadout_item/singleton = GLOB.loadout_items[item]
- if(singleton.is_unlocked_for(user.client))
- loadouts_available[item.name] = item
- else
- // Show it but greyed out with a hint, so players know it exists
- var/datum/award/A = SSachievements.awards[item.required_award]
- var/locked_name = "\[Locked\] [item.name]"
- if(A?.name)
- locked_name += " (Requires: [A.name]"
- // Show progress for progress-type awards
- if(istype(A, /datum/award/achievement/progress))
- locked_name += " - [user.client.player_details.achievements.get_progress_string(item.required_award)]"
- locked_name += ")"
- loadouts_available[locked_name] = null // Maps to null so set_loadout gets nothing if somehow selected
- var/loadout_input = browser_input_list(
- user,
- "Choose your character's loadout item. RMB a tree, statue or clock to collect.",
- "Loadout",
- loadouts_available,
- )
- var/loadout_number = href_list["loadout_number"]
- // Re-validate on submission in case of href manipulation
- var/datum/loadout_item/chosen = loadouts_available[loadout_input]
- var/datum/loadout_item/chosen_singleton = GLOB.loadout_items[chosen]
- if(!chosen || !chosen_singleton)
- to_chat(user, span_warning("Error selecting [loadout_input] for loadout."))
- return
- if(!chosen_singleton.is_unlocked_for(user.client))
- to_chat(user, span_warning("You haven't unlocked that loadout item yet."))
- return
- set_loadout(user, loadout_number, chosen)
+ open_loadout_shop(user)
if("species")
selected_accent = ACCENT_DEFAULT
@@ -1968,29 +1963,7 @@ GLOBAL_LIST_INIT(name_adjustments, list())
else
domhand = 1
if("bespecial")
- if(next_special_trait)
- print_special_text(user, next_special_trait)
- return
- to_chat(user, span_boldwarning("You will become special for one round, this could be something negative, positive or neutral and could have a high impact on your character and your experience. You cannot back out from or reroll this, and it will not carry over to other rounds."))
- if(!donator)
- to_chat(user, span_boldwarning("THIS COSTS 1 TRIUMPH"))
- if(user.get_triumphs() < 1)
- to_chat(user, span_bignotice("YOU DON'T HAVE ENOUGH TRIUMPHS."))
- return
- var/result = tgui_alert(user, "You'll receive a unique trait for one round\n You cannot back out from or reroll this.\nDo you really wish to [donator ? "" : "spend 1 triumph and " ]proceed?", "Be Special", list("Yes", "No"))
- if(result != "Yes")
- return
- if(!donator)
- user.adjust_triumphs(-1)
- if(next_special_trait)
- return
- next_special_trait = roll_random_special(user.client)
- if(next_special_trait)
- log_game("SPECIALS: Rolled [next_special_trait] for ckey: [user.ckey]")
- print_special_text(user, next_special_trait)
- user.playsound_local(user, 'sound/misc/alert.ogg', 100)
- to_chat(user, span_warning("This will be applied on your next game join."))
- to_chat(user, span_warning("You may switch your character and choose any role, if you don't meet the requirements (if any are specified) it won't be applied"))
+ open_loadout_shop(user)
if("family")
var/list/famtree_options_list = list(FAMILY_NONE, FAMILY_PARTIAL, FAMILY_NEWLYWED, FAMILY_FULL, "EXPLAIN THIS TO ME")
@@ -2191,8 +2164,6 @@ GLOBAL_LIST_INIT(name_adjustments, list())
user << browse(null, "window=latechoices") //closes late job selection
user << browse(null, "window=migration") // Closes migrant menu
- SStriumphs.remove_triumph_buy_menu(user.client)
-
winshow(user, "stonekeep_prefwin", FALSE)
user << browse(null, "window=preferences_browser")
user.client?.clear_character_previews() // browse null doesn't call on-close directly as far as i can tell
diff --git a/code/modules/client/preferences/preferences_savefile.dm b/code/modules/client/preferences/preferences_savefile.dm
index be8c4e4f014..60bbcbb5d75 100644
--- a/code/modules/client/preferences/preferences_savefile.dm
+++ b/code/modules/client/preferences/preferences_savefile.dm
@@ -175,6 +175,8 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
S["tip_delay"] >> tip_delay
S["ui_scale"] >> ui_scale
S["multi_char_ready"] >> multi_char_ready
+ S["owned_loadout_items"] >> owned_loadout_items
+ S["next_special_trait"] >> next_special_trait
S["multi_ready_slots"] >> multi_ready_slots
if(!islist(multi_ready_slots))
@@ -183,6 +185,9 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
// Custom hotkeys
S["key_bindings"] >> key_bindings
+ if(!islist(owned_loadout_items))
+ owned_loadout_items = list()
+
if(!char_theme)
char_theme = "grimshart"
//try to fix any outdated data if necessary
@@ -222,6 +227,8 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
check_new_keybindings()
+ load_tickets(S)
+
//ROGUETOWN
parallax = PARALLAX_INSANE
@@ -289,6 +296,9 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
WRITE_FILE(S["key_bindings"], key_bindings)
WRITE_FILE(S["multi_char_ready"], multi_char_ready)
WRITE_FILE(S["multi_ready_slots"], multi_ready_slots)
+ WRITE_FILE(S["owned_loadout_items"], owned_loadout_items)
+ WRITE_FILE(S["next_special_trait"], next_special_trait)
+ save_tickets(S)
return TRUE
/datum/preferences/proc/_load_species(S)
@@ -297,38 +307,6 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
species_type = /datum/species/human/northern
pref_species = new species_type()
-/datum/preferences/proc/_load_loadouts(S)
- for(var/i in 1 to 3)
- S["loadout[i]"] >> vars["loadout[i]"]
- validate_loadouts()
-
-/datum/preferences/proc/validate_loadouts()
- if(!parent.patreon.has_access(ACCESS_ASSISTANT_RANK) && !parent.twitch.has_access(ACCESS_TWITCH_SUB_TIER_1))
- loadout1 = null
- loadout2 = null
- loadout3 = null
- return FALSE
-
- var/pass = TRUE
- var/datum/loadout_item/testing_item
- if(loadout1)
- testing_item = GLOB.loadout_items[loadout1]
- if(!testing_item.is_unlocked_for(parent))
- loadout1 = null
- pass = FALSE
- if(loadout2)
- testing_item = GLOB.loadout_items[loadout2]
- if(!testing_item.is_unlocked_for(parent))
- loadout2 = null
- pass = FALSE
- if(loadout3)
- testing_item = GLOB.loadout_items[loadout3]
- if(!testing_item.is_unlocked_for(parent))
- loadout3 = null
- pass = FALSE
-
- return pass
-
/datum/preferences/proc/_load_culinary_preferences(S)
var/list/loaded_culinary_preferences
S["culinary_preferences"] >> loaded_culinary_preferences
@@ -390,7 +368,7 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
//Species
_load_species(S)
- _load_loadouts(S)
+ load_triumph_shop_character_data(S)
_load_culinary_preferences(S)
@@ -532,6 +510,11 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
WRITE_FILE(S["setspouse"], setspouse)
WRITE_FILE(S["selected_accent"], selected_accent)
+ WRITE_FILE(S["equipped_loadout"], equipped_loadout)
+ WRITE_FILE(S["equipped_loadout_colors"], equipped_loadout_colors)
+ WRITE_FILE(S["single_round_loadout_colors"], single_round_loadout_colors)
+ WRITE_FILE(S["single_round_loadout"], single_round_loadout)
+
//Custom names
for(var/custom_name_id in GLOB.preferences_custom_names)
var/savefile_slot_name = custom_name_id + "_name" //TODO remove this
diff --git a/code/modules/clothing/armor/chainmail.dm b/code/modules/clothing/armor/chainmail.dm
index 6694a284ab3..769c611bc17 100644
--- a/code/modules/clothing/armor/chainmail.dm
+++ b/code/modules/clothing/armor/chainmail.dm
@@ -49,6 +49,14 @@
. = ..()
AddComponent(/datum/component/item_equipped_movement_rustle)
+/obj/item/clothing/armor/chainmail/hauberk/aalloy
+ name = "decrepit hauberk"
+ desc = "Frayed bronze rings and rotting leather, woven together to form a sleeved maille-atekon. Once, the armored vestments of a paladin: now, the withered veil of Zizo's undying legionnaires."
+ icon_state = "ancienthauberk"
+ max_integrity = ARMOR_INT_CHEST_MEDIUM_DECREPIT
+ material_category = ARMOR_MAT_CHAINMAIL
+ anvilrepair = null
+
/obj/item/clothing/armor/chainmail/hauberk/fluted
name = "fluted hauberk"
desc = "A steel maille, of a pattern popularized by Psydonian templars."
diff --git a/code/modules/clothing/armor/cuirass.dm b/code/modules/clothing/armor/cuirass.dm
index 2b09ebfe12e..c5fe3e00df6 100644
--- a/code/modules/clothing/armor/cuirass.dm
+++ b/code/modules/clothing/armor/cuirass.dm
@@ -132,3 +132,24 @@
icon_state = "ornatecuirass"
desc = "An ornate steel cuirass with tassets, favored by both the Oratorium Throni Vacui and the Order of the Silver Psycross. \
Made to endure."
+
+/obj/item/clothing/armor/cuirass/fluted/gold
+ name = "golden cuirass"
+ icon_state = "goldcuirass"
+ desc = "A resplendant cuirass of pure gold, fitted with tassets for additional coverage. It is dressed atop a besilked arming jacket to ensure the absolute comfort of its wearer, and the holy sigil has been meticulously formed from its slanted plates."
+ armor_class = AC_HEAVY
+ anvilrepair = null
+ melting_material = /datum/material/gold
+ melt_amount = 100
+ grid_height = 96
+ grid_width = 96
+ sellprice = 300
+
+/obj/item/clothing/armor/cuirass/fluted/gold/heroic
+ name = "golden heroic cuirass"
+ icon_state = "heroiccuirass"
+ desc = "A resplendant cuirass of pure gold, fitted with tassets for additional coverage. It has been meticulously waxed-and-assembled from dozens of smaller golden plates, in order to replicate the statuesque physique of Psydonia's legendary heroes."
+
+/obj/item/clothing/armor/cuirass/fluted/gold/king
+ name = "golden heroic cuirass"
+ sellprice = 400
diff --git a/code/modules/clothing/armor/misc.dm b/code/modules/clothing/armor/misc.dm
index 71f1c7d2c96..66f340c2fe3 100644
--- a/code/modules/clothing/armor/misc.dm
+++ b/code/modules/clothing/armor/misc.dm
@@ -68,6 +68,29 @@
. = ..()
AddComponent(/datum/component/item_equipped_movement_rustle, custom_sounds = SFX_PLATE_COAT_STEP)
+/obj/item/clothing/armor/brigandine/haraate
+ name = "hansimhae cuirass"
+ desc = "A more common form of Blackmeadow armor, consisting of several interlocking plates of blacksteel-coated steel. Much cheaper than a full set of armor, these are commonly seen on militia forces and standing armies alike."
+ icon_state = "kazengunmedium"
+ boobed = FALSE
+ item_state = "kazengunmedium"
+ detail_tag = "_detail"
+ color = "#FFFFFF"
+ detail_color = "#FFFFFF"
+ sleeved = 'icons/roguetown/clothing/onmob/helpers/sleeves_armor.dmi'
+ detail_tag = "_detail"
+
+/obj/item/clothing/armor/brigandine/haraate/attack_hand_secondary(mob/user, list/modifiers)
+ . = ..()
+ var/choice = input(user, "Choose a color.", "Uniform colors") as anything in COLOR_MAP
+ var/playerchoice = COLOR_MAP[choice]
+ detail_color = playerchoice
+ update_icon()
+ if(loc == user && ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_armor()
+ H.update_icon()
+
//................ Abyssal Robe ............... //
/obj/item/clothing/armor/brigandine/abyssor // This is only a brigandine subtype for balance reasons, it should be a cuirass variant.
name = "abyssal robe"
@@ -139,3 +162,25 @@
w_class = WEIGHT_CLASS_BULKY
prevent_crits = ALL_EXCEPT_STAB
item_weight = 3.95 KILOGRAMS
+
+/obj/item/clothing/armor/plate/bronze
+ name = "bronze cuirass"
+ desc = "A chiseled breastplate of bronze, further padded with hide to comfort its championing bod. The plates have been carefully forged to mimic the statuesque physiques of Psydonia's ancient heroes. Wearing it bolsters you with determination."
+ body_parts_covered = CHEST | VITALS | LEGS
+ icon_state = "bronzecuirass"
+ armor = ARMOR_BRIGANDINE
+ melt_amount = 150
+ melting_material = /datum/material/bronze
+ max_integrity = ARMOR_INT_CHEST_MEDIUM_SCALE
+ armor_class = AC_MEDIUM
+ boobed = FALSE
+
+/obj/item/clothing/armor/plate/bronze/light
+ name = "bronze cardiophylax"
+ desc = "A thick bronze plate, meticulously sculpted to fit its wearer's physique and guard their heart from all that'd seek to strike it. Unfortunately, it does little to riposte more emotional blows."
+ icon_state = "bronzeprotector"
+ item_state = "bronzeprotector"
+ body_parts_covered = CHEST | VITALS
+ max_integrity = ARMOR_INT_CHEST_MEDIUM_SCALE //250 INT, or a little above Iron - and +100 INT over the Copper variant.
+ armor_class = AC_LIGHT
+ armor = ARMOR_BRIGANDINE
diff --git a/code/modules/clothing/armor/plate.dm b/code/modules/clothing/armor/plate.dm
index e7c39c62e0c..91ed789fb1f 100644
--- a/code/modules/clothing/armor/plate.dm
+++ b/code/modules/clothing/armor/plate.dm
@@ -33,6 +33,40 @@
armor = ARMOR_PLATE_BAD
max_integrity = INTEGRITY_STRONG
+/obj/item/clothing/armor/plate/iron/banded
+ name = "banded iron armor"
+ desc = "An iron chestplate, pauldrons and tassets worn over a fur vest and padded with heavy leathers. It's primarily worn in the cold north, where armor has to sometimes be cobbled together due to logistical shortages. It leaves the stomach exposed for maneuverability."
+ max_integrity = ARMOR_INT_CHEST_PLATE_IRON + 25
+ icon_state = "ibandedarmor"
+ item_state = "ibandedarmor"
+ armor_class = AC_HEAVY
+ body_parts_covered = CHEST | ARMS | LEGS | GROIN
+
+/obj/item/clothing/armor/heartfelt
+ slot_flags = ITEM_SLOT_ARMOR
+ name = "coat of armor"
+ desc = "A lordly coat of armor."
+ body_parts_covered = COVERAGE_ALL_BUT_LEGS
+ icon_state = "heartfelt"
+ item_state = "heartfelt"
+ armor = ARMOR_PLATE
+ allowed_sex = list(MALE, FEMALE)
+ nodismemsleeves = TRUE
+ blocking_behavior = null
+ max_integrity = ARMOR_INT_CHEST_PLATE_STEEL
+ anvilrepair = /datum/attribute/skill/craft/armor_repair
+ melting_material = /datum/material/steel
+ melt_amount = 375
+ armor_class = AC_HEAVY
+
+/obj/item/clothing/armor/heartfelt/hand
+ slot_flags = ITEM_SLOT_ARMOR
+ name = "coat of armor"
+ desc = "A lordly coat of armor."
+ body_parts_covered = COVERAGE_ALL_BUT_LEGS
+ icon_state = "heartfelt_hand"
+ item_state = "heartfelt_hand"
+
//................ Full Plate Armor ............... //
/obj/item/clothing/armor/plate/full
name = "plate armor"
@@ -47,6 +81,29 @@
body_parts_covered = COVERAGE_FULL
item_weight = 17 KILOGRAMS
+
+/obj/item/clothing/armor/plate/full/samsibsa
+ name = "samsibsa scaleplate"
+ desc = "A heavy set of armour worn by the kouken of distant Blackmeadow. As opposed to the plate armour utilized by most of Psydonia and the West, samsiba-cheolpan is made of thirty-four rows of composite scales, each an ultra-thin sheet of blacksteel gilded over steel. It is an extremely common practice to engrave characters onto individual plates - such as LUCK, HONOR, or HEAVEN."
+ icon_state = "kazengunheavy"
+ item_state = "kazengunheavy"
+ detail_tag = "_detail"
+ color = null
+ detail_color = CLOTHING_WHITE
+ max_integrity = ARMOR_INT_CHEST_PLATE_STEEL - 50 //slightly worse
+ detail_tag = "_detail"
+
+/obj/item/clothing/armor/plate/full/samsibsa/attack_hand_secondary(mob/user, list/modifiers)
+ . = ..()
+ var/choice = input(user, "Choose a color.", "Uniform colors") as anything in COLOR_MAP
+ var/playerchoice = COLOR_MAP[choice]
+ detail_color = playerchoice
+ update_icon()
+ if(loc == user && ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_armor()
+ H.update_icon()
+
/obj/item/clothing/armor/plate/full/iron
name = "iron plate armor"
desc = "Full iron plate. Leg protecting tassets, groin cup, armored vambraces."
@@ -74,6 +131,20 @@
max_integrity = INTEGRITY_STANDARD
item_weight = 8.75 KILOGRAMS
+/obj/item/clothing/armor/plate/silver
+ slot_flags = ITEM_SLOT_ARMOR
+ name = "templar's half-plate"
+ desc = "Noc's holy silver, one fifth. Steel, three fifths. Chosen Material, one fifth. The armor of the Templar, protector and warrior of the Ten's Faithful."
+ body_parts_covered = COVERAGE_TORSO
+ icon_state = "silverhalfplate"
+ item_state = "silverhalfplate"
+ armor = ARMOR_PLATE
+ max_integrity = ARMOR_INT_CHEST_PLATE_STEEL
+ allowed_sex = list(MALE, FEMALE)
+ melting_material = /datum/material/steel
+ melt_amount = 275
+ armor_class = AC_MEDIUM
+
/obj/item/clothing/armor/plate/blkknight
name = "blacksteel plate"
@@ -106,6 +177,55 @@
desc = "A halfplate decorated with a gold ornament on the chestplate and a fine silk corset. More for decoration then actual use."
icon_state = "halfplate_decorated_corset"
+/datum/attribute_modifier/full_bronze
+ id = "Bronze Plate"
+ attribute_list = list(
+ STAT_CONSTITUTION = 1,
+ STAT_SPEED = -1,
+ )
+
+/obj/item/clothing/armor/plate/full/bronze
+ name = "bronze panoplic armor"
+ desc = "What can only be described as an 'armored robe'; thick bronze plates, layered atop one-another and interlinked with strappings \
+ to form an assembly of segmented plate armor. While overwhelmingly heavy and cumbersome, it is certain to weather any storm poised its way. \
+ Scholars oft-describe this suit as a 'panoply', purpose-made for the physiques of Psydonia's earliest Aasimari."
+ icon_state = "bronzeplate"
+ item_state = "bronzeplate"
+ armor = ARMOR_PLATE_BAD
+ max_integrity = ARMOR_INT_CHEST_MEDIUM_IRON + 100
+ armor_class = AC_HEAVY
+ melt_amount = 275
+ melting_material = /datum/material/bronze
+ var/bronzeplatecumbersome = FALSE
+
+/obj/item/clothing/armor/plate/full/bronze/equipped(mob/living/carbon/human/user, slot)
+ . = ..()
+ if(slot == SLOT_ARMOR)
+ to_chat(user, span_suicide("The panoply clatters into place, and I feel my shoulders slouch beneath its weight - yet even now, I feel sturdier than ever before.."))
+ user.attributes?.add_attribute_modifier(/datum/attribute_modifier/full_bronze)
+ bronzeplatecumbersome = TRUE
+ return
+
+/obj/item/clothing/armor/plate/full/bronze/dropped(mob/living/carbon/human/user)
+ . = ..()
+ if(bronzeplatecumbersome == TRUE)
+ to_chat(user, span_hypnophrase("..and with a sigh of relief, the panoply's weight no longer burdens my shoulders."))
+ user.attributes?.remove_attribute_modifier(/datum/attribute_modifier/full_bronze)
+ bronzeplatecumbersome = FALSE
+ return
+
+/obj/item/clothing/armor/plate/full/bronze/get_mechanics_examine(mob/user)
+ . = ..()
+ . += span_info("Even with the necessary training, this suit of armor is difficult to maneuver in. Wearing the armor will slightly fortify your Constitution, at the cost of further reducing your Speed.")
+
+/obj/item/clothing/armor/plate/full/bronze/alt
+ name = "bronze panoplic assembly"
+ icon_state = "bronzeplatealt"
+ item_state = "bronzeplatealt"
+ body_parts_covered = CHEST | VITALS | LEGS
+ max_integrity = ARMOR_INT_CHEST_MEDIUM_IRON //Halfplate analogue. Still heavy as hell.
+
+
//................ Zizo Armor ...............//
/obj/item/clothing/armor/plate/full/zizo
diff --git a/code/modules/clothing/armor/race.dm b/code/modules/clothing/armor/race.dm
new file mode 100644
index 00000000000..75e69a5132f
--- /dev/null
+++ b/code/modules/clothing/armor/race.dm
@@ -0,0 +1,21 @@
+/obj/item/clothing/armor/plate/full/dwarven
+ name = "grudgebearer dwarven plate"
+ desc = "A sturdy set of dwarven plate armor, forged in the old ways. It cannot be worked on without intrinsic dwarven knowledge."
+ icon = 'icons/roguetown/clothing/special/race_armor.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/special/onmob/race_armor.dmi'
+ allowed_race = list(SPEC_ID_DWARF)
+ icon_state = "dwarfchest"
+ item_state = "dwarfchest"
+ melt_amount = 375
+ item_weight = 17 KILOGRAMS
+ stand_speed_reduction = 1.2
+
+/obj/item/clothing/armor/plate/full/dwarven/smith
+ name = "grudgebearer splint apron"
+ desc = "A mixture of plate and maille, worn by dwarven smiths. It cannot be worked on without intrinsic dwarven knowledge."
+ icon_state = "dsmithchest"
+ item_state = "dsmithchest"
+ armor_class = AC_MEDIUM
+ body_parts_covered = CHEST|GROIN|VITALS|LEGS
+ max_integrity = ARMOR_INT_CHEST_MEDIUM_STEEL
+ melt_amount = 250
diff --git a/code/modules/clothing/armor/rare.dm b/code/modules/clothing/armor/rare.dm
index 44754a91a0f..69d4bd1187e 100644
--- a/code/modules/clothing/armor/rare.dm
+++ b/code/modules/clothing/armor/rare.dm
@@ -42,14 +42,6 @@
icon_state = "welfchest"
item_weight = 17 KILOGRAMS
-/obj/item/clothing/armor/rare/dwarfplate
- name = "dwarvish plate"
- desc = "Plate armor made out of the sturdiest, finest dwarvish metal armor. It's as heavy and durable as it gets."
- icon_state = "dwarfchest"
- allowed_race = list(SPEC_ID_DWARF)
- item_weight = 17 KILOGRAMS
- stand_speed_reduction = 1.2
-
/obj/item/clothing/armor/rare/grenzelplate
name = "grenzelhoftian plate regalia"
desc = "Engraved on this masterwork of humen metallurgy lies \"Thrice Fingered, Thrice Betrayed, Thrice Pronged\" alongside the symbol of Psydon in its neck guard. No one is certain what the third betrayal is meant to signify, yet Samantha's poetry is clear."
diff --git a/code/modules/clothing/belt/misc.dm b/code/modules/clothing/belt/misc.dm
index 7bfa12d2e95..16a5da1f745 100644
--- a/code/modules/clothing/belt/misc.dm
+++ b/code/modules/clothing/belt/misc.dm
@@ -18,6 +18,12 @@
for(var/obj/item/I in things)
STR.remove_from_storage(I, get_turf(src))
+/obj/item/storage/belt/leather/double
+ name = "pair of belts"
+ desc = "A pair of slim black belts worn around the waist."
+ icon_state = "belt_double"
+ item_state = "belt_double"
+
/obj/item/storage/belt/leather/assassin // Assassin's super edgy and cool belt can carry normal items (for poison vial, lockpick).
empty_when_dropped = FALSE
component_type = /datum/component/storage/concrete/grid/belt/assassin
@@ -587,3 +593,33 @@
anvilrepair = /datum/attribute/skill/craft/blacksmithing
smeltresult = /obj/item/ingot/gold
component_type = /datum/component/storage/concrete/grid/headhook/bronze
+
+
+/obj/item/storage/belt/leather/breechcloth
+ name = "belt with breechcloth"
+ desc = "A fine leather strap notched with holes for a buckle to secure itself, and nestled above a halved tabard's coverings."
+ icon_state = "breechcloth"
+ sewrepair = FALSE
+ detail_tag = "_belt"
+
+/obj/item/storage/belt/leather/breechcloth/blackbelt
+ name = "black belt with breechcloth"
+ desc = "A fine black-leather strap notched with holes for a buckle to secure itself, and nestled above a halved tabard's coverings."
+ icon_state = "breechclothalt"
+ sewrepair = FALSE
+ detail_tag = "_belt"
+
+/obj/item/storage/belt/leather/slayer
+ name = "rugged dwarven belt"
+ desc = "The golden beard of the face plate doubles as a codpiece."
+ icon_state = "slayer"
+ item_state = "slayer"
+ sellprice = 50
+ detail_tag = "_belt"
+ sewrepair = FALSE
+
+/obj/item/storage/belt/leather/shawl
+ name = "shawl"
+ desc = "A cloth shawl."
+ icon_state = "beltshawl"
+ item_state = "beltshawl"
diff --git a/code/modules/clothing/cloak/inquistion.dm b/code/modules/clothing/cloak/inquistion.dm
index 17511f94041..985a6a07c40 100644
--- a/code/modules/clothing/cloak/inquistion.dm
+++ b/code/modules/clothing/cloak/inquistion.dm
@@ -50,6 +50,100 @@
H.update_inv_cloak()
H.update_inv_armor()
+/obj/item/clothing/cloak/psydontabard/black
+ name = "blessed tabard"
+ desc = "A tabard worn by the worshippers of Psydon. A funeral shroud for the paradise that could've been, and a solemn vow to continue the struggle towards salvation."
+ icon_state = "blackpsydontabard"
+ item_state = "blackpsydontabard"
+
+/obj/item/clothing/cloak/psydontabard/black/alt
+ name = "opened blessed tabard"
+ desc = "A tabard worn by the worshippers of Psydon, peeled back to reveal its mourning innards."
+ body_parts_covered = GROIN
+
+/obj/item/clothing/cloak/psydontabard/black/MiddleClick(mob/user)
+ ..()
+ user.update_inv_shirt()
+
+/obj/item/clothing/cloak/psydontabard/black/attack_hand_secondary(mob/user)
+ switch(open_wear)
+ if(FALSE)
+ name = "opened blessed tabard"
+ desc = "A tabard worn by the worshippers of Psydon, peeled back to reveal its mourning innards."
+ body_parts_covered = GROIN
+ icon_state = "blackpsydontabardalt"
+ item_state = "blackpsydontabardalt"
+ open_wear = TRUE
+ flags_inv = NONE
+ to_chat(usr, span_warning("You pull back the threaded burlap, baring your heart to Psydonia's eyes."))
+ if(TRUE)
+ name = "blessed tabard"
+ desc = "A tabard worn by the worshippers of Psydon. A funeral shroud for the paradise that could've been, and a solemn vow to continue the struggle towards salvation."
+ body_parts_covered = CHEST|GROIN
+ icon_state = "blackpsydontabard"
+ item_state = "blackpsydontabard"
+ flags_inv = HIDEBOOB
+ open_wear = FALSE
+ to_chat(usr, span_warning("You cloak yourself in the threaded burlap, veiling your heart from Psydonia's eyes."))
+ update_icon()
+ if(user)
+ if(ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_cloak()
+ H.update_inv_armor()
+
+/obj/item/clothing/cloak/tabard/toga
+ name = "toga"
+ desc = "The ancestral predecessor to Psydonia's many tabards, worn by the heroes and villains of antiquity."
+ icon_state = "whitepsydontabard"
+ item_state = "whitepsydontabard"
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ slot_flags = ITEM_SLOT_SHIRT|ITEM_SLOT_ARMOR|ITEM_SLOT_CLOAK
+ var/open_wear = FALSE
+
+/obj/item/clothing/cloak/tabard/toga/get_mechanics_examine(mob/user)
+ . = ..()
+ . += span_info("Right-clicking this cloak allows for it to be dynamically worn as a traditional tabard, or as a sleeveless robe that partially exposes the chest.")
+
+/obj/item/clothing/cloak/tabard/toga/alt
+ name = "opened toga"
+ desc = "The ancestral predecessor to Psydonia's many tabards, parted to reveal what lies beneath its cloth."
+ body_parts_covered = GROIN
+ icon_state = "whitepsydontabardalt"
+ item_state = "whitepsydontabardalt"
+ open_wear = TRUE
+
+/obj/item/clothing/cloak/tabard/toga/MiddleClick(mob/user)
+ ..()
+ user.update_inv_shirt()
+
+/obj/item/clothing/cloak/tabard/toga/attack_hand_secondary(mob/user)
+ switch(open_wear)
+ if(FALSE)
+ name = "opened toga"
+ desc = "The ancestral predecessor to Psydonia's many tabards, parted to reveal what lies beneath its cloth."
+ body_parts_covered = GROIN
+ icon_state = "whitepsydontabardalt"
+ item_state = "whitepsydontabardalt"
+ open_wear = TRUE
+ flags_inv = NONE
+ to_chat(usr, span_warning("You pull back the threaded cloth, baring your heart to Psydonia's eyes."))
+ if(TRUE)
+ name = "toga"
+ desc = "The ancestral predecessor to Psydonia's many tabards, worn by the heroes and villains of antiquity."
+ body_parts_covered = CHEST|GROIN
+ icon_state = "whitepsydontabard"
+ item_state = "whitekpsydontabard"
+ flags_inv = HIDEBOOB
+ open_wear = FALSE
+ to_chat(usr, span_warning("You cloak yourself in the threaded cloth, veiling your heart from Psydonia's eyes."))
+ update_icon()
+ if(user)
+ if(ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_cloak()
+ H.update_inv_armor()
+
/obj/item/clothing/cloak/ordinatorcape
name = "ordinator cape"
desc = "A flowing red cape complete with an ornately patterned steel shoulderguard. Made to last. Made to ENDURE. Made to LIVE."
@@ -65,6 +159,31 @@
. = ..()
AddComponent(/datum/component/storage/concrete/grid/cloak)
+/obj/item/clothing/cloak/ordinatorcape/lirvas
+ name = "warrior silks"
+ desc = "Fine silks. Only the best for me, of course. You need to look good while beating someone to death."
+ icon_state = "lirvastabard"
+ item_state = "lirvastabard"
+ sellprice = 25
+
+/obj/item/clothing/cloak/cape/inquisitorgold
+ name = "golden order cloak"
+ desc = "A time honored cloak inlined with golden threading, the stitchwork tethers it to the Golden Orders."
+ icon_state = "inquisitor_cloak"
+ icon = 'icons/roguetown/clothing/cloaks.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ sleeved = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ color = null
+
+/obj/item/clothing/cloak/cape/inquisitorsilver
+ name = "silver order cloak"
+ desc = "A time honored cloak inlined with silver threading, the stitchwork tethers it to the Silver Orders"
+ icon_state = "sinquisitor_cloak"
+ icon = 'icons/roguetown/clothing/cloaks.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ sleeved = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ color = null
+
/obj/item/clothing/cloak/absolutionistrobe
name = "absolver's robe"
desc = "Absolve them of their pain. Absolve them of their longing. Live, as PSYDON lives."
@@ -79,3 +198,9 @@
/obj/item/clothing/cloak/absolutionistrobe/Initialize(mapload, ...)
. = ..()
AddComponent(/datum/component/storage/concrete/grid/cloak)
+
+/obj/item/clothing/cloak/absolutionistrobe/black
+ name = "blessed robe"
+ desc = "Weep for what was lost. Pray for those who may yet be saved. Endure, in His name."
+ icon_state = "blackabsolutionistrobe"
+ item_state = "blackabsolutionistrobe"
diff --git a/code/modules/clothing/cloak/misc.dm b/code/modules/clothing/cloak/misc.dm
index ef6e6688b55..76badc04c2c 100644
--- a/code/modules/clothing/cloak/misc.dm
+++ b/code/modules/clothing/cloak/misc.dm
@@ -287,6 +287,14 @@
dyeable = TRUE
sellprice = 0 // See above comment
+/obj/item/clothing/cloak/graggar/heavy
+ name = "vicious halfcloak"
+ icon = 'icons/roguetown/clothing/cloaks.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ sleeved = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ desc = "Sorrow begets spite; and when one has nothing else to lose, spite is all that's needed for Man to defy God."
+ icon_state = "graggarcloak_heavy"
+
/obj/item/clothing/cloak/savage
name = "savage cloak"
desc = "A cloak covered in an predatory aura, it seeks to bring about the natural chaos of the wild to you, dripping in gore and bloodied fur."
@@ -352,3 +360,81 @@
item_state = "seecloak"
boobed = FALSE
sleeved = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+
+/obj/item/clothing/cloak/bandolier
+ name = "bandolier"
+ desc = "A sash that's pelted with pouches, perfect for carrying plenty of pint-sized pieces. 'Hail to the King, baby.'"
+ color = null
+ icon_state = "bandolier"
+ item_state = "bandolier"
+ resistance_flags = FIRE_PROOF
+ w_class = WEIGHT_CLASS_BULKY
+ slot_flags = ITEM_SLOT_BACK_R|ITEM_SLOT_SHIRT|ITEM_SLOT_ARMOR|ITEM_SLOT_CLOAK //Same slots as the regular tabard, with the added bonus of being slingable on the rightmost backslot.
+ salvage_result = /obj/item/natural/hide/cured
+ grid_width = 64
+ grid_height = 96
+
+/obj/item/clothing/cloak/bandolier/Initialize(mapload, ...)
+ . = ..()
+ AddComponent(/datum/component/storage/concrete/grid/bandolier)
+
+/obj/item/clothing/cloak/scaledcloak
+ name = "scaled cloak"
+ desc = "A light cloak covered in shimmering metal scales. Beautiful even if too light to protect it's wearer from more than other travel cloaks."
+ icon_state = "scalecloak"
+ item_state = "scalecloak"
+ alternate_worn_layer = CLOAK_BEHIND_LAYER
+ boobed = FALSE
+ slot_flags = ITEM_SLOT_CLOAK|ITEM_SLOT_BACK_R|ITEM_SLOT_BACK_L
+ sleeved = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ sleevetype = "shirt"
+ nodismemsleeves = TRUE
+ inhand_mod = TRUE
+ detail_tag = "_detail"
+ detail_color = "#405996"
+
+/obj/item/clothing/cloak/sleevedtabard
+ name = "sleeved tabard"
+ desc = "A tabard with a light sleeve and pauldron sewn on, it lacks the explicit detailing of other tabards in exchange."
+ color = null
+ boobed = TRUE
+ icon_state = "halfsurcoat"
+ item_state = "halfsurcoat"
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ sleeved = 'icons/roguetown/clothing/onmob/helpers/sleeves_cloaks.dmi'
+ sleevetype = "shirt"
+
+/obj/item/clothing/cloak/minotaur
+ name = "minotaur cloak"
+ desc = "Minotaur fur and straw roughly sewn into a long mantle."
+ icon_state = "mino"
+ item_state = "mino"
+ sleeved = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ salvage_result = /obj/item/natural/hide/cured
+ salvage_amount = 4
+
+/obj/item/clothing/cloak/poncho/fancycoat
+ name = "fancy coat"
+ desc = "A loose garment that is usually draped across ones upper body. No one's quite sure of its cultural origin but it does look fancy."
+ icon_state = "fancycoat"
+ item_state = "fancycoat"
+ alternate_worn_layer = TABARD_LAYER
+ boobed = FALSE
+ flags_inv = HIDEBOOB
+ slot_flags = ITEM_SLOT_CLOAK|ITEM_SLOT_ARMOR
+ sleeved = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ nodismemsleeves = TRUE
+ color = CLOTHING_WHITE
+ detail_tag = "_detail"
+ detail_color = CLOTHING_WHITE
+
+/obj/item/clothing/cloak/kazengun
+ name = "jinbaori"
+ desc = "A simple kind of Blackmeadow surcoat, worn here in the distant battlefields of Azuria to differentiate friend from foe."
+ icon_state = "kazenguncoat"
+ item_state = "kazenguncoat"
+ detail_tag = "_detail"
+ sleeved = 'icons/roguetown/clothing/onmob/cloaks.dmi'
+ slot_flags = ITEM_SLOT_BACK_R|ITEM_SLOT_CLOAK
+ color = "#FFFFFF"
+ detail_color = "#FFFFFF"
diff --git a/code/modules/clothing/cloak/solider_tabard.dm b/code/modules/clothing/cloak/solider_tabard.dm
index ae9665dc454..d19d710a5ed 100644
--- a/code/modules/clothing/cloak/solider_tabard.dm
+++ b/code/modules/clothing/cloak/solider_tabard.dm
@@ -204,6 +204,14 @@
icon_state = "tabard_ravox"
item_state = "tabard_ravox"
+/obj/item/clothing/cloak/stabard/templar/justice
+ name = "surcoat of the justice order"
+ icon_state = "justicetabard"
+ item_state = "justicetabard"
+ icon = 'icons/roguetown/clothing/special/ravoxtemplar.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/special/onmob/ravoxtabard.dmi'
+ sleeved = 'icons/roguetown/clothing/special/onmob/ravoxtabard.dmi'
+
/obj/item/clothing/cloak/stabard/templar/xylix
name = "surcoat of the xylixian order"
icon_state = "tabard_xylix"
diff --git a/code/modules/clothing/face/masks.dm b/code/modules/clothing/face/masks.dm
index a7d1a197c62..ea294c7d873 100644
--- a/code/modules/clothing/face/masks.dm
+++ b/code/modules/clothing/face/masks.dm
@@ -345,6 +345,44 @@
icon_state = "steppemask_snout"
desc = "A steel mask shaped like a beast's face, worn by steppe riders to intimidate their enemies."
+/obj/item/clothing/face/facemask/steel/kazengun
+ name = "soldier's half-mask"
+ desc = "\"The first lesson of war is that it would be better to live in peace.\""
+ block2add = null
+ armor = ARMOR_PLATE // because it's only half
+ icon_state = "kazengunmouthguard"
+ item_state = "kazengunmouthguard"
+
+/obj/item/clothing/face/facemask/steel/kazengun/full
+ name = "ogre mask"
+ desc = "\"The second lesson: Rich men have dreams. Poor men die to make them come true.\""
+ icon_state = "kazengunfaceguard"
+ item_state = "kazengunfaceguard"
+
+/obj/item/clothing/face/facemask/steel/graggar
+ name = "vicious jawmask"
+ desc = "Shattered jaws, chipped teeth, sunken metal - fit for a skull of the same. It snarls in mimicry of the Sinistar's visage."
+ icon_state = "graggarplatemask_heavy"
+ block2add = null
+ body_parts_covered = MOUTH|NOSE
+ flags_inv = HIDEFACE
+
+/obj/item/clothing/face/facemask/bronze
+ name = "bronze mask"
+ desc = "Glimmering bronze, curved to veil its wearer's face from both judgement and harm."
+ armor_class = AC_LIGHT
+ icon_state = "bronzemask"
+ item_state = "bronzemask"
+ max_integrity = 150
+ melting_material = /datum/material/bronze
+ melt_amount = 75
+
+/obj/item/clothing/face/facemask/bronze/classic
+ name = "bronze death mask"
+ icon_state = "bronzemask_legacy"
+ item_state = "bronzemask_legacy"
+ desc = "Glimmering bronze, meticuliusly shaped to mimic the guise of another. One of civilization's oldest superstitions is the belief that donning such masks would impart a sliver of the mimicked facebearer's power unto its wearer."
+
/obj/item/clothing/face/facemask/silver
name = "silver mask"
icon = 'icons/roguetown/clothing/special/adept.dmi'
@@ -562,3 +600,33 @@
resistance_flags = FLAMMABLE
item_weight = 356 GRAMS
+/obj/item/clothing/face/xylixmask
+ name = "jester mask"
+ item_state = "xylixmask"
+ icon_state = "xylixmask"
+ desc = "A ceramic mask, forever stuck with the joyful smile its patron god favors. While it will shatter easily from blows, its smug countenance shall taunt its foes."
+ max_integrity = 50
+ armor = null
+ flags_inv = HIDEFACE
+ body_parts_covered = FACE
+ block2add = FOV_BEHIND
+ slot_flags = ITEM_SLOT_MASK|ITEM_SLOT_HIP
+ smeltresult = null
+ sellprice = 0
+
+/obj/item/clothing/face/xylixmask/weathered
+ name = "weathered mask"
+ item_state = "xylix_weathered"
+ icon_state = "xylix_weathered"
+ desc = "An ancient ceramic face. It looks weathered, the sort molded by Xylixian worshippers of many yils past. Even when cast aside, it feels like the hardened clay has never left your hands. As if it always finds a way back into your palms."
+ // No armor anyways
+ max_integrity = 200
+ // Not messing with jester mask, but again, it has no armor. many other masks also don't block vision.
+ block2add = FOV_DEFAULT
+
+/obj/item/clothing/face/faceveil
+ name = "simple veil"
+ icon_state = "faceveil"
+ desc = "A remarkably plain veil meant to conceal ones face... if you wore this, a gust of wind would be all it takes to reveal your identity."
+ grid_width = 32
+ grid_height = 32
diff --git a/code/modules/clothing/gloves/angle.dm b/code/modules/clothing/gloves/angle.dm
index f0ec059132a..f8479219d1a 100644
--- a/code/modules/clothing/gloves/angle.dm
+++ b/code/modules/clothing/gloves/angle.dm
@@ -58,3 +58,10 @@
unarmed_bonus = 1.25
max_integrity = 250
color = "#ffffff"
+
+/obj/item/clothing/gloves/angle/freifechter
+ name = "fencing gloves"
+ desc = "A pair of hardened leather gloves used by fencers who aren't exactly convinced of losing a finger to a particularly strong feder cut. The inside is padded for extra durability."
+ icon_state = "freigloves"
+ item_state = "freigloves"
+ max_integrity = ARMOR_INT_SIDE_HARDLEATHER + 50
diff --git a/code/modules/clothing/gloves/plate.dm b/code/modules/clothing/gloves/plate.dm
index 0dd559ef4b3..e2d98900040 100644
--- a/code/modules/clothing/gloves/plate.dm
+++ b/code/modules/clothing/gloves/plate.dm
@@ -35,6 +35,12 @@
armor = ARMOR_PLATE_BAD
max_integrity = INTEGRITY_STRONG
+/obj/item/clothing/gloves/plate/iron/banded
+ name = "banded iron gauntlets"
+ desc = "A pair of leather gloves layered under a fur wrap with an iron plate hastily tightened together on both ends. It's primarily worn in the cold north, where armor has to sometimes be cobbled together due to logistical shortages."
+ icon_state = "bandedgloves"
+ item_state = "bandedgloves"
+
/obj/item/clothing/gloves/plate/rust
name = "rusted riveted gauntlets"
desc = "Riveted gauntlets made out of iron. They're covered in rust.. at least the glove liner is good still."
@@ -104,6 +110,14 @@
sleeved = 'icons/roguetown/clothing/special/onmob/evilarmor.dmi'
sellprice = 0 // See above comment
+/obj/item/clothing/gloves/plate/graggar/heavy
+ name = "vicious plated gauntlets"
+ desc = "Steel plated gauntlets overlaid by an ornamental imagery of fractured bone and entrails. The violet smears; a tether to the life that once was - and now, a stinging reminder of what could've been."
+ icon_state = "graggarplategloves_heavy"
+ sleeved = 'icons/roguetown/clothing/onmob/gloves.dmi'
+ icon = 'icons/roguetown/clothing/gloves.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/gloves.dmi'
+
//............... Gronnic gloves ............... //
/obj/item/clothing/gloves/plate/iron/gronn
name = "osslandic iron gauntlets"
@@ -113,3 +127,21 @@
mob_overlay_icon = 'icons/roguetown/clothing/special/onmob/gronn.dmi'
sleeved = 'icons/roguetown/clothing/special/onmob/gronn.dmi'
+/obj/item/clothing/gloves/plate/kote
+ name = "jjajeungna gauntlets"
+ desc = "A set of reinforced Blackmeadow gauntlets. Difficult to do much other than fight in, but not entirely arresting."
+ icon_state = "kazengungauntlets"
+ item_state = "kazengungauntlets"
+ body_parts_covered = HANDS|ARMS
+ detail_tag = "_detail"
+
+/obj/item/clothing/gloves/plate/kote/attack_hand_secondary(mob/user, list/modifiers)
+ . = ..()
+ var/choice = input(user, "Choose a color.", "Uniform colors") as anything in COLOR_MAP
+ var/playerchoice = COLOR_MAP[choice]
+ detail_color = playerchoice
+ update_appearance()
+ if(loc == user && ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_armor()
+ H.update_icon()
diff --git a/code/modules/clothing/gloves/race.dm b/code/modules/clothing/gloves/race.dm
new file mode 100644
index 00000000000..bc93d820e9d
--- /dev/null
+++ b/code/modules/clothing/gloves/race.dm
@@ -0,0 +1,9 @@
+/obj/item/clothing/gloves/plate/dwarven
+ name = "grudgebearer dwarven gauntlets"
+ desc = "Forged to fit the stubbiest of fingers."
+ icon = 'icons/roguetown/clothing/special/race_armor.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/special/onmob/race_armor.dmi'
+ allowed_race = list(SPEC_ID_DWARF)
+ icon_state = "dwarfhand"
+ item_state = "dwarfhand"
+ item_weight = 1.65 KILOGRAMS
diff --git a/code/modules/clothing/gloves/rare.dm b/code/modules/clothing/gloves/rare.dm
index 3c4e6dc193a..68d7d1ce9e4 100644
--- a/code/modules/clothing/gloves/rare.dm
+++ b/code/modules/clothing/gloves/rare.dm
@@ -36,14 +36,6 @@
icon_state = "welfhand"
item_weight = 1.65 KILOGRAMS
-/obj/item/clothing/gloves/rare/dwarfplate
- name = "dwarvish plate gauntlets"
- desc = "Plated gauntlets of masterwork dwarven smithing, the pinnacle of protection for one's hands."
- icon_state = "dwarfhand"
- allowed_race = list(SPEC_ID_DWARF)
- allowed_sex = list(MALE, FEMALE)
- item_weight = 1.65 KILOGRAMS
-
/obj/item/clothing/gloves/rare/grenzelplate
name = "grenzelhoftian plate gauntlets"
desc = "Battling the Zaladins led to the exchange of military ideas. The Grenzelhoft adopted refined chain and plate armaments to better allow their knights unmatchable resilience against the enemies of their Empire."
diff --git a/code/modules/clothing/head/helmets/heavy.dm b/code/modules/clothing/head/helmets/heavy.dm
index 362365511d5..8676c91f4cb 100644
--- a/code/modules/clothing/head/helmets/heavy.dm
+++ b/code/modules/clothing/head/helmets/heavy.dm
@@ -30,6 +30,51 @@
prevent_crits = ALL_EXCEPT_BLUNT
block2add = FOV_BEHIND
+/obj/item/clothing/head/helmet/heavy/undivided
+ name = "templar silver sallet"
+ desc = "A silver-plated jousting helm, and symbol of hope worn by the Azurian Sect of The Undivided. Those who don it have sworn to lay down their lyves for the greater good, for no cost is too great to preserve Their will."
+ icon_state = "silversallet"
+ item_state = "silversallet"
+
+/obj/item/clothing/head/helmet/heavy/undivided/attackby(obj/item/W, mob/living/user, params)
+ ..()
+ if(istype(W, /obj/item/natural/cloth) && !detail_tag)
+ var/choice = input(user, "Choose a color.", "Orle") as anything in COLOR_MAP
+ user.visible_message(span_warning("[user] adds [W] to [src]."))
+ user.transferItemToLoc(W, src, FALSE, FALSE)
+ detail_color = COLOR_MAP[choice]
+ detail_tag = "_detail"
+ update_icon()
+ if(loc == user && ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_head()
+
+/obj/item/clothing/head/helmet/heavy/bronze
+ name = "bronze barbute"
+ desc = "A greathelm of bronze, who's nasalguard and mandibles leave the wearer's face cloaked in darkness. The heroes of yore have long since passed, yet their blood still courses through the veins of Psydonia's children; you are no different. Quiff a feather to its skullcap to bare your allegience with pride."
+ body_parts_covered = FULL_HEAD
+ icon_state = "bronzebarbute"
+ item_state = "bronzebarbute"
+ flags_inv = HIDEEARS|HIDEFACE
+ flags_cover = HEADCOVERSEYES | HEADCOVERSMOUTH
+ block2add = FOV_BEHIND
+ melting_material = /datum/material/bronze
+ max_integrity = ARMOR_INT_HELMET_HEAVY_IRON
+ armor_class = AC_MEDIUM
+
+/obj/item/clothing/head/helmet/heavy/bronze/attackby(obj/item/W, mob/living/user, params)
+ ..()
+ if(istype(W, /obj/item/natural/feather) && !detail_tag)
+ var/choice = input(user, "Choose a color.", "Plume") as anything in COLOR_MAP
+ detail_color = COLOR_MAP[choice]
+ detail_tag = "_detail"
+ user.visible_message(span_warning("[user] adds [W] to [src]."))
+ user.transferItemToLoc(W, src, FALSE, FALSE)
+ update_icon()
+ if(loc == user && ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_head()
+
/obj/item/clothing/head/helmet/heavy/psydonbarbute
name = "psydonian barbute"
desc = "A barbute styled with Psydonian Imagery."
@@ -73,6 +118,40 @@
max_integrity = INTEGRITY_STANDARD
item_weight = 2.4 KILOGRAMS
+/obj/item/clothing/head/helmet/heavy/kabuto
+ name = "kabuto"
+ desc = "A Blackmeadow helmet of steel plates, gilded in blacksteel and gold trim alike to evoke feelings of nobility and strength. Commonly worn with a mask or mouthguard."
+ flags_inv = HIDEEARS|HIDEHAIR
+ flags_cover = null
+ icon_state = "kazengunheavyhelm"
+
+/obj/item/clothing/head/helmet/heavy/aalloy
+ name = "decrepit barbute"
+ desc = "Frayed bronze plates, pounded into a visored helmet. Scrapes and dents line the curved plating, weathered from centuries of neglect. The remains of a plume's stub hang atop its rim."
+ body_parts_covered = COVERAGE_HEAD
+ max_integrity = ARMOR_INT_HELMET_HEAVY_DECREPIT
+ icon_state = "ancientbarbute"
+ material_category = ARMOR_MAT_PLATE
+ anvilrepair = null
+ worn_x_dimension = 64
+ worn_y_dimension = 64
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/64x64/head.dmi' //Uses the new 'greatplume + orle' system. If this glitches out, I made sure to include a fully-prepared 32x32 version - with details - in head.dmi.
+ bloody_icon = 'icons/effects/blood64x64.dmi'
+ item_weight = 2.4 KILOGRAMS
+
+/obj/item/clothing/head/helmet/heavy/aalloy/attackby(obj/item/W, mob/living/user, params)
+ ..()
+ if(istype(W, /obj/item/natural/feather) && !detail_tag)
+ var/choice = input(user, "Choose a color.", "Plume") as anything in COLOR_MAP
+ detail_color = COLOR_MAP[choice]
+ detail_tag = "_detail"
+ user.visible_message(span_warning("[user] adds [W] to [src]."))
+ user.transferItemToLoc(W, src, FALSE, FALSE)
+ update_icon()
+ if(loc == user && ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_head()
+
//............... Great Helm ............... //
/obj/item/clothing/head/helmet/heavy/bucket
name = "great helm"
@@ -84,6 +163,16 @@
prevent_crits = ALL_CRITICAL_HITS
item_weight = 4.3 KILOGRAMS
+/obj/item/clothing/head/helmet/heavy/bucket/keeper
+ name = "keeper's stone mask"
+ desc = "A hooded stone mask worn by Pestran keepers. Their face, oft marred by disease doth not hold value, for it is the pursuit of knowledge of the heartbeast that is the true cause."
+ icon_state = "keeperhelm"
+ item_state = "keeperhelm"
+ // Best approximation for stone as we have no standard!
+ armor = ARMOR_PLATE
+ armor_class = AC_LIGHT
+ smeltresult = null
+
/obj/item/clothing/head/helmet/heavy/bucket/gold
icon_state = "topfhelm_gold"
item_weight = 8.6 KILOGRAMS
@@ -117,6 +206,32 @@
block2add = FOV_BEHIND
sellprice = 0 // Incredibly evil Zizoid armor, this should be burnt, nobody wants this
+/obj/item/clothing/head/helmet/heavy/zizo/volfhelm
+ name = "avantyne volf-face bascinet"
+ desc = "A terminal prognosis, a lethal parasite; unholy strands of avantyne, worming their way through the steel to make something greater. Progress is an agonising process, both unto flesh and metal."
+ icon_state = "volfplate_avantyne"
+ item_state = "volfplate_avantyne"
+ icon = 'icons/roguetown/clothing/head.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/head.dmi'
+ adjustable = CAN_CADJUST
+ emote_environment = 3
+ flags_inv = HIDEEARS|HIDEFACE|HIDEHAIR
+ flags_cover = HEADCOVERSEYES | HEADCOVERSMOUTH
+ max_integrity = ARMOR_INT_HELMET_HEAVY_STEEL - ARMOR_INT_HELMET_HEAVY_ADJUSTABLE_PENALTY
+ melting_material = /datum/material/steel
+
+/obj/item/clothing/head/helmet/heavy/zizo/bascinet
+ name = "avantyne bascinet"
+ desc = "A darksteeled bascinet, perpetually backlit with an eerie crimson haze. Glimpse into the abyss for too long....and something will look back."
+ icon_state = "zizobascinet"
+ item_state = "zizobascinet"
+ icon = 'icons/roguetown/clothing/head.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/head.dmi'
+ flags_inv = HIDEFACE|HIDEEARS|HIDEHAIR
+ body_parts_covered = HEAD|EARS|HAIR
+ adjustable = CANT_CADJUST
+ melting_material = /datum/material/steel
+
//............... Matthios Helmet ............... //
/obj/item/clothing/head/helmet/heavy/matthios
@@ -151,6 +266,13 @@
block2add = FOV_BEHIND
sellprice = 0 // See above comment
+/obj/item/clothing/head/helmet/heavy/graggar/skull
+ name = "vicious skullhelm"
+ desc = "Nigh like a crushed skull worn with pride; as sturdy as one that has seen fractures.. and survived them, too. Godliness was never meant to be tainted with minds so fragile and passionate."
+ icon_state = "graggarplatehelm_heavy"
+ icon = 'icons/roguetown/clothing/head.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/head.dmi'
+
//............... Baothan Helmet ............... //
/obj/item/clothing/head/helmet/heavy/baotha
@@ -236,7 +358,6 @@
item_state = "eorahelm"
item_weight = 3.2 KILOGRAMS
-
//............... Pestra Helmet ............... //
/obj/item/clothing/head/helmet/heavy/necked/pestrahelm
name = "pestran helmet"
diff --git a/code/modules/clothing/head/helmets/misc.dm b/code/modules/clothing/head/helmets/misc.dm
index a46a0a15df7..6d383851484 100644
--- a/code/modules/clothing/head/helmets/misc.dm
+++ b/code/modules/clothing/head/helmets/misc.dm
@@ -122,6 +122,18 @@
body_parts_covered = COVERAGE_HEAD
item_weight = 2.2 KILOGRAMS
+/obj/item/clothing/head/helmet/kettle/jingasa
+ name = "jingasa"
+ desc = "A steel-reinforced conical hat with a decorative rim of fabric. It protects the head and ears as much as it shields the eyes from the sun."
+ icon_state = "kazengunmedhelm"
+ item_state = "kazengunmedhelm"
+ detail_tag = "_detail"
+ detail_color = "#FFFFFF"
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/head.dmi'
+ flags_inv = HIDEEARS|HIDEHAIR
+ worn_x_dimension = 32
+ worn_y_dimension = 32
+
/obj/item/clothing/head/helmet/kettle/iron
name = "iron kettle helmet"
desc = "A lightweight iron helmet generally worn by crossbowmen and garrison archers."
@@ -133,6 +145,17 @@
item_weight = 2.2 KILOGRAMS
smeltresult = /obj/item/ingot/iron
+/obj/item/clothing/head/helmet/kettle/aalloy
+ name = "decrepit kettle helmet"
+ desc = "A frayed, bronze helmet which protects the top and sides of the head. Atop a resurrected levyman's scalp, it's a sign that forces-most-foul are soon to besiege; and atop a fleshless ballistaeman's skull, it's a sign that you should probably duck."
+ icon_state = "ancientkettle"
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/head.dmi'
+ body_parts_covered = HEAD|HAIR|EARS
+ material_category = ARMOR_MAT_PLATE
+ anvilrepair = null
+ worn_x_dimension = 32
+ worn_y_dimension = 32
+
//................ Kettle Helmet (Slitted)............... //
/obj/item/clothing/head/helmet/kettle/slit
name = "slitted kettle helmet"
@@ -239,6 +262,20 @@
max_integrity = INTEGRITY_STRONG
item_weight = 3.1 KILOGRAMS
+/obj/item/clothing/head/helmet/sallet/beastskull
+ name = "beast skull"
+ desc = "The skull of a horned beast, carved and fashioned into a helmet. An steel skull cap has been inserted on the inside."
+ icon_state = "marauder_head"
+ body_parts_covered = HEAD|EARS|HAIR
+ max_integrity = INTEGRITY_STRONG + 50
+ smeltresult = /obj/item/ingot/steel_slag
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/64x64/head.dmi'
+ worn_x_dimension = 64
+ worn_y_dimension = 64
+ bloody_icon = 'icons/effects/blood64x64.dmi'
+ max_integrity = INTEGRITY_STRONG
+ item_weight = 3.5 KILOGRAMS
+
/obj/item/clothing/head/helmet/sallet/iron
name = "iron sallet"
icon_state = "isallet"
@@ -250,6 +287,20 @@
max_integrity = INTEGRITY_STRONG
item_weight = 3.1 KILOGRAMS
+/obj/item/clothing/head/helmet/sallet/iron/banded
+ name = "banded iron helmet"
+ desc = "A menacing horned half-face iron helmet worn primarily by mercenaries hailing from an unaligned conflict-ridden enclave near the borders of Ossland. \
+ A helmet of this kind was notoriously worn by an unknown person said to kill the last Great Drakyn inhabiting the mountains of Hammerhold."
+ max_integrity = ARMOR_INT_HELMET_HEAVY_IRON
+ armor_class = AC_MEDIUM
+ flags_inv = HIDEEARS|HIDEFACE
+ flags_cover = HEADCOVERSEYES
+ body_parts_covered = HEAD|EARS|HAIR|NOSE|EYES
+ block2add = FOV_BEHIND
+ icon_state = "ibandedhelm"
+ item_state = "ibandedhelm"
+
+
//................ Elf Sallet ............... //
/obj/item/clothing/head/helmet/sallet/elven // blackoak merc helmet
desc = "A steel helmet with a thin gold plating designed for Elven woodland guardians."
@@ -313,13 +364,14 @@
max_integrity = INTEGRITY_STRONG
prevent_crits = ALL_CRITICAL_HITS
abstract_type = /obj/item/clothing/head/helmet/visored
+ var/raise_state = "_raised"
/obj/item/clothing/head/helmet/visored/AdjustClothes(mob/user)
if(loc == user)
playsound(user, "sound/items/visor.ogg", 100, TRUE, -1)
if(adjustable == CAN_CADJUST)
adjustable = CADJUSTED
- icon_state = "[initial(icon_state)]_raised"
+ icon_state = "[initial(icon_state)][raise_state]"
body_parts_covered = COVERAGE_HEAD
flags_inv = HIDEEARS
flags_cover = null
@@ -400,6 +452,28 @@
emote_environment = 3
item_weight = 4.45 KILOGRAMS
+/obj/item/clothing/head/helmet/visored/knight/owl
+ name = "strigidae armet"
+ desc = "An armet of distinct bird like design with a pronounced beak. \
+ Close to the teachings of the moon himself, it shields the curious gaze of the one wearing it. \
+ This one used to be in the hands of a pale elf and may be fitted with a great plume atop, to bear heraldic colors."
+ icon_state = "armetowl"
+ raise_state = "_t"
+
+/obj/item/clothing/head/helmet/visored/knight/aalloy
+ name = "decrepit bascinet"
+ desc = "A chipped greathelm of frayed bronze. The fittings squeal with nauseous annoyance, whenever you move to lift its half-rusted visor up and down. Add a feather to show the colors of your family or allegiance."
+ icon_state = "ancientknight"
+ item_state = "ancientknight"
+ max_integrity = ARMOR_INT_HELMET_HEAVY_DECREPIT
+ material_category = ARMOR_MAT_PLATE
+ anvilrepair = null
+ worn_x_dimension = 64
+ worn_y_dimension = 64
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/64x64/head.dmi' //Uses the new 'greatplume + orle' system. If this glitches out, I made sure to include a fully-prepared 32x32 version - with details - in head.dmi.
+ bloody_icon = 'icons/effects/blood64x64.dmi'
+ raise_state = "_t"
+
/obj/item/clothing/head/helmet/visored/knight/blk
color = CLOTHING_SOOT_BLACK
@@ -415,6 +489,46 @@
armor = ARMOR_PLATE_BAD
max_integrity = INTEGRITY_STRONG
+
+/obj/item/clothing/head/helmet/visored/gold
+ name = "golden knight's armet"
+ desc = "A resplendant armet, masterfully forged from pure gold. Hexagrammic etchings of a holy sigil line its visor, and its interior is fitted with a besilked arming cap. Even in absolute darkness, the polished surface sparkles with imbued sunlight."
+ icon_state = "goldknight"
+ armor = ARMOR_HEAD_HELMET_VISOR //Renders its wearer completely invulnerable to damage. The caveat is, however..
+ max_integrity = ARMOR_INT_HELMET_HEAVY_IRON // ..is that it's extraordinarily fragile. To note, this is lower than even Decrepit-tier armor.
+ armor_class = AC_HEAVY //Ceremonial. Heavy is the head that bares the burden.
+ anvilrepair = null
+ melting_material = /datum/material/gold
+ grid_height = 96 //Prevents 'armorstacking'. That, and it's like.. carrying a golden watermelon.
+ grid_width = 96
+ sellprice = 200
+ worn_x_dimension = 64
+ worn_y_dimension = 64
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/64x64/head.dmi'
+ bloody_icon = 'icons/effects/blood64x64.dmi'
+ raise_state = "_t"
+ item_weight = 5.6 KILOGRAMS
+
+/obj/item/clothing/head/helmet/visored/gold/attackby(obj/item/W, mob/living/user, params)
+ ..()
+ if(istype(W, /obj/item/natural/feather) && !detail_tag)
+ var/choice = input(user, "Choose a color.", "Greatplume") as anything in COLOR_MAP
+ detail_color = COLOR_MAP[choice]
+ detail_tag = "_detail"
+ user.visible_message(span_warning("[user] adds [W] to [src]."))
+ user.transferItemToLoc(W, src, FALSE, FALSE)
+ update_icon()
+ if(loc == user && ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_head()
+
+/obj/item/clothing/head/helmet/visored/gold/king
+ name = "royal golden armet"
+ desc = "A resplendant armet, masterfully forged from pure gold. Hexagrammic etchings of a holy sigil line its visor, and its interior is fitted with a besilked arming cap. The dorpeled crown atop its brow invokes authority, be it misbegotten or endowed."
+ icon_state = "goldknight_crown"
+ sellprice = 300
+
+
//................. Royal Knight's helmet .............. //
/obj/item/clothing/head/helmet/visored/royalknight
name = "royal knights helmet"
@@ -616,3 +730,59 @@
else
user.visible_message(span_warning("[user] stops reshaping [src]."))
return
+
+/obj/item/clothing/head/helmet/bronze
+ name = "bronze illyriahelm"
+ desc = "A helmet of bronze, older-in-design than you could possibly imagine. Mounted to its crest is a decorative sigil that has \
+ sparked scholarly debates for the better part of a millennium; is it a star, a vortex, or the Sun? A notch behind the sigil \
+ allows for the joint mounting of a plume. Nock a feather into it to show off your alliegence's colors."
+ max_integrity = ARMOR_INT_HELMET_IRON - 25 //Close, but no cigar.
+ material_category = ARMOR_MAT_PLATE
+ body_parts_covered = HEAD|HAIR|EARS
+ icon_state = "bronzehelmet"
+ item_state = "bronzehelmet"
+ worn_x_dimension = 64
+ worn_y_dimension = 64
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/64x64/head.dmi'
+ bloody_icon = 'icons/effects/blood64x64.dmi'
+ melting_material = /datum/material/bronze
+
+/obj/item/clothing/head/helmet/bronze/attackby(obj/item/W, mob/living/user, params)
+ ..()
+ if(istype(W, /obj/item/natural/feather) && !detail_tag)
+ var/choice = input(user, "Choose a color.", "Greatplume") as anything in COLOR_MAP
+ detail_color = COLOR_MAP[choice]
+ detail_tag = "_detail"
+ user.visible_message(span_warning("[user] adds [W] to [src]."))
+ user.transferItemToLoc(W, src, FALSE, FALSE)
+ update_icon()
+ if(loc == user && ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_head()
+
+/obj/item/clothing/head/helmet/bronzegladiator
+ name = "bronze murmillo"
+ desc = "A bronze helmet that veils the wearer's face behind a perforated visor; a distant ancestor to both the sallet and sayovard, \
+ providing excellent coverage while ensuring one doesn't suffocate on their own adrenal huffs. Out of all actorial labors, none surpass \
+ the reenactment of Ravox's duel against Graggar atop Ur-Syon's ruins - mythologized not as a tentacled star, but as a towering doppelganger-champion; \
+ sculpted by the followers of evil to be the inverse to all who stood for justice and chivalry."
+ max_integrity = ARMOR_INT_HELMET_IRON - 100
+ armor_class = AC_LIGHT
+ material_category = ARMOR_MAT_PLATE
+ body_parts_covered = FULL_HEAD
+ icon_state = "bronzemurmillo"
+ item_state = "bronzemurmillo"
+ melting_material = /datum/material/bronze
+
+/obj/item/clothing/head/helmet/bronzegladiator/attackby(obj/item/W, mob/living/user, params)
+ ..()
+ if(istype(W, /obj/item/natural/cloth) && !detail_tag)
+ var/choice = input(user, "Choose a color.", "Orle") as anything in COLOR_MAP
+ user.visible_message(span_warning("[user] adds [W] to [src]."))
+ user.transferItemToLoc(W, src, FALSE, FALSE)
+ detail_color = COLOR_MAP[choice]
+ detail_tag = "_detail"
+ update_icon()
+ if(loc == user && ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_head()
diff --git a/code/modules/clothing/head/hood.dm b/code/modules/clothing/head/hood.dm
index e2561ad6207..91438bb1c29 100644
--- a/code/modules/clothing/head/hood.dm
+++ b/code/modules/clothing/head/hood.dm
@@ -201,3 +201,31 @@
/obj/item/clothing/head/roguehood/leather/masterwork/Initialize()
. = ..()
filters += filter(type="drop_shadow", x=0, y=0, size=0.5, offset=1, color=rgb(218, 165, 32))
+
+/obj/item/clothing/head/roguehood/studded
+ name = "studded hood"
+ desc = "A padded hood splinted across creating a cocoon for whoever wears it - won't protect your face however."
+ icon_state = "studhood"
+ item_state = "studhood"
+ body_parts_covered = NECK | HEAD | HAIR
+ slot_flags = ITEM_SLOT_HEAD
+ flags_inv = HIDEEARS|HIDEHAIR
+ blocksound = SOFTHIT
+ armor = ARMOR_LEATHER
+ max_integrity = ARMOR_INT_CHEST_LIGHT_MASTER
+ dynamic_hair_suffix = ""
+ edelay_type = 1
+ adjustable = CAN_CADJUST
+ toggle_icon_state = TRUE
+ block2add = null
+ salvage_result = /obj/item/natural/cloth
+ salvage_amount = 1
+
+ color = CLOTHING_BROWN
+
+/obj/item/clothing/head/roguehood/studded/retinue //For skirmisher
+ name = "guard studded hood"
+ desc = "A padded hood splinted across creating a cocoon for whoever wears it - won't protect your face however. This one bears the heraldry of the local lord."
+ detail_tag = "_detail"
+ color = CLOTHING_AZURE
+ detail_color = CLOTHING_WHITE
diff --git a/code/modules/clothing/head/misc.dm b/code/modules/clothing/head/misc.dm
index 0e60295a129..aa38ea56811 100644
--- a/code/modules/clothing/head/misc.dm
+++ b/code/modules/clothing/head/misc.dm
@@ -291,6 +291,12 @@
/obj/item/clothing/head/headdress/alt
icon_state = "headdressalt"
+/obj/item/clothing/head/dancer_headdress // egyptian
+ name = "dancer's headdress"
+ desc = ""
+ icon_state = "headdress_dance"
+ item_weight = 77 GRAMS
+
/obj/item/clothing/head/armingcap/colored
misc_flags = CRAFTING_TEST_EXCLUDE
@@ -433,3 +439,29 @@
toggle_icon_state = TRUE
max_integrity = 200
item_weight = 145 GRAMS
+
+/obj/item/clothing/head/archercap
+ name = "archer's cap"
+ desc = "For the merry men."
+ icon_state = "archercap"
+
+/obj/item/clothing/head/fedora
+ name = "archeologist's hat"
+ desc = "A strangely-shaped hat with dust caked onto its aged leather."
+ icon_state = "curator"
+ item_state = "curator"
+ sewrepair = TRUE
+ salvage_result = /obj/item/natural/hide/cured
+
+/obj/item/clothing/head/leather/inqhat/gravehat
+ name = "gravetender's hat"
+ desc = "A fine leather slouch fitted with a hidden steel skull cap. It serves as a reminder that Necra's grasp is never too far."
+ icon_state = "gravehat"
+ item_state = "gravehat"
+
+/obj/item/clothing/head/explorerhat
+ name = "explorer's hat"
+ desc = "How many secrets can I uncover this week?"
+ icon_state = "explorerhat"
+ item_state = "explorerhat"
+ sewrepair = TRUE
diff --git a/code/modules/clothing/head/race.dm b/code/modules/clothing/head/race.dm
new file mode 100644
index 00000000000..9a042f2d68a
--- /dev/null
+++ b/code/modules/clothing/head/race.dm
@@ -0,0 +1,22 @@
+/obj/item/clothing/head/helmet/heavy/dwarven
+ name = "grudgebearer dwarven helm"
+ desc = "A hardy dwarven helmet. It lets one's dwarvenly beard to poke out."
+ body_parts_covered = HEAD|MOUTH|NOSE|EYES|EARS|NECK //This specifically omits hair so you could hang your beard out of the helm
+ armor = ARMOR_PLATE
+ allowed_race = list(SPEC_ID_DWARF)
+ icon = 'icons/roguetown/clothing/special/race_armor.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/special/onmob/race_armor.dmi'
+ icon_state = "dwarfhead"
+ item_state = "dwarfhead"
+ bloody_icon = 'icons/effects/blood64x64.dmi'
+ melting_material = /datum/material/steel
+ experimental_inhand = TRUE
+ experimental_onhip = TRUE
+ item_weight = 3.5 KILOGRAMS
+
+/obj/item/clothing/head/helmet/heavy/dwarven/smith
+ name = "grudgebearer smith helm"
+ desc = "A hardy dwarven helmet. It lets one's dwarvenly beard to poke out. \
+ This one is intended for the smiths of the clan. No less protective. All the more stylish."
+ icon_state = "dsmithhead"
+ item_state = "dsmithhead"
diff --git a/code/modules/clothing/head/rare.dm b/code/modules/clothing/head/rare.dm
index 58b9474d46c..4faae55bd0f 100644
--- a/code/modules/clothing/head/rare.dm
+++ b/code/modules/clothing/head/rare.dm
@@ -40,18 +40,6 @@
item_weight = 3.5 KILOGRAMS
-//............... Langobard Helmet ............... //
-/obj/item/clothing/head/rare/dwarfplate // Unique Longbeard kit
- name = "langobard pot helm"
- desc = "The Langobards are a cult of personality that are tasked by the Dwarven Kings to issue judgement, \
- justice and order around the realms for dwarvenkind. This helmet is a respected symbol of authority."
- icon_state = "dwarfhead"
- allowed_race = list(SPEC_ID_DWARF)
- flags_inv = HIDEEARS
- clothing_flags = CANT_SLEEP_IN
- body_parts_covered = HEAD_EXCEPT_MOUTH
- item_weight = 3.5 KILOGRAMS
-
//............... Swordmaster Helmet ............... //
/obj/item/clothing/head/rare/grenzelplate // Unique Swordmaster kit
name = "chicklet sallet"
diff --git a/code/modules/clothing/neck/misc.dm b/code/modules/clothing/neck/misc.dm
index a864556724b..0c0fa17767b 100644
--- a/code/modules/clothing/neck/misc.dm
+++ b/code/modules/clothing/neck/misc.dm
@@ -232,6 +232,20 @@
/obj/item/clothing/neck/bellcollar/Initialize()
. = ..()
AddComponent(/datum/component/item_equipped_movement_rustle, custom_sounds = list(SFX_JINGLE_BELLS))
+
+/obj/item/clothing/neck/woolen
+ name = "woolen collar"
+ desc = "A comfortable and thick collar made of wools and cloth, not protective but it sure keeps your neck warm."
+ icon_state = "woolencollar"
+ item_state = "woolencollar"
+ slot_flags = ITEM_SLOT_NECK|ITEM_SLOT_MOUTH
+ salvage_result = /obj/item/natural/cloth
+ salvage_amount = 1
+ dropshrink = 0.5
+ muteinmouth = FALSE
+ spitoutmouth = FALSE
+ sewrepair = TRUE
+
//..................................................................................................................................
/*---------------\
| |
@@ -355,6 +369,13 @@
. = ..()
ADD_TRAIT(src, TRAIT_HARD_TO_STEAL, TRAIT_GENERIC)
+/obj/item/clothing/neck/bevor/bronze
+ name = "bronze gorgette"
+ desc = "A jutting slab of bronze, traditionally mounted atop a panoplic assembly to veil the neck from precise strikes. To tip the chin up while grounded is an ancient gesture; one which willingly beckons for the 'gift of mercy'."
+ icon_state = "bbevor"
+ melt_amount = 75
+ melting_material = /datum/material/bronze
+
/obj/item/clothing/neck/bevor/iron
name = "iron bevor"
desc = "A piece of iron plate armor meant to protect the throat and neck of its wearer against decapitation, extending the protection of armor plates."
@@ -393,6 +414,11 @@
. = ..()
ADD_TRAIT(src, TRAIT_HARD_TO_STEAL, TRAIT_GENERIC)
+/obj/item/clothing/neck/gorget/kazengun
+ name = "blackmeadow gorget"
+ desc = "A series of interlocking rings of metal set around the throat. Used by the kouken of Blackmeadow for precisely the same reason as the knights of Psydonia."
+ icon_state = "kazengunneckguard"
+
/obj/item/clothing/neck/gorget/explosive
name = "collar of servitude"
examine_name = "gorget"
@@ -490,6 +516,21 @@
qdel(src)
+/obj/item/clothing/neck/gorget/gold
+ name = "golden gorget"
+ desc = "A series of resplendant golden plates designed to protect the neck, traditionally worn atop a jacket or cuirass. The holy sigil between its buckled halves promises to carry the flame of its wearer, no matter what strike's poised its way."
+ icon_state = "goldgorget"
+ armor_class = AC_HEAVY //Ceremonial. Heavy is the head that bares the burden.
+ melting_material = /datum/material/gold
+ melt_amount = 75
+ grid_height = 96
+ grid_width = 96
+ sellprice = 200
+
+/obj/item/clothing/neck/gorget/gold/king
+ name = "royal golden gorget"
+ sellprice = 300
+
/obj/item/collar_detonator
name = "collar detonator"
desc = "What seems to be an ordinary key at first is actually an enchanted contraption designed to \
diff --git a/code/modules/clothing/neck/psycross.dm b/code/modules/clothing/neck/psycross.dm
index 29d752cc471..d8370dec320 100644
--- a/code/modules/clothing/neck/psycross.dm
+++ b/code/modules/clothing/neck/psycross.dm
@@ -25,6 +25,30 @@
sellprice = 0
experimental_onhip = TRUE
+
+/obj/item/clothing/neck/psycross/matthios
+ name = "amulet of Matthios"
+ desc = "He was but one flame in the dark. Together, his flock shall outblaze the tyrant sun."
+ icon_state = "matthios"
+ resistance_flags = FIRE_PROOF
+ slot_flags = ITEM_SLOT_NECK|ITEM_SLOT_HIP|ITEM_SLOT_WRISTS
+ smeltresult = null
+
+/obj/item/clothing/neck/psycross/graggar
+ name = "amulet of Graggar"
+ desc = "Blood leads only to glory, and violence begets divinity. Nothing less. Conquest is simply another name for victory."
+ icon_state = "graggar"
+ resistance_flags = FIRE_PROOF
+ smeltresult = null
+
+/obj/item/clothing/neck/psycross/baotha
+ name = "amulet of Baotha"
+ desc = "A hollow promise rendered in gold. It weighs heavy with the memory of sweet wine turned to poison, and the comfort of a sorrow that refuses to fade."
+ icon_state = "baotha"
+ resistance_flags = FIRE_PROOF
+ slot_flags = ITEM_SLOT_NECK|ITEM_SLOT_HIP|ITEM_SLOT_WRISTS
+ smeltresult = null
+
/* // GRONN PSYCROSSES
/obj/item/clothing/neck/psycross/gronn
name = "carved talisman" //plotting talisman
diff --git a/code/modules/clothing/pants/tights.dm b/code/modules/clothing/pants/tights.dm
index d485970c5cf..7152fca8242 100644
--- a/code/modules/clothing/pants/tights.dm
+++ b/code/modules/clothing/pants/tights.dm
@@ -63,3 +63,9 @@
/obj/item/clothing/pants/tights/sailor
name = "pants"
icon_state = "sailorpants"
+
+/obj/item/clothing/pants/tights/explorerpants
+ name = "explorer's pants"
+ desc = "Practical and modest, you hope that it will survive the next cavedive."
+ icon_state = "explorerpants"
+ item_state = "explorerpants"
diff --git a/code/modules/clothing/pants/trousers.dm b/code/modules/clothing/pants/trousers.dm
index 4e0c8729811..1895f828fb0 100644
--- a/code/modules/clothing/pants/trousers.dm
+++ b/code/modules/clothing/pants/trousers.dm
@@ -152,6 +152,7 @@
icon_state = "butlershorts"
item_state = "butlershorts"
detail_color = CLOTHING_SOOT_BLACK
+ childcore = TRUE
/obj/item/clothing/pants/trou/courtphysician
name = "sanguine trousers"
@@ -183,3 +184,22 @@
max_integrity = ARMOR_INT_LEG_HARDLEATHER
icon = 'icons/roguetown/clothing/special/gronn.dmi'
mob_overlay_icon = 'icons/roguetown/clothing/special/onmob/gronn.dmi'
+
+/obj/item/clothing/pants/trou/leather/shepherd
+ name = "shepherd's pants"
+ desc = "A pair of white pants decorated with red stripes and traditional patterning."
+ icon_state = "shepherdpants"
+
+/obj/item/clothing/pants/trou/leather/kazengun //no, not 'eastpants3', silly!
+ name = "gambeson trousers"
+ desc = "A form of Blackmeadow peasant's trousers. The fabric used in their manufacture is strong, and could probably turn away a few blows."
+ icon_state = "baggypants"
+ item_state = "baggypants"
+ max_integrity = ARMOR_INT_LEG_HARDLEATHER - 50
+
+/obj/item/clothing/pants/trou/leather/pontifex
+ name = "pontifex's chaqchur"
+ desc = "A handmade pair of baggy, thin leather pants. They end in a tight stocking around the calf, ballooning out around the thigh."
+ icon_state = "monkpants"
+ item_state = "monkpants"
+ salvage_result = /obj/item/natural/hide/cured
diff --git a/code/modules/clothing/ring/metal_rings.dm b/code/modules/clothing/ring/metal_rings.dm
new file mode 100644
index 00000000000..1726e3013c0
--- /dev/null
+++ b/code/modules/clothing/ring/metal_rings.dm
@@ -0,0 +1,61 @@
+/obj/item/clothing/ring/signet/psy
+ name = "psydonian signet ring"
+ icon_state = "psysignet"
+ desc = "A ring of blessed silver, bearing the Archbishop's symbol. Its face is cut to seal writs of religious importance, a bead of tallow nested in the underside."
+ sellprice = 90
+
+/obj/item/clothing/ring/signet/psy/Initialize(mapload)
+ . = ..()
+ enchant(/datum/enchantment/silver)
+
+/obj/item/clothing/ring/signet/psy/get_mechanics_examine(mob/user)
+ . = ..()
+ . += span_info("Stamping a folded ACCUSATION or CONFESSION will increase the amount of MARQUES it'll reward, once sent through the HERMES.")
+ . += span_info("Packing an INDEXER into an ACCUSATION or CONFESSION before folding-and-stamping it will further amplify this financial bonus.")
+
+/obj/item/clothing/ring/signet/psy/g
+ name = "psydonian golden signet ring"
+ icon_state = "psysignet_gold"
+ desc = "A ring of opulent gold, embodying the unforgotten belief in Psydon's eternity. Its face is cut to seal writs of religious importance, a bead of tallow nested in the underside."
+
+/obj/item/clothing/ring/duelist
+ name = "duelist's ring"
+ desc = "Born out of duelists desire for theatrics, this ring denotes a proposal — an honorable duel, with stakes set ahigh.\nIf both duelists wear this ring, successful baits will off balance them, and clashing disarms will never be unlikely.\n'You shall know his name. You shall know his purpose. You shall die.'"
+ icon_state = "ring_duel"
+ sellprice = 10
+
+/obj/item/clothing/ring/emeraldbs
+ name = "gemerald ring of blacksteel"
+ icon_state = "bs_ring_emerald"
+ desc = "A mythical blacksteel ring with a polished Gemerald set into it."
+ sellprice = 295
+
+/obj/item/clothing/ring/rubybs
+ name = "rontz ring of blacksteel"
+ icon_state = "bs_ring_ruby"
+ desc = "A mythical blacksteel ring with a polished Rontz set into it."
+ sellprice = 355
+
+/obj/item/clothing/ring/topazbs
+ name = "toper ring of blacksteel"
+ icon_state = "bs_ring_topaz"
+ desc = "A mythical blacksteel ring with a polished Toper set into it."
+ sellprice = 380
+
+/obj/item/clothing/ring/quartzbs
+ name = "blortz ring of blacksteel"
+ icon_state = "bs_ring_quartz"
+ desc = "A mythical blacksteel ring with a polished Blortz set into it."
+ sellprice = 345
+
+/obj/item/clothing/ring/sapphirebs
+ name = "saffira ring of blacksteel"
+ icon_state = "bs_ring_sapphire"
+ desc = "A mythical blacksteel ring with a polished Saffira set into it."
+ sellprice = 300
+
+/obj/item/clothing/ring/diamondbs
+ name = "dorpel ring of blacksteel"
+ icon_state = "bs_ring_diamond"
+ desc = "A mythical blacksteel ring with a polished Dorpel set into it."
+ sellprice = 370
diff --git a/code/modules/clothing/ring/misc.dm b/code/modules/clothing/ring/misc.dm
index 66160daa140..d012d565125 100644
--- a/code/modules/clothing/ring/misc.dm
+++ b/code/modules/clothing/ring/misc.dm
@@ -189,6 +189,12 @@
. = ..()
qdel(GetComponent(/datum/component/anti_magic))
+/obj/item/clothing/ring/active/nomag/get_mechanics_examine(mob/user)
+ . = ..()
+ . += span_info("Right click to activate the ring's ward, which provides temporary invulnerability against all direct magical attacks for thirty seconds.")
+ . += span_info("Wearers with unholy ailments are also rendered invulnerable to being sundered by silver weaponry, for the ward's duration.")
+ . += span_info("Once the ring's ward is exhausted, it'll require ten minutes to recharge enough power for another activation.")
+
// ................... Ring of Protection ....................... (rare treasure, not for purchase)
/obj/item/clothing/ring/gold/protection
name = "ring of protection"
@@ -256,7 +262,7 @@
/obj/item/clothing/ring/silver/calm
name = "soothing ring"
desc = "A lightweight ring that feels entirely weightless, and easing to your mind as you place it upon a finger."
- icon_state = "ring_calm"
+ icon_state = "s_newring_quartz"
/obj/item/clothing/ring/silver/calm/equipped(mob/living/user, slot)
. = ..()
@@ -291,35 +297,6 @@
UnregisterSignal(wearer, COMSIG_MOB_UNEQUIPPED_ITEM)
wearer.remove_status_effect(/datum/status_effect/buff/noc)
-/obj/item/clothing/ring/dragon_ring
- name = "dragon ring"
- icon_state = "ring_g" // supposed to have it's own sprite but I'm lazy asf
- desc = "Carrying the likeness of a dragon, this glorious ring hums with a subtle energy."
- sellprice = 666
- var/active_item
-
-/obj/item/clothing/ring/dragon_ring/equipped(mob/living/user, slot)
- . = ..()
- if(active_item)
- return
- else if(slot & ITEM_SLOT_RING)
- active_item = TRUE
- to_chat(user, span_notice("Here be dragons."))
- user.change_stat(STAT_STRENGTH, 2)
- user.change_stat(STAT_CONSTITUTION, 2)
- user.change_stat(STAT_ENDURANCE, 2)
- return
-
-/obj/item/clothing/ring/dragon_ring/dropped(mob/living/user)
- ..()
- if(active_item)
- to_chat(user, span_notice("Gone is thy hoard."))
- user.change_stat(STAT_STRENGTH, -2)
- user.change_stat(STAT_CONSTITUTION, -2)
- user.change_stat(STAT_ENDURANCE, -2)
- active_item = FALSE
- return
-
/obj/item/clothing/ring/signet
name = "Signet Ring"
name = "signet ring"
@@ -330,6 +307,7 @@
sellprice = 135
sellprice = 135
var/tallowed = FALSE
+ var/tallow_color = "red"
/obj/item/clothing/ring/signet/silver
name = "silver signet ring"
@@ -352,10 +330,16 @@
/obj/item/clothing/ring/signet/update_icon_state()
. = ..()
if(tallowed)
- icon_state = "[icon_state]_stamp"
+ icon_state = "[initial(icon_state)]_[tallow_color]_stamp"
else
icon_state = initial(icon_state)
+/obj/item/clothing/ring/signet/get_mechanics_examine(mob/user)
+ . = ..()
+ . += span_info("Certain letters can be folded and stamped with the ring, which proves minor financial benefits.")
+ . += span_info("Pressed upon a quest scroll by a Steward, Clerk, or Grand Duke, the ring stamps it LEVY EXEMPT - waiving the Crown's Contract Levy on its reward.")
+
+
// ................... The Feldsher's ring .......................
/obj/item/clothing/ring/feldsher_ring
diff --git a/code/modules/clothing/ring/stat_rings.dm b/code/modules/clothing/ring/stat_rings.dm
new file mode 100644
index 00000000000..08bead7c410
--- /dev/null
+++ b/code/modules/clothing/ring/stat_rings.dm
@@ -0,0 +1,219 @@
+
+//Anything above +1 that bestows positive traits or has no downsides should be restricted to higher-tier dungeons and loot pools.
+//Anything below that - either a +1, or anything that comes with a negative trait or malus - should be acceptable for lower-tier dungeons and loot pools.
+//These rings shouldn't be craftable under any circumstance, unless it involves combining multiple rings or rare components. Don't add recipes unless you absolutely know what you're doing.
+
+/datum/attribute_modifier/statgemerald
+ id = "Ring of Swiftness"
+ attribute_list = list(
+ STAT_SPEED = 1,
+ STAT_FORTUNE = 1,
+ )
+
+/obj/item/clothing/ring/statgemerald
+ name = "ring of swiftness"
+ desc = "A gemerald ring, glimmering with verdant brilliance. The closer your hand drifts to it, the stronger that the wind howls."
+ icon_state = "g_newring_emerald"
+ sellprice = 222
+ var/active_item
+
+/obj/item/clothing/ring/statgemerald/equipped(mob/living/user, slot)
+ . = ..()
+ if(active_item)
+ return
+ else if(slot == ITEM_SLOT_RING)
+ active_item = TRUE
+ to_chat(user, span_green("'..the way of lyfe, bountiful but fleeting..'"))
+ user.attributes?.add_attribute_modifier(/datum/attribute_modifier/statgemerald)
+ return
+
+/obj/item/clothing/ring/statgemerald/dropped(mob/living/user)
+ ..()
+ if(active_item)
+ to_chat(user, span_green("'..but without an end to the journey, what would become of lyfe's meaning?'"))
+ user.attributes?.remove_attribute_modifier(/datum/attribute_modifier/statgemerald)
+ active_item = FALSE
+ return
+
+/datum/attribute_modifier/statonyx
+ id = "Ring of Vitality"
+ attribute_list = list(
+ STAT_CONSTITUTION = 1,
+ STAT_ENDURANCE = 1,
+ )
+
+/obj/item/clothing/ring/statonyx
+ name = "ring of vitality"
+ desc = "An onyx ring, shining with violet determination. The closer your hand drifts to it, the faster your heart pounds."
+ icon_state = "g_newring_quartz"
+ sellprice = 222
+ var/active_item
+
+/obj/item/clothing/ring/statonyx/equipped(mob/living/user, slot)
+ . = ..()
+ if(active_item)
+ return
+ else if(slot == ITEM_SLOT_RING)
+ active_item = TRUE
+ to_chat(user, span_purple("'..the way of blood, shed from you in vain..'"))
+ user.attributes?.add_attribute_modifier(/datum/attribute_modifier/statonyx)
+ return
+
+/obj/item/clothing/ring/statonyx/dropped(mob/living/user)
+ ..()
+ if(active_item)
+ to_chat(user, span_purple("'..but if you don't stand for those who cannot, who will?'"))
+ user.attributes?.remove_attribute_modifier(/datum/attribute_modifier/statonyx)
+ active_item = FALSE
+ return
+
+/datum/attribute_modifier/statamythortz
+ id = "Ring of Wisdom"
+ attribute_list = list(
+ STAT_INTELLIGENCE = 1,
+ STAT_PERCEPTION = 1,
+ )
+
+/obj/item/clothing/ring/statamythortz
+ name = "ring of wisdom"
+ desc = "A saffira ring, crackling with azuric fascination. The closer your hand drifts to it, the clearer your mind becomes."
+ icon_state = "g_newring_sapphire"
+ sellprice = 222
+ var/active_item
+
+/obj/item/clothing/ring/statamythortz/equipped(mob/living/user, slot)
+ . = ..()
+ if(active_item)
+ return
+ else if(slot == ITEM_SLOT_RING)
+ active_item = TRUE
+ to_chat(user, span_rose("'..the way of knowledge, cursing its pursuers with inzanity..'"))
+ user.attributes?.add_attribute_modifier(/datum/attribute_modifier/statamythortz)
+ return
+
+/obj/item/clothing/ring/statamythortz/dropped(mob/living/user)
+ ..()
+ if(active_item)
+ to_chat(user, span_rose("'..but if we root ourselves in the thoughtless, how else will we progress?'"))
+ user.attributes?.remove_attribute_modifier(/datum/attribute_modifier/statamythortz)
+ active_item = FALSE
+ return
+
+/datum/attribute_modifier/statrontz
+ id = "Ring of Courage"
+ attribute_list = list(
+ STAT_STRENGTH = 1,
+ )
+
+/obj/item/clothing/ring/statrontz
+ name = "ring of courage"
+ desc = "A rontz ring, radiating with crimson authority. The closer your hand drifts to it, the tighter your knuckles curl."
+ icon_state = "g_newring_ruby"
+ sellprice = 222
+ var/active_item
+
+/obj/item/clothing/ring/statrontz/equipped(mob/living/user, slot)
+ . = ..()
+ if(active_item)
+ return
+ else if(slot == ITEM_SLOT_RING)
+ active_item = TRUE
+ to_chat(user, span_red("'..the way of death, indiscriminate and total..'"))
+ user.attributes?.add_attribute_modifier(/datum/attribute_modifier/statrontz)
+ return
+
+/obj/item/clothing/ring/statrontz/dropped(mob/living/user)
+ ..()
+ if(active_item)
+ to_chat(user, span_red("'..but without violence, what would stop evil from triumphing?'"))
+ user.attributes?.remove_attribute_modifier(/datum/attribute_modifier/statrontz)
+ active_item = FALSE
+ return
+
+/datum/attribute_modifier/statdorpel
+ id = "Ring of Omnipotence"
+ attribute_list = list(
+ STAT_STRENGTH = 1,
+ STAT_CONSTITUTION = 1,
+ STAT_ENDURANCE = 1,
+ STAT_SPEED = 1,
+ STAT_INTELLIGENCE = 1,
+ STAT_PERCEPTION = 1,
+ STAT_FORTUNE = 1,
+ )
+
+/obj/item/clothing/ring/statdorpel
+ name = "ring of omnipotence"
+ desc = "A dorpel ring, glowing with resplendent beauty. The closer your hand drifts to it, the more that your fears melt away."
+ icon_state = "newmulticolor"
+ smeltresult = /obj/item/riddleofsteel
+ sellprice = 777
+ var/active_item
+
+/obj/item/clothing/ring/statdorpel/Initialize(mapload)
+ . = ..()
+ enchant(/datum/enchantment/silver)
+
+/obj/item/clothing/ring/statdorpel/equipped(mob/living/user, slot)
+ . = ..()
+ if(active_item)
+ return
+ else if(slot == ITEM_SLOT_RING)
+ active_item = TRUE
+ to_chat(user, span_blue("'..the way of hope, unbreakable and unifying..'"))
+ user.attributes?.add_attribute_modifier(/datum/attribute_modifier/statdorpel)
+ user.add_chem_effect(CE_ENERGETIC, 5, "[type]")
+ return
+
+/obj/item/clothing/ring/statdorpel/dropped(mob/living/user)
+ ..()
+ if(active_item)
+ to_chat(user, span_blue("'..I know that kindness exists, for I am kind..' '..I know hope exists, for I have hope..' '..and I know love exists, for I love.'"))
+ user.attributes?.remove_attribute_modifier(/datum/attribute_modifier/statdorpel)
+ user.remove_chem_effect(CE_ENERGETIC, "[type]")
+ active_item = FALSE
+ return
+
+/datum/attribute_modifier/dragon_ring
+ id = "Dragon Ring"
+ attribute_list = list(
+ STAT_STRENGTH = 2,
+ STAT_CONSTITUTION = 2,
+ STAT_ENDURANCE = 2,
+ )
+
+/obj/item/clothing/ring/dragon_ring
+ name = "dragonstone ring"
+ icon_state = "dragonring" //Should be safe for vampyres to wear, as the ring itself isn't made of silver. If they've suffered enough to make this ring, they should be able to wear it.
+ desc = "A gilded blacksteel ring with a drake's head, sculpted from silver. Perched within its sockets is a blortz and saffira - each, crackling with the reflection of a raging fire."
+ melting_material = /datum/material/draconic
+ melt_amount = 100
+ sellprice = 666
+ var/active_item
+
+/obj/item/clothing/ring/dragon_ring/equipped(mob/living/user, slot)
+ . = ..()
+ if(active_item)
+ return
+ else if(slot == ITEM_SLOT_RING)
+ active_item = TRUE
+ to_chat(user, span_suicide("Draconic fire courses through my veins! I feel powerful!"))
+ user.attributes?.add_attribute_modifier(/datum/attribute_modifier/dragon_ring)
+ update_icon()
+ return
+
+/obj/item/clothing/ring/dragon_ring/dropped(mob/living/user)
+ ..()
+ if(active_item)
+ to_chat(user, span_suicide("A chilling sensation courses through my body, and the ring's heat remains oh-so-alluring.. ..yet, one must wonder.. could such fiery strength withstand a forge's heat?"))
+ user.attributes?.remove_attribute_modifier(/datum/attribute_modifier/dragon_ring)
+ active_item = FALSE
+ update_icon()
+ return
+
+/obj/item/clothing/ring/dragon_ring/update_icon()
+ ..()
+ if(active_item)
+ icon_state = "factive"
+ else
+ icon_state = "dragonring"
diff --git a/code/modules/clothing/ring/weddingbands.dm b/code/modules/clothing/ring/weddingbands.dm
new file mode 100644
index 00000000000..9b6cb5435da
--- /dev/null
+++ b/code/modules/clothing/ring/weddingbands.dm
@@ -0,0 +1,54 @@
+/obj/item/clothing/ring/band
+ name = "silver weddingband"
+ desc = "A glimmering weddingband of silver, ornately decorated with the engravings of a lover's name."
+ icon_state = "s_ring_wedding"
+ sellprice = 3 //You don't get to smelt this down or sell it. No free mams for a loadout item.
+ var/choicename = FALSE
+ var/choicedesc = FALSE
+
+
+/obj/item/clothing/ring/band/attack_hand_secondary(mob/user, list/modifiers)
+ if(choicename)
+ return
+ if(choicedesc)
+ return
+ var/current_time = world.time
+ var/namechoice = input(user, "Input a new name", "Rename Object")
+ var/descchoice = input(user, "Input a new description", "Describe Object")
+ if(namechoice)
+ name = namechoice
+ choicename = TRUE
+ if(descchoice)
+ desc = descchoice
+ choicedesc = TRUE
+ else
+ return
+ if(world.time > (current_time + 30 SECONDS))
+ return
+
+/obj/item/clothing/ring/band/get_mechanics_examine(mob/user)
+ . = ..()
+ . += span_info("Right-click to add a custom name and description to the weddingband.")
+ . += span_info("If your character is meant to be already married to someone else, offer the ring to them while they are offering theirs to you. This will mark you as spouses, but will not change your names.")
+
+/obj/item/clothing/ring/band/gold
+ name = "gold weddingband"
+ desc = "A beautiful weddingband of gold, ornately decorated with the engravings of a lover's name."
+ icon_state = "g_ring_wedding"
+
+/obj/item/clothing/ring/band/bronze
+ name = "bronze weddingband"
+ desc = "A resilient weddingband of bronze, ornately decorated with the engravings of a lover's name."
+ icon_state = "b_ring_wedding"
+
+/obj/item/clothing/ring/band/aalloy
+ name = "decrepit weddingband"
+ desc = "A decaying weddingband of tarnished bronze, ornately decorated with the engravings of a lover's name."
+ icon_state = "a_ring_wedding"
+ color = "#bb9696"
+ anvilrepair = null
+
+/obj/item/clothing/ring/band/paalloy
+ name = "ancient weddingband"
+ desc = "An enchanting weddingband of polished gilbranze, ornately decorated with the engravings of a lover's name."
+ icon_state = "a_ring_wedding"
diff --git a/code/modules/clothing/shirts/misc.dm b/code/modules/clothing/shirts/misc.dm
index 467221be77b..0caf4b26cc6 100644
--- a/code/modules/clothing/shirts/misc.dm
+++ b/code/modules/clothing/shirts/misc.dm
@@ -159,3 +159,72 @@
item_state = "vrobe"
r_sleeve_status = SLEEVE_NORMAL
l_sleeve_status = SLEEVE_NORMAL
+
+
+/obj/item/clothing/shirt/fancyjacket
+ name = "fancy jacket"
+ desc = "My, so modern -- so elegant. Which fine hands sewed this?"
+ icon_state = "fancyjacket"
+ icon = 'icons/roguetown/clothing/shirts.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/shirts.dmi'
+ sleeved = 'icons/roguetown/clothing/onmob/helpers/sleeves_shirts.dmi'
+ body_parts_covered = CHEST|GROIN|ARMS|VITALS
+ boobed = FALSE
+ flags_inv = HIDEBOOB
+ r_sleeve_status = SLEEVE_NORMAL
+ l_sleeve_status = SLEEVE_NORMAL
+
+
+/obj/item/clothing/shirt/explorer
+ name = "explorer's vest"
+ desc = "Vest belonging to those who seek knowledge!"
+ icon_state = "explorervest"
+ icon = 'icons/roguetown/clothing/shirts.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/shirts.dmi'
+ sleeved = 'icons/roguetown/clothing/onmob/helpers/sleeves_shirts.dmi'
+ body_parts_covered = CHEST|GROIN|ARMS|VITALS
+ boobed = TRUE
+ flags_inv = HIDEBOOB
+ r_sleeve_status = SLEEVE_NORMAL
+ l_sleeve_status = SLEEVE_NORMAL
+ color = null
+
+/obj/item/clothing/shirt/saree
+ name = "saree"
+ desc = "Expertly made with a single swatch of fabric!"
+ icon_state = "saree"
+ icon = 'icons/roguetown/clothing/shirts.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/shirts.dmi'
+ sleeved = 'icons/roguetown/clothing/onmob/helpers/sleeves_shirts.dmi'
+ detail_tag = "_detail"
+ body_parts_covered = CHEST|GROIN|ARMS|VITALS
+ boobed = TRUE
+ flags_inv = HIDEBOOB
+ r_sleeve_status = SLEEVE_NORMAL
+ l_sleeve_status = SLEEVE_NORMAL
+ color = null
+
+/obj/item/clothing/shirt/dress/slit
+ slot_flags = ITEM_SLOT_ARMOR|ITEM_SLOT_SHIRT
+ name = "slitted dress"
+ desc = "A finely sewn dress with a slit to expose the thigh, how scandalous!"
+ icon_state = "slitdress"
+ item_state = "slitdress"
+ r_sleeve_status = SLEEVE_NOMOD
+ l_sleeve_status = SLEEVE_NOMOD
+
+/obj/item/clothing/shirt/dress/velvetdress
+ name = "velvet dress"
+ desc = "A garment made with embroidered velvet, both elegant and warm. Poetry made manifest in swaying fabric."
+ icon_state = "velvetdress"
+ item_state = "velvetdress"
+ sleeved = 'icons/roguetown/clothing/onmob/helpers/sleeves_shirts.dmi'
+
+/obj/item/clothing/shirt/dress/nobledress
+ name = "noble's pinafore"
+ desc = "A comfortable dress adapted from simpler garments often worn by working-class women."
+ icon_state = "nobledress"
+ item_state = "nobledress"
+ sleeved = 'icons/roguetown/clothing/onmob/helpers/sleeves_shirts.dmi'
+ detail_tag = "_detail"
+ detail_color = CLOTHING_WHITE
diff --git a/code/modules/clothing/shirts/robe.dm b/code/modules/clothing/shirts/robe.dm
index 8c0bd07a30d..3dd62a0f4cf 100644
--- a/code/modules/clothing/shirts/robe.dm
+++ b/code/modules/clothing/shirts/robe.dm
@@ -398,3 +398,36 @@
sleeved = null
sleevetype = null
misc_flags = CRAFTING_TEST_EXCLUDE
+
+/obj/item/clothing/shirt/robe/bared
+ name = "bared robe"
+ desc = "A robe of basic cloth, it's chest bared open to expose what lay underneath."
+ icon_state = "openrobe"
+ item_state = "openrobe"
+ icon = 'icons/roguetown/clothing/armor.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/armor.dmi'
+ color = CLOTHING_WHITE
+
+/obj/item/clothing/shirt/robe/shepherdvest
+ name = "shepherd vest"
+ desc = "A vest of basic cloth, it's chest bared open to expose what lay underneath."
+ icon_state = "shepherdvest"
+ item_state = "shepherdvest"
+ icon = 'icons/roguetown/clothing/armor.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/armor.dmi'
+ color = CLOTHING_WHITE
+
+/obj/item/clothing/shirt/robe/hag //Not a boon item, but nonetheless something they have
+ slot_flags = ITEM_SLOT_ARMOR|ITEM_SLOT_SHIRT|ITEM_SLOT_CLOAK
+ name = "wyrd robe"
+ desc = "A robe with an ancient design, unknown to tailors and sewers here."
+ body_parts_covered = CHEST|GROIN|ARMS|LEGS|VITALS
+ icon_state = "hag"
+ icon = 'icons/roguetown/clothing/armor.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/onmob/armor.dmi'
+ sleeved = 'icons/roguetown/clothing/onmob/helpers/sleeves_armor.dmi'
+ boobed = TRUE
+ color = null
+ r_sleeve_status = SLEEVE_NORMAL
+ l_sleeve_status = SLEEVE_NORMAL
+
diff --git a/code/modules/clothing/shoes/boots.dm b/code/modules/clothing/shoes/boots.dm
index e3dd120d8e0..306ba1dff1e 100644
--- a/code/modules/clothing/shoes/boots.dm
+++ b/code/modules/clothing/shoes/boots.dm
@@ -37,6 +37,31 @@
material_category = ARMOR_MAT_PLATE
+/obj/item/clothing/shoes/boots/armor/gold
+ name = "golden greaves"
+ desc = "Resplendant sabatons of pure gold, adorned with angled greaves that proudly bare the holy sigil. Its besilked cuffs have remained surprisingly bereft of debris - not even a sprig of lint remains to be criticized."
+ icon_state = "goldgreaves"
+ item_state = "goldgreaves"
+ body_parts_covered = FEET | LEGS
+ armor_class = AC_HEAVY //Ceremonial. Heavy is the head that bares the burden.
+ anvilrepair = null
+ melting_material = /datum/material/gold
+ melt_amount = 75
+ grid_height = 96
+ grid_width = 96
+ sellprice = 200
+
+/obj/item/clothing/shoes/boots/armor/gold/king
+ name = "royal golden greaves"
+ sellprice = 300
+
+/obj/item/clothing/shoes/boots/armor/bronze
+ name = "bronze greaves"
+ desc = "Padded sabatons of bronze, tightly strapped together and padded with hide from a fearsome beaste. The sandals clack about, yet they do not feel obstructive; if anything, you've never felt more agile while beplated."
+ icon_state = "bronzegreaves"
+ body_parts_covered = FEET | LEGS
+ melting_material = /datum/material/bronze
+
/obj/item/clothing/shoes/boots/armor/light
name = "light plate boots"
icon_state = "soldierboots"
@@ -298,3 +323,23 @@
mob_overlay_icon = 'icons/roguetown/clothing/special/onmob/gronn.dmi'
sleeved = 'icons/roguetown/clothing/special/onmob/gronn.dmi'
+
+/obj/item/clothing/shoes/boots/leather/kazengun
+ name = "armored sandals"
+ desc = "Leather sandals, with steel ankle-protectors and socks of sturdy cloth."
+ icon_state = "kazengunboots"
+ item_state = "kazengunboots"
+ detail_tag = "_detail"
+ color = "#FFFFFF"
+ detail_tag = "_detail"
+
+/obj/item/clothing/shoes/boots/leather/kazengun/attack_hand_secondary(mob/user, list/modifiers)
+ . = ..()
+ var/choice = input(user, "Choose a color.", "Uniform colors") as anything in COLOR_MAP
+ var/playerchoice = COLOR_MAP[choice]
+ detail_color = playerchoice
+ update_appearance()
+ if(loc == user && ishuman(user))
+ var/mob/living/carbon/H = user
+ H.update_inv_armor()
+ H.update_icon()
diff --git a/code/modules/clothing/shoes/misc.dm b/code/modules/clothing/shoes/misc.dm
index ae2542deba2..361605dc875 100644
--- a/code/modules/clothing/shoes/misc.dm
+++ b/code/modules/clothing/shoes/misc.dm
@@ -151,6 +151,13 @@
sellprice = 20
wetable = FALSE
+/obj/item/clothing/shoes/rare/grenzelhoft/freifechter
+ name = "fencing boots"
+ desc = "A pair of lightweight snugly fitting boots. They're reinforced along the toes and ankles and offer a measure of protection against missteps and glancing blows during close exchanges, often favoured by duelists and other itinerant swordsmen."
+ icon_state = "freiboots"
+ item_state = "freiboots"
+ max_integrity = ARMOR_INT_SIDE_HARDLEATHER + 50
+
/obj/item/clothing/shoes/otavan
name = "grenzelhoftian leather boots"
desc = "Boots of outstanding craft, your fragile feet have never felt so protected and comfortable before."
diff --git a/code/modules/clothing/shoes/race.dm b/code/modules/clothing/shoes/race.dm
new file mode 100644
index 00000000000..898e8d3a600
--- /dev/null
+++ b/code/modules/clothing/shoes/race.dm
@@ -0,0 +1,9 @@
+/obj/item/clothing/shoes/boots/armor/dwarven
+ name = "grudgebearer dwarven boots"
+ desc = "Clatters mightily."
+ icon = 'icons/roguetown/clothing/special/race_armor.dmi'
+ mob_overlay_icon = 'icons/roguetown/clothing/special/onmob/race_armor.dmi'
+ allowed_race = list(SPEC_ID_DWARF)
+ icon_state = "dwarfshoe"
+ item_state = "dwarfshoe"
+ item_weight = 2.1 KILOGRAMS
diff --git a/code/modules/clothing/shoes/rare.dm b/code/modules/clothing/shoes/rare.dm
index 571614967a5..336d122eacd 100644
--- a/code/modules/clothing/shoes/rare.dm
+++ b/code/modules/clothing/shoes/rare.dm
@@ -43,18 +43,6 @@
icon_state = "welfshoes"
item_state = "welfshoes"
-/obj/item/clothing/shoes/boots/rare/dwarfplate
- name = "decorated dwarven plate boots"
- allowed_race = list(SPEC_ID_DWARF)
- allowed_sex = list(MALE, FEMALE)
- desc = "Laced with golden bands, these dwarven plated boots glitter with glory as they are used to kick enemy's shins."
- body_parts_covered = FEET|LEGS
- icon_state = "dwarfshoe"
- item_state = "dwarfshoe"
- color = null
- blocksound = PLATEHIT
- item_weight = 2.1 KILOGRAMS
-
/obj/item/clothing/shoes/boots/rare/grenzelplate
name = "grenzelhoft \"Elvenbane\" sabatons"
allowed_race = list(SPEC_ID_HUMEN, SPEC_ID_AASIMAR)
diff --git a/code/modules/crafting/anvil_recipes/armor.dm b/code/modules/crafting/anvil_recipes/armor.dm
index ef7d1c3b2af..463f80878c7 100644
--- a/code/modules/crafting/anvil_recipes/armor.dm
+++ b/code/modules/crafting/anvil_recipes/armor.dm
@@ -48,6 +48,66 @@
abstract_type = /datum/anvil_recipe/armor/bronze
///////////////////////////////////////////////
+/datum/anvil_recipe/armor/bronze/barbute
+ name = "Barbute, Bronze (+1 Bronze, +1 Cured Leather)"
+ additional_items = list(/obj/item/ingot/bronze, /obj/item/natural/hide/cured)
+ created_item = /obj/item/clothing/head/helmet/heavy/bronze
+
+/datum/anvil_recipe/armor/bronze/murmillo
+ name = "Murmillo-Style Helmet, Bronze (+1 Bronze, +1 Fur)"
+ additional_items = list(/obj/item/ingot/bronze, /obj/item/natural/fur)
+ created_item = /obj/item/clothing/head/helmet/bronzegladiator
+ craftdiff = 2
+
+/datum/anvil_recipe/armor/bronze/illyria
+ name = "Bascinet, Bronze (+1 Cured Leather)"
+ additional_items = list( /obj/item/natural/hide/cured)
+ created_item = /obj/item/clothing/head/helmet/bronze
+
+/datum/anvil_recipe/armor/bronze/protector
+ name = "Heart Protector, Bronze (+1 Cured Leather)"
+ additional_items = list(/obj/item/ingot/bronze, /obj/item/natural/hide/cured)
+ created_item = /obj/item/clothing/armor/plate/bronze/light
+
+/datum/anvil_recipe/armor/bronze/cuirass
+ name = "Cuirass, Bronze (+1 Bronze, +1 Cured Leather)"
+ additional_items = list(/obj/item/ingot/bronze, /obj/item/natural/hide/cured)
+ created_item = /obj/item/clothing/armor/plate/bronze
+
+/datum/anvil_recipe/armor/bronze/halfplate
+ name = "Panoply Assembly, Halved, Bronze (+2 Bronze, +1 Cured Leather, +1 Fur)"
+ additional_items = list(/obj/item/ingot/bronze, /obj/item/ingot/bronze, /obj/item/ingot/bronze, /obj/item/natural/hide/cured, /obj/item/natural/fur)
+ created_item = /obj/item/clothing/armor/plate/full/bronze/alt
+ craftdiff = 2
+
+/datum/anvil_recipe/armor/bronze/fullplate
+ name = "Panoply Assembly, Full, Bronze (+3 Bronze, +1 Cured Leather, +1 Fur)"
+ additional_items = list(/obj/item/ingot/bronze, /obj/item/ingot/bronze, /obj/item/ingot/bronze, /obj/item/natural/hide/cured, /obj/item/natural/fur)
+ created_item = /obj/item/clothing/armor/plate/full/bronze
+ craftdiff = 3
+
+/datum/anvil_recipe/armor/bronze/bevor
+ name = "Bevor, Bronze (+1 Cured Leather)"
+ additional_items = list(/obj/item/natural/hide/cured)
+ created_item = /obj/item/clothing/neck/bevor/bronze
+ craftdiff = 2
+
+/datum/anvil_recipe/armor/bronze/greaves
+ name = "Greaves, Bronze (+1 Cured Leather)"
+ additional_items = list(/obj/item/natural/hide/cured)
+ created_item = /obj/item/clothing/shoes/boots/armor/bronze
+
+
+/datum/anvil_recipe/armor/bronze/mask
+ name = "Mask, Bronze (+1 Cured Leather)"
+ additional_items = list(/obj/item/natural/hide/cured)
+ created_item = /obj/item/clothing/face/facemask/bronze
+
+/datum/anvil_recipe/armor/bronze/maskclassic
+ name = "Mask, Ornate, Bronze (+1 Cured Leather)"
+ additional_items = list(/obj/item/natural/hide/cured)
+ created_item = /obj/item/clothing/face/facemask/bronze/classic
+
// BRONZE ARMOR
/datum/anvil_recipe/armor/bronze/brigandine
@@ -143,6 +203,12 @@
created_item = /obj/item/clothing/gloves/chain/iron
output_amount = 2
+/datum/anvil_recipe/armor/iron/scaledcloak
+ name = "Scaled Cloak (+Bar)"
+ additional_items = list(/obj/item/ingot/iron)
+ created_item = /obj/item/clothing/cloak/scaledcloak
+ output_amount = 2
+
// IRON NECK ARMOR
/datum/anvil_recipe/armor/iron/gorget
name = "Iron Gorget"
@@ -335,6 +401,12 @@
created_item = (/obj/item/clothing/head/helmet/visored/knight/iron)
craftdiff = 2
+/datum/anvil_recipe/armor/iron/owlhelmet
+ name = "strigidae armet (+Bar)"
+ additional_items = list(/obj/item/ingot/iron)
+ created_item = /obj/item/clothing/head/helmet/visored/knight/owl
+ craftdiff = 2
+
// IRON PLATE ARMOR
/datum/anvil_recipe/armor/iron/halfplate
name = "Iron Half-plate (+Bar x2)"
@@ -443,6 +515,13 @@
name = "Great Helm"
required_material = /obj/item/ingot/steel
created_item = (/obj/item/clothing/head/helmet/heavy/bucket)
+
+/datum/anvil_recipe/armor/steel/keeperbucket
+ name = "Keeper's Helm"
+ required_material = /obj/item/ingot/steel
+ additional_items = list(/obj/item/natural/cloth)
+ created_item = (/obj/item/clothing/head/helmet/heavy/bucket/keeper)
+
/*
/datum/anvil_recipe/armor/steel/sinistar
name = "Sinistar Helmet (+Steel Bar)"
@@ -952,6 +1031,12 @@
created_item = /obj/item/clothing/armor/plate/full/silver
craftdiff = 4
+/datum/anvil_recipe/armor/silver/halfplate
+ name = "Silver Half Plate Armor (+Silver Bar, +Steel Bar)"
+ additional_items = list(/obj/item/ingot/silver, /obj/item/ingot/steel)
+ created_item = /obj/item/clothing/armor/plate/silver
+ craftdiff = 4
+
/datum/anvil_recipe/armor/silver/gauntlet
name = "Silver Gauntlets"
additional_items = list(/obj/item/ingot/silver)
@@ -1304,3 +1389,48 @@
name = "Psydonic Chain Gloves"
required_material = /obj/item/ingot/silverblessed
created_item = /obj/item/clothing/gloves/chain/psydon
+
+/datum/anvil_recipe/armor/gold
+ abstract_type = /datum/anvil_recipe/armor/gold
+ craftdiff = SKILL_LEVEL_LEGENDARY
+ required_material = /obj/item/ingot/gold
+
+/datum/anvil_recipe/armor/gold/armet
+ name = "Golden Knight's Armet (+1 Gold, +2 Silk)"
+ additional_items = list(/obj/item/ingot/gold, /obj/item/natural/silk, /obj/item/natural/silk)
+ created_item = /obj/item/clothing/head/helmet/visored/gold/king
+
+/datum/anvil_recipe/armor/gold/armetcrown
+ name = "Golden Knight's Armet, Royal (+1 Gold, +2 Silk, +1 Dorpel)"
+ additional_items = list(/obj/item/ingot/gold, /obj/item/natural/silk, /obj/item/natural/silk, /obj/item/gem/diamond)
+ created_item = /obj/item/clothing/head/helmet/visored/gold
+
+/datum/anvil_recipe/armor/gold/gorget
+ name = "Golden Gorget (+1 Gold, +2 Silk)"
+ additional_items = list(/obj/item/ingot/gold, /obj/item/natural/silk, /obj/item/natural/silk)
+ created_item = /obj/item/clothing/neck/gorget/gold
+
+/datum/anvil_recipe/armor/gold/cuirass
+ name = "Golden Cuirass (+2 Gold, +2 Silk)"
+ additional_items = list(/obj/item/ingot/gold, /obj/item/ingot/gold, /obj/item/natural/silk, /obj/item/natural/silk)
+ created_item = /obj/item/clothing/armor/cuirass/fluted/gold
+
+/datum/anvil_recipe/armor/gold/cuirasshero
+ name = "Golden Cuirass, Heroic (+2 Gold, +2 Silk, +1 Tallow)"
+ additional_items = list(/obj/item/ingot/gold, /obj/item/ingot/gold, /obj/item/natural/silk, /obj/item/natural/silk, /obj/item/reagent_containers/food/snacks/tallow)
+ created_item = /obj/item/clothing/armor/cuirass/fluted/gold/heroic
+
+/datum/anvil_recipe/armor/gold/greaves
+ name = "Golden Greaves (+1 Gold, +2 Silk)"
+ additional_items = list(/obj/item/ingot/gold, /obj/item/natural/silk, /obj/item/natural/silk)
+ created_item = /obj/item/clothing/shoes/boots/armor/gold
+
+/datum/anvil_recipe/armor/holysteel
+ required_material = /obj/item/ingot/steelholy
+ craftdiff = 4
+ abstract_type = /datum/anvil_recipe/armor/holysteel
+
+/datum/anvil_recipe/armor/holysteel/undividedtemplar_sallet
+ name = "Undivided Templar's Sallet (+1 Holy Steel, +1 Cured Leather)"
+ additional_items = list(/obj/item/ingot/steelholy, /obj/item/natural/hide/cured)
+ created_item = /obj/item/clothing/head/helmet/heavy/undivided
diff --git a/code/modules/crafting/anvil_recipes/valuables.dm b/code/modules/crafting/anvil_recipes/valuables.dm
index 59da4688cc8..1cca9b1a718 100644
--- a/code/modules/crafting/anvil_recipes/valuables.dm
+++ b/code/modules/crafting/anvil_recipes/valuables.dm
@@ -201,6 +201,48 @@
created_item = /obj/item/clothing/ring/silver/makers_guild
craftdiff = 6
+// --------- BS ------------
+/datum/anvil_recipe/valuables/blacksteel
+ required_material = /obj/item/ingot/blacksteel
+ abstract_type = /datum/anvil_recipe/valuables/blacksteel
+ craftdiff = 4
+
+/datum/anvil_recipe/valuables/blacksteel/dorpels
+ name = "Blacksteel Dorpel Ring"
+ additional_items = list(/obj/item/gem/diamond)
+ created_item = /obj/item/clothing/ring/diamondbs
+ craftdiff = 4
+
+/datum/anvil_recipe/valuables/blacksteel/blortzs
+ name = "Blacksteel Blortz Ring"
+ additional_items = list(/obj/item/gem/blue)
+ created_item = /obj/item/clothing/ring/quartzbs
+ craftdiff = 4
+
+/datum/anvil_recipe/valuables/blacksteel/saffiras
+ name = "Blacksteel Saffira Ring"
+ additional_items = list(/obj/item/gem/violet)
+ created_item = /obj/item/clothing/ring/sapphirebs
+ craftdiff = 4
+
+/datum/anvil_recipe/valuables/blacksteel/gemeralds
+ name = "Blacksteel Gemerald Ring"
+ additional_items = list(/obj/item/gem/green)
+ created_item = /obj/item/clothing/ring/emeraldbs
+ craftdiff = 4
+
+/datum/anvil_recipe/valuables/blacksteel/topers
+ name = "Blacksteel Toper Ring"
+ additional_items = list(/obj/item/gem/yellow)
+ created_item = /obj/item/clothing/ring/topazbs
+ craftdiff = 4
+
+/datum/anvil_recipe/valuables/blacksteel/rontzs
+ name = "Blacksteel Rontz Ring"
+ additional_items = list(/obj/item/gem/red)
+ created_item = /obj/item/clothing/ring/rubybs
+ craftdiff = 4
+
// --------- GOLD -----------
/datum/anvil_recipe/valuables/gold
@@ -359,23 +401,35 @@
created_item = /obj/item/clothing/head/crown/sparrowcrown
craftdiff = 6
+/datum/anvil_recipe/valuables/signet/unblessedsilver
+ name = "Silver Signet Ring"
+ required_material = /obj/item/ingot/silver
+ craftdiff = SKILL_LEVEL_EXPERT
+ created_item = /obj/item/clothing/ring/signet/silver
+
/datum/anvil_recipe/valuables/signet
name = "Signet Ring"
required_material = /obj/item/ingot/gold
craftdiff = SKILL_LEVEL_EXPERT
created_item = /obj/item/clothing/ring/signet
+/datum/anvil_recipe/valuables/signet/psy/gold
+ name = "Gold Signet Ring"
+ craftdiff = SKILL_LEVEL_EXPERT
+ required_material = /obj/item/ingot/gold
+ created_item = /obj/item/clothing/ring/signet/psy/g
+
/datum/anvil_recipe/valuables/signet/silver
name = "Blessed Silver Signet Ring"
craftdiff = SKILL_LEVEL_MASTER
required_material = /obj/item/ingot/silverblessed
- created_item = /obj/item/clothing/ring/signet/silver
+ created_item = /obj/item/clothing/ring/signet/psy
/datum/anvil_recipe/valuables/signet/silver/inq
name = "Blessed Silver Signet Ring"
craftdiff = SKILL_LEVEL_MASTER
required_material = /obj/item/ingot/silverblessed
- created_item = /obj/item/clothing/ring/signet/silver
+ created_item = /obj/item/clothing/ring/signet/psy
// --------- BRONZE -----------
@@ -447,3 +501,33 @@
name = "Bronze Fish Figurines"
created_item = /obj/item/statue/bronze/fish
output_amount = 2
+
+/datum/anvil_recipe/valuables/weddingrings
+ name = "Weddingbands, Silver (x2)"
+ required_material = /obj/item/ingot/silver
+ created_item = /obj/item/clothing/ring/band
+ output_amount = 2
+
+/datum/anvil_recipe/valuables/weddingringg
+ name = "Weddingbands, Gold (x2)"
+ required_material = /obj/item/ingot/gold
+ created_item = /obj/item/clothing/ring/band/gold
+ output_amount = 2
+
+/datum/anvil_recipe/valuables/weddingringb
+ name = "Weddingbands, Bronze (x2)"
+ required_material = /obj/item/ingot/bronze
+ created_item = /obj/item/clothing/ring/band/bronze
+ output_amount = 2
+
+/datum/anvil_recipe/valuables/weddingringp
+ name = "Weddingbands, Ancient (x2)"
+ required_material = /obj/item/ingot/purifiedaalloy
+ created_item = /obj/item/clothing/ring/band/paalloy
+ output_amount = 2
+
+/datum/anvil_recipe/valuables/draconic_ring
+ name = "Draconic Ring"
+ required_material = /obj/item/ingot/draconic
+ created_item = /obj/item/clothing/ring/dragon_ring
+ craftdiff = 4
diff --git a/code/modules/crafting/artificer/misc.dm b/code/modules/crafting/artificer/misc.dm
index 012d8e20a60..932474fd7ff 100644
--- a/code/modules/crafting/artificer/misc.dm
+++ b/code/modules/crafting/artificer/misc.dm
@@ -494,6 +494,21 @@
required_item = /obj/item/ingot/silver
created_item = /obj/item/clothing/neck/psycross/silver/divine
+/datum/artificer_recipe/psycross/matthios
+ name = "Matthios Psycross"
+ required_item = /obj/item/ingot/gold
+ created_item = /obj/item/clothing/neck/psycross/matthios
+
+/datum/artificer_recipe/psycross/graggar
+ name = "Graggar Psycross"
+ required_item = /obj/item/ingot/gold
+ created_item = /obj/item/clothing/neck/psycross/graggar
+
+/datum/artificer_recipe/psycross/baotha
+ name = "Baotha Psycross"
+ required_item = /obj/item/ingot/gold
+ created_item = /obj/item/clothing/neck/psycross/baotha
+
/datum/artificer_recipe/psycross/noc
name = "Noc Psycross"
required_item = /obj/item/ingot/silver
diff --git a/code/modules/crafting/quality_of_crafting/leatherworking.dm b/code/modules/crafting/quality_of_crafting/leatherworking.dm
index 164302b247d..460f8fdfee4 100644
--- a/code/modules/crafting/quality_of_crafting/leatherworking.dm
+++ b/code/modules/crafting/quality_of_crafting/leatherworking.dm
@@ -263,6 +263,10 @@
name = "mourning pants"
output = /obj/item/clothing/pants/trou/leather/mourning
+/datum/repeatable_crafting_recipe/leather/pants/shepherd
+ name = "shepherd's pants"
+ output = /obj/item/clothing/pants/trou/leather/shepherd
+
/datum/repeatable_crafting_recipe/leather/shoes
name = "leather shoes"
output_amount = 2
@@ -306,6 +310,14 @@
output = /obj/item/clothing/cloak/raincloak
craftdiff = 2
+/datum/repeatable_crafting_recipe/leather/cloak/bandolier
+ name = "bandolier"
+ requirements = list(
+ /obj/item/natural/hide/cured = 2,
+ /obj/item/rope = 1,
+ )
+ output = /obj/item/clothing/cloak/bandolier
+
/datum/repeatable_crafting_recipe/leather/cloakfur
name = "fur lined raincloak"
requirements = list(
@@ -333,6 +345,15 @@
output = /obj/item/clothing/cloak/graggar
craftdiff = 4
+/datum/repeatable_crafting_recipe/leather/heavy_graggar_cloak
+ name = "heavy vicious cloak"
+ requirements = list(
+ /obj/item/natural/hide/cured = 4,
+ /obj/item/natural/silk = 1,
+ )
+ output = /obj/item/clothing/cloak/graggar/heavy
+ craftdiff = 4
+
/datum/repeatable_crafting_recipe/leather/savage_cloak
name = "savage cloak"
requirements = list(
@@ -590,6 +611,15 @@
output = /obj/item/clothing/head/leather/inqhat
craftdiff = 4
+/datum/repeatable_crafting_recipe/leather/gravetenderhat
+ name = "gravetender's hat"
+ requirements = list(
+ /obj/item/natural/hide/cured = 2,
+ /obj/item/natural/feather = 1,
+ )
+ output = /obj/item/clothing/head/leather/inqhat/gravehat
+ craftdiff = 2
+
/datum/repeatable_crafting_recipe/leather/nobleboots
name = "noble boots"
output = /obj/item/clothing/shoes/nobleboot
@@ -1250,6 +1280,26 @@
craftdiff = 2
category = "Hat"
+/datum/repeatable_crafting_recipe/leather/studdedleatherhood
+ name = "studded leather hood"
+ requirements = list(
+ /obj/item/natural/hide/cured = 3,
+ /obj/item/natural/fibers/sinew = 1,
+ )
+ output = /obj/item/clothing/head/roguehood/studded
+ craftdiff = 2
+ category = "Hat"
+
+/datum/repeatable_crafting_recipe/leather/studdedleatherhoodretinue
+ name = "guard studded leather hood"
+ requirements = list(
+ /obj/item/natural/hide/cured = 3,
+ /obj/item/natural/fibers = 2,
+ )
+ output = /obj/item/clothing/head/roguehood/studded/retinue
+ craftdiff = 3
+ category = "Hat"
+
/datum/repeatable_crafting_recipe/leather/sanguinejacket
name = "sanguine jacket"
requirements = list(
diff --git a/code/modules/crafting/quality_of_crafting/sewing.dm b/code/modules/crafting/quality_of_crafting/sewing.dm
index ca6995acd78..d39429e12af 100644
--- a/code/modules/crafting/quality_of_crafting/sewing.dm
+++ b/code/modules/crafting/quality_of_crafting/sewing.dm
@@ -178,6 +178,13 @@
/obj/item/natural/fibers = 1)
category = "Pants"
+/datum/repeatable_crafting_recipe/sewing/explorerpants
+ name = "explorer's pants"
+ output = /obj/item/clothing/pants/tights/explorerpants
+ requirements = list(/obj/item/natural/cloth = 2,
+ /obj/item/natural/fibers = 1)
+ category = "Pants"
+
/datum/repeatable_crafting_recipe/sewing/lakkarikilt
name = "padded kilt"
output = /obj/item/clothing/pants/trou/leather/quiltedkilt
@@ -345,6 +352,13 @@
craftdiff = 1
category = "Neck"
+/datum/repeatable_crafting_recipe/sewing/woolenneck
+ name = "woolen collar"
+ output = /obj/item/clothing/neck/woolen
+ requirements = list(/obj/item/natural/cloth = 3)
+ craftdiff = 1
+ category = "Neck"
+
/datum/repeatable_crafting_recipe/sewing/keffiyeh
name = "keffiyeh"
requirements = list(/obj/item/natural/cloth = 2, /obj/item/natural/fibers = 1,)
@@ -394,6 +408,27 @@
craftdiff = 1
category = "Hat"
+/datum/repeatable_crafting_recipe/sewing/explorerhat
+ name = "explorer's hat"
+ requirements = list(/obj/item/natural/cloth = 1, /obj/item/natural/fibers = 1,)
+ output = /obj/item/clothing/head/explorerhat
+ craftdiff = 2
+ category = "Hat"
+
+/datum/repeatable_crafting_recipe/sewing/fedora
+ name = "archeologist's hat"
+ requirements = list(/obj/item/natural/cloth = 1, /obj/item/natural/fibers = 1,)
+ output = /obj/item/clothing/head/fedora
+ craftdiff = 2
+ category = "Hat"
+
+/datum/repeatable_crafting_recipe/sewing/archercap
+ name = "archer's cap"
+ requirements = list(/obj/item/natural/cloth = 1, /obj/item/natural/fibers = 1,)
+ output = /obj/item/clothing/head/archercap
+ craftdiff = 2
+ category = "Hat"
+
/datum/repeatable_crafting_recipe/sewing/chefhat
name = "chef hat"
requirements = list(/obj/item/natural/cloth = 1, /obj/item/natural/fibers = 1,)
@@ -476,6 +511,24 @@
/obj/item/alch/herb/salvia = 2)
output = /obj/item/clothing/head/flowercrown/salvia
+/datum/repeatable_crafting_recipe/sewing/flowercrown/calendula
+ name = "calendula crown"
+ requirements = list(/obj/item/natural/fibers = 1,\
+ /obj/item/alch/herb/calendula = 2)
+ output = /obj/item/clothing/head/flowercrown/calendula
+
+/datum/repeatable_crafting_recipe/sewing/flowercrown/manabloom
+ name = "manabloom crown"
+ requirements = list(/obj/item/natural/fibers = 1,\
+ /obj/item/reagent_containers/food/snacks/produce/manabloom = 2)
+ output = /obj/item/clothing/head/flowercrown/manabloom
+
+/datum/repeatable_crafting_recipe/sewing/flowercrown/matricaria
+ name = "matricaria crown"
+ requirements = list(/obj/item/natural/fibers = 1,\
+ /obj/item/alch/herb/matricaria = 2)
+ output = /obj/item/clothing/head/flowercrown/matricaria
+
/*.............. recipes requiring skill 2 ..............*/
/datum/repeatable_crafting_recipe/sewing/gambeson
name = "gambeson"
@@ -493,6 +546,22 @@
craftdiff = 2
category = "Cloak"
+/datum/repeatable_crafting_recipe/sewing/togaalt
+ name = "toga"
+ output = /obj/item/clothing/cloak/tabard/toga/alt
+ requirements = list(/obj/item/natural/cloth = 3,
+ /obj/item/natural/fibers = 1)
+ craftdiff = 2
+ category = "Cloak"
+
+/datum/repeatable_crafting_recipe/sewing/toga
+ name = "toga alt"
+ output = /obj/item/clothing/cloak/tabard/toga
+ requirements = list(/obj/item/natural/cloth = 3,
+ /obj/item/natural/fibers = 1)
+ craftdiff = 2
+ category = "Cloak"
+
/datum/repeatable_crafting_recipe/sewing/pegasusknight
name = "pegasus knight tabard"
output = /obj/item/clothing/cloak/pegasusknight
@@ -511,6 +580,15 @@
craftdiff = 2
category = "Cloak"
+/datum/repeatable_crafting_recipe/sewing/fancycoat
+ name = "fancy coat"
+ output = /obj/item/clothing/cloak/poncho/fancycoat
+ requirements = list(
+ /obj/item/natural/silk = 2,
+ /obj/item/natural/fibers = 1,)
+ craftdiff = 3
+ category = "Cloak"
+
/datum/repeatable_crafting_recipe/sewing/tabard/crusader
name = "tabard (crusader)"
output = /obj/item/clothing/cloak/tabard/crusader
@@ -621,6 +699,30 @@
craftdiff = 3
category = "Shirt"
+/datum/repeatable_crafting_recipe/sewing/saree
+ name = "saree"
+ output = /obj/item/clothing/shirt/saree
+ requirements = list(/obj/item/natural/cloth = 3,
+ /obj/item/natural/fibers = 2)
+ craftdiff = 3
+ category = "Shirt"
+
+/datum/repeatable_crafting_recipe/sewing/explorershirt
+ name = "explorer's vest"
+ output = /obj/item/clothing/shirt/explorer
+ requirements = list(/obj/item/natural/cloth = 3,
+ /obj/item/natural/fibers = 2)
+ craftdiff = 3
+ category = "Shirt"
+
+/datum/repeatable_crafting_recipe/sewing/fancyjacket
+ name = "fancy jacket"
+ output = /obj/item/clothing/shirt/fancyjacket
+ requirements = list(/obj/item/natural/cloth = 3,
+ /obj/item/natural/fibers = 2)
+ craftdiff = 3
+ category = "Shirt"
+
/datum/repeatable_crafting_recipe/sewing/trousershorts
name = "trouser shorts"
output = /obj/item/clothing/pants/trou/formal/shorts
@@ -645,6 +747,20 @@
craftdiff = 3
category = "Hat"
+/datum/repeatable_crafting_recipe/sewing/baredrobes
+ name = "bared robes"
+ output = /obj/item/clothing/shirt/robe/bared
+ requirements = list(/obj/item/natural/cloth = 3,
+ /obj/item/natural/fibers = 1)
+ craftdiff = 1
+
+/datum/repeatable_crafting_recipe/sewing/shepherdvest
+ name = "shepherd's best"
+ output = /obj/item/clothing/shirt/robe/shepherdvest
+ requirements = list(/obj/item/natural/cloth = 3,
+ /obj/item/natural/fibers = 1)
+ craftdiff = 2
+
/datum/repeatable_crafting_recipe/sewing/wizardrobes
name = "wizard robes"
output = /obj/item/clothing/shirt/robe/wizard
@@ -1034,6 +1150,30 @@
craftdiff = 3
category = "Dress"
+/datum/repeatable_crafting_recipe/sewing/nobledress
+ name = "noble dress"
+ output = /obj/item/clothing/shirt/dress/nobledress
+ requirements = list(/obj/item/natural/silk = 3,
+ /obj/item/natural/fibers = 1)
+ craftdiff = 4
+ category = "Dress"
+
+/datum/repeatable_crafting_recipe/sewing/dress/velvetdress
+ name = "velvet dress"
+ output = /obj/item/clothing/shirt/dress/velvetdress
+ requirements = list(/obj/item/natural/cloth = 3,
+ /obj/item/natural/fibers = 1)
+ craftdiff = 3
+ category = "Dress"
+
+/datum/repeatable_crafting_recipe/sewing/slitdress
+ name = "slit dress"
+ output = /obj/item/clothing/shirt/dress/slit
+ requirements = list(/obj/item/natural/cloth = 3,
+ /obj/item/natural/fibers = 1)
+ craftdiff = 3
+ category = "Dress"
+
/datum/repeatable_crafting_recipe/sewing/stockdress
name = "stock dress"
output = /obj/item/clothing/shirt/dress/gen
diff --git a/code/modules/jobs/job_types/adventurer/types/combat/rare/longbeard.dm b/code/modules/jobs/job_types/adventurer/types/combat/rare/longbeard.dm
index 43d75a81d8d..67cdad13a43 100644
--- a/code/modules/jobs/job_types/adventurer/types/combat/rare/longbeard.dm
+++ b/code/modules/jobs/job_types/adventurer/types/combat/rare/longbeard.dm
@@ -40,11 +40,11 @@
pants = /obj/item/clothing/pants/tights/colored/black
backr = /obj/item/weapon/mace/goden/steel/warhammer
beltl = /obj/item/storage/belt/pouch/coins/mid
- shoes = /obj/item/clothing/shoes/boots/rare/dwarfplate
- gloves = /obj/item/clothing/gloves/rare/dwarfplate
+ shoes = /obj/item/clothing/shoes/boots/armor/dwarven
+ gloves = /obj/item/clothing/gloves/plate/dwarven
belt = /obj/item/storage/belt/leather
shirt = /obj/item/clothing/shirt/undershirt/colored/black
- armor = /obj/item/clothing/armor/rare/dwarfplate
+ armor = /obj/item/clothing/armor/plate/full/dwarven
backl = /obj/item/storage/backpack/satchel
- head = /obj/item/clothing/head/rare/dwarfplate
+ head = /obj/item/clothing/head/helmet/heavy/dwarven
neck = /obj/item/clothing/neck/chaincoif
diff --git a/code/modules/mob/dead/dead.dm b/code/modules/mob/dead/dead.dm
index 154ca7c2263..4ed669aec4b 100644
--- a/code/modules/mob/dead/dead.dm
+++ b/code/modules/mob/dead/dead.dm
@@ -237,8 +237,6 @@ INITIALIZE_IMMEDIATE(/mob/dead)
src << browse(null, "window=food_selection")
src << browse(null, "window=drink_selection")
- SStriumphs.remove_triumph_buy_menu(client)
-
winshow(src, "stonekeep_prefwin", FALSE)
src << browse(null, "window=preferences_browser")
src << browse(null, "window=lobby_window")
diff --git a/code/modules/mob/dead/new_player/new_player.dm b/code/modules/mob/dead/new_player/new_player.dm
index e7266859f8c..3d04e80b6ba 100644
--- a/code/modules/mob/dead/new_player/new_player.dm
+++ b/code/modules/mob/dead/new_player/new_player.dm
@@ -463,7 +463,7 @@ GLOBAL_LIST_INIT(roleplay_readme, file2list("strings/rt/Lore_Primer.txt"))
GLOB.respawncounts[character.ckey] += 1
if(humanc)
- try_apply_character_post_equipment(humanc)
+ try_apply_character_post_equipment(humanc, client)
log_manifest(character.mind.key,character.mind,character,latejoin = TRUE)
diff --git a/code/modules/mob/living/carbon/human/species.dm b/code/modules/mob/living/carbon/human/species.dm
index 7bea449d2de..6514ceeb05a 100644
--- a/code/modules/mob/living/carbon/human/species.dm
+++ b/code/modules/mob/living/carbon/human/species.dm
@@ -270,8 +270,8 @@ GLOBAL_LIST_EMPTY(roundstart_species)
OFFSET_NECK = list(0,-4),\
OFFSET_MOUTH = list(0,-4),\
OFFSET_PANTS = list(0,0),\
- OFFSET_SHIRT = list(0,0),\
- OFFSET_ARMOR = list(0,0),\
+ OFFSET_SHIRT = list(0,1),\
+ OFFSET_ARMOR = list(0,1),\
OFFSET_UNDIES = list(0,0),\
)
diff --git a/code/modules/mob/living/carbon/human/update_icons.dm b/code/modules/mob/living/carbon/human/update_icons.dm
index fa8214ac287..6b44ed18bf6 100644
--- a/code/modules/mob/living/carbon/human/update_icons.dm
+++ b/code/modules/mob/living/carbon/human/update_icons.dm
@@ -380,7 +380,7 @@ GLOBAL_PROTECT(no_child_icons)
else
offsets = (age == AGE_CHILD) ? species.offset_features_child : species.offset_features_m
- var/mutable_appearance/neck_overlay = wear_neck.build_worn_icon(age, NECK_LAYER, 'icons/roguetown/clothing/onmob/neck.dmi')
+ var/mutable_appearance/neck_overlay = wear_neck.build_worn_icon(age, NECK_LAYER, 'icons/roguetown/clothing/onmob/neck.dmi', force_child = TRUE)
if(LAZYACCESS(offsets, OFFSET_NECK))
neck_overlay.pixel_x += offsets[OFFSET_NECK][1]
neck_overlay.pixel_y += offsets[OFFSET_NECK][2]
@@ -415,7 +415,7 @@ GLOBAL_PROTECT(no_child_icons)
else
offsets = (age == AGE_CHILD) ? species.offset_features_child : species.offset_features_m
- var/mutable_appearance/ring_overlay = wear_ring.build_worn_icon(age, RING_LAYER, 'icons/roguetown/clothing/onmob/rings.dmi')
+ var/mutable_appearance/ring_overlay = wear_ring.build_worn_icon(age, RING_LAYER, 'icons/roguetown/clothing/onmob/rings.dmi', force_child = TRUE)
if(LAZYACCESS(offsets, OFFSET_RING))
ring_overlay.pixel_x += offsets[OFFSET_RING][1]
ring_overlay.pixel_y += offsets[OFFSET_RING][2]
@@ -636,7 +636,7 @@ GLOBAL_PROTECT(no_child_icons)
else
offsets = (age == AGE_CHILD) ? species.offset_features_child : species.offset_features_m
- overlays_standing[HEAD_LAYER] = head.build_worn_icon(age = age, default_layer = HEAD_LAYER, default_icon_file = 'icons/roguetown/clothing/onmob/head.dmi', coom = FALSE)
+ overlays_standing[HEAD_LAYER] = head.build_worn_icon(age = age, default_layer = HEAD_LAYER, default_icon_file = 'icons/roguetown/clothing/onmob/head.dmi', coom = FALSE, force_child = TRUE)
var/mutable_appearance/head_overlay = overlays_standing[HEAD_LAYER]
if(head_overlay)
if(LAZYACCESS(offsets, OFFSET_HEAD))
@@ -714,7 +714,7 @@ GLOBAL_PROTECT(no_child_icons)
standing_front += onbelt_overlay
standing_behind += onbelt_behind
else
- onbelt_overlay = beltr.build_worn_icon(age, BELT_LAYER, 'icons/roguetown/clothing/onmob/belt_r.dmi')
+ onbelt_overlay = beltr.build_worn_icon(age, BELT_LAYER, 'icons/roguetown/clothing/onmob/belt_r.dmi', force_child = TRUE)
if(onbelt_overlay)
if(LAZYACCESS(offsets, OFFSET_BELT))
onbelt_overlay.pixel_x += offsets[OFFSET_BELT][1]
@@ -754,7 +754,7 @@ GLOBAL_PROTECT(no_child_icons)
standing_front += onbelt_overlay
standing_behind += onbelt_behind
else
- onbelt_overlay = beltl.build_worn_icon(age, BELT_LAYER, 'icons/roguetown/clothing/onmob/belt_l.dmi')
+ onbelt_overlay = beltl.build_worn_icon(age, BELT_LAYER, 'icons/roguetown/clothing/onmob/belt_l.dmi', force_child = TRUE)
if(onbelt_overlay)
if(LAZYACCESS(offsets, OFFSET_BELT))
onbelt_overlay.pixel_x += offsets[OFFSET_BELT][1]
@@ -796,7 +796,7 @@ GLOBAL_PROTECT(no_child_icons)
if(wear_mask)
update_hud_wear_mask(wear_mask)
if(!(ITEM_SLOT_MASK & check_obscured_slots()))
- var/mutable_appearance/mask_overlay = wear_mask.build_worn_icon(default_layer = MASK_LAYER, default_icon_file = 'icons/roguetown/clothing/onmob/masks.dmi')
+ var/mutable_appearance/mask_overlay = wear_mask.build_worn_icon(default_layer = MASK_LAYER, default_icon_file = 'icons/roguetown/clothing/onmob/masks.dmi', force_child = TRUE)
var/datum/species/species = dna?.species
var/use_female_sprites = FALSE
if(species.sexes)
@@ -874,7 +874,7 @@ GLOBAL_PROTECT(no_child_icons)
LAZYADD(overcloaks, back_overlay)
LAZYADD(backbehind, behindback_overlay)
else
- back_overlay = backr.build_worn_icon(age, BACK_LAYER, 'icons/roguetown/clothing/onmob/back_r.dmi')
+ back_overlay = backr.build_worn_icon(age, BACK_LAYER, 'icons/roguetown/clothing/onmob/back_r.dmi', force_child = TRUE)
if(LAZYACCESS(offsets, OFFSET_BACK))
back_overlay.pixel_x += offsets[OFFSET_BACK][1]
back_overlay.pixel_y += offsets[OFFSET_BACK][2]
@@ -912,7 +912,7 @@ GLOBAL_PROTECT(no_child_icons)
LAZYADD(overcloaks, back_overlay)
LAZYADD(backbehind, behindback_overlay)
else
- back_overlay = backl.build_worn_icon(age, BACK_LAYER, 'icons/roguetown/clothing/onmob/back_l.dmi')
+ back_overlay = backl.build_worn_icon(age, BACK_LAYER, 'icons/roguetown/clothing/onmob/back_l.dmi', force_child = TRUE)
if(LAZYACCESS(offsets, OFFSET_BACK))
back_overlay.pixel_x += offsets[OFFSET_BACK][1]
back_overlay.pixel_y += offsets[OFFSET_BACK][2]
@@ -1238,7 +1238,7 @@ GLOBAL_PROTECT(no_child_icons)
offsets = (age == AGE_CHILD) ? species.offset_features_child : species.offset_features_f
else
offsets = (age == AGE_CHILD) ? species.offset_features_child : species.offset_features_m
- var/mutable_appearance/mouth_overlay = mouth.build_worn_icon(age, MOUTH_LAYER, 'icons/roguetown/clothing/onmob/mouth_items.dmi')
+ var/mutable_appearance/mouth_overlay = mouth.build_worn_icon(age, MOUTH_LAYER, 'icons/roguetown/clothing/onmob/mouth_items.dmi', force_child = TRUE)
if(mouth_overlay)
if(LAZYACCESS(offsets, OFFSET_MOUTH))
mouth_overlay.pixel_x += offsets[OFFSET_MOUTH][1]
@@ -1361,11 +1361,17 @@ generate/load female uniform sprites matching all previously decided variables
*/
-/obj/item/proc/build_worn_icon(age = AGE_ADULT, default_layer = 0, default_icon_file = null, isinhands = FALSE, femaleuniform = NO_FEMALE_UNIFORM, override_state = null, coom = FALSE, customi = null, sleeveindex)
+/obj/item/proc/build_worn_icon(age = AGE_ADULT, default_layer = 0, default_icon_file = null, isinhands = FALSE, femaleuniform = NO_FEMALE_UNIFORM, override_state = null, coom = FALSE, customi = null, sleeveindex, force_child = FALSE)
var/t_state
var/sleevejazz = sleevetype
- if(age == AGE_CHILD)
- coom = FALSE
+
+ if(!childcore)
+ if(age == AGE_CHILD && (!is_type_in_list(src, GLOB.no_child_icons) || !force_child))
+ coom = TRUE
+ customi = SPEC_ID_DWARF
+ else
+ if(age == AGE_CHILD)
+ coom = FALSE
if(override_state)
t_state = override_state
else if(isinhands && item_state)
@@ -1381,8 +1387,9 @@ generate/load female uniform sprites matching all previously decided variables
if(sleevejazz)
sleevejazz += "_[customi]"
var/t_icon = mob_overlay_icon
- if(age == AGE_CHILD && !is_type_in_list(src, GLOB.no_child_icons))
- t_state += "_child"
+ if(childcore)
+ if(age == AGE_CHILD && !is_type_in_list(src, GLOB.no_child_icons))
+ t_state += "_child"
if(!t_icon)
t_icon = default_icon_file
@@ -1476,35 +1483,45 @@ generate/load female uniform sprites matching all previously decided variables
return standing
-/mob/living/carbon/proc/get_sleeves_layer(obj/item/I,sleeveindex,layer2use)
+/mob/living/carbon/proc/get_sleeves_layer(obj/item/I, sleeveindex, layer2use)
if(!I)
return
var/list/sleeves = list()
-
if(I.r_sleeve_status == SLEEVE_TORN || I.r_sleeve_status == SLEEVE_ROLLED)
if(sleeveindex == 4 || sleeveindex == 2)
sleeveindex -= 1
if(I.l_sleeve_status == SLEEVE_TORN || I.l_sleeve_status == SLEEVE_ROLLED)
if(sleeveindex == 4 || sleeveindex == 3)
sleeveindex -= 2
-
var/index = I.icon_state
var/mob/living/carbon/human/HM = src
- if(istype(HM) && HM.age == AGE_CHILD && !is_type_in_list(I, GLOB.no_child_icons))
- index += "_child"
+ var/coom = FALSE
+ var/customi = null
+ if(!I.childcore)
+ if(istype(HM) && HM.age == AGE_CHILD && (!is_type_in_list(I, GLOB.no_child_icons)))
+ coom = TRUE
+ customi = SPEC_ID_DWARF
+ else
+ if(istype(HM) && HM.age == AGE_CHILD)
+ coom = FALSE
+ if(coom)
+ index += "_f"
else if(gender == FEMALE ^ dna.species.swap_female_clothes)
index += "_f"
- if(dna.species.custom_clothes)
+ if(customi)
+ index += "_[customi]"
+ else if(dna.species.custom_clothes)
index += "_[dna.species.custom_id ? dna.species.custom_id : dna.species.id]"
-
+ if(I.childcore)
+ if(istype(HM) && HM.age == AGE_CHILD && !is_type_in_list(I, GLOB.no_child_icons))
+ index += "_child"
var/static/list/bloody_r = list()
var/static/list/bloody_l = list()
- if(I.nodismemsleeves && sleeveindex) //armor pauldrons that show up above arms but don't get dismembered
+ if(I.nodismemsleeves && sleeveindex)
sleeveindex = 4
-
var/leftused = FALSE
var/rightused = FALSE
- if(I.inhand_mod) //cloak holding icons
+ if(I.inhand_mod)
for(var/obj/item/H in held_items)
var/rightorleft
rightorleft = get_held_index_of_item(H) % 2
@@ -1512,7 +1529,6 @@ generate/load female uniform sprites matching all previously decided variables
rightused = TRUE
else
leftused = TRUE
-
if(sleeveindex == 2 || sleeveindex == 4 || !sleeveindex)
var/used = "r_[index]"
if(!sleeveindex)
@@ -1522,24 +1538,21 @@ generate/load female uniform sprites matching all previously decided variables
r_sleeve.color = I.color
r_sleeve.alpha = I.alpha
sleeves += r_sleeve
-
if(I.get_detail_tag())
var/mutable_appearance/pic = mutable_appearance(icon(I.sleeved, "[used][I.get_detail_tag()]"), layer=-layer2use)
-// pic.appearance_flags = RESET_COLOR
+// pic.appearance_flags = RESET_COLOR
if(I.get_detail_color())
pic.color = I.get_detail_color()
sleeves += pic
-
if(GET_ATOM_BLOOD_DNA_LENGTH(I))
var/icon/blood_overlay = bloody_r[used]
if(!blood_overlay)
blood_overlay = icon(I.sleeved, used)
- blood_overlay.Blend("#fff", ICON_ADD) //fills the icon_state with white (except where it's transparent)
- blood_overlay.Blend(icon(I.bloody_icon, I.bloody_icon_state), ICON_MULTIPLY) //adds blood and the remaining white areas become transparant
+ blood_overlay.Blend("#fff", ICON_ADD)
+ blood_overlay.Blend(icon(I.bloody_icon, I.bloody_icon_state), ICON_MULTIPLY)
bloody_r[used] = fcopy_rsc(blood_overlay)
var/mutable_appearance/pic = mutable_appearance(blood_overlay, layer=-layer2use)
sleeves += pic
-
if(sleeveindex == 3 || sleeveindex == 4 || !sleeveindex)
var/used = "l_[index]"
if(!sleeveindex)
@@ -1549,27 +1562,23 @@ generate/load female uniform sprites matching all previously decided variables
l_sleeve.color = I.color
l_sleeve.alpha = I.alpha
sleeves += l_sleeve
-
if(I.get_detail_tag())
var/mutable_appearance/pic = mutable_appearance(icon(I.sleeved, "[used][I.get_detail_tag()]"), layer=-layer2use)
-// pic.appearance_flags = RESET_COLOR
+// pic.appearance_flags = RESET_COLOR
if(I.get_detail_color())
pic.color = I.get_detail_color()
sleeves += pic
-
if(GET_ATOM_BLOOD_DNA_LENGTH(I))
var/icon/blood_overlay = bloody_l[used]
if(!blood_overlay)
blood_overlay = icon(I.sleeved, used)
- blood_overlay.Blend("#fff", ICON_ADD) //fills the icon_state with white (except where it's transparent)
- blood_overlay.Blend(icon(I.bloody_icon, I.bloody_icon_state), ICON_MULTIPLY) //adds blood and the remaining white areas become transparant
+ blood_overlay.Blend("#fff", ICON_ADD)
+ blood_overlay.Blend(icon(I.bloody_icon, I.bloody_icon_state), ICON_MULTIPLY)
bloody_l[used] = fcopy_rsc(blood_overlay)
var/mutable_appearance/pic = mutable_appearance(blood_overlay, layer=-layer2use)
sleeves += pic
-
return sleeves
-
/obj/item/proc/get_held_offsets()
var/list/L
if(ismob(loc))
diff --git a/code/modules/mob/living/simple_animal/hostile/retaliate/mirespider.dm b/code/modules/mob/living/simple_animal/hostile/retaliate/mirespider.dm
index 3ebd3a0907f..46926c5951b 100644
--- a/code/modules/mob/living/simple_animal/hostile/retaliate/mirespider.dm
+++ b/code/modules/mob/living/simple_animal/hostile/retaliate/mirespider.dm
@@ -67,6 +67,7 @@
AddComponent(/datum/component/ai_aggro_system)
ADD_TRAIT(src, TRAIT_NOPAINSTUN, TRAIT_GENERIC)
ADD_TRAIT(src, TRAIT_KNEESTINGER_IMMUNITY, INNATE_TRAIT)
+ ADD_TRAIT(src, TRAIT_POISONBITE, INNATE_TRAIT)
addtimer(CALLBACK(src, PROC_REF(find_lurker_to_follow)), 10)
diff --git a/code/modules/unit_tests/craftable_clothes.dm b/code/modules/unit_tests/craftable_clothes.dm
index a3dfc6d49b6..557716f429f 100644
--- a/code/modules/unit_tests/craftable_clothes.dm
+++ b/code/modules/unit_tests/craftable_clothes.dm
@@ -67,8 +67,78 @@ abstract types are automatically excluded.
/obj/item/clothing/head/menacing/mad_touched_treasure_hunter, //cursed
/obj/item/clothing/face/facemask/steel/mad_touched, //cursed
/obj/item/clothing/cloak/poncho/yellow,//No free colouring
+ /obj/item/clothing/cloak/ordinatorcape/lirvas,
+ /obj/item/clothing/cloak/minotaur,
+ /obj/item/clothing/cloak/psydontabard/black,
+ /obj/item/clothing/cloak/psydontabard/black/alt,
+ /obj/item/clothing/shoes/boots/armor/gold/king,
+ /obj/item/clothing/armor/cuirass/fluted/gold/king,
+ /obj/item/clothing/head/helmet/visored/gold/king,
+ /obj/item/clothing/neck/gorget/gold/king,
+ /obj/item/clothing/head/helmet/visored/gold/king,
+ /obj/item/clothing/shirt/robe/hag,
- /obj/item/clothing/wrists/bracers/naledi //Inqstuff
+ /obj/item/clothing/head/helmet/heavy/dwarven,
+ /obj/item/clothing/head/helmet/heavy/dwarven/smith,
+ /obj/item/clothing/shoes/boots/armor/dwarven,
+ /obj/item/clothing/gloves/plate/dwarven,
+ /obj/item/clothing/armor/plate/full/dwarven,
+ /obj/item/clothing/armor/plate/full/dwarven/smith,
+
+ //stat rings
+ /obj/item/clothing/ring/statgemerald,
+ /obj/item/clothing/ring/statonyx,
+ /obj/item/clothing/ring/statamythortz,
+ /obj/item/clothing/ring/statrontz,
+ /obj/item/clothing/ring/statdorpel,
+
+ /obj/item/clothing/armor/heartfelt,
+ /obj/item/clothing/armor/heartfelt/hand,
+
+ /obj/item/clothing/armor/cuirass/fluted,
+
+ ///these can probably have recipes?
+ /obj/item/clothing/face/xylixmask,
+ /obj/item/clothing/face/xylixmask/weathered,
+ /obj/item/clothing/ring/duelist,
+ /obj/item/clothing/cloak/stabard/templar/justice,
+ /obj/item/clothing/cloak/cape/inquisitorgold,
+ /obj/item/clothing/cloak/cape/inquisitorsilver,
+ /obj/item/clothing/cloak/sleevedtabard,
+ /obj/item/clothing/cloak/absolutionistrobe/black,
+ /obj/item/clothing/gloves/angle/freifechter,
+ /obj/item/clothing/head/dancer_headdress,
+ /obj/item/clothing/face/faceveil,
+
+ //we have no way to get aalloy yet
+ /obj/item/clothing/head/helmet/heavy/aalloy,
+ /obj/item/clothing/head/helmet/kettle/aalloy,
+ /obj/item/clothing/head/helmet/visored/knight/aalloy,
+ /obj/item/clothing/armor/chainmail/hauberk/aalloy,
+ /obj/item/clothing/ring/band/aalloy,
+
+ //funny clothes no recipe
+ /obj/item/clothing/gloves/plate/iron/banded,
+ /obj/item/clothing/head/helmet/sallet/beastskull,
+ /obj/item/clothing/head/helmet/sallet/iron/banded,
+ /obj/item/clothing/armor/plate/iron/banded,
+ /obj/item/clothing/gloves/plate/iron/banded,
+
+ ///all blackmeadow are pending blackmeadow features
+ /obj/item/clothing/cloak/kazengun,
+ /obj/item/clothing/neck/gorget/kazengun,
+ /obj/item/clothing/shoes/boots/leather/kazengun,
+ /obj/item/clothing/pants/trou/leather/kazengun,
+ /obj/item/clothing/face/facemask/steel/kazengun,
+ /obj/item/clothing/face/facemask/steel/kazengun/full,
+ /obj/item/clothing/head/helmet/heavy/kabuto,
+ /obj/item/clothing/armor/plate/full/samsibsa,
+ /obj/item/clothing/gloves/plate/kote,
+ /obj/item/clothing/head/helmet/kettle/jingasa,
+ /obj/item/clothing/armor/brigandine/haraate,
+
+ /obj/item/clothing/wrists/bracers/naledi, //Inqstuff
+ /obj/item/clothing/pants/trou/leather/pontifex,
)
// these don't use misc_flags = CRAFTING_TEST_EXCLUDE because we want to explicitly know which paths we are excluding.
/// excludes paths along with their subtypes
diff --git a/icons/roguetown/clothing/armor.dmi b/icons/roguetown/clothing/armor.dmi
index b1752cb1fe4..2773bc46c91 100644
Binary files a/icons/roguetown/clothing/armor.dmi and b/icons/roguetown/clothing/armor.dmi differ
diff --git a/icons/roguetown/clothing/belts.dmi b/icons/roguetown/clothing/belts.dmi
index 770803928ad..622d8bba3a9 100644
Binary files a/icons/roguetown/clothing/belts.dmi and b/icons/roguetown/clothing/belts.dmi differ
diff --git a/icons/roguetown/clothing/cloaks.dmi b/icons/roguetown/clothing/cloaks.dmi
index aeed68701d3..fa91a26581d 100644
Binary files a/icons/roguetown/clothing/cloaks.dmi and b/icons/roguetown/clothing/cloaks.dmi differ
diff --git a/icons/roguetown/clothing/feet.dmi b/icons/roguetown/clothing/feet.dmi
index 3ccd1652bdd..9466883484b 100644
Binary files a/icons/roguetown/clothing/feet.dmi and b/icons/roguetown/clothing/feet.dmi differ
diff --git a/icons/roguetown/clothing/gloves.dmi b/icons/roguetown/clothing/gloves.dmi
index 7f59339668f..9a33af19fd2 100644
Binary files a/icons/roguetown/clothing/gloves.dmi and b/icons/roguetown/clothing/gloves.dmi differ
diff --git a/icons/roguetown/clothing/head.dmi b/icons/roguetown/clothing/head.dmi
index e53477696f0..251bdc11dc0 100644
Binary files a/icons/roguetown/clothing/head.dmi and b/icons/roguetown/clothing/head.dmi differ
diff --git a/icons/roguetown/clothing/masks.dmi b/icons/roguetown/clothing/masks.dmi
index 0d33ed3c253..0212efd04fc 100644
Binary files a/icons/roguetown/clothing/masks.dmi and b/icons/roguetown/clothing/masks.dmi differ
diff --git a/icons/roguetown/clothing/neck.dmi b/icons/roguetown/clothing/neck.dmi
index 26ee6283217..1e0ca0cfe91 100644
Binary files a/icons/roguetown/clothing/neck.dmi and b/icons/roguetown/clothing/neck.dmi differ
diff --git a/icons/roguetown/clothing/onmob/64x64/head.dmi b/icons/roguetown/clothing/onmob/64x64/head.dmi
index 326cb568bb7..bc131a7a0b8 100644
Binary files a/icons/roguetown/clothing/onmob/64x64/head.dmi and b/icons/roguetown/clothing/onmob/64x64/head.dmi differ
diff --git a/icons/roguetown/clothing/onmob/armor.dmi b/icons/roguetown/clothing/onmob/armor.dmi
index fbe0e533252..9161c8f32c2 100644
Binary files a/icons/roguetown/clothing/onmob/armor.dmi and b/icons/roguetown/clothing/onmob/armor.dmi differ
diff --git a/icons/roguetown/clothing/onmob/belts.dmi b/icons/roguetown/clothing/onmob/belts.dmi
index aa26aa5c5d7..bc61e9d6944 100644
Binary files a/icons/roguetown/clothing/onmob/belts.dmi and b/icons/roguetown/clothing/onmob/belts.dmi differ
diff --git a/icons/roguetown/clothing/onmob/cloaks.dmi b/icons/roguetown/clothing/onmob/cloaks.dmi
index 7ad2fec940d..6108cc233b2 100644
Binary files a/icons/roguetown/clothing/onmob/cloaks.dmi and b/icons/roguetown/clothing/onmob/cloaks.dmi differ
diff --git a/icons/roguetown/clothing/onmob/feet.dmi b/icons/roguetown/clothing/onmob/feet.dmi
index b640f55f913..04d2aeffe74 100644
Binary files a/icons/roguetown/clothing/onmob/feet.dmi and b/icons/roguetown/clothing/onmob/feet.dmi differ
diff --git a/icons/roguetown/clothing/onmob/gloves.dmi b/icons/roguetown/clothing/onmob/gloves.dmi
index c105635c6ce..dffc6930a3d 100644
Binary files a/icons/roguetown/clothing/onmob/gloves.dmi and b/icons/roguetown/clothing/onmob/gloves.dmi differ
diff --git a/icons/roguetown/clothing/onmob/head.dmi b/icons/roguetown/clothing/onmob/head.dmi
index 250be71170b..f071df2b4f1 100644
Binary files a/icons/roguetown/clothing/onmob/head.dmi and b/icons/roguetown/clothing/onmob/head.dmi differ
diff --git a/icons/roguetown/clothing/onmob/head_items.dmi b/icons/roguetown/clothing/onmob/head_items.dmi
index ca360544ea2..cc823723adc 100644
Binary files a/icons/roguetown/clothing/onmob/head_items.dmi and b/icons/roguetown/clothing/onmob/head_items.dmi differ
diff --git a/icons/roguetown/clothing/onmob/helpers/sleeves_armor.dmi b/icons/roguetown/clothing/onmob/helpers/sleeves_armor.dmi
index b0a08ac3c09..93804b262bf 100644
Binary files a/icons/roguetown/clothing/onmob/helpers/sleeves_armor.dmi and b/icons/roguetown/clothing/onmob/helpers/sleeves_armor.dmi differ
diff --git a/icons/roguetown/clothing/onmob/helpers/sleeves_pants.dmi b/icons/roguetown/clothing/onmob/helpers/sleeves_pants.dmi
index 015d7dfd6b3..d18328ba25a 100644
Binary files a/icons/roguetown/clothing/onmob/helpers/sleeves_pants.dmi and b/icons/roguetown/clothing/onmob/helpers/sleeves_pants.dmi differ
diff --git a/icons/roguetown/clothing/onmob/helpers/sleeves_shirts.dmi b/icons/roguetown/clothing/onmob/helpers/sleeves_shirts.dmi
index ed6055fdcd1..36b189539da 100644
Binary files a/icons/roguetown/clothing/onmob/helpers/sleeves_shirts.dmi and b/icons/roguetown/clothing/onmob/helpers/sleeves_shirts.dmi differ
diff --git a/icons/roguetown/clothing/onmob/masks.dmi b/icons/roguetown/clothing/onmob/masks.dmi
index 788466da18d..9499ecb13d8 100644
Binary files a/icons/roguetown/clothing/onmob/masks.dmi and b/icons/roguetown/clothing/onmob/masks.dmi differ
diff --git a/icons/roguetown/clothing/onmob/neck.dmi b/icons/roguetown/clothing/onmob/neck.dmi
index 71906a7b1bf..ff4f1763152 100644
Binary files a/icons/roguetown/clothing/onmob/neck.dmi and b/icons/roguetown/clothing/onmob/neck.dmi differ
diff --git a/icons/roguetown/clothing/onmob/pants.dmi b/icons/roguetown/clothing/onmob/pants.dmi
index d2a1e4bdb16..8fe8968fbaf 100644
Binary files a/icons/roguetown/clothing/onmob/pants.dmi and b/icons/roguetown/clothing/onmob/pants.dmi differ
diff --git a/icons/roguetown/clothing/onmob/rings.dmi b/icons/roguetown/clothing/onmob/rings.dmi
index d188fa11ee2..0a427da43aa 100644
Binary files a/icons/roguetown/clothing/onmob/rings.dmi and b/icons/roguetown/clothing/onmob/rings.dmi differ
diff --git a/icons/roguetown/clothing/onmob/shirts.dmi b/icons/roguetown/clothing/onmob/shirts.dmi
index 768f152c429..637eabf0087 100644
Binary files a/icons/roguetown/clothing/onmob/shirts.dmi and b/icons/roguetown/clothing/onmob/shirts.dmi differ
diff --git a/icons/roguetown/clothing/pants.dmi b/icons/roguetown/clothing/pants.dmi
index c943590e29a..7f63eff6d86 100644
Binary files a/icons/roguetown/clothing/pants.dmi and b/icons/roguetown/clothing/pants.dmi differ
diff --git a/icons/roguetown/clothing/rings.dmi b/icons/roguetown/clothing/rings.dmi
index b109bce5fb5..d65d74318a7 100644
Binary files a/icons/roguetown/clothing/rings.dmi and b/icons/roguetown/clothing/rings.dmi differ
diff --git a/icons/roguetown/clothing/shirts.dmi b/icons/roguetown/clothing/shirts.dmi
index 3345f97c035..9bafc2a8da9 100644
Binary files a/icons/roguetown/clothing/shirts.dmi and b/icons/roguetown/clothing/shirts.dmi differ
diff --git a/icons/roguetown/clothing/special/onmob/race_armor.dmi b/icons/roguetown/clothing/special/onmob/race_armor.dmi
index c19ea72654a..cbf7b7dde50 100644
Binary files a/icons/roguetown/clothing/special/onmob/race_armor.dmi and b/icons/roguetown/clothing/special/onmob/race_armor.dmi differ
diff --git a/icons/roguetown/clothing/special/race_armor.dmi b/icons/roguetown/clothing/special/race_armor.dmi
index c8ba74d1e4e..b6bb931dfef 100644
Binary files a/icons/roguetown/clothing/special/race_armor.dmi and b/icons/roguetown/clothing/special/race_armor.dmi differ
diff --git a/icons/roguetown/clothing/special/ravoxtemplar.dmi b/icons/roguetown/clothing/special/ravoxtemplar.dmi
index 1c1cfcaef39..49953fca7b9 100644
Binary files a/icons/roguetown/clothing/special/ravoxtemplar.dmi and b/icons/roguetown/clothing/special/ravoxtemplar.dmi differ
diff --git a/icons/roguetown/items/gems.dmi b/icons/roguetown/items/gems.dmi
index bdbc06d15d1..9018124dce3 100644
Binary files a/icons/roguetown/items/gems.dmi and b/icons/roguetown/items/gems.dmi differ
diff --git a/icons/roguetown/items/ore.dmi b/icons/roguetown/items/ore.dmi
index b61dea69469..b9d9b2d0dec 100644
Binary files a/icons/roguetown/items/ore.dmi and b/icons/roguetown/items/ore.dmi differ
diff --git a/icons/roguetown/mob/bodies/c/child.dmi b/icons/roguetown/mob/bodies/c/child.dmi
index 9d448916b87..a555b6b0d31 100644
Binary files a/icons/roguetown/mob/bodies/c/child.dmi and b/icons/roguetown/mob/bodies/c/child.dmi differ
diff --git a/tgui/packages/tgui/interfaces/AdminTicketGranter.tsx b/tgui/packages/tgui/interfaces/AdminTicketGranter.tsx
new file mode 100644
index 00000000000..a06aa3b90a1
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/AdminTicketGranter.tsx
@@ -0,0 +1,247 @@
+import { useState } from 'react';
+import {
+ Button,
+ Divider,
+ Dropdown,
+ Input,
+ LabeledList,
+ NoticeBox,
+ NumberInput,
+ Section,
+ Stack,
+ TextArea,
+} from 'tgui-core/components';
+import { useBackend } from '../backend';
+import { Window } from '../layouts';
+
+type FieldDescriptor = {
+ key: string;
+ label: string;
+ type: 'text' | 'number' | 'typepath';
+ base?: string;
+ placeholder?: string;
+ required?: boolean;
+ min?: number;
+};
+
+type TypeSchema = {
+ ticket_type: string;
+ label: string;
+ fa_icon: string;
+ color: string;
+ fields: FieldDescriptor[];
+};
+
+type Data = {
+ type_schemas: TypeSchema[];
+ typepath_options: Record;
+};
+
+export const AdminTicketGranter = () => {
+ const { data, act } = useBackend();
+ const { type_schemas = [], typepath_options = {} } = data;
+
+ const [selectedType, setSelectedType] = useState(
+ type_schemas[0]?.ticket_type ?? '',
+ );
+ const [targetCkey, setTargetCkey] = useState('');
+ const [ticketName, setTicketName] = useState('');
+ const [ticketDesc, setTicketDesc] = useState('');
+ const [grantReason, setGrantReason] = useState('');
+ const [payloadValues, setPayloadValues] = useState>({});
+ const [submitted, setSubmitted] = useState(false);
+
+ const schema = type_schemas.find((s) => s.ticket_type === selectedType);
+
+ const handleTypeChange = (newType: string) => {
+ setSelectedType(newType);
+ setPayloadValues({});
+ };
+
+ const handlePayloadChange = (key: string, val: string) => {
+ setPayloadValues((prev) => ({ ...prev, [key]: val }));
+ };
+
+ const missingFields = (schema?.fields ?? [])
+ .filter((f) => f.required && !payloadValues[f.key])
+ .map((f) => f.label);
+
+ const anyTouched = !!targetCkey || !!ticketName || Object.keys(payloadValues).length > 0;
+
+ const canSubmit =
+ !!targetCkey.trim() &&
+ !!ticketName.trim() &&
+ !!selectedType &&
+ missingFields.length === 0;
+
+ const handleGrant = () => {
+ if (!canSubmit) return;
+ setSubmitted(true);
+ act('grant_ticket', {
+ target_ckey: targetCkey.trim(),
+ ticket_type: selectedType,
+ name: ticketName.trim(),
+ description: ticketDesc.trim(),
+ grant_reason: grantReason.trim(),
+ ...payloadValues,
+ });
+ setTimeout(() => {
+ setSubmitted(false);
+ setTargetCkey('');
+ setTicketName('');
+ setTicketDesc('');
+ setGrantReason('');
+ setPayloadValues({});
+ }, 1500);
+ };
+
+ return (
+
+
+
+
+
+ {type_schemas.map((s) => (
+
+
+
+ ))}
+
+
+
+
+
+ {schema && schema.fields.length > 0 && (
+
+ )}
+
+ {anyTouched && !canSubmit && (
+
+ {!targetCkey.trim() && Target ckey is required.
}
+ {!ticketName.trim() && Ticket name is required.
}
+ {missingFields.map((f) => (
+ Required field missing: {f}
+ ))}
+
+ )}
+
+
+
+
+
+
+ {schema && targetCkey && (
+
+ Granting: {schema.label} → {targetCkey}
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/ColorPicker.tsx b/tgui/packages/tgui/interfaces/ColorPicker.tsx
new file mode 100644
index 00000000000..17edc3153d0
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/ColorPicker.tsx
@@ -0,0 +1,185 @@
+import { Box, Button, Icon, Section, Stack } from 'tgui-core/components';
+import type { BooleanLike } from 'tgui-core/react';
+
+export type ColorEntry = {
+ /** Display name, e.g. "Forest Green" */
+ name: string;
+ /** Hex value, e.g. "#3a7d44" */
+ hex: string;
+ /** Whether the client owns this color */
+ owned: BooleanLike;
+ /**
+ * Loadout item path string used to purchase this color.
+ * Null for always-free colors (peasant palette).
+ */
+ purchase_path: string | null;
+ /** Triumph cost to permanently unlock. 0 = free. */
+ cost: number;
+};
+
+type ColorPickerProps = {
+ colors: ColorEntry[];
+ selected: string | null;
+ onSelect: (hex: string) => void;
+ onClear: () => void;
+ onBuy: (path: string) => void;
+ label?: string;
+ /** If true, renders without a Section wrapper (for embedding) */
+ bare?: boolean;
+};
+
+const SWATCH_SIZE = '22px';
+
+const ColorSwatch = ({
+ entry,
+ isSelected,
+ onSelect,
+ onBuy,
+}: {
+ entry: ColorEntry;
+ isSelected: boolean;
+ onSelect: (hex: string) => void;
+ onBuy: (path: string) => void;
+}) => {
+ const owned = !!entry.owned;
+ const free = entry.cost === 0;
+
+ const tooltip = owned
+ ? entry.name
+ : free
+ ? `${entry.name} - free, click to claim`
+ : `${entry.name} - ${entry.cost} triumphs to unlock`;
+
+ return (
+ {
+ if (owned) {
+ onSelect(entry.hex);
+ } else if (entry.purchase_path) {
+ onBuy(entry.purchase_path);
+ }
+ }}
+ style={{
+ display: 'inline-block',
+ width: SWATCH_SIZE,
+ height: SWATCH_SIZE,
+ borderRadius: '3px',
+ background: entry.hex,
+ cursor: owned ? 'pointer' : 'default',
+ opacity: owned ? 1 : 0.3,
+ outline: isSelected
+ ? '2px solid white'
+ : '1px solid rgba(255,255,255,0.15)',
+ outlineOffset: isSelected ? '2px' : '0px',
+ position: 'relative',
+ margin: '2px',
+ flexShrink: 0,
+ }}
+ >
+ {!owned && (
+
+ {free ? '✓' : '🔒'}
+
+ )}
+
+ );
+};
+
+export const ColorPicker = ({
+ colors,
+ selected,
+ onSelect,
+ onClear,
+ onBuy,
+ label,
+ bare,
+}: ColorPickerProps) => {
+ const inner = (
+
+
+
+ {colors.map((entry) => (
+
+
+
+ ))}
+
+
+
+
+
+ {selected ? (
+ <>
+
+
+
+
+ {colors.find((c) => c.hex === selected)?.name ?? selected}
+
+
+
+
+ >
+ ) : (
+
+
+ No color applied, default appearance
+
+ )}
+
+
+
+
+
+
+ Greyed swatches require triumph unlocks. Click to purchase.
+
+
+
+ );
+
+ if (bare) {
+ return inner;
+ }
+
+ return (
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/TicketShop.tsx b/tgui/packages/tgui/interfaces/TicketShop.tsx
new file mode 100644
index 00000000000..4a378355db5
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/TicketShop.tsx
@@ -0,0 +1,977 @@
+import { useMemo, useState } from 'react';
+import {
+ Box,
+ Button,
+ DmIcon,
+ Icon,
+ Input,
+ NoticeBox,
+ Section,
+ Stack,
+ Tabs,
+} from 'tgui-core/components';
+import type { BooleanLike } from 'tgui-core/react';
+
+export type TicketEntry = {
+ ticket_id: string;
+ ticket_type: 'loadout' | 'special' | 'job_boost' | 'unknown';
+ name: string;
+ description: string | null;
+ granted_by: string;
+ granted_at: string;
+ grant_reason: string | null;
+ ui_icon: string | null;
+ ui_icon_state: string | null;
+ ui_fa_icon: string; // font-awesome fallback
+ ui_color: string; // hex accent
+ ui_type_label: string; // badge text
+ ui_grant_summary: string; // one-liner
+};
+
+export type HistoryEntry = {
+ event: 'used' | 'traded_away' | 'traded_received' | 'granted' | 'converted';
+ description: string;
+ timestamp: string;
+ // may be comma-joined list of ids for basket trades
+ ticket_ids: string;
+ names: string;
+ received_names?: string;
+ partner?: string;
+};
+
+export type IncomingTrade = {
+ trade_id: string;
+ from_ckey: string;
+ /** Tickets they are giving us **/
+ offered_tickets: TicketEntry[];
+ offered_ticket_names: string[];
+ /** Tickets they want from us **/
+ requested_tickets: TicketEntry[];
+ requested_ticket_names: string[];
+ cancelling: BooleanLike;
+};
+
+export type OutgoingTrade = {
+ trade_id: string;
+ to_ckey: string;
+ offered_ticket_ids: string[];
+ offered_ticket_names: string[];
+ requested_ticket_ids: string[];
+ requested_ticket_names: string[];
+ cancelling: BooleanLike;
+};
+
+const TicketSprite = ({ ticket, size = 2 }: { ticket: TicketEntry; size?: number }) => {
+ if (ticket.ui_icon && ticket.ui_icon_state) {
+ return (
+ }
+ />
+ );
+ }
+ return (
+
+ );
+};
+
+const ticketGrantSummary = (t: TicketEntry) => t.ui_grant_summary;
+
+type TicketCardProps = {
+ ticket: TicketEntry;
+ selected?: boolean;
+ locked?: boolean; // already in an outgoing trade
+ onToggle?: (id: string) => void; // basket selection
+ onUse?: (id: string) => void;
+ readOnly?: boolean;
+};
+
+const TicketCard = ({
+ ticket,
+ selected = false,
+ locked = false,
+ onToggle,
+ onUse,
+ readOnly = false,
+}: TicketCardProps) => {
+ const typeColor = ticket.ui_color;
+ const isClickable = !!onToggle && !locked && !readOnly;
+
+ return (
+ onToggle!(ticket.ticket_id) : undefined}
+ >
+
+
+
+
+
+
+
+ {ticket.name}
+ {locked && (
+
+ (in trade)
+
+ )}
+
+
+ {ticketGrantSummary(ticket)}
+
+ {!!ticket.description && (
+
+ {ticket.description}
+
+ )}
+
+ {ticket.ui_type_label} — from {ticket.granted_by} on {ticket.granted_at}
+ {!!ticket.grant_reason && ` (${ticket.grant_reason})`}
+
+
+ {!readOnly && (
+
+ {onToggle && (
+
+ )}
+ {!onToggle && !!onUse && (
+
+ )}
+
+ )}
+
+
+ );
+};
+
+const BasketSummary = ({
+ offerTickets,
+ requestTickets,
+ targetCkey,
+}: {
+ offerTickets: TicketEntry[];
+ requestTickets: TicketEntry[];
+ targetCkey: string;
+}) => {
+ const hasOffer = offerTickets.length > 0;
+ const hasRequest = requestTickets.length > 0;
+ if (!hasOffer && !hasRequest) return null;
+
+ const Chip = ({ ticket }: { ticket: TicketEntry }) => (
+
+
+
+ {ticket.name}
+
+
+ );
+
+ return (
+
+
+
+
+
+ You give to {targetCkey || '…'}
+
+ {hasOffer ? (
+
+ {offerTickets.map((t) => (
+
+ ))}
+
+ ) : (
+
+ Nothing
+
+ )}
+
+
+
+
+
+ You get from {targetCkey || '…'}
+
+ {hasRequest ? (
+
+ {requestTickets.map((t) => (
+
+ ))}
+
+ ) : (
+
+ Nothing
+
+ )}
+
+
+
+ );
+};
+
+const IncomingTradeCard = ({
+ trade,
+ onAccept,
+ onDecline,
+}: {
+ trade: IncomingTrade;
+ onAccept: (id: string) => void;
+ onDecline: (id: string) => void;
+}) => {
+ const [expanded, setExpanded] = useState(false);
+ const isCancelling = !!trade.cancelling;
+ const hasOffered = trade.offered_tickets.length > 0;
+ const hasRequested = trade.requested_tickets.length > 0;
+
+ return (
+
+
+
+
+
+ Trade offer from {trade.from_ckey}
+
+
+ {hasOffered
+ ? `They give: ${trade.offered_ticket_names.join(', ')}`
+ : 'They give: nothing'}
+ {' · '}
+ {hasRequested
+ ? `They want: ${trade.requested_ticket_names.join(', ')}`
+ : 'They want: nothing'}
+
+ {isCancelling && (
+
+
+ Sender is cancelling
+
+ )}
+
+
+
+
+
+
+
+
+
+ {expanded && (
+
+
+
+
+
+ You receive
+
+ {hasOffered ? (
+ trade.offered_tickets.map((t) => (
+
+ ))
+ ) : (
+
+ Nothing
+
+ )}
+
+
+
+
+
+ They take from you
+
+ {hasRequested ? (
+ trade.requested_tickets.map((t) => (
+
+ ))
+ ) : (
+
+ Nothing
+
+ )}
+
+
+
+ )}
+
+ );
+};
+
+type TradeComposerProps = {
+ ownedTickets: TicketEntry[];
+ lockedOfferingIds: Set;
+ outgoingTrades: OutgoingTrade[];
+ onlineCkeys: string[];
+ lookupResultCkey: string | null;
+ lookupResultTickets: TicketEntry[] | null;
+ onLookupCkey: (ckey: string) => void;
+ onOfferTrade: (
+ offered_ids: string[],
+ requested_ids: string[],
+ to_ckey: string,
+ ) => void;
+ onCancelTrade: (trade_id: string) => void;
+};
+
+const TradeComposerPanel = ({
+ ownedTickets,
+ lockedOfferingIds,
+ outgoingTrades,
+ onlineCkeys,
+ lookupResultCkey,
+ lookupResultTickets,
+ onLookupCkey,
+ onOfferTrade,
+ onCancelTrade,
+}: TradeComposerProps) => {
+ const [targetMode, setTargetMode] = useState<'online' | 'offline'>('online');
+ const [targetCkey, setTargetCkey] = useState('');
+ const [offlineInput, setOfflineInput] = useState('');
+
+ const [offerIds, setOfferIds] = useState>(new Set());
+ const [requestIds, setRequestIds] = useState>(new Set());
+
+ const effectiveCkey =
+ targetMode === 'online' ? targetCkey : offlineInput.trim().toLowerCase();
+
+ const toggle = (
+ set: Set,
+ setter: React.Dispatch>>,
+ id: string,
+ ) => {
+ setter((prev) => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ });
+ };
+
+ const offerTickets = ownedTickets.filter((t) => offerIds.has(t.ticket_id));
+ const requestTickets = (lookupResultTickets ?? []).filter((t) =>
+ requestIds.has(t.ticket_id),
+ );
+
+ const lookupIsFresh =
+ lookupResultCkey && lookupResultCkey === effectiveCkey;
+
+ const canSend =
+ !!effectiveCkey &&
+ (offerIds.size > 0 || requestIds.size > 0) &&
+ [...offerIds].every((id) => !lockedOfferingIds.has(id));
+
+ const handleSend = () => {
+ if (!canSend) return;
+ onOfferTrade([...offerIds], [...requestIds], effectiveCkey);
+ setOfferIds(new Set());
+ setRequestIds(new Set());
+ setTargetCkey('');
+ setOfflineInput('');
+ };
+
+ return (
+
+
+
+ Who are you trading with?
+
+
+
+
+
+
+
+
+
+
+ {targetMode === 'online' ? (
+ onlineCkeys.length === 0 ? (
+
+ No other players online.
+
+ ) : (
+
+ {onlineCkeys.map((ckey) => (
+
+
+
+ ))}
+
+ )
+ ) : (
+
+
+ setOfflineInput(v)}
+ />
+
+
+
+
+
+ )}
+
+
+
+
+ Tickets you are offering{' '}
+ {offerIds.size > 0 && (
+
+ ({offerIds.size} selected)
+
+ )}
+
+ {ownedTickets.length === 0 ? (
+
+ You have no tickets to offer.
+
+ ) : (
+ ownedTickets.map((t) => (
+ toggle(offerIds, setOfferIds, id)}
+ />
+ ))
+ )}
+ {offerIds.size > 0 && (
+
+ )}
+
+
+
+
+ Tickets you want in return{' '}
+ {requestIds.size > 0 && (
+
+ ({requestIds.size} selected)
+
+ )}
+
+ {!effectiveCkey ? (
+
+ Select a player first to see their tickets.
+
+ ) : !lookupIsFresh ? (
+
+ {targetMode === 'offline'
+ ? 'Click "Look up" to fetch their inventory.'
+ : 'Select a player above to load their tickets.'}
+
+ ) : !lookupResultTickets || lookupResultTickets.length === 0 ? (
+
+ {lookupResultCkey} has no tickets.
+
+ ) : (
+ lookupResultTickets.map((t) => (
+ toggle(requestIds, setRequestIds, id)}
+ />
+ ))
+ )}
+ {requestIds.size > 0 && (
+
+ )}
+
+
+
+
+
+
+
+ {outgoingTrades.length > 0 && (
+
+
+ Your pending outgoing trades:
+
+ {outgoingTrades.map((tr) => (
+
+
+
+
+
+ To {tr.to_ckey}
+ {!!tr.cancelling && (
+
+ (cancelling…)
+
+ )}
+
+
+ {tr.offered_ticket_names.length > 0
+ ? `Offering: ${tr.offered_ticket_names.join(', ')}`
+ : 'Offering: nothing'}
+ {tr.requested_ticket_names.length > 0 &&
+ ` · Wanting: ${tr.requested_ticket_names.join(', ')}`}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+const EVENT_ICON: Record = {
+ used: 'play',
+ traded_away: 'arrow-right',
+ traded_received: 'arrow-left',
+ granted: 'arrow-left',
+ converted: 'arrow-left',
+};
+const EVENT_COLOR: Record = {
+ used: 'good',
+ traded_away: 'average',
+ traded_received: '#2196f3',
+ granted: 'good',
+ converted: 'bad',
+};
+const EVENT_LABEL: Record = {
+ used: 'Used',
+ traded_away: 'Traded away',
+ traded_received: 'Received via trade',
+ granted: 'Given',
+ converted: "Converted",
+};
+
+const HistoryView = ({ history }: { history: HistoryEntry[] }) => {
+ const sorted = useMemo(() => [...history].reverse(), [history]);
+
+ if (!sorted.length) {
+ return (
+
+ No history yet, use or trade a ticket to see it here.
+
+ );
+ }
+
+ return (
+
+ {sorted.map((entry, i) => {
+ const color = EVENT_COLOR[entry.event] ?? 'label';
+ const icon = EVENT_ICON[entry.event] ?? 'info';
+ const label = EVENT_LABEL[entry.event] ?? entry.event;
+ return (
+
+
+
+
+
+
+
+ {label}
+
+ {entry.names}
+ {!!entry.received_names && (
+
+ {' ↔ '}{entry.received_names}
+
+ )}
+ {!!entry.partner && (
+
+ {' '}
+ {entry.event === 'traded_away'
+ ? `→ ${entry.partner}`
+ : `← ${entry.partner}`}
+
+ )}
+
+ {!!entry.description && (
+
+ {entry.description}
+
+ )}
+
+ {entry.timestamp}
+
+
+
+ );
+ })}
+
+ );
+};
+
+type TicketShopViewProps = {
+ ownedTickets: TicketEntry[];
+ ticketHistory: HistoryEntry[];
+ incomingTrades: IncomingTrade[];
+ outgoingTrades: OutgoingTrade[];
+ lockedOfferingIds: string[]; // ticket_ids locked in outgoing trades
+ onlineCkeys: string[];
+ lookupResultCkey: string | null;
+ lookupResultTickets: TicketEntry[] | null;
+ onUseTicket: (ticket_id: string) => void;
+ onOfferTrade: (
+ offered_ids: string[],
+ requested_ids: string[],
+ to_ckey: string,
+ ) => void;
+ onAcceptTrade: (trade_id: string) => void;
+ onCancelTrade: (trade_id: string) => void;
+ onDeclineTrade: (trade_id: string) => void;
+ onLookupCkey: (ckey: string) => void;
+};
+
+type SubTab = 'inventory' | 'trade' | 'history';
+
+export const TicketShopView = ({
+ ownedTickets,
+ ticketHistory,
+ incomingTrades,
+ outgoingTrades,
+ lockedOfferingIds,
+ onlineCkeys,
+ lookupResultCkey,
+ lookupResultTickets,
+ onUseTicket,
+ onOfferTrade,
+ onAcceptTrade,
+ onCancelTrade,
+ onDeclineTrade,
+ onLookupCkey,
+}: TicketShopViewProps) => {
+ const [subTab, setSubTab] = useState('inventory');
+
+ const lockedSet = useMemo(
+ () => new Set(lockedOfferingIds),
+ [lockedOfferingIds],
+ );
+
+ return (
+
+
+
+
+ setSubTab('inventory')}
+ >
+
+ My Tickets
+ {ownedTickets.length > 0 && (
+
+ {ownedTickets.length}
+
+ )}
+
+ setSubTab('trade')}
+ >
+
+ Trade
+ {incomingTrades.length > 0 && (
+
+ {incomingTrades.length}
+
+ )}
+
+ setSubTab('history')}
+ >
+
+ History
+
+
+
+
+
+ {subTab === 'inventory' && (
+
+ {ownedTickets.length === 0 ? (
+
+ You have no tickets. Tickets are granted by admins and server
+ events.
+
+ ) : (
+ ownedTickets.map((t) => (
+
+ ))
+ )}
+
+ )}
+
+ {subTab === 'trade' && (
+
+ {incomingTrades.length > 0 && (
+
+
+ {incomingTrades.map((tr) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+ )}
+
+ {subTab === 'history' && (
+
+ )}
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/TriumphBuy.tsx b/tgui/packages/tgui/interfaces/TriumphBuy.tsx
new file mode 100644
index 00000000000..a53be5f0f55
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/TriumphBuy.tsx
@@ -0,0 +1,438 @@
+import { useMemo, useState } from 'react';
+import { Box, Button, Icon, NoticeBox, NumberInput, Section, Stack } from 'tgui-core/components';
+import type { BooleanLike } from 'tgui-core/react';
+
+export type TriumphBuyEntry = {
+ ref: string;
+ triumph_buy_id: string;
+ name: string;
+ desc: string;
+ cost: number;
+ category: string;
+ is_communal: BooleanLike;
+ communal_current: number;
+ communal_max: number;
+ communal_activated: BooleanLike;
+ pre_round_only: BooleanLike;
+ limited: BooleanLike;
+ /** -1 = unlimited */
+ stock: number;
+ conflicted: BooleanLike;
+ disabled: BooleanLike;
+ allow_multiple: BooleanLike;
+ already_owned: BooleanLike;
+ can_be_refunded: BooleanLike;
+ activated: BooleanLike;
+ is_seasonal: BooleanLike;
+ visible_active: BooleanLike;
+};
+
+export type ActiveTriumphBuy = {
+ ref: string;
+ triumph_buy_id: string;
+ name: string;
+ desc: string;
+ cost: number;
+ pre_round_only: BooleanLike;
+ can_be_refunded: BooleanLike;
+ activated: BooleanLike;
+ is_seasonal: BooleanLike;
+};
+
+const CommunalContributor = ({
+ entry,
+ balance,
+ onContribute,
+}: {
+ entry: TriumphBuyEntry;
+ balance: number;
+ onContribute: (ref: string, amount: number) => void;
+}) => {
+ const remaining = Math.max(0, entry.communal_max - entry.communal_current);
+ const maxPossible = Math.min(balance, remaining > 0 ? remaining : balance);
+ const [amount, setAmount] = useState(1);
+
+ const progressPct =
+ entry.communal_max > 0
+ ? Math.min(100, (entry.communal_current / entry.communal_max) * 100)
+ : 0;
+
+ const canContribute =
+ !entry.communal_activated && amount > 0 && amount <= balance && balance > 0;
+
+ return (
+
+
+
+
+
+ Community pool: {entry.communal_current.toLocaleString()}
+ {entry.communal_max > 0 &&
+ ` / ${entry.communal_max.toLocaleString()}`}
+
+
+
+
+ {Math.round(progressPct)}%
+
+
+
+
+ = 100 ? '#4caf50' : '#2196f3',
+ borderRadius: '3px',
+ transition: 'width 0.3s ease',
+ }}
+ />
+
+
+
+ {entry.communal_activated ? (
+
+
+ This communal goal is already active!
+
+ ) : remaining === 0 && entry.communal_max > 0 ? (
+
+ Pool is full.
+
+ ) : (
+
+
+
+ Contribute triumphs:
+
+ 0 ? maxPossible : 1}
+ stepPixelSize={4}
+ onChange={(val: number) => setAmount(Math.max(1, Math.floor(val)))}
+ />
+
+
+
+
+ {maxPossible > 0 && (
+
+
+
+ )}
+
+ )}
+
+ );
+};
+
+const TriumphBuyRow = ({
+ entry,
+ balance,
+ onBuy,
+ onContribute,
+}: {
+ entry: TriumphBuyEntry;
+ balance: number;
+ onBuy: (ref: string) => void;
+ onContribute: (ref: string, amount: number) => void;
+}) => {
+ const [communalOpen, setCommunalOpen] = useState(false);
+
+ const isCommunal = !!entry.is_communal;
+ const disabled = !!entry.disabled;
+ const conflicted = !!entry.conflicted;
+ const preRound = !!entry.pre_round_only;
+ const outOfStock = !!entry.limited && entry.stock <= 0;
+ const alreadyOwned = !!entry.already_owned;
+ const activated = !!entry.activated;
+ const isSeasonal = !!entry.is_seasonal;
+ const canAfford = balance >= entry.cost;
+
+ let statusText: string | null = null;
+ let statusColor = 'label';
+ if (disabled) {
+ statusText = 'Disabled';
+ statusColor = 'bad';
+ } else if (conflicted) {
+ statusText = preRound ? 'Round started' : 'Conflict';
+ statusColor = 'average';
+ } else if (outOfStock) {
+ statusText = 'Out of stock';
+ statusColor = 'bad';
+ } else if (alreadyOwned) {
+ statusText = isSeasonal ? 'Active (seasonal)' : 'Purchased';
+ statusColor = 'good';
+ } else if (isCommunal && activated) {
+ statusText = 'Active';
+ statusColor = 'good';
+ }
+
+ const buyBlocked =
+ disabled || conflicted || outOfStock || alreadyOwned || (isCommunal && activated);
+
+ return (
+
+
+
+
+
+
+ {entry.name}
+
+ {entry.desc}
+
+ {isCommunal && (
+
+
+ Community goal
+ {entry.communal_max > 0 &&
+ ` ${entry.communal_current.toLocaleString()} / ${entry.communal_max.toLocaleString()}`}
+
+ )}
+ {isSeasonal && (
+
+
+ Seasonal: persists across rounds
+
+ )}
+
+
+ {statusText && (
+
+
+ {statusText}
+
+
+ )}
+
+ {!!entry.limited && entry.stock > 0 && (
+
+
+ {entry.stock} left
+
+
+ )}
+
+
+ {isCommunal && !disabled ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isCommunal && communalOpen && !activated && (
+ {
+ onContribute(ref, amt);
+ setCommunalOpen(false);
+ }}
+ />
+ )}
+
+ );
+};
+
+export const TriumphBuyCategoryView = ({
+ items,
+ balance,
+ search,
+ onBuy,
+ onContribute,
+}: {
+ items: TriumphBuyEntry[];
+ balance: number;
+ search: string;
+ onBuy: (ref: string) => void;
+ onContribute: (ref: string, amount: number) => void;
+}) => {
+ const filtered = useMemo(() => {
+ if (!search) return items;
+ const q = search.toLowerCase();
+ return items.filter(
+ (i) =>
+ i.name.toLowerCase().includes(q) || i.desc.toLowerCase().includes(q),
+ );
+ }, [items, search]);
+
+ if (!filtered.length) {
+ return (
+
+ No items found{search ? ` for "${search}"` : ''}.
+
+ );
+ }
+
+ return (
+
+ {filtered.map((entry) => (
+
+ ))}
+
+ );
+};
+
+export const ActiveTriumphBuysView = ({
+ items,
+ onRefund,
+}: {
+ items: ActiveTriumphBuy[];
+ onRefund: (ref: string) => void;
+}) => {
+ if (!items.length) {
+ return (
+
+ You have no active triumph purchases this round. Browse the shop tabs to
+ spend triumphs!
+
+ );
+ }
+
+ return (
+
+ {items.map((entry) => {
+ const roundStarted = !!entry.pre_round_only; // server already set conflicted if round started
+ const canRefund = !!entry.can_be_refunded && !entry.activated;
+ const isSeasonal = !!entry.is_seasonal;
+
+ return (
+
+
+
+
+
+ {entry.name}
+
+ {entry.desc}
+
+ {isSeasonal && (
+
+
+ Seasonal: no refund available
+
+ )}
+ {entry.activated && !isSeasonal && (
+
+ Already activated, cannot refund
+
+ )}
+
+
+ {canRefund ? (
+
+ ) : (
+
+ {isSeasonal ? 'No refund' : 'Activated'}
+
+ )}
+
+
+ );
+ })}
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/TriumphShop.tsx b/tgui/packages/tgui/interfaces/TriumphShop.tsx
new file mode 100644
index 00000000000..b280430b40f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/TriumphShop.tsx
@@ -0,0 +1,1479 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useBackend, useLocalState } from '../backend';
+import {
+ Box,
+ Button,
+ DmIcon,
+ Icon,
+ Input,
+ NoticeBox,
+ Section,
+ Stack,
+ Tabs,
+} from 'tgui-core/components';
+import type { BooleanLike } from 'tgui-core/react';
+import { Window } from '../layouts';
+import { ColorPicker } from './ColorPicker';
+import type { ColorEntry } from './ColorPicker';
+import {
+ ActiveTriumphBuysView,
+ TriumphBuyCategoryView,
+ type ActiveTriumphBuy,
+ type TriumphBuyEntry,
+} from './TriumphBuy';
+import {
+ TicketShopView,
+ type TicketEntry,
+ type HistoryEntry,
+ type IncomingTrade,
+ type OutgoingTrade,
+} from './TicketShop';
+
+
+type LoadoutEntry = {
+ path: string;
+ name: string;
+ description: string | null;
+ cost_single: number;
+ cost_permanent: number;
+ free: BooleanLike;
+ owned: BooleanLike;
+ equipped: BooleanLike;
+ rented: BooleanLike;
+ can_afford_single: BooleanLike;
+ can_afford_perm: BooleanLike;
+ award_locked: BooleanLike;
+ ui_icon: string | null;
+ ui_icon_state: string | null;
+ category: string;
+ no_rent: BooleanLike;
+ no_equip: BooleanLike;
+ patreon_locked: BooleanLike;
+ donator_free: BooleanLike;
+};
+
+type EquippedSlot = {
+ path: string | null;
+ name: string;
+ permanent: BooleanLike;
+ dyeable: BooleanLike;
+ has_detail: BooleanLike;
+ base_color: string | null;
+ detail_color: string | null;
+};
+
+type SpecialEntry = {
+ path: string;
+ name: string;
+ greet_text: string;
+ req_text: string | null;
+ weight: number;
+ total_weight: number;
+ eligible: BooleanLike;
+ cost_random: number;
+ cost_specific: number;
+ is_pending: BooleanLike;
+};
+
+type Data = {
+ triumph_balance: number;
+ cost_random_special: number;
+ pending_special: string | null;
+ donator: BooleanLike;
+ categories: Record;
+ equipped_slots: [EquippedSlot, EquippedSlot, EquippedSlot];
+ specials: SpecialEntry[];
+ available_colors: ColorEntry[];
+ /** keyed by TRIUMPH_CAT_* strings */
+ triumph_buy_categories: Record;
+ active_triumph_buys: ActiveTriumphBuy[];
+ owned_tickets: TicketEntry[];
+ ticket_history: HistoryEntry[];
+ incoming_trades: IncomingTrade[];
+ outgoing_trades: OutgoingTrade[];
+ locked_offering_ids: string[]; // ticket_ids currently in outgoing trades
+ online_ckeys: string[];
+ lookup_result_ckey: string | null;
+ lookup_result_tickets: TicketEntry[] | null;
+
+
+};
+
+function flattenCategories(
+ categories: Record,
+): LoadoutEntry[] {
+ return Object.values(categories).flatMap((v) =>
+ Array.isArray(v) ? v : [v as unknown as LoadoutEntry],
+ );
+}
+
+type RarityTier = 'common' | 'uncommon' | 'rare' | 'epic';
+
+function getRarity(weight: number, allWeights: number[]): RarityTier {
+ if (!allWeights.length || weight <= 0) return 'epic';
+ const sorted = [...allWeights].sort((a, b) => b - a);
+ const q1 = sorted[Math.floor(sorted.length * 0.25)];
+ const q2 = sorted[Math.floor(sorted.length * 0.5)];
+ const q3 = sorted[Math.floor(sorted.length * 0.75)];
+ if (weight >= q1) return 'common';
+ if (weight >= q2) return 'uncommon';
+ if (weight >= q3) return 'rare';
+ return 'epic';
+}
+
+const RARITY_COLOR: Record = {
+ common: '#9e9e9e',
+ uncommon: '#4caf50',
+ rare: '#2196f3',
+ epic: '#9c27b0',
+};
+
+const RARITY_LABEL: Record = {
+ common: 'Common',
+ uncommon: 'Uncommon',
+ rare: 'Rare',
+ epic: 'Epic',
+};
+
+const ItemSprite = ({
+ icon,
+ icon_state,
+ size = 2,
+}: {
+ icon: string | null;
+ icon_state: string | null;
+ size?: number;
+}) => {
+ if (!icon || !icon_state) {
+ return ;
+ }
+ return (
+ }
+ />
+ );
+};
+
+const EquippedPanel = ({
+ slots,
+ availableColors,
+ onUnequip,
+ onSetColor,
+ onClearColor,
+ onBuyColor,
+}: {
+ slots: EquippedSlot[];
+ availableColors: ColorEntry[];
+ onUnequip: (path: string) => void;
+ onSetColor: (path: string, layer: 'base' | 'detail', hex: string) => void;
+ onClearColor: (path: string, layer: 'base' | 'detail') => void;
+ onBuyColor: (colorPath: string) => void;
+}) => {
+ const [openSlotPath, setOpenSlotPath] = useState(null);
+ const [openLayer, setOpenLayer] = useState<'base' | 'detail'>('base');
+
+ const togglePicker = (path: string, layer: 'base' | 'detail' = 'base') => {
+ if (openSlotPath === path && openLayer === layer) {
+ setOpenSlotPath(null);
+ } else {
+ setOpenSlotPath(path);
+ setOpenLayer(layer);
+ }
+ };
+
+ return (
+
+
+ {slots.map((slot, i) => {
+ const isOpen = !!slot.path && openSlotPath === slot.path;
+ const dyeable = !!slot.dyeable;
+ const hasDetail = !!slot.has_detail;
+
+ return (
+
+
+
+
+
+
+ {slot.path ? slot.name : `Slot ${i + 1} - Empty`}
+
+
+ {!!slot.path && dyeable && (
+
+
+ )}
+ {!!slot.path && hasDetail && (
+
+
+ )}
+
+ {!!slot.path && (
+
+
+ )}
+
+
+ {isOpen && slot.path && (
+
+ {dyeable && hasDetail && (
+
+
+
+
+
+
+
+
+ )}
+ onSetColor(slot.path!, openLayer, hex)}
+ onClear={() => onClearColor(slot.path!, openLayer)}
+ onBuy={onBuyColor}
+ label={
+ openLayer === 'base' ? 'Base Color' : 'Accent Color'
+ }
+ />
+
+ )}
+
+ );
+ })}
+
+
+
+ Items and colors apply on your next spawn.
+
+
+
+
+ );
+};
+
+const LoadoutItemRow = ({
+ item,
+ onBuySingle,
+ onBuyPermanent,
+ onEquip,
+ onUnequip,
+ slotsUsed,
+ donator,
+}: {
+ item: LoadoutEntry;
+ onBuySingle: (path: string) => void;
+ onBuyPermanent: (path: string) => void;
+ onEquip: (path: string) => void;
+ onUnequip: (path: string) => void;
+ slotsUsed: number;
+ donator: BooleanLike;
+}) => {
+ const slotsFull = slotsUsed >= 3;
+ const owned = !!item.owned;
+ const equipped = !!item.equipped;
+ const rented = !!item.rented;
+ const free = !!item.free;
+ const awardLocked = !!item.award_locked;
+ const canSingle = !!item.can_afford_single;
+ const canPerm = !!item.can_afford_perm;
+ const noRent = !!item.no_rent;
+ const noEquip = !!item.no_equip;
+ const patreonLock = !!item.patreon_locked;
+ const isDonatorFree = !!donator && !!item.donator_free;
+
+ return (
+
+
+
+
+
+ {item.name}
+ {!!item.description && (
+
+ {item.description}
+
+ )}
+
+
+ {patreonLock && !owned && (
+
+ Patreon exclusive
+
+ )}
+ {awardLocked && (
+
+ Achievement locked
+
+ )}
+ {!awardLocked && !patreonLock && owned && (
+
+ {noEquip ? 'Claimed' : 'Owned'}
+
+ )}
+ {!awardLocked && !patreonLock && !owned && rented && (
+
+ Rented this round
+
+ )}
+
+
+
+ {owned && !equipped && !noEquip && (
+
+ )}
+ {equipped && !noEquip && (
+
+ )}
+ {!owned &&
+ !rented &&
+ !awardLocked &&
+ !patreonLock &&
+ !noRent &&
+ !noEquip && (
+
+ )}
+ {rented && !noEquip && (
+
+ )}
+ {!owned && !awardLocked && !patreonLock && item.cost_permanent > 0 && (
+
+ )}
+ {!owned &&
+ !awardLocked &&
+ !patreonLock &&
+ item.cost_permanent === 0 &&
+ !rented &&
+ free && (
+
+ )}
+
+
+
+ );
+};
+
+const LoadoutCategoryView = ({
+ items,
+ onBuySingle,
+ onBuyPermanent,
+ onEquip,
+ onUnequip,
+ slotsUsed,
+ search,
+ donator,
+}: {
+ items: LoadoutEntry[];
+ onBuySingle: (path: string) => void;
+ onBuyPermanent: (path: string) => void;
+ onEquip: (path: string) => void;
+ onUnequip: (path: string) => void;
+ slotsUsed: number;
+ search: string;
+ donator: BooleanLike;
+}) => {
+ const filtered = useMemo(() => {
+ if (!search) return items;
+ const q = search.toLowerCase();
+ return items.filter(
+ (i) =>
+ i.name.toLowerCase().includes(q) ||
+ (i.description ?? '').toLowerCase().includes(q),
+ );
+ }, [items, search]);
+
+ if (!filtered.length) {
+ return (
+
+ No items found{search ? ` for "${search}"` : ''}.
+
+ );
+ }
+
+ return (
+
+ {filtered.map((item) => (
+
+ ))}
+
+ );
+};
+
+const CollectionView = ({
+ categories,
+ onEquip,
+ onUnequip,
+ slotsUsed,
+ donator,
+}: {
+ categories: Record;
+ onEquip: (path: string) => void;
+ onUnequip: (path: string) => void;
+ slotsUsed: number;
+ donator: BooleanLike;
+}) => {
+ const items = useMemo(
+ () =>
+ flattenCategories(categories).filter(
+ (i) => i.owned || i.equipped || i.rented,
+ ),
+ [categories],
+ );
+
+ if (!items.length) {
+ return (
+
+ You have not unlocked any items yet. Browse the shop tabs to spend
+ triumphs!
+
+ );
+ }
+
+ return (
+
+ {items.map((item) => (
+ {}}
+ onBuyPermanent={() => {}}
+ onEquip={onEquip}
+ onUnequip={onUnequip}
+ slotsUsed={slotsUsed}
+ donator={donator}
+ />
+ ))}
+
+ );
+};
+
+const REEL_VISIBLE = 5;
+const REEL_CARD_H = 52;
+const REEL_LAND_MS = 2000;
+const REEL_LINGER_MS = 2500;
+
+function buildPool(specials: SpecialEntry[]): string[] {
+ if (!specials.length) return [];
+ const totalWeight = specials.reduce((s, x) => s + x.weight, 0);
+ const divisor = totalWeight / 20;
+ const pool: string[] = [];
+ for (const s of specials) {
+ const reps = Math.max(1, Math.round(s.weight / divisor));
+ for (let i = 0; i < reps; i++) pool.push(s.path);
+ }
+ for (let i = pool.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [pool[i], pool[j]] = [pool[j], pool[i]];
+ }
+ while (pool.length < 50) {
+ pool.push(...pool.slice(0, Math.min(pool.length, 50 - pool.length)));
+ }
+ return pool;
+}
+
+function safeMod(n: number, m: number): number {
+ if (m <= 0) return 0;
+ return ((n % m) + m) % m;
+}
+
+const TraitReel = ({
+ specials,
+ landOnPath,
+ onDone,
+}: {
+ specials: SpecialEntry[];
+ landOnPath: string | null;
+ onDone: () => void;
+}) => {
+ const allWeights = useMemo(
+ () => specials.map((s) => s.weight),
+ [specials],
+ );
+ const nameMap = useMemo(
+ () => Object.fromEntries(specials.map((s) => [s.path, s.name])),
+ [specials],
+ );
+ const weightMap = useMemo(
+ () => Object.fromEntries(specials.map((s) => [s.path, s.weight])),
+ [specials],
+ );
+
+ const [strip, setStrip] = useState(() => buildPool(specials));
+ const [offsetY, setOffsetY] = useState(0);
+
+ const rafRef = useRef(null);
+ const phaseRef = useRef<'spin' | 'land' | 'linger'>('spin');
+ const spinOffsetRef = useRef(0);
+ const landStartRef = useRef(null);
+ const landFromRef = useRef(0);
+ const landTargetRef = useRef(0);
+ const lingerStartRef = useRef(null);
+ const doneRef = useRef(false);
+ const centreIndex = Math.floor(REEL_VISIBLE / 2);
+
+ const spinLoopRef = useRef<(ts: number) => void>(() => {});
+ spinLoopRef.current = (_ts: number) => {
+ if (phaseRef.current !== 'spin') return;
+ spinOffsetRef.current += REEL_CARD_H * 0.35;
+ const maxOffset = strip.length * REEL_CARD_H;
+ if (maxOffset > 0 && spinOffsetRef.current >= maxOffset)
+ spinOffsetRef.current -= maxOffset;
+ setOffsetY(Math.round(spinOffsetRef.current));
+ rafRef.current = requestAnimationFrame(spinLoopRef.current);
+ };
+
+ useEffect(() => {
+ phaseRef.current = 'spin';
+ rafRef.current = requestAnimationFrame(spinLoopRef.current);
+ return () => {
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!landOnPath || phaseRef.current !== 'spin') return;
+ const newStrip = buildPool(specials);
+ newStrip.push(landOnPath);
+ setStrip(newStrip);
+
+ const targetIndex = newStrip.length - 1 - centreIndex;
+ const targetOffset = targetIndex * REEL_CARD_H;
+ landFromRef.current = spinOffsetRef.current;
+ landTargetRef.current = targetOffset;
+ landStartRef.current = null;
+ phaseRef.current = 'land';
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
+
+ const lingerLoop = (ts: number) => {
+ if (!lingerStartRef.current) lingerStartRef.current = ts;
+ if (ts - lingerStartRef.current < REEL_LINGER_MS) {
+ rafRef.current = requestAnimationFrame(lingerLoop);
+ } else if (!doneRef.current) {
+ doneRef.current = true;
+ onDone();
+ }
+ };
+
+ const landAnimate = (ts: number) => {
+ if (!landStartRef.current) landStartRef.current = ts;
+ const elapsed = ts - landStartRef.current;
+ const t = Math.min(elapsed / REEL_LAND_MS, 1);
+ const eased = 1 - Math.pow(1 - t, 3);
+ const current =
+ landFromRef.current +
+ (landTargetRef.current - landFromRef.current) * eased;
+ setOffsetY(Math.round(current));
+ if (t < 1) {
+ rafRef.current = requestAnimationFrame(landAnimate);
+ } else {
+ setOffsetY(landTargetRef.current);
+ phaseRef.current = 'linger';
+ lingerStartRef.current = null;
+ rafRef.current = requestAnimationFrame(lingerLoop);
+ }
+ };
+ rafRef.current = requestAnimationFrame(landAnimate);
+ }, [landOnPath]);
+
+ const startIndex =
+ strip.length > 0 ? Math.floor(offsetY / REEL_CARD_H) : 0;
+ const pixelOffset = strip.length > 0 ? offsetY % REEL_CARD_H : 0;
+ const visibleIndices: number[] = [];
+ for (let i = 0; i < REEL_VISIBLE + 1; i++)
+ visibleIndices.push(startIndex + i);
+
+ const centredPath =
+ strip.length > 0
+ ? strip[safeMod(startIndex + centreIndex, strip.length)] ?? ''
+ : '';
+ const centredRarity = getRarity(weightMap[centredPath] ?? 0, allWeights);
+ const centreColor = RARITY_COLOR[centredRarity];
+
+ return (
+
+
+
+
+ {visibleIndices.map((idx, i) => {
+ const path =
+ strip.length > 0
+ ? strip[safeMod(idx, strip.length)] ?? ''
+ : '';
+ const isCentre = i === centreIndex;
+ const rarity = getRarity(weightMap[path] ?? 0, allWeights);
+ const color = RARITY_COLOR[rarity];
+ return (
+
+
+ {nameMap[path] ?? '???'}
+
+ );
+ })}
+
+
+
+ );
+};
+
+const SpecialsTab = ({
+ specials,
+ pendingSpecial,
+ balance,
+ costRandom,
+ donator,
+ onRollRandom,
+ onBuySpecific,
+ onClearPending,
+}: {
+ specials: SpecialEntry[];
+ pendingSpecial: string | null;
+ balance: number;
+ costRandom: number;
+ donator: BooleanLike;
+ onRollRandom: () => void;
+ onBuySpecific: (path: string) => void;
+ onClearPending: () => void;
+}) => {
+ const [showReel, setShowReel] = useState(false);
+ const [landOnPath, setLandOnPath] = useState(null);
+ const prevPendingRef = useRef(pendingSpecial);
+
+ const allWeights = useMemo(() => specials.map((s) => s.weight), [specials]);
+ const totalWeight = specials[0]?.total_weight ?? 1;
+
+ useEffect(() => {
+ if (showReel && pendingSpecial && pendingSpecial !== prevPendingRef.current) {
+ setLandOnPath(pendingSpecial);
+ }
+ }, [pendingSpecial, showReel]);
+
+ const handleRollClick = () => {
+ prevPendingRef.current = pendingSpecial;
+ setLandOnPath(null);
+ setShowReel(true);
+ onRollRandom();
+ };
+
+ const handleReelDone = () => setShowReel(false);
+
+ const hasPending = !!pendingSpecial;
+ const canAffordRandom = balance >= costRandom;
+ const pendingTrait = pendingSpecial
+ ? specials.find((s) => s.path === pendingSpecial)
+ : null;
+
+ return (
+
+
+ {!!donator ? (
+
+
+
+ Patreon Supporter perk:
+ {' '}
+ Random rolls are free for you, and specific trait costs are 50% off.
+
+ ) : (
+
+
+ Patreon supporters get free random rolls and 50% off specific trait
+ picks.
+
+ )}
+
+
+
+
+ {showReel ? (
+
+ ) : hasPending ? (
+
+
+
+
+
+ {pendingTrait?.name ?? pendingSpecial}
+
+ {pendingTrait && (
+
+ {
+ RARITY_LABEL[
+ getRarity(pendingTrait.weight, allWeights)
+ ]
+ }
+
+ )}
+ {pendingTrait?.req_text && (
+
+ Requirements: {pendingTrait.req_text}
+
+ )}
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ or pick a specific trait below, cost varies by rarity
+
+
+ )}
+
+
+
+
+
+ {specials.map((trait) => {
+ const eligible = !!trait.eligible;
+ const rarity = getRarity(trait.weight, allWeights);
+ const color = RARITY_COLOR[rarity];
+ const canAfford = balance >= trait.cost_specific;
+ const expectedRolls =
+ totalWeight > 0
+ ? Math.round(totalWeight / Math.max(trait.weight, 1))
+ : 999;
+
+ return (
+
+
+
+
+ {trait.name}
+ {!eligible && (
+
+ (ineligible)
+
+ )}
+
+
+ {RARITY_LABEL[rarity]} ~1 in {expectedRolls} rolls
+
+ {!!trait.req_text && (
+
+ Requires: {trait.req_text}
+
+ )}
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+};
+
+export const TriumphShop = () => {
+ const { act, data } = useBackend();
+ const {
+ triumph_balance,
+ categories,
+ equipped_slots,
+ specials,
+ pending_special,
+ cost_random_special,
+ donator,
+ available_colors,
+ triumph_buy_categories = {},
+ active_triumph_buys = [],
+ owned_tickets = [],
+ ticket_history = [],
+ incoming_trades = [],
+ outgoing_trades = [],
+ locked_offering_ids = [],
+ online_ckeys = [],
+ lookup_result_ckey = null,
+ lookup_result_tickets = null,
+
+
+ } = data;
+
+ const loadoutCategoryNames = Object.keys(categories);
+
+ const tbCategoryNames = Object.keys(triumph_buy_categories).filter(
+ (k) => triumph_buy_categories[k]?.length > 0,
+ );
+
+ const TRIUMPH_SHOP_TAB = 'Seasonal / Round Shop';
+ const MY_PURCHASES_TAB = 'My Purchases';
+ const TICKETS_TAB = 'Tickets';
+
+ const allTabs = [
+ TICKETS_TAB,
+ 'Collection',
+ 'Specials',
+ ...loadoutCategoryNames,
+ ...(tbCategoryNames.length > 0 ? [TRIUMPH_SHOP_TAB] : []),
+ ];
+
+ const [activeTab, setActiveTab] = useLocalState('ts_tab', 'Specials');
+ const [search, setSearch] = useLocalState('ts_search', '');
+ const [triumphShopSubTab, setTriumphShopSubTab] = useLocalState(
+ 'ts_triumph_subtab',
+ MY_PURCHASES_TAB,
+ );
+
+ const slotsUsed = equipped_slots.filter((s) => s.path !== null).length;
+
+ const isTicketsTab = activeTab === TICKETS_TAB;
+ const isTriumphShopTab = activeTab === TRIUMPH_SHOP_TAB;
+ const isSpecialsTab = activeTab === 'Specials';
+
+ const showEquippedPanel = !isSpecialsTab && !isTriumphShopTab && !isTicketsTab;
+ const showSearch = !isSpecialsTab && !isTicketsTab;
+
+ const effectiveTab =
+ search.length > 1 && !isTriumphShopTab && !isSpecialsTab
+ ? '__search__'
+ : activeTab;
+
+ const allLoadoutItems = useMemo(
+ () => flattenCategories(categories),
+ [categories],
+ );
+ const allTbItems = useMemo(
+ () => Object.values(triumph_buy_categories).flat(),
+ [triumph_buy_categories],
+ );
+
+ const [convertAmount, setConvertAmount] = useState('');
+ const [showConvert, setShowConvert] = useState(false);
+
+ const handleConvertToTicket = () => {
+ const n = parseInt(convertAmount, 10);
+ if (!n || n <= 0) return;
+ act('convert_triumphs_to_ticket', { amount: n });
+ setConvertAmount('');
+ setShowConvert(false);
+ };
+
+ const handleBuySingle = (path: string) => act('buy_single', { path });
+ const handleBuyPermanent = (path: string) => act('buy_permanent', { path });
+ const handleEquip = (path: string) => act('equip_item', { path });
+ const handleUnequip = (path: string) => act('unequip_item', { path });
+ const handleSetColor = (
+ path: string,
+ layer: 'base' | 'detail',
+ hex: string,
+ ) => act('set_loadout_color', { path, layer, hex });
+ const handleClearColor = (path: string, layer: 'base' | 'detail') =>
+ act('clear_loadout_color', { path, layer });
+ const handleBuyColor = (colorPath: string) =>
+ act('buy_permanent', { path: colorPath });
+
+ const handleRollRandom = () => act('buy_random_special');
+ const handleBuySpecific = (path: string) =>
+ act('buy_specific_special', { path });
+ const handleClearPending = () => act('clear_pending_special');
+
+ const handleTriumphBuy = (ref: string) => act('triumph_buy', { ref });
+ const handleTriumphRefund = (ref: string) => act('triumph_refund', { ref });
+ const handleContribute = (ref: string, amount: number) =>
+ act('triumph_contribute', { ref, amount });
+
+ const handleUseTicket = (ticket_id: string) => act('use_ticket', { ticket_id });
+ const handleOfferTrade = (
+ offered_ids: string[],
+ requested_ids: string[],
+ to_ckey: string,
+ ) => act('offer_trade', { offered_ids, requested_ids, to_ckey });
+
+ const handleAcceptTrade = (trade_id: string) => act('accept_trade', { trade_id });
+ const handleCancelTrade = (trade_id: string) => act('cancel_trade', { trade_id });
+ const handleDeclineTrade = (trade_id: string) => act('cancel_trade', { trade_id }); // recipient decline reuses cancel
+ const handleLookupCkey = (target_ckey: string) => act('lookup_ckey_tickets', { target_ckey });
+
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showEquippedPanel && (
+
+
+
+ )}
+
+
+ {isSpecialsTab && (
+
+ )}
+
+ {isTriumphShopTab && (
+
+
+
+
+
+ setTriumphShopSubTab(MY_PURCHASES_TAB)
+ }
+ >
+
+ My Purchases
+ {active_triumph_buys.length > 0 && (
+
+ {active_triumph_buys.length}
+
+ )}
+
+ {tbCategoryNames.map((cat) => (
+ setTriumphShopSubTab(cat)}
+ >
+
+ {cat}
+
+ ))}
+
+
+
+
+ {triumphShopSubTab === MY_PURCHASES_TAB ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ {isTicketsTab && (
+
+ )}
+
+
+ {!isSpecialsTab && !isTriumphShopTab && (
+
+ {effectiveTab === '__search__' ? (
+
+ ) : activeTab === 'Collection' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/vanderlin.dme b/vanderlin.dme
index 483b27659d6..25528cea69c 100644
--- a/vanderlin.dme
+++ b/vanderlin.dme
@@ -103,6 +103,7 @@
#include "code\__DEFINES\lighting.dm"
#include "code\__DEFINES\liquids.dm"
#include "code\__DEFINES\living.dm"
+#include "code\__DEFINES\loadout.dm"
#include "code\__DEFINES\logging.dm"
#include "code\__DEFINES\lux.dm"
#include "code\__DEFINES\machines.dm"
@@ -534,6 +535,7 @@
#include "code\controllers\subsystem\elastic\shards\heartbeat.dm"
#include "code\controllers\subsystem\elastic\shards\medical.dm"
#include "code\controllers\subsystem\elastic\shards\round_data.dm"
+#include "code\controllers\subsystem\elastic\shards\shop.dm"
#include "code\controllers\subsystem\elastic\shards\storytellers.dm"
#include "code\controllers\subsystem\mobs\islander.dm"
#include "code\controllers\subsystem\mobs\matthios.dm"
@@ -572,7 +574,6 @@
#include "code\controllers\subsystem\role_class_handler\role_class_handler.dm"
#include "code\controllers\subsystem\role_class_handler\class_select_handler\class_select_handler.dm"
#include "code\controllers\subsystem\triumph\triumph_adjust_procs.dm"
-#include "code\controllers\subsystem\triumph\triumph_buy_menu.dm"
#include "code\controllers\subsystem\triumph\triumphs.dm"
#include "code\controllers\subsystem\triumph\buy_datums\triumph_buy_datums.dm"
#include "code\controllers\subsystem\triumph\buy_datums\challenges\curse.dm"
@@ -619,7 +620,6 @@
#include "code\datums\inspiration.dm"
#include "code\datums\ip_info.dm"
#include "code\datums\json_database.dm"
-#include "code\datums\loadouts.dm"
#include "code\datums\map_adjustment.dm"
#include "code\datums\map_adjustment_include.dm"
#include "code\datums\map_config.dm"
@@ -1342,6 +1342,19 @@
#include "code\datums\liquids\liquid_effect.dm"
#include "code\datums\liquids\liquid_group.dm"
#include "code\datums\liquids\liquid_status_effect.dm"
+#include "code\datums\loadouts\_base_loadout_item.dm"
+#include "code\datums\loadouts\accessory_loadout_items.dm"
+#include "code\datums\loadouts\armor_loadout_items.dm"
+#include "code\datums\loadouts\cloak_loadout_items.dm"
+#include "code\datums\loadouts\dye_loadout_items.dm"
+#include "code\datums\loadouts\face_loadout_items.dm"
+#include "code\datums\loadouts\hat_loadout_items.dm"
+#include "code\datums\loadouts\item_loadout_items.dm"
+#include "code\datums\loadouts\misc_loadout_item.dm"
+#include "code\datums\loadouts\neck_loadout_items.dm"
+#include "code\datums\loadouts\pants_loadout_items.dm"
+#include "code\datums\loadouts\shirt_loadout_items.dm"
+#include "code\datums\loadouts\shoes_loadout_items.dm"
#include "code\datums\locks\_lock.dm"
#include "code\datums\locks\lock_access.dm"
#include "code\datums\locks\lockpicking.dm"
@@ -1362,16 +1375,24 @@
#include "code\datums\materials\clay\_base.dm"
#include "code\datums\materials\material_traits\_base.dm"
#include "code\datums\materials\material_traits\silver_bane.dm"
+#include "code\datums\materials\metals\ancient_alloy.dm"
+#include "code\datums\materials\metals\avantyne.dm"
#include "code\datums\materials\metals\blacksteel.dm"
#include "code\datums\materials\metals\bronze.dm"
#include "code\datums\materials\metals\coke.dm"
#include "code\datums\materials\metals\copper.dm"
+#include "code\datums\materials\metals\draconic.dm"
+#include "code\datums\materials\metals\glimmering_slag.dm"
#include "code\datums\materials\metals\gold.dm"
#include "code\datums\materials\metals\iron.dm"
+#include "code\datums\materials\metals\ketryl.dm"
+#include "code\datums\materials\metals\lithmyc.dm"
+#include "code\datums\materials\metals\purified_alloy.dm"
#include "code\datums\materials\metals\silver.dm"
#include "code\datums\materials\metals\steel.dm"
#include "code\datums\materials\metals\thaumic_iron.dm"
#include "code\datums\materials\metals\tin.dm"
+#include "code\datums\materials\metals\weeping.dm"
#include "code\datums\materials\molten_materials\_base.dm"
#include "code\datums\materials\molten_materials\metal_combine_recipes.dm"
#include "code\datums\migrants\migrant_job.dm"
@@ -1613,6 +1634,16 @@
#include "code\datums\runeword\word_combos\_base.dm"
#include "code\datums\runeword\word_combos\flamebrand.dm"
#include "code\datums\runeword\word_combos\scattershot.dm"
+#include "code\datums\shop\shop.dm"
+#include "code\datums\shop\tickets\__helpers.dm"
+#include "code\datums\shop\tickets\__trade.dm"
+#include "code\datums\shop\tickets\_base.dm"
+#include "code\datums\shop\tickets\_preference_save.dm"
+#include "code\datums\shop\tickets\admin_panel.dm"
+#include "code\datums\shop\tickets\job_boost.dm"
+#include "code\datums\shop\tickets\loadout.dm"
+#include "code\datums\shop\tickets\special.dm"
+#include "code\datums\shop\tickets\triumph.dm"
#include "code\datums\speech_modifiers\_speech_modifier.dm"
#include "code\datums\speech_modifiers\lisp.dm"
#include "code\datums\stamina_modifier\_stamina_modifier.dm"
@@ -2547,6 +2578,7 @@
#include "code\modules\clothing\armor\medium.dm"
#include "code\modules\clothing\armor\misc.dm"
#include "code\modules\clothing\armor\plate.dm"
+#include "code\modules\clothing\armor\race.dm"
#include "code\modules\clothing\armor\rare.dm"
#include "code\modules\clothing\armor\regenerating.dm"
#include "code\modules\clothing\armor\steam.dm"
@@ -2574,6 +2606,7 @@
#include "code\modules\clothing\gloves\fingerless.dm"
#include "code\modules\clothing\gloves\leather.dm"
#include "code\modules\clothing\gloves\plate.dm"
+#include "code\modules\clothing\gloves\race.dm"
#include "code\modules\clothing\gloves\rare.dm"
#include "code\modules\clothing\gloves\steam.dm"
#include "code\modules\clothing\head\_head.dm"
@@ -2584,6 +2617,7 @@
#include "code\modules\clothing\head\hood.dm"
#include "code\modules\clothing\head\job_hats.dm"
#include "code\modules\clothing\head\misc.dm"
+#include "code\modules\clothing\head\race.dm"
#include "code\modules\clothing\head\rare.dm"
#include "code\modules\clothing\head\helmets\_helmet.dm"
#include "code\modules\clothing\head\helmets\heavy.dm"
@@ -2616,7 +2650,10 @@
#include "code\modules\clothing\quivers\_quiver.dm"
#include "code\modules\clothing\quivers\misc.dm"
#include "code\modules\clothing\ring\_ring.dm"
+#include "code\modules\clothing\ring\metal_rings.dm"
#include "code\modules\clothing\ring\misc.dm"
+#include "code\modules\clothing\ring\stat_rings.dm"
+#include "code\modules\clothing\ring\weddingbands.dm"
#include "code\modules\clothing\shirts\_shirt.dm"
#include "code\modules\clothing\shirts\dress.dm"
#include "code\modules\clothing\shirts\misc.dm"
@@ -2627,6 +2664,7 @@
#include "code\modules\clothing\shoes\_shoes.dm"
#include "code\modules\clothing\shoes\boots.dm"
#include "code\modules\clothing\shoes\misc.dm"
+#include "code\modules\clothing\shoes\race.dm"
#include "code\modules\clothing\shoes\rare.dm"
#include "code\modules\clothing\shoes\steam.dm"
#include "code\modules\clothing\wrists\_wrist.dm"