Code/SboxDirector/EncounterSchedules.cs

Encounter scheduling system. Defines EncounterRule and timing policies, and implements several IEncounterSchedule types (rule-based, milestone, composite) with state capture/restore and selection logic for spawning encounters.

File Access
#nullable enable annotations
namespace SboxDirector;

public sealed class EncounterRule
{
    public string Id { get; init; } = "rule";
    public EncounterKind Kind { get; init; }
    public int Count { get; init; } = 1;
    public int Priority { get; init; }
    public double CooldownSeconds { get; init; } = 10;
    public float MinimumPressure { get; init; }
    public float MaximumPressure { get; init; } = 1f;
    public float Probability { get; init; } = 1f;
    public IReadOnlySet<TempoPhase> Phases { get; init; } = new HashSet<TempoPhase> { TempoPhase.BuildUp, TempoPhase.SustainPeak };
    public IReadOnlySet<string> Modes { get; init; } = new HashSet<string>();
    public Func<WorldSnapshot, bool>? AdditionalCondition { get; init; }

    public void Validate()
    {
        if (string.IsNullOrWhiteSpace(Id) || Count <= 0 || !double.IsFinite(CooldownSeconds) || CooldownSeconds < 0 || !float.IsFinite(MinimumPressure) || !float.IsFinite(MaximumPressure) || !float.IsFinite(Probability) || MinimumPressure < 0 || MaximumPressure > 1 || MaximumPressure < MinimumPressure || Probability is < 0 or > 1) throw new ArgumentException($"Invalid encounter rule '{Id}'.");
    }
}

public interface IEncounterTimingPolicy
{
    double AdjustCooldown(EncounterRule rule, double proposedSeconds, WorldSnapshot world, TempoPhase tempo, float teamPressure);
}

public sealed class DefaultEncounterTimingPolicy : IEncounterTimingPolicy
{
    public double AdjustCooldown(EncounterRule rule, double proposedSeconds, WorldSnapshot world, TempoPhase tempo, float teamPressure) => proposedSeconds;
}

public sealed class ModeEncounterTimingPolicy : IEncounterTimingPolicy
{
    readonly IReadOnlyDictionary<string, double> _modeMultipliers;
    readonly double _highPressureMultiplier;
    public ModeEncounterTimingPolicy(IReadOnlyDictionary<string, double>? modeMultipliers = null, double highPressureMultiplier = 1)
    {
        _modeMultipliers = new Dictionary<string, double>(modeMultipliers ?? new Dictionary<string, double>(), StringComparer.Ordinal); if (_modeMultipliers.Any(x => string.IsNullOrWhiteSpace(x.Key) || !double.IsFinite(x.Value) || x.Value < 0) || !double.IsFinite(highPressureMultiplier) || highPressureMultiplier < 0) throw new ArgumentException("Cooldown multipliers must be finite and non-negative."); _highPressureMultiplier = highPressureMultiplier;
    }
    public double AdjustCooldown(EncounterRule rule, double proposedSeconds, WorldSnapshot world, TempoPhase tempo, float teamPressure)
    {
        var mode = _modeMultipliers.TryGetValue(world.ModeId, out var multiplier) ? multiplier : 1; var pressure = 1 + (_highPressureMultiplier - 1) * Math.Clamp(teamPressure, 0, 1); return proposedSeconds * mode * pressure;
    }
}

public sealed class RuleBasedEncounterSchedule : IEncounterSchedule, IPersistentEncounterSchedule, IEncounterScheduleFeedback
{
    readonly IReadOnlyList<EncounterRule> _rules;
    readonly IEncounterTimingPolicy _timing;
    readonly Dictionary<string, double> _nextTimes = new();
    EncounterRule? _selected;
    double _selectedPreviousTime;

    public RuleBasedEncounterSchedule(IEnumerable<EncounterRule> rules, IEncounterTimingPolicy? timing = null)
    {
        _rules = rules.OrderByDescending(x => x.Priority).ThenBy(x => x.Id, StringComparer.Ordinal).ToArray();
        if (_rules.Count == 0) throw new ArgumentException("At least one encounter rule is required.", nameof(rules));
        foreach (var rule in _rules) rule.Validate();
        if (_rules.Select(x => x.Id).Distinct(StringComparer.Ordinal).Count() != _rules.Count) throw new ArgumentException("Encounter rule ids must be unique.");
        _timing = timing ?? new DefaultEncounterTimingPolicy();
    }

    public EncounterKind? SelectEncounter(WorldSnapshot world, TempoPhase tempo, float pressure, PopulationLedger population, DeterministicRandom random)
    {
        _selected = null;
        foreach (var rule in _rules)
        {
            if (_nextTimes.TryGetValue(rule.Id, out var next) && world.Time < next) continue;
            if (!rule.Phases.Contains(tempo) || pressure < rule.MinimumPressure || pressure > rule.MaximumPressure) continue;
            if (rule.Modes.Count > 0 && !rule.Modes.Contains(world.ModeId)) continue;
            if (rule.AdditionalCondition is not null && !rule.AdditionalCondition(world)) continue;
            if (population.Available(rule.Kind, world.Population) <= 0) continue;
            var cooldown = _timing.AdjustCooldown(rule, rule.CooldownSeconds, world, tempo, pressure); if (!double.IsFinite(cooldown) || cooldown < 0) throw new InvalidOperationException("Encounter timing policy returned an invalid cooldown.");
            if (random.NextFloat() > rule.Probability) { _nextTimes[rule.Id] = world.Time + cooldown; continue; }
            _selected = rule; _selectedPreviousTime = _nextTimes.TryGetValue(rule.Id, out var previous) ? previous : double.NegativeInfinity; _nextTimes[rule.Id] = world.Time + cooldown; return rule.Kind;
        }
        return null;
    }

