Files
Bubberstation/code/modules/mob/mob_helpers.dm
MrMelbert 4c277dc572 Dynamic Rework (#91290)
## About The Pull Request

Implements https://hackmd.io/@tgstation/SkeUS7lSp , rewriting Dynamic
from the ground-up

- Dynamic configuration is now vastly streamlined, making it far far far
easier to understand and edit

- Threat is gone entirely; round chaos is now determined by dynamic
tiers
   - There's 5 dynamic tiers, 0 to 4.
      - 0 is a pure greenshift.
- Tiers are just picked via weight - "16% chance of getting a high chaos
round".
- Tiers have min pop ranges. "Tier 4 (high chaos) requires 25 pop to be
selected".
- Tier determines how much of every ruleset is picked. "Tier 4 (High
Chaos) will pick 3-4 roundstart[1], 1-2 light, 1-2 heavy, and 2-3
latejoins".
- The number of rulesets picked depends on how many people are in the
server - this is also configurable[2]. As an example, a tier that
demands "1-3" rulesets will not spawn 3 rulesets if population <= 40 and
will not spawn 2 rulesets if population <= 25.
- Tiers also determine time before light, heavy, and latejoin rulesets
are picked, as well as the cooldown range between spawns. More chaotic
tiers may send midrounds sooner or wait less time between sending them.

- On the ruleset side of things, "requirements", "scaling", and
"enemies" is gone.
- You can configure a ruleset's min pop and weight flat, or per tier.
- For example a ruleset like Obsession is weighted higher for tiers 1-2
and lower for tiers 3-4.
- Rather than scaling up, roundstart rulesets can just be selected
multiple times.
- Rulesets also have `min_antag_cap` and `max_antag_cap`.
`min_antag_cap` determines how many candidates are needed for it to run,
and `max_antag_cap` determines how many candidates are selected.

- Rulesets attempt to run every 2.5 minutes. [3]

- Light rulesets will ALWAYS be picked before heavy rulesets. [4]

- Light injection chance is no longer 100%, heavy injection chance
formula has been simplified.
- Chance simply scales based on number of dead players / total number
off players, with a flag 50% chance if no antags exist. [5]

[1] This does not guarantee you will actually GET 3-4 roundstart
rulesets. If a roundstart ruleset is picked, and it ends up being unable
to execute (such as "not enough candidates", that slot is effectively a
wash.) This might be revisited.

[2] Currently, this is a hard limit - below X pop, you WILL get a
quarter or a half of the rulesets. This might be revisited to just be
weighted - you are just MORE LIKELY to get a quarter or a half.

[3] Little worried about accidentally frontloading everything so we'll
see about this

[4] This may be revisited but in most contexts it seems sensible. 

[5] This may also be revisited, I'm not 100% sure what the best / most
simple way to tackle midround chances is.

Other implementation details

- The process of making rulesets has been streamlined as well. Many
rulesets only amount to a definition and `assign_role`.

- Dynamic.json -> Dynamic.toml

- Dynamic event hijacked was ripped out entirely.
- Most midround antag random events are now dynamic rulesets. Fugitives,
Morphs, Slaughter Demons, etc.
      - The 1 weight slaughter demon event is gone. RIP in peace. 
- There is now a hidden midround event that simply adds +1 latejoin, +1
light, or +1 heavy ruleset.

- `mind.special_role` is dead. Minds have a lazylist of special roles
now but it's essentially only used for traitor panel.

- Revs refactored almost entirely. Revs can now exist without a dynamic
ruleset.

- Cult refactored a tiny bit. 

- Antag datums cleaned up.

- Pre round setup is less centralized on Dynamic.

- Admins have a whole panel for interfacing with dynamic. It's pretty
slapdash I'm sure someone could make a nicer looking one.


