173 lines
6.7 KiB
C#
173 lines
6.7 KiB
C#
|
|
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));
|
|||
|
|
}
|