    public int RequestedCount(EncounterKind kind, WorldSnapshot world) => _selected?.Kind == kind ? _selected.Count : 1;
    public IReadOnlyDictionary<string, string> CaptureScheduleState() => _nextTimes.ToDictionary(x => x.Key, x => x.Value.ToString("R", CultureInfo.InvariantCulture));
    public void RestoreScheduleState(IReadOnlyDictionary<string, string> state)
    {
        _nextTimes.Clear(); foreach (var pair in state) if (double.TryParse(pair.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) _nextTimes[pair.Key] = value;
    }
    public void SelectionFinalized(bool requestCreated) { if (_selected is not null && !requestCreated) { if (double.IsNegativeInfinity(_selectedPreviousTime)) _nextTimes.Remove(_selected.Id); else _nextTimes[_selected.Id] = _selectedPreviousTime; } _selected = null; }
}

public sealed class MilestoneEncounterSchedule : IEncounterSchedule, IPersistentEncounterSchedule, IEncounterScheduleFeedback
{
    readonly string _id; readonly EncounterKind _kind; readonly float _minimumProgress; readonly float _maximumProgress; readonly int _count;
    readonly HashSet<string> _completedScopes = new(); string? _selectedScope;
    public MilestoneEncounterSchedule(string id, EncounterKind kind, float minimumProgress, float maximumProgress = 1f, int count = 1)
    {
        if (string.IsNullOrWhiteSpace(id) || minimumProgress < 0 || maximumProgress < minimumProgress || count <= 0) throw new ArgumentException("Invalid milestone schedule.");
        _id = id; _kind = kind; _minimumProgress = minimumProgress; _maximumProgress = maximumProgress; _count = count;
    }
    public EncounterKind? SelectEncounter(WorldSnapshot world, TempoPhase tempo, float teamPressure, PopulationLedger population, DeterministicRandom random)
    {
        var scope = string.IsNullOrWhiteSpace(world.RoundId) ? world.ModeId : world.ModeId + ":" + world.RoundId;
        if (_completedScopes.Contains(scope) || world.TeamProgress < _minimumProgress || world.TeamProgress > _maximumProgress || population.Available(_kind, world.Population) <= 0) return null;
        _selectedScope = scope; return _kind;
    }
    public int RequestedCount(EncounterKind kind, WorldSnapshot world) => kind == _kind && _selectedScope is not null ? _count : 1;
    public IReadOnlyDictionary<string, string> CaptureScheduleState() => new Dictionary<string, string> { ["id"] = _id, ["completed"] = string.Join("|", _completedScopes.OrderBy(x => x, StringComparer.Ordinal)) };
    public void RestoreScheduleState(IReadOnlyDictionary<string, string> state) { _completedScopes.Clear(); if (state.TryGetValue("completed", out var value)) foreach (var scope in value.Split('|', StringSplitOptions.RemoveEmptyEntries)) _completedScopes.Add(scope); }
    public void SelectionFinalized(bool requestCreated) { if (requestCreated && _selectedScope is not null) _completedScopes.Add(_selectedScope); _selectedScope = null; }
}

public sealed class CompositeEncounterSchedule : IEncounterSchedule, IPersistentEncounterSchedule, IEncounterScheduleFeedback
{
    readonly IReadOnlyList<IEncounterSchedule> _schedules; IEncounterSchedule? _selected;
    public CompositeEncounterSchedule(params IEncounterSchedule[] schedules) { _schedules = schedules; if (schedules.Length == 0) throw new ArgumentException("At least one schedule is required."); }
    public EncounterKind? SelectEncounter(WorldSnapshot world, TempoPhase tempo, float teamPressure, PopulationLedger population, DeterministicRandom random)
    {
        _selected = null; foreach (var schedule in _schedules) { var kind = schedule.SelectEncounter(world, tempo, teamPressure, population, random); if (kind is not null) { _selected = schedule; return kind; } }
        return null;
    }
    public int RequestedCount(EncounterKind kind, WorldSnapshot world) => _selected?.RequestedCount(kind, world) ?? 1;
    public IReadOnlyDictionary<string, string> CaptureScheduleState()
    {
        var output = new Dictionary<string, string>(); for (var i = 0; i < _schedules.Count; i++) if (_schedules[i] is IPersistentEncounterSchedule persistent) foreach (var pair in persistent.CaptureScheduleState()) output[$"{i}:{pair.Key}"] = pair.Value; return output;
    }
    public void RestoreScheduleState(IReadOnlyDictionary<string, string> state)
    {
        for (var i = 0; i < _schedules.Count; i++) if (_schedules[i] is IPersistentEncounterSchedule persistent) persistent.RestoreScheduleState(state.Where(x => x.Key.StartsWith(i + ":", StringComparison.Ordinal)).ToDictionary(x => x.Key[(x.Key.IndexOf(':') + 1)..], x => x.Value));
    }
    public void SelectionFinalized(bool requestCreated) { if (_selected is IEncounterScheduleFeedback feedback) feedback.SelectionFinalized(requestCreated); _selected = null; }
}