Files
institution/Wiki/core/Modder-API.md
T
flan e96ac23949 Move AI attribution to a single NOTICE file
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.
2026-07-16 01:30:09 +00:00

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.