using System; namespace ProjectI.Combat { public sealed class WeaponStateMachine { private const double CadenceTolerance = 0.02; private const double SwitchDuration = 0.15; private readonly ulong owner; private readonly WeaponTuning[] tuning; private readonly long[] ammunition = new long[2]; private readonly double[] reloadUntil = new double[2]; private readonly double[] nextShotAt = new double[2]; private readonly bool[] reloading = new bool[2]; private uint lastSequence; public int ActiveSlot { get; private set; } public long AmmoFirst => ammunition[0]; public uint CompletedReloads { get; private set; } public long AmmoSecond => ammunition[1]; public double ReloadUntilFirst => reloadUntil[0]; public double ReloadUntilSecond => reloadUntil[1]; public double ReadyAfterSwitch { get; private set; } public WeaponStateMachine(ulong owner, WeaponTuning first, WeaponTuning second) { this.owner = owner; tuning = new[] { first ?? throw new ArgumentNullException(nameof(first)), second ?? throw new ArgumentNullException(nameof(second)) }; Reset(0); } public bool TryFire(ulong sender, uint sequence, bool canAct, double now) { if (!Admit(sender, sequence, canAct, now)) return false; return FireAt(now, CadenceTolerance); } // Only the host's admitted held-input scheduler uses this path. Event time is cadence time, // never the rendering frame time; each accepted shell retains its own ammo/proc context. public bool TryFireScheduled(bool canAct, double eventTime) { if (!canAct || !IsFinite(eventTime) || eventTime < 0 || !tuning[ActiveSlot].Automatic) return false; return FireAt(eventTime, 0); } public double NextAutomaticShotAt(double earliest) { if (!IsFinite(earliest) || earliest < 0 || !tuning[ActiveSlot].Automatic) return double.PositiveInfinity; int slot = ActiveSlot; return Math.Max(earliest, Math.Max(ReadyAfterSwitch, Math.Max(nextShotAt[slot], reloading[slot] ? reloadUntil[slot] : 0))); } private bool FireAt(double now, double tolerance) { Tick(now); int slot = ActiveSlot; if (now < ReadyAfterSwitch || reloading[slot] || ammunition[slot] == 0 || now + tolerance < nextShotAt[slot]) return false; ammunition[slot]--; // Carry early allowance forward: repeated early requests cannot increase sustained fire rate. nextShotAt[slot] = Math.Max(now, nextShotAt[slot]) + tuning[slot].ShotInterval; if (ammunition[slot] == 0) TryBeginReload(slot, now); return true; } public bool TryReload(ulong sender, uint sequence, bool canAct, double now) { if (!Admit(sender, sequence, canAct, now)) return false; Tick(now); if (now < ReadyAfterSwitch) return false; return TryBeginReload(ActiveSlot, now); } private bool TryBeginReload(int slot, double now) { if (reloading[slot] || ammunition[slot] == tuning[slot].MagazineSize) return false; reloading[slot] = true; reloadUntil[slot] = now + tuning[slot].ReloadDuration; return true; } public bool TrySwitch(ulong sender, uint sequence, int slot, bool canAct, double now) { if (!Admit(sender, sequence, canAct, now)) return false; Tick(now); if (slot < 0 || slot > 1 || slot == ActiveSlot || now < ReadyAfterSwitch) return false; ActiveSlot = slot; ReadyAfterSwitch = now + SwitchDuration; return true; } public void Tick(double now) { if (!IsFinite(now)) return; for (int slot = 0; slot < 2; slot++) { if (!reloading[slot] || now < reloadUntil[slot]) continue; if (ammunition[slot] < tuning[slot].MagazineSize && CompletedReloads < uint.MaxValue) CompletedReloads++; ammunition[slot] = tuning[slot].MagazineSize; reloading[slot] = false; reloadUntil[slot] = 0; } } public void Reset(double now) { if (!IsFinite(now)) return; ActiveSlot = 0; ReadyAfterSwitch = now; for (int slot = 0; slot < 2; slot++) { ammunition[slot] = tuning[slot].MagazineSize; reloading[slot] = false; reloadUntil[slot] = 0; nextShotAt[slot] = now; } } public WeaponTuning GetTuning(int slot) { if (slot < 0 || slot > 1) throw new ArgumentOutOfRangeException(nameof(slot)); return tuning[slot]; } public void UpdateTuning(int slot, WeaponTuning value, double now) { if (slot < 0 || slot > 1) throw new ArgumentOutOfRangeException(nameof(slot)); if (value == null) throw new ArgumentNullException(nameof(value)); if (!IsFinite(now)) return; Tick(now); var previous = tuning[slot]; if (reloading[slot]) reloadUntil[slot] = now + Math.Max(0, (reloadUntil[slot] - now) / previous.ReloadDuration) * value.ReloadDuration; // Preserve committed cadence; a stat change cannot award another immediate shot. ammunition[slot] = Math.Min(ammunition[slot], value.MagazineSize); tuning[slot] = value; } private bool Admit(ulong sender, uint sequence, bool canAct, double now) { if (sender != owner || sequence == 0 || sequence <= lastSequence || !IsFinite(now)) return false; // Consume owner requests even when their gameplay preconditions fail, preventing delayed replay. lastSequence = sequence; return canAct; } private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); } }