Per the updated project policy, AI/Claude attribution lives in exactly one NOTICE file per repo and nowhere else -- removed it from the About description, README, wiki, and roadmap.
10 KiB
Modder API — building on Core
Core is a library. It has no XML, no defs, no UI — its entire purpose is to be called, by the other
Institution mods and by yours. This page is the public surface, with the one seam that matters most
(Propensity.DeterrenceFactor) explained in full.
Everything here is verified against the three source files in Source/Core/.
The namespace is Contraband
Before anything else: all of Core's types live in namespace Contraband, not Core and not
Institution. This is historical — the engine was extracted from Institution: Contraband — and it is
not going to change under you, but it does mean your using looks slightly surprising:
using Contraband; // Propensity, CriminalRecord, GameComponent_CriminalRecords,
// SecuredKind, SecuredContext, SecuredContexts,
// TreatmentProgram, TreatableConditionExtension
Reference the assembly InstitutionCore.dll (packageId flan.institution.core) and load your mod
after Core.
The public surface at a glance
| Call | Returns | Creates state? | Use for |
|---|---|---|---|
Propensity.Nature(pawn) |
float 0..1 |
no | fixed trait-based disposition |
Propensity.Nurture(pawn) |
float (multiplier) |
no | live circumstance multiplier |
Propensity.Would(pawn, baseChance, salt, cap=0.85) |
bool |
no | the seeded yes/no for a behaviour |
Propensity.DeterrenceFactor |
Func<Pawn,float> field |
— | the seam a justice layer fills |
GameComponent_CriminalRecords.For(pawn) |
CriminalRecord |
yes (blank on first ask) | write paths |
GameComponent_CriminalRecords.PeekFor(pawn) |
CriminalRecord? |
no | read paths |
SecuredContexts.Of(pawn) |
SecuredContext? |
no | one pawn's hold kind |
SecuredContexts.OnMap(map) |
IEnumerable<SecuredContext> |
no | every held/free pawn on a map |
TreatmentProgram.HasTreatableCondition(pawn) |
bool |
no | is there a marked condition to work through? |
TreatmentProgram.ReduceConditions(pawn, strength) |
void |
mutates hediffs | run one session's worth of treatment |
TreatmentProgram.FacilityQuality(pawn) |
float 0.5..1.5 |
no | scale a session by the room's quality |
TreatmentProgram.AdvanceRecovery(pawn, def, amount) |
float |
yes (recovery hediff) | build a discharge / parole track |
All of these are static and null-safe. None throws on a null pawn; they return 0.05/1f/null/
false as appropriate. See the Propensity, Criminal Record,
and Secured Context pages for the mechanics behind each.
Asking "would this pawn?"
The idiomatic pattern for a new behaviour is: pick a base rate, pick a stable salt unique to
your question, and call Would. Do not re-implement the nature/nurture math — that is the whole point
of the shared engine.
using Contraband;
// A unique, STABLE salt for this specific question. Any constant int works; keep it
// distinct from other questions so the rolls stay independent per behaviour.
private const int Salt_WouldStealMeds = 0x5EED01;
if (Propensity.Would(pawn, baseChance: 0.08f, salt: Salt_WouldStealMeds))
{
// this pawn, under these conditions, is the kind who does this
}
Because the roll is seeded on pawn.thingIDNumber ^ salt, the answer is a stable fact per pawn per
question and survives save/reload — so you can ask it repeatedly and get a consistent character, and
different salts give you independent facts about the same pawn. Pass a custom cap if 0.85 is the
wrong ceiling for your behaviour (e.g. a lower cap for something that should always keep a large
chance of not happening).
If you only need the ingredients — say, to display a disposition readout or gate a threshold — read
Nature and Nurture directly. Institution: Gangs, for instance, joins a pawn to a gang when
Nature(pawn) * Nurture(pawn) clears its own threshold, computed from these two calls rather than
from Would.
Reading and writing records
Follow the one rule from the Criminal Record page — write with For, read
with PeekFor — so read paths never litter the save with blank records.
using Contraband;
// WRITE: an event happened, so we need a record to write to.
CriminalRecord rec = GameComponent_CriminalRecords.For(pawn);
rec.contrabandMade++;
// READ: peek, and tolerate null (the pawn may have no record yet).
CriminalRecord existing = GameComponent_CriminalRecords.PeekFor(pawn);
if (existing != null && existing.reform < 0f)
{
// this pawn has been hardened
}
Core owns the storage and persistence; you own the meaning of your own events. Do not keep your own per-pawn crime dictionary — that is exactly the fragmentation Core exists to prevent.
Working across hold kinds
Reduce a pawn to its context and branch on the predicate, not the label:
using Contraband;
SecuredContext? maybe = SecuredContexts.Of(pawn);
if (maybe is SecuredContext ctx && ctx.IsHeld)
{
// prisoner OR slave OR ward patient -- all covered, no special cases
}
// Sweep everyone the suite cares about on a map:
foreach (SecuredContext c in SecuredContexts.OnMap(map))
{
if (c.CanConceal) { /* consider hoarders too, not just the held */ }
}
Of returns null for anything the suite does not model (animals, mechs, the dead), so a null
check is your "not our business" branch.
Running a treatment programme
Core carries the maths of "sustained attention in a secured facility reduces a marked condition and
builds a recovery track toward discharge" — the shared engine behind Ward's psychiatric care and
Justice's rehabilitation. Mark a hediff treatable with TreatableConditionExtension, then drive it
from your own job or interaction:
using Contraband;
float strength = skill * TreatmentProgram.FacilityQuality(pawn) * share; // your own blend
TreatmentProgram.ReduceConditions(pawn, strength); // lower the condition
if (!TreatmentProgram.HasTreatableCondition(pawn))
{
TreatmentProgram.AdvanceRecovery(pawn, MyDefOf.Recovery, 0.15f * strength); // build discharge
}
You own the interaction mode, the job, the recovery hediff (give it a negative severityPerDay so it
decays without attention), and the discharge alert; Core owns only the numbers. Full formulas and the
consumer contract are on the Treatment Engine page.
The DeterrenceFactor seam
This is the most important extension point in Core, and the reason Core can remain a dependency-free leaf while a colony-wide deterrence feedback loop runs through it.
What it is
// In Propensity:
public static System.Func<Pawn, float> DeterrenceFactor = _ => 1f;
A single static delegate. Nurture multiplies its result by DeterrenceFactor(pawn) as its last
step. Out of the box the delegate returns 1f for every pawn — perfectly neutral — so Core alone
behaves as if deterrence did not exist.
What it is for
Deterrence is a property of the whole colony's climate of order: a well-policed colony should deter everyone a little; a lawless one should embolden them. That is a feedback loop — catching and punishing one pawn changes the disposition of the rest. But deterrence itself is a justice-layer concept, and Core must not know the justice layer exists.
The seam resolves the tension. Core defines where the colony's climate multiplies into disposition
(the last line of Nurture) without defining what that climate is. From the source:
"Deterrence lives in the Justice layer, so Core reads it through the seam above — neutral until Justice fills it. This one seam is what keeps Core a dependency-free leaf despite the deterrence feedback loop running through it."
How Institution: Justice fills it
When Institution: Justice loads, it assigns the delegate, pointing it at its own live deterrence
level. The shape it installs maps a 0..1 deterrence reading to a multiplier that straddles 1.0:
// Illustrative — this lives in Institution: Justice, not Core.
Propensity.DeterrenceFactor = pawn =>
{
float level = DeterrenceLevelFor(pawn.Map); // 0 = lawless, 1 = tightly policed
return Mathf.Lerp(1.3f, 0.7f, level); // lawless emboldens, order deters; neutral at 0.5
};
| Deterrence level | Factor | Effect on disposition |
|---|---|---|
0.0 (lawless) |
1.3 |
everyone ~30% more inclined |
0.5 (baseline) |
1.0 |
neutral — a default colony sits exactly here |
1.0 (tightly policed) |
0.7 |
everyone ~30% less inclined |
(The 1.3 → 0.7 range, neutral at the 0.5 baseline, are Justice's numbers, documented here for context;
Core only guarantees the neutral 1f default. Confirm against Institution: Justice's own source for
its current values.)
How to fill it yourself
If you are writing your own policing or morale layer and want it to feed disposition the same way, just assign the delegate — ideally at game load, and ideally composing with whatever is already there rather than clobbering it:
using Contraband;
using UnityEngine;
// Compose: fold your factor into any existing one (e.g. Justice's) instead of overwriting.
var previous = Propensity.DeterrenceFactor;
Propensity.DeterrenceFactor = pawn => previous(pawn) * MyClimateFactorFor(pawn);
Two cautions. First, it is a single static field — if two mods both assign it (rather than compose),
the last one wins, so compose when you can. Second, keep your factor bounded and centred near 1.0:
Nurture has no clamp on this term, so an extreme factor multiplies straight through into every
pawn's Would roll. Return 1f when you have nothing to say, exactly as the default does.
Why a delegate and not a dependency
The alternative — Core calling into Justice — would make Core depend on Justice, inverting the whole
topology and breaking Core's promise that it references only the base game. A single assignable
Func<Pawn,float>, neutral by default, lets the feedback loop run through Core while the arrow of
dependency still points only at Core. It is the smallest possible seam that buys the largest possible
decoupling, and it is why Core can sit at the bottom of the suite with zero dependencies of its own.
Part of the Institution suite.