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>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user