using Theriapolis.Core.Data; using Theriapolis.Core.Entities; namespace Theriapolis.Core.Rules.Reputation; /// /// Phase 6.5 M7 — when a player betrays a specific NPC (a /// 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 ; this layer /// doesn't re-apply that delta). /// 2. **Permanent memory flag** "betrayed_me" on the NPC's /// personal record (also already handled by /// via the /// property — we /// additionally write the explicit memory tag for dialogue gates that /// check has_memory_flag: betrayed_me). /// 3. **Faction propagation** — a tier-mapped negative delta is applied /// to the betrayed NPC's primary faction; the existing opposition /// matrix in handles the /// faction-side cascade. /// 4. **Permanent aggro** — for guards/patrols, set /// . 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): /// ≤ → -50 faction /// ≤ → -30 faction /// ≤ → -15 faction /// ≤ → -5 faction /// /// The cascade is **deterministic** per the input event id — same event, /// same outcome — so save/load round-trips reproduce identically. /// public static class BetrayalCascade { /// /// Apply the cascade for an already-applied betrayal event. Caller is /// responsible for having 'd the /// underlying first; this helper layers the /// cross-cutting consequences on top. /// /// 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). /// public static BetrayalCascadeResult Apply( RepEvent betrayalEvent, PlayerReputation rep, NpcActor? betrayedNpc, IEnumerable npcs, IReadOnlyDictionary 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); } /// /// 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. /// 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; } /// /// Resolve which faction takes the cascade hit. Priority: /// 1. The betrayed NPC's own faction id (most natural attribution). /// 2. The event's (caller-overridden). /// 3. Empty (no faction cascade — personal-only event). /// private static string ResolveFactionForBetrayal(NpcActor? betrayedNpc, RepEvent ev) { if (betrayedNpc is not null && !string.IsNullOrEmpty(betrayedNpc.FactionId)) return betrayedNpc.FactionId; return ev.FactionId ?? ""; } /// /// 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. /// 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); } } /// Componentised result of one cascade application — used by tests + UI surfacing. public readonly record struct BetrayalCascadeResult( string personalRoleTag, int personalMagnitude, string factionId, List<(string FactionId, int Delta)> factionDeltas, int permanentAggroFlipped) { /// True if the cascade had no effect (e.g. magnitude ≥ 0, or no faction). public bool IsEmpty => factionDeltas.Count == 0 && permanentAggroFlipped == 0; public static BetrayalCascadeResult Empty => new("", 0, "", new List<(string, int)>(), 0); }