[MIRROR] spawn verb port [No GBP] (#12618)

Co-authored-by: Kashargul <144968721+Kashargul@users.noreply.github.com>
This commit is contained in:
CHOMPStation2StaffMirrorBot
2026-03-31 09:30:34 -04:00
committed by GitHub
co-authored by Kashargul
parent 060ae4e7fa
commit 14ff5d9ebc
7 changed files with 694 additions and 20 deletions
+70
View File
@@ -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
+86
View File
@@ -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",
))
+29 -20
View File
@@ -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)
+1
View File
@@ -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
+92
View File
@@ -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
@@ -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<string, string>;
abstractTypes: Record<string, boolean>;
fancyTypes: Record<string, string>;
};
type AtomPathData = {
types: AtomTypeData[];
abstractTypes: Record<string, boolean>;
fancyTypes: Record<string, string>;
};
type AtomTypeData = {
typepath: string;
name: string;
};
const initialAtomPathData: AtomPathData = {
types: [],
abstractTypes: {},
fancyTypes: {},
};
export function SpawnSearch() {
const { act, data } = useBackend<SpawnSearchData>();
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<HTMLDivElement>): 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 (
<Window
title="Spawn Atom"
width={400}
height={500}
buttons={
<>
<Button
icon="font"
selected={includeAbstracts}
tooltip="Include Abstract Types"
onClick={() =>
act('setIncludeAbstracts', {
includeAbstracts: !includeAbstracts,
})
}
/>
<Button
icon="file-signature"
selected={searchNames}
tooltip="Name Search"
onClick={() => act('setNameSearch', { searchNames: !searchNames })}
/>
<Button
icon="wand-magic-sparkles"
selected={fancyTypes}
tooltip="Fancy Type Display"
onClick={() => act('setFancyTypes', { fancyTypes: !fancyTypes })}
/>
</>
}
>
<Window.Content onKeyDown={handleKeyDown}>
<Stack fill vertical>
<Stack.Item grow>
<Section fill scrollable>
<Autofocus />
<VirtualList>
{filteredItems.map((item, index) => (
<Button
className="candystripe"
color="transparent"
fluid
id={index.toString()}
key={index}
onClick={() => {
if (index !== selected) setSelected(index);
}}
onDoubleClick={() => handleSelect(item)}
onKeyDown={(event) => {
if (/^[a-z]$/i.test(event.key)) {
event.preventDefault();
setSearchBarVisible(false);
setTimeout(() => {
setSearchBarVisible(true);
}, 1);
}
}}
selected={index === selected}
style={{
animation: 'none',
transition: 'none',
}}
>
<ListItem atomData={atomData} item={item} />
</Button>
))}
</VirtualList>
</Section>
</Stack.Item>
<Stack.Item>
{!!searchBarVisible && (
<Stack fill align="center" g={0.5}>
<Stack.Item width={2}>
<Button
color="transparent"
tooltip={modeText}
onClick={() => {
act('setRegexSearch', { regexSearch: !regexSearch });
}}
>
{regexSearch ? (
<Box as="span" color="good">
re:
</Box>
) : (
<Icon name="search" />
)}
</Button>
</Stack.Item>
<Stack.Item grow>
<Input
autoFocus
autoSelect
expensive
fluid
onEnter={() => handleSelect(filteredItems[selected])}
onChange={handleSearch}
placeholder="Search..."
value={query}
style={{
borderColor: invalidInput ? 'red' : undefined,
}}
/>
</Stack.Item>
</Stack>
)}
</Stack.Item>
</Stack>
</Window.Content>
</Window>
);
}
type AtomSpanProps = {
atomData: AtomPathData;
item: AtomTypeData;
};
function ListItem(props: AtomSpanProps) {
const { atomData, item } = props;
const { data } = useBackend<SpawnSearchData>();
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 (
<>
<span
style={
atomData.abstractTypes[item.typepath]
? { opacity: 0.75, color: '#FFA246' }
: {}
}
>
{displayPath}
</span>
<span
className="label label-info"
style={{
marginLeft: '0.5em',
color: 'rgba(200, 200, 200, 0.5)',
fontSize: '10px',
}}
>
{item.name}
</span>
{!!atomData.abstractTypes[item.typepath] && (
<span
style={{
float: 'right',
marginRight: '0.5em',
color: 'rgba(255, 162, 70, 0.5)',
}}
>
Abstract
</span>
)}
</>
);
}
+3
View File
@@ -389,6 +389,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"
@@ -432,6 +433,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"
@@ -2285,6 +2287,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"