Files
TheriapolisV3/Theriapolis.Core/Rules/Reputation/RepPropagation.cs
T

173 lines
6.7 KiB
C#
Raw Normal View History

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));
}