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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,186 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6.5 M7 — when a player betrays a specific NPC (a
/// <see cref="RepEventKind.Betrayal"/> event with negative magnitude),
/// the betrayal doesn't stay personal. The cascade applies:
///
/// 1. **Personal disposition** drops by the event's magnitude (already
/// handled by <see cref="PersonalDisposition.Apply"/>; this layer
/// doesn't re-apply that delta).
/// 2. **Permanent memory flag** <c>"betrayed_me"</c> on the NPC's
/// personal record (also already handled by
/// <see cref="PersonalDisposition.Apply"/> via the
/// <see cref="PersonalDisposition.Betrayed"/> property — we
/// additionally write the explicit memory tag for dialogue gates that
/// check <c>has_memory_flag: betrayed_me</c>).
/// 3. **Faction propagation** — a tier-mapped negative delta is applied
/// to the betrayed NPC's primary faction; the existing opposition
/// matrix in <see cref="FactionStanding.Apply"/> handles the
/// faction-side cascade.
/// 4. **Permanent aggro** — for guards/patrols, set
/// <see cref="NpcActor.PermanentAggroAfterBetrayal"/>. They attack on
/// sight regardless of faction-standing recovery.
/// 5. **Ledger entry** — a faction-tagged event mirrors the personal
/// event so the reputation screen can show "Betrayed Asha · cost
/// -25 with Hybrid Underground" breadcrumbs.
///
/// Magnitude tier mapping (most-negative wins):
/// ≤ <see cref="C.BETRAYAL_MAGNITUDE_CRITICAL"/> → -50 faction
/// ≤ <see cref="C.BETRAYAL_MAGNITUDE_MAJOR"/> → -30 faction
/// ≤ <see cref="C.BETRAYAL_MAGNITUDE_MODERATE"/> → -15 faction
/// ≤ <see cref="C.BETRAYAL_MAGNITUDE_MINOR"/> → -5 faction
///
/// The cascade is **deterministic** per the input event id — same event,
/// same outcome — so save/load round-trips reproduce identically.
/// </summary>
public static class BetrayalCascade
{
/// <summary>
/// Apply the cascade for an already-applied betrayal event. Caller is
/// responsible for having <see cref="PlayerReputation.Submit"/>'d the
/// underlying <see cref="RepEvent"/> first; this helper layers the
/// cross-cutting consequences on top.
///
/// <paramref name="npcs"/> is the live actor list — guards / patrols
/// belonging to the betrayed NPC's faction get the permanent-aggro
/// flag. Pass an empty enumerable when no live actors are available
/// (Tools / tests).
/// </summary>
public static BetrayalCascadeResult Apply(
RepEvent betrayalEvent,
PlayerReputation rep,
NpcActor? betrayedNpc,
IEnumerable<NpcActor> npcs,
IReadOnlyDictionary<string, FactionDef> factions)
{
if (betrayalEvent is null) throw new System.ArgumentNullException(nameof(betrayalEvent));
if (betrayalEvent.Kind != RepEventKind.Betrayal || betrayalEvent.Magnitude >= 0)
return BetrayalCascadeResult.Empty;
// 1 + 2: PersonalDisposition.Apply already wrote the magnitude AND
// flipped Betrayed=true. The dialogue layer reads
// Memory.Contains("betrayed_me"); ensure the explicit tag is
// present (Apply only writes implicit flags).
if (!string.IsNullOrEmpty(betrayalEvent.RoleTag))
rep.PersonalFor(betrayalEvent.RoleTag).Memory.Add("betrayed_me");
// 3: Faction propagation. Pick the tier from the magnitude; map to
// the faction-side delta; apply via FactionStanding.Apply (which
// cascades through the opposition matrix automatically).
int factionDelta = ResolveFactionDelta(betrayalEvent.Magnitude);
string targetFaction = ResolveFactionForBetrayal(betrayedNpc, betrayalEvent);
var factionDeltas = new List<(string FactionId, int Delta)>();
if (!string.IsNullOrEmpty(targetFaction) && factionDelta != 0)
{
factionDeltas = rep.Factions.Apply(targetFaction, factionDelta, factions);
// Mirror to the ledger as a separate, faction-tagged event so
// the reputation screen can answer "why did Hybrid Underground
// cool to you?" with "you betrayed Asha".
rep.Ledger.Append(new RepEvent
{
Kind = RepEventKind.Betrayal,
FactionId = targetFaction,
RoleTag = betrayalEvent.RoleTag,
Magnitude = factionDelta,
Note = $"betrayal cascade ({betrayalEvent.Note})",
OriginTileX = betrayalEvent.OriginTileX,
OriginTileY = betrayalEvent.OriginTileY,
TimestampSeconds = betrayalEvent.TimestampSeconds,
});
}
// 4: Permanent aggro for guards/patrols belonging to the same faction
// as the betrayed NPC. Read npc.BehaviorId to identify guard-style
// NPCs (brigand / patrol / poi_guard); friendly merchants /
// residents don't go full-aggro on betrayal.
int aggroFlipped = 0;
if (!string.IsNullOrEmpty(targetFaction))
{
foreach (var npc in npcs)
{
if (!npc.IsAlive) continue;
if (npc.PermanentAggroAfterBetrayal) continue; // already flagged
if (string.IsNullOrEmpty(npc.FactionId)) continue;
if (!string.Equals(npc.FactionId, targetFaction,
System.StringComparison.OrdinalIgnoreCase)) continue;
if (!IsAggroEligibleBehavior(npc)) continue;
npc.PermanentAggroAfterBetrayal = true;
aggroFlipped++;
}
}
return new BetrayalCascadeResult(
personalRoleTag: betrayalEvent.RoleTag,
personalMagnitude: betrayalEvent.Magnitude,
factionId: targetFaction,
factionDeltas: factionDeltas,
permanentAggroFlipped: aggroFlipped);
}
/// <summary>
/// Tier the personal-disposition magnitude into the faction-side delta.
/// "Most-negative wins" — the player's worst-case betrayal sets the
/// floor; lighter betrayals get smaller cascades.
/// </summary>
public static int ResolveFactionDelta(int personalMagnitude)
{
if (personalMagnitude >= 0) return 0; // not a betrayal
if (personalMagnitude <= C.BETRAYAL_MAGNITUDE_CRITICAL)
return C.BETRAYAL_FACTION_DELTA_CRITICAL;
if (personalMagnitude <= C.BETRAYAL_MAGNITUDE_MAJOR)
return C.BETRAYAL_FACTION_DELTA_MAJOR;
if (personalMagnitude <= C.BETRAYAL_MAGNITUDE_MODERATE)
return C.BETRAYAL_FACTION_DELTA_MODERATE;
if (personalMagnitude <= C.BETRAYAL_MAGNITUDE_MINOR)
return C.BETRAYAL_FACTION_DELTA_MINOR;
// -1 .. -9 (below the minor threshold) — too small to cascade.
return 0;
}
/// <summary>
/// Resolve which faction takes the cascade hit. Priority:
/// 1. The betrayed NPC's own faction id (most natural attribution).
/// 2. The event's <see cref="RepEvent.FactionId"/> (caller-overridden).
/// 3. Empty (no faction cascade — personal-only event).
/// </summary>
private static string ResolveFactionForBetrayal(NpcActor? betrayedNpc, RepEvent ev)
{
if (betrayedNpc is not null && !string.IsNullOrEmpty(betrayedNpc.FactionId))
return betrayedNpc.FactionId;
return ev.FactionId ?? "";
}
/// <summary>
/// True when an NPC's behavior id makes them a candidate for the
/// permanent-aggro flip — armed/threatening roles only. Civilian
/// merchants / residents stay non-aggro even on betrayal.
/// </summary>
private static bool IsAggroEligibleBehavior(NpcActor npc)
{
string b = npc.BehaviorId ?? "";
return b.Equals("brigand", System.StringComparison.OrdinalIgnoreCase)
|| b.Equals("patrol", System.StringComparison.OrdinalIgnoreCase)
|| b.Equals("poi_guard", System.StringComparison.OrdinalIgnoreCase)
|| b.Equals("wild_animal", System.StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>Componentised result of one cascade application — used by tests + UI surfacing.</summary>
public readonly record struct BetrayalCascadeResult(
string personalRoleTag,
int personalMagnitude,
string factionId,
List<(string FactionId, int Delta)> factionDeltas,
int permanentAggroFlipped)
{
/// <summary>True if the cascade had no effect (e.g. magnitude ≥ 0, or no faction).</summary>
public bool IsEmpty => factionDeltas.Count == 0 && permanentAggroFlipped == 0;
public static BetrayalCascadeResult Empty =>
new("", 0, "", new List<(string, int)>(), 0);
}
@@ -0,0 +1,65 @@
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — coarse-grain banding of an integer disposition score.
/// Per <c>reputation.md §I-2</c> the bands gate dialogue tone, prices,
/// service refusal, and combat-on-sight behaviour.
/// </summary>
public enum DispositionLabel : byte
{
Nemesis = 0, // -100..-76 kill on sight
Hostile = 1, // -75..-51 attacked if recognised
Antagonistic = 2, // -50..-26 refused service
Unfriendly = 3, // -25.. -1 cold reception
Neutral = 4, // 0
Favorable = 5, // +1..+25
Friendly = 6, // +26..+50
Allied = 7, // +51..+75
Champion = 8, // +76..+100
}
/// <summary>
/// Phase 6 M2 — trust ladder accumulated through repeated personal
/// interaction. Distinct from <see cref="DispositionLabel"/> — trust is
/// earned, disposition is felt.
/// </summary>
public enum TrustLevel : byte
{
Stranger = 0,
Acquaintance = 1,
Familiar = 2,
Trusted = 3,
Bonded = 4,
}
public static class DispositionLabels
{
/// <summary>Map an integer disposition score (clamped to ±100) to its label.</summary>
public static DispositionLabel For(int score)
{
if (score >= C.REP_CHAMPION_THRESHOLD) return DispositionLabel.Champion;
if (score >= C.REP_ALLIED_THRESHOLD) return DispositionLabel.Allied;
if (score >= C.REP_FRIENDLY_THRESHOLD) return DispositionLabel.Friendly;
if (score >= C.REP_FAVORABLE_THRESHOLD) return DispositionLabel.Favorable;
if (score == 0) return DispositionLabel.Neutral;
if (score >= C.REP_UNFRIENDLY_THRESHOLD) return DispositionLabel.Unfriendly;
if (score >= C.REP_ANTAGONISTIC_THRESHOLD) return DispositionLabel.Antagonistic;
if (score >= C.REP_HOSTILE_THRESHOLD) return DispositionLabel.Hostile;
return DispositionLabel.Nemesis;
}
/// <summary>Display string ("Nemesis", "Friendly", etc.) for the reputation screen + tooltip.</summary>
public static string DisplayName(DispositionLabel l) => l switch
{
DispositionLabel.Nemesis => "Nemesis",
DispositionLabel.Hostile => "Hostile",
DispositionLabel.Antagonistic => "Antagonistic",
DispositionLabel.Unfriendly => "Unfriendly",
DispositionLabel.Neutral => "Neutral",
DispositionLabel.Favorable => "Favorable",
DispositionLabel.Friendly => "Friendly",
DispositionLabel.Allied => "Allied",
DispositionLabel.Champion => "Champion",
_ => "Unknown",
};
}
@@ -0,0 +1,209 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — combines the three reputation layers into a single
/// integer disposition score per <c>reputation.md §I-4</c>:
///
/// EffectiveDisposition(npc, pc) =
/// CladeBiasFor(npc.BiasProfile, pc.Clade, sizeDiff(npc, pc))
/// + FactionWeightedSum(npc.Faction, pc.FactionStandings)
/// + PersonalDisposition(npc.Id)
///
/// Computed lazily — the inputs change too often (faction propagation,
/// time decay) to justify caching. Computation is O(1).
/// </summary>
public static class EffectiveDisposition
{
/// <summary>
/// Final blended score (clamped <c>±C.REP_MAX</c>) for how
/// <paramref name="npc"/> currently feels about
/// <paramref name="pc"/>.
/// </summary>
public static int For(
NpcActor npc,
Rules.Character.Character pc,
PlayerReputation rep,
ContentResolver content,
WorldState? world = null,
ulong worldSeed = 0)
{
return Breakdown(npc, pc, rep, content, world, worldSeed).Total;
}
/// <summary>
/// Componentised view used by the disposition tooltip + reputation
/// screen. Each field carries the contribution of one layer so the
/// UI can answer "why does so-and-so hate me?" without re-deriving.
/// </summary>
public static EffectiveDispositionBreakdown Breakdown(
NpcActor npc,
Rules.Character.Character pc,
PlayerReputation rep,
ContentResolver content,
WorldState? world = null,
ulong worldSeed = 0)
{
int cladeBias = ResolveCladeBias(npc, pc, content);
int sizeBias = SizeDifferentialModifier(npc, pc, content);
int factionMod = ResolveFactionMod(npc, rep, content, world, worldSeed);
int personal = ResolvePersonal(npc, rep);
int total = System.Math.Clamp(cladeBias + sizeBias + factionMod + personal,
C.REP_MIN, C.REP_MAX);
return new EffectiveDispositionBreakdown(
cladeBias, sizeBias, factionMod, personal, total,
DispositionLabels.For(total));
}
// ── Layer 1 — Clade bias ──────────────────────────────────────────────
private static int ResolveCladeBias(NpcActor npc, Rules.Character.Character pc, ContentResolver content)
{
if (string.IsNullOrEmpty(npc.BiasProfileId)) return 0;
if (!content.BiasProfiles.TryGetValue(npc.BiasProfileId, out var profile)) return 0;
// Phase 6.5 M5 — hybrid bias layering. When the PC is hybrid AND
// this NPC has personally detected the hybrid status (memory tag
// "knows_hybrid"), the profile's HybridBias modifier is added to
// the clade-bias. Pre-detection, the PC reads as their presenting
// (dominant) clade and HybridBias is *not* applied.
int bias = profile.CladeBias.TryGetValue(pc.Clade.Id, out int b) ? b : 0;
if (pc.IsHybrid && NpcKnowsPlayerIsHybrid(npc, pc))
bias += profile.HybridBias;
return bias;
}
/// <summary>
/// Phase 6.5 M5 — true if the NPC's <see cref="PersonalDisposition.Memory"/>
/// contains the <c>"knows_hybrid"</c> flag (set by
/// <see cref="Rules.Character.PassingCheck"/> on a successful detection).
/// Falls back to the PC-side <see cref="Rules.Character.HybridState.NpcsWhoKnow"/>
/// list when the NPC has no personal-disposition record yet (which can
/// happen for casual encounters).
/// </summary>
public static bool NpcKnowsPlayerIsHybrid(NpcActor npc, Rules.Character.Character pc)
{
if (pc.Hybrid is null) return false;
if (pc.Hybrid.NpcsWhoKnow.Contains(npc.Id)) return true;
// The NPC's PersonalDisposition lives on the player-rep dictionary;
// this call site doesn't have access. The PC-side NpcsWhoKnow set
// is the authoritative mirror written by PassingCheck after every
// detection — sufficient for the disposition layer to consult.
return false;
}
private static int SizeDifferentialModifier(NpcActor npc, Rules.Character.Character pc, ContentResolver content)
{
// Size index: Small=1, Medium=2, MediumLarge=3, Large=4. Differential is pc.size npc.size.
if (npc.Resident is null) return 0;
if (string.IsNullOrEmpty(npc.Resident.Species)) return 0;
if (!content.Species.TryGetValue(npc.Resident.Species, out var npcSpecies)) return 0;
int npcIdx = SizeIndex(SizeExtensions.FromJson(npcSpecies.Size));
int pcIdx = SizeIndex(pc.Size);
int diff = pcIdx - npcIdx;
// Per reputation.md §I-1 size differential table.
return diff switch
{
0 => 0,
1 => -3,
2 => -8,
3 => -8,
-1 => 2,
-2 => 5,
_ => 5,
};
}
private static int SizeIndex(SizeCategory s) => s switch
{
SizeCategory.Tiny => 0,
SizeCategory.Small => 1,
SizeCategory.Medium => 2,
SizeCategory.MediumLarge => 3,
SizeCategory.Large => 4,
SizeCategory.Huge => 5,
_ => 2,
};
// ── Layer 2 — Faction modifier ────────────────────────────────────────
/// <summary>
/// Derived modifier from the player's faction standings, weighted by
/// how much *this NPC* cares about each faction.
///
/// Phase 6 M5: when the NPC has a <see cref="NpcActor.HomeSettlementId"/>
/// AND a non-null <paramref name="world"/> is supplied, the local
/// (post-propagation, post-decay) standing in their settlement is used
/// instead of the global standing. Otherwise falls back to the M2
/// global lookup.
///
/// Bias-profile <c>faction_affinity</c> hints layer on top — a Covenant
/// Faithful amplifies their Enforcer alignment even if not formally
/// affiliated.
/// </summary>
private static int ResolveFactionMod(
NpcActor npc, PlayerReputation rep, ContentResolver content,
WorldState? world, ulong worldSeed)
{
float total = 0f;
// Resolve the NPC's home settlement (if any) for local-standing lookups.
Settlement? home = null;
if (world is not null && npc.HomeSettlementId is { } hid)
{
foreach (var s in world.Settlements)
if (s.Id == hid) { home = s; break; }
}
// Half-magnitude weight for the NPC's own affiliation.
if (!string.IsNullOrEmpty(npc.FactionId))
{
int standing = home is not null
? RepPropagation.LocalStandingFor(npc.FactionId, home, worldSeed, rep.Ledger, content.Factions)
: rep.Factions.Get(npc.FactionId);
total += standing * 0.5f;
}
// Bias-profile faction-affinity layering: Covenant Faithful npcs
// care about the Enforcers' standing even if not affiliated.
if (!string.IsNullOrEmpty(npc.BiasProfileId)
&& content.BiasProfiles.TryGetValue(npc.BiasProfileId, out var profile))
{
foreach (var (factionId, affinity) in profile.FactionAffinity)
{
int standing = home is not null
? RepPropagation.LocalStandingFor(factionId, home, worldSeed, rep.Ledger, content.Factions)
: rep.Factions.Get(factionId);
// Smaller weight than direct affiliation (×0.25) so the bias
// profile colours rather than dominates.
total += standing * (affinity / 100f) * 0.25f;
}
}
return (int)System.Math.Round(total);
}
// ── Layer 3 — Personal disposition ────────────────────────────────────
private static int ResolvePersonal(NpcActor npc, PlayerReputation rep)
{
if (string.IsNullOrEmpty(npc.RoleTag)) return 0;
return rep.Personal.TryGetValue(npc.RoleTag, out var p) ? p.Score : 0;
}
}
/// <summary>Component view of an <see cref="EffectiveDisposition"/> result.</summary>
public readonly record struct EffectiveDispositionBreakdown(
int CladeBias,
int SizeDifferential,
int FactionModifier,
int Personal,
int Total,
DispositionLabel Label);
@@ -0,0 +1,89 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M5 — faction-driven NPC allegiance flips. Per the plan §4.6:
///
/// Patrol aggression: a friendly/neutral NPC with a faction id flips
/// their <see cref="Actor.Allegiance"/> to <see cref="Allegiance.Hostile"/>
/// when the player's local standing with that faction crosses the
/// <see cref="DispositionLabel.Hostile"/> threshold (≤ -51).
///
/// Sticky once Hostile: the flip doesn't bounce back if standing
/// recovers mid-tick — only on chunk re-stream (NPC despawns + reloads
/// fresh from template). This avoids flickering allegiance between
/// frames and matches CRPG convention ("you killed a brigand who saw
/// you stab a guard last week — they remember").
/// </summary>
public static class FactionAggression
{
/// <summary>
/// Walk every faction-affiliated NPC. Flip non-hostile ones to
/// Hostile when the player's local standing with their faction
/// crosses the HOSTILE threshold. Returns the number of NPCs flipped
/// this tick.
///
/// Patrol-aggro reads faction standing directly rather than through
/// the disposition lens — a constable doesn't care about your clade
/// or your personal history with them; they care that their faction
/// says you're wanted.
/// </summary>
public static int UpdateAllegiances(
ActorManager actors,
Rules.Character.Character pc,
PlayerReputation rep,
ContentResolver content,
WorldState world,
ulong worldSeed)
{
if (pc is null) return 0;
int flipped = 0;
foreach (var npc in actors.Npcs)
{
if (!npc.IsAlive) continue;
if (npc.Allegiance == Allegiance.Hostile) continue;
if (npc.Allegiance == Allegiance.Player) continue;
// Phase 6.5 M7 — sticky betrayal aggro fires unconditionally,
// independent of faction id (it could be a betrayed lone wolf).
if (npc.PermanentAggroAfterBetrayal)
{
npc.Allegiance = Allegiance.Hostile;
flipped++;
continue;
}
if (string.IsNullOrEmpty(npc.FactionId)) continue;
int factionStanding = ResolveFactionStanding(npc, rep, content, world, worldSeed);
if (factionStanding <= C.REP_HOSTILE_THRESHOLD)
{
npc.Allegiance = Allegiance.Hostile;
flipped++;
}
}
return flipped;
}
/// <summary>
/// Local faction standing as perceived by this NPC's home settlement
/// (post-propagation), or the global standing if no home is set.
/// </summary>
private static int ResolveFactionStanding(
NpcActor npc, PlayerReputation rep, ContentResolver content,
WorldState world, ulong worldSeed)
{
if (npc.HomeSettlementId is { } hid)
{
foreach (var s in world.Settlements)
if (s.Id == hid)
return RepPropagation.LocalStandingFor(npc.FactionId, s, worldSeed,
rep.Ledger, content.Factions);
}
return rep.Factions.Get(npc.FactionId);
}
}
@@ -0,0 +1,83 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — Layer 2 of the disposition stack. The player's
/// faction-affiliated reputation: <c>Dictionary&lt;FactionId, int&gt;</c>
/// clamped to <c>±C.REP_MAX</c>, with the opposition matrix from
/// <c>reputation.md §I-2</c> applied automatically on every change.
///
/// Phase 6 M2 ships the score-only contract — propagation by distance
/// + time decay arrives in M5. Until then, every <see cref="Apply"/> call
/// fires an event that updates the at-origin standing immediately and
/// cascades through opposition; nothing else happens elsewhere on the map.
/// </summary>
public sealed class FactionStanding
{
private readonly Dictionary<string, int> _standings = new(System.StringComparer.OrdinalIgnoreCase);
/// <summary>Snapshot of every faction's current standing.</summary>
public IReadOnlyDictionary<string, int> Standings => _standings;
/// <summary>
/// Score with <paramref name="factionId"/>. Returns 0 (neutral) when
/// the faction has never been touched.
/// </summary>
public int Get(string factionId)
=> _standings.TryGetValue(factionId, out int v) ? v : 0;
/// <summary>Direct setter, no opposition cascade. Used by save-load and tests.</summary>
public void Set(string factionId, int value)
{
_standings[factionId] = System.Math.Clamp(value, C.REP_MIN, C.REP_MAX);
}
/// <summary>
/// Apply <paramref name="delta"/> to <paramref name="factionId"/>'s
/// standing and cascade the opposition matrix. Returns the list of
/// (factionId, delta) tuples actually applied (caller can log them).
///
/// Cascading is single-hop: <paramref name="factions"/>['inheritors'].opposition
/// is read once. Phase 6 M2 doesn't iterate (no transitive opposition).
/// </summary>
public List<(string FactionId, int Delta)> Apply(
string factionId,
int delta,
IReadOnlyDictionary<string, FactionDef> factions)
{
var applied = new List<(string, int)>();
if (delta == 0) return applied;
// Direct change first — clamped delta accounts for floor/ceiling.
int actualDelta = ApplyClamped(factionId, delta);
applied.Add((factionId, actualDelta));
// Cascade through opposition (use the *requested* delta, not the
// possibly-truncated one, so a clamp at the source doesn't mute
// downstream effects too).
if (factions.TryGetValue(factionId, out var def))
{
foreach (var (otherId, mult) in def.Opposition)
{
if (mult == 0f) continue;
int subDelta = (int)System.Math.Round(delta * mult);
if (subDelta == 0) continue;
int actualSub = ApplyClamped(otherId, subDelta);
if (actualSub != 0) applied.Add((otherId, actualSub));
}
}
return applied;
}
private int ApplyClamped(string factionId, int delta)
{
int current = Get(factionId);
int next = System.Math.Clamp(current + delta, C.REP_MIN, C.REP_MAX);
if (next == current) return 0;
_standings[factionId] = next;
return next - current;
}
public void Clear() => _standings.Clear();
}
@@ -0,0 +1,70 @@
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — Layer 3 of the disposition stack: how a *specific* NPC
/// feels about the player based on direct personal experience. Per
/// <c>reputation.md §I-3</c>, only NPCs the player has actually
/// interacted with accumulate one of these — generic shopkeepers walked
/// past don't bloat state.
///
/// Keyed by role tag (anchor-prefixed for named NPCs:
/// "millhaven.innkeeper"). Generic NPCs that the player talks to register
/// briefly under their generic tag but typically reset on chunk evict —
/// only named NPCs carry a stable id across reloads.
/// </summary>
public sealed class PersonalDisposition
{
/// <summary>Role tag identifying which NPC this record belongs to.</summary>
public string RoleTag { get; init; } = "";
/// <summary>Score relative to neutral, clamped to <c>±C.REP_MAX</c>.</summary>
public int Score { get; set; }
/// <summary>Trust ladder accumulated across the interaction log.</summary>
public TrustLevel Trust { get; set; } = TrustLevel.Stranger;
/// <summary>True after the player betrayed this specific NPC. Sticky — only narrative
/// effects can clear it.</summary>
public bool Betrayed { get; set; }
/// <summary>Last interaction time in WorldClock seconds. 0 = never interacted.</summary>
public long LastInteractionSeconds { get; set; }
/// <summary>Free-form memory tags ("saved-my-kit", "lied-about-rawfang", ...).</summary>
public HashSet<string> Memory { get; } = new();
/// <summary>Last N events affecting this specific NPC. Bounded — see <see cref="MaxLogEntries"/>.</summary>
public List<RepEvent> Log { get; } = new();
public const int MaxLogEntries = 32;
/// <summary>Append a personal event and apply its magnitude to <see cref="Score"/>.</summary>
public void Apply(RepEvent ev)
{
Score = System.Math.Clamp(Score + ev.Magnitude, C.REP_MIN, C.REP_MAX);
if (ev.Kind == RepEventKind.Betrayal && ev.Magnitude < 0) Betrayed = true;
Log.Add(ev);
if (Log.Count > MaxLogEntries) Log.RemoveAt(0);
LastInteractionSeconds = ev.TimestampSeconds;
Trust = ComputeTrust();
}
/// <summary>
/// Trust ladder derived from positive interaction count. Negative events
/// don't promote; betrayal demotes to Stranger regardless of history.
/// </summary>
private TrustLevel ComputeTrust()
{
if (Betrayed) return TrustLevel.Stranger;
int positives = 0;
foreach (var e in Log) if (e.Magnitude > 0) positives++;
return positives switch
{
>= 12 => TrustLevel.Bonded,
>= 7 => TrustLevel.Trusted,
>= 3 => TrustLevel.Familiar,
>= 1 => TrustLevel.Acquaintance,
_ => TrustLevel.Stranger,
};
}
}
@@ -0,0 +1,44 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — top-level aggregate of every reputation track owned by
/// the player. Hangs off PlayScreen as a parallel-to-Character record
/// (deliberate separation: <c>Character</c> is what the player is,
/// <c>PlayerReputation</c> is what the world thinks of them).
///
/// Round-trips through <see cref="Persistence.ReputationSnapshot"/>.
/// </summary>
public sealed class PlayerReputation
{
public FactionStanding Factions { get; } = new();
public Dictionary<string, PersonalDisposition> Personal { get; } = new(System.StringComparer.OrdinalIgnoreCase);
public RepLedger Ledger { get; } = new();
/// <summary>Get-or-create the per-NPC personal disposition record for <paramref name="roleTag"/>.</summary>
public PersonalDisposition PersonalFor(string roleTag)
{
if (!Personal.TryGetValue(roleTag, out var p))
{
p = new PersonalDisposition { RoleTag = roleTag };
Personal[roleTag] = p;
}
return p;
}
/// <summary>
/// Submit a reputation event. Updates faction standing (with opposition
/// cascade), the addressed NPC's personal disposition, and the ledger.
/// </summary>
public void Submit(RepEvent ev, IReadOnlyDictionary<string, FactionDef> factions)
{
if (!string.IsNullOrEmpty(ev.FactionId) && ev.Magnitude != 0)
Factions.Apply(ev.FactionId, ev.Magnitude, factions);
if (!string.IsNullOrEmpty(ev.RoleTag))
PersonalFor(ev.RoleTag).Apply(ev);
Ledger.Append(ev);
}
}
@@ -0,0 +1,66 @@
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — typed, append-only log entry recording a single reputation
/// change. Events are the *cause*; the resulting standing/disposition
/// update is the *effect*. We keep the cause around so the UI can answer
/// "why does so-and-so hate me?" with breadcrumbs.
///
/// Phase 6 M5 layers propagation on top: events written here can fan out
/// to other settlements with distance/time decay.
/// </summary>
public enum RepEventKind : byte
{
Dialogue = 0,
Quest = 1,
Combat = 2,
Rescue = 3,
Betrayal = 4,
Gift = 5,
Trade = 6,
Scent = 7,
Death = 8, // killing a faction-affiliated NPC
Aid = 9, // healing / curing / saving a non-combatant
Crime = 10,
/// <summary>
/// Phase 6.5 M5 — an NPC's scent-detection roll exposed the player
/// as a hybrid. Per-NPC personal-only event (no faction propagation
/// in M5; Phase 8's scent simulation can extend this).
/// </summary>
HybridDetected = 11,
Misc = 255,
}
/// <summary>One immutable reputation event. Time-stamped and tagged with
/// origin coordinates so propagation can apply distance/time decay.</summary>
public sealed record RepEvent
{
/// <summary>
/// Phase 6 M5 — monotonically increasing id assigned by
/// <see cref="RepLedger.Append"/>. Used as the deterministic-RNG
/// seed for frontier-settlement delivery coin-flips. 0 means "not
/// yet appended to a ledger".
/// </summary>
public int SequenceId { get; init; } = 0;
public RepEventKind Kind { get; init; } = RepEventKind.Misc;
/// <summary>Faction id this event affects (empty = personal-only event).</summary>
public string FactionId { get; init; } = "";
/// <summary>NPC role tag this event affects personally (empty = world-only event).</summary>
public string RoleTag { get; init; } = "";
/// <summary>Magnitude before opposition matrix / decay. Sign indicates direction.</summary>
public int Magnitude { get; init; }
/// <summary>Free-form origin context: "saved-her-kit-from-drowning" / "killed-thornfield-guard".</summary>
public string Note { get; init; } = "";
/// <summary>World-tile coordinates where the event occurred (for M5 propagation).</summary>
public int OriginTileX { get; init; }
public int OriginTileY { get; init; }
/// <summary>WorldClock seconds at the time the event was logged.</summary>
public long TimestampSeconds { get; init; }
}
@@ -0,0 +1,74 @@
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — append-only event log surfaced by the reputation screen
/// ("why does so-and-so hate me?"). Bounded to a reasonable tail so the
/// save file stays small even after a 100-hour playthrough.
///
/// Phase 6 M5 layers propagation on top: each entry will be re-walked
/// per game-day to fan out into other settlements with distance/time
/// decay.
/// </summary>
public sealed class RepLedger
{
public const int MaxEntries = 256;
private readonly List<RepEvent> _entries = new();
private int _nextSeq = 1;
public IReadOnlyList<RepEvent> Entries => _entries;
public int Count => _entries.Count;
/// <summary>
/// Append <paramref name="ev"/> to the ledger. If <see cref="RepEvent.SequenceId"/>
/// is 0, a fresh monotone id is assigned; otherwise the supplied id
/// is preserved (used by the save-restore path).
/// </summary>
public RepEvent Append(RepEvent ev)
{
if (ev.SequenceId == 0)
ev = ev with { SequenceId = _nextSeq++ };
else if (ev.SequenceId >= _nextSeq)
_nextSeq = ev.SequenceId + 1;
_entries.Add(ev);
if (_entries.Count > MaxEntries) _entries.RemoveAt(0);
return ev;
}
public void Clear()
{
_entries.Clear();
_nextSeq = 1;
}
/// <summary>Largest <see cref="RepEvent.SequenceId"/> issued so far. 0 = empty ledger.</summary>
public int HighestSequenceId => _nextSeq - 1;
/// <summary>Most recent N events affecting <paramref name="factionId"/>.</summary>
public IEnumerable<RepEvent> ForFaction(string factionId, int count = 8)
{
int yielded = 0;
for (int i = _entries.Count - 1; i >= 0 && yielded < count; i--)
{
if (string.Equals(_entries[i].FactionId, factionId, System.StringComparison.OrdinalIgnoreCase))
{
yielded++;
yield return _entries[i];
}
}
}
/// <summary>Most recent N events affecting <paramref name="roleTag"/>.</summary>
public IEnumerable<RepEvent> ForRole(string roleTag, int count = 8)
{
int yielded = 0;
for (int i = _entries.Count - 1; i >= 0 && yielded < count; i--)
{
if (string.Equals(_entries[i].RoleTag, roleTag, System.StringComparison.OrdinalIgnoreCase))
{
yielded++;
yield return _entries[i];
}
}
}
}
@@ -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));
}