using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Rules.Reputation;
///
/// Phase 6 M2 — combines the three reputation layers into a single
/// integer disposition score per reputation.md §I-4:
///
/// 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).
///
public static class EffectiveDisposition
{
///
/// Final blended score (clamped ±C.REP_MAX) for how
/// currently feels about
/// .
///
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;
}
///
/// 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.
///
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;
}
///
/// Phase 6.5 M5 — true if the NPC's
/// contains the "knows_hybrid" flag (set by
/// on a successful detection).
/// Falls back to the PC-side
/// list when the NPC has no personal-disposition record yet (which can
/// happen for casual encounters).
///
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 ────────────────────────────────────────
///
/// 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
/// AND a non-null 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 faction_affinity hints layer on top — a Covenant
/// Faithful amplifies their Enforcer alignment even if not formally
/// affiliated.
///
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;
}
}
/// Component view of an result.
public readonly record struct EffectiveDispositionBreakdown(
int CladeBias,
int SizeDifferential,
int FactionModifier,
int Personal,
int Total,
DispositionLabel Label);