ZOA Loot System
Weighted tables, affix pools, containers, and the one interactable that covers both AAA loot idioms.
Loot answers three questions that are usually tangled together, and it answers them in separate layers. What could drop is a table. What actually dropped this time is a roll. How the player receives it is an interactable. Because those layers stay apart, one authored table can feed a chest the player browses, a crate that empties straight into the bag, and a corpse the AI package built at runtime.
The core layer is plain C# and engine-free. `ZOA.Loot.Core` holds the ids, the entry and result models, the registry, the roller, and the container service, and every one of them is constructible in a NUnit test with no scene. The Unity layer lands that core in a project: authoring ScriptableObjects, a scene installer that registers the services, a content-source strategy, the interactable, and the transport shims for six networking backends.
There are two rolling algorithms in this package and confusing them is the one real trap. The pool model (`LootTableDefinitionAsset` plus `LootRoller`) picks one weighted entry per roll and is what containers use. The chance model (`LootDropTableDefinition` plus `LootDropper`) rolls each entry independently against its own `DropChance`, so a single resolution can yield several entries or none. Containers want the first. Enemy and wave drops want the second.
Depends on (6)
Depended on by (4)
How it works
Concepts
A table is a weighted pool plus a guaranteed floor
`ILootTableDefinition` carries four lists. `Entries` is the weighted pool: each `LootEntry` names an item definition id, a `Weight`, a quantity range, a `LootRarity`, and an optional condition list. `GuaranteedDrops` is a parallel list applied unconditionally. `AffixPool` is a list of `AffixId` values the roller may stamp onto drops. `Tags` classify the table for filtering.
`LootRoller.Roll(tableId, rollCount, luckModifier)` resolves the table from `ILootTableRegistry`, emits every guaranteed drop first, then performs `rollCount` independent weighted picks over `Entries`. Each pick is a single entry, so a table with three rolls and one guaranteed drop yields four items. Quantity is drawn per drop from the entry's min and max, inclusive.
The luck modifier is narrow. It multiplies the weight of entries whose rarity is `Rare` or above, and leaves Common and Uncommon weights alone. That means luck shifts the shape of the pool toward the good end rather than inflating everything uniformly, and a luck of 1.0 reproduces the authored distribution exactly.
Determinism is a constructor argument, not a global. `new LootRoller(registry, seed)` gives a repeatable sequence for tests and replays; the parameterless seed leaves the underlying `System.Random` unseeded.
Affixes are ids on the drop, not stats applied by the roller
When a table has a non-empty `AffixPool`, the roller attaches between zero and three affix ids to every drop it produces, sampling the pool with replacement. Those ids land on `LootDropItem.AppliedAffixes` and travel with the drop.
The roller stops there. `AffixDefinition` describes what an affix means, with an `AffixType` of Prefix, Suffix, or Implicit, a list of `AffixStatMod` records carrying an additive and a multiplicative term per stat id, a rarity, tags, and an `IsStackable` flag. Nothing in this package resolves an `AffixId` back to an `AffixDefinition` or applies those stat mods to an item, because the item and its stat model belong to inventory and armory, not to loot.
Treat the affix pool as a tagging mechanism that the roller populates and your item-instantiation code consumes. If you need stackability enforced or a weighted affix selection, that logic lives on your side of the `AffixId` boundary.
Containers own lifecycle; the roller owns randomness
`LootContainerService` is constructed with an `ILootRoller` and holds two dictionaries: registered `LootContainerDefinition` records and their live `ContainerState`. Opening a container is a single call, `OpenContainer(containerId, entityId)`, which gates on availability, rolls the container's table with `MaxItems` as the roll count, stamps the state, and raises `ContainerOpened`.
Availability has three inputs. An exhausted container is never available. A container with `RespawnTimeSeconds` above zero becomes available again once that many seconds of wall-clock time have elapsed since the last open, evaluated lazily inside `IsContainerAvailable` rather than on a timer. A container that is `IsOneTimeOnly`, or whose respawn time is zero, is marked exhausted immediately after a successful open and raises `ContainerExhausted`.
That last rule is the most common authoring surprise: on `LootContainerDefinitionAsset`, a respawn time of zero does not mean instant respawn, it means never respawns. The service treats it as one-shot so a save and reload cannot roll the same cache twice.
State is snapshot-friendly. `GetAllContainerStates` returns clones rather than live references, and `RestoreContainerState` applies a captured state back onto a registered container id, refusing states whose container was never registered.
One interactable, two idioms, pluggable content
`LootInteractable` is the single world-placed loot surface, and it covers both AAA delivery shapes through a serialized `LootInteractionMode`. `DropStyle` grants everything into the collector's bag the instant the object is opened, with no panel: the looter-shooter idiom. `BrowseStyle` opens an inventory panel the player drags items out of, granting only what is taken: the RPG and survival idiom.
What is inside is a separate axis, authored polymorphically through a `[SerializeReference] ILootContentSource`. `ContainerLootContentSource` rolls a `LootContainerDefinitionAsset` through the registered `ILootContainerService`. `InventoryLootContentSource` binds a live `InventoryComponent`, which is the corpse-loot case: the bag is the truth, items leave it as the player takes them, and `CanOpen` goes false once it is empty so a drained body stops prompting.
The two axes combine cleanly. A container source in BrowseStyle has no live bag to bind, so it returns false from `TryProvideBrowseInventory` and the interactable materialises a transient inventory from the roll instead; what the player leaves behind is discarded on close. An inventory source in DropStyle snapshots the bag into a `LootDropResult` and empties it into the collector.
Bus discipline is uniform across every combination. Interact completion raises once per open. Item-collected and the `LootDelivery.Granted` fan-out fire per item actually delivered: all at once at open time for DropStyle, per drag-out for BrowseStyle. So the objective and analytics view of "loot reached a player" is identical whichever idiom a given crate uses.
Every grant funnels through one static hub
`LootDelivery` is a static event carrying a `LootDelivery.Grant` struct: item definition id, quantity, collector GameObject, collector entity id, and a diagnostic source label. Every granter in the product raises through it, and `LootDelivery.Raise` wraps each subscriber individually in a try/catch so one throwing listener cannot strand the others mid-grant.
`LootGrantInventoryAdapter` is the one canonical subscriber that mutates a bag. It resolves the player inventory through `IInventoryService` first and falls back to walking up from the collector for an `InventoryComponent`, then calls `TryAddQuantity`. It also subscribes to `ItemPickup.Granted` from the inventory package and forwards those through the same path, so ground pickups and crate loot take the same code route.
The hub is a static event rather than a service. Grants happen on paths that already exist (trigger enter, interact, corpse spawn) and the listener set is tiny, so the registry resolve would buy nothing. The adapter is ensured into every loot-bearing scene idempotently by `ZOALootSceneInstaller`, and it owns its subscription through OnEnable and OnDisable so re-entering play mode cannot double-subscribe.
One filter is worth knowing about. Grants whose source label begins with `loot-browse-observed:` are skipped by the adapter, because a browse-panel drag already moved the item directly; those events exist only so objectives and analytics still see the delivery.
Chance-model drops for enemies and waves
`LootDropper` is a MonoBehaviour that resolves `LootDropTableDefinition` assets, where every `LootDropEntry` carries its own `DropChance` in the range zero to one. Resolution walks the entries, multiplies each chance by the component's `DropChanceMultiplier`, clamps to one, and rolls independently, so a resolution can produce several entries or none at all.
It exposes three entry points. `DropFromTable` resolves an explicit table. `DropWaveLoot` treats the wave number as a one-indexed lookup into an ordered wave-table list, clamping to the last table past the end. `DropEnemyLoot` looks up a table by the killed agent's type id and returns empty when no table is mapped, which is the intended behaviour for enemy types that should not drop.
The dropper never instantiates anything. It publishes: a C# `OnLootResolved` event always, and a `LootDroppedEvent` on the messaging bus when one has been installed through `SetEventBus`. A spawner or demo bridge subscribes and materialises the physical container. Randomness is injectable through `SetRandom`, with `UnityEngineLootRandom` as the production default and `SeededLootRandom` for deterministic tests.
In the editor
Screens
Screenshot pending
/screenshots/loot-workbench-tables.png
The Workbench window with Loot selected in the module list, the capability strip showing Loot Tables, Containers, and Overview, and the Loot Tables capability open with several table assets listed and one selected so its entries, weights, and rarities are visible in the inspector pane.
Screenshot pending
/screenshots/loot-table-wizard-entries.png
The wizard shell with its five-step rail (Identity, Entries, Guaranteed, Rolls, Review) visible, the Entries step showing three or four rows filled in with item ids, weights, quantity ranges, and mixed rarity values including at least one Epic.
Screenshot pending
/screenshots/loot-browse-panel.png
In-game view with the loot browse panel open over the scene, titled from the content source's display name, showing several stacked items the player can drag into their own inventory, with the interact prompt visible behind it.
Setup
Workflow
- 01
Author a table
Create a LootTableDefinitionAsset from Assets > Create > Tools > ZOA > Loot > Loot Table, or step through the Loot Table Wizard. Give it a stable slug, add weighted entries keyed by item definition id, set the roll count, and add any guaranteed drops. Weights are relative to their siblings, so there is nothing to normalise.
- 02
Wrap the table in a container
Create a LootContainerDefinitionAsset, point it at the table, and set the lifecycle. MaxItems is passed straight through as the roll count. Respawn seconds above zero makes the container re-openable after that delay; zero, or ticking One Time Only, makes it a one-shot that exhausts on first open.
- 03
Install the services in the scene
Add a ZOALootSceneInstaller to a scene root, then drop your tables into Tables To Register and your containers into Containers To Register. On Awake it registers ILootTableRegistry, ILootRoller, and ILootContainerService against FoundryServiceRegistry and ensures the grant adapter. Tick Dont Destroy On Load when the loot services should survive scene changes.
- 04
Place the interactable
Add a LootInteractable to the world object, pick DropStyle or BrowseStyle, and assign a content source. For a crate that is a ContainerLootContentSource pointing at the container asset. For BrowseStyle with a container source, also assign the Browse Inventory field: the rolled items are pushed into that inventory on open and cleared on close.
- 05
Consume grants
The canonical inventory routing already happens through LootGrantInventoryAdapter. Subscribe to LootDelivery.Granted yourself only for the extras: toast UI, audio cues, analytics. Never write to the bag from a second subscriber, or a browse-panel take will be counted twice.
- 06
Add enemy and wave drops if you need them
For kill and wave rewards, author LootDropTableDefinition assets, put a LootDropper in the scene, map agent type ids to tables, and call DropEnemyLoot or DropWaveLoot from your death or wave-clear handler. Subscribe to OnLootResolved to spawn whatever physical pickup or crate the drop should become.
Surface
Key types
ILootTableDefinition
interface
The read side of an authored table: weighted entries, guaranteed drops, an affix pool, and classification tags.
- LootTableId Id { get; }
- string DisplayName { get; }
- IReadOnlyList<LootEntry> Entries { get; }
- IReadOnlyList<LootEntry> GuaranteedDrops { get; }
- IReadOnlyList<AffixId> AffixPool { get; }
- IReadOnlyList<string> Tags { get; }
ILootTableRegistry
interface
Id-keyed store of table definitions. The roller resolves through it, so a table can be authored, generated, or built in code without the roller caring which.
- void Register(ILootTableDefinition table)
- void Unregister(LootTableId tableId)
- bool TryGet(LootTableId tableId, out ILootTableDefinition table)
- IReadOnlyCollection<ILootTableDefinition> GetAll()
- event Action<ILootTableDefinition> TableRegistered
- event Action<LootTableId> TableUnregistered
ILootRoller
interface
The single rolling entry point for the pool model. One call takes a table id, a roll count, and a luck modifier and returns everything that dropped.
- LootDropResult Roll(LootTableId tableId, int rollCount = 1, float luckModifier = 1.0f)
LootRoller
class
Default roller. Emits guaranteed drops first, then performs rollCount weighted picks, scaling weights by the luck modifier only for entries at Rare or above. Takes an optional seed for determinism.
- LootRoller(ILootTableRegistry tableRegistry, int? seed = null)
ILootContainerService
interface
Owns container registration, availability, opening, and exhaustion. Also the save seam: states can be enumerated and restored wholesale.
- void RegisterContainer(LootContainerDefinition containerDef)
- LootDropResult OpenContainer(LootContainerId containerId, string entityId)
- bool IsContainerAvailable(LootContainerId containerId)
- ContainerState GetContainerState(LootContainerId containerId)
- IReadOnlyCollection<ContainerState> GetAllContainerStates()
- bool RestoreContainerState(ContainerState state)
- event Action<LootContainerId, string> ContainerOpened
- event Action<LootContainerId> ContainerExhausted
ContainerState
class
Per-container runtime state: availability, who opened it last, when, and whether it is exhausted. Handed out as clones so a caller cannot mutate service state by accident.
- LootContainerId ContainerId { get; set; }
- bool IsAvailable { get; set; }
- string LastOpenedBy { get; set; }
- double LastOpenedTime { get; set; }
- bool IsExhausted { get; set; }
LootEntry
class
One row of a table. Item id, selection weight, quantity range, rarity tier, and an optional condition id list.
- string ItemDefinitionId { get; set; }
- float Weight { get; set; }
- int MinQuantity { get; set; }
- int MaxQuantity { get; set; }
- LootRarity Rarity { get; set; }
- List<string> Conditions { get; set; }
LootDropResult
class
What a roll produced: a list of LootDropItem records, each with an item id, a quantity, a rarity, and the affix ids the roller stamped on it.
- List<LootDropItem> Items { get; set; }
LootTableBuilder
class
Fluent construction of a LootTableDefinition in code. The path bootstraps and tests use when there is no asset to author against.
- LootTableBuilder AddEntry(string itemId, float weight, int minQuantity, int maxQuantity, LootRarity rarity = LootRarity.Common)
- LootTableBuilder AddGuaranteedDrop(string itemId, int quantity, LootRarity rarity = LootRarity.Common)
- LootTableBuilder WithAffixPool(List<AffixId> affixIds)
- LootTableBuilder AddTag(string tag)
- LootTableDefinition Build()
AffixDefinition
class
Describes an affix: prefix, suffix, or implicit, with a list of AffixStatMod records, a rarity, tags, and a stackability flag. Data only; this package never applies it.
- AffixId Id { get; set; }
- AffixType AffixType { get; set; }
- List<AffixStatMod> StatModifications { get; set; }
- LootRarity Rarity { get; set; }
- bool IsStackable { get; set; }
LootRarity
enum
Common, Uncommon, Rare, Epic, Legendary, Mythic. The roller only branches on it for the luck modifier, which applies from Rare upward.
LootInteractionMode
enum
DropStyle grants everything silently on open with no panel. BrowseStyle opens an inventory panel and grants only what the player takes.
ILootContentSource
interface
The strategy that decides what a LootInteractable yields. Implementations are pure data adapters: they resolve contents and never publish to the objective or grant buses themselves.
- string InteractableId { get; }
- string DisplayName { get; }
- bool CanOpen { get; }
- LootDropResult Resolve(string collectorEntityId)
- bool TryProvideBrowseInventory(out InventoryComponent inventory)
- void OnBrowseClosed()
LootInteractable
component
The world-placed loot surface. Implements IZOACloseable so the interact key toggles a browse panel open and closed, and exposes replay entry points the network shims drive.
- LootInteractionMode Mode { get; set; }
- ILootContentSource Source { get; }
- bool IsOpen { get; }
- bool TryOpen(GameObject collector)
- bool TryClose()
- void SetContentSource(ILootContentSource source)
- static event Action<LootInteractable, GameObject, LootContainerId, LootDropResult> Granted
LootDelivery
class
The static fan-out every granter raises through. One event, one canonical inventory subscriber, and per-subscriber exception isolation on publish.
- static event Action<Grant> Granted
- static void Raise(in Grant grant)
- readonly struct Grant(string itemDefinitionId, int quantity, GameObject collector, string collectorEntityId, string sourceLabel)
LootGrantInventoryAdapter
component
The single grant-to-inventory router. Subscribes to LootDelivery.Granted and ItemPickup.Granted, resolves the player inventory, and calls TryAddQuantity. One per scene, ensured idempotently.
- static LootGrantInventoryAdapter EnsureInScene(Transform parent = null)
ZOALootSceneInstaller
component
Scene-root bootstrap at execution order -9300. Registers the registry, roller, and container service, registers the authored tables and containers listed on it, and ensures the grant adapter. Unregisters its own instances on destroy.
- void EnsureRegistered()
- ILootTableRegistry TableRegistry { get; }
- ILootRoller Roller { get; }
- ILootContainerService ContainerService { get; }
LootDropper
component
The chance-model counterpart to LootRoller. Resolves independent per-entry drop chances and publishes the result; it never spawns the physical container itself.
- IReadOnlyList<ResolvedDrop> DropFromTable(LootDropTableDefinition table, Vector3 position, string sourceIdOverride = null)
- IReadOnlyList<ResolvedDrop> DropWaveLoot(Vector3 position, int waveNumber)
- IReadOnlyList<ResolvedDrop> DropEnemyLoot(Vector3 position, string agentTypeId)
- void SetRandom(ILootRandom random)
- void SetEventBus(IFoundryEventBus bus)
- event Action<Vector3, string, IReadOnlyList<string>, IReadOnlyList<int>> OnLootResolved
ILootRandom
interface
Random source the dropper consumes. UnityEngineLootRandom is the production default; SeededLootRandom wraps System.Random for deterministic tests.
- float NextValue()
- int NextInt(int minInclusive, int maxInclusive)
LootDroppedEvent
struct
Readonly struct published on the messaging bus when a dropper resolves a non-empty set. Carries the world position, a source id, and parallel arrays of item ids and quantities.
- Vector3 Position
- string SourceId
- string[] ItemDefinitionIds
- int[] Quantities
ILootBrowseUiSurface
interface
The seam between loot and whatever hosts the browse panel. Loot declares the contract and resolves it from the registry; the equipment package's inventory screen registers itself as the implementation, so the dependency never runs the other way.
- void OpenBrowse(InventoryComponent inventory, string title)
- void CloseBrowse(InventoryComponent inventory)
- event Action<InventoryComponent> BrowseClosed
- bool IsBrowseOpen { get; }
BrowsableLootBuilder
class
Turns a concrete item set into a browsable loot pile: ensure a configured inventory on a host, fill it with real GameDatabase items, and wrap it in a BrowseStyle LootInteractable. The path corpse loot and demo piles share.
- static InventoryComponent EnsureLootInventory(GameObject host, InventoryDefinition definitionOverride = null)
- static int PopulateFromDatabase(InventoryComponent inventory, int rolls, int maxStackCap = 3)
- static LootInteractable AttachBrowsableInteractable(...)
- static LootInteractable BuildDatabaseLootPile(...)
RewardContainerProp
component
A lootable container registered as a world prop. Adds Locked gating, id-addressed actuation, optional self-fill on first open, and Depleted bookkeeping on top of a sibling LootInteractable.
- LootInteractable Loot { get; }
- void SetLocked(bool locked)
- override string Kind => "reward_container"
Surface
Authoring assets
LootTableDefinitionAsset
asset
The pool-model authoring asset, created from Assets > Create > Tools > ZOA > Loot > Loot Table. Holds a stable table id, a roll count, the weighted entry list, the guaranteed list, and an affix id pool. Tags come from DefinitionBase rather than a local field.
- string TableId
- int Rolls
- IReadOnlyList<SerializedLootEntry> Entries
- IReadOnlyList<SerializedLootEntry> GuaranteedDrops
- IReadOnlyList<string> AffixPool
- LootTableDefinition ToRuntimeDefinition()
LootContainerDefinitionAsset
asset
Pairs a container id with a table reference and a lifecycle policy: max items per open, respawn seconds, one-time-only, and an optional key id. Created from Assets > Create > Tools > ZOA > Loot > Loot Container.
- string ContainerId
- LootTableDefinitionAsset Table
- int MaxItems
- float RespawnTimeSeconds
- bool OneTimeOnly
- string RequiresKey
- LootContainerDefinition ToRuntimeDefinition()
LootDropTableDefinition
asset
The chance-model table, created from Assets > Create > ZOA > Gameplay > Loot Drop Table. Each entry carries its own DropChance in zero to one, so several entries can resolve in a single drop. Consumed by LootDropper, never by LootRoller.
- string TableId { get; set; }
- IReadOnlyList<LootDropEntry> Entries
- int EntryCount
- void AddEntry(LootDropEntry entry)
- void ClearEntries()
Usage
Examples
using ZOA.Loot.Core;
var registry = new LootTableRegistry();
var table = new LootTableBuilder(new LootTableId("boss_drop"), "Boss Drop")
.AddGuaranteedDrop("med.bandage", quantity: 2)
.AddEntry("ammo.medium", weight: 5f, minQuantity: 6, maxQuantity: 12)
.AddEntry("weapon.rifle.marksman", weight: 1f, minQuantity: 1, maxQuantity: 1, LootRarity.Epic)
.WithAffixPool(new List<AffixId> { new AffixId("scorched"), new AffixId("reinforced") })
.AddTag("boss")
.Build();
registry.Register(table);
// Seeded so the same sequence comes back every run.
var roller = new LootRoller(registry, seed: 1337);
// Three weighted picks plus the guaranteed bandages, with luck
// biasing only the Rare-and-above entries.
LootDropResult result = roller.Roll(new LootTableId("boss_drop"), rollCount: 3, luckModifier: 1.5f);
foreach (var item in result.Items)
Debug.Log(item.ItemDefinitionId + " x" + item.Quantity + " (" + item.Rarity + ")");using ZOA.Loot.Core;
using ZOA.Messaging;
var containers = FoundryServiceRegistry.Get<ILootContainerService>();
containers.ContainerExhausted += id =>
Debug.Log("Cache " + id.Value + " is spent; hide the prompt.");
var cacheId = new LootContainerId("starter_cache.alpha");
if (containers.IsContainerAvailable(cacheId))
{
// Null means the open was refused between the probe and the call.
LootDropResult drop = containers.OpenContainer(cacheId, entityId: "player.local");
if (drop != null)
{
foreach (var item in drop.Items)
Debug.Log("Looted " + item.ItemDefinitionId + " x" + item.Quantity);
}
}using System.Collections.Generic;
using ZOA.Loot.Core;
// Capture: states come back as clones, safe to hold and serialise.
IReadOnlyCollection<ContainerState> snapshot = containers.GetAllContainerStates();
// Restore, after the same containers have been registered again.
foreach (var state in snapshot)
{
// Returns false when the id is not registered, which is the
// signal that the save is ahead of the scene's authored content.
if (!containers.RestoreContainerState(state))
Debug.LogWarning("No registered container for " + state.ContainerId.Value);
}using UnityEngine;
using ZOA.Inventory.Unity.Components;
using ZOA.Loot.Unity.Interactables;
using ZOA.Loot.Unity.Interactables.LootContentSources;
// The AI package does this when an agent dies: the bag stays the
// source of truth, so what the player leaves behind is still there
// on the second interact.
InventoryComponent bag = BrowsableLootBuilder.EnsureLootInventory(corpse);
BrowsableLootBuilder.PopulateFromDatabase(bag, rolls: 4);
var interactable = corpse.AddComponent<LootInteractable>();
interactable.Mode = LootInteractionMode.BrowseStyle;
interactable.SetContentSource(new InventoryLootContentSource(
bag,
displayName: "Marine",
interactableId: "corpse:grunt_011"));using UnityEngine;
using ZOA.Loot.Unity.Drop;
using ZOA.Messaging;
public sealed class EnemyDeathLootBridge : MonoBehaviour
{
[SerializeField] private LootDropper dropper;
private void Awake()
{
dropper.SetEventBus(FoundryServiceRegistry.Get<IFoundryEventBus>());
dropper.OnLootResolved += SpawnPile;
}
public void OnAgentKilled(string agentTypeId, Vector3 position)
{
// Empty result when the agent type has no mapped table.
dropper.DropEnemyLoot(position, agentTypeId);
}
private void SpawnPile(
Vector3 position,
string sourceId,
IReadOnlyList<string> itemIds,
IReadOnlyList<int> quantities)
{
// Materialise the physical container here; the dropper
// deliberately does not instantiate anything itself.
}
}Tooling
Editor tools
Loot Workbench module
Tools > ZOA > Workbench > Open Workbench, then Loot
Three capabilities under the Items and Economy workflow: a Loot Tables browser and editor, a Containers browser and editor, and an Overview describing the drop pipeline. Both browsers create, validate, duplicate, and delete definition assets in place.
Loot Table Wizard
Tools > ZOA > Advanced > Define > Items > Loot > Loot Table Wizard
A five-step guided authoring flow: identity, weighted entries, guaranteed drops, roll count, and review. The menu entry routes into the Workbench's loot module rather than opening a floating window. Assets land under Assets/ZOA/Generated/LootTables.
Bundled loot tables
Tools > ZOA > Advanced > Generate > Loot > Bundled Tables > Ensure Assets
Emits three baseline tables, loot.common_cache, loot.uncommon_cache, and loot.boss_drop, under Assets/ZOA/Generated/Loot/Tables. Idempotent: re-running detects existing assets and skips them.
Loot scene installer utility
ZOALootSceneInstallerUtility.InstallOrUpdateInActiveScene creates or finds the ZOA Loot root and its ZOALootSceneInstaller, with options for the root name, parent, Dont Destroy On Load, and selecting the object afterwards. Scene scaffolding calls it directly; there is no menu item.
Read this
Notes and caveats
See also