Files
Bubberstation/code/controllers/subsystem/dynamic
Time-Green 89cf4c7787 The Voidwalker | New Midround Antagonist (#84674)
## The Voidwalker

Adds a new antagonist, the Voidwalker! 
It's a rare antag that spawns in space when there's at least 40 people
Design doc is here: https://hackmd.io/jE4YScP8RPykXo37rTBV2Q (there's
some deviations)
No biddle

## Summary
Spooky space antag that moves around space, ambushing people either in
space or near space. They can move through glass, have an ability to
stamina drain you if they remain in your vision for 8 seconds, can
temporarily remove glass windows to drag you through them and deal
~~stamina damage~~ incredibly violence. Upon being taken, they can be
kidnapped and cursed, muting and pacifying the person and sending them
to the void.

## Passive

![image](https://github.com/tgstation/tgstation/assets/7501474/e2bbac33-bac8-4eb9-9042-994904ebec18)

![image](https://github.com/tgstation/tgstation/assets/7501474/b76df700-84d4-4722-bf6f-2e2049c345b3)

Passive abilities:

- Permanent space flight and indoor flight
- Space regen
- Slowdown when in gravity
- Space camo (very low alpha when in space
- Can freely move through unshocked glass
- Mute, but can hear all station frequencies (excluding binary)
- Will quickly die when on planets or moons (obviously can't roll on
icebox)
- 10 brute armor and 20 burn armor. They can't wear any form of armor
and don't have any get out of jail free cards, so I think it'll help
with their survivability a slight bit

## Abilities
**Void eater:** Literally just a light-eater but instead of eating light
it instantly shatters windows, but restores them after a few seconds

**Space Dive:** New ability that lets you move under the station with a
2 second do_after, so they can still get to closed of space spots
without being as annoying as heretic space shift

**Unsettle:** Remain in view of the target for 8 seconds to give them a
short stun, slurring and 80 stamina damage, but announce your presence
and location to them. Both you and your target can move, as long as you
remain in their view.

**Space Kidnap:** When your target is incapacitated and in space, you
can drag them into the cosmic void. They'll be returned cursed after
undergoing a sort of reverse heretic sacrifice (more on that in the next
section).

[Showcase of all the above abilities!](https://youtu.be/NJ01H28PV9w)

## Voided Crew
A brain trauma received when you get kidnapped. While under it's
influence, you are muted and pacified. You will die in planetary gravity
and cannot enter space.


![image](https://github.com/tgstation/tgstation/assets/7501474/ea90bf9b-b8d1-4d46-8c69-7b0900e8e50c)

It can be cured with a lobotomy or by dying in planetary gravity. 
You get warned to avoid the Voidwalker. The voidwalker now does extra
damage and gets the option to glass gib you if you die, leaving just a
brain

## Loot
On death, the Voidwalker shatters into glass and drops a cosmic skull.
Looking into the skull gives you a stable version of the Voided brain
trauma. It doesn't give you pacifism and doesn't ban you from space. It
also makes you space immune and gives you the ability to walk through
unshocked glass after 2 seconds do_after.

Sprites for the cosmic skull by Justice12354 and Rex9001! I'll throw up
a video showcasing death and their sprites on Sunday

## Why It's Good For The Game
We don't have any space centered antagonists. The closest we have are
Space Dragons, but they have to go deep into the station anyway. I'm
also quite fond of simpler antagonists, like revenant and nightmare.
Give people the tools, and they'll make the fun themselves.

I've been comparing the Voidwalker with the Nightmare. Both are goalles,
simple antagonists, but where the Nightmare's gimmick is darkness, the
Voidwalker's gimmick is space.

They also get a dark tentacle arm so they can murderize people, but it
only gets them to crit. After that they can drag people into the "void"
to kidnap them. This is mostly to encourage the Voidwalker not to just
space them cause that's kinda lame. They still can though, just in-case
it would be very funny

## Changelog
🆑 Time-Green, Justice12354, Rex9001
add: Adds the Voidwalker, a new rare space based midround antagonist!
code: Adds the ability to texture limbs and bodypart_overlays
Sprite: Cosmic skull sprites by Justice12354 and Rex9001
/🆑

- [x] Add a better kidnapping mechanic, instead of just teleporting
someone to a station turf
- [x] Add an armblade or weapon or something, unarmed combat is kinda
ass for this
- [x] Fix the antag preview not rendering textures
- [x] Prevent them from space phasing when in combat so they can
actually be killed in space combat, even if really fucking hard
- [x] Nerf visibility for people with space parallax disabled, probably
also something to improve camo with colored parallax
- [x] Replace the stamina damage stuff
- [x] Cool rework of space phase idea I have
- [x] Update/implement vidual effects
- [x] Implement names

## Considerations
There's a few things that I might change or implement later, depending
on how it actually plays.

It's a space focused antag, but there might not be enough people near
space in a given round. An ability to let them "capture" area's or let
them turn into a meteor or something might be needed later if they turn
out to be too passive.

They might also be _too_ oppressive in space. I designed them for
near-space combat, but may've made them insanely overpowered in in raw
space combat.

Probably wont do biddle, but might add more powers that you can reroll
every few minutes or something (like blob). Either wat it wont be this
PR

---------

Co-authored-by: carlarctg <53100513+carlarctg@users.noreply.github.com>
Co-authored-by: MrMelbert <51863163+MrMelbert@users.noreply.github.com>
Co-authored-by: Jacquerel <hnevard@gmail.com>
2024-07-14 18:57:44 +00:00
..

Dynamic Mode

Roundstart

Dynamic rolls threat based on a special sauce formula:

[dynamic_curve_width][/datum/controller/global_vars/var/dynamic_curve_width] * tan((3.1416 * (rand() - 0.5) * 57.2957795)) + [dynamic_curve_centre][/datum/controller/global_vars/var/dynamic_curve_centre]

This threat is split into two separate budgets--round_start_budget and mid_round_budget. For example, a round with 50 threat might be split into a 30 roundstart budget, and a 20 midround budget. The roundstart budget is used to apply antagonists applied on readied players when the roundstarts (/datum/dynamic_ruleset/roundstart). The midround budget is used for two types of rulesets:

  • /datum/dynamic_ruleset/midround - Rulesets that apply to either existing alive players, or to ghosts. Think Blob or Space Ninja, which poll ghosts asking if they want to play as these roles.
  • /datum/dynamic_ruleset/latejoin - Rulesets that apply to the next player that joins. Think Syndicate Infiltrator, which converts a player just joining an existing round into traitor.

This split is done with a similar method, known as the "lorentz distribution", exists to create a bell curve that ensures that while most rounds will have a threat level around ~50, chaotic and tame rounds still exist for variety.

The process of creating these numbers occurs in /datum/controller/subsystem/dynamic/proc/generate_threat (for creating the threat level) and /datum/controller/subsystem/dynamic/proc/generate_budgets (for splitting the threat level into budgets).

Deciding roundstart threats

In /datum/controller/subsystem/dynamic/proc/roundstart() (called when no admin chooses the rulesets explicitly), Dynamic uses the available roundstart budget to pick threats. This is done through the following system:

  • All roundstart rulesets (remember, /datum/dynamic_ruleset/roundstart) are put into an associative list with their weight as the values (drafted_rules).
  • Until there is either no roundstart budget left, or until there is no ruleset we can choose from with the available threat, a pickweight is done based on the drafted_rules. If the same threat is picked twice, it will "scale up". The meaning of this depends on the ruleset itself, using the scaled_times variable; traitors for instance will create more the higher they scale.
    • If a ruleset is chosen with the HIGH_IMPACT_RULESET in its flags, then all other HIGH_IMPACT_RULESETs will be removed from drafted_rules. This is so that only one can ever be chosen.
    • If a ruleset has LONE_RULESET in its flags, then it will be removed from drafted_rules. This is to ensure it will only ever be picked once. An example of this in use is Wizard, to avoid creating multiple wizards.
  • After all roundstart threats are chosen, /datum/dynamic_ruleset/proc/picking_roundstart_rule is called for each, passing in the ruleset and the number of times it is scaled.
    • In this stage, pre_execute is called, which is the function that will determine what players get what antagonists. If this function returns FALSE for whatever reason (in the case of an error), then its threat is refunded.

After this process is done, any leftover roundstart threat will be given to the existing midround budget (done in /datum/controller/subsystem/dynamic/pre_setup()).

Deciding midround threats

Frequency

The frequency of midround threats is based on the midround threat of the round. The number of midround threats that will roll is threat_level / threat_per_midround_roll (configurable), rounded up. For example, if threat_per_midround_roll is set to 5, then for every 5 threat, one midround roll will be added. If you have 6 threat, with this configuration, you will get 2 midround rolls.

These midround roll points are then equidistantly spaced across the round, starting from midround_lower_bound (configurable) to midround_upper_bound (configurable), with a +/- of midround_roll_distance (configurable).

For example, if:

  1. midround_lower_bound is 10 MINUTES
  2. midround_upper_bound is 100 MINUTES
  3. midround_roll_distance is 3 MINUTES
  4. You have 5 midround rolls for the round

...then those 5 midround rolls will be placed equidistantly (meaning equally apart) across the first 10-100 minutes of the round. Every individual roll will then be adjusted to either be 3 minutes earlier, or 3 minutes later.

Threat variety

Threats are split between heavy rulesets and light rulesets. A heavy ruleset includes major threats like space dragons or blobs, while light rulesets are ones that don't often cause shuttle calls when rolled, such as revenants or traitors (sleeper agents).

When a midround roll occurs, the decision to choose between light or heavy depends on the current round time. If it is less than midround_light_upper_bound (configurable), then it is guaranteed to be a light ruleset. If it is more than midround_heavy_lower_bound, then it is guaranteed to be a heavy ruleset. If it is any point in between, it will interpolate the value between those. This means that the longer the round goes on, the more likely you are to get a heavy ruleset.

If no heavy ruleset can run, such as not having enough threat, then a light ruleset is guaranteed to run.

Rule Processing

Calls [rule_process][/datum/dynamic_ruleset/proc/rule_process] on every rule which is in the current_rules list. Every sixty seconds, update_playercounts() Midround injection time is checked against world.time to see if an injection should happen. If midround injection time is lower than world.time, it updates playercounts again, then tries to inject and generates a new cooldown regardless of whether a rule is picked.

Latejoin

make_antag_chance(newPlayer) -> (For each latespawn rule...) -> acceptable(living players, threat_level) -> trim_candidates() -> ready(forced=FALSE) **If true, add to drafted rules **NOTE that acceptable uses threat_level not threat! **NOTE Latejoin timer is ONLY reset if at least one rule was drafted. **NOTE the new_player.dm AttemptLateSpawn() calls OnPostSetup for all roles (unless assigned role is MODE)

(After collecting all draftble rules...) -> picking_latejoin_ruleset(drafted_rules) -> spend threat -> ruleset.execute()

Midround

process() -> (For each midround rule... -> acceptable(living players, threat_level) -> trim_candidates() -> ready(forced=FALSE) (After collecting all draftble rules...) -> picking_midround_ruleset(drafted_rules) -> spend threat -> ruleset.execute()

Forced

For latejoin, it simply sets forced_latejoin_rule make_antag_chance(newPlayer) -> trim_candidates() -> ready(forced=TRUE) **NOTE no acceptable() call

For midround, calls the below proc with forced = TRUE picking_specific_rule(ruletype,forced) -> forced OR acceptable(living_players, threat_level) -> trim_candidates() -> ready(forced) -> spend threat -> execute() **NOTE specific rule can be called by RS traitor->MR autotraitor w/ forced=FALSE **NOTE that due to short circuiting acceptable() need not be called if forced.

Ruleset

acceptable(population,threat) just checks if enough threat_level for population indice. **NOTE that we currently only send threat_level as the second arg, not threat. ready(forced) checks if enough candidates and calls the map's map_ruleset(dynamic_ruleset) at the parent level

trim_candidates() varies significantly according to the ruleset type Roundstart: All candidates are new_player mobs. Check them for standard stuff: connected, desire role, not banned, etc. **NOTE Roundstart deals with both candidates (trimmed list of valid players) and mode.candidates (everyone readied up). Don't confuse them! Latejoin: Only one candidate, the latejoiner. Standard checks. Midround: Instead of building a single list candidates, candidates contains four lists: living, dead, observing, and living antags. Standard checks in trim_list(list).

Midround - Rulesets have additional types /from_ghosts: execute() -> send_applications() -> review_applications() -> finish_applications() -> finish_setup(mob/newcharacter, index) -> setup_role(role) **NOTE: execute() here adds dead players and observers to candidates list

Configuration and variables

Configuration

Configuration can be done through a config/dynamic.json file. One is provided as example in the codebase. This config file, loaded in /datum/controller/subsystem/dynamic/pre_setup(), directly overrides the values in the codebase, and so is perfect for making some rulesets harder/easier to get, turning them off completely, changing how much they cost, etc.

The format of this file is:

{
	"Dynamic": {
		/* Configuration in here will directly override `/datum/controller/subsystem/dynamic` itself. */
		/* Keys are variable names, values are their new values. */
	},

	"Roundstart": {
		/* Configuration in here will apply to `/datum/dynamic_ruleset/roundstart` instances. */
		/* Keys are the ruleset names, values are another associative list with keys being variable names and values being new values. */
		"Wizard": {
			/* I, a head admin, have died to wizard, and so I made it cost a lot more threat than it does in the codebase. */
			"cost": 80
		}
	},

	"Midround": {
		/* Same as "Roundstart", but for `/datum/dynamic_ruleset/midround` instead. */
	},

	"Latejoin": {
		/* Same as "Roundstart", but for `/datum/dynamic_ruleset/latejoin` instead. */
	},

	"Station": {
		/* Special threat reductions for dangerous station traits. Traits are selected before dynamic, so traits will always  */
		/* reduce threat even if there's no threat for it available. Only "cost" can be modified */
	}
}

Note: Comments are not possible in this format, and are just in this document for the sake of readability.

Rulesets

Rulesets have the following variables notable to developers and those interested in tuning.

  • required_candidates - The number of people that must be willing (in their preferences) to be an antagonist with this ruleset. If the candidates do not meet this requirement, then the ruleset will not bother to be drafted.
  • antag_cap - Judges the amount of antagonists to apply, for both solo and teams. Note that some antagonists (such as traitors, lings, heretics, etc) will add more based on how many times they've been scaled. Written as a linear equation--ceil(x/denominator) + offset, or as a fixed constant. If written as a linear equation, will be in the form of list("denominator" = denominator, "offset" = offset).
    • Examples include:
      • Traitor: antag_cap = list("denominator" = 24). This means that for every 24 players, 1 traitor will be added (assuming no scaling).
      • Nuclear Emergency: antag_cap = list("denominator" = 18, "offset" = 1). For every 18 players, 1 nuke op will be added. Starts at 1, meaning at 30 players, 3 nuke ops will be created, rather than 2.
      • Revolution: antag_cap = 3. There will always be 3 rev-heads, no matter what.
  • minimum_required_age - The minimum age in order to apply for the ruleset.
  • weight - How likely this ruleset is to be picked. A higher weight results in a higher chance of drafting.
  • cost - The initial cost of the ruleset. This cost is taken from either the roundstart or midround budget, depending on the ruleset.
  • scaling_cost - Cost for every additional application of this ruleset.
    • Suppose traitors has a cost of 8, and a scaling_cost of 5. This means that buying 1 application of the traitor ruleset costs 8 threat, but buying two costs 13 (8 + 5). Buying it a third time is 18 (8 + 5 + 5), etc.
  • pop_per_requirement - The range of population each value in requirements represents. By default, this is 6.
    • If the value is five the range is 0-4, 5-9, 10-14, 15-19, 20-24, 25-29, 30-34, 35-39, 40-54, 45+.
    • If it is six the range is 0-5, 6-11, 12-17, 18-23, 24-29, 30-35, 36-41, 42-47, 48-53, 54+.
    • If it is seven the range is 0-6, 7-13, 14-20, 21-27, 28-34, 35-41, 42-48, 49-55, 56-62, 63+.
  • requirements - A list that represents, per population range (see: pop_per_requirement), how much threat is required to consider this ruleset. This is independent of how much it'll actually cost. This uses threat level, not the budget--meaning if a round has 50 threat level, but only 10 points of round start threat, a ruleset with a requirement of 40 can still be picked if it can be bought.
    • Suppose wizard has a requirements of list(90,90,70,40,30,20,10,10,10,10). This means that, at 0-5 and 6-11 players, A station must have 90 threat in order for a wizard to be possible. At 12-17, 70 threat is required instead, etc.
  • restricted_roles - A list of jobs that can't be drafted by this ruleset. For example, cyborgs cannot be changelings, and so are in the restricted_roles.
  • protected_roles - Serves the same purpose of restricted_roles, except it can be turned off through configuration (protect_roles_from_antagonist). For example, security officers shouldn't be made traitor, so they are in Traitor's protected_roles.
    • When considering putting a role in protected_roles or restricted_roles, the rule of thumb is if it is technically infeasible to support that job in that role. There's no technical reason a security officer can't be a traitor, and so they are simply in protected_roles. There are technical reasons a cyborg can't be a changeling, so they are in restricted_roles instead.

This is not a complete list--search "configurable" in this README to learn more.

Dynamic

The "Dynamic" key has the following configurable values:

  • pop_per_requirement - The default value of pop_per_requirement for any ruleset that does not explicitly set it. Defaults to 6.
  • latejoin_delay_min, latejoin_delay_max - The time range, in deciseconds (take your seconds, and multiply by 10), for a latejoin to attempt rolling. Once this timer is finished, a new one will be created within the same range.
    • Suppose you have a latejoin_delay_min of 600 (60 seconds, 1 minute) and a latejoin_delay_max of 1800 (180 seconds, 3 minutes). Once the round starts, a random number in this range will be picked--let's suppose 1.5 minutes. After 1.5 minutes, Dynamic will decide if a latejoin threat should be created (a probability of /datum/controller/subsystem/dynamic/proc/get_injection_chance()). Regardless of its decision, a new timer will be started within the range of 1 to 3 minutes, repeatedly.
  • threat_curve_centre - A number between -5 and +5. A negative value will give a more peaceful round and a positive value will give a round with higher threat.
  • threat_curve_width - A number between 0.5 and 4. Higher value will favour extreme rounds and lower value rounds closer to the average.
  • roundstart_split_curve_centre - A number between -5 and +5. Equivalent to threat_curve_centre, but for the budget split. A negative value will weigh towards midround rulesets, and a positive value will weight towards roundstart ones.
  • roundstart_split_curve_width - A number between 0.5 and 4. Equivalent to threat_curve_width, but for the budget split. Higher value will favour more variance in splits and lower value rounds closer to the average.
  • random_event_hijack_minimum - The minimum amount of time for antag random events to be hijacked. (See Random Event Hijacking)
  • random_event_hijack_maximum - The maximum amount of time for antag random events to be hijacked. (See Random Event Hijacking)
  • hijacked_random_event_injection_chance - The amount of injection chance to give to Dynamic when a random event is hijacked. (See Random Event Hijacking)
  • max_threat_level - Sets the maximum amount of threat that can be rolled. Defaults to 100. You should only use this to lower the maximum threat, as raising it higher will not do anything.

Random Event "Hijacking"

Random events have the potential to be hijacked by Dynamic to keep the pace of midround injections, while also allowing greenshifts to contain some antagonists.

/datum/round_event_control/dynamic_should_hijack is a variable to random events to allow Dynamic to hijack them, and defaults to FALSE. This is set to TRUE for random events that spawn antagonists.

In /datum/controller/subsystem/dynamic/on_pre_random_event (in dynamic_hijacking.dm), Dynamic hooks to random events. If the dynamic_should_hijack variable is TRUE, the following sequence of events occurs:

Flow chart to describe the chain of events for Dynamic 2021 to take

n is a random value between random_event_hijack_minimum and random_event_hijack_maximum. Heavy injection chance, should it need to be raised, is increased by hijacked_random_event_injection_chance_modifier.