diff --git a/code/controllers/subsystem/non_firing/SSlate_mapping.dm b/code/controllers/subsystem/non_firing/SSlate_mapping.dm index eec96be19f9..3c8c5f435f6 100644 --- a/code/controllers/subsystem/non_firing/SSlate_mapping.dm +++ b/code/controllers/subsystem/non_firing/SSlate_mapping.dm @@ -28,3 +28,5 @@ SUBSYSTEM_DEF(late_mapping) QDEL_LIST_CONTENTS(maze_generators) var/duration = stop_watch(watch) log_startup_progress("Generated [mgcount] mazes in [duration]s") + + GLOB.spawn_pool_manager.process_pools() diff --git a/code/game/objects/effects/spawners/random/pool/pool_spawner.dm b/code/game/objects/effects/spawners/random/pool/pool_spawner.dm new file mode 100644 index 00000000000..2cfcaaff9bd --- /dev/null +++ b/code/game/objects/effects/spawners/random/pool/pool_spawner.dm @@ -0,0 +1,104 @@ +/// A random spawner managed by a [/datum/spawn_pool]. +/obj/effect/spawner/random/pool + icon = 'icons/effects/random_spawners.dmi' + icon_state = "loot" + + /// How much this spawner will subtract from the available budget if it + /// spawns. A value of `INFINITY` (i.e., not setting the value on a subtype) + /// does not attempt to subtract from the budget. This is useful for + /// spawners which themselves spawn other spawners. + var/point_value = INFINITY + /// Whether non-spawner items should be removed from the shared loot pool + /// after spawning. + var/unique_picks = FALSE + /// Guaranteed spawners will always proc, and always proc first. + var/guaranteed = FALSE + /// The ID of the spawn pool. Must match the pool's [/datum/spawn_pool/var/id]. + var/spawn_pool_id + +/obj/effect/spawner/random/pool/Initialize(mapload) + // short-circuit atom init machinery since we won't be around long + if(initialized) + stack_trace("Warning: [src]([type]) initialized multiple times!") + initialized = TRUE + + if(!spawn_pool_id) + stack_trace("No spawn pool ID provided to [src]([type])") + + if(GLOB.spawn_pool_manager.finalized) + // We've already gotten through SSlate_mapping, so someone probably spawned this manually. + // Skip all the shit and just spawn it. + spawn_loot() + qdel(src) + return + + var/datum/spawn_pool/pool = GLOB.spawn_pool_manager.get(spawn_pool_id) + if(!pool) + stack_trace("Could not find spawn pool with ID [spawn_pool_id]") + + if(unique_picks && !(type in pool.unique_spawners)) + pool.unique_spawners[type] = loot.Copy() + + if(guaranteed) + pool.guaranteed_spawners |= src + else + pool.known_spawners |= src + +/obj/effect/spawner/random/pool/generate_loot_list() + var/datum/spawn_pool/pool = GLOB.spawn_pool_manager.get(spawn_pool_id) + if(!pool) + stack_trace("Could not find spawn pool with ID [spawn_pool_id]") + + if(unique_picks) + var/list/unique_loot = pool.unique_spawners[type] + return unique_loot.Copy() + + return ..() + +/obj/effect/spawner/random/pool/check_safe(type_path_to_make) + // TODO: Spawners with `spawn_all_loot` set will subtract the + // point value for each item spawned. This needs to change so + // that the budget is only checked once initially, and then + // all of the loot is spawned after. + if(!..()) + return FALSE + + var/is_safe = FALSE + var/deduct_points = TRUE + var/datum/spawn_pool/pool = GLOB.spawn_pool_manager.get(spawn_pool_id) + if(!pool) + stack_trace("Could not find spawn pool with ID [spawn_pool_id]") + + if(ispath(type_path_to_make, /obj/effect/spawner/random/pool)) + return TRUE + + // If we're past SSlate_mapping, we're safe and don't have a pool + // to deduct points from + if(GLOB.spawn_pool_manager.finalized) + is_safe = TRUE + deduct_points = FALSE + + // If we don't have a sane point value, don't deduct points + if(point_value == INFINITY) + deduct_points = FALSE + + // If we deduct points, we need to check affordability + if(deduct_points) + if(pool.can_afford(point_value)) + is_safe = TRUE + else + is_safe = TRUE + + // Early breakout if we're not safe + if(!is_safe) + return FALSE + + if(deduct_points) + pool.consume(point_value) + + if(pool && unique_picks) + // We may have multiple instances of a given type so just remove the first instance we find + var/list/unique_spawners = pool.unique_spawners[type] + unique_spawners.Remove(type_path_to_make) + + return TRUE diff --git a/code/game/objects/effects/spawners/random/pool/spawn_pool.dm b/code/game/objects/effects/spawners/random/pool/spawn_pool.dm new file mode 100644 index 00000000000..8588a1b1dcb --- /dev/null +++ b/code/game/objects/effects/spawners/random/pool/spawn_pool.dm @@ -0,0 +1,59 @@ +/// Keeps track of the available points for a given pool, as well as any +/// spawners that need to keep track globally of the number of any specific item +/// that they spawn. +/datum/spawn_pool + /// The ID of the spawn pool. All spawners registered to this pool must use this ID. + var/id + /// The number of points left for the spawner to use. Starts at its initial value. + var/available_points = 0 + /// A list of all spawners registered to this pool. + var/list/known_spawners = list() + /// A key-value list of spawners with TRUE `unique_picks` to a shared copy of their + /// loot pool. When items from one of these spawners are spawned, it is removed + /// from the shared loot pool so it never spawns again. + var/list/unique_spawners = list() + /// A list of spawners whose `guaranteed` is `TRUE`. These spawners will + /// always spawn, and always before anything else, + var/list/guaranteed_spawners = list() + +/datum/spawn_pool/proc/can_afford(points) + if(available_points >= points) + return TRUE + + return FALSE + +/datum/spawn_pool/proc/consume(points) + available_points -= points + +/datum/spawn_pool/proc/process_guaranteed_spawners() + while(length(guaranteed_spawners)) + var/obj/effect/spawner/random/pool/spawner = guaranteed_spawners[length(guaranteed_spawners)] + guaranteed_spawners.len-- + spawner.spawn_loot() + qdel(spawner) + + QDEL_LIST_CONTENTS(guaranteed_spawners) + +/datum/spawn_pool/proc/process_spawners() + process_guaranteed_spawners() + + shuffle_inplace(known_spawners) + while(length(known_spawners)) + if(available_points <= 0) + break + + var/obj/effect/spawner/random/pool/spawner = known_spawners[length(known_spawners)] + known_spawners.len-- + if(spawner.point_value != INFINITY && available_points < spawner.point_value) + qdel(spawner) + continue + + spawner.spawn_loot() + if(length(guaranteed_spawners)) + WARNING("non-guaranteed spawner [spawner.type] spawned a guaranteed spawner, this should be avoided") + process_guaranteed_spawners() + + qdel(spawner) + + + QDEL_LIST_CONTENTS(known_spawners) diff --git a/code/game/objects/effects/spawners/random/pool/spawn_pool_manager.dm b/code/game/objects/effects/spawners/random/pool/spawn_pool_manager.dm new file mode 100644 index 00000000000..855eea2187a --- /dev/null +++ b/code/game/objects/effects/spawners/random/pool/spawn_pool_manager.dm @@ -0,0 +1,27 @@ +GLOBAL_DATUM_INIT(spawn_pool_manager, /datum/spawn_pool_manager, new) + +/// The singleton which keeps track of all spawn pools. +/// All known [/datum/spawn_pool] subtypes are registered +/// to it and are processed in SSlate_mapping. +/datum/spawn_pool_manager + var/list/spawn_pools = list() + var/finalized = FALSE + +/datum/spawn_pool_manager/New() + for(var/spawn_pool_type in subtypesof(/datum/spawn_pool)) + var/datum/spawn_pool/pool = new spawn_pool_type + spawn_pools[pool.id] = pool + +/datum/spawn_pool_manager/proc/get(id) + return spawn_pools[id] + +/datum/spawn_pool_manager/proc/process_pools() + for(var/pool_id in spawn_pools) + var/datum/spawn_pool/pool = spawn_pools[pool_id] + pool.process_spawners() + + finalized = TRUE + +/datum/spawn_pool_manager/Destroy() + QDEL_LIST_CONTENTS(spawn_pools) + return ..() diff --git a/code/game/objects/effects/spawners/random/random_spawner.dm b/code/game/objects/effects/spawners/random/random_spawner.dm index 1120de174df..4d7487f65e6 100644 --- a/code/game/objects/effects/spawners/random/random_spawner.dm +++ b/code/game/objects/effects/spawners/random/random_spawner.dm @@ -51,6 +51,18 @@ spawn_loot() return INITIALIZE_HINT_QDEL +/obj/effect/spawner/random/proc/generate_loot_list() + if(loot_type_path) + loot += typesof(loot_type_path) + + if(loot_subtype_path) + loot += subtypesof(loot_subtype_path) + + return loot + +/obj/effect/spawner/random/proc/check_safe(type_path_to_make) + return TRUE + ///If the spawner has any loot defined, randomly picks some and spawns it. Does not cleanup the spawner. /obj/effect/spawner/random/proc/spawn_loot(lootcount_override) if(!prob(spawn_loot_chance)) @@ -66,21 +78,22 @@ spawn_loot_count = INFINITY spawn_loot_double = FALSE - if(loot_type_path) - loot += typesof(loot_type_path) + var/list/loot_list = generate_loot_list() + var/safe_failure_count = 0 - if(loot_subtype_path) - loot += subtypesof(loot_subtype_path) - - if(length(loot)) + if(length(loot_list)) var/loot_spawned = 0 var/pixel_divider = FLOOR(spawn_random_offset_max_pixels / spawn_loot_split_pixel_offsets, 1) - while((spawn_loot_count-loot_spawned) && length(loot)) + while((spawn_loot_count-loot_spawned) && length(loot_list) && safe_failure_count <= 10) loot_spawned++ + var/lootspawn = pick_weight_recursive(loot_list) + + if(!check_safe(lootspawn)) + safe_failure_count++ + continue - var/lootspawn = pick_weight_recursive(loot) if(!spawn_loot_double) - loot.Remove(lootspawn) + loot_list.Remove(lootspawn) if(lootspawn) var/turf/spawn_loc = loc if(spawn_scatter_radius > 0 && length(spawn_locations)) @@ -91,6 +104,13 @@ continue var/atom/movable/spawned_loot = make_item(spawn_loc, lootspawn) + + // If we make something that then makes something else and gets itself + // qdel'd, we'll have a null result here. This doesn't necessarily mean + // that nothing's been spawned, so it's not necessarily a failure. + if(!spawned_loot) + continue + spawned_loot.setDir(dir) if(!spawn_loot_split && !spawn_random_offset) diff --git a/docs/mapping/spawn_pools.md b/docs/mapping/spawn_pools.md new file mode 100644 index 00000000000..b17c1bea4f1 --- /dev/null +++ b/docs/mapping/spawn_pools.md @@ -0,0 +1,195 @@ +# Spawn Pools + +Mappers have many useful random spawners to provide variety to their maps. +However, if these spawners provide high-value loot, or rare loot, it can become +difficult to ensure that these spawners are generally balanced. + +In some cases this doesn't matter so much. Maintenance loot spawners have free +reign to spawn loot of varying value and the only balancing done is to adjust +the weights of these loots, and scale the amount of spawners to reasonably work +for a given map's size and expected population. + +In other situations, like explorer loot in space, a surplus of valuable loot +spawners can quickly give players enormous advantages. This is compounded by the +fact that ruins are generally reviewed for balance in a vacuum, because it would +be too difficult to try and reason through what could spawn on any given round +across all ruins. + +Spawn pools were written as an attempt to solve this problem. Pool spawners are +assigned a point value, and registered to a global pool. Spawn pools track these +spawners, and deduct points from a central budget every time one of them spawns +loot. Once the budget runs out, no more spawners from that pool are activated. + +## Example + +Let's take our explorer loot scenario as before. If we want to register all +explorer loot to a pool, we first need to create the pool as a distinct +`/datum/spawn_pool` subtype: + +```dm +/datum/spawn_pool/space_loot + id = "space_loot_pool" + available_points = 200 +``` + +And then we create the spawners we want, all subtypes of +`/obj/effect/spawner/random/pool`: + +```dm +/obj/effect/spawner/random/pool/space_loot + spawn_pool_id = "space_loot_pool" + +/obj/effect/spawner/random/pool/space_loot/tier1 + point_value = 5 + loot = list( + /obj/item/soap/syndie, + /obj/item/stamp/chameleon, + ) + +/obj/effect/spawner/random/pool/space_loot/tier2 + point_value = 60 + loot = list( + /obj/item/ammo_box/magazine/smgm45, + /obj/item/ammo_box/magazine/apsm10mm/hp, + ) + +/obj/effect/spawner/random/pool/space_loot/tier3 + point_value = 100 + loot = list( + /obj/item/gun/projectile/automatic/pistol/APS = 2, + /obj/item/gun/projectile/automatic/l6_saw = 1, + ) +``` + +Here we have three different tiers of loot. The lowest tier has low-value items +and a low point value of 5. The middle tier has ammo and a point value of 50. +The highest tier has guns and a point value of 100. Note that you can continue +to use weights in these spawners, as well as all the other features of +`/obj/effect/spawner/random`. + +Armed with these three spawners, we can now use them on any ruin we want, in any +quantity. We may put the tier 3 spawner in a difficult, popular ruin, and the +tier 1 spawner in a less popular ruin. Importantly, we can place many of these +spawners, with a guarantee that only a handful will actually receive loot. + +Let's examine through some sample runs. Let's say we have: + +- two tier 1 spawners +- two tier 2 spawners +- two tier 3 spawners + +### Example Scenarios + +Spawners are selected at random, so one run might look like this: + +1. Pool budget is 200. +2. Tier 3 spawner chosen, gun spawns, budget is now 100. +3. Tier 2 spawner chosen, ammo spawns, budget is now 40. +4. Tier 1 spawner chosen, soap spawns, budget is now 35. + +At this point, neither tier 2 or tier 3 spawners can be afforded. If they are +chosen in the random selection, they are discarded, and do not spawn anything. +When the pool reaches the remainin tier 1 spawner, it will proc: + +4. Tier 2 spawner chosen, cost too high, does not spawn. +5. Tier 3 spawner chosen, cost too high, does not spawn. +6. Tier 1 spawner chosen, chameleon stamp spawns, budget is now 30. + +The pool has now exhausted all its spawners and stops. + +Another run may look like this: + +1. Pool budget is 200. +2. Tier 3 spawner chosen, gun spawns, budget is now 100. +3. Tier 3 spawner chosen, gun spawns, budget is now 0. + +The budget has been exhausted and no more spawners are chosen. Obviously this +second scenario is not ideal, as now only high-value loot has spawned. One of +the ways to fix this is to set the tier 3 cost to 110, rather than 100. That +way, the pool can only afford to ever spawn one of them. + +## Nested Spawners + +This is not a great usage of the spawn pool. It still means that high value +ruins will have high value loot, and less often explored ruins are still not +worth visiting. What we want to do is expand the amount of variety and +randomness across all ruins. + +To do this, we can nest our spawners. Create a new subtype: + +```dm +/obj/effect/spawner/random/pool/space_loot/mixed + loot = list( + /obj/effect/spawner/random/pool/space_loot/tier1 = 5, + /obj/effect/spawner/random/pool/space_loot/tier2 = 2, + /obj/effect/spawner/random/pool/space_loot/tier3 = 1, + ) +``` + +This works much better for our purposes. Not only are we decreasing the weight +of tier 3 spawns, but we can place one spawner type everywhere, meaning any of +the spawners has a chance of spawning great or passable loot. + +By nesting spawners, we gain the fine-grained control we want over drop +likelihood: the budget system, much like ruin placement, is concerned only about +how much loot it should attempt to spawn. The nested spawner's weights dictate +what it is we want to spawn more or less often. + +## Unique and Guaranteed Spawners + +Pool spawners also expose two other important features: guaranteed and unique +spawns. + +If we have ruins that have specialized, valuable loot, we always want the pool +to spawn loot there, no matter what. It would be bad to run a difficult, unique +ruin only to be left with nothing or generic loot from the table. + +_Guaranteed spawns_ are a way to fix that. For example, let's say we have a mech +themed ruin and we want the loot to be exosuit parts, but we don't want those +parts spawning anywhere else. We create a distinct subtype: + +```dm +/obj/effect/spawner/random/pool/space_loot/mech_ruin + point_value = 100 + guaranteed = TRUE + loot = list( + /obj/item/mecha_parts/mecha_equipment/drill/diamonddrill, + /obj/item/mecha_parts/mecha_equipment/extinguisher, + /obj/item/mecha_parts/mecha_equipment/medical/sleeper, + ) +``` + +By placing this spawner in our ruin, we can guarantee that loot is spawned there +before any other spawners are checked. But we can also include a point value +with it, so that it still counts as part of the overall budget. + +Let's say we have an item that we want to be part of the generic pool, but we +only want it to spawn once. This is what _unique spawns_ are for. For example, +say we want to spawn a Syndicate Elite MODsuit, but we only want one spawning +anywhere in space. We create a unique subtype: + +```dm +/obj/effect/spawner/random/pool/space_loot/elite_modsuit + point_value = 100 + unique_picks = TRUE + loot = list(/obj/item/mod/control/pre_equipped/traitor_elite) +``` + +If this spawner is chosen, it will spawn the MODsuit, and then the MODsuit will +be removed from the list. Spawners with empty lists are no-ops, so any other +spawners of this type will simply be dropped. By providing rare unique spawns +like this, you can give players motivation to search high and low for elusive +loot, while continuing to ensure the global budget of the spawn pool is +respected. + +## Conclusion + +Spawn pools are useful and flexible, but they are not magic. Balancing their +spawner values against their budget requires careful consideration and testing, +and you will not get them right on the first try. There are various subtle +things you will have to watch out for. For example, if you make high-value loot +too high in cost but low-value loot too low, then if the budget is too high +you'll end up with a tiny amount of high-value loot (good) but a +disproportionately enormous amount of low-value loot (bad). If you have more +budget than spawners to accomodate it, then you'll end up with unused budget +which will skew balance. Be sure to tweak and experiment. diff --git a/mkdocs.yml b/mkdocs.yml index 73aa8d48235..4f166851582 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -87,6 +87,7 @@ nav: - 'Mapping Requirements': './mapping/requirements.md' - 'Design Guidelines': './mapping/design.md' - 'Guide to Submaps': './mapping/submaps.md' + - 'Spawn Pools': './mapping/spawn_pools.md' - 'References': - 'Glossary': './references/glossary.md' diff --git a/paradise.dme b/paradise.dme index c8ea14df65b..f1130d83300 100644 --- a/paradise.dme +++ b/paradise.dme @@ -1043,6 +1043,9 @@ #include "code\game\objects\effects\spawners\random\random_spawner.dm" #include "code\game\objects\effects\spawners\random\trash_spawners.dm" #include "code\game\objects\effects\spawners\random\wall_decal_spawners.dm" +#include "code\game\objects\effects\spawners\random\pool\pool_spawner.dm" +#include "code\game\objects\effects\spawners\random\pool\spawn_pool.dm" +#include "code\game\objects\effects\spawners\random\pool\spawn_pool_manager.dm" #include "code\game\objects\effects\temporary_visuals\clockcult.dm" #include "code\game\objects\effects\temporary_visuals\cult_visuals.dm" #include "code\game\objects\effects\temporary_visuals\explosion_temp_visuals.dm"