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
@@ -0,0 +1,83 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — Layer 2 of the disposition stack. The player's
/// faction-affiliated reputation: <c>Dictionary&lt;FactionId, int&gt;</c>
/// clamped to <c>±C.REP_MAX</c>, with the opposition matrix from
/// <c>reputation.md §I-2</c> applied automatically on every change.
///
/// Phase 6 M2 ships the score-only contract — propagation by distance
/// + time decay arrives in M5. Until then, every <see cref="Apply"/> call
/// fires an event that updates the at-origin standing immediately and
/// cascades through opposition; nothing else happens elsewhere on the map.
/// </summary>
public sealed class FactionStanding
{
private readonly Dictionary<string, int> _standings = new(System.StringComparer.OrdinalIgnoreCase);
/// <summary>Snapshot of every faction's current standing.</summary>
public IReadOnlyDictionary<string, int> Standings => _standings;
/// <summary>
/// Score with <paramref name="factionId"/>. Returns 0 (neutral) when
/// the faction has never been touched.
/// </summary>
public int Get(string factionId)
=> _standings.TryGetValue(factionId, out int v) ? v : 0;
/// <summary>Direct setter, no opposition cascade. Used by save-load and tests.</summary>
public void Set(string factionId, int value)
{
_standings[factionId] = System.Math.Clamp(value, C.REP_MIN, C.REP_MAX);
}
/// <summary>
/// Apply <paramref name="delta"/> to <paramref name="factionId"/>'s
/// standing and cascade the opposition matrix. Returns the list of
/// (factionId, delta) tuples actually applied (caller can log them).
///
/// Cascading is single-hop: <paramref name="factions"/>['inheritors'].opposition
/// is read once. Phase 6 M2 doesn't iterate (no transitive opposition).
/// </summary>
public List<(string FactionId, int Delta)> Apply(
string factionId,
int delta,
IReadOnlyDictionary<string, FactionDef> factions)
{
var applied = new List<(string, int)>();
if (delta == 0) return applied;
// Direct change first — clamped delta accounts for floor/ceiling.
int actualDelta = ApplyClamped(factionId, delta);
applied.Add((factionId, actualDelta));
// Cascade through opposition (use the *requested* delta, not the
// possibly-truncated one, so a clamp at the source doesn't mute
// downstream effects too).
if (factions.TryGetValue(factionId, out var def))
{
foreach (var (otherId, mult) in def.Opposition)
{
if (mult == 0f) continue;
int subDelta = (int)System.Math.Round(delta * mult);
if (subDelta == 0) continue;
int actualSub = ApplyClamped(otherId, subDelta);
if (actualSub != 0) applied.Add((otherId, actualSub));
}
}
return applied;
}
private int ApplyClamped(string factionId, int delta)
{
int current = Get(factionId);
int next = System.Math.Clamp(current + delta, C.REP_MIN, C.REP_MAX);
if (next == current) return 0;
_standings[factionId] = next;
return next - current;
}
public void Clear() => _standings.Clear();
}