API: Spawn pools, a distributed loot manager. (#27199)

* api: Spawn pools, a distributed loot manager.

* meh

* documentation and cleanups

* how do numbers work

* word wrapping

* fixes found from prototyping
This commit is contained in:
warriorstar-orion
2024-11-22 14:00:23 -05:00
committed by GitHub
parent cb1e9df8be
commit c4e4487452
8 changed files with 420 additions and 9 deletions
@@ -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()
@@ -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
@@ -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)
@@ -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 ..()
@@ -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)
+195
View File
@@ -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.
+1
View File
@@ -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'
+3
View File
@@ -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"