using Theriapolis.Core.Util; namespace Theriapolis.Core.Rules.Combat; /// /// One combat encounter. Owns the participants, initiative order, current /// turn pointer, log, and a per-encounter seeded /// from worldSeed ^ C.RNG_COMBAT ^ encounterId. Save/load can resume /// mid-combat by capturing + /// and replaying the dice stream from the same /// sequence point — see . /// public sealed class Encounter { public ulong EncounterId { get; } public ulong EncounterSeed { get; } public IReadOnlyList Participants => _participants; public IReadOnlyList InitiativeOrder => _initiativeOrder; public int CurrentTurnIndex { get; private set; } public int RoundNumber { get; private set; } = 1; public Turn CurrentTurn { get; private set; } public IReadOnlyList Log => _log; public bool IsOver => _isOver; /// How many dice rolls have been drawn from this encounter's RNG. public int RollCount { get; private set; } private readonly List _participants; private readonly List _initiativeOrder; private readonly List _log = new(); private SeededRng _rng; private bool _isOver; public Encounter(ulong worldSeed, ulong encounterId, IEnumerable combatants) { EncounterId = encounterId; EncounterSeed = worldSeed ^ C.RNG_COMBAT ^ encounterId; _rng = new SeededRng(EncounterSeed); _participants = new List(combatants); if (_participants.Count == 0) throw new System.ArgumentException("Encounter requires at least one combatant.", nameof(combatants)); _initiativeOrder = RollInitiative(); CurrentTurnIndex = 0; CurrentTurn = Turn.FreshFor(CurrentActor.Id, CurrentActor.SpeedFt); AppendLog(CombatLogEntry.Kind.Initiative, FormatInitiativeOrder()); AppendLog(CombatLogEntry.Kind.TurnStart, $"Round 1 — {CurrentActor.Name}'s turn."); } public Combatant CurrentActor => _participants[_initiativeOrder[CurrentTurnIndex]]; public Combatant? GetById(int id) { foreach (var c in _participants) if (c.Id == id) return c; return null; } /// /// Advances to the next living combatant. Wraps the round counter when /// we cycle past the last initiative slot. Marks the encounter over if /// only one allegiance has living combatants. /// public void EndTurn() { if (_isOver) return; int n = _initiativeOrder.Count; for (int step = 0; step < n; step++) { CurrentTurnIndex++; if (CurrentTurnIndex >= n) { CurrentTurnIndex = 0; RoundNumber++; } var next = CurrentActor; if (next.IsAlive) { CurrentTurn = Turn.FreshFor(next.Id, next.SpeedFt); next.OnTurnStart(); // Phase 5 M6: reset per-turn feature flags (Sneak Attack) AppendLog(CombatLogEntry.Kind.TurnStart, $"Round {RoundNumber} — {next.Name}'s turn."); CheckForVictory(); return; } } // No one is alive. EndEncounter("No combatants remain."); } /// /// Returns true and ends the encounter if only one allegiance has /// living combatants left. Called automatically at end-of-turn. /// public bool CheckForVictory() { var living = new HashSet(); foreach (var c in _participants) if (c.IsAlive && !c.IsDown) living.Add(c.Allegiance); // Allies and Players count as the same side for victory purposes. bool playerSide = living.Contains(Rules.Character.Allegiance.Player) || living.Contains(Rules.Character.Allegiance.Allied); bool hostileSide = living.Contains(Rules.Character.Allegiance.Hostile); if (!playerSide || !hostileSide) { string verdict = playerSide ? "Player side wins." : (hostileSide ? "Hostile side wins." : "Mutual annihilation."); EndEncounter(verdict); return true; } return false; } private void EndEncounter(string verdict) { _isOver = true; AppendLog(CombatLogEntry.Kind.EncounterEnd, $"Encounter ends after {RoundNumber} round(s). {verdict}"); } // ── Dice ────────────────────────────────────────────────────────────── /// /// Draw a uniform integer in [1, sides]. Increments /// ; save/load uses that count to resume. /// public int RollDie(int sides) { if (sides < 1) return 0; RollCount++; return (int)(_rng.NextUInt64() % (ulong)sides) + 1; } public int RollD20() => RollDie(20); /// /// Roll d20 with advantage (best of two) or disadvantage (worst of two). /// Returns (kept, other) so the caller can log both. /// public (int kept, int other) RollD20WithMode(SituationFlags flags) { if (flags.RollsAdvantage()) { int a = RollD20(); int b = RollD20(); return a >= b ? (a, b) : (b, a); } if (flags.RollsDisadvantage()) { int a = RollD20(); int b = RollD20(); return a <= b ? (a, b) : (b, a); } return (RollD20(), -1); } /// /// Re-create the RNG and skip rolls. /// Used by the save layer to resume mid-combat encounters: capture /// (encounterId, rollCount) on save; recreate Encounter with same /// participants and call ResumeRolls(savedRollCount) on load. /// public void ResumeRolls(int rollCount) { _rng = new SeededRng(EncounterSeed); for (int i = 0; i < rollCount; i++) _rng.NextUInt64(); RollCount = rollCount; } // ── Logging ─────────────────────────────────────────────────────────── public void AppendLog(CombatLogEntry.Kind kind, string message) { _log.Add(new CombatLogEntry { Round = RoundNumber, Turn = CurrentTurnIndex, Type = kind, Message = message, }); } // ── Initiative ──────────────────────────────────────────────────────── private List RollInitiative() { var rolls = new (int idx, int total, int initBonus, int dexMod)[_participants.Count]; for (int i = 0; i < _participants.Count; i++) { var c = _participants[i]; int d20 = RollD20(); rolls[i] = (i, d20 + c.InitiativeBonus, c.InitiativeBonus, Stats.AbilityScores.Mod(c.Abilities.DEX)); } // Sort descending by total; ties broken by DEX mod descending; final tiebreaker by id ascending. System.Array.Sort(rolls, (a, b) => { int byTotal = b.total.CompareTo(a.total); if (byTotal != 0) return byTotal; int byDex = b.dexMod.CompareTo(a.dexMod); if (byDex != 0) return byDex; return _participants[a.idx].Id.CompareTo(_participants[b.idx].Id); }); var order = new List(rolls.Length); foreach (var r in rolls) order.Add(r.idx); return order; } private string FormatInitiativeOrder() { var parts = new List(_initiativeOrder.Count); foreach (int idx in _initiativeOrder) { var c = _participants[idx]; parts.Add($"{c.Name} (init+{c.InitiativeBonus})"); } return "Initiative: " + string.Join(", ", parts); } }