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,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<FactionId, int></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));
|
||||
}
|
||||
Reference in New Issue
Block a user