mirror of
https://github.com/ParadiseSS13/Paradise.git
synced 2026-07-17 10:03:50 +01:00
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:
committed by
GitHub
parent
cb1e9df8be
commit
c4e4487452
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user