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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user