b451f83174
Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
218 lines
8.2 KiB
C#
218 lines
8.2 KiB
C#
using Theriapolis.Core.Util;
|
|
|
|
namespace Theriapolis.Core.Rules.Combat;
|
|
|
|
/// <summary>
|
|
/// One combat encounter. Owns the participants, initiative order, current
|
|
/// turn pointer, log, and a per-encounter <see cref="SeededRng"/> seeded
|
|
/// from <c>worldSeed ^ C.RNG_COMBAT ^ encounterId</c>. Save/load can resume
|
|
/// mid-combat by capturing <see cref="EncounterSeed"/> +
|
|
/// <see cref="RollCount"/> and replaying the dice stream from the same
|
|
/// sequence point — see <see cref="ResumeRolls"/>.
|
|
/// </summary>
|
|
public sealed class Encounter
|
|
{
|
|
public ulong EncounterId { get; }
|
|
public ulong EncounterSeed { get; }
|
|
public IReadOnlyList<Combatant> Participants => _participants;
|
|
public IReadOnlyList<int> InitiativeOrder => _initiativeOrder;
|
|
public int CurrentTurnIndex { get; private set; }
|
|
public int RoundNumber { get; private set; } = 1;
|
|
public Turn CurrentTurn { get; private set; }
|
|
public IReadOnlyList<CombatLogEntry> Log => _log;
|
|
public bool IsOver => _isOver;
|
|
|
|
/// <summary>How many dice rolls have been drawn from this encounter's RNG.</summary>
|
|
public int RollCount { get; private set; }
|
|
|
|
private readonly List<Combatant> _participants;
|
|
private readonly List<int> _initiativeOrder;
|
|
private readonly List<CombatLogEntry> _log = new();
|
|
private SeededRng _rng;
|
|
private bool _isOver;
|
|
|
|
public Encounter(ulong worldSeed, ulong encounterId, IEnumerable<Combatant> combatants)
|
|
{
|
|
EncounterId = encounterId;
|
|
EncounterSeed = worldSeed ^ C.RNG_COMBAT ^ encounterId;
|
|
_rng = new SeededRng(EncounterSeed);
|
|
_participants = new List<Combatant>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true and ends the encounter if only one allegiance has
|
|
/// living combatants left. Called automatically at end-of-turn.
|
|
/// </summary>
|
|
public bool CheckForVictory()
|
|
{
|
|
var living = new HashSet<Rules.Character.Allegiance>();
|
|
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 ──────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Draw a uniform integer in [1, sides]. Increments
|
|
/// <see cref="RollCount"/>; save/load uses that count to resume.
|
|
/// </summary>
|
|
public int RollDie(int sides)
|
|
{
|
|
if (sides < 1) return 0;
|
|
RollCount++;
|
|
return (int)(_rng.NextUInt64() % (ulong)sides) + 1;
|
|
}
|
|
|
|
public int RollD20() => RollDie(20);
|
|
|
|
/// <summary>
|
|
/// Roll d20 with advantage (best of two) or disadvantage (worst of two).
|
|
/// Returns (kept, other) so the caller can log both.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-create the RNG and skip <paramref name="rollCount"/> 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.
|
|
/// </summary>
|
|
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<int> 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<int>(rolls.Length);
|
|
foreach (var r in rolls) order.Add(r.idx);
|
|
return order;
|
|
}
|
|
|
|
private string FormatInitiativeOrder()
|
|
{
|
|
var parts = new List<string>(_initiativeOrder.Count);
|
|
foreach (int idx in _initiativeOrder)
|
|
{
|
|
var c = _participants[idx];
|
|
parts.Add($"{c.Name} (init+{c.InitiativeBonus})");
|
|
}
|
|
return "Initiative: " + string.Join(", ", parts);
|
|
}
|
|
}
|