using Theriapolis.Core.Data; using Theriapolis.Core.Util; using Theriapolis.Core.World; namespace Theriapolis.Core.Rules.Reputation; /// /// Phase 6 M5 — distance-banded reputation propagation per /// reputation.md §I-2. /// /// The model: every in the /// 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 /// (worldSeed, eventSequenceId, settlementId) 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. /// public static class RepPropagation { /// /// Faction standing as perceived in . /// Walks the ledger, applies distance decay + cascade. Clamped to /// ±C.REP_MAX. /// public static int LocalStandingFor( string factionId, Settlement settlement, ulong worldSeed, RepLedger ledger, IReadOnlyDictionary 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); } /// /// 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). /// public static int ContributionForFaction( string factionId, RepEvent ev, Settlement settlement, ulong worldSeed, IReadOnlyDictionary 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; } /// /// 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. /// public static IEnumerable<(RepEvent Event, int LocalDelta, DistanceBand Band)> ExplainLocalStanding( string factionId, Settlement settlement, ulong worldSeed, RepLedger ledger, IReadOnlyDictionary 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, }; /// /// Deterministic coin-flip per (worldSeed, eventSequenceId, settlementId). /// Returns true if the news of this event reaches the frontier /// settlement at all. /// 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)); }