Initial commit: Theriapolis baseline at port/godot branch point

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>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
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);
}
}