From 1012403504a39a2575f8ca47fd51ab49242a396d Mon Sep 17 00:00:00 2001 From: Kashargul <144968721+Kashargul@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:39:19 +0200 Subject: [PATCH] spawn verb port (#19360) --- code/_helpers/abstract_types.dm | 70 +++ code/_helpers/type_processing.dm | 86 ++++ code/modules/admin/admin.dm | 49 ++- code/modules/admin/holder2.dm | 1 + code/modules/admin/spawn_menu.dm | 92 ++++ tgui/packages/tgui/interfaces/SpawnSearch.tsx | 413 ++++++++++++++++++ vorestation.dme | 3 + 7 files changed, 694 insertions(+), 20 deletions(-) create mode 100644 code/_helpers/abstract_types.dm create mode 100644 code/_helpers/type_processing.dm create mode 100644 code/modules/admin/spawn_menu.dm create mode 100644 tgui/packages/tgui/interfaces/SpawnSearch.tsx diff --git a/code/_helpers/abstract_types.dm b/code/_helpers/abstract_types.dm new file mode 100644 index 0000000000..e2013ad4bb --- /dev/null +++ b/code/_helpers/abstract_types.dm @@ -0,0 +1,70 @@ +/// Returns a list of all abstract typepaths for all datums +/proc/get_abstract_types() + var/static/list/abstracts + if(abstracts) + return abstracts + abstracts = list() + for(var/datum/sometype as anything in subtypesof(/datum)) + if(sometype == sometype::abstract_type) + abstracts[sometype::abstract_type] = TRUE + + return abstracts + +// The time complexity of these two procs is really bad, sitting at O(n * m). +// And yet, the overhead is ridiculously small for some reason. We never figured out why. +// So doing list subtraction instead of filtering is faster in all cases, at least for now. +// If we ever breach like 3000-5000 abstract types, that will likely change. +// As of right now we're at 272 or so, with subtraction being ~10x faster. +// Curse this language, for you shall live in hell alongside me. + +/// Like subtypesof, but automatically excludes abstract typepaths +/proc/valid_subtypesof(datum/sometype) + return subtypesof(sometype) - get_abstract_types() + +/// Like typesof, but automatically excludes abstract typepaths +/proc/valid_typesof(datum/sometype) + return typesof(sometype) - get_abstract_types() + +/// Returns a list of concrete types under abstract sub-branches of `root` +/proc/get_abstract_branch_descendants(datum/root) + var/list/abstracts = get_abstract_types() + var/list/to_remove = list() + var/list/seen_abstract_parents = list() + + for (var/datum/sometype as anything in subtypesof(root)) + if (abstracts[sometype]) + continue + + var/datum/parenttype = sometype.parent_type + while (parenttype && parenttype != root) + if (seen_abstract_parents[parenttype] || (abstracts[parenttype])) + seen_abstract_parents[parenttype] = TRUE + to_remove += sometype + break + parenttype = parenttype.parent_type + + return to_remove + +/// Like valid_subtypesof(), but excludes concrete descendants of abstract sub-branches +/proc/valid_direct_subtypesof(datum/root) + var/list/result = subtypesof(root) + + // Remove all abstract types + result -= get_abstract_types() + + // Remove concrete types under abstract sub-branches + result -= get_abstract_branch_descendants(root) + + return result + +/// Like valid_typesof(), but excludes concrete descendants of abstract sub-branches +/proc/valid_direct_typesof(datum/root) + var/list/result = typesof(root) + + // Remove all abstract types + result -= get_abstract_types() + + // Remove concrete types under abstract sub-branches + result -= get_abstract_branch_descendants(root) + + return result diff --git a/code/_helpers/type_processing.dm b/code/_helpers/type_processing.dm new file mode 100644 index 0000000000..e355db1b05 --- /dev/null +++ b/code/_helpers/type_processing.dm @@ -0,0 +1,86 @@ +// Longer paths should come after shorter ones +GLOBAL_LIST_INIT(fancy_type_replacements, list( + /datum = "DATUM", + /area = "AREA", + /atom/movable = "MOVABLE", + /obj = "OBJ", + /turf = "TURF", + /turf/simulated = "SIMULATED", + /turf/simulated/open = "OPEN", + + /mob = "MOB", + /mob/living = "LIVING", + /mob/living/carbon = "CARBON", + /mob/living/carbon/human = "HUMANOID", + /mob/living/simple_mob = "SIMPLE", + /mob/living/silicon = "SILICON", + /mob/living/silicon/robot = "CYBORG", + /mob/living/silicon/robot/drone = "DRONE", + /mob/living/silicon/pai = "PAI", + /mob/living/silicon/ai = "AI", + + /obj/item = "ITEM", + /obj/item/mecha_parts/mecha_equipment = "MECHA_EQUIP", + /obj/item/mecha_parts/mecha_equipment/weapon = "MECHA_WEAPON", + /obj/item/organ = "ORGAN", + /obj/item/rig = "RIGSUIT", + /obj/item/rig_module = "RIGSUIT_MOD", + /obj/item/ammo_magazine = "AMMO", + /obj/item/ammo_magazine/ammo_box = "AMMOBOX", + /obj/item/gun = "GUN", + /obj/item/gun/magic = "GUN_MAGIC", + /obj/item/gun/energy = "GUN_ENERGY", + /obj/item/gun/energy/laser = "GUN_LASER", + /obj/item/gun/projectile = "GUN_BALLISTIC", + /obj/item/gun/projectile/automatic = "GUN_AUTOMATIC", + /obj/item/gun/projectile/revolver = "GUN_REVOLVER", + /obj/item/gun/projectile/heavysniper = "GUN_RIFLE", + /obj/item/gun/projectile/shotgun = "GUN_SHOTGUN", + /obj/item/stack/material = "SHEET", + /obj/item/ore = "ORE", + /obj/item/aiModule = "AI_LAW_MODULE", + /obj/item/circuitboard = "CIRCUITBOARD", + /obj/item/circuitboard/machine = "MACHINE_BOARD", + /obj/item/reagent_containers = "REAGENT_CONTAINERS", + /obj/item/reagent_containers/pill = "PILL", + /obj/item/reagent_containers/hypospray/autoinjector = "MEDIPEN", + /obj/item/reagent_containers/food/drinks = "DRINK", + /obj/item/reagent_containers/food/snacks = "FOOD", + /obj/item/organ/external = "BODYPART", + /obj/effect/decal/cleanable = "CLEANABLE", + /obj/item/radio/headset = "HEADSET", + /obj/item/clothing = "CLOTHING", + /obj/item/clothing/accessory = "ACCESSORY", + /obj/item/clothing/mask/gas = "GASMASK", + /obj/item/clothing/mask = "MASK", + /obj/item/clothing/gloves = "GLOVES", + /obj/item/clothing/shoes = "SHOES", + /obj/item/clothing/under = "JUMPSUIT", + /obj/item/clothing/suit/armor = "ARMOR", + /obj/item/clothing/suit = "SUIT", + /obj/item/clothing/head/helmet = "HELMET", + /obj/item/clothing/head = "HEAD", + /obj/item/storage/backpack = "BACKPACK", + /obj/item/storage/belt = "BELT", + /obj/item/storage/pill_bottle = "PILL_BOTTLE", + /obj/item/book/manual = "MANUAL", + + /obj/structure = "STRUCTURE", + /obj/structure/closet = "CLOSET", + /obj/structure/closet/crate = "CRATE", + /obj/structure/closet/crate/secure = "LOCKED_CRATE", + /obj/structure/closet/secure_closet = "LOCKED_CLOSET", + + /obj/machinery = "MACHINERY", + /obj/machinery/atmospherics = "ATMOS_MECH", + /obj/machinery/portable_atmospherics = "PORT_ATMOS", + /obj/machinery/door = "DOOR", + /obj/machinery/door/airlock = "AIRLOCK", + /obj/machinery/rnd/production = "RND_FABRICATOR", + /obj/machinery/computer = "COMPUTER", + /obj/machinery/embedded_controller/radio/docking_port_multi = "DOCKING_CONTROLLER", + /obj/machinery/vending = "VENDING", + /obj/machinery/vending/wardrobe = "JOBDROBE", + /obj/effect = "EFFECT", + /obj/item/projectile = "PROJECTILE", +)) diff --git a/code/modules/admin/admin.dm b/code/modules/admin/admin.dm index 964488c356..1eab3e10b6 100644 --- a/code/modules/admin/admin.dm +++ b/code/modules/admin/admin.dm @@ -1024,33 +1024,42 @@ ADMIN_VERB(spawn_plant, R_SPAWN, "Spawn Plant", "Spawn a spreading plant effect. new /obj/effect/plant(get_turf(user_mob), SSplants.seeds[seedtype]) log_admin("[key_name(user)] spawned [seedtype] vines at ([user_mob.x],[user_mob.y],[user_mob.z])") -ADMIN_VERB(spawn_atom, R_SPAWN, "Spawn", "(atom path) Spawn an atom", ADMIN_CATEGORY_DEBUG_GAME, object as text) - var/list/types = typesof(/atom) - var/list/matches = new() +ADMIN_VERB(spawn_atom, R_SPAWN, "Spawn", "(atom path) Spawn an atom", ADMIN_CATEGORY_DEBUG_GAME, object as text|null) + var/static/list/atom_types + if(isnull(atom_types)) + atom_types = subtypesof(/atom) - for(var/path in types) - if(findtext("[path]", object)) - matches += path + var/chosen_path = null + var/list/preparsed = null + if(object) + preparsed = splittext(object, ":") + var/list/matches = filter_fancy_list(atom_types, preparsed[1]) + if(length(matches) == 1) + chosen_path = matches[1] - if(matches.len==0) + if(!chosen_path) + var/datum/spawn_menu/menu = user.holder.spawn_menu + if(!menu) + menu = new() + user.holder.spawn_menu = menu + menu.init_value = object + menu.tgui_interact(user.mob) + feedback_add_details("admin_verb","SA") return - var/chosen - if(matches.len==1) - chosen = matches[1] - else - chosen = tgui_input_list(user, "Select an atom type", "Spawn Atom", matches) - if(!chosen) - return + var/amount = 1 + if(length(preparsed) > 1) + amount = clamp(text2num(preparsed[2]), 1, ADMIN_SPAWN_CAP) - var/mob/user_mob = user.mob - if(ispath(chosen,/turf)) - var/turf/T = get_turf(user_mob.loc) - T.ChangeTurf(chosen) + var/turf/target_turf = get_turf(user.mob) + if(ispath(chosen_path, /turf)) + target_turf.ChangeTurf(chosen_path) else - new chosen(user_mob.loc) + for(var/i in 1 to amount) + var/atom/spawned = new chosen_path(target_turf) + spawned.flags |= ADMIN_SPAWNED - log_and_message_admins("spawned [chosen] at ([user_mob.x],[user_mob.y],[user_mob.z])") + log_and_message_admins("spawned [amount] x [chosen_path] at [AREACOORD(user.mob)]", user) feedback_add_details("admin_verb","SA") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! ADMIN_VERB(show_traitor_panel, R_ADMIN|R_FUN|R_EVENT, "Show Traitor Panel", "Edit mobs's memory and role", ADMIN_CATEGORY_EVENTS, mob/M in GLOB.mob_list) diff --git a/code/modules/admin/holder2.dm b/code/modules/admin/holder2.dm index 17a8bdc9e9..d4a3014c24 100644 --- a/code/modules/admin/holder2.dm +++ b/code/modules/admin/holder2.dm @@ -32,6 +32,7 @@ GLOBAL_PROTECT(href_token) var/datum/filter_editor/filteriffic var/datum/particle_editor/particle_test var/datum/whitelist_editor/whitelist_editor + var/datum/spawn_menu/spawn_menu var/datum/spawnpanel/spawn_panel /// A lazylist of tagged datums, for quick reference with the View Tags verb diff --git a/code/modules/admin/spawn_menu.dm b/code/modules/admin/spawn_menu.dm new file mode 100644 index 0000000000..c838a95b2a --- /dev/null +++ b/code/modules/admin/spawn_menu.dm @@ -0,0 +1,92 @@ +/datum/spawn_menu + /// Does the menu default to a regex prefix? + var/regex_search = FALSE + /// Does the search include atom names? + var/name_search = TRUE + /// Should we display full typepaths or the condensed versions? + var/fancy_types = TRUE + /// Should abstract types be included in the search? + var/include_abstracts = FALSE + /// Initial search value from the latest command + var/init_value = null + +/datum/spawn_menu/tgui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if (!ui) + ui = new(user, src, "SpawnSearch") + ui.open() + +/datum/spawn_menu/tgui_state(mob/user) + return ADMIN_STATE(R_SPAWN) + +/datum/spawn_menu/tgui_act(action, params, datum/tgui/ui) + if (..() || !check_rights_for(ui.user.client, R_SPAWN)) + return FALSE + + switch (action) + if ("setRegexSearch") + regex_search = params["regexSearch"] + return TRUE + + if ("setNameSearch") + name_search = params["searchNames"] + return TRUE + + if ("setFancyTypes") + fancy_types = params["fancyTypes"] + return TRUE + + if ("setIncludeAbstracts") + include_abstracts = params["includeAbstracts"] + return TRUE + + if ("spawn") + var/path = text2path(params["type"]) + if (!path) + return TRUE + var/amount = clamp(text2num(params["amount"]) || 1, 1, ADMIN_SPAWN_CAP) + var/turf/target_turf = get_turf(ui.user) + if(ispath(path, /turf)) + target_turf.ChangeTurf(path) + else + for(var/i in 1 to amount) + var/atom/spawned = new path(target_turf) + spawned.flags |= ADMIN_SPAWNED + + log_admin("[key_name(ui.user)] spawned [amount] x [path] at [AREACOORD(ui.user)]") + SStgui.close_uis(src) + return TRUE + + if ("cancel") + SStgui.close_uis(src) + return TRUE + +/datum/spawn_menu/tgui_data(mob/user) + var/list/data = list() + data["initValue"] = init_value + data["searchNames"] = name_search + data["regexSearch"] = regex_search + data["fancyTypes"] = fancy_types + data["includeAbstracts"] = include_abstracts + return data + +/datum/spawn_menu/ui_assets(mob/user) + return list( + get_asset_datum(/datum/asset/json/spawn_menu), + ) + +/datum/asset/json/spawn_menu + name = "spawn_menu_atom_data" + +/datum/asset/json/spawn_menu/generate() + var/list/data = list() + var/static/list/types_list + if (isnull(types_list)) + var/list/local_types = list() + for (var/atom/atom_type as anything in subtypesof(/atom)) + local_types[atom_type] = atom_type::name || "" + types_list = local_types + data["types"] = types_list + data["abstractTypes"] = get_abstract_types() + data["fancyTypes"] = GLOB.fancy_type_replacements + return data diff --git a/tgui/packages/tgui/interfaces/SpawnSearch.tsx b/tgui/packages/tgui/interfaces/SpawnSearch.tsx new file mode 100644 index 0000000000..5dbe4cb7f4 --- /dev/null +++ b/tgui/packages/tgui/interfaces/SpawnSearch.tsx @@ -0,0 +1,413 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Autofocus, + Box, + Button, + Icon, + Input, + Section, + Stack, + VirtualList, +} from 'tgui-core/components'; +import { fetchRetry } from 'tgui-core/http'; +import { + KEY_DOWN, + KEY_ENTER, + KEY_ESCAPE, + KEY_F, + KEY_N, + KEY_R, + KEY_UP, +} from 'tgui-core/keycodes'; +import type { BooleanLike } from 'tgui-core/react'; +import { resolveAsset } from '../assets'; +import { useBackend } from '../backend'; +import { Window } from '../layouts'; +import { logger } from '../logging'; + +type SpawnSearchData = { + initValue: string | null; + searchNames: BooleanLike; + regexSearch: BooleanLike; + fancyTypes: BooleanLike; + includeAbstracts: BooleanLike; +}; + +type SpawnAtomData = { + // Type -> Name + types: Record; + abstractTypes: Record; + fancyTypes: Record; +}; + +type AtomPathData = { + types: AtomTypeData[]; + abstractTypes: Record; + fancyTypes: Record; +}; + +type AtomTypeData = { + typepath: string; + name: string; +}; + +const initialAtomPathData: AtomPathData = { + types: [], + abstractTypes: {}, + fancyTypes: {}, +}; + +export function SpawnSearch() { + const { act, data } = useBackend(); + const { + fancyTypes, + includeAbstracts, + initValue = '', + regexSearch, + searchNames, + } = data; + + const [atomData, setAtomData] = useState(initialAtomPathData); + const [selected, setSelected] = useState(0); + const [query, setQuery] = useState(initValue || ''); + const [searchBarVisible, setSearchBarVisible] = useState(true); + + const { invalidInput, spawnAmount } = useMemo(() => { + let invalidInput = false; + let spawnAmount = 1; + + const possibleAmountData = query.split(':'); + const amountElement = possibleAmountData[possibleAmountData.length - 1]; + + if (possibleAmountData.length > 1 && !Number.isNaN(+amountElement)) { + if (+amountElement <= 0) { + invalidInput = true; + } else { + spawnAmount = +amountElement; + } + } + + if (regexSearch) { + try { + new RegExp(query); + } catch (error) { + invalidInput = true; + } + } + + return { invalidInput, spawnAmount }; + }, [query, regexSearch]); + + const filteredItems = useMemo(() => { + let filterQuery = query; + + // Extract amount suffix (e.g., ":5" from "query:5") + const amountMatch = query.match(/^(.+):(\d+)$/); + if (amountMatch) { + const amount = +amountMatch[2]; + if (amount <= 0) { + return []; + } + filterQuery = amountMatch[1].trimEnd(); + } + + if (filterQuery.length === 0) return []; + + if (regexSearch) { + try { + const queryRegex = new RegExp(filterQuery); + return atomData.types.filter( + (type: AtomTypeData) => + queryRegex.test(type.typepath) || + (searchNames && queryRegex.test(type.name)), + ); + } catch (error) { + return []; + } + } + + const finalizer = filterQuery.slice(filterQuery.length - 1); + if (finalizer === '*' || finalizer === '!') + filterQuery = filterQuery.slice(0, filterQuery.length - 1); + filterQuery = filterQuery.toLowerCase(); + + let searchLambda = (x: string) => x.toLowerCase().includes(filterQuery); + if (finalizer === '!') { + searchLambda = (x: string) => + x.toLowerCase().includes(filterQuery) && + x.toLowerCase().lastIndexOf(filterQuery) === + x.length - filterQuery.length; + } else if (finalizer === '*') { + searchLambda = (x: string) => + x.toLowerCase().includes(filterQuery) && + !x.slice(x.toLowerCase().lastIndexOf(filterQuery)).includes('/'); + } + + return atomData.types.filter( + (type: AtomTypeData) => + (searchLambda(type.typepath) || + (searchNames && searchLambda(type.name))) && + (includeAbstracts || !atomData.abstractTypes[type.typepath]), + ); + }, [query, atomData, regexSearch, includeAbstracts, searchNames]); + + useEffect(() => { + fetchRetry(resolveAsset('spawn_menu_atom_data.json')) + .then((response) => response.json()) + .then((data: SpawnAtomData) => { + setAtomData({ + types: Object.keys(data.types).map((x: string) => ({ + typepath: x, + name: data.types[x], + })), + abstractTypes: data.abstractTypes, + fancyTypes: data.fancyTypes, + }); + }) + .catch((error) => { + logger.log( + 'Failed to fetch spawn_menu_atom_data.json', + JSON.stringify(error), + ); + }); + }, []); + + // User presses up or down on keyboard + // Simulates clicking an item + function handleArrowKey(key: number): void { + if (!filteredItems.length) return; + + const len = filteredItems.length - 1; + + if (key === KEY_DOWN) { + const next = selected >= len ? 0 : selected + 1; + setSelected(next); + document?.getElementById(next.toString())?.scrollIntoView(); + } else if (key === KEY_UP) { + const prev = selected <= 0 ? len : selected - 1; + setSelected(prev); + document?.getElementById(prev.toString())?.scrollIntoView(); + } + } + + function handleKeyDown(event: React.KeyboardEvent): void { + const keyCode = window.event ? event.which : event.keyCode; + if (keyCode === KEY_DOWN || keyCode === KEY_UP) { + event.preventDefault(); + handleArrowKey(keyCode); + } + + if (keyCode === KEY_ENTER) { + event.preventDefault(); + handleSelect(filteredItems[selected]); + } + + if (keyCode === KEY_ESCAPE) { + event.preventDefault(); + act('cancel'); + } + + if (keyCode === KEY_R && event.altKey) { + act('setRegexSearch', { regexSearch: !regexSearch }); + } + + if (keyCode === KEY_N && event.altKey) { + act('setNameSearch', { searchNames: !searchNames }); + } + + if (keyCode === KEY_F && event.altKey) { + act('setFancyTypes', { fancyTypes: !fancyTypes }); + } + } + + function handleSelect(selection?: AtomTypeData): void { + if (!selection) return; + + act('spawn', { type: selection.typepath, amount: spawnAmount }); + } + + function handleSearch(newQuery: string): void { + if (newQuery === query) return; + + setQuery(newQuery); + setSelected(0); + document!.getElementById('0')?.scrollIntoView(); + } + + // Grabs the cursor when no search bar is visible. + if (!searchBarVisible) { + setTimeout(() => document!.getElementById(selected.toString())?.focus(), 1); + } + + const modeText = regexSearch ? 'RegEx Mode' : 'Standard Mode'; + + return ( + + + ))} + + + + + {!!searchBarVisible && ( + + + + + + handleSelect(filteredItems[selected])} + onChange={handleSearch} + placeholder="Search..." + value={query} + style={{ + borderColor: invalidInput ? 'red' : undefined, + }} + /> + + + )} + + + + + ); +} + +type AtomSpanProps = { + atomData: AtomPathData; + item: AtomTypeData; +}; + +function ListItem(props: AtomSpanProps) { + const { atomData, item } = props; + + const { data } = useBackend(); + const { fancyTypes } = data; + + const matchingKey = fancyTypes + ? Object.keys(atomData.fancyTypes).findLast( + (x: string) => item.typepath.indexOf(x) === 0, + ) + : undefined; + + const displayPath = matchingKey + ? item.typepath.replace(matchingKey, atomData.fancyTypes[matchingKey]) + : item.typepath; + + return ( + <> + + {displayPath} + + + {item.name} + + {!!atomData.abstractTypes[item.typepath] && ( + + Abstract + + )} + + ); +} diff --git a/vorestation.dme b/vorestation.dme index 4b49945373..dcb23422ce 100644 --- a/vorestation.dme +++ b/vorestation.dme @@ -379,6 +379,7 @@ #include "code\_global_vars\traits\_traits.dm" #include "code\_helpers\_global_objects.dm" #include "code\_helpers\_lists.dm" +#include "code\_helpers\abstract_types.dm" #include "code\_helpers\admin.dm" #include "code\_helpers\announcements.dm" #include "code\_helpers\atmospherics.dm" @@ -419,6 +420,7 @@ #include "code\_helpers\traits.dm" #include "code\_helpers\turfs.dm" #include "code\_helpers\type2type.dm" +#include "code\_helpers\type_processing.dm" #include "code\_helpers\typelists.dm" #include "code\_helpers\unsorted.dm" #include "code\_helpers\unsorted_vr.dm" @@ -2195,6 +2197,7 @@ #include "code\modules\admin\player_notes.dm" #include "code\modules\admin\player_panel.dm" #include "code\modules\admin\random_quest.dm" +#include "code\modules\admin\spawn_menu.dm" #include "code\modules\admin\tag.dm" #include "code\modules\admin\topic.dm" #include "code\modules\admin\ToRban.dm"