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,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<FactionId, int></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();
|
||||
}
|
||||
Reference in New Issue
Block a user