![image](https://github.com/user-attachments/assets/e99ca607-20b0-4d30-ab4a-f602babe7ac7)


![image](https://github.com/user-attachments/assets/470c3c20-c354-4ee6-b63b-a8f36dda4b5c)

- Maybe some other things.

## Why It's Good For The Game

See readme for more info.

Will you see a massive change in how rounds play out? My hunch says
rounds will spawn less rulesets on average, but it's ultimately to how
it's configured

## Changelog

🆑 Melbert
refactor: Dynamic rewritten entirely, report any strange rounds
config: Dynamic config reworked, it's now a TOML file
refactor: Refactored antag roles somewhat, report any oddities
refactor: Refactored Revolution entirely, report any oddities
del: Deleted most midround events that spawn antags - they use dynamic
rulesets now
add: Dynamic rulesets can now be false alarms
add: Adds a random event that gives dynamic the ability to run another
ruleset later
admin: Adds a panel for messing around with dynamic
admin: Adds a panel for chance for every dynamic ruleset to be selected
admin: You can spawn revs without using dynamic now
fix: Nuke team leaders get their fun title back
/🆑
2025-06-25 17:36:10 -07:00

568 lines
19 KiB
Plaintext

// see _DEFINES/is_helpers.dm for mob type checks
///Find the mob at the bottom of a buckle chain
/mob/proc/lowest_buckled_mob()
. = src
if(buckled && ismob(buckled))
var/mob/Buckled = buckled
. = Buckled.lowest_buckled_mob()
///Convert a PRECISE ZONE into the BODY_ZONE
/proc/check_zone(zone)
if(!zone)
return BODY_ZONE_CHEST
switch(zone)
if(BODY_ZONE_PRECISE_EYES)
zone = BODY_ZONE_HEAD
if(BODY_ZONE_PRECISE_MOUTH)
zone = BODY_ZONE_HEAD
if(BODY_ZONE_PRECISE_L_HAND)
zone = BODY_ZONE_L_ARM
if(BODY_ZONE_PRECISE_R_HAND)
zone = BODY_ZONE_R_ARM
if(BODY_ZONE_PRECISE_L_FOOT)
zone = BODY_ZONE_L_LEG
if(BODY_ZONE_PRECISE_R_FOOT)
zone = BODY_ZONE_R_LEG
if(BODY_ZONE_PRECISE_GROIN)
zone = BODY_ZONE_CHEST
return zone
/**
* Return the zone or randomly, another valid zone
*
* probability controls the chance it chooses the passed in zone, or another random zone
* defaults to 80
*/
/proc/ran_zone(zone, probability = 80, list/weighted_list)
if(prob(probability))
zone = check_zone(zone)
else
zone = pick_weight(weighted_list ? weighted_list : list(BODY_ZONE_HEAD = 1, BODY_ZONE_CHEST = 1, BODY_ZONE_L_ARM = 4, BODY_ZONE_R_ARM = 4, BODY_ZONE_L_LEG = 4, BODY_ZONE_R_LEG = 4))
return zone
/**
* More or less ran_zone, but only returns bodyzones that the mob /actually/ has.
*
* * blacklisted_parts - allows you to specify zones that will not be chosen. eg: list(BODY_ZONE_CHEST, BODY_ZONE_R_LEG)
* * * !!!! blacklisting BODY_ZONE_CHEST is really risky since it's the only bodypart guarunteed to ALWAYS exists !!!!
* * * !!!! Only do that if you're REALLY CERTAIN they have limbs, otherwise we'll CRASH() !!!!
*
* * ran_zone has a base prob(80) to return the base_zone (or if null, BODY_ZONE_CHEST) vs something in our generated list of limbs.
* * this probability is overriden when either blacklisted_parts contains BODY_ZONE_CHEST and we aren't passed a base_zone (since the default fallback for ran_zone would be the chest in that scenario), or if even_weights is enabled.
* * you can also manually adjust this probability by altering base_probability
*
* * even_weights - ran_zone has a 40% chance (after the prob(80) mentioned above) of picking a limb, vs the torso & head which have an additional 10% chance.
* * Setting even_weight to TRUE will make it just a straight up pick() between all possible bodyparts.
*
*/
/mob/proc/get_random_valid_zone(base_zone, base_probability = 80, list/blacklisted_parts, even_weights, bypass_warning)
return BODY_ZONE_CHEST //even though they don't really have a chest, let's just pass the default of check_zone to be safe.
/mob/living/carbon/get_random_valid_zone(base_zone, base_probability = 80, list/blacklisted_parts, even_weights, bypass_warning)
var/list/limbs = list()
for(var/obj/item/bodypart/part as anything in bodyparts)
var/limb_zone = part.body_zone //cache the zone since we're gonna check it a ton.
if(limb_zone in blacklisted_parts)
continue
if(even_weights)
limbs[limb_zone] = 1
continue
if(limb_zone == BODY_ZONE_CHEST || limb_zone == BODY_ZONE_HEAD)
limbs[limb_zone] = 1
else
limbs[limb_zone] = 4
if(base_zone && !(check_zone(base_zone) in limbs))
base_zone = null //check if the passed zone is infact valid
var/chest_blacklisted
if((BODY_ZONE_CHEST in blacklisted_parts))
chest_blacklisted = TRUE
if(bypass_warning && !limbs.len)
CRASH("limbs is empty and the chest is blacklisted. this may not be intended!")
return (((chest_blacklisted && !base_zone) || even_weights) ? pick_weight(limbs) : ran_zone(base_zone, base_probability, limbs))
///Would this zone be above the neck
/proc/above_neck(zone)
var/list/zones = list(BODY_ZONE_HEAD, BODY_ZONE_PRECISE_MOUTH, BODY_ZONE_PRECISE_EYES)
if(zones.Find(zone))
return TRUE
else
return FALSE
/**
* Convert random parts of a passed in message to stars
*
* * phrase - the string to convert
* * probability - probability any character gets changed
*
* This proc is dangerously laggy, avoid it or die
*/
/proc/stars(phrase, probability = 25)
if(probability <= 0)
return phrase
phrase = html_decode(phrase)
var/leng = length(phrase)
. = ""
var/char = ""
for(var/i = 1, i <= leng, i += length(char))
char = phrase[i]
if(char == " " || !prob(probability))
. += char
else
. += "*"
return sanitize(.)
/**
* For when you're only able to speak a limited amount of words
* phrase - the string to convert
* definitive_limit - the amount of words to limit the phrase to, optional
*/
/proc/stifled(phrase, definitive_limit = null)
phrase = html_decode(phrase)
var/num_words = 0
var/words = splittext(phrase, " ")
if(definitive_limit > 0) // in case someone passes a negative
num_words = min(definitive_limit, length(words))
else
num_words = min(rand(3, 5), length(words))
. = ""
for(var/i = 1, i <= num_words, i++)
if(num_words == i)
. += words[i] + "..."
else
. += words[i] + " ... "
return sanitize(.)
/**
* Turn text into complete gibberish!
*
* text is the inputted message, replace_characters will cause original letters to be replaced and chance are the odds that a character gets modified.
*/
/proc/Gibberish(text, replace_characters = FALSE, chance = 50)
text = html_decode(text)
. = ""
var/rawchar = ""
var/letter = ""
var/lentext = length(text)
for(var/i = 1, i <= lentext, i += length(rawchar))
rawchar = letter = text[i]
if(prob(chance))
if(replace_characters)
letter = ""
for(var/j in 1 to rand(0, 2))
letter += pick("#", "@", "*", "&", "%", "$", "/", "<", ">", ";", "*", "*", "*", "*", "*", "*", "*")
. += letter
return sanitize(.)
#define TILES_PER_SECOND 0.7
///Shake the camera of the person viewing the mob SO REAL!
///Takes the mob to shake, the time span to shake for, and the amount of tiles we're allowed to shake by in tiles
///Duration isn't taken as a strict limit, since we don't trust our coders to not make things feel shitty. So it's more like a soft cap.
/proc/shake_camera(mob/M, duration, strength=1)
if(!M || !M.client || duration < 1)
return
var/client/C = M.client
var/oldx = C.pixel_x
var/oldy = C.pixel_y
var/max_x = strength*ICON_SIZE_X
var/max_y = strength*ICON_SIZE_Y
var/min_x = -(strength*ICON_SIZE_X)
var/min_y = -(strength*ICON_SIZE_Y)
if(C.prefs?.read_preference(/datum/preference/toggle/screen_shake_darken))
var/type = /atom/movable/screen/fullscreen/flash/black
M.overlay_fullscreen("flash", type)
addtimer(CALLBACK(M, TYPE_PROC_REF(/mob, clear_fullscreen), "flash", 3 SECONDS), 3 SECONDS)
//How much time to allot for each pixel moved
var/time_scalar = (1 / ICON_SIZE_ALL) * TILES_PER_SECOND
var/last_x = oldx
var/last_y = oldy
var/time_spent = 0
while(time_spent < duration)
//Get a random pos in our box
var/x_pos = rand(min_x, max_x) + oldx
var/y_pos = rand(min_y, max_y) + oldy
//We take the smaller of our two distances so things still have the propencity to feel somewhat jerky
var/time = round(max(min(abs(last_x - x_pos), abs(last_y - y_pos)) * time_scalar, 1))
if (time_spent == 0)
animate(C, pixel_x=x_pos, pixel_y=y_pos, time=time)
else
animate(pixel_x=x_pos, pixel_y=y_pos, time=time)
last_x = x_pos
last_y = y_pos
//We go based on time spent, so there is a chance we'll overshoot our duration. Don't care
time_spent += time
animate(pixel_x=oldx, pixel_y=oldy, time=3)
#undef TILES_PER_SECOND
///Find if the message has the real name of any user mob in the mob_list
/proc/findname(msg)
if(!istext(msg))
msg = "[msg]"
for(var/i in GLOB.mob_list)
var/mob/M = i
if(M.real_name == msg)
return M
return 0
///Returns a mob's real name between brackets. Useful when you want to display a mob's name alongside their real name
/mob/proc/get_realname_string()
if(real_name && real_name != name)
return " \[[real_name]\]"
return ""
/**
* Checks if this mob is an antag
* By default excludes antags like Valentines, which are "fake antags"
*/
/mob/proc/is_antag(blacklisted_antag_flags = ANTAG_FAKE)
for(var/datum/antagonist/antag_datum as anything in mind?.antag_datums)
if(!blacklisted_antag_flags || !(antag_datum.antag_flags & blacklisted_antag_flags))
return TRUE
return FALSE
/mob/living/silicon/robot/is_antag(blacklisted_antag_flags)
return FALSE
/mob/living/silicon/ai/is_antag(blacklisted_antag_flags)
return ..() && !!(laws?.zeroth) // AIs only count as antags if they have a zeroth law (apparently)
/**
* Fancy notifications for ghosts
*
* The kitchen sink of notification procs
*
* Arguments:
* * message: The message displayed in chat.
* * source: The source of the notification. This is required for an icon
* * header: The title text to display on the icon tooltip.
* * alert_overlay: Optional. Create a custom overlay if you want, otherwise it will use the source
* * click_interact: If true, adds a link + clicking the icon will attack_ghost the source
* * custom_link: Optional. If you want to add a custom link to the chat notification
* * ghost_sound: sound to play
* * ignore_key: Ignore keys if they're in the GLOB.poll_ignore list
* * notify_volume: How loud the sound should be to spook the user
*/
/proc/notify_ghosts(
message,
atom/source,
header = "Something Interesting!",
mutable_appearance/alert_overlay,
click_interact = FALSE,
custom_link = "",
ghost_sound,
ignore_key,
notify_flags = NOTIFY_CATEGORY_DEFAULT,
notify_volume = 100,
)
if(notify_flags & GHOST_NOTIFY_IGNORE_MAPLOAD && SSatoms.initialized != INITIALIZATION_INNEW_REGULAR) //don't notify for objects created during a map load
return
if(source)
if(isnull(alert_overlay))
alert_overlay = get_small_overlay(source)
alert_overlay.appearance_flags |= TILE_BOUND
alert_overlay.layer = FLOAT_LAYER
alert_overlay.plane = FLOAT_PLANE
for(var/mob/dead/observer/ghost in GLOB.player_list)
if(!(notify_flags & GHOST_NOTIFY_NOTIFY_SUICIDERS) && HAS_TRAIT(ghost, TRAIT_SUICIDED))
continue
if(ignore_key && (ghost.ckey in GLOB.poll_ignore[ignore_key]))
continue
if(notify_flags & GHOST_NOTIFY_FLASH_WINDOW)
window_flash(ghost.client)
if(ghost_sound)
SEND_SOUND(ghost, sound(ghost_sound, volume = notify_volume))
if(isnull(source))
to_chat(ghost, span_ghostalert(message))
continue
var/interact_link = click_interact ? " <a href='byond://?src=[REF(ghost)];play=[REF(source)]'>(Play)</a>" : ""
var/view_link = " <a href='byond://?src=[REF(ghost)];view=[REF(source)]'>(View)</a>"
to_chat(ghost, span_ghostalert("[message][custom_link][interact_link][view_link]"))
var/atom/movable/screen/alert/notify_action/toast = ghost.throw_alert(
category = "[REF(source)]_notify_action",
type = /atom/movable/screen/alert/notify_action,
)
toast.add_overlay(alert_overlay)
toast.click_interact = click_interact
toast.desc = "Click to [click_interact ? "play" : "view"]."
toast.name = header
toast.target_ref = WEAKREF(source)
///Is the passed in mob a ghost with admin powers, doesn't check for AI interact like isAdminGhost() used to
/proc/isAdminObserver(mob/user)
if(!user) //Are they a mob? Auto interface updates call this with a null src
return
if(!user.client) // Do they have a client?
return
if(!isobserver(user)) // Are they a ghost?
return
if(!check_rights_for(user.client, R_ADMIN)) // Are they allowed?
return
return TRUE
///Returns TRUE/FALSE on whether the mob is an Admin Ghost AI.
///This requires this snowflake check because AI interact gives the access to the mob's client, rather
///than the mob like everyone else, and we keep it that way so they can't accidentally give someone Admin AI access.
/proc/isAdminGhostAI(mob/user)
if(!isAdminObserver(user))
return FALSE
if(!HAS_TRAIT_FROM(user.client, TRAIT_AI_ACCESS, ADMIN_TRAIT)) // Do they have it enabled?
return FALSE
return TRUE
/**
* Offer control of the passed in mob to dead player
*
* Automatic logging and uses poll_candidates_for_mob, how convenient
*/
/proc/offer_control(mob/M)
if(isdead(M))
to_chat(usr, "You can't give ghosts control of a ghost. They're already ghosts.")
return FALSE
to_chat(M, "Control of your mob has been offered to dead players.")
if(usr)
log_admin("[key_name(usr)] has offered control of ([key_name(M)]) to ghosts.")
message_admins("[key_name_admin(usr)] has offered control of ([ADMIN_LOOKUPFLW(M)]) to ghosts")
var/whomst = span_danger(M.real_name)
if(M.mind && !is_unassigned_job(M.mind?.assigned_role))
whomst += "Job: [span_notice(M.mind.assigned_role.title)]."
if(length(M.mind?.get_special_roles()))
whomst += "Status: [span_boldnotice(english_list(M.mind?.get_special_roles()))]."
var/mob/chosen_one = SSpolling.poll_ghosts_for_target("Do you want to play as [whomst]?", check_jobban = ROLE_PAI, poll_time = 10 SECONDS, checked_target = M, alert_pic = M, role_name_text = "ghost control")
if(chosen_one)
to_chat(M, "Your mob has been taken over by a ghost!")
message_admins("[key_name_admin(chosen_one)] has taken control of ([ADMIN_LOOKUPFLW(M)])")
M.ghostize(FALSE)
M.PossessByPlayer(chosen_one.key)
M.client?.init_verbs()
return TRUE
else
to_chat(M, "There were no ghosts willing to take control.")
message_admins("No ghosts were willing to take control of [ADMIN_LOOKUPFLW(M)])")
return FALSE
///Clicks a random nearby mob with the source from this mob
/mob/proc/click_random_mob()
var/list/nearby_mobs = list()
for(var/mob/living/L in range(1, src))
if(L != src)
nearby_mobs |= L
if(nearby_mobs.len)
var/mob/living/T = pick(nearby_mobs)
ClickOn(T)
///Can the mob hear
/mob/proc/can_hear()
return !HAS_TRAIT(src, TRAIT_DEAF)
/**
* Get the list of keywords for policy config
*
* This gets the type, mind assigned roles and antag datums as a list, these are later used
* to send the user relevant headadmin policy config
*/
/mob/proc/get_policy_keywords()
. = list()
. += "[type]"
if(isnull(mind))
return
if(mind.assigned_role.policy_index)
. += mind.assigned_role.policy_index
. += mind.assigned_role.title //A bit redunant, but both title and policy index are used
for(var/datum/antagonist/antag_datum as anything in mind.antag_datums)
. += "[antag_datum.type]"
if(antag_datum.pref_flag)
. += antag_datum.pref_flag
if(antag_datum.jobban_flag)
. += antag_datum.jobban_flag
///Can the mob see reagents inside of containers?
/mob/proc/can_see_reagents()
return stat == DEAD || HAS_TRAIT(src, TRAIT_REAGENT_SCANNER) //Dead guys and silicons can always see reagents
///Can this mob hold items
/mob/proc/can_hold_items(obj/item/I)
return length(held_items)
/// Returns this mob's default lighting alpha
/mob/proc/default_lighting_cutoff()
if(client?.combo_hud_enabled && (client?.prefs?.toggles & COMBOHUD_LIGHTING))
return LIGHTING_CUTOFF_FULLBRIGHT
return initial(lighting_cutoff)
/// Returns a generic path of the object based on the slot
/proc/get_path_by_slot(slot_id)
switch(slot_id)
if(ITEM_SLOT_BACK)
return /obj/item/storage/backpack
if(ITEM_SLOT_MASK)
return /obj/item/clothing/mask
if(ITEM_SLOT_NECK)
return /obj/item/clothing/neck
if(ITEM_SLOT_HANDCUFFED)
return /obj/item/restraints/handcuffs
if(ITEM_SLOT_LEGCUFFED)
return /obj/item/restraints/legcuffs
if(ITEM_SLOT_BELT)
return /obj/item/storage/belt
if(ITEM_SLOT_ID)
return /obj/item/card/id/advanced
if(ITEM_SLOT_EARS)
return /obj/item/clothing/ears
if(ITEM_SLOT_EYES)
return /obj/item/clothing/glasses
if(ITEM_SLOT_GLOVES)
return /obj/item/clothing/gloves
if(ITEM_SLOT_HEAD)
return /obj/item/clothing/head
if(ITEM_SLOT_FEET)
return /obj/item/clothing/shoes
if(ITEM_SLOT_OCLOTHING)
return /obj/item/clothing/suit
if(ITEM_SLOT_ICLOTHING)
return /obj/item/clothing/under
if(ITEM_SLOT_LPOCKET)
return /obj/item
if(ITEM_SLOT_RPOCKET)
return /obj/item
if(ITEM_SLOT_SUITSTORE)
return /obj/item
return null
/// Returns a client from a mob, mind or client
/proc/get_player_client(player)
if(ismob(player))
var/mob/player_mob = player
player = player_mob.client
else if(istype(player, /datum/mind))
var/datum/mind/player_mind = player
player = player_mind.current.client
if(!istype(player, /client))
return
return player
/proc/health_percentage(mob/living/mob)
var/divided_health = mob.health / mob.maxHealth
if(iscyborg(mob) || islarva(mob))
divided_health = (mob.health + mob.maxHealth) / (mob.maxHealth * 2)
else if(iscarbon(mob) || isAI(mob) || isbrain(mob))
divided_health = abs(HEALTH_THRESHOLD_DEAD - mob.health) / abs(HEALTH_THRESHOLD_DEAD - mob.maxHealth)
return divided_health * 100
/**
* Generates a log message when a user manually changes their targeted zone.
* Only need to one of new_target or old_target, and the other will be auto populated with the current selected zone.
*/
/mob/proc/log_manual_zone_selected_update(source, new_target, old_target)
if(!new_target && !old_target)
CRASH("Called log_manual_zone_selected_update without specifying a new or old target")
old_target ||= zone_selected
new_target ||= zone_selected
if(old_target == new_target)
return
var/list/data = list(
"new_target" = new_target,
"old_target" = old_target,
)
if(mind?.assigned_role)
data["assigned_role"] = mind.assigned_role.title
if(job)
data["assigned_job"] = job
var/atom/handitem = get_active_held_item()
if(handitem)
data["active_item"] = list(
"type" = handitem.type,
"name" = handitem.name,
)
var/atom/offhand = get_inactive_held_item()
if(offhand)
data["offhand_item"] = list(
"type" = offhand.type,
"name" = offhand.name,
)
logger.Log(
LOG_CATEGORY_TARGET_ZONE_SWITCH,
"[key_name(src)] manually changed selected zone",
data,
)
/**
* Returns an associative list of the logs of a certain amount of lines spoken recently by this mob
* copy_amount - number of lines to return
* line_chance - chance to return a line, if you don't want just the most recent x lines
*/
/mob/proc/copy_recent_speech(copy_amount = LING_ABSORB_RECENT_SPEECH, line_chance = 100)
var/list/recent_speech = list()
var/list/say_log = list()
var/log_source = logging
for(var/log_type in log_source)
var/nlog_type = text2num(log_type)
if(nlog_type & LOG_SAY)
var/list/reversed = log_source[log_type]
if(islist(reversed))
say_log = reverse_range(reversed.Copy())
break
for(var/spoken_memory in say_log)
if(recent_speech.len >= copy_amount)
break
if(!prob(line_chance))
continue
recent_speech[spoken_memory] = splittext(say_log[spoken_memory], "\"", 1, 0, TRUE)[3]
var/list/raw_lines = list()
for (var/key as anything in recent_speech)
raw_lines += recent_speech[key]
return raw_lines
/// Takes in an associated list (key `/datum/action` typepaths, value is the AI blackboard key) and handles granting the action and adding it to the mob's AI controller blackboard.
/// This is only useful in instances where you don't want to store the reference to the action on a variable on the mob.
/// You can set the value to null if you don't want to add it to the blackboard (like in player controlled instances). Is also safe with null AI controllers.
/// Assumes that the action will be initialized and held in the mob itself, which is typically standard.
/mob/proc/grant_actions_by_list(list/input)
if(length(input) <= 0)
return
for(var/action in input)
var/datum/action/ability = new action(src)
ability.Grant(src)
var/blackboard_key = input[action]
if(isnull(blackboard_key))
continue
ai_controller?.set_blackboard_key(blackboard_key, ability)