namespace TravelEar.Core; /// Why the launcher did or did not spawn the Helper this time. public enum SpawnDecision { /// Spawn it now. Returned at most once per . Spawn, /// Config SpawnHelper is off: the operator starts the Helper by hand. SkipDisabled, /// The Helper executable is not where HelperPath (or the default) points. SkipMissing, /// A Helper process already exists (started by hand or by an earlier game launch). SkipAlreadyRunning, /// This lifecycle already spawned once; it never respawns, whatever became of that process. SkipAlreadySpawned, } /// /// The Sink lifecycle policy (docs/DESIGN.md "Sink transport and Helper", REQ-SINK-LIFECYCLE): /// the mod spawns the Helper at most once per game launch and never respawns it, and the pipe /// server is re-armed no more often than every after a disconnect. /// Pure: the launcher and the pump feed it what they observe and act on what it decides, so the /// "never respawn" and "retry every 5 s" promises are unit-tested without a process or a pipe. /// A failed spawn is absorbed the same way as a successful one: recorded, never retried, and never /// thrown (docs/KNOWN-HAZARDS.md 2.1). /// public sealed class HelperLifecycle { /// Minimum spacing between two pipe re-arms after a disconnect. public const double RetryIntervalMs = 5000; private readonly double _retryIntervalMs; private bool _spawnDecided; private bool _spawnFailed; private double _lastArmMs = double.NegativeInfinity; private int _armAttempts; public HelperLifecycle(double retryIntervalMs = RetryIntervalMs) { if (retryIntervalMs < 0 || double.IsNaN(retryIntervalMs)) throw new ArgumentOutOfRangeException(nameof(retryIntervalMs), retryIntervalMs, "The retry interval must be zero or positive."); _retryIntervalMs = retryIntervalMs; } /// True once has returned . public bool HasSpawned => _spawnDecided; /// True when the one spawn attempt failed (). public bool SpawnFailed => _spawnFailed; /// How many times has scheduled a pipe arm. public int ArmAttempts => _armAttempts; // [impl->REQ-SINK-LIFECYCLE] /// /// Decides whether to spawn the Helper now. Only the first call that finds spawning enabled, /// the executable present, and no Helper running returns ; /// every later call returns , even after a /// failed spawn or a Helper that has since exited. The cheaper skips (disabled, missing, /// already running) do not consume the one spawn, so a Helper the operator closes by hand /// and a config the operator flips mid-session behave the same on the next game launch. /// public SpawnDecision DecideSpawn(bool spawnEnabled, bool helperPresent, bool alreadyRunning) { if (_spawnDecided) return SpawnDecision.SkipAlreadySpawned; if (!spawnEnabled) return SpawnDecision.SkipDisabled; if (!helperPresent) return SpawnDecision.SkipMissing; if (alreadyRunning) return SpawnDecision.SkipAlreadyRunning; _spawnDecided = true; return SpawnDecision.Spawn; } // [impl->REQ-HAZARD-NO-GAMEPLAY-IMPACT] /// Records that the one spawn attempt failed. Never throws; the spawn stays consumed. public void RecordSpawnFailure() { _spawnFailed = true; } // [impl->REQ-SINK-LIFECYCLE] /// /// Called by the pump right before it arms the pipe server. Returns how long to wait first so /// that consecutive arms are at least the retry interval apart, and books the arm at that /// future instant. The first arm and any arm after a quiet stretch wait zero. /// public double DelayBeforeArmMs(double nowMs) { var delay = Math.Max(0, _retryIntervalMs - (nowMs - _lastArmMs)); if (double.IsInfinity(delay) || double.IsNaN(delay)) delay = 0; _lastArmMs = nowMs + delay; _armAttempts++; return delay; } }