b451f83174
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>
187 lines
8.8 KiB
C#
187 lines
8.8 KiB
C#
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);
|
|
}
|