diff --git a/_maps/RandomRuins/LavaRuins/lavaland_surface_puzzle.dmm b/_maps/RandomRuins/LavaRuins/lavaland_surface_puzzle.dmm
new file mode 100644
index 0000000000..911ee904fe
--- /dev/null
+++ b/_maps/RandomRuins/LavaRuins/lavaland_surface_puzzle.dmm
@@ -0,0 +1,47 @@
+//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE
+"a" = (
+/turf/template_noop,
+/area/lavaland/surface/outdoors)
+"b" = (
+/obj/effect/sliding_puzzle/lavaland,
+/turf/open/floor/plating/asteroid/basalt/lava_land_surface,
+/area/lavaland/surface/outdoors)
+"c" = (
+/turf/open/floor/plating/asteroid/basalt/lava_land_surface,
+/area/lavaland/surface/outdoors)
+
+(1,1,1) = {"
+a
+a
+a
+a
+a
+"}
+(2,1,1) = {"
+a
+c
+c
+c
+a
+"}
+(3,1,1) = {"
+a
+c
+b
+c
+a
+"}
+(4,1,1) = {"
+a
+c
+c
+c
+a
+"}
+(5,1,1) = {"
+a
+a
+a
+a
+a
+"}
diff --git a/code/__DEFINES/admin.dm b/code/__DEFINES/admin.dm
index 255464d406..d43addf25a 100644
--- a/code/__DEFINES/admin.dm
+++ b/code/__DEFINES/admin.dm
@@ -70,6 +70,7 @@
#define ADMIN_PUNISHMENT_FIREBALL "Fireball"
#define ADMIN_PUNISHMENT_ROD "Immovable Rod"
#define ADMIN_PUNISHMENT_SUPPLYPOD "Supply Pod"
+#define ADMIN_PUNISHMENT_MAZING "Puzzle"
#define AHELP_ACTIVE 1
#define AHELP_CLOSED 2
diff --git a/code/_onclick/click.dm b/code/_onclick/click.dm
index f98de83c23..b79e303d26 100644
--- a/code/_onclick/click.dm
+++ b/code/_onclick/click.dm
@@ -72,6 +72,9 @@
if(check_click_intercept(params,A))
return
+ if(notransform)
+ return
+
var/list/modifiers = params2list(params)
if(modifiers["shift"] && modifiers["middle"])
ShiftMiddleClickOn(A)
diff --git a/code/controllers/subsystem/npcpool.dm b/code/controllers/subsystem/npcpool.dm
index 67e31b6931..d93f1f2407 100644
--- a/code/controllers/subsystem/npcpool.dm
+++ b/code/controllers/subsystem/npcpool.dm
@@ -23,7 +23,7 @@ SUBSYSTEM_DEF(npcpool)
var/mob/living/simple_animal/SA = currentrun[currentrun.len]
--currentrun.len
- if(!SA.ckey)
+ if(!SA.ckey && !SA.notransform)
if(SA.stat != DEAD)
SA.handle_automated_movement()
if(SA.stat != DEAD)
diff --git a/code/datums/ruins/lavaland.dm b/code/datums/ruins/lavaland.dm
index af299aaee9..4006c13a2c 100644
--- a/code/datums/ruins/lavaland.dm
+++ b/code/datums/ruins/lavaland.dm
@@ -215,3 +215,10 @@
suffix = "lavaland_surface_random_ripley.dmm"
allow_duplicates = FALSE
cost = 5
+
+/datum/map_template/ruin/lavaland/puzzle
+ name = "Ancient Puzzle"
+ id = "puzzle"
+ description = "Mystery to be solved."
+ suffix = "lavaland_surface_puzzle.dmm"
+ cost = 5
\ No newline at end of file
diff --git a/code/modules/admin/verbs/randomverbs.dm b/code/modules/admin/verbs/randomverbs.dm
index 0184c4b34f..de30b69f97 100644
--- a/code/modules/admin/verbs/randomverbs.dm
+++ b/code/modules/admin/verbs/randomverbs.dm
@@ -1313,7 +1313,7 @@ GLOBAL_LIST_EMPTY(custom_outfits) //Admin created outfits
if(!check_rights(R_ADMIN))
return
- var/list/punishment_list = list(ADMIN_PUNISHMENT_LIGHTNING, ADMIN_PUNISHMENT_BRAINDAMAGE, ADMIN_PUNISHMENT_GIB, ADMIN_PUNISHMENT_BSA, ADMIN_PUNISHMENT_FIREBALL, ADMIN_PUNISHMENT_ROD, ADMIN_PUNISHMENT_SUPPLYPOD)
+ var/list/punishment_list = list(ADMIN_PUNISHMENT_LIGHTNING, ADMIN_PUNISHMENT_BRAINDAMAGE, ADMIN_PUNISHMENT_GIB, ADMIN_PUNISHMENT_BSA, ADMIN_PUNISHMENT_FIREBALL, ADMIN_PUNISHMENT_ROD, ADMIN_PUNISHMENT_SUPPLYPOD, ADMIN_PUNISHMENT_MAZING)
var/punishment = input("Choose a punishment", "DIVINE SMITING") as null|anything in punishment_list
@@ -1359,6 +1359,10 @@ GLOBAL_LIST_EMPTY(custom_outfits) //Admin created outfits
if(iscarbon(target))
target.Stun(10)//takes 0.53 seconds for CentCom pod to land
new /obj/effect/DPtarget(get_turf(target), delivery, POD_CENTCOM)
+ if(ADMIN_PUNISHMENT_MAZING)
+ if(!puzzle_imprison(target))
+ to_chat(usr,"Imprisonment failed!")
+ return
var/msg = "[key_name_admin(usr)] punished [key_name_admin(target)] with [punishment]."
message_admins(msg)
diff --git a/code/modules/mining/lavaland/necropolis_chests.dm b/code/modules/mining/lavaland/necropolis_chests.dm
index 002efdbf8d..5345410005 100644
--- a/code/modules/mining/lavaland/necropolis_chests.dm
+++ b/code/modules/mining/lavaland/necropolis_chests.dm
@@ -1315,3 +1315,18 @@
for(var/t in RANGE_TURFS(1, T))
var/obj/effect/temp_visual/hierophant/blast/B = new(t, user, friendly_fire_check)
B.damage = 15 //keeps monster damage boost due to lower damage
+
+
+//Just some minor stuff
+/obj/structure/closet/crate/necropolis/puzzle
+ name = "puzzling chest"
+
+/obj/structure/closet/crate/necropolis/puzzle/PopulateContents()
+ var/loot = rand(1,3)
+ switch(loot)
+ if(1)
+ new /obj/item/soulstone/anybody(src)
+ if(2)
+ new /obj/item/wisp_lantern(src)
+ if(3)
+ new /obj/item/prisoncube(src)
\ No newline at end of file
diff --git a/code/modules/ruins/lavalandruin_code/puzzle.dm b/code/modules/ruins/lavalandruin_code/puzzle.dm
new file mode 100644
index 0000000000..9e798507da
--- /dev/null
+++ b/code/modules/ruins/lavalandruin_code/puzzle.dm
@@ -0,0 +1,351 @@
+/obj/effect/sliding_puzzle
+ name = "Sliding puzzle generator"
+ icon = 'icons/obj/items_and_weapons.dmi' //mapping
+ icon_state = "syndballoon"
+ invisibility = INVISIBILITY_ABSTRACT
+ anchored = TRUE
+ var/list/elements
+ var/floor_type = /turf/open/floor/vault
+ var/finished = FALSE
+ var/reward_type = /obj/item/reagent_containers/food/snacks/cookie
+ var/element_type = /obj/structure/puzzle_element
+ var/auto_setup = TRUE
+ var/empty_tile_id
+
+//Gets the turf where the tile with given id should be
+/obj/effect/sliding_puzzle/proc/get_turf_for_id(id)
+ var/turf/center = get_turf(src)
+ switch(id)
+ if(1)
+ return get_step(center,NORTHWEST)
+ if(2)
+ return get_step(center,NORTH)
+ if(3)
+ return get_step(center,NORTHEAST)
+ if(4)
+ return get_step(center,WEST)
+ if(5)
+ return center
+ if(6)
+ return get_step(center,EAST)
+ if(7)
+ return get_step(center,SOUTHWEST)
+ if(8)
+ return get_step(center,SOUTH)
+ if(9)
+ return get_step(center,SOUTHEAST)
+
+/obj/effect/sliding_puzzle/Initialize(mapload)
+ ..()
+ return INITIALIZE_HINT_LATELOAD
+
+/obj/effect/sliding_puzzle/LateInitialize()
+ if(auto_setup)
+ setup()
+
+/obj/effect/sliding_puzzle/proc/check_setup_location()
+ for(var/id in 1 to 9)
+ var/turf/T = get_turf_for_id(id)
+ if(!T)
+ return FALSE
+ if(istype(T,/turf/closed/indestructible))
+ return FALSE
+ return TRUE
+
+
+/obj/effect/sliding_puzzle/proc/validate()
+ if(finished)
+ return
+
+ if(elements.len < 8) //Someone broke it
+ qdel(src)
+
+ //Check if everything is in place
+ for(var/id in 1 to 9)
+ var/target_turf = get_turf_for_id(id)
+ var/obj/structure/puzzle_element/E = locate() in target_turf
+ if(id == empty_tile_id && !E) // This location should be empty.
+ continue
+ if(!E || E.id != id) //wrong tile or no tile at all
+ return
+ //Ding ding
+ finish()
+
+/obj/effect/sliding_puzzle/Destroy()
+ if(LAZYLEN(elements))
+ for(var/obj/structure/puzzle_element/E in elements)
+ E.source = null
+ elements.Cut()
+ return ..()
+
+#define COLLAPSE_DURATION 7
+
+/obj/effect/sliding_puzzle/proc/finish()
+ finished = TRUE
+ for(var/mob/M in range(7,src))
+ shake_camera(M, COLLAPSE_DURATION , 1)
+ for(var/obj/structure/puzzle_element/E in elements)
+ E.collapse()
+
+ dispense_reward()
+
+/obj/effect/sliding_puzzle/proc/dispense_reward()
+ new reward_type(get_turf(src))
+
+/obj/effect/sliding_puzzle/proc/is_solvable()
+ var/list/current_ordering = list()
+ for(var/obj/structure/puzzle_element/E in elements_in_order())
+ current_ordering += E.id
+
+ var/swap_tally = 0
+ for(var/i in 1 to current_ordering.len)
+ var/checked_value = current_ordering[i]
+ for(var/j in i to current_ordering.len)
+ if(current_ordering[j] < checked_value)
+ swap_tally++
+
+ return swap_tally % 2 == 0
+
+//swap two tiles in same row
+/obj/effect/sliding_puzzle/proc/make_solvable()
+ var/first_tile_id = 1
+ var/other_tile_id = 2
+ if(empty_tile_id == 1 || empty_tile_id == 2) //Can't swap with empty one so just grab some in second row
+ first_tile_id = 4
+ other_tile_id = 5
+
+ var/turf/T1 = get_turf_for_id(first_tile_id)
+ var/turf/T2 = get_turf_for_id(other_tile_id)
+
+ var/obj/structure/puzzle_element/E1 = locate() in T1
+ var/obj/structure/puzzle_element/E2 = locate() in T2
+
+ E1.forceMove(T2)
+ E2.forceMove(T1)
+
+/proc/cmp_xy_desc(atom/movable/A,atom/movable/B)
+ if(A.y > B.y)
+ return -1
+ if(A.y < B.y)
+ return 1
+ if(A.x > B.x)
+ return 1
+ if(A.x < B.x)
+ return -1
+ return 0
+
+/obj/effect/sliding_puzzle/proc/elements_in_order()
+ return sortTim(elements,cmp=/proc/cmp_xy_desc)
+
+/obj/effect/sliding_puzzle/proc/get_base_icon()
+ var/icon/I = new('icons/obj/puzzle.dmi')
+ var/list/puzzles = icon_states(I)
+ var/puzzle_state = pick(puzzles)
+ var/icon/P = new('icons/obj/puzzle.dmi',puzzle_state)
+ return P
+
+/obj/effect/sliding_puzzle/proc/setup()
+ //First we slice the 96x96 icon into 32x32 pieces
+ var/list/puzzle_pieces = list() //id -> icon list
+
+ var/width = 3
+ var/height = 3
+ var/list/left_ids = list()
+ var/tile_count = width * height
+
+ //Generate per tile icons
+ for(var/id in 1 to tile_count)
+ var/y = width - round((id - 1) / width)
+ var/x = ((id - 1) % width) + 1
+
+ var/x_start = 1 + (x - 1) * world.icon_size
+ var/x_end = x_start + world.icon_size - 1
+ var/y_start = 1 + ((y - 1) * world.icon_size)
+ var/y_end = y_start + world.icon_size - 1
+
+ var/icon/T = get_base_icon()
+ T.Crop(x_start,y_start,x_end,y_end)
+ puzzle_pieces["[id]"] = T
+ left_ids += id
+
+ //Setup random empty tile
+ empty_tile_id = pick_n_take(left_ids)
+ var/turf/empty_tile_turf = get_turf_for_id(empty_tile_id)
+ empty_tile_turf.PlaceOnTop(floor_type,null,CHANGETURF_INHERIT_AIR)
+ var/mutable_appearance/MA = new(puzzle_pieces["[empty_tile_id]"])
+ MA.layer = empty_tile_turf.layer + 0.1
+ empty_tile_turf.add_overlay(MA)
+
+ elements = list()
+ var/list/empty_spots = left_ids.Copy()
+ for(var/spot_id in empty_spots)
+ var/turf/T = get_turf_for_id(spot_id)
+ T = T.PlaceOnTop(floor_type,null,CHANGETURF_INHERIT_AIR)
+ var/obj/structure/puzzle_element/E = new element_type(T)
+ elements += E
+ var/chosen_id = pick_n_take(left_ids)
+ E.puzzle_icon = puzzle_pieces["[chosen_id]"]
+ E.source = src
+ E.id = chosen_id
+ E.set_puzzle_icon()
+
+ if(!is_solvable())
+ make_solvable()
+
+/obj/structure/puzzle_element
+ name = "mysterious pillar"
+ desc = "puzzling..."
+ icon = 'icons/obj/lavaland/artefacts.dmi'
+ icon_state = "puzzle_pillar"
+ anchored = FALSE
+ density = TRUE
+ var/id = 0
+ var/obj/effect/sliding_puzzle/source
+ var/icon/puzzle_icon
+
+/obj/structure/puzzle_element/Move(nloc, dir)
+ if(!isturf(nloc) || moving_diagonally || get_dist(get_step(src,dir),get_turf(source)) > 1)
+ return 0
+ else
+ return ..()
+
+/obj/structure/puzzle_element/proc/set_puzzle_icon()
+ cut_overlays()
+ if(puzzle_icon)
+ //Need to scale it down a bit to fit the static border
+ var/icon/C = new(puzzle_icon)
+ C.Scale(19,19)
+ var/mutable_appearance/puzzle_small = new(C)
+ puzzle_small.layer = layer + 0.1
+ puzzle_small.pixel_x = 7
+ puzzle_small.pixel_y = 7
+ add_overlay(puzzle_small)
+
+/obj/structure/puzzle_element/Destroy()
+ if(source)
+ source.elements -= src
+ source.validate()
+ return ..()
+
+//Set the full image on the turf and delete yourself
+/obj/structure/puzzle_element/proc/collapse()
+ var/turf/T = get_turf(src)
+ var/mutable_appearance/MA = new(puzzle_icon)
+ MA.layer = T.layer + 0.1
+ T.add_overlay(MA)
+ //Some basic shaking animation
+ for(var/i in 1 to COLLAPSE_DURATION)
+ animate(src, pixel_x=rand(-5,5), pixel_y=rand(-2,2), time=1)
+ QDEL_IN(src,COLLAPSE_DURATION)
+
+/obj/structure/puzzle_element/Moved()
+ . = ..()
+ source.validate()
+
+//Admin abuse version so you can pick the icon before it sets up
+/obj/effect/sliding_puzzle/admin
+ auto_setup = FALSE
+ var/icon/puzzle_icon
+ var/puzzle_state
+
+/obj/effect/sliding_puzzle/admin/get_base_icon()
+ var/icon/I = new(puzzle_icon,puzzle_state)
+ return I
+
+//Ruin version
+/obj/effect/sliding_puzzle/lavaland
+ reward_type = /obj/structure/closet/crate/necropolis/puzzle
+
+/obj/effect/sliding_puzzle/lavaland/dispense_reward()
+ if(prob(25))
+ //If it's not roaming somewhere else already.
+ var/mob/living/simple_animal/hostile/megafauna/bubblegum/B = locate() in GLOB.mob_list
+ if(!B)
+ reward_type = /mob/living/simple_animal/hostile/megafauna/bubblegum
+ return ..()
+
+//Prison cube version
+/obj/effect/sliding_puzzle/prison
+ auto_setup = FALSE //This will be done by cube proc
+ var/mob/living/prisoner
+ element_type = /obj/structure/puzzle_element/prison
+
+/obj/effect/sliding_puzzle/prison/get_base_icon()
+ if(!prisoner)
+ CRASH("Prison cube without prisoner")
+ prisoner.setDir(SOUTH)
+ var/icon/I = getFlatIcon(prisoner)
+ I.Scale(96,96)
+ return I
+
+/obj/effect/sliding_puzzle/prison/Destroy()
+ if(prisoner)
+ to_chat(prisoner,"With the cube broken by force, you can feel your body falling apart.")
+ prisoner.death()
+ qdel(prisoner)
+ . = ..()
+
+/obj/effect/sliding_puzzle/prison/dispense_reward()
+ prisoner.forceMove(get_turf(src))
+ prisoner.notransform = FALSE
+ prisoner = null
+
+//Some armor so it's harder to kill someone by mistake. EDITED - Hugboxed
+/obj/structure/puzzle_element/prison
+ resistance_flags = INDESTRUCTIBLE | ACID_PROOF | FIRE_PROOF | LAVA_PROOF
+
+/obj/structure/puzzle_element/prison/relaymove(mob/user)
+ return
+
+/obj/item/prisoncube
+ name = "Prison Cube"
+ desc = "Dusty cube with humanoid imprint on it."
+ icon = 'icons/obj/lavaland/artefacts.dmi'
+ icon_state = "prison_cube"
+
+/obj/item/prisoncube/afterattack(atom/target, mob/user, proximity_flag, click_parameters)
+ . = ..()
+ if(!proximity_flag || !isliving(target))
+ return
+ var/mob/living/victim = target
+ var/mob/living/carbon/carbon_victim = victim
+ //Handcuffed or unconcious
+ if(istype(carbon_victim) && carbon_victim.handcuffed || victim.stat != CONSCIOUS)
+ if(!puzzle_imprison(target))
+ to_chat(user,"[src] does nothing.")
+ return
+ to_chat(user,"You trap [victim] in the prison cube!")
+ qdel(src)
+ else
+ to_chat(user,"[src] only accepts restrained or unconcious prisoners.")
+
+/proc/puzzle_imprison(mob/living/prisoner)
+ var/turf/T = get_turf(prisoner)
+ var/obj/effect/sliding_puzzle/prison/cube = new(T)
+ if(!cube.check_setup_location())
+ qdel(cube)
+ return FALSE
+
+ //First grab the prisoner and move them temporarily into the generator so they won't get thrown around.
+ prisoner.notransform = TRUE
+ prisoner.forceMove(cube)
+ to_chat(prisoner,"You're trapped by the prison cube! You will remain trapped until someone solves it.")
+
+ //Clear the area from objects (and cube user)
+ var/list/things_to_throw = list()
+ for(var/atom/movable/AM in range(1,T))
+ if(!AM.anchored)
+ things_to_throw += AM
+
+ for(var/atom/movable/AM in things_to_throw)
+ var/throwtarget = get_edge_target_turf(T, get_dir(T, get_step_away(AM, T)))
+ AM.throw_at(throwtarget, 2, 3)
+
+ //Create puzzle itself
+ cube.prisoner = prisoner
+ cube.setup()
+
+ //Move them into random block
+ var/obj/structure/puzzle_element/E = pick(cube.elements)
+ prisoner.forceMove(E)
+ return TRUE
diff --git a/icons/obj/lavaland/artefacts.dmi b/icons/obj/lavaland/artefacts.dmi
index 0530f7bb3b..7f11ba29d4 100644
Binary files a/icons/obj/lavaland/artefacts.dmi and b/icons/obj/lavaland/artefacts.dmi differ
diff --git a/icons/obj/puzzle.dmi b/icons/obj/puzzle.dmi
new file mode 100644
index 0000000000..f623142beb
Binary files /dev/null and b/icons/obj/puzzle.dmi differ
diff --git a/tgstation.dme b/tgstation.dme
index 5996f01dee..5f2fd794b0 100755
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -2468,6 +2468,7 @@
#include "code\modules\research\xenobiology\crossbreeding\stabilized.dm"
#include "code\modules\ruins\lavaland_ruin_code.dm"
#include "code\modules\ruins\lavalandruin_code\biodome_clown_planet.dm"
+#include "code\modules\ruins\lavalandruin_code\puzzle.dm"
#include "code\modules\ruins\lavalandruin_code\sloth.dm"
#include "code\modules\ruins\lavalandruin_code\surface.dm"
#include "code\modules\ruins\objects_and_mobs\ash_walker_den.dm"