Files
TheriapolisV3/Theriapolis.Core/Rules/Reputation/RepPropagation.cs
T
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

173 lines
6.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Theriapolis.Core.Data;
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M5 — distance-banded reputation propagation per
/// <c>reputation.md §I-2</c>.
///
/// The model: every <see cref="RepEvent"/> in the
/// <see cref="RepLedger"/> is *visible everywhere* (full magnitude at
/// origin, decayed by Chebyshev tile distance to other settlements,
/// frontier settlements may not receive at all). This module computes
/// per-settlement faction standing on demand by walking the ledger and
/// summing the decayed contributions plus opposition-matrix cascades.
///
/// Determinism: frontier coin-flips are keyed by
/// <c>(worldSeed, eventSequenceId, settlementId)</c> so the same news
/// arrives (or doesn't) the same way across save/load.
///
/// Complexity: O(events × settlements × factions) for a full sweep, but
/// per-NPC-disposition queries hit only the player's home settlement
/// and run in O(events × factions) — bounded ledger size keeps it cheap.
/// </summary>
public static class RepPropagation
{
/// <summary>
/// Faction standing as perceived in <paramref name="settlement"/>.
/// Walks the ledger, applies distance decay + cascade. Clamped to
/// <c>±C.REP_MAX</c>.
/// </summary>
public static int LocalStandingFor(
string factionId,
Settlement settlement,
ulong worldSeed,
RepLedger ledger,
IReadOnlyDictionary<string, FactionDef> factions)
{
if (string.IsNullOrEmpty(factionId)) return 0;
if (settlement is null) return 0;
if (ledger.Count == 0) return 0;
int total = 0;
foreach (var ev in ledger.Entries)
{
int delta = ContributionForFaction(factionId, ev, settlement, worldSeed, factions);
total += delta;
}
return System.Math.Clamp(total, C.REP_MIN, C.REP_MAX);
}
/// <summary>
/// Per-event contribution to a settlement's local standing for one
/// faction. Includes both direct events (event.FactionId == faction)
/// and cascade events (other factions whose opposition matrix names
/// this faction). Returns 0 when the event hasn't propagated to this
/// settlement (frontier coin-flip failure).
/// </summary>
public static int ContributionForFaction(
string factionId,
RepEvent ev,
Settlement settlement,
ulong worldSeed,
IReadOnlyDictionary<string, FactionDef> factions)
{
if (string.IsNullOrEmpty(ev.FactionId)) return 0;
int distTiles = ChebyshevDistance(ev.OriginTileX, ev.OriginTileY,
settlement.TileX, settlement.TileY);
bool isExtreme = System.Math.Abs(ev.Magnitude) >= C.REP_EXTREME_BYPASS_MAGNITUDE;
// Frontier band requires a per-(event, settlement) coin flip.
var band = BandFor(distTiles);
if (!isExtreme && band == DistanceBand.Frontier
&& !FrontierDelivered(worldSeed, ev.SequenceId, settlement.Id))
return 0;
int decayPct = isExtreme ? C.REP_DECAY_AT_ORIGIN_PCT : DecayPctFor(band);
int direct = 0;
if (string.Equals(ev.FactionId, factionId, System.StringComparison.OrdinalIgnoreCase))
direct = (int)System.Math.Round(ev.Magnitude * (decayPct / 100f));
int cascade = 0;
if (factions.TryGetValue(ev.FactionId, out var sourceDef)
&& sourceDef.Opposition.TryGetValue(factionId, out float mult)
&& mult != 0f)
{
cascade = (int)System.Math.Round(ev.Magnitude * mult * (decayPct / 100f));
}
return direct + cascade;
}
/// <summary>
/// Convenience: human-readable breakdown of *why* the local standing
/// looks the way it does. Used by the disposition tooltip and the
/// reputation screen's "recent events" tail.
/// </summary>
public static IEnumerable<(RepEvent Event, int LocalDelta, DistanceBand Band)>
ExplainLocalStanding(
string factionId,
Settlement settlement,
ulong worldSeed,
RepLedger ledger,
IReadOnlyDictionary<string, FactionDef> factions,
int max = 8)
{
if (string.IsNullOrEmpty(factionId) || settlement is null) yield break;
int yielded = 0;
// Most recent first.
for (int i = ledger.Entries.Count - 1; i >= 0 && yielded < max; i--)
{
var ev = ledger.Entries[i];
int delta = ContributionForFaction(factionId, ev, settlement, worldSeed, factions);
if (delta == 0) continue;
int distTiles = ChebyshevDistance(ev.OriginTileX, ev.OriginTileY,
settlement.TileX, settlement.TileY);
yield return (ev, delta, BandFor(distTiles));
yielded++;
}
}
public enum DistanceBand : byte
{
Origin = 0,
Adjacent = 1,
Regional = 2,
Continental = 3,
Frontier = 4,
}
public static DistanceBand BandFor(int chebyshevTiles)
{
if (chebyshevTiles == 0) return DistanceBand.Origin;
if (chebyshevTiles <= C.REP_ADJACENT_DIST_TILES) return DistanceBand.Adjacent;
if (chebyshevTiles <= C.REP_REGIONAL_DIST_TILES) return DistanceBand.Regional;
if (chebyshevTiles <= C.REP_CONTINENTAL_DIST_TILES) return DistanceBand.Continental;
return DistanceBand.Frontier;
}
public static int DecayPctFor(DistanceBand band) => band switch
{
DistanceBand.Origin => C.REP_DECAY_AT_ORIGIN_PCT,
DistanceBand.Adjacent => C.REP_DECAY_ADJACENT_PCT,
DistanceBand.Regional => C.REP_DECAY_REGIONAL_PCT,
DistanceBand.Continental => C.REP_DECAY_CONTINENTAL_PCT,
DistanceBand.Frontier => C.REP_DECAY_FRONTIER_PCT,
_ => 0,
};
/// <summary>
/// Deterministic coin-flip per <c>(worldSeed, eventSequenceId, settlementId)</c>.
/// Returns true if the news of this event reaches the frontier
/// settlement at all.
/// </summary>
public static bool FrontierDelivered(ulong worldSeed, int eventSequenceId, int settlementId)
{
// Mix the keys so seeds collide as rarely as possible.
ulong mix = unchecked(worldSeed
^ C.RNG_REP_PROPAGATION
^ ((ulong)(uint)eventSequenceId << 16)
^ ((ulong)(uint)settlementId << 40));
var rng = new SeededRng(mix);
int roll = (int)(rng.NextUInt64() % 100UL);
return roll < C.REP_FRONTIER_DELIVERY_PROB_PCT;
}
private static int ChebyshevDistance(int x1, int y1, int x2, int y2)
=> System.Math.Max(System.Math.Abs(x1 - x2), System.Math.Abs(y1 - y2));
}