210 lines
8.5 KiB
C#
210 lines
8.5 KiB
C#
|
|
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);
|