84 lines
3.3 KiB
C#
84 lines
3.3 KiB
C#
|
|
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();
|
||
|
|
}
|