diff --git a/code/modules/ai/ai_holder.dm b/code/modules/ai/ai_holder.dm
index 7770d4476a..5c351ec3fb 100644
--- a/code/modules/ai/ai_holder.dm
+++ b/code/modules/ai/ai_holder.dm
@@ -46,6 +46,7 @@
// mob to stay still (e.g. delayed attacking). If you need the mob to be inactive for an extended period of time,
// consider sleeping the AI instead.
var/process_flags = 0 // Where we're processing, see flag defines.
+ var/list/snapshot = null // A list used in mass-editing of AI datums, holding a snapshot of the 'before' state
var/list/static/fastprocess_stances = list(
STANCE_ALERT,
STANCE_APPROACH,
@@ -69,6 +70,143 @@
hostile = TRUE
retaliate = TRUE
+/datum/ai_holder/vv_get_dropdown()
+ . = ..()
+ if(snapshot)
+ VV_DROPDOWN_OPTION("mass_edit_finish", "End Mass Edit")
+ else
+ VV_DROPDOWN_OPTION("mass_edit_start", "Begin Mass Edit")
+
+/datum/ai_holder/vv_do_topic(list/href_list)
+ . = ..()
+ IF_VV_OPTION("mass_edit_start")
+ if(!check_rights(R_ADMIN))
+ return
+ if(snapshot)
+ to_chat(usr, "Someone (or you) may have started a mass edit on this AI datum already. Refresh the VV window to get the option to end the mass edit instead.")
+ href_list["datumrefresh"] = "\ref[src]"
+ return
+ snapshot = vars.Copy() //'vars' appears to be special in that vars.Copy produces a flat list of keys with no values. It seems that 'vars[key]' is handled somewhere in the byond engine differently than normal lists.
+
+ // Remove things that may change without admin input during editing. If they aren't present in the snapshot, they aren't eligible for the diff.
+ snapshot -= list(
+ //other
+ "datum_flags",
+ "active_timers",
+ //main
+ "snapshot",
+ "stance",
+ "process_flags",
+ //targeting
+ "target",
+ "preferred_target",
+ "target_last_seen_turf",
+ "attackers",
+ "lose_target_time",
+ //pathfinding
+ "path",
+ "obstacles",
+ "failed_steps",
+ //movement
+ "destination",
+ "failed_breakthroughs",
+ "faction_friends",
+ //communication
+ "threatening",
+ "last_conflict_time",
+ "last_threaten_time",
+ "last_target_time"
+ )
+
+ for(var/key in snapshot)
+ var/thing = vars[key]
+ if(islist(thing)) // This is just too dangerous to do. Maybe in the future we can have a whitelist of lists that are fine to edit.
+ thing = null
+ snapshot[key] = thing
+
+ VARSET_IN(src, snapshot, null, 2 MINUTES) // Safety
+ to_chat(usr, "Variable snapshot saved. Begin editing the datum, and end the mass edit from the dropdown menu within 2 minutes. Note that editing the contents of lists is not supported.")
+ href_list["datumrefresh"] = "\ref[src]"
+
+ IF_VV_OPTION("mass_edit_finish")
+ if(!check_rights(R_ADMIN))
+ return
+
+ var/list/before = snapshot //This PROBABLY works, right?
+ snapshot = null
+ var/list/after = vars.Copy() //'vars' appears to be special in that vars.Copy produces a flat list of keys with no values. It seems that 'vars[key]' is handled somewhere in the byond engine differently than normal lists.
+ after &= before //Ignore any dangerous keys. Not a big deal, because just 'before' being safe is enough to exclude the dangerous ones from the diff, but why not
+ for(var/key in after)
+ var/thing = vars[key]
+ if(islist(thing)) // This is just too dangerous to do. Maybe in the future we can have a whitelist of lists that are fine to edit.
+ thing = null
+ after[key] = thing
+
+ var/list/diff = list()
+ for(var/key in before)
+ if(before[key] == after[key])
+ continue
+ diff += key
+
+ if(!diff.len)
+ to_chat(usr, "You don't appear to have changed anything on the AI datum you were editing.")
+ href_list["datumrefresh"] = "\ref[src]"
+ else
+ var/message = "These differences were detected in your varedit. If you notice any that you didn't change, please redo your edit:
"
+ for(var/key in diff)
+ message += "- [key]: [before[key]] => [after[key]]
"
+ message += ""
+ to_chat(usr,message)
+
+ var/original_type = holder.type
+ var/list/levels_working = GetConnectedZlevels(holder.z)
+
+ var/list/types = list()
+ var/list/split = splittext("[original_type]", "/")
+ split -= "" // The first / creates an empty string at the start as it tries to split on [nothing]/mob/living etc
+ var/typestring = "/"
+ for(var/el in split)
+ typestring += el
+
+ types += text2path(typestring)
+ typestring += "/"
+
+ var/list/searching = living_mob_list // Started/seeded with this
+ var/list/choices = list()
+ for(var/typechoice in types)
+ var/list/found = list()
+ for(var/mob in searching) // Isnt't there a helper for this, maybe? I forget.
+ var/atom/M = mob
+ if(!(M.z in levels_working))
+ continue
+ if(!istype(mob,typechoice))
+ continue
+ found += M
+ choices["[typechoice] ([found.len])"] = found // Prettified name for the user input below)
+ searching = found // Now we only search the list we just made, because of the order of our types list, each subsequent list will be a subset of the one we just finished
+
+ var/choice = input(usr,"Based on your AI holder's mob location, we'll edit mobs on Z [levels_working.Join(",")]. What types do you want to alter?") as null|anything in choices
+ if(!choice)
+ href_list["datumrefresh"] = "\ref[src]"
+ return
+ var/list/selected = choices[choice]
+ for(var/mob in selected)
+ var/mob/living/L = mob
+ if(!istype(L))
+ to_chat(usr,"Skipping incompatible mob: [L] [ADMIN_COORDJMP(L)]")
+ continue
+ if(!L.ai_holder)
+ to_chat(usr,"Skipping due to no AI: [L] [ADMIN_COORDJMP(L)]")
+ continue
+ for(var/newvar in diff)
+ if(newvar in L.ai_holder.vars)
+ L.ai_holder.vars[newvar] = after[newvar]
+ else
+ to_chat(usr,"Skipping unavailable var '[newvar]' on: [L] [ADMIN_COORDJMP(L)]")
+
+ to_chat(usr,"Mass AI edit done.")
+ href_list["datumrefresh"] = "\ref[src]"
+
/datum/ai_holder/New(var/new_holder)
ASSERT(new_holder)
holder = new_holder