Skip to content

Catching a loot tree that quietly awards nothing

Nested tables are where drop rates get expressive and where they get silently wrong. This walks the tree and tells you which branch is going to award nothing — before a player finds it for you.

Recipe

Systems required: Loot. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists. No scene, no prefab, no setup ritual. Public API only. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.

Why it is worth having

The roller already protects itself. It refuses to follow a cycle, refuses to descend past LootRoller.MaxNestingDepth, and says so on the way — through DevDiagnostics, which is [Conditional("UNITY_EDITOR")] and [Conditional("DEVELOPMENT_BUILD")].

In a release build those warnings do not exist

Not "are hidden" — the calls are compiled away. The branch simply awards nothing, on a table that looks perfectly reasonable in the inspector, and the bug reaches you as "the boss sometimes drops nothing" months later. This check logs through plain Debug.LogWarning for exactly that reason: it is meant to survive into the build where the roller has gone quiet.

The part that is not obvious

A table reached twice is not a cycle. The same "common junk" table legitimately hangs off several branches of a healthy tree, and that is good authoring, not a fault. Only a table that reaches itself is a cycle.

So the check tracks the current path, not a set of everything visited. The visited-set version is shorter, it is the one you write first, and it reports a false fault on every well-built tree that shares a sub-table. The roller makes the same distinction for the same reason; this agrees with it deliberately.

The depth rule is borrowed, not restated. MaxNestingDepth is public, and the comparison here is the roller's own. A check that has drifted from the thing it checks is worse than no check, because it gets believed — it will pass trees that get truncated and fail trees that roll perfectly well.

What it reports

Fault Why it matters
A cycle The roller stops; that branch awards nothing
Nesting past MaxNestingDepth Same — it stops descending, silently in a release build
A Table entry with nothing assigned Consumes a pick and awards nothing
A weighted table where every weight is zero Can never award anything at all
An Item or Currency entry with no id Rolls, wins, delivers nothing
An inverted quantity range Not fatal — bounds are applied on read — but it awards the minimum rather than the range you meant

The two faults you cannot find by eye carry the whole path, because "there is a cycle somewhere" is not actionable and Boss -> Rare -> Trinkets -> Rare is. Those are the cycle and the too-deep-to-reach branch — the ones where the fault is the route rather than the table.

Every other message names the table and the entry index instead, which is what you need to go straight to it: the fault is in that entry, and how you arrived there does not change the fix.

Drop it in

LootTableTreeCheck.cs
using System.Collections.Generic;
using System.Text;

using RevGaming.RevFramework.Loot.Core;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.LootTableTreeCheck
{
    /// <summary>
    /// Walks a tree of nested loot tables and reports the faults that would otherwise show up as a
    /// drop that quietly never happens.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Loot</b> only — which ships in the <i>Inventory, Pickups &amp; Crafting</i> package and in
    /// <i>Complete</i>, since no package is named after it. Public API only.</para>
    ///
    /// <para><b>Why this is worth having.</b> The roller already refuses to follow a cycle or to
    /// descend past <see cref="LootRoller.MaxNestingDepth"/>, and it says so — through
    /// <c>DevDiagnostics</c>, which is <c>[Conditional("UNITY_EDITOR")]</c> and
    /// <c>[Conditional("DEVELOPMENT_BUILD")]</c>. In a release player build those warnings do not
    /// exist. The branch simply awards nothing, on a table that looks perfectly reasonable in the
    /// inspector, and the bug arrives as "the boss sometimes drops nothing". Three of the faults below
    /// are worse than that: an empty table, a weighted table where everything weighs zero, and an
    /// independent table where every chance is zero all award nothing, and rolling one warns in
    /// <i>no</i> build, the Editor included, because the roller returns from all three silently. The
    /// Loot Debugger window flags the zero-weight case if you happen to open it on that table; the
    /// other two are visible nowhere until the drop does not arrive.</para>
    ///
    /// <para><b>The subtle part is what counts as a cycle.</b> A table reached twice is not a fault —
    /// the same "common junk" table legitimately hangs off several branches. Only a table that reaches
    /// <i>itself</i> is a cycle, so the check has to track the current <b>path</b> rather than a set of
    /// everything visited. A visited-set implementation is the obvious one, it is shorter, and it
    /// reports false faults on every well-built table tree that shares a sub-table. The roller has the
    /// same rule and the same comment; this agrees with it deliberately.</para>
    ///
    /// <para><b>The depth rule is taken from the roller, not restated.</b>
    /// <see cref="LootRoller.MaxNestingDepth"/> is public, and the comparison here is the one the
    /// roller makes — a check that drifts from the thing it is checking is worse than no check, because
    /// it is believed.</para>
    ///
    /// <para><b>Shared sub-tables are walked — and reported — once per branch that reaches them.</b>
    /// One fault in a widely shared table therefore produces one line per reaching branch, and the
    /// returned number counts reports rather than distinct faults. That is the price of path-based
    /// cycle detection and it is fine for hand-authored trees. Do not pay it off by memoising the
    /// traversal: whether an entry is a cycle depends on the path that reached it and not on where the
    /// table sits, so a memo keyed on the table — with or without the depth it was reached at — skips
    /// branches that would have reported a different cycle, and it does so only on trees that have one.
    /// If the width matters, de-duplicate the emitted messages instead and leave the walk alone.</para>
    ///
    /// <para>Run it from a boot scene, a test, or an editor button — including in a release build,
    /// which is the case it exists for. It allocates only while it runs and touches nothing.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class LootTableTreeCheck : MonoBehaviour
    {
        [Tooltip("Root tables to check. Nested tables are followed automatically.")]
        [SerializeField] private List<LootTable> tables = new();

        [Tooltip("Check on Start and log what is found, release builds included. Off leaves it to your own call.")]
        [SerializeField] private bool checkOnStart = true;

        private readonly List<string> _problems = new();

        private void Start()
        {
            if (!checkOnStart)
                return;

            int found = Validate(_problems);

            if (found == 0)
                return;

            var sb = new StringBuilder();
            sb.Append('[').Append(nameof(LootTableTreeCheck)).Append("] ").Append(found)
              .Append(found == 1 ? " problem found:" : " problems found:");

            for (int i = 0; i < _problems.Count; i++)
                sb.AppendLine().Append("  - ").Append(_problems[i]);

            // A plain LogWarning rather than DevDiagnostics: the whole point is that this survives into
            // a build, where the roller's own warnings have been compiled away.
            Debug.LogWarning(sb.ToString(), this);
        }

        /// <summary>
        /// Checks every assigned root and fills <paramref name="problems"/> with what it found.
        /// </summary>
        /// <param name="problems">
        /// Receives one line per problem. <b>Cleared first</b> — call <see cref="Inspect"/> instead to
        /// accumulate into a list that already holds something.
        /// </param>
        /// <returns>How many problems were found. Zero means the trees are sound.</returns>
        /// <exception cref="System.ArgumentNullException">
        /// <paramref name="problems"/> is null. Returning zero would report "the trees are sound" for a
        /// check that never ran, which is the failure this recipe exists to make impossible.
        /// </exception>
        public int Validate(List<string> problems)
        {
            if (problems == null)
                throw new System.ArgumentNullException(nameof(problems));

            problems.Clear();

            // A check with nothing to check must not return the value that means "sound".
            if (tables.Count == 0)
                problems.Add("No root tables are assigned, so nothing was checked.");

            for (int i = 0; i < tables.Count; i++)
                Inspect(tables[i], problems);

            return problems.Count;
        }

        /// <summary>
        /// Checks one table and everything it reaches.
        /// </summary>
        /// <remarks>
        /// Static and public so a test or an editor tool can call it without a scene object.
        /// </remarks>
        /// <param name="root">Table to start from. Null is reported rather than ignored.</param>
        /// <param name="problems">Receives one line per problem. Not cleared.</param>
        /// <returns>How many problems this table and its children contributed.</returns>
        /// <exception cref="System.ArgumentNullException">
        /// <paramref name="problems"/> is null. A test that asserts this returns zero has to fail on an
        /// uninitialised list rather than pass, or it asserts nothing at all.
        /// </exception>
        public static int Inspect(LootTable root, List<string> problems)
        {
            if (problems == null)
                throw new System.ArgumentNullException(nameof(problems));

            int before = problems.Count;

            if (root == null)
            {
                problems.Add("A root table slot is empty.");
                return problems.Count - before;
            }

            var path = new List<LootTable>(8);
            Walk(root, 0, path, problems);

            return problems.Count - before;
        }

        private static void Walk(LootTable table, int depth, List<LootTable> path, List<string> problems)
        {
            path.Add(table);

            try
            {
                IReadOnlyList<LootEntry> entries = table.Entries;

                // The three ways a whole table awards nothing. The roller returns from all three
                // silently -- there is no DevDiagnostics call on any of them -- so unlike the entry
                // faults below, rolling one warns in no build, the Editor included.
                if (entries.Count == 0)
                    problems.Add($"'{table.name}' has no entries, so any branch that reaches it awards nothing.");
                else if (table.Mode == LootTableMode.Weighted && table.TotalWeight <= 0f)
                    problems.Add($"'{table.name}' is weighted but every entry weighs zero, so it can never award anything.");
                else if (table.Mode == LootTableMode.IndependentChance && EveryChanceIsZero(entries))
                    problems.Add($"'{table.name}' tests every entry independently but every chance is zero, so it can never award anything.");

                for (int i = 0; i < entries.Count; i++)
                {
                    LootEntry entry = entries[i];

                    if (entry.kind != LootEntryKind.Table)
                    {
                        CheckLeaf(table, entry, i, problems);
                        continue;
                    }

                    if (entry.nested == null)
                    {
                        // Not "it consumes a pick": that is true in Weighted mode and meaningless in
                        // IndependentChance, which has no pick budget to spend.
                        problems.Add($"'{table.name}' entry {i} is a Table entry with nothing assigned. " +
                                     "It is a dead entry and awards nothing.");
                        continue;
                    }

                    // Path membership, not a visited set: the same table hanging off two branches is
                    // fine, and only a table that reaches itself is a cycle.
                    if (path.Contains(entry.nested))
                    {
                        problems.Add($"'{table.name}' entry {i} reaches '{entry.nested.name}', which is " +
                                     $"already on the path: {Describe(path)} -> {entry.nested.name}. " +
                                     "The roller stops here and this branch awards nothing.");
                        continue;
                    }

                    // The roller's own comparison, made against its own constant. Drifting from it would
                    // mean passing tables it truncates, or failing tables it rolls perfectly well.
                    if (depth + 1 >= LootRoller.MaxNestingDepth)
                    {
                        problems.Add($"'{table.name}' entry {i} nests deeper than " +
                                     $"{LootRoller.MaxNestingDepth} tables: {Describe(path)} -> " +
                                     $"{entry.nested.name}. The roller stops descending and this branch " +
                                     "awards nothing.");
                        continue;
                    }

                    Walk(entry.nested, depth + 1, path, problems);
                }
            }
            finally
            {
                path.RemoveAt(path.Count - 1);
            }
        }

        /// <summary>
        /// Whether no entry can pass its independent roll. The roller clamps, so a negative chance is a
        /// zero chance -- the twin of the zero-weight case, and just as silent.
        /// </summary>
        private static bool EveryChanceIsZero(IReadOnlyList<LootEntry> entries)
        {
            for (int i = 0; i < entries.Count; i++)
            {
                if (entries[i].chance01 > 0f)
                    return false;
            }

            return true;
        }

        private static void CheckLeaf(LootTable table, in LootEntry entry, int index, List<string> problems)
        {
            if (entry.kind == LootEntryKind.Item && string.IsNullOrWhiteSpace(entry.itemGuid))
                problems.Add($"'{table.name}' entry {index} is an Item entry with no item assigned.");

            if (entry.kind == LootEntryKind.Currency && string.IsNullOrWhiteSpace(entry.currencyId))
                problems.Add($"'{table.name}' entry {index} is a Currency entry with no currency id.");

            // Not fatal -- reading applies the bounds -- but an inverted range in the inspector is
            // almost always a typo, and it awards the minimum rather than the range that was meant.
            if (entry.kind != LootEntryKind.Nothing && entry.quantityMax < entry.quantityMin)
                problems.Add($"'{table.name}' entry {index} has its quantity range inverted " +
                             $"({entry.quantityMin}..{entry.quantityMax}).");
        }

        /// <summary>Renders the current path for a message, so a fault can actually be found.</summary>
        private static string Describe(List<LootTable> path)
        {
            var sb = new StringBuilder();

            for (int i = 0; i < path.Count; i++)
            {
                if (i > 0) sb.Append(" -> ");
                sb.Append(path[i] ? path[i].name : "(null)");
            }

            return sb.ToString();
        }
    }
}

Wiring it up

  1. Put the component in a boot scene and assign your root tables. Nested tables are followed automatically.
  2. Leave checkOnStart on wherever you would notice the log — a development build, or CI. The point is to run it before the build where the fault is silent, not in it: the roller's own warnings are Editor-only, so a tree that awards nothing complains in the Editor and says nothing at all in a player. Turn it off and call Validate(problems) yourself if you would rather own when it runs.
  3. Or skip the component: LootTableTreeCheck.Inspect(table, problems) is static, so a test or an editor tool can call it with no scene at all.

A test that asserts Inspect returns zero for every shipped table is a good use of ten lines.

What it deliberately does not do

It does not fix anything. It reports paths and leaves the decision to you — a cycle might mean a wrong reference, or it might mean the tree wants restructuring, and only you know which.

It does not judge your odds. A one-in-a-million drop is not a fault, and this will not tell you your rare tier is unreachable in practice. It answers "can this branch ever award", not "will it".

It does not memoise. Shared sub-tables are walked once per branch that reaches them, which is the price of path-based cycle detection. Fine for hand-authored trees; if yours are wide enough for it to matter, memoise on the table and the depth it was reached at.