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