Code/SboxDirector/OwnershipAllocator.cs

Two small utility classes for encounter/resource management. OwnershipCandidate is a simple record holding id, weight and eligibility. EncounterOwnershipAllocator picks an eligible candidate by weighted random using a provided DeterministicRandom. ResourceBudget tracks named float budgets, allows querying remaining, spending if affordable, and capturing a snapshot.

Reflection
#nullable enable annotations
namespace SboxDirector;

public sealed record OwnershipCandidate(string Id, float Weight = 1f, bool Eligible = true);

public sealed class EncounterOwnershipAllocator
{
    public string? Select(IReadOnlyList<OwnershipCandidate> candidates, DeterministicRandom random)
    {
        var eligible = candidates.Where(x => x.Eligible && x.Weight > 0 && !string.IsNullOrWhiteSpace(x.Id)).ToArray();
        if (eligible.Length == 0) return null;
        var total = eligible.Sum(x => x.Weight); var roll = random.NextFloat() * total;
        foreach (var candidate in eligible) { roll -= candidate.Weight; if (roll <= 0) return candidate.Id; }
        return eligible[^1].Id;
    }
}

public sealed class ResourceBudget
{
    readonly Dictionary<string, float> _remaining;
    public ResourceBudget(IReadOnlyDictionary<string, float> budgets) { _remaining = budgets.ToDictionary(x => x.Key, x => Math.Max(0, x.Value)); }
    public float Remaining(string category) => _remaining.TryGetValue(category, out var value) ? value : 0;
    public bool TrySpend(string category, float cost) { if (cost < 0) throw new ArgumentOutOfRangeException(nameof(cost)); if (Remaining(category) < cost) return false; _remaining[category] -= cost; return true; }
    public IReadOnlyDictionary<string, float> Capture() => new Dictionary<string, float>(_remaining);